recipes/room/main.ts
One file of recipes/room, at commit 9a5bb24.
// The room on the page under the gate: an act module stored on the server runs in a frame on
// the page, shares the board both ways, asks through the page's identity, routes on the page's
// URL, and what goes wrong inside reaches the page as data. Every check is made through a real
// browser, because no fake DOM enforces a frame's isolation.
//
// Run: AWEFT_DEFAULT_H=@aweftjs/ui node --import @aweftjs/build/loader recipes/room/main.ts
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { gzipSync } from 'node:zlib';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { type Frame, chromium } from 'playwright';
import { build, createServer as createViteServer } from 'vite';
import { errors } from '@aweftjs/logs';
// These run inside the browser, where tsc has no DOM lib. Declared loosely so the file
// typechecks in Node; the browser supplies the real ones.
declare const document: { getElementById(id: string): unknown };
declare const location: { origin: string };
declare function getComputedStyle(element: unknown): { paddingLeft: string };
const here = fileURLToPath(new URL('.', import.meta.url));
let checks = 0;
let failed = 0;
const check = (ok: boolean, what: string): void => {
checks += 1;
if (!ok) failed += 1;
console.log(` ${ok ? 'ok ' : 'FAIL'} ${what}`);
};
// --- the backend, on a free port -------------------------------------------------------------
process.env.PORT = '0';
const backend = await import('./backend/main.ts');
process.env.AWEFT_BACKEND_PORT = String(backend.listener.port);
// --- the room bundle, then the dev server with the backend behind it ---------------------------
await build({ configFile: join(here, 'page', 'room.config.ts'), logLevel: 'warn' });
const bundle = join(here, 'page', 'public', 'room');
const files = readdirSync(bundle).filter((name) => name.endsWith('.js'));
const bytes = files.reduce((sum, name) => sum + statSync(join(bundle, name)).size, 0);
const zipped = files.reduce((sum, name) => sum + gzipSync(readFileSync(join(bundle, name))).length, 0);
console.log(` -- the room bundle: ${String(files.length)} files, ${String(bytes)} bytes, ${String(zipped)} bytes gzipped`);
const dev = await createViteServer({
configFile: join(here, 'page', 'vite.config.ts'),
logLevel: 'warn',
server: { port: 0 },
});
await dev.listen();
const origin = dev.resolvedUrls!.local[0]!.replace(/\/$/, '');
// --- the page --------------------------------------------------------------------------------
const browser = await chromium.launch();
const view = await browser.newPage({ viewport: { width: 900, height: 700 } });
const pageErrors: string[] = [];
view.on('pageerror', (error) => pageErrors.push(error.message));
const inside = (): Frame => {
const frame = view.frames().find((one) => one !== view.mainFrame());
if (frame === undefined) throw new Error('no frame on the page');
return frame;
};
const roomState = (): Promise<{ errors: { kind: string; message: string; module?: string }[]; lines: [string, string][]; visit: string }> =>
view.evaluate(() => {
const held = (globalThis as unknown as { __room: { errors: unknown[]; lines: unknown[]; visit: string } }).__room;
return JSON.parse(JSON.stringify({ errors: held.errors, lines: held.lines, visit: held.visit }));
});
try {
await view.goto(origin);
await view.locator('#home').waitFor();
await view.locator('input').first().fill('ada@example.com');
await view.locator('input[type=password]').fill('zQpw77neverstored');
await view.click('#sign-in');
await view.locator('#who', { hasText: 'signed in as' }).waitFor();
const who = (await view.locator('#who').textContent())!.replace('signed in as ', '');
// The act renders in the frame, from the module document the server holds.
await view.click('#open-app');
const frame = view.frameLocator('#app iframe');
await frame.locator('#board-title', { hasText: 'the board' }).waitFor();
check(await view.locator('#app iframe').count() === 1, 'the act renders inside the frame, on the board the server shared');
check(await frame.locator('#first').isVisible(), 'the room\'s stage is on its index');
// A write inside the frame reaches the server's copy, and the module's call reads it back
// with the page's identity.
await frame.locator('#write').click();
await frame.locator('#ask').click();
await frame.locator('#asked', { hasText: 'wrote written in the room' }).waitFor();
check((await frame.locator('#asked').textContent()) === `${who} wrote written in the room`, 'the board written in the room reached the server, and the ask answered with the signed-in user');
await frame.locator('#ask-secret').click();
await frame.locator('#refusal', { hasText: 'refused' }).waitFor();
check(true, 'an ask of a name not granted is refused inside');
// The act's own stage navigates: the address bar follows, back returns, a deep link opens deep.
await frame.locator('#go-second').click();
await frame.locator('#second').waitFor();
await view.waitForURL(`${origin}/app/7/second`);
check(view.url() === `${origin}/app/7/second`, 'a navigation inside the room is the page\'s URL, under the host act');
await view.goBack();
await frame.locator('#first').waitFor();
check(view.url() === `${origin}/app/7`, 'the browser\'s back returns the act to its first screen');
await view.goto(`${origin}/app/7/second`);
await view.frameLocator('#app iframe').locator('#second').waitFor();
check(true, 'a deep link opens the room on its second screen');
check(await inside().evaluate(() => location.origin) === 'null', 'the frame\'s origin is opaque');
await view.frameLocator('#app iframe').locator('#go-first').click();
await view.frameLocator('#app iframe').locator('#first').waitFor();
// What leaves the room as data: an error attributed to the act, recorded by the page's logs;
// a warning crosses and a log line does not.
await view.frameLocator('#app iframe').locator('#boom').click();
await view.waitForFunction(() => (globalThis as unknown as { __room: { errors: unknown[] } }).__room.errors.length === 1);
await view.frameLocator('#app iframe').locator('#warn').click();
await view.waitForFunction(() => (globalThis as unknown as { __room: { lines: unknown[] } }).__room.lines.length === 1);
const state = await roomState();
const first = state.errors[0];
check(first?.kind === 'error' && first.message === 'Error: the act blew up' && first.module === 'app/Main', `the click that threw reached handlers.error naming the act: ${JSON.stringify({ kind: first?.kind, message: first?.message, module: first?.module })}`);
check(state.lines.length === 1 && state.lines[0]![0] === 'warn' && state.lines[0]![1] === 'a warning from the room', `console.warn crossed and console.log did not: ${JSON.stringify(state.lines)}`);
await view.evaluate(() => (globalThis as unknown as { __room: { flush(): Promise<void> } }).__room.flush());
const grouped = await errors(backend.store, {});
check(grouped.some((group) => group.message === 'Error: the act blew up' && group.count >= 1), 'errors() reads the room\'s error back from the logs battery');
// The frame may paint and may not reach out: an image from an origin not in allow fails, an
// inline style applies, fetch is refused.
const picture = await inside().evaluate(() => {
const img = document.getElementById('picture') as { complete: boolean; naturalWidth: number };
const styled = document.getElementById('styled')!;
return { complete: img.complete, width: img.naturalWidth, padding: getComputedStyle(styled).paddingLeft };
});
check(picture.complete && picture.width === 0, `an image from an origin not in allow fails to load: ${JSON.stringify(picture)}`);
check(picture.padding === '7px', 'an inline style applies');
await view.frameLocator('#app iframe').locator('#fetch').click();
await view.frameLocator('#app iframe').locator('#fetched', { hasText: 'TypeError' }).waitFor();
check(true, 'fetch inside is refused');
// Leaving the act removes the frame and stops the room.
await view.click('#leave');
await view.locator('#home').waitFor();
check(await view.locator('iframe').count() === 0, 'leaving the act removes the frame');
check(view.frames().length === 1, 'and nothing of the room is left on the page');
check(pageErrors.every((message) => message === 'the act blew up'), `nothing but the act\'s own throw escaped: ${pageErrors.join('; ')}`);
} finally {
await browser.close();
await dev.close();
await backend.server.stop();
}
console.log(`\n${checks - failed}/${checks} checks passed`);
process.exit(failed === 0 ? 0 : 1);