Files
wehub-resource-sync b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

733 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Tests for admin router page components.
*
* Bug: content created in the wrong locale when using the locale switcher.
*
* Root cause (two parts):
* 1. ContentListPage renders ContentList with `activeLocale` but the "Add New"
* <Link> in ContentList does NOT forward `search={{ locale: activeLocale }}` to
* the new-content route. The locale is silently dropped on navigation.
* 2. ContentNewPage (router.tsx:380) has no `validateSearch` and never reads the
* locale from URL search params, so `createContent` is always called without a
* locale, defaulting to English regardless of what is configured.
*
* Fix required in:
* packages/admin/src/components/ContentList.tsx forward locale on Add-New links
* packages/admin/src/router.tsx (ContentNewPage) read locale from search params
* and pass it to createContent
*/
import { Toasty } from "@cloudflare/kumo";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import * as React from "react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { AdminManifest } from "../src/lib/api";
import { createAdminRouter } from "../src/router";
import { render } from "./utils/render.tsx";
import { createTestQueryClient, createMockFetch, waitFor } from "./utils/test-helpers";
// ---------------------------------------------------------------------------
// Component mocks keep layout plumbing out of these tests
// ---------------------------------------------------------------------------
vi.mock("../src/components/Shell", () => ({
Shell: ({ children }: { children: React.ReactNode }) => <div data-testid="shell">{children}</div>,
}));
vi.mock("../src/components/AdminCommandPalette", () => ({
AdminCommandPalette: () => null,
}));
vi.mock("../src/components/ContentEditor", () => ({
ContentEditor: ({
item,
onSave,
onAutosave,
onSeoChange,
isSaving,
isAutosaving,
isSaveFeedbackActive,
autosaveCompletionToken,
}: {
item?: { data?: { title?: string }; slug?: string | null };
onSave?: (payload: { data: Record<string, unknown> }) => void;
onAutosave?: (payload: { data: Record<string, unknown>; slug?: string }) => void;
onSeoChange?: (seo: { title: string }) => void;
isSaving?: boolean;
isAutosaving?: boolean;
isSaveFeedbackActive?: boolean;
autosaveCompletionToken?: number;
}) => (
<div data-testid="content-editor">
<div data-testid="mock-title">{item?.data?.title ?? ""}</div>
<div data-testid="mock-slug">{item?.slug ?? ""}</div>
<div data-testid="is-saving">{isSaveFeedbackActive ? "saving" : "idle"}</div>
<div data-testid="manual-save-blocked">{isSaving ? "blocked" : "ready"}</div>
<div data-testid="autosave-blocked">{isSaving || isAutosaving ? "blocked" : "ready"}</div>
<div data-testid="autosave-completion-token">{autosaveCompletionToken ?? 0}</div>
<form
onSubmit={(e) => {
e.preventDefault();
onSave?.({ data: { title: "Test Post" } });
}}
>
<button type="submit" disabled={isSaving}>
Save
</button>
</form>
<button
type="button"
disabled={isSaving || isAutosaving}
onClick={() =>
onAutosave?.({
data: { title: "Autosaved Title" },
slug: "autosaved-title",
})
}
>
Trigger Draft Sync
</button>
<button type="button" onClick={() => onSeoChange?.({ title: "Search title" })}>
Trigger SEO Sync
</button>
</div>
),
}));
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const MANIFEST: AdminManifest = {
version: "1.0.0",
hash: "abc123",
authMode: "passkey",
collections: {
posts: {
label: "Posts",
labelSingular: "Post",
supports: ["drafts"],
hasSeo: false,
fields: {
title: { kind: "string", label: "Title" },
},
},
},
plugins: {},
taxonomies: [],
i18n: {
defaultLocale: "fr",
locales: ["fr", "en", "de"],
},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function buildRouter() {
const queryClient = createTestQueryClient();
const router = createAdminRouter(queryClient);
if (!i18n.locale) {
i18n.loadAndActivate({ locale: "en", messages: {} });
}
// Toasty and I18nProvider are provided by App.tsx in production.
// Mirror that here so useLingui() and Toast.useToastManager() work inside page components.
function TestApp() {
return (
<I18nProvider i18n={i18n}>
<Toasty>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</Toasty>
</I18nProvider>
);
}
return { router, queryClient, TestApp };
}
// ---------------------------------------------------------------------------
// Tests: ContentListPage locale forwarded to "Add New" link
// ---------------------------------------------------------------------------
describe("ContentListPage locale forwarding to the new-content route", () => {
let mockFetch: ReturnType<typeof createMockFetch>;
beforeEach(() => {
mockFetch = createMockFetch();
mockFetch
.on("GET", "/_emdash/api/manifest", { data: MANIFEST })
.on("GET", "/_emdash/api/auth/me", {
data: { id: "user_01", role: 60 },
})
.on("GET", "/_emdash/api/content/posts", {
data: { items: [], nextCursor: undefined },
})
.on("GET", "/_emdash/api/content/posts/trashed", {
data: { items: [] },
});
});
afterEach(() => {
mockFetch.restore();
});
it("Add New link includes the active locale when a non-default locale (de) is selected", async () => {
// Navigate to the content list with locale=de selected in the switcher.
// The default locale is fr, so de is a non-default locale.
// The "Add New" <Link> must carry ?locale=de so that ContentNewPage
// receives it and creates content in German, not the default French.
const { router, TestApp } = buildRouter();
await router.navigate({
to: "/content/$collection",
params: { collection: "posts" },
search: { locale: "de" },
});
const screen = await render(<TestApp />);
const addNewLink = screen.getByRole("link", { name: /add new/i });
await expect.element(addNewLink).toBeInTheDocument();
const href = addNewLink.element().getAttribute("href") ?? "";
expect(href).toContain("locale=de");
});
it("Add New link uses the default locale (fr) when no locale is set in the URL", async () => {
// Navigate to the content list without an explicit locale param.
// activeLocale falls back to the configured defaultLocale ("fr").
// The "Add New" <Link> must carry ?locale=fr so that ContentNewPage
// creates content in the correct default language.
const { router, TestApp } = buildRouter();
await router.navigate({
to: "/content/$collection",
params: { collection: "posts" },
});
const screen = await render(<TestApp />);
const addNewLink = screen.getByRole("link", { name: /add new/i });
await expect.element(addNewLink).toBeInTheDocument();
const href = addNewLink.element().getAttribute("href") ?? "";
expect(href).toContain("locale=fr");
});
it("Add New link does not include a locale param when i18n is not configured", async () => {
const manifestWithoutI18n: AdminManifest = { ...MANIFEST, i18n: undefined };
mockFetch.on("GET", "/_emdash/api/manifest", { data: manifestWithoutI18n });
const { router, TestApp } = buildRouter();
await router.navigate({
to: "/content/$collection",
params: { collection: "posts" },
});
const screen = await render(<TestApp />);
const addNewLink = screen.getByRole("link", { name: /add new/i });
await expect.element(addNewLink).toBeInTheDocument();
const href = addNewLink.element().getAttribute("href") ?? "";
expect(href).not.toContain("locale=");
});
});
// ---------------------------------------------------------------------------
// Tests: ContentListPage hook order stays stable across an erroring refetch
// (regression for #1415: inline useCallback for onLoadMore sat below the early
// returns, so a render that took the `error` guard ran one fewer hook → React
// #300 "Rendered fewer hooks than expected").
// ---------------------------------------------------------------------------
describe("ContentListPage hook order is stable when a refetch errors (#1415)", () => {
let mockFetch: ReturnType<typeof createMockFetch>;
beforeEach(() => {
mockFetch = createMockFetch();
mockFetch
.on("GET", "/_emdash/api/manifest", { data: MANIFEST })
.on("GET", "/_emdash/api/auth/me", {
data: { id: "user_01", role: 60 },
})
.on("GET", "/_emdash/api/content/posts", {
data: { items: [], nextCursor: undefined },
})
.on("GET", "/_emdash/api/content/posts/trashed", {
data: { items: [] },
});
});
afterEach(() => {
mockFetch.restore();
});
it("renders the ErrorScreen instead of crashing when a content refetch fails", async () => {
const { router, queryClient, TestApp } = buildRouter();
await router.navigate({
to: "/content/$collection",
params: { collection: "posts" },
});
const screen = await render(<TestApp />);
// First render succeeds and reaches the inline onLoadMore useCallback.
const addNewLink = screen.getByRole("link", { name: /add new/i });
await expect.element(addNewLink).toBeInTheDocument();
// Make the next content fetch fail, then force a refetch. The component
// re-renders with `error` truthy and takes the ErrorScreen early return,
// which sits ABOVE the onLoadMore useCallback. If that callback hook is
// below the guards, this render runs one fewer hook and React throws #300
// — so the ErrorScreen (and its Retry button) never appears.
mockFetch.on("GET", "/_emdash/api/content/posts", { error: { message: "Boom" } }, 500);
await queryClient.refetchQueries({ queryKey: ["content", "posts"] });
const retryButton = screen.getByRole("button", { name: /retry/i });
await expect.element(retryButton).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Tests: ContentNewPage locale passed to createContent
// ---------------------------------------------------------------------------
describe("ContentNewPage locale passed to createContent", () => {
let mockFetch: ReturnType<typeof createMockFetch>;
beforeEach(() => {
mockFetch = createMockFetch();
mockFetch
.on("GET", "/_emdash/api/manifest", { data: MANIFEST })
.on("GET", "/_emdash/api/auth/me", {
data: { id: "user_01", role: 60 },
})
.on("GET", "/_emdash/api/bylines", { data: { items: [] } })
.on("POST", "/_emdash/api/content/posts", {
data: {
item: {
id: "new_01",
type: "posts",
slug: null,
status: "draft",
locale: "de",
translationGroup: null,
data: { title: "Test Post" },
authorId: null,
primaryBylineId: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
publishedAt: null,
scheduledAt: null,
liveRevisionId: null,
draftRevisionId: null,
},
},
});
});
afterEach(() => {
mockFetch.restore();
});
it("passes locale=de to the API when ?locale=de is in the URL", async () => {
// The default locale is fr; navigating with ?locale=de tests that the
// non-default locale is read from search params and forwarded to createContent.
const { router, TestApp } = buildRouter();
await router.navigate({
to: "/content/$collection/new",
params: { collection: "posts" },
search: { locale: "de" },
});
const screen = await render(<TestApp />);
// Wait for the editor to appear (manifest must have loaded)
await expect
.element(screen.getByRole("button", { name: "Save", exact: true }))
.toBeInTheDocument();
// Capture outgoing requests
const requests: { url: string; body: unknown }[] = [];
const origFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/content/posts") && init?.method === "POST") {
const body = init.body ? JSON.parse(init.body as string) : null;
requests.push({ url, body });
}
return origFetch(input, init);
};
await screen.getByRole("button", { name: "Save", exact: true }).click();
globalThis.fetch = origFetch;
// After the fix: the POST body must include locale: "de"
expect(requests).toHaveLength(1);
expect(requests[0]!.body).toMatchObject({ locale: "de" });
});
});
// ---------------------------------------------------------------------------
// Tests: ContentEditPage autosave cache stays in sync
// ---------------------------------------------------------------------------
describe("ContentEditPage autosave cache patching", () => {
let mockFetch: ReturnType<typeof createMockFetch>;
beforeEach(() => {
mockFetch = createMockFetch();
const manifestWithRevisions: AdminManifest = {
...MANIFEST,
i18n: undefined,
collections: {
posts: {
...MANIFEST.collections.posts,
supports: ["drafts", "revisions"],
},
},
};
mockFetch
.on("GET", "/_emdash/api/manifest", { data: manifestWithRevisions })
.on("GET", "/_emdash/api/auth/me", {
data: { id: "user_01", role: 30 },
})
.on("GET", "/_emdash/api/bylines", { data: { items: [] } })
.on("GET", "/_emdash/api/content/posts/post_1", {
data: {
item: {
id: "post_1",
type: "posts",
slug: "published-slug",
status: "draft",
locale: "en",
translationGroup: null,
data: { title: "Published Title" },
authorId: null,
primaryBylineId: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
publishedAt: "2025-01-01T00:00:00Z",
scheduledAt: null,
liveRevisionId: "rev_live",
draftRevisionId: "rev_draft",
},
},
})
.on("GET", "/_emdash/api/content/posts/post_2", {
data: {
item: {
id: "post_2",
type: "posts",
slug: "second-post",
status: "draft",
locale: "en",
translationGroup: null,
data: { title: "Second Post" },
authorId: null,
primaryBylineId: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
publishedAt: null,
scheduledAt: null,
liveRevisionId: null,
draftRevisionId: null,
},
},
})
.on("GET", "/_emdash/api/revisions/rev_draft", {
data: {
item: {
id: "rev_draft",
collection: "posts",
entryId: "post_1",
data: { title: "Draft Title", _slug: "draft-slug" },
authorId: null,
createdAt: "2025-01-01T00:00:00Z",
},
},
})
.on("PUT", "/_emdash/api/content/posts/post_1", {
data: {
item: {
id: "post_1",
type: "posts",
slug: "published-slug",
status: "draft",
locale: "en",
translationGroup: null,
data: { title: "Published Title" },
authorId: null,
primaryBylineId: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
publishedAt: "2025-01-01T00:00:00Z",
scheduledAt: null,
liveRevisionId: "rev_live",
draftRevisionId: "rev_draft",
},
},
});
});
afterEach(() => {
mockFetch.restore();
});
it("keeps the edited draft title and slug after autosave completes", async () => {
const { router, TestApp } = buildRouter();
await router.navigate({
to: "/content/$collection/$id",
params: { collection: "posts", id: "post_1" },
});
const screen = await render(<TestApp />);
await waitFor(() => {
expect(screen.getByTestId("mock-title").element().textContent).toBe("Draft Title");
expect(screen.getByTestId("mock-slug").element().textContent).toBe("draft-slug");
});
await screen.getByRole("button", { name: "Trigger Draft Sync" }).click();
await waitFor(() => {
expect(screen.getByTestId("mock-title").element().textContent).toBe("Autosaved Title");
expect(screen.getByTestId("mock-slug").element().textContent).toBe("autosaved-title");
});
});
it("does not report auxiliary writes as saving; editor saves still do", async () => {
const { router, TestApp } = buildRouter();
await router.navigate({
to: "/content/$collection/$id",
params: { collection: "posts", id: "post_1" },
});
const screen = await render(<TestApp />);
await waitFor(() => {
expect(screen.getByTestId("mock-title").element().textContent).toBe("Draft Title");
});
// Hold every PUT open so the mutation's pending window is observable.
const fetchWithMocks = globalThis.fetch;
let resolvePut: (() => void) | undefined;
globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (init?.method === "PUT" && url.includes("/content/posts/post_1")) {
return new Promise<Response>((resolve) => {
resolvePut = () =>
resolve(
new Response(
JSON.stringify({
data: {
item: {
id: "post_1",
type: "posts",
slug: "published-slug",
status: "draft",
locale: "en",
data: { title: "Published Title" },
updatedAt: "2025-01-02T00:00:00Z",
draftRevisionId: "rev_draft",
},
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
});
}
return fetchWithMocks(input, init);
}) as typeof fetch;
try {
// Auxiliary write (SEO): the Save control must stay idle while it flies.
await screen.getByRole("button", { name: "Trigger SEO Sync" }).click();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(screen.getByTestId("is-saving").element().textContent).toBe("idle");
expect(screen.getByTestId("manual-save-blocked").element().textContent).toBe("blocked");
await expect
.element(screen.getByRole("button", { name: "Save", exact: true }))
.toBeDisabled();
resolvePut?.();
resolvePut = undefined;
await waitFor(() => {
expect(screen.getByTestId("manual-save-blocked").element().textContent).toBe("ready");
});
// Editor save: the same mutation with source "editor" must report saving.
await screen.getByRole("button", { name: "Save", exact: true }).click();
await waitFor(() => {
expect(screen.getByTestId("is-saving").element().textContent).toBe("saving");
expect(screen.getByTestId("manual-save-blocked").element().textContent).toBe("blocked");
});
resolvePut?.();
await waitFor(() => {
expect(screen.getByTestId("is-saving").element().textContent).toBe("idle");
});
} finally {
globalThis.fetch = fetchWithMocks;
}
});
it("keeps editor save feedback visual without strengthening main's operation gating", async () => {
const { router, TestApp } = buildRouter();
await router.navigate({
to: "/content/$collection/$id",
params: { collection: "posts", id: "post_1" },
});
const screen = await render(<TestApp />);
await waitFor(() => {
expect(screen.getByTestId("mock-title").element().textContent).toBe("Draft Title");
});
const fetchWithMocks = globalThis.fetch;
const resolvers: (() => void)[] = [];
globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (init?.method === "PUT" && url.includes("/content/posts/post_1")) {
return new Promise<Response>((resolve) => {
resolvers.push(() =>
resolve(
new Response(
JSON.stringify({
data: {
item: {
id: "post_1",
type: "posts",
slug: "published-slug",
status: "draft",
locale: "en",
data: { title: "Published Title" },
updatedAt: "2025-01-02T00:00:00Z",
draftRevisionId: "rev_draft",
},
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
),
);
});
}
return fetchWithMocks(input, init);
}) as typeof fetch;
try {
await screen.getByRole("button", { name: "Save", exact: true }).click();
await waitFor(() => {
expect(screen.getByTestId("is-saving").element().textContent).toBe("saving");
});
// Auxiliary write lands while the editor save is still in flight.
await screen.getByRole("button", { name: "Trigger SEO Sync" }).click();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(screen.getByTestId("is-saving").element().textContent).toBe("saving");
// Main's shared mutation observer follows the latest auxiliary write.
// When it settles, operation gating becomes idle even though the older
// editor request is still running; feedback remains visual-only.
expect(resolvers).toHaveLength(2);
resolvers[1]?.();
await waitFor(() => {
expect(screen.getByTestId("manual-save-blocked").element().textContent).toBe("ready");
});
expect(screen.getByTestId("is-saving").element().textContent).toBe("saving");
await expect.element(screen.getByRole("button", { name: "Save", exact: true })).toBeEnabled();
resolvers[0]?.();
await waitFor(() => {
expect(screen.getByTestId("is-saving").element().textContent).toBe("idle");
});
} finally {
globalThis.fetch = fetchWithMocks;
}
});
it("does not deliver an old entry's autosave completion to the current entry", async () => {
const { router, TestApp } = buildRouter();
await router.navigate({
to: "/content/$collection/$id",
params: { collection: "posts", id: "post_1" },
});
const screen = await render(<TestApp />);
await waitFor(() => {
expect(screen.getByTestId("mock-title").element().textContent).toBe("Draft Title");
});
const fetchWithMocks = globalThis.fetch;
let resolveFirstAutosave: (() => void) | undefined;
globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (init?.method === "PUT" && url.includes("/content/posts/post_1")) {
return new Promise<Response>((resolve) => {
resolveFirstAutosave = () =>
resolve(
new Response(
JSON.stringify({
data: {
item: {
id: "post_1",
type: "posts",
slug: "autosaved-title",
status: "draft",
locale: "en",
data: { title: "Autosaved Title" },
updatedAt: "2025-01-02T00:00:00Z",
draftRevisionId: "rev_draft",
},
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
});
}
return fetchWithMocks(input, init);
}) as typeof fetch;
try {
await screen.getByRole("button", { name: "Trigger Draft Sync" }).click();
await router.navigate({
to: "/content/$collection/$id",
params: { collection: "posts", id: "post_2" },
});
await waitFor(() => {
expect(screen.getByTestId("mock-title").element().textContent).toBe("Second Post");
});
expect(screen.getByTestId("manual-save-blocked").element().textContent).toBe("ready");
expect(screen.getByTestId("autosave-blocked").element().textContent).toBe("blocked");
await expect.element(screen.getByRole("button", { name: "Save", exact: true })).toBeEnabled();
resolveFirstAutosave?.();
await waitFor(() => {
expect(screen.getByTestId("autosave-blocked").element().textContent).toBe("ready");
});
expect(screen.getByTestId("autosave-completion-token").element().textContent).toBe("0");
} finally {
globalThis.fetch = fetchWithMocks;
}
});
});