vibeview 0.27.1 → 0.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -22,13 +22,15 @@ import {
22
22
  readDevState,
23
23
  readMetroState,
24
24
  runAgentVerb
25
- } from "./chunk-7IOGMM5W.js";
25
+ } from "./chunk-3DKY443B.js";
26
26
  import {
27
27
  ApiClient,
28
+ ApiStatusError,
28
29
  DEVICE_STATUS_FILTERS,
29
30
  EXIT_INFRA_ERROR,
30
31
  EXIT_PASS,
31
32
  EXIT_TEST_FAIL,
33
+ NetworkError,
32
34
  NotFoundError,
33
35
  UsageError,
34
36
  createSpinner,
@@ -36,11 +38,12 @@ import {
36
38
  formatUtcTimestamp,
37
39
  loadConfig,
38
40
  printJsonError,
41
+ queueReasonCopy,
39
42
  reportCommandError,
40
43
  resolveAuth,
41
44
  saveConfig,
42
45
  uploadApp
43
- } from "./chunk-FTTWLCTE.js";
46
+ } from "./chunk-PBMB6TG4.js";
44
47
 
45
48
  // src/cli.ts
46
49
  import { createRequire } from "module";
@@ -94,11 +97,8 @@ function formatHuman(result, verbose) {
94
97
  const icon = result.status === "passed" ? chalk.green("PASS") : chalk.red("FAIL");
95
98
  const duration = result.duration_ms ? `${(result.duration_ms / 1e3).toFixed(1)}s` : "-";
96
99
  lines.push("");
97
- if (result.suite_name) {
98
- lines.push(` ${icon} ${chalk.bold(result.suite_name)} ${chalk.dim(duration)}`);
99
- } else {
100
- lines.push(` ${icon} ${chalk.bold(result.run_id)} ${chalk.dim(duration)}`);
101
- }
100
+ const title = result.suite_name || result.case_name || result.run_id;
101
+ lines.push(` ${icon} ${chalk.bold(title)} ${chalk.dim(duration)}`);
102
102
  const cases = resultCases(result);
103
103
  const passed = cases.filter((t) => t.status === "passed").length;
104
104
  const total = cases.length;
@@ -123,7 +123,7 @@ function formatHuman(result, verbose) {
123
123
  }
124
124
  if (result.steps && result.steps.length > 0) {
125
125
  lines.push("");
126
- if (result.case_name) {
126
+ if (result.case_name && result.case_name !== title) {
127
127
  lines.push(` ${chalk.bold(result.case_name)}`);
128
128
  }
129
129
  for (const step of result.steps) {
@@ -173,31 +173,64 @@ function formatJunit(result) {
173
173
  lines.push(
174
174
  ` <testsuite name="${suiteName}" tests="${tests}" failures="${failures}" errors="${errors}" time="${totalTime}" timestamp="${timestamp}">`
175
175
  );
176
+ if (result.url) {
177
+ lines.push(" <properties>");
178
+ lines.push(` <property name="vibeview.run_url" value="${escapeXml(result.url)}" />`);
179
+ lines.push(" </properties>");
180
+ }
176
181
  for (const tr of cases) {
177
182
  const caseName = escapeXml(tr.case_name);
178
183
  const caseTime = tr.duration_ms ? (tr.duration_ms / 1e3).toFixed(1) : "0";
179
- if (tr.status === "passed") {
184
+ const out = tr.url ? ` <system-out>${escapeXml(tr.url)}</system-out>` : null;
185
+ if (tr.status === "passed" && !out) {
180
186
  lines.push(` <testcase name="${caseName}" classname="${suiteName}" time="${caseTime}" />`);
181
- } else if (tr.status === "failed") {
187
+ continue;
188
+ }
189
+ lines.push(` <testcase name="${caseName}" classname="${suiteName}" time="${caseTime}">`);
190
+ if (tr.status === "failed") {
182
191
  const msg = escapeXml(tr.error_message || "Test failed");
183
- lines.push(` <testcase name="${caseName}" classname="${suiteName}" time="${caseTime}">`);
184
192
  lines.push(` <failure message="${msg}" type="TestFailure">${msg}</failure>`);
185
- lines.push(" </testcase>");
186
- } else {
193
+ } else if (tr.status !== "passed") {
187
194
  const msg = escapeXml(tr.error_message || "Infrastructure error");
188
- lines.push(` <testcase name="${caseName}" classname="${suiteName}" time="${caseTime}">`);
189
195
  lines.push(` <error message="${msg}" type="InfraError">${msg}</error>`);
190
- lines.push(" </testcase>");
191
196
  }
197
+ if (out) lines.push(out);
198
+ lines.push(" </testcase>");
192
199
  }
193
200
  lines.push(" </testsuite>");
194
201
  lines.push("</testsuites>");
195
202
  return lines.join("\n");
196
203
  }
197
- function formatMultiSuiteJunit(result) {
204
+ function formatJunitError(name, message, url) {
205
+ const n = escapeXml(name);
206
+ const msg = escapeXml(message);
207
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
208
+ const lines = [];
209
+ lines.push('<?xml version="1.0" encoding="UTF-8"?>');
210
+ lines.push(`<testsuites name="vibeview" tests="1" failures="0" errors="1" time="0">`);
211
+ lines.push(
212
+ ` <testsuite name="${n}" tests="1" failures="0" errors="1" time="0" timestamp="${timestamp}">`
213
+ );
214
+ if (url) {
215
+ lines.push(" <properties>");
216
+ lines.push(` <property name="vibeview.run_url" value="${escapeXml(url)}" />`);
217
+ lines.push(" </properties>");
218
+ }
219
+ lines.push(` <testcase name="${n}" classname="${n}" time="0">`);
220
+ lines.push(` <error message="${msg}" type="InfraError">${msg}</error>`);
221
+ lines.push(" </testcase>");
222
+ lines.push(" </testsuite>");
223
+ lines.push("</testsuites>");
224
+ return lines.join("\n");
225
+ }
226
+ var MULTI_TERMINAL = /* @__PURE__ */ new Set(["passed", "failed", "error", "cancelled"]);
227
+ function formatMultiSuiteJunit(result, opts = {}) {
228
+ const unfinished = (status) => opts.timedOut !== void 0 && !MULTI_TERMINAL.has(status);
229
+ const errored = (status) => status === "error" || unfinished(status);
198
230
  const tests = result.suite_runs.reduce((sum, sr) => sum + sr.passed + sr.failed, 0);
199
231
  const failures = result.suite_runs.reduce((sum, sr) => sum + sr.failed, 0);
200
- const totalErrors = result.suite_runs.filter((sr) => sr.status === "error").length;
232
+ const noSuites = opts.timedOut !== void 0 && result.suite_runs.length === 0;
233
+ const totalErrors = result.suite_runs.filter((sr) => errored(sr.status)).length + (noSuites ? 1 : 0);
201
234
  const timestamp = result.started_at ?? (/* @__PURE__ */ new Date()).toISOString();
202
235
  const lines = [];
203
236
  lines.push('<?xml version="1.0" encoding="UTF-8"?>');
@@ -208,43 +241,114 @@ function formatMultiSuiteJunit(result) {
208
241
  const suiteName = escapeXml(sr.suite_name || sr.suite_id);
209
242
  const suiteTests = sr.passed + sr.failed;
210
243
  const suiteFails = sr.failed;
211
- const suiteErrors = sr.status === "error" ? 1 : 0;
244
+ const suiteErrors = errored(sr.status) ? 1 : 0;
212
245
  lines.push(
213
246
  ` <testsuite name="${suiteName}" tests="${suiteTests}" failures="${suiteFails}" errors="${suiteErrors}" timestamp="${timestamp}">`
214
247
  );
215
- if (suiteErrors > 0) {
248
+ if (sr.url) {
249
+ lines.push(" <properties>");
250
+ lines.push(` <property name="vibeview.run_url" value="${escapeXml(sr.url)}" />`);
251
+ lines.push(" </properties>");
252
+ }
253
+ if (unfinished(sr.status)) {
254
+ const msg = escapeXml(`${opts.timedOut} (suite was ${sr.status})`);
255
+ lines.push(` <error message="${msg}" type="InfraError" />`);
256
+ } else if (suiteErrors > 0) {
216
257
  lines.push(` <error message="Suite errored" type="InfraError" />`);
217
258
  }
218
259
  lines.push(" </testsuite>");
219
260
  }
261
+ if (noSuites) {
262
+ const msg = escapeXml(opts.timedOut);
263
+ lines.push(
264
+ ` <testsuite name="vibeview run-suites" tests="0" failures="0" errors="1" timestamp="${timestamp}">`
265
+ );
266
+ lines.push(` <error message="${msg}" type="InfraError" />`);
267
+ lines.push(" </testsuite>");
268
+ }
220
269
  lines.push("</testsuites>");
221
270
  return lines.join("\n");
222
271
  }
223
272
 
224
273
  // src/lib/poll.ts
225
- function printBelowSpinner(spinner, line) {
226
- const text = spinner.text;
274
+ function resumeSpinner(spinner, text) {
275
+ if (spinner.isEnabled === false) {
276
+ if (text !== void 0) spinner.text = text;
277
+ return;
278
+ }
279
+ spinner.start(text);
280
+ }
281
+ function printBelowSpinner(spinner, line, nextText) {
282
+ const text = nextText ?? spinner.text;
227
283
  spinner.stop();
228
284
  process.stderr.write(line + "\n");
229
- spinner.start(text);
285
+ resumeSpinner(spinner, text);
286
+ }
287
+ function printStepLines(stepNum, total, status, text, actions, error) {
288
+ const icon = status === "passed" ? chalk2.green("\u2713") : status === "failed" ? chalk2.red("\u2717") : chalk2.yellow("!");
289
+ process.stderr.write(` ${icon} Step ${stepNum}/${total}: ${text}
290
+ `);
291
+ for (const action of actions ?? []) {
292
+ process.stderr.write(` ${chalk2.dim(formatAction(action))}
293
+ `);
294
+ }
295
+ if (error) process.stderr.write(` ${chalk2.red(error)}
296
+ `);
297
+ }
298
+ var PollTimeoutError = class extends Error {
299
+ constructor(message) {
300
+ super(message);
301
+ this.name = "PollTimeoutError";
302
+ }
303
+ };
304
+ var POLL_RETRY_DELAYS_MS = [2e3, 4e3, 8e3, 16e3, 3e4];
305
+ function isTransientPollError(err) {
306
+ if (err instanceof NetworkError) return true;
307
+ if (err instanceof ApiStatusError) {
308
+ return err.status === 408 || err.status === 429 || err.status >= 500;
309
+ }
310
+ return false;
311
+ }
312
+ async function retryTransient(fn, opts = {}) {
313
+ for (let attempt = 0; ; attempt++) {
314
+ try {
315
+ return await fn();
316
+ } catch (err) {
317
+ const delay = POLL_RETRY_DELAYS_MS[attempt];
318
+ if (!isTransientPollError(err) || delay === void 0) throw err;
319
+ if (opts.deadline !== void 0 && Date.now() + delay >= opts.deadline) throw err;
320
+ const message = err instanceof Error ? err.message : String(err);
321
+ process.stderr.write(
322
+ `${opts.label ?? "Status poll"} failed (${message}); retrying in ${delay / 1e3}s (${attempt + 1}/${POLL_RETRY_DELAYS_MS.length})
323
+ `
324
+ );
325
+ await new Promise((r) => setTimeout(r, delay));
326
+ }
327
+ }
328
+ }
329
+ function keepAliveEnabled(output) {
330
+ return output !== "json" && output !== "junit";
230
331
  }
231
- async function pollUntilComplete(client, runId, timeoutMs, spinner, onQueueObserved) {
332
+ async function pollUntilComplete(client, runId, timeoutMs, spinner, onQueueObserved, opts = {}) {
232
333
  const deadline = Date.now() + timeoutMs;
233
334
  const isCI = process.env.CI === "true" || process.env.CI === "1" || !process.stdout.isTTY;
335
+ const keepAlive = isCI && opts.keepAlive !== false;
234
336
  let lastLogTime = Date.now();
235
337
  let lastSeenEventCount = 0;
236
338
  let totalSteps;
237
339
  let currentStepLabel = "";
340
+ const printedSteps = /* @__PURE__ */ new Set();
238
341
  let cachedDeviceLabel = null;
342
+ let lastQueueReason = null;
239
343
  const caseStateBefore = /* @__PURE__ */ new Map();
240
344
  const caseStartedPrinted = /* @__PURE__ */ new Set();
241
345
  while (Date.now() < deadline) {
242
- const result = await client.getRunStatus(runId);
346
+ const result = await retryTransient(() => client.getRunStatus(runId), { deadline });
243
347
  const progress = result.progress || [];
244
348
  if (progress.length > lastSeenEventCount) {
245
349
  const newEvents = progress.slice(lastSeenEventCount);
246
350
  for (const evt of newEvents) {
247
- const ctx = { spinner, totalSteps, currentStepLabel };
351
+ const ctx = { spinner, totalSteps, currentStepLabel, printedSteps };
248
352
  displayProgressEvent(evt, ctx);
249
353
  totalSteps = ctx.totalSteps;
250
354
  currentStepLabel = ctx.currentStepLabel;
@@ -277,6 +381,20 @@ async function pollUntilComplete(client, runId, timeoutMs, spinner, onQueueObser
277
381
  }
278
382
  }
279
383
  if (["passed", "failed", "error", "cancelled"].includes(result.status)) {
384
+ if (printedSteps.size > 0 && Array.isArray(result.steps)) {
385
+ spinner.stop();
386
+ result.steps.forEach((st, i) => {
387
+ if (printedSteps.has(i)) return;
388
+ printStepLines(
389
+ i + 1,
390
+ totalSteps ?? result.steps.length,
391
+ st.status,
392
+ st.step,
393
+ st.actions,
394
+ st.error
395
+ );
396
+ });
397
+ }
280
398
  return result;
281
399
  }
282
400
  if (result.status === "queued" && result.queue) {
@@ -299,6 +417,11 @@ async function pollUntilComplete(client, runId, timeoutMs, spinner, onQueueObser
299
417
  label = "the device pool";
300
418
  }
301
419
  spinner.text = `Queued \u2014 position ${q.position} in line for ${label}`;
420
+ const why = queueReasonCopy(q.reason);
421
+ if (why && why !== lastQueueReason) {
422
+ printBelowSpinner(spinner, `${chalk2.yellow("\u23F3")} Waiting for a device: ${why}`);
423
+ }
424
+ lastQueueReason = why;
302
425
  }
303
426
  if (progress.length === 0) {
304
427
  if (result.status === "pending") {
@@ -317,20 +440,25 @@ async function pollUntilComplete(client, runId, timeoutMs, spinner, onQueueObser
317
440
  spinner.text = `Status: ${result.status}...`;
318
441
  }
319
442
  }
320
- if (isCI && Date.now() - lastLogTime >= 3e4) {
321
- console.log(`Status: ${result.status}...`);
443
+ if (keepAlive && Date.now() - lastLogTime >= 3e4) {
444
+ const why = result.status === "queued" ? queueReasonCopy(result.queue?.reason) : null;
445
+ const where = result.status === "queued" && result.queue ? ` (position ${result.queue.position} in line for a device${why ? `: ${why}` : ""})` : "";
446
+ process.stderr.write(`Status: ${result.status}${where}...
447
+ `);
322
448
  lastLogTime = Date.now();
323
449
  }
324
450
  await new Promise((r) => setTimeout(r, 3e3));
325
451
  }
326
- throw new Error(`Timeout: run did not complete within ${Math.round(timeoutMs / 1e3)}s`);
452
+ throw new PollTimeoutError(
453
+ `Timeout: run did not complete within ${Math.round(timeoutMs / 1e3)}s`
454
+ );
327
455
  }
328
456
  function formatAction(action) {
329
457
  const tool = action.tool;
330
458
  const args = action.args || {};
331
459
  switch (tool) {
332
460
  case "tap":
333
- return `tap ${args.element_ref || ""}`;
461
+ return `tap ${args.ref ?? args.element_ref ?? ""}`.trimEnd();
334
462
  case "tap_coordinates":
335
463
  return `tap (${args.x}, ${args.y})`;
336
464
  case "type_text":
@@ -358,7 +486,8 @@ function displayProgressEvent(evt, ctx) {
358
486
  case "run_started":
359
487
  printBelowSpinner(
360
488
  spinner,
361
- `${chalk2.cyan("\u25B6")} AI test started \u2014 ${evt.total_steps} step(s), model: ${chalk2.bold(evt.model || "default")}`
489
+ `${chalk2.cyan("\u25B6")} AI test started \u2014 ${evt.total_steps} step(s), model: ${chalk2.bold(evt.model || "default")}`,
490
+ " Starting test..."
362
491
  );
363
492
  ctx.totalSteps = evt.total_steps ?? ctx.totalSteps;
364
493
  break;
@@ -370,23 +499,17 @@ function displayProgressEvent(evt, ctx) {
370
499
  break;
371
500
  }
372
501
  case "step_completed": {
373
- const stepNum = (evt.step_index ?? 0) + 1;
502
+ const stepIndex = evt.step_index ?? 0;
503
+ const stepNum = stepIndex + 1;
374
504
  const total = ctx.totalSteps ?? evt.total_steps ?? "?";
375
- const icon = evt.status === "passed" ? chalk2.green("\u2713") : evt.status === "failed" ? chalk2.red("\u2717") : chalk2.yellow("!");
505
+ if (ctx.printedSteps.has(stepIndex)) break;
506
+ ctx.printedSteps.add(stepIndex);
376
507
  spinner.stop();
377
- process.stderr.write(` ${icon} Step ${stepNum}/${total}: ${evt.step_text || ""}
378
- `);
379
- if (evt.actions && evt.actions.length > 0) {
380
- for (const action of evt.actions) {
381
- process.stderr.write(` ${chalk2.dim(formatAction(action))}
382
- `);
383
- }
384
- }
385
- if (evt.error) {
386
- process.stderr.write(` ${chalk2.red(evt.error)}
387
- `);
388
- }
389
- spinner.start();
508
+ printStepLines(stepNum, total, evt.status, evt.step_text || "", evt.actions, evt.error);
509
+ resumeSpinner(
510
+ spinner,
511
+ typeof total === "number" && stepNum >= total ? " Finishing..." : " Next step..."
512
+ );
390
513
  break;
391
514
  }
392
515
  case "conversation_turn": {
@@ -405,7 +528,7 @@ function displayProgressEvent(evt, ctx) {
405
528
  `${icon} Run ${evt.status}${evt.duration_ms ? ` (${(evt.duration_ms / 1e3).toFixed(1)}s)` : ""}
406
529
  `
407
530
  );
408
- spinner.start();
531
+ resumeSpinner(spinner);
409
532
  break;
410
533
  }
411
534
  case "app_installing":
@@ -418,6 +541,17 @@ function displayProgressEvent(evt, ctx) {
418
541
  break;
419
542
  }
420
543
  }
544
+ function outputRunError(err, options, ctx) {
545
+ if (options.output === "json") {
546
+ const extra = {};
547
+ if (ctx.runId) extra.run_id = ctx.runId;
548
+ if (ctx.url) extra.url = ctx.url;
549
+ printJsonError(err, extra);
550
+ } else if (options.output === "junit") {
551
+ const message = err instanceof Error ? err.message : String(err);
552
+ console.log(formatJunitError(ctx.name, message, ctx.url));
553
+ }
554
+ }
421
555
  function outputResult(result, options) {
422
556
  switch (options.output) {
423
557
  case "json":
@@ -443,6 +577,7 @@ async function runSuite(suiteId, options) {
443
577
  const spinner = createSpinner("Starting suite run...");
444
578
  let sessionId;
445
579
  let lastQueueEntryId;
580
+ let runId;
446
581
  let cleaning = false;
447
582
  const cleanup = async (signal) => {
448
583
  if (cleaning) process.exit(130);
@@ -503,6 +638,7 @@ ${signal} received.
503
638
  metadata: options.metadata
504
639
  });
505
640
  sessionId = response.session_id;
641
+ runId = response.run_id;
506
642
  spinner.stop();
507
643
  process.stderr.write(
508
644
  `${chalk3.dim("Suite:")} ${suiteId}
@@ -519,7 +655,8 @@ ${chalk3.dim("Run:")} ${response.run_id}
519
655
  spinner,
520
656
  (entryId) => {
521
657
  lastQueueEntryId = entryId;
522
- }
658
+ },
659
+ { keepAlive: keepAliveEnabled(options.output) }
523
660
  );
524
661
  spinner.stop();
525
662
  if (process.stdin.isTTY) {
@@ -530,7 +667,11 @@ ${chalk3.dim("Run:")} ${response.run_id}
530
667
  process.exit(mapExitCode(result.status));
531
668
  } catch (err) {
532
669
  spinner.fail(err instanceof Error ? err.message : "Unknown error");
533
- if (options.output === "json") printJsonError(err);
670
+ outputRunError(err, options, {
671
+ name: suiteId,
672
+ runId,
673
+ url: runId ? `${baseUrl}/tests/runs/${runId}` : void 0
674
+ });
534
675
  if (lastQueueEntryId !== void 0) {
535
676
  try {
536
677
  await client.cancelQueueEntry(lastQueueEntryId);
@@ -609,7 +750,8 @@ ${signal} received.
609
750
  model: opts.model,
610
751
  provider: opts.provider,
611
752
  mode: opts.mode,
612
- device_category: opts.device ? void 0 : opts.deviceCategory ?? "phone",
753
+ // Omitted unless asked for: the backend picks tv for each TV suite.
754
+ device_category: opts.device ? void 0 : opts.deviceCategory,
613
755
  device_id: opts.device,
614
756
  build_id: opts.build,
615
757
  metadata: opts.metadata
@@ -621,11 +763,13 @@ ${signal} received.
621
763
  const suffix = opts.platform ? ` on platform ${opts.platform}` : "";
622
764
  const noMatch = `No suites match tags: ${opts.tag.join(", ")}${suffix}`;
623
765
  if (opts.output === "json") printJsonError(noMatch);
766
+ if (opts.output === "junit") console.log(formatJunitError("vibeview run-suites", noMatch));
624
767
  process.stderr.write(chalk4.yellow(`${noMatch}
625
768
  `));
626
769
  process.exit(EXIT_INFRA_ERROR);
627
770
  }
628
771
  if (opts.output === "json") printJsonError(err);
772
+ if (opts.output === "junit") console.log(formatJunitError("vibeview run-suites", msg));
629
773
  process.stderr.write(chalk4.red(`Failed to start: ${msg}
630
774
  `));
631
775
  process.exit(EXIT_INFRA_ERROR);
@@ -657,6 +801,7 @@ ${chalk4.dim("Matched:")} ${totalMatched} suite(s)${matchedNames.length ? " \u20
657
801
  const deadline = Date.now() + totalTimeout * 1e3;
658
802
  const suiteStateBefore = /* @__PURE__ */ new Map();
659
803
  const suiteStartedPrinted = /* @__PURE__ */ new Set();
804
+ let lastQueueReason = null;
660
805
  const print = (line) => {
661
806
  spinner.stop();
662
807
  process.stderr.write(line + "\n");
@@ -664,8 +809,47 @@ ${chalk4.dim("Matched:")} ${totalMatched} suite(s)${matchedNames.length ? " \u20
664
809
  };
665
810
  const TERMINAL = /* @__PURE__ */ new Set(["passed", "failed", "error", "cancelled"]);
666
811
  let last = null;
812
+ const abandon = async (reason) => {
813
+ spinner.stop();
814
+ if (process.stdin.isTTY) {
815
+ process.stdin.setRawMode(false);
816
+ process.stdin.pause();
817
+ }
818
+ process.stderr.write(chalk4.red(`${reason}
819
+ `));
820
+ process.stderr.write("Cancelling multi-suite run...\n");
821
+ try {
822
+ await client.cancelMultiSuiteRun(start.aggregate_id);
823
+ process.stderr.write("Cancellation requested.\n");
824
+ } catch (err) {
825
+ const m = err instanceof Error ? err.message : String(err);
826
+ process.stderr.write(chalk4.yellow(`Could not cancel the run: ${m}
827
+ `));
828
+ }
829
+ if (opts.output === "json") {
830
+ printJsonError(reason, { aggregate_id: start.aggregate_id, result: last });
831
+ } else if (opts.output === "junit") {
832
+ const doc = last ?? {
833
+ id: start.aggregate_id,
834
+ status: "running",
835
+ suite_runs: [],
836
+ started_at: null,
837
+ completed_at: null,
838
+ commit_sha: null
839
+ };
840
+ process.stdout.write(formatMultiSuiteJunit(doc, { timedOut: reason }) + "\n");
841
+ }
842
+ process.exit(EXIT_INFRA_ERROR);
843
+ };
667
844
  while (Date.now() < deadline) {
668
- last = await client.getMultiSuiteRun(start.aggregate_id);
845
+ try {
846
+ last = await retryTransient(() => client.getMultiSuiteRun(start.aggregate_id), {
847
+ deadline
848
+ });
849
+ } catch (err) {
850
+ const m = err instanceof Error ? err.message : String(err);
851
+ return abandon(`Could not read the run's status: ${m}`);
852
+ }
669
853
  let doneCount = 0;
670
854
  let queuedChild;
671
855
  for (const sr of last.suite_runs) {
@@ -696,23 +880,31 @@ ${chalk4.dim("Matched:")} ${totalMatched} suite(s)${matchedNames.length ? " \u20
696
880
  if (queuedChild && queuedChild.queue) {
697
881
  const q = queuedChild.queue;
698
882
  const target = q.specific_device_id ?? "the device pool";
883
+ const why = queueReasonCopy(q.reason);
884
+ if (why && why !== lastQueueReason) {
885
+ print(
886
+ `${chalk4.yellow("\u23F3")} ${chalk4.bold(queuedChild.suite_name)} is waiting for a device: ${why}`
887
+ );
888
+ }
889
+ lastQueueReason = why;
699
890
  spinner.text = `Queued \u2014 ${chalk4.bold(queuedChild.suite_name)} position ${q.position} for ${target} (${doneCount}/${totalMatched} suite(s) complete)`;
700
891
  } else {
892
+ lastQueueReason = null;
701
893
  spinner.text = `Running suites \u2014 ${doneCount}/${totalMatched} complete...`;
702
894
  }
703
- if (last.status !== "pending" && last.status !== "running") break;
895
+ if (TERMINAL.has(last.status)) break;
704
896
  await new Promise((r) => setTimeout(r, 2e3));
705
897
  }
898
+ if (!last || !TERMINAL.has(last.status)) {
899
+ return abandon(
900
+ last ? `Timeout: run did not complete within ${totalTimeout}s` : "Timed out before any status was returned."
901
+ );
902
+ }
706
903
  spinner.stop();
707
904
  if (process.stdin.isTTY) {
708
905
  process.stdin.setRawMode(false);
709
906
  process.stdin.pause();
710
907
  }
711
- if (!last) {
712
- if (opts.output === "json") printJsonError("Timed out before any status was returned.");
713
- process.stderr.write(chalk4.red("Timed out before any status was returned.\n"));
714
- process.exit(EXIT_INFRA_ERROR);
715
- }
716
908
  if (opts.output === "json") {
717
909
  process.stdout.write(JSON.stringify(last, null, 2) + "\n");
718
910
  } else if (opts.output === "junit") {
@@ -758,6 +950,7 @@ async function runTest(testId, options) {
758
950
  const client = new ApiClient(baseUrl, token);
759
951
  let sessionId;
760
952
  let lastQueueEntryId;
953
+ let runId;
761
954
  const spinner = createSpinner("Starting test run...");
762
955
  let cleaning = false;
763
956
  const cleanup = async (signal) => {
@@ -819,6 +1012,7 @@ ${signal} received.
819
1012
  metadata: options.metadata
820
1013
  });
821
1014
  sessionId = response.session_id;
1015
+ runId = response.run_id;
822
1016
  spinner.stop();
823
1017
  process.stderr.write(
824
1018
  `${chalk5.dim("Session:")} ${sessionId || "unknown"}
@@ -836,7 +1030,8 @@ ${chalk5.dim("Run:")} ${response.run_id}
836
1030
  spinner,
837
1031
  (entryId) => {
838
1032
  lastQueueEntryId = entryId;
839
- }
1033
+ },
1034
+ { keepAlive: keepAliveEnabled(options.output) }
840
1035
  );
841
1036
  spinner.stop();
842
1037
  if (process.stdin.isTTY) {
@@ -847,7 +1042,11 @@ ${chalk5.dim("Run:")} ${response.run_id}
847
1042
  process.exit(mapExitCode(result.status));
848
1043
  } catch (err) {
849
1044
  spinner.fail(err instanceof Error ? err.message : "Unknown error");
850
- if (options.output === "json") printJsonError(err);
1045
+ outputRunError(err, options, {
1046
+ name: testId,
1047
+ runId,
1048
+ url: runId ? `${baseUrl}/tests/runs/${runId}` : void 0
1049
+ });
851
1050
  if (lastQueueEntryId !== void 0) {
852
1051
  try {
853
1052
  await client.cancelQueueEntry(lastQueueEntryId);
@@ -893,7 +1092,9 @@ async function listSuites(options) {
893
1092
  for (const s of suites) {
894
1093
  const platform = s.platform ? chalk6.dim(` [${s.platform}]`) : "";
895
1094
  const tagsText = s.tags && s.tags.length > 0 ? chalk6.dim(` [${s.tags.map((t) => t.display_name).join(", ")}]`) : "";
896
- console.log(` ${chalk6.cyan(s.id)} ${s.name}${platform}${tagsText} (${s.case_count} cases)`);
1095
+ console.log(
1096
+ ` ${chalk6.cyan(s.id)} ${s.name}${platform}${tagsText} (${s.case_count} ${s.case_count === 1 ? "case" : "cases"})`
1097
+ );
897
1098
  }
898
1099
  console.log();
899
1100
  }
@@ -1048,9 +1249,12 @@ async function createTest(options) {
1048
1249
  app_id: appPublicId || "",
1049
1250
  steps,
1050
1251
  context: options.context,
1051
- mode: "ai"
1252
+ mode: "ai",
1253
+ if_exists: options.ifExists
1052
1254
  });
1053
- spinner.succeed(`Test created: ${result.name}`);
1255
+ spinner.succeed(
1256
+ result.outcome === "updated" ? `Test updated: ${result.name}` : result.outcome === "unchanged" ? `Test already exists, unchanged: ${result.name}` : `Test created: ${result.name}`
1257
+ );
1054
1258
  if (options.output === "json") {
1055
1259
  console.log(
1056
1260
  JSON.stringify(
@@ -1058,6 +1262,7 @@ async function createTest(options) {
1058
1262
  test_id: result.public_id,
1059
1263
  name: result.name,
1060
1264
  app_id: appPublicId || null,
1265
+ outcome: result.outcome ?? null,
1061
1266
  url: `${baseUrl}/tests?tab=standalone`
1062
1267
  },
1063
1268
  null,
@@ -1078,8 +1283,11 @@ async function createTest(options) {
1078
1283
  `));
1079
1284
  }
1080
1285
  } catch (err) {
1286
+ if (options.output === "json") printJsonError(err);
1081
1287
  console.error(chalk9.red(err instanceof Error ? err.message : "Failed to create test"));
1082
- process.exit(EXIT_INFRA_ERROR);
1288
+ process.exit(
1289
+ err instanceof ApiStatusError && err.status === 409 ? EXIT_TEST_FAIL : EXIT_INFRA_ERROR
1290
+ );
1083
1291
  }
1084
1292
  }
1085
1293
 
@@ -1161,6 +1369,11 @@ async function logout() {
1161
1369
 
1162
1370
  // src/commands/dev-status.ts
1163
1371
  import chalk10 from "chalk";
1372
+ function buildLine(info) {
1373
+ if (!info || typeof info.app_build_id !== "number") return null;
1374
+ const version = info.app_version ? ` version ${info.app_version}${info.app_build_number ? ` (${info.app_build_number})` : ""}` : "";
1375
+ return `${info.app_build_id}${version}`;
1376
+ }
1164
1377
  async function devStatus(opts) {
1165
1378
  const state = readDevState(process.cwd());
1166
1379
  if (!state) {
@@ -1175,12 +1388,14 @@ async function devStatus(opts) {
1175
1388
  const { token, baseUrl } = resolveAuth(opts);
1176
1389
  const api = new ApiClient(baseUrl, token);
1177
1390
  let sessionStatus;
1391
+ let info;
1178
1392
  try {
1179
- const info = await api.getSession(state.session_id);
1393
+ info = await api.getSession(state.session_id);
1180
1394
  sessionStatus = info.status;
1181
1395
  } catch {
1182
1396
  sessionStatus = void 0;
1183
1397
  }
1398
+ const build = buildLine(info);
1184
1399
  const metro = readMetroState(process.cwd());
1185
1400
  if (opts.json) {
1186
1401
  console.log(
@@ -1194,6 +1409,9 @@ async function devStatus(opts) {
1194
1409
  metro_port: state.metro_port,
1195
1410
  started_at: state.started_at,
1196
1411
  session_status: sessionStatus ?? null,
1412
+ build_id: info?.app_build_id ?? null,
1413
+ build_version: info?.app_version ?? null,
1414
+ build_number: info?.app_build_number ?? null,
1197
1415
  ...metro ? { owned_metro_pid: metro.pid, owned_metro_port: metro.port } : {}
1198
1416
  })
1199
1417
  );
@@ -1202,6 +1420,7 @@ async function devStatus(opts) {
1202
1420
  console.error(` session: ${state.session_id}${sessionStatus ? ` (${sessionStatus})` : ""}`);
1203
1421
  console.error(` platform: ${state.platform}`);
1204
1422
  console.error(` app: ${state.app_id}`);
1423
+ if (build) console.error(` build: ${build}`);
1205
1424
  console.error(` url: ${state.page_url}`);
1206
1425
  console.error(` started: ${state.started_at}`);
1207
1426
  }
@@ -1353,13 +1572,13 @@ function formatBuildsHuman(builds2) {
1353
1572
  ]);
1354
1573
  const widths = HEADERS.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
1355
1574
  const padCells = (cells) => cells.map((c, i) => c.padEnd(widths[i]));
1356
- const join3 = (cells) => cells.join(" ").trimEnd();
1357
- const lines = [chalk12.bold(join3(padCells(HEADERS)))];
1575
+ const join4 = (cells) => cells.join(" ").trimEnd();
1576
+ const lines = [chalk12.bold(join4(padCells(HEADERS)))];
1358
1577
  for (const row of rows) {
1359
1578
  const padded = padCells(row);
1360
1579
  const colorFn = STATUS_COLOR[row[2]] ?? ((s) => s);
1361
1580
  padded[2] = colorFn(row[2]) + padded[2].slice(row[2].length);
1362
- lines.push(join3(padded));
1581
+ lines.push(join4(padded));
1363
1582
  }
1364
1583
  return lines.join("\n") + "\n";
1365
1584
  }
@@ -1946,9 +2165,9 @@ function formatVersionsHuman(versions2) {
1946
2165
  const rows = versions2.map((v) => [v.platform, String(v.build_number), v.updated_at ?? ""]);
1947
2166
  const widths = HEADERS2.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
1948
2167
  const padCells = (cells) => cells.map((c, i) => c.padEnd(widths[i]));
1949
- const join3 = (cells) => cells.join(" ").trimEnd();
1950
- const lines = [chalk15.bold(join3(padCells(HEADERS2)))];
1951
- for (const row of rows) lines.push(join3(padCells(row)));
2168
+ const join4 = (cells) => cells.join(" ").trimEnd();
2169
+ const lines = [chalk15.bold(join4(padCells(HEADERS2)))];
2170
+ for (const row of rows) lines.push(join4(padCells(row)));
1952
2171
  return lines.join("\n") + "\n";
1953
2172
  }
1954
2173
  function printError3(opts, err) {
@@ -2105,9 +2324,9 @@ function formatEnvVarsHuman(variables) {
2105
2324
  ]);
2106
2325
  const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2107
2326
  const padCells = (cells) => cells.map((c, i) => c.padEnd(widths[i]));
2108
- const join3 = (cells) => cells.join(" ").trimEnd();
2109
- const lines = [chalk17.bold(join3(padCells(HEADERS3)))];
2110
- for (const row of rows) lines.push(join3(padCells(row)));
2327
+ const join4 = (cells) => cells.join(" ").trimEnd();
2328
+ const lines = [chalk17.bold(join4(padCells(HEADERS3)))];
2329
+ for (const row of rows) lines.push(join4(padCells(row)));
2111
2330
  return lines.join("\n") + "\n";
2112
2331
  }
2113
2332
  async function envSet(name, value, opts, deps) {
@@ -2647,9 +2866,9 @@ function formatAscKeysHuman(keys) {
2647
2866
  const rows = keys.map((k) => [k.name, k.key_id, k.issuer_id, formatUtcTimestamp(k.created_at)]);
2648
2867
  const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2649
2868
  const padCells = (cells) => cells.map((c, i) => c.padEnd(widths[i]));
2650
- const join3 = (cells) => cells.join(" ").trimEnd();
2651
- const lines = [chalk18.bold(join3(padCells(HEADERS3)))];
2652
- for (const row of rows) lines.push(join3(padCells(row)));
2869
+ const join4 = (cells) => cells.join(" ").trimEnd();
2870
+ const lines = [chalk18.bold(join4(padCells(HEADERS3)))];
2871
+ for (const row of rows) lines.push(join4(padCells(row)));
2653
2872
  return lines.join("\n") + "\n";
2654
2873
  }
2655
2874
  async function ascKeyRm(name, opts, deps) {
@@ -2723,9 +2942,9 @@ function formatPlayKeysHuman(keys) {
2723
2942
  const rows = keys.map((k) => [k.name, k.client_email, formatUtcTimestamp(k.created_at)]);
2724
2943
  const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2725
2944
  const padCells = (cells) => cells.map((c, i) => c.padEnd(widths[i]));
2726
- const join3 = (cells) => cells.join(" ").trimEnd();
2727
- const lines = [chalk18.bold(join3(padCells(HEADERS3)))];
2728
- for (const row of rows) lines.push(join3(padCells(row)));
2945
+ const join4 = (cells) => cells.join(" ").trimEnd();
2946
+ const lines = [chalk18.bold(join4(padCells(HEADERS3)))];
2947
+ for (const row of rows) lines.push(join4(padCells(row)));
2729
2948
  return lines.join("\n") + "\n";
2730
2949
  }
2731
2950
  async function playKeyRm(name, opts, deps) {
@@ -2803,9 +3022,9 @@ function formatCredentialsHuman(credentials2) {
2803
3022
  ]);
2804
3023
  const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2805
3024
  const padCells = (cells) => cells.map((c, i) => c.padEnd(widths[i]));
2806
- const join3 = (cells) => cells.join(" ").trimEnd();
2807
- const lines = [chalk18.bold(join3(padCells(HEADERS3)))];
2808
- for (const row of rows) lines.push(join3(padCells(row)));
3025
+ const join4 = (cells) => cells.join(" ").trimEnd();
3026
+ const lines = [chalk18.bold(join4(padCells(HEADERS3)))];
3027
+ for (const row of rows) lines.push(join4(padCells(row)));
2809
3028
  return lines.join("\n") + "\n";
2810
3029
  }
2811
3030
  async function credentialsRm(name, opts, deps) {
@@ -2922,9 +3141,9 @@ function formatWebhooksHuman(webhooks2) {
2922
3141
  ]);
2923
3142
  const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2924
3143
  const padCells = (cells) => cells.map((c, i) => c.padEnd(widths[i]));
2925
- const join3 = (cells) => cells.join(" ").trimEnd();
2926
- const lines = [chalk19.bold(join3(padCells(HEADERS3)))];
2927
- for (const row of rows) lines.push(join3(padCells(row)));
3144
+ const join4 = (cells) => cells.join(" ").trimEnd();
3145
+ const lines = [chalk19.bold(join4(padCells(HEADERS3)))];
3146
+ for (const row of rows) lines.push(join4(padCells(row)));
2928
3147
  return lines.join("\n") + "\n";
2929
3148
  }
2930
3149
  async function webhooksList(opts, deps) {
@@ -3199,6 +3418,72 @@ Suggested line for your project's ${chalk20.bold("CLAUDE.md")} (or ${chalk20.bol
3199
3418
  );
3200
3419
  }
3201
3420
 
3421
+ // src/lib/update-check.ts
3422
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync } from "fs";
3423
+ import { homedir as homedir2 } from "os";
3424
+ import { dirname as dirname2, join as join3 } from "path";
3425
+ var CACHE_FILE = join3(homedir2(), ".vibeview", "update-check.json");
3426
+ var REGISTRY_URL = "https://registry.npmjs.org/vibeview/latest";
3427
+ var ONE_DAY_MS = 24 * 60 * 60 * 1e3;
3428
+ function isNewerVersion(a, b) {
3429
+ const parse = (v) => /^\d+\.\d+\.\d+$/.test(v) ? v.split(".").map(Number) : null;
3430
+ const x = parse(a);
3431
+ const y = parse(b);
3432
+ if (!x || !y) return false;
3433
+ for (let i = 0; i < 3; i++) {
3434
+ if (x[i] !== y[i]) return x[i] > y[i];
3435
+ }
3436
+ return false;
3437
+ }
3438
+ function updateCheckAllowed(argv, env2, stderrIsTTY) {
3439
+ if (!stderrIsTTY) return false;
3440
+ if (env2.CI || env2.NO_UPDATE_NOTIFIER || env2.VIBEVIEW_NO_UPDATE_CHECK) return false;
3441
+ const args = argv.slice(2);
3442
+ if (args[0] === "mcp") return false;
3443
+ if (args.includes("--json")) return false;
3444
+ const output = args.findIndex((a) => a === "--output" || a.startsWith("--output="));
3445
+ if (output !== -1) {
3446
+ const value = args[output].includes("=") ? args[output].split("=")[1] : args[output + 1];
3447
+ if (value && value !== "human") return false;
3448
+ }
3449
+ return true;
3450
+ }
3451
+ function updateNotice(current, latest) {
3452
+ return `A newer vibeview is available: ${current} \u2192 ${latest}. Run \`npm i -g vibeview@latest\`, or \`npx vibeview@latest ...\`.`;
3453
+ }
3454
+ function readCache(file) {
3455
+ try {
3456
+ const parsed = JSON.parse(readFileSync3(file, "utf8"));
3457
+ if (typeof parsed?.checked_at === "number" && typeof parsed?.latest === "string") {
3458
+ return parsed;
3459
+ }
3460
+ } catch {
3461
+ }
3462
+ return null;
3463
+ }
3464
+ function checkForUpdate(current, opts) {
3465
+ const file = opts.cacheFile ?? CACHE_FILE;
3466
+ const now = opts.now ?? Date.now();
3467
+ const cache = readCache(file);
3468
+ if (cache && isNewerVersion(cache.latest, current)) {
3469
+ opts.print(updateNotice(current, cache.latest));
3470
+ }
3471
+ if (cache && now - cache.checked_at < ONE_DAY_MS) return Promise.resolve();
3472
+ const fetchLatest = opts.fetchLatest ?? fetchLatestFromRegistry;
3473
+ return fetchLatest().then((latest) => {
3474
+ if (!latest) return;
3475
+ mkdirSync3(dirname2(file), { recursive: true });
3476
+ writeFileSync(file, JSON.stringify({ checked_at: now, latest }) + "\n");
3477
+ }).catch(() => {
3478
+ });
3479
+ }
3480
+ async function fetchLatestFromRegistry() {
3481
+ const resp = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(1500) });
3482
+ if (!resp.ok) return null;
3483
+ const body = await resp.json();
3484
+ return typeof body.version === "string" ? body.version : null;
3485
+ }
3486
+
3202
3487
  // src/cli.ts
3203
3488
  var require2 = createRequire(import.meta.url);
3204
3489
  var pkg = require2("../package.json");
@@ -3239,10 +3524,12 @@ function resolveMetadata(opts) {
3239
3524
  }
3240
3525
  }
3241
3526
  var program = new Command();
3242
- program.name("vibeview").description("VibeView CLI -- run mobile tests from CI/CD pipelines").version(pkg.version);
3527
+ program.name("vibeview").description(
3528
+ "VibeView CLI -- run your app on cloud iOS simulators, Android emulators, Apple TV, Android TV and Roku (beta) devices: a React Native dev loop (dev), device control for you or your coding agent (ui-tree, tap, mcp), and tests from CI (run-test, run-suites)"
3529
+ ).version(pkg.version);
3243
3530
  program.command("run-suite <suite-id>").description("Run all test cases in a suite").option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").option("--output <format>", "Output format: human, json, junit", "human").option("--verbose", "Show per-case breakdown", false).option("--timeout <seconds>", "Max wait time in seconds (queue wait plus execution)", "1800").option("--commit-sha <sha>", "Git commit SHA for GitHub status checks").option("--model <model>", "AI model to use").option("--provider <provider>", "AI provider").option("--mode <mode>", "Execution mode: hybrid, ai, replay").option(
3244
3531
  "--device-category <category>",
3245
- "Device category: phone, tablet, tv (default: phone when --device is not set)",
3532
+ "Device category: phone, tablet, tv (default: tv for a TV platform, else phone)",
3246
3533
  parseDeviceCategory
3247
3534
  ).option(
3248
3535
  "--device <device_id>",
@@ -3270,7 +3557,7 @@ program.command("run-suites").description("Run all suites matching a tag filter"
3270
3557
  "Filter matched suites to one platform: ios, android, tvos, androidtv (default: no filter)"
3271
3558
  ).option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").option("--output <format>", "Output format: human, json, junit", "human").option("--verbose", "Show per-suite progress", false).option("--timeout <seconds>", "Per-suite timeout, capped at 2 hours total", "1800").option("--commit-sha <sha>", "Git commit SHA propagated to every SuiteRun").option("--model <model>", "AI model").option("--provider <provider>", "AI provider").option("--mode <mode>", "Execution mode: hybrid, ai, replay").option(
3272
3559
  "--device-category <category>",
3273
- "Device category: phone, tablet, tv (default: phone when --device is not set)",
3560
+ "Device category: phone, tablet, tv (default: tv for a TV platform, else phone)",
3274
3561
  parseDeviceCategory
3275
3562
  ).option(
3276
3563
  "--device <device_id>",
@@ -3290,7 +3577,7 @@ program.command("run-suites").description("Run all suites matching a tag filter"
3290
3577
  });
3291
3578
  program.command("run-test <test-id>").description("Run a single test case").option("--token <token>", "API token").option("--output <format>", "Output format: human, json, junit", "human").option("--verbose", "Show step details", false).option("--timeout <seconds>", "Max wait time in seconds (queue wait plus execution)", "1800").option("--commit-sha <sha>", "Git commit SHA for GitHub status checks").option("--model <model>", "AI model to use").option("--provider <provider>", "AI provider").option("--mode <mode>", "Execution mode: hybrid, ai, replay").option(
3292
3579
  "--device-category <category>",
3293
- "Device category: phone, tablet, tv (default: phone when --device is not set)",
3580
+ "Device category: phone, tablet, tv (default: tv for a TV platform, else phone)",
3294
3581
  parseDeviceCategory
3295
3582
  ).option(
3296
3583
  "--device <device_id>",
@@ -3335,7 +3622,12 @@ program.command("list-devices").description("List devices registered with the pl
3335
3622
  program.command("upload-app <file>").description(
3336
3623
  "Upload an app build (zipped simulator .app for iOS/tvOS, .apk for Android/Android TV, or a Roku channel .zip)"
3337
3624
  ).option("--token <token>", "API token").option("--output <format>", "Output format: human, json", "human").option("--name <name>", "App name (auto-detected if not provided)").option("--note <text>", "Note stored on the uploaded build (shown in the dashboard)").action((file, opts) => uploadApp(file, opts));
3338
- program.command("create-test [name]").description("Create a standalone AI test case").option("--token <token>", "API token").option("--output <format>", "Output format: human, json", "human").option("--app <app>", "App ID (public_id) or package name (requires --platform)").option("--platform <platform>", "Platform (ios, android) when using package name for --app").option("--steps <json>", "Test steps as inline JSON array").option("--steps-file <path>", "Path to JSON file containing test steps").option("--context <text>", "Additional context for AI test execution").option("--test-name <name>", "Test case name (alternative to positional arg)").action((name, opts) => createTest({ ...opts, testName: name || opts.testName || "CLI Test" }));
3625
+ program.command("create-test [name]").description("Create a standalone AI test case").option("--token <token>", "API token").option("--output <format>", "Output format: human, json", "human").option("--app <app>", "App ID (public_id) or package name (requires --platform)").option("--platform <platform>", "Platform (ios, android) when using package name for --app").option("--steps <json>", "Test steps as inline JSON array").option("--steps-file <path>", "Path to JSON file containing test steps").option("--context <text>", "Additional context for AI test execution").option("--test-name <name>", "Test case name (alternative to positional arg)").addOption(
3626
+ new Option(
3627
+ "--if-exists <mode>",
3628
+ "When a standalone test with this name already exists for the app: fail, update it, or create a new one alongside it. Default: reuse an identical test, refuse a different one"
3629
+ ).choices(["fail", "update", "new"])
3630
+ ).action((name, opts) => createTest({ ...opts, testName: name || opts.testName || "CLI Test" }));
3339
3631
  program.command("login").description("Log in to VibeView (interactive)").option("--base-url <url>", "VibeView server URL (defaults to https://vibeview.io)").action((opts) => login(opts));
3340
3632
  program.command("logout").description("Revoke the saved login token and remove it from ~/.vibeview/config.json").action(() => logout());
3341
3633
  program.command("dev").description(
@@ -3501,7 +3793,7 @@ program.command("dev-reload").description(
3501
3793
  program.command("mcp").description(
3502
3794
  "Run an MCP stdio server exposing the agent verbs (tap, swipe, type, ...) plus dev_start/dev_stop/dev_reload as tools"
3503
3795
  ).option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").action(async (opts) => {
3504
- const { runMcpServer } = await import("./server-ZIUZ7U5T.js");
3796
+ const { runMcpServer } = await import("./server-BGQJOVSN.js");
3505
3797
  await runMcpServer({ ...opts, version: pkg.version });
3506
3798
  });
3507
3799
  program.command("agent-setup").description(
@@ -3525,6 +3817,10 @@ ${pressButtonsHelp()}`);
3525
3817
  return runAgentVerb(cmd, positionals, opts);
3526
3818
  });
3527
3819
  }
3820
+ if (updateCheckAllowed(process.argv, process.env, process.stderr.isTTY)) {
3821
+ void checkForUpdate(pkg.version, { print: (line) => process.stderr.write(`${line}
3822
+ `) });
3823
+ }
3528
3824
  program.parseAsync().catch((e) => {
3529
3825
  if (e instanceof Error && e.name === "ExitPromptError") {
3530
3826
  process.exit(130);