// The room's browser window and the web server's window against Java's // (tools/java-oracle BrowserWindowRun) on web-lan's network: the same // addresses typed, links followed and form buttons pressed, the same address // line, title, page and requests after each; the server's start button, its // virtual hosts' check box and table. Named where the window is better than // Java's: // - an HTTP error shows the browser's error page for its status (Java's // never finds its template and shows "Fehler"), // - a page's relative image counts from the page's folder (Java's from the // server's root), // - a mark in the page (#oben) stays in the page (Java loads the start page), // - the virtual hosts' file holds the complete rows only (Java's holds // every row and, read again, moves a cell of an incomplete row into the // row before). import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { httpStatusText, installationFiles, NetworkRuntimeEngine, VirtualFileSystem, withSnapshot, type WebServer } from '@mdg-filius/core'; import { BROWSER_START, browserTitle, htmlTitle, type BrowserPage } from '@mdg-filius/protocol'; import { snapshotOf, type Scenario } from '../../core/test/scenario.js'; import { BrowserWindow, browserPage } from '../src/browserWindow.js'; interface JavaBrowserStep { readonly step: string; readonly address: string; readonly title: string; readonly pages: readonly string[]; readonly requests: readonly string[]; } interface JavaServerStep { readonly step: string; readonly button: string; readonly virtualHosts: boolean; readonly serverVirtualHosts: boolean; readonly table: readonly (readonly [string, string])[]; readonly vhostsFile: string | null; readonly log: string; } const here = dirname(fileURLToPath(import.meta.url)); const java = JSON.parse(readFileSync(join(here, 'browserWindow.java.json'), 'utf8')) as { readonly steps: readonly (JavaBrowserStep | JavaServerStep)[] }; const scenario = JSON.parse(readFileSync(join(here, '../../core/test/scenarios/web-lan.scenario.json'), 'utf8')) as Scenario; const BROWSER = '00000000-0000-0000-0000-000000000201'; const SERVER = '00000000-0000-0000-0000-000000000203'; /** The pages BrowserWindowRun gives the server, besides those it brings. */ const PAGES: Record = { '/webserver/links.html': 'Links & mehr\n' + 'rel abs host\n' + 'folder query anchor\n' + '
\n' + '
' + '' + '
\n' + '', '/webserver/ziel.html': 'ZielZiel', '/webserver/ohne.html': 'Ohne Titel', '/webserver/text.txt': 'Nur Text\nzwei Zeilen', '/webserver/ordner/drei.html': 'Dreivier', '/webserver/ordner/vier.html': 'Vier im Ordner', '/webserver/vier.html': 'Vier oben', }; /** What the form buttons send, as Java's window sends it (and the editor's panel, browserPanel.test.ts). */ const FORMS: Record = { Los: ['ziel.html?q=a+b&los=Los'], Gut: ['ziel.html', 'name=Fil+ius&c=1&s=y&t=%C3%BC%26%3D&ok=Gut'], }; function serverFiles(): VirtualFileSystem { const files = new VirtualFileSystem(); for (const file of installationFiles('webServer', 'de')) { if (file.content.kind === 'directory') files.createDirectory(file.path); else if (file.content.kind === 'text') files.writeTextFile(file.path, file.content.text); else if (file.content.kind === 'image') files.writeImageFile(file.path, file.content.data, file.content.mediaType); } files.writeImageFile('/webserver/bild.png', files.imageFile('/webserver/splashscreen-mini.png').data, 'image/png'); files.createDirectory('/webserver/ordner'); for (const [path, text] of Object.entries(PAGES)) files.writeTextFile(path, text); return files; } function setUp(files: VirtualFileSystem = serverFiles()) { const engine = new NetworkRuntimeEngine(scenario.seed); const snapshot = withSnapshot(snapshotOf(scenario), { webServersByNodeId: new Map([[SERVER, { port: 80, documentRoot: '/webserver', virtualHosts: false, isActive: false }]]), webBrowserNodeIds: new Set([BROWSER]), fileSystemsByNodeId: new Map([[SERVER, files]]), }); engine.handle({ type: 'start', snapshot, seed: scenario.seed, initialTimeMilliseconds: 0 }); const server = engine.webServer(SERVER)!; const window = new BrowserWindow(engine.webBrowser(BROWSER)!); let requestsSeen = 0; /** The requests the server got since the last call (its ">>" lines). */ const requests = () => { const received = server.log.all.filter((line) => line.kind === 'received').map((line) => (line.kind === 'received' ? line.text : '')); const fresh = received.slice(requestsSeen); requestsSeen = received.length; return fresh; }; /** As the oracle waits: until a page came and nothing happened for 2.5 s, at most 25 s. */ const settle = () => { const browserLog = engine.webBrowser(BROWSER)!.log; const start = browserLog.all.length; const until = engine.state.currentTimeMilliseconds + 25_000; let known = -1; let quiet = engine.state.currentTimeMilliseconds; while (engine.state.currentTimeMilliseconds < until) { engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 100 }); const count = browserLog.all.length + server.log.all.length; const page = browserLog.all.slice(start).some((line) => line.kind !== 'image'); if (count !== known) { known = count; quiet = engine.state.currentTimeMilliseconds; } else if (page && engine.state.currentTimeMilliseconds - quiet > 2_500) { break; } } window.pull(); }; return { engine, server, window, requests, settle }; } /** The title as the editor's German window shows it, an HTML page's as written (Java's way; the editor decodes it). */ const pageTitle = (page: BrowserPage) => page.kind === 'html' ? htmlTitle(page.html) : page.kind === 'unreachable' ? 'Fehler' : page.kind === 'error' ? `Fehler ${httpStatusText(page.statusCode)} (${page.statusCode})` : undefined; /** Java's last page of a step, as the window's page. */ function javaPage(pages: readonly string[]): BrowserPage | undefined { const page = pages.filter((each) => each !== 'update').at(-1); if (page === undefined) return undefined; const [, status, type, body] = /^(\d+) (\S+)\n([\s\S]*)$/.exec(page)!; if (status === '0') return { kind: 'unreachable' }; if (status !== '200') return { kind: 'error', statusCode: Number(status) }; return type === 'text/html' ? { kind: 'html', html: body!, images: {} } : { kind: 'text', text: body! }; } /** Where Java errs, per step: its record corrected, as the window does it. */ const JAVA_BUGS: Record JavaBrowserStep> = { // Java's error page never finds its template: its title is "Fehler". 'type:http://www.filius.de/nichts.html': (step) => ({ ...step, title: 'Fehler Not Found (404)' }), // The image beside the page in its folder, which is not there (Java asks for the root's). 'follow:ordner/drei.html': (step) => ({ ...step, pages: step.pages.filter((page) => page !== 'update'), requests: [step.requests[0]!, 'GET ordner/bild.png HTTP/1.1\nHost: www.filius.de\n'] }), // A mark in the page: nothing loads (Java loads the host's start page). 'follow:#oben': (step) => ({ ...step, address: 'http://www.filius.de/links.html', title: 'Links & mehr', pages: [], requests: [] }), }; const isServerStep = (step: JavaBrowserStep | JavaServerStep): step is JavaServerStep => step.step.startsWith('server:'); describe("the browser's window, against Java's", () => { it('shows the address line, title and page Java shows, and sends the requests Java sends', () => { const { server, window, requests, settle } = setUp(); let page: BrowserPage = { kind: 'start' }; for (const recorded of java.steps) { if (isServerStep(recorded)) { if (recorded.step === 'server:start') server.setActive(true); continue; } const step = JAVA_BUGS[recorded.step]?.(recorded) ?? recorded; const [action, argument] = [step.step.slice(0, step.step.indexOf(':')), step.step.slice(step.step.indexOf(':') + 1).replace(/ \(server off\)$/, '')]; if (action === 'type') window.open(argument); if (action === 'follow' || action === 'press') { const [href, post] = action === 'follow' ? [argument] : FORMS[argument]!; window.follow(href, post); } if (step.step !== 'open') settle(); expect(window.view.address, step.step).toBe(step.address); expect(browserTitle(window.view, 'Webbrowser', pageTitle), step.step).toBe(step.title); page = javaPage(step.pages) ?? page; const shown = window.view.page; expect(shown.kind === 'html' ? { ...shown, images: {} } : shown, step.step).toEqual(page); // An image that came: Java tells its window ("update"). const images = shown.kind === 'html' ? Object.keys(shown.images).length : 0; if (step.pages.includes('update') && step.step !== 'open') expect(images, step.step).toBeGreaterThan(0); expect(requests(), step.step).toEqual(step.requests); // Java's oracle loads the links page anew after each link, without a step. if (action === 'follow' || step.step === 'press:Los') { window.open('www.filius.de/links.html'); settle(); requests(); page = { kind: 'html', html: PAGES['/webserver/links.html']!, images: {} }; } } expect(window.view.page).toMatchObject({ kind: 'html', html: PAGES['/webserver/ziel.html'] }); }); it('shows its start page with the program as its title before it loads anything', () => { const { window } = setUp(); expect(window.view).toBe(BROWSER_START); const open = java.steps.find((step) => step.step === 'open') as JavaBrowserStep; expect([window.view.address, browserTitle(window.view, 'Webbrowser', pageTitle)]).toEqual([open.address, open.title]); }); it("shows a page's images from their data, the images of a page in a folder from beside it", () => { const files = serverFiles(); files.writeImageFile('/webserver/ordner/bild.png', new Uint8Array([1, 2, 3]), 'image/png'); const { server, window, settle, requests } = setUp(files); server.setActive(true); window.open('192.168.0.30'); settle(); expect(window.view.page).toMatchObject({ kind: 'html', images: { 'splashscreen-mini.png': { contentType: 'image/png' } } }); window.open('www.filius.de/ordner/drei.html'); settle(); requests(); expect(window.view.page).toMatchObject({ kind: 'html', images: { 'bild.png': { contentType: 'image/png', data: 'AQID' } } }); // Its link, also from beside it. window.follow('vier.html'); settle(); expect([window.view.address, window.view.page]).toEqual(['http://www.filius.de/ordner/vier.html', { kind: 'html', html: PAGES['/webserver/ordner/vier.html'], images: {} }]); }); it('shows plain text as text and an image as an image (Java shows both as HTML)', () => { const { server, window, settle } = setUp(); server.setActive(true); window.open('www.filius.de/text.txt'); settle(); expect(window.view.page).toEqual({ kind: 'text', text: 'Nur Text\nzwei Zeilen' }); window.open('www.filius.de/bild.png'); settle(); expect(window.view.page).toMatchObject({ kind: 'image', image: { contentType: 'image/png' } }); expect((window.view.page as { image: { data: string } }).image.data).toMatch(/^iVBOR[\w+/=]+$/); }); it('follows no link without a page, nor one of another scheme, and keeps its page over a new program', () => { const { engine, server, window, settle, requests } = setUp(); window.follow('ziel.html'); settle(); expect(window.view).toBe(BROWSER_START); server.setActive(true); window.open('www.filius.de/links.html'); settle(); requests(); const before = window.view; window.follow('mailto:anna@filius.de'); window.follow('ftp://www.filius.de/'); settle(); expect([window.view, requests()]).toEqual([before, []]); // A new start's program (Java's window outlives the stop): the page stays, the new program's lines are read. engine.handle({ type: 'stop' }); engine.handle({ type: 'start', snapshot: engine.state.topologySnapshot, seed: scenario.seed, initialTimeMilliseconds: engine.state.currentTimeMilliseconds }); window.attach(engine.webBrowser(BROWSER)!); expect(window.view).toBe(before); engine.webServer(SERVER)!.setActive(true); engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 1_000 }); // The new program writes its page before the window reads it again: read from its first line. window.follow('ziel.html'); settle(); expect(engine.webBrowser(BROWSER)!.log.all.map((line) => line.kind)).toEqual(['page']); expect(window.view.page).toEqual({ kind: 'html', html: PAGES['/webserver/ziel.html'], images: {} }); }); it("writes the browser's last host into the address line for an address without one, as Java's window does", () => { const { server, window, settle } = setUp(); server.setActive(true); window.open('www.filius.de/links.html'); settle(); window.open('http:///ziel.html?x=1'); expect(window.view.address).toBe('http://www.filius.de/ziel.html'); settle(); expect(window.view.page).toEqual({ kind: 'html', html: PAGES['/webserver/ziel.html'], images: {} }); }); }); describe("the web server's window, against Java's", () => { const steps = java.steps.filter(isServerStep); const step = (name: string) => steps.find((each) => each.step === `server:${name}`)!; const table = (server: WebServer) => server.virtualHostTable.map(([host, folder]) => [host, folder]); const vhosts = (files: VirtualFileSystem) => (files.contains('/www.conf/vhosts') ? files.textFile('/www.conf/vhosts') : null); it('starts and stops, switches the virtual hosts and writes their table as Java does', () => { const files = serverFiles(); const { server } = setUp(files); const state = () => ({ active: server.isActive, virtualHosts: server.useVirtualHosts, table: table(server), vhostsFile: vhosts(files) }); const javaState = (each: JavaServerStep) => ({ active: each.button === 'Anhalten', virtualHosts: each.virtualHosts, table: each.table, vhostsFile: each.vhostsFile }); expect(state()).toEqual(javaState(step('open'))); server.setActive(true); expect(state()).toEqual(javaState(step('start'))); server.setUseVirtualHosts(true); expect(state()).toEqual(javaState(step('virtual hosts on'))); for (const [row, column, value] of [ [0, 0, 'seite.filius.de'], [0, 1, 'vhost1'], [1, 0, 'nurhost.filius.de'], [2, 1, 'nurordner'], [3, 0, 'andere.filius.de'], [3, 1, 'vhost2'], ] as const) { server.setVirtualHost(row, column, value); } // The complete rows only (Java writes every row, its incomplete ones as empty lines). expect(state()).toEqual({ ...javaState(step('rows written')), vhostsFile: 'seite.filius.de\nvhost1\nandere.filius.de\nvhost2\n' }); expect(step('rows written').vhostsFile).toBe('seite.filius.de\nvhost1\nnurhost.filius.de\n\n\nnurordner\nandere.filius.de\nvhost2\n\n\n'); }); it('reads its table again at the next start as it wrote it (Java moves the incomplete rows’ cells)', () => { const files = serverFiles(); const { server } = setUp(files); server.setUseVirtualHosts(true); for (const [row, column, value] of [ [0, 0, 'seite.filius.de'], [0, 1, 'vhost1'], [1, 0, 'nurhost.filius.de'], [2, 1, 'nurordner'], [3, 0, 'andere.filius.de'], [3, 1, 'vhost2'], ] as const) { server.setVirtualHost(row, column, value); } const again = setUp(files).server; // A file of virtual hosts switches them on, as in Java. expect(again.useVirtualHosts).toBe(step('restart').serverVirtualHosts); expect(table(again)).toEqual([['seite.filius.de', 'vhost1'], ['andere.filius.de', 'vhost2'], ['', ''], ['', ''], ['', '']]); expect(step('restart').table).toEqual([['seite.filius.de', 'vhost1'], ['nurhost.filius.de', 'nurordner'], ['andere.filius.de', 'vhost2'], ['', ''], ['', '']]); // Switched off, they are on again at the next start while the file is there, as in Java (until the window's setting is kept, F4.6f). again.setUseVirtualHosts(false); expect([again.useVirtualHosts, setUp(files).server.useVirtualHosts]).toEqual([step('virtual hosts off').serverVirtualHosts, step('restart after off').serverVirtualHosts]); }); it("writes Java's lines into its window when it starts and stops", () => { const { engine, server } = setUp(); server.setActive(true); expect(server.log.all.map((line) => line.kind)).toEqual(['acceptingStarted']); expect(step('start').log).toBe('Annahme von Verbindungsanfragen gestartet\n'); server.setActive(false); engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 200 }); expect(server.log.all.map((line) => line.kind).slice(-2)).toEqual(['acceptingStopped', 'serverSocketClosed']); expect(step('stop').log.endsWith('Annahme von Verbindungsanfragen beendet\nServer-Socket geschlossen\n')).toBe(true); }); }); describe('the page for what came', () => { it("is the error page for any status but 200, else by the content type in any case", () => { for (const statusCode of [0, 201, 301, 400, 404, 500, 501]) expect(browserPage(statusCode, 'text/html', '

x

')).toEqual({ kind: 'error', statusCode }); expect(browserPage(200, 'TEXT/HTML', undefined)).toEqual({ kind: 'html', html: '', images: {} }); expect(browserPage(200, 'image/GIF', 'R0lG\nODlh')).toEqual({ kind: 'image', image: { contentType: 'image/gif', data: 'R0lGODlh' } }); expect(browserPage(200, 'image/jpeg', 'x')).toEqual({ kind: 'text', text: 'x' }); expect(browserPage(200, undefined, undefined)).toEqual({ kind: 'text', text: '' }); }); });