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,30 @@
|
||||
// The page's side of the worker test: a document, a LocalSimHost on a real
|
||||
// Worker, a ping from the command line, the frames that come back.
|
||||
|
||||
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 { LocalSimHost } from '../src/localSimHost.js';
|
||||
|
||||
async function run(archive: Uint8Array, workerUrl: string): Promise<{ icmpRows: number; mode: string }> {
|
||||
let counter = 0;
|
||||
const doc = new Y.Doc();
|
||||
writeProject(doc, openProjectArchive(archive, () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`));
|
||||
const notebook = readProject(doc).graph.nodes.find((node) => node.displayName === 'Notebook 1.10')!;
|
||||
const worker = new Worker(workerUrl);
|
||||
const host = new LocalSimHost(doc, worker, () => worker.terminate());
|
||||
const got: HostMessage[] = [];
|
||||
host.onMessage((message) => got.push(message));
|
||||
host.send({ v: 1, kind: 'sub', topics: [`trace:${notebook.ports[0]!.id}`] });
|
||||
host.send({ v: 1, kind: 'start' });
|
||||
host.send({ v: 1, kind: 'speed', value: 100 });
|
||||
host.send({ v: 1, kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
|
||||
const icmpRows = () => got.flatMap((message) => (message.kind === 'trace' ? message.frames : [])).filter((frame) => frame.rows.some((row) => row.protocol === 'ICMP')).length;
|
||||
for (let waited = 0; waited < 15_000 && icmpRows() < 8; waited += 100) await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const mode = got.filter((message) => message.kind === 'state').at(-1);
|
||||
host.close();
|
||||
return { icmpRows: icmpRows(), mode: mode?.kind === 'state' ? mode.mode : 'none' };
|
||||
}
|
||||
|
||||
(globalThis as unknown as { filiusLocal: unknown }).filiusLocal = { run };
|
||||
@@ -0,0 +1,57 @@
|
||||
// M9 and the real worker, in Chromium (Playwright). The packages are bundled
|
||||
// with esbuild as an editor would bundle them.
|
||||
//
|
||||
// - M9: every FILIUS example of the corpus (but the one with modems, which
|
||||
// the core has not got yet), run 30 s with a few lines typed, gives the
|
||||
// same capture and windows in Chromium as in Node.
|
||||
// - LocalSimHost in a real Worker: started, pinged, the frames come back.
|
||||
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { build } from 'esbuild';
|
||||
import { traceText } from './traceDigest.js';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const examples = join(here, '../../../corpus/java-examples');
|
||||
/** Remote links (Modem) are not in the core yet: this example's run stops at its first frame to the modem. */
|
||||
const NOT_YET = new Set(['modem_beispiel.fls']);
|
||||
|
||||
async function bundle(entry: string): Promise<string> {
|
||||
const result = await build({ entryPoints: [join(here, entry)], bundle: true, format: 'iife', platform: 'browser', write: false, logLevel: 'silent' });
|
||||
return result.outputFiles[0]!.text;
|
||||
}
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test('M9: the same trace in Node and in Chromium', async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
await page.setContent('<!doctype html><title>M9</title>');
|
||||
await page.addScriptTag({ content: await bundle('m9Entry.ts') });
|
||||
for (const name of readdirSync(examples).filter((file) => file.endsWith('.fls') && !NOT_YET.has(file))) {
|
||||
const archive = new Uint8Array(readFileSync(join(examples, name)));
|
||||
const inNode = traceText(archive);
|
||||
// Every run has traffic: frames on the cards and lines in a window.
|
||||
expect(inNode.includes('"protocol":"ARP"') && inNode.includes('"kind":"text"'), name).toBe(true);
|
||||
const inBrowser = await page.evaluate((bytes) => (globalThis as unknown as { filiusM9: { traceText: (archive: Uint8Array) => string } }).filiusM9.traceText(new Uint8Array(bytes)), [...archive]);
|
||||
expect(inBrowser.length, name).toBe(inNode.length);
|
||||
expect(inBrowser === inNode, name).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('LocalSimHost runs the room in a real worker', async ({ page }) => {
|
||||
await page.setContent('<!doctype html><title>LocalSimHost</title>');
|
||||
await page.addScriptTag({ content: await bundle('localSimHostEntry.ts') });
|
||||
const archive = [...new Uint8Array(readFileSync(join(examples, 'internetwork_komplett.fls')))];
|
||||
const workerCode = await bundle('../src/worker.ts');
|
||||
const result = await page.evaluate(
|
||||
async ({ bytes, code }) => {
|
||||
const api = (globalThis as unknown as { filiusLocal: { run: (archive: Uint8Array, workerUrl: string) => Promise<{ icmpRows: number; mode: string }> } }).filiusLocal;
|
||||
return api.run(new Uint8Array(bytes), URL.createObjectURL(new Blob([code], { type: 'text/javascript' })));
|
||||
},
|
||||
{ bytes: archive, code: workerCode },
|
||||
);
|
||||
expect(result.mode).toBe('simulation');
|
||||
expect(result.icmpRows).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
// The browser's side of M9: the same function on the page.
|
||||
|
||||
import { traceText } from './traceDigest.js';
|
||||
|
||||
(globalThis as unknown as { filiusM9: unknown }).filiusM9 = { traceText };
|
||||
@@ -0,0 +1,48 @@
|
||||
// M9 (plan §13): does the core give the same packet trace in Node and in a
|
||||
// browser? One run of an example — read from its .fls, through the shared
|
||||
// document, started and advanced tick by tick, with a few lines typed (a
|
||||
// command line installed where the example has none) — as
|
||||
// text: every card's capture in both views and every command line's window.
|
||||
// Node and Chromium each compute it; the texts must be equal.
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import { NetworkRuntimeEngine } from '@mdg-filius/core';
|
||||
import { docSnapshot, readProject, writeProject } from '@mdg-filius/doc';
|
||||
import { openProjectArchive } from '@mdg-filius/fls';
|
||||
import { traceFramesOf } from '../src/room.js';
|
||||
|
||||
export const M9_SECONDS = 30;
|
||||
const SEED = 14;
|
||||
|
||||
export function traceText(archive: Uint8Array): string {
|
||||
let counter = 0;
|
||||
const opened = openProjectArchive(archive, () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`);
|
||||
// An example without a command line gets one on its first PC or notebook, so that every run has traffic.
|
||||
if (![...opened.installedPrograms.values()].some((programs) => programs.has('commandPrompt'))) {
|
||||
const host = opened.graph.nodes.find((node) => node.kind === 'pc' || node.kind === 'notebook');
|
||||
if (host !== undefined) opened.installedPrograms.set(host.id, new Set([...(opened.installedPrograms.get(host.id) ?? []), 'commandPrompt']));
|
||||
}
|
||||
const doc = new Y.Doc();
|
||||
writeProject(doc, opened);
|
||||
const project = readProject(doc);
|
||||
const engine = new NetworkRuntimeEngine(SEED, undefined, { wallClock: () => 0 });
|
||||
engine.handle({ type: 'start', snapshot: docSnapshot(doc), seed: SEED, initialTimeMilliseconds: 0 });
|
||||
// The first command line pings the first other host with an address and asks for a name.
|
||||
const terminal = project.graph.nodes.find((node) => project.installedPrograms.get(node.id)?.has('commandPrompt') === true);
|
||||
const target = project.graph.nodes.map((node) => project.deviceConfigurations.get(node.id)?.ipAddress).find((ip) => ip !== undefined && ip !== '' && ip !== project.deviceConfigurations.get(terminal?.id ?? '')?.ipAddress);
|
||||
const typed: [number, string][] = [
|
||||
[5_000, 'ipconfig'],
|
||||
[6_000, `ping ${target ?? '127.0.0.1'}`],
|
||||
[15_000, 'host www.filius.de'],
|
||||
];
|
||||
for (let t = 200; t <= M9_SECONDS * 1_000; t += 200) {
|
||||
engine.handle({ type: 'advance', toMilliseconds: t });
|
||||
for (const [at, line] of typed) if (at === t && terminal !== undefined) engine.terminal(terminal.id)?.enter(line);
|
||||
}
|
||||
const cards = engine.capture.portIds().sort().map((portId) => ({ portId, frames: traceFramesOf(engine.capture.frames(portId)) }));
|
||||
const windows = project.graph.nodes.flatMap((node) => {
|
||||
const log = engine.terminal(node.id)?.log.all;
|
||||
return log === undefined ? [] : [{ node: node.id, log }];
|
||||
});
|
||||
return JSON.stringify({ cards, windows });
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"composite": false,
|
||||
"declaration": false,
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"."
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user