Files

239 lines
7.3 KiB
TypeScript

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";
import { test as base, type Page } from "@playwright/test";
/** The platform-appropriate modifier key: Meta on macOS, Control elsewhere. */
export const mod = platform() === "darwin" ? "Meta" : "Control";
/**
* Full redo chord, per the "Editor: Redo" binding in editor_commands.ts:
* Cmd-Shift-z on macOS, Ctrl-y elsewhere. `${mod}+Shift+z` is NOT a redo
* anywhere but macOS -- it silently does nothing on Linux.
*/
export const redoChord = platform() === "darwin"
? "Meta+Shift+z"
: "Control+y";
/** The admin account every multi-space e2e test provisions via `silverbullet setup`. */
export const ADMIN_USER = "admin";
export const ADMIN_PASSWORD = "adminpw123";
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>;
disableServiceWorker: boolean;
singleSpace: boolean;
sbServer: SBServer;
sbPage: Page;
};
export 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);
});
}
export 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 type SpawnServerOptions = {
disableServiceWorker?: boolean;
singleSpace?: boolean;
};
/**
* Spawns a `silverbullet` server process against `spaceDir` on `port`, with
* stdout/stderr piped (not inherited). Shared by the `sbServer` fixture and
* any test that needs to spawn a second/replacement server directly (e.g.
* simulating a server restart) -- keeps the binary path, args shape, and env
* vars in one place.
*/
export function spawnServerProcess(
spaceDir: string,
port: number,
opts: SpawnServerOptions = {},
): ChildProcess {
const { disableServiceWorker = true, singleSpace = true } = opts;
const args = [spaceDir, "-p", String(port), "-L", "127.0.0.1"];
// A fresh empty temp dir boots into the setup wizard unless we force
// single-space mode; tests exercising the wizard pass singleSpace: false.
if (singleSpace) args.push("--single");
return spawn("./target/debug/silverbullet", args, {
cwd: join(import.meta.dirname, ".."),
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,
// Disable the server-side headless-Chrome runtime API: in
// `?headless=1` the client uses its own in-page runtime, so the
// e2e servers never need to spawn Chrome (and don't require it).
SB_RUNTIME_API: "0",
...(disableServiceWorker ? { SB_DISABLE_SERVICE_WORKER: "1" } : {}),
},
});
}
export const test = base.extend<SBFixtures>({
spaceFiles: [{}, { option: true }],
disableServiceWorker: [true, { option: true }],
singleSpace: [true, { option: true }],
sbServer: async ({ spaceFiles, disableServiceWorker, singleSpace }, 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 = spawnServerProcess(spaceDir, port, {
disableServiceWorker,
singleSpace,
});
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);
},
});
/**
* Navigate to a SilverBullet page in the test space and wait for the editor
* to be visible. The test server runs with `SB_DISABLE_SERVICE_WORKER=1`, so
* the boot path skips the service worker for deterministic test behavior.
*
* `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("/");
await page.goto(`${sbServer.url}/${encoded}?headless=1`);
await page
.locator("#sb-editor .cm-editor")
.waitFor({ state: "visible", timeout: 30_000 });
await waitForEditorReady(page);
}
/**
* Wait for a full unsaved → saved transition on the current page, then fetch
* the page content from the server's filesystem API.
*/
export async function waitForSaveAndReadFromServer(
page: Page,
sbServer: SBServer,
pagePath: string,
): Promise<string> {
const pageNameSel = "#sb-current-page";
// Observe `sb-unsaved` first so we're past the edits the caller just made.
await page
.locator(`${pageNameSel}.sb-unsaved`)
.waitFor({ state: "attached", timeout: 10_000 });
// Then wait for the round-trip back to `sb-saved` — this fires only after
// `space.writePage(...)` resolves, which in HTTP mode means after the
// server's PUT response.
await page
.locator(`${pageNameSel}.sb-saved`)
.waitFor({ state: "attached", timeout: 10_000 });
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 to finish booting widgets.
*/
export async function waitForEditorReady(page: Page): Promise<void> {
await page.waitForFunction(
() => (globalThis as any).sbRuntime?.ready === true,
undefined,
{ timeout: 15_000 },
);
}
export { expect } from "@playwright/test";