diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 52fd1c1ceef..dc5302cb2fd 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -9e5735cc1b202ce5e5657ad83eeeb7b037141e09 +4e18794f846ad0d27bea3443caa2b51cd9afd722 diff --git a/app/assets/javascripts/editor/source_editor.js b/app/assets/javascripts/editor/source_editor.js index 81ddf8d77fa..26fbd1f4d8a 100644 --- a/app/assets/javascripts/editor/source_editor.js +++ b/app/assets/javascripts/editor/source_editor.js @@ -26,26 +26,6 @@ export default class SourceEditor { 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) { if (!inst) { 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({ blobPath, blobContent, @@ -187,7 +150,6 @@ export default class SourceEditor { blobContent = '', blobOriginalContent = '', blobGlobalId = uuids()[0], - extensions = [], isDiff = false, ...instanceOptions } = {}) { @@ -218,9 +180,8 @@ export default class SourceEditor { SourceEditor.instanceDisposeModels(this, instance, model); }); - SourceEditor.manageDefaultExtensions(instance, el, extensions); - this.instances.push(instance); + el.dispatchEvent(new CustomEvent(EDITOR_READY_EVENT, { instance })); return instance; } @@ -234,11 +195,4 @@ export default class SourceEditor { dispose() { this.instances.forEach((instance) => instance.dispose()); } - - use(exts) { - this.instances.forEach((inst) => { - inst.use(exts); - }); - return this; - } } diff --git a/app/assets/javascripts/editor/source_editor_instance.js b/app/assets/javascripts/editor/source_editor_instance.js index e0ca4ea518b..052a73d7091 100644 --- a/app/assets/javascripts/editor/source_editor_instance.js +++ b/app/assets/javascripts/editor/source_editor_instance.js @@ -73,9 +73,7 @@ export default class EditorInstance { if (methodExtension) { const extension = extensionsStore.get(methodExtension); - return (...args) => { - return extension.api[prop].call(seInstance, ...args, receiver); - }; + return (...args) => extension.api[prop].call(seInstance, receiver, ...args); } return Reflect.get(seInstance[prop] ? seInstance : target, prop, receiver); }, diff --git a/app/experiments/application_experiment.rb b/app/experiments/application_experiment.rb index 37d87baf30b..22c632a08aa 100644 --- a/app/experiments/application_experiment.rb +++ b/app/experiments/application_experiment.rb @@ -32,8 +32,8 @@ class ApplicationExperiment < Gitlab::Experiment # rubocop:disable Gitlab/Namesp subject = value[:namespace] || value[:group] || value[:project] || value[:user] || value[:actor] return unless ExperimentSubject.valid_subject?(subject) - variant = :experimental if @variant_name != :control - Experiment.add_subject(name, variant: variant || :control, subject: subject) + variant_name = :experimental if variant&.name != 'control' + Experiment.add_subject(name, variant: variant_name || :control, subject: subject) end def record! diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb index cf6d95fc6df..f04ac6f1722 100644 --- a/app/models/clusters/agent.rb +++ b/app/models/clusters/agent.rb @@ -16,6 +16,8 @@ module Clusters has_many :project_authorizations, class_name: 'Clusters::Agents::ProjectAuthorization' 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 :with_name, -> (name) { where(name: name) } diff --git a/app/models/clusters/agents/activity_event.rb b/app/models/clusters/agents/activity_event.rb new file mode 100644 index 00000000000..668aba74821 --- /dev/null +++ b/app/models/clusters/agents/activity_event.rb @@ -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 diff --git a/app/services/clusters/agent_tokens/create_service.rb b/app/services/clusters/agent_tokens/create_service.rb index ae2617f510b..5b8a0e46a6c 100644 --- a/app/services/clusters/agent_tokens/create_service.rb +++ b/app/services/clusters/agent_tokens/create_service.rb @@ -11,6 +11,8 @@ module Clusters token = ::Clusters::AgentToken.new(filtered_params.merge(created_by_user: current_user)) if token.save + log_activity_event!(token) + ServiceResponse.success(payload: { secret: token.token, token: token }) else ServiceResponse.error(message: token.errors.full_messages) @@ -26,6 +28,16 @@ module Clusters def filtered_params params.slice(*ALLOWED_PARAMS) 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 diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 522f0f771cd..ca276519758 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -2,8 +2,7 @@ - @hide_breadcrumbs = true - @no_container = true - page_title user_display_name(@user) -- page_description @user.bio -- header_title @user.name, user_path(@user) +- page_description @user.bio unless @user.blocked? || !@user.confirmed? - page_itemtype 'http://schema.org/Person' - link_classes = "flex-grow-1 mx-1 " diff --git a/config/feature_flags/development/ci_require_credit_card_on_free_plan.yml b/config/feature_flags/development/ci_require_credit_card_on_free_plan.yml index 868c3c84649..7e5795de6a0 100644 --- a/config/feature_flags/development/ci_require_credit_card_on_free_plan.yml +++ b/config/feature_flags/development/ci_require_credit_card_on_free_plan.yml @@ -1,8 +1,8 @@ --- 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 milestone: '13.12' type: development -group: group::pipeline execution +group: group::fulfillment default_enabled: false diff --git a/config/feature_flags/development/ci_require_credit_card_on_trial_plan.yml b/config/feature_flags/development/ci_require_credit_card_on_trial_plan.yml index 6a946f0959c..578101a1ba4 100644 --- a/config/feature_flags/development/ci_require_credit_card_on_trial_plan.yml +++ b/config/feature_flags/development/ci_require_credit_card_on_trial_plan.yml @@ -1,8 +1,8 @@ --- 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 milestone: '13.12' type: development -group: group::pipeline execution +group: group::fulfillment default_enabled: false diff --git a/db/migrate/20211110014701_create_agent_activity_events.rb b/db/migrate/20211110014701_create_agent_activity_events.rb new file mode 100644 index 00000000000..11b9c6d03b3 --- /dev/null +++ b/db/migrate/20211110014701_create_agent_activity_events.rb @@ -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 diff --git a/db/migrate/20211110015252_add_agent_activity_events_foreign_keys.rb b/db/migrate/20211110015252_add_agent_activity_events_foreign_keys.rb new file mode 100644 index 00000000000..fcbafcccb06 --- /dev/null +++ b/db/migrate/20211110015252_add_agent_activity_events_foreign_keys.rb @@ -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 diff --git a/db/schema_migrations/20211110014701 b/db/schema_migrations/20211110014701 new file mode 100644 index 00000000000..fe3721eb055 --- /dev/null +++ b/db/schema_migrations/20211110014701 @@ -0,0 +1 @@ +1c5f65a25c9cf81a50bd9ffa2e74e2621cff04e58a2f90b19c66741ebb459d3e \ No newline at end of file diff --git a/db/schema_migrations/20211110015252 b/db/schema_migrations/20211110015252 new file mode 100644 index 00000000000..06a6a5b0ad7 --- /dev/null +++ b/db/schema_migrations/20211110015252 @@ -0,0 +1 @@ +4038c269ce9c47ca9327fb1b81bb588e9065f0821f291d17c7965d7f8fe1f275 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index caa1f603df3..c9b3243f025 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -9721,6 +9721,30 @@ CREATE SEQUENCE abuse_reports_id_seq 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 ( 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 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_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 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 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_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 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 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 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 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 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 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 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 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 ADD CONSTRAINT fk_d76a2b9a8c FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; diff --git a/doc/api/members.md b/doc/api/members.md index ce276487f21..497c9a00dfd 100644 --- a/doc/api/members.md +++ b/doc/api/members.md @@ -65,7 +65,8 @@ Example response: "web_url": "http://192.168.1.8:3000/root", "expires_at": "2012-10-22T14:13:35Z", "access_level": 30, - "group_saml_identity": null + "group_saml_identity": null, + "membership_state": "active" }, { "id": 2, @@ -81,7 +82,8 @@ Example response: "extern_uid":"ABC-1234567890", "provider": "group_saml", "saml_provider_id": 10 - } + }, + "membership_state": "active" } ] ``` @@ -126,7 +128,8 @@ Example response: "web_url": "http://192.168.1.8:3000/root", "expires_at": "2012-10-22T14:13:35Z", "access_level": 30, - "group_saml_identity": null + "group_saml_identity": null, + "membership_state": "active" }, { "id": 2, @@ -142,7 +145,8 @@ Example response: "extern_uid":"ABC-1234567890", "provider": "group_saml", "saml_provider_id": 10 - } + }, + "membership_state": "active" }, { "id": 3, @@ -153,7 +157,8 @@ Example response: "web_url": "http://192.168.1.8:3000/root", "expires_at": "2012-11-22T14:13:35Z", "access_level": 30, - "group_saml_identity": null + "group_saml_identity": null, + "membership_state": "active" } ] ``` @@ -191,7 +196,8 @@ Example response: "email": "john@example.com", "created_at": "2012-10-22T14:13:35Z", "expires_at": null, - "group_saml_identity": null + "group_saml_identity": null, + "membership_state": "active" } ``` @@ -229,7 +235,8 @@ Example response: "access_level": 30, "email": "john@example.com", "expires_at": null, - "group_saml_identity": null + "group_saml_identity": null, + "membership_state": "active" } ``` diff --git a/doc/user/project/merge_requests/squash_and_merge.md b/doc/user/project/merge_requests/squash_and_merge.md index c3fc2fa871f..e551f65b75d 100644 --- a/doc/user/project/merge_requests/squash_and_merge.md +++ b/doc/user/project/merge_requests/squash_and_merge.md @@ -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. -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. ![A squash commit message editor](img/squash_mr_message.png) diff --git a/lib/gitlab/database/gitlab_schemas.yml b/lib/gitlab/database/gitlab_schemas.yml index 66157e998a0..5a553a6ef56 100644 --- a/lib/gitlab/database/gitlab_schemas.yml +++ b/lib/gitlab/database/gitlab_schemas.yml @@ -1,4 +1,5 @@ abuse_reports: :gitlab_main +agent_activity_events: :gitlab_main agent_group_authorizations: :gitlab_main agent_project_authorizations: :gitlab_main alert_management_alert_assignees: :gitlab_main diff --git a/locale/gitlab.pot b/locale/gitlab.pot index b9f393c43ea..adc07db990c 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -38540,9 +38540,15 @@ msgstr "" msgid "VulnerabilityManagement|Something went wrong, could not update vulnerability state." msgstr "" +msgid "VulnerabilityManagement|Summary, detailed description, steps to reproduce, etc." +msgstr "" + msgid "VulnerabilityManagement|Verified as fixed or mitigated" msgstr "" +msgid "VulnerabilityManagement|Vulnerability name or type. Ex: Cross-site scripting" +msgstr "" + msgid "VulnerabilityManagement|Will not fix or a false-positive" msgstr "" diff --git a/spec/experiments/application_experiment_spec.rb b/spec/experiments/application_experiment_spec.rb index b0788eec808..dda8ca66eee 100644 --- a/spec/experiments/application_experiment_spec.rb +++ b/spec/experiments/application_experiment_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe ApplicationExperiment, :experiment do - subject { described_class.new('namespaced/stub', **context) } + subject(:application_experiment) { described_class.new('namespaced/stub', **context) } let(:context) { {} } let(:feature_definition) { { name: 'namespaced_stub', type: 'experiment', default_enabled: false } } @@ -15,7 +15,7 @@ RSpec.describe ApplicationExperiment, :experiment do end before do - allow(subject).to receive(:enabled?).and_return(true) + allow(application_experiment).to receive(:enabled?).and_return(true) end it "doesn't raise an exception without a defined control" do @@ -26,7 +26,7 @@ RSpec.describe ApplicationExperiment, :experiment do describe "#enabled?" 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(Gitlab).to receive(:dev_env_or_com?).and_return(true) @@ -34,25 +34,25 @@ RSpec.describe ApplicationExperiment, :experiment do end it "is enabled when all criteria are met" do - expect(subject).to be_enabled + expect(application_experiment).to be_enabled end 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(subject).not_to be_enabled + expect(application_experiment).not_to be_enabled end 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(subject).not_to be_enabled + expect(application_experiment).not_to be_enabled end 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(subject).not_to be_enabled + expect(application_experiment).not_to be_enabled end end @@ -60,11 +60,11 @@ RSpec.describe ApplicationExperiment, :experiment do let(:should_track) { true } before do - allow(subject).to receive(:should_track?).and_return(should_track) + allow(application_experiment).to receive(:should_track?).and_return(should_track) end it "tracks the assignment", :snowplow do - subject.publish + application_experiment.publish expect_snowplow_event( category: 'namespaced/stub', @@ -74,24 +74,24 @@ RSpec.describe ApplicationExperiment, :experiment do end 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 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 context 'when we should not track' do let(:should_track) { false } it 'does not track an event to Snowplow', :snowplow do - subject.publish + application_experiment.publish expect_no_snowplow_event end @@ -102,13 +102,13 @@ RSpec.describe ApplicationExperiment, :experiment do signature = { key: '86208ac54ca798e11f127e8b23ec396a', variant: 'control' } expect(Gon).to receive(:push).with({ experiment: { 'namespaced/stub' => hash_including(signature) } }, true) - subject.publish_to_client + application_experiment.publish_to_client end it "handles when Gon raises exceptions (like when it can't be pushed into)" do 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 context 'when we should not track' do @@ -117,7 +117,7 @@ RSpec.describe ApplicationExperiment, :experiment do it 'returns early' do expect(Gon).not_to receive(:push) - subject.publish_to_client + application_experiment.publish_to_client end end end @@ -125,13 +125,15 @@ RSpec.describe ApplicationExperiment, :experiment do describe '#publish_to_database' do using RSpec::Parameterized::TableSyntax + let(:publish_to_database) { application_experiment.publish_to_database } + shared_examples 'does not record to the database' 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 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 @@ -139,16 +141,16 @@ RSpec.describe ApplicationExperiment, :experiment do let(:context) { { context_key => context_value } } where(:context_key, :context_value, :object_type) do - :namespace | build(:namespace) | :namespace - :group | build(:namespace) | :namespace - :project | build(:project) | :project - :user | build(:user) | :user - :actor | build(:user) | :user + :namespace | build(:namespace, id: non_existing_record_id) | :namespace + :group | build(:namespace, id: non_existing_record_id) | :namespace + :project | build(:project, id: non_existing_record_id) | :project + :user | build(:user, id: non_existing_record_id) | :user + :actor | build(:user, id: non_existing_record_id) | :user end with_them 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(ExperimentSubject.last.send(object_type)).to eq(context[context_key]) @@ -156,6 +158,16 @@ RSpec.describe ApplicationExperiment, :experiment do 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 let(:context) { { context_key => context_value } } @@ -183,15 +195,15 @@ RSpec.describe ApplicationExperiment, :experiment do end 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 end 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( category: 'namespaced/stub', @@ -233,7 +245,7 @@ RSpec.describe ApplicationExperiment, :experiment do describe "#key_for" 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 @@ -256,26 +268,26 @@ RSpec.describe ApplicationExperiment, :experiment do with_them do it "returns the url or nil if invalid" do 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 it "considers all urls invalid when not on dev or com" do 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 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') - expect(url).to include("/-/experiment/namespaced%2Fstub:#{subject.context.key}?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:#{application_experiment.context.key}?https://docs.gitlab.com") end end context "when resolving variants" 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 context "when rolled out to 100%" do @@ -284,10 +296,10 @@ RSpec.describe ApplicationExperiment, :experiment do end it "returns the first variant name" do - subject.try(:variant1) {} - subject.try(:variant2) {} + application_experiment.try(:variant1) {} + application_experiment.try(:variant2) {} - expect(subject.variant.name).to eq('variant1') + expect(application_experiment.variant.name).to eq('variant1') end end end @@ -298,18 +310,18 @@ RSpec.describe ApplicationExperiment, :experiment do before do 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 - subject.try { } # setup the candidate + application_experiment.use { } # setup the control + application_experiment.try { } # setup the candidate end 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 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. 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 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 # the field from the cache (or clear the entire experiment cache) -- or # 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 context "arbitrary attributes" do before do - subject.cache.store.clear(key: subject.name + '_attrs') + application_experiment.cache.store.clear(key: application_experiment.name + '_attrs') end 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 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(subject.cache.attr_inc(:foo)).to eq(2) + expect(application_experiment.cache.attr_inc(:foo)).to eq(1) + expect(application_experiment.cache.attr_inc(:foo)).to eq(2) end end end diff --git a/spec/factories/clusters/agents/activity_events.rb b/spec/factories/clusters/agents/activity_events.rb new file mode 100644 index 00000000000..ff73f617964 --- /dev/null +++ b/spec/factories/clusters/agents/activity_events.rb @@ -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 diff --git a/spec/features/users/show_spec.rb b/spec/features/users/show_spec.rb index 61672662fbe..8edbf639c81 100644 --- a/spec/features/users/show_spec.rb +++ b/spec/features/users/show_spec.rb @@ -207,34 +207,31 @@ RSpec.describe 'User page' do state: :blocked, organization: 'GitLab - work info test', job_title: 'Frontend Engineer', - pronunciation: 'pruh-nuhn-see-ay-shn' + pronunciation: 'pruh-nuhn-see-ay-shn', + bio: 'My personal bio' ) end let_it_be(:status) { create(:user_status, user: user, message: "Working hard!") } - it 'shows no tab' do - subject + before do + visit_profile + end + it 'shows no tab' do expect(page).to have_css("div.profile-header") expect(page).not_to have_css("ul.nav-links") end it 'shows blocked message' do - subject - expect(page).to have_content("This user is blocked") end it 'shows user name as blocked' do - subject - expect(page).to have_css(".cover-title", text: 'Blocked user') end it 'shows no additional fields' do - subject - 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('Frontend Engineer') @@ -243,10 +240,10 @@ RSpec.describe 'User page' do end it 'shows username' do - subject - expect(page).to have_content("@#{user.username}") end + + it_behaves_like 'default brand title page meta description' end context 'with unconfirmed user' do @@ -256,7 +253,8 @@ RSpec.describe 'User page' do :unconfirmed, organization: 'GitLab - work info test', job_title: 'Frontend Engineer', - pronunciation: 'pruh-nuhn-see-ay-shn' + pronunciation: 'pruh-nuhn-see-ay-shn', + bio: 'My personal bio' ) end @@ -287,6 +285,8 @@ RSpec.describe 'User page' do it 'shows private profile message' do expect(page).to have_content("This user has a private profile") end + + it_behaves_like 'default brand title page meta description' end context 'when visited by an authenticated user' do diff --git a/spec/frontend/editor/helpers.js b/spec/frontend/editor/helpers.js index 6f7cdf6efb3..c77be4f8c58 100644 --- a/spec/frontend/editor/helpers.js +++ b/spec/frontend/editor/helpers.js @@ -1,4 +1,4 @@ -export class MyClassExtension { +export class SEClassExtension { // eslint-disable-next-line class-methods-use-this provides() { return { @@ -8,7 +8,7 @@ export class MyClassExtension { } } -export function MyFnExtension() { +export function SEFnExtension() { return { fnExtMethod: () => 'fn own method', provides: () => { @@ -19,7 +19,7 @@ export function MyFnExtension() { }; } -export const MyConstExt = () => { +export const SEConstExt = () => { return { provides: () => { 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 = { WithInstanceExt: () => { return { diff --git a/spec/frontend/editor/source_editor_extension_spec.js b/spec/frontend/editor/source_editor_extension_spec.js index 6f2eb07a043..de3f9da0aed 100644 --- a/spec/frontend/editor/source_editor_extension_spec.js +++ b/spec/frontend/editor/source_editor_extension_spec.js @@ -22,15 +22,15 @@ describe('Editor Extension', () => { it.each` definition | setupOptions | expectedName - ${helpers.MyClassExtension} | ${undefined} | ${'MyClassExtension'} - ${helpers.MyClassExtension} | ${{}} | ${'MyClassExtension'} - ${helpers.MyClassExtension} | ${dummyObj} | ${'MyClassExtension'} - ${helpers.MyFnExtension} | ${undefined} | ${'MyFnExtension'} - ${helpers.MyFnExtension} | ${{}} | ${'MyFnExtension'} - ${helpers.MyFnExtension} | ${dummyObj} | ${'MyFnExtension'} - ${helpers.MyConstExt} | ${undefined} | ${'MyConstExt'} - ${helpers.MyConstExt} | ${{}} | ${'MyConstExt'} - ${helpers.MyConstExt} | ${dummyObj} | ${'MyConstExt'} + ${helpers.SEClassExtension} | ${undefined} | ${'SEClassExtension'} + ${helpers.SEClassExtension} | ${{}} | ${'SEClassExtension'} + ${helpers.SEClassExtension} | ${dummyObj} | ${'SEClassExtension'} + ${helpers.SEFnExtension} | ${undefined} | ${'SEFnExtension'} + ${helpers.SEFnExtension} | ${{}} | ${'SEFnExtension'} + ${helpers.SEFnExtension} | ${dummyObj} | ${'SEFnExtension'} + ${helpers.SEConstExt} | ${undefined} | ${'SEConstExt'} + ${helpers.SEConstExt} | ${{}} | ${'SEConstExt'} + ${helpers.SEConstExt} | ${dummyObj} | ${'SEConstExt'} `( 'correctly creates extension for definition = $definition and setupOptions = $setupOptions', ({ definition, setupOptions, expectedName }) => { @@ -51,9 +51,9 @@ describe('Editor Extension', () => { describe('api', () => { it.each` definition | expectedKeys - ${helpers.MyClassExtension} | ${['shared', 'classExtMethod']} - ${helpers.MyFnExtension} | ${['fnExtMethod']} - ${helpers.MyConstExt} | ${['constExtMethod']} + ${helpers.SEClassExtension} | ${['shared', 'classExtMethod']} + ${helpers.SEFnExtension} | ${['fnExtMethod']} + ${helpers.SEConstExt} | ${['constExtMethod']} `('correctly returns API for $definition', ({ definition, expectedKeys }) => { const extension = new EditorExtension({ definition }); const expectedApi = Object.fromEntries( diff --git a/spec/frontend/editor/source_editor_instance_spec.js b/spec/frontend/editor/source_editor_instance_spec.js index 87b20a4ba73..a46eea4c4cd 100644 --- a/spec/frontend/editor/source_editor_instance_spec.js +++ b/spec/frontend/editor/source_editor_instance_spec.js @@ -6,23 +6,29 @@ import { EDITOR_EXTENSION_NOT_REGISTERED_ERROR, EDITOR_EXTENSION_NOT_SPECIFIED_FOR_UNUSE_ERROR, } from '~/editor/constants'; -import Instance from '~/editor/source_editor_instance'; +import SourceEditorInstance from '~/editor/source_editor_instance'; import { sprintf } from '~/locale'; -import { MyClassExtension, conflictingExtensions, MyFnExtension, MyConstExt } from './helpers'; +import { + SEClassExtension, + conflictingExtensions, + SEFnExtension, + SEConstExt, + SEWithSetupExt, +} from './helpers'; describe('Source Editor Instance', () => { let seInstance; const defSetupOptions = { foo: 'bar' }; const fullExtensionsArray = [ - { definition: MyClassExtension }, - { definition: MyFnExtension }, - { definition: MyConstExt }, + { definition: SEClassExtension }, + { definition: SEFnExtension }, + { definition: SEConstExt }, ]; const fullExtensionsArrayWithOptions = [ - { definition: MyClassExtension, setupOptions: defSetupOptions }, - { definition: MyFnExtension, setupOptions: defSetupOptions }, - { definition: MyConstExt, setupOptions: defSetupOptions }, + { definition: SEClassExtension, setupOptions: defSetupOptions }, + { definition: SEFnExtension, setupOptions: defSetupOptions }, + { definition: SEConstExt, setupOptions: defSetupOptions }, ]; const fooFn = jest.fn(); @@ -40,26 +46,26 @@ describe('Source Editor Instance', () => { }); it('sets up the registry for the methods coming from extensions', () => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); expect(seInstance.methods).toBeDefined(); - seInstance.use({ definition: MyClassExtension }); + seInstance.use({ definition: SEClassExtension }); expect(seInstance.methods).toEqual({ - shared: 'MyClassExtension', - classExtMethod: 'MyClassExtension', + shared: 'SEClassExtension', + classExtMethod: 'SEClassExtension', }); - seInstance.use({ definition: MyFnExtension }); + seInstance.use({ definition: SEFnExtension }); expect(seInstance.methods).toEqual({ - shared: 'MyClassExtension', - classExtMethod: 'MyClassExtension', - fnExtMethod: 'MyFnExtension', + shared: 'SEClassExtension', + classExtMethod: 'SEClassExtension', + fnExtMethod: 'SEFnExtension', }); }); describe('proxy', () => { it('returns prop from an extension if extension provides it', () => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); seInstance.use({ definition: DummyExt }); expect(fooFn).not.toHaveBeenCalled(); @@ -67,8 +73,58 @@ describe('Source Editor Instance', () => { 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', () => { - seInstance = new Instance({ + seInstance = new SourceEditorInstance({ use: fooFn, }); 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', () => { - seInstance = new Instance({ + seInstance = new SourceEditorInstance({ fooFn, }); @@ -92,13 +148,13 @@ describe('Source Editor Instance', () => { describe('public API', () => { it.each(['use', 'unuse'], 'provides "%s" as public method by default', (method) => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); expect(seInstance[method]).toBeDefined(); }); describe('use', () => { it('extends the SE instance with methods provided by an extension', () => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); seInstance.use({ definition: DummyExt }); expect(fooFn).not.toHaveBeenCalled(); @@ -108,15 +164,15 @@ describe('Source Editor Instance', () => { it.each` extensions | expectedProps - ${{ definition: MyClassExtension }} | ${['shared', 'classExtMethod']} - ${{ definition: MyFnExtension }} | ${['fnExtMethod']} - ${{ definition: MyConstExt }} | ${['constExtMethod']} + ${{ definition: SEClassExtension }} | ${['shared', 'classExtMethod']} + ${{ definition: SEFnExtension }} | ${['fnExtMethod']} + ${{ definition: SEConstExt }} | ${['constExtMethod']} ${fullExtensionsArray} | ${['shared', 'classExtMethod', 'fnExtMethod', 'constExtMethod']} ${fullExtensionsArrayWithOptions} | ${['shared', 'classExtMethod', 'fnExtMethod', 'constExtMethod']} `( 'Should register $expectedProps when extension is "$extensions"', ({ extensions, expectedProps }) => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); expect(seInstance.extensionsAPI).toHaveLength(0); seInstance.use(extensions); @@ -127,15 +183,15 @@ describe('Source Editor Instance', () => { it.each` definition | preInstalledExtDefinition | expectedErrorProp - ${conflictingExtensions.WithInstanceExt} | ${MyClassExtension} | ${'use'} + ${conflictingExtensions.WithInstanceExt} | ${SEClassExtension} | ${'use'} ${conflictingExtensions.WithInstanceExt} | ${null} | ${'use'} ${conflictingExtensions.WithAnotherExt} | ${null} | ${undefined} - ${conflictingExtensions.WithAnotherExt} | ${MyClassExtension} | ${'shared'} - ${MyClassExtension} | ${conflictingExtensions.WithAnotherExt} | ${'shared'} + ${conflictingExtensions.WithAnotherExt} | ${SEClassExtension} | ${'shared'} + ${SEClassExtension} | ${conflictingExtensions.WithAnotherExt} | ${'shared'} `( 'logs the naming conflict error when registering $definition', ({ definition, preInstalledExtDefinition, expectedErrorProp }) => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); jest.spyOn(console, 'error').mockImplementation(() => {}); if (preInstalledExtDefinition) { @@ -175,7 +231,7 @@ describe('Source Editor Instance', () => { `( 'Should throw $thrownError when extension is "$extensions"', ({ extensions, thrownError }) => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); const useExtension = () => { seInstance.use(extensions); }; @@ -188,24 +244,24 @@ describe('Source Editor Instance', () => { beforeEach(() => { extensionStore = new Map(); - seInstance = new Instance({}, extensionStore); + seInstance = new SourceEditorInstance({}, extensionStore); }); 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.entries().next().value).toEqual(['MyClassExtension', extension]); + expect(extensionStore.entries().next().value).toEqual(['SEClassExtension', extension]); }); it('does not duplicate entries in the registry', () => { jest.spyOn(extensionStore, 'set'); - const extension1 = seInstance.use({ definition: MyClassExtension }); - seInstance.use({ definition: MyClassExtension }); + const extension1 = seInstance.use({ definition: SEClassExtension }); + seInstance.use({ definition: SEClassExtension }); expect(extensionStore.set).toHaveBeenCalledTimes(1); - expect(extensionStore.set).toHaveBeenCalledWith('MyClassExtension', extension1); + expect(extensionStore.set).toHaveBeenCalledWith('SEClassExtension', extension1); }); it.each` @@ -222,20 +278,20 @@ describe('Source Editor Instance', () => { jest.spyOn(extensionStore, 'set'); const extension1 = seInstance.use({ - definition: MyClassExtension, + definition: SEClassExtension, setupOptions: currentSetupOptions, }); const extension2 = seInstance.use({ - definition: MyClassExtension, + definition: SEClassExtension, setupOptions: newSetupOptions, }); expect(extensionStore.size).toBe(1); expect(extensionStore.set).toHaveBeenCalledTimes(expectedCallTimes); if (expectedCallTimes > 1) { - expect(extensionStore.set).toHaveBeenCalledWith('MyClassExtension', extension2); + expect(extensionStore.set).toHaveBeenCalledWith('SEClassExtension', extension2); } 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"`, ({ unuseExtension, thrownError }) => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); const unuse = () => { seInstance.unuse(unuseExtension); }; @@ -262,16 +318,16 @@ describe('Source Editor Instance', () => { it.each` initExtensions | unuseExtensionIndex | remainingAPI - ${{ definition: MyClassExtension }} | ${0} | ${[]} - ${{ definition: MyFnExtension }} | ${0} | ${[]} - ${{ definition: MyConstExt }} | ${0} | ${[]} + ${{ definition: SEClassExtension }} | ${0} | ${[]} + ${{ definition: SEFnExtension }} | ${0} | ${[]} + ${{ definition: SEConstExt }} | ${0} | ${[]} ${fullExtensionsArray} | ${0} | ${['fnExtMethod', 'constExtMethod']} ${fullExtensionsArray} | ${1} | ${['shared', 'classExtMethod', 'constExtMethod']} ${fullExtensionsArray} | ${2} | ${['shared', 'classExtMethod', 'fnExtMethod']} `( 'un-registers properties introduced by single extension $unuseExtension', ({ initExtensions, unuseExtensionIndex, remainingAPI }) => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); const extensions = seInstance.use(initExtensions); if (Array.isArray(initExtensions)) { @@ -291,7 +347,7 @@ describe('Source Editor Instance', () => { `( 'un-registers properties introduced by multiple extensions $unuseExtension', ({ unuseExtensionIndex, remainingAPI }) => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); const extensions = seInstance.use(fullExtensionsArray); const extensionsToUnuse = extensions.filter((ext, 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', () => { const extensionStore = new Map(); - seInstance = new Instance({}, extensionStore); + seInstance = new SourceEditorInstance({}, extensionStore); const extensions = seInstance.use(fullExtensionsArray); const verifyExpectations = () => { const entries = extensionStore.entries(); - const mockExtensions = ['MyClassExtension', 'MyFnExtension', 'MyConstExt']; + const mockExtensions = ['SEClassExtension', 'SEFnExtension', 'SEConstExt']; expect(extensionStore.size).toBe(mockExtensions.length); mockExtensions.forEach((ext, index) => { expect(entries.next().value).toEqual([ext, extensions[index]]); @@ -326,7 +382,7 @@ describe('Source Editor Instance', () => { beforeEach(() => { instanceModel = monacoEditor.createModel(''); - seInstance = new Instance({ + seInstance = new SourceEditorInstance({ getModel: () => instanceModel, }); }); @@ -363,7 +419,7 @@ describe('Source Editor Instance', () => { }; it('passes correct arguments to callback fns when using an extension', () => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); seInstance.use({ definition: MyFullExtWithCallbacks, setupOptions: defSetupOptions, @@ -373,7 +429,7 @@ describe('Source Editor Instance', () => { }); it('passes correct arguments to callback fns when un-using an extension', () => { - seInstance = new Instance(); + seInstance = new SourceEditorInstance(); const extension = seInstance.use({ definition: MyFullExtWithCallbacks, setupOptions: defSetupOptions, diff --git a/spec/frontend/editor/source_editor_markdown_ext_spec.js b/spec/frontend/editor/source_editor_markdown_ext_spec.js index 245c6c28d31..4a53f870f6d 100644 --- a/spec/frontend/editor/source_editor_markdown_ext_spec.js +++ b/spec/frontend/editor/source_editor_markdown_ext_spec.js @@ -57,7 +57,7 @@ describe('Markdown Extension for Source Editor', () => { blobPath: markdownPath, blobContent: text, }); - editor.use(new EditorMarkdownExtension({ instance, previewMarkdownPath })); + instance.use(new EditorMarkdownExtension({ instance, previewMarkdownPath })); panelSpy = jest.spyOn(EditorMarkdownExtension, 'togglePreviewPanel'); }); diff --git a/spec/frontend/editor/source_editor_spec.js b/spec/frontend/editor/source_editor_spec.js index d87d373c952..f1b887b2dc1 100644 --- a/spec/frontend/editor/source_editor_spec.js +++ b/spec/frontend/editor/source_editor_spec.js @@ -1,6 +1,5 @@ /* eslint-disable max-classes-per-file */ import { editor as monacoEditor, languages as monacoLanguages } from 'monaco-editor'; -import waitForPromises from 'helpers/wait_for_promises'; import { SOURCE_EDITOR_INSTANCE_ERROR_NO_EL, URI_PREFIX, @@ -531,105 +530,19 @@ describe('Base editor', () => { instance.use(FunctionExt); expect(instance.inst()).toEqual(editor.instances[0]); }); - }); - describe('extensions as an instance parameter', () => { - let editorExtensionSpy; - const instanceConstructor = (extensions = []) => { - return editor.createInstance({ - el: editorEl, - 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; + it('emits the EDITOR_READY_EVENT event after setting up the instance', () => { + jest.spyOn(monacoEditor, 'create').mockImplementation(() => { + return { + setModel: jest.fn(), + onDidDispose: jest.fn(), + }; }); + const eventSpy = jest.fn(); editorEl.addEventListener(EDITOR_READY_EVENT, eventSpy); - instance = instanceConstructor('foo, bar'); - await waitForPromises(); - expect(useSpy.mock.calls).toHaveLength(2); - expect(calls).toEqual(['use', 'use', 'event']); - }); - }); - - describe('multiple instances', () => { - let inst1; - let inst2; - let editorEl1; - let editorEl2; - - beforeEach(() => { - setFixtures('
'); - 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); + expect(eventSpy).not.toHaveBeenCalled(); + instance = editor.createInstance({ el: editorEl }); + expect(eventSpy).toHaveBeenCalled(); }); }); }); diff --git a/spec/models/clusters/agents/activity_event_spec.rb b/spec/models/clusters/agents/activity_event_spec.rb new file mode 100644 index 00000000000..18b9c82fa6a --- /dev/null +++ b/spec/models/clusters/agents/activity_event_spec.rb @@ -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 diff --git a/spec/services/clusters/agent_tokens/create_service_spec.rb b/spec/services/clusters/agent_tokens/create_service_spec.rb index 92629af06c8..dc7abd1504b 100644 --- a/spec/services/clusters/agent_tokens/create_service_spec.rb +++ b/spec/services/clusters/agent_tokens/create_service_spec.rb @@ -47,6 +47,21 @@ RSpec.describe Clusters::AgentTokens::CreateService do expect(token.name).to eq(params[:name]) 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 let(:params) { { agent_id: 'bad_id' } } @@ -54,6 +69,10 @@ RSpec.describe Clusters::AgentTokens::CreateService do expect { subject }.not_to change(::Clusters::AgentToken, :count) 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 expect(subject.status).to eq(:error) expect(subject.message).to eq(["Agent must exist", "Name can't be blank"]) diff --git a/spec/support/shared_examples/features/page_description_shared_examples.rb b/spec/support/shared_examples/features/page_description_shared_examples.rb index 81653220b4c..e3ea36633d1 100644 --- a/spec/support/shared_examples/features/page_description_shared_examples.rb +++ b/spec/support/shared_examples/features/page_description_shared_examples.rb @@ -7,3 +7,13 @@ RSpec.shared_examples 'page meta description' do |expected_description| 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