The measuring tool had /root/.filius-test.env written into it. The development environment is moving to a user of its own, so the path now comes from os.homedir() and follows whoever runs the tool. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
174 lines
7.0 KiB
TypeScript
174 lines
7.0 KiB
TypeScript
// 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<string> {
|
|
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<void> {
|
|
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<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);
|
|
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<Shared> {
|
|
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<void> {
|
|
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<any>;
|
|
onMessage: ((m: any) => void) | undefined;
|
|
close(): void;
|
|
closed: Promise<string>;
|
|
}
|
|
|
|
export function openRoom(project: string, cookie: string): Promise<Room> {
|
|
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}`));
|
|
});
|
|
}
|