@specific.dev/spectest 0.60.1 → 0.61.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/dist/daemon.js +24 -1
- package/dist/harness/catalogue.d.ts +11 -1
- package/dist/harness/catalogue.js +14 -1
- package/dist/harness/coverage.d.ts +6 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +27 -0
- package/package.json +1 -1
- package/src/daemon.ts +22 -1
- package/src/harness/catalogue.test.ts +9 -1
- package/src/harness/catalogue.ts +21 -1
- package/src/harness/coverage.ts +6 -0
- package/src/index.ts +30 -0
package/dist/daemon.js
CHANGED
|
@@ -114,7 +114,7 @@ let loaded = null;
|
|
|
114
114
|
/** The catalogue mapping lives in `harness/catalogue.ts`; these wrappers
|
|
115
115
|
* keep the call sites reading off the suite. */
|
|
116
116
|
function casesMetadata(suite) {
|
|
117
|
-
return catalogueCases(suite?.tests);
|
|
117
|
+
return catalogueCases(suite?.tests, APP_DIR);
|
|
118
118
|
}
|
|
119
119
|
function groupsMetadata(suite) {
|
|
120
120
|
return catalogueGroups(suite?.tests);
|
|
@@ -3115,6 +3115,26 @@ async function runCoverageAdapters(svc) {
|
|
|
3115
3115
|
clearTimeout(timer);
|
|
3116
3116
|
}
|
|
3117
3117
|
}
|
|
3118
|
+
/** Container name → its working directory, read once per container
|
|
3119
|
+
* (`docker inspect`); forks with the env like the rest of module memory.
|
|
3120
|
+
* Empty when the image sets none (paths are then reported from `/`). */
|
|
3121
|
+
const COVERAGE_ROOTS = new Map();
|
|
3122
|
+
async function coverageRoot(service) {
|
|
3123
|
+
const cached = COVERAGE_ROOTS.get(service);
|
|
3124
|
+
if (cached !== undefined)
|
|
3125
|
+
return cached;
|
|
3126
|
+
let root = "";
|
|
3127
|
+
try {
|
|
3128
|
+
const r = await docker(["inspect", "-f", "{{.Config.WorkingDir}}", service], 10_000);
|
|
3129
|
+
if (r.code === 0)
|
|
3130
|
+
root = r.stdout.trim();
|
|
3131
|
+
}
|
|
3132
|
+
catch {
|
|
3133
|
+
// Best-effort: the tree then falls back to a common-prefix guess.
|
|
3134
|
+
}
|
|
3135
|
+
COVERAGE_ROOTS.set(service, root);
|
|
3136
|
+
return root;
|
|
3137
|
+
}
|
|
3118
3138
|
async function captureServiceCoverage() {
|
|
3119
3139
|
const l = loaded;
|
|
3120
3140
|
if (!l)
|
|
@@ -3130,6 +3150,9 @@ async function captureServiceCoverage() {
|
|
|
3130
3150
|
if (error)
|
|
3131
3151
|
return { service: svc.name, reports: [], error };
|
|
3132
3152
|
const read = await readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
|
|
3153
|
+
const root = await coverageRoot(svc.name);
|
|
3154
|
+
if (root)
|
|
3155
|
+
read.root = root;
|
|
3133
3156
|
// What ships is what ran during this capture, whatever the tool
|
|
3134
3157
|
// counts: cumulative lcov is subtracted against the previous
|
|
3135
3158
|
// capture (module memory, forks with the env); a delta adapter's
|
|
@@ -21,6 +21,8 @@ export interface CatalogueCase {
|
|
|
21
21
|
id: string;
|
|
22
22
|
};
|
|
23
23
|
};
|
|
24
|
+
/** Absolute path of the file that registered the test, if known. */
|
|
25
|
+
file?: string;
|
|
24
26
|
}
|
|
25
27
|
export interface CaseMeta {
|
|
26
28
|
id: string;
|
|
@@ -30,6 +32,9 @@ export interface CaseMeta {
|
|
|
30
32
|
/** The group this case is shown in, if any. Display only — a group never
|
|
31
33
|
* changes the DAG, which `dependsOn` still describes in full. */
|
|
32
34
|
groupId?: string;
|
|
35
|
+
/** Project-relative path (`spectest/tests/…`) of the file that defines
|
|
36
|
+
* the test, when the SDK recorded one and it lies under `appDir`. */
|
|
37
|
+
file?: string;
|
|
33
38
|
}
|
|
34
39
|
/** A display-only test group (`env.group(...)`), for the CLI tree and the
|
|
35
40
|
* dashboard. `dependsOn` is the group's own level: every member of the
|
|
@@ -39,7 +44,12 @@ export interface GroupMeta {
|
|
|
39
44
|
name: string;
|
|
40
45
|
dependsOn?: string;
|
|
41
46
|
}
|
|
42
|
-
export declare function casesMetadata(cases: readonly CatalogueCase[] | undefined): CaseMeta[];
|
|
47
|
+
export declare function casesMetadata(cases: readonly CatalogueCase[] | undefined, appDir?: string): CaseMeta[];
|
|
48
|
+
/** `file` relative to `appDir` (the app dir holds the project's `spectest/`
|
|
49
|
+
* directory, so the result reads `spectest/tests/x.ts`). A file outside it
|
|
50
|
+
* — or no `appDir` — is reported as nothing: the selector then treats a
|
|
51
|
+
* change to a test file as unknown, which is the safe reading. */
|
|
52
|
+
export declare function relativeTestFile(file: string | undefined, appDir: string | undefined): string | undefined;
|
|
43
53
|
/**
|
|
44
54
|
* The groups the suite uses, in the order their first member appears —
|
|
45
55
|
* which is the order both surfaces render in.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Ported out of `daemon.ts`. Three harness methods (`load`, `loadTests`,
|
|
6
6
|
* `cases`) return this, so the mapping lives in one place.
|
|
7
7
|
*/
|
|
8
|
-
export function casesMetadata(cases) {
|
|
8
|
+
export function casesMetadata(cases, appDir) {
|
|
9
9
|
if (!cases)
|
|
10
10
|
return [];
|
|
11
11
|
return cases.map((t) => ({
|
|
@@ -14,8 +14,21 @@ export function casesMetadata(cases) {
|
|
|
14
14
|
dependsOn: t.dependsOn?.id,
|
|
15
15
|
timeoutMs: t.timeoutMs,
|
|
16
16
|
groupId: t.group?.id,
|
|
17
|
+
file: relativeTestFile(t.file, appDir),
|
|
17
18
|
}));
|
|
18
19
|
}
|
|
20
|
+
/** `file` relative to `appDir` (the app dir holds the project's `spectest/`
|
|
21
|
+
* directory, so the result reads `spectest/tests/x.ts`). A file outside it
|
|
22
|
+
* — or no `appDir` — is reported as nothing: the selector then treats a
|
|
23
|
+
* change to a test file as unknown, which is the safe reading. */
|
|
24
|
+
export function relativeTestFile(file, appDir) {
|
|
25
|
+
if (!file || !appDir)
|
|
26
|
+
return undefined;
|
|
27
|
+
const root = appDir.endsWith("/") ? appDir : appDir + "/";
|
|
28
|
+
if (!file.startsWith(root))
|
|
29
|
+
return undefined;
|
|
30
|
+
return file.slice(root.length);
|
|
31
|
+
}
|
|
19
32
|
/**
|
|
20
33
|
* The groups the suite uses, in the order their first member appears —
|
|
21
34
|
* which is the order both surfaces render in.
|
|
@@ -17,6 +17,12 @@ export interface CoverageReport {
|
|
|
17
17
|
export interface ServiceCoverageCapture {
|
|
18
18
|
service: string;
|
|
19
19
|
reports: CoverageReport[];
|
|
20
|
+
/** The container's working directory (`docker inspect`), the root
|
|
21
|
+
* the reported container paths are relative to for a `COPY . <dir>`
|
|
22
|
+
* image. The server strips it to draw a project-relative tree; it
|
|
23
|
+
* never has to fetch code to do so. Absent for a container it could
|
|
24
|
+
* not be read from. */
|
|
25
|
+
root?: string;
|
|
20
26
|
/** Set when the capture failed: the command failed, the directory was
|
|
21
27
|
* empty, no file held an `SF:` record, or the reports were too large.
|
|
22
28
|
* A case with an error here fails — a hole in the coverage data must
|
package/dist/index.d.ts
CHANGED
|
@@ -903,6 +903,11 @@ export interface TestCase<T = unknown, S extends ServicesMap = ServicesMap, F ex
|
|
|
903
903
|
readonly group?: TestGroup<unknown, S, F>;
|
|
904
904
|
/** Override the default per-test timeout (default 60s). */
|
|
905
905
|
readonly timeoutMs?: number;
|
|
906
|
+
/** @internal — absolute path of the file that called `env.test(...)`,
|
|
907
|
+
* read off the call stack at registration. The catalogue reports it
|
|
908
|
+
* project-relative so a change to a test file can select exactly the
|
|
909
|
+
* cases it defines (subset runs). Absent when the stack gave none. */
|
|
910
|
+
readonly file?: string;
|
|
906
911
|
/** @internal — the body that the in-sandbox daemon invokes. */
|
|
907
912
|
readonly run: TestFn<T, unknown, S, F>;
|
|
908
913
|
}
|
package/dist/index.js
CHANGED
|
@@ -470,9 +470,36 @@ maybeFn) {
|
|
|
470
470
|
dependsOn: opts.dependsOn ?? opts.group?.dependsOn,
|
|
471
471
|
group: opts.group,
|
|
472
472
|
timeoutMs: opts.timeoutMs,
|
|
473
|
+
file: callerFile(),
|
|
473
474
|
run: fn,
|
|
474
475
|
};
|
|
475
476
|
}
|
|
477
|
+
/**
|
|
478
|
+
* The file of the nearest stack frame outside this module — the test file
|
|
479
|
+
* that called `env.test(...)`. Frames look like `at fn (/abs/path.ts:1:2)`
|
|
480
|
+
* or `at /abs/path.ts:1:2`; the first one whose path is not this module's
|
|
481
|
+
* is the caller. `undefined` when the stack is unavailable.
|
|
482
|
+
*/
|
|
483
|
+
function callerFile() {
|
|
484
|
+
const stack = new Error().stack;
|
|
485
|
+
if (!stack)
|
|
486
|
+
return undefined;
|
|
487
|
+
let self;
|
|
488
|
+
for (const line of stack.split("\n")) {
|
|
489
|
+
const m = /\(?((?:\/|[A-Za-z]:\\)[^\s()]+?):\d+:\d+\)?\s*$/.exec(line);
|
|
490
|
+
if (!m)
|
|
491
|
+
continue;
|
|
492
|
+
const file = m[1];
|
|
493
|
+
// The first path on the stack is this module (callerFile itself).
|
|
494
|
+
if (self === undefined) {
|
|
495
|
+
self = file;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (file !== self)
|
|
499
|
+
return file;
|
|
500
|
+
}
|
|
501
|
+
return undefined;
|
|
502
|
+
}
|
|
476
503
|
function validateSuite(suite) {
|
|
477
504
|
const seenIds = new Map();
|
|
478
505
|
for (const t of suite.tests) {
|
package/package.json
CHANGED
package/src/daemon.ts
CHANGED
|
@@ -286,7 +286,7 @@ let loaded: Loaded | null = null;
|
|
|
286
286
|
/** The catalogue mapping lives in `harness/catalogue.ts`; these wrappers
|
|
287
287
|
* keep the call sites reading off the suite. */
|
|
288
288
|
function casesMetadata(suite: TestSuite | undefined): CaseMeta[] {
|
|
289
|
-
return catalogueCases(suite?.tests);
|
|
289
|
+
return catalogueCases(suite?.tests, APP_DIR);
|
|
290
290
|
}
|
|
291
291
|
|
|
292
292
|
function groupsMetadata(suite: TestSuite | undefined): GroupMeta[] {
|
|
@@ -3759,6 +3759,25 @@ async function runCoverageAdapters(
|
|
|
3759
3759
|
}
|
|
3760
3760
|
}
|
|
3761
3761
|
|
|
3762
|
+
/** Container name → its working directory, read once per container
|
|
3763
|
+
* (`docker inspect`); forks with the env like the rest of module memory.
|
|
3764
|
+
* Empty when the image sets none (paths are then reported from `/`). */
|
|
3765
|
+
const COVERAGE_ROOTS = new Map<string, string>();
|
|
3766
|
+
|
|
3767
|
+
async function coverageRoot(service: string): Promise<string> {
|
|
3768
|
+
const cached = COVERAGE_ROOTS.get(service);
|
|
3769
|
+
if (cached !== undefined) return cached;
|
|
3770
|
+
let root = "";
|
|
3771
|
+
try {
|
|
3772
|
+
const r = await docker(["inspect", "-f", "{{.Config.WorkingDir}}", service], 10_000);
|
|
3773
|
+
if (r.code === 0) root = r.stdout.trim();
|
|
3774
|
+
} catch {
|
|
3775
|
+
// Best-effort: the tree then falls back to a common-prefix guess.
|
|
3776
|
+
}
|
|
3777
|
+
COVERAGE_ROOTS.set(service, root);
|
|
3778
|
+
return root;
|
|
3779
|
+
}
|
|
3780
|
+
|
|
3762
3781
|
async function captureServiceCoverage(): Promise<ServiceCoverageCapture[]> {
|
|
3763
3782
|
const l = loaded;
|
|
3764
3783
|
if (!l) return [];
|
|
@@ -3771,6 +3790,8 @@ async function captureServiceCoverage(): Promise<ServiceCoverageCapture[]> {
|
|
|
3771
3790
|
const { error, deltaReports } = await runCoverageAdapters(svc);
|
|
3772
3791
|
if (error) return { service: svc.name, reports: [], error };
|
|
3773
3792
|
const read = await readCoverageDir(svc.name, coverageHostDir(WORKSPACE, svc.name));
|
|
3793
|
+
const root = await coverageRoot(svc.name);
|
|
3794
|
+
if (root) read.root = root;
|
|
3774
3795
|
// What ships is what ran during this capture, whatever the tool
|
|
3775
3796
|
// counts: cumulative lcov is subtracted against the previous
|
|
3776
3797
|
// capture (module memory, forks with the env); a delta adapter's
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import { casesMetadata, groupsMetadata, type CatalogueCase } from "./catalogue";
|
|
3
|
+
import { casesMetadata, groupsMetadata, relativeTestFile, type CatalogueCase } from "./catalogue";
|
|
4
4
|
|
|
5
5
|
const billing = { id: "billing", name: "Billing" };
|
|
6
6
|
const errors = {
|
|
@@ -59,3 +59,11 @@ describe("groupsMetadata", () => {
|
|
|
59
59
|
expect(groupsMetadata(undefined)).toEqual([]);
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
|
+
|
|
63
|
+
test("relativeTestFile reports a test file relative to the app dir, or nothing", () => {
|
|
64
|
+
expect(relativeTestFile("/opt/spectest/app/spectest/tests/a.ts", "/opt/spectest/app")).toBe("spectest/tests/a.ts");
|
|
65
|
+
expect(relativeTestFile("/opt/spectest/app/spectest/tests/a.ts", "/opt/spectest/app/")).toBe("spectest/tests/a.ts");
|
|
66
|
+
expect(relativeTestFile("/elsewhere/a.ts", "/opt/spectest/app")).toBeUndefined();
|
|
67
|
+
expect(relativeTestFile(undefined, "/opt/spectest/app")).toBeUndefined();
|
|
68
|
+
expect(relativeTestFile("/opt/spectest/app/spectest/tests/a.ts", undefined)).toBeUndefined();
|
|
69
|
+
});
|
package/src/harness/catalogue.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface CatalogueCase {
|
|
|
14
14
|
dependsOn?: { id: string };
|
|
15
15
|
timeoutMs?: number;
|
|
16
16
|
group?: { id: string; name: string; dependsOn?: { id: string } };
|
|
17
|
+
/** Absolute path of the file that registered the test, if known. */
|
|
18
|
+
file?: string;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
export interface CaseMeta {
|
|
@@ -24,6 +26,9 @@ export interface CaseMeta {
|
|
|
24
26
|
/** The group this case is shown in, if any. Display only — a group never
|
|
25
27
|
* changes the DAG, which `dependsOn` still describes in full. */
|
|
26
28
|
groupId?: string;
|
|
29
|
+
/** Project-relative path (`spectest/tests/…`) of the file that defines
|
|
30
|
+
* the test, when the SDK recorded one and it lies under `appDir`. */
|
|
31
|
+
file?: string;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
/** A display-only test group (`env.group(...)`), for the CLI tree and the
|
|
@@ -35,7 +40,10 @@ export interface GroupMeta {
|
|
|
35
40
|
dependsOn?: string;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
|
-
export function casesMetadata(
|
|
43
|
+
export function casesMetadata(
|
|
44
|
+
cases: readonly CatalogueCase[] | undefined,
|
|
45
|
+
appDir?: string,
|
|
46
|
+
): CaseMeta[] {
|
|
39
47
|
if (!cases) return [];
|
|
40
48
|
return cases.map((t) => ({
|
|
41
49
|
id: t.id,
|
|
@@ -43,9 +51,21 @@ export function casesMetadata(cases: readonly CatalogueCase[] | undefined): Case
|
|
|
43
51
|
dependsOn: t.dependsOn?.id,
|
|
44
52
|
timeoutMs: t.timeoutMs,
|
|
45
53
|
groupId: t.group?.id,
|
|
54
|
+
file: relativeTestFile(t.file, appDir),
|
|
46
55
|
}));
|
|
47
56
|
}
|
|
48
57
|
|
|
58
|
+
/** `file` relative to `appDir` (the app dir holds the project's `spectest/`
|
|
59
|
+
* directory, so the result reads `spectest/tests/x.ts`). A file outside it
|
|
60
|
+
* — or no `appDir` — is reported as nothing: the selector then treats a
|
|
61
|
+
* change to a test file as unknown, which is the safe reading. */
|
|
62
|
+
export function relativeTestFile(file: string | undefined, appDir: string | undefined): string | undefined {
|
|
63
|
+
if (!file || !appDir) return undefined;
|
|
64
|
+
const root = appDir.endsWith("/") ? appDir : appDir + "/";
|
|
65
|
+
if (!file.startsWith(root)) return undefined;
|
|
66
|
+
return file.slice(root.length);
|
|
67
|
+
}
|
|
68
|
+
|
|
49
69
|
/**
|
|
50
70
|
* The groups the suite uses, in the order their first member appears —
|
|
51
71
|
* which is the order both surfaces render in.
|
package/src/harness/coverage.ts
CHANGED
|
@@ -57,6 +57,12 @@ export interface CoverageReport {
|
|
|
57
57
|
export interface ServiceCoverageCapture {
|
|
58
58
|
service: string;
|
|
59
59
|
reports: CoverageReport[];
|
|
60
|
+
/** The container's working directory (`docker inspect`), the root
|
|
61
|
+
* the reported container paths are relative to for a `COPY . <dir>`
|
|
62
|
+
* image. The server strips it to draw a project-relative tree; it
|
|
63
|
+
* never has to fetch code to do so. Absent for a container it could
|
|
64
|
+
* not be read from. */
|
|
65
|
+
root?: string;
|
|
60
66
|
/** Set when the capture failed: the command failed, the directory was
|
|
61
67
|
* empty, no file held an `SF:` record, or the reports were too large.
|
|
62
68
|
* A case with an error here fails — a hole in the coverage data must
|
package/src/index.ts
CHANGED
|
@@ -1407,6 +1407,11 @@ export interface TestCase<
|
|
|
1407
1407
|
readonly group?: TestGroup<unknown, S, F>;
|
|
1408
1408
|
/** Override the default per-test timeout (default 60s). */
|
|
1409
1409
|
readonly timeoutMs?: number;
|
|
1410
|
+
/** @internal — absolute path of the file that called `env.test(...)`,
|
|
1411
|
+
* read off the call stack at registration. The catalogue reports it
|
|
1412
|
+
* project-relative so a change to a test file can select exactly the
|
|
1413
|
+
* cases it defines (subset runs). Absent when the stack gave none. */
|
|
1414
|
+
readonly file?: string;
|
|
1410
1415
|
/** @internal — the body that the in-sandbox daemon invokes. */
|
|
1411
1416
|
readonly run: TestFn<T, unknown, S, F>;
|
|
1412
1417
|
}
|
|
@@ -2375,10 +2380,35 @@ function buildTestCase(
|
|
|
2375
2380
|
dependsOn: opts.dependsOn ?? opts.group?.dependsOn,
|
|
2376
2381
|
group: opts.group,
|
|
2377
2382
|
timeoutMs: opts.timeoutMs,
|
|
2383
|
+
file: callerFile(),
|
|
2378
2384
|
run: fn,
|
|
2379
2385
|
};
|
|
2380
2386
|
}
|
|
2381
2387
|
|
|
2388
|
+
/**
|
|
2389
|
+
* The file of the nearest stack frame outside this module — the test file
|
|
2390
|
+
* that called `env.test(...)`. Frames look like `at fn (/abs/path.ts:1:2)`
|
|
2391
|
+
* or `at /abs/path.ts:1:2`; the first one whose path is not this module's
|
|
2392
|
+
* is the caller. `undefined` when the stack is unavailable.
|
|
2393
|
+
*/
|
|
2394
|
+
function callerFile(): string | undefined {
|
|
2395
|
+
const stack = new Error().stack;
|
|
2396
|
+
if (!stack) return undefined;
|
|
2397
|
+
let self: string | undefined;
|
|
2398
|
+
for (const line of stack.split("\n")) {
|
|
2399
|
+
const m = /\(?((?:\/|[A-Za-z]:\\)[^\s()]+?):\d+:\d+\)?\s*$/.exec(line);
|
|
2400
|
+
if (!m) continue;
|
|
2401
|
+
const file = m[1]!;
|
|
2402
|
+
// The first path on the stack is this module (callerFile itself).
|
|
2403
|
+
if (self === undefined) {
|
|
2404
|
+
self = file;
|
|
2405
|
+
continue;
|
|
2406
|
+
}
|
|
2407
|
+
if (file !== self) return file;
|
|
2408
|
+
}
|
|
2409
|
+
return undefined;
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2382
2412
|
/** The two option shapes as one, for the untyped runtime implementation.
|
|
2383
2413
|
* The public overloads keep them apart — see {@link TestOpts}. */
|
|
2384
2414
|
interface AnyTestOpts {
|