diff --git a/test/non-isolated-runner.resolve-mocks.test.ts b/test/non-isolated-runner.resolve-mocks.test.ts new file mode 100644 index 000000000000..fda01a503ce7 --- /dev/null +++ b/test/non-isolated-runner.resolve-mocks.test.ts @@ -0,0 +1,97 @@ +// Guards the resolveMocks serialization pin: passes run sequentially so a +// drained snapshot is never registered (and its mock modules never +// invalidated) twice, while every caller's pass starts at or after its call so +// previously queued ids are registered before the caller's fetch proceeds. +import { describe, expect, it } from "vitest"; +import { serializeMockerResolveMocks } from "./non-isolated-runner.js"; + +// Mirrors BareModuleMocker.resolveMocks: snapshots the static queue's contents +// at pass start, awaits its RPCs, then reassigns the static to [] so ids +// pushed during the await land in the abandoned array. +class FakeMocker { + static pendingIds: unknown[] = []; + passes = 0; + active = 0; + maxConcurrentPasses = 0; + processed: unknown[] = []; + + async resolveMocks(): Promise { + if (FakeMocker.pendingIds.length === 0) { + return; + } + this.active += 1; + this.maxConcurrentPasses = Math.max(this.maxConcurrentPasses, this.active); + this.passes += 1; + const snapshot = [...FakeMocker.pendingIds]; + // Simulate the parallel resolveId RPC round-trips inside one pass. + await new Promise((resolve) => { + setTimeout(resolve, 1); + }); + this.processed.push(...snapshot); + FakeMocker.pendingIds = []; + this.active -= 1; + } +} + +describe("serializeMockerResolveMocks", () => { + it("serializes concurrent callers and never re-registers a drained snapshot", async () => { + FakeMocker.pendingIds = ["mock-a", "mock-b"]; + const mocker = new FakeMocker(); + serializeMockerResolveMocks(mocker); + + await Promise.all([mocker.resolveMocks(), mocker.resolveMocks(), mocker.resolveMocks()]); + + expect(mocker.maxConcurrentPasses).toBe(1); + // Later chained passes see the cleared queue and no-op instead of + // re-registering (and re-invalidating) the same snapshot. + expect(mocker.passes).toBe(1); + expect(mocker.processed).toEqual(["mock-a", "mock-b"]); + expect(FakeMocker.pendingIds).toEqual([]); + }); + + it("registers ids queued while a pass is in flight before the later caller resolves", async () => { + FakeMocker.pendingIds = ["mock-a"]; + const mocker = new FakeMocker(); + serializeMockerResolveMocks(mocker); + + const first = mocker.resolveMocks(); + // Upstream would abandon this push when it reassigns pendingIds to []; + // the wrapper must requeue it and the second caller's own chained pass + // must register it before that caller proceeds with its fetch. + FakeMocker.pendingIds.push("mock-late"); + const second = mocker.resolveMocks(); + await second; + + expect(mocker.processed).toEqual(["mock-a", "mock-late"]); + expect(mocker.maxConcurrentPasses).toBe(1); + await first; + expect(FakeMocker.pendingIds).toEqual([]); + }); + + it("does not double-wrap when installed repeatedly", async () => { + FakeMocker.pendingIds = ["mock-a"]; + const mocker = new FakeMocker(); + serializeMockerResolveMocks(mocker); + // Identity check: a second install must keep the first wrapper in place. + const wrapped: unknown = Reflect.get(mocker, "resolveMocks"); + serializeMockerResolveMocks(mocker); + + expect(Reflect.get(mocker, "resolveMocks")).toBe(wrapped); + await mocker.resolveMocks(); + expect(mocker.passes).toBe(1); + }); + + it("allows a fresh pass after the previous one settles", async () => { + FakeMocker.pendingIds = ["mock-a"]; + const mocker = new FakeMocker(); + serializeMockerResolveMocks(mocker); + await mocker.resolveMocks(); + + FakeMocker.pendingIds = ["mock-b"]; + await mocker.resolveMocks(); + + expect(mocker.passes).toBe(2); + expect(mocker.processed).toEqual(["mock-a", "mock-b"]); + expect(FakeMocker.pendingIds).toEqual([]); + }); +}); diff --git a/test/non-isolated-runner.ts b/test/non-isolated-runner.ts index 2926fde15821..f26970723924 100644 --- a/test/non-isolated-runner.ts +++ b/test/non-isolated-runner.ts @@ -14,8 +14,13 @@ type EvaluatedModules = { idToModuleMap: Map; }; +type SerializableMocker = { + reset?: () => void; + resolveMocks?: () => Promise; +}; + type TestRunnerInternals = { - moduleRunner?: { mocker?: { reset?: () => void } }; + moduleRunner?: { mocker?: SerializableMocker }; workerState: { evaluatedModules: unknown }; }; @@ -227,9 +232,71 @@ function resetOpenClawGlobalDiagnosticState(): void { Reflect.deleteProperty(globalStore, DIAGNOSTIC_EVENTS_STATE); } +const SERIALIZED_RESOLVE_MOCKS = Symbol.for("openclaw.serializedResolveMocks"); + +// Vitest's BareModuleMocker.resolveMocks has no in-flight guard: pendingIds is +// cleared only after all parallel resolveId RPCs settle, and every registration +// re-invalidates the mock module node. In a shared isolate:false worker, stray +// async work from an earlier file (a leaked timer running a dynamic import) can +// start a second concurrent pass over the same pendingIds while the next file's +// vi.mock registrations resolve. The slower pass then re-registers and wipes +// already-evaluated manual mock modules mid-import-chain, so importers before +// the wipe hold one factory instance and later importers get a fresh one +// (vi.mocked(...) on the test's binding silently stops reaching prod). +// +// The pin chains each caller onto its own sequential pass instead of sharing +// one in-flight pass. Two invariants both matter: +// - Serialization: a pass queued behind an in-flight one sees the cleared +// queue and no-ops, so a snapshot is never registered (and its mock modules +// never invalidated) twice. +// - Freshness: every caller's pass starts at or after its call, so ids the +// caller queued (vi.mock/doMock/doUnmock before a dynamic import) are +// registered before its fetch proceeds. Sharing one pass breaks this — a +// caller can coalesce onto a pass snapshotted before its ids were queued and +// then import with mock state unresolved (observed: auth-provenance's +// doUnmock + Promise.all imports loading the real provider-auth warm worker +// and a 120s oauth refresh instead of the mocked provider hook). +export function serializeMockerResolveMocks( + mocker: SerializableMocker & { [SERIALIZED_RESOLVE_MOCKS]?: boolean }, +): void { + if (!mocker.resolveMocks || mocker[SERIALIZED_RESOLVE_MOCKS]) { + return; + } + mocker[SERIALIZED_RESOLVE_MOCKS] = true; + const original = mocker.resolveMocks.bind(mocker); + const statics = mocker.constructor as { pendingIds?: unknown[] }; + const runPass = async (): Promise => { + const queue = statics.pendingIds; + const processedCount = queue?.length ?? 0; + await original(); + // Upstream snapshots the queue contents at pass start and reassigns the + // pendingIds static to [] at the end, so ids queued during the pass's RPC + // window land in the abandoned array. Requeue them so the next chained + // pass registers them instead of silently dropping the registration. + if (queue && queue !== statics.pendingIds && queue.length > processedCount) { + statics.pendingIds?.push(...queue.slice(processedCount)); + } + }; + let tail: Promise = Promise.resolve(); + mocker.resolveMocks = () => { + const pass = tail.then(runPass); + // Keep the chain alive after a rejected pass; the rejection still reaches + // the caller that owns that pass, matching upstream behavior. + tail = pass.then( + () => undefined, + () => undefined, + ); + return pass; + }; +} + export default class OpenClawNonIsolatedRunner extends TestRunner { override onCollectStart(file: RunnerTestFile) { super.onCollectStart(file); + const internals = this as unknown as TestRunnerInternals; + if (internals.moduleRunner?.mocker) { + serializeMockerResolveMocks(internals.moduleRunner.mocker); + } restoreRealTimers(); restoreNativeTimerGlobals(); restoreSharedTestHomeAfterEnvUnstub(getSharedTestHome());