33//
44// affinescript-deno-test: runner.ts
55//
6- // Loads a compiled AffineScript WASM module and wraps its `main` export as
7- // a Deno.test() case. A test passes when `main` returns `true`, fails when
8- // it returns `false`.
6+ // Loads a compiled AffineScript WASM module and wraps every exported function
7+ // whose name starts with `test_` as a Deno.test() case. A test passes when
8+ // the function returns `true`, fails when it returns `false`.
99//
10- // MVP convention (v0.1 .0): one test per .affine file. The export name is
11- // always `main` because the current AffineScript codegen (lib/codegen.ml
12- // line 1725) hardcodes the exportable-name allowlist to
13- // ["main"; "init_state"; "step_state"; "get_state"; "mission_active"]
14- // with no `pub fn` / `@ export` keyword. Multi-test-per-file support is a
15- // planned follow-up once the compiler gains arbitrary-export syntax .
10+ // Convention (v0.2 .0): each ` .affine` file may define multiple tests via
11+ // the `pub fn test_<name>() -> Bool` syntax. Every `pub fn test_*` export
12+ // becomes a separate Deno.test() case. Non-`pub` helpers stay internal to
13+ // the module. This relies on the AffineScript compiler honouring `fd_vis`
14+ // in its WASM- export decision (commit ce324fa, both codegen.ml and
15+ // codegen_gc.ml) .
1616//
1717// Uses the existing @hyperpolymath /affine-js bridge for WASM loading and
1818// value marshalling, plus a minimal WASI stub for `fd_write` (AffineScript
19- // codegen always pulls this import even for programs that never print).
19+ // codegen imports this unconditionally even for programs that never print).
2020
2121import { AffineModule } from "@hyperpolymath/affine-js" ;
2222
23- /** Convention: the single test export per file is always named this . */
24- export const TEST_EXPORT = "main " ;
23+ /** Convention: every `pub fn` whose name begins with this prefix is a test . */
24+ export const TEST_PREFIX = "test_ " ;
2525
2626/** Result shape returned by AffineScript Bool exports (via affine-js). */
2727interface BoolValue {
@@ -30,13 +30,15 @@ interface BoolValue {
3030}
3131
3232/**
33- * Derive the Deno.test() case name from the WASM path. Strips the directory
34- * and the ` .wasm` extension; if the filename ends in `_test` or `.test`,
35- * strips that suffix too for readability .
33+ * Derive the Deno.test() case name from the file basename + export name.
34+ * For a wasm at `/path/to/math_test .wasm` with export `test_add`, yields
35+ * `math / add` .
3636 */
37- function caseName ( wasmPath : string ) : string {
37+ function caseName ( wasmPath : string , exportName : string ) : string {
3838 const base = wasmPath . split ( "/" ) . pop ( ) ?? wasmPath ;
39- return base . replace ( / \. w a s m $ / , "" ) . replace ( / ( _ t e s t | \. t e s t ) $ / , "" ) ;
39+ const fileStem = base . replace ( / \. w a s m $ / , "" ) . replace ( / ( _ t e s t | \. t e s t ) $ / , "" ) ;
40+ const caseStem = exportName . replace ( / ^ t e s t _ / , "" ) ;
41+ return `${ fileStem } / ${ caseStem } ` ;
4042}
4143
4244/**
@@ -61,10 +63,12 @@ function makeWasiStub(): WebAssembly.ModuleImports {
6163}
6264
6365/**
64- * Register a Deno.test() case for the `main ` export in the WASM module at
65- * `wasmPath`. Path should be absolute; relative paths resolve against CWD.
66+ * Register a Deno.test() case for every `test_* ` export in the WASM module
67+ * at `wasmPath`. Path should be absolute; relative paths resolve against CWD.
6668 *
67- * Side-effect: calls `Deno.test()` exactly once.
69+ * Returns the number of tests registered. Throws if no `test_*` exports
70+ * are found (indicating a misconfigured file — at least one `pub fn test_*`
71+ * is expected).
6872 */
6973export async function registerTestsFromWasm ( wasmPath : string ) : Promise < number > {
7074 const absolute = wasmPath . startsWith ( "/" )
@@ -84,27 +88,32 @@ export async function registerTestsFromWasm(wasmPath: string): Promise<number> {
8488 }
8589
8690 const mod = await AffineModule . fromBytes ( bytes ) ;
91+ const testExports = mod . functionExports . filter ( ( name : string ) =>
92+ name . startsWith ( TEST_PREFIX )
93+ ) ;
8794
88- if ( ! mod . functionExports . includes ( TEST_EXPORT ) ) {
95+ if ( testExports . length === 0 ) {
8996 throw new Error (
90- `affinescript-deno-test: no '${ TEST_EXPORT } ' export found in ${ wasmPath } . ` +
97+ `affinescript-deno-test: no '${ TEST_PREFIX } *' exports found in ${ wasmPath } . ` +
9198 `Available: [${ mod . functionExports . join ( ", " ) } ]. ` +
92- `Each .affine test file must define ' fn main () -> Bool'.` ,
99+ `Each test must be declared as 'pub fn test_<name> () -> Bool'.` ,
93100 ) ;
94101 }
95102
96- Deno . test ( caseName ( wasmPath ) , ( ) => {
97- const result = mod . call ( TEST_EXPORT , { returnType : "bool" } ) as BoolValue ;
98- if ( result . kind !== "bool" ) {
99- throw new Error (
100- `test '${ caseName ( wasmPath ) } ' returned non-bool value: ${ JSON . stringify ( result ) } ` ,
101- ) ;
102- }
103- if ( ! result . value ) {
104- throw new Error ( `test '${ caseName ( wasmPath ) } ' returned false` ) ;
105- }
106- } ) ;
107- return 1 ;
103+ for ( const exportName of testExports ) {
104+ Deno . test ( caseName ( wasmPath , exportName ) , ( ) => {
105+ const result = mod . call ( exportName , { returnType : "bool" } ) as BoolValue ;
106+ if ( result . kind !== "bool" ) {
107+ throw new Error (
108+ `test '${ exportName } ' returned non-bool value: ${ JSON . stringify ( result ) } ` ,
109+ ) ;
110+ }
111+ if ( ! result . value ) {
112+ throw new Error ( `test '${ exportName } ' returned false` ) ;
113+ }
114+ } ) ;
115+ }
116+ return testExports . length ;
108117}
109118
110119/**
@@ -126,28 +135,36 @@ async function registerTestsWithWasi(
126135 wasi_snapshot_preview1 : makeWasiStub ( ) ,
127136 } ) ;
128137
129- const mainExport = instance . exports [ TEST_EXPORT ] ;
130- if ( typeof mainExport !== "function" ) {
138+ const testExports = Object . keys ( instance . exports ) . filter (
139+ ( name ) =>
140+ typeof instance . exports [ name ] === "function" &&
141+ name . startsWith ( TEST_PREFIX ) ,
142+ ) ;
143+
144+ if ( testExports . length === 0 ) {
131145 const available = Object . keys ( instance . exports ) . join ( ", " ) ;
132146 throw new Error (
133- `affinescript-deno-test: no '${ TEST_EXPORT } ' function export found in ${ wasmPath } . ` +
147+ `affinescript-deno-test: no '${ TEST_PREFIX } * ' function exports found in ${ wasmPath } . ` +
134148 `Available: [${ available } ]. ` +
135- `Each .affine test file must define ' fn main () -> Bool'.` ,
149+ `Each test must be declared as 'pub fn test_<name> () -> Bool'.` ,
136150 ) ;
137151 }
138152
139- Deno . test ( caseName ( wasmPath ) , ( ) => {
140- const raw = ( mainExport as ( ) => number ) ( ) ;
141- // AffineScript compiles Bool to i32 (0 = false, 1 = true).
142- if ( raw !== 0 && raw !== 1 ) {
143- throw new Error (
144- `test '${ caseName ( wasmPath ) } ' returned non-bool raw value ${ raw } ; ` +
145- `'main' must have signature 'fn main() -> Bool'` ,
146- ) ;
147- }
148- if ( raw === 0 ) {
149- throw new Error ( `test '${ caseName ( wasmPath ) } ' returned false` ) ;
150- }
151- } ) ;
152- return 1 ;
153+ for ( const exportName of testExports ) {
154+ const fn = instance . exports [ exportName ] as ( ) => number ;
155+ Deno . test ( caseName ( wasmPath , exportName ) , ( ) => {
156+ const raw = fn ( ) ;
157+ // AffineScript compiles Bool to i32 (0 = false, 1 = true).
158+ if ( raw !== 0 && raw !== 1 ) {
159+ throw new Error (
160+ `test '${ exportName } ' returned non-bool raw value ${ raw } ; ` +
161+ `'${ exportName } ' must have signature 'fn ${ exportName } () -> Bool'` ,
162+ ) ;
163+ }
164+ if ( raw === 0 ) {
165+ throw new Error ( `test '${ exportName } ' returned false` ) ;
166+ }
167+ } ) ;
168+ }
169+ return testExports . length ;
153170}
0 commit comments