// The room's command line window against Java's (tools/java-oracle // TerminalWindowRun): the same keys typed into a computer's window, the same // text, prompt and history after each, over a stop and a start. One // difference is named: what a run made in the files is not yet written back // into the document (F4.6f), so after the restart the folder made before is // gone and the window goes on at the root, where Java's goes on in it. 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 { addDevice, emptyProject, ROOTS, setSoftware, writeProject } from '@mdg-filius/doc'; import { historyLine, historyStep, terminalScreen, type HostMessage, type TerminalLine } from '@mdg-filius/protocol'; import { SimulationRoom, TICK_MILLISECONDS } from '../src/index.js'; interface JavaStep { readonly step: string; readonly text: string; readonly prompt: string; readonly promptShown: boolean; readonly input: string; } const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'terminalWindow.java.json'), 'utf8')) as { readonly steps: readonly JavaStep[] }; /** A computer with a command line, in a room on a clock the test moves, and a client that keeps the window's lines. */ function setUp(programs: readonly string[] = ['commandPrompt']) { const doc = new Y.Doc(); writeProject(doc, emptyProject()); let counter = 0; addDevice(doc, { kind: 'pc', id: 'pc', name: 'PC', x: 0, y: 0, newId: () => `port-${++counter}` }, 'me'); const installed = doc.getMap>(ROOTS.nodes).get('pc')!.get('programs') as Y.Map>; for (const program of programs) { const entry = new Y.Map(); entry.set('active', true); installed.set(program, entry); } let now = 0; const room = new SimulationRoom(doc, { now: () => now, seed: () => 7 }); const got: HostMessage[] = []; const client = room.connect((message) => got.push(message)); let lines: TerminalLine[] = []; const read = () => { for (const message of got.splice(0)) { if (message.kind !== 'app' || message.app !== 'terminal') continue; expect(message.from).toBe(message.from === 0 ? 0 : lines.length); lines = [...lines.slice(0, message.from), ...(message.lines as TerminalLine[])]; } return lines; }; const send = (fields: object) => room.receive(client, { v: 1, ...fields }); const run = (milliseconds: number) => { for (let elapsed = 0; elapsed < milliseconds; elapsed += TICK_MILLISECONDS) { now += TICK_MILLISECONDS; room.tick(); } }; return { doc, room, send, run, read, got, connect: (listener: (message: HostMessage) => void) => room.connect(listener) }; } /** Until the files of a run are written back (F4.6f): the folder made before the restart is gone, the window goes on at the root. */ const FILES_NOT_KEPT_YET = (text: string) => text.replaceAll('/dir', '/'); describe("a command line's window", () => { it("shows what Java's window shows, key by key, over a stop and a start", () => { const { send, run, read } = setUp(); send({ kind: 'sub', topics: ['app:pc/terminal'] }); send({ kind: 'start' }); run(TICK_MILLISECONDS); let pointer = -1; let input = ''; let restartedAt: number | undefined; for (const step of java.steps) { if (step.step.startsWith('enter:')) { send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: step.step.slice('enter:'.length) }, reqId: step.step }); run(400); pointer = -1; input = ''; } else if (step.step === 'up' || step.step === 'down') { const history = terminalScreen(read()).history; pointer = historyStep(pointer, step.step, history.length); input = historyLine(history, pointer); } else if (step.step === 'restart') { send({ kind: 'stop' }); send({ kind: 'start' }); run(400); restartedAt = terminalScreen(read()).text.length; } const screen = terminalScreen(read()); const expected = restartedAt === undefined ? step : { ...step, text: step.text.slice(0, restartedAt) + FILES_NOT_KEPT_YET(step.text.slice(restartedAt)), prompt: FILES_NOT_KEPT_YET(step.prompt) }; expect({ text: screen.text, prompt: screen.prompt, promptShown: screen.promptShown, input }, step.step).toEqual({ text: expected.text, prompt: expected.prompt, promptShown: expected.promptShown, input: expected.input, }); } expect(read().at(-1)).toEqual({ kind: 'exit' }); }); it('keeps the working directory over a stop and a start, where it is still a folder', () => { const { send, run, read, doc } = setUp(); const files = doc.getMap>(ROOTS.nodes).get('pc')!.get('files') as Y.Map>; const folder = new Y.Map(); folder.set('order', files.size); folder.set('kind', 'directory'); files.set('/docs', folder); send({ kind: 'sub', topics: ['app:pc/terminal'] }); send({ kind: 'start' }); run(TICK_MILLISECONDS); // A folder the project has, so that the next start's file system has it too. send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'cd /docs' }, reqId: 'a' }); run(400); expect(terminalScreen(read()).prompt).toBe('/docs> '); send({ kind: 'stop' }); send({ kind: 'start' }); run(400); expect(terminalScreen(read()).prompt).toBe('/docs> '); // The folder gone from the project: the next start goes on at the root. files.delete('/docs'); send({ kind: 'stop' }); send({ kind: 'start' }); run(400); expect(terminalScreen(read()).prompt).toBe('/> '); }); it('hides the prompt while a command runs on, and Ctrl+C ends it', () => { const { send, run, read } = setUp(); send({ kind: 'sub', topics: ['app:pc/terminal'] }); send({ kind: 'start' }); run(TICK_MILLISECONDS); send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.99' }, reqId: 'a' }); run(1_000); expect(terminalScreen(read()).promptShown).toBe(false); expect(read().some((line) => line.kind === 'busy' && line.busy)).toBe(true); send({ kind: 'input', node: 'pc', app: 'terminal', action: 'interrupt', args: {}, reqId: 'b' }); run(15_000); expect(terminalScreen(read()).promptShown).toBe(true); expect(read().filter((line) => line.kind === 'busy').at(-1)).toEqual({ kind: 'busy', busy: false }); }); it('shows the prompt again after a stop that cut a command off', () => { const { send, run, read } = setUp(); send({ kind: 'sub', topics: ['app:pc/terminal'] }); send({ kind: 'start' }); run(TICK_MILLISECONDS); send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'ping 192.168.0.99' }, reqId: 'a' }); run(1_000); expect(terminalScreen(read()).promptShown).toBe(false); send({ kind: 'stop' }); send({ kind: 'start' }); run(TICK_MILLISECONDS); expect(terminalScreen(read()).promptShown).toBe(true); }); it('takes no line once its computer has no command line any more, and keeps what it showed', () => { const { doc, send, run, read, got } = setUp(); send({ kind: 'sub', topics: ['app:pc/terminal'] }); send({ kind: 'start' }); run(TICK_MILLISECONDS); send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'a' }); run(400); const shown = terminalScreen(read()).text; send({ kind: 'stop' }); (doc.getMap>(ROOTS.nodes).get('pc')!.get('programs') as Y.Map).delete('commandPrompt'); send({ kind: 'start' }); run(TICK_MILLISECONDS); send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'ls' }, reqId: 'b' }); expect(got.filter((message) => message.kind === 'error')).toEqual([{ v: 1, kind: 'error', code: 'unknownApp', reqId: 'b' }]); expect(terminalScreen(read()).text).toBe(shown); }); it('is the same window for everyone who opens it, and a client that comes later gets all of it', () => { const { room, send, run, read, connect } = setUp(); send({ kind: 'sub', topics: ['app:pc/terminal'] }); send({ kind: 'start' }); run(TICK_MILLISECONDS); send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'a' }); run(400); const later: HostMessage[] = []; const other = connect((message) => later.push(message)); room.receive(other, { v: 1, kind: 'sub', topics: ['app:pc/terminal'] }); const first = read(); expect(first.some((line) => line.kind === 'input' && line.line === 'pwd')).toBe(true); expect(later.filter((message) => message.kind === 'app')).toEqual([{ v: 1, kind: 'app', node: 'pc', app: 'terminal', from: 0, lines: first }]); // What the later client types, the first one sees. room.receive(other, { v: 1, kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'ls' }, reqId: 'b' }); run(400); expect(read().some((line) => line.kind === 'input' && line.line === 'ls')).toBe(true); }); it('has no window on a computer without a command line, and takes no line while the simulation stands', () => { const { send, run, read, got } = setUp([]); send({ kind: 'sub', topics: ['app:pc/terminal'] }); send({ kind: 'start' }); run(TICK_MILLISECONDS); expect(read()).toEqual([]); send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'a' }); expect(got.filter((message) => message.kind === 'error')).toEqual([{ v: 1, kind: 'error', code: 'unknownApp', reqId: 'a' }]); }); }); describe("the window's history", () => { it("steps as Java's arrow keys do, never beyond its ends", () => { expect(historyStep(-1, 'up', 0)).toBe(-1); expect(historyStep(-1, 'up', 2)).toBe(0); expect(historyStep(1, 'up', 2)).toBe(1); expect(historyStep(0, 'down', 2)).toBe(-1); expect(historyStep(-1, 'down', 2)).toBe(-1); expect([historyLine(['a', 'b'], -1), historyLine(['a', 'b'], 0), historyLine(['a', 'b'], 1)]).toEqual(['', 'b', 'a']); }); }); describe('the software installation while the simulation runs', () => { it("installs a program at once, as Java starts it: a command line to type into, a web server's files", () => { const { doc, send, run, read, got } = setUp([]); send({ kind: 'sub', topics: ['app:pc/terminal'] }); send({ kind: 'start' }); run(TICK_MILLISECONDS); expect(read()).toEqual([]); setSoftware(doc, 'pc', { install: ['commandPrompt', 'webServer'], remove: [] }, 'de', 'me'); run(TICK_MILLISECONDS); expect(terminalScreen(read()).text).toContain("Verwende den Befehl 'help'"); send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'ls /webserver' }, reqId: 'a' }); run(400); expect(terminalScreen(read()).text).toMatch(/index\.html[\s\S]*splashscreen-mini\.png/); expect(got.filter((message) => message.kind === 'error')).toEqual([]); // Removed: the window stays with its text, and takes no line. setSoftware(doc, 'pc', { install: [], remove: ['commandPrompt'] }, 'de', 'me'); const shown = terminalScreen(read()).text; send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'b' }); expect(got.filter((message) => message.kind === 'error')).toEqual([{ v: 1, kind: 'error', code: 'unknownApp', reqId: 'b' }]); expect(terminalScreen(read()).text).toBe(shown); // Installed again, it goes on in the same window. setSoftware(doc, 'pc', { install: ['commandPrompt'], remove: [] }, 'de', 'me'); send({ kind: 'input', node: 'pc', app: 'terminal', action: 'enter', args: { line: 'pwd' }, reqId: 'c' }); run(400); expect(terminalScreen(read()).text.startsWith(shown)).toBe(true); expect(terminalScreen(read()).text.slice(shown.length)).toBe('\n/> pwd\n/'); }); it('leaves the running engine alone in the design, and starts with the programs then', () => { const { doc, send, run, read } = setUp([]); send({ kind: 'sub', topics: ['app:pc/terminal'] }); send({ kind: 'start' }); send({ kind: 'stop' }); setSoftware(doc, 'pc', { install: ['commandPrompt'], remove: [] }, 'de', 'me'); expect(read()).toEqual([]); send({ kind: 'start' }); run(TICK_MILLISECONDS); expect(terminalScreen(read()).text).toContain("Verwende den Befehl 'help'"); }); });