vibeview 0.37.0 → 0.38.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/README.md CHANGED
@@ -141,11 +141,11 @@ Other MCP clients (Cursor, Claude Desktop, VS Code Copilot) run command
141
141
  # Upload the build under test (zipped simulator .app for iOS, .apk for Android)
142
142
  vibeview upload-app ./MyApp.zip
143
143
 
144
- # Run a single test or a whole suite
144
+ # Run a single test or a whole group
145
145
  vibeview run-test <test-id> --output json
146
146
  vibeview run-suite <suite-id> --output junit
147
147
 
148
- # Run several suites with an aggregated exit code
148
+ # Run several groups with an aggregated exit code
149
149
  vibeview run-suites --tag smoke
150
150
  ```
151
151
 
@@ -181,11 +181,12 @@ Output formats: `human`, `json`, and `junit` for any CI system. The exit code re
181
181
  | `vibeview list-apps` | List your apps |
182
182
  | `vibeview list-builds <app>` | List an app's uploaded builds (ids for `dev --build <id>`, `run-test --build`) |
183
183
  | `vibeview list-devices` | List available device types |
184
- | `vibeview list-suites` | List test suites |
184
+ | `vibeview list-suites` | List groups (the CLI calls them suites) |
185
185
  | `vibeview create-test [name]` | Create a test case |
186
+ | `vibeview import-maestro <path>` | Import Maestro flows (file, `.zip` or folder) as tests; `--dry-run` previews |
186
187
  | `vibeview run-test <test-id>` | Run a single test |
187
- | `vibeview run-suite <suite-id>` | Run a test suite |
188
- | `vibeview run-suites` | Run multiple suites |
188
+ | `vibeview run-suite <suite-id>` | Run a test group |
189
+ | `vibeview run-suites` | Run multiple groups |
189
190
 
190
191
  Run any command with `--help` for its options.
191
192
 
@@ -13,7 +13,7 @@ import {
13
13
  resolveArtifact,
14
14
  resolveAuth,
15
15
  uploadAppBuild
16
- } from "./chunk-QRJAFBH3.js";
16
+ } from "./chunk-MYXUGCR5.js";
17
17
 
18
18
  // src/lib/dev-config.ts
19
19
  import { readFileSync, renameSync, rmSync, writeFileSync } from "fs";
@@ -885,7 +885,7 @@ function rokuDefaults(cwd) {
885
885
  }
886
886
  return { buildCommand: ROKU_ZIP_COMMAND, artifact };
887
887
  }
888
- function defaultsFor(cwd, platform) {
888
+ function defaultsFor(cwd, platform, evaluatedIosName) {
889
889
  const flavor = detectFlavor(cwd);
890
890
  if (platform === "roku") return flavor === "roku" ? rokuDefaults(cwd) : {};
891
891
  if (flavor === "unknown" || flavor === "roku") return {};
@@ -900,7 +900,7 @@ function defaultsFor(cwd, platform) {
900
900
  };
901
901
  }
902
902
  if (isManagedExpo(cwd, "ios")) {
903
- const name = managedExpoIosName(cwd);
903
+ const name = managedExpoIosName(cwd, evaluatedIosName);
904
904
  return {
905
905
  buildCommand: name ? managedExpoIosCommand(cwd, name) : void 0,
906
906
  artifact: IOS_ARTIFACT,
@@ -925,10 +925,13 @@ function managedExpoAndroidProductionCommand(cwd) {
925
925
  function expoSanitizedName(name) {
926
926
  return name.replace(/[\W_]+/g, "").normalize("NFD").replace(/[\u0300-\u036f]/g, "");
927
927
  }
928
- function managedExpoIosName(cwd) {
929
- if (existsSync3(join5(cwd, "app.config.js")) || existsSync3(join5(cwd, "app.config.ts"))) {
930
- return null;
931
- }
928
+ var DYNAMIC_EXPO_CONFIGS = ["app.config.ts", "app.config.js", "app.config.mjs", "app.config.cjs"];
929
+ function dynamicExpoConfig(cwd) {
930
+ return DYNAMIC_EXPO_CONFIGS.find((f) => existsSync3(join5(cwd, f)));
931
+ }
932
+ function managedExpoIosName(cwd, evaluatedName) {
933
+ if (evaluatedName !== void 0) return expoSanitizedName(evaluatedName) || null;
934
+ if (dynamicExpoConfig(cwd)) return null;
932
935
  const raw = readJson(cwd, "app.config.json") ?? readJson(cwd, "app.json");
933
936
  if (!raw) return null;
934
937
  const config = "expo" in raw ? raw.expo : raw;
@@ -943,7 +946,10 @@ function iosArchiveExportCommand(ws) {
943
946
  const exportCmd = `xcodebuild -exportArchive -archivePath ${archivePath} -exportPath ios/build -exportOptionsPlist "$VIBEVIEW_EXPORT_OPTIONS_PLIST"`;
944
947
  return `${archiveCmd} && ${exportCmd}`;
945
948
  }
946
- function productionDefaultsFor(cwd, platform) {
949
+ function managedExpoIosProductionCommand(cwd, name) {
950
+ return `${installCommand(cwd)} && npx expo prebuild --platform ios && ` + iosArchiveExportCommand(name);
951
+ }
952
+ function productionDefaultsFor(cwd, platform, evaluatedIosName) {
947
953
  if (platform === "android" || platform === "androidtv") {
948
954
  return {
949
955
  buildCommand: isManagedExpo(cwd, "android") ? managedExpoAndroidProductionCommand(cwd) : androidProductionCommand(cwd),
@@ -955,10 +961,10 @@ function productionDefaultsFor(cwd, platform) {
955
961
  }
956
962
  if (platform === "ios") {
957
963
  if (isManagedExpo(cwd, "ios")) {
958
- const name = managedExpoIosName(cwd);
964
+ const name = managedExpoIosName(cwd, evaluatedIosName);
959
965
  if (!name) return { artifact: IOS_PRODUCTION_ARTIFACT };
960
966
  return {
961
- buildCommand: `${installCommand(cwd)} && npx expo prebuild --platform ios && ` + iosArchiveExportCommand(name),
967
+ buildCommand: managedExpoIosProductionCommand(cwd, name),
962
968
  artifact: IOS_PRODUCTION_ARTIFACT
963
969
  };
964
970
  }
@@ -994,36 +1000,65 @@ function pbxprojText(cwd) {
994
1000
  return null;
995
1001
  }
996
1002
  }
997
- var readExpoConfig = (cwd) => {
1003
+ function parseExpoConfigJson(stdout) {
1004
+ const lines = stdout.trim().split("\n");
1005
+ for (const text of [stdout, lines[lines.length - 1]]) {
1006
+ try {
1007
+ const parsed = JSON.parse(text);
1008
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1009
+ return parsed;
1010
+ }
1011
+ } catch {
1012
+ }
1013
+ }
1014
+ return null;
1015
+ }
1016
+ var EXPO_CONFIG_TIMEOUT_MS = 3e4;
1017
+ var readExpoConfig = (cwd, timeoutMs = EXPO_CONFIG_TIMEOUT_MS) => {
998
1018
  const cli = join5(cwd, "node_modules", "expo", "bin", "cli");
999
1019
  if (!existsSync3(cli)) {
1000
- return Promise.reject(new Error("Expo is not installed in this project (node_modules/expo)"));
1020
+ return Promise.reject(
1021
+ new Error("Expo isn't installed in this project yet (run your install command first)")
1022
+ );
1001
1023
  }
1002
1024
  return new Promise((resolve, reject) => {
1003
- execFile(
1025
+ let settled = false;
1026
+ const settle = (fn) => {
1027
+ if (settled) return;
1028
+ settled = true;
1029
+ clearTimeout(timer);
1030
+ fn();
1031
+ };
1032
+ const child = execFile(
1004
1033
  process.execPath,
1005
1034
  [cli, "config", "--json", "--type", "public"],
1006
1035
  {
1007
1036
  cwd,
1008
- timeout: 3e4,
1009
1037
  maxBuffer: 8 * 1024 * 1024,
1010
1038
  env: { ...process.env, EXPO_NO_TELEMETRY: "1", CI: "1" }
1011
1039
  },
1012
1040
  (err, stdout, stderr) => {
1013
1041
  if (err) {
1014
1042
  const why = String(stderr || err.message).split("\n").map((l) => l.trim()).find((l) => l.length > 0);
1015
- reject(new Error(`expo config failed${why ? `: ${why}` : ""}`));
1043
+ settle(() => reject(new Error(`expo config failed${why ? `: ${why}` : ""}`)));
1016
1044
  return;
1017
1045
  }
1018
- try {
1019
- const parsed = JSON.parse(stdout);
1020
- if (parsed && typeof parsed === "object") resolve(parsed);
1021
- else reject(new Error("expo config printed no config"));
1022
- } catch {
1023
- reject(new Error("expo config printed something that is not JSON"));
1024
- }
1046
+ const parsed = parseExpoConfigJson(stdout);
1047
+ settle(
1048
+ () => parsed ? resolve(parsed) : reject(new Error("expo config printed something that is not JSON"))
1049
+ );
1025
1050
  }
1026
1051
  );
1052
+ child.stdin?.end();
1053
+ const timer = setTimeout(() => {
1054
+ child.kill("SIGKILL");
1055
+ child.stdout?.destroy();
1056
+ child.stderr?.destroy();
1057
+ settle(
1058
+ () => reject(new Error(`expo config timed out after ${Math.round(timeoutMs / 1e3)} s`))
1059
+ );
1060
+ }, timeoutMs);
1061
+ timer.unref();
1027
1062
  });
1028
1063
  };
1029
1064
  async function localAppIdentity(cwd, platform, readConfig = readExpoConfig) {
@@ -1034,9 +1069,7 @@ async function localAppIdentity(cwd, platform, readConfig = readExpoConfig) {
1034
1069
  let version = null;
1035
1070
  let bundleId = null;
1036
1071
  let versionUnreadable = null;
1037
- const dynamicName = ["app.config.ts", "app.config.js", "app.config.mjs", "app.config.cjs"].find(
1038
- (f) => existsSync3(join5(cwd, f))
1039
- );
1072
+ const dynamicName = dynamicExpoConfig(cwd);
1040
1073
  const staticRaw = readJson(cwd, "app.config.json") ?? readJson(cwd, "app.json");
1041
1074
  const isExpo = Boolean(dynamicName) || staticRaw !== null && "expo" in staticRaw || existsSync3(join5(cwd, "node_modules", "expo", "package.json"));
1042
1075
  let expo = null;
@@ -1075,6 +1108,41 @@ async function localAppIdentity(cwd, platform, readConfig = readExpoConfig) {
1075
1108
  }
1076
1109
  return { reactNative, version, bundleId, versionUnreadable: version ? null : versionUnreadable };
1077
1110
  }
1111
+ var evaluatedNameCache = /* @__PURE__ */ new Map();
1112
+ function evaluatedExpoName(cwd, readConfig, onEvaluate) {
1113
+ const cached = evaluatedNameCache.get(cwd);
1114
+ if (cached) return cached;
1115
+ onEvaluate?.();
1116
+ const pending = Promise.resolve().then(() => readConfig(cwd)).then(
1117
+ (config) => typeof config.name === "string" ? { name: config.name } : { error: "the resolved config has no name" },
1118
+ (err) => ({
1119
+ error: err instanceof Error ? err.message : String(err)
1120
+ })
1121
+ );
1122
+ evaluatedNameCache.set(cwd, pending);
1123
+ return pending;
1124
+ }
1125
+ async function resolvedDefaultsFor(cwd, platform, opts = {}) {
1126
+ const { production = false, readConfig = (c) => readExpoConfig(c), onEvaluate } = opts;
1127
+ const base = production ? productionDefaultsFor : defaultsFor;
1128
+ const dynamic = dynamicExpoConfig(cwd);
1129
+ if (platform !== "ios" || !dynamic || !isManagedExpo(cwd, "ios")) {
1130
+ return base(cwd, platform);
1131
+ }
1132
+ const evaluated = await evaluatedExpoName(cwd, readConfig, onEvaluate);
1133
+ if ("error" in evaluated) {
1134
+ return { ...base(cwd, platform), evaluatedConfig: dynamic, evaluationError: evaluated.error };
1135
+ }
1136
+ const named = base(cwd, platform, evaluated.name);
1137
+ if (!named.buildCommand) {
1138
+ return {
1139
+ ...named,
1140
+ evaluatedConfig: dynamic,
1141
+ evaluationError: `the name "${evaluated.name}" has no letters or digits`
1142
+ };
1143
+ }
1144
+ return { ...named, evaluatedConfig: dynamic };
1145
+ }
1078
1146
 
1079
1147
  // src/lib/dev-setup.ts
1080
1148
  function platformChoices(cwd) {
@@ -1109,6 +1177,37 @@ function fillLaterHint(skipped, platform) {
1109
1177
  }
1110
1178
  return lines.join("\n");
1111
1179
  }
1180
+ async function promptDefaults(cwd, platform, opts = {}) {
1181
+ const progress = {};
1182
+ try {
1183
+ return await resolvedDefaultsFor(cwd, platform, {
1184
+ ...opts,
1185
+ onEvaluate: () => {
1186
+ progress.spinner = createSpinner("Reading Expo config\u2026").start();
1187
+ }
1188
+ });
1189
+ } finally {
1190
+ progress.spinner?.stop();
1191
+ }
1192
+ }
1193
+ function evaluatedConfigNote(cwd, defaults) {
1194
+ const file = defaults.evaluatedConfig;
1195
+ if (!file) return;
1196
+ if (defaults.evaluationError) {
1197
+ console.log(chalk2.dim(` Couldn't read the name from ${file}: ${defaults.evaluationError}`));
1198
+ return;
1199
+ }
1200
+ if (notedConfigDirs.has(cwd)) return;
1201
+ notedConfigDirs.add(cwd);
1202
+ console.log(
1203
+ chalk2.dim(
1204
+ ` The iOS name was read from ${file} using your local environment.
1205
+ If it depends on environment variables, set the same ones for the cloud build
1206
+ (build.env, or build variables).`
1207
+ )
1208
+ );
1209
+ }
1210
+ var notedConfigDirs = /* @__PURE__ */ new Set();
1112
1211
  async function commandSelect(message, detected) {
1113
1212
  const choices = [];
1114
1213
  if (detected) choices.push({ value: "default", name: `Use detected default: ${detected}` });
@@ -1117,7 +1216,8 @@ async function commandSelect(message, detected) {
1117
1216
  return select({ message, choices });
1118
1217
  }
1119
1218
  async function promptBuildConfig(cwd, platform) {
1120
- const defaults = defaultsFor(cwd, platform);
1219
+ const defaults = await promptDefaults(cwd, platform);
1220
+ evaluatedConfigNote(cwd, defaults);
1121
1221
  const choice = await commandSelect(`Build command for ${platform}`, defaults.buildCommand);
1122
1222
  if (choice === "skip") return null;
1123
1223
  if (choice === "default") {
@@ -1137,7 +1237,8 @@ async function promptBuildConfig(cwd, platform) {
1137
1237
  return { command, artifact };
1138
1238
  }
1139
1239
  async function promptProductionBuildConfig(cwd, platform) {
1140
- const defaults = productionDefaultsFor(cwd, platform);
1240
+ const defaults = await promptDefaults(cwd, platform, { production: true });
1241
+ evaluatedConfigNote(cwd, defaults);
1141
1242
  const choice = await commandSelect(
1142
1243
  `Production build command for ${platform}`,
1143
1244
  defaults.buildCommand
@@ -4557,10 +4658,10 @@ var AGENT_COMMANDS = [
4557
4658
  {
4558
4659
  // relaunch_app: {} — terminate the session's app and launch it again the
4559
4660
  // way the session launched it (same launch params); its data is kept.
4560
- // Refused on embed sessions (their watchdog owns launching) and on Roku.
4661
+ // Refused on embed sessions (their watchdog owns launching).
4561
4662
  verb: "relaunch-app",
4562
4663
  tool: "relaunch_app",
4563
- summary: "Close the app under test and start it again: a cold start that keeps the app's data, e.g. to check that something persists. Its log keeps streaming across the restart. Not available on Roku or embed sessions.",
4664
+ summary: "Close the app under test and start it again: a cold start that keeps the app's data, e.g. to check that something persists. Its log keeps streaming across the restart. Not available on embed sessions.",
4564
4665
  mutating: true,
4565
4666
  params: []
4566
4667
  },
@@ -163,9 +163,16 @@ async function parseJsonBody(resp) {
163
163
  return withUtcTimestamps(await resp.json());
164
164
  }
165
165
  var ApiClient = class {
166
- constructor(baseUrl, token) {
166
+ constructor(baseUrl, token, opts = {}) {
167
167
  this.baseUrl = baseUrl;
168
168
  this.token = token;
169
+ this.opts = opts;
170
+ }
171
+ extraHeaders() {
172
+ const h = {};
173
+ if (this.opts.client) h["X-VibeView-Client"] = this.opts.client;
174
+ if (process.env.CI) h["X-VibeView-CI"] = "1";
175
+ return h;
169
176
  }
170
177
  /**
171
178
  * fetch() rejects with undici's bare "TypeError: fetch failed" on any
@@ -189,6 +196,7 @@ var ApiClient = class {
189
196
  const url = `${this.baseUrl}/api/v1/ci${path}`;
190
197
  const headers = {
191
198
  Authorization: `Bearer ${this.token}`,
199
+ ...this.extraHeaders(),
192
200
  "Content-Type": "application/json"
193
201
  };
194
202
  const resp = await this.fetchOrExplain(url, {
@@ -231,7 +239,8 @@ var ApiClient = class {
231
239
  build_id: options?.build,
232
240
  metadata: options?.metadata,
233
241
  platform: options?.platform,
234
- app_id: options?.app
242
+ app_id: options?.app,
243
+ retry_failed: options?.retryFailed
235
244
  });
236
245
  }
237
246
  async runTest(testId, options) {
@@ -246,7 +255,8 @@ var ApiClient = class {
246
255
  build_id: options?.build,
247
256
  metadata: options?.metadata,
248
257
  platform: options?.platform,
249
- app_id: options?.app
258
+ app_id: options?.app,
259
+ retry_failed: options?.retryFailed
250
260
  });
251
261
  }
252
262
  /** Cancel a still-queued CI run by its queue entry id. Idempotent best-effort. */
@@ -271,6 +281,7 @@ var ApiClient = class {
271
281
  const url = `${this.baseUrl}/api/v1${path}`;
272
282
  const headers = {
273
283
  Authorization: `Bearer ${this.token}`,
284
+ ...this.extraHeaders(),
274
285
  "Content-Type": "application/json"
275
286
  };
276
287
  const resp = await this.fetchOrExplain(url, {
@@ -295,7 +306,8 @@ var ApiClient = class {
295
306
  async requestFormData(method, path, formData) {
296
307
  const url = `${this.baseUrl}/api/v1${path}`;
297
308
  const headers = {
298
- Authorization: `Bearer ${this.token}`
309
+ Authorization: `Bearer ${this.token}`,
310
+ ...this.extraHeaders()
299
311
  // Do NOT set Content-Type — let fetch set multipart boundary automatically
300
312
  };
301
313
  const resp = await this.fetchOrExplain(url, {
@@ -351,12 +363,35 @@ var ApiClient = class {
351
363
  }
352
364
  return this.requestFormData("POST", `/apps/${appPublicId}/builds`, formData);
353
365
  }
366
+ /** POST /api/v1/tests/import/maestro (multipart): preview (`dryRun`) or
367
+ * import Maestro flows as tests of one app. `group` is a group id or name. */
368
+ async importMaestro(upload, opts) {
369
+ const formData = new FormData();
370
+ formData.append("file", new Blob([Buffer.from(upload.bytes)]), upload.name);
371
+ formData.append("app_id", opts.appId);
372
+ if (opts.group) formData.append("group", opts.group);
373
+ formData.append("dry_run", opts.dryRun ? "true" : "false");
374
+ if (opts.skip && opts.skip.length > 0) formData.append("skip", JSON.stringify(opts.skip));
375
+ return this.requestFormData("POST", "/tests/import/maestro", formData);
376
+ }
354
377
  async createTest(body) {
355
378
  return this.requestRaw("POST", "/tests/cases/", body);
356
379
  }
357
380
  async runSuites(body) {
358
381
  return this.request("POST", "/run-suites", body);
359
382
  }
383
+ async startRun(body) {
384
+ return this.requestRaw("POST", "/tests/runs", body);
385
+ }
386
+ async startRunOnSession(sessionId, body) {
387
+ return this.requestRaw("POST", `/sessions/${encodeURIComponent(sessionId)}/runs`, body);
388
+ }
389
+ async getRunBatch(batchId) {
390
+ return this.requestRaw("GET", `/tests/batches/${encodeURIComponent(batchId)}`);
391
+ }
392
+ async resolveRun(runId) {
393
+ return this.requestRaw("GET", `/tests/runs/${encodeURIComponent(runId)}/resolve`);
394
+ }
360
395
  async getMultiSuiteRun(aggregateId) {
361
396
  return this.request("GET", `/multi-suite-runs/${aggregateId}`);
362
397
  }
@@ -474,7 +509,8 @@ var ApiClient = class {
474
509
  async downloadCloudBuildArtifact(id, destDir) {
475
510
  const url = `${this.baseUrl}/api/v1/builds/${id}/artifact`;
476
511
  const headers = {
477
- Authorization: `Bearer ${this.token}`
512
+ Authorization: `Bearer ${this.token}`,
513
+ ...this.extraHeaders()
478
514
  };
479
515
  const resp = await this.fetchOrExplain(url, { method: "GET", headers });
480
516
  if (!resp.ok) {