Skip to content

Commit b0b0a22

Browse files
test(stdlib): STDLIB-04d — hermetic e2e coverage for IO externs (Closes #331) (#341)
## Summary `print`/`println`/`read_line`/`read_file`/`write_file` were already wired in interp + Deno codegen, but had **no dedicated hermetic tests** asserting the round-trip semantics. Test-debt, not impl-debt: a silent regression to any of these would slip through the existing gate (the TEA-bridge tests exercise the redirect path but don't assert extern behaviour as a first-class surface). ## Tests 5 new tests in `E2E STDLIB-04d IO #331`: | Test | Asserts | |---|---| | `write_file` → `read_file` round-trip | byte-for-byte content preserved on a real tmpfile (cleanup via `Fun.protect`) | | `read_file` on missing path | returns `Err(_)`, doesn't raise | | `print` exec | no error | | `println` exec | no error | | Deno codegen | prelude defines both `print` and `println` | `read_line` is interactive and intentionally out of scope — the TEA-bridge tests already exercise that surface with full `Unix.dup2` stdin redirection. ## Test plan - [x] 5 new hermetic tests added - [x] tmpfile cleanup via `Fun.protect` — no leftover fixtures on test exit - [ ] CI: `dune runtest` green (e2e gate +5) - [ ] Hypatia DOC-FORMAT: no `.md` introduced No implementation change. Updates `docs/TECH-DEBT.adoc` row 04d → DONE per the audit-split contract. Closes #331. Refs #175. --- _Generated by [Claude Code](https://claude.ai/code/session_01NUHL3MH3yKKQAEhSZn4Thu)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1938289 commit b0b0a22

2 files changed

Lines changed: 142 additions & 2 deletions

File tree

docs/TECH-DEBT.adoc

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,13 @@ fresh tyvar). 3 hermetic tests in "E2E STDLIB-04b error #329". |S3
210210
operator independently lowered. Decide: remove (operator-only) or wire
211211
to mirror `++` |S3 |open — issue #330
212212
|STDLIB-04d |IO externs (`print`/`println`/`read_line`/`read_file`/
213-
`write_file`) — wired on all backends but no dedicated hermetic e2e
214-
tests; test-debt, not impl-debt |S3 |open — issue #331
213+
`write_file`) — *DONE* (Refs #331): 5 hermetic tests in "E2E
214+
STDLIB-04d IO #331" — `write_file` → `read_file` round-trip on a real
215+
tmpfile, `read_file` on a missing path returns `Err` (not raise),
216+
`print`/`println` exec without error, Deno codegen wires both into the
217+
prelude. `read_line` is interactive and intentionally out of scope (the
218+
TEA-bridge tests already exercise the redirected-stdin path). No impl
219+
change; was test-debt, not impl-debt. |S3 |DONE 2026-05-24 (Refs #331)
215220
|STDLIB-04e |Pure externs (`int_to_string`/`string_to_int`/
216221
`string_length`) — *DONE* (Refs #332): `int_to_string` +
217222
`string_length` were already real-wired; `string_to_int` was unwired

test/test_e2e.ml

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3279,6 +3279,137 @@ let stdlib_04a_mut_tests = [
32793279
Alcotest.test_case "#328 Deno codegen emits __cell shape" `Quick test_stdlib_04a_mut_deno_codegen;
32803280
]
32813281

3282+
(* ---- STDLIB-04d: IO externs hermetic test coverage (Refs #331) ----
3283+
3284+
`print`/`println`/`read_line`/`read_file`/`write_file` were already
3285+
wired in interp + Deno codegen, but had no dedicated hermetic tests
3286+
asserting the round-trip semantics (test-debt, not impl-debt). This
3287+
row adds them. `read_line` is interactive and skipped here — that
3288+
surface is exercised by the TEA-bridge tests with redirected stdin. *)
3289+
3290+
(* write_file -> read_file round-trip on a real tmpfile *)
3291+
let test_stdlib_04d_write_then_read_file () =
3292+
let tmp = Filename.temp_file "as_04d_io" ".txt" in
3293+
Fun.protect ~finally:(fun () -> if Sys.file_exists tmp then Sys.remove tmp)
3294+
(fun () ->
3295+
let src = Printf.sprintf {|
3296+
fn writer() -> Result<Unit, String> { write_file("%s", "hello-04d\n") }
3297+
fn reader() -> Result<String, String> { read_file("%s") }
3298+
|} (String.escaped tmp) (String.escaped tmp) in
3299+
let prog = Parse_driver.parse_string ~file:"<test>" src in
3300+
match Interp.eval_program prog with
3301+
| Error e -> Alcotest.failf "interp load failed: %s"
3302+
(Value.show_eval_error e)
3303+
| Ok env ->
3304+
let call name =
3305+
match Value.lookup_env name env with
3306+
| Error e -> Error e
3307+
| Ok fn -> Interp.apply_function fn []
3308+
in
3309+
(match call "writer" with
3310+
| Ok (Value.VVariant ("Ok", _)) -> ()
3311+
| Ok v -> Alcotest.failf "writer expected Ok(Unit), got %s"
3312+
(Value.show_value v)
3313+
| Error e -> Alcotest.failf "writer failed: %s"
3314+
(Value.show_eval_error e));
3315+
(match call "reader" with
3316+
| Ok (Value.VVariant ("Ok", Some (Value.VString s))) ->
3317+
Alcotest.(check string) "reader returns written content"
3318+
"hello-04d\n" s
3319+
| Ok v -> Alcotest.failf "reader expected Ok(String), got %s"
3320+
(Value.show_value v)
3321+
| Error e -> Alcotest.failf "reader failed: %s"
3322+
(Value.show_eval_error e)))
3323+
3324+
(* read_file on a path that does not exist returns Err, not raises. *)
3325+
let test_stdlib_04d_read_file_missing () =
3326+
let tmp = Filename.temp_file "as_04d_missing" ".txt" in
3327+
Sys.remove tmp; (* removed -- guaranteed missing *)
3328+
let src = Printf.sprintf
3329+
"fn f() -> Result<String, String> { read_file(\"%s\") }"
3330+
(String.escaped tmp) in
3331+
let prog = Parse_driver.parse_string ~file:"<test>" src in
3332+
match Interp.eval_program prog with
3333+
| Error e -> Alcotest.failf "interp failed: %s" (Value.show_eval_error e)
3334+
| Ok env ->
3335+
(match Value.lookup_env "f" env with
3336+
| Ok fn ->
3337+
(match Interp.apply_function fn [] with
3338+
| Ok (Value.VVariant ("Err", _)) -> ()
3339+
| Ok v -> Alcotest.failf "expected Err(_), got %s" (Value.show_value v)
3340+
| Error e -> Alcotest.failf "apply failed: %s"
3341+
(Value.show_eval_error e))
3342+
| Error e -> Alcotest.failf "lookup failed: %s"
3343+
(Value.show_eval_error e))
3344+
3345+
(* `print` and `println` exec without error. Stdout-content capture is
3346+
intentionally out of scope here (the TEA-bridge tests already
3347+
exercise the redirect path with full Unix.dup2 plumbing); we just
3348+
prove the lowering doesn't blow up at runtime. *)
3349+
let test_stdlib_04d_print_no_error () =
3350+
let src = "fn f() -> Unit { print(\"\") }" in
3351+
let prog = Parse_driver.parse_string ~file:"<test>" src in
3352+
match Interp.eval_program prog with
3353+
| Error e -> Alcotest.failf "interp failed: %s" (Value.show_eval_error e)
3354+
| Ok env ->
3355+
(match Value.lookup_env "f" env with
3356+
| Ok fn ->
3357+
(match Interp.apply_function fn [] with
3358+
| Ok _ -> ()
3359+
| Error e -> Alcotest.failf "print failed: %s"
3360+
(Value.show_eval_error e))
3361+
| Error e -> Alcotest.failf "lookup failed: %s"
3362+
(Value.show_eval_error e))
3363+
3364+
let test_stdlib_04d_println_no_error () =
3365+
let src = "fn f() -> Unit { println(\"\") }" in
3366+
let prog = Parse_driver.parse_string ~file:"<test>" src in
3367+
match Interp.eval_program prog with
3368+
| Error e -> Alcotest.failf "interp failed: %s" (Value.show_eval_error e)
3369+
| Ok env ->
3370+
(match Value.lookup_env "f" env with
3371+
| Ok fn ->
3372+
(match Interp.apply_function fn [] with
3373+
| Ok _ -> ()
3374+
| Error e -> Alcotest.failf "println failed: %s"
3375+
(Value.show_eval_error e))
3376+
| Error e -> Alcotest.failf "lookup failed: %s"
3377+
(Value.show_eval_error e))
3378+
3379+
(* Deno codegen lowers IO externs to the right host shape. *)
3380+
let test_stdlib_04d_io_deno_codegen () =
3381+
let src = {|
3382+
fn run() -> Unit {
3383+
print("a");
3384+
println("b")
3385+
}
3386+
|} in
3387+
let prog = Parse_driver.parse_string ~file:"<test>" src in
3388+
let loader = Module_loader.create (Module_loader.default_config ()) in
3389+
match Resolve.resolve_program_with_loader prog loader with
3390+
| Error (e, _) ->
3391+
Alcotest.failf "resolve failed: %s" (Resolve.show_resolve_error e)
3392+
| Ok (rctx, _) ->
3393+
(match Codegen_deno.codegen_deno prog rctx.symbols with
3394+
| Error e -> Alcotest.failf "deno-codegen failed: %s" e
3395+
| Ok js ->
3396+
let contains needle =
3397+
let nl = String.length needle and sl = String.length js in
3398+
let rec go i = i + nl <= sl &&
3399+
(String.sub js i nl = needle || go (i + 1))
3400+
in nl = 0 || go 0
3401+
in
3402+
Alcotest.(check bool) "prelude defines print/println"
3403+
true (contains "const print" && contains "const println"))
3404+
3405+
let stdlib_04d_io_tests = [
3406+
Alcotest.test_case "#331 write_file -> read_file round-trip" `Quick test_stdlib_04d_write_then_read_file;
3407+
Alcotest.test_case "#331 read_file on missing path returns Err" `Quick test_stdlib_04d_read_file_missing;
3408+
Alcotest.test_case "#331 print exec without error" `Quick test_stdlib_04d_print_no_error;
3409+
Alcotest.test_case "#331 println exec without error" `Quick test_stdlib_04d_println_no_error;
3410+
Alcotest.test_case "#331 Deno codegen wires print/println" `Quick test_stdlib_04d_io_deno_codegen;
3411+
]
3412+
32823413
(* ---- STDLIB-04e: Pure externs (Refs #332) ----
32833414
32843415
Three externs declared in stdlib/effects.affine as pure:
@@ -3354,6 +3485,8 @@ let stdlib_04e_pure_tests = [
33543485
Alcotest.test_case "#332 string_to_int(\"123\") == Some(123)" `Quick test_stdlib_04e_string_to_int_some;
33553486
Alcotest.test_case "#332 string_to_int(\"abc\") == None" `Quick test_stdlib_04e_string_to_int_none;
33563487
Alcotest.test_case "#332 string_length(\"hello\") == 5" `Quick test_stdlib_04e_string_length;
3488+
]
3489+
33573490
(* ---- STDLIB-04b: Throws extern `error<T>` (Refs #329) ----
33583491
33593492
`error<T>(msg: String) -> T / Throws` was declared in
@@ -3438,6 +3571,7 @@ let stdlib_04b_error_tests = [
34383571
Alcotest.test_case "#329 Deno codegen lowers to throw" `Quick test_stdlib_04b_error_deno_codegen;
34393572
]
34403573

3574+
34413575
(* ---- Issue #35 Phase 2 — Vscode bindings ----
34423576
34433577
Verifies stdlib/Vscode.affine and stdlib/VscodeLanguageClient.affine
@@ -4129,6 +4263,7 @@ let tests =
41294263
("E2E Xmod Other Codegens", cross_module_other_codegens_tests);
41304264
("E2E Externs", extern_tests);
41314265
("E2E STDLIB-04a Mut #328", stdlib_04a_mut_tests);
4266+
("E2E STDLIB-04d IO #331", stdlib_04d_io_tests);
41324267
("E2E STDLIB-04e Pure #332", stdlib_04e_pure_tests);
41334268
("E2E STDLIB-04b error #329", stdlib_04b_error_tests);
41344269
("E2E Vscode Bindings", vscode_bindings_tests);

0 commit comments

Comments
 (0)