@ishlabs/cli 0.25.0 → 0.26.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/doctor.d.ts +26 -0
- package/dist/commands/doctor.js +334 -0
- package/dist/index.js +2 -0
- package/dist/lib/local-sim/actions.js +2 -0
- package/dist/lib/local-sim/adb.d.ts +10 -0
- package/dist/lib/local-sim/adb.js +16 -2
- package/dist/lib/local-sim/android.js +6 -1
- package/dist/lib/local-sim/coordinates.d.ts +4 -4
- package/dist/lib/local-sim/coordinates.js +4 -4
- package/dist/lib/local-sim/device.d.ts +2 -2
- package/dist/lib/local-sim/device.js +1 -1
- package/dist/lib/local-sim/ios.d.ts +26 -9
- package/dist/lib/local-sim/ios.js +73 -20
- package/dist/lib/local-sim/loop.js +2 -0
- package/dist/lib/local-sim/native-a11y.d.ts +21 -7
- package/dist/lib/local-sim/native-a11y.js +82 -47
- package/dist/lib/local-sim/simctl.d.ts +13 -43
- package/dist/lib/local-sim/simctl.js +12 -141
- package/dist/lib/local-sim/types.d.ts +2 -0
- package/dist/lib/local-sim/xcuitest.d.ts +60 -0
- package/dist/lib/local-sim/xcuitest.js +303 -0
- package/dist/lib/paths.d.ts +8 -0
- package/dist/lib/paths.js +12 -0
- package/package.json +1 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ish check / ish setup — environment diagnostics + dependency setup for
|
|
3
|
+
* local web and native (iOS/Android) simulations. (`doctor` is a kept alias.)
|
|
4
|
+
*
|
|
5
|
+
* `check [platform]` runs read-only checks and prints a flutter-doctor-style
|
|
6
|
+
* checklist (or `--json`), scoped to one platform's group + Account when a
|
|
7
|
+
* platform is given (ios|android|web), else everything. `setup`
|
|
8
|
+
* fetches/installs what it can (Chromium today; the iOS XCUITest runner once
|
|
9
|
+
* the artifact pipeline lands) and prints guidance for the rest, then re-runs
|
|
10
|
+
* the checks.
|
|
11
|
+
*
|
|
12
|
+
* Design notes:
|
|
13
|
+
* - Device probes are light, NON-throwing `execFile` calls reimplemented here
|
|
14
|
+
* (not imported from the local-sim drivers): doctor must never throw on a
|
|
15
|
+
* missing tool, and must not pull the playwright/local-sim module graph.
|
|
16
|
+
* - The Chromium check/setup is a DYNAMIC import of `install.ts` — that module
|
|
17
|
+
* deep-imports a non-exported playwright path that crashes at module load on
|
|
18
|
+
* the npm-installed CLI, so it can only be loaded lazily (same reason
|
|
19
|
+
* `study run --local` imports it dynamically).
|
|
20
|
+
* - iOS UI automation is migrating from idb to Apple's XCUITest (a prebuilt
|
|
21
|
+
* WebDriverAgent runner fetched into `wdaDir()`); the runner row reflects
|
|
22
|
+
* that target. Until the artifact ships, point `ISH_WDA_PATH` at a local
|
|
23
|
+
* build.
|
|
24
|
+
*/
|
|
25
|
+
import type { Command } from "commander";
|
|
26
|
+
export declare function registerDoctorCommands(program: Command): void;
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ish check / ish setup — environment diagnostics + dependency setup for
|
|
3
|
+
* local web and native (iOS/Android) simulations. (`doctor` is a kept alias.)
|
|
4
|
+
*
|
|
5
|
+
* `check [platform]` runs read-only checks and prints a flutter-doctor-style
|
|
6
|
+
* checklist (or `--json`), scoped to one platform's group + Account when a
|
|
7
|
+
* platform is given (ios|android|web), else everything. `setup`
|
|
8
|
+
* fetches/installs what it can (Chromium today; the iOS XCUITest runner once
|
|
9
|
+
* the artifact pipeline lands) and prints guidance for the rest, then re-runs
|
|
10
|
+
* the checks.
|
|
11
|
+
*
|
|
12
|
+
* Design notes:
|
|
13
|
+
* - Device probes are light, NON-throwing `execFile` calls reimplemented here
|
|
14
|
+
* (not imported from the local-sim drivers): doctor must never throw on a
|
|
15
|
+
* missing tool, and must not pull the playwright/local-sim module graph.
|
|
16
|
+
* - The Chromium check/setup is a DYNAMIC import of `install.ts` — that module
|
|
17
|
+
* deep-imports a non-exported playwright path that crashes at module load on
|
|
18
|
+
* the npm-installed CLI, so it can only be loaded lazily (same reason
|
|
19
|
+
* `study run --local` imports it dynamically).
|
|
20
|
+
* - iOS UI automation is migrating from idb to Apple's XCUITest (a prebuilt
|
|
21
|
+
* WebDriverAgent runner fetched into `wdaDir()`); the runner row reflects
|
|
22
|
+
* that target. Until the artifact ships, point `ISH_WDA_PATH` at a local
|
|
23
|
+
* build.
|
|
24
|
+
*/
|
|
25
|
+
import { execFile } from "node:child_process";
|
|
26
|
+
import { promisify } from "node:util";
|
|
27
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
28
|
+
import * as path from "node:path";
|
|
29
|
+
import { runInline } from "../lib/command-helpers.js";
|
|
30
|
+
import { output } from "../lib/output.js";
|
|
31
|
+
import { loadConfig } from "../config.js";
|
|
32
|
+
import { decodeJwtClaims, isTokenExpired } from "../auth.js";
|
|
33
|
+
import { wdaDir } from "../lib/paths.js";
|
|
34
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
35
|
+
const execFileAsync = promisify(execFile);
|
|
36
|
+
const XCRUN = "/usr/bin/xcrun";
|
|
37
|
+
const isMac = process.platform === "darwin";
|
|
38
|
+
async function tryExec(cmd, args, timeoutMs = 8000) {
|
|
39
|
+
try {
|
|
40
|
+
const { stdout, stderr } = await execFileAsync(cmd, args, {
|
|
41
|
+
timeout: timeoutMs,
|
|
42
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
43
|
+
});
|
|
44
|
+
return { ok: true, stdout, stderr };
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
const err = e;
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
stdout: err.stdout ?? "",
|
|
51
|
+
stderr: err.stderr ?? err.message ?? String(e),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** adb path, mirroring the device layer's resolution (ISH_ADB → ANDROID_HOME → PATH). */
|
|
56
|
+
function resolveAdb() {
|
|
57
|
+
for (const v of [process.env.ISH_ADB, process.env.ADB]) {
|
|
58
|
+
if (v && existsSync(v))
|
|
59
|
+
return v;
|
|
60
|
+
}
|
|
61
|
+
for (const home of [process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT]) {
|
|
62
|
+
if (home) {
|
|
63
|
+
const p = path.join(home, "platform-tools", "adb");
|
|
64
|
+
if (existsSync(p))
|
|
65
|
+
return p;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return "adb";
|
|
69
|
+
}
|
|
70
|
+
// ── Individual checks ──────────────────────────────────────────────────────
|
|
71
|
+
function checkAuth() {
|
|
72
|
+
const cfg = loadConfig();
|
|
73
|
+
const token = cfg.access_token || cfg.token;
|
|
74
|
+
if (!token) {
|
|
75
|
+
return { key: "auth", name: "Account", group: "Account", status: "fail", message: "not logged in", fix: "ish login" };
|
|
76
|
+
}
|
|
77
|
+
const claims = decodeJwtClaims(token);
|
|
78
|
+
const email = claims?.email ?? "authenticated";
|
|
79
|
+
if (isTokenExpired(token)) {
|
|
80
|
+
// Expired access tokens auto-refresh via the refresh_token on the next API
|
|
81
|
+
// call, so this is a warn, not a hard fail.
|
|
82
|
+
return { key: "auth", name: "Account", group: "Account", status: "warn", message: `${email} (token expired — refreshes on next call)`, fix: "ish login (if refresh fails)" };
|
|
83
|
+
}
|
|
84
|
+
return { key: "auth", name: "Account", group: "Account", status: "pass", message: email };
|
|
85
|
+
}
|
|
86
|
+
function checkWorkspace() {
|
|
87
|
+
const cfg = loadConfig();
|
|
88
|
+
if (cfg.workspace) {
|
|
89
|
+
return { key: "workspace", name: "Active workspace", group: "Account", status: "pass", message: cfg.workspace };
|
|
90
|
+
}
|
|
91
|
+
return { key: "workspace", name: "Active workspace", group: "Account", status: "warn", message: "none set", fix: "ish workspace use <id>" };
|
|
92
|
+
}
|
|
93
|
+
async function checkXcode() {
|
|
94
|
+
if (!isMac) {
|
|
95
|
+
return { key: "xcode", name: "Xcode / xcrun", group: "iOS", status: "skip", message: "iOS simulations require macOS" };
|
|
96
|
+
}
|
|
97
|
+
const r = await tryExec(XCRUN, ["--version"]);
|
|
98
|
+
if (r.ok) {
|
|
99
|
+
const ver = r.stdout.trim().split("\n")[0] || "xcrun present";
|
|
100
|
+
return { key: "xcode", name: "Xcode / xcrun", group: "iOS", status: "pass", message: ver };
|
|
101
|
+
}
|
|
102
|
+
return { key: "xcode", name: "Xcode / xcrun", group: "iOS", status: "fail", message: "xcrun not found", fix: "Install Xcode from the App Store, then `xcodebuild -runFirstLaunch`" };
|
|
103
|
+
}
|
|
104
|
+
async function checkSimulator() {
|
|
105
|
+
if (!isMac) {
|
|
106
|
+
return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "skip" };
|
|
107
|
+
}
|
|
108
|
+
const r = await tryExec(XCRUN, ["simctl", "list", "devices", "booted", "-j"]);
|
|
109
|
+
if (!r.ok) {
|
|
110
|
+
return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "fail", message: "could not query simctl", fix: "Install Xcode" };
|
|
111
|
+
}
|
|
112
|
+
let booted = [];
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(r.stdout);
|
|
115
|
+
booted = Object.values(parsed.devices ?? {})
|
|
116
|
+
.flat()
|
|
117
|
+
.filter((d) => d.state === "Booted")
|
|
118
|
+
.map((d) => ({ name: d.name, udid: d.udid }));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "fail", message: "could not parse simctl output" };
|
|
122
|
+
}
|
|
123
|
+
if (booted.length === 1) {
|
|
124
|
+
return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "pass", message: `${booted[0].name} (booted)` };
|
|
125
|
+
}
|
|
126
|
+
if (booted.length === 0) {
|
|
127
|
+
return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "warn", message: "none booted", fix: "Open Simulator.app (Xcode ships simulators) or `xcrun simctl boot <udid>`" };
|
|
128
|
+
}
|
|
129
|
+
return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "warn", message: `${booted.length} booted — native runs drive exactly one`, fix: "Shut down the extras" };
|
|
130
|
+
}
|
|
131
|
+
/** True when the prebuilt XCUITest runner (WebDriverAgent `.app`) is present. */
|
|
132
|
+
function wdaBundlePresent() {
|
|
133
|
+
const override = process.env.ISH_WDA_PATH;
|
|
134
|
+
if (override)
|
|
135
|
+
return existsSync(override);
|
|
136
|
+
const dir = wdaDir();
|
|
137
|
+
if (!existsSync(dir))
|
|
138
|
+
return false;
|
|
139
|
+
try {
|
|
140
|
+
return readdirSync(dir).some((e) => e.endsWith(".app"));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function checkXcuitestRunner() {
|
|
147
|
+
if (!isMac) {
|
|
148
|
+
return { key: "xcuitest_runner", name: "XCUITest runner", group: "iOS", status: "skip" };
|
|
149
|
+
}
|
|
150
|
+
if (wdaBundlePresent()) {
|
|
151
|
+
return { key: "xcuitest_runner", name: "XCUITest runner", group: "iOS", status: "pass", message: process.env.ISH_WDA_PATH ? "ISH_WDA_PATH" : wdaDir() };
|
|
152
|
+
}
|
|
153
|
+
return { key: "xcuitest_runner", name: "XCUITest runner", group: "iOS", status: "warn", message: "not installed", fix: "ish setup (or set ISH_WDA_PATH to a local WebDriverAgent build)" };
|
|
154
|
+
}
|
|
155
|
+
async function checkAdb() {
|
|
156
|
+
const adbPath = resolveAdb();
|
|
157
|
+
const r = await tryExec(adbPath, ["devices"]);
|
|
158
|
+
if (!r.ok) {
|
|
159
|
+
const adb = { key: "adb", name: "Android adb", group: "Android", status: "warn", message: "not found (only needed for Android)", fix: "Install Android platform-tools" };
|
|
160
|
+
const emulator = { key: "android_emulator", name: "Android emulator", group: "Android", status: "skip" };
|
|
161
|
+
return { adb, emulator };
|
|
162
|
+
}
|
|
163
|
+
const adb = { key: "adb", name: "Android adb", group: "Android", status: "pass", message: adbPath };
|
|
164
|
+
// Lines after the "List of devices attached" header, in "device" state.
|
|
165
|
+
const devices = r.stdout
|
|
166
|
+
.split("\n")
|
|
167
|
+
.slice(1)
|
|
168
|
+
.map((l) => l.trim())
|
|
169
|
+
.filter((l) => l.endsWith("\tdevice"));
|
|
170
|
+
const emulator = devices.length === 1
|
|
171
|
+
? { key: "android_emulator", name: "Android emulator", group: "Android", status: "pass", message: devices[0].split("\t")[0] }
|
|
172
|
+
: devices.length === 0
|
|
173
|
+
? { key: "android_emulator", name: "Android emulator", group: "Android", status: "warn", message: "none online", fix: "Create + boot an AVD in Android Studio > Device Manager (or `emulator -avd <name>`)" }
|
|
174
|
+
: { key: "android_emulator", name: "Android emulator", group: "Android", status: "warn", message: `${devices.length} online — native runs drive exactly one`, fix: "Stop the extras" };
|
|
175
|
+
return { adb, emulator };
|
|
176
|
+
}
|
|
177
|
+
async function checkChromium() {
|
|
178
|
+
try {
|
|
179
|
+
const { isBrowserInstalled } = await import("../lib/local-sim/install.js");
|
|
180
|
+
return isBrowserInstalled()
|
|
181
|
+
? { key: "chromium", name: "Chromium (web local)", group: "Web", status: "pass", message: "installed" }
|
|
182
|
+
: { key: "chromium", name: "Chromium (web local)", group: "Web", status: "warn", message: "not installed", fix: "ish setup" };
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return { key: "chromium", name: "Chromium (web local)", group: "Web", status: "skip", message: "unavailable in this build" };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function checkCli() {
|
|
189
|
+
return { key: "cli", name: "ish CLI", group: "Account", status: "pass", message: `v${pkg.version}` };
|
|
190
|
+
}
|
|
191
|
+
async function runChecks() {
|
|
192
|
+
const { adb, emulator } = await checkAdb();
|
|
193
|
+
return [
|
|
194
|
+
await checkXcode(),
|
|
195
|
+
await checkSimulator(),
|
|
196
|
+
checkXcuitestRunner(),
|
|
197
|
+
adb,
|
|
198
|
+
emulator,
|
|
199
|
+
await checkChromium(),
|
|
200
|
+
checkAuth(),
|
|
201
|
+
checkWorkspace(),
|
|
202
|
+
checkCli(),
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
// ── Rendering ───────────────────────────────────────────────────────────────
|
|
206
|
+
const MARK = { pass: "[ OK ]", warn: "[WARN]", fail: "[FAIL]", skip: "[ -- ]" };
|
|
207
|
+
const GROUP_ORDER = ["iOS", "Android", "Web", "Account"];
|
|
208
|
+
const GROUP_LABEL = {
|
|
209
|
+
iOS: "iOS (native simulations)",
|
|
210
|
+
Android: "Android (native simulations)",
|
|
211
|
+
Web: "Web (local browser simulations)",
|
|
212
|
+
Account: "Account",
|
|
213
|
+
};
|
|
214
|
+
// `ish check <platform>` scopes the report to one platform's group + Account,
|
|
215
|
+
// so an Android study never nags about Xcode and an iOS study never about adb.
|
|
216
|
+
const PLATFORM_GROUPS = {
|
|
217
|
+
ios: ["iOS", "Account"],
|
|
218
|
+
android: ["Android", "Account"],
|
|
219
|
+
web: ["Web", "Account"],
|
|
220
|
+
};
|
|
221
|
+
function scopeChecks(checks, platform) {
|
|
222
|
+
if (!platform)
|
|
223
|
+
return checks;
|
|
224
|
+
const groups = PLATFORM_GROUPS[platform];
|
|
225
|
+
return groups ? checks.filter((c) => groups.includes(c.group)) : checks;
|
|
226
|
+
}
|
|
227
|
+
function overall(checks) {
|
|
228
|
+
if (checks.some((c) => c.status === "fail"))
|
|
229
|
+
return "fail";
|
|
230
|
+
if (checks.some((c) => c.status === "warn"))
|
|
231
|
+
return "warn";
|
|
232
|
+
return "pass";
|
|
233
|
+
}
|
|
234
|
+
function renderHuman(checks) {
|
|
235
|
+
const lines = ["", "ish check — local & native simulation environment", ""];
|
|
236
|
+
const width = Math.max(...checks.map((c) => c.name.length)) + 2;
|
|
237
|
+
for (const group of GROUP_ORDER) {
|
|
238
|
+
const inGroup = checks.filter((c) => c.group === group);
|
|
239
|
+
if (inGroup.length === 0)
|
|
240
|
+
continue;
|
|
241
|
+
lines.push(` ${GROUP_LABEL[group]}`);
|
|
242
|
+
for (const c of inGroup) {
|
|
243
|
+
const label = c.name.padEnd(width, " ");
|
|
244
|
+
let line = ` ${MARK[c.status]} ${label}${c.message ?? ""}`;
|
|
245
|
+
lines.push(line.trimEnd());
|
|
246
|
+
if ((c.status === "warn" || c.status === "fail") && c.fix) {
|
|
247
|
+
lines.push(` ↳ ${c.fix}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
lines.push("");
|
|
251
|
+
}
|
|
252
|
+
const o = overall(checks);
|
|
253
|
+
const fails = checks.filter((c) => c.status === "fail").length;
|
|
254
|
+
const warns = checks.filter((c) => c.status === "warn").length;
|
|
255
|
+
if (o === "pass") {
|
|
256
|
+
lines.push("Everything checks out.");
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
const parts = [fails ? `${fails} blocking` : "", warns ? `${warns} optional` : ""].filter(Boolean);
|
|
260
|
+
lines.push(`${parts.join(", ")} issue(s). Run \`ish setup\` to fetch what's fetchable.`);
|
|
261
|
+
}
|
|
262
|
+
console.log(lines.join("\n"));
|
|
263
|
+
}
|
|
264
|
+
// ── Commands ──────────────────────────────────────────────────────────────
|
|
265
|
+
export function registerDoctorCommands(program) {
|
|
266
|
+
program
|
|
267
|
+
.command("check [platform]")
|
|
268
|
+
.alias("doctor")
|
|
269
|
+
.description("Check your local simulation setup for a platform (ios | android | web), or everything")
|
|
270
|
+
.action(async (platform, _opts, cmd) => {
|
|
271
|
+
await runInline(cmd, async (globals) => {
|
|
272
|
+
const scope = platform?.toLowerCase();
|
|
273
|
+
if (scope && !PLATFORM_GROUPS[scope]) {
|
|
274
|
+
throw new Error(`Unknown platform "${platform}". Use one of: ios, android, web.`);
|
|
275
|
+
}
|
|
276
|
+
const checks = scopeChecks(await runChecks(), scope);
|
|
277
|
+
if (globals.json) {
|
|
278
|
+
output({ checks, overall: overall(checks) }, true);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
renderHuman(checks);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
program
|
|
286
|
+
.command("setup")
|
|
287
|
+
.description("Fetch/install local-simulation dependencies (Chromium, iOS runner) and show what's left to do")
|
|
288
|
+
.action(async (_opts, cmd) => {
|
|
289
|
+
await runInline(cmd, async (globals) => {
|
|
290
|
+
const quiet = globals.json;
|
|
291
|
+
const log = (m) => { if (!quiet)
|
|
292
|
+
console.error(m); };
|
|
293
|
+
// 1. Chromium (web local) — auto-fetchable.
|
|
294
|
+
try {
|
|
295
|
+
const { isBrowserInstalled, ensureBrowser } = await import("../lib/local-sim/install.js");
|
|
296
|
+
if (isBrowserInstalled()) {
|
|
297
|
+
log("Chromium: already installed.");
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
log("Chromium: fetching for local web simulations...");
|
|
301
|
+
await ensureBrowser({ quiet, skipPrompt: true });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
catch (e) {
|
|
305
|
+
log(`Chromium: could not install (${e instanceof Error ? e.message : String(e)}).`);
|
|
306
|
+
}
|
|
307
|
+
// 2. iOS XCUITest runner (WebDriverAgent) — auto-fetchable on macOS.
|
|
308
|
+
if (isMac) {
|
|
309
|
+
try {
|
|
310
|
+
if (wdaBundlePresent()) {
|
|
311
|
+
log("iOS runner: already installed.");
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
log("iOS runner: fetching WebDriverAgent...");
|
|
315
|
+
const { resolveWdaBundle } = await import("../lib/local-sim/xcuitest.js");
|
|
316
|
+
await resolveWdaBundle();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
catch (e) {
|
|
320
|
+
log(`iOS runner: could not fetch (${e instanceof Error ? e.message : String(e)}).`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// 3. Re-run checks so the user sees the resulting state, and hand back
|
|
324
|
+
// the structured result on --json.
|
|
325
|
+
const checks = await runChecks();
|
|
326
|
+
if (globals.json) {
|
|
327
|
+
output({ checks, overall: overall(checks) }, true);
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
renderHuman(checks);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { registerDocsCommands } from "./commands/docs.js";
|
|
|
19
19
|
import { registerInitCommands } from "./commands/init.js";
|
|
20
20
|
import { registerMcpCommands } from "./commands/mcp.js";
|
|
21
21
|
import { registerSecretCommands } from "./commands/secret.js";
|
|
22
|
+
import { registerDoctorCommands } from "./commands/doctor.js";
|
|
22
23
|
import { AGENT_HELP_FOOTER } from "./lib/docs.js";
|
|
23
24
|
import { runInline, EXIT_USAGE, injectGlobalWorkspaceOption } from "./lib/command-helpers.js";
|
|
24
25
|
import { resolveApiUrl, resolveToken, verifyToken } from "./lib/auth.js";
|
|
@@ -510,6 +511,7 @@ registerDocsCommands(program);
|
|
|
510
511
|
registerInitCommands(program);
|
|
511
512
|
registerMcpCommands(program);
|
|
512
513
|
registerSecretCommands(program);
|
|
514
|
+
registerDoctorCommands(program);
|
|
513
515
|
program
|
|
514
516
|
.command("upgrade")
|
|
515
517
|
.description("Update ish to the latest version")
|
|
@@ -429,6 +429,8 @@ export function describeAction(action) {
|
|
|
429
429
|
return `wait ${action.duration_ms ?? 1000}ms`;
|
|
430
430
|
case "navigate_back":
|
|
431
431
|
return "navigate back";
|
|
432
|
+
case "open_system_panel":
|
|
433
|
+
return `open_system_panel (${action.panel ?? "notifications"})`;
|
|
432
434
|
case "long_press":
|
|
433
435
|
return `long_press on '${element}'${modSuffix}`;
|
|
434
436
|
case "double_tap":
|
|
@@ -44,6 +44,16 @@ export declare function inputDrag(x1: number, y1: number, x2: number, y2: number
|
|
|
44
44
|
/** A long-press is a zero-distance swipe held for `durationMs`. */
|
|
45
45
|
export declare function inputLongPress(x: number, y: number, durationMs?: number): Promise<void>;
|
|
46
46
|
export declare function pressKeyEvent(keyevent: string): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Open a system panel via `adb shell cmd statusbar expand-settings|expand-notifications`
|
|
49
|
+
* — a deterministic OS-driven open, chosen over a top-edge `input swipe` because the
|
|
50
|
+
* generic swipe is center-anchored and a synthetic top-edge swipe never registers the
|
|
51
|
+
* system pull-down. `expand-notifications` opens the notification shade;
|
|
52
|
+
* `expand-settings` pulls all the way to quick settings.
|
|
53
|
+
*/
|
|
54
|
+
export declare function statusbarExpand(panel: "expand-settings" | "expand-notifications"): Promise<void>;
|
|
55
|
+
/** Collapse the open system panel (`cmd statusbar collapse`). */
|
|
56
|
+
export declare function statusbarCollapse(): Promise<void>;
|
|
47
57
|
/**
|
|
48
58
|
* Force a device orientation. We first disable auto-rotation
|
|
49
59
|
* (`accelerometer_rotation 0`) — otherwise the sensor immediately overrides
|
|
@@ -90,7 +90,7 @@ export async function requireOneDevice() {
|
|
|
90
90
|
}
|
|
91
91
|
catch (err) {
|
|
92
92
|
const msg = err instanceof Error ? err.message : String(err);
|
|
93
|
-
throw new AdbError(`Could not run adb (looked for "${ADB}").
|
|
93
|
+
throw new AdbError(`Could not run adb (looked for "${ADB}"). Run \`ish check android\` to check your setup. ${msg}`);
|
|
94
94
|
}
|
|
95
95
|
// Output: "List of devices attached\n<serial>\tdevice\n..."
|
|
96
96
|
const online = out
|
|
@@ -99,7 +99,7 @@ export async function requireOneDevice() {
|
|
|
99
99
|
.map((l) => l.trim())
|
|
100
100
|
.filter((l) => l && l.endsWith("\tdevice"));
|
|
101
101
|
if (online.length === 0) {
|
|
102
|
-
throw new AdbError("No Android device/emulator online.
|
|
102
|
+
throw new AdbError("No Android device/emulator online. Run `ish check android` to check your setup and how to boot one.");
|
|
103
103
|
}
|
|
104
104
|
if (online.length > 1) {
|
|
105
105
|
throw new AdbError(`Expected exactly one Android device, found ${online.length}. Stop the extras (the sim drives a single device).`);
|
|
@@ -153,6 +153,20 @@ export async function inputLongPress(x, y, durationMs = 600) {
|
|
|
153
153
|
export async function pressKeyEvent(keyevent) {
|
|
154
154
|
await adbShell(["input", "keyevent", keyevent]);
|
|
155
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Open a system panel via `adb shell cmd statusbar expand-settings|expand-notifications`
|
|
158
|
+
* — a deterministic OS-driven open, chosen over a top-edge `input swipe` because the
|
|
159
|
+
* generic swipe is center-anchored and a synthetic top-edge swipe never registers the
|
|
160
|
+
* system pull-down. `expand-notifications` opens the notification shade;
|
|
161
|
+
* `expand-settings` pulls all the way to quick settings.
|
|
162
|
+
*/
|
|
163
|
+
export async function statusbarExpand(panel) {
|
|
164
|
+
await adbShell(["cmd", "statusbar", panel]);
|
|
165
|
+
}
|
|
166
|
+
/** Collapse the open system panel (`cmd statusbar collapse`). */
|
|
167
|
+
export async function statusbarCollapse() {
|
|
168
|
+
await adbShell(["cmd", "statusbar", "collapse"]);
|
|
169
|
+
}
|
|
156
170
|
/**
|
|
157
171
|
* Force a device orientation. We first disable auto-rotation
|
|
158
172
|
* (`accelerometer_rotation 0`) — otherwise the sensor immediately overrides
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* - Vision path: px = round(x / 1000 * screencapWidth); same for y.
|
|
20
20
|
*/
|
|
21
21
|
import { resolveTextValue } from "./actions.js";
|
|
22
|
-
import { requireOneDevice, screencapPng, pngDimensions, dumpUiautomatorXml, inputTap, inputSwipe, inputDrag, inputLongPress, setUserRotation, forceStop, launchApp, installApk, isPackageInstalled, listPackages, isAdbKeyboardInstalled, enableAdbKeyboard, setIme, resetIme, currentIme, adbKeyboardType, adbKeyboardClear, pressKeyEvent, ADB_KEYBOARD_PKG, } from "./adb.js";
|
|
22
|
+
import { requireOneDevice, screencapPng, pngDimensions, dumpUiautomatorXml, inputTap, inputSwipe, inputDrag, inputLongPress, setUserRotation, forceStop, launchApp, installApk, isPackageInstalled, listPackages, isAdbKeyboardInstalled, enableAdbKeyboard, setIme, resetIme, currentIme, adbKeyboardType, adbKeyboardClear, pressKeyEvent, statusbarExpand, ADB_KEYBOARD_PKG, } from "./adb.js";
|
|
23
23
|
import { isLocalPath } from "../upload.js";
|
|
24
24
|
import { deNormalizePoint, deNormalizeDrag } from "./coordinates.js";
|
|
25
25
|
import { parseUiautomatorXml, serializeNativeTree, boundsCenter } from "./native-a11y.js";
|
|
@@ -276,6 +276,11 @@ export class AndroidDevice {
|
|
|
276
276
|
await pressKeyEvent("KEYCODE_BACK");
|
|
277
277
|
break;
|
|
278
278
|
}
|
|
279
|
+
case "open_system_panel": {
|
|
280
|
+
// Element-less, like navigate_back: no px, just an OS-driven panel open.
|
|
281
|
+
await statusbarExpand(action.panel === "quick_settings" ? "expand-settings" : "expand-notifications");
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
279
284
|
case "drag": {
|
|
280
285
|
// A drag GRABS an element and RELEASES it elsewhere ("click the
|
|
281
286
|
// element, move, let go") — distinct from a swipe (element-less
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The backend's vision locator returns NORMALIZED 0-1000 coordinates. A native
|
|
5
5
|
* device de-normalizes them against a concrete dimension (screencap pixels for
|
|
6
|
-
* Android;
|
|
6
|
+
* Android; WDA POINTS for the iOS tap, screenshot PIXELS for the iOS record),
|
|
7
7
|
* and the loop later re-normalizes the recorded coordinate back to 0-1000.
|
|
8
8
|
*
|
|
9
9
|
* Pure and side-effect-free so the round-trip can be unit-tested without a
|
|
@@ -32,7 +32,7 @@ export declare function deNormalizePoint(c: {
|
|
|
32
32
|
* De-normalize a drag's start AND end points (each normalized 0-1000) against
|
|
33
33
|
* one device dimension into the {start,end} pixel/point pair the drivers feed to
|
|
34
34
|
* a slow swipe. The drag path is a from→to gesture, so BOTH ends de-normalize
|
|
35
|
-
* against the SAME basis (Android: screencap pixels; iOS:
|
|
35
|
+
* against the SAME basis (Android: screencap pixels; iOS: WDA POINTS). Pure so
|
|
36
36
|
* the two-ended de-normalization is unit-testable without a device.
|
|
37
37
|
*/
|
|
38
38
|
export declare function deNormalizeDrag(drag: {
|
|
@@ -52,9 +52,9 @@ export declare function deNormalizeDrag(drag: {
|
|
|
52
52
|
};
|
|
53
53
|
/**
|
|
54
54
|
* Scale an iOS POINT coordinate into the PIXEL space, used by the element path:
|
|
55
|
-
*
|
|
55
|
+
* WebDriverAgent reports a11y frames (and so the tappable bounds-center) in POINTS, but
|
|
56
56
|
* the loop records — and re-normalizes against `dimensions()` — in PIXELS. So
|
|
57
|
-
* the element path taps the point-center directly (
|
|
57
|
+
* the element path taps the point-center directly (WDA consumes points) yet must
|
|
58
58
|
* RECORD a pixel-center; this converts the one to the other per-axis by the
|
|
59
59
|
* point→pixel ratio (the @Nx scale). Pure so the conversion is unit-testable
|
|
60
60
|
* without a simulator. Android needs no analog: its screencap and tap share one
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The backend's vision locator returns NORMALIZED 0-1000 coordinates. A native
|
|
5
5
|
* device de-normalizes them against a concrete dimension (screencap pixels for
|
|
6
|
-
* Android;
|
|
6
|
+
* Android; WDA POINTS for the iOS tap, screenshot PIXELS for the iOS record),
|
|
7
7
|
* and the loop later re-normalizes the recorded coordinate back to 0-1000.
|
|
8
8
|
*
|
|
9
9
|
* Pure and side-effect-free so the round-trip can be unit-tested without a
|
|
@@ -32,7 +32,7 @@ export function deNormalizePoint(c, width, height) {
|
|
|
32
32
|
* De-normalize a drag's start AND end points (each normalized 0-1000) against
|
|
33
33
|
* one device dimension into the {start,end} pixel/point pair the drivers feed to
|
|
34
34
|
* a slow swipe. The drag path is a from→to gesture, so BOTH ends de-normalize
|
|
35
|
-
* against the SAME basis (Android: screencap pixels; iOS:
|
|
35
|
+
* against the SAME basis (Android: screencap pixels; iOS: WDA POINTS). Pure so
|
|
36
36
|
* the two-ended de-normalization is unit-testable without a device.
|
|
37
37
|
*/
|
|
38
38
|
export function deNormalizeDrag(drag, width, height) {
|
|
@@ -43,9 +43,9 @@ export function deNormalizeDrag(drag, width, height) {
|
|
|
43
43
|
}
|
|
44
44
|
/**
|
|
45
45
|
* Scale an iOS POINT coordinate into the PIXEL space, used by the element path:
|
|
46
|
-
*
|
|
46
|
+
* WebDriverAgent reports a11y frames (and so the tappable bounds-center) in POINTS, but
|
|
47
47
|
* the loop records — and re-normalizes against `dimensions()` — in PIXELS. So
|
|
48
|
-
* the element path taps the point-center directly (
|
|
48
|
+
* the element path taps the point-center directly (WDA consumes points) yet must
|
|
49
49
|
* RECORD a pixel-center; this converts the one to the other per-axis by the
|
|
50
50
|
* point→pixel ratio (the @Nx scale). Pure so the conversion is unit-testable
|
|
51
51
|
* without a simulator. Android needs no analog: its screencap and tap share one
|
|
@@ -18,7 +18,7 @@ import type { BrowserSession } from "./browser.js";
|
|
|
18
18
|
* One observation of the target's current state.
|
|
19
19
|
*
|
|
20
20
|
* `accessibilityTree` is populated by the browser (CDP) and by native targets
|
|
21
|
-
* (uiautomator /
|
|
21
|
+
* (uiautomator / WDA /source), serialized to the same `[id] role "name"`
|
|
22
22
|
* format the backend DOMLocator reasons over; it's "" only when a native dump
|
|
23
23
|
* fails or yields a sparse tree, which makes the backend take its vision branch.
|
|
24
24
|
* `url` is browser-only ("" for native). `tabs` is browser-only and empty for
|
|
@@ -130,7 +130,7 @@ export declare class BrowserDevice implements SimulationDevice {
|
|
|
130
130
|
/**
|
|
131
131
|
* Build the device for a platform. `web`/`browser`/`""` → Playwright
|
|
132
132
|
* `BrowserDevice`; `android` → `AndroidDevice` (adb); `ios` → `IOSDevice`
|
|
133
|
-
* (simctl +
|
|
133
|
+
* (simctl + WebDriverAgent). The native cases are dynamically imported so the browser path
|
|
134
134
|
* never pulls in the adb/simctl modules.
|
|
135
135
|
*/
|
|
136
136
|
export declare function createDevice(platform: string, opts: {
|
|
@@ -116,7 +116,7 @@ export class BrowserDevice {
|
|
|
116
116
|
/**
|
|
117
117
|
* Build the device for a platform. `web`/`browser`/`""` → Playwright
|
|
118
118
|
* `BrowserDevice`; `android` → `AndroidDevice` (adb); `ios` → `IOSDevice`
|
|
119
|
-
* (simctl +
|
|
119
|
+
* (simctl + WebDriverAgent). The native cases are dynamically imported so the browser path
|
|
120
120
|
* never pulls in the adb/simctl modules.
|
|
121
121
|
*/
|
|
122
122
|
export async function createDevice(platform, opts) {
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* IOSDevice — drives a local iOS simulator via `xcrun simctl` +
|
|
2
|
+
* IOSDevice — drives a local iOS simulator via `xcrun simctl` (lifecycle +
|
|
3
|
+
* screenshot) and WebDriverAgent/XCUITest (UI + a11y; see xcuitest.ts),
|
|
3
4
|
* implementing the SimulationDevice surface the loop expects. Mirrors
|
|
4
5
|
* AndroidDevice; the one substantive difference is the coordinate space.
|
|
5
6
|
*
|
|
6
7
|
* Two resolution paths, mirroring the browser:
|
|
7
|
-
* - ELEMENT (preferred): observe() reads
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* - ELEMENT (preferred): observe() reads WDA's `/source` a11y tree, serializes
|
|
9
|
+
* it to the `[id] role "label"` string the backend DOMLocator reasons over,
|
|
10
|
+
* and keeps a local `shortId → bounds` map (bounds in POINTS). The backend
|
|
11
|
+
* returns a `node_id`; executeAction() looks the bounds up and taps the
|
|
12
|
+
* element's CENTER.
|
|
12
13
|
* - VISION (fallback): when the tree is empty/sparse, observe() returns an
|
|
13
14
|
* empty tree so the backend takes its vision branch and returns NORMALIZED
|
|
14
15
|
* 0-1000 coordinates. Also taken per-action whenever node_id is absent.
|
|
@@ -16,7 +17,7 @@
|
|
|
16
17
|
* COORDINATE SPACE — two spaces, the key difference from Android (where
|
|
17
18
|
* screencap and tap share one pixel space):
|
|
18
19
|
* `simctl io booted screenshot` is in PIXELS (e.g. 1179x2556 @3x), but
|
|
19
|
-
*
|
|
20
|
+
* WDA taps/swipes AND the `/source` a11y frames are POINTS (393x852).
|
|
20
21
|
* The invariant in BOTH paths: TAP in points, RECORD in pixels, because the
|
|
21
22
|
* loop re-normalizes the recorded coord against dimensions() (PIXELS).
|
|
22
23
|
* - VISION: tap pt = round(n/1000 * pointSize); record px = round(n/1000 * pixelSize).
|
|
@@ -46,6 +47,8 @@ export declare class IOSDevice implements SimulationDevice {
|
|
|
46
47
|
private readonly appPath;
|
|
47
48
|
/** udid of the single booted simulator we drive. */
|
|
48
49
|
private udid;
|
|
50
|
+
/** Set once the WebDriverAgent runner is up, so the startup note logs once. */
|
|
51
|
+
private wdaStarted;
|
|
49
52
|
/** POINT size — what idb ui tap/swipe consume (de-normalization basis for TAPS). */
|
|
50
53
|
private pointWidth;
|
|
51
54
|
private pointHeight;
|
|
@@ -77,7 +80,7 @@ export declare class IOSDevice implements SimulationDevice {
|
|
|
77
80
|
private refreshScreen;
|
|
78
81
|
observe(): Promise<DeviceObservation>;
|
|
79
82
|
/**
|
|
80
|
-
* Read + serialize
|
|
83
|
+
* Read + serialize WDA's /source a11y tree (bounds in POINTS). Any
|
|
81
84
|
* failure (retries exhausted on a trivial tree, parse error) degrades to an
|
|
82
85
|
* empty tree so the backend falls back to vision — a missing tree must never
|
|
83
86
|
* abort the observation.
|
|
@@ -89,7 +92,7 @@ export declare class IOSDevice implements SimulationDevice {
|
|
|
89
92
|
width: number;
|
|
90
93
|
height: number;
|
|
91
94
|
};
|
|
92
|
-
/** Normalized 0-1000 → POINT space (
|
|
95
|
+
/** Normalized 0-1000 → POINT space (WDA taps/swipes take points). */
|
|
93
96
|
private toPoints;
|
|
94
97
|
/** Normalized 0-1000 → PIXEL space (the recorded/reported coord). */
|
|
95
98
|
private toPixels;
|
|
@@ -140,6 +143,20 @@ export declare class IOSDevice implements SimulationDevice {
|
|
|
140
143
|
* do drive the system gesture) when no back button is visible.
|
|
141
144
|
*/
|
|
142
145
|
private navigateBack;
|
|
146
|
+
/**
|
|
147
|
+
* Best-effort open of an iOS system panel by swiping down from the top edge.
|
|
148
|
+
* iOS has no `cmd statusbar` equivalent, so on a Face-ID layout:
|
|
149
|
+
* - notifications → Notification Center: swipe down from the top-CENTER.
|
|
150
|
+
* - quick_settings → Control Center: swipe down from the top-RIGHT corner.
|
|
151
|
+
* Coordinates are POINTS (idb consumes points; see toPoints()/the swipe()
|
|
152
|
+
* helper). This is FLAKY on the simulator — idb's synthetic touch frequently
|
|
153
|
+
* doesn't trigger the system edge gesture (the same limitation navigateBack's
|
|
154
|
+
* edge-swipe hits). We compare a before/after screenshot and log LOUDLY when
|
|
155
|
+
* the screen didn't change, rather than silently reporting success, so a
|
|
156
|
+
* no-op is visible in the run. The executeAction caller still returns
|
|
157
|
+
* success:true (the gesture was attempted); the loud log is the signal.
|
|
158
|
+
*/
|
|
159
|
+
private openSystemPanel;
|
|
143
160
|
/**
|
|
144
161
|
* The nav-bar back button: the leading (leftmost) actionable button in the
|
|
145
162
|
* top nav-bar band. iOS HIG guarantees "back" is the leading nav item in a
|