@crvouga/atlas 0.14.0 → 0.16.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/reports/src/artifact-tag-store.d.ts +20 -0
- package/dist/reports/src/artifact-tag-store.js +70 -0
- package/dist/reports/src/artifact-tags.d.ts +96 -0
- package/dist/reports/src/artifact-tags.js +96 -0
- package/dist/reports/src/evidence-detail.d.ts +20 -3
- package/dist/reports/src/evidence-detail.js +66 -13
- package/dist/reports/src/evidence-types.d.ts +33 -0
- package/dist/reports/src/layer-merge.js +2 -2
- package/dist/reports/src/reports-explorer-route.d.ts +8 -0
- package/dist/reports/src/reports-route.d.ts +8 -0
- package/dist/reports/src/test-catalog.d.ts +35 -26
- package/dist/reports/src/test-catalog.js +79 -15
- package/dist/schema/src/api.d.ts +246 -0
- package/dist/schema/src/api.js +59 -5
- package/dist/schema/src/layer.d.ts +33 -0
- package/dist/schema/src/layer.js +11 -1
- package/dist/server/src/app.js +35 -3
- package/dist/server/src/llms.js +2 -1
- package/dist/server/src/openapi.d.ts +1 -1
- package/dist/server/src/openapi.js +20 -3
- package/docs/extending.md +3 -0
- package/docs/reference/http-api.md +52 -2
- package/docs/reference/reports.md +327 -5
- package/docs/reference/schema.md +48 -0
- package/docs/rich-evidence.md +28 -0
- package/docs/server.md +25 -1
- package/llms-full.txt +108 -3
- package/package.json +1 -1
- package/packages/schema/schemas/check-detail.schema.json +28 -0
- package/packages/schema/schemas/effective-delta.schema.json +96 -0
- package/packages/schema/schemas/effective-evidence.schema.json +96 -0
- package/packages/schema/schemas/evidence-page.schema.json +48 -0
- package/packages/schema/schemas/layer-rows.schema.json +48 -0
- package/packages/schema/schemas/layer.schema.json +70 -0
- package/packages/schema/schemas/publish-checks.schema.json +35 -0
- package/packages/schema/schemas/report-run.schema.json +71 -0
- package/packages/schema/schemas/report-runs.schema.json +38 -1
- package/packages/schema/schemas/test-catalog.schema.json +83 -0
- package/packages/schema/schemas/test-history.schema.json +110 -0
- package/packages/server/openapi.json +1259 -33
- package/packages/visualizer/src/evidence/artifact-tags.tsx +285 -0
- package/packages/visualizer/src/evidence/artifacts.tsx +18 -12
- package/packages/visualizer/src/explorer/RunsView.tsx +128 -10
- package/packages/visualizer/src/explorer/catalog.tsx +16 -6
- package/packages/visualizer/src/explorer/search.ts +4 -1
|
@@ -1,27 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module Test catalog, history and runs
|
|
3
|
-
*
|
|
4
|
-
* What a person or an agent asks of a report once it holds more than a handful of checks: every test with where it ran,
|
|
5
|
-
* how it went over time and how long it takes; the specs, domains, environments and producers they fall under; the CI
|
|
6
|
-
* runs that produced them and which tests each one ran.
|
|
7
|
-
*
|
|
8
|
-
* - `TestHistory` keeps every recorded result of every test (bounded per test) and every run's results, folded in one
|
|
9
|
-
* layer at a time, in any order, with the same outcome. It never decides a status: the current status of a test is
|
|
10
|
-
* always the report's winner (`combineEvidence`), and history only describes how it got there.
|
|
11
|
-
* - `testCatalog` turns the report's winners (plus a history, when there is one) into a `TestCatalog`: a summary with
|
|
12
|
-
* insights (slowest, flaky, failing), facets to filter by, and the tests that match a `TestQuery`.
|
|
13
|
-
* - `testHistory`, `runList` and `runDetail` answer for one test, every run, and one run.
|
|
14
|
-
* - `catalogMarkdown`, `historyMarkdown`, `runsMarkdown` and `runMarkdown` say the same in Markdown, for an agent or a
|
|
15
|
-
* terminal.
|
|
16
|
-
*
|
|
17
|
-
* A test is a check identity (its spec path and id) regardless of producer, environment and parameters; each of those
|
|
18
|
-
* combinations is one of its results. A result that is not `passed` is never counted as a pass, and a test with no
|
|
19
|
-
* completed result has no pass rate.
|
|
20
|
-
*
|
|
21
|
-
* @see docs/server.md#reading-the-report-as-an-agent
|
|
22
|
-
*/
|
|
23
1
|
import type { EvidenceStatus, SpecEntry } from './explorer-types.js';
|
|
24
|
-
import type { EvidenceSummary, LayerEvidence, LayerHeader, LayerRow, StatusCounts } from './evidence-types.js';
|
|
2
|
+
import type { EvidenceSummary, LayerEvidence, LayerHeader, LayerRow, StatusCounts, VideoCounts, VideoRef } from './evidence-types.js';
|
|
25
3
|
import { RecordedSpecs } from './recorded-specs.js';
|
|
26
4
|
/** The version of the documents this module writes (`TestCatalog`, `TestHistoryDocument`, `RunList`, `RunDetail`). Only optional fields are added within it. */
|
|
27
5
|
export declare const TEST_CATALOG_VERSION = 1;
|
|
@@ -116,6 +94,10 @@ export type TestSummary = {
|
|
|
116
94
|
readonly outcomes: readonly TestOutcome[];
|
|
117
95
|
readonly stats: TestStats;
|
|
118
96
|
readonly tags: readonly string[];
|
|
97
|
+
/** The normalized tags of the attachments of its current results (`demo`, shown as `#demo`). Never inherited from a check or run; the `tags` above are. Absent in documents of older servers. */
|
|
98
|
+
readonly artifactTags?: readonly string[];
|
|
99
|
+
/** The normalized tags its attachments inherit from their check and run (those of the results that have attachments), apart from `artifactTags`. What an inherited artifact query also matches. */
|
|
100
|
+
readonly inheritedArtifactTags?: readonly string[];
|
|
119
101
|
readonly labels: Readonly<Record<string, string>>;
|
|
120
102
|
/** What its current results carry: attachments, steps, retries, and the kinds of attachment. */
|
|
121
103
|
readonly evidence: {
|
|
@@ -187,6 +169,8 @@ export type CatalogFacets = {
|
|
|
187
169
|
readonly counts: StatusCounts;
|
|
188
170
|
})[];
|
|
189
171
|
readonly tags: readonly FacetValue[];
|
|
172
|
+
/** The tags of attachments, normalized. Absent in documents of older servers. */
|
|
173
|
+
readonly artifactTags?: readonly FacetValue[];
|
|
190
174
|
readonly branches: readonly FacetValue[];
|
|
191
175
|
readonly labels: Readonly<Record<string, readonly FacetValue[]>>;
|
|
192
176
|
};
|
|
@@ -206,6 +190,10 @@ export type TestQuery = {
|
|
|
206
190
|
/** Spec paths (`specs/...`). */
|
|
207
191
|
readonly spec?: readonly string[];
|
|
208
192
|
readonly tag?: readonly string[];
|
|
193
|
+
/** Only tests with an attachment carrying this tag (`demo` and `#demo` are the same), not counting tags inherited from a check or run: the exact artifact query. `tag` is the inherited one. */
|
|
194
|
+
readonly artifactTag?: readonly string[];
|
|
195
|
+
/** With `artifactTag`: also match the tags an attachment inherits from its check and run (a recording without a tag of its own under a run tagged `demo`). Default false: the exact query. */
|
|
196
|
+
readonly inherited?: boolean;
|
|
209
197
|
/** Only tests recorded in this run (a key from `runList`). */
|
|
210
198
|
readonly run?: string;
|
|
211
199
|
readonly sort?: TestSort;
|
|
@@ -270,13 +258,26 @@ export type RunSummary = {
|
|
|
270
258
|
readonly environment: string | null;
|
|
271
259
|
readonly detail: string;
|
|
272
260
|
}[];
|
|
261
|
+
/**
|
|
262
|
+
* Its videos, from the run's layers and from the last result of each test: references, distinct playable recordings
|
|
263
|
+
* and references that cannot be played. Absent when it has none, and in a report that carries no video information.
|
|
264
|
+
*/
|
|
265
|
+
readonly videos?: VideoCounts;
|
|
266
|
+
};
|
|
267
|
+
/** What `runList` can be asked. */
|
|
268
|
+
export type RunQuery = {
|
|
269
|
+
/** Only runs that have at least one playable video recording. `total` then counts only those. */
|
|
270
|
+
readonly video?: boolean;
|
|
273
271
|
};
|
|
274
272
|
/** `GET /api/reports/runs`: runs, newest first. */
|
|
275
273
|
export type RunList = {
|
|
276
274
|
readonly schemaVersion: typeof TEST_CATALOG_VERSION;
|
|
277
275
|
readonly version: string;
|
|
276
|
+
/** Runs that matched, before `limit`. */
|
|
278
277
|
readonly total: number;
|
|
279
278
|
readonly runs: readonly RunSummary[];
|
|
279
|
+
/** How many of all runs (whatever was asked, before `limit`) have a playable video recording. */
|
|
280
|
+
readonly withVideo?: number;
|
|
280
281
|
};
|
|
281
282
|
/** One test's last result within one run. */
|
|
282
283
|
export type RunResult = {
|
|
@@ -291,6 +292,8 @@ export type RunResult = {
|
|
|
291
292
|
readonly at: number;
|
|
292
293
|
readonly layer: string;
|
|
293
294
|
readonly detail: string;
|
|
295
|
+
/** Every video its evidence references, with who holds it (check, retry attempt or step). Absent when there is none. */
|
|
296
|
+
readonly videos?: readonly VideoRef[];
|
|
294
297
|
};
|
|
295
298
|
/** `GET /api/reports/runs/detail`: one run and every result it recorded. */
|
|
296
299
|
export type RunDetail = {
|
|
@@ -392,11 +395,17 @@ export declare function matchesQuery(model: CatalogModel, test: TestSummary, que
|
|
|
392
395
|
export declare function testCatalog(model: CatalogModel, query?: TestQuery): TestCatalog;
|
|
393
396
|
/** One test and its recorded results, or null when the report has no test with that key. */
|
|
394
397
|
export declare function testHistory(model: CatalogModel, key: string): TestHistoryDocument | null;
|
|
395
|
-
/**
|
|
396
|
-
export declare function
|
|
398
|
+
/** Whether a run has at least one playable video recording. */
|
|
399
|
+
export declare function hasVideo(run: Pick<RunSummary, 'videos'>): boolean;
|
|
400
|
+
/**
|
|
401
|
+
* Every run the history knows of, newest first, `limit` at most (default 100). With `query.video` only the runs that
|
|
402
|
+
* have a playable video recording: the filter and `total` apply to every run before `limit`, and nothing is read from
|
|
403
|
+
* any recording's bytes. `withVideo` always counts every run that has one.
|
|
404
|
+
*/
|
|
405
|
+
export declare function runList(history: TestHistory, version: string, limit?: number, query?: RunQuery): RunList;
|
|
397
406
|
/** One run with every result it recorded, or null when no run has that key. */
|
|
398
407
|
export declare function runDetail(history: TestHistory, version: string, key: string): RunDetail | null;
|
|
399
|
-
/** Reads a `TestQuery` from URL search parameters: `q`, `sort`, `offset`, `limit`, `run`, and repeatable or comma-separated `status`, `environment`, `producer`, `domain`, `spec`, `tag`. */
|
|
408
|
+
/** Reads a `TestQuery` from URL search parameters: `q`, `sort`, `offset`, `limit`, `run`, and repeatable or comma-separated `status`, `environment`, `producer`, `domain`, `spec`, `tag`, `artifactTag`, and `inherited=1`. */
|
|
400
409
|
export declare function parseTestQuery(params: URLSearchParams): TestQuery;
|
|
401
410
|
/** A duration for a person: `850 ms`, `12.3 s`, `4m 05s`. */
|
|
402
411
|
export declare function formatMs(ms: number | null | undefined): string;
|
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Test catalog, history and runs
|
|
3
|
+
*
|
|
4
|
+
* What a person or an agent asks of a report once it holds more than a handful of checks: every test with where it ran,
|
|
5
|
+
* how it went over time and how long it takes; the specs, domains, environments and producers they fall under; the CI
|
|
6
|
+
* runs that produced them and which tests each one ran.
|
|
7
|
+
*
|
|
8
|
+
* - `TestHistory` keeps every recorded result of every test (bounded per test) and every run's results, folded in one
|
|
9
|
+
* layer at a time, in any order, with the same outcome. It never decides a status: the current status of a test is
|
|
10
|
+
* always the report's winner (`combineEvidence`), and history only describes how it got there.
|
|
11
|
+
* - `testCatalog` turns the report's winners (plus a history, when there is one) into a `TestCatalog`: a summary with
|
|
12
|
+
* insights (slowest, flaky, failing), facets to filter by, and the tests that match a `TestQuery`.
|
|
13
|
+
* - `testHistory`, `runList` and `runDetail` answer for one test, every run, and one run.
|
|
14
|
+
* - `catalogMarkdown`, `historyMarkdown`, `runsMarkdown` and `runMarkdown` say the same in Markdown, for an agent or a
|
|
15
|
+
* terminal.
|
|
16
|
+
*
|
|
17
|
+
* A test is a check identity (its spec path and id) regardless of producer, environment and parameters; each of those
|
|
18
|
+
* combinations is one of its results. A result that is not `passed` is never counted as a pass, and a test with no
|
|
19
|
+
* completed result has no pass rate.
|
|
20
|
+
*
|
|
21
|
+
* @see docs/server.md#reading-the-report-as-an-agent
|
|
22
|
+
*/
|
|
23
|
+
import { displayTag, normalizeTag, normalizeTags } from './artifact-tags.js';
|
|
24
|
+
import { videoCounts } from './evidence-detail.js';
|
|
1
25
|
import { checkSlots } from './evidence-view.js';
|
|
2
26
|
import { combineEvidence, emptyCounts, slotKey, specPath as normalSpecPath } from './layer-merge.js';
|
|
3
27
|
import { RecordedSpecs } from './recorded-specs.js';
|
|
@@ -103,7 +127,7 @@ export class TestHistory {
|
|
|
103
127
|
const key = runKey(layer);
|
|
104
128
|
let run = this.#runs.get(key);
|
|
105
129
|
if (!run) {
|
|
106
|
-
run = { key, runUrl: layer.runUrl, branch: layer.branch, sha: layer.sha, start: layer.recordedAtMs, end: layer.recordedAtMs, firstAt: layer.recordedAtMs, first: layer.id, producers: new Set(), environments: new Set(), layers: 0, runId: layer.run?.id, title: undefined, titledAt: 0, titledBy: '', freshness: undefined };
|
|
130
|
+
run = { key, runUrl: layer.runUrl, branch: layer.branch, sha: layer.sha, start: layer.recordedAtMs, end: layer.recordedAtMs, firstAt: layer.recordedAtMs, first: layer.id, producers: new Set(), environments: new Set(), layers: 0, runId: layer.run?.id, title: undefined, titledAt: 0, titledBy: '', freshness: undefined, videos: [] };
|
|
107
131
|
this.#runs.set(key, run);
|
|
108
132
|
}
|
|
109
133
|
// A run is told by its first layer (by time, then id), whatever order its layers arrive in.
|
|
@@ -130,6 +154,8 @@ export class TestHistory {
|
|
|
130
154
|
if (layer.environment)
|
|
131
155
|
run.environments.add(layer.environment);
|
|
132
156
|
run.layers += 1;
|
|
157
|
+
if (layer.evidence?.videos)
|
|
158
|
+
run.videos.push(...layer.evidence.videos);
|
|
133
159
|
const origin = originOf(layer, backend);
|
|
134
160
|
for (const row of rows) {
|
|
135
161
|
// "Still running" is progress, not history: a test running now shows it as its current outcome.
|
|
@@ -217,6 +243,7 @@ export class TestHistory {
|
|
|
217
243
|
const results = cells.map(({ i, r }) => ({
|
|
218
244
|
key: i.slot, id: i.id, title: i.title, specPath: i.specPath, environment: r.o.environment, producer: r.o.producer,
|
|
219
245
|
status: r.s, durationMs: r.d, at: r.o.at, layer: r.o.layer, detail: shortDetail(r.t),
|
|
246
|
+
...(r.e?.videos ? { videos: r.e.videos } : {}),
|
|
220
247
|
}));
|
|
221
248
|
results.sort((a, b) => FAILURE_RANK[b.status] - FAILURE_RANK[a.status] || compareText(a.specPath ?? '', b.specPath ?? '') || compareText(a.title, b.title) || compareText(a.environment ?? '', b.environment ?? '') || compareText(a.producer, b.producer) || compareText(a.key, b.key));
|
|
222
249
|
return { run: runSummary(run, cells), results };
|
|
@@ -231,7 +258,10 @@ function runSummary(run, cells) {
|
|
|
231
258
|
const tests = new Set();
|
|
232
259
|
let total = 0, measured = false;
|
|
233
260
|
const failed = [];
|
|
261
|
+
const videos = [...run.videos];
|
|
234
262
|
for (const cell of cells) {
|
|
263
|
+
if (cell.r.e?.videos)
|
|
264
|
+
videos.push(...cell.r.e.videos);
|
|
235
265
|
counts[cell.r.s] += 1;
|
|
236
266
|
tests.add(cell.i.slot);
|
|
237
267
|
if (cell.r.d !== null) {
|
|
@@ -242,6 +272,7 @@ function runSummary(run, cells) {
|
|
|
242
272
|
failed.push(cell);
|
|
243
273
|
}
|
|
244
274
|
failed.sort((a, b) => compareText(a.i.slot, b.i.slot) || compareText(a.r.o.place, b.r.o.place) || compareText(a.r.k, b.r.k));
|
|
275
|
+
const video = videoCounts(videos);
|
|
245
276
|
const failing = failed.slice(0, 20).map(cell => ({ key: cell.i.slot, title: cell.i.title, environment: cell.r.o.environment, detail: (cell.r.t.split('\n')[0] ?? '').slice(0, 500) }));
|
|
246
277
|
return {
|
|
247
278
|
key: run.key, ...(run.runId ? { runId: run.runId } : {}), ...(run.title ? { title: run.title } : {}), ...(run.freshness ? { freshness: run.freshness } : {}),
|
|
@@ -249,6 +280,7 @@ function runSummary(run, cells) {
|
|
|
249
280
|
startedAtMs: run.start, endedAtMs: run.end, spanMs: run.end - run.start,
|
|
250
281
|
producers: [...run.producers].sort(), environments: [...run.environments].sort(),
|
|
251
282
|
layers: run.layers, tests: tests.size, counts, totalDurationMs: measured ? total : null, failing,
|
|
283
|
+
...(video ? { videos: video } : {}),
|
|
252
284
|
};
|
|
253
285
|
}
|
|
254
286
|
function compareText(a, b) {
|
|
@@ -337,15 +369,22 @@ function outcomeOf(result) {
|
|
|
337
369
|
function summaryOf(slot, history, known) {
|
|
338
370
|
const outcomes = slot.outcomes.map(outcomeOf);
|
|
339
371
|
const tags = new Set();
|
|
372
|
+
const artifactTags = new Set();
|
|
373
|
+
const inheritedTags = new Set();
|
|
340
374
|
const labels = {};
|
|
341
375
|
let attachments = 0, steps = 0, attempts = 0;
|
|
342
376
|
const roles = new Set();
|
|
343
|
-
for (const outcome of outcomes) {
|
|
377
|
+
for (const [at, outcome] of outcomes.entries()) {
|
|
344
378
|
const evidence = outcome.evidence;
|
|
345
379
|
if (!evidence)
|
|
346
380
|
continue;
|
|
381
|
+
if (evidence.attachments > 0)
|
|
382
|
+
for (const tag of normalizeTags([...evidence.tags, ...(slot.outcomes[at]?.layer.evidence?.tags ?? [])]))
|
|
383
|
+
inheritedTags.add(tag);
|
|
347
384
|
for (const tag of evidence.tags)
|
|
348
385
|
tags.add(tag);
|
|
386
|
+
for (const tag of evidence.artifactTags ?? [])
|
|
387
|
+
artifactTags.add(tag);
|
|
349
388
|
Object.assign(labels, evidence.labels);
|
|
350
389
|
attachments += evidence.attachments;
|
|
351
390
|
steps += evidence.steps;
|
|
@@ -366,6 +405,8 @@ function summaryOf(slot, history, known) {
|
|
|
366
405
|
outcomes,
|
|
367
406
|
stats: statsOf(history?.of(slot.key) ?? [], outcomes, tagList, labels),
|
|
368
407
|
tags: tagList,
|
|
408
|
+
...(artifactTags.size ? { artifactTags: [...artifactTags].sort() } : {}),
|
|
409
|
+
...([...inheritedTags].some(tag => !artifactTags.has(tag)) ? { inheritedArtifactTags: [...inheritedTags].filter(tag => !artifactTags.has(tag)).sort() } : {}),
|
|
369
410
|
labels,
|
|
370
411
|
evidence: { attachments, steps, attempts, roles: [...roles].sort() },
|
|
371
412
|
lastRecordedAtMs: slot.latestAtMs,
|
|
@@ -375,7 +416,7 @@ function summaryOf(slot, history, known) {
|
|
|
375
416
|
/** The lowercase text a search matches a test against: title, id, spec, domain, tags, labels, environments and producers. */
|
|
376
417
|
export function haystackOf(test) {
|
|
377
418
|
return [
|
|
378
|
-
test.title, test.id, test.specPath ?? '', test.spec?.title ?? '', test.spec?.domain ?? '', ...test.tags,
|
|
419
|
+
test.title, test.id, test.specPath ?? '', test.spec?.title ?? '', test.spec?.domain ?? '', ...test.tags, ...(test.artifactTags ?? []).map(displayTag),
|
|
379
420
|
...Object.entries(test.labels).flatMap(([key, value]) => [key, value]), ...test.environments, ...test.producers,
|
|
380
421
|
].join('\n').toLowerCase();
|
|
381
422
|
}
|
|
@@ -481,6 +522,7 @@ export function catalogModel(layers, sources = {}) {
|
|
|
481
522
|
domains: facet(tests, test => [test.spec?.domain || '(no spec)']),
|
|
482
523
|
specs: [...specGroups].map(([, list]) => { const g = group(list[0].spec.path, list); return { ...list[0].spec, tests: g.tests, counts: g.counts }; }).sort((a, b) => compareText(a.domain, b.domain) || compareText(a.title, b.title)),
|
|
483
524
|
tags: facet(tests, test => test.tags),
|
|
525
|
+
artifactTags: facet(tests, test => test.artifactTags ?? []),
|
|
484
526
|
branches: facet(tests, test => test.stats.branches),
|
|
485
527
|
labels: Object.fromEntries([...labelKeys].sort().map(key => [key, facet(tests, test => (test.labels[key] === undefined ? [] : [test.labels[key]]))])),
|
|
486
528
|
};
|
|
@@ -515,6 +557,8 @@ export function matchesQuery(model, test, query, inRun) {
|
|
|
515
557
|
return false;
|
|
516
558
|
if (query.tag?.length && !query.tag.some(tag => test.tags.includes(tag)))
|
|
517
559
|
return false;
|
|
560
|
+
if (query.artifactTag?.length && !query.artifactTag.some(tag => { const wanted = normalizeTag(tag); return wanted !== null && ((test.artifactTags?.includes(wanted) ?? false) || (query.inherited === true && (test.inheritedArtifactTags?.includes(wanted) ?? false))); }))
|
|
561
|
+
return false;
|
|
518
562
|
if (inRun && !inRun.has(test.key))
|
|
519
563
|
return false;
|
|
520
564
|
return true;
|
|
@@ -536,17 +580,27 @@ export function testHistory(model, key) {
|
|
|
536
580
|
return null;
|
|
537
581
|
return { schemaVersion: TEST_CATALOG_VERSION, version: model.version, test, history: model.history?.of(key) ?? test.outcomes };
|
|
538
582
|
}
|
|
539
|
-
/**
|
|
540
|
-
export function
|
|
541
|
-
|
|
542
|
-
|
|
583
|
+
/** Whether a run has at least one playable video recording. */
|
|
584
|
+
export function hasVideo(run) {
|
|
585
|
+
return (run.videos?.recordings ?? 0) > 0;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Every run the history knows of, newest first, `limit` at most (default 100). With `query.video` only the runs that
|
|
589
|
+
* have a playable video recording: the filter and `total` apply to every run before `limit`, and nothing is read from
|
|
590
|
+
* any recording's bytes. `withVideo` always counts every run that has one.
|
|
591
|
+
*/
|
|
592
|
+
export function runList(history, version, limit = 100, query = {}) {
|
|
593
|
+
const all = history.runs();
|
|
594
|
+
const withVideo = all.filter(hasVideo).length;
|
|
595
|
+
const runs = query.video ? all.filter(hasVideo) : all;
|
|
596
|
+
return { schemaVersion: TEST_CATALOG_VERSION, version, total: runs.length, runs: runs.slice(0, Math.max(0, limit)), withVideo };
|
|
543
597
|
}
|
|
544
598
|
/** One run with every result it recorded, or null when no run has that key. */
|
|
545
599
|
export function runDetail(history, version, key) {
|
|
546
600
|
const found = history.run(key);
|
|
547
601
|
return found ? { schemaVersion: TEST_CATALOG_VERSION, version, ...found } : null;
|
|
548
602
|
}
|
|
549
|
-
/** Reads a `TestQuery` from URL search parameters: `q`, `sort`, `offset`, `limit`, `run`, and repeatable or comma-separated `status`, `environment`, `producer`, `domain`, `spec`, `tag`. */
|
|
603
|
+
/** Reads a `TestQuery` from URL search parameters: `q`, `sort`, `offset`, `limit`, `run`, and repeatable or comma-separated `status`, `environment`, `producer`, `domain`, `spec`, `tag`, `artifactTag`, and `inherited=1`. */
|
|
550
604
|
export function parseTestQuery(params) {
|
|
551
605
|
const many = (name) => params.getAll(name).flatMap(value => value.split(',')).map(value => value.trim()).filter(Boolean);
|
|
552
606
|
const number = (name) => { const value = params.get(name); return value !== null && /^\d+$/.test(value) ? Number(value) : undefined; };
|
|
@@ -555,11 +609,13 @@ export function parseTestQuery(params) {
|
|
|
555
609
|
const q = params.get('q');
|
|
556
610
|
if (q)
|
|
557
611
|
query.q = q;
|
|
558
|
-
for (const name of ['status', 'environment', 'producer', 'domain', 'spec', 'tag']) {
|
|
612
|
+
for (const name of ['status', 'environment', 'producer', 'domain', 'spec', 'tag', 'artifactTag']) {
|
|
559
613
|
const values = many(name);
|
|
560
614
|
if (values.length)
|
|
561
615
|
query[name] = values;
|
|
562
616
|
}
|
|
617
|
+
if (['1', 'true'].includes(params.get('inherited') ?? ''))
|
|
618
|
+
query.inherited = true;
|
|
563
619
|
const run = params.get('run');
|
|
564
620
|
if (run)
|
|
565
621
|
query.run = run;
|
|
@@ -633,6 +689,7 @@ export function historyMarkdown(document) {
|
|
|
633
689
|
`- Results: ${test.stats.results} recorded, ${test.stats.passed} passed, ${test.stats.failed} failed${test.stats.passRate === null ? '' : `, pass rate ${Math.round(test.stats.passRate * 100)}%`}, in ${test.stats.runs} runs`,
|
|
634
690
|
`- Duration: ${d ? `avg ${formatMs(d.avgMs)}, p50 ${formatMs(d.p50Ms)}, p95 ${formatMs(d.p95Ms)}, min ${formatMs(d.minMs)}, max ${formatMs(d.maxMs)}, last ${formatMs(d.lastMs)}` : 'not measured'}`,
|
|
635
691
|
...(test.tags.length ? [`- Tags: ${test.tags.join(', ')}`] : []),
|
|
692
|
+
...(test.artifactTags?.length ? [`- Artifact tags: ${test.artifactTags.map(displayTag).join(', ')}`] : []),
|
|
636
693
|
...(Object.keys(test.labels).length ? [`- Labels: ${Object.entries(test.labels).map(([k, v]) => `${k}=${v}`).join(', ')}`] : []),
|
|
637
694
|
`- Evidence: ${test.evidence.attachments} attachments${test.evidence.roles.length ? ` (${test.evidence.roles.join(', ')})` : ''}, ${test.evidence.steps} steps`,
|
|
638
695
|
'',
|
|
@@ -649,6 +706,12 @@ export function historyMarkdown(document) {
|
|
|
649
706
|
...document.history.map(e => `| ${iso(e.at)} | ${e.environment ?? '—'} | ${e.status} | ${formatMs(e.durationMs)} | ${cell(e.branch ?? '—')} | ${e.sha?.slice(0, 10) ?? '—'} | ${cell(e.detail.split('\n')[0] ?? '')} |`),
|
|
650
707
|
].join('\n')}\n`;
|
|
651
708
|
}
|
|
709
|
+
/** Video counts in a few words: `2 (3 references)`, with how many cannot be played, or `—` when there are none. */
|
|
710
|
+
function videosText(videos) {
|
|
711
|
+
if (!videos)
|
|
712
|
+
return '—';
|
|
713
|
+
return `${videos.recordings}${videos.references !== videos.recordings ? ` (${videos.references} references)` : ''}${videos.unavailable ? `, ${videos.unavailable} unavailable` : ''}`;
|
|
714
|
+
}
|
|
652
715
|
/** What to say beside a commit that was not recorded from a clean checkout: nothing, ` (modified)` or ` (origin unknown)`. */
|
|
653
716
|
function freshnessNote(freshness) {
|
|
654
717
|
return freshness === 'modified' ? ' (modified: local changes)' : freshness === 'unknown' ? ' (origin unknown)' : '';
|
|
@@ -658,9 +721,9 @@ export function runsMarkdown(list) {
|
|
|
658
721
|
return `${[
|
|
659
722
|
`# Runs (${list.runs.length} of ${list.total})`,
|
|
660
723
|
'',
|
|
661
|
-
'| Ended | Branch | Commit | Environments | Tests | Passed | Failed | Not run | Span | Run |',
|
|
662
|
-
'
|
|
663
|
-
...list.runs.map(r => `| ${iso(r.endedAtMs)} | ${cell(r.branch ?? '—')} | ${r.sha?.slice(0, 10) ?? '—'}${freshnessNote(r.freshness)} | ${r.environments.join(', ') || '—'} | ${r.tests} | ${r.counts.passed} | ${r.counts.failed} | ${r.counts['not-run']} | ${formatMs(r.spanMs)} | ${cell(r.title ? `${r.title} (${r.key})` : r.key)} |`),
|
|
724
|
+
'| Ended | Branch | Commit | Environments | Tests | Passed | Failed | Not run | Span | Videos | Run |',
|
|
725
|
+
'|---|---|---|---|---:|---:|---:|---:|---:|---|---|',
|
|
726
|
+
...list.runs.map(r => `| ${iso(r.endedAtMs)} | ${cell(r.branch ?? '—')} | ${r.sha?.slice(0, 10) ?? '—'}${freshnessNote(r.freshness)} | ${r.environments.join(', ') || '—'} | ${r.tests} | ${r.counts.passed} | ${r.counts.failed} | ${r.counts['not-run']} | ${formatMs(r.spanMs)} | ${videosText(r.videos)} | ${cell(r.title ? `${r.title} (${r.key})` : r.key)} |`),
|
|
664
727
|
].join('\n')}\n`;
|
|
665
728
|
}
|
|
666
729
|
/** One run as Markdown. */
|
|
@@ -674,9 +737,10 @@ export function runMarkdown(detail) {
|
|
|
674
737
|
`- ${iso(run.startedAtMs)} to ${iso(run.endedAtMs)} (${formatMs(run.spanMs)}); ${run.layers} layers from ${run.producers.join(', ')}`,
|
|
675
738
|
`- Environments: ${run.environments.join(', ') || '—'}`,
|
|
676
739
|
`- ${run.tests} tests: ${counted(run.counts)}${run.totalDurationMs === null ? '' : `; ${formatMs(run.totalDurationMs)} of test time`}`,
|
|
740
|
+
...(run.videos ? [`- Videos: ${videosText(run.videos)}`] : []),
|
|
677
741
|
'',
|
|
678
|
-
'| Status | Test | Spec | Environment | Duration | Detail |',
|
|
679
|
-
'
|
|
680
|
-
...detail.results.map(r => `| ${r.status} | ${cell(r.title)} | ${cell(r.specPath ?? '—')} | ${r.environment ?? '—'} | ${formatMs(r.durationMs)} | ${cell(r.detail.split('\n')[0] ?? '')} |`),
|
|
742
|
+
'| Status | Test | Spec | Environment | Duration | Videos | Detail |',
|
|
743
|
+
'|---|---|---|---|---:|---|---|',
|
|
744
|
+
...detail.results.map(r => `| ${r.status} | ${cell(r.title)} | ${cell(r.specPath ?? '—')} | ${r.environment ?? '—'} | ${formatMs(r.durationMs)} | ${videosText(videoCounts(r.videos))} | ${cell(r.detail.split('\n')[0] ?? '')} |`),
|
|
681
745
|
].join('\n')}\n`;
|
|
682
746
|
}
|