`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>
198 lines
7.7 KiB
TypeScript
198 lines
7.7 KiB
TypeScript
// What the server's tests share: the log kept in memory, a server F with
|
|
// test secrets, a project as the service makes it, a person signed in with
|
|
// a ticket as the dashboard signs one, and that person in a project with
|
|
// Hocuspocus' own provider over a real WebSocket (bringing the session's
|
|
// cookie, as the browser does). The rooms' thread is the real one, bundled
|
|
// by esbuild as the build bundles it.
|
|
|
|
import { randomUUID } from 'node:crypto';
|
|
import { mkdtempSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join as joinPath } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { build } from 'esbuild';
|
|
import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider';
|
|
import { expect } from 'vitest';
|
|
import WebSocket from 'ws';
|
|
import * as Y from 'yjs';
|
|
import { emptyProject, writeProject } from '@mdg-filius/doc';
|
|
import { PostgresBlobs, startServer, type DocStore, type PostgresStore, type FiliusServer, type Log, type ServerOptions, type SessionStore } from '../src/index.js';
|
|
import { signTicket } from './tickets.js';
|
|
|
|
export const TICKET_SECRET = 'test-ticket-secret';
|
|
export const SERVICE_KEY = 'test-service-key';
|
|
export const EDITOR_ORIGIN = 'http://editor.test';
|
|
|
|
export type MemoryLog = Log & { readonly lines: Record<string, unknown>[] };
|
|
|
|
/** The log's lines, kept. */
|
|
export function memoryLog(): MemoryLog {
|
|
const lines: Record<string, unknown>[] = [];
|
|
const entry =
|
|
(level: string) =>
|
|
(event: string, fields: Record<string, unknown> = {}) => {
|
|
lines.push(JSON.parse(JSON.stringify({ level, event, ...fields })) as Record<string, unknown>);
|
|
};
|
|
return { lines, info: entry('info'), warn: entry('warn'), error: entry('error') };
|
|
}
|
|
|
|
export type TestServer = FiliusServer & { readonly log: MemoryLog; readonly base: string };
|
|
|
|
/** F's blob store in the test database (the table made, once a file). */
|
|
async function testBlobs(store: DocStore): Promise<PostgresBlobs> {
|
|
const blobs = new PostgresBlobs((store as PostgresStore).pool);
|
|
await blobs.migrate();
|
|
return blobs;
|
|
}
|
|
|
|
let roomThread: Promise<URL> | undefined;
|
|
|
|
/** src/simWorker.ts bundled into a directory of its own, once a test file. */
|
|
export function bundledRoomThread(): Promise<URL> {
|
|
roomThread ??= (async () => {
|
|
const outfile = joinPath(mkdtempSync(joinPath(tmpdir(), 'filius-room-thread-')), 'simWorker.mjs');
|
|
await build({ entryPoints: [new URL('../src/simWorker.ts', import.meta.url).pathname], bundle: true, platform: 'node', format: 'esm', target: 'node24', outfile, logLevel: 'error' });
|
|
return pathToFileURL(outfile);
|
|
})();
|
|
return roomThread;
|
|
}
|
|
|
|
export async function startTestServer(store: DocStore, sessions: SessionStore, options: Partial<ServerOptions> = {}): Promise<TestServer> {
|
|
const log = memoryLog();
|
|
const server = await startServer({
|
|
port: 0,
|
|
host: '127.0.0.1',
|
|
store,
|
|
sessions,
|
|
log,
|
|
ticketSecret: TICKET_SECRET,
|
|
serviceKey: SERVICE_KEY,
|
|
origins: [EDITOR_ORIGIN],
|
|
secureCookie: false,
|
|
workerUrl: await bundledRoomThread(),
|
|
blobs: await testBlobs(store),
|
|
simThreads: 1,
|
|
...options,
|
|
});
|
|
return Object.assign(server, { log, base: `http://127.0.0.1:${server.port}` });
|
|
}
|
|
|
|
/** A new project as the service makes it: an empty FILIUS project. */
|
|
export async function newProject(store: DocStore): Promise<string> {
|
|
const project = randomUUID();
|
|
const doc = new Y.Doc();
|
|
writeProject(doc, emptyProject());
|
|
expect(await store.create(project, Y.encodeStateAsUpdate(doc))).toBe(true);
|
|
return project;
|
|
}
|
|
|
|
export interface Person {
|
|
readonly user?: string;
|
|
readonly login?: string;
|
|
/** The name the dashboard shows. */
|
|
readonly name?: string;
|
|
readonly level?: 'read' | 'write';
|
|
/** The cookie the browser brings to /__auth. */
|
|
readonly cookie?: string;
|
|
}
|
|
|
|
/** The answer of /__auth to a fresh ticket. */
|
|
export function exchange(server: TestServer, project: string, { user = randomUUID(), login = 'ab12', name = 'Ada Beispiel', level = 'write', cookie }: Person = {}, next = '/'): Promise<Response> {
|
|
const ticket = signTicket({ u: user, n: login, d: name, c: '#3366cc', lang: 'de', p: project, lvl: level }, TICKET_SECRET);
|
|
return fetch(`${server.base}/__auth?ticket=${encodeURIComponent(ticket)}&next=${encodeURIComponent(next)}`, {
|
|
redirect: 'manual',
|
|
...(cookie === undefined ? {} : { headers: { cookie } }),
|
|
});
|
|
}
|
|
|
|
/** Signed in through /__auth: the cookie for the browser's next requests. */
|
|
export async function signIn(server: TestServer, project: string, person: Person = {}): Promise<string> {
|
|
const response = await exchange(server, project, person);
|
|
expect(response.status).toBe(302);
|
|
return response.headers.get('set-cookie')!.split(';')[0]!;
|
|
}
|
|
|
|
export interface Client {
|
|
readonly doc: Y.Doc;
|
|
readonly provider: HocuspocusProvider;
|
|
/** Why F closed the connection, in order. */
|
|
readonly closings: string[];
|
|
/** Why F refused it after it had been in, in order. */
|
|
readonly refusals: string[];
|
|
/** Stateless messages from F. */
|
|
readonly messages: unknown[];
|
|
close(): void;
|
|
}
|
|
|
|
export interface JoinOptions {
|
|
readonly cookie?: string;
|
|
/** Comes back at once when let go (no delay, as a client may). */
|
|
readonly eager?: boolean;
|
|
readonly origin?: string;
|
|
}
|
|
|
|
/** A person in a project: synced, or refused. */
|
|
export function join(server: FiliusServer, project: string, { cookie, eager = false, origin }: JoinOptions = {}): Promise<Client> {
|
|
const doc = new Y.Doc();
|
|
const headers = { ...(cookie === undefined ? {} : { cookie }), ...(origin === undefined ? {} : { origin }) };
|
|
class Browser extends WebSocket {
|
|
constructor(url: string, protocols?: string | string[]) {
|
|
super(url, protocols, { headers });
|
|
// Let go while it is still connecting (the test ends, the service has gone), ws reports an error no one awaits.
|
|
this.on('error', () => {});
|
|
}
|
|
}
|
|
const socket = new HocuspocusProviderWebsocket({ url: `ws://127.0.0.1:${server.port}/sync`, WebSocketPolyfill: Browser, ...(eager ? { delay: 0, minDelay: 0, jitter: false } : {}) });
|
|
const closings: string[] = [];
|
|
const refusals: string[] = [];
|
|
const messages: unknown[] = [];
|
|
return new Promise((resolve, reject) => {
|
|
let synced = false;
|
|
const timer = setTimeout(() => {
|
|
provider.destroy();
|
|
socket.destroy();
|
|
reject(new Error('not synced within 3 s'));
|
|
}, 3_000);
|
|
const provider = new HocuspocusProvider({
|
|
websocketProvider: socket,
|
|
name: project,
|
|
document: doc,
|
|
token: 'cookie',
|
|
onSynced: () => {
|
|
if (synced) return;
|
|
synced = true;
|
|
clearTimeout(timer);
|
|
resolve({ doc, provider, closings, refusals, messages, close: () => (provider.destroy(), socket.destroy()) });
|
|
},
|
|
onAuthenticationFailed: ({ reason }) => {
|
|
if (synced) {
|
|
refusals.push(reason);
|
|
return;
|
|
}
|
|
clearTimeout(timer);
|
|
provider.destroy();
|
|
socket.destroy();
|
|
reject(new Error(`refused: ${reason}`));
|
|
},
|
|
onStateless: ({ payload }) => messages.push(JSON.parse(payload)),
|
|
onClose: ({ event }) => {
|
|
if (synced) closings.push(event.reason);
|
|
else reject(new Error(`closed: ${event.reason}`));
|
|
},
|
|
});
|
|
provider.attach();
|
|
});
|
|
}
|
|
|
|
/** Signed in with a fresh ticket, then in the project. */
|
|
export async function enter(server: TestServer, project: string, person: Person = {}, options: Omit<JoinOptions, 'cookie'> = {}): Promise<Client> {
|
|
return join(server, project, { ...options, cookie: await signIn(server, project, person) });
|
|
}
|
|
|
|
export const until = async (condition: () => boolean | Promise<boolean>, milliseconds = 5_000) => {
|
|
for (let waited = 0; !(await condition()); waited += 25) {
|
|
if (waited > milliseconds) throw new Error('timed out');
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
};
|