Add Playwright component test quarantine flow

This commit is contained in:
Jeppe B
2026-07-09 11:29:44 +02:00
parent 416f392108
commit fd4db442e4
14 changed files with 469 additions and 15 deletions
+1
View File
@@ -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
+224
View File
@@ -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",
+4 -1
View File
@@ -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",
+1 -1
View File
@@ -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,
+53
View File
@@ -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"],
},
},
],
});
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pleno Component Test</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.ts"></script>
</body>
</html>
+25
View File
@@ -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);
});
+17 -5
View File
@@ -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;
}
@@ -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<Shortcut[]>(() => (
));
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),
};
});
+61
View File
@@ -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();
});
});
+14
View File
@@ -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 };
+10
View File
@@ -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."
}
]
+43
View File
@@ -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"');
});
});