feat(sim): F4.2 the sim protocol, the simulation room and LocalSimHost; M9 measured
packages/protocol holds the one list of /sim messages and checks every field, naming what is wrong. packages/sim has the simulation room for F and the web worker: the document's snapshot at start, the engine's clock on the wall clock with Java's speed as cable delay, subscriptions to cable activity, a card's new frames and a program's new window lines, capture and clock kept over a stop, the command line as first action, a core failure told to every client. LocalSimHost mirrors the document into the worker and back without echo. M9 in Chromium: 17 examples give the same capture as in Node, character for character; the room runs in a real worker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
// 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<T>(find: () => T | undefined): Promise<T> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user