Compare commits

..
Author SHA1 Message Date
Jeppe B a6b64a4a83 Link duplicate cron i18n labels 2026-07-09 12:36:31 +02:00
Jeppe B fd4db442e4 Add Playwright component test quarantine flow 2026-07-09 11:29:44 +02:00
Jeppe B 416f392108 Classify superuser cron Playwright spec 2026-07-09 11:26:59 +02:00
Jeppe B dbe9f6b0b2 Redirect QR signups to kundeoprettelse
Redirect legacy QR signup routes for new customers and drivers to the shared /kundeoprettelse customer creation page.

Validated with focused eslint and QR Playwright coverage for mobile and desktop.
2026-07-09 11:19:30 +02:00
Jeppe B 18f06f7a63 Format POS visual e2e spec 2026-07-09 11:19:23 +02:00
Jeppe B fb75ddc3e9 Format superuser cron e2e spec 2026-07-09 11:15:29 +02:00
Jeppe B 1326a40033 Add superuser cron operations UI 2026-07-09 11:12:02 +02:00
24 changed files with 1323 additions and 40 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);
});
+1
View File
@@ -99,6 +99,7 @@ export const ownedFilesByRole = {
"self-serve-studio-flow.spec.js",
"session-bootstrap.spec.ts",
"superuser-bookings.spec.ts",
"superuser-cron.spec.ts",
"superuser-customer-complaints.spec.ts",
"superuser-customers-mass-import.spec.ts",
"superuser-department-branding.spec.js",
+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),
};
});
@@ -463,6 +463,17 @@ const items = computed<NavigationItemProps[]>(() => [
},
],
},
// System
{
label: t("cron.system_nav"),
type: "category",
children: [
{ label: t("system_status.title"), to: "/superuser" },
{ label: t("cron.nav"), to: "/superuser/system/cron" },
{ label: t("system_status.cards.database"), to: "/superuser/system/database" },
{ label: t("cron.replication"), to: "/superuser/system/replication" },
],
},
// Other
{
label: t("superuser.nav.other"),
+68
View File
@@ -2061,6 +2061,74 @@
"subtitle": "@:{'templates.generated.compat.connectivity.subtitle'}",
"title": "@:{'templates.generated.compat.connectivity.title'}"
},
"cron": {
"actions": {
"edit_interval": "Edit interval",
"refresh": "Refresh",
"run_now": "Run now",
"save": "Save",
"toggle": "Toggle cron task"
},
"duration": {
"ms": "{value} ms",
"seconds": "{value} s"
},
"empty": "No cron tasks found.",
"empty_value": "--",
"errors": {
"generic": "Unable to load cron tasks."
},
"history": {
"duration": "Duration",
"empty": "No cron runs found.",
"source": "Source",
"started": "Started",
"status": "Status",
"task": "Task",
"title": "Run history"
},
"interval": {
"hours": "Every {value} h",
"minutes": "Every {value} min",
"seconds": "Every {value} s"
},
"loading": "Loading cron tasks...",
"nav": "@:{'cron.title'}",
"replication": "Replication",
"states": {
"disabled": "Disabled",
"due_now": "Due now",
"enabled": "Enabled"
},
"status": {
"failed": "Failed",
"queued": "Queued",
"running": "Running",
"skipped": "Skipped",
"succeeded": "Succeeded",
"timed_out": "Timed out",
"unknown": "Unknown"
},
"subtitle": "Configure schedules, run tasks manually, and inspect recent cron execution.",
"summary": {
"due": "@:{'words.generated.due'}",
"enabled": "@:{'cron.states.enabled'}",
"next": "Next run",
"total": "Tasks"
},
"system_nav": "System",
"table": {
"actions": "Actions",
"enabled": "@:{'cron.states.enabled'}",
"estimate": "Estimate",
"module": "Module",
"next_run": "@:{'cron.summary.next'}",
"schedule": "Schedule",
"status": "@:{'cron.history.status'}",
"task": "@:{'cron.history.task'}"
},
"title": "Cron tasks"
},
"customer_creation": {
"customer": {
"benefit_credit": "@:{'templates.generated.compat.customer_creation.customer.benefit_credit'}",
@@ -0,0 +1,70 @@
{
"cron": {
"actions": {
"edit_interval": "Edit interval",
"refresh": "Refresh",
"run_now": "Run now",
"save": "Save",
"toggle": "Toggle cron task"
},
"duration": {
"ms": "{value} ms",
"seconds": "{value} s"
},
"empty": "No cron tasks found.",
"empty_value": "--",
"errors": {
"generic": "Unable to load cron tasks."
},
"history": {
"duration": "Duration",
"empty": "No cron runs found.",
"source": "Source",
"started": "Started",
"status": "Status",
"task": "Task",
"title": "Run history"
},
"interval": {
"hours": "Every {value} h",
"minutes": "Every {value} min",
"seconds": "Every {value} s"
},
"loading": "Loading cron tasks...",
"nav": "@:{'cron.title'}",
"replication": "Replication",
"states": {
"disabled": "Disabled",
"due_now": "Due now",
"enabled": "Enabled"
},
"status": {
"failed": "Failed",
"queued": "Queued",
"running": "Running",
"skipped": "Skipped",
"succeeded": "Succeeded",
"timed_out": "Timed out",
"unknown": "Unknown"
},
"subtitle": "Configure schedules, run tasks manually, and inspect recent cron execution.",
"summary": {
"due": "@:{'words.generated.due'}",
"enabled": "@:{'cron.states.enabled'}",
"next": "Next run",
"total": "Tasks"
},
"system_nav": "System",
"table": {
"actions": "Actions",
"enabled": "@:{'cron.states.enabled'}",
"estimate": "Estimate",
"module": "Module",
"next_run": "@:{'cron.summary.next'}",
"schedule": "Schedule",
"status": "@:{'cron.history.status'}",
"task": "@:{'cron.history.task'}"
},
"title": "Cron tasks"
}
}
+11 -6
View File
@@ -83,6 +83,7 @@ const MyOrders = lazyView('@/views/dashboards/userDashboard/orders/MyOrders.vue'
const NewVehicle = lazyView('@/views/dashboards/userDashboard/vehicles/NewVehicle.vue');
const MyBookings = lazyView('@/views/dashboards/userDashboard/bookings/MyBookings.vue');
const DatabaseOverview = lazyView('@/views/dashboards/superUserDashboard/system/DatabaseOverview.vue');
const CronOperations = lazyView('@/views/dashboards/superUserDashboard/system/CronOperations.vue');
const ReplicationManagement = lazyView('@/views/dashboards/superUserDashboard/system/ReplicationManagement.vue');
const DepartmentBookings = lazyView('@/views/dashboards/departmentDashboard/modules/bookings/DepartmentBookings.vue');
const Employee = lazyView('@/views/auth/Employee.vue');
@@ -179,9 +180,6 @@ const PrivacyPolicyPage = lazyView('@/views/pages/PrivacyPolicyPage.vue');
const PasswordResetPage = lazyView('@/views/pages/auth/PasswordResetPage.vue');
const PasswordResetConfirmationPage = lazyView('@/views/pages/auth/PasswordResetConfirmationPage.vue');
const SubuserCompleteRegistrationPage = lazyView('@/views/pages/auth/SubuserCompleteRegistrationPage.vue');
const GuestQRNewCustomer = lazyView('@/views/guest/qr/GuestQRNewCustomer.vue');
const GuestQrNewDriver = lazyView('@/views/guest/qr/GuestQRNewDriver.vue');
const GuestQRNewDriver = lazyView('@/views/guest/qr/GuestQRNewDriver.vue');
const GuestQRNewBooking = lazyView('@/views/guest/qr/GuestQRNewBooking.vue');
const CustomerCreationPage = lazyView('@/views/pages/auth/CustomerCreationPage.vue');
const Subusers = lazyView('@/views/dashboards/superUserDashboard/Subusers.vue');
@@ -238,6 +236,7 @@ export const router = createRouter({
{
name: 'customer-creation',
path: '/customer-creation',
alias: '/kundeoprettelse',
component: CustomerCreationPage,
meta: { template: 'with-header' }
},
@@ -398,16 +397,16 @@ export const router = createRouter({
meta: { middleware: authMiddleware }
},
{
// Used on QR codes
// Used on legacy QR codes
name: 'qrregister',
path: '/qr/new-customer',
component: GuestQRNewCustomer,
redirect: '/kundeoprettelse',
meta: { middleware: guestMiddleware }
},
{
name: 'qrdriver',
path: '/qr/new-driver',
component: GuestQRNewDriver,
redirect: '/kundeoprettelse',
meta: { middleware: guestMiddleware }
},
{
@@ -1121,6 +1120,12 @@ export const router = createRouter({
component: DatabaseOverview,
meta: { middleware: superUserMiddleware }
},
{
name: 'systemcron',
path: '/superuser/system/cron',
component: CronOperations,
meta: { middleware: superUserMiddleware }
},
{
name: 'systemreplication',
path: '/superuser/system/replication',
+22
View File
@@ -0,0 +1,22 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
export const listCronTasks = () =>
authenticatedRequest("/superuser/cron", "GET", {});
export const listCronRuns = ({ taskId = null, limit = 50 } = {}) =>
authenticatedRequest("/superuser/cron/runs", "GET", {
...(taskId ? { task_id: taskId } : {}),
limit,
});
export const runCronTask = (taskId, { force = false } = {}) =>
authenticatedRequest("/superuser/cron/run", "POST", {
task_id: taskId,
force,
});
export const updateCronTask = (taskId, payload) =>
authenticatedRequest("/superuser/cron/config", "PATCH", {
task_id: taskId,
...payload,
});
@@ -0,0 +1,466 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
import {
listCronRuns,
listCronTasks,
runCronTask,
updateCronTask,
} from "@/services/superuserCron.js";
const { t, locale } = useI18n();
const tasks = ref([]);
const runs = ref([]);
const summary = ref({ total: 0, enabled: 0, due: 0 });
const intervalDrafts = ref({});
const loading = ref(false);
const runsLoading = ref(false);
const errorMessage = ref("");
const busyTaskId = ref(null);
const savingTaskId = ref(null);
const canView = computed(() => SessionUser.canAccessSuperUser() || SessionUser.hasPermission("superuser_cron_view"));
const canRun = computed(() => SessionUser.canAccessSuperUser() || SessionUser.hasPermission("SUPERUSER_RUN_CRON"));
const canManage = computed(() => SessionUser.canAccessSuperUser() || SessionUser.hasPermission("superuser_cron_manage"));
const enabledTasks = computed(() => tasks.value.filter((task) => task.enabled));
const dueTasks = computed(() => enabledTasks.value.filter((task) => task.due));
const nextTask = computed(() => {
return [...enabledTasks.value]
.filter((task) => task.next_run_at)
.sort((left, right) => Date.parse(left.next_run_at) - Date.parse(right.next_run_at))[0] || null;
});
const responseData = (response, fallback) => response?.data?.data ?? fallback;
const parseError = (error) => error?.response?.data?.data?.message
|| error?.response?.data?.message
|| error?.message
|| t("cron.errors.generic");
function seedIntervalDrafts(nextTasks) {
const nextDrafts = {};
nextTasks.forEach((task) => {
nextDrafts[task.id] = Number(task?.schedule?.seconds || task?.default_schedule?.seconds || 60);
});
intervalDrafts.value = nextDrafts;
}
async function loadTasks() {
const data = responseData(await listCronTasks(), {});
const nextTasks = Array.isArray(data.tasks) ? data.tasks : [];
tasks.value = nextTasks;
summary.value = data.summary || { total: nextTasks.length, enabled: enabledTasks.value.length, due: dueTasks.value.length };
seedIntervalDrafts(nextTasks);
}
async function loadRuns() {
runsLoading.value = true;
try {
const data = responseData(await listCronRuns({ limit: 25 }), {});
runs.value = Array.isArray(data.runs) ? data.runs : [];
} finally {
runsLoading.value = false;
}
}
async function refreshCron() {
loading.value = true;
errorMessage.value = "";
try {
await Promise.all([loadTasks(), loadRuns()]);
} catch (error) {
errorMessage.value = parseError(error);
} finally {
loading.value = false;
}
}
async function runTask(task) {
busyTaskId.value = task.id;
errorMessage.value = "";
try {
const data = responseData(await runCronTask(task.id, { force: false }), null);
if (data) {
runs.value = [data, ...runs.value].slice(0, 25);
}
await loadTasks();
} catch (error) {
errorMessage.value = parseError(error);
} finally {
busyTaskId.value = null;
}
}
async function toggleTask(task) {
savingTaskId.value = task.id;
errorMessage.value = "";
try {
const data = responseData(await updateCronTask(task.id, { enabled: !task.enabled }), {});
if (Array.isArray(data.tasks)) {
tasks.value = data.tasks;
summary.value = data.summary || summary.value;
seedIntervalDrafts(data.tasks);
} else {
await loadTasks();
}
} catch (error) {
errorMessage.value = parseError(error);
} finally {
savingTaskId.value = null;
}
}
async function saveInterval(task) {
const seconds = Math.max(30, Number.parseInt(intervalDrafts.value[task.id], 10) || task?.schedule?.seconds || 60);
intervalDrafts.value = {
...intervalDrafts.value,
[task.id]: seconds,
};
savingTaskId.value = task.id;
errorMessage.value = "";
try {
const data = responseData(await updateCronTask(task.id, {
schedule: {
type: "interval",
seconds,
},
}), {});
if (Array.isArray(data.tasks)) {
tasks.value = data.tasks;
summary.value = data.summary || summary.value;
seedIntervalDrafts(data.tasks);
} else {
await loadTasks();
}
} catch (error) {
errorMessage.value = parseError(error);
} finally {
savingTaskId.value = null;
}
}
function formatDate(value) {
if (!value) {
return t("cron.empty_value");
}
const timestamp = Date.parse(String(value).replace(" ", "T"));
if (!Number.isFinite(timestamp)) {
return String(value);
}
return new Intl.DateTimeFormat(locale.value, {
dateStyle: "short",
timeStyle: "medium",
}).format(new Date(timestamp));
}
function formatDuration(milliseconds) {
const value = Number(milliseconds || 0);
if (!Number.isFinite(value) || value <= 0) {
return t("cron.empty_value");
}
if (value < 1000) {
return t("cron.duration.ms", { value });
}
return t("cron.duration.seconds", { value: (value / 1000).toFixed(1) });
}
function scheduleLabel(task) {
const seconds = Number(task?.schedule?.seconds || 0);
if (seconds <= 0) {
return t("cron.empty_value");
}
if (seconds % 3600 === 0) {
return t("cron.interval.hours", { value: seconds / 3600 });
}
if (seconds % 60 === 0) {
return t("cron.interval.minutes", { value: seconds / 60 });
}
return t("cron.interval.seconds", { value: seconds });
}
function statusLabel(status) {
const key = `cron.status.${status || "unknown"}`;
const translated = t(key);
return translated === key ? (status || t("cron.status.unknown")) : translated;
}
function statusClass(status) {
if (status === "succeeded") {
return "is-success";
}
if (status === "failed" || status === "timed_out") {
return "is-danger";
}
if (status === "running") {
return "is-info";
}
return "is-light";
}
onMounted(() => {
void refreshCron();
});
</script>
<template>
<RestrictedPageWrapper :hasPermission="canView">
<SuperUserDashboardNavigation />
<PageTitle :title="t('cron.title')" :subtitle="t('cron.subtitle')" />
<section class="cron-page" data-testid="superuser-cron-page">
<div class="cron-toolbar">
<div class="cron-stats" aria-live="polite">
<div class="cron-stat" data-testid="cron-total-tasks">
<span>{{ t("cron.summary.total") }}</span>
<strong>{{ summary.total ?? tasks.length }}</strong>
</div>
<div class="cron-stat" data-testid="cron-enabled-tasks">
<span>{{ t("cron.summary.enabled") }}</span>
<strong>{{ summary.enabled ?? enabledTasks.length }}</strong>
</div>
<div class="cron-stat" data-testid="cron-due-tasks">
<span>{{ t("cron.summary.due") }}</span>
<strong>{{ summary.due ?? dueTasks.length }}</strong>
</div>
<div class="cron-stat" data-testid="cron-next-task">
<span>{{ t("cron.summary.next") }}</span>
<strong>{{ nextTask ? formatDate(nextTask.next_run_at) : t("cron.empty_value") }}</strong>
</div>
</div>
<button class="button is-light" type="button" :class="{ 'is-loading': loading }" @click="refreshCron" data-testid="cron-refresh">
{{ t("cron.actions.refresh") }}
</button>
</div>
<div v-if="errorMessage" class="notification is-danger is-light" data-testid="cron-error">
{{ errorMessage }}
</div>
<div class="table-container cron-table-container">
<table class="table is-fullwidth is-hoverable cron-table" data-testid="cron-task-table">
<thead>
<tr>
<th>{{ t("cron.table.task") }}</th>
<th>{{ t("cron.table.module") }}</th>
<th>{{ t("cron.table.schedule") }}</th>
<th>{{ t("cron.table.next_run") }}</th>
<th>{{ t("cron.table.estimate") }}</th>
<th>{{ t("cron.table.status") }}</th>
<th>{{ t("cron.table.enabled") }}</th>
<th>{{ t("cron.table.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-if="loading && tasks.length === 0">
<td colspan="8">{{ t("cron.loading") }}</td>
</tr>
<tr v-else-if="tasks.length === 0">
<td colspan="8">{{ t("cron.empty") }}</td>
</tr>
<tr v-for="task in tasks" :key="task.id" :data-testid="`cron-task-row-${task.id}`">
<td>
<strong>{{ task.name }}</strong>
<small>{{ task.description }}</small>
</td>
<td>{{ task.module }}</td>
<td>
<div class="cron-interval-control">
<input
v-model.number="intervalDrafts[task.id]"
class="input is-small"
type="number"
min="30"
step="30"
:disabled="!canManage || savingTaskId === task.id"
:aria-label="t('cron.actions.edit_interval')"
:data-testid="`cron-task-interval-${task.id}`"
/>
<button
class="button is-small is-light"
type="button"
:disabled="!canManage || savingTaskId === task.id"
:class="{ 'is-loading': savingTaskId === task.id }"
@click="saveInterval(task)"
:data-testid="`cron-task-save-${task.id}`"
>
{{ t("cron.actions.save") }}
</button>
</div>
<small>{{ scheduleLabel(task) }}</small>
</td>
<td>
<span>{{ formatDate(task.next_run_at) }}</span>
<small v-if="task.due">{{ t("cron.states.due_now") }}</small>
</td>
<td>{{ formatDuration(task.estimated_duration_ms) }}</td>
<td>
<span class="tag" :class="statusClass(task.last_status)">
{{ statusLabel(task.last_status) }}
</span>
<small v-if="task.last_error">{{ task.last_error }}</small>
</td>
<td>
<label class="cron-toggle">
<input
type="checkbox"
:checked="task.enabled"
:disabled="!canManage || savingTaskId === task.id"
@change="toggleTask(task)"
:data-testid="`cron-task-toggle-${task.id}`"
/>
<span>{{ task.enabled ? t("cron.states.enabled") : t("cron.states.disabled") }}</span>
</label>
</td>
<td>
<button
class="button is-small is-primary"
type="button"
:disabled="!canRun || busyTaskId === task.id"
:class="{ 'is-loading': busyTaskId === task.id }"
@click="runTask(task)"
:data-testid="`cron-task-run-${task.id}`"
>
{{ t("cron.actions.run_now") }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
<section class="cron-history" data-testid="cron-run-history">
<div class="cron-section-heading">
<h2>{{ t("cron.history.title") }}</h2>
<button class="button is-small is-light" type="button" :class="{ 'is-loading': runsLoading }" @click="loadRuns">
{{ t("cron.actions.refresh") }}
</button>
</div>
<div class="table-container">
<table class="table is-fullwidth is-striped">
<thead>
<tr>
<th>{{ t("cron.history.task") }}</th>
<th>{{ t("cron.history.source") }}</th>
<th>{{ t("cron.history.status") }}</th>
<th>{{ t("cron.history.started") }}</th>
<th>{{ t("cron.history.duration") }}</th>
</tr>
</thead>
<tbody>
<tr v-if="runs.length === 0">
<td colspan="5">{{ t("cron.history.empty") }}</td>
</tr>
<tr v-for="run in runs" :key="run.id || `${run.task_id}-${run.started_at}`">
<td>{{ run.task_id }}</td>
<td>{{ run.source }}</td>
<td>
<span class="tag" :class="statusClass(run.status)">{{ statusLabel(run.status) }}</span>
</td>
<td>{{ formatDate(run.started_at) }}</td>
<td>{{ formatDuration(run.duration_ms) }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</section>
</RestrictedPageWrapper>
</template>
<style scoped>
.cron-page {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0 1rem 2rem;
}
.cron-toolbar,
.cron-section-heading {
align-items: center;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.cron-stats {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(4, minmax(8rem, 1fr));
width: 100%;
}
.cron-stat {
border: 1px solid #d7dde5;
border-radius: 6px;
padding: 0.75rem;
}
.cron-stat span,
.cron-table small {
color: #667085;
display: block;
font-size: 0.8rem;
}
.cron-stat strong {
display: block;
font-size: 1rem;
margin-top: 0.25rem;
}
.cron-table-container {
border: 1px solid #d7dde5;
border-radius: 6px;
}
.cron-table td {
vertical-align: middle;
}
.cron-interval-control {
align-items: center;
display: flex;
gap: 0.5rem;
min-width: 12rem;
}
.cron-interval-control .input {
max-width: 6rem;
}
.cron-toggle {
align-items: center;
display: inline-flex;
gap: 0.4rem;
white-space: nowrap;
}
.cron-history h2 {
font-size: 1.1rem;
font-weight: 700;
margin: 0;
}
@media (max-width: 900px) {
.cron-toolbar,
.cron-section-heading {
align-items: stretch;
flex-direction: column;
}
.cron-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.cron-stats {
grid-template-columns: 1fr;
}
}
</style>
+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 };
+11 -6
View File
@@ -1291,12 +1291,17 @@ test.describe("POS visuals", () => {
"webkit-mobile": { width: 326, height: 318 },
"firefox-mobile": { width: 337, height: 319 },
};
await expectClippedLocatorScreenshot(page, getVisibleTestId(page, "pos-order-panel-cart"), "pos-order-detail-add-items-mobile.png", {
...(cartClipByProject[testInfo.project.name] || { width: 337, height: 320 }),
maxDiffPixels: 3000,
maxDiffPixelRatio: 0.04,
expandViewportForClip: true,
});
await expectClippedLocatorScreenshot(
page,
getVisibleTestId(page, "pos-order-panel-cart"),
"pos-order-detail-add-items-mobile.png",
{
...(cartClipByProject[testInfo.project.name] || { width: 337, height: 320 }),
maxDiffPixels: 3000,
maxDiffPixelRatio: 0.04,
expandViewportForClip: true,
}
);
});
test("mobile step 3 snapshot", async ({ page }, testInfo) => {
+23 -13
View File
@@ -1,7 +1,15 @@
import { expect, test } from "@playwright/test";
import { mockApi } from "./support/network.js";
const controlSelectors = ["#customer_cvr", "#email", "#customer_phone", "#customer-register-button"];
const controlSelectors = [
"#customer_cvr",
"#email",
"#customer_phone",
"#customer-register-button",
"#driver_cvr",
"#driver_phone",
"#driver-register-button",
];
async function expectNoHorizontalOverflow(page) {
const dimensions = await page.evaluate(() => ({
@@ -26,23 +34,25 @@ async function expectControlWithinViewport(page, selector: string) {
expect(box!.height).toBeGreaterThanOrEqual(44);
}
test.describe("/qr/new-customer responsive layout", () => {
test.describe("customer creation QR redirects", () => {
test.beforeEach(async ({ page }) => {
await mockApi(page, { authenticated: false });
});
test("renders a mobile-safe customer registration layout", async ({ page }) => {
await page.goto("/qr/new-customer");
for (const path of ["/qr/new-customer", "/qr/new-driver"]) {
test(`${path} redirects to the shared customer creation page`, async ({ page }) => {
await page.goto(path);
await expect(page.locator("#qr-new-customer-title")).toBeVisible();
await expect(page.locator(".hero-card")).toBeVisible();
await expect(page.locator(".form-card")).toBeVisible();
await expect(page).toHaveURL(/\/kundeoprettelse$/);
await expect(page.locator(".hero-section")).toBeVisible();
await expect(page.locator(".truckwash-signup")).toBeVisible();
for (const selector of controlSelectors) {
await expect(page.locator(selector)).toBeVisible();
await expectControlWithinViewport(page, selector);
}
for (const selector of controlSelectors) {
await expect(page.locator(selector)).toBeVisible();
await expectControlWithinViewport(page, selector);
}
await expectNoHorizontalOverflow(page);
});
await expectNoHorizontalOverflow(page);
});
}
});
+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."
}
]
+171
View File
@@ -0,0 +1,171 @@
import { expect, test } from "@playwright/test";
import { apiPathPattern, mockApi, primeMockSession } from "./support/network.js";
const cronTasks = [
{
id: "economic.transfer_queue",
name: "Process e-conomic transfer queue",
description: "Processes pending e-conomic transfer queue jobs.",
module: "economic",
schedule: { type: "interval", seconds: 30 },
default_schedule: { type: "interval", seconds: 30 },
enabled: true,
default_enabled: true,
estimated_duration_ms: 2400,
next_run_at: "2026-07-09 12:01:00",
last_status: "succeeded",
due: true,
},
{
id: "system.sync_logs",
name: "Sync logs",
description: "Flushes application logs.",
module: "system",
schedule: { type: "interval", seconds: 300 },
default_schedule: { type: "interval", seconds: 300 },
enabled: false,
default_enabled: true,
estimated_duration_ms: 900,
next_run_at: "2026-07-09 12:05:00",
last_status: "failed",
last_error: "Redis unavailable",
due: false,
},
];
function cronListPayload(tasks = cronTasks) {
return {
success: true,
data: {
tasks,
summary: {
total: tasks.length,
enabled: tasks.filter((task) => task.enabled).length,
due: tasks.filter((task) => task.enabled && task.due).length,
},
},
meta: {},
includes: {},
};
}
test.describe("Superuser cron operations", () => {
test.beforeEach(async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "superuser_cron_view", "SUPERUSER_RUN_CRON", "superuser_cron_manage"],
});
await primeMockSession(page, { token: "superuser-cron-token", bootPath: null });
});
test("superusers can inspect, run, toggle, and reschedule cron tasks", async ({ page }) => {
const patchPayloads: Array<Record<string, unknown>> = [];
const runPayloads: Array<Record<string, unknown>> = [];
let currentTasks = cronTasks.map((task) => ({ ...task, schedule: { ...task.schedule } }));
await page.route(apiPathPattern("/superuser/cron"), async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(cronListPayload(currentTasks)),
});
});
await page.route(apiPathPattern("/superuser/cron/runs"), async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
data: {
runs: [
{
id: 41,
task_id: "economic.transfer_queue",
source: "automatic",
status: "succeeded",
started_at: "2026-07-09 12:00:00",
duration_ms: 2100,
},
],
},
meta: {},
includes: {},
}),
});
});
await page.route(apiPathPattern("/superuser/cron/run"), async (route) => {
runPayloads.push(route.request().postDataJSON() as Record<string, unknown>);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
data: {
id: 42,
task_id: "economic.transfer_queue",
source: "manual",
status: "succeeded",
started_at: "2026-07-09 12:02:00",
duration_ms: 1800,
},
meta: {},
includes: {},
}),
});
});
await page.route(apiPathPattern("/superuser/cron/config"), async (route) => {
const payload = route.request().postDataJSON() as Record<string, unknown>;
patchPayloads.push(payload);
currentTasks = currentTasks.map((task) => {
if (task.id !== payload.task_id) {
return task;
}
return {
...task,
...(typeof payload.enabled === "boolean" ? { enabled: payload.enabled } : {}),
...(payload.schedule ? { schedule: payload.schedule as { type: string; seconds: number } } : {}),
};
});
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(cronListPayload(currentTasks)),
});
});
await page.goto("/superuser/system/cron", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("superuser-cron-page")).toBeVisible();
await expect(page.getByTestId("cron-task-row-economic.transfer_queue")).toContainText(
"Process e-conomic transfer queue"
);
await expect(page.getByTestId("cron-total-tasks")).toContainText("2");
await Promise.all([
page.waitForResponse(
(response) => response.url().includes("/superuser/cron/run") && response.request().method() === "POST"
),
page.getByTestId("cron-task-run-economic.transfer_queue").click(),
]);
expect(runPayloads).toEqual([{ task_id: "economic.transfer_queue", force: false }]);
await expect(page.getByTestId("cron-run-history")).toContainText("manual");
await page.getByTestId("cron-task-toggle-system.sync_logs").click();
expect(patchPayloads.at(-1)).toMatchObject({ task_id: "system.sync_logs", enabled: true });
await page.getByTestId("cron-task-interval-economic.transfer_queue").fill("120");
await page.getByTestId("cron-task-save-economic.transfer_queue").click();
expect(patchPayloads.at(-1)).toMatchObject({
task_id: "economic.transfer_queue",
schedule: { type: "interval", seconds: 120 },
});
});
});
+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"');
});
});