Files
EpicNext-Cms/e2e/ui/catalog-live-update.spec.ts
T
openhandsandClaude Opus 4.8 9550b3d66f
Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 29s
CI / tests-unit (push) Successful in 1m34s
CI / tests-integration (push) Failing after 1m34s
CI / tests-ui (push) Successful in 2m19s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
feat(catalog): make the live catalog self-correcting and honest about failure
The previous commit made imports update the Studio without a reload, but the
guarantee only held inside the tab that started the import and only as long as
every read succeeded. Four holes were left, and this closes them.

A session that mounted the tree before an import kept the pre-import tree for
the rest of its life, because ensureCatalogTreeLoaded() was a once-per-session
no-op. It now asks the server whether what it holds is still current. The answer
is a revision: sendCatalogUpdate() already runs after every catalog write, so it
bumps one, and clients read it on mount, on focus, on a 20s poll and from other
tabs over a BroadcastChannel. An import that finishes in another tab, another
browser or the job worker now lands here too.

A failed read used to be swallowed, which is the worst outcome available: the
rail kept showing pre-import counts as if they were current and nothing said so.
The snapshot now carries the error, the rail shows it with a retry, and the
previous tree stays on screen because stale beats empty.

Every settled import pulled the entire flat tree, which is the one payload that
grows with the size of the catalog. The revision doubles as the ETag on
mode=full, so an unchanged catalog answers 304 and the poll costs a file read.

An import could also report success for an offer the hotel will never sell: a
hidden or disabled page, an item_ids that misses the furni id, a zero amount.
importSingleFurni reads its own row back and reports each of those as a warning,
where the import report already is, instead of leaving it to surface as "the
import did not work" in the client.

Finally, the catalog items table no longer falls back to router.refresh() —
onRefresh is now required, so every mutation ends in a refresh of the caller's
own data instead of a route re-render that threw away editor state and scroll
position. useServerAction keeps its default, because 47 callers across the app
depend on it. The 750-line CatalogTree in catalog-tree.tsx was dead code that
kept its own stale tree and three more router.refresh() calls; only CatalogIcon
and LAYOUT_COLORS are still imported, so the rest is gone.

Tests: the store now covers revisions, 304s, probe failures and error recovery;
a jsdom test mounts a consumer and asserts the tree updates in place with no
navigation; the old organize-imports e2e asserted nothing about the endpoints
the code actually calls, and is replaced by one that asserts a cross-tab write
lands in the mounted categories without a reload.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-09-30 15:15:34 +02:00

144 lines
4.2 KiB
TypeScript

import { expect, type Page, test } from "@playwright/test";
/**
* The guarantee this file exists to guard: an import that finishes outside this
* page — the job worker, another tab, another browser — puts the new categories
* on screen without a reload. Every earlier test in this suite asserted only
* that no page errors were thrown, so a regression back to "refresh the page to
* see it" would have gone unnoticed.
*/
function node(id: number, caption: string, itemCount = 0) {
return {
id,
caption,
parentId: -1,
depth: 0,
orderNum: id,
enabled: "1",
visible: "1",
iconImage: 0,
iconColor: 0,
pageLayout: "default_3x3",
childCount: 0,
itemCount,
};
}
/** The catalog as the server would report it; specs move it forward mid-test. */
const catalog = {
revision: "rev-1",
pages: [node(1, "Root")],
treeStatus: 200,
};
async function mockStudio(page: Page) {
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url());
if (url.pathname === "/api/admin/catalog/revision")
return route.fulfill({ json: { ok: true, revision: catalog.revision } });
if (
url.pathname === "/api/admin/catalog/tree" &&
url.searchParams.get("mode") === "full"
)
return route.fulfill({
status: catalog.treeStatus,
json: {
ok: catalog.treeStatus === 200,
pages: catalog.pages,
totals: null,
revision: catalog.revision,
},
});
if (url.pathname.endsWith("/inspect"))
return route.fulfill({ json: { items: [] } });
if (url.pathname.endsWith("/source-assets"))
return route.fulfill({ json: { items: [] } });
if (url.pathname.endsWith("/furni"))
return route.fulfill({
json: url.searchParams.has("action")
? { totalInDb: 0, inCatalog: 0, notInCatalog: 0, missingNitro: 0 }
: {
items: [],
meta: {
currentPage: 1,
lastPage: 1,
total: 0,
perPage: 100,
},
},
});
if (url.pathname.endsWith("/clone"))
return route.fulfill({ json: { sources: [] } });
if (url.pathname.endsWith("/import-jobs"))
return route.fulfill({ json: { ok: true, jobs: [], nextCursor: null } });
return route.fulfill({
status: 404,
json: { error: "Unknown fixture endpoint" },
});
});
}
test.beforeEach(() => {
catalog.revision = "rev-1";
catalog.pages = [node(1, "Root")];
catalog.treeStatus = 200;
});
test("a write announced by another tab lands in the mounted categories", async ({
page,
}, testInfo) => {
test.skip(
(testInfo.project.use.viewport?.width ?? 0) < 1024,
"the category rail is collapsed on narrow viewports",
);
await mockStudio(page);
const navigations: string[] = [];
page.on("framenavigated", (frame) => {
if (frame === page.mainFrame()) navigations.push(frame.url());
});
await page.goto("/admin/studio-harness");
await expect(page.getByRole("button", { name: /^Root/ })).toBeVisible();
const navigationsAfterLoad = navigations.length;
// An import finished elsewhere: the catalog moved on, so the tree route now
// answers with the imported category.
catalog.revision = "rev-2";
catalog.pages = [...catalog.pages, node(2, "Imported Furniture", 3)];
await page.evaluate((revision) => {
const channel = new BroadcastChannel("atom-cms-catalog");
channel.postMessage({ revision });
channel.close();
}, catalog.revision);
await expect(
page.getByRole("button", { name: /Imported Furniture/ }),
).toBeVisible();
// The page was never navigated or reloaded: the tree updated in place.
expect(navigations).toHaveLength(navigationsAfterLoad);
});
test("a failed refresh keeps the categories and says it is stale", async ({
page,
}, testInfo) => {
test.skip(
(testInfo.project.use.viewport?.width ?? 0) < 1024,
"the category rail is collapsed on narrow viewports",
);
await mockStudio(page);
await page.goto("/admin/studio-harness");
await expect(page.getByRole("button", { name: /^Root/ })).toBeVisible();
catalog.treeStatus = 500;
catalog.revision = "rev-2";
await page.evaluate((revision) => {
const channel = new BroadcastChannel("atom-cms-catalog");
channel.postMessage({ revision });
channel.close();
}, catalog.revision);
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
await expect(page.getByRole("button", { name: /^Root/ })).toBeVisible();
});