@flowriki/agent 0.1.0-beta.52 → 0.1.0-beta.55

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.
@@ -14,7 +14,9 @@ import { spawn as spawn2 } from "child_process";
14
14
  import fs3 from "fs";
15
15
  import os3 from "os";
16
16
  import path3 from "path";
17
+ import { execFile as execFile2 } from "child_process";
17
18
  import fs4 from "fs";
19
+ import os4 from "os";
18
20
  import path4 from "path";
19
21
 
20
22
  // ../test-model/dist/chunk-I5COYCQ5.js
@@ -2568,6 +2570,10 @@ async function localCapabilities(target, udid, opts = {}) {
2568
2570
  "appium:appPackage": app.id,
2569
2571
  ...app.activity ? { "appium:appActivity": app.activity } : {},
2570
2572
  "appium:appWaitActivity": "*",
2573
+ // Its own ports: two phones on one Appium each need their own driver
2574
+ // server and picture stream, or the second session steals the first's.
2575
+ ...opts.ports?.system ? { "appium:systemPort": opts.ports.system } : {},
2576
+ ...opts.ports?.mjpeg ? { "appium:mjpegServerPort": opts.ports.mjpeg } : {},
2571
2577
  "appium:autoGrantPermissions": false,
2572
2578
  "appium:disableWindowAnimation": true,
2573
2579
  "appium:uiautomator2ServerLaunchTimeout": 12e4,
@@ -2584,6 +2590,8 @@ async function localCapabilities(target, udid, opts = {}) {
2584
2590
  "appium:bundleId": app.id,
2585
2591
  "appium:wdaLaunchTimeout": 24e4,
2586
2592
  "appium:wdaConnectionTimeout": 24e4,
2593
+ ...opts.ports?.wda ? { "appium:wdaLocalPort": opts.ports.wda } : {},
2594
+ ...opts.ports?.mjpeg ? { "appium:mjpegServerPort": opts.ports.mjpeg } : {},
2587
2595
  ...target.mobile.ios?.teamId ? { "appium:xcodeOrgId": target.mobile.ios.teamId, "appium:xcodeSigningId": "Apple Development" } : {}
2588
2596
  };
2589
2597
  }
@@ -2641,7 +2649,7 @@ async function cloudDevices(account, key) {
2641
2649
  if (!res.ok) throw new Error(`${account.provider} answered ${res.status}.`);
2642
2650
  return res.json();
2643
2651
  };
2644
- const platformOf = (os4) => /android/i.test(os4) ? "android" : /ios/i.test(os4) ? "ios" : null;
2652
+ const platformOf = (os5) => /android/i.test(os5) ? "android" : /ios/i.test(os5) ? "ios" : null;
2645
2653
  const out = [];
2646
2654
  if (account.provider === "browserstack") {
2647
2655
  for (const d of await get("https://api-cloud.browserstack.com/app-automate/devices.json")) {
@@ -2655,11 +2663,11 @@ async function cloudDevices(account, key) {
2655
2663
  if (p) out.push({ name: d.name, platform: p, osVersion: d.osVersion });
2656
2664
  }
2657
2665
  } else {
2658
- for (const os4 of ["android", "ios"]) {
2659
- const listed = await get(`https://mobile-api.lambdatest.com/mobile-automation/api/v1/list?region=us&os=${os4}`);
2666
+ for (const os5 of ["android", "ios"]) {
2667
+ const listed = await get(`https://mobile-api.lambdatest.com/mobile-automation/api/v1/list?region=us&os=${os5}`);
2660
2668
  for (const [, devices] of Object.entries(listed)) {
2661
2669
  if (!Array.isArray(devices)) continue;
2662
- for (const d of devices) out.push({ name: d.deviceName, platform: os4, osVersion: d.platformVersion });
2670
+ for (const d of devices) out.push({ name: d.deviceName, platform: os5, osVersion: d.platformVersion });
2663
2671
  }
2664
2672
  }
2665
2673
  }
@@ -3203,6 +3211,8 @@ async function checkMobileAssertion(session, a, timeoutMs = 3e3) {
3203
3211
  throw last;
3204
3212
  }
3205
3213
  var uploaded = /* @__PURE__ */ new Map();
3214
+ var OPEN = /* @__PURE__ */ new Map();
3215
+ var openSessionOn = (platform) => OPEN.get(platform) ?? null;
3206
3216
  var MobileSession = class _MobileSession {
3207
3217
  constructor(driver, platform, appId, deviceName, cloud) {
3208
3218
  this.driver = driver;
@@ -3243,6 +3253,7 @@ var MobileSession = class _MobileSession {
3243
3253
  throw new Error(`${CLOUD_PROVIDER_NAME[choice2.provider]} could not open ${choice2.device}: ${e.message}`);
3244
3254
  });
3245
3255
  const cloudSession = new _MobileSession(driver2, target.platform, app.id, choice2.device, true);
3256
+ OPEN.set(target.platform, cloudSession);
3246
3257
  await cloudSession.settle(2e4);
3247
3258
  return cloudSession;
3248
3259
  }
@@ -3256,12 +3267,14 @@ var MobileSession = class _MobileSession {
3256
3267
  const endpoint = await appiumServer();
3257
3268
  const buildFile = app.build ? await localBuild(app.build) : null;
3258
3269
  say(`Opening the app on ${known.name}\u2026`);
3259
- const capabilities = await localCapabilities(target, udid, { buildFile });
3270
+ const ports = target.platform === "android" ? { system: await freePort(), mjpeg: await freePort() } : { wda: await freePort(), mjpeg: await freePort() };
3271
+ const capabilities = await localCapabilities(target, udid, { buildFile, ports });
3260
3272
  const driver = await WebDriverSession.create(endpoint, capabilities).catch((e) => {
3261
3273
  const tail = appiumLog().filter((l) => /error/i.test(l)).slice(-2).join(" ");
3262
3274
  throw new Error(explainStartFailure(e.message, target.platform) + (tail ? ` (${tail.slice(0, 300)})` : ""));
3263
3275
  });
3264
3276
  const session = new _MobileSession(driver, target.platform, app.id, known.name, false);
3277
+ OPEN.set(target.platform, session);
3265
3278
  await session.terminateApp().catch(() => {
3266
3279
  });
3267
3280
  await session.launchApp().catch(() => {
@@ -3302,6 +3315,16 @@ var MobileSession = class _MobileSession {
3302
3315
  }
3303
3316
  return png;
3304
3317
  }
3318
+ /** The screen as a picture to LOOK at — the recorder's mirror, the run
3319
+ * page's live phone — at most `maxPx` on its longer side. A phone's own
3320
+ * resolution is three to four times what a mirror draws, and every byte
3321
+ * of it crosses the control channel each time; on macOS `sips` shrinks it
3322
+ * in a few milliseconds, elsewhere the full picture is sent. Evidence
3323
+ * files keep the full size. */
3324
+ async preview(maxPx = 900) {
3325
+ const png = await this.screenshot();
3326
+ return shrink(png, maxPx);
3327
+ }
3305
3328
  /** Android: the activity on screen. iOS has none worth reading. */
3306
3329
  async currentScreenName() {
3307
3330
  if (this.platform !== "android") return "";
@@ -3494,10 +3517,27 @@ var MobileSession = class _MobileSession {
3494
3517
  return { relPath: path4.basename(file), bytes: bytes.length };
3495
3518
  }
3496
3519
  async close() {
3520
+ if (OPEN.get(this.platform) === this) OPEN.delete(this.platform);
3497
3521
  if (this.recording) await this.stopVideo(null);
3498
3522
  await this.driver.close();
3499
3523
  }
3500
3524
  };
3525
+ async function shrink(png, maxPx) {
3526
+ if (process.platform !== "darwin") return png;
3527
+ const dir = fs4.mkdtempSync(path4.join(os4.tmpdir(), "flowriki-shot-"));
3528
+ const file = path4.join(dir, "s.png");
3529
+ try {
3530
+ fs4.writeFileSync(file, png);
3531
+ await new Promise((resolve, reject) => {
3532
+ execFile2("/usr/bin/sips", ["-Z", String(maxPx), file], { timeout: 5e3 }, (error) => error ? reject(error) : resolve());
3533
+ });
3534
+ return fs4.readFileSync(file);
3535
+ } catch {
3536
+ return png;
3537
+ } finally {
3538
+ fs4.rmSync(dir, { recursive: true, force: true });
3539
+ }
3540
+ }
3501
3541
  var ANDROID_KEYS = {
3502
3542
  enter: 66,
3503
3543
  return: 66,
@@ -4152,7 +4192,9 @@ export {
4152
4192
  CheckError,
4153
4193
  textOf,
4154
4194
  checkMobileAssertion,
4195
+ openSessionOn,
4155
4196
  MobileSession,
4197
+ shrink,
4156
4198
  MOBILE_TIMEOUTS,
4157
4199
  performMobileStep,
4158
4200
  runMobileSteps,
@@ -47,6 +47,7 @@ import {
47
47
  nodeAt,
48
48
  normalizeStartUrl,
49
49
  openMobileSession,
50
+ openSessionOn,
50
51
  parseFlowDocument,
51
52
  performMobileStep,
52
53
  platformsOf,
@@ -67,7 +68,7 @@ import {
67
68
  textIndependent,
68
69
  validateActors,
69
70
  webCloudOf
70
- } from "./chunk-KDW65RBE.js";
71
+ } from "./chunk-3XKTIMRP.js";
71
72
 
72
73
  // src/engine/machine.ts
73
74
  import fs from "fs";
@@ -75,7 +76,7 @@ import os from "os";
75
76
  import path from "path";
76
77
  import { createRequire } from "module";
77
78
  import { spawnSync } from "child_process";
78
- var AGENT_VERSION = "0.1.0-beta.52";
79
+ var AGENT_VERSION = "0.1.0-beta.55";
79
80
  async function prepareBrowsers() {
80
81
  if (browsersReady()) {
81
82
  return { ok: true, already: true, message: "chromium is already installed" };
@@ -135,7 +136,7 @@ async function checkMachine() {
135
136
  }
136
137
  } catch {
137
138
  }
138
- const { mobileDoctor: mobileDoctor2 } = await import("./dist-R2I4D66B.js");
139
+ const { mobileDoctor: mobileDoctor2 } = await import("./dist-RTDFWPOC.js");
139
140
  return {
140
141
  agentVersion: AGENT_VERSION,
141
142
  node: process.versions.node,
@@ -234,7 +235,7 @@ function forgetConnection(service, env = process.env) {
234
235
  }
235
236
  var fingerprint = (secret2) => crypto2.createHash("sha256").update(secret2).digest("hex").slice(0, 12);
236
237
 
237
- // ../contracts/dist/chunk-S3GLB3QV.js
238
+ // ../contracts/dist/chunk-NTBXG5EZ.js
238
239
  import { z } from "zod";
239
240
  var PROTOCOL_VERSION = 1;
240
241
  var PAIRING_CODE_TTL_MS = 5 * 60 * 1e3;
@@ -283,6 +284,9 @@ var CommandNameSchema = z.enum([
283
284
  // Reading a live phone session: its screen as a picture, and what is under
284
285
  // a point. Not effectful — a picture is far too big to keep as a result.
285
286
  "mobile.screen",
287
+ // The screen of whatever phone session is open on a platform — a run's,
288
+ // a recording's — as a picture, for the live mirror. Reading.
289
+ "mobile.peek",
286
290
  // What this computer has installed for web and phone testing. Reading.
287
291
  "machine.check"
288
292
  ]);
@@ -3962,7 +3966,7 @@ async function replayCase(opts) {
3962
3966
  let status2 = "passed";
3963
3967
  let error = null;
3964
3968
  const browserRows = occurrences.filter((o) => o.platform !== "android" && o.platform !== "ios");
3965
- const laneOf = new Map(sessionLanes(browserRows, opts.defaultActorId).map((plan, i) => [browserRows[i].occurrenceId, plan]));
3969
+ const laneOf2 = new Map(sessionLanes(browserRows, opts.defaultActorId).map((plan, i) => [browserRows[i].occurrenceId, plan]));
3966
3970
  try {
3967
3971
  for (const [index, occurrence] of occurrences.entries()) {
3968
3972
  if (status2 !== "passed" || opts.signal?.aborted) {
@@ -4014,7 +4018,7 @@ async function replayCase(opts) {
4014
4018
  const navigates = navigatesFirst(occurrence);
4015
4019
  const who = occurrence.actorId ?? opts.defaultActorId ?? "default";
4016
4020
  const entry = resolveStartUrl(occurrence.baseUrl ?? baseUrl, occurrence.startPath ?? "/");
4017
- const plan = laneOf.get(occurrence.occurrenceId);
4021
+ const plan = laneOf2.get(occurrence.occurrenceId);
4018
4022
  try {
4019
4023
  if (plan.opens && startOf(occurrence) === "fresh") await actors.restart(who, entry);
4020
4024
  else if (plan.opens) await actors.select(who, entry);
@@ -7990,6 +7994,105 @@ var BrowserBusy = class extends Error {
7990
7994
  this.name = "BrowserBusy";
7991
7995
  }
7992
7996
  };
7997
+ var LANES = ["web", "android", "ios"];
7998
+ var OneLane = class {
7999
+ owner = null;
8000
+ phase = "idle";
8001
+ since = null;
8002
+ /** Take the lane, or report who has it. Never awaits — that is the point. */
8003
+ acquire(owner) {
8004
+ if (this.owner !== null) {
8005
+ return { ok: false, heldBy: this.owner, phase: this.phase };
8006
+ }
8007
+ this.owner = owner;
8008
+ this.phase = "starting";
8009
+ this.since = Date.now();
8010
+ return { ok: true };
8011
+ }
8012
+ /** The holder finished starting and is now doing the thing. */
8013
+ activate(owner) {
8014
+ if (this.owner === owner) this.phase = "active";
8015
+ }
8016
+ /** The holder has begun shutting down. Still not idle: a second stop must
8017
+ * join the first rather than starting its own. */
8018
+ beginRelease(owner) {
8019
+ if (this.owner !== owner || this.phase === "stopping") return false;
8020
+ this.phase = "stopping";
8021
+ return true;
8022
+ }
8023
+ release(owner) {
8024
+ if (this.owner !== owner) return;
8025
+ this.owner = null;
8026
+ this.phase = "idle";
8027
+ this.since = null;
8028
+ }
8029
+ forceRelease() {
8030
+ this.owner = null;
8031
+ this.phase = "idle";
8032
+ this.since = null;
8033
+ }
8034
+ state() {
8035
+ return { owner: this.owner, phase: this.phase, since: this.since };
8036
+ }
8037
+ };
8038
+ var BrowserLease = class {
8039
+ lanes = { web: new OneLane(), android: new OneLane(), ios: new OneLane() };
8040
+ /** The web lane's methods, as they always were: the browser coordinators
8041
+ * drive one platform and need not know about lanes. */
8042
+ acquire(owner, lane = "web") {
8043
+ return this.lanes[lane].acquire(owner);
8044
+ }
8045
+ activate(owner, lane = "web") {
8046
+ this.lanes[lane].activate(owner);
8047
+ }
8048
+ beginRelease(owner, lane = "web") {
8049
+ return this.lanes[lane].beginRelease(owner);
8050
+ }
8051
+ release(owner, lane = "web") {
8052
+ this.lanes[lane].release(owner);
8053
+ }
8054
+ state(lane = "web") {
8055
+ return this.lanes[lane].state();
8056
+ }
8057
+ /** One lane, bound: what a coordinator for that platform is handed. */
8058
+ lane(lane) {
8059
+ return this.lanes[lane];
8060
+ }
8061
+ /** Take several lanes at once — all or none, so a job on two platforms
8062
+ * never holds one while refused the other. */
8063
+ acquireAll(owner, lanes) {
8064
+ const taken = [];
8065
+ for (const lane of new Set(lanes)) {
8066
+ const claim = this.lanes[lane].acquire(owner);
8067
+ if (!claim.ok) {
8068
+ for (const had of taken) this.lanes[had].release(owner);
8069
+ return { ok: false, lane, heldBy: claim.heldBy, phase: claim.phase };
8070
+ }
8071
+ taken.push(lane);
8072
+ }
8073
+ return { ok: true };
8074
+ }
8075
+ activateAll(owner, lanes) {
8076
+ for (const lane of new Set(lanes)) this.lanes[lane].activate(owner);
8077
+ }
8078
+ releaseAll(owner, lanes) {
8079
+ for (const lane of new Set(lanes)) this.lanes[lane].release(owner);
8080
+ }
8081
+ /** Drop every lane whoever holds it — shutdown, and nothing else. */
8082
+ forceRelease() {
8083
+ for (const lane of LANES) this.lanes[lane].forceRelease();
8084
+ }
8085
+ /** The sentence a refused caller is shown. Names what is happening and on
8086
+ * what, because "busy" leaves someone hunting for a window they cannot find. */
8087
+ static refusal(heldBy, phase, lane = "web") {
8088
+ const what = heldBy === "recording" ? "a recording" : heldBy === "debug" ? "a debug session" : "a run";
8089
+ const where = lane === "web" ? "the browser" : lane === "android" ? "the Android phone" : "the iPhone";
8090
+ if (phase === "starting") return `${what} is starting on ${where} \u2014 wait for it to open, or stop it first`;
8091
+ if (phase === "stopping") return `${what} is still closing on ${where} \u2014 try again in a moment`;
8092
+ return `${what} is already using ${where} \u2014 stop it before starting another`;
8093
+ }
8094
+ };
8095
+ var laneOf = (platform) => platform === "android" || platform === "ios" ? platform : "web";
7993
8096
  var COMMANDS = /* @__PURE__ */ new Set([
7994
8097
  "play",
7995
8098
  "pause",
@@ -8166,11 +8269,12 @@ async function verifyFlowSteps(deps, opts) {
8166
8269
  const steps = (opts.steps ?? flow.steps).map((s2) => DraftStepSchema.parse(s2));
8167
8270
  if (!opts.verifyIds.length) return { results: [], error: null, failedAtStepId: null, ok: true };
8168
8271
  const baseUrl = store.baseUrlFor(flow);
8169
- const claim = deps.lease?.acquire("run");
8272
+ const lane = laneOf(flow.platform);
8273
+ const claim = deps.lease?.acquire("run", lane);
8170
8274
  if (claim && !claim.ok) {
8171
- throw new BrowserBusy(`the browser on this machine is in use (${claim.heldBy}, ${claim.phase})`);
8275
+ throw new BrowserBusy(`${lane === "web" ? "the browser" : lane === "android" ? "the Android phone" : "the iPhone"} on this machine is in use (${claim.heldBy}, ${claim.phase})`);
8172
8276
  }
8173
- deps.lease?.activate("run");
8277
+ deps.lease?.activate("run", lane);
8174
8278
  try {
8175
8279
  const secrets = store.readSecrets();
8176
8280
  if (isMobile(flow.platform)) {
@@ -8215,7 +8319,7 @@ async function verifyFlowSteps(deps, opts) {
8215
8319
  resolveSecret: (ref) => secrets[ref]
8216
8320
  });
8217
8321
  } finally {
8218
- deps.lease?.release("run");
8322
+ deps.lease?.release("run", lane);
8219
8323
  }
8220
8324
  }
8221
8325
  function deliveredRun(delivered) {
@@ -8235,55 +8339,6 @@ function deliveredRun(delivered) {
8235
8339
  resolveFile: () => void 0
8236
8340
  };
8237
8341
  }
8238
- var BrowserLease = class {
8239
- owner = null;
8240
- phase = "idle";
8241
- since = null;
8242
- /** Take the lease, or report who has it. Never awaits — that is the point. */
8243
- acquire(owner) {
8244
- if (this.owner !== null) {
8245
- return { ok: false, heldBy: this.owner, phase: this.phase };
8246
- }
8247
- this.owner = owner;
8248
- this.phase = "starting";
8249
- this.since = Date.now();
8250
- return { ok: true };
8251
- }
8252
- /** The holder finished starting and is now doing the thing. */
8253
- activate(owner) {
8254
- if (this.owner === owner) this.phase = "active";
8255
- }
8256
- /** The holder has begun shutting down. Still not idle: a second stop must
8257
- * join the first rather than starting its own. */
8258
- beginRelease(owner) {
8259
- if (this.owner !== owner || this.phase === "stopping") return false;
8260
- this.phase = "stopping";
8261
- return true;
8262
- }
8263
- release(owner) {
8264
- if (this.owner !== owner) return;
8265
- this.owner = null;
8266
- this.phase = "idle";
8267
- this.since = null;
8268
- }
8269
- /** Drop the lease whoever holds it — shutdown, and nothing else. */
8270
- forceRelease() {
8271
- this.owner = null;
8272
- this.phase = "idle";
8273
- this.since = null;
8274
- }
8275
- state() {
8276
- return { owner: this.owner, phase: this.phase, since: this.since };
8277
- }
8278
- /** The sentence a refused caller is shown. Names what is happening, because
8279
- * "busy" leaves someone hunting for a window they cannot find. */
8280
- static refusal(heldBy, phase) {
8281
- const what = heldBy === "recording" ? "a recording" : heldBy === "debug" ? "a debug session" : "a run";
8282
- if (phase === "starting") return `${what} is starting \u2014 wait for it to open, or stop it first`;
8283
- if (phase === "stopping") return `${what} is still closing \u2014 try again in a moment`;
8284
- return `${what} is already using the browser \u2014 stop it before starting another`;
8285
- }
8286
- };
8287
8342
 
8288
8343
  // src/engine/checkouts.ts
8289
8344
  function resolveCheckouts(dirs) {
@@ -9240,11 +9295,12 @@ function executionHandlers(ctx) {
9240
9295
  detail: `this flow needs ${missing.length} secret value(s) that are not on this machine (${missing.map((m) => m.ref || m.field).join(", ")}) \u2014 set them on the machine that runs it`
9241
9296
  };
9242
9297
  }
9243
- const claim = lease?.acquire("run");
9298
+ const lanes = [laneOf(flow.platform)];
9299
+ const claim = lease?.acquireAll("run", lanes);
9244
9300
  if (claim && !claim.ok) {
9245
- return { ok: false, reason: "busy", detail: `the browser on this machine is in use (${claim.heldBy})` };
9301
+ return { ok: false, reason: "busy", detail: BrowserLease.refusal(claim.heldBy, claim.phase, claim.lane) };
9246
9302
  }
9247
- lease?.activate("run");
9303
+ lease?.activateAll("run", lanes);
9248
9304
  onActivity?.({ kind: "run", projectId: project.id, detail: flow.name });
9249
9305
  const aborter = new AbortController();
9250
9306
  cancels.set(command.id, { projectId: project.id, cancel: () => aborter.abort() });
@@ -9279,6 +9335,8 @@ function executionHandlers(ctx) {
9279
9335
  ok: true,
9280
9336
  result: {
9281
9337
  runId: outcome.run?.id ?? null,
9338
+ // What it was called, for a service that only knew its id.
9339
+ flowName: flow.name,
9282
9340
  // The WHOLE outcome, not a summary of it. A dashboard renders the
9283
9341
  // per-step report exactly as the local app does, and a result
9284
9342
  // carrying only counts makes that screen crash rather than degrade.
@@ -9294,7 +9352,7 @@ function executionHandlers(ctx) {
9294
9352
  if (typeof cancelKey === "string" && cancelKey.startsWith("suite:")) cancels.delete(cancelKey);
9295
9353
  if (runId) cancels.delete(runId);
9296
9354
  onActivity?.(null);
9297
- lease?.release("run");
9355
+ lease?.releaseAll("run", lanes);
9298
9356
  }
9299
9357
  },
9300
9358
  /** Run a test case: several flows, one browser, in order.
@@ -9312,11 +9370,12 @@ function executionHandlers(ctx) {
9312
9370
  if (testCase.occurrences.length === 0) {
9313
9371
  return { ok: false, reason: "failed", detail: `"${testCase.name}" has no flows in it to run` };
9314
9372
  }
9315
- const claim = lease?.acquire("run");
9373
+ const lanes = [...new Set(testCase.occurrences.map((o) => laneOf(o.platform)))];
9374
+ const claim = lease?.acquireAll("run", lanes);
9316
9375
  if (claim && !claim.ok) {
9317
- return { ok: false, reason: "busy", detail: `the browser on this machine is in use (${claim.heldBy})` };
9376
+ return { ok: false, reason: "busy", detail: BrowserLease.refusal(claim.heldBy, claim.phase, claim.lane) };
9318
9377
  }
9319
- lease?.activate("run");
9378
+ lease?.activateAll("run", lanes);
9320
9379
  onActivity?.({ kind: "run", projectId: project.id, detail: testCase.name });
9321
9380
  const aborter = new AbortController();
9322
9381
  cancels.set(command.id, { projectId: project.id, cancel: () => aborter.abort() });
@@ -9355,6 +9414,8 @@ function executionHandlers(ctx) {
9355
9414
  ok: true,
9356
9415
  result: {
9357
9416
  runId: run?.id ?? null,
9417
+ // What it was called, for a service that only knew its id.
9418
+ caseName: testCase.name,
9358
9419
  // The whole outcome, per-flow groups included: a dashboard renders
9359
9420
  // the report from this, and a result carrying only counts makes
9360
9421
  // that screen crash rather than degrade.
@@ -9366,7 +9427,7 @@ function executionHandlers(ctx) {
9366
9427
  if (typeof cancelKey === "string" && cancelKey.startsWith("suite:")) cancels.delete(cancelKey);
9367
9428
  if (runId) cancels.delete(runId);
9368
9429
  onActivity?.(null);
9369
- lease?.release("run");
9430
+ lease?.releaseAll("run", lanes);
9370
9431
  }
9371
9432
  },
9372
9433
  "run.cancel": async (command, project) => {
@@ -9779,12 +9840,17 @@ var runFile = (file2, args, timeoutMs) => new Promise((resolve2, reject) => {
9779
9840
  resolve2({ code, stdout: String(stdout), stderr: String(stderr) });
9780
9841
  });
9781
9842
  });
9782
- var live = null;
9843
+ var live = /* @__PURE__ */ new Map();
9844
+ var liveById = (sessionId) => [...live.values()].find((l) => l.id === sessionId);
9845
+ var ownerOf = (l) => l.draft ? "recording" : "debug";
9783
9846
  var SECRET_WORDS = /password|passwd|pass[-_\s]?code|pwd|secret|otp|\bpin\b|cvv|cvc/i;
9784
9847
  async function screenOf(l, withImage = false) {
9785
9848
  l.session.changed();
9786
- const screen = await l.session.screen();
9787
- const png = withImage ? await l.session.screenshot() : null;
9849
+ const [screen, png, where] = await Promise.all([
9850
+ l.session.screen(),
9851
+ withImage ? l.session.preview() : Promise.resolve(null),
9852
+ l.session.currentScreenName()
9853
+ ]);
9788
9854
  return {
9789
9855
  ...png ? { image: png.toString("base64") } : {},
9790
9856
  width: screen.size.width,
@@ -9799,14 +9865,15 @@ async function screenOf(l, withImage = false) {
9799
9865
  ...n2.value ? { value: n2.password ? "\u2022\u2022\u2022" : n2.value } : {},
9800
9866
  ...n2.id ? { id: n2.id } : {}
9801
9867
  })),
9802
- where: await l.session.currentScreenName(),
9868
+ where,
9803
9869
  device: l.session.deviceName
9804
9870
  };
9805
9871
  }
9806
9872
  function mobileHandlers(ctx) {
9807
9873
  const require2 = (command) => {
9808
- if (!live || command.args["sessionId"] !== live.id) throw new Error("that phone session has ended \u2014 open it again");
9809
- return live;
9874
+ const l = liveById(command.args["sessionId"]);
9875
+ if (!l) throw new Error("that phone session has ended \u2014 open it again");
9876
+ return l;
9810
9877
  };
9811
9878
  return {
9812
9879
  // Everything this computer has for testing — web and phones — for the Guide.
@@ -9817,6 +9884,21 @@ function mobileHandlers(ctx) {
9817
9884
  const devices = await listDevices();
9818
9885
  return { ok: true, result: { devices, ...command.args["doctor"] === true ? { doctor: await mobileDoctor() } : {} } };
9819
9886
  },
9887
+ // THE PHONE A RUN IS DRIVING, as a picture: whatever session is open on
9888
+ // that platform — a run's, a recording's. Not effectful, never kept, and
9889
+ // nothing when no phone is open: the mirror then shows that.
9890
+ "mobile.peek": async (command) => {
9891
+ const platform = command.args["platform"];
9892
+ if (!isMobile(platform)) return { ok: false, reason: "failed", detail: "say which phone: android or ios" };
9893
+ const session = live.get(platform)?.session ?? openSessionOn(platform);
9894
+ if (!session) return { ok: true, result: { image: null } };
9895
+ try {
9896
+ const png = await session.preview(720);
9897
+ return { ok: true, result: { image: png.toString("base64"), device: session.deviceName } };
9898
+ } catch {
9899
+ return { ok: true, result: { image: null } };
9900
+ }
9901
+ },
9820
9902
  "mobile.screen": async (command) => {
9821
9903
  try {
9822
9904
  const l = require2(command);
@@ -9833,14 +9915,21 @@ function mobileHandlers(ctx) {
9833
9915
  },
9834
9916
  "mobile.session": async (command, project) => {
9835
9917
  const op = String(command.args["op"] ?? "");
9918
+ let claimed = null;
9836
9919
  try {
9837
9920
  if (op === "start") {
9838
- if (live) {
9839
- await live.session.close().catch(() => void 0);
9840
- live = null;
9841
- }
9842
9921
  const platform = command.args["platform"];
9843
9922
  if (!isMobile(platform)) return { ok: false, reason: "failed", detail: "say which phone: android or ios" };
9923
+ const prior = live.get(platform);
9924
+ if (prior) {
9925
+ live.delete(prior.platform);
9926
+ await prior.session.close().catch(() => void 0);
9927
+ ctx.lease?.release(ownerOf(prior), prior.platform);
9928
+ }
9929
+ const owner = command.args["record"] === true ? "recording" : "debug";
9930
+ const claim = ctx.lease?.acquire(owner, platform);
9931
+ if (claim && !claim.ok) return { ok: false, reason: "busy", detail: BrowserLease.refusal(claim.heldBy, claim.phase, platform) };
9932
+ claimed = { owner, platform };
9844
9933
  const config = command.args["config"] ?? {};
9845
9934
  const secrets = command.args["secrets"] ?? {};
9846
9935
  const device = command.args["device"] ? DeviceChoiceSchema.parse(command.args["device"]) : void 0;
@@ -9875,7 +9964,8 @@ function mobileHandlers(ctx) {
9875
9964
  ...auth?.profileId ? { signIn: auth } : {}
9876
9965
  };
9877
9966
  }
9878
- live = l2;
9967
+ live.set(l2.platform, l2);
9968
+ ctx.lease?.activate(owner, l2.platform);
9879
9969
  if (/^[a-z][\w+.-]*:\/\//i.test(startPath)) await session.openLink(startPath);
9880
9970
  const opening = [
9881
9971
  ...l2.draft?.signIn ? [RecordedStepSchema.parse({ id: "sign-in", type: "signIn", auth: l2.draft.signIn, role: "", name: "Sign in", scope: "", value: "", secret: false, legacySelector: "", proposals: [] })] : [],
@@ -9916,18 +10006,18 @@ function mobileHandlers(ctx) {
9916
10006
  if (op === "run" || step.type === "signIn" || step.type === "assert") {
9917
10007
  const done = await runMobileSteps(l.session, [step], { values: l.values, resolveSecret: l.resolveSecret, resolveAuth: l.resolveAuth, ...l.runHelper ? { runHelper: l.runHelper } : {} });
9918
10008
  const result = done.results[0];
9919
- if (op === "run") return { ok: true, result: { result: result ?? null, screen: await screenOf(l) } };
10009
+ if (op === "run") return { ok: true, result: { result: result ?? null } };
9920
10010
  if (done.status !== "passed") throw new Error(result?.detail ?? done.error ?? "it did not work");
9921
10011
  } else {
9922
10012
  await performMobileStep(l.session, step, { values: l.values, resolveSecret: l.resolveSecret, resolveAuth: l.resolveAuth, ...l.runHelper ? { runHelper: l.runHelper } : {} });
9923
10013
  }
9924
10014
  } catch (error) {
9925
10015
  if (secret2) l.typed.delete(ref);
9926
- return { ok: true, result: { kept: false, error: error.message.split("\n")[0], screen: await screenOf(l) } };
10016
+ return { ok: true, result: { kept: false, error: error.message.split("\n")[0] } };
9927
10017
  }
9928
- await new Promise((r) => setTimeout(r, 500));
10018
+ await new Promise((r) => setTimeout(r, 300));
9929
10019
  l.draft?.steps.push(step);
9930
- return { ok: true, result: { kept: true, step, durationMs: Date.now() - t0, steps: l.draft?.steps ?? [], screen: await screenOf(l) } };
10020
+ return { ok: true, result: { kept: true, step, durationMs: Date.now() - t0, steps: l.draft?.steps ?? [] } };
9931
10021
  }
9932
10022
  if (op === "check") {
9933
10023
  const assertion = RecordedStepSchema.shape.proposals.element.parse({ enabled: true, ...command.args["assertion"] });
@@ -9958,7 +10048,8 @@ function mobileHandlers(ctx) {
9958
10048
  return { ok: true, result: { steps: l.draft.steps } };
9959
10049
  }
9960
10050
  if (op === "stop" || op === "discard") {
9961
- live = null;
10051
+ live.delete(l.platform);
10052
+ ctx.lease?.release(ownerOf(l), l.platform);
9962
10053
  await l.session.close().catch(() => void 0);
9963
10054
  if (op === "discard" || !l.draft) return { ok: true, result: { ended: true } };
9964
10055
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -9982,6 +10073,7 @@ function mobileHandlers(ctx) {
9982
10073
  }
9983
10074
  return { ok: false, reason: "failed", detail: `unknown phone session operation "${op}"` };
9984
10075
  } catch (error) {
10076
+ if (claimed && !live.has(claimed.platform)) ctx.lease?.release(claimed.owner, claimed.platform);
9985
10077
  return { ok: false, reason: "failed", detail: error.message.split("\n")[0] ?? "the phone session failed" };
9986
10078
  }
9987
10079
  }
@@ -10247,75 +10339,15 @@ function createClaudeCli(options) {
10247
10339
  ask(ask) {
10248
10340
  if (current) return { started: false, detail: "The assistant is already answering a question on this machine." };
10249
10341
  fs43.mkdirSync(scratchRoot2, { recursive: true, mode: 448 });
10250
- const dir = fs43.mkdtempSync(path42.join(scratchRoot2, "ask-"));
10342
+ const dir = path42.join(scratchRoot2, `project-${ask.projectId.replace(/[^A-Za-z0-9_-]/g, "_")}`);
10343
+ fs43.mkdirSync(dir, { recursive: true, mode: 448 });
10251
10344
  const mcpConfigPath = path42.join(dir, "mcp.json");
10252
10345
  fs43.writeFileSync(
10253
10346
  mcpConfigPath,
10254
10347
  JSON.stringify({ mcpServers: { [MCP_SERVER_NAME]: { command: options.mcp.command, args: options.mcp.args } } }),
10255
10348
  { mode: 384 }
10256
10349
  );
10257
- let child;
10258
- try {
10259
- child = spawn2(
10260
- binary,
10261
- buildAskArgs({
10262
- prompt: ask.prompt,
10263
- mcpConfigPath,
10264
- projectId: ask.projectId,
10265
- projectName: ask.projectName,
10266
- ...ask.sessionId ? { sessionId: ask.sessionId } : {},
10267
- ...ask.folders?.length ? { folders: ask.folders } : {},
10268
- ...ask.mode ? { mode: ask.mode } : {}
10269
- }),
10270
- // THE SCRATCH FOLDER, not the project: nothing here to read or write.
10271
- { cwd: dir, stdio: ["ignore", "pipe", "pipe"] }
10272
- );
10273
- } catch {
10274
- fs43.rmSync(dir, { recursive: true, force: true });
10275
- return { started: false, detail: "The assistant\u2019s command-line tool could not be started." };
10276
- }
10277
- current = { askId: ask.askId, child };
10278
- let buffer = "", stderr = "", sawDone = false, ended = false;
10279
- const handleLine = (line) => {
10280
- for (const event of eventsFromLine(ask.askId, line)) {
10281
- if (event.name === "assistant.done") sawDone = true;
10282
- ask.emit(event.name, event.data);
10283
- }
10284
- };
10285
- const end = (ok, detail) => {
10286
- if (ended) return;
10287
- ended = true;
10288
- clearTimeout(timer);
10289
- if (buffer.trim()) handleLine(buffer);
10290
- if (!sawDone) ask.emit("assistant.done", { askId: ask.askId, ok, result: detail });
10291
- current = null;
10292
- fs43.rmSync(dir, { recursive: true, force: true });
10293
- };
10294
- const timer = setTimeout(() => {
10295
- child.kill("SIGTERM");
10296
- end(false, `Stopped after ${Math.max(1, Math.round(maxAskMs / 6e4))} minutes. Whatever it saved so far is on the Branches page \u2014 send \u201Ccontinue\u201D to carry on.`);
10297
- }, maxAskMs);
10298
- timer.unref?.();
10299
- child.stdout?.on("data", (chunk) => {
10300
- buffer += chunk.toString();
10301
- let at;
10302
- while ((at = buffer.indexOf("\n")) >= 0) {
10303
- handleLine(buffer.slice(0, at));
10304
- buffer = buffer.slice(at + 1);
10305
- }
10306
- });
10307
- child.stderr?.on("data", (chunk) => {
10308
- stderr = (stderr + chunk.toString()).slice(-2e3);
10309
- });
10310
- child.on("error", () => end(false, "The assistant\u2019s command-line tool could not be started."));
10311
- child.on(
10312
- "close",
10313
- (code) => end(
10314
- code === 0,
10315
- cancelled === ask.askId ? "Stopped." : code === 0 ? "" : stderr.trim().split("\n").slice(-3).join(" ") || `The assistant stopped (exit ${code ?? "?"}).`
10316
- )
10317
- );
10318
- return { started: true };
10350
+ return launch(ask, dir, mcpConfigPath, ask.sessionId);
10319
10351
  },
10320
10352
  cancel(askId) {
10321
10353
  if (!current || current.askId !== askId) return false;
@@ -10325,8 +10357,85 @@ function createClaudeCli(options) {
10325
10357
  setTimeout(() => child.kill("SIGKILL"), 3e3).unref?.();
10326
10358
  return true;
10327
10359
  },
10328
- busy: () => current !== null
10360
+ busy() {
10361
+ return current !== null;
10362
+ }
10329
10363
  };
10364
+ function launch(ask, dir, mcpConfigPath, sessionId) {
10365
+ let child;
10366
+ try {
10367
+ child = spawn2(
10368
+ binary,
10369
+ buildAskArgs({
10370
+ prompt: ask.prompt,
10371
+ mcpConfigPath,
10372
+ projectId: ask.projectId,
10373
+ projectName: ask.projectName,
10374
+ ...sessionId ? { sessionId } : {},
10375
+ ...ask.folders?.length ? { folders: ask.folders } : {},
10376
+ ...ask.mode ? { mode: ask.mode } : {},
10377
+ ...ask.brief ? { brief: ask.brief } : {}
10378
+ }),
10379
+ // THE SCRATCH FOLDER, not the project: nothing here to read or write.
10380
+ { cwd: dir, stdio: ["ignore", "pipe", "pipe"] }
10381
+ );
10382
+ } catch {
10383
+ return { started: false, detail: "The assistant\u2019s command-line tool could not be started." };
10384
+ }
10385
+ current = { askId: ask.askId, child };
10386
+ let buffer = "", stderr = "", sawDone = false, ended = false;
10387
+ let silentFailure = false;
10388
+ const handleLine = (line) => {
10389
+ for (const event of eventsFromLine(ask.askId, line)) {
10390
+ if (event.name === "assistant.done" && event.data["ok"] === false && !String(event.data["result"] ?? "").trim()) {
10391
+ silentFailure = true;
10392
+ continue;
10393
+ }
10394
+ if (event.name === "assistant.done") sawDone = true;
10395
+ ask.emit(event.name, event.data);
10396
+ }
10397
+ };
10398
+ const end = (ok, detail) => {
10399
+ if (ended) return;
10400
+ ended = true;
10401
+ clearTimeout(timer);
10402
+ if (buffer.trim()) handleLine(buffer);
10403
+ current = null;
10404
+ if (silentFailure && sessionId && /no conversation found/i.test(stderr) && cancelled !== ask.askId) {
10405
+ const again = launch(ask, dir, mcpConfigPath, void 0);
10406
+ if (again.started) return;
10407
+ }
10408
+ if (!sawDone) {
10409
+ const why = detail || stderr.trim().split("\n").filter((l) => l && !/^Warning:/.test(l)).slice(-3).join(" ") || "The assistant stopped without saying why.";
10410
+ ask.emit("assistant.done", { askId: ask.askId, ok: ok && !silentFailure, result: ok && !silentFailure ? "" : why });
10411
+ }
10412
+ };
10413
+ const timer = setTimeout(() => {
10414
+ child.kill("SIGTERM");
10415
+ end(false, `Stopped after ${Math.max(1, Math.round(maxAskMs / 6e4))} minutes. Whatever it saved so far is on the Branches page \u2014 send \u201Ccontinue\u201D to carry on.`);
10416
+ }, maxAskMs);
10417
+ timer.unref?.();
10418
+ child.stdout?.on("data", (chunk) => {
10419
+ buffer += chunk.toString();
10420
+ let at;
10421
+ while ((at = buffer.indexOf("\n")) >= 0) {
10422
+ handleLine(buffer.slice(0, at));
10423
+ buffer = buffer.slice(at + 1);
10424
+ }
10425
+ });
10426
+ child.stderr?.on("data", (chunk) => {
10427
+ stderr = (stderr + chunk.toString()).slice(-2e3);
10428
+ });
10429
+ child.on("error", () => end(false, "The assistant\u2019s command-line tool could not be started."));
10430
+ child.on(
10431
+ "close",
10432
+ (code) => end(
10433
+ code === 0,
10434
+ cancelled === ask.askId ? "Stopped." : code === 0 ? "" : stderr.trim().split("\n").filter((l) => l && !/^Warning:/.test(l)).slice(-3).join(" ") || `The assistant stopped (exit ${code ?? "?"}).`
10435
+ )
10436
+ );
10437
+ return { started: true };
10438
+ }
10330
10439
  }
10331
10440
  function createAgentRuntime(options) {
10332
10441
  const lease = new BrowserLease();
package/dist/cli.js CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  runtimeValue,
24
24
  start,
25
25
  status
26
- } from "./chunk-Z3SL46OX.js";
26
+ } from "./chunk-NWJWMDVO.js";
27
27
  import {
28
28
  CLOUD_PROVIDERS,
29
29
  CLOUD_PROVIDER_NAME,
@@ -49,7 +49,7 @@ import {
49
49
  screenLines,
50
50
  stepRefusedOn,
51
51
  targetFor
52
- } from "./chunk-KDW65RBE.js";
52
+ } from "./chunk-3XKTIMRP.js";
53
53
 
54
54
  // src/cli.ts
55
55
  import { parseArgs } from "util";
@@ -31,6 +31,7 @@ import {
31
31
  mobileSetup,
32
32
  nodeAt,
33
33
  openMobileSession,
34
+ openSessionOn,
34
35
  performMobileStep,
35
36
  readScreen,
36
37
  replayMobileCase,
@@ -39,12 +40,13 @@ import {
39
40
  run,
40
41
  runMobileSteps,
41
42
  screenLines,
43
+ shrink,
42
44
  stopAppium,
43
45
  targetFor,
44
46
  textOf,
45
47
  toolEnv,
46
48
  typesSecrets
47
- } from "./chunk-KDW65RBE.js";
49
+ } from "./chunk-3XKTIMRP.js";
48
50
  export {
49
51
  AGENT_HOME,
50
52
  APPIUM_DIR,
@@ -77,6 +79,7 @@ export {
77
79
  mobileSetup,
78
80
  nodeAt,
79
81
  openMobileSession,
82
+ openSessionOn,
80
83
  performMobileStep,
81
84
  readScreen,
82
85
  replayMobileCase,
@@ -85,6 +88,7 @@ export {
85
88
  runMobileSteps,
86
89
  run as runTool,
87
90
  screenLines,
91
+ shrink,
88
92
  stopAppium,
89
93
  targetFor,
90
94
  textOf,
package/dist/engine.js CHANGED
@@ -9,8 +9,8 @@ import {
9
9
  resolveCheckouts,
10
10
  start,
11
11
  status
12
- } from "./chunk-Z3SL46OX.js";
13
- import "./chunk-KDW65RBE.js";
12
+ } from "./chunk-NWJWMDVO.js";
13
+ import "./chunk-3XKTIMRP.js";
14
14
  export {
15
15
  AGENT_VERSION,
16
16
  browsersReady,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowriki/agent",
3
- "version": "0.1.0-beta.52",
3
+ "version": "0.1.0-beta.55",
4
4
  "description": "Run Flowriki UI tests on your own computer. Connects this machine to your Flowriki account.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",