WASM E2E test: Speckl specs compile to runnable WebAssembly
Some checks failed
Deploy Landing Page to Storj / deploy (push) Failing after 3s

- 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.
This commit is contained in:
Scott Scoble 2026-05-03 13:13:55 -07:00
parent 7ffd65f6b3
commit 39436b4382
4 changed files with 236 additions and 11 deletions

View file

@ -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);
});

View file

@ -1,24 +1,26 @@
{ {
"name": "speckl-compile", "name": "speckl-compile",
"version": "0.2.0", "version": "0.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "speckl-compile", "name": "speckl-compile",
"version": "0.2.0", "version": "0.3.1",
"dependencies": { "dependencies": {
"yargs": "^17.7.2" "yargs": "^17.7.2"
}, },
"bin": { "bin": {
"speckl-compile": "dist/index.js" "speckl-compile": "dist/index.js",
"speckl-validate": "dist/validator.js"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@types/yargs": "^17.0.33", "@types/yargs": "^17.0.33",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.3", "vite": "^6.0.3",
"vitest": "^2.1.8" "vitest": "^2.1.8",
"wabt": "^1.0.39"
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "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": { "node_modules/why-is-node-running": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",

View file

@ -23,6 +23,7 @@
"@types/yargs": "^17.0.33", "@types/yargs": "^17.0.33",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.3", "vite": "^6.0.3",
"vitest": "^2.1.8" "vitest": "^2.1.8",
"wabt": "^1.0.39"
} }
} }

View file

@ -238,6 +238,7 @@ function emitActionWAT(action: ActionNode, stateVars: StateNode['variables']): s
return ` ;; action: ${action.name}\n` + return ` ;; action: ${action.name}\n` +
` (func $${action.name} (export "${action.name}") ${params} (result i32)\n` + ` (func $${action.name} (export "${action.name}") ${params} (result i32)\n` +
` ${locals}\n` +
` ${bodyLines.join('\n ')}\n` + ` ${bodyLines.join('\n ')}\n` +
` )`; ` )`;
} }
@ -254,15 +255,15 @@ function emitGuardExpr(
// Very simple pattern: "x >= 0", "x == true", "x != y", etc. // Very simple pattern: "x >= 0", "x == true", "x != y", etc.
const m = expr.match(/^\s*(\w+)\s*(>=|<=|>|<|==|!=)\s*(.+?)\s*$/); const m = expr.match(/^\s*(\w+)\s*(>=|<=|>|<|==|!=)\s*(.+?)\s*$/);
if (!m) { if (!m) {
// Can't compile guard — return 1 (pass) with comment // Can't compile guard — just comment, no stack effect
return `;; Complex guard not compiled: ${expr}\n (i32.const 1)`; return `;; Complex guard not compiled: ${expr}`;
} }
const [, left, op, rightRaw] = m; const [, left, op, rightRaw] = m;
const right = rightRaw.trim(); const right = rightRaw.trim();
if (!scalars.has(left)) { 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})`; const lhs = `(global.get $${left})`;
@ -279,7 +280,7 @@ function emitGuardExpr(
} else if (scalars.has(right)) { } else if (scalars.has(right)) {
rhs = `(global.get $${right})`; rhs = `(global.get $${right})`;
} else { } else {
return `;; Complex right-hand side not compiled: ${expr}\n (i32.const 1)`; return `;; Complex right-hand side not compiled: ${expr}`;
} }
const comparison = (() => { const comparison = (() => {
@ -294,8 +295,8 @@ function emitGuardExpr(
} }
})(); })();
// If guard fails, return 0 // If guard fails (comparison is FALSE), return 0
return `${comparison}\n (if (then (return (i32.const 0))))`; return `${comparison}\n (i32.eqz)\n (if (then (return (i32.const 0))))`;
} }
function emitExpr(expr: string, scalars: Set<string>): string { function emitExpr(expr: string, scalars: Set<string>): string {