|
| 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