@specific.dev/spectest 0.60.2 → 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);
@@ -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.
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.2",
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[] {
@@ -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.
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 {