A comprehensive edge-case test suite for the Perry TypeScript compiler, designed to surface bugs and compatibility gaps by comparing Perry's native output against Node.js running the same TypeScript source.
Status (v0.4.50): 7 of 26 tests pass with full parity against Node.js. Several others are within 1–6 output-line diffs of passing.
The goal of this suite is to find the kinds of bugs we've historically only discovered in production:
- NaN-boxing edge cases (boolean tags, null vs undefined, negative numbers)
- Closure capture of module-level / block-scoped variables
- Class inheritance with
super.method()in subclass methods - Type coercion at string/number/boolean boundaries
- Union types and discriminated dispatch
- Array/Object/Map/Set method return types
- Destructuring (nested, defaults, rest)
- Control flow (labeled breaks, switch fallthrough, try/catch/finally)
- Promise chains, async/await tuple returns
- Generic classes and methods
- Math / Number static methods and constants
- String methods (trimStart/End, replaceAll, matchAll)
- Optional chaining side effects
Each test is a self-contained .ts file that asserts correctness via console.log output. The suite runs every test with both Node.js (--experimental-strip-types) and Perry's native compiler, then diffs the output line-by-line.
All 26 edge-case tests live in test-files/:
test-files/
test_edge_arrays.ts
test_edge_class_advanced.ts
test_edge_classes.ts
test_edge_closures.ts
test_edge_complex_patterns.ts
test_edge_control_flow.ts
test_edge_destructuring.ts
test_edge_enums_const.ts
test_edge_error_handling.ts
test_edge_generics.ts
test_edge_higher_order.ts
test_edge_interfaces.ts
test_edge_iteration.ts
test_edge_json_regex.ts
test_edge_map_set.ts
test_edge_numeric.ts
test_edge_objects_records.ts
test_edge_operators.ts
test_edge_promises.ts
test_edge_regression.ts
test_edge_rest_spread_defaults.ts
test_edge_scope_hoisting.ts
test_edge_strings.ts
test_edge_truthiness.ts
test_edge_type_coercion.ts
test_edge_type_narrowing.ts
multi-edge/
helpers.ts
index.ts
Each file focuses on one category (closures, classes, numeric, etc.) and ranges from ~30 to ~180 assertions. Total: ~1,357 assertions.
Note: When first added, these files were unintentionally excluded by a
test-*pattern in.gitignorethat accidentally matched thetest-files/directory. That pattern has been fixed — the tests are now trackable and cross-platform.
Tests use console.log output rather than explicit assertions because that matches the existing parity-testing infrastructure (run_parity_tests.sh). The expected value is shown in a trailing comment:
const acc = makeAccumulator();
console.log(acc(5)); // 5
console.log(acc(3)); // 8
console.log(acc(10)); // 18For each test file, the runner:
- Runs
node --experimental-strip-types <file>→ captures stdout - Compiles with
perry <file> -o <bin>, then runs<bin>→ captures stdout - Normalizes whitespace / line endings
- Compares line-by-line; any diff is a FAIL
A test passes only if the normalized outputs match exactly.
- Rust toolchain (for building Perry)
- Node.js ≥ 22 (needs
--experimental-strip-types) - Platform: macOS / Linux / Windows (all supported by Perry)
# Build Perry compiler + runtime + stdlib
cargo build --release -p perry-runtime -p perry-stdlib
cargo build --releaseThe script below compiles each test with Perry, runs both Node and Perry outputs, normalizes, diffs, and prints per-test results plus a summary.
Save as run_edge_parity.sh in the project root:
#!/bin/bash
PERRY_BIN="./target/release/perry"
TEST_DIR="test-files"
OUT_DIR="${TMPDIR:-/tmp}/perry_edge_tests"
mkdir -p "$OUT_DIR"
PASS=0
FAIL=0
COMPILE_FAIL=0
CRASH=0
RESULTS=""
normalize() {
sed 's/\r$//' | sed 's/[[:space:]]*$//' | sed '/^$/d'
}
for f in "$TEST_DIR"/test_edge_*.ts; do
name=$(basename "$f" .ts)
node_out=$(node --experimental-strip-types "$f" 2>/dev/null | normalize) || true
compile_out=$($PERRY_BIN "$f" -o "$OUT_DIR/$name" 2>&1)
if [ $? -ne 0 ]; then
echo "COMPILE_FAIL: $name"
echo "$compile_out" | tail -3 | sed 's/^/ /'
COMPILE_FAIL=$((COMPILE_FAIL + 1))
RESULTS="${RESULTS}COMPILE_FAIL: $name\n"
continue
fi
perry_out=$("$OUT_DIR/$name" 2>&1 | normalize)
run_rc=$?
if [ $run_rc -gt 128 ]; then
sig=$((run_rc - 128))
echo "CRASH: $name (signal $sig)"
CRASH=$((CRASH + 1))
RESULTS="${RESULTS}CRASH: $name (signal $sig)\n"
continue
fi
if [ "$node_out" = "$perry_out" ]; then
echo "PASS: $name"
PASS=$((PASS + 1))
RESULTS="${RESULTS}PASS: $name\n"
else
node_lines=$(echo "$node_out" | wc -l | tr -d ' ')
diff_lines=$(diff <(echo "$node_out") <(echo "$perry_out") | grep -c '^[<>]' || true)
echo "FAIL: $name ($diff_lines diff lines / $node_lines total)"
diff <(echo "$node_out") <(echo "$perry_out") | head -12 | sed 's/^/ /'
echo
FAIL=$((FAIL + 1))
RESULTS="${RESULTS}FAIL: $name ($diff_lines/$node_lines)\n"
fi
done
echo
echo "========================================"
echo "EDGE-CASE TEST RESULTS"
echo "========================================"
echo " PASS: $PASS"
echo " FAIL: $FAIL"
echo " COMPILE_FAIL: $COMPILE_FAIL"
echo " CRASH: $CRASH"
echo " TOTAL: $((PASS + FAIL + COMPILE_FAIL + CRASH))"
echo "========================================"
echo
echo "--- Per-test ---"
echo -e "$RESULTS"Then:
chmod +x run_edge_parity.sh
./run_edge_parity.sh# Compile and run with Perry
./target/release/perry test-files/test_edge_closures.ts -o /tmp/test_edge_closures
/tmp/test_edge_closures
# Compare against Node.js
node --experimental-strip-types test-files/test_edge_closures.ts- Linux / macOS: the shell script above works as-is.
- Windows: the script works in Git Bash or WSL. For native PowerShell, replace the shell loop with
Get-ChildItem test-files\test_edge_*.ts | ForEach-Object { ... }. - The
.tssource files are platform-agnostic — no path separators, no OS-specific APIs, no filesystem calls.
| Status | Count |
|---|---|
| PASS | 7 |
| FAIL | 19 |
| COMPILE_FAIL | 0 |
| CRASH | 0 |
test_edge_error_handling— try/catch/finally, error types, nested exceptions, error propagationtest_edge_interfaces— polymorphic dispatch, generic interfaces, structural typing, interface arraystest_edge_operators— arithmetic, bitwise, comparison, logical, nullish coalescing, typeof, optional chainingtest_edge_promises— async/await, Promise.all, chaining, nested async functionstest_edge_regression— 18 historical regression cases from v0.4.x bugstest_edge_truthiness— NaN-box booleans,!!coercion,Boolean(), all falsy valuestest_edge_type_narrowing— typeof/instanceof guards,inoperator, discriminated unions
| Test | Diff lines | What's left |
|---|---|---|
test_edge_numeric |
2 | Math.round(-0.5) prints 0 instead of -0 (cosmetic) |
test_edge_higher_order |
2 | Memoization closure capturing a Map |
test_edge_strings |
4 | lastIndexOf unimplemented; "str " + array concatenation calls default Object.toString |
test_edge_objects_records |
6 | Object spread override { ...base, x: 10 } doesn't override |
test_edge_rest_spread_defaults |
6 | Same spread override bug; [..."hello"] spreads a string into garbage |
test_edge_control_flow |
6 | Labeled break outer / continue outer not supported; do...while not supported |
test_edge_closures, test_edge_class_advanced, test_edge_classes, test_edge_iteration, test_edge_map_set, test_edge_generics, test_edge_type_coercion, test_edge_json_regex, test_edge_enums_const, test_edge_arrays, test_edge_scope_hoisting
test_edge_complex_patterns — uses Record<string, Record<string, string>> (nested records), which Perry doesn't fully support
test_edge_destructuring — nested patterns, rest, defaults, computed keys all have gaps
test_edge_control_flow — labeled break/continue and do-while cascade into failures
Impact: Many tests, including all uses of .has(), .includes(), .startsWith(), .endsWith(), .test(), isNaN, isFinite, instanceof.
Root cause: Codegen for these methods used fcvt_from_sint(F64, i32) to turn a 0/1 return into f64. The result was a plain number 0.0 or 1.0, not a NaN-boxed boolean. console.log correctly prints numbers as 0/1, so tests expecting true/false failed.
Fix:
- Added
i32_to_nanbox_bool(builder, val_i32)helper incrates/perry-codegen/src/util.rsthat emitsselect(val_i32 != 0, TAG_TRUE, TAG_FALSE)and returns an f64. - Replaced every buggy call site in
crates/perry-codegen/src/expr.rs(Map.has/delete, Set.has/delete, Array.includes, String.startsWith/endsWith/includes, inline method-dispatch paths). - Runtime functions
js_is_nan,js_is_finite,js_instanceofupdated to return NaN-boxed booleans directly.
Impact: Any TypeScript program with subclass method overrides that call super.X(). Error: super.X() called outside of class context.
Root cause: The method inlining pass in crates/perry-transform/src/inline.rs inlines small methods into their call sites. When the subclass method contained Expr::SuperCall or Expr::SuperMethodCall, the inlined body ended up in the caller (e.g., main()) where there is no ThisContext, so compilation of the super call failed.
Fix: Added body_contains_super_call() in inline.rs and made is_inlinable() reject any method whose body contains a super call. These methods are now always compiled as real methods, preserving the class context.
Impact: Any code using these constants or the strict Number predicates returned undefined.
Fix:
- Added constant handling in
crates/perry-hir/src/lower.rsfor the entireNumbernamespace (MAX_SAFE_INTEGER,MIN_SAFE_INTEGER,EPSILON,MAX_VALUE,MIN_VALUE,POSITIVE_INFINITY,NEGATIVE_INFINITY,NaN). - Added HIR variants
NumberIsNaN,NumberIsFinite,NumberIsInteger,NumberIsSafeInteger. - Added runtime functions
js_number_is_nan/is_finite/is_integer/is_safe_integerincrates/perry-runtime/src/builtins.rsthat properly distinguish plain numbers from NaN-boxed tag values.
Fix: Desugared at HIR level in lower.rs:
Math.trunc(x)→x >= 0 ? floor(x) : ceil(x)Math.sign(x)→x > 0 ? 1 : x < 0 ? -1 : x
Root cause: The nearest (roundeven) instruction uses IEEE 754 round-half-to-even (banker's rounding). 0.5 rounds to 0 because 0 is even. JS uses round-half-away-from-zero for positives (0.5 → 1).
Fix: MathRound now emits floor(x + 0.5) in expr.rs.
Impact: Many boolean expressions involving null/undefined/NaN or string concatenation.
Root cause: The unary Not handler had a needs_truthy_check list that decided between calling js_is_truthy and doing a naive fcmp(val, 0.0). The list was missing Null, Undefined, Array, Object, New, BooleanCoerce, NumberIsX, string-producing Binary::Add, Logical, Conditional. For plain numeric values, the fallback (val == 0) was wrong because NaN is also falsy in JS.
Fix:
- Expanded
needs_truthy_checkto a recursive helperexpr_yields_nanboxed()that handles all NaN-boxed operand kinds including binary add of strings, logical/conditional returning strings, etc. - Numeric fallback now uses
(val == 0) || (val != val)to treat NaN as falsy.
Root cause: const s = ""; s || "default" — the string variable is stored as I64 (raw pointer) because is_string && is_pointer. The Logical Or fast path checked (ptr != 0) which is true for any allocated empty string, so JS's "empty string is falsy" semantic was lost.
Fix: LogicalOp::Or now detects string operands and NaN-boxes the I64 pointer (via inline_nanbox_string) before calling js_is_truthy.
Root cause: The Compare codegen had a "null compare" fast path that OR'd together all three nullish representations (TAG_NULL, TAG_UNDEFINED, raw null). That's JS loose equality semantics (==). TypeScript only has strict ===.
Fix: For strict equality, Perry now compares the value against the specific tag of the literal: null === x checks for TAG_NULL only; undefined === x checks for TAG_UNDEFINED only. So null === undefined correctly returns false.
Root cause: js_number_to_string, js_string_coerce, and js_array_join used Rust's format!("{}", f) which produces inf/-inf. JS spec requires Infinity/-Infinity.
Fix: All three runtime functions now branch on is_nan() / is_infinite() first and emit JS-correct strings.
Root cause: When user code declares class EventEmitter { ... } with its own on/emit methods, Perry's HIR lowering confuses calls to those methods with Perry's built-in EventEmitter (from the events module), which has a different signature. The result is a codegen error: mismatched argument count: got 1, expected 3.
Workaround: The test file renames the class to MyEmitter.
Proper fix (TODO): User classes should take precedence over built-in native classes when the user explicitly declares one. This is in the HIR lookup_class/native_extends resolution logic.
| Before | After | |
|---|---|---|
| Passing | 3 | 7 |
| Failing | 21 | 19 |
| Compile failures | 2 | 0 |
| Runtime crashes | 0 | 0 |
Net: +4 tests passing, no regressions, two previously-broken features (super.method() and Number.*) now work.
The suite is set up so anyone can run it on their machine and see exactly where Perry deviates from Node.js. Here are the remaining high-value fixes, ordered by estimated ROI:
Math.round(-0.5)prints0instead of-0. Cosmetic but counts as a diff. Either special-case-0injs_console_log_dynamic, or emit-0.0from the round codegen when input is in(-0.5, 0].- Memoize closure capturing a
Map. Test:test_edge_higher_order. The closure body'scache.has(n)always returns false, so every call is a cache miss. Likely root cause: the closure capturescacheas a value type rather than a pointer, so a fresh Map is created each invocation.
String.prototype.lastIndexOf— not implemented. Addjs_string_last_index_ofinperry-runtime/src/string.rsmirroringjs_string_index_ofbut scanning from the end."prefix" + arrayconcatenation — currently producesprefix [object Object]. JS callsArray.prototype.toString()which is equivalent to.join(","). In the string concatenation codegen (expr.rs, Binary::Add path with string on one side), detect when the other operand is an array and emit a call tojs_array_joinwith",".- Object spread override
{...base, x: 10}— the later key should win. Currently Perry keeps the original. Fix is inexpr.rsExpr::ObjectSpread: iterate spread keys first, then explicit keys, letting explicit keys overwrite. [..."hello"]— spreading a string into an array should produce["h","e","l","l","o"]. Currently produces garbage. Add a special case in array-spread codegen for string operands that iterates the string and pushes each char.
- Block scoping for
let/const. Affectstest_edge_scope_hoistingandtest_edge_enums_const. Innerletshadowing outerletleaks — the inner scope's value escapes. Requires tracking scope depth in HIR lowering and creating freshLocalIds for inner scopes. - Labeled
break/continue. Affectstest_edge_control_flow. Requires:- Adding
Stmt::Label(String, Box<Stmt>),Stmt::LabeledBreak(String),Stmt::LabeledContinue(String)incrates/perry-hir/src/ir.rs. - HIR lowering support in
lower.rsforast::LabeledStmt,ast::BreakStmt { label: Some(_) },ast::ContinueStmt { label: Some(_) }. - Codegen support in
stmt.rs:LoopContextneeds a stack of(label, break_block, continue_block)instead of just one entry.
- Adding
do...while. AddStmt::DoWhile { body, condition }. The only difference fromwhileis the body runs once unconditionally before checking the condition. Straightforward codegen.- Nested destructuring with defaults and rest. Affects
test_edge_destructuring. The pattern lowering inlower.rshandles top-level destructuring OK but nested{ outer: { inner } }and[a = 1, ...rest]need work. - Arrow fn expression body (
() => value) inside object literals with multiple closures. Affectstest_edge_closures. Works for single-closure objects, fails for multi-closure. Likely related to closure capture deduplication or object-literal property value compilation order.
- Nested
Record<string, Record<string, T>>. Affectstest_edge_complex_patterns. State machine, observer, pipeline patterns all use this. for...ofon strings. Currently produces corrupted output. Requires iterating character-by-character instead of treating the string as an array.- User class named the same as a built-in (e.g.,
EventEmitter). Resolve user declarations before built-ins inHIR lookup_class.
- Un-ignore
test-files/in.gitignore. ✅ Done in v0.4.50 — previously thetest-*pattern matched the directory, so the test sources were silently untracked. - Add this script to CI. The
run_edge_parity.shscript above could run on every PR. Threshold: pass count must not decrease. - Add a
cargo xtask edge-paritytask so it's easy to run from any directory and on any OS without shell differences. - Split
test_edge_arrays.ts/test_edge_classes.tsinto smaller files — a single bug can cascade and skew the diff count. Smaller files give more precise signal.
- Run the suite:
./run_edge_parity.sh - Pick a failing test with ≤ 6 diff lines from the "Close to passing" table.
- Read just the failing assertions:
diff <(node --experimental-strip-types test-files/<test>.ts) <(/tmp/perry_edge_tests/<test>). - Reduce to the minimal failing snippet in
/tmp/test_repro.ts. - Find the relevant codegen path:
- Method dispatch:
crates/perry-codegen/src/expr.rs— search for method name - Runtime function:
crates/perry-runtime/src/*.rs - HIR lowering:
crates/perry-hir/src/lower.rs - Method inlining:
crates/perry-transform/src/inline.rs
- Method dispatch:
- Fix, rebuild:
cargo build --release -p perry-runtime -p perry-stdlib cargo build --release
- Re-run the suite to verify no regressions.
- Follow
CLAUDE.mdworkflow: bump patch version, add Recent Changes entry, commit.
New test files (26 in test-files/ plus test-files/multi-edge/).
Codegen:
crates/perry-codegen/src/util.rs—i32_to_nanbox_boolhelpercrates/perry-codegen/src/expr.rs— boolean returns, Unary Not, Logical Or with strings, strict null equality, Math.round,NumberIs*crates/perry-codegen/src/closures.rs— walker coverage for new HIR variantscrates/perry-codegen/src/runtime_decls.rs— declaredjs_number_is_*crates/perry-codegen-js/src/emit.rs— JS backend emit forNumberIs*
HIR:
crates/perry-hir/src/ir.rs—NumberIsNaN/Finite/Integer/SafeIntegervariantscrates/perry-hir/src/lower.rs—Number.*constants and methods,Math.trunc/sign
Transform:
crates/perry-transform/src/inline.rs—body_contains_super_call()check
Runtime:
crates/perry-runtime/src/builtins.rs—js_is_nan/js_is_finite/js_string_coerceInfinity formatting; newjs_number_is_*functionscrates/perry-runtime/src/object.rs—js_instanceofreturns NaN-boxed boolcrates/perry-runtime/src/string.rs—js_number_to_stringInfinity/NaN/−0 formattingcrates/perry-runtime/src/array.rs—js_array_joinInfinity/NaN formatting
Misc:
CLAUDE.md— version bump, Recent Changes entryCargo.toml—0.4.49→0.4.50.gitignore— fixedtest-*pattern that was excludingtest-files/test-files/test_edge_classes.ts— renamedEventEmitter→MyEmitter(works around a known class-name collision bug)