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