@houwert/conductor 0.28.0 → 0.29.1

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.
@@ -1,260 +0,0 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.HELP = void 0;
7
- exports.storeDir = storeDir;
8
- exports.loadCases = loadCases;
9
- exports.casesList = casesList;
10
- exports.parseJunit = parseJunit;
11
- exports.casesReport = casesReport;
12
- exports.casesResult = casesResult;
13
- exports.HELP = ` cases list List the repo's test cases and their coverage
14
- cases report --junit <file> File a JUnit report as test-case results
15
- cases result <id> --verdict <v> Record one case result (passed/failed/blocked/skipped)`;
16
- const promises_1 = require("node:fs/promises");
17
- const node_fs_1 = require("node:fs");
18
- const node_os_1 = require("node:os");
19
- const node_path_1 = __importDefault(require("node:path"));
20
- const output_js_1 = require("../output.js");
21
- /**
22
- * Test cases from the CLI, so CI can file results without Studio running.
23
- *
24
- * Cases and their results live under `~/.conductor/studio/cases/<project>/`,
25
- * outside the repo under test — testing a project never adds files to it. The
26
- * project is identified by its path, the same way Studio scopes its own store,
27
- * so both see the same cases for the same checkout.
28
- */
29
- const RESULTS = 'results.jsonl';
30
- /** Legacy in-repo location, still read so an older checkout keeps working. */
31
- const IN_REPO_CASES = 'test-cases';
32
- function studioRoot() {
33
- // __CONDUCTOR_STUDIO_DIR keeps tests (and CI sandboxes) out of the real home.
34
- return process.env.__CONDUCTOR_STUDIO_DIR ?? node_path_1.default.join((0, node_os_1.homedir)(), '.conductor', 'studio');
35
- }
36
- function slug(text) {
37
- return (text
38
- .toLowerCase()
39
- .replace(/[^a-z0-9]+/g, '-')
40
- .replace(/^-|-$/g, '')
41
- .slice(0, 48) || 'project');
42
- }
43
- function hash(text) {
44
- let h = 0;
45
- for (let i = 0; i < text.length; i++)
46
- h = (h * 31 + text.charCodeAt(i)) | 0;
47
- return Math.abs(h).toString(36);
48
- }
49
- /** `<basename>-<hash of path>`, matching Studio's own project scoping. */
50
- function storeDir(root) {
51
- const resolved = node_path_1.default.resolve(root);
52
- return node_path_1.default.join(studioRoot(), 'cases', `${slug(node_path_1.default.basename(resolved))}-${hash(resolved)}`);
53
- }
54
- /** Where cases are read from: the store, falling back to a legacy in-repo dir. */
55
- function casesDir(root) {
56
- const store = storeDir(root);
57
- if ((0, node_fs_1.existsSync)(store))
58
- return store;
59
- const legacy = node_path_1.default.join(root, IN_REPO_CASES);
60
- return (0, node_fs_1.existsSync)(legacy) ? legacy : store;
61
- }
62
- /** Minimal YAML reader for the case fields we need — no dependency for one shape. */
63
- function readCase(text) {
64
- const lines = text.split(/\r?\n/);
65
- const scalar = (key) => {
66
- const hit = lines.find((l) => l.startsWith(`${key}:`));
67
- return hit
68
- ?.slice(key.length + 1)
69
- .trim()
70
- .replace(/^['"]|['"]$/g, '');
71
- };
72
- const id = scalar('id');
73
- const title = scalar('title');
74
- if (!id || !title)
75
- return null;
76
- // `flows:` is a nested block: read indented `column: path` until the indent ends.
77
- const flows = {};
78
- const start = lines.findIndex((l) => /^flows:\s*$/.test(l));
79
- if (start >= 0) {
80
- for (const line of lines.slice(start + 1)) {
81
- if (!line.trim())
82
- continue;
83
- if (!/^\s/.test(line))
84
- break;
85
- const entry = /^\s+([\w-]+):\s*(.+)$/.exec(line);
86
- if (entry)
87
- flows[entry[1]] = entry[2].trim().replace(/^['"]|['"]$/g, '');
88
- }
89
- }
90
- return {
91
- id,
92
- title,
93
- flow: scalar('flow'),
94
- flows: Object.keys(flows).length ? flows : undefined,
95
- };
96
- }
97
- async function loadCases(root) {
98
- const dir = casesDir(root);
99
- if (!(0, node_fs_1.existsSync)(dir))
100
- return [];
101
- const cases = [];
102
- for (const file of (await (0, promises_1.readdir)(dir)).filter((f) => /\.ya?ml$/i.test(f))) {
103
- const parsed = readCase(await (0, promises_1.readFile)(node_path_1.default.join(dir, file), 'utf8'));
104
- if (parsed)
105
- cases.push(parsed);
106
- }
107
- return cases.sort((a, b) => a.id.localeCompare(b.id));
108
- }
109
- function flowsOf(c) {
110
- const entries = Object.entries(c.flows ?? {}).map(([column, flow]) => ({ column, flow }));
111
- if (c.flow)
112
- entries.push({ flow: c.flow });
113
- return entries;
114
- }
115
- let seq = 0;
116
- async function append(root, results) {
117
- const file = node_path_1.default.join(storeDir(root), RESULTS);
118
- await (0, promises_1.mkdir)(node_path_1.default.dirname(file), { recursive: true });
119
- await (0, promises_1.appendFile)(file, results.map((r) => JSON.stringify(r)).join('\n') + '\n', 'utf8');
120
- }
121
- function result(fields) {
122
- seq += 1;
123
- return { id: `res-${Date.now()}-${seq}`, at: Date.now(), ...fields };
124
- }
125
- async function casesList(root, opts = {}) {
126
- const cases = await loadCases(root);
127
- if (!cases.length) {
128
- (0, output_js_1.printError)(`No test cases found under ${casesDir(root)}.`, opts);
129
- return 1;
130
- }
131
- if (opts.json) {
132
- (0, output_js_1.printData)({
133
- status: 'ok',
134
- total: cases.length,
135
- automated: cases.filter((c) => flowsOf(c).length).length,
136
- cases: cases.map((c) => ({ id: c.id, title: c.title, flows: flowsOf(c) })),
137
- }, opts);
138
- }
139
- else {
140
- console.log(`${cases.length} cases, ${cases.filter((c) => flowsOf(c).length).length} with a flow`);
141
- for (const c of cases) {
142
- console.log(` ${c.id.padEnd(10)} ${c.title}${flowsOf(c).length ? '' : ' (no flow)'}`);
143
- }
144
- }
145
- return 0;
146
- }
147
- /** `<testcase name="…" classname="…">` plus its failure/skipped child, if any. */
148
- function parseJunit(xml) {
149
- const out = [];
150
- const re = /<testcase\b([^>]*?)(\/>|>([\s\S]*?)<\/testcase>)/g;
151
- let match;
152
- while ((match = re.exec(xml))) {
153
- const attrs = match[1];
154
- const body = match[3] ?? '';
155
- const name = /\bname="([^"]*)"/.exec(attrs)?.[1] ?? '';
156
- const classname = /\bclassname="([^"]*)"/.exec(attrs)?.[1] ?? '';
157
- out.push({
158
- name: [classname, name].filter(Boolean).join(' '),
159
- failed: /<(failure|error)\b/.test(body),
160
- skipped: /<skipped\b/.test(body),
161
- });
162
- }
163
- return out;
164
- }
165
- /** Whole-word id match, so DT-9 doesn't claim a test named for DT-97. */
166
- function mentionsId(entry, id) {
167
- const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
168
- return new RegExp(`(^|[^A-Za-z0-9])${escaped}([^A-Za-z0-9]|$)`, 'i').test(entry);
169
- }
170
- /** A report entry belongs to a flow when it names the file or its basename. */
171
- function matchesFlow(entry, flow) {
172
- const name = entry.toLowerCase();
173
- const base = (flow.split('/').pop() ?? flow).toLowerCase();
174
- return (name.includes(flow.toLowerCase()) ||
175
- name.includes(base) ||
176
- name.includes(base.replace(/\.[^.]+$/, '')));
177
- }
178
- async function casesReport(root, junitPath, opts = {}) {
179
- if (!junitPath) {
180
- (0, output_js_1.printError)('Usage: conductor cases report --junit <file.xml>', opts);
181
- return 1;
182
- }
183
- if (!(0, node_fs_1.existsSync)(junitPath)) {
184
- (0, output_js_1.printError)(`No such JUnit file: ${junitPath}`, opts);
185
- return 1;
186
- }
187
- const entries = parseJunit(await (0, promises_1.readFile)(junitPath, 'utf8'));
188
- const cases = await loadCases(root);
189
- const records = [];
190
- const unmatched = [];
191
- for (const entry of entries) {
192
- // Prefer the flow: it says which platform column ran. An id in the test
193
- // name only identifies the case, so it files one case-level result.
194
- const byFlow = [];
195
- for (const c of cases) {
196
- for (const { column, flow } of flowsOf(c)) {
197
- if (matchesFlow(entry.name, flow))
198
- byFlow.push({ c, column, flow });
199
- }
200
- }
201
- const targets = byFlow.length
202
- ? byFlow
203
- : cases
204
- .filter((c) => mentionsId(entry.name, c.id))
205
- .map((c) => ({ c, column: undefined, flow: undefined }));
206
- if (!targets.length) {
207
- unmatched.push(entry.name);
208
- continue;
209
- }
210
- for (const target of targets) {
211
- records.push(result({
212
- caseId: target.c.id,
213
- column: target.column,
214
- flow: target.flow,
215
- verdict: entry.skipped ? 'skipped' : entry.failed ? 'failed' : 'passed',
216
- source: 'ci',
217
- note: entry.name,
218
- build: opts.build,
219
- environment: opts.environment,
220
- }));
221
- }
222
- }
223
- if (records.length)
224
- await append(root, records);
225
- if (opts.json) {
226
- (0, output_js_1.printData)({ status: 'ok', reported: records.length, tests: entries.length, unmatched }, opts);
227
- }
228
- else {
229
- console.log(`Filed ${records.length} results from ${entries.length} tests` +
230
- (unmatched.length ? `; ${unmatched.length} matched no case` : ''));
231
- }
232
- return 0;
233
- }
234
- async function casesResult(root, caseId, verdict, opts = {}) {
235
- if (!caseId || !verdict) {
236
- (0, output_js_1.printError)('Usage: conductor cases result <case-id> --verdict passed|failed|blocked|skipped', opts);
237
- return 1;
238
- }
239
- const known = await loadCases(root);
240
- if (known.length && !known.some((c) => c.id === caseId)) {
241
- (0, output_js_1.printError)(`No case "${caseId}" under ${casesDir(root)}.`, opts);
242
- return 1;
243
- }
244
- await append(root, [
245
- result({
246
- caseId,
247
- verdict,
248
- column: opts.column,
249
- source: 'ci',
250
- note: opts.note,
251
- build: opts.build,
252
- environment: opts.environment,
253
- }),
254
- ]);
255
- if (opts.json)
256
- (0, output_js_1.printData)({ status: 'ok', caseId, verdict }, opts);
257
- else
258
- console.log(`Recorded ${caseId}: ${verdict}`);
259
- return 0;
260
- }
@@ -1,65 +0,0 @@
1
- ---
2
- name: conductor-test-cases
3
- description: Read a repo's test cases and file execution results from the command line, including turning a JUnit report from a CI run into per-case results. Use when reporting automated test outcomes back to the test-case matrix, checking which cases have no flow behind them, or recording a case verdict from a script.
4
- ---
5
-
6
- # Conductor — test cases
7
-
8
- A **test case** is the human-readable spec — id, title, business rule, steps,
9
- tags — kept as a YAML file under `~/.conductor/studio/cases/<project>/`, keyed
10
- by the project's path. Executions are appended to `results.jsonl` beside them.
11
- Neither is written into the repo under test: the Maestro flow a case names is
12
- the implementation, and that is what belongs in git.
13
-
14
- Both are plain files — read them, diff them, sync them however you like. Set
15
- `__CONDUCTOR_STUDIO_DIR` to relocate the store (CI sandboxes, tests). Cases an
16
- older version wrote to `test-cases/` in the repo are still read.
17
-
18
- | Command | Purpose |
19
- |---|---|
20
- | `conductor cases list [--project <dir>]` | List every case and the flow behind it |
21
- | `conductor cases report --junit <file.xml>` | File a JUnit report as per-case results |
22
- | `conductor cases result <case-id> --verdict <v>` | Record one result by hand |
23
-
24
- `--project <dir>` points at the repo root; it defaults to the working directory.
25
- These commands touch files only — no device, no session.
26
-
27
- ## Report a CI run
28
-
29
- ```bash
30
- conductor cases report --junit maestro-report.xml \
31
- --build 2026.17.0 --environment staging
32
- ```
33
-
34
- Each `<testcase>` binds to a case by the **flow** it names (`vod-playback.tv.yaml`
35
- → the case whose `flows.tv` is that file, recording against the `tv` column) or,
36
- failing that, by a **case id** appearing in the test name as a whole word
37
- (`DT-97 search returns results` → case `DT-97`, recorded case-wide). Entries that
38
- match nothing are reported as unmatched rather than silently dropped.
39
-
40
- Add it as the last step of an e2e job, after the report is written:
41
-
42
- ```yaml
43
- - run: conductor cases report --junit report.xml --build ${{ github.sha }}
44
- ```
45
-
46
- ## Record a single result
47
-
48
- ```bash
49
- conductor cases result DT-1 --verdict failed --column tv \
50
- --note "Subtitles never appear after seek" --build 2026.17.0
51
- ```
52
-
53
- `--verdict` is `passed`, `failed`, `blocked` or `skipped`. `--column` scopes the
54
- result to one platform of a case that has a flow per platform; omit it for a
55
- case-wide verdict.
56
-
57
- ## Find work
58
-
59
- ```bash
60
- conductor cases list --json | jq '.cases[] | select(.flows | length == 0) | .id'
61
- ```
62
-
63
- Cases with no flow are the unautomated ones — the backlog. Write the flow with
64
- `conductor-create-flow`, then add its path to the case's `flow:` (or `flows:`,
65
- keyed by platform) so the matrix picks it up.