@yoqa/runner 0.3.16 → 0.3.18

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.
Files changed (2) hide show
  1. package/dist/index.js +574 -414
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -16801,7 +16801,7 @@ function getCatalogDb() {
16801
16801
  // package.json
16802
16802
  var package_default = {
16803
16803
  name: "@yoqa/runner",
16804
- version: "0.3.16",
16804
+ version: "0.3.18",
16805
16805
  description: "Yoqa local runner \u2014 Appium + catalog HTTP API (Bun). Start with yoqa serve or the yoqa-runner bin.",
16806
16806
  license: "UNLICENSED",
16807
16807
  type: "module",
@@ -16896,11 +16896,458 @@ function abortAllMjpegProxies() {
16896
16896
  import { mkdir as mkdir3 } from "fs/promises";
16897
16897
  import { createServer as createServer2 } from "net";
16898
16898
  import { homedir as homedir6 } from "os";
16899
- import { join as join6 } from "path";
16899
+ import { join as join7 } from "path";
16900
16900
  import { remote } from "webdriverio";
16901
- var YOQA_ROOT4 = join6(homedir6(), ".yoqa");
16901
+
16902
+ // src/domains/devices/application.ts
16903
+ import { mkdtemp as mkdtemp2, readFile, rm as rm2 } from "fs/promises";
16904
+ import { tmpdir as tmpdir2 } from "os";
16905
+ import { join as join6 } from "path";
16906
+
16907
+ // src/domains/devices/android-identity.ts
16908
+ function matchAndroidAppiumIdentity(deviceId, connected, knownAvds = []) {
16909
+ const id = deviceId.trim();
16910
+ if (!id)
16911
+ return {};
16912
+ const bySerial = connected.find((device) => device.serial === id);
16913
+ if (bySerial) {
16914
+ return {
16915
+ udid: bySerial.serial,
16916
+ ...bySerial.avdName ? { avd: bySerial.avdName } : {}
16917
+ };
16918
+ }
16919
+ const byAvd = connected.find((device) => device.avdName === id);
16920
+ if (byAvd) {
16921
+ return { udid: byAvd.serial, avd: byAvd.avdName ?? id };
16922
+ }
16923
+ if (knownAvds.includes(id)) {
16924
+ return { avd: id };
16925
+ }
16926
+ return { udid: id };
16927
+ }
16928
+ function parseAdbEmuAvdName(stdout) {
16929
+ const name = stdout.split(/\r?\n/).map((line) => line.trim()).find((line) => line && line !== "OK");
16930
+ return name || null;
16931
+ }
16932
+
16933
+ // src/domains/devices/application.ts
16934
+ async function runCommand3(command, options) {
16935
+ try {
16936
+ const proc = Bun.spawn(command, {
16937
+ env: options?.env ? { ...process.env, ...options.env } : process.env,
16938
+ stdout: "pipe",
16939
+ stderr: "pipe"
16940
+ });
16941
+ const [stdout, stderr, exitCode] = await Promise.all([
16942
+ new Response(proc.stdout).text(),
16943
+ new Response(proc.stderr).text(),
16944
+ proc.exited
16945
+ ]);
16946
+ return { stdout, stderr, exitCode };
16947
+ } catch {
16948
+ return { stdout: "", stderr: `failed to spawn: ${command[0]}`, exitCode: 127 };
16949
+ }
16950
+ }
16951
+ function androidSdkRoot() {
16952
+ return resolveAndroidSdkRoot();
16953
+ }
16954
+ async function resolveTool(candidates) {
16955
+ for (const candidate of candidates) {
16956
+ if (!candidate)
16957
+ continue;
16958
+ const { exitCode } = await runCommand3(["test", "-x", candidate]);
16959
+ if (exitCode === 0)
16960
+ return candidate;
16961
+ }
16962
+ return null;
16963
+ }
16964
+ async function resolveAdb() {
16965
+ const sdk = androidSdkRoot();
16966
+ return resolveTool([...sdk ? [join6(sdk, "platform-tools/adb")] : [], "adb"]);
16967
+ }
16968
+ async function resolveEmulator() {
16969
+ const sdk = androidSdkRoot();
16970
+ return resolveTool([...sdk ? [join6(sdk, "emulator/emulator")] : [], "emulator"]);
16971
+ }
16972
+ function formatIosVersion(version2) {
16973
+ const trimmed = version2.trim();
16974
+ if (!trimmed)
16975
+ return "iOS";
16976
+ if (/^iOS\b/i.test(trimmed))
16977
+ return trimmed;
16978
+ return `iOS ${trimmed}`;
16979
+ }
16980
+ function formatAndroidVersion(apiOrVersion) {
16981
+ const trimmed = apiOrVersion.trim();
16982
+ if (!trimmed)
16983
+ return "Android";
16984
+ if (/^Android\b/i.test(trimmed))
16985
+ return trimmed;
16986
+ if (/^API\b/i.test(trimmed))
16987
+ return trimmed;
16988
+ if (/^\d+(\.\d+)?$/.test(trimmed))
16989
+ return `API ${trimmed}`;
16990
+ return `Android ${trimmed}`;
16991
+ }
16992
+ function titleCaseWords(value) {
16993
+ return value.split(/\s+/).filter(Boolean).map((word) => {
16994
+ if (/^[a-z]$/i.test(word))
16995
+ return word.toUpperCase();
16996
+ if (/^\d/.test(word))
16997
+ return word;
16998
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
16999
+ }).join(" ");
17000
+ }
17001
+ function runtimeVersionFromIdentifier(runtimeId) {
17002
+ const match = runtimeId.match(/iOS-([\d-]+)$/i);
17003
+ if (!match?.[1])
17004
+ return "iOS";
17005
+ return formatIosVersion(match[1].replace(/-/g, "."));
17006
+ }
17007
+ async function listIosSimulators(includeUnavailable) {
17008
+ const { stdout, exitCode } = await runCommand3([
17009
+ "xcrun",
17010
+ "simctl",
17011
+ "list",
17012
+ "devices",
17013
+ "available",
17014
+ "-j"
17015
+ ]);
17016
+ if (exitCode !== 0)
17017
+ return [];
17018
+ let parsed;
17019
+ try {
17020
+ parsed = JSON.parse(stdout);
17021
+ } catch {
17022
+ return [];
17023
+ }
17024
+ const devices = [];
17025
+ for (const [runtimeId, runtimeDevices] of Object.entries(parsed.devices ?? {})) {
17026
+ if (!/iOS/i.test(runtimeId))
17027
+ continue;
17028
+ const osVersion = runtimeVersionFromIdentifier(runtimeId);
17029
+ for (const device of runtimeDevices) {
17030
+ if (!device.udid || !device.name)
17031
+ continue;
17032
+ if (device.isAvailable === false)
17033
+ continue;
17034
+ const state = device.state ?? "Unknown";
17035
+ if (!includeUnavailable && state !== "Booted")
17036
+ continue;
17037
+ devices.push({
17038
+ id: device.udid,
17039
+ name: device.name,
17040
+ osVersion,
17041
+ platform: "ios",
17042
+ kind: "simulator",
17043
+ state,
17044
+ model: device.deviceTypeIdentifier?.split(".").pop()?.replace(/-/g, " ")
17045
+ });
17046
+ }
17047
+ }
17048
+ return devices.sort((a, b) => {
17049
+ if (a.state === "Booted" && b.state !== "Booted")
17050
+ return -1;
17051
+ if (b.state === "Booted" && a.state !== "Booted")
17052
+ return 1;
17053
+ return a.name.localeCompare(b.name);
17054
+ });
17055
+ }
17056
+ function isPhoneOrTablet(deviceType) {
17057
+ if (!deviceType)
17058
+ return false;
17059
+ return /^(iPhone|iPad)$/i.test(deviceType);
17060
+ }
17061
+ function physicalIosState(connection) {
17062
+ const tunnel = connection?.tunnelState?.toLowerCase() ?? "";
17063
+ if (tunnel.includes("connected"))
17064
+ return "connected";
17065
+ if (tunnel.includes("unavailable"))
17066
+ return "unavailable";
17067
+ if (tunnel.includes("disconnected"))
17068
+ return "disconnected";
17069
+ if (connection?.pairingState === "paired")
17070
+ return "paired";
17071
+ return tunnel || connection?.pairingState || "unknown";
17072
+ }
17073
+ async function listIosPhysicalDevices(includeUnavailable) {
17074
+ const dir = await mkdtemp2(join6(tmpdir2(), "yoqa-devices-"));
17075
+ const jsonPath = join6(dir, "devices.json");
17076
+ try {
17077
+ const { exitCode } = await runCommand3([
17078
+ "xcrun",
17079
+ "devicectl",
17080
+ "list",
17081
+ "devices",
17082
+ "--json-output",
17083
+ jsonPath
17084
+ ]);
17085
+ if (exitCode !== 0)
17086
+ return [];
17087
+ let parsed;
17088
+ try {
17089
+ parsed = JSON.parse(await readFile(jsonPath, "utf8"));
17090
+ } catch {
17091
+ return [];
17092
+ }
17093
+ const devices = [];
17094
+ for (const item of parsed.result?.devices ?? []) {
17095
+ const hardware = item.hardwareProperties;
17096
+ const props = item.deviceProperties;
17097
+ if (!hardware?.udid)
17098
+ continue;
17099
+ if (hardware.reality && hardware.reality !== "physical")
17100
+ continue;
17101
+ if (!isPhoneOrTablet(hardware.deviceType))
17102
+ continue;
17103
+ if (hardware.platform && !/^iOS$/i.test(hardware.platform))
17104
+ continue;
17105
+ const state = physicalIosState(item.connectionProperties);
17106
+ if (!includeUnavailable && state === "unavailable")
17107
+ continue;
17108
+ const modelName = hardware.marketingName ?? hardware.productType ?? "iPhone";
17109
+ const owner = props?.name?.trim() || undefined;
17110
+ devices.push({
17111
+ id: hardware.udid,
17112
+ name: modelName,
17113
+ owner,
17114
+ osVersion: formatIosVersion(props?.osVersionNumber ?? ""),
17115
+ platform: "ios",
17116
+ kind: "physical",
17117
+ state,
17118
+ model: hardware.productType
17119
+ });
17120
+ }
17121
+ return devices.sort((a, b) => a.name.localeCompare(b.name));
17122
+ } finally {
17123
+ await rm2(dir, { recursive: true, force: true });
17124
+ }
17125
+ }
17126
+ async function listIosDevices(options = {}) {
17127
+ const includeUnavailable = options.includeUnavailable ?? true;
17128
+ const [physical, simulators] = await Promise.all([
17129
+ listIosPhysicalDevices(includeUnavailable),
17130
+ listIosSimulators(includeUnavailable)
17131
+ ]);
17132
+ return [...physical, ...simulators];
17133
+ }
17134
+ function parseAdbDevices(output) {
17135
+ const lines = output.split(`
17136
+ `).map((line) => line.trim()).filter(Boolean);
17137
+ const results = [];
17138
+ for (const line of lines) {
17139
+ if (line.startsWith("List of devices"))
17140
+ continue;
17141
+ const parts = line.split(/\s+/);
17142
+ const serial = parts[0];
17143
+ const state = parts[1];
17144
+ if (!serial || !state)
17145
+ continue;
17146
+ const meta = {};
17147
+ for (const part of parts.slice(2)) {
17148
+ const eq2 = part.indexOf(":");
17149
+ if (eq2 <= 0)
17150
+ continue;
17151
+ meta[part.slice(0, eq2)] = part.slice(eq2 + 1);
17152
+ }
17153
+ results.push({
17154
+ serial,
17155
+ state,
17156
+ product: meta.product,
17157
+ model: meta.model,
17158
+ device: meta.device,
17159
+ transportId: meta.transport_id
17160
+ });
17161
+ }
17162
+ return results;
17163
+ }
17164
+ function humanizeAvdName(avdName) {
17165
+ return titleCaseWords(avdName.replace(/_/g, " "));
17166
+ }
17167
+ function humanizeModel(model) {
17168
+ if (!model)
17169
+ return;
17170
+ return titleCaseWords(model.replace(/_/g, " "));
17171
+ }
17172
+ async function findAvdConfigPath(avdName) {
17173
+ const home = process.env.HOME ?? "";
17174
+ const roots = [join6(home, ".android/avd"), join6(home, "Library/Android/sdk/.android/avd")];
17175
+ for (const root of roots) {
17176
+ const direct = join6(root, `${avdName}.avd`, "config.ini");
17177
+ try {
17178
+ await readFile(direct, "utf8");
17179
+ return direct;
17180
+ } catch {}
17181
+ try {
17182
+ const glob = new Bun.Glob("*.avd/config.ini");
17183
+ for await (const match of glob.scan({ cwd: root, onlyFiles: true })) {
17184
+ const path = join6(root, match);
17185
+ const text2 = await readFile(path, "utf8");
17186
+ const idLine = text2.split(`
17187
+ `).find((line) => line.startsWith("AvdId="));
17188
+ const id = idLine?.slice("AvdId=".length).trim();
17189
+ if (id === avdName)
17190
+ return path;
17191
+ }
17192
+ } catch {}
17193
+ }
17194
+ return null;
17195
+ }
17196
+ async function readAvdConfig(avdName) {
17197
+ const path = await findAvdConfigPath(avdName);
17198
+ if (!path)
17199
+ return { api: null, deviceName: null };
17200
+ try {
17201
+ const text2 = await readFile(path, "utf8");
17202
+ const imageLine = text2.split(`
17203
+ `).find((line) => line.includes("system-images") && line.includes("android-"));
17204
+ const apiMatch = imageLine?.match(/android-([\d.]+)/);
17205
+ const deviceLine = text2.split(`
17206
+ `).find((entry) => entry.startsWith("hw.device.name="));
17207
+ const deviceRaw = deviceLine?.slice("hw.device.name=".length).trim();
17208
+ return {
17209
+ api: apiMatch?.[1] ?? null,
17210
+ deviceName: humanizeModel(deviceRaw) ?? null
17211
+ };
17212
+ } catch {
17213
+ return { api: null, deviceName: null };
17214
+ }
17215
+ }
17216
+ async function adbAvdName(adb, serial) {
17217
+ const fromProp = await adbGetProp(adb, serial, "ro.boot.qemu.avd_name") ?? await adbGetProp(adb, serial, "ro.kernel.qemu.avd_name");
17218
+ if (fromProp)
17219
+ return fromProp;
17220
+ const { stdout, exitCode } = await runCommand3([adb, "-s", serial, "emu", "avd", "name"]);
17221
+ if (exitCode !== 0)
17222
+ return null;
17223
+ return parseAdbEmuAvdName(stdout);
17224
+ }
17225
+ async function adbGetProp(adb, serial, prop) {
17226
+ const { stdout, exitCode } = await runCommand3([adb, "-s", serial, "shell", "getprop", prop]);
17227
+ if (exitCode !== 0)
17228
+ return null;
17229
+ const value = stdout.trim();
17230
+ return value || null;
17231
+ }
17232
+ async function listAndroidAdbDevices(adb) {
17233
+ const { stdout, exitCode } = await runCommand3([adb, "devices", "-l"]);
17234
+ if (exitCode !== 0) {
17235
+ return { physical: [], runningEmulators: [], runningAvdNames: new Set };
17236
+ }
17237
+ const entries = parseAdbDevices(stdout);
17238
+ const physical = [];
17239
+ const runningEmulators = [];
17240
+ const runningAvdNames = new Set;
17241
+ for (const entry of entries) {
17242
+ const isEmulator = entry.serial.startsWith("emulator-") || entry.product === "sdk_gphone";
17243
+ if (isEmulator) {
17244
+ const avdName = await adbAvdName(adb, entry.serial);
17245
+ if (avdName)
17246
+ runningAvdNames.add(avdName);
17247
+ const avdConfig = avdName ? await readAvdConfig(avdName) : null;
17248
+ const release2 = await adbGetProp(adb, entry.serial, "ro.build.version.release") ?? avdConfig?.api ?? null;
17249
+ const model2 = humanizeModel(entry.model) ?? avdConfig?.deviceName ?? (avdName ? humanizeAvdName(avdName) : entry.serial);
17250
+ runningEmulators.push({
17251
+ id: avdName ?? entry.serial,
17252
+ name: model2,
17253
+ osVersion: formatAndroidVersion(release2 ?? ""),
17254
+ platform: "android",
17255
+ kind: "emulator",
17256
+ state: entry.state === "device" ? "online" : entry.state,
17257
+ model: avdName ?? entry.model
17258
+ });
17259
+ continue;
17260
+ }
17261
+ const release = await adbGetProp(adb, entry.serial, "ro.build.version.release");
17262
+ const model = humanizeModel(entry.model) ?? await adbGetProp(adb, entry.serial, "ro.product.model") ?? entry.serial;
17263
+ physical.push({
17264
+ id: entry.serial,
17265
+ name: model,
17266
+ osVersion: formatAndroidVersion(release ?? ""),
17267
+ platform: "android",
17268
+ kind: "physical",
17269
+ state: entry.state,
17270
+ model: entry.model
17271
+ });
17272
+ }
17273
+ return { physical, runningEmulators, runningAvdNames };
17274
+ }
17275
+ async function listAndroidAvds(emulatorBin, runningAvdNames, includeUnavailable) {
17276
+ const { stdout, exitCode } = await runCommand3([emulatorBin, "-list-avds"]);
17277
+ if (exitCode !== 0)
17278
+ return [];
17279
+ const names = stdout.split(`
17280
+ `).map((line) => line.trim()).filter(Boolean);
17281
+ const devices = [];
17282
+ for (const avdName of names) {
17283
+ const isRunning = runningAvdNames.has(avdName);
17284
+ if (!includeUnavailable && !isRunning)
17285
+ continue;
17286
+ if (isRunning)
17287
+ continue;
17288
+ const { api, deviceName } = await readAvdConfig(avdName);
17289
+ devices.push({
17290
+ id: avdName,
17291
+ name: deviceName ?? humanizeAvdName(avdName),
17292
+ osVersion: formatAndroidVersion(api ?? ""),
17293
+ platform: "android",
17294
+ kind: "emulator",
17295
+ state: "offline",
17296
+ model: avdName
17297
+ });
17298
+ }
17299
+ return devices;
17300
+ }
17301
+ async function resolveAndroidAppiumIdentity(deviceId) {
17302
+ const [adb, emulatorBin] = await Promise.all([resolveAdb(), resolveEmulator()]);
17303
+ const connected = [];
17304
+ if (adb) {
17305
+ const { stdout, exitCode } = await runCommand3([adb, "devices", "-l"]);
17306
+ if (exitCode === 0) {
17307
+ for (const entry of parseAdbDevices(stdout)) {
17308
+ const isEmulator = entry.serial.startsWith("emulator-") || entry.product === "sdk_gphone";
17309
+ connected.push({
17310
+ serial: entry.serial,
17311
+ avdName: isEmulator ? await adbAvdName(adb, entry.serial) : null
17312
+ });
17313
+ }
17314
+ }
17315
+ }
17316
+ const knownAvds = [];
17317
+ if (emulatorBin) {
17318
+ const { stdout, exitCode } = await runCommand3([emulatorBin, "-list-avds"]);
17319
+ if (exitCode === 0) {
17320
+ knownAvds.push(...stdout.split(`
17321
+ `).map((line) => line.trim()).filter(Boolean));
17322
+ }
17323
+ }
17324
+ return matchAndroidAppiumIdentity(deviceId, connected, knownAvds);
17325
+ }
17326
+ async function listAndroidDevices(options = {}) {
17327
+ const includeUnavailable = options.includeUnavailable ?? true;
17328
+ const [adb, emulatorBin] = await Promise.all([resolveAdb(), resolveEmulator()]);
17329
+ const adbResult = adb ? await listAndroidAdbDevices(adb) : { physical: [], runningEmulators: [], runningAvdNames: new Set };
17330
+ const offlineAvds = emulatorBin != null ? await listAndroidAvds(emulatorBin, adbResult.runningAvdNames, includeUnavailable) : [];
17331
+ const physical = includeUnavailable ? adbResult.physical : adbResult.physical.filter((device) => device.state === "device");
17332
+ const emulators = [...adbResult.runningEmulators, ...offlineAvds].sort((a, b) => {
17333
+ const aOnline = a.state === "online" ? 0 : 1;
17334
+ const bOnline = b.state === "online" ? 0 : 1;
17335
+ if (aOnline !== bOnline)
17336
+ return aOnline - bOnline;
17337
+ return a.name.localeCompare(b.name);
17338
+ });
17339
+ return [...physical, ...emulators];
17340
+ }
17341
+ async function listDevices(platform, options = {}) {
17342
+ if (platform === "ios")
17343
+ return listIosDevices(options);
17344
+ return listAndroidDevices(options);
17345
+ }
17346
+
17347
+ // src/domains/devices/session.ts
17348
+ var YOQA_ROOT4 = join7(homedir6(), ".yoqa");
16902
17349
  var DEFAULT_MJPEG_PORT = Number(process.env.YOQA_MJPEG_PORT ?? "9100");
16903
- var SCREENSHOT_DIR = join6(YOQA_ROOT4, "runs", "screenshots");
17350
+ var SCREENSHOT_DIR = join7(YOQA_ROOT4, "runs", "screenshots");
16904
17351
  var MJPEG_SETTINGS_BASE = {
16905
17352
  mjpegServerScreenshotQuality: 35,
16906
17353
  mjpegScalingFactor: 50
@@ -17020,8 +17467,11 @@ function mergeCapabilities(appCaps, caseCaps) {
17020
17467
  function looksLikePhysicalIosUdid(udid) {
17021
17468
  return /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{16}$/.test(udid.trim());
17022
17469
  }
17470
+ var PHYSICAL_IOS_SESSION_TIMEOUT_MS = 60000;
17471
+ var SIMULATOR_WDA_SESSION_TIMEOUT_MS = 600000;
17472
+ var SIMULATOR_WDA_DERIVED_DATA = join7(YOQA_ROOT4, "wda-sim");
17023
17473
  function iosSessionCreateTimeoutMs(deviceId) {
17024
- return looksLikePhysicalIosUdid(deviceId) ? 60000 : 240000;
17474
+ return looksLikePhysicalIosUdid(deviceId) ? PHYSICAL_IOS_SESSION_TIMEOUT_MS : SIMULATOR_WDA_SESSION_TIMEOUT_MS;
17025
17475
  }
17026
17476
  async function buildW3cCapabilities(options, mjpegPort) {
17027
17477
  const merged = mergeCapabilities(options.appCaps, options.caseCaps);
@@ -17030,18 +17480,17 @@ async function buildW3cCapabilities(options, mjpegPort) {
17030
17480
  const caps = {
17031
17481
  platformName,
17032
17482
  "appium:automationName": automationName,
17033
- "appium:udid": options.deviceId,
17034
17483
  "appium:newCommandTimeout": 3600,
17035
17484
  "appium:mjpegServerPort": mjpegPort,
17036
17485
  ...Object.fromEntries(Object.entries(merged).map(([key, value]) => key.includes(":") ? [key, value] : [`appium:${key}`, value]))
17037
17486
  };
17038
- if (options.platform === "ios" && options.bundleId && !caps["appium:bundleId"]) {
17039
- caps["appium:bundleId"] = options.bundleId;
17040
- }
17041
- if (options.platform === "android" && options.appPackage && !caps["appium:appPackage"]) {
17042
- caps["appium:appPackage"] = options.appPackage;
17043
- }
17044
17487
  if (options.platform === "ios") {
17488
+ if (caps["appium:udid"] === undefined) {
17489
+ caps["appium:udid"] = options.deviceId;
17490
+ }
17491
+ if (options.bundleId && !caps["appium:bundleId"]) {
17492
+ caps["appium:bundleId"] = options.bundleId;
17493
+ }
17045
17494
  const physical = looksLikePhysicalIosUdid(options.deviceId);
17046
17495
  if (physical) {
17047
17496
  const prep = await loadDevicePrep(options.deviceId);
@@ -17071,6 +17520,23 @@ async function buildW3cCapabilities(options, mjpegPort) {
17071
17520
  if (caps["appium:waitForIdleTimeout"] === undefined) {
17072
17521
  caps["appium:waitForIdleTimeout"] = 0;
17073
17522
  }
17523
+ if (!physical && caps["appium:derivedDataPath"] === undefined) {
17524
+ await mkdir3(SIMULATOR_WDA_DERIVED_DATA, { recursive: true });
17525
+ caps["appium:derivedDataPath"] = SIMULATOR_WDA_DERIVED_DATA;
17526
+ }
17527
+ }
17528
+ if (options.platform === "android") {
17529
+ const requested = String(caps["appium:udid"] ?? options.deviceId);
17530
+ const identity = await resolveAndroidAppiumIdentity(requested);
17531
+ if (identity.udid) {
17532
+ caps["appium:udid"] = identity.udid;
17533
+ }
17534
+ if (identity.avd && caps["appium:avd"] === undefined) {
17535
+ caps["appium:avd"] = identity.avd;
17536
+ }
17537
+ if (options.appPackage && !caps["appium:appPackage"]) {
17538
+ caps["appium:appPackage"] = options.appPackage;
17539
+ }
17074
17540
  }
17075
17541
  return caps;
17076
17542
  }
@@ -17362,7 +17828,7 @@ async function createDeviceSession(options) {
17362
17828
  const screenshot = async () => {
17363
17829
  await mkdir3(SCREENSHOT_DIR, { recursive: true });
17364
17830
  const frame = await captureFrame();
17365
- const path = join6(SCREENSHOT_DIR, `shot_${Date.now()}_${crypto.randomUUID()}.png`);
17831
+ const path = join7(SCREENSHOT_DIR, `shot_${Date.now()}_${crypto.randomUUID()}.png`);
17366
17832
  await Bun.write(path, Uint8Array.from(Buffer.from(frame.base64, "base64")));
17367
17833
  return { path, base64: frame.base64 };
17368
17834
  };
@@ -23551,13 +24017,24 @@ var caseFlowStepSchema = exports_external.object({
23551
24017
  flowId: exports_external.string().min(1).nullable().optional()
23552
24018
  });
23553
24019
  var caseRunStatusSchema = exports_external.union([exports_external.literal("passed"), exports_external.literal("errored")]);
23554
- var caseScriptActionSchema = exports_external.discriminatedUnion("type", [
23555
- exports_external.object({
23556
- type: exports_external.literal("tap"),
23557
- x: exports_external.number().min(0).max(1000),
23558
- y: exports_external.number().min(0).max(1000),
23559
- reason: exports_external.string().optional()
23560
- }),
24020
+ var caseScriptTapSchema = exports_external.object({
24021
+ type: exports_external.literal("tap"),
24022
+ x: exports_external.number().min(0).max(1000).optional(),
24023
+ y: exports_external.number().min(0).max(1000).optional(),
24024
+ label: exports_external.string().min(1).optional(),
24025
+ id: exports_external.string().min(1).optional(),
24026
+ reason: exports_external.string().optional()
24027
+ }).superRefine((value, ctx) => {
24028
+ const hasCoords = value.x != null && value.y != null;
24029
+ if (!hasCoords && !value.label && !value.id) {
24030
+ ctx.addIssue({
24031
+ code: exports_external.ZodIssueCode.custom,
24032
+ message: "tap requires x/y or label or id"
24033
+ });
24034
+ }
24035
+ });
24036
+ var caseScriptActionSchema = exports_external.union([
24037
+ caseScriptTapSchema,
23561
24038
  exports_external.object({
23562
24039
  type: exports_external.literal("type"),
23563
24040
  text: exports_external.string(),
@@ -23567,6 +24044,13 @@ var caseScriptActionSchema = exports_external.discriminatedUnion("type", [
23567
24044
  type: exports_external.literal("wait"),
23568
24045
  ms: exports_external.number().min(0).max(1e4),
23569
24046
  reason: exports_external.string().optional()
24047
+ }),
24048
+ exports_external.object({
24049
+ type: exports_external.literal("assert"),
24050
+ assertion: exports_external.union([exports_external.literal("visible"), exports_external.literal("not-visible")]).default("visible"),
24051
+ text: exports_external.string().min(1),
24052
+ timeoutMs: exports_external.number().min(0).max(120000).optional(),
24053
+ reason: exports_external.string().optional()
23570
24054
  })
23571
24055
  ]);
23572
24056
  var caseScriptSchema = exports_external.object({
@@ -23975,6 +24459,9 @@ var createBuildRequestSchema = exports_external.object({
23975
24459
  var listBuildsResponseSchema = exports_external.object({
23976
24460
  builds: exports_external.array(buildSchema)
23977
24461
  });
24462
+
24463
+ // ../../packages/runner-client/src/wait-for-run.ts
24464
+ var DEFAULT_WAIT_FOR_RUN_TIMEOUT_MS = 30 * 60 * 1000;
23978
24465
  // ../../packages/runner-client/src/shell-script.ts
23979
24466
  var DEFAULT_SHELL_SCRIPT_HEADER = [
23980
24467
  "#!/usr/bin/env bash",
@@ -23984,6 +24471,16 @@ var DEFAULT_SHELL_SCRIPT_HEADER = [
23984
24471
  ""
23985
24472
  ].join(`
23986
24473
  `);
24474
+ function screenHasText(elements, text2) {
24475
+ const needle = text2.trim().toLowerCase();
24476
+ if (!needle)
24477
+ return false;
24478
+ return (elements ?? []).some((el) => {
24479
+ const label = el.label?.toLowerCase() ?? "";
24480
+ const type = el.type?.toLowerCase() ?? "";
24481
+ return label.includes(needle) || type.includes(needle);
24482
+ });
24483
+ }
23987
24484
  function findElementByLabel(elements, label) {
23988
24485
  const needle = label.trim().toLowerCase();
23989
24486
  if (!needle)
@@ -24155,8 +24652,13 @@ async function installBuildOnDevice(options) {
24155
24652
  }
24156
24653
  throw new Error(`Cannot install iOS build at ${build.path}. Use a .app on simulator or install .ipa via ideviceinstaller.`);
24157
24654
  }
24655
+ const identity = await resolveAndroidAppiumIdentity(deviceId);
24656
+ const serial = identity.udid;
24657
+ if (!serial) {
24658
+ throw new Error(`Android emulator ${deviceId} is not running. Boot it, then retry the install.`);
24659
+ }
24158
24660
  const adb = Bun.which("adb") ?? "adb";
24159
- const proc = Bun.spawn([adb, "-s", deviceId, "install", "-r", build.path], {
24661
+ const proc = Bun.spawn([adb, "-s", serial, "install", "-r", build.path], {
24160
24662
  stdout: "pipe",
24161
24663
  stderr: "pipe"
24162
24664
  });
@@ -24979,397 +25481,6 @@ function createCatalogRoutes() {
24979
25481
  return app;
24980
25482
  }
24981
25483
 
24982
- // src/domains/devices/application.ts
24983
- import { mkdtemp as mkdtemp2, readFile, rm as rm2 } from "fs/promises";
24984
- import { tmpdir as tmpdir2 } from "os";
24985
- import { join as join7 } from "path";
24986
- async function runCommand3(command, options) {
24987
- try {
24988
- const proc = Bun.spawn(command, {
24989
- env: options?.env ? { ...process.env, ...options.env } : process.env,
24990
- stdout: "pipe",
24991
- stderr: "pipe"
24992
- });
24993
- const [stdout, stderr, exitCode] = await Promise.all([
24994
- new Response(proc.stdout).text(),
24995
- new Response(proc.stderr).text(),
24996
- proc.exited
24997
- ]);
24998
- return { stdout, stderr, exitCode };
24999
- } catch {
25000
- return { stdout: "", stderr: `failed to spawn: ${command[0]}`, exitCode: 127 };
25001
- }
25002
- }
25003
- function androidSdkRoot() {
25004
- return resolveAndroidSdkRoot();
25005
- }
25006
- async function resolveTool(candidates) {
25007
- for (const candidate of candidates) {
25008
- if (!candidate)
25009
- continue;
25010
- const { exitCode } = await runCommand3(["test", "-x", candidate]);
25011
- if (exitCode === 0)
25012
- return candidate;
25013
- }
25014
- return null;
25015
- }
25016
- async function resolveAdb() {
25017
- const sdk = androidSdkRoot();
25018
- return resolveTool([...sdk ? [join7(sdk, "platform-tools/adb")] : [], "adb"]);
25019
- }
25020
- async function resolveEmulator() {
25021
- const sdk = androidSdkRoot();
25022
- return resolveTool([...sdk ? [join7(sdk, "emulator/emulator")] : [], "emulator"]);
25023
- }
25024
- function formatIosVersion(version2) {
25025
- const trimmed = version2.trim();
25026
- if (!trimmed)
25027
- return "iOS";
25028
- if (/^iOS\b/i.test(trimmed))
25029
- return trimmed;
25030
- return `iOS ${trimmed}`;
25031
- }
25032
- function formatAndroidVersion(apiOrVersion) {
25033
- const trimmed = apiOrVersion.trim();
25034
- if (!trimmed)
25035
- return "Android";
25036
- if (/^Android\b/i.test(trimmed))
25037
- return trimmed;
25038
- if (/^API\b/i.test(trimmed))
25039
- return trimmed;
25040
- if (/^\d+(\.\d+)?$/.test(trimmed))
25041
- return `API ${trimmed}`;
25042
- return `Android ${trimmed}`;
25043
- }
25044
- function titleCaseWords(value) {
25045
- return value.split(/\s+/).filter(Boolean).map((word) => {
25046
- if (/^[a-z]$/i.test(word))
25047
- return word.toUpperCase();
25048
- if (/^\d/.test(word))
25049
- return word;
25050
- return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
25051
- }).join(" ");
25052
- }
25053
- function runtimeVersionFromIdentifier(runtimeId) {
25054
- const match2 = runtimeId.match(/iOS-([\d-]+)$/i);
25055
- if (!match2?.[1])
25056
- return "iOS";
25057
- return formatIosVersion(match2[1].replace(/-/g, "."));
25058
- }
25059
- async function listIosSimulators(includeUnavailable) {
25060
- const { stdout, exitCode } = await runCommand3([
25061
- "xcrun",
25062
- "simctl",
25063
- "list",
25064
- "devices",
25065
- "available",
25066
- "-j"
25067
- ]);
25068
- if (exitCode !== 0)
25069
- return [];
25070
- let parsed;
25071
- try {
25072
- parsed = JSON.parse(stdout);
25073
- } catch {
25074
- return [];
25075
- }
25076
- const devices = [];
25077
- for (const [runtimeId, runtimeDevices] of Object.entries(parsed.devices ?? {})) {
25078
- if (!/iOS/i.test(runtimeId))
25079
- continue;
25080
- const osVersion = runtimeVersionFromIdentifier(runtimeId);
25081
- for (const device of runtimeDevices) {
25082
- if (!device.udid || !device.name)
25083
- continue;
25084
- if (device.isAvailable === false)
25085
- continue;
25086
- const state = device.state ?? "Unknown";
25087
- if (!includeUnavailable && state !== "Booted")
25088
- continue;
25089
- devices.push({
25090
- id: device.udid,
25091
- name: device.name,
25092
- osVersion,
25093
- platform: "ios",
25094
- kind: "simulator",
25095
- state,
25096
- model: device.deviceTypeIdentifier?.split(".").pop()?.replace(/-/g, " ")
25097
- });
25098
- }
25099
- }
25100
- return devices.sort((a, b) => {
25101
- if (a.state === "Booted" && b.state !== "Booted")
25102
- return -1;
25103
- if (b.state === "Booted" && a.state !== "Booted")
25104
- return 1;
25105
- return a.name.localeCompare(b.name);
25106
- });
25107
- }
25108
- function isPhoneOrTablet(deviceType) {
25109
- if (!deviceType)
25110
- return false;
25111
- return /^(iPhone|iPad)$/i.test(deviceType);
25112
- }
25113
- function physicalIosState(connection) {
25114
- const tunnel = connection?.tunnelState?.toLowerCase() ?? "";
25115
- if (tunnel.includes("connected"))
25116
- return "connected";
25117
- if (tunnel.includes("unavailable"))
25118
- return "unavailable";
25119
- if (tunnel.includes("disconnected"))
25120
- return "disconnected";
25121
- if (connection?.pairingState === "paired")
25122
- return "paired";
25123
- return tunnel || connection?.pairingState || "unknown";
25124
- }
25125
- async function listIosPhysicalDevices(includeUnavailable) {
25126
- const dir = await mkdtemp2(join7(tmpdir2(), "yoqa-devices-"));
25127
- const jsonPath = join7(dir, "devices.json");
25128
- try {
25129
- const { exitCode } = await runCommand3([
25130
- "xcrun",
25131
- "devicectl",
25132
- "list",
25133
- "devices",
25134
- "--json-output",
25135
- jsonPath
25136
- ]);
25137
- if (exitCode !== 0)
25138
- return [];
25139
- let parsed;
25140
- try {
25141
- parsed = JSON.parse(await readFile(jsonPath, "utf8"));
25142
- } catch {
25143
- return [];
25144
- }
25145
- const devices = [];
25146
- for (const item of parsed.result?.devices ?? []) {
25147
- const hardware = item.hardwareProperties;
25148
- const props = item.deviceProperties;
25149
- if (!hardware?.udid)
25150
- continue;
25151
- if (hardware.reality && hardware.reality !== "physical")
25152
- continue;
25153
- if (!isPhoneOrTablet(hardware.deviceType))
25154
- continue;
25155
- if (hardware.platform && !/^iOS$/i.test(hardware.platform))
25156
- continue;
25157
- const state = physicalIosState(item.connectionProperties);
25158
- if (!includeUnavailable && state === "unavailable")
25159
- continue;
25160
- const modelName = hardware.marketingName ?? hardware.productType ?? "iPhone";
25161
- const owner = props?.name?.trim() || undefined;
25162
- devices.push({
25163
- id: hardware.udid,
25164
- name: modelName,
25165
- owner,
25166
- osVersion: formatIosVersion(props?.osVersionNumber ?? ""),
25167
- platform: "ios",
25168
- kind: "physical",
25169
- state,
25170
- model: hardware.productType
25171
- });
25172
- }
25173
- return devices.sort((a, b) => a.name.localeCompare(b.name));
25174
- } finally {
25175
- await rm2(dir, { recursive: true, force: true });
25176
- }
25177
- }
25178
- async function listIosDevices(options = {}) {
25179
- const includeUnavailable = options.includeUnavailable ?? true;
25180
- const [physical, simulators] = await Promise.all([
25181
- listIosPhysicalDevices(includeUnavailable),
25182
- listIosSimulators(includeUnavailable)
25183
- ]);
25184
- return [...physical, ...simulators];
25185
- }
25186
- function parseAdbDevices(output) {
25187
- const lines = output.split(`
25188
- `).map((line) => line.trim()).filter(Boolean);
25189
- const results = [];
25190
- for (const line of lines) {
25191
- if (line.startsWith("List of devices"))
25192
- continue;
25193
- const parts = line.split(/\s+/);
25194
- const serial = parts[0];
25195
- const state = parts[1];
25196
- if (!serial || !state)
25197
- continue;
25198
- const meta = {};
25199
- for (const part of parts.slice(2)) {
25200
- const eq2 = part.indexOf(":");
25201
- if (eq2 <= 0)
25202
- continue;
25203
- meta[part.slice(0, eq2)] = part.slice(eq2 + 1);
25204
- }
25205
- results.push({
25206
- serial,
25207
- state,
25208
- product: meta.product,
25209
- model: meta.model,
25210
- device: meta.device,
25211
- transportId: meta.transport_id
25212
- });
25213
- }
25214
- return results;
25215
- }
25216
- function humanizeAvdName(avdName) {
25217
- return titleCaseWords(avdName.replace(/_/g, " "));
25218
- }
25219
- function humanizeModel(model) {
25220
- if (!model)
25221
- return;
25222
- return titleCaseWords(model.replace(/_/g, " "));
25223
- }
25224
- async function findAvdConfigPath(avdName) {
25225
- const home = process.env.HOME ?? "";
25226
- const roots = [join7(home, ".android/avd"), join7(home, "Library/Android/sdk/.android/avd")];
25227
- for (const root of roots) {
25228
- const direct = join7(root, `${avdName}.avd`, "config.ini");
25229
- try {
25230
- await readFile(direct, "utf8");
25231
- return direct;
25232
- } catch {}
25233
- try {
25234
- const glob = new Bun.Glob("*.avd/config.ini");
25235
- for await (const match2 of glob.scan({ cwd: root, onlyFiles: true })) {
25236
- const path = join7(root, match2);
25237
- const text2 = await readFile(path, "utf8");
25238
- const idLine = text2.split(`
25239
- `).find((line) => line.startsWith("AvdId="));
25240
- const id = idLine?.slice("AvdId=".length).trim();
25241
- if (id === avdName)
25242
- return path;
25243
- }
25244
- } catch {}
25245
- }
25246
- return null;
25247
- }
25248
- async function readAvdConfig(avdName) {
25249
- const path = await findAvdConfigPath(avdName);
25250
- if (!path)
25251
- return { api: null, deviceName: null };
25252
- try {
25253
- const text2 = await readFile(path, "utf8");
25254
- const imageLine = text2.split(`
25255
- `).find((line) => line.includes("system-images") && line.includes("android-"));
25256
- const apiMatch = imageLine?.match(/android-([\d.]+)/);
25257
- const deviceLine = text2.split(`
25258
- `).find((entry) => entry.startsWith("hw.device.name="));
25259
- const deviceRaw = deviceLine?.slice("hw.device.name=".length).trim();
25260
- return {
25261
- api: apiMatch?.[1] ?? null,
25262
- deviceName: humanizeModel(deviceRaw) ?? null
25263
- };
25264
- } catch {
25265
- return { api: null, deviceName: null };
25266
- }
25267
- }
25268
- async function adbAvdName(adb, serial) {
25269
- const { stdout, exitCode } = await runCommand3([adb, "-s", serial, "emu", "avd", "name"]);
25270
- if (exitCode !== 0)
25271
- return null;
25272
- const name = stdout.trim().split(`
25273
- `)[0]?.trim();
25274
- return name || null;
25275
- }
25276
- async function adbGetProp(adb, serial, prop) {
25277
- const { stdout, exitCode } = await runCommand3([adb, "-s", serial, "shell", "getprop", prop]);
25278
- if (exitCode !== 0)
25279
- return null;
25280
- const value = stdout.trim();
25281
- return value || null;
25282
- }
25283
- async function listAndroidAdbDevices(adb) {
25284
- const { stdout, exitCode } = await runCommand3([adb, "devices", "-l"]);
25285
- if (exitCode !== 0) {
25286
- return { physical: [], runningEmulators: [], runningAvdNames: new Set };
25287
- }
25288
- const entries = parseAdbDevices(stdout);
25289
- const physical = [];
25290
- const runningEmulators = [];
25291
- const runningAvdNames = new Set;
25292
- for (const entry of entries) {
25293
- const isEmulator = entry.serial.startsWith("emulator-") || entry.product === "sdk_gphone";
25294
- if (isEmulator) {
25295
- const avdName = await adbAvdName(adb, entry.serial);
25296
- if (avdName)
25297
- runningAvdNames.add(avdName);
25298
- const avdConfig = avdName ? await readAvdConfig(avdName) : null;
25299
- const release2 = await adbGetProp(adb, entry.serial, "ro.build.version.release") ?? avdConfig?.api ?? null;
25300
- const model2 = humanizeModel(entry.model) ?? avdConfig?.deviceName ?? (avdName ? humanizeAvdName(avdName) : entry.serial);
25301
- runningEmulators.push({
25302
- id: avdName ?? entry.serial,
25303
- name: model2,
25304
- osVersion: formatAndroidVersion(release2 ?? ""),
25305
- platform: "android",
25306
- kind: "emulator",
25307
- state: entry.state === "device" ? "online" : entry.state,
25308
- model: avdName ?? entry.model
25309
- });
25310
- continue;
25311
- }
25312
- const release = await adbGetProp(adb, entry.serial, "ro.build.version.release");
25313
- const model = humanizeModel(entry.model) ?? await adbGetProp(adb, entry.serial, "ro.product.model") ?? entry.serial;
25314
- physical.push({
25315
- id: entry.serial,
25316
- name: model,
25317
- osVersion: formatAndroidVersion(release ?? ""),
25318
- platform: "android",
25319
- kind: "physical",
25320
- state: entry.state,
25321
- model: entry.model
25322
- });
25323
- }
25324
- return { physical, runningEmulators, runningAvdNames };
25325
- }
25326
- async function listAndroidAvds(emulatorBin, runningAvdNames, includeUnavailable) {
25327
- const { stdout, exitCode } = await runCommand3([emulatorBin, "-list-avds"]);
25328
- if (exitCode !== 0)
25329
- return [];
25330
- const names = stdout.split(`
25331
- `).map((line) => line.trim()).filter(Boolean);
25332
- const devices = [];
25333
- for (const avdName of names) {
25334
- const isRunning = runningAvdNames.has(avdName);
25335
- if (!includeUnavailable && !isRunning)
25336
- continue;
25337
- if (isRunning)
25338
- continue;
25339
- const { api, deviceName } = await readAvdConfig(avdName);
25340
- devices.push({
25341
- id: avdName,
25342
- name: deviceName ?? humanizeAvdName(avdName),
25343
- osVersion: formatAndroidVersion(api ?? ""),
25344
- platform: "android",
25345
- kind: "emulator",
25346
- state: "offline",
25347
- model: avdName
25348
- });
25349
- }
25350
- return devices;
25351
- }
25352
- async function listAndroidDevices(options = {}) {
25353
- const includeUnavailable = options.includeUnavailable ?? true;
25354
- const [adb, emulatorBin] = await Promise.all([resolveAdb(), resolveEmulator()]);
25355
- const adbResult = adb ? await listAndroidAdbDevices(adb) : { physical: [], runningEmulators: [], runningAvdNames: new Set };
25356
- const offlineAvds = emulatorBin != null ? await listAndroidAvds(emulatorBin, adbResult.runningAvdNames, includeUnavailable) : [];
25357
- const physical = includeUnavailable ? adbResult.physical : adbResult.physical.filter((device) => device.state === "device");
25358
- const emulators = [...adbResult.runningEmulators, ...offlineAvds].sort((a, b) => {
25359
- const aOnline = a.state === "online" ? 0 : 1;
25360
- const bOnline = b.state === "online" ? 0 : 1;
25361
- if (aOnline !== bOnline)
25362
- return aOnline - bOnline;
25363
- return a.name.localeCompare(b.name);
25364
- });
25365
- return [...physical, ...emulators];
25366
- }
25367
- async function listDevices(platform, options = {}) {
25368
- if (platform === "ios")
25369
- return listIosDevices(options);
25370
- return listAndroidDevices(options);
25371
- }
25372
-
25373
25484
  // src/interfaces/http/devices.ts
25374
25485
  function createDevicesRoutes() {
25375
25486
  const app = new Hono2;
@@ -97429,6 +97540,10 @@ var defaultClock = {
97429
97540
  };
97430
97541
  async function executeScriptCase(deps) {
97431
97542
  const perform = deps.performAction ?? performAction;
97543
+ const readScreen = deps.readScreen ?? (async (session) => {
97544
+ const screen = await getScreen(session, { full: false });
97545
+ return { elements: screen.elements };
97546
+ });
97432
97547
  const clock = deps.clock ?? defaultClock;
97433
97548
  const settleMs = deps.settleMs ?? POST_ACTION_SETTLE_MS;
97434
97549
  let stepIdx = 0;
@@ -97446,7 +97561,16 @@ async function executeScriptCase(deps) {
97446
97561
  return "cancelled";
97447
97562
  }
97448
97563
  if (action.type === "tap") {
97449
- await perform(deps.session, { kind: "tap", x: action.x, y: action.y });
97564
+ const tapBody = { kind: "tap" };
97565
+ if (action.label)
97566
+ tapBody.label = action.label;
97567
+ if (action.id)
97568
+ tapBody.id = action.id;
97569
+ if (action.x != null)
97570
+ tapBody.x = action.x;
97571
+ if (action.y != null)
97572
+ tapBody.y = action.y;
97573
+ await perform(deps.session, tapBody);
97450
97574
  await clock.sleep(settleMs);
97451
97575
  await deps.appendStep({
97452
97576
  idx: stepIdx,
@@ -97454,13 +97578,15 @@ async function executeScriptCase(deps) {
97454
97578
  type: "tap",
97455
97579
  x: action.x,
97456
97580
  y: action.y,
97581
+ label: action.label,
97582
+ id: action.id,
97457
97583
  reason: action.reason ?? "Replayed saved script tap",
97458
97584
  thoughts: "Replaying the saved script without calling the AI agent."
97459
97585
  },
97460
97586
  screenshotUri: shot.path,
97461
97587
  ok: true,
97462
97588
  latencyMs,
97463
- detail: action.reason ?? null
97589
+ detail: action.reason ?? action.label ?? action.id ?? null
97464
97590
  });
97465
97591
  } else if (action.type === "type") {
97466
97592
  await perform(deps.session, { kind: "input", text: action.text });
@@ -97478,6 +97604,40 @@ async function executeScriptCase(deps) {
97478
97604
  latencyMs,
97479
97605
  detail: action.reason ?? null
97480
97606
  });
97607
+ } else if (action.type === "assert") {
97608
+ const timeoutMs = action.timeoutMs ?? 5000;
97609
+ const assertion = action.assertion;
97610
+ const deadline = clock.now() + timeoutMs;
97611
+ for (;; ) {
97612
+ if (deps.isAborted()) {
97613
+ return "cancelled";
97614
+ }
97615
+ const screen = await readScreen(deps.session);
97616
+ const found = screenHasText(screen.elements, action.text);
97617
+ if (assertion === "visible" && found)
97618
+ break;
97619
+ if (assertion === "not-visible" && !found)
97620
+ break;
97621
+ if (clock.now() >= deadline) {
97622
+ throw new Error(assertion === "visible" ? `Expected visible text not found within ${Math.round(timeoutMs / 1000)}s: ${action.text}` : `Unexpected text still visible after ${Math.round(timeoutMs / 1000)}s: ${action.text}`);
97623
+ }
97624
+ await clock.sleep(400);
97625
+ }
97626
+ await deps.appendStep({
97627
+ idx: stepIdx,
97628
+ action: {
97629
+ type: "assert",
97630
+ assertion,
97631
+ text: action.text,
97632
+ timeoutMs,
97633
+ reason: action.reason ?? `Assert ${assertion}: ${action.text}`,
97634
+ thoughts: "Replaying the saved script without calling the AI agent."
97635
+ },
97636
+ screenshotUri: shot.path,
97637
+ ok: true,
97638
+ latencyMs,
97639
+ detail: action.reason ?? `${assertion}: ${action.text}`
97640
+ });
97481
97641
  } else {
97482
97642
  const waitMs = Math.min(3000, Math.max(500, action.ms));
97483
97643
  await clock.sleep(waitMs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoqa/runner",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
4
4
  "description": "Yoqa local runner — Appium + catalog HTTP API (Bun). Start with yoqa serve or the yoqa-runner bin.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",