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
733 lines
24 KiB
TypeScript
733 lines
24 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|
||
});
|
||
});
|