A Deno test-framework integration for AffineScript. Compiles .affine test
files to WebAssembly via the standard affinescript compiler, loads them
through the existing @hyperpolymath/affine-js bridge, and registers each
as a Deno.test() case so deno test reports pass/fail in the usual way.
Part of the developer-ecosystem
monorepo. Sibling tools in the AffineScript ecosystem live alongside this
component: affinescript/ (the compiler — actually mirrored from upstream),
affinescriptiser/, affinescript-vite/, rattlescript/.
v0.2.0. Multi-test-per-file harness. The "one test per file" MVP constraint was resolved same-day via an upstream compiler change (see Constraints and planned follow-ups).
# Compile + smoke-test the bundled example:
deno task smoke:test
# → hello ... ok (1 passed, 0 failed)Each test case is a top-level pub fn test_<name>() → Bool. Return true
to pass, false to fail. One .affine file may contain as many test cases
as you like. Non-pub helpers stay internal to the module. Filename ends
in _test.affine or .test.affine.
// example/hello_test.affine
// SPDX-License-Identifier: CC-BY-SA-4.0
pub fn test_addition() -> Bool {
let result = 2 + 3;
result == 5
}
pub fn test_identity() -> Bool {
let x = 42;
x == 42
}
fn helper_not_exported() -> Int {
99
}
pub fn test_uses_helper() -> Bool {
helper_not_exported() == 99
}The harness reports each test as <filestem> / <casename> — e.g. the file
above produces hello / addition, hello / identity, hello / uses_helper
under deno test.
The public API is runAll(root) in mod.ts:
// driver.ts
import { runAll } from "@hyperpolymath/affinescript-deno-test";
await runAll("./tests");Invoke with deno test:
deno test --allow-read --allow-run --allow-env driver.tsThe runner:
-
Walks
rootfor_test.affine/.test.affinefiles -
Compiles each via
affinescript compile … -o ….wasm -
Loads the WASM (with a minimal WASI
fd_writestub — AffineScript codegen imports this unconditionally) -
Registers a
Deno.test()case named after the file (stripped of the_testsuffix) that invokesmainand checks the Bool return
cli.ts is a thin smoke-check that prints discovery + compile results
without running assertions. Useful for CI pipelines that want to fail fast
on compile errors before invoking deno test.
deno run --allow-read --allow-run --allow-env cli.ts ./tests
# Discovered 3 test file(s):
# ✓ tests/foo_test.affine → tests/foo_test.wasm
# ✓ tests/bar_test.affine → tests/bar_test.wasm
# ...-
AFFINESCRIPT_BINenv var — absolute path to theaffinescriptcompiler. Default:/var/mnt/eclipse/repos/developer-ecosystem/nextgen-languages/affinescript/_build/install/default/bin/affinescript(the local dev build). Override when the compiler is installed elsewhere or on$PATH.
Two constraints were resolved same-day (one for v0.2.0, plus the three codegen bugs below). Two remain, both resolvable with small upstream changes.
The AffineScript codegen previously hardcoded an exportable-name allowlist
to ["main"; "init_state"; "step_state"; "get_state"; "mission_active"]
with no pub fn / @export keyword — so only those five names appeared
in the WASM export list, no matter the source.
The fix (AffineScript commit ce324fa) makes both codegen.ml and
codegen_gc.ml honour the AST’s fd_vis = Public field in addition to
the game-hook allowlist. The allowlist is retained verbatim for backward
compatibility (pre-pub programs and the IDApTIK CharacterSelect bridge
continue to work unchanged).
The harness now uses the expected multi-test convention: any top-level
pub fn test_<name>() → Bool becomes a separate Deno.test() case.
Every AffineScript WASM output imports wasi_snapshot_preview1.fd_write
even when the program uses no IO. The harness provides a minimal no-op
stub so tests instantiate cleanly.
Planned follow-up: upstream compiler change so pure programs (no effect
IO) skip the WASI import entirely. Until then, the stub is correct and
harmless.
The existing bridge’s AffineModule.fromBytes merges custom imports under
the env module key only. For WASM modules that import from a non-env
module (e.g. wasi_snapshot_preview1), the harness bypasses AffineModule
and uses raw WebAssembly.instantiate plus its own WASI stub. The Bool
return is unmarshalled by hand (AffineScript compiles Bool → i32, 0/1).
Planned follow-up: extend affine-js to accept a namespacedImports:
Record<string, Record<string, ImportValue>> option, then unify both paths
behind AffineModule.
Both bugs discovered while writing the double-track-browser lifecycle
test pilot were fixed upstream in developer-ecosystem/nextgen-languages/
affinescript/lib/codegen.ml the same day. Regression coverage lives in
example/codegen_regression_test.affine and the full
double-track-browser extension_lifecycle_test.affine (10/10 green).
-
Enum-in-match stack imbalance —
PatCon-with-args pushed the tag-test boolean onto the stack twice (LocalTee+ a trailingLocalGeton the match-result local), so any arm body that produced an i32 blew up WASM validation with "expected 1 elements on the stack for fallthru, found 2". Fixed by removing the save/restore pair; the field-binding code between them is stack-neutral by construction. -
Bare zero-arity variant as an expression —
Initialised(no parens) fell through to theExprVarlookup and failed withUnboundVariableunless written asState::Initialised. Fixed by falling back toctx.variant_tagswhenlookup_localmisses, mirroring the existingExprCall-with-variant-tag branch. -
Non-first struct-field read from parameter/call-result — the per-variable
field_layoutsmap was only populated for let-boundExprRecordliterals, so every other binding path defaulted offsets to 0 and.field_1_or_laterread the tag byte. Fixed by registering struct layouts globally fromTopType(TyStruct)and propagating them to (a) function parameters viap_ty, (b) call-result lets via a newfn_ret_structsmap, (c) let-bindings with an explicit type annotation, and (d) let-bindings whose RHS is another tracked variable.
The harness now supports idiomatic enum-plus-match application-state test suites without fallback tagged-struct workarounds.
ReScript is the estate’s default TypeScript replacement and has a mature
Deno integration (see fireflag). The AffineScript path exists because
AffineScript has stronger semantics for:
-
affine / quantitative types (resource lifecycle tracking)
-
algebraic effects (explicit side-effect boundaries)
-
row polymorphism (extensible records)
Test suites that want to demonstrate these properties — e.g. "this resource is consumed exactly once", "this protocol sequence is honoured" — are a natural fit for AffineScript. Plain behavioural tests can continue in ReScript; there’s no migration pressure either direction.
The trade-off today: AffineScript’s compiler matures while ReScript’s is
mature, so this harness carries the MVP constraints above. As the compiler
gains pub fn / effect-conditional WASI imports / better bridge ergonomics,
the harness sheds them.