@crvouga/atlas 0.15.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 +6 -0
- package/dist/reports/src/evidence-detail.d.ts +19 -2
- package/dist/reports/src/evidence-detail.js +63 -14
- package/dist/reports/src/evidence-types.d.ts +31 -0
- package/dist/reports/src/layer-merge.js +2 -2
- package/dist/reports/src/test-catalog.d.ts +24 -3
- package/dist/reports/src/test-catalog.js +37 -11
- package/dist/schema/src/api.d.ts +167 -0
- package/dist/schema/src/api.js +28 -4
- package/dist/server/src/app.js +3 -2
- package/dist/server/src/llms.js +2 -1
- package/dist/server/src/openapi.js +2 -1
- package/docs/reference/http-api.md +4 -2
- package/docs/reference/reports.md +110 -3
- package/docs/reference/schema.md +16 -0
- package/docs/server.md +1 -1
- package/llms-full.txt +5 -3
- package/package.json +1 -1
- package/packages/schema/schemas/effective-delta.schema.json +82 -0
- package/packages/schema/schemas/effective-evidence.schema.json +82 -0
- package/packages/schema/schemas/evidence-page.schema.json +41 -0
- package/packages/schema/schemas/layer-rows.schema.json +41 -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 +41 -0
- package/packages/schema/schemas/test-history.schema.json +82 -0
- package/packages/server/openapi.json +498 -3
- 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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog: @crvouga/atlas
|
|
2
2
|
|
|
3
|
+
## 0.16.0 (2026-10-28)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- show video availability and filter runs with recordings ([dece195](https://github.com/crvouga/atlas/commit/dece195ef419a3fc6eaedb643544be29ffb341c3))
|
|
8
|
+
|
|
3
9
|
## 0.15.0 (2026-10-28)
|
|
4
10
|
|
|
5
11
|
### Features
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* @see docs/report-contract.md (evidence)
|
|
11
11
|
*/
|
|
12
12
|
import type { Annotation, Attachment, Attempt, Check, Step } from '../../schema/src/layer.js';
|
|
13
|
-
import type { EvidenceSummary } from './evidence-types.js';
|
|
13
|
+
import type { EvidenceSummary, VideoCounts, VideoRef } from './evidence-types.js';
|
|
14
14
|
/** Anything that may carry evidence: a layer, a part or a check. */
|
|
15
15
|
export type EvidenceHolder = {
|
|
16
16
|
readonly tags?: readonly string[];
|
|
@@ -34,9 +34,26 @@ export type EvidenceHolder = {
|
|
|
34
34
|
* Several holders (for example a check and its layer) are summed: `attachments` also counts those on steps and
|
|
35
35
|
* attempts, `steps` counts only each holder's own top-level steps, tags are de-duplicated, and for a repeated
|
|
36
36
|
* label the later holder wins. `artifactTags` are the attachments' own normalized tags and are never inherited. `roles` are the attachments' roles (or, without one, what their media type or file
|
|
37
|
-
* name implies), sorted and distinct.
|
|
37
|
+
* name implies), sorted and distinct. `videos` lists every attachment whose effective media type is video, with the
|
|
38
|
+
* check, retry attempt or step that holds it (see {@link isVideoAttachment}).
|
|
38
39
|
*/
|
|
39
40
|
export declare function summarizeEvidence(...holders: readonly EvidenceHolder[]): EvidenceSummary | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Like {@link summarizeEvidence} for a layer and its parts: the same summary, but a video attached to the layer or a
|
|
43
|
+
* part is owned by the `layer`, not by a check.
|
|
44
|
+
*/
|
|
45
|
+
export declare function summarizeLayerEvidence(...holders: readonly EvidenceHolder[]): EvidenceSummary | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* Whether an attachment is a video, by its effective media type: the producer's `contentType`, else the one its file
|
|
48
|
+
* name implies. A role never decides this on its own (`demo` on a PNG is not a video), except that a `video` role
|
|
49
|
+
* settles a file whose type nothing says (`application/octet-stream`).
|
|
50
|
+
*/
|
|
51
|
+
export declare function isVideoAttachment(attachment: Pick<Attachment, 'name' | 'role' | 'contentType'>): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Counts references, distinct playable recordings and unplayable references. Undefined when there is no reference, so
|
|
54
|
+
* "no video" and "nothing known about video" look the same on the wire.
|
|
55
|
+
*/
|
|
56
|
+
export declare function videoCounts(videos: readonly VideoRef[] | undefined): VideoCounts | undefined;
|
|
40
57
|
/** A role to show when a producer gave none, from the media type: `image`, `video`, `audio`, `snapshot` (text/html), `text` (text, JSON, XML) or `file`. Roles are an open vocabulary; a producer's own role always wins. */
|
|
41
58
|
export declare function roleOfType(contentType: string | undefined): string;
|
|
42
59
|
/** All of one result's evidence, read when someone opens it. */
|
|
@@ -11,31 +11,48 @@ function count(holder) {
|
|
|
11
11
|
* Several holders (for example a check and its layer) are summed: `attachments` also counts those on steps and
|
|
12
12
|
* attempts, `steps` counts only each holder's own top-level steps, tags are de-duplicated, and for a repeated
|
|
13
13
|
* label the later holder wins. `artifactTags` are the attachments' own normalized tags and are never inherited. `roles` are the attachments' roles (or, without one, what their media type or file
|
|
14
|
-
* name implies), sorted and distinct.
|
|
14
|
+
* name implies), sorted and distinct. `videos` lists every attachment whose effective media type is video, with the
|
|
15
|
+
* check, retry attempt or step that holds it (see {@link isVideoAttachment}).
|
|
15
16
|
*/
|
|
16
17
|
export function summarizeEvidence(...holders) {
|
|
18
|
+
return summarize(holders, 'check');
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Like {@link summarizeEvidence} for a layer and its parts: the same summary, but a video attached to the layer or a
|
|
22
|
+
* part is owned by the `layer`, not by a check.
|
|
23
|
+
*/
|
|
24
|
+
export function summarizeLayerEvidence(...holders) {
|
|
25
|
+
return summarize(holders, 'layer');
|
|
26
|
+
}
|
|
27
|
+
function summarize(holders, owner) {
|
|
17
28
|
if (!holders.some(holder => count(holder) > 0))
|
|
18
29
|
return undefined;
|
|
19
30
|
const roles = new Set();
|
|
20
31
|
const tags = new Set();
|
|
21
32
|
const artifactTags = new Set();
|
|
22
33
|
const labels = {};
|
|
34
|
+
const videos = [];
|
|
23
35
|
let attachments = 0, steps = 0, annotations = 0, attempts = 0, links = 0;
|
|
24
|
-
const attachmentsOf = (list
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
36
|
+
const attachmentsOf = (list, by, where = {}) => {
|
|
37
|
+
for (const item of list ?? []) {
|
|
38
|
+
attachments += 1;
|
|
39
|
+
for (const tag of normalizeTags(item.tags))
|
|
40
|
+
artifactTags.add(tag);
|
|
41
|
+
roles.add(item.role ?? roleOfType(item.contentType ?? contentTypeOf(item.name)));
|
|
42
|
+
const video = videoOf(item, by, where);
|
|
43
|
+
if (video)
|
|
44
|
+
videos.push(video);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const stepsOf = (list, attempt) => { for (const step of list ?? [])
|
|
48
|
+
attachmentsOf(step.attachments, 'step', { ...(attempt === undefined ? {} : { attempt }), step: step.name }); };
|
|
30
49
|
for (const holder of holders) {
|
|
31
|
-
attachmentsOf(holder.attachments);
|
|
50
|
+
attachmentsOf(holder.attachments, owner);
|
|
32
51
|
// Attachments on steps and attempts are part of the check too.
|
|
33
|
-
|
|
34
|
-
attachmentsOf(step.attachments);
|
|
52
|
+
stepsOf(holder.steps);
|
|
35
53
|
for (const attempt of holder.attempts ?? []) {
|
|
36
|
-
attachmentsOf(attempt.attachments);
|
|
37
|
-
|
|
38
|
-
attachmentsOf(step.attachments);
|
|
54
|
+
attachmentsOf(attempt.attachments, 'attempt', { attempt: attempt.index });
|
|
55
|
+
stepsOf(attempt.steps, attempt.index);
|
|
39
56
|
}
|
|
40
57
|
steps += holder.steps?.length ?? 0;
|
|
41
58
|
annotations += holder.annotations?.length ?? 0;
|
|
@@ -45,7 +62,39 @@ export function summarizeEvidence(...holders) {
|
|
|
45
62
|
tags.add(tag);
|
|
46
63
|
Object.assign(labels, holder.labels);
|
|
47
64
|
}
|
|
48
|
-
return { attachments, steps, annotations, attempts, links, tags: [...tags], ...(artifactTags.size ? { artifactTags: [...artifactTags].sort() } : {}), labels, roles: [...roles].sort() };
|
|
65
|
+
return { attachments, steps, annotations, attempts, links, tags: [...tags], ...(artifactTags.size ? { artifactTags: [...artifactTags].sort() } : {}), labels, roles: [...roles].sort(), ...(videos.length ? { videos } : {}) };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Whether an attachment is a video, by its effective media type: the producer's `contentType`, else the one its file
|
|
69
|
+
* name implies. A role never decides this on its own (`demo` on a PNG is not a video), except that a `video` role
|
|
70
|
+
* settles a file whose type nothing says (`application/octet-stream`).
|
|
71
|
+
*/
|
|
72
|
+
export function isVideoAttachment(attachment) {
|
|
73
|
+
const type = (attachment.contentType ?? contentTypeOf(attachment.name)).toLowerCase();
|
|
74
|
+
return type.startsWith('video/') || (attachment.role === 'video' && type.startsWith('application/octet-stream'));
|
|
75
|
+
}
|
|
76
|
+
function videoOf(item, owner, where) {
|
|
77
|
+
if (!isVideoAttachment(item))
|
|
78
|
+
return undefined;
|
|
79
|
+
const recording = item.sha256 ? `sha256:${item.sha256}` : item.bundleKey ?? item.uri ?? (item.text === undefined ? undefined : `inline:${item.name}`);
|
|
80
|
+
return { name: item.name, owner, ...where, ...(recording === undefined ? {} : { recording }), available: recording !== undefined };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Counts references, distinct playable recordings and unplayable references. Undefined when there is no reference, so
|
|
84
|
+
* "no video" and "nothing known about video" look the same on the wire.
|
|
85
|
+
*/
|
|
86
|
+
export function videoCounts(videos) {
|
|
87
|
+
if (!videos?.length)
|
|
88
|
+
return undefined;
|
|
89
|
+
const distinct = new Set();
|
|
90
|
+
let unavailable = 0;
|
|
91
|
+
for (const video of videos) {
|
|
92
|
+
if (video.available && video.recording !== undefined)
|
|
93
|
+
distinct.add(video.recording);
|
|
94
|
+
else
|
|
95
|
+
unavailable += 1;
|
|
96
|
+
}
|
|
97
|
+
return { references: videos.length, recordings: distinct.size, unavailable };
|
|
49
98
|
}
|
|
50
99
|
/** A role to show when a producer gave none, from the media type: `image`, `video`, `audio`, `snapshot` (text/html), `text` (text, JSON, XML) or `file`. Roles are an open vocabulary; a producer's own role always wins. */
|
|
51
100
|
export function roleOfType(contentType) {
|
|
@@ -36,6 +36,35 @@ export type BackendInfo = {
|
|
|
36
36
|
};
|
|
37
37
|
/** How many results have each status (`passed`, `failed`, `running`, `not-run`). */
|
|
38
38
|
export type StatusCounts = Readonly<Record<EvidenceStatus, number>>;
|
|
39
|
+
/** Who holds a recording: the whole layer (or one of its parts), one check, one retry attempt of a check, or one step. */
|
|
40
|
+
export type VideoOwner = 'layer' | 'check' | 'attempt' | 'step';
|
|
41
|
+
/**
|
|
42
|
+
* One reference to a video, found by its effective media type (the producer's `contentType`, else the one the file
|
|
43
|
+
* name implies; a `video` role alone counts only when nothing else says what the file is). A reference is not a
|
|
44
|
+
* recording: the same bytes referenced from three checks are three references and one recording.
|
|
45
|
+
*/
|
|
46
|
+
export type VideoRef = {
|
|
47
|
+
/** The attachment's name, for a person. */
|
|
48
|
+
readonly name: string;
|
|
49
|
+
readonly owner: VideoOwner;
|
|
50
|
+
/** The try that holds it, when `owner` is `attempt` or the step is inside one. */
|
|
51
|
+
readonly attempt?: number;
|
|
52
|
+
/** The name of the step that holds it, when `owner` is `step`. */
|
|
53
|
+
readonly step?: string;
|
|
54
|
+
/** What identifies the bytes (digest, bundle key or address); references with the same one are the same recording. Absent when `available` is false. */
|
|
55
|
+
readonly recording?: string;
|
|
56
|
+
/** False when the reference names no bytes at all (no bundle key, address or inline text), so it can never be played. */
|
|
57
|
+
readonly available: boolean;
|
|
58
|
+
};
|
|
59
|
+
/** Video availability as counts: references, distinct playable recordings (deduplicated), and references that cannot be played. */
|
|
60
|
+
export type VideoCounts = {
|
|
61
|
+
/** Every reference to a video, playable or not. */
|
|
62
|
+
readonly references: number;
|
|
63
|
+
/** Distinct playable recordings: identical bytes referenced twice are one. */
|
|
64
|
+
readonly recordings: number;
|
|
65
|
+
/** References that name no bytes. A report that predates availability metadata has none of these and none of the others. */
|
|
66
|
+
readonly unavailable: number;
|
|
67
|
+
};
|
|
39
68
|
/**
|
|
40
69
|
* What a result (or a run) carries beyond its outcome, as counts and short facts: attachments, steps,
|
|
41
70
|
* annotations, retries, tags, labels, links. It is only there when there is something. The evidence itself
|
|
@@ -55,6 +84,8 @@ export type EvidenceSummary = {
|
|
|
55
84
|
readonly labels: Readonly<Record<string, string>>;
|
|
56
85
|
/** The distinct roles of its attachments (or what their media types say), to show what kind of evidence there is. */
|
|
57
86
|
readonly roles: readonly string[];
|
|
87
|
+
/** Its references to videos, wherever they are held (layer, check, attempt, step). Only there when it has any. */
|
|
88
|
+
readonly videos?: readonly VideoRef[];
|
|
58
89
|
};
|
|
59
90
|
/** A report layer's origin and totals. Its checks are read separately, once. */
|
|
60
91
|
export type LayerHeader = {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { summarizeEvidence } from './evidence-detail.js';
|
|
1
|
+
import { summarizeEvidence, summarizeLayerEvidence } from './evidence-detail.js';
|
|
2
2
|
import { layerPlace } from './layer-axes.js';
|
|
3
3
|
/**
|
|
4
4
|
* A short, deterministic fingerprint of a spec's text, shaped `<length>:<hash>:<hash>` (the length in UTF-16 code
|
|
@@ -94,7 +94,7 @@ export function layerEvidence(summary, backend) {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
const recording = summary.recording;
|
|
97
|
-
const layerSummaryEvidence =
|
|
97
|
+
const layerSummaryEvidence = summarizeLayerEvidence(summary, ...summary.parts);
|
|
98
98
|
const layer = {
|
|
99
99
|
...(summary.freshness ? { freshness: summary.freshness } : {}),
|
|
100
100
|
...(summary.run ? { run: summary.run } : {}),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { EvidenceStatus, SpecEntry } from './explorer-types.js';
|
|
2
|
-
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';
|
|
3
3
|
import { RecordedSpecs } from './recorded-specs.js';
|
|
4
4
|
/** The version of the documents this module writes (`TestCatalog`, `TestHistoryDocument`, `RunList`, `RunDetail`). Only optional fields are added within it. */
|
|
5
5
|
export declare const TEST_CATALOG_VERSION = 1;
|
|
@@ -258,13 +258,26 @@ export type RunSummary = {
|
|
|
258
258
|
readonly environment: string | null;
|
|
259
259
|
readonly detail: string;
|
|
260
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;
|
|
261
271
|
};
|
|
262
272
|
/** `GET /api/reports/runs`: runs, newest first. */
|
|
263
273
|
export type RunList = {
|
|
264
274
|
readonly schemaVersion: typeof TEST_CATALOG_VERSION;
|
|
265
275
|
readonly version: string;
|
|
276
|
+
/** Runs that matched, before `limit`. */
|
|
266
277
|
readonly total: number;
|
|
267
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;
|
|
268
281
|
};
|
|
269
282
|
/** One test's last result within one run. */
|
|
270
283
|
export type RunResult = {
|
|
@@ -279,6 +292,8 @@ export type RunResult = {
|
|
|
279
292
|
readonly at: number;
|
|
280
293
|
readonly layer: string;
|
|
281
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[];
|
|
282
297
|
};
|
|
283
298
|
/** `GET /api/reports/runs/detail`: one run and every result it recorded. */
|
|
284
299
|
export type RunDetail = {
|
|
@@ -380,8 +395,14 @@ export declare function matchesQuery(model: CatalogModel, test: TestSummary, que
|
|
|
380
395
|
export declare function testCatalog(model: CatalogModel, query?: TestQuery): TestCatalog;
|
|
381
396
|
/** One test and its recorded results, or null when the report has no test with that key. */
|
|
382
397
|
export declare function testHistory(model: CatalogModel, key: string): TestHistoryDocument | null;
|
|
383
|
-
/**
|
|
384
|
-
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;
|
|
385
406
|
/** One run with every result it recorded, or null when no run has that key. */
|
|
386
407
|
export declare function runDetail(history: TestHistory, version: string, key: string): RunDetail | null;
|
|
387
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`. */
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* @see docs/server.md#reading-the-report-as-an-agent
|
|
22
22
|
*/
|
|
23
23
|
import { displayTag, normalizeTag, normalizeTags } from './artifact-tags.js';
|
|
24
|
+
import { videoCounts } from './evidence-detail.js';
|
|
24
25
|
import { checkSlots } from './evidence-view.js';
|
|
25
26
|
import { combineEvidence, emptyCounts, slotKey, specPath as normalSpecPath } from './layer-merge.js';
|
|
26
27
|
import { RecordedSpecs } from './recorded-specs.js';
|
|
@@ -126,7 +127,7 @@ export class TestHistory {
|
|
|
126
127
|
const key = runKey(layer);
|
|
127
128
|
let run = this.#runs.get(key);
|
|
128
129
|
if (!run) {
|
|
129
|
-
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: [] };
|
|
130
131
|
this.#runs.set(key, run);
|
|
131
132
|
}
|
|
132
133
|
// A run is told by its first layer (by time, then id), whatever order its layers arrive in.
|
|
@@ -153,6 +154,8 @@ export class TestHistory {
|
|
|
153
154
|
if (layer.environment)
|
|
154
155
|
run.environments.add(layer.environment);
|
|
155
156
|
run.layers += 1;
|
|
157
|
+
if (layer.evidence?.videos)
|
|
158
|
+
run.videos.push(...layer.evidence.videos);
|
|
156
159
|
const origin = originOf(layer, backend);
|
|
157
160
|
for (const row of rows) {
|
|
158
161
|
// "Still running" is progress, not history: a test running now shows it as its current outcome.
|
|
@@ -240,6 +243,7 @@ export class TestHistory {
|
|
|
240
243
|
const results = cells.map(({ i, r }) => ({
|
|
241
244
|
key: i.slot, id: i.id, title: i.title, specPath: i.specPath, environment: r.o.environment, producer: r.o.producer,
|
|
242
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 } : {}),
|
|
243
247
|
}));
|
|
244
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));
|
|
245
249
|
return { run: runSummary(run, cells), results };
|
|
@@ -254,7 +258,10 @@ function runSummary(run, cells) {
|
|
|
254
258
|
const tests = new Set();
|
|
255
259
|
let total = 0, measured = false;
|
|
256
260
|
const failed = [];
|
|
261
|
+
const videos = [...run.videos];
|
|
257
262
|
for (const cell of cells) {
|
|
263
|
+
if (cell.r.e?.videos)
|
|
264
|
+
videos.push(...cell.r.e.videos);
|
|
258
265
|
counts[cell.r.s] += 1;
|
|
259
266
|
tests.add(cell.i.slot);
|
|
260
267
|
if (cell.r.d !== null) {
|
|
@@ -265,6 +272,7 @@ function runSummary(run, cells) {
|
|
|
265
272
|
failed.push(cell);
|
|
266
273
|
}
|
|
267
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);
|
|
268
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) }));
|
|
269
277
|
return {
|
|
270
278
|
key: run.key, ...(run.runId ? { runId: run.runId } : {}), ...(run.title ? { title: run.title } : {}), ...(run.freshness ? { freshness: run.freshness } : {}),
|
|
@@ -272,6 +280,7 @@ function runSummary(run, cells) {
|
|
|
272
280
|
startedAtMs: run.start, endedAtMs: run.end, spanMs: run.end - run.start,
|
|
273
281
|
producers: [...run.producers].sort(), environments: [...run.environments].sort(),
|
|
274
282
|
layers: run.layers, tests: tests.size, counts, totalDurationMs: measured ? total : null, failing,
|
|
283
|
+
...(video ? { videos: video } : {}),
|
|
275
284
|
};
|
|
276
285
|
}
|
|
277
286
|
function compareText(a, b) {
|
|
@@ -571,10 +580,20 @@ export function testHistory(model, key) {
|
|
|
571
580
|
return null;
|
|
572
581
|
return { schemaVersion: TEST_CATALOG_VERSION, version: model.version, test, history: model.history?.of(key) ?? test.outcomes };
|
|
573
582
|
}
|
|
574
|
-
/**
|
|
575
|
-
export function
|
|
576
|
-
|
|
577
|
-
|
|
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 };
|
|
578
597
|
}
|
|
579
598
|
/** One run with every result it recorded, or null when no run has that key. */
|
|
580
599
|
export function runDetail(history, version, key) {
|
|
@@ -687,6 +706,12 @@ export function historyMarkdown(document) {
|
|
|
687
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] ?? '')} |`),
|
|
688
707
|
].join('\n')}\n`;
|
|
689
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
|
+
}
|
|
690
715
|
/** What to say beside a commit that was not recorded from a clean checkout: nothing, ` (modified)` or ` (origin unknown)`. */
|
|
691
716
|
function freshnessNote(freshness) {
|
|
692
717
|
return freshness === 'modified' ? ' (modified: local changes)' : freshness === 'unknown' ? ' (origin unknown)' : '';
|
|
@@ -696,9 +721,9 @@ export function runsMarkdown(list) {
|
|
|
696
721
|
return `${[
|
|
697
722
|
`# Runs (${list.runs.length} of ${list.total})`,
|
|
698
723
|
'',
|
|
699
|
-
'| Ended | Branch | Commit | Environments | Tests | Passed | Failed | Not run | Span | Run |',
|
|
700
|
-
'
|
|
701
|
-
...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)} |`),
|
|
702
727
|
].join('\n')}\n`;
|
|
703
728
|
}
|
|
704
729
|
/** One run as Markdown. */
|
|
@@ -712,9 +737,10 @@ export function runMarkdown(detail) {
|
|
|
712
737
|
`- ${iso(run.startedAtMs)} to ${iso(run.endedAtMs)} (${formatMs(run.spanMs)}); ${run.layers} layers from ${run.producers.join(', ')}`,
|
|
713
738
|
`- Environments: ${run.environments.join(', ') || '—'}`,
|
|
714
739
|
`- ${run.tests} tests: ${counted(run.counts)}${run.totalDurationMs === null ? '' : `; ${formatMs(run.totalDurationMs)} of test time`}`,
|
|
740
|
+
...(run.videos ? [`- Videos: ${videosText(run.videos)}`] : []),
|
|
715
741
|
'',
|
|
716
|
-
'| Status | Test | Spec | Environment | Duration | Detail |',
|
|
717
|
-
'
|
|
718
|
-
...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] ?? '')} |`),
|
|
719
745
|
].join('\n')}\n`;
|
|
720
746
|
}
|