@crvouga/atlas 1.6.0 → 1.8.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 +13 -1
- package/dist/reports/src/explorer-types.d.ts +19 -0
- package/dist/reports/src/latency.d.ts +204 -0
- package/dist/reports/src/latency.js +219 -0
- package/dist/reports/src/reports-explorer-route.d.ts +19 -6
- package/dist/reports/src/reports-route.d.ts +19 -6
- package/dist/reports/src/test-catalog.d.ts +3 -1
- package/dist/reports/src/test-catalog.js +4 -2
- package/dist/schema/src/api.d.ts +151 -0
- package/dist/schema/src/api.js +4 -3
- package/dist/schema/src/layer.d.ts +90 -0
- package/dist/schema/src/layer.js +20 -1
- package/docs/extending.md +18 -1
- package/docs/reference/reports.md +347 -0
- package/docs/reference/schema.md +28 -0
- package/docs/rich-evidence.md +43 -0
- package/docs/ui.md +24 -1
- package/llms-full.txt +85 -2
- package/package.json +1 -1
- package/packages/schema/schemas/check-detail.schema.json +56 -1
- package/packages/schema/schemas/effective-delta.schema.json +60 -2
- package/packages/schema/schemas/effective-evidence.schema.json +60 -2
- package/packages/schema/schemas/layer-rows.schema.json +60 -2
- package/packages/schema/schemas/layer.schema.json +112 -2
- package/packages/schema/schemas/publish-checks.schema.json +56 -1
- package/packages/schema/schemas/test-catalog.schema.json +85 -0
- package/packages/schema/schemas/test-history.schema.json +170 -0
- package/packages/server/openapi.json +547 -8
- package/packages/visualizer/src/evidence/behavior-panel.tsx +10 -16
- package/packages/visualizer/src/evidence/check-detail.tsx +59 -15
- package/packages/visualizer/src/evidence/outcome-summary.tsx +208 -0
- package/packages/visualizer/src/evidence/outcome.test.ts +129 -0
- package/packages/visualizer/src/evidence/outcome.ts +309 -0
- package/packages/visualizer/src/explorer/ProductLatency.tsx +214 -0
- package/packages/visualizer/src/explorer/TestDetail.tsx +32 -3
- package/packages/visualizer/src/explorer/search.ts +3 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Outcome explanation
|
|
3
|
+
*
|
|
4
|
+
* Pure reading of what one selected result says, set beside everything else recorded for the same check, so a
|
|
5
|
+
* detail never leads with a summary that contradicts its evidence. The selected source's outcome, the other current
|
|
6
|
+
* sources, where a failure happened, whether the behavior was reached, and the chronology of one source are all
|
|
7
|
+
* stated separately. Nothing here changes a status or a winner: it only words what was recorded, and anything that
|
|
8
|
+
* was not recorded stays unknown. In particular a failure is never attributed to the product, the test harness or
|
|
9
|
+
* the environment from the wording of its message; only a producer's own `atlas.failure` declaration is shown.
|
|
10
|
+
*/
|
|
11
|
+
import type { HistoryEntry, CheckSlot } from '@crvouga/atlas/reports/evidence-view';
|
|
12
|
+
import { isVideoAttachment, type EvidenceHolder } from '@crvouga/atlas/reports/evidence-detail';
|
|
13
|
+
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
14
|
+
import type { EvidenceResult } from '@crvouga/atlas/reports/layer-merge';
|
|
15
|
+
import type { ReportStep, RunReport } from '@crvouga/atlas/reports/report-types';
|
|
16
|
+
import type { Attachment, Step } from '@crvouga/atlas/schema';
|
|
17
|
+
import { ago, shortSha, statusWord } from './format';
|
|
18
|
+
|
|
19
|
+
/** The extension key under which a producer may declare why and where a check failed. */
|
|
20
|
+
export const FAILURE_EXTENSION = 'atlas.failure';
|
|
21
|
+
|
|
22
|
+
/** Who a producer declared a failure to be the fault of. Absent is unknown, never inferred. */
|
|
23
|
+
export type Attribution = 'product' | 'test-harness' | 'environment';
|
|
24
|
+
|
|
25
|
+
const ATTRIBUTIONS: readonly string[] = ['product', 'test-harness', 'environment'];
|
|
26
|
+
|
|
27
|
+
/** What a producer declared about a failure: the expectation, what was observed, and optionally whose fault it was. */
|
|
28
|
+
export type FailureNote = {
|
|
29
|
+
readonly expected?: string;
|
|
30
|
+
readonly actual?: string;
|
|
31
|
+
readonly attribution?: Attribution;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Whether the failed step belongs to getting ready for the behavior, to the behavior itself, or is not known to be either. */
|
|
35
|
+
export type Phase = 'setup' | 'behavior' | 'unspecified';
|
|
36
|
+
|
|
37
|
+
/** Step kinds (an open vocabulary) that are preparation, not the behavior under test. */
|
|
38
|
+
const SETUP_KINDS = new Set(['hook', 'setup', 'fixture', 'before', 'beforeall', 'beforeeach', 'given']);
|
|
39
|
+
/** Step kinds that are the behavior being exercised or an expectation about it. */
|
|
40
|
+
const BEHAVIOR_KINDS = new Set(['when', 'then', 'assertion', 'expect']);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Which phase a step of this `kind` belongs to. Decided by the recorded kind only, never by a step's name or message;
|
|
44
|
+
* a kind that is not recognized, or none, is `unspecified`.
|
|
45
|
+
*/
|
|
46
|
+
export function phaseOf(kind: string | undefined): Phase {
|
|
47
|
+
const key = kind?.toLowerCase();
|
|
48
|
+
if (!key) return 'unspecified';
|
|
49
|
+
return SETUP_KINDS.has(key) ? 'setup' : BEHAVIOR_KINDS.has(key) ? 'behavior' : 'unspecified';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The step at which a result failed, as far as the recording says. */
|
|
53
|
+
export type FailurePoint = {
|
|
54
|
+
readonly name: string;
|
|
55
|
+
readonly kind: string | undefined;
|
|
56
|
+
readonly phase: Phase;
|
|
57
|
+
/** The step's own message, as recorded. */
|
|
58
|
+
readonly detail: string | undefined;
|
|
59
|
+
/** How long after the check started it began, when recorded. */
|
|
60
|
+
readonly offsetMs: number | undefined;
|
|
61
|
+
readonly note: FailureNote | undefined;
|
|
62
|
+
/** The videos on the step itself, if it has any. */
|
|
63
|
+
readonly videos: readonly Attachment[];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function text(value: unknown): string | undefined {
|
|
67
|
+
if (value === undefined || value === null) return undefined;
|
|
68
|
+
if (typeof value === 'string') return value;
|
|
69
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
70
|
+
return JSON.stringify(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The failure a producer declared under {@link FAILURE_EXTENSION} in `extensions`: `expected`, `actual` and an
|
|
75
|
+
* `attribution` of `product`, `test-harness` or `environment`. Anything else is ignored; undefined when nothing usable is there.
|
|
76
|
+
*/
|
|
77
|
+
export function failureNote(extensions: Readonly<Record<string, unknown>> | undefined): FailureNote | undefined {
|
|
78
|
+
const raw = extensions?.[FAILURE_EXTENSION];
|
|
79
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
|
|
80
|
+
const { expected, actual, attribution } = raw as Record<string, unknown>;
|
|
81
|
+
const note: { expected?: string; actual?: string; attribution?: Attribution } = {};
|
|
82
|
+
const e = text(expected);
|
|
83
|
+
const a = text(actual);
|
|
84
|
+
if (e !== undefined) note.expected = e;
|
|
85
|
+
if (a !== undefined) note.actual = a;
|
|
86
|
+
if (typeof attribution === 'string' && ATTRIBUTIONS.includes(attribution)) note.attribution = attribution as Attribution;
|
|
87
|
+
return Object.keys(note).length ? note : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The first failed step of a recorded check, preferring the innermost one (a failed action inside a failed group is
|
|
92
|
+
* the action). Steps are ordered by their start offset, else by their position. Undefined when no step failed or none
|
|
93
|
+
* was recorded.
|
|
94
|
+
*/
|
|
95
|
+
export function failedStep(steps: readonly Step[] | undefined): FailurePoint | undefined {
|
|
96
|
+
if (!steps?.length) return undefined;
|
|
97
|
+
const failedParents = new Set(steps.filter((step) => step.status === 'failed' && step.parentId).map((step) => step.parentId));
|
|
98
|
+
const order = steps.map((step, index) => ({ step, index }));
|
|
99
|
+
order.sort((a, b) => (a.step.offsetMs ?? 0) - (b.step.offsetMs ?? 0) || a.index - b.index);
|
|
100
|
+
const found = order.find(({ step }) => step.status === 'failed' && !(step.id !== undefined && failedParents.has(step.id)))?.step;
|
|
101
|
+
if (!found) return undefined;
|
|
102
|
+
return {
|
|
103
|
+
name: found.name,
|
|
104
|
+
kind: found.kind,
|
|
105
|
+
phase: phaseOf(found.kind),
|
|
106
|
+
detail: found.detail,
|
|
107
|
+
offsetMs: found.offsetMs,
|
|
108
|
+
note: failureNote(found.extensions),
|
|
109
|
+
videos: (found.attachments ?? []).filter(isVideoAttachment)
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The first innermost failed step of an Atlas run's own timeline (see {@link failedStep}). */
|
|
114
|
+
export function failedReportStep(report: RunReport | undefined): FailurePoint | undefined {
|
|
115
|
+
if (!report) return undefined;
|
|
116
|
+
const walk = (steps: readonly ReportStep[]): ReportStep | undefined => {
|
|
117
|
+
for (const step of steps) {
|
|
118
|
+
if (step.status !== 'failed') continue;
|
|
119
|
+
return walk(step.steps) ?? step;
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
};
|
|
123
|
+
const found = walk(report.steps);
|
|
124
|
+
if (!found) return undefined;
|
|
125
|
+
return {
|
|
126
|
+
name: found.title,
|
|
127
|
+
kind: found.kind,
|
|
128
|
+
phase: phaseOf(found.kind),
|
|
129
|
+
detail: found.error,
|
|
130
|
+
offsetMs: Math.max(0, found.startedAtMs - report.meta.startedAtMs),
|
|
131
|
+
note: undefined,
|
|
132
|
+
videos: []
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The recorded videos a holder carries on itself, its steps and its attempts. */
|
|
137
|
+
export function videosIn(holder: EvidenceHolder | undefined): Attachment[] {
|
|
138
|
+
if (!holder) return [];
|
|
139
|
+
return [
|
|
140
|
+
...(holder.attachments ?? []).filter(isVideoAttachment),
|
|
141
|
+
...(holder.steps ?? []).flatMap((step) => (step.attachments ?? []).filter(isVideoAttachment)),
|
|
142
|
+
...(holder.attempts ?? []).flatMap((attempt) => [
|
|
143
|
+
...(attempt.attachments ?? []).filter(isVideoAttachment),
|
|
144
|
+
...(attempt.steps ?? []).flatMap((step) => (step.attachments ?? []).filter(isVideoAttachment))
|
|
145
|
+
])
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The one-line statement of what the failure point says about the behavior. */
|
|
150
|
+
export function reachOf(point: FailurePoint | undefined): { readonly state: 'setup-failed' | 'expectation-failed' | 'unknown'; readonly text: string } {
|
|
151
|
+
if (!point) return { state: 'unknown', text: 'Where it failed was not recorded, so whether the behavior was reached is unknown.' };
|
|
152
|
+
if (point.phase === 'setup')
|
|
153
|
+
return { state: 'setup-failed', text: `Behavior not verified: setup failed at “${point.name}”. The execution is still recorded as failed.` };
|
|
154
|
+
if (point.phase === 'behavior')
|
|
155
|
+
return { state: 'expectation-failed', text: `The behavior was reached and an expectation was not met at “${point.name}”.` };
|
|
156
|
+
return { state: 'unknown', text: `Failed at “${point.name}”. Whether the behavior was reached is not recorded.` };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const WORD = { passed: 'passed', failed: 'failed', running: 'is still running', 'not-run': 'was not run' } as const;
|
|
160
|
+
|
|
161
|
+
/** Counts of results by status, in words: `1 passed, 2 failed`. */
|
|
162
|
+
function tally(results: readonly EvidenceResult[]): string {
|
|
163
|
+
const counts = new Map<string, number>();
|
|
164
|
+
for (const { row } of results) counts.set(row.status, (counts.get(row.status) ?? 0) + 1);
|
|
165
|
+
return (['passed', 'failed', 'running', 'not-run'] as const)
|
|
166
|
+
.filter((status) => counts.has(status))
|
|
167
|
+
.map((status) => `${counts.get(status)} ${status === 'not-run' ? 'not run' : status}`)
|
|
168
|
+
.join(', ');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** What the selected result says, and the current results of the other sources it must be read beside. */
|
|
172
|
+
export type Headline = {
|
|
173
|
+
readonly text: string;
|
|
174
|
+
/** The current results from other sources than the selected one, newest first. */
|
|
175
|
+
readonly others: readonly EvidenceResult[];
|
|
176
|
+
/** False when the selected result is an earlier one that a newer run has replaced in the combined report. */
|
|
177
|
+
readonly current: boolean;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The heading for a selected result: its own outcome first, then what the other current sources recorded. A passing
|
|
182
|
+
* selection beside a failing source says both; an earlier result says it is earlier and names the combined status.
|
|
183
|
+
* Never reads the selected outcome as the check's status.
|
|
184
|
+
*/
|
|
185
|
+
export function outcomeHeadline(selected: HistoryEntry, slot: CheckSlot): Headline {
|
|
186
|
+
const current = slot.outcomes.some((outcome) => outcome.layer.id === selected.layer.id);
|
|
187
|
+
const others = slot.outcomes.filter((outcome) => outcome.layer.id !== selected.layer.id);
|
|
188
|
+
const own = WORD[selected.row.status];
|
|
189
|
+
if (!current) {
|
|
190
|
+
return {
|
|
191
|
+
current,
|
|
192
|
+
others,
|
|
193
|
+
text: `This earlier result ${own}; the combined report now shows ${statusWord(slot.status).toLowerCase()} across ${others.length === 1 ? 'its current source' : `its ${others.length} current sources`}`
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const failed = others.filter((outcome) => outcome.row.status === 'failed').length;
|
|
197
|
+
let text = `Selected result ${own}`;
|
|
198
|
+
if (selected.row.status === 'passed' && failed > 0) text += `; ${failed === 1 ? 'another scoped source' : `${failed} other scoped sources`} failed`;
|
|
199
|
+
else if (others.length > 0) text += `; ${others.length === 1 ? 'the other scoped source' : 'other scoped sources'}: ${tally(others)}`;
|
|
200
|
+
return { current, others, text };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The result a detail shows: the run the address pins, else the current result of the source it names, else the newest.
|
|
205
|
+
* Undefined only for a check with no result.
|
|
206
|
+
*/
|
|
207
|
+
export function selectedResult(slot: CheckSlot, pinned?: HistoryEntry, source?: string): HistoryEntry | undefined {
|
|
208
|
+
if (pinned) return pinned;
|
|
209
|
+
const found = (source ? slot.outcomes.find((outcome) => outcome.layer.id === source) : undefined) ?? slot.outcomes[0];
|
|
210
|
+
return found ? { layer: found.layer, row: found.row } : undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** How a choosable source is told apart from its siblings without reading opaque producer ids. */
|
|
214
|
+
export type SourceLabel = {
|
|
215
|
+
/** The environment and parameters, or the producer when neither is named. */
|
|
216
|
+
readonly name: string;
|
|
217
|
+
/** Status in words. */
|
|
218
|
+
readonly status: string;
|
|
219
|
+
/** When it was recorded, relative. */
|
|
220
|
+
readonly when: string;
|
|
221
|
+
/** The commit, or the branch, or `revision not recorded`. */
|
|
222
|
+
readonly revision: string;
|
|
223
|
+
/** Present when other sources share the same name: its place among them, newest first, and its producer. */
|
|
224
|
+
readonly attempt: string | undefined;
|
|
225
|
+
/** All of the above in one line. */
|
|
226
|
+
readonly text: string;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Labels for the current results of a check so each can be told from the others by status, time, revision and,
|
|
231
|
+
* when several share a name, its place among them. `now` is epoch milliseconds.
|
|
232
|
+
*/
|
|
233
|
+
export function sourceLabels(outcomes: readonly EvidenceResult[], now: number): SourceLabel[] {
|
|
234
|
+
const names = outcomes.map((outcome) => {
|
|
235
|
+
const environment = outcome.layer.environment ? environmentLabel(outcome.layer.environment) : '';
|
|
236
|
+
const parameters = Object.entries(outcome.parameters)
|
|
237
|
+
.map(([name, value]) => `${name}: ${value}`)
|
|
238
|
+
.join(', ');
|
|
239
|
+
return [environment, parameters].filter(Boolean).join(' · ') || outcome.layer.producer;
|
|
240
|
+
});
|
|
241
|
+
return outcomes.map((outcome, index) => {
|
|
242
|
+
const name = names[index]!;
|
|
243
|
+
const siblings = names.flatMap((each, at) => (each === name ? [at] : []));
|
|
244
|
+
const attempt =
|
|
245
|
+
siblings.length > 1 ? `result ${siblings.indexOf(index) + 1} of ${siblings.length} from ${outcome.layer.producer}` : undefined;
|
|
246
|
+
const revision = shortSha(outcome.layer.sha) ?? outcome.layer.branch ?? 'revision not recorded';
|
|
247
|
+
const status = statusWord(outcome.row.status);
|
|
248
|
+
const when = ago(outcome.layer.recordedAtMs, now);
|
|
249
|
+
return { name, status, when, revision, attempt, text: [name, status, when, revision, attempt].filter(Boolean).join(' · ') };
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** What the chronology needs to know of one recorded result, whether it came from the held layers or from a test's history. */
|
|
254
|
+
export type ResultRecord = {
|
|
255
|
+
/** The id of the layer that recorded it. */
|
|
256
|
+
readonly layer: string;
|
|
257
|
+
readonly kind: string;
|
|
258
|
+
readonly status: HistoryEntry['row']['status'];
|
|
259
|
+
readonly at: number;
|
|
260
|
+
readonly sha: string | null;
|
|
261
|
+
readonly producer: string;
|
|
262
|
+
readonly environment: string | null;
|
|
263
|
+
readonly parameters: Readonly<Record<string, string>>;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/** The record of a layer's result. */
|
|
267
|
+
export function recordOf(entry: HistoryEntry): ResultRecord {
|
|
268
|
+
const { layer, row } = entry;
|
|
269
|
+
return {
|
|
270
|
+
layer: layer.id,
|
|
271
|
+
kind: row.kind,
|
|
272
|
+
status: row.status,
|
|
273
|
+
at: layer.recordedAtMs,
|
|
274
|
+
sha: layer.sha,
|
|
275
|
+
producer: layer.producer,
|
|
276
|
+
environment: layer.environment,
|
|
277
|
+
parameters: layer.parameters
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** One record of the selected result's own source, for the chronology. */
|
|
282
|
+
export type ChronologyEntry = { readonly record: ResultRecord; readonly selected: boolean };
|
|
283
|
+
|
|
284
|
+
function samePlace(a: ResultRecord, b: ResultRecord): boolean {
|
|
285
|
+
return (
|
|
286
|
+
a.producer === b.producer &&
|
|
287
|
+
a.environment === b.environment &&
|
|
288
|
+
JSON.stringify(Object.entries(a.parameters).sort()) === JSON.stringify(Object.entries(b.parameters).sort())
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The records of the selected result's own source (same producer, environment and parameters), oldest first, one per
|
|
294
|
+
* layer. Empty when there is only one, or when they all have the same outcome, so there is no chronology to tell.
|
|
295
|
+
*/
|
|
296
|
+
export function chronology(selected: ResultRecord, history: readonly ResultRecord[]): ChronologyEntry[] {
|
|
297
|
+
const seen = new Set<string>();
|
|
298
|
+
const own = history
|
|
299
|
+
.filter((record) => samePlace(record, selected) && !seen.has(`${record.layer}|${record.kind}`) && seen.add(`${record.layer}|${record.kind}`))
|
|
300
|
+
.sort((a, b) => a.at - b.at);
|
|
301
|
+
if (own.length < 2 || new Set(own.map((record) => record.status)).size < 2) return [];
|
|
302
|
+
return own.map((record) => ({ record, selected: record.layer === selected.layer && record.kind === selected.kind }));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Whether the newest record of a chronology passed after an earlier one failed. */
|
|
306
|
+
export function passedAfterFailure(entries: readonly ChronologyEntry[]): boolean {
|
|
307
|
+
const last = entries.at(-1);
|
|
308
|
+
return last !== undefined && last.record.status === 'passed' && entries.slice(0, -1).some((each) => each.record.status === 'failed');
|
|
309
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Product latency view
|
|
3
|
+
*
|
|
4
|
+
* How quickly the product responded to a user, apart from how long the test took. It reads only the measurements a
|
|
5
|
+
* producer named as product latency (see `@crvouga/atlas/reports/latency`) and shows, for each, its boundaries, unit,
|
|
6
|
+
* source, group (device, build or day), sample count, budget and breaches, p50 and p95 when enough observations
|
|
7
|
+
* support them, and a comparison with a chosen baseline that names every difference in instrumentation or
|
|
8
|
+
* conditions. What the evidence cannot support is withheld with the reason. A test with no named measurement says its
|
|
9
|
+
* product latency is unknown; its execution time is never shown here.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
compareLatency, formatLatency, latencyReview, LATENCY_GROUPINGS, type LatencyChange, type LatencyGrouping, type LatencyReview, type LatencySummary
|
|
13
|
+
} from '@crvouga/atlas/reports/latency';
|
|
14
|
+
import type { TestResultEntry } from '@crvouga/atlas/reports/test-catalog';
|
|
15
|
+
import { Badge, Box, Code, Flex, List, NativeSelect, Stack, Table, Text, Title } from '@mantine/core';
|
|
16
|
+
import { useMemo, useState } from 'react';
|
|
17
|
+
|
|
18
|
+
import { plural } from '../evidence/format';
|
|
19
|
+
|
|
20
|
+
const GROUPING_LABEL: Record<LatencyGrouping, string> = { none: 'Not grouped', device: 'Device', build: 'Build', window: 'Day recorded' };
|
|
21
|
+
|
|
22
|
+
const sign = (value: number) => (value > 0 ? '+' : value < 0 ? '−' : '');
|
|
23
|
+
|
|
24
|
+
/** A change as words: `+100 ms (+25%) in p50`. */
|
|
25
|
+
function changeText(change: LatencyChange, unit: string): string {
|
|
26
|
+
const delta = change.p50 ?? change.p95 ?? change.last;
|
|
27
|
+
if (!change.comparable || !delta) return 'Not compared';
|
|
28
|
+
const relative = delta.relative === null ? 'baseline was 0' : `${sign(delta.relative)}${Math.abs(Math.round(delta.relative * 1000) / 10)}%`;
|
|
29
|
+
const basis = change.basis === 'last' ? 'in the newest observation, not a percentile' : `in ${change.basis}`;
|
|
30
|
+
return `${sign(delta.absolute)}${formatLatency(Math.abs(delta.absolute), unit)} (${relative}) ${basis}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function Figure({ value, unit, summary }: { value: number | undefined; unit: string; summary: LatencySummary }) {
|
|
34
|
+
return value === undefined ? (
|
|
35
|
+
<Text span inherit c="dimmed">
|
|
36
|
+
Withheld{summary.claim ? '' : ' (no boundaries)'}
|
|
37
|
+
</Text>
|
|
38
|
+
) : (
|
|
39
|
+
<>
|
|
40
|
+
{summary.lowerBound ? '≥ ' : ''}
|
|
41
|
+
{formatLatency(value, unit)}
|
|
42
|
+
</>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function MetricTable({ summaries, baseline }: { summaries: readonly LatencySummary[]; baseline: LatencySummary | undefined }) {
|
|
47
|
+
const unit = summaries[0]!.metric.unit;
|
|
48
|
+
const changes = new Map(summaries.map((summary) => [summary.group, baseline && summary !== baseline ? compareLatency(baseline, summary) : undefined]));
|
|
49
|
+
return (
|
|
50
|
+
<Table.ScrollContainer minWidth={560}>
|
|
51
|
+
<Table>
|
|
52
|
+
<Table.Thead>
|
|
53
|
+
<Table.Tr>
|
|
54
|
+
<Table.Th scope="col">Group</Table.Th>
|
|
55
|
+
<Table.Th scope="col">Observations</Table.Th>
|
|
56
|
+
<Table.Th scope="col">p50</Table.Th>
|
|
57
|
+
<Table.Th scope="col">p95</Table.Th>
|
|
58
|
+
<Table.Th scope="col">Budget</Table.Th>
|
|
59
|
+
<Table.Th scope="col">Over budget</Table.Th>
|
|
60
|
+
{baseline && <Table.Th scope="col">Against baseline</Table.Th>}
|
|
61
|
+
</Table.Tr>
|
|
62
|
+
</Table.Thead>
|
|
63
|
+
<Table.Tbody>
|
|
64
|
+
{summaries.map((summary) => {
|
|
65
|
+
const change = changes.get(summary.group);
|
|
66
|
+
return (
|
|
67
|
+
<Table.Tr key={summary.group}>
|
|
68
|
+
<Table.Th scope="row">{summary.group}</Table.Th>
|
|
69
|
+
<Table.Td>
|
|
70
|
+
{summary.samples}
|
|
71
|
+
{summary.timeouts + summary.failures > 0 && (
|
|
72
|
+
<Text span inherit c="dimmed">
|
|
73
|
+
{' '}
|
|
74
|
+
({summary.timeouts + summary.failures} did not complete)
|
|
75
|
+
</Text>
|
|
76
|
+
)}
|
|
77
|
+
</Table.Td>
|
|
78
|
+
<Table.Td>
|
|
79
|
+
<Figure value={summary.p50} unit={unit} summary={summary} />
|
|
80
|
+
</Table.Td>
|
|
81
|
+
<Table.Td>
|
|
82
|
+
<Figure value={summary.p95} unit={unit} summary={summary} />
|
|
83
|
+
</Table.Td>
|
|
84
|
+
<Table.Td>{summary.budget === undefined ? <Text span inherit c="dimmed">None set</Text> : formatLatency(summary.budget, unit)}</Table.Td>
|
|
85
|
+
<Table.Td>
|
|
86
|
+
{summary.budget === undefined ? (
|
|
87
|
+
'—'
|
|
88
|
+
) : summary.breaches > 0 ? (
|
|
89
|
+
<Badge color="red" variant="light">
|
|
90
|
+
{summary.breaches} of {summary.samples} over
|
|
91
|
+
</Badge>
|
|
92
|
+
) : (
|
|
93
|
+
`0 of ${summary.samples}`
|
|
94
|
+
)}
|
|
95
|
+
</Table.Td>
|
|
96
|
+
{baseline && <Table.Td>{summary === baseline ? 'Baseline' : change ? changeText(change, unit) : '—'}</Table.Td>}
|
|
97
|
+
</Table.Tr>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
</Table.Tbody>
|
|
101
|
+
</Table>
|
|
102
|
+
</Table.ScrollContainer>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** What a reader must not assume, per group and per comparison, as one list. */
|
|
107
|
+
function Notes({ summaries, baseline }: { summaries: readonly LatencySummary[]; baseline: LatencySummary | undefined }) {
|
|
108
|
+
const lines: string[] = [];
|
|
109
|
+
for (const summary of summaries) {
|
|
110
|
+
const name = summaries.length > 1 ? `${summary.group}: ` : '';
|
|
111
|
+
for (const line of [...summary.gaps, ...summary.withheld]) lines.push(`${name}${line}`);
|
|
112
|
+
if (baseline && summary !== baseline) {
|
|
113
|
+
const change = compareLatency(baseline, summary);
|
|
114
|
+
for (const reason of change.reasons) lines.push(`${summary.group} against ${baseline.group}: ${reason}`);
|
|
115
|
+
for (const disclosure of change.disclosures) lines.push(`${summary.group} against ${baseline.group}: ${disclosure}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (!lines.length) return null;
|
|
119
|
+
return (
|
|
120
|
+
<List aria-label="What these figures do not show" size="sm" c="dimmed">
|
|
121
|
+
{lines.map((line, index) => (
|
|
122
|
+
<List.Item key={index}>{line}</List.Item>
|
|
123
|
+
))}
|
|
124
|
+
</List>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function Metric({ metric, groups, baselineGroup }: { metric: LatencyReview['metrics'][number]['metric']; groups: readonly LatencySummary[]; baselineGroup: string | undefined }) {
|
|
129
|
+
const baseline = groups.length > 1 ? (groups.find((summary) => summary.group === baselineGroup) ?? groups[1]) : undefined;
|
|
130
|
+
const first = groups[0]!;
|
|
131
|
+
const source = [...new Set(groups.map((summary) => summary.metric.boundary.label))];
|
|
132
|
+
return (
|
|
133
|
+
<Stack gap="xs" aria-label={metric.name} role="group">
|
|
134
|
+
<Flex gap="xs" align="center" wrap="wrap">
|
|
135
|
+
<Text span inherit fw={600}>
|
|
136
|
+
{metric.name}
|
|
137
|
+
</Text>
|
|
138
|
+
<Text span inherit c="dimmed">
|
|
139
|
+
{source.join(', ')} · {metric.unit}
|
|
140
|
+
</Text>
|
|
141
|
+
{metric.boundary.state === 'partial' && <Badge color="orange" variant="light">Partial interval</Badge>}
|
|
142
|
+
{metric.boundary.state === 'undeclared' && <Badge color="gray" variant="light">Boundaries not declared</Badge>}
|
|
143
|
+
{first.lowerBound && <Badge color="orange" variant="light">Includes timeouts or failures</Badge>}
|
|
144
|
+
</Flex>
|
|
145
|
+
<MetricTable summaries={groups} baseline={baseline} />
|
|
146
|
+
<Notes summaries={groups} baseline={baseline} />
|
|
147
|
+
</Stack>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The product latency of one test, from its recorded results (newest first, as the history lists them). Grouping and
|
|
153
|
+
* the baseline are the viewer's own choices and are not saved. With nothing recorded it says product latency is
|
|
154
|
+
* unknown and how a producer records it.
|
|
155
|
+
*/
|
|
156
|
+
export function ProductLatency({ entries }: { entries: readonly TestResultEntry[] }) {
|
|
157
|
+
const [by, setBy] = useState<LatencyGrouping>('none');
|
|
158
|
+
const [baseline, setBaseline] = useState<string | undefined>(undefined);
|
|
159
|
+
const review = useMemo(() => latencyReview(entries, by), [entries, by]);
|
|
160
|
+
const groups = [...new Set(review.metrics.flatMap((item) => item.groups.map((summary) => summary.group)))];
|
|
161
|
+
return (
|
|
162
|
+
<Box aria-label="Product latency" component="section" mb="lg">
|
|
163
|
+
<Title order={3} mb="sm" c="dimmed">
|
|
164
|
+
Product latency
|
|
165
|
+
</Title>
|
|
166
|
+
<Text component="div" c="dimmed" mb="sm">
|
|
167
|
+
How quickly the product responded to a user, between two events the producer named. It is not the test's duration, which includes setup,
|
|
168
|
+
waits, retries and any demo pacing.
|
|
169
|
+
</Text>
|
|
170
|
+
{review.metrics.length === 0 ? (
|
|
171
|
+
<Text component="div" role="status">
|
|
172
|
+
No product latency was recorded for this test, so it is unknown, not fast. A producer records one as a measurement with{' '}
|
|
173
|
+
<Code>kind: "product-latency"</Code>, a <Code>from</Code> and a <Code>to</Code>, and optionally a <Code>limit</Code> as the budget.
|
|
174
|
+
</Text>
|
|
175
|
+
) : (
|
|
176
|
+
<Stack gap="md">
|
|
177
|
+
<Flex gap="md" wrap="wrap" align="flex-end">
|
|
178
|
+
<NativeSelect
|
|
179
|
+
label="Group latency by"
|
|
180
|
+
value={by}
|
|
181
|
+
onChange={(event) => {
|
|
182
|
+
setBy(event.target.value as LatencyGrouping);
|
|
183
|
+
setBaseline(undefined);
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
{LATENCY_GROUPINGS.map((option) => (
|
|
187
|
+
<option key={option} value={option}>
|
|
188
|
+
{GROUPING_LABEL[option]}
|
|
189
|
+
</option>
|
|
190
|
+
))}
|
|
191
|
+
</NativeSelect>
|
|
192
|
+
{groups.length > 1 && (
|
|
193
|
+
<NativeSelect label="Baseline to compare against" value={baseline ?? ''} onChange={(event) => setBaseline(event.target.value || undefined)}>
|
|
194
|
+
<option value="">Second newest group</option>
|
|
195
|
+
{groups.map((group) => (
|
|
196
|
+
<option key={group} value={group}>
|
|
197
|
+
{group}
|
|
198
|
+
</option>
|
|
199
|
+
))}
|
|
200
|
+
</NativeSelect>
|
|
201
|
+
)}
|
|
202
|
+
</Flex>
|
|
203
|
+
{review.metrics.map(({ metric, groups: summaries }) => (
|
|
204
|
+
<Metric key={metric.key} metric={metric} groups={summaries} baselineGroup={baseline} />
|
|
205
|
+
))}
|
|
206
|
+
<Text component="div" c="dimmed">
|
|
207
|
+
{plural(review.measured, 'recorded result carries', 'recorded results carry')} product latency
|
|
208
|
+
{review.unmeasured > 0 ? `; ${plural(review.unmeasured, 'result records', 'results record')} none, which is unknown, not fast` : ''}.
|
|
209
|
+
</Text>
|
|
210
|
+
</Stack>
|
|
211
|
+
)}
|
|
212
|
+
</Box>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
@@ -17,9 +17,11 @@ import { CheckDetail } from '../evidence/check-detail';
|
|
|
17
17
|
import { ago, exactTime, plural, shortSha, statusWord, useNow } from '../evidence/format';
|
|
18
18
|
import { useEvidence } from '../evidence/evidence-context';
|
|
19
19
|
import { Origin, StatusMark } from '../evidence/marks';
|
|
20
|
+
import { outcomeHeadline, selectedResult } from '../evidence/outcome';
|
|
20
21
|
import { useTestHistory } from './catalog';
|
|
21
22
|
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
22
23
|
import { Badges, runName } from './parts';
|
|
24
|
+
import { ProductLatency } from './ProductLatency';
|
|
23
25
|
import { usePinnedResult, type PinnedResult } from './pinned';
|
|
24
26
|
const RecordingPage = lazy(() => import('../evidence/recording-page').then((module) => ({ default: module.RecordingPage })));
|
|
25
27
|
/** Results the history table shows before "Show more". */
|
|
@@ -126,7 +128,7 @@ function Scope({ pinned, slot }: { pinned: PinnedResult; slot: CheckSlot | undef
|
|
|
126
128
|
export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot | undefined }) {
|
|
127
129
|
const now = useNow();
|
|
128
130
|
const history = useTestHistory(test.key);
|
|
129
|
-
const { layer } = useExplorerSearch();
|
|
131
|
+
const { layer, source } = useExplorerSearch();
|
|
130
132
|
const go = useExplorerNavigate();
|
|
131
133
|
const pinned = usePinnedResult(layer, test.key);
|
|
132
134
|
const [recording, setRecording] = useState(false);
|
|
@@ -134,6 +136,7 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
|
|
|
134
136
|
const [moment, setMoment] = useState<number | null>(null);
|
|
135
137
|
const [rows, setRows] = useState(HISTORY_ROWS);
|
|
136
138
|
const entries = history.data?.history ?? [];
|
|
139
|
+
const selected = slot && (!layer || pinned.state === 'ready') ? selectedResult(slot, pinned.state === 'ready' ? pinned.entry : undefined, source) : undefined;
|
|
137
140
|
const stats = test.stats;
|
|
138
141
|
const d = stats.durations;
|
|
139
142
|
if (recording && slot) {
|
|
@@ -211,6 +214,9 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
|
|
|
211
214
|
<Text data-status={test.status} span inherit fw={600}>
|
|
212
215
|
{statusWord(test.status)}
|
|
213
216
|
</Text>
|
|
217
|
+
<Text span inherit c="dimmed">
|
|
218
|
+
worst current result of {plural(test.outcomes.length, 'source', 'sources')}
|
|
219
|
+
</Text>
|
|
214
220
|
{stats.flaky && (
|
|
215
221
|
<Text title={stats.flakyReason} span inherit>
|
|
216
222
|
Flaky: {stats.flakyReason}
|
|
@@ -218,6 +224,11 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
|
|
|
218
224
|
)}
|
|
219
225
|
<Badges test={test} />
|
|
220
226
|
</Flex>
|
|
227
|
+
{selected && slot && (
|
|
228
|
+
<Text component="p" role="status" fw={600} data-outcome-headline>
|
|
229
|
+
{outcomeHeadline(selected, slot).text}
|
|
230
|
+
</Text>
|
|
231
|
+
)}
|
|
221
232
|
{test.spec && <Code c="dimmed">{test.spec!.path}</Code>}
|
|
222
233
|
<Code title="Check id" c="dimmed">
|
|
223
234
|
{test.id}
|
|
@@ -300,6 +311,10 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
|
|
|
300
311
|
<Title order={3} mb="sm" c="dimmed">
|
|
301
312
|
How it has gone
|
|
302
313
|
</Title>
|
|
314
|
+
<Text component="div" c="dimmed" mb="sm">
|
|
315
|
+
Durations here are test execution time: setup, waits, retries and any demo pacing included. They do not say how quickly the product
|
|
316
|
+
responded; see Product latency.
|
|
317
|
+
</Text>
|
|
303
318
|
<SimpleGrid component="dl" cols={{ base: 2, sm: 3 }}>
|
|
304
319
|
<Stack gap="sm">
|
|
305
320
|
<Box component="dt">Pass rate</Box>
|
|
@@ -384,12 +399,14 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
|
|
|
384
399
|
)}
|
|
385
400
|
{!d && (
|
|
386
401
|
<Text component="div" c="dimmed">
|
|
387
|
-
No duration recorded. Producers can send <Code>durationMs</Code> with each check.
|
|
402
|
+
No test duration recorded. Producers can send <Code>durationMs</Code> with each check.
|
|
388
403
|
</Text>
|
|
389
404
|
)}
|
|
390
405
|
{entries.length > 1 && <Trend entries={entries} />}
|
|
391
406
|
</Box>
|
|
392
407
|
|
|
408
|
+
<ProductLatency entries={entries} />
|
|
409
|
+
|
|
393
410
|
{(test.tags.length > 0 || Object.keys(test.labels).length > 0) && (
|
|
394
411
|
<Box aria-label="Tags and labels" component="section" mb="lg">
|
|
395
412
|
<Title order={3} mb="sm" c="dimmed">
|
|
@@ -425,6 +442,18 @@ export function TestDetail({ test, slot }: { test: TestSummary; slot: CheckSlot
|
|
|
425
442
|
layerId={layer}
|
|
426
443
|
{...(pinned.state === 'ready' ? { pinned: pinned.entry, pin: true } : {})}
|
|
427
444
|
reads="steps"
|
|
445
|
+
headline={false}
|
|
446
|
+
records={entries.map((entry) => ({
|
|
447
|
+
layer: entry.layer,
|
|
448
|
+
kind: entry.kind,
|
|
449
|
+
status: entry.status,
|
|
450
|
+
at: entry.at,
|
|
451
|
+
sha: entry.sha,
|
|
452
|
+
producer: entry.producer,
|
|
453
|
+
environment: entry.environment,
|
|
454
|
+
parameters: entry.parameters
|
|
455
|
+
}))}
|
|
456
|
+
{...(layer ? {} : { source, onSource: (next: string | null) => go({ source: next ?? undefined }) })}
|
|
428
457
|
select={(patch) => {
|
|
429
458
|
if (patch.view === 'recording') {
|
|
430
459
|
setRecordingLayer(patch.layer ?? undefined);
|
|
@@ -555,7 +584,7 @@ function Trend({ entries }: { entries: readonly TestResultEntry[] }) {
|
|
|
555
584
|
{timed.length > 1 && (
|
|
556
585
|
<Box component="figure" m={0}>
|
|
557
586
|
<Box component="figcaption">
|
|
558
|
-
|
|
587
|
+
Test duration over the last {plural(timed.length, 'result', 'results')} · max {formatMs(max)}
|
|
559
588
|
</Box>
|
|
560
589
|
<svg
|
|
561
590
|
viewBox={`0 0 ${width} ${height}`}
|
|
@@ -67,6 +67,8 @@ export const ExplorerSearchFields = {
|
|
|
67
67
|
openRun: text,
|
|
68
68
|
/** Pins the open test's detail to one exact layer (a run's record of it), instead of the latest combined evidence. With `openRun` and `artifact` it names the layer the file is in. */
|
|
69
69
|
layer: text,
|
|
70
|
+
/** Which current source (a layer id) of the open test the detail's outcome summary, media and failure describe, without pinning a run. Unknown or absent: the newest. */
|
|
71
|
+
source: text,
|
|
70
72
|
/** With `openRun` and `layer`: the one file of that layer (an `artifactRef`: `id:…`, `key:…` or `name:…`) the run's Artifacts select. */
|
|
71
73
|
artifact: text,
|
|
72
74
|
/** Only the runs, and the results of an opened run, that have a playable video recording. */
|
|
@@ -98,6 +100,7 @@ export type ExplorerSearch = {
|
|
|
98
100
|
readonly tab?: 'runs' | 'artifacts';
|
|
99
101
|
readonly openRun?: string;
|
|
100
102
|
readonly layer?: string;
|
|
103
|
+
readonly source?: string;
|
|
101
104
|
readonly artifact?: string;
|
|
102
105
|
readonly video?: true;
|
|
103
106
|
};
|