@useblacksmith/vitest 0.0.0-stage → 0.2.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.
@@ -0,0 +1,109 @@
1
+ import { createRequire, syncBuiltinESMExports } from 'node:module';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ /**
5
+ * Setup file the plugin puts first in every worker: each `fs` read lands in
6
+ * the dependency set Vitest keeps for the test file being run, beside its
7
+ * imports, so a fixture, a listed directory or a probed-but-missing file
8
+ * reruns the test when it changes. The wrapped functions stay on the shared
9
+ * `fs` object across the files a worker runs; the set is looked up per call,
10
+ * so a read is charged to whichever file is current.
11
+ */
12
+ // Loaded with `require`, not `import`: under `vmThreads`/`vmForks` an import
13
+ // would build this context's `node:fs` module namespace now, from the
14
+ // functions as they are before wrapping, and a test file's own
15
+ // `import { readFileSync } from 'node:fs'` would get that snapshot.
16
+ const fs = createRequire(import.meta.url)('node:fs');
17
+ const BRAND = Symbol.for('blacksmith.recordReads');
18
+ const CONTEXT = Symbol.for('blacksmith.recordReads.context');
19
+ // `vmThreads`/`vmForks` evaluate this file once per test context while the
20
+ // builtin `fs` object is shared, so the wrappers read the worker state through
21
+ // a slot the latest evaluation points at the current context's global.
22
+ const shared = fs;
23
+ shared[CONTEXT] = globalThis;
24
+ function toPath(target) {
25
+ if (typeof target === 'string')
26
+ return target;
27
+ if (Buffer.isBuffer(target))
28
+ return target.toString();
29
+ if (target instanceof URL) {
30
+ try {
31
+ return fileURLToPath(target);
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ return null;
38
+ }
39
+ function record(target) {
40
+ const dependencies = shared[CONTEXT]
41
+ ?.__vitest_worker__?.dependencies;
42
+ if (!dependencies)
43
+ return;
44
+ const file = toPath(target);
45
+ if (file === null)
46
+ return;
47
+ const absolute = path.resolve(file);
48
+ if (absolute.split(path.sep).includes('node_modules'))
49
+ return;
50
+ dependencies.add(absolute);
51
+ }
52
+ /** A recursive copy reads every file below its source. */
53
+ function recordTree(target) {
54
+ record(target);
55
+ const file = toPath(target);
56
+ if (file === null)
57
+ return;
58
+ let entries;
59
+ try {
60
+ entries = fs.readdirSync(file, { recursive: true, encoding: 'utf8' });
61
+ }
62
+ catch {
63
+ return;
64
+ }
65
+ for (const entry of entries)
66
+ record(path.join(file, entry));
67
+ }
68
+ function wrap(object, name, recorder = record) {
69
+ const original = object[name];
70
+ if (typeof original !== 'function' || BRAND in original)
71
+ return;
72
+ const wrapped = function (...args) {
73
+ recorder(args[0]);
74
+ return original.apply(this, args);
75
+ };
76
+ // `realpathSync.native` and friends live on the function.
77
+ Object.assign(wrapped, original);
78
+ wrap(wrapped, 'native', recorder);
79
+ Object.defineProperty(wrapped, BRAND, { value: true });
80
+ object[name] = wrapped;
81
+ }
82
+ const READS = [
83
+ 'access',
84
+ 'copyFile',
85
+ 'lstat',
86
+ 'open',
87
+ 'opendir',
88
+ 'readdir',
89
+ 'readFile',
90
+ 'readlink',
91
+ 'realpath',
92
+ 'stat',
93
+ ];
94
+ for (const name of READS) {
95
+ wrap(fs, name);
96
+ wrap(fs, `${name}Sync`);
97
+ wrap(fs.promises, name);
98
+ }
99
+ wrap(fs, 'existsSync');
100
+ wrap(fs, 'createReadStream');
101
+ for (const object of [fs, fs.promises]) {
102
+ wrap(object, 'cp', recordTree);
103
+ }
104
+ wrap(fs, 'cpSync', recordTree);
105
+ // `import { readFileSync } from 'node:fs'` binds the builtin's ESM namespace,
106
+ // which only sees the wrapped functions once the namespace is resynced; the
107
+ // vm pools keep their own per-context namespaces and expose their resync.
108
+ const required = globalThis.__vitest_required__;
109
+ (required?.syncBuiltinESMExports ?? syncBuiltinESMExports)();
@@ -0,0 +1,29 @@
1
+ import type { Reporter, SerializedError, TestModule, TestRunEndReason, Vitest } from 'vitest/node';
2
+ import { type RunError, type TestResultRecord } from './payload.js';
3
+ export declare const ENV_BACKEND_URL = "BLACKSMITH_BACKEND_URL";
4
+ export declare const ENV_TOKEN = "BLACKSMITH_JOB_HISTORY_TOKEN";
5
+ export declare const RESULTS_PATH = "/api/jobs/test-results";
6
+ /**
7
+ * Marks a reporter as ours across package copies: a workspace whose root and
8
+ * project resolve two installs of this package has two `BlacksmithReporter`
9
+ * classes, and `instanceof` would let both register.
10
+ */
11
+ export declare const REPORTER_BRAND: unique symbol;
12
+ export declare function isBlacksmithReporter(reporter: unknown): boolean;
13
+ /**
14
+ * Sends every test's outcome and duration to Blacksmith in one request when
15
+ * the run ends. Does nothing unless the runner injected BLACKSMITH_BACKEND_URL
16
+ * and BLACKSMITH_JOB_HISTORY_TOKEN, and never changes the run's outcome.
17
+ */
18
+ export declare class BlacksmithReporter implements Reporter {
19
+ readonly [REPORTER_BRAND] = true;
20
+ private vitest;
21
+ private startedAt;
22
+ onInit(vitest: Vitest): void;
23
+ onTestRunStart(): void;
24
+ onTestRunEnd(testModules: ReadonlyArray<TestModule>, unhandledErrors: ReadonlyArray<SerializedError>, reason: TestRunEndReason): Promise<void>;
25
+ private report;
26
+ }
27
+ export declare function collectResults(testModules: ReadonlyArray<TestModule>, workspace: string | undefined): TestResultRecord[];
28
+ /** Errors a module raised while being imported or collected, outside any test (a syntax error, a top-level throw). */
29
+ export declare function collectModuleErrors(testModules: ReadonlyArray<TestModule>, workspace: string | undefined): RunError[];
@@ -0,0 +1,149 @@
1
+ import { createRequire } from 'node:module';
2
+ import path from 'node:path';
3
+ import { buildPayload, MAX_ERROR_MESSAGE_CHARS, MAX_ERROR_STACK_CHARS, truncate, } from './payload.js';
4
+ import { upload } from './upload.js';
5
+ export const ENV_BACKEND_URL = 'BLACKSMITH_BACKEND_URL';
6
+ export const ENV_TOKEN = 'BLACKSMITH_JOB_HISTORY_TOKEN';
7
+ export const RESULTS_PATH = '/api/jobs/test-results';
8
+ /**
9
+ * Marks a reporter as ours across package copies: a workspace whose root and
10
+ * project resolve two installs of this package has two `BlacksmithReporter`
11
+ * classes, and `instanceof` would let both register.
12
+ */
13
+ export const REPORTER_BRAND = Symbol.for('@useblacksmith/vitest:reporter');
14
+ export function isBlacksmithReporter(reporter) {
15
+ return (typeof reporter === 'object' &&
16
+ reporter !== null &&
17
+ REPORTER_BRAND in reporter);
18
+ }
19
+ const { version: reporterVersion } = createRequire(import.meta.url)('../package.json');
20
+ /**
21
+ * Sends every test's outcome and duration to Blacksmith in one request when
22
+ * the run ends. Does nothing unless the runner injected BLACKSMITH_BACKEND_URL
23
+ * and BLACKSMITH_JOB_HISTORY_TOKEN, and never changes the run's outcome.
24
+ */
25
+ export class BlacksmithReporter {
26
+ [REPORTER_BRAND] = true;
27
+ vitest;
28
+ startedAt = new Date();
29
+ onInit(vitest) {
30
+ this.vitest = vitest;
31
+ }
32
+ onTestRunStart() {
33
+ this.startedAt = new Date();
34
+ }
35
+ async onTestRunEnd(testModules, unhandledErrors, reason) {
36
+ const backendUrl = process.env[ENV_BACKEND_URL];
37
+ const token = process.env[ENV_TOKEN];
38
+ if (!backendUrl || !token)
39
+ return;
40
+ try {
41
+ await this.report(backendUrl, token, testModules, unhandledErrors, reason);
42
+ }
43
+ catch (error) {
44
+ this.vitest.logger.warn(`[blacksmith] could not report test results: ${error instanceof Error ? error.message : String(error)}`);
45
+ }
46
+ }
47
+ async report(backendUrl, token, testModules, unhandledErrors, reason) {
48
+ const workspace = process.env.GITHUB_WORKSPACE;
49
+ const results = collectResults(testModules, workspace);
50
+ const errors = [
51
+ ...collectModuleErrors(testModules, workspace),
52
+ ...unhandledErrors.map((error) => toRunError(null, error)),
53
+ ];
54
+ if (results.length === 0 && errors.length === 0)
55
+ return;
56
+ const payload = buildPayload(results, errors, {
57
+ started_at: this.startedAt.toISOString(),
58
+ finished_at: new Date().toISOString(),
59
+ reason,
60
+ }, { framework: this.vitest.version, reporter: reporterVersion });
61
+ const outcome = await upload(`${backendUrl.replace(/\/+$/, '')}${RESULTS_PATH}`, token, `blacksmith-vitest/${reporterVersion}`, JSON.stringify(payload));
62
+ if (!outcome.ok) {
63
+ throw new Error(outcome.error);
64
+ }
65
+ const counts = [
66
+ `${payload.results.length} test results`,
67
+ payload.errors.length > 0 ? `${payload.errors.length} errors` : '',
68
+ payload.truncated ? '(truncated)' : '',
69
+ ];
70
+ this.vitest.logger.log(`[blacksmith] reported ${counts.filter(Boolean).join(' ')}`);
71
+ }
72
+ }
73
+ export function collectResults(testModules, workspace) {
74
+ const records = [];
75
+ for (const testModule of testModules) {
76
+ const file = relativeFile(testModule, workspace);
77
+ const project = testModule.project.name || null;
78
+ for (const testCase of testModule.children.allTests()) {
79
+ const result = testCase.result();
80
+ if (result.state === 'pending')
81
+ continue;
82
+ records.push(toRecord(testCase, file, project, result.state));
83
+ }
84
+ }
85
+ return records;
86
+ }
87
+ /** Errors a module raised while being imported or collected, outside any test (a syntax error, a top-level throw). */
88
+ export function collectModuleErrors(testModules, workspace) {
89
+ const errors = [];
90
+ for (const testModule of testModules) {
91
+ const file = relativeFile(testModule, workspace);
92
+ for (const error of testModule.errors()) {
93
+ errors.push(toRunError(file, error));
94
+ }
95
+ }
96
+ return errors;
97
+ }
98
+ /**
99
+ * Paths are repo-relative when the test file is inside the checkout (a
100
+ * monorepo package's vitest root is not the repo root), else Vitest's
101
+ * root-relative id.
102
+ */
103
+ function relativeFile(testModule, workspace) {
104
+ if (workspace) {
105
+ const relative = path.relative(workspace, testModule.moduleId);
106
+ if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
107
+ return relative.split(path.sep).join('/');
108
+ }
109
+ }
110
+ return testModule.relativeModuleId;
111
+ }
112
+ function toRecord(testCase, file, project, status) {
113
+ const diagnostic = testCase.diagnostic();
114
+ const record = {
115
+ file,
116
+ project,
117
+ suite: suiteChain(testCase),
118
+ name: testCase.name,
119
+ status,
120
+ duration_ms: diagnostic?.duration ?? null,
121
+ started_at: diagnostic
122
+ ? new Date(diagnostic.startTime).toISOString()
123
+ : null,
124
+ retries: diagnostic?.retryCount ?? 0,
125
+ flaky: diagnostic?.flaky ?? false,
126
+ };
127
+ const error = status === 'failed' ? testCase.result().errors?.[0] : undefined;
128
+ if (error) {
129
+ record.error = {
130
+ message: truncate(error.message ?? '', MAX_ERROR_MESSAGE_CHARS),
131
+ stack: truncate(error.stack ?? '', MAX_ERROR_STACK_CHARS),
132
+ };
133
+ }
134
+ return record;
135
+ }
136
+ function toRunError(file, error) {
137
+ return {
138
+ file,
139
+ message: truncate(error.message ?? '', MAX_ERROR_MESSAGE_CHARS),
140
+ stack: truncate(error.stack ?? '', MAX_ERROR_STACK_CHARS),
141
+ };
142
+ }
143
+ function suiteChain(testCase) {
144
+ const chain = [];
145
+ for (let parent = testCase.parent; parent.type === 'suite'; parent = parent.parent) {
146
+ chain.unshift(parent.name);
147
+ }
148
+ return chain;
149
+ }
@@ -0,0 +1,40 @@
1
+ import { BaseSequencer, type ResolvedConfig, type TestSpecification, type Vitest } from 'vitest/node';
2
+ declare const BRAND: unique symbol;
3
+ type SequencerClass = ResolvedConfig['sequence']['sequencer'];
4
+ /**
5
+ * The name of the configured sequencer when it is not Blacksmith's (nor a
6
+ * subclass of it), `null` when it is. Another plugin or the config may have
7
+ * replaced `sequence.sequencer` after this plugin's `config()` ran.
8
+ */
9
+ export declare function customSequencer(sequencer: unknown): string | null;
10
+ /** Zero-based shard owning `relativePath`; depends only on the path and `count`, never on the other test files. */
11
+ export declare function shardFor(relativePath: string, count: number): number;
12
+ export declare function inShard(root: string, moduleId: string, shard: {
13
+ index: number;
14
+ count: number;
15
+ } | undefined): boolean;
16
+ /**
17
+ * Replaces vitest's contiguous slices: a test stays in the same shard when
18
+ * other tests are added, removed or filtered out by `--changed`, so each
19
+ * shard's dependency record only ever has to vouch for its own tests.
20
+ */
21
+ /**
22
+ * Vitest hands `shard()` the specs `--changed` kept, so a sequencer that
23
+ * slices by position moves tests between shards as the selection changes,
24
+ * and a test two shards disagree about can run on neither. The wrapper takes
25
+ * the sequencer's own assignment over the full test list, as on a full run,
26
+ * and keeps it whatever the selection; `sort()` stays the sequencer's.
27
+ */
28
+ export declare function stableShards(Theirs: SequencerClass): SequencerClass;
29
+ /**
30
+ * Which tests this shard runs, independent of the selection: by path hash
31
+ * under Blacksmith's sequencer, by the wrapped sequencer's own `shard()`
32
+ * over the full test list otherwise.
33
+ */
34
+ export declare function shardOwner(vitest: Vitest): Promise<(moduleId: string) => boolean>;
35
+ export declare class HashShardSequencer extends BaseSequencer {
36
+ /** Marks this class and its subclasses, across copies of the module. */
37
+ static readonly [BRAND] = true;
38
+ shard(files: TestSpecification[]): Promise<TestSpecification[]>;
39
+ }
40
+ export {};
@@ -0,0 +1,79 @@
1
+ import { createHash } from 'node:crypto';
2
+ import path from 'node:path';
3
+ import { BaseSequencer, } from 'vitest/node';
4
+ const BRAND = Symbol.for('blacksmith.vitest.sequencer');
5
+ const WRAPPED = Symbol.for('blacksmith.vitest.sequencer.wrapped');
6
+ /**
7
+ * The name of the configured sequencer when it is not Blacksmith's (nor a
8
+ * subclass of it), `null` when it is. Another plugin or the config may have
9
+ * replaced `sequence.sequencer` after this plugin's `config()` ran.
10
+ */
11
+ export function customSequencer(sequencer) {
12
+ if (typeof sequencer !== 'function')
13
+ return null;
14
+ if (sequencer[BRAND] === true)
15
+ return null;
16
+ return sequencer.name || 'anonymous sequencer';
17
+ }
18
+ /** Zero-based shard owning `relativePath`; depends only on the path and `count`, never on the other test files. */
19
+ export function shardFor(relativePath, count) {
20
+ const digest = createHash('sha1')
21
+ .update(relativePath.split(path.sep).join('/'))
22
+ .digest();
23
+ return digest.readUInt32BE(0) % count;
24
+ }
25
+ export function inShard(root, moduleId, shard) {
26
+ if (!shard)
27
+ return true;
28
+ return (shardFor(path.relative(root, moduleId), shard.count) === shard.index - 1);
29
+ }
30
+ /**
31
+ * Replaces vitest's contiguous slices: a test stays in the same shard when
32
+ * other tests are added, removed or filtered out by `--changed`, so each
33
+ * shard's dependency record only ever has to vouch for its own tests.
34
+ */
35
+ /**
36
+ * Vitest hands `shard()` the specs `--changed` kept, so a sequencer that
37
+ * slices by position moves tests between shards as the selection changes,
38
+ * and a test two shards disagree about can run on neither. The wrapper takes
39
+ * the sequencer's own assignment over the full test list, as on a full run,
40
+ * and keeps it whatever the selection; `sort()` stays the sequencer's.
41
+ */
42
+ export function stableShards(Theirs) {
43
+ return class extends Theirs {
44
+ static [BRAND] = true;
45
+ static [WRAPPED] = Theirs;
46
+ #vitest;
47
+ constructor(vitest) {
48
+ super(vitest);
49
+ this.#vitest = vitest;
50
+ }
51
+ shard = async (files) => {
52
+ const owns = await shardOwner(this.#vitest);
53
+ return files.filter((file) => owns(file.moduleId));
54
+ };
55
+ };
56
+ }
57
+ /**
58
+ * Which tests this shard runs, independent of the selection: by path hash
59
+ * under Blacksmith's sequencer, by the wrapped sequencer's own `shard()`
60
+ * over the full test list otherwise.
61
+ */
62
+ export async function shardOwner(vitest) {
63
+ const { root, shard } = vitest.config;
64
+ if (!shard)
65
+ return () => true;
66
+ const Theirs = vitest.config.sequence.sequencer[WRAPPED];
67
+ if (!Theirs)
68
+ return (moduleId) => inShard(root, moduleId, shard);
69
+ const owned = new Set((await new Theirs(vitest).shard(await vitest.globTestSpecifications())).map((specification) => specification.moduleId));
70
+ return (moduleId) => owned.has(moduleId);
71
+ }
72
+ export class HashShardSequencer extends BaseSequencer {
73
+ /** Marks this class and its subclasses, across copies of the module. */
74
+ static [BRAND] = true;
75
+ async shard(files) {
76
+ const { root, shard } = this.ctx.config;
77
+ return files.filter((file) => inShard(root, file.moduleId, shard));
78
+ }
79
+ }
@@ -0,0 +1,17 @@
1
+ export declare const REQUEST_TIMEOUT_MS = 10000;
2
+ export declare const ATTEMPTS = 3;
3
+ export declare const BACKOFF_MS: number[];
4
+ export type UploadOutcome = {
5
+ ok: true;
6
+ status: number;
7
+ bytes: number;
8
+ } | {
9
+ ok: false;
10
+ status: number | null;
11
+ error: string;
12
+ };
13
+ /**
14
+ * One gzipped bearer POST, retried on network errors and 5xx only; a 4xx is
15
+ * final because the same body would be rejected again.
16
+ */
17
+ export declare function upload(url: string, token: string, userAgent: string, json: string, sleep?: (ms: number) => Promise<void>): Promise<UploadOutcome>;
package/dist/upload.js ADDED
@@ -0,0 +1,47 @@
1
+ import { gzipSync } from 'node:zlib';
2
+ export const REQUEST_TIMEOUT_MS = 10_000;
3
+ export const ATTEMPTS = 3;
4
+ export const BACKOFF_MS = [1_000, 2_000];
5
+ /**
6
+ * One gzipped bearer POST, retried on network errors and 5xx only; a 4xx is
7
+ * final because the same body would be rejected again.
8
+ */
9
+ export async function upload(url, token, userAgent, json, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
10
+ const body = gzipSync(json);
11
+ let last = { ok: false, status: null, error: 'not attempted' };
12
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
13
+ if (attempt > 0)
14
+ await sleep(BACKOFF_MS[attempt - 1] ?? 0);
15
+ try {
16
+ const response = await fetch(url, {
17
+ method: 'POST',
18
+ headers: {
19
+ authorization: `Bearer ${token}`,
20
+ 'content-type': 'application/json',
21
+ 'content-encoding': 'gzip',
22
+ 'user-agent': userAgent,
23
+ },
24
+ body,
25
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
26
+ });
27
+ if (response.ok) {
28
+ return { ok: true, status: response.status, bytes: body.byteLength };
29
+ }
30
+ last = {
31
+ ok: false,
32
+ status: response.status,
33
+ error: `HTTP ${response.status}`,
34
+ };
35
+ if (response.status < 500)
36
+ return last;
37
+ }
38
+ catch (error) {
39
+ last = {
40
+ ok: false,
41
+ status: null,
42
+ error: error instanceof Error ? error.message : String(error),
43
+ };
44
+ }
45
+ }
46
+ return last;
47
+ }
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
- "name": "@useblacksmith/vitest",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@useblacksmith/vitest",
3
+ "version": "0.2.0",
4
+ "description": "Reports Vitest test results to Blacksmith and runs only the tests affected since the last run",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/useblacksmith/vitest.git"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.build.json",
22
+ "prepack": "npm run build",
23
+ "check:types": "tsc --noEmit",
24
+ "test": "vitest run"
25
+ },
26
+ "peerDependencies": {
27
+ "vitest": ">=5.1.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^26.6.6",
31
+ "@types/picomatch": "^4.0.3",
32
+ "typescript": "^7.0.2",
33
+ "vite": "^8.3.5",
34
+ "vitest": "^5.1.0"
35
+ },
36
+ "dependencies": {
37
+ "picomatch": "4.0.7"
38
+ }
39
+ }