@useblacksmith/vitest 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +58 -2
- package/dist/affected.d.ts +124 -0
- package/dist/affected.js +570 -0
- package/dist/bloom.d.ts +23 -0
- package/dist/bloom.js +70 -0
- package/dist/cache.d.ts +49 -0
- package/dist/cache.js +125 -0
- package/dist/git.d.ts +21 -0
- package/dist/git.js +225 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +92 -0
- package/dist/payload.d.ts +56 -0
- package/dist/payload.js +40 -0
- package/dist/record-reads.d.ts +1 -0
- package/dist/record-reads.js +109 -0
- package/dist/reporter.d.ts +29 -0
- package/dist/reporter.js +149 -0
- package/dist/sequencer.d.ts +40 -0
- package/dist/sequencer.js +79 -0
- package/dist/upload.d.ts +17 -0
- package/dist/upload.js +47 -0
- package/package.json +38 -5
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps the dependency record and tree anchor in the Actions cache from
|
|
3
|
+
* inside the run, so a job needs no restore/save steps. Speaks the cache
|
|
4
|
+
* service's twirp API at ACTIONS_RESULTS_URL; on a Blacksmith runner a plain
|
|
5
|
+
* step can reach it with no token because the VM proxy adds one, elsewhere
|
|
6
|
+
* the variable is absent and nothing happens. Every failure here is logged
|
|
7
|
+
* once and never changes the run.
|
|
8
|
+
*/
|
|
9
|
+
export declare const ENV_CACHE_URL = "ACTIONS_RESULTS_URL";
|
|
10
|
+
/** Bundle format; bumped when its shape changes so old entries are missed, not misread. */
|
|
11
|
+
export declare const BUNDLE_VERSION = "blacksmith-vitest-bundle-1";
|
|
12
|
+
export declare const TIMEOUT_MS = 15000;
|
|
13
|
+
/** Files under the cache dir that travel in the bundle. */
|
|
14
|
+
export declare const RECORD_FILE: RegExp;
|
|
15
|
+
export interface CacheKey {
|
|
16
|
+
/** Exact key for this tree; also the save key. */
|
|
17
|
+
key: string;
|
|
18
|
+
/** Prefix shared by every entry for the same shard and project set: the newest one restores. */
|
|
19
|
+
prefix: string;
|
|
20
|
+
}
|
|
21
|
+
export declare const DEFAULT_CACHE_PREFIX = "blacksmith-vitest";
|
|
22
|
+
export declare function cacheKey(input: {
|
|
23
|
+
snapshotVersion: number;
|
|
24
|
+
projects: string[];
|
|
25
|
+
shard: {
|
|
26
|
+
index: number;
|
|
27
|
+
count: number;
|
|
28
|
+
} | null;
|
|
29
|
+
head: string;
|
|
30
|
+
}): CacheKey;
|
|
31
|
+
export type RestoreOutcome = {
|
|
32
|
+
restored: true;
|
|
33
|
+
key: string;
|
|
34
|
+
files: number;
|
|
35
|
+
} | {
|
|
36
|
+
restored: false;
|
|
37
|
+
reason: string;
|
|
38
|
+
};
|
|
39
|
+
/** Fetches the newest bundle under `prefix` (exact `key` first) into `cacheDir`. */
|
|
40
|
+
export declare function restoreBundle(base: string, cacheDir: string, { key, prefix }: CacheKey): Promise<RestoreOutcome>;
|
|
41
|
+
export type SaveOutcome = {
|
|
42
|
+
saved: true;
|
|
43
|
+
bytes: number;
|
|
44
|
+
} | {
|
|
45
|
+
saved: false;
|
|
46
|
+
reason: string;
|
|
47
|
+
};
|
|
48
|
+
/** Uploads the record and anchors under the exact `key`; an existing entry for that key is left alone. */
|
|
49
|
+
export declare function saveBundle(base: string, cacheDir: string, { key }: CacheKey): Promise<SaveOutcome>;
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { readdir, readFile, mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { gunzipSync, gzipSync } from 'node:zlib';
|
|
5
|
+
/**
|
|
6
|
+
* Keeps the dependency record and tree anchor in the Actions cache from
|
|
7
|
+
* inside the run, so a job needs no restore/save steps. Speaks the cache
|
|
8
|
+
* service's twirp API at ACTIONS_RESULTS_URL; on a Blacksmith runner a plain
|
|
9
|
+
* step can reach it with no token because the VM proxy adds one, elsewhere
|
|
10
|
+
* the variable is absent and nothing happens. Every failure here is logged
|
|
11
|
+
* once and never changes the run.
|
|
12
|
+
*/
|
|
13
|
+
export const ENV_CACHE_URL = 'ACTIONS_RESULTS_URL';
|
|
14
|
+
const TWIRP = 'twirp/github.actions.results.api.v1.CacheService/';
|
|
15
|
+
/** Bundle format; bumped when its shape changes so old entries are missed, not misread. */
|
|
16
|
+
export const BUNDLE_VERSION = 'blacksmith-vitest-bundle-1';
|
|
17
|
+
export const TIMEOUT_MS = 15_000;
|
|
18
|
+
/** Files under the cache dir that travel in the bundle. */
|
|
19
|
+
export const RECORD_FILE = /^(dependencies\.json|blacksmith(\.[0-9a-f]{12})?\.json)$/;
|
|
20
|
+
export const DEFAULT_CACHE_PREFIX = 'blacksmith-vitest';
|
|
21
|
+
export function cacheKey(input) {
|
|
22
|
+
const scope = [
|
|
23
|
+
`v${input.snapshotVersion}`,
|
|
24
|
+
input.projects.some(Boolean)
|
|
25
|
+
? createHash('sha1')
|
|
26
|
+
.update(input.projects.join('\n'))
|
|
27
|
+
.digest('hex')
|
|
28
|
+
.slice(0, 12)
|
|
29
|
+
: 'all',
|
|
30
|
+
input.shard ? `${input.shard.index}of${input.shard.count}` : '1of1',
|
|
31
|
+
].join('-');
|
|
32
|
+
const prefix = `${DEFAULT_CACHE_PREFIX}-${scope}-`;
|
|
33
|
+
return { key: `${prefix}${input.head}`, prefix };
|
|
34
|
+
}
|
|
35
|
+
async function twirp(base, method, body) {
|
|
36
|
+
const response = await fetch(new URL(TWIRP + method, base), {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: {
|
|
39
|
+
'content-type': 'application/json',
|
|
40
|
+
'user-agent': '@useblacksmith/vitest',
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify(body),
|
|
43
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
44
|
+
});
|
|
45
|
+
if (!response.ok)
|
|
46
|
+
throw new Error(`${method} ${response.status}`);
|
|
47
|
+
return (await response.json());
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Requests carry both casings: a twirp JSON server accepts proto field names
|
|
51
|
+
* (`restore_keys`, `size_bytes` as an integer) and their lowerCamel aliases;
|
|
52
|
+
* the Blacksmith cache service validates the former and answers in it.
|
|
53
|
+
*/
|
|
54
|
+
function field(record, snake, camel) {
|
|
55
|
+
return record[snake] ?? record[camel];
|
|
56
|
+
}
|
|
57
|
+
/** Fetches the newest bundle under `prefix` (exact `key` first) into `cacheDir`. */
|
|
58
|
+
export async function restoreBundle(base, cacheDir, { key, prefix }) {
|
|
59
|
+
const lookup = await twirp(base, 'GetCacheEntryDownloadURL', {
|
|
60
|
+
key,
|
|
61
|
+
restore_keys: [prefix],
|
|
62
|
+
restoreKeys: [prefix],
|
|
63
|
+
version: BUNDLE_VERSION,
|
|
64
|
+
});
|
|
65
|
+
const url = field(lookup, 'signed_download_url', 'signedDownloadUrl');
|
|
66
|
+
if (!lookup.ok || typeof url !== 'string' || url === '') {
|
|
67
|
+
return { restored: false, reason: `no entry under ${prefix}` };
|
|
68
|
+
}
|
|
69
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
70
|
+
if (!response.ok)
|
|
71
|
+
throw new Error(`download ${response.status}`);
|
|
72
|
+
const bundle = JSON.parse(gunzipSync(Buffer.from(await response.arrayBuffer())).toString('utf8'));
|
|
73
|
+
if (bundle.version !== BUNDLE_VERSION) {
|
|
74
|
+
return { restored: false, reason: `bundle version ${bundle.version}` };
|
|
75
|
+
}
|
|
76
|
+
await mkdir(cacheDir, { recursive: true });
|
|
77
|
+
for (const [name, content] of Object.entries(bundle.files)) {
|
|
78
|
+
if (!RECORD_FILE.test(name))
|
|
79
|
+
continue;
|
|
80
|
+
await writeFile(path.join(cacheDir, name), content);
|
|
81
|
+
}
|
|
82
|
+
const matched = field(lookup, 'matched_key', 'matchedKey');
|
|
83
|
+
return {
|
|
84
|
+
restored: true,
|
|
85
|
+
key: typeof matched === 'string' && matched !== '' ? matched : key,
|
|
86
|
+
files: Object.keys(bundle.files).length,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Uploads the record and anchors under the exact `key`; an existing entry for that key is left alone. */
|
|
90
|
+
export async function saveBundle(base, cacheDir, { key }) {
|
|
91
|
+
const files = {};
|
|
92
|
+
for (const name of await readdir(cacheDir)) {
|
|
93
|
+
if (RECORD_FILE.test(name))
|
|
94
|
+
files[name] = await readFile(path.join(cacheDir, name), 'utf8');
|
|
95
|
+
}
|
|
96
|
+
const body = gzipSync(JSON.stringify({ version: BUNDLE_VERSION, files }));
|
|
97
|
+
const reserve = await twirp(base, 'CreateCacheEntry', {
|
|
98
|
+
key,
|
|
99
|
+
version: BUNDLE_VERSION,
|
|
100
|
+
});
|
|
101
|
+
const url = field(reserve, 'signed_upload_url', 'signedUploadUrl');
|
|
102
|
+
if (!reserve.ok || typeof url !== 'string' || url === '') {
|
|
103
|
+
return { saved: false, reason: `${key} already exists or was refused` };
|
|
104
|
+
}
|
|
105
|
+
const put = await fetch(url, {
|
|
106
|
+
method: 'PUT',
|
|
107
|
+
headers: {
|
|
108
|
+
'x-ms-blob-type': 'BlockBlob',
|
|
109
|
+
'content-type': 'application/octet-stream',
|
|
110
|
+
},
|
|
111
|
+
body,
|
|
112
|
+
signal: AbortSignal.timeout(TIMEOUT_MS * 4),
|
|
113
|
+
});
|
|
114
|
+
if (!put.ok)
|
|
115
|
+
throw new Error(`upload ${put.status}`);
|
|
116
|
+
const done = await twirp(base, 'FinalizeCacheEntryUpload', {
|
|
117
|
+
key,
|
|
118
|
+
version: BUNDLE_VERSION,
|
|
119
|
+
size_bytes: body.byteLength,
|
|
120
|
+
sizeBytes: String(body.byteLength),
|
|
121
|
+
});
|
|
122
|
+
if (!done.ok)
|
|
123
|
+
return { saved: false, reason: `finalize refused ${key}` };
|
|
124
|
+
return { saved: true, bytes: body.byteLength };
|
|
125
|
+
}
|
package/dist/git.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Repository-relative path → `<mode> <sha>` (see `anchorTree`), so a chmod is a change. */
|
|
2
|
+
export type Tree = Record<string, string>;
|
|
3
|
+
export declare function repoRoot(cwd: string): Promise<string>;
|
|
4
|
+
export declare function headSha(repo: string): Promise<string>;
|
|
5
|
+
/** Lockfiles anywhere in the worktree, tracked or not. */
|
|
6
|
+
export declare function lockfilePaths(repo: string, names: Iterable<string>): Promise<string[]>;
|
|
7
|
+
/** Every path git would show for the worktree: tracked and untracked, not ignored. */
|
|
8
|
+
export declare function knownPaths(repo: string): Promise<Set<string>>;
|
|
9
|
+
/**
|
|
10
|
+
* What each path looks like now: a file is `<mode> <blob>` as `git ls-tree`
|
|
11
|
+
* prints it, a symlink hashes its target, a directory hashes its listing (every
|
|
12
|
+
* tracked or untracked non-ignored path under it, so an entry moving in, out or
|
|
13
|
+
* within changes it; an edit to one does not), a submodule stands by its
|
|
14
|
+
* commit. Ignored files and paths that do not exist get no entry, the same
|
|
15
|
+
* absence as before they appeared.
|
|
16
|
+
*
|
|
17
|
+
* e.g. `['src/a.ts', 'fixtures']` → `{ 'src/a.ts': '100644 3b18e5…', fixtures: '040000 9f2c…' }`
|
|
18
|
+
*/
|
|
19
|
+
export declare function anchorTree(repo: string, paths: Iterable<string>): Promise<Tree>;
|
|
20
|
+
/** Paths whose blob differs between the trees, including files only present in one of them. */
|
|
21
|
+
export declare function diffTrees(before: Tree, after: Tree): string[];
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { lstatSync, readlinkSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
function git(cwd, args, input) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const child = spawn('git', args, {
|
|
8
|
+
cwd,
|
|
9
|
+
stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
10
|
+
});
|
|
11
|
+
const stdout = [];
|
|
12
|
+
const stderr = [];
|
|
13
|
+
child.stdout?.on('data', (chunk) => stdout.push(chunk));
|
|
14
|
+
child.stderr?.on('data', (chunk) => stderr.push(chunk));
|
|
15
|
+
child.on('error', reject);
|
|
16
|
+
child.on('close', (code) => {
|
|
17
|
+
if (code === 0)
|
|
18
|
+
resolve(Buffer.concat(stdout).toString('utf8'));
|
|
19
|
+
else {
|
|
20
|
+
reject(new Error(`git ${args.join(' ')} exited with ${code}: ${Buffer.concat(stderr).toString('utf8').trim()}`));
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
if (input !== undefined) {
|
|
24
|
+
child.stdin?.on('error', reject);
|
|
25
|
+
child.stdin?.end(input);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export async function repoRoot(cwd) {
|
|
30
|
+
return (await git(cwd, ['rev-parse', '--show-toplevel'])).trim();
|
|
31
|
+
}
|
|
32
|
+
export async function headSha(repo) {
|
|
33
|
+
return (await git(repo, ['rev-parse', 'HEAD'])).trim();
|
|
34
|
+
}
|
|
35
|
+
/** A dirty submodule is the only directory `git ls-files` names itself; its checked-out commit stands in for a blob sha. */
|
|
36
|
+
async function submoduleHead(repo, dir) {
|
|
37
|
+
try {
|
|
38
|
+
return `160000 ${(await git(path.join(repo, dir), ['rev-parse', 'HEAD'])).trim()}`;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return `160000 dirty:${dir}`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** False where git ignores the executable bit (`core.fileMode`, e.g. FAT or Windows); a file then keeps the mode the index has for it. */
|
|
45
|
+
async function tracksFileMode(repo) {
|
|
46
|
+
try {
|
|
47
|
+
const value = await git(repo, ['config', '--type=bool', 'core.fileMode']);
|
|
48
|
+
return value.trim() !== 'false';
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Lockfiles anywhere in the worktree, tracked or not. */
|
|
55
|
+
export function lockfilePaths(repo, names) {
|
|
56
|
+
return listKnown(repo, [...names].map((name) => `:(glob)**/${name}`), false).then((known) => [...known].sort());
|
|
57
|
+
}
|
|
58
|
+
/** Every path git would show for the worktree: tracked and untracked, not ignored. */
|
|
59
|
+
export function knownPaths(repo) {
|
|
60
|
+
return listKnown(repo, ['.'], false);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Paths git would show for the pathspecs: tracked files still on disk and
|
|
64
|
+
* untracked files not ignored, so a directory's listing is what `git status`
|
|
65
|
+
* would attribute to it. Index entries whose file is gone are left out.
|
|
66
|
+
*/
|
|
67
|
+
async function listKnown(repo, pathspecs, literal = true) {
|
|
68
|
+
const known = new Set();
|
|
69
|
+
const gone = new Set();
|
|
70
|
+
for (let i = 0; i < pathspecs.length; i += 500) {
|
|
71
|
+
const output = await git(repo, [
|
|
72
|
+
...(literal ? ['--literal-pathspecs'] : []),
|
|
73
|
+
'ls-files',
|
|
74
|
+
'-z',
|
|
75
|
+
'-t',
|
|
76
|
+
'--cached',
|
|
77
|
+
'--others',
|
|
78
|
+
'--deleted',
|
|
79
|
+
'--exclude-standard',
|
|
80
|
+
'--',
|
|
81
|
+
...pathspecs.slice(i, i + 500),
|
|
82
|
+
]);
|
|
83
|
+
for (const entry of output.split('\0')) {
|
|
84
|
+
if (entry === '')
|
|
85
|
+
continue;
|
|
86
|
+
const file = entry.slice(2);
|
|
87
|
+
if (entry[0] === 'R')
|
|
88
|
+
gone.add(file);
|
|
89
|
+
else
|
|
90
|
+
known.add(file);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const file of gone)
|
|
94
|
+
known.delete(file);
|
|
95
|
+
return known;
|
|
96
|
+
}
|
|
97
|
+
/** Entries of `sorted` under `dir/`, by binary search for the prefix. */
|
|
98
|
+
function under(sorted, dir) {
|
|
99
|
+
const prefix = `${dir}/`;
|
|
100
|
+
let low = 0;
|
|
101
|
+
let high = sorted.length;
|
|
102
|
+
while (low < high) {
|
|
103
|
+
const mid = (low + high) >>> 1;
|
|
104
|
+
if ((sorted[mid] ?? '') < prefix)
|
|
105
|
+
low = mid + 1;
|
|
106
|
+
else
|
|
107
|
+
high = mid;
|
|
108
|
+
}
|
|
109
|
+
const listing = [];
|
|
110
|
+
for (let i = low; i < sorted.length; i++) {
|
|
111
|
+
const entry = sorted[i] ?? '';
|
|
112
|
+
if (!entry.startsWith(prefix))
|
|
113
|
+
break;
|
|
114
|
+
listing.push(entry);
|
|
115
|
+
}
|
|
116
|
+
return listing;
|
|
117
|
+
}
|
|
118
|
+
const DIRECTORY_MODE = '040000';
|
|
119
|
+
/**
|
|
120
|
+
* What each path looks like now: a file is `<mode> <blob>` as `git ls-tree`
|
|
121
|
+
* prints it, a symlink hashes its target, a directory hashes its listing (every
|
|
122
|
+
* tracked or untracked non-ignored path under it, so an entry moving in, out or
|
|
123
|
+
* within changes it; an edit to one does not), a submodule stands by its
|
|
124
|
+
* commit. Ignored files and paths that do not exist get no entry, the same
|
|
125
|
+
* absence as before they appeared.
|
|
126
|
+
*
|
|
127
|
+
* e.g. `['src/a.ts', 'fixtures']` → `{ 'src/a.ts': '100644 3b18e5…', fixtures: '040000 9f2c…' }`
|
|
128
|
+
*/
|
|
129
|
+
export async function anchorTree(repo, paths) {
|
|
130
|
+
const wanted = [...new Set(paths)]
|
|
131
|
+
.filter((file) => file !== '' &&
|
|
132
|
+
file !== '.' &&
|
|
133
|
+
file !== '..' &&
|
|
134
|
+
!file.startsWith('../') &&
|
|
135
|
+
!path.isAbsolute(file))
|
|
136
|
+
.sort();
|
|
137
|
+
const tree = {};
|
|
138
|
+
if (wanted.length === 0)
|
|
139
|
+
return tree;
|
|
140
|
+
const present = new Map();
|
|
141
|
+
for (const file of wanted) {
|
|
142
|
+
const stat = lstatSync(path.join(repo, file), { throwIfNoEntry: false });
|
|
143
|
+
if (stat !== undefined)
|
|
144
|
+
present.set(file, stat);
|
|
145
|
+
}
|
|
146
|
+
// Directories apart from files: a directory pathspec walks its contents, a
|
|
147
|
+
// file pathspec is a lookup, and a chunk mixing them pays the walk for all.
|
|
148
|
+
const dirs = [];
|
|
149
|
+
const others = [];
|
|
150
|
+
for (const [file, stat] of present)
|
|
151
|
+
(stat.isDirectory() ? dirs : others).push(file);
|
|
152
|
+
const known = await listKnown(repo, dirs);
|
|
153
|
+
for (const file of await listKnown(repo, others))
|
|
154
|
+
known.add(file);
|
|
155
|
+
const sorted = [...known].sort();
|
|
156
|
+
const files = [];
|
|
157
|
+
const modes = [];
|
|
158
|
+
let fileMode;
|
|
159
|
+
for (const [file, stat] of present) {
|
|
160
|
+
if (stat.isDirectory()) {
|
|
161
|
+
tree[file] = known.has(file)
|
|
162
|
+
? await submoduleHead(repo, file)
|
|
163
|
+
: `${DIRECTORY_MODE} ${createHash('sha1').update(under(sorted, file).join('\0')).digest('hex')}`;
|
|
164
|
+
}
|
|
165
|
+
else if (!known.has(file))
|
|
166
|
+
continue;
|
|
167
|
+
else if (stat.isSymbolicLink()) {
|
|
168
|
+
const target = readlinkSync(path.join(repo, file));
|
|
169
|
+
const sha = (await git(repo, ['hash-object', '--stdin'], target)).trim();
|
|
170
|
+
tree[file] = `120000 ${sha}`;
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
files.push(file);
|
|
174
|
+
fileMode ??= await tracksFileMode(repo);
|
|
175
|
+
modes.push(!fileMode ? '' : stat.mode & 0o111 ? '100755' : '100644');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (files.length === 0)
|
|
179
|
+
return tree;
|
|
180
|
+
if (fileMode === false) {
|
|
181
|
+
const indexModes = new Map();
|
|
182
|
+
for (let i = 0; i < files.length; i += 500) {
|
|
183
|
+
const staged = await git(repo, [
|
|
184
|
+
'--literal-pathspecs',
|
|
185
|
+
'ls-files',
|
|
186
|
+
'-z',
|
|
187
|
+
'--stage',
|
|
188
|
+
'--',
|
|
189
|
+
...files.slice(i, i + 500),
|
|
190
|
+
]);
|
|
191
|
+
for (const entry of staged.split('\0')) {
|
|
192
|
+
const tab = entry.indexOf('\t');
|
|
193
|
+
if (tab === -1)
|
|
194
|
+
continue;
|
|
195
|
+
indexModes.set(entry.slice(tab + 1), entry.slice(0, 6));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
files.forEach((file, index) => {
|
|
199
|
+
modes[index] = indexModes.get(file) ?? '100644';
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
const shas = (await git(repo, ['hash-object', '--stdin-paths'], files.join('\n')))
|
|
203
|
+
.trim()
|
|
204
|
+
.split('\n');
|
|
205
|
+
files.forEach((file, index) => {
|
|
206
|
+
const sha = shas[index];
|
|
207
|
+
if (sha === undefined)
|
|
208
|
+
throw new Error(`git hash-object returned no sha for ${file}`);
|
|
209
|
+
tree[file] = `${modes[index]} ${sha}`;
|
|
210
|
+
});
|
|
211
|
+
return tree;
|
|
212
|
+
}
|
|
213
|
+
/** Paths whose blob differs between the trees, including files only present in one of them. */
|
|
214
|
+
export function diffTrees(before, after) {
|
|
215
|
+
const changed = [];
|
|
216
|
+
for (const [file, sha] of Object.entries(after)) {
|
|
217
|
+
if (before[file] !== sha)
|
|
218
|
+
changed.push(file);
|
|
219
|
+
}
|
|
220
|
+
for (const file of Object.keys(before)) {
|
|
221
|
+
if (!(file in after))
|
|
222
|
+
changed.push(file);
|
|
223
|
+
}
|
|
224
|
+
return changed;
|
|
225
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Plugin } from 'vitest/config';
|
|
2
|
+
export { BlacksmithReporter } from './reporter.js';
|
|
3
|
+
export { HashShardSequencer, shardFor } from './sequencer.js';
|
|
4
|
+
export { ShadowAnalysis, type ShadowResult, SNAPSHOT_FILE, type Snapshot, } from './affected.js';
|
|
5
|
+
export type { Payload, RunError, TestResultRecord, TestStatus, } from './payload.js';
|
|
6
|
+
export interface BlacksmithOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Run every test instead of only those affected since the last green run.
|
|
9
|
+
* By default (false) the plugin turns `--changed` on itself, provided the
|
|
10
|
+
* running Vitest routes it through the plugin's VCS provider and records
|
|
11
|
+
* dependencies; otherwise it warns once and every test runs, so an older
|
|
12
|
+
* Vitest never skips tests on a stale record.
|
|
13
|
+
*/
|
|
14
|
+
runAllTests?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Run every test and only *report* what the affected selection would have
|
|
17
|
+
* run: vitest records dependencies but gets no `--changed` and no VCS
|
|
18
|
+
* provider, the shard's cached record is fetched into a temporary directory
|
|
19
|
+
* (vitest's `cacheDir` is left alone) and, when the run ends, the plugin
|
|
20
|
+
* logs how many of the files that ran it would have selected and which
|
|
21
|
+
* failing test files it would have skipped. A green run still saves the record.
|
|
22
|
+
*/
|
|
23
|
+
shadow?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Dependencies the tracking cannot see (files a test reads through a child
|
|
26
|
+
* process, in `globalSetup`, or outside node): globs of test files mapped
|
|
27
|
+
* to the glob(s) of the files they depend on, both relative to the config
|
|
28
|
+
* root. A change to a matching file reruns every test file the key matches.
|
|
29
|
+
* `{ 'tests/*.spec.ts': 'fixtures/**' }`, say.
|
|
30
|
+
*/
|
|
31
|
+
dependencies?: Record<string, string | string[]>;
|
|
32
|
+
}
|
|
33
|
+
export default function blacksmith(options?: BlacksmithOptions): Plugin;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { compileDependencies, configuredProvider, isTreeSnapshotProvider, isSnapshotRecorder, restoreDependencyCache, ShadowAnalysis, SnapshotRecorder, TreeSnapshotProvider, } from './affected.js';
|
|
3
|
+
import { customSequencer, HashShardSequencer, stableShards, } from './sequencer.js';
|
|
4
|
+
export { BlacksmithReporter } from './reporter.js';
|
|
5
|
+
export { HashShardSequencer, shardFor } from './sequencer.js';
|
|
6
|
+
export { ShadowAnalysis, SNAPSHOT_FILE, } from './affected.js';
|
|
7
|
+
/** Beside this module; `.ts` when the plugin runs from source (its own test fixtures). */
|
|
8
|
+
const recordReads = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? './record-reads.ts' : './record-reads.js', import.meta.url));
|
|
9
|
+
/** Whether this Vitest wired up the pieces `--changed` selection relies on. */
|
|
10
|
+
function supportsChanged(vitest) {
|
|
11
|
+
const { vcs } = vitest;
|
|
12
|
+
const experimental = vitest.config.experimental;
|
|
13
|
+
return (typeof vcs?.findChangedFiles === 'function' &&
|
|
14
|
+
experimental?.recordDependencies === true &&
|
|
15
|
+
isTreeSnapshotProvider(experimental.vcsProvider));
|
|
16
|
+
}
|
|
17
|
+
export default function blacksmith(options = {}) {
|
|
18
|
+
const provider = new TreeSnapshotProvider();
|
|
19
|
+
provider.dependencies = compileDependencies(options.dependencies);
|
|
20
|
+
let changedApplied = false;
|
|
21
|
+
let restored;
|
|
22
|
+
let shadow;
|
|
23
|
+
return {
|
|
24
|
+
name: 'blacksmith',
|
|
25
|
+
config(userConfig) {
|
|
26
|
+
// Vite merges this over the user's config, so a sequencer the config
|
|
27
|
+
// names is kept; `configureVitest` pins its shard assignment.
|
|
28
|
+
const theirs = customSequencer(userConfig.test
|
|
29
|
+
?.sequence?.sequencer);
|
|
30
|
+
return {
|
|
31
|
+
test: {
|
|
32
|
+
experimental: options.shadow
|
|
33
|
+
? { recordDependencies: true }
|
|
34
|
+
: { recordDependencies: true, vcsProvider: provider },
|
|
35
|
+
...(theirs ? {} : { sequence: { sequencer: HashShardSequencer } }),
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
},
|
|
39
|
+
async configureVitest({ vitest, project }) {
|
|
40
|
+
// Vitest loads the root config's provider. A project with its own
|
|
41
|
+
// `root` re-evaluates this config file, so this hook may run from a
|
|
42
|
+
// second plugin instance; configure the provider vitest actually uses.
|
|
43
|
+
const active = configuredProvider(vitest) ?? provider;
|
|
44
|
+
active.vitest = vitest;
|
|
45
|
+
active.dependencies = provider.dependencies;
|
|
46
|
+
// Their sequencer's shards must not depend on which tests the
|
|
47
|
+
// selection kept, or the provider's view of the shard and vitest's
|
|
48
|
+
// would differ.
|
|
49
|
+
if (customSequencer(vitest.config.sequence.sequencer)) {
|
|
50
|
+
vitest.config.sequence.sequencer = stableShards(vitest.config.sequence.sequencer);
|
|
51
|
+
}
|
|
52
|
+
if (options.shadow) {
|
|
53
|
+
if (vitest.config.changed) {
|
|
54
|
+
vitest.logger.warn('[blacksmith] shadow mode runs every test; ignoring --changed');
|
|
55
|
+
vitest.config.changed = false;
|
|
56
|
+
}
|
|
57
|
+
shadow ??= new ShadowAnalysis(vitest, active.dependencies);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
restored ??= restoreDependencyCache(vitest);
|
|
61
|
+
await restored;
|
|
62
|
+
}
|
|
63
|
+
if (!options.shadow &&
|
|
64
|
+
!options.runAllTests &&
|
|
65
|
+
!vitest.config.changed &&
|
|
66
|
+
!changedApplied) {
|
|
67
|
+
changedApplied = true;
|
|
68
|
+
if (supportsChanged(vitest)) {
|
|
69
|
+
vitest.config.changed = true;
|
|
70
|
+
// An empty selection is the success case here, not a misconfigured run.
|
|
71
|
+
vitest.config.passWithNoTests = true;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
vitest.logger.warn('[blacksmith] This vitest version does not support running changed tests with Blacksmith');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Per project, since a project's own `setupFiles` replace the root's;
|
|
78
|
+
// first, so reads by those setup files are recorded too. A browser
|
|
79
|
+
// project has no `node:fs` to wrap.
|
|
80
|
+
if (!project.isBrowserEnabled()) {
|
|
81
|
+
project.config.setupFiles = [
|
|
82
|
+
recordReads,
|
|
83
|
+
...project.config.setupFiles.filter((file) => file !== recordReads),
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
const reporters = vitest.config.reporters;
|
|
87
|
+
if (!reporters.some(isSnapshotRecorder)) {
|
|
88
|
+
reporters.push(new SnapshotRecorder(active, { shadow }));
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export type TestStatus = 'passed' | 'failed' | 'skipped';
|
|
2
|
+
export interface TestError {
|
|
3
|
+
message: string;
|
|
4
|
+
stack: string;
|
|
5
|
+
}
|
|
6
|
+
export interface TestResultRecord {
|
|
7
|
+
/** Test file, relative to the repository when the run is inside GITHUB_WORKSPACE */
|
|
8
|
+
file: string;
|
|
9
|
+
/** Vitest project the test ran in; null for the root project */
|
|
10
|
+
project: string | null;
|
|
11
|
+
/** Enclosing describe blocks, outermost first */
|
|
12
|
+
suite: string[];
|
|
13
|
+
name: string;
|
|
14
|
+
status: TestStatus;
|
|
15
|
+
/** null for tests that never ran (skipped, todo) */
|
|
16
|
+
duration_ms: number | null;
|
|
17
|
+
started_at: string | null;
|
|
18
|
+
retries: number;
|
|
19
|
+
flaky: boolean;
|
|
20
|
+
error?: TestError;
|
|
21
|
+
}
|
|
22
|
+
/** A failure outside any test: a module that threw while being imported or collected, or an unhandled error */
|
|
23
|
+
export interface RunError {
|
|
24
|
+
/** null when the error is not attributable to a test file */
|
|
25
|
+
file: string | null;
|
|
26
|
+
message: string;
|
|
27
|
+
stack: string;
|
|
28
|
+
}
|
|
29
|
+
export interface Payload {
|
|
30
|
+
framework: 'vitest';
|
|
31
|
+
framework_version: string;
|
|
32
|
+
reporter_version: string;
|
|
33
|
+
run: {
|
|
34
|
+
started_at: string;
|
|
35
|
+
finished_at: string;
|
|
36
|
+
reason: 'passed' | 'failed' | 'interrupted';
|
|
37
|
+
};
|
|
38
|
+
results: TestResultRecord[];
|
|
39
|
+
errors: RunError[];
|
|
40
|
+
/** true when results or errors beyond the count or byte caps were dropped */
|
|
41
|
+
truncated: boolean;
|
|
42
|
+
}
|
|
43
|
+
export declare const MAX_RESULTS = 50000;
|
|
44
|
+
export declare const MAX_PAYLOAD_BYTES: number;
|
|
45
|
+
export declare const MAX_ERROR_MESSAGE_CHARS = 4096;
|
|
46
|
+
export declare const MAX_ERROR_STACK_CHARS = 16384;
|
|
47
|
+
export declare function truncate(text: string, max: number): string;
|
|
48
|
+
/**
|
|
49
|
+
* Keeps results in order until either cap is hit, then errors in whatever
|
|
50
|
+
* budget is left, so a payload never serializes past MAX_PAYLOAD_BYTES of
|
|
51
|
+
* JSON (the gzip input, which is also the backend's memory ceiling).
|
|
52
|
+
*/
|
|
53
|
+
export declare function buildPayload(results: TestResultRecord[], errors: RunError[], run: Payload['run'], versions: {
|
|
54
|
+
framework: string;
|
|
55
|
+
reporter: string;
|
|
56
|
+
}, maxBytes?: number): Payload;
|
package/dist/payload.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const MAX_RESULTS = 50_000;
|
|
2
|
+
export const MAX_PAYLOAD_BYTES = 32 * 1024 * 1024;
|
|
3
|
+
export const MAX_ERROR_MESSAGE_CHARS = 4_096;
|
|
4
|
+
export const MAX_ERROR_STACK_CHARS = 16_384;
|
|
5
|
+
export function truncate(text, max) {
|
|
6
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Keeps results in order until either cap is hit, then errors in whatever
|
|
10
|
+
* budget is left, so a payload never serializes past MAX_PAYLOAD_BYTES of
|
|
11
|
+
* JSON (the gzip input, which is also the backend's memory ceiling).
|
|
12
|
+
*/
|
|
13
|
+
export function buildPayload(results, errors, run, versions, maxBytes = MAX_PAYLOAD_BYTES) {
|
|
14
|
+
let bytes = 0;
|
|
15
|
+
let truncated = false;
|
|
16
|
+
const keep = (items, max) => {
|
|
17
|
+
const kept = [];
|
|
18
|
+
for (const item of items) {
|
|
19
|
+
const size = JSON.stringify(item).length + 1;
|
|
20
|
+
if (kept.length >= max || bytes + size > maxBytes) {
|
|
21
|
+
truncated = true;
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
bytes += size;
|
|
25
|
+
kept.push(item);
|
|
26
|
+
}
|
|
27
|
+
return kept;
|
|
28
|
+
};
|
|
29
|
+
const keptResults = keep(results, MAX_RESULTS);
|
|
30
|
+
const keptErrors = keep(errors, MAX_RESULTS);
|
|
31
|
+
return {
|
|
32
|
+
framework: 'vitest',
|
|
33
|
+
framework_version: versions.framework,
|
|
34
|
+
reporter_version: versions.reporter,
|
|
35
|
+
run,
|
|
36
|
+
results: keptResults,
|
|
37
|
+
errors: keptErrors,
|
|
38
|
+
truncated,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|