diff --git a/.changeset/spyable-turbostubs.md b/.changeset/spyable-turbostubs.md new file mode 100644 index 0000000..ec7b52c --- /dev/null +++ b/.changeset/spyable-turbostubs.md @@ -0,0 +1,5 @@ +--- +"vitest-native": patch +--- + +Make native-engine turboStubs identity-stable and spy-able. Unmocked native modules were served by a Proxy that minted a fresh stub object on every property access and whose get trap never consulted the target — so `NativeModules.Foo !== NativeModules.Foo`, and `vi.spyOn(NativeModules.Foo, 'method')` silently recorded nothing (the spy landed on a throwaway object). Stubs are now memoized per module name in the shared boundary state (`NativeModules.Foo === TurboModuleRegistry.get('Foo')`, matching bridgeless RN), methods are memoized on first read, and explicitly-set properties win — so spies record and restore correctly. A `has` trap reports all properties present, consistent with the get trap's serve-anything behavior, which `vi.spyOn`'s existence check requires. Under the hot runtime, per-file overrides (spies, memoized methods) are cleared between files via the surgical-reset registry while stub identity is preserved for resident libraries holding references. diff --git a/packages/vitest-native/src/native/boundary.mjs b/packages/vitest-native/src/native/boundary.mjs index 453db23..bc59e57 100644 --- a/packages/vitest-native/src/native/boundary.mjs +++ b/packages/vitest-native/src/native/boundary.mjs @@ -143,36 +143,73 @@ function turboStubSource(platform, version) { // (e.g. showShareActionSheetWithOptions(options, error, success)). For the latter, // the success callback is the LAST function arg. const __SUCCESS_LAST = new Set(["showShareActionSheetWithOptions"]); - const turboStub = (name) => new Proxy({}, { - get: (_t, p) => { - if (p === "getConstants") return () => (__C[name] || {}); - if (p === "getColorScheme") { - return () => getBoundaryState(name).colorScheme ?? "light"; - } - if (p === "setColorScheme") { - return (colorScheme) => { - getBoundaryState(name).colorScheme = - colorScheme === "unspecified" || colorScheme == null ? "light" : colorScheme; - }; - } - if (p === "addListener") return () => ({ remove: () => {} }); - if (p === "removeListeners") return () => {}; - return (...args) => { - // Callback-style native methods resolve via a callback argument. Invoke the - // success callback so JS Promises that wrap these settle instead of hanging. - const fns = args.filter((a) => typeof a === "function"); - if (fns.length) { - const cb = typeof p === "string" && __SUCCESS_LAST.has(p) ? fns[fns.length - 1] : fns[0]; - return cb(false); - } - // Promise-returning native methods must yield a Promise, not undefined. - if (typeof p === "string" && Object.prototype.hasOwnProperty.call(__ASYNC, p)) { - return Promise.resolve(__ASYNC[p]); + // Stubs are memoized per module name in the shared boundary state, so + // NativeModules.Foo === NativeModules.Foo === TurboModuleRegistry.get('Foo') + // (matching bridgeless RN, where NativeModules proxies TurboModuleRegistry). + // Methods are memoized as own properties of the proxy target on first read — + // identity-stable across reads, and explicit writes win, so + // vi.spyOn(NativeModules.Foo, 'method') records calls instead of silently + // landing on a throwaway object. + const turboStub = (name) => { + const state = getBoundaryState(name); + if (state.__stub) return state.__stub; + const target = {}; + state.__stub = new Proxy(target, { + get: (t, p) => { + // Explicitly-set properties win (spies, manual overrides, memoized methods). + if (Object.prototype.hasOwnProperty.call(t, p)) return t[p]; + let v; + if (p === "getConstants") v = () => (__C[name] || {}); + else if (p === "getColorScheme") { + v = () => getBoundaryState(name).colorScheme ?? "light"; + } else if (p === "setColorScheme") { + v = (colorScheme) => { + getBoundaryState(name).colorScheme = + colorScheme === "unspecified" || colorScheme == null ? "light" : colorScheme; + }; + } else if (p === "addListener") v = () => ({ remove: () => {} }); + else if (p === "removeListeners") v = () => {}; + else { + v = (...args) => { + // Callback-style native methods resolve via a callback argument. Invoke the + // success callback so JS Promises that wrap these settle instead of hanging. + const fns = args.filter((a) => typeof a === "function"); + if (fns.length) { + const cb = typeof p === "string" && __SUCCESS_LAST.has(p) ? fns[fns.length - 1] : fns[0]; + return cb(false); + } + // Promise-returning native methods must yield a Promise, not undefined. + if (typeof p === "string" && Object.prototype.hasOwnProperty.call(__ASYNC, p)) { + return Promise.resolve(__ASYNC[p]); + } + return undefined; + }; } - return undefined; - }; - }, - }); + // defineProperty, not assignment: sloppy-mode \`t["__proto__"] = fn\` + // would invoke the inherited __proto__ SETTER and silently swap the + // target's prototype instead of memoizing. + Object.defineProperty(t, p, { + value: v, + writable: true, + enumerable: true, + configurable: true, + }); + return v; + }, + // Every property reads as a callable stub, so report them all as present — + // vi.spyOn refuses to spy on a property its \`in\` check can't see. + has: () => true, + }); + // Hot runtime: clear per-file state (spies, memoized methods) between files + // via the surgical-reset registry, while keeping the stub's identity for + // resident libraries that captured a reference at import time. Under the + // default engine each file gets a fresh process, so this never fires. + const resets = globalThis.__vitest_native_resets || (globalThis.__vitest_native_resets = []); + resets.push(() => { + for (const k of Reflect.ownKeys(target)) delete target[k]; + }); + return state.__stub; + }; `; } diff --git a/packages/vitest-native/src/native/reset.mjs b/packages/vitest-native/src/native/reset.mjs index bda4d31..47ba8e4 100644 --- a/packages/vitest-native/src/native/reset.mjs +++ b/packages/vitest-native/src/native/reset.mjs @@ -34,8 +34,9 @@ // 2b. process.env — restored to the worker boot snapshot. // 3. Vitest timers/global/env stubs — restored by setup.mjs before this reset. // 4. Boundary/preset mocks that registered callbacks in -// globalThis.__vitest_native_resets (none are stateful today; the -// registry is the extension point). +// globalThis.__vitest_native_resets (the turboStubs register one per +// native-module name to clear per-file overrides; runs before the +// value-restores, which route through those stubs). // 5. globalThis keys added by a file — deleted. The baseline starts at the // first per-file call (after Vitest injected its per-batch globals) and // never grows implicitly. Mutations of pre-existing keys are not restored @@ -147,6 +148,21 @@ export function installHotReset({ projectRoot, diagnostics, preserveGlobals = [] // act/auto-cleanup machinery corrupted rendering for every later file // when the consumer's graph inlines RNTL (found via Rocket.Chat). + // (4) Boundary/preset mock reset callbacks — BEFORE the value-restore: + // these clear per-file overrides on the boundary stubs (spies, direct + // method assignments), and the value-restore below routes through those + // very stubs (Appearance.setColorScheme → NativeAppearance). Restoring + // first would send the restore through a previous file's dead override + // and then clear it one step too late. + const resets = globalThis.__vitest_native_resets; + if (Array.isArray(resets)) { + for (const fn of resets) { + try { + fn(); + } catch {} + } + } + // (2) Restore mutable resident RN state (value-restore, always safe). if (dims) { try { @@ -157,16 +173,6 @@ export function installHotReset({ projectRoot, diagnostics, preserveGlobals = [] RN.Appearance.setColorScheme?.(colorScheme); } catch {} - // (4) Boundary/preset mock reset callbacks. - const resets = globalThis.__vitest_native_resets; - if (Array.isArray(resets)) { - for (const fn of resets) { - try { - fn(); - } catch {} - } - } - if (!armed) return; // no bless yet → cannot attribute; fail open // (1) Remove the previous file's test-phase RN event listeners. diff --git a/packages/vitest-native/tests-native/hot-isolation/01-first.test.ts b/packages/vitest-native/tests-native/hot-isolation/01-first.test.ts index 4aed855..a98eb22 100644 --- a/packages/vitest-native/tests-native/hot-isolation/01-first.test.ts +++ b/packages/vitest-native/tests-native/hot-isolation/01-first.test.ts @@ -1,4 +1,4 @@ -import { Appearance, DeviceEventEmitter } from "react-native"; +import { Appearance, DeviceEventEmitter, NativeModules } from "react-native"; import { expect, it } from "vitest"; const globalKey = "__VN_HOT_ISOLATION_OWNER__"; @@ -14,6 +14,11 @@ const inheritedColorScheme = Appearance.getColorScheme(); process.env[envKey] = "first"; DeviceEventEmitter.addListener(eventName, () => {}); Appearance.setColorScheme("dark"); +// Leave a DEAD override on the boundary stub, un-restored. The next file's +// hot reset must clear stub overrides BEFORE the colorScheme value-restore — +// restoring first would route the restore through this no-op and leak "dark" +// into file 2 (demonstrated in review of the spy-able-turboStubs change). +(NativeModules.Appearance as Record).setColorScheme = () => {}; it("starts without state from another test file", () => { expect(inheritedGlobal).toBeUndefined(); diff --git a/packages/vitest-native/tests-native/hot-isolation/02-second.test.ts b/packages/vitest-native/tests-native/hot-isolation/02-second.test.ts index e807f35..4082563 100644 --- a/packages/vitest-native/tests-native/hot-isolation/02-second.test.ts +++ b/packages/vitest-native/tests-native/hot-isolation/02-second.test.ts @@ -1,4 +1,4 @@ -import { Appearance, DeviceEventEmitter } from "react-native"; +import { Appearance, DeviceEventEmitter, NativeModules } from "react-native"; import { expect, it } from "vitest"; const globalKey = "__VN_HOT_ISOLATION_OWNER__"; @@ -9,6 +9,11 @@ const inheritedGlobal = (globalThis as Record)[globalKey]; const inheritedEnv = process.env[envKey]; const inheritedListeners = DeviceEventEmitter.listenerCount(eventName); const inheritedColorScheme = Appearance.getColorScheme(); +// Read the boundary stub DIRECTLY, bypassing RN's JS-side appearance cache: +// file 1 left a dead override on this stub's setColorScheme, so if the hot +// reset value-restores BEFORE clearing stub overrides, the restore is +// swallowed and this read leaks "dark" while the JS cache says "light". +const inheritedNativeColorScheme = NativeModules.Appearance.getColorScheme(); (globalThis as Record)[globalKey] = "second"; process.env[envKey] = "second"; @@ -19,6 +24,16 @@ it("starts without state from another test file", () => { expect(inheritedGlobal).toBeUndefined(); expect(inheritedEnv).toBeUndefined(); expect(inheritedListeners).toBe(0); + // File 1 set "dark" AND left a dead override on the Appearance stub; both + // must be gone — the reset clears stub overrides before the value-restore. expect(inheritedColorScheme).toBe("light"); + expect(inheritedNativeColorScheme).toBe("light"); expect(Appearance.getColorScheme()).toBe("dark"); }); + +it("does not inherit the previous file's boundary-stub override", () => { + // File 1 replaced NativeModules.Appearance.setColorScheme with a no-op. If + // that override survived, this write would be swallowed. + NativeModules.Appearance.setColorScheme("dark"); + expect(NativeModules.Appearance.getColorScheme()).toBe("dark"); +}); diff --git a/packages/vitest-native/tests-native/native-module-spies.test.ts b/packages/vitest-native/tests-native/native-module-spies.test.ts new file mode 100644 index 0000000..5541c1f --- /dev/null +++ b/packages/vitest-native/tests-native/native-module-spies.test.ts @@ -0,0 +1,39 @@ +/** + * Unmocked native modules are served by identity-stable, spy-able turboStubs. + * Previously every property access minted a fresh Proxy and the get trap never + * consulted the target, so `vi.spyOn(NativeModules.X, 'method')` silently + * recorded nothing — a real pattern in migrated Jest suites. + */ +import { describe, it, expect, vi } from "vitest"; +import { NativeModules, TurboModuleRegistry } from "react-native"; + +describe("NativeModules stubs under the native engine", () => { + it("are identity-stable across property accesses", () => { + expect(NativeModules.VNIdentityProbe).toBe(NativeModules.VNIdentityProbe); + expect(NativeModules.VNIdentityProbe.someMethod).toBe(NativeModules.VNIdentityProbe.someMethod); + }); + + it("share identity with TurboModuleRegistry", () => { + expect(TurboModuleRegistry.get("VNSharedProbe")).toBe(NativeModules.VNSharedProbe); + }); + + it("support vi.spyOn on stub methods", () => { + const spy = vi.spyOn(NativeModules.VNSpyProbe, "doThing"); + NativeModules.VNSpyProbe.doThing("payload", 42); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith("payload", 42); + spy.mockRestore(); + expect(NativeModules.VNSpyProbe.doThing("x")).toBeUndefined(); + }); + + it("keeps callback/promise conventions after memoization", async () => { + // Callback-style: success callback is invoked so wrapping Promises settle. + let called: unknown = "not called"; + NativeModules.VNCallbackProbe.fetchState((v: unknown) => { + called = v; + }); + expect(called).toBe(false); + // getConstants stays functional on the memoized stub. + expect(typeof NativeModules.VNCallbackProbe.getConstants()).toBe("object"); + }); +}); diff --git a/packages/vitest-native/tests/native-unit.test.ts b/packages/vitest-native/tests/native-unit.test.ts index d890766..5227d8c 100644 --- a/packages/vitest-native/tests/native-unit.test.ts +++ b/packages/vitest-native/tests/native-unit.test.ts @@ -114,6 +114,49 @@ describe("native boundary", () => { const mod = evalCjs(src!); expect(typeof mod.default).toBe("function"); }); + + it("turboStubs are identity-stable and spy-able", () => { + const src = boundarySourceFor("/x/react-native/Libraries/TurboModule/TurboModuleRegistry.js"); + const mod = evalCjs(src!); + const stub = mod.get("IdentityTarget"); + // Repeated module and method reads return the same objects — a prerequisite + // for vi.spyOn and instanceof-style assertions in migrated Jest suites. + expect(mod.get("IdentityTarget")).toBe(stub); + expect(stub.someMethod).toBe(stub.someMethod); + const spy = vi.spyOn(stub, "someMethod"); + stub.someMethod("a", 1); + expect(spy).toHaveBeenCalledWith("a", 1); + spy.mockRestore(); + // Restored method keeps the generic stub behavior. + expect(stub.someMethod()).toBeUndefined(); + }); + + it("NativeModules and TurboModuleRegistry share stub identity", () => { + const tm = evalCjs( + boundarySourceFor("/x/react-native/Libraries/TurboModule/TurboModuleRegistry.js")!, + ); + const nm = evalCjs( + boundarySourceFor("/x/react-native/Libraries/BatchedBridge/NativeModules.js")!, + ).default; + expect(nm.SharedIdentityTarget).toBe(tm.get("SharedIdentityTarget")); + }); + + it("the hot-reset registry clears overrides but keeps stub identity", () => { + const src = boundarySourceFor("/x/react-native/Libraries/TurboModule/TurboModuleRegistry.js"); + const mod = evalCjs(src!); + const stub = mod.get("ResetTarget"); + stub.overridden = "test-phase value"; + const resets = (globalThis as any).__vitest_native_resets; + expect(Array.isArray(resets)).toBe(true); + for (const fn of resets) fn(); + // Same stub object survives (resident libs hold references), but the + // per-file override is gone — the slot regenerates as a generic stub + // method like any other unknown property. + expect(mod.get("ResetTarget")).toBe(stub); + expect(stub.overridden).not.toBe("test-phase value"); + expect(typeof stub.overridden).toBe("function"); + expect(typeof stub.someMethod).toBe("function"); + }); }); // @ts-expect-error — runtime .mjs