@yoqa/cli 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 (3) hide show
  1. package/README.md +1 -1
  2. package/dist/main.js +515 -73
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -18,4 +18,4 @@ yoqa serve
18
18
 
19
19
  Disable auto-start with `YOQA_NO_AUTOSTART=1`. Override the URL with `YOQA_RUNNER_HOST` / `YOQA_RUNNER_PORT` / `YOQA_RUNNER_URL`.
20
20
 
21
- See [CLI docs](https://yoqa.mintlify.app/docs/cli).
21
+ See [CLI docs](https://yoqa.mintlify.app/docs/cli) and [GitHub Actions](https://yoqa.mintlify.app/docs/github-actions).
package/dist/main.js CHANGED
@@ -2122,7 +2122,7 @@ var require_commander = __commonJS((exports) => {
2122
2122
  });
2123
2123
 
2124
2124
  // src/main.ts
2125
- import { readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
2125
+ import { readFile as readFile2 } from "node:fs/promises";
2126
2126
 
2127
2127
  // ../../node_modules/zod/v3/external.js
2128
2128
  var exports_external = {};
@@ -6293,13 +6293,24 @@ var caseFlowStepSchema = exports_external.object({
6293
6293
  flowId: exports_external.string().min(1).nullable().optional()
6294
6294
  });
6295
6295
  var caseRunStatusSchema = exports_external.union([exports_external.literal("passed"), exports_external.literal("errored")]);
6296
- var caseScriptActionSchema = exports_external.discriminatedUnion("type", [
6297
- exports_external.object({
6298
- type: exports_external.literal("tap"),
6299
- x: exports_external.number().min(0).max(1000),
6300
- y: exports_external.number().min(0).max(1000),
6301
- reason: exports_external.string().optional()
6302
- }),
6296
+ var caseScriptTapSchema = exports_external.object({
6297
+ type: exports_external.literal("tap"),
6298
+ x: exports_external.number().min(0).max(1000).optional(),
6299
+ y: exports_external.number().min(0).max(1000).optional(),
6300
+ label: exports_external.string().min(1).optional(),
6301
+ id: exports_external.string().min(1).optional(),
6302
+ reason: exports_external.string().optional()
6303
+ }).superRefine((value, ctx) => {
6304
+ const hasCoords = value.x != null && value.y != null;
6305
+ if (!hasCoords && !value.label && !value.id) {
6306
+ ctx.addIssue({
6307
+ code: exports_external.ZodIssueCode.custom,
6308
+ message: "tap requires x/y or label or id"
6309
+ });
6310
+ }
6311
+ });
6312
+ var caseScriptActionSchema = exports_external.union([
6313
+ caseScriptTapSchema,
6303
6314
  exports_external.object({
6304
6315
  type: exports_external.literal("type"),
6305
6316
  text: exports_external.string(),
@@ -6309,6 +6320,13 @@ var caseScriptActionSchema = exports_external.discriminatedUnion("type", [
6309
6320
  type: exports_external.literal("wait"),
6310
6321
  ms: exports_external.number().min(0).max(1e4),
6311
6322
  reason: exports_external.string().optional()
6323
+ }),
6324
+ exports_external.object({
6325
+ type: exports_external.literal("assert"),
6326
+ assertion: exports_external.union([exports_external.literal("visible"), exports_external.literal("not-visible")]).default("visible"),
6327
+ text: exports_external.string().min(1),
6328
+ timeoutMs: exports_external.number().min(0).max(120000).optional(),
6329
+ reason: exports_external.string().optional()
6312
6330
  })
6313
6331
  ]);
6314
6332
  var caseScriptSchema = exports_external.object({
@@ -6716,18 +6734,103 @@ var createBuildRequestSchema = exports_external.object({
6716
6734
  var listBuildsResponseSchema = exports_external.object({
6717
6735
  builds: exports_external.array(buildSchema)
6718
6736
  });
6737
+
6738
+ // ../runner-client/src/wait-for-run.ts
6739
+ var DEFAULT_WAIT_FOR_RUN_TIMEOUT_MS = 30 * 60 * 1000;
6740
+ var DEFAULT_WAIT_FOR_RUN_INTERVAL_MS = 2000;
6741
+ function isTerminalRunStatus(status) {
6742
+ return status === "passed" || status === "errored" || status === "cancelled";
6743
+ }
6744
+
6745
+ class WaitForRunTimeoutError extends Error {
6746
+ runId;
6747
+ lastRun;
6748
+ timeoutMs;
6749
+ constructor(runId, timeoutMs, lastRun) {
6750
+ const lastStatus = lastRun?.status ?? "unknown";
6751
+ super(`Timed out after ${timeoutMs}ms waiting for run ${runId} (last status: ${lastStatus})`);
6752
+ this.name = "WaitForRunTimeoutError";
6753
+ this.runId = runId;
6754
+ this.lastRun = lastRun;
6755
+ this.timeoutMs = timeoutMs;
6756
+ }
6757
+ }
6758
+
6759
+ class WaitForRunAbortedError extends Error {
6760
+ runId;
6761
+ lastRun;
6762
+ constructor(runId, lastRun) {
6763
+ super(`Wait for run ${runId} was aborted`);
6764
+ this.name = "WaitForRunAbortedError";
6765
+ this.runId = runId;
6766
+ this.lastRun = lastRun;
6767
+ }
6768
+ }
6769
+ function defaultSleep(ms) {
6770
+ return new Promise((resolve) => setTimeout(resolve, ms));
6771
+ }
6772
+ async function waitForRun(client, runId, options = {}) {
6773
+ const timeoutMs = options.timeoutMs ?? DEFAULT_WAIT_FOR_RUN_TIMEOUT_MS;
6774
+ const intervalMs = options.intervalMs ?? DEFAULT_WAIT_FOR_RUN_INTERVAL_MS;
6775
+ if (!(timeoutMs > 0)) {
6776
+ throw new Error("timeoutMs must be positive");
6777
+ }
6778
+ if (intervalMs < 0) {
6779
+ throw new Error("intervalMs must be non-negative");
6780
+ }
6781
+ const now = options.now ?? Date.now;
6782
+ const sleep = options.sleep ?? defaultSleep;
6783
+ const deadline = now() + timeoutMs;
6784
+ let lastRun = null;
6785
+ while (now() < deadline) {
6786
+ if (options.signal?.aborted) {
6787
+ throw new WaitForRunAbortedError(runId, lastRun);
6788
+ }
6789
+ lastRun = await client.getRun(runId);
6790
+ options.onStatus?.(lastRun);
6791
+ if (isTerminalRunStatus(lastRun.status)) {
6792
+ return lastRun;
6793
+ }
6794
+ const remaining = deadline - now();
6795
+ if (remaining <= 0)
6796
+ break;
6797
+ await sleep(Math.min(intervalMs, remaining));
6798
+ }
6799
+ if (options.signal?.aborted) {
6800
+ throw new WaitForRunAbortedError(runId, lastRun);
6801
+ }
6802
+ if (lastRun && isTerminalRunStatus(lastRun.status)) {
6803
+ return lastRun;
6804
+ }
6805
+ throw new WaitForRunTimeoutError(runId, timeoutMs, lastRun);
6806
+ }
6807
+ function pickLatestRun(runs) {
6808
+ if (runs.length === 0)
6809
+ return null;
6810
+ return runs.reduce((best, run) => run.createdAt > best.createdAt ? run : best);
6811
+ }
6719
6812
  // ../runner-client/src/run-report.ts
6720
6813
  function actionSummary(action) {
6721
6814
  if (!action || typeof action !== "object")
6722
6815
  return "Step";
6723
6816
  const record = action;
6724
6817
  const type = typeof record.type === "string" ? record.type : "step";
6725
- if (type === "tap")
6818
+ if (type === "tap") {
6819
+ if (typeof record.label === "string" && record.label.trim())
6820
+ return `Tap: ${record.label}`;
6821
+ if (typeof record.id === "string" && record.id.trim())
6822
+ return `Tap #${record.id}`;
6726
6823
  return "Tap";
6824
+ }
6727
6825
  if (type === "type")
6728
6826
  return `Type${typeof record.text === "string" ? `: ${record.text}` : ""}`;
6729
6827
  if (type === "wait")
6730
6828
  return "Wait";
6829
+ if (type === "assert") {
6830
+ const assertion = typeof record.assertion === "string" ? record.assertion : "visible";
6831
+ const text = typeof record.text === "string" ? record.text : "";
6832
+ return text ? `Assert ${assertion}: ${text}` : `Assert ${assertion}`;
6833
+ }
6731
6834
  if (type === "verify")
6732
6835
  return "Verify";
6733
6836
  if (type === "done")
@@ -7012,6 +7115,54 @@ function formatRunReportMarkdown(doc) {
7012
7115
  return lines.join(`
7013
7116
  `);
7014
7117
  }
7118
+ function formatRunReportGithubSummary(doc) {
7119
+ const accent = statusAccent(doc.status);
7120
+ const passed = doc.tests.filter((test) => test.status === "passed").length;
7121
+ const failed = doc.tests.filter((test) => test.status === "errored").length;
7122
+ const cancelled = doc.tests.filter((test) => test.status === "cancelled").length;
7123
+ const failedSteps = doc.tests.flatMap((test) => test.steps.filter((step) => !step.ok).map((step) => ({ test, step })));
7124
+ const lines = [
7125
+ `## Yoqa Report — ${doc.title}`,
7126
+ "",
7127
+ `**${accent.label}** · ${doc.tests.length} test${doc.tests.length === 1 ? "" : "s"} · ${passed} passed · ${failed} failed · ${cancelled} cancelled`,
7128
+ "",
7129
+ "| Field | Value |",
7130
+ "| --- | --- |",
7131
+ `| App | ${escapeMd(doc.appLabel ?? "—")} |`,
7132
+ `| Device | ${escapeMd(doc.deviceLabel ?? "—")} |`,
7133
+ `| Platform | ${escapeMd(doc.platform ?? "—")} |`,
7134
+ `| Mode | ${escapeMd(doc.executionMode ?? "—")} |`,
7135
+ `| Duration | ${escapeMd(formatDuration(doc.startedAt ?? doc.createdAt, doc.finishedAt))} |`,
7136
+ `| Run ID | \`${doc.id}\` |`,
7137
+ ""
7138
+ ];
7139
+ if (doc.error) {
7140
+ lines.push(`> **Error:** ${doc.error}`, "");
7141
+ }
7142
+ if (doc.tests.length > 0) {
7143
+ lines.push("### Tests", "", "| # | Test | Status | Steps |", "| --- | --- | --- | --- |");
7144
+ doc.tests.forEach((test, testIndex) => {
7145
+ const testLabel = test.status === "passed" ? "Passed" : test.status === "errored" ? "Failed" : test.status === "cancelled" ? "Cancelled" : test.status;
7146
+ lines.push(`| ${testIndex + 1} | ${escapeMd(test.title)} | ${testLabel} | ${test.steps.length} |`);
7147
+ });
7148
+ lines.push("");
7149
+ }
7150
+ if (failedSteps.length > 0) {
7151
+ lines.push("### Failed steps", "");
7152
+ for (const { test, step } of failedSteps) {
7153
+ lines.push(`- **${escapeMd(test.title)}** · step ${step.index}: ${escapeMd(step.summary)}`);
7154
+ if (step.reason)
7155
+ lines.push(` - Reason: ${escapeMd(step.reason)}`);
7156
+ if (step.detail && step.detail !== step.reason) {
7157
+ lines.push(` - Detail: ${escapeMd(step.detail)}`);
7158
+ }
7159
+ }
7160
+ lines.push("");
7161
+ }
7162
+ lines.push("Download the `yoqa-report` artifact for the full HTML report with screenshots.", "");
7163
+ return lines.join(`
7164
+ `);
7165
+ }
7015
7166
  // ../runner-client/src/shell-script.ts
7016
7167
  function shellSingleQuote(value) {
7017
7168
  return `'${value.replace(/'/g, `'\\''`)}'`;
@@ -7719,6 +7870,9 @@ class RunnerClient {
7719
7870
  const json = await this.requestJson(`/runs/${encodeURIComponent(runId)}`, undefined, "Get run failed");
7720
7871
  return runSchema.parse(json);
7721
7872
  }
7873
+ async waitForRun(runId, options) {
7874
+ return waitForRun(this, runId, options);
7875
+ }
7722
7876
  async deleteRun(runId) {
7723
7877
  await this.requestJson(`/runs/${encodeURIComponent(runId)}`, { method: "DELETE" }, "Delete run failed");
7724
7878
  }
@@ -7850,7 +8004,7 @@ var {
7850
8004
  // package.json
7851
8005
  var package_default = {
7852
8006
  name: "@yoqa/cli",
7853
- version: "0.3.16",
8007
+ version: "0.3.18",
7854
8008
  description: "Yoqa CLI — drive local mobile QA from the terminal or CI (talks to the Yoqa runner over HTTP)",
7855
8009
  license: "UNLICENSED",
7856
8010
  type: "module",
@@ -7888,7 +8042,7 @@ var package_default = {
7888
8042
  health: "bun run src/main.ts health"
7889
8043
  },
7890
8044
  dependencies: {
7891
- "@yoqa/runner": "0.3.16"
8045
+ "@yoqa/runner": "0.3.18"
7892
8046
  },
7893
8047
  devDependencies: {
7894
8048
  "@types/node": "^22.15.30",
@@ -7902,6 +8056,15 @@ var package_default = {
7902
8056
  }
7903
8057
  };
7904
8058
 
8059
+ // src/catalog-app.ts
8060
+ function findCatalogApp(apps, prefixOrId) {
8061
+ const needle = prefixOrId.trim();
8062
+ if (!needle)
8063
+ return;
8064
+ const lower = needle.toLowerCase();
8065
+ return apps.find((app) => app.prefix === needle) ?? apps.find((app) => app.prefix.toLowerCase() === lower) ?? apps.find((app) => app.id === needle);
8066
+ }
8067
+
7905
8068
  // src/doctor-table.ts
7906
8069
  var ANSI = {
7907
8070
  reset: "\x1B[0m",
@@ -8369,6 +8532,206 @@ function commandChainIncludes(command, name) {
8369
8532
  return false;
8370
8533
  }
8371
8534
 
8535
+ // src/report.ts
8536
+ import { mkdir as mkdir3, writeFile as writeFile2 } from "node:fs/promises";
8537
+ import { dirname as dirname3 } from "node:path";
8538
+
8539
+ // src/github.ts
8540
+ import { appendFile, mkdir as mkdir2 } from "node:fs/promises";
8541
+ import { dirname as dirname2 } from "node:path";
8542
+ function isGithubActions(env = process.env) {
8543
+ return env.GITHUB_ACTIONS === "true";
8544
+ }
8545
+ function resolveGithubWritePath(flag, filePath, env = process.env) {
8546
+ if (flag === false)
8547
+ return null;
8548
+ const path = filePath?.trim();
8549
+ if (!path)
8550
+ return null;
8551
+ if (flag === true || isGithubActions(env))
8552
+ return path;
8553
+ return null;
8554
+ }
8555
+ function formatGithubOutputLines(fields) {
8556
+ const lines = [];
8557
+ for (const [key, value] of Object.entries(fields)) {
8558
+ if (value.includes(`
8559
+ `) || value.includes("\r")) {
8560
+ throw new Error(`GitHub output ${key} cannot contain newlines`);
8561
+ }
8562
+ lines.push(`${key}=${value}`);
8563
+ }
8564
+ return `${lines.join(`
8565
+ `)}
8566
+ `;
8567
+ }
8568
+ async function appendGithubFile(path, contents) {
8569
+ await mkdir2(dirname2(path), { recursive: true });
8570
+ const body = contents.endsWith(`
8571
+ `) ? contents : `${contents}
8572
+ `;
8573
+ await appendFile(path, body, "utf8");
8574
+ }
8575
+
8576
+ // src/report.ts
8577
+ var DEFAULT_RUN_WAIT_TIMEOUT_SECONDS = 1800;
8578
+ function normalizeReportFormat(raw) {
8579
+ const format = raw === "md" || raw === "markdown" ? "md" : raw ?? "html";
8580
+ if (format !== "html" && format !== "md") {
8581
+ throw new Error("--format must be html or md");
8582
+ }
8583
+ return format;
8584
+ }
8585
+ function parseFailOn(raw) {
8586
+ const value = (raw ?? "never").trim().toLowerCase();
8587
+ if (value === "errored" || value === "never")
8588
+ return value;
8589
+ throw new Error("--fail-on must be errored or never");
8590
+ }
8591
+ function parseTimeoutSeconds(raw, fallback = DEFAULT_RUN_WAIT_TIMEOUT_SECONDS) {
8592
+ const n = raw == null || raw.trim() === "" ? fallback : Number(raw);
8593
+ if (!Number.isFinite(n) || n <= 0) {
8594
+ throw new Error("--timeout must be a positive number of seconds");
8595
+ }
8596
+ return n;
8597
+ }
8598
+ function shouldFailOnRunStatus(failOn, status) {
8599
+ return failOn === "errored" && status === "errored";
8600
+ }
8601
+ async function resolveReportRunId(client, runIdArg, latestAppId, latestLabel) {
8602
+ const runId = runIdArg?.trim();
8603
+ if (runId && latestAppId) {
8604
+ throw new Error("Pass either <runId> or --latest <app>, not both");
8605
+ }
8606
+ if (runId)
8607
+ return runId;
8608
+ if (!latestAppId) {
8609
+ throw new Error("Provide a run id or --latest <app>");
8610
+ }
8611
+ const latest = pickLatestRun(await client.listRuns(latestAppId));
8612
+ if (!latest) {
8613
+ throw new Error(`No runs found for app ${latestLabel}`);
8614
+ }
8615
+ return latest.id;
8616
+ }
8617
+ async function loadFinishedRun(client, runId, options, deps) {
8618
+ const run = await client.getRun(runId);
8619
+ if (isTerminalRunStatus(run.status))
8620
+ return run;
8621
+ if (!options.wait) {
8622
+ throw new Error(`Run is still ${run.status}. Wait until it finishes (passed / errored / cancelled) or pass --wait.`);
8623
+ }
8624
+ const timeoutSeconds = parseTimeoutSeconds(options.timeout);
8625
+ try {
8626
+ return await waitForRun(client, runId, {
8627
+ timeoutMs: timeoutSeconds * 1000,
8628
+ onStatus: deps.onWaitStatus,
8629
+ sleep: deps.sleep,
8630
+ now: deps.now
8631
+ });
8632
+ } catch (error) {
8633
+ if (error instanceof WaitForRunTimeoutError && error.lastRun) {
8634
+ await writeRunGithubOutput(error.lastRun, undefined, deps, options);
8635
+ }
8636
+ throw error;
8637
+ }
8638
+ }
8639
+ async function writeRunGithubOutput(run, reportPath, deps, options) {
8640
+ const env = deps.env ?? process.env;
8641
+ const outputPath = resolveGithubWritePath(options.githubOutput, env.GITHUB_OUTPUT, env);
8642
+ if (!outputPath)
8643
+ return;
8644
+ const fields = {
8645
+ run_id: run.id,
8646
+ status: run.status
8647
+ };
8648
+ if (reportPath)
8649
+ fields.report_path = reportPath;
8650
+ const append = deps.appendGithubFile ?? appendGithubFile;
8651
+ await append(outputPath, formatGithubOutputLines(fields));
8652
+ }
8653
+ async function exportCatalogRunReport(client, runId, options, deps = {}) {
8654
+ const format = normalizeReportFormat(options.format);
8655
+ const failOn = parseFailOn(options.failOn);
8656
+ const env = deps.env ?? process.env;
8657
+ const log = deps.log ?? console.log;
8658
+ const write = deps.writeFile ?? writeFile2;
8659
+ const makeDir = deps.mkdir ?? mkdir3;
8660
+ const append = deps.appendGithubFile ?? appendGithubFile;
8661
+ const fetchImpl = deps.fetchImpl ?? fetch;
8662
+ const run = await loadFinishedRun(client, runId, options, deps);
8663
+ const screenshotsByStepId = {};
8664
+ const steps = run.tests.flatMap((test) => test.steps ?? []);
8665
+ await Promise.all(steps.map(async (step) => {
8666
+ if (!step.screenshotUri)
8667
+ return;
8668
+ const response = await fetchImpl(client.getRunStepScreenshotUrl(run.id, step.id));
8669
+ if (!response.ok)
8670
+ return;
8671
+ const bytes = new Uint8Array(await response.arrayBuffer());
8672
+ screenshotsByStepId[step.id] = Buffer.from(bytes).toString("base64");
8673
+ }));
8674
+ const apps = await client.listApps();
8675
+ const app = apps.find((row) => row.id === run.appId);
8676
+ const cases = await client.listCases(run.appId);
8677
+ const caseTitles = {};
8678
+ for (const item of cases) {
8679
+ caseTitles[item.id] = `#${item.number} ${item.name}`;
8680
+ }
8681
+ let deviceLabel = run.deviceId;
8682
+ try {
8683
+ const devices = await client.listDevices(run.platform, { includeUnavailable: true });
8684
+ const device = devices.devices.find((row) => row.id === run.deviceId);
8685
+ if (device) {
8686
+ deviceLabel = `${device.name} · ${run.platform} ${device.osVersion}`;
8687
+ }
8688
+ } catch {}
8689
+ const doc = buildRunReportFromCatalogRun(run, {
8690
+ appLabel: app ? `${app.prefix} — ${app.name}` : run.appId,
8691
+ deviceLabel,
8692
+ caseTitles
8693
+ }, screenshotsByStepId);
8694
+ const contents = format === "html" ? formatRunReportHtml(doc) : formatRunReportMarkdown(doc);
8695
+ const extension = format === "html" ? "html" : "md";
8696
+ const outputPath = options.output?.trim() || `${suggestedRunReportBasename(doc)}.${extension}`;
8697
+ await makeDir(dirname3(outputPath), { recursive: true });
8698
+ await write(outputPath, contents, "utf8");
8699
+ log(`wrote ${outputPath} (${doc.status}, ${steps.length} steps)`);
8700
+ const summaryPath = resolveGithubWritePath(options.githubSummary, env.GITHUB_STEP_SUMMARY, env);
8701
+ if (summaryPath) {
8702
+ await append(summaryPath, formatRunReportGithubSummary(doc));
8703
+ }
8704
+ await writeRunGithubOutput(run, outputPath, { ...deps, env, appendGithubFile: append }, options);
8705
+ return {
8706
+ outputPath,
8707
+ status: doc.status,
8708
+ runId: run.id,
8709
+ stepCount: steps.length,
8710
+ shouldFail: shouldFailOnRunStatus(failOn, doc.status)
8711
+ };
8712
+ }
8713
+ function attachReportCommandOptions(cmd) {
8714
+ return cmd.argument("[runId]", "Run id").option("--latest <app>", "Export the newest run for this app prefix or id").option("--format <format>", "html | md (default: html)", "html").option("-o, --output <path>", "Output file path (default: yoqa-run-<id>-<status>.html|md)").option("--wait", "Wait until the run finishes before exporting").option("--timeout <seconds>", "Wait timeout in seconds (default: 1800)", String(DEFAULT_RUN_WAIT_TIMEOUT_SECONDS)).option("--github-summary", "Append compact markdown to $GITHUB_STEP_SUMMARY").option("--no-github-summary", "Do not write a GitHub job summary").option("--github-output", "Write run_id, status, report_path to $GITHUB_OUTPUT").option("--no-github-output", "Do not write GitHub step outputs").option("--fail-on <when>", "errored | never (default: never)", "never");
8715
+ }
8716
+ function createWaitStatusLogger(write = console.error) {
8717
+ let last = "";
8718
+ return (run) => {
8719
+ if (run.status === last)
8720
+ return;
8721
+ last = run.status;
8722
+ write(`run ${run.id} ${run.status}`);
8723
+ };
8724
+ }
8725
+ async function maybeWriteGithubRunOutput(run, flag, env = process.env, append = appendGithubFile) {
8726
+ const outputPath = resolveGithubWritePath(flag, env.GITHUB_OUTPUT, env);
8727
+ if (!outputPath)
8728
+ return;
8729
+ await append(outputPath, formatGithubOutputLines({ run_id: run.id, status: run.status }));
8730
+ }
8731
+ function attachGithubOutputOptions(cmd) {
8732
+ return cmd.option("--github-output", "Write run_id and status to $GITHUB_OUTPUT").option("--no-github-output", "Do not write GitHub step outputs");
8733
+ }
8734
+
8372
8735
  // src/main.ts
8373
8736
  function runnerBaseUrl() {
8374
8737
  return runnerBaseUrlFromEnv();
@@ -8423,12 +8786,37 @@ program2.hook("preAction", async (_thisCommand, actionCommand) => {
8423
8786
  });
8424
8787
  async function resolveAppId(c, prefixOrId) {
8425
8788
  const apps = await c.listApps();
8426
- const match = apps.find((a) => a.prefix === prefixOrId) ?? apps.find((a) => a.id === prefixOrId);
8789
+ const match = findCatalogApp(apps, prefixOrId);
8427
8790
  if (!match) {
8428
8791
  throw new Error(`App not found: ${prefixOrId}. Run: yoqa apps list`);
8429
8792
  }
8430
8793
  return { id: match.id, prefix: match.prefix, name: match.name };
8431
8794
  }
8795
+ async function loadCaseScriptFile(file) {
8796
+ const raw = await readFile2(file, "utf8");
8797
+ const parsed = caseScriptSchema.safeParse(JSON.parse(raw));
8798
+ if (!parsed.success) {
8799
+ throw new Error(`Invalid CaseScript: ${parsed.error.message}`);
8800
+ }
8801
+ return parsed.data;
8802
+ }
8803
+ async function handleReportCommand(commandLabel, runIdArg, options) {
8804
+ try {
8805
+ const c = client(options.baseUrl);
8806
+ let latestAppId;
8807
+ if (options.latest) {
8808
+ latestAppId = (await resolveAppId(c, options.latest)).id;
8809
+ }
8810
+ const runId = await resolveReportRunId(c, runIdArg, latestAppId, options.latest ?? "");
8811
+ const result = await exportCatalogRunReport(c, runId, options, {
8812
+ onWaitStatus: createWaitStatusLogger()
8813
+ });
8814
+ if (result.shouldFail)
8815
+ process.exitCode = 1;
8816
+ } catch (error) {
8817
+ fail(commandLabel, error);
8818
+ }
8819
+ }
8432
8820
  program2.command("health").description("Check that the local runner is reachable").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).action(async (options) => {
8433
8821
  try {
8434
8822
  const health = await client(options.baseUrl).health();
@@ -8877,6 +9265,25 @@ appsCmd.command("list").option("--base-url <url>", "Runner base URL", runnerBase
8877
9265
  fail("apps list", error);
8878
9266
  }
8879
9267
  });
9268
+ appsCmd.command("create").description("Create a local catalog app").requiredOption("--name <name>", "Display name").option("--prefix <prefix>", "CLI prefix (allocated from name when omitted)").option("--ios-bundle-id <id>", "iOS bundle id").option("--android-application-id <id>", "Android application id").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").action(async (options) => {
9269
+ try {
9270
+ const c = client(options.baseUrl);
9271
+ let app = await c.createApp({ name: options.name, prefix: options.prefix });
9272
+ if (options.iosBundleId || options.androidApplicationId) {
9273
+ app = await c.updateApp(app.id, {
9274
+ iosBundleId: options.iosBundleId,
9275
+ androidApplicationId: options.androidApplicationId
9276
+ });
9277
+ }
9278
+ if (options.json) {
9279
+ console.log(JSON.stringify(app, null, 2));
9280
+ return;
9281
+ }
9282
+ console.log(`created ${app.prefix}`);
9283
+ } catch (error) {
9284
+ fail("apps create", error);
9285
+ }
9286
+ });
8880
9287
  appsCmd.command("get").argument("<app>", "App prefix or id").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").action(async (appArg, options) => {
8881
9288
  try {
8882
9289
  const c = client(options.baseUrl);
@@ -8960,15 +9367,20 @@ casesCmd.command("get").argument("<app>", "App prefix or id").argument("<number>
8960
9367
  fail("cases get", error);
8961
9368
  }
8962
9369
  });
8963
- casesCmd.command("create").argument("<app>", "App prefix or id").requiredOption("--title <title>", "Case title").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").action(async (appArg, options) => {
9370
+ casesCmd.command("create").argument("<app>", "App prefix or id").requiredOption("--title <title>", "Case title").option("--script-file <path>", "Path to a .yoqa.json CaseScript to attach").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").action(async (appArg, options) => {
8964
9371
  try {
8965
9372
  const c = client(options.baseUrl);
8966
9373
  const resolved = await resolveAppId(c, appArg);
8967
- const created = await c.createCase(resolved.id, {
9374
+ let created = await c.createCase(resolved.id, {
8968
9375
  name: options.title,
8969
9376
  flows: [],
8970
9377
  tags: []
8971
9378
  });
9379
+ if (options.scriptFile) {
9380
+ created = await c.updateCase(created.id, {
9381
+ script: await loadCaseScriptFile(options.scriptFile)
9382
+ });
9383
+ }
8972
9384
  if (options.json) {
8973
9385
  console.log(JSON.stringify(created, null, 2));
8974
9386
  return;
@@ -8978,7 +9390,7 @@ casesCmd.command("create").argument("<app>", "App prefix or id").requiredOption(
8978
9390
  fail("cases create", error);
8979
9391
  }
8980
9392
  });
8981
- casesCmd.command("update").argument("<app>", "App prefix or id").argument("<number>", "Case number", (v) => Number(v)).option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--title <title>", "New title").option("--json", "Print raw JSON").action(async (appArg, number, options) => {
9393
+ casesCmd.command("update").argument("<app>", "App prefix or id").argument("<number>", "Case number", (v) => Number(v)).option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--title <title>", "New title").option("--script-file <path>", "Path to a .yoqa.json CaseScript to attach").option("--json", "Print raw JSON").action(async (appArg, number, options) => {
8982
9394
  try {
8983
9395
  const c = client(options.baseUrl);
8984
9396
  const resolved = await resolveAppId(c, appArg);
@@ -8986,7 +9398,10 @@ casesCmd.command("update").argument("<app>", "App prefix or id").argument("<numb
8986
9398
  const found = cases.find((item) => item.number === number);
8987
9399
  if (!found)
8988
9400
  throw new Error(`Case #${number} not found`);
8989
- const updated = await c.updateCase(found.id, { name: options.title });
9401
+ const updated = await c.updateCase(found.id, {
9402
+ name: options.title,
9403
+ script: options.scriptFile ? await loadCaseScriptFile(options.scriptFile) : undefined
9404
+ });
8990
9405
  if (options.json) {
8991
9406
  console.log(JSON.stringify(updated, null, 2));
8992
9407
  return;
@@ -9151,6 +9566,9 @@ buildsCmd.command("delete").argument("<buildId>", "Build id").option("--base-url
9151
9566
  fail("builds delete", error);
9152
9567
  }
9153
9568
  });
9569
+ attachReportCommandOptions(program2.command("report").description("Export a detailed HTML or Markdown report with step screenshots")).option("--base-url <url>", "Runner base URL", runnerBaseUrl()).action(async (runId, options) => {
9570
+ await handleReportCommand("report", runId, options);
9571
+ });
9154
9572
  var runsCmd = program2.command("runs").description("Create and inspect local agent runs");
9155
9573
  runsCmd.command("list").argument("<app>", "App prefix or id").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").action(async (appArg, options) => {
9156
9574
  try {
@@ -9183,55 +9601,28 @@ runsCmd.command("get").argument("<runId>", "Run id").option("--base-url <url>",
9183
9601
  fail("runs get", error);
9184
9602
  }
9185
9603
  });
9186
- runsCmd.command("report").description("Export a detailed HTML or Markdown report with step screenshots").argument("<runId>", "Run id").option("--format <format>", "html | md (default: html)", "html").option("-o, --output <path>", "Output file path (default: yoqa-run-<id>-<status>.html|md)").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).action(async (runId, options) => {
9604
+ attachReportCommandOptions(runsCmd.command("report").description("Export a detailed HTML or Markdown report with step screenshots")).option("--base-url <url>", "Runner base URL", runnerBaseUrl()).action(async (runId, options) => {
9605
+ await handleReportCommand("runs report", runId, options);
9606
+ });
9607
+ attachGithubOutputOptions(runsCmd.command("wait").description("Wait until a catalog run finishes").argument("<runId>", "Run id").option("--timeout <seconds>", "Wait timeout in seconds (default: 1800)", String(DEFAULT_RUN_WAIT_TIMEOUT_SECONDS))).option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").action(async (runId, options) => {
9187
9608
  try {
9188
- const format = options.format === "md" || options.format === "markdown" ? "md" : options.format;
9189
- if (format !== "html" && format !== "md") {
9190
- throw new Error("--format must be html or md");
9191
- }
9192
- const c = client(options.baseUrl);
9193
- const run = await c.getRun(runId);
9194
- if (run.status === "queued" || run.status === "running") {
9195
- throw new Error(`Run is still ${run.status}. Wait until it finishes (passed / errored / cancelled).`);
9196
- }
9197
- const screenshotsByStepId = {};
9198
- const steps = run.tests.flatMap((test) => test.steps ?? []);
9199
- await Promise.all(steps.map(async (step) => {
9200
- if (!step.screenshotUri)
9201
- return;
9202
- const response = await fetch(c.getRunStepScreenshotUrl(run.id, step.id));
9203
- if (!response.ok)
9204
- return;
9205
- const bytes = new Uint8Array(await response.arrayBuffer());
9206
- screenshotsByStepId[step.id] = Buffer.from(bytes).toString("base64");
9207
- }));
9208
- const apps = await c.listApps();
9209
- const app = apps.find((row) => row.id === run.appId);
9210
- const cases = await c.listCases(run.appId);
9211
- const caseTitles = {};
9212
- for (const item of cases) {
9213
- caseTitles[item.id] = `#${item.number} ${item.name}`;
9609
+ const finished = await waitForRun(client(options.baseUrl), runId, {
9610
+ timeoutMs: parseTimeoutSeconds(options.timeout) * 1000,
9611
+ onStatus: createWaitStatusLogger()
9612
+ });
9613
+ await maybeWriteGithubRunOutput(finished, options.githubOutput);
9614
+ if (options.json) {
9615
+ console.log(JSON.stringify(finished, null, 2));
9616
+ } else {
9617
+ console.log(`${finished.id} ${finished.status}`);
9214
9618
  }
9215
- let deviceLabel = run.deviceId;
9216
- try {
9217
- const devices2 = await c.listDevices(run.platform, { includeUnavailable: true });
9218
- const device = devices2.devices.find((row) => row.id === run.deviceId);
9219
- if (device) {
9220
- deviceLabel = `${device.name} · ${run.platform} ${device.osVersion}`;
9221
- }
9222
- } catch {}
9223
- const doc = buildRunReportFromCatalogRun(run, {
9224
- appLabel: app ? `${app.prefix} — ${app.name}` : run.appId,
9225
- deviceLabel,
9226
- caseTitles
9227
- }, screenshotsByStepId);
9228
- const contents = format === "html" ? formatRunReportHtml(doc) : formatRunReportMarkdown(doc);
9229
- const extension = format === "html" ? "html" : "md";
9230
- const outputPath = options.output?.trim() || `${suggestedRunReportBasename(doc)}.${extension}`;
9231
- await writeFile2(outputPath, contents, "utf8");
9232
- console.log(`wrote ${outputPath} (${doc.status}, ${steps.length} steps)`);
9619
+ if (shouldFailOnRunStatus("errored", finished.status))
9620
+ process.exitCode = 1;
9233
9621
  } catch (error) {
9234
- fail("runs report", error);
9622
+ if (error instanceof WaitForRunTimeoutError && error.lastRun) {
9623
+ await maybeWriteGithubRunOutput(error.lastRun, options.githubOutput);
9624
+ }
9625
+ fail("runs wait", error);
9235
9626
  }
9236
9627
  });
9237
9628
  runsCmd.command("delete").argument("<runId>", "Run id").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).action(async (runId, options) => {
@@ -9242,7 +9633,7 @@ runsCmd.command("delete").argument("<runId>", "Run id").option("--base-url <url>
9242
9633
  fail("runs delete", error);
9243
9634
  }
9244
9635
  });
9245
- 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("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON").action(async (appArg, options) => {
9636
+ 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("--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) => {
9246
9637
  try {
9247
9638
  const c = client(options.baseUrl);
9248
9639
  const resolved = await resolveAppId(c, appArg);
@@ -9274,7 +9665,7 @@ runsCmd.command("create").argument("<app>", "App prefix or id").requiredOption("
9274
9665
  if (options.mode && !executionMode) {
9275
9666
  throw new Error("--mode must be auto, script, or agent");
9276
9667
  }
9277
- const run = await c.createRun({
9668
+ let run = await c.createRun({
9278
9669
  appId: resolved.id,
9279
9670
  caseIds,
9280
9671
  deviceId,
@@ -9283,11 +9674,28 @@ runsCmd.command("create").argument("<app>", "App prefix or id").requiredOption("
9283
9674
  buildPath: options.buildPath,
9284
9675
  executionMode
9285
9676
  });
9677
+ if (options.wait) {
9678
+ try {
9679
+ run = await waitForRun(c, run.id, {
9680
+ timeoutMs: parseTimeoutSeconds(options.timeout) * 1000,
9681
+ onStatus: createWaitStatusLogger()
9682
+ });
9683
+ } catch (error) {
9684
+ if (error instanceof WaitForRunTimeoutError && error.lastRun) {
9685
+ await maybeWriteGithubRunOutput(error.lastRun, options.githubOutput);
9686
+ }
9687
+ throw error;
9688
+ }
9689
+ }
9690
+ await maybeWriteGithubRunOutput(run, options.githubOutput);
9286
9691
  if (options.json) {
9287
9692
  console.log(JSON.stringify(run, null, 2));
9288
- return;
9693
+ } else {
9694
+ console.log(`created ${run.id} (${run.status})`);
9695
+ }
9696
+ if (options.wait && shouldFailOnRunStatus("errored", run.status)) {
9697
+ process.exitCode = 1;
9289
9698
  }
9290
- console.log(`created ${run.id} (${run.status})`);
9291
9699
  } catch (error) {
9292
9700
  fail("runs create", error);
9293
9701
  }
@@ -9295,17 +9703,12 @@ runsCmd.command("create").argument("<app>", "App prefix or id").requiredOption("
9295
9703
  var scriptCmd = program2.command("script").description("Replay exported CaseScript files");
9296
9704
  scriptCmd.command("run").argument("<file>", "Path to a .yoqa.json / CaseScript JSON file").option("--base-url <url>", "Runner base URL", runnerBaseUrl()).option("--json", "Print raw JSON per step").action(async (file, options) => {
9297
9705
  try {
9298
- const raw = await readFile2(file, "utf8");
9299
- const parsed = caseScriptSchema.safeParse(JSON.parse(raw));
9300
- if (!parsed.success) {
9301
- throw new Error(`Invalid CaseScript: ${parsed.error.message}`);
9302
- }
9706
+ const script = await loadCaseScriptFile(file);
9303
9707
  const c = client(options.baseUrl);
9304
9708
  const active = await c.getActiveDevice();
9305
9709
  if (!active) {
9306
9710
  throw new Error("No active device session. Connect first: yoqa devices connect <id>");
9307
9711
  }
9308
- const script = parsed.data;
9309
9712
  let idx = 0;
9310
9713
  for (const action2 of script.actions) {
9311
9714
  idx += 1;
@@ -9319,7 +9722,46 @@ scriptCmd.command("run").argument("<file>", "Path to a .yoqa.json / CaseScript J
9319
9722
  await sleep(ms);
9320
9723
  continue;
9321
9724
  }
9322
- const body = action2.type === "tap" ? await c.performAction({ kind: "tap", x: action2.x, y: action2.y }) : await c.performAction({ kind: "input", text: action2.text });
9725
+ if (action2.type === "assert") {
9726
+ const timeoutMs = action2.timeoutMs ?? 5000;
9727
+ const deadline = Date.now() + timeoutMs;
9728
+ for (;; ) {
9729
+ const screen = await c.getScreen();
9730
+ const found = screenHasText(screen.elements, action2.text);
9731
+ if (action2.assertion === "visible" && found)
9732
+ break;
9733
+ if (action2.assertion === "not-visible" && !found)
9734
+ break;
9735
+ if (Date.now() >= deadline) {
9736
+ throw new Error(action2.assertion === "visible" ? `Expected visible text not found within ${Math.round(timeoutMs / 1000)}s: ${action2.text}` : `Unexpected text still visible after ${Math.round(timeoutMs / 1000)}s: ${action2.text}`);
9737
+ }
9738
+ await sleep(400);
9739
+ }
9740
+ if (options.json) {
9741
+ console.log(JSON.stringify({
9742
+ ok: true,
9743
+ kind: "assert",
9744
+ assertion: action2.assertion,
9745
+ text: action2.text,
9746
+ step: idx
9747
+ }, null, 2));
9748
+ } else {
9749
+ console.log(`ok assert ${action2.assertion} (${idx}/${script.actions.length})`);
9750
+ }
9751
+ continue;
9752
+ }
9753
+ const tapBody = { kind: "tap" };
9754
+ if (action2.type === "tap") {
9755
+ if (action2.label)
9756
+ tapBody.label = action2.label;
9757
+ if (action2.id)
9758
+ tapBody.id = action2.id;
9759
+ if (action2.x != null)
9760
+ tapBody.x = action2.x;
9761
+ if (action2.y != null)
9762
+ tapBody.y = action2.y;
9763
+ }
9764
+ const body = action2.type === "tap" ? await c.performAction(tapBody) : await c.performAction({ kind: "input", text: action2.text });
9323
9765
  if (options.json) {
9324
9766
  console.log(JSON.stringify({ ...body, step: idx }, null, 2));
9325
9767
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoqa/cli",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
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.16"
41
+ "@yoqa/runner": "0.3.18"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/node": "^22.15.30",