WASM E2E test: Speckl specs compile to runnable WebAssembly
Some checks failed
Deploy Landing Page to Storj / deploy (push) Failing after 3s
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:
parent
7ffd65f6b3
commit
39436b4382
4 changed files with 236 additions and 11 deletions
203
compiler/__tests__/e2e-wasm.test.ts
Normal file
203
compiler/__tests__/e2e-wasm.test.ts
Normal 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);
|
||||||
|
});
|
||||||
28
compiler/package-lock.json
generated
28
compiler/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue