@crvouga/atlas 0.8.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 +12 -0
- package/dist/core/src/lint.js +6 -2
- package/dist/core/src/spec/types.d.ts +4 -1
- 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/schema/src/spec.d.ts +2 -1
- package/dist/schema/src/spec.js +5 -2
- 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/core.md +7 -2
- 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 +58 -1
- package/docs/rich-evidence.md +198 -0
- package/docs/server.md +41 -0
- package/docs/statecharts.md +2 -1
- package/llms-full.txt +380 -3
- 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/state-node.schema.json +7 -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,87 @@
|
|
|
1
|
+
import { catalogModel, TestHistory } from '@crvouga/atlas/reports/test-catalog';
|
|
2
|
+
import type { LayerEvidence, LayerHeader, LayerRow } from '@crvouga/atlas/reports/evidence-types';
|
|
3
|
+
import { expect, test } from 'bun:test';
|
|
4
|
+
|
|
5
|
+
import { modelFromCatalog, withLiveProgress } from './catalog';
|
|
6
|
+
import { environmentStatuses, environmentTally, groupTests } from './groups';
|
|
7
|
+
import { explorerSearch, queryOf, toggled, values } from './search';
|
|
8
|
+
import { testCatalog } from '@crvouga/atlas/reports/test-catalog';
|
|
9
|
+
import type { EvidenceView } from '@crvouga/atlas/reports/evidence-view';
|
|
10
|
+
|
|
11
|
+
function layer(id: string, environment: string, at: number, rows: Partial<LayerRow>[]): LayerEvidence {
|
|
12
|
+
const header: LayerHeader = {
|
|
13
|
+
id: id.padEnd(64, '0'), backend: 'local', producer: `p-${environment}`, packageDir: null, recordedAtMs: at, sha: 'a', branch: 'main', environment, runUrl: `run-${at}`,
|
|
14
|
+
parameters: {}, recording: null, live: false, counts: { passed: 0, failed: 0, running: 0, 'not-run': 0 }, execution: id
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
id: header.id,
|
|
18
|
+
layer: header,
|
|
19
|
+
fingerprints: {},
|
|
20
|
+
rows: rows.map((row) => ({ kind: 'checks', id: 'x', title: 'X', status: 'passed', specPath: null, detail: '', measurements: [], testId: null, variant: {}, durationMs: null, ...row }))
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const LAYERS = [
|
|
25
|
+
layer('a', 'api', 10, [
|
|
26
|
+
{ id: 'find', title: 'Find', status: 'failed', specPath: 'specs/support/find.feature', durationMs: 100 },
|
|
27
|
+
{ id: 'pay', title: 'Pay', status: 'passed', specPath: 'specs/billing/pay.feature', durationMs: 50 }
|
|
28
|
+
]),
|
|
29
|
+
layer('b', 'desktop-web', 11, [{ id: 'find', title: 'Find', status: 'passed', specPath: 'specs/support/find.feature', durationMs: 900 }])
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function model() {
|
|
33
|
+
const history = new TestHistory();
|
|
34
|
+
for (const entry of LAYERS) history.add(entry.layer, entry.rows);
|
|
35
|
+
return catalogModel(LAYERS, { history });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('groups by spec in domain order, and by environment with each test counted by how it went there', () => {
|
|
39
|
+
const { tests } = testCatalog(model(), { limit: 100 });
|
|
40
|
+
const bySpec = groupTests(tests, 'spec');
|
|
41
|
+
expect(bySpec.map((group) => [group.parent, group.label])).toEqual([['billing', 'Pay'], ['support', 'Find']]);
|
|
42
|
+
const byEnv = groupTests(tests, 'environment');
|
|
43
|
+
const web = byEnv.find((group) => group.key === 'desktop-web')!;
|
|
44
|
+
expect(web.counts).toMatchObject({ passed: 1, failed: 0 });
|
|
45
|
+
expect(web.avgMs).toBe(900);
|
|
46
|
+
expect(byEnv.find((group) => group.key === 'api')!.counts).toMatchObject({ passed: 1, failed: 1 });
|
|
47
|
+
expect(environmentTally(tests, 'api').avgMs).toBe(75);
|
|
48
|
+
expect(groupTests(tests, 'status').map((group) => group.key)).toEqual(['failed', 'passed']);
|
|
49
|
+
const find = tests.find((test) => test.id === 'find')!;
|
|
50
|
+
expect(environmentStatuses(find)).toEqual([{ environment: 'api', status: 'failed' }, { environment: 'desktop-web', status: 'passed' }]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('a server catalog is queried like a model built in the page', () => {
|
|
54
|
+
const built = model();
|
|
55
|
+
const read = modelFromCatalog(testCatalog(built, { limit: 20_000 }));
|
|
56
|
+
expect(testCatalog(read, { q: 'billing' }).tests.map((test) => test.id)).toEqual(['pay']);
|
|
57
|
+
expect(testCatalog(read, { environment: ['desktop-web'] }).tests.map((test) => test.id)).toEqual(['find']);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('live progress shows a test as running without rewriting recorded results', () => {
|
|
61
|
+
const built = model();
|
|
62
|
+
const pay = built.tests.find((test) => test.id === 'pay')!;
|
|
63
|
+
const view: EvidenceView = {
|
|
64
|
+
specs: new Map(),
|
|
65
|
+
unassigned: [
|
|
66
|
+
{ key: pay.key, id: 'pay', title: 'Pay', specPath: pay.specPath, status: 'running', outcomes: [], diagnostics: [], latestAtMs: 20, live: true },
|
|
67
|
+
{ key: JSON.stringify(['specs/new.feature', 'new']), id: 'new', title: 'New', specPath: 'specs/new.feature', status: 'running', outcomes: [], diagnostics: [], latestAtMs: 20, live: true }
|
|
68
|
+
],
|
|
69
|
+
total: { status: 'none', counts: { passed: 0, failed: 0, running: 0, 'not-run': 0 }, total: 0, latestAtMs: 0 } as unknown as EvidenceView['total']
|
|
70
|
+
};
|
|
71
|
+
const live = withLiveProgress(built, view);
|
|
72
|
+
expect(live.byKey.get(pay.key)?.status).toBe('running');
|
|
73
|
+
expect(live.byKey.get(pay.key)?.outcomes).toEqual(pay.outcomes);
|
|
74
|
+
expect(live.tests.map((test) => test.id).sort()).toEqual(['find', 'new', 'pay']);
|
|
75
|
+
expect(live.summary.counts.running).toBe(2);
|
|
76
|
+
expect(withLiveProgress(built, { ...view, unassigned: [] })).toBe(built);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('the address keeps filters as comma-separated text and drops what it cannot read', () => {
|
|
80
|
+
expect(explorerSearch({ q: 123, status: 'failed,flaky', group: 'nope', sort: 'slowest', test: ['specs/a.feature', 'a'] })).toEqual({
|
|
81
|
+
q: '123', status: 'failed,flaky', sort: 'slowest', test: JSON.stringify(['specs/a.feature', 'a'])
|
|
82
|
+
} as never);
|
|
83
|
+
expect(values(' api, desktop-web ,')).toEqual(['api', 'desktop-web']);
|
|
84
|
+
expect(toggled('api', 'ios')).toBe('api,ios');
|
|
85
|
+
expect(toggled('api', 'api')).toBeUndefined();
|
|
86
|
+
expect(queryOf({ env: 'api', status: 'failed' })).toMatchObject({ environment: ['api'], status: ['failed'], sort: 'spec' });
|
|
87
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Explorer groups
|
|
3
|
+
*
|
|
4
|
+
* The explorer's list in groups (by spec, domain, environment, status or producer) with what each group's header says:
|
|
5
|
+
* how many tests, by current status, and their average duration. A test checked on two environments is in both
|
|
6
|
+
* environment groups. Groups keep the order of the sorted tests (the group holding the first test comes first), except
|
|
7
|
+
* when sorted by spec, where they are in domain and title order, and by status, where failures come first.
|
|
8
|
+
*/
|
|
9
|
+
import type { StatusCounts } from '@crvouga/atlas/reports/evidence-types';
|
|
10
|
+
import type { EvidenceStatus } from '@crvouga/atlas/reports/explorer-types';
|
|
11
|
+
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
12
|
+
import type { TestSummary } from '@crvouga/atlas/reports/test-catalog';
|
|
13
|
+
|
|
14
|
+
import type { TestGroup } from './search';
|
|
15
|
+
|
|
16
|
+
/** One group of tests and its tally. */
|
|
17
|
+
export type TestGroupView = {
|
|
18
|
+
/** Stable within a grouping: what is remembered as open or closed. */
|
|
19
|
+
readonly key: string;
|
|
20
|
+
readonly label: string;
|
|
21
|
+
/** A second, quieter line: the spec's path. */
|
|
22
|
+
readonly detail: string | null;
|
|
23
|
+
/** The domain a spec group belongs to, for a heading above it. */
|
|
24
|
+
readonly parent: string | null;
|
|
25
|
+
/** What to filter by to see only this group: the filter and its value. */
|
|
26
|
+
readonly filter: { readonly field: 'spec' | 'domain' | 'env' | 'status' | 'producer'; readonly value: string } | null;
|
|
27
|
+
readonly tests: readonly TestSummary[];
|
|
28
|
+
readonly counts: StatusCounts;
|
|
29
|
+
/** The average of its tests' average durations, ms, or null when none was measured. */
|
|
30
|
+
readonly avgMs: number | null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Order of statuses when grouping by status: what needs attention first. */
|
|
34
|
+
export const STATUS_ORDER: readonly EvidenceStatus[] = ['failed', 'running', 'not-run', 'passed'];
|
|
35
|
+
|
|
36
|
+
const NO_SPEC = '(no spec)';
|
|
37
|
+
|
|
38
|
+
/** Counts by current status, and the mean of measured average durations. */
|
|
39
|
+
export function tally(tests: readonly TestSummary[]): { counts: StatusCounts; avgMs: number | null } {
|
|
40
|
+
const counts: Record<EvidenceStatus, number> = { passed: 0, failed: 0, running: 0, 'not-run': 0 };
|
|
41
|
+
let total = 0, measured = 0;
|
|
42
|
+
for (const test of tests) {
|
|
43
|
+
counts[test.status] += 1;
|
|
44
|
+
if (test.stats.durations) { total += test.stats.durations.avgMs; measured += 1; }
|
|
45
|
+
}
|
|
46
|
+
return { counts, avgMs: measured ? Math.round(total / measured) : null };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The tally of one environment: each test counted by how it went there (a test failing on the API and passing on the
|
|
51
|
+
* web counts as passed for the web), and the mean of the durations of its current results there.
|
|
52
|
+
*/
|
|
53
|
+
export function environmentTally(tests: readonly TestSummary[], environment: string): { counts: StatusCounts; avgMs: number | null } {
|
|
54
|
+
const counts: Record<EvidenceStatus, number> = { passed: 0, failed: 0, running: 0, 'not-run': 0 };
|
|
55
|
+
let total = 0, measured = 0;
|
|
56
|
+
const env = environment === '(none)' ? null : environment;
|
|
57
|
+
for (const test of tests) {
|
|
58
|
+
const own = environmentStatuses(test).find((entry) => entry.environment === env);
|
|
59
|
+
counts[own?.status ?? test.status] += 1;
|
|
60
|
+
for (const outcome of test.outcomes) {
|
|
61
|
+
if (outcome.environment === env && outcome.durationMs !== null) { total += outcome.durationMs; measured += 1; }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { counts, avgMs: measured ? Math.round(total / measured) : null };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type Keyed = { key: string; label: string; detail: string | null; parent: string | null; filter: TestGroupView['filter'] };
|
|
68
|
+
|
|
69
|
+
function keysOf(test: TestSummary, by: TestGroup): Keyed[] {
|
|
70
|
+
switch (by) {
|
|
71
|
+
case 'spec':
|
|
72
|
+
return [test.spec
|
|
73
|
+
? { key: test.spec.path, label: test.spec.title, detail: test.spec.path, parent: test.spec.domain || NO_SPEC, filter: { field: 'spec', value: test.spec.path } }
|
|
74
|
+
: { key: NO_SPEC, label: 'Tests without a spec', detail: null, parent: NO_SPEC, filter: null }];
|
|
75
|
+
case 'domain': {
|
|
76
|
+
const domain = test.spec?.domain || NO_SPEC;
|
|
77
|
+
return [{ key: domain, label: domain === NO_SPEC ? 'No spec' : domain, detail: null, parent: null, filter: { field: 'domain', value: domain } }];
|
|
78
|
+
}
|
|
79
|
+
case 'environment':
|
|
80
|
+
return test.environments.length
|
|
81
|
+
? test.environments.map((env) => ({ key: env, label: environmentLabel(env), detail: env === environmentLabel(env) ? null : env, parent: null, filter: { field: 'env', value: env } }))
|
|
82
|
+
: [{ key: '(none)', label: 'No environment', detail: null, parent: null, filter: { field: 'env', value: '(none)' } }];
|
|
83
|
+
case 'status':
|
|
84
|
+
return [{ key: test.status, label: { passed: 'Passed', failed: 'Failed', running: 'Running', 'not-run': 'Not run' }[test.status], detail: null, parent: null, filter: { field: 'status', value: test.status } }];
|
|
85
|
+
case 'producer':
|
|
86
|
+
return test.producers.length
|
|
87
|
+
? test.producers.map((producer) => ({ key: producer, label: producer, detail: null, parent: null, filter: { field: 'producer', value: producer } }))
|
|
88
|
+
: [{ key: '(none)', label: 'No producer', detail: null, parent: null, filter: null }];
|
|
89
|
+
case 'none':
|
|
90
|
+
return [{ key: 'all', label: 'All tests', detail: null, parent: null, filter: null }];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const text = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
|
|
95
|
+
|
|
96
|
+
/** The tests (already filtered and sorted) in groups. `bySpecOrder` keeps groups in domain and title order. */
|
|
97
|
+
export function groupTests(tests: readonly TestSummary[], by: TestGroup, bySpecOrder = by === 'spec'): TestGroupView[] {
|
|
98
|
+
const groups = new Map<string, Keyed & { tests: TestSummary[] }>();
|
|
99
|
+
for (const test of tests) {
|
|
100
|
+
for (const keyed of keysOf(test, by)) {
|
|
101
|
+
const group = groups.get(keyed.key);
|
|
102
|
+
if (group) group.tests.push(test);
|
|
103
|
+
else groups.set(keyed.key, { ...keyed, tests: [test] });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
let list = [...groups.values()];
|
|
107
|
+
if (by === 'status') list.sort((a, b) => STATUS_ORDER.indexOf(a.key as EvidenceStatus) - STATUS_ORDER.indexOf(b.key as EvidenceStatus));
|
|
108
|
+
else if (bySpecOrder && (by === 'spec' || by === 'domain')) {
|
|
109
|
+
const last = (value: string | null) => (value === NO_SPEC ? '' : (value ?? '').toLowerCase());
|
|
110
|
+
list = list.sort((a, b) => text(last(a.parent), last(b.parent)) || text(a.key === NO_SPEC ? '' : a.label.toLowerCase(), b.key === NO_SPEC ? '' : b.label.toLowerCase()));
|
|
111
|
+
}
|
|
112
|
+
return list.map((group) => ({ ...group, ...(by === 'environment' ? environmentTally(group.tests, group.key) : tally(group.tests)) }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The worst current status per environment of a test, in the order its environments are listed. */
|
|
116
|
+
export function environmentStatuses(test: TestSummary): { environment: string | null; status: EvidenceStatus }[] {
|
|
117
|
+
const rank: Record<EvidenceStatus, number> = { passed: 0, 'not-run': 1, running: 2, failed: 3 };
|
|
118
|
+
const seen = new Map<string | null, EvidenceStatus>();
|
|
119
|
+
for (const outcome of test.outcomes) {
|
|
120
|
+
const prior = seen.get(outcome.environment);
|
|
121
|
+
if (prior === undefined || rank[outcome.status] > rank[prior]) seen.set(outcome.environment, outcome.status);
|
|
122
|
+
}
|
|
123
|
+
// Live progress may say running where no recorded result does.
|
|
124
|
+
if (test.status === 'running' && ![...seen.values()].includes('running')) return [{ environment: null, status: 'running' }, ...[...seen].map(([environment, status]) => ({ environment, status }))];
|
|
125
|
+
return [...seen].map(([environment, status]) => ({ environment, status })).sort((a, b) => text(a.environment ?? '', b.environment ?? ''));
|
|
126
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Explorer navigation
|
|
3
|
+
*
|
|
4
|
+
* Changing what the explorer shows is changing the address of `/`: these helpers read and write the explorer's
|
|
5
|
+
* search parameters, keeping the rest of the address (the recorded run picked in the header) as it is.
|
|
6
|
+
*/
|
|
7
|
+
import { useNavigate, useSearch } from '@tanstack/react-router';
|
|
8
|
+
import { useMemo } from 'react';
|
|
9
|
+
|
|
10
|
+
import { explorerSearch, type ExplorerSearch } from './search';
|
|
11
|
+
|
|
12
|
+
/** The explorer's search parameters, read from the address. */
|
|
13
|
+
export function useExplorerSearch(): ExplorerSearch {
|
|
14
|
+
const raw = useSearch({ strict: false }) as Record<string, unknown>;
|
|
15
|
+
return useMemo(() => explorerSearch(raw), [raw]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A change to the explorer's search: fields set to undefined are removed from the address. */
|
|
19
|
+
export type ExplorerPatch = { -readonly [K in keyof ExplorerSearch]?: ExplorerSearch[K] | undefined };
|
|
20
|
+
|
|
21
|
+
/** Returns `go(patch, { replace })`, which moves the explorer to the address with `patch` applied. */
|
|
22
|
+
export function useExplorerNavigate() {
|
|
23
|
+
const navigate = useNavigate();
|
|
24
|
+
return (patch: ExplorerPatch, options: { replace?: boolean } = {}) =>
|
|
25
|
+
void navigate({
|
|
26
|
+
to: '/',
|
|
27
|
+
search: ((prev: Record<string, unknown>) => ({ ...prev, ...patch })) as never,
|
|
28
|
+
replace: options.replace ?? false,
|
|
29
|
+
resetScroll: false
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The search of a link that applies `patch` to the current explorer address. */
|
|
34
|
+
export function patched(patch: ExplorerPatch) {
|
|
35
|
+
return ((prev: Record<string, unknown>) => ({ ...prev, ...patch })) as never;
|
|
36
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Explorer parts
|
|
3
|
+
*
|
|
4
|
+
* Small pieces the explorer's list, its test detail and its runs view share: the project's title, a run's short name,
|
|
5
|
+
* the chips that say where a test ran and how it went there, and the badges for what its results carry.
|
|
6
|
+
*/
|
|
7
|
+
import { environmentLabel } from '@crvouga/atlas/reports/layer-axes';
|
|
8
|
+
import type { TestSummary } from '@crvouga/atlas/reports/test-catalog';
|
|
9
|
+
|
|
10
|
+
import { plural } from '../evidence/format';
|
|
11
|
+
import { StatusMark } from '../evidence/marks';
|
|
12
|
+
import { currentProject } from '../runtime';
|
|
13
|
+
import styles from './explorer.module.css';
|
|
14
|
+
import { environmentStatuses } from './groups';
|
|
15
|
+
|
|
16
|
+
/** The project's name for its title: `?project=geviti-app` reads "Geviti App". */
|
|
17
|
+
export function projectTitle(): string {
|
|
18
|
+
return currentProject()?.split('-').map((word) => (word[0]?.toUpperCase() ?? '') + word.slice(1)).join(' ') || 'Project reports';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A run key for a person: its CI run number, or `branch@sha` shortened. */
|
|
22
|
+
export function runName(key: string): string {
|
|
23
|
+
const actions = /\/actions\/runs\/(\d+)/.exec(key);
|
|
24
|
+
if (actions) return `#${actions[1]}`;
|
|
25
|
+
if (/^https?:/.test(key)) return key.replace(/^https?:\/\//, '').slice(0, 40);
|
|
26
|
+
const [branch, sha] = key.split('@');
|
|
27
|
+
return [branch, sha?.slice(0, 7)].filter(Boolean).join(' @ ') || key;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Small badges for what a test's results carry: flaky, video, screenshots, a trace, steps. */
|
|
31
|
+
export function Badges({ test }: { test: TestSummary }) {
|
|
32
|
+
const roles = test.evidence.roles;
|
|
33
|
+
const badges = [
|
|
34
|
+
test.stats.flaky ? { id: 'flaky', label: 'Flaky', title: test.stats.flakyReason } : null,
|
|
35
|
+
roles.includes('video') ? { id: 'video', label: 'Video', title: 'Has a video' } : null,
|
|
36
|
+
roles.includes('image') || roles.includes('screenshot') ? { id: 'image', label: 'Screenshots', title: 'Has screenshots' } : null,
|
|
37
|
+
roles.includes('trace') ? { id: 'trace', label: 'Trace', title: 'Has a trace' } : null,
|
|
38
|
+
test.evidence.steps > 0 ? { id: 'steps', label: plural(test.evidence.steps, 'step', 'steps'), title: 'Recorded steps' } : null,
|
|
39
|
+
test.live ? { id: 'live', label: 'Running now', title: 'A run is in progress' } : null
|
|
40
|
+
].filter((badge): badge is { id: string; label: string; title: string } => badge !== null);
|
|
41
|
+
if (!badges.length) return null;
|
|
42
|
+
return <span className={styles.badges}>{badges.map((badge) => <span key={badge.id} data-badge={badge.id} title={badge.title}>{badge.label}</span>)}</span>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Where a test ran and how it went there: one chip per environment. */
|
|
46
|
+
export function EnvironmentChips({ test }: { test: TestSummary }) {
|
|
47
|
+
const environments = environmentStatuses(test);
|
|
48
|
+
if (!environments.length) return null;
|
|
49
|
+
return (
|
|
50
|
+
<span className={styles.envChips}>
|
|
51
|
+
{environments.map(({ environment, status }) => (
|
|
52
|
+
<span key={environment ?? 'none'} data-status={status} title={`${environmentLabel(environment)}: ${status}`}>
|
|
53
|
+
<StatusMark status={status} size={10} />
|
|
54
|
+
{environmentLabel(environment)}
|
|
55
|
+
</span>
|
|
56
|
+
))}
|
|
57
|
+
</span>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Explorer address
|
|
3
|
+
*
|
|
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, and the Runs tab with the run opened in it. Filters that
|
|
6
|
+
* take several values are comma-separated (`status=failed,flaky`). Anything unreadable is dropped, never an error.
|
|
7
|
+
*/
|
|
8
|
+
import { TEST_SORTS, type TestQuery, type TestSort } from '@crvouga/atlas/reports/test-catalog';
|
|
9
|
+
import { z } from 'zod/v4';
|
|
10
|
+
|
|
11
|
+
/** How the explorer groups its tests. `spec` is the default. */
|
|
12
|
+
export const TEST_GROUPS = ['spec', 'domain', 'environment', 'status', 'producer', 'none'] as const;
|
|
13
|
+
/** One of {@link TEST_GROUPS}. */
|
|
14
|
+
export type TestGroup = (typeof TEST_GROUPS)[number];
|
|
15
|
+
|
|
16
|
+
/** A search value as text: the router reads `123` as a number and `["a"]` as an array, both of which are meant as text here. */
|
|
17
|
+
const text = z
|
|
18
|
+
.preprocess((value) => (typeof value === 'number' || typeof value === 'boolean' ? String(value) : value !== null && typeof value === 'object' ? JSON.stringify(value) : value), z.string().min(1))
|
|
19
|
+
.optional()
|
|
20
|
+
.catch(undefined);
|
|
21
|
+
|
|
22
|
+
/** The explorer's part of the `/` route's search: see the module comment. */
|
|
23
|
+
export const ExplorerSearchFields = {
|
|
24
|
+
/** The open test, a `TestSummary.key`. */
|
|
25
|
+
test: text,
|
|
26
|
+
q: text,
|
|
27
|
+
status: text,
|
|
28
|
+
env: text,
|
|
29
|
+
producer: text,
|
|
30
|
+
domain: text,
|
|
31
|
+
spec: text,
|
|
32
|
+
tag: text,
|
|
33
|
+
/** Only the tests a CI run recorded (a `RunSummary.key`). Not `run`, which already names a recorded statechart run. */
|
|
34
|
+
inRun: text,
|
|
35
|
+
group: z.enum(TEST_GROUPS).optional().catch(undefined),
|
|
36
|
+
sort: z.enum(TEST_SORTS).optional().catch(undefined),
|
|
37
|
+
tab: z.enum(['runs']).optional().catch(undefined),
|
|
38
|
+
/** The run opened in the Runs tab. */
|
|
39
|
+
openRun: text
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The explorer's search parameters as the route holds them. */
|
|
43
|
+
export type ExplorerSearch = {
|
|
44
|
+
readonly test?: string;
|
|
45
|
+
readonly q?: string;
|
|
46
|
+
readonly status?: string;
|
|
47
|
+
readonly env?: string;
|
|
48
|
+
readonly producer?: string;
|
|
49
|
+
readonly domain?: string;
|
|
50
|
+
readonly spec?: string;
|
|
51
|
+
readonly tag?: string;
|
|
52
|
+
readonly inRun?: string;
|
|
53
|
+
readonly group?: TestGroup;
|
|
54
|
+
readonly sort?: TestSort;
|
|
55
|
+
readonly tab?: 'runs';
|
|
56
|
+
readonly openRun?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** The values of a comma-separated filter, empty when it is not set. */
|
|
60
|
+
export function values(value: string | undefined): string[] {
|
|
61
|
+
return value ? value.split(',').map((item) => item.trim()).filter(Boolean) : [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A list of values as one comma-separated parameter, or undefined (left out of the address) when empty. */
|
|
65
|
+
export function joined(list: readonly string[]): string | undefined {
|
|
66
|
+
return list.length ? list.join(',') : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Adds a value to a comma-separated filter, or removes it when it is there. */
|
|
70
|
+
export function toggled(value: string | undefined, item: string): string | undefined {
|
|
71
|
+
const list = values(value);
|
|
72
|
+
return joined(list.includes(item) ? list.filter((entry) => entry !== item) : [...list, item]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The catalog query an address asks for, without the run (it is matched separately) and without paging. */
|
|
76
|
+
export function queryOf(search: ExplorerSearch): TestQuery {
|
|
77
|
+
return {
|
|
78
|
+
...(search.q ? { q: search.q } : {}),
|
|
79
|
+
status: values(search.status),
|
|
80
|
+
environment: values(search.env),
|
|
81
|
+
producer: values(search.producer),
|
|
82
|
+
domain: values(search.domain),
|
|
83
|
+
spec: values(search.spec),
|
|
84
|
+
tag: values(search.tag),
|
|
85
|
+
sort: search.sort ?? 'spec'
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Only the explorer's own fields of a route's search, so other routes' parameters are not carried along. */
|
|
90
|
+
export function explorerSearch(search: Record<string, unknown>): ExplorerSearch {
|
|
91
|
+
return z.object(ExplorerSearchFields).parse(search) as ExplorerSearch;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** True when any filter or search narrows the list. */
|
|
95
|
+
export function narrowed(search: ExplorerSearch): boolean {
|
|
96
|
+
return !!(search.q || search.status || search.env || search.producer || search.domain || search.spec || search.tag || search.inRun);
|
|
97
|
+
}
|
|
@@ -209,6 +209,8 @@ export function Finder() {
|
|
|
209
209
|
params: { kind },
|
|
210
210
|
search: { run, spec: target.search.spec, check: target.search.check, view: target.search.view }
|
|
211
211
|
});
|
|
212
|
+
} else if (target.to === '/' && target.search.test) {
|
|
213
|
+
void navigate({ to: '/', search: { run, test: target.search.test } });
|
|
212
214
|
}
|
|
213
215
|
setFinderOpen(false);
|
|
214
216
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CheckSlot, EvidenceView } from '@crvouga/atlas/reports/evidence-view';
|
|
2
|
-
import type
|
|
2
|
+
import { isStatechartFile, type RunStatus } from '@crvouga/atlas/schema';
|
|
3
3
|
|
|
4
4
|
import type { AtlasView, ItemStatus } from '../data/model';
|
|
5
5
|
import { worstOf } from '../data/model/status';
|
|
@@ -65,6 +65,8 @@ export type CorpusSource = {
|
|
|
65
65
|
readonly files: readonly CorpusFile[];
|
|
66
66
|
readonly slots: readonly CorpusSlot[];
|
|
67
67
|
readonly texts: ReadonlyMap<string, string>;
|
|
68
|
+
/** The project home is the test explorer (no statecharts): a check without a published spec opens there. */
|
|
69
|
+
readonly explorer?: boolean;
|
|
68
70
|
};
|
|
69
71
|
|
|
70
72
|
function itemStatus(status: ItemStatus): FindStatus {
|
|
@@ -128,7 +130,10 @@ function actionsIn(text: string): string[] {
|
|
|
128
130
|
return [...new Set([...text.matchAll(ACTION)].map((match) => match[1]!))];
|
|
129
131
|
}
|
|
130
132
|
|
|
131
|
-
function runDoc(slot: CorpusSlot, title: string, kind: string | null, file: string): FindDoc {
|
|
133
|
+
function runDoc(slot: CorpusSlot, title: string, kind: string | null, file: string, explorer = false): FindDoc {
|
|
134
|
+
if (explorer) {
|
|
135
|
+
return { id: `run:${slot.key}`, group: 'Runs', kind: 'run', title, subtitle: file, fields: fieldsOf(title, '', slot.detail, file), status: slot.status, related: [], target: { to: '/', search: { test: slot.key } }, recording: null };
|
|
136
|
+
}
|
|
132
137
|
const recording = timelineTarget(kind, file, [slot]);
|
|
133
138
|
return {
|
|
134
139
|
id: `run:${slot.key}`,
|
|
@@ -222,11 +227,11 @@ export function buildCorpus(source: CorpusSource): FindDoc[] {
|
|
|
222
227
|
}
|
|
223
228
|
}
|
|
224
229
|
|
|
225
|
-
const pushRun = (slot: CorpusSlot, title: string, kind: string | null, file: string) => {
|
|
230
|
+
const pushRun = (slot: CorpusSlot, title: string, kind: string | null, file: string, explorer = false) => {
|
|
226
231
|
const id = `run:${slot.key}`;
|
|
227
232
|
if (seenRuns.has(id)) return;
|
|
228
233
|
seenRuns.add(id);
|
|
229
|
-
docs.push(runDoc(slot, title, kind, file));
|
|
234
|
+
docs.push(runDoc(slot, title, kind, file, explorer));
|
|
230
235
|
};
|
|
231
236
|
|
|
232
237
|
for (const file of source.files) {
|
|
@@ -314,7 +319,8 @@ export function buildCorpus(source: CorpusSource): FindDoc[] {
|
|
|
314
319
|
|
|
315
320
|
for (const slot of source.slots) {
|
|
316
321
|
const file = slot.specPath ? specFile(slot.specPath) : '';
|
|
317
|
-
|
|
322
|
+
// Its spec was not published: in a project without statecharts the explorer has it, with everything recorded for it.
|
|
323
|
+
pushRun(slot, slot.title, file ? kindOfFile(file)?.id ?? null : null, file, source.explorer === true);
|
|
318
324
|
}
|
|
319
325
|
return docs;
|
|
320
326
|
}
|
|
@@ -363,6 +369,7 @@ export function corpusFrom(view: AtlasView | null, files: readonly string[], tit
|
|
|
363
369
|
: [],
|
|
364
370
|
files: files.map((path) => ({ path, title: titleOf(path) })),
|
|
365
371
|
slots,
|
|
366
|
-
texts
|
|
372
|
+
texts,
|
|
373
|
+
explorer: !files.some(isStatechartFile)
|
|
367
374
|
});
|
|
368
375
|
}
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { Link } from '@tanstack/react-router';
|
|
2
|
-
import { lazy, Suspense, useState } from 'react';
|
|
3
|
-
import { isStatechartFile } from '@crvouga/atlas/schema';
|
|
4
|
-
import { useEvidence } from './evidence-context';
|
|
5
|
-
import { CheckDetail, ResultRow, type CheckSelection } from './check-detail';
|
|
6
|
-
import { TestFilter, useRunningFilter } from './test-filter';
|
|
7
|
-
import { Bounded } from './marks';
|
|
8
|
-
import { SPEC_KINDS, kindOfFile } from '../spec-kind';
|
|
9
|
-
import { currentProject } from '../runtime';
|
|
10
|
-
import styles from './evidence.module.css';
|
|
11
|
-
const RecordingPage = lazy(() => import('./recording-page').then(module => ({ default: module.RecordingPage })));
|
|
12
|
-
|
|
13
|
-
/** A project can contain any test runner or spec notation; statecharts are optional. */
|
|
14
|
-
export function ProjectHome() {
|
|
15
|
-
const evidence = useEvidence();
|
|
16
|
-
const runningOnly = useRunningFilter();
|
|
17
|
-
const [selection, setSelection] = useState<CheckSelection>({});
|
|
18
|
-
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
|
19
|
-
const [recording, setRecording] = useState(false);
|
|
20
|
-
const [moment, setMoment] = useState<number | null>(null);
|
|
21
|
-
const kinds = SPEC_KINDS.map(kind => ({ kind, files: evidence.files.filter(kind.accepts) })).filter(entry => entry.files.length);
|
|
22
|
-
const checks = [...evidence.view.unassigned, ...[...evidence.view.specs].filter(([file]) => !kindOfFile(file) && !isStatechartFile(file)).flatMap(([, entry]) => entry.slots)];
|
|
23
|
-
const visible = checks.filter(slot => !runningOnly || slot.status === 'running');
|
|
24
|
-
return <article className={styles.page}>
|
|
25
|
-
<header className={styles.specHead}><h1 className={styles.title}>{currentProject()?.split('-').map(word => word[0]?.toUpperCase() + word.slice(1)).join(' ') || 'Project reports'}</h1></header>
|
|
26
|
-
{evidence.unavailable && <p role="alert" className={styles.notice}>{evidence.unavailable}</p>}
|
|
27
|
-
{kinds.length > 0 && <section className={styles.block}><h2 className={styles.blockTitle}>Specs</h2><ul className={styles.list}>{kinds.map(({ kind, files }) => <li key={kind.id}>
|
|
28
|
-
<Link className={styles.listRow} to="/specs/$kind" params={{ kind: kind.id }} search={{}}><strong>{kind.label}</strong><span>{files.length} {files.length === 1 ? kind.one : kind.many}</span></Link>
|
|
29
|
-
</li>)}</ul></section>}
|
|
30
|
-
{checks.length > 0 && <section className={styles.block}><h2 className={styles.blockTitle}>Tests</h2><TestFilter slots={checks} /><ul className={styles.results}>
|
|
31
|
-
<Bounded items={visible} size={40} more="More tests">{slot => <ResultRow key={slot.key} label={slot.title} slot={slot} open={selectedKey === slot.key} onToggle={() => { setSelectedKey(selectedKey === slot.key ? null : slot.key); setSelection({ check: slot.id }); setRecording(false); }}>
|
|
32
|
-
{recording ? <Suspense fallback={<p role="status">Loading the recording…</p>}><RecordingPage slot={slot} layerId={selection.layer} label={slot.title} back="Test report" moment={moment} onMoment={setMoment} onClose={() => setRecording(false)} /></Suspense>
|
|
33
|
-
: <CheckDetail slot={slot} layerId={selection.layer} reads="steps" select={patch => { setSelection({ check: patch.check ?? slot.id, layer: patch.layer ?? undefined }); if (patch.view === 'recording') { setRecording(true); setMoment(null); } }} />}
|
|
34
|
-
</ResultRow>}</Bounded>
|
|
35
|
-
</ul></section>}
|
|
36
|
-
{!kinds.length && !checks.length && <p className={styles.aside}>No reports yet. Publish tests and specs to this project to populate its report.</p>}
|
|
37
|
-
</article>;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function ProjectSpecIndex() {
|
|
41
|
-
const { files } = useEvidence();
|
|
42
|
-
return <nav className={styles.index} aria-label="Project specs"><h2>Specs</h2><ul>{SPEC_KINDS.filter(kind => files.some(kind.accepts)).map(kind => <li key={kind.id}>
|
|
43
|
-
<Link className={styles.indexRow} to="/specs/$kind" params={{ kind: kind.id }} search={{}}>{kind.label}</Link>
|
|
44
|
-
</li>)}</ul></nav>;
|
|
45
|
-
}
|