Review 25424, point 1: the `if (!cancelled)` of ChangelogContent's .catch had no test (V1). Modelled on the late-answer test: the French file rejects after the English one was read, and the history still shows the English notes, not the empty message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
363 lines
13 KiB
TypeScript
363 lines
13 KiB
TypeScript
// ChangelogContent — the version history folds by version (#372).
|
|
//
|
|
// React's hooks come from the shared harness (src/test/hookHarness.ts): the
|
|
// component runs as a plain function, its state survives from one call to the
|
|
// next, `commit()` runs its effect (the fetch of the changelog), and the
|
|
// handlers of the rendered tree are called as clicks would. The markup is
|
|
// read through renderToStaticMarkup.
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
|
|
|
vi.mock("react", async (orig) => (await import("../../test/hookHarness")).fakeReact(await orig()));
|
|
const ctx = vi.hoisted(() => ({ language: "fr" }));
|
|
vi.mock("react-i18next", async () =>
|
|
(await import("../../test/i18nMock")).i18nMock({ language: () => ctx.language }),
|
|
);
|
|
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { hookHarness } from "../../test/hookHarness";
|
|
import ChangelogContent, { CHANGELOG_NOTES_ID_PREFIX } from "./ChangelogContent";
|
|
|
|
const harness = hookHarness();
|
|
|
|
const FR = [
|
|
"# Journal des modifications",
|
|
"",
|
|
"## [Non publié]",
|
|
"",
|
|
"## [0.17.2] - 2026-10-18",
|
|
"",
|
|
"### Ajouté",
|
|
"",
|
|
"- **Import** : note-0172-a",
|
|
"",
|
|
"### Corrigé",
|
|
"",
|
|
"- note-0172-b",
|
|
"",
|
|
"## [0.17.1] - 2026-10-08",
|
|
"",
|
|
"### Modifié",
|
|
"",
|
|
"- note-0171",
|
|
"",
|
|
"## 0.3.2",
|
|
"",
|
|
"### Ajouté",
|
|
"",
|
|
"- note-032",
|
|
"",
|
|
].join("\n");
|
|
|
|
const EN = FR.replace("Journal des modifications", "Changelog")
|
|
.replace("[Non publié]", "[Unreleased]")
|
|
.replace("note-0171", "note-0171-en");
|
|
|
|
/** FR with content under its unreleased heading, as in a development build. */
|
|
const FR_DEV = FR.replace("## [Non publié]\n", "## [Non publié]\n\n### Modifié\n\n- note-dev\n");
|
|
|
|
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
|
|
|
|
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
|
|
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
|
|
if (!isValidElement(node)) return [];
|
|
const el = node as AnyElement;
|
|
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
|
|
}
|
|
|
|
function texts(node: ReactNode): string[] {
|
|
if (Array.isArray(node)) return node.flatMap(texts);
|
|
if (typeof node === "string") return [node];
|
|
if (!isValidElement(node)) return [];
|
|
return texts((node as AnyElement).props.children);
|
|
}
|
|
|
|
interface FetchedFile {
|
|
text: () => Promise<string>;
|
|
}
|
|
|
|
let files: Record<string, string>;
|
|
/** The bundled files, `files` by URL; any other URL fails, as an unreadable file. */
|
|
const readFiles = async (url: string): Promise<FetchedFile> => {
|
|
if (!(url in files)) throw new Error(`unexpected fetch ${url}`);
|
|
return { text: async () => files[url] };
|
|
};
|
|
const fetchMock = vi.fn(readFiles);
|
|
|
|
/** Lets the fetch, `text()` and the state updates that follow them settle. */
|
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
/** One render of the mounted component. */
|
|
function render(installedVersion: string | null = "0.17.1"): ReactNode {
|
|
return harness.render(() => ChangelogContent({ installedVersion }) as ReactNode);
|
|
}
|
|
|
|
/** Mount, run the effect, let the file load, then render with the loaded state. */
|
|
async function mount(installedVersion: string | null = "0.17.1"): Promise<ReactNode> {
|
|
render(installedVersion);
|
|
harness.commit();
|
|
await settle();
|
|
return render(installedVersion);
|
|
}
|
|
|
|
/** The title button of `version`. */
|
|
function titleOf(tree: ReactNode, version: string): AnyElement {
|
|
const [button] = find(
|
|
tree,
|
|
(e) => e.type === "button" && e.props["aria-controls"] !== undefined && texts(e.props.children).includes(version),
|
|
);
|
|
expect(button, `title of ${version}`).toBeDefined();
|
|
return button;
|
|
}
|
|
|
|
/** The version titles, in order, with their state. */
|
|
function titles(tree: ReactNode): Array<[string, boolean]> {
|
|
return find(tree, (e) => e.type === "button" && e.props["aria-controls"] !== undefined).map((e) => [
|
|
texts(e.props.children).join(""),
|
|
e.props["aria-expanded"] as boolean,
|
|
]);
|
|
}
|
|
|
|
/** The expand-all button. */
|
|
function allButton(tree: ReactNode): AnyElement {
|
|
const [button] = find(tree, (e) => e.type === "button" && e.props["aria-controls"] === undefined);
|
|
expect(button, "expand-all button").toBeDefined();
|
|
return button;
|
|
}
|
|
|
|
const click = (button: AnyElement) => (button.props.onClick as () => void)();
|
|
|
|
beforeEach(() => {
|
|
harness.clear();
|
|
ctx.language = "fr";
|
|
files = { "/CHANGELOG.fr.md": FR, "/CHANGELOG.md": EN };
|
|
fetchMock.mockReset();
|
|
fetchMock.mockImplementation(readFiles);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("ChangelogContent — loading", () => {
|
|
it("reads the changelog of the app language", async () => {
|
|
await mount();
|
|
expect(fetchMock).toHaveBeenCalledWith("/CHANGELOG.fr.md");
|
|
|
|
harness.clear();
|
|
ctx.language = "en";
|
|
fetchMock.mockClear();
|
|
await mount();
|
|
expect(fetchMock).toHaveBeenCalledWith("/CHANGELOG.md");
|
|
});
|
|
|
|
it("shows the empty message until the file is read, and when it cannot be read", async () => {
|
|
expect(renderToStaticMarkup(render())).toContain("t(changelog.empty)");
|
|
|
|
harness.clear();
|
|
files = {};
|
|
const html = renderToStaticMarkup(await mount());
|
|
expect(html).toContain("t(changelog.empty)");
|
|
expect(html).not.toContain("aria-expanded");
|
|
});
|
|
});
|
|
|
|
describe("ChangelogContent — which versions open (#372)", () => {
|
|
it("opens on the installed version, every other version folded", async () => {
|
|
const tree = await mount("0.17.1");
|
|
expect(titles(tree)).toEqual([
|
|
["0.17.2", false],
|
|
["0.17.1", true],
|
|
["0.3.2", false],
|
|
]);
|
|
const html = renderToStaticMarkup(tree);
|
|
expect(html).toContain("note-0171");
|
|
expect(html).not.toContain("note-0172-a");
|
|
expect(html).not.toContain("note-032");
|
|
});
|
|
|
|
it("opens on the first published version when the installed version is unknown", async () => {
|
|
const tree = await mount(null);
|
|
expect(titles(tree)).toEqual([
|
|
["0.17.2", true],
|
|
["0.17.1", false],
|
|
["0.3.2", false],
|
|
]);
|
|
});
|
|
|
|
it("opens on the first published version when the installed one is not in the file", async () => {
|
|
const tree = await mount("0.18.0");
|
|
expect(titles(tree).filter(([, open]) => open)).toEqual([["0.17.2", true]]);
|
|
});
|
|
|
|
it("hides the empty unreleased heading", async () => {
|
|
const html = renderToStaticMarkup(await mount());
|
|
expect(html).not.toContain("Non publié");
|
|
});
|
|
|
|
it("keeps an unreleased heading with content, folded, before the versions", async () => {
|
|
files["/CHANGELOG.fr.md"] = FR_DEV;
|
|
const tree = await mount("0.17.2");
|
|
expect(titles(tree)).toEqual([
|
|
["Non publié", false],
|
|
["0.17.2", true],
|
|
["0.17.1", false],
|
|
["0.3.2", false],
|
|
]);
|
|
expect(renderToStaticMarkup(tree)).not.toContain("note-dev");
|
|
});
|
|
|
|
it("shows the notes of an open version, sections and items, without bold marks", async () => {
|
|
const html = renderToStaticMarkup(await mount("0.17.2"));
|
|
expect(html).toContain(">Ajouté</h4>");
|
|
expect(html).toContain(">Corrigé</h4>");
|
|
expect(html).toContain("Import : note-0172-a");
|
|
expect(html).not.toContain("**Import**");
|
|
expect(html).toContain("note-0172-b");
|
|
});
|
|
});
|
|
|
|
describe("ChangelogContent — folding by hand", () => {
|
|
it("unfolds a folded version and folds it again on its title", async () => {
|
|
let tree: ReactNode = await mount("0.17.1");
|
|
click(titleOf(tree, "0.3.2"));
|
|
tree = render();
|
|
expect(titles(tree)).toEqual([
|
|
["0.17.2", false],
|
|
["0.17.1", true],
|
|
["0.3.2", true],
|
|
]);
|
|
expect(renderToStaticMarkup(tree)).toContain("note-032");
|
|
|
|
click(titleOf(tree, "0.3.2"));
|
|
tree = render();
|
|
expect(titleOf(tree, "0.3.2").props["aria-expanded"]).toBe(false);
|
|
expect(renderToStaticMarkup(tree)).not.toContain("note-032");
|
|
});
|
|
|
|
it("folds the installed version on its title", async () => {
|
|
let tree: ReactNode = await mount("0.17.1");
|
|
click(titleOf(tree, "0.17.1"));
|
|
tree = render();
|
|
expect(titles(tree).every(([, open]) => !open)).toBe(true);
|
|
});
|
|
|
|
it("unfolds everything with « Expand all », then folds everything with « Collapse all »", async () => {
|
|
let tree: ReactNode = await mount("0.17.1");
|
|
expect(texts(allButton(tree).props.children)).toEqual(["t(changelog.expandAll)"]);
|
|
|
|
click(allButton(tree));
|
|
tree = render();
|
|
expect(titles(tree).every(([, open]) => open)).toBe(true);
|
|
expect(texts(allButton(tree).props.children)).toEqual(["t(changelog.collapseAll)"]);
|
|
const html = renderToStaticMarkup(tree);
|
|
for (const note of ["note-0172-a", "note-0171", "note-032"]) expect(html).toContain(note);
|
|
|
|
click(allButton(tree));
|
|
tree = render();
|
|
expect(titles(tree).every(([, open]) => !open)).toBe(true);
|
|
expect(texts(allButton(tree).props.children)).toEqual(["t(changelog.expandAll)"]);
|
|
});
|
|
|
|
it("offers « Collapse all » once every version is unfolded by hand", async () => {
|
|
let tree: ReactNode = await mount("0.17.1");
|
|
click(titleOf(tree, "0.17.2"));
|
|
tree = render();
|
|
expect(texts(allButton(tree).props.children)).toEqual(["t(changelog.expandAll)"]);
|
|
click(titleOf(tree, "0.3.2"));
|
|
tree = render();
|
|
expect(texts(allButton(tree).props.children)).toEqual(["t(changelog.collapseAll)"]);
|
|
});
|
|
});
|
|
|
|
describe("ChangelogContent — accessibility", () => {
|
|
it("makes each title a button inside its heading, with its state and the notes it controls", async () => {
|
|
const html = renderToStaticMarkup(await mount("0.17.1"));
|
|
// Heading > button with aria-expanded and aria-controls, chevron, version.
|
|
expect(html).toMatch(
|
|
/<h3[^>]*><button type="button" aria-expanded="true" aria-controls="changelog-notes-1" title="t\(changelog\.hideNotes\)"[^>]*><svg[^>]*aria-hidden="true"[^>]*>.*?<\/svg>0\.17\.1<\/button><\/h3>/,
|
|
);
|
|
expect(html).toMatch(
|
|
/<h3[^>]*><button type="button" aria-expanded="false" aria-controls="changelog-notes-0" title="t\(changelog\.showNotes\)"[^>]*><svg[^>]*aria-hidden="true"[^>]*>.*?<\/svg>0\.17\.2<\/button><\/h3>/,
|
|
);
|
|
// Every id an aria-controls names is in the page, folded or not.
|
|
const controlled = [...html.matchAll(/aria-controls="([^"]+)"/g)].map((m) => m[1]);
|
|
expect(controlled).toEqual([0, 1, 2].map((i) => `${CHANGELOG_NOTES_ID_PREFIX}${i}`));
|
|
for (const id of controlled) expect(html).toContain(`id="${id}"`);
|
|
});
|
|
|
|
it("names the expand-all button through t, never in hard text", async () => {
|
|
const html = renderToStaticMarkup(await mount("0.17.1"));
|
|
expect(html).toMatch(/<button type="button"[^>]*><svg[^>]*aria-hidden="true"[^>]*>.*?<\/svg>t\(changelog\.expandAll\)<\/button>/);
|
|
});
|
|
});
|
|
|
|
describe("ChangelogContent — language switch", () => {
|
|
it("reloads the file of the new language and opens the installed version again", async () => {
|
|
let tree: ReactNode = await mount("0.17.1");
|
|
click(allButton(tree));
|
|
ctx.language = "en";
|
|
render();
|
|
harness.commit();
|
|
await settle();
|
|
tree = render();
|
|
expect(fetchMock).toHaveBeenLastCalledWith("/CHANGELOG.md");
|
|
expect(titles(tree)).toEqual([
|
|
["0.17.2", false],
|
|
["0.17.1", true],
|
|
["0.3.2", false],
|
|
]);
|
|
expect(renderToStaticMarkup(tree)).toContain("note-0171-en");
|
|
});
|
|
|
|
it("never lets a late answer of the previous language replace the current one", async () => {
|
|
const pending: Array<{ url: string; resolve: (text: string) => void }> = [];
|
|
fetchMock.mockImplementation(
|
|
(url: string) =>
|
|
new Promise<FetchedFile>((resolveFetch) => {
|
|
pending.push({ url, resolve: (text) => resolveFetch({ text: async () => text }) });
|
|
}),
|
|
);
|
|
|
|
render("0.17.1");
|
|
harness.commit(); // asks for the French file
|
|
ctx.language = "en";
|
|
render("0.17.1");
|
|
harness.commit(); // cancels the French one, asks for the English file
|
|
expect(pending.map((p) => p.url)).toEqual(["/CHANGELOG.fr.md", "/CHANGELOG.md"]);
|
|
|
|
pending[1].resolve(EN);
|
|
await settle();
|
|
pending[0].resolve(FR);
|
|
await settle();
|
|
const html = renderToStaticMarkup(render("0.17.1"));
|
|
expect(html).toContain("note-0171-en");
|
|
expect(html).not.toMatch(/note-0171(?!-en)/);
|
|
});
|
|
|
|
it("never lets a late failure of the previous language empty the current history", async () => {
|
|
const pending: Array<{ url: string; resolve: (text: string) => void; reject: (error: Error) => void }> = [];
|
|
fetchMock.mockImplementation(
|
|
(url: string) =>
|
|
new Promise<FetchedFile>((resolveFetch, rejectFetch) => {
|
|
pending.push({ url, resolve: (text) => resolveFetch({ text: async () => text }), reject: rejectFetch });
|
|
}),
|
|
);
|
|
|
|
render("0.17.1");
|
|
harness.commit(); // asks for the French file
|
|
ctx.language = "en";
|
|
render("0.17.1");
|
|
harness.commit(); // cancels the French one, asks for the English file
|
|
expect(pending.map((p) => p.url)).toEqual(["/CHANGELOG.fr.md", "/CHANGELOG.md"]);
|
|
|
|
pending[1].resolve(EN);
|
|
await settle();
|
|
pending[0].reject(new Error("the French file could not be read"));
|
|
await settle();
|
|
const html = renderToStaticMarkup(render("0.17.1"));
|
|
expect(html).toContain("note-0171-en");
|
|
expect(html).not.toContain("t(changelog.empty)");
|
|
});
|
|
});
|