feat(hk): explain background updates with protected content links and retry details
This commit is contained in:
1 parent
dd7613850e
commit
c5c9941768
42 files changed
+1542
-145
No files matched your search
@@ -0,0 +1,126 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("deliveries explain outcomes without exposing unauthorized content or raw metadata", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.goto("/admin/deliveries-harness");
|
||||
const review = page.getByRole("region", { name: "Delivery review" });
|
||||
const cards = review.getByRole("article");
|
||||
await expect(cards).toHaveCount(6);
|
||||
const news = cards.nth(0);
|
||||
await expect(news.getByRole("heading", { level: 2 })).toContainText(
|
||||
"Community update: SummerFestival",
|
||||
);
|
||||
await expect(news.getByText("Waiting", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
news.getByText(
|
||||
"The content is saved. This update runs in the background.",
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(news.getByRole("status")).toContainText(
|
||||
"The news cache could not be refreshed.",
|
||||
);
|
||||
await expect(news.getByRole("status")).toContainText(
|
||||
"Next automatic attempt: Jan 1, 2030, 12:05 PM",
|
||||
);
|
||||
await expect(
|
||||
news.getByRole("link", { name: "Open content", exact: true }),
|
||||
).toHaveAttribute("href", "/admin/articles/9007199254740993");
|
||||
await expect(
|
||||
news.getByRole("link", { name: "Service diagnostics", exact: true }),
|
||||
).toHaveAttribute("href", "/admin/devops/installation");
|
||||
const failed = cards.nth(1);
|
||||
await expect(
|
||||
failed.getByRole("heading", { name: "9 catalog offers", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
failed.getByText("Needs attention", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(failed.getByRole("status")).toContainText(
|
||||
"Check the emulator connection.",
|
||||
);
|
||||
await expect(failed.getByRole("status")).toContainText(
|
||||
"Automatic attempts are exhausted.",
|
||||
);
|
||||
await expect(
|
||||
failed.getByRole("link", { name: "Open content", exact: true }),
|
||||
).toHaveAttribute("href", "/admin/catalog");
|
||||
await expect(
|
||||
failed.getByRole("button", { name: "Retry delivery", exact: true }),
|
||||
).toBeVisible();
|
||||
for (const card of [cards.nth(2), cards.nth(3)]) {
|
||||
await expect(
|
||||
card.getByRole("heading", {
|
||||
name: "Content details unavailable",
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
card.getByRole("link", { name: "Open content", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
card.getByRole("button", { name: "Retry delivery", exact: true }),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
const unknown = cards.nth(4);
|
||||
await expect(
|
||||
unknown.getByText("Other background update", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
unknown.getByText("Unknown status", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(unknown).toContainText("Time unavailable");
|
||||
await expect(unknown).toContainText("System");
|
||||
await expect(cards.nth(5)).toContainText(
|
||||
"Check the Git export history for the final result; disabled export does not publish files.",
|
||||
);
|
||||
await expect(review).not.toContainText(
|
||||
/RESTRICTED_NEWS_TITLE|SERIALIZED_METADATA_SECRET|INTERNAL_FILE_PATH_SECRET|UNKNOWN_OPERATION_SECRET|489 catalog offers|resultJson|articleId/,
|
||||
);
|
||||
await expect(review.locator("details[open]")).toHaveCount(0);
|
||||
await expect(
|
||||
news.getByText("Operation: operation-authorized-news-1234567890", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
news.getByText("Delivery: delivery-authorized-news-1234567890", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeHidden();
|
||||
await news.locator("summary").click();
|
||||
await expect(
|
||||
news.getByText("Operation: operation-authorized-news-1234567890", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
news.getByText("Delivery: delivery-authorized-news-1234567890", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await news.locator("summary").click();
|
||||
await expect(
|
||||
news.getByText("Operation: operation-authorized-news-1234567890", {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeHidden();
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= window.innerWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
const title = news.getByRole("heading", { level: 2 });
|
||||
expect(
|
||||
await title.evaluate((node) => node.scrollWidth <= node.clientWidth),
|
||||
).toBe(true);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await testInfo.attach("delivery-cards", {
|
||||
body: await page.screenshot({ fullPage: true }),
|
||||
contentType: "image/png",
|
||||
});
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -15,6 +15,7 @@ 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/catalog",
|
||||
"@/actions/import-furni",
|
||||
"@/actions/events",
|
||||
"@/actions/admin-settings",
|
||||
@@ -199,6 +200,7 @@ const server = createServer(async (req, res) => {
|
||||
[
|
||||
"/notifications",
|
||||
"/admin/unsaved-harness",
|
||||
"/admin/deliveries-harness",
|
||||
"/admin/attachment-harness",
|
||||
"/admin/studio-harness",
|
||||
"/admin/jobs-harness",
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DeliveryCard,
|
||||
type DeliveryCardItem,
|
||||
} from "@/features/operations/delivery-card";
|
||||
import { deliveryContext } from "@/features/operations/delivery-context";
|
||||
|
||||
const shared: Omit<DeliveryCardItem, "id" | "operationId" | "context"> = {
|
||||
topic: "news.refresh",
|
||||
status: "pending",
|
||||
attempts: 2,
|
||||
actorId: 7,
|
||||
createdAt: "2030-01-01T11:00:00Z",
|
||||
availableAt: "2030-01-01T12:05:00Z",
|
||||
hasError: true,
|
||||
};
|
||||
const records: DeliveryCardItem[] = [
|
||||
{
|
||||
...shared,
|
||||
id: "delivery-authorized-news-1234567890",
|
||||
operationId: "operation-authorized-news-1234567890",
|
||||
context: deliveryContext(
|
||||
"news.update",
|
||||
JSON.stringify({
|
||||
articleId: "9007199254740993",
|
||||
articleTitle: `Community update: ${"SummerFestival".repeat(14)}`,
|
||||
internalNotes: "SERIALIZED_METADATA_SECRET",
|
||||
lastError: "INTERNAL_FILE_PATH_SECRET",
|
||||
}),
|
||||
{ news: true, catalog: false },
|
||||
),
|
||||
},
|
||||
{
|
||||
...shared,
|
||||
id: "delivery-failed-catalog-1234567890",
|
||||
operationId: "operation-failed-catalog-1234567890",
|
||||
topic: "catalog.refresh",
|
||||
status: "failed",
|
||||
attempts: 5,
|
||||
context: deliveryContext("catalog.bulk.apply", '{"changedCount":9}', {
|
||||
news: false,
|
||||
catalog: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
...shared,
|
||||
id: "delivery-restricted-news",
|
||||
operationId: "operation-restricted-news",
|
||||
hasError: false,
|
||||
context: deliveryContext(
|
||||
"news.update",
|
||||
'{"articleTitle":"RESTRICTED_NEWS_TITLE","articleId":"99"}',
|
||||
{ news: false, catalog: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
...shared,
|
||||
id: "delivery-restricted-catalog",
|
||||
operationId: "operation-restricted-catalog",
|
||||
topic: "catalog.refresh",
|
||||
hasError: false,
|
||||
context: deliveryContext("catalog.bulk.apply", '{"changedCount":489}', {
|
||||
news: true,
|
||||
catalog: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
...shared,
|
||||
id: "delivery-unknown",
|
||||
operationId: "operation-unknown",
|
||||
topic: "future.topic",
|
||||
status: "future.status",
|
||||
actorId: 0,
|
||||
createdAt: "invalid",
|
||||
context: deliveryContext(
|
||||
"future.operation",
|
||||
'{"articleTitle":"UNKNOWN_OPERATION_SECRET"}',
|
||||
{ news: true, catalog: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
...shared,
|
||||
id: "delivery-completed-export",
|
||||
operationId: "operation-completed-export",
|
||||
topic: "catalog.export.request",
|
||||
status: "done",
|
||||
hasError: false,
|
||||
context: deliveryContext("catalog.bulk.apply", '{"changedCount":4}', {
|
||||
news: false,
|
||||
catalog: true,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export function DeliveriesHarness() {
|
||||
const t = useTranslations("pages.admin.deliveries");
|
||||
return (
|
||||
<section aria-label="Delivery review" className="min-w-0 space-y-4">
|
||||
<p>{t("scope")}</p>
|
||||
<ul className="space-y-3">
|
||||
{records.map((item) => (
|
||||
<li key={item.id}>
|
||||
<DeliveryCard
|
||||
item={item}
|
||||
retry={
|
||||
item.status === "failed" ? (
|
||||
<Button type="button" variant="outline">
|
||||
{t("retry")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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 { OrganizeImportsLauncher } from "@/components/admin/studio/organize-imports-launcher";
|
||||
import { StudioClient } from "@/components/admin/studio/studio-client";
|
||||
import { NotificationCenter } from "@/features/notifications/notification-center";
|
||||
import type { ArticleSaveResult } from "@/lib/article-input";
|
||||
@@ -15,6 +16,7 @@ import { themePaletteCss } from "@/lib/theme-css";
|
||||
import { PRESETS } from "@/lib/theme-presets";
|
||||
import messages from "@/messages/en.json";
|
||||
import { AttachmentHarness } from "./attachment-harness";
|
||||
import { DeliveriesHarness } from "./deliveries-harness";
|
||||
import { FurnitureJobsHarness } from "./furniture-jobs-harness";
|
||||
import { fixtureAction } from "./server-actions";
|
||||
import { UnsavedHarness } from "./unsaved-harness";
|
||||
@@ -120,18 +122,25 @@ createRoot(root).render(
|
||||
? "Support desk"
|
||||
: route.includes("articles")
|
||||
? "News editor"
|
||||
: "Furniture synchronization"}
|
||||
: route.includes("deliveries-harness")
|
||||
? "Background updates"
|
||||
: "Furniture synchronization"}
|
||||
</h1>
|
||||
{route.includes("notifications") ? (
|
||||
<NotificationCenter userId={7} />
|
||||
) : route.includes("deliveries-harness") ? (
|
||||
<DeliveriesHarness />
|
||||
) : route.includes("unsaved-harness") ? (
|
||||
<UnsavedHarness />
|
||||
) : route.includes("studio-harness") ? (
|
||||
<StudioClient
|
||||
source={buildFurniImportSource("it")}
|
||||
initialTree={[]}
|
||||
defaultTranslate={false}
|
||||
/>
|
||||
<>
|
||||
<OrganizeImportsLauncher />
|
||||
<StudioClient
|
||||
source={buildFurniImportSource("it")}
|
||||
initialTree={[]}
|
||||
defaultTranslate={false}
|
||||
/>
|
||||
</>
|
||||
) : route.includes("attachment-harness") ? (
|
||||
<AttachmentHarness />
|
||||
) : route.includes("events/recovery") ? (
|
||||
|
||||
@@ -32,3 +32,6 @@ export const deleteImportedFurni = (input: unknown) =>
|
||||
fixtureAction("delete-imported-furni", input);
|
||||
export const setFurnidataTranslateEnabled = (input: unknown) =>
|
||||
fixtureAction("furnidata-translation", input);
|
||||
|
||||
export const organizeImportFurni = (input: unknown) =>
|
||||
fixtureAction("organize-import-furni", input);
|
||||
@@ -205,3 +205,102 @@ test("Studio opens and dismisses detail, Nitro editor and import review", async
|
||||
expect(mutations).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("Studio loads organize imports on demand and preserves the dialog on reopen", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const errors: string[] = [];
|
||||
const mutations: string[] = [];
|
||||
let organizeRequests = 0;
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.route("**/fixture/actions/**", async (route) => {
|
||||
mutations.push(route.request().url());
|
||||
await route.fulfill({
|
||||
status: 405,
|
||||
json: { error: "No mutations expected" },
|
||||
});
|
||||
});
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
if (request.method() !== "GET") {
|
||||
mutations.push(`${request.method()} ${url.pathname}`);
|
||||
return route.fulfill({
|
||||
status: 405,
|
||||
json: { error: "No mutations expected" },
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/admin/import/organize") {
|
||||
organizeRequests++;
|
||||
return route.fulfill({
|
||||
json: {
|
||||
groups: [
|
||||
{
|
||||
name: "Fixture imports",
|
||||
icon: 1,
|
||||
layout: "default_3x3",
|
||||
total: 1,
|
||||
moved: 0,
|
||||
added: 1,
|
||||
thumbs: [],
|
||||
items: [{ itemId: 1, catalogItemId: null, alreadyPlaced: false }],
|
||||
},
|
||||
],
|
||||
importRootPageId: null,
|
||||
importRootCaption: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/admin/catalog/tree")
|
||||
return route.fulfill({ json: { pages: [] } });
|
||||
if (url.pathname === "/api/admin/import/furni")
|
||||
return route.fulfill({
|
||||
json:
|
||||
url.searchParams.get("action") === "stats"
|
||||
? { totalInDb: 0, inCatalog: 0, notInCatalog: 0, missingNitro: 0 }
|
||||
: {
|
||||
items: [],
|
||||
meta: { currentPage: 1, lastPage: 1, total: 0, perPage: 20 },
|
||||
},
|
||||
});
|
||||
if (url.pathname === "/api/admin/import/clone")
|
||||
return route.fulfill({ json: { sources: [] } });
|
||||
if (url.pathname === "/api/admin/studio/import-jobs")
|
||||
return route.fulfill({ json: { ok: true, jobs: [], nextCursor: null } });
|
||||
return route.fulfill({
|
||||
status: 404,
|
||||
json: { error: "Unknown fixture endpoint" },
|
||||
});
|
||||
});
|
||||
await page.goto("/admin/studio-harness");
|
||||
const trigger = page.getByRole("button", {
|
||||
name: "Organize imports",
|
||||
exact: true,
|
||||
});
|
||||
await expect(trigger).toBeVisible();
|
||||
expect(organizeRequests).toBe(0);
|
||||
await trigger.click();
|
||||
const dialog = page.getByRole("dialog", { name: /^Organize imports/ });
|
||||
await expect(dialog).toBeVisible();
|
||||
const filter = dialog.getByPlaceholder("Filter import groups...");
|
||||
await expect(filter).toBeVisible();
|
||||
await expect(dialog.locator('input[value="Fixture imports"]')).toBeVisible();
|
||||
await filter.fill("Fixture");
|
||||
expect(organizeRequests).toBe(1);
|
||||
await testInfo.attach("organize-imports", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
await trigger.click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(filter).toHaveValue("Fixture");
|
||||
await expect.poll(() => organizeRequests).toBe(2);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
expect(mutations).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
Reference in new issue
Block a user