// LocalSimHost and the worker's side (serveRoom) over a MessageChannel, which // clones as a worker's port does: the editor's document reaches the room, // changes to it follow, and the room's messages come back parsed. import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import * as Y from 'yjs'; import { readProject, writeProject } from '@mdg-filius/doc'; import { openProjectArchive } from '@mdg-filius/fls'; import type { HostMessage } from '@mdg-filius/protocol'; import { CHANNEL_ORIGIN, LocalSimHost, serveRoom, type Port } from '../src/index.js'; const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus'); function exampleDoc(): Y.Doc { let counter = 0; const file = join(corpus, 'java-examples/internetwork_komplett.fls'); const project = openProjectArchive(new Uint8Array(readFileSync(file)), () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`); const doc = new Y.Doc(); writeProject(doc, project); return doc; } /** Waits until the messages so far hold one the test wants (the channel delivers on later turns). */ async function until(find: () => T | undefined): Promise { for (let attempt = 0; attempt < 200; attempt += 1) { const found = find(); if (found !== undefined) return found; await new Promise((resolve) => setTimeout(resolve, 5)); } throw new Error('never came'); } function setUp() { const doc = exampleDoc(); const channel = new MessageChannel(); let now = 0; let tick = () => {}; const room = serveRoom(channel.port2 as unknown as Port, { now: () => now, seed: () => 14, every: (_, each) => (tick = each) }); const host = new LocalSimHost(doc, channel.port1 as unknown as Port, () => channel.port1.close()); const got: HostMessage[] = []; host.onMessage((message) => got.push(message)); const run = (milliseconds: number) => { for (let elapsed = 0; elapsed < milliseconds; elapsed += 200) { now += 200; tick(); } }; return { doc, room, host, got, run, close: () => channel.port2.close() }; } describe('the simulation in a worker of the page', () => { it('runs the document the editor has, and sends back what comes of it', async () => { const { doc, host, got, run, close } = setUp(); const notebook = readProject(doc).graph.nodes.find((node) => node.displayName === 'Notebook 1.10')!; host.send({ v: 1, kind: 'sub', topics: [`trace:${notebook.ports[0]!.id}`] }); host.send({ v: 1, kind: 'start' }); await until(() => got.find((message) => message.kind === 'state' && message.mode === 'simulation')); host.send({ v: 1, kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' }); await until(() => got.find((message) => message.kind === 'ack')); run(6_000); const frames = await until(() => { const all = got.flatMap((message) => (message.kind === 'trace' ? message.frames : [])); return all.filter((frame) => frame.rows.some((row) => row.protocol === 'ICMP')).length >= 8 ? all : undefined; }); expect(frames[0]!.rows[0]!.source).toMatch(/^[0-9A-F]{2}(:[0-9A-F]{2}){5}$/); host.close(); close(); }); it("gives the room the editor's changes, which count from the next start", async () => { const { doc, host, got, close } = setUp(); const notebook = readProject(doc).graph.nodes.find((node) => node.displayName === 'Notebook 1.10')!; doc.getMap('nodes').delete(notebook.id); host.send({ v: 1, kind: 'start' }); host.send({ v: 1, kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ipconfig' }, reqId: 'r1' }); expect(await until(() => got.find((message) => message.kind === 'error'))).toMatchObject({ code: 'unknownNode', reqId: 'r1' }); host.close(); close(); }); it("takes the room's changes to the document without sending them back", async () => { const doc = exampleDoc(); const channel = new MessageChannel(); const sent: unknown[] = []; // A stand-in for the worker: it records what the page sends and changes its replica as a room would. const replica = new Y.Doc(); channel.port2.addEventListener('message', (event) => { const data = (event as MessageEvent).data as { type: string; update?: Uint8Array }; sent.push(data.type); if (data.type === 'doc') Y.applyUpdate(replica, data.update!, CHANNEL_ORIGIN); }); channel.port2.start(); const host = new LocalSimHost(doc, channel.port1 as unknown as Port, () => channel.port1.close()); await until(() => (sent.length > 0 ? true : undefined)); const before = sent.length; const update = (() => { let captured: Uint8Array | undefined; replica.on('update', (each: Uint8Array) => (captured = each)); replica.getMap('meta').set('note', 'from the room'); return captured!; })(); channel.port2.postMessage({ type: 'doc', update }); await until(() => (doc.getMap('meta').get('note') === 'from the room' ? true : undefined)); await new Promise((resolve) => setTimeout(resolve, 20)); expect(sent.length).toBe(before); host.close(); channel.port2.close(); }); it('tells the editor of a message it does not know, as an error', async () => { const doc = exampleDoc(); const channel = new MessageChannel(); channel.port2.start(); const host = new LocalSimHost(doc, channel.port1 as unknown as Port, () => channel.port1.close()); const got: HostMessage[] = []; host.onMessage((message) => got.push(message)); channel.port2.postMessage({ type: 'host', message: { v: 1, kind: 'node', id: 'x' } }); expect(await until(() => got[0])).toEqual({ v: 1, kind: 'error', code: 'unknownKind', detail: 'kind node' }); host.close(); channel.port2.close(); }); it("sends the editor's changes to the worker, and nothing of them back", async () => { const doc = exampleDoc(); const channel = new MessageChannel(); const fromWorker: string[] = []; channel.port1.addEventListener('message', (event) => fromWorker.push(((event as MessageEvent).data as { type: string }).type)); const room = serveRoom(channel.port2 as unknown as Port, { now: () => 0, seed: () => 14, every: () => {} }); const host = new LocalSimHost(doc, channel.port1 as unknown as Port, () => channel.port1.close()); await until(() => (fromWorker.includes('host') ? true : undefined)); doc.getMap('meta').set('note', 'changed'); host.send({ v: 1, kind: 'start' }); await until(() => (room.currentMode === 'simulation' ? true : undefined)); await new Promise((resolve) => setTimeout(resolve, 20)); expect(fromWorker.filter((type) => type === 'doc')).toEqual([]); host.close(); channel.port2.close(); }); });