From fd4db442e4920440ec63347ed39d7ea45fc78de0 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Thu, 9 Jul 2026 11:29:44 +0200 Subject: [PATCH] Add Playwright component test quarantine flow --- .github/workflows/tests.yml | 1 + package-lock.json | 224 ++++++++++++++++++ package.json | 5 +- playwright.config.ts | 2 +- playwright.ct.config.ts | 53 +++++ playwright/index.html | 12 + playwright/index.ts | 25 ++ scripts/run-playwright-pr.mjs | 22 +- .../displays/buttons/DatePeriodSelector.vue | 12 +- tests/ct/date-period-selector.ct.spec.ts | 61 +++++ tests/ct/fixtures/plenoCt.ts | 14 ++ .../date-period-selector.smoke.spec.js | 0 tests/e2e/quarantine/quarantine.json | 10 + tests/unit/playwright-quarantine.spec.js | 43 ++++ 14 files changed, 469 insertions(+), 15 deletions(-) create mode 100644 playwright.ct.config.ts create mode 100644 playwright/index.html create mode 100644 playwright/index.ts create mode 100644 tests/ct/date-period-selector.ct.spec.ts create mode 100644 tests/ct/fixtures/plenoCt.ts rename tests/e2e/{ => quarantine/component-harness}/date-period-selector.smoke.spec.js (100%) create mode 100644 tests/e2e/quarantine/quarantine.json create mode 100644 tests/unit/playwright-quarantine.spec.js diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2c9870f8..a49f0970 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -486,6 +486,7 @@ jobs: install_dependencies ulimit -n 16384 || true if [[ "$MATRIX_SUITE" == "core" ]]; then + PLAYWRIGHT_ARTIFACT_NAMESPACE="${PLAYWRIGHT_ARTIFACT_NAMESPACE}-ct" npm run test:ct -- --project="$MATRIX_PROJECT" npx playwright test --grep @smoke --project="$MATRIX_PROJECT" npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT" else diff --git a/package-lock.json b/package-lock.json index 73ba1a77..f677dfb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,7 @@ "@creativebulma/bulma-divider": "^1.1.0", "@eslint/js": "^10.0.1", "@event-calendar/core": "^4.1.0", + "@playwright/experimental-ct-vue": "^1.58.2", "@playwright/test": "^1.58.2", "@types/event-calendar__core": "^3.7.0", "@vitejs/plugin-vue": "^6.0.5", @@ -4219,6 +4220,229 @@ "node": ">=14" } }, + "node_modules/@playwright/experimental-ct-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/experimental-ct-core/-/experimental-ct-core-1.58.2.tgz", + "integrity": "sha512-Imif9ggQp6YIblHAX6MvJuqDFrCGHYspoibxLP3+1soXp+1wBNuuSRajv0VWXzFbh//4l29I+xy2tpTgej0vEA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2", + "playwright-core": "1.58.2", + "vite": "^6.4.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/@playwright/experimental-ct-core/node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/@playwright/experimental-ct-vue": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/experimental-ct-vue/-/experimental-ct-vue-1.58.2.tgz", + "integrity": "sha512-joLgyXZOV7odSY3PrEkzNRqz6rdaf7eoyTus4deSAAD6u5qvWYgDZl2mDJYiivcE+jDns9SGiZQlkMmyDcjQpg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@playwright/experimental-ct-core": "1.58.2", + "@vitejs/plugin-vue": "^5.2.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/experimental-ct-vue/node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@playwright/experimental-ct-vue/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/@playwright/experimental-ct-vue/node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/@playwright/test": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", diff --git a/package.json b/package.json index fdf60065..31155c69 100644 --- a/package.json +++ b/package.json @@ -47,11 +47,13 @@ "test:e2e:live:public": "playwright test --config=playwright.live.config.ts --grep @public-live", "test:e2e:live:roles": "playwright test --config=playwright.live.config.ts --grep @role-live", "test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live", + "test:ct": "playwright test --config=playwright.ct.config.ts", + "test:ct:pr": "playwright test --config=playwright.ct.config.ts --project=chromium-desktop", "release:verify-upload": "node scripts/release/verify-upload.mjs", "release:update-server-version": "node scripts/release/update-server-version.mjs", "release:upload:lftp": "bash scripts/release/upload-dist-lftp.sh", "test:e2e:pos-mobile-live": "node -e \"const { spawnSync } = require('child_process'); const result = spawnSync('npx', ['playwright', 'test', 'tests/e2e/adminModulePosMobileOrderFlow.spec.ts', '--project=chromium-mobile'], { stdio: 'inherit', shell: true, env: { ...process.env, PLAYWRIGHT_LIVE: '1' } }); process.exit(result.status ?? 1);\"", - "test:all": "npm run test:unit && npm run test:e2e:pr", + "test:all": "npm run test:unit && npm run test:ct:pr && npm run test:e2e:pr", "twa:build": "bubblewrap build", "twa:update": "bubblewrap update", "mobile:sync": "npm run build && npx cap sync", @@ -128,6 +130,7 @@ "@creativebulma/bulma-divider": "^1.1.0", "@eslint/js": "^10.0.1", "@event-calendar/core": "^4.1.0", + "@playwright/experimental-ct-vue": "^1.58.2", "@playwright/test": "^1.58.2", "@types/event-calendar__core": "^3.7.0", "@vitejs/plugin-vue": "^6.0.5", diff --git a/playwright.config.ts b/playwright.config.ts index 5b553ccb..b03d66bd 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -40,7 +40,7 @@ function buildProject(name: string, browserName: "chromium" | "firefox" | "webki export default defineConfig({ testDir: "./tests/e2e", - testIgnore: ["**/release/**"], + testIgnore: ["**/release/**", "**/quarantine/**"], snapshotPathTemplate: "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}-win32{ext}", timeout: 60_000, fullyParallel: true, diff --git a/playwright.ct.config.ts b/playwright.ct.config.ts new file mode 100644 index 00000000..2257eb7b --- /dev/null +++ b/playwright.ct.config.ts @@ -0,0 +1,53 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig, devices } from "@playwright/experimental-ct-vue"; + +const projectRoot = fileURLToPath(new URL(".", import.meta.url)); +const artifactNamespace = (process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || "ct").trim(); +const artifactRoot = path.join("output", "playwright", artifactNamespace); +const reporterMode = (process.env.PLAYWRIGHT_REPORTER_MODE || "").trim(); +const reporter = + reporterMode === "line-html" + ? [["line"], ["html", { open: "never", outputFolder: path.join(artifactRoot, "report") }]] + : [["list"], ["html", { open: "never", outputFolder: path.join(artifactRoot, "report") }]]; +const configuredWorkers = Number(process.env.PLAYWRIGHT_WORKERS || 2); +const workers = Number.isFinite(configuredWorkers) && configuredWorkers > 0 ? configuredWorkers : 2; + +export default defineConfig({ + testDir: "./tests/ct", + timeout: 45_000, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers, + reporter, + outputDir: path.join(artifactRoot, "test-results"), + use: { + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + ctViteConfig: { + resolve: { + alias: { + "@": path.resolve(projectRoot, "src"), + }, + preserveSymlinks: true, + dedupe: ["vue", "vue-router", "vue-i18n", "@vueuse/core", "@vueuse/head", "@unhead/vue"], + }, + }, + }, + projects: [ + { + name: "chromium-desktop", + use: { + ...devices["Desktop Chrome"], + }, + }, + { + name: "chromium-mobile", + use: { + ...devices["Pixel 5"], + }, + }, + ], +}); diff --git a/playwright/index.html b/playwright/index.html new file mode 100644 index 00000000..acd7e6f1 --- /dev/null +++ b/playwright/index.html @@ -0,0 +1,12 @@ + + + + + + Pleno Component Test + + +
+ + + diff --git a/playwright/index.ts b/playwright/index.ts new file mode 100644 index 00000000..9d7f3100 --- /dev/null +++ b/playwright/index.ts @@ -0,0 +1,25 @@ +import { beforeMount } from "@playwright/experimental-ct-vue/hooks"; +import Buefy from "buefy"; +import "bulma/css/bulma.min.css"; +import "buefy/dist/css/buefy.css"; + +import i18n from "@/i18n"; + +type PlenoHooksConfig = { + locale?: string; +}; + +beforeMount(({ app, hooksConfig }) => { + const config = (hooksConfig || {}) as PlenoHooksConfig; + const locale = config.locale || "en"; + const globalLocale = i18n.global.locale as unknown; + + if (globalLocale && typeof globalLocale === "object" && "value" in globalLocale) { + (globalLocale as { value: string }).value = locale; + } else { + (i18n.global as unknown as { locale: string }).locale = locale; + } + + app.use(i18n); + app.use(Buefy); +}); diff --git a/scripts/run-playwright-pr.mjs b/scripts/run-playwright-pr.mjs index 7778163b..224ae2bc 100644 --- a/scripts/run-playwright-pr.mjs +++ b/scripts/run-playwright-pr.mjs @@ -2,7 +2,13 @@ import { execFile, spawn } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; -import { chromiumProjects, fallbackChangePatterns, prGrep, smokeGrep, sourceMappings } from "./playwright-pr-mapping.mjs"; +import { + chromiumProjects, + fallbackChangePatterns, + prGrep, + smokeGrep, + sourceMappings, +} from "./playwright-pr-mapping.mjs"; const execFileAsync = promisify(execFile); const workingDirectory = process.cwd(); @@ -245,7 +251,7 @@ function addSpec(selection, spec, projects) { } function isE2eSpec(file) { - return /^tests\/e2e\/.+\.spec\.(?:js|ts)$/u.test(file); + return /^tests\/e2e\/(?!quarantine\/).+\.spec\.(?:js|ts)$/u.test(file); } function shouldFallback(file) { @@ -361,7 +367,9 @@ async function runChangedSelection(selection) { if (selection.fallback) { console.log( - `[playwright-pr] Falling back to broader ${smokeGrep} coverage because these changed files were unmapped: ${selection.unmappedFiles.join(", ")}` + `[playwright-pr] Falling back to broader ${smokeGrep} coverage because these changed files were unmapped: ${selection.unmappedFiles.join( + ", " + )}` ); for (const project of projects) { const code = await runPlaywright({ @@ -386,7 +394,9 @@ async function runChangedSelection(selection) { if (selection.skippedDirectSpecFiles.length > 0) { console.log( - `[playwright-pr] Source mappings selected changed-area specs; direct E2E file edits are covered by mapped/core gates: ${selection.skippedDirectSpecFiles.join(", ")}` + `[playwright-pr] Source mappings selected changed-area specs; direct E2E file edits are covered by mapped/core gates: ${selection.skippedDirectSpecFiles.join( + ", " + )}` ); } @@ -466,7 +476,9 @@ async function main() { const changed = await getChangedFiles(); if (changed.unavailable) { - console.log(`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`); + console.log( + `[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.` + ); return; } diff --git a/src/components/displays/buttons/DatePeriodSelector.vue b/src/components/displays/buttons/DatePeriodSelector.vue index b3cb4052..7e887547 100644 --- a/src/components/displays/buttons/DatePeriodSelector.vue +++ b/src/components/displays/buttons/DatePeriodSelector.vue @@ -7,7 +7,7 @@ import { useI18n } from "vue-i18n"; import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { formatLocalDateOnly } from "@/services/dateOnly.js"; -import { buildRelativeDateShortcuts } from "@/services/relativeDateShortcuts.js"; +import { buildRelativeDateShortcuts, splitRelativeDateShortcuts } from "@/services/relativeDateShortcuts.js"; import BuefyDateField from "@/components/forms/BuefyDateField.vue"; import BuefyMonthField from "@/components/forms/BuefyMonthField.vue"; @@ -300,21 +300,17 @@ const shortcuts = computed(() => ( )); const relativeShortcutBuckets = computed(() => { - const primaryShortcutIds = new Set(props.primaryShortcutIds); const allShortcuts = buildRelativeDateShortcuts().filter((shortcut) => enabledShortcutIds.value.includes(shortcut.key)); const mapShortcut = (shortcut) => ({ id: shortcut.key, label: shortcut.label, getRange: shortcut.getRange, }); + const { primary, other } = splitRelativeDateShortcuts(allShortcuts, props.primaryShortcutIds); return { - primary: allShortcuts - .filter((shortcut) => primaryShortcutIds.has(shortcut.key)) - .map(mapShortcut), - other: allShortcuts - .filter((shortcut) => !primaryShortcutIds.has(shortcut.key)) - .map(mapShortcut), + primary: primary.map(mapShortcut), + other: other.map(mapShortcut), }; }); diff --git a/tests/ct/date-period-selector.ct.spec.ts b/tests/ct/date-period-selector.ct.spec.ts new file mode 100644 index 00000000..64f2a1ad --- /dev/null +++ b/tests/ct/date-period-selector.ct.spec.ts @@ -0,0 +1,61 @@ +import DatePeriodSelector from "@/components/displays/buttons/DatePeriodSelector.vue"; + +import { expect, test } from "./fixtures/plenoCt"; + +const selection = { + startDate: new Date("2026-05-01T00:00:00.000Z"), + endDate: new Date("2026-05-04T23:59:59.999Z"), +}; + +const mountDatePeriodSelector = async (mount, props = {}) => + mount(DatePeriodSelector, { + props: { + selection, + allowEmptySelection: true, + visibility: { + showMonthSelector: true, + showYearSelector: true, + showUpdateButton: false, + }, + onSelectionChange: () => {}, + ...props, + }, + hooksConfig: { + locale: "en", + }, + }); + +test.describe("DatePeriodSelector component", () => { + test("desktop other month action opens an inline month picker modal", async ({ mount, page }, testInfo) => { + test.skip(testInfo.project.name.includes("mobile"), "Desktop shortcut buttons are replaced by the mobile select."); + + await page.clock.setFixedTime(new Date("2026-05-04T10:00:00.000Z")); + + const component = await mountDatePeriodSelector(mount); + + await expect(component.getByTestId("date-period-shortcut-anytime")).toContainText(/anytime/i); + await component.getByTestId("date-period-other-dropdown-trigger").click(); + + const otherMonthOption = component.getByTestId("date-period-other-other_month"); + await expect(otherMonthOption).toBeVisible(); + await expect(otherMonthOption).toContainText(/other month/i); + + await otherMonthOption.click(); + + await expect(component.getByTestId("date-period-other-month-modal")).toBeVisible(); + await expect(component.getByTestId("date-period-other-month-picker")).toBeVisible(); + }); + + test("mobile shortcuts keep primary and other actions reachable without route boot", async ({ mount, page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.clock.setFixedTime(new Date("2026-05-04T10:00:00.000Z")); + + const component = await mountDatePeriodSelector(mount); + + const shortcutSelect = component.getByTestId("date-period-shortcuts"); + await expect(shortcutSelect.locator("option").filter({ hasText: /anytime/i })).toHaveCount(1); + + await component.getByTestId("date-period-other-dropdown-trigger").click(); + await expect(component.getByTestId("date-period-other-other_month")).toBeVisible(); + }); +}); diff --git a/tests/ct/fixtures/plenoCt.ts b/tests/ct/fixtures/plenoCt.ts new file mode 100644 index 00000000..0e366ddf --- /dev/null +++ b/tests/ct/fixtures/plenoCt.ts @@ -0,0 +1,14 @@ +import { expect, test as base } from "@playwright/experimental-ct-vue"; + +export const test = base.extend({ + page: async ({ page }, use) => { + await page.addStyleTag({ + content: + "#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }", + }); + + await use(page); + }, +}); + +export { expect }; diff --git a/tests/e2e/date-period-selector.smoke.spec.js b/tests/e2e/quarantine/component-harness/date-period-selector.smoke.spec.js similarity index 100% rename from tests/e2e/date-period-selector.smoke.spec.js rename to tests/e2e/quarantine/component-harness/date-period-selector.smoke.spec.js diff --git a/tests/e2e/quarantine/quarantine.json b/tests/e2e/quarantine/quarantine.json new file mode 100644 index 00000000..1b57de5d --- /dev/null +++ b/tests/e2e/quarantine/quarantine.json @@ -0,0 +1,10 @@ +[ + { + "originalPath": "tests/e2e/date-period-selector.smoke.spec.js", + "quarantinePath": "tests/e2e/quarantine/component-harness/date-period-selector.smoke.spec.js", + "cluster": "component-harness", + "reason": "The active spec exercises a dedicated DatePeriodSelector harness rather than route, auth, or backend integration behavior.", + "replacementCoverage": ["tests/unit/date-period-selector.spec.js", "tests/ct/date-period-selector.ct.spec.ts"], + "removalCriteria": "Remove after Playwright CT is installable in CI and the replacement CT plus unit coverage pass." + } +] diff --git a/tests/unit/playwright-quarantine.spec.js b/tests/unit/playwright-quarantine.spec.js new file mode 100644 index 00000000..505d7f31 --- /dev/null +++ b/tests/unit/playwright-quarantine.spec.js @@ -0,0 +1,43 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = process.cwd(); +const quarantineManifestPath = join(root, "tests/e2e/quarantine/quarantine.json"); + +function readQuarantineManifest() { + return JSON.parse(readFileSync(quarantineManifestPath, "utf8")); +} + +describe("Playwright E2E quarantine", () => { + it("keeps quarantined specs out of the active E2E tree while replacements are introduced", () => { + const manifest = readQuarantineManifest(); + + expect(Array.isArray(manifest)).toBe(true); + expect(manifest.length).toBeGreaterThan(0); + + for (const entry of manifest) { + expect(entry.originalPath).toMatch(/^tests\/e2e\/.+\.spec\.(?:js|ts)$/u); + expect(entry.quarantinePath).toMatch(/^tests\/e2e\/quarantine\/.+\.spec\.(?:js|ts)$/u); + expect(entry.reason).toEqual(expect.any(String)); + expect(entry.removalCriteria).toEqual(expect.any(String)); + expect(entry.replacementCoverage).toEqual(expect.arrayContaining([expect.stringMatching(/^tests\//u)])); + expect(existsSync(join(root, entry.originalPath))).toBe(false); + expect(existsSync(join(root, entry.quarantinePath))).toBe(true); + } + }); + + it("ignores quarantined specs in the active Playwright E2E config", () => { + const source = readFileSync(join(root, "playwright.config.ts"), "utf8"); + + expect(source).toContain('"**/quarantine/**"'); + }); + + it("keeps component coverage wired into local and CI gates", () => { + const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + const workflowSource = readFileSync(join(root, ".github/workflows/tests.yml"), "utf8"); + + expect(packageJson.scripts["test:all"]).toContain("npm run test:ct:pr"); + expect(workflowSource).toContain('npm run test:ct -- --project="$MATRIX_PROJECT"'); + }); +});