Skip to content

Latest commit

 

History

History
442 lines (322 loc) · 21.5 KB

File metadata and controls

442 lines (322 loc) · 21.5 KB

TypeScript Compatibility Test Suite

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.


What Is This?

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.


Where the Tests Live

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 .gitignore that accidentally matched the test-files/ directory. That pattern has been fixed — the tests are now trackable and cross-platform.


How the Tests Work

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));  // 18

For each test file, the runner:

  1. Runs node --experimental-strip-types <file> → captures stdout
  2. Compiles with perry <file> -o <bin>, then runs <bin> → captures stdout
  3. Normalizes whitespace / line endings
  4. Compares line-by-line; any diff is a FAIL

A test passes only if the normalized outputs match exactly.


Running the Tests

Prerequisites

  • Rust toolchain (for building Perry)
  • Node.js ≥ 22 (needs --experimental-strip-types)
  • Platform: macOS / Linux / Windows (all supported by Perry)

One-time setup

# Build Perry compiler + runtime + stdlib
cargo build --release -p perry-runtime -p perry-stdlib
cargo build --release

Run the full suite

The 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

Run a single test

# 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

Cross-platform notes

  • 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 .ts source files are platform-agnostic — no path separators, no OS-specific APIs, no filesystem calls.

Current Results (v0.4.50)

Status Count
PASS 7
FAIL 19
COMPILE_FAIL 0
CRASH 0

Passing (100% parity with Node)

  1. test_edge_error_handling — try/catch/finally, error types, nested exceptions, error propagation
  2. test_edge_interfaces — polymorphic dispatch, generic interfaces, structural typing, interface arrays
  3. test_edge_operators — arithmetic, bitwise, comparison, logical, nullish coalescing, typeof, optional chaining
  4. test_edge_promises — async/await, Promise.all, chaining, nested async functions
  5. test_edge_regression — 18 historical regression cases from v0.4.x bugs
  6. test_edge_truthiness — NaN-box booleans, !! coercion, Boolean(), all falsy values
  7. test_edge_type_narrowing — typeof/instanceof guards, in operator, discriminated unions

Close to passing (≤ 6 diff lines)

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

Moderate failures (7–20 diff lines)

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

Major failures (> 20 diff lines)

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


Bugs Found and Fixed This Session (v0.4.50)

1. Boolean return values returned as f64 1.0/0.0 instead of NaN-boxed tags

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 in crates/perry-codegen/src/util.rs that emits select(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_instanceof updated to return NaN-boxed booleans directly.

2. super.method() in subclass methods caused compile error

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.

3. Number.MAX_SAFE_INTEGER and Number.isNaN/isFinite/isInteger/isSafeInteger unsupported

Impact: Any code using these constants or the strict Number predicates returned undefined.

Fix:

  • Added constant handling in crates/perry-hir/src/lower.rs for the entire Number namespace (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_integer in crates/perry-runtime/src/builtins.rs that properly distinguish plain numbers from NaN-boxed tag values.

4. Math.trunc and Math.sign missing

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

5. Math.round(0.5) returned 0

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.51).

Fix: MathRound now emits floor(x + 0.5) in expr.rs.

6. !null, !undefined, !NaN, !!("" + "") all wrong

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_check to a recursive helper expr_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.

7. "" || "default" returned ""

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.

8. null === undefined returned true

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.

9. Infinity printed as inf, NaN as NaN sometimes, -0 as 0

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.

10. Class named EventEmitter collided with Perry's native EventEmitter

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 Summary

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.


What to Do Next

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:

Quick wins (1–2 diff lines, each unlocks a full test)

  1. Math.round(-0.5) prints 0 instead of -0. Cosmetic but counts as a diff. Either special-case -0 in js_console_log_dynamic, or emit -0.0 from the round codegen when input is in (-0.5, 0].
  2. Memoize closure capturing a Map. Test: test_edge_higher_order. The closure body's cache.has(n) always returns false, so every call is a cache miss. Likely root cause: the closure captures cache as a value type rather than a pointer, so a fresh Map is created each invocation.

Medium wins (4–6 diff lines)

  1. String.prototype.lastIndexOf — not implemented. Add js_string_last_index_of in perry-runtime/src/string.rs mirroring js_string_index_of but scanning from the end.
  2. "prefix" + array concatenation — currently produces prefix [object Object]. JS calls Array.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 to js_array_join with ",".
  3. Object spread override {...base, x: 10} — the later key should win. Currently Perry keeps the original. Fix is in expr.rs Expr::ObjectSpread: iterate spread keys first, then explicit keys, letting explicit keys overwrite.
  4. [..."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.

Medium-large fixes (7–20 diff lines, affect multiple tests)

  1. Block scoping for let / const. Affects test_edge_scope_hoisting and test_edge_enums_const. Inner let shadowing outer let leaks — the inner scope's value escapes. Requires tracking scope depth in HIR lowering and creating fresh LocalIds for inner scopes.
  2. Labeled break / continue. Affects test_edge_control_flow. Requires:
    • Adding Stmt::Label(String, Box<Stmt>), Stmt::LabeledBreak(String), Stmt::LabeledContinue(String) in crates/perry-hir/src/ir.rs.
    • HIR lowering support in lower.rs for ast::LabeledStmt, ast::BreakStmt { label: Some(_) }, ast::ContinueStmt { label: Some(_) }.
    • Codegen support in stmt.rs: LoopContext needs a stack of (label, break_block, continue_block) instead of just one entry.
  3. do...while. Add Stmt::DoWhile { body, condition }. The only difference from while is the body runs once unconditionally before checking the condition. Straightforward codegen.
  4. Nested destructuring with defaults and rest. Affects test_edge_destructuring. The pattern lowering in lower.rs handles top-level destructuring OK but nested { outer: { inner } } and [a = 1, ...rest] need work.
  5. Arrow fn expression body (() => value) inside object literals with multiple closures. Affects test_edge_closures. Works for single-closure objects, fails for multi-closure. Likely related to closure capture deduplication or object-literal property value compilation order.

Large fixes (> 20 diff lines)

  1. Nested Record<string, Record<string, T>>. Affects test_edge_complex_patterns. State machine, observer, pipeline patterns all use this.
  2. for...of on strings. Currently produces corrupted output. Requires iterating character-by-character instead of treating the string as an array.
  3. User class named the same as a built-in (e.g., EventEmitter). Resolve user declarations before built-ins in HIR lookup_class.

Testing hygiene

  1. Un-ignore test-files/ in .gitignore. ✅ Done in v0.4.50 — previously the test-* pattern matched the directory, so the test sources were silently untracked.
  2. Add this script to CI. The run_edge_parity.sh script above could run on every PR. Threshold: pass count must not decrease.
  3. Add a cargo xtask edge-parity task so it's easy to run from any directory and on any OS without shell differences.
  4. Split test_edge_arrays.ts / test_edge_classes.ts into smaller files — a single bug can cascade and skew the diff count. Smaller files give more precise signal.

Where to Start If You're Joining the Effort

  1. Run the suite: ./run_edge_parity.sh
  2. Pick a failing test with ≤ 6 diff lines from the "Close to passing" table.
  3. Read just the failing assertions: diff <(node --experimental-strip-types test-files/<test>.ts) <(/tmp/perry_edge_tests/<test>).
  4. Reduce to the minimal failing snippet in /tmp/test_repro.ts.
  5. 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
  6. Fix, rebuild:
    cargo build --release -p perry-runtime -p perry-stdlib
    cargo build --release
  7. Re-run the suite to verify no regressions.
  8. Follow CLAUDE.md workflow: bump patch version, add Recent Changes entry, commit.

Files Modified in v0.4.50 (this session)

New test files (26 in test-files/ plus test-files/multi-edge/).

Codegen:

  • crates/perry-codegen/src/util.rsi32_to_nanbox_bool helper
  • crates/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 variants
  • crates/perry-codegen/src/runtime_decls.rs — declared js_number_is_*
  • crates/perry-codegen-js/src/emit.rs — JS backend emit for NumberIs*

HIR:

  • crates/perry-hir/src/ir.rsNumberIsNaN/Finite/Integer/SafeInteger variants
  • crates/perry-hir/src/lower.rsNumber.* constants and methods, Math.trunc/sign

Transform:

  • crates/perry-transform/src/inline.rsbody_contains_super_call() check

Runtime:

  • crates/perry-runtime/src/builtins.rsjs_is_nan/js_is_finite/js_string_coerce Infinity formatting; new js_number_is_* functions
  • crates/perry-runtime/src/object.rsjs_instanceof returns NaN-boxed bool
  • crates/perry-runtime/src/string.rsjs_number_to_string Infinity/NaN/−0 formatting
  • crates/perry-runtime/src/array.rsjs_array_join Infinity/NaN formatting

Misc:

  • CLAUDE.md — version bump, Recent Changes entry
  • Cargo.toml0.4.490.4.50
  • .gitignore — fixed test-* pattern that was excluding test-files/
  • test-files/test_edge_classes.ts — renamed EventEmitterMyEmitter (works around a known class-name collision bug)