Skip to content

Commit 1789c0e

Browse files
authored
test(genkit-tools): prevent fs mock leakage between test files (#5544)
1 parent 9c17573 commit 1789c0e

2 files changed

Lines changed: 103 additions & 0 deletions

File tree

genkit-tools/common/jest.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ const config: Config = {
2828
// A preset that is used as a base for Jest's configuration
2929
preset: 'ts-jest',
3030

31+
// Restore the real `fs` implementation after each test file so that test
32+
// files which mock `fs` by direct assignment can't leak those mocks into
33+
// other test files running later in the same worker. See tests/jest.setup.ts.
34+
setupFilesAfterEnv: ['<rootDir>/tests/jest.setup.ts'],
35+
3136
// The glob patterns Jest uses to detect test files
3237
testMatch: ['**/tests/**/*_test.ts'],
3338

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { afterAll } from '@jest/globals';
18+
import fs from 'fs';
19+
20+
/**
21+
* Several test files mock filesystem behavior by directly reassigning methods
22+
* on the built-in `fs` module, for example:
23+
*
24+
* fs.writeFileSync = jest.fn(() => {});
25+
* fs.promises.writeFile = jest.fn(async () => undefined);
26+
*
27+
* `jest.restoreAllMocks()` only restores spies created via `jest.spyOn`; it does
28+
* NOT undo a direct property assignment. Worse, Jest does not sandbox Node's
29+
* built-in modules between test files in the same worker — `require('fs')`
30+
* returns the same shared object everywhere. So a mock assigned (and never
31+
* restored) in one test file silently corrupts `fs` for every test file that
32+
* later runs in that same worker, producing flaky, scheduling-dependent
33+
* failures (e.g. `fs.writeFileSync` becoming a no-op in an unrelated test).
34+
*
35+
* To make tests robust regardless of how they mock `fs`, snapshot the real
36+
* implementations once at load time (before any test mutates them) and restore
37+
* them after each test FILE completes (`afterAll`). We deliberately do NOT
38+
* restore between individual tests: some suites set an `fs` mock in one test and
39+
* rely on it in the next. The cross-FILE leak (within a shared worker) is the
40+
* actual bug, and `afterAll` cleans up before the next file runs without
41+
* disturbing intra-file behavior.
42+
*/
43+
function snapshot(source: object): Record<string | symbol, any> {
44+
const snap: Record<string | symbol, any> = {};
45+
for (const key of Reflect.ownKeys(source)) {
46+
try {
47+
snap[key as any] = (source as any)[key];
48+
} catch {
49+
// Ignore getters that throw.
50+
}
51+
}
52+
return snap;
53+
}
54+
55+
const realFs = snapshot(fs);
56+
const realFsPromises = snapshot(fs.promises);
57+
58+
/**
59+
* Restores own properties of `target` from `snapshot`: deletes any properties a
60+
* test added that weren't in the original, and re-assigns any that changed.
61+
* Skips non-writable members (for example `fs.constants`) and unchanged ones.
62+
* Uses `Reflect.ownKeys` so symbol and non-enumerable properties are handled.
63+
*/
64+
function restoreFrom(
65+
target: Record<string | symbol, any>,
66+
snap: Record<string | symbol, any>
67+
) {
68+
// Remove properties added by a test that weren't part of the snapshot.
69+
for (const key of Reflect.ownKeys(target)) {
70+
if (!(key in snap)) {
71+
try {
72+
delete target[key];
73+
} catch {
74+
// Best-effort; ignore properties that can't be deleted.
75+
}
76+
}
77+
}
78+
// Restore changed properties back to their original implementations.
79+
for (const key of Reflect.ownKeys(snap)) {
80+
if (target[key] === snap[key]) {
81+
continue;
82+
}
83+
const descriptor = Object.getOwnPropertyDescriptor(target, key);
84+
if (descriptor && descriptor.writable === false) {
85+
continue;
86+
}
87+
try {
88+
target[key] = snap[key];
89+
} catch {
90+
// Best-effort restore; ignore properties that can't be reassigned.
91+
}
92+
}
93+
}
94+
95+
afterAll(() => {
96+
restoreFrom(fs, realFs);
97+
restoreFrom(fs.promises, realFsPromises);
98+
});

0 commit comments

Comments
 (0)