vibeview 0.27.1 → 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
@@ -22,13 +22,15 @@ import {
22
22
  readDevState,
23
23
  readMetroState,
24
24
  runAgentVerb
25
- } from "./chunk-7IOGMM5W.js";
25
+ } from "./chunk-OKOAWGWV.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,
@@ -40,7 +42,7 @@ import {
40
42
  resolveAuth,
41
43
  saveConfig,
42
44
  uploadApp
43
- } from "./chunk-FTTWLCTE.js";
45
+ } from "./chunk-2JLTC67A.js";
44
46
 
45
47
  // src/cli.ts
46
48
  import { createRequire } from "module";
@@ -94,11 +96,8 @@ function formatHuman(result, verbose) {
94
96
  const icon = result.status === "passed" ? chalk.green("PASS") : chalk.red("FAIL");
95
97
  const duration = result.duration_ms ? `${(result.duration_ms / 1e3).toFixed(1)}s` : "-";
96
98
  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
- }
99
+ const title = result.suite_name || result.case_name || result.run_id;
100
+ lines.push(` ${icon} ${chalk.bold(title)} ${chalk.dim(duration)}`);
102
101
  const cases = resultCases(result);
103
102
  const passed = cases.filter((t) => t.status === "passed").length;
104
103
  const total = cases.length;
@@ -123,7 +122,7 @@ function formatHuman(result, verbose) {
123
122
  }
124
123
  if (result.steps && result.steps.length > 0) {
125
124
  lines.push("");
126
- if (result.case_name) {
125
+ if (result.case_name && result.case_name !== title) {
127
126
  lines.push(` ${chalk.bold(result.case_name)}`);
128
127
  }
129
128
  for (const step of result.steps) {
@@ -173,31 +172,64 @@ function formatJunit(result) {
173
172
  lines.push(
174
173
  ` <testsuite name="${suiteName}" tests="${tests}" failures="${failures}" errors="${errors}" time="${totalTime}" timestamp="${timestamp}">`
175
174
  );
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
+ }
176
180
  for (const tr of cases) {
177
181
  const caseName = escapeXml(tr.case_name);
178
182
  const caseTime = tr.duration_ms ? (tr.duration_ms / 1e3).toFixed(1) : "0";
179
- if (tr.status === "passed") {
183
+ const out = tr.url ? ` <system-out>${escapeXml(tr.url)}</system-out>` : null;
184
+ if (tr.status === "passed" && !out) {
180
185
  lines.push(` <testcase name="${caseName}" classname="${suiteName}" time="${caseTime}" />`);
181
- } else if (tr.status === "failed") {
186
+ continue;
187
+ }
188
+ lines.push(` <testcase name="${caseName}" classname="${suiteName}" time="${caseTime}">`);
189
+ if (tr.status === "failed") {
182
190
  const msg = escapeXml(tr.error_message || "Test failed");
183
- lines.push(` <testcase name="${caseName}" classname="${suiteName}" time="${caseTime}">`);
184
191
  lines.push(` <failure message="${msg}" type="TestFailure">${msg}</failure>`);
185
- lines.push(" </testcase>");
186
- } else {
192
+ } else if (tr.status !== "passed") {
187
193
  const msg = escapeXml(tr.error_message || "Infrastructure error");
188
- lines.push(` <testcase name="${caseName}" classname="${suiteName}" time="${caseTime}">`);
189
194
  lines.push(` <error message="${msg}" type="InfraError">${msg}</error>`);
190
- lines.push(" </testcase>");
191
195
  }
196
+ if (out) lines.push(out);
197
+ lines.push(" </testcase>");
192
198
  }
193
199
  lines.push(" </testsuite>");
194
200
  lines.push("</testsuites>");
195
201
  return lines.join("\n");
196
202
  }
197
- 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);
198
229
  const tests = result.suite_runs.reduce((sum, sr) => sum + sr.passed + sr.failed, 0);
199
230
  const failures = result.suite_runs.reduce((sum, sr) => sum + sr.failed, 0);
200
- 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);
201
233
  const timestamp = result.started_at ?? (/* @__PURE__ */ new Date()).toISOString();
202
234
  const lines = [];
203
235
  lines.push('<?xml version="1.0" encoding="UTF-8"?>');
@@ -208,43 +240,113 @@ function formatMultiSuiteJunit(result) {
208
240
  const suiteName = escapeXml(sr.suite_name || sr.suite_id);
209
241
  const suiteTests = sr.passed + sr.failed;
210
242
  const suiteFails = sr.failed;
211
- const suiteErrors = sr.status === "error" ? 1 : 0;
243
+ const suiteErrors = errored(sr.status) ? 1 : 0;
212
244
  lines.push(
213
245
  ` <testsuite name="${suiteName}" tests="${suiteTests}" failures="${suiteFails}" errors="${suiteErrors}" timestamp="${timestamp}">`
214
246
  );
215
- 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) {
216
256
  lines.push(` <error message="Suite errored" type="InfraError" />`);
217
257
  }
218
258
  lines.push(" </testsuite>");
219
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
+ }
220
268
  lines.push("</testsuites>");
221
269
  return lines.join("\n");
222
270
  }
223
271
 
224
272
  // src/lib/poll.ts
225
- function printBelowSpinner(spinner, line) {
226
- 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;
227
282
  spinner.stop();
228
283
  process.stderr.write(line + "\n");
229
- 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
+ }
230
327
  }
231
- async function pollUntilComplete(client, runId, timeoutMs, spinner, onQueueObserved) {
328
+ function keepAliveEnabled(output) {
329
+ return output !== "json" && output !== "junit";
330
+ }
331
+ async function pollUntilComplete(client, runId, timeoutMs, spinner, onQueueObserved, opts = {}) {
232
332
  const deadline = Date.now() + timeoutMs;
233
333
  const isCI = process.env.CI === "true" || process.env.CI === "1" || !process.stdout.isTTY;
334
+ const keepAlive = isCI && opts.keepAlive !== false;
234
335
  let lastLogTime = Date.now();
235
336
  let lastSeenEventCount = 0;
236
337
  let totalSteps;
237
338
  let currentStepLabel = "";
339
+ const printedSteps = /* @__PURE__ */ new Set();
238
340
  let cachedDeviceLabel = null;
239
341
  const caseStateBefore = /* @__PURE__ */ new Map();
240
342
  const caseStartedPrinted = /* @__PURE__ */ new Set();
241
343
  while (Date.now() < deadline) {
242
- const result = await client.getRunStatus(runId);
344
+ const result = await retryTransient(() => client.getRunStatus(runId), { deadline });
243
345
  const progress = result.progress || [];
244
346
  if (progress.length > lastSeenEventCount) {
245
347
  const newEvents = progress.slice(lastSeenEventCount);
246
348
  for (const evt of newEvents) {
247
- const ctx = { spinner, totalSteps, currentStepLabel };
349
+ const ctx = { spinner, totalSteps, currentStepLabel, printedSteps };
248
350
  displayProgressEvent(evt, ctx);
249
351
  totalSteps = ctx.totalSteps;
250
352
  currentStepLabel = ctx.currentStepLabel;
@@ -277,6 +379,20 @@ async function pollUntilComplete(client, runId, timeoutMs, spinner, onQueueObser
277
379
  }
278
380
  }
279
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
+ }
280
396
  return result;
281
397
  }
282
398
  if (result.status === "queued" && result.queue) {
@@ -317,20 +433,24 @@ async function pollUntilComplete(client, runId, timeoutMs, spinner, onQueueObser
317
433
  spinner.text = `Status: ${result.status}...`;
318
434
  }
319
435
  }
320
- if (isCI && Date.now() - lastLogTime >= 3e4) {
321
- 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
+ `);
322
440
  lastLogTime = Date.now();
323
441
  }
324
442
  await new Promise((r) => setTimeout(r, 3e3));
325
443
  }
326
- 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
+ );
327
447
  }
328
448
  function formatAction(action) {
329
449
  const tool = action.tool;
330
450
  const args = action.args || {};
331
451
  switch (tool) {
332
452
  case "tap":
333
- return `tap ${args.element_ref || ""}`;
453
+ return `tap ${args.ref ?? args.element_ref ?? ""}`.trimEnd();
334
454
  case "tap_coordinates":
335
455
  return `tap (${args.x}, ${args.y})`;
336
456
  case "type_text":
@@ -358,7 +478,8 @@ function displayProgressEvent(evt, ctx) {
358
478
  case "run_started":
359
479
  printBelowSpinner(
360
480
  spinner,
361
- `${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..."
362
483
  );
363
484
  ctx.totalSteps = evt.total_steps ?? ctx.totalSteps;
364
485
  break;
@@ -370,23 +491,17 @@ function displayProgressEvent(evt, ctx) {
370
491
  break;
371
492
  }
372
493
  case "step_completed": {
373
- const stepNum = (evt.step_index ?? 0) + 1;
494
+ const stepIndex = evt.step_index ?? 0;
495
+ const stepNum = stepIndex + 1;
374
496
  const total = ctx.totalSteps ?? evt.total_steps ?? "?";
375
- 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);
376
499
  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();
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
+ );
390
505
  break;
391
506
  }
392
507
  case "conversation_turn": {
@@ -405,7 +520,7 @@ function displayProgressEvent(evt, ctx) {
405
520
  `${icon} Run ${evt.status}${evt.duration_ms ? ` (${(evt.duration_ms / 1e3).toFixed(1)}s)` : ""}
406
521
  `
407
522
  );
408
- spinner.start();
523
+ resumeSpinner(spinner);
409
524
  break;
410
525
  }
411
526
  case "app_installing":
@@ -418,6 +533,17 @@ function displayProgressEvent(evt, ctx) {
418
533
  break;
419
534
  }
420
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
+ }
421
547
  function outputResult(result, options) {
422
548
  switch (options.output) {
423
549
  case "json":
@@ -443,6 +569,7 @@ async function runSuite(suiteId, options) {
443
569
  const spinner = createSpinner("Starting suite run...");
444
570
  let sessionId;
445
571
  let lastQueueEntryId;
572
+ let runId;
446
573
  let cleaning = false;
447
574
  const cleanup = async (signal) => {
448
575
  if (cleaning) process.exit(130);
@@ -503,6 +630,7 @@ ${signal} received.
503
630
  metadata: options.metadata
504
631
  });
505
632
  sessionId = response.session_id;
633
+ runId = response.run_id;
506
634
  spinner.stop();
507
635
  process.stderr.write(
508
636
  `${chalk3.dim("Suite:")} ${suiteId}
@@ -519,7 +647,8 @@ ${chalk3.dim("Run:")} ${response.run_id}
519
647
  spinner,
520
648
  (entryId) => {
521
649
  lastQueueEntryId = entryId;
522
- }
650
+ },
651
+ { keepAlive: keepAliveEnabled(options.output) }
523
652
  );
524
653
  spinner.stop();
525
654
  if (process.stdin.isTTY) {
@@ -530,7 +659,11 @@ ${chalk3.dim("Run:")} ${response.run_id}
530
659
  process.exit(mapExitCode(result.status));
531
660
  } catch (err) {
532
661
  spinner.fail(err instanceof Error ? err.message : "Unknown error");
533
- if (options.output === "json") printJsonError(err);
662
+ outputRunError(err, options, {
663
+ name: suiteId,
664
+ runId,
665
+ url: runId ? `${baseUrl}/tests/runs/${runId}` : void 0
666
+ });
534
667
  if (lastQueueEntryId !== void 0) {
535
668
  try {
536
669
  await client.cancelQueueEntry(lastQueueEntryId);
@@ -621,11 +754,13 @@ ${signal} received.
621
754
  const suffix = opts.platform ? ` on platform ${opts.platform}` : "";
622
755
  const noMatch = `No suites match tags: ${opts.tag.join(", ")}${suffix}`;
623
756
  if (opts.output === "json") printJsonError(noMatch);
757
+ if (opts.output === "junit") console.log(formatJunitError("vibeview run-suites", noMatch));
624
758
  process.stderr.write(chalk4.yellow(`${noMatch}
625
759
  `));
626
760
  process.exit(EXIT_INFRA_ERROR);
627
761
  }
628
762
  if (opts.output === "json") printJsonError(err);
763
+ if (opts.output === "junit") console.log(formatJunitError("vibeview run-suites", msg));
629
764
  process.stderr.write(chalk4.red(`Failed to start: ${msg}
630
765
  `));
631
766
  process.exit(EXIT_INFRA_ERROR);
@@ -664,8 +799,47 @@ ${chalk4.dim("Matched:")} ${totalMatched} suite(s)${matchedNames.length ? " \u20
664
799
  };
665
800
  const TERMINAL = /* @__PURE__ */ new Set(["passed", "failed", "error", "cancelled"]);
666
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
+ };
667
834
  while (Date.now() < deadline) {
668
- 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
+ }
669
843
  let doneCount = 0;
670
844
  let queuedChild;
671
845
  for (const sr of last.suite_runs) {
@@ -700,19 +874,19 @@ ${chalk4.dim("Matched:")} ${totalMatched} suite(s)${matchedNames.length ? " \u20
700
874
  } else {
701
875
  spinner.text = `Running suites \u2014 ${doneCount}/${totalMatched} complete...`;
702
876
  }
703
- if (last.status !== "pending" && last.status !== "running") break;
877
+ if (TERMINAL.has(last.status)) break;
704
878
  await new Promise((r) => setTimeout(r, 2e3));
705
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
+ }
706
885
  spinner.stop();
707
886
  if (process.stdin.isTTY) {
708
887
  process.stdin.setRawMode(false);
709
888
  process.stdin.pause();
710
889
  }
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
890
  if (opts.output === "json") {
717
891
  process.stdout.write(JSON.stringify(last, null, 2) + "\n");
718
892
  } else if (opts.output === "junit") {
@@ -758,6 +932,7 @@ async function runTest(testId, options) {
758
932
  const client = new ApiClient(baseUrl, token);
759
933
  let sessionId;
760
934
  let lastQueueEntryId;
935
+ let runId;
761
936
  const spinner = createSpinner("Starting test run...");
762
937
  let cleaning = false;
763
938
  const cleanup = async (signal) => {
@@ -819,6 +994,7 @@ ${signal} received.
819
994
  metadata: options.metadata
820
995
  });
821
996
  sessionId = response.session_id;
997
+ runId = response.run_id;
822
998
  spinner.stop();
823
999
  process.stderr.write(
824
1000
  `${chalk5.dim("Session:")} ${sessionId || "unknown"}
@@ -836,7 +1012,8 @@ ${chalk5.dim("Run:")} ${response.run_id}
836
1012
  spinner,
837
1013
  (entryId) => {
838
1014
  lastQueueEntryId = entryId;
839
- }
1015
+ },
1016
+ { keepAlive: keepAliveEnabled(options.output) }
840
1017
  );
841
1018
  spinner.stop();
842
1019
  if (process.stdin.isTTY) {
@@ -847,7 +1024,11 @@ ${chalk5.dim("Run:")} ${response.run_id}
847
1024
  process.exit(mapExitCode(result.status));
848
1025
  } catch (err) {
849
1026
  spinner.fail(err instanceof Error ? err.message : "Unknown error");
850
- if (options.output === "json") printJsonError(err);
1027
+ outputRunError(err, options, {
1028
+ name: testId,
1029
+ runId,
1030
+ url: runId ? `${baseUrl}/tests/runs/${runId}` : void 0
1031
+ });
851
1032
  if (lastQueueEntryId !== void 0) {
852
1033
  try {
853
1034
  await client.cancelQueueEntry(lastQueueEntryId);
@@ -893,7 +1074,9 @@ async function listSuites(options) {
893
1074
  for (const s of suites) {
894
1075
  const platform = s.platform ? chalk6.dim(` [${s.platform}]`) : "";
895
1076
  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)`);
1077
+ console.log(
1078
+ ` ${chalk6.cyan(s.id)} ${s.name}${platform}${tagsText} (${s.case_count} ${s.case_count === 1 ? "case" : "cases"})`
1079
+ );
897
1080
  }
898
1081
  console.log();
899
1082
  }
@@ -1048,9 +1231,12 @@ async function createTest(options) {
1048
1231
  app_id: appPublicId || "",
1049
1232
  steps,
1050
1233
  context: options.context,
1051
- mode: "ai"
1234
+ mode: "ai",
1235
+ if_exists: options.ifExists
1052
1236
  });
1053
- 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
+ );
1054
1240
  if (options.output === "json") {
1055
1241
  console.log(
1056
1242
  JSON.stringify(
@@ -1058,6 +1244,7 @@ async function createTest(options) {
1058
1244
  test_id: result.public_id,
1059
1245
  name: result.name,
1060
1246
  app_id: appPublicId || null,
1247
+ outcome: result.outcome ?? null,
1061
1248
  url: `${baseUrl}/tests?tab=standalone`
1062
1249
  },
1063
1250
  null,
@@ -1078,8 +1265,11 @@ async function createTest(options) {
1078
1265
  `));
1079
1266
  }
1080
1267
  } catch (err) {
1268
+ if (options.output === "json") printJsonError(err);
1081
1269
  console.error(chalk9.red(err instanceof Error ? err.message : "Failed to create test"));
1082
- process.exit(EXIT_INFRA_ERROR);
1270
+ process.exit(
1271
+ err instanceof ApiStatusError && err.status === 409 ? EXIT_TEST_FAIL : EXIT_INFRA_ERROR
1272
+ );
1083
1273
  }
1084
1274
  }
1085
1275
 
@@ -1161,6 +1351,11 @@ async function logout() {
1161
1351
 
1162
1352
  // src/commands/dev-status.ts
1163
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
+ }
1164
1359
  async function devStatus(opts) {
1165
1360
  const state = readDevState(process.cwd());
1166
1361
  if (!state) {
@@ -1175,12 +1370,14 @@ async function devStatus(opts) {
1175
1370
  const { token, baseUrl } = resolveAuth(opts);
1176
1371
  const api = new ApiClient(baseUrl, token);
1177
1372
  let sessionStatus;
1373
+ let info;
1178
1374
  try {
1179
- const info = await api.getSession(state.session_id);
1375
+ info = await api.getSession(state.session_id);
1180
1376
  sessionStatus = info.status;
1181
1377
  } catch {
1182
1378
  sessionStatus = void 0;
1183
1379
  }
1380
+ const build = buildLine(info);
1184
1381
  const metro = readMetroState(process.cwd());
1185
1382
  if (opts.json) {
1186
1383
  console.log(
@@ -1194,6 +1391,9 @@ async function devStatus(opts) {
1194
1391
  metro_port: state.metro_port,
1195
1392
  started_at: state.started_at,
1196
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,
1197
1397
  ...metro ? { owned_metro_pid: metro.pid, owned_metro_port: metro.port } : {}
1198
1398
  })
1199
1399
  );
@@ -1202,6 +1402,7 @@ async function devStatus(opts) {
1202
1402
  console.error(` session: ${state.session_id}${sessionStatus ? ` (${sessionStatus})` : ""}`);
1203
1403
  console.error(` platform: ${state.platform}`);
1204
1404
  console.error(` app: ${state.app_id}`);
1405
+ if (build) console.error(` build: ${build}`);
1205
1406
  console.error(` url: ${state.page_url}`);
1206
1407
  console.error(` started: ${state.started_at}`);
1207
1408
  }
@@ -1353,13 +1554,13 @@ function formatBuildsHuman(builds2) {
1353
1554
  ]);
1354
1555
  const widths = HEADERS.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
1355
1556
  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)))];
1557
+ const join4 = (cells) => cells.join(" ").trimEnd();
1558
+ const lines = [chalk12.bold(join4(padCells(HEADERS)))];
1358
1559
  for (const row of rows) {
1359
1560
  const padded = padCells(row);
1360
1561
  const colorFn = STATUS_COLOR[row[2]] ?? ((s) => s);
1361
1562
  padded[2] = colorFn(row[2]) + padded[2].slice(row[2].length);
1362
- lines.push(join3(padded));
1563
+ lines.push(join4(padded));
1363
1564
  }
1364
1565
  return lines.join("\n") + "\n";
1365
1566
  }
@@ -1946,9 +2147,9 @@ function formatVersionsHuman(versions2) {
1946
2147
  const rows = versions2.map((v) => [v.platform, String(v.build_number), v.updated_at ?? ""]);
1947
2148
  const widths = HEADERS2.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
1948
2149
  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)));
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)));
1952
2153
  return lines.join("\n") + "\n";
1953
2154
  }
1954
2155
  function printError3(opts, err) {
@@ -2105,9 +2306,9 @@ function formatEnvVarsHuman(variables) {
2105
2306
  ]);
2106
2307
  const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2107
2308
  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)));
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)));
2111
2312
  return lines.join("\n") + "\n";
2112
2313
  }
2113
2314
  async function envSet(name, value, opts, deps) {
@@ -2647,9 +2848,9 @@ function formatAscKeysHuman(keys) {
2647
2848
  const rows = keys.map((k) => [k.name, k.key_id, k.issuer_id, formatUtcTimestamp(k.created_at)]);
2648
2849
  const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2649
2850
  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)));
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)));
2653
2854
  return lines.join("\n") + "\n";
2654
2855
  }
2655
2856
  async function ascKeyRm(name, opts, deps) {
@@ -2723,9 +2924,9 @@ function formatPlayKeysHuman(keys) {
2723
2924
  const rows = keys.map((k) => [k.name, k.client_email, formatUtcTimestamp(k.created_at)]);
2724
2925
  const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2725
2926
  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)));
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)));
2729
2930
  return lines.join("\n") + "\n";
2730
2931
  }
2731
2932
  async function playKeyRm(name, opts, deps) {
@@ -2803,9 +3004,9 @@ function formatCredentialsHuman(credentials2) {
2803
3004
  ]);
2804
3005
  const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2805
3006
  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)));
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)));
2809
3010
  return lines.join("\n") + "\n";
2810
3011
  }
2811
3012
  async function credentialsRm(name, opts, deps) {
@@ -2922,9 +3123,9 @@ function formatWebhooksHuman(webhooks2) {
2922
3123
  ]);
2923
3124
  const widths = HEADERS3.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
2924
3125
  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)));
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)));
2928
3129
  return lines.join("\n") + "\n";
2929
3130
  }
2930
3131
  async function webhooksList(opts, deps) {
@@ -3199,6 +3400,72 @@ Suggested line for your project's ${chalk20.bold("CLAUDE.md")} (or ${chalk20.bol
3199
3400
  );
3200
3401
  }
3201
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
+
3202
3469
  // src/cli.ts
3203
3470
  var require2 = createRequire(import.meta.url);
3204
3471
  var pkg = require2("../package.json");
@@ -3239,7 +3506,9 @@ function resolveMetadata(opts) {
3239
3506
  }
3240
3507
  }
3241
3508
  var program = new Command();
3242
- 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);
3243
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(
3244
3513
  "--device-category <category>",
3245
3514
  "Device category: phone, tablet, tv (default: phone when --device is not set)",
@@ -3335,7 +3604,12 @@ program.command("list-devices").description("List devices registered with the pl
3335
3604
  program.command("upload-app <file>").description(
3336
3605
  "Upload an app build (zipped simulator .app for iOS/tvOS, .apk for Android/Android TV, or a Roku channel .zip)"
3337
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));
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" }));
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" }));
3339
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));
3340
3614
  program.command("logout").description("Revoke the saved login token and remove it from ~/.vibeview/config.json").action(() => logout());
3341
3615
  program.command("dev").description(
@@ -3501,7 +3775,7 @@ program.command("dev-reload").description(
3501
3775
  program.command("mcp").description(
3502
3776
  "Run an MCP stdio server exposing the agent verbs (tap, swipe, type, ...) plus dev_start/dev_stop/dev_reload as tools"
3503
3777
  ).option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").action(async (opts) => {
3504
- const { runMcpServer } = await import("./server-ZIUZ7U5T.js");
3778
+ const { runMcpServer } = await import("./server-6SRFOQ6B.js");
3505
3779
  await runMcpServer({ ...opts, version: pkg.version });
3506
3780
  });
3507
3781
  program.command("agent-setup").description(
@@ -3525,6 +3799,10 @@ ${pressButtonsHelp()}`);
3525
3799
  return runAgentVerb(cmd, positionals, opts);
3526
3800
  });
3527
3801
  }
3802
+ if (updateCheckAllowed(process.argv, process.env, process.stderr.isTTY)) {
3803
+ void checkForUpdate(pkg.version, { print: (line) => process.stderr.write(`${line}
3804
+ `) });
3805
+ }
3528
3806
  program.parseAsync().catch((e) => {
3529
3807
  if (e instanceof Error && e.name === "ExitPromptError") {
3530
3808
  process.exit(130);