`engines`, `@types/node` and the three esbuild targets said node20 while both stages of the image have been node:24-alpine for a while — and since 24.10.2026 the development machines run 24 too. So the bundles are built for exactly the runtime that carries them instead of for a version nobody runs any more. `npm run check`: 110 files, 1825 tests, green on node 24.22.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
116 lines
5.6 KiB
TypeScript
116 lines
5.6 KiB
TypeScript
// A real server F for the editor's browser tests: bundled as the build
|
|
// bundles it (dist/main.mjs and its room thread), started as a process with
|
|
// a throwaway Postgres (packages/server/test/database.ts), on the host the
|
|
// editor's page comes from, so that F's cookie goes with the page's
|
|
// requests. A project is made in F's store straight from a FILIUS file; a
|
|
// person signs in as the dashboard would let them, with a ticket at /__auth.
|
|
// Given a built editor, F hands it out itself, as in the image: then the
|
|
// editor's page comes from F's own origin.
|
|
|
|
import { spawn, type ChildProcess } from 'node:child_process';
|
|
import { mkdtempSync, readFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { randomUUID } from 'node:crypto';
|
|
import type { BrowserContext } from '@playwright/test';
|
|
import { build } from 'esbuild';
|
|
import pg from 'pg';
|
|
import * as Y from 'yjs';
|
|
import { writeProject } from '@mdg-filius/doc';
|
|
import { openProjectArchive } from '@mdg-filius/fls';
|
|
import { testDatabase, type TestDatabase } from '../../server/test/database.js';
|
|
import { signTicket } from '../../server/test/tickets.js';
|
|
|
|
const TICKET_SECRET = 'browser-ticket-secret';
|
|
export const SERVICE_KEY = 'browser-service-key';
|
|
|
|
export interface ServerF {
|
|
readonly base: string;
|
|
/** A project made from a FILIUS file of the corpus; its devices' ids by name. */
|
|
project(file: string): Promise<{ readonly id: string; readonly node: (name: string) => string }>;
|
|
/** Signs a person in, in this browser context. */
|
|
signIn(context: BrowserContext, project: string, person: { readonly name: string; readonly level?: 'read' | 'write'; readonly user?: string }): Promise<void>;
|
|
service(method: string, path: string, body?: unknown): Promise<Response>;
|
|
/** The size of a blob in F's store, none without it. */
|
|
blobSize(sha: string): Promise<number | undefined>;
|
|
/** How many updates of a project F has stored since it last compacted it. */
|
|
updateCount(project: string): Promise<number>;
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
/** Server F on the editor page's host (`editorBase`), for pages from there, or from itself with `editorFolder`. */
|
|
export async function startServerF(editorBase: string, editorFolder?: string): Promise<ServerF> {
|
|
const editor = new URL(editorBase);
|
|
const database: TestDatabase = await testDatabase();
|
|
const out = mkdtempSync(join(tmpdir(), 'filius-server-f-'));
|
|
const source = new URL('../../server/src/', import.meta.url).pathname;
|
|
await build({
|
|
entryPoints: [join(source, 'main.ts'), join(source, 'simWorker.ts')],
|
|
bundle: true,
|
|
platform: 'node',
|
|
format: 'esm',
|
|
target: 'node24',
|
|
outdir: out,
|
|
outExtension: { '.js': '.mjs' },
|
|
external: ['pg-native'],
|
|
banner: { js: "import { createRequire as createBundleRequire } from 'node:module'; const require = createBundleRequire(import.meta.url);" },
|
|
logLevel: 'error',
|
|
});
|
|
const port = 20_000 + Math.floor(Math.random() * 20_000);
|
|
const base = `${editor.protocol}//${editor.hostname}:${port}`;
|
|
const child: ChildProcess = spawn(process.execPath, [join(out, 'main.mjs')], {
|
|
env: {
|
|
...process.env,
|
|
DATABASE_URL: database.url,
|
|
FILIUS_TICKET_SECRET: TICKET_SECRET,
|
|
FILIUS_SERVICE_KEY: SERVICE_KEY,
|
|
FILIUS_ORIGINS: editorFolder === undefined ? editor.origin : base,
|
|
...(editorFolder === undefined ? {} : { FILIUS_EDITOR_DIR: editorFolder }),
|
|
FILIUS_PORT: String(port),
|
|
FILIUS_INSECURE_COOKIE: '1',
|
|
},
|
|
stdio: ['ignore', 'pipe', 'inherit'],
|
|
});
|
|
await new Promise<void>((resolve, reject) => {
|
|
child.stdout!.on('data', (data: Buffer) => {
|
|
if (data.toString().includes('"server.listening"')) resolve();
|
|
});
|
|
child.once('exit', (code) => reject(new Error(`server F ended (${code})`)));
|
|
});
|
|
const sql = new pg.Pool({ connectionString: database.url });
|
|
return {
|
|
base,
|
|
async project(file) {
|
|
const id = randomUUID();
|
|
const imported = openProjectArchive(new Uint8Array(readFileSync(new URL(`../../../corpus/${file}`, import.meta.url))), () => randomUUID());
|
|
const doc = new Y.Doc();
|
|
writeProject(doc, imported);
|
|
await sql.query('INSERT INTO filius_doc (project_uuid, state) VALUES ($1, $2)', [id, Buffer.from(Y.encodeStateAsUpdate(doc))]);
|
|
return { id, node: (name) => imported.graph.nodes.find((each) => each.displayName === name)!.id };
|
|
},
|
|
async signIn(context, project, { name, level = 'write', user = randomUUID() }) {
|
|
const ticket = signTicket({ u: user, n: name.toLowerCase(), d: name, p: project, lvl: level }, TICKET_SECRET);
|
|
const page = await context.newPage();
|
|
const response = await page.goto(`${base}/__auth?ticket=${encodeURIComponent(ticket)}&next=/healthz`);
|
|
if (response?.status() !== 200) throw new Error(`not signed in: ${response?.status()}`);
|
|
await page.close();
|
|
},
|
|
async blobSize(sha) {
|
|
const result = await sql.query<{ size: number }>('SELECT size FROM filius_blob WHERE sha = $1', [sha]);
|
|
return result.rows[0]?.size;
|
|
},
|
|
async updateCount(project) {
|
|
const result = await sql.query<{ count: string }>('SELECT count(*) FROM filius_doc_update WHERE project_uuid = $1', [project]);
|
|
return Number(result.rows[0]!.count);
|
|
},
|
|
service: (method, path, body) => fetch(`${base}${path}`, { method, headers: { 'x-filius-service-key': SERVICE_KEY }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }),
|
|
async close() {
|
|
const ended = new Promise((resolve) => child.once('exit', resolve));
|
|
child.kill('SIGTERM');
|
|
await ended;
|
|
await sql.end();
|
|
await database.stop();
|
|
},
|
|
};
|
|
}
|