speckl/compiler/__tests__/e2e-wasm.test.ts
Scott Scoble 39436b4382
Some checks failed
Deploy Landing Page to Storj / deploy (push) Failing after 3s
WASM E2E test: Speckl specs compile to runnable WebAssembly
- 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.
2026-05-03 13:13:55 -07:00

203 lines
7 KiB
TypeScript

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