feat(cms): recover drafts and failed imports with verified UI workflows
No files matched your search
@@ -0,0 +1,10 @@
|
||||
# Isolated UI regression checks
|
||||
|
||||
Run `pnpm test:ui` after `pnpm install --frozen-lockfile` and `pnpm exec playwright install chromium`.
|
||||
The fixture server bundles real CMS components with local HTTP/server-action fixtures. It does not use the database or production credentials. Production smoke tests remain in the separate `pnpm test:e2e` command.
|
||||
|
||||
The suite covers accessible settings switches, news editor/preview, support-table filters, import history, attachment recovery and news/event recovery. Axe checks WCAG A/AA rules. The sandboxed preview cannot execute Axe; its accessible name and keyboard exit are checked separately. Automated checks do not replace a full accessibility audit.
|
||||
|
||||
Visual references are committed under `__screenshots__/<platform>/<viewport>`. Normal runs compare references and must not update them. For an intentional UI change, use `pnpm test:ui:update`, inspect every changed PNG and commit only accepted references. Keep the Playwright browser version aligned with the lockfile. Linux references must be captured on the Linux runner; Windows references are not interchangeable.
|
||||
|
||||
Fixtures contain synthetic data only. Do not add production requests, environment secrets, ignored local documents or database access to tests. CI retains test reports and failures as the `ui-results` artifact.
|
||||
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,109 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const attachmentId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
const file = {
|
||||
name: "fixture_chair.nitro",
|
||||
mimeType: "application/octet-stream",
|
||||
buffer: Buffer.from("isolated upload fixture"),
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/admin/attachment-harness");
|
||||
await page
|
||||
.getByText("Attach original file and resume", { exact: true })
|
||||
.click();
|
||||
});
|
||||
|
||||
test("attachment upload and double-click resume send one retry and refresh history", async ({
|
||||
page,
|
||||
}) => {
|
||||
let retries = 0;
|
||||
await page.route("**/api/admin/studio/import-attachment", async (route) => {
|
||||
expect(route.request().method()).toBe("POST");
|
||||
expect(route.request().postData()).toContain('name="classname"');
|
||||
expect(route.request().postData()).toContain("fixture_chair");
|
||||
expect(route.request().postData()).toContain(
|
||||
'filename="fixture_chair.nitro"',
|
||||
);
|
||||
await route.fulfill({ json: { ok: true, attachmentId } });
|
||||
});
|
||||
await page.route("**/api/admin/studio/import-jobs", async (route) => {
|
||||
retries++;
|
||||
expect(route.request().method()).toBe("PATCH");
|
||||
expect(route.request().postDataJSON()).toEqual({
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
action: "retry",
|
||||
attachments: { fixture_chair: attachmentId },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
await route.fulfill({ json: { ok: true } });
|
||||
});
|
||||
await page.getByLabel("Choose the original .nitro file").setInputFiles(file);
|
||||
await expect(
|
||||
page.getByText("Matching original file attached", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole("button", {
|
||||
name: "Resume failed imports with this file",
|
||||
exact: true,
|
||||
})
|
||||
.dblclick();
|
||||
await expect(page.getByLabel("Resumed count")).toHaveText("1");
|
||||
expect(retries).toBe(1);
|
||||
await expect(
|
||||
page.getByRole("button", {
|
||||
name: "Resume failed imports with this file",
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
name: "owner rejection",
|
||||
status: 403,
|
||||
code: "forbidden",
|
||||
message: "Could not attach the file.",
|
||||
},
|
||||
{
|
||||
name: "mismatched furniture",
|
||||
status: 400,
|
||||
code: "mismatchedFile",
|
||||
message: "This file belongs to different furniture.",
|
||||
},
|
||||
{
|
||||
name: "network failure",
|
||||
status: 0,
|
||||
code: "",
|
||||
message: "Network connection failed",
|
||||
},
|
||||
])
|
||||
test(`attachment ${scenario.name} shows readable error without enabling resume`, async ({
|
||||
page,
|
||||
}) => {
|
||||
let retries = 0;
|
||||
await page.route("**/api/admin/studio/import-jobs", async (route) => {
|
||||
retries++;
|
||||
await route.fulfill({ json: { ok: true } });
|
||||
});
|
||||
await page.route("**/api/admin/studio/import-attachment", (route) =>
|
||||
scenario.status
|
||||
? route.fulfill({
|
||||
status: scenario.status,
|
||||
json: { ok: false, code: scenario.code },
|
||||
})
|
||||
: route.abort("failed"),
|
||||
);
|
||||
await page
|
||||
.getByLabel("Choose the original .nitro file")
|
||||
.setInputFiles(file);
|
||||
await expect(page.getByRole("alert")).toContainText(scenario.message);
|
||||
await expect(
|
||||
page.getByRole("button", {
|
||||
name: "Resume failed imports with this file",
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByLabel("Resumed count")).toHaveText("0");
|
||||
expect(retries).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const key = "cms:draft:v1:7:article:new";
|
||||
test("browser draft survives session failure and reload, restores explicitly and clears only after save", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/fixture/actions/load-recovery", (route) =>
|
||||
route.fulfill({ status: 401, body: "expired" }),
|
||||
);
|
||||
await page.route("**/fixture/actions/article", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: false, error: "Session expired" }),
|
||||
}),
|
||||
);
|
||||
await page.goto("/admin/articles/recovery");
|
||||
const title = page.locator('input[name="title"]');
|
||||
await title.fill("Keep this unfinished article");
|
||||
await expect
|
||||
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
|
||||
.toContain("Keep this unfinished article");
|
||||
await page.getByRole("button", { name: "Save article", exact: true }).click();
|
||||
await expect(page.getByRole("alert")).toContainText("Session expired");
|
||||
expect(await page.evaluate((k) => localStorage.getItem(k), key)).toContain(
|
||||
"Keep this unfinished article",
|
||||
);
|
||||
page.on("dialog", (dialog) => dialog.accept());
|
||||
await page.reload();
|
||||
await expect(title).toHaveValue("Community update");
|
||||
await page
|
||||
.getByRole("button", { name: "Restore browser copy", exact: true })
|
||||
.click();
|
||||
await expect(title).toHaveValue("Keep this unfinished article");
|
||||
await page.unroute("**/fixture/actions/article");
|
||||
await page.route("**/fixture/actions/article", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true }),
|
||||
}),
|
||||
);
|
||||
await page.getByRole("button", { name: "Save article", exact: true }).click();
|
||||
await expect
|
||||
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
|
||||
.toBeNull();
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Restore browser copy", exact: true }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
test("pending recovery does not overwrite current edits and discard preserves them", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/admin/articles/recovery");
|
||||
const title = page.locator('input[name="title"]');
|
||||
await title.fill("Older draft");
|
||||
await expect
|
||||
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
|
||||
.toContain("Older draft");
|
||||
page.on("dialog", (dialog) => dialog.accept());
|
||||
await page.reload();
|
||||
await title.fill("New current edits");
|
||||
await page
|
||||
.getByRole("button", { name: "Discard browser copy", exact: true })
|
||||
.click();
|
||||
await expect(title).toHaveValue("New current edits");
|
||||
await expect
|
||||
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
|
||||
.toContain("New current edits");
|
||||
});
|
||||
test("unavailable browser storage leaves the form editable", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
Storage.prototype.setItem = () => {
|
||||
throw Error("quota");
|
||||
};
|
||||
});
|
||||
await page.goto("/admin/articles/recovery");
|
||||
await page.locator('input[name="title"]').fill("Still editable");
|
||||
await expect(
|
||||
page.getByText("Browser recovery is unavailable.", { exact: false }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('input[name="title"]')).toHaveValue(
|
||||
"Still editable",
|
||||
);
|
||||
});
|
||||
test("saving while recovery is pending enables recovery for subsequent edits", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/fixture/actions/article", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true }),
|
||||
}),
|
||||
);
|
||||
await page.goto("/admin/articles/recovery");
|
||||
const title = page.locator('input[name="title"]');
|
||||
await title.fill("Previous browser draft");
|
||||
await expect
|
||||
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
|
||||
.toContain("Previous browser draft");
|
||||
page.on("dialog", (dialog) => dialog.accept());
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Restore browser copy", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Save article", exact: true }).click();
|
||||
await expect
|
||||
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
|
||||
.toBeNull();
|
||||
await title.fill("Edits after successful save");
|
||||
await expect
|
||||
.poll(() => page.evaluate((k) => localStorage.getItem(k), key))
|
||||
.toContain("Edits after successful save");
|
||||
});
|
||||
test("unmount flush retains an edit before the autosave interval", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/admin/articles/recovery");
|
||||
await page.clock.install();
|
||||
await page.clock.pauseAt(new Date());
|
||||
await page.locator('input[name="title"]').fill("Immediate final edit");
|
||||
await page
|
||||
.getByRole("button", { name: "Close editor fixture", exact: true })
|
||||
.click();
|
||||
expect(await page.evaluate((k) => localStorage.getItem(k), key)).toContain(
|
||||
"Immediate final edit",
|
||||
);
|
||||
});
|
||||
test("event title and dates recover after reload and clear after successful update", async ({
|
||||
page,
|
||||
}) => {
|
||||
const eventKey = "cms:draft:v1:7:event:12";
|
||||
await page.route("**/fixture/actions/update-event", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true, data: { id: 12 } }),
|
||||
}),
|
||||
);
|
||||
await page.goto("/admin/events/recovery");
|
||||
const title = page.locator("#title");
|
||||
const dates = page.locator('input[type="datetime-local"]');
|
||||
await title.fill("Recovered community event");
|
||||
await dates.nth(0).fill("2030-02-03T14:30");
|
||||
await dates.nth(1).fill("2030-02-03T16:00");
|
||||
await expect
|
||||
.poll(() => page.evaluate((k) => localStorage.getItem(k), eventKey))
|
||||
.toContain("2030-02-03T14:30:00.000Z");
|
||||
page.on("dialog", (dialog) => dialog.accept());
|
||||
await page.reload();
|
||||
await expect(title).toHaveValue("Original event");
|
||||
await page
|
||||
.getByRole("button", { name: "Restore browser copy", exact: true })
|
||||
.click();
|
||||
await expect(title).toHaveValue("Recovered community event");
|
||||
await expect(dates.nth(0)).toHaveValue("2030-02-03T14:30");
|
||||
await expect(dates.nth(1)).toHaveValue("2030-02-03T16:00");
|
||||
const save = page.waitForRequest("**/fixture/actions/update-event");
|
||||
await page.locator('button[type="submit"]').click();
|
||||
expect((await save).postDataJSON()).toMatchObject({
|
||||
id: 12,
|
||||
title: "Recovered community event",
|
||||
startsAt: "2030-02-03T14:30:00.000Z",
|
||||
endsAt: "2030-02-03T16:00:00.000Z",
|
||||
});
|
||||
await expect
|
||||
.poll(() => page.evaluate((k) => localStorage.getItem(k), eventKey))
|
||||
.toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import tailwind from "@tailwindcss/postcss";
|
||||
import { build } from "esbuild";
|
||||
import postcss from "postcss";
|
||||
|
||||
const root = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../..",
|
||||
);
|
||||
const fixtures = path.join(root, "e2e/ui/fixtures");
|
||||
const port = Number(process.env.UI_TEST_PORT || 3107);
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
||||
throw Error("Invalid UI_TEST_PORT");
|
||||
const actions = new Set([
|
||||
"@/actions/events",
|
||||
"@/actions/admin-settings",
|
||||
"@/actions/admin-table-views",
|
||||
"@/actions/article-recovery",
|
||||
"@/actions/admin-media",
|
||||
]);
|
||||
const bundle = await build({
|
||||
entryPoints: [path.join(fixtures, "entry.tsx")],
|
||||
bundle: true,
|
||||
write: false,
|
||||
outdir: path.join(root, "test-results/ui-fixture"),
|
||||
format: "iife",
|
||||
jsx: "automatic",
|
||||
alias: { "@": path.join(root, "src") },
|
||||
define: { "process.env.NODE_ENV": '"production"' },
|
||||
plugins: [
|
||||
{
|
||||
name: "server-contracts",
|
||||
setup(build) {
|
||||
build.onResolve(
|
||||
{ filter: /^next\/navigation$|^next\/link$|^@\/actions\// },
|
||||
(args) => {
|
||||
if (args.path === "next/navigation")
|
||||
return { path: path.join(fixtures, "navigation.ts") };
|
||||
if (args.path === "next/link")
|
||||
return { path: path.join(fixtures, "next-link.tsx") };
|
||||
if (actions.has(args.path))
|
||||
return { path: path.join(fixtures, "server-actions.ts") };
|
||||
throw Error(`Unexpected server action in UI fixture: ${args.path}`);
|
||||
},
|
||||
);
|
||||
build.onResolve(
|
||||
{ filter: /^@\/lib\/db$|^mysql2|^next\/headers$/ },
|
||||
(args) => {
|
||||
throw Error(
|
||||
`Database or server dependency forbidden in UI fixture: ${args.path}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const globalCss = (
|
||||
await postcss([tailwind({ base: root })]).process(
|
||||
await readFile(path.join(root, "src/app/globals.css"), "utf8"),
|
||||
{ from: path.join(root, "src/app/globals.css") },
|
||||
)
|
||||
).css;
|
||||
const css =
|
||||
globalCss +
|
||||
"\n" +
|
||||
bundle.outputFiles
|
||||
.filter((file) => file.path.endsWith(".css"))
|
||||
.map((file) => file.text)
|
||||
.join("\n");
|
||||
const js = bundle.outputFiles.find((file) => file.path.endsWith(".js")).text;
|
||||
const html =
|
||||
'<!doctype html><html lang="en" class="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="csrf-token" content="' +
|
||||
"a".repeat(64) +
|
||||
'"><title>UI verification fixture</title><link rel="stylesheet" href="/fixture/styles.css"></head><body><div id="root"></div><script src="/fixture/app.js"></script></body></html>';
|
||||
const cover =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="720" height="160" viewBox="0 0 720 160"><rect width="720" height="160" fill="#334155"/><circle cx="620" cy="80" r="48" fill="#f59e0b"/></svg>';
|
||||
const jobs = new Map();
|
||||
const views = [];
|
||||
const json = (res, value, status = 200) => {
|
||||
res.writeHead(status, {
|
||||
"content-type": "application/json",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
res.end(JSON.stringify(value));
|
||||
};
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, "http://127.0.0.1");
|
||||
if (url.pathname === "/fixture/reset") {
|
||||
jobs.clear();
|
||||
views.length = 0;
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
if (url.pathname === "/health") return json(res, { ready: true });
|
||||
if (url.pathname === "/fixture/styles.css") {
|
||||
res.setHeader("content-type", "text/css");
|
||||
return res.end(css);
|
||||
}
|
||||
if (url.pathname === "/fixture/app.js") {
|
||||
res.setHeader("content-type", "application/javascript");
|
||||
return res.end(js);
|
||||
}
|
||||
if (url.pathname === "/fixture/cover.svg") {
|
||||
res.setHeader("content-type", "image/svg+xml");
|
||||
return res.end(cover);
|
||||
}
|
||||
if (url.pathname.startsWith("/vendor/tinymce/")) {
|
||||
const assetRoot = path.join(root, "node_modules/tinymce");
|
||||
const asset = path.resolve(
|
||||
assetRoot,
|
||||
url.pathname.slice("/vendor/tinymce/".length),
|
||||
);
|
||||
if (!asset.startsWith(assetRoot + path.sep)) {
|
||||
res.writeHead(400);
|
||||
return res.end();
|
||||
}
|
||||
res.setHeader(
|
||||
"content-type",
|
||||
asset.endsWith(".css")
|
||||
? "text/css"
|
||||
: asset.endsWith(".js")
|
||||
? "application/javascript"
|
||||
: asset.endsWith(".woff")
|
||||
? "font/woff"
|
||||
: "application/octet-stream",
|
||||
);
|
||||
return res.end(await readFile(asset));
|
||||
}
|
||||
let body = {};
|
||||
if (req.method === "POST" || req.method === "PATCH") {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > 1000000) throw Error("Fixture payload too large");
|
||||
chunks.push(chunk);
|
||||
}
|
||||
body = JSON.parse(Buffer.concat(chunks).toString() || "{}");
|
||||
}
|
||||
if (url.pathname === "/api/admin/studio/import-jobs") {
|
||||
if (req.method === "PATCH") {
|
||||
const job = jobs.get(body.id);
|
||||
if (job) job.state = "cancelled";
|
||||
return json(res, { ok: true, job });
|
||||
}
|
||||
return json(res, {
|
||||
ok: true,
|
||||
jobs: [...jobs.values()],
|
||||
nextCursor: null,
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/admin/import/official/sync-all") {
|
||||
if (!jobs.has(body.id))
|
||||
jobs.set(body.id, {
|
||||
id: body.id,
|
||||
userId: 7,
|
||||
createdAt: "2030-01-01T12:00:00.000Z",
|
||||
updatedAt: "2030-01-01T12:00:00.000Z",
|
||||
state: "queued",
|
||||
translate: false,
|
||||
syncKind: "official",
|
||||
items: [
|
||||
{
|
||||
id: 0,
|
||||
classname: "fixture_chair",
|
||||
name: "Fixture chair",
|
||||
description: "",
|
||||
type: "flooritem",
|
||||
category: "other",
|
||||
revision: 1,
|
||||
state: "pending",
|
||||
},
|
||||
],
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
return json(res, { ok: true, job: jobs.get(body.id) });
|
||||
}
|
||||
if (url.pathname === "/api/media") return json(res, { files: [] });
|
||||
if (url.pathname.startsWith("/fixture/actions/")) {
|
||||
const name = url.pathname.split("/").at(-1);
|
||||
if (name === "load-recovery")
|
||||
return json(res, { version: 0, draft: null, revisions: [] });
|
||||
if (name === "autosave" || name === "clear-recovery")
|
||||
return json(res, { ok: true, version: 1 });
|
||||
if (name === "list-views")
|
||||
return json(res, { ok: true, data: { views } });
|
||||
if (name === "save-view") {
|
||||
views.push(body.view ?? body);
|
||||
return json(res, { ok: true });
|
||||
}
|
||||
return json(res, { ok: true, data: {} });
|
||||
}
|
||||
if (
|
||||
[
|
||||
"/admin/attachment-harness",
|
||||
"/admin/jobs-harness",
|
||||
"/admin/settings",
|
||||
"/admin/articles/new",
|
||||
"/admin/articles/recovery",
|
||||
"/admin/events/recovery",
|
||||
"/admin/tickets/desk",
|
||||
"/admin/import/official",
|
||||
].includes(url.pathname)
|
||||
) {
|
||||
res.setHeader("content-type", "text/html");
|
||||
return res.end(html);
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.writeHead(500);
|
||||
res.end("Fixture error");
|
||||
}
|
||||
});
|
||||
server.listen(port, "127.0.0.1", () =>
|
||||
console.log(`Isolated UI fixture ready at http://127.0.0.1:${port}`),
|
||||
);
|
||||
for (const signal of ["SIGINT", "SIGTERM"])
|
||||
process.on(signal, () => server.close(() => process.exit(0)));
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { ImportAttachmentRecovery } from "@/components/admin/studio/import-attachment-recovery";
|
||||
export function AttachmentHarness() {
|
||||
const [resumed, setResumed] = useState(0);
|
||||
return (
|
||||
<>
|
||||
<ImportAttachmentRecovery
|
||||
jobId="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||
classname="fixture_chair"
|
||||
disabled={false}
|
||||
onResumed={async () => {
|
||||
setResumed((value) => value + 1);
|
||||
}}
|
||||
/>
|
||||
<output aria-label="Resumed count">{resumed}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Toaster } from "sonner";
|
||||
import { EventForm } from "@/app/admin/events/event-form";
|
||||
import { CmsSettingsForm } from "@/app/admin/settings/cms-settings-form";
|
||||
import { TicketsTable } from "@/app/admin/tickets/tickets-table";
|
||||
import { ArticleForm } from "@/components/admin/article-form";
|
||||
import { FurnitureSyncQueue } from "@/components/admin/studio/furniture-sync-queue";
|
||||
import type { ArticleSaveResult } from "@/lib/article-input";
|
||||
import { themePaletteCss } from "@/lib/theme-css";
|
||||
import { PRESETS } from "@/lib/theme-presets";
|
||||
import messages from "@/messages/en.json";
|
||||
import { AttachmentHarness } from "./attachment-harness";
|
||||
import { FurnitureJobsHarness } from "./furniture-jobs-harness";
|
||||
import { fixtureAction } from "./server-actions";
|
||||
|
||||
const theme = document.createElement("style");
|
||||
theme.textContent = themePaletteCss(
|
||||
":root.dark",
|
||||
PRESETS["Atom (golden)"].dark,
|
||||
);
|
||||
document.head.appendChild(theme);
|
||||
const route = window.location.pathname;
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw Error("Fixture root missing");
|
||||
const rows = [
|
||||
{
|
||||
id: 1,
|
||||
subject: "Account assistance",
|
||||
creator: "SampleMember",
|
||||
creatorId: 7,
|
||||
category: "general",
|
||||
messages: 2,
|
||||
status: "open",
|
||||
priority: "normal",
|
||||
assignee: "Moderator",
|
||||
date: "01/01/2030",
|
||||
awaitingStaff: true,
|
||||
replyAgeMinutes: 90,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
subject: "Event question",
|
||||
creator: "AnotherMember",
|
||||
creatorId: 8,
|
||||
category: "events",
|
||||
messages: 1,
|
||||
status: "waiting",
|
||||
priority: "high",
|
||||
assignee: "—",
|
||||
date: "01/01/2030",
|
||||
awaitingStaff: false,
|
||||
replyAgeMinutes: 15,
|
||||
},
|
||||
];
|
||||
const saveArticle = async (form: FormData): Promise<ArticleSaveResult> =>
|
||||
fixtureAction("article", Object.fromEntries(form));
|
||||
|
||||
function ArticleFixture() {
|
||||
const [visible, setVisible] = useState(true);
|
||||
return (
|
||||
<>
|
||||
{route.includes("recovery") && (
|
||||
<button type="button" onClick={() => setVisible(false)}>
|
||||
Close editor fixture
|
||||
</button>
|
||||
)}
|
||||
{visible && (
|
||||
<ArticleForm
|
||||
userId={route.includes("recovery") ? "7" : undefined}
|
||||
action={saveArticle}
|
||||
articleKey="new"
|
||||
defaultValues={{
|
||||
title: "Community update",
|
||||
slug: "community-update",
|
||||
image: "/fixture/cover.svg",
|
||||
shortStory: "A short update for the community.",
|
||||
fullStory:
|
||||
"<p>Join the upcoming event and explore the hotel together.</p>",
|
||||
status: "draft",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<NextIntlClientProvider
|
||||
locale="en"
|
||||
timeZone="UTC"
|
||||
now={new Date("2030-01-01T12:00:00Z")}
|
||||
messages={messages}
|
||||
>
|
||||
<main data-admin className="min-w-0 mx-auto max-w-6xl p-5">
|
||||
<h1 className="mb-5 text-2xl font-bold">
|
||||
{route.includes("settings")
|
||||
? "CMS settings"
|
||||
: route.includes("tickets")
|
||||
? "Support desk"
|
||||
: route.includes("articles")
|
||||
? "News editor"
|
||||
: "Furniture synchronization"}
|
||||
</h1>
|
||||
{route.includes("attachment-harness") ? (
|
||||
<AttachmentHarness />
|
||||
) : route.includes("events/recovery") ? (
|
||||
<EventForm
|
||||
userId="7"
|
||||
eventTypes={[
|
||||
{ id: 1, name: "Community", slug: "community", color: "#123456" },
|
||||
]}
|
||||
defaultValues={{
|
||||
id: 12,
|
||||
title: "Original event",
|
||||
description: "Event details",
|
||||
typeId: 1,
|
||||
status: "draft",
|
||||
isRecurring: 0,
|
||||
startsAt: new Date("2030-01-01T12:00:00Z"),
|
||||
}}
|
||||
/>
|
||||
) : route.includes("jobs-harness") ? (
|
||||
<FurnitureJobsHarness />
|
||||
) : route.includes("settings") ? (
|
||||
<CmsSettingsForm
|
||||
canEdit
|
||||
values={{ default_dark: "0", cms_hotel_name: "Example Hotel" }}
|
||||
/>
|
||||
) : route.includes("tickets") ? (
|
||||
<TicketsTable
|
||||
data={{ rows, total: 2, page: 1, perPage: 20, lastPage: 1 }}
|
||||
/>
|
||||
) : route.includes("articles") ? (
|
||||
<ArticleFixture />
|
||||
) : (
|
||||
<FurnitureSyncQueue kind="official" />
|
||||
)}
|
||||
</main>
|
||||
<Toaster />
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import { useFurnitureJobs } from "@/components/admin/studio/use-furniture-jobs";
|
||||
|
||||
export function FurnitureJobsHarness() {
|
||||
const [completions, setCompletions] = useState(0);
|
||||
const history = useFurnitureJobs(
|
||||
() => setCompletions((value) => value + 1),
|
||||
true,
|
||||
);
|
||||
return (
|
||||
<section aria-label="Import history hook">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void history.refresh();
|
||||
void history.refresh();
|
||||
}}
|
||||
>
|
||||
Refresh twice
|
||||
</button>
|
||||
<button type="button" onClick={() => void history.navigate("next")}>
|
||||
Next page
|
||||
</button>
|
||||
<button type="button" onClick={() => void history.navigate("previous")}>
|
||||
Previous page
|
||||
</button>
|
||||
<output aria-label="Page">{history.page}</output>
|
||||
<output aria-label="Completions">{completions}</output>
|
||||
<output aria-label="Loading">{String(history.loading)}</output>
|
||||
<output aria-label="Jobs">
|
||||
{history.jobs.map((job) => `${job.id}:${job.state}`).join(",")}
|
||||
</output>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
const router = {
|
||||
push: (url: string) => {
|
||||
window.location.href = url;
|
||||
},
|
||||
replace: (url: string) => {
|
||||
window.location.replace(url);
|
||||
},
|
||||
refresh: () => {},
|
||||
};
|
||||
export const useRouter = () => router;
|
||||
export const usePathname = () => window.location.pathname;
|
||||
export const useSearchParams = () =>
|
||||
new URLSearchParams(window.location.search);
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { AnchorHTMLAttributes } from "react";
|
||||
export default function FixtureLink({
|
||||
prefetch: _prefetch,
|
||||
...props
|
||||
}: AnchorHTMLAttributes<HTMLAnchorElement> & { prefetch?: boolean }) {
|
||||
return <a {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export async function fixtureAction(name: string, input: unknown = {}) {
|
||||
const response = await fetch(`/fixture/actions/${name}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!response.ok) throw Error(`Fixture action failed: ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
export const saveManagedSettings = (input: unknown) =>
|
||||
fixtureAction("settings", input);
|
||||
export const listTableViews = (input: unknown) =>
|
||||
fixtureAction("list-views", input);
|
||||
export const saveTableView = (input: unknown) =>
|
||||
fixtureAction("save-view", input);
|
||||
export const deleteTableView = (input: unknown) =>
|
||||
fixtureAction("delete-view", input);
|
||||
export const loadArticleRecovery = (key: unknown) =>
|
||||
fixtureAction("load-recovery", { key });
|
||||
export const autosaveArticle = (...input: unknown[]) =>
|
||||
fixtureAction("autosave", input);
|
||||
export const clearArticleRecovery = (...input: unknown[]) =>
|
||||
fixtureAction("clear-recovery", input);
|
||||
export const uploadMediaAndReturn = () => fixtureAction("upload");
|
||||
|
||||
export const createEvent = (input: unknown) =>
|
||||
fixtureAction("create-event", input);
|
||||
export const updateEvent = (input: unknown) =>
|
||||
fixtureAction("update-event", input);
|
||||
@@ -0,0 +1,99 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
furnitureCursorFixture,
|
||||
furnitureJobFixture,
|
||||
} from "../../src/test/furniture-jobs-fixtures";
|
||||
|
||||
test("history hook deduplicates refreshes, pages and reports completion once", async ({
|
||||
page,
|
||||
}) => {
|
||||
let requests = 0;
|
||||
let completed = false;
|
||||
await page.route("**/api/admin/studio/import-jobs?*", async (route) => {
|
||||
requests++;
|
||||
const secondPage = new URL(route.request().url()).searchParams.has(
|
||||
"before",
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await route.fulfill({
|
||||
json: {
|
||||
ok: true,
|
||||
jobs: [
|
||||
{
|
||||
...furnitureJobFixture,
|
||||
id: secondPage
|
||||
? "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||
: furnitureJobFixture.id,
|
||||
state: completed ? "completed" : "queued",
|
||||
},
|
||||
],
|
||||
nextCursor: secondPage ? null : furnitureCursorFixture,
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto("/admin/jobs-harness");
|
||||
await expect(page.getByLabel("Jobs", { exact: true })).toContainText(
|
||||
furnitureJobFixture.id,
|
||||
);
|
||||
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
|
||||
const initial = requests;
|
||||
await page.getByRole("button", { name: "Refresh twice" }).click();
|
||||
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("true");
|
||||
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
|
||||
expect(requests).toBe(initial + 1);
|
||||
await page.getByRole("button", { name: "Next page" }).click();
|
||||
await expect(page.getByLabel("Page", { exact: true })).toHaveText("2");
|
||||
await expect(page.getByLabel("Jobs", { exact: true })).toContainText(
|
||||
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
);
|
||||
await page.getByRole("button", { name: "Previous page" }).click();
|
||||
await expect(page.getByLabel("Page", { exact: true })).toHaveText("1");
|
||||
await expect(page.getByLabel("Jobs", { exact: true })).toContainText(
|
||||
furnitureJobFixture.id,
|
||||
);
|
||||
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
|
||||
completed = true;
|
||||
await page.getByRole("button", { name: "Refresh twice" }).click();
|
||||
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("true");
|
||||
await expect(page.getByLabel("Completions", { exact: true })).toHaveText("1");
|
||||
await page.getByRole("button", { name: "Refresh twice" }).click();
|
||||
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("true");
|
||||
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
|
||||
await expect(page.getByLabel("Completions", { exact: true })).toHaveText("1");
|
||||
});
|
||||
|
||||
test("history polling pauses while hidden and refreshes on visibility return", async ({
|
||||
page,
|
||||
}) => {
|
||||
let requests = 0;
|
||||
await page.clock.install();
|
||||
await page.route("**/api/admin/studio/import-jobs?*", (route) => {
|
||||
requests++;
|
||||
return route.fulfill({
|
||||
json: { ok: true, jobs: [furnitureJobFixture], nextCursor: null },
|
||||
});
|
||||
});
|
||||
await page.goto("/admin/jobs-harness");
|
||||
await expect(page.getByLabel("Jobs", { exact: true })).toContainText(
|
||||
furnitureJobFixture.id,
|
||||
);
|
||||
await expect(page.getByLabel("Loading", { exact: true })).toHaveText("false");
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
get: () => "hidden",
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
const hiddenCount = requests;
|
||||
await page.clock.fastForward(60000);
|
||||
expect(requests).toBe(hiddenCount);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
get: () => "visible",
|
||||
});
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
await expect.poll(() => requests).toBe(hiddenCount + 1);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
|
||||
async function accessible(page: Page, sandboxPreview = false) {
|
||||
const builder = new AxeBuilder({ page });
|
||||
// A sandbox without allow-scripts cannot run axe; its accessible name is asserted separately.
|
||||
if (sandboxPreview) builder.exclude("iframe[sandbox]");
|
||||
const result = await builder
|
||||
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
||||
.analyze();
|
||||
expect(
|
||||
result.violations.map(({ id, impact, nodes }) => ({
|
||||
id,
|
||||
impact,
|
||||
nodes: nodes.map((node) => ({
|
||||
target: node.target,
|
||||
summary: node.failureSummary,
|
||||
})),
|
||||
})),
|
||||
).toEqual([]);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, baseURL, request }) => {
|
||||
await request.post("/fixture/reset");
|
||||
await page.clock.setFixedTime(new Date("2030-01-01T12:00:00Z"));
|
||||
await page.route("**/*", (route) =>
|
||||
new URL(route.request().url()).origin === baseURL
|
||||
? route.continue()
|
||||
: route.abort(),
|
||||
);
|
||||
page.on("pageerror", (error) => {
|
||||
throw error;
|
||||
});
|
||||
});
|
||||
|
||||
test("settings switches have names and remain keyboard operable", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/admin/settings");
|
||||
const toggle = page.getByRole("switch", {
|
||||
name: "Dark mode by default",
|
||||
exact: true,
|
||||
});
|
||||
await expect(toggle).not.toBeChecked();
|
||||
await toggle.focus();
|
||||
await page.keyboard.press("Space");
|
||||
await expect(toggle).toBeChecked();
|
||||
await accessible(page);
|
||||
const group = page.locator("section").filter({ has: toggle });
|
||||
await expect(group).toHaveScreenshot("settings-switches.png");
|
||||
const response = page.waitForResponse((response) =>
|
||||
response.url().endsWith("/fixture/actions/settings"),
|
||||
);
|
||||
await page
|
||||
.getByRole("button", { name: "Save settings", exact: true })
|
||||
.click();
|
||||
expect((await response).ok()).toBe(true);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Save settings", exact: true }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test("article form and preview dialog pass accessibility with focus return", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/admin/articles/new");
|
||||
await expect(page.locator("iframe.tox-edit-area__iframe")).toBeVisible();
|
||||
await accessible(page);
|
||||
await page.locator('input[name="title"]').fill("Accessible community update");
|
||||
const preview = page.getByRole("button", { name: "Preview", exact: true });
|
||||
await preview.click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toHaveAccessibleName("Article preview");
|
||||
await expect(dialog.locator("iframe")).toHaveAttribute("title", /.+/);
|
||||
await accessible(page, true);
|
||||
await expect(dialog).toHaveScreenshot("article-preview.png");
|
||||
// Keyboard events inside the sandbox do not bubble to the parent dialog.
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Close", exact: true }),
|
||||
).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(preview).toBeFocused();
|
||||
await expect(page.getByRole("main")).toHaveScreenshot("article-form.png");
|
||||
});
|
||||
|
||||
test("support table filters and saved-view popover stay accessible", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/admin/tickets/desk");
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Account assistance" }),
|
||||
).toBeVisible();
|
||||
await accessible(page);
|
||||
await expect(page.getByRole("main")).toHaveScreenshot("ticket-table.png");
|
||||
await page
|
||||
.getByRole("button", { name: "Assigned to me", exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/filter_assignee=mine/);
|
||||
await page.getByRole("button", { name: "Saved views", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "View name" })).toBeVisible();
|
||||
await accessible(page);
|
||||
await expect(page.getByRole("main")).toHaveScreenshot(
|
||||
"ticket-saved-views.png",
|
||||
);
|
||||
});
|
||||
|
||||
test("queued synchronization survives reload without duplicate submission", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/admin/import/official");
|
||||
const requests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (request.method() === "POST" && request.url().endsWith("/sync-all"))
|
||||
requests.push(request.postData() || "");
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: "Start synchronization", exact: true })
|
||||
.dblclick();
|
||||
await expect(
|
||||
page.getByRole("status").filter({ hasText: "Synchronization saved" }),
|
||||
).toBeVisible();
|
||||
expect(requests).toHaveLength(1);
|
||||
await page.reload();
|
||||
await page.locator("details details > summary").first().click();
|
||||
await expect(page.getByText("fixture_chair", { exact: true })).toBeVisible();
|
||||
await accessible(page);
|
||||
await expect(page.getByRole("main")).toHaveScreenshot("sync-history.png");
|
||||
});
|
||||