|
| 1 | +// SPDX-License-Identifier: MPL-2.0 |
| 2 | +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk> |
| 3 | +// |
| 4 | +// Node ESM harness for examples/consumers/extension-boundary. |
| 5 | +// |
| 6 | +// This file is the other half of the on-ramp: it shows exactly how a host |
| 7 | +// satisfies the `extern fn` surface declared in src/boundary.affine, and |
| 8 | +// what the assertable contract looks like in CI. Node is used because CI |
| 9 | +// already has it; nothing here is Node-specific except `readFile`, so the |
| 10 | +// same object literal works in Deno, Bun, or a browser extension |
| 11 | +// (`WebAssembly.instantiate` with the same `imports`). |
| 12 | +import assert from "node:assert/strict"; |
| 13 | +import { readFile } from "node:fs/promises"; |
| 14 | +import { fileURLToPath } from "node:url"; |
| 15 | +import { dirname, join } from "node:path"; |
| 16 | + |
| 17 | +const here = dirname(fileURLToPath(import.meta.url)); |
| 18 | + |
| 19 | +// The error taxonomy a real consumer needs is stable and enumerated. These |
| 20 | +// are the codes the host returns; the guest only ever sees integers. |
| 21 | +const BW = { |
| 22 | + OK: 0, |
| 23 | + BW_PDF_ENCRYPTED: 1042, |
| 24 | + BW_NO_BLOCKS_FOUND: 1051, |
| 25 | +}; |
| 26 | + |
| 27 | +// The host owns the message text. Nothing crosses the boundary as a string |
| 28 | +// — `bw_error_message_*` is a byte-wise accessor over this value. |
| 29 | +const MESSAGES = { |
| 30 | + [BW.BW_PDF_ENCRYPTED]: "document is encrypted; decrypt before filling", |
| 31 | + [BW.BW_NO_BLOCKS_FOUND]: "no fillable blocks detected in document", |
| 32 | +}; |
| 33 | + |
| 34 | +// `context` is the third leg of the {code, message, context} payload the |
| 35 | +// consumer contract requires; it stays host-side too. |
| 36 | +let lastFailure = null; |
| 37 | + |
| 38 | +function fail(code, context) { |
| 39 | + lastFailure = { code, message: MESSAGES[code] ?? "", context }; |
| 40 | + return code; |
| 41 | +} |
| 42 | + |
| 43 | +const calls = { detect: 0, fill: 0 }; |
| 44 | + |
| 45 | +const imports = { |
| 46 | + wasi_snapshot_preview1: { |
| 47 | + // Nothing in this guest touches WASI, but the compiler links the |
| 48 | + // preview-1 surface when the file mentions effects; a stub keeps the |
| 49 | + // instantiation total rather than depending on what codegen emitted. |
| 50 | + fd_write: () => 0, |
| 51 | + }, |
| 52 | + env: { |
| 53 | + bw_detect_blocks: (pdfLen) => { |
| 54 | + calls.detect += 1; |
| 55 | + assert.ok(Number.isInteger(pdfLen) && pdfLen > 0, "host got the length"); |
| 56 | + return BW.OK; |
| 57 | + }, |
| 58 | + bw_fill_blocks: (pdfLen, fieldCount) => { |
| 59 | + calls.fill += 1; |
| 60 | + assert.ok(Number.isInteger(pdfLen) && pdfLen > 0, "host got the length"); |
| 61 | + assert.ok(fieldCount > 0, "host got the field count"); |
| 62 | + // Second document is encrypted — the failure path, deliberately. |
| 63 | + return fail(BW.BW_PDF_ENCRYPTED, { pdfLen, fieldCount }); |
| 64 | + }, |
| 65 | + bw_error_code: () => lastFailure?.code ?? BW.OK, |
| 66 | + bw_error_message_len: () => lastFailure?.message.length ?? 0, |
| 67 | + bw_error_message_byte: (offset) => |
| 68 | + lastFailure?.message.charCodeAt(offset) ?? 0, |
| 69 | + }, |
| 70 | +}; |
| 71 | + |
| 72 | +const wasmPath = join(here, "dist", "boundary.wasm"); |
| 73 | +const bytes = await readFile(wasmPath); |
| 74 | +const { instance } = await WebAssembly.instantiate(bytes, imports); |
| 75 | +const guest = instance.exports; |
| 76 | + |
| 77 | +// ── The contract, asserted ─────────────────────────────────────────────── |
| 78 | + |
| 79 | +// 1. Success path: the guest normalises "host said 0" to 0. |
| 80 | +assert.equal(guest.detect(4096), 0, "detect returns 0 on success"); |
| 81 | +assert.equal(guest.is_ok(0), 1, "is_ok(0) is true"); |
| 82 | +assert.equal(guest.message_len(0), 0, "no message for a success status"); |
| 83 | +assert.equal(calls.detect, 1, "host detected once"); |
| 84 | + |
| 85 | +// 2. Failure path: the guest propagates the stable code, not a string. |
| 86 | +assert.equal( |
| 87 | + guest.fill(4096, 3), |
| 88 | + BW.BW_PDF_ENCRYPTED, |
| 89 | + "fill propagates the host's BW_* code", |
| 90 | +); |
| 91 | +assert.equal(calls.fill, 1, "host filled once"); |
| 92 | + |
| 93 | +// 3. The message is host-owned and byte-addressable, exactly as declared. |
| 94 | +const len = guest.message_len(BW.BW_PDF_ENCRYPTED); |
| 95 | +assert.equal(len, MESSAGES[BW.BW_PDF_ENCRYPTED].length, "message length crosses"); |
| 96 | +const rebuilt = Array.from({ length: len }, (_, i) => |
| 97 | + String.fromCharCode(guest.message_byte(BW.BW_PDF_ENCRYPTED, i)), |
| 98 | +).join(""); |
| 99 | +assert.equal(rebuilt, MESSAGES[BW.BW_PDF_ENCRYPTED], "message round-trips"); |
| 100 | +assert.equal(guest.message_len(0), 0, "success still has no message"); |
| 101 | + |
| 102 | +// 4. The consumer-facing payload shape: {code, message, context} — never a |
| 103 | +// bare string, which is the taxonomy requirement this example exists for. |
| 104 | +assert.deepEqual(lastFailure, { |
| 105 | + code: BW.BW_PDF_ENCRYPTED, |
| 106 | + message: MESSAGES[BW.BW_PDF_ENCRYPTED], |
| 107 | + context: { pdfLen: 4096, fieldCount: 3 }, |
| 108 | +}); |
| 109 | + |
| 110 | +// 5. The guest's public surface is what the consumer advertised — a |
| 111 | +// two-function boundary plus the error accessors. |
| 112 | +for (const name of [ |
| 113 | + "detect", "fill", "is_ok", "message_len", "message_byte", |
| 114 | +]) { |
| 115 | + assert.equal(typeof guest[name], "function", `${name} is exported`); |
| 116 | +} |
| 117 | + |
| 118 | +console.log("examples/consumers/extension-boundary: OK"); |
0 commit comments