@houwert/conductor 0.24.1 → 0.25.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/dist/commands/input-server.js +28 -0
- package/dist/commands/launch-app.js +30 -3
- package/dist/commands/native-rn.js +110 -0
- package/dist/commands/native.js +320 -0
- package/dist/daemon/client.js +5 -0
- package/dist/daemon/input-backends.js +203 -0
- package/dist/daemon/input-protocol.js +40 -0
- package/dist/daemon/input-router.js +110 -0
- package/dist/daemon/input-server.js +124 -0
- package/dist/daemon/server.js +84 -0
- package/dist/drivers/bootstrap.js +49 -0
- package/dist/drivers/eval-compiler.js +131 -0
- package/dist/drivers/ios-hid.js +95 -0
- package/dist/drivers/ios-inproc.js +198 -0
- package/dist/drivers/ios.js +39 -8
- package/dist/drivers/metro-scripts.js +174 -0
- package/dist/index.js +141 -0
- package/dist/runner.js +33 -0
- package/package.json +1 -1
- package/skills/conductor-device-interact/SKILL.md +13 -0
- package/skills/conductor-device-setup/SKILL.md +1 -1
- package/skills/conductor-inspect/SKILL.md +43 -0
- package/skills/conductor-metro-debugger/SKILL.md +10 -0
|
@@ -0,0 +1,131 @@
|
|
|
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.compileEval = compileEval;
|
|
7
|
+
/**
|
|
8
|
+
* Compiles user Swift into a fresh dylib exporting `conductor_eval`, then drops
|
|
9
|
+
* it into the target app's container so the injected library can dlopen it.
|
|
10
|
+
*
|
|
11
|
+
* `expr` mode wraps an expression; `full` mode takes the whole function body.
|
|
12
|
+
* Each build is a uniquely-named dylib to avoid dlopen caching.
|
|
13
|
+
*/
|
|
14
|
+
const child_process_1 = require("child_process");
|
|
15
|
+
const util_1 = require("util");
|
|
16
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
17
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
18
|
+
const os_1 = __importDefault(require("os"));
|
|
19
|
+
const path_1 = __importDefault(require("path"));
|
|
20
|
+
const run = (0, util_1.promisify)(child_process_1.execFile);
|
|
21
|
+
const EXPR_TEMPLATE = (code) => `import Foundation
|
|
22
|
+
import UIKit
|
|
23
|
+
import SwiftUI
|
|
24
|
+
|
|
25
|
+
@_cdecl("conductor_eval")
|
|
26
|
+
public func conductor_eval() -> UnsafePointer<CChar> {
|
|
27
|
+
let __result: Any = {
|
|
28
|
+
${code}
|
|
29
|
+
}()
|
|
30
|
+
return UnsafePointer(strdup(String(describing: __result))!)
|
|
31
|
+
}
|
|
32
|
+
`;
|
|
33
|
+
const FULL_TEMPLATE = (code) => `import Foundation
|
|
34
|
+
import UIKit
|
|
35
|
+
import SwiftUI
|
|
36
|
+
|
|
37
|
+
@_cdecl("conductor_eval")
|
|
38
|
+
public func conductor_eval() -> UnsafePointer<CChar> {
|
|
39
|
+
${code}
|
|
40
|
+
}
|
|
41
|
+
`;
|
|
42
|
+
async function detectTarget(platform) {
|
|
43
|
+
const sdkName = platform === 'tvos' ? 'appletvsimulator' : 'iphonesimulator';
|
|
44
|
+
const sdkPlatform = platform === 'tvos' ? 'tvos' : 'ios';
|
|
45
|
+
const [{ stdout: sdkPath }, { stdout: sdkVer }] = await Promise.all([
|
|
46
|
+
run('xcrun', ['--sdk', sdkName, '--show-sdk-path']),
|
|
47
|
+
run('xcrun', ['--sdk', sdkName, '--show-sdk-version']),
|
|
48
|
+
]);
|
|
49
|
+
const major = sdkVer.trim().split('.')[0];
|
|
50
|
+
const arch = os_1.default.arch() === 'arm64' ? 'arm64' : 'x86_64';
|
|
51
|
+
return {
|
|
52
|
+
sdkName,
|
|
53
|
+
sdkPath: sdkPath.trim(),
|
|
54
|
+
target: `${arch}-apple-${sdkPlatform}${major}.0-simulator`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Compile `code` and place the dylib inside the app container's tmp so the
|
|
59
|
+
* sandboxed app can dlopen it. Returns the container-relative host path (which,
|
|
60
|
+
* on the simulator, is the same path the app opens).
|
|
61
|
+
*/
|
|
62
|
+
async function compileEval(code, mode, platform, deviceId, bundleId) {
|
|
63
|
+
const start = Date.now();
|
|
64
|
+
const { sdkName, sdkPath, target } = await detectTarget(platform);
|
|
65
|
+
const source = (mode === 'full' ? FULL_TEMPLATE : EXPR_TEMPLATE)(code);
|
|
66
|
+
const hash = crypto_1.default.createHash('md5').update(source).digest('hex').slice(0, 10);
|
|
67
|
+
const workDir = path_1.default.join(os_1.default.tmpdir(), 'conductor-eval');
|
|
68
|
+
await promises_1.default.mkdir(workDir, { recursive: true });
|
|
69
|
+
const swiftFile = path_1.default.join(workDir, `eval_${hash}.swift`);
|
|
70
|
+
const dylibName = `eval_${hash}_${Date.now()}.dylib`;
|
|
71
|
+
const dylibPath = path_1.default.join(workDir, dylibName);
|
|
72
|
+
await promises_1.default.writeFile(swiftFile, source, 'utf-8');
|
|
73
|
+
try {
|
|
74
|
+
await run('xcrun', [
|
|
75
|
+
'-sdk',
|
|
76
|
+
sdkName,
|
|
77
|
+
'swiftc',
|
|
78
|
+
'-target',
|
|
79
|
+
target,
|
|
80
|
+
'-sdk',
|
|
81
|
+
sdkPath,
|
|
82
|
+
'-emit-library',
|
|
83
|
+
'-Onone',
|
|
84
|
+
'-enable-testing',
|
|
85
|
+
'-o',
|
|
86
|
+
dylibPath,
|
|
87
|
+
// Unresolved symbols (app/system) resolve at dlopen time in the host process.
|
|
88
|
+
'-Xlinker',
|
|
89
|
+
'-undefined',
|
|
90
|
+
'-Xlinker',
|
|
91
|
+
'dynamic_lookup',
|
|
92
|
+
swiftFile,
|
|
93
|
+
]);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
error: `compile failed:\n${err.stderr ?? String(err)}`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// Ad-hoc sign (sim rejects unsigned dylibs for dlopen on newer runtimes).
|
|
102
|
+
await run('codesign', ['--force', '--sign', '-', dylibPath]).catch(() => { });
|
|
103
|
+
// Copy into the app's data container tmp so the sandboxed app can read it.
|
|
104
|
+
try {
|
|
105
|
+
const { stdout } = await run('xcrun', [
|
|
106
|
+
'simctl',
|
|
107
|
+
'get_app_container',
|
|
108
|
+
deviceId,
|
|
109
|
+
bundleId,
|
|
110
|
+
'data',
|
|
111
|
+
]);
|
|
112
|
+
const container = stdout.trim();
|
|
113
|
+
// System apps (and any without a data container) print "(null)"; use the raw
|
|
114
|
+
// path there — simulator apps are host processes and can dlopen it directly.
|
|
115
|
+
if (!container.startsWith('/'))
|
|
116
|
+
throw new Error('no data container');
|
|
117
|
+
const containerTmp = path_1.default.join(container, 'tmp');
|
|
118
|
+
await promises_1.default.mkdir(containerTmp, { recursive: true });
|
|
119
|
+
const dest = path_1.default.join(containerTmp, dylibName);
|
|
120
|
+
await promises_1.default.copyFile(dylibPath, dest);
|
|
121
|
+
return { ok: true, dylibPath: dest, info: `compiled in ${Date.now() - start}ms` };
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Fall back to the raw temp path — simulator apps can usually open it directly.
|
|
125
|
+
return {
|
|
126
|
+
ok: true,
|
|
127
|
+
dylibPath,
|
|
128
|
+
info: `compiled in ${Date.now() - start}ms (container copy skipped)`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
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.IOSHidClient = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* Node client for the host-side CoreSimulator HID injector
|
|
9
|
+
* (`packages/ios-hid`, built to `drivers/ios-hid/conductor-hid`).
|
|
10
|
+
*
|
|
11
|
+
* This is the streaming pointer backend: unlike XCUITest's atomic
|
|
12
|
+
* `_XCT_synthesizeEvent`, it holds a touch DOWN and streams moves, so a live
|
|
13
|
+
* drag animates on-device as the finger moves. Opt-in (CONDUCTOR_IOS_HID=1) and
|
|
14
|
+
* single-touch — multitouch/discrete gestures stay on the XCUITest path.
|
|
15
|
+
*
|
|
16
|
+
* Talks newline-delimited JSON over the binary's stdio, one request in flight
|
|
17
|
+
* at a time (FIFO correlation, mirroring the XCTest driver's simplicity).
|
|
18
|
+
*/
|
|
19
|
+
const child_process_1 = require("child_process");
|
|
20
|
+
const readline_1 = __importDefault(require("readline"));
|
|
21
|
+
const PHASE_TO_TYPE = {
|
|
22
|
+
down: 0,
|
|
23
|
+
move: 1,
|
|
24
|
+
up: 2,
|
|
25
|
+
cancel: 2, // release the held touch
|
|
26
|
+
};
|
|
27
|
+
class IOSHidClient {
|
|
28
|
+
constructor(binaryPath, udid) {
|
|
29
|
+
this.binaryPath = binaryPath;
|
|
30
|
+
this.udid = udid;
|
|
31
|
+
this.proc = null;
|
|
32
|
+
this.rl = null;
|
|
33
|
+
this.pending = [];
|
|
34
|
+
}
|
|
35
|
+
start() {
|
|
36
|
+
if (this.proc)
|
|
37
|
+
return;
|
|
38
|
+
this.proc = (0, child_process_1.spawn)(this.binaryPath, [], { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
39
|
+
this.rl = readline_1.default.createInterface({ input: this.proc.stdout });
|
|
40
|
+
this.rl.on('line', (line) => {
|
|
41
|
+
const resolve = this.pending.shift();
|
|
42
|
+
if (!resolve)
|
|
43
|
+
return;
|
|
44
|
+
try {
|
|
45
|
+
resolve(JSON.parse(line));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
resolve({ ok: false, error: `bad response: ${line}` });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
this.proc.on('exit', () => {
|
|
52
|
+
this.proc = null;
|
|
53
|
+
this.rl = null;
|
|
54
|
+
// Fail any in-flight requests so callers don't hang.
|
|
55
|
+
while (this.pending.length)
|
|
56
|
+
this.pending.shift()({ ok: false, error: 'hid process exited' });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
stop() {
|
|
60
|
+
this.proc?.kill();
|
|
61
|
+
this.proc = null;
|
|
62
|
+
this.rl = null;
|
|
63
|
+
}
|
|
64
|
+
send(req) {
|
|
65
|
+
if (!this.proc)
|
|
66
|
+
this.start();
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
this.pending.push(resolve);
|
|
69
|
+
this.proc.stdin.write(JSON.stringify(req) + '\n');
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
async ping() {
|
|
73
|
+
const r = await this.send({ cmd: 'ping' });
|
|
74
|
+
return r.ok;
|
|
75
|
+
}
|
|
76
|
+
/** Inject a touch phase at normalized coords. */
|
|
77
|
+
async touch(nx, ny, phase) {
|
|
78
|
+
const r = await this.send({
|
|
79
|
+
cmd: 'touch',
|
|
80
|
+
udid: this.udid,
|
|
81
|
+
x: nx,
|
|
82
|
+
y: ny,
|
|
83
|
+
type: PHASE_TO_TYPE[phase],
|
|
84
|
+
});
|
|
85
|
+
if (!r.ok)
|
|
86
|
+
throw new Error(`hid touch failed (rc=${r.rc ?? '?'}) ${r.error ?? ''}`.trim());
|
|
87
|
+
}
|
|
88
|
+
/** Adapter for the router's live-pointer path (single finger; id ignored). */
|
|
89
|
+
asLivePointer() {
|
|
90
|
+
return {
|
|
91
|
+
pointer: (_id, phase, nx, ny) => this.touch(nx, ny, phase),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
exports.IOSHidClient = IOSHidClient;
|
|
@@ -0,0 +1,198 @@
|
|
|
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.InprocClient = void 0;
|
|
7
|
+
exports.getInprocPort = getInprocPort;
|
|
8
|
+
/**
|
|
9
|
+
* Client + port allocation for the injected in-process control library
|
|
10
|
+
* (`packages/ios-inproc`). This is a second inspection plane that runs *inside*
|
|
11
|
+
* the target app — distinct from the external XCUITest driver in `ios.ts`.
|
|
12
|
+
*
|
|
13
|
+
* The dylib is injected at launch (see IOSDriver.launchApp `inject` option). The
|
|
14
|
+
* CLI allocates a loopback port per device, hands it to the app via
|
|
15
|
+
* SIMCTL_CHILD_CONDUCTOR_INPROC_PORT, and connects here. Simulator apps share
|
|
16
|
+
* the host loopback, so 127.0.0.1:<port> reaches the in-process server — the same
|
|
17
|
+
* mechanism the XCUITest driver uses on :1075.
|
|
18
|
+
*/
|
|
19
|
+
const http_1 = __importDefault(require("http"));
|
|
20
|
+
const fs_1 = __importDefault(require("fs"));
|
|
21
|
+
const os_1 = __importDefault(require("os"));
|
|
22
|
+
const path_1 = __importDefault(require("path"));
|
|
23
|
+
const INPROC_BASE_PORT = 6075;
|
|
24
|
+
const PORT_FILE = path_1.default.join(os_1.default.homedir(), '.conductor', 'inproc-ports.json');
|
|
25
|
+
function readState() {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(fs_1.default.readFileSync(PORT_FILE, 'utf-8'));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return { assignments: {}, nextPort: INPROC_BASE_PORT };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Deterministic in-process control port for a device. Stable across calls so
|
|
35
|
+
* the launcher and any later `native-*` command agree without a discovery file.
|
|
36
|
+
*/
|
|
37
|
+
function getInprocPort(deviceId) {
|
|
38
|
+
const state = readState();
|
|
39
|
+
const existing = state.assignments[deviceId];
|
|
40
|
+
if (existing !== undefined)
|
|
41
|
+
return existing;
|
|
42
|
+
const port = state.nextPort;
|
|
43
|
+
state.assignments[deviceId] = port;
|
|
44
|
+
state.nextPort = port + 1;
|
|
45
|
+
fs_1.default.mkdirSync(path_1.default.dirname(PORT_FILE), { recursive: true });
|
|
46
|
+
fs_1.default.writeFileSync(PORT_FILE, JSON.stringify(state, null, 2));
|
|
47
|
+
return port;
|
|
48
|
+
}
|
|
49
|
+
/** HTTP/JSON client for the in-process control server. */
|
|
50
|
+
class InprocClient {
|
|
51
|
+
constructor(port, host = '127.0.0.1') {
|
|
52
|
+
this.port = port;
|
|
53
|
+
this.host = host;
|
|
54
|
+
}
|
|
55
|
+
get(reqPath, timeoutMs = 5000) {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const req = http_1.default.request({ hostname: this.host, port: this.port, path: reqPath, method: 'GET' }, (res) => {
|
|
58
|
+
const chunks = [];
|
|
59
|
+
res.on('data', (c) => chunks.push(c));
|
|
60
|
+
res.on('end', () => {
|
|
61
|
+
try {
|
|
62
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')));
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
reject(err);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
res.on('error', reject);
|
|
69
|
+
});
|
|
70
|
+
req.setTimeout(timeoutMs, () => req.destroy(new Error('in-proc request timed out')));
|
|
71
|
+
req.on('error', reject);
|
|
72
|
+
req.end();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
ping(timeoutMs = 5000) {
|
|
76
|
+
return this.get('/ping', timeoutMs);
|
|
77
|
+
}
|
|
78
|
+
/** Full native view hierarchy with colors, fonts, text, and layer visuals. */
|
|
79
|
+
inspect(timeoutMs = 15000) {
|
|
80
|
+
return this.get('/inspect', timeoutMs);
|
|
81
|
+
}
|
|
82
|
+
/** Navigation / view-controller hierarchy (stacks, tabs, presented, titles). */
|
|
83
|
+
nav(timeoutMs = 15000) {
|
|
84
|
+
return this.get('/nav', timeoutMs);
|
|
85
|
+
}
|
|
86
|
+
/** Full property detail for one view (by id from inspect). */
|
|
87
|
+
view(id, timeoutMs = 8000) {
|
|
88
|
+
return this.get(`/view?id=${encodeURIComponent(id)}`, timeoutMs);
|
|
89
|
+
}
|
|
90
|
+
/** Live-set a whitelisted property on a view (alpha, backgroundColor, text, frame, …). */
|
|
91
|
+
set(id, key, value, timeoutMs = 8000) {
|
|
92
|
+
const q = `id=${encodeURIComponent(id)}&key=${encodeURIComponent(key)}&value=${encodeURIComponent(value)}`;
|
|
93
|
+
return this.get(`/set?${q}`, timeoutMs);
|
|
94
|
+
}
|
|
95
|
+
/** React Native Fabric props: typed ViewProps + the raw JS prop bag. */
|
|
96
|
+
props(id, timeoutMs = 8000) {
|
|
97
|
+
return this.get(`/props?id=${encodeURIComponent(id)}`, timeoutMs);
|
|
98
|
+
}
|
|
99
|
+
/** Auto Layout constraints affecting a view + ambiguity flag. */
|
|
100
|
+
constraints(id, timeoutMs = 8000) {
|
|
101
|
+
return this.get(`/constraints?id=${encodeURIComponent(id)}`, timeoutMs);
|
|
102
|
+
}
|
|
103
|
+
/** Topmost view at a window point, plus its ancestor chain. */
|
|
104
|
+
hittest(x, y, timeoutMs = 8000) {
|
|
105
|
+
return this.get(`/hittest?x=${x}&y=${y}`, timeoutMs);
|
|
106
|
+
}
|
|
107
|
+
/** Flash a highlight overlay over a view on the device. */
|
|
108
|
+
highlight(id, timeoutMs = 8000) {
|
|
109
|
+
return this.get(`/highlight?id=${encodeURIComponent(id)}`, timeoutMs);
|
|
110
|
+
}
|
|
111
|
+
/** Search views by class-name substring and/or text substring. */
|
|
112
|
+
find(q, timeoutMs = 10000) {
|
|
113
|
+
const parts = [];
|
|
114
|
+
if (q.className)
|
|
115
|
+
parts.push(`class=${encodeURIComponent(q.className)}`);
|
|
116
|
+
if (q.text)
|
|
117
|
+
parts.push(`text=${encodeURIComponent(q.text)}`);
|
|
118
|
+
return this.get(`/find?${parts.join('&')}`, timeoutMs);
|
|
119
|
+
}
|
|
120
|
+
/** PNG of the whole key window. */
|
|
121
|
+
screenshot(timeoutMs = 20000) {
|
|
122
|
+
return this.getBuffer('/screenshot', timeoutMs);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* PNG of a single view in isolation — the texture for a 3D exploded-layer
|
|
126
|
+
* viewer. Default (`includeSubviews=false`) captures only this view's own
|
|
127
|
+
* content, so each node is a distinct transparent layer plane.
|
|
128
|
+
*/
|
|
129
|
+
snapshot(id, includeSubviews = false, timeoutMs = 15000) {
|
|
130
|
+
const q = `id=${encodeURIComponent(id)}&subviews=${includeSubviews ? 'true' : 'false'}`;
|
|
131
|
+
return this.getBuffer(`/snapshot?${q}`, timeoutMs);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* PNG crop of a window-absolute rect (use a node's `absFrame` from inspect).
|
|
135
|
+
* Composites whatever is drawn there — works for UIImageView, RN Fabric, etc.
|
|
136
|
+
*/
|
|
137
|
+
image(frame, timeoutMs = 15000) {
|
|
138
|
+
return this.getBuffer(`/image?frame=${frame.x},${frame.y},${frame.w},${frame.h}`, timeoutMs);
|
|
139
|
+
}
|
|
140
|
+
/** Raw GET to any endpoint, parsed as JSON. */
|
|
141
|
+
rawJson(reqPath, timeoutMs = 15000) {
|
|
142
|
+
const path = reqPath.startsWith('/') ? reqPath : `/${reqPath}`;
|
|
143
|
+
return this.get(path, timeoutMs);
|
|
144
|
+
}
|
|
145
|
+
/** Raw GET to any endpoint — returns the content-type and bytes (json or image). */
|
|
146
|
+
rawRequest(reqPath, timeoutMs = 20000) {
|
|
147
|
+
const path = reqPath.startsWith('/') ? reqPath : `/${reqPath}`;
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
const req = http_1.default.request({ hostname: this.host, port: this.port, path, method: 'GET' }, (res) => {
|
|
150
|
+
const chunks = [];
|
|
151
|
+
res.on('data', (c) => chunks.push(c));
|
|
152
|
+
res.on('end', () => resolve({ contentType: res.headers['content-type'] ?? '', body: Buffer.concat(chunks) }));
|
|
153
|
+
res.on('error', reject);
|
|
154
|
+
});
|
|
155
|
+
req.setTimeout(timeoutMs, () => req.destroy(new Error('in-proc request timed out')));
|
|
156
|
+
req.on('error', reject);
|
|
157
|
+
req.end();
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
getBuffer(reqPath, timeoutMs) {
|
|
161
|
+
return new Promise((resolve, reject) => {
|
|
162
|
+
const req = http_1.default.request({ hostname: this.host, port: this.port, path: reqPath, method: 'GET' }, (res) => {
|
|
163
|
+
const chunks = [];
|
|
164
|
+
res.on('data', (c) => chunks.push(c));
|
|
165
|
+
res.on('end', () => {
|
|
166
|
+
const buf = Buffer.concat(chunks);
|
|
167
|
+
const type = res.headers['content-type'] ?? '';
|
|
168
|
+
if (!type.startsWith('image/')) {
|
|
169
|
+
reject(new Error(`expected an image, got: ${buf.toString('utf-8').slice(0, 200)}`));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
resolve(buf);
|
|
173
|
+
});
|
|
174
|
+
res.on('error', reject);
|
|
175
|
+
});
|
|
176
|
+
req.setTimeout(timeoutMs, () => req.destroy(new Error('in-proc image request timed out')));
|
|
177
|
+
req.on('error', reject);
|
|
178
|
+
req.end();
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/** True once the in-process server answers a ping (poll after an injected launch). */
|
|
182
|
+
async waitUntilReady(timeoutMs = 10000) {
|
|
183
|
+
const deadline = Date.now() + timeoutMs;
|
|
184
|
+
while (Date.now() < deadline) {
|
|
185
|
+
try {
|
|
186
|
+
const res = await this.ping(1500);
|
|
187
|
+
if (res.status === 'ok')
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
/* not up yet */
|
|
192
|
+
}
|
|
193
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
exports.InprocClient = InprocClient;
|
package/dist/drivers/ios.js
CHANGED
|
@@ -89,14 +89,35 @@ class IOSDriver {
|
|
|
89
89
|
invalidateHierarchyCache() {
|
|
90
90
|
this.hierarchyCache = null;
|
|
91
91
|
}
|
|
92
|
-
simctl(args) {
|
|
92
|
+
simctl(args, childEnv) {
|
|
93
93
|
const _id = this.requireDeviceId();
|
|
94
94
|
return new Promise((resolve, reject) => {
|
|
95
|
-
const proc = (0, child_process_1.spawn)('xcrun', ['simctl', ...args], {
|
|
95
|
+
const proc = (0, child_process_1.spawn)('xcrun', ['simctl', ...args], {
|
|
96
|
+
stdio: 'ignore',
|
|
97
|
+
env: childEnv ? this.launchEnv(childEnv) : undefined,
|
|
98
|
+
});
|
|
96
99
|
proc.on('close', (code) => code === 0 ? resolve() : reject(new Error(`xcrun simctl ${args[0]} failed (exit ${code})`)));
|
|
97
100
|
proc.on('error', reject);
|
|
98
101
|
});
|
|
99
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Build the env for a `simctl launch` that must forward vars into the target
|
|
105
|
+
* app. `simctl` copies `SIMCTL_CHILD_*` vars into the app's real environment at
|
|
106
|
+
* exec() — the only path dyld honours for restricted vars like
|
|
107
|
+
* DYLD_INSERT_LIBRARIES. Inherited SIMCTL_CHILD_* are stripped first so stale
|
|
108
|
+
* values (from a parent shell) can't override ours and silently break injection.
|
|
109
|
+
*/
|
|
110
|
+
launchEnv(childEnv) {
|
|
111
|
+
const env = { ...process.env };
|
|
112
|
+
for (const key of Object.keys(env)) {
|
|
113
|
+
if (key.startsWith('SIMCTL_CHILD_'))
|
|
114
|
+
delete env[key];
|
|
115
|
+
}
|
|
116
|
+
for (const [key, value] of Object.entries(childEnv)) {
|
|
117
|
+
env[`SIMCTL_CHILD_${key}`] = value;
|
|
118
|
+
}
|
|
119
|
+
return env;
|
|
120
|
+
}
|
|
100
121
|
simctlCapture(args) {
|
|
101
122
|
this.requireDeviceId();
|
|
102
123
|
return new Promise((resolve, reject) => {
|
|
@@ -167,15 +188,25 @@ class IOSDriver {
|
|
|
167
188
|
});
|
|
168
189
|
this.invalidateHierarchyCache();
|
|
169
190
|
}
|
|
170
|
-
async launchApp(bundleId, args) {
|
|
171
|
-
|
|
191
|
+
async launchApp(bundleId, args, inject) {
|
|
192
|
+
const argPairs = [];
|
|
193
|
+
for (const [key, value] of Object.entries(args ?? {})) {
|
|
194
|
+
argPairs.push(`-${key}`, value);
|
|
195
|
+
}
|
|
196
|
+
if (inject) {
|
|
197
|
+
// Injection requires simctl launch with SIMCTL_CHILD_ env — the XCTest
|
|
198
|
+
// /launchApp path only activates and can't set environment.
|
|
199
|
+
const deviceId = this.requireDeviceId();
|
|
200
|
+
await this.simctl(['terminate', deviceId, bundleId]).catch(() => { });
|
|
201
|
+
await this.simctl(['launch', '--terminate-running-process', deviceId, bundleId, ...argPairs], {
|
|
202
|
+
DYLD_INSERT_LIBRARIES: inject.dylibPath,
|
|
203
|
+
CONDUCTOR_INPROC_PORT: String(inject.inprocPort),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
else if (argPairs.length > 0) {
|
|
172
207
|
const deviceId = this.requireDeviceId();
|
|
173
208
|
// xctest /launchApp doesn't support launch args — use simctl
|
|
174
209
|
await this.simctl(['terminate', deviceId, bundleId]).catch(() => { });
|
|
175
|
-
const argPairs = [];
|
|
176
|
-
for (const [key, value] of Object.entries(args)) {
|
|
177
|
-
argPairs.push(`-${key}`, value);
|
|
178
|
-
}
|
|
179
210
|
await this.simctl(['launch', '--terminate-running-process', deviceId, bundleId, ...argPairs]);
|
|
180
211
|
}
|
|
181
212
|
else {
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
*/
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
18
|
exports.makeComponentTreeScript = makeComponentTreeScript;
|
|
19
|
+
exports.makeOverridePropsScript = makeOverridePropsScript;
|
|
20
|
+
exports.makeRnPropsScript = makeRnPropsScript;
|
|
19
21
|
exports.makeInspectElementScript = makeInspectElementScript;
|
|
20
22
|
/** RN internals + navigation/safe-area wrappers we always strip from the tree. */
|
|
21
23
|
const SKIP_NAMES = [
|
|
@@ -289,6 +291,178 @@ function makeComponentTreeScript(requestId) {
|
|
|
289
291
|
}
|
|
290
292
|
})();`;
|
|
291
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* Shared JS: locate a host fiber by its native reactTag, across every registered
|
|
296
|
+
* renderer and both RN architectures (Paper `_nativeTag`/`canonical.nativeTag`,
|
|
297
|
+
* Fabric bridgeless `__nativeTag` on the state node / public instance). Defines
|
|
298
|
+
* `findByTag(hook, TAG)` returning `{ fiber, renderer }` or null.
|
|
299
|
+
*/
|
|
300
|
+
const FIBER_BY_TAG_HELPERS = `
|
|
301
|
+
function nativeTagOf(f) {
|
|
302
|
+
if (typeof f.type !== 'string' || !f.stateNode) return null;
|
|
303
|
+
var sn = f.stateNode;
|
|
304
|
+
if (typeof sn._nativeTag === 'number') return sn._nativeTag;
|
|
305
|
+
if (typeof sn.__nativeTag === 'number') return sn.__nativeTag;
|
|
306
|
+
if (sn.canonical) {
|
|
307
|
+
if (typeof sn.canonical.nativeTag === 'number') return sn.canonical.nativeTag;
|
|
308
|
+
var pi = sn.canonical.publicInstance;
|
|
309
|
+
if (pi && typeof pi.__nativeTag === 'number') return pi.__nativeTag;
|
|
310
|
+
}
|
|
311
|
+
if (sn.node && typeof sn.node.__nativeTag === 'number') return sn.node.__nativeTag;
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
function findHostByTag(root, TAG) {
|
|
315
|
+
var stack = [root.current || root], seen = 0;
|
|
316
|
+
while (stack.length && seen < 40000) {
|
|
317
|
+
var f = stack.pop(); seen++;
|
|
318
|
+
if (!f) continue;
|
|
319
|
+
if (nativeTagOf(f) === TAG) return f;
|
|
320
|
+
if (f.sibling) stack.push(f.sibling);
|
|
321
|
+
if (f.child) stack.push(f.child);
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
function findByTag(hook, TAG) {
|
|
326
|
+
var entries = [];
|
|
327
|
+
hook.renderers.forEach(function(r, id) { entries.push([id, r]); });
|
|
328
|
+
for (var i = 0; i < entries.length; i++) {
|
|
329
|
+
var id = entries[i][0], r = entries[i][1], roots = null;
|
|
330
|
+
try { roots = hook.getFiberRoots(id); } catch (e) {}
|
|
331
|
+
if (!roots) continue;
|
|
332
|
+
var arr = Array.from(roots);
|
|
333
|
+
for (var j = 0; j < arr.length; j++) {
|
|
334
|
+
var hf = findHostByTag(arr[j], TAG);
|
|
335
|
+
if (hf) return { fiber: hf, renderer: r };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}`;
|
|
340
|
+
/**
|
|
341
|
+
* Live-edit props via React DevTools' `overrideProps(fiber, path, value)` — the
|
|
342
|
+
* same call the DevTools "edit prop" UI makes. Maps `reactTag` → host fiber, then
|
|
343
|
+
* picks the fiber that owns the top-level path key (for `children`, prefers the
|
|
344
|
+
* composite `<Text>` ancestor, so the visible string changes). Returns a JSON
|
|
345
|
+
* string: `{status:'ok',applied:true}` or `{status:'error',message}`.
|
|
346
|
+
*/
|
|
347
|
+
function makeOverridePropsScript(reactTag, path, valueJson) {
|
|
348
|
+
return `(function() {
|
|
349
|
+
try {
|
|
350
|
+
var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
351
|
+
if (!hook || !hook.renderers || !hook.getFiberRoots) {
|
|
352
|
+
return JSON.stringify({ status: 'error', message: 'No React DevTools hook — not a dev/debug build?' });
|
|
353
|
+
}
|
|
354
|
+
var TAG = ${JSON.stringify(reactTag)};
|
|
355
|
+
var PATH = ${JSON.stringify(path)};
|
|
356
|
+
var VALUE = ${valueJson};
|
|
357
|
+
${FIBER_BY_TAG_HELPERS}
|
|
358
|
+
var hit = findByTag(hook, TAG);
|
|
359
|
+
if (!hit) return JSON.stringify({ status: 'error', message: 'no fiber for reactTag ' + TAG });
|
|
360
|
+
var renderer = hit.renderer;
|
|
361
|
+
if (!renderer || typeof renderer.overrideProps !== 'function') {
|
|
362
|
+
return JSON.stringify({ status: 'error', message: 'renderer has no overrideProps — is this a dev build?' });
|
|
363
|
+
}
|
|
364
|
+
// Pick the fiber that owns the top path key. For 'children' prefer the
|
|
365
|
+
// nearest fiber whose children is a string (the renderable <Text>/RCTText),
|
|
366
|
+
// so overriding actually swaps the visible glyphs.
|
|
367
|
+
var key = PATH[0];
|
|
368
|
+
var cur = hit.fiber, hops = 0, fallback = null;
|
|
369
|
+
while (cur && hops < 10) {
|
|
370
|
+
var p = cur.memoizedProps;
|
|
371
|
+
if (p && typeof p === 'object' && Object.prototype.hasOwnProperty.call(p, key)) {
|
|
372
|
+
if (fallback === null) fallback = cur;
|
|
373
|
+
if (key !== 'children') { fallback = cur; break; }
|
|
374
|
+
if (typeof p.children === 'string') { fallback = cur; break; }
|
|
375
|
+
}
|
|
376
|
+
cur = cur.return; hops++;
|
|
377
|
+
}
|
|
378
|
+
var target = fallback || hit.fiber;
|
|
379
|
+
// RN styles are often arrays (StyleSheet composition). overrideProps' setIn
|
|
380
|
+
// can't create missing intermediates, and a key set on the array object is
|
|
381
|
+
// ignored by flattening — so for a 'style.<key>' path we flatten the current
|
|
382
|
+
// style to a plain object (what RN does anyway), apply the override, and set
|
|
383
|
+
// the whole 'style'. Works whether style started as an object or an array.
|
|
384
|
+
if (PATH[0] === 'style' && PATH.length >= 2) {
|
|
385
|
+
function flattenStyle(s) {
|
|
386
|
+
var acc = {};
|
|
387
|
+
(function merge(x) {
|
|
388
|
+
if (!x) return;
|
|
389
|
+
if (Array.isArray(x)) { for (var i = 0; i < x.length; i++) merge(x[i]); return; }
|
|
390
|
+
if (typeof x === 'object') { for (var k in x) if (Object.prototype.hasOwnProperty.call(x, k)) acc[k] = x[k]; }
|
|
391
|
+
})(s);
|
|
392
|
+
return acc;
|
|
393
|
+
}
|
|
394
|
+
var flat = flattenStyle(target.memoizedProps && target.memoizedProps.style);
|
|
395
|
+
var keys = PATH.slice(1), o = flat;
|
|
396
|
+
for (var i = 0; i < keys.length - 1; i++) {
|
|
397
|
+
if (typeof o[keys[i]] !== 'object' || o[keys[i]] === null) o[keys[i]] = {};
|
|
398
|
+
o = o[keys[i]];
|
|
399
|
+
}
|
|
400
|
+
o[keys[keys.length - 1]] = VALUE;
|
|
401
|
+
renderer.overrideProps(target, ['style'], flat);
|
|
402
|
+
return JSON.stringify({ status: 'ok', applied: true, note: 'style flattened to object; override applied' });
|
|
403
|
+
}
|
|
404
|
+
renderer.overrideProps(target, PATH, VALUE);
|
|
405
|
+
return JSON.stringify({ status: 'ok', applied: true });
|
|
406
|
+
} catch (e) {
|
|
407
|
+
return JSON.stringify({ status: 'error', message: String((e && e.message) || e) });
|
|
408
|
+
}
|
|
409
|
+
})();`;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Dump a host fiber's `memoizedProps` (the real JSX props RN passed to the native
|
|
413
|
+
* view — the JS-side analog of the native `/props` rawProps that Fabric drops).
|
|
414
|
+
* Functions become `"[Function: name]"`; cycles/over-depth become markers.
|
|
415
|
+
* Returns a JSON string `{status:'ok',props:{...}}` or `{status:'error',message}`.
|
|
416
|
+
*/
|
|
417
|
+
function makeRnPropsScript(reactTag) {
|
|
418
|
+
return `(function() {
|
|
419
|
+
try {
|
|
420
|
+
var hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
421
|
+
if (!hook || !hook.renderers || !hook.getFiberRoots) {
|
|
422
|
+
return JSON.stringify({ status: 'error', message: 'No React DevTools hook — not a dev/debug build?' });
|
|
423
|
+
}
|
|
424
|
+
var TAG = ${JSON.stringify(reactTag)};
|
|
425
|
+
${FIBER_BY_TAG_HELPERS}
|
|
426
|
+
var hit = findByTag(hook, TAG);
|
|
427
|
+
if (!hit) return JSON.stringify({ status: 'error', message: 'no fiber for reactTag ' + TAG });
|
|
428
|
+
var seen = [];
|
|
429
|
+
function ser(v, depth) {
|
|
430
|
+
if (v === null || v === undefined) return v === undefined ? undefined : null;
|
|
431
|
+
var t = typeof v;
|
|
432
|
+
if (t === 'string' || t === 'boolean') return v;
|
|
433
|
+
if (t === 'number') return isFinite(v) ? v : String(v);
|
|
434
|
+
if (t === 'function') return '[Function: ' + (v.name || 'anonymous') + ']';
|
|
435
|
+
if (t === 'symbol') return v.toString();
|
|
436
|
+
if (t === 'bigint') return String(v) + 'n';
|
|
437
|
+
if (t === 'object') {
|
|
438
|
+
if (depth > 6) return '[Object: max depth]';
|
|
439
|
+
if (seen.indexOf(v) !== -1) return '[Circular]';
|
|
440
|
+
if (v && v.$$typeof) return '[ReactElement]';
|
|
441
|
+
seen.push(v);
|
|
442
|
+
var out;
|
|
443
|
+
if (Array.isArray(v)) {
|
|
444
|
+
out = [];
|
|
445
|
+
for (var i = 0; i < v.length && i < 200; i++) out.push(ser(v[i], depth + 1));
|
|
446
|
+
} else {
|
|
447
|
+
out = {};
|
|
448
|
+
var keys = Object.keys(v);
|
|
449
|
+
for (var k = 0; k < keys.length; k++) {
|
|
450
|
+
var val = ser(v[keys[k]], depth + 1);
|
|
451
|
+
if (val !== undefined) out[keys[k]] = val;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
seen.pop();
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
return String(v);
|
|
458
|
+
}
|
|
459
|
+
var props = ser(hit.fiber.memoizedProps || {}, 0);
|
|
460
|
+
return JSON.stringify({ status: 'ok', props: props });
|
|
461
|
+
} catch (e) {
|
|
462
|
+
return JSON.stringify({ status: 'error', message: String((e && e.message) || e) });
|
|
463
|
+
}
|
|
464
|
+
})();`;
|
|
465
|
+
}
|
|
292
466
|
/**
|
|
293
467
|
* Inspect-at-point script. Uses React DevTools's own
|
|
294
468
|
* `renderer.rendererConfig.getInspectorDataForViewAtPoint(inspectRef, x, y, cb)`,
|