Skip to content

Commit 02aa1b7

Browse files
hyperpolymathclaude
andcommitted
feat: add P0 E2E smoke test, package.json for @rescript/runtime
Add end-to-end smoke test that reads examples/01-single-module.twasm, parses it with the ReScript parser, and verifies the AST structure corresponds to the Idris2 ABI types (40 assertions). Add package.json with @rescript/runtime dependency needed to run compiled ReScript tests. Unignore tests/smoke/*.mjs so handwritten test scripts are tracked. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ee7ca98 commit 02aa1b7

4 files changed

Lines changed: 322 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ erl_crash.dump
4343
/lib/ocaml/
4444
/.bsb.lock
4545
*.mjs
46+
!tests/smoke/*.mjs
4647
*.cmi
4748
*.cmj
4849
*.cmt

deno.lock

Lines changed: 59 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "typed-wasm",
3+
"version": "0.1.0",
4+
"type": "module",
5+
"private": true,
6+
"license": "PMPL-1.0-or-later",
7+
"dependencies": {
8+
"@rescript/runtime": "^12.0.0",
9+
"rescript": "^12.0.0"
10+
}
11+
}

tests/smoke/e2e-smoke.mjs

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
//
4+
// End-to-end smoke test for typed-wasm P0 validation.
5+
//
6+
// This script:
7+
// 1. Reads examples/01-single-module.twasm
8+
// 2. Parses it with the ReScript parser
9+
// 3. Extracts region schemas from the AST
10+
// 4. Verifies the schema structure matches what the Idris2 ABI types expect
11+
//
12+
// Run: node tests/smoke/e2e-smoke.mjs
13+
14+
import { readFileSync } from "node:fs";
15+
import { resolve, dirname } from "node:path";
16+
import { fileURLToPath } from "node:url";
17+
import { parseModule } from "../../src/parser/Parser.mjs";
18+
19+
const __dirname = dirname(fileURLToPath(import.meta.url));
20+
const projectRoot = resolve(__dirname, "../..");
21+
22+
let passed = 0;
23+
let failed = 0;
24+
25+
function assert(condition, message) {
26+
if (condition) {
27+
console.log(` OK: ${message}`);
28+
passed++;
29+
} else {
30+
console.error(` FAIL: ${message}`);
31+
failed++;
32+
}
33+
}
34+
35+
// ============================================================================
36+
// Step 1: Read example file
37+
// ============================================================================
38+
39+
console.log("=== E2E Smoke Test: typed-wasm P0 ===\n");
40+
console.log("Step 1: Read examples/01-single-module.twasm");
41+
42+
const examplePath = resolve(projectRoot, "examples/01-single-module.twasm");
43+
const source = readFileSync(examplePath, "utf-8");
44+
assert(source.length > 0, "File is non-empty");
45+
assert(source.includes("region Vec2"), "File contains Vec2 region");
46+
assert(source.includes("region Players"), "File contains Players region");
47+
48+
// ============================================================================
49+
// Step 2: Parse with ReScript parser
50+
// ============================================================================
51+
52+
console.log("\nStep 2: Parse with ReScript parser");
53+
54+
const result = parseModule(source);
55+
assert(result.TAG === "Ok", "Parse succeeds without errors");
56+
57+
const module_ = result._0;
58+
const declarations = module_.declarations;
59+
assert(declarations.length >= 8, `Has >= 8 declarations (got ${declarations.length})`);
60+
61+
// ============================================================================
62+
// Step 3: Extract region schemas from AST
63+
// ============================================================================
64+
65+
console.log("\nStep 3: Extract region schemas from AST");
66+
67+
// Extract all region declarations
68+
const regions = [];
69+
const functions = [];
70+
const memories = [];
71+
72+
for (const decl of declarations) {
73+
const node = decl.node;
74+
if (typeof node === "object" && node.TAG === "RegionDecl") {
75+
regions.push(node._0);
76+
} else if (typeof node === "object" && node.TAG === "FunctionDecl") {
77+
functions.push(node._0);
78+
} else if (typeof node === "object" && node.TAG === "MemoryDecl") {
79+
memories.push(node._0);
80+
}
81+
}
82+
83+
assert(regions.length >= 3, `Has >= 3 region declarations (got ${regions.length})`);
84+
assert(functions.length >= 4, `Has >= 4 function declarations (got ${functions.length})`);
85+
assert(memories.length >= 1, `Has >= 1 memory declaration (got ${memories.length})`);
86+
87+
// ============================================================================
88+
// Step 4: Verify schema structure against Idris2 ABI expectations
89+
// ============================================================================
90+
91+
console.log("\nStep 4: Verify schema structure against Idris2 ABI expectations");
92+
93+
// Find specific regions by name
94+
const vec2Region = regions.find((r) => r.name === "Vec2");
95+
const playersRegion = regions.find((r) => r.name === "Players");
96+
const enemiesRegion = regions.find((r) => r.name === "Enemies");
97+
98+
// --- Vec2 region ---
99+
assert(vec2Region !== undefined, "Vec2 region found");
100+
assert(vec2Region.fields.length === 2, `Vec2 has 2 fields (got ${vec2Region.fields.length})`);
101+
assert(
102+
vec2Region.fields[0].node.name === "x",
103+
`Vec2 field 0 is 'x' (got '${vec2Region.fields[0].node.name}')`
104+
);
105+
assert(
106+
vec2Region.fields[1].node.name === "y",
107+
`Vec2 field 1 is 'y' (got '${vec2Region.fields[1].node.name}')`
108+
);
109+
110+
// Verify field types are f32 (Primitive with tag "F32")
111+
// The AST represents Primitive(F32) as { TAG: "Primitive", _0: "F32" }
112+
const vec2F0Type = vec2Region.fields[0].node.fieldType.node;
113+
const vec2F1Type = vec2Region.fields[1].node.fieldType.node;
114+
assert(
115+
vec2F0Type.TAG === "Primitive" && vec2F0Type._0 === "F32",
116+
`Vec2.x is f32 (got ${JSON.stringify(vec2F0Type)})`
117+
);
118+
assert(
119+
vec2F1Type.TAG === "Primitive" && vec2F1Type._0 === "F32",
120+
`Vec2.y is f32 (got ${JSON.stringify(vec2F1Type)})`
121+
);
122+
123+
// No instance count for Vec2 (singleton)
124+
assert(
125+
vec2Region.instanceCount === undefined,
126+
"Vec2 has no instance count (singleton region)"
127+
);
128+
129+
// --- Players region ---
130+
assert(playersRegion !== undefined, "Players region found");
131+
assert(
132+
playersRegion.instanceCount !== undefined,
133+
"Players has instance count (array region)"
134+
);
135+
assert(
136+
playersRegion.fields.length >= 4,
137+
`Players has >= 4 fields (got ${playersRegion.fields.length})`
138+
);
139+
140+
// Players field types match Idris2 ABI WasmType expectations:
141+
// hp: i32 (ABI: I32, size 4)
142+
// speed: f64 (ABI: F64, size 8)
143+
// pos: @Vec2 (ABI: RegionRef, embedded)
144+
// name: u8[24] (ABI: ArrayFieldType of U8)
145+
const hpField = playersRegion.fields.find((f) => f.node.name === "hp");
146+
assert(hpField !== undefined, "Players.hp field exists");
147+
assert(
148+
hpField.node.fieldType.node.TAG === "Primitive" &&
149+
hpField.node.fieldType.node._0 === "I32",
150+
`Players.hp is i32 (matches ABI WasmType.I32)`
151+
);
152+
153+
const speedField = playersRegion.fields.find((f) => f.node.name === "speed");
154+
assert(speedField !== undefined, "Players.speed field exists");
155+
assert(
156+
speedField.node.fieldType.node.TAG === "Primitive" &&
157+
speedField.node.fieldType.node._0 === "F64",
158+
`Players.speed is f64 (matches ABI WasmType.F64)`
159+
);
160+
161+
const posField = playersRegion.fields.find((f) => f.node.name === "pos");
162+
assert(posField !== undefined, "Players.pos field exists");
163+
assert(
164+
posField.node.fieldType.node.TAG === "RegionRef" &&
165+
posField.node.fieldType.node._0 === "Vec2",
166+
`Players.pos is @Vec2 (embedded region reference)`
167+
);
168+
169+
const nameField = playersRegion.fields.find((f) => f.node.name === "name");
170+
assert(nameField !== undefined, "Players.name field exists");
171+
assert(
172+
nameField.node.fieldType.node.TAG === "ArrayFieldType",
173+
`Players.name is an array type (u8[24])`
174+
);
175+
176+
// Players has alignment
177+
assert(
178+
playersRegion.alignment !== undefined,
179+
"Players has alignment specified"
180+
);
181+
182+
// --- Enemies region ---
183+
assert(enemiesRegion !== undefined, "Enemies region found");
184+
assert(
185+
enemiesRegion.instanceCount !== undefined,
186+
"Enemies has instance count"
187+
);
188+
189+
// Check nullable field: target: opt<@Players>
190+
const targetField = enemiesRegion.fields.find((f) => f.node.name === "target");
191+
assert(targetField !== undefined, "Enemies.target field exists");
192+
assert(
193+
targetField.node.fieldType.node.TAG === "OptionalType",
194+
`Enemies.target is opt<...> (Level 4: null safety)`
195+
);
196+
197+
// --- Memory declaration ---
198+
const gameMem = memories.find((m) => m.name === "game_memory");
199+
assert(gameMem !== undefined, "game_memory declaration found");
200+
assert(gameMem.initialPages > 0, `game_memory has initial pages (${gameMem.initialPages})`);
201+
assert(
202+
gameMem.maximumPages !== undefined,
203+
"game_memory has maximum pages"
204+
);
205+
assert(
206+
gameMem.placements.length >= 2,
207+
`game_memory has >= 2 placements (got ${gameMem.placements.length})`
208+
);
209+
210+
// --- Functions ---
211+
const getHpFn = functions.find((f) => f.name === "get_player_hp");
212+
assert(getHpFn !== undefined, "get_player_hp function found");
213+
assert(getHpFn.params.length === 2, `get_player_hp has 2 params`);
214+
assert(getHpFn.returnType !== undefined, "get_player_hp has return type");
215+
assert(
216+
getHpFn.effects !== undefined && getHpFn.effects.length >= 1,
217+
`get_player_hp has effects declared`
218+
);
219+
220+
// Verify region handle parameter type
221+
const playersParam = getHpFn.params[0];
222+
assert(
223+
playersParam.node.paramType.node.TAG === "RegionHandleParam",
224+
`First param is a region handle (matches ABI Region type)`
225+
);
226+
227+
// ============================================================================
228+
// ABI Correspondence Summary
229+
// ============================================================================
230+
231+
console.log("\nABI Correspondence Summary:");
232+
console.log(" Parser AST | Idris2 ABI Type");
233+
console.log(" ────────────────┼─────────────────────");
234+
console.log(" Primitive(I32) | WasmType.I32 (size 4)");
235+
console.log(" Primitive(F32) | WasmType.F32 (size 4)");
236+
console.log(" Primitive(F64) | WasmType.F64 (size 8)");
237+
console.log(" RegionRef(name) | Region schema (embedded)");
238+
console.log(" OptionalType | Nullable field (Level 4)");
239+
console.log(" ArrayFieldType | Fixed-size array");
240+
console.log(" instanceCount | Region.count");
241+
console.log(" alignment | Schema alignment");
242+
243+
// ============================================================================
244+
// Summary
245+
// ============================================================================
246+
247+
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
248+
249+
if (failed > 0) {
250+
process.exit(1);
251+
}

0 commit comments

Comments
 (0)