diff --git a/.gitlab/ci/frontend.gitlab-ci.yml b/.gitlab/ci/frontend.gitlab-ci.yml
index ff46b572373d458430f48222b62e54920da5e73c..67cfb6f183b8f2fa362bb081535be30051c565d3 100644
--- a/.gitlab/ci/frontend.gitlab-ci.yml
+++ b/.gitlab/ci/frontend.gitlab-ci.yml
@@ -263,7 +263,7 @@ jest-build-cache:
     paths:
       - tmp/cache/jest/
   script:
-    - run_timed_command "yarn jest:ci:build-cache"
+    - run_timed_command "scripts/frontend/warm_jest_cache.mjs"
   variables:
     # Propagate exit code correctly. See https://gitlab.com/groups/gitlab-org/-/epics/6074.
     FF_USE_NEW_BASH_EVAL_STRATEGY: 'true'
@@ -312,7 +312,7 @@ jest:
   parallel: 11
   script:
     - if [[ -z "${CI_MERGE_REQUEST_IID}" ]] && ! [[ "$CI_COMMIT_BRANCH" =~ ^as-if-foss/ ]]; then export JEST_COVERAGE="--coverage"; fi
-    - run_timed_command "yarn jest:ci:without-fixtures $JEST_COVERAGE"
+    - run_timed_command "scripts/frontend/jest_ci.js $JEST_COVERAGE"
 
 jest-with-fixtures:
   extends:
@@ -327,7 +327,7 @@ jest-with-fixtures:
   parallel: 2
   script:
     - if [[ -z "${CI_MERGE_REQUEST_IID}" ]] && ! [[ "$CI_COMMIT_BRANCH" =~ ^as-if-foss/ ]]; then export JEST_COVERAGE="--coverage"; fi
-    - run_timed_command "yarn jest:ci:with-fixtures $JEST_COVERAGE"
+    - run_timed_command "scripts/frontend/jest_ci.js --fixtures $JEST_COVERAGE"
 
 jest vue3:
   extends:
@@ -363,7 +363,7 @@ jest vue3 mr:
       - junit_jest.xml
   parallel: 6
   script:
-    - run_timed_command "yarn jest:ci:vue3-mr:without-fixtures"
+    - run_timed_command "scripts/frontend/jest_ci.js --vue3"
   allow_failure: false
 
 jest-with-fixtures vue3 mr:
@@ -383,7 +383,7 @@ jest-with-fixtures vue3 mr:
       - junit_jest.xml
   parallel: 1
   script:
-    - run_timed_command "yarn jest:ci:vue3-mr:with-fixtures"
+    - run_timed_command "scripts/frontend/jest_ci.js --vue3 --fixtures"
 
 jest predictive:
   extends:
@@ -393,7 +393,7 @@ jest predictive:
     - !reference [jest, needs]
     - "detect-tests"
   script:
-    - if [[ -s "$RSPEC_CHANGED_FILES_PATH" ]] || [[ -s "$RSPEC_MATCHING_JS_FILES_PATH" ]]; then run_timed_command "yarn jest:ci:predictive-without-fixtures"; fi
+    - if [[ -s "$RSPEC_CHANGED_FILES_PATH" ]] || [[ -s "$RSPEC_MATCHING_JS_FILES_PATH" ]]; then run_timed_command "scripts/frontend/jest_ci.js --predictive"; fi
   parallel: 4
 
 jest-with-fixtures predictive:
@@ -404,7 +404,7 @@ jest-with-fixtures predictive:
     - !reference [jest-with-fixtures, needs]
     - "detect-tests"
   script:
-    - if [[ -s "$RSPEC_CHANGED_FILES_PATH" ]] || [[ -s "$RSPEC_MATCHING_JS_FILES_PATH" ]]; then run_timed_command "yarn jest:ci:predictive-with-fixtures"; fi
+    - if [[ -s "$RSPEC_CHANGED_FILES_PATH" ]] || [[ -s "$RSPEC_MATCHING_JS_FILES_PATH" ]]; then run_timed_command "scripts/frontend/jest_ci.js --predictive --fixtures"; fi
 
 jest vue3 predictive:
   extends:
@@ -414,7 +414,7 @@ jest vue3 predictive:
     - !reference [jest vue3 mr, needs]
     - "detect-tests"
   script:
-    - if [[ -s "$RSPEC_CHANGED_FILES_PATH" ]] || [[ -s "$RSPEC_MATCHING_JS_FILES_PATH" ]]; then run_timed_command "yarn jest:ci:vue3-mr:predictive-without-fixtures"; fi
+    - if [[ -s "$RSPEC_CHANGED_FILES_PATH" ]] || [[ -s "$RSPEC_MATCHING_JS_FILES_PATH" ]]; then run_timed_command "scripts/frontend/jest_ci.js --vue3 --predictive"; fi
 
 jest-with-fixtures vue3 predictive:
   extends:
@@ -424,7 +424,7 @@ jest-with-fixtures vue3 predictive:
     - !reference [jest-with-fixtures vue3 mr, needs]
     - "detect-tests"
   script:
-    - if [[ -s "$RSPEC_CHANGED_FILES_PATH" ]] || [[ -s "$RSPEC_MATCHING_JS_FILES_PATH" ]]; then run_timed_command "yarn jest:ci:vue3-mr:predictive-with-fixtures"; fi
+    - if [[ -s "$RSPEC_CHANGED_FILES_PATH" ]] || [[ -s "$RSPEC_MATCHING_JS_FILES_PATH" ]]; then run_timed_command "scripts/frontend/jest_ci.js --vue3 --predictive --fixtures"; fi
   allow_failure: false
 
 jest vue3 check quarantined predictive:
diff --git a/package.json b/package.json
index b853df16dfcafb8f89fd7710dc43b293de1d5268..94dca631d05a32f7dad59d12e0e179938e56075a 100644
--- a/package.json
+++ b/package.json
@@ -14,16 +14,6 @@
     "tailwindcss:build": "node scripts/frontend/tailwindcss.mjs",
     "jest": "jest --config jest.config.js",
     "jest-debug": "node --inspect-brk node_modules/.bin/jest --runInBand",
-    "jest:ci:build-cache": "./scripts/frontend/warm_jest_cache.mjs",
-    "jest:ci": "jest --config jest.config.js --ci --coverage --testSequencer ./scripts/frontend/parallel_ci_sequencer.js --shard \"${CI_NODE_INDEX:-1}/${CI_NODE_TOTAL:-1}\" --logHeapUsage",
-    "jest:ci:without-fixtures": "jest --config jest.config.js --ci --testSequencer ./scripts/frontend/fixture_ci_sequencer.js --shard \"${CI_NODE_INDEX:-1}/${CI_NODE_TOTAL:-1}\" --logHeapUsage",
-    "jest:ci:with-fixtures": "JEST_FIXTURE_JOBS_ONLY=1 jest --config jest.config.js --ci --testSequencer ./scripts/frontend/fixture_ci_sequencer.js --shard \"${CI_NODE_INDEX:-1}/${CI_NODE_TOTAL:-1}\" --logHeapUsage",
-    "jest:ci:predictive-without-fixtures": "jest --config jest.config.js --ci --findRelatedTests $(cat $RSPEC_CHANGED_FILES_PATH) $(cat $RSPEC_MATCHING_JS_FILES_PATH) --passWithNoTests --testSequencer ./scripts/frontend/fixture_ci_sequencer.js --shard \"${CI_NODE_INDEX:-1}/${CI_NODE_TOTAL:-1}\" --logHeapUsage",
-    "jest:ci:predictive-with-fixtures": "JEST_FIXTURE_JOBS_ONLY=1 jest --config jest.config.js --ci --findRelatedTests $(cat $RSPEC_CHANGED_FILES_PATH) $(cat $RSPEC_MATCHING_JS_FILES_PATH) --passWithNoTests --testSequencer ./scripts/frontend/fixture_ci_sequencer.js --shard \"${CI_NODE_INDEX:-1}/${CI_NODE_TOTAL:-1}\" --logHeapUsage",
-    "jest:ci:vue3-mr:without-fixtures": "jest --config jest.config.js --ci --testSequencer ./scripts/frontend/skip_specs_broken_in_vue_compat_fixture_ci_sequencer.js --shard \"${CI_NODE_INDEX:-1}/${CI_NODE_TOTAL:-1}\" --logHeapUsage",
-    "jest:ci:vue3-mr:with-fixtures": "JEST_FIXTURE_JOBS_ONLY=1 jest --config jest.config.js --ci --testSequencer ./scripts/frontend/skip_specs_broken_in_vue_compat_fixture_ci_sequencer.js --shard \"${CI_NODE_INDEX:-1}/${CI_NODE_TOTAL:-1}\" --logHeapUsage",
-    "jest:ci:vue3-mr:predictive-without-fixtures": "jest --config jest.config.js --ci --findRelatedTests $(cat $RSPEC_CHANGED_FILES_PATH) $(cat $RSPEC_MATCHING_JS_FILES_PATH) --passWithNoTests --testSequencer ./scripts/frontend/skip_specs_broken_in_vue_compat_fixture_ci_sequencer.js --shard \"${CI_NODE_INDEX:-1}/${CI_NODE_TOTAL:-1}\" --logHeapUsage",
-    "jest:ci:vue3-mr:predictive-with-fixtures": "JEST_FIXTURE_JOBS_ONLY=1 jest --config jest.config.js --ci --findRelatedTests $(cat $RSPEC_CHANGED_FILES_PATH) $(cat $RSPEC_MATCHING_JS_FILES_PATH) --passWithNoTests --testSequencer ./scripts/frontend/skip_specs_broken_in_vue_compat_fixture_ci_sequencer.js --shard \"${CI_NODE_INDEX:-1}/${CI_NODE_TOTAL:-1}\" --logHeapUsage",
     "jest:contract": "PACT_DO_NOT_TRACK=true jest --config jest.config.contract.js --runInBand",
     "jest:integration": "jest --config jest.config.integration.js",
     "jest:scripts": "jest --config jest.config.scripts.js",
diff --git a/scripts/frontend/jest_ci.js b/scripts/frontend/jest_ci.js
new file mode 100755
index 0000000000000000000000000000000000000000..fe853063682eff8531a64d61ce0e2fdeaf7d8658
--- /dev/null
+++ b/scripts/frontend/jest_ci.js
@@ -0,0 +1,151 @@
+#!/usr/bin/env node
+
+const { spawnSync } = require('node:child_process');
+const { readFileSync } = require('node:fs');
+const defaultChalk = require('chalk');
+const program = require('commander');
+
+const IS_CI = Boolean(process.env.CI);
+
+const VUE_3_TESTING_DOCS_URL =
+  // eslint-disable-next-line no-restricted-syntax
+  'https://docs.gitlab.com/ee/development/testing_guide/testing_vue3.html';
+const VUE_3_TESTING_EPIC = 'https://gitlab.com/groups/gitlab-org/-/epics/11740';
+
+// Force basic color output in CI
+const chalk = new defaultChalk.constructor({ level: IS_CI ? 1 : undefined });
+
+function showVue3Help() {
+  console.warn(' ');
+  console.warn(
+    chalk.green.bold('Having trouble getting tests to pass under Vue 3? These resources may help:'),
+  );
+  console.warn(' ');
+  console.warn(` - ${chalk.green('Vue 3 testing documentation:')} ${VUE_3_TESTING_DOCS_URL}`);
+  console.warn(` - ${chalk.green('Epic for fixing tests under Vue 3:')} w${VUE_3_TESTING_EPIC}`);
+}
+
+function parseArgumentsAndEnvironment() {
+  program
+    .usage('[options]')
+    .description(`Runs Jest under CI.`)
+    .option('--vue3', 'Run tests under Vue 3 (via @vue/compat). The default is to run under Vue 2.')
+    .option(
+      '--predictive',
+      'Only run specs affected by the changes in the merge request. The default is to run all specs.',
+    )
+    .option(
+      '--fixtures',
+      'Only run specs which rely on generated fixtures. The default is to only run specs which do not rely on generated fixtures.',
+    )
+    .option('--coverage', 'Tell Jest to generate coverage. The default is not to.')
+    .parse(process.argv);
+
+  if (!IS_CI) {
+    console.warn('This script is intended to run in CI only.');
+    if (program.vue3) showVue3Help();
+    process.exit(1);
+  }
+
+  const changedFiles = [];
+  if (program.predictive) {
+    const { RSPEC_MATCHING_JS_FILES_PATH, RSPEC_CHANGED_FILES_PATH } = process.env;
+
+    for (const [name, path] of Object.entries({
+      RSPEC_CHANGED_FILES_PATH,
+      RSPEC_MATCHING_JS_FILES_PATH,
+    })) {
+      try {
+        const contents = readFileSync(path, { encoding: 'UTF-8' });
+        changedFiles.push(...contents.split(/\s+/).filter(Boolean));
+      } catch (error) {
+        console.warn(
+          `Failed to read from path ${path} given by environment variable ${name}`,
+          error,
+        );
+      }
+    }
+
+    if (!changedFiles) {
+      console.warn('No changed files detected; will not run Jest.');
+      process.exit(0);
+    }
+  }
+
+  return {
+    vue3: program.vue3,
+    predictive: program.predictive,
+    fixtures: program.fixtures,
+    coverage: program.coverage,
+    nodeIndex: process.env.CI_NODE_INDEX ?? '1',
+    nodeTotal: process.env.CI_NODE_TOTAL ?? '1',
+    changedFiles,
+  };
+}
+
+function loggedSpawnSync(command, args, options) {
+  const env = ['JEST_FIXTURE_JOBS_ONLY', 'VUE_VERSION']
+    .map((name) => `${name}=${options.env[name] ?? ''}`)
+    .join(' ');
+  const fullCommand = `${env} ${command} ${args.join(' ')}`;
+  console.warn(`Running command:\n${fullCommand}`);
+  const childProcess = spawnSync(command, args, options);
+  console.warn(`Command ${fullCommand} exited with status ${childProcess.status}`);
+  return childProcess;
+}
+
+function runJest({ vue3, predictive, fixtures, coverage, nodeIndex, nodeTotal, changedFiles }) {
+  const commonArguments = [
+    '--config',
+    'jest.config.js',
+    '--ci',
+    `--shard=${nodeIndex}/${nodeTotal}`,
+    '--logHeapUsage',
+  ];
+
+  const sequencerArguments = [
+    '--testSequencer',
+    vue3
+      ? './scripts/frontend/skip_specs_broken_in_vue_compat_fixture_ci_sequencer.js'
+      : './scripts/frontend/fixture_ci_sequencer.js',
+  ];
+
+  const predictiveArguments = predictive
+    ? ['--passWithNoTests', '--findRelatedTests', ...changedFiles]
+    : [];
+
+  const coverageArguments = coverage ? ['--coverage'] : [];
+
+  const childProcess = loggedSpawnSync(
+    'node_modules/.bin/jest',
+    [...commonArguments, ...sequencerArguments, ...predictiveArguments, ...coverageArguments],
+    {
+      stdio: 'inherit',
+      env: {
+        ...process.env,
+        ...(fixtures ? { JEST_FIXTURE_JOBS_ONLY: '1' } : {}),
+        ...(vue3 ? { VUE_VERSION: '3' } : {}),
+      },
+    },
+  );
+
+  return childProcess;
+}
+
+function main() {
+  const config = parseArgumentsAndEnvironment();
+  const childProcess = runJest(config);
+
+  if (childProcess.status !== 0 && config.vue3) {
+    showVue3Help();
+  }
+
+  return childProcess.status;
+}
+
+try {
+  process.exitCode = main();
+} catch (error) {
+  process.exitCode = 1;
+  console.error(error);
+}