// Measuring on test-filius: projects through the service, people through // tickets, the shared document through /sync, rooms through /sim. The // secrets come from ~/.filius-test.env and are never printed. import { readFileSync } from 'node:fs'; import { randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import * as Y from 'yjs'; import { openProjectArchive } from '@mdg-filius/fls'; import { writeProject } from '@mdg-filius/doc'; import { signTicket } from '../../packages/server/test/tickets.js'; import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider'; import WebSocket from 'ws'; const env = Object.fromEntries( readFileSync(`${homedir()}/.filius-test.env`, 'utf8') .split('\n') .filter((l) => l.includes('=')) .map((l) => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()]), ); export const HOST = process.env.FILIUS_HOST ?? 'test-filius.hub.mdg-hamburg.de'; export const BASE = `https://${HOST}`; const SECRET = env.FILIUS_TICKET_SECRET!; const KEY = env.FILIUS_SERVICE_KEY!; export const made: string[] = []; /** * How many seconds before now the tickets are signed (0: as the dashboard * signs them). F before TICKET_AHEAD_SECONDS refused a ticket from its * future, and test-filius' clock was behind this machine's (886 ms, then * 8 ms with NTP); against such an F, 2 helps. */ const SKEW_SECONDS = Number(process.env.SKEW_SECONDS ?? 0); export async function svc(method: string, path: string, body?: unknown): Promise<{ status: number; json: any }> { const r = await fetch(`${BASE}${path}`, { method, headers: { 'x-filius-service-key': KEY, 'content-type': 'application/json' }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); return { status: r.status, json: await r.json().catch(() => null) }; } export async function newProject(): Promise { const id = randomUUID(); const r = await svc('POST', '/svc/projects', { project: id }); if (r.status !== 201) throw new Error(`project not made: ${r.status}`); made.push(id); return id; } export async function cleanUp(): Promise { for (const id of made.splice(0)) await svc('DELETE', `/svc/projects/${id}`); } /** The address a person's browser opens: a fresh ticket, then the editor on the project. */ export function signInAddress(project: string, { user = randomUUID(), name = 'Messung', level = 'write' }: { user?: string; name?: string; level?: string } = {}): string { const ticket = signTicket({ u: user, n: name.toLowerCase().replace(/\W/g, ''), d: name, c: '#3366cc', lang: 'de', p: project, lvl: level }, SECRET, Math.floor(Date.now() / 1000) - SKEW_SECONDS); return `${BASE}/__auth?ticket=${encodeURIComponent(ticket)}&next=${encodeURIComponent(`/?project=${project}`)}`; } export async function signIn(project: string, { user = randomUUID(), name = 'Messung', level = 'write' }: { user?: string; name?: string; level?: string } = {}): Promise { const ticket = signTicket({ u: user, n: name.toLowerCase().replace(/\W/g, ''), d: name, c: '#3366cc', lang: 'de', p: project, lvl: level }, SECRET, Math.floor(Date.now() / 1000) - SKEW_SECONDS); const r = await fetch(`${BASE}/__auth?ticket=${encodeURIComponent(ticket)}&next=/`, { redirect: 'manual' }); if (r.status !== 302) throw new Error(`not signed in: ${r.status}`); return r.headers.get('set-cookie')!.split(';')[0]!; } export function readExample(file: string) { return openProjectArchive(new Uint8Array(readFileSync(new URL(`../../corpus/${file}`, import.meta.url))), () => randomUUID()); } export interface Shared { doc: Y.Doc; provider: any; close(): void; } export function joinDoc(project: string, cookie: string): Promise { const doc = new Y.Doc(); class Browser extends WebSocket { constructor(url: string, protocols?: string | string[]) { super(url, protocols, { headers: { cookie, origin: BASE } }); this.on('error', () => {}); } } const socket = new HocuspocusProviderWebsocket({ url: `wss://${HOST}/sync`, WebSocketPolyfill: Browser }); return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('not synced in 15 s')), 15_000); const provider = new HocuspocusProvider({ websocketProvider: socket, name: project, document: doc, token: 'cookie', onSynced: () => { clearTimeout(timer); resolve({ doc, provider, close: () => { provider.destroy(); socket.destroy(); }, }); }, onAuthenticationFailed: ({ reason }: any) => reject(new Error(`refused ${reason}`)), }); provider.attach(); }); } export async function whenStored(shared: Shared, ms = 15_000): Promise { for (let waited = 0; shared.provider.unsyncedChanges > 0; waited += 20) { if (waited > ms) throw new Error('not stored'); await new Promise((r) => setTimeout(r, 20)); } } /** A project of F with an example written into it, as one person's editor would. */ export async function seededProject(file: string) { const project = await newProject(); const example = readExample(file); const shared = await joinDoc(project, await signIn(project)); writeProject(shared.doc, example); await whenStored(shared); shared.close(); return { project, example }; } export interface Room { send(m: object): void; messages: any[]; waitFor(test: (m: any) => boolean, ms?: number): Promise; onMessage: ((m: any) => void) | undefined; close(): void; closed: Promise; } export function openRoom(project: string, cookie: string): Promise { return new Promise((resolve, reject) => { const socket = new WebSocket(`wss://${HOST}/sim?project=${project}`, { headers: { cookie, origin: BASE } }); const messages: any[] = []; const waiters: { test: (m: any) => boolean; resolve: (m: any) => void }[] = []; let closedWith: (r: string) => void = () => {}; const room: Room = { send: (m) => socket.send(JSON.stringify({ v: 1, ...m })), messages, onMessage: undefined, waitFor: (test, ms = 10_000) => new Promise((resolve, reject) => { const found = messages.find(test); if (found) return resolve(found); const timer = setTimeout(() => reject(new Error('waited in vain')), ms); waiters.push({ test, resolve: (m) => { clearTimeout(timer); resolve(m); }, }); }), close: () => socket.close(), closed: new Promise((r) => (closedWith = r)), }; socket.on('message', (data: Buffer) => { const m = JSON.parse(data.toString()); room.onMessage?.(m); if (m.kind !== 'activity') messages.push(m); for (const w of [...waiters]) if (w.test(m)) { waiters.splice(waiters.indexOf(w), 1); w.resolve(m); } }); socket.on('open', () => resolve(room)); socket.on('error', (e: Error) => reject(e)); socket.on('close', (code: number, reason: Buffer) => closedWith(`${code} ${reason}`)); }); }