@crvouga/atlas 1.3.0 → 1.5.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 (33) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/reports/src/evidence-detail.d.ts +23 -0
  3. package/dist/reports/src/evidence-detail.js +19 -1
  4. package/dist/reports/src/export-evidence.js +1 -1
  5. package/dist/reports/src/layers-route.d.ts +2 -1
  6. package/dist/reports/src/layers-route.js +8 -4
  7. package/dist/reports/src/test-catalog.d.ts +27 -0
  8. package/dist/reports/src/test-catalog.js +8 -2
  9. package/dist/schema/src/api.d.ts +22 -0
  10. package/dist/schema/src/api.js +10 -1
  11. package/dist/server/src/openapi.js +2 -2
  12. package/docs/reference/http-api.md +4 -4
  13. package/docs/reference/reports.md +64 -7
  14. package/docs/rich-evidence.md +23 -0
  15. package/docs/ui.md +22 -2
  16. package/llms-full.txt +49 -6
  17. package/package.json +1 -1
  18. package/packages/schema/schemas/check-detail.schema.json +24 -0
  19. package/packages/schema/schemas/report-run.schema.json +85 -0
  20. package/packages/schema/schemas/report-runs.schema.json +6 -0
  21. package/packages/server/openapi.json +119 -4
  22. package/packages/visualizer/src/evidence/artifacts.tsx +69 -18
  23. package/packages/visualizer/src/evidence/behavior-panel.tsx +291 -0
  24. package/packages/visualizer/src/evidence/behavior.test.ts +99 -0
  25. package/packages/visualizer/src/evidence/behavior.ts +268 -0
  26. package/packages/visualizer/src/evidence/check-detail.tsx +4 -0
  27. package/packages/visualizer/src/evidence/feature-page.tsx +82 -36
  28. package/packages/visualizer/src/evidence/gherkin.ts +31 -1
  29. package/packages/visualizer/src/evidence/spec-index.tsx +48 -15
  30. package/packages/visualizer/src/explorer/Explorer.tsx +9 -5
  31. package/packages/visualizer/src/explorer/RunArtifacts.tsx +246 -0
  32. package/packages/visualizer/src/explorer/RunsView.tsx +40 -9
  33. package/packages/visualizer/src/explorer/search.ts +5 -2
package/llms-full.txt CHANGED
@@ -1290,6 +1290,29 @@ or the command line, an attachment may name a `path` instead; `atlas report publ
1290
1290
  `atlas report publish --ctrf` keeps it ([native runner reporters](runners.md) cover CodeceptJS, Mocha, Jest and Node's
1291
1291
  test runner).
1292
1292
 
1293
+ ## Run artifacts and direct links
1294
+
1295
+ Files attached to a run as a whole (top-level `attachments`: a review movie, a ZIP, an HTML gallery, a summary) are
1296
+ shown under **Artifacts** when you open a run in the viewer's Runs tab, without opening any test. The movie plays
1297
+ inline, an archive is a download, and each file keeps its name, role, type, size, duration and tags. They are grouped
1298
+ by the layer that carries them, with who recorded it and when. A layer may hold no checks at all, only files: it is
1299
+ listed with `No checks in this layer`, and a run of only such layers shows `No checks recorded`, never `0 passed`.
1300
+
1301
+ - **Direct link.** *Link to this file* gives an address that names the project, the run, one immutable layer and one
1302
+ attachment: `#/?tab=runs&openRun=<run key>&layer=<layer id>&artifact=<id:… | key:… | name:…>`. Opened in a fresh tab
1303
+ it selects that same file of that same layer, whatever runs arrived since. A link whose file is not in the layer says
1304
+ so instead of showing another file.
1305
+ - **Shared recordings.** Tests that reference the same bytes (same SHA-256 or bundle key) are one recording. The run's
1306
+ *Recordings in tests* lists each once, with every test that references it, and a run-level file that a test also
1307
+ references says so.
1308
+ - **Unplayable files.** A file with no stored bytes, with an address that is not a web address, in a type the viewer
1309
+ has no preview for, or that the browser cannot play, shows what is wrong and, when it can, a download.
1310
+ - **Safety is unchanged.** Files are served only through the attachment route (never sniffed, sandboxed), and an HTML
1311
+ archive renders in an iframe with an empty `sandbox`: it cannot run script.
1312
+ - **API.** `GET /api/reports/runs/detail` lists the run's `layers` (backend, check count, file count) and
1313
+ `GET /api/reports/evidence/detail/{backend}/{id}` returns what a layer carries as a whole (and, as `parts`, what its
1314
+ parts carry). Both only gained optional fields.
1315
+
1293
1316
  ## Recording completeness
1294
1317
 
1295
1318
  A green run does not say whether the videos you expected were kept and published. Atlas answers that separately from
@@ -3876,6 +3899,26 @@ and component defaults still live only in `ui/theme.tsx`. Quint/Alloy grammar so
3876
3899
  [`ui/grammars/README.md`](../packages/visualizer/src/ui/grammars/README.md). Unknown formats remain plain
3877
3900
  text, and parse failures must leave the source visible. Syntax highlighting never means a spec passed.
3878
3901
 
3902
+ ## Behaviors: Read, Watch, Inspect
3903
+
3904
+ A Gherkin page reads as behaviors: each scenario, or each example row of an outline (never the outline template as an
3905
+ extra), is one behavior in one coverage state (`evidence/behavior.ts`). Opening a behavior shows Mantine `Tabs`:
3906
+ **Read** (the steps with the example's values filled in), **Watch** (what is expected next to the matched recording,
3907
+ its status and provenance) and **Inspect** (the existing check detail: runs, environments, attempts, files). A behavior
3908
+ with a playable recording opens on Watch, one in a run in progress on Inspect, otherwise on Read. The video is read only
3909
+ when Watch is on screen.
3910
+
3911
+ - States stay distinct and none but `demonstrated` is a pass with proof: recording missing, recording of an older spec
3912
+ revision, no recording, API only, failed, running, skipped, parked (tagged `@parked`, `@wip`, `@pending`) and not executed.
3913
+ Counts are in the Coverage section above the source; an empty state is left out except `demonstrated`.
3914
+ - The promise's revision (the spec's fingerprint now) and the evidence's revision (the fingerprint the layer recorded)
3915
+ are shown separately. A known difference demotes a recording to "Recording is older" with an alert; a missing
3916
+ fingerprint is "not recorded", never a match.
3917
+ - A recording is always labelled illustrative: it shows one run and does not approve a behavior for production.
3918
+ `demo`/`representative` tags only add a "Representative demo" label.
3919
+ - The spec index searches titles, paths and declared domains, and can group by the domain from spec metadata (specs with
3920
+ none are listed under "No domain declared"; the Tests view reports domain problems).
3921
+
3879
3922
  ## Icons and notation marks
3880
3923
 
3881
3924
  Use named imports from `@tabler/icons-react` for all UI glyphs. The icon package supplies SVG paths;
@@ -3934,9 +3977,9 @@ The narrowly scoped exceptions are:
3934
3977
  | Area | Screens/interactions | Mantine composition |
3935
3978
  | --- | --- | --- |
3936
3979
  | Shared shell | Project switching, spec navigation, run selection, connection status, diagnostics, mobile index | AppShell, Burger, NavLink, Menu, Popover, NativeSelect, Drawer |
3937
- | Project home | Tests/runs/artifacts tabs, metrics, search, status/environment/domain/artifact-tag filters, run and artifact facets with saved views, grouping, sorting, show more | Container, Tabs, SimpleGrid, Paper, TextInput, MultiSelect, Popover, Button, Accordion |
3980
+ | Project home | Tests/runs/artifacts tabs, a run's Artifacts (files of its layers, shared recordings, direct links), metrics, search, status/environment/domain/artifact-tag filters, run and artifact facets with saved views, grouping, sorting, show more | Container, Tabs, SimpleGrid, Paper, TextInput, MultiSelect, Popover, Button, Accordion |
3938
3981
  | Test detail | Current outcomes, statistics, duration graph, history, artifacts, recording; the `layer` address parameter pins it to one run's result, labelled "This run" against "Latest combined evidence" | Drawer, Table, Badge, Alert, Stage |
3939
- | Specs | All six existing kinds, overview/index, Gherkin rules/scenarios/examples, Quint declarations/traces, DMN tables, source text | SpecHeader, Accordion, Table, CodeHighlightTabs, ResultRow |
3982
+ | Specs | All six existing kinds, overview/index with title search and folder or declared-domain grouping, Gherkin rules/scenarios/examples with a coverage summary and Read / Watch / Inspect per behavior, Quint declarations/traces, DMN tables, source text | SpecHeader, Accordion, Tabs, Table, CodeHighlightTabs, ResultRow |
3940
3983
  | Statecharts | Graph, screen/event/journey details, developer metadata, chart test results | Mantine cards/navigation and product compositions; graph exception above |
3941
3984
  | Evidence | Steps, retries, annotations, facts, unknown extensions, safe downloads and previews | List, Badge, Accordion, Code, Image, native media |
3942
3985
  | Recordings | Pictures/video/timeline, actor selection, moments, failure jumps, timing, model-state differences | Tabs, Slider, Button, ScrollArea, Table, Progress |
@@ -5230,7 +5273,7 @@ Responses: `200` The matching results and the facets.; `304` Unchanged since the
5230
5273
 
5231
5274
  `GET /api/reports/runs/detail` · role: read
5232
5275
 
5233
- One run and its last result for every test, environment and producer it recorded, failures first. Send `format=markdown` for Markdown.
5276
+ One run and its last result for every test, environment and producer it recorded, failures first, and `layers`: each layer of the run with its backend, how many checks it recorded (zero means evidence only, never a pass) and how many files it carries as a whole. Open a layer's files with `/api/reports/evidence/detail/{backend}/{id}`. Send `format=markdown` for Markdown.
5234
5277
 
5235
5278
  - `key` (query, required): The run's `key` from `/api/reports/runs`, URL-encoded.
5236
5279
  - `format` (query): `markdown` for Markdown instead of JSON.
@@ -5242,7 +5285,7 @@ Responses: `200` The run and its results.; `304` Unchanged since the `If-None-Ma
5242
5285
 
5243
5286
  `GET /api/projects/{project}/reports/runs/detail` · role: read
5244
5287
 
5245
- One run and its last result for every test, environment and producer it recorded, failures first. Send `format=markdown` for Markdown.
5288
+ One run and its last result for every test, environment and producer it recorded, failures first, and `layers`: each layer of the run with its backend, how many checks it recorded (zero means evidence only, never a pass) and how many files it carries as a whole. Open a layer's files with `/api/reports/evidence/detail/{backend}/{id}`. Send `format=markdown` for Markdown.
5246
5289
 
5247
5290
  Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
5248
5291
 
@@ -5352,7 +5395,7 @@ Responses: `200` The rows.; `401` No token, or an unknown one. Send `Authorizati
5352
5395
 
5353
5396
  `GET /api/reports/evidence/detail/{backend}/{id}` · role: read
5354
5397
 
5355
- The timeline, annotations, retries, attachments and extensions of one check, and what its run carries as a whole. Without `check`, only the run's. Attachment bytes are fetched from `/api/reports/layers/{id}/blobs/{bundleKey}`.
5398
+ The timeline, annotations, retries, attachments and extensions of one check, and what its run carries as a whole. Without `check`, only the run's, and `parts`: what each part of the layer that carries files or links of its own holds. Attachment bytes are fetched from `/api/reports/layers/{id}/blobs/{bundleKey}`.
5356
5399
 
5357
5400
  - `backend` (path, required): A backend id from `/api/server`.
5358
5401
  - `id` (path, required): A layer id: 64 lowercase hex digits.
@@ -5366,7 +5409,7 @@ Responses: `200` The detail.; `401` No token, or an unknown one. Send `Authoriza
5366
5409
 
5367
5410
  `GET /api/projects/{project}/reports/evidence/detail/{backend}/{id}` · role: read
5368
5411
 
5369
- The timeline, annotations, retries, attachments and extensions of one check, and what its run carries as a whole. Without `check`, only the run's. Attachment bytes are fetched from `/api/reports/layers/{id}/blobs/{bundleKey}`.
5412
+ The timeline, annotations, retries, attachments and extensions of one check, and what its run carries as a whole. Without `check`, only the run's, and `parts`: what each part of the layer that carries files or links of its own holds. Attachment bytes are fetched from `/api/reports/layers/{id}/blobs/{bundleKey}`.
5370
5413
 
5371
5414
  Scoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.
5372
5415
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crvouga/atlas",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Atlas: specs of any kind, reported as layered evidence. Publish checks from any runner (JUnit, CTRF, a script), combine them across nodes, branches, environments and backends, gate CI on the result, and run statecharts against the real app.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1645,6 +1645,30 @@
1645
1645
  "additionalProperties": {},
1646
1646
  "description": "What the run as a whole carries: its logs, links, labels, annotations, scope and extensions"
1647
1647
  },
1648
+ "parts": {
1649
+ "description": "Without `check` only: the parts of the layer that carry files or links of their own, each with what it carries. Absent when none",
1650
+ "type": "array",
1651
+ "items": {
1652
+ "type": "object",
1653
+ "properties": {
1654
+ "kind": {
1655
+ "type": "string"
1656
+ },
1657
+ "id": {
1658
+ "type": "string"
1659
+ },
1660
+ "title": {
1661
+ "type": "string"
1662
+ }
1663
+ },
1664
+ "required": [
1665
+ "kind",
1666
+ "id",
1667
+ "title"
1668
+ ],
1669
+ "additionalProperties": {}
1670
+ }
1671
+ },
1648
1672
  "inheritedScope": {
1649
1673
  "description": "The scope the check inherits: its part's declaration, else its layer's. Absent when neither declared one: unknown, not live or physical",
1650
1674
  "type": "object",
@@ -171,6 +171,12 @@
171
171
  ],
172
172
  "additionalProperties": {}
173
173
  },
174
+ "attachments": {
175
+ "description": "Files attached to the run itself, in its layers, not to a check. Absent when there are none",
176
+ "type": "integer",
177
+ "exclusiveMinimum": 0,
178
+ "maximum": 9007199254740991
179
+ },
174
180
  "artifactTags": {
175
181
  "description": "The normalized tags of the attachments of its results, sorted and distinct. Absent when none is tagged, and in reports of older servers",
176
182
  "type": "array",
@@ -431,6 +437,85 @@
431
437
  ],
432
438
  "additionalProperties": {}
433
439
  }
440
+ },
441
+ "layers": {
442
+ "description": "Every layer of the run, oldest first. Absent from an older server",
443
+ "type": "array",
444
+ "items": {
445
+ "type": "object",
446
+ "properties": {
447
+ "id": {
448
+ "type": "string",
449
+ "description": "The layer id; read its files with `/api/reports/evidence/detail/{backend}/{id}`"
450
+ },
451
+ "backend": {
452
+ "type": "string"
453
+ },
454
+ "producer": {
455
+ "type": "string"
456
+ },
457
+ "environment": {
458
+ "type": [
459
+ "string",
460
+ "null"
461
+ ]
462
+ },
463
+ "recordedAtMs": {
464
+ "type": "number"
465
+ },
466
+ "checks": {
467
+ "type": "integer",
468
+ "minimum": 0,
469
+ "maximum": 9007199254740991,
470
+ "description": "How many checks the layer recorded. Zero means evidence only, not a pass"
471
+ },
472
+ "attachments": {
473
+ "type": "integer",
474
+ "minimum": 0,
475
+ "maximum": 9007199254740991,
476
+ "description": "Files the layer carries as a whole, not those of its checks"
477
+ },
478
+ "videos": {
479
+ "type": "object",
480
+ "properties": {
481
+ "references": {
482
+ "type": "integer",
483
+ "minimum": 0,
484
+ "maximum": 9007199254740991,
485
+ "description": "Every reference to a video; identical bytes referenced twice count twice"
486
+ },
487
+ "recordings": {
488
+ "type": "integer",
489
+ "minimum": 0,
490
+ "maximum": 9007199254740991,
491
+ "description": "Distinct playable recordings"
492
+ },
493
+ "unavailable": {
494
+ "type": "integer",
495
+ "minimum": 0,
496
+ "maximum": 9007199254740991,
497
+ "description": "References that name no bytes"
498
+ }
499
+ },
500
+ "required": [
501
+ "references",
502
+ "recordings",
503
+ "unavailable"
504
+ ],
505
+ "additionalProperties": {}
506
+ }
507
+ },
508
+ "required": [
509
+ "id",
510
+ "backend",
511
+ "producer",
512
+ "environment",
513
+ "recordedAtMs",
514
+ "checks",
515
+ "attachments"
516
+ ],
517
+ "additionalProperties": {}
518
+ }
434
519
  }
435
520
  },
436
521
  "required": [
@@ -179,6 +179,12 @@
179
179
  ],
180
180
  "additionalProperties": {}
181
181
  },
182
+ "attachments": {
183
+ "description": "Files attached to the run itself, in its layers, not to a check. Absent when there are none",
184
+ "type": "integer",
185
+ "exclusiveMinimum": 0,
186
+ "maximum": 9007199254740991
187
+ },
182
188
  "artifactTags": {
183
189
  "description": "The normalized tags of the attachments of its results, sorted and distinct. Absent when none is tagged, and in reports of older servers",
184
190
  "type": "array",
@@ -5299,7 +5299,7 @@
5299
5299
  "Report"
5300
5300
  ],
5301
5301
  "summary": "Every test one run recorded",
5302
- "description": "One run and its last result for every test, environment and producer it recorded, failures first. Send `format=markdown` for Markdown.\n\nRole needed: `read`.",
5302
+ "description": "One run and its last result for every test, environment and producer it recorded, failures first, and `layers`: each layer of the run with its backend, how many checks it recorded (zero means evidence only, never a pass) and how many files it carries as a whole. Open a layer's files with `/api/reports/evidence/detail/{backend}/{id}`. Send `format=markdown` for Markdown.\n\nRole needed: `read`.",
5303
5303
  "x-atlas-role": "read",
5304
5304
  "security": [
5305
5305
  {
@@ -5409,7 +5409,7 @@
5409
5409
  "Report"
5410
5410
  ],
5411
5411
  "summary": "Every test one run recorded",
5412
- "description": "One run and its last result for every test, environment and producer it recorded, failures first. Send `format=markdown` for Markdown.\n\nScoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.\n\nRole needed: `read`.",
5412
+ "description": "One run and its last result for every test, environment and producer it recorded, failures first, and `layers`: each layer of the run with its backend, how many checks it recorded (zero means evidence only, never a pass) and how many files it carries as a whole. Open a layer's files with `/api/reports/evidence/detail/{backend}/{id}`. Send `format=markdown` for Markdown.\n\nScoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.\n\nRole needed: `read`.",
5413
5413
  "x-atlas-role": "read",
5414
5414
  "security": [
5415
5415
  {
@@ -6093,7 +6093,7 @@
6093
6093
  "Report"
6094
6094
  ],
6095
6095
  "summary": "Everything one result carries",
6096
- "description": "The timeline, annotations, retries, attachments and extensions of one check, and what its run carries as a whole. Without `check`, only the run's. Attachment bytes are fetched from `/api/reports/layers/{id}/blobs/{bundleKey}`.\n\nRole needed: `read`.",
6096
+ "description": "The timeline, annotations, retries, attachments and extensions of one check, and what its run carries as a whole. Without `check`, only the run's, and `parts`: what each part of the layer that carries files or links of its own holds. Attachment bytes are fetched from `/api/reports/layers/{id}/blobs/{bundleKey}`.\n\nRole needed: `read`.",
6097
6097
  "x-atlas-role": "read",
6098
6098
  "security": [
6099
6099
  {
@@ -6195,7 +6195,7 @@
6195
6195
  "Report"
6196
6196
  ],
6197
6197
  "summary": "Everything one result carries",
6198
- "description": "The timeline, annotations, retries, attachments and extensions of one check, and what its run carries as a whole. Without `check`, only the run's. Attachment bytes are fetched from `/api/reports/layers/{id}/blobs/{bundleKey}`.\n\nScoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.\n\nRole needed: `read`.",
6198
+ "description": "The timeline, annotations, retries, attachments and extensions of one check, and what its run carries as a whole. Without `check`, only the run's, and `parts`: what each part of the layer that carries files or links of its own holds. Attachment bytes are fetched from `/api/reports/layers/{id}/blobs/{bundleKey}`.\n\nScoped to one project. The same project may be selected on the unprefixed API using ?project= or X-Atlas-Project. Unknown projects return 404 on reads and are created by an authenticated upload.\n\nRole needed: `read`.",
6199
6199
  "x-atlas-role": "read",
6200
6200
  "security": [
6201
6201
  {
@@ -15767,6 +15767,30 @@
15767
15767
  "additionalProperties": {},
15768
15768
  "description": "What the run as a whole carries: its logs, links, labels, annotations, scope and extensions"
15769
15769
  },
15770
+ "parts": {
15771
+ "description": "Without `check` only: the parts of the layer that carry files or links of their own, each with what it carries. Absent when none",
15772
+ "type": "array",
15773
+ "items": {
15774
+ "type": "object",
15775
+ "properties": {
15776
+ "kind": {
15777
+ "type": "string"
15778
+ },
15779
+ "id": {
15780
+ "type": "string"
15781
+ },
15782
+ "title": {
15783
+ "type": "string"
15784
+ }
15785
+ },
15786
+ "required": [
15787
+ "kind",
15788
+ "id",
15789
+ "title"
15790
+ ],
15791
+ "additionalProperties": {}
15792
+ }
15793
+ },
15770
15794
  "inheritedScope": {
15771
15795
  "description": "The scope the check inherits: its part's declaration, else its layer's. Absent when neither declared one: unknown, not live or physical",
15772
15796
  "type": "object",
@@ -18974,6 +18998,12 @@
18974
18998
  ],
18975
18999
  "additionalProperties": {}
18976
19000
  },
19001
+ "attachments": {
19002
+ "description": "Files attached to the run itself, in its layers, not to a check. Absent when there are none",
19003
+ "type": "integer",
19004
+ "exclusiveMinimum": 0,
19005
+ "maximum": 9007199254740991
19006
+ },
18977
19007
  "artifactTags": {
18978
19008
  "description": "The normalized tags of the attachments of its results, sorted and distinct. Absent when none is tagged, and in reports of older servers",
18979
19009
  "type": "array",
@@ -19928,6 +19958,12 @@
19928
19958
  ],
19929
19959
  "additionalProperties": {}
19930
19960
  },
19961
+ "attachments": {
19962
+ "description": "Files attached to the run itself, in its layers, not to a check. Absent when there are none",
19963
+ "type": "integer",
19964
+ "exclusiveMinimum": 0,
19965
+ "maximum": 9007199254740991
19966
+ },
19931
19967
  "artifactTags": {
19932
19968
  "description": "The normalized tags of the attachments of its results, sorted and distinct. Absent when none is tagged, and in reports of older servers",
19933
19969
  "type": "array",
@@ -20188,6 +20224,85 @@
20188
20224
  ],
20189
20225
  "additionalProperties": {}
20190
20226
  }
20227
+ },
20228
+ "layers": {
20229
+ "description": "Every layer of the run, oldest first. Absent from an older server",
20230
+ "type": "array",
20231
+ "items": {
20232
+ "type": "object",
20233
+ "properties": {
20234
+ "id": {
20235
+ "type": "string",
20236
+ "description": "The layer id; read its files with `/api/reports/evidence/detail/{backend}/{id}`"
20237
+ },
20238
+ "backend": {
20239
+ "type": "string"
20240
+ },
20241
+ "producer": {
20242
+ "type": "string"
20243
+ },
20244
+ "environment": {
20245
+ "type": [
20246
+ "string",
20247
+ "null"
20248
+ ]
20249
+ },
20250
+ "recordedAtMs": {
20251
+ "type": "number"
20252
+ },
20253
+ "checks": {
20254
+ "type": "integer",
20255
+ "minimum": 0,
20256
+ "maximum": 9007199254740991,
20257
+ "description": "How many checks the layer recorded. Zero means evidence only, not a pass"
20258
+ },
20259
+ "attachments": {
20260
+ "type": "integer",
20261
+ "minimum": 0,
20262
+ "maximum": 9007199254740991,
20263
+ "description": "Files the layer carries as a whole, not those of its checks"
20264
+ },
20265
+ "videos": {
20266
+ "type": "object",
20267
+ "properties": {
20268
+ "references": {
20269
+ "type": "integer",
20270
+ "minimum": 0,
20271
+ "maximum": 9007199254740991,
20272
+ "description": "Every reference to a video; identical bytes referenced twice count twice"
20273
+ },
20274
+ "recordings": {
20275
+ "type": "integer",
20276
+ "minimum": 0,
20277
+ "maximum": 9007199254740991,
20278
+ "description": "Distinct playable recordings"
20279
+ },
20280
+ "unavailable": {
20281
+ "type": "integer",
20282
+ "minimum": 0,
20283
+ "maximum": 9007199254740991,
20284
+ "description": "References that name no bytes"
20285
+ }
20286
+ },
20287
+ "required": [
20288
+ "references",
20289
+ "recordings",
20290
+ "unavailable"
20291
+ ],
20292
+ "additionalProperties": {}
20293
+ }
20294
+ },
20295
+ "required": [
20296
+ "id",
20297
+ "backend",
20298
+ "producer",
20299
+ "environment",
20300
+ "recordedAtMs",
20301
+ "checks",
20302
+ "attachments"
20303
+ ],
20304
+ "additionalProperties": {}
20305
+ }
20191
20306
  }
20192
20307
  },
20193
20308
  "required": [
@@ -4,9 +4,9 @@ import { effectiveScope } from '@crvouga/atlas/reports/evidence-scope';
4
4
  import type { LayerHeader, LayerRow } from '@crvouga/atlas/reports/evidence-types';
5
5
  import { contentTypeOf } from '@crvouga/atlas/reports/media-types';
6
6
  import type { Annotation, Attachment, Attempt, Step } from '@crvouga/atlas/schema';
7
- import { Accordion, Alert, Anchor, Box, Code, Flex, Image, List, Stack, Text, Title } from '@mantine/core';
7
+ import { Accordion, Alert, Anchor, Badge, Box, Code, Flex, Image, List, Stack, Text, Title } from '@mantine/core';
8
8
  import { useQuery } from '@tanstack/react-query';
9
- import { useMemo, type ReactNode } from 'react';
9
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
10
10
  import { ArtifactTagList, ArtifactTagsProvider, TagFilterBar, useFilteredAttachments } from './artifact-tags';
11
11
  import { reportAssetUrl, reportJson, reportText } from './api';
12
12
  import { ScopeNotice, ScopeProvider, useScope } from './evidence-scope';
@@ -35,6 +35,23 @@ export type ArtifactRenderer = {
35
35
  const mediaType = (attachment: Attachment) =>
36
36
  (attachment.contentType ?? contentTypeOf(attachment.name)).split(';')[0]!.trim().toLowerCase();
37
37
  const label = (attachment: Attachment) => attachment.description ?? attachment.name;
38
+ /** A video that plays inline; when the file cannot be loaded or this browser cannot play it, it says so and offers the file, instead of an empty player. */
39
+ function VideoPlayer({ attachment, url }: { attachment: Attachment; url: string }) {
40
+ const [failed, setFailed] = useState(false);
41
+ if (failed)
42
+ return (
43
+ <Alert role="alert" color="orange" title="This video cannot be played">
44
+ <Text component="div">
45
+ {label(attachment)} could not be loaded, or this browser cannot play its format. Nothing is wrong with the report: the file may be missing from the
46
+ store or in an unsupported codec.{' '}
47
+ <Anchor href={url} download={attachment.name} target="_blank" rel="noreferrer">
48
+ Download {attachment.name}
49
+ </Anchor>
50
+ </Text>
51
+ </Alert>
52
+ );
53
+ return <Box src={url} controls preload="metadata" aria-label={label(attachment)} component="video" maw="100%" w="100%" onError={() => setFailed(true)} />;
54
+ }
38
55
  function TextValue({ attachment, path }: { attachment: Attachment; path: string | null }) {
39
56
  const stored = useQuery({
40
57
  queryKey: ['artifact-text', path ?? ''],
@@ -92,8 +109,7 @@ export const artifactRenderers: ArtifactRenderer[] = [
92
109
  id: 'video',
93
110
  wide: true,
94
111
  matches: (_, type) => type.startsWith('video/'),
95
- render: (a, { url }) =>
96
- url ? <Box src={url} controls preload="metadata" aria-label={label(a)} component="video" maw="100%" w="100%" /> : null
112
+ render: (a, { url }) => (url ? <VideoPlayer attachment={a} url={url} /> : null)
97
113
  },
98
114
  {
99
115
  id: 'audio',
@@ -130,25 +146,46 @@ function sizeOf(bytes: number | undefined) {
130
146
  if (bytes < 1024) return `${bytes} B`;
131
147
  return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(0)} KiB` : `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
132
148
  }
133
- function Artifact({ attachment, layer }: { attachment: Attachment; layer: string }) {
134
- const type = mediaType(attachment);
149
+ /** Where an attachment's bytes are, if a browser can reach them: its stored file in the layer, else its safe `http(s)` address. */
150
+ export function attachmentSource(attachment: Attachment, layer: string): { path: string | null; url: string | null } {
135
151
  const path = attachment.bundleKey
136
152
  ? `/layers/${encodeURIComponent(layer)}/blobs/${attachment.bundleKey.split('/').map(encodeURIComponent).join('/')}`
137
153
  : null;
138
- const url = path ? reportAssetUrl(path) : safeUri(attachment.uri);
154
+ return { path, url: path ? reportAssetUrl(path) : safeUri(attachment.uri) };
155
+ }
156
+ /**
157
+ * One attachment as a card: it plays or shows itself when it can, and always says what it is (name, role, type, size,
158
+ * duration, tags) and how to get it. A file that has no address, has an address that is not a web address, or that
159
+ * nothing can preview says so in words. Pass `link` for a control that opens this one artifact, `note` for how it
160
+ * relates to the rest of the report, and `selected` to mark the one a link pointed at (it scrolls into view).
161
+ */
162
+ export function ArtifactCard({ attachment, layer, link, note, selected }: { attachment: Attachment; layer: string; link?: ReactNode; note?: ReactNode; selected?: boolean }) {
163
+ const type = mediaType(attachment);
164
+ const { path, url } = attachmentSource(attachment, layer);
139
165
  const renderer = artifactRenderers.find((candidate) => candidate.matches(attachment, type));
140
166
  const body = renderer?.render(attachment, { url, path, type });
167
+ const item = useRef<HTMLLIElement>(null);
168
+ useEffect(() => {
169
+ if (selected) item.current?.scrollIntoView?.({ block: 'center' });
170
+ }, [selected]);
141
171
  const scope = effectiveScope(attachment.scope, useScope());
142
172
  // A recording says what it shows before it is played; evidence that declared a scope of its own says so too.
143
173
  const scoped = isVideoAttachment(attachment) || attachment.scope !== undefined;
144
174
  return (
145
- <List.Item data-wide={renderer?.wide || undefined} data-role={attachment.role ?? roleOfType(type)}>
175
+ <List.Item
176
+ ref={item}
177
+ data-wide={renderer?.wide || undefined}
178
+ data-role={attachment.role ?? roleOfType(type)}
179
+ data-selected={selected || undefined}
180
+ {...(selected ? { bg: 'blue.0', p: 'sm', bd: '2px solid var(--mantine-color-blue-6)' } : {})}
181
+ >
146
182
  {scoped && <ScopeNotice scope={scope} name={attachment.name} />}
147
183
  {body}
148
184
  <Box component="figcaption">
149
185
  <Text span inherit fw={600}>
150
186
  {attachment.name}
151
187
  </Text>
188
+ {selected && <Badge variant="light">Selected</Badge>}
152
189
  {attachment.role && (
153
190
  <Text span inherit>
154
191
  {attachment.role}
@@ -162,12 +199,21 @@ function Artifact({ attachment, layer }: { attachment: Attachment; layer: string
162
199
  {body ? 'Download' : 'Open'}
163
200
  </Anchor>
164
201
  )}
165
- {!url && attachment.text === undefined && (
166
- <Text span inherit>
167
- No file to show
168
- </Text>
169
- )}
202
+ {link}
170
203
  </Box>
204
+ {!url && attachment.text === undefined && (
205
+ <Alert role={selected ? 'alert' : undefined} color="orange" data-unavailable my="xs">
206
+ {attachment.uri
207
+ ? 'No file to show. This file is only given by an address that is not a web address, so Atlas will not follow it. Ask the producer for a web address or to upload the file.'
208
+ : 'No file to show. The report lists this file, but it was never stored, so it cannot be shown or downloaded. Publish it again with the file attached.'}
209
+ </Alert>
210
+ )}
211
+ {url && !renderer && (
212
+ <Text component="div" c="dimmed" data-no-preview>
213
+ Atlas has no preview for this kind of file. Open or download it to inspect it.
214
+ </Text>
215
+ )}
216
+ {note}
171
217
  <ArtifactTagList attachment={attachment} />
172
218
  </List.Item>
173
219
  );
@@ -182,7 +228,7 @@ export function Attachments({ attachments: all, layer }: { attachments: readonly
182
228
  </Title>
183
229
  <List spacing="xs" listStyleType="none">
184
230
  {attachments.map((attachment, index) => (
185
- <Artifact key={`${attachment.id ?? attachment.name}:${index}`} attachment={attachment} layer={layer} />
231
+ <ArtifactCard key={`${attachment.id ?? attachment.name}:${index}`} attachment={attachment} layer={layer} />
186
232
  ))}
187
233
  </List>
188
234
  </Box>
@@ -379,20 +425,25 @@ function Evidence({ holder, layer, steps }: { holder: EvidenceHolder; layer: str
379
425
  </ScopeProvider>
380
426
  );
381
427
  }
382
- /** Everything one result carries beyond its outcome, read when it is opened, and what the run around it carried. */
383
- export function ResultEvidence({ layer, row }: { layer: LayerHeader; row: LayerRow }) {
428
+ /** The full evidence of one result, read only when `enabled` and the result has some to read (an archived layer). Shared by every view of the result, so it is read once. */
429
+ export function useCheckDetail(layer: LayerHeader, row: LayerRow, enabled = true) {
384
430
  const wanted = row.evidence !== undefined || layer.evidence !== undefined;
385
- const detail = useQuery({
431
+ return useQuery({
386
432
  queryKey: ['evidence-detail', layer.backend, layer.id, row.kind, row.id],
387
433
  queryFn: ({ signal }) =>
388
434
  reportJson<CheckDetail>(
389
435
  `/evidence/detail/${encodeURIComponent(layer.backend)}/${encodeURIComponent(layer.id)}?check=${encodeURIComponent(row.id)}&kind=${encodeURIComponent(row.kind)}`,
390
436
  signal
391
437
  ),
392
- enabled: wanted && /^[0-9a-f]{64}$/.test(layer.id),
438
+ enabled: enabled && wanted && /^[0-9a-f]{64}$/.test(layer.id),
393
439
  staleTime: Number.POSITIVE_INFINITY,
394
440
  retry: false
395
441
  });
442
+ }
443
+ /** Everything one result carries beyond its outcome, read when it is opened, and what the run around it carried. */
444
+ export function ResultEvidence({ layer, row }: { layer: LayerHeader; row: LayerRow }) {
445
+ const wanted = row.evidence !== undefined || layer.evidence !== undefined;
446
+ const detail = useCheckDetail(layer, row);
396
447
  if (!wanted) return null;
397
448
  if (detail.isLoading)
398
449
  return (