Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-11-23 21:10:02 +00:00
parent fee19ef336
commit abd24a801e
30 changed files with 506 additions and 301 deletions

View file

@ -1 +1 @@
9e5735cc1b202ce5e5657ad83eeeb7b037141e09 4e18794f846ad0d27bea3443caa2b51cd9afd722

View file

@ -26,26 +26,6 @@ export default class SourceEditor {
registerLanguages(...languages); registerLanguages(...languages);
} }
static pushToImportsArray(arr, toImport) {
arr.push(import(toImport));
}
static loadExtensions(extensions) {
if (!extensions) {
return Promise.resolve();
}
const promises = [];
const extensionsArray = typeof extensions === 'string' ? extensions.split(',') : extensions;
extensionsArray.forEach((ext) => {
const prefix = ext.includes('/') ? '' : 'editor/';
const trimmedExt = ext.replace(/^\//, '').trim();
SourceEditor.pushToImportsArray(promises, `~/${prefix}${trimmedExt}`);
});
return Promise.all(promises);
}
static mixIntoInstance(source, inst) { static mixIntoInstance(source, inst) {
if (!inst) { if (!inst) {
return; return;
@ -71,23 +51,6 @@ export default class SourceEditor {
}); });
} }
static manageDefaultExtensions(instance, el, extensions) {
SourceEditor.loadExtensions(extensions, instance)
.then((modules) => {
if (modules) {
modules.forEach((module) => {
instance.use(module.default);
});
}
})
.then(() => {
el.dispatchEvent(new Event(EDITOR_READY_EVENT));
})
.catch((e) => {
throw e;
});
}
static createEditorModel({ static createEditorModel({
blobPath, blobPath,
blobContent, blobContent,
@ -187,7 +150,6 @@ export default class SourceEditor {
blobContent = '', blobContent = '',
blobOriginalContent = '', blobOriginalContent = '',
blobGlobalId = uuids()[0], blobGlobalId = uuids()[0],
extensions = [],
isDiff = false, isDiff = false,
...instanceOptions ...instanceOptions
} = {}) { } = {}) {
@ -218,9 +180,8 @@ export default class SourceEditor {
SourceEditor.instanceDisposeModels(this, instance, model); SourceEditor.instanceDisposeModels(this, instance, model);
}); });
SourceEditor.manageDefaultExtensions(instance, el, extensions);
this.instances.push(instance); this.instances.push(instance);
el.dispatchEvent(new CustomEvent(EDITOR_READY_EVENT, { instance }));
return instance; return instance;
} }
@ -234,11 +195,4 @@ export default class SourceEditor {
dispose() { dispose() {
this.instances.forEach((instance) => instance.dispose()); this.instances.forEach((instance) => instance.dispose());
} }
use(exts) {
this.instances.forEach((inst) => {
inst.use(exts);
});
return this;
}
} }

View file

@ -73,9 +73,7 @@ export default class EditorInstance {
if (methodExtension) { if (methodExtension) {
const extension = extensionsStore.get(methodExtension); const extension = extensionsStore.get(methodExtension);
return (...args) => { return (...args) => extension.api[prop].call(seInstance, receiver, ...args);
return extension.api[prop].call(seInstance, ...args, receiver);
};
} }
return Reflect.get(seInstance[prop] ? seInstance : target, prop, receiver); return Reflect.get(seInstance[prop] ? seInstance : target, prop, receiver);
}, },

View file

@ -32,8 +32,8 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp
subject = value[:namespace] || value[:group] || value[:project] || value[:user] || value[:actor] subject = value[:namespace] || value[:group] || value[:project] || value[:user] || value[:actor]
return unless ExperimentSubject.valid_subject?(subject) return unless ExperimentSubject.valid_subject?(subject)
variant = :experimental if @variant_name != :control variant_name = :experimental if variant&.name != 'control'
Experiment.add_subject(name, variant: variant || :control, subject: subject) Experiment.add_subject(name, variant: variant_name || :control, subject: subject)
end end
def record! def record!

View file

@ -16,6 +16,8 @@ module Clusters
has_many :project_authorizations, class_name: 'Clusters::Agents::ProjectAuthorization' has_many :project_authorizations, class_name: 'Clusters::Agents::ProjectAuthorization'
has_many :authorized_projects, class_name: '::Project', through: :project_authorizations, source: :project has_many :authorized_projects, class_name: '::Project', through: :project_authorizations, source: :project
has_many :activity_events, -> { in_timeline_order }, class_name: 'Clusters::Agents::ActivityEvent', inverse_of: :agent
scope :ordered_by_name, -> { order(:name) } scope :ordered_by_name, -> { order(:name) }
scope :with_name, -> (name) { where(name: name) } scope :with_name, -> (name) { where(name: name) }

View file

@ -0,0 +1,34 @@
# frozen_string_literal: true
module Clusters
module Agents
class ActivityEvent < ApplicationRecord
include NullifyIfBlank
self.table_name = 'agent_activity_events'
belongs_to :agent, class_name: 'Clusters::Agent', optional: false
belongs_to :user
belongs_to :agent_token, class_name: 'Clusters::AgentToken'
scope :in_timeline_order, -> { order(recorded_at: :desc, id: :desc) }
validates :recorded_at, :kind, :level, presence: true
nullify_if_blank :detail
enum kind: {
token_created: 0
}, _prefix: true
enum level: {
debug: 0,
info: 1,
warn: 2,
error: 3,
fatal: 4,
unknown: 5
}, _prefix: true
end
end
end

View file

@ -11,6 +11,8 @@ module Clusters
token = ::Clusters::AgentToken.new(filtered_params.merge(created_by_user: current_user)) token = ::Clusters::AgentToken.new(filtered_params.merge(created_by_user: current_user))
if token.save if token.save
log_activity_event!(token)
ServiceResponse.success(payload: { secret: token.token, token: token }) ServiceResponse.success(payload: { secret: token.token, token: token })
else else
ServiceResponse.error(message: token.errors.full_messages) ServiceResponse.error(message: token.errors.full_messages)
@ -26,6 +28,16 @@ module Clusters
def filtered_params def filtered_params
params.slice(*ALLOWED_PARAMS) params.slice(*ALLOWED_PARAMS)
end end
def log_activity_event!(token)
token.agent.activity_events.create!(
kind: :token_created,
level: :info,
recorded_at: token.created_at,
user: current_user,
agent_token: token
)
end
end end
end end
end end

View file

@ -2,8 +2,7 @@
- @hide_breadcrumbs = true - @hide_breadcrumbs = true
- @no_container = true - @no_container = true
- page_title user_display_name(@user) - page_title user_display_name(@user)
- page_description @user.bio - page_description @user.bio unless @user.blocked? || !@user.confirmed?
- header_title @user.name, user_path(@user)
- page_itemtype 'http://schema.org/Person' - page_itemtype 'http://schema.org/Person'
- link_classes = "flex-grow-1 mx-1 " - link_classes = "flex-grow-1 mx-1 "

View file

@ -1,8 +1,8 @@
--- ---
name: ci_require_credit_card_on_free_plan name: ci_require_credit_card_on_free_plan
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61152 introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61152
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/330104 rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/330104
milestone: '13.12' milestone: '13.12'
type: development type: development
group: group::pipeline execution group: group::fulfillment
default_enabled: false default_enabled: false

View file

@ -1,8 +1,8 @@
--- ---
name: ci_require_credit_card_on_trial_plan name: ci_require_credit_card_on_trial_plan
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61152 introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61152
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/330105 rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/330105
milestone: '13.12' milestone: '13.12'
type: development type: development
group: group::pipeline execution group: group::fulfillment
default_enabled: false default_enabled: false

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
class CreateAgentActivityEvents < Gitlab::Database::Migration[1.0]
def change
create_table :agent_activity_events do |t|
t.bigint :agent_id, null: false
t.bigint :user_id, index: { where: 'user_id IS NOT NULL' }
t.bigint :project_id, index: { where: 'project_id IS NOT NULL' }
t.bigint :merge_request_id, index: { where: 'merge_request_id IS NOT NULL' }
t.bigint :agent_token_id, index: { where: 'agent_token_id IS NOT NULL' }
t.datetime_with_timezone :recorded_at, null: false
t.integer :kind, limit: 2, null: false
t.integer :level, limit: 2, null: false
t.binary :sha
t.text :detail, limit: 255
t.index [:agent_id, :recorded_at, :id]
end
end
end

View file

@ -0,0 +1,35 @@
# frozen_string_literal: true
class AddAgentActivityEventsForeignKeys < Gitlab::Database::Migration[1.0]
disable_ddl_transaction!
def up
add_concurrent_foreign_key :agent_activity_events, :cluster_agents, column: :agent_id, on_delete: :cascade
add_concurrent_foreign_key :agent_activity_events, :users, column: :user_id, on_delete: :nullify
add_concurrent_foreign_key :agent_activity_events, :projects, column: :project_id, on_delete: :nullify
add_concurrent_foreign_key :agent_activity_events, :merge_requests, column: :merge_request_id, on_delete: :nullify
add_concurrent_foreign_key :agent_activity_events, :cluster_agent_tokens, column: :agent_token_id, on_delete: :nullify
end
def down
with_lock_retries do
remove_foreign_key_if_exists :agent_activity_events, column: :agent_id
end
with_lock_retries do
remove_foreign_key_if_exists :agent_activity_events, column: :user_id
end
with_lock_retries do
remove_foreign_key_if_exists :agent_activity_events, column: :project_id
end
with_lock_retries do
remove_foreign_key_if_exists :agent_activity_events, column: :merge_request_id
end
with_lock_retries do
remove_foreign_key_if_exists :agent_activity_events, column: :agent_token_id
end
end
end

View file

@ -0,0 +1 @@
1c5f65a25c9cf81a50bd9ffa2e74e2621cff04e58a2f90b19c66741ebb459d3e

View file

@ -0,0 +1 @@
4038c269ce9c47ca9327fb1b81bb588e9065f0821f291d17c7965d7f8fe1f275

View file

@ -9721,6 +9721,30 @@ CREATE SEQUENCE abuse_reports_id_seq
ALTER SEQUENCE abuse_reports_id_seq OWNED BY abuse_reports.id; ALTER SEQUENCE abuse_reports_id_seq OWNED BY abuse_reports.id;
CREATE TABLE agent_activity_events (
id bigint NOT NULL,
agent_id bigint NOT NULL,
user_id bigint,
project_id bigint,
merge_request_id bigint,
agent_token_id bigint,
recorded_at timestamp with time zone NOT NULL,
kind smallint NOT NULL,
level smallint NOT NULL,
sha bytea,
detail text,
CONSTRAINT check_068205e735 CHECK ((char_length(detail) <= 255))
);
CREATE SEQUENCE agent_activity_events_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE agent_activity_events_id_seq OWNED BY agent_activity_events.id;
CREATE TABLE agent_group_authorizations ( CREATE TABLE agent_group_authorizations (
id bigint NOT NULL, id bigint NOT NULL,
group_id bigint NOT NULL, group_id bigint NOT NULL,
@ -21064,6 +21088,8 @@ ALTER SEQUENCE zoom_meetings_id_seq OWNED BY zoom_meetings.id;
ALTER TABLE ONLY abuse_reports ALTER COLUMN id SET DEFAULT nextval('abuse_reports_id_seq'::regclass); ALTER TABLE ONLY abuse_reports ALTER COLUMN id SET DEFAULT nextval('abuse_reports_id_seq'::regclass);
ALTER TABLE ONLY agent_activity_events ALTER COLUMN id SET DEFAULT nextval('agent_activity_events_id_seq'::regclass);
ALTER TABLE ONLY agent_group_authorizations ALTER COLUMN id SET DEFAULT nextval('agent_group_authorizations_id_seq'::regclass); ALTER TABLE ONLY agent_group_authorizations ALTER COLUMN id SET DEFAULT nextval('agent_group_authorizations_id_seq'::regclass);
ALTER TABLE ONLY agent_project_authorizations ALTER COLUMN id SET DEFAULT nextval('agent_project_authorizations_id_seq'::regclass); ALTER TABLE ONLY agent_project_authorizations ALTER COLUMN id SET DEFAULT nextval('agent_project_authorizations_id_seq'::regclass);
@ -22412,6 +22438,9 @@ ALTER TABLE ONLY gitlab_partitions_static.product_analytics_events_experimental_
ALTER TABLE ONLY abuse_reports ALTER TABLE ONLY abuse_reports
ADD CONSTRAINT abuse_reports_pkey PRIMARY KEY (id); ADD CONSTRAINT abuse_reports_pkey PRIMARY KEY (id);
ALTER TABLE ONLY agent_activity_events
ADD CONSTRAINT agent_activity_events_pkey PRIMARY KEY (id);
ALTER TABLE ONLY agent_group_authorizations ALTER TABLE ONLY agent_group_authorizations
ADD CONSTRAINT agent_group_authorizations_pkey PRIMARY KEY (id); ADD CONSTRAINT agent_group_authorizations_pkey PRIMARY KEY (id);
@ -24907,6 +24936,16 @@ CREATE UNIQUE INDEX idx_vulnerability_issue_links_on_vulnerability_id_and_link_t
CREATE INDEX index_abuse_reports_on_user_id ON abuse_reports USING btree (user_id); CREATE INDEX index_abuse_reports_on_user_id ON abuse_reports USING btree (user_id);
CREATE INDEX index_agent_activity_events_on_agent_id_and_recorded_at_and_id ON agent_activity_events USING btree (agent_id, recorded_at, id);
CREATE INDEX index_agent_activity_events_on_agent_token_id ON agent_activity_events USING btree (agent_token_id) WHERE (agent_token_id IS NOT NULL);
CREATE INDEX index_agent_activity_events_on_merge_request_id ON agent_activity_events USING btree (merge_request_id) WHERE (merge_request_id IS NOT NULL);
CREATE INDEX index_agent_activity_events_on_project_id ON agent_activity_events USING btree (project_id) WHERE (project_id IS NOT NULL);
CREATE INDEX index_agent_activity_events_on_user_id ON agent_activity_events USING btree (user_id) WHERE (user_id IS NOT NULL);
CREATE UNIQUE INDEX index_agent_group_authorizations_on_agent_id_and_group_id ON agent_group_authorizations USING btree (agent_id, group_id); CREATE UNIQUE INDEX index_agent_group_authorizations_on_agent_id_and_group_id ON agent_group_authorizations USING btree (agent_id, group_id);
CREATE INDEX index_agent_group_authorizations_on_group_id ON agent_group_authorizations USING btree (group_id); CREATE INDEX index_agent_group_authorizations_on_group_id ON agent_group_authorizations USING btree (group_id);
@ -28803,6 +28842,9 @@ ALTER TABLE ONLY import_failures
ALTER TABLE ONLY project_ci_cd_settings ALTER TABLE ONLY project_ci_cd_settings
ADD CONSTRAINT fk_24c15d2f2e FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; ADD CONSTRAINT fk_24c15d2f2e FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY agent_activity_events
ADD CONSTRAINT fk_256c631779 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL;
ALTER TABLE ONLY epics ALTER TABLE ONLY epics
ADD CONSTRAINT fk_25b99c1be3 FOREIGN KEY (parent_id) REFERENCES epics(id) ON DELETE CASCADE; ADD CONSTRAINT fk_25b99c1be3 FOREIGN KEY (parent_id) REFERENCES epics(id) ON DELETE CASCADE;
@ -28875,6 +28917,9 @@ ALTER TABLE ONLY bulk_import_exports
ALTER TABLE ONLY ci_builds ALTER TABLE ONLY ci_builds
ADD CONSTRAINT fk_3a9eaa254d FOREIGN KEY (stage_id) REFERENCES ci_stages(id) ON DELETE CASCADE; ADD CONSTRAINT fk_3a9eaa254d FOREIGN KEY (stage_id) REFERENCES ci_stages(id) ON DELETE CASCADE;
ALTER TABLE ONLY agent_activity_events
ADD CONSTRAINT fk_3af186389b FOREIGN KEY (merge_request_id) REFERENCES merge_requests(id) ON DELETE SET NULL;
ALTER TABLE ONLY issues ALTER TABLE ONLY issues
ADD CONSTRAINT fk_3b8c72ea56 FOREIGN KEY (sprint_id) REFERENCES sprints(id) ON DELETE SET NULL; ADD CONSTRAINT fk_3b8c72ea56 FOREIGN KEY (sprint_id) REFERENCES sprints(id) ON DELETE SET NULL;
@ -29319,6 +29364,12 @@ ALTER TABLE ONLY geo_event_log
ALTER TABLE ONLY issues ALTER TABLE ONLY issues
ADD CONSTRAINT fk_c63cbf6c25 FOREIGN KEY (closed_by_id) REFERENCES users(id) ON DELETE SET NULL; ADD CONSTRAINT fk_c63cbf6c25 FOREIGN KEY (closed_by_id) REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE ONLY agent_activity_events
ADD CONSTRAINT fk_c815368376 FOREIGN KEY (agent_id) REFERENCES cluster_agents(id) ON DELETE CASCADE;
ALTER TABLE ONLY agent_activity_events
ADD CONSTRAINT fk_c8b006d40f FOREIGN KEY (agent_token_id) REFERENCES cluster_agent_tokens(id) ON DELETE SET NULL;
ALTER TABLE ONLY issue_links ALTER TABLE ONLY issue_links
ADD CONSTRAINT fk_c900194ff2 FOREIGN KEY (source_id) REFERENCES issues(id) ON DELETE CASCADE; ADD CONSTRAINT fk_c900194ff2 FOREIGN KEY (source_id) REFERENCES issues(id) ON DELETE CASCADE;
@ -29373,6 +29424,9 @@ ALTER TABLE ONLY geo_event_log
ALTER TABLE ONLY lists ALTER TABLE ONLY lists
ADD CONSTRAINT fk_d6cf4279f7 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ADD CONSTRAINT fk_d6cf4279f7 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY agent_activity_events
ADD CONSTRAINT fk_d6f785c9fc FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE ONLY metrics_users_starred_dashboards ALTER TABLE ONLY metrics_users_starred_dashboards
ADD CONSTRAINT fk_d76a2b9a8c FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; ADD CONSTRAINT fk_d76a2b9a8c FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;

View file

@ -65,7 +65,8 @@ Example response:
"web_url": "http://192.168.1.8:3000/root", "web_url": "http://192.168.1.8:3000/root",
"expires_at": "2012-10-22T14:13:35Z", "expires_at": "2012-10-22T14:13:35Z",
"access_level": 30, "access_level": 30,
"group_saml_identity": null "group_saml_identity": null,
"membership_state": "active"
}, },
{ {
"id": 2, "id": 2,
@ -81,7 +82,8 @@ Example response:
"extern_uid":"ABC-1234567890", "extern_uid":"ABC-1234567890",
"provider": "group_saml", "provider": "group_saml",
"saml_provider_id": 10 "saml_provider_id": 10
} },
"membership_state": "active"
} }
] ]
``` ```
@ -126,7 +128,8 @@ Example response:
"web_url": "http://192.168.1.8:3000/root", "web_url": "http://192.168.1.8:3000/root",
"expires_at": "2012-10-22T14:13:35Z", "expires_at": "2012-10-22T14:13:35Z",
"access_level": 30, "access_level": 30,
"group_saml_identity": null "group_saml_identity": null,
"membership_state": "active"
}, },
{ {
"id": 2, "id": 2,
@ -142,7 +145,8 @@ Example response:
"extern_uid":"ABC-1234567890", "extern_uid":"ABC-1234567890",
"provider": "group_saml", "provider": "group_saml",
"saml_provider_id": 10 "saml_provider_id": 10
} },
"membership_state": "active"
}, },
{ {
"id": 3, "id": 3,
@ -153,7 +157,8 @@ Example response:
"web_url": "http://192.168.1.8:3000/root", "web_url": "http://192.168.1.8:3000/root",
"expires_at": "2012-11-22T14:13:35Z", "expires_at": "2012-11-22T14:13:35Z",
"access_level": 30, "access_level": 30,
"group_saml_identity": null "group_saml_identity": null,
"membership_state": "active"
} }
] ]
``` ```
@ -191,7 +196,8 @@ Example response:
"email": "john@example.com", "email": "john@example.com",
"created_at": "2012-10-22T14:13:35Z", "created_at": "2012-10-22T14:13:35Z",
"expires_at": null, "expires_at": null,
"group_saml_identity": null "group_saml_identity": null,
"membership_state": "active"
} }
``` ```
@ -229,7 +235,8 @@ Example response:
"access_level": 30, "access_level": 30,
"email": "john@example.com", "email": "john@example.com",
"expires_at": null, "expires_at": null,
"group_saml_identity": null "group_saml_identity": null,
"membership_state": "active"
} }
``` ```

View file

@ -30,9 +30,6 @@ The squashed commit in this example is followed by a merge commit, because the m
The squashed commit's default commit message is taken from the merge request title. The squashed commit's default commit message is taken from the merge request title.
NOTE:
This only takes effect if there are at least 2 commits. As there is nothing to squash, the commit message does not change if there is only 1 commit.
It can be customized before merging a merge request. It can be customized before merging a merge request.
![A squash commit message editor](img/squash_mr_message.png) ![A squash commit message editor](img/squash_mr_message.png)

View file

@ -1,4 +1,5 @@
abuse_reports: :gitlab_main abuse_reports: :gitlab_main
agent_activity_events: :gitlab_main
agent_group_authorizations: :gitlab_main agent_group_authorizations: :gitlab_main
agent_project_authorizations: :gitlab_main agent_project_authorizations: :gitlab_main
alert_management_alert_assignees: :gitlab_main alert_management_alert_assignees: :gitlab_main

View file

@ -38540,9 +38540,15 @@ msgstr ""
msgid "VulnerabilityManagement|Something went wrong, could not update vulnerability state." msgid "VulnerabilityManagement|Something went wrong, could not update vulnerability state."
msgstr "" msgstr ""
msgid "VulnerabilityManagement|Summary, detailed description, steps to reproduce, etc."
msgstr ""
msgid "VulnerabilityManagement|Verified as fixed or mitigated" msgid "VulnerabilityManagement|Verified as fixed or mitigated"
msgstr "" msgstr ""
msgid "VulnerabilityManagement|Vulnerability name or type. Ex: Cross-site scripting"
msgstr ""
msgid "VulnerabilityManagement|Will not fix or a false-positive" msgid "VulnerabilityManagement|Will not fix or a false-positive"
msgstr "" msgstr ""

View file

@ -3,7 +3,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe ApplicationExperiment, :experiment do RSpec.describe ApplicationExperiment, :experiment do
subject { described_class.new('namespaced/stub', **context) } subject(:application_experiment) { described_class.new('namespaced/stub', **context) }
let(:context) { {} } let(:context) { {} }
let(:feature_definition) { { name: 'namespaced_stub', type: 'experiment', default_enabled: false } } let(:feature_definition) { { name: 'namespaced_stub', type: 'experiment', default_enabled: false } }
@ -15,7 +15,7 @@ RSpec.describe ApplicationExperiment, :experiment do
end end
before do before do
allow(subject).to receive(:enabled?).and_return(true) allow(application_experiment).to receive(:enabled?).and_return(true)
end end
it "doesn't raise an exception without a defined control" do it "doesn't raise an exception without a defined control" do
@ -26,7 +26,7 @@ RSpec.describe ApplicationExperiment, :experiment do
describe "#enabled?" do describe "#enabled?" do
before do before do
allow(subject).to receive(:enabled?).and_call_original allow(application_experiment).to receive(:enabled?).and_call_original
allow(Feature::Definition).to receive(:get).and_return('_instance_') allow(Feature::Definition).to receive(:get).and_return('_instance_')
allow(Gitlab).to receive(:dev_env_or_com?).and_return(true) allow(Gitlab).to receive(:dev_env_or_com?).and_return(true)
@ -34,25 +34,25 @@ RSpec.describe ApplicationExperiment, :experiment do
end end
it "is enabled when all criteria are met" do it "is enabled when all criteria are met" do
expect(subject).to be_enabled expect(application_experiment).to be_enabled
end end
it "isn't enabled if the feature definition doesn't exist" do it "isn't enabled if the feature definition doesn't exist" do
expect(Feature::Definition).to receive(:get).with('namespaced_stub').and_return(nil) expect(Feature::Definition).to receive(:get).with('namespaced_stub').and_return(nil)
expect(subject).not_to be_enabled expect(application_experiment).not_to be_enabled
end end
it "isn't enabled if we're not in dev or dotcom environments" do it "isn't enabled if we're not in dev or dotcom environments" do
expect(Gitlab).to receive(:dev_env_or_com?).and_return(false) expect(Gitlab).to receive(:dev_env_or_com?).and_return(false)
expect(subject).not_to be_enabled expect(application_experiment).not_to be_enabled
end end
it "isn't enabled if the feature flag state is :off" do it "isn't enabled if the feature flag state is :off" do
expect(Feature).to receive(:get).with('namespaced_stub').and_return(double(state: :off)) expect(Feature).to receive(:get).with('namespaced_stub').and_return(double(state: :off))
expect(subject).not_to be_enabled expect(application_experiment).not_to be_enabled
end end
end end
@ -60,11 +60,11 @@ RSpec.describe ApplicationExperiment, :experiment do
let(:should_track) { true } let(:should_track) { true }
before do before do
allow(subject).to receive(:should_track?).and_return(should_track) allow(application_experiment).to receive(:should_track?).and_return(should_track)
end end
it "tracks the assignment", :snowplow do it "tracks the assignment", :snowplow do
subject.publish application_experiment.publish
expect_snowplow_event( expect_snowplow_event(
category: 'namespaced/stub', category: 'namespaced/stub',
@ -74,24 +74,24 @@ RSpec.describe ApplicationExperiment, :experiment do
end end
it "publishes to the client" do it "publishes to the client" do
expect(subject).to receive(:publish_to_client) expect(application_experiment).to receive(:publish_to_client)
subject.publish application_experiment.publish
end end
it "publishes to the database if we've opted for that" do it "publishes to the database if we've opted for that" do
subject.record! application_experiment.record!
expect(subject).to receive(:publish_to_database) expect(application_experiment).to receive(:publish_to_database)
subject.publish application_experiment.publish
end end
context 'when we should not track' do context 'when we should not track' do
let(:should_track) { false } let(:should_track) { false }
it 'does not track an event to Snowplow', :snowplow do it 'does not track an event to Snowplow', :snowplow do
subject.publish application_experiment.publish
expect_no_snowplow_event expect_no_snowplow_event
end end
@ -102,13 +102,13 @@ RSpec.describe ApplicationExperiment, :experiment do
signature = { key: '86208ac54ca798e11f127e8b23ec396a', variant: 'control' } signature = { key: '86208ac54ca798e11f127e8b23ec396a', variant: 'control' }
expect(Gon).to receive(:push).with({ experiment: { 'namespaced/stub' => hash_including(signature) } }, true) expect(Gon).to receive(:push).with({ experiment: { 'namespaced/stub' => hash_including(signature) } }, true)
subject.publish_to_client application_experiment.publish_to_client
end end
it "handles when Gon raises exceptions (like when it can't be pushed into)" do it "handles when Gon raises exceptions (like when it can't be pushed into)" do
expect(Gon).to receive(:push).and_raise(NoMethodError) expect(Gon).to receive(:push).and_raise(NoMethodError)
expect { subject.publish_to_client }.not_to raise_error expect { application_experiment.publish_to_client }.not_to raise_error
end end
context 'when we should not track' do context 'when we should not track' do
@ -117,7 +117,7 @@ RSpec.describe ApplicationExperiment, :experiment do
it 'returns early' do it 'returns early' do
expect(Gon).not_to receive(:push) expect(Gon).not_to receive(:push)
subject.publish_to_client application_experiment.publish_to_client
end end
end end
end end
@ -125,13 +125,15 @@ RSpec.describe ApplicationExperiment, :experiment do
describe '#publish_to_database' do describe '#publish_to_database' do
using RSpec::Parameterized::TableSyntax using RSpec::Parameterized::TableSyntax
let(:publish_to_database) { application_experiment.publish_to_database }
shared_examples 'does not record to the database' do shared_examples 'does not record to the database' do
it 'does not create an experiment record' do it 'does not create an experiment record' do
expect { subject.publish_to_database }.not_to change(Experiment, :count) expect { publish_to_database }.not_to change(Experiment, :count)
end end
it 'does not create an experiment subject record' do it 'does not create an experiment subject record' do
expect { subject.publish_to_database }.not_to change(ExperimentSubject, :count) expect { publish_to_database }.not_to change(ExperimentSubject, :count)
end end
end end
@ -139,16 +141,16 @@ RSpec.describe ApplicationExperiment, :experiment do
let(:context) { { context_key => context_value } } let(:context) { { context_key => context_value } }
where(:context_key, :context_value, :object_type) do where(:context_key, :context_value, :object_type) do
:namespace | build(:namespace) | :namespace :namespace | build(:namespace, id: non_existing_record_id) | :namespace
:group | build(:namespace) | :namespace :group | build(:namespace, id: non_existing_record_id) | :namespace
:project | build(:project) | :project :project | build(:project, id: non_existing_record_id) | :project
:user | build(:user) | :user :user | build(:user, id: non_existing_record_id) | :user
:actor | build(:user) | :user :actor | build(:user, id: non_existing_record_id) | :user
end end
with_them do with_them do
it 'creates an experiment and experiment subject record' do it 'creates an experiment and experiment subject record' do
expect { subject.publish_to_database }.to change(Experiment, :count).by(1) expect { publish_to_database }.to change(Experiment, :count).by(1)
expect(Experiment.last.name).to eq('namespaced/stub') expect(Experiment.last.name).to eq('namespaced/stub')
expect(ExperimentSubject.last.send(object_type)).to eq(context[context_key]) expect(ExperimentSubject.last.send(object_type)).to eq(context[context_key])
@ -156,6 +158,16 @@ RSpec.describe ApplicationExperiment, :experiment do
end end
end end
context "when experiment hasn't ran" do
let(:context) { { user: create(:user) } }
it 'sets a variant on the experiment subject' do
publish_to_database
expect(ExperimentSubject.last.variant).to eq('control')
end
end
context 'when there is not a usable subject' do context 'when there is not a usable subject' do
let(:context) { { context_key => context_value } } let(:context) { { context_key => context_value } }
@ -183,15 +195,15 @@ RSpec.describe ApplicationExperiment, :experiment do
end end
it "doesn't track if we shouldn't track" do it "doesn't track if we shouldn't track" do
allow(subject).to receive(:should_track?).and_return(false) allow(application_experiment).to receive(:should_track?).and_return(false)
subject.track(:action) application_experiment.track(:action)
expect_no_snowplow_event expect_no_snowplow_event
end end
it "tracks the event with the expected arguments and merged contexts" do it "tracks the event with the expected arguments and merged contexts" do
subject.track(:action, property: '_property_', context: [fake_context]) application_experiment.track(:action, property: '_property_', context: [fake_context])
expect_snowplow_event( expect_snowplow_event(
category: 'namespaced/stub', category: 'namespaced/stub',
@ -233,7 +245,7 @@ RSpec.describe ApplicationExperiment, :experiment do
describe "#key_for" do describe "#key_for" do
it "generates MD5 hashes" do it "generates MD5 hashes" do
expect(subject.key_for(foo: :bar)).to eq('6f9ac12afdb9b58c2f19a136d09f9153') expect(application_experiment.key_for(foo: :bar)).to eq('6f9ac12afdb9b58c2f19a136d09f9153')
end end
end end
@ -256,26 +268,26 @@ RSpec.describe ApplicationExperiment, :experiment do
with_them do with_them do
it "returns the url or nil if invalid" do it "returns the url or nil if invalid" do
allow(Gitlab).to receive(:dev_env_or_com?).and_return(true) allow(Gitlab).to receive(:dev_env_or_com?).and_return(true)
expect(subject.process_redirect_url(url)).to eq(processed_url) expect(application_experiment.process_redirect_url(url)).to eq(processed_url)
end end
it "considers all urls invalid when not on dev or com" do it "considers all urls invalid when not on dev or com" do
allow(Gitlab).to receive(:dev_env_or_com?).and_return(false) allow(Gitlab).to receive(:dev_env_or_com?).and_return(false)
expect(subject.process_redirect_url(url)).to be_nil expect(application_experiment.process_redirect_url(url)).to be_nil
end end
end end
it "generates the correct urls based on where the engine was mounted" do it "generates the correct urls based on where the engine was mounted" do
url = Rails.application.routes.url_helpers.experiment_redirect_url(subject, url: 'https://docs.gitlab.com') url = Rails.application.routes.url_helpers.experiment_redirect_url(application_experiment, url: 'https://docs.gitlab.com')
expect(url).to include("/-/experiment/namespaced%2Fstub:#{subject.context.key}?https://docs.gitlab.com") expect(url).to include("/-/experiment/namespaced%2Fstub:#{application_experiment.context.key}?https://docs.gitlab.com")
end end
end end
context "when resolving variants" do context "when resolving variants" do
it "uses the default value as specified in the yaml" do it "uses the default value as specified in the yaml" do
expect(Feature).to receive(:enabled?).with('namespaced_stub', subject, type: :experiment, default_enabled: :yaml) expect(Feature).to receive(:enabled?).with('namespaced_stub', application_experiment, type: :experiment, default_enabled: :yaml)
expect(subject.variant.name).to eq('control') expect(application_experiment.variant.name).to eq('control')
end end
context "when rolled out to 100%" do context "when rolled out to 100%" do
@ -284,10 +296,10 @@ RSpec.describe ApplicationExperiment, :experiment do
end end
it "returns the first variant name" do it "returns the first variant name" do
subject.try(:variant1) {} application_experiment.try(:variant1) {}
subject.try(:variant2) {} application_experiment.try(:variant2) {}
expect(subject.variant.name).to eq('variant1') expect(application_experiment.variant.name).to eq('variant1')
end end
end end
end end
@ -298,18 +310,18 @@ RSpec.describe ApplicationExperiment, :experiment do
before do before do
allow(Gitlab::Experiment::Configuration).to receive(:cache).and_call_original allow(Gitlab::Experiment::Configuration).to receive(:cache).and_call_original
cache.clear(key: subject.name) cache.clear(key: application_experiment.name)
subject.use { } # setup the control application_experiment.use { } # setup the control
subject.try { } # setup the candidate application_experiment.try { } # setup the candidate
end end
it "caches the variant determined by the variant resolver" do it "caches the variant determined by the variant resolver" do
expect(subject.variant.name).to eq('candidate') # we should be in the experiment expect(application_experiment.variant.name).to eq('candidate') # we should be in the experiment
subject.run application_experiment.run
expect(subject.cache.read).to eq('candidate') expect(application_experiment.cache.read).to eq('candidate')
end end
it "doesn't cache a variant if we don't explicitly provide one" do it "doesn't cache a variant if we don't explicitly provide one" do
@ -320,11 +332,11 @@ RSpec.describe ApplicationExperiment, :experiment do
# the control. # the control.
stub_feature_flags(namespaced_stub: false) # simulate being not rolled out stub_feature_flags(namespaced_stub: false) # simulate being not rolled out
expect(subject.variant.name).to eq('control') # if we ask, it should be control expect(application_experiment.variant.name).to eq('control') # if we ask, it should be control
subject.run application_experiment.run
expect(subject.cache.read).to be_nil expect(application_experiment.cache.read).to be_nil
end end
it "caches a control variant if we assign it specifically" do it "caches a control variant if we assign it specifically" do
@ -332,27 +344,27 @@ RSpec.describe ApplicationExperiment, :experiment do
# that this context will always get the control variant unless we delete # that this context will always get the control variant unless we delete
# the field from the cache (or clear the entire experiment cache) -- or # the field from the cache (or clear the entire experiment cache) -- or
# write code that would specify a different variant. # write code that would specify a different variant.
subject.run(:control) application_experiment.run(:control)
expect(subject.cache.read).to eq('control') expect(application_experiment.cache.read).to eq('control')
end end
context "arbitrary attributes" do context "arbitrary attributes" do
before do before do
subject.cache.store.clear(key: subject.name + '_attrs') application_experiment.cache.store.clear(key: application_experiment.name + '_attrs')
end end
it "sets and gets attributes about an experiment" do it "sets and gets attributes about an experiment" do
subject.cache.attr_set(:foo, :bar) application_experiment.cache.attr_set(:foo, :bar)
expect(subject.cache.attr_get(:foo)).to eq('bar') expect(application_experiment.cache.attr_get(:foo)).to eq('bar')
end end
it "increments a value for an experiment" do it "increments a value for an experiment" do
expect(subject.cache.attr_get(:foo)).to be_nil expect(application_experiment.cache.attr_get(:foo)).to be_nil
expect(subject.cache.attr_inc(:foo)).to eq(1) expect(application_experiment.cache.attr_inc(:foo)).to eq(1)
expect(subject.cache.attr_inc(:foo)).to eq(2) expect(application_experiment.cache.attr_inc(:foo)).to eq(2)
end end
end end
end end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
FactoryBot.define do
factory :agent_activity_event, class: 'Clusters::Agents::ActivityEvent' do
association :agent, factory: :cluster_agent
association :agent_token, factory: :cluster_agent_token
user
kind { :token_created }
level { :info }
recorded_at { Time.current }
end
end

View file

@ -207,34 +207,31 @@ RSpec.describe 'User page' do
state: :blocked, state: :blocked,
organization: 'GitLab - work info test', organization: 'GitLab - work info test',
job_title: 'Frontend Engineer', job_title: 'Frontend Engineer',
pronunciation: 'pruh-nuhn-see-ay-shn' pronunciation: 'pruh-nuhn-see-ay-shn',
bio: 'My personal bio'
) )
end end
let_it_be(:status) { create(:user_status, user: user, message: "Working hard!") } let_it_be(:status) { create(:user_status, user: user, message: "Working hard!") }
it 'shows no tab' do before do
subject visit_profile
end
it 'shows no tab' do
expect(page).to have_css("div.profile-header") expect(page).to have_css("div.profile-header")
expect(page).not_to have_css("ul.nav-links") expect(page).not_to have_css("ul.nav-links")
end end
it 'shows blocked message' do it 'shows blocked message' do
subject
expect(page).to have_content("This user is blocked") expect(page).to have_content("This user is blocked")
end end
it 'shows user name as blocked' do it 'shows user name as blocked' do
subject
expect(page).to have_css(".cover-title", text: 'Blocked user') expect(page).to have_css(".cover-title", text: 'Blocked user')
end end
it 'shows no additional fields' do it 'shows no additional fields' do
subject
expect(page).not_to have_css(".profile-user-bio") expect(page).not_to have_css(".profile-user-bio")
expect(page).not_to have_content('GitLab - work info test') expect(page).not_to have_content('GitLab - work info test')
expect(page).not_to have_content('Frontend Engineer') expect(page).not_to have_content('Frontend Engineer')
@ -243,10 +240,10 @@ RSpec.describe 'User page' do
end end
it 'shows username' do it 'shows username' do
subject
expect(page).to have_content("@#{user.username}") expect(page).to have_content("@#{user.username}")
end end
it_behaves_like 'default brand title page meta description'
end end
context 'with unconfirmed user' do context 'with unconfirmed user' do
@ -256,7 +253,8 @@ RSpec.describe 'User page' do
:unconfirmed, :unconfirmed,
organization: 'GitLab - work info test', organization: 'GitLab - work info test',
job_title: 'Frontend Engineer', job_title: 'Frontend Engineer',
pronunciation: 'pruh-nuhn-see-ay-shn' pronunciation: 'pruh-nuhn-see-ay-shn',
bio: 'My personal bio'
) )
end end
@ -287,6 +285,8 @@ RSpec.describe 'User page' do
it 'shows private profile message' do it 'shows private profile message' do
expect(page).to have_content("This user has a private profile") expect(page).to have_content("This user has a private profile")
end end
it_behaves_like 'default brand title page meta description'
end end
context 'when visited by an authenticated user' do context 'when visited by an authenticated user' do

View file

@ -1,4 +1,4 @@
export class MyClassExtension { export class SEClassExtension {
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
provides() { provides() {
return { return {
@ -8,7 +8,7 @@ export class MyClassExtension {
} }
} }
export function MyFnExtension() { export function SEFnExtension() {
return { return {
fnExtMethod: () => 'fn own method', fnExtMethod: () => 'fn own method',
provides: () => { provides: () => {
@ -19,7 +19,7 @@ export function MyFnExtension() {
}; };
} }
export const MyConstExt = () => { export const SEConstExt = () => {
return { return {
provides: () => { provides: () => {
return { return {
@ -29,6 +29,33 @@ export const MyConstExt = () => {
}; };
}; };
export function SEWithSetupExt() {
return {
onSetup: (setupOptions = {}, instance) => {
if (setupOptions && !Array.isArray(setupOptions)) {
Object.entries(setupOptions).forEach(([key, value]) => {
Object.assign(instance, {
[key]: value,
});
});
}
},
provides: () => {
return {
returnInstanceAndProps: (instance, stringProp, objProp = {}) => {
return [stringProp, objProp, instance];
},
returnInstance: (instance) => {
return instance;
},
giveMeContext: () => {
return this;
},
};
},
};
}
export const conflictingExtensions = { export const conflictingExtensions = {
WithInstanceExt: () => { WithInstanceExt: () => {
return { return {

View file

@ -22,15 +22,15 @@ describe('Editor Extension', () => {
it.each` it.each`
definition | setupOptions | expectedName definition | setupOptions | expectedName
${helpers.MyClassExtension} | ${undefined} | ${'MyClassExtension'} ${helpers.SEClassExtension} | ${undefined} | ${'SEClassExtension'}
${helpers.MyClassExtension} | ${{}} | ${'MyClassExtension'} ${helpers.SEClassExtension} | ${{}} | ${'SEClassExtension'}
${helpers.MyClassExtension} | ${dummyObj} | ${'MyClassExtension'} ${helpers.SEClassExtension} | ${dummyObj} | ${'SEClassExtension'}
${helpers.MyFnExtension} | ${undefined} | ${'MyFnExtension'} ${helpers.SEFnExtension} | ${undefined} | ${'SEFnExtension'}
${helpers.MyFnExtension} | ${{}} | ${'MyFnExtension'} ${helpers.SEFnExtension} | ${{}} | ${'SEFnExtension'}
${helpers.MyFnExtension} | ${dummyObj} | ${'MyFnExtension'} ${helpers.SEFnExtension} | ${dummyObj} | ${'SEFnExtension'}
${helpers.MyConstExt} | ${undefined} | ${'MyConstExt'} ${helpers.SEConstExt} | ${undefined} | ${'SEConstExt'}
${helpers.MyConstExt} | ${{}} | ${'MyConstExt'} ${helpers.SEConstExt} | ${{}} | ${'SEConstExt'}
${helpers.MyConstExt} | ${dummyObj} | ${'MyConstExt'} ${helpers.SEConstExt} | ${dummyObj} | ${'SEConstExt'}
`( `(
'correctly creates extension for definition = $definition and setupOptions = $setupOptions', 'correctly creates extension for definition = $definition and setupOptions = $setupOptions',
({ definition, setupOptions, expectedName }) => { ({ definition, setupOptions, expectedName }) => {
@ -51,9 +51,9 @@ describe('Editor Extension', () => {
describe('api', () => { describe('api', () => {
it.each` it.each`
definition | expectedKeys definition | expectedKeys
${helpers.MyClassExtension} | ${['shared', 'classExtMethod']} ${helpers.SEClassExtension} | ${['shared', 'classExtMethod']}
${helpers.MyFnExtension} | ${['fnExtMethod']} ${helpers.SEFnExtension} | ${['fnExtMethod']}
${helpers.MyConstExt} | ${['constExtMethod']} ${helpers.SEConstExt} | ${['constExtMethod']}
`('correctly returns API for $definition', ({ definition, expectedKeys }) => { `('correctly returns API for $definition', ({ definition, expectedKeys }) => {
const extension = new EditorExtension({ definition }); const extension = new EditorExtension({ definition });
const expectedApi = Object.fromEntries( const expectedApi = Object.fromEntries(

View file

@ -6,23 +6,29 @@ import {
EDITOR_EXTENSION_NOT_REGISTERED_ERROR, EDITOR_EXTENSION_NOT_REGISTERED_ERROR,
EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR, EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR,
} from '~/editor/constants'; } from '~/editor/constants';
import Instance from '~/editor/source_editor_instance'; import SourceEditorInstance from '~/editor/source_editor_instance';
import { sprintf } from '~/locale'; import { sprintf } from '~/locale';
import { MyClassExtension, conflictingExtensions, MyFnExtension, MyConstExt } from './helpers'; import {
SEClassExtension,
conflictingExtensions,
SEFnExtension,
SEConstExt,
SEWithSetupExt,
} from './helpers';
describe('Source Editor Instance', () => { describe('Source Editor Instance', () => {
let seInstance; let seInstance;
const defSetupOptions = { foo: 'bar' }; const defSetupOptions = { foo: 'bar' };
const fullExtensionsArray = [ const fullExtensionsArray = [
{ definition: MyClassExtension }, { definition: SEClassExtension },
{ definition: MyFnExtension }, { definition: SEFnExtension },
{ definition: MyConstExt }, { definition: SEConstExt },
]; ];
const fullExtensionsArrayWithOptions = [ const fullExtensionsArrayWithOptions = [
{ definition: MyClassExtension, setupOptions: defSetupOptions }, { definition: SEClassExtension, setupOptions: defSetupOptions },
{ definition: MyFnExtension, setupOptions: defSetupOptions }, { definition: SEFnExtension, setupOptions: defSetupOptions },
{ definition: MyConstExt, setupOptions: defSetupOptions }, { definition: SEConstExt, setupOptions: defSetupOptions },
]; ];
const fooFn = jest.fn(); const fooFn = jest.fn();
@ -40,26 +46,26 @@ describe('Source Editor Instance', () => {
}); });
it('sets up the registry for the methods coming from extensions', () => { it('sets up the registry for the methods coming from extensions', () => {
seInstance = new Instance(); seInstance = new SourceEditorInstance();
expect(seInstance.methods).toBeDefined(); expect(seInstance.methods).toBeDefined();
seInstance.use({ definition: MyClassExtension }); seInstance.use({ definition: SEClassExtension });
expect(seInstance.methods).toEqual({ expect(seInstance.methods).toEqual({
shared: 'MyClassExtension', shared: 'SEClassExtension',
classExtMethod: 'MyClassExtension', classExtMethod: 'SEClassExtension',
}); });
seInstance.use({ definition: MyFnExtension }); seInstance.use({ definition: SEFnExtension });
expect(seInstance.methods).toEqual({ expect(seInstance.methods).toEqual({
shared: 'MyClassExtension', shared: 'SEClassExtension',
classExtMethod: 'MyClassExtension', classExtMethod: 'SEClassExtension',
fnExtMethod: 'MyFnExtension', fnExtMethod: 'SEFnExtension',
}); });
}); });
describe('proxy', () => { describe('proxy', () => {
it('returns prop from an extension if extension provides it', () => { it('returns prop from an extension if extension provides it', () => {
seInstance = new Instance(); seInstance = new SourceEditorInstance();
seInstance.use({ definition: DummyExt }); seInstance.use({ definition: DummyExt });
expect(fooFn).not.toHaveBeenCalled(); expect(fooFn).not.toHaveBeenCalled();
@ -67,8 +73,58 @@ describe('Source Editor Instance', () => {
expect(fooFn).toHaveBeenCalled(); expect(fooFn).toHaveBeenCalled();
}); });
it.each`
stringPropToPass | objPropToPass | setupOptions
${undefined} | ${undefined} | ${undefined}
${'prop'} | ${undefined} | ${undefined}
${'prop'} | ${[]} | ${undefined}
${'prop'} | ${{}} | ${undefined}
${'prop'} | ${{ alpha: 'beta' }} | ${undefined}
${'prop'} | ${{ alpha: 'beta' }} | ${defSetupOptions}
${'prop'} | ${undefined} | ${defSetupOptions}
${undefined} | ${undefined} | ${defSetupOptions}
${''} | ${{}} | ${defSetupOptions}
`(
'correctly passes arguments ("$stringPropToPass", "$objPropToPass") and instance (with "$setupOptions" setupOptions) to extension methods',
({ stringPropToPass, objPropToPass, setupOptions }) => {
seInstance = new SourceEditorInstance();
seInstance.use({ definition: SEWithSetupExt, setupOptions });
const [stringProp, objProp, instance] = seInstance.returnInstanceAndProps(
stringPropToPass,
objPropToPass,
);
const expectedObjProps = objPropToPass || {};
expect(instance).toBe(seInstance);
expect(stringProp).toBe(stringPropToPass);
expect(objProp).toEqual(expectedObjProps);
if (setupOptions) {
Object.keys(setupOptions).forEach((key) => {
expect(instance[key]).toBe(setupOptions[key]);
});
}
},
);
it('correctly passes instance to the methods even if no additional props have been passed', () => {
seInstance = new SourceEditorInstance();
seInstance.use({ definition: SEWithSetupExt });
const instance = seInstance.returnInstance();
expect(instance).toBe(seInstance);
});
it("correctly sets the context of the 'this' keyword for the extension's methods", () => {
seInstance = new SourceEditorInstance();
seInstance.use({ definition: SEWithSetupExt });
expect(seInstance.giveMeContext().constructor).toEqual(SEWithSetupExt);
});
it('returns props from SE instance itself if no extension provides the prop', () => { it('returns props from SE instance itself if no extension provides the prop', () => {
seInstance = new Instance({ seInstance = new SourceEditorInstance({
use: fooFn, use: fooFn,
}); });
jest.spyOn(seInstance, 'use').mockImplementation(() => {}); jest.spyOn(seInstance, 'use').mockImplementation(() => {});
@ -80,7 +136,7 @@ describe('Source Editor Instance', () => {
}); });
it('returns props from Monaco instance when the prop does not exist on the SE instance', () => { it('returns props from Monaco instance when the prop does not exist on the SE instance', () => {
seInstance = new Instance({ seInstance = new SourceEditorInstance({
fooFn, fooFn,
}); });
@ -92,13 +148,13 @@ describe('Source Editor Instance', () => {
describe('public API', () => { describe('public API', () => {
it.each(['use', 'unuse'], 'provides "%s" as public method by default', (method) => { it.each(['use', 'unuse'], 'provides "%s" as public method by default', (method) => {
seInstance = new Instance(); seInstance = new SourceEditorInstance();
expect(seInstance[method]).toBeDefined(); expect(seInstance[method]).toBeDefined();
}); });
describe('use', () => { describe('use', () => {
it('extends the SE instance with methods provided by an extension', () => { it('extends the SE instance with methods provided by an extension', () => {
seInstance = new Instance(); seInstance = new SourceEditorInstance();
seInstance.use({ definition: DummyExt }); seInstance.use({ definition: DummyExt });
expect(fooFn).not.toHaveBeenCalled(); expect(fooFn).not.toHaveBeenCalled();
@ -108,15 +164,15 @@ describe('Source Editor Instance', () => {
it.each` it.each`
extensions | expectedProps extensions | expectedProps
${{ definition: MyClassExtension }} | ${['shared', 'classExtMethod']} ${{ definition: SEClassExtension }} | ${['shared', 'classExtMethod']}
${{ definition: MyFnExtension }} | ${['fnExtMethod']} ${{ definition: SEFnExtension }} | ${['fnExtMethod']}
${{ definition: MyConstExt }} | ${['constExtMethod']} ${{ definition: SEConstExt }} | ${['constExtMethod']}
${fullExtensionsArray} | ${['shared', 'classExtMethod', 'fnExtMethod', 'constExtMethod']} ${fullExtensionsArray} | ${['shared', 'classExtMethod', 'fnExtMethod', 'constExtMethod']}
${fullExtensionsArrayWithOptions} | ${['shared', 'classExtMethod', 'fnExtMethod', 'constExtMethod']} ${fullExtensionsArrayWithOptions} | ${['shared', 'classExtMethod', 'fnExtMethod', 'constExtMethod']}
`( `(
'Should register $expectedProps when extension is "$extensions"', 'Should register $expectedProps when extension is "$extensions"',
({ extensions, expectedProps }) => { ({ extensions, expectedProps }) => {
seInstance = new Instance(); seInstance = new SourceEditorInstance();
expect(seInstance.extensionsAPI).toHaveLength(0); expect(seInstance.extensionsAPI).toHaveLength(0);
seInstance.use(extensions); seInstance.use(extensions);
@ -127,15 +183,15 @@ describe('Source Editor Instance', () => {
it.each` it.each`
definition | preInstalledExtDefinition | expectedErrorProp definition | preInstalledExtDefinition | expectedErrorProp
${conflictingExtensions.WithInstanceExt} | ${MyClassExtension} | ${'use'} ${conflictingExtensions.WithInstanceExt} | ${SEClassExtension} | ${'use'}
${conflictingExtensions.WithInstanceExt} | ${null} | ${'use'} ${conflictingExtensions.WithInstanceExt} | ${null} | ${'use'}
${conflictingExtensions.WithAnotherExt} | ${null} | ${undefined} ${conflictingExtensions.WithAnotherExt} | ${null} | ${undefined}
${conflictingExtensions.WithAnotherExt} | ${MyClassExtension} | ${'shared'} ${conflictingExtensions.WithAnotherExt} | ${SEClassExtension} | ${'shared'}
${MyClassExtension} | ${conflictingExtensions.WithAnotherExt} | ${'shared'} ${SEClassExtension} | ${conflictingExtensions.WithAnotherExt} | ${'shared'}
`( `(
'logs the naming conflict error when registering $definition', 'logs the naming conflict error when registering $definition',
({ definition, preInstalledExtDefinition, expectedErrorProp }) => { ({ definition, preInstalledExtDefinition, expectedErrorProp }) => {
seInstance = new Instance(); seInstance = new SourceEditorInstance();
jest.spyOn(console, 'error').mockImplementation(() => {}); jest.spyOn(console, 'error').mockImplementation(() => {});
if (preInstalledExtDefinition) { if (preInstalledExtDefinition) {
@ -175,7 +231,7 @@ describe('Source Editor Instance', () => {
`( `(
'Should throw $thrownError when extension is "$extensions"', 'Should throw $thrownError when extension is "$extensions"',
({ extensions, thrownError }) => { ({ extensions, thrownError }) => {
seInstance = new Instance(); seInstance = new SourceEditorInstance();
const useExtension = () => { const useExtension = () => {
seInstance.use(extensions); seInstance.use(extensions);
}; };
@ -188,24 +244,24 @@ describe('Source Editor Instance', () => {
beforeEach(() => { beforeEach(() => {
extensionStore = new Map(); extensionStore = new Map();
seInstance = new Instance({}, extensionStore); seInstance = new SourceEditorInstance({}, extensionStore);
}); });
it('stores _instances_ of the used extensions in a global registry', () => { it('stores _instances_ of the used extensions in a global registry', () => {
const extension = seInstance.use({ definition: MyClassExtension }); const extension = seInstance.use({ definition: SEClassExtension });
expect(extensionStore.size).toBe(1); expect(extensionStore.size).toBe(1);
expect(extensionStore.entries().next().value).toEqual(['MyClassExtension', extension]); expect(extensionStore.entries().next().value).toEqual(['SEClassExtension', extension]);
}); });
it('does not duplicate entries in the registry', () => { it('does not duplicate entries in the registry', () => {
jest.spyOn(extensionStore, 'set'); jest.spyOn(extensionStore, 'set');
const extension1 = seInstance.use({ definition: MyClassExtension }); const extension1 = seInstance.use({ definition: SEClassExtension });
seInstance.use({ definition: MyClassExtension }); seInstance.use({ definition: SEClassExtension });
expect(extensionStore.set).toHaveBeenCalledTimes(1); expect(extensionStore.set).toHaveBeenCalledTimes(1);
expect(extensionStore.set).toHaveBeenCalledWith('MyClassExtension', extension1); expect(extensionStore.set).toHaveBeenCalledWith('SEClassExtension', extension1);
}); });
it.each` it.each`
@ -222,20 +278,20 @@ describe('Source Editor Instance', () => {
jest.spyOn(extensionStore, 'set'); jest.spyOn(extensionStore, 'set');
const extension1 = seInstance.use({ const extension1 = seInstance.use({
definition: MyClassExtension, definition: SEClassExtension,
setupOptions: currentSetupOptions, setupOptions: currentSetupOptions,
}); });
const extension2 = seInstance.use({ const extension2 = seInstance.use({
definition: MyClassExtension, definition: SEClassExtension,
setupOptions: newSetupOptions, setupOptions: newSetupOptions,
}); });
expect(extensionStore.size).toBe(1); expect(extensionStore.size).toBe(1);
expect(extensionStore.set).toHaveBeenCalledTimes(expectedCallTimes); expect(extensionStore.set).toHaveBeenCalledTimes(expectedCallTimes);
if (expectedCallTimes > 1) { if (expectedCallTimes > 1) {
expect(extensionStore.set).toHaveBeenCalledWith('MyClassExtension', extension2); expect(extensionStore.set).toHaveBeenCalledWith('SEClassExtension', extension2);
} else { } else {
expect(extensionStore.set).toHaveBeenCalledWith('MyClassExtension', extension1); expect(extensionStore.set).toHaveBeenCalledWith('SEClassExtension', extension1);
} }
}, },
); );
@ -252,7 +308,7 @@ describe('Source Editor Instance', () => {
`( `(
`Should throw "${EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR}" when extension is "$unuseExtension"`, `Should throw "${EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR}" when extension is "$unuseExtension"`,
({ unuseExtension, thrownError }) => { ({ unuseExtension, thrownError }) => {
seInstance = new Instance(); seInstance = new SourceEditorInstance();
const unuse = () => { const unuse = () => {
seInstance.unuse(unuseExtension); seInstance.unuse(unuseExtension);
}; };
@ -262,16 +318,16 @@ describe('Source Editor Instance', () => {
it.each` it.each`
initExtensions | unuseExtensionIndex | remainingAPI initExtensions | unuseExtensionIndex | remainingAPI
${{ definition: MyClassExtension }} | ${0} | ${[]} ${{ definition: SEClassExtension }} | ${0} | ${[]}
${{ definition: MyFnExtension }} | ${0} | ${[]} ${{ definition: SEFnExtension }} | ${0} | ${[]}
${{ definition: MyConstExt }} | ${0} | ${[]} ${{ definition: SEConstExt }} | ${0} | ${[]}
${fullExtensionsArray} | ${0} | ${['fnExtMethod', 'constExtMethod']} ${fullExtensionsArray} | ${0} | ${['fnExtMethod', 'constExtMethod']}
${fullExtensionsArray} | ${1} | ${['shared', 'classExtMethod', 'constExtMethod']} ${fullExtensionsArray} | ${1} | ${['shared', 'classExtMethod', 'constExtMethod']}
${fullExtensionsArray} | ${2} | ${['shared', 'classExtMethod', 'fnExtMethod']} ${fullExtensionsArray} | ${2} | ${['shared', 'classExtMethod', 'fnExtMethod']}
`( `(
'un-registers properties introduced by single extension $unuseExtension', 'un-registers properties introduced by single extension $unuseExtension',
({ initExtensions, unuseExtensionIndex, remainingAPI }) => { ({ initExtensions, unuseExtensionIndex, remainingAPI }) => {
seInstance = new Instance(); seInstance = new SourceEditorInstance();
const extensions = seInstance.use(initExtensions); const extensions = seInstance.use(initExtensions);
if (Array.isArray(initExtensions)) { if (Array.isArray(initExtensions)) {
@ -291,7 +347,7 @@ describe('Source Editor Instance', () => {
`( `(
'un-registers properties introduced by multiple extensions $unuseExtension', 'un-registers properties introduced by multiple extensions $unuseExtension',
({ unuseExtensionIndex, remainingAPI }) => { ({ unuseExtensionIndex, remainingAPI }) => {
seInstance = new Instance(); seInstance = new SourceEditorInstance();
const extensions = seInstance.use(fullExtensionsArray); const extensions = seInstance.use(fullExtensionsArray);
const extensionsToUnuse = extensions.filter((ext, index) => const extensionsToUnuse = extensions.filter((ext, index) =>
unuseExtensionIndex.includes(index), unuseExtensionIndex.includes(index),
@ -304,11 +360,11 @@ describe('Source Editor Instance', () => {
it('it does not remove entry from the global registry to keep for potential future re-use', () => { it('it does not remove entry from the global registry to keep for potential future re-use', () => {
const extensionStore = new Map(); const extensionStore = new Map();
seInstance = new Instance({}, extensionStore); seInstance = new SourceEditorInstance({}, extensionStore);
const extensions = seInstance.use(fullExtensionsArray); const extensions = seInstance.use(fullExtensionsArray);
const verifyExpectations = () => { const verifyExpectations = () => {
const entries = extensionStore.entries(); const entries = extensionStore.entries();
const mockExtensions = ['MyClassExtension', 'MyFnExtension', 'MyConstExt']; const mockExtensions = ['SEClassExtension', 'SEFnExtension', 'SEConstExt'];
expect(extensionStore.size).toBe(mockExtensions.length); expect(extensionStore.size).toBe(mockExtensions.length);
mockExtensions.forEach((ext, index) => { mockExtensions.forEach((ext, index) => {
expect(entries.next().value).toEqual([ext, extensions[index]]); expect(entries.next().value).toEqual([ext, extensions[index]]);
@ -326,7 +382,7 @@ describe('Source Editor Instance', () => {
beforeEach(() => { beforeEach(() => {
instanceModel = monacoEditor.createModel(''); instanceModel = monacoEditor.createModel('');
seInstance = new Instance({ seInstance = new SourceEditorInstance({
getModel: () => instanceModel, getModel: () => instanceModel,
}); });
}); });
@ -363,7 +419,7 @@ describe('Source Editor Instance', () => {
}; };
it('passes correct arguments to callback fns when using an extension', () => { it('passes correct arguments to callback fns when using an extension', () => {
seInstance = new Instance(); seInstance = new SourceEditorInstance();
seInstance.use({ seInstance.use({
definition: MyFullExtWithCallbacks, definition: MyFullExtWithCallbacks,
setupOptions: defSetupOptions, setupOptions: defSetupOptions,
@ -373,7 +429,7 @@ describe('Source Editor Instance', () => {
}); });
it('passes correct arguments to callback fns when un-using an extension', () => { it('passes correct arguments to callback fns when un-using an extension', () => {
seInstance = new Instance(); seInstance = new SourceEditorInstance();
const extension = seInstance.use({ const extension = seInstance.use({
definition: MyFullExtWithCallbacks, definition: MyFullExtWithCallbacks,
setupOptions: defSetupOptions, setupOptions: defSetupOptions,

View file

@ -57,7 +57,7 @@ describe('Markdown Extension for Source Editor', () => {
blobPath: markdownPath, blobPath: markdownPath,
blobContent: text, blobContent: text,
}); });
editor.use(new EditorMarkdownExtension({ instance, previewMarkdownPath })); instance.use(new EditorMarkdownExtension({ instance, previewMarkdownPath }));
panelSpy = jest.spyOn(EditorMarkdownExtension, 'togglePreviewPanel'); panelSpy = jest.spyOn(EditorMarkdownExtension, 'togglePreviewPanel');
}); });

View file

@ -1,6 +1,5 @@
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
import { editor as monacoEditor, languages as monacoLanguages } from 'monaco-editor'; import { editor as monacoEditor, languages as monacoLanguages } from 'monaco-editor';
import waitForPromises from 'helpers/wait_for_promises';
import { import {
SOURCE_EDITOR_INSTANCE_ERROR_NO_EL, SOURCE_EDITOR_INSTANCE_ERROR_NO_EL,
URI_PREFIX, URI_PREFIX,
@ -531,105 +530,19 @@ describe('Base editor', () => {
instance.use(FunctionExt); instance.use(FunctionExt);
expect(instance.inst()).toEqual(editor.instances[0]); expect(instance.inst()).toEqual(editor.instances[0]);
}); });
});
describe('extensions as an instance parameter', () => { it('emits the EDITOR_READY_EVENT event after setting up the instance', () => {
let editorExtensionSpy; jest.spyOn(monacoEditor, 'create').mockImplementation(() => {
const instanceConstructor = (extensions = []) => { return {
return editor.createInstance({ setModel: jest.fn(),
el: editorEl, onDidDispose: jest.fn(),
blobPath, };
blobContent,
extensions,
});
};
beforeEach(() => {
editorExtensionSpy = jest
.spyOn(SourceEditor, 'pushToImportsArray')
.mockImplementation((arr) => {
arr.push(
Promise.resolve({
default: {},
}),
);
});
});
it.each([undefined, [], [''], ''])(
'does not fail and makes no fetch if extensions is %s',
() => {
instance = instanceConstructor(null);
expect(editorExtensionSpy).not.toHaveBeenCalled();
},
);
it.each`
type | value | callsCount
${'simple string'} | ${'foo'} | ${1}
${'combined string'} | ${'foo, bar'} | ${2}
${'array of strings'} | ${['foo', 'bar']} | ${2}
`('accepts $type as an extension parameter', ({ value, callsCount }) => {
instance = instanceConstructor(value);
expect(editorExtensionSpy).toHaveBeenCalled();
expect(editorExtensionSpy.mock.calls).toHaveLength(callsCount);
});
it.each`
desc | path | expectation
${'~/editor'} | ${'foo'} | ${'~/editor/foo'}
${'~/CUSTOM_PATH with leading slash'} | ${'/my_custom_path/bar'} | ${'~/my_custom_path/bar'}
${'~/CUSTOM_PATH without leading slash'} | ${'my_custom_path/delta'} | ${'~/my_custom_path/delta'}
`('fetches extensions from $desc path', ({ path, expectation }) => {
instance = instanceConstructor(path);
expect(editorExtensionSpy).toHaveBeenCalledWith(expect.any(Array), expectation);
});
it('emits EDITOR_READY_EVENT event after all extensions were applied', async () => {
const calls = [];
const eventSpy = jest.fn().mockImplementation(() => {
calls.push('event');
});
const useSpy = jest.fn().mockImplementation(() => {
calls.push('use');
});
jest.spyOn(SourceEditor, 'convertMonacoToELInstance').mockImplementation((inst) => {
const decoratedInstance = inst;
decoratedInstance.use = useSpy;
return decoratedInstance;
}); });
const eventSpy = jest.fn();
editorEl.addEventListener(EDITOR_READY_EVENT, eventSpy); editorEl.addEventListener(EDITOR_READY_EVENT, eventSpy);
instance = instanceConstructor('foo, bar'); expect(eventSpy).not.toHaveBeenCalled();
await waitForPromises(); instance = editor.createInstance({ el: editorEl });
expect(useSpy.mock.calls).toHaveLength(2); expect(eventSpy).toHaveBeenCalled();
expect(calls).toEqual(['use', 'use', 'event']);
});
});
describe('multiple instances', () => {
let inst1;
let inst2;
let editorEl1;
let editorEl2;
beforeEach(() => {
setFixtures('<div id="editor1"></div><div id="editor2"></div>');
editorEl1 = document.getElementById('editor1');
editorEl2 = document.getElementById('editor2');
inst1 = editor.createInstance({ el: editorEl1, blobPath: `foo-${blobPath}` });
inst2 = editor.createInstance({ el: editorEl2, blobPath: `bar-${blobPath}` });
});
afterEach(() => {
editor.dispose();
editorEl1.remove();
editorEl2.remove();
});
it('extends all instances if no specific instance is passed', () => {
editor.use(AlphaExt);
expect(inst1.alpha()).toEqual(alphaRes);
expect(inst2.alpha()).toEqual(alphaRes);
}); });
}); });
}); });

View file

@ -0,0 +1,32 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Clusters::Agents::ActivityEvent do
it { is_expected.to belong_to(:agent).class_name('Clusters::Agent').required }
it { is_expected.to belong_to(:user).optional }
it { is_expected.to belong_to(:agent_token).class_name('Clusters::AgentToken').optional }
it { is_expected.to validate_presence_of(:kind) }
it { is_expected.to validate_presence_of(:level) }
it { is_expected.to validate_presence_of(:recorded_at) }
it { is_expected.to nullify_if_blank(:detail) }
describe 'scopes' do
let_it_be(:agent) { create(:cluster_agent) }
describe '.in_timeline_order' do
let(:recorded_at) { 1.hour.ago }
let!(:event1) { create(:agent_activity_event, agent: agent, recorded_at: recorded_at) }
let!(:event2) { create(:agent_activity_event, agent: agent, recorded_at: Time.current) }
let!(:event3) { create(:agent_activity_event, agent: agent, recorded_at: recorded_at) }
subject { described_class.in_timeline_order }
it 'sorts by recorded_at: :desc, id: :desc' do
is_expected.to eq([event2, event3, event1])
end
end
end
end

View file

@ -47,6 +47,21 @@ RSpec.describe Clusters::AgentTokens::CreateService do
expect(token.name).to eq(params[:name]) expect(token.name).to eq(params[:name])
end end
it 'creates an activity event' do
expect { subject }.to change { ::Clusters::Agents::ActivityEvent.count }.by(1)
token = subject.payload[:token].reload
event = cluster_agent.activity_events.last
expect(event).to have_attributes(
kind: 'token_created',
level: 'info',
recorded_at: token.created_at,
user: token.created_by_user,
agent_token: token
)
end
context 'when params are invalid' do context 'when params are invalid' do
let(:params) { { agent_id: 'bad_id' } } let(:params) { { agent_id: 'bad_id' } }
@ -54,6 +69,10 @@ RSpec.describe Clusters::AgentTokens::CreateService do
expect { subject }.not_to change(::Clusters::AgentToken, :count) expect { subject }.not_to change(::Clusters::AgentToken, :count)
end end
it 'does not create an activity event' do
expect { subject }.not_to change { ::Clusters::Agents::ActivityEvent.count }
end
it 'returns validation errors', :aggregate_failures do it 'returns validation errors', :aggregate_failures do
expect(subject.status).to eq(:error) expect(subject.status).to eq(:error)
expect(subject.message).to eq(["Agent must exist", "Name can't be blank"]) expect(subject.message).to eq(["Agent must exist", "Name can't be blank"])

View file

@ -7,3 +7,13 @@ RSpec.shared_examples 'page meta description' do |expected_description|
end end
end end
end end
RSpec.shared_examples 'default brand title page meta description' do
include AppearancesHelper
it 'renders the page with description, og:description, and twitter:description meta tags with the default brand title', :aggregate_failures do
%w(name='description' property='og:description' property='twitter:description').each do |selector|
expect(page).to have_selector("meta[#{selector}][content='#{default_brand_title}']", visible: false)
end
end
end