@ishlabs/cli 0.26.1 → 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.
- package/README.md +4 -0
- package/dist/commands/doctor.js +21 -11
- package/dist/commands/iteration.js +13 -4
- package/dist/commands/study-run.js +12 -12
- package/dist/commands/study-screenshots.js +15 -12
- package/dist/commands/study.js +22 -3
- package/dist/lib/api-client.d.ts +1 -0
- package/dist/lib/docs.js +139 -7
- package/dist/lib/local-sim/adb.d.ts +35 -2
- package/dist/lib/local-sim/adb.js +107 -14
- package/dist/lib/local-sim/android.d.ts +5 -3
- package/dist/lib/local-sim/android.js +29 -11
- package/dist/lib/local-sim/device-pool.d.ts +85 -0
- package/dist/lib/local-sim/device-pool.js +316 -0
- package/dist/lib/local-sim/device.d.ts +29 -0
- package/dist/lib/local-sim/device.js +19 -1
- package/dist/lib/local-sim/emulator.d.ts +50 -0
- package/dist/lib/local-sim/emulator.js +189 -0
- package/dist/lib/local-sim/install.js +23 -3
- package/dist/lib/local-sim/ios.d.ts +31 -5
- package/dist/lib/local-sim/ios.js +80 -21
- package/dist/lib/local-sim/loop.js +199 -9
- package/dist/lib/local-sim/native-a11y.d.ts +24 -0
- package/dist/lib/local-sim/native-a11y.js +76 -14
- package/dist/lib/local-sim/screen-signature.d.ts +77 -0
- package/dist/lib/local-sim/screen-signature.js +170 -0
- package/dist/lib/local-sim/simctl-provision.d.ts +49 -0
- package/dist/lib/local-sim/simctl-provision.js +89 -0
- package/dist/lib/local-sim/simctl.d.ts +6 -4
- package/dist/lib/local-sim/simctl.js +18 -5
- package/dist/lib/local-sim/xcuitest.d.ts +22 -1
- package/dist/lib/local-sim/xcuitest.js +38 -6
- package/dist/lib/modality.js +7 -2
- package/dist/lib/paths.d.ts +1 -0
- package/dist/lib/paths.js +3 -0
- package/dist/lib/skill-content.js +5 -2
- package/dist/lib/upload.d.ts +27 -0
- package/dist/lib/upload.js +108 -11
- package/package.json +2 -2
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native "screen signature" v2 — a SCROLL-INVARIANT structural identity for a
|
|
3
|
+
* logical native screen, derived from the accessibility tree, sent to the
|
|
4
|
+
* backend as an entry/cross-run frame anchor (Phase 2 of native frame
|
|
5
|
+
* continuity; Phase 1 reuses the prior frame on pure scroll/keyboard steps).
|
|
6
|
+
*
|
|
7
|
+
* FCIS: this module is PURE (NativeNode[] + coarse inputs in, signature out) —
|
|
8
|
+
* no device access. The device gathers the coarse inputs (foreground activity /
|
|
9
|
+
* bundle id) and the parsed tree; this turns them into `{value, usable}`.
|
|
10
|
+
*
|
|
11
|
+
* The signature has two parts:
|
|
12
|
+
* coarse — a cheap, almost-always-available anchor (android `package|activity`,
|
|
13
|
+
* ios `bundleId|navTitle`).
|
|
14
|
+
* tokens — the persistent CHROME tokens that are NOT scroll content. Each
|
|
15
|
+
* chrome node contributes its resource-id (`id:…`) AND its label
|
|
16
|
+
* (`tx:…`) when present. This is what makes the signature
|
|
17
|
+
* scroll-invariant AND lets two same-activity screens be told apart.
|
|
18
|
+
*
|
|
19
|
+
* WHY v2 (two verified gaps in the id-only v1):
|
|
20
|
+
* 1. LABELS close the shared-chrome OVER-MERGE. A single-Activity app — Jetpack
|
|
21
|
+
* Compose (exposes NO resource-ids beyond the framework `android:id/content`)
|
|
22
|
+
* or a View app with a fixed toolbar+container shared across fragments —
|
|
23
|
+
* gives two DISTINCT screens the SAME id-set → identical signature → SILENT
|
|
24
|
+
* over-merge (the cardinal failure). But those screens DO differ in chrome
|
|
25
|
+
* LABELS (a home screen vs a settings sub-screen show different toolbar /
|
|
26
|
+
* button text). Including labels makes distinct screens produce distinct
|
|
27
|
+
* signatures, and makes Compose usable at all (label-only tokens).
|
|
28
|
+
* 2. STRUCTURAL scroll-exclusion replaces v1's geometric `contains()`. v1
|
|
29
|
+
* excluded scroll content by bounds-containment, which (a) mis-flagged an
|
|
30
|
+
* overlay/FAB sitting inside a list's rect as content (→ could over-merge),
|
|
31
|
+
* and (b) on iOS the scroll CONTAINER is isAccessible=0 and pruned from the
|
|
32
|
+
* NativeNode[], so geometric exclusion never fired (scroll changed the
|
|
33
|
+
* signature → over-split, feature inert). v2 excludes by TREE STRUCTURE: a
|
|
34
|
+
* node is content iff `insideScrollable` (it has a scrollable ANCESTOR),
|
|
35
|
+
* computed during parsing — see native-a11y.ts. The scroll container's OWN
|
|
36
|
+
* tokens are kept (it's durable chrome; `insideScrollable` is about
|
|
37
|
+
* descendants).
|
|
38
|
+
*
|
|
39
|
+
* The remaining failure mode after v2 is SAFE: dynamic chrome labels (a live
|
|
40
|
+
* clock, an unread badge) cause OVER-SPLIT (a new frame), never over-merge — the
|
|
41
|
+
* backend just mints a fresh frame, which is the conservative direction.
|
|
42
|
+
*
|
|
43
|
+
* USABLE GUARD (load-bearing, unchanged in spirit): `usable` is true only with
|
|
44
|
+
* >= MIN_STABLE_TOKENS tokens. A signature derived from an empty/sparse token
|
|
45
|
+
* set must NEVER be sent — sha1("") (and any near-empty set) collides across
|
|
46
|
+
* distinct screens and would silently over-merge them. When unusable the caller
|
|
47
|
+
* omits the field entirely and the backend falls back to Phase-1 continuity.
|
|
48
|
+
* This is the SAFE default: Flutter (no a11y tree) and the sparsest screens
|
|
49
|
+
* degrade here; id-rich Android and label-rich Compose are the validated wins.
|
|
50
|
+
*/
|
|
51
|
+
import { createHash } from "node:crypto";
|
|
52
|
+
/** Minimum stable-chrome tokens for a signature to be usable (sent to the backend). */
|
|
53
|
+
export const MIN_STABLE_TOKENS = 2;
|
|
54
|
+
/**
|
|
55
|
+
* App-bar / title chrome resource-ids. A node whose resource-id matches carries
|
|
56
|
+
* the SCREEN TITLE (e.g. a CollapsingToolbar's title, an ActionBar/Toolbar title).
|
|
57
|
+
* On modern Android the title sits INSIDE the scrollable app-bar (CoordinatorLayout
|
|
58
|
+
* / NestedScrollView), so structural scroll-exclusion drops it — collapsing every
|
|
59
|
+
* sub-screen of a single host activity (e.g. Android Settings' `.SubSettings`) to
|
|
60
|
+
* the SAME generic outer-container ids and silently OVER-MERGING distinct screens
|
|
61
|
+
* (proven live: Display/Apps/Network all → one frame). We rescue the title LABEL
|
|
62
|
+
* even when `insideScrollable`: the title text is the screen's most reliable
|
|
63
|
+
* discriminator and is scroll-INVARIANT (it stays constant as the bar collapses).
|
|
64
|
+
*
|
|
65
|
+
* WHY no `_title$` here (removed): a trailing-`_title$` rescue was originally added
|
|
66
|
+
* to also catch Android `homepage_title`, but (a) `homepage_title` is the VOLATILE
|
|
67
|
+
* home big-title we deliberately do NOT want to rescue, and (b) `_title$`
|
|
68
|
+
* OVER-MATCHES iOS list-row section ids that are pure SCROLL CONTENT —
|
|
69
|
+
* `MOTION_TITLE`, `SPEECH_TITLE`, `LIVE_SPEECH_TITLE`, `VOCAL_SHORTCUTS_TITLE`
|
|
70
|
+
* (role=text, insideScrollable=true). Rescuing those re-includes scroll-content row
|
|
71
|
+
* labels in the signature, so scrolling reveals different rows and CHURNS the
|
|
72
|
+
* signature → over-fragment (iOS Settings/Accessibility split into two frames). The
|
|
73
|
+
* SubSettings over-merge discriminator rides on the explicitly-matched
|
|
74
|
+
* `collapsing_toolbar` / `action_bar` / `toolbar`, NOT on an arbitrary `_title$`.
|
|
75
|
+
*/
|
|
76
|
+
const TITLE_CHROME_ID = /(collapsing_toolbar|action_bar|toolbar)$/;
|
|
77
|
+
function isTitleChrome(resourceId) {
|
|
78
|
+
return resourceId !== "" && TITLE_CHROME_ID.test(resourceId.toLowerCase());
|
|
79
|
+
}
|
|
80
|
+
/** Max length of a label token's value — bounds the hashed string on chatty labels. */
|
|
81
|
+
const LABEL_TOKEN_MAX_LENGTH = 64;
|
|
82
|
+
/**
|
|
83
|
+
* Normalized role of the iOS navigation bar after `normalizeRole` in
|
|
84
|
+
* native-a11y.ts (`AXNavigationBar`/`NavigationBar` → "navigationbar"). The
|
|
85
|
+
* spike matched the raw "NavigationBar" role; the CLI's NativeNode carries the
|
|
86
|
+
* normalized role, so we match the normalized form here.
|
|
87
|
+
*/
|
|
88
|
+
const IOS_NAV_BAR_ROLE = "navigationbar";
|
|
89
|
+
function sha1(s) {
|
|
90
|
+
return createHash("sha1").update(s).digest("hex");
|
|
91
|
+
}
|
|
92
|
+
/** First nav-bar node's label (iOS), else "" — best-effort coarse signal. */
|
|
93
|
+
function iosNavTitle(nodes) {
|
|
94
|
+
const nav = nodes.find((n) => n.role === IOS_NAV_BAR_ROLE);
|
|
95
|
+
return nav ? nav.label.trim() : "";
|
|
96
|
+
}
|
|
97
|
+
/** The coarse, almost-always-available anchor token. */
|
|
98
|
+
function coarseToken(nodes, coarse) {
|
|
99
|
+
if (coarse.platform === "android") {
|
|
100
|
+
return `${coarse.package ?? ""}|${coarse.activity ?? ""}`;
|
|
101
|
+
}
|
|
102
|
+
return `${coarse.bundleId ?? ""}|${iosNavTitle(nodes)}`;
|
|
103
|
+
}
|
|
104
|
+
/** Collapse whitespace, lowercase, and cap length so a label token is stable. */
|
|
105
|
+
function normalizeLabelToken(label) {
|
|
106
|
+
const collapsed = label.replace(/\s+/g, " ").trim().toLowerCase();
|
|
107
|
+
return collapsed.length <= LABEL_TOKEN_MAX_LENGTH
|
|
108
|
+
? collapsed
|
|
109
|
+
: collapsed.slice(0, LABEL_TOKEN_MAX_LENGTH);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Sorted, de-duped token set of the persistent CHROME — every node that is NOT
|
|
113
|
+
* scroll content (`!insideScrollable`). Each such node contributes:
|
|
114
|
+
* - `id:<resourceId>` when its resource-id is non-empty, and
|
|
115
|
+
* - `tx:<label>` when its label is non-empty (normalized).
|
|
116
|
+
* A scroll CONTAINER's own tokens ARE kept (the container is durable chrome;
|
|
117
|
+
* `insideScrollable` flags its descendants, not the container). Labels are the
|
|
118
|
+
* v2 win: they discriminate single-Activity screens that share a chrome id-set
|
|
119
|
+
* (and give Compose, which exposes no ids, a usable signature at all).
|
|
120
|
+
*/
|
|
121
|
+
function stableTokenSet(nodes) {
|
|
122
|
+
const out = new Set();
|
|
123
|
+
for (const n of nodes) {
|
|
124
|
+
const id = (n.resourceId ?? "").trim();
|
|
125
|
+
const label = n.label.trim();
|
|
126
|
+
// App-bar / title chrome carries the screen's name but sits inside the
|
|
127
|
+
// scrollable app-bar on modern Android — rescue its scroll-invariant LABEL
|
|
128
|
+
// even when insideScrollable (its generic container id is NOT added; the
|
|
129
|
+
// label is the discriminator). See TITLE_CHROME_ID.
|
|
130
|
+
if (label && isTitleChrome(id))
|
|
131
|
+
out.add(`tx:${normalizeLabelToken(label)}`);
|
|
132
|
+
if (n.insideScrollable)
|
|
133
|
+
continue; // scroll content — shifts under a scroll
|
|
134
|
+
// Suppress the `id:` token for a self-named / generic nav control. On iOS,
|
|
135
|
+
// parseXcuiHierarchy promotes the WDA `name` to resourceId, but WDA reports a
|
|
136
|
+
// NON-DETERMINISTIC name for the nav back button — "BackButton" when fresh,
|
|
137
|
+
// the parent screen's title (e.g. "Settings") once scrolled — while its
|
|
138
|
+
// visible label stays constant. That churn flips the `id:` token between
|
|
139
|
+
// scroll states and fragments the same screen. Drop the id when it's the
|
|
140
|
+
// literal "BackButton" OR is redundant with the node's own label (id ===
|
|
141
|
+
// normalized label): in both cases the `id:` carries no identity beyond the
|
|
142
|
+
// already-emitted `tx:` label. We KEEP the `tx:` token below.
|
|
143
|
+
const redundantNavId = id !== "" &&
|
|
144
|
+
(id === "BackButton" ||
|
|
145
|
+
(label !== "" && normalizeLabelToken(id) === normalizeLabelToken(label)));
|
|
146
|
+
if (id && !redundantNavId)
|
|
147
|
+
out.add(`id:${id}`);
|
|
148
|
+
if (label)
|
|
149
|
+
out.add(`tx:${normalizeLabelToken(label)}`);
|
|
150
|
+
}
|
|
151
|
+
return [...out].sort();
|
|
152
|
+
}
|
|
153
|
+
// EXPERIMENT: native-consensus-rescue (REVERTED) — a stateful consensus-rescue layer on top of
|
|
154
|
+
// this single-compute signature nets only -2 frag/220 but adds 8 real over-merges. Canonical
|
|
155
|
+
// record: ish-backend docs/experiments/native-consensus-rescue/README.md (stub in docs/experiments/
|
|
156
|
+
// here). Don't re-litigate stateful identity without reading it.
|
|
157
|
+
/**
|
|
158
|
+
* Compute the screen signature from this step's parsed tree + coarse inputs.
|
|
159
|
+
* `value` is `platform|coarse|sha1(tokens)`; `usable` gates whether it's safe to
|
|
160
|
+
* send (>= MIN_STABLE_TOKENS distinct stable chrome tokens).
|
|
161
|
+
*/
|
|
162
|
+
export function computeScreenSignature(nodes, coarse) {
|
|
163
|
+
const tokens = stableTokenSet(nodes);
|
|
164
|
+
const value = `${coarse.platform}|${coarseToken(nodes, coarse)}|${sha1(tokens.join(","))}`;
|
|
165
|
+
return {
|
|
166
|
+
value,
|
|
167
|
+
usable: tokens.length >= MIN_STABLE_TOKENS,
|
|
168
|
+
tokenCount: tokens.length,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simulator provisioning primitives (boot / clone / shutdown / delete) used by
|
|
3
|
+
* the device pool to stand up N iOS simulators for a parallel run and tear down
|
|
4
|
+
* the ones it created. Kept separate from simctl.ts (the per-device drive layer)
|
|
5
|
+
* so the lifecycle module stays lean.
|
|
6
|
+
*
|
|
7
|
+
* All of these are thin wrappers over `xcrun simctl`. The pool, not this module,
|
|
8
|
+
* decides WHICH devices to clone/boot and tracks ownership for teardown.
|
|
9
|
+
*/
|
|
10
|
+
export interface SimDevice {
|
|
11
|
+
udid: string;
|
|
12
|
+
name: string;
|
|
13
|
+
/** "Booted" | "Shutdown" | "Creating" | … */
|
|
14
|
+
state: string;
|
|
15
|
+
/** e.g. com.apple.CoreSimulator.SimDeviceType.iPhone-16 */
|
|
16
|
+
deviceTypeId?: string;
|
|
17
|
+
/** runtime identifier the device lives under, e.g. com.apple.CoreSimulator.SimRuntime.iOS-18-2 */
|
|
18
|
+
runtime: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Every device across all runtimes (not just booted), with its runtime folded
|
|
22
|
+
* in from the grouping key. Unavailable devices (missing runtime) are dropped.
|
|
23
|
+
*/
|
|
24
|
+
export declare function listAllDevices(): Promise<SimDevice[]>;
|
|
25
|
+
/** Booted devices only (convenience over listAllDevices). */
|
|
26
|
+
export declare function listBootedDevices(): Promise<SimDevice[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Create a fresh simulator of the given device type + runtime and return its
|
|
29
|
+
* udid. Unlike `clone`, `create` works regardless of the source's state (clone
|
|
30
|
+
* refuses while the source is Booted, which it always is when we're reusing it
|
|
31
|
+
* as device 0). A created device is clean — the per-participant app install
|
|
32
|
+
* (IOSDevice.resolveBundleId) and WDA install (ensureWda) populate it.
|
|
33
|
+
*/
|
|
34
|
+
export declare function createSimulator(name: string, deviceTypeId: string, runtime: string): Promise<string>;
|
|
35
|
+
/**
|
|
36
|
+
* Clone a SHUTDOWN simulator (carries installed apps; faster than create). Not
|
|
37
|
+
* used by the pool today because the reuse source is booted — kept for callers
|
|
38
|
+
* that hold a shut-down source.
|
|
39
|
+
*/
|
|
40
|
+
export declare function cloneDevice(sourceUdid: string, name: string): Promise<string>;
|
|
41
|
+
/**
|
|
42
|
+
* Boot a simulator and wait until it's fully booted. `bootstatus -b` boots the
|
|
43
|
+
* device if needed and blocks until ready, so it's a one-shot "boot and wait".
|
|
44
|
+
*/
|
|
45
|
+
export declare function bootDevice(udid: string): Promise<void>;
|
|
46
|
+
/** Power down a simulator. Tolerant of an already-shutdown device. */
|
|
47
|
+
export declare function shutdownDevice(udid: string): Promise<void>;
|
|
48
|
+
/** Delete a (cloned, disposable) simulator. Shut it down first if needed. */
|
|
49
|
+
export declare function deleteDevice(udid: string): Promise<void>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simulator provisioning primitives (boot / clone / shutdown / delete) used by
|
|
3
|
+
* the device pool to stand up N iOS simulators for a parallel run and tear down
|
|
4
|
+
* the ones it created. Kept separate from simctl.ts (the per-device drive layer)
|
|
5
|
+
* so the lifecycle module stays lean.
|
|
6
|
+
*
|
|
7
|
+
* All of these are thin wrappers over `xcrun simctl`. The pool, not this module,
|
|
8
|
+
* decides WHICH devices to clone/boot and tracks ownership for teardown.
|
|
9
|
+
*/
|
|
10
|
+
import { simctl, IosError } from "./simctl.js";
|
|
11
|
+
/**
|
|
12
|
+
* Every device across all runtimes (not just booted), with its runtime folded
|
|
13
|
+
* in from the grouping key. Unavailable devices (missing runtime) are dropped.
|
|
14
|
+
*/
|
|
15
|
+
export async function listAllDevices() {
|
|
16
|
+
const out = await simctl(["list", "devices", "-j"], 30_000);
|
|
17
|
+
let parsed;
|
|
18
|
+
try {
|
|
19
|
+
parsed = JSON.parse(out);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new IosError("Could not parse `simctl list devices -j` output.");
|
|
23
|
+
}
|
|
24
|
+
const rows = [];
|
|
25
|
+
for (const [runtime, devices] of Object.entries(parsed.devices ?? {})) {
|
|
26
|
+
for (const d of devices) {
|
|
27
|
+
if (d.isAvailable === false)
|
|
28
|
+
continue;
|
|
29
|
+
rows.push({
|
|
30
|
+
udid: d.udid,
|
|
31
|
+
name: d.name,
|
|
32
|
+
state: d.state,
|
|
33
|
+
deviceTypeId: d.deviceTypeIdentifier,
|
|
34
|
+
runtime,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return rows;
|
|
39
|
+
}
|
|
40
|
+
/** Booted devices only (convenience over listAllDevices). */
|
|
41
|
+
export async function listBootedDevices() {
|
|
42
|
+
return (await listAllDevices()).filter((d) => d.state === "Booted");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Create a fresh simulator of the given device type + runtime and return its
|
|
46
|
+
* udid. Unlike `clone`, `create` works regardless of the source's state (clone
|
|
47
|
+
* refuses while the source is Booted, which it always is when we're reusing it
|
|
48
|
+
* as device 0). A created device is clean — the per-participant app install
|
|
49
|
+
* (IOSDevice.resolveBundleId) and WDA install (ensureWda) populate it.
|
|
50
|
+
*/
|
|
51
|
+
export async function createSimulator(name, deviceTypeId, runtime) {
|
|
52
|
+
const out = await simctl(["create", name, deviceTypeId, runtime], 120_000);
|
|
53
|
+
const udid = out.trim();
|
|
54
|
+
if (!/^[0-9A-Fa-f-]{36}$/.test(udid)) {
|
|
55
|
+
throw new IosError(`simctl create did not return a udid (got: ${udid.slice(0, 80)})`);
|
|
56
|
+
}
|
|
57
|
+
return udid;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Clone a SHUTDOWN simulator (carries installed apps; faster than create). Not
|
|
61
|
+
* used by the pool today because the reuse source is booted — kept for callers
|
|
62
|
+
* that hold a shut-down source.
|
|
63
|
+
*/
|
|
64
|
+
export async function cloneDevice(sourceUdid, name) {
|
|
65
|
+
const out = await simctl(["clone", sourceUdid, name], 120_000);
|
|
66
|
+
const udid = out.trim();
|
|
67
|
+
if (!/^[0-9A-Fa-f-]{36}$/.test(udid)) {
|
|
68
|
+
throw new IosError(`simctl clone did not return a udid (got: ${udid.slice(0, 80)})`);
|
|
69
|
+
}
|
|
70
|
+
return udid;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Boot a simulator and wait until it's fully booted. `bootstatus -b` boots the
|
|
74
|
+
* device if needed and blocks until ready, so it's a one-shot "boot and wait".
|
|
75
|
+
*/
|
|
76
|
+
export async function bootDevice(udid) {
|
|
77
|
+
// `boot` errors if already booted; swallow that, then bootstatus waits.
|
|
78
|
+
await simctl(["boot", udid], 60_000).catch(() => { });
|
|
79
|
+
await simctl(["bootstatus", udid, "-b"], 180_000);
|
|
80
|
+
}
|
|
81
|
+
/** Power down a simulator. Tolerant of an already-shutdown device. */
|
|
82
|
+
export async function shutdownDevice(udid) {
|
|
83
|
+
await simctl(["shutdown", udid], 60_000).catch(() => { });
|
|
84
|
+
}
|
|
85
|
+
/** Delete a (cloned, disposable) simulator. Shut it down first if needed. */
|
|
86
|
+
export async function deleteDevice(udid) {
|
|
87
|
+
await shutdownDevice(udid);
|
|
88
|
+
await simctl(["delete", udid], 60_000);
|
|
89
|
+
}
|
|
@@ -39,14 +39,16 @@ export interface IosScreen {
|
|
|
39
39
|
density: number;
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
|
-
* Capture
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* Capture a simulator's screen as PNG bytes via `simctl io <udid> screenshot`.
|
|
43
|
+
* simctl writes to a file path (no reliable stdout in current Xcode), so we
|
|
44
|
+
* round-trip through a temp file. Targets an explicit udid (not the "booted"
|
|
45
|
+
* literal) so concurrent pooled devices each screenshot the right simulator.
|
|
45
46
|
*/
|
|
46
|
-
export declare function screenshotPng(): Promise<Buffer>;
|
|
47
|
+
export declare function screenshotPng(udid: string): Promise<Buffer>;
|
|
47
48
|
export declare function terminateApp(udid: string, bundleId: string): Promise<void>;
|
|
48
49
|
export declare function launchApp(udid: string, bundleId: string): Promise<void>;
|
|
49
50
|
export declare function installApp(udid: string, appPath: string): Promise<void>;
|
|
51
|
+
export declare function uninstallApp(udid: string, bundleId: string): Promise<void>;
|
|
50
52
|
export declare function isAppInstalled(udid: string, bundleId: string): Promise<boolean>;
|
|
51
53
|
/**
|
|
52
54
|
* Read CFBundleIdentifier from a local `.app`'s Info.plist via `plutil`. Lets us
|
|
@@ -82,15 +82,16 @@ export async function requireOneBootedSimulator() {
|
|
|
82
82
|
}
|
|
83
83
|
// --- Screenshot (PIXELS) ---
|
|
84
84
|
/**
|
|
85
|
-
* Capture
|
|
86
|
-
*
|
|
87
|
-
*
|
|
85
|
+
* Capture a simulator's screen as PNG bytes via `simctl io <udid> screenshot`.
|
|
86
|
+
* simctl writes to a file path (no reliable stdout in current Xcode), so we
|
|
87
|
+
* round-trip through a temp file. Targets an explicit udid (not the "booted"
|
|
88
|
+
* literal) so concurrent pooled devices each screenshot the right simulator.
|
|
88
89
|
*/
|
|
89
|
-
export async function screenshotPng() {
|
|
90
|
+
export async function screenshotPng(udid) {
|
|
90
91
|
const dir = await mkdtemp(join(tmpdir(), "ish-ios-shot-"));
|
|
91
92
|
const path = join(dir, "shot.png");
|
|
92
93
|
try {
|
|
93
|
-
await simctl(["io",
|
|
94
|
+
await simctl(["io", udid, "screenshot", path], SCREENSHOT_TIMEOUT_MS);
|
|
94
95
|
return await readFile(path);
|
|
95
96
|
}
|
|
96
97
|
finally {
|
|
@@ -117,6 +118,18 @@ export async function installApp(udid, appPath) {
|
|
|
117
118
|
// Simulator builds aren't code-signed; `simctl install` just stages the .app.
|
|
118
119
|
await simctl(["install", udid, appPath], 180_000);
|
|
119
120
|
}
|
|
121
|
+
export async function uninstallApp(udid, bundleId) {
|
|
122
|
+
// Removes the app AND its data container — this is what actually wipes state
|
|
123
|
+
// between participants (a terminate+relaunch leaves the data store intact).
|
|
124
|
+
// Uninstalling an app that isn't installed exits non-zero; swallow it so a
|
|
125
|
+
// first-participant "reset" is a no-op rather than a failure.
|
|
126
|
+
try {
|
|
127
|
+
await simctl(["uninstall", udid, bundleId], 60_000);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// not installed — nothing to remove
|
|
131
|
+
}
|
|
132
|
+
}
|
|
120
133
|
export async function isAppInstalled(udid, bundleId) {
|
|
121
134
|
// `simctl listapps` emits a plist of installed bundles; a substring check on
|
|
122
135
|
// the quoted bundle id is enough to confirm presence.
|
|
@@ -18,6 +18,19 @@
|
|
|
18
18
|
* and reuses an already-running runner.
|
|
19
19
|
*/
|
|
20
20
|
import { type IosScreen } from "./simctl.js";
|
|
21
|
+
/**
|
|
22
|
+
* The Return keystroke we feed to WDA's `/wda/keys` for a submit.
|
|
23
|
+
*
|
|
24
|
+
* NOT the W3C ENTER code (U+E007): WDA's `/wda/keys` does NOT interpret the
|
|
25
|
+
* WebDriver special-key PUA codepoints \u2014 it types them LITERALLY. On the
|
|
26
|
+
* simulator U+E007 renders as a running-shoe emoji, so a submit appended a
|
|
27
|
+
* stray `\uD83D\uDC5F` to the field (e.g. a search for `Photos` became `Photos\uD83D\uDC5F`,
|
|
28
|
+
* returning no results and derailing the run) AND never actually submitted.
|
|
29
|
+
* A plain newline is what WDA's keyboard treats as Return \u2014 it submits
|
|
30
|
+
* single-line fields and inserts a line break in multiline ones, with no glyph.
|
|
31
|
+
* Verified on a booted iOS 18 simulator (Settings search).
|
|
32
|
+
*/
|
|
33
|
+
export declare const WDA_RETURN = "\n";
|
|
21
34
|
interface Session {
|
|
22
35
|
port: number;
|
|
23
36
|
baseUrl: string;
|
|
@@ -38,6 +51,7 @@ export declare function resolveWdaBundle(): Promise<string>;
|
|
|
38
51
|
*/
|
|
39
52
|
export declare function ensureWda(udid: string, opts?: {
|
|
40
53
|
bundleId?: string;
|
|
54
|
+
port?: number;
|
|
41
55
|
}): Promise<Session>;
|
|
42
56
|
/** Tear down the WDA session for `udid` (the runner is left for the next run). */
|
|
43
57
|
export declare function closeWda(udid: string): Promise<void>;
|
|
@@ -45,6 +59,13 @@ export declare function closeWda(udid: string): Promise<void>;
|
|
|
45
59
|
export declare function describeScreen(udid: string): Promise<IosScreen>;
|
|
46
60
|
/** Raw WDA `/source?format=json` string — feed to `parseXcuiHierarchy`. */
|
|
47
61
|
export declare function describeAll(udid: string): Promise<string>;
|
|
62
|
+
/**
|
|
63
|
+
* The foreground app's bundle id via WDA `GET /wda/activeAppInfo`, a coarse
|
|
64
|
+
* input for the screen signature. Best-effort: returns "" on any failure (the
|
|
65
|
+
* signature degrades to a bundleId-less coarse token, and the run never depends
|
|
66
|
+
* on this read).
|
|
67
|
+
*/
|
|
68
|
+
export declare function activeBundleId(udid: string): Promise<string>;
|
|
48
69
|
export declare function uiTap(udid: string, x: number, y: number): Promise<void>;
|
|
49
70
|
export declare function uiLongPress(udid: string, x: number, y: number, durationMs?: number): Promise<void>;
|
|
50
71
|
export declare function uiSwipe(udid: string, x1: number, y1: number, x2: number, y2: number, durationMs?: number): Promise<void>;
|
|
@@ -52,7 +73,7 @@ export declare function uiSwipe(udid: string, x1: number, y1: number, x2: number
|
|
|
52
73
|
export declare function uiText(udid: string, text: string): Promise<void>;
|
|
53
74
|
/**
|
|
54
75
|
* Press a key. Only the idb HID Return keycode (40) is used by ios.ts today;
|
|
55
|
-
* map it to
|
|
76
|
+
* map it to a newline (see WDA_RETURN). Unknown codes are a no-op-safe error.
|
|
56
77
|
*/
|
|
57
78
|
export declare function uiKey(udid: string, keycode: number): Promise<void>;
|
|
58
79
|
/** Re-export so a future ios.ts can drop the simctl HID constant. */
|
|
@@ -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
|
-
/**
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -250,6 +266,22 @@ export async function describeAll(udid) {
|
|
|
250
266
|
const json = await wdaCall(s.port, "GET", `/session/${s.sessionId}/source?format=json`);
|
|
251
267
|
return JSON.stringify(json);
|
|
252
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* The foreground app's bundle id via WDA `GET /wda/activeAppInfo`, a coarse
|
|
271
|
+
* input for the screen signature. Best-effort: returns "" on any failure (the
|
|
272
|
+
* signature degrades to a bundleId-less coarse token, and the run never depends
|
|
273
|
+
* on this read).
|
|
274
|
+
*/
|
|
275
|
+
export async function activeBundleId(udid) {
|
|
276
|
+
try {
|
|
277
|
+
const s = await getSession(udid);
|
|
278
|
+
const v = unwrap(await wdaCall(s.port, "GET", "/wda/activeAppInfo"));
|
|
279
|
+
return typeof v.bundleId === "string" ? v.bundleId : "";
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return "";
|
|
283
|
+
}
|
|
284
|
+
}
|
|
253
285
|
// ── Gestures (W3C pointer actions; coordinates in POINTS) ────────────────────
|
|
254
286
|
function pointerAction(steps) {
|
|
255
287
|
return {
|
|
@@ -291,13 +323,13 @@ export async function uiText(udid, text) {
|
|
|
291
323
|
}
|
|
292
324
|
/**
|
|
293
325
|
* Press a key. Only the idb HID Return keycode (40) is used by ios.ts today;
|
|
294
|
-
* map it to
|
|
326
|
+
* map it to a newline (see WDA_RETURN). Unknown codes are a no-op-safe error.
|
|
295
327
|
*/
|
|
296
328
|
export async function uiKey(udid, keycode) {
|
|
297
329
|
if (keycode !== 40)
|
|
298
330
|
throw new IosError(`unsupported WDA keycode: ${keycode}`);
|
|
299
331
|
const s = await getSession(udid);
|
|
300
|
-
await wdaCall(s.port, "POST", `/session/${s.sessionId}/wda/keys`, { value: [
|
|
332
|
+
await wdaCall(s.port, "POST", `/session/${s.sessionId}/wda/keys`, { value: [WDA_RETURN] });
|
|
301
333
|
}
|
|
302
334
|
/** Re-export so a future ios.ts can drop the simctl HID constant. */
|
|
303
335
|
export const HID_KEY_RETURN = 40;
|
package/dist/lib/modality.js
CHANGED
|
@@ -97,8 +97,13 @@ export function iterationHasContent(details, modality) {
|
|
|
97
97
|
const ep = readExternalChatbotEndpoint(details);
|
|
98
98
|
return !!ep.endpoint || !!ep.chatbot_endpoint_id;
|
|
99
99
|
}
|
|
100
|
-
// interactive (default)
|
|
101
|
-
|
|
100
|
+
// interactive (default): browser/figma iterations carry a `url`; native
|
|
101
|
+
// (ios/android) iterations carry an `app_artifact` instead and need no URL
|
|
102
|
+
// (the backend made url optional for native — ish-backend 9708e06e). Either
|
|
103
|
+
// satisfies "has content".
|
|
104
|
+
const hasUrl = typeof d.url === "string" && d.url.length > 0;
|
|
105
|
+
const hasApp = typeof d.app_artifact === "string" && d.app_artifact.length > 0;
|
|
106
|
+
return hasUrl || hasApp;
|
|
102
107
|
}
|
|
103
108
|
/** The flag fragment a user should pass to populate content for a given modality. */
|
|
104
109
|
export function describeRequiredContentFlag(modality, chatMode) {
|
package/dist/lib/paths.d.ts
CHANGED
package/dist/lib/paths.js
CHANGED
|
@@ -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
|
-
- **
|
|
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
|
|
package/dist/lib/upload.d.ts
CHANGED
|
@@ -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.
|