@yoqa/cli 0.3.28 → 0.3.29

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/main.js +542 -18
  2. package/package.json +2 -2
package/dist/main.js CHANGED
@@ -2122,7 +2122,7 @@ var require_commander = __commonJS((exports) => {
2122
2122
  });
2123
2123
 
2124
2124
  // src/program.ts
2125
- import { readFile as readFile2 } from "node:fs/promises";
2125
+ import { readFile as readFile2, writeFile as writeFile3 } from "node:fs/promises";
2126
2126
 
2127
2127
  // ../../node_modules/zod/v3/external.js
2128
2128
  var exports_external = {};
@@ -6105,6 +6105,7 @@ var healthResponseSchema = exports_external.object({
6105
6105
  uptimeMs: exports_external.number().nonnegative()
6106
6106
  });
6107
6107
  var devicePlatformSchema = exports_external.union([exports_external.literal("ios"), exports_external.literal("android")]);
6108
+ var laneNameSchema = exports_external.union([exports_external.literal("appium"), exports_external.literal("direct")]);
6108
6109
  var deviceKindSchema = exports_external.union([
6109
6110
  exports_external.literal("physical"),
6110
6111
  exports_external.literal("simulator"),
@@ -6632,7 +6633,8 @@ var createRunRequestSchema = exports_external.object({
6632
6633
  buildId: exports_external.string().min(1).optional(),
6633
6634
  buildPath: exports_external.string().min(1).optional(),
6634
6635
  executionMode: runExecutionModeSchema.optional(),
6635
- screenMode: runScreenModeSchema.optional()
6636
+ screenMode: runScreenModeSchema.optional(),
6637
+ lane: exports_external.union([laneNameSchema, exports_external.literal("auto")]).optional()
6636
6638
  });
6637
6639
  var stepPhasesSchema = exports_external.object({
6638
6640
  captureMs: exports_external.number().nonnegative(),
@@ -6677,6 +6679,8 @@ var runSchema = exports_external.object({
6677
6679
  status: runStatusSchema,
6678
6680
  executionMode: runExecutionModeSchema,
6679
6681
  screenMode: runScreenModeSchema.optional(),
6682
+ lane: laneNameSchema.optional(),
6683
+ laneWarning: exports_external.string().min(1).optional(),
6680
6684
  error: exports_external.string().nullable(),
6681
6685
  createdAt: exports_external.number().int().nonnegative(),
6682
6686
  startedAt: exports_external.number().int().nonnegative().nullable(),
@@ -6694,7 +6698,8 @@ var connectDeviceRequestSchema = exports_external.object({
6694
6698
  deviceId: exports_external.string().min(1),
6695
6699
  platform: devicePlatformSchema,
6696
6700
  bundleId: exports_external.string().min(1).optional(),
6697
- appPackage: exports_external.string().min(1).optional()
6701
+ appPackage: exports_external.string().min(1).optional(),
6702
+ lane: exports_external.union([laneNameSchema, exports_external.literal("auto")]).optional()
6698
6703
  });
6699
6704
  var activeDeviceResponseSchema = exports_external.object({
6700
6705
  deviceId: exports_external.string().min(1),
@@ -6703,7 +6708,9 @@ var activeDeviceResponseSchema = exports_external.object({
6703
6708
  mjpegPort: exports_external.number().int().positive().optional(),
6704
6709
  streamReady: exports_external.boolean().optional(),
6705
6710
  streamUrl: exports_external.string().min(1).optional(),
6706
- heldByRun: exports_external.boolean().optional()
6711
+ heldByRun: exports_external.boolean().optional(),
6712
+ lane: laneNameSchema.optional(),
6713
+ laneWarning: exports_external.string().min(1).optional()
6707
6714
  });
6708
6715
  var controlPointerMessageSchema = exports_external.object({
6709
6716
  type: exports_external.literal("pointer"),
@@ -6764,15 +6771,23 @@ var actionRequestSchema = exports_external.object({
6764
6771
  alertAction: exports_external.union([exports_external.literal("accept"), exports_external.literal("dismiss")]).optional(),
6765
6772
  seconds: exports_external.number().optional(),
6766
6773
  screenshot: exports_external.boolean().optional(),
6767
- settleMs: exports_external.number().int().min(0).max(1e4).optional()
6774
+ settleMs: exports_external.number().int().min(0).max(1e4).optional(),
6775
+ fullImage: exports_external.boolean().optional(),
6776
+ imageScale: exports_external.number().min(0.01).max(1).optional()
6768
6777
  });
6769
6778
  var actionResultScreenshotSchema = exports_external.object({
6770
6779
  path: exports_external.string().min(1),
6780
+ rawPath: exports_external.string().min(1).optional(),
6771
6781
  annotatedPath: exports_external.string().min(1).optional(),
6772
6782
  settled: exports_external.boolean(),
6773
6783
  waitedMs: exports_external.number(),
6774
6784
  changed: exports_external.boolean().nullable()
6775
6785
  });
6786
+ var actionPhasesSchema = exports_external.object({
6787
+ captureMs: exports_external.number().nonnegative(),
6788
+ actionMs: exports_external.number().nonnegative(),
6789
+ settleMs: exports_external.number().nonnegative()
6790
+ });
6776
6791
  var actionResponseSchema = exports_external.object({
6777
6792
  ok: exports_external.literal(true),
6778
6793
  kind: actionKindSchema,
@@ -6780,7 +6795,23 @@ var actionResponseSchema = exports_external.object({
6780
6795
  x: exports_external.number().optional(),
6781
6796
  y: exports_external.number().optional()
6782
6797
  }).optional(),
6783
- screenshot: actionResultScreenshotSchema.optional()
6798
+ screenshot: actionResultScreenshotSchema.optional(),
6799
+ phases: actionPhasesSchema.optional()
6800
+ });
6801
+ var actionBatchRequestSchema = exports_external.object({
6802
+ steps: exports_external.array(actionRequestSchema).min(1),
6803
+ settleMs: exports_external.number().int().min(0).max(1e4).optional(),
6804
+ fullImage: exports_external.boolean().optional(),
6805
+ imageScale: exports_external.number().min(0.01).max(1).optional()
6806
+ });
6807
+ var actionBatchResponseSchema = exports_external.object({
6808
+ ok: exports_external.boolean(),
6809
+ completed: exports_external.number().int().nonnegative(),
6810
+ total: exports_external.number().int().nonnegative(),
6811
+ failedIndex: exports_external.number().int().nonnegative().optional(),
6812
+ error: exports_external.string().optional(),
6813
+ screenshot: actionResultScreenshotSchema.optional(),
6814
+ steps: exports_external.array(actionResponseSchema)
6784
6815
  });
6785
6816
  var yoqaStatusResponseSchema = exports_external.object({
6786
6817
  runner: exports_external.object({
@@ -7600,6 +7631,8 @@ function buildRunReportFromCatalogRun(run, meta = {}, screenshotsByStepId = {})
7600
7631
  platform: run.platform,
7601
7632
  executionMode: run.executionMode,
7602
7633
  screenMode: run.screenMode ?? null,
7634
+ lane: run.lane ?? null,
7635
+ laneWarning: run.laneWarning ?? null,
7603
7636
  error: run.error,
7604
7637
  createdAt: run.createdAt,
7605
7638
  startedAt: run.startedAt,
@@ -7647,7 +7680,11 @@ function pngDataUri(base64) {
7647
7680
  }
7648
7681
  function formatRunReportHtml(doc) {
7649
7682
  const accent = statusAccent(doc.status);
7650
- const screenRows = doc.screenMode ? [["Screen", doc.screenMode]] : [];
7683
+ const screenRows = [
7684
+ ...doc.screenMode ? [["Screen", doc.screenMode]] : [],
7685
+ ...doc.lane ? [["Lane", doc.lane]] : [],
7686
+ ...doc.laneWarning ? [["Lane warning", doc.laneWarning]] : []
7687
+ ];
7651
7688
  const metaRows = [
7652
7689
  ["Status", accent.label],
7653
7690
  ["Source", doc.source === "catalog" ? "Catalog run" : "Manual Inspector"],
@@ -7782,6 +7819,8 @@ function formatRunReportMarkdown(doc) {
7782
7819
  `| Platform | ${escapeMd(doc.platform ?? "—")} |`,
7783
7820
  `| Mode | ${escapeMd(doc.executionMode ?? "—")} |`,
7784
7821
  ...doc.screenMode ? [`| Screen | ${escapeMd(doc.screenMode)} |`] : [],
7822
+ ...doc.lane ? [`| Lane | ${escapeMd(doc.lane)} |`] : [],
7823
+ ...doc.laneWarning ? [`| Lane warning | ${escapeMd(doc.laneWarning)} |`] : [],
7785
7824
  `| Started | ${escapeMd(formatWhen(doc.startedAt ?? doc.createdAt))} |`,
7786
7825
  `| Finished | ${escapeMd(formatWhen(doc.finishedAt))} |`,
7787
7826
  `| Duration | ${escapeMd(formatDuration(doc.startedAt ?? doc.createdAt, doc.finishedAt))} |`,
@@ -7849,6 +7888,8 @@ function formatRunReportGithubSummary(doc) {
7849
7888
  `| Platform | ${escapeMd(doc.platform ?? "—")} |`,
7850
7889
  `| Mode | ${escapeMd(doc.executionMode ?? "—")} |`,
7851
7890
  ...doc.screenMode ? [`| Screen | ${escapeMd(doc.screenMode)} |`] : [],
7891
+ ...doc.lane ? [`| Lane | ${escapeMd(doc.lane)} |`] : [],
7892
+ ...doc.laneWarning ? [`| Lane warning | ${escapeMd(doc.laneWarning)} |`] : [],
7852
7893
  `| Duration | ${escapeMd(formatDuration(doc.startedAt ?? doc.createdAt, doc.finishedAt))} |`,
7853
7894
  `| Run ID | \`${doc.id}\` |`,
7854
7895
  ""
@@ -8304,6 +8345,15 @@ class RunnerClient {
8304
8345
  }, "Action failed");
8305
8346
  return actionResponseSchema.parse(json);
8306
8347
  }
8348
+ async performActionBatch(request) {
8349
+ const body = actionBatchRequestSchema.parse(request);
8350
+ const json = await this.requestJson("/action/batch", {
8351
+ method: "POST",
8352
+ headers: { "Content-Type": "application/json" },
8353
+ body: JSON.stringify(body)
8354
+ }, "Action batch failed");
8355
+ return actionBatchResponseSchema.parse(json);
8356
+ }
8307
8357
  async getStatus() {
8308
8358
  const json = await this.requestJson("/status", undefined, "Status failed");
8309
8359
  return yoqaStatusResponseSchema.parse(json);
@@ -8352,10 +8402,369 @@ var {
8352
8402
  Option,
8353
8403
  Help
8354
8404
  } = import__.default;
8405
+
8406
+ // ../../services/runner/src/domains/benchmark/accuracy.ts
8407
+ function pointInElement(element, x, y) {
8408
+ return x >= element.x && x <= element.x + element.width && y >= element.y && y <= element.y + element.height;
8409
+ }
8410
+ function tapHitsElement(elements, x, y, expect) {
8411
+ const intended = expect?.id ? elements.filter((el) => el.id === expect.id) : expect?.label ? elements.filter((el) => el.label === expect.label) : elements;
8412
+ return intended.some((el) => pointInElement(el, x, y));
8413
+ }
8414
+
8415
+ // ../../services/runner/src/domains/benchmark/drivers.ts
8416
+ function yoqaBenchmarkDriver(client, options) {
8417
+ return {
8418
+ name: "yoqa",
8419
+ connect: async () => {
8420
+ await client.connectDevice({
8421
+ deviceId: options.deviceId,
8422
+ platform: options.platform,
8423
+ lane: options.lane
8424
+ });
8425
+ },
8426
+ screen: async () => {
8427
+ await client.getScreen();
8428
+ },
8429
+ tapToResult: async (x, y, expect) => {
8430
+ const result = await client.performAction({ kind: "tap", x, y, screenshot: true });
8431
+ const tree = await client.getScreen().catch(() => ({ elements: [] }));
8432
+ const hit = tapHitsElement(tree.elements ?? [], x, y, expect);
8433
+ return {
8434
+ hit,
8435
+ phases: {
8436
+ capture: result.phases?.captureMs ?? 0,
8437
+ action: result.phases?.actionMs ?? 0,
8438
+ settle: result.phases?.settleMs ?? result.screenshot?.waitedMs ?? 0
8439
+ }
8440
+ };
8441
+ },
8442
+ runCase: client.createRun && client.waitForRun && options.appId ? async (caseId) => {
8443
+ const created = await client.createRun?.({
8444
+ appId: options.appId ?? "",
8445
+ caseIds: [caseId],
8446
+ deviceId: options.deviceId,
8447
+ platform: options.platform,
8448
+ executionMode: "agent",
8449
+ screenMode: options.screenMode,
8450
+ lane: options.lane
8451
+ });
8452
+ if (!created)
8453
+ return { passed: false };
8454
+ const finished = await client.waitForRun?.(created.id);
8455
+ const steps = finished?.tests?.reduce((n, test) => n + (test.steps?.length ?? 0), 0);
8456
+ return { passed: finished?.status === "passed", steps };
8457
+ } : undefined,
8458
+ disconnect: async () => {
8459
+ await client.disconnectDevice();
8460
+ }
8461
+ };
8462
+ }
8463
+
8464
+ // ../../services/runner/src/domains/benchmark/scenario.ts
8465
+ async function runLatencyScenario(driver, options) {
8466
+ const clock = options.clock ?? { now: () => Date.now() };
8467
+ const taps = options.suite?.taps ?? [{ x: options.x ?? 500, y: options.y ?? 500 }];
8468
+ const tapToResult = [];
8469
+ const screenRead = [];
8470
+ const coldStart = [];
8471
+ const tapHits = [];
8472
+ const casePasses = [];
8473
+ const stepCounts = [];
8474
+ const captures = [];
8475
+ const actions = [];
8476
+ const settles = [];
8477
+ const t0 = clock.now();
8478
+ await driver.connect();
8479
+ options.advance?.(50);
8480
+ coldStart.push(clock.now() - t0);
8481
+ const tScreen = clock.now();
8482
+ await driver.screen();
8483
+ options.advance?.(20);
8484
+ screenRead.push(clock.now() - tScreen);
8485
+ for (let i = 0;i < options.repeats; i++) {
8486
+ const tap = taps[i % taps.length];
8487
+ if (!tap)
8488
+ continue;
8489
+ const tTap = clock.now();
8490
+ const result = await driver.tapToResult(tap.x, tap.y, {
8491
+ id: tap.id,
8492
+ label: tap.label
8493
+ });
8494
+ options.advance?.(30);
8495
+ tapToResult.push(clock.now() - tTap);
8496
+ if (result.hit != null)
8497
+ tapHits.push(result.hit);
8498
+ if (result.phases?.capture != null)
8499
+ captures.push(result.phases.capture);
8500
+ if (result.phases?.action != null)
8501
+ actions.push(result.phases.action);
8502
+ if (result.phases?.settle != null)
8503
+ settles.push(result.phases.settle);
8504
+ }
8505
+ if (driver.runCase && options.suite) {
8506
+ const times = options.suite.passRepeats;
8507
+ for (let i = 0;i < times; i++) {
8508
+ for (const item of options.suite.cases) {
8509
+ const outcome = await driver.runCase(item.caseId);
8510
+ const passed = typeof outcome === "boolean" ? outcome : outcome.passed;
8511
+ casePasses.push(passed);
8512
+ if (typeof outcome !== "boolean" && outcome.steps != null) {
8513
+ stepCounts.push(outcome.steps);
8514
+ }
8515
+ }
8516
+ }
8517
+ }
8518
+ await driver.disconnect();
8519
+ const phases = driver.name === "yoqa" && (captures.length || actions.length || settles.length) ? {
8520
+ ...captures.length ? { capture: captures } : {},
8521
+ ...actions.length ? { action: actions } : {},
8522
+ ...settles.length ? { settle: settles } : {}
8523
+ } : undefined;
8524
+ return {
8525
+ tool: driver.name,
8526
+ platform: options.platform ?? "ios",
8527
+ ...options.screenMode ? { screenMode: options.screenMode } : {},
8528
+ tapToResult,
8529
+ screenRead,
8530
+ coldStart,
8531
+ ...tapHits.length ? { tapHits } : {},
8532
+ ...casePasses.length ? { casePasses } : {},
8533
+ ...stepCounts.length ? { stepCounts } : {},
8534
+ ...phases ? { phases } : {}
8535
+ };
8536
+ }
8537
+
8538
+ // ../../services/runner/src/domains/benchmark/suite.ts
8539
+ var BENCHMARK_SUITE_VERSION = 1;
8540
+ var DEFAULT_BENCHMARK_SUITE = {
8541
+ version: BENCHMARK_SUITE_VERSION,
8542
+ repeats: 5,
8543
+ taps: [{ x: 500, y: 500 }],
8544
+ cases: [],
8545
+ passRepeats: 3,
8546
+ arms: ["vision"]
8547
+ };
8548
+ function parseBenchmarkSuite(raw) {
8549
+ if (!raw || typeof raw !== "object") {
8550
+ throw new Error("Benchmark suite must be an object");
8551
+ }
8552
+ const value = raw;
8553
+ const version = Number(value.version);
8554
+ const repeats = Number(value.repeats);
8555
+ const passRepeats = Number(value.passRepeats ?? 3);
8556
+ if (!Number.isInteger(version) || version < 1) {
8557
+ throw new Error("Benchmark suite version must be a positive integer");
8558
+ }
8559
+ if (!Number.isInteger(repeats) || repeats < 1) {
8560
+ throw new Error("Benchmark suite repeats must be a positive integer");
8561
+ }
8562
+ if (!Array.isArray(value.taps) || value.taps.length === 0) {
8563
+ throw new Error("Benchmark suite needs at least one tap");
8564
+ }
8565
+ const taps = value.taps.map((tap, i) => {
8566
+ if (!tap || typeof tap !== "object") {
8567
+ throw new Error(`Benchmark suite tap ${i} is invalid`);
8568
+ }
8569
+ const row = tap;
8570
+ const x = Number(row.x);
8571
+ const y = Number(row.y);
8572
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
8573
+ throw new Error(`Benchmark suite tap ${i} needs x and y`);
8574
+ }
8575
+ return {
8576
+ x,
8577
+ y,
8578
+ ...typeof row.id === "string" ? { id: row.id } : {},
8579
+ ...typeof row.label === "string" ? { label: row.label } : {}
8580
+ };
8581
+ });
8582
+ const cases = Array.isArray(value.cases) ? value.cases.flatMap((item) => {
8583
+ if (!item || typeof item !== "object")
8584
+ return [];
8585
+ const caseId = item.caseId;
8586
+ return typeof caseId === "string" && caseId.length > 0 ? [{ caseId }] : [];
8587
+ }) : [];
8588
+ return {
8589
+ version,
8590
+ repeats,
8591
+ taps,
8592
+ cases,
8593
+ passRepeats: Number.isInteger(passRepeats) ? passRepeats : 3,
8594
+ arms: parseBenchmarkArms(value.arms)
8595
+ };
8596
+ }
8597
+ function parseBenchmarkArms(raw) {
8598
+ if (raw == null)
8599
+ return ["vision"];
8600
+ if (!Array.isArray(raw) || raw.length === 0) {
8601
+ throw new Error("Benchmark suite arms must be a non-empty list of vision | tree");
8602
+ }
8603
+ const arms = [];
8604
+ for (const item of raw) {
8605
+ if (item !== "vision" && item !== "tree") {
8606
+ throw new Error("Benchmark suite arms must be vision or tree");
8607
+ }
8608
+ if (!arms.includes(item))
8609
+ arms.push(item);
8610
+ }
8611
+ return arms;
8612
+ }
8613
+
8614
+ // ../../services/runner/src/domains/benchmark/summarize.ts
8615
+ function percentile(samples, p) {
8616
+ if (samples.length === 0)
8617
+ return 0;
8618
+ const sorted = [...samples].sort((a, b) => a - b);
8619
+ const rank = Math.ceil(p / 100 * sorted.length) - 1;
8620
+ return sorted[Math.max(0, Math.min(sorted.length - 1, rank))] ?? 0;
8621
+ }
8622
+ function summarizeSamples(samples) {
8623
+ return {
8624
+ p50: percentile(samples, 50),
8625
+ p95: percentile(samples, 95),
8626
+ n: samples.length,
8627
+ samples: [...samples]
8628
+ };
8629
+ }
8630
+ function mean(samples) {
8631
+ if (!samples || samples.length === 0)
8632
+ return;
8633
+ return samples.reduce((sum, n) => sum + n, 0) / samples.length;
8634
+ }
8635
+ function summarizeRate(hits) {
8636
+ if (!hits || hits.length === 0)
8637
+ return;
8638
+ const n = hits.length;
8639
+ const count = hits.filter(Boolean).length;
8640
+ return { hits: count, n, rate: count / n };
8641
+ }
8642
+ function summarizeLatency(input) {
8643
+ return {
8644
+ recordedAt: input.recordedAt,
8645
+ repeats: input.repeats,
8646
+ ...input.suiteVersion != null ? { suiteVersion: input.suiteVersion } : {},
8647
+ results: input.runs.map((run) => {
8648
+ const capture = mean(run.phases?.capture);
8649
+ const action = mean(run.phases?.action);
8650
+ const settle = mean(run.phases?.settle);
8651
+ const phases = capture != null || action != null || settle != null ? {
8652
+ ...capture != null ? { capture } : {},
8653
+ ...action != null ? { action } : {},
8654
+ ...settle != null ? { settle } : {}
8655
+ } : undefined;
8656
+ const accuracy = summarizeRate(run.tapHits);
8657
+ const passRate = summarizeRate(run.casePasses);
8658
+ const steps = run.stepCounts?.length ? summarizeSamples(run.stepCounts) : undefined;
8659
+ return {
8660
+ tool: run.tool,
8661
+ platform: run.platform,
8662
+ ...run.screenMode ? { screenMode: run.screenMode } : {},
8663
+ metrics: {
8664
+ tapToResult: summarizeSamples(run.tapToResult),
8665
+ screenRead: summarizeSamples(run.screenRead),
8666
+ coldStart: summarizeSamples(run.coldStart)
8667
+ },
8668
+ ...accuracy ? { accuracy } : {},
8669
+ ...passRate ? { passRate } : {},
8670
+ ...steps ? { steps } : {},
8671
+ ...phases ? { phases } : {}
8672
+ };
8673
+ })
8674
+ };
8675
+ }
8676
+ function formatLatencyTable(report) {
8677
+ const header = [
8678
+ "tool".padEnd(12),
8679
+ "platform".padEnd(10),
8680
+ "metric".padEnd(14),
8681
+ "p50".padStart(8),
8682
+ "p95".padStart(8),
8683
+ "n".padStart(4)
8684
+ ].join(" ");
8685
+ const lines = [header];
8686
+ for (const row of report.results) {
8687
+ const tool = row.screenMode ? `${row.tool}/${row.screenMode}` : row.tool;
8688
+ for (const metric of ["tapToResult", "screenRead", "coldStart"]) {
8689
+ const s = row.metrics[metric];
8690
+ lines.push([
8691
+ tool.padEnd(12),
8692
+ row.platform.padEnd(10),
8693
+ metric.padEnd(14),
8694
+ String(Math.round(s.p50)).padStart(8),
8695
+ String(Math.round(s.p95)).padStart(8),
8696
+ String(s.n).padStart(4)
8697
+ ].join(" "));
8698
+ }
8699
+ if (row.accuracy) {
8700
+ lines.push([
8701
+ tool.padEnd(12),
8702
+ row.platform.padEnd(10),
8703
+ "tapAccuracy".padEnd(14),
8704
+ row.accuracy.rate.toFixed(2).padStart(8),
8705
+ "".padStart(8),
8706
+ String(row.accuracy.n).padStart(4)
8707
+ ].join(" "));
8708
+ }
8709
+ if (row.passRate) {
8710
+ lines.push([
8711
+ tool.padEnd(12),
8712
+ row.platform.padEnd(10),
8713
+ "casePassRate".padEnd(14),
8714
+ row.passRate.rate.toFixed(2).padStart(8),
8715
+ "".padStart(8),
8716
+ String(row.passRate.n).padStart(4)
8717
+ ].join(" "));
8718
+ }
8719
+ if (row.steps) {
8720
+ lines.push([
8721
+ tool.padEnd(12),
8722
+ row.platform.padEnd(10),
8723
+ "stepCount".padEnd(14),
8724
+ String(Math.round(row.steps.p50)).padStart(8),
8725
+ String(Math.round(row.steps.p95)).padStart(8),
8726
+ String(row.steps.n).padStart(4)
8727
+ ].join(" "));
8728
+ }
8729
+ if (row.phases) {
8730
+ const parts = Object.entries(row.phases).map(([k, v]) => `${k}=${Math.round(v)}`);
8731
+ lines.push(` phases ${parts.join(" ")}`);
8732
+ }
8733
+ }
8734
+ return `${lines.join(`
8735
+ `)}
8736
+ `;
8737
+ }
8738
+ function recommendScreenDefault(vision, tree) {
8739
+ if (!vision || !tree) {
8740
+ return {
8741
+ screenMode: "vision",
8742
+ reason: "Need both vision and tree arms before flipping the default"
8743
+ };
8744
+ }
8745
+ if (!vision.passRate || !tree.passRate) {
8746
+ return {
8747
+ screenMode: "vision",
8748
+ reason: "No Case pass-rate samples; keep vision-first"
8749
+ };
8750
+ }
8751
+ const visionAcc = vision.accuracy?.rate ?? 0;
8752
+ const treeAcc = tree.accuracy?.rate ?? 0;
8753
+ if (tree.passRate.rate > vision.passRate.rate && treeAcc >= visionAcc) {
8754
+ return {
8755
+ screenMode: "tree",
8756
+ reason: "Tree arm won Case pass rate without losing tap accuracy"
8757
+ };
8758
+ }
8759
+ return {
8760
+ screenMode: "vision",
8761
+ reason: "Tree arm did not beat vision on pass rate and accuracy; keep vision-first"
8762
+ };
8763
+ }
8355
8764
  // package.json
8356
8765
  var package_default = {
8357
8766
  name: "@yoqa/cli",
8358
- version: "0.3.28",
8767
+ version: "0.3.29",
8359
8768
  description: "Yoqa CLI — drive local mobile QA from the terminal or CI (talks to the Yoqa runner over HTTP)",
8360
8769
  license: "UNLICENSED",
8361
8770
  type: "module",
@@ -8393,7 +8802,7 @@ var package_default = {
8393
8802
  health: "bun run src/main.ts health"
8394
8803
  },
8395
8804
  dependencies: {
8396
- "@yoqa/runner": "0.3.28"
8805
+ "@yoqa/runner": "0.3.29"
8397
8806
  },
8398
8807
  devDependencies: {
8399
8808
  "@types/node": "^22.15.30",
@@ -9244,23 +9653,30 @@ for (const platform of ["ios", "android"]) {
9244
9653
  }
9245
9654
  });
9246
9655
  }
9247
- devices.command("connect").description("Open an Appium session on a device").argument("<deviceId>", "Device UDID / serial").requiredOption("--platform <platform>", "ios | android").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--bundle-id <id>", "iOS bundle id to launch").option("--app-package <id>", "Android application id to launch").option("--json", "Print raw JSON").action(async (deviceId, options) => {
9656
+ devices.command("connect").description("Open a Device Session on a device").argument("<deviceId>", "Device UDID / serial").requiredOption("--platform <platform>", "ios | android").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--bundle-id <id>", "iOS bundle id to launch").option("--app-package <id>", "Android application id to launch").option("--lane <lane>", "appium | direct | auto (Android / iOS sim auto → Direct)").option("--json", "Print raw JSON").action(async (deviceId, options) => {
9248
9657
  try {
9249
9658
  const platform = options.platform;
9250
9659
  if (platform !== "ios" && platform !== "android") {
9251
9660
  throw new Error("--platform must be ios or android");
9252
9661
  }
9662
+ const lane = options.lane;
9663
+ if (lane != null && lane !== "appium" && lane !== "direct" && lane !== "auto") {
9664
+ throw new Error("--lane must be appium, direct, or auto");
9665
+ }
9253
9666
  const body = await client(options.baseUrl).connectDevice({
9254
9667
  deviceId,
9255
9668
  platform,
9256
9669
  bundleId: options.bundleId,
9257
- appPackage: options.appPackage
9670
+ appPackage: options.appPackage,
9671
+ lane
9258
9672
  });
9259
9673
  if (options.json) {
9260
9674
  console.log(JSON.stringify(body, null, 2));
9261
9675
  return;
9262
9676
  }
9263
- console.log(`connected ${body.platform} ${body.deviceId}`);
9677
+ console.log(`connected ${body.platform} ${body.deviceId} lane ${body.lane ?? "appium"}`);
9678
+ if (body.laneWarning)
9679
+ console.log(`lane warning: ${body.laneWarning}`);
9264
9680
  } catch (error) {
9265
9681
  fail("devices connect", error);
9266
9682
  }
@@ -9277,7 +9693,9 @@ devices.command("active").description("Show the currently connected device").opt
9277
9693
  console.log(JSON.stringify(body, null, 2));
9278
9694
  return;
9279
9695
  }
9280
- console.log(`${body.platform} ${body.deviceId}`);
9696
+ console.log(`${body.platform} ${body.deviceId} lane ${body.lane ?? "appium"}`);
9697
+ if (body.laneWarning)
9698
+ console.log(`lane warning: ${body.laneWarning}`);
9281
9699
  } catch (error) {
9282
9700
  fail("devices active", error);
9283
9701
  }
@@ -9322,15 +9740,20 @@ program2.command("screenshot").description("Capture a screenshot from the active
9322
9740
  });
9323
9741
  var action = program2.command("action").description("Perform an action on the active device");
9324
9742
  function addActionOptions(cmd) {
9325
- return cmd.option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").option("-d, --description <text>", "Ground target by natural language").option("--label <text>", "Tap/focus by accessibility label (cleaned screen tree)").option("--id <text>", "Tap/focus by resource-id / accessibility id").option("--x <n>", "X in 0–1000", (v) => Number(v)).option("--y <n>", "Y in 0–1000", (v) => Number(v)).option("--x2 <n>", "End X in 0–1000", (v) => Number(v)).option("--y2 <n>", "End Y in 0–1000", (v) => Number(v)).option("--duration <ms>", "Gesture duration ms (long-press for tap)", (v) => Number(v)).option("--double", "Double-tap (tap only)").option("--text <text>", "Text to type").option("--app-id <id>", "Bundle id / application id").option("--url <url>", "URL to open").option("--seconds <n>", "Background seconds", (v) => Number(v)).option("--screenshot", "Wait for the screen to settle, then return the result screenshot (and a copy marking where the tap or swipe landed)").option("--settle <ms>", "With --screenshot: longest wait for the screen to settle (default 1500)", (v) => Number(v));
9743
+ return cmd.option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").option("-d, --description <text>", "Ground target by natural language").option("--label <text>", "Tap/focus by accessibility label (cleaned screen tree)").option("--id <text>", "Tap/focus by resource-id / accessibility id").option("--x <n>", "X in 0–1000", (v) => Number(v)).option("--y <n>", "Y in 0–1000", (v) => Number(v)).option("--x2 <n>", "End X in 0–1000", (v) => Number(v)).option("--y2 <n>", "End Y in 0–1000", (v) => Number(v)).option("--duration <ms>", "Gesture duration ms (long-press for tap)", (v) => Number(v)).option("--double", "Double-tap (tap only)").option("--text <text>", "Text to type").option("--app-id <id>", "Bundle id / application id").option("--url <url>", "URL to open").option("--seconds <n>", "Background seconds", (v) => Number(v)).option("--screenshot", "Wait for the screen to settle, then return the result screenshot (and a copy marking where the tap or swipe landed)").option("--settle <ms>", "With --screenshot: longest wait for the screen to settle (default 1500)", (v) => Number(v)).option("--full", "With --screenshot: return the raw Result screenshot instead of the Agent image").option("--scale <n>", "With --screenshot: Agent image scale 0.01–1 (default: long edge ~1000 px)", (v) => Number(v));
9326
9744
  }
9327
9745
  function printResultScreenshot(shot) {
9328
9746
  console.log(`screenshot ${shot.path}`);
9747
+ if (shot.rawPath)
9748
+ console.log(`raw ${shot.rawPath}`);
9329
9749
  if (shot.annotatedPath)
9330
9750
  console.log(`marked ${shot.annotatedPath}`);
9331
9751
  console.log(`settled ${shot.settled} (${Math.round(shot.waitedMs)}ms)`);
9332
9752
  console.log(`changed ${shot.changed === null ? "unknown (screen still animating)" : shot.changed}`);
9333
9753
  }
9754
+ function printActionPhases(phases) {
9755
+ console.log(`phases capture=${phases.captureMs} action=${phases.actionMs} settle=${phases.settleMs}`);
9756
+ }
9334
9757
  for (const kind of [
9335
9758
  "tap",
9336
9759
  "swipe",
@@ -9360,7 +9783,9 @@ for (const kind of [
9360
9783
  url: options.url,
9361
9784
  seconds: options.seconds,
9362
9785
  screenshot: options.screenshot === true ? true : undefined,
9363
- settleMs: options.settle
9786
+ settleMs: options.settle,
9787
+ fullImage: options.full === true ? true : undefined,
9788
+ imageScale: options.scale
9364
9789
  });
9365
9790
  if (options.json) {
9366
9791
  console.log(JSON.stringify(body, null, 2));
@@ -9372,6 +9797,8 @@ for (const kind of [
9372
9797
  }
9373
9798
  if (body.screenshot)
9374
9799
  printResultScreenshot(body.screenshot);
9800
+ if (body.phases)
9801
+ printActionPhases(body.phases);
9375
9802
  } catch (error) {
9376
9803
  fail(`action ${kind}`, error);
9377
9804
  }
@@ -9383,7 +9810,9 @@ addActionOptions(action.command("alert").description("Accept or dismiss a system
9383
9810
  kind: "alert",
9384
9811
  alertAction: options.dismiss ? "dismiss" : "accept",
9385
9812
  screenshot: options.screenshot === true ? true : undefined,
9386
- settleMs: options.settle
9813
+ settleMs: options.settle,
9814
+ fullImage: options.full === true ? true : undefined,
9815
+ imageScale: options.scale
9387
9816
  });
9388
9817
  if (options.json) {
9389
9818
  console.log(JSON.stringify(body, null, 2));
@@ -9396,6 +9825,44 @@ addActionOptions(action.command("alert").description("Accept or dismiss a system
9396
9825
  fail("action alert", error);
9397
9826
  }
9398
9827
  });
9828
+ addActionOptions(action.command("batch").description("Run known Actions from a JSON file or stdin (one Settle and Result screenshot at the end). Description-grounded steps are rejected.").argument("[file]", "JSON file of { steps: Action[] } or an Action array. Omit to read stdin.")).action(async (file, options) => {
9829
+ try {
9830
+ const raw = file ? await readFile2(file, "utf8") : await new Promise((resolve, reject) => {
9831
+ const chunks = [];
9832
+ process.stdin.on("data", (chunk) => {
9833
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
9834
+ });
9835
+ process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
9836
+ process.stdin.on("error", reject);
9837
+ });
9838
+ const parsed = JSON.parse(raw);
9839
+ const steps = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" && ("steps" in parsed) ? parsed.steps : null;
9840
+ if (!Array.isArray(steps) || steps.length === 0) {
9841
+ throw new Error("Batch JSON must be an Action array or { steps: Action[] }");
9842
+ }
9843
+ const body = await client(String(options.baseUrl)).performActionBatch({
9844
+ steps,
9845
+ settleMs: options.settle,
9846
+ fullImage: options.full === true ? true : undefined,
9847
+ imageScale: options.scale
9848
+ });
9849
+ if (options.json) {
9850
+ console.log(JSON.stringify(body, null, 2));
9851
+ return;
9852
+ }
9853
+ console.log(`batch ${body.ok ? "ok" : "failed"} ${body.completed}/${body.total}`);
9854
+ if (body.failedIndex != null)
9855
+ console.log(`failedIndex ${body.failedIndex}`);
9856
+ if (body.error)
9857
+ console.log(body.error);
9858
+ if (body.screenshot)
9859
+ printResultScreenshot(body.screenshot);
9860
+ if (!body.ok)
9861
+ process.exitCode = 1;
9862
+ } catch (error) {
9863
+ fail("action batch", error);
9864
+ }
9865
+ });
9399
9866
  var setup = program2.command("setup").description("Install Appium and the platform driver (xcuitest / uiautomator2)");
9400
9867
  setup.command("ios").description("Ensure Appium + xcuitest; optionally build and install WDA on a physical device").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").option("--device <udid>", "Physical device UDID to install WebDriverAgent on").option("--kind <kind>", "Device kind: physical | simulator", "physical").option("--xcode <path>", "Xcode Contents/Developer path (DEVELOPER_DIR)").option("--team <teamId>", "Apple Development team ID").option("--identity <name>", 'Codesigning identity name, e.g. "Apple Development: …"').option("--force", "Force a full WebDriverAgent rebuild even when prep is reusable").action(async (options) => {
9401
9868
  try {
@@ -10022,7 +10489,7 @@ runsCmd.command("delete").argument("<runId>", "Run id").option("--base-url <url>
10022
10489
  fail("runs delete", error);
10023
10490
  }
10024
10491
  });
10025
- runsCmd.command("create").argument("<app>", "App prefix or id").requiredOption("--cases <numbers>", "Comma-separated case numbers, e.g. 1,2,5").option("--device <id>", "Device id (defaults to active session)").option("--platform <platform>", "ios | android (defaults to active session)").option("--build-id <id>", "Registered build id to install before run").option("--build-path <path>", "Absolute build path to register + install").option("--mode <mode>", "auto | script | agent (default: auto)").option("--screen-mode <mode>", "vision | tree — what the agent sees: screenshot only, or also the accessibility tree (default: vision)").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").option("--wait", "Wait until the run finishes (passed / errored / cancelled)").option("--timeout <seconds>", "Wait timeout in seconds (default: 1800)", String(DEFAULT_RUN_WAIT_TIMEOUT_SECONDS)).option("--github-output", "Write run_id and status to $GITHUB_OUTPUT").option("--no-github-output", "Do not write GitHub step outputs").action(async (appArg, options) => {
10492
+ runsCmd.command("create").argument("<app>", "App prefix or id").requiredOption("--cases <numbers>", "Comma-separated case numbers, e.g. 1,2,5").option("--device <id>", "Device id (defaults to active session)").option("--platform <platform>", "ios | android (defaults to active session)").option("--build-id <id>", "Registered build id to install before run").option("--build-path <path>", "Absolute build path to register + install").option("--mode <mode>", "auto | script | agent (default: auto)").option("--screen-mode <mode>", "vision | tree — what the agent sees: screenshot only, or also the accessibility tree (default: vision)").option("--lane <lane>", "appium | direct | auto — Android / iOS sim auto → Direct").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").option("--wait", "Wait until the run finishes (passed / errored / cancelled)").option("--timeout <seconds>", "Wait timeout in seconds (default: 1800)", String(DEFAULT_RUN_WAIT_TIMEOUT_SECONDS)).option("--github-output", "Write run_id and status to $GITHUB_OUTPUT").option("--no-github-output", "Do not write GitHub step outputs").action(async (appArg, options) => {
10026
10493
  try {
10027
10494
  const c = client(options.baseUrl);
10028
10495
  const resolved = await resolveAppId(c, appArg);
@@ -10058,6 +10525,10 @@ runsCmd.command("create").argument("<app>", "App prefix or id").requiredOption("
10058
10525
  if (options.screenMode && !screenMode) {
10059
10526
  throw new Error("--screen-mode must be vision or tree");
10060
10527
  }
10528
+ const lane = options.lane === "appium" || options.lane === "direct" || options.lane === "auto" ? options.lane : undefined;
10529
+ if (options.lane && !lane) {
10530
+ throw new Error("--lane must be appium, direct, or auto");
10531
+ }
10061
10532
  let run = await c.createRun({
10062
10533
  appId: resolved.id,
10063
10534
  caseIds,
@@ -10066,7 +10537,8 @@ runsCmd.command("create").argument("<app>", "App prefix or id").requiredOption("
10066
10537
  buildId: options.buildId,
10067
10538
  buildPath: options.buildPath,
10068
10539
  executionMode,
10069
- screenMode
10540
+ screenMode,
10541
+ lane
10070
10542
  });
10071
10543
  if (options.wait) {
10072
10544
  try {
@@ -10245,6 +10717,58 @@ scriptCmd.command("run").argument("<file>", "Path to a .yoqa.json / CaseScript J
10245
10717
  fail("script run", error);
10246
10718
  }
10247
10719
  });
10720
+ program2.command("benchmark").description("Compare Yoqa (and Argent, when installed) tap-to-result latency on one device").requiredOption("--device <id>", "Device UDID / serial").requiredOption("--platform <platform>", "ios | android").option("--repeats <n>", "Tap-to-result repeats (default: suite repeats)", (v) => Number(v)).option("--suite <path>", "Versioned suite JSON (default: built-in v1)").option("--tools <list>", "Comma-separated: yoqa,argent (default: yoqa)", "yoqa").option("--lane <lane>", "Yoqa Lane: appium | direct | auto", "auto").option("--arms <list>", "Yoqa Screen arms: vision,tree (default: suite arms)").option("--app <id>", "Catalog App id for Case arms (required when the suite has cases)").option("--out <path>", "Write the JSON report to this path").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print JSON instead of the table").action(async (options) => {
10721
+ try {
10722
+ const platform = options.platform;
10723
+ if (platform !== "ios" && platform !== "android") {
10724
+ throw new Error("--platform must be ios or android");
10725
+ }
10726
+ const lane = options.lane === "appium" || options.lane === "direct" || options.lane === "auto" ? options.lane : undefined;
10727
+ if (options.lane && !lane)
10728
+ throw new Error("--lane must be appium, direct, or auto");
10729
+ const suite = options.suite ? parseBenchmarkSuite(JSON.parse(await readFile2(options.suite, "utf8"))) : DEFAULT_BENCHMARK_SUITE;
10730
+ const arms = options.arms ? parseBenchmarkArms(options.arms.split(",").map((item) => item.trim())) : suite.arms;
10731
+ const repeats = options.repeats && options.repeats > 0 ? options.repeats : suite.repeats;
10732
+ const tools = options.tools.split(",").map((t) => t.trim());
10733
+ const c = client(options.baseUrl);
10734
+ const runs = [];
10735
+ if (tools.includes("yoqa")) {
10736
+ for (const screenMode of arms) {
10737
+ runs.push(await runLatencyScenario(yoqaBenchmarkDriver(c, {
10738
+ deviceId: options.device,
10739
+ platform,
10740
+ lane,
10741
+ screenMode,
10742
+ appId: options.app
10743
+ }), { repeats, platform, suite, screenMode }));
10744
+ }
10745
+ }
10746
+ if (tools.includes("argent")) {
10747
+ console.error("Argent driver: install the argent CLI and re-run; skipping for now.");
10748
+ }
10749
+ const report = summarizeLatency({
10750
+ recordedAt: new Date().toISOString(),
10751
+ repeats,
10752
+ suiteVersion: suite.version,
10753
+ runs
10754
+ });
10755
+ const recommendation = recommendScreenDefault(report.results.find((row) => row.screenMode === "vision"), report.results.find((row) => row.screenMode === "tree"));
10756
+ if (options.out) {
10757
+ await writeFile3(options.out, `${JSON.stringify({ ...report, recommendation }, null, 2)}
10758
+ `);
10759
+ }
10760
+ if (options.json)
10761
+ console.log(JSON.stringify({ ...report, recommendation }, null, 2));
10762
+ else {
10763
+ console.log(formatLatencyTable(report));
10764
+ if (arms.includes("tree")) {
10765
+ console.log(`default ${recommendation.screenMode} — ${recommendation.reason}`);
10766
+ }
10767
+ }
10768
+ } catch (error) {
10769
+ fail("benchmark", error);
10770
+ }
10771
+ });
10248
10772
  function buildProgram() {
10249
10773
  return program2;
10250
10774
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoqa/cli",
3
- "version": "0.3.28",
3
+ "version": "0.3.29",
4
4
  "description": "Yoqa CLI — drive local mobile QA from the terminal or CI (talks to the Yoqa runner over HTTP)",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -38,7 +38,7 @@
38
38
  "health": "bun run src/main.ts health"
39
39
  },
40
40
  "dependencies": {
41
- "@yoqa/runner": "0.3.28"
41
+ "@yoqa/runner": "0.3.29"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/node": "^22.15.30",