recipes/full-stack/main.ts
One file of recipes/full-stack, at commit 9a5bb24.
// Both halves of an application in one directory, and the seam between them in development.
//
// A page cannot reach a backend on another origin and keep a session: the cookie belongs to the
// origin that set it, so a socket opened somewhere else carries nothing. In development the
// answer is the dev server. It serves the page and forwards `/api` and `/ws` to the backend, so
// there is one origin and the page names no host at all.
//
// This starts the backend on a port of its own, starts that dev server against it, and drives
// the page in Chromium. Every check here can only pass through the proxy: the board's title
// arrives over the socket, the public ask is answered over it, and signing up sets a cookie on
// the dev server's origin that the socket after it carries to the gated module.
//
// It does not do production serving: the proxy is development only, and in production the server
// serves the built `dist/` itself, the way `recipes/client` does, or a static host does. It does
// not do password reset, and it does not do anything else a real application adds.
//
// Run: AWEFT_DEFAULT_H=@aweftjs/ui AWEFT_TEXT=1 node --import @aweftjs/build/loader recipes/full-stack/main.ts
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { createServer } from 'vite';
import { audit, walk } from '@aweftjs/testing/browser';
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 port nothing else is on ------------------------------------------------
// The boot file reads `PORT` the same way it does when an application starts it by hand, and 0
// asks the operating system for a free one. That is why it is imported here rather than above:
// importing it is what starts it.
process.env.PORT = '0';
const backend = await import('./backend/main.ts');
const backendPort = backend.listener.port!;
// --- the dev server, with the backend behind it ------------------------------------------------
// The one thing the config file cannot know. A reader running `npx vite` sets it too, and its
// default there is the port the README tells them to start the backend on.
process.env.AWEFT_BACKEND_PORT = String(backendPort);
console.log('the dev server, with the backend behind it');
const dev = await createServer({
configFile: join(here, 'page', 'vite.config.ts'),
logLevel: 'warn',
// A free port, so a run collides with nothing. `resolvedUrls` says which one it got.
server: { port: 0 },
});
await dev.listen();
const origin = dev.resolvedUrls!.local[0]!;
// --- the page ------------------------------------------------------------------------------------
const browser = await chromium.launch();
const view = await browser.newPage({ viewport: { width: 900, height: 700 } });
const problems: string[] = [];
view.on('pageerror', (error) => problems.push(String(error)));
view.on('console', (message) => { if (message.type() === 'error') problems.push(message.text()); });
try {
await view.goto(origin);
await view.waitForSelector('#page');
// Nothing on this page is in the markup: the title is on a document the server holds, and it
// reaches the browser only over a socket the dev server forwarded.
await view.locator('#board-title', { hasText: 'the notice board' }).waitFor();
check(await view.textContent('#board-title') === 'the notice board',
'the board arrived over the socket the dev server proxied');
await view.locator('#notice', { hasText: 'the board is open to everyone' }).waitFor();
check(await view.textContent('#notice') === 'the board is open to everyone',
'and a public ask on the same socket was answered');
await view.locator('#who', { hasText: 'nobody' }).waitFor();
check(await view.textContent('#mine') === '', 'the connection is nobody, so the page asks the gated module nothing');
// --- signing up ---------------------------------------------------------------------------
console.log('\nsigning up through the page');
await view.getByLabel('Email').fill('ada@example.com');
await view.getByLabel('Password').fill('correct horse battery staple');
await view.getByRole('button', { name: 'Sign in' }).click();
// `POST /api/session` went through the HTTP half of the same proxy, and the cookie it set
// belongs to the dev server's origin, because that is the origin the page asked from.
await view.locator('#who', { hasText: 'signed in as ' }).waitFor();
const who = String(await view.textContent('#who')).replace('signed in as ', '');
const session = (await view.context().cookies()).find((held) => held.name === 'session');
check(session !== undefined && session.domain === new URL(origin).hostname,
'signing up set the session cookie on the origin the page came from');
// A cookie cannot be set on an open socket, so the client reconnects after signing in. This
// is the socket after that one, and its handshake is what read the cookie.
await view.locator('#mine', { hasText: 'the board of ' }).waitFor();
check(await view.textContent('#mine') === `the board of ${who}`,
'and the socket after the reconnect carried it, so the gated module answered');
check(problems.length === 0, `the page threw nothing and wrote no error to the console${problems.length === 0 ? '' : `: ${problems.join(', ')}`}`);
// --- can everyone use it ------------------------------------------------------------------
// axe over the page as it stands, in both colour schemes, and a Tab walk: every control reached,
// every one showing a ring, none holding the focus. The build already refused what the source
// settles; this is what only the rendered page can say.
console.log('\nthe page, read the way a screen reader and a keyboard read it');
for (const scheme of ['light', 'dark'] as const) {
await view.emulateMedia({ colorScheme: scheme });
const { violations } = await audit(view);
for (const violation of violations) console.log(` ${violation.rule}: ${violation.help} at ${violation.nodes.map((node) => node.target).join(', ')}`);
check(violations.length === 0, `axe finds nothing to fix in ${scheme}`);
}
const walked = await walk(view);
for (const problem of walked.problems) console.log(` ${problem.reason} at ${problem.target}: ${problem.fix}`);
check(walked.problems.length === 0, `Tab reaches every control (${String(walked.stops.length)}), each one rings, and none keeps the focus`);
} finally {
await browser.close();
await dev.close();
await backend.server.stop();
await backend.store.stop();
}
console.log(`\n${String(checks - failed)}/${String(checks)} checks passed`);
if (failed > 0) process.exitCode = 1;