@crvouga/atlas 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/playwright-reporter/src/index.d.ts +11 -0
- package/dist/playwright-reporter/src/index.js +4 -2
- package/dist/reports/src/evidence-types.d.ts +3 -0
- package/dist/reports/src/layer-merge.d.ts +1 -1
- package/dist/reports/src/layer-merge.js +3 -2
- package/dist/reports/src/layer-types.d.ts +3 -0
- package/dist/reports/src/publish-checks.js +1 -0
- package/dist/reports/src/report-cli.d.ts +1 -1
- package/dist/reports/src/report-cli.js +30 -2
- package/dist/reports/src/reporter.d.ts +1 -1
- package/dist/reports/src/test-catalog.d.ts +20 -4
- package/dist/reports/src/test-catalog.js +31 -8
- package/dist/schema/src/api.d.ts +49 -1
- package/dist/schema/src/api.js +7 -3
- package/dist/schema/src/layer.d.ts +26 -0
- package/dist/schema/src/layer.js +17 -0
- package/docs/integrating.md +8 -0
- package/docs/reference/cli.md +7 -0
- package/docs/reference/playwright-reporter.md +6 -6
- package/docs/reference/reports.md +18 -6
- package/docs/reference/schema.md +20 -1
- package/docs/report-contract.md +1 -0
- package/docs/ui.md +1 -1
- package/llms-full.txt +17 -1
- package/package.json +1 -1
- package/packages/schema/schemas/effective-delta.schema.json +30 -0
- package/packages/schema/schemas/effective-evidence.schema.json +30 -0
- package/packages/schema/schemas/evidence-page.schema.json +30 -0
- package/packages/schema/schemas/layer.schema.json +30 -0
- package/packages/schema/schemas/publish-checks.schema.json +30 -0
- package/packages/schema/schemas/report-run.schema.json +17 -1
- package/packages/schema/schemas/report-runs.schema.json +17 -1
- package/packages/server/openapi.json +154 -2
- package/packages/visualizer/src/evidence/check-detail.tsx +10 -4
- package/packages/visualizer/src/evidence/recording-page.tsx +5 -2
- package/packages/visualizer/src/explorer/Explorer.tsx +6 -6
- package/packages/visualizer/src/explorer/RunsView.tsx +25 -6
- package/packages/visualizer/src/explorer/TestDetail.tsx +142 -14
- package/packages/visualizer/src/explorer/parts.tsx +2 -1
- package/packages/visualizer/src/explorer/pinned.ts +91 -0
- package/packages/visualizer/src/explorer/search.ts +5 -2
|
@@ -7575,6 +7575,36 @@
|
|
|
7575
7575
|
"modified"
|
|
7576
7576
|
]
|
|
7577
7577
|
},
|
|
7578
|
+
"run": {
|
|
7579
|
+
"description": "The execution the producer named for it (one local or manual invocation). Grouping and display only; never part of the slot",
|
|
7580
|
+
"type": "object",
|
|
7581
|
+
"properties": {
|
|
7582
|
+
"id": {
|
|
7583
|
+
"type": "string",
|
|
7584
|
+
"minLength": 1,
|
|
7585
|
+
"maxLength": 256,
|
|
7586
|
+
"description": "The execution's identity within a project, chosen by the producer: a UUID, a timestamped name. Layers with the same id are one run; it never changes after it is recorded"
|
|
7587
|
+
},
|
|
7588
|
+
"title": {
|
|
7589
|
+
"description": "A human name for the run. Presentation only: it is not part of the identity and does not hide the commit or `freshness`",
|
|
7590
|
+
"type": "string"
|
|
7591
|
+
},
|
|
7592
|
+
"startedAtMs": {
|
|
7593
|
+
"description": "When the execution started, epoch milliseconds",
|
|
7594
|
+
"type": "number",
|
|
7595
|
+
"minimum": 0
|
|
7596
|
+
},
|
|
7597
|
+
"endedAtMs": {
|
|
7598
|
+
"description": "When the execution ended, epoch milliseconds",
|
|
7599
|
+
"type": "number",
|
|
7600
|
+
"minimum": 0
|
|
7601
|
+
}
|
|
7602
|
+
},
|
|
7603
|
+
"required": [
|
|
7604
|
+
"id"
|
|
7605
|
+
],
|
|
7606
|
+
"additionalProperties": {}
|
|
7607
|
+
},
|
|
7578
7608
|
"recording": {
|
|
7579
7609
|
"anyOf": [
|
|
7580
7610
|
{},
|
|
@@ -8018,6 +8048,36 @@
|
|
|
8018
8048
|
"modified"
|
|
8019
8049
|
]
|
|
8020
8050
|
},
|
|
8051
|
+
"run": {
|
|
8052
|
+
"description": "The execution the producer named for it (one local or manual invocation). Grouping and display only; never part of the slot",
|
|
8053
|
+
"type": "object",
|
|
8054
|
+
"properties": {
|
|
8055
|
+
"id": {
|
|
8056
|
+
"type": "string",
|
|
8057
|
+
"minLength": 1,
|
|
8058
|
+
"maxLength": 256,
|
|
8059
|
+
"description": "The execution's identity within a project, chosen by the producer: a UUID, a timestamped name. Layers with the same id are one run; it never changes after it is recorded"
|
|
8060
|
+
},
|
|
8061
|
+
"title": {
|
|
8062
|
+
"description": "A human name for the run. Presentation only: it is not part of the identity and does not hide the commit or `freshness`",
|
|
8063
|
+
"type": "string"
|
|
8064
|
+
},
|
|
8065
|
+
"startedAtMs": {
|
|
8066
|
+
"description": "When the execution started, epoch milliseconds",
|
|
8067
|
+
"type": "number",
|
|
8068
|
+
"minimum": 0
|
|
8069
|
+
},
|
|
8070
|
+
"endedAtMs": {
|
|
8071
|
+
"description": "When the execution ended, epoch milliseconds",
|
|
8072
|
+
"type": "number",
|
|
8073
|
+
"minimum": 0
|
|
8074
|
+
}
|
|
8075
|
+
},
|
|
8076
|
+
"required": [
|
|
8077
|
+
"id"
|
|
8078
|
+
],
|
|
8079
|
+
"additionalProperties": {}
|
|
8080
|
+
},
|
|
8021
8081
|
"recording": {
|
|
8022
8082
|
"anyOf": [
|
|
8023
8083
|
{},
|
|
@@ -9683,6 +9743,36 @@
|
|
|
9683
9743
|
"modified"
|
|
9684
9744
|
]
|
|
9685
9745
|
},
|
|
9746
|
+
"run": {
|
|
9747
|
+
"description": "Name the execution these checks belong to, so repeated local runs at one commit stay separate runs and shards of one execution stay together",
|
|
9748
|
+
"type": "object",
|
|
9749
|
+
"properties": {
|
|
9750
|
+
"id": {
|
|
9751
|
+
"type": "string",
|
|
9752
|
+
"minLength": 1,
|
|
9753
|
+
"maxLength": 256,
|
|
9754
|
+
"description": "The execution's identity within a project, chosen by the producer: a UUID, a timestamped name. Layers with the same id are one run; it never changes after it is recorded"
|
|
9755
|
+
},
|
|
9756
|
+
"title": {
|
|
9757
|
+
"description": "A human name for the run. Presentation only: it is not part of the identity and does not hide the commit or `freshness`",
|
|
9758
|
+
"type": "string"
|
|
9759
|
+
},
|
|
9760
|
+
"startedAtMs": {
|
|
9761
|
+
"description": "When the execution started, epoch milliseconds",
|
|
9762
|
+
"type": "number",
|
|
9763
|
+
"minimum": 0
|
|
9764
|
+
},
|
|
9765
|
+
"endedAtMs": {
|
|
9766
|
+
"description": "When the execution ended, epoch milliseconds",
|
|
9767
|
+
"type": "number",
|
|
9768
|
+
"minimum": 0
|
|
9769
|
+
}
|
|
9770
|
+
},
|
|
9771
|
+
"required": [
|
|
9772
|
+
"id"
|
|
9773
|
+
],
|
|
9774
|
+
"additionalProperties": {}
|
|
9775
|
+
},
|
|
9686
9776
|
"specs": {
|
|
9687
9777
|
"default": [],
|
|
9688
9778
|
"type": "array",
|
|
@@ -10171,6 +10261,36 @@
|
|
|
10171
10261
|
"modified"
|
|
10172
10262
|
]
|
|
10173
10263
|
},
|
|
10264
|
+
"run": {
|
|
10265
|
+
"description": "The execution the producer named for it (one local or manual invocation). Grouping and display only; never part of the slot",
|
|
10266
|
+
"type": "object",
|
|
10267
|
+
"properties": {
|
|
10268
|
+
"id": {
|
|
10269
|
+
"type": "string",
|
|
10270
|
+
"minLength": 1,
|
|
10271
|
+
"maxLength": 256,
|
|
10272
|
+
"description": "The execution's identity within a project, chosen by the producer: a UUID, a timestamped name. Layers with the same id are one run; it never changes after it is recorded"
|
|
10273
|
+
},
|
|
10274
|
+
"title": {
|
|
10275
|
+
"description": "A human name for the run. Presentation only: it is not part of the identity and does not hide the commit or `freshness`",
|
|
10276
|
+
"type": "string"
|
|
10277
|
+
},
|
|
10278
|
+
"startedAtMs": {
|
|
10279
|
+
"description": "When the execution started, epoch milliseconds",
|
|
10280
|
+
"type": "number",
|
|
10281
|
+
"minimum": 0
|
|
10282
|
+
},
|
|
10283
|
+
"endedAtMs": {
|
|
10284
|
+
"description": "When the execution ended, epoch milliseconds",
|
|
10285
|
+
"type": "number",
|
|
10286
|
+
"minimum": 0
|
|
10287
|
+
}
|
|
10288
|
+
},
|
|
10289
|
+
"required": [
|
|
10290
|
+
"id"
|
|
10291
|
+
],
|
|
10292
|
+
"additionalProperties": {}
|
|
10293
|
+
},
|
|
10174
10294
|
"recording": {
|
|
10175
10295
|
"anyOf": [
|
|
10176
10296
|
{},
|
|
@@ -13747,7 +13867,23 @@
|
|
|
13747
13867
|
"properties": {
|
|
13748
13868
|
"key": {
|
|
13749
13869
|
"type": "string",
|
|
13750
|
-
"description": "
|
|
13870
|
+
"description": "`run:<id>` for an execution a producer named, else the run URL, else `branch@sha`. Pass it to `/api/reports/runs/detail?key=` or `/api/reports/tests?run=`"
|
|
13871
|
+
},
|
|
13872
|
+
"runId": {
|
|
13873
|
+
"description": "The execution id the producer named, when it did. Layers that share it are one run however many uploads they took",
|
|
13874
|
+
"type": "string"
|
|
13875
|
+
},
|
|
13876
|
+
"title": {
|
|
13877
|
+
"description": "The human title of the execution (the newest layer's). Presentation only: the key, commit and `freshness` are unaffected",
|
|
13878
|
+
"type": "string"
|
|
13879
|
+
},
|
|
13880
|
+
"freshness": {
|
|
13881
|
+
"description": "`modified` when any layer of the run was recorded from a checkout with uncommitted changes; `unknown` when an origin was not captured; absent when all are clean. Shown beside the commit, never replaced by the title",
|
|
13882
|
+
"type": "string",
|
|
13883
|
+
"enum": [
|
|
13884
|
+
"unknown",
|
|
13885
|
+
"modified"
|
|
13886
|
+
]
|
|
13751
13887
|
},
|
|
13752
13888
|
"runUrl": {
|
|
13753
13889
|
"type": [
|
|
@@ -13901,7 +14037,23 @@
|
|
|
13901
14037
|
"properties": {
|
|
13902
14038
|
"key": {
|
|
13903
14039
|
"type": "string",
|
|
13904
|
-
"description": "
|
|
14040
|
+
"description": "`run:<id>` for an execution a producer named, else the run URL, else `branch@sha`. Pass it to `/api/reports/runs/detail?key=` or `/api/reports/tests?run=`"
|
|
14041
|
+
},
|
|
14042
|
+
"runId": {
|
|
14043
|
+
"description": "The execution id the producer named, when it did. Layers that share it are one run however many uploads they took",
|
|
14044
|
+
"type": "string"
|
|
14045
|
+
},
|
|
14046
|
+
"title": {
|
|
14047
|
+
"description": "The human title of the execution (the newest layer's). Presentation only: the key, commit and `freshness` are unaffected",
|
|
14048
|
+
"type": "string"
|
|
14049
|
+
},
|
|
14050
|
+
"freshness": {
|
|
14051
|
+
"description": "`modified` when any layer of the run was recorded from a checkout with uncommitted changes; `unknown` when an origin was not captured; absent when all are clean. Shown beside the commit, never replaced by the title",
|
|
14052
|
+
"type": "string",
|
|
14053
|
+
"enum": [
|
|
14054
|
+
"unknown",
|
|
14055
|
+
"modified"
|
|
14056
|
+
]
|
|
13905
14057
|
},
|
|
13906
14058
|
"runUrl": {
|
|
13907
14059
|
"type": [
|
|
@@ -268,6 +268,10 @@ type Props = {
|
|
|
268
268
|
reads: 'steps' | 'trace';
|
|
269
269
|
/** The check as written, shown until a recording is read or when there is none. */
|
|
270
270
|
written?: ReactNode;
|
|
271
|
+
/** An exact earlier result the caller found (for example on a server that only sends winners). It wins over `layerId`. */
|
|
272
|
+
pinned?: HistoryEntry;
|
|
273
|
+
/** Choosing a run in the history always names its layer, even when that result is a current one, so the choice stays pinned. */
|
|
274
|
+
pin?: boolean;
|
|
271
275
|
};
|
|
272
276
|
/** Everything recorded for one check: the chosen result, how it went, and every other run of it. */
|
|
273
277
|
export function CheckDetail(props: Props) {
|
|
@@ -298,13 +302,13 @@ export function CheckDetail(props: Props) {
|
|
|
298
302
|
</>
|
|
299
303
|
);
|
|
300
304
|
}
|
|
301
|
-
function RecordedCheckDetail({ slot, layerId, select, reads, written }: Props) {
|
|
305
|
+
function RecordedCheckDetail({ slot, layerId, select, reads, written, pinned, pin }: Props) {
|
|
302
306
|
const { evidence, backends } = useEvidence();
|
|
303
307
|
const now = useNow();
|
|
304
308
|
const [variant, setVariant] = useState(0);
|
|
305
309
|
const history = useMemo(() => checkHistory(evidence, slot.specPath, slot.id), [evidence, slot.specPath, slot.id]);
|
|
306
310
|
const winner = slot.outcomes[Math.min(variant, slot.outcomes.length - 1)]!;
|
|
307
|
-
const earlier = layerId ? history.find((entry) => entry.layer.id === layerId) : undefined;
|
|
311
|
+
const earlier = pinned ?? (layerId ? history.find((entry) => entry.layer.id === layerId) : undefined);
|
|
308
312
|
const chosen: HistoryEntry = earlier ?? { layer: winner.layer, row: winner.row };
|
|
309
313
|
const superseded = !slot.outcomes.some((outcome) => outcome.layer.id === chosen.layer.id);
|
|
310
314
|
const { paths, report } = useRecordedReport(chosen);
|
|
@@ -397,7 +401,7 @@ function RecordedCheckDetail({ slot, layerId, select, reads, written }: Props) {
|
|
|
397
401
|
{moments > 0 && (
|
|
398
402
|
<Button
|
|
399
403
|
type="button"
|
|
400
|
-
onClick={() => select({ view: 'recording', check: slot.id, layer: earlier ? chosen.layer.id : null })}
|
|
404
|
+
onClick={() => select({ view: 'recording', check: slot.id, layer: earlier || variant > 0 ? chosen.layer.id : null })}
|
|
401
405
|
variant="filled"
|
|
402
406
|
>
|
|
403
407
|
Open recording{' '}
|
|
@@ -428,7 +432,9 @@ function RecordedCheckDetail({ slot, layerId, select, reads, written }: Props) {
|
|
|
428
432
|
type="button"
|
|
429
433
|
aria-current={current || undefined}
|
|
430
434
|
onClick={() =>
|
|
431
|
-
select({
|
|
435
|
+
select({
|
|
436
|
+
layer: !pin && slot.outcomes.some((outcome) => outcome.layer.id === entry.layer.id) ? null : entry.layer.id
|
|
437
|
+
})
|
|
432
438
|
}
|
|
433
439
|
variant="light"
|
|
434
440
|
>
|
|
@@ -15,6 +15,8 @@ type Props = {
|
|
|
15
15
|
/** The check whose recording is open, once the combined report holds it. */
|
|
16
16
|
slot: CheckSlot | undefined;
|
|
17
17
|
layerId: string | undefined;
|
|
18
|
+
/** The exact result to play, when the caller already found it (it wins over `layerId` and the combined winner). */
|
|
19
|
+
entry?: HistoryEntry | undefined;
|
|
18
20
|
label: string;
|
|
19
21
|
back: string;
|
|
20
22
|
moment: number | null;
|
|
@@ -36,14 +38,15 @@ function needsStylesheet(report: RunReport | undefined): boolean {
|
|
|
36
38
|
return visit(report.steps) || (report.artifacts ?? []).some((artifact) => artifact.media === 'html');
|
|
37
39
|
}
|
|
38
40
|
/** One recorded test, opened in place of its spec. */
|
|
39
|
-
export function RecordingPage({ slot, layerId, label, back, moment, onMoment, onClose }: Props) {
|
|
41
|
+
export function RecordingPage({ slot, layerId, entry, label, back, moment, onMoment, onClose }: Props) {
|
|
40
42
|
const progress = useTestProgress(slot ?? null);
|
|
41
43
|
const { evidence, backends, pending, ready, unavailable } = useEvidence();
|
|
42
44
|
const now = useNow();
|
|
43
45
|
const history = useMemo(() => (slot ? checkHistory(evidence, slot.specPath, slot.id) : []), [evidence, slot]);
|
|
44
46
|
const winner = slot?.outcomes[0];
|
|
45
47
|
const chosen: HistoryEntry | null =
|
|
46
|
-
|
|
48
|
+
entry ??
|
|
49
|
+
(layerId ? history.find((earlier) => earlier.layer.id === layerId) : undefined) ??
|
|
47
50
|
(winner ? { layer: winner.layer, row: winner.row } : null);
|
|
48
51
|
const { paths, report } = useRecordedReport(chosen);
|
|
49
52
|
const wantsCss = needsStylesheet(report.data);
|
|
@@ -143,7 +143,7 @@ export function Explorer() {
|
|
|
143
143
|
{model && model.tests.length > 0 && (
|
|
144
144
|
<Tabs
|
|
145
145
|
value={search.tab ?? 'tests'}
|
|
146
|
-
onChange={(value) => go({ tab: value === 'runs' ? 'runs' : undefined, test: undefined, openRun: undefined })}
|
|
146
|
+
onChange={(value) => go({ tab: value === 'runs' ? 'runs' : undefined, test: undefined, openRun: undefined, layer: undefined })}
|
|
147
147
|
>
|
|
148
148
|
<Tabs.List aria-label="Explorer view">
|
|
149
149
|
<Tabs.Tab
|
|
@@ -247,7 +247,7 @@ export function Explorer() {
|
|
|
247
247
|
</>
|
|
248
248
|
))}
|
|
249
249
|
</Box>
|
|
250
|
-
<Drawer opened={split} onClose={() => go({ test: undefined })} title="Test detail" size="xl">
|
|
250
|
+
<Drawer opened={split} onClose={() => go({ test: undefined, layer: undefined })} title="Test detail" size="xl">
|
|
251
251
|
<Box aria-label="Test detail" component="aside">
|
|
252
252
|
{selected ? (
|
|
253
253
|
<TestDetail test={selected} slot={slots.get(selected.key)} />
|
|
@@ -258,7 +258,7 @@ export function Explorer() {
|
|
|
258
258
|
</Alert>
|
|
259
259
|
<Anchor
|
|
260
260
|
renderRoot={(props) => (
|
|
261
|
-
<Link {...props} to="/" search={patched({ test: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
261
|
+
<Link {...props} to="/" search={patched({ test: undefined, layer: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
262
262
|
)}
|
|
263
263
|
>
|
|
264
264
|
Close
|
|
@@ -358,7 +358,7 @@ function InsightStrip({ model, scope, search }: { model: CatalogModel; scope: re
|
|
|
358
358
|
<Link
|
|
359
359
|
{...props}
|
|
360
360
|
to="/"
|
|
361
|
-
search={patched({ tab: 'runs', test: undefined })}
|
|
361
|
+
search={patched({ tab: 'runs', test: undefined, layer: undefined })}
|
|
362
362
|
resetScroll={false}
|
|
363
363
|
activeOptions={{ exact: true }}
|
|
364
364
|
/>
|
|
@@ -443,7 +443,7 @@ function MoreInsights({ scope }: { scope: readonly TestSummary[] }) {
|
|
|
443
443
|
<List.Item key={test.key}>
|
|
444
444
|
<Anchor
|
|
445
445
|
renderRoot={(props) => (
|
|
446
|
-
<Link {...props} to="/" search={patched({ test: test.key })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
446
|
+
<Link {...props} to="/" search={patched({ test: test.key, layer: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
447
447
|
)}
|
|
448
448
|
>
|
|
449
449
|
<StatusMark status={test.status} size={12} />
|
|
@@ -819,7 +819,7 @@ function TestRow({ test, selected, now }: { test: TestSummary; selected: boolean
|
|
|
819
819
|
<Link
|
|
820
820
|
{...props}
|
|
821
821
|
to="/"
|
|
822
|
-
search={patched({ test: selected ? undefined : test.key })}
|
|
822
|
+
search={patched({ test: selected ? undefined : test.key, layer: undefined })}
|
|
823
823
|
resetScroll={false}
|
|
824
824
|
activeOptions={{ exact: true }}
|
|
825
825
|
/>
|
|
@@ -23,6 +23,19 @@ export function RunsView() {
|
|
|
23
23
|
const search = useExplorerSearch();
|
|
24
24
|
return search.openRun ? <RunDetailView runKey={search.openRun} /> : <RunTable />;
|
|
25
25
|
}
|
|
26
|
+
/** What a run is called: the title its producer gave it, else its CI run number or `branch@sha`. */
|
|
27
|
+
function runLabel(run: RunSummary): string {
|
|
28
|
+
return run.title ?? runName(run.key);
|
|
29
|
+
}
|
|
30
|
+
/** Beside a commit: said whenever the run was not recorded from a clean checkout, whatever the run is titled. */
|
|
31
|
+
function Freshness({ run }: { run: RunSummary }) {
|
|
32
|
+
if (!run.freshness) return null;
|
|
33
|
+
return (
|
|
34
|
+
<Text span inherit c="dimmed">
|
|
35
|
+
{run.freshness === 'modified' ? 'with local changes' : 'origin not captured'}
|
|
36
|
+
</Text>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
26
39
|
function Counts({ run }: { run: RunSummary }) {
|
|
27
40
|
return (
|
|
28
41
|
<Text span inherit>
|
|
@@ -98,19 +111,24 @@ function RunTable() {
|
|
|
98
111
|
<Table.Tr key={run.key} data-status={run.counts.failed ? 'failed' : 'passed'}>
|
|
99
112
|
<Table.Td title={exactTime(run.endedAtMs)}>
|
|
100
113
|
<Anchor
|
|
101
|
-
aria-label={`Open run ${
|
|
114
|
+
aria-label={`Open run ${runLabel(run)}`}
|
|
102
115
|
renderRoot={(props) => (
|
|
103
116
|
<Link {...props} to="/" search={patched({ openRun: run.key })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
104
117
|
)}
|
|
105
118
|
>
|
|
106
119
|
{ago(run.endedAtMs, now)}
|
|
107
120
|
</Anchor>
|
|
121
|
+
{run.title && (
|
|
122
|
+
<Text c="dimmed" fz="xs">
|
|
123
|
+
{run.title}
|
|
124
|
+
</Text>
|
|
125
|
+
)}
|
|
108
126
|
</Table.Td>
|
|
109
127
|
<Table.Td title={run.branch ?? ''} c="dimmed">
|
|
110
128
|
{run.branch ?? '—'}
|
|
111
129
|
</Table.Td>
|
|
112
130
|
<Table.Td>
|
|
113
|
-
<Code>{shortSha(run.sha) ?? '—'}</Code>
|
|
131
|
+
<Code>{shortSha(run.sha) ?? '—'}</Code> <Freshness run={run} />
|
|
114
132
|
</Table.Td>
|
|
115
133
|
<Table.Td>{run.environments.map((env) => environmentLabel(env)).join(', ') || '—'}</Table.Td>
|
|
116
134
|
<Table.Td>{run.tests}</Table.Td>
|
|
@@ -215,10 +233,10 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
215
233
|
);
|
|
216
234
|
const { run } = detail.data;
|
|
217
235
|
return (
|
|
218
|
-
<Box aria-label={`Run ${
|
|
236
|
+
<Box aria-label={`Run ${runLabel(run)}`} component="section" mb="lg">
|
|
219
237
|
{back}
|
|
220
238
|
<Title order={2} mb="sm">
|
|
221
|
-
Run {
|
|
239
|
+
Run {runLabel(run)}
|
|
222
240
|
</Title>
|
|
223
241
|
<Text component="div">
|
|
224
242
|
{run.branch && (
|
|
@@ -227,6 +245,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
227
245
|
</Text>
|
|
228
246
|
)}
|
|
229
247
|
{run.sha && <Code>{shortSha(run.sha)}</Code>}
|
|
248
|
+
<Freshness run={run} />
|
|
230
249
|
<Text title={exactTime(run.endedAtMs)} span inherit>
|
|
231
250
|
ended {ago(run.endedAtMs, now)}
|
|
232
251
|
</Text>
|
|
@@ -262,7 +281,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
262
281
|
<Link
|
|
263
282
|
{...props}
|
|
264
283
|
to="/"
|
|
265
|
-
search={patched({ tab: undefined, openRun: undefined, inRun: run.key, test: undefined })}
|
|
284
|
+
search={patched({ tab: undefined, openRun: undefined, inRun: run.key, test: undefined, layer: undefined })}
|
|
266
285
|
resetScroll={false}
|
|
267
286
|
activeOptions={{ exact: true }}
|
|
268
287
|
/>
|
|
@@ -328,7 +347,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
328
347
|
<Link
|
|
329
348
|
{...props}
|
|
330
349
|
to="/"
|
|
331
|
-
search={patched({ tab: undefined, openRun: undefined, test: result.key })}
|
|
350
|
+
search={patched({ tab: undefined, openRun: undefined, test: result.key, layer: result.layer })}
|
|
332
351
|
resetScroll={false}
|
|
333
352
|
activeOptions={{ exact: true }}
|
|
334
353
|
/>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { IconX, IconCircleFilled } from '@tabler/icons-react';
|
|
2
|
-
import { Alert, Anchor, Box, Button, Code, Flex, SimpleGrid, Stack, Table, Text, Title, VisuallyHidden } from '@mantine/core';
|
|
2
|
+
import { Alert, Anchor, Badge, Box, Button, Code, Flex, SimpleGrid, Stack, Table, Text, Title, VisuallyHidden } from '@mantine/core';
|
|
3
3
|
/**
|
|
4
4
|
* @module Test detail
|
|
5
5
|
*
|
|
@@ -10,27 +10,127 @@ import { Alert, Anchor, Box, Button, Code, Flex, SimpleGrid, Stack, Table, Text,
|
|
|
10
10
|
*/
|
|
11
11
|
import type { CheckSlot } from '@crvouga/atlas/reports/evidence-view';
|
|
12
12
|
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
13
|
-
import { formatMs, type TestResultEntry, type TestSummary } from '@crvouga/atlas/reports/test-catalog';
|
|
13
|
+
import { formatMs, runKey, type TestResultEntry, type TestSummary } from '@crvouga/atlas/reports/test-catalog';
|
|
14
14
|
import { Link } from '@tanstack/react-router';
|
|
15
15
|
import { lazy, Suspense, useState } from 'react';
|
|
16
|
-
import { CheckDetail
|
|
16
|
+
import { CheckDetail } from '../evidence/check-detail';
|
|
17
17
|
import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
|
|
18
|
-
import {
|
|
18
|
+
import { useEvidence } from '../evidence/evidence-context';
|
|
19
|
+
import { Origin, StatusMark } from '../evidence/marks';
|
|
19
20
|
import { useTestHistory } from './catalog';
|
|
20
|
-
import { patched } from './navigate';
|
|
21
|
-
import { Badges } from './parts';
|
|
21
|
+
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
22
|
+
import { Badges, runName } from './parts';
|
|
23
|
+
import { usePinnedResult, type PinnedResult } from './pinned';
|
|
22
24
|
const RecordingPage = lazy(() => import('../evidence/recording-page').then((module) => ({ default: module.RecordingPage })));
|
|
23
25
|
/** Results the history table shows before "Show more". */
|
|
24
26
|
const HISTORY_ROWS = 20;
|
|
25
27
|
function percent(rate: number | null): string {
|
|
26
28
|
return rate === null ? '—' : `${Math.round(rate * 100)}%`;
|
|
27
29
|
}
|
|
30
|
+
/** Switches the detail from one pinned result back to the latest combined evidence. */
|
|
31
|
+
function LatestLink({ children }: { children: string }) {
|
|
32
|
+
return (
|
|
33
|
+
<Anchor
|
|
34
|
+
renderRoot={(props) => (
|
|
35
|
+
<Link {...props} to="/" search={patched({ layer: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
{children}
|
|
39
|
+
</Anchor>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Says which evidence the detail shows: this one run's result (pinned by the address), or the latest combined evidence.
|
|
45
|
+
* The change from one to the other is always an explicit link, so a result never turns into another silently.
|
|
46
|
+
*/
|
|
47
|
+
function Scope({ pinned, slot }: { pinned: PinnedResult; slot: CheckSlot | undefined }) {
|
|
48
|
+
const { backends } = useEvidence();
|
|
49
|
+
const now = useNow();
|
|
50
|
+
if (pinned.state === 'none')
|
|
51
|
+
return (
|
|
52
|
+
<Flex aria-label="Evidence scope" role="group" gap="xs" align="center" wrap="wrap" mb="lg">
|
|
53
|
+
<Badge variant="light">Latest combined evidence</Badge>
|
|
54
|
+
<Text span inherit c="dimmed">
|
|
55
|
+
The newest result of each environment, which can come from different runs. Open a run to see exactly what it recorded.
|
|
56
|
+
</Text>
|
|
57
|
+
</Flex>
|
|
58
|
+
);
|
|
59
|
+
if (pinned.state === 'loading')
|
|
60
|
+
return (
|
|
61
|
+
<Text role="status" component="div" c="dimmed" mb="lg">
|
|
62
|
+
Reading the result of the run you opened…
|
|
63
|
+
</Text>
|
|
64
|
+
);
|
|
65
|
+
if (pinned.state === 'missing')
|
|
66
|
+
return (
|
|
67
|
+
<Alert color="blue" title="That result is not available" mb="lg">
|
|
68
|
+
<Stack gap="xs">
|
|
69
|
+
<Text span inherit>
|
|
70
|
+
{pinned.reason} No other result is shown in its place.
|
|
71
|
+
</Text>
|
|
72
|
+
<LatestLink>Show latest combined evidence</LatestLink>
|
|
73
|
+
</Stack>
|
|
74
|
+
</Alert>
|
|
75
|
+
);
|
|
76
|
+
const { layer } = pinned.entry;
|
|
77
|
+
const newer = slot?.outcomes.find(
|
|
78
|
+
(outcome) =>
|
|
79
|
+
outcome.layer.id !== layer.id &&
|
|
80
|
+
outcome.layer.recordedAtMs > layer.recordedAtMs &&
|
|
81
|
+
outcome.layer.producer === layer.producer &&
|
|
82
|
+
outcome.layer.environment === layer.environment &&
|
|
83
|
+
JSON.stringify(outcome.parameters) === JSON.stringify(pinned.entry.row.variant)
|
|
84
|
+
);
|
|
85
|
+
const key = runKey(layer);
|
|
86
|
+
return (
|
|
87
|
+
<Alert color="blue" title={`This run: ${runName(key)}`} mb="lg">
|
|
88
|
+
<Stack gap="xs">
|
|
89
|
+
<Flex gap="xs" align="center" wrap="wrap">
|
|
90
|
+
<Text span inherit fw={600}>
|
|
91
|
+
{layer.producer}
|
|
92
|
+
</Text>
|
|
93
|
+
<Origin layer={layer} backends={backends} now={now} />
|
|
94
|
+
</Flex>
|
|
95
|
+
<Text span inherit>
|
|
96
|
+
The evidence below is what this run recorded. The current results, statistics and history are about the latest combined evidence
|
|
97
|
+
across runs.
|
|
98
|
+
</Text>
|
|
99
|
+
{newer && (
|
|
100
|
+
<Text span inherit role="status">
|
|
101
|
+
A newer result exists: {statusWord(newer.row.status)} {ago(newer.layer.recordedAtMs, now)}.
|
|
102
|
+
</Text>
|
|
103
|
+
)}
|
|
104
|
+
<Flex gap="md" align="center" wrap="wrap">
|
|
105
|
+
<LatestLink>Show latest combined evidence</LatestLink>
|
|
106
|
+
<Anchor
|
|
107
|
+
renderRoot={(props) => (
|
|
108
|
+
<Link
|
|
109
|
+
{...props}
|
|
110
|
+
to="/"
|
|
111
|
+
search={patched({ tab: 'runs', openRun: key, test: undefined, layer: undefined })}
|
|
112
|
+
resetScroll={false}
|
|
113
|
+
activeOptions={{ exact: true }}
|
|
114
|
+
/>
|
|
115
|
+
)}
|
|
116
|
+
>
|
|
117
|
+
Back to run {runName(key)}
|
|
118
|
+
</Anchor>
|
|
119
|
+
</Flex>
|
|
120
|
+
</Stack>
|
|
121
|
+
</Alert>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
28
125
|
/** The open test. `slot` is the report's check for it, when the page holds one, for its evidence. */
|
|
29
126
|
export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot | undefined }) {
|
|
30
127
|
const now = useNow();
|
|
31
128
|
const history = useTestHistory(test.key);
|
|
32
|
-
const
|
|
129
|
+
const { layer } = useExplorerSearch();
|
|
130
|
+
const go = useExplorerNavigate();
|
|
131
|
+
const pinned = usePinnedResult(layer, test.key);
|
|
33
132
|
const [recording, setRecording] = useState(false);
|
|
133
|
+
const [recordingLayer, setRecordingLayer] = useState<string | undefined>(undefined);
|
|
34
134
|
const [moment, setMoment] = useState<number | null>(null);
|
|
35
135
|
const [rows, setRows] = useState(HISTORY_ROWS);
|
|
36
136
|
const entries = history.data?.history ?? [];
|
|
@@ -47,7 +147,8 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
|
|
|
47
147
|
>
|
|
48
148
|
<RecordingPage
|
|
49
149
|
slot={slot}
|
|
50
|
-
layerId={
|
|
150
|
+
layerId={recordingLayer ?? layer}
|
|
151
|
+
entry={pinned.state === 'ready' && (recordingLayer ?? layer) === pinned.entry.layer.id ? pinned.entry : undefined}
|
|
51
152
|
label={test.title}
|
|
52
153
|
back="Test detail"
|
|
53
154
|
moment={moment}
|
|
@@ -63,7 +164,7 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
|
|
|
63
164
|
<Anchor
|
|
64
165
|
aria-label="Close the test"
|
|
65
166
|
renderRoot={(props) => (
|
|
66
|
-
<Link {...props} to="/" search={patched({ test: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
167
|
+
<Link {...props} to="/" search={patched({ test: undefined, layer: undefined })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
67
168
|
)}
|
|
68
169
|
>
|
|
69
170
|
<IconX size={20} aria-hidden="true" />
|
|
@@ -123,6 +224,8 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
|
|
|
123
224
|
</Code>
|
|
124
225
|
</Box>
|
|
125
226
|
|
|
227
|
+
<Scope pinned={pinned} slot={slot} />
|
|
228
|
+
|
|
126
229
|
<Box aria-label="Current results" component="section" mb="lg">
|
|
127
230
|
<Title order={3} mb="sm" c="dimmed">
|
|
128
231
|
Current result per environment
|
|
@@ -314,21 +417,26 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
|
|
|
314
417
|
|
|
315
418
|
<Box aria-label="Evidence" component="section" mb="lg">
|
|
316
419
|
<Title order={3} mb="sm" c="dimmed">
|
|
317
|
-
What the current result carries
|
|
420
|
+
{layer ? 'What this run recorded' : 'What the current result carries'}
|
|
318
421
|
</Title>
|
|
319
|
-
{slot ? (
|
|
422
|
+
{slot && (!layer || pinned.state === 'ready') ? (
|
|
320
423
|
<CheckDetail
|
|
321
424
|
slot={slot}
|
|
322
|
-
layerId={
|
|
425
|
+
layerId={layer}
|
|
426
|
+
{...(pinned.state === 'ready' ? { pinned: pinned.entry, pin: true } : {})}
|
|
323
427
|
reads="steps"
|
|
324
428
|
select={(patch) => {
|
|
325
|
-
setSelection({ check: patch.check ?? slot.id, layer: patch.layer ?? undefined });
|
|
326
429
|
if (patch.view === 'recording') {
|
|
430
|
+
setRecordingLayer(patch.layer ?? undefined);
|
|
327
431
|
setRecording(true);
|
|
328
432
|
setMoment(null);
|
|
329
|
-
}
|
|
433
|
+
} else go({ layer: patch.layer ?? undefined });
|
|
330
434
|
}}
|
|
331
435
|
/>
|
|
436
|
+
) : layer ? (
|
|
437
|
+
<Text component="div" c="dimmed">
|
|
438
|
+
{pinned.state === 'missing' ? 'There is no evidence to show for that run.' : 'Reading the evidence of that run…'}
|
|
439
|
+
</Text>
|
|
332
440
|
) : (
|
|
333
441
|
<Text component="div" c="dimmed">
|
|
334
442
|
The evidence of this result is not loaded in this page.
|
|
@@ -362,6 +470,9 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
|
|
|
362
470
|
<Table.Th scope="col">Branch</Table.Th>
|
|
363
471
|
<Table.Th scope="col">Commit</Table.Th>
|
|
364
472
|
<Table.Th scope="col">Detail</Table.Th>
|
|
473
|
+
<Table.Th scope="col">
|
|
474
|
+
<VisuallyHidden>Evidence</VisuallyHidden>
|
|
475
|
+
</Table.Th>
|
|
365
476
|
</Table.Tr>
|
|
366
477
|
</Table.Thead>
|
|
367
478
|
<Table.Tbody>
|
|
@@ -396,6 +507,23 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
|
|
|
396
507
|
<Table.Td title={entry.detail} c="dimmed">
|
|
397
508
|
{entry.detail.split('\n')[0]}
|
|
398
509
|
</Table.Td>
|
|
510
|
+
<Table.Td>
|
|
511
|
+
<Anchor
|
|
512
|
+
aria-label={`Open the ${statusWord(entry.status)} result recorded ${ago(entry.at, now)} in ${environmentLabel(entry.environment)}`}
|
|
513
|
+
aria-current={entry.layer === layer ? 'true' : undefined}
|
|
514
|
+
renderRoot={(props) => (
|
|
515
|
+
<Link
|
|
516
|
+
{...props}
|
|
517
|
+
to="/"
|
|
518
|
+
search={patched({ layer: entry.layer })}
|
|
519
|
+
resetScroll={false}
|
|
520
|
+
activeOptions={{ exact: true }}
|
|
521
|
+
/>
|
|
522
|
+
)}
|
|
523
|
+
>
|
|
524
|
+
Open this result
|
|
525
|
+
</Anchor>
|
|
526
|
+
</Table.Td>
|
|
399
527
|
</Table.Tr>
|
|
400
528
|
))}
|
|
401
529
|
</Table.Tbody>
|
|
@@ -21,8 +21,9 @@ export function projectTitle(): string {
|
|
|
21
21
|
.join(' ') || 'Project reports'
|
|
22
22
|
);
|
|
23
23
|
}
|
|
24
|
-
/** A run key for a person: its CI run number, or `branch@sha` shortened. */
|
|
24
|
+
/** A run key for a person: the execution id a producer named, its CI run number, or `branch@sha` shortened. */
|
|
25
25
|
export function runName(key: string): string {
|
|
26
|
+
if (key.startsWith('run:')) return key.slice(4);
|
|
26
27
|
const actions = /\/actions\/runs\/(\d+)/.exec(key);
|
|
27
28
|
if (actions) return `#${actions[1]}`;
|
|
28
29
|
if (/^https?:/.test(key)) return key.replace(/^https?:\/\//, '').slice(0, 40);
|