@useblacksmith/vitest 0.2.1 → 0.4.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 +17 -0
- package/dist/affected.d.ts +3 -1
- package/dist/affected.js +14 -3
- package/dist/cache.d.ts +2 -0
- package/dist/cache.js +2 -2
- package/dist/git.js +10 -1
- package/dist/index.d.ts +0 -4
- package/dist/index.js +0 -3
- package/package.json +4 -3
- package/dist/payload.d.ts +0 -56
- package/dist/payload.js +0 -40
- package/dist/reporter.d.ts +0 -29
- package/dist/reporter.js +0 -149
- package/dist/upload.d.ts +0 -17
- package/dist/upload.js +0 -47
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/affected.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { BloomFilter } from './bloom.js';
|
|
|
2
2
|
import type { Reporter, TestModule, TestRunEndReason, TestSpecification, VCSProvider, VCSProviderOptions, Vitest } from 'vitest/node';
|
|
3
3
|
import { type Tree } from './git.js';
|
|
4
4
|
export declare const SNAPSHOT_FILE = "blacksmith.json";
|
|
5
|
-
declare const SNAPSHOT_VERSION =
|
|
5
|
+
declare const SNAPSHOT_VERSION = 5;
|
|
6
6
|
/** A `dependencies` entry, compiled: test files matching `tests` depend on files matching `deps`; both root-relative. */
|
|
7
7
|
export interface DependencyRule {
|
|
8
8
|
tests: (file: string) => boolean;
|
|
@@ -20,6 +20,8 @@ export interface Shard {
|
|
|
20
20
|
export interface Snapshot {
|
|
21
21
|
version: typeof SNAPSHOT_VERSION;
|
|
22
22
|
head: string;
|
|
23
|
+
/** Vitest's root relative to the repository, so one package's snapshot never vouches for another's. */
|
|
24
|
+
root: string;
|
|
23
25
|
shard: Shard | null;
|
|
24
26
|
/** Names of the projects the run covered (`--project` narrows them). */
|
|
25
27
|
projects: string[];
|
package/dist/affected.js
CHANGED
|
@@ -11,7 +11,7 @@ import { shardOwner } from './sequencer.js';
|
|
|
11
11
|
export const SNAPSHOT_FILE = 'blacksmith.json';
|
|
12
12
|
// Bumped whenever the snapshot's shape changes or the records an anchor
|
|
13
13
|
// vouches for gain a kind of dependency, so an older snapshot is not trusted.
|
|
14
|
-
const SNAPSHOT_VERSION =
|
|
14
|
+
const SNAPSHOT_VERSION = 5;
|
|
15
15
|
export function compileDependencies(dependencies) {
|
|
16
16
|
return Object.entries(dependencies ?? {}).map(([tests, deps]) => ({
|
|
17
17
|
tests: picomatch(tests, { dot: true }),
|
|
@@ -138,10 +138,11 @@ async function readSnapshot(file) {
|
|
|
138
138
|
}
|
|
139
139
|
if (typeof parsed !== 'object' || parsed === null)
|
|
140
140
|
return null;
|
|
141
|
-
const { version, head, shard, projects, tree, tests } = parsed;
|
|
141
|
+
const { version, head, root, shard, projects, tree, tests } = parsed;
|
|
142
142
|
const covered = BloomFilter.parse(tests);
|
|
143
143
|
if (version !== SNAPSHOT_VERSION ||
|
|
144
144
|
typeof head !== 'string' ||
|
|
145
|
+
typeof root !== 'string' ||
|
|
145
146
|
!isShard(shard) ||
|
|
146
147
|
!Array.isArray(projects) ||
|
|
147
148
|
!projects.every((name) => typeof name === 'string') ||
|
|
@@ -152,6 +153,7 @@ async function readSnapshot(file) {
|
|
|
152
153
|
return {
|
|
153
154
|
version: SNAPSHOT_VERSION,
|
|
154
155
|
head,
|
|
156
|
+
root,
|
|
155
157
|
shard,
|
|
156
158
|
projects: projects,
|
|
157
159
|
tree,
|
|
@@ -213,6 +215,12 @@ export class TreeSnapshotProvider {
|
|
|
213
215
|
log(`no dependency snapshot, running every test of the ${describeShard(shard)}`);
|
|
214
216
|
return null;
|
|
215
217
|
}
|
|
218
|
+
const repo = await repoRoot(root);
|
|
219
|
+
const here = repoRelative(repo, this.vitest.config.root);
|
|
220
|
+
if (snapshot.root !== here) {
|
|
221
|
+
log(`dependency snapshot is for ${snapshot.root || 'the repository root'}, this run is for ${here || 'the repository root'}, running every test of the ${describeShard(shard)}`);
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
216
224
|
if (!sameShard(snapshot.shard, shard)) {
|
|
217
225
|
log(`dependency snapshot is for ${describeShard(snapshot.shard)}, running every test of the ${describeShard(shard)}`);
|
|
218
226
|
return null;
|
|
@@ -222,7 +230,6 @@ export class TreeSnapshotProvider {
|
|
|
222
230
|
log(`dependency snapshot covers projects [${snapshot.projects.join(', ')}], this run covers [${projects.join(', ')}], running every test of the ${describeShard(shard)}`);
|
|
223
231
|
return null;
|
|
224
232
|
}
|
|
225
|
-
const repo = await repoRoot(root);
|
|
226
233
|
const tree = await anchorTree(repo, await anchoredPaths(this.vitest, repo, dir, this.dependencies));
|
|
227
234
|
const changedPaths = diffTrees(snapshot.tree, tree);
|
|
228
235
|
const owns = await shardOwner(this.vitest);
|
|
@@ -307,6 +314,7 @@ export class ShadowAnalysis {
|
|
|
307
314
|
const repo = await repoRoot(vitest.config.root);
|
|
308
315
|
const key = cacheKey({
|
|
309
316
|
snapshotVersion: SNAPSHOT_VERSION,
|
|
317
|
+
root: repoRelative(repo, vitest.config.root),
|
|
310
318
|
projects: projectNames(vitest),
|
|
311
319
|
shard: vitest.config.shard ?? null,
|
|
312
320
|
head: process.env.GITHUB_SHA || (await headSha(repo)),
|
|
@@ -480,6 +488,7 @@ export class SnapshotRecorder {
|
|
|
480
488
|
const snapshot = {
|
|
481
489
|
version: SNAPSHOT_VERSION,
|
|
482
490
|
head: await headSha(repo),
|
|
491
|
+
root: repoRelative(repo, root),
|
|
483
492
|
shard: shard ?? null,
|
|
484
493
|
projects: projectNames(this.vitest),
|
|
485
494
|
tree: await anchorTree(repo, await anchoredPaths(this.vitest, repo, cacheDir, this.provider.dependencies)),
|
|
@@ -499,6 +508,7 @@ export class SnapshotRecorder {
|
|
|
499
508
|
return;
|
|
500
509
|
const key = cacheKey({
|
|
501
510
|
snapshotVersion: snapshot.version,
|
|
511
|
+
root: snapshot.root,
|
|
502
512
|
projects: snapshot.projects,
|
|
503
513
|
shard: snapshot.shard,
|
|
504
514
|
head: snapshot.head,
|
|
@@ -554,6 +564,7 @@ export async function restoreDependencyCache(vitest) {
|
|
|
554
564
|
const repo = await repoRoot(vitest.config.root);
|
|
555
565
|
const key = cacheKey({
|
|
556
566
|
snapshotVersion: SNAPSHOT_VERSION,
|
|
567
|
+
root: repoRelative(repo, vitest.config.root),
|
|
557
568
|
projects: projectNames(vitest),
|
|
558
569
|
shard: vitest.config.shard ?? null,
|
|
559
570
|
head: process.env.GITHUB_SHA || (await headSha(repo)),
|
package/dist/cache.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface CacheKey {
|
|
|
21
21
|
export declare const DEFAULT_CACHE_PREFIX = "blacksmith-vitest";
|
|
22
22
|
export declare function cacheKey(input: {
|
|
23
23
|
snapshotVersion: number;
|
|
24
|
+
/** Vitest's root relative to the repository: each package of a monorepo keeps its own entries. */
|
|
25
|
+
root: string;
|
|
24
26
|
projects: string[];
|
|
25
27
|
shard: {
|
|
26
28
|
index: number;
|
package/dist/cache.js
CHANGED
|
@@ -21,9 +21,9 @@ export const DEFAULT_CACHE_PREFIX = 'blacksmith-vitest';
|
|
|
21
21
|
export function cacheKey(input) {
|
|
22
22
|
const scope = [
|
|
23
23
|
`v${input.snapshotVersion}`,
|
|
24
|
-
input.projects.some(Boolean)
|
|
24
|
+
input.root !== '' || input.projects.some(Boolean)
|
|
25
25
|
? createHash('sha1')
|
|
26
|
-
.update(input.projects.join('\n'))
|
|
26
|
+
.update([input.root, ...input.projects].join('\n'))
|
|
27
27
|
.digest('hex')
|
|
28
28
|
.slice(0, 12)
|
|
29
29
|
: 'all',
|
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 =
|
|
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.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.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": {
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"build": "tsc -p tsconfig.build.json",
|
|
22
22
|
"prepack": "npm run build",
|
|
23
23
|
"check:types": "tsc --noEmit",
|
|
24
|
-
"test": "vitest run"
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"release": "scripts/release.sh"
|
|
25
26
|
},
|
|
26
27
|
"peerDependencies": {
|
|
27
28
|
"vitest": ">=5.1.0"
|
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
|
-
}
|
package/dist/reporter.d.ts
DELETED
|
@@ -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
|
-
}
|