@swmansion/argent 0.16.2-next.9 → 0.17.1-next.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.
@@ -109,7 +109,9 @@ var init_artifacts = __esm({
109
109
  ".xml": "application/xml",
110
110
  ".txt": "text/plain",
111
111
  ".md": "text/markdown",
112
- ".html": "text/html"
112
+ ".html": "text/html",
113
+ ".mp4": "video/mp4",
114
+ ".mov": "video/quicktime"
113
115
  };
114
116
  ArtifactStore = class {
115
117
  entries = /* @__PURE__ */ new Map();
@@ -138,6 +140,7 @@ var init_artifacts = __esm({
138
140
  };
139
141
  if (mtimeMs != null) handle.mtimeMs = mtimeMs;
140
142
  if (isDirectory2) handle.archive = "tar.gz";
143
+ if (opts?.saveDir) handle.saveDir = opts.saveDir;
141
144
  return handle;
142
145
  }
143
146
  get(id) {
@@ -382,6 +385,7 @@ var init_failure_codes = __esm({
382
385
  SCREEN_RECORDING_NO_ACTIVE_SESSION: "SCREEN_RECORDING_NO_ACTIVE_SESSION",
383
386
  SCREEN_RECORDING_STOP_IN_PROGRESS: "SCREEN_RECORDING_STOP_IN_PROGRESS",
384
387
  SCREEN_RECORDING_START_EXITED: "SCREEN_RECORDING_START_EXITED",
388
+ SCREEN_RECORDING_START_TIMEOUT: "SCREEN_RECORDING_START_TIMEOUT",
385
389
  SCREEN_RECORDING_PROCESS_ERROR: "SCREEN_RECORDING_PROCESS_ERROR",
386
390
  SCREEN_RECORDING_OUTPUT_MISSING: "SCREEN_RECORDING_OUTPUT_MISSING",
387
391
  SCREEN_RECORDING_SERVER_SHUTTING_DOWN: "SCREEN_RECORDING_SERVER_SHUTTING_DOWN",
@@ -34931,7 +34935,7 @@ var require_view = __commonJS({
34931
34935
  var path38 = require("node:path");
34932
34936
  var fs50 = require("node:fs");
34933
34937
  var dirname14 = path38.dirname;
34934
- var basename9 = path38.basename;
34938
+ var basename10 = path38.basename;
34935
34939
  var extname4 = path38.extname;
34936
34940
  var join46 = path38.join;
34937
34941
  var resolve7 = path38.resolve;
@@ -34970,7 +34974,7 @@ var require_view = __commonJS({
34970
34974
  var root2 = roots[i];
34971
34975
  var loc = resolve7(root2, name);
34972
34976
  var dir = dirname14(loc);
34973
- var file2 = basename9(loc);
34977
+ var file2 = basename10(loc);
34974
34978
  path39 = this.resolve(dir, file2);
34975
34979
  }
34976
34980
  return path39;
@@ -35000,7 +35004,7 @@ var require_view = __commonJS({
35000
35004
  if (stat4 && stat4.isFile()) {
35001
35005
  return path39;
35002
35006
  }
35003
- path39 = join46(dir, basename9(file2, ext), "index" + ext);
35007
+ path39 = join46(dir, basename10(file2, ext), "index" + ext);
35004
35008
  stat4 = tryStat(path39);
35005
35009
  if (stat4 && stat4.isFile()) {
35006
35010
  return path39;
@@ -57445,9 +57449,9 @@ var require_content_disposition = __commonJS({
57445
57449
  if (typeof fallback === "string" && NON_LATIN1_REGEXP.test(fallback)) {
57446
57450
  throw new TypeError("fallback must be ISO-8859-1 string");
57447
57451
  }
57448
- var name = basename9(filename);
57452
+ var name = basename10(filename);
57449
57453
  var isQuotedString = TEXT_REGEXP.test(name);
57450
- var fallbackName = typeof fallback !== "string" ? fallback && getlatin1(name) : basename9(fallback);
57454
+ var fallbackName = typeof fallback !== "string" ? fallback && getlatin1(name) : basename10(fallback);
57451
57455
  var hasFallback = typeof fallbackName === "string" && fallbackName !== name;
57452
57456
  if (hasFallback || !isQuotedString || hasHexEscape(name)) {
57453
57457
  params["filename*"] = name;
@@ -57567,7 +57571,7 @@ var require_content_disposition = __commonJS({
57567
57571
  this.type = type;
57568
57572
  this.parameters = parameters;
57569
57573
  }
57570
- function basename9(path38) {
57574
+ function basename10(path38) {
57571
57575
  const normalized = path38.replaceAll("\\", "/");
57572
57576
  let end = normalized.length;
57573
57577
  while (end > 0 && normalized[end - 1] === "/") {
@@ -104489,7 +104493,7 @@ var _CI_VENDOR_COUNT_FOR_TEST = vendors_default.length;
104489
104493
  var SESSION_ID = (0, import_node_crypto3.randomUUID)();
104490
104494
  function readCliVersion() {
104491
104495
  if (true) {
104492
- return "0.16.1";
104496
+ return "0.17.0";
104493
104497
  }
104494
104498
  return "0.0.0";
104495
104499
  }
@@ -105877,7 +105881,7 @@ var import_node_path5 = __toESM(require("node:path"));
105877
105881
  var import_semver = __toESM(require_semver2());
105878
105882
 
105879
105883
  // ../tool-server/package.json
105880
- var version3 = "0.16.1";
105884
+ var version3 = "0.17.0";
105881
105885
 
105882
105886
  // ../tool-server/src/utils/update-checker.ts
105883
105887
  var import_update_core = __toESM(require_dist4());
@@ -114038,6 +114042,27 @@ function sendCommand(api, cmd) {
114038
114042
  ws.once("open", () => ws.send(payload));
114039
114043
  }
114040
114044
  }
114045
+ function setPointerVisible(api, show, signal) {
114046
+ return pointerPost(api, { show }, signal);
114047
+ }
114048
+ function setPointerTrail(api, trail, signal) {
114049
+ return pointerPost(api, { trail }, signal);
114050
+ }
114051
+ async function pointerPost(api, body, signal) {
114052
+ try {
114053
+ const res = await fetch(`${api.apiUrl}/api/pointer`, {
114054
+ method: "POST",
114055
+ headers: { "Content-Type": "application/json" },
114056
+ body: JSON.stringify(body),
114057
+ signal
114058
+ });
114059
+ if (!res.ok) return false;
114060
+ const parsed = await res.json().catch(() => null);
114061
+ return parsed?.status === "ok";
114062
+ } catch {
114063
+ return false;
114064
+ }
114065
+ }
114041
114066
  async function simulatorPost(toolLabel, api, endpoint, reqBody, signal, fallbackHint) {
114042
114067
  let res;
114043
114068
  try {
@@ -126869,6 +126894,7 @@ Use when you need to zoom in or out on a map, image, or zoomable view. Returns {
126869
126894
 
126870
126895
  // ../tool-server/src/tools/gesture-rotate/index.ts
126871
126896
  init_zod();
126897
+ init_src();
126872
126898
  var zodSchema22 = external_exports.object({
126873
126899
  udid: external_exports.string().describe("Target device id from `list-devices` (iOS UDID or Android serial)."),
126874
126900
  centerX: external_exports.number().describe(
@@ -126877,13 +126903,33 @@ var zodSchema22 = external_exports.object({
126877
126903
  centerY: external_exports.number().describe(
126878
126904
  "Center of rotation, vertical: normalized 0.0\u20131.0 (fraction of screen height, not pixels)"
126879
126905
  ),
126880
- radius: external_exports.number().describe(
126881
- "Distance from center to each finger: normalized 0.0\u20131.0 (fraction of screen, not pixels). E.g. 0.15 = fingers placed 15% of screen away from center."
126906
+ radius: external_exports.number().optional().describe(
126907
+ "Distance from center to each finger: normalized 0.0\u20131.0 (fraction of screen, not pixels). E.g. 0.15 = fingers placed 15% of screen away from center. One value for both axes, so on a non-square screen the orbit is a physical ellipse \u2014 pass radiusX+radiusY instead for a true circle. Required unless radiusX and radiusY are given."
126908
+ ),
126909
+ radiusX: external_exports.number().optional().describe(
126910
+ "Per-axis finger distance, horizontal: normalized 0.0\u20131.0 fraction of screen WIDTH. Give both radiusX and radiusY (they override radius) with radiusX\xB7screenWidth = radiusY\xB7screenHeight for a physically circular orbit \u2014 constant finger separation, no pinch coupled into the turn."
126911
+ ),
126912
+ radiusY: external_exports.number().optional().describe(
126913
+ "Per-axis finger distance, vertical: normalized 0.0\u20131.0 fraction of screen HEIGHT. Always paired with radiusX \u2014 see radiusX."
126882
126914
  ),
126883
126915
  startAngle: external_exports.number().describe("Starting angle in degrees (0 = right, 90 = down)"),
126884
126916
  endAngle: external_exports.number().describe("Ending angle in degrees. endAngle > startAngle = clockwise."),
126885
126917
  durationMs: external_exports.number().optional().describe("Total gesture duration in milliseconds (default 300)")
126886
- });
126918
+ }).refine((p) => p.radiusX === void 0 === (p.radiusY === void 0), {
126919
+ message: "radiusX and radiusY must be given together (one physical radius, two normalizations)"
126920
+ }).refine((p) => p.radius !== void 0 || p.radiusX !== void 0, {
126921
+ message: "Pass radius, or both radiusX and radiusY."
126922
+ });
126923
+ var inputSchema = {
126924
+ ...zodObjectToJsonSchema(zodSchema22),
126925
+ anyOf: [
126926
+ { required: ["radiusX", "radiusY"] },
126927
+ {
126928
+ required: ["radius"],
126929
+ not: { anyOf: [{ required: ["radiusX"] }, { required: ["radiusY"] }] }
126930
+ }
126931
+ ]
126932
+ };
126887
126933
  var capability14 = {
126888
126934
  apple: { simulator: true, device: true },
126889
126935
  appleRemote: { simulator: true },
@@ -126891,32 +126937,51 @@ var capability14 = {
126891
126937
  };
126892
126938
  var gestureRotateTool = {
126893
126939
  id: "gesture-rotate",
126894
- description: `Send a two-finger circular arc gesture to rotate on-screen content by a specified angle. Two fingers are placed opposite each other at a fixed radius from the center, then swept from startAngle to endAngle degrees. All positions and radius are normalized 0.0\u20131.0 (fractions of screen width/height, not pixels)\u2014same coordinate space as gesture-tap and gesture-swipe.
126895
- endAngle > startAngle = clockwise rotation. Typical values: radius 0.15, startAngle 0, endAngle 90 for a 90\xB0 clockwise turn.
126940
+ description: `Send a two-finger circular arc gesture to rotate on-screen content by a specified angle. Two fingers are placed opposite each other at a fixed radius from the center, then swept from startAngle to endAngle degrees. All positions and radii are normalized 0.0\u20131.0 (fractions of screen width/height, not pixels)\u2014same coordinate space as gesture-tap and gesture-swipe.
126941
+ endAngle > startAngle = clockwise rotation. Typical values: radius 0.15, startAngle 0, endAngle 90 for a 90\xB0 clockwise turn. A single radius applies to both axes, so on a non-square screen it traces a physical ellipse (finger separation varies through the turn); pass radiusX+radiusY (fractions of width/height with radiusX\xB7width = radiusY\xB7height) for a physically circular orbit instead.
126896
126942
  Auto-generates interpolated frames at ~60fps.
126897
126943
  Unlike gesture-pinch which moves fingers linearly to zoom, this orbits fingers in an arc to change orientation.
126898
126944
  Use when you need to rotate a map, image picker, or any rotateable UI element. Returns { rotated: true, timestampMs }. Fails if the simulator-server / emulator backend is not reachable for the given device.`,
126899
126945
  zodSchema: zodSchema22,
126946
+ inputSchema,
126900
126947
  capability: capability14,
126901
126948
  services: (params) => ({
126902
126949
  simulatorServer: simulatorServerRef(resolveDevice(params.udid))
126903
126950
  }),
126904
- async execute(services, params) {
126951
+ async execute(services, params, ctx) {
126905
126952
  const api = services.simulatorServer;
126906
126953
  const duration3 = params.durationMs ?? 300;
126907
126954
  const steps = Math.max(1, Math.round(duration3 / 16));
126955
+ const radiusX = params.radiusX ?? params.radius;
126956
+ const radiusY = params.radiusY ?? params.radius;
126908
126957
  let timestampMs = 0;
126958
+ let lastX1 = 0;
126959
+ let lastY1 = 0;
126960
+ let lastX2 = 0;
126961
+ let lastY2 = 0;
126909
126962
  for (let i = 0; i <= steps; i++) {
126963
+ if (ctx?.signal?.aborted) {
126964
+ if (i > 0) sendTouchEvent(api, "Up", lastX1, lastY1, lastX2, lastY2);
126965
+ const err = new Error(
126966
+ `gesture-rotate aborted \u2014 cancelled mid-gesture after ${i} of ${steps + 1} frames`
126967
+ );
126968
+ err.name = "AbortError";
126969
+ throw err;
126970
+ }
126910
126971
  const t = i / steps;
126911
126972
  const angleDeg = params.startAngle + (params.endAngle - params.startAngle) * t;
126912
126973
  const angleRad = angleDeg * Math.PI / 180;
126913
- const x1 = params.centerX + params.radius * Math.cos(angleRad);
126914
- const y1 = params.centerY + params.radius * Math.sin(angleRad);
126915
- const x2 = params.centerX - params.radius * Math.cos(angleRad);
126916
- const y2 = params.centerY - params.radius * Math.sin(angleRad);
126974
+ const x1 = params.centerX + radiusX * Math.cos(angleRad);
126975
+ const y1 = params.centerY + radiusY * Math.sin(angleRad);
126976
+ const x2 = params.centerX - radiusX * Math.cos(angleRad);
126977
+ const y2 = params.centerY - radiusY * Math.sin(angleRad);
126917
126978
  const type = i === 0 ? "Down" : i === steps ? "Up" : "Move";
126918
126979
  if (i === 0) timestampMs = Date.now();
126919
126980
  sendTouchEvent(api, type, x1, y1, x2, y2);
126981
+ lastX1 = x1;
126982
+ lastY1 = y1;
126983
+ lastX2 = x2;
126984
+ lastY2 = y2;
126920
126985
  if (i < steps) await sleep(16);
126921
126986
  }
126922
126987
  return { rotated: true, timestampMs };
@@ -133994,7 +134059,7 @@ Allowed tools and their args (udid is auto-injected, do NOT include it in args):
133994
134059
  gesture-drag: { fromX: number, fromY: number, toX: number, toY: number, durationMs?: number } [chromium only]
133995
134060
  gesture-custom: { events: [{ type: "Down"|"Move"|"Up", x: number, y: number, x2?: number, y2?: number, delayMs?: number }], interpolate?: number } [ios/android]
133996
134061
  gesture-pinch: { centerX: number, centerY: number, startDistance: number, endDistance: number, endCenterX?: number, endCenterY?: number, angle?: number, durationMs?: number } [ios/android]
133997
- gesture-rotate: { centerX: number, centerY: number, radius: number, startAngle: number, endAngle: number, durationMs?: number } [ios/android]
134062
+ gesture-rotate: { centerX: number, centerY: number, radius?: number, radiusX?: number, radiusY?: number, startAngle: number, endAngle: number, durationMs?: number } [ios/android]
133998
134063
  button: { button: "home"|"back"|"power"|"volumeUp"|"volumeDown"|"appSwitch"|"actionButton" } [ios/android]
133999
134064
  keyboard: { text?: string, key?: string, delayMs?: number } (key pressed after text; TV: text only) [ios/android/chromium/vega/tv]
134000
134065
  text supports {{secret:<NAME>}} placeholders, resolved server-side from ARGENT_SECRET_<NAME> env vars (prefix mandatory) \u2014 credentials never enter agent context
@@ -142986,6 +143051,8 @@ function clearLiveState2(state3) {
142986
143051
  state3.pendingChild = null;
142987
143052
  state3.frameStream = null;
142988
143053
  state3.lastFrameStreamError = null;
143054
+ state3.pointerDisable = null;
143055
+ state3.pointerFailed = false;
142989
143056
  state3.recordingTimedOut = false;
142990
143057
  state3.recordingExitedUnexpectedly = false;
142991
143058
  state3.lastExitInfo = null;
@@ -143039,6 +143106,8 @@ var screenRecordingSessionBlueprint = {
143039
143106
  trimStatic: true,
143040
143107
  framesWritten: 0,
143041
143108
  trimmedAnyFrames: false,
143109
+ pointerDisable: null,
143110
+ pointerFailed: false,
143042
143111
  wallClockStartMs: null,
143043
143112
  wallClockEndMs: null,
143044
143113
  timeLimitSeconds: null,
@@ -143061,6 +143130,12 @@ var screenRecordingSessionBlueprint = {
143061
143130
  state3.pumpTimer = null;
143062
143131
  }
143063
143132
  state3.frameStream?.close();
143133
+ if (state3.pointerDisable) {
143134
+ const disable = state3.pointerDisable;
143135
+ state3.pointerDisable = null;
143136
+ await disable().catch(() => {
143137
+ });
143138
+ }
143064
143139
  if (state3.pendingChild) {
143065
143140
  try {
143066
143141
  state3.pendingChild.kill("SIGKILL");
@@ -143593,6 +143668,12 @@ function startPump(api, stream) {
143593
143668
  }
143594
143669
  }, FRAME_INTERVAL_MS);
143595
143670
  }
143671
+ async function disablePointer(api) {
143672
+ const disable = api.pointerDisable;
143673
+ api.pointerDisable = null;
143674
+ if (disable) await disable().catch(() => {
143675
+ });
143676
+ }
143596
143677
  function stopPump(api) {
143597
143678
  if (api.pumpTimer) {
143598
143679
  clearInterval(api.pumpTimer);
@@ -143687,6 +143768,7 @@ async function startCaptureLocked(api, params) {
143687
143768
  api.recordingTimedOut = false;
143688
143769
  api.recordingExitedUnexpectedly = false;
143689
143770
  api.pendingRetrieval = false;
143771
+ api.pointerFailed = false;
143690
143772
  api.lastExitInfo = null;
143691
143773
  api.lastFrameStreamError = null;
143692
143774
  api.outputFile = outputFile;
@@ -143702,16 +143784,6 @@ async function startCaptureLocked(api, params) {
143702
143784
  api.timeLimitSeconds = params.timeLimitSeconds;
143703
143785
  registerActiveScreenRecording(api.deviceId, api.wallClockStartMs, params.timeLimitSeconds);
143704
143786
  startPump(api, stream);
143705
- api.recordingTimeout = setTimeout(() => {
143706
- api.recordingTimeout = null;
143707
- if (api.captureProcess !== child) return;
143708
- api.recordingTimedOut = true;
143709
- api.recordingActive = false;
143710
- api.wallClockEndMs = Date.now();
143711
- api.pendingRetrieval = true;
143712
- markScreenRecordingFinalized(api.deviceId, `it hit its ${params.timeLimitSeconds}s time limit`);
143713
- finalizeCapture(api);
143714
- }, params.timeLimitSeconds * 1e3);
143715
143787
  child.on("exit", (code, signal) => {
143716
143788
  if (api.captureProcess !== child) return;
143717
143789
  api.lastExitInfo = { code, signal };
@@ -143726,9 +143798,25 @@ async function startCaptureLocked(api, params) {
143726
143798
  api.wallClockEndMs = Date.now();
143727
143799
  api.pendingRetrieval = true;
143728
143800
  stopPump(api);
143801
+ void disablePointer(api);
143729
143802
  markScreenRecordingFinalized(api.deviceId, "the recording process exited unexpectedly");
143730
143803
  }
143731
143804
  });
143805
+ if (params.pointer) {
143806
+ api.pointerDisable = params.pointer.disable;
143807
+ api.pointerFailed = !await params.pointer.enable();
143808
+ }
143809
+ api.recordingTimeout = setTimeout(() => {
143810
+ api.recordingTimeout = null;
143811
+ if (api.captureProcess !== child) return;
143812
+ api.recordingTimedOut = true;
143813
+ api.recordingActive = false;
143814
+ api.wallClockEndMs = Date.now();
143815
+ api.pendingRetrieval = true;
143816
+ markScreenRecordingFinalized(api.deviceId, `it hit its ${params.timeLimitSeconds}s time limit`);
143817
+ finalizeCapture(api);
143818
+ void disablePointer(api);
143819
+ }, params.timeLimitSeconds * 1e3);
143732
143820
  return {
143733
143821
  status: "recording",
143734
143822
  timeLimitSeconds: params.timeLimitSeconds,
@@ -143795,6 +143883,7 @@ async function stopCapture(api) {
143795
143883
  const endedEarly = api.recordingTimedOut || api.recordingExitedUnexpectedly;
143796
143884
  const streamError = api.frameStream?.error ?? api.lastFrameStreamError ?? null;
143797
143885
  const watermarkSkipped = api.watermarkSkipped;
143886
+ const pointerFailed = api.pointerFailed;
143798
143887
  let warning;
143799
143888
  try {
143800
143889
  const child = api.captureProcess;
@@ -143828,6 +143917,12 @@ async function stopCapture(api) {
143828
143917
  if (watermarkSkipped) {
143829
143918
  warning = [warning, `The watermark was not applied (${watermarkSkipped}).`].filter(Boolean).join(" ");
143830
143919
  }
143920
+ if (pointerFailed) {
143921
+ warning = [
143922
+ warning,
143923
+ "The touch visualizer could not be enabled on simulator-server, so touches are not shown in this video."
143924
+ ].filter(Boolean).join(" ");
143925
+ }
143831
143926
  const size = await statNonEmptyOutput(outputFile, "screen_recording_stop");
143832
143927
  const wallClockMs = startedAtMs === null ? null : (api.wallClockEndMs ?? Date.now()) - startedAtMs;
143833
143928
  const durationMs = trimStatic ? Math.round(api.framesWritten / OUTPUT_FPS2 * 1e3) : wallClockMs;
@@ -143846,6 +143941,7 @@ async function stopCapture(api) {
143846
143941
  throw err;
143847
143942
  } finally {
143848
143943
  stopPump(api);
143944
+ await disablePointer(api);
143849
143945
  api.recordingActive = false;
143850
143946
  api.stopPending = false;
143851
143947
  api.pendingRetrieval = false;
@@ -143853,6 +143949,7 @@ async function stopCapture(api) {
143853
143949
  api.outputFile = null;
143854
143950
  api.logoFile = null;
143855
143951
  api.watermarkSkipped = null;
143952
+ api.pointerFailed = false;
143856
143953
  api.framesWritten = 0;
143857
143954
  api.trimmedAnyFrames = false;
143858
143955
  api.wallClockStartMs = null;
@@ -143871,6 +143968,8 @@ async function stopCapture(api) {
143871
143968
  // ../tool-server/src/tools/screen-recording/screen-recording-start.ts
143872
143969
  var DEFAULT_TIME_LIMIT_SECONDS = 180;
143873
143970
  var MAX_TIME_LIMIT_SECONDS = 600;
143971
+ var POINTER_TRAIL_LENGTH = 8;
143972
+ var POINTER_REQUEST_TIMEOUT_MS = 2e3;
143874
143973
  var zodSchema51 = external_exports.object({
143875
143974
  udid: external_exports.string().describe("Target device id from `list-devices` (iOS Simulator UDID or Android serial)."),
143876
143975
  timeLimitSeconds: external_exports.number().int().min(1).max(MAX_TIME_LIMIT_SECONDS).optional().describe(
@@ -143878,6 +143977,9 @@ var zodSchema51 = external_exports.object({
143878
143977
  ),
143879
143978
  trimStatic: external_exports.boolean().optional().describe(
143880
143979
  "Default true. Collapse stretches where the screen does not change: the first second of each still stretch is kept, then unchanged frames are dropped until something moves again, so a long recording with brief activity comes back short instead of full of dead air. The returned durationMs is the trimmed length; wallClockMs/trimmedMs report what was removed. Set false to keep a faithful real-time recording."
143980
+ ),
143981
+ showTouches: external_exports.boolean().optional().describe(
143982
+ "Default true. Draw simulator-server's touch visualizer into the recording: a pulse marks each tap, a comet trail follows swipes and drags, and paired markers show two-finger pinch/rotate, so the video makes clear where every interaction landed. Set false to record the raw screen with no overlay."
143881
143983
  )
143882
143984
  });
143883
143985
  var capability26 = {
@@ -143890,6 +143992,7 @@ function createScreenRecordingStartTool(registry2) {
143890
143992
  capability: capability26,
143891
143993
  description: `Start recording the device screen to a video file (h264 mp4, 30fps at the device's native resolution).
143892
143994
  By default stretches where the screen does not change are trimmed out (see trimStatic), so a long session with only brief activity comes back as a short clip instead of minutes of dead air.
143995
+ By default every tap, swipe, drag, pinch and rotate is drawn into the video as an on-screen touch marker (see showTouches), so the recording shows where each interaction landed.
143893
143996
  The recording keeps running across other tool calls (every result carries a reminder) until \`screen-recording-stop\` is called or timeLimitSeconds elapses \u2014 immediately after starting, set yourself a reminder/wakeup for the expected end of the recording so it is never left running.
143894
143997
  Use when the user wants a video of an interaction, animation, or app behavior \u2014 for a single still frame use \`screenshot\` instead.
143895
143998
  Returns { status: "recording", timeLimitSeconds, outputFile } \u2014 the video is retrieved later by \`screen-recording-stop\`, not by reading outputFile directly.
@@ -143934,21 +144037,53 @@ Fails if a recording is already running on the device, the device is not booted,
143934
144037
  }
143935
144038
  );
143936
144039
  }
144040
+ const showTouches = params.showTouches ?? true;
144041
+ const pointer = showTouches ? makePointerControl(simulator) : void 0;
143937
144042
  return startCapture(api, {
143938
144043
  streamUrl,
143939
144044
  timeLimitSeconds,
143940
144045
  watermark: isFeatureEnabled("video-watermark"),
143941
- trimStatic: params.trimStatic ?? true
144046
+ trimStatic: params.trimStatic ?? true,
144047
+ pointer
143942
144048
  });
143943
144049
  }
143944
144050
  };
143945
144051
  }
144052
+ function makePointerControl(simulator) {
144053
+ let enabling = null;
144054
+ return {
144055
+ async enable() {
144056
+ const run2 = (async () => {
144057
+ await setPointerTrail(
144058
+ simulator,
144059
+ POINTER_TRAIL_LENGTH,
144060
+ AbortSignal.timeout(POINTER_REQUEST_TIMEOUT_MS)
144061
+ );
144062
+ return setPointerVisible(simulator, true, AbortSignal.timeout(POINTER_REQUEST_TIMEOUT_MS));
144063
+ })();
144064
+ enabling = run2;
144065
+ try {
144066
+ return await run2;
144067
+ } finally {
144068
+ if (enabling === run2) enabling = null;
144069
+ }
144070
+ },
144071
+ async disable() {
144072
+ const pending = enabling;
144073
+ if (pending) await pending.catch(() => {
144074
+ });
144075
+ await setPointerVisible(simulator, false, AbortSignal.timeout(POINTER_REQUEST_TIMEOUT_MS));
144076
+ }
144077
+ };
144078
+ }
143946
144079
 
143947
144080
  // ../tool-server/src/tools/screen-recording/screen-recording-stop.ts
144081
+ var import_node_path20 = require("node:path");
143948
144082
  init_zod();
143949
144083
  var zodSchema52 = external_exports.object({
143950
144084
  udid: external_exports.string().describe("Target device id from `list-devices` (iOS Simulator UDID or Android serial).")
143951
144085
  });
144086
+ var RECORDINGS_DIR = ".argent/recordings";
143952
144087
  var capability27 = {
143953
144088
  apple: { simulator: true },
143954
144089
  android: { emulator: true, device: true, unknown: true }
@@ -143972,7 +144107,13 @@ Fails if no recording (running or finished-but-unretrieved) exists for the given
143972
144107
  assertSupported("screen-recording-stop", capability27, device);
143973
144108
  const stopped = await stopCapture(api);
143974
144109
  const artifacts = requireArtifacts(ctx);
143975
- const video = await artifacts.register(stopped.outputFile, { mimeType: "video/mp4" });
144110
+ const video = await artifacts.register(stopped.outputFile, {
144111
+ mimeType: "video/mp4",
144112
+ // Drop the internal `argent-` temp prefix so the saved file reads cleanly
144113
+ // as `.argent/recordings/screen-recording-<device>-<ts>.mp4`.
144114
+ filename: (0, import_node_path20.basename)(stopped.outputFile).replace(/^argent-/, ""),
144115
+ saveDir: RECORDINGS_DIR
144116
+ });
143976
144117
  const result = { video, durationMs: stopped.durationMs };
143977
144118
  if (stopped.wallClockMs !== void 0) result.wallClockMs = stopped.wallClockMs;
143978
144119
  if (stopped.trimmedMs !== void 0) result.trimmedMs = stopped.trimmedMs;
@@ -145848,6 +145989,123 @@ var fs39 = __toESM(require("node:fs/promises"));
145848
145989
  init_src();
145849
145990
  var import_yaml = __toESM(require_dist5());
145850
145991
  init_src();
145992
+
145993
+ // ../tool-server/src/tools/flows/flow-pinch-geometry.ts
145994
+ var PINCH_RATIO_PER_GESTURE = 4;
145995
+ var PINCH_SETTLE_MS = 250;
145996
+ var TARGET_START_FRACTION = 0.82;
145997
+ var SCREEN_EDGE_INSET = 0.02;
145998
+ var MIN_VIABLE_TRAVEL = 0.03;
145999
+ function systemEdgeGuards(device) {
146000
+ return device.platform === "android" ? { left: 0.13, right: 0.13, top: 0.08, bottom: 0.08 } : { left: 0.08, right: 0.08, top: 0.08, bottom: 0.08 };
146001
+ }
146002
+ function decomposePinch(scale) {
146003
+ const n = Math.max(
146004
+ 1,
146005
+ Math.ceil(Math.abs(Math.log(scale)) / Math.log(PINCH_RATIO_PER_GESTURE) - 1e-9)
146006
+ );
146007
+ return { n, per: scale ** (1 / n) };
146008
+ }
146009
+ function clamp2(v, lo, hi) {
146010
+ return v < lo ? lo : v > hi ? hi : v;
146011
+ }
146012
+ function buildAxisCandidate(input) {
146013
+ const { angle, center, targetSpan, per, guards } = input;
146014
+ const c = angle === 0 ? center.x : center.y;
146015
+ const gLow = angle === 0 ? guards.left : guards.top;
146016
+ const gHigh = angle === 0 ? guards.right : guards.bottom;
146017
+ const p = angle === 0 ? center.y : center.x;
146018
+ const pLow = angle === 0 ? guards.top : guards.left;
146019
+ const pHigh = angle === 0 ? guards.bottom : guards.right;
146020
+ const screenEndSpan = 1 - 2 * SCREEN_EDGE_INSET;
146021
+ const edgeSafeStartSpan = 2 * Math.max(0, Math.min(c - gLow, 1 - gHigh - c));
146022
+ const insetStartSpan = 2 * Math.max(0, Math.min(c - SCREEN_EDGE_INSET, 1 - SCREEN_EDGE_INSET - c));
146023
+ const screenStartLimit = edgeSafeStartSpan > 0 ? Math.min(screenEndSpan, edgeSafeStartSpan) : Math.min(screenEndSpan, insetStartSpan);
146024
+ const startLimit = targetSpan !== void 0 ? Math.min(screenStartLimit, targetSpan * TARGET_START_FRACTION) : screenStartLimit;
146025
+ const start2 = per > 1 ? Math.min(startLimit, screenEndSpan / per) : startLimit;
146026
+ const end = Math.min(screenEndSpan, start2 * per);
146027
+ const travel = Math.abs(end - start2);
146028
+ if (!(travel > 0)) return void 0;
146029
+ const endCenter = clamp2(c, SCREEN_EDGE_INSET + end / 2, 1 - SCREEN_EDGE_INSET - end / 2);
146030
+ const downLow = c - start2 / 2;
146031
+ const downHigh = c + start2 / 2;
146032
+ const axisSafe = downLow >= gLow && downHigh <= 1 - gHigh;
146033
+ const perpSafe = p >= pLow && p <= 1 - pHigh;
146034
+ const clearance = Math.min(downLow - gLow, 1 - gHigh - downHigh, p - pLow, 1 - pHigh - p);
146035
+ return {
146036
+ angle,
146037
+ start: start2,
146038
+ end,
146039
+ endCenter,
146040
+ travel,
146041
+ viable: travel >= MIN_VIABLE_TRAVEL,
146042
+ axisSafe,
146043
+ fullyEdgeSafe: axisSafe && perpSafe,
146044
+ clearance
146045
+ };
146046
+ }
146047
+ function selectPinchCandidate(candidates) {
146048
+ if (candidates.length === 0) return void 0;
146049
+ const rank2 = (c) => c.fullyEdgeSafe ? 0 : c.axisSafe ? 1 : 2;
146050
+ return [...candidates].sort((a, b) => {
146051
+ const byViable = Number(b.viable) - Number(a.viable);
146052
+ if (byViable !== 0) return byViable;
146053
+ const byRank = rank2(a) - rank2(b);
146054
+ if (byRank !== 0) return byRank;
146055
+ if (a.fullyEdgeSafe && b.fullyEdgeSafe) return b.travel - a.travel;
146056
+ return b.clearance - a.clearance || b.travel - a.travel;
146057
+ })[0];
146058
+ }
146059
+
146060
+ // ../tool-server/src/tools/flows/flow-rotate-geometry.ts
146061
+ var ROTATE_MS_PER_90_DEG = 300;
146062
+ var MAX_DERIVED_ROTATE_MS = 1e4;
146063
+ var MAX_ROTATE_BY_DEG = MAX_DERIVED_ROTATE_MS / ROTATE_MS_PER_90_DEG * 90;
146064
+ function deriveRotateDurationMs(by) {
146065
+ return Math.round(ROTATE_MS_PER_90_DEG * Math.max(1, Math.abs(by) / 90));
146066
+ }
146067
+ function buildRotateCandidate(input) {
146068
+ const { startAngle, center, targetSpan, guards, aspect } = input;
146069
+ const xClear = Math.min(center.x - SCREEN_EDGE_INSET, 1 - SCREEN_EDGE_INSET - center.x);
146070
+ const yClear = Math.min(center.y - SCREEN_EDGE_INSET, 1 - SCREEN_EDGE_INSET - center.y);
146071
+ const screenRx = Math.min(xClear, yClear / aspect);
146072
+ const targetRx = targetSpan === void 0 ? Infinity : startAngle === 0 ? targetSpan * TARGET_START_FRACTION / 2 : targetSpan * TARGET_START_FRACTION / 2 / aspect;
146073
+ const radiusX = Math.min(screenRx, targetRx);
146074
+ if (!(radiusX > 0)) return void 0;
146075
+ const radiusY = radiusX * aspect;
146076
+ const c = startAngle === 0 ? center.x : center.y;
146077
+ const half = startAngle === 0 ? radiusX : radiusY;
146078
+ const gLow = startAngle === 0 ? guards.left : guards.top;
146079
+ const gHigh = startAngle === 0 ? guards.right : guards.bottom;
146080
+ const p = startAngle === 0 ? center.y : center.x;
146081
+ const pLow = startAngle === 0 ? guards.top : guards.left;
146082
+ const pHigh = startAngle === 0 ? guards.bottom : guards.right;
146083
+ const downLow = c - half;
146084
+ const downHigh = c + half;
146085
+ const axisSafe = downLow >= gLow && downHigh <= 1 - gHigh;
146086
+ const perpSafe = p >= pLow && p <= 1 - pHigh;
146087
+ const clearance = Math.min(downLow - gLow, 1 - gHigh - downHigh, p - pLow, 1 - pHigh - p);
146088
+ return {
146089
+ startAngle,
146090
+ radiusX,
146091
+ radiusY,
146092
+ axisSafe,
146093
+ fullyEdgeSafe: axisSafe && perpSafe,
146094
+ clearance
146095
+ };
146096
+ }
146097
+ function selectRotateCandidate(candidates) {
146098
+ if (candidates.length === 0) return void 0;
146099
+ const rank2 = (c) => c.fullyEdgeSafe ? 0 : c.axisSafe ? 1 : 2;
146100
+ return [...candidates].sort((a, b) => {
146101
+ const byRank = rank2(a) - rank2(b);
146102
+ if (byRank !== 0) return byRank;
146103
+ if (a.fullyEdgeSafe && b.fullyEdgeSafe) return b.radiusX - a.radiusX;
146104
+ return b.clearance - a.clearance || b.radiusX - a.radiusX;
146105
+ })[0];
146106
+ }
146107
+
146108
+ // ../tool-server/src/tools/flows/flow-utils.ts
145851
146109
  var FLOWS_DIR_NAME = path29.join(".argent", "flows");
145852
146110
  var activeFlowName = null;
145853
146111
  var activeProjectRoot = null;
@@ -146157,6 +146415,10 @@ function toYamlStep(step) {
146157
146415
  return {
146158
146416
  pinch: step.selector ? { on: selectorToYaml(step.selector), scale: step.scale } : { scale: step.scale }
146159
146417
  };
146418
+ case "rotate":
146419
+ return {
146420
+ rotate: step.selector ? { on: selectorToYaml(step.selector), by: step.by } : { by: step.by }
146421
+ };
146160
146422
  case "snapshot":
146161
146423
  return step.maxMismatch === void 0 ? { snapshot: step.name } : { snapshot: { name: step.name, maxMismatch: step.maxMismatch } };
146162
146424
  case "tool":
@@ -146430,6 +146692,7 @@ var STEP_DIRECTIVE_KEYS = [
146430
146692
  "wait",
146431
146693
  "scroll-to",
146432
146694
  "pinch",
146695
+ "rotate",
146433
146696
  "snapshot"
146434
146697
  ];
146435
146698
  function parseTapTimes(raw, entry) {
@@ -146548,6 +146811,31 @@ function parsePinch(body, entry) {
146548
146811
  if (obj.on !== void 0) step.selector = parseSelector(obj.on, "pinch.on");
146549
146812
  return step;
146550
146813
  }
146814
+ function parseRotate(body, entry) {
146815
+ if (body === null || typeof body !== "object") {
146816
+ badEntry(
146817
+ entry,
146818
+ 'rotate takes an options map \u2014 e.g. rotate: { on: "Map", by: 90 } (an angle is required)'
146819
+ );
146820
+ }
146821
+ const obj = body;
146822
+ if (obj.text !== void 0 || obj.id !== void 0 || obj.identifier !== void 0 || obj.role !== void 0) {
146823
+ badEntry(entry, 'rotate takes a nested selector \u2014 e.g. rotate: { on: "Map", by: 90 }');
146824
+ }
146825
+ rejectUnknownKeys(entry, obj, ["on", "by"], "rotate");
146826
+ if (typeof obj.by !== "number" || !Number.isFinite(obj.by) || obj.by === 0) {
146827
+ badEntry(entry, "rotate.by must be a finite non-zero number of degrees (+CW, \u2212CCW)");
146828
+ }
146829
+ if (Math.abs(obj.by) > MAX_ROTATE_BY_DEG) {
146830
+ badEntry(
146831
+ entry,
146832
+ `rotate.by must be within \xB1${MAX_ROTATE_BY_DEG}\xB0 \u2014 one continuous gesture at ~300\xB0/s (10 s max)`
146833
+ );
146834
+ }
146835
+ const step = { kind: "rotate", by: obj.by };
146836
+ if (obj.on !== void 0) step.selector = parseSelector(obj.on, "rotate.on");
146837
+ return step;
146838
+ }
146551
146839
  function parseWhenCondition(raw) {
146552
146840
  const conditionKeys = `${WAIT_CONDITIONS.join(", ")}, platform`;
146553
146841
  if (raw === null || typeof raw !== "object") {
@@ -146716,6 +147004,7 @@ function fromYamlStep(raw, whenDepth = 0) {
146716
147004
  return step;
146717
147005
  }
146718
147006
  if ("pinch" in raw) return parsePinch(raw.pinch, raw);
147007
+ if ("rotate" in raw) return parseRotate(raw.rotate, raw);
146719
147008
  if ("snapshot" in raw) {
146720
147009
  const body = raw.snapshot;
146721
147010
  if (body !== null && typeof body === "object" && !Array.isArray(body)) {
@@ -147120,7 +147409,7 @@ function projectIosNode(node, screenW, screenH) {
147120
147409
  scrolls: role === "AXScrollArea"
147121
147410
  };
147122
147411
  }
147123
- function adaptFullHierarchyToDescribeResult(raw) {
147412
+ function adaptFullHierarchy(raw) {
147124
147413
  const windows = typeof raw === "object" && raw !== null && Array.isArray(raw.windows) ? raw.windows.map(asViewNode).filter((n) => n !== null) : [];
147125
147414
  let screenW = 0;
147126
147415
  let screenH = 0;
@@ -147137,11 +147426,12 @@ function adaptFullHierarchyToDescribeResult(raw) {
147137
147426
  flattenHoisting(win, (n) => projectIosNode(n, screenW, screenH), children);
147138
147427
  }
147139
147428
  }
147140
- return parseDescribeResult({
147429
+ const tree = parseDescribeResult({
147141
147430
  role: "AXGroup",
147142
147431
  frame: { x: 0, y: 0, width: 1, height: 1 },
147143
147432
  children
147144
147433
  });
147434
+ return screenW > 0 && screenH > 0 ? { tree, screen: { width: screenW, height: screenH } } : { tree };
147145
147435
  }
147146
147436
  var FULL_HIERARCHY_FIELDS = [
147147
147437
  "className",
@@ -147183,7 +147473,8 @@ async function queryFullHierarchyTree(registry2, device) {
147183
147473
  if (rawResult.error) {
147184
147474
  throw new Error(`getFullHierarchy failed for ${target.bundleId}: ${rawResult.error}`);
147185
147475
  }
147186
- return { tree: adaptFullHierarchyToDescribeResult(rawResult), source: "native-devtools" };
147476
+ const { tree, screen } = adaptFullHierarchy(rawResult);
147477
+ return { tree, source: "native-devtools", ...screen ? { screen } : {} };
147187
147478
  }
147188
147479
  function errMsg(err) {
147189
147480
  return err instanceof Error ? err.message : String(err);
@@ -147306,7 +147597,11 @@ async function queryAndroidFullHierarchy(registry2, device) {
147306
147597
  devtools.getScreenSize()
147307
147598
  ]);
147308
147599
  const tree = adaptFullAndroidHierarchyToDescribeResult(xml, size.width, size.height);
147309
- return { tree, source: "android-devtools" };
147600
+ return {
147601
+ tree,
147602
+ source: "android-devtools",
147603
+ ...size.width > 0 && size.height > 0 ? { screen: { width: size.width, height: size.height } } : {}
147604
+ };
147310
147605
  }
147311
147606
 
147312
147607
  // ../tool-server/src/tools/flows/flow-chromium-tree.ts
@@ -147614,6 +147909,8 @@ You can still edit the .yaml file directly afterwards to remove or reorder steps
147614
147909
  return `${n}. scroll-to: ${selectorLabel(step.target)} (${step.direction})`;
147615
147910
  case "pinch":
147616
147911
  return `${n}. pinch: scale ${step.scale}${step.selector ? ` on ${selectorLabel(step.selector)}` : ""}`;
147912
+ case "rotate":
147913
+ return `${n}. rotate: by ${step.by}\xB0${step.selector ? ` on ${selectorLabel(step.selector)}` : ""}`;
147617
147914
  case "snapshot":
147618
147915
  return `${n}. snapshot: ${step.name}`;
147619
147916
  case "tool":
@@ -147640,73 +147937,6 @@ var fs45 = __toESM(require("node:fs/promises"));
147640
147937
  var path33 = __toESM(require("node:path"));
147641
147938
  init_src();
147642
147939
 
147643
- // ../tool-server/src/tools/flows/flow-pinch-geometry.ts
147644
- var PINCH_RATIO_PER_GESTURE = 4;
147645
- var PINCH_SETTLE_MS = 250;
147646
- var TARGET_START_FRACTION = 0.82;
147647
- var SCREEN_EDGE_INSET = 0.02;
147648
- var MIN_VIABLE_TRAVEL = 0.03;
147649
- function systemEdgeGuards(device) {
147650
- return device.platform === "android" ? { left: 0.13, right: 0.13, top: 0.08, bottom: 0.08 } : { left: 0.08, right: 0.08, top: 0.08, bottom: 0.08 };
147651
- }
147652
- function decomposePinch(scale) {
147653
- const n = Math.max(
147654
- 1,
147655
- Math.ceil(Math.abs(Math.log(scale)) / Math.log(PINCH_RATIO_PER_GESTURE) - 1e-9)
147656
- );
147657
- return { n, per: scale ** (1 / n) };
147658
- }
147659
- function clamp2(v, lo, hi) {
147660
- return v < lo ? lo : v > hi ? hi : v;
147661
- }
147662
- function buildAxisCandidate(input) {
147663
- const { angle, center, targetSpan, per, guards } = input;
147664
- const c = angle === 0 ? center.x : center.y;
147665
- const gLow = angle === 0 ? guards.left : guards.top;
147666
- const gHigh = angle === 0 ? guards.right : guards.bottom;
147667
- const p = angle === 0 ? center.y : center.x;
147668
- const pLow = angle === 0 ? guards.top : guards.left;
147669
- const pHigh = angle === 0 ? guards.bottom : guards.right;
147670
- const screenEndSpan = 1 - 2 * SCREEN_EDGE_INSET;
147671
- const edgeSafeStartSpan = 2 * Math.max(0, Math.min(c - gLow, 1 - gHigh - c));
147672
- const insetStartSpan = 2 * Math.max(0, Math.min(c - SCREEN_EDGE_INSET, 1 - SCREEN_EDGE_INSET - c));
147673
- const screenStartLimit = edgeSafeStartSpan > 0 ? Math.min(screenEndSpan, edgeSafeStartSpan) : Math.min(screenEndSpan, insetStartSpan);
147674
- const startLimit = targetSpan !== void 0 ? Math.min(screenStartLimit, targetSpan * TARGET_START_FRACTION) : screenStartLimit;
147675
- const start2 = per > 1 ? Math.min(startLimit, screenEndSpan / per) : startLimit;
147676
- const end = Math.min(screenEndSpan, start2 * per);
147677
- const travel = Math.abs(end - start2);
147678
- if (!(travel > 0)) return void 0;
147679
- const endCenter = clamp2(c, SCREEN_EDGE_INSET + end / 2, 1 - SCREEN_EDGE_INSET - end / 2);
147680
- const downLow = c - start2 / 2;
147681
- const downHigh = c + start2 / 2;
147682
- const axisSafe = downLow >= gLow && downHigh <= 1 - gHigh;
147683
- const perpSafe = p >= pLow && p <= 1 - pHigh;
147684
- const clearance = Math.min(downLow - gLow, 1 - gHigh - downHigh, p - pLow, 1 - pHigh - p);
147685
- return {
147686
- angle,
147687
- start: start2,
147688
- end,
147689
- endCenter,
147690
- travel,
147691
- viable: travel >= MIN_VIABLE_TRAVEL,
147692
- axisSafe,
147693
- fullyEdgeSafe: axisSafe && perpSafe,
147694
- clearance
147695
- };
147696
- }
147697
- function selectPinchCandidate(candidates) {
147698
- if (candidates.length === 0) return void 0;
147699
- const rank2 = (c) => c.fullyEdgeSafe ? 0 : c.axisSafe ? 1 : 2;
147700
- return [...candidates].sort((a, b) => {
147701
- const byViable = Number(b.viable) - Number(a.viable);
147702
- if (byViable !== 0) return byViable;
147703
- const byRank = rank2(a) - rank2(b);
147704
- if (byRank !== 0) return byRank;
147705
- if (a.fullyEdgeSafe && b.fullyEdgeSafe) return b.travel - a.travel;
147706
- return b.clearance - a.clearance || b.travel - a.travel;
147707
- })[0];
147708
- }
147709
-
147710
147940
  // ../tool-server/src/tools/flows/flow-actions.ts
147711
147941
  var ABORTED_OUTCOME = {
147712
147942
  ok: false,
@@ -147920,16 +148150,16 @@ function offscreenHint(sel) {
147920
148150
  return `no visible element matched selector ${describeSelector(sel)} \u2014 if it is off-screen, add a scroll-to step before this one`;
147921
148151
  }
147922
148152
  async function runDirective(env, step) {
147923
- if (env.device.platform === "vega" && (step.kind === "tap" || step.kind === "long-press" || step.kind === "type" || step.kind === "scroll-to" || step.kind === "pinch")) {
148153
+ if (env.device.platform === "vega" && (step.kind === "tap" || step.kind === "long-press" || step.kind === "type" || step.kind === "scroll-to" || step.kind === "pinch" || step.kind === "rotate")) {
147924
148154
  return {
147925
148155
  ok: false,
147926
148156
  reason: `${step.kind} is a touch directive and Vega is remote-driven \u2014 move focus with \`tool: tv-remote\` steps (and type via \`tool: keyboard\`) instead`
147927
148157
  };
147928
148158
  }
147929
- if (step.kind === "pinch" && env.device.platform === "chromium") {
148159
+ if ((step.kind === "pinch" || step.kind === "rotate") && env.device.platform === "chromium") {
147930
148160
  return {
147931
148161
  ok: false,
147932
- reason: "pinch is unsupported on chromium \u2014 desktop apps have no uniform pinch-zoom mapping (they zoom via ctrl+wheel or their own controls); drive the app's zoom UI with tap/keyboard instead"
148162
+ reason: step.kind === "pinch" ? "pinch is unsupported on chromium \u2014 desktop apps have no uniform pinch-zoom mapping (they zoom via ctrl+wheel or their own controls); drive the app's zoom UI with tap/keyboard instead" : "rotate is unsupported on chromium \u2014 desktop apps have no rotate-gesture idiom; drive the app's rotate controls with tap/keyboard instead"
147933
148163
  };
147934
148164
  }
147935
148165
  switch (step.kind) {
@@ -147950,6 +148180,8 @@ async function runDirective(env, step) {
147950
148180
  }
147951
148181
  case "pinch":
147952
148182
  return runPinch(env, step);
148183
+ case "rotate":
148184
+ return runRotate(env, step);
147953
148185
  }
147954
148186
  }
147955
148187
  async function resolveTargetPoint(env, target) {
@@ -148039,6 +148271,66 @@ async function runPinch(env, step) {
148039
148271
  }
148040
148272
  return { ok: true };
148041
148273
  }
148274
+ async function fetchScreenAspect(env) {
148275
+ try {
148276
+ const { screen } = await fetchFlowTree(env.registry, env.device);
148277
+ return screen && screen.width > 0 && screen.height > 0 ? screen.width / screen.height : void 0;
148278
+ } catch {
148279
+ return void 0;
148280
+ }
148281
+ }
148282
+ async function runRotate(env, step) {
148283
+ let center = { x: 0.5, y: 0.5 };
148284
+ let frame;
148285
+ if (step.selector) {
148286
+ const resolved = await waitForFrame(env, step.selector);
148287
+ if (resolved === "aborted") return ABORTED_OUTCOME;
148288
+ if (!resolved) return { ok: false, reason: offscreenHint(step.selector) };
148289
+ frame = resolved;
148290
+ center = getDescribeTapPoint(resolved);
148291
+ }
148292
+ const aspect = await fetchScreenAspect(env);
148293
+ const guards = systemEdgeGuards(env.device);
148294
+ const candidates = [
148295
+ buildRotateCandidate({
148296
+ startAngle: 0,
148297
+ center,
148298
+ targetSpan: frame?.width,
148299
+ guards,
148300
+ aspect: aspect ?? 1
148301
+ }),
148302
+ buildRotateCandidate({
148303
+ startAngle: 90,
148304
+ center,
148305
+ targetSpan: frame?.height,
148306
+ guards,
148307
+ aspect: aspect ?? 1
148308
+ })
148309
+ ].filter((c) => c !== void 0);
148310
+ const selected = selectRotateCandidate(candidates);
148311
+ if (!selected) {
148312
+ return {
148313
+ ok: false,
148314
+ reason: `rotate found no on-screen orbit radius around (${center.x}, ${center.y})`
148315
+ };
148316
+ }
148317
+ if (env.signal?.aborted) return ABORTED_OUTCOME;
148318
+ try {
148319
+ await invokeOnDevice(env, "gesture-rotate", {
148320
+ centerX: center.x,
148321
+ centerY: center.y,
148322
+ ...aspect === void 0 ? { radius: selected.radiusX } : { radiusX: selected.radiusX, radiusY: selected.radiusY },
148323
+ startAngle: selected.startAngle,
148324
+ // endAngle > startAngle = clockwise in the tool, matching +by.
148325
+ endAngle: selected.startAngle + step.by,
148326
+ durationMs: deriveRotateDurationMs(step.by)
148327
+ });
148328
+ } catch (err) {
148329
+ if (env.signal?.aborted) return ABORTED_OUTCOME;
148330
+ throw err;
148331
+ }
148332
+ return { ok: true };
148333
+ }
148042
148334
  async function runType(env, step) {
148043
148335
  const frame = await waitForFrame(env, step.into);
148044
148336
  if (frame === "aborted") return ABORTED_OUTCOME;
@@ -150499,7 +150791,9 @@ ready; \`tool\` calls dispatch through the registry; \`tap\`/\`long-press\`/\`ty
150499
150791
  element and act on it (\`tap: { on, times: 2 }\` double-taps; \`long-press: { on, duration }\` presses and
150500
150792
  holds; \`tap\`/\`long-press\` alternatively take a raw normalized point \u2014 bare \`{ x, y }\` or \`on: { x, y }\`);
150501
150793
  \`scroll-to\` scrolls (momentum-free) until a target is visible; \`pinch\` zooms
150502
- (\`pinch: { on?, scale }\` \u2014 scale > 1 in, < 1 out; screen center when \`on\` is omitted); \`await\` waits
150794
+ (\`pinch: { on?, scale }\` \u2014 scale > 1 in, < 1 out; screen center when \`on\` is omitted); \`rotate\` is the
150795
+ two-finger rotation gesture (\`rotate: { on?, by }\` \u2014 degrees, + clockwise, within \xB13000\xB0; screen center
150796
+ when \`on\` is omitted; distinct from the \`rotate\` tool, which changes device orientation); \`await\` waits
150503
150797
  for a UI condition; \`wait\` pauses for a fixed number of milliseconds; \`assert\` checks one now; \`snapshot\`
150504
150798
  diffs a screenshot against a stored baseline (a missing baseline fails the step \u2014 set updateBaselines
150505
150799
  to adopt the current screen); \`echo\` annotates; \`run\` executes a referenced fragment inline.
@@ -150688,6 +150982,10 @@ function stepTarget(step) {
150688
150982
  const scale = `scale ${step.scale}`;
150689
150983
  return step.selector ? `${selectorLabel2(step.selector)} (${scale})` : scale;
150690
150984
  }
150985
+ case "rotate": {
150986
+ const by = `by ${step.by}\xB0`;
150987
+ return step.selector ? `${selectorLabel2(step.selector)} (${by})` : by;
150988
+ }
150691
150989
  case "snapshot":
150692
150990
  return `"${step.name}"`;
150693
150991
  default:
@@ -150870,7 +151168,8 @@ async function execLeafStep(state3, step, index, scope) {
150870
151168
  case "await":
150871
151169
  case "assert":
150872
151170
  case "scroll-to":
150873
- case "pinch": {
151171
+ case "pinch":
151172
+ case "rotate": {
150874
151173
  try {
150875
151174
  const r = await runDirective(state3, step);
150876
151175
  if (r.aborted) return { ...base, status: "skip", reason: r.reason };
@@ -150994,7 +151293,7 @@ init_zod();
150994
151293
 
150995
151294
  // ../tool-server/src/utils/workspace-reader.ts
150996
151295
  var import_promises12 = require("node:fs/promises");
150997
- var import_node_path20 = require("node:path");
151296
+ var import_node_path21 = require("node:path");
150998
151297
  var import_node_child_process27 = require("node:child_process");
150999
151298
  var import_dotenv = __toESM(require_main());
151000
151299
  async function exists(path38) {
@@ -151082,7 +151381,7 @@ async function detectLockfile(workspacePath) {
151082
151381
  const checks = await Promise.all(
151083
151382
  LOCKFILES.map(async (name) => ({
151084
151383
  name,
151085
- exists: await exists((0, import_node_path20.join)(workspacePath, name))
151384
+ exists: await exists((0, import_node_path21.join)(workspacePath, name))
151086
151385
  }))
151087
151386
  );
151088
151387
  return checks.find((c) => c.exists)?.name ?? null;
@@ -151101,7 +151400,7 @@ var CI_CONFIGS = [
151101
151400
  ];
151102
151401
  async function detectCiConfig(workspacePath) {
151103
151402
  for (const ci of CI_CONFIGS) {
151104
- if (await exists((0, import_node_path20.join)(workspacePath, ci.path))) return ci.label;
151403
+ if (await exists((0, import_node_path21.join)(workspacePath, ci.path))) return ci.label;
151105
151404
  }
151106
151405
  return null;
151107
151406
  }
@@ -151122,7 +151421,7 @@ async function detectLintStagedConfig(workspacePath, packageJson) {
151122
151421
  return packageJson["lint-staged"];
151123
151422
  }
151124
151423
  for (const file2 of LINT_STAGED_FILES) {
151125
- const full = (0, import_node_path20.join)(workspacePath, file2);
151424
+ const full = (0, import_node_path21.join)(workspacePath, file2);
151126
151425
  if (await exists(full)) {
151127
151426
  if (file2.endsWith(".json") || file2 === ".lintstagedrc") {
151128
151427
  return readJsonFile(full);
@@ -151164,7 +151463,7 @@ var CONFIG_FILES = [
151164
151463
  async function detectConfigFiles(workspacePath) {
151165
151464
  const results = await Promise.allSettled(
151166
151465
  CONFIG_FILES.map(async (file2) => {
151167
- if (await exists((0, import_node_path20.join)(workspacePath, file2))) return file2;
151466
+ if (await exists((0, import_node_path21.join)(workspacePath, file2))) return file2;
151168
151467
  return null;
151169
151468
  })
151170
151469
  );
@@ -151203,7 +151502,7 @@ async function detectEnvFiles(workspacePath) {
151203
151502
  const envFileNames = entries.filter((e) => e === ".env" || e.startsWith(".env."));
151204
151503
  const results = await Promise.all(
151205
151504
  envFileNames.map(async (name) => {
151206
- const content = await readTextFile((0, import_node_path20.join)(workspacePath, name));
151505
+ const content = await readTextFile((0, import_node_path21.join)(workspacePath, name));
151207
151506
  return {
151208
151507
  name,
151209
151508
  keys: content ? extractEnvKeys(content) : []
@@ -151230,34 +151529,34 @@ async function readWorkspaceSnapshot(workspacePath) {
151230
151529
  ciConfig,
151231
151530
  configFilesFound
151232
151531
  ] = await Promise.all([
151233
- readJsonFile((0, import_node_path20.join)(workspacePath, "package.json")),
151234
- readJsonFile((0, import_node_path20.join)(workspacePath, "app.json")),
151235
- readJsonFile((0, import_node_path20.join)(workspacePath, "eas.json")),
151236
- readJsonFile((0, import_node_path20.join)(workspacePath, "tsconfig.json")),
151237
- readTextFile((0, import_node_path20.join)(workspacePath, "metro.config.js")).then(
151238
- (r) => r ?? readTextFile((0, import_node_path20.join)(workspacePath, "metro.config.ts"))
151532
+ readJsonFile((0, import_node_path21.join)(workspacePath, "package.json")),
151533
+ readJsonFile((0, import_node_path21.join)(workspacePath, "app.json")),
151534
+ readJsonFile((0, import_node_path21.join)(workspacePath, "eas.json")),
151535
+ readJsonFile((0, import_node_path21.join)(workspacePath, "tsconfig.json")),
151536
+ readTextFile((0, import_node_path21.join)(workspacePath, "metro.config.js")).then(
151537
+ (r) => r ?? readTextFile((0, import_node_path21.join)(workspacePath, "metro.config.ts"))
151239
151538
  ),
151240
- readTextFile((0, import_node_path20.join)(workspacePath, "babel.config.js")).then(
151241
- (r) => r ?? readTextFile((0, import_node_path20.join)(workspacePath, "babel.config.cjs"))
151539
+ readTextFile((0, import_node_path21.join)(workspacePath, "babel.config.js")).then(
151540
+ (r) => r ?? readTextFile((0, import_node_path21.join)(workspacePath, "babel.config.cjs"))
151242
151541
  ),
151243
- isDirectory((0, import_node_path20.join)(workspacePath, "ios")),
151244
- isDirectory((0, import_node_path20.join)(workspacePath, "android")),
151542
+ isDirectory((0, import_node_path21.join)(workspacePath, "ios")),
151543
+ isDirectory((0, import_node_path21.join)(workspacePath, "android")),
151245
151544
  detectLockfile(workspacePath),
151246
151545
  detectEnvFiles(workspacePath),
151247
151546
  detectToolVersions(workspacePath),
151248
- listDir((0, import_node_path20.join)(workspacePath, "scripts")),
151249
- listDir((0, import_node_path20.join)(workspacePath, ".husky")),
151547
+ listDir((0, import_node_path21.join)(workspacePath, "scripts")),
151548
+ listDir((0, import_node_path21.join)(workspacePath, ".husky")),
151250
151549
  detectCiConfig(workspacePath),
151251
151550
  detectConfigFiles(workspacePath)
151252
151551
  ]);
151253
- const iosDir = (0, import_node_path20.join)(workspacePath, "ios");
151254
- const androidDir = (0, import_node_path20.join)(workspacePath, "android");
151552
+ const iosDir = (0, import_node_path21.join)(workspacePath, "ios");
151553
+ const androidDir = (0, import_node_path21.join)(workspacePath, "android");
151255
151554
  const [iosWorkspace, iosHasPodfile, makefileText, lintStagedConfig, androidHasGradle] = await Promise.all([
151256
151555
  hasIosDir ? findIosWorkspace(iosDir) : Promise.resolve(null),
151257
- exists((0, import_node_path20.join)(workspacePath, "ios", "Podfile")),
151258
- readTextFile((0, import_node_path20.join)(workspacePath, "Makefile")),
151556
+ exists((0, import_node_path21.join)(workspacePath, "ios", "Podfile")),
151557
+ readTextFile((0, import_node_path21.join)(workspacePath, "Makefile")),
151259
151558
  detectLintStagedConfig(workspacePath, packageJson),
151260
- hasAndroidDir ? exists((0, import_node_path20.join)(androidDir, "gradlew")) : Promise.resolve(false)
151559
+ hasAndroidDir ? exists((0, import_node_path21.join)(androidDir, "gradlew")) : Promise.resolve(false)
151261
151560
  ]);
151262
151561
  const metroPort = metroConfigRaw ? extractMetroPort(metroConfigRaw) : null;
151263
151562
  const makefileTargets = makefileText ? extractMakefileTargets(makefileText) : null;