diff --git a/app/assets/javascripts/vue_shared/components/list_selector/group_item.stories.js b/app/assets/javascripts/vue_shared/components/list_selector/group_item.stories.js new file mode 100644 index 00000000000..2348b52334c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/list_selector/group_item.stories.js @@ -0,0 +1,37 @@ +import GroupItem from './group_item.vue'; + +export default { + component: GroupItem, + title: 'vue_shared/list_selector/group_item', +}; + +const Template = (args, { argTypes }) => ({ + components: { GroupItem }, + props: Object.keys(argTypes), + template: '', +}); + +export const Default = Template.bind({}); +Default.args = { + data: { + id: '1', + fullName: 'Gitlab Org', + name: 'Gitlab Org', + }, + canDelete: false, +}; + +export const DeletableGroup = Template.bind({}); +DeletableGroup.args = { + ...Default.args, + canDelete: true, +}; + +export const HiddenGroups = Template.bind({}); +HiddenGroups.args = { + ...Default.args, + data: { + ...Default.args.data, + type: 'hidden_groups', + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/list_selector/group_item.vue b/app/assets/javascripts/vue_shared/components/list_selector/group_item.vue index f155149209e..d17606003bf 100644 --- a/app/assets/javascripts/vue_shared/components/list_selector/group_item.vue +++ b/app/assets/javascripts/vue_shared/components/list_selector/group_item.vue @@ -44,9 +44,9 @@ export default { diff --git a/db/docs/batched_background_migrations/create_compliance_standards_adherence.yml b/db/docs/batched_background_migrations/create_compliance_standards_adherence.yml index 0f0d47b39bc..8970593d42f 100644 --- a/db/docs/batched_background_migrations/create_compliance_standards_adherence.yml +++ b/db/docs/batched_background_migrations/create_compliance_standards_adherence.yml @@ -1,7 +1,9 @@ --- migration_job_name: CreateComplianceStandardsAdherence -description: This migration creates 'project_compliance_standards_adherence' table for existing projects +description: This migration creates 'project_compliance_standards_adherence' table + for existing projects feature_category: compliance_management introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/129941 queued_migration_version: 20230818142801 milestone: '16.4' +finalized_by: '20240707231815' diff --git a/db/docs/geo_repositories_changed_events.yml b/db/docs/deleted_tables/geo_repositories_changed_events.yml similarity index 77% rename from db/docs/geo_repositories_changed_events.yml rename to db/docs/deleted_tables/geo_repositories_changed_events.yml index 738779586c4..c15a42fd27e 100644 --- a/db/docs/geo_repositories_changed_events.yml +++ b/db/docs/deleted_tables/geo_repositories_changed_events.yml @@ -8,4 +8,5 @@ description: Geo event for when the repositories for selective sync of a specifi introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/commit/312bc703a4619b87ba2ac4e59623e7747a24502c milestone: '9.5' gitlab_schema: gitlab_main_cell -sharding_key_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/464364 # this table will be deleted soon +removed_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/158660 +removed_in_milestone: '17.2' diff --git a/db/post_migrate/20240707231815_finalize_create_compliance_standards_adherence.rb b/db/post_migrate/20240707231815_finalize_create_compliance_standards_adherence.rb new file mode 100644 index 00000000000..aa85209e3c3 --- /dev/null +++ b/db/post_migrate/20240707231815_finalize_create_compliance_standards_adherence.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class FinalizeCreateComplianceStandardsAdherence < Gitlab::Database::Migration[2.2] + milestone '17.2' + + disable_ddl_transaction! + + restrict_gitlab_migration gitlab_schema: :gitlab_main + + def up + ensure_batched_background_migration_is_finished( + job_class_name: 'CreateComplianceStandardsAdherence', + table_name: :projects, + column_name: :id, + job_arguments: [], + finalize: true + ) + end + + def down; end +end diff --git a/db/post_migrate/20240708175722_remove_foreign_key_geo_event_log.rb b/db/post_migrate/20240708175722_remove_foreign_key_geo_event_log.rb new file mode 100644 index 00000000000..21cde24181d --- /dev/null +++ b/db/post_migrate/20240708175722_remove_foreign_key_geo_event_log.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class RemoveForeignKeyGeoEventLog < Gitlab::Database::Migration[2.2] + milestone '17.2' + + disable_ddl_transaction! + + FROM_TABLE = :geo_event_log + TO_TABLE = :geo_repositories_changed_events + + def up + with_lock_retries do + remove_foreign_key( + FROM_TABLE, + TO_TABLE, + column: :repositories_changed_event_id, + if_exists: true + ) + end + end + + def down + add_concurrent_foreign_key( + FROM_TABLE, + TO_TABLE, + name: :fk_4a99ebfd60, + column: :repositories_changed_event_id, + on_delete: :cascade + ) + end +end diff --git a/db/post_migrate/20240708180142_drop_geo_repositories_changed_events.rb b/db/post_migrate/20240708180142_drop_geo_repositories_changed_events.rb new file mode 100644 index 00000000000..49cc0d74c52 --- /dev/null +++ b/db/post_migrate/20240708180142_drop_geo_repositories_changed_events.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class DropGeoRepositoriesChangedEvents < Gitlab::Database::Migration[2.2] + milestone '17.2' + + disable_ddl_transaction! + + def up + drop_table :geo_repositories_changed_events + end + + def down + create_table :geo_repositories_changed_events do |t| + t.integer :geo_node_id, + index: { name: 'index_geo_repositories_changed_events_on_geo_node_id' }, + null: false + end + + add_concurrent_foreign_key( + :geo_repositories_changed_events, + :geo_nodes, + name: :fk_rails_75ec0fefcc, + column: :geo_node_id, + on_delete: :cascade + ) + end +end diff --git a/db/schema_migrations/20240707231815 b/db/schema_migrations/20240707231815 new file mode 100644 index 00000000000..1fecad6119a --- /dev/null +++ b/db/schema_migrations/20240707231815 @@ -0,0 +1 @@ +274a6acea5c5940bd76616e7bd9c6e7c1d58f1ca133f56c00b930bdf094d85a3 \ No newline at end of file diff --git a/db/schema_migrations/20240708175722 b/db/schema_migrations/20240708175722 new file mode 100644 index 00000000000..5c1ae672c94 --- /dev/null +++ b/db/schema_migrations/20240708175722 @@ -0,0 +1 @@ +a500e7c16e274cd083b865dea60cafe1bd9618f8f5767f492947b7b43d7e9284 \ No newline at end of file diff --git a/db/schema_migrations/20240708180142 b/db/schema_migrations/20240708180142 new file mode 100644 index 00000000000..a961e7f9f72 --- /dev/null +++ b/db/schema_migrations/20240708180142 @@ -0,0 +1 @@ +23a8c778820f8a25cdbe7c864d3fa9b667a11d6b61e3d9e6ad2f5e7b32da93b9 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 207d55179eb..56e5accef61 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -10695,20 +10695,6 @@ CREATE SEQUENCE geo_nodes_id_seq ALTER SEQUENCE geo_nodes_id_seq OWNED BY geo_nodes.id; -CREATE TABLE geo_repositories_changed_events ( - id bigint NOT NULL, - geo_node_id integer NOT NULL -); - -CREATE SEQUENCE geo_repositories_changed_events_id_seq - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE geo_repositories_changed_events_id_seq OWNED BY geo_repositories_changed_events.id; - CREATE TABLE ghost_user_migrations ( id bigint NOT NULL, user_id bigint NOT NULL, @@ -20864,8 +20850,6 @@ ALTER TABLE ONLY geo_node_statuses ALTER COLUMN id SET DEFAULT nextval('geo_node ALTER TABLE ONLY geo_nodes ALTER COLUMN id SET DEFAULT nextval('geo_nodes_id_seq'::regclass); -ALTER TABLE ONLY geo_repositories_changed_events ALTER COLUMN id SET DEFAULT nextval('geo_repositories_changed_events_id_seq'::regclass); - ALTER TABLE ONLY ghost_user_migrations ALTER COLUMN id SET DEFAULT nextval('ghost_user_migrations_id_seq'::regclass); ALTER TABLE ONLY gitlab_subscription_histories ALTER COLUMN id SET DEFAULT nextval('gitlab_subscription_histories_id_seq'::regclass); @@ -23018,9 +23002,6 @@ ALTER TABLE ONLY geo_node_statuses ALTER TABLE ONLY geo_nodes ADD CONSTRAINT geo_nodes_pkey PRIMARY KEY (id); -ALTER TABLE ONLY geo_repositories_changed_events - ADD CONSTRAINT geo_repositories_changed_events_pkey PRIMARY KEY (id); - ALTER TABLE ONLY ghost_user_migrations ADD CONSTRAINT ghost_user_migrations_pkey PRIMARY KEY (id); @@ -27289,8 +27270,6 @@ CREATE UNIQUE INDEX index_geo_nodes_on_name ON geo_nodes USING btree (name); CREATE INDEX index_geo_nodes_on_primary ON geo_nodes USING btree ("primary"); -CREATE INDEX index_geo_repositories_changed_events_on_geo_node_id ON geo_repositories_changed_events USING btree (geo_node_id); - CREATE INDEX index_ghost_user_migrations_on_consume_after_id ON ghost_user_migrations USING btree (consume_after, id); CREATE UNIQUE INDEX index_ghost_user_migrations_on_user_id ON ghost_user_migrations USING btree (user_id); @@ -32254,9 +32233,6 @@ ALTER TABLE ONLY releases ALTER TABLE ONLY workspace_variables ADD CONSTRAINT fk_494e093520 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; -ALTER TABLE ONLY geo_event_log - ADD CONSTRAINT fk_4a99ebfd60 FOREIGN KEY (repositories_changed_event_id) REFERENCES geo_repositories_changed_events(id) ON DELETE CASCADE; - ALTER TABLE ONLY user_namespace_callouts ADD CONSTRAINT fk_4b1257f385 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; @@ -34237,9 +34213,6 @@ ALTER TABLE ONLY release_links ALTER TABLE ONLY milestone_releases ADD CONSTRAINT fk_rails_754f27dbfa FOREIGN KEY (release_id) REFERENCES releases(id) ON DELETE CASCADE; -ALTER TABLE ONLY geo_repositories_changed_events - ADD CONSTRAINT fk_rails_75ec0fefcc FOREIGN KEY (geo_node_id) REFERENCES geo_nodes(id) ON DELETE CASCADE; - ALTER TABLE ONLY resource_label_events ADD CONSTRAINT fk_rails_75efb0a653 FOREIGN KEY (epic_id) REFERENCES epics(id) ON DELETE CASCADE; diff --git a/doc/administration/packages/container_registry_metadata_database.md b/doc/administration/packages/container_registry_metadata_database.md index c2e630876c5..9fb22e99f22 100644 --- a/doc/administration/packages/container_registry_metadata_database.md +++ b/doc/administration/packages/container_registry_metadata_database.md @@ -551,3 +551,51 @@ If either of these values is set to `on`, you must disable it: 1. Restart your Postgres server to apply these settings. 1. Try to [apply schema migrations](#apply-schema-migrations) again, if applicable. 1. Restart the registry `sudo gitlab-ctl restart registry`. + +### Error: `cannot import all repositories while the tags table has entries` + +If you try to [migrate existing registries](#existing-registries) and encounter the following error: + +```shell +ERRO[0000] cannot import all repositories while the tags table has entries, you must truncate the table manually before retrying, +see https://docs.gitlab.com/ee/administration/packages/container_registry_metadata_database.html#troubleshooting +common_blobs=true dry_run=false error="tags table is not empty" +``` + +This error happens when there are existing entries in the `tags` table of the registry database, +which can happen if you: + +- Attempted the [one step migration](#one-step-migration) and encountered errors. +- Attempted the [three-step migration](#three-step-migration) process and encountered errors. +- Stopped the migration process on purpose. +- Tried to run the migration again after any of the above. +- Ran the migration against the wrong configuration file. + +To resolve this issue, you must delete the existing entries in the tags table. +You must truncate the table manually on your PostgreSQL instance: + +1. Edit `/etc/gitlab/gitlab.rb` and ensure the metadata database is **disabled**: + + ```ruby + registry['database'] = { + 'enabled' => false, + 'host' => 'localhost', + 'port' => 5432, + 'user' => 'registry-database-user', + 'password' => 'registry-database-password', + 'dbname' => 'registry-database-name', + 'sslmode' => 'require', # See the PostgreSQL documentation for additional information https://www.postgresql.org/docs/current/libpq-ssl.html. + 'sslcert' => '/path/to/cert.pem', + 'sslkey' => '/path/to/private.key', + 'sslrootcert' => '/path/to/ca.pem' + } + ``` + +1. Connect to your registry database using a PostgreSQL client. +1. Truncate the `tags` table to remove all existing entries: + + ```sql + TRUNCATE TABLE tags RESTART IDENTITY CASCADE; + ``` + +1. After truncating the `tags` table, try running the migration process again. diff --git a/doc/development/testing_guide/frontend_testing.md b/doc/development/testing_guide/frontend_testing.md index 97493ed2e96..31a72910446 100644 --- a/doc/development/testing_guide/frontend_testing.md +++ b/doc/development/testing_guide/frontend_testing.md @@ -769,6 +769,54 @@ beforeEach(() => { }); ``` +## Console warnings and errors in tests + +Unexpected console warnings and errors are indicative of problems in our production code. +We want our test environment to be strict, so your tests should fail when unexpected +`console.error` or `console.warn` calls are made. + +### Ignoring console messages from watcher + +Since there's a lot of code outside of our control, there are some console messages that +are ignored by default and will **not** fail a test if used. This list of ignored +messages can be maintained where we call `setupConsoleWatcher`. Example: + +```javascript +setupConsoleWatcher({ + ignores: [ + ..., + // Any call to `console.error('Foo bar')` or `console.warn('Foo bar')` will be ignored by our console watcher. + 'Foo bar', + // Use regex to allow for flexible message matching. + /Lorem ipsum/, + ] +}); +``` + +If a specific test needs to ignore a specific message for a `describe` block, use the +`ignoreConsoleMessages` helper near the top of the `describe`. This automatically +calls `beforeAll` and `afterAll` to set up/teardown this set of ignored for the test +context. + +Use this sparingly and only if absolutely necessary for test maintainability. Example: + +```javascript +import { ignoreConsoleMessages } from 'helpers/console_watcher'; + +describe('foos/components/foo.vue', () => { + describe('when blooped', () => { + // Will not fail a test if `console.warn('Lorem ipsum')` is called + ignoreConsoleMessages([ + /^Lorem ipsum/ + ]); + }); + + describe('default', () => { + // Will fail a test if `console.warn('Lorem ipsum')` is called + }); +}); +``` + ## Factories TBU diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ea25ed9c85b..8d11d72078d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -46490,12 +46490,21 @@ msgstr "" msgid "ScanExecutionPolicy|Add new condition" msgstr "" +msgid "ScanExecutionPolicy|Are you sure you want to create merge request fot this policy?" +msgstr "" + +msgid "ScanExecutionPolicy|Back to edit policy" +msgstr "" + msgid "ScanExecutionPolicy|Choose a method to execute code" msgstr "" msgid "ScanExecutionPolicy|Conditions" msgstr "" +msgid "ScanExecutionPolicy|Create merge request" +msgstr "" + msgid "ScanExecutionPolicy|Create new scan profile" msgstr "" @@ -46547,6 +46556,9 @@ msgstr "" msgid "ScanExecutionPolicy|Override" msgstr "" +msgid "ScanExecutionPolicy|Potential overload for infrastructure" +msgstr "" + msgid "ScanExecutionPolicy|Run %{scan} with the following options:" msgstr "" @@ -46610,6 +46622,9 @@ msgstr "" msgid "ScanExecutionPolicy|The file path can't be empty" msgstr "" +msgid "ScanExecutionPolicy|This scan execution policy will generate a large number of pipelines, which can have a significant performance impact. To reduce potential performance issues, consider creating separate policies for smaller subsets of projects." +msgstr "" + msgid "ScanExecutionPolicy|Triggers:" msgstr "" diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb index 3f8ce41ebeb..d3677c008a8 100644 --- a/spec/db/schema_spec.rb +++ b/spec/db/schema_spec.rb @@ -96,7 +96,7 @@ RSpec.describe 'Database schema', feature_category: :database do epics: %w[updated_by_id last_edited_by_id state_id], events: %w[target_id], forked_project_links: %w[forked_from_project_id], - geo_event_log: %w[hashed_storage_attachments_event_id], + geo_event_log: %w[hashed_storage_attachments_event_id repositories_changed_event_id], geo_node_statuses: %w[last_event_id cursor_last_event_id], geo_nodes: %w[oauth_application_id], geo_repository_deleted_events: %w[project_id], diff --git a/spec/frontend/__helpers__/console_watcher.js b/spec/frontend/__helpers__/console_watcher.js new file mode 100644 index 00000000000..d1105364e21 --- /dev/null +++ b/spec/frontend/__helpers__/console_watcher.js @@ -0,0 +1,244 @@ +const METHODS_TO_WATCH = ['error', 'warn']; + +const matchesStringOrRegex = (target, strOrRegex) => { + if (typeof strOrRegex === 'string') { + return target === strOrRegex; + } + + // why: We can't just check `instanceof RegExp` for some reason. I think it happens when values cross the Jest test sandbox into the outer environment. + // Please see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/145065#note_1788386920 + if ('test' in strOrRegex) { + return strOrRegex.test(target); + } + + throw new Error(`Unexpected value to match (${strOrRegex}). Expected string or RegExp.`); +}; + +export const throwErrorFromCalls = (consoleCalls) => { + const consoleCallsList = consoleCalls + .map(({ method, args }, idx) => `\n[${idx + 1}] ${method}: ${args}\n`) + .join('') + .split('\n') + .map((x) => `\t${x}`) + .join('\n'); + + throw new Error( + `Unexpected calls to console (${consoleCalls.length}) with:\n${consoleCallsList}\n`, + ); +}; + +class ConsoleWatcher { + /** + * Reference to the console instance that we are watching and overriding. + * + * @type {Console} + */ + #console; + + /** + * Array of RegExp's or string's that will be used to ignore certain calls. + * These are applied to only the message received by the `ConsoleWatcher`, regardless + * of whether it was a `console.warn` or `console.error`. + * + * @type {(RegExp | string)[]} + */ + #ignores; + + /** + * List of console method calls that were collected. This can include ignored consoles. + * We don't filter out ignores until we `getCalls`. + * + * @type {{method: string, args: unknown[]}[]} + */ + #calls; + + /** + * @type {{ error: Function, warn: Function }} Reference to the original Console methods + */ + #original; + + /** + * Flag for whether to use the legacy behavior of throwing immediately + * + * @type {boolean} + */ + #shouldThrowImmediately; + + constructor(console, { ignores = [], shouldThrowImmediately = false } = {}) { + this.#console = console; + this.#ignores = ignores; + this.#calls = []; + this.#original = {}; + this.#shouldThrowImmediately = shouldThrowImmediately; + + METHODS_TO_WATCH.forEach((method) => { + const originalFn = console[method]; + + this.#original[method] = originalFn; + + Object.assign(console, { + [method]: (...args) => { + this.#handleCall(method, args); + }, + }); + }); + } + + dispose() { + Object.entries(this.#original).forEach(([key, fn]) => { + Object.assign(this.#console, { [key]: fn }); + }); + } + + getIgnores() { + return this.#ignores; + } + + setIgnores(ignores) { + this.#ignores = ignores; + } + + setShouldThrowImmediately(value) { + this.#shouldThrowImmediately = value; + } + + shouldThrowImmediately() { + return this.#shouldThrowImmediately; + } + + forgetCalls() { + this.#calls = []; + } + + getCalls() { + return this.#calls.filter((call) => !this.#shouldIgnore(call)); + } + + #shouldIgnore({ args }) { + const argsAsStr = args.map(String).join(); + + return this.#ignores.some((ignoreMatcher) => matchesStringOrRegex(argsAsStr, ignoreMatcher)); + } + + #handleCall(method, args) { + const call = { method, args }; + + if (this.#shouldThrowImmediately && !this.#shouldIgnore(call)) { + throwErrorFromCalls([call]); + return; + } + + this.#calls.push(call); + + this.#original[method](...args); + } +} + +/** + * @param {CustomEnvironment} environment - Jest environment to attach the globals to + * @param {Console} console - the instnace of Console to setup watchers. + * @param {Object} options - optional options to use when setting up the console watcher. + * @param {(RegExp | string)[]} options.ignores - list of console messages to ignore. + * @param {boolean} options.shouldThrowImmediately - flag for whether we do the legacy behavior of throwing immediately. + * @returns + */ +export const setupConsoleWatcher = (environment, console, options) => { + if (environment.global.jestConsoleWatcher) { + throw new Error('jestConsoleWatcher already exists'); + } + + const consoleWatcher = new ConsoleWatcher(console, options); + + // eslint-disable-next-line no-param-reassign + environment.global.jestConsoleWatcher = consoleWatcher; + + return consoleWatcher; +}; + +export const forgetConsoleCalls = () => global.jestConsoleWatcher?.forgetCalls(); + +export const getConsoleCalls = () => global.jestConsoleWatcher?.getCalls() || []; + +/** + * Flags whether or the current `describe` should adopt the legacy test behavior of throwing immediately on `console.warn` or `console.error` + * + * Example: + * + * ```javascript + * describe('Foo', () => { + * useConsoleWatcherThrowsImmediately(); + * + * describe('bar', () => { + * useConsoleWatcherThrowsImmediately(false); + * + * // These tests **will not** throw immediately if `console.warn` or `console.error` is called. + * }); + * + * describe('zed', () => { + * // These tests **will** throw immediately if `console.warn` or `console.error` is called. + * }) + * }); + * ``` + * + * @param {boolean} val - True if the consoleWatcher should throw immediately on a console method call + * @deprecated This only exists to support legacy tests that depend on this erroneous test behavior + */ +export const useConsoleWatcherThrowsImmediately = (val = true) => { + let origLegacy; + + beforeAll(() => { + origLegacy = global.jestConsoleWatcher.shouldThrowImmediately(); + + global.jestConsoleWatcher.setShouldThrowImmediately(val); + }); + + afterAll(() => { + global.jestConsoleWatcher.setShouldThrowImmediately(origLegacy); + }); +}; + +/** + * Sets up the console watcher to ignore the given messages for the current `describe` block. + * + * Example: + * + * ```javascript + * describe('Foo', () => { + * ignoreConsoleMessages([ + * 'Hello world', + * /The field .* is not okay/, + * ]); + * + * it('works', () => { + * // Passes :) + * console.error('Hello world'); + * console.warn('The field FOO is not okay'); + * + * // Fail :( + * console.error('Hello world, strings are compared strictly.'); + * }); + * }); + * ``` + * + * @param {(string | RegExp)[]} ignores - Array of console messages to ignore for the current `describe` block. + */ +export const ignoreConsoleMessages = (ignores) => { + if (!Array.isArray(ignores)) { + throw new Error('Expected ignoreConsoleMessages to receive an Array of strings or RegExp'); + } + + let origIgnores; + + beforeAll(() => { + origIgnores = global.jestConsoleWatcher.getIgnores(); + + global.jestConsoleWatcher.setIgnores(origIgnores.concat(ignores)); + }); + + afterAll(() => { + global.jestConsoleWatcher.setIgnores(origIgnores); + }); +}; + +export const ignoreVueConsoleWarnings = () => + ignoreConsoleMessages([/^\[Vue warn\]: Missing required prop/, /^\[Vue warn\]: Invalid prop/]); diff --git a/spec/frontend/__helpers__/console_watcher_spec.js b/spec/frontend/__helpers__/console_watcher_spec.js new file mode 100644 index 00000000000..73ce11cc12d --- /dev/null +++ b/spec/frontend/__helpers__/console_watcher_spec.js @@ -0,0 +1,175 @@ +import { + setupConsoleWatcher, + throwErrorFromCalls, + forgetConsoleCalls, + getConsoleCalls, + ignoreConsoleMessages, + // eslint-disable-next-line import/no-deprecated + useConsoleWatcherThrowsImmediately, +} from './console_watcher'; + +const TEST_IGNORED_MESSAGE = 'Full message to ignore.'; +const TEST_IGNORED_REGEX_MESSAGE = 'Part of this message matches partial ignore.'; + +describe('__helpers__/console_watcher', () => { + let testEnvironment; + let testConsole; + let testConsoleOriginalFn; + let consoleWatcher; + + const callConsoleMethods = () => { + testConsole.log('Hello world log'); + testConsole.info('Hello world info'); + testConsole.info(TEST_IGNORED_MESSAGE); + testConsole.warn(TEST_IGNORED_REGEX_MESSAGE); + testConsole.warn('Hello world warn'); + testConsole.error('Hello world error'); + testConsole.error(TEST_IGNORED_MESSAGE); + }; + + // note: To test the beforeAll/afterAll behavior in some parts of console_watcher, we need to have our setup + // use beforeAll/afterAll instead of beforeEach/afterEach. + beforeAll(() => { + testEnvironment = { global: {} }; + testConsole = { + log: (...args) => testConsoleOriginalFn('log', ...args), + info: (...args) => testConsoleOriginalFn('info', ...args), + warn: (...args) => testConsoleOriginalFn('warn', ...args), + error: (...args) => testConsoleOriginalFn('error', ...args), + }; + Object.defineProperty(global, 'jestConsoleWatcher', { + get() { + return testEnvironment.global.jestConsoleWatcher; + }, + }); + }); + + beforeEach(() => { + // why: Let's make sure we have a fresh spy for every test + testConsoleOriginalFn = jest.fn(); + }); + + afterEach(() => { + // why: We need to forget calls or else our main test_setup will pick up on console calls and throw an error + forgetConsoleCalls(); + }); + + describe('throwErrorFromCalls', () => { + it('throws error with message containing calls', () => { + const calls = [ + { method: 'error', args: ['Hello world', 2, 'Lorem\nIpsum\nDolar\nSit'] }, + { method: 'info', args: [] }, + { method: 'warn', args: ['Hello world', 'something bad happened'] }, + ]; + + expect(() => throwErrorFromCalls(calls)).toThrowErrorMatchingInlineSnapshot(` +"Unexpected calls to console (3) with: + + [1] error: Hello world,2,Lorem + Ipsum + Dolar + Sit + + [2] info: + + [3] warn: Hello world,something bad happened + +" +`); + }); + }); + + describe('setupConsoleWatcher', () => { + beforeAll(() => { + testEnvironment = { global: {} }; + consoleWatcher = setupConsoleWatcher(testEnvironment, testConsole, { + ignores: ['Full message to ignore.', /partial ignore/], + }); + }); + + afterAll(() => { + consoleWatcher.dispose(); + }); + + describe.each(['warn', 'error'])('with %s', (method) => { + it('with unexpected message, calls original console method', () => { + testConsole[method]('BOOM!'); + + expect(testConsoleOriginalFn).toHaveBeenCalledTimes(1); + expect(testConsoleOriginalFn).toHaveBeenCalledWith(method, 'BOOM!'); + }); + + it('with ignored message, calls original console method', () => { + testConsole[method](TEST_IGNORED_MESSAGE); + + expect(testConsoleOriginalFn).toHaveBeenCalledTimes(1); + expect(testConsoleOriginalFn).toHaveBeenCalledWith(method, TEST_IGNORED_MESSAGE); + }); + }); + + describe('with ignoreConsoleMessages', () => { + ignoreConsoleMessages([/Hello world .*/]); + + it('adds to ignored messages only for describe block', () => { + callConsoleMethods(); + + expect(getConsoleCalls()).toEqual([]); + }); + }); + + describe('with useConsoleWatcherThrowsImmediately', () => { + // eslint-disable-next-line import/no-deprecated + useConsoleWatcherThrowsImmediately(); + + it('throws when non ignored message', () => { + expect(callConsoleMethods).toThrow(); + }); + }); + + it('with getConsoleCalls, only returns non ignored ones', () => { + expect(getConsoleCalls()).toEqual([]); + + callConsoleMethods(); + + expect(getConsoleCalls()).toEqual([ + { method: 'warn', args: ['Hello world warn'] }, + { method: 'error', args: ['Hello world error'] }, + ]); + }); + + it('with forgetConsoleCalls, clears out calls', () => { + callConsoleMethods(); + forgetConsoleCalls(); + + expect(getConsoleCalls()).toEqual([]); + }); + }); + + describe('setupConsoleWatcher with shouldThrowImmediately', () => { + beforeAll(() => { + testEnvironment = { global: {} }; + consoleWatcher = setupConsoleWatcher(testEnvironment, testConsole, { + ignores: ['Full message to ignore.', /partial ignore/], + shouldThrowImmediately: true, + }); + }); + + afterAll(() => { + consoleWatcher.dispose(); + }); + + it('does not throw on ignored call', () => { + expect(() => testConsole.error(TEST_IGNORED_MESSAGE)).not.toThrow(); + }); + + it('throws when call is not ignored', () => { + expect(() => testConsole.error('BLOW UP!')).toThrowErrorMatchingInlineSnapshot(` +"Unexpected calls to console (1) with: + + [1] error: BLOW UP! + +" +`); + }); + }); +}); diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js index 38214531379..40f2be06e4c 100644 --- a/spec/frontend/environment.js +++ b/spec/frontend/environment.js @@ -8,6 +8,7 @@ const { } = require('./__helpers__/fake_date/fake_date'); const { TEST_HOST } = require('./__helpers__/test_constants'); const { createGon } = require('./__helpers__/gon_helper'); +const { setupConsoleWatcher } = require('./__helpers__/console_watcher'); class CustomEnvironment extends TestEnvironment { constructor({ globalConfig, projectConfig }, context) { @@ -18,35 +19,17 @@ class CustomEnvironment extends TestEnvironment { // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39496#note_503084332 setGlobalDateToFakeDate(); - const { error: originalErrorFn } = context.console; - Object.assign(context.console, { - error(...args) { - const firstError = args?.[0]; - if ( - typeof firstError === 'string' && - ['[Vue warn]: Missing required prop', '[Vue warn]: Invalid prop'].some((line) => - firstError.startsWith(line), - ) - ) { - originalErrorFn.apply(context.console, args); - return; - } - - throw new ErrorWithStack( - `Unexpected call of console.error() with:\n\n${args.join(', ')}`, - this.error, - ); - }, - - warn(...args) { - if (args?.[0]?.includes('The updateQuery callback for fetchMore is deprecated')) { - return; - } - throw new ErrorWithStack( - `Unexpected call of console.warn() with:\n\n${args.join(', ')}`, - this.warn, - ); - }, + this.jestConsoleWatcher = setupConsoleWatcher(this, context.console, { + ignores: [ + /The updateQuery callback for fetchMore is deprecated/, + // TODO: Remove this and replace with localized calls to `ignoreVueConsoleWarnings` + // https://gitlab.com/gitlab-org/gitlab/-/issues/396779#note_1788506238 + /^\[Vue warn\]: Missing required prop/, + /^\[Vue warn\]: Invalid prop/, + ], + // TODO: Remove this and replace with localized calls to `useConsoleWatcherThrowsImmediately` + // https://gitlab.com/gitlab-org/gitlab/-/issues/396779#note_1788506238 + shouldThrowImmediately: true, }); const { IS_EE } = projectConfig.testEnvironmentOptions; @@ -123,6 +106,8 @@ class CustomEnvironment extends TestEnvironment { // Reset `Date` so that Jest can report timing accurately *roll eyes*... setGlobalDateToRealDate(); + this.jestConsoleWatcher.dispose(); + // eslint-disable-next-line no-restricted-syntax await new Promise(setImmediate); diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js index d3d3e5c8c72..61caa114f2b 100644 --- a/spec/frontend/test_setup.js +++ b/spec/frontend/test_setup.js @@ -4,6 +4,7 @@ import { setImmediate } from 'timers'; import Dexie from 'dexie'; import { IDBKeyRange, IDBFactory } from 'fake-indexeddb'; import 'helpers/shared_test_setup'; +import { forgetConsoleCalls, getConsoleCalls, throwErrorFromCalls } from 'helpers/console_watcher'; const indexedDB = new IDBFactory(); @@ -19,6 +20,15 @@ afterEach(() => }), ); +afterEach(() => { + const consoleCalls = getConsoleCalls(); + forgetConsoleCalls(); + + if (consoleCalls.length) { + throwErrorFromCalls(consoleCalls); + } +}); + afterEach(async () => { const dbs = await indexedDB.databases();