@yemi33/minions 0.1.2071 → 0.1.2073

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.
@@ -0,0 +1,323 @@
1
+ /**
2
+ * engine/qa-runners.js — P-b8e1d4a6
3
+ *
4
+ * Pluggable test-runner registry for QA Sessions. Mirrors the
5
+ * engine/watches.js target-type registry pattern (registerTargetType /
6
+ * getTargetType / listTargetTypes / plugin folder discovery).
7
+ *
8
+ * A runner adapter teaches QA Sessions how to translate a natural-language
9
+ * QA flow into a runner-native test file, execute it against a managed-spawn
10
+ * target, and surface artifacts. Each runner is registered with five hooks:
11
+ *
12
+ * detect(target, project) → boolean. Inspect the target/project
13
+ * and return true when this runner is
14
+ * the right choice (e.g. a Playwright
15
+ * adapter returns true when the project
16
+ * has playwright.config.* in its repo).
17
+ * generateBrief(opts) → string|object. Produces the
18
+ * instructions handed to the DRAFT
19
+ * agent so it emits a runner-native
20
+ * test file under engine/qa-tests/<id>/.
21
+ * executeBrief(opts) → result. Produces the instructions /
22
+ * command the EXECUTE agent runs to
23
+ * invoke the drafted test against the
24
+ * live managed-spawn target.
25
+ * validateOutputDir(dir) → { ok: boolean, errors: string[] }.
26
+ * Called after DRAFT lands a test file
27
+ * to confirm the runner-native artifact
28
+ * is present before allowing EXECUTE
29
+ * to schedule.
30
+ * installHint → string. Human-readable instructions
31
+ * shown to the user when detect()
32
+ * returns true but the runner CLI is
33
+ * missing (e.g. "Run `npm i -D
34
+ * @playwright/test` and `npx playwright
35
+ * install` to enable this runner.").
36
+ *
37
+ * Resolution order (detectRunner):
38
+ * 1. Explicit override: when the caller passes a registered runner name,
39
+ * return that runner immediately — no detect() call. This is what the
40
+ * `qa-session` POST payload's `runner` field flows into.
41
+ * 2. Priority-desc iteration: registered runners are scanned in descending
42
+ * `priority` order (ties broken by name asc for determinism). The first
43
+ * runner whose `detect(target, project)` returns truthy wins.
44
+ * 3. No match: return null. Callers surface this as a "no QA runner
45
+ * detected — pass runner=… explicitly or install one" error.
46
+ *
47
+ * Plugin folder discovery:
48
+ * At module load, every `*.js` file under `<MINIONS_DIR>/qa-runners.d/`
49
+ * is required inside its own try/catch. Each file exports either:
50
+ *
51
+ * module.exports = { name: 'playwright', spec: { ... } };
52
+ *
53
+ * or an array of those objects for multi-runner plugins. Bad plugin files
54
+ * log WARN with the file path and do NOT block sibling plugins or built-in
55
+ * runners from registering. A missing qa-runners.d/ directory is a silent
56
+ * no-op (matches watches.d/ behaviour).
57
+ *
58
+ * Security: plugins are user-trusted JS (same trust level as `playbooks/`
59
+ * and `watches.d/`); no sandboxing. Hot-reload is not supported at module
60
+ * level — restart the engine to pick up new plugin files, or POST
61
+ * /api/qa/runners/reload (implemented in P-d2f5a8c9) which re-invokes
62
+ * loadRunnerPlugins().
63
+ *
64
+ * Built-in runner adapters (Maestro + Playwright) ship in P-c4a9e7f3.
65
+ */
66
+
67
+ const fs = require('fs');
68
+ const path = require('path');
69
+ const shared = require('./shared');
70
+ const { log } = shared;
71
+
72
+ const _KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
73
+ const RUNNER_NAME_MAX = 64;
74
+ const REQUIRED_HOOK_FNS = ['detect', 'generateBrief', 'executeBrief', 'validateOutputDir'];
75
+
76
+ // Registry. Keyed by runner name (kebab-case). The whole object is the
77
+ // allowlist — listRunners / detectRunner / `POST /api/qa/runners/reload`
78
+ // derive their answers from it.
79
+ const RUNNERS = {};
80
+
81
+ function _isNonEmptyString(v) {
82
+ return typeof v === 'string' && v.length > 0;
83
+ }
84
+
85
+ /**
86
+ * Validate a runner spec without registering it. Returns { ok, errors }.
87
+ * Never throws — `registerQaRunner` is the throwing wrapper.
88
+ */
89
+ function validateRunnerSpec(name, spec) {
90
+ const errors = [];
91
+
92
+ if (!_isNonEmptyString(name)) {
93
+ errors.push('name is required (non-empty string)');
94
+ } else {
95
+ if (name.length > RUNNER_NAME_MAX) {
96
+ errors.push(`name exceeds ${RUNNER_NAME_MAX} chars`);
97
+ }
98
+ if (!_KEBAB_RE.test(name)) {
99
+ errors.push('name must be kebab-case (a-z, 0-9, hyphens; no leading/trailing hyphen, no double hyphen)');
100
+ }
101
+ }
102
+
103
+ if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
104
+ return { ok: false, errors: errors.concat(['spec must be a plain object']) };
105
+ }
106
+
107
+ if (typeof spec.priority !== 'number' || !Number.isFinite(spec.priority)) {
108
+ errors.push('spec.priority is required (finite number; higher = preferred)');
109
+ }
110
+
111
+ for (const hook of REQUIRED_HOOK_FNS) {
112
+ if (typeof spec[hook] !== 'function') {
113
+ errors.push(`spec.${hook} is required (function)`);
114
+ }
115
+ }
116
+
117
+ if (!_isNonEmptyString(spec.installHint)) {
118
+ errors.push('spec.installHint is required (non-empty string)');
119
+ }
120
+
121
+ return { ok: errors.length === 0, errors };
122
+ }
123
+
124
+ /**
125
+ * Register a QA runner adapter. Throws on validation failure so a bad
126
+ * registration is loud — the engine prints the file path + reason at boot
127
+ * via the per-plugin try/catch in _loadRunnerPlugins.
128
+ *
129
+ * Last-write-wins: re-registering an existing name replaces the previous
130
+ * spec. This matches watches.js so plugins can override built-ins.
131
+ */
132
+ function registerQaRunner(name, spec) {
133
+ const v = validateRunnerSpec(name, spec);
134
+ if (!v.ok) {
135
+ const err = new Error(`registerQaRunner(${name}): ${v.errors.join('; ')}`);
136
+ err.validationErrors = v.errors;
137
+ throw err;
138
+ }
139
+ // Freeze the registry entry so callers can't mutate hooks after register-time.
140
+ // installHint stays as a string, priority stays as a number.
141
+ RUNNERS[name] = Object.freeze({
142
+ name,
143
+ priority: spec.priority,
144
+ detect: spec.detect,
145
+ generateBrief: spec.generateBrief,
146
+ executeBrief: spec.executeBrief,
147
+ validateOutputDir: spec.validateOutputDir,
148
+ installHint: spec.installHint,
149
+ // Optional human-readable label/description for dashboard pickers.
150
+ label: _isNonEmptyString(spec.label) ? spec.label : name,
151
+ description: _isNonEmptyString(spec.description) ? spec.description : '',
152
+ });
153
+ }
154
+
155
+ /** Returns the registered spec for a runner name, or null. */
156
+ function getRunner(name) {
157
+ if (!_isNonEmptyString(name)) return null;
158
+ return RUNNERS[name] || null;
159
+ }
160
+
161
+ /**
162
+ * Returns the registered runners as a serializable array, sorted priority
163
+ * desc, then name asc for determinism. Hooks are NOT included because they
164
+ * are functions — only metadata (name, priority, label, description,
165
+ * installHint) is returned, suitable for `GET /api/qa/runners` and
166
+ * dashboard pickers.
167
+ */
168
+ function listRunners() {
169
+ return Object.keys(RUNNERS)
170
+ .sort()
171
+ .map(k => RUNNERS[k])
172
+ .sort((a, b) => (b.priority - a.priority) || a.name.localeCompare(b.name))
173
+ .map(r => ({
174
+ name: r.name,
175
+ priority: r.priority,
176
+ label: r.label,
177
+ description: r.description,
178
+ installHint: r.installHint,
179
+ }));
180
+ }
181
+
182
+ /**
183
+ * Resolve which runner should handle a target. Resolution order:
184
+ *
185
+ * 1. If `explicitRunner` names a registered runner, return it (no detect
186
+ * call). Unknown explicit names return null — the caller decides
187
+ * whether to fall through to auto-detect or surface an error.
188
+ * 2. Iterate registered runners by priority desc (ties broken by name
189
+ * asc) and return the first whose `detect(target, project)` returns
190
+ * truthy. Per-runner detect() errors are caught + logged at WARN so
191
+ * one broken adapter can't poison the iteration.
192
+ * 3. No match: return null.
193
+ *
194
+ * @param {object} target - target object from the session spec
195
+ * (e.g. { kind: 'pr', prId: 1234 }).
196
+ * @param {object|null} project - optional project config (or null when
197
+ * the session is central / project-less).
198
+ * @param {string|null} [explicitRunner] - explicit runner name from the
199
+ * session spec; takes precedence.
200
+ * @returns {object|null} - the frozen registry entry, or null.
201
+ */
202
+ function detectRunner(target, project, explicitRunner) {
203
+ // Explicit-runner-wins: when the caller specifies a runner by name and it
204
+ // exists, use it immediately. detect() is intentionally NOT consulted —
205
+ // the user said this runner, so honour it. Unknown explicit names fall
206
+ // through to null (auto-detect is suppressed to avoid surprising the user).
207
+ if (explicitRunner !== undefined && explicitRunner !== null && explicitRunner !== '') {
208
+ if (!_isNonEmptyString(explicitRunner)) return null;
209
+ return RUNNERS[explicitRunner] || null;
210
+ }
211
+
212
+ const ordered = Object.keys(RUNNERS)
213
+ .sort()
214
+ .map(k => RUNNERS[k])
215
+ .sort((a, b) => (b.priority - a.priority) || a.name.localeCompare(b.name));
216
+
217
+ for (const r of ordered) {
218
+ let hit = false;
219
+ try {
220
+ hit = !!r.detect(target, project);
221
+ } catch (err) {
222
+ log('warn', `qa-runners: detect() threw for runner '${r.name}': ${err.message}`);
223
+ continue;
224
+ }
225
+ if (hit) return r;
226
+ }
227
+ return null;
228
+ }
229
+
230
+ // ── Plugin folder discovery (qa-runners.d/) ─────────────────────────────────
231
+ // Mirrors engine/watches.js:_loadPluginTargetTypes (W-mp7hg58e000b5212).
232
+ // Auto-loaded at module load time below. Tests bust the require cache via
233
+ // test/_helpers.js ISOLATED_MODULES so MINIONS_TEST_DIR/qa-runners.d/ takes
234
+ // effect on every createTestMinionsDir() boundary.
235
+ function _loadRunnerPlugins() {
236
+ const dir = path.join(shared.MINIONS_DIR, 'qa-runners.d');
237
+ let entries;
238
+ try {
239
+ entries = fs.readdirSync(dir);
240
+ } catch (err) {
241
+ if (err && err.code === 'ENOENT') return; // silent no-op
242
+ log('warn', `qa-runners.d/ readdir failed: ${err.message}`);
243
+ return;
244
+ }
245
+ for (const fname of entries.sort()) {
246
+ if (!fname.endsWith('.js')) continue;
247
+ const fp = path.join(dir, fname);
248
+ try {
249
+ // Bust cache so re-loads (tests, /api/qa/runners/reload) pick up edits.
250
+ try { delete require.cache[require.resolve(fp)]; } catch {}
251
+ const mod = require(fp);
252
+ const list = Array.isArray(mod) ? mod : [mod];
253
+ for (const entry of list) {
254
+ if (!entry || !entry.name || !entry.spec) {
255
+ log('warn', `qa-runners.d/${fname}: export missing { name, spec } — skipping entry`);
256
+ continue;
257
+ }
258
+ try {
259
+ registerQaRunner(entry.name, entry.spec);
260
+ log('info', `Loaded QA runner '${entry.name}' from qa-runners.d/${fname}`);
261
+ } catch (regErr) {
262
+ log('warn', `qa-runners.d/${fname}: registerQaRunner('${entry.name}') failed: ${regErr.message}`);
263
+ }
264
+ }
265
+ } catch (err) {
266
+ log('warn', `qa-runners.d/${fname}: load failed: ${err.message}`);
267
+ }
268
+ }
269
+ }
270
+
271
+ // Clear the in-process registry. Exposed for tests + `POST
272
+ // /api/qa/runners/reload` (P-d2f5a8c9), which calls _clearRunners() →
273
+ // _registerBuiltins() → _loadRunnerPlugins() to re-scan disk without
274
+ // restarting the engine.
275
+ function _clearRunners() {
276
+ for (const k of Object.keys(RUNNERS)) delete RUNNERS[k];
277
+ }
278
+
279
+ // Register the built-in adapters that ship with the engine. Plugins under
280
+ // <MINIONS_DIR>/qa-runners.d/ run AFTER this and may override built-ins by
281
+ // name (last-write-wins, mirrors watches.js). Surface registration
282
+ // failures as WARN so a broken built-in does not block other adapters or
283
+ // disk plugins. Built-ins are required relative to this file (not
284
+ // MINIONS_DIR) so test isolation can't strand them on a stale module path.
285
+ function _registerBuiltins() {
286
+ const BUILTIN_FILES = ['./qa-runners/maestro', './qa-runners/playwright'];
287
+ for (const rel of BUILTIN_FILES) {
288
+ try {
289
+ const resolved = require.resolve(rel);
290
+ delete require.cache[resolved];
291
+ const mod = require(rel);
292
+ if (!mod || !mod.name || !mod.spec) {
293
+ log('warn', `qa-runners built-in ${rel}: export missing { name, spec } — skipping`);
294
+ continue;
295
+ }
296
+ try {
297
+ registerQaRunner(mod.name, mod.spec);
298
+ } catch (regErr) {
299
+ log('warn', `qa-runners built-in ${rel}: registerQaRunner('${mod.name}') failed: ${regErr.message}`);
300
+ }
301
+ } catch (err) {
302
+ log('warn', `qa-runners built-in ${rel}: load failed: ${err.message}`);
303
+ }
304
+ }
305
+ }
306
+
307
+ _registerBuiltins();
308
+ _loadRunnerPlugins();
309
+
310
+ module.exports = {
311
+ RUNNER_NAME_MAX,
312
+ REQUIRED_HOOK_FNS,
313
+ validateRunnerSpec,
314
+ registerQaRunner,
315
+ getRunner,
316
+ listRunners,
317
+ detectRunner,
318
+ // Exposed for tests + the reload endpoint (P-d2f5a8c9).
319
+ _loadRunnerPlugins,
320
+ _registerBuiltins,
321
+ _clearRunners,
322
+ _RUNNERS: RUNNERS,
323
+ };