vibeview 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -11,7 +11,6 @@ import {
11
11
  formatDeviceModelsHuman,
12
12
  formatDevicesHuman,
13
13
  formatDuration,
14
- formatUtcTimestamp,
15
14
  killOwnedMetro,
16
15
  listBuilds,
17
16
  loadDevConfig,
@@ -23,22 +22,27 @@ import {
23
22
  readDevState,
24
23
  readMetroState,
25
24
  runAgentVerb
26
- } from "./chunk-46X7UN6Y.js";
25
+ } from "./chunk-OKOAWGWV.js";
27
26
  import {
28
27
  ApiClient,
28
+ ApiStatusError,
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,
35
37
  errorExitCode,
38
+ formatUtcTimestamp,
36
39
  loadConfig,
40
+ printJsonError,
37
41
  reportCommandError,
38
42
  resolveAuth,
39
43
  saveConfig,
40
44
  uploadApp
41
- } from "./chunk-U7FCGMWG.js";
45
+ } from "./chunk-2JLTC67A.js";
42
46
 
43
47
  // src/cli.ts
44
48
  import { createRequire } from "module";
@@ -52,6 +56,26 @@ import chalk2 from "chalk";
52
56
 
53
57
  // src/lib/formatters/human.ts
54
58
  import chalk from "chalk";
59
+
60
+ // src/lib/formatters/cases.ts
61
+ function resultCases(result) {
62
+ if (Array.isArray(result.test_runs)) return result.test_runs;
63
+ const failedStep = result.steps?.find((s) => s.status !== "passed");
64
+ return [
65
+ {
66
+ case_name: result.case_name || result.run_id,
67
+ status: result.status,
68
+ duration_ms: result.duration_ms,
69
+ error_message: result.error_message ?? failedStep?.error ?? null,
70
+ error_kind: failedStep?.error_kind ?? null,
71
+ error_kind_description: failedStep?.error_kind_description ?? null,
72
+ screenshot_signed_url: failedStep?.screenshot_signed_url ?? null,
73
+ url: result.url
74
+ }
75
+ ];
76
+ }
77
+
78
+ // src/lib/formatters/human.ts
55
79
  function failureLines(failure) {
56
80
  const lines = [];
57
81
  if (failure.error_kind) {
@@ -72,20 +96,19 @@ function formatHuman(result, verbose) {
72
96
  const icon = result.status === "passed" ? chalk.green("PASS") : chalk.red("FAIL");
73
97
  const duration = result.duration_ms ? `${(result.duration_ms / 1e3).toFixed(1)}s` : "-";
74
98
  lines.push("");
75
- if (result.suite_name) {
76
- lines.push(` ${icon} ${chalk.bold(result.suite_name)} ${chalk.dim(duration)}`);
77
- } else {
78
- lines.push(` ${icon} ${chalk.bold(result.run_id)} ${chalk.dim(duration)}`);
79
- }
80
- const passed = result.test_runs.filter((t) => t.status === "passed").length;
81
- const total = result.test_runs.length;
99
+ const title = result.suite_name || result.case_name || result.run_id;
100
+ lines.push(` ${icon} ${chalk.bold(title)} ${chalk.dim(duration)}`);
101
+ const cases = resultCases(result);
102
+ const passed = cases.filter((t) => t.status === "passed").length;
103
+ const total = cases.length;
82
104
  lines.push(` ${chalk.dim(`${passed}/${total} cases passed`)}`);
83
105
  if (result.url) {
84
106
  lines.push(` ${chalk.dim(result.url)}`);
85
107
  }
86
- if (result.test_runs.length > 0) {
108
+ const suiteCases = result.test_runs ?? [];
109
+ if (suiteCases.length > 0) {
87
110
  lines.push("");
88
- for (const tr of result.test_runs) {
111
+ for (const tr of suiteCases) {
89
112
  const caseIcon = tr.status === "passed" ? chalk.green(" PASS") : tr.status === "cancelled" || tr.status === "pending" || tr.status === "running" ? chalk.yellow(" SKIP") : chalk.red(" FAIL");
90
113
  const caseDuration = tr.duration_ms ? `${(tr.duration_ms / 1e3).toFixed(1)}s` : "-";
91
114
  lines.push(` ${caseIcon} ${tr.case_name} ${chalk.dim(caseDuration)}`);
@@ -99,7 +122,7 @@ function formatHuman(result, verbose) {
99
122
  }
100
123
  if (result.steps && result.steps.length > 0) {
101
124
  lines.push("");
102
- if (result.case_name) {
125
+ if (result.case_name && result.case_name !== title) {
103
126
  lines.push(` ${chalk.bold(result.case_name)}`);
104
127
  }
105
128
  for (const step of result.steps) {
@@ -134,10 +157,11 @@ function escapeXml(s) {
134
157
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
135
158
  }
136
159
  function formatJunit(result) {
137
- const suiteName = escapeXml(result.suite_name || result.run_id);
138
- const tests = result.test_runs.length;
139
- const failures = result.test_runs.filter((t) => t.status === "failed").length;
140
- const errors = result.test_runs.filter((t) => t.status === "error").length;
160
+ const suiteName = escapeXml(result.suite_name || result.case_name || result.run_id);
161
+ const cases = resultCases(result);
162
+ const tests = cases.length;
163
+ const failures = cases.filter((t) => t.status === "failed").length;
164
+ const errors = cases.filter((t) => t.status === "error").length;
141
165
  const totalTime = result.duration_ms ? (result.duration_ms / 1e3).toFixed(1) : "0";
142
166
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
143
167
  const lines = [];
@@ -148,31 +172,64 @@ function formatJunit(result) {
148
172
  lines.push(
149
173
  ` <testsuite name="${suiteName}" tests="${tests}" failures="${failures}" errors="${errors}" time="${totalTime}" timestamp="${timestamp}">`
150
174
  );
151
- for (const tr of result.test_runs) {
175
+ if (result.url) {
176
+ lines.push(" <properties>");
177
+ lines.push(` <property name="vibeview.run_url" value="${escapeXml(result.url)}" />`);
178
+ lines.push(" </properties>");
179
+ }
180
+ for (const tr of cases) {
152
181
  const caseName = escapeXml(tr.case_name);
153
182
  const caseTime = tr.duration_ms ? (tr.duration_ms / 1e3).toFixed(1) : "0";
154
- if (tr.status === "passed") {
183
+ const out = tr.url ? ` <system-out>${escapeXml(tr.url)}</system-out>` : null;
184
+ if (tr.status === "passed" && !out) {
155
185
  lines.push(` <testcase name="${caseName}" classname="${suiteName}" time="${caseTime}" />`);
156
- } else if (tr.status === "failed") {
186
+ continue;
187
+ }
188
+ lines.push(` <testcase name="${caseName}" classname="${suiteName}" time="${caseTime}">`);
189
+ if (tr.status === "failed") {
157
190
  const msg = escapeXml(tr.error_message || "Test failed");
158
- lines.push(` <testcase name="${caseName}" classname="${suiteName}" time="${caseTime}">`);
159
191
  lines.push(` <failure message="${msg}" type="TestFailure">${msg}</failure>`);
160
- lines.push(" </testcase>");
161
- } else {
192
+ } else if (tr.status !== "passed") {
162
193
  const msg = escapeXml(tr.error_message || "Infrastructure error");
163
- lines.push(` <testcase name="${caseName}" classname="${suiteName}" time="${caseTime}">`);
164
194
  lines.push(` <error message="${msg}" type="InfraError">${msg}</error>`);
165
- lines.push(" </testcase>");
166
195
  }
196
+ if (out) lines.push(out);
197
+ lines.push(" </testcase>");
167
198
  }
168
199
  lines.push(" </testsuite>");
169
200
  lines.push("</testsuites>");
170
201
  return lines.join("\n");
171
202
  }
172
- function formatMultiSuiteJunit(result) {
203
+ function formatJunitError(name, message, url) {
204
+ const n = escapeXml(name);
205
+ const msg = escapeXml(message);
206
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
207
+ const lines = [];
208
+ lines.push('<?xml version="1.0" encoding="UTF-8"?>');
209
+ lines.push(`<testsuites name="vibeview" tests="1" failures="0" errors="1" time="0">`);
210
+ lines.push(
211
+ ` <testsuite name="${n}" tests="1" failures="0" errors="1" time="0" timestamp="${timestamp}">`
212
+ );
213
+ if (url) {
214
+ lines.push(" <properties>");
215
+ lines.push(` <property name="vibeview.run_url" value="${escapeXml(url)}" />`);
216
+ lines.push(" </properties>");
217
+ }
218
+ lines.push(` <testcase name="${n}" classname="${n}" time="0">`);
219
+ lines.push(` <error message="${msg}" type="InfraError">${msg}</error>`);
220
+ lines.push(" </testcase>");
221
+ lines.push(" </testsuite>");
222
+ lines.push("</testsuites>");
223
+ return lines.join("\n");
224
+ }
225
+ var MULTI_TERMINAL = /* @__PURE__ */ new Set(["passed", "failed", "error", "cancelled"]);
226
+ function formatMultiSuiteJunit(result, opts = {}) {
227
+ const unfinished = (status) => opts.timedOut !== void 0 && !MULTI_TERMINAL.has(status);
228
+ const errored = (status) => status === "error" || unfinished(status);
173
229
  const tests = result.suite_runs.reduce((sum, sr) => sum + sr.passed + sr.failed, 0);
174
230
  const failures = result.suite_runs.reduce((sum, sr) => sum + sr.failed, 0);
175
- const totalErrors = result.suite_runs.filter((sr) => sr.status === "error").length;
231
+ const noSuites = opts.timedOut !== void 0 && result.suite_runs.length === 0;
232
+ const totalErrors = result.suite_runs.filter((sr) => errored(sr.status)).length + (noSuites ? 1 : 0);
176
233
  const timestamp = result.started_at ?? (/* @__PURE__ */ new Date()).toISOString();
177
234
  const lines = [];
178
235
  lines.push('<?xml version="1.0" encoding="UTF-8"?>');
@@ -183,43 +240,113 @@ function formatMultiSuiteJunit(result) {
183
240
  const suiteName = escapeXml(sr.suite_name || sr.suite_id);
184
241
  const suiteTests = sr.passed + sr.failed;
185
242
  const suiteFails = sr.failed;
186
- const suiteErrors = sr.status === "error" ? 1 : 0;
243
+ const suiteErrors = errored(sr.status) ? 1 : 0;
187
244
  lines.push(
188
245
  ` <testsuite name="${suiteName}" tests="${suiteTests}" failures="${suiteFails}" errors="${suiteErrors}" timestamp="${timestamp}">`
189
246
  );
190
- if (suiteErrors > 0) {
247
+ if (sr.url) {
248
+ lines.push(" <properties>");
249
+ lines.push(` <property name="vibeview.run_url" value="${escapeXml(sr.url)}" />`);
250
+ lines.push(" </properties>");
251
+ }
252
+ if (unfinished(sr.status)) {
253
+ const msg = escapeXml(`${opts.timedOut} (suite was ${sr.status})`);
254
+ lines.push(` <error message="${msg}" type="InfraError" />`);
255
+ } else if (suiteErrors > 0) {
191
256
  lines.push(` <error message="Suite errored" type="InfraError" />`);
192
257
  }
193
258
  lines.push(" </testsuite>");
194
259
  }
260
+ if (noSuites) {
261
+ const msg = escapeXml(opts.timedOut);
262
+ lines.push(
263
+ ` <testsuite name="vibeview run-suites" tests="0" failures="0" errors="1" timestamp="${timestamp}">`
264
+ );
265
+ lines.push(` <error message="${msg}" type="InfraError" />`);
266
+ lines.push(" </testsuite>");
267
+ }
195
268
  lines.push("</testsuites>");
196
269
  return lines.join("\n");
197
270
  }
198
271
 
199
272
  // src/lib/poll.ts
200
- function printBelowSpinner(spinner, line) {
201
- const text = spinner.text;
273
+ function resumeSpinner(spinner, text) {
274
+ if (spinner.isEnabled === false) {
275
+ if (text !== void 0) spinner.text = text;
276
+ return;
277
+ }
278
+ spinner.start(text);
279
+ }
280
+ function printBelowSpinner(spinner, line, nextText) {
281
+ const text = nextText ?? spinner.text;
202
282
  spinner.stop();
203
283
  process.stderr.write(line + "\n");
204
- spinner.start(text);
284
+ resumeSpinner(spinner, text);
285
+ }
286
+ function printStepLines(stepNum, total, status, text, actions, error) {
287
+ const icon = status === "passed" ? chalk2.green("\u2713") : status === "failed" ? chalk2.red("\u2717") : chalk2.yellow("!");
288
+ process.stderr.write(` ${icon} Step ${stepNum}/${total}: ${text}
289
+ `);
290
+ for (const action of actions ?? []) {
291
+ process.stderr.write(` ${chalk2.dim(formatAction(action))}
292
+ `);
293
+ }
294
+ if (error) process.stderr.write(` ${chalk2.red(error)}
295
+ `);
296
+ }
297
+ var PollTimeoutError = class extends Error {
298
+ constructor(message) {
299
+ super(message);
300
+ this.name = "PollTimeoutError";
301
+ }
302
+ };
303
+ var POLL_RETRY_DELAYS_MS = [2e3, 4e3, 8e3, 16e3, 3e4];
304
+ function isTransientPollError(err) {
305
+ if (err instanceof NetworkError) return true;
306
+ if (err instanceof ApiStatusError) {
307
+ return err.status === 408 || err.status === 429 || err.status >= 500;
308
+ }
309
+ return false;
310
+ }
311
+ async function retryTransient(fn, opts = {}) {
312
+ for (let attempt = 0; ; attempt++) {
313
+ try {
314
+ return await fn();
315
+ } catch (err) {
316
+ const delay = POLL_RETRY_DELAYS_MS[attempt];
317
+ if (!isTransientPollError(err) || delay === void 0) throw err;
318
+ if (opts.deadline !== void 0 && Date.now() + delay >= opts.deadline) throw err;
319
+ const message = err instanceof Error ? err.message : String(err);
320
+ process.stderr.write(
321
+ `${opts.label ?? "Status poll"} failed (${message}); retrying in ${delay / 1e3}s (${attempt + 1}/${POLL_RETRY_DELAYS_MS.length})
322
+ `
323
+ );
324
+ await new Promise((r) => setTimeout(r, delay));
325
+ }
326
+ }
327
+ }
328
+ function keepAliveEnabled(output) {
329
+ return output !== "json" && output !== "junit";
205
330
  }
206
- async function pollUntilComplete(client, runId, timeoutMs, spinner, onQueueObserved) {
331
+ async function pollUntilComplete(client, runId, timeoutMs, spinner, onQueueObserved, opts = {}) {
207
332
  const deadline = Date.now() + timeoutMs;
208
333
  const isCI = process.env.CI === "true" || process.env.CI === "1" || !process.stdout.isTTY;
334
+ const keepAlive = isCI && opts.keepAlive !== false;
209
335
  let lastLogTime = Date.now();
210
336
  let lastSeenEventCount = 0;
211
337
  let totalSteps;
212
338
  let currentStepLabel = "";
339
+ const printedSteps = /* @__PURE__ */ new Set();
213
340
  let cachedDeviceLabel = null;
214
341
  const caseStateBefore = /* @__PURE__ */ new Map();
215
342
  const caseStartedPrinted = /* @__PURE__ */ new Set();
216
343
  while (Date.now() < deadline) {
217
- const result = await client.getRunStatus(runId);
344
+ const result = await retryTransient(() => client.getRunStatus(runId), { deadline });
218
345
  const progress = result.progress || [];
219
346
  if (progress.length > lastSeenEventCount) {
220
347
  const newEvents = progress.slice(lastSeenEventCount);
221
348
  for (const evt of newEvents) {
222
- const ctx = { spinner, totalSteps, currentStepLabel };
349
+ const ctx = { spinner, totalSteps, currentStepLabel, printedSteps };
223
350
  displayProgressEvent(evt, ctx);
224
351
  totalSteps = ctx.totalSteps;
225
352
  currentStepLabel = ctx.currentStepLabel;
@@ -252,6 +379,20 @@ async function pollUntilComplete(client, runId, timeoutMs, spinner, onQueueObser
252
379
  }
253
380
  }
254
381
  if (["passed", "failed", "error", "cancelled"].includes(result.status)) {
382
+ if (printedSteps.size > 0 && Array.isArray(result.steps)) {
383
+ spinner.stop();
384
+ result.steps.forEach((st, i) => {
385
+ if (printedSteps.has(i)) return;
386
+ printStepLines(
387
+ i + 1,
388
+ totalSteps ?? result.steps.length,
389
+ st.status,
390
+ st.step,
391
+ st.actions,
392
+ st.error
393
+ );
394
+ });
395
+ }
255
396
  return result;
256
397
  }
257
398
  if (result.status === "queued" && result.queue) {
@@ -292,20 +433,24 @@ async function pollUntilComplete(client, runId, timeoutMs, spinner, onQueueObser
292
433
  spinner.text = `Status: ${result.status}...`;
293
434
  }
294
435
  }
295
- if (isCI && Date.now() - lastLogTime >= 3e4) {
296
- console.log(`Status: ${result.status}...`);
436
+ if (keepAlive && Date.now() - lastLogTime >= 3e4) {
437
+ const where = result.status === "queued" && result.queue ? ` (position ${result.queue.position} in line for a device)` : "";
438
+ process.stderr.write(`Status: ${result.status}${where}...
439
+ `);
297
440
  lastLogTime = Date.now();
298
441
  }
299
442
  await new Promise((r) => setTimeout(r, 3e3));
300
443
  }
301
- throw new Error(`Timeout: run did not complete within ${Math.round(timeoutMs / 1e3)}s`);
444
+ throw new PollTimeoutError(
445
+ `Timeout: run did not complete within ${Math.round(timeoutMs / 1e3)}s`
446
+ );
302
447
  }
303
448
  function formatAction(action) {
304
449
  const tool = action.tool;
305
450
  const args = action.args || {};
306
451
  switch (tool) {
307
452
  case "tap":
308
- return `tap ${args.element_ref || ""}`;
453
+ return `tap ${args.ref ?? args.element_ref ?? ""}`.trimEnd();
309
454
  case "tap_coordinates":
310
455
  return `tap (${args.x}, ${args.y})`;
311
456
  case "type_text":
@@ -333,7 +478,8 @@ function displayProgressEvent(evt, ctx) {
333
478
  case "run_started":
334
479
  printBelowSpinner(
335
480
  spinner,
336
- `${chalk2.cyan("\u25B6")} AI test started \u2014 ${evt.total_steps} step(s), model: ${chalk2.bold(evt.model || "default")}`
481
+ `${chalk2.cyan("\u25B6")} AI test started \u2014 ${evt.total_steps} step(s), model: ${chalk2.bold(evt.model || "default")}`,
482
+ " Starting test..."
337
483
  );
338
484
  ctx.totalSteps = evt.total_steps ?? ctx.totalSteps;
339
485
  break;
@@ -345,23 +491,17 @@ function displayProgressEvent(evt, ctx) {
345
491
  break;
346
492
  }
347
493
  case "step_completed": {
348
- const stepNum = (evt.step_index ?? 0) + 1;
494
+ const stepIndex = evt.step_index ?? 0;
495
+ const stepNum = stepIndex + 1;
349
496
  const total = ctx.totalSteps ?? evt.total_steps ?? "?";
350
- const icon = evt.status === "passed" ? chalk2.green("\u2713") : evt.status === "failed" ? chalk2.red("\u2717") : chalk2.yellow("!");
497
+ if (ctx.printedSteps.has(stepIndex)) break;
498
+ ctx.printedSteps.add(stepIndex);
351
499
  spinner.stop();
352
- process.stderr.write(` ${icon} Step ${stepNum}/${total}: ${evt.step_text || ""}
353
- `);
354
- if (evt.actions && evt.actions.length > 0) {
355
- for (const action of evt.actions) {
356
- process.stderr.write(` ${chalk2.dim(formatAction(action))}
357
- `);
358
- }
359
- }
360
- if (evt.error) {
361
- process.stderr.write(` ${chalk2.red(evt.error)}
362
- `);
363
- }
364
- spinner.start();
500
+ printStepLines(stepNum, total, evt.status, evt.step_text || "", evt.actions, evt.error);
501
+ resumeSpinner(
502
+ spinner,
503
+ typeof total === "number" && stepNum >= total ? " Finishing..." : " Next step..."
504
+ );
365
505
  break;
366
506
  }
367
507
  case "conversation_turn": {
@@ -380,7 +520,7 @@ function displayProgressEvent(evt, ctx) {
380
520
  `${icon} Run ${evt.status}${evt.duration_ms ? ` (${(evt.duration_ms / 1e3).toFixed(1)}s)` : ""}
381
521
  `
382
522
  );
383
- spinner.start();
523
+ resumeSpinner(spinner);
384
524
  break;
385
525
  }
386
526
  case "app_installing":
@@ -393,6 +533,17 @@ function displayProgressEvent(evt, ctx) {
393
533
  break;
394
534
  }
395
535
  }
536
+ function outputRunError(err, options, ctx) {
537
+ if (options.output === "json") {
538
+ const extra = {};
539
+ if (ctx.runId) extra.run_id = ctx.runId;
540
+ if (ctx.url) extra.url = ctx.url;
541
+ printJsonError(err, extra);
542
+ } else if (options.output === "junit") {
543
+ const message = err instanceof Error ? err.message : String(err);
544
+ console.log(formatJunitError(ctx.name, message, ctx.url));
545
+ }
546
+ }
396
547
  function outputResult(result, options) {
397
548
  switch (options.output) {
398
549
  case "json":
@@ -418,6 +569,7 @@ async function runSuite(suiteId, options) {
418
569
  const spinner = createSpinner("Starting suite run...");
419
570
  let sessionId;
420
571
  let lastQueueEntryId;
572
+ let runId;
421
573
  let cleaning = false;
422
574
  const cleanup = async (signal) => {
423
575
  if (cleaning) process.exit(130);
@@ -478,6 +630,7 @@ ${signal} received.
478
630
  metadata: options.metadata
479
631
  });
480
632
  sessionId = response.session_id;
633
+ runId = response.run_id;
481
634
  spinner.stop();
482
635
  process.stderr.write(
483
636
  `${chalk3.dim("Suite:")} ${suiteId}
@@ -494,7 +647,8 @@ ${chalk3.dim("Run:")} ${response.run_id}
494
647
  spinner,
495
648
  (entryId) => {
496
649
  lastQueueEntryId = entryId;
497
- }
650
+ },
651
+ { keepAlive: keepAliveEnabled(options.output) }
498
652
  );
499
653
  spinner.stop();
500
654
  if (process.stdin.isTTY) {
@@ -505,6 +659,11 @@ ${chalk3.dim("Run:")} ${response.run_id}
505
659
  process.exit(mapExitCode(result.status));
506
660
  } catch (err) {
507
661
  spinner.fail(err instanceof Error ? err.message : "Unknown error");
662
+ outputRunError(err, options, {
663
+ name: suiteId,
664
+ runId,
665
+ url: runId ? `${baseUrl}/tests/runs/${runId}` : void 0
666
+ });
508
667
  if (lastQueueEntryId !== void 0) {
509
668
  try {
510
669
  await client.cancelQueueEntry(lastQueueEntryId);
@@ -593,10 +752,15 @@ ${signal} received.
593
752
  const msg = err instanceof Error ? err.message : String(err);
594
753
  if (msg.startsWith("Not found:") || msg.includes("404") || /No suites match tags/.test(msg)) {
595
754
  const suffix = opts.platform ? ` on platform ${opts.platform}` : "";
596
- process.stderr.write(chalk4.yellow(`No suites match tags: ${opts.tag.join(", ")}${suffix}
755
+ const noMatch = `No suites match tags: ${opts.tag.join(", ")}${suffix}`;
756
+ if (opts.output === "json") printJsonError(noMatch);
757
+ if (opts.output === "junit") console.log(formatJunitError("vibeview run-suites", noMatch));
758
+ process.stderr.write(chalk4.yellow(`${noMatch}
597
759
  `));
598
760
  process.exit(EXIT_INFRA_ERROR);
599
761
  }
762
+ if (opts.output === "json") printJsonError(err);
763
+ if (opts.output === "junit") console.log(formatJunitError("vibeview run-suites", msg));
600
764
  process.stderr.write(chalk4.red(`Failed to start: ${msg}
601
765
  `));
602
766
  process.exit(EXIT_INFRA_ERROR);
@@ -635,8 +799,47 @@ ${chalk4.dim("Matched:")} ${totalMatched} suite(s)${matchedNames.length ? " \u20
635
799
  };
636
800
  const TERMINAL = /* @__PURE__ */ new Set(["passed", "failed", "error", "cancelled"]);
637
801
  let last = null;
802
+ const abandon = async (reason) => {
803
+ spinner.stop();
804
+ if (process.stdin.isTTY) {
805
+ process.stdin.setRawMode(false);
806
+ process.stdin.pause();
807
+ }
808
+ process.stderr.write(chalk4.red(`${reason}
809
+ `));
810
+ process.stderr.write("Cancelling multi-suite run...\n");
811
+ try {
812
+ await client.cancelMultiSuiteRun(start.aggregate_id);
813
+ process.stderr.write("Cancellation requested.\n");
814
+ } catch (err) {
815
+ const m = err instanceof Error ? err.message : String(err);
816
+ process.stderr.write(chalk4.yellow(`Could not cancel the run: ${m}
817
+ `));
818
+ }
819
+ if (opts.output === "json") {
820
+ printJsonError(reason, { aggregate_id: start.aggregate_id, result: last });
821
+ } else if (opts.output === "junit") {
822
+ const doc = last ?? {
823
+ id: start.aggregate_id,
824
+ status: "running",
825
+ suite_runs: [],
826
+ started_at: null,
827
+ completed_at: null,
828
+ commit_sha: null
829
+ };
830
+ process.stdout.write(formatMultiSuiteJunit(doc, { timedOut: reason }) + "\n");
831
+ }
832
+ process.exit(EXIT_INFRA_ERROR);
833
+ };
638
834
  while (Date.now() < deadline) {
639
- last = await client.getMultiSuiteRun(start.aggregate_id);
835
+ try {
836
+ last = await retryTransient(() => client.getMultiSuiteRun(start.aggregate_id), {
837
+ deadline
838
+ });
839
+ } catch (err) {
840
+ const m = err instanceof Error ? err.message : String(err);
841
+ return abandon(`Could not read the run's status: ${m}`);
842
+ }
640
843
  let doneCount = 0;
641
844
  let queuedChild;
642
845
  for (const sr of last.suite_runs) {
@@ -671,18 +874,19 @@ ${chalk4.dim("Matched:")} ${totalMatched} suite(s)${matchedNames.length ? " \u20
671
874
  } else {
672
875
  spinner.text = `Running suites \u2014 ${doneCount}/${totalMatched} complete...`;
673
876
  }
674
- if (last.status !== "pending" && last.status !== "running") break;
877
+ if (TERMINAL.has(last.status)) break;
675
878
  await new Promise((r) => setTimeout(r, 2e3));
676
879
  }
880
+ if (!last || !TERMINAL.has(last.status)) {
881
+ return abandon(
882
+ last ? `Timeout: run did not complete within ${totalTimeout}s` : "Timed out before any status was returned."
883
+ );
884
+ }
677
885
  spinner.stop();
678
886
  if (process.stdin.isTTY) {
679
887
  process.stdin.setRawMode(false);
680
888
  process.stdin.pause();
681
889
  }
682
- if (!last) {
683
- process.stderr.write(chalk4.red("Timed out before any status was returned.\n"));
684
- process.exit(EXIT_INFRA_ERROR);
685
- }
686
890
  if (opts.output === "json") {
687
891
  process.stdout.write(JSON.stringify(last, null, 2) + "\n");
688
892
  } else if (opts.output === "junit") {
@@ -728,6 +932,7 @@ async function runTest(testId, options) {
728
932
  const client = new ApiClient(baseUrl, token);
729
933
  let sessionId;
730
934
  let lastQueueEntryId;
935
+ let runId;
731
936
  const spinner = createSpinner("Starting test run...");
732
937
  let cleaning = false;
733
938
  const cleanup = async (signal) => {
@@ -789,6 +994,7 @@ ${signal} received.
789
994
  metadata: options.metadata
790
995
  });
791
996
  sessionId = response.session_id;
997
+ runId = response.run_id;
792
998
  spinner.stop();
793
999
  process.stderr.write(
794
1000
  `${chalk5.dim("Session:")} ${sessionId || "unknown"}
@@ -806,7 +1012,8 @@ ${chalk5.dim("Run:")} ${response.run_id}
806
1012
  spinner,
807
1013
  (entryId) => {
808
1014
  lastQueueEntryId = entryId;
809
- }
1015
+ },
1016
+ { keepAlive: keepAliveEnabled(options.output) }
810
1017
  );
811
1018
  spinner.stop();
812
1019
  if (process.stdin.isTTY) {
@@ -817,6 +1024,11 @@ ${chalk5.dim("Run:")} ${response.run_id}
817
1024
  process.exit(mapExitCode(result.status));
818
1025
  } catch (err) {
819
1026
  spinner.fail(err instanceof Error ? err.message : "Unknown error");
1027
+ outputRunError(err, options, {
1028
+ name: testId,
1029
+ runId,
1030
+ url: runId ? `${baseUrl}/tests/runs/${runId}` : void 0
1031
+ });
820
1032
  if (lastQueueEntryId !== void 0) {
821
1033
  try {
822
1034
  await client.cancelQueueEntry(lastQueueEntryId);
@@ -862,7 +1074,9 @@ async function listSuites(options) {
862
1074
  for (const s of suites) {
863
1075
  const platform = s.platform ? chalk6.dim(` [${s.platform}]`) : "";
864
1076
  const tagsText = s.tags && s.tags.length > 0 ? chalk6.dim(` [${s.tags.map((t) => t.display_name).join(", ")}]`) : "";
865
- console.log(` ${chalk6.cyan(s.id)} ${s.name}${platform}${tagsText} (${s.case_count} cases)`);
1077
+ console.log(
1078
+ ` ${chalk6.cyan(s.id)} ${s.name}${platform}${tagsText} (${s.case_count} ${s.case_count === 1 ? "case" : "cases"})`
1079
+ );
866
1080
  }
867
1081
  console.log();
868
1082
  }
@@ -1017,9 +1231,12 @@ async function createTest(options) {
1017
1231
  app_id: appPublicId || "",
1018
1232
  steps,
1019
1233
  context: options.context,
1020
- mode: "ai"
1234
+ mode: "ai",
1235
+ if_exists: options.ifExists
1021
1236
  });
1022
- spinner.succeed(`Test created: ${result.name}`);
1237
+ spinner.succeed(
1238
+ result.outcome === "updated" ? `Test updated: ${result.name}` : result.outcome === "unchanged" ? `Test already exists, unchanged: ${result.name}` : `Test created: ${result.name}`
1239
+ );
1023
1240
  if (options.output === "json") {
1024
1241
  console.log(
1025
1242
  JSON.stringify(
@@ -1027,6 +1244,7 @@ async function createTest(options) {
1027
1244
  test_id: result.public_id,
1028
1245
  name: result.name,
1029
1246
  app_id: appPublicId || null,
1247
+ outcome: result.outcome ?? null,
1030
1248
  url: `${baseUrl}/tests?tab=standalone`
1031
1249
  },
1032
1250
  null,
@@ -1047,8 +1265,11 @@ async function createTest(options) {
1047
1265
  `));
1048
1266
  }
1049
1267
  } catch (err) {
1268
+ if (options.output === "json") printJsonError(err);
1050
1269
  console.error(chalk9.red(err instanceof Error ? err.message : "Failed to create test"));
1051
- process.exit(EXIT_INFRA_ERROR);
1270
+ process.exit(
1271
+ err instanceof ApiStatusError && err.status === 409 ? EXIT_TEST_FAIL : EXIT_INFRA_ERROR
1272
+ );
1052
1273
  }
1053
1274
  }
1054
1275
 
@@ -1130,6 +1351,11 @@ async function logout() {
1130
1351
 
1131
1352
  // src/commands/dev-status.ts
1132
1353
  import chalk10 from "chalk";
1354
+ function buildLine(info) {
1355
+ if (!info || typeof info.app_build_id !== "number") return null;
1356
+ const version = info.app_version ? ` version ${info.app_version}${info.app_build_number ? ` (${info.app_build_number})` : ""}` : "";
1357
+ return `${info.app_build_id}${version}`;
1358
+ }
1133
1359
  async function devStatus(opts) {
1134
1360
  const state = readDevState(process.cwd());
1135
1361
  if (!state) {
@@ -1144,12 +1370,14 @@ async function devStatus(opts) {
1144
1370
  const { token, baseUrl } = resolveAuth(opts);
1145
1371
  const api = new ApiClient(baseUrl, token);
1146
1372
  let sessionStatus;
1373
+ let info;
1147
1374
  try {
1148
- const info = await api.getSession(state.session_id);
1375
+ info = await api.getSession(state.session_id);
1149
1376
  sessionStatus = info.status;
1150
1377
  } catch {
1151
1378
  sessionStatus = void 0;
1152
1379
  }
1380
+ const build = buildLine(info);
1153
1381
  const metro = readMetroState(process.cwd());
1154
1382
  if (opts.json) {
1155
1383
  console.log(
@@ -1163,6 +1391,9 @@ async function devStatus(opts) {
1163
1391
  metro_port: state.metro_port,
1164
1392
  started_at: state.started_at,
1165
1393
  session_status: sessionStatus ?? null,
1394
+ build_id: info?.app_build_id ?? null,
1395
+ build_version: info?.app_version ?? null,
1396
+ build_number: info?.app_build_number ?? null,
1166
1397
  ...metro ? { owned_metro_pid: metro.pid, owned_metro_port: metro.port } : {}
1167
1398
  })
1168
1399
  );
@@ -1171,6 +1402,7 @@ async function devStatus(opts) {
1171
1402
  console.error(` session: ${state.session_id}${sessionStatus ? ` (${sessionStatus})` : ""}`);
1172
1403
  console.error(` platform: ${state.platform}`);
1173
1404
  console.error(` app: ${state.app_id}`);
1405
+ if (build) console.error(` build: ${build}`);
1174
1406
  console.error(` url: ${state.page_url}`);
1175
1407
  console.error(` started: ${state.started_at}`);
1176
1408
  }
@@ -1322,13 +1554,13 @@ function formatBuildsHuman(builds2) {
1322
1554
  ]);
1323
1555
  const widths = HEADERS.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
1324
1556
  const padCells = (cells) => cells.map((c, i) => c.padEnd(widths[i]));
1325
- const join3 = (cells) => cells.join(" ").trimEnd();
1326
- const lines = [chalk12.bold(join3(padCells(HEADERS)))];
1557
+ const join4 = (cells) => cells.join(" ").trimEnd();
1558
+ const lines = [chalk12.bold(join4(padCells(HEADERS)))];
1327
1559
  for (const row of rows) {
1328
1560
  const padded = padCells(row);
1329
1561
  const colorFn = STATUS_COLOR[row[2]] ?? ((s) => s);
1330
1562
  padded[2] = colorFn(row[2]) + padded[2].slice(row[2].length);
1331
- lines.push(join3(padded));
1563
+ lines.push(join4(padded));
1332
1564
  }
1333
1565
  return lines.join("\n") + "\n";
1334
1566
  }
@@ -1915,9 +2147,9 @@ function formatVersionsHuman(versions2) {
1915
2147
  const rows = versions2.map((v) => [v.platform, String(v.build_number), v.updated_at ?? ""]);
1916
2148
  const widths = HEADERS2.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
1917
2149
  const padCells = (cells) => cells.map((c, i) => c.padEnd(widths[i]));
1918
- const join3 = (cells) => cells.join(" ").trimEnd();
1919
- const lines = [chalk15.bold(join3(padCells(HEADERS2)))];
1920
- for (const row of rows) lines.push(join3(padCells(row)));
2150
+ const join4 = (cells) => cells.join(" ").trimEnd();
2151
+ const lines = [chalk15.bold(join4(padCells(HEADERS2)))];
2152
+ for (const row of rows) lines.push(join4(padCells(row)));
1921
2153
  return lines.join("\n") + "\n";
1922
2154
  }
1923
2155
  function printError3(opts, err) {
@@ -2074,9 +2306,9 @@ function formatEnvVarsHuman(variables) {
2074
2306
  ]);
2075
2307
  const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2076
2308
  const padCells = (cells) => cells.map((c, i) => c.padEnd(widths[i]));
2077
- const join3 = (cells) => cells.join(" ").trimEnd();
2078
- const lines = [chalk17.bold(join3(padCells(HEADERS3)))];
2079
- for (const row of rows) lines.push(join3(padCells(row)));
2309
+ const join4 = (cells) => cells.join(" ").trimEnd();
2310
+ const lines = [chalk17.bold(join4(padCells(HEADERS3)))];
2311
+ for (const row of rows) lines.push(join4(padCells(row)));
2080
2312
  return lines.join("\n") + "\n";
2081
2313
  }
2082
2314
  async function envSet(name, value, opts, deps) {
@@ -2616,9 +2848,9 @@ function formatAscKeysHuman(keys) {
2616
2848
  const rows = keys.map((k) => [k.name, k.key_id, k.issuer_id, formatUtcTimestamp(k.created_at)]);
2617
2849
  const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2618
2850
  const padCells = (cells) => cells.map((c, i) => c.padEnd(widths[i]));
2619
- const join3 = (cells) => cells.join(" ").trimEnd();
2620
- const lines = [chalk18.bold(join3(padCells(HEADERS3)))];
2621
- for (const row of rows) lines.push(join3(padCells(row)));
2851
+ const join4 = (cells) => cells.join(" ").trimEnd();
2852
+ const lines = [chalk18.bold(join4(padCells(HEADERS3)))];
2853
+ for (const row of rows) lines.push(join4(padCells(row)));
2622
2854
  return lines.join("\n") + "\n";
2623
2855
  }
2624
2856
  async function ascKeyRm(name, opts, deps) {
@@ -2692,9 +2924,9 @@ function formatPlayKeysHuman(keys) {
2692
2924
  const rows = keys.map((k) => [k.name, k.client_email, formatUtcTimestamp(k.created_at)]);
2693
2925
  const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2694
2926
  const padCells = (cells) => cells.map((c, i) => c.padEnd(widths[i]));
2695
- const join3 = (cells) => cells.join(" ").trimEnd();
2696
- const lines = [chalk18.bold(join3(padCells(HEADERS3)))];
2697
- for (const row of rows) lines.push(join3(padCells(row)));
2927
+ const join4 = (cells) => cells.join(" ").trimEnd();
2928
+ const lines = [chalk18.bold(join4(padCells(HEADERS3)))];
2929
+ for (const row of rows) lines.push(join4(padCells(row)));
2698
2930
  return lines.join("\n") + "\n";
2699
2931
  }
2700
2932
  async function playKeyRm(name, opts, deps) {
@@ -2772,9 +3004,9 @@ function formatCredentialsHuman(credentials2) {
2772
3004
  ]);
2773
3005
  const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2774
3006
  const padCells = (cells) => cells.map((c, i) => c.padEnd(widths[i]));
2775
- const join3 = (cells) => cells.join(" ").trimEnd();
2776
- const lines = [chalk18.bold(join3(padCells(HEADERS3)))];
2777
- for (const row of rows) lines.push(join3(padCells(row)));
3007
+ const join4 = (cells) => cells.join(" ").trimEnd();
3008
+ const lines = [chalk18.bold(join4(padCells(HEADERS3)))];
3009
+ for (const row of rows) lines.push(join4(padCells(row)));
2778
3010
  return lines.join("\n") + "\n";
2779
3011
  }
2780
3012
  async function credentialsRm(name, opts, deps) {
@@ -2891,9 +3123,9 @@ function formatWebhooksHuman(webhooks2) {
2891
3123
  ]);
2892
3124
  const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2893
3125
  const padCells = (cells) => cells.map((c, i) => c.padEnd(widths[i]));
2894
- const join3 = (cells) => cells.join(" ").trimEnd();
2895
- const lines = [chalk19.bold(join3(padCells(HEADERS3)))];
2896
- for (const row of rows) lines.push(join3(padCells(row)));
3126
+ const join4 = (cells) => cells.join(" ").trimEnd();
3127
+ const lines = [chalk19.bold(join4(padCells(HEADERS3)))];
3128
+ for (const row of rows) lines.push(join4(padCells(row)));
2897
3129
  return lines.join("\n") + "\n";
2898
3130
  }
2899
3131
  async function webhooksList(opts, deps) {
@@ -3168,6 +3400,72 @@ Suggested line for your project's ${chalk20.bold("CLAUDE.md")} (or ${chalk20.bol
3168
3400
  );
3169
3401
  }
3170
3402
 
3403
+ // src/lib/update-check.ts
3404
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync } from "fs";
3405
+ import { homedir as homedir2 } from "os";
3406
+ import { dirname as dirname2, join as join3 } from "path";
3407
+ var CACHE_FILE = join3(homedir2(), ".vibeview", "update-check.json");
3408
+ var REGISTRY_URL = "https://registry.npmjs.org/vibeview/latest";
3409
+ var ONE_DAY_MS = 24 * 60 * 60 * 1e3;
3410
+ function isNewerVersion(a, b) {
3411
+ const parse = (v) => /^\d+\.\d+\.\d+$/.test(v) ? v.split(".").map(Number) : null;
3412
+ const x = parse(a);
3413
+ const y = parse(b);
3414
+ if (!x || !y) return false;
3415
+ for (let i = 0; i < 3; i++) {
3416
+ if (x[i] !== y[i]) return x[i] > y[i];
3417
+ }
3418
+ return false;
3419
+ }
3420
+ function updateCheckAllowed(argv, env2, stderrIsTTY) {
3421
+ if (!stderrIsTTY) return false;
3422
+ if (env2.CI || env2.NO_UPDATE_NOTIFIER || env2.VIBEVIEW_NO_UPDATE_CHECK) return false;
3423
+ const args = argv.slice(2);
3424
+ if (args[0] === "mcp") return false;
3425
+ if (args.includes("--json")) return false;
3426
+ const output = args.findIndex((a) => a === "--output" || a.startsWith("--output="));
3427
+ if (output !== -1) {
3428
+ const value = args[output].includes("=") ? args[output].split("=")[1] : args[output + 1];
3429
+ if (value && value !== "human") return false;
3430
+ }
3431
+ return true;
3432
+ }
3433
+ function updateNotice(current, latest) {
3434
+ return `A newer vibeview is available: ${current} \u2192 ${latest}. Run \`npm i -g vibeview@latest\`, or \`npx vibeview@latest ...\`.`;
3435
+ }
3436
+ function readCache(file) {
3437
+ try {
3438
+ const parsed = JSON.parse(readFileSync3(file, "utf8"));
3439
+ if (typeof parsed?.checked_at === "number" && typeof parsed?.latest === "string") {
3440
+ return parsed;
3441
+ }
3442
+ } catch {
3443
+ }
3444
+ return null;
3445
+ }
3446
+ function checkForUpdate(current, opts) {
3447
+ const file = opts.cacheFile ?? CACHE_FILE;
3448
+ const now = opts.now ?? Date.now();
3449
+ const cache = readCache(file);
3450
+ if (cache && isNewerVersion(cache.latest, current)) {
3451
+ opts.print(updateNotice(current, cache.latest));
3452
+ }
3453
+ if (cache && now - cache.checked_at < ONE_DAY_MS) return Promise.resolve();
3454
+ const fetchLatest = opts.fetchLatest ?? fetchLatestFromRegistry;
3455
+ return fetchLatest().then((latest) => {
3456
+ if (!latest) return;
3457
+ mkdirSync3(dirname2(file), { recursive: true });
3458
+ writeFileSync(file, JSON.stringify({ checked_at: now, latest }) + "\n");
3459
+ }).catch(() => {
3460
+ });
3461
+ }
3462
+ async function fetchLatestFromRegistry() {
3463
+ const resp = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(1500) });
3464
+ if (!resp.ok) return null;
3465
+ const body = await resp.json();
3466
+ return typeof body.version === "string" ? body.version : null;
3467
+ }
3468
+
3171
3469
  // src/cli.ts
3172
3470
  var require2 = createRequire(import.meta.url);
3173
3471
  var pkg = require2("../package.json");
@@ -3208,7 +3506,9 @@ function resolveMetadata(opts) {
3208
3506
  }
3209
3507
  }
3210
3508
  var program = new Command();
3211
- program.name("vibeview").description("VibeView CLI -- run mobile tests from CI/CD pipelines").version(pkg.version);
3509
+ program.name("vibeview").description(
3510
+ "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)"
3511
+ ).version(pkg.version);
3212
3512
  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(
3213
3513
  "--device-category <category>",
3214
3514
  "Device category: phone, tablet, tv (default: phone when --device is not set)",
@@ -3291,7 +3591,12 @@ program.command("list-devices").description("List devices registered with the pl
3291
3591
  new Option("--type <type>", "Filter by device type").choices(["ios", "android", "roku"])
3292
3592
  ).addOption(
3293
3593
  new Option("--category <category>", "Filter by category").choices(["phone", "tablet", "tv"])
3294
- ).addOption(new Option("--status <status>", "Filter by status").choices(["available", "busy"])).option(
3594
+ ).addOption(
3595
+ new Option(
3596
+ "--status <status>",
3597
+ "Filter by status (updating = being updated, takes no new session yet)"
3598
+ ).choices([...DEVICE_STATUS_FILTERS])
3599
+ ).option(
3295
3600
  "--models",
3296
3601
  "List the device models you can start instead (the names `dev --model` takes)",
3297
3602
  false
@@ -3299,7 +3604,12 @@ program.command("list-devices").description("List devices registered with the pl
3299
3604
  program.command("upload-app <file>").description(
3300
3605
  "Upload an app build (zipped simulator .app for iOS/tvOS, .apk for Android/Android TV, or a Roku channel .zip)"
3301
3606
  ).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));
3302
- 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" }));
3607
+ 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(
3608
+ new Option(
3609
+ "--if-exists <mode>",
3610
+ "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"
3611
+ ).choices(["fail", "update", "new"])
3612
+ ).action((name, opts) => createTest({ ...opts, testName: name || opts.testName || "CLI Test" }));
3303
3613
  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));
3304
3614
  program.command("logout").description("Revoke the saved login token and remove it from ~/.vibeview/config.json").action(() => logout());
3305
3615
  program.command("dev").description(
@@ -3465,7 +3775,7 @@ program.command("dev-reload").description(
3465
3775
  program.command("mcp").description(
3466
3776
  "Run an MCP stdio server exposing the agent verbs (tap, swipe, type, ...) plus dev_start/dev_stop/dev_reload as tools"
3467
3777
  ).option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").action(async (opts) => {
3468
- const { runMcpServer } = await import("./server-XJREB7KU.js");
3778
+ const { runMcpServer } = await import("./server-6SRFOQ6B.js");
3469
3779
  await runMcpServer({ ...opts, version: pkg.version });
3470
3780
  });
3471
3781
  program.command("agent-setup").description(
@@ -3489,6 +3799,10 @@ ${pressButtonsHelp()}`);
3489
3799
  return runAgentVerb(cmd, positionals, opts);
3490
3800
  });
3491
3801
  }
3802
+ if (updateCheckAllowed(process.argv, process.env, process.stderr.isTTY)) {
3803
+ void checkForUpdate(pkg.version, { print: (line) => process.stderr.write(`${line}
3804
+ `) });
3805
+ }
3492
3806
  program.parseAsync().catch((e) => {
3493
3807
  if (e instanceof Error && e.name === "ExitPromptError") {
3494
3808
  process.exit(130);