@crvouga/atlas 1.2.0 → 1.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/reports/src/evidence-detail.d.ts +37 -3
  3. package/dist/reports/src/evidence-detail.js +52 -20
  4. package/dist/reports/src/evidence-scope.d.ts +83 -0
  5. package/dist/reports/src/evidence-scope.js +129 -0
  6. package/dist/reports/src/evidence-types.d.ts +3 -1
  7. package/dist/reports/src/explorer-types.d.ts +1 -1
  8. package/dist/reports/src/export-evidence.js +1 -1
  9. package/dist/reports/src/layer-merge.js +3 -2
  10. package/dist/reports/src/layer-types.d.ts +1 -1
  11. package/dist/reports/src/layers-route.d.ts +2 -1
  12. package/dist/reports/src/layers-route.js +8 -4
  13. package/dist/reports/src/publish-checks.js +1 -0
  14. package/dist/reports/src/reporter.d.ts +1 -1
  15. package/dist/reports/src/reports-explorer-route.d.ts +276 -0
  16. package/dist/reports/src/reports-route.d.ts +276 -0
  17. package/dist/reports/src/test-catalog.d.ts +34 -1
  18. package/dist/reports/src/test-catalog.js +23 -5
  19. package/dist/schema/src/api.d.ts +542 -0
  20. package/dist/schema/src/api.js +17 -5
  21. package/dist/schema/src/layer.d.ts +981 -0
  22. package/dist/schema/src/layer.js +43 -4
  23. package/dist/server/src/openapi.js +3 -2
  24. package/docs/extending.md +1 -0
  25. package/docs/reference/http-api.md +6 -4
  26. package/docs/reference/reports.md +243 -14
  27. package/docs/reference/schema.md +23 -0
  28. package/docs/rich-evidence.md +70 -0
  29. package/docs/ui.md +1 -1
  30. package/llms-full.txt +78 -5
  31. package/package.json +1 -1
  32. package/packages/schema/schemas/check-detail.schema.json +697 -1
  33. package/packages/schema/schemas/effective-delta.schema.json +192 -0
  34. package/packages/schema/schemas/effective-evidence.schema.json +192 -0
  35. package/packages/schema/schemas/evidence-page.schema.json +96 -0
  36. package/packages/schema/schemas/layer-rows.schema.json +96 -0
  37. package/packages/schema/schemas/layer.schema.json +1598 -62
  38. package/packages/schema/schemas/publish-checks.schema.json +768 -0
  39. package/packages/schema/schemas/report-run.schema.json +181 -0
  40. package/packages/schema/schemas/report-runs.schema.json +6 -0
  41. package/packages/schema/schemas/test-catalog.schema.json +128 -0
  42. package/packages/schema/schemas/test-history.schema.json +202 -0
  43. package/packages/server/openapi.json +2721 -48
  44. package/packages/visualizer/src/evidence/artifacts.tsx +77 -29
  45. package/packages/visualizer/src/evidence/evidence-scope.tsx +106 -0
  46. package/packages/visualizer/src/evidence/format.ts +11 -0
  47. package/packages/visualizer/src/explorer/Explorer.tsx +28 -5
  48. package/packages/visualizer/src/explorer/RunArtifacts.tsx +246 -0
  49. package/packages/visualizer/src/explorer/RunsView.tsx +40 -9
  50. package/packages/visualizer/src/explorer/search.ts +10 -3
@@ -105,6 +105,29 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
105
105
  } | null;
106
106
  parts: readonly {
107
107
  tags?: string[] | undefined;
108
+ scope?: {
109
+ [x: string]: import("hono/utils/types").JSONValue;
110
+ surface?: string | undefined;
111
+ deviceKind?: string | undefined;
112
+ syntheticData?: boolean | undefined;
113
+ dependencies?: string | undefined;
114
+ audioCaptured?: boolean | undefined;
115
+ pacing?: string | undefined;
116
+ asserted?: string[] | undefined;
117
+ limitations?: string[] | undefined;
118
+ declaredBy?: string | undefined;
119
+ verified?: string[] | undefined;
120
+ verifiedBy?: string | undefined;
121
+ provenance?: {
122
+ [x: string]: import("hono/utils/types").JSONValue;
123
+ url: string;
124
+ name?: string | undefined;
125
+ rel?: string | undefined;
126
+ }[] | undefined;
127
+ extensions?: {
128
+ [x: string]: import("hono/utils/types").JSONValue;
129
+ } | undefined;
130
+ } | undefined;
108
131
  attachments?: {
109
132
  [x: string]: import("hono/utils/types").JSONValue;
110
133
  name: string;
@@ -121,6 +144,29 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
121
144
  durationMs?: number | undefined;
122
145
  description?: string | undefined;
123
146
  omitted?: string | undefined;
147
+ scope?: {
148
+ [x: string]: import("hono/utils/types").JSONValue;
149
+ surface?: string | undefined;
150
+ deviceKind?: string | undefined;
151
+ syntheticData?: boolean | undefined;
152
+ dependencies?: string | undefined;
153
+ audioCaptured?: boolean | undefined;
154
+ pacing?: string | undefined;
155
+ asserted?: string[] | undefined;
156
+ limitations?: string[] | undefined;
157
+ declaredBy?: string | undefined;
158
+ verified?: string[] | undefined;
159
+ verifiedBy?: string | undefined;
160
+ provenance?: {
161
+ [x: string]: import("hono/utils/types").JSONValue;
162
+ url: string;
163
+ name?: string | undefined;
164
+ rel?: string | undefined;
165
+ }[] | undefined;
166
+ extensions?: {
167
+ [x: string]: import("hono/utils/types").JSONValue;
168
+ } | undefined;
169
+ } | undefined;
124
170
  tags?: string[] | undefined;
125
171
  at?: {
126
172
  [x: string]: import("hono/utils/types").JSONValue;
@@ -140,6 +186,29 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
140
186
  durationMs?: number | undefined;
141
187
  startedAtMs?: number | undefined;
142
188
  detail?: string | undefined;
189
+ scope?: {
190
+ [x: string]: import("hono/utils/types").JSONValue;
191
+ surface?: string | undefined;
192
+ deviceKind?: string | undefined;
193
+ syntheticData?: boolean | undefined;
194
+ dependencies?: string | undefined;
195
+ audioCaptured?: boolean | undefined;
196
+ pacing?: string | undefined;
197
+ asserted?: string[] | undefined;
198
+ limitations?: string[] | undefined;
199
+ declaredBy?: string | undefined;
200
+ verified?: string[] | undefined;
201
+ verifiedBy?: string | undefined;
202
+ provenance?: {
203
+ [x: string]: import("hono/utils/types").JSONValue;
204
+ url: string;
205
+ name?: string | undefined;
206
+ rel?: string | undefined;
207
+ }[] | undefined;
208
+ extensions?: {
209
+ [x: string]: import("hono/utils/types").JSONValue;
210
+ } | undefined;
211
+ } | undefined;
143
212
  steps?: {
144
213
  [x: string]: import("hono/utils/types").JSONValue;
145
214
  name: string;
@@ -166,6 +235,29 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
166
235
  durationMs?: number | undefined;
167
236
  description?: string | undefined;
168
237
  omitted?: string | undefined;
238
+ scope?: {
239
+ [x: string]: import("hono/utils/types").JSONValue;
240
+ surface?: string | undefined;
241
+ deviceKind?: string | undefined;
242
+ syntheticData?: boolean | undefined;
243
+ dependencies?: string | undefined;
244
+ audioCaptured?: boolean | undefined;
245
+ pacing?: string | undefined;
246
+ asserted?: string[] | undefined;
247
+ limitations?: string[] | undefined;
248
+ declaredBy?: string | undefined;
249
+ verified?: string[] | undefined;
250
+ verifiedBy?: string | undefined;
251
+ provenance?: {
252
+ [x: string]: import("hono/utils/types").JSONValue;
253
+ url: string;
254
+ name?: string | undefined;
255
+ rel?: string | undefined;
256
+ }[] | undefined;
257
+ extensions?: {
258
+ [x: string]: import("hono/utils/types").JSONValue;
259
+ } | undefined;
260
+ } | undefined;
169
261
  tags?: string[] | undefined;
170
262
  at?: {
171
263
  [x: string]: import("hono/utils/types").JSONValue;
@@ -224,6 +316,29 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
224
316
  durationMs?: number | undefined;
225
317
  description?: string | undefined;
226
318
  omitted?: string | undefined;
319
+ scope?: {
320
+ [x: string]: import("hono/utils/types").JSONValue;
321
+ surface?: string | undefined;
322
+ deviceKind?: string | undefined;
323
+ syntheticData?: boolean | undefined;
324
+ dependencies?: string | undefined;
325
+ audioCaptured?: boolean | undefined;
326
+ pacing?: string | undefined;
327
+ asserted?: string[] | undefined;
328
+ limitations?: string[] | undefined;
329
+ declaredBy?: string | undefined;
330
+ verified?: string[] | undefined;
331
+ verifiedBy?: string | undefined;
332
+ provenance?: {
333
+ [x: string]: import("hono/utils/types").JSONValue;
334
+ url: string;
335
+ name?: string | undefined;
336
+ rel?: string | undefined;
337
+ }[] | undefined;
338
+ extensions?: {
339
+ [x: string]: import("hono/utils/types").JSONValue;
340
+ } | undefined;
341
+ } | undefined;
227
342
  tags?: string[] | undefined;
228
343
  at?: {
229
344
  [x: string]: import("hono/utils/types").JSONValue;
@@ -292,6 +407,29 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
292
407
  durationMs?: number | undefined;
293
408
  description?: string | undefined;
294
409
  omitted?: string | undefined;
410
+ scope?: {
411
+ [x: string]: import("hono/utils/types").JSONValue;
412
+ surface?: string | undefined;
413
+ deviceKind?: string | undefined;
414
+ syntheticData?: boolean | undefined;
415
+ dependencies?: string | undefined;
416
+ audioCaptured?: boolean | undefined;
417
+ pacing?: string | undefined;
418
+ asserted?: string[] | undefined;
419
+ limitations?: string[] | undefined;
420
+ declaredBy?: string | undefined;
421
+ verified?: string[] | undefined;
422
+ verifiedBy?: string | undefined;
423
+ provenance?: {
424
+ [x: string]: import("hono/utils/types").JSONValue;
425
+ url: string;
426
+ name?: string | undefined;
427
+ rel?: string | undefined;
428
+ }[] | undefined;
429
+ extensions?: {
430
+ [x: string]: import("hono/utils/types").JSONValue;
431
+ } | undefined;
432
+ } | undefined;
295
433
  tags?: string[] | undefined;
296
434
  at?: {
297
435
  [x: string]: import("hono/utils/types").JSONValue;
@@ -383,6 +521,29 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
383
521
  readonly recordedAtMs: number;
384
522
  readonly checks: readonly {
385
523
  tags?: string[] | undefined;
524
+ scope?: {
525
+ [x: string]: import("hono/utils/types").JSONValue;
526
+ surface?: string | undefined;
527
+ deviceKind?: string | undefined;
528
+ syntheticData?: boolean | undefined;
529
+ dependencies?: string | undefined;
530
+ audioCaptured?: boolean | undefined;
531
+ pacing?: string | undefined;
532
+ asserted?: string[] | undefined;
533
+ limitations?: string[] | undefined;
534
+ declaredBy?: string | undefined;
535
+ verified?: string[] | undefined;
536
+ verifiedBy?: string | undefined;
537
+ provenance?: {
538
+ [x: string]: import("hono/utils/types").JSONValue;
539
+ url: string;
540
+ name?: string | undefined;
541
+ rel?: string | undefined;
542
+ }[] | undefined;
543
+ extensions?: {
544
+ [x: string]: import("hono/utils/types").JSONValue;
545
+ } | undefined;
546
+ } | undefined;
386
547
  attachments?: {
387
548
  [x: string]: import("hono/utils/types").JSONValue;
388
549
  name: string;
@@ -399,6 +560,29 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
399
560
  durationMs?: number | undefined;
400
561
  description?: string | undefined;
401
562
  omitted?: string | undefined;
563
+ scope?: {
564
+ [x: string]: import("hono/utils/types").JSONValue;
565
+ surface?: string | undefined;
566
+ deviceKind?: string | undefined;
567
+ syntheticData?: boolean | undefined;
568
+ dependencies?: string | undefined;
569
+ audioCaptured?: boolean | undefined;
570
+ pacing?: string | undefined;
571
+ asserted?: string[] | undefined;
572
+ limitations?: string[] | undefined;
573
+ declaredBy?: string | undefined;
574
+ verified?: string[] | undefined;
575
+ verifiedBy?: string | undefined;
576
+ provenance?: {
577
+ [x: string]: import("hono/utils/types").JSONValue;
578
+ url: string;
579
+ name?: string | undefined;
580
+ rel?: string | undefined;
581
+ }[] | undefined;
582
+ extensions?: {
583
+ [x: string]: import("hono/utils/types").JSONValue;
584
+ } | undefined;
585
+ } | undefined;
402
586
  tags?: string[] | undefined;
403
587
  at?: {
404
588
  [x: string]: import("hono/utils/types").JSONValue;
@@ -418,6 +602,29 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
418
602
  durationMs?: number | undefined;
419
603
  startedAtMs?: number | undefined;
420
604
  detail?: string | undefined;
605
+ scope?: {
606
+ [x: string]: import("hono/utils/types").JSONValue;
607
+ surface?: string | undefined;
608
+ deviceKind?: string | undefined;
609
+ syntheticData?: boolean | undefined;
610
+ dependencies?: string | undefined;
611
+ audioCaptured?: boolean | undefined;
612
+ pacing?: string | undefined;
613
+ asserted?: string[] | undefined;
614
+ limitations?: string[] | undefined;
615
+ declaredBy?: string | undefined;
616
+ verified?: string[] | undefined;
617
+ verifiedBy?: string | undefined;
618
+ provenance?: {
619
+ [x: string]: import("hono/utils/types").JSONValue;
620
+ url: string;
621
+ name?: string | undefined;
622
+ rel?: string | undefined;
623
+ }[] | undefined;
624
+ extensions?: {
625
+ [x: string]: import("hono/utils/types").JSONValue;
626
+ } | undefined;
627
+ } | undefined;
421
628
  steps?: {
422
629
  [x: string]: import("hono/utils/types").JSONValue;
423
630
  name: string;
@@ -444,6 +651,29 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
444
651
  durationMs?: number | undefined;
445
652
  description?: string | undefined;
446
653
  omitted?: string | undefined;
654
+ scope?: {
655
+ [x: string]: import("hono/utils/types").JSONValue;
656
+ surface?: string | undefined;
657
+ deviceKind?: string | undefined;
658
+ syntheticData?: boolean | undefined;
659
+ dependencies?: string | undefined;
660
+ audioCaptured?: boolean | undefined;
661
+ pacing?: string | undefined;
662
+ asserted?: string[] | undefined;
663
+ limitations?: string[] | undefined;
664
+ declaredBy?: string | undefined;
665
+ verified?: string[] | undefined;
666
+ verifiedBy?: string | undefined;
667
+ provenance?: {
668
+ [x: string]: import("hono/utils/types").JSONValue;
669
+ url: string;
670
+ name?: string | undefined;
671
+ rel?: string | undefined;
672
+ }[] | undefined;
673
+ extensions?: {
674
+ [x: string]: import("hono/utils/types").JSONValue;
675
+ } | undefined;
676
+ } | undefined;
447
677
  tags?: string[] | undefined;
448
678
  at?: {
449
679
  [x: string]: import("hono/utils/types").JSONValue;
@@ -502,6 +732,29 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
502
732
  durationMs?: number | undefined;
503
733
  description?: string | undefined;
504
734
  omitted?: string | undefined;
735
+ scope?: {
736
+ [x: string]: import("hono/utils/types").JSONValue;
737
+ surface?: string | undefined;
738
+ deviceKind?: string | undefined;
739
+ syntheticData?: boolean | undefined;
740
+ dependencies?: string | undefined;
741
+ audioCaptured?: boolean | undefined;
742
+ pacing?: string | undefined;
743
+ asserted?: string[] | undefined;
744
+ limitations?: string[] | undefined;
745
+ declaredBy?: string | undefined;
746
+ verified?: string[] | undefined;
747
+ verifiedBy?: string | undefined;
748
+ provenance?: {
749
+ [x: string]: import("hono/utils/types").JSONValue;
750
+ url: string;
751
+ name?: string | undefined;
752
+ rel?: string | undefined;
753
+ }[] | undefined;
754
+ extensions?: {
755
+ [x: string]: import("hono/utils/types").JSONValue;
756
+ } | undefined;
757
+ } | undefined;
505
758
  tags?: string[] | undefined;
506
759
  at?: {
507
760
  [x: string]: import("hono/utils/types").JSONValue;
@@ -570,6 +823,29 @@ export declare function createReportsApp(deps: ReportsAppDeps): import("hono/hon
570
823
  durationMs?: number | undefined;
571
824
  description?: string | undefined;
572
825
  omitted?: string | undefined;
826
+ scope?: {
827
+ [x: string]: import("hono/utils/types").JSONValue;
828
+ surface?: string | undefined;
829
+ deviceKind?: string | undefined;
830
+ syntheticData?: boolean | undefined;
831
+ dependencies?: string | undefined;
832
+ audioCaptured?: boolean | undefined;
833
+ pacing?: string | undefined;
834
+ asserted?: string[] | undefined;
835
+ limitations?: string[] | undefined;
836
+ declaredBy?: string | undefined;
837
+ verified?: string[] | undefined;
838
+ verifiedBy?: string | undefined;
839
+ provenance?: {
840
+ [x: string]: import("hono/utils/types").JSONValue;
841
+ url: string;
842
+ name?: string | undefined;
843
+ rel?: string | undefined;
844
+ }[] | undefined;
845
+ extensions?: {
846
+ [x: string]: import("hono/utils/types").JSONValue;
847
+ } | undefined;
848
+ } | undefined;
573
849
  tags?: string[] | undefined;
574
850
  at?: {
575
851
  [x: string]: import("hono/utils/types").JSONValue;
@@ -99,6 +99,8 @@ export type TestSummary = {
99
99
  readonly artifactTags?: readonly string[];
100
100
  /** The normalized tags its attachments inherit from their check and run (those of the results that have attachments), apart from `artifactTags`. What an inherited artifact query also matches. */
101
101
  readonly inheritedArtifactTags?: readonly string[];
102
+ /** What each distinct playable recording of its current results declares it shows, as filter tokens (`device:physical`, `audio:silent`; see `scopeTokens`). A recording that declared nothing is `['unknown']`. Absent when it has no playable recording. A query's `scope` must hold for one recording, so evidence from two different devices never combines. */
103
+ readonly scopeProfiles?: readonly (readonly string[])[];
102
104
  readonly labels: Readonly<Record<string, string>>;
103
105
  /** What its current results carry: attachments, steps, retries, and the kinds of attachment. */
104
106
  readonly evidence: {
@@ -172,6 +174,8 @@ export type CatalogFacets = {
172
174
  readonly tags: readonly FacetValue[];
173
175
  /** The tags of attachments, normalized. Absent in documents of older servers. */
174
176
  readonly artifactTags?: readonly FacetValue[];
177
+ /** The scope tokens of its recordings (`device:simulator`, `audio:recorded`, `unknown`), with how many tests have a recording that states each. Absent in documents of older servers. */
178
+ readonly scopes?: readonly FacetValue[];
175
179
  readonly branches: readonly FacetValue[];
176
180
  readonly labels: Readonly<Record<string, readonly FacetValue[]>>;
177
181
  };
@@ -195,6 +199,8 @@ export type TestQuery = {
195
199
  readonly artifactTag?: readonly string[];
196
200
  /** With `artifactTag`: also match the tags an attachment inherits from its check and run (a recording without a tag of its own under a run tagged `demo`). Default false: the exact query. */
197
201
  readonly inherited?: boolean;
202
+ /** Only tests with one playable recording that states every one of these (`device:physical`, `audio:recorded`, `data:synthetic`, `dependencies:live`, `surface:browser`, `pacing:paced`; see `scopeTokens`). Evidence that did not declare a value never matches it; `unknown` matches only a recording that declared no scope. */
203
+ readonly scope?: readonly string[];
198
204
  /** Only tests recorded in this run (a key from `runList`). */
199
205
  readonly run?: string;
200
206
  readonly sort?: TestSort;
@@ -264,6 +270,11 @@ export type RunSummary = {
264
270
  * and references that cannot be played. Absent when it has none, and in a report that carries no video information.
265
271
  */
266
272
  readonly videos?: VideoCounts;
273
+ /**
274
+ * Files attached to the run itself, in its layers (and their parts), not to any one check: review movies, archives,
275
+ * summaries. All kinds, videos included. Absent when none, and in a report that carries no such information.
276
+ */
277
+ readonly attachments?: number;
267
278
  /** The normalized tags of the attachments of its results and layers, sorted and distinct. Absent when none is tagged. */
268
279
  readonly artifactTags?: readonly string[];
269
280
  /** The tags of its checks and layers, which its attachments inherit (what `inherited=1` also matches), sorted and distinct. Absent when none. */
@@ -308,12 +319,33 @@ export type RunResult = {
308
319
  /** Every video its evidence references, with who holds it (check, retry attempt or step). Absent when there is none. */
309
320
  readonly videos?: readonly VideoRef[];
310
321
  };
322
+ /**
323
+ * One layer of a run, as the run's artifact hub names it: who recorded it, how many checks it holds (zero is possible
324
+ * and is not a pass) and the files it carries as a whole. Read the files with `GET /evidence/detail/{backend}/{id}`.
325
+ */
326
+ export type RunLayer = {
327
+ /** The layer's id: 64 hex digits for an archived layer. */
328
+ readonly id: string;
329
+ /** The backend it was read from, for `GET /evidence/detail/{backend}/{id}`. */
330
+ readonly backend: string;
331
+ readonly producer: string;
332
+ readonly environment: string | null;
333
+ readonly recordedAtMs: number;
334
+ /** How many checks the layer recorded. A layer of zero checks is evidence only: it is not a passing test. */
335
+ readonly checks: number;
336
+ /** Files the layer carries as a whole (not those of its checks). */
337
+ readonly attachments: number;
338
+ /** Its videos, when it has any. */
339
+ readonly videos?: VideoCounts;
340
+ };
311
341
  /** `GET /api/reports/runs/detail`: one run and every result it recorded. */
312
342
  export type RunDetail = {
313
343
  readonly schemaVersion: typeof TEST_CATALOG_VERSION;
314
344
  readonly version: string;
315
345
  readonly run: RunSummary;
316
346
  readonly results: readonly RunResult[];
347
+ /** Every layer of the run, oldest first, so its run-level files can be found even in a layer with no checks. Absent from an older server. */
348
+ readonly layers?: readonly RunLayer[];
317
349
  };
318
350
  /**
319
351
  * One recorded result that carries evidence (an attachment: a recording, a screenshot, a trace), with where and when it
@@ -417,6 +449,7 @@ export declare class TestHistory {
417
449
  run(key: string): {
418
450
  readonly run: RunSummary;
419
451
  readonly results: RunResult[];
452
+ readonly layers: RunLayer[];
420
453
  } | null;
421
454
  /**
422
455
  * Every recorded result that carries an attachment, newest first, with the values a query looks at. Built once per
@@ -499,7 +532,7 @@ export declare function artifactList(history: TestHistory, version: string, quer
499
532
  }): ArtifactList;
500
533
  /** One run with every result it recorded, or null when no run has that key. */
501
534
  export declare function runDetail(history: TestHistory, version: string, key: string): RunDetail | null;
502
- /** Reads a `TestQuery` from URL search parameters: `q`, `sort`, `offset`, `limit`, `run`, and repeatable or comma-separated `status`, `environment`, `producer`, `domain`, `spec`, `tag`, `artifactTag`, and `inherited=1`. */
535
+ /** Reads a `TestQuery` from URL search parameters: `q`, `sort`, `offset`, `limit`, `run`, and repeatable or comma-separated `status`, `environment`, `producer`, `domain`, `spec`, `tag`, `artifactTag`, `scope` (every token must hold for one recording), and `inherited=1`. */
503
536
  export declare function parseTestQuery(params: URLSearchParams): TestQuery;
504
537
  /** A duration for a person: `850 ms`, `12.3 s`, `4m 05s`. */
505
538
  export declare function formatMs(ms: number | null | undefined): string;
@@ -25,6 +25,7 @@
25
25
  import { displayTag, normalizeTag, normalizeTags } from './artifact-tags.js';
26
26
  import { facetsOf, forRuns, labelList, matchesFilter, outcomeOfCounts, splitList, usedFilters } from './artifact-query.js';
27
27
  import { videoCounts } from './evidence-detail.js';
28
+ import { matchesScope, scopeTokens, UNKNOWN_SCOPE } from './evidence-scope.js';
28
29
  import { checkSlots } from './evidence-view.js';
29
30
  import { combineEvidence, emptyCounts, slotKey, specPath as normalSpecPath } from './layer-merge.js';
30
31
  import { RecordedSpecs } from './recorded-specs.js';
@@ -131,7 +132,7 @@ export class TestHistory {
131
132
  const key = runKey(layer);
132
133
  let run = this.#runs.get(key);
133
134
  if (!run) {
134
- run = { key, runUrl: layer.runUrl, branch: layer.branch, sha: layer.sha, start: layer.recordedAtMs, end: layer.recordedAtMs, firstAt: layer.recordedAtMs, first: layer.id, producers: new Set(), environments: new Set(), layers: 0, runId: layer.run?.id, title: undefined, titledAt: 0, titledBy: '', freshness: undefined, videos: [], evidence: { tags: new Set(), artifactTags: new Set(), roles: new Set(), labels: new Set() } };
135
+ run = { key, runUrl: layer.runUrl, branch: layer.branch, sha: layer.sha, start: layer.recordedAtMs, end: layer.recordedAtMs, firstAt: layer.recordedAtMs, first: layer.id, producers: new Set(), environments: new Set(), layers: 0, runId: layer.run?.id, title: undefined, titledAt: 0, titledBy: '', freshness: undefined, videos: [], attachments: 0, members: [], evidence: { tags: new Set(), artifactTags: new Set(), roles: new Set(), labels: new Set() } };
135
136
  this.#runs.set(key, run);
136
137
  }
137
138
  // A run is told by its first layer (by time, then id), whatever order its layers arrive in.
@@ -160,6 +161,10 @@ export class TestHistory {
160
161
  run.layers += 1;
161
162
  if (layer.evidence?.videos)
162
163
  run.videos.push(...layer.evidence.videos);
164
+ const held = layer.evidence?.attachments ?? 0;
165
+ run.attachments += held;
166
+ const layerVideos = videoCounts(layer.evidence?.videos);
167
+ run.members.push({ id: layer.id, backend, producer: layer.producer, environment: layer.environment, recordedAtMs: layer.recordedAtMs, checks: rows.length, attachments: held, ...(layerVideos ? { videos: layerVideos } : {}) });
163
168
  if (layer.evidence)
164
169
  takeEvidence(run.evidence, layer.evidence);
165
170
  const origin = originOf(layer, backend);
@@ -252,7 +257,8 @@ export class TestHistory {
252
257
  ...(r.e?.videos ? { videos: r.e.videos } : {}),
253
258
  }));
254
259
  results.sort((a, b) => FAILURE_RANK[b.status] - FAILURE_RANK[a.status] || compareText(a.specPath ?? '', b.specPath ?? '') || compareText(a.title, b.title) || compareText(a.environment ?? '', b.environment ?? '') || compareText(a.producer, b.producer) || compareText(a.key, b.key));
255
- return { run: runSummary(run, cells), results };
260
+ const layers = [...run.members].sort((a, b) => a.recordedAtMs - b.recordedAtMs || compareText(a.id, b.id));
261
+ return { run: runSummary(run, cells), results, layers };
256
262
  }
257
263
  /**
258
264
  * Every recorded result that carries an attachment, newest first, with the values a query looks at. Built once per
@@ -351,6 +357,7 @@ function runSummary(run, cells) {
351
357
  producers: [...run.producers].sort(), environments: [...run.environments].sort(),
352
358
  layers: run.layers, tests: tests.size, counts, totalDurationMs: measured ? total : null, failing,
353
359
  ...(video ? { videos: video } : {}),
360
+ ...(run.attachments ? { attachments: run.attachments } : {}),
354
361
  ...(carried.artifactTags.size ? { artifactTags: [...carried.artifactTags].sort() } : {}),
355
362
  ...(carried.roles.size ? { types: typesOf(carried.roles, (video?.recordings ?? 0) > 0) } : {}),
356
363
  ...(carried.labels.size ? { labels: [...carried.labels].sort() } : {}),
@@ -448,10 +455,16 @@ function summaryOf(slot, history, known) {
448
455
  const labels = {};
449
456
  let attachments = 0, steps = 0, attempts = 0;
450
457
  const roles = new Set();
458
+ const profiles = new Map();
451
459
  for (const [at, outcome] of outcomes.entries()) {
452
460
  const evidence = outcome.evidence;
453
461
  if (!evidence)
454
462
  continue;
463
+ for (const video of evidence.videos ?? [])
464
+ if (video.available) {
465
+ const tokens = scopeTokens(video.scope);
466
+ profiles.set(tokens.join('\n'), tokens);
467
+ }
455
468
  if (evidence.attachments > 0)
456
469
  for (const tag of normalizeTags([...evidence.tags, ...(slot.outcomes[at]?.layer.evidence?.tags ?? [])]))
457
470
  inheritedTags.add(tag);
@@ -481,6 +494,7 @@ function summaryOf(slot, history, known) {
481
494
  tags: tagList,
482
495
  ...(artifactTags.size ? { artifactTags: [...artifactTags].sort() } : {}),
483
496
  ...([...inheritedTags].some(tag => !artifactTags.has(tag)) ? { inheritedArtifactTags: [...inheritedTags].filter(tag => !artifactTags.has(tag)).sort() } : {}),
497
+ ...(profiles.size ? { scopeProfiles: [...profiles.values()] } : {}),
484
498
  labels,
485
499
  evidence: { attachments, steps, attempts, roles: [...roles].sort() },
486
500
  lastRecordedAtMs: slot.latestAtMs,
@@ -490,7 +504,7 @@ function summaryOf(slot, history, known) {
490
504
  /** The lowercase text a search matches a test against: title, id, spec, domain, tags, labels, environments and producers. */
491
505
  export function haystackOf(test) {
492
506
  return [
493
- test.title, test.id, test.specPath ?? '', test.spec?.title ?? '', test.spec?.domain ?? '', ...test.tags, ...(test.artifactTags ?? []).map(displayTag),
507
+ test.title, test.id, test.specPath ?? '', test.spec?.title ?? '', test.spec?.domain ?? '', ...test.tags, ...(test.artifactTags ?? []).map(displayTag), ...(test.scopeProfiles ?? []).flat().filter(token => token !== UNKNOWN_SCOPE),
494
508
  ...Object.entries(test.labels).flatMap(([key, value]) => [key, value]), ...test.environments, ...test.producers,
495
509
  ].join('\n').toLowerCase();
496
510
  }
@@ -597,6 +611,7 @@ export function catalogModel(layers, sources = {}) {
597
611
  specs: [...specGroups].map(([, list]) => { const g = group(list[0].spec.path, list); return { ...list[0].spec, tests: g.tests, counts: g.counts }; }).sort((a, b) => compareText(a.domain, b.domain) || compareText(a.title, b.title)),
598
612
  tags: facet(tests, test => test.tags),
599
613
  artifactTags: facet(tests, test => test.artifactTags ?? []),
614
+ scopes: facet(tests, test => (test.scopeProfiles ?? []).flat()),
600
615
  branches: facet(tests, test => test.stats.branches),
601
616
  labels: Object.fromEntries([...labelKeys].sort().map(key => [key, facet(tests, test => (test.labels[key] === undefined ? [] : [test.labels[key]]))])),
602
617
  };
@@ -633,6 +648,8 @@ export function matchesQuery(model, test, query, inRun) {
633
648
  return false;
634
649
  if (query.artifactTag?.length && !query.artifactTag.some(tag => { const wanted = normalizeTag(tag); return wanted !== null && ((test.artifactTags?.includes(wanted) ?? false) || (query.inherited === true && (test.inheritedArtifactTags?.includes(wanted) ?? false))); }))
635
650
  return false;
651
+ if (query.scope?.length && !(test.scopeProfiles ?? []).some(tokens => matchesScope(tokens, query.scope)))
652
+ return false;
636
653
  if (inRun && !inRun.has(test.key))
637
654
  return false;
638
655
  return true;
@@ -725,7 +742,7 @@ export function runDetail(history, version, key) {
725
742
  const found = history.run(key);
726
743
  return found ? { schemaVersion: TEST_CATALOG_VERSION, version, ...found } : null;
727
744
  }
728
- /** Reads a `TestQuery` from URL search parameters: `q`, `sort`, `offset`, `limit`, `run`, and repeatable or comma-separated `status`, `environment`, `producer`, `domain`, `spec`, `tag`, `artifactTag`, and `inherited=1`. */
745
+ /** Reads a `TestQuery` from URL search parameters: `q`, `sort`, `offset`, `limit`, `run`, and repeatable or comma-separated `status`, `environment`, `producer`, `domain`, `spec`, `tag`, `artifactTag`, `scope` (every token must hold for one recording), and `inherited=1`. */
729
746
  export function parseTestQuery(params) {
730
747
  const many = (name) => params.getAll(name).flatMap(splitList);
731
748
  const number = (name) => { const value = params.get(name); return value !== null && /^\d+$/.test(value) ? Number(value) : undefined; };
@@ -734,7 +751,7 @@ export function parseTestQuery(params) {
734
751
  const q = params.get('q');
735
752
  if (q)
736
753
  query.q = q;
737
- for (const name of ['status', 'environment', 'producer', 'domain', 'spec', 'tag', 'artifactTag']) {
754
+ for (const name of ['status', 'environment', 'producer', 'domain', 'spec', 'tag', 'artifactTag', 'scope']) {
738
755
  const values = many(name);
739
756
  if (values.length)
740
757
  query[name] = values;
@@ -815,6 +832,7 @@ export function historyMarkdown(document) {
815
832
  `- Duration: ${d ? `avg ${formatMs(d.avgMs)}, p50 ${formatMs(d.p50Ms)}, p95 ${formatMs(d.p95Ms)}, min ${formatMs(d.minMs)}, max ${formatMs(d.maxMs)}, last ${formatMs(d.lastMs)}` : 'not measured'}`,
816
833
  ...(test.tags.length ? [`- Tags: ${test.tags.join(', ')}`] : []),
817
834
  ...(test.artifactTags?.length ? [`- Artifact tags: ${test.artifactTags.map(displayTag).join(', ')}`] : []),
835
+ ...(test.scopeProfiles?.length ? [`- Recording scope: ${test.scopeProfiles.map(tokens => tokens.join(' ')).join('; ')}`] : []),
818
836
  ...(Object.keys(test.labels).length ? [`- Labels: ${Object.entries(test.labels).map(([k, v]) => `${k}=${v}`).join(', ')}`] : []),
819
837
  `- Evidence: ${test.evidence.attachments} attachments${test.evidence.roles.length ? ` (${test.evidence.roles.join(', ')})` : ''}, ${test.evidence.steps} steps`,
820
838
  '',