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);
}
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;
}
}

View file

@ -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);
},

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]
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!

View file

@ -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) }

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))
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

View file

@ -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 "

View file

@ -4,5 +4,5 @@ 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

View file

@ -4,5 +4,5 @@ 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

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;
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;

View file

@ -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"
}
```

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.
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)

View file

@ -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

View file

@ -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 ""

View file

@ -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

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,
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

View file

@ -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 {

View file

@ -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(

View file

@ -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,

View file

@ -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');
});

View file

@ -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('<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);
expect(eventSpy).not.toHaveBeenCalled();
instance = editor.createInstance({ el: editorEl });
expect(eventSpy).toHaveBeenCalled();
});
});
});

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])
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"])

View file

@ -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