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:
@@ -5,3 +5,5 @@ coverage/
|
|||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/settings.json
|
!.vscode/settings.json
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|||||||
+16
@@ -120,6 +120,22 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
|||||||
| `test/projectDoc.test.ts` | — | neu (F4.1): alle 23 Korpusdateien → Dokument → Yjs-Update → Projekt: dasselbe Projekt, derselbe Schnappschuss, dieselbe `.fls`, dieselbe Reihenfolge je Ordner; Kennungen absichtlich gegen die Dateireihenfolge |
|
| `test/projectDoc.test.ts` | — | neu (F4.1): alle 23 Korpusdateien → Dokument → Yjs-Update → Projekt: dasselbe Projekt, derselbe Schnappschuss, dieselbe `.fls`, dieselbe Reihenfolge je Ordner; Kennungen absichtlich gegen die Dateireihenfolge |
|
||||||
| `test/collaboration.test.ts`, `test/values.test.ts` | — | neu (F4.1): zwei Replikate — zwei Felder eines Geräts, zwei neue Zeilen, gemeinsames Tippen; der Hausmeister nach einem Zusammenführen (I1, I2 auf beiden Replikaten gleich, I4 auch in anderer Schreibweise), idempotent ohne Aktualisierung; `long` einer eigenen Anwendung, Binärdateien |
|
| `test/collaboration.test.ts`, `test/values.test.ts` | — | neu (F4.1): zwei Replikate — zwei Felder eines Geräts, zwei neue Zeilen, gemeinsames Tippen; der Hausmeister nach einem Zusammenführen (I1, I2 auf beiden Replikaten gleich, I4 auch in anderer Schreibweise), idempotent ohne Aktualisierung; `long` einer eigenen Anwendung, Binärdateien |
|
||||||
|
|
||||||
|
## packages/protocol
|
||||||
|
|
||||||
|
| Datei | Quelle | Art der Übertragung |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `src/sim.ts`, `src/host.ts` | Plan §6.4 | neu (F4.2): die Nachrichten zwischen Editor und Simulationsraum in einer Liste (Client: sub, unsub, start, stop, speed, input; Raum: state, activity, trace, app, ack, error), Prüfung jedes Felds mit benanntem Fehler statt stillem Verwerfen; `SimHost` |
|
||||||
|
| `test/sim.test.ts` | — | neu (F4.2): jede Art gelesen, jede Ablehnung mit ihrem Grund |
|
||||||
|
|
||||||
|
## packages/sim
|
||||||
|
|
||||||
|
| Datei | Quelle | Art der Übertragung |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `src/room.ts` | Plan §6.1–6.4; Javas Geschwindigkeit `Verbindung.verzoegerungsFaktor` | neu (F4.2): der Simulationsraum — eine Engine je Projekt, Schnappschuss des Dokuments beim Start, Uhr im Takt der Wanduhr (Geschwindigkeit = Kabelverzögerung wie Java, Nachholen höchstens 2 s), Mitschnitt und Uhr über Stopp und Start, Abonnements (Kabelaktivität bei Änderung, neue Rahmen je Karte in beiden Ansichten, neue Fensterzeilen je Programm), Befehlszeile als erste Aktion; ein Fehler des Kerns wird allen gemeldet (`engineFailed`) statt den Raum zu beenden |
|
||||||
|
| `src/channel.ts`, `src/serveRoom.ts`, `src/localSimHost.ts`, `src/worker.ts` | Plan §6.1 | neu (F4.2): der Raum in einem Web Worker der Seite — Dokument-Updates in beide Richtungen ohne Echo, Nachrichten geprüft |
|
||||||
|
| `test/room.test.ts`, `test/localSimHost.test.ts` | — | neu (F4.2): der Raum gegen die von Hand gesteuerte Engine (dieselben Rahmen, dieselben Zeilen), Abonnieren, Geschwindigkeit, Nachholen, Fehler; Host und Worker-Seite über einen `MessageChannel` |
|
||||||
|
| `browser/m9.spec.ts`, `browser/traceDigest.ts`, `browser/*Entry.ts`, `/playwright.config.ts` | Plan §13 M9 | neu (F4.2): M9 in Chromium — 17 Beispiele, je 30 s mit Eingaben, ergeben Zeichen für Zeichen denselben Mitschnitt wie in Node; der Raum in einem echten Worker |
|
||||||
|
|
||||||
## tools/swift-oracle
|
## tools/swift-oracle
|
||||||
|
|
||||||
Baut den unveränderten Swift-Kern (Stand `3b5ded0`) unter Linux, nur zwei
|
Baut den unveränderten Swift-Kern (Stand `3b5ded0`) unter Linux, nur zwei
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ die Herkunft je Paket. Lizenz dieses Repos: **GPL-3.0** (`LICENSE`).
|
|||||||
| `packages/core` | Simulationskern: Modell, Ereignis-Warteschlange, Protokolle, Anwendungen — ohne DOM, ohne Node |
|
| `packages/core` | Simulationskern: Modell, Ereignis-Warteschlange, Protokolle, Anwendungen — ohne DOM, ohne Node |
|
||||||
| `packages/fls` | `.fls` lesen, schreiben, Unbekanntes bewahren |
|
| `packages/fls` | `.fls` lesen, schreiben, Unbekanntes bewahren |
|
||||||
| `packages/doc` | gemeinsames Dokument (Yjs): Projekt ⇄ Dokument, Schnappschuss des Kerns, Hausmeister |
|
| `packages/doc` | gemeinsames Dokument (Yjs): Projekt ⇄ Dokument, Schnappschuss des Kerns, Hausmeister |
|
||||||
|
| `packages/protocol` | die eine Nachrichtenliste zwischen Editor und Simulationsraum (`/sim`) |
|
||||||
|
| `packages/sim` | Simulationsraum (für Server F und den Web Worker) und `LocalSimHost` |
|
||||||
| `packages/editor` | Oberfläche im Iframe des Dashboards *(folgt)* |
|
| `packages/editor` | Oberfläche im Iframe des Dashboards *(folgt)* |
|
||||||
| `server` | Filius-Dienst auf Server F *(folgt)* |
|
| `server` | Filius-Dienst auf Server F *(folgt)* |
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ Plan und Entscheidungen: `knowledge/plans/FILIUS_WEB_PLAN.md` im Dashboard.
|
|||||||
npm install
|
npm install
|
||||||
npm run hooks # Sprachprüfung vor jedem Commit (braucht das Dashboard drumherum)
|
npm run hooks # Sprachprüfung vor jedem Commit (braucht das Dashboard drumherum)
|
||||||
npm run check # Typprüfung + Tests
|
npm run check # Typprüfung + Tests
|
||||||
|
npm run test:browser # Tests im echten Browser (Playwright, Chromium aus dem Cache)
|
||||||
```
|
```
|
||||||
|
|
||||||
Code, Kommentare und Testnamen sind englisch; sichtbare Texte stehen hinter
|
Code, Kommentare und Testnamen sind englisch; sichtbare Texte stehen hinter
|
||||||
|
|||||||
Generated
+92
@@ -10,7 +10,9 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/node": "^20.19.0",
|
"@types/node": "^20.19.0",
|
||||||
|
"esbuild": "^0.28.2",
|
||||||
"typescript": "^5.9.0",
|
"typescript": "^5.9.0",
|
||||||
"vitest": "^3.2.0"
|
"vitest": "^3.2.0"
|
||||||
},
|
},
|
||||||
@@ -479,6 +481,14 @@
|
|||||||
"resolved": "packages/fls",
|
"resolved": "packages/fls",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@mdg-filius/protocol": {
|
||||||
|
"resolved": "packages/protocol",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@mdg-filius/sim": {
|
||||||
|
"resolved": "packages/sim",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||||
@@ -496,6 +506,22 @@
|
|||||||
"node": "^22.20 || ^24.12 || >=25"
|
"node": "^22.20 || ^24.12 || >=25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.63.5",
|
"version": "4.63.5",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz",
|
||||||
@@ -1297,6 +1323,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.62.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.62.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.28",
|
"version": "8.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||||
@@ -1745,6 +1818,25 @@
|
|||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
"saxes": "^6.0.0"
|
"saxes": "^6.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"packages/protocol": {
|
||||||
|
"name": "@mdg-filius/protocol",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"license": "GPL-3.0-only",
|
||||||
|
"dependencies": {
|
||||||
|
"@mdg-filius/core": "0.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/sim": {
|
||||||
|
"name": "@mdg-filius/sim",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"license": "GPL-3.0-only",
|
||||||
|
"dependencies": {
|
||||||
|
"@mdg-filius/core": "0.0.0",
|
||||||
|
"@mdg-filius/doc": "0.0.0",
|
||||||
|
"@mdg-filius/protocol": "0.0.0",
|
||||||
|
"yjs": "^13.6.33"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -11,14 +11,17 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc -b && tsc -p packages/core/test && tsc -p packages/fls/test && tsc -p packages/doc/test",
|
"typecheck": "tsc -b && tsc -p packages/core/test && tsc -p packages/fls/test && tsc -p packages/doc/test && tsc -p packages/protocol/test && tsc -p packages/sim/test && tsc -p packages/sim/browser",
|
||||||
|
"test:browser": "playwright test",
|
||||||
"check": "npm run typecheck && npm run test",
|
"check": "npm run typecheck && npm run test",
|
||||||
"hooks": "git config core.hooksPath scripts/hooks",
|
"hooks": "git config core.hooksPath scripts/hooks",
|
||||||
"oracle": "tools/swift-oracle/run.sh",
|
"oracle": "tools/swift-oracle/run.sh",
|
||||||
"oracle:java": "tools/java-oracle/run.sh"
|
"oracle:java": "tools/java-oracle/run.sh"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/node": "^20.19.0",
|
"@types/node": "^20.19.0",
|
||||||
|
"esbuild": "^0.28.2",
|
||||||
"typescript": "^5.9.0",
|
"typescript": "^5.9.0",
|
||||||
"vitest": "^3.2.0"
|
"vitest": "^3.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "@mdg-filius/protocol",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"license": "GPL-3.0-only",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mdg-filius/core": "0.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// The editor's side of a simulation room: LocalSimHost (a web worker) and
|
||||||
|
// RemoteSimHost (F, from F5) look alike to it.
|
||||||
|
|
||||||
|
import type { ClientMessage, HostMessage } from './sim.js';
|
||||||
|
|
||||||
|
export interface SimHost {
|
||||||
|
/** Sends a message to the room. */
|
||||||
|
send(message: ClientMessage): void;
|
||||||
|
/** Hears the room's messages; returns how to stop hearing them. */
|
||||||
|
onMessage(listener: (message: HostMessage) => void): () => void;
|
||||||
|
/** Leaves the room. */
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// Public surface of the wire protocol.
|
||||||
|
export * from './host.js';
|
||||||
|
export * from './sim.js';
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// The messages between the editor and a simulation room (plan §6.4) — the
|
||||||
|
// one list both sides import, so that a kind one side sends and the other
|
||||||
|
// does not know is a reported error, never silently dropped (Scratch's trap
|
||||||
|
// 6.1). The room runs on F (RemoteSimHost, F5) or in a web worker
|
||||||
|
// (LocalSimHost); the messages are the same.
|
||||||
|
|
||||||
|
import type { CaptureRow } from '@mdg-filius/core';
|
||||||
|
|
||||||
|
export const SIM_PROTOCOL_VERSION = 1;
|
||||||
|
|
||||||
|
/** The programs whose windows a client can watch and act in. */
|
||||||
|
export const APP_KINDS = ['terminal', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'echoServer', 'simpleClient', 'gnutella'] as const;
|
||||||
|
export type AppKind = (typeof APP_KINDS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a client subscribes to: which cables carry frames (`activity`), a
|
||||||
|
* card's capture (`trace:<portId>`), a program's window
|
||||||
|
* (`app:<nodeId>/<app>`).
|
||||||
|
*/
|
||||||
|
export type Topic = 'activity' | `trace:${string}` | `app:${string}/${AppKind}`;
|
||||||
|
|
||||||
|
export const CLIENT_MESSAGE_KINDS = ['sub', 'unsub', 'start', 'stop', 'speed', 'input'] as const;
|
||||||
|
export type ClientMessageKind = (typeof CLIENT_MESSAGE_KINDS)[number];
|
||||||
|
|
||||||
|
export type ClientMessage =
|
||||||
|
| { readonly v: 1; readonly kind: 'sub'; readonly topics: readonly Topic[] }
|
||||||
|
| { readonly v: 1; readonly kind: 'unsub'; readonly topics: readonly Topic[] }
|
||||||
|
/** Starts the simulation with the document as it is now. */
|
||||||
|
| { readonly v: 1; readonly kind: 'start' }
|
||||||
|
| { readonly v: 1; readonly kind: 'stop' }
|
||||||
|
/** Java's speed slider, 1 to 100 per cent: how long a frame takes over a cable. */
|
||||||
|
| { readonly v: 1; readonly kind: 'speed'; readonly value: number }
|
||||||
|
/** An action in a program's window, answered by `ack` or `error` with the same reqId. */
|
||||||
|
| {
|
||||||
|
readonly v: 1;
|
||||||
|
readonly kind: 'input';
|
||||||
|
readonly node: string;
|
||||||
|
readonly app: AppKind;
|
||||||
|
readonly action: string;
|
||||||
|
readonly args: Readonly<Record<string, unknown>>;
|
||||||
|
readonly reqId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HOST_MESSAGE_KINDS = ['state', 'activity', 'trace', 'app', 'ack', 'error'] as const;
|
||||||
|
export type HostMessageKind = (typeof HOST_MESSAGE_KINDS)[number];
|
||||||
|
|
||||||
|
export type SimMode = 'design' | 'simulation';
|
||||||
|
|
||||||
|
/** A frame's rows as the capture window shows them: with addresses (data exchange) and layered. */
|
||||||
|
export interface TraceFrame {
|
||||||
|
readonly number: number;
|
||||||
|
readonly rows: readonly CaptureRow[];
|
||||||
|
readonly layeredRows: readonly CaptureRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** engineFailed: the core threw (a part it has not got yet, such as remote links); the room is back in design mode. */
|
||||||
|
export const ERROR_CODES = ['badMessage', 'badVersion', 'unknownKind', 'badTopic', 'notRunning', 'unknownNode', 'unknownApp', 'unknownAction', 'badArgs', 'engineFailed'] as const;
|
||||||
|
export type ErrorCode = (typeof ERROR_CODES)[number];
|
||||||
|
|
||||||
|
export type HostMessage =
|
||||||
|
/** The room's mode, speed and time; sent to every client on a change and when it connects. */
|
||||||
|
| { readonly v: 1; readonly kind: 'state'; readonly mode: SimMode; readonly speed: number; readonly t: number }
|
||||||
|
/** The cables that carried a frame since the last tick. */
|
||||||
|
| { readonly v: 1; readonly kind: 'activity'; readonly links: readonly string[] }
|
||||||
|
/** A card's new frames; on subscribing, all it holds (`reset`). */
|
||||||
|
| { readonly v: 1; readonly kind: 'trace'; readonly portId: string; readonly frames: readonly TraceFrame[]; readonly reset: boolean }
|
||||||
|
/** A program's new window lines from line `from` on; on subscribing, all (from 0). */
|
||||||
|
| { readonly v: 1; readonly kind: 'app'; readonly node: string; readonly app: AppKind; readonly from: number; readonly lines: readonly unknown[] }
|
||||||
|
| { readonly v: 1; readonly kind: 'ack'; readonly reqId: string }
|
||||||
|
| { readonly v: 1; readonly kind: 'error'; readonly code: ErrorCode; readonly reqId?: string; readonly detail?: string };
|
||||||
|
|
||||||
|
export type Parsed<T> = { readonly message: T } | { readonly error: { readonly code: ErrorCode; readonly detail: string } };
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
const bad = (code: ErrorCode, detail: string) => ({ error: { code, detail } });
|
||||||
|
|
||||||
|
/** Whether a topic is one the room knows. */
|
||||||
|
export function isTopic(value: unknown): value is Topic {
|
||||||
|
if (value === 'activity') return true;
|
||||||
|
if (typeof value !== 'string') return false;
|
||||||
|
if (value.startsWith('trace:')) return value.length > 'trace:'.length;
|
||||||
|
const app = /^app:([^/]+)\/(.+)$/.exec(value);
|
||||||
|
return app !== null && (APP_KINDS as readonly string[]).includes(app[2]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A client's message as the room reads it: every field checked, an unknown kind named. */
|
||||||
|
export function parseClientMessage(value: unknown): Parsed<ClientMessage> {
|
||||||
|
if (!isRecord(value)) return bad('badMessage', 'not an object');
|
||||||
|
if (value.v !== SIM_PROTOCOL_VERSION) return bad('badVersion', `version ${String(value.v)}`);
|
||||||
|
const kind = value.kind;
|
||||||
|
if (!(CLIENT_MESSAGE_KINDS as readonly unknown[]).includes(kind)) return bad('unknownKind', `kind ${String(kind)}`);
|
||||||
|
switch (kind as ClientMessageKind) {
|
||||||
|
case 'sub':
|
||||||
|
case 'unsub': {
|
||||||
|
const topics = value.topics;
|
||||||
|
if (!Array.isArray(topics)) return bad('badMessage', 'topics is no list');
|
||||||
|
const unknown = topics.find((topic) => !isTopic(topic));
|
||||||
|
if (unknown !== undefined) return bad('badTopic', `topic ${String(unknown)}`);
|
||||||
|
return { message: { v: 1, kind: kind as 'sub' | 'unsub', topics: topics as Topic[] } };
|
||||||
|
}
|
||||||
|
case 'start':
|
||||||
|
case 'stop':
|
||||||
|
return { message: { v: 1, kind: kind as 'start' | 'stop' } };
|
||||||
|
case 'speed':
|
||||||
|
if (typeof value.value !== 'number' || !Number.isFinite(value.value)) return bad('badMessage', 'speed is no number');
|
||||||
|
return { message: { v: 1, kind: 'speed', value: value.value } };
|
||||||
|
case 'input': {
|
||||||
|
const { node, app, action, args, reqId } = value;
|
||||||
|
if (typeof reqId !== 'string') return bad('badMessage', 'reqId is no string');
|
||||||
|
if (typeof node !== 'string' || typeof action !== 'string' || !isRecord(args)) return bad('badMessage', 'input needs node, action and args');
|
||||||
|
if (!(APP_KINDS as readonly unknown[]).includes(app)) return bad('unknownApp', `app ${String(app)}`);
|
||||||
|
return { message: { v: 1, kind: 'input', node, app: app as AppKind, action, args, reqId } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A host's message as the editor reads it: the kind known, else an error the editor shows. */
|
||||||
|
export function parseHostMessage(value: unknown): Parsed<HostMessage> {
|
||||||
|
if (!isRecord(value)) return bad('badMessage', 'not an object');
|
||||||
|
if (value.v !== SIM_PROTOCOL_VERSION) return bad('badVersion', `version ${String(value.v)}`);
|
||||||
|
if (!(HOST_MESSAGE_KINDS as readonly unknown[]).includes(value.kind)) return bad('unknownKind', `kind ${String(value.kind)}`);
|
||||||
|
return { message: value as unknown as HostMessage };
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// The one list of messages: what the room accepts from a client, and what
|
||||||
|
// it refuses, each with its reason.
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CLIENT_MESSAGE_KINDS, HOST_MESSAGE_KINDS, isTopic, parseClientMessage, parseHostMessage } from '../src/index.js';
|
||||||
|
|
||||||
|
describe("a client's messages", () => {
|
||||||
|
it('reads each kind', () => {
|
||||||
|
const messages = [
|
||||||
|
{ v: 1, kind: 'sub', topics: ['activity', 'trace:port-1', 'app:node-1/terminal'] },
|
||||||
|
{ v: 1, kind: 'unsub', topics: ['activity'] },
|
||||||
|
{ v: 1, kind: 'start' },
|
||||||
|
{ v: 1, kind: 'stop' },
|
||||||
|
{ v: 1, kind: 'speed', value: 50 },
|
||||||
|
{ v: 1, kind: 'input', node: 'node-1', app: 'terminal', action: 'enter', args: { line: 'ping 1.2.3.4' }, reqId: 'r1' },
|
||||||
|
];
|
||||||
|
expect(messages.map((each) => each.kind)).toEqual([...CLIENT_MESSAGE_KINDS]);
|
||||||
|
for (const each of messages) expect(parseClientMessage(each)).toEqual({ message: each });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names what is wrong', () => {
|
||||||
|
const code = (value: unknown) => ('error' in parseClientMessage(value) ? (parseClientMessage(value) as { error: { code: string } }).error.code : 'ok');
|
||||||
|
expect(code(null)).toBe('badMessage');
|
||||||
|
expect(code([])).toBe('badMessage');
|
||||||
|
expect(code({ v: 2, kind: 'start' })).toBe('badVersion');
|
||||||
|
expect(code({ v: 1, kind: 'reset' })).toBe('unknownKind');
|
||||||
|
expect(code({ v: 1, kind: 'sub', topics: 'activity' })).toBe('badMessage');
|
||||||
|
expect(code({ v: 1, kind: 'sub', topics: ['activity', 'trace:'] })).toBe('badTopic');
|
||||||
|
expect(code({ v: 1, kind: 'unsub', topics: ['app:node/paint'] })).toBe('badTopic');
|
||||||
|
expect(code({ v: 1, kind: 'speed', value: '50' })).toBe('badMessage');
|
||||||
|
expect(code({ v: 1, kind: 'speed', value: Number.NaN })).toBe('badMessage');
|
||||||
|
expect(code({ v: 1, kind: 'input', node: 'n', app: 'terminal', action: 'enter', args: {} })).toBe('badMessage');
|
||||||
|
expect(code({ v: 1, kind: 'input', node: 'n', app: 'terminal', action: 'enter', args: [], reqId: 'r' })).toBe('badMessage');
|
||||||
|
expect(code({ v: 1, kind: 'input', node: 'n', app: 'paint', action: 'enter', args: {}, reqId: 'r' })).toBe('unknownApp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('knows its topics', () => {
|
||||||
|
expect(['activity', 'trace:x', 'app:n/gnutella', 'app:n/webBrowser'].every(isTopic)).toBe(true);
|
||||||
|
expect(['', 'trace:', 'app:/terminal', 'app:n/', 'app:n/terminal/x', 'node:n', 7].some(isTopic)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a host's messages", () => {
|
||||||
|
it('reads each kind, and names an unknown one', () => {
|
||||||
|
for (const kind of HOST_MESSAGE_KINDS) expect('message' in parseHostMessage({ v: 1, kind })).toBe(true);
|
||||||
|
expect(parseHostMessage({ v: 1, kind: 'node' })).toEqual({ error: { code: 'unknownKind', detail: 'kind node' } });
|
||||||
|
expect(parseHostMessage({ v: 0, kind: 'state' })).toEqual({ error: { code: 'badVersion', detail: 'version 0' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"composite": false,
|
||||||
|
"declaration": false,
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "../core"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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": [
|
||||||
|
"."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "@mdg-filius/sim",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"license": "GPL-3.0-only",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./worker": "./src/worker.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mdg-filius/core": "0.0.0",
|
||||||
|
"@mdg-filius/doc": "0.0.0",
|
||||||
|
"@mdg-filius/protocol": "0.0.0",
|
||||||
|
"yjs": "^13.6.33"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// The messages between the editor and the worker that holds its room: the
|
||||||
|
// document's updates both ways (the room reads the document at a start and,
|
||||||
|
// later, writes what the simulation changes back), and the protocol's
|
||||||
|
// messages. Both ends are a port as a worker and its page have them.
|
||||||
|
|
||||||
|
import type { ClientMessage, HostMessage } from '@mdg-filius/protocol';
|
||||||
|
|
||||||
|
export type ChannelMessage =
|
||||||
|
| { readonly type: 'doc'; readonly update: Uint8Array }
|
||||||
|
| { readonly type: 'client'; readonly message: ClientMessage }
|
||||||
|
| { readonly type: 'host'; readonly message: HostMessage };
|
||||||
|
|
||||||
|
/** One end: a Worker, the worker's own scope, or a MessagePort. */
|
||||||
|
export interface Port {
|
||||||
|
postMessage(message: ChannelMessage): void;
|
||||||
|
addEventListener(type: 'message', listener: (event: { readonly data: unknown }) => void): void;
|
||||||
|
/** A MessagePort delivers only after start (a Worker and its scope need none). */
|
||||||
|
start?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The origin of an update that came over the channel: it is not sent back. */
|
||||||
|
export const CHANNEL_ORIGIN = 'channel';
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Public surface of the simulation room and its local host.
|
||||||
|
export * from './channel.js';
|
||||||
|
export * from './localSimHost.js';
|
||||||
|
export * from './room.js';
|
||||||
|
export * from './serveRoom.js';
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// The simulation in a web worker of the editor's own page (plan §6.1): for
|
||||||
|
// development, tests and a preview without F. The worker gets the document
|
||||||
|
// as it is and every change after; what the room changes in it comes back.
|
||||||
|
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { parseHostMessage, SIM_PROTOCOL_VERSION, type ClientMessage, type HostMessage, type SimHost } from '@mdg-filius/protocol';
|
||||||
|
import { CHANNEL_ORIGIN, type ChannelMessage, type Port } from './channel.js';
|
||||||
|
|
||||||
|
export class LocalSimHost implements SimHost {
|
||||||
|
private readonly listeners = new Set<(message: HostMessage) => void>();
|
||||||
|
private readonly forward = (update: Uint8Array, origin: unknown) => {
|
||||||
|
if (origin !== CHANNEL_ORIGIN) this.port.postMessage({ type: 'doc', update });
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly doc: Y.Doc,
|
||||||
|
private readonly port: Port,
|
||||||
|
private readonly end: () => void = () => {},
|
||||||
|
) {
|
||||||
|
port.addEventListener('message', (event) => {
|
||||||
|
const data = event.data as ChannelMessage;
|
||||||
|
if (data?.type === 'doc') {
|
||||||
|
Y.applyUpdate(doc, data.update, CHANNEL_ORIGIN);
|
||||||
|
} else if (data?.type === 'host') {
|
||||||
|
const parsed = parseHostMessage(data.message);
|
||||||
|
const message: HostMessage = 'message' in parsed ? parsed.message : { v: SIM_PROTOCOL_VERSION, kind: 'error', code: parsed.error.code, detail: parsed.error.detail };
|
||||||
|
for (const listener of this.listeners) listener(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
port.start?.();
|
||||||
|
port.postMessage({ type: 'doc', update: Y.encodeStateAsUpdate(doc) });
|
||||||
|
doc.on('update', this.forward);
|
||||||
|
}
|
||||||
|
|
||||||
|
send(message: ClientMessage): void {
|
||||||
|
this.port.postMessage({ type: 'client', message });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMessage(listener: (message: HostMessage) => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.doc.off('update', this.forward);
|
||||||
|
this.listeners.clear();
|
||||||
|
this.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
// A simulation room (plan §6): one engine for one project, the clients that
|
||||||
|
// watch it, and what goes to them. The same room runs on F for everyone in a
|
||||||
|
// project and in a web worker for one (LocalSimHost); it takes the
|
||||||
|
// document's snapshot when the simulation starts, so that changes made while
|
||||||
|
// it runs come in at the next start, as in Java.
|
||||||
|
//
|
||||||
|
// Time: the engine's clock follows the wall clock, a millisecond for a
|
||||||
|
// millisecond, as Java's threads do; the speed is Java's slider, the delay of
|
||||||
|
// a cable (RuntimeSpeed), not a time lapse — unlike Swift's tick (plan §6.3).
|
||||||
|
// A tick catches up at most MAXIMUM_CATCH_UP_MILLISECONDS: after a tab slept,
|
||||||
|
// the simulation goes on from where it was rather than running the missed
|
||||||
|
// minutes in one go.
|
||||||
|
//
|
||||||
|
// The engine outlives a stop: the capture stays, as Java's Lauscher does, and
|
||||||
|
// the next start goes on at the time it stopped, so that the capture's times
|
||||||
|
// keep rising (plan §6.2).
|
||||||
|
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { DEFAULT_SPEED_PERCENT, NetworkRuntimeEngine, captureRows, type CapturedFrame, type CaptureRow, type Id, type LogLine } from '@mdg-filius/core';
|
||||||
|
import { docSnapshot } from '@mdg-filius/doc';
|
||||||
|
import {
|
||||||
|
parseClientMessage,
|
||||||
|
SIM_PROTOCOL_VERSION,
|
||||||
|
type AppKind,
|
||||||
|
type ClientMessage,
|
||||||
|
type ErrorCode,
|
||||||
|
type HostMessage,
|
||||||
|
type SimMode,
|
||||||
|
type Topic,
|
||||||
|
type TraceFrame,
|
||||||
|
} from '@mdg-filius/protocol';
|
||||||
|
|
||||||
|
export const TICK_MILLISECONDS = 200;
|
||||||
|
export const MAXIMUM_CATCH_UP_MILLISECONDS = 2_000;
|
||||||
|
|
||||||
|
export interface RoomOptions {
|
||||||
|
/** The wall clock, in milliseconds. */
|
||||||
|
readonly now: () => number;
|
||||||
|
/** A seed for each start. */
|
||||||
|
readonly seed: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Client {
|
||||||
|
readonly send: (message: HostMessage) => void;
|
||||||
|
readonly topics: Set<Topic>;
|
||||||
|
/** Per card, the number of the last frame sent. */
|
||||||
|
readonly traceSent: Map<Id, number>;
|
||||||
|
/** Per program window, how many of its program's lines it has; none while the program does not run (a stop empties it). */
|
||||||
|
readonly appSent: Map<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A program with a window: its lines. */
|
||||||
|
interface WithLog {
|
||||||
|
readonly log: { readonly all: readonly LogLine<unknown>[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = <T extends Omit<HostMessage, 'v'>>(fields: T) => ({ v: SIM_PROTOCOL_VERSION, ...fields }) as unknown as HostMessage;
|
||||||
|
|
||||||
|
export class SimulationRoom {
|
||||||
|
private readonly engine: NetworkRuntimeEngine;
|
||||||
|
private mode: SimMode = 'design';
|
||||||
|
private speed = DEFAULT_SPEED_PERCENT;
|
||||||
|
private readonly clients = new Map<number, Client>();
|
||||||
|
private nextClient = 1;
|
||||||
|
private lastWall: number | undefined;
|
||||||
|
/** The cable each card is plugged into, as the simulation started. */
|
||||||
|
private linkOfPort = new Map<Id, Id>();
|
||||||
|
/** Per card, how many frames it held at the last tick. */
|
||||||
|
private framesSeen = new Map<Id, number>();
|
||||||
|
private lastActivity: string[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly doc: Y.Doc,
|
||||||
|
private readonly options: RoomOptions,
|
||||||
|
) {
|
||||||
|
this.engine = new NetworkRuntimeEngine(options.seed(), undefined, { wallClock: options.now });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new client; it gets the room's state at once. */
|
||||||
|
connect(send: (message: HostMessage) => void): number {
|
||||||
|
const id = this.nextClient++;
|
||||||
|
this.clients.set(id, { send, topics: new Set(), traceSent: new Map(), appSent: new Map() });
|
||||||
|
send(this.state());
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(client: number): void {
|
||||||
|
this.clients.delete(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How many clients the room has (F stops a room nobody watches). */
|
||||||
|
get clientCount(): number {
|
||||||
|
return this.clients.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentMode(): SimMode {
|
||||||
|
return this.mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A client's message, as it came over the wire. */
|
||||||
|
receive(clientId: number, raw: unknown): void {
|
||||||
|
const client = this.clients.get(clientId);
|
||||||
|
if (client === undefined) return;
|
||||||
|
const parsed = parseClientMessage(raw);
|
||||||
|
if ('error' in parsed) {
|
||||||
|
const reqId = typeof (raw as { reqId?: unknown } | null)?.reqId === 'string' ? (raw as { reqId: string }).reqId : undefined;
|
||||||
|
client.send(message({ kind: 'error', code: parsed.error.code, detail: parsed.error.detail, ...(reqId === undefined ? {} : { reqId }) }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.handle(client, parsed.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handle(client: Client, input: ClientMessage): void {
|
||||||
|
switch (input.kind) {
|
||||||
|
case 'sub':
|
||||||
|
for (const topic of input.topics) {
|
||||||
|
client.topics.add(topic);
|
||||||
|
if (topic.startsWith('trace:')) client.traceSent.delete(topic.slice('trace:'.length));
|
||||||
|
if (topic.startsWith('app:')) client.appSent.delete(topic);
|
||||||
|
}
|
||||||
|
this.flush([client], true);
|
||||||
|
return;
|
||||||
|
case 'unsub':
|
||||||
|
for (const topic of input.topics) client.topics.delete(topic);
|
||||||
|
return;
|
||||||
|
case 'start':
|
||||||
|
this.start();
|
||||||
|
return;
|
||||||
|
case 'stop':
|
||||||
|
this.stop();
|
||||||
|
return;
|
||||||
|
case 'speed':
|
||||||
|
this.speed = Math.min(100, Math.max(1, Math.trunc(input.value)));
|
||||||
|
this.engine.setSimulationSpeed(this.speed);
|
||||||
|
this.broadcast(this.state());
|
||||||
|
return;
|
||||||
|
case 'input': {
|
||||||
|
let failure: ErrorCode | undefined = 'engineFailed';
|
||||||
|
if (!this.guarded(() => (failure = this.act(input)))) return;
|
||||||
|
client.send(failure === undefined ? message({ kind: 'ack', reqId: input.reqId }) : message({ kind: 'error', code: failure, reqId: input.reqId }));
|
||||||
|
this.flush([...this.clients.values()], false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private state(): HostMessage {
|
||||||
|
return message({ kind: 'state', mode: this.mode, speed: this.speed, t: this.engine.state.currentTimeMilliseconds });
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcast(output: HostMessage): void {
|
||||||
|
for (const client of this.clients.values()) client.send(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.mode === 'simulation') return;
|
||||||
|
const snapshot = docSnapshot(this.doc);
|
||||||
|
this.engine.setSimulationSpeed(this.speed);
|
||||||
|
if (!this.guarded(() => this.engine.handle({ type: 'start', snapshot, seed: this.options.seed(), initialTimeMilliseconds: this.engine.state.currentTimeMilliseconds }))) return;
|
||||||
|
this.linkOfPort = new Map(snapshot.links.flatMap((link) => [[link.sourcePortId, link.id] as const, [link.targetPortId, link.id] as const]));
|
||||||
|
this.mode = 'simulation';
|
||||||
|
this.lastWall = this.options.now();
|
||||||
|
this.broadcast(this.state());
|
||||||
|
this.flush([...this.clients.values()], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.mode === 'design') return;
|
||||||
|
this.engine.handle({ type: 'stop' });
|
||||||
|
this.mode = 'design';
|
||||||
|
this.lastWall = undefined;
|
||||||
|
this.broadcast(this.state());
|
||||||
|
this.flush([...this.clients.values()], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lets the simulation run to the wall clock's time, then sends what came of it. */
|
||||||
|
tick(): void {
|
||||||
|
if (this.mode !== 'simulation') return;
|
||||||
|
const now = this.options.now();
|
||||||
|
const elapsed = Math.min(MAXIMUM_CATCH_UP_MILLISECONDS, Math.max(0, now - (this.lastWall ?? now)));
|
||||||
|
this.lastWall = now;
|
||||||
|
if (!this.guarded(() => this.engine.handle({ type: 'advance', toMilliseconds: this.engine.state.currentTimeMilliseconds + elapsed }))) return;
|
||||||
|
this.flush([...this.clients.values()], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a step of the engine. When the core throws (a part it has not got
|
||||||
|
* yet), the room tells every client and goes back to design mode instead of
|
||||||
|
* dying with its worker; false then.
|
||||||
|
*/
|
||||||
|
private guarded(step: () => void): boolean {
|
||||||
|
try {
|
||||||
|
step();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.broadcast(message({ kind: 'error', code: 'engineFailed', detail: error instanceof Error ? error.message : String(error) }));
|
||||||
|
if (this.engine.state.phase === 'running') this.engine.handle({ type: 'stop' });
|
||||||
|
this.mode = 'design';
|
||||||
|
this.lastWall = undefined;
|
||||||
|
this.broadcast(this.state());
|
||||||
|
this.flush([...this.clients.values()], false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An action in a program's window; the failure, if any. */
|
||||||
|
private act(input: Extract<ClientMessage, { kind: 'input' }>): ErrorCode | undefined {
|
||||||
|
if (this.mode !== 'simulation') return 'notRunning';
|
||||||
|
if (!this.engine.state.topologySnapshot.nodes.some((node) => node.id === input.node)) return 'unknownNode';
|
||||||
|
if (input.app !== 'terminal') return 'unknownAction';
|
||||||
|
const terminal = this.engine.terminal(input.node);
|
||||||
|
if (terminal === undefined) return 'unknownApp';
|
||||||
|
switch (input.action) {
|
||||||
|
case 'enter':
|
||||||
|
if (typeof input.args.line !== 'string') return 'badArgs';
|
||||||
|
terminal.enter(input.args.line);
|
||||||
|
return undefined;
|
||||||
|
case 'interrupt':
|
||||||
|
terminal.interrupt();
|
||||||
|
return undefined;
|
||||||
|
default:
|
||||||
|
return 'unknownAction';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private program(node: Id, app: AppKind): WithLog | undefined {
|
||||||
|
switch (app) {
|
||||||
|
case 'terminal':
|
||||||
|
return this.engine.terminal(node);
|
||||||
|
case 'webBrowser':
|
||||||
|
return this.engine.webBrowser(node);
|
||||||
|
case 'webServer':
|
||||||
|
return this.engine.webServer(node);
|
||||||
|
case 'emailClient':
|
||||||
|
return this.engine.emailClient(node);
|
||||||
|
case 'emailServer':
|
||||||
|
return this.engine.emailServer(node);
|
||||||
|
case 'echoServer':
|
||||||
|
return this.engine.echoServer(node);
|
||||||
|
case 'simpleClient':
|
||||||
|
return this.engine.simpleClient(node);
|
||||||
|
case 'gnutella':
|
||||||
|
return this.engine.gnutella(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the clients are owed: the cables' activity, new frames of the cards and new lines of the windows they watch. */
|
||||||
|
private flush(clients: readonly Client[], subscribing: boolean): void {
|
||||||
|
if (!subscribing) this.updateActivity();
|
||||||
|
const rowsCache = new Map<Id, TraceFrame[]>();
|
||||||
|
const traceFrames = (portId: Id, after: number): TraceFrame[] => {
|
||||||
|
const frames = this.engine.capture.frames(portId).filter((frame) => frame.number > after);
|
||||||
|
if (frames.length === 0) return [];
|
||||||
|
if (after === 0 && rowsCache.has(portId)) return rowsCache.get(portId)!;
|
||||||
|
const result = traceFramesOf(frames);
|
||||||
|
if (after === 0) rowsCache.set(portId, result);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
for (const client of clients) {
|
||||||
|
for (const topic of client.topics) {
|
||||||
|
if (topic === 'activity') {
|
||||||
|
if (subscribing) client.send(message({ kind: 'activity', links: this.lastActivity }));
|
||||||
|
} else if (topic.startsWith('trace:')) {
|
||||||
|
const portId = topic.slice('trace:'.length);
|
||||||
|
const reset = !client.traceSent.has(portId);
|
||||||
|
const frames = traceFrames(portId, client.traceSent.get(portId) ?? 0);
|
||||||
|
if (frames.length === 0 && !reset) continue;
|
||||||
|
client.traceSent.set(portId, frames.at(-1)?.number ?? client.traceSent.get(portId) ?? 0);
|
||||||
|
client.send(message({ kind: 'trace', portId, frames, reset }));
|
||||||
|
} else {
|
||||||
|
const [, node, app] = /^app:([^/]+)\/(.+)$/.exec(topic)!;
|
||||||
|
const program = this.program(node!, app as AppKind);
|
||||||
|
const sent = client.appSent.get(topic);
|
||||||
|
if (program === undefined) {
|
||||||
|
// A window of a program that is not running: it starts empty with the next start.
|
||||||
|
if (sent !== undefined || subscribing) client.send(message({ kind: 'app', node: node!, app: app as AppKind, from: 0, lines: [] }));
|
||||||
|
client.appSent.delete(topic);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The window goes on from the lines it has; after a stop (none sent) it begins anew.
|
||||||
|
const from = sent ?? 0;
|
||||||
|
const lines = program.log.all.slice(from);
|
||||||
|
if (sent !== undefined && lines.length === 0) continue;
|
||||||
|
client.appSent.set(topic, program.log.all.length);
|
||||||
|
client.send(message({ kind: 'app', node: node!, app: app as AppKind, from, lines }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cables whose cards got frames since the last tick; sent when that changes. */
|
||||||
|
private updateActivity(): void {
|
||||||
|
const links = new Set<string>();
|
||||||
|
for (const portId of this.engine.capture.portIds()) {
|
||||||
|
const frames = this.engine.capture.frames(portId);
|
||||||
|
const count = frames.at(-1)?.number ?? 0;
|
||||||
|
if (count > (this.framesSeen.get(portId) ?? 0)) {
|
||||||
|
const link = this.linkOfPort.get(portId);
|
||||||
|
if (link !== undefined) links.add(link);
|
||||||
|
}
|
||||||
|
this.framesSeen.set(portId, count);
|
||||||
|
}
|
||||||
|
const activity = [...links].sort();
|
||||||
|
if (activity.join() === this.lastActivity.join()) return;
|
||||||
|
this.lastActivity = activity;
|
||||||
|
for (const client of this.clients.values()) if (client.topics.has('activity')) client.send(message({ kind: 'activity', links: activity }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A card's frames as the capture window shows them, both views. */
|
||||||
|
export function traceFramesOf(frames: readonly CapturedFrame[]): TraceFrame[] {
|
||||||
|
const byNumber = (rows: readonly CaptureRow[]) => {
|
||||||
|
const grouped = new Map<number, CaptureRow[]>();
|
||||||
|
for (const row of rows) grouped.set(row.number, [...(grouped.get(row.number) ?? []), row]);
|
||||||
|
return grouped;
|
||||||
|
};
|
||||||
|
const rows = byNumber(captureRows(frames, { withAddresses: true }));
|
||||||
|
const layered = byNumber(captureRows(frames, { withAddresses: false }));
|
||||||
|
return frames.map((frame) => ({ number: frame.number, rows: rows.get(frame.number) ?? [], layeredRows: layered.get(frame.number) ?? [] }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// The worker's side: a replica of the editor's document and the room that
|
||||||
|
// reads it, ticking every TICK_MILLISECONDS.
|
||||||
|
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { CHANNEL_ORIGIN, type ChannelMessage, type Port } from './channel.js';
|
||||||
|
import { SimulationRoom, TICK_MILLISECONDS, type RoomOptions } from './room.js';
|
||||||
|
|
||||||
|
export interface ServeOptions extends RoomOptions {
|
||||||
|
/** Calls `tick` every so many milliseconds. */
|
||||||
|
readonly every: (milliseconds: number, tick: () => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serveRoom(port: Port, options: ServeOptions): SimulationRoom {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
doc.on('update', (update: Uint8Array, origin: unknown) => {
|
||||||
|
if (origin !== CHANNEL_ORIGIN) port.postMessage({ type: 'doc', update });
|
||||||
|
});
|
||||||
|
const room = new SimulationRoom(doc, options);
|
||||||
|
const client = room.connect((message) => port.postMessage({ type: 'host', message }));
|
||||||
|
port.addEventListener('message', (event) => {
|
||||||
|
const data = event.data as ChannelMessage;
|
||||||
|
if (data?.type === 'doc') Y.applyUpdate(doc, data.update, CHANNEL_ORIGIN);
|
||||||
|
else if (data?.type === 'client') room.receive(client, data.message);
|
||||||
|
});
|
||||||
|
port.start?.();
|
||||||
|
options.every(TICK_MILLISECONDS, () => room.tick());
|
||||||
|
return room;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// The entry of the web worker that holds a LocalSimHost's room.
|
||||||
|
|
||||||
|
import type { Port } from './channel.js';
|
||||||
|
import { serveRoom } from './serveRoom.js';
|
||||||
|
|
||||||
|
// The worker's scope, with what it is used for here (the package has no DOM types).
|
||||||
|
declare const self: Port;
|
||||||
|
declare function setInterval(handler: () => void, milliseconds: number): unknown;
|
||||||
|
declare const performance: { now(): number };
|
||||||
|
|
||||||
|
serveRoom(self, {
|
||||||
|
now: () => performance.now(),
|
||||||
|
seed: () => Math.floor(Math.random() * 2 ** 31),
|
||||||
|
every: (milliseconds, tick) => setInterval(tick, milliseconds),
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
// The simulation room against the engine run by hand: what a client gets —
|
||||||
|
// a card's frames, a program's window, the cables' activity, the room's
|
||||||
|
// state — is what the engine holds, sent once each and only to the clients
|
||||||
|
// that subscribed. On FILIUS' example internetwork_komplett: Notebook 1.10
|
||||||
|
// pings Webserver 5.10 across three routers from its command line.
|
||||||
|
|
||||||
|
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 { NetworkRuntimeEngine, type Id } from '@mdg-filius/core';
|
||||||
|
import { docSnapshot, readProject, writeProject } from '@mdg-filius/doc';
|
||||||
|
import { openProjectArchive } from '@mdg-filius/fls';
|
||||||
|
import type { HostMessage, TraceFrame } from '@mdg-filius/protocol';
|
||||||
|
import { MAXIMUM_CATCH_UP_MILLISECONDS, SimulationRoom, TICK_MILLISECONDS, traceFramesOf } from '../src/index.js';
|
||||||
|
|
||||||
|
const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus');
|
||||||
|
const SEED = 14;
|
||||||
|
|
||||||
|
function exampleDoc(file = 'java-examples/internetwork_komplett.fls'): Y.Doc {
|
||||||
|
let counter = 0;
|
||||||
|
const project = openProjectArchive(new Uint8Array(readFileSync(join(corpus, file))), () => `00000000-0000-0000-0000-${(++counter).toString(16).padStart(12, '0')}`);
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
writeProject(doc, project);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A room on a clock the test moves, and a client that keeps what it gets. */
|
||||||
|
function setUp(doc = exampleDoc()) {
|
||||||
|
let now = 1_000_000;
|
||||||
|
const room = new SimulationRoom(doc, { now: () => now, seed: () => SEED });
|
||||||
|
const got: HostMessage[] = [];
|
||||||
|
const client = room.connect((message) => got.push(message));
|
||||||
|
const project = readProject(doc);
|
||||||
|
const node = (name: string) => project.graph.nodes.find((each) => each.displayName === name)!;
|
||||||
|
return {
|
||||||
|
room,
|
||||||
|
got,
|
||||||
|
client,
|
||||||
|
node,
|
||||||
|
send: (message: object) => room.receive(client, { v: 1, ...message }),
|
||||||
|
/** Ticks for so many milliseconds of wall time. */
|
||||||
|
run: (milliseconds: number) => {
|
||||||
|
for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) {
|
||||||
|
now += TICK_MILLISECONDS;
|
||||||
|
room.tick();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
jump: (milliseconds: number) => {
|
||||||
|
now += milliseconds;
|
||||||
|
room.tick();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ofKind = <K extends HostMessage['kind']>(got: readonly HostMessage[], kind: K) => got.filter((message): message is Extract<HostMessage, { kind: K }> => message.kind === kind);
|
||||||
|
|
||||||
|
describe('a simulation room', () => {
|
||||||
|
it('tells a new client its state, and every client a change', () => {
|
||||||
|
const { room, got, send } = setUp();
|
||||||
|
const other: HostMessage[] = [];
|
||||||
|
room.connect((message) => other.push(message));
|
||||||
|
expect(got).toEqual([{ v: 1, kind: 'state', mode: 'design', speed: 90, t: 0 }]);
|
||||||
|
send({ kind: 'start' });
|
||||||
|
send({ kind: 'speed', value: 250 });
|
||||||
|
send({ kind: 'stop' });
|
||||||
|
for (const each of [got, other]) {
|
||||||
|
expect(ofKind(each, 'state').map(({ mode, speed }) => [mode, speed])).toEqual([
|
||||||
|
['design', 90],
|
||||||
|
['simulation', 90],
|
||||||
|
['simulation', 100],
|
||||||
|
['design', 100],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a card's frames and a terminal's lines as the engine has them", () => {
|
||||||
|
const { got, send, node, run } = setUp();
|
||||||
|
const notebook = node('Notebook 1.10');
|
||||||
|
const port = notebook.ports[0]!.id;
|
||||||
|
send({ kind: 'sub', topics: [`trace:${port}`, `app:${notebook.id}/terminal`] });
|
||||||
|
send({ kind: 'start' });
|
||||||
|
run(1_000);
|
||||||
|
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
|
||||||
|
run(8_000);
|
||||||
|
|
||||||
|
// The same story on an engine run by hand: started alike, advanced tick by tick, the line typed at the same time.
|
||||||
|
const engine = new NetworkRuntimeEngine(SEED);
|
||||||
|
engine.handle({ type: 'start', snapshot: docSnapshot(exampleDoc()), seed: SEED, initialTimeMilliseconds: 0 });
|
||||||
|
engine.setSimulationSpeed(90);
|
||||||
|
for (let t = TICK_MILLISECONDS; t <= 1_000; t += TICK_MILLISECONDS) engine.handle({ type: 'advance', toMilliseconds: t });
|
||||||
|
engine.terminal(notebook.id)!.enter('ping 141.99.5.10');
|
||||||
|
for (let t = 1_000 + TICK_MILLISECONDS; t <= 9_000; t += TICK_MILLISECONDS) engine.handle({ type: 'advance', toMilliseconds: t });
|
||||||
|
|
||||||
|
expect(ofKind(got, 'ack')).toEqual([{ v: 1, kind: 'ack', reqId: 'r1' }]);
|
||||||
|
const traces = ofKind(got, 'trace');
|
||||||
|
expect(traces[0]).toMatchObject({ portId: port, reset: true });
|
||||||
|
expect(traces.slice(1).every((each) => !each.reset && each.frames.length > 0)).toBe(true);
|
||||||
|
const frames: TraceFrame[] = traces.flatMap((each) => each.frames);
|
||||||
|
expect(frames).toEqual(traceFramesOf(engine.capture.frames(port)));
|
||||||
|
expect(frames.length).toBeGreaterThan(8);
|
||||||
|
|
||||||
|
const windows = ofKind(got, 'app');
|
||||||
|
let lines: unknown[] = [];
|
||||||
|
for (const each of windows) {
|
||||||
|
expect(each.from).toBe(each.from === 0 ? 0 : lines.length);
|
||||||
|
lines = [...lines.slice(0, each.from), ...each.lines];
|
||||||
|
}
|
||||||
|
expect(lines).toEqual(engine.terminal(notebook.id)!.log.all);
|
||||||
|
expect(lines.length).toBeGreaterThan(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the cables that carry frames when they change, and only to who asked', () => {
|
||||||
|
const { room, got, send, node, run } = setUp();
|
||||||
|
const quiet: HostMessage[] = [];
|
||||||
|
room.connect((message) => quiet.push(message));
|
||||||
|
send({ kind: 'sub', topics: ['activity'] });
|
||||||
|
send({ kind: 'start' });
|
||||||
|
run(1_000);
|
||||||
|
const notebook = node('Notebook 1.10');
|
||||||
|
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
|
||||||
|
run(10_000);
|
||||||
|
const activity = ofKind(got, 'activity');
|
||||||
|
expect(activity[0]).toEqual({ v: 1, kind: 'activity', links: [] });
|
||||||
|
expect(activity.some((each) => each.links.length >= 2)).toBe(true);
|
||||||
|
// Each message differs from the one before; the last, after the ping, says all is quiet.
|
||||||
|
for (let index = 1; index < activity.length; index += 1) expect(activity[index]!.links).not.toEqual(activity[index - 1]!.links);
|
||||||
|
expect(activity.at(-1)!.links).toEqual([]);
|
||||||
|
expect(ofKind(quiet, 'activity')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops sending a card it no longer watches', () => {
|
||||||
|
const { got, send, node, run } = setUp();
|
||||||
|
const notebook = node('Notebook 1.10');
|
||||||
|
send({ kind: 'sub', topics: [`trace:${notebook.ports[0]!.id}`] });
|
||||||
|
send({ kind: 'start' });
|
||||||
|
send({ kind: 'unsub', topics: [`trace:${notebook.ports[0]!.id}`] });
|
||||||
|
const before = got.length;
|
||||||
|
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
|
||||||
|
run(5_000);
|
||||||
|
expect(ofKind(got.slice(before), 'trace')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the capture over a stop and goes on at the time it stopped', () => {
|
||||||
|
const { got, send, node, run } = setUp();
|
||||||
|
const notebook = node('Notebook 1.10');
|
||||||
|
const port = notebook.ports[0]!.id;
|
||||||
|
send({ kind: 'start' });
|
||||||
|
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
|
||||||
|
run(5_000);
|
||||||
|
send({ kind: 'stop' });
|
||||||
|
const stoppedAt = ofKind(got, 'state').at(-1)!.t;
|
||||||
|
expect(stoppedAt).toBe(5_000);
|
||||||
|
send({ kind: 'start' });
|
||||||
|
expect(ofKind(got, 'state').at(-1)!.t).toBe(stoppedAt);
|
||||||
|
send({ kind: 'sub', topics: [`trace:${port}`] });
|
||||||
|
const reset = ofKind(got, 'trace').at(-1)!;
|
||||||
|
expect(reset.reset).toBe(true);
|
||||||
|
expect(reset.frames.length).toBeGreaterThan(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empties a window when its program starts anew', () => {
|
||||||
|
const { got, send, node, run } = setUp();
|
||||||
|
const notebook = node('Notebook 1.10');
|
||||||
|
send({ kind: 'sub', topics: [`app:${notebook.id}/terminal`] });
|
||||||
|
expect(ofKind(got, 'app')).toEqual([{ v: 1, kind: 'app', node: notebook.id, app: 'terminal', from: 0, lines: [] }]);
|
||||||
|
send({ kind: 'start' });
|
||||||
|
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ipconfig' }, reqId: 'r1' });
|
||||||
|
run(400);
|
||||||
|
expect(ofKind(got, 'app').at(-1)!.lines.length).toBeGreaterThan(0);
|
||||||
|
send({ kind: 'stop' });
|
||||||
|
expect(ofKind(got, 'app').at(-1)).toMatchObject({ from: 0, lines: [] });
|
||||||
|
send({ kind: 'start' });
|
||||||
|
expect(ofKind(got, 'app').at(-1)).toMatchObject({ from: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers an action that cannot be done with its reason', () => {
|
||||||
|
const { got, send, node } = setUp();
|
||||||
|
const notebook = node('Notebook 1.10').id;
|
||||||
|
const reason = (action: object) => {
|
||||||
|
send({ kind: 'input', reqId: 'r', args: {}, ...action });
|
||||||
|
return got.at(-1);
|
||||||
|
};
|
||||||
|
expect(reason({ node: notebook, app: 'terminal', action: 'enter', args: { line: 'ipconfig' } })).toMatchObject({ kind: 'error', code: 'notRunning', reqId: 'r' });
|
||||||
|
send({ kind: 'start' });
|
||||||
|
expect(reason({ node: 'nobody', app: 'terminal', action: 'enter', args: { line: 'x' } })).toMatchObject({ code: 'unknownNode' });
|
||||||
|
expect(reason({ node: node('Switch 1').id, app: 'terminal', action: 'enter', args: { line: 'x' } })).toMatchObject({ code: 'unknownApp' });
|
||||||
|
expect(reason({ node: notebook, app: 'terminal', action: 'paint' })).toMatchObject({ code: 'unknownAction' });
|
||||||
|
expect(reason({ node: notebook, app: 'webBrowser', action: 'open' })).toMatchObject({ code: 'unknownAction' });
|
||||||
|
expect(reason({ node: notebook, app: 'terminal', action: 'enter', args: { line: 7 } })).toMatchObject({ code: 'badArgs' });
|
||||||
|
expect(reason({ node: notebook, app: 'terminal', action: 'interrupt' })).toEqual({ v: 1, kind: 'ack', reqId: 'r' });
|
||||||
|
send({ kind: 'nonsense', reqId: 'q' });
|
||||||
|
expect(got.at(-1)).toEqual({ v: 1, kind: 'error', code: 'unknownKind', detail: 'kind nonsense', reqId: 'q' });
|
||||||
|
send({ kind: 'speed' });
|
||||||
|
expect(got.at(-1)).toEqual({ v: 1, kind: 'error', code: 'badMessage', detail: 'speed is no number' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes a cable slower with a lower speed, as Java does', () => {
|
||||||
|
const roundTrip = (speed: number) => {
|
||||||
|
const { got, send, node, run } = setUp();
|
||||||
|
const notebook = node('Notebook 1.10');
|
||||||
|
send({ kind: 'sub', topics: [`trace:${notebook.ports[0]!.id}`] });
|
||||||
|
send({ kind: 'speed', value: speed });
|
||||||
|
send({ kind: 'start' });
|
||||||
|
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
|
||||||
|
run(10_000);
|
||||||
|
const rows = ofKind(got, 'trace').flatMap((each) => each.frames).flatMap((frame) => frame.rows).filter((row) => row.protocol === 'ICMP');
|
||||||
|
return rows[1]!.timeMilliseconds - rows[0]!.timeMilliseconds;
|
||||||
|
};
|
||||||
|
expect(roundTrip(80)).toBeGreaterThan(roundTrip(90));
|
||||||
|
expect(roundTrip(90)).toBeGreaterThan(roundTrip(100));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches up at most two seconds after the clock jumped', () => {
|
||||||
|
const { got, send, jump } = setUp();
|
||||||
|
send({ kind: 'start' });
|
||||||
|
jump(60_000);
|
||||||
|
send({ kind: 'stop' });
|
||||||
|
expect(ofKind(got, 'state').at(-1)!.t).toBe(MAXIMUM_CATCH_UP_MILLISECONDS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with the document as it is then', () => {
|
||||||
|
const doc = exampleDoc();
|
||||||
|
const { send, node, room, got } = setUp(doc);
|
||||||
|
const notebook: Id = node('Notebook 1.10').id;
|
||||||
|
send({ kind: 'start' });
|
||||||
|
doc.getMap<Y.Map<unknown>>('nodes').get(notebook)!.set('name', 'Neu');
|
||||||
|
send({ kind: 'input', node: notebook, app: 'terminal', action: 'enter', args: { line: 'ipconfig' }, reqId: 'r1' });
|
||||||
|
expect(got.at(-1)).toMatchObject({ kind: 'ack' });
|
||||||
|
send({ kind: 'stop' });
|
||||||
|
// The room snapshots at the start: a node removed while it runs is gone at the next start.
|
||||||
|
doc.getMap('nodes').delete(notebook);
|
||||||
|
send({ kind: 'start' });
|
||||||
|
send({ kind: 'input', node: notebook, app: 'terminal', action: 'enter', args: { line: 'ipconfig' }, reqId: 'r2' });
|
||||||
|
expect(got.at(-1)).toMatchObject({ kind: 'error', code: 'unknownNode', reqId: 'r2' });
|
||||||
|
expect(room.currentMode).toBe('simulation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells every client when the core fails, and goes back to design mode', () => {
|
||||||
|
// FILIUS' modem example: remote links are not in the core yet, it throws at the first frame to a modem.
|
||||||
|
const { room, got, send, run, node } = setUp(exampleDoc('java-examples/modem_beispiel.fls'));
|
||||||
|
const other: HostMessage[] = [];
|
||||||
|
room.connect((message) => other.push(message));
|
||||||
|
const ping = () => send({ kind: 'input', node: node('Notebook 0.10').id, app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.11' }, reqId: 'r' });
|
||||||
|
send({ kind: 'start' });
|
||||||
|
ping();
|
||||||
|
run(10_000);
|
||||||
|
for (const each of [got, other]) {
|
||||||
|
expect(ofKind(each, 'error')).toEqual([{ v: 1, kind: 'error', code: 'engineFailed', detail: expect.stringContaining('remote links') }]);
|
||||||
|
expect(ofKind(each, 'state').at(-1)).toMatchObject({ mode: 'design' });
|
||||||
|
}
|
||||||
|
expect(room.currentMode).toBe('design');
|
||||||
|
// It can start again (and fail again, telling it again).
|
||||||
|
send({ kind: 'start' });
|
||||||
|
ping();
|
||||||
|
run(10_000);
|
||||||
|
expect(ofKind(got, 'error')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends a card anew to a client that watches it again', () => {
|
||||||
|
const { got, send, node, run } = setUp();
|
||||||
|
const notebook = node('Notebook 1.10');
|
||||||
|
const topic = `trace:${notebook.ports[0]!.id}`;
|
||||||
|
send({ kind: 'start' });
|
||||||
|
send({ kind: 'sub', topics: [topic] });
|
||||||
|
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
|
||||||
|
run(3_000);
|
||||||
|
send({ kind: 'unsub', topics: [topic] });
|
||||||
|
run(3_000);
|
||||||
|
send({ kind: 'sub', topics: [topic] });
|
||||||
|
const again = ofKind(got, 'trace').at(-1)!;
|
||||||
|
expect(again.reset).toBe(true);
|
||||||
|
expect(again.frames[0]!.number).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes the speed of a running simulation', () => {
|
||||||
|
const roundTrip = (change: boolean) => {
|
||||||
|
const { got, send, node, run } = setUp();
|
||||||
|
const notebook = node('Notebook 1.10');
|
||||||
|
send({ kind: 'sub', topics: [`trace:${notebook.ports[0]!.id}`] });
|
||||||
|
send({ kind: 'start' });
|
||||||
|
run(1_000);
|
||||||
|
if (change) send({ kind: 'speed', value: 100 });
|
||||||
|
send({ kind: 'input', node: notebook.id, app: 'terminal', action: 'enter', args: { line: 'ping 141.99.5.10' }, reqId: 'r1' });
|
||||||
|
run(10_000);
|
||||||
|
const rows = ofKind(got, 'trace').flatMap((each) => each.frames).flatMap((frame) => frame.rows).filter((row) => row.protocol === 'ICMP');
|
||||||
|
return rows[1]!.timeMilliseconds - rows[0]!.timeMilliseconds;
|
||||||
|
};
|
||||||
|
expect(roundTrip(true)).toBeLessThan(roundTrip(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the document anew at the start after a failure', () => {
|
||||||
|
const doc = exampleDoc('java-examples/modem_beispiel.fls');
|
||||||
|
const { got, send, run, node } = setUp(doc);
|
||||||
|
const ping = () => send({ kind: 'input', node: node('Notebook 0.10').id, app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.11' }, reqId: 'r' });
|
||||||
|
send({ kind: 'start' });
|
||||||
|
ping();
|
||||||
|
run(10_000);
|
||||||
|
expect(ofKind(got, 'error')).toHaveLength(1);
|
||||||
|
// Without the modems' cables, nothing reaches a modem: the next start runs.
|
||||||
|
doc.getMap('links').clear();
|
||||||
|
send({ kind: 'start' });
|
||||||
|
ping();
|
||||||
|
run(10_000);
|
||||||
|
expect(ofKind(got, 'error')).toHaveLength(1);
|
||||||
|
expect(ofKind(got, 'state').at(-1)).toMatchObject({ mode: 'simulation' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"composite": false,
|
||||||
|
"declaration": false,
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"."
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "../core"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../doc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../protocol"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// The browser tests: what needs a real browser — the core in Chromium (M9),
|
||||||
|
// the room in a real web worker, later the editor's gestures (plan §7.2).
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: 'packages',
|
||||||
|
testMatch: '**/browser/*.spec.ts',
|
||||||
|
workers: 1,
|
||||||
|
reporter: 'list',
|
||||||
|
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||||
|
});
|
||||||
@@ -9,6 +9,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "packages/doc"
|
"path": "packages/doc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "packages/protocol"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "packages/sim"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user