Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
21 changes: 21 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions src/wasm.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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..];
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions test/wasm/diagnostic_accepted.hdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
hdoc(version="2.0");
title "WASM Warning Coverage"
p { The header intentionally omits a lang attribute. }
2 changes: 2 additions & 0 deletions test/wasm/diagnostic_rejected.hdoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
h1 "Missing header"
p { This file lacks the required hdoc header. }
16 changes: 16 additions & 0 deletions test/wasm/diagnostics_expected.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
}
177 changes: 177 additions & 0 deletions test/wasm/validate.js
Original file line number Diff line number Diff line change
@@ -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;
});