diff --git a/.github/workflows/wasm-site.yml b/.github/workflows/wasm-site.yml index 161a3d3..53d53fa 100644 --- a/.github/workflows/wasm-site.yml +++ b/.github/workflows/wasm-site.yml @@ -136,6 +136,7 @@ jobs: const throughput = await page.locator('#throughputMetric').textContent(); const documents = await page.locator('#documentsMetric').textContent(); const runLog = await page.locator('#runLog').textContent(); + const streamDocument = await page.locator('#streamDocument').textContent(); const parsed = parsedStdout(output); if (parsed.ok !== true) throw new Error(output); if (parsed.transport !== 'SwiftWASIHTTPClient.HostHTTPClient') throw new Error(output); @@ -170,6 +171,18 @@ jobs: if (!runLog.includes('Fetching YAML bytes') || !runLog.includes('SwiftWASIHTTPClient.HostHTTPClient') || !runLog.includes('Starting Swift parser.') || !runLog.includes('ChunkedUTF8Reader consumed') || !runLog.includes('Benchmark completed successfully.')) { throw new Error(`Expected detailed run log, got: ${runLog}`); } + if (!streamDocument.includes('name: ci-doc-0') || !streamDocument.includes('name: ci-doc-2999')) { + throw new Error('Expected buffered document panel to show generated YAML'); + } + if (await page.locator('#streamDocument .yaml-key').count() < 3) { + throw new Error('Expected buffered document panel syntax highlighting'); + } + await page.click('#copyRunLogButton'); + await page.waitForFunction( + () => document.querySelector('#copyRunLogButton')?.textContent === 'Copied', + null, + { timeout: 5000 } + ); await page.click('#streamButton'); await page.waitForFunction( @@ -179,6 +192,7 @@ jobs: ); const streamingOutput = await page.locator('#output').textContent(); const streamingRunLog = await page.locator('#runLog').textContent(); + const streamingDocument = await page.locator('#streamDocument').textContent(); const streamingParsed = parsedStdout(streamingOutput); if (streamingParsed.ok !== true) throw new Error(streamingOutput); if (streamingParsed.transport !== 'SharedArrayBuffer fd_read stdin') throw new Error(streamingOutput); @@ -195,6 +209,12 @@ jobs: if (!streamingRunLog.includes('Streaming HTTP 200 response body into SharedArrayBuffer') || !streamingRunLog.includes('Network chunk') || !streamingRunLog.includes('Worker fd_read calls') || !streamingRunLog.includes('SharedArrayBuffer fd_read stdin')) { throw new Error(`Expected true streaming run log, got: ${streamingRunLog}`); } + if (!streamingDocument.includes('name: ci-doc-0') || !streamingDocument.includes('name: ci-doc-2999')) { + throw new Error('Expected streaming document panel to append generated YAML'); + } + if (await page.locator('#streamDocument .yaml-key').count() < 3) { + throw new Error('Expected streaming document panel syntax highlighting'); + } await browser.close(); })(); NODE @@ -271,6 +291,7 @@ jobs: const output = await page.locator('#output').textContent(); const throughput = await page.locator('#throughputMetric').textContent(); const runLog = await page.locator('#runLog').textContent(); + const streamDocument = await page.locator('#streamDocument').textContent(); const parsed = parsedStdout(output); if (parsed.transport !== 'SwiftWASIHTTPClient.HostHTTPClient') throw new Error(output); if (parsed.httpStatusCode !== 200) throw new Error(output); @@ -298,6 +319,12 @@ jobs: if (!runLog.includes('Fetching YAML bytes') || !runLog.includes('SwiftWASIHTTPClient.HostHTTPClient') || !runLog.includes('Starting Swift parser.') || !runLog.includes('ChunkedUTF8Reader consumed') || !runLog.includes('Benchmark completed successfully.')) { throw new Error(`Expected live detailed run log, got: ${runLog}`); } + if (!streamDocument.includes('name: ci-doc-0') || !streamDocument.includes('name: ci-doc-2999')) { + throw new Error('Expected live buffered document panel to show generated YAML'); + } + if (await page.locator('#streamDocument .yaml-key').count() < 3) { + throw new Error('Expected live buffered document panel syntax highlighting'); + } await page.click('#streamButton'); await page.waitForFunction( () => document.querySelector('#status')?.textContent === 'Passed', @@ -306,6 +333,7 @@ jobs: ); const streamingOutput = await page.locator('#output').textContent(); const streamingRunLog = await page.locator('#runLog').textContent(); + const streamingDocument = await page.locator('#streamDocument').textContent(); const streamingParsed = parsedStdout(streamingOutput); if (streamingParsed.transport !== 'SharedArrayBuffer fd_read stdin') throw new Error(streamingOutput); if (streamingParsed.httpStatusCode !== 200) throw new Error(streamingOutput); @@ -318,6 +346,12 @@ jobs: if (!streamingRunLog.includes('Streaming HTTP 200 response body into SharedArrayBuffer') || !streamingRunLog.includes('Network chunk') || !streamingRunLog.includes('Worker fd_read calls') || !streamingRunLog.includes('SharedArrayBuffer fd_read stdin')) { throw new Error(`Expected live true streaming run log, got: ${streamingRunLog}`); } + if (!streamingDocument.includes('name: ci-doc-0') || !streamingDocument.includes('name: ci-doc-2999')) { + throw new Error('Expected live streaming document panel to append generated YAML'); + } + if (await page.locator('#streamDocument .yaml-key').count() < 3) { + throw new Error('Expected live streaming document panel syntax highlighting'); + } await browser.close(); })(); NODE diff --git a/WasmSite/index.html b/WasmSite/index.html index 4ca1d67..0b0c802 100644 --- a/WasmSite/index.html +++ b/WasmSite/index.html @@ -76,15 +76,28 @@
-

Run Log

+
+

Run Log

+ +
Waiting for benchmark run.
+
+

Document Stream

+

+ In true streaming mode this panel appends decoded YAML chunks as the + browser receives them. In buffered mode it shows the fetched body after + the browser buffer is complete. +

+
Waiting for YAML bytes.
+
+

Output

-
Pick a YAML file and click "Run Benchmark".
+
Pick a YAML file and choose a benchmark mode.
diff --git a/WasmSite/main.js b/WasmSite/main.js index 855df2b..6f63029 100644 --- a/WasmSite/main.js +++ b/WasmSite/main.js @@ -12,6 +12,8 @@ const urlInput = document.querySelector("#urlInput"); const statusElement = document.querySelector("#status"); const outputElement = document.querySelector("#output"); const runLogElement = document.querySelector("#runLog"); +const copyRunLogButton = document.querySelector("#copyRunLogButton"); +const streamDocumentElement = document.querySelector("#streamDocument"); const isolationMetric = document.querySelector("#isolationMetric"); const sabMetric = document.querySelector("#sabMetric"); const workerMetric = document.querySelector("#workerMetric"); @@ -110,6 +112,12 @@ streamButton.addEventListener("click", () => { }); }); +copyRunLogButton.addEventListener("click", () => { + copyRunLog().catch((error) => { + logStep(`Unable to copy run log: ${String(error?.message || error)}`, "error"); + }); +}); + sampleSelect.addEventListener("change", () => { urlInput.value = sampleSelect.value; }); @@ -129,6 +137,7 @@ async function runBenchmark() { outputElement.textContent = ""; resetMetrics(); resetLog(); + resetStreamDocument("Waiting for buffered YAML fetch to complete."); logStep("Queued benchmark run."); logStep(`Selected source: ${sourceURL}`); @@ -147,6 +156,7 @@ async function runBenchmark() { } const yamlBytes = new Uint8Array(await response.arrayBuffer()); const fetchMS = performance.now() - fetchStart; + replaceStreamDocument(decodeYAMLBytes(yamlBytes)); logStep(`Fetched HTTP ${response.status}, ${formatBytes(yamlBytes.byteLength)} in ${formatDuration(fetchMS)}.`); logStep("Handing buffered response to SwiftWASIHTTPClient.HostHTTPClient through WASI stdin."); @@ -239,6 +249,7 @@ async function runStreamingBenchmark() { outputElement.textContent = ""; resetMetrics(); resetLog(); + resetStreamDocument("Waiting for streaming YAML chunks."); logStep("Queued true browser-to-WASI streaming run."); logStep(`Selected source: ${sourceURL}`); @@ -295,6 +306,7 @@ async function runStreamingBenchmark() { logStep(`Streaming HTTP ${response.status} response body into SharedArrayBuffer.`); const reader = response.body.getReader(); + const documentDecoder = new TextDecoder(); let networkChunkCount = 0; let fetchedByteCount = 0; try { @@ -305,6 +317,7 @@ async function runStreamingBenchmark() { } networkChunkCount += 1; fetchedByteCount += value.byteLength; + appendStreamDocumentChunk(documentDecoder.decode(value, { stream: true })); await writeToRingBuffer(value, control, bytes); logStep(`Network chunk ${networkChunkCount}: wrote ${formatBytes(value.byteLength)} to ring buffer.`); } @@ -315,6 +328,7 @@ async function runStreamingBenchmark() { worker?.terminate(); throw error; } + appendStreamDocumentChunk(documentDecoder.decode()); Atomics.store(control, streamControlIndex.closed, 1); wakeRingBuffer(control); const fetchMS = performance.now() - fetchStart; @@ -504,6 +518,131 @@ function resetMetrics() { documentsMetric.textContent = "-"; } +async function copyRunLog() { + const text = runLogElement.innerText.trim(); + if (!text) { + return; + } + if ("clipboard" in navigator && window.isSecureContext) { + await navigator.clipboard.writeText(text); + } else { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.append(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); + } + const previousLabel = copyRunLogButton.textContent; + copyRunLogButton.textContent = "Copied"; + window.setTimeout(() => { + copyRunLogButton.textContent = previousLabel; + }, 1500); +} + +function resetStreamDocument(message) { + streamDocumentElement.replaceChildren(document.createTextNode(message)); + streamDocumentElement.scrollTop = 0; +} + +function replaceStreamDocument(text) { + streamDocumentElement.replaceChildren(renderYAMLSyntax(text || "(empty document)")); + streamDocumentElement.scrollTop = 0; +} + +function appendStreamDocumentChunk(text) { + if (!text) { + return; + } + if (streamDocumentElement.childNodes.length === 1 && streamDocumentElement.textContent.startsWith("Waiting for")) { + streamDocumentElement.replaceChildren(); + } + streamDocumentElement.append(renderYAMLSyntax(text)); + streamDocumentElement.scrollTop = streamDocumentElement.scrollHeight; +} + +function decodeYAMLBytes(bytes) { + return new TextDecoder().decode(bytes); +} + +function renderYAMLSyntax(text) { + const fragment = document.createDocumentFragment(); + for (const segment of text.split(/(\r?\n)/)) { + if (segment === "\n" || segment === "\r\n") { + fragment.append(document.createTextNode(segment)); + } else { + renderYAMLLine(segment, fragment); + } + } + return fragment; +} + +function renderYAMLLine(line, fragment) { + const commentIndex = findYAMLCommentIndex(line); + const source = commentIndex >= 0 ? line.slice(0, commentIndex) : line; + const comment = commentIndex >= 0 ? line.slice(commentIndex) : ""; + const keyMatch = source.match(/^(\s*(?:-\s*)?)([A-Za-z0-9_.-]+)(\s*:)(.*)$/); + if (source.trim() === "---" || source.trim() === "...") { + appendSyntaxSpan(fragment, source, "yaml-marker"); + } else if (keyMatch) { + fragment.append(document.createTextNode(keyMatch[1])); + appendSyntaxSpan(fragment, keyMatch[2], "yaml-key"); + appendSyntaxSpan(fragment, keyMatch[3], "yaml-punctuation"); + appendYAMLValue(fragment, keyMatch[4]); + } else { + appendYAMLValue(fragment, source); + } + if (comment) { + appendSyntaxSpan(fragment, comment, "yaml-comment"); + } +} + +function appendYAMLValue(fragment, value) { + const trimmed = value.trim(); + const leading = value.slice(0, value.length - value.trimStart().length); + const trailingStart = value.length - value.trimEnd().length; + const trailing = trailingStart > 0 ? value.slice(value.length - trailingStart) : ""; + if (leading) { + fragment.append(document.createTextNode(leading)); + } + if (/^(true|false|null|~)$/i.test(trimmed)) { + appendSyntaxSpan(fragment, trimmed, "yaml-literal"); + } else if (/^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(trimmed)) { + appendSyntaxSpan(fragment, trimmed, "yaml-number"); + } else if (/^(['"]).*\1$/.test(trimmed)) { + appendSyntaxSpan(fragment, trimmed, "yaml-string"); + } else { + fragment.append(document.createTextNode(trimmed)); + } + if (trailing) { + fragment.append(document.createTextNode(trailing)); + } +} + +function appendSyntaxSpan(fragment, text, className) { + const span = document.createElement("span"); + span.className = className; + span.textContent = text; + fragment.append(span); +} + +function findYAMLCommentIndex(line) { + let quote = null; + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + if ((character === "'" || character === "\"") && line[index - 1] !== "\\") { + quote = quote === character ? null : quote || character; + } + if (character === "#" && quote === null && (index === 0 || /\s/.test(line[index - 1]))) { + return index; + } + } + return -1; +} + function formatDuration(milliseconds) { if (milliseconds < 1000) { return `${milliseconds.toFixed(1)} ms`; diff --git a/WasmSite/style.css b/WasmSite/style.css index 1b5d15e..0b50dc1 100644 --- a/WasmSite/style.css +++ b/WasmSite/style.css @@ -90,11 +90,30 @@ button { color: CanvasText; } +.secondary-button { + margin-top: 0; + border: 1px solid CanvasText; + background: Canvas; + color: CanvasText; +} + button:disabled { cursor: wait; opacity: 0.55; } +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.section-header h2 { + margin-bottom: 0; +} + .status { margin-top: 18px; display: inline-flex; @@ -157,6 +176,45 @@ dd { font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; } +.stream-document { + height: min(420px, 55vh); + overflow: auto; + border: 1px solid color-mix(in srgb, CanvasText 14%, transparent); + border-radius: 16px; + padding: 14px; + background: color-mix(in srgb, Canvas 96%, CanvasText 4%); + font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.yaml-key { + color: #4f8cff; + font-weight: 700; +} + +.yaml-punctuation, +.yaml-marker { + color: #b66cff; + font-weight: 700; +} + +.yaml-comment { + color: #708090; + font-style: italic; +} + +.yaml-string { + color: #138a36; +} + +.yaml-number { + color: #b36b00; +} + +.yaml-literal { + color: #c21f39; + font-weight: 700; +} + .log-entry { padding: 6px 0; border-bottom: 1px solid color-mix(in srgb, CanvasText 8%, transparent);