@crvouga/atlas 0.2.0 → 0.3.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 +7 -1
- package/README.md +8 -2
- package/dist/reports/src/export-evidence.js +2 -10
- package/dist/reports/src/frames.d.ts +3 -1
- package/dist/reports/src/frames.js +21 -0
- package/dist/reports/src/publish.js +2 -11
- package/dist/reports/src/recording-keys.js +4 -0
- package/dist/reports/src/report-types.d.ts +19 -2
- package/dist/reports/src/source-entries.js +2 -12
- package/package.json +4 -3
- package/packages/visualizer/README.md +3 -1
- package/packages/visualizer/src/app/AppShell.tsx +11 -12
- package/packages/visualizer/src/app/gazetteer.module.css +12 -2
- package/packages/visualizer/src/app/gazetteer.tsx +38 -26
- package/packages/visualizer/src/components/Header.module.css +20 -4
- package/packages/visualizer/src/components/Header.tsx +6 -3
- package/packages/visualizer/src/data/model/build.ts +17 -5
- package/packages/visualizer/src/data/model/types.ts +10 -1
- package/packages/visualizer/src/evidence/api.ts +6 -1
- package/packages/visualizer/src/evidence/recording-page.tsx +19 -2
- package/packages/visualizer/src/evidence/recording.module.css +4 -8
- package/packages/visualizer/src/evidence/recording.tsx +74 -83
- package/packages/visualizer/src/evidence/snapshot.tsx +5 -4
- package/packages/visualizer/src/evidence/spec-index.tsx +3 -11
- package/packages/visualizer/src/find/Finder.module.css +168 -0
- package/packages/visualizer/src/find/Finder.tsx +303 -0
- package/packages/visualizer/src/find/corpus.test.ts +77 -0
- package/packages/visualizer/src/find/corpus.ts +368 -0
- package/packages/visualizer/src/find/open.ts +51 -0
- package/packages/visualizer/src/find/rank.test.ts +237 -0
- package/packages/visualizer/src/find/rank.ts +405 -0
- package/packages/visualizer/src/find/semantic.test.ts +62 -0
- package/packages/visualizer/src/find/semantic.ts +93 -0
- package/packages/visualizer/src/find/semantic.worker.ts +40 -0
- package/packages/visualizer/src/looks/Stage.tsx +322 -0
- package/packages/visualizer/src/looks/collect.test.ts +158 -0
- package/packages/visualizer/src/looks/collect.ts +245 -0
- package/packages/visualizer/src/looks/stage.module.css +300 -0
- package/packages/visualizer/src/reachable.test.ts +1 -1
- package/packages/visualizer/src/views/SidePanel.tsx +39 -49
- package/packages/visualizer/src/app/search-query.tsx +0 -14
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { Command } from 'cmdk';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { useNavigate, useSearch } from '@tanstack/react-router';
|
|
4
|
+
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
|
5
|
+
|
|
6
|
+
import { useAtlas } from '../app/atlas-context';
|
|
7
|
+
import { keys, specKey, useAdapter } from '../data/queries';
|
|
8
|
+
import { StatusMark } from '../evidence/marks';
|
|
9
|
+
import { useEvidence } from '../evidence/evidence-context';
|
|
10
|
+
import { kindOfFile } from '../spec-kind';
|
|
11
|
+
import { corpusFrom } from './corpus';
|
|
12
|
+
import { setFinderOpen, setHelpOpen, toggleFinder, useFinderOpen, useHelpOpen, isHelpOpen } from './open';
|
|
13
|
+
import { FIND_GROUPS, indexCorpus, notationOf, rankIndex, splitQuery, type FindDoc, type FindKind, type FindTarget, type Ranked } from './rank';
|
|
14
|
+
import { applySemantic, createWorkerEmbedder, embeddingsAvailable, embedText, textHash, type Embedder } from './semantic';
|
|
15
|
+
import styles from './Finder.module.css';
|
|
16
|
+
|
|
17
|
+
const KIND_WORD: Record<FindKind, string> = {
|
|
18
|
+
journey: 'Journey',
|
|
19
|
+
event: 'Action',
|
|
20
|
+
screen: 'Place',
|
|
21
|
+
spec: 'Spec',
|
|
22
|
+
scenario: 'Scenario',
|
|
23
|
+
action: 'Action',
|
|
24
|
+
run: 'Run'
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const SHORTCUTS: readonly { readonly keys: string; readonly does: string }[] = [
|
|
28
|
+
{ keys: '⌘K or Ctrl+K', does: 'Find a flow' },
|
|
29
|
+
{ keys: '/', does: 'Find a flow' },
|
|
30
|
+
{ keys: '↑ ↓', does: 'Move through the list' },
|
|
31
|
+
{ keys: 'Enter', does: 'Open' },
|
|
32
|
+
{ keys: '⌘Enter or Ctrl+Enter', does: 'Open the recording' },
|
|
33
|
+
{ keys: 'Esc', does: 'Close' },
|
|
34
|
+
{ keys: '?', does: 'Show these shortcuts' },
|
|
35
|
+
{ keys: '← →', does: 'Previous and next moment' },
|
|
36
|
+
{ keys: 'Home End', does: 'Start and end of a recording' }
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function commandKey() {
|
|
40
|
+
return typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘K' : 'Ctrl+K';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isTypingTarget(target: EventTarget | null) {
|
|
44
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
45
|
+
const tag = target.tagName;
|
|
46
|
+
return target.isContentEditable || tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'BUTTON' || tag === 'A';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function useSpecTexts(files: readonly string[]) {
|
|
50
|
+
const adapter = useAdapter();
|
|
51
|
+
const client = useQueryClient();
|
|
52
|
+
const source = specKey(adapter);
|
|
53
|
+
const cache = useRef(new Map<string, string>());
|
|
54
|
+
const [texts, setTexts] = useState<ReadonlyMap<string, string>>(() => new Map());
|
|
55
|
+
const [pending, setPending] = useState(false);
|
|
56
|
+
const key = files.join('\n');
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const wanted = key.length === 0 ? [] : key.split('\n');
|
|
59
|
+
let cancelled = false;
|
|
60
|
+
const missing = wanted.filter((file) => !cache.current.has(file));
|
|
61
|
+
if (missing.length === 0) {
|
|
62
|
+
setPending(false);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
setPending(true);
|
|
66
|
+
let cursor = 0;
|
|
67
|
+
const pull = async () => {
|
|
68
|
+
for (;;) {
|
|
69
|
+
const file = missing[cursor++];
|
|
70
|
+
if (file === undefined || cancelled) return;
|
|
71
|
+
let text = '';
|
|
72
|
+
try {
|
|
73
|
+
text = await client.fetchQuery({ queryKey: keys.specFile(source, file), queryFn: () => adapter.specFile(file) });
|
|
74
|
+
} catch {
|
|
75
|
+
text = '';
|
|
76
|
+
}
|
|
77
|
+
if (cancelled) return;
|
|
78
|
+
cache.current.set(file, text);
|
|
79
|
+
setTexts(new Map(cache.current));
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
void Promise.all([pull(), pull(), pull(), pull()]).then(() => {
|
|
83
|
+
if (!cancelled) setPending(false);
|
|
84
|
+
});
|
|
85
|
+
return () => {
|
|
86
|
+
cancelled = true;
|
|
87
|
+
};
|
|
88
|
+
}, [adapter, client, key, source]);
|
|
89
|
+
return { texts, pending };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The vector cache outlives a keystroke, so a live run does not embed the suite again. */
|
|
93
|
+
const vectorCache = new Map<string, Float32Array>();
|
|
94
|
+
|
|
95
|
+
function vectorsFor(docs: readonly FindDoc[]): ReadonlyMap<string, Float32Array> {
|
|
96
|
+
const vectors = new Map<string, Float32Array>();
|
|
97
|
+
for (const doc of docs) {
|
|
98
|
+
const vector = vectorCache.get(textHash(embedText(doc)));
|
|
99
|
+
if (vector) vectors.set(doc.id, vector);
|
|
100
|
+
}
|
|
101
|
+
return vectors;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function Finder() {
|
|
105
|
+
const open = useFinderOpen();
|
|
106
|
+
const help = useHelpOpen();
|
|
107
|
+
const { view } = useAtlas();
|
|
108
|
+
const evidence = useEvidence();
|
|
109
|
+
const navigate = useNavigate();
|
|
110
|
+
const routeSearch = useSearch({ strict: false });
|
|
111
|
+
const readable = useMemo(() => evidence.files.filter((file) => kindOfFile(file)), [evidence.files]);
|
|
112
|
+
const { texts, pending } = useSpecTexts(readable);
|
|
113
|
+
const docs = useMemo(() => corpusFrom(view, evidence.files, evidence.titleOf, evidence.view, texts), [view, evidence.files, evidence.titleOf, evidence.view, texts]);
|
|
114
|
+
const index = useMemo(() => indexCorpus(docs), [docs]);
|
|
115
|
+
const [query, setQuery] = useState('');
|
|
116
|
+
const [picked, setPicked] = useState('');
|
|
117
|
+
const lexical = useMemo(() => rankIndex(index, query), [index, query]);
|
|
118
|
+
const [embedded, setEmbedded] = useState<{ readonly query: string; readonly vector: Float32Array } | null>(null);
|
|
119
|
+
const [vectors, setVectors] = useState<ReadonlyMap<string, Float32Array>>(() => new Map());
|
|
120
|
+
const queryVector = embedded !== null && embedded.query === query.trim() ? embedded.vector : null;
|
|
121
|
+
const searchable = useMemo(() => {
|
|
122
|
+
const notations = splitQuery(query).notations;
|
|
123
|
+
if (notations.size === 0) return docs;
|
|
124
|
+
return docs.filter((doc) => notations.has(notationOf(doc)));
|
|
125
|
+
}, [docs, query]);
|
|
126
|
+
const shown = queryVector ? applySemantic(searchable, lexical, queryVector, vectors) : lexical;
|
|
127
|
+
const ordered = useMemo(() => FIND_GROUPS.flatMap((group) => shown.filter((hit) => hit.doc.group === group)), [shown]);
|
|
128
|
+
const selected = ordered.some((hit) => hit.doc.id === picked) ? picked : (ordered[0]?.doc.id ?? '');
|
|
129
|
+
const embedder = useRef<Embedder | null>(null);
|
|
130
|
+
const embedFailed = useRef(false);
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (open) return;
|
|
134
|
+
setQuery('');
|
|
135
|
+
setPicked('');
|
|
136
|
+
}, [open]);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
const onKey = (event: KeyboardEvent) => {
|
|
140
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k' && !event.altKey && !event.shiftKey) {
|
|
141
|
+
event.preventDefault();
|
|
142
|
+
toggleFinder();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (event.key === 'Escape' && isHelpOpen()) {
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
setHelpOpen(false);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (isTypingTarget(event.target)) return;
|
|
151
|
+
if ((event.key === '?' || (event.key === '/' && event.shiftKey)) && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
setHelpOpen(true);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (event.key === '/' && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
setFinderOpen(true);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
window.addEventListener('keydown', onKey);
|
|
162
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
163
|
+
}, []);
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!open || embedFailed.current || !embeddingsAvailable() || query.trim().length === 0) return;
|
|
167
|
+
let cancelled = false;
|
|
168
|
+
const needle = query.trim();
|
|
169
|
+
embedder.current ??= createWorkerEmbedder();
|
|
170
|
+
const rankedFirst = new Set(lexical.map((hit) => hit.doc.id));
|
|
171
|
+
const ordered = [...lexical.map((hit) => hit.doc), ...docs.filter((doc) => !rankedFirst.has(doc.id))];
|
|
172
|
+
void (async () => {
|
|
173
|
+
try {
|
|
174
|
+
const embedded = await embedder.current!.embed([needle]);
|
|
175
|
+
const nextQuery = embedded[0];
|
|
176
|
+
if (cancelled || !nextQuery) return;
|
|
177
|
+
setEmbedded({ query: needle, vector: nextQuery });
|
|
178
|
+
for (let start = 0; start < ordered.length && !cancelled; start += 8) {
|
|
179
|
+
const batch = ordered.slice(start, start + 8);
|
|
180
|
+
const missing = batch.filter((doc) => !vectorCache.has(textHash(embedText(doc))));
|
|
181
|
+
if (missing.length > 0) {
|
|
182
|
+
const batchVectors = await embedder.current!.embed(missing.map((doc) => embedText(doc)));
|
|
183
|
+
if (cancelled) return;
|
|
184
|
+
missing.forEach((doc, at) => {
|
|
185
|
+
const vector = batchVectors[at];
|
|
186
|
+
if (vector) vectorCache.set(textHash(embedText(doc)), vector);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (!cancelled) setVectors(vectorsFor(docs));
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
embedFailed.current = true;
|
|
193
|
+
}
|
|
194
|
+
})();
|
|
195
|
+
return () => {
|
|
196
|
+
cancelled = true;
|
|
197
|
+
};
|
|
198
|
+
}, [open, query, docs, lexical]);
|
|
199
|
+
|
|
200
|
+
const openTarget = (target: FindTarget) => {
|
|
201
|
+
const run = typeof routeSearch.run === 'string' ? routeSearch.run : undefined;
|
|
202
|
+
if (target.to === '/chart/$chartId' && target.chartId) {
|
|
203
|
+
const chartId = target.chartId;
|
|
204
|
+
void navigate({
|
|
205
|
+
to: '/chart/$chartId',
|
|
206
|
+
params: { chartId },
|
|
207
|
+
search: { run, screen: target.search.screen, event: target.search.event, journey: target.search.journey }
|
|
208
|
+
});
|
|
209
|
+
} else if (target.to === '/specs/$kind' && target.kind && target.search.spec) {
|
|
210
|
+
const kind = target.kind;
|
|
211
|
+
void navigate({
|
|
212
|
+
to: '/specs/$kind',
|
|
213
|
+
params: { kind },
|
|
214
|
+
search: { run, spec: target.search.spec, check: target.search.check, view: target.search.view }
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
setFinderOpen(false);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const onCommandKey = (event: ReactKeyboardEvent) => {
|
|
221
|
+
if (event.key !== 'Enter' || (!event.metaKey && !event.ctrlKey)) return;
|
|
222
|
+
event.preventDefault();
|
|
223
|
+
const hit = shown.find((item) => item.doc.id === selected);
|
|
224
|
+
if (hit) openTarget(hit.doc.recording ?? hit.doc.target);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<>
|
|
229
|
+
<Command.Dialog
|
|
230
|
+
open={open}
|
|
231
|
+
onOpenChange={setFinderOpen}
|
|
232
|
+
label="Find"
|
|
233
|
+
shouldFilter={false}
|
|
234
|
+
loop
|
|
235
|
+
vimBindings={false}
|
|
236
|
+
overlayClassName={styles.overlay}
|
|
237
|
+
contentClassName={styles.content}
|
|
238
|
+
className={styles.command}
|
|
239
|
+
value={selected}
|
|
240
|
+
onValueChange={setPicked}
|
|
241
|
+
onKeyDown={onCommandKey}
|
|
242
|
+
>
|
|
243
|
+
<Command.Input className={styles.input} placeholder='Try "add a todo" or "gherkin"' value={query} onValueChange={(value) => { setQuery(value); setPicked(''); }} />
|
|
244
|
+
<Command.List className={styles.list} label="Flows, specs and runs">
|
|
245
|
+
{shown.length === 0 && <p className={styles.empty}>{query.trim() ? 'Nothing close to that.' : 'Type what someone does. For example, add a todo.'}</p>}
|
|
246
|
+
{FIND_GROUPS.map((group) => {
|
|
247
|
+
const hits = shown.filter((hit) => hit.doc.group === group);
|
|
248
|
+
if (hits.length === 0) return null;
|
|
249
|
+
return (
|
|
250
|
+
<Command.Group key={group} heading={group} value={group}>
|
|
251
|
+
{hits.map((hit) => (
|
|
252
|
+
<Command.Item key={hit.doc.id} className={styles.item} value={hit.doc.id} onSelect={() => openTarget(hit.doc.target)}>
|
|
253
|
+
<Row hit={hit} />
|
|
254
|
+
</Command.Item>
|
|
255
|
+
))}
|
|
256
|
+
</Command.Group>
|
|
257
|
+
);
|
|
258
|
+
})}
|
|
259
|
+
</Command.List>
|
|
260
|
+
<footer className={styles.footer}>
|
|
261
|
+
<span>{pending ? 'Reading specs…' : '↑↓ move'}</span>
|
|
262
|
+
<span>↵ open</span>
|
|
263
|
+
<span>esc close</span>
|
|
264
|
+
</footer>
|
|
265
|
+
</Command.Dialog>
|
|
266
|
+
{help && (
|
|
267
|
+
<div className={styles.overlay} onClick={() => setHelpOpen(false)}>
|
|
268
|
+
<div className={styles.help} role="dialog" aria-label="Keyboard shortcuts" onClick={(event) => event.stopPropagation()}>
|
|
269
|
+
<h2>Keyboard shortcuts</h2>
|
|
270
|
+
<ul>
|
|
271
|
+
{SHORTCUTS.map((shortcut) => (
|
|
272
|
+
<li key={shortcut.keys}>
|
|
273
|
+
<kbd>{shortcut.keys}</kbd>
|
|
274
|
+
<span>{shortcut.does}</span>
|
|
275
|
+
</li>
|
|
276
|
+
))}
|
|
277
|
+
</ul>
|
|
278
|
+
<button type="button" onClick={() => setHelpOpen(false)}>Close</button>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
)}
|
|
282
|
+
</>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function Row({ hit }: { hit: Ranked }) {
|
|
287
|
+
const { doc, snippet } = hit;
|
|
288
|
+
return (
|
|
289
|
+
<>
|
|
290
|
+
{doc.status && <StatusMark status={doc.status} size={14} />}
|
|
291
|
+
<span className={styles.text}>
|
|
292
|
+
<span className={styles.title}>{doc.title}</span>
|
|
293
|
+
{snippet && snippet !== doc.subtitle && <span className={styles.snippet}>{snippet}</span>}
|
|
294
|
+
</span>
|
|
295
|
+
<span className={styles.meta}>
|
|
296
|
+
{doc.subtitle && <span className={styles.subtitle}>{doc.subtitle}</span>}
|
|
297
|
+
<span className={styles.kind}>{KIND_WORD[doc.kind]}</span>
|
|
298
|
+
</span>
|
|
299
|
+
</>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export { commandKey };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { buildCorpus, type CorpusSource } from './corpus';
|
|
4
|
+
|
|
5
|
+
const FEATURE = `Feature: Working the list
|
|
6
|
+
Scenario: Add a todo
|
|
7
|
+
When they add a todo
|
|
8
|
+
|
|
9
|
+
Scenario: Evening routine
|
|
10
|
+
When they tap "Mark all done"
|
|
11
|
+
`;
|
|
12
|
+
|
|
13
|
+
function source(extra: Partial<CorpusSource> = {}): CorpusSource {
|
|
14
|
+
return {
|
|
15
|
+
charts: [{ id: 'app', name: 'Todo' }],
|
|
16
|
+
states: [],
|
|
17
|
+
transitions: [],
|
|
18
|
+
journeys: [],
|
|
19
|
+
files: [{ path: 'working-the-list.feature', title: 'Working the list' }],
|
|
20
|
+
slots: [],
|
|
21
|
+
texts: new Map([['working-the-list.feature', FEATURE]]),
|
|
22
|
+
...extra
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('the same action on several states is one row, opened on a transition of its own', () => {
|
|
27
|
+
const docs = buildCorpus(source({
|
|
28
|
+
transitions: [
|
|
29
|
+
{ id: 'carried', chartId: 'app', event: 'Adds a todo', source: 'Desktop', target: 'Has items', status: 'passed', carriedBy: 'phone' },
|
|
30
|
+
{ id: 'real', chartId: 'app', event: 'Adds a todo', source: 'No todos', target: 'Has items', status: 'passed' },
|
|
31
|
+
{ id: 'again', chartId: 'app', event: 'Adds a todo', source: 'Has items', target: 'Has items', status: 'failed' }
|
|
32
|
+
]
|
|
33
|
+
}));
|
|
34
|
+
const events = docs.filter((doc) => doc.kind === 'event');
|
|
35
|
+
expect(events.map((doc) => doc.title)).toEqual(['Adds a todo']);
|
|
36
|
+
expect(events[0]?.subtitle).toContain('No todos');
|
|
37
|
+
expect(events[0]?.target.search.event).toBe('real');
|
|
38
|
+
expect(events[0]?.status).toBe('failed');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('a scenario opens its recorded check, and a chart file stays on the map', () => {
|
|
42
|
+
const docs = buildCorpus(source({
|
|
43
|
+
files: [
|
|
44
|
+
{ path: 'working-the-list.feature', title: 'Working the list' },
|
|
45
|
+
{ path: 'todo.qnt', title: 'todo.qnt' },
|
|
46
|
+
{ path: 'todo-app.scxml', title: 'Todo app' }
|
|
47
|
+
],
|
|
48
|
+
texts: new Map([
|
|
49
|
+
['working-the-list.feature', FEATURE],
|
|
50
|
+
['todo.qnt', 'module todo {\n action add = true\n action mark_all_done = true\n}\n']
|
|
51
|
+
]),
|
|
52
|
+
slots: [
|
|
53
|
+
{
|
|
54
|
+
key: 'lists/evening',
|
|
55
|
+
id: 'lists/evening',
|
|
56
|
+
title: 'Evening routine',
|
|
57
|
+
specPath: 'specs/working-the-list.feature',
|
|
58
|
+
status: 'passed',
|
|
59
|
+
detail: '',
|
|
60
|
+
recording: true
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}));
|
|
64
|
+
expect(docs.some((doc) => doc.subtitle === 'todo-app.scxml' || doc.title === 'Todo app')).toBe(false);
|
|
65
|
+
const add = docs.find((doc) => doc.kind === 'scenario' && doc.title === 'Add a todo');
|
|
66
|
+
expect(add?.target).toMatchObject({ to: '/specs/$kind', kind: 'gherkin', search: { spec: 'working-the-list.feature', check: 'scenario:Add a todo' } });
|
|
67
|
+
const evening = docs.find((doc) => doc.kind === 'scenario' && doc.title === 'Evening routine');
|
|
68
|
+
expect(evening?.related).toEqual(['run:lists/evening']);
|
|
69
|
+
expect(evening?.target.search.check).toBe('lists/evening');
|
|
70
|
+
const run = docs.find((doc) => doc.id === 'run:lists/evening');
|
|
71
|
+
expect(run?.group).toBe('Runs');
|
|
72
|
+
expect(run?.target.search).toMatchObject({ spec: 'working-the-list.feature', check: 'lists/evening', view: 'recording' });
|
|
73
|
+
expect(run?.recording?.search).toMatchObject({ spec: 'working-the-list.feature', check: 'lists/evening', view: 'recording' });
|
|
74
|
+
expect(evening?.recording?.search).toMatchObject({ spec: 'working-the-list.feature', check: 'lists/evening', view: 'recording' });
|
|
75
|
+
expect(evening?.target.search.view).toBeUndefined();
|
|
76
|
+
expect(docs.filter((doc) => doc.kind === 'action').map((doc) => doc.title).sort()).toEqual(['add', 'mark_all_done']);
|
|
77
|
+
});
|