vibeview 0.7.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.
Files changed (2) hide show
  1. package/dist/cli.js +1947 -0
  2. package/package.json +46 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1947 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { createRequire } from "module";
5
+ import { Command } from "commander";
6
+
7
+ // src/commands/run-suite.ts
8
+ import chalk3 from "chalk";
9
+
10
+ // src/lib/config.ts
11
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
12
+ import { join } from "path";
13
+ import { homedir } from "os";
14
+ var CONFIG_DIR = join(homedir(), ".vibeview");
15
+ var CONFIG_FILE = join(CONFIG_DIR, "config.json");
16
+ function loadConfig() {
17
+ try {
18
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+ function saveConfig(config) {
24
+ mkdirSync(CONFIG_DIR, { recursive: true });
25
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
26
+ }
27
+
28
+ // src/lib/auth.ts
29
+ function resolveAuth(options) {
30
+ const token = options.token || process.env.VIBEVIEW_API_TOKEN || loadConfig().token;
31
+ if (!token) {
32
+ console.error("Error: No API token found.");
33
+ console.error(
34
+ "Set VIBEVIEW_API_TOKEN environment variable, use --token flag, or run: vibeview login"
35
+ );
36
+ process.exit(2);
37
+ }
38
+ const baseUrl = options.baseUrl || process.env.VIBEVIEW_BASE_URL || loadConfig().baseUrl || "https://vibeview.io";
39
+ return { token, baseUrl };
40
+ }
41
+
42
+ // src/lib/api-client.ts
43
+ import { readFileSync as readFileSync2 } from "fs";
44
+ import { basename } from "path";
45
+ var ApiClient = class {
46
+ constructor(baseUrl, token) {
47
+ this.baseUrl = baseUrl;
48
+ this.token = token;
49
+ }
50
+ async request(method, path, body) {
51
+ const url = `${this.baseUrl}/api/v1/ci${path}`;
52
+ const headers = {
53
+ Authorization: `Bearer ${this.token}`,
54
+ "Content-Type": "application/json"
55
+ };
56
+ const resp = await fetch(url, {
57
+ method,
58
+ headers,
59
+ body: body ? JSON.stringify(body) : void 0
60
+ });
61
+ if (!resp.ok) {
62
+ const text = await resp.text().catch(() => "");
63
+ if (resp.status === 401) throw new Error("Authentication failed. Check your API token.");
64
+ if (resp.status === 404) throw new Error(`Not found: ${path}`);
65
+ throw new Error(`API error ${resp.status}: ${text}`);
66
+ }
67
+ return resp.json();
68
+ }
69
+ async listSuites(opts) {
70
+ const params = new URLSearchParams();
71
+ if (opts?.tags && opts.tags.length > 0) params.set("tags", opts.tags.join(","));
72
+ if (opts?.match) params.set("match", opts.match);
73
+ const qs = params.toString();
74
+ return this.request("GET", `/list-suites${qs ? `?${qs}` : ""}`);
75
+ }
76
+ async runSuite(suiteId, options) {
77
+ return this.request("POST", "/run-suite", {
78
+ suite_id: suiteId,
79
+ commit_sha: options?.commitSha,
80
+ model: options?.model,
81
+ provider: options?.provider,
82
+ mode: options?.mode,
83
+ // When pinning a specific device, omit device_category — the backend
84
+ // rejects the combo.
85
+ device_category: options?.device ? void 0 : options?.deviceCategory ?? "phone",
86
+ device_id: options?.device,
87
+ build_id: options?.build,
88
+ metadata: options?.metadata
89
+ });
90
+ }
91
+ async runTest(testId, options) {
92
+ return this.request("POST", "/run-test", {
93
+ test_id: testId,
94
+ commit_sha: options?.commitSha,
95
+ model: options?.model,
96
+ provider: options?.provider,
97
+ mode: options?.mode,
98
+ device_category: options?.device ? void 0 : options?.deviceCategory ?? "phone",
99
+ device_id: options?.device,
100
+ build_id: options?.build,
101
+ metadata: options?.metadata
102
+ });
103
+ }
104
+ /** Cancel a still-queued CI run by its queue entry id. Idempotent best-effort. */
105
+ async cancelQueueEntry(queueEntryId) {
106
+ await this.request("DELETE", `/queue/${queueEntryId}`);
107
+ }
108
+ /**
109
+ * Request cancellation of a still-running multi-suite run.
110
+ * The aggregate transitions to CANCELLED on the runner's next
111
+ * aggregate-status write; the active child suite stops at its
112
+ * next case boundary.
113
+ */
114
+ async cancelMultiSuiteRun(aggregateId) {
115
+ await this.request("DELETE", `/multi-suite-runs/${aggregateId}`);
116
+ }
117
+ async getRunStatus(runId) {
118
+ const data = await this.request("GET", `/runs/${runId}`);
119
+ return { ...data, url: data.run_url ?? `${this.baseUrl}/tests/runs/${runId}` };
120
+ }
121
+ /** Request without /ci prefix — for non-CI endpoints like /apps, /tests */
122
+ async requestRaw(method, path, body) {
123
+ const url = `${this.baseUrl}/api/v1${path}`;
124
+ const headers = {
125
+ Authorization: `Bearer ${this.token}`,
126
+ "Content-Type": "application/json"
127
+ };
128
+ const resp = await fetch(url, {
129
+ method,
130
+ headers,
131
+ body: body ? JSON.stringify(body) : void 0
132
+ });
133
+ if (!resp.ok) {
134
+ const text = await resp.text().catch(() => "");
135
+ if (resp.status === 401) throw new Error("Authentication failed. Check your API token.");
136
+ if (resp.status === 404) throw new Error(`Not found: ${path}`);
137
+ throw new Error(`API error ${resp.status}: ${text}`);
138
+ }
139
+ return resp.json();
140
+ }
141
+ /** FormData request without /ci prefix — for file uploads */
142
+ async requestFormData(method, path, formData) {
143
+ const url = `${this.baseUrl}/api/v1${path}`;
144
+ const headers = {
145
+ Authorization: `Bearer ${this.token}`
146
+ // Do NOT set Content-Type — let fetch set multipart boundary automatically
147
+ };
148
+ const resp = await fetch(url, {
149
+ method,
150
+ headers,
151
+ body: formData
152
+ });
153
+ if (!resp.ok) {
154
+ const text = await resp.text().catch(() => "");
155
+ if (resp.status === 401) throw new Error("Authentication failed. Check your API token.");
156
+ if (resp.status === 404) throw new Error(`Not found: ${path}`);
157
+ throw new Error(`API error ${resp.status}: ${text}`);
158
+ }
159
+ return resp.json();
160
+ }
161
+ async listApps() {
162
+ return this.requestRaw("GET", "/apps/");
163
+ }
164
+ async uploadApp(filePath, name) {
165
+ const fileBuffer = readFileSync2(filePath);
166
+ const blob = new Blob([fileBuffer]);
167
+ const formData = new FormData();
168
+ formData.append("file", blob, basename(filePath));
169
+ if (name) {
170
+ formData.append("name", name);
171
+ }
172
+ return this.requestFormData("POST", "/apps/upload", formData);
173
+ }
174
+ async uploadBuild(appPublicId, filePath, version) {
175
+ const fileBuffer = readFileSync2(filePath);
176
+ const blob = new Blob([fileBuffer]);
177
+ const formData = new FormData();
178
+ formData.append("file", blob, basename(filePath));
179
+ if (version) {
180
+ formData.append("version", version);
181
+ }
182
+ return this.requestFormData("POST", `/apps/${appPublicId}/builds`, formData);
183
+ }
184
+ async createTest(body) {
185
+ return this.requestRaw("POST", "/tests/cases/", body);
186
+ }
187
+ async runSuites(body) {
188
+ return this.request("POST", "/run-suites", body);
189
+ }
190
+ async getMultiSuiteRun(aggregateId) {
191
+ return this.request("GET", `/multi-suite-runs/${aggregateId}`);
192
+ }
193
+ async listDevices(opts) {
194
+ const params = new URLSearchParams();
195
+ if (opts?.type) params.set("device_type", opts.type);
196
+ if (opts?.category) params.set("category", opts.category);
197
+ const qs = params.toString();
198
+ const url = qs ? `/devices?${qs}` : "/devices";
199
+ const all = await this.requestRaw("GET", url);
200
+ if (opts?.status) {
201
+ return all.filter((d) => d.status === opts.status);
202
+ }
203
+ return all;
204
+ }
205
+ async getDevice(deviceId) {
206
+ return this.requestRaw("GET", `/devices/${deviceId}`);
207
+ }
208
+ async createSession(body) {
209
+ return this.requestRaw("POST", "/sessions/", body);
210
+ }
211
+ async startSession(sessionId, deviceId) {
212
+ return this.requestRaw("POST", `/sessions/${sessionId}/start`, { device_id: deviceId });
213
+ }
214
+ async getSession(sessionId) {
215
+ return this.requestRaw("GET", `/sessions/${sessionId}`);
216
+ }
217
+ async stopSession(sessionId) {
218
+ await this.requestRaw("DELETE", `/sessions/${sessionId}`);
219
+ }
220
+ };
221
+
222
+ // src/lib/spinner.ts
223
+ import ora from "ora";
224
+ function createSpinner(text) {
225
+ const isCI = process.env.CI === "true" || process.env.CI === "1" || !process.stdout.isTTY;
226
+ return ora({ text, isEnabled: !isCI, stream: process.stderr });
227
+ }
228
+
229
+ // src/lib/poll.ts
230
+ import chalk2 from "chalk";
231
+
232
+ // src/lib/formatters/human.ts
233
+ import chalk from "chalk";
234
+ function failureLines(failure) {
235
+ const lines = [];
236
+ if (failure.error_kind) {
237
+ const desc = failure.error_kind_description ? ` ${failure.error_kind_description}` : "";
238
+ lines.push(` ${chalk.yellow(`[${failure.error_kind}]`)}${chalk.dim(desc)}`);
239
+ }
240
+ const detail = failure.error ?? failure.error_message;
241
+ if (detail) {
242
+ lines.push(` ${chalk.dim(detail)}`);
243
+ }
244
+ if (failure.screenshot_signed_url) {
245
+ lines.push(` ${chalk.dim(`\u2192 ${failure.screenshot_signed_url}`)}`);
246
+ }
247
+ return lines;
248
+ }
249
+ function formatHuman(result, verbose) {
250
+ const lines = [];
251
+ const icon = result.status === "passed" ? chalk.green("PASS") : chalk.red("FAIL");
252
+ const duration = result.duration_ms ? `${(result.duration_ms / 1e3).toFixed(1)}s` : "-";
253
+ lines.push("");
254
+ if (result.suite_name) {
255
+ lines.push(` ${icon} ${chalk.bold(result.suite_name)} ${chalk.dim(duration)}`);
256
+ } else {
257
+ lines.push(` ${icon} ${chalk.bold(result.run_id)} ${chalk.dim(duration)}`);
258
+ }
259
+ const passed = result.test_runs.filter((t) => t.status === "passed").length;
260
+ const total = result.test_runs.length;
261
+ lines.push(` ${chalk.dim(`${passed}/${total} cases passed`)}`);
262
+ if (result.url) {
263
+ lines.push(` ${chalk.dim(result.url)}`);
264
+ }
265
+ if (result.test_runs.length > 0) {
266
+ lines.push("");
267
+ for (const tr of result.test_runs) {
268
+ const caseIcon = tr.status === "passed" ? chalk.green(" PASS") : tr.status === "cancelled" || tr.status === "pending" || tr.status === "running" ? chalk.yellow(" SKIP") : chalk.red(" FAIL");
269
+ const caseDuration = tr.duration_ms ? `${(tr.duration_ms / 1e3).toFixed(1)}s` : "-";
270
+ lines.push(` ${caseIcon} ${tr.case_name} ${chalk.dim(caseDuration)}`);
271
+ if (tr.url) {
272
+ lines.push(` ${chalk.dim(tr.url)}`);
273
+ }
274
+ if (tr.status !== "passed") {
275
+ lines.push(...failureLines(tr));
276
+ }
277
+ }
278
+ }
279
+ if (result.steps && result.steps.length > 0) {
280
+ lines.push("");
281
+ if (result.case_name) {
282
+ lines.push(` ${chalk.bold(result.case_name)}`);
283
+ }
284
+ for (const step of result.steps) {
285
+ const stepIcon = step.status === "passed" ? chalk.green(" PASS") : chalk.red(" FAIL");
286
+ const stepDuration = step.duration_ms ? `${(step.duration_ms / 1e3).toFixed(1)}s` : "-";
287
+ lines.push(` ${stepIcon} ${step.step} ${chalk.dim(stepDuration)}`);
288
+ if (verbose && step.actions && step.actions.length > 0) {
289
+ for (const action of step.actions) {
290
+ lines.push(` ${chalk.dim(formatAction(action))}`);
291
+ }
292
+ }
293
+ if (step.status !== "passed") {
294
+ lines.push(...failureLines(step));
295
+ }
296
+ }
297
+ }
298
+ if (result.error_message) {
299
+ lines.push(`
300
+ ${chalk.red("Error:")} ${result.error_message}`);
301
+ }
302
+ lines.push("");
303
+ return lines.join("\n");
304
+ }
305
+
306
+ // src/lib/formatters/json.ts
307
+ function formatJson(result) {
308
+ return JSON.stringify(result, null, 2);
309
+ }
310
+
311
+ // src/lib/formatters/junit.ts
312
+ function escapeXml(s) {
313
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
314
+ }
315
+ function formatJunit(result) {
316
+ const suiteName = escapeXml(result.suite_name || result.run_id);
317
+ const tests = result.test_runs.length;
318
+ const failures = result.test_runs.filter((t) => t.status === "failed").length;
319
+ const errors = result.test_runs.filter((t) => t.status === "error").length;
320
+ const totalTime = result.duration_ms ? (result.duration_ms / 1e3).toFixed(1) : "0";
321
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
322
+ const lines = [];
323
+ lines.push('<?xml version="1.0" encoding="UTF-8"?>');
324
+ lines.push(
325
+ `<testsuites name="vibeview" tests="${tests}" failures="${failures}" errors="${errors}" time="${totalTime}">`
326
+ );
327
+ lines.push(
328
+ ` <testsuite name="${suiteName}" tests="${tests}" failures="${failures}" errors="${errors}" time="${totalTime}" timestamp="${timestamp}">`
329
+ );
330
+ for (const tr of result.test_runs) {
331
+ const caseName = escapeXml(tr.case_name);
332
+ const caseTime = tr.duration_ms ? (tr.duration_ms / 1e3).toFixed(1) : "0";
333
+ if (tr.status === "passed") {
334
+ lines.push(` <testcase name="${caseName}" classname="${suiteName}" time="${caseTime}" />`);
335
+ } else if (tr.status === "failed") {
336
+ const msg = escapeXml(tr.error_message || "Test failed");
337
+ lines.push(` <testcase name="${caseName}" classname="${suiteName}" time="${caseTime}">`);
338
+ lines.push(` <failure message="${msg}" type="TestFailure">${msg}</failure>`);
339
+ lines.push(" </testcase>");
340
+ } else {
341
+ const msg = escapeXml(tr.error_message || "Infrastructure error");
342
+ lines.push(` <testcase name="${caseName}" classname="${suiteName}" time="${caseTime}">`);
343
+ lines.push(` <error message="${msg}" type="InfraError">${msg}</error>`);
344
+ lines.push(" </testcase>");
345
+ }
346
+ }
347
+ lines.push(" </testsuite>");
348
+ lines.push("</testsuites>");
349
+ return lines.join("\n");
350
+ }
351
+ function formatMultiSuiteJunit(result) {
352
+ const tests = result.suite_runs.reduce((sum, sr) => sum + sr.passed + sr.failed, 0);
353
+ const failures = result.suite_runs.reduce((sum, sr) => sum + sr.failed, 0);
354
+ const totalErrors = result.suite_runs.filter((sr) => sr.status === "error").length;
355
+ const timestamp = result.started_at ?? (/* @__PURE__ */ new Date()).toISOString();
356
+ const lines = [];
357
+ lines.push('<?xml version="1.0" encoding="UTF-8"?>');
358
+ lines.push(
359
+ `<testsuites name="vibeview-multi" tests="${tests}" failures="${failures}" errors="${totalErrors}">`
360
+ );
361
+ for (const sr of result.suite_runs) {
362
+ const suiteName = escapeXml(sr.suite_name || sr.suite_id);
363
+ const suiteTests = sr.passed + sr.failed;
364
+ const suiteFails = sr.failed;
365
+ const suiteErrors = sr.status === "error" ? 1 : 0;
366
+ lines.push(
367
+ ` <testsuite name="${suiteName}" tests="${suiteTests}" failures="${suiteFails}" errors="${suiteErrors}" timestamp="${timestamp}">`
368
+ );
369
+ if (suiteErrors > 0) {
370
+ lines.push(` <error message="Suite errored" type="InfraError" />`);
371
+ }
372
+ lines.push(" </testsuite>");
373
+ }
374
+ lines.push("</testsuites>");
375
+ return lines.join("\n");
376
+ }
377
+
378
+ // src/lib/types.ts
379
+ var EXIT_PASS = 0;
380
+ var EXIT_TEST_FAIL = 1;
381
+ var EXIT_INFRA_ERROR = 2;
382
+
383
+ // src/lib/poll.ts
384
+ function printBelowSpinner(spinner, line) {
385
+ const text = spinner.text;
386
+ spinner.stop();
387
+ process.stderr.write(line + "\n");
388
+ spinner.start(text);
389
+ }
390
+ async function pollUntilComplete(client, runId, timeoutMs, spinner, onQueueObserved) {
391
+ const deadline = Date.now() + timeoutMs;
392
+ const isCI = process.env.CI === "true" || process.env.CI === "1" || !process.stdout.isTTY;
393
+ let lastLogTime = Date.now();
394
+ let lastSeenEventCount = 0;
395
+ let totalSteps;
396
+ let currentStepLabel = "";
397
+ let cachedDeviceLabel = null;
398
+ const caseStateBefore = /* @__PURE__ */ new Map();
399
+ const caseStartedPrinted = /* @__PURE__ */ new Set();
400
+ while (Date.now() < deadline) {
401
+ const result = await client.getRunStatus(runId);
402
+ const progress = result.progress || [];
403
+ if (progress.length > lastSeenEventCount) {
404
+ const newEvents = progress.slice(lastSeenEventCount);
405
+ for (const evt of newEvents) {
406
+ const ctx = { spinner, totalSteps, currentStepLabel };
407
+ displayProgressEvent(evt, ctx);
408
+ totalSteps = ctx.totalSteps;
409
+ currentStepLabel = ctx.currentStepLabel;
410
+ }
411
+ lastSeenEventCount = progress.length;
412
+ }
413
+ if (result.type === "suite_run" && Array.isArray(result.test_runs)) {
414
+ for (const tr of result.test_runs) {
415
+ if (!tr.public_id) continue;
416
+ const prev = caseStateBefore.get(tr.public_id);
417
+ const cur = tr.status ?? "pending";
418
+ if (cur === "running" && !caseStartedPrinted.has(tr.public_id)) {
419
+ caseStartedPrinted.add(tr.public_id);
420
+ printBelowSpinner(
421
+ spinner,
422
+ `${chalk2.cyan("\u25B6")} ${chalk2.bold(tr.case_name || "Case")} started`
423
+ );
424
+ }
425
+ const isTerminal = ["passed", "failed", "error", "cancelled"].includes(cur);
426
+ const wasTerminal = prev && ["passed", "failed", "error", "cancelled"].includes(prev);
427
+ if (isTerminal && !wasTerminal) {
428
+ const icon = cur === "passed" ? chalk2.green("\u2713") : cur === "cancelled" ? chalk2.yellow("\u26A0") : chalk2.red("\u2717");
429
+ const dur = tr.duration_ms ? ` (${(tr.duration_ms / 1e3).toFixed(1)}s)` : "";
430
+ printBelowSpinner(spinner, `${icon} ${chalk2.bold(tr.case_name || "Case")} ${cur}${dur}`);
431
+ if (tr.error_message && cur !== "passed") {
432
+ printBelowSpinner(spinner, ` ${chalk2.red(tr.error_message)}`);
433
+ }
434
+ }
435
+ caseStateBefore.set(tr.public_id, cur);
436
+ }
437
+ }
438
+ if (["passed", "failed", "error", "cancelled"].includes(result.status)) {
439
+ return result;
440
+ }
441
+ if (result.status === "queued" && result.queue) {
442
+ const q = result.queue;
443
+ if (onQueueObserved) {
444
+ onQueueObserved(q.entry_id);
445
+ }
446
+ let label;
447
+ if (q.specific_device_id) {
448
+ if (cachedDeviceLabel === null) {
449
+ try {
450
+ const dev2 = await client.getDevice(q.specific_device_id);
451
+ cachedDeviceLabel = `${dev2.name} (${dev2.device_id})`;
452
+ } catch {
453
+ cachedDeviceLabel = q.specific_device_id;
454
+ }
455
+ }
456
+ label = cachedDeviceLabel;
457
+ } else {
458
+ label = "the device pool";
459
+ }
460
+ spinner.text = `Queued \u2014 position ${q.position} in line for ${label}`;
461
+ }
462
+ if (progress.length === 0) {
463
+ if (result.status === "pending") {
464
+ spinner.text = "Waiting for device...";
465
+ } else if (result.status === "running") {
466
+ if (result.type === "suite_run" && Array.isArray(result.test_runs)) {
467
+ const total = result.total_cases ?? result.test_runs.length;
468
+ const done = result.test_runs.filter(
469
+ (tr) => ["passed", "failed", "error", "cancelled"].includes(tr.status ?? "")
470
+ ).length;
471
+ spinner.text = `Running suite \u2014 ${done}/${total} cases complete...`;
472
+ } else {
473
+ spinner.text = "Running tests...";
474
+ }
475
+ } else {
476
+ spinner.text = `Status: ${result.status}...`;
477
+ }
478
+ }
479
+ if (isCI && Date.now() - lastLogTime >= 3e4) {
480
+ console.log(`Status: ${result.status}...`);
481
+ lastLogTime = Date.now();
482
+ }
483
+ await new Promise((r) => setTimeout(r, 3e3));
484
+ }
485
+ throw new Error(`Timeout: run did not complete within ${Math.round(timeoutMs / 1e3)}s`);
486
+ }
487
+ function formatAction(action) {
488
+ const tool = action.tool;
489
+ const args = action.args || {};
490
+ switch (tool) {
491
+ case "tap":
492
+ return `tap ${args.element_ref || ""}`;
493
+ case "tap_coordinates":
494
+ return `tap (${args.x}, ${args.y})`;
495
+ case "type_text":
496
+ return `type "${args.text || ""}"`;
497
+ case "swipe":
498
+ return `swipe ${args.direction || ""}`;
499
+ case "press_button":
500
+ return `press ${args.button || ""}`;
501
+ case "wait":
502
+ return `wait ${args.seconds || args.ms || ""}s`;
503
+ case "step_complete":
504
+ return `step complete: ${args.summary || ""}`;
505
+ case "step_failed":
506
+ return `step failed: ${args.reason || ""}`;
507
+ default:
508
+ return `${tool}(${Object.entries(args).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(", ")})`;
509
+ }
510
+ }
511
+ function formatToolCall(tc) {
512
+ return formatAction({ tool: tc.name, args: tc.args });
513
+ }
514
+ function displayProgressEvent(evt, ctx) {
515
+ const { spinner } = ctx;
516
+ switch (evt.event) {
517
+ case "run_started":
518
+ printBelowSpinner(
519
+ spinner,
520
+ `${chalk2.cyan("\u25B6")} AI test started \u2014 ${evt.total_steps} step(s), model: ${chalk2.bold(evt.model || "default")}`
521
+ );
522
+ ctx.totalSteps = evt.total_steps ?? ctx.totalSteps;
523
+ break;
524
+ case "step_started": {
525
+ const stepNum = (evt.step_index ?? 0) + 1;
526
+ const total = ctx.totalSteps ?? evt.total_steps ?? "?";
527
+ ctx.currentStepLabel = `Step ${stepNum}/${total}: ${evt.step_text || "..."}`;
528
+ spinner.text = ` ${ctx.currentStepLabel}`;
529
+ break;
530
+ }
531
+ case "step_completed": {
532
+ const stepNum = (evt.step_index ?? 0) + 1;
533
+ const total = ctx.totalSteps ?? evt.total_steps ?? "?";
534
+ const icon = evt.status === "passed" ? chalk2.green("\u2713") : evt.status === "failed" ? chalk2.red("\u2717") : chalk2.yellow("!");
535
+ spinner.stop();
536
+ process.stderr.write(` ${icon} Step ${stepNum}/${total}: ${evt.step_text || ""}
537
+ `);
538
+ if (evt.actions && evt.actions.length > 0) {
539
+ for (const action of evt.actions) {
540
+ process.stderr.write(` ${chalk2.dim(formatAction(action))}
541
+ `);
542
+ }
543
+ }
544
+ if (evt.error) {
545
+ process.stderr.write(` ${chalk2.red(evt.error)}
546
+ `);
547
+ }
548
+ spinner.start();
549
+ break;
550
+ }
551
+ case "conversation_turn": {
552
+ const turn = evt.turn;
553
+ if (!turn) break;
554
+ if (turn.role === "assistant" && turn.tool_calls && turn.tool_calls.length > 0) {
555
+ const lastAction = turn.tool_calls[turn.tool_calls.length - 1];
556
+ spinner.text = ` ${ctx.currentStepLabel} ${chalk2.dim("\u2192 " + formatToolCall(lastAction))}`;
557
+ }
558
+ break;
559
+ }
560
+ case "run_completed": {
561
+ const icon = evt.status === "passed" ? chalk2.green("\u2713") : evt.status === "cancelled" ? chalk2.yellow("\u26A0") : chalk2.red("\u2717");
562
+ spinner.stop();
563
+ process.stderr.write(
564
+ `${icon} Run ${evt.status}${evt.duration_ms ? ` (${(evt.duration_ms / 1e3).toFixed(1)}s)` : ""}
565
+ `
566
+ );
567
+ spinner.start();
568
+ break;
569
+ }
570
+ case "app_installing":
571
+ spinner.text = " Installing app...";
572
+ break;
573
+ case "app_installed":
574
+ spinner.text = " App installed. Starting test...";
575
+ break;
576
+ default:
577
+ break;
578
+ }
579
+ }
580
+ function outputResult(result, options) {
581
+ switch (options.output) {
582
+ case "json":
583
+ console.log(formatJson(result));
584
+ break;
585
+ case "junit":
586
+ console.log(formatJunit(result));
587
+ break;
588
+ default:
589
+ console.log(formatHuman(result, options.verbose ?? false));
590
+ }
591
+ }
592
+ function mapExitCode(status) {
593
+ if (status === "passed") return EXIT_PASS;
594
+ if (status === "failed") return EXIT_TEST_FAIL;
595
+ return EXIT_INFRA_ERROR;
596
+ }
597
+
598
+ // src/commands/run-suite.ts
599
+ async function runSuite(suiteId, options) {
600
+ const { token, baseUrl } = resolveAuth(options);
601
+ const client = new ApiClient(baseUrl, token);
602
+ const spinner = createSpinner("Starting suite run...");
603
+ let sessionId;
604
+ let lastQueueEntryId;
605
+ let cleaning = false;
606
+ const cleanup = async (signal) => {
607
+ if (cleaning) process.exit(130);
608
+ cleaning = true;
609
+ try {
610
+ spinner.stop();
611
+ } catch {
612
+ }
613
+ if (process.stdin.isTTY) {
614
+ try {
615
+ process.stdin.setRawMode(false);
616
+ process.stdin.pause();
617
+ } catch {
618
+ }
619
+ }
620
+ process.stderr.write(`
621
+ ${signal} received.
622
+ `);
623
+ if (lastQueueEntryId !== void 0) {
624
+ process.stderr.write("Cancelling queued run...\n");
625
+ try {
626
+ await client.cancelQueueEntry(lastQueueEntryId);
627
+ process.stderr.write("Queue entry cancelled.\n");
628
+ } catch {
629
+ }
630
+ }
631
+ if (sessionId) {
632
+ process.stderr.write("Stopping session...\n");
633
+ try {
634
+ await client.stopSession(sessionId);
635
+ process.stderr.write("Session stopped.\n");
636
+ } catch {
637
+ }
638
+ }
639
+ process.exit(130);
640
+ };
641
+ process.on("SIGINT", () => void cleanup("SIGINT"));
642
+ process.on("SIGTERM", () => void cleanup("SIGTERM"));
643
+ if (process.stdin.isTTY) {
644
+ process.stdin.setRawMode(true);
645
+ process.stdin.resume();
646
+ process.stdin.on("data", (key) => {
647
+ if (key[0] === 3 || key[0] === 4) {
648
+ void cleanup("Ctrl+C");
649
+ }
650
+ });
651
+ }
652
+ spinner.start();
653
+ try {
654
+ const response = await client.runSuite(suiteId, {
655
+ commitSha: options.commitSha,
656
+ model: options.model,
657
+ provider: options.provider,
658
+ mode: options.mode,
659
+ deviceCategory: options.deviceCategory,
660
+ device: options.device,
661
+ build: options.build,
662
+ metadata: options.metadata
663
+ });
664
+ sessionId = response.session_id;
665
+ spinner.stop();
666
+ process.stderr.write(
667
+ `${chalk3.dim("Suite:")} ${suiteId}
668
+ ${chalk3.dim("Run:")} ${response.run_id}
669
+ ` + (sessionId ? `${chalk3.dim("Session:")} ${sessionId}
670
+ ` : "")
671
+ );
672
+ spinner.start("Waiting for device...");
673
+ const timeoutMs = (options.timeout ?? 300) * 1e3;
674
+ const result = await pollUntilComplete(
675
+ client,
676
+ response.run_id,
677
+ timeoutMs,
678
+ spinner,
679
+ (entryId) => {
680
+ lastQueueEntryId = entryId;
681
+ }
682
+ );
683
+ spinner.stop();
684
+ if (process.stdin.isTTY) {
685
+ process.stdin.setRawMode(false);
686
+ process.stdin.pause();
687
+ }
688
+ outputResult(result, options);
689
+ process.exit(mapExitCode(result.status));
690
+ } catch (err) {
691
+ spinner.fail(err instanceof Error ? err.message : "Unknown error");
692
+ if (lastQueueEntryId !== void 0) {
693
+ try {
694
+ await client.cancelQueueEntry(lastQueueEntryId);
695
+ } catch {
696
+ }
697
+ }
698
+ if (sessionId) {
699
+ try {
700
+ await client.stopSession(sessionId);
701
+ } catch {
702
+ }
703
+ }
704
+ process.exit(EXIT_INFRA_ERROR);
705
+ }
706
+ }
707
+
708
+ // src/commands/run-suites.ts
709
+ import chalk4 from "chalk";
710
+ var AGGREGATE_TIMEOUT_CAP_S = 7200;
711
+ async function runSuites(opts) {
712
+ if (!opts.tag || opts.tag.length === 0) {
713
+ process.stderr.write("error: at least one --tag is required\n");
714
+ process.exit(EXIT_INFRA_ERROR);
715
+ }
716
+ const { token, baseUrl } = resolveAuth(opts);
717
+ const client = new ApiClient(baseUrl, token);
718
+ const spinner = createSpinner("Submitting multi-suite run...");
719
+ let aggregateId;
720
+ let cleaning = false;
721
+ const cleanup = async (signal) => {
722
+ if (cleaning) process.exit(130);
723
+ cleaning = true;
724
+ try {
725
+ spinner.stop();
726
+ } catch {
727
+ }
728
+ if (process.stdin.isTTY) {
729
+ try {
730
+ process.stdin.setRawMode(false);
731
+ process.stdin.pause();
732
+ } catch {
733
+ }
734
+ }
735
+ process.stderr.write(`
736
+ ${signal} received.
737
+ `);
738
+ if (aggregateId) {
739
+ process.stderr.write("Cancelling multi-suite run...\n");
740
+ try {
741
+ await client.cancelMultiSuiteRun(aggregateId);
742
+ process.stderr.write("Cancellation requested.\n");
743
+ } catch {
744
+ }
745
+ }
746
+ process.exit(130);
747
+ };
748
+ process.on("SIGINT", () => void cleanup("SIGINT"));
749
+ process.on("SIGTERM", () => void cleanup("SIGTERM"));
750
+ if (process.stdin.isTTY) {
751
+ process.stdin.setRawMode(true);
752
+ process.stdin.resume();
753
+ process.stdin.on("data", (key) => {
754
+ if (key[0] === 3 || key[0] === 4) {
755
+ void cleanup("Ctrl+C");
756
+ }
757
+ });
758
+ }
759
+ spinner.start();
760
+ let start;
761
+ try {
762
+ start = await client.runSuites({
763
+ tags: opts.tag,
764
+ match: opts.match ?? "any",
765
+ platform: opts.platform,
766
+ commit_sha: opts.commitSha,
767
+ model: opts.model,
768
+ provider: opts.provider,
769
+ mode: opts.mode,
770
+ device_category: opts.device ? void 0 : opts.deviceCategory ?? "phone",
771
+ device_id: opts.device,
772
+ build_id: opts.build,
773
+ metadata: opts.metadata
774
+ });
775
+ } catch (err) {
776
+ spinner.stop();
777
+ const msg = err instanceof Error ? err.message : String(err);
778
+ if (msg.startsWith("Not found:") || msg.includes("404") || /No suites match tags/.test(msg)) {
779
+ const suffix = opts.platform ? ` on platform ${opts.platform}` : "";
780
+ process.stderr.write(chalk4.yellow(`No suites match tags: ${opts.tag.join(", ")}${suffix}
781
+ `));
782
+ process.exit(EXIT_INFRA_ERROR);
783
+ }
784
+ process.stderr.write(chalk4.red(`Failed to start: ${msg}
785
+ `));
786
+ process.exit(EXIT_INFRA_ERROR);
787
+ }
788
+ aggregateId = start.aggregate_id;
789
+ spinner.stop();
790
+ const matchedNames = start.matched_suites?.map((s) => s.name) ?? [];
791
+ const totalMatched = start.matched_suite_ids.length;
792
+ process.stderr.write(
793
+ `${chalk4.dim("Aggregate:")} ${start.aggregate_id}
794
+ ${chalk4.dim("Tags:")} ${opts.tag.join(", ")} ${chalk4.dim("(" + (opts.match ?? "any") + ")")}
795
+ ${chalk4.dim("Matched:")} ${totalMatched} suite(s)${matchedNames.length ? " \u2014 " + matchedNames.join(", ") : ""}
796
+ `
797
+ );
798
+ const excluded = start.excluded_suites ?? [];
799
+ if (excluded.length) {
800
+ const names = excluded.map((s) => s.name).join(", ");
801
+ process.stderr.write(
802
+ chalk4.dim(`Excluded ${excluded.length} suite(s) (platform mismatch): ${names}
803
+ `)
804
+ );
805
+ }
806
+ spinner.start(`Running ${totalMatched} suite(s)...`);
807
+ const perSuiteTimeout = Math.max(opts.timeout ?? 300, 30);
808
+ const totalTimeout = Math.min(
809
+ perSuiteTimeout * start.matched_suite_ids.length,
810
+ AGGREGATE_TIMEOUT_CAP_S
811
+ );
812
+ const deadline = Date.now() + totalTimeout * 1e3;
813
+ const suiteStateBefore = /* @__PURE__ */ new Map();
814
+ const suiteStartedPrinted = /* @__PURE__ */ new Set();
815
+ const print = (line) => {
816
+ spinner.stop();
817
+ process.stderr.write(line + "\n");
818
+ spinner.start();
819
+ };
820
+ const TERMINAL = /* @__PURE__ */ new Set(["passed", "failed", "error", "cancelled"]);
821
+ let last = null;
822
+ while (Date.now() < deadline) {
823
+ last = await client.getMultiSuiteRun(start.aggregate_id);
824
+ let doneCount = 0;
825
+ let queuedChild;
826
+ for (const sr of last.suite_runs) {
827
+ const cur = sr.status;
828
+ const prev = suiteStateBefore.get(sr.run_id);
829
+ if (cur === "queued" && prev !== "queued") {
830
+ queuedChild = sr;
831
+ }
832
+ if (cur === "running" && !suiteStartedPrinted.has(sr.run_id)) {
833
+ suiteStartedPrinted.add(sr.run_id);
834
+ print(`${chalk4.cyan("\u25B6")} ${chalk4.bold(sr.suite_name)} started`);
835
+ }
836
+ const wasTerminal = prev !== void 0 && TERMINAL.has(prev);
837
+ if (TERMINAL.has(cur) && !wasTerminal) {
838
+ const mark = cur === "passed" ? chalk4.green("\u2713") : cur === "cancelled" ? chalk4.yellow("\u26A0") : chalk4.red("\u2717");
839
+ const link = sr.url ? chalk4.dim(" " + sr.url) : "";
840
+ print(
841
+ `${mark} ${chalk4.bold(sr.suite_name)} ${cur}: ${sr.passed} passed, ${sr.failed} failed${link}`
842
+ );
843
+ if (sr.error_message) {
844
+ print(` ${chalk4.red(sr.error_message)}`);
845
+ }
846
+ }
847
+ suiteStateBefore.set(sr.run_id, cur);
848
+ if (TERMINAL.has(cur)) doneCount++;
849
+ if (cur === "queued") queuedChild = sr;
850
+ }
851
+ if (queuedChild && queuedChild.queue) {
852
+ const q = queuedChild.queue;
853
+ const target = q.specific_device_id ?? "the device pool";
854
+ spinner.text = `Queued \u2014 ${chalk4.bold(queuedChild.suite_name)} position ${q.position} for ${target} (${doneCount}/${totalMatched} suite(s) complete)`;
855
+ } else {
856
+ spinner.text = `Running suites \u2014 ${doneCount}/${totalMatched} complete...`;
857
+ }
858
+ if (last.status !== "pending" && last.status !== "running") break;
859
+ await new Promise((r) => setTimeout(r, 2e3));
860
+ }
861
+ spinner.stop();
862
+ if (process.stdin.isTTY) {
863
+ process.stdin.setRawMode(false);
864
+ process.stdin.pause();
865
+ }
866
+ if (!last) {
867
+ process.stderr.write(chalk4.red("Timed out before any status was returned.\n"));
868
+ process.exit(EXIT_INFRA_ERROR);
869
+ }
870
+ if (opts.output === "json") {
871
+ process.stdout.write(JSON.stringify(last, null, 2) + "\n");
872
+ } else if (opts.output === "junit") {
873
+ process.stdout.write(formatMultiSuiteJunit(last) + "\n");
874
+ } else {
875
+ process.stdout.write(`
876
+ Aggregate: ${last.status}
877
+ `);
878
+ for (const sr of last.suite_runs) {
879
+ const mark = sr.status === "passed" ? chalk4.green("\u2713") : chalk4.red("\u2717");
880
+ const link = sr.url ? chalk4.dim(` ${sr.url}`) : "";
881
+ process.stdout.write(
882
+ ` ${mark} ${sr.suite_name}: ${sr.passed} passed, ${sr.failed} failed${link}
883
+ `
884
+ );
885
+ if (sr.error_message) {
886
+ process.stdout.write(` ${chalk4.red(sr.error_message)}
887
+ `);
888
+ }
889
+ for (const cr of sr.case_runs ?? []) {
890
+ const caseMark = cr.status === "passed" ? chalk4.green("\u2713") : cr.status === "cancelled" || cr.status === "pending" || cr.status === "running" ? chalk4.yellow("-") : chalk4.red("\u2717");
891
+ const dur = cr.duration_ms ? chalk4.dim(` (${(cr.duration_ms / 1e3).toFixed(1)}s)`) : "";
892
+ process.stdout.write(
893
+ ` ${caseMark} ${cr.case_name ?? "Case"} ${cr.status ?? "?"}${dur} ${chalk4.dim(cr.url)}
894
+ `
895
+ );
896
+ if (cr.error_message && cr.status !== "passed") {
897
+ process.stdout.write(` ${chalk4.red(cr.error_message)}
898
+ `);
899
+ }
900
+ }
901
+ }
902
+ }
903
+ if (last.status === "passed") process.exit(EXIT_PASS);
904
+ if (last.status === "failed") process.exit(EXIT_TEST_FAIL);
905
+ process.exit(EXIT_INFRA_ERROR);
906
+ }
907
+
908
+ // src/commands/run-test.ts
909
+ import chalk5 from "chalk";
910
+ async function runTest(testId, options) {
911
+ const { token, baseUrl } = resolveAuth(options);
912
+ const client = new ApiClient(baseUrl, token);
913
+ let sessionId;
914
+ let lastQueueEntryId;
915
+ const spinner = createSpinner("Starting test run...");
916
+ let cleaning = false;
917
+ const cleanup = async (signal) => {
918
+ if (cleaning) process.exit(130);
919
+ cleaning = true;
920
+ try {
921
+ spinner.stop();
922
+ } catch {
923
+ }
924
+ if (process.stdin.isTTY) {
925
+ try {
926
+ process.stdin.setRawMode(false);
927
+ process.stdin.pause();
928
+ } catch {
929
+ }
930
+ }
931
+ process.stderr.write(`
932
+ ${signal} received.
933
+ `);
934
+ if (lastQueueEntryId !== void 0) {
935
+ process.stderr.write("Cancelling queued run...\n");
936
+ try {
937
+ await client.cancelQueueEntry(lastQueueEntryId);
938
+ process.stderr.write("Queue entry cancelled.\n");
939
+ } catch {
940
+ }
941
+ }
942
+ if (sessionId) {
943
+ process.stderr.write("Stopping session...\n");
944
+ try {
945
+ await client.stopSession(sessionId);
946
+ process.stderr.write("Session stopped.\n");
947
+ } catch {
948
+ }
949
+ }
950
+ process.exit(130);
951
+ };
952
+ process.on("SIGINT", () => void cleanup("SIGINT"));
953
+ process.on("SIGTERM", () => void cleanup("SIGTERM"));
954
+ if (process.stdin.isTTY) {
955
+ process.stdin.setRawMode(true);
956
+ process.stdin.resume();
957
+ process.stdin.on("data", (key) => {
958
+ if (key[0] === 3 || key[0] === 4) {
959
+ void cleanup("Ctrl+C");
960
+ }
961
+ });
962
+ }
963
+ spinner.start();
964
+ try {
965
+ const response = await client.runTest(testId, {
966
+ commitSha: options.commitSha,
967
+ model: options.model,
968
+ provider: options.provider,
969
+ mode: options.mode,
970
+ deviceCategory: options.deviceCategory,
971
+ device: options.device,
972
+ build: options.build,
973
+ metadata: options.metadata
974
+ });
975
+ sessionId = response.session_id;
976
+ spinner.stop();
977
+ process.stderr.write(
978
+ `${chalk5.dim("Session:")} ${sessionId || "unknown"}
979
+ ${chalk5.dim("Device:")} ${response.device_name || "allocating..."}
980
+ ${chalk5.dim("Test:")} ${testId}
981
+ ${chalk5.dim("Run:")} ${response.run_id}
982
+ `
983
+ );
984
+ spinner.start("Waiting for device...");
985
+ const timeoutMs = (options.timeout ?? 300) * 1e3;
986
+ const result = await pollUntilComplete(
987
+ client,
988
+ response.run_id,
989
+ timeoutMs,
990
+ spinner,
991
+ (entryId) => {
992
+ lastQueueEntryId = entryId;
993
+ }
994
+ );
995
+ spinner.stop();
996
+ if (process.stdin.isTTY) {
997
+ process.stdin.setRawMode(false);
998
+ process.stdin.pause();
999
+ }
1000
+ outputResult(result, options);
1001
+ process.exit(mapExitCode(result.status));
1002
+ } catch (err) {
1003
+ spinner.fail(err instanceof Error ? err.message : "Unknown error");
1004
+ if (lastQueueEntryId !== void 0) {
1005
+ try {
1006
+ await client.cancelQueueEntry(lastQueueEntryId);
1007
+ } catch {
1008
+ }
1009
+ }
1010
+ if (sessionId) {
1011
+ try {
1012
+ await client.stopSession(sessionId);
1013
+ } catch {
1014
+ }
1015
+ }
1016
+ process.exit(EXIT_INFRA_ERROR);
1017
+ }
1018
+ }
1019
+
1020
+ // src/commands/list-suites.ts
1021
+ import chalk6 from "chalk";
1022
+ async function listSuites(options) {
1023
+ const { token, baseUrl } = resolveAuth(options);
1024
+ const client = new ApiClient(baseUrl, token);
1025
+ const suites = await client.listSuites({
1026
+ tags: options.tag && options.tag.length > 0 ? options.tag : void 0,
1027
+ match: options.match
1028
+ });
1029
+ if (options.output === "json") {
1030
+ console.log(JSON.stringify(suites, null, 2));
1031
+ return;
1032
+ }
1033
+ if (suites.length === 0) {
1034
+ console.log("No test suites found.");
1035
+ return;
1036
+ }
1037
+ console.log(chalk6.bold(`
1038
+ ${suites.length} suite(s)
1039
+ `));
1040
+ for (const s of suites) {
1041
+ const platform = s.platform ? chalk6.dim(` [${s.platform}]`) : "";
1042
+ const tagsText = s.tags && s.tags.length > 0 ? chalk6.dim(` [${s.tags.map((t) => t.display_name).join(", ")}]`) : "";
1043
+ console.log(` ${chalk6.cyan(s.id)} ${s.name}${platform}${tagsText} (${s.case_count} cases)`);
1044
+ }
1045
+ console.log();
1046
+ }
1047
+
1048
+ // src/commands/list-apps.ts
1049
+ import chalk7 from "chalk";
1050
+ async function listApps(options) {
1051
+ const { token, baseUrl } = resolveAuth(options);
1052
+ const client = new ApiClient(baseUrl, token);
1053
+ const apps = await client.listApps();
1054
+ if (options.output === "json") {
1055
+ console.log(JSON.stringify(apps, null, 2));
1056
+ return;
1057
+ }
1058
+ if (apps.length === 0) {
1059
+ console.log("No apps found.");
1060
+ return;
1061
+ }
1062
+ console.log(chalk7.bold(`
1063
+ ${apps.length} app(s)
1064
+ `));
1065
+ for (const app of apps) {
1066
+ const platform = chalk7.dim(` [${app.platform}]`);
1067
+ const pkg2 = chalk7.dim(app.package_name);
1068
+ console.log(` ${chalk7.cyan(app.public_id)} ${app.name}${platform} ${pkg2}`);
1069
+ console.log(chalk7.dim(` ${baseUrl}/apps/${app.public_id}`));
1070
+ }
1071
+ console.log();
1072
+ }
1073
+
1074
+ // src/commands/upload-app.ts
1075
+ import chalk8 from "chalk";
1076
+ import { existsSync } from "fs";
1077
+ async function uploadApp(filePath, options) {
1078
+ if (!existsSync(filePath)) {
1079
+ console.error(chalk8.red(`Error: File not found: ${filePath}`));
1080
+ process.exit(EXIT_INFRA_ERROR);
1081
+ }
1082
+ const { token, baseUrl } = resolveAuth(options);
1083
+ const client = new ApiClient(baseUrl, token);
1084
+ const spinner = createSpinner("Uploading app...");
1085
+ spinner.start();
1086
+ try {
1087
+ const result = await client.uploadApp(filePath, options.name);
1088
+ const isNew = result.is_new !== false;
1089
+ spinner.succeed(
1090
+ isNew ? `New app created: ${result.name}` : `Build added to existing app: ${result.name}`
1091
+ );
1092
+ if (options.output === "json") {
1093
+ console.log(
1094
+ JSON.stringify(
1095
+ {
1096
+ app_id: result.public_id,
1097
+ name: result.name,
1098
+ package_name: result.package_name,
1099
+ platform: result.platform,
1100
+ is_new: isNew,
1101
+ url: `${baseUrl}/apps/${result.public_id}`,
1102
+ warnings: result.warnings ?? []
1103
+ },
1104
+ null,
1105
+ 2
1106
+ )
1107
+ );
1108
+ } else {
1109
+ console.log(
1110
+ `
1111
+ ${chalk8.cyan(result.public_id)} ${result.name} ${chalk8.dim(`[${result.platform}]`)}`
1112
+ );
1113
+ console.log(` ${chalk8.dim(result.package_name)}`);
1114
+ console.log(chalk8.dim(` ${baseUrl}/apps/${result.public_id}
1115
+ `));
1116
+ }
1117
+ for (const w of result.warnings ?? []) {
1118
+ console.error(chalk8.yellow(`
1119
+ \u26A0 ${w}`));
1120
+ }
1121
+ } catch (err) {
1122
+ spinner.fail(err instanceof Error ? err.message : "Upload failed");
1123
+ process.exit(EXIT_INFRA_ERROR);
1124
+ }
1125
+ }
1126
+
1127
+ // src/commands/create-test.ts
1128
+ import chalk9 from "chalk";
1129
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
1130
+ async function readStdin() {
1131
+ return new Promise((resolve, reject) => {
1132
+ let data = "";
1133
+ const timeout = setTimeout(() => {
1134
+ process.stdin.removeAllListeners();
1135
+ resolve(data);
1136
+ }, 5e3);
1137
+ process.stdin.setEncoding("utf-8");
1138
+ process.stdin.on("data", (chunk) => {
1139
+ data += chunk;
1140
+ });
1141
+ process.stdin.on("end", () => {
1142
+ clearTimeout(timeout);
1143
+ resolve(data);
1144
+ });
1145
+ process.stdin.on("error", (err) => {
1146
+ clearTimeout(timeout);
1147
+ reject(err);
1148
+ });
1149
+ });
1150
+ }
1151
+ async function resolveSteps(options) {
1152
+ if (options.steps) {
1153
+ return JSON.parse(options.steps);
1154
+ }
1155
+ if (options.stepsFile) {
1156
+ if (!existsSync2(options.stepsFile)) {
1157
+ throw new Error(`Steps file not found: ${options.stepsFile}`);
1158
+ }
1159
+ return JSON.parse(readFileSync3(options.stepsFile, "utf-8"));
1160
+ }
1161
+ if (!process.stdin.isTTY) {
1162
+ const input = await readStdin();
1163
+ if (input.trim()) {
1164
+ return JSON.parse(input.trim());
1165
+ }
1166
+ }
1167
+ throw new Error("No steps provided. Use --steps, --steps-file, or pipe JSON to stdin.");
1168
+ }
1169
+ async function resolveAppId(client, appRef, platform) {
1170
+ if (appRef.includes(".")) {
1171
+ if (!platform) {
1172
+ throw new Error(
1173
+ "When using a package name for --app, you must also specify --platform (ios, android)."
1174
+ );
1175
+ }
1176
+ const apps = await client.listApps();
1177
+ const match = apps.find((a) => a.package_name === appRef && a.platform === platform);
1178
+ if (!match) {
1179
+ throw new Error(`No app found with package name "${appRef}" and platform "${platform}".`);
1180
+ }
1181
+ return match.public_id;
1182
+ }
1183
+ return appRef;
1184
+ }
1185
+ async function createTest(options) {
1186
+ const { token, baseUrl } = resolveAuth(options);
1187
+ const client = new ApiClient(baseUrl, token);
1188
+ try {
1189
+ const steps = await resolveSteps(options);
1190
+ let appPublicId;
1191
+ if (options.app) {
1192
+ appPublicId = await resolveAppId(client, options.app, options.platform);
1193
+ }
1194
+ const spinner = createSpinner("Creating test...");
1195
+ spinner.start();
1196
+ const result = await client.createTest({
1197
+ name: options.testName || "CLI Test",
1198
+ app_id: appPublicId || "",
1199
+ steps,
1200
+ context: options.context,
1201
+ mode: "ai"
1202
+ });
1203
+ spinner.succeed(`Test created: ${result.name}`);
1204
+ if (options.output === "json") {
1205
+ console.log(
1206
+ JSON.stringify(
1207
+ {
1208
+ test_id: result.public_id,
1209
+ name: result.name,
1210
+ app_id: appPublicId || null,
1211
+ url: `${baseUrl}/tests?tab=standalone`
1212
+ },
1213
+ null,
1214
+ 2
1215
+ )
1216
+ );
1217
+ } else {
1218
+ console.log(`
1219
+ ${chalk9.cyan(result.public_id)} ${result.name}`);
1220
+ if (result.app_name) {
1221
+ console.log(` ${chalk9.dim(`App: ${result.app_name}`)}`);
1222
+ }
1223
+ if (result.platform) {
1224
+ console.log(` ${chalk9.dim(`Platform: ${result.platform}`)}`);
1225
+ }
1226
+ console.log(` ${chalk9.dim(`Steps: ${steps.length}`)}`);
1227
+ console.log(chalk9.dim(` ${baseUrl}/tests?tab=standalone
1228
+ `));
1229
+ }
1230
+ } catch (err) {
1231
+ console.error(chalk9.red(err instanceof Error ? err.message : "Failed to create test"));
1232
+ process.exit(EXIT_INFRA_ERROR);
1233
+ }
1234
+ }
1235
+
1236
+ // src/commands/login.ts
1237
+ import { randomUUID } from "crypto";
1238
+ import open from "open";
1239
+ async function login(options) {
1240
+ const baseUrl = options.baseUrl || process.env.VIBEVIEW_BASE_URL || loadConfig().baseUrl || "https://vibeview.io";
1241
+ const code = randomUUID();
1242
+ try {
1243
+ await fetch(`${baseUrl}/api/v1/auth/cli-token/register`, {
1244
+ method: "POST",
1245
+ headers: { "Content-Type": "application/json" },
1246
+ body: JSON.stringify({ code })
1247
+ });
1248
+ } catch {
1249
+ }
1250
+ const loginUrl = `${baseUrl}/login?cli_code=${code}`;
1251
+ console.log(`
1252
+ Opening browser to log in...
1253
+ ${loginUrl}
1254
+ `);
1255
+ await open(loginUrl);
1256
+ const spinner = createSpinner("Waiting for browser login...");
1257
+ spinner.start();
1258
+ const deadline = Date.now() + 12e4;
1259
+ while (Date.now() < deadline) {
1260
+ try {
1261
+ const resp = await fetch(`${baseUrl}/api/v1/auth/cli-token?code=${code}`);
1262
+ if (resp.status === 410) {
1263
+ spinner.fail("Login code expired. Please try again.");
1264
+ process.exit(EXIT_INFRA_ERROR);
1265
+ }
1266
+ const data = await resp.json();
1267
+ if (data.status === "complete" && data.token) {
1268
+ spinner.succeed("Logged in successfully.");
1269
+ saveConfig({ token: data.token, baseUrl });
1270
+ console.log(" Token saved to ~/.vibeview/config.json");
1271
+ return;
1272
+ }
1273
+ } catch {
1274
+ }
1275
+ await new Promise((r) => setTimeout(r, 2e3));
1276
+ }
1277
+ spinner.fail("Login timed out. Please try again.");
1278
+ process.exit(EXIT_INFRA_ERROR);
1279
+ }
1280
+
1281
+ // src/commands/dev.ts
1282
+ import { select } from "@inquirer/prompts";
1283
+ import chalk10 from "chalk";
1284
+ import openBrowser from "open";
1285
+
1286
+ // src/lib/tunnel-client.ts
1287
+ import net from "net";
1288
+ import WebSocket from "ws";
1289
+
1290
+ // src/lib/mux.ts
1291
+ var FRAME_OPEN = 1;
1292
+ var FRAME_DATA = 2;
1293
+ var FRAME_CLOSE = 3;
1294
+ var HEADER_LEN = 5;
1295
+ var KNOWN = /* @__PURE__ */ new Set([FRAME_OPEN, FRAME_DATA, FRAME_CLOSE]);
1296
+ function encodeFrame(type, streamId, payload = Buffer.alloc(0)) {
1297
+ const header = Buffer.alloc(HEADER_LEN);
1298
+ header.writeUInt8(type, 0);
1299
+ header.writeUInt32BE(streamId, 1);
1300
+ return Buffer.concat([header, payload]);
1301
+ }
1302
+ function decodeFrame(data) {
1303
+ if (data.length < HEADER_LEN) throw new Error(`tunnel frame too short: ${data.length}`);
1304
+ const type = data.readUInt8(0);
1305
+ if (!KNOWN.has(type)) throw new Error(`unknown tunnel frame type: ${type}`);
1306
+ return { type, streamId: data.readUInt32BE(1), payload: data.subarray(HEADER_LEN) };
1307
+ }
1308
+
1309
+ // src/lib/tunnel-client.ts
1310
+ var FATAL_CLOSE_CODES = /* @__PURE__ */ new Set([4008, 4009]);
1311
+ var MAX_BACKOFF_MS = 8e3;
1312
+ var STABLE_MS = 2e3;
1313
+ var TunnelClient = class {
1314
+ constructor(opts) {
1315
+ this.opts = opts;
1316
+ this.url = opts.url;
1317
+ }
1318
+ ws = null;
1319
+ sockets = /* @__PURE__ */ new Map();
1320
+ stopped = false;
1321
+ backoffMs = 1e3;
1322
+ metroWarned = false;
1323
+ reconnectTimer = null;
1324
+ stableTimer = null;
1325
+ url;
1326
+ connCount = 0;
1327
+ debug(msg) {
1328
+ this.opts.onDebug?.(msg);
1329
+ }
1330
+ start() {
1331
+ this.connect();
1332
+ }
1333
+ stop() {
1334
+ if (this.reconnectTimer) {
1335
+ clearTimeout(this.reconnectTimer);
1336
+ this.reconnectTimer = null;
1337
+ }
1338
+ if (this.stableTimer) {
1339
+ clearTimeout(this.stableTimer);
1340
+ this.stableTimer = null;
1341
+ }
1342
+ this.stopped = true;
1343
+ this.setState("closed");
1344
+ this.ws?.close();
1345
+ for (const s of this.sockets.values()) s.destroy();
1346
+ this.sockets.clear();
1347
+ }
1348
+ setState(s) {
1349
+ this.opts.onState?.(s);
1350
+ }
1351
+ connect() {
1352
+ if (this.stopped) return;
1353
+ this.setState("connecting");
1354
+ const conn = ++this.connCount;
1355
+ const openedAt = Date.now();
1356
+ this.debug(`ws#${conn} connecting url=${this.url}`);
1357
+ const ws = new WebSocket(this.url);
1358
+ this.ws = ws;
1359
+ ws.on("open", () => {
1360
+ this.backoffMs = 1e3;
1361
+ this.debug(`ws#${conn} open`);
1362
+ this.stableTimer = setTimeout(() => {
1363
+ this.stableTimer = null;
1364
+ if (this.ws === ws && !this.stopped) this.setState("connected");
1365
+ }, STABLE_MS);
1366
+ });
1367
+ ws.on("message", (data) => this.onFrame(data));
1368
+ ws.on("error", (err) => {
1369
+ this.debug(`ws#${conn} error: ${err.message}`);
1370
+ });
1371
+ ws.on("close", (code, reason) => {
1372
+ if (this.ws !== ws) this.debug(`ws#${conn} close code=${code} \u2014 SUPERSEDED socket`);
1373
+ const heldMs = Date.now() - openedAt;
1374
+ const why = reason?.length ? ` reason="${reason.toString()}"` : "";
1375
+ this.debug(`ws#${conn} close code=${code}${why} heldMs=${heldMs}`);
1376
+ if (this.stableTimer) {
1377
+ clearTimeout(this.stableTimer);
1378
+ this.stableTimer = null;
1379
+ }
1380
+ for (const s of this.sockets.values()) s.destroy();
1381
+ this.sockets.clear();
1382
+ if (this.stopped || FATAL_CLOSE_CODES.has(code)) {
1383
+ this.stopped = true;
1384
+ this.setState("closed");
1385
+ return;
1386
+ }
1387
+ this.setState("reconnecting");
1388
+ const delay = this.backoffMs;
1389
+ this.backoffMs = Math.min(this.backoffMs * 2, MAX_BACKOFF_MS);
1390
+ this.debug(`ws#${conn} scheduling reconnect in ${delay}ms`);
1391
+ this.reconnectTimer = setTimeout(() => {
1392
+ this.reconnectTimer = null;
1393
+ void this.reconnect();
1394
+ }, delay);
1395
+ });
1396
+ }
1397
+ async reconnect() {
1398
+ if (this.stopped) return;
1399
+ if (this.opts.refreshUrl) {
1400
+ let fresh;
1401
+ try {
1402
+ fresh = await this.opts.refreshUrl();
1403
+ } catch (err) {
1404
+ this.debug(`refreshUrl threw: ${err.message}`);
1405
+ fresh = null;
1406
+ }
1407
+ if (this.stopped) return;
1408
+ if (!fresh) {
1409
+ this.debug("refreshUrl returned null \u2014 session gone, closing");
1410
+ this.stopped = true;
1411
+ this.setState("closed");
1412
+ return;
1413
+ }
1414
+ this.debug("refreshUrl ok \u2014 reconnecting with fresh url");
1415
+ this.url = fresh;
1416
+ }
1417
+ this.connect();
1418
+ }
1419
+ onFrame(data) {
1420
+ let frame;
1421
+ try {
1422
+ frame = decodeFrame(Buffer.isBuffer(data) ? data : Buffer.from(data));
1423
+ } catch {
1424
+ return;
1425
+ }
1426
+ if (frame.type === FRAME_OPEN) return this.openStream(frame.streamId);
1427
+ const sock = this.sockets.get(frame.streamId);
1428
+ if (!sock) return;
1429
+ if (frame.type === FRAME_DATA) sock.write(frame.payload);
1430
+ else if (frame.type === FRAME_CLOSE) sock.destroy();
1431
+ }
1432
+ openStream(id) {
1433
+ const sock = net.createConnection({ port: this.opts.metroPort, host: "127.0.0.1" });
1434
+ this.sockets.set(id, sock);
1435
+ sock.on("data", (chunk) => this.ws?.send(encodeFrame(FRAME_DATA, id, chunk)));
1436
+ sock.on("error", () => {
1437
+ if (!this.metroWarned) {
1438
+ this.metroWarned = true;
1439
+ this.setState("metro-unreachable");
1440
+ }
1441
+ });
1442
+ sock.on("close", () => {
1443
+ if (this.sockets.delete(id) && this.ws?.readyState === WebSocket.OPEN) {
1444
+ this.ws.send(encodeFrame(FRAME_CLOSE, id));
1445
+ }
1446
+ });
1447
+ }
1448
+ };
1449
+
1450
+ // src/lib/metro-check.ts
1451
+ async function checkMetro(port, timeoutMs = 1500) {
1452
+ try {
1453
+ const res = await fetch(`http://127.0.0.1:${port}/status`, {
1454
+ signal: AbortSignal.timeout(timeoutMs)
1455
+ });
1456
+ const body = await res.text().catch(() => "");
1457
+ return body.includes("packager-status:running") ? "running" : "listening";
1458
+ } catch {
1459
+ return "down";
1460
+ }
1461
+ }
1462
+
1463
+ // src/lib/dev-config.ts
1464
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
1465
+ import { join as join2 } from "path";
1466
+ var DEV_PLATFORMS = ["ios", "android", "tvos", "androidtv"];
1467
+ var FILE = "vibeview.json";
1468
+ function isDevPlatform(p) {
1469
+ return typeof p === "string" && DEV_PLATFORMS.includes(p);
1470
+ }
1471
+ function loadDevConfig(cwd) {
1472
+ let raw;
1473
+ try {
1474
+ raw = readFileSync4(join2(cwd, FILE), "utf-8");
1475
+ } catch {
1476
+ return null;
1477
+ }
1478
+ const parsed = JSON.parse(raw);
1479
+ if (!("platforms" in parsed) && typeof parsed.platform === "string") {
1480
+ const legacy = parsed;
1481
+ if (!isDevPlatform(legacy.platform)) {
1482
+ throw new Error(
1483
+ `vibeview.json: "platform" must be one of ${DEV_PLATFORMS.join(", ")}, got "${legacy.platform}"`
1484
+ );
1485
+ }
1486
+ if (!legacy.appId || typeof legacy.appId !== "string") {
1487
+ throw new Error('vibeview.json: "appId" is required \u2014 run `vibeview dev` to set it up');
1488
+ }
1489
+ return {
1490
+ defaultPlatform: legacy.platform,
1491
+ metroPort: legacy.metroPort,
1492
+ platforms: {
1493
+ [legacy.platform]: { appId: legacy.appId, deviceCategory: legacy.deviceCategory }
1494
+ }
1495
+ };
1496
+ }
1497
+ const cfg = parsed;
1498
+ if (!isDevPlatform(cfg.defaultPlatform)) {
1499
+ throw new Error(
1500
+ `vibeview.json: "defaultPlatform" must be one of ${DEV_PLATFORMS.join(", ")}, got "${String(cfg.defaultPlatform)}"`
1501
+ );
1502
+ }
1503
+ if (!cfg.platforms || typeof cfg.platforms !== "object") {
1504
+ throw new Error(
1505
+ 'vibeview.json: "platforms" object is required \u2014 run `vibeview dev` to set it up'
1506
+ );
1507
+ }
1508
+ for (const [key, entry] of Object.entries(cfg.platforms)) {
1509
+ if (!isDevPlatform(key)) {
1510
+ throw new Error(
1511
+ `vibeview.json: unknown platform "${key}" (expected one of ${DEV_PLATFORMS.join(", ")})`
1512
+ );
1513
+ }
1514
+ if (!entry || typeof entry.appId !== "string" || !entry.appId) {
1515
+ throw new Error(`vibeview.json: platforms.${key}.appId is required`);
1516
+ }
1517
+ }
1518
+ return cfg;
1519
+ }
1520
+ function saveDevConfig(cwd, cfg) {
1521
+ writeFileSync2(join2(cwd, FILE), JSON.stringify(cfg, null, 2) + "\n");
1522
+ }
1523
+
1524
+ // src/commands/dev.ts
1525
+ function findBlockingWarning(info) {
1526
+ return (info.warnings ?? []).find((w) => w.includes("No debug build"));
1527
+ }
1528
+ async function pollUntilTunnelReady(api, sessionId, timeoutMs, intervalMs = 2e3) {
1529
+ const deadline = Date.now() + timeoutMs;
1530
+ for (; ; ) {
1531
+ const info = await api.getSession(sessionId);
1532
+ const blocking = findBlockingWarning(info);
1533
+ if (blocking) throw new Error(blocking);
1534
+ if (info.tunnel_url) return info;
1535
+ if (info.status === "stopped" || info.status === "failed") {
1536
+ throw new Error(`session ${info.status} before the tunnel came up`);
1537
+ }
1538
+ if (Date.now() > deadline) throw new Error("timed out waiting for the device to start");
1539
+ await new Promise((r) => setTimeout(r, intervalMs));
1540
+ }
1541
+ }
1542
+ var PLATFORM_DEVICE = {
1543
+ ios: { deviceType: "ios", tv: false },
1544
+ android: { deviceType: "android", tv: false },
1545
+ tvos: { deviceType: "ios", tv: true },
1546
+ androidtv: { deviceType: "android", tv: true }
1547
+ };
1548
+ function appsForPlatform(apps, platform) {
1549
+ return apps.filter((a) => a.platform === platform);
1550
+ }
1551
+ async function ensureConfig(api, opts) {
1552
+ const existing = loadDevConfig(process.cwd());
1553
+ if (opts.platform && !isDevPlatform(opts.platform)) {
1554
+ console.error(chalk10.red(`error: --platform must be one of ios, android, tvos, androidtv`));
1555
+ process.exit(2);
1556
+ }
1557
+ const platform = opts.platform || existing?.defaultPlatform || await select({
1558
+ message: "Platform to run on",
1559
+ choices: [
1560
+ { value: "ios", name: "iOS simulator" },
1561
+ { value: "android", name: "Android emulator" },
1562
+ { value: "tvos", name: "Apple TV simulator" },
1563
+ { value: "androidtv", name: "Android TV emulator" }
1564
+ ]
1565
+ });
1566
+ const isTv = PLATFORM_DEVICE[platform].tv;
1567
+ if (isTv && opts.deviceCategory) {
1568
+ console.error(
1569
+ chalk10.red(`error: --device-category does not apply to ${platform} (always a tv device)`)
1570
+ );
1571
+ process.exit(2);
1572
+ }
1573
+ if (!isTv && opts.deviceCategory === "tv") {
1574
+ console.error(chalk10.red(`error: use --platform tvos or --platform androidtv for TV devices`));
1575
+ process.exit(2);
1576
+ }
1577
+ const entry = existing?.platforms?.[platform];
1578
+ let appId = opts.app || entry?.appId;
1579
+ if (!appId) {
1580
+ const apps = appsForPlatform(await api.listApps(), platform);
1581
+ if (apps.length === 0) {
1582
+ console.error(
1583
+ chalk10.red(`No ${platform} apps found \u2014 upload a debug build with \`vibeview upload-app\``)
1584
+ );
1585
+ process.exit(1);
1586
+ }
1587
+ appId = await select({
1588
+ message: "Which uploaded app is your debug build? (upload one with `vibeview upload-app`)",
1589
+ // Platform suffix dropped: the picker is already filtered to one platform
1590
+ // (appsForPlatform), so `(ios)` on every row is redundant noise.
1591
+ choices: apps.map((a) => ({ value: a.public_id, name: a.name }))
1592
+ });
1593
+ }
1594
+ const deviceCategory = isTv ? "tv" : opts.deviceCategory || entry?.deviceCategory || "phone";
1595
+ const metroPort = opts.metroPort ? parseInt(opts.metroPort, 10) : existing?.metroPort ?? 8081;
1596
+ const cfg = {
1597
+ defaultPlatform: platform,
1598
+ metroPort,
1599
+ platforms: {
1600
+ ...existing?.platforms,
1601
+ [platform]: { appId, deviceCategory: isTv ? void 0 : deviceCategory }
1602
+ }
1603
+ };
1604
+ saveDevConfig(process.cwd(), cfg);
1605
+ return { platform, appId, deviceCategory, metroPort };
1606
+ }
1607
+ async function dev(opts) {
1608
+ const { token, baseUrl } = resolveAuth(opts);
1609
+ console.error(chalk10.dim(`Using ${baseUrl}`));
1610
+ const api = new ApiClient(baseUrl, token);
1611
+ const cfg = await ensureConfig(api, opts);
1612
+ const metroPort = cfg.metroPort ?? 8081;
1613
+ const metro = await checkMetro(metroPort);
1614
+ if (metro === "down") {
1615
+ console.error(
1616
+ chalk10.red(`error: Metro isn't running on localhost:${metroPort}.`) + `
1617
+ Start it in your project first (yarn start), then re-run vibeview dev.
1618
+ If Metro uses another port, pass --metro-port <port>.`
1619
+ );
1620
+ process.exit(1);
1621
+ }
1622
+ if (metro === "listening") {
1623
+ console.error(
1624
+ chalk10.yellow(
1625
+ `\u26A0 something is listening on localhost:${metroPort} but it doesn't look like Metro \u2014 continuing anyway`
1626
+ )
1627
+ );
1628
+ }
1629
+ console.error(chalk10.dim(`Starting a ${cfg.platform} dev session for ${cfg.appId}...`));
1630
+ const created = await api.createSession({
1631
+ device_type: PLATFORM_DEVICE[cfg.platform].deviceType,
1632
+ device_category: cfg.deviceCategory,
1633
+ app_id: cfg.appId,
1634
+ tunnel: true
1635
+ });
1636
+ const sessionId = created.session_id;
1637
+ let stopping = false;
1638
+ const stopSession = async (code) => {
1639
+ if (stopping) return;
1640
+ stopping = true;
1641
+ tunnel?.stop();
1642
+ console.error(chalk10.dim("\nStopping session..."));
1643
+ await api.stopSession(sessionId).catch(() => {
1644
+ });
1645
+ process.exit(code);
1646
+ };
1647
+ process.on("SIGINT", () => void stopSession(0));
1648
+ process.on("SIGTERM", () => void stopSession(0));
1649
+ let tunnel = null;
1650
+ try {
1651
+ await api.startSession(sessionId);
1652
+ const ready = await pollUntilTunnelReady(api, sessionId, 18e4);
1653
+ for (const w of ready.warnings ?? []) {
1654
+ console.error(chalk10.yellow(`\u26A0 ${w}`));
1655
+ }
1656
+ let everConnected = false;
1657
+ let waitingPrinted = false;
1658
+ tunnel = new TunnelClient({
1659
+ url: ready.tunnel_url,
1660
+ metroPort: cfg.metroPort ?? 8081,
1661
+ // Re-mint the URL on each reconnect: the tunnel token has a ~1h TTL and
1662
+ // a worker restart drops the proxy registration, so the original URL
1663
+ // eventually goes stale. Every authorized GET /sessions/{id} re-signs a
1664
+ // fresh tunnel token. Null (session stopped/failed) ends the tunnel.
1665
+ refreshUrl: async () => {
1666
+ const info = await api.getSession(sessionId);
1667
+ if (info.status === "stopped" || info.status === "failed") return null;
1668
+ return info.tunnel_url ?? null;
1669
+ },
1670
+ // --debug: dump raw tunnel lifecycle (close codes/reasons, reconnect
1671
+ // scheduling, url refresh) to diagnose flapping. Timestamped so the
1672
+ // drop→reconnect cadence is visible.
1673
+ onDebug: opts.debug ? (msg) => console.error(chalk10.dim(`[tunnel ${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}`)) : void 0,
1674
+ onState: (s) => {
1675
+ if (s === "connected") {
1676
+ everConnected = true;
1677
+ console.error(chalk10.green("\u2713 tunnel connected \u2014 edit away, Fast Refresh is live"));
1678
+ } else if (s === "reconnecting") {
1679
+ if (!everConnected) {
1680
+ if (!waitingPrinted) {
1681
+ waitingPrinted = true;
1682
+ console.error(
1683
+ chalk10.dim("\u2026 waiting for the device to come up (tunnel not ready yet)")
1684
+ );
1685
+ }
1686
+ return;
1687
+ }
1688
+ console.error(chalk10.yellow("\u2026 tunnel dropped, reconnecting"));
1689
+ } else if (s === "metro-unreachable")
1690
+ console.error(
1691
+ chalk10.red(
1692
+ `\u2717 can't reach Metro on localhost:${cfg.metroPort ?? 8081} \u2014 is it running? (yarn start)`
1693
+ )
1694
+ );
1695
+ else if (s === "closed" && !stopping) {
1696
+ console.error(chalk10.red("tunnel closed by server \u2014 session ended"));
1697
+ void stopSession(1);
1698
+ }
1699
+ }
1700
+ });
1701
+ tunnel.start();
1702
+ const webBase = opts.webUrl || process.env.VIBEVIEW_WEB_URL || baseUrl;
1703
+ const pageUrl = `${webBase}/sandbox/${sessionId}`;
1704
+ console.error(`
1705
+ Watch and interact: ${chalk10.cyan(pageUrl)}
1706
+ Ctrl-C to end the session.
1707
+ `);
1708
+ if (opts.open !== false) await openBrowser(pageUrl).catch(() => {
1709
+ });
1710
+ } catch (err) {
1711
+ console.error(chalk10.red(`error: ${err.message}`));
1712
+ await stopSession(1);
1713
+ }
1714
+ }
1715
+
1716
+ // src/lib/formatters/devices.ts
1717
+ import chalk11 from "chalk";
1718
+ var HEADERS = ["DEVICE_ID", "NAME", "TYPE", "CATEGORY", "OS", "STATUS"];
1719
+ function formatDevicesHuman(devices) {
1720
+ if (devices.length === 0) {
1721
+ return "No devices registered.\n";
1722
+ }
1723
+ const rows = devices.map((d) => [
1724
+ d.device_id,
1725
+ d.name,
1726
+ d.type,
1727
+ d.category,
1728
+ d.os_version,
1729
+ d.status
1730
+ ]);
1731
+ const widths = HEADERS.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
1732
+ const fmt = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
1733
+ const lines = [chalk11.bold(fmt(HEADERS))];
1734
+ for (const row of rows.sort(rowSort)) {
1735
+ const statusColored = row[5] === "available" ? chalk11.green(row[5]) : row[5] === "busy" ? chalk11.yellow(row[5]) : chalk11.dim(row[5]);
1736
+ lines.push(fmt([row[0], row[1], row[2], row[3], row[4], statusColored]));
1737
+ }
1738
+ return lines.join("\n") + "\n";
1739
+ }
1740
+ function rowSort(a, b) {
1741
+ return a[2].localeCompare(b[2]) || a[3].localeCompare(b[3]) || a[0].localeCompare(b[0]);
1742
+ }
1743
+
1744
+ // src/commands/list-devices.ts
1745
+ async function listDevicesCommand(options) {
1746
+ const { token, baseUrl } = resolveAuth(options);
1747
+ const client = new ApiClient(baseUrl, token);
1748
+ try {
1749
+ const devices = await client.listDevices({
1750
+ type: options.type,
1751
+ category: options.category,
1752
+ status: options.status
1753
+ });
1754
+ if (options.output === "json") {
1755
+ process.stdout.write(JSON.stringify(devices, null, 2) + "\n");
1756
+ } else {
1757
+ process.stdout.write(formatDevicesHuman(devices));
1758
+ }
1759
+ process.exit(EXIT_PASS);
1760
+ } catch (err) {
1761
+ process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}
1762
+ `);
1763
+ process.exit(EXIT_INFRA_ERROR);
1764
+ }
1765
+ }
1766
+
1767
+ // src/lib/metadata.ts
1768
+ import { readFileSync as readFileSync5 } from "fs";
1769
+ var MAX_METADATA_BYTES = 16384;
1770
+ var MAX_METADATA_DEPTH = 4;
1771
+ function measureDepth(value, current = 1) {
1772
+ if (Array.isArray(value)) {
1773
+ if (value.length === 0) return current;
1774
+ return Math.max(...value.map((v) => measureDepth(v, current + 1)));
1775
+ }
1776
+ if (value !== null && typeof value === "object") {
1777
+ const entries = Object.values(value);
1778
+ if (entries.length === 0) return current;
1779
+ return Math.max(...entries.map((v) => measureDepth(v, current + 1)));
1780
+ }
1781
+ return current - 1;
1782
+ }
1783
+ function parseMetadata(raw) {
1784
+ if (raw === void 0) return void 0;
1785
+ let parsed;
1786
+ try {
1787
+ parsed = JSON.parse(raw);
1788
+ } catch (e) {
1789
+ throw new Error(`failed to parse --metadata JSON: ${e.message}`);
1790
+ }
1791
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1792
+ throw new Error("metadata must be a JSON object at the top level");
1793
+ }
1794
+ const serialized = JSON.stringify(parsed);
1795
+ if (Buffer.byteLength(serialized, "utf8") > MAX_METADATA_BYTES) {
1796
+ throw new Error(`metadata exceeds max size of ${MAX_METADATA_BYTES} bytes`);
1797
+ }
1798
+ const depth = measureDepth(parsed);
1799
+ if (depth > MAX_METADATA_DEPTH) {
1800
+ throw new Error(`metadata nesting depth ${depth} exceeds max of ${MAX_METADATA_DEPTH}`);
1801
+ }
1802
+ return parsed;
1803
+ }
1804
+ function readMetadataFile(path) {
1805
+ let raw;
1806
+ try {
1807
+ raw = readFileSync5(path, "utf8");
1808
+ } catch (e) {
1809
+ throw new Error(`failed to read --metadata-file ${path}: ${e.message}`);
1810
+ }
1811
+ const parsed = parseMetadata(raw);
1812
+ if (parsed === void 0) {
1813
+ throw new Error(`--metadata-file ${path} is empty`);
1814
+ }
1815
+ return parsed;
1816
+ }
1817
+
1818
+ // src/cli.ts
1819
+ var require2 = createRequire(import.meta.url);
1820
+ var pkg = require2("../package.json");
1821
+ process.on("unhandledRejection", (reason) => {
1822
+ if (reason instanceof Error && reason.name === "ExitPromptError") {
1823
+ process.exit(130);
1824
+ }
1825
+ throw reason;
1826
+ });
1827
+ var DEVICE_CATEGORIES = ["phone", "tablet", "tv"];
1828
+ function parseDeviceCategory(value) {
1829
+ if (DEVICE_CATEGORIES.includes(value)) {
1830
+ return value;
1831
+ }
1832
+ process.stderr.write(
1833
+ `error: --device-category must be one of: ${DEVICE_CATEGORIES.join(", ")} (got "${value}")
1834
+ `
1835
+ );
1836
+ process.exit(2);
1837
+ }
1838
+ function resolveMetadata(opts) {
1839
+ if (opts.metadata && opts.metadataFile) {
1840
+ process.stderr.write("error: --metadata cannot be combined with --metadata-file\n");
1841
+ process.exit(2);
1842
+ }
1843
+ try {
1844
+ if (opts.metadataFile) {
1845
+ return readMetadataFile(opts.metadataFile);
1846
+ }
1847
+ if (opts.metadata) {
1848
+ return parseMetadata(opts.metadata);
1849
+ }
1850
+ return void 0;
1851
+ } catch (e) {
1852
+ process.stderr.write(`error: ${e.message}
1853
+ `);
1854
+ process.exit(2);
1855
+ }
1856
+ }
1857
+ var program = new Command();
1858
+ program.name("vibeview").description("VibeView CLI -- run mobile tests from CI/CD pipelines").version(pkg.version);
1859
+ 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("--base-url <url>", "VibeView server URL (or set VIBEVIEW_BASE_URL)").option("--output <format>", "Output format: human, json, junit", "human").option("--verbose", "Show per-case breakdown", false).option("--timeout <seconds>", "Max wait time in seconds", "300").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(
1860
+ "--device-category <category>",
1861
+ "Device category: phone, tablet, tv (default: phone when --device is not set)",
1862
+ parseDeviceCategory
1863
+ ).option(
1864
+ "--device <device_id>",
1865
+ "Pin run to a specific device (use `vibeview list-devices` to find one). Conflicts with --device-category."
1866
+ ).option("--build <id>", "Specific AppBuild id to run against (defaults to latest)").option("--metadata <json>", "JSON object passed through to webhooks (e.g. PR context)").option("--metadata-file <path>", "Read JSON object from a file (use for nested JSON in CI)").action((suiteId, opts) => {
1867
+ if (opts.device && opts.deviceCategory) {
1868
+ process.stderr.write("error: --device cannot be combined with --device-category\n");
1869
+ process.exit(2);
1870
+ }
1871
+ const metadata = resolveMetadata(opts);
1872
+ return runSuite(suiteId, {
1873
+ ...opts,
1874
+ metadata,
1875
+ timeout: parseInt(opts.timeout, 10),
1876
+ build: opts.build ? parseInt(opts.build, 10) : void 0
1877
+ });
1878
+ });
1879
+ program.command("run-suites").description("Run all suites matching a tag filter").requiredOption(
1880
+ "--tag <tag>",
1881
+ "Tag to filter by (repeatable)",
1882
+ (val, prev = []) => [...prev, val],
1883
+ []
1884
+ ).option("--match <mode>", "any (default) or all", "any").option(
1885
+ "--platform <platform>",
1886
+ "Filter matched suites to one platform: ios, android, tvos, androidtv (default: no filter)"
1887
+ ).option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").option("--base-url <url>", "VibeView server URL (or set VIBEVIEW_BASE_URL)").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", "300").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(
1888
+ "--device-category <category>",
1889
+ "Device category: phone, tablet, tv (default: phone when --device is not set)",
1890
+ parseDeviceCategory
1891
+ ).option(
1892
+ "--device <device_id>",
1893
+ "Pin run to a specific device (use `vibeview list-devices` to find one). Conflicts with --device-category."
1894
+ ).option("--build <id>", "AppBuild id (validated against every matched suite)").option("--metadata <json>", "JSON object passed through to webhooks (e.g. PR context)").option("--metadata-file <path>", "Read JSON object from a file (use for nested JSON in CI)").action((opts) => {
1895
+ if (opts.device && opts.deviceCategory) {
1896
+ process.stderr.write("error: --device cannot be combined with --device-category\n");
1897
+ process.exit(2);
1898
+ }
1899
+ const metadata = resolveMetadata(opts);
1900
+ return runSuites({
1901
+ ...opts,
1902
+ metadata,
1903
+ timeout: parseInt(opts.timeout, 10),
1904
+ build: opts.build ? parseInt(opts.build, 10) : void 0
1905
+ });
1906
+ });
1907
+ program.command("run-test <test-id>").description("Run a single test case").option("--token <token>", "API token").option("--base-url <url>", "VibeView server URL").option("--output <format>", "Output format: human, json, junit", "human").option("--verbose", "Show step details", false).option("--timeout <seconds>", "Max wait time in seconds", "300").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(
1908
+ "--device-category <category>",
1909
+ "Device category: phone, tablet, tv (default: phone when --device is not set)",
1910
+ parseDeviceCategory
1911
+ ).option(
1912
+ "--device <device_id>",
1913
+ "Pin run to a specific device (use `vibeview list-devices` to find one). Conflicts with --device-category."
1914
+ ).option("--build <id>", "Specific AppBuild id to run against (defaults to latest)").option("--metadata <json>", "JSON object passed through to webhooks (e.g. PR context)").option("--metadata-file <path>", "Read JSON object from a file (use for nested JSON in CI)").action((testId, opts) => {
1915
+ if (opts.device && opts.deviceCategory) {
1916
+ process.stderr.write("error: --device cannot be combined with --device-category\n");
1917
+ process.exit(2);
1918
+ }
1919
+ const metadata = resolveMetadata(opts);
1920
+ return runTest(testId, {
1921
+ ...opts,
1922
+ metadata,
1923
+ timeout: parseInt(opts.timeout, 10),
1924
+ build: opts.build ? parseInt(opts.build, 10) : void 0
1925
+ });
1926
+ });
1927
+ program.command("list-suites").description("List available test suites").option("--token <token>", "API token").option("--base-url <url>", "VibeView server URL").option("--output <format>", "Output format: human, json", "human").option(
1928
+ "--tag <tag>",
1929
+ "Filter suites by tag (repeatable)",
1930
+ (val, prev = []) => [...prev, val],
1931
+ []
1932
+ ).option("--match <mode>", "When multiple --tag flags: any (default) or all", "any").action((opts) => listSuites(opts));
1933
+ program.command("list-apps").description("List apps in your organization").option("--token <token>", "API token").option("--base-url <url>", "VibeView server URL").option("--output <format>", "Output format: human, json", "human").action((opts) => listApps(opts));
1934
+ program.command("list-devices").description("List devices registered with the platform (filterable by type and category)").option("--token <token>", "API token").option("--base-url <url>", "VibeView server URL").option("--output <format>", "Output format: human, json", "human").option("--type <type>", "Filter by device type: ios, android").option("--category <category>", "Filter by category: phone, tablet, tv").option("--status <status>", "Filter by status: available, busy, offline").action((opts) => listDevicesCommand(opts));
1935
+ program.command("upload-app <file>").description("Upload an app build (zipped simulator .app for iOS, .apk for Android)").option("--token <token>", "API token").option("--base-url <url>", "VibeView server URL").option("--output <format>", "Output format: human, json", "human").option("--name <name>", "App name (auto-detected if not provided)").action((file, opts) => uploadApp(file, opts));
1936
+ program.command("create-test [name]").description("Create a standalone AI test case").option("--token <token>", "API token").option("--base-url <url>", "VibeView server URL").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" }));
1937
+ program.command("login").description("Log in to VibeView (interactive)").option("--base-url <url>", "VibeView server URL").action((opts) => login(opts));
1938
+ program.command("dev").description("Stream your app from the fleet with live Metro hot-reload (RN debug builds)").option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").option("--base-url <url>", "VibeView server URL (or set VIBEVIEW_BASE_URL)").option("--web-url <url>", "Dashboard URL if different from --base-url (local dev)").option(
1939
+ "--platform <platform>",
1940
+ "ios, android, tvos, or androidtv (first run is interactive otherwise)"
1941
+ ).option("--app <app_id>", "Uploaded debug build to run (see `vibeview list-apps`)").option("--metro-port <port>", "Local Metro port (default: 8081, remembered in vibeview.json)").option(
1942
+ "--device-category <category>",
1943
+ "phone or tablet (default: phone; TV platforms always use tv)",
1944
+ parseDeviceCategory
1945
+ ).option("--no-open", "Do not open the browser automatically").option("--debug", "Log raw tunnel lifecycle events (close codes, reconnects) for diagnostics").action((opts) => dev(opts));
1946
+ program.parse();
1947
+ //# sourceMappingURL=cli.js.map