@crvouga/atlas 0.9.0 → 0.10.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/playwright-reporter/src/evidence.d.ts +122 -0
- package/dist/playwright-reporter/src/evidence.js +327 -0
- package/dist/playwright-reporter/src/index.d.ts +114 -0
- package/dist/playwright-reporter/src/index.js +312 -0
- package/dist/reports/src/compose.js +7 -14
- package/dist/reports/src/recorded-specs.d.ts +33 -0
- package/dist/reports/src/recorded-specs.js +42 -0
- package/dist/reports/src/report-index.d.ts +6 -0
- package/dist/reports/src/report-index.js +14 -2
- package/dist/reports/src/test-catalog.d.ts +386 -0
- package/dist/reports/src/test-catalog.js +655 -0
- package/dist/schema/src/api.d.ts +585 -0
- package/dist/schema/src/api.js +105 -0
- package/dist/server/src/app.js +82 -4
- package/dist/server/src/llms.js +14 -0
- package/dist/server/src/openapi.d.ts +1 -1
- package/dist/server/src/openapi.js +60 -3
- package/docs/agents.md +1 -0
- package/docs/integrating.md +1 -0
- package/docs/reference/http-api.md +134 -2
- package/docs/reference/playwright-reporter.md +332 -0
- package/docs/reference/reports.md +632 -0
- package/docs/reference/schema.md +57 -0
- package/docs/rich-evidence.md +198 -0
- package/docs/server.md +41 -0
- package/llms-full.txt +378 -2
- package/llms.txt +2 -0
- package/package.json +6 -1
- package/packages/schema/schemas/report-run.schema.json +251 -0
- package/packages/schema/schemas/report-runs.schema.json +177 -0
- package/packages/schema/schemas/test-catalog.schema.json +1177 -0
- package/packages/schema/schemas/test-history.schema.json +696 -0
- package/packages/server/openapi.json +8676 -5351
- package/packages/visualizer/src/app/AppShell.tsx +7 -3
- package/packages/visualizer/src/app/router.tsx +26 -3
- package/packages/visualizer/src/components/Header.tsx +2 -2
- package/packages/visualizer/src/evidence/evidence-context.tsx +10 -1
- package/packages/visualizer/src/explorer/Explorer.tsx +433 -0
- package/packages/visualizer/src/explorer/ExplorerIndex.tsx +120 -0
- package/packages/visualizer/src/explorer/RunsView.tsx +164 -0
- package/packages/visualizer/src/explorer/TestDetail.tsx +214 -0
- package/packages/visualizer/src/explorer/catalog.tsx +274 -0
- package/packages/visualizer/src/explorer/explorer.module.css +1238 -0
- package/packages/visualizer/src/explorer/groups.test.ts +87 -0
- package/packages/visualizer/src/explorer/groups.ts +126 -0
- package/packages/visualizer/src/explorer/navigate.ts +36 -0
- package/packages/visualizer/src/explorer/parts.tsx +59 -0
- package/packages/visualizer/src/explorer/search.ts +97 -0
- package/packages/visualizer/src/find/Finder.tsx +2 -0
- package/packages/visualizer/src/find/corpus.ts +13 -6
- package/packages/visualizer/src/find/rank.ts +2 -0
- package/packages/visualizer/src/evidence/project-home.tsx +0 -45
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Test catalog source
|
|
3
|
+
*
|
|
4
|
+
* One way for the explorer to read the tests, wherever the viewer runs:
|
|
5
|
+
*
|
|
6
|
+
* - On an Atlas server (the effective report) it asks the server, which holds every layer's history:
|
|
7
|
+
* `GET {reports}/tests`, `/tests/history`, `/runs` and `/runs/detail`. The catalog is read whole (`limit=20000`)
|
|
8
|
+
* once per report version, so filtering and searching happen in the page, as you type.
|
|
9
|
+
* - Anywhere else (local folders, a static site), or while the server is starting, or when it is an older one without
|
|
10
|
+
* those routes, the same documents are built here from the layers the viewer already read (`TestHistory` and
|
|
11
|
+
* `catalogModel` from `@crvouga/atlas/reports/test-catalog`).
|
|
12
|
+
*
|
|
13
|
+
* The UI only sees {@link useTestCatalog}, {@link useTestHistory}, {@link useRunList} and {@link useRunDetail}.
|
|
14
|
+
* Tests a producer reports as running right now (live progress) are shown as running on top of either source.
|
|
15
|
+
*/
|
|
16
|
+
import type { EvidenceStatus } from '@crvouga/atlas/reports/explorer-types';
|
|
17
|
+
import type { LayerEvidence, LayerRows, StatusCounts } from '@crvouga/atlas/reports/evidence-types';
|
|
18
|
+
import type { CheckSlot, EvidenceView } from '@crvouga/atlas/reports/evidence-view';
|
|
19
|
+
import {
|
|
20
|
+
catalogModel,
|
|
21
|
+
haystackOf,
|
|
22
|
+
runDetail,
|
|
23
|
+
runList,
|
|
24
|
+
testHistory,
|
|
25
|
+
TestHistory,
|
|
26
|
+
type CatalogModel,
|
|
27
|
+
type RunDetail,
|
|
28
|
+
type RunList,
|
|
29
|
+
type TestCatalog,
|
|
30
|
+
type TestHistoryDocument,
|
|
31
|
+
type TestSummary
|
|
32
|
+
} from '@crvouga/atlas/reports/test-catalog';
|
|
33
|
+
import { isStatechartFile } from '@crvouga/atlas/schema';
|
|
34
|
+
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
|
35
|
+
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
36
|
+
|
|
37
|
+
import { reportJson, ReportsError } from '../evidence/api';
|
|
38
|
+
import { useEvidence } from '../evidence/evidence-context';
|
|
39
|
+
|
|
40
|
+
/** How many runs the Runs tab lists. */
|
|
41
|
+
export const RUNS_SHOWN = 500;
|
|
42
|
+
/** The server's catalog is read again at most this often while results keep arriving. */
|
|
43
|
+
const SERVER_REREAD_MS = 5_000;
|
|
44
|
+
/** Layers read one after another are folded into the page's catalog at most this often. */
|
|
45
|
+
const PAGE_REBUILD_MS = 300;
|
|
46
|
+
|
|
47
|
+
/** What the explorer reads its tests from. */
|
|
48
|
+
export type CatalogState = {
|
|
49
|
+
/** Every test, with live progress applied; null until there is something to show. */
|
|
50
|
+
readonly model: CatalogModel | null;
|
|
51
|
+
/** `server`: the server's catalog and history. `page`: built from the layers this page read. */
|
|
52
|
+
readonly source: 'server' | 'page';
|
|
53
|
+
/** Changes whenever the catalog does; part of every query key that depends on it. */
|
|
54
|
+
readonly version: string;
|
|
55
|
+
/** Why the server's catalog could not be read, when it could not (the page's own is shown meanwhile). */
|
|
56
|
+
readonly notice: string | null;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const CatalogContext = createContext<CatalogState | null>(null);
|
|
60
|
+
|
|
61
|
+
/** The tests of the report, from the server or from the page. Needs a {@link TestCatalogProvider}. */
|
|
62
|
+
export function useTestCatalog(): CatalogState {
|
|
63
|
+
const state = useContext(CatalogContext);
|
|
64
|
+
if (!state) throw new Error('useTestCatalog needs a TestCatalogProvider');
|
|
65
|
+
return state;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A value that follows `value`, at most once per `ms` (always ending on the newest). */
|
|
69
|
+
function useThrottled<T>(value: T, ms: number): T {
|
|
70
|
+
const [held, setHeld] = useState(value);
|
|
71
|
+
const last = useRef(0);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (Object.is(held, value)) return;
|
|
74
|
+
const wait = Math.max(0, last.current + ms - Date.now());
|
|
75
|
+
const timer = setTimeout(() => {
|
|
76
|
+
last.current = Date.now();
|
|
77
|
+
setHeld(value);
|
|
78
|
+
}, wait);
|
|
79
|
+
return () => clearTimeout(timer);
|
|
80
|
+
}, [value, held, ms]);
|
|
81
|
+
return held;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A server's catalog document as a model the page can query (`matchesQuery`, `testCatalog`). It has no history of its own. */
|
|
85
|
+
export function modelFromCatalog(catalog: TestCatalog): CatalogModel {
|
|
86
|
+
return {
|
|
87
|
+
version: catalog.version,
|
|
88
|
+
generatedAtMs: catalog.generatedAtMs,
|
|
89
|
+
tests: catalog.tests,
|
|
90
|
+
byKey: new Map(catalog.tests.map((test) => [test.key, test])),
|
|
91
|
+
summary: catalog.summary,
|
|
92
|
+
facets: catalog.facets,
|
|
93
|
+
haystacks: new Map(catalog.tests.map((test) => [test.key, haystackOf(test)])),
|
|
94
|
+
history: null
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function allSlots(view: EvidenceView): CheckSlot[] {
|
|
99
|
+
return [...[...view.specs.values()].flatMap((entry) => entry.slots), ...view.unassigned];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function liveOnly(slot: CheckSlot, model: CatalogModel): TestSummary {
|
|
103
|
+
const spec = slot.specPath ? model.facets.specs.find((entry) => entry.path === slot.specPath) ?? null : null;
|
|
104
|
+
return {
|
|
105
|
+
key: slot.key, id: slot.id, title: slot.title, specPath: slot.specPath,
|
|
106
|
+
spec: spec ? { path: spec.path, title: spec.title, domain: spec.domain, kind: spec.kind } : null,
|
|
107
|
+
status: slot.status, environments: [], producers: [], outcomes: [],
|
|
108
|
+
stats: { results: 0, passed: 0, failed: 0, passRate: null, flaky: false, flakyReason: '', flips: 0, durations: null, firstSeenMs: null, lastSeenMs: null, lastPassedAtMs: null, lastFailedAtMs: null, runs: 0, branches: [] },
|
|
109
|
+
tags: [], labels: {}, evidence: { attachments: 0, steps: 0, attempts: 0, roles: [] }, lastRecordedAtMs: slot.latestAtMs, live: slot.live
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The model with live progress applied: a test a producer is running right now shows as running (and one that only
|
|
115
|
+
* exists as live progress is listed), as the rest of the viewer shows it. Recorded results are not changed.
|
|
116
|
+
*/
|
|
117
|
+
export function withLiveProgress(model: CatalogModel, view: EvidenceView): CatalogModel {
|
|
118
|
+
const changed = new Map<string, TestSummary>();
|
|
119
|
+
const added: TestSummary[] = [];
|
|
120
|
+
for (const slot of allSlots(view)) {
|
|
121
|
+
const test = model.byKey.get(slot.key);
|
|
122
|
+
if (!test) {
|
|
123
|
+
if (!slot.outcomes.length && (slot.status === 'running' || slot.live)) added.push(liveOnly(slot, model));
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if ((slot.status === 'running') !== (test.status === 'running') || slot.live !== test.live) {
|
|
127
|
+
// Live progress can only say "running now" or that a run was cut short; a recorded result is never rewritten otherwise.
|
|
128
|
+
if (slot.status === 'running' || test.status === 'running') changed.set(test.key, { ...test, status: slot.status === 'running' ? 'running' : slot.status, live: slot.live });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (!changed.size && !added.length) return model;
|
|
132
|
+
const tests = [...model.tests.map((test) => changed.get(test.key) ?? test), ...added];
|
|
133
|
+
const counts: Record<EvidenceStatus, number> = { passed: 0, failed: 0, running: 0, 'not-run': 0 };
|
|
134
|
+
for (const test of tests) counts[test.status] += 1;
|
|
135
|
+
return {
|
|
136
|
+
...model,
|
|
137
|
+
tests,
|
|
138
|
+
byKey: new Map(tests.map((test) => [test.key, test])),
|
|
139
|
+
haystacks: added.length ? new Map([...model.haystacks, ...added.map((test) => [test.key, haystackOf(test)] as const)]) : model.haystacks,
|
|
140
|
+
summary: { ...model.summary, tests: tests.length, counts: counts as StatusCounts }
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
type PageHistory = { readonly history: TestHistory; readonly stamp: string };
|
|
145
|
+
let generation = 0;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The history of the layers this page read. Layers that arrive later are added to the same history; when a layer
|
|
149
|
+
* was dropped (a source left out) or re-read (a running suite), it is built again.
|
|
150
|
+
*/
|
|
151
|
+
function usePageHistory(layers: readonly LayerEvidence[] | null): PageHistory | null {
|
|
152
|
+
const held = useRef<{ history: TestHistory; rows: Map<string, LayerRows['rows']>; gen: number } | null>(null);
|
|
153
|
+
return useMemo(() => {
|
|
154
|
+
if (!layers) return null;
|
|
155
|
+
let current = held.current;
|
|
156
|
+
const next = new Map(layers.map((entry) => [`${entry.layer.backend}|${entry.layer.id}`, entry.rows]));
|
|
157
|
+
const keeps = current !== null && [...current.rows].every(([key, rows]) => next.get(key) === rows);
|
|
158
|
+
if (!current || !keeps) {
|
|
159
|
+
current = { history: new TestHistory(), rows: new Map(), gen: ++generation };
|
|
160
|
+
held.current = current;
|
|
161
|
+
}
|
|
162
|
+
for (const entry of layers) {
|
|
163
|
+
const key = `${entry.layer.backend}|${entry.layer.id}`;
|
|
164
|
+
if (current.rows.has(key)) continue;
|
|
165
|
+
current.rows.set(key, entry.rows);
|
|
166
|
+
current.history.add(entry.layer, entry.rows, [], entry.layer.backend);
|
|
167
|
+
}
|
|
168
|
+
return { history: current.history, stamp: `page:${current.gen}:${current.history.rev}` };
|
|
169
|
+
}, [layers]);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Reads the catalog for everything below it: from the server when it has one, else from the page. */
|
|
173
|
+
export function TestCatalogProvider({ children }: { children: ReactNode }) {
|
|
174
|
+
const evidence = useEvidence();
|
|
175
|
+
const enabled = evidence.ready && !evidence.files.some(isStatechartFile);
|
|
176
|
+
const version = useThrottled(evidence.version, SERVER_REREAD_MS);
|
|
177
|
+
// A server without the catalog routes (an older one) answers 404: stop asking until the page is reloaded.
|
|
178
|
+
const [unsupported, setUnsupported] = useState(false);
|
|
179
|
+
const server = useQuery({
|
|
180
|
+
queryKey: ['test-catalog', version],
|
|
181
|
+
queryFn: ({ signal }) => reportJson<TestCatalog>('/tests?limit=20000', signal),
|
|
182
|
+
enabled: enabled && evidence.effective && version !== null && !unsupported,
|
|
183
|
+
placeholderData: keepPreviousData,
|
|
184
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
185
|
+
// A server still starting answers 503: ask again shortly. Any other answer (an older server has no such route) is final.
|
|
186
|
+
retry: (count, error) => error instanceof ReportsError && error.status === 503 && count < 30,
|
|
187
|
+
retryDelay: 2_000
|
|
188
|
+
});
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (server.error instanceof ReportsError && server.error.status === 404) setUnsupported(true);
|
|
191
|
+
}, [server.error]);
|
|
192
|
+
const fromServer = evidence.effective && !unsupported && server.data !== undefined;
|
|
193
|
+
const layers = useThrottled(evidence.evidence, PAGE_REBUILD_MS);
|
|
194
|
+
const page = usePageHistory(enabled && !fromServer ? layers : null);
|
|
195
|
+
const serverModel = useMemo(() => (server.data ? modelFromCatalog(server.data) : null), [server.data]);
|
|
196
|
+
const pageModel = useMemo(
|
|
197
|
+
() => (page ? catalogModel(layers, { history: page.history, specs: evidence.specs, version: page.stamp }) : null),
|
|
198
|
+
// `page.stamp` changes exactly when the history (and so the layers in it) does.
|
|
199
|
+
[page?.stamp, evidence.specs]
|
|
200
|
+
);
|
|
201
|
+
const base = fromServer ? serverModel : pageModel;
|
|
202
|
+
const model = useMemo(() => (base ? withLiveProgress(base, evidence.view) : null), [base, evidence.view]);
|
|
203
|
+
const state: CatalogState = {
|
|
204
|
+
model,
|
|
205
|
+
source: fromServer ? 'server' : 'page',
|
|
206
|
+
version: fromServer ? `server:${server.data!.version}:${version ?? ''}` : page?.stamp ?? 'none',
|
|
207
|
+
notice: evidence.effective && server.isError && !(server.error instanceof ReportsError && server.error.status === 404) ? server.error.message : null
|
|
208
|
+
};
|
|
209
|
+
return <CatalogContext.Provider value={state}>{children}</CatalogContext.Provider>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const notFound = (error: unknown) => error instanceof ReportsError && error.status === 404;
|
|
213
|
+
|
|
214
|
+
/** One test and its recorded results (newest first), or null when the report has no such test. */
|
|
215
|
+
export function useTestHistory(key: string | undefined) {
|
|
216
|
+
const catalog = useTestCatalog();
|
|
217
|
+
return useQuery({
|
|
218
|
+
queryKey: ['test-history', catalog.version, key ?? ''],
|
|
219
|
+
queryFn: async ({ signal }): Promise<TestHistoryDocument | null> => {
|
|
220
|
+
const model = catalog.model!;
|
|
221
|
+
if (catalog.source === 'page') return testHistory(model, key!);
|
|
222
|
+
try {
|
|
223
|
+
return await reportJson<TestHistoryDocument>(`/tests/history?key=${encodeURIComponent(key!)}`, signal);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
// A test only seen as live progress has no recorded history on the server yet.
|
|
226
|
+
if (notFound(error)) return testHistory(model, key!);
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
enabled: !!key && catalog.model !== null,
|
|
231
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
232
|
+
retry: false
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** CI runs, newest first. */
|
|
237
|
+
export function useRunList(enabled = true) {
|
|
238
|
+
const catalog = useTestCatalog();
|
|
239
|
+
return useQuery({
|
|
240
|
+
queryKey: ['test-runs', catalog.version],
|
|
241
|
+
queryFn: ({ signal }): Promise<RunList> | RunList => {
|
|
242
|
+
if (catalog.source === 'server') return reportJson<RunList>(`/runs?limit=${RUNS_SHOWN}`, signal);
|
|
243
|
+
const history = catalog.model?.history;
|
|
244
|
+
return history ? runList(history, catalog.version, RUNS_SHOWN) : { schemaVersion: 1, version: catalog.version, total: 0, runs: [] };
|
|
245
|
+
},
|
|
246
|
+
enabled: enabled && catalog.model !== null,
|
|
247
|
+
placeholderData: keepPreviousData,
|
|
248
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
249
|
+
retry: false
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** One CI run and every result it recorded, or null when no run has that key. */
|
|
254
|
+
export function useRunDetail(key: string | undefined) {
|
|
255
|
+
const catalog = useTestCatalog();
|
|
256
|
+
return useQuery({
|
|
257
|
+
queryKey: ['test-run', catalog.version, key ?? ''],
|
|
258
|
+
queryFn: async ({ signal }): Promise<RunDetail | null> => {
|
|
259
|
+
if (catalog.source === 'page') {
|
|
260
|
+
const history = catalog.model?.history;
|
|
261
|
+
return history ? runDetail(history, catalog.version, key!) : null;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
return await reportJson<RunDetail>(`/runs/detail?key=${encodeURIComponent(key!)}`, signal);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (notFound(error)) return null;
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
enabled: !!key && catalog.model !== null,
|
|
271
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
272
|
+
retry: false
|
|
273
|
+
});
|
|
274
|
+
}
|