@ishlabs/cli 0.27.0 → 0.27.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.
@@ -31,8 +31,19 @@ const WDA_BUNDLE_ID = "com.facebook.WebDriverAgentRunner.xctrunner";
31
31
  const DEFAULT_PORT = Number(process.env.ISH_WDA_PORT) || 8100;
32
32
  /** WDA's XCTest runtime cold-starts slowly; poll /status up to this long. */
33
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
34
+ /**
35
+ * The Return keystroke we feed to WDA's `/wda/keys` for a submit.
36
+ *
37
+ * NOT the W3C ENTER code (U+E007): WDA's `/wda/keys` does NOT interpret the
38
+ * WebDriver special-key PUA codepoints \u2014 it types them LITERALLY. On the
39
+ * simulator U+E007 renders as a running-shoe emoji, so a submit appended a
40
+ * stray `\uD83D\uDC5F` to the field (e.g. a search for `Photos` became `Photos\uD83D\uDC5F`,
41
+ * returning no results and derailing the run) AND never actually submitted.
42
+ * A plain newline is what WDA's keyboard treats as Return \u2014 it submits
43
+ * single-line fields and inserts a line break in multiline ones, with no glyph.
44
+ * Verified on a booted iOS 18 simulator (Settings search).
45
+ */
46
+ export const WDA_RETURN = "\n";
36
47
  const sessions = new Map();
37
48
  // ── WDA bundle resolution (fetch is wired in the distribution phase) ──────────
38
49
  /** Appium's prebuilt WebDriverAgent simulator release we fetch + pin. */
@@ -178,7 +189,10 @@ export async function ensureWda(udid, opts = {}) {
178
189
  const existing = sessions.get(udid);
179
190
  if (existing && (await statusOk(existing.port)))
180
191
  return existing;
181
- const port = DEFAULT_PORT;
192
+ // Each device gets its own WDA runner on its own port so N pooled simulators
193
+ // don't collide on 8100. The pool allocates the port and passes it in; the
194
+ // single-device path falls back to DEFAULT_PORT (8100 / $ISH_WDA_PORT).
195
+ const port = opts.port ?? DEFAULT_PORT;
182
196
  if (!(await statusOk(port))) {
183
197
  const app = await resolveWdaBundle();
184
198
  await simctlRun(["install", udid, app]);
@@ -211,7 +225,9 @@ async function getSession(udid) {
211
225
  const s = sessions.get(udid);
212
226
  if (s && (await statusOk(s.port)))
213
227
  return s;
214
- return ensureWda(udid);
228
+ // Re-ensure on the SAME port if this device had one (a WDA restart mid-run
229
+ // must not migrate a pooled device back to DEFAULT_PORT).
230
+ return ensureWda(udid, s ? { port: s.port } : {});
215
231
  }
216
232
  /** Tear down the WDA session for `udid` (the runner is left for the next run). */
217
233
  export async function closeWda(udid) {
@@ -307,13 +323,13 @@ export async function uiText(udid, text) {
307
323
  }
308
324
  /**
309
325
  * Press a key. Only the idb HID Return keycode (40) is used by ios.ts today;
310
- * map it to W3C ENTER. Unknown codes are a no-op-safe error.
326
+ * map it to a newline (see WDA_RETURN). Unknown codes are a no-op-safe error.
311
327
  */
312
328
  export async function uiKey(udid, keycode) {
313
329
  if (keycode !== 40)
314
330
  throw new IosError(`unsupported WDA keycode: ${keycode}`);
315
331
  const s = await getSession(udid);
316
- await wdaCall(s.port, "POST", `/session/${s.sessionId}/wda/keys`, { value: [W3C_ENTER] });
332
+ await wdaCall(s.port, "POST", `/session/${s.sessionId}/wda/keys`, { value: [WDA_RETURN] });
317
333
  }
318
334
  /** Re-export so a future ios.ts can drop the simctl HID constant. */
319
335
  export const HID_KEY_RETURN = 40;
@@ -27,3 +27,4 @@ export declare function wdaVersionFile(): string;
27
27
  */
28
28
  export declare function adbBin(): string;
29
29
  export declare function connectLockPath(): string;
30
+ export declare function devicePoolLockPath(): string;
package/dist/lib/paths.js CHANGED
@@ -58,3 +58,6 @@ export function adbBin() {
58
58
  export function connectLockPath() {
59
59
  return path.join(rootDir(), "connect.lock");
60
60
  }
61
+ export function devicePoolLockPath() {
62
+ return path.join(rootDir(), "device-pool.lock");
63
+ }
@@ -230,8 +230,11 @@ 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
+ - **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). \`screen_format\` defaults to **mobile_portrait** for native (vs desktop for browser). 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\`. Full walkthrough: \`ish docs get-page guides/native-app\`.
234
+ - **Native runs reset state per participant only with a local .app**: with a local \`.app\`/\`.apk\` the runner uninstall+reinstalls before each participant (no state leak). A bare bundle-id / system app (e.g. \`com.apple.reminders\`) can't be reinstalled it relaunches and warns once that earlier-participant state may persist; pass \`--app <.app>\` or run one participant per study for a clean start.
235
+ - **Parallel native runs**: \`ish study run --local --platform ios|android --parallel N\` drives a **pool of N devices** (iOS: reuses booted simulators + auto-creates the shortfall; Android: reuses online emulators + auto-launches headless emulators from your AVDs), one participant per device, and tears down only what it started. N auto-sizes to host RAM; default 1, max 5 — small machines run fewer + queue, never error. Android needs just **one AVD** (the pool clones it via file-copy — no JDK — and deletes the clones). Browser \`--parallel\` is unchanged.
236
+ - **Local runs still capture per-interaction screenshots**: \`ish study run --local\` (including ios/android) does NOT populate the remote frame-grouped index (\`ish study screenshots list\` reads that and won't show local frames), but per-interaction screenshots ARE captured — read them via \`ish study get <id>\` (each interaction carries \`screenshot_url\`) or the per-step HTML debug report at \`~/.ish/debug/sim-*.html\` (path printed at the end of the run).
237
+ - **\`<entity> use --json\` is capturable**: \`study use\`/\`workspace use\`/\`ask use\` print the human confirmation to stderr and an \`{id, alias, name, active}\` object to stdout under \`--json\`, so \`ish study use s-… --json --get alias\` works.
235
238
 
236
239
  ## When in doubt
237
240
 
@@ -16,6 +16,14 @@ export declare function validateFile(filePath: string): Promise<{
16
16
  size: number;
17
17
  mime: string;
18
18
  }>;
19
+ /**
20
+ * Upload raw bytes to Supabase Storage via the backend's signed URL flow.
21
+ * Returns the public content_url. The bytes-level core shared by file uploads
22
+ * and HTML image archiving (no temp file needed).
23
+ */
24
+ export declare function uploadStudyContentBytes(client: ApiClient, studyId: string, data: Buffer, mime: string, name: string, opts?: {
25
+ quiet?: boolean;
26
+ }): Promise<string>;
19
27
  /**
20
28
  * Upload a local file to Supabase Storage via the backend's signed URL flow.
21
29
  * Returns the public content_url for use in iteration details.
@@ -40,6 +48,25 @@ export declare function resolveContentUrls(client: ApiClient, studyId: string, c
40
48
  mimeTypeOverride?: string;
41
49
  quiet?: boolean;
42
50
  }): Promise<string[]>;
51
+ /**
52
+ * Archive every external `<img>` in a text iteration's `content_html` onto the
53
+ * workspace storage origin, rewriting each `src` to the uploaded URL.
54
+ *
55
+ * Why: the render-to-image worker that lets a participant SEE the page
56
+ * default-denies egress to every origin except workspace storage (SSRF guard).
57
+ * An `<img>` pointing at the open web is therefore aborted and renders broken.
58
+ * The frontend's paste pipeline (`cacheHtmlAssets`) already archives images;
59
+ * this is the CLI-side equivalent so `ish iteration create --content-html`
60
+ * produces content whose images actually render. It is a focused subset (just
61
+ * `<img src>` via regex — the CLI has no HTML-parser dependency); inline
62
+ * `data:` images, relative paths, and non-image responses are left untouched.
63
+ *
64
+ * Best-effort: a single image that cannot be fetched/uploaded is left as-is
65
+ * with a warning rather than failing the whole create.
66
+ */
67
+ export declare function archiveHtmlImages(client: ApiClient, studyId: string, html: string, opts?: {
68
+ quiet?: boolean;
69
+ }): Promise<string>;
43
70
  /**
44
71
  * Resolve text content. If the value starts with '@', read the file at
45
72
  * the path that follows (curl-style convention). Otherwise return as-is.
@@ -96,29 +96,30 @@ export async function validateFile(filePath) {
96
96
  // Core upload: 3-step backend-mediated flow
97
97
  // ---------------------------------------------------------------------------
98
98
  /**
99
- * Upload a local file to Supabase Storage via the backend's signed URL flow.
100
- * Returns the public content_url for use in iteration details.
99
+ * Upload raw bytes to Supabase Storage via the backend's signed URL flow.
100
+ * Returns the public content_url. The bytes-level core shared by file uploads
101
+ * and HTML image archiving (no temp file needed).
101
102
  */
102
- export async function uploadStudyContent(client, studyId, filePath, opts) {
103
- const resolved = resolvePath(filePath);
104
- const { size, mime: detectedMime } = await validateFile(filePath);
105
- const mime = opts?.mimeTypeOverride || detectedMime;
106
- const name = basename(filePath);
103
+ export async function uploadStudyContentBytes(client, studyId, data, mime, name, opts) {
104
+ const size = data.byteLength;
107
105
  const sizeMB = (size / (1024 * 1024)).toFixed(1);
108
106
  const log = (msg) => { if (!opts?.quiet)
109
107
  process.stderr.write(msg); };
110
108
  // Step 1: Request a signed upload URL from the backend
111
109
  log(`Uploading ${name} (${sizeMB} MB)...`);
112
110
  const uploadResp = await client.post(`/studies/${studyId}/content/upload`, { content_type: mime, file_size_bytes: size }, { timeout: 60_000 });
113
- // Step 2: PUT the raw file bytes to the signed URL
114
- const fileBuffer = await readFile(resolved);
111
+ // Step 2: PUT the raw bytes to the signed URL
115
112
  const putResp = await fetch(uploadResp.upload_info.signed_upload_url, {
116
113
  method: "PUT",
117
114
  headers: {
118
115
  "Content-Type": mime,
119
- "Content-Length": String(fileBuffer.byteLength),
116
+ "Content-Length": String(size),
120
117
  },
121
- body: fileBuffer,
118
+ // Zero-copy view as Uint8Array<ArrayBuffer>: a Node Buffer's generic
119
+ // ArrayBufferLike (which includes SharedArrayBuffer) is not a valid fetch
120
+ // BodyInit, but a plain-ArrayBuffer-backed view is. Node buffers are never
121
+ // SharedArrayBuffer-backed, so the cast is safe.
122
+ body: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
122
123
  signal: AbortSignal.timeout(300_000), // 5 min timeout for large files
123
124
  });
124
125
  if (!putResp.ok) {
@@ -133,6 +134,17 @@ export async function uploadStudyContent(client, studyId, filePath, opts) {
133
134
  log(" done.\n");
134
135
  return uploadResp.content_url;
135
136
  }
137
+ /**
138
+ * Upload a local file to Supabase Storage via the backend's signed URL flow.
139
+ * Returns the public content_url for use in iteration details.
140
+ */
141
+ export async function uploadStudyContent(client, studyId, filePath, opts) {
142
+ const resolved = resolvePath(filePath);
143
+ const { mime: detectedMime } = await validateFile(filePath);
144
+ const mime = opts?.mimeTypeOverride || detectedMime;
145
+ const fileBuffer = await readFile(resolved);
146
+ return uploadStudyContentBytes(client, studyId, fileBuffer, mime, basename(filePath), { quiet: opts?.quiet });
147
+ }
136
148
  // ---------------------------------------------------------------------------
137
149
  // High-level resolvers (URL passthrough or upload)
138
150
  // ---------------------------------------------------------------------------
@@ -157,6 +169,91 @@ export async function resolveContentUrls(client, studyId, commaSeparated, opts)
157
169
  }
158
170
  return results;
159
171
  }
172
+ // ---------------------------------------------------------------------------
173
+ // HTML image archiving (text modality)
174
+ // ---------------------------------------------------------------------------
175
+ const MAX_ARCHIVED_IMAGE_BYTES = 15 * 1024 * 1024; // 15 MB per image
176
+ const IMAGE_FETCH_TIMEOUT_MS = 15_000;
177
+ /**
178
+ * Archive every external `<img>` in a text iteration's `content_html` onto the
179
+ * workspace storage origin, rewriting each `src` to the uploaded URL.
180
+ *
181
+ * Why: the render-to-image worker that lets a participant SEE the page
182
+ * default-denies egress to every origin except workspace storage (SSRF guard).
183
+ * An `<img>` pointing at the open web is therefore aborted and renders broken.
184
+ * The frontend's paste pipeline (`cacheHtmlAssets`) already archives images;
185
+ * this is the CLI-side equivalent so `ish iteration create --content-html`
186
+ * produces content whose images actually render. It is a focused subset (just
187
+ * `<img src>` via regex — the CLI has no HTML-parser dependency); inline
188
+ * `data:` images, relative paths, and non-image responses are left untouched.
189
+ *
190
+ * Best-effort: a single image that cannot be fetched/uploaded is left as-is
191
+ * with a warning rather than failing the whole create.
192
+ */
193
+ export async function archiveHtmlImages(client, studyId, html, opts) {
194
+ const log = (msg) => { if (!opts?.quiet)
195
+ process.stderr.write(msg); };
196
+ const imgTags = html.match(/<img\b[^>]*>/gi);
197
+ if (!imgTags)
198
+ return html;
199
+ let result = html;
200
+ let archived = 0;
201
+ let failed = 0;
202
+ const seen = new Map(); // original src -> archived url
203
+ for (const tag of imgTags) {
204
+ const srcMatch = tag.match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)')/i);
205
+ if (!srcMatch)
206
+ continue;
207
+ const src = srcMatch[2] ?? srcMatch[3] ?? "";
208
+ // Only archive remote http(s) images; leave data:/blob:/relative as-is.
209
+ if (!/^https?:\/\//i.test(src))
210
+ continue;
211
+ let archivedUrl = seen.get(src);
212
+ if (archivedUrl === undefined) {
213
+ try {
214
+ archivedUrl = await fetchAndUploadImage(client, studyId, src, archived, opts);
215
+ seen.set(src, archivedUrl);
216
+ archived += 1;
217
+ }
218
+ catch (e) {
219
+ failed += 1;
220
+ const reason = e instanceof Error ? e.message : String(e);
221
+ log(` ! could not archive image ${src}: ${reason} (it will not render)\n`);
222
+ continue;
223
+ }
224
+ }
225
+ // Rewrite this tag: point src at the archived URL and drop srcset so the
226
+ // browser uses the archived src, not an un-archived srcset candidate.
227
+ let newTag = tag.replace(srcMatch[0], `src="${archivedUrl}"`);
228
+ newTag = newTag.replace(/\s+srcset\s*=\s*("[^"]*"|'[^']*')/gi, "");
229
+ if (newTag !== tag)
230
+ result = result.replace(tag, newTag);
231
+ }
232
+ if (archived > 0 || failed > 0) {
233
+ log(`Archived ${archived} image(s) to workspace storage${failed ? `, ${failed} failed` : ""}.\n`);
234
+ }
235
+ return result;
236
+ }
237
+ async function fetchAndUploadImage(client, studyId, url, index, opts) {
238
+ const resp = await fetch(url, {
239
+ signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS),
240
+ redirect: "follow",
241
+ });
242
+ if (!resp.ok)
243
+ throw new Error(`HTTP ${resp.status}`);
244
+ const ctype = (resp.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
245
+ if (!ctype.startsWith("image/")) {
246
+ throw new Error(`not an image (content-type: ${ctype || "unknown"})`);
247
+ }
248
+ const bytes = Buffer.from(await resp.arrayBuffer());
249
+ if (bytes.byteLength === 0)
250
+ throw new Error("empty response");
251
+ if (bytes.byteLength > MAX_ARCHIVED_IMAGE_BYTES) {
252
+ throw new Error(`image too large (${(bytes.byteLength / (1024 * 1024)).toFixed(1)} MB)`);
253
+ }
254
+ const ext = ctype.split("/")[1]?.split("+")[0] || "img";
255
+ return uploadStudyContentBytes(client, studyId, bytes, ctype, `imported-image-${index}.${ext}`, { quiet: opts?.quiet });
256
+ }
160
257
  /**
161
258
  * Resolve text content. If the value starts with '@', read the file at
162
259
  * the path that follows (curl-style convention). Otherwise return as-is.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ishlabs/cli",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
4
4
  "description": "The command-line interface for ish",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,7 +47,7 @@
47
47
  "@sentry/node": "^10.13.0",
48
48
  "commander": "^13.0.0",
49
49
  "http-proxy": "^1.18.1",
50
- "playwright-core": "^1.58.2"
50
+ "playwright-core": "1.59.1"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/http-proxy": "^1.17.17",