@crvouga/atlas 1.9.0 → 1.11.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 +15 -1
- package/dist/reports/src/review-collection-store.d.ts +47 -0
- package/dist/reports/src/review-collection-store.js +214 -0
- package/dist/reports/src/review-collection.d.ts +88 -0
- package/dist/reports/src/review-collection.js +144 -0
- package/dist/reports/src/test-catalog.d.ts +2 -0
- package/dist/reports/src/test-catalog.js +1 -1
- package/dist/schema/src/api.d.ts +574 -0
- package/dist/schema/src/api.js +152 -1
- package/dist/server/src/app.js +4 -1
- package/dist/server/src/openapi.js +46 -1
- package/dist/server/src/review-collections.d.ts +39 -0
- package/dist/server/src/review-collections.js +179 -0
- package/docs/extending.md +1 -1
- package/docs/reference/http-api.md +122 -0
- package/docs/reference/reports.md +294 -0
- package/docs/reference/schema.md +131 -0
- package/docs/reference/server.md +44 -0
- package/docs/rich-evidence.md +38 -0
- package/docs/server.md +41 -0
- package/docs/ui.md +67 -1
- package/llms-full.txt +269 -2
- package/package.json +1 -1
- package/packages/schema/schemas/review-collection.schema.json +398 -0
- package/packages/schema/schemas/review-manifest.schema.json +475 -0
- package/packages/server/openapi.json +6729 -4084
- package/packages/visualizer/src/app/router.tsx +6 -1
- package/packages/visualizer/src/evidence/applicability-view.tsx +233 -0
- package/packages/visualizer/src/evidence/applicability.test.ts +131 -0
- package/packages/visualizer/src/evidence/applicability.ts +241 -0
- package/packages/visualizer/src/evidence/artifact-tags.tsx +3 -2
- package/packages/visualizer/src/evidence/behavior-panel.tsx +11 -1
- package/packages/visualizer/src/evidence/feature-page.tsx +25 -4
- package/packages/visualizer/src/explorer/ArtifactsView.tsx +26 -2
- package/packages/visualizer/src/explorer/Explorer.tsx +7 -2
- package/packages/visualizer/src/explorer/RecordingTextSearch.tsx +355 -0
- package/packages/visualizer/src/explorer/RunsView.tsx +11 -1
- package/packages/visualizer/src/explorer/collection-model.test.ts +31 -0
- package/packages/visualizer/src/explorer/collection-model.ts +45 -0
- package/packages/visualizer/src/explorer/collections.tsx +513 -0
- package/packages/visualizer/src/explorer/playlist-entries.test.ts +51 -0
- package/packages/visualizer/src/explorer/playlist-entries.ts +163 -0
- package/packages/visualizer/src/explorer/playlist.tsx +500 -0
- package/packages/visualizer/src/explorer/recording-text.test.ts +149 -0
- package/packages/visualizer/src/explorer/recording-text.ts +490 -0
- package/packages/visualizer/src/explorer/search.ts +10 -2
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { formatTimecode, parseTagsField, parseTimecode, sourceSpread } from './collection-model';
|
|
4
|
+
|
|
5
|
+
describe('review collection helpers', () => {
|
|
6
|
+
it('reads a moment as seconds, minutes and seconds, or hours; empty is no time and a mistake is told apart', () => {
|
|
7
|
+
expect(parseTimecode('75')).toBe(75_000);
|
|
8
|
+
expect(parseTimecode('1:15')).toBe(75_000);
|
|
9
|
+
expect(parseTimecode('1:02:03')).toBe(3_723_000);
|
|
10
|
+
expect(parseTimecode('0:01.5')).toBe(1_500);
|
|
11
|
+
expect(parseTimecode(' ')).toBeNull();
|
|
12
|
+
for (const bad of ['abc', '1:xx', '-3', '1:2:3:4', '1::2']) expect(parseTimecode(bad)).toBeUndefined();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('writes a moment the way it is read back', () => {
|
|
16
|
+
expect(formatTimecode(75_000)).toBe('1:15');
|
|
17
|
+
expect(formatTimecode(3_723_000)).toBe('1:02:03');
|
|
18
|
+
expect(formatTimecode(0)).toBe('0:00');
|
|
19
|
+
for (const ms of [0, 1_000, 59_000, 3_599_000, 7_200_000]) expect(parseTimecode(formatTimecode(ms))).toBe(ms);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('counts distinct runs and layers, so entries from several executions never read as one', () => {
|
|
23
|
+
const entry = (layer: string, run: string) => ({ id: layer, source: { layer, check: 'c' }, snapshot: { run } }) as never;
|
|
24
|
+
expect(sourceSpread({ entries: [entry('a', 'r1'), entry('b', 'r2'), entry('c', 'r2')] })).toEqual({ runs: 2, layers: 3 });
|
|
25
|
+
expect(sourceSpread({ entries: [] })).toEqual({ runs: 0, layers: 0 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('splits a tags field on commas and spaces', () => {
|
|
29
|
+
expect(parseTagsField('demo, #wip demo')).toEqual(['demo', '#wip']);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Review collection model
|
|
3
|
+
*
|
|
4
|
+
* The small pure parts of the Collections tab: reading and writing the time of a note in a recording (`1:05`), how many
|
|
5
|
+
* distinct runs and layers a collection's entries come from (so the page never lets entries from several executions read
|
|
6
|
+
* as one), and the tags field. The page is `collections.tsx`.
|
|
7
|
+
*/
|
|
8
|
+
import type { ReviewCollection } from '@crvouga/atlas/reports/review-collection';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A moment in a recording as the person typed it, in milliseconds: `75` is seconds, `1:15` minutes and seconds, `1:02:03`
|
|
12
|
+
* hours too. Empty is no time (`null`, valid); anything else that is not a time is `undefined`, so a mistake is told apart
|
|
13
|
+
* from no time. Fractions of a second are kept (`1:15.5`).
|
|
14
|
+
*/
|
|
15
|
+
export function parseTimecode(raw: string): number | null | undefined {
|
|
16
|
+
const value = raw.trim();
|
|
17
|
+
if (value === '') return null;
|
|
18
|
+
const parts = value.split(':');
|
|
19
|
+
if (parts.length > 3 || parts.some((part) => !/^\d+(\.\d+)?$/.test(part))) return undefined;
|
|
20
|
+
const seconds = parts.reduce((total, part) => total * 60 + Number(part), 0);
|
|
21
|
+
return Math.round(seconds * 1000);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A moment in a recording for a person: `1:15`, or `1:02:03` from an hour. */
|
|
25
|
+
export function formatTimecode(ms: number): string {
|
|
26
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
27
|
+
const hours = Math.floor(total / 3600);
|
|
28
|
+
const minutes = Math.floor((total % 3600) / 60);
|
|
29
|
+
const seconds = total % 60;
|
|
30
|
+
const two = (value: number) => String(value).padStart(2, '0');
|
|
31
|
+
return hours > 0 ? `${hours}:${two(minutes)}:${two(seconds)}` : `${minutes}:${two(seconds)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** How many distinct runs and layers (executions) a collection's entries were recorded in. More than one run means the collection is not one execution. */
|
|
35
|
+
export function sourceSpread(collection: Pick<ReviewCollection, 'entries'>): { readonly runs: number; readonly layers: number } {
|
|
36
|
+
return {
|
|
37
|
+
runs: new Set(collection.entries.map((entry) => entry.snapshot.run)).size,
|
|
38
|
+
layers: new Set(collection.entries.map((entry) => entry.source.layer)).size
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The tags typed in a field (commas or spaces apart), without repeats; the server normalizes them. */
|
|
43
|
+
export function parseTagsField(raw: string): string[] {
|
|
44
|
+
return [...new Set(raw.split(/[\s,]+/).map((tag) => tag.trim()).filter(Boolean))];
|
|
45
|
+
}
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Collections tab
|
|
3
|
+
*
|
|
4
|
+
* Review collections: named, curated, ordered sets of recorded results frozen from a filtered view, with notes, gaps and
|
|
5
|
+
* reviewed marks. A collection is curation, not a test run: the page says so, never shows one status for the whole,
|
|
6
|
+
* lists every entry with the run, commit and status it was recorded with (so entries from several executions are not read
|
|
7
|
+
* as one), keeps failed, retried and incomplete results beside a later passing replay in the order the curator chose, and
|
|
8
|
+
* keeps an entry whose source is gone in its place with the reason. A reviewed mark and a note change no outcome and are
|
|
9
|
+
* not an approval. Writing needs a token that may publish (kept only in this tab); reading needs only what the report needs.
|
|
10
|
+
*/
|
|
11
|
+
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
12
|
+
import type { ReviewCollectionDetail, ReviewCollectionList, ReviewNote } from '@crvouga/atlas/reports/review-collection';
|
|
13
|
+
import type { ArtifactResult } from '@crvouga/atlas/reports/test-catalog';
|
|
14
|
+
import { Alert, Anchor, Badge, Button, Group, Modal, NativeSelect, Paper, PasswordInput, Stack, Switch, Table, Text, Textarea, TextInput, Title } from '@mantine/core';
|
|
15
|
+
import { IconDownload, IconLock, IconPlayerPlay } from '@tabler/icons-react';
|
|
16
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
17
|
+
import { Link } from '@tanstack/react-router';
|
|
18
|
+
import { useState, type FormEvent } from 'react';
|
|
19
|
+
|
|
20
|
+
import { readProblem } from '../evidence/artifact-tags';
|
|
21
|
+
import { reportAssetUrl, reportJson, ReportsError } from '../evidence/api';
|
|
22
|
+
import { exactTime, plural, shortSha, statusWord } from '../evidence/format';
|
|
23
|
+
import { StatusMark } from '../evidence/marks';
|
|
24
|
+
import { formatTimecode, parseTagsField, parseTimecode, sourceSpread } from './collection-model';
|
|
25
|
+
import { patched, useExplorerNavigate, useExplorerSearch } from './navigate';
|
|
26
|
+
import { runName } from './parts';
|
|
27
|
+
import { WatchPlaylist } from './playlist';
|
|
28
|
+
import { entriesFromCollection, RECORDING_STATE_TEXT, recordingState } from './playlist-entries';
|
|
29
|
+
|
|
30
|
+
/** The token that may publish, kept for as long as this page is open and nowhere else. */
|
|
31
|
+
let heldToken = '';
|
|
32
|
+
|
|
33
|
+
/** The publish token this tab holds and a way to change it. */
|
|
34
|
+
function usePublishToken(): readonly [string, (token: string) => void] {
|
|
35
|
+
const [token, setToken] = useState(heldToken);
|
|
36
|
+
return [
|
|
37
|
+
token,
|
|
38
|
+
(next) => {
|
|
39
|
+
heldToken = next;
|
|
40
|
+
setToken(next);
|
|
41
|
+
}
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Sends one change to the server as JSON; resolves with the answer, rejects with the server's reason. */
|
|
46
|
+
async function send<T>(path: string, body: unknown, token: string): Promise<T> {
|
|
47
|
+
const response = await fetch(reportAssetUrl(path), {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
50
|
+
body: JSON.stringify(body)
|
|
51
|
+
});
|
|
52
|
+
if (!response.ok) throw new Error(await readProblem(response));
|
|
53
|
+
return (await response.json()) as T;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const TokenField = ({ token, onChange }: { token: string; onChange: (token: string) => void }) => (
|
|
57
|
+
<PasswordInput
|
|
58
|
+
label="Token"
|
|
59
|
+
description="A token that may publish. Kept only in this tab. Not needed on a server without tokens."
|
|
60
|
+
value={token}
|
|
61
|
+
onChange={(event) => onChange(event.currentTarget.value)}
|
|
62
|
+
autoComplete="off"
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
/** The Collections tab: the published collections, or the one the address opens. */
|
|
67
|
+
export function CollectionsView() {
|
|
68
|
+
const search = useExplorerSearch();
|
|
69
|
+
return search.collection ? <CollectionPage id={search.collection} revision={search.rev} /> : <CollectionList />;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function CollectionList() {
|
|
73
|
+
const answer = useQuery({ queryKey: ['review-collections'], queryFn: ({ signal }) => reportJson<ReviewCollectionList>('/review-collections', signal), retry: false, staleTime: 5000 });
|
|
74
|
+
if (answer.isPending)
|
|
75
|
+
return (
|
|
76
|
+
<Text role="status" component="div" c="dimmed">
|
|
77
|
+
Reading the review collections…
|
|
78
|
+
</Text>
|
|
79
|
+
);
|
|
80
|
+
if (answer.isError) {
|
|
81
|
+
const missing = answer.error instanceof ReportsError && answer.error.status === 404;
|
|
82
|
+
return (
|
|
83
|
+
<Alert role={missing ? 'status' : 'alert'} color={missing ? 'blue' : 'red'} my="sm">
|
|
84
|
+
<Stack gap="xs" align="flex-start">
|
|
85
|
+
<Text>{missing ? 'This server does not keep review collections. Update it to publish and share curated reviews.' : `The review collections could not be read. ${answer.error.message}`}</Text>
|
|
86
|
+
{!missing && (
|
|
87
|
+
<Button type="button" onClick={() => void answer.refetch()}>
|
|
88
|
+
Try again
|
|
89
|
+
</Button>
|
|
90
|
+
)}
|
|
91
|
+
</Stack>
|
|
92
|
+
</Alert>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const { collections } = answer.data;
|
|
96
|
+
return (
|
|
97
|
+
<Stack component="section" aria-label="Review collections" gap="sm" mb="lg">
|
|
98
|
+
<Text c="dimmed">A review collection is a curated, frozen set of recordings with notes and gaps. It is not a test run and has no status of its own.</Text>
|
|
99
|
+
{collections.length === 0 ? (
|
|
100
|
+
<Text component="div" c="dimmed">
|
|
101
|
+
No review collection has been published yet. Filter the Artifacts tab to the recordings to review and choose Freeze as review collection.
|
|
102
|
+
</Text>
|
|
103
|
+
) : (
|
|
104
|
+
<Table.ScrollContainer minWidth={600}>
|
|
105
|
+
<Table data-collections>
|
|
106
|
+
<Table.Thead>
|
|
107
|
+
<Table.Tr>
|
|
108
|
+
<Table.Th scope="col">Collection</Table.Th>
|
|
109
|
+
<Table.Th scope="col">Audience</Table.Th>
|
|
110
|
+
<Table.Th scope="col">Entries</Table.Th>
|
|
111
|
+
<Table.Th scope="col">Gaps</Table.Th>
|
|
112
|
+
<Table.Th scope="col">Revision</Table.Th>
|
|
113
|
+
<Table.Th scope="col">Published</Table.Th>
|
|
114
|
+
</Table.Tr>
|
|
115
|
+
</Table.Thead>
|
|
116
|
+
<Table.Tbody>
|
|
117
|
+
{collections.map((item) => (
|
|
118
|
+
<Table.Tr key={item.id}>
|
|
119
|
+
<Table.Td>
|
|
120
|
+
<Anchor renderRoot={(props) => <Link {...props} to="/" search={patched({ tab: 'collections', collection: item.id, rev: undefined })} resetScroll={false} activeOptions={{ exact: true }} />}>{item.title}</Anchor>
|
|
121
|
+
{item.purpose && (
|
|
122
|
+
<Text c="dimmed" fz="xs">
|
|
123
|
+
{item.purpose}
|
|
124
|
+
</Text>
|
|
125
|
+
)}
|
|
126
|
+
{item.tags.length > 0 && <Text fz="xs">{item.tags.map((tag) => `#${tag}`).join(' ')}</Text>}
|
|
127
|
+
</Table.Td>
|
|
128
|
+
<Table.Td>{item.audience ?? '—'}</Table.Td>
|
|
129
|
+
<Table.Td>{item.entries}</Table.Td>
|
|
130
|
+
<Table.Td>{item.gaps}</Table.Td>
|
|
131
|
+
<Table.Td>
|
|
132
|
+
{item.revision}
|
|
133
|
+
{item.revisions > 1 ? ` of ${item.revisions}` : ''}
|
|
134
|
+
</Table.Td>
|
|
135
|
+
<Table.Td>
|
|
136
|
+
{item.createdBy}, {exactTime(item.createdAtMs)}
|
|
137
|
+
</Table.Td>
|
|
138
|
+
</Table.Tr>
|
|
139
|
+
))}
|
|
140
|
+
</Table.Tbody>
|
|
141
|
+
</Table>
|
|
142
|
+
</Table.ScrollContainer>
|
|
143
|
+
)}
|
|
144
|
+
</Stack>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const AVAILABILITY: Record<'available' | 'unverified' | 'unavailable', { label: string; color: string }> = {
|
|
149
|
+
available: { label: 'Source available', color: 'gray' },
|
|
150
|
+
unverified: { label: 'Source not verified', color: 'yellow' },
|
|
151
|
+
unavailable: { label: 'Source unavailable', color: 'yellow' }
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
function CollectionPage({ id, revision }: { id: string; revision: number | undefined }) {
|
|
155
|
+
const go = useExplorerNavigate();
|
|
156
|
+
const client = useQueryClient();
|
|
157
|
+
const [token, setToken] = usePublishToken();
|
|
158
|
+
const [watching, setWatching] = useState<string | null | false>(false);
|
|
159
|
+
const detail = useQuery({
|
|
160
|
+
queryKey: ['review-collection', id, revision ?? 'newest'],
|
|
161
|
+
queryFn: ({ signal }) => reportJson<ReviewCollectionDetail>(`/review-collections/detail?id=${encodeURIComponent(id)}${revision ? `&revision=${revision}` : ''}`, signal),
|
|
162
|
+
retry: false,
|
|
163
|
+
// How the sources stand is read each time the page opens: a recording may have been collected since.
|
|
164
|
+
staleTime: 0
|
|
165
|
+
});
|
|
166
|
+
const back = (
|
|
167
|
+
<Anchor renderRoot={(props) => <Link {...props} to="/" search={patched({ tab: 'collections', collection: undefined, rev: undefined })} resetScroll={false} activeOptions={{ exact: true }} />}>
|
|
168
|
+
All review collections
|
|
169
|
+
</Anchor>
|
|
170
|
+
);
|
|
171
|
+
if (detail.isPending)
|
|
172
|
+
return (
|
|
173
|
+
<Text role="status" component="div" c="dimmed">
|
|
174
|
+
Reading the review collection…
|
|
175
|
+
</Text>
|
|
176
|
+
);
|
|
177
|
+
if (detail.isError) {
|
|
178
|
+
const missing = detail.error instanceof ReportsError && detail.error.status === 404;
|
|
179
|
+
return (
|
|
180
|
+
<Alert role={missing ? 'status' : 'alert'} color={missing ? 'blue' : 'red'} my="sm">
|
|
181
|
+
<Stack gap="xs" align="flex-start">
|
|
182
|
+
<Text>{missing ? 'No review collection has that address, or that revision of it.' : `The review collection could not be read. ${detail.error.message}`}</Text>
|
|
183
|
+
{!missing && (
|
|
184
|
+
<Button type="button" onClick={() => void detail.refetch()}>
|
|
185
|
+
Try again
|
|
186
|
+
</Button>
|
|
187
|
+
)}
|
|
188
|
+
{back}
|
|
189
|
+
</Stack>
|
|
190
|
+
</Alert>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
const { collection, revisions, availability, reviews, reviewed } = detail.data;
|
|
194
|
+
const newest = revisions.at(-1) ?? collection.revision;
|
|
195
|
+
const spread = sourceSpread(collection);
|
|
196
|
+
const states = new Map(availability.map((item) => [item.entry, item]));
|
|
197
|
+
const playlist = entriesFromCollection(collection, availability);
|
|
198
|
+
const notesOf = (entry: string | undefined) => reviews.filter((note) => note.entry === entry && (note.kind === 'note' || note.kind === 'gap'));
|
|
199
|
+
const record = async (body: Record<string, unknown>) => {
|
|
200
|
+
await send('/review-collections/reviews', { collection: collection.id, revision: collection.revision, ...body }, token);
|
|
201
|
+
await client.invalidateQueries({ queryKey: ['review-collection', id] });
|
|
202
|
+
};
|
|
203
|
+
const exportHref = reportAssetUrl(`/review-collections/export?id=${encodeURIComponent(collection.id)}&revision=${collection.revision}`);
|
|
204
|
+
const shareLink = typeof window === 'undefined' ? '' : window.location.href;
|
|
205
|
+
return (
|
|
206
|
+
<Stack component="section" aria-label="Review collection" gap="md" mb="lg">
|
|
207
|
+
{back}
|
|
208
|
+
<Stack gap="xs">
|
|
209
|
+
<Title order={2}>{collection.title}</Title>
|
|
210
|
+
{collection.purpose && <Text>{collection.purpose}</Text>}
|
|
211
|
+
<Text c="dimmed" component="div">
|
|
212
|
+
{collection.audience ? `For ${collection.audience}. ` : ''}Published by {collection.createdBy}, {exactTime(collection.createdAtMs)}.
|
|
213
|
+
{collection.tags.length > 0 ? ` ${collection.tags.map((tag) => `#${tag}`).join(' ')}` : ''}
|
|
214
|
+
</Text>
|
|
215
|
+
<Group gap="sm" align="flex-end" data-revision={collection.revision}>
|
|
216
|
+
<Badge variant="light" leftSection={<IconLock size={14} aria-hidden="true" />}>
|
|
217
|
+
Frozen, revision {collection.revision} of {newest}
|
|
218
|
+
</Badge>
|
|
219
|
+
{revisions.length > 1 && (
|
|
220
|
+
<NativeSelect
|
|
221
|
+
label="Revision"
|
|
222
|
+
value={String(collection.revision)}
|
|
223
|
+
onChange={(event) => go({ rev: Number(event.currentTarget.value) === newest ? undefined : Number(event.currentTarget.value) })}
|
|
224
|
+
data={revisions.map((value) => ({ value: String(value), label: value === newest ? `${value} (newest)` : String(value) }))}
|
|
225
|
+
/>
|
|
226
|
+
)}
|
|
227
|
+
</Group>
|
|
228
|
+
</Stack>
|
|
229
|
+
<Alert color="blue" role="note" data-curation>
|
|
230
|
+
<Text>
|
|
231
|
+
This is a review collection, not a test run. Its {plural(collection.entries.length, 'entry', 'entries')} {collection.entries.length === 1 ? 'was' : 'were'} recorded in {plural(spread.runs, 'run', 'runs')}
|
|
232
|
+
{spread.runs > 1 ? ' and are not one execution' : ''}; each shows the status it was recorded with. Membership and order are fixed: later runs and tag changes do not alter this revision, and a change is a new revision.
|
|
233
|
+
Notes and reviewed marks are curation. They do not change any result and are not an approval.
|
|
234
|
+
{revision && revision !== newest ? ` You are reading revision ${collection.revision}; revision ${newest} is newer.` : ''}
|
|
235
|
+
</Text>
|
|
236
|
+
</Alert>
|
|
237
|
+
<Group gap="sm" align="flex-end" wrap="wrap">
|
|
238
|
+
<Button type="button" variant="filled" leftSection={<IconPlayerPlay size={16} aria-hidden="true" />} onClick={() => setWatching(null)} disabled={collection.entries.length === 0}>
|
|
239
|
+
Watch these {plural(collection.entries.length, 'entry', 'entries')}
|
|
240
|
+
</Button>
|
|
241
|
+
<Button component="a" href={exportHref} download leftSection={<IconDownload size={16} aria-hidden="true" />}>
|
|
242
|
+
Download manifest
|
|
243
|
+
</Button>
|
|
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
|
+
</Group>
|
|
246
|
+
<TokenField token={token} onChange={setToken} />
|
|
247
|
+
{watching !== false && <WatchPlaylist title={collection.title} entries={playlist} initial={watching ?? undefined} onClose={() => setWatching(false)} />}
|
|
248
|
+
{notesOf(undefined).length > 0 && (
|
|
249
|
+
<Stack gap="xs" component="section" aria-label="Notes on the collection">
|
|
250
|
+
<Title order={3}>Notes on the collection</Title>
|
|
251
|
+
{notesOf(undefined).map((note) => (
|
|
252
|
+
<NoteLine key={note.id} note={note} />
|
|
253
|
+
))}
|
|
254
|
+
</Stack>
|
|
255
|
+
)}
|
|
256
|
+
{collection.entries.length === 0 && <Text c="dimmed">This revision has no entries, only gaps.</Text>}
|
|
257
|
+
<Stack gap="md" data-entries>
|
|
258
|
+
{collection.gaps.filter((gap) => gap.after === 0).map((gap) => <GapItem key={gap.id} note={gap.note} />)}
|
|
259
|
+
{collection.entries.map((entry, index) => {
|
|
260
|
+
const playable = playlist[index]!;
|
|
261
|
+
const standing = states.get(entry.id);
|
|
262
|
+
const mark = reviewed[entry.id];
|
|
263
|
+
const notes = notesOf(entry.id);
|
|
264
|
+
const state = recordingState(playable);
|
|
265
|
+
const tries = entry.snapshot.attemptStatuses ?? [];
|
|
266
|
+
return (
|
|
267
|
+
<Stack key={entry.id} gap="md">
|
|
268
|
+
<Paper withBorder p="md" component="section" aria-label={`Entry ${index + 1}: ${entry.snapshot.title}`} data-entry={entry.id} data-status={entry.snapshot.status} data-availability={standing?.state ?? 'unverified'}>
|
|
269
|
+
<Stack gap="xs">
|
|
270
|
+
<Group justify="space-between" align="flex-start" wrap="wrap">
|
|
271
|
+
<Title order={3}>
|
|
272
|
+
{index + 1}.{' '}
|
|
273
|
+
<Anchor
|
|
274
|
+
inherit
|
|
275
|
+
renderRoot={(props) => (
|
|
276
|
+
<Link {...props} to="/" search={patched({ tab: undefined, openRun: undefined, collection: undefined, rev: undefined, test: entry.snapshot.testKey, layer: entry.source.layer })} resetScroll={false} activeOptions={{ exact: true }} />
|
|
277
|
+
)}
|
|
278
|
+
>
|
|
279
|
+
{entry.snapshot.title}
|
|
280
|
+
</Anchor>
|
|
281
|
+
</Title>
|
|
282
|
+
<Group gap="xs">
|
|
283
|
+
<Text span>
|
|
284
|
+
<StatusMark status={entry.snapshot.status} size={14} /> {statusWord(entry.snapshot.status)} as recorded
|
|
285
|
+
</Text>
|
|
286
|
+
<Badge variant="outline" color={AVAILABILITY[standing?.state ?? 'unverified'].color}>
|
|
287
|
+
{AVAILABILITY[standing?.state ?? 'unverified'].label}
|
|
288
|
+
</Badge>
|
|
289
|
+
</Group>
|
|
290
|
+
</Group>
|
|
291
|
+
{entry.caption && <Text>{entry.caption}</Text>}
|
|
292
|
+
<Text c="dimmed" fz="sm" component="div">
|
|
293
|
+
Recorded {exactTime(entry.snapshot.recordedAtMs)} · {environmentLabel(entry.snapshot.environment)} · {entry.snapshot.producer} · run {runName(entry.snapshot.run)}
|
|
294
|
+
{entry.snapshot.sha ? ` · commit ${shortSha(entry.snapshot.sha)}` : ''}
|
|
295
|
+
{entry.snapshot.branch ? ` · ${entry.snapshot.branch}` : ''} · layer {entry.source.layer.slice(0, 12)}
|
|
296
|
+
</Text>
|
|
297
|
+
{tries.length > 0 && (
|
|
298
|
+
<Text fz="sm" component="div" data-tries>
|
|
299
|
+
Tries, in order: {tries.map((status) => statusWord(status)).join(', then ')}
|
|
300
|
+
</Text>
|
|
301
|
+
)}
|
|
302
|
+
{standing && standing.state !== 'available' && (
|
|
303
|
+
<Alert color="yellow" role="status" title={standing.state === 'unavailable' ? 'This source is no longer available' : 'This source could not be verified'}>
|
|
304
|
+
{standing.reason} What it was when frozen is kept here.
|
|
305
|
+
</Alert>
|
|
306
|
+
)}
|
|
307
|
+
<Group gap="sm" align="center">
|
|
308
|
+
<Text span fz="sm" c="dimmed">
|
|
309
|
+
{RECORDING_STATE_TEXT[state].label}
|
|
310
|
+
</Text>
|
|
311
|
+
{state === 'playable' && (
|
|
312
|
+
<Button type="button" size="compact-sm" variant="light" leftSection={<IconPlayerPlay size={14} aria-hidden="true" />} onClick={() => setWatching(entry.id)}>
|
|
313
|
+
Watch entry {index + 1}
|
|
314
|
+
</Button>
|
|
315
|
+
)}
|
|
316
|
+
</Group>
|
|
317
|
+
<ReviewControls
|
|
318
|
+
entry={entry.id}
|
|
319
|
+
label={`entry ${index + 1}`}
|
|
320
|
+
reviewed={mark}
|
|
321
|
+
notes={notes}
|
|
322
|
+
onMark={(on) => record({ entry: entry.id, kind: on ? 'reviewed' : 'unreviewed' })}
|
|
323
|
+
onNote={(body) => record({ entry: entry.id, ...body })}
|
|
324
|
+
/>
|
|
325
|
+
</Stack>
|
|
326
|
+
</Paper>
|
|
327
|
+
{collection.gaps.filter((gap) => gap.after === index + 1).map((gap) => <GapItem key={gap.id} note={gap.note} />)}
|
|
328
|
+
</Stack>
|
|
329
|
+
);
|
|
330
|
+
})}
|
|
331
|
+
</Stack>
|
|
332
|
+
</Stack>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const GapItem = ({ note }: { note: string }) => (
|
|
337
|
+
<Alert color="gray" title="Gap" data-gap role="note">
|
|
338
|
+
{note}
|
|
339
|
+
</Alert>
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
function NoteLine({ note }: { note: ReviewNote }) {
|
|
343
|
+
return (
|
|
344
|
+
<Text fz="sm" component="div" data-note={note.kind}>
|
|
345
|
+
<Badge size="sm" variant="light" color={note.kind === 'gap' ? 'orange' : 'gray'} mr="xs">
|
|
346
|
+
{note.kind === 'gap' ? 'Gap' : 'Note'}
|
|
347
|
+
</Badge>
|
|
348
|
+
{note.timecodeMs !== undefined && <Text span fw={600}>{formatTimecode(note.timecodeMs)} </Text>}
|
|
349
|
+
{note.text}{' '}
|
|
350
|
+
<Text span c="dimmed" inherit>
|
|
351
|
+
{note.by}, {exactTime(note.atMs)}
|
|
352
|
+
</Text>
|
|
353
|
+
</Text>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** The reviewed mark, the notes of one entry and the form for a new one. Each change is a new record with who and when. */
|
|
358
|
+
function ReviewControls({
|
|
359
|
+
entry: _entry,
|
|
360
|
+
label,
|
|
361
|
+
reviewed,
|
|
362
|
+
notes,
|
|
363
|
+
onMark,
|
|
364
|
+
onNote
|
|
365
|
+
}: {
|
|
366
|
+
entry: string;
|
|
367
|
+
/** What a person calls it: `entry 2`. */
|
|
368
|
+
label: string;
|
|
369
|
+
reviewed: { by: string; atMs: number } | undefined;
|
|
370
|
+
notes: readonly ReviewNote[];
|
|
371
|
+
onMark: (on: boolean) => Promise<unknown>;
|
|
372
|
+
onNote: (body: { kind: 'note' | 'gap'; text: string; timecodeMs?: number }) => Promise<unknown>;
|
|
373
|
+
}) {
|
|
374
|
+
const [text, setText] = useState('');
|
|
375
|
+
const [time, setTime] = useState('');
|
|
376
|
+
const [gap, setGap] = useState(false);
|
|
377
|
+
const [busy, setBusy] = useState(false);
|
|
378
|
+
const [problem, setProblem] = useState<string | null>(null);
|
|
379
|
+
const timecode = parseTimecode(time);
|
|
380
|
+
const run = async (work: () => Promise<unknown>) => {
|
|
381
|
+
setBusy(true);
|
|
382
|
+
setProblem(null);
|
|
383
|
+
try {
|
|
384
|
+
await work();
|
|
385
|
+
return true;
|
|
386
|
+
} catch (error) {
|
|
387
|
+
setProblem(error instanceof Error ? error.message : String(error));
|
|
388
|
+
return false;
|
|
389
|
+
} finally {
|
|
390
|
+
setBusy(false);
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
const submit = async (event: FormEvent) => {
|
|
394
|
+
event.preventDefault();
|
|
395
|
+
if (timecode === undefined || !text.trim()) return;
|
|
396
|
+
if (await run(() => onNote({ kind: gap ? 'gap' : 'note', text, ...(timecode === null ? {} : { timecodeMs: timecode }) }))) {
|
|
397
|
+
setText('');
|
|
398
|
+
setTime('');
|
|
399
|
+
setGap(false);
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
return (
|
|
403
|
+
<Stack gap="xs" role="group" aria-label={`Review of ${label}`}>
|
|
404
|
+
<Switch
|
|
405
|
+
label="Reviewed"
|
|
406
|
+
description={reviewed ? `Marked reviewed by ${reviewed.by}, ${exactTime(reviewed.atMs)}. This is curation, not an outcome or an approval.` : 'Not marked reviewed. A mark is curation, not an outcome or an approval.'}
|
|
407
|
+
checked={!!reviewed}
|
|
408
|
+
disabled={busy}
|
|
409
|
+
onChange={(event) => void run(() => onMark(event.currentTarget.checked))}
|
|
410
|
+
/>
|
|
411
|
+
{notes.map((note) => (
|
|
412
|
+
<NoteLine key={note.id} note={note} />
|
|
413
|
+
))}
|
|
414
|
+
<form onSubmit={(event) => void submit(event)} aria-label={`Add a note to ${label}`}>
|
|
415
|
+
<Group align="flex-end" gap="sm" wrap="wrap">
|
|
416
|
+
<Textarea label="Note" value={text} onChange={(event) => setText(event.currentTarget.value)} autosize minRows={1} maxLength={4000} miw={240} />
|
|
417
|
+
<TextInput
|
|
418
|
+
label="Time in the recording"
|
|
419
|
+
description="m:ss, optional"
|
|
420
|
+
value={time}
|
|
421
|
+
onChange={(event) => setTime(event.currentTarget.value)}
|
|
422
|
+
error={timecode === undefined ? 'Use m:ss, like 1:15, or seconds.' : undefined}
|
|
423
|
+
w={160}
|
|
424
|
+
/>
|
|
425
|
+
<Switch label="This is a gap" description="Something missing or unclear" checked={gap} onChange={(event) => setGap(event.currentTarget.checked)} />
|
|
426
|
+
<Button type="submit" loading={busy} disabled={!text.trim() || timecode === undefined}>
|
|
427
|
+
Add note
|
|
428
|
+
</Button>
|
|
429
|
+
</Group>
|
|
430
|
+
</form>
|
|
431
|
+
{problem && (
|
|
432
|
+
<Alert role="alert" color="red">
|
|
433
|
+
{problem}
|
|
434
|
+
</Alert>
|
|
435
|
+
)}
|
|
436
|
+
</Stack>
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The dialog that freezes the results of a filtered view as a review collection: a title, purpose, audience and tags, and
|
|
442
|
+
* the token that may publish. The entries are the results as the page lists them, in that order, from whichever runs they
|
|
443
|
+
* were recorded in. Resolves to the new collection's address by opening it.
|
|
444
|
+
*/
|
|
445
|
+
export function FreezeCollection({ results, total, filterQuery, onClose }: { results: readonly ArtifactResult[]; total: number; filterQuery: string; onClose: () => void }) {
|
|
446
|
+
const go = useExplorerNavigate();
|
|
447
|
+
const client = useQueryClient();
|
|
448
|
+
const [token, setToken] = usePublishToken();
|
|
449
|
+
const [title, setTitle] = useState('');
|
|
450
|
+
const [purpose, setPurpose] = useState('');
|
|
451
|
+
const [audience, setAudience] = useState('');
|
|
452
|
+
const [tags, setTags] = useState('');
|
|
453
|
+
const [busy, setBusy] = useState(false);
|
|
454
|
+
const [problem, setProblem] = useState<string | null>(null);
|
|
455
|
+
const runs = new Set(results.map((result) => result.run)).size;
|
|
456
|
+
const submit = async (event: FormEvent) => {
|
|
457
|
+
event.preventDefault();
|
|
458
|
+
if (!title.trim()) return;
|
|
459
|
+
setBusy(true);
|
|
460
|
+
setProblem(null);
|
|
461
|
+
try {
|
|
462
|
+
const made = await send<{ id: string }>(
|
|
463
|
+
'/review-collections',
|
|
464
|
+
{
|
|
465
|
+
title,
|
|
466
|
+
...(purpose.trim() ? { purpose } : {}),
|
|
467
|
+
...(audience.trim() ? { audience } : {}),
|
|
468
|
+
tags: parseTagsField(tags),
|
|
469
|
+
...(filterQuery ? { frozenFrom: filterQuery } : {}),
|
|
470
|
+
entries: results.map((result) => ({ layer: result.layer, check: result.id, kind: result.kind }))
|
|
471
|
+
},
|
|
472
|
+
token
|
|
473
|
+
);
|
|
474
|
+
await client.invalidateQueries({ queryKey: ['review-collections'] });
|
|
475
|
+
onClose();
|
|
476
|
+
go({ tab: 'collections', collection: made.id, rev: undefined, test: undefined, openRun: undefined, layer: undefined });
|
|
477
|
+
} catch (error) {
|
|
478
|
+
setProblem(error instanceof Error ? error.message : String(error));
|
|
479
|
+
} finally {
|
|
480
|
+
setBusy(false);
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
return (
|
|
484
|
+
<Modal opened onClose={onClose} title="Freeze as review collection" size="lg" centered>
|
|
485
|
+
<form onSubmit={(event) => void submit(event)} aria-label="Freeze as review collection">
|
|
486
|
+
<Stack gap="sm">
|
|
487
|
+
<Text role="status" component="div" data-freeze-summary>
|
|
488
|
+
{plural(results.length, 'result', 'results')} from {plural(runs, 'run', 'runs')}, in the order listed
|
|
489
|
+
{total > results.length ? ` (the newest of ${total.toLocaleString()} matching)` : ''}. Failed and incomplete results stay in. Later runs and tag changes will not alter the collection; a change is a new revision.
|
|
490
|
+
</Text>
|
|
491
|
+
<TextInput label="Title" required value={title} onChange={(event) => setTitle(event.currentTarget.value)} maxLength={200} data-autofocus />
|
|
492
|
+
<Textarea label="Purpose" description="Why this review exists" value={purpose} onChange={(event) => setPurpose(event.currentTarget.value)} autosize minRows={2} maxLength={2000} />
|
|
493
|
+
<TextInput label="Audience" description="Who should review it" value={audience} onChange={(event) => setAudience(event.currentTarget.value)} maxLength={500} />
|
|
494
|
+
<TextInput label="Tags" description="Such as demo, separated by commas" value={tags} onChange={(event) => setTags(event.currentTarget.value)} />
|
|
495
|
+
<TokenField token={token} onChange={setToken} />
|
|
496
|
+
{problem && (
|
|
497
|
+
<Alert role="alert" color="red">
|
|
498
|
+
{problem}
|
|
499
|
+
</Alert>
|
|
500
|
+
)}
|
|
501
|
+
<Group justify="flex-end">
|
|
502
|
+
<Button type="button" variant="default" onClick={onClose}>
|
|
503
|
+
Cancel
|
|
504
|
+
</Button>
|
|
505
|
+
<Button type="submit" variant="filled" loading={busy} disabled={!title.trim() || results.length === 0}>
|
|
506
|
+
Freeze {plural(results.length, 'result', 'results')}
|
|
507
|
+
</Button>
|
|
508
|
+
</Group>
|
|
509
|
+
</Stack>
|
|
510
|
+
</form>
|
|
511
|
+
</Modal>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { VideoRef } from '@crvouga/atlas/reports/evidence-types';
|
|
2
|
+
import { expect, test } from 'bun:test';
|
|
3
|
+
|
|
4
|
+
import { entriesFromRun, playlistCounts, recordingState, stepFrom, type PlaylistEntry } from './playlist-entries';
|
|
5
|
+
|
|
6
|
+
const video = (recording?: string): VideoRef => ({ name: 'a.webm', owner: 'check', available: recording !== undefined, ...(recording ? { recording } : {}) });
|
|
7
|
+
const entry = (id: string, extra: Partial<PlaylistEntry> = {}): PlaylistEntry => ({
|
|
8
|
+
id, title: id, key: id, specPath: null, status: 'passed', environment: 'desktop-web', producer: 'web', at: 1, layer: 'l', backend: 'local', check: id, kind: null, tags: [], videos: [], ...extra
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('a recording state never says more than what is stored', () => {
|
|
12
|
+
expect(recordingState(entry('a', { videos: [video('sha256:1')] }))).toBe('playable');
|
|
13
|
+
expect(recordingState(entry('a', { videos: [video()] }))).toBe('unplayable');
|
|
14
|
+
// Bytes exist, but nothing says which backend to read them from.
|
|
15
|
+
expect(recordingState(entry('a', { videos: [video('sha256:1')], backend: null }))).toBe('unplayable');
|
|
16
|
+
expect(recordingState(entry('a', { environment: 'api-rest' }))).toBe('api-only');
|
|
17
|
+
expect(recordingState(entry('a', { environment: null }))).toBe('none');
|
|
18
|
+
expect(recordingState(entry('a'))).toBe('none');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('counts keep failures and gaps visible', () => {
|
|
22
|
+
const counts = playlistCounts([
|
|
23
|
+
entry('a', { videos: [video('sha256:1')] }),
|
|
24
|
+
entry('b', { status: 'failed', videos: [video('sha256:2')] }),
|
|
25
|
+
entry('c', { status: 'failed' }),
|
|
26
|
+
entry('d', { environment: 'api' })
|
|
27
|
+
]);
|
|
28
|
+
expect(counts).toEqual({ playable: 2, unplayable: 0, 'api-only': 1, none: 1, failed: 2 });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('previous and next walk the watchable entries, skip gaps, wrap only when asked and work from a gap', () => {
|
|
32
|
+
const play = { videos: [video('sha256:1')] };
|
|
33
|
+
const list = [entry('a', play), entry('gap'), entry('b', play), entry('c', play)];
|
|
34
|
+
expect(stepFrom(list, 'a', 1, false)?.id).toBe('b');
|
|
35
|
+
expect(stepFrom(list, 'c', 1, false)).toBeNull();
|
|
36
|
+
expect(stepFrom(list, 'c', 1, true)?.id).toBe('a');
|
|
37
|
+
expect(stepFrom(list, 'a', -1, false)).toBeNull();
|
|
38
|
+
expect(stepFrom(list, 'a', -1, true)?.id).toBe('c');
|
|
39
|
+
expect(stepFrom(list, 'gap', 1, false)?.id).toBe('b');
|
|
40
|
+
expect(stepFrom(list, 'gap', -1, false)?.id).toBe('a');
|
|
41
|
+
expect(stepFrom([list[0]!], 'a', 1, true)?.id).toBe('a');
|
|
42
|
+
expect(stepFrom([entry('gap')], 'gap', 1, true)).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('a run result gets its backend from the layers of the run, and none when they are not known', () => {
|
|
46
|
+
const result = { key: 'k', id: 'c', title: 'T', specPath: null, environment: 'ios', producer: 'p', status: 'failed' as const, durationMs: null, at: 1, layer: 'abc', detail: '', videos: [{ ...video('sha256:1'), tags: ['demo'] }] };
|
|
47
|
+
const layers = [{ id: 'abc', backend: 'shared', producer: 'p', environment: 'ios', recordedAtMs: 1, checks: 1, attachments: 0 }];
|
|
48
|
+
expect(entriesFromRun([result], layers)[0]).toMatchObject({ backend: 'shared', check: 'c', status: 'failed', tags: ['demo'] });
|
|
49
|
+
expect(entriesFromRun([result], undefined)[0]!.backend).toBeNull();
|
|
50
|
+
expect(recordingState(entriesFromRun([result], undefined)[0]!)).toBe('unplayable');
|
|
51
|
+
});
|