@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.60.1",
3
+ "version": "0.61.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
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
+ });
@@ -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(cases: readonly CatalogueCase[] | undefined): CaseMeta[] {
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.
@@ -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 {