@crvouga/atlas 1.17.0 → 1.18.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 (43) hide show
  1. package/CHANGELOG.md +7 -1
  2. package/dist/reports/src/artifact-query.d.ts +14 -2
  3. package/dist/reports/src/artifact-query.js +13 -4
  4. package/dist/reports/src/evidence-detail.js +7 -4
  5. package/dist/reports/src/evidence-types.d.ts +4 -0
  6. package/dist/reports/src/recording-receipt.d.ts +23 -0
  7. package/dist/reports/src/recording-receipt.js +16 -0
  8. package/dist/reports/src/recording-verify.d.ts +74 -0
  9. package/dist/reports/src/recording-verify.js +142 -0
  10. package/dist/reports/src/report-cli.d.ts +2 -2
  11. package/dist/reports/src/report-cli.js +53 -3
  12. package/dist/reports/src/test-catalog.d.ts +7 -1
  13. package/dist/reports/src/test-catalog.js +10 -4
  14. package/dist/schema/src/api.d.ts +84 -0
  15. package/dist/schema/src/api.js +11 -2
  16. package/dist/schema/src/cli-results.d.ts +51 -1
  17. package/dist/schema/src/cli-results.js +25 -1
  18. package/dist/server/src/openapi.js +1 -0
  19. package/docs/agents.md +1 -0
  20. package/docs/reference/cli.md +11 -2
  21. package/docs/reference/http-api.md +2 -0
  22. package/docs/reference/reports.md +185 -9
  23. package/docs/reference/schema.md +19 -1
  24. package/docs/rich-evidence.md +32 -5
  25. package/docs/server.md +1 -1
  26. package/llms-full.txt +47 -8
  27. package/package.json +1 -1
  28. package/packages/schema/schemas/effective-delta.schema.json +8 -0
  29. package/packages/schema/schemas/effective-evidence.schema.json +8 -0
  30. package/packages/schema/schemas/evidence-page.schema.json +4 -0
  31. package/packages/schema/schemas/layer-rows.schema.json +4 -0
  32. package/packages/schema/schemas/report-run.schema.json +74 -0
  33. package/packages/schema/schemas/report-runs.schema.json +86 -0
  34. package/packages/schema/schemas/review-collection.schema.json +4 -0
  35. package/packages/schema/schemas/review-manifest.schema.json +4 -0
  36. package/packages/schema/schemas/test-catalog.schema.json +4 -0
  37. package/packages/schema/schemas/test-history.schema.json +8 -0
  38. package/packages/schema/schemas/verify-recordings-result.schema.json +97 -0
  39. package/packages/server/openapi.json +252 -0
  40. package/packages/visualizer/src/explorer/ArtifactsView.tsx +3 -2
  41. package/packages/visualizer/src/explorer/RunsView.tsx +28 -3
  42. package/packages/visualizer/src/explorer/filters.tsx +11 -3
  43. package/packages/visualizer/src/explorer/search.ts +6 -2
@@ -5856,6 +5856,15 @@
5856
5856
  "type": "string"
5857
5857
  }
5858
5858
  },
5859
+ {
5860
+ "required": false,
5861
+ "name": "recordings",
5862
+ "in": "query",
5863
+ "description": "Recording completeness of the run, apart from its outcome: `complete`, `incomplete`, `none-expected` or `unknown` (a run whose layers declared no recording expectations is `unknown`, never `complete`). Repeatable or comma-separated. Answered from the layers' receipts; no recording is read. A server that does not know it ignores it and does not name it in `filters`.",
5864
+ "schema": {
5865
+ "type": "string"
5866
+ }
5867
+ },
5859
5868
  {
5860
5869
  "required": false,
5861
5870
  "name": "q",
@@ -6076,6 +6085,15 @@
6076
6085
  "type": "string"
6077
6086
  }
6078
6087
  },
6088
+ {
6089
+ "required": false,
6090
+ "name": "recordings",
6091
+ "in": "query",
6092
+ "description": "Recording completeness of the run, apart from its outcome: `complete`, `incomplete`, `none-expected` or `unknown` (a run whose layers declared no recording expectations is `unknown`, never `complete`). Repeatable or comma-separated. Answered from the layers' receipts; no recording is read. A server that does not know it ignores it and does not name it in `filters`.",
6093
+ "schema": {
6094
+ "type": "string"
6095
+ }
6096
+ },
6079
6097
  {
6080
6098
  "required": false,
6081
6099
  "name": "q",
@@ -10730,6 +10748,10 @@
10730
10748
  "type": "boolean",
10731
10749
  "description": "False when the reference names no bytes: no bundle key, address or inline text, or a bundle key that the layer's own bundle does not hold"
10732
10750
  },
10751
+ "reason": {
10752
+ "description": "Why it is unavailable, when the report can tell: `not-in-bundle` is a file the layer names but does not hold (stored, then gone). An open vocabulary: treat an unknown reason as plain unavailable",
10753
+ "type": "string"
10754
+ },
10733
10755
  "scope": {
10734
10756
  "description": "What this recording shows as its producer declared it: its own `scope`, else its attempt's, check's, part's or layer's. Absent: the scope is unknown, never live, physical or audible",
10735
10757
  "type": "object",
@@ -11209,6 +11231,10 @@
11209
11231
  "type": "boolean",
11210
11232
  "description": "False when the reference names no bytes: no bundle key, address or inline text, or a bundle key that the layer's own bundle does not hold"
11211
11233
  },
11234
+ "reason": {
11235
+ "description": "Why it is unavailable, when the report can tell: `not-in-bundle` is a file the layer names but does not hold (stored, then gone). An open vocabulary: treat an unknown reason as plain unavailable",
11236
+ "type": "string"
11237
+ },
11212
11238
  "scope": {
11213
11239
  "description": "What this recording shows as its producer declared it: its own `scope`, else its attempt's, check's, part's or layer's. Absent: the scope is unknown, never live, physical or audible",
11214
11240
  "type": "object",
@@ -11723,6 +11749,10 @@
11723
11749
  "type": "boolean",
11724
11750
  "description": "False when the reference names no bytes: no bundle key, address or inline text, or a bundle key that the layer's own bundle does not hold"
11725
11751
  },
11752
+ "reason": {
11753
+ "description": "Why it is unavailable, when the report can tell: `not-in-bundle` is a file the layer names but does not hold (stored, then gone). An open vocabulary: treat an unknown reason as plain unavailable",
11754
+ "type": "string"
11755
+ },
11726
11756
  "scope": {
11727
11757
  "description": "What this recording shows as its producer declared it: its own `scope`, else its attempt's, check's, part's or layer's. Absent: the scope is unknown, never live, physical or audible",
11728
11758
  "type": "object",
@@ -12202,6 +12232,10 @@
12202
12232
  "type": "boolean",
12203
12233
  "description": "False when the reference names no bytes: no bundle key, address or inline text, or a bundle key that the layer's own bundle does not hold"
12204
12234
  },
12235
+ "reason": {
12236
+ "description": "Why it is unavailable, when the report can tell: `not-in-bundle` is a file the layer names but does not hold (stored, then gone). An open vocabulary: treat an unknown reason as plain unavailable",
12237
+ "type": "string"
12238
+ },
12205
12239
  "scope": {
12206
12240
  "description": "What this recording shows as its producer declared it: its own `scope`, else its attempt's, check's, part's or layer's. Absent: the scope is unknown, never live, physical or audible",
12207
12241
  "type": "object",
@@ -15610,6 +15644,10 @@
15610
15644
  "type": "boolean",
15611
15645
  "description": "False when the reference names no bytes: no bundle key, address or inline text, or a bundle key that the layer's own bundle does not hold"
15612
15646
  },
15647
+ "reason": {
15648
+ "description": "Why it is unavailable, when the report can tell: `not-in-bundle` is a file the layer names but does not hold (stored, then gone). An open vocabulary: treat an unknown reason as plain unavailable",
15649
+ "type": "string"
15650
+ },
15613
15651
  "scope": {
15614
15652
  "description": "What this recording shows as its producer declared it: its own `scope`, else its attempt's, check's, part's or layer's. Absent: the scope is unknown, never live, physical or audible",
15615
15653
  "type": "object",
@@ -16104,6 +16142,10 @@
16104
16142
  "type": "boolean",
16105
16143
  "description": "False when the reference names no bytes: no bundle key, address or inline text, or a bundle key that the layer's own bundle does not hold"
16106
16144
  },
16145
+ "reason": {
16146
+ "description": "Why it is unavailable, when the report can tell: `not-in-bundle` is a file the layer names but does not hold (stored, then gone). An open vocabulary: treat an unknown reason as plain unavailable",
16147
+ "type": "string"
16148
+ },
16107
16149
  "scope": {
16108
16150
  "description": "What this recording shows as its producer declared it: its own `scope`, else its attempt's, check's, part's or layer's. Absent: the scope is unknown, never live, physical or audible",
16109
16151
  "type": "object",
@@ -18714,6 +18756,10 @@
18714
18756
  "type": "boolean",
18715
18757
  "description": "False when the reference names no bytes: no bundle key, address or inline text, or a bundle key that the layer's own bundle does not hold"
18716
18758
  },
18759
+ "reason": {
18760
+ "description": "Why it is unavailable, when the report can tell: `not-in-bundle` is a file the layer names but does not hold (stored, then gone). An open vocabulary: treat an unknown reason as plain unavailable",
18761
+ "type": "string"
18762
+ },
18717
18763
  "scope": {
18718
18764
  "description": "What this recording shows as its producer declared it: its own `scope`, else its attempt's, check's, part's or layer's. Absent: the scope is unknown, never live, physical or audible",
18719
18765
  "type": "object",
@@ -19196,6 +19242,10 @@
19196
19242
  "type": "boolean",
19197
19243
  "description": "False when the reference names no bytes: no bundle key, address or inline text, or a bundle key that the layer's own bundle does not hold"
19198
19244
  },
19245
+ "reason": {
19246
+ "description": "Why it is unavailable, when the report can tell: `not-in-bundle` is a file the layer names but does not hold (stored, then gone). An open vocabulary: treat an unknown reason as plain unavailable",
19247
+ "type": "string"
19248
+ },
19199
19249
  "scope": {
19200
19250
  "description": "What this recording shows as its producer declared it: its own `scope`, else its attempt's, check's, part's or layer's. Absent: the scope is unknown, never live, physical or audible",
19201
19251
  "type": "object",
@@ -19838,6 +19888,10 @@
19838
19888
  "type": "boolean",
19839
19889
  "description": "False when the reference names no bytes: no bundle key, address or inline text, or a bundle key that the layer's own bundle does not hold"
19840
19890
  },
19891
+ "reason": {
19892
+ "description": "Why it is unavailable, when the report can tell: `not-in-bundle` is a file the layer names but does not hold (stored, then gone). An open vocabulary: treat an unknown reason as plain unavailable",
19893
+ "type": "string"
19894
+ },
19841
19895
  "scope": {
19842
19896
  "description": "What this recording shows as its producer declared it: its own `scope`, else its attempt's, check's, part's or layer's. Absent: the scope is unknown, never live, physical or audible",
19843
19897
  "type": "object",
@@ -21093,6 +21147,10 @@
21093
21147
  "type": "boolean",
21094
21148
  "description": "False when the reference names no bytes: no bundle key, address or inline text, or a bundle key that the layer's own bundle does not hold"
21095
21149
  },
21150
+ "reason": {
21151
+ "description": "Why it is unavailable, when the report can tell: `not-in-bundle` is a file the layer names but does not hold (stored, then gone). An open vocabulary: treat an unknown reason as plain unavailable",
21152
+ "type": "string"
21153
+ },
21096
21154
  "scope": {
21097
21155
  "description": "What this recording shows as its producer declared it: its own `scope`, else its attempt's, check's, part's or layer's. Absent: the scope is unknown, never live, physical or audible",
21098
21156
  "type": "object",
@@ -22284,6 +22342,10 @@
22284
22342
  "type": "boolean",
22285
22343
  "description": "False when the reference names no bytes: no bundle key, address or inline text, or a bundle key that the layer's own bundle does not hold"
22286
22344
  },
22345
+ "reason": {
22346
+ "description": "Why it is unavailable, when the report can tell: `not-in-bundle` is a file the layer names but does not hold (stored, then gone). An open vocabulary: treat an unknown reason as plain unavailable",
22347
+ "type": "string"
22348
+ },
22287
22349
  "scope": {
22288
22350
  "description": "What this recording shows as its producer declared it: its own `scope`, else its attempt's, check's, part's or layer's. Absent: the scope is unknown, never live, physical or audible",
22289
22351
  "type": "object",
@@ -22955,6 +23017,10 @@
22955
23017
  "type": "boolean",
22956
23018
  "description": "False when the reference names no bytes: no bundle key, address or inline text, or a bundle key that the layer's own bundle does not hold"
22957
23019
  },
23020
+ "reason": {
23021
+ "description": "Why it is unavailable, when the report can tell: `not-in-bundle` is a file the layer names but does not hold (stored, then gone). An open vocabulary: treat an unknown reason as plain unavailable",
23022
+ "type": "string"
23023
+ },
22958
23024
  "scope": {
22959
23025
  "description": "What this recording shows as its producer declared it: its own `scope`, else its attempt's, check's, part's or layer's. Absent: the scope is unknown, never live, physical or audible",
22960
23026
  "type": "object",
@@ -23984,6 +24050,12 @@
23984
24050
  "minimum": 0,
23985
24051
  "maximum": 9007199254740991,
23986
24052
  "description": "References that name no bytes, or bytes the layer does not hold"
24053
+ },
24054
+ "missing": {
24055
+ "description": "Of `unavailable`, those that name a file the layer's bundle does not hold (stored, then gone). Absent when there are none",
24056
+ "type": "integer",
24057
+ "exclusiveMinimum": 0,
24058
+ "maximum": 9007199254740991
23987
24059
  }
23988
24060
  },
23989
24061
  "required": [
@@ -23993,6 +24065,64 @@
23993
24065
  ],
23994
24066
  "additionalProperties": {}
23995
24067
  },
24068
+ "recordings": {
24069
+ "description": "Whether the recordings its layers expected were published, apart from its outcomes. Absent when no layer declared an expectation: its completeness is unknown, never complete. Matched by the `recordings` filter",
24070
+ "type": "object",
24071
+ "properties": {
24072
+ "state": {
24073
+ "type": "string",
24074
+ "enum": [
24075
+ "complete",
24076
+ "incomplete",
24077
+ "none-expected",
24078
+ "unknown"
24079
+ ],
24080
+ "description": "`incomplete` when an expected recording was skipped or unresolved; else `unknown` when a layer declared no expectations; else `complete` when something was expected and `none-expected` when nothing was"
24081
+ },
24082
+ "expected": {
24083
+ "type": "integer",
24084
+ "minimum": 0,
24085
+ "maximum": 9007199254740991
24086
+ },
24087
+ "published": {
24088
+ "type": "integer",
24089
+ "minimum": 0,
24090
+ "maximum": 9007199254740991,
24091
+ "description": "Of the expected recordings, uploaded or referenced by address"
24092
+ },
24093
+ "skipped": {
24094
+ "type": "integer",
24095
+ "minimum": 0,
24096
+ "maximum": 9007199254740991
24097
+ },
24098
+ "unresolved": {
24099
+ "type": "integer",
24100
+ "minimum": 0,
24101
+ "maximum": 9007199254740991
24102
+ },
24103
+ "notApplicable": {
24104
+ "type": "integer",
24105
+ "minimum": 0,
24106
+ "maximum": 9007199254740991
24107
+ },
24108
+ "layersWithoutReceipt": {
24109
+ "type": "integer",
24110
+ "minimum": 0,
24111
+ "maximum": 9007199254740991,
24112
+ "description": "Layers of the run that declared no recording expectations: nothing is claimed for them"
24113
+ }
24114
+ },
24115
+ "required": [
24116
+ "state",
24117
+ "expected",
24118
+ "published",
24119
+ "skipped",
24120
+ "unresolved",
24121
+ "notApplicable",
24122
+ "layersWithoutReceipt"
24123
+ ],
24124
+ "additionalProperties": {}
24125
+ },
23996
24126
  "attachments": {
23997
24127
  "description": "Files attached to the run itself, in its layers, not to a check. Absent when there are none",
23998
24128
  "type": "integer",
@@ -24233,6 +24363,28 @@
24233
24363
  ],
24234
24364
  "additionalProperties": {}
24235
24365
  }
24366
+ },
24367
+ "recordings": {
24368
+ "description": "Only for runs: `complete`, `incomplete`, `none-expected` and `unknown` with how many runs have each. Absent on servers that predate the filter",
24369
+ "type": "array",
24370
+ "items": {
24371
+ "type": "object",
24372
+ "properties": {
24373
+ "value": {
24374
+ "type": "string"
24375
+ },
24376
+ "count": {
24377
+ "type": "integer",
24378
+ "minimum": 0,
24379
+ "maximum": 9007199254740991
24380
+ }
24381
+ },
24382
+ "required": [
24383
+ "value",
24384
+ "count"
24385
+ ],
24386
+ "additionalProperties": {}
24387
+ }
24236
24388
  }
24237
24389
  },
24238
24390
  "required": [
@@ -24444,6 +24596,10 @@
24444
24596
  "type": "boolean",
24445
24597
  "description": "False when the reference names no bytes: no bundle key, address or inline text, or a bundle key that the layer's own bundle does not hold"
24446
24598
  },
24599
+ "reason": {
24600
+ "description": "Why it is unavailable, when the report can tell: `not-in-bundle` is a file the layer names but does not hold (stored, then gone). An open vocabulary: treat an unknown reason as plain unavailable",
24601
+ "type": "string"
24602
+ },
24447
24603
  "scope": {
24448
24604
  "description": "What this recording shows as its producer declared it: its own `scope`, else its attempt's, check's, part's or layer's. Absent: the scope is unknown, never live, physical or audible",
24449
24605
  "type": "object",
@@ -24769,6 +24925,28 @@
24769
24925
  ],
24770
24926
  "additionalProperties": {}
24771
24927
  }
24928
+ },
24929
+ "recordings": {
24930
+ "description": "Only for runs: `complete`, `incomplete`, `none-expected` and `unknown` with how many runs have each. Absent on servers that predate the filter",
24931
+ "type": "array",
24932
+ "items": {
24933
+ "type": "object",
24934
+ "properties": {
24935
+ "value": {
24936
+ "type": "string"
24937
+ },
24938
+ "count": {
24939
+ "type": "integer",
24940
+ "minimum": 0,
24941
+ "maximum": 9007199254740991
24942
+ }
24943
+ },
24944
+ "required": [
24945
+ "value",
24946
+ "count"
24947
+ ],
24948
+ "additionalProperties": {}
24949
+ }
24772
24950
  }
24773
24951
  },
24774
24952
  "required": [
@@ -25736,6 +25914,12 @@
25736
25914
  "minimum": 0,
25737
25915
  "maximum": 9007199254740991,
25738
25916
  "description": "References that name no bytes, or bytes the layer does not hold"
25917
+ },
25918
+ "missing": {
25919
+ "description": "Of `unavailable`, those that name a file the layer's bundle does not hold (stored, then gone). Absent when there are none",
25920
+ "type": "integer",
25921
+ "exclusiveMinimum": 0,
25922
+ "maximum": 9007199254740991
25739
25923
  }
25740
25924
  },
25741
25925
  "required": [
@@ -25745,6 +25929,64 @@
25745
25929
  ],
25746
25930
  "additionalProperties": {}
25747
25931
  },
25932
+ "recordings": {
25933
+ "description": "Whether the recordings its layers expected were published, apart from its outcomes. Absent when no layer declared an expectation: its completeness is unknown, never complete. Matched by the `recordings` filter",
25934
+ "type": "object",
25935
+ "properties": {
25936
+ "state": {
25937
+ "type": "string",
25938
+ "enum": [
25939
+ "complete",
25940
+ "incomplete",
25941
+ "none-expected",
25942
+ "unknown"
25943
+ ],
25944
+ "description": "`incomplete` when an expected recording was skipped or unresolved; else `unknown` when a layer declared no expectations; else `complete` when something was expected and `none-expected` when nothing was"
25945
+ },
25946
+ "expected": {
25947
+ "type": "integer",
25948
+ "minimum": 0,
25949
+ "maximum": 9007199254740991
25950
+ },
25951
+ "published": {
25952
+ "type": "integer",
25953
+ "minimum": 0,
25954
+ "maximum": 9007199254740991,
25955
+ "description": "Of the expected recordings, uploaded or referenced by address"
25956
+ },
25957
+ "skipped": {
25958
+ "type": "integer",
25959
+ "minimum": 0,
25960
+ "maximum": 9007199254740991
25961
+ },
25962
+ "unresolved": {
25963
+ "type": "integer",
25964
+ "minimum": 0,
25965
+ "maximum": 9007199254740991
25966
+ },
25967
+ "notApplicable": {
25968
+ "type": "integer",
25969
+ "minimum": 0,
25970
+ "maximum": 9007199254740991
25971
+ },
25972
+ "layersWithoutReceipt": {
25973
+ "type": "integer",
25974
+ "minimum": 0,
25975
+ "maximum": 9007199254740991,
25976
+ "description": "Layers of the run that declared no recording expectations: nothing is claimed for them"
25977
+ }
25978
+ },
25979
+ "required": [
25980
+ "state",
25981
+ "expected",
25982
+ "published",
25983
+ "skipped",
25984
+ "unresolved",
25985
+ "notApplicable",
25986
+ "layersWithoutReceipt"
25987
+ ],
25988
+ "additionalProperties": {}
25989
+ },
25748
25990
  "attachments": {
25749
25991
  "description": "Files attached to the run itself, in its layers, not to a check. Absent when there are none",
25750
25992
  "type": "integer",
@@ -25890,6 +26132,10 @@
25890
26132
  "type": "boolean",
25891
26133
  "description": "False when the reference names no bytes: no bundle key, address or inline text, or a bundle key that the layer's own bundle does not hold"
25892
26134
  },
26135
+ "reason": {
26136
+ "description": "Why it is unavailable, when the report can tell: `not-in-bundle` is a file the layer names but does not hold (stored, then gone). An open vocabulary: treat an unknown reason as plain unavailable",
26137
+ "type": "string"
26138
+ },
25893
26139
  "scope": {
25894
26140
  "description": "What this recording shows as its producer declared it: its own `scope`, else its attempt's, check's, part's or layer's. Absent: the scope is unknown, never live, physical or audible",
25895
26141
  "type": "object",
@@ -26091,6 +26337,12 @@
26091
26337
  "minimum": 0,
26092
26338
  "maximum": 9007199254740991,
26093
26339
  "description": "References that name no bytes, or bytes the layer does not hold"
26340
+ },
26341
+ "missing": {
26342
+ "description": "Of `unavailable`, those that name a file the layer's bundle does not hold (stored, then gone). Absent when there are none",
26343
+ "type": "integer",
26344
+ "exclusiveMinimum": 0,
26345
+ "maximum": 9007199254740991
26094
26346
  }
26095
26347
  },
26096
26348
  "required": [
@@ -26,7 +26,7 @@ import { entriesFromArtifacts } from './playlist-entries';
26
26
  import { WatchPlaylist } from './playlist';
27
27
  import { RecordingTextSearch } from './RecordingTextSearch';
28
28
  import { filtered, filterOf, NO_FILTERS } from './search';
29
- import { filterParams } from '@crvouga/atlas/reports/artifact-query';
29
+ import { filterParams, forArtifacts } from '@crvouga/atlas/reports/artifact-query';
30
30
 
31
31
  const ARTIFACT_ROWS = 50;
32
32
 
@@ -34,7 +34,8 @@ const ARTIFACT_ROWS = 50;
34
34
  export function ArtifactsView() {
35
35
  const search = useExplorerSearch();
36
36
  const go = useExplorerNavigate();
37
- const filter = useMemo(() => filterOf(search), [search]);
37
+ // Recording completeness belongs to a run, not to a recorded result: the Artifacts tab never asks for it.
38
+ const filter = useMemo(() => forArtifacts(filterOf(search)), [search]);
38
39
  const narrow = filtered(search);
39
40
  const answer = useArtifactList(filter);
40
41
  const now = useNow();
@@ -93,15 +93,39 @@ function ownerText(video: VideoRef): string {
93
93
  return 'the test';
94
94
  }
95
95
  const UNAVAILABLE = 'Listed in the report, but its file was never stored, so it cannot be played.';
96
- /** The unavailable references of a video summary and why they cannot be watched. */
96
+ const MISSING = 'The run says it stored this file, but its bundle no longer holds it, so it cannot be played.';
97
+ /** The unavailable references of a video summary and why they cannot be watched: never stored, or stored and then gone. */
97
98
  function Unavailable({ videos }: { videos: VideoCounts }) {
98
99
  if (!videos.unavailable) return null;
100
+ const missing = Math.min(videos.missing ?? 0, videos.unavailable);
99
101
  return (
100
102
  <Text component="div" size="xs" c="dimmed" data-video-unavailable>
101
- <IconAlertTriangle size={16} aria-hidden="true" /> {plural(videos.unavailable, 'video', 'videos')} unavailable. {UNAVAILABLE}
103
+ <IconAlertTriangle size={16} aria-hidden="true" /> {plural(videos.unavailable, 'video', 'videos')} unavailable.{' '}
104
+ {missing > 0 && <Text span inherit data-video-missing>{missing === videos.unavailable ? MISSING : `${plural(missing, 'video', 'videos')}: ${MISSING}`}{' '}</Text>}
105
+ {missing < videos.unavailable && UNAVAILABLE}
102
106
  </Text>
103
107
  );
104
108
  }
109
+ /** Words for a run's recording completeness, apart from how its tests went. */
110
+ const COMPLETENESS_WORDS: Record<string, string> = { complete: 'complete', incomplete: 'incomplete', 'none-expected': 'none expected', unknown: 'unknown' };
111
+ /** A run's recording completeness in its row: how many expected recordings were published, said in words and independent of its outcomes. Nothing when its layers declared no expectations (unknown is not shown as complete). */
112
+ function RunRecordings({ run }: { run: RunSummary }) {
113
+ const recordings = run.recordings;
114
+ if (!recordings) return null;
115
+ const word = COMPLETENESS_WORDS[recordings.state] ?? 'unknown';
116
+ const text = recordings.expected > 0 ? `Recordings ${recordings.published} of ${recordings.expected}: ${word}` : `Recordings: ${word}`;
117
+ return (
118
+ <Badge
119
+ size="sm"
120
+ variant="light"
121
+ color={recordings.state === 'incomplete' ? 'orange' : 'gray'}
122
+ data-recordings={recordings.state}
123
+ title={recordings.state === 'incomplete' ? 'Some expected recordings were not published. The test outcomes are unaffected.' : undefined}
124
+ >
125
+ {text}
126
+ </Badge>
127
+ );
128
+ }
105
129
  /** A run's videos in its row: a link to the recordings, how many references they come from, and what cannot be played. */
106
130
  function RunVideos({ run }: { run: RunSummary }) {
107
131
  const videos = run.videos;
@@ -210,7 +234,7 @@ const RUN_SORTS = [
210
234
  { id: 'relevance', label: 'Best match' }
211
235
  ] as const;
212
236
  /** What a filter is called when a page says a server could not apply it. */
213
- const FILTER_WORDS: Partial<Record<FilterName, string>> = { artifactTag: 'tag', type: 'media type', label: 'label', domain: 'domain', spec: 'spec' };
237
+ const FILTER_WORDS: Partial<Record<FilterName, string>> = { artifactTag: 'tag', type: 'media type', label: 'label', domain: 'domain', spec: 'spec', recordings: 'recording completeness' };
214
238
  /** Says which filters the list is not narrowed by, because the server that answered ignored them. */
215
239
  export function NotApplied({ names, noun }: { names: readonly FilterName[]; noun: string }) {
216
240
  if (!names.length) return null;
@@ -383,6 +407,7 @@ function RunTable() {
383
407
  <Table.Td>{formatMs(run.spanMs)}</Table.Td>
384
408
  <Table.Td>
385
409
  <RunVideos run={run} />
410
+ <RunRecordings run={run} />
386
411
  </Table.Td>
387
412
  <Table.Td data-tags>{run.artifactTags?.map((tag) => `#${tag}`).join(' ') || '—'}</Table.Td>
388
413
  <Table.Td>
@@ -2,7 +2,7 @@
2
2
  * @module Run and artifact filters
3
3
  *
4
4
  * The filter bar of the Runs and Artifacts tabs: words, artifact tag, media type, label, environment, branch, outcome,
5
- * feature domain and a time range, each offering the values the report has with counts in the scope of the list. What is
5
+ * feature domain and a time range (and, for runs, the completeness of their recordings, apart from their outcome), each offering the values the report has with counts in the scope of the list. What is
6
6
  * chosen shows as removable chips and is the address, so copying it or reloading restores the same view; named views keep
7
7
  * a query to open again. Choices combine with AND across the controls and OR within one.
8
8
  */
@@ -18,7 +18,7 @@ import { patched, useExplorerNavigate } from './navigate';
18
18
  import { filtered, joined, NO_FILTERS, toggled, values, type ExplorerSearch } from './search';
19
19
  import { filtersOf, isView, readViews, writeViews, type SavedView } from './views';
20
20
 
21
- type Param = 'artifactTag' | 'type' | 'label' | 'env' | 'branch' | 'outcome' | 'domain' | 'producer';
21
+ type Param = 'artifactTag' | 'type' | 'label' | 'env' | 'branch' | 'outcome' | 'recordings' | 'domain' | 'producer';
22
22
 
23
23
  const NOUN: Record<Param, string> = {
24
24
  artifactTag: 'Tag',
@@ -27,13 +27,18 @@ const NOUN: Record<Param, string> = {
27
27
  env: 'Environment',
28
28
  branch: 'Branch',
29
29
  outcome: 'Outcome',
30
+ recordings: 'Recordings',
30
31
  domain: 'Domain',
31
32
  producer: 'Producer'
32
33
  };
33
34
 
35
+ /** How the completeness of a run's recordings reads. */
36
+ const COMPLETENESS: Record<string, string> = { complete: 'Complete', incomplete: 'Incomplete', 'none-expected': 'None expected', unknown: 'Unknown' };
37
+
34
38
  /** How a chosen value reads: tags with their hash, environments by name. */
35
39
  function shown(param: Param, value: string): string {
36
40
  if (param === 'artifactTag') return `#${value}`;
41
+ if (param === 'recordings') return COMPLETENESS[value] ?? value;
37
42
  if (param === 'env') return environmentLabel(value);
38
43
  if (param === 'domain' && value === '(no spec)') return 'No spec';
39
44
  return value;
@@ -178,7 +183,7 @@ export function FilterBar({
178
183
  const [draft, setDraft] = useState(search.q ?? '');
179
184
  // The address may change from elsewhere (a link, a saved view, back): follow it.
180
185
  useEffect(() => setDraft((current) => (current.trim() === (search.q ?? '').trim() ? current : (search.q ?? ''))), [search.q]);
181
- const params: Param[] = ['artifactTag', 'type', 'label', 'env', 'branch', 'outcome', ...(tab === 'artifacts' ? (['domain'] as const) : []), 'producer'];
186
+ const params: Param[] = ['artifactTag', 'type', 'label', 'env', 'branch', 'outcome', ...(tab === 'runs' ? (['recordings'] as const) : []), ...(tab === 'artifacts' ? (['domain'] as const) : []), 'producer'];
182
187
  const chips = [
183
188
  ...params.flatMap((param) =>
184
189
  values(search[param]).map((value) => ({ label: `${NOUN[param]}: ${shown(param, value)}`, clear: { [param]: toggled(search[param], value) } as never }))
@@ -233,6 +238,9 @@ export function FilterBar({
233
238
  <Facet param="env" facets={facets.environments} search={search} />
234
239
  <Facet param="branch" facets={facets.branches} search={search} />
235
240
  <Facet param="outcome" facets={facets.outcomes} search={search} />
241
+ {tab === 'runs' && (facets.recordings || values(search.recordings).length > 0) && (
242
+ <Facet param="recordings" facets={facets.recordings ?? []} search={search} />
243
+ )}
236
244
  {tab === 'artifacts' && facets.domains && <Facet param="domain" facets={facets.domains} search={search} />}
237
245
  {facets.producers.length > 1 && <Facet param="producer" facets={facets.producers} search={search} />}
238
246
  <TextInput type="date" label="From" value={search.since ?? ''} onChange={(event) => go({ since: event.target.value || undefined })} />
@@ -7,7 +7,7 @@
7
7
  * or `\\`, and the router encodes `#` and spaces. Anything unreadable is dropped, never an error.
8
8
  *
9
9
  * The Runs and Artifacts tabs share the tests' `q`, `env`, `producer`, `domain` and `spec` and add `artifactTag`,
10
- * `type`, `label`, `branch`, `outcome`, `since` and `until`, so a link to one tab's filters opens the same query on another.
10
+ * `type`, `label`, `branch`, `outcome`, `since` and `until` (and the Runs tab alone `recordings`, the completeness of its recordings), so a link to one tab's filters opens the same query on another.
11
11
  */
12
12
  import { joinList, splitList, type ArtifactFilter } from '@crvouga/atlas/reports/artifact-query';
13
13
  import type { ReliabilityScope } from '@crvouga/atlas/reports/reliability';
@@ -55,6 +55,8 @@ export const ExplorerSearchFields = {
55
55
  branch: text,
56
56
  /** Outcomes of a run or recorded result (`passed`, `failed`…). */
57
57
  outcome: text,
58
+ /** Recording completeness of a run (`complete`, `incomplete`, `none-expected`, `unknown`): runs only, apart from the outcome. */
59
+ recordings: text,
58
60
  /** Only records from this day (`YYYY-MM-DD`, the viewer's local time). */
59
61
  since: text,
60
62
  /** Only records until the end of this day (`YYYY-MM-DD`, local time). */
@@ -130,6 +132,7 @@ export type ExplorerSearch = {
130
132
  readonly label?: string;
131
133
  readonly branch?: string;
132
134
  readonly outcome?: string;
135
+ readonly recordings?: string;
133
136
  readonly since?: string;
134
137
  readonly until?: string;
135
138
  readonly inRun?: string;
@@ -229,7 +232,7 @@ export function reliabilityScopeOf(search: ExplorerSearch): ReliabilityScope {
229
232
  }
230
233
 
231
234
  /** The filters of the Runs and Artifacts tabs, in the address. */
232
- export const FILTER_FIELDS = ['q', 'artifactTag', 'inherited', 'type', 'label', 'env', 'producer', 'branch', 'outcome', 'domain', 'spec', 'since', 'until', 'video'] as const;
235
+ export const FILTER_FIELDS = ['q', 'artifactTag', 'inherited', 'type', 'label', 'env', 'producer', 'branch', 'outcome', 'recordings', 'domain', 'spec', 'since', 'until', 'video'] as const;
233
236
 
234
237
  /** A patch that removes every filter of the Runs and Artifacts tabs. */
235
238
  export const NO_FILTERS = Object.fromEntries(FILTER_FIELDS.map((field) => [field, undefined])) as { [K in (typeof FILTER_FIELDS)[number]]?: undefined };
@@ -263,6 +266,7 @@ export function filterOf(search: ExplorerSearch): ArtifactFilter {
263
266
  producer: list(search.producer),
264
267
  branch: list(search.branch),
265
268
  outcome: list(search.outcome),
269
+ recordings: list(search.recordings),
266
270
  domain: list(search.domain),
267
271
  spec: list(search.spec),
268
272
  since,