@useblacksmith/vitest 0.2.0 → 0.3.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 CHANGED
@@ -24,6 +24,23 @@ With the plugin, vitest only runs tests impacted by changes since the last succe
24
24
 
25
25
  Requires Vitest 5.1 or newer.
26
26
 
27
+ ### Vite+
28
+
29
+ Vite+ runs Vitest under `vp test`, so the plugin is loaded from the shared `vite.config.ts`:
30
+
31
+ ```ts
32
+ // vite.config.ts
33
+ import 'vite-plus/test/config'
34
+ import { defineConfig } from 'vite-plus'
35
+ import blacksmith from '@useblacksmith/vitest'
36
+
37
+ export default defineConfig({
38
+ plugins: [blacksmith()],
39
+ })
40
+ ```
41
+
42
+ `vp test run` then runs the affected selection, including with `--shard`, test file filters, and `--config <path> --dir .` from a workspace package. Requires `vite-plus` 1.5.0 or newer, the first release that ships Vitest 5.1.
43
+
27
44
  ## Running locally
28
45
 
29
46
  The same selection applies locally: a run reruns what changed on disk since the last successful run, committed or not. To run every test locally and select only in CI:
package/dist/git.js CHANGED
@@ -116,6 +116,15 @@ function under(sorted, dir) {
116
116
  return listing;
117
117
  }
118
118
  const DIRECTORY_MODE = '040000';
119
+ /** A path nothing could read, e.g. a component over NAME_MAX or a NUL byte, is as absent as a missing one. */
120
+ function lstat(file) {
121
+ try {
122
+ return lstatSync(file, { throwIfNoEntry: false });
123
+ }
124
+ catch {
125
+ return undefined;
126
+ }
127
+ }
119
128
  /**
120
129
  * What each path looks like now: a file is `<mode> <blob>` as `git ls-tree`
121
130
  * prints it, a symlink hashes its target, a directory hashes its listing (every
@@ -139,7 +148,7 @@ export async function anchorTree(repo, paths) {
139
148
  return tree;
140
149
  const present = new Map();
141
150
  for (const file of wanted) {
142
- const stat = lstatSync(path.join(repo, file), { throwIfNoEntry: false });
151
+ const stat = lstat(path.join(repo, file));
143
152
  if (stat !== undefined)
144
153
  present.set(file, stat);
145
154
  }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,4 @@
1
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
2
  export interface BlacksmithOptions {
7
3
  /**
8
4
  * Run every test instead of only those affected since the last green run.
package/dist/index.js CHANGED
@@ -1,9 +1,6 @@
1
1
  import { fileURLToPath } from 'node:url';
2
2
  import { compileDependencies, configuredProvider, isTreeSnapshotProvider, isSnapshotRecorder, restoreDependencyCache, ShadowAnalysis, SnapshotRecorder, TreeSnapshotProvider, } from './affected.js';
3
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
4
  /** Beside this module; `.ts` when the plugin runs from source (its own test fixtures). */
8
5
  const recordReads = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? './record-reads.ts' : './record-reads.js', import.meta.url));
9
6
  /** Whether this Vitest wired up the pieces `--changed` selection relies on. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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",
3
+ "version": "0.3.0",
4
+ "description": "Runs only the Vitest tests affected since the last successful run, with Blacksmith-backed caching",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
package/dist/payload.d.ts DELETED
@@ -1,56 +0,0 @@
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 DELETED
@@ -1,40 +0,0 @@
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
- }
@@ -1,29 +0,0 @@
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[];
package/dist/reporter.js DELETED
@@ -1,149 +0,0 @@
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
- }
package/dist/upload.d.ts DELETED
@@ -1,17 +0,0 @@
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 DELETED
@@ -1,47 +0,0 @@
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
- }