Files
lda-wf/web/apps/console/e2e/presentation-sync.spec.ts
T

223 lines
7.9 KiB
TypeScript

import { createServer } from "node:net";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { once } from "node:events";
import { fileURLToPath } from "node:url";
import { expect, test, type Browser, type BrowserContext, type Page } from "@playwright/test";
const INITIAL_HASH = "#scene/thesis/title";
const ARCHITECTURE_FOCUS_HASH =
"#scene/architecture/runtime/focus/runtime-providers/configured-providers";
const DISCUSSION_HASH = "#discuss/where-is-ai-agent";
const serverEntry = fileURLToPath(new URL("../../server/dist/index.js", import.meta.url));
let server: ChildProcessWithoutNullStreams;
let baseUrl: string;
const reservePort = async (): Promise<number> => {
const listener = createServer();
listener.listen(0, "127.0.0.1");
await once(listener, "listening");
const address = listener.address();
if (address === null || typeof address === "string") {
listener.close();
throw new Error("Could not reserve an E2E server port");
}
const { port } = address;
listener.close();
await once(listener, "close");
return port;
};
const waitForServer = async (child: ChildProcessWithoutNullStreams): Promise<void> => {
let output = "";
child.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
for (let attempt = 0; attempt < 100; attempt += 1) {
if (child.exitCode !== null) {
throw new Error(`E2E server exited with ${child.exitCode}:\n${output}`);
}
try {
const response = await fetch(baseUrl);
if (response.ok) return;
} catch {
// The isolated process has not bound its port yet.
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`E2E server did not become ready:\n${output}`);
};
const stopServer = async (): Promise<void> => {
if (server.exitCode !== null) return;
const exited = once(server, "exit");
server.kill("SIGTERM");
const exitedGracefully = await Promise.race([
exited.then(() => true),
new Promise<false>((resolve) => setTimeout(() => resolve(false), 7_000)),
]);
if (exitedGracefully || server.exitCode !== null) return;
// Never reach beyond the child created by this test when graceful server
// shutdown fails; force only that recorded process and wait for its exit.
const forcedExit = once(server, "exit");
server.kill("SIGKILL");
await forcedExit;
};
const hashOf = (page: Page): string => new URL(page.url()).hash;
const expectHashes = async (expected: string, ...pages: readonly Page[]): Promise<void> => {
await expect.poll(() => pages.map(hashOf)).toEqual(pages.map(() => expected));
};
const startSession = async (
page: Page,
creatorPath: "/present" | "/presenter",
): Promise<string> => {
await page.getByRole("button", { name: "Pair presentation" }).click();
await page.getByRole("button", { name: "Start session" }).click();
const code = page.locator(".presentation-pairing__code");
await expect(code).toBeVisible();
const value = (await code.textContent())?.trim() ?? "";
const joinPath = creatorPath === "/presenter" ? "/present" : "/presenter";
const joinUrl = `${baseUrl}${joinPath}?pair=${value}`;
await expect(page.getByRole("img", { name: "Pairing QR code" })).toHaveAttribute(
"data-qr-value",
joinUrl,
);
await expect(page.getByRole("link", { name: "Copyable join URL" })).toHaveAttribute(
"href",
joinUrl,
);
return value;
};
const expectConnected = async (...pages: readonly Page[]): Promise<void> => {
await Promise.all(
pages.map((page) =>
expect(page.getByRole("status", { name: "Connected" })).toBeVisible(),
),
);
};
const openPair = async (
browser: Browser,
creatorPath: "/present" | "/presenter",
joinMethod: "link" | "code" = "link",
): Promise<{
creatorContext: BrowserContext;
creator: Page;
joinerContext: BrowserContext;
joiner: Page;
}> => {
const creatorIsPresenter = creatorPath === "/presenter";
const creatorContext = await browser.newContext({
viewport: creatorIsPresenter ? { width: 390, height: 844 } : { width: 1280, height: 720 },
});
const joinerContext = await browser.newContext({
viewport: creatorIsPresenter ? { width: 1280, height: 720 } : { width: 390, height: 844 },
});
const creator = await creatorContext.newPage();
await creator.goto(`${baseUrl}${creatorPath}${INITIAL_HASH}`);
const code = await startSession(creator, creatorPath);
expect(code).toMatch(/^[A-Z0-9]{6}$/);
const joinerPath = creatorIsPresenter ? "/present" : "/presenter";
const joiner = await joinerContext.newPage();
if (joinMethod === "link") {
await joiner.goto(`${baseUrl}${joinerPath}?pair=${code}${INITIAL_HASH}`);
} else {
await joiner.goto(`${baseUrl}${joinerPath}${INITIAL_HASH}`);
await joiner.getByRole("button", { name: "Pair presentation" }).click();
await joiner.getByLabel("Pairing code").fill(code);
await joiner.getByRole("button", { name: "Join session" }).click();
}
await expectConnected(creator, joiner);
await expectHashes(INITIAL_HASH, creator, joiner);
return { creatorContext, creator, joinerContext, joiner };
};
test.beforeAll(async () => {
const port = await reservePort();
baseUrl = `http://127.0.0.1:${port}`;
server = spawn(process.execPath, [serverEntry], {
env: {
...process.env,
WEB_HOST: process.env.PRESENTATION_E2E_BIND_HOST ?? "127.0.0.1",
WEB_PORT: String(port),
},
stdio: ["pipe", "pipe", "pipe"],
});
await waitForServer(server);
});
test.afterAll(async () => {
await stopServer();
});
test("synchronizes presenter and audience navigation, reload, fidelity, and termination", async ({ browser }) => {
const { creatorContext: phoneContext, creator: phone, joinerContext: audienceContext, joiner: audience } =
await openPair(browser, "/presenter");
try {
await phone.getByRole("link", { name: "Next →", exact: true }).click();
await expectHashes("#scene/thesis/substrate", phone, audience);
await audience.keyboard.press("ArrowLeft");
await expectHashes(INITIAL_HASH, phone, audience);
await audience.evaluate((hash) => {
window.location.hash = hash;
}, DISCUSSION_HASH);
await expectHashes(DISCUSSION_HASH, phone, audience);
await expect(phone.getByText("Q&A", { exact: true }).first()).toBeVisible();
await audience.evaluate((hash) => {
window.location.hash = hash;
}, ARCHITECTURE_FOCUS_HASH);
await expectHashes(ARCHITECTURE_FOCUS_HASH, phone, audience);
await phone.reload();
await expectConnected(phone, audience);
await expectHashes(ARCHITECTURE_FOCUS_HASH, phone, audience);
await phone.getByRole("button", { name: "End presentation" }).click();
await phone.getByRole("button", { name: "End presentation now" }).click();
await Promise.all([
expect(phone.getByText("The presenter ended this session.")).toBeVisible(),
expect(audience.getByText("The presenter ended this session.")).toBeVisible(),
]);
} finally {
await phoneContext.close();
await audienceContext.close();
}
});
test("supports symmetric creation from the audience route", async ({ browser }) => {
const { creatorContext: audienceContext, creator: audience, joinerContext: phoneContext, joiner: phone } =
await openPair(browser, "/present", "code");
try {
await audience.keyboard.press("ArrowRight");
await expectHashes("#scene/thesis/substrate", audience, phone);
await phone.getByRole("link", { name: "← Previous", exact: true }).click();
await expectHashes(INITIAL_HASH, audience, phone);
await phone.getByRole("button", { name: "End presentation" }).click();
await phone.getByRole("button", { name: "End presentation now" }).click();
await Promise.all([
expect(phone.getByText("The presenter ended this session.")).toBeVisible(),
expect(audience.getByText("The presenter ended this session.")).toBeVisible(),
]);
} finally {
await audienceContext.close();
await phoneContext.close();
}
});