Files
plainleaf/e2e/index-upgrade.test.ts

271 lines
9.8 KiB
TypeScript

import type { Page } from "@playwright/test";
import { expect, gotoSilverBulletPage, test } from "./fixtures.ts";
/**
* Tests for the `desiredIndexVersion` upgrade path.
*
* `client/data/object_index.ts` carries a `desiredIndexVersion` constant.
* Bumping it should cause the next-loaded client to run a full space
* reindex, but only once the client is sure it is on the same build as
* the server (and, for the SW-enabled path, the initial space sync has
* completed). These tests simulate "an old client booted after the
* server upgrade" by writing a stale value into the local datastore
* before reloading, then verifying that a real reindex actually fires
* (not just that the stored version number bumps — see the "Performing
* a full space reindex" log line).
*/
async function readIndexVersion(page: Page): Promise<number | undefined> {
return await page.evaluate(async () => {
const client = (globalThis as any).client;
if (!client) return undefined;
return (await client.ds.get(["$indexVersion"])) as number | undefined;
});
}
async function writeIndexVersion(page: Page, version: number): Promise<void> {
await page.evaluate(async (v) => {
const client = (globalThis as any).client;
if (!client) throw new Error("client not initialized");
await client.ds.set(["$indexVersion"], v);
}, version);
}
async function waitForIndexVersionAtLeast(
page: Page,
min: number,
timeoutMs = 30_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const v = await readIndexVersion(page);
if (typeof v === "number" && v >= min) return;
await new Promise((r) => setTimeout(r, 250));
}
const final = await readIndexVersion(page);
throw new Error(
`Index version did not reach ${min} within ${timeoutMs}ms (last value: ${String(final)})`,
);
}
async function waitForEditor(page: Page): Promise<void> {
await page
.locator("#sb-editor .cm-editor")
.waitFor({ state: "visible", timeout: 30_000 });
}
async function readFullIndexCompleted(
page: Page,
): Promise<boolean | undefined> {
return await page.evaluate(() => {
const client = (globalThis as any).client;
if (!client) return undefined;
return client.fullIndexCompleted as boolean;
});
}
/**
* Polls the live widget render-mode signals on the client. The
* `widgetRenderMode` helper (see client/codemirror/util.ts) returns
* "loading" whenever any of `systemReady`, `clientSystem.scriptsLoaded`,
* `fullIndexCompleted` or `pageListLoaded` is false; this captures the
* same set so a stale-version reload can prove the widgets don't drop
* back into loading mode.
*/
async function waitForWidgetsReady(
page: Page,
timeoutMs = 15_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let last: unknown;
while (Date.now() < deadline) {
last = await page.evaluate(() => {
const client = (globalThis as any).client;
if (!client) return null;
return {
systemReady: !!client.systemReady,
scriptsLoaded: !!client.clientSystem?.scriptsLoaded,
fullIndexCompleted: !!client.fullIndexCompleted,
pageListLoaded: !!client.pageListLoaded,
};
});
if (
last &&
typeof last === "object" &&
(last as Record<string, boolean>).systemReady &&
(last as Record<string, boolean>).scriptsLoaded &&
(last as Record<string, boolean>).fullIndexCompleted &&
(last as Record<string, boolean>).pageListLoaded
) {
return;
}
await new Promise((r) => setTimeout(r, 100));
}
throw new Error(
`Widgets did not reach the ready state within ${timeoutMs}ms (last state: ${JSON.stringify(last)})`,
);
}
/**
* Capture all `console` messages the page emits. The returned `messages`
* array is updated live; callers can check for substrings after the
* behavior of interest has had a chance to run.
*/
function captureConsole(page: Page): { messages: string[] } {
const messages: string[] = [];
page.on("console", (msg) => {
messages.push(msg.text());
});
return { messages };
}
const REINDEX_LOG = "Performing a full space reindex";
async function waitForReindexLog(
consoleState: { messages: string[] },
timeoutMs = 30_000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (consoleState.messages.some((m) => m.includes(REINDEX_LOG))) return;
await new Promise((r) => setTimeout(r, 100));
}
const tail = consoleState.messages.slice(-50).join("\n ");
throw new Error(
`Did not observe a "${REINDEX_LOG}" log line within ${timeoutMs}ms.\nLast console output:\n ${tail}`,
);
}
/** Marker we embed in a `space-style` block to look for in the DOM. */
const SPACE_STYLE_MARKER = "stale-reindex-marker-color: rebeccapurple";
async function customStylesContent(page: Page): Promise<string> {
return (await page.locator("#custom-styles").innerHTML()) ?? "";
}
test.describe("index version upgrade (service worker disabled)", () => {
test.use({
spaceFiles: {
"index.md": "# Index\nFirst page in the upgrade-test space.",
"Other.md": "# Other\nSee [[index]] for the entry point.",
"Styles.md": `# Styles\n\n\`\`\`space-style\n/* ${SPACE_STYLE_MARKER} */\n\`\`\`\n`,
},
});
test("stale index version triggers a real reindex on next boot", async ({
sbServer,
page,
}) => {
const consoleState = captureConsole(page);
// First boot: let the initial indexing run to completion so we know
// what value the current build considers "desired".
await gotoSilverBulletPage(page, sbServer);
await waitForEditor(page);
await waitForIndexVersionAtLeast(page, 1);
const baseline = await readIndexVersion(page);
expect(typeof baseline).toBe("number");
expect(baseline).toBeGreaterThan(0);
// First boot is a fresh install (no stored version), so there
// should be NO "Performing a full space reindex" log line yet.
expect(consoleState.messages.some((m) => m.includes(REINDEX_LOG))).toBe(
false,
);
// Simulate "this client was previously running an older build that
// stamped an older desiredIndexVersion into the datastore".
await writeIndexVersion(page, 1);
expect(await readIndexVersion(page)).toBe(1);
// Re-navigate (the SW-disabled path runs the reindex check at the
// end of `Client.init()`).
await gotoSilverBulletPage(page, sbServer);
await waitForEditor(page);
// A stale-but-present index should not flip widgets into the
// loading/spinner mode at boot — the existing entries are still
// queryable until the actual reindex starts. (Before this fix the
// boot-time flag was set from `hasFullIndexCompleted()`, which is
// `stored >= desired` and therefore false during the wait, and
// `updatePageListCache` would take the fallback branch and leave
// `pageListLoaded` false.)
expect(await readFullIndexCompleted(page)).toBe(true);
await waitForWidgetsReady(page);
// Custom styles must also load from the stale-but-present index —
// before this was wired up, `loadCustomStyles` bailed out on the
// strict `hasFullIndexCompleted` check and the `#custom-styles`
// element stayed empty.
expect(await customStylesContent(page)).toContain(SPACE_STYLE_MARKER);
// Critical assertion: a real reindex must actually have run, not
// just the stored version having been silently bumped.
await waitForReindexLog(consoleState);
await waitForIndexVersionAtLeast(page, baseline as number);
expect(await readIndexVersion(page)).toBe(baseline);
});
});
test.describe("index version upgrade (service worker enabled)", () => {
// SW + IndexedDB interactions are flaky in Firefox's Playwright
// implementation; mirror what pwa-offline does.
test.skip(
({ browserName }) => browserName !== "chromium",
"SW path only runs on Chromium",
);
test.describe.configure({ retries: 2 });
test.use({
disableServiceWorker: false,
spaceFiles: {
"index.md": "# Index\nFirst page in the SW-enabled upgrade test.",
"Other.md": "# Other\nLink back to [[index]].",
"Styles.md": `# Styles\n\n\`\`\`space-style\n/* ${SPACE_STYLE_MARKER} */\n\`\`\`\n`,
},
});
test("stale index version triggers a real reindex after sync + version match", async ({
sbServer,
page,
}) => {
const consoleState = captureConsole(page);
// First boot with the SW active: navigate without `?headless=1` so
// the service worker actually registers.
await page.goto(sbServer.url);
await waitForEditor(page);
await waitForIndexVersionAtLeast(page, 1);
const baseline = await readIndexVersion(page);
expect(typeof baseline).toBe("number");
expect(baseline).toBeGreaterThan(0);
expect(consoleState.messages.some((m) => m.includes(REINDEX_LOG))).toBe(
false,
);
await writeIndexVersion(page, 1);
expect(await readIndexVersion(page)).toBe(1);
// Re-navigate. The SW-enabled path triggers the reindex once both
// `space-sync-complete` and a matching `server-version` message
// have been observed.
await page.goto(sbServer.url);
await waitForEditor(page);
// Stale-but-present index should keep widgets in their ready state
// during the wait for sync + version-match (see the SW-disabled
// test for the rationale).
expect(await readFullIndexCompleted(page)).toBe(true);
await waitForWidgetsReady(page);
// Custom styles must also load from the stale-but-present index —
// before this was wired up, `loadCustomStyles` bailed out on the
// strict `hasFullIndexCompleted` check and the `#custom-styles`
// element stayed empty.
expect(await customStylesContent(page)).toContain(SPACE_STYLE_MARKER);
await waitForReindexLog(consoleState);
await waitForIndexVersionAtLeast(page, baseline as number);
expect(await readIndexVersion(page)).toBe(baseline);
});
});