@houwert/conductor 0.27.1 → 0.28.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/README.md CHANGED
@@ -110,10 +110,25 @@ conductor/
110
110
  ├── packages/
111
111
  │ ├── cli/ # TypeScript CLI (@houwert/conductor)
112
112
  │ ├── android-driver/ # Kotlin/Gradle instrumentation driver
113
- └── ios-driver/ # Swift/Xcode XCTest driver
113
+ ├── ios-driver/ # Swift/Xcode XCTest driver
114
+ │ └── studio-ui/ # Design system for Conductor Studio
115
+ ├── apps/
116
+ │ └── studio/ # Conductor Studio — the desktop app
114
117
  └── Makefile
115
118
  ```
116
119
 
120
+ ## 🖥️ Conductor Studio
121
+
122
+ There's also a desktop app: **Conductor Studio**, a Maestro test workbench built
123
+ on this CLI — flow editor with autocomplete, live device stream with element
124
+ picking, an agentic test writer, and test case management. It lives in
125
+ [`apps/studio`](apps/studio); see its
126
+ [README](apps/studio/README.md) for what it does and how to run it.
127
+
128
+ ```bash
129
+ pnpm dev:studio
130
+ ```
131
+
117
132
  ## 🛠️ Development
118
133
 
119
134
  ```bash
@@ -0,0 +1,260 @@
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
+ }
@@ -7,6 +7,10 @@ exports._testDeviceOverride = exports.HELP = void 0;
7
7
  exports.devicePool = devicePool;
8
8
  exports.HELP = ` device-pool --list List all devices and pool status
9
9
  device-pool --acquire Claim a free device (prints device ID)
10
+ --device <id> Claim this specific device instead of any free one
11
+ --owner <pid> Hold the claim for this process (default: this CLI's
12
+ own PID, which exits immediately — pass a long-lived
13
+ PID to keep the device reserved)
10
14
  device-pool --release <id> Release a device back to the pool`;
11
15
  /**
12
16
  * device-pool: Manage a pool of available devices for concurrent multi-agent use.
@@ -115,7 +119,11 @@ function pruneStaleAcquisitions(state) {
115
119
  }
116
120
  }
117
121
  // ── Commands ──────────────────────────────────────────────────────────────────
118
- async function devicePool(action, releaseId, opts = {}) {
122
+ async function devicePool(action, releaseId, opts = {},
123
+ /** Claim this device specifically, rather than any free one. */
124
+ wantedDevice,
125
+ /** Process that owns the claim; it is released when that process goes away. */
126
+ ownerPid) {
119
127
  if (action === 'list') {
120
128
  const allDevices = await discoverAllDevices();
121
129
  const pool = await withLock(() => {
@@ -152,6 +160,9 @@ async function devicePool(action, releaseId, opts = {}) {
152
160
  (0, output_js_1.printError)('No devices available', opts);
153
161
  return 1;
154
162
  }
163
+ // A claim lives as long as its owner process. This CLI exits immediately, so
164
+ // a caller that wants to keep a device reserved passes its own long-lived PID.
165
+ const owner = String(ownerPid ?? process.pid);
155
166
  const result = await withLock(() => {
156
167
  const state = readPool();
157
168
  pruneStaleAcquisitions(state);
@@ -161,17 +172,25 @@ async function devicePool(action, releaseId, opts = {}) {
161
172
  state.devices.push({ deviceId: id });
162
173
  }
163
174
  }
164
- // Find a free device
165
- const free = state.devices.find((e) => allDevices.includes(e.deviceId) && !e.acquiredBy);
175
+ // Asking for a device by name is idempotent: re-claiming one you already
176
+ // hold succeeds, because an owner running two things against one device
177
+ // isn't a conflict. Asking for *any* free device is the opposite request
178
+ // — "give me one nobody is on" — so a device you already hold doesn't
179
+ // count as free, or two parallel runs would land on the same screen.
180
+ const free = wantedDevice
181
+ ? state.devices.find((e) => e.deviceId === wantedDevice && (!e.acquiredBy || e.acquiredBy === owner))
182
+ : state.devices.find((e) => allDevices.includes(e.deviceId) && !e.acquiredBy);
166
183
  if (!free)
167
184
  return null;
168
- free.acquiredBy = String(process.pid);
185
+ free.acquiredBy = owner;
169
186
  free.acquiredAt = Date.now();
170
187
  writePool(state);
171
188
  return free.deviceId;
172
189
  });
173
190
  if (!result) {
174
- (0, output_js_1.printError)('No free devices available in pool', opts);
191
+ (0, output_js_1.printError)(wantedDevice
192
+ ? `Device ${wantedDevice} is already in use by another agent`
193
+ : 'No free devices available in pool', opts);
175
194
  return 1;
176
195
  }
177
196
  if (opts.json) {
@@ -23,6 +23,8 @@ async function listApps(opts = {}, sessionName = 'default') {
23
23
  }
24
24
  const platform = await (0, bootstrap_js_1.detectPlatform)(deviceId);
25
25
  let appIds;
26
+ // Display names, where the platform gives them up cheaply (iOS/tvOS only).
27
+ const appNames = {};
26
28
  if (platform === 'web') {
27
29
  (0, output_js_1.printError)('list-apps is not supported on web. Use foreground-app to get the current URL.', opts);
28
30
  return 1;
@@ -43,6 +45,11 @@ async function listApps(opts = {}, sessionName = 'default') {
43
45
  try {
44
46
  const parsed = JSON.parse(result.stdout);
45
47
  appIds = Object.keys(parsed).sort();
48
+ for (const id of appIds) {
49
+ const name = parsed[id]?.CFBundleDisplayName ?? parsed[id]?.CFBundleName;
50
+ if (typeof name === 'string' && name)
51
+ appNames[id] = name;
52
+ }
46
53
  }
47
54
  catch {
48
55
  (0, output_js_1.printError)('Failed to parse app list from simctl', opts);
@@ -62,7 +69,7 @@ async function listApps(opts = {}, sessionName = 'default') {
62
69
  .sort();
63
70
  }
64
71
  if (opts.json) {
65
- (0, output_js_1.printData)({ status: 'ok', apps: appIds }, opts);
72
+ (0, output_js_1.printData)({ status: 'ok', apps: appIds, ...(Object.keys(appNames).length ? { appNames } : {}) }, opts);
66
73
  }
67
74
  else {
68
75
  for (const id of appIds)
@@ -21,8 +21,25 @@ const cdp_discovery_js_1 = require("../drivers/cdp-discovery.js");
21
21
  // Module-scoped because the discover function returns Device[]; bolting an
22
22
  // extra return field onto the public type would ripple beyond this fix.
23
23
  let listAvdsError;
24
+ /**
25
+ * Android TV identifies itself through `ro.build.characteristics` (or the
26
+ * leanback feature). One cheap getprop per device beats guessing from a model
27
+ * name like `sdk_google_atv_x86` or `AFTKA`.
28
+ */
29
+ async function annotateAndroidFormFactors(devices, ids) {
30
+ await Promise.all(ids.map(async (id) => {
31
+ const device = devices.find((d) => d.id === id);
32
+ if (!device)
33
+ return;
34
+ const res = await (0, runner_js_1.spawnCommand)((0, sdk_js_1.resolveAndroidTool)('adb'), ['-s', id, 'shell', 'getprop', 'ro.build.characteristics'], { env: (0, sdk_js_1.androidSpawnEnv)() });
35
+ if (!res.success)
36
+ return;
37
+ device.formFactor = /\btv\b/i.test(res.stdout) ? 'tv' : 'handset';
38
+ }));
39
+ }
24
40
  async function discoverBootedDevices() {
25
41
  const devices = [];
42
+ const androidIds = [];
26
43
  // Try adb devices (Android)
27
44
  const adb = await (0, runner_js_1.spawnCommand)((0, sdk_js_1.resolveAndroidTool)('adb'), ['devices', '-l'], {
28
45
  env: (0, sdk_js_1.androidSpawnEnv)(),
@@ -40,9 +57,11 @@ async function discoverBootedDevices() {
40
57
  const modelMatch = trimmed.match(/model:(\S+)/);
41
58
  const name = modelMatch ? modelMatch[1].replace(/_/g, ' ') : id;
42
59
  devices.push({ id, name, platform: 'android', status });
60
+ androidIds.push(id);
43
61
  }
44
62
  }
45
63
  }
64
+ await annotateAndroidFormFactors(devices, androidIds);
46
65
  // Try xcrun simctl list (iOS simulators)
47
66
  const xcrun = await (0, runner_js_1.spawnCommand)('xcrun', ['simctl', 'list', 'devices', 'booted', '--json']);
48
67
  if (xcrun.success) {
@@ -143,7 +162,17 @@ async function discoverAvailableDevices() {
143
162
  for (const line of emu.stdout.split('\n')) {
144
163
  const name = line.trim();
145
164
  if (name) {
146
- devices.push({ id: name, name, platform: 'android', status: 'available' });
165
+ // A shut-down AVD can't be probed, so fall back to its name: the AVD
166
+ // wizard calls TV images "Television"/"Android TV" or `_atv_`.
167
+ devices.push({
168
+ id: name,
169
+ name,
170
+ platform: 'android',
171
+ status: 'available',
172
+ formFactor: /(^|[_\s-])(tv|television|atv|leanback)([_\s-]|$)/i.test(name)
173
+ ? 'tv'
174
+ : 'handset',
175
+ });
147
176
  }
148
177
  }
149
178
  }
@@ -246,6 +246,11 @@ function findPackageRoot() {
246
246
  }
247
247
  const DRIVERS_CACHE_ROOT = path_1.default.join(os_1.default.homedir(), '.conductor', 'drivers');
248
248
  const DRIVERS_DOWNLOAD_BASE = 'https://github.com/DouweBos/conductor/releases/download';
249
+ // Must match the tag `.github/workflows/release.yml` pushes — the two are only
250
+ // ever in sync because a release builds this file and cuts its tag from one
251
+ // commit. Releases up to v0.27.2 predate the prefix and keep their `v<version>`
252
+ // tags, which is fine — each published version only ever fetches its own tag.
253
+ const DRIVERS_TAG_PREFIX = 'cli-v';
249
254
  const DRIVERS_LOCK_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes (download can be slow)
250
255
  const DRIVERS_LOCK_POLL_MS = 500;
251
256
  let _driversDirPromise = null;
@@ -331,7 +336,7 @@ async function ensureDriversCache(pkgRoot) {
331
336
  fs_1.default.rmSync(tmpDir, { recursive: true, force: true });
332
337
  fs_1.default.mkdirSync(tmpDir, { recursive: true });
333
338
  const tarball = path_1.default.join(tmpDir, 'drivers.tar.gz');
334
- const url = `${DRIVERS_DOWNLOAD_BASE}/v${version}/drivers.tar.gz`;
339
+ const url = `${DRIVERS_DOWNLOAD_BASE}/${DRIVERS_TAG_PREFIX}${version}/drivers.tar.gz`;
335
340
  (0, verbose_js_1.log)(`Downloading conductor drivers v${version} from ${url}...`);
336
341
  try {
337
342
  await downloadToFile(url, tarball);
@@ -29,6 +29,23 @@ const utils_js_1 = require("../utils.js");
29
29
  function fmtMs(ms) {
30
30
  return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
31
31
  }
32
+ // tvOS remote key names (uppercased, as flow pressKey normalises them) → IOSDriver
33
+ // button values. Without this a flow's `pressKey: "Remote Dpad Up"` would fall
34
+ // through to the software-keyboard path and silently do nothing — tvOS has no
35
+ // keyboard. Mirrors TVOS_REMOTE_BUTTONS in commands/press-key.ts.
36
+ const TVOS_FLOW_BUTTONS = {
37
+ 'REMOTE DPAD UP': 'up',
38
+ 'REMOTE DPAD DOWN': 'down',
39
+ 'REMOTE DPAD LEFT': 'left',
40
+ 'REMOTE DPAD RIGHT': 'right',
41
+ 'REMOTE DPAD CENTER': 'select',
42
+ 'REMOTE MENU': 'menu',
43
+ 'REMOTE MEDIA PLAY PAUSE': 'playPause',
44
+ ENTER: 'select',
45
+ RETURN: 'select',
46
+ ESCAPE: 'menu',
47
+ BACK: 'menu',
48
+ };
32
49
  // vega (Amazon Fire TV) remote key names → VegaDriver button values, for flow pressKey.
33
50
  const VEGA_FLOW_BUTTONS = {
34
51
  BACK: 'back',
@@ -1055,13 +1072,20 @@ async function executeCommandBody(key, val, driver, opts) {
1055
1072
  case 'pressKey': {
1056
1073
  const keyName = val.toUpperCase();
1057
1074
  if (driver instanceof ios_js_1.IOSDriver) {
1075
+ const tvosButton = driver.platform === 'tvos' ? TVOS_FLOW_BUTTONS[keyName] : undefined;
1058
1076
  // Home and Lock are hardware buttons on iOS, not software keys
1059
- if (keyName === 'HOME') {
1077
+ if (tvosButton) {
1078
+ await driver.pressButton(tvosButton);
1079
+ }
1080
+ else if (keyName === 'HOME') {
1060
1081
  await driver.pressButton('home');
1061
1082
  }
1062
1083
  else if (keyName === 'LOCK' || keyName === 'POWER') {
1063
1084
  await driver.pressButton('lock');
1064
1085
  }
1086
+ else if (driver.platform === 'tvos') {
1087
+ throw new Error(`pressKey: key "${val}" is not supported on tvOS`);
1088
+ }
1065
1089
  else {
1066
1090
  await driver.pressKey(mapIosKey(keyName));
1067
1091
  }
package/dist/index.js CHANGED
@@ -61,6 +61,7 @@ const start_device_js_1 = require("./commands/start-device.js");
61
61
  const stop_device_js_1 = require("./commands/stop-device.js");
62
62
  const delete_device_js_1 = require("./commands/delete-device.js");
63
63
  const logs_js_1 = require("./commands/logs.js");
64
+ const cases_js_1 = require("./commands/cases.js");
64
65
  const memory_js_1 = require("./commands/memory.js");
65
66
  const metro_js_1 = require("./commands/metro.js");
66
67
  const clipboard_js_1 = require("./commands/clipboard.js");
@@ -88,6 +89,7 @@ const COMMAND_HELP = {
88
89
  'list-devices': list_devices_js_1.HELP,
89
90
  'foreground-app': foreground_app_js_1.HELP,
90
91
  'list-apps': list_apps_js_1.HELP,
92
+ cases: cases_js_1.HELP,
91
93
  'copy-app': copy_app_js_1.HELP,
92
94
  'download-app': download_app_js_1.HELP,
93
95
  'install-app': install_app_js_1.HELP,
@@ -292,6 +294,13 @@ async function main() {
292
294
  'speed',
293
295
  'threshold',
294
296
  'reference',
297
+ 'project',
298
+ 'junit',
299
+ 'verdict',
300
+ 'note',
301
+ 'column',
302
+ 'build',
303
+ 'environment',
295
304
  ],
296
305
  alias: { h: 'help', v: 'verbose', V: 'version', o: 'output', y: 'yes' },
297
306
  });
@@ -869,6 +878,35 @@ async function main() {
869
878
  interval: argv['interval'] !== undefined ? Number(argv['interval']) : undefined,
870
879
  });
871
880
  break;
881
+ case 'cases': {
882
+ // Repo-scoped, not device-scoped: cases and their results are files.
883
+ const root = argv['project'] ?? process.cwd();
884
+ const sub = rest[0] ?? 'list';
885
+ if (sub === 'list') {
886
+ exitCode = await (0, cases_js_1.casesList)(root, opts);
887
+ }
888
+ else if (sub === 'report') {
889
+ exitCode = await (0, cases_js_1.casesReport)(root, argv['junit'] ?? '', {
890
+ ...opts,
891
+ build: argv['build'],
892
+ environment: argv['environment'],
893
+ });
894
+ }
895
+ else if (sub === 'result') {
896
+ exitCode = await (0, cases_js_1.casesResult)(root, rest[1] ?? '', argv['verdict'] ?? 'passed', {
897
+ ...opts,
898
+ note: argv['note'],
899
+ column: argv['column'],
900
+ build: argv['build'],
901
+ environment: argv['environment'],
902
+ });
903
+ }
904
+ else {
905
+ console.error(`Unknown cases subcommand "${sub}". Use list, report or result.`);
906
+ exitCode = 1;
907
+ }
908
+ break;
909
+ }
872
910
  case 'logs':
873
911
  exitCode = await (0, logs_js_1.logs)(opts, sessionName, {
874
912
  source: argv['source'],
@@ -942,7 +980,8 @@ async function main() {
942
980
  const release = argv['release'];
943
981
  const releaseId = typeof argv['release'] === 'string' ? argv['release'] : rest[0];
944
982
  const action = acquire ? 'acquire' : release || releaseId ? 'release' : 'list';
945
- exitCode = await (0, device_pool_js_1.devicePool)(action, releaseId, opts);
983
+ const owner = argv['owner'] ? Number(argv['owner']) : undefined;
984
+ exitCode = await (0, device_pool_js_1.devicePool)(action, releaseId, opts, argv['device'], owner);
946
985
  break;
947
986
  }
948
987
  case 'run-sequence': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@houwert/conductor",
3
- "version": "0.27.1",
3
+ "version": "0.28.0",
4
4
  "description": "CLI tool for mobile app interactions — optimized for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -14,7 +14,7 @@ nothing is booted yet.
14
14
  conductor workspace info # detected project type, bundle IDs, devices, Metro port — best first call
15
15
  conductor list-devices # booted + available devices
16
16
  conductor foreground-app # bundle id of the app currently in front
17
- conductor list-apps # installed app ids / package names
17
+ conductor list-apps # installed app ids / package names (--json adds appNames on iOS/tvOS)
18
18
  ```
19
19
 
20
20
  ## Devices
@@ -74,7 +74,7 @@ screen recording, clipboard, `clear-state`/`uninstall-app`.
74
74
  | Command | Purpose |
75
75
  | ----------------------------------------------------- | ---------------------------------------------------------------------- |
76
76
  | `conductor install-app <path>` | Install .app / .ipa / .apk |
77
- | `conductor launch-app <appId>` | Launch app (saved to session); `--no-stop-app`, `--argument key=value`, `--inject` |
77
+ | `conductor launch-app <appId>` | Launch app (saved to session); `--no-stop-app`, `--argument key=value`, `--inject` (enables the `native-*` in-process instrument — see `conductor-native`) |
78
78
  | `conductor stop-app [<appId>]` | Stop app |
79
79
  | `conductor uninstall-app <appId>` | Uninstall app |
80
80
  | `conductor copy-app <bundleId> --from <id> --to <id>` | Copy an installed app between iOS simulators |
@@ -100,11 +100,28 @@ Parallel agents each get their own `--session <name>` so they don't collide.
100
100
  | `conductor daemon-status` | Show daemon status |
101
101
  | `conductor daemon-stop [--all]` | Stop this session's daemon (`--all` = every session) |
102
102
  | `conductor device-pool --list` | List devices + pool status |
103
- | `conductor device-pool --acquire` | Claim a free device (prints id) |
103
+ | `conductor device-pool --acquire` | Claim a free device (prints id); `--device <id>` claims that one, `--owner <pid>` holds the claim |
104
104
  | `conductor device-pool --release <id>` | Release a device back to the pool |
105
105
 
106
106
  Don't leave a daemon running when you're done — `daemon-stop` it.
107
107
 
108
+ ### Reserving a device
109
+
110
+ Claim a device before driving it when other agents share the machine, so nobody
111
+ taps through your test half-way. A claim belongs to a **process**: conductor
112
+ frees any claim whose owner has exited, and the CLI exits immediately, so
113
+ `--acquire` on its own reserves nothing. Pass the PID that should hold it:
114
+
115
+ ```bash
116
+ conductor device-pool --acquire --device <id> --owner $$ --json # claim it
117
+ conductor device-pool --list --json # who holds what
118
+ conductor device-pool --release <id> # give it back
119
+ ```
120
+
121
+ Acquiring a device someone else holds fails rather than stealing it. Always
122
+ release when you're done — a crash releases it for you, an abandoned shell
123
+ doesn't.
124
+
108
125
  ## Tips
109
126
 
110
127
  - `--device <id>` / `--device-name <name>` targets a device; `--platform` scopes by platform.
@@ -29,48 +29,13 @@ conductor capture-ui --output /tmp/screen.json
29
29
  conductor tap-on @e5
30
30
  ```
31
31
 
32
- ## Native in-process inspection (iOS/tvOS simulator)
32
+ ## Native internals (colors, fonts, layers, live-edit)
33
33
 
34
34
  The commands above observe the app **externally** (accessibility snapshots), so
35
- they can't see real component colors, fonts, or the view-controller stack. When
36
- you need that native detail, launch the app with an injected in-process library
37
- and use the `native-*` commands. Requires `launch-app <appId> --inject` first
38
- (iOS/tvOS simulator only).
39
-
40
- | Command | Purpose |
41
- |---|---|
42
- | `conductor native-ping` | Verify the injected in-process control library is alive |
43
- | `conductor native-inspect` | Real UIView/CALayer tree: resolved colors (`#RRGGBBAA`), fonts, text (incl. React Native Fabric), corner radius, borders, shadows, gradients, and each node's `absFrame` |
44
- | `conductor native-nav` | Navigation state: `UINavigationController` stacks, tab selection, presented controllers, titles |
45
- | `conductor native-screenshot --output <p.png>` | In-process PNG of the key window |
46
- | `conductor native-image <x,y,w,h> --output <p.png>` | Extract a component as a PNG — pass a node's `absFrame` from `native-inspect` |
47
- | `conductor native-snapshot <id> --output <p.png>` | Isolated PNG of one view's own content (transparent) — per-layer texture for a 3D explosion; `--with-subviews` composites the subtree |
48
- | `conductor native-console [--since <n>]` / `native-network [--since <n>]` | App stdout/stderr + captured HTTP; poll with the returned `cursor` |
49
- | `conductor native-heap --pattern <s> \| --class <name> \| --read <addr> [--key <keyPath>]` | Live-object browser (find classes/instances, read a property off an address) |
50
- | `conductor native-appearance <light\|dark\|system> \| --direction <ltr\|rtl> \| --anim-speed <n>` | Force appearance / RTL / freeze animations app-wide |
51
- | `conductor native-eval '<swift>'` | Compile & run arbitrary Swift inside the app (full UIKit / ObjC-runtime access); `--mode full` for a whole function body. e.g. `native-eval 'UIScreen.main.bounds'` |
52
- | `conductor native-raw <path>` | Escape hatch — GET any in-process endpoint (e.g. `'/get?id=..&keyPath=layer.cornerRadius'`, `'/class?id=..'`, `'/responders?id=..'`, `'/swiftui'`, `'/defaults'`, `'/focus'`, `'/snapshots?scale=0.5'`). Full list in `packages/ios-inproc/README.md`. |
53
- | `conductor native-view <id>` | Full property detail for one view (class chain, transform, layer, gestures, text/font) |
54
- | `conductor native-set <id> <key> <value>` | **Live-edit** a property: alpha, hidden, backgroundColor, tintColor, cornerRadius, borderWidth, borderColor, frame, text, textColor. `text`/`textColor` work on RN Fabric text views too |
55
- | `conductor native-props <id>` | React Native Fabric props: typed `ViewProps` + the raw JS prop bag (Fabric host views only) |
56
-
57
- > **Editing RN Fabric text/props:** the native plane can't set text on `RCTParagraphComponentView` (no native setter) and `native-props` returns `rawProps: null` on Fabric. Edit through React instead with `conductor native-rn-set --react-tag <n> --path children --value '"…"'` (and read raw JSX props with `native-rn-props --react-tag <n>`). `reactTag` comes from this tree's `rn.reactTag`. See the conductor-metro-debugger skill. Dev builds only.
58
- | `conductor native-constraints <id>` | Auto Layout constraints affecting a view + ambiguity |
59
- | `conductor native-hittest <x,y>` | Topmost view at a point + ancestor chain (select-by-point) |
60
- | `conductor native-highlight <id>` | Flash a highlight over the view on the device |
61
- | `conductor native-find [--class <name>] [--text <s>]` | Search views by class and/or text |
62
-
63
- Every `native-inspect` node has a stable `id` (for this launch). The Reveal-style loop:
64
- inspect → pick an `id` → `native-view` for detail → `native-set` to edit live → see it
65
- on the device. IDs are pointer-based and reset each launch, so re-inspect after relaunch.
66
-
67
- ```bash
68
- conductor launch-app com.example.app --inject
69
- conductor native-inspect # tree with ids, colors, fonts, absFrame
70
- conductor native-view 0x10280d0c0 # full detail for a view
71
- conductor native-set 0x10280d0c0 backgroundColor '#FF3B30FF' # live-edit, visible on device
72
- conductor native-image 816,286,288,288 --output /tmp/avatar.png
73
- ```
35
+ they can't see real component colors, fonts, or the view-controller stack. For
36
+ that native detail or to live-edit a running app's view properties, force
37
+ appearance/RTL, or run Swift in-process use the **`conductor-native`** skill
38
+ (`launch-app --inject` + the `native-*` commands; iOS/tvOS simulator, dev builds).
74
39
 
75
40
  ## Assertions
76
41
 
@@ -21,7 +21,8 @@ Playwright web.
21
21
  | `conductor native-rn-set --react-tag <n> --path <dot.path> --value <json>` | Live-edit an RN component's props via React DevTools `overrideProps` (text via `--path children`, color via `--path style.color`). Dev builds only |
22
22
  | `conductor native-rn-props --react-tag <n>` | Raw JSX props (`memoizedProps`) of an RN fiber by reactTag — the JS-side truth for Fabric where native `/props` `rawProps` is null |
23
23
 
24
- `--react-tag` comes from `native-inspect`'s `rn.reactTag`. `--value` is JSON (a bare
24
+ `--react-tag` comes from `native-inspect`'s `rn.reactTag` (see `conductor-native`
25
+ for the native-plane instrument). `--value` is JSON (a bare
25
26
  string works for text). `--path` is a dot path into props: `children`, `style.color`,
26
27
  `style.fontSize`, `accessibilityLabel`. `style.color` works whether the component's
27
28
  `style` is an object or a composed array. These drive React itself over Metro CDP, so
@@ -0,0 +1,64 @@
1
+ ---
2
+ name: conductor-native
3
+ description: Inspect and live-edit a running app's native internals with the conductor CLI's in-process instrument (iOS/tvOS simulator, requires launch-app --inject). Use when you need real native view details the accessibility tree can't show — resolved colors/fonts/layers, the UIViewController/navigation stack, Auto Layout constraints, live-object heap — or to tweak the running app in place: set a view's properties (color, text, frame), force dark/RTL/animation state, or run arbitrary Swift inside the process.
4
+ ---
5
+
6
+ # Conductor — native in-process inspection & live editing
7
+
8
+ The external commands in `conductor-inspect` observe the app through the
9
+ **accessibility tree**, so they can't see real component colors, fonts, layers,
10
+ or the view-controller stack. When you need that native detail — or want to
11
+ tweak the running app in place — launch with an injected in-process library and
12
+ use the `native-*` commands.
13
+
14
+ Requires `launch-app <appId> --inject` first. **iOS/tvOS simulator, dev builds
15
+ only.** IDs are pointer-based and reset each launch, so re-inspect after relaunch.
16
+
17
+ ## Inspect the native plane
18
+
19
+ | Command | Purpose |
20
+ |---|---|
21
+ | `conductor native-ping` | Verify the injected in-process control library is alive |
22
+ | `conductor native-inspect` | Real UIView/CALayer tree: resolved colors (`#RRGGBBAA`), fonts, text (incl. React Native Fabric), corner radius, borders, shadows, gradients, and each node's `absFrame` |
23
+ | `conductor native-nav` | Navigation state: `UINavigationController` stacks, tab selection, presented controllers, titles |
24
+ | `conductor native-view <id>` | Full property detail for one view (class chain, transform, layer, gestures, text/font) |
25
+ | `conductor native-props <id>` | React Native Fabric props: typed `ViewProps` + the raw JS prop bag (Fabric host views only) |
26
+ | `conductor native-constraints <id>` | Auto Layout constraints affecting a view + ambiguity |
27
+ | `conductor native-hittest <x,y>` | Topmost view at a point + ancestor chain (select-by-point) |
28
+ | `conductor native-find [--class <name>] [--text <s>]` | Search views by class and/or text |
29
+ | `conductor native-heap --pattern <s> \| --class <name> \| --read <addr> [--key <keyPath>]` | Live-object browser (find classes/instances, read a property off an address) |
30
+ | `conductor native-console [--since <n>]` / `native-network [--since <n>]` | App stdout/stderr + captured HTTP; poll with the returned `cursor` |
31
+ | `conductor native-screenshot --output <p.png>` | In-process PNG of the key window |
32
+ | `conductor native-image <x,y,w,h> --output <p.png>` | Extract a component as a PNG — pass a node's `absFrame` from `native-inspect` |
33
+ | `conductor native-snapshot <id> --output <p.png>` | Isolated PNG of one view's own content (transparent); `--with-subviews` composites the subtree |
34
+ | `conductor native-raw <path>` | Escape hatch — GET any in-process endpoint (e.g. `'/get?id=..&keyPath=layer.cornerRadius'`, `'/class?id=..'`, `'/responders?id=..'`, `'/swiftui'`, `'/defaults'`, `'/focus'`, `'/snapshots?scale=0.5'`). Full list in `packages/ios-inproc/README.md`. |
35
+
36
+ ## Live-edit the running app
37
+
38
+ | Command | Purpose |
39
+ |---|---|
40
+ | `conductor native-set <id> <key> <value>` | **Live-edit** a property: alpha, hidden, backgroundColor, tintColor, cornerRadius, borderWidth, borderColor, frame, text, textColor. `text`/`textColor` work on RN Fabric text views too |
41
+ | `conductor native-highlight <id>` | Flash a highlight over the view on the device |
42
+ | `conductor native-appearance <light\|dark\|system> \| --direction <ltr\|rtl> \| --anim-speed <n>` | Force appearance / RTL / freeze animations app-wide |
43
+ | `conductor native-eval '<swift>'` | Compile & run arbitrary Swift inside the app (full UIKit / ObjC-runtime access); `--mode full` for a whole function body. e.g. `native-eval 'UIScreen.main.bounds'` |
44
+
45
+ > **Editing RN Fabric text/props:** the native plane can't set text on `RCTParagraphComponentView` (no native setter) and `native-props` returns `rawProps: null` on Fabric. Edit through React instead with `conductor native-rn-set --react-tag <n> --path children --value '"…"'` (and read raw JSX props with `native-rn-props --react-tag <n>`). `reactTag` comes from this tree's `rn.reactTag`. See the `conductor-metro-debugger` skill. Dev builds only.
46
+
47
+ ## The Reveal-style loop
48
+
49
+ Every `native-inspect` node has a stable `id` (for this launch): inspect →
50
+ pick an `id` → `native-view` for detail → `native-set` to edit live → see it on
51
+ the device. Re-inspect after relaunch (IDs reset).
52
+
53
+ ```bash
54
+ conductor launch-app com.example.app --inject
55
+ conductor native-inspect # tree with ids, colors, fonts, absFrame
56
+ conductor native-view 0x10280d0c0 # full detail for a view
57
+ conductor native-set 0x10280d0c0 backgroundColor '#FF3B30FF' # live-edit, visible on device
58
+ conductor native-image 816,286,288,288 --output /tmp/avatar.png
59
+ ```
60
+
61
+ ## Related
62
+
63
+ - `conductor-inspect` — external observation (a11y snapshots, screenshots, `@eN` refs) + assertions.
64
+ - `conductor-metro-debugger` — the React/JS plane: `native-rn-set` / `native-rn-props`, `debug evaluate`, component tree, logs, network.
@@ -0,0 +1,65 @@
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.