Files
plainleaf/e2e/multi-space-service-worker.test.ts
T
2026-07-23 14:47:31 +02:00

179 lines
5.9 KiB
TypeScript

import { type ChildProcess, execFileSync, spawn } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, type Page, test } from "@playwright/test";
import {
ADMIN_PASSWORD,
ADMIN_USER,
getFreePort,
waitForServer,
} from "./fixtures";
/**
* A space bound at "/" registers its service worker at scope "/", which covers
* every path on the origin — including the prefixes of *other* spaces. These
* tests pin down that such a worker must not answer navigations belonging to a
* different space with its own cached app shell: that shell carries the root
* space's `<base href="/">`, so the client would boot resolving `.config` and
* `/.fs/*` against the server root and silently load the wrong space.
*
* Unlike the rest of the multi-space e2e suite, these run with the service
* worker ENABLED — the bug is unreachable without it.
*/
const BIN = "./target/debug/silverbullet";
const CWD = join(import.meta.dirname, "..");
let proc: ChildProcess;
let rootDir: string;
let base: string;
let cookie: string;
/** Call the admin API (nested at `/.spaces/api/admin`) as the logged-in admin. */
async function admin(method: string, path: string, body?: unknown) {
const resp = await fetch(`${base}/.spaces/api/admin/${path}`, {
method,
headers: { "Content-Type": "application/json", Cookie: cookie },
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!resp.ok) {
throw new Error(`${method} ${path} -> ${resp.status} ${await resp.text()}`);
}
return resp.json();
}
/** Create a public space so the browser side needs no login. */
async function createSpace(name: string, prefix: string): Promise<string> {
const { id } = await admin("POST", "spaces", {
name,
folder: join(rootDir, `${name}-space`),
binding: { prefix },
public: true,
seedIndex: true,
});
return id;
}
/**
* Load the root space and wait until its service worker controls the page —
* that worker is what the assertions below guard. Each test gets a fresh
* browser context, so this re-registers per test.
*/
async function registerRootServiceWorker(page: Page) {
await page.goto(`${base}/`);
await page.waitForFunction(
() => !!navigator.serviceWorker.controller,
undefined,
{
timeout: 30_000,
},
);
// Let the worker finish precaching the app shell before we navigate away.
await page.waitForTimeout(2000);
}
test.beforeAll(async () => {
rootDir = await mkdtemp(join(tmpdir(), "sb-sw-scope-e2e-"));
execFileSync(
BIN,
["setup", rootDir, "--admin", `${ADMIN_USER}:${ADMIN_PASSWORD}`],
{
cwd: CWD,
stdio: "pipe",
},
);
const port = await getFreePort();
proc = spawn(BIN, [rootDir, "-p", String(port), "-L", "127.0.0.1"], {
cwd: CWD,
stdio: ["ignore", "pipe", "pipe"],
// Note: SB_DISABLE_SERVICE_WORKER is deliberately NOT set here.
env: { ...process.env, SB_RUNTIME_API: "0" },
});
base = `http://127.0.0.1:${port}`;
await waitForServer(`${base}/.spaces`);
const login = await fetch(`${base}/.spaces/api/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: ADMIN_USER, password: ADMIN_PASSWORD }),
});
cookie = login.headers
.getSetCookie()
.map((c) => c.split(";")[0])
.join("; ");
// The root-bound space whose service worker shadows the whole origin. Both
// tests depend on it, and only one space can hold "/" — so it is created
// once here rather than per test.
await createSpace("root", "/");
});
test.afterAll(async () => {
proc?.kill();
await rm(rootDir, { recursive: true, force: true });
});
test("a sibling space is not shadowed by the root space's service worker", async ({
page,
}) => {
await registerRootServiceWorker(page);
await createSpace("sibling", "/sibling");
await page.goto(`${base}/sibling/`);
// The decisive assertion: the client must boot against the sibling space's
// base URL. When the root worker serves its own cached shell instead, this
// is `${base}/` and the client silently loads the root space.
expect(await page.evaluate(() => document.baseURI)).toBe(`${base}/sibling/`);
});
test("a space moved to a different prefix still boots", async ({ page }) => {
const id = await createSpace("moved", "/before");
await registerRootServiceWorker(page);
await admin("PATCH", `spaces/${id}`, { binding: { prefix: "/after" } });
await page.goto(`${base}/after/`);
expect(await page.evaluate(() => document.baseURI)).toBe(`${base}/after/`);
});
test("a sibling space's client assets are not answered with the app shell", async ({
page,
}) => {
// The root worker's scope covers `/private/.client/*`, but those files are
// not in its precache (whose keys are its own space's) and not in its local
// data — so it used to fall through to the SPA-shell fallback and answer a
// JavaScript module request with HTML. The browser refuses to execute that,
// and the login page it belongs to renders blank.
//
// A private space, because its login page is the one asset request a visitor
// makes before authenticating anywhere — the case a user actually hits.
await registerRootServiceWorker(page);
await admin("POST", "spaces", {
name: "private",
folder: join(rootDir, "private-space"),
binding: { prefix: "/private" },
public: false,
seedIndex: true,
});
const asset = await page.evaluate(async (url) => {
const response = await fetch(url);
return {
status: response.status,
body: (await response.text()).slice(0, 40),
};
}, `${base}/private/.client/auth.js`);
expect(asset.status).toBe(200);
expect(asset.body).not.toContain("<!doctype");
// And the page that needs it actually renders its form.
await page.goto(`${base}/private/`);
await expect(page.locator("#login")).toBeVisible({ timeout: 30_000 });
await expect(page.locator("#username")).toBeVisible();
});