@ishlabs/cli 0.25.0 → 0.26.1
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 +42 -0
- package/dist/commands/doctor.js +359 -0
- package/dist/commands/iteration.js +23 -5
- package/dist/commands/study-participant.js +1 -1
- package/dist/commands/study-run.js +26 -1
- package/dist/commands/study-screenshots.js +38 -5
- package/dist/index.js +2 -0
- package/dist/lib/api-client.d.ts +3 -0
- package/dist/lib/api-client.js +6 -1
- package/dist/lib/docs.js +15 -3
- package/dist/lib/local-sim/actions.d.ts +18 -0
- package/dist/lib/local-sim/actions.js +32 -0
- package/dist/lib/local-sim/adb.d.ts +33 -0
- package/dist/lib/local-sim/adb.js +121 -17
- package/dist/lib/local-sim/android.d.ts +7 -1
- package/dist/lib/local-sim/android.js +21 -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 +21 -2
- package/dist/lib/local-sim/device.js +1 -1
- package/dist/lib/local-sim/ios.d.ts +33 -10
- package/dist/lib/local-sim/ios.js +88 -20
- package/dist/lib/local-sim/loop.js +134 -25
- 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 +28 -43
- package/dist/lib/local-sim/simctl.js +53 -142
- package/dist/lib/local-sim/types.d.ts +13 -2
- 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 +14 -0
- package/dist/lib/paths.js +21 -0
- package/dist/lib/report-readiness.d.ts +44 -0
- package/dist/lib/report-readiness.js +74 -0
- package/dist/lib/skill-content.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,42 @@
|
|
|
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 type CheckStatus = "pass" | "warn" | "fail" | "skip";
|
|
27
|
+
export interface Check {
|
|
28
|
+
/** Stable machine key (for --json). */
|
|
29
|
+
key: string;
|
|
30
|
+
/** Human label. */
|
|
31
|
+
name: string;
|
|
32
|
+
/** Coarse grouping for the human checklist. */
|
|
33
|
+
group: "iOS" | "Android" | "Web" | "Account";
|
|
34
|
+
status: CheckStatus;
|
|
35
|
+
message?: string;
|
|
36
|
+
/** One-line remediation hint shown on warn/fail. */
|
|
37
|
+
fix?: string;
|
|
38
|
+
}
|
|
39
|
+
export declare function runChecks(): Promise<Check[]>;
|
|
40
|
+
export declare function scopeChecks(checks: Check[], platform?: string): Check[];
|
|
41
|
+
export declare function overall(checks: Check[]): CheckStatus;
|
|
42
|
+
export declare function registerDoctorCommands(program: Command): void;
|
|
@@ -0,0 +1,359 @@
|
|
|
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 { reportReadiness } from "../lib/report-readiness.js";
|
|
32
|
+
import { loadConfig } from "../config.js";
|
|
33
|
+
import { decodeJwtClaims, isTokenExpired } from "../auth.js";
|
|
34
|
+
import { wdaDir, adbBin } from "../lib/paths.js";
|
|
35
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
36
|
+
const execFileAsync = promisify(execFile);
|
|
37
|
+
const XCRUN = "/usr/bin/xcrun";
|
|
38
|
+
const isMac = process.platform === "darwin";
|
|
39
|
+
async function tryExec(cmd, args, timeoutMs = 8000) {
|
|
40
|
+
try {
|
|
41
|
+
const { stdout, stderr } = await execFileAsync(cmd, args, {
|
|
42
|
+
timeout: timeoutMs,
|
|
43
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
44
|
+
});
|
|
45
|
+
return { ok: true, stdout, stderr };
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
const err = e;
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
stdout: err.stdout ?? "",
|
|
52
|
+
stderr: err.stderr ?? err.message ?? String(e),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** adb path, mirroring the device layer's resolution (ISH_ADB → SDK → Homebrew → our download cache → PATH). */
|
|
57
|
+
function resolveAdb() {
|
|
58
|
+
for (const v of [process.env.ISH_ADB, process.env.ADB]) {
|
|
59
|
+
if (v && existsSync(v))
|
|
60
|
+
return v;
|
|
61
|
+
}
|
|
62
|
+
for (const home of [process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT]) {
|
|
63
|
+
if (home) {
|
|
64
|
+
const p = path.join(home, "platform-tools", "adb");
|
|
65
|
+
if (existsSync(p))
|
|
66
|
+
return p;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (existsSync("/opt/homebrew/bin/adb"))
|
|
70
|
+
return "/opt/homebrew/bin/adb";
|
|
71
|
+
if (existsSync(adbBin()))
|
|
72
|
+
return adbBin();
|
|
73
|
+
return "adb";
|
|
74
|
+
}
|
|
75
|
+
// ── Individual checks ──────────────────────────────────────────────────────
|
|
76
|
+
function checkAuth() {
|
|
77
|
+
const cfg = loadConfig();
|
|
78
|
+
const token = cfg.access_token || cfg.token;
|
|
79
|
+
if (!token) {
|
|
80
|
+
return { key: "auth", name: "Account", group: "Account", status: "fail", message: "not logged in", fix: "ish login" };
|
|
81
|
+
}
|
|
82
|
+
const claims = decodeJwtClaims(token);
|
|
83
|
+
const email = claims?.email ?? "authenticated";
|
|
84
|
+
if (isTokenExpired(token)) {
|
|
85
|
+
// Expired access tokens auto-refresh via the refresh_token on the next API
|
|
86
|
+
// call, so this is a warn, not a hard fail.
|
|
87
|
+
return { key: "auth", name: "Account", group: "Account", status: "warn", message: `${email} (token expired — refreshes on next call)`, fix: "ish login (if refresh fails)" };
|
|
88
|
+
}
|
|
89
|
+
return { key: "auth", name: "Account", group: "Account", status: "pass", message: email };
|
|
90
|
+
}
|
|
91
|
+
function checkWorkspace() {
|
|
92
|
+
const cfg = loadConfig();
|
|
93
|
+
if (cfg.workspace) {
|
|
94
|
+
return { key: "workspace", name: "Active workspace", group: "Account", status: "pass", message: cfg.workspace };
|
|
95
|
+
}
|
|
96
|
+
return { key: "workspace", name: "Active workspace", group: "Account", status: "warn", message: "none set", fix: "ish workspace use <id>" };
|
|
97
|
+
}
|
|
98
|
+
async function checkXcode() {
|
|
99
|
+
if (!isMac) {
|
|
100
|
+
return { key: "xcode", name: "Xcode / xcrun", group: "iOS", status: "skip", message: "iOS simulations require macOS" };
|
|
101
|
+
}
|
|
102
|
+
const r = await tryExec(XCRUN, ["--version"]);
|
|
103
|
+
if (r.ok) {
|
|
104
|
+
const ver = r.stdout.trim().split("\n")[0] || "xcrun present";
|
|
105
|
+
return { key: "xcode", name: "Xcode / xcrun", group: "iOS", status: "pass", message: ver };
|
|
106
|
+
}
|
|
107
|
+
return { key: "xcode", name: "Xcode / xcrun", group: "iOS", status: "fail", message: "xcrun not found", fix: "Install Xcode from the App Store, then `xcodebuild -runFirstLaunch`" };
|
|
108
|
+
}
|
|
109
|
+
async function checkSimulator() {
|
|
110
|
+
if (!isMac) {
|
|
111
|
+
return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "skip" };
|
|
112
|
+
}
|
|
113
|
+
const r = await tryExec(XCRUN, ["simctl", "list", "devices", "booted", "-j"]);
|
|
114
|
+
if (!r.ok) {
|
|
115
|
+
return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "fail", message: "could not query simctl", fix: "Install Xcode" };
|
|
116
|
+
}
|
|
117
|
+
let booted = [];
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(r.stdout);
|
|
120
|
+
booted = Object.values(parsed.devices ?? {})
|
|
121
|
+
.flat()
|
|
122
|
+
.filter((d) => d.state === "Booted")
|
|
123
|
+
.map((d) => ({ name: d.name, udid: d.udid }));
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "fail", message: "could not parse simctl output" };
|
|
127
|
+
}
|
|
128
|
+
if (booted.length === 1) {
|
|
129
|
+
return { key: "ios_simulator", name: "iOS simulator", group: "iOS", status: "pass", message: `${booted[0].name} (booted)` };
|
|
130
|
+
}
|
|
131
|
+
if (booted.length === 0) {
|
|
132
|
+
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>`" };
|
|
133
|
+
}
|
|
134
|
+
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" };
|
|
135
|
+
}
|
|
136
|
+
/** True when the prebuilt XCUITest runner (WebDriverAgent `.app`) is present. */
|
|
137
|
+
function wdaBundlePresent() {
|
|
138
|
+
const override = process.env.ISH_WDA_PATH;
|
|
139
|
+
if (override)
|
|
140
|
+
return existsSync(override);
|
|
141
|
+
const dir = wdaDir();
|
|
142
|
+
if (!existsSync(dir))
|
|
143
|
+
return false;
|
|
144
|
+
try {
|
|
145
|
+
return readdirSync(dir).some((e) => e.endsWith(".app"));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function checkXcuitestRunner() {
|
|
152
|
+
if (!isMac) {
|
|
153
|
+
return { key: "xcuitest_runner", name: "XCUITest runner", group: "iOS", status: "skip" };
|
|
154
|
+
}
|
|
155
|
+
if (wdaBundlePresent()) {
|
|
156
|
+
return { key: "xcuitest_runner", name: "XCUITest runner", group: "iOS", status: "pass", message: process.env.ISH_WDA_PATH ? "ISH_WDA_PATH" : wdaDir() };
|
|
157
|
+
}
|
|
158
|
+
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)" };
|
|
159
|
+
}
|
|
160
|
+
async function checkAdb() {
|
|
161
|
+
const adbPath = resolveAdb();
|
|
162
|
+
const r = await tryExec(adbPath, ["devices"]);
|
|
163
|
+
if (!r.ok) {
|
|
164
|
+
const adb = { key: "adb", name: "Android adb", group: "Android", status: "warn", message: "not found (only needed for Android)", fix: "ish setup (fetches adb), or install Android platform-tools" };
|
|
165
|
+
const emulator = { key: "android_emulator", name: "Android emulator", group: "Android", status: "skip" };
|
|
166
|
+
return { adb, emulator };
|
|
167
|
+
}
|
|
168
|
+
const adb = { key: "adb", name: "Android adb", group: "Android", status: "pass", message: adbPath };
|
|
169
|
+
// Lines after the "List of devices attached" header, in "device" state.
|
|
170
|
+
const devices = r.stdout
|
|
171
|
+
.split("\n")
|
|
172
|
+
.slice(1)
|
|
173
|
+
.map((l) => l.trim())
|
|
174
|
+
.filter((l) => l.endsWith("\tdevice"));
|
|
175
|
+
const emulator = devices.length === 1
|
|
176
|
+
? { key: "android_emulator", name: "Android emulator", group: "Android", status: "pass", message: devices[0].split("\t")[0] }
|
|
177
|
+
: devices.length === 0
|
|
178
|
+
? { 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>`)" }
|
|
179
|
+
: { key: "android_emulator", name: "Android emulator", group: "Android", status: "warn", message: `${devices.length} online — native runs drive exactly one`, fix: "Stop the extras" };
|
|
180
|
+
return { adb, emulator };
|
|
181
|
+
}
|
|
182
|
+
async function checkChromium() {
|
|
183
|
+
try {
|
|
184
|
+
const { isBrowserInstalled } = await import("../lib/local-sim/install.js");
|
|
185
|
+
return isBrowserInstalled()
|
|
186
|
+
? { key: "chromium", name: "Chromium (web local)", group: "Web", status: "pass", message: "installed" }
|
|
187
|
+
: { key: "chromium", name: "Chromium (web local)", group: "Web", status: "warn", message: "not installed", fix: "ish setup" };
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return { key: "chromium", name: "Chromium (web local)", group: "Web", status: "skip", message: "unavailable in this build" };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function checkCli() {
|
|
194
|
+
return { key: "cli", name: "ish CLI", group: "Account", status: "pass", message: `v${pkg.version}` };
|
|
195
|
+
}
|
|
196
|
+
export async function runChecks() {
|
|
197
|
+
const { adb, emulator } = await checkAdb();
|
|
198
|
+
return [
|
|
199
|
+
await checkXcode(),
|
|
200
|
+
await checkSimulator(),
|
|
201
|
+
checkXcuitestRunner(),
|
|
202
|
+
adb,
|
|
203
|
+
emulator,
|
|
204
|
+
await checkChromium(),
|
|
205
|
+
checkAuth(),
|
|
206
|
+
checkWorkspace(),
|
|
207
|
+
checkCli(),
|
|
208
|
+
];
|
|
209
|
+
}
|
|
210
|
+
// ── Rendering ───────────────────────────────────────────────────────────────
|
|
211
|
+
const MARK = { pass: "[ OK ]", warn: "[WARN]", fail: "[FAIL]", skip: "[ -- ]" };
|
|
212
|
+
const GROUP_ORDER = ["iOS", "Android", "Web", "Account"];
|
|
213
|
+
const GROUP_LABEL = {
|
|
214
|
+
iOS: "iOS (native simulations)",
|
|
215
|
+
Android: "Android (native simulations)",
|
|
216
|
+
Web: "Web (local browser simulations)",
|
|
217
|
+
Account: "Account",
|
|
218
|
+
};
|
|
219
|
+
// `ish check <platform>` scopes the report to one platform's group + Account,
|
|
220
|
+
// so an Android study never nags about Xcode and an iOS study never about adb.
|
|
221
|
+
const PLATFORM_GROUPS = {
|
|
222
|
+
ios: ["iOS", "Account"],
|
|
223
|
+
android: ["Android", "Account"],
|
|
224
|
+
web: ["Web", "Account"],
|
|
225
|
+
};
|
|
226
|
+
export function scopeChecks(checks, platform) {
|
|
227
|
+
if (!platform)
|
|
228
|
+
return checks;
|
|
229
|
+
const groups = PLATFORM_GROUPS[platform];
|
|
230
|
+
return groups ? checks.filter((c) => groups.includes(c.group)) : checks;
|
|
231
|
+
}
|
|
232
|
+
export function overall(checks) {
|
|
233
|
+
if (checks.some((c) => c.status === "fail"))
|
|
234
|
+
return "fail";
|
|
235
|
+
if (checks.some((c) => c.status === "warn"))
|
|
236
|
+
return "warn";
|
|
237
|
+
return "pass";
|
|
238
|
+
}
|
|
239
|
+
function renderHuman(checks) {
|
|
240
|
+
const lines = ["", "ish check — local & native simulation environment", ""];
|
|
241
|
+
const width = Math.max(...checks.map((c) => c.name.length)) + 2;
|
|
242
|
+
for (const group of GROUP_ORDER) {
|
|
243
|
+
const inGroup = checks.filter((c) => c.group === group);
|
|
244
|
+
if (inGroup.length === 0)
|
|
245
|
+
continue;
|
|
246
|
+
lines.push(` ${GROUP_LABEL[group]}`);
|
|
247
|
+
for (const c of inGroup) {
|
|
248
|
+
const label = c.name.padEnd(width, " ");
|
|
249
|
+
let line = ` ${MARK[c.status]} ${label}${c.message ?? ""}`;
|
|
250
|
+
lines.push(line.trimEnd());
|
|
251
|
+
if ((c.status === "warn" || c.status === "fail") && c.fix) {
|
|
252
|
+
lines.push(` ↳ ${c.fix}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
lines.push("");
|
|
256
|
+
}
|
|
257
|
+
const o = overall(checks);
|
|
258
|
+
const fails = checks.filter((c) => c.status === "fail").length;
|
|
259
|
+
const warns = checks.filter((c) => c.status === "warn").length;
|
|
260
|
+
if (o === "pass") {
|
|
261
|
+
lines.push("Everything checks out.");
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
const parts = [fails ? `${fails} blocking` : "", warns ? `${warns} optional` : ""].filter(Boolean);
|
|
265
|
+
lines.push(`${parts.join(", ")} issue(s). Run \`ish setup\` to fetch what's fetchable.`);
|
|
266
|
+
}
|
|
267
|
+
console.log(lines.join("\n"));
|
|
268
|
+
}
|
|
269
|
+
// ── Commands ──────────────────────────────────────────────────────────────
|
|
270
|
+
export function registerDoctorCommands(program) {
|
|
271
|
+
program
|
|
272
|
+
.command("check [platform]")
|
|
273
|
+
.alias("doctor")
|
|
274
|
+
.description("Check your local simulation setup for a platform (ios | android | web), or everything")
|
|
275
|
+
.action(async (platform, _opts, cmd) => {
|
|
276
|
+
await runInline(cmd, async (globals) => {
|
|
277
|
+
const scope = platform?.toLowerCase();
|
|
278
|
+
if (scope && !PLATFORM_GROUPS[scope]) {
|
|
279
|
+
throw new Error(`Unknown platform "${platform}". Use one of: ios, android, web.`);
|
|
280
|
+
}
|
|
281
|
+
const checks = scopeChecks(await runChecks(), scope);
|
|
282
|
+
const aggregate = overall(checks);
|
|
283
|
+
if (globals.json) {
|
|
284
|
+
output({ checks, overall: aggregate }, true);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
renderHuman(checks);
|
|
288
|
+
}
|
|
289
|
+
// Best-effort: when scoped to a native platform, report readiness to
|
|
290
|
+
// the backend so the web app can render a live native-readiness panel
|
|
291
|
+
// (the native analog of `ish connect` registering a tunnel). Never for
|
|
292
|
+
// the no-arg `ish check` (no single platform) or `web`. Awaited so a
|
|
293
|
+
// short-lived `ish check` doesn't drop the in-flight request before
|
|
294
|
+
// exit — `reportReadiness` is fully best-effort and never throws or
|
|
295
|
+
// blocks meaningfully (5s ceiling).
|
|
296
|
+
if (scope === "ios" || scope === "android") {
|
|
297
|
+
await reportReadiness(scope, checks, aggregate, globals);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
program
|
|
302
|
+
.command("setup")
|
|
303
|
+
.description("Fetch/install local-simulation dependencies (Chromium, iOS runner, adb) and show what's left to do")
|
|
304
|
+
.action(async (_opts, cmd) => {
|
|
305
|
+
await runInline(cmd, async (globals) => {
|
|
306
|
+
const quiet = globals.json;
|
|
307
|
+
const log = (m) => { if (!quiet)
|
|
308
|
+
console.error(m); };
|
|
309
|
+
// 1. Chromium (web local) — auto-fetchable.
|
|
310
|
+
try {
|
|
311
|
+
const { isBrowserInstalled, ensureBrowser } = await import("../lib/local-sim/install.js");
|
|
312
|
+
if (isBrowserInstalled()) {
|
|
313
|
+
log("Chromium: already installed.");
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
log("Chromium: fetching for local web simulations...");
|
|
317
|
+
await ensureBrowser({ quiet, skipPrompt: true });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
log(`Chromium: could not install (${e instanceof Error ? e.message : String(e)}).`);
|
|
322
|
+
}
|
|
323
|
+
// 2. iOS XCUITest runner (WebDriverAgent) — auto-fetchable on macOS.
|
|
324
|
+
if (isMac) {
|
|
325
|
+
try {
|
|
326
|
+
if (wdaBundlePresent()) {
|
|
327
|
+
log("iOS runner: already installed.");
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
log("iOS runner: fetching WebDriverAgent...");
|
|
331
|
+
const { resolveWdaBundle } = await import("../lib/local-sim/xcuitest.js");
|
|
332
|
+
await resolveWdaBundle();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
catch (e) {
|
|
336
|
+
log(`iOS runner: could not fetch (${e instanceof Error ? e.message : String(e)}).`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// 3. Android adb — auto-fetchable (Google's standalone platform-tools).
|
|
340
|
+
try {
|
|
341
|
+
const { ensureAdb } = await import("../lib/local-sim/adb.js");
|
|
342
|
+
await ensureAdb();
|
|
343
|
+
log("Android adb: ready.");
|
|
344
|
+
}
|
|
345
|
+
catch (e) {
|
|
346
|
+
log(`Android adb: could not fetch (${e instanceof Error ? e.message : String(e)}).`);
|
|
347
|
+
}
|
|
348
|
+
// 4. Re-run checks so the user sees the resulting state, and hand back
|
|
349
|
+
// the structured result on --json.
|
|
350
|
+
const checks = await runChecks();
|
|
351
|
+
if (globals.json) {
|
|
352
|
+
output({ checks, overall: overall(checks) }, true);
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
renderHuman(checks);
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
}
|
|
@@ -23,6 +23,15 @@ function readTextOrAtFile(value) {
|
|
|
23
23
|
}
|
|
24
24
|
return value;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Native (ios/android) interactive platforms. For these the real target app is
|
|
28
|
+
* supplied at run time via `ish study run --app <bundle>`, so the iteration's
|
|
29
|
+
* `url` is just a placeholder — we don't force the user to invent one.
|
|
30
|
+
* Mirrors `isNativePlatform` in `lib/local-sim/loop.ts`.
|
|
31
|
+
*/
|
|
32
|
+
function isNativePlatform(platform) {
|
|
33
|
+
return platform === "ios" || platform === "android";
|
|
34
|
+
}
|
|
26
35
|
/**
|
|
27
36
|
* Parse a JSON-blob flag that also supports `@filepath` (read from disk).
|
|
28
37
|
* Used for participant_pair `--role-criteria-a/-b` and any future blob inputs.
|
|
@@ -282,7 +291,7 @@ function buildIterationDetails(modality, opts) {
|
|
|
282
291
|
};
|
|
283
292
|
}
|
|
284
293
|
default:
|
|
285
|
-
if (!opts.url) {
|
|
294
|
+
if (!opts.url && !isNativePlatform(opts.platform)) {
|
|
286
295
|
throw new Error("Interactive iterations require --url. Provide the URL to test.");
|
|
287
296
|
}
|
|
288
297
|
if (opts.platform === "figma" && (!opts.fileKey || !opts.startNodeId)) {
|
|
@@ -296,10 +305,18 @@ function buildIterationDetails(modality, opts) {
|
|
|
296
305
|
}
|
|
297
306
|
screenFormat = normalized;
|
|
298
307
|
}
|
|
308
|
+
// Native (ios/android) names its target via app_artifact (a bundle id /
|
|
309
|
+
// package name, or a local .app/.apk path) and carries no url. We accept
|
|
310
|
+
// the target from --app (preferred) or a --url passed out of habit. When
|
|
311
|
+
// neither is given, app_artifact is omitted = "chosen at run time".
|
|
312
|
+
const isNative = isNativePlatform(opts.platform);
|
|
313
|
+
const nativeTarget = isNative ? (opts.app ?? opts.url)?.trim() : undefined;
|
|
299
314
|
return {
|
|
300
315
|
type: "interactive",
|
|
301
316
|
platform: opts.platform || "browser",
|
|
302
|
-
|
|
317
|
+
...(isNative
|
|
318
|
+
? (nativeTarget ? { app_artifact: nativeTarget } : {})
|
|
319
|
+
: { url: opts.url }),
|
|
303
320
|
screen_format: screenFormat,
|
|
304
321
|
...(opts.locale && { locale: opts.locale }),
|
|
305
322
|
...(opts.fileKey && { file_key: opts.fileKey }),
|
|
@@ -365,8 +382,9 @@ Concept pages: ish docs get-page concepts/iteration
|
|
|
365
382
|
.option("--name <name>", "Iteration name (defaults to the next position letter A/B/C… if omitted)")
|
|
366
383
|
.option("--description <description>", "Iteration description")
|
|
367
384
|
// Interactive
|
|
368
|
-
.option("--platform <platform>", "Platform (browser, android, figma, code) — interactive only")
|
|
369
|
-
.option("--url <url>", "URL to test — interactive only")
|
|
385
|
+
.option("--platform <platform>", "Platform (browser, android, ios, figma, code) — interactive only")
|
|
386
|
+
.option("--url <url>", "URL to test — interactive only (optional for ios/android native apps)")
|
|
387
|
+
.option("--app <id>", "Native app bundle id (or .app/.apk path) — ios/android; supplies the iteration target so --url isn't required")
|
|
370
388
|
.option("--screen-format <format>", "Screen format (mobile_portrait, desktop) — interactive only; hyphen/underscore variants accepted")
|
|
371
389
|
.option("--locale <locale>", "Locale code (e.g. en-US) — interactive only")
|
|
372
390
|
.option("--file-key <key>", "Figma file key — required when --platform=figma")
|
|
@@ -596,7 +614,7 @@ Next: \`ish study run\` to dispatch simulations against this iteration.`)
|
|
|
596
614
|
break;
|
|
597
615
|
}
|
|
598
616
|
default:
|
|
599
|
-
if (!opts.url) {
|
|
617
|
+
if (!opts.url && !isNativePlatform(opts.platform)) {
|
|
600
618
|
throw new Error("Interactive iterations require --url. Provide the URL to test.");
|
|
601
619
|
}
|
|
602
620
|
}
|
|
@@ -109,7 +109,7 @@ Tips:
|
|
|
109
109
|
.option("--study <id>", "Study ID; resolves to the latest iteration on that study (alternative to --iteration)")
|
|
110
110
|
.requiredOption("--person <id>", "Participant profile ID")
|
|
111
111
|
.option("--language <lang>", "Language code (e.g. en, sv)")
|
|
112
|
-
.option("--platform <platform>", "Platform (browser, android, figma, code)")
|
|
112
|
+
.option("--platform <platform>", "Platform (browser, android, ios, figma, code)")
|
|
113
113
|
.option("--participant-type <type>", "Participant type (ai, human)", "ai")
|
|
114
114
|
.addHelpText("after", "\nExamples:\n $ ish study participant create --iteration <id> --person <id>\n $ ish study participant create --study s-XXX --person p-XXX\n $ ish study participant create --iteration <id> --person <id> --platform android --json")
|
|
115
115
|
.action(async (opts, cmd) => {
|
|
@@ -24,6 +24,8 @@ import { isMediaModality, isChatModality, iterationHasContent, describeRequiredC
|
|
|
24
24
|
// just `study run --local`. The bun-compiled binary bundles the deep
|
|
25
25
|
// path so it doesn't hit Node's resolver; only the npm path is sensitive.
|
|
26
26
|
import { estimateChatPair, estimateChatSolo, estimateMediaRun } from "../lib/billing.js";
|
|
27
|
+
import { reportReadiness } from "../lib/report-readiness.js";
|
|
28
|
+
import { runChecks, scopeChecks, overall } from "./doctor.js";
|
|
27
29
|
function parseMaxInteractions(value) {
|
|
28
30
|
const n = parseInt(value, 10);
|
|
29
31
|
if (isNaN(n) || n < 1)
|
|
@@ -262,6 +264,7 @@ function readIterationDetails(details) {
|
|
|
262
264
|
...(screenFormat && { screenFormat }),
|
|
263
265
|
...(typeof details.locale === "string" && { locale: details.locale }),
|
|
264
266
|
...(typeof details.title === "string" && { title: details.title }),
|
|
267
|
+
...(typeof details.app_artifact === "string" && { appArtifact: details.app_artifact }),
|
|
265
268
|
};
|
|
266
269
|
}
|
|
267
270
|
/**
|
|
@@ -760,6 +763,24 @@ Examples:
|
|
|
760
763
|
?? platformFromApp
|
|
761
764
|
?? detailsView.platform
|
|
762
765
|
?? "browser";
|
|
766
|
+
// Best-effort native-readiness report. When this is a LOCAL native run
|
|
767
|
+
// (iOS/Android driven on this developer's machine), fire-and-forget a
|
|
768
|
+
// fresh, platform-scoped `runChecks()` to the backend so the web app
|
|
769
|
+
// can render a live native-readiness panel — the native analog of how
|
|
770
|
+
// `ish connect` registers a tunnel. Fully decoupled from the run:
|
|
771
|
+
// never awaited (must not delay dispatch), never blocks, never throws.
|
|
772
|
+
// Browser local runs and all remote runs are skipped (remote runs use
|
|
773
|
+
// Browserbase, so this machine's device readiness is irrelevant there).
|
|
774
|
+
if (opts.local && (resolvedPlatform === "ios" || resolvedPlatform === "android")) {
|
|
775
|
+
const readinessPlatform = resolvedPlatform;
|
|
776
|
+
void (async () => {
|
|
777
|
+
const scoped = scopeChecks(await runChecks(), readinessPlatform);
|
|
778
|
+
await reportReadiness(readinessPlatform, scoped, overall(scoped), globals, { debug: !!opts.debug });
|
|
779
|
+
})().catch(() => {
|
|
780
|
+
// Belt-and-suspenders: reportReadiness already swallows everything,
|
|
781
|
+
// but runChecks() / scopeChecks() must not bubble either.
|
|
782
|
+
});
|
|
783
|
+
}
|
|
763
784
|
if (isPair && pairConfig) {
|
|
764
785
|
// Pair-mode flow mirrors the MCP (`ish-mcp` `_run_pair_mode`):
|
|
765
786
|
// 1. If the iteration already carries `conversations[]` from a
|
|
@@ -884,7 +905,11 @@ Examples:
|
|
|
884
905
|
debug: opts.debug,
|
|
885
906
|
parallel: opts.parallel ? parseInt(opts.parallel, 10) : undefined,
|
|
886
907
|
platform: resolvedPlatform,
|
|
887
|
-
|
|
908
|
+
// Native target: --app overrides; otherwise default from the
|
|
909
|
+
// iteration's remembered app_artifact so reruns need no --app.
|
|
910
|
+
...((opts.app ?? detailsView.appArtifact) && {
|
|
911
|
+
appPath: opts.app ?? detailsView.appArtifact,
|
|
912
|
+
}),
|
|
888
913
|
quiet: globals.quiet,
|
|
889
914
|
json: globals.json,
|
|
890
915
|
});
|
|
@@ -21,6 +21,36 @@ import { dirname, extname, join } from "node:path";
|
|
|
21
21
|
import { withClient, resolveStudy } from "../lib/command-helpers.js";
|
|
22
22
|
import { resolveId } from "../lib/alias-store.js";
|
|
23
23
|
import { output, printTable } from "../lib/output.js";
|
|
24
|
+
import { ApiError } from "../lib/api-client.js";
|
|
25
|
+
/**
|
|
26
|
+
* Server-side screenshots are produced by remote interactive runs only. A
|
|
27
|
+
* study whose only runs were local (`ish study run --local`) has none — and the
|
|
28
|
+
* grouped endpoint currently 500s instead of returning an empty index. Tag this
|
|
29
|
+
* hint onto the error so the bare 500 points the user at the local debug report.
|
|
30
|
+
*/
|
|
31
|
+
const LOCAL_RUN_SCREENSHOT_HINT = [
|
|
32
|
+
"Screenshots are produced by remote runs only.",
|
|
33
|
+
"Ran this study locally (--local)? The per-step screenshots are in the HTML debug report under ~/.ish/debug/ (path printed at the end of each local run).",
|
|
34
|
+
];
|
|
35
|
+
/**
|
|
36
|
+
* GET the frame-grouped screenshot index, tagging the local-run hint onto any
|
|
37
|
+
* ApiError before it propagates to `outputError` (which merges `.suggestions`).
|
|
38
|
+
*/
|
|
39
|
+
async function getGroupedScreenshots(client, studyId) {
|
|
40
|
+
try {
|
|
41
|
+
return await client.get(`/studies/${studyId}/screenshots/grouped`);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
if (err instanceof ApiError) {
|
|
45
|
+
const tagged = err;
|
|
46
|
+
tagged.suggestions = [
|
|
47
|
+
...(Array.isArray(tagged.suggestions) ? tagged.suggestions : []),
|
|
48
|
+
...LOCAL_RUN_SCREENSHOT_HINT,
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
24
54
|
function projectScreenshot(s) {
|
|
25
55
|
return {
|
|
26
56
|
id: s.id,
|
|
@@ -106,9 +136,12 @@ Examples:
|
|
|
106
136
|
$ ish study screenshots download <study-id> --id <scid> --out shot.png
|
|
107
137
|
$ ish study screenshots download <study-id> --all --out ./shots/
|
|
108
138
|
|
|
109
|
-
Screenshots are produced server-side by interactive runs only — chat /
|
|
110
|
-
text studies don't have them
|
|
111
|
-
|
|
139
|
+
Screenshots are produced server-side by remote interactive runs only — chat /
|
|
140
|
+
video / text studies don't have them, and neither do local runs
|
|
141
|
+
(\`ish study run --local\`), which instead write a per-step HTML debug report to
|
|
142
|
+
~/.ish/debug/ (the path is printed at the end of each local run). Each row's
|
|
143
|
+
storage URL is self-credentialed, so the CLI fetches bytes without forwarding
|
|
144
|
+
your bearer.`);
|
|
112
145
|
screenshots
|
|
113
146
|
.command("list", { isDefault: true })
|
|
114
147
|
.description("List screenshots for a study (frame-grouped).")
|
|
@@ -117,7 +150,7 @@ so the CLI fetches bytes without forwarding your bearer.`);
|
|
|
117
150
|
.action(async (id, _opts, cmd) => {
|
|
118
151
|
await withClient(cmd, async (client, globals) => {
|
|
119
152
|
const studyId = resolveStudy(id);
|
|
120
|
-
const raw = await client
|
|
153
|
+
const raw = await getGroupedScreenshots(client, studyId);
|
|
121
154
|
const listing = projectListing(studyId, raw);
|
|
122
155
|
if (globals.json) {
|
|
123
156
|
output(listing, true, { preProjected: true });
|
|
@@ -172,7 +205,7 @@ so the CLI fetches bytes without forwarding your bearer.`);
|
|
|
172
205
|
}
|
|
173
206
|
// --all: walk the index, fetch each row, save under --out dir.
|
|
174
207
|
const outDir = opts.out ?? "./screenshots";
|
|
175
|
-
const grouped = await client
|
|
208
|
+
const grouped = await getGroupedScreenshots(client, studyId);
|
|
176
209
|
const all = [
|
|
177
210
|
...(grouped.groups ?? []).flatMap((g) => g.screenshots ?? []),
|
|
178
211
|
...(grouped.uncategorized ?? []),
|
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")
|
package/dist/lib/api-client.d.ts
CHANGED
|
@@ -40,6 +40,7 @@ export declare class ApiClient {
|
|
|
40
40
|
iteration_id: string;
|
|
41
41
|
}): Promise<import("./local-sim/types.js").LocalSimInitResponse>;
|
|
42
42
|
localSimStep(body: import("./local-sim/types.js").LocalSimStepRequest): Promise<import("./local-sim/types.js").LocalSimStepResponseRaw>;
|
|
43
|
+
localSimRecordInteraction(body: import("./local-sim/types.js").LocalSimInteractionRequest): Promise<import("./local-sim/types.js").LocalSimInteractionResponse>;
|
|
43
44
|
localSimRecord(body: import("./local-sim/types.js").LocalSimRecordRequest): Promise<import("./local-sim/types.js").LocalSimRecordResponse>;
|
|
44
45
|
localSimMatchFrame(body: {
|
|
45
46
|
product_id: string;
|
|
@@ -50,6 +51,8 @@ export declare class ApiClient {
|
|
|
50
51
|
screen_format?: string;
|
|
51
52
|
full_page_screenshot_base64?: string;
|
|
52
53
|
platform?: string;
|
|
54
|
+
previous_frame_version_id?: string;
|
|
55
|
+
same_screen_continuation?: boolean;
|
|
53
56
|
}): Promise<{
|
|
54
57
|
frame_version_id: string;
|
|
55
58
|
}>;
|
package/dist/lib/api-client.js
CHANGED
|
@@ -260,8 +260,13 @@ export class ApiClient {
|
|
|
260
260
|
async localSimStep(body) {
|
|
261
261
|
return this.post("/simulation/local/step", body, { timeout: 60_000 });
|
|
262
262
|
}
|
|
263
|
+
async localSimRecordInteraction(body) {
|
|
264
|
+
return this.post("/simulation/local/interaction", body, { timeout: 30_000 });
|
|
265
|
+
}
|
|
263
266
|
async localSimRecord(body) {
|
|
264
|
-
|
|
267
|
+
// Finalize now runs the pre/post survey + participant summary inline
|
|
268
|
+
// server-side, so it can take materially longer than a plain DB write.
|
|
269
|
+
return this.post("/simulation/local/record", body, { timeout: 180_000 });
|
|
265
270
|
}
|
|
266
271
|
async localSimMatchFrame(body) {
|
|
267
272
|
return this.post("/simulation/local/match-frame", body, { timeout: 30_000 });
|