From 39436b4382a15db644ae130e46bec48137fc7d61 Mon Sep 17 00:00:00 2001 From: Scott Scoble Date: Sun, 3 May 2026 13:13:55 -0700 Subject: [PATCH] WASM E2E test: Speckl specs compile to runnable WebAssembly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add __tests__/e2e-wasm.test.ts: full pipeline test - Parse Counter speck → generate WAT → assemble with wabt → instantiate → run - Verify init, action calls, guard pass/fail, state mutations - TigerBeetle WAT validation test (wabt parse + validate + toBinary) - Fix wasm.ts bugs found by E2E: - Guard condition inverted: added i32.eqz to negate comparison - Missing local declarations: locals now inserted before body - Uncompiled guards left stack values: now emit comments only (no i32.const 1) - Install wabt npm package for WAT→WASM assembly in tests - 41/41 tests passing (39 original + 2 E2E) Milestone: Speckl produces runnable WebAssembly from specs. --- compiler/__tests__/e2e-wasm.test.ts | 203 ++++++++++++++++++++++++++++ compiler/package-lock.json | 28 +++- compiler/package.json | 3 +- compiler/src/generators/wasm.ts | 13 +- 4 files changed, 236 insertions(+), 11 deletions(-) create mode 100644 compiler/__tests__/e2e-wasm.test.ts diff --git a/compiler/__tests__/e2e-wasm.test.ts b/compiler/__tests__/e2e-wasm.test.ts new file mode 100644 index 0000000..aef5625 --- /dev/null +++ b/compiler/__tests__/e2e-wasm.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect } from 'vitest'; +import { parseSpeckContent } from '../src/parser.js'; +import { generateWASM } from '../src/generators/wasm.js'; +import { createHostImports, bindMemory, readScalarState } from '../src/runtime/wasm-host.js'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +// Dynamic import wabt since it's a CommonJS module +async function loadWabt() { + const wabt = await import('wabt'); + return wabt.default(); +} + +describe('SpeckDL WASM E2E', () => { + it('should compile Counter speck to WAT, assemble to WASM, and run in Node.js', async () => { + // A minimal scalar-only speck that we can fully compile and run + // NOTE: Parser requires braces on the SAME line as block keywords + const speckSource = `speck Counter { + author: "Speckl Compiler v0.3" + source: document ref "WASM-E2E" + review: automated + + state: { + count: Nat := 0 + maxCount: Nat := 10 + } + + init: { + count := 0 + maxCount := 10 + } + + action Increment() { + require count < maxCount + count := count + 1 + } + + action Decrement() { + require count > 0 + count := count - 1 + } + + action Reset() { + count := 0 + } + + action GetCount() { + return count + } +}`; + + // 1. Parse + const ast = parseSpeckContent(speckSource); + expect(ast.specks).toHaveLength(1); + expect(ast.specks[0].name).toBe('Counter'); + + // 2. Generate WAT to temp dir + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'speckl-wasm-e2e-')); + generateWASM(ast, tmpDir); + + const watPath = path.join(tmpDir, 'Counter.wat'); + expect(fs.existsSync(watPath)).toBe(true); + + const watContent = fs.readFileSync(watPath, 'utf-8'); + expect(watContent).toContain('(module'); + expect(watContent).toContain('(export "init")'); + expect(watContent).toContain('(export "Increment")'); + expect(watContent).toContain('(export "Decrement")'); + expect(watContent).toContain('(export "Reset")'); + expect(watContent).toContain('(export "GetCount")'); + expect(watContent).toContain('(export "getState")'); + expect(watContent).toContain('(export "memory")'); + + // 3. Assemble WAT → WASM using wabt + const wabtModule = await loadWabt(); + const wasmModule = wabtModule.parseWat('Counter.wat', watContent); + wasmModule.resolveNames(); + wasmModule.validate({ features: {} }); + const binaryOutput = wasmModule.toBinary({ log: false, write_debug_names: false }); + const wasmBytes = binaryOutput.buffer; + + expect(wasmBytes.length).toBeGreaterThan(0); + expect(wasmBytes.length).toBeGreaterThan(50); // Not just empty header + + // 4. Instantiate with host runtime + const hostState = { + maps: new Map(), + sets: new Map(), + nextHandle: 1, + memory: null as WebAssembly.Memory | null, + }; + const imports = { speckl: createHostImports(hostState) }; + + const wasm = await WebAssembly.instantiate(wasmBytes, imports); + const memory = wasm.instance.exports.memory as WebAssembly.Memory; + bindMemory(hostState, memory); + + // 5. Verify exports + expect(typeof wasm.instance.exports.init).toBe('function'); + expect(typeof wasm.instance.exports.Increment).toBe('function'); + expect(typeof wasm.instance.exports.Decrement).toBe('function'); + expect(typeof wasm.instance.exports.Reset).toBe('function'); + expect(typeof wasm.instance.exports.GetCount).toBe('function'); + expect(typeof wasm.instance.exports.getState).toBe('function'); + expect(memory).toBeInstanceOf(WebAssembly.Memory); + + // 6. Run init + (wasm.instance.exports.init as Function)(); + + // 7. Verify initial state via getState + const stateResult = (wasm.instance.exports.getState as Function)(); + // getState returns i32 i32 (ptr, len) + expect(stateResult).toBeDefined(); + + // Read memory directly to verify state + const scalarState = readScalarState(memory, ['count', 'maxCount']); + expect(scalarState.count).toBe(0); + expect(scalarState.maxCount).toBe(10); + + // 8. Run Increment 3 times, calling getState after each to sync memory + for (let i = 0; i < 3; i++) { + const ret = (wasm.instance.exports.Increment as Function)(); + expect(ret).toBe(1); // Success + (wasm.instance.exports.getState as Function)(); // Sync globals to memory + } + + const afterIncrement = readScalarState(memory, ['count', 'maxCount']); + expect(afterIncrement.count).toBe(3); + expect(afterIncrement.maxCount).toBe(10); + + // 9. Run Decrement once + const decRet = (wasm.instance.exports.Decrement as Function)(); + expect(decRet).toBe(1); // Success + (wasm.instance.exports.getState as Function)(); + + const afterDecrement = readScalarState(memory, ['count', 'maxCount']); + expect(afterDecrement.count).toBe(2); + + // 10. Run GetCount — should return current count (global, not memory) + const getRet = (wasm.instance.exports.GetCount as Function)(); + expect(getRet).toBe(2); + + // 11. Run Reset + const resetRet = (wasm.instance.exports.Reset as Function)(); + expect(resetRet).toBe(1); // Success + (wasm.instance.exports.getState as Function)(); + + const afterReset = readScalarState(memory, ['count', 'maxCount']); + expect(afterReset.count).toBe(0); + + // 12. Guard test: Decrement at 0 should fail + const guardFail = (wasm.instance.exports.Decrement as Function)(); + expect(guardFail).toBe(0); // Guard failed → return 0 + + // 13. Guard test: Increment at maxCount should fail + // First increment to max + for (let i = 0; i < 10; i++) { + (wasm.instance.exports.Increment as Function)(); + } + (wasm.instance.exports.getState as Function)(); + const atMax = readScalarState(memory, ['count', 'maxCount']); + expect(atMax.count).toBe(10); + + const maxGuardFail = (wasm.instance.exports.Increment as Function)(); + expect(maxGuardFail).toBe(0); // Guard failed → return 0 + + // Cleanup + fs.rmSync(tmpDir, { recursive: true, force: true }); + }, 30000); // 30s timeout for wabt compilation + + it('should compile TigerBeetle speck to valid WAT that wabt accepts', async () => { + // Load the full TigerBeetle speck from file + const speckPath = path.join(__dirname, 'tigerbeetle.speck'); + const speckSource = fs.readFileSync(speckPath, 'utf-8'); + + const ast = parseSpeckContent(speckSource); + expect(ast.specks).toHaveLength(1); + expect(ast.specks[0].name).toBe('TigerBeetle'); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'speckl-wasm-e2e-tb-')); + generateWASM(ast, tmpDir); + + const watPath = path.join(tmpDir, 'TigerBeetle.wat'); + expect(fs.existsSync(watPath)).toBe(true); + + const watContent = fs.readFileSync(watPath, 'utf-8'); + + // Validate with wabt + const wabtModule = await loadWabt(); + const wasmModule = wabtModule.parseWat('TigerBeetle.wat', watContent); + + // These should not throw + wasmModule.resolveNames(); + wasmModule.validate({ features: {} }); + const binaryOutput = wasmModule.toBinary({ log: false, write_debug_names: false }); + + expect(binaryOutput.buffer.length).toBeGreaterThan(100); + + // Cleanup + fs.rmSync(tmpDir, { recursive: true, force: true }); + }, 30000); +}); diff --git a/compiler/package-lock.json b/compiler/package-lock.json index e30d9d0..e244b6f 100644 --- a/compiler/package-lock.json +++ b/compiler/package-lock.json @@ -1,24 +1,26 @@ { "name": "speckl-compile", - "version": "0.2.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "speckl-compile", - "version": "0.2.0", + "version": "0.3.1", "dependencies": { "yargs": "^17.7.2" }, "bin": { - "speckl-compile": "dist/index.js" + "speckl-compile": "dist/index.js", + "speckl-validate": "dist/validator.js" }, "devDependencies": { "@types/node": "^22.10.5", "@types/yargs": "^17.0.33", "typescript": "^5.7.2", "vite": "^6.0.3", - "vitest": "^2.1.8" + "vitest": "^2.1.8", + "wabt": "^1.0.39" } }, "node_modules/@esbuild/aix-ppc64": { @@ -2679,6 +2681,24 @@ } } }, + "node_modules/wabt": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/wabt/-/wabt-1.0.39.tgz", + "integrity": "sha512-ba+dRL/75VQQY7RkU/CgriGbkoWAfS8TDyUlJfJhJ8KhtXgMl5dhNvoPNUcQ9IWRhW8u41glMSuZeTvsYq2rRg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "wasm-decompile": "bin/wasm-decompile", + "wasm-interp": "bin/wasm-interp", + "wasm-objdump": "bin/wasm-objdump", + "wasm-stats": "bin/wasm-stats", + "wasm-strip": "bin/wasm-strip", + "wasm-validate": "bin/wasm-validate", + "wasm2c": "bin/wasm2c", + "wasm2wat": "bin/wasm2wat", + "wat2wasm": "bin/wat2wasm" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/compiler/package.json b/compiler/package.json index a59b20d..8710f2b 100644 --- a/compiler/package.json +++ b/compiler/package.json @@ -23,6 +23,7 @@ "@types/yargs": "^17.0.33", "typescript": "^5.7.2", "vite": "^6.0.3", - "vitest": "^2.1.8" + "vitest": "^2.1.8", + "wabt": "^1.0.39" } } diff --git a/compiler/src/generators/wasm.ts b/compiler/src/generators/wasm.ts index f0c8965..0b0d3f8 100644 --- a/compiler/src/generators/wasm.ts +++ b/compiler/src/generators/wasm.ts @@ -238,6 +238,7 @@ function emitActionWAT(action: ActionNode, stateVars: StateNode['variables']): s return ` ;; action: ${action.name}\n` + ` (func $${action.name} (export "${action.name}") ${params} (result i32)\n` + + ` ${locals}\n` + ` ${bodyLines.join('\n ')}\n` + ` )`; } @@ -254,15 +255,15 @@ function emitGuardExpr( // Very simple pattern: "x >= 0", "x == true", "x != y", etc. const m = expr.match(/^\s*(\w+)\s*(>=|<=|>|<|==|!=)\s*(.+?)\s*$/); if (!m) { - // Can't compile guard — return 1 (pass) with comment - return `;; Complex guard not compiled: ${expr}\n (i32.const 1)`; + // Can't compile guard — just comment, no stack effect + return `;; Complex guard not compiled: ${expr}`; } const [, left, op, rightRaw] = m; const right = rightRaw.trim(); if (!scalars.has(left)) { - return `;; Guard on non-scalar not compiled: ${expr}\n (i32.const 1)`; + return `;; Guard on non-scalar not compiled: ${expr}`; } const lhs = `(global.get $${left})`; @@ -279,7 +280,7 @@ function emitGuardExpr( } else if (scalars.has(right)) { rhs = `(global.get $${right})`; } else { - return `;; Complex right-hand side not compiled: ${expr}\n (i32.const 1)`; + return `;; Complex right-hand side not compiled: ${expr}`; } const comparison = (() => { @@ -294,8 +295,8 @@ function emitGuardExpr( } })(); - // If guard fails, return 0 - return `${comparison}\n (if (then (return (i32.const 0))))`; + // If guard fails (comparison is FALSE), return 0 + return `${comparison}\n (i32.eqz)\n (if (then (return (i32.const 0))))`; } function emitExpr(expr: string, scalars: Set): string {