Skip to content

test(native): add unit tests for loadNative() load-order and env-var override#1414

Merged
carlos-alm merged 15 commits into
mainfrom
test/native-loader-1392-v2
Jun 9, 2026
Merged

test(native): add unit tests for loadNative() load-order and env-var override#1414
carlos-alm merged 15 commits into
mainfrom
test/native-loader-1392-v2

Conversation

@carlos-alm

Copy link
Copy Markdown
Contributor

Summary

  • Adds tests/unit/native-loader.test.ts with 5 tests that exercise each load path in loadNative() in isolation
  • Each test uses vi.resetModules() + vi.doMock() + dynamic import() to get a fresh module with clean singleton state (_cached / _loadError reset per test)

Tests added

Scenario What is verified
NAPI_RS_NATIVE_LIBRARY_PATH set and valid Returns the module; second call hits the singleton cache (require called once)
NAPI_RS_NATIVE_LIBRARY_PATH set but bad path Returns null; no fall-through to local binary or npm package; WARN emitted
No env var, local binary present Loads local binary; npm package require never attempted
No env var, no local binary, npm package present Loads npm package (@optave/codegraph-darwin-arm64)
No env var, unsupported platform Returns null without any require calls; getNative() throws EngineError

Closes #1392

carlos-alm added 11 commits June 7, 2026 18:36
When a locally compiled `crates/codegraph-core/*.node` binary exists it
is now loaded before the published npm platform package.  This ensures
that Rust changes (like the prototype-method extraction from PR #1339)
are picked up immediately in development without waiting for a new
npm release.

Load order is now:
  1. NAPI_RS_NATIVE_LIBRARY_PATH env var  — explicit override
  2. crates/codegraph-core/<platform>.node — freshly compiled dev binary
  3. @optave/codegraph-<platform> npm pkg  — published production binary

Closes #1361
…+ static block + field def

- Add `(class name: ...)` query patterns for JS/TS class expressions so that
  `return class Foo extends Bar { ... }` records the extends relationship in
  ctx.classes — previously only class_declaration was captured, leaving class
  expressions invisible to resolveThisDispatch.

- Add `class_static_block` → `ClassName.<static>` synthetic method definition
  in both query path (extractClassMembersWalk) and walk path (walkJavaScriptNode).
  Calls inside `static { super.f(); }` blocks are now attributed to a method-kind
  node so the CHA parents map can resolve `super.f()` to the parent class.

- Add `field_definition`/`public_field_definition` → `ClassName.fieldName` method
  definition when the field value is an arrow function or function expression.
  `static f = () => { ... }` becomes a resolvable `A.f` node so
  `resolveThisDispatch` can emit the `B.<static> → A.f` edge.

- Mirror all three changes in the native Rust extractor for parity.

- Add 6 parser unit tests and import Jelly micro-test fixtures for super,
  super2, super3, super4, super5 as ground-truth benchmarks.

Benchmark result: super fixture 31% → 38% recall (B.<static> → A.f now resolved).

docs check acknowledged

Closes #1377
…llbacks into buildCallEdges

After resolveCallTargets returns empty:
1. Retry with class-qualified name (ClassName.method) for this-receiver calls
2. Resolve via Object.defineProperty accessor receiver typeMap or same-file lookup

These two blocks existed in buildFileCallEdges (build-edges.ts) but were missing
from the incremental path, causing divergence between full and watch-mode builds.

Also adds the missing callerName argument to resolveCallTargets.

Closes #1384
PLATFORM_PACKAGES[platformKey] was inlined in loadNative() while
getNativePackageVersion() used resolvePlatformPackage(). Any future
change to the lookup (fallback key, default value) would only apply
to one site. Call resolvePlatformPackage() consistently in both places.
…ame in buildCallEdges

Incremental rebuilds using buildCallEdges (incremental.ts) were missing two
things needed for Phase 8.3f scoped-key resolution (#1358 / #1369):

1. The typeMap was not seeded with callee::restName entries from
   objectRestParamBindings × paramBindings. Without this seeding the scoped
   key `callee::restName` (e.g. `f2::rest`) is absent from the map, so
   resolveByMethodOrGlobal's third fallback (`typeMap.get(callerName::receiver)`)
   has nothing to find and the rest-param call goes unresolved.

2. caller.callerName was not passed to resolveCallTargets, so even if the
   scoped key was present in the typeMap (e.g. from WASM extraction), the
   `${callerName}::${effectiveReceiver}` lookup in resolveByMethodOrGlobal
   never fired.

Fix: mirror what buildObjectRestParamPostPass (native full-build post-pass)
and buildCallEdgesJS (WASM full-build path) already do:
- Compute restNameCallees to know how many callees share a rest name.
- Seed typeMap[callee::restName] for each objectRestParamBinding × paramBinding pair.
- Also seed the unscoped key when only one callee uses that rest name, so
  resolution still works when callerName is null (findCaller couldn't match).
- Pass caller.callerName to resolveCallTargets (already present from #1389).

Also syncs call-resolver.ts and build-edges.ts with the scoped-key changes
from PR #1368 (merged to main separately), which this branch was missing.

docs check acknowledged

Closes #1369
…or static blocks (#1389)

The merge conflict resolution in 8d5ba14 dropped two changes from ca3123f:
1. The 'class' case in walkJavaScriptNode's switch — class expressions like
   'return class Child extends Parent { ... }' were never dispatched to
   handleClassDecl, so ctx.classes remained empty and no extends relationship
   was recorded.
2. handleStaticBlock used kind: 'function' instead of kind: 'method', so the
   CHA parents map could not walk up to the parent class for super.method()
   resolution. Also update the existing test that asserted kind: 'function'.
@greptile-apps

greptile-apps Bot commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds tests/unit/native-loader.test.ts with five isolation tests for loadNative(), one per load-order branch (env-path success/failure, local binary, npm package, unsupported platform). Each test re-imports the module after vi.resetModules() + vi.doMock() to get a clean singleton state.

  • Tests 1–2 cover the NAPI_RS_NATIVE_LIBRARY_PATH override, including cache behaviour and the no-fall-through guarantee; test 2 now also confirms the null result is cached (second call does not retry require).
  • Tests 3–4 exercise the local-binary-first / npm-package-fallback ordering via a controlled existsSync mock.
  • Test 5 verifies the unsupported-platform path returns null with zero require calls and that getNative() raises EngineError.

Confidence Score: 5/5

Test-only addition with no changes to production code; safe to merge.

The change is entirely additive — new test file only. The five tests correctly isolate singleton state via vi.resetModules + dynamic import, assertions match the actual load-order logic in native.ts, and the feedback from previous review rounds (redundant restore, null-result cache, node: pass-through) has been incorporated.

No files require special attention.

Important Files Changed

Filename Overview
tests/unit/native-loader.test.ts New test file covering all five load-order branches of loadNative(); uses vi.resetModules + vi.doMock + dynamic import pattern correctly to isolate singleton state per test.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[loadNative called] --> B{_cached !== undefined?}
    B -- Yes --> C[return _cached]
    B -- No --> D{NAPI_RS_NATIVE_LIBRARY_PATH set?}
    D -- Yes --> E[_require envPath]
    E -- success --> F[_cached = module\nreturn module]
    E -- error --> G[_cached = null\nwarn to stderr\nreturn null]
    D -- No --> H{localFile defined?\nexistsSync localPath?}
    H -- Yes --> I[_require localPath]
    I -- success --> J[_cached = module\nreturn module]
    I -- error --> K[_cached = null\nwarn\nreturn null]
    H -- No --> L{Platform in PLATFORM_PACKAGES?}
    L -- Yes --> M[_require pkg]
    M -- success --> N[_cached = module\nreturn module]
    M -- error --> O[_loadError = err\n_cached = null\nreturn null]
    L -- No --> P[_loadError = unsupported platform\n_cached = null\nreturn null]
Loading

Reviews (8): Last reviewed commit: "Merge branch 'main' into test/native-loa..." | Re-trigger Greptile

Comment on lines +54 to +58
afterEach(() => {
stderrSpy.mockRestore();
vi.restoreAllMocks();
vi.unstubAllEnvs();
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Redundant spy restore: stderrSpy.mockRestore() is called on line 57, and then vi.restoreAllMocks() is called on line 58 — which already invokes mockRestore() on every active spy, including stderrSpy. The explicit call is a no-op and can be removed to keep afterEach clean.

Suggested change
afterEach(() => {
stderrSpy.mockRestore();
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — removed the redundant stderrSpy.mockRestore() call. vi.restoreAllMocks() already covers all active spies including stderrSpy.

Comment on lines +91 to +97
expect(result).toBeNull();
// Only the env-path require was attempted — no fall-through to local or npm
expect(requireFn).toHaveBeenCalledTimes(1);
expect(requireFn).toHaveBeenCalledWith('/bad/path.node');
// A warning mentioning the env var name was emitted to stderr
const stderr = stderrSpy.mock.calls.map((c) => String(c[0])).join('');
expect(stderr).toContain('NAPI_RS_NATIVE_LIBRARY_PATH');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The null-result singleton cache is exercised by test 1 (positive path) but not by this test for the negative path. A second loadNative() call here would confirm that, after a failed env-path load, the _cached = null short-circuit fires and require is not retried — mirroring the toHaveBeenCalledTimes(1) assertion in test 1.

Suggested change
expect(result).toBeNull();
// Only the env-path require was attempted — no fall-through to local or npm
expect(requireFn).toHaveBeenCalledTimes(1);
expect(requireFn).toHaveBeenCalledWith('/bad/path.node');
// A warning mentioning the env var name was emitted to stderr
const stderr = stderrSpy.mock.calls.map((c) => String(c[0])).join('');
expect(stderr).toContain('NAPI_RS_NATIVE_LIBRARY_PATH');
expect(result).toBeNull();
// Only the env-path require was attempted — no fall-through to local or npm
expect(requireFn).toHaveBeenCalledTimes(1);
expect(requireFn).toHaveBeenCalledWith('/bad/path.node');
// A warning mentioning the env var name was emitted to stderr
const stderr = stderrSpy.mock.calls.map((c) => String(c[0])).join('');
expect(stderr).toContain('NAPI_RS_NATIVE_LIBRARY_PATH');
// Null result is cached — second call must not invoke require again
expect(loadNative()).toBeNull();
expect(requireFn).toHaveBeenCalledTimes(1);

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added the null-result cache second-call assertion to the bad-env-path test. A second loadNative() call now confirms requireFn is still only called once.

Comment on lines +20 to +37
localBinaryOk,
npmPackageOk,
}: {
localBinaryOk: boolean;
npmPackageOk: boolean;
}) {
return vi.fn((path: string) => {
const isAbsolute = path.startsWith('/') || /^[A-Z]:\\/.test(path);
if (isAbsolute) {
if (localBinaryOk) return FAKE_ADDON;
throw new Error(`ENOENT: no such file: ${path}`);
}
if (path.startsWith('@optave/')) {
if (npmPackageOk) return FAKE_ADDON;
throw new Error(`Cannot find module '${path}'`);
}
throw new Error(`Unexpected require call: ${path}`);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 makeMockRequire handles absolute paths and @optave/ package names, but has no branch for bare Node built-in specifiers (e.g. node:fs). In loadNative(), detectLibc() calls _require('node:fs') for the Linux platform. Because all current tests mock the platform as darwin or freebsd, detectLibc() is never triggered and the tests pass today. However, if a future test adds a Linux scenario (e.g. linux-x64-gnu), makeMockRequire will throw Unexpected require call: node:fs, producing a misleading failure.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added a node: pass-through branch in makeMockRequire that delegates to the real require for Node built-ins. This prevents a misleading Unexpected require call: node:fs failure if a future test exercises the Linux code path through detectLibc().

- Remove redundant stderrSpy.mockRestore() before vi.restoreAllMocks()
- Add null-result cache assertion in bad-env-path test (second call must
  not retry require)
- Add node:fs pass-through in makeMockRequire for future Linux tests
@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai

…3 and 4

Test 3 (local binary present) now asserts the result is cached on
a second call — require is not re-invoked.

Test 4 (npm fallback) now asserts the local binary path was attempted
first (two require calls total), pinning the load-order contract.
@carlos-alm

Copy link
Copy Markdown
Contributor Author

Addressed the two additional issues from the Greptile review summary:

  • Test 3 (local binary present): added second-call cache assertion — confirms requireFn is not re-invoked after the result is cached.
  • Test 4 (npm fallback): added load-order assertions — toHaveBeenCalledTimes(2) plus explicit checks that both the absolute local-binary path and @optave/codegraph-darwin-arm64 were called in order.

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai

Base automatically changed from fix/native-dev-binary-priority-1361 to main June 8, 2026 21:06
@carlos-alm

Copy link
Copy Markdown
Contributor Author

Merge conflict resolution (main → this branch):

Two files had conflicts:

  1. src/infrastructure/native.ts: Resolved by taking origin/main's behavior — existsSync check before calling require() on the local dev binary, with warn-and-halt if the file exists but fails to load.

  2. src/domain/graph/builder/incremental.ts: Resolved by taking origin/main's const/let split (const { targets: initialTargets, ... } = ... / let targets = initialTargets) and removing the duplicate if (targets.length === 0 ...) blocks that appeared from the merge.

Test update: The existsSync change in native.ts required updating tests/unit/native-loader.test.ts:

  • mockDeps now accepts a localBinaryExists parameter and mocks node:fs accordingly.
  • Test 3 (local binary present): passes localBinaryExists: true so existsSync returns true and require is called for the local binary.
  • Test 4 (npm fallback): passes localBinaryExists: false so existsSync returns false — the local binary require is skipped entirely, resulting in 1 call total (npm package only) rather than the 2 calls described in the previous comment. This is correct behavior: existsSync is the guard, so when the file is absent, require is never attempted.

All 5 native-loader tests pass.

@carlos-alm

Copy link
Copy Markdown
Contributor Author

@greptileai

@carlos-alm carlos-alm merged commit 117ac5b into main Jun 9, 2026
22 checks passed
@carlos-alm carlos-alm deleted the test/native-loader-1392-v2 branch June 9, 2026 09:23
@github-actions github-actions Bot locked and limited conversation to collaborators Jun 9, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(native): add unit tests for loadNative() load-order and env-var override

1 participant