From ccedc73b1dc61460500e505a9a0fa2c5bcccfabc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Quei=C3=9Fner?= Date: Sun, 4 Jan 2026 12:48:56 +0100 Subject: [PATCH] Gate WASM Node tests via build --- README.md | 2 + build.zig | 21 ++++ src/wasm.zig | 6 + test/wasm/diagnostic_accepted.hdoc | 3 + test/wasm/diagnostic_rejected.hdoc | 2 + test/wasm/diagnostics_expected.json | 16 +++ test/wasm/validate.js | 177 ++++++++++++++++++++++++++++ 7 files changed, 227 insertions(+) create mode 100644 test/wasm/diagnostic_accepted.hdoc create mode 100644 test/wasm/diagnostic_rejected.hdoc create mode 100644 test/wasm/diagnostics_expected.json create mode 100644 test/wasm/validate.js diff --git a/README.md b/README.md index 6806429..08847f7 100644 --- a/README.md +++ b/README.md @@ -29,3 +29,5 @@ Requires [Zig 0.15.2](https://ziglang.org/) installed. ```sh-session [user@host] hyperdoc$ zig build test ``` + +> Optional: installing Node.js enables the WASM integration tests that exercise the compiled `hyperdoc_wasm.wasm` via `node test/wasm/validate.js`. diff --git a/build.zig b/build.zig index ab971a0..5e35295 100644 --- a/build.zig +++ b/build.zig @@ -73,6 +73,18 @@ pub fn build(b: *std.Build) void { }, }), }); + wasm_exe.root_module.export_symbol_names = &.{ + "hdoc_set_document_len", + "hdoc_document_ptr", + "hdoc_process", + "hdoc_html_ptr", + "hdoc_html_len", + "hdoc_diagnostic_count", + "hdoc_diagnostic_line", + "hdoc_diagnostic_column", + "hdoc_diagnostic_message_ptr", + "hdoc_diagnostic_message_len", + }; b.installArtifact(wasm_exe); const run_cmd = b.addRunArtifact(exe); @@ -177,6 +189,15 @@ pub fn build(b: *std.Build) void { .use_llvm = true, }); test_step.dependOn(&b.addRunArtifact(main_tests).step); + + const node_path = b.findProgram(&.{"node"}, &.{}) catch null; + if (node_path) |node| { + const wasm_validate = b.addSystemCommand(&.{ node, "test/wasm/validate.js" }); + wasm_validate.step.dependOn(b.getInstallStep()); + test_step.dependOn(&wasm_validate.step); + } else { + std.debug.print("node not found; skipping WASM integration tests\n", .{}); + } } fn rawFileMod(b: *std.Build, path: []const u8) std.Build.Module.Import { diff --git a/src/wasm.zig b/src/wasm.zig index 852768a..8cc0627 100644 --- a/src/wasm.zig +++ b/src/wasm.zig @@ -118,6 +118,9 @@ fn capture_diagnostics(source: *hyperdoc.Diagnostics) !void { diag.code.format(&adapter.new_interface) catch { adapter.err = error.WriteFailed; }; + adapter.new_interface.flush() catch { + adapter.err = error.WriteFailed; + }; if (adapter.err) |_| return; const rendered = diagnostic_text.items[start..]; @@ -172,6 +175,9 @@ export fn hdoc_process() bool { hyperdoc.render.html5(parsed, &html_adapter.new_interface) catch { html_adapter.err = error.WriteFailed; }; + html_adapter.new_interface.flush() catch { + html_adapter.err = error.WriteFailed; + }; if (html_adapter.err) |_| { capture_diagnostics(&diagnostics) catch {}; return false; diff --git a/test/wasm/diagnostic_accepted.hdoc b/test/wasm/diagnostic_accepted.hdoc new file mode 100644 index 0000000..fcd9f85 --- /dev/null +++ b/test/wasm/diagnostic_accepted.hdoc @@ -0,0 +1,3 @@ +hdoc(version="2.0"); +title "WASM Warning Coverage" +p { The header intentionally omits a lang attribute. } diff --git a/test/wasm/diagnostic_rejected.hdoc b/test/wasm/diagnostic_rejected.hdoc new file mode 100644 index 0000000..a43140b --- /dev/null +++ b/test/wasm/diagnostic_rejected.hdoc @@ -0,0 +1,2 @@ +h1 "Missing header" +p { This file lacks the required hdoc header. } diff --git a/test/wasm/diagnostics_expected.json b/test/wasm/diagnostics_expected.json new file mode 100644 index 0000000..703225c --- /dev/null +++ b/test/wasm/diagnostics_expected.json @@ -0,0 +1,16 @@ +{ + "accepted": [ + { + "line": 1, + "column": 1, + "message": "Document language is missing; set lang on the hdoc header." + } + ], + "rejected": [ + { + "line": 1, + "column": 1, + "message": "Document must start with an 'hdoc' header." + } + ] +} diff --git a/test/wasm/validate.js b/test/wasm/validate.js new file mode 100644 index 0000000..45ffd18 --- /dev/null +++ b/test/wasm/validate.js @@ -0,0 +1,177 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +const repoRoot = path.join(__dirname, '..', '..'); +const wasmPath = path.join(repoRoot, 'zig-out', 'bin', 'hyperdoc_wasm.wasm'); + +const htmlSnapshotTests = [ + { + name: 'document_header', + source: path.join(repoRoot, 'test', 'snapshot', 'document_header.hdoc'), + expected: path.join(repoRoot, 'test', 'snapshot', 'document_header.html'), + }, + { + name: 'paragraph_styles', + source: path.join(repoRoot, 'test', 'snapshot', 'paragraph_styles.hdoc'), + expected: path.join(repoRoot, 'test', 'snapshot', 'paragraph_styles.html'), + }, + { + name: 'tables', + source: path.join(repoRoot, 'test', 'snapshot', 'tables.hdoc'), + expected: path.join(repoRoot, 'test', 'snapshot', 'tables.html'), + }, +]; + +const diagnosticsInput = { + accepted: path.join(__dirname, 'diagnostic_accepted.hdoc'), + rejected: path.join(__dirname, 'diagnostic_rejected.hdoc'), + expected: path.join(__dirname, 'diagnostics_expected.json'), +}; + +function assertFileExists(filePath) { + if (!fs.existsSync(filePath)) { + throw new Error(`Missing required file: ${filePath}`); + } +} + +function readUtf8(filePath) { + return fs.readFileSync(filePath, 'utf8'); +} + +function createLogImports(memoryRef) { + const state = { buffer: '' }; + return { + reset_log() { + state.buffer = ''; + }, + append_log(ptr, len) { + if (len === 0 || ptr === 0) return; + const memory = memoryRef.current; + if (!memory) return; + const view = new Uint8Array(memory.buffer, ptr, len); + state.buffer += textDecoder.decode(view); + }, + flush_log(level) { + if (state.buffer.length === 0) return; + const method = ['error', 'warn', 'info', 'debug'][level] || 'log'; + console[method](`[wasm ${method}] ${state.buffer}`); + state.buffer = ''; + }, + }; +} + +function getMemory(wasm, memoryRef) { + const memory = wasm.memory || memoryRef.current; + memoryRef.current = memory; + if (!memory) { + throw new Error('WASM memory is unavailable'); + } + return memory; +} + +async function instantiateWasm() { + assertFileExists(wasmPath); + const bytes = await fs.promises.readFile(wasmPath); + const memoryRef = { current: null }; + const env = createLogImports(memoryRef); + const { instance } = await WebAssembly.instantiate(bytes, { env }); + memoryRef.current = instance.exports.memory; + return { wasm: instance.exports, memoryRef }; +} + +function readString(memory, ptr, len) { + if (!ptr || len === 0) return ''; + const view = new Uint8Array(memory.buffer, ptr, len); + return textDecoder.decode(view); +} + +function processDocument(ctx, sourceText) { + const { wasm, memoryRef } = ctx; + const bytes = textEncoder.encode(sourceText); + + if (!wasm.hdoc_set_document_len(bytes.length)) { + throw new Error('Failed to allocate WASM document buffer'); + } + + const memoryForInput = getMemory(wasm, memoryRef); + const docPtr = wasm.hdoc_document_ptr(); + if (bytes.length > 0) { + new Uint8Array(memoryForInput.buffer, docPtr, bytes.length).set(bytes); + } + + const ok = wasm.hdoc_process() !== 0; + const memory = getMemory(wasm, memoryRef); + + const htmlPtr = wasm.hdoc_html_ptr(); + const htmlLen = wasm.hdoc_html_len(); + const html = readString(memory, htmlPtr ?? 0, htmlLen); + + const diagnostics = []; + const diagCount = wasm.hdoc_diagnostic_count(); + for (let i = 0; i < diagCount; i += 1) { + const msgPtr = wasm.hdoc_diagnostic_message_ptr(i) ?? 0; + const msgLen = wasm.hdoc_diagnostic_message_len(i); + diagnostics.push({ + line: wasm.hdoc_diagnostic_line(i), + column: wasm.hdoc_diagnostic_column(i), + message: readString(memory, msgPtr, msgLen), + }); + } + + return { ok, html, diagnostics }; +} + +function compareDiagnostics(actual, expected, label) { + assert.deepStrictEqual( + actual, + expected, + `${label} diagnostics differ.\nExpected: ${JSON.stringify(expected, null, 2)}\nActual: ${JSON.stringify(actual, null, 2)}`, + ); +} + +async function runHtmlTests(ctx) { + for (const test of htmlSnapshotTests) { + assertFileExists(test.source); + assertFileExists(test.expected); + const { ok, html, diagnostics } = processDocument(ctx, readUtf8(test.source)); + assert.equal(ok, true, `WASM processing failed for ${test.name}`); + assert.deepStrictEqual(diagnostics, [], `Expected no diagnostics for ${test.name}`); + const expectedHtml = readUtf8(test.expected); + assert.equal(html, expectedHtml, `Rendered HTML mismatch for ${test.name}`); + } +} + +async function runDiagnosticTests(ctx) { + assertFileExists(diagnosticsInput.accepted); + assertFileExists(diagnosticsInput.rejected); + assertFileExists(diagnosticsInput.expected); + + const expectations = JSON.parse(readUtf8(diagnosticsInput.expected)); + + const acceptedResult = processDocument(ctx, readUtf8(diagnosticsInput.accepted)); + assert.equal(acceptedResult.ok, true, 'Accepted diagnostic test should render successfully'); + compareDiagnostics(acceptedResult.diagnostics, expectations.accepted, 'Accepted'); + + const rejectedResult = processDocument(ctx, readUtf8(diagnosticsInput.rejected)); + assert.equal(rejectedResult.ok, false, 'Rejected diagnostic test should fail'); + compareDiagnostics(rejectedResult.diagnostics, expectations.rejected, 'Rejected'); +} + +async function main() { + const ctx = await instantiateWasm(); + await runHtmlTests(ctx); + await runDiagnosticTests(ctx); + console.log('WASM integration tests passed.'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +});