@crvouga/atlas 1.18.0 → 1.19.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 +8 -1
- package/dist/reports/src/evidence-detail.js +13 -3
- package/dist/reports/src/evidence-types.d.ts +4 -0
- package/dist/schema/src/api.d.ts +50 -0
- package/dist/schema/src/api.js +2 -0
- package/docs/reference/reports.md +4 -0
- package/docs/rich-evidence.md +1 -0
- package/docs/ui.md +40 -1
- package/llms-full.txt +41 -1
- package/package.json +1 -1
- package/packages/schema/schemas/effective-delta.schema.json +18 -0
- package/packages/schema/schemas/effective-evidence.schema.json +18 -0
- package/packages/schema/schemas/evidence-page.schema.json +9 -0
- package/packages/schema/schemas/layer-rows.schema.json +9 -0
- package/packages/schema/schemas/report-run.schema.json +9 -0
- package/packages/schema/schemas/review-collection.schema.json +9 -0
- package/packages/schema/schemas/review-manifest.schema.json +9 -0
- package/packages/schema/schemas/test-catalog.schema.json +9 -0
- package/packages/schema/schemas/test-history.schema.json +18 -0
- package/packages/server/openapi.json +126 -0
- package/packages/visualizer/src/app/AppShell.tsx +2 -2
- package/packages/visualizer/src/app/router.tsx +25 -1
- package/packages/visualizer/src/components/Header.tsx +15 -4
- package/packages/visualizer/src/evidence/behavior-index.test.ts +119 -0
- package/packages/visualizer/src/evidence/behavior-index.ts +162 -0
- package/packages/visualizer/src/evidence/behavior-panel.tsx +36 -22
- package/packages/visualizer/src/evidence/behavior-return.test.ts +41 -0
- package/packages/visualizer/src/evidence/behavior-return.ts +59 -0
- package/packages/visualizer/src/evidence/behavior.ts +32 -1
- package/packages/visualizer/src/evidence/behaviors-page.tsx +465 -0
- package/packages/visualizer/src/evidence/feature-page.tsx +5 -26
- package/packages/visualizer/src/evidence/scoped-summary-view.tsx +110 -0
- package/packages/visualizer/src/evidence/scoped-summary.test.ts +62 -0
- package/packages/visualizer/src/evidence/scoped-summary.ts +118 -0
- package/packages/visualizer/src/evidence/spec-texts.ts +88 -0
- package/packages/visualizer/src/explorer/ArtifactsView.tsx +4 -4
- package/packages/visualizer/src/explorer/Explorer.tsx +1 -1
- package/packages/visualizer/src/explorer/RunsView.tsx +4 -4
- package/packages/visualizer/src/explorer/collections.tsx +5 -5
- package/packages/visualizer/src/explorer/navigate.ts +17 -0
- package/packages/visualizer/src/explorer/playlist-entries.test.ts +10 -1
- package/packages/visualizer/src/explorer/playlist-entries.ts +10 -0
- package/packages/visualizer/src/explorer/playlist.tsx +43 -8
- package/packages/visualizer/src/explorer/search.ts +7 -1
- package/packages/visualizer/src/find/Finder.tsx +1 -62
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { TestResultEntry } from '@crvouga/atlas/reports/test-catalog';
|
|
2
|
+
import { expect, test } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
import { entriesInScope, scopedFigures } from './scoped-summary';
|
|
5
|
+
|
|
6
|
+
const entry = (i: number, over: Partial<TestResultEntry> = {}): TestResultEntry => ({
|
|
7
|
+
at: 1_000 + i, status: 'passed', durationMs: 9_000, environment: 'desktop-web', producer: 'web', kind: 'checks', parameters: {}, branch: 'main',
|
|
8
|
+
sha: 'a'.repeat(40), runUrl: null, run: `run:r${i}`, layer: `l${i}`, backend: 'one', detail: '', ...over
|
|
9
|
+
});
|
|
10
|
+
const KEY = '["specs/a.feature","a"]';
|
|
11
|
+
const latency = (value: number, over: Record<string, unknown> = {}) => ({ name: 'save', kind: 'product-latency', unit: 'ms', value, from: 'tap', to: 'saved', ...over });
|
|
12
|
+
|
|
13
|
+
test('no history leaves reliability and product latency unknown, never good', () => {
|
|
14
|
+
const figures = scopedFigures(KEY, [], { environment: 'desktop-web' });
|
|
15
|
+
expect(figures.reliability.tone).toBe('unknown');
|
|
16
|
+
expect(figures.reliability.headline).toContain('unknown');
|
|
17
|
+
expect(figures.latency).toHaveLength(1);
|
|
18
|
+
expect(figures.latency[0]!.headline).toContain('unknown');
|
|
19
|
+
// The duration of the test is not a stand-in for the product's response time.
|
|
20
|
+
expect(figures.latency[0]!.qualifiers.join(' ')).toContain('not product latency');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('a rate always names its independent executions and a small sample is insufficient evidence', () => {
|
|
24
|
+
const figures = scopedFigures(KEY, [entry(0), entry(1), entry(2, { status: 'failed' })], { environment: 'desktop-web' });
|
|
25
|
+
expect(figures.reliability.headline).toBe('2 of 3 independent executions passed in the end (66.7%).');
|
|
26
|
+
expect(figures.reliability.tone).toBe('weak');
|
|
27
|
+
expect(figures.reliability.qualifiers.join(' ')).toContain('Insufficient evidence');
|
|
28
|
+
expect(figures.reliability.qualifiers.join(' ')).toContain('First-try success is unknown');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('the scope is the shown result\'s environment: another environment is not pooled into it', () => {
|
|
32
|
+
const history = [entry(0), entry(1, { environment: 'ios', status: 'failed' }), entry(2, { environment: 'ios', status: 'failed' })];
|
|
33
|
+
expect(entriesInScope(history, { environment: 'desktop-web' })).toHaveLength(1);
|
|
34
|
+
expect(entriesInScope(history, { environment: null })).toHaveLength(3);
|
|
35
|
+
const scoped = scopedFigures(KEY, history, { environment: 'desktop-web' });
|
|
36
|
+
expect(scoped.reliability.headline).toContain('1 of 1 independent execution passed');
|
|
37
|
+
expect(scoped.reliability.qualifiers[0]).toContain('desktop-web');
|
|
38
|
+
// With no environment named the executions are pooled, and the figure says so.
|
|
39
|
+
const pooled = scopedFigures(KEY, history, { environment: null });
|
|
40
|
+
expect(pooled.reliability.qualifiers.join(' ')).toMatch(/environments are pooled/i);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('product latency is read only from named measurements, with withheld percentiles explained', () => {
|
|
44
|
+
const history = [entry(0, { latency: [latency(300)] }), entry(1, { latency: [latency(500, { limit: 400 })] }), entry(2)];
|
|
45
|
+
const [line] = scopedFigures(KEY, history, { environment: 'desktop-web' }).latency;
|
|
46
|
+
expect(line!.headline).toContain('save');
|
|
47
|
+
expect(line!.headline).toContain('no percentile can be stated');
|
|
48
|
+
expect(line!.tone).toBe('weak');
|
|
49
|
+
const text = line!.qualifiers.join(' ');
|
|
50
|
+
expect(text).toContain('2 observations');
|
|
51
|
+
expect(text).toContain('1 observation over it');
|
|
52
|
+
expect(text).toContain('p50 needs 5 observations');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('enough observations state p50 and p95 with the budget and its breaches', () => {
|
|
56
|
+
const history = Array.from({ length: 20 }, (_, i) => entry(i, { latency: [latency(100 + i * 10, { limit: 250 })] }));
|
|
57
|
+
const [line] = scopedFigures(KEY, history, { environment: 'desktop-web' }).latency;
|
|
58
|
+
expect(line!.headline).toContain('p50');
|
|
59
|
+
expect(line!.headline).toContain('p95');
|
|
60
|
+
expect(line!.tone).toBe('figure');
|
|
61
|
+
expect(line!.qualifiers.join(' ')).toContain('20 observations');
|
|
62
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Scoped behavior summary
|
|
3
|
+
*
|
|
4
|
+
* The short account that sits beside a behavior's written promise and recording: what the selected result says, how
|
|
5
|
+
* reliable the check has been, and how quickly the product responded. It does not compute anything of its own. The
|
|
6
|
+
* outcome is {@link outcomeHeadline}; reliability is `reliability` and product latency is `latencyReview` from the
|
|
7
|
+
* reports library, read over the history of the same check in the same environment as the recording shown. This module
|
|
8
|
+
* only chooses the scope and puts the qualified figures into short sentences, keeping every qualification that makes a
|
|
9
|
+
* figure honest: how many independent executions it rests on, that a small sample is not established, which notes warn
|
|
10
|
+
* against reading it as representative, and that absent evidence is unknown, never good.
|
|
11
|
+
*/
|
|
12
|
+
import { formatLatency, latencyReview, type LatencyReview } from '@crvouga/atlas/reports/latency';
|
|
13
|
+
import { reliability, scopeText, type ReliabilityDocument, type ReliabilityScope } from '@crvouga/atlas/reports/reliability';
|
|
14
|
+
import type { TestResultEntry } from '@crvouga/atlas/reports/test-catalog';
|
|
15
|
+
|
|
16
|
+
import { plural } from './format';
|
|
17
|
+
|
|
18
|
+
/** One line of the summary: the figure in words, and what qualifies it. */
|
|
19
|
+
export type SummaryLine = {
|
|
20
|
+
readonly headline: string;
|
|
21
|
+
/** What a reader must keep in mind: sample size, scope, warnings. Never empty for a figure. */
|
|
22
|
+
readonly qualifiers: readonly string[];
|
|
23
|
+
/** `figure`: a number supported by evidence. `unknown`: there is no evidence to state. `weak`: a figure that its own qualifiers say not to rely on. */
|
|
24
|
+
readonly tone: 'figure' | 'weak' | 'unknown';
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** The scope the summary describes: the environment of the shown result, so environments are never pooled silently. */
|
|
28
|
+
export type SummaryScope = {
|
|
29
|
+
/** The environment of the result shown; null when it recorded none (then every environment is pooled and the summary says so). */
|
|
30
|
+
readonly environment: string | null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const percent = (rate: number | null) => (rate === null ? 'no rate' : `${Math.round(rate * 1000) / 10}%`);
|
|
34
|
+
|
|
35
|
+
/** The reliability scope for a summary scope. */
|
|
36
|
+
export function reliabilityScope(scope: SummaryScope): ReliabilityScope {
|
|
37
|
+
return scope.environment === null ? {} : { environment: [scope.environment] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The recorded results of the summary's scope: those of its environment, or all when it names none. */
|
|
41
|
+
export function entriesInScope(entries: readonly TestResultEntry[], scope: SummaryScope): readonly TestResultEntry[] {
|
|
42
|
+
return scope.environment === null ? entries : entries.filter((entry) => entry.environment === scope.environment);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Reliability in one sentence and its qualifiers. A count of independent executions always comes with the figure;
|
|
47
|
+
* fewer than the minimum is "Insufficient evidence"; none is unknown. The library's warning notes are carried over.
|
|
48
|
+
*/
|
|
49
|
+
export function reliabilityLine(document: ReliabilityDocument): SummaryLine {
|
|
50
|
+
const { eventual, firstAttempt } = document;
|
|
51
|
+
const where = scopeText(document.scope);
|
|
52
|
+
if (document.sample === 'none') {
|
|
53
|
+
return {
|
|
54
|
+
headline: 'Reliability is unknown: no independent execution is recorded in this scope.',
|
|
55
|
+
qualifiers: [`Scope: ${where}.`],
|
|
56
|
+
tone: 'unknown'
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const qualifiers = [`Scope: ${where}.`];
|
|
60
|
+
if (document.sample === 'insufficient') {
|
|
61
|
+
qualifiers.push(`Insufficient evidence: at least ${document.minimumExecutions} independent executions are needed before a rate means much.`);
|
|
62
|
+
}
|
|
63
|
+
qualifiers.push(
|
|
64
|
+
firstAttempt.executions
|
|
65
|
+
? `${firstAttempt.passed} of ${plural(firstAttempt.executions, 'execution', 'executions')} passed first time.`
|
|
66
|
+
: 'First-try success is unknown: no tries were recorded.'
|
|
67
|
+
);
|
|
68
|
+
for (const note of document.notes) if (note.severity === 'warning') qualifiers.push(note.message);
|
|
69
|
+
return {
|
|
70
|
+
headline: `${eventual.passed} of ${plural(eventual.executions, 'independent execution', 'independent executions')} passed in the end (${percent(eventual.rate)}).`,
|
|
71
|
+
qualifiers,
|
|
72
|
+
tone: document.sample === 'insufficient' || document.notes.some((note) => note.severity === 'warning') ? 'weak' : 'figure'
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Product latency: one line per measurement a producer named as product latency. The test's own duration is never read
|
|
78
|
+
* here. Percentiles the evidence cannot support are said to be withheld with their reason; with no measurement the
|
|
79
|
+
* answer is unknown, not fast.
|
|
80
|
+
*/
|
|
81
|
+
export function latencyLines(review: LatencyReview): readonly SummaryLine[] {
|
|
82
|
+
if (review.metrics.length === 0) {
|
|
83
|
+
return [
|
|
84
|
+
{
|
|
85
|
+
headline: 'Product latency is unknown: no product-latency measurement is recorded.',
|
|
86
|
+
qualifiers: ['The duration of the test is not product latency and is not shown here.'],
|
|
87
|
+
tone: 'unknown'
|
|
88
|
+
}
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
return review.metrics.flatMap(({ metric, groups }) =>
|
|
92
|
+
groups.map<SummaryLine>((summary) => {
|
|
93
|
+
const figures = [
|
|
94
|
+
summary.p50 === undefined ? undefined : `p50 ${formatLatency(summary.p50, metric.unit)}`,
|
|
95
|
+
summary.p95 === undefined ? undefined : `p95 ${formatLatency(summary.p95, metric.unit)}`
|
|
96
|
+
].filter((figure): figure is string => figure !== undefined);
|
|
97
|
+
const qualifiers = [`${plural(summary.samples, 'observation', 'observations')}${summary.lowerBound ? ', including some that did not complete (lower bounds)' : ''}.`];
|
|
98
|
+
if (summary.budget !== undefined) {
|
|
99
|
+
qualifiers.push(`Budget ${formatLatency(summary.budget, metric.unit)}: ${plural(summary.breaches, 'observation', 'observations')} over it.`);
|
|
100
|
+
}
|
|
101
|
+
qualifiers.push(...summary.withheld, ...summary.gaps);
|
|
102
|
+
return {
|
|
103
|
+
headline: `${metric.name}${metric.boundary.label ? ` (${metric.boundary.label})` : ''}: ${figures.length ? figures.join(', ') : 'no percentile can be stated'}.`,
|
|
104
|
+
qualifiers,
|
|
105
|
+
tone: figures.length > 0 && summary.gaps.length === 0 ? 'figure' : 'weak'
|
|
106
|
+
};
|
|
107
|
+
})
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Reliability and product latency of one check in a scope, worked out from its recorded history. */
|
|
112
|
+
export function scopedFigures(key: string, history: readonly TestResultEntry[], scope: SummaryScope) {
|
|
113
|
+
const entries = entriesInScope(history, scope);
|
|
114
|
+
return {
|
|
115
|
+
reliability: reliabilityLine(reliability(key, history, reliabilityScope(scope))),
|
|
116
|
+
latency: latencyLines(latencyReview(entries))
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Reading spec texts for search and indexes
|
|
3
|
+
*
|
|
4
|
+
* The finder and the project-wide behavior index both need the text of many specs, not only the one on screen. They
|
|
5
|
+
* read it here: a few files at a time, each landing as it arrives so a search can use what is read so far, through the
|
|
6
|
+
* same query cache the spec pages use (so a file is never fetched twice and a page opened afterwards finds it ready).
|
|
7
|
+
*/
|
|
8
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
9
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
10
|
+
|
|
11
|
+
import { keys, specKey, useAdapter } from '../data/queries';
|
|
12
|
+
|
|
13
|
+
/** How many spec files are read at once. */
|
|
14
|
+
const READERS = 4;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Reads the text of every spec in `files`, a few at a time, once `enabled`. Each file lands as it arrives, so a search
|
|
18
|
+
* can use what is read so far; `read` of `total` say how far it is, and `failed` how many could not be read at all
|
|
19
|
+
* (`texts` holds an empty text for them). Files the query cache already holds are available at once, without a request.
|
|
20
|
+
* Setting `open` (the finder opening) gives the files that could not be read another chance.
|
|
21
|
+
*/
|
|
22
|
+
export function useSpecTexts(files: readonly string[], enabled: boolean, open: boolean) {
|
|
23
|
+
const adapter = useAdapter();
|
|
24
|
+
const client = useQueryClient();
|
|
25
|
+
const source = specKey(adapter);
|
|
26
|
+
const cache = useRef(new Map<string, string>());
|
|
27
|
+
const unreadable = useRef(new Set<string>());
|
|
28
|
+
const [texts, setTexts] = useState<ReadonlyMap<string, string>>(() => new Map());
|
|
29
|
+
const [pending, setPending] = useState(false);
|
|
30
|
+
const [failedFiles, setFailedFiles] = useState<ReadonlySet<string>>(() => new Set());
|
|
31
|
+
const key = files.join('\n');
|
|
32
|
+
// Opening the finder is another chance for the files that could not be read.
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!open || unreadable.current.size === 0) return;
|
|
35
|
+
for (const file of unreadable.current) cache.current.delete(file);
|
|
36
|
+
unreadable.current.clear();
|
|
37
|
+
setFailedFiles(new Set());
|
|
38
|
+
}, [open]);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!enabled) return;
|
|
41
|
+
const wanted = key.length === 0 ? [] : key.split('\n');
|
|
42
|
+
let cancelled = false;
|
|
43
|
+
// What the pages already read is not asked for again.
|
|
44
|
+
let seeded = false;
|
|
45
|
+
for (const file of wanted) {
|
|
46
|
+
if (cache.current.has(file)) continue;
|
|
47
|
+
const held = client.getQueryData<string>(keys.specFile(source, file));
|
|
48
|
+
if (typeof held === 'string') {
|
|
49
|
+
cache.current.set(file, held);
|
|
50
|
+
seeded = true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (seeded) setTexts(new Map(cache.current));
|
|
54
|
+
const missing = wanted.filter((file) => !cache.current.has(file));
|
|
55
|
+
if (missing.length === 0) {
|
|
56
|
+
setPending(false);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
setPending(true);
|
|
60
|
+
let cursor = 0;
|
|
61
|
+
const pull = async () => {
|
|
62
|
+
for (;;) {
|
|
63
|
+
const file = missing[cursor++];
|
|
64
|
+
if (file === undefined || cancelled) return;
|
|
65
|
+
let text = '';
|
|
66
|
+
try {
|
|
67
|
+
text = await client.fetchQuery({ queryKey: keys.specFile(source, file), queryFn: () => adapter.specFile(file), retry: false });
|
|
68
|
+
} catch {
|
|
69
|
+
text = '';
|
|
70
|
+
unreadable.current.add(file);
|
|
71
|
+
}
|
|
72
|
+
if (cancelled) return;
|
|
73
|
+
cache.current.set(file, text);
|
|
74
|
+
setTexts(new Map(cache.current));
|
|
75
|
+
if (unreadable.current.has(file)) setFailedFiles(new Set(unreadable.current));
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
void Promise.all(Array.from({ length: READERS }, pull)).then(() => {
|
|
79
|
+
if (!cancelled) setPending(false);
|
|
80
|
+
});
|
|
81
|
+
return () => {
|
|
82
|
+
cancelled = true;
|
|
83
|
+
};
|
|
84
|
+
}, [adapter, client, enabled, key, source]);
|
|
85
|
+
const read = useMemo(() => files.filter((file) => texts.has(file)).length, [files, texts]);
|
|
86
|
+
const failed = useMemo(() => files.filter((file) => failedFiles.has(file)).length, [files, failedFiles]);
|
|
87
|
+
return { texts, pending, total: files.length, read, failed };
|
|
88
|
+
}
|
|
@@ -20,7 +20,7 @@ import { StatusMark } from '../evidence/marks';
|
|
|
20
20
|
import { useArtifactList } from './catalog';
|
|
21
21
|
import { FreezeCollection } from './collections';
|
|
22
22
|
import { FilterBar } from './filters';
|
|
23
|
-
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
23
|
+
import { patched, useExplorerNavigate, useExplorerSearch, useWatchAddress } from './navigate';
|
|
24
24
|
import { runName } from './parts';
|
|
25
25
|
import { entriesFromArtifacts } from './playlist-entries';
|
|
26
26
|
import { WatchPlaylist } from './playlist';
|
|
@@ -40,7 +40,7 @@ export function ArtifactsView() {
|
|
|
40
40
|
const answer = useArtifactList(filter);
|
|
41
41
|
const now = useNow();
|
|
42
42
|
const [shown, setShown] = useState(ARTIFACT_ROWS);
|
|
43
|
-
const
|
|
43
|
+
const watch = useWatchAddress();
|
|
44
44
|
const [freezing, setFreezing] = useState(false);
|
|
45
45
|
const entries = useMemo(() => (answer.data && !answer.data.unsupported ? entriesFromArtifacts(answer.data.list.results) : []), [answer.data]);
|
|
46
46
|
if (answer.isPending)
|
|
@@ -89,7 +89,7 @@ export function ArtifactsView() {
|
|
|
89
89
|
)}
|
|
90
90
|
{list.results.length > 0 && (
|
|
91
91
|
<Group my="sm" gap="sm">
|
|
92
|
-
<Button type="button" variant="filled" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() =>
|
|
92
|
+
<Button type="button" variant="filled" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() => watch.open()}>
|
|
93
93
|
Watch these {plural(list.results.length, 'result', 'results')}
|
|
94
94
|
</Button>
|
|
95
95
|
{!staticEvidence && (
|
|
@@ -99,7 +99,7 @@ export function ArtifactsView() {
|
|
|
99
99
|
)}
|
|
100
100
|
</Group>
|
|
101
101
|
)}
|
|
102
|
-
{watching && <WatchPlaylist entries={entries} total={list.total}
|
|
102
|
+
{watch.watching && <WatchPlaylist entries={entries} total={list.total} initial={watch.clip} onSelect={watch.select} onClose={watch.close} />}
|
|
103
103
|
{freezing && <FreezeCollection results={list.results} total={list.total} filterQuery={filterParams(filter).toString()} onClose={() => setFreezing(false)} />}
|
|
104
104
|
{list.results.length > 0 && <RecordingTextSearch entries={entries} total={list.total} />}
|
|
105
105
|
{list.results.length > 0 && (
|
|
@@ -198,7 +198,7 @@ export function Explorer() {
|
|
|
198
198
|
{model && (model.tests.length > 0 || model.summary.runs > 0) && (
|
|
199
199
|
<Tabs
|
|
200
200
|
value={search.tab ?? 'tests'}
|
|
201
|
-
onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' || value === 'collections' || value === 'compare' || value === 'health' ? value : undefined, test: undefined, openRun: undefined, layer: undefined, artifact: undefined, collection: undefined, rev: undefined, pair: undefined })}
|
|
201
|
+
onChange={(value) => go({ tab: value === 'runs' || value === 'artifacts' || value === 'collections' || value === 'compare' || value === 'health' ? value : undefined, test: undefined, openRun: undefined, layer: undefined, artifact: undefined, collection: undefined, rev: undefined, pair: undefined, watch: undefined, watchClip: undefined })}
|
|
202
202
|
>
|
|
203
203
|
<Tabs.List aria-label="Explorer view">
|
|
204
204
|
<Tabs.Tab
|
|
@@ -29,7 +29,7 @@ import { entriesFromRun } from './playlist-entries';
|
|
|
29
29
|
import { WatchPlaylist } from './playlist';
|
|
30
30
|
import { FilterBar } from './filters';
|
|
31
31
|
import { SearchNotes, TypedFilters } from './search-notes';
|
|
32
|
-
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
32
|
+
import { patched, useExplorerNavigate, useExplorerSearch, useWatchAddress } from './navigate';
|
|
33
33
|
import { runName } from './parts';
|
|
34
34
|
import { filtered, filterOf, NO_FILTERS } from './search';
|
|
35
35
|
const RUN_ROWS = 50;
|
|
@@ -496,7 +496,7 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
496
496
|
),
|
|
497
497
|
[narrowedResults, status, statusFilters, model]
|
|
498
498
|
);
|
|
499
|
-
const
|
|
499
|
+
const watch = useWatchAddress();
|
|
500
500
|
const playlist = useMemo(() => entriesFromRun(filtered, detail.data?.layers), [filtered, detail.data]);
|
|
501
501
|
const bySpec = useMemo(() => {
|
|
502
502
|
const groups = new Map<string, RunResult[]>();
|
|
@@ -676,11 +676,11 @@ function RunDetailView({ runKey }: { runKey: string }) {
|
|
|
676
676
|
</Text>
|
|
677
677
|
)}
|
|
678
678
|
{filtered.length > 0 && (
|
|
679
|
-
<Button type="button" variant="filled" my="sm" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() =>
|
|
679
|
+
<Button type="button" variant="filled" my="sm" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() => watch.open()}>
|
|
680
680
|
Watch these {plural(filtered.length, 'result', 'results')}
|
|
681
681
|
</Button>
|
|
682
682
|
)}
|
|
683
|
-
{watching && <WatchPlaylist entries={playlist}
|
|
683
|
+
{watch.watching && <WatchPlaylist entries={playlist} initial={watch.clip} onSelect={watch.select} onClose={watch.close} />}
|
|
684
684
|
{bySpec.map(([spec, list]) => (
|
|
685
685
|
<Box key={spec} data-open aria-label={spec || 'No spec'} component="section" mb="lg">
|
|
686
686
|
<Button data-static variant="default" fullWidth h="auto" py="sm" justify="flex-start">
|
|
@@ -22,7 +22,7 @@ import { reportAssetUrl, reportJson, ReportsError } from '../evidence/api';
|
|
|
22
22
|
import { exactTime, plural, shortSha, statusWord } from '../evidence/format';
|
|
23
23
|
import { StatusMark } from '../evidence/marks';
|
|
24
24
|
import { formatTimecode, parseTagsField, parseTimecode, sourceSpread } from './collection-model';
|
|
25
|
-
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
25
|
+
import { patched, useExplorerNavigate, useExplorerSearch, useWatchAddress } from './navigate';
|
|
26
26
|
import { runName } from './parts';
|
|
27
27
|
import { WatchPlaylist } from './playlist';
|
|
28
28
|
import { entriesFromCollection, RECORDING_STATE_TEXT, recordingState } from './playlist-entries';
|
|
@@ -155,7 +155,7 @@ function CollectionPage({ id, revision }: { id: string; revision: number | undef
|
|
|
155
155
|
const go = useExplorerNavigate();
|
|
156
156
|
const client = useQueryClient();
|
|
157
157
|
const [token, setToken] = usePublishToken();
|
|
158
|
-
const
|
|
158
|
+
const watch = useWatchAddress();
|
|
159
159
|
const detail = useQuery({
|
|
160
160
|
queryKey: ['review-collection', id, revision ?? 'newest'],
|
|
161
161
|
queryFn: ({ signal }) => reportJson<ReviewCollectionDetail>(`/review-collections/detail?id=${encodeURIComponent(id)}${revision ? `&revision=${revision}` : ''}`, signal),
|
|
@@ -235,7 +235,7 @@ function CollectionPage({ id, revision }: { id: string; revision: number | undef
|
|
|
235
235
|
</Text>
|
|
236
236
|
</Alert>
|
|
237
237
|
<Group gap="sm" align="flex-end" wrap="wrap">
|
|
238
|
-
<Button type="button" variant="filled" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() =>
|
|
238
|
+
<Button type="button" variant="filled" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() => watch.open()} disabled={collection.entries.length === 0}>
|
|
239
239
|
Watch these {plural(collection.entries.length, 'entry', 'entries')}
|
|
240
240
|
</Button>
|
|
241
241
|
<Button component="a" href={exportHref} download leftSection={<IconDownload size={16} aria-hidden="true" />}>
|
|
@@ -244,7 +244,7 @@ function CollectionPage({ id, revision }: { id: string; revision: number | undef
|
|
|
244
244
|
<TextInput label="Share link" description="Opening it needs the same read access as this report." value={shareLink} readOnly onFocus={(event) => event.currentTarget.select()} miw={280} />
|
|
245
245
|
</Group>
|
|
246
246
|
<TokenField token={token} onChange={setToken} />
|
|
247
|
-
{watching
|
|
247
|
+
{watch.watching && <WatchPlaylist title={collection.title} entries={playlist} initial={watch.clip} onSelect={watch.select} onClose={watch.close} />}
|
|
248
248
|
{notesOf(undefined).length > 0 && (
|
|
249
249
|
<Stack gap="xs" component="section" aria-label="Notes on the collection">
|
|
250
250
|
<Title order={3}>Notes on the collection</Title>
|
|
@@ -309,7 +309,7 @@ function CollectionPage({ id, revision }: { id: string; revision: number | undef
|
|
|
309
309
|
{RECORDING_STATE_TEXT[state].label}
|
|
310
310
|
</Text>
|
|
311
311
|
{state === 'playable' && (
|
|
312
|
-
<Button type="button" size="compact-sm" variant="light" leftSection={<IconPlayerPlay size={14} aria-hidden="true" />} onClick={() =>
|
|
312
|
+
<Button type="button" size="compact-sm" variant="light" leftSection={<IconPlayerPlay size={14} aria-hidden="true" />} onClick={() => watch.open(entry.id)}>
|
|
313
313
|
Watch entry {index + 1}
|
|
314
314
|
</Button>
|
|
315
315
|
)}
|
|
@@ -34,3 +34,20 @@ export function useExplorerNavigate() {
|
|
|
34
34
|
export function patched(patch: ExplorerPatch) {
|
|
35
35
|
return ((prev: Record<string, unknown>) => ({ ...prev, ...patch })) as never;
|
|
36
36
|
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The open watch playlist as the address holds it, so a link restores it: `watching` is true while the address says it is
|
|
40
|
+
* open and `clip` is the entry it is on. `open` pushes a history entry (Back closes it), `select` and `close` replace the
|
|
41
|
+
* current one, so stepping through clips does not fill the history. `open(clip)` starts on that entry.
|
|
42
|
+
*/
|
|
43
|
+
export function useWatchAddress() {
|
|
44
|
+
const search = useExplorerSearch();
|
|
45
|
+
const go = useExplorerNavigate();
|
|
46
|
+
return {
|
|
47
|
+
watching: search.watch === true,
|
|
48
|
+
clip: search.watchClip,
|
|
49
|
+
open: (clip?: string) => go({ watch: true, watchClip: clip }),
|
|
50
|
+
select: (clip: string) => go({ watchClip: clip }, { replace: true }),
|
|
51
|
+
close: () => go({ watch: undefined, watchClip: undefined }, { replace: true })
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { VideoRef } from '@crvouga/atlas/reports/evidence-types';
|
|
2
2
|
import { expect, test } from 'bun:test';
|
|
3
3
|
|
|
4
|
-
import { entriesFromRun, playlistCounts, recordingState, stepFrom, type PlaylistEntry } from './playlist-entries';
|
|
4
|
+
import { clipHints, entriesFromRun, playlistCounts, recordingState, stepFrom, type PlaylistEntry } from './playlist-entries';
|
|
5
5
|
|
|
6
6
|
const video = (recording?: string): VideoRef => ({ name: 'a.webm', owner: 'check', available: recording !== undefined, ...(recording ? { recording } : {}) });
|
|
7
7
|
const entry = (id: string, extra: Partial<PlaylistEntry> = {}): PlaylistEntry => ({
|
|
@@ -49,3 +49,12 @@ test('a run result gets its backend from the layers of the run, and none when th
|
|
|
49
49
|
expect(entriesFromRun([result], undefined)[0]!.backend).toBeNull();
|
|
50
50
|
expect(recordingState(entriesFromRun([result], undefined)[0]!)).toBe('unplayable');
|
|
51
51
|
});
|
|
52
|
+
|
|
53
|
+
test('clip hints are the poster and length the catalog already named for the clip that would play, and nothing otherwise', () => {
|
|
54
|
+
const hinted: VideoRef = { ...video('sha256:1'), poster: 'attachments/p', durationMs: 4_000 };
|
|
55
|
+
expect(clipHints(entry('a', { videos: [hinted] }))).toEqual({ poster: 'attachments/p', durationMs: 4_000 });
|
|
56
|
+
expect(clipHints(entry('a', { videos: [video('sha256:1')] }))).toEqual({});
|
|
57
|
+
// A reference that names no bytes is not the clip that would play.
|
|
58
|
+
expect(clipHints(entry('a', { videos: [{ ...video(), durationMs: 9 }] }))).toEqual({});
|
|
59
|
+
expect(clipHints(entry('a'))).toEqual({});
|
|
60
|
+
});
|
|
@@ -68,6 +68,16 @@ export function playableVideo(entry: Pick<PlaylistEntry, 'videos'>): VideoRef |
|
|
|
68
68
|
return entry.videos.find((video) => video.available && video.recording !== undefined);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* What the catalog already says about the clip an entry would play, before anything is read or downloaded: the bundle key
|
|
73
|
+
* of its poster image and its declared length. Each is absent when the producer declared none (or the server predates the
|
|
74
|
+
* hints); they are hints to recognise a clip, never a reading of the recording and never a latency.
|
|
75
|
+
*/
|
|
76
|
+
export function clipHints(entry: Pick<PlaylistEntry, 'videos'>): { readonly poster?: string; readonly durationMs?: number } {
|
|
77
|
+
const video = playableVideo(entry);
|
|
78
|
+
return { ...(video?.poster ? { poster: video.poster } : {}), ...(video?.durationMs ? { durationMs: video.durationMs } : {}) };
|
|
79
|
+
}
|
|
80
|
+
|
|
71
81
|
/** The recorded results of the Artifacts tab as playlist entries, in their order. */
|
|
72
82
|
export function entriesFromArtifacts(results: readonly ArtifactResult[]): PlaylistEntry[] {
|
|
73
83
|
return results.map((result) => ({
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Only the clip on screen is read: the list is made of the metadata the catalog already sent, the current result's
|
|
9
9
|
* evidence is read when it is selected, and its video is loaded with `preload="metadata"`, so opening the playlist of a
|
|
10
|
-
* large library downloads no recordings in bulk.
|
|
10
|
+
* large library downloads no recordings in bulk. A row shows a poster (lazily loaded) and a length only when the catalog
|
|
11
|
+
* already carried them for its clip; none is read to find out. There is one `<video>`; selecting another clip replaces it and keeps
|
|
11
12
|
* the position of the one left, so returning resumes there. A clip that will not load (or whose autoplay the browser
|
|
12
13
|
* blocked) stays in the list and offers Retry, Play and Next. Recordings are always labelled illustrative: playback
|
|
13
14
|
* length and speed are those of the recording, never production latency.
|
|
@@ -16,7 +17,7 @@ import { attachmentIdentity, isVideoAttachment, type CheckDetail } from '@crvoug
|
|
|
16
17
|
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
17
18
|
import { contentTypeOf } from '@crvouga/atlas/reports/media-types';
|
|
18
19
|
import type { Attachment, Step } from '@crvouga/atlas/schema';
|
|
19
|
-
import { Alert, Anchor, Badge, Box, Button, Code, Flex, Grid, Group, List, Modal, NativeSelect, Stack, Switch, Text, Title } from '@mantine/core';
|
|
20
|
+
import { Alert, Anchor, Badge, Box, Button, Code, Flex, Grid, Group, Image, List, Modal, NativeSelect, Stack, Switch, Text, Title } from '@mantine/core';
|
|
20
21
|
import { IconAlertTriangle, IconPlayerPlay, IconPlayerSkipBack, IconPlayerSkipForward } from '@tabler/icons-react';
|
|
21
22
|
import { useQuery } from '@tanstack/react-query';
|
|
22
23
|
import { Link } from '@tanstack/react-router';
|
|
@@ -31,7 +32,7 @@ import { duration, exactTime, plural, statusWord } from '../evidence/format';
|
|
|
31
32
|
import { StatusMark } from '../evidence/marks';
|
|
32
33
|
import { patched } from './navigate';
|
|
33
34
|
import { clock, TEXT_KIND_WORD, type RecordingFocus } from './recording-text';
|
|
34
|
-
import { playableVideo, playlistCounts, RECORDING_STATE_TEXT, recordingState, stepFrom, type PlaylistEntry } from './playlist-entries';
|
|
35
|
+
import { clipHints, playableVideo, playlistCounts, RECORDING_STATE_TEXT, recordingState, stepFrom, type PlaylistEntry } from './playlist-entries';
|
|
35
36
|
|
|
36
37
|
/** Entries listed before "more"; the selected entry is always listed. */
|
|
37
38
|
const LISTED = 50;
|
|
@@ -70,6 +71,14 @@ export function useEntryDetail(entry: PlaylistEntry, enabled: boolean) {
|
|
|
70
71
|
});
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/** The poster the catalog named for a clip, loaded only when its row is near the screen (`loading="lazy"`); a poster that will not load is left out. */
|
|
75
|
+
function Poster({ entry, bundleKey }: { entry: PlaylistEntry; bundleKey: string }) {
|
|
76
|
+
const [broken, setBroken] = useState(false);
|
|
77
|
+
const url = attachmentSource({ name: 'poster', bundleKey }, entry.layer).url;
|
|
78
|
+
if (!url || broken) return null;
|
|
79
|
+
return <Image src={url} alt="" loading="lazy" decoding="async" w={80} fit="contain" radius="sm" data-poster onError={() => setBroken(true)} />;
|
|
80
|
+
}
|
|
81
|
+
|
|
73
82
|
/** One step of the scenario: in words, with a control to show it in the recording and a mark while it is playing. */
|
|
74
83
|
function ScenarioStep({ step }: { step: Step }) {
|
|
75
84
|
const playing = useIsPlaying(step);
|
|
@@ -269,7 +278,7 @@ function Clip({
|
|
|
269
278
|
const developer = (
|
|
270
279
|
<Anchor
|
|
271
280
|
renderRoot={(props) => (
|
|
272
|
-
<Link {...props} to="/" search={patched({ tab: undefined, openRun: undefined, artifact: undefined, test: entry.key, layer: entry.layer })} resetScroll={false} activeOptions={{ exact: true }} onClick={onClose} />
|
|
281
|
+
<Link {...props} to="/" search={patched({ tab: undefined, openRun: undefined, artifact: undefined, watch: undefined, watchClip: undefined, test: entry.key, layer: entry.layer })} resetScroll={false} activeOptions={{ exact: true }} onClick={onClose} />
|
|
273
282
|
)}
|
|
274
283
|
>
|
|
275
284
|
Developer details
|
|
@@ -412,7 +421,7 @@ function Clip({
|
|
|
412
421
|
steps={steps}
|
|
413
422
|
attachments={placedIn}
|
|
414
423
|
layer={entry.layer}
|
|
415
|
-
linkSearch={{ test: entry.key, layer: entry.layer, tab: undefined, openRun: undefined, artifact: undefined }}
|
|
424
|
+
linkSearch={{ test: entry.key, layer: entry.layer, tab: undefined, openRun: undefined, artifact: undefined, watch: undefined, watchClip: undefined }}
|
|
416
425
|
>
|
|
417
426
|
<Stack gap="md" miw={0} aria-label={`Now showing ${entry.title}`} component="section" data-clip={entry.id}>
|
|
418
427
|
{body}
|
|
@@ -432,9 +441,26 @@ function Clip({
|
|
|
432
441
|
* The watch session for `entries`: opens as a modal over the explorer. `total` is how many results matched when the list
|
|
433
442
|
* holds only the newest of them, so the session says it is not the whole set. `focus` opens it on one found moment: that
|
|
434
443
|
* result, that attempt's video and, when the time is usable, that time. `initial` is the entry to start on when there is no
|
|
435
|
-
* `focus`, and `title` the heading.
|
|
444
|
+
* `focus`, and `title` the heading. `onSelect` is told the id of the entry on screen when the session opens and each time it
|
|
445
|
+
* changes, so the address can name it. Closing it discards positions.
|
|
436
446
|
*/
|
|
437
|
-
export function WatchPlaylist({
|
|
447
|
+
export function WatchPlaylist({
|
|
448
|
+
entries,
|
|
449
|
+
total,
|
|
450
|
+
focus,
|
|
451
|
+
onClose,
|
|
452
|
+
onSelect,
|
|
453
|
+
initial,
|
|
454
|
+
title = 'Watch recordings'
|
|
455
|
+
}: {
|
|
456
|
+
entries: readonly PlaylistEntry[];
|
|
457
|
+
total?: number;
|
|
458
|
+
focus?: RecordingFocus;
|
|
459
|
+
onClose: () => void;
|
|
460
|
+
onSelect?: (id: string) => void;
|
|
461
|
+
initial?: string;
|
|
462
|
+
title?: string;
|
|
463
|
+
}) {
|
|
438
464
|
const first = entries.find((entry) => entry.id === initial) ?? entries.find((entry) => recordingState(entry) === 'playable') ?? entries[0];
|
|
439
465
|
const [chosen, setChosen] = useState<string | null>(focus && entries.some((entry) => entry.id === focus.entryId) ? focus.entryId : (first?.id ?? null));
|
|
440
466
|
// Selecting anything else leaves the found moment behind: it describes one video and one time.
|
|
@@ -449,6 +475,12 @@ export function WatchPlaylist({ entries, total, focus, onClose, initial, title =
|
|
|
449
475
|
const active = entries.find((entry) => entry.id === chosen) ?? first ?? null;
|
|
450
476
|
const counts = useMemo(() => playlistCounts(entries), [entries]);
|
|
451
477
|
const activeAt = active ? entries.indexOf(active) : -1;
|
|
478
|
+
const activeId = active?.id;
|
|
479
|
+
const selected = useRef(onSelect);
|
|
480
|
+
selected.current = onSelect;
|
|
481
|
+
useEffect(() => {
|
|
482
|
+
if (activeId !== undefined && !focus) selected.current?.(activeId);
|
|
483
|
+
}, [activeId, focus]);
|
|
452
484
|
const next = stepFrom(entries, active?.id ?? null, 1, repeat);
|
|
453
485
|
const previous = stepFrom(entries, active?.id ?? null, -1, repeat);
|
|
454
486
|
const select = (entry: PlaylistEntry, play: boolean) => {
|
|
@@ -524,6 +556,7 @@ export function WatchPlaylist({ entries, total, focus, onClose, initial, title =
|
|
|
524
556
|
{listed.map((entry, index) => {
|
|
525
557
|
const state = recordingState(entry);
|
|
526
558
|
const here = entry.id === active?.id;
|
|
559
|
+
const hints = clipHints(entry);
|
|
527
560
|
return (
|
|
528
561
|
<List.Item key={entry.id} data-entry={entry.id} data-status={entry.status} data-recording-state={state}>
|
|
529
562
|
<Button
|
|
@@ -534,11 +567,12 @@ export function WatchPlaylist({ entries, total, focus, onClose, initial, title =
|
|
|
534
567
|
py="xs"
|
|
535
568
|
justify="flex-start"
|
|
536
569
|
aria-current={here ? 'true' : undefined}
|
|
537
|
-
aria-label={`${index + 1}. ${entry.title}, ${statusWord(entry.status)}, ${failedIds.has(entry.id) ? 'could not be loaded' : RECORDING_STATE_TEXT[state].label}`}
|
|
570
|
+
aria-label={`${index + 1}. ${entry.title}, ${statusWord(entry.status)}, ${failedIds.has(entry.id) ? 'could not be loaded' : RECORDING_STATE_TEXT[state].label}${hints.durationMs ? `, ${duration(hints.durationMs)} long` : ''}`}
|
|
538
571
|
onClick={() => select(entry, state === 'playable')}
|
|
539
572
|
>
|
|
540
573
|
<Flex component="span" gap="xs" wrap="nowrap" align="flex-start">
|
|
541
574
|
<StatusMark status={entry.status} size={14} />
|
|
575
|
+
{hints.poster && <Poster entry={entry} bundleKey={hints.poster} />}
|
|
542
576
|
<Flex component="span" direction="column" align="flex-start">
|
|
543
577
|
<Text span inherit ta="left">
|
|
544
578
|
{entry.title}
|
|
@@ -546,6 +580,7 @@ export function WatchPlaylist({ entries, total, focus, onClose, initial, title =
|
|
|
546
580
|
<Text span inherit c="dimmed" size="xs" ta="left">
|
|
547
581
|
{statusWord(entry.status)} · {environmentLabel(entry.environment)} ·{' '}
|
|
548
582
|
{failedIds.has(entry.id) ? 'Could not be loaded' : RECORDING_STATE_TEXT[state].label}
|
|
583
|
+
{hints.durationMs ? ` · ${duration(hints.durationMs)} recording` : ''}
|
|
549
584
|
</Text>
|
|
550
585
|
</Flex>
|
|
551
586
|
</Flex>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* @module Explorer address
|
|
3
3
|
*
|
|
4
4
|
* Everything the test explorer shows is in the address of the `/` route, so a link reproduces the view: the open test,
|
|
5
|
-
* the words searched, the filters, the grouping, the order, the Runs tab with the run opened in it, the Compare tab with its two pinned runs (`base`, `cand`) and the behavior opened (`pair`), the Catalog health tab with the root mappings it tries (`root`), the exact layer a test's detail is pinned to, the recording and position of a shared moment in it (`clip`, `clipAt`), and the one file of a run's layer its Artifacts select. Filters that
|
|
5
|
+
* the words searched, the filters, the grouping, the order, the Runs tab with the run opened in it, the Compare tab with its two pinned runs (`base`, `cand`) and the behavior opened (`pair`), the Catalog health tab with the root mappings it tries (`root`), the exact layer a test's detail is pinned to, the recording and position of a shared moment in it (`clip`, `clipAt`), the watch playlist that is open over the list or run and the clip it is on (`watch`, `watchClip`), and the one file of a run's layer its Artifacts select. Filters that
|
|
6
6
|
* take several values are comma-separated (`status=failed,flaky`); a comma or backslash inside a value is written `\,`
|
|
7
7
|
* or `\\`, and the router encodes `#` and spaces. Anything unreadable is dropped, never an error.
|
|
8
8
|
*
|
|
@@ -109,6 +109,10 @@ export const ExplorerSearchFields = {
|
|
|
109
109
|
relRevision: z.enum(['any']).optional().catch(undefined),
|
|
110
110
|
/** The reliability's independent executions (its denominator) are open. */
|
|
111
111
|
relOpen: z.preprocess((value) => (value === 'true' ? true : value), z.literal(true)).optional().catch(undefined),
|
|
112
|
+
/** The watch playlist is open over the list, opened run or collection the rest of the address names. A link with it restores the playlist; without it the page is as it was before. */
|
|
113
|
+
watch: z.preprocess((value) => (value === 'true' ? true : value), z.literal(true)).optional().catch(undefined),
|
|
114
|
+
/** With `watch`: the entry of the playlist that is playing (its id, which names the result and the layer that recorded it). Unknown or absent: the first one that can be watched. */
|
|
115
|
+
watchClip: text,
|
|
112
116
|
/** Words to find inside the recordings of the Artifacts tab (what was said or shown, from the text producers supplied). Not a filter of the list. */
|
|
113
117
|
inside: text,
|
|
114
118
|
/** Only the runs, and the results of an opened run, that have a playable video recording. */
|
|
@@ -166,6 +170,8 @@ export type ExplorerSearch = {
|
|
|
166
170
|
readonly relOpen?: true;
|
|
167
171
|
readonly inside?: string;
|
|
168
172
|
readonly video?: true;
|
|
173
|
+
readonly watch?: true;
|
|
174
|
+
readonly watchClip?: string;
|
|
169
175
|
};
|
|
170
176
|
|
|
171
177
|
/** The values of a comma-separated filter, empty when it is not set. */
|