@ishlabs/cli 0.24.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/ask.js +3 -3
- package/dist/commands/doctor.d.ts +26 -0
- package/dist/commands/doctor.js +334 -0
- package/dist/commands/iteration.js +1 -1
- package/dist/commands/study-analyze.js +1 -1
- package/dist/commands/study-run.js +80 -12
- package/dist/commands/study.js +11 -7
- package/dist/index.js +2 -0
- package/dist/lib/alias-store.js +1 -1
- package/dist/lib/api-client.d.ts +2 -0
- package/dist/lib/docs.js +57 -42
- package/dist/lib/local-sim/actions.d.ts +10 -2
- package/dist/lib/local-sim/actions.js +18 -11
- package/dist/lib/local-sim/adb.d.ts +113 -0
- package/dist/lib/local-sim/adb.js +366 -0
- package/dist/lib/local-sim/android.d.ts +111 -0
- package/dist/lib/local-sim/android.js +504 -0
- package/dist/lib/local-sim/apk-manifest.d.ts +22 -0
- package/dist/lib/local-sim/apk-manifest.js +210 -0
- package/dist/lib/local-sim/browser.d.ts +22 -0
- package/dist/lib/local-sim/browser.js +65 -0
- package/dist/lib/local-sim/coordinates.d.ts +69 -0
- package/dist/lib/local-sim/coordinates.js +59 -0
- package/dist/lib/local-sim/device.d.ts +143 -0
- package/dist/lib/local-sim/device.js +152 -0
- package/dist/lib/local-sim/ios.d.ts +185 -0
- package/dist/lib/local-sim/ios.js +599 -0
- package/dist/lib/local-sim/loop.d.ts +14 -2
- package/dist/lib/local-sim/loop.js +168 -73
- package/dist/lib/local-sim/native-a11y.d.ts +111 -0
- package/dist/lib/local-sim/native-a11y.js +419 -0
- package/dist/lib/local-sim/simctl.d.ts +55 -0
- package/dist/lib/local-sim/simctl.js +144 -0
- package/dist/lib/local-sim/types.d.ts +39 -2
- package/dist/lib/local-sim/upload.d.ts +1 -1
- package/dist/lib/local-sim/upload.js +9 -6
- package/dist/lib/local-sim/xcuitest.d.ts +60 -0
- package/dist/lib/local-sim/xcuitest.js +303 -0
- package/dist/lib/output.js +58 -12
- package/dist/lib/paths.d.ts +8 -0
- package/dist/lib/paths.js +12 -0
- package/dist/lib/skill-content.js +10 -9
- package/package.json +2 -1
|
@@ -44,6 +44,15 @@ export interface ContextValue {
|
|
|
44
44
|
value: string | null;
|
|
45
45
|
description?: string;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Per-turn assignment status the agent can emit. Mirrors the backend's
|
|
49
|
+
* AssignmentStatus enum (app/db/schemas/enums/study.py) restricted to the
|
|
50
|
+
* LLM-emittable values (_LLM_EMITTABLE_STATUSES in
|
|
51
|
+
* app/interactive/instructions/output.py). The harness-only values
|
|
52
|
+
* (pending / max_steps_reached / failed) are NOT emittable per turn; the
|
|
53
|
+
* run-level AssignmentStatusUpdate sends those terminal values instead.
|
|
54
|
+
*/
|
|
55
|
+
export type AssignmentStatus = "in_progress" | "completed" | "abandoned";
|
|
47
56
|
export interface HistoryEntry {
|
|
48
57
|
comment: string;
|
|
49
58
|
action_description: string;
|
|
@@ -104,6 +113,19 @@ export interface LocalStepAction {
|
|
|
104
113
|
modifiers: string[] | null;
|
|
105
114
|
key: string | null;
|
|
106
115
|
tab_id: string | null;
|
|
116
|
+
orientation: string | null;
|
|
117
|
+
panel: "quick_settings" | "notifications" | null;
|
|
118
|
+
scale: number | null;
|
|
119
|
+
coordinates: {
|
|
120
|
+
x: number;
|
|
121
|
+
y: number;
|
|
122
|
+
} | null;
|
|
123
|
+
drag: {
|
|
124
|
+
startX: number;
|
|
125
|
+
startY: number;
|
|
126
|
+
endX: number;
|
|
127
|
+
endY: number;
|
|
128
|
+
} | null;
|
|
107
129
|
}
|
|
108
130
|
/** Raw backend step response — output is nested, actions are separate. */
|
|
109
131
|
export interface LocalSimStepResponseRaw {
|
|
@@ -114,7 +136,7 @@ export interface LocalSimStepResponseRaw {
|
|
|
114
136
|
sentiment_intensity?: number;
|
|
115
137
|
current_location: string;
|
|
116
138
|
effort_seconds: number;
|
|
117
|
-
|
|
139
|
+
assignment_status: AssignmentStatus;
|
|
118
140
|
action: {
|
|
119
141
|
actions: Array<{
|
|
120
142
|
type: string;
|
|
@@ -135,6 +157,18 @@ export interface LocalSimStepResponseRaw {
|
|
|
135
157
|
modifiers?: string[];
|
|
136
158
|
key?: string;
|
|
137
159
|
tab_id?: string;
|
|
160
|
+
orientation?: string;
|
|
161
|
+
panel?: "quick_settings" | "notifications";
|
|
162
|
+
scale?: number;
|
|
163
|
+
coordinates?: {
|
|
164
|
+
x: number;
|
|
165
|
+
y: number;
|
|
166
|
+
} | {
|
|
167
|
+
startX: number;
|
|
168
|
+
startY: number;
|
|
169
|
+
endX: number;
|
|
170
|
+
endY: number;
|
|
171
|
+
};
|
|
138
172
|
}>;
|
|
139
173
|
};
|
|
140
174
|
};
|
|
@@ -149,6 +183,7 @@ export interface LocalSimStepResponse {
|
|
|
149
183
|
sentiment_intensity: number;
|
|
150
184
|
current_location: string;
|
|
151
185
|
effort_seconds: number;
|
|
186
|
+
assignment_status: AssignmentStatus;
|
|
152
187
|
assignment_completed: boolean;
|
|
153
188
|
actions: LocalStepAction[];
|
|
154
189
|
loop_detected: boolean;
|
|
@@ -174,6 +209,8 @@ export interface RecordInteraction {
|
|
|
174
209
|
assignment_id: string;
|
|
175
210
|
screenshot_base64?: string;
|
|
176
211
|
screenshot_url?: string;
|
|
212
|
+
screen_width?: number;
|
|
213
|
+
screen_height?: number;
|
|
177
214
|
frame_version_id?: string;
|
|
178
215
|
timestamp_ms: number;
|
|
179
216
|
comment: string | null;
|
|
@@ -181,7 +218,7 @@ export interface RecordInteraction {
|
|
|
181
218
|
sentiment: SentimentData;
|
|
182
219
|
actions: ActionData[];
|
|
183
220
|
current_location: string | null;
|
|
184
|
-
|
|
221
|
+
assignment_status: AssignmentStatus;
|
|
185
222
|
tabs?: LocalTabInfo[];
|
|
186
223
|
}
|
|
187
224
|
export interface AssignmentStatusUpdate {
|
|
@@ -3,4 +3,4 @@ export interface ScreenshotUploadResult {
|
|
|
3
3
|
screenshotUrl: string;
|
|
4
4
|
screenshotId: string;
|
|
5
5
|
}
|
|
6
|
-
export declare function uploadScreenshot(client: ApiClient, productId: string,
|
|
6
|
+
export declare function uploadScreenshot(client: ApiClient, productId: string, imageBuffer: Buffer, contentType?: "image/jpeg" | "image/png"): Promise<ScreenshotUploadResult>;
|
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
export async function uploadScreenshot(client, productId,
|
|
2
|
+
export async function uploadScreenshot(client, productId, imageBuffer,
|
|
3
|
+
// Browser captures JPEG; native (adb screencap) emits PNG. The signed-URL
|
|
4
|
+
// request and the PUT header MUST agree so storage serves correct bytes.
|
|
5
|
+
contentType = "image/jpeg") {
|
|
3
6
|
const screenshotId = randomUUID();
|
|
4
7
|
// Step 1: Get signed URL from backend
|
|
5
8
|
const resp = await client.localSimScreenshotUpload({
|
|
6
9
|
product_id: productId,
|
|
7
10
|
screenshot_id: screenshotId,
|
|
8
|
-
content_type:
|
|
11
|
+
content_type: contentType,
|
|
9
12
|
});
|
|
10
|
-
// Step 2: PUT raw
|
|
13
|
+
// Step 2: PUT raw image bytes directly to Supabase Storage
|
|
11
14
|
const putResp = await fetch(resp.upload_info.signed_upload_url, {
|
|
12
15
|
method: "PUT",
|
|
13
16
|
headers: {
|
|
14
|
-
"Content-Type":
|
|
15
|
-
"Content-Length": String(
|
|
17
|
+
"Content-Type": contentType,
|
|
18
|
+
"Content-Length": String(imageBuffer.byteLength),
|
|
16
19
|
},
|
|
17
|
-
body:
|
|
20
|
+
body: imageBuffer,
|
|
18
21
|
signal: AbortSignal.timeout(30_000),
|
|
19
22
|
});
|
|
20
23
|
if (!putResp.ok) {
|
|
@@ -0,0 +1,60 @@
|
|
|
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 { type IosScreen } from "./simctl.js";
|
|
21
|
+
interface Session {
|
|
22
|
+
port: number;
|
|
23
|
+
baseUrl: string;
|
|
24
|
+
sessionId: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the prebuilt WDA `.app`, downloading it on first use:
|
|
28
|
+
* `ISH_WDA_PATH` override → `~/.ish/bin/wda/` cache → fetch Appium's prebuilt
|
|
29
|
+
* WebDriverAgent-for-simulator from its GitHub release. Mirrors
|
|
30
|
+
* `connect.ts:resolveCloudflaredBin` (which fetches cloudflared the same way).
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveWdaBundle(): Promise<string>;
|
|
33
|
+
/**
|
|
34
|
+
* Ensure a WDA runner is up for `udid` and a session exists, idempotently. If a
|
|
35
|
+
* runner already answers on the port (a prior `ensureWda`, or an externally
|
|
36
|
+
* launched one), it is reused — only a fresh session is created. Otherwise the
|
|
37
|
+
* prebuilt runner is installed and `simctl launch`-ed.
|
|
38
|
+
*/
|
|
39
|
+
export declare function ensureWda(udid: string, opts?: {
|
|
40
|
+
bundleId?: string;
|
|
41
|
+
}): Promise<Session>;
|
|
42
|
+
/** Tear down the WDA session for `udid` (the runner is left for the next run). */
|
|
43
|
+
export declare function closeWda(udid: string): Promise<void>;
|
|
44
|
+
/** Screen geometry from WDA `/wda/screen` (points + retina scale). */
|
|
45
|
+
export declare function describeScreen(udid: string): Promise<IosScreen>;
|
|
46
|
+
/** Raw WDA `/source?format=json` string — feed to `parseXcuiHierarchy`. */
|
|
47
|
+
export declare function describeAll(udid: string): Promise<string>;
|
|
48
|
+
export declare function uiTap(udid: string, x: number, y: number): Promise<void>;
|
|
49
|
+
export declare function uiLongPress(udid: string, x: number, y: number, durationMs?: number): Promise<void>;
|
|
50
|
+
export declare function uiSwipe(udid: string, x1: number, y1: number, x2: number, y2: number, durationMs?: number): Promise<void>;
|
|
51
|
+
/** Type into the focused element (the caller taps a field first). */
|
|
52
|
+
export declare function uiText(udid: string, text: string): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* Press a key. Only the idb HID Return keycode (40) is used by ios.ts today;
|
|
55
|
+
* map it to W3C ENTER. Unknown codes are a no-op-safe error.
|
|
56
|
+
*/
|
|
57
|
+
export declare function uiKey(udid: string, keycode: number): Promise<void>;
|
|
58
|
+
/** Re-export so a future ios.ts can drop the simctl HID constant. */
|
|
59
|
+
export declare const HID_KEY_RETURN = 40;
|
|
60
|
+
export {};
|
|
@@ -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/output.js
CHANGED
|
@@ -503,7 +503,7 @@ function suggestionsForError(err) {
|
|
|
503
503
|
"If you didn't pass the resource explicitly, your saved active workspace/study/ask may be stale — run `ish status` to check, then `ish workspace use --clear` (or `ish study use --clear` / `ish ask use --clear`) to reset.",
|
|
504
504
|
];
|
|
505
505
|
case "insufficient_credits":
|
|
506
|
-
return ["
|
|
506
|
+
return ["Get more credits at https://app.ishlabs.io"];
|
|
507
507
|
case "usage_limit_reached": {
|
|
508
508
|
const d = structuredDetail(err);
|
|
509
509
|
const upgradeUrl = typeof d?.upgrade_url === "string" ? d.upgrade_url : "https://app.ishlabs.io/billing";
|
|
@@ -625,7 +625,7 @@ export function outputError(err, json) {
|
|
|
625
625
|
}
|
|
626
626
|
else {
|
|
627
627
|
if (err.status === 402) {
|
|
628
|
-
console.error("Error: Insufficient credits.
|
|
628
|
+
console.error("Error: Insufficient credits. Get more credits at https://app.ishlabs.io");
|
|
629
629
|
}
|
|
630
630
|
else {
|
|
631
631
|
console.error(`Error: ${remapEntityName(err.message)}`);
|
|
@@ -1222,7 +1222,7 @@ export function formatStudyResults(study, participants, json) {
|
|
|
1222
1222
|
console.log(` ${alias} (${t.name}): ${truncate(t.errorMessage, 200)}`);
|
|
1223
1223
|
}
|
|
1224
1224
|
}
|
|
1225
|
-
console.log("\nRun `ish participant
|
|
1225
|
+
console.log("\nRun `ish study participant <id> --json` for full interaction details.");
|
|
1226
1226
|
}
|
|
1227
1227
|
}
|
|
1228
1228
|
/**
|
|
@@ -1668,7 +1668,7 @@ export function formatSimulationPoll(results, json, isMedia = false) {
|
|
|
1668
1668
|
// Pattern A (cli half): list per-participant error_message under the table so
|
|
1669
1669
|
// agents see why a simulation failed without re-fetching every participant.
|
|
1670
1670
|
// Truncate to 200 chars; full text is available via --json or
|
|
1671
|
-
// `ish study participant
|
|
1671
|
+
// `ish study participant <id>`.
|
|
1672
1672
|
const failedRows = results.filter((r) => {
|
|
1673
1673
|
const status = String(r.status || "").toLowerCase();
|
|
1674
1674
|
return (status === "failed" || status === "errored") && r.error_message;
|
|
@@ -1689,6 +1689,43 @@ function variantLetter(index) {
|
|
|
1689
1689
|
return String.fromCharCode(65 + index);
|
|
1690
1690
|
return `V${index + 1}`;
|
|
1691
1691
|
}
|
|
1692
|
+
/**
|
|
1693
|
+
* Assign one stable letter per variant id across an ask's rounds — the CLI
|
|
1694
|
+
* mirror of the backend's `app/asks/variant_loader.py:build_ask_label_map`.
|
|
1695
|
+
*
|
|
1696
|
+
* D2 fix: the LLM letters variants GLOBALLY across the whole ask. It scans
|
|
1697
|
+
* rounds in `order_index` order and gives `A, B, C…` on the *first appearance*
|
|
1698
|
+
* of each stable variant id, so the participant comments and round summaries
|
|
1699
|
+
* say `[[A]]/[[B]]` in round 1 and `[[C]]/[[D]]` in round 2. The CLI table used
|
|
1700
|
+
* to re-letter each round positionally (`variantLetter(localIndex)` → A/B every
|
|
1701
|
+
* round), so a comment's `[[C]]` pointed at a row labeled `A`. Building the same
|
|
1702
|
+
* id-keyed map here makes the table, the picks/winner/ratings aggregates, and
|
|
1703
|
+
* the cross-round columns all agree with the `[[token]]` letters.
|
|
1704
|
+
*
|
|
1705
|
+
* Identity is the variant `id` (persisted variants always carry one). A variant
|
|
1706
|
+
* missing an id is skipped from the map — it then falls back to the round-local
|
|
1707
|
+
* positional letter at the call site, matching the backend's documented fallback
|
|
1708
|
+
* for direct callers with no cross-round context.
|
|
1709
|
+
*/
|
|
1710
|
+
function buildAskLabelMap(rounds) {
|
|
1711
|
+
const labelMap = new Map();
|
|
1712
|
+
const ordered = [...rounds].sort((a, b) => {
|
|
1713
|
+
const ai = typeof a.order_index === "number" ? a.order_index : 0;
|
|
1714
|
+
const bi = typeof b.order_index === "number" ? b.order_index : 0;
|
|
1715
|
+
return ai - bi;
|
|
1716
|
+
});
|
|
1717
|
+
for (const round of ordered) {
|
|
1718
|
+
const variants = Array.isArray(round.variants) ? round.variants : [];
|
|
1719
|
+
for (const v of variants) {
|
|
1720
|
+
const id = v.id;
|
|
1721
|
+
if (typeof id !== "string" || id.length === 0)
|
|
1722
|
+
continue;
|
|
1723
|
+
if (!labelMap.has(id))
|
|
1724
|
+
labelMap.set(id, variantLetter(labelMap.size));
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
return labelMap;
|
|
1728
|
+
}
|
|
1692
1729
|
export function formatAskList(asks, json) {
|
|
1693
1730
|
injectAliases(asks, ALIAS_PREFIX.ask);
|
|
1694
1731
|
if (json) {
|
|
@@ -1852,13 +1889,17 @@ export function formatRoundDetail(round, json) {
|
|
|
1852
1889
|
console.log(` ${summary.comment}`);
|
|
1853
1890
|
}
|
|
1854
1891
|
}
|
|
1855
|
-
function computeVariantStats(round) {
|
|
1892
|
+
function computeVariantStats(round, labelMap) {
|
|
1856
1893
|
const variants = Array.isArray(round.variants) ? round.variants : [];
|
|
1857
1894
|
const responses = Array.isArray(round.responses) ? round.responses : [];
|
|
1858
1895
|
const stats = variants.map((v, i) => {
|
|
1859
1896
|
const variant = v;
|
|
1897
|
+
const id = typeof variant.id === "string" ? variant.id : undefined;
|
|
1860
1898
|
return {
|
|
1861
|
-
|
|
1899
|
+
// D2: prefer the ask-global letter (id-keyed, matches the LLM's
|
|
1900
|
+
// `[[token]]` letters) and only fall back to the round-local positional
|
|
1901
|
+
// letter when no map entry exists (single-round / mapless callers).
|
|
1902
|
+
letter: (id && labelMap?.get(id)) || variantLetter(i),
|
|
1862
1903
|
label: variant.label ? String(variant.label) : undefined,
|
|
1863
1904
|
kind: String(variant.kind || "-"),
|
|
1864
1905
|
pickCount: 0,
|
|
@@ -2087,13 +2128,13 @@ export function deriveWinnerConfidence(args) {
|
|
|
2087
2128
|
return "medium";
|
|
2088
2129
|
return "high";
|
|
2089
2130
|
}
|
|
2090
|
-
function buildCrossRoundSummary(rounds) {
|
|
2131
|
+
function buildCrossRoundSummary(rounds, labelMap) {
|
|
2091
2132
|
if (rounds.length < 2)
|
|
2092
2133
|
return undefined;
|
|
2093
2134
|
const entries = [];
|
|
2094
2135
|
for (const round of rounds) {
|
|
2095
2136
|
const idx = typeof round.order_index === "number" ? round.order_index : 0;
|
|
2096
|
-
const stats = computeVariantStats(round);
|
|
2137
|
+
const stats = computeVariantStats(round, labelMap);
|
|
2097
2138
|
const aggregates = buildAggregates(round, stats);
|
|
2098
2139
|
const entry = {
|
|
2099
2140
|
round_number: idx + 1,
|
|
@@ -2128,12 +2169,17 @@ export function formatAskResults(ask, json, roundFilter) {
|
|
|
2128
2169
|
const filtered = roundFilter !== undefined
|
|
2129
2170
|
? rounds.filter((r) => (typeof r.order_index === "number" ? r.order_index : 0) === roundFilter - 1)
|
|
2130
2171
|
: rounds;
|
|
2172
|
+
// D2: build the ask-global variant→letter map from the FULL round list (not
|
|
2173
|
+
// `filtered`) so that even `--round 2` still letters its variants C/D — the
|
|
2174
|
+
// letter a variant earned when it debuted, matching the `[[token]]` letters
|
|
2175
|
+
// in the LLM's comments and round summaries.
|
|
2176
|
+
const labelMap = buildAskLabelMap(rounds);
|
|
2131
2177
|
if (json) {
|
|
2132
2178
|
let total = 0;
|
|
2133
2179
|
let complete = 0;
|
|
2134
2180
|
let errored = 0;
|
|
2135
2181
|
const enrichedRounds = filtered.map((round) => {
|
|
2136
|
-
const stats = computeVariantStats(round);
|
|
2182
|
+
const stats = computeVariantStats(round, labelMap);
|
|
2137
2183
|
const aggregates = buildAggregates(round, stats);
|
|
2138
2184
|
const decorated = denormalizeRoundCounts(round);
|
|
2139
2185
|
total += decorated.responses_total ?? 0;
|
|
@@ -2158,7 +2204,7 @@ export function formatAskResults(ask, json, roundFilter) {
|
|
|
2158
2204
|
}
|
|
2159
2205
|
// Pattern H2: include cross-round summary when 2+ rounds exist so agents
|
|
2160
2206
|
// don't have to diff two `ask results` calls themselves.
|
|
2161
|
-
const crossRound = buildCrossRoundSummary(filtered);
|
|
2207
|
+
const crossRound = buildCrossRoundSummary(filtered, labelMap);
|
|
2162
2208
|
if (crossRound)
|
|
2163
2209
|
payload.cross_round_summary = crossRound;
|
|
2164
2210
|
console.log(jsonOutput(payload));
|
|
@@ -2175,7 +2221,7 @@ export function formatAskResults(ask, json, roundFilter) {
|
|
|
2175
2221
|
const completed = responses.filter((r) => r.status === "completed");
|
|
2176
2222
|
console.log(`\nRound ${idx + 1} [${round.status || "-"}] · ${completed.length}/${responses.length} responded`);
|
|
2177
2223
|
console.log(` Prompt: "${truncate(String(round.prompt || ""), 100)}"`);
|
|
2178
|
-
const stats = computeVariantStats(round);
|
|
2224
|
+
const stats = computeVariantStats(round, labelMap);
|
|
2179
2225
|
if (stats.length > 0 && (round.wants_pick || round.wants_ratings)) {
|
|
2180
2226
|
const hasPick = !!round.wants_pick;
|
|
2181
2227
|
const hasRatings = !!round.wants_ratings;
|
|
@@ -2222,7 +2268,7 @@ export function formatAskResults(ask, json, roundFilter) {
|
|
|
2222
2268
|
}
|
|
2223
2269
|
// Pattern H2: cross-round picks comparison when 2+ rounds exist. Saves
|
|
2224
2270
|
// agents from re-running results twice and diffing aggregates by hand.
|
|
2225
|
-
const crossRound = buildCrossRoundSummary(filtered);
|
|
2271
|
+
const crossRound = buildCrossRoundSummary(filtered, labelMap);
|
|
2226
2272
|
if (crossRound) {
|
|
2227
2273
|
console.log("\nCross-round summary:");
|
|
2228
2274
|
const letters = new Set();
|
package/dist/lib/paths.d.ts
CHANGED
|
@@ -12,4 +12,12 @@ 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;
|
|
15
23
|
export declare function connectLockPath(): string;
|