@crvouga/atlas 0.5.0 → 0.6.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/README.md +30 -11
- package/dist/core/src/cli.d.ts +1 -1
- package/dist/core/src/cli.js +8 -3
- package/dist/core/src/config.d.ts +7 -0
- package/dist/core/src/config.js +48 -14
- package/dist/core/src/run/execute.d.ts +6 -0
- package/dist/core/src/run/execute.js +4 -2
- package/dist/reports/src/doctor.d.ts +1 -1
- package/dist/reports/src/doctor.js +3 -1
- package/dist/reports/src/http-store.d.ts +4 -0
- package/dist/reports/src/http-store.js +6 -3
- package/dist/reports/src/init.js +6 -1
- package/dist/reports/src/live-reporter.d.ts +34 -0
- package/dist/reports/src/live-reporter.js +122 -0
- package/dist/reports/src/report-cli.d.ts +1 -1
- package/dist/reports/src/report-cli.js +3 -2
- package/dist/reports/src/store-url.js +1 -1
- package/dist/runners/src/codecept.d.ts +14 -0
- package/dist/runners/src/codecept.js +16 -0
- package/dist/runners/src/jest.d.ts +50 -0
- package/dist/runners/src/jest.js +52 -0
- package/dist/runners/src/mocha.d.ts +40 -0
- package/dist/runners/src/mocha.js +39 -0
- package/dist/runners/src/node-test.d.ts +28 -0
- package/dist/runners/src/node-test.js +44 -0
- package/dist/runners/src/publish.d.ts +30 -0
- package/dist/runners/src/publish.js +68 -0
- package/dist/schema/src/api.d.ts +1 -0
- package/dist/schema/src/api.js +1 -1
- package/dist/schema/src/index.d.ts +2 -0
- package/dist/schema/src/index.js +2 -0
- package/dist/schema/src/live-run.d.ts +123 -0
- package/dist/schema/src/live-run.js +30 -0
- package/dist/schema/src/manifest.d.ts +2 -2
- package/dist/schema/src/project.d.ts +20 -0
- package/dist/schema/src/project.js +10 -0
- package/dist/schema/src/report-sources.d.ts +1 -0
- package/dist/schema/src/report-sources.js +2 -1
- package/dist/schema/src/runs-index.d.ts +1 -1
- package/dist/schema/src/server-config.d.ts +84 -0
- package/dist/schema/src/server-config.js +12 -0
- package/dist/server/src/app.d.ts +10 -0
- package/dist/server/src/app.js +148 -10
- package/dist/server/src/config.d.ts +1 -1
- package/dist/server/src/config.js +20 -3
- package/dist/server/src/dev.d.ts +3 -3
- package/dist/server/src/dev.js +22 -13
- package/dist/server/src/events.d.ts +3 -3
- package/dist/server/src/events.js +1 -1
- package/dist/server/src/index.d.ts +2 -0
- package/dist/server/src/index.js +2 -0
- package/dist/server/src/live-runs.d.ts +52 -0
- package/dist/server/src/live-runs.js +229 -0
- package/dist/server/src/main.d.ts +1 -1
- package/dist/server/src/main.js +7 -5
- package/dist/server/src/openapi.d.ts +2 -5
- package/dist/server/src/openapi.js +71 -7
- package/dist/server/src/projects.d.ts +3 -0
- package/dist/server/src/projects.js +154 -0
- package/dist/server/src/settings-page.d.ts +9 -0
- package/dist/server/src/settings-page.js +93 -0
- package/dist/server/src/settings.d.ts +23 -0
- package/dist/server/src/settings.js +72 -0
- package/dist/server/src/site.d.ts +8 -6
- package/dist/server/src/site.js +45 -10
- package/dist/server/src/store-api.js +43 -22
- package/docs/deploying.md +19 -19
- package/docs/dogfooding.md +2 -2
- package/docs/extending.md +3 -3
- package/docs/integrating.md +9 -9
- package/docs/reference/cli.md +9 -7
- package/docs/reference/core.md +6 -0
- package/docs/reference/http-api.md +498 -2
- package/docs/reference/reports.md +55 -1
- package/docs/reference/runners.md +169 -0
- package/docs/reference/schema.md +111 -0
- package/docs/reference/server.md +154 -14
- package/docs/report-backends.md +8 -8
- package/docs/report-contract.md +7 -7
- package/docs/reporting-issues.md +3 -3
- package/docs/runners.md +69 -0
- package/docs/server.md +61 -25
- package/docs/statecharts.md +22 -20
- package/llms-full.txt +713 -105
- package/llms.txt +3 -1
- package/package.json +31 -9
- package/packages/runners/README.md +6 -0
- package/packages/runners/compat/codecept.cjs +2 -0
- package/packages/runners/compat/jest.cjs +2 -0
- package/packages/runners/compat/mocha.cjs +2 -0
- package/packages/schema/schemas/atlas-change.schema.json +4 -0
- package/packages/schema/schemas/hello-event.schema.json +4 -0
- package/packages/schema/schemas/live-run-start.schema.json +42 -0
- package/packages/schema/schemas/live-run-update.schema.json +93 -0
- package/packages/schema/schemas/live-run.schema.json +225 -0
- package/packages/schema/schemas/project-slug.schema.json +8 -0
- package/packages/schema/schemas/project.schema.json +22 -0
- package/packages/schema/schemas/projects.schema.json +33 -0
- package/packages/schema/schemas/server-config.schema.json +14 -0
- package/packages/schema/schemas/server-editable-settings.schema.json +132 -0
- package/packages/schema/schemas/server-settings-snapshot.schema.json +285 -0
- package/packages/server/openapi.json +5101 -779
- package/packages/visualizer/README.md +13 -13
- package/packages/visualizer/bin/atlas-client.mjs +176 -0
- package/packages/visualizer/bin/atlas-visualizer.mjs +2 -175
- package/packages/visualizer/src/app/AppShell.tsx +26 -17
- package/packages/visualizer/src/app/report-sources.tsx +2 -1
- package/packages/visualizer/src/components/ActivityIndicator.module.css +4 -0
- package/packages/visualizer/src/components/ActivityIndicator.tsx +6 -0
- package/packages/visualizer/src/components/Header.module.css +101 -227
- package/packages/visualizer/src/components/Header.tsx +79 -41
- package/packages/visualizer/src/components/ProjectPicker.tsx +64 -0
- package/packages/visualizer/src/data/adapters/report-backend.test.ts +3 -2
- package/packages/visualizer/src/data/adapters/report-backend.ts +1 -1
- package/packages/visualizer/src/data/queries.ts +14 -7
- package/packages/visualizer/src/evidence/api.ts +2 -1
- package/packages/visualizer/src/evidence/check-detail.tsx +19 -4
- package/packages/visualizer/src/evidence/document-page.tsx +6 -2
- package/packages/visualizer/src/evidence/evidence-context.tsx +17 -12
- package/packages/visualizer/src/evidence/evidence.module.css +11 -1
- package/packages/visualizer/src/evidence/feature-page.tsx +7 -3
- package/packages/visualizer/src/evidence/model-page.tsx +6 -2
- package/packages/visualizer/src/evidence/project-home.tsx +45 -0
- package/packages/visualizer/src/evidence/recording-page.tsx +4 -1
- package/packages/visualizer/src/evidence/spec-index.tsx +2 -1
- package/packages/visualizer/src/evidence/test-filter.tsx +21 -0
- package/packages/visualizer/src/live/live-query.ts +21 -0
- package/packages/visualizer/src/live/live-runs.tsx +69 -0
- package/packages/visualizer/src/live/live-stream.ts +30 -9
- package/packages/visualizer/src/live/report-progress.ts +62 -0
- package/packages/visualizer/src/live/test-progress.tsx +34 -0
- package/packages/visualizer/src/runtime.ts +17 -1
- package/packages/visualizer/src/state/ui-store.ts +3 -2
- package/packages/visualizer/src/views/ChartPage.tsx +2 -0
- package/packages/visualizer/src/views/ChartTests.tsx +43 -0
- package/packages/visualizer/src/views/SidePanel.tsx +2 -0
- package/packages/visualizer/vite/atlas-files.test.ts +4 -4
- package/packages/visualizer/vite/atlas-files.ts +1 -5
- package/packages/visualizer/vite/atlas-server-proxy.ts +2 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { liveInvalidator } from '../live/live-query';
|
|
1
2
|
import type { DataIssue } from '@crvouga/atlas/schema';
|
|
2
3
|
import { useContext, useEffect, useMemo } from 'react';
|
|
3
4
|
import { isStatechartFile, RunsIndexSchema, SpecIndexSchema } from '@crvouga/atlas/schema';
|
|
@@ -71,28 +72,34 @@ export function useLiveUpdates(enabled = true) {
|
|
|
71
72
|
useEffect(() => {
|
|
72
73
|
if (!enabled) return;
|
|
73
74
|
const unsubscribe: (() => void)[] = [];
|
|
75
|
+
const refreshers = new Map<string, ReturnType<typeof liveInvalidator>>();
|
|
76
|
+
const refresh = (key: readonly string[]) => {
|
|
77
|
+
const id = JSON.stringify(key);
|
|
78
|
+
let reread = refreshers.get(id);
|
|
79
|
+
if (!reread) { reread = liveInvalidator(client, key); refreshers.set(id, reread); }
|
|
80
|
+
reread();
|
|
81
|
+
};
|
|
74
82
|
if (adapter.subscribe)
|
|
75
83
|
unsubscribe.push(
|
|
76
84
|
adapter.subscribe((change) => {
|
|
77
85
|
if (change.scope !== 'specs') return;
|
|
78
|
-
|
|
79
|
-
if (!change.files.length)
|
|
80
|
-
for (const file of change.files)
|
|
86
|
+
refresh(keys.specIndex(specKey(adapter)));
|
|
87
|
+
if (!change.files.length) refresh(['spec', specKey(adapter)]);
|
|
88
|
+
for (const file of change.files) refresh(keys.specFile(specKey(adapter), file));
|
|
81
89
|
})
|
|
82
90
|
);
|
|
83
91
|
for (const { backend, enabled: active } of entries) {
|
|
84
92
|
if (!active || !backend.subscribe) continue;
|
|
85
93
|
unsubscribe.push(
|
|
86
94
|
backend.subscribe((change) => {
|
|
87
|
-
if (change.scope !== 'runs') return;
|
|
88
|
-
|
|
89
|
-
if (!change.runIds.length) void client.invalidateQueries({ queryKey: keys.manifests(backend.cacheKey) });
|
|
90
|
-
for (const id of change.runIds) void client.invalidateQueries({ queryKey: keys.manifest(backend.cacheKey, id) });
|
|
95
|
+
if (change.scope !== 'runs' || change.liveRunId) return;
|
|
96
|
+
refresh(['reports', backend.cacheKey]);
|
|
91
97
|
})
|
|
92
98
|
);
|
|
93
99
|
}
|
|
94
100
|
return () => {
|
|
95
101
|
for (const stop of unsubscribe) stop();
|
|
102
|
+
for (const refresh of refreshers.values()) refresh.cancel();
|
|
96
103
|
};
|
|
97
104
|
}, [adapter, entries, client, enabled]);
|
|
98
105
|
}
|
|
@@ -33,7 +33,8 @@ function explain(status: number) {
|
|
|
33
33
|
|
|
34
34
|
/** A captured file's address in the browser. Pictures and video use it directly; fetches use the same address. */
|
|
35
35
|
export function reportAssetUrl(path: string): string {
|
|
36
|
-
|
|
36
|
+
const base = runtime()?.reportsUrl?.replace(/\/$/, '') ?? reportsBase;
|
|
37
|
+
return staticEvidence ? `${base}/${evidenceFile(path).split('/').map(encodeURIComponent).join('/')}` : `${base}${path}`;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export async function reportResponse(path: string, signal?: AbortSignal) {
|
|
@@ -16,6 +16,7 @@ import type { FeatureExamples, FeatureStep } from './gherkin';
|
|
|
16
16
|
import { Bounded, Origin, StatusMark } from './marks';
|
|
17
17
|
import { ModelTrace } from './model-trace';
|
|
18
18
|
import { recordedModelStates } from './quint';
|
|
19
|
+
import { TestProgress, useTestProgress } from '../live/test-progress';
|
|
19
20
|
|
|
20
21
|
/** What the address says is open: a check, and optionally one particular layer's record of it. */
|
|
21
22
|
export type CheckSelection = { readonly check?: string; readonly layer?: string };
|
|
@@ -59,8 +60,11 @@ function outcomeName(outcome: EvidenceResult) {
|
|
|
59
60
|
|
|
60
61
|
export function ResultRow({ label, slot, open, onToggle, children }: { label: string; slot: CheckSlot | null; open: boolean; onToggle: () => void; children: ReactNode }) {
|
|
61
62
|
const now = useNow();
|
|
63
|
+
const progress = useTestProgress(slot);
|
|
64
|
+
const current = progress.tests.find(({ run, check }) => run.status === 'running' && check.status === 'running');
|
|
65
|
+
const interrupted = !current && progress.tests.find(({ run, check }) => run.status === 'interrupted' && check.status === 'not-run');
|
|
62
66
|
const newest = slot?.outcomes[0];
|
|
63
|
-
const marks = slot ? environmentMarks(slot) : [];
|
|
67
|
+
const marks = slot && !current ? environmentMarks(slot) : [];
|
|
64
68
|
const failure = slot?.status === 'failed' ? slot.outcomes.find((outcome) => outcome.row.status === 'failed')?.row.detail.split('\n')[0] : '';
|
|
65
69
|
return (
|
|
66
70
|
<li className={styles.result} data-status={slot?.status ?? 'none'} data-open={open || undefined}>
|
|
@@ -77,12 +81,15 @@ export function ResultRow({ label, slot, open, onToggle, children }: { label: st
|
|
|
77
81
|
)}
|
|
78
82
|
</span>
|
|
79
83
|
{slot?.live && <span className={styles.liveTag}>Running now</span>}
|
|
84
|
+
{interrupted && <span className={styles.liveTag}>Interrupted</span>}
|
|
80
85
|
<span className={styles.resultAside}>
|
|
81
|
-
{newest ? [newest.row.evidence?.attachments ? plural(newest.row.evidence.attachments, 'attachment', 'attachments') : '', duration(newest.row.durationMs), slot.live ? '' : ago(slot.latestAtMs, now)].filter(Boolean).join(' · ') : 'No result'}
|
|
86
|
+
{current ? duration(current.check.durationMs ?? null) || 'In progress' : interrupted ? 'No completed result' : newest ? [newest.row.evidence?.attachments ? plural(newest.row.evidence.attachments, 'attachment', 'attachments') : '', duration(newest.row.durationMs), slot.live ? '' : ago(slot.latestAtMs, now)].filter(Boolean).join(' · ') : 'No result'}
|
|
82
87
|
</span>
|
|
83
88
|
<span className={styles.chevron} aria-hidden="true" />
|
|
84
89
|
</button>
|
|
85
|
-
{!open &&
|
|
90
|
+
{!open && current && <p className={styles.excerpt} data-progress="true" role="status">{current.check.detail || current.check.steps?.at(-1)?.name || 'Test in progress…'}{progress.error ? ` · ${progress.error}` : ''}</p>}
|
|
91
|
+
{!open && interrupted && <p className={styles.excerpt} data-progress="true" role="status">{interrupted.run.reason ?? 'The test run was interrupted.'}</p>}
|
|
92
|
+
{!open && !current && failure && <p className={styles.excerpt}>{failure}</p>}
|
|
86
93
|
{open && <div className={styles.resultBody}>{children}</div>}
|
|
87
94
|
</li>
|
|
88
95
|
);
|
|
@@ -197,7 +204,15 @@ type Props = {
|
|
|
197
204
|
};
|
|
198
205
|
|
|
199
206
|
/** Everything recorded for one check: the chosen result, how it went, and every other run of it. */
|
|
200
|
-
export function CheckDetail(
|
|
207
|
+
export function CheckDetail(props: Props) {
|
|
208
|
+
const progress = useTestProgress(props.slot);
|
|
209
|
+
const inProgress = progress.tests.some(({ run, check }) => check.status === 'running' && run.status === 'running' || check.status === 'not-run' && run.status === 'interrupted');
|
|
210
|
+
return <><TestProgress {...progress} />{props.slot.outcomes.length > 0
|
|
211
|
+
? inProgress && !props.layerId ? <details className={styles.actions}><summary>Previous recorded result</summary><RecordedCheckDetail {...props} /></details> : <RecordedCheckDetail {...props} />
|
|
212
|
+
: props.written}</>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function RecordedCheckDetail({ slot, layerId, select, reads, written }: Props) {
|
|
201
216
|
const { evidence, backends } = useEvidence();
|
|
202
217
|
const now = useNow();
|
|
203
218
|
const [variant, setVariant] = useState(0);
|
|
@@ -7,23 +7,27 @@ import styles from './evidence.module.css';
|
|
|
7
7
|
import { Bounded } from './marks';
|
|
8
8
|
import { SourceText, SpecHeader } from './spec-header';
|
|
9
9
|
import { SpecSnapshot } from './spec-snapshot';
|
|
10
|
+
import { TestFilter, useRunningFilter } from './test-filter';
|
|
10
11
|
|
|
11
12
|
type Props = { file: string; text: string; kind: SpecKind; entry: SpecEvidence | undefined; selection: CheckSelection; select: SelectCheck };
|
|
12
13
|
|
|
13
14
|
/** A decision table, structure or objective: what it says, and each check recorded against it. */
|
|
14
15
|
export function DocumentPage({ file, text, kind, entry, selection, select }: Props) {
|
|
15
16
|
const slots = entry?.slots ?? [];
|
|
17
|
+
const runningOnly = useRunningFilter();
|
|
18
|
+
const visible = slots.filter(slot => !runningOnly || slot.status === 'running');
|
|
16
19
|
const toggle = (id: string) => select({ check: selection.check === id ? null : id, layer: null });
|
|
17
20
|
const name = file.split('/').at(-1)!.replace(/\.[^.]+$/, '').replace(/\.openslo$/, '');
|
|
18
21
|
return (
|
|
19
22
|
<article className={styles.page}>
|
|
20
23
|
<SpecHeader title={name} file={file} kind={kind} entry={entry} />
|
|
21
24
|
<SpecSnapshot title={name} text={text} kind={kind.one} />
|
|
22
|
-
{slots
|
|
25
|
+
<TestFilter slots={slots} />
|
|
26
|
+
{visible.length > 0 && (
|
|
23
27
|
<section className={styles.block}>
|
|
24
28
|
<h2 className={styles.blockTitle}>Checks</h2>
|
|
25
29
|
<ul className={styles.results}>
|
|
26
|
-
<Bounded items={
|
|
30
|
+
<Bounded items={visible} size={40} more="More checks">
|
|
27
31
|
{(slot) => (
|
|
28
32
|
<ResultRow key={slot.key} label={slot.title} slot={slot} open={selection.check === slot.id} onToggle={() => toggle(slot.id)}>
|
|
29
33
|
<CheckDetail slot={slot} layerId={selection.check === slot.id ? selection.layer : undefined} select={(patch) => select({ check: slot.id, ...patch })} reads="steps" />
|
|
@@ -10,7 +10,10 @@ import { useInfiniteQuery, useQueries, useQuery, useQueryClient } from '@tanstac
|
|
|
10
10
|
import { createContext, useContext, useEffect, useMemo, useRef, type ReactNode } from 'react';
|
|
11
11
|
|
|
12
12
|
import { keys, specKey, useAdapter } from '../data/queries';
|
|
13
|
-
import { eventsAddress, listenLive,
|
|
13
|
+
import { eventsAddress, listenLive, useLiveStatus } from '../live/live-stream';
|
|
14
|
+
import { liveInvalidator } from '../live/live-query';
|
|
15
|
+
import { useLiveTests } from '../live/live-runs';
|
|
16
|
+
import { withLiveProgress } from '../live/report-progress';
|
|
14
17
|
import { fileName, folderOf, kindOfFile, outlinePath } from '../spec-kind';
|
|
15
18
|
import { useUiStore } from '../state/ui-store';
|
|
16
19
|
import { effectiveEvidence, reportJson, staticEvidence } from './api';
|
|
@@ -92,22 +95,19 @@ export function EvidenceProvider({ children }: { children: ReactNode }) {
|
|
|
92
95
|
const url = eventsAddress();
|
|
93
96
|
if (!url) return;
|
|
94
97
|
// A burst of results (a CI run finishing, many shards at once) is read once, not once for each.
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const reoutline = throttled(() => {
|
|
100
|
-
void client.invalidateQueries({ queryKey: ['evidence-outline'] });
|
|
101
|
-
void client.invalidateQueries({ queryKey: ['evidence-effective'] });
|
|
102
|
-
}, LIVE_REREAD_MS);
|
|
98
|
+
const reads = [liveInvalidator(client, ['evidence-effective'], LIVE_REREAD_MS), liveInvalidator(client, ['evidence-index'], LIVE_REREAD_MS)];
|
|
99
|
+
const outlines = [liveInvalidator(client, ['evidence-outline'], LIVE_REREAD_MS), liveInvalidator(client, ['spec', source], LIVE_REREAD_MS)];
|
|
100
|
+
const reread = () => reads.forEach(refresh => refresh());
|
|
101
|
+
const reoutline = () => { outlines.forEach(refresh => refresh()); reread(); };
|
|
103
102
|
const stop = listenLive(url, (event) => {
|
|
104
103
|
if (event.type === 'change' && event.change.scope === 'specs') return reoutline();
|
|
104
|
+
if (event.type === 'change' && event.change.scope === 'runs' && !event.change.liveRunId) return reread();
|
|
105
105
|
if (event.type !== 'report' && event.type !== 'open') return;
|
|
106
106
|
reread();
|
|
107
107
|
if (event.type === 'open') reoutline();
|
|
108
108
|
});
|
|
109
|
-
return () => { stop();
|
|
110
|
-
}, [client]);
|
|
109
|
+
return () => { stop(); [...reads, ...outlines].forEach(refresh => refresh.cancel()); };
|
|
110
|
+
}, [client, source]);
|
|
111
111
|
|
|
112
112
|
const specIndex = useQuery({ queryKey: keys.specIndex(source), queryFn: adapter.specIndex, retry: false });
|
|
113
113
|
const files = useMemo(() => {
|
|
@@ -129,6 +129,8 @@ export function EvidenceProvider({ children }: { children: ReactNode }) {
|
|
|
129
129
|
},
|
|
130
130
|
enabled: effective,
|
|
131
131
|
staleTime: 0,
|
|
132
|
+
refetchOnWindowFocus: 'always',
|
|
133
|
+
refetchOnReconnect: 'always',
|
|
132
134
|
retry: false,
|
|
133
135
|
refetchInterval: streaming ? LIVE_SAFETY_POLL_MS : EFFECTIVE_POLL_MS
|
|
134
136
|
});
|
|
@@ -140,6 +142,8 @@ export function EvidenceProvider({ children }: { children: ReactNode }) {
|
|
|
140
142
|
queryFn: ({ pageParam, signal }) => reportJson<EvidencePage>(`/evidence${pageParam ? `?cursor=${encodeURIComponent(pageParam)}` : ''}`, signal),
|
|
141
143
|
getNextPageParam: (page) => page.cursor,
|
|
142
144
|
staleTime: 0,
|
|
145
|
+
refetchOnWindowFocus: 'always',
|
|
146
|
+
refetchOnReconnect: 'always',
|
|
143
147
|
retry: false,
|
|
144
148
|
refetchInterval: (query) => (staticEvidence ? false : streaming ? LIVE_SAFETY_POLL_MS : query.state.data?.pages[0]?.active.length ? LIVE_POLL_MS : IDLE_POLL_MS)
|
|
145
149
|
});
|
|
@@ -186,7 +190,8 @@ export function EvidenceProvider({ children }: { children: ReactNode }) {
|
|
|
186
190
|
}, [wanted, rows.stamp]);
|
|
187
191
|
|
|
188
192
|
const shown = effective ? overview.data?.layers ?? NONE : evidence;
|
|
189
|
-
const
|
|
193
|
+
const { tests: liveTests } = useLiveTests();
|
|
194
|
+
const view = useMemo(() => withLiveProgress(evidenceView(combineEvidence(outline, shown)), liveTests), [outline, shown, liveTests]);
|
|
190
195
|
const titles = useMemo(() => new Map(outline.specs.map((spec) => [spec.path, spec.title])), [outline]);
|
|
191
196
|
const effectiveHeaders = useMemo(() => (overview.data?.layers ?? NONE).map((entry) => entry.layer), [overview.data]);
|
|
192
197
|
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
.mark[data-status='running'] {
|
|
17
17
|
color: var(--place);
|
|
18
|
-
animation: turn
|
|
18
|
+
animation: turn 1.8s linear infinite;
|
|
19
|
+
opacity: .8;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
@keyframes turn {
|
|
@@ -509,6 +510,11 @@
|
|
|
509
510
|
transform: rotate(-135deg);
|
|
510
511
|
}
|
|
511
512
|
|
|
513
|
+
.excerpt[data-progress] {
|
|
514
|
+
color: var(--ink-3);
|
|
515
|
+
font-family: inherit;
|
|
516
|
+
}
|
|
517
|
+
|
|
512
518
|
.excerpt {
|
|
513
519
|
margin: -4px 14px 10px 40px;
|
|
514
520
|
overflow: hidden;
|
|
@@ -895,3 +901,7 @@ a.listRow:hover {
|
|
|
895
901
|
display: none;
|
|
896
902
|
}
|
|
897
903
|
}
|
|
904
|
+
|
|
905
|
+
@media (prefers-reduced-motion: reduce) {
|
|
906
|
+
.mark[data-status='running'] { animation: none; }
|
|
907
|
+
}
|
|
@@ -8,6 +8,7 @@ import { featureModel, scenarioLabel, slotsByScenario, type FeatureModel, type F
|
|
|
8
8
|
import { Bounded } from './marks';
|
|
9
9
|
import { SourceText, SpecHeader } from './spec-header';
|
|
10
10
|
import { SpecSnapshot } from './spec-snapshot';
|
|
11
|
+
import { TestFilter, useRunningFilter } from './test-filter';
|
|
11
12
|
|
|
12
13
|
type Props = { file: string; text: string; kind: SpecKind; entry: SpecEvidence | undefined; selection: CheckSelection; select: SelectCheck };
|
|
13
14
|
|
|
@@ -23,6 +24,7 @@ function parse(text: string): { feature: FeatureModel | null; error: string | nu
|
|
|
23
24
|
export function FeaturePage({ file, text, kind, entry, selection, select }: Props) {
|
|
24
25
|
const { feature, error } = useMemo(() => parse(text), [text]);
|
|
25
26
|
const slots = entry?.slots ?? [];
|
|
27
|
+
const runningOnly = useRunningFilter();
|
|
26
28
|
const filed = useMemo(() => (feature ? slotsByScenario(feature, slots) : { byScenario: new Map<string, CheckSlot[]>(), leftOver: slots }), [feature, slots]);
|
|
27
29
|
const scenarios = feature?.rules.flatMap((rule) => rule.scenarios.filter((scenario) => !scenario.background)) ?? [];
|
|
28
30
|
const planned = scenarios.reduce((count, scenario) => count + Math.max(1, filed.byScenario.get(scenario.name)?.length ?? 0), 0) + filed.leftOver.length;
|
|
@@ -30,8 +32,9 @@ export function FeaturePage({ file, text, kind, entry, selection, select }: Prop
|
|
|
30
32
|
|
|
31
33
|
const rows = (scenario: FeatureScenario) => {
|
|
32
34
|
const written = <PlainSteps steps={scenario.steps} examples={scenario.examples} />;
|
|
33
|
-
const recorded = filed.byScenario.get(scenario.name) ?? [];
|
|
35
|
+
const recorded = (filed.byScenario.get(scenario.name) ?? []).filter(slot => !runningOnly || slot.status === 'running');
|
|
34
36
|
if (!recorded.length) {
|
|
37
|
+
if (runningOnly) return null;
|
|
35
38
|
const id = `scenario:${scenario.name}`;
|
|
36
39
|
return (
|
|
37
40
|
<ResultRow key={id} label={scenario.name} slot={null} open={selection.check === id} onToggle={() => toggle(id)}>
|
|
@@ -53,6 +56,7 @@ export function FeaturePage({ file, text, kind, entry, selection, select }: Prop
|
|
|
53
56
|
{feature?.description && <p className={styles.lead}>{feature.description}</p>}
|
|
54
57
|
</SpecHeader>
|
|
55
58
|
<SpecSnapshot title={feature?.name || file.split('/').at(-1)!} text={text} kind={kind.one} />
|
|
59
|
+
<TestFilter slots={slots} />
|
|
56
60
|
{error && <p role="alert" className={styles.notice}>This feature could not be parsed: {error}</p>}
|
|
57
61
|
{feature?.rules.map((rule, index) => (
|
|
58
62
|
<section key={index} className={styles.block}>
|
|
@@ -67,12 +71,12 @@ export function FeaturePage({ file, text, kind, entry, selection, select }: Prop
|
|
|
67
71
|
<ul className={styles.results}>{rule.scenarios.filter((scenario) => !scenario.background).map(rows)}</ul>
|
|
68
72
|
</section>
|
|
69
73
|
))}
|
|
70
|
-
{filed.leftOver.
|
|
74
|
+
{filed.leftOver.some(slot => !runningOnly || slot.status === 'running') && (
|
|
71
75
|
<section className={styles.block}>
|
|
72
76
|
<h2 className={styles.blockTitle}>{feature ? 'Other recorded results' : 'Recorded results'}</h2>
|
|
73
77
|
{feature && <p className={styles.blockLead}>Recorded against this file under a name no scenario has now.</p>}
|
|
74
78
|
<ul className={styles.results}>
|
|
75
|
-
<Bounded items={filed.leftOver} size={40} more="More results">
|
|
79
|
+
<Bounded items={filed.leftOver.filter(slot => !runningOnly || slot.status === 'running')} size={40} more="More results">
|
|
76
80
|
{(slot) => (
|
|
77
81
|
<ResultRow key={slot.key} label={feature ? scenarioLabel(feature.name, slot.title) : slot.title} slot={slot} open={selection.check === slot.id} onToggle={() => toggle(slot.id)}>
|
|
78
82
|
<CheckDetail slot={slot} layerId={selection.check === slot.id ? selection.layer : undefined} select={(patch) => select({ check: slot.id, ...patch })} reads="steps" />
|
|
@@ -8,6 +8,7 @@ import { Bounded } from './marks';
|
|
|
8
8
|
import { QUINT_GROUPS, quintOutline } from './quint';
|
|
9
9
|
import { SourceText, SpecHeader } from './spec-header';
|
|
10
10
|
import { SpecSnapshot } from './spec-snapshot';
|
|
11
|
+
import { TestFilter, useRunningFilter } from './test-filter';
|
|
11
12
|
|
|
12
13
|
type Props = { file: string; text: string; kind: SpecKind; entry: SpecEvidence | undefined; selection: CheckSelection; select: SelectCheck };
|
|
13
14
|
|
|
@@ -16,17 +17,20 @@ export function ModelPage({ file, text, kind, entry, selection, select }: Props)
|
|
|
16
17
|
const items = useMemo(() => quintOutline(text), [text]);
|
|
17
18
|
const moduleName = items.find((item) => item.kind === 'module')?.name;
|
|
18
19
|
const slots = useMemo(() => [...(entry?.slots ?? [])].sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true })), [entry]);
|
|
20
|
+
const runningOnly = useRunningFilter();
|
|
21
|
+
const visible = slots.filter(slot => !runningOnly || slot.status === 'running');
|
|
19
22
|
const toggle = (id: string) => select({ check: selection.check === id ? null : id, layer: null });
|
|
20
23
|
const label = (title: string) => (moduleName && title.startsWith(`${moduleName} · `) ? title.slice(moduleName.length + 3) : title);
|
|
21
24
|
return (
|
|
22
25
|
<article className={styles.page}>
|
|
23
26
|
<SpecHeader title={moduleName ?? file.split('/').at(-1)!} file={file} kind={kind} entry={entry} />
|
|
24
27
|
<SpecSnapshot title={moduleName ?? file.split('/').at(-1)!} text={text} kind={kind.one} />
|
|
25
|
-
{slots
|
|
28
|
+
<TestFilter slots={slots} />
|
|
29
|
+
{visible.length > 0 && (
|
|
26
30
|
<section className={styles.block}>
|
|
27
31
|
<h2 className={styles.blockTitle}>Traces</h2>
|
|
28
32
|
<ul className={styles.results}>
|
|
29
|
-
<Bounded items={
|
|
33
|
+
<Bounded items={visible} size={40} more="More traces">
|
|
30
34
|
{(slot) => (
|
|
31
35
|
<ResultRow key={slot.key} label={label(slot.title)} slot={slot} open={selection.check === slot.id} onToggle={() => toggle(slot.id)}>
|
|
32
36
|
<CheckDetail slot={slot} layerId={selection.check === slot.id ? selection.layer : undefined} select={(patch) => select({ check: slot.id, ...patch })} reads="trace" />
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
}
|
|
@@ -10,6 +10,7 @@ import styles from './evidence.module.css';
|
|
|
10
10
|
import { duration, statusWord, useNow } from './format';
|
|
11
11
|
import { Origin, StatusMark } from './marks';
|
|
12
12
|
import { Timeline } from './recording';
|
|
13
|
+
import { TestProgress, useTestProgress } from '../live/test-progress';
|
|
13
14
|
|
|
14
15
|
type Props = {
|
|
15
16
|
/** The check whose recording is open, once the combined report holds it. */
|
|
@@ -39,6 +40,7 @@ function needsStylesheet(report: RunReport | undefined): boolean {
|
|
|
39
40
|
|
|
40
41
|
/** One recorded test, opened in place of its spec. */
|
|
41
42
|
export function RecordingPage({ slot, layerId, label, back, moment, onMoment, onClose }: Props) {
|
|
43
|
+
const progress = useTestProgress(slot ?? null);
|
|
42
44
|
const { evidence, backends, pending, ready, unavailable } = useEvidence();
|
|
43
45
|
const now = useNow();
|
|
44
46
|
const history = useMemo(() => (slot ? checkHistory(evidence, slot.specPath, slot.id) : []), [evidence, slot]);
|
|
@@ -64,8 +66,9 @@ export function RecordingPage({ slot, layerId, label, back, moment, onMoment, on
|
|
|
64
66
|
</p>
|
|
65
67
|
)}
|
|
66
68
|
</header>
|
|
69
|
+
<TestProgress {...progress} />
|
|
67
70
|
{waiting && <p className={styles.aside} role="status">Loading the recording…</p>}
|
|
68
|
-
{!chosen && !waiting && <p className={styles.notice}>{unavailable ?? 'This result is not in the combined report any more. A newer run may have replaced it, or its source is switched off.'}</p>}
|
|
71
|
+
{!chosen && !waiting && !progress.tests.length && <p className={styles.notice}>{unavailable ?? 'This result is not in the combined report any more. A newer run may have replaced it, or its source is switched off.'}</p>}
|
|
69
72
|
{chosen && !paths && <p className={styles.notice}>This result has an outcome and no recorded timeline.</p>}
|
|
70
73
|
{report.isLoading && <p className={styles.aside} role="status">Loading the recording…</p>}
|
|
71
74
|
{report.isError && (
|
|
@@ -11,6 +11,7 @@ import { StatusMark, Tally } from './marks';
|
|
|
11
11
|
const FILTERS: readonly { readonly id: ResultFilter; readonly label: string; readonly keeps: (summary: SpecSummary) => boolean }[] = [
|
|
12
12
|
{ id: 'all', label: 'All', keeps: () => true },
|
|
13
13
|
{ id: 'results', label: 'Results', keeps: (summary) => summary.status !== 'none' },
|
|
14
|
+
{ id: 'running', label: 'Running', keeps: (summary) => summary.counts.running > 0 },
|
|
14
15
|
{ id: 'failed', label: 'Failing', keeps: (summary) => summary.counts.failed > 0 },
|
|
15
16
|
{ id: 'none', label: 'None', keeps: (summary) => summary.status === 'none' }
|
|
16
17
|
];
|
|
@@ -47,7 +48,7 @@ export function SpecIndex({ kind }: { kind: SpecKind }) {
|
|
|
47
48
|
{evidence.filesError && <p className={styles.indexNote}>The file list could not be loaded.</p>}
|
|
48
49
|
{!evidence.filesPending && !evidence.filesError && visible.length === 0 && (
|
|
49
50
|
<p className={styles.indexNote}>
|
|
50
|
-
{rows.length === 0 ? `No ${kind.many} in this spec set.` : filter === 'failed' ? `No failing ${kind.many}.` : filter === 'results' ? `No ${kind.many} with results yet.` : `Every ${kind.one} has results.`}
|
|
51
|
+
{rows.length === 0 ? `No ${kind.many} in this spec set.` : filter === 'running' ? `No running ${kind.many}.` : filter === 'failed' ? `No failing ${kind.many}.` : filter === 'results' ? `No ${kind.many} with results yet.` : `Every ${kind.one} has results.`}
|
|
51
52
|
</p>
|
|
52
53
|
)}
|
|
53
54
|
{groups.map(([folder, entries]) => (
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CheckSlot } from '@crvouga/atlas/reports/evidence-view';
|
|
2
|
+
import { useUiStore } from '../state/ui-store';
|
|
3
|
+
import styles from './evidence.module.css';
|
|
4
|
+
|
|
5
|
+
/** Filter actual test rows as well as the spec index. Counts always describe the whole document. */
|
|
6
|
+
export function TestFilter({ slots }: { slots: readonly CheckSlot[] }) {
|
|
7
|
+
const filter = useUiStore(s => s.resultFilter);
|
|
8
|
+
const setFilter = useUiStore(s => s.setResultFilter);
|
|
9
|
+
const running = slots.filter(slot => slot.status === 'running').length;
|
|
10
|
+
return <>
|
|
11
|
+
<div className={styles.filters} role="group" aria-label="Test status">
|
|
12
|
+
<button type="button" aria-pressed={filter !== 'running'} onClick={() => setFilter('all')}>All tests <span>{slots.length}</span></button>
|
|
13
|
+
<button type="button" aria-pressed={filter === 'running'} onClick={() => setFilter('running')}>Running <span>{running}</span></button>
|
|
14
|
+
</div>
|
|
15
|
+
{filter === 'running' && running === 0 && <p className={styles.aside} role="status">No running tests.</p>}
|
|
16
|
+
</>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useRunningFilter() {
|
|
20
|
+
return useUiStore(s => s.resultFilter === 'running');
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { QueryClient, QueryKey } from '@tanstack/react-query';
|
|
2
|
+
import { throttled } from './live-stream';
|
|
3
|
+
|
|
4
|
+
/** Coalesce bursts without cancelling useful work; an event during a fetch guarantees one trailing read. */
|
|
5
|
+
export function liveInvalidator(client: QueryClient, queryKey: QueryKey, ms = 150) {
|
|
6
|
+
let dirty = false;
|
|
7
|
+
let reading = false;
|
|
8
|
+
let stopped = false;
|
|
9
|
+
const drain = throttled(() => {
|
|
10
|
+
if (stopped || reading || !dirty) return;
|
|
11
|
+
dirty = false;
|
|
12
|
+
reading = true;
|
|
13
|
+
void client.invalidateQueries({ queryKey }, { cancelRefetch: false }).finally(() => {
|
|
14
|
+
reading = false;
|
|
15
|
+
if (dirty && !stopped) drain();
|
|
16
|
+
});
|
|
17
|
+
}, ms);
|
|
18
|
+
const refresh = () => { if (!stopped) { dirty = true; drain(); } };
|
|
19
|
+
refresh.cancel = () => { stopped = true; dirty = false; drain.cancel(); };
|
|
20
|
+
return refresh;
|
|
21
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useMemo, type ReactNode } from 'react';
|
|
2
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import type { LiveRun } from '@crvouga/atlas/schema';
|
|
4
|
+
import { runtimeConfig, runtimeUrl } from '../runtime';
|
|
5
|
+
import { eventsAddress, listenLive, useLiveStatus } from './live-stream';
|
|
6
|
+
import { liveInvalidator } from './live-query';
|
|
7
|
+
import type { LiveCheck } from '@crvouga/atlas/schema';
|
|
8
|
+
import { useUiStore } from '../state/ui-store';
|
|
9
|
+
import { indexLiveTests } from './report-progress';
|
|
10
|
+
|
|
11
|
+
export type LiveTest = { run: LiveRun; check: LiveCheck };
|
|
12
|
+
type LiveState = { tests: readonly LiveTest[]; index: ReturnType<typeof indexLiveTests>; error: string | null };
|
|
13
|
+
const EMPTY: LiveState = { tests: [], index: new Map(), error: null };
|
|
14
|
+
const LiveContext = createContext<LiveState>(EMPTY);
|
|
15
|
+
|
|
16
|
+
/** One subscription shared by report rows; leases never rewrite historical report results. */
|
|
17
|
+
export function LiveRunsProvider({ children, enabled = true }: { children: ReactNode; enabled?: boolean }) {
|
|
18
|
+
const client = useQueryClient();
|
|
19
|
+
const streaming = useLiveStatus() === 'live';
|
|
20
|
+
const excluded = useUiStore(s => s.excluded);
|
|
21
|
+
const api = runtimeUrl(runtimeConfig()?.api);
|
|
22
|
+
const url = api && enabled ? `${api.replace(/\/$/, '')}/live` : undefined;
|
|
23
|
+
const key = ['live-runs', url] as const;
|
|
24
|
+
const query = useQuery({ queryKey: key, enabled: !!url, retry: false, staleTime: 0, refetchOnWindowFocus: 'always', refetchOnReconnect: 'always',
|
|
25
|
+
queryFn: async ({ signal }) => {
|
|
26
|
+
const response = await fetch(url!, { signal, cache: 'no-store' });
|
|
27
|
+
if (!response.ok) throw new Error(`Live feed unavailable (${response.status}). Last received results may be stale.`);
|
|
28
|
+
const fetched = (await response.json()) as { runs: LiveRun[] };
|
|
29
|
+
// An event can arrive while this HTTP snapshot is in flight. Keep its newer progress,
|
|
30
|
+
// while still loading the other runs from the snapshot rather than cancelling it.
|
|
31
|
+
const held = client.getQueryData<{ runs: LiveRun[] }>(key);
|
|
32
|
+
const runs = new Map(fetched.runs.map(run => [run.id, run]));
|
|
33
|
+
for (const run of held?.runs ?? []) {
|
|
34
|
+
const read = runs.get(run.id);
|
|
35
|
+
if (!read || run.sequence > read.sequence || (run.sequence === read.sequence &&
|
|
36
|
+
((run.status !== 'running' && read.status === 'running') || run.lastHeartbeatAtMs > read.lastHeartbeatAtMs))) runs.set(run.id, run);
|
|
37
|
+
}
|
|
38
|
+
return { runs: [...runs.values()].sort((a, b) => b.startedAtMs - a.startedAtMs || a.id.localeCompare(b.id)).slice(0, 50) };
|
|
39
|
+
}, refetchInterval: streaming ? 60_000 : 2000, refetchIntervalInBackground: true });
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const address = eventsAddress();
|
|
42
|
+
if (!address || !url) return;
|
|
43
|
+
const refresh = liveInvalidator(client, ['live-runs', url]);
|
|
44
|
+
const stop = listenLive(address, event => {
|
|
45
|
+
if (event.type === 'run') {
|
|
46
|
+
client.setQueryData<{ runs: LiveRun[] }>(['live-runs', url], held => ({ runs: [event.run, ...(held?.runs ?? []).filter(run => run.id !== event.run.id)]
|
|
47
|
+
.sort((a, b) => b.startedAtMs - a.startedAtMs || a.id.localeCompare(b.id)).slice(0, 50) }));
|
|
48
|
+
}
|
|
49
|
+
if (event.type === 'open' || (event.type === 'change' && event.change.scope === 'runs' && !event.change.liveRunId)) refresh();
|
|
50
|
+
});
|
|
51
|
+
return () => { stop(); refresh.cancel(); };
|
|
52
|
+
}, [client, url]);
|
|
53
|
+
const value = useMemo(() => {
|
|
54
|
+
const tests: LiveTest[] = [];
|
|
55
|
+
for (const run of [...(query.data?.runs ?? [])].sort((a, b) => b.startedAtMs - a.startedAtMs)) {
|
|
56
|
+
if (excluded.branches.includes(run.branch ?? 'Unknown branch') || excluded.versions.includes(run.sha ?? 'unknown') ||
|
|
57
|
+
excluded.environments.includes(run.environment || 'unspecified') || excluded.backends.length > 0) continue;
|
|
58
|
+
const checks = new Map([...run.recent, ...run.current].map(check => [check.id, check]));
|
|
59
|
+
for (const check of checks.values()) tests.push({ run, check: run.status !== 'running' && check.status === 'running'
|
|
60
|
+
? { ...check, status: 'not-run', detail: run.reason ?? 'Run ended before this test completed.' } : check });
|
|
61
|
+
}
|
|
62
|
+
return { tests, index: indexLiveTests(tests), error: query.error?.message ?? null };
|
|
63
|
+
}, [query.data, query.error, excluded]);
|
|
64
|
+
return <LiveContext.Provider value={url ? value : EMPTY}>{children}</LiveContext.Provider>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function useLiveTests() {
|
|
68
|
+
return useContext(LiveContext);
|
|
69
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AtlasChangeSchema, type AtlasChange } from '@crvouga/atlas/schema';
|
|
1
|
+
import { AtlasChangeSchema, LiveRunSchema, type LiveRun, type AtlasChange } from '@crvouga/atlas/schema';
|
|
2
2
|
import { useSyncExternalStore } from 'react';
|
|
3
3
|
|
|
4
4
|
import { runtimeConfig, runtimeUrl } from '../runtime';
|
|
@@ -6,6 +6,8 @@ import { runtimeConfig, runtimeUrl } from '../runtime';
|
|
|
6
6
|
/** What the server tells a viewer as it happens. */
|
|
7
7
|
export type LiveEvent =
|
|
8
8
|
| { readonly type: 'open' }
|
|
9
|
+
| { readonly type: 'run'; readonly run: LiveRun }
|
|
10
|
+
| { readonly type: 'projects' }
|
|
9
11
|
| { readonly type: 'report'; readonly report: ReportAnnouncement }
|
|
10
12
|
| { readonly type: 'change'; readonly change: AtlasChange };
|
|
11
13
|
|
|
@@ -74,31 +76,45 @@ function connect(stream: Stream) {
|
|
|
74
76
|
stream.timer = null;
|
|
75
77
|
const source = new EventSource(stream.url);
|
|
76
78
|
stream.source = source;
|
|
79
|
+
let silenceMs = SILENCE_MS;
|
|
80
|
+
const on = (name: string, handler: (event: Event) => void) => source.addEventListener(name, event => {
|
|
81
|
+
if (stream.source === source) handler(event);
|
|
82
|
+
});
|
|
77
83
|
const heard = () => {
|
|
78
84
|
if (stream.watchdog) clearTimeout(stream.watchdog);
|
|
79
|
-
stream.watchdog = setTimeout(() => lose(stream),
|
|
85
|
+
stream.watchdog = setTimeout(() => lose(stream), silenceMs);
|
|
80
86
|
};
|
|
81
|
-
|
|
87
|
+
on('open', () => {
|
|
82
88
|
heard();
|
|
83
89
|
stream.attempts = 0;
|
|
84
90
|
setStatus(stream, 'live');
|
|
85
91
|
// Anything may have happened while the stream was away, so a (re)connection reads everything again.
|
|
86
92
|
emit(stream, { type: 'open' });
|
|
87
93
|
});
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
94
|
+
on('hello', event => {
|
|
95
|
+
const hello = parse((event as MessageEvent<string>).data) as { heartbeatMs?: unknown } | null;
|
|
96
|
+
if (typeof hello?.heartbeatMs === 'number' && Number.isFinite(hello.heartbeatMs) && hello.heartbeatMs > 0) silenceMs = Math.max(SILENCE_MS, hello.heartbeatMs * 3);
|
|
97
|
+
heard();
|
|
98
|
+
});
|
|
99
|
+
on('ping', heard);
|
|
100
|
+
on('report', (event) => {
|
|
91
101
|
heard();
|
|
92
102
|
const report = parse((event as MessageEvent<string>).data) as ReportAnnouncement | null;
|
|
93
103
|
if (report && typeof report.version === 'string') emit(stream, { type: 'report', report: { ...report, added: Array.isArray(report.added) ? report.added : [] } });
|
|
94
104
|
});
|
|
95
|
-
|
|
105
|
+
on('run', event => {
|
|
106
|
+
heard();
|
|
107
|
+
const run = LiveRunSchema.safeParse(parse((event as MessageEvent<string>).data));
|
|
108
|
+
if (run.success) emit(stream, { type: 'run', run: run.data });
|
|
109
|
+
});
|
|
110
|
+
on('projects', () => { heard(); emit(stream, { type: 'projects' }); });
|
|
111
|
+
on('atlas:change', (event) => {
|
|
96
112
|
heard();
|
|
97
113
|
const change = AtlasChangeSchema.safeParse(parse((event as MessageEvent<string>).data));
|
|
98
114
|
if (change.success) emit(stream, { type: 'change', change: change.data });
|
|
99
115
|
});
|
|
100
116
|
// The server's heartbeat is a named `ping` event, so a quiet server is told apart from a dead connection.
|
|
101
|
-
|
|
117
|
+
on('error', () => {
|
|
102
118
|
setStatus(stream, stream.attempts === 0 && stream.status === 'connecting' ? 'connecting' : 'reconnecting');
|
|
103
119
|
// The browser retries a dropped stream by itself. A refused one (401, 503) it gives up on: retry that here, slowly.
|
|
104
120
|
if (source.readyState === EventSource.CLOSED) lose(stream);
|
|
@@ -124,6 +140,7 @@ function release(stream: Stream) {
|
|
|
124
140
|
if (stream.timer) clearTimeout(stream.timer);
|
|
125
141
|
if (stream.watchdog) clearTimeout(stream.watchdog);
|
|
126
142
|
stream.source?.close();
|
|
143
|
+
stream.source = null;
|
|
127
144
|
streams.delete(stream.url);
|
|
128
145
|
setStatus(stream, 'off');
|
|
129
146
|
}
|
|
@@ -155,7 +172,11 @@ export function listenChanges(url: string, onChange: (change: AtlasChange) => vo
|
|
|
155
172
|
return listenLive(url, (event) => {
|
|
156
173
|
if (event.type === 'change') onChange(event.change);
|
|
157
174
|
// A fresh connection may have missed changes while it was away.
|
|
158
|
-
if (event.type === 'open')
|
|
175
|
+
if (event.type === 'open') {
|
|
176
|
+
onChange({ scope: 'specs', files: [], runIds: [] });
|
|
177
|
+
onChange({ scope: 'runs', files: [], runIds: [] });
|
|
178
|
+
}
|
|
179
|
+
if (event.type === 'report') onChange({ scope: 'runs', files: [], runIds: [] });
|
|
159
180
|
});
|
|
160
181
|
}
|
|
161
182
|
|