@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 +16 -1
- package/dist/commands/cases.js +260 -0
- package/dist/commands/device-pool.js +24 -5
- package/dist/commands/list-apps.js +8 -1
- package/dist/commands/list-devices.js +30 -1
- package/dist/drivers/bootstrap.js +6 -1
- package/dist/drivers/flow-runner.js +25 -1
- package/dist/index.js +40 -1
- package/package.json +1 -1
- package/skills/conductor-device-setup/SKILL.md +20 -3
- package/skills/conductor-inspect/SKILL.md +5 -40
- package/skills/conductor-metro-debugger/SKILL.md +2 -1
- package/skills/conductor-native/SKILL.md +64 -0
- package/skills/conductor-test-cases/SKILL.md +65 -0
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
|
-
│
|
|
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
|
-
//
|
|
165
|
-
|
|
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 =
|
|
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)(
|
|
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
|
-
|
|
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}
|
|
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 (
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
(iOS/tvOS simulator
|
|
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
|
|
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.
|