@useblacksmith/vitest 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/affected.d.ts +27 -9
- package/dist/affected.js +294 -46
- package/dist/browser.d.ts +11 -0
- package/dist/browser.js +57 -0
- package/dist/cache.d.ts +9 -0
- package/dist/cache.js +12 -3
- package/dist/fs-recorder.d.ts +18 -0
- package/dist/fs-recorder.js +104 -0
- package/dist/git.d.ts +7 -0
- package/dist/git.js +55 -9
- package/dist/index.js +12 -1
- package/dist/record-child.d.ts +1 -0
- package/dist/record-child.js +60 -0
- package/dist/record-reads.js +278 -83
- package/package.json +6 -2
- package/dist/bloom.d.ts +0 -23
- package/dist/bloom.js +0 -70
package/dist/browser.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
function dependenciesCache(vitest) {
|
|
2
|
+
const cache = vitest.cache;
|
|
3
|
+
const record = cache?._dependencies?.record;
|
|
4
|
+
return typeof record === 'function'
|
|
5
|
+
? { record: record.bind(cache?._dependencies) }
|
|
6
|
+
: null;
|
|
7
|
+
}
|
|
8
|
+
const warned = new WeakSet();
|
|
9
|
+
/**
|
|
10
|
+
* Vitest's browser tester never reports what a test file imported, so
|
|
11
|
+
* `dependencies.json` holds no record for a browser test and `--changed` runs
|
|
12
|
+
* it every time. The project's Vite client environment transformed every
|
|
13
|
+
* module the browser loaded; walking its graph from the test file and the
|
|
14
|
+
* setup files gives the same set the node worker reports, recorded through
|
|
15
|
+
* the same call its RPC makes. Virtual modules (`/@vitest/...`, `\0...`) have
|
|
16
|
+
* no file and are left out; vitest drops `node_modules` paths itself.
|
|
17
|
+
*/
|
|
18
|
+
export function recordBrowserDependencies(vitest, module) {
|
|
19
|
+
const project = module.project;
|
|
20
|
+
if (!project.isBrowserEnabled())
|
|
21
|
+
return;
|
|
22
|
+
const cache = dependenciesCache(vitest);
|
|
23
|
+
if (!cache) {
|
|
24
|
+
if (!warned.has(vitest)) {
|
|
25
|
+
warned.add(vitest);
|
|
26
|
+
vitest.logger.warn('[blacksmith] this vitest does not expose its dependency record; browser tests run on every --changed');
|
|
27
|
+
}
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const graph = project.vite.environments.client?.moduleGraph;
|
|
31
|
+
if (!graph)
|
|
32
|
+
return;
|
|
33
|
+
const roots = [...(graph.getModulesByFile(module.moduleId) ?? [])];
|
|
34
|
+
// A test the browser never fetched through this graph has no record to
|
|
35
|
+
// give; an empty one would let `--changed` skip it on every edit.
|
|
36
|
+
if (roots.length === 0)
|
|
37
|
+
return;
|
|
38
|
+
const files = new Set([module.moduleId]);
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
const queue = [
|
|
41
|
+
...roots,
|
|
42
|
+
...project.config.setupFiles.flatMap((file) => [
|
|
43
|
+
...(graph.getModulesByFile(file) ?? []),
|
|
44
|
+
]),
|
|
45
|
+
];
|
|
46
|
+
while (queue.length > 0) {
|
|
47
|
+
const node = queue.pop();
|
|
48
|
+
if (!node?.id || seen.has(node.id))
|
|
49
|
+
continue;
|
|
50
|
+
seen.add(node.id);
|
|
51
|
+
if (node.file)
|
|
52
|
+
files.add(node.file);
|
|
53
|
+
for (const imported of node.importedModules)
|
|
54
|
+
queue.push(imported);
|
|
55
|
+
}
|
|
56
|
+
cache.record(project, module.moduleId, [...files]);
|
|
57
|
+
}
|
package/dist/cache.d.ts
CHANGED
|
@@ -19,8 +19,17 @@ export interface CacheKey {
|
|
|
19
19
|
prefix: string;
|
|
20
20
|
}
|
|
21
21
|
export declare const DEFAULT_CACHE_PREFIX = "blacksmith-vitest";
|
|
22
|
+
/**
|
|
23
|
+
* What the recorded inputs were resolved and run under. A `matrix.node` job
|
|
24
|
+
* on another major, or a platform whose resolution differs (case-insensitive
|
|
25
|
+
* fs, `.node` binaries), must not vouch for this one.
|
|
26
|
+
*/
|
|
27
|
+
export declare function runtimeId(): string;
|
|
22
28
|
export declare function cacheKey(input: {
|
|
23
29
|
snapshotVersion: number;
|
|
30
|
+
runtime: string;
|
|
31
|
+
/** Vitest's root relative to the repository: each package of a monorepo keeps its own entries. */
|
|
32
|
+
root: string;
|
|
24
33
|
projects: string[];
|
|
25
34
|
shard: {
|
|
26
35
|
index: number;
|
package/dist/cache.js
CHANGED
|
@@ -16,14 +16,23 @@ const TWIRP = 'twirp/github.actions.results.api.v1.CacheService/';
|
|
|
16
16
|
export const BUNDLE_VERSION = 'blacksmith-vitest-bundle-1';
|
|
17
17
|
export const TIMEOUT_MS = 15_000;
|
|
18
18
|
/** Files under the cache dir that travel in the bundle. */
|
|
19
|
-
export const RECORD_FILE = /^(dependencies\.json|blacksmith(\.[0-9a-f]{12})?\.json)$/;
|
|
19
|
+
export const RECORD_FILE = /^(dependencies\.json|blacksmith(\.[0-9a-f]{12})?(\.\d+of\d+)?\.json)$/;
|
|
20
20
|
export const DEFAULT_CACHE_PREFIX = 'blacksmith-vitest';
|
|
21
|
+
/**
|
|
22
|
+
* What the recorded inputs were resolved and run under. A `matrix.node` job
|
|
23
|
+
* on another major, or a platform whose resolution differs (case-insensitive
|
|
24
|
+
* fs, `.node` binaries), must not vouch for this one.
|
|
25
|
+
*/
|
|
26
|
+
export function runtimeId() {
|
|
27
|
+
return `node${process.versions.node.split('.')[0]}-${process.platform}-${process.arch}`;
|
|
28
|
+
}
|
|
21
29
|
export function cacheKey(input) {
|
|
22
30
|
const scope = [
|
|
23
31
|
`v${input.snapshotVersion}`,
|
|
24
|
-
input.
|
|
32
|
+
input.runtime,
|
|
33
|
+
input.root !== '' || input.projects.some(Boolean)
|
|
25
34
|
? createHash('sha1')
|
|
26
|
-
.update(input.projects.join('\n'))
|
|
35
|
+
.update([input.root, ...input.projects].join('\n'))
|
|
27
36
|
.digest('hex')
|
|
28
37
|
.slice(0, 12)
|
|
29
38
|
: 'all',
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type * as FS from 'node:fs';
|
|
2
|
+
/** Marks a function this module already wrapped, so a second load leaves it alone. */
|
|
3
|
+
export declare const BRAND: unique symbol;
|
|
4
|
+
/** Set in a child process's environment: the file its recorder appends the paths it loaded or read to. */
|
|
5
|
+
export declare const CHILD_RECORD_ENV = "BLACKSMITH_CHILD_RECORD";
|
|
6
|
+
/** The repository root; a read outside it is not recorded, the anchor cannot cover it. */
|
|
7
|
+
export declare const ENV_REPO = "BLACKSMITH_REPO";
|
|
8
|
+
export declare function toPath(target: unknown): string | null;
|
|
9
|
+
/** Installed packages are vouched for by the lockfile, not by their files. */
|
|
10
|
+
export declare function isIgnored(absolute: string): boolean;
|
|
11
|
+
type Wrappable = Record<string, unknown>;
|
|
12
|
+
/** Replaces `object[name]` with a function that calls `before(args)` first; properties and symbols of the original (`realpathSync.native`, `promisify.custom`) carry over. */
|
|
13
|
+
export declare function wrap(object: Wrappable, name: string, before: (args: unknown[]) => void): void;
|
|
14
|
+
/** Own properties of `original` that `wrapped` lacks, non-enumerable ones included. */
|
|
15
|
+
export declare function copyProperties(wrapped: object, original: object): void;
|
|
16
|
+
/** Wraps every read entry point of `fs` so `record` sees the path argument; a copied tree is recorded file by file. */
|
|
17
|
+
export declare function wrapReads(fs: typeof FS, record: (file: string) => void): void;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
/** Marks a function this module already wrapped, so a second load leaves it alone. */
|
|
4
|
+
export const BRAND = Symbol.for('blacksmith.recordReads');
|
|
5
|
+
/** Set in a child process's environment: the file its recorder appends the paths it loaded or read to. */
|
|
6
|
+
export const CHILD_RECORD_ENV = 'BLACKSMITH_CHILD_RECORD';
|
|
7
|
+
/** The repository root; a read outside it is not recorded, the anchor cannot cover it. */
|
|
8
|
+
export const ENV_REPO = 'BLACKSMITH_REPO';
|
|
9
|
+
export function toPath(target) {
|
|
10
|
+
if (typeof target === 'string')
|
|
11
|
+
return target;
|
|
12
|
+
if (Buffer.isBuffer(target))
|
|
13
|
+
return target.toString();
|
|
14
|
+
if (target instanceof URL) {
|
|
15
|
+
try {
|
|
16
|
+
return fileURLToPath(target);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
/** Installed packages are vouched for by the lockfile, not by their files. */
|
|
25
|
+
export function isIgnored(absolute) {
|
|
26
|
+
if (absolute.split(path.sep).includes('node_modules'))
|
|
27
|
+
return true;
|
|
28
|
+
const repo = process.env[ENV_REPO];
|
|
29
|
+
if (!repo)
|
|
30
|
+
return false;
|
|
31
|
+
const relative = path.relative(repo, absolute);
|
|
32
|
+
return relative.startsWith('..') || path.isAbsolute(relative);
|
|
33
|
+
}
|
|
34
|
+
/** Replaces `object[name]` with a function that calls `before(args)` first; properties and symbols of the original (`realpathSync.native`, `promisify.custom`) carry over. */
|
|
35
|
+
export function wrap(object, name, before) {
|
|
36
|
+
const original = object[name];
|
|
37
|
+
if (typeof original !== 'function' || BRAND in original)
|
|
38
|
+
return;
|
|
39
|
+
const wrapped = function (...args) {
|
|
40
|
+
before(args);
|
|
41
|
+
return original.apply(this, args);
|
|
42
|
+
};
|
|
43
|
+
copyProperties(wrapped, original);
|
|
44
|
+
wrap(wrapped, 'native', before);
|
|
45
|
+
Object.defineProperty(wrapped, BRAND, { value: true });
|
|
46
|
+
object[name] = wrapped;
|
|
47
|
+
}
|
|
48
|
+
/** Own properties of `original` that `wrapped` lacks, non-enumerable ones included. */
|
|
49
|
+
export function copyProperties(wrapped, original) {
|
|
50
|
+
for (const key of Reflect.ownKeys(original)) {
|
|
51
|
+
if (Object.prototype.hasOwnProperty.call(wrapped, key))
|
|
52
|
+
continue;
|
|
53
|
+
const descriptor = Object.getOwnPropertyDescriptor(original, key);
|
|
54
|
+
if (descriptor)
|
|
55
|
+
Object.defineProperty(wrapped, key, descriptor);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const READS = [
|
|
59
|
+
'access',
|
|
60
|
+
'copyFile',
|
|
61
|
+
'lstat',
|
|
62
|
+
'open',
|
|
63
|
+
'opendir',
|
|
64
|
+
'readdir',
|
|
65
|
+
'readFile',
|
|
66
|
+
'readlink',
|
|
67
|
+
'realpath',
|
|
68
|
+
'stat',
|
|
69
|
+
];
|
|
70
|
+
/** Wraps every read entry point of `fs` so `record` sees the path argument; a copied tree is recorded file by file. */
|
|
71
|
+
export function wrapReads(fs, record) {
|
|
72
|
+
const first = (args) => {
|
|
73
|
+
const file = toPath(args[0]);
|
|
74
|
+
if (file !== null)
|
|
75
|
+
record(file);
|
|
76
|
+
};
|
|
77
|
+
const tree = (args) => {
|
|
78
|
+
first(args);
|
|
79
|
+
const file = toPath(args[0]);
|
|
80
|
+
if (file === null)
|
|
81
|
+
return;
|
|
82
|
+
let entries;
|
|
83
|
+
try {
|
|
84
|
+
entries = fs.readdirSync(file, { recursive: true, encoding: 'utf8' });
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
for (const entry of entries)
|
|
90
|
+
record(path.join(file, entry));
|
|
91
|
+
};
|
|
92
|
+
const target = fs;
|
|
93
|
+
const promises = fs.promises;
|
|
94
|
+
for (const name of READS) {
|
|
95
|
+
wrap(target, name, first);
|
|
96
|
+
wrap(target, `${name}Sync`, first);
|
|
97
|
+
wrap(promises, name, first);
|
|
98
|
+
}
|
|
99
|
+
wrap(target, 'existsSync', first);
|
|
100
|
+
wrap(target, 'createReadStream', first);
|
|
101
|
+
wrap(target, 'cp', tree);
|
|
102
|
+
wrap(promises, 'cp', tree);
|
|
103
|
+
wrap(target, 'cpSync', tree);
|
|
104
|
+
}
|
package/dist/git.d.ts
CHANGED
|
@@ -19,3 +19,10 @@ export declare function knownPaths(repo: string): Promise<Set<string>>;
|
|
|
19
19
|
export declare function anchorTree(repo: string, paths: Iterable<string>): Promise<Tree>;
|
|
20
20
|
/** Paths whose blob differs between the trees, including files only present in one of them. */
|
|
21
21
|
export declare function diffTrees(before: Tree, after: Tree): string[];
|
|
22
|
+
/**
|
|
23
|
+
* The blob sha each of `paths` had at `commit`, for those that were tracked
|
|
24
|
+
* there; `null` when git cannot answer. `cat-file` replies one line per request
|
|
25
|
+
* in order, so a path is matched by position and may hold spaces; one holding
|
|
26
|
+
* a newline cannot be requested and is left out.
|
|
27
|
+
*/
|
|
28
|
+
export declare function blobsAt(repo: string, commit: string, paths: readonly string[]): Promise<Map<string, string> | null>;
|
package/dist/git.js
CHANGED
|
@@ -64,7 +64,7 @@ export function knownPaths(repo) {
|
|
|
64
64
|
* untracked files not ignored, so a directory's listing is what `git status`
|
|
65
65
|
* would attribute to it. Index entries whose file is gone are left out.
|
|
66
66
|
*/
|
|
67
|
-
async function listKnown(repo, pathspecs, literal = true) {
|
|
67
|
+
async function listKnown(repo, pathspecs, literal = true, ignored = false) {
|
|
68
68
|
const known = new Set();
|
|
69
69
|
const gone = new Set();
|
|
70
70
|
for (let i = 0; i < pathspecs.length; i += 500) {
|
|
@@ -76,7 +76,7 @@ async function listKnown(repo, pathspecs, literal = true) {
|
|
|
76
76
|
'--cached',
|
|
77
77
|
'--others',
|
|
78
78
|
'--deleted',
|
|
79
|
-
'--exclude-standard',
|
|
79
|
+
...(ignored ? [] : ['--exclude-standard']),
|
|
80
80
|
'--',
|
|
81
81
|
...pathspecs.slice(i, i + 500),
|
|
82
82
|
]);
|
|
@@ -143,7 +143,7 @@ export async function anchorTree(repo, paths) {
|
|
|
143
143
|
!file.startsWith('../') &&
|
|
144
144
|
!path.isAbsolute(file))
|
|
145
145
|
.sort();
|
|
146
|
-
const tree =
|
|
146
|
+
const tree = Object.create(null);
|
|
147
147
|
if (wanted.length === 0)
|
|
148
148
|
return tree;
|
|
149
149
|
const present = new Map();
|
|
@@ -159,7 +159,9 @@ export async function anchorTree(repo, paths) {
|
|
|
159
159
|
for (const [file, stat] of present)
|
|
160
160
|
(stat.isDirectory() ? dirs : others).push(file);
|
|
161
161
|
const known = await listKnown(repo, dirs);
|
|
162
|
-
|
|
162
|
+
// A recorded file counts even when git ignores it: generated code and
|
|
163
|
+
// `.env.local` are inputs the test read, whatever `.gitignore` says.
|
|
164
|
+
for (const file of await listKnown(repo, others, true, true))
|
|
163
165
|
known.add(file);
|
|
164
166
|
const sorted = [...known].sort();
|
|
165
167
|
const files = [];
|
|
@@ -208,11 +210,26 @@ export async function anchorTree(repo, paths) {
|
|
|
208
210
|
modes[index] = indexModes.get(file) ?? '100644';
|
|
209
211
|
});
|
|
210
212
|
}
|
|
211
|
-
|
|
213
|
+
// `hash-object --stdin-paths` takes one path per line and has no `-z`: a
|
|
214
|
+
// path with a newline in it would split into two and shift every sha after
|
|
215
|
+
// it, so such a file gets its own call.
|
|
216
|
+
const batched = [];
|
|
217
|
+
for (const [index, file] of files.entries()) {
|
|
218
|
+
if (!file.includes('\n')) {
|
|
219
|
+
batched.push(index);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const sha = (await git(repo, ['hash-object', '--', file])).trim();
|
|
223
|
+
tree[file] = `${modes[index]} ${sha}`;
|
|
224
|
+
}
|
|
225
|
+
if (batched.length === 0)
|
|
226
|
+
return tree;
|
|
227
|
+
const shas = (await git(repo, ['hash-object', '--stdin-paths'], batched.map((index) => files[index]).join('\n')))
|
|
212
228
|
.trim()
|
|
213
229
|
.split('\n');
|
|
214
|
-
|
|
215
|
-
const
|
|
230
|
+
batched.forEach((index, position) => {
|
|
231
|
+
const file = files[index] ?? '';
|
|
232
|
+
const sha = shas[position];
|
|
216
233
|
if (sha === undefined)
|
|
217
234
|
throw new Error(`git hash-object returned no sha for ${file}`);
|
|
218
235
|
tree[file] = `${modes[index]} ${sha}`;
|
|
@@ -223,12 +240,41 @@ export async function anchorTree(repo, paths) {
|
|
|
223
240
|
export function diffTrees(before, after) {
|
|
224
241
|
const changed = [];
|
|
225
242
|
for (const [file, sha] of Object.entries(after)) {
|
|
226
|
-
if (before[file] !== sha)
|
|
243
|
+
if (!Object.hasOwn(before, file) || before[file] !== sha)
|
|
227
244
|
changed.push(file);
|
|
228
245
|
}
|
|
229
246
|
for (const file of Object.keys(before)) {
|
|
230
|
-
if (!(file
|
|
247
|
+
if (!Object.hasOwn(after, file))
|
|
231
248
|
changed.push(file);
|
|
232
249
|
}
|
|
233
250
|
return changed;
|
|
234
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* The blob sha each of `paths` had at `commit`, for those that were tracked
|
|
254
|
+
* there; `null` when git cannot answer. `cat-file` replies one line per request
|
|
255
|
+
* in order, so a path is matched by position and may hold spaces; one holding
|
|
256
|
+
* a newline cannot be requested and is left out.
|
|
257
|
+
*/
|
|
258
|
+
export async function blobsAt(repo, commit, paths) {
|
|
259
|
+
const named = paths.filter((file) => !file.includes('\n'));
|
|
260
|
+
if (named.length === 0)
|
|
261
|
+
return new Map();
|
|
262
|
+
let output;
|
|
263
|
+
try {
|
|
264
|
+
output = await git(repo, ['cat-file', '--batch-check=%(objectname) %(objecttype)'], named.map((file) => `${commit}:${file}\n`).join(''));
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
const lines = output.split('\n');
|
|
270
|
+
const result = new Map();
|
|
271
|
+
named.forEach((file, index) => {
|
|
272
|
+
const line = lines[index] ?? '';
|
|
273
|
+
if (line.endsWith(' missing'))
|
|
274
|
+
return;
|
|
275
|
+
const [sha, type] = line.split(' ');
|
|
276
|
+
if (type === 'blob' && sha)
|
|
277
|
+
result.set(file, sha);
|
|
278
|
+
});
|
|
279
|
+
return result;
|
|
280
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { ENV_REPO } from './fs-recorder.js';
|
|
3
|
+
import { repoRoot } from './git.js';
|
|
2
4
|
import { compileDependencies, configuredProvider, isTreeSnapshotProvider, isSnapshotRecorder, restoreDependencyCache, ShadowAnalysis, SnapshotRecorder, TreeSnapshotProvider, } from './affected.js';
|
|
3
5
|
import { customSequencer, HashShardSequencer, stableShards, } from './sequencer.js';
|
|
4
6
|
/** Beside this module; `.ts` when the plugin runs from source (its own test fixtures). */
|
|
@@ -13,7 +15,6 @@ function supportsChanged(vitest) {
|
|
|
13
15
|
}
|
|
14
16
|
export default function blacksmith(options = {}) {
|
|
15
17
|
const provider = new TreeSnapshotProvider();
|
|
16
|
-
provider.dependencies = compileDependencies(options.dependencies);
|
|
17
18
|
let changedApplied = false;
|
|
18
19
|
let restored;
|
|
19
20
|
let shadow;
|
|
@@ -39,7 +40,12 @@ export default function blacksmith(options = {}) {
|
|
|
39
40
|
// second plugin instance; configure the provider vitest actually uses.
|
|
40
41
|
const active = configuredProvider(vitest) ?? provider;
|
|
41
42
|
active.vitest = vitest;
|
|
43
|
+
provider.dependencies = compileDependencies(options.dependencies, vitest.config.root);
|
|
42
44
|
active.dependencies = provider.dependencies;
|
|
45
|
+
// Workers drop what they read outside the repository: the anchor
|
|
46
|
+
// cannot cover it, and vitest's record would otherwise keep every
|
|
47
|
+
// temp file a run touched.
|
|
48
|
+
process.env[ENV_REPO] ??= await repoRoot(vitest.config.root);
|
|
43
49
|
// Their sequencer's shards must not depend on which tests the
|
|
44
50
|
// selection kept, or the provider's view of the shard and vitest's
|
|
45
51
|
// would differ.
|
|
@@ -71,6 +77,11 @@ export default function blacksmith(options = {}) {
|
|
|
71
77
|
vitest.logger.warn('[blacksmith] This vitest version does not support running changed tests with Blacksmith');
|
|
72
78
|
}
|
|
73
79
|
}
|
|
80
|
+
// Coverage follows the selection (vitest's `config.related`) rather
|
|
81
|
+
// than asking the provider again with a `changedSince` it ignores.
|
|
82
|
+
if (!options.shadow && vitest.config.changed && supportsChanged(vitest)) {
|
|
83
|
+
vitest.config.coverage.changed = false;
|
|
84
|
+
}
|
|
74
85
|
// Per project, since a project's own `setupFiles` replace the root's;
|
|
75
86
|
// first, so reads by those setup files are recorded too. A browser
|
|
76
87
|
// project has no `node:fs` to wrap.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createRequire, syncBuiltinESMExports } from 'node:module';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { CHILD_RECORD_ENV, isIgnored, wrapReads } from './fs-recorder.js';
|
|
5
|
+
/**
|
|
6
|
+
* Preloaded (`--import`) into every node process and worker thread a test
|
|
7
|
+
* starts: records the modules it loads and the files it reads, and appends
|
|
8
|
+
* them to the file the test's recorder named in the environment. Children of
|
|
9
|
+
* the child inherit the environment and append to the same file.
|
|
10
|
+
*/
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const nodeModule = require('node:module');
|
|
14
|
+
const output = process.env[CHILD_RECORD_ENV];
|
|
15
|
+
if (output) {
|
|
16
|
+
// Our own bookkeeping must not count as a read.
|
|
17
|
+
const { openSync, writeSync } = fs;
|
|
18
|
+
let fd;
|
|
19
|
+
const paths = new Set();
|
|
20
|
+
// Written on the spot, not batched or left to `exit`: a signal
|
|
21
|
+
// (`child.kill()`) or `worker.terminate()` can end this process or thread
|
|
22
|
+
// at any point, with no `exit` event and no later tick.
|
|
23
|
+
const record = (file) => {
|
|
24
|
+
const absolute = path.resolve(file);
|
|
25
|
+
if (isIgnored(absolute) || paths.has(absolute))
|
|
26
|
+
return;
|
|
27
|
+
paths.add(absolute);
|
|
28
|
+
try {
|
|
29
|
+
fd ??= openSync(output, 'a');
|
|
30
|
+
writeSync(fd, `${absolute}\n`);
|
|
31
|
+
}
|
|
32
|
+
catch { }
|
|
33
|
+
};
|
|
34
|
+
wrapReads(fs, record);
|
|
35
|
+
// A loader preloaded before this one (tsx) may already hold the ESM
|
|
36
|
+
// namespace of `node:fs`; resync so its named imports see the wrappers.
|
|
37
|
+
syncBuiltinESMExports();
|
|
38
|
+
// Node 22.15+: every ESM and CJS resolution, including through loaders such as tsx.
|
|
39
|
+
if (typeof nodeModule.registerHooks === 'function') {
|
|
40
|
+
nodeModule.registerHooks({
|
|
41
|
+
resolve(specifier, context, next) {
|
|
42
|
+
const result = next(specifier, context);
|
|
43
|
+
if (result.url.startsWith('file:')) {
|
|
44
|
+
try {
|
|
45
|
+
record(fileURLToPath(result.url));
|
|
46
|
+
}
|
|
47
|
+
catch { }
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
process.on('exit', function blacksmithRecordChild() {
|
|
54
|
+
const script = process.argv[1];
|
|
55
|
+
if (script)
|
|
56
|
+
record(script);
|
|
57
|
+
for (const id of Object.keys(require.cache))
|
|
58
|
+
record(id);
|
|
59
|
+
});
|
|
60
|
+
}
|