@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,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side WebDriverAgent (XCUITest) client — the iOS UI-interaction + a11y
|
|
3
|
+
* layer that replaces idb. Device LIFECYCLE (install target app, screenshot,
|
|
4
|
+
* launch/terminate target) stays on `xcrun simctl` in `simctl.ts`; this module
|
|
5
|
+
* owns ONLY the WDA surface.
|
|
6
|
+
*
|
|
7
|
+
* Mechanism (validated on the simulator, no xcodebuild): a PREBUILT WDA runner
|
|
8
|
+
* (Appium's `WebDriverAgentRunner-Runner.app`, bundle id
|
|
9
|
+
* `com.facebook.WebDriverAgentRunner.xctrunner`) is `simctl install`-ed and
|
|
10
|
+
* `simctl launch`-ed with `SIMCTL_CHILD_USE_PORT`; the xctrunner self-hosts the
|
|
11
|
+
* XCTest runtime and serves W3C WebDriver on `localhost:<port>`. We then drive
|
|
12
|
+
* taps/swipes/text via `/session/:id/actions` and read the a11y tree via
|
|
13
|
+
* `/session/:id/source?format=json` (parsed by `parseXcuiHierarchy`).
|
|
14
|
+
*
|
|
15
|
+
* API mirrors the idb functions in `simctl.ts` (udid-keyed, points space) so
|
|
16
|
+
* `ios.ts` swaps the import with a near-identical call surface. Per-udid session
|
|
17
|
+
* state (port + WDA sessionId) is held in a module map; `ensureWda` is idempotent
|
|
18
|
+
* and reuses an already-running runner.
|
|
19
|
+
*/
|
|
20
|
+
import { execFile } from "node:child_process";
|
|
21
|
+
import { promisify } from "node:util";
|
|
22
|
+
import { existsSync, readdirSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { wdaDir, wdaVersionFile } from "../paths.js";
|
|
25
|
+
import { IosError } from "./simctl.js";
|
|
26
|
+
const execFileAsync = promisify(execFile);
|
|
27
|
+
const XCRUN = "/usr/bin/xcrun";
|
|
28
|
+
/** Bundle id of Appium's prebuilt WDA xctrunner (self-hosts the XCTest runtime). */
|
|
29
|
+
const WDA_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
|
|
30
|
+
/** Default WDA port; override with ISH_WDA_PORT. One device → one runner. */
|
|
31
|
+
const DEFAULT_PORT = Number(process.env.ISH_WDA_PORT) || 8100;
|
|
32
|
+
/** WDA's XCTest runtime cold-starts slowly; poll /status up to this long. */
|
|
33
|
+
const STARTUP_TIMEOUT_MS = 75_000;
|
|
34
|
+
/** W3C ENTER key (maps the idb HID Return keycode 40 used by ios.ts). */
|
|
35
|
+
const W3C_ENTER = "\uE007"; // idb HID Return (40) -> W3C ENTER
|
|
36
|
+
const sessions = new Map();
|
|
37
|
+
// ── WDA bundle resolution (fetch is wired in the distribution phase) ──────────
|
|
38
|
+
/** Appium's prebuilt WebDriverAgent simulator release we fetch + pin. */
|
|
39
|
+
const WDA_PINNED_TAG = "v13.2.0";
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the prebuilt WDA `.app`, downloading it on first use:
|
|
42
|
+
* `ISH_WDA_PATH` override → `~/.ish/bin/wda/` cache → fetch Appium's prebuilt
|
|
43
|
+
* WebDriverAgent-for-simulator from its GitHub release. Mirrors
|
|
44
|
+
* `connect.ts:resolveCloudflaredBin` (which fetches cloudflared the same way).
|
|
45
|
+
*/
|
|
46
|
+
export async function resolveWdaBundle() {
|
|
47
|
+
const override = process.env.ISH_WDA_PATH;
|
|
48
|
+
if (override) {
|
|
49
|
+
if (!existsSync(override)) {
|
|
50
|
+
throw new IosError(`ISH_WDA_PATH does not exist: ${override}`);
|
|
51
|
+
}
|
|
52
|
+
return override.endsWith(".app") ? override : findApp(override);
|
|
53
|
+
}
|
|
54
|
+
const dir = wdaDir();
|
|
55
|
+
if (existsSync(dir)) {
|
|
56
|
+
try {
|
|
57
|
+
return findApp(dir);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// cache dir exists but holds no .app yet → (re)download
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return downloadWdaBundle();
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Fetch Appium's prebuilt WDA-for-simulator zip (just the xctrunner `.app`,
|
|
67
|
+
* which self-hosts the XCTest runtime — no xcodebuild) and unpack it into
|
|
68
|
+
* `~/.ish/bin/wda/`. arch follows the host (arm64 sims on Apple Silicon, x86_64
|
|
69
|
+
* on Intel). Logs to stderr so a run shows the one-time fetch.
|
|
70
|
+
*/
|
|
71
|
+
async function downloadWdaBundle() {
|
|
72
|
+
const arch = process.arch === "arm64" ? "arm64" : "x86_64";
|
|
73
|
+
const asset = `WebDriverAgentRunner-Build-Sim-${arch}.zip`;
|
|
74
|
+
const url = `https://github.com/appium/WebDriverAgent/releases/download/${WDA_PINNED_TAG}/${asset}`;
|
|
75
|
+
const dir = wdaDir();
|
|
76
|
+
console.error(`Fetching the iOS automation runner (WebDriverAgent ${WDA_PINNED_TAG}, ${arch})...`);
|
|
77
|
+
mkdirSync(dir, { recursive: true });
|
|
78
|
+
const zipPath = path.join(dir, "wda.zip");
|
|
79
|
+
let resp;
|
|
80
|
+
try {
|
|
81
|
+
resp = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
throw new IosError(`failed to download WebDriverAgent from ${url}: ${e instanceof Error ? e.message : String(e)}`);
|
|
85
|
+
}
|
|
86
|
+
if (!resp.ok) {
|
|
87
|
+
throw new IosError(`failed to download WebDriverAgent: HTTP ${resp.status} from ${url}`);
|
|
88
|
+
}
|
|
89
|
+
writeFileSync(zipPath, Buffer.from(await resp.arrayBuffer()));
|
|
90
|
+
try {
|
|
91
|
+
await execFileAsync("/usr/bin/unzip", ["-o", "-q", zipPath, "-d", dir], { timeout: 60_000 });
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
throw new IosError(`failed to unpack WebDriverAgent: ${e instanceof Error ? e.message : String(e)}`);
|
|
95
|
+
}
|
|
96
|
+
rmSync(zipPath, { force: true });
|
|
97
|
+
writeFileSync(wdaVersionFile(), `${WDA_PINNED_TAG} ${arch}\n`);
|
|
98
|
+
return findApp(dir);
|
|
99
|
+
}
|
|
100
|
+
/** First `*.app` directly under `dir` (or `dir` itself if it is one). */
|
|
101
|
+
function findApp(dir) {
|
|
102
|
+
if (dir.endsWith(".app") && existsSync(dir))
|
|
103
|
+
return dir;
|
|
104
|
+
try {
|
|
105
|
+
const hit = readdirSync(dir).find((e) => e.endsWith(".app"));
|
|
106
|
+
if (hit)
|
|
107
|
+
return path.join(dir, hit);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* fallthrough */
|
|
111
|
+
}
|
|
112
|
+
throw new IosError(`No WebDriverAgentRunner-Runner.app found under ${dir}`);
|
|
113
|
+
}
|
|
114
|
+
// ── HTTP to the runner ────────────────────────────────────────────────────
|
|
115
|
+
async function wdaCall(port, method, route, body) {
|
|
116
|
+
const url = `http://localhost:${port}${route}`;
|
|
117
|
+
let resp;
|
|
118
|
+
try {
|
|
119
|
+
resp = await fetch(url, {
|
|
120
|
+
method,
|
|
121
|
+
headers: body ? { "Content-Type": "application/json" } : undefined,
|
|
122
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
123
|
+
signal: AbortSignal.timeout(30_000),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
throw new IosError(`WDA ${method} ${route} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
128
|
+
}
|
|
129
|
+
const text = await resp.text();
|
|
130
|
+
let json;
|
|
131
|
+
try {
|
|
132
|
+
json = text ? JSON.parse(text) : {};
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
throw new IosError(`WDA ${method} ${route} returned non-JSON (HTTP ${resp.status})`);
|
|
136
|
+
}
|
|
137
|
+
if (!resp.ok) {
|
|
138
|
+
const detail = json?.value?.message ?? text.slice(0, 200);
|
|
139
|
+
throw new IosError(`WDA ${method} ${route} -> HTTP ${resp.status}: ${detail}`);
|
|
140
|
+
}
|
|
141
|
+
return json;
|
|
142
|
+
}
|
|
143
|
+
function unwrap(json) {
|
|
144
|
+
return json && typeof json === "object" && "value" in json
|
|
145
|
+
? json.value
|
|
146
|
+
: json;
|
|
147
|
+
}
|
|
148
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
149
|
+
async function simctlRun(args, timeoutMs = 180_000) {
|
|
150
|
+
try {
|
|
151
|
+
const { stdout } = await execFileAsync(XCRUN, ["simctl", ...args], {
|
|
152
|
+
timeout: timeoutMs,
|
|
153
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
154
|
+
});
|
|
155
|
+
return stdout;
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
throw new IosError(`xcrun simctl ${args[0]} failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
162
|
+
async function statusOk(port) {
|
|
163
|
+
try {
|
|
164
|
+
const resp = await fetch(`http://localhost:${port}/status`, { signal: AbortSignal.timeout(2500) });
|
|
165
|
+
return resp.ok;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Ensure a WDA runner is up for `udid` and a session exists, idempotently. If a
|
|
173
|
+
* runner already answers on the port (a prior `ensureWda`, or an externally
|
|
174
|
+
* launched one), it is reused — only a fresh session is created. Otherwise the
|
|
175
|
+
* prebuilt runner is installed and `simctl launch`-ed.
|
|
176
|
+
*/
|
|
177
|
+
export async function ensureWda(udid, opts = {}) {
|
|
178
|
+
const existing = sessions.get(udid);
|
|
179
|
+
if (existing && (await statusOk(existing.port)))
|
|
180
|
+
return existing;
|
|
181
|
+
const port = DEFAULT_PORT;
|
|
182
|
+
if (!(await statusOk(port))) {
|
|
183
|
+
const app = await resolveWdaBundle();
|
|
184
|
+
await simctlRun(["install", udid, app]);
|
|
185
|
+
// SIMCTL_CHILD_* env passes through to the launched process; USE_PORT tells
|
|
186
|
+
// WDA which port to bind. --terminate-running-process clears a stale runner.
|
|
187
|
+
await execFileAsync(XCRUN, ["simctl", "launch", "--terminate-running-process", udid, WDA_BUNDLE_ID], { timeout: 30_000, env: { ...process.env, SIMCTL_CHILD_USE_PORT: String(port) } }).catch((e) => {
|
|
188
|
+
throw new IosError(`failed to launch WDA runner: ${e instanceof Error ? e.message : String(e)}`);
|
|
189
|
+
});
|
|
190
|
+
const deadline = Date.now() + STARTUP_TIMEOUT_MS;
|
|
191
|
+
while (!(await statusOk(port))) {
|
|
192
|
+
if (Date.now() > deadline) {
|
|
193
|
+
throw new IosError(`WDA runner did not start within ${STARTUP_TIMEOUT_MS / 1000}s`);
|
|
194
|
+
}
|
|
195
|
+
await sleep(1000);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const baseUrl = `http://localhost:${port}`;
|
|
199
|
+
const caps = opts.bundleId
|
|
200
|
+
? { capabilities: { alwaysMatch: { bundleId: opts.bundleId } } }
|
|
201
|
+
: { capabilities: { alwaysMatch: {} } };
|
|
202
|
+
const resp = (await wdaCall(port, "POST", "/session", caps));
|
|
203
|
+
const sessionId = resp.sessionId ?? unwrap(resp)?.sessionId;
|
|
204
|
+
if (!sessionId)
|
|
205
|
+
throw new IosError("WDA did not return a sessionId");
|
|
206
|
+
const session = { port, baseUrl, sessionId };
|
|
207
|
+
sessions.set(udid, session);
|
|
208
|
+
return session;
|
|
209
|
+
}
|
|
210
|
+
async function getSession(udid) {
|
|
211
|
+
const s = sessions.get(udid);
|
|
212
|
+
if (s && (await statusOk(s.port)))
|
|
213
|
+
return s;
|
|
214
|
+
return ensureWda(udid);
|
|
215
|
+
}
|
|
216
|
+
/** Tear down the WDA session for `udid` (the runner is left for the next run). */
|
|
217
|
+
export async function closeWda(udid) {
|
|
218
|
+
const s = sessions.get(udid);
|
|
219
|
+
sessions.delete(udid);
|
|
220
|
+
if (!s)
|
|
221
|
+
return;
|
|
222
|
+
try {
|
|
223
|
+
await wdaCall(s.port, "DELETE", `/session/${s.sessionId}`);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
/* best-effort teardown */
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// ── Geometry + a11y ─────────────────────────────────────────────────────────
|
|
230
|
+
/** Screen geometry from WDA `/wda/screen` (points + retina scale). */
|
|
231
|
+
export async function describeScreen(udid) {
|
|
232
|
+
const s = await getSession(udid);
|
|
233
|
+
const v = unwrap(await wdaCall(s.port, "GET", "/wda/screen"));
|
|
234
|
+
const pointWidth = Number(v.screenSize?.width) || 0;
|
|
235
|
+
const pointHeight = Number(v.screenSize?.height) || 0;
|
|
236
|
+
const density = Number(v.scale) || 1;
|
|
237
|
+
if (!pointWidth || !pointHeight)
|
|
238
|
+
throw new IosError("WDA /wda/screen returned no screen size");
|
|
239
|
+
return {
|
|
240
|
+
pointWidth,
|
|
241
|
+
pointHeight,
|
|
242
|
+
density,
|
|
243
|
+
pixelWidth: Math.round(pointWidth * density),
|
|
244
|
+
pixelHeight: Math.round(pointHeight * density),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/** Raw WDA `/source?format=json` string — feed to `parseXcuiHierarchy`. */
|
|
248
|
+
export async function describeAll(udid) {
|
|
249
|
+
const s = await getSession(udid);
|
|
250
|
+
const json = await wdaCall(s.port, "GET", `/session/${s.sessionId}/source?format=json`);
|
|
251
|
+
return JSON.stringify(json);
|
|
252
|
+
}
|
|
253
|
+
// ── Gestures (W3C pointer actions; coordinates in POINTS) ────────────────────
|
|
254
|
+
function pointerAction(steps) {
|
|
255
|
+
return {
|
|
256
|
+
actions: [{ type: "pointer", id: "finger1", parameters: { pointerType: "touch" }, actions: steps }],
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
async function performActions(udid, steps) {
|
|
260
|
+
const s = await getSession(udid);
|
|
261
|
+
await wdaCall(s.port, "POST", `/session/${s.sessionId}/actions`, pointerAction(steps));
|
|
262
|
+
}
|
|
263
|
+
export async function uiTap(udid, x, y) {
|
|
264
|
+
await performActions(udid, [
|
|
265
|
+
{ type: "pointerMove", duration: 0, x, y },
|
|
266
|
+
{ type: "pointerDown", button: 0 },
|
|
267
|
+
{ type: "pause", duration: 60 },
|
|
268
|
+
{ type: "pointerUp", button: 0 },
|
|
269
|
+
]);
|
|
270
|
+
}
|
|
271
|
+
export async function uiLongPress(udid, x, y, durationMs = 600) {
|
|
272
|
+
await performActions(udid, [
|
|
273
|
+
{ type: "pointerMove", duration: 0, x, y },
|
|
274
|
+
{ type: "pointerDown", button: 0 },
|
|
275
|
+
{ type: "pause", duration: durationMs },
|
|
276
|
+
{ type: "pointerUp", button: 0 },
|
|
277
|
+
]);
|
|
278
|
+
}
|
|
279
|
+
export async function uiSwipe(udid, x1, y1, x2, y2, durationMs = 300) {
|
|
280
|
+
await performActions(udid, [
|
|
281
|
+
{ type: "pointerMove", duration: 0, x: x1, y: y1 },
|
|
282
|
+
{ type: "pointerDown", button: 0 },
|
|
283
|
+
{ type: "pointerMove", duration: durationMs, x: x2, y: y2 },
|
|
284
|
+
{ type: "pointerUp", button: 0 },
|
|
285
|
+
]);
|
|
286
|
+
}
|
|
287
|
+
/** Type into the focused element (the caller taps a field first). */
|
|
288
|
+
export async function uiText(udid, text) {
|
|
289
|
+
const s = await getSession(udid);
|
|
290
|
+
await wdaCall(s.port, "POST", `/session/${s.sessionId}/wda/keys`, { value: [...text] });
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Press a key. Only the idb HID Return keycode (40) is used by ios.ts today;
|
|
294
|
+
* map it to W3C ENTER. Unknown codes are a no-op-safe error.
|
|
295
|
+
*/
|
|
296
|
+
export async function uiKey(udid, keycode) {
|
|
297
|
+
if (keycode !== 40)
|
|
298
|
+
throw new IosError(`unsupported WDA keycode: ${keycode}`);
|
|
299
|
+
const s = await getSession(udid);
|
|
300
|
+
await wdaCall(s.port, "POST", `/session/${s.sessionId}/wda/keys`, { value: [W3C_ENTER] });
|
|
301
|
+
}
|
|
302
|
+
/** Re-export so a future ios.ts can drop the simctl HID constant. */
|
|
303
|
+
export const HID_KEY_RETURN = 40;
|
package/dist/lib/paths.d.ts
CHANGED
|
@@ -12,4 +12,18 @@ export declare function binDir(): string;
|
|
|
12
12
|
export declare function browsersDir(): string;
|
|
13
13
|
export declare function simulationsDir(): string;
|
|
14
14
|
export declare function cloudflaredBin(): string;
|
|
15
|
+
/**
|
|
16
|
+
* Cache dir for the prebuilt iOS XCUITest runner (WebDriverAgent) bundle —
|
|
17
|
+
* the `.app` + `.xctestrun` fetched on demand for native iOS simulations,
|
|
18
|
+
* mirroring how `cloudflaredBin()` is fetched into `binDir()`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function wdaDir(): string;
|
|
21
|
+
/** Stamp file recording which CLI/runner version the cached WDA bundle is for. */
|
|
22
|
+
export declare function wdaVersionFile(): string;
|
|
23
|
+
/**
|
|
24
|
+
* Path to `adb` inside Google's standalone Android platform-tools, when we've
|
|
25
|
+
* fetched it into `binDir()` on demand (the zip unpacks a `platform-tools/`
|
|
26
|
+
* dir). Mirrors `cloudflaredBin()` / `wdaDir()`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function adbBin(): string;
|
|
15
29
|
export declare function connectLockPath(): string;
|
package/dist/lib/paths.js
CHANGED
|
@@ -34,6 +34,27 @@ export function cloudflaredBin() {
|
|
|
34
34
|
const exe = process.platform === "win32" ? "cloudflared.exe" : "cloudflared";
|
|
35
35
|
return path.join(binDir(), exe);
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Cache dir for the prebuilt iOS XCUITest runner (WebDriverAgent) bundle —
|
|
39
|
+
* the `.app` + `.xctestrun` fetched on demand for native iOS simulations,
|
|
40
|
+
* mirroring how `cloudflaredBin()` is fetched into `binDir()`.
|
|
41
|
+
*/
|
|
42
|
+
export function wdaDir() {
|
|
43
|
+
return path.join(binDir(), "wda");
|
|
44
|
+
}
|
|
45
|
+
/** Stamp file recording which CLI/runner version the cached WDA bundle is for. */
|
|
46
|
+
export function wdaVersionFile() {
|
|
47
|
+
return path.join(wdaDir(), "VERSION");
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Path to `adb` inside Google's standalone Android platform-tools, when we've
|
|
51
|
+
* fetched it into `binDir()` on demand (the zip unpacks a `platform-tools/`
|
|
52
|
+
* dir). Mirrors `cloudflaredBin()` / `wdaDir()`.
|
|
53
|
+
*/
|
|
54
|
+
export function adbBin() {
|
|
55
|
+
const exe = process.platform === "win32" ? "adb.exe" : "adb";
|
|
56
|
+
return path.join(binDir(), "platform-tools", exe);
|
|
57
|
+
}
|
|
37
58
|
export function connectLockPath() {
|
|
38
59
|
return path.join(rootDir(), "connect.lock");
|
|
39
60
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort readiness reporting to the ish backend.
|
|
3
|
+
*
|
|
4
|
+
* After the CLI computes its native-simulation readiness checks (the same
|
|
5
|
+
* array `ish check <platform> --json` emits), we POST them to
|
|
6
|
+
* `${apiUrl}/api/v1/connect/device` so the ish web app can render a live
|
|
7
|
+
* native-readiness panel. This is the native analog of how `ish connect
|
|
8
|
+
* <port>` registers a cloudflare tunnel: a side-channel that tells the
|
|
9
|
+
* backend "this developer's machine is (or isn't) ready to drive a native
|
|
10
|
+
* iOS/Android simulation right now."
|
|
11
|
+
*
|
|
12
|
+
* Contract (must match the backend exactly):
|
|
13
|
+
*
|
|
14
|
+
* POST /api/v1/connect/device
|
|
15
|
+
* {
|
|
16
|
+
* "platform": "ios" | "android",
|
|
17
|
+
* "checks": [ {key,name,group,status,message?,fix?}, ... ],
|
|
18
|
+
* "overall": "pass" | "warn" | "fail" | "skip",
|
|
19
|
+
* "cli_version": "<package.json version>"
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* This is COMPLETELY best-effort. It never throws, never blocks the command,
|
|
23
|
+
* never writes to stdout, and silently returns when the user isn't logged in
|
|
24
|
+
* or is offline. The CLI's normal output and exit code are unaffected. The
|
|
25
|
+
* only side-channel is an optional stderr line under a `debug` flag, mirroring
|
|
26
|
+
* how connect.ts logs its own warnings.
|
|
27
|
+
*/
|
|
28
|
+
import type { GlobalOpts } from "./command-helpers.js";
|
|
29
|
+
import type { Check, CheckStatus } from "../commands/doctor.js";
|
|
30
|
+
/**
|
|
31
|
+
* POST the platform-scoped readiness checks to the backend. Best-effort:
|
|
32
|
+
* resolves auth + posts inside a single try/catch, swallowing every error.
|
|
33
|
+
*
|
|
34
|
+
* @param platform The native platform the checks were scoped to.
|
|
35
|
+
* @param checks The exact `runChecks()` array (already scoped to `platform`).
|
|
36
|
+
* @param overall The aggregate status (`overall(checks)`).
|
|
37
|
+
* @param globals Resolved CLI globals — used only for `--api-url` / `--dev`
|
|
38
|
+
* / `--token` resolution. Pass the command's `globals`.
|
|
39
|
+
* @param opts.debug When true, log a one-line failure note to stderr (off by
|
|
40
|
+
* default so the post is silent). Mirrors connect.ts.
|
|
41
|
+
*/
|
|
42
|
+
export declare function reportReadiness(platform: "ios" | "android", checks: Check[], overall: CheckStatus, globals: Pick<GlobalOpts, "apiUrl" | "dev" | "token" | "tokenFile">, opts?: {
|
|
43
|
+
debug?: boolean;
|
|
44
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort readiness reporting to the ish backend.
|
|
3
|
+
*
|
|
4
|
+
* After the CLI computes its native-simulation readiness checks (the same
|
|
5
|
+
* array `ish check <platform> --json` emits), we POST them to
|
|
6
|
+
* `${apiUrl}/api/v1/connect/device` so the ish web app can render a live
|
|
7
|
+
* native-readiness panel. This is the native analog of how `ish connect
|
|
8
|
+
* <port>` registers a cloudflare tunnel: a side-channel that tells the
|
|
9
|
+
* backend "this developer's machine is (or isn't) ready to drive a native
|
|
10
|
+
* iOS/Android simulation right now."
|
|
11
|
+
*
|
|
12
|
+
* Contract (must match the backend exactly):
|
|
13
|
+
*
|
|
14
|
+
* POST /api/v1/connect/device
|
|
15
|
+
* {
|
|
16
|
+
* "platform": "ios" | "android",
|
|
17
|
+
* "checks": [ {key,name,group,status,message?,fix?}, ... ],
|
|
18
|
+
* "overall": "pass" | "warn" | "fail" | "skip",
|
|
19
|
+
* "cli_version": "<package.json version>"
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* This is COMPLETELY best-effort. It never throws, never blocks the command,
|
|
23
|
+
* never writes to stdout, and silently returns when the user isn't logged in
|
|
24
|
+
* or is offline. The CLI's normal output and exit code are unaffected. The
|
|
25
|
+
* only side-channel is an optional stderr line under a `debug` flag, mirroring
|
|
26
|
+
* how connect.ts logs its own warnings.
|
|
27
|
+
*/
|
|
28
|
+
import { resolveApiUrl, resolveToken } from "./auth.js";
|
|
29
|
+
import { ApiClient } from "./api-client.js";
|
|
30
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
31
|
+
/** Backend endpoint (relative to the `/api/v1` base ApiClient prepends). */
|
|
32
|
+
const DEVICE_ENDPOINT = "/connect/device";
|
|
33
|
+
/**
|
|
34
|
+
* POST the platform-scoped readiness checks to the backend. Best-effort:
|
|
35
|
+
* resolves auth + posts inside a single try/catch, swallowing every error.
|
|
36
|
+
*
|
|
37
|
+
* @param platform The native platform the checks were scoped to.
|
|
38
|
+
* @param checks The exact `runChecks()` array (already scoped to `platform`).
|
|
39
|
+
* @param overall The aggregate status (`overall(checks)`).
|
|
40
|
+
* @param globals Resolved CLI globals — used only for `--api-url` / `--dev`
|
|
41
|
+
* / `--token` resolution. Pass the command's `globals`.
|
|
42
|
+
* @param opts.debug When true, log a one-line failure note to stderr (off by
|
|
43
|
+
* default so the post is silent). Mirrors connect.ts.
|
|
44
|
+
*/
|
|
45
|
+
export async function reportReadiness(platform, checks, overall, globals, opts = {}) {
|
|
46
|
+
try {
|
|
47
|
+
const apiUrl = resolveApiUrl(globals.apiUrl, globals.dev);
|
|
48
|
+
// resolveToken throws when the user isn't logged in (or a network blip
|
|
49
|
+
// prevents an expired-token refresh) — that throw is caught below and we
|
|
50
|
+
// silently return, exactly as required for the offline / logged-out case.
|
|
51
|
+
const token = await resolveToken(globals.token, apiUrl, globals.tokenFile);
|
|
52
|
+
const client = new ApiClient({ apiUrl, token });
|
|
53
|
+
await client.post(DEVICE_ENDPOINT, {
|
|
54
|
+
platform,
|
|
55
|
+
checks,
|
|
56
|
+
overall,
|
|
57
|
+
cli_version: pkg.version,
|
|
58
|
+
},
|
|
59
|
+
// Tight timeout: `ish check` awaits this so the beacon lands before the
|
|
60
|
+
// process exits, but the panel POST must never stall the command. 2.5s
|
|
61
|
+
// is plenty on a healthy backend and bounds the worst case if the host
|
|
62
|
+
// is unreachable or hung.
|
|
63
|
+
{ timeout: 2_500 });
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
// Swallow everything. A logged-out user, an offline machine, an old
|
|
67
|
+
// backend without the endpoint, a 5xx — none of it should ever surface
|
|
68
|
+
// to the user or change the command's behavior.
|
|
69
|
+
if (opts.debug) {
|
|
70
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
71
|
+
console.error(`(readiness report skipped: ${reason})`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -230,6 +230,8 @@ To hand a study to someone **without an ish account** — a prospect, a stakehol
|
|
|
230
230
|
- **\`ish person create\` accepts inline flags** (mirrors \`person update\`): the file-only API (\`--file <path>\`) is preserved as an escape hatch but the common path is \`ish person create --name "X" --type ai --country US ...\` — \`--type\` defaults to \`ai\` when \`--file\` is omitted. See \`ish person create --help\` for the full inline-flag set including \`--household\` (MECE rule applies) and \`--accessibility-profile\`.
|
|
231
231
|
- **\`ish status\` now surfaces \`chat_endpoint\`** alongside \`workspace\`/\`study\`/\`ask\`. Stale or orphan active refs get a \`warning\` + \`hint\` field on the affected ref (instead of silently dropping the \`name\`). On \`workspace use <other>\`, the CLI cascade-clears \`study\`/\`ask\`/\`chat_endpoint\` (they belong to the previous workspace).
|
|
232
232
|
- **Share link URL host ≠ API host**: \`ish study share\` prints the backend-built \`share_url\` (the web frontend host). Use it verbatim — never reconstruct the URL from the API host or app URL; they differ. \`ish study unshare\` takes the **raw token** (from \`study share\` / \`study share --list\`), not a study id or alias.
|
|
233
|
+
- **Native app iterations (ios/android) name the app, not a URL**: \`ish iteration create --platform ios --app <bundle-id>\` stores the target as \`app_artifact\` (no URL). The iteration remembers it, so \`ish study run --local\` needs **no \`--app\` on reruns** (it defaults from the iteration). Pass \`--app <path-to.app|.apk>\` only to override with a fresh local build. \`--app\` is optional at create time (omit it for "chosen at run time"). Only \`browser\`/\`figma\` iterations require \`--url\`.
|
|
234
|
+
- **Local runs have no server-side screenshots**: \`ish study run --local\` (including ios/android) writes a per-step HTML debug report to \`~/.ish/debug/sim-*.html\` (path printed at the end of the run) instead of pushing screenshots to the server. \`ish study screenshots list\` on a local-only study finds none — open the debug report instead.
|
|
233
235
|
|
|
234
236
|
## When in doubt
|
|
235
237
|
|