@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/playwright-reporter/src/evidence.d.ts +122 -0
  3. package/dist/playwright-reporter/src/evidence.js +327 -0
  4. package/dist/playwright-reporter/src/index.d.ts +114 -0
  5. package/dist/playwright-reporter/src/index.js +312 -0
  6. package/dist/reports/src/compose.js +7 -14
  7. package/dist/reports/src/recorded-specs.d.ts +33 -0
  8. package/dist/reports/src/recorded-specs.js +42 -0
  9. package/dist/reports/src/report-index.d.ts +6 -0
  10. package/dist/reports/src/report-index.js +14 -2
  11. package/dist/reports/src/test-catalog.d.ts +386 -0
  12. package/dist/reports/src/test-catalog.js +655 -0
  13. package/dist/schema/src/api.d.ts +585 -0
  14. package/dist/schema/src/api.js +105 -0
  15. package/dist/server/src/app.js +82 -4
  16. package/dist/server/src/llms.js +14 -0
  17. package/dist/server/src/openapi.d.ts +1 -1
  18. package/dist/server/src/openapi.js +60 -3
  19. package/docs/agents.md +1 -0
  20. package/docs/integrating.md +1 -0
  21. package/docs/reference/http-api.md +134 -2
  22. package/docs/reference/playwright-reporter.md +332 -0
  23. package/docs/reference/reports.md +632 -0
  24. package/docs/reference/schema.md +57 -0
  25. package/docs/rich-evidence.md +198 -0
  26. package/docs/server.md +41 -0
  27. package/llms-full.txt +378 -2
  28. package/llms.txt +2 -0
  29. package/package.json +6 -1
  30. package/packages/schema/schemas/report-run.schema.json +251 -0
  31. package/packages/schema/schemas/report-runs.schema.json +177 -0
  32. package/packages/schema/schemas/test-catalog.schema.json +1177 -0
  33. package/packages/schema/schemas/test-history.schema.json +696 -0
  34. package/packages/server/openapi.json +8676 -5351
  35. package/packages/visualizer/src/app/AppShell.tsx +7 -3
  36. package/packages/visualizer/src/app/router.tsx +26 -3
  37. package/packages/visualizer/src/components/Header.tsx +2 -2
  38. package/packages/visualizer/src/evidence/evidence-context.tsx +10 -1
  39. package/packages/visualizer/src/explorer/Explorer.tsx +433 -0
  40. package/packages/visualizer/src/explorer/ExplorerIndex.tsx +120 -0
  41. package/packages/visualizer/src/explorer/RunsView.tsx +164 -0
  42. package/packages/visualizer/src/explorer/TestDetail.tsx +214 -0
  43. package/packages/visualizer/src/explorer/catalog.tsx +274 -0
  44. package/packages/visualizer/src/explorer/explorer.module.css +1238 -0
  45. package/packages/visualizer/src/explorer/groups.test.ts +87 -0
  46. package/packages/visualizer/src/explorer/groups.ts +126 -0
  47. package/packages/visualizer/src/explorer/navigate.ts +36 -0
  48. package/packages/visualizer/src/explorer/parts.tsx +59 -0
  49. package/packages/visualizer/src/explorer/search.ts +97 -0
  50. package/packages/visualizer/src/find/Finder.tsx +2 -0
  51. package/packages/visualizer/src/find/corpus.ts +13 -6
  52. package/packages/visualizer/src/find/rank.ts +2 -0
  53. 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 { RunStatus } from '@crvouga/atlas/schema';
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
- pushRun(slot, slot.title, file ? kindOfFile(file)?.id ?? null : null, file);
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
  }
@@ -52,6 +52,8 @@ export type FindTarget = {
52
52
  readonly spec?: string;
53
53
  readonly check?: string;
54
54
  readonly view?: 'recording';
55
+ /** A test opened in the explorer of a project without statecharts (`TestSummary.key`). */
56
+ readonly test?: string;
55
57
  };
56
58
  };
57
59
 
@@ -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
- }