Files
plainleaf/e2e/fixtures.ts
T
2026-04-14 20:59:23 +02:00

188 lines
5.7 KiB
TypeScript

import { test as base, type Page } from "@playwright/test";
import { type ChildProcess, spawn } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import { platform, tmpdir } from "node:os";
import { dirname, join } from "node:path";
/** The platform-appropriate modifier key: Meta on macOS, Control elsewhere. */
export const mod = platform() === "darwin" ? "Meta" : "Control";
export type SBServer = {
url: string;
port: number;
spaceDir: string;
/** Stop the server process (simulates "server down"). */
stop: () => Promise<void>;
};
type SBFixtures = {
spaceFiles: Record<string, string>;
sbServer: SBServer;
sbPage: Page;
sbPageWithSync: Page;
};
async function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer();
srv.listen(0, "127.0.0.1", () => {
const addr = srv.address() as net.AddressInfo;
srv.close(() => resolve(addr.port));
});
srv.on("error", reject);
});
}
async function waitForServer(url: string, timeoutMs = 30_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const resp = await fetch(url);
if (resp.ok) return;
} catch {
// server not ready yet
}
await new Promise((r) => setTimeout(r, 200));
}
throw new Error(`Server did not become ready at ${url} within ${timeoutMs}ms`);
}
export const test = base.extend<SBFixtures>({
spaceFiles: [{}, { option: true }],
sbServer: async ({ spaceFiles }, use) => {
const spaceDir = await mkdtemp(join(tmpdir(), "sb-e2e-"));
// Seed space with files
for (const [path, content] of Object.entries(spaceFiles)) {
const fullPath = join(spaceDir, path);
await mkdir(dirname(fullPath), { recursive: true });
await writeFile(fullPath, content);
}
const port = await getFreePort();
const proc: ChildProcess = spawn(
"./silverbullet",
[spaceDir, "-p", String(port), "-L", "127.0.0.1"],
{
cwd: join(import.meta.dirname, ".."),
stdio: ["ignore", "pipe", "pipe"],
},
);
let serverOutput = "";
proc.stdout?.on("data", (d: Buffer) => {
serverOutput += d.toString();
});
proc.stderr?.on("data", (d: Buffer) => {
serverOutput += d.toString();
});
const url = `http://127.0.0.1:${port}`;
try {
await waitForServer(`${url}/.ping`);
} catch (err) {
proc.kill("SIGKILL");
throw new Error(`Server failed to start. Output:\n${serverOutput}\n${err}`);
}
let stopped = false;
const stop = (): Promise<void> => {
if (stopped) return Promise.resolve();
stopped = true;
return new Promise<void>((resolve) => {
const timer = setTimeout(() => {
proc.kill("SIGKILL");
resolve();
}, 5000);
proc.on("exit", () => {
clearTimeout(timer);
resolve();
});
proc.kill("SIGTERM");
});
};
await use({ url, port, spaceDir, stop });
// Cleanup
await stop();
await rm(spaceDir, { recursive: true, force: true });
},
sbPage: async ({ sbServer, page }, use) => {
await gotoSilverBulletPage(page, sbServer);
await use(page);
},
sbPageWithSync: async ({ sbServer, page }, use) => {
await page.goto(sbServer.url);
await page.locator("#sb-editor .cm-editor").waitFor({ state: "visible", timeout: 30_000 });
await use(page);
},
});
/**
* Navigate to a SilverBullet page in the test space and wait for the editor
* to be visible. Disables the service worker (`?enableSW=0`) to match how the
* `sbPage` fixture boots, so the test environment is consistent across tests.
*
* `pagePath` is the SilverBullet page name without the `.md` extension. Pass
* an empty string (the default) to land on the index page. Each path segment
* is URL-encoded so names with spaces or other special characters work.
*/
export async function gotoSilverBulletPage(
page: Page,
sbServer: SBServer,
pagePath = "",
): Promise<void> {
const encoded = pagePath.split("/").map(encodeURIComponent).join("/");
// headless=1 makes the client expose `__sbRuntimeAPIReady` on window once
// the initial index completes — `waitForEditorReady` relies on it.
await page.goto(`${sbServer.url}/${encoded}?enableSW=0&headless=1`);
await page.locator("#sb-editor .cm-editor").waitFor({ state: "visible", timeout: 30_000 });
}
/**
* Wait for the client to save (indicated by #sb-current-page having class "sb-saved"),
* then fetch the page content from the server's filesystem API.
*/
export async function waitForSaveAndReadFromServer(
page: Page,
sbServer: SBServer,
pagePath: string,
): Promise<string> {
// Wait for the save indicator — "sb-saved" class on the page name element
await page.locator("#sb-current-page.sb-saved").waitFor({ state: "attached", timeout: 10_000 });
// Fetch the file from the server
const resp = await fetch(`${sbServer.url}/.fs/${pagePath}`);
if (!resp.ok) {
throw new Error(`Failed to read ${pagePath} from server: ${resp.status}`);
}
return resp.text();
}
/**
* Wait for the SilverBullet client's initial index to complete.
*
* The client sets `window.__sbRuntimeAPIReady = true` after its first full
* index drains (see `client/client.ts`). Completion of that index also
* triggers `editor:reloadState` → `rebuildEditorState()`, which resets the
* CodeMirror editor. If that reset fires mid-type, the cursor jumps to
* position 0 and synthetic input splits across the document. Waiting on
* this global before typing into a fresh page sidesteps the race without
* introducing test-only hooks into the client.
*/
export async function waitForEditorReady(page: Page): Promise<void> {
await page.waitForFunction(
() => (globalThis as any).__sbRuntimeAPIReady === true,
undefined,
{ timeout: 15_000 },
);
}
export { expect } from "@playwright/test";