Compare commits

...
Author SHA1 Message Date
Jeppe B 50794f56a1 Tighten session bootstrap heading assertion 2026-07-09 15:53:54 +02:00
Jeppe B 4e56191b7e Fix cron due i18n link target
Add locale glossary entries for the cron due label and link the source alias through terms.glossary.due so generated runtime links resolve across active locales.
2026-07-09 14:51:17 +02:00
Jeppe B 4404fb49d3 Fix cron i18n duplicate aliases
Link repeated cron labels through existing cron/shared i18n aliases and regenerate global-v2.json so i18n integrity checks pass.
2026-07-09 13:00:25 +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
33 changed files with 1332 additions and 41 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"),
+1
View File
@@ -259,6 +259,7 @@
"download": "download",
"du": "du",
"dubletter": "dubletter",
"due": "Forfalden",
"duration": "duration",
"e": "e",
"economic": "Economic",
+1
View File
@@ -328,6 +328,7 @@
"draft": "draft",
"drucken": "drucken",
"duplikate": "Duplikate",
"due": "Fällig",
"duration": "duration",
"durchgef": "durchgef",
"durchschn": "Durchschn",
+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'}",
+1
View File
@@ -288,6 +288,7 @@
"driver": "driver",
"du": "du",
"duplikater": "duplikater",
"due": "Forfalt",
"duration": "duration",
"e": "e",
"edge": "edge",
+1
View File
@@ -328,6 +328,7 @@
"du": "du",
"dubbletter": "dubbletter",
"duplicate": "duplicate",
"due": "Förfallen",
"duration": "duration",
"dygntvatt": "Dygntvätt",
"e": "e",
+1
View File
@@ -42,6 +42,7 @@
"download": "download",
"du": "du",
"dubletter": "dubletter",
"due": "Forfalden",
"duration": "duration"
}
}
+1
View File
@@ -48,6 +48,7 @@
"draft": "draft",
"drucken": "drucken",
"duplikate": "Duplikate",
"due": "Fällig",
"duration": "duration",
"durchgef": "durchgef",
"durchschn": "Durchschn"
@@ -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": "@:{'terms.glossary.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"
}
}
+1
View File
@@ -51,6 +51,7 @@
"driver": "driver",
"du": "du",
"duplikater": "duplikater",
"due": "Forfalt",
"duration": "duration"
}
}
+1
View File
@@ -57,6 +57,7 @@
"du": "du",
"dubbletter": "dubbletter",
"duplicate": "duplicate",
"due": "Förfallen",
"duration": "duration",
"dygntvatt": "Dygntvätt"
}
+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."
}
]
+1 -1
View File
@@ -80,7 +80,7 @@ test.describe("session bootstrap", () => {
releaseSession.resolve();
await expect(page.getByText("Bootstrap User")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/System Status|Systemstatus/)).toBeVisible();
await expect(page.getByRole("heading", { name: /System Status|Systemstatus/ })).toBeVisible();
await expect(page.getByText("403 Forbidden")).toHaveCount(0);
await expect(page.getByText("Unnamed")).toHaveCount(0);
});
+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"');
});
});