feat(cms): recover drafts and failed imports with verified UI workflows

This commit is contained in:
Simo committed 2026-09-11 08:58:10 +02:00
1 parent 410a1e466c
commit 88790d1a0d
84 files changed
+3952 -288

No files matched your search

+10
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+18
View File
@@ -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>
</>
);
}
File diff suppressed because it is too large. Load diff
@@ -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>
);
}
+13
View File
@@ -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);
+7
View File
@@ -0,0 +1,7 @@
import type { AnchorHTMLAttributes } from "react";
export default function FixtureLink({
prefetch: _prefetch,
...props
}: AnchorHTMLAttributes<HTMLAnchorElement> & { prefetch?: boolean }) {
return <a {...props} />;
}
+29
View File
@@ -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);
+99
View File
@@ -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);
});
File diff suppressed because it is too large. Load diff