@swmansion/argent 0.22.0 → 0.22.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.
- package/bin/argent-android-devtools-0.1.0.apk +0 -0
- package/bin/darwin/ax-service +0 -0
- package/bin/darwin/tvos-ax-service +0 -0
- package/bin/darwin/tvos-hid-daemon +0 -0
- package/bin/tcp/ax-service +0 -0
- package/dist/cli-cmds.mjs +1 -1
- package/dist/installer.mjs +1 -1
- package/dist/tool-server.cjs +818 -501
- package/dylibs/libArgentInjectionBootstrap.dylib +0 -0
- package/dylibs/libKeyboardPatch.dylib +0 -0
- package/dylibs/libNativeDevtoolsIos.dylib +0 -0
- package/dylibs/tcp/libArgentInjectionBootstrap.dylib +0 -0
- package/dylibs/tcp/libKeyboardPatch.dylib +0 -0
- package/dylibs/tcp/libNativeDevtoolsIos.dylib +0 -0
- package/dylibs/tvos/libArgentInjectionBootstrap.dylib +0 -0
- package/dylibs/tvos/libKeyboardPatch.dylib +0 -0
- package/dylibs/tvos/libNativeDevtoolsIos.dylib +0 -0
- package/package.json +1 -1
- package/skills/argent-create-flow/references/flow-yaml.md +8 -8
- package/skills/argent-create-flow/references/live-authoring.md +21 -3
- package/skills/argent-create-flow/references/reliability-and-recovery.md +4 -2
- package/skills/argent-device-interact/SKILL.md +1 -1
- package/skills/argent-qa-flows/SKILL.md +1 -1
package/dist/tool-server.cjs
CHANGED
|
@@ -95692,7 +95692,7 @@ var _CI_VENDOR_COUNT_FOR_TEST = vendors_default.length;
|
|
|
95692
95692
|
var SESSION_ID = (0, import_node_crypto3.randomUUID)();
|
|
95693
95693
|
function readCliVersion() {
|
|
95694
95694
|
if (true) {
|
|
95695
|
-
return "0.22.0";
|
|
95695
|
+
return "0.22.1-next.0";
|
|
95696
95696
|
}
|
|
95697
95697
|
return "0.0.0";
|
|
95698
95698
|
}
|
|
@@ -123431,13 +123431,15 @@ async function pollDescribeTree(args) {
|
|
|
123431
123431
|
let polls = 0;
|
|
123432
123432
|
let lastData = null;
|
|
123433
123433
|
let lastError;
|
|
123434
|
+
let lastAttemptSettled = false;
|
|
123434
123435
|
const outcome = (result, aborted2) => ({
|
|
123435
123436
|
result,
|
|
123436
123437
|
aborted: aborted2,
|
|
123437
123438
|
polls,
|
|
123438
123439
|
elapsedMs: Date.now() - start2,
|
|
123439
123440
|
lastData,
|
|
123440
|
-
lastError
|
|
123441
|
+
lastError,
|
|
123442
|
+
lastAttemptSettled
|
|
123441
123443
|
});
|
|
123442
123444
|
for (; ; ) {
|
|
123443
123445
|
if (signal?.aborted) return outcome(void 0, true);
|
|
@@ -123445,6 +123447,7 @@ async function pollDescribeTree(args) {
|
|
|
123445
123447
|
const settled = await settleWithin(fetchTree2(), remaining, signal);
|
|
123446
123448
|
polls += 1;
|
|
123447
123449
|
if (settled.type === "aborted") return outcome(void 0, true);
|
|
123450
|
+
lastAttemptSettled = settled.type !== "timeout";
|
|
123448
123451
|
if (settled.type === "timeout") {
|
|
123449
123452
|
if (lastData === null) {
|
|
123450
123453
|
lastError ??= `tree fetch did not complete within the ${timeoutMs}ms wait budget`;
|
|
@@ -129524,6 +129527,20 @@ var AWAIT_UI_ELEMENT_TOOL_ID = "await-ui-element";
|
|
|
129524
129527
|
function isUnmetUiWaitResult(tool, result) {
|
|
129525
129528
|
return tool === AWAIT_UI_ELEMENT_TOOL_ID && typeof result === "object" && result !== null && result.success === false;
|
|
129526
129529
|
}
|
|
129530
|
+
var WAIT_CANCELLED_NOTE = "wait was cancelled before the condition was met";
|
|
129531
|
+
var TREE_FETCH_FAILED_NOTE_PREFIX = "last tree fetch failed: ";
|
|
129532
|
+
var HIDDEN_UNREADABLE_NOTE = "could not confirm the element is hidden \u2014 the UI tree was empty or unreadable at timeout";
|
|
129533
|
+
function unmetUiWaitCause(result) {
|
|
129534
|
+
const carried = result?.cause;
|
|
129535
|
+
if (carried === "unmet" || carried === "unreadable" || carried === "cancelled") return carried;
|
|
129536
|
+
const note = result?.note;
|
|
129537
|
+
if (typeof note !== "string") return "unmet";
|
|
129538
|
+
if (note === WAIT_CANCELLED_NOTE) return "cancelled";
|
|
129539
|
+
if (note.startsWith(TREE_FETCH_FAILED_NOTE_PREFIX) || note.startsWith(HIDDEN_UNREADABLE_NOTE)) {
|
|
129540
|
+
return "unreadable";
|
|
129541
|
+
}
|
|
129542
|
+
return "unmet";
|
|
129543
|
+
}
|
|
129527
129544
|
var DEFAULT_TIMEOUT_MS3 = 5e3;
|
|
129528
129545
|
var DEFAULT_POLL_INTERVAL_MS = 400;
|
|
129529
129546
|
var zodSchema27 = external_exports.object({
|
|
@@ -129561,6 +129578,19 @@ var conditionCompleted = {
|
|
|
129561
129578
|
hidden: "UI element became hidden",
|
|
129562
129579
|
text: "UI element matched expected text"
|
|
129563
129580
|
};
|
|
129581
|
+
var DARK_TAIL_TOLERANCE_INTERVALS = 2;
|
|
129582
|
+
var DARK_TAIL_TOLERANCE_MAX_MS = 2e3;
|
|
129583
|
+
function timeoutCause(condition, lastTrustedReadAt, finalRead, pollIntervalMs) {
|
|
129584
|
+
if (lastTrustedReadAt === void 0) return "unreadable";
|
|
129585
|
+
if (finalRead === "trusted") return "unmet";
|
|
129586
|
+
if (finalRead === "untrusted" && condition === "hidden") return "unreadable";
|
|
129587
|
+
const darkTailMs = Date.now() - lastTrustedReadAt;
|
|
129588
|
+
const tolerance = Math.min(
|
|
129589
|
+
DARK_TAIL_TOLERANCE_INTERVALS * pollIntervalMs,
|
|
129590
|
+
DARK_TAIL_TOLERANCE_MAX_MS
|
|
129591
|
+
);
|
|
129592
|
+
return darkTailMs > tolerance ? "unreadable" : "unmet";
|
|
129593
|
+
}
|
|
129564
129594
|
var capability21 = {
|
|
129565
129595
|
apple: { simulator: true, device: true },
|
|
129566
129596
|
android: { emulator: true, device: true, unknown: true },
|
|
@@ -129586,7 +129616,7 @@ function appendDiagnostics(base, lastData) {
|
|
|
129586
129616
|
return extras.length === 0 ? base : `${base} (${extras.join("; ")})`;
|
|
129587
129617
|
}
|
|
129588
129618
|
function timeoutNote(params, lastTree, fetchError, lastData) {
|
|
129589
|
-
if (fetchError) return
|
|
129619
|
+
if (fetchError) return `${TREE_FETCH_FAILED_NOTE_PREFIX}${fetchError}`;
|
|
129590
129620
|
const matches2 = lastTree ? findAll(lastTree, params.selector) : [];
|
|
129591
129621
|
let base;
|
|
129592
129622
|
switch (params.condition) {
|
|
@@ -129597,7 +129627,7 @@ function timeoutNote(params, lastTree, fetchError, lastData) {
|
|
|
129597
129627
|
break;
|
|
129598
129628
|
}
|
|
129599
129629
|
case "hidden":
|
|
129600
|
-
base = matches2.some(isVisible) ? "an element matching the selector was still visible at timeout" :
|
|
129630
|
+
base = matches2.some(isVisible) ? "an element matching the selector was still visible at timeout" : HIDDEN_UNREADABLE_NOTE;
|
|
129601
129631
|
break;
|
|
129602
129632
|
case "visible":
|
|
129603
129633
|
base = matches2.length > 0 ? "element(s) matched but none was visible (zero-area frame) before timeout" : "no element matched the selector before timeout";
|
|
@@ -129646,9 +129676,11 @@ It polls the same accessibility / DOM tree as \`describe\`
|
|
|
129646
129676
|
(iOS AXRuntime, Android uiautomator, Chromium CDP, Vega automation toolkit) every pollIntervalMs
|
|
129647
129677
|
(default ${DEFAULT_POLL_INTERVAL_MS}ms) until timeoutMs (default ${DEFAULT_TIMEOUT_MS3}ms).
|
|
129648
129678
|
|
|
129649
|
-
Returns { success: boolean, elapsed: number } \u2014 success=false means the
|
|
129650
|
-
|
|
129651
|
-
|
|
129679
|
+
Returns { success: boolean, elapsed: number, note?, cause? } \u2014 success=false means the wait ended without the
|
|
129680
|
+
condition holding, which is not always a verdict on the condition: \`cause\` says which it was \u2014 \`unmet\` (the tree
|
|
129681
|
+
was read and the condition was false there), \`unreadable\` (no trustworthy read, so nothing was judged) or
|
|
129682
|
+
\`cancelled\` \u2014 and \`note\` describes what was seen. Only \`unmet\` licenses rewriting the check. Use this after a
|
|
129683
|
+
tap/navigation to wait for the next screen, or before tapping an element that appears asynchronously.`,
|
|
129652
129684
|
alwaysLoad: true,
|
|
129653
129685
|
searchHint: "wait await poll until visible hidden exists text appears disappears timeout element condition settle",
|
|
129654
129686
|
longRunning: true,
|
|
@@ -129674,21 +129706,24 @@ or before tapping an element that appears asynchronously.`,
|
|
|
129674
129706
|
const cancelled = () => ({
|
|
129675
129707
|
success: false,
|
|
129676
129708
|
elapsed: Date.now() - start2,
|
|
129677
|
-
note:
|
|
129709
|
+
note: WAIT_CANCELLED_NOTE,
|
|
129710
|
+
cause: "cancelled"
|
|
129678
129711
|
});
|
|
129679
129712
|
const timeoutMs = params.timeoutMs ?? DEFAULT_TIMEOUT_MS3;
|
|
129680
129713
|
const pollIntervalMs = params.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
129681
129714
|
const selector = params.selector;
|
|
129682
129715
|
let everMatched = false;
|
|
129716
|
+
let lastTrustedReadAt;
|
|
129683
129717
|
const poll = await pollDescribeTree({
|
|
129684
129718
|
fetchTree: () => fetchTree2(device, params, services, isTvOs, androidIsTv),
|
|
129685
129719
|
timeoutMs,
|
|
129686
129720
|
pollIntervalMs,
|
|
129687
129721
|
signal,
|
|
129688
|
-
onSample: (data) => {
|
|
129722
|
+
onSample: (data, nowMs) => {
|
|
129689
129723
|
const matches2 = findAll(data.tree, selector);
|
|
129690
129724
|
if (matches2.length > 0) everMatched = true;
|
|
129691
129725
|
const blind = isBlindRead(data, everMatched);
|
|
129726
|
+
if (!blind) lastTrustedReadAt = nowMs;
|
|
129692
129727
|
if (!blind && evaluateMatches(params, matches2)) {
|
|
129693
129728
|
const result = { success: true, elapsed: Date.now() - start2 };
|
|
129694
129729
|
if (params.condition === "hidden" && !everMatched) {
|
|
@@ -129701,10 +129736,12 @@ or before tapping an element that appears asynchronously.`,
|
|
|
129701
129736
|
});
|
|
129702
129737
|
if (poll.aborted) return cancelled();
|
|
129703
129738
|
if (poll.result) return poll.result;
|
|
129739
|
+
const finalRead = !poll.lastAttemptSettled ? "unsettled" : poll.lastError === void 0 && poll.lastData !== null && !isBlindRead(poll.lastData, everMatched) ? "trusted" : "untrusted";
|
|
129704
129740
|
return {
|
|
129705
129741
|
success: false,
|
|
129706
129742
|
elapsed: Date.now() - start2,
|
|
129707
|
-
note: timeoutNote(params, poll.lastData?.tree ?? null, poll.lastError, poll.lastData)
|
|
129743
|
+
note: timeoutNote(params, poll.lastData?.tree ?? null, poll.lastError, poll.lastData),
|
|
129744
|
+
cause: timeoutCause(params.condition, lastTrustedReadAt, finalRead, pollIntervalMs)
|
|
129708
129745
|
};
|
|
129709
129746
|
}
|
|
129710
129747
|
};
|
|
@@ -144185,13 +144222,58 @@ function assertSessionStillLive(session, step) {
|
|
|
144185
144222
|
}
|
|
144186
144223
|
);
|
|
144187
144224
|
}
|
|
144225
|
+
function renderStepForCompare(step) {
|
|
144226
|
+
try {
|
|
144227
|
+
return JSON.stringify(step);
|
|
144228
|
+
} catch {
|
|
144229
|
+
return null;
|
|
144230
|
+
}
|
|
144231
|
+
}
|
|
144232
|
+
function sameStepRun(now, before, n, nowFrom, beforeFrom) {
|
|
144233
|
+
if (now.length < nowFrom + n || before.length < beforeFrom + n) return false;
|
|
144234
|
+
for (let i = 0; i < n; i += 1) {
|
|
144235
|
+
const rendered = now[nowFrom + i];
|
|
144236
|
+
if (rendered === null || rendered !== before[beforeFrom + i]) {
|
|
144237
|
+
return false;
|
|
144238
|
+
}
|
|
144239
|
+
}
|
|
144240
|
+
return true;
|
|
144241
|
+
}
|
|
144242
|
+
function anchorHolds(now, before, n) {
|
|
144243
|
+
if (!sameStepRun(now, before, n, 0, 0)) return false;
|
|
144244
|
+
const deleted = before.length - now.length;
|
|
144245
|
+
const inserted = now.length - before.length;
|
|
144246
|
+
for (let at = 0; at < n; at += 1) {
|
|
144247
|
+
for (let size = 1; size <= deleted; size += 1) {
|
|
144248
|
+
if (sameStepRun(now, before, n - at, at, at + size)) return false;
|
|
144249
|
+
}
|
|
144250
|
+
for (let size = 1; size <= inserted; size += 1) {
|
|
144251
|
+
if (sameStepRun(now, before, n - at, at + size, at)) return false;
|
|
144252
|
+
}
|
|
144253
|
+
}
|
|
144254
|
+
return true;
|
|
144255
|
+
}
|
|
144256
|
+
function dropMovedWarnings(warnings, now, before) {
|
|
144257
|
+
if (!warnings) return 0;
|
|
144258
|
+
const nowRendered = now.map(renderStepForCompare);
|
|
144259
|
+
const beforeRendered = before.map(renderStepForCompare);
|
|
144260
|
+
let dropped = 0;
|
|
144261
|
+
for (const n of [...warnings.keys()]) {
|
|
144262
|
+
if (anchorHolds(nowRendered, beforeRendered, n)) continue;
|
|
144263
|
+
warnings.delete(n);
|
|
144264
|
+
dropped += 1;
|
|
144265
|
+
}
|
|
144266
|
+
return dropped;
|
|
144267
|
+
}
|
|
144188
144268
|
async function appendStepToFlow(session, step) {
|
|
144189
144269
|
return withFlowLock(session.key, async () => {
|
|
144190
144270
|
assertSessionStillLive(session, step);
|
|
144191
144271
|
session.lastTouchedSeq = touch();
|
|
144192
144272
|
if (session.persist === "host") {
|
|
144273
|
+
const before = session.flow.steps;
|
|
144193
144274
|
const flowFile = await appendStep(session.filePath, step);
|
|
144194
144275
|
session.flow = parseFlow(flowFile);
|
|
144276
|
+
session.discardedWarnings = (session.discardedWarnings ?? 0) + dropMovedWarnings(session.stepWarnings, session.flow.steps.slice(0, -1), before);
|
|
144195
144277
|
return { savedTo: session.filePath, stepCount: session.flow.steps.length };
|
|
144196
144278
|
}
|
|
144197
144279
|
session.flow.steps.push(step);
|
|
@@ -144248,16 +144330,24 @@ land in another's file. Steps still run LIVE, so give each concurrent recording
|
|
|
144248
144330
|
its own device and pick a name unique to your task.
|
|
144249
144331
|
|
|
144250
144332
|
After starting, use flow-add-step to append tool calls \u2014 each step is executed
|
|
144251
|
-
LIVE so you can verify it works before it gets recorded.
|
|
144333
|
+
LIVE so you can verify it works before it gets recorded. Read each step's
|
|
144334
|
+
\`message\`: an await-ui-element whose condition never held is still recorded (it
|
|
144335
|
+
returns success:false rather than failing), and a check that passes live can
|
|
144336
|
+
still fail once polished into an \`await:\`/\`assert:\` directive, which resolves
|
|
144337
|
+
against a different tree. flow-add-step warns about both when you record the
|
|
144338
|
+
wait DIRECTLY. A wait nested inside a recorded run-sequence gets neither warning
|
|
144339
|
+
\u2014 that tool reports its own shape \u2014 so for those, read \`toolResult\`. For a self-contained
|
|
144252
144340
|
e2e flow, record a restart-app of the app under test as the FIRST step (captured
|
|
144253
144341
|
as the flow's \`launch\` step); for a reusable fragment, skip that and pass
|
|
144254
144342
|
executionPrerequisite instead. Use flow-add-echo to add labels. Call
|
|
144255
144343
|
flow-finish-recording when done.
|
|
144256
144344
|
|
|
144257
|
-
If a recorded step turns out to be wrong,
|
|
144258
|
-
|
|
144259
|
-
|
|
144260
|
-
write serializes it over your edit
|
|
144345
|
+
If a recorded step turns out to be wrong, edit the .yaml file directly to
|
|
144346
|
+
remove or reorder steps - after flow-finish-recording, not during the
|
|
144347
|
+
recording. Against a remote client the in-memory copy is authoritative and
|
|
144348
|
+
every write serializes it over your edit; in host mode the recorder re-reads
|
|
144349
|
+
the file before each append, so a mid-recording edit renumbers the steps and
|
|
144350
|
+
costs the finish the cross-tree verdicts anchored to them.`,
|
|
144261
144351
|
zodSchema: zodSchema61,
|
|
144262
144352
|
fileInputs: fileInputs2,
|
|
144263
144353
|
services: () => ({}),
|
|
@@ -144315,141 +144405,10 @@ write serializes it over your edit.`,
|
|
|
144315
144405
|
|
|
144316
144406
|
// ../tool-server/src/tools/flows/flow-add-step.ts
|
|
144317
144407
|
init_zod();
|
|
144318
|
-
var
|
|
144408
|
+
var fs48 = __toESM(require("node:fs/promises"));
|
|
144319
144409
|
var path33 = __toESM(require("node:path"));
|
|
144320
144410
|
init_src();
|
|
144321
144411
|
|
|
144322
|
-
// ../tool-server/src/tools/flows/flow-finish-recording.ts
|
|
144323
|
-
init_zod();
|
|
144324
|
-
var fs46 = __toESM(require("node:fs/promises"));
|
|
144325
|
-
function selectorLabel(sel) {
|
|
144326
|
-
return JSON.stringify(selectorToYaml(sel));
|
|
144327
|
-
}
|
|
144328
|
-
function textConditionLabel(sel, expectedText, textMatch) {
|
|
144329
|
-
const selector = selectorLabel(sel);
|
|
144330
|
-
const expected = expectedText ?? "";
|
|
144331
|
-
return textMatch === "matches" ? `text ${selector} matches /${expected}/` : textMatch === "equals" ? `text ${selector} == ${JSON.stringify(expected)}` : `text ${selector} contains ${JSON.stringify(expected)}`;
|
|
144332
|
-
}
|
|
144333
|
-
var zodSchema62 = external_exports.object({
|
|
144334
|
-
name: external_exports.string().describe("Name of the flow being recorded \u2014 the one passed to flow-start-recording."),
|
|
144335
|
-
project_root: external_exports.string().describe(
|
|
144336
|
-
"Absolute path to the project root of the flow being recorded \u2014 the same value passed to flow-start-recording. Together with `name` it identifies which recording to finish."
|
|
144337
|
-
)
|
|
144338
|
-
});
|
|
144339
|
-
var flowFinishRecordingTool = {
|
|
144340
|
-
id: "flow-finish-recording",
|
|
144341
|
-
interaction: {
|
|
144342
|
-
// Name the flow: other recordings stay live across this call, so an
|
|
144343
|
-
// unqualified "Finishing flow recording" would not identify which one.
|
|
144344
|
-
startedMsg: ({ params }) => `Finishing recording of flow ${params.name}`,
|
|
144345
|
-
// `params.name` rather than the basename of `result.path`: the two are the
|
|
144346
|
-
// same string on every branch — `assertSafeFlowName` admits no dots or
|
|
144347
|
-
// separators, so `getFlowPath` produces `<name>.yaml` and nothing else —
|
|
144348
|
-
// and this spelling matches the two formatters either side of it.
|
|
144349
|
-
completedMsg: ({ params }) => `Saved recorded flow ${params.name}`,
|
|
144350
|
-
failedMsg: ({ params, failureSignal: failureSignal2 }) => `Failed to finish recording of flow ${params.name}: ${failureSignal2.error_code}`
|
|
144351
|
-
},
|
|
144352
|
-
description: `Finish recording the flow named by \`name\` + \`project_root\`, leaving recordings under any other key untouched. Returns { message, path, executionPrerequisite, steps, summary, flowFile, savedTo } - a summary of all recorded steps plus the final YAML. Use when you have added all desired steps and want to finalize the flow file. Fails if that flow has no recording in progress.
|
|
144353
|
-
You can still edit the .yaml file directly afterwards to remove or reorder steps.`,
|
|
144354
|
-
zodSchema: zodSchema62,
|
|
144355
|
-
services: () => ({}),
|
|
144356
|
-
async execute(_services, params) {
|
|
144357
|
-
const { filePath, flowFile, savedTo, flow, summary } = await withFlowFileLock(
|
|
144358
|
-
params.project_root,
|
|
144359
|
-
params.name,
|
|
144360
|
-
async () => {
|
|
144361
|
-
const session = await requireRecordingSession(params.project_root, params.name);
|
|
144362
|
-
const filePath2 = session.filePath;
|
|
144363
|
-
let flowFile2;
|
|
144364
|
-
let savedTo2;
|
|
144365
|
-
if (session.persist === "client") {
|
|
144366
|
-
flowFile2 = serializeFlow(session.flow);
|
|
144367
|
-
savedTo2 = clientFileDirective(filePath2, flowFile2);
|
|
144368
|
-
} else {
|
|
144369
|
-
flowFile2 = await fs46.readFile(filePath2, "utf8");
|
|
144370
|
-
savedTo2 = filePath2;
|
|
144371
|
-
}
|
|
144372
|
-
const flow2 = parseFlow(flowFile2);
|
|
144373
|
-
const summary2 = summarizeSteps(flow2);
|
|
144374
|
-
clearRecordingSession(session);
|
|
144375
|
-
return { filePath: filePath2, flowFile: flowFile2, savedTo: savedTo2, flow: flow2, summary: summary2 };
|
|
144376
|
-
}
|
|
144377
|
-
);
|
|
144378
|
-
return {
|
|
144379
|
-
message: `Finished recording "${params.name}" flow (${flow.steps.length} steps)`,
|
|
144380
|
-
path: filePath,
|
|
144381
|
-
executionPrerequisite: flow.executionPrerequisite,
|
|
144382
|
-
steps: flow.steps.length,
|
|
144383
|
-
summary,
|
|
144384
|
-
flowFile,
|
|
144385
|
-
savedTo
|
|
144386
|
-
};
|
|
144387
|
-
}
|
|
144388
|
-
};
|
|
144389
|
-
function renderToolArgs(args) {
|
|
144390
|
-
try {
|
|
144391
|
-
return `${JSON.stringify(args)}`;
|
|
144392
|
-
} catch {
|
|
144393
|
-
return "[cyclic args]";
|
|
144394
|
-
}
|
|
144395
|
-
}
|
|
144396
|
-
function delayLabel(step) {
|
|
144397
|
-
if (!step.delayMs) return "";
|
|
144398
|
-
const ms = Number(step.delayMs);
|
|
144399
|
-
return Number.isFinite(ms) && ms >= 1 ? ` (after ${ms}ms)` : "";
|
|
144400
|
-
}
|
|
144401
|
-
function summarizeSteps(flow) {
|
|
144402
|
-
return flow.steps.map((step, i) => summarizeStep(step, i + 1));
|
|
144403
|
-
}
|
|
144404
|
-
function summarizeStep(step, n) {
|
|
144405
|
-
switch (step.kind) {
|
|
144406
|
-
case "echo":
|
|
144407
|
-
return `${n}. echo: ${step.message}`;
|
|
144408
|
-
case "launch":
|
|
144409
|
-
return `${n}. launch: ${typeof step.app === "string" ? step.app : JSON.stringify(step.app)}`;
|
|
144410
|
-
case "run":
|
|
144411
|
-
return `${n}. run: ${step.flow}`;
|
|
144412
|
-
case "tap":
|
|
144413
|
-
case "long-press": {
|
|
144414
|
-
const target = step.selector ? selectorLabel(step.selector) : `(${step.x}, ${step.y})`;
|
|
144415
|
-
const times = step.kind === "tap" && step.times !== void 0 && step.times > 1 ? ` \xD7${step.times}` : "";
|
|
144416
|
-
const held = step.kind === "long-press" && step.duration !== void 0 ? ` for ${step.duration}ms` : "";
|
|
144417
|
-
return `${n}. ${step.kind}: ${target}${times}${held}`;
|
|
144418
|
-
}
|
|
144419
|
-
case "type":
|
|
144420
|
-
return `${n}. type: ${selectorLabel(step.into)} \u2190 "${step.text}"`;
|
|
144421
|
-
case "await":
|
|
144422
|
-
case "assert": {
|
|
144423
|
-
const tail = step.condition === "text" ? textConditionLabel(step.selector, step.expectedText, step.textMatch) : `${step.condition} ${selectorLabel(step.selector)}`;
|
|
144424
|
-
return `${n}. ${step.kind}: ${tail}`;
|
|
144425
|
-
}
|
|
144426
|
-
case "wait":
|
|
144427
|
-
return `${n}. wait: ${step.ms}ms`;
|
|
144428
|
-
case "when": {
|
|
144429
|
-
const cond = step.condition.kind === "platform" ? `platform ${step.condition.platform}` : step.condition.condition === "text" ? textConditionLabel(
|
|
144430
|
-
step.condition.selector,
|
|
144431
|
-
step.condition.expectedText,
|
|
144432
|
-
step.condition.textMatch
|
|
144433
|
-
) : `${step.condition.condition} ${selectorLabel(step.condition.selector)}`;
|
|
144434
|
-
const count2 = step.steps.length;
|
|
144435
|
-
return `${n}. when: ${cond} (${count2} step${count2 === 1 ? "" : "s"})`;
|
|
144436
|
-
}
|
|
144437
|
-
case "scroll-to":
|
|
144438
|
-
return `${n}. scroll-to: ${selectorLabel(step.target)} (${step.direction})`;
|
|
144439
|
-
case "pinch":
|
|
144440
|
-
return `${n}. pinch: scale ${step.scale}${step.selector ? ` on ${selectorLabel(step.selector)}` : ""}`;
|
|
144441
|
-
case "rotate":
|
|
144442
|
-
return `${n}. rotate: by ${step.by}\xB0${step.selector ? ` on ${selectorLabel(step.selector)}` : ""}`;
|
|
144443
|
-
case "snapshot":
|
|
144444
|
-
return `${n}. snapshot: ${step.name}`;
|
|
144445
|
-
case "idle":
|
|
144446
|
-
return `${n}. await: screen idle`;
|
|
144447
|
-
case "tool":
|
|
144448
|
-
default:
|
|
144449
|
-
return `${n}. tool: ${step.name} ${renderToolArgs(step.args)}${delayLabel(step)}`;
|
|
144450
|
-
}
|
|
144451
|
-
}
|
|
144452
|
-
|
|
144453
144412
|
// ../tool-server/src/tools/flows/flow-device.ts
|
|
144454
144413
|
init_src();
|
|
144455
144414
|
var DEVICE_BIND_KEYS = ["udid", "device_id", "device"];
|
|
@@ -145030,359 +144989,8 @@ function supportsFlowTree(platform) {
|
|
|
145030
144989
|
return FLOW_TREE_SOURCES[platform] !== void 0;
|
|
145031
144990
|
}
|
|
145032
144991
|
|
|
145033
|
-
// ../tool-server/src/tools/flows/flow-add-step.ts
|
|
145034
|
-
var zodSchema63 = external_exports.object({
|
|
145035
|
-
name: external_exports.string().describe("Name of the flow being recorded \u2014 the one passed to flow-start-recording."),
|
|
145036
|
-
project_root: external_exports.string().describe(
|
|
145037
|
-
"Absolute path to the project root of the flow being recorded \u2014 the same value passed to flow-start-recording. Together with `name` it identifies which recording this step belongs to."
|
|
145038
|
-
),
|
|
145039
|
-
command: external_exports.string().describe('MCP tool name (e.g. "gesture-tap", "screenshot", "launch-app")'),
|
|
145040
|
-
args: external_exports.string().optional().describe(
|
|
145041
|
-
`Tool arguments as a JSON string, e.g. '{"udid": "ABC", "x": 0.5, "y": 0.3}'. Omit for tools with no arguments.`
|
|
145042
|
-
),
|
|
145043
|
-
delayMs: external_exports.number().int().min(0).optional().describe("Milliseconds to sleep before executing this step during replay.")
|
|
145044
|
-
});
|
|
145045
|
-
var REPLAY_TREE_SOURCES = {
|
|
145046
|
-
ios: "native-devtools",
|
|
145047
|
-
android: "android-devtools"
|
|
145048
|
-
};
|
|
145049
|
-
function recordedLaunchedApp(session, platform) {
|
|
145050
|
-
for (let i = session.flow.steps.length - 1; i >= 0; i--) {
|
|
145051
|
-
const step = session.flow.steps[i];
|
|
145052
|
-
if (step.kind === "launch") return appIdForPlatform(step.app, platform) ?? void 0;
|
|
145053
|
-
}
|
|
145054
|
-
return void 0;
|
|
145055
|
-
}
|
|
145056
|
-
function fallbackSourceWarning(source, platform) {
|
|
145057
|
-
const expected = REPLAY_TREE_SOURCES[platform];
|
|
145058
|
-
if (!expected || source === expected) return void 0;
|
|
145059
|
-
return `selector captured from the fallback ${source} tree (${expected} unavailable) \u2014 replay resolves against the full hierarchy, which may not match it`;
|
|
145060
|
-
}
|
|
145061
|
-
async function captureTapSelector(registry2, session, udid, point) {
|
|
145062
|
-
try {
|
|
145063
|
-
const device = resolveDevice(udid);
|
|
145064
|
-
const launched = recordedLaunchedApp(session, device.platform);
|
|
145065
|
-
const { tree, source } = await fetchFlowTree(registry2, device, launched);
|
|
145066
|
-
const node = nodeAtPoint(tree, point);
|
|
145067
|
-
if (!node) return { warning: "no element found under the tap; kept coordinates (brittle)" };
|
|
145068
|
-
const selector = deriveSelector(node);
|
|
145069
|
-
if (!selector)
|
|
145070
|
-
return { warning: "tapped element has no stable text/id; kept coordinates (brittle)" };
|
|
145071
|
-
const resolved = selectorToFrame(tree, selector);
|
|
145072
|
-
if (!resolved) {
|
|
145073
|
-
return {
|
|
145074
|
-
warning: `selector ${describeSelector(selector)} matches no element on this screen; kept coordinates (brittle)`
|
|
145075
|
-
};
|
|
145076
|
-
}
|
|
145077
|
-
if (!frameContains(resolved, point.x, point.y)) {
|
|
145078
|
-
return {
|
|
145079
|
-
warning: `selector ${describeSelector(selector)} resolves to a different element on this screen; kept coordinates (brittle)`
|
|
145080
|
-
};
|
|
145081
|
-
}
|
|
145082
|
-
return { selector, warning: fallbackSourceWarning(source, device.platform) };
|
|
145083
|
-
} catch (err) {
|
|
145084
|
-
return {
|
|
145085
|
-
warning: `selector capture failed (${err instanceof Error ? err.message : String(err)}); kept coordinates`
|
|
145086
|
-
};
|
|
145087
|
-
}
|
|
145088
|
-
}
|
|
145089
|
-
var RUN_TARGET_COMMAND = "flow-execute";
|
|
145090
|
-
async function rewriteSiblingFlowPath(session, args) {
|
|
145091
|
-
const flowPath = args.flow_path;
|
|
145092
|
-
if (typeof flowPath !== "string" || args.name !== void 0) return;
|
|
145093
|
-
const invalid = (detail) => new FailureError(
|
|
145094
|
-
`Cannot record a flow-execute of flow_path "${flowPath}": ${detail}. flow_path carries no file-input resolution through flow-add-step's opaque args \u2014 pass name + project_root for a flow saved beside the recording, or add a \`run: <relative path>.yaml\` step to the flow YAML by hand for a cross-directory target.`,
|
|
145095
|
-
{
|
|
145096
|
-
error_code: FAILURE_CODES.FLOW_FILE_INVALID,
|
|
145097
|
-
failure_stage: "flow_add_step_flow_path",
|
|
145098
|
-
failure_area: "tool_server",
|
|
145099
|
-
error_kind: "validation"
|
|
145100
|
-
}
|
|
145101
|
-
);
|
|
145102
|
-
if (!session || session.persist !== "host") {
|
|
145103
|
-
throw invalid(
|
|
145104
|
-
"the recording is not persisted on this host, so its siblings cannot be resolved here"
|
|
145105
|
-
);
|
|
145106
|
-
}
|
|
145107
|
-
if (flowPath.split(/[\\/]+/).includes("..")) {
|
|
145108
|
-
throw invalid(
|
|
145109
|
-
'flow paths must not contain ".." segments \u2014 sibling identity is decided lexically from this path, and a symlinked directory component would make the rewrite run a different file than the path opens'
|
|
145110
|
-
);
|
|
145111
|
-
}
|
|
145112
|
-
const ext = path33.extname(flowPath);
|
|
145113
|
-
const bareExtension = path33.basename(flowPath).toLowerCase() === ".yaml";
|
|
145114
|
-
if (!bareExtension && ext !== ".yaml") {
|
|
145115
|
-
throw invalid(
|
|
145116
|
-
ext.toLowerCase() === ".yaml" ? `flow files must use the lowercase .yaml extension, not "${ext}"` : "flow files must use the .yaml extension"
|
|
145117
|
-
);
|
|
145118
|
-
}
|
|
145119
|
-
const flowsDir = path33.dirname(session.filePath);
|
|
145120
|
-
if (path33.resolve(path33.dirname(flowPath)) !== path33.resolve(flowsDir)) {
|
|
145121
|
-
throw invalid(
|
|
145122
|
-
`it is not in the recording's flow directory ("${flowsDir}"), and a raw tool: step has no boundary to resolve a path through at replay`
|
|
145123
|
-
);
|
|
145124
|
-
}
|
|
145125
|
-
const stem = bareExtension ? "" : path33.basename(flowPath, ".yaml");
|
|
145126
|
-
assertSafeFlowName(stem);
|
|
145127
|
-
const projectRoot = args.project_root;
|
|
145128
|
-
if (typeof projectRoot !== "string" || !path33.isAbsolute(projectRoot)) {
|
|
145129
|
-
throw invalid(
|
|
145130
|
-
`project_root must be an absolute path (got ${typeof projectRoot === "string" ? `"${projectRoot}"` : "none"}) \u2014 a relative root would be resolved against the tool server's cwd, not the calling agent's`
|
|
145131
|
-
);
|
|
145132
|
-
}
|
|
145133
|
-
if (path33.resolve(flowsDirFor(projectRoot), `${stem}.yaml`) !== path33.resolve(flowPath)) {
|
|
145134
|
-
throw invalid(`project_root "${projectRoot}" does not resolve "${stem}" to it`);
|
|
145135
|
-
}
|
|
145136
|
-
const suppliedBase = path33.basename(flowPath);
|
|
145137
|
-
const spelling = await classifyOnDiskSpelling(flowsDir, suppliedBase);
|
|
145138
|
-
if (spelling.state !== "listed") {
|
|
145139
|
-
const recovery = spelling.state === "absent" ? `pass the basename exactly as it appears on disk` : spelling.addressable ? `pass flow_path with the on-disk basename "${spelling.actual}"` : `rename "${spelling.actual}" to "${suppliedBase}" to record it \u2014 flow files must be lowercase .yaml`;
|
|
145140
|
-
throw invalid(
|
|
145141
|
-
`the file must be named as it appears on disk \u2014 no directory entry is named "${suppliedBase}"` + (spelling.state === "case_folded" ? ` (this filesystem matched it case-insensitively to "${spelling.actual}")` : "") + `, so the recorded run: step would name a flow no case-sensitive checkout can find \u2014 ` + recovery
|
|
145142
|
-
);
|
|
145143
|
-
}
|
|
145144
|
-
delete args.flow_path;
|
|
145145
|
-
args.name = stem;
|
|
145146
|
-
}
|
|
145147
|
-
async function captureRunTarget(session, args) {
|
|
145148
|
-
const name = typeof args.name === "string" ? args.name : void 0;
|
|
145149
|
-
if (name === void 0) {
|
|
145150
|
-
return { warning: "flow-execute call had no flow name; kept the raw step" };
|
|
145151
|
-
}
|
|
145152
|
-
if (session.persist !== "host") {
|
|
145153
|
-
return {
|
|
145154
|
-
warning: `kept the raw flow-execute step \u2014 run: composition is host-resolved, so a remote recording can't reference "${name}" portably`
|
|
145155
|
-
};
|
|
145156
|
-
}
|
|
145157
|
-
try {
|
|
145158
|
-
assertSafeFlowName(name);
|
|
145159
|
-
const realFlowPath = await fs47.realpath(session.filePath);
|
|
145160
|
-
const flowsDir = path33.dirname(realFlowPath);
|
|
145161
|
-
const fragPath = path33.join(flowsDir, `${name}.yaml`);
|
|
145162
|
-
const projectRoot = args.project_root;
|
|
145163
|
-
if (typeof projectRoot !== "string" || !path33.isAbsolute(projectRoot)) {
|
|
145164
|
-
return {
|
|
145165
|
-
warning: `kept the raw flow-execute step \u2014 project_root must be an absolute path (got ${typeof projectRoot === "string" ? `"${projectRoot}"` : "none"}) to confirm "${name}" names the recording's own sibling`
|
|
145166
|
-
};
|
|
145167
|
-
}
|
|
145168
|
-
const spelling = await classifyOnDiskSpelling(flowsDir, `${name}.yaml`);
|
|
145169
|
-
if (spelling.state === "case_folded") {
|
|
145170
|
-
const recovery = spelling.addressable ? `re-run it as name "${path33.basename(spelling.actual, ".yaml")}" to record it` : `rename "${spelling.actual}" to "${name}.yaml" to record it \u2014 flow files must be lowercase .yaml`;
|
|
145171
|
-
return {
|
|
145172
|
-
warning: `kept the raw flow-execute step \u2014 no sibling is named "${name}.yaml" (this filesystem matched it case-insensitively to "${spelling.actual}"), so a run: ${name} step would name a flow no case-sensitive checkout can find \u2014 ${recovery}`
|
|
145173
|
-
};
|
|
145174
|
-
}
|
|
145175
|
-
parseFlow(await fs47.readFile(fragPath, "utf8"));
|
|
145176
|
-
let executedPath;
|
|
145177
|
-
try {
|
|
145178
|
-
executedPath = await fs47.realpath(path33.join(flowsDirFor(projectRoot), `${name}.yaml`));
|
|
145179
|
-
} catch {
|
|
145180
|
-
executedPath = void 0;
|
|
145181
|
-
}
|
|
145182
|
-
if (executedPath === void 0) {
|
|
145183
|
-
return {
|
|
145184
|
-
warning: `kept the raw flow-execute step \u2014 could not verify which file the live flow-execute ran ("${name}" has no canonical file in project_root's flows dir to compare the sibling against)`
|
|
145185
|
-
};
|
|
145186
|
-
}
|
|
145187
|
-
if (executedPath !== await fs47.realpath(fragPath)) {
|
|
145188
|
-
return {
|
|
145189
|
-
warning: `kept the raw flow-execute step \u2014 project_root "${projectRoot}" resolves "${name}" to "${executedPath}", not the recording's sibling "${fragPath}", so "${name}.yaml" beside the recording's real file is not the file the live flow-execute ran and a run: ${name} step would replay a different flow than the one that just ran`
|
|
145190
|
-
};
|
|
145191
|
-
}
|
|
145192
|
-
return { flow: `${name}.yaml` };
|
|
145193
|
-
} catch (err) {
|
|
145194
|
-
return {
|
|
145195
|
-
warning: `could not resolve "${name}" as a sibling fragment (${err instanceof Error ? err.message : String(err)}); kept the raw flow-execute step`
|
|
145196
|
-
};
|
|
145197
|
-
}
|
|
145198
|
-
}
|
|
145199
|
-
function createFlowAddStepTool(registry2) {
|
|
145200
|
-
return {
|
|
145201
|
-
id: "flow-add-step",
|
|
145202
|
-
interaction: {
|
|
145203
|
-
// Name the flow: recordings are concurrent, so several of these lines can
|
|
145204
|
-
// interleave in one log and "the recorded flow" would not identify which.
|
|
145205
|
-
startedMsg: ({ params }) => `Adding ${params.command} step to flow ${params.name}`,
|
|
145206
|
-
completedMsg: ({ params }) => `Added ${params.command} step to flow ${params.name}`,
|
|
145207
|
-
failedMsg: ({ params, failureSignal: failureSignal2 }) => `Failed to add ${params.command} step to flow ${params.name}: ${failureSignal2.error_code}`
|
|
145208
|
-
},
|
|
145209
|
-
description: `Execute a tool call and record it as a step in the flow named by \`name\` + \`project_root\` (the recording must already be open \u2014 see flow-start-recording). Use when recording a flow and you want to run and capture each action. A coordinate \`gesture-tap\` is recorded as a portable \`tap: { selector }\` step when the tapped element has stable text/identifier (otherwise coordinates are kept with a warning); a \`restart-app\` is recorded as a \`launch\` step (record one FIRST to make the flow a self-contained e2e flow; restart-app has no chromium support, so a chromium flow records as a fragment \u2014 add the \`launch: { chromium: <app path> }\` line to the YAML afterward, deleting the executionPrerequisite line if one was recorded: a flow that starts with a launch must not declare it).
|
|
145210
|
-
Returns { message, toolResult, stepCount, recorded, savedTo } on success. If it fails an error is returned and nothing is recorded.
|
|
145211
|
-
If a step was recorded by mistake, edit the .yaml to remove it \u2014 against a remote client, only after \`flow-finish-recording\`: the in-memory copy is authoritative there, and every write serializes it over your edit.`,
|
|
145212
|
-
zodSchema: zodSchema63,
|
|
145213
|
-
services: () => ({}),
|
|
145214
|
-
async execute(_services, params, ctx) {
|
|
145215
|
-
const session = await requireRecordingSession(params.project_root, params.name);
|
|
145216
|
-
const args = params.args ? JSON.parse(params.args) : {};
|
|
145217
|
-
if (params.command === RUN_TARGET_COMMAND) await rewriteSiblingFlowPath(session, args);
|
|
145218
|
-
const isTap = params.command === "gesture-tap" && params.delayMs === void 0 && typeof args.udid === "string" && typeof args.x === "number" && typeof args.y === "number";
|
|
145219
|
-
let captured;
|
|
145220
|
-
if (isTap) {
|
|
145221
|
-
captured = await captureTapSelector(registry2, session, args.udid, {
|
|
145222
|
-
x: args.x,
|
|
145223
|
-
y: args.y
|
|
145224
|
-
});
|
|
145225
|
-
}
|
|
145226
|
-
const toolResult = await invokeSubTool(registry2, ctx, params.command, args);
|
|
145227
|
-
const runTarget = params.command === RUN_TARGET_COMMAND && params.delayMs === void 0 ? await captureRunTarget(session, args) : void 0;
|
|
145228
|
-
const strippedArgs = stripDeviceKeys(args);
|
|
145229
|
-
const isLaunch = params.command === "restart-app" && params.delayMs === void 0 && typeof strippedArgs.bundleId === "string" && Object.keys(strippedArgs).length === 1;
|
|
145230
|
-
const cc = args.clickCount;
|
|
145231
|
-
const tapTimes = isTap && typeof cc === "number" && Number.isInteger(cc) && cc >= 2 && cc <= 10 ? { times: cc } : {};
|
|
145232
|
-
let step;
|
|
145233
|
-
let warning;
|
|
145234
|
-
if (captured?.selector) {
|
|
145235
|
-
step = { kind: "tap", selector: captured.selector, ...tapTimes };
|
|
145236
|
-
warning = captured.warning;
|
|
145237
|
-
} else if (isTap) {
|
|
145238
|
-
step = { kind: "tap", x: args.x, y: args.y, ...tapTimes };
|
|
145239
|
-
warning = captured?.warning;
|
|
145240
|
-
} else if (isLaunch) {
|
|
145241
|
-
step = { kind: "launch", app: strippedArgs.bundleId };
|
|
145242
|
-
} else if (runTarget?.flow) {
|
|
145243
|
-
step = { kind: "run", flow: runTarget.flow };
|
|
145244
|
-
} else {
|
|
145245
|
-
warning = runTarget?.warning;
|
|
145246
|
-
step = {
|
|
145247
|
-
kind: "tool",
|
|
145248
|
-
name: params.command,
|
|
145249
|
-
args: strippedArgs,
|
|
145250
|
-
delayMs: params.delayMs
|
|
145251
|
-
};
|
|
145252
|
-
}
|
|
145253
|
-
const { savedTo, stepCount } = await appendStepToFlow(session, step);
|
|
145254
|
-
return {
|
|
145255
|
-
message: `Step added to "${params.name}" flow${warning ? ` \u2014 ${warning}` : ""}`,
|
|
145256
|
-
toolResult,
|
|
145257
|
-
stepCount,
|
|
145258
|
-
recorded: summarizeStep(step, stepCount),
|
|
145259
|
-
// Host mode: a path. Client mode: the directive that carries the YAML
|
|
145260
|
-
// to the client, which IS the persistence mechanism there — the one
|
|
145261
|
-
// place the full file still has to travel per step.
|
|
145262
|
-
savedTo
|
|
145263
|
-
};
|
|
145264
|
-
}
|
|
145265
|
-
};
|
|
145266
|
-
}
|
|
145267
|
-
|
|
145268
|
-
// ../tool-server/src/tools/flows/flow-insert-echo.ts
|
|
145269
|
-
init_zod();
|
|
145270
|
-
var zodSchema64 = external_exports.object({
|
|
145271
|
-
name: external_exports.string().describe("Name of the flow being recorded \u2014 the one passed to flow-start-recording."),
|
|
145272
|
-
project_root: external_exports.string().describe(
|
|
145273
|
-
"Absolute path to the project root of the flow being recorded \u2014 the same value passed to flow-start-recording. Together with `name` it identifies which recording this echo belongs to."
|
|
145274
|
-
),
|
|
145275
|
-
message: external_exports.string().describe("Message to echo when the flow is replayed")
|
|
145276
|
-
});
|
|
145277
|
-
var flowInsertEchoTool = {
|
|
145278
|
-
id: "flow-add-echo",
|
|
145279
|
-
interaction: {
|
|
145280
|
-
// Name the flow: recordings are concurrent, so several of these lines can
|
|
145281
|
-
// interleave in one log and "the recorded flow" would not identify which.
|
|
145282
|
-
startedMsg: ({ params }) => `Adding note to flow ${params.name}`,
|
|
145283
|
-
completedMsg: ({ params }) => `Added note to flow ${params.name}`,
|
|
145284
|
-
failedMsg: ({ params, failureSignal: failureSignal2 }) => `Failed to add note to flow ${params.name}: ${failureSignal2.error_code}`
|
|
145285
|
-
},
|
|
145286
|
-
description: `Record an echo step in the flow named by \`name\` + \`project_root\`. Echo steps print a message when the flow is replayed \u2014 useful as labels between tool calls.
|
|
145287
|
-
Use when you want to annotate a recorded flow with a human-readable label or checkpoint message.
|
|
145288
|
-
Returns { message, stepCount, savedTo }. Fails if that flow has no recording in progress.`,
|
|
145289
|
-
zodSchema: zodSchema64,
|
|
145290
|
-
services: () => ({}),
|
|
145291
|
-
async execute(_services, params) {
|
|
145292
|
-
const session = await requireRecordingSession(params.project_root, params.name);
|
|
145293
|
-
const { savedTo, stepCount } = await appendStepToFlow(session, {
|
|
145294
|
-
kind: "echo",
|
|
145295
|
-
message: params.message
|
|
145296
|
-
});
|
|
145297
|
-
return {
|
|
145298
|
-
message: `Echo added to "${params.name}" flow`,
|
|
145299
|
-
stepCount,
|
|
145300
|
-
savedTo
|
|
145301
|
-
};
|
|
145302
|
-
}
|
|
145303
|
-
};
|
|
145304
|
-
|
|
145305
|
-
// ../tool-server/src/tools/flows/flow-run.ts
|
|
145306
|
-
init_zod();
|
|
145307
|
-
var fs51 = __toESM(require("node:fs/promises"));
|
|
145308
|
-
var path36 = __toESM(require("node:path"));
|
|
145309
|
-
init_src();
|
|
145310
|
-
|
|
145311
|
-
// ../tool-server/src/tools/flows/flow-nested-outcome.ts
|
|
145312
|
-
var FLOW_EXECUTE_TOOL_ID = "flow-execute";
|
|
145313
|
-
var RUN_SEQUENCE_TOOL_ID = "run-sequence";
|
|
145314
|
-
function isRecord(value) {
|
|
145315
|
-
return typeof value === "object" && value !== null;
|
|
145316
|
-
}
|
|
145317
|
-
function firstFailingStep(steps) {
|
|
145318
|
-
if (!Array.isArray(steps)) return void 0;
|
|
145319
|
-
for (const entry of steps) {
|
|
145320
|
-
if (!isRecord(entry)) continue;
|
|
145321
|
-
if (entry.status !== "fail" && entry.status !== "error") continue;
|
|
145322
|
-
const what = typeof entry.tool === "string" ? entry.tool : typeof entry.kind === "string" ? entry.kind : "step";
|
|
145323
|
-
const why = typeof entry.reason === "string" ? entry.reason : "no reason given";
|
|
145324
|
-
return `${what}: ${why}`;
|
|
145325
|
-
}
|
|
145326
|
-
return void 0;
|
|
145327
|
-
}
|
|
145328
|
-
function count(value) {
|
|
145329
|
-
return typeof value === "number" ? value : 0;
|
|
145330
|
-
}
|
|
145331
|
-
function flowExecuteOutcome(result) {
|
|
145332
|
-
const flow = typeof result.flow === "string" ? result.flow : "the composed flow";
|
|
145333
|
-
if (!("steps" in result) && typeof result.notice === "string") {
|
|
145334
|
-
const prerequisite = typeof result.executionPrerequisite === "string" && result.executionPrerequisite ? `: ${result.executionPrerequisite}` : "";
|
|
145335
|
-
return {
|
|
145336
|
-
status: "error",
|
|
145337
|
-
reason: `flow "${flow}" did not run \u2014 its execution prerequisite was not acknowledged${prerequisite}. Add prerequisiteAcknowledged: true to the step's args, or compose with run: instead.`
|
|
145338
|
-
};
|
|
145339
|
-
}
|
|
145340
|
-
if (result.aborted === true) {
|
|
145341
|
-
return { status: "skip", reason: `flow "${flow}" was aborted` };
|
|
145342
|
-
}
|
|
145343
|
-
if (result.ok === false) {
|
|
145344
|
-
const detail = firstFailingStep(result.steps);
|
|
145345
|
-
return {
|
|
145346
|
-
status: "fail",
|
|
145347
|
-
reason: `flow "${flow}" failed: ${count(result.passed)} passed, ${count(result.failed)} failed, ${count(result.errored)} errored${detail ? ` (${detail})` : ""}`
|
|
145348
|
-
};
|
|
145349
|
-
}
|
|
145350
|
-
return void 0;
|
|
145351
|
-
}
|
|
145352
|
-
function runSequenceOutcome(result) {
|
|
145353
|
-
const steps = result.steps;
|
|
145354
|
-
if (!Array.isArray(steps)) return void 0;
|
|
145355
|
-
const failed = steps.find((s) => isRecord(s) && typeof s.error === "string");
|
|
145356
|
-
if (failed && isRecord(failed)) {
|
|
145357
|
-
const tool = typeof failed.tool === "string" ? failed.tool : "step";
|
|
145358
|
-
return {
|
|
145359
|
-
status: "fail",
|
|
145360
|
-
reason: `run-sequence stopped at ${tool} after ${count(result.completed)} of ${count(result.total)} steps: ${String(failed.error)}`
|
|
145361
|
-
};
|
|
145362
|
-
}
|
|
145363
|
-
const total = count(result.total);
|
|
145364
|
-
if (total > 0 && steps.length < total) {
|
|
145365
|
-
return {
|
|
145366
|
-
status: "skip",
|
|
145367
|
-
reason: `run-sequence was aborted after ${count(result.completed)} of ${total} steps`
|
|
145368
|
-
};
|
|
145369
|
-
}
|
|
145370
|
-
return void 0;
|
|
145371
|
-
}
|
|
145372
|
-
var NESTED_ORCHESTRATORS = /* @__PURE__ */ new Map([
|
|
145373
|
-
[FLOW_EXECUTE_TOOL_ID, flowExecuteOutcome],
|
|
145374
|
-
[RUN_SEQUENCE_TOOL_ID, runSequenceOutcome]
|
|
145375
|
-
]);
|
|
145376
|
-
function isNestedOrchestratorTool(tool) {
|
|
145377
|
-
return NESTED_ORCHESTRATORS.has(tool);
|
|
145378
|
-
}
|
|
145379
|
-
function nestedOrchestratorOutcome(tool, result) {
|
|
145380
|
-
if (!isRecord(result)) return void 0;
|
|
145381
|
-
return NESTED_ORCHESTRATORS.get(tool)?.(result);
|
|
145382
|
-
}
|
|
145383
|
-
|
|
145384
144992
|
// ../tool-server/src/tools/flows/flow-pixels.ts
|
|
145385
|
-
var
|
|
144993
|
+
var fs46 = __toESM(require("node:fs/promises"));
|
|
145386
144994
|
var import_pngjs2 = __toESM(require_png());
|
|
145387
144995
|
init_adb();
|
|
145388
144996
|
var CAPTURE_SCALE = 0.25;
|
|
@@ -145452,9 +145060,9 @@ async function capturePng(env, budgetMs) {
|
|
|
145452
145060
|
if (env.device.platform === "chromium") return captureChromiumPng(env);
|
|
145453
145061
|
const file2 = await captureFile(env, budgetMs);
|
|
145454
145062
|
try {
|
|
145455
|
-
return await
|
|
145063
|
+
return await fs46.readFile(file2);
|
|
145456
145064
|
} finally {
|
|
145457
|
-
await
|
|
145065
|
+
await fs46.rm(file2, { force: true }).catch(() => {
|
|
145458
145066
|
});
|
|
145459
145067
|
}
|
|
145460
145068
|
}
|
|
@@ -146268,6 +145876,715 @@ function assertReason(condition, selector, expectedText, textMatch, matches2) {
|
|
|
146268
145876
|
}
|
|
146269
145877
|
}
|
|
146270
145878
|
|
|
145879
|
+
// ../tool-server/src/tools/flows/flow-finish-recording.ts
|
|
145880
|
+
init_zod();
|
|
145881
|
+
var fs47 = __toESM(require("node:fs/promises"));
|
|
145882
|
+
function selectorLabel(sel) {
|
|
145883
|
+
const yaml = selectorToYaml(sel);
|
|
145884
|
+
if (typeof yaml !== "object" || yaml === null) return JSON.stringify(yaml);
|
|
145885
|
+
const sorted = Object.fromEntries(
|
|
145886
|
+
Object.entries(yaml).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
145887
|
+
);
|
|
145888
|
+
return JSON.stringify(sorted);
|
|
145889
|
+
}
|
|
145890
|
+
function textConditionLabel(sel, expectedText, textMatch) {
|
|
145891
|
+
const selector = selectorLabel(sel);
|
|
145892
|
+
const expected = expectedText ?? "";
|
|
145893
|
+
return textMatch === "matches" ? `text ${selector} matches /${expected}/` : textMatch === "equals" ? `text ${selector} == ${JSON.stringify(expected)}` : `text ${selector} contains ${JSON.stringify(expected)}`;
|
|
145894
|
+
}
|
|
145895
|
+
var zodSchema62 = external_exports.object({
|
|
145896
|
+
name: external_exports.string().describe("Name of the flow being recorded \u2014 the one passed to flow-start-recording."),
|
|
145897
|
+
project_root: external_exports.string().describe(
|
|
145898
|
+
"Absolute path to the project root of the flow being recorded \u2014 the same value passed to flow-start-recording. Together with `name` it identifies which recording to finish."
|
|
145899
|
+
)
|
|
145900
|
+
});
|
|
145901
|
+
function attachStepWarnings(summary, warnings) {
|
|
145902
|
+
if (warnings.size === 0) return summary;
|
|
145903
|
+
return summary.flatMap((line, i) => {
|
|
145904
|
+
const recorded = warnings.get(i + 1);
|
|
145905
|
+
return recorded ? [line, ` warning: ${recorded.warning}`] : [line];
|
|
145906
|
+
});
|
|
145907
|
+
}
|
|
145908
|
+
function warningHeadline(warnings, discarded) {
|
|
145909
|
+
const counts = { conversion: 0, wait: 0 };
|
|
145910
|
+
for (const { kind } of warnings.values()) counts[kind] += 1;
|
|
145911
|
+
const clauses = [];
|
|
145912
|
+
if (counts.conversion > 0) {
|
|
145913
|
+
clauses.push(
|
|
145914
|
+
`${counts.conversion} ${counts.conversion === 1 ? "step carries" : "steps carry"} a cross-tree warning about converting a recorded wait`
|
|
145915
|
+
);
|
|
145916
|
+
}
|
|
145917
|
+
if (counts.wait > 0) {
|
|
145918
|
+
clauses.push(
|
|
145919
|
+
`${counts.wait} ${counts.wait === 1 ? "step" : "steps"} recorded a wait that did not pass`
|
|
145920
|
+
);
|
|
145921
|
+
}
|
|
145922
|
+
const carried = clauses.length === 0 ? "" : ` \u2014 ${clauses.join(", and ")}; read \`summary\` before converting or replaying`;
|
|
145923
|
+
if (discarded === 0) return carried;
|
|
145924
|
+
const one = discarded === 1;
|
|
145925
|
+
const drop = `${discarded} ${one ? "warning" : "warnings"} raised during this recording ${one ? "is" : "are"} NOT in \`summary\`: a hand edit to the .yaml moved the ${one ? "step it judged" : "steps they judged"}, so which step ${one ? "it belongs" : "they belong"} to is no longer knowable \u2014 re-record ${one ? "that wait" : "those waits"} to see ${one ? "it" : "them"} again`;
|
|
145926
|
+
return carried === "" ? ` \u2014 ${drop}` : `${carried}. ${drop}`;
|
|
145927
|
+
}
|
|
145928
|
+
function anchoredWarnings(session, steps) {
|
|
145929
|
+
const kept = /* @__PURE__ */ new Map();
|
|
145930
|
+
const recorded = session.flow.steps;
|
|
145931
|
+
if (recorded.length !== steps.length) return kept;
|
|
145932
|
+
if (!steps.every((step, i) => stepAnchor(step) === stepAnchor(recorded[i]))) return kept;
|
|
145933
|
+
for (const [n, verdict] of session.stepWarnings ?? []) {
|
|
145934
|
+
const step = steps[n - 1];
|
|
145935
|
+
if (step !== void 0 && stepAnchor(step) === verdict.step) kept.set(n, verdict);
|
|
145936
|
+
}
|
|
145937
|
+
return kept;
|
|
145938
|
+
}
|
|
145939
|
+
var flowFinishRecordingTool = {
|
|
145940
|
+
id: "flow-finish-recording",
|
|
145941
|
+
interaction: {
|
|
145942
|
+
// Name the flow: other recordings stay live across this call, so an
|
|
145943
|
+
// unqualified "Finishing flow recording" would not identify which one.
|
|
145944
|
+
startedMsg: ({ params }) => `Finishing recording of flow ${params.name}`,
|
|
145945
|
+
// `params.name` rather than the basename of `result.path`: the two are the
|
|
145946
|
+
// same string on every branch — `assertSafeFlowName` admits no dots or
|
|
145947
|
+
// separators, so `getFlowPath` produces `<name>.yaml` and nothing else —
|
|
145948
|
+
// and this spelling matches the two formatters either side of it.
|
|
145949
|
+
completedMsg: ({ params }) => `Saved recorded flow ${params.name}`,
|
|
145950
|
+
failedMsg: ({ params, failureSignal: failureSignal2 }) => `Failed to finish recording of flow ${params.name}: ${failureSignal2.error_code}`
|
|
145951
|
+
},
|
|
145952
|
+
description: `Finish recording the flow named by \`name\` + \`project_root\`, leaving recordings under any other key untouched. Returns { message, path, executionPrerequisite, steps, summary, flowFile, savedTo } - a summary of all recorded steps plus the final YAML. Use when you have added all desired steps and want to finalize the flow file. Fails if that flow has no recording in progress.
|
|
145953
|
+
A warning flow-add-step raised on a recorded \`await-ui-element\` is repeated in \`summary\` as a \`warning:\` line of its own, right below the step it judges, and \`message\` counts them by kind. A warning is repeated only while the step it judges is still identifiable by its number: hand-editing the .yaml during the recording moves the steps, so those warnings are DROPPED rather than pinned on whichever step inherited the number, and \`message\` says how many were dropped. A step that carries a cross-tree warning was re-probed against the runner's tree: read it before converting that wait to \`await:\`/\`assert:\`, which is what the verdict is about and what this moment is for. A step that recorded a wait which did not pass was never probed at all, and its own warning names the CAUSE, because only one of them judges the condition: an unmet wait was read and found false, and it stops the run at replay; a wait whose tree source could not be read, or one that was cancelled, observed nothing and leaves the condition UNKNOWN rather than known-bad. Read those before replaying.
|
|
145954
|
+
You can still edit the .yaml file directly afterwards to remove or reorder steps.`,
|
|
145955
|
+
zodSchema: zodSchema62,
|
|
145956
|
+
services: () => ({}),
|
|
145957
|
+
async execute(_services, params) {
|
|
145958
|
+
const { filePath, flowFile, savedTo, flow, summary, headline } = await withFlowFileLock(
|
|
145959
|
+
params.project_root,
|
|
145960
|
+
params.name,
|
|
145961
|
+
async () => {
|
|
145962
|
+
const session = await requireRecordingSession(params.project_root, params.name);
|
|
145963
|
+
const filePath2 = session.filePath;
|
|
145964
|
+
let flowFile2;
|
|
145965
|
+
let savedTo2;
|
|
145966
|
+
if (session.persist === "client") {
|
|
145967
|
+
flowFile2 = serializeFlow(session.flow);
|
|
145968
|
+
savedTo2 = clientFileDirective(filePath2, flowFile2);
|
|
145969
|
+
} else {
|
|
145970
|
+
flowFile2 = await fs47.readFile(filePath2, "utf8");
|
|
145971
|
+
savedTo2 = filePath2;
|
|
145972
|
+
}
|
|
145973
|
+
const flow2 = parseFlow(flowFile2);
|
|
145974
|
+
const anchored = anchoredWarnings(session, flow2.steps);
|
|
145975
|
+
const summary2 = attachStepWarnings(summarizeSteps(flow2), anchored);
|
|
145976
|
+
const discarded = (session.discardedWarnings ?? 0) + (session.stepWarnings?.size ?? 0) - anchored.size;
|
|
145977
|
+
const headline2 = warningHeadline(anchored, discarded);
|
|
145978
|
+
clearRecordingSession(session);
|
|
145979
|
+
return { filePath: filePath2, flowFile: flowFile2, savedTo: savedTo2, flow: flow2, summary: summary2, headline: headline2 };
|
|
145980
|
+
}
|
|
145981
|
+
);
|
|
145982
|
+
return {
|
|
145983
|
+
// Name the counts in `message` as well. A caller that reads only
|
|
145984
|
+
// `message` would otherwise polish blind.
|
|
145985
|
+
message: `Finished recording "${params.name}" flow (${flow.steps.length} steps)` + headline,
|
|
145986
|
+
path: filePath,
|
|
145987
|
+
executionPrerequisite: flow.executionPrerequisite,
|
|
145988
|
+
steps: flow.steps.length,
|
|
145989
|
+
summary,
|
|
145990
|
+
flowFile,
|
|
145991
|
+
savedTo
|
|
145992
|
+
};
|
|
145993
|
+
}
|
|
145994
|
+
};
|
|
145995
|
+
function renderToolArgs(args) {
|
|
145996
|
+
try {
|
|
145997
|
+
return `${JSON.stringify(args)}`;
|
|
145998
|
+
} catch {
|
|
145999
|
+
return "[cyclic args]";
|
|
146000
|
+
}
|
|
146001
|
+
}
|
|
146002
|
+
function delayLabel(step) {
|
|
146003
|
+
if (!step.delayMs) return "";
|
|
146004
|
+
const ms = Number(step.delayMs);
|
|
146005
|
+
return Number.isFinite(ms) && ms >= 1 ? ` (after ${ms}ms)` : "";
|
|
146006
|
+
}
|
|
146007
|
+
function summarizeSteps(flow) {
|
|
146008
|
+
return flow.steps.map((step, i) => summarizeStep(step, i + 1));
|
|
146009
|
+
}
|
|
146010
|
+
function stepAnchor(step) {
|
|
146011
|
+
return summarizeStep(step, 0);
|
|
146012
|
+
}
|
|
146013
|
+
function summarizeStep(step, n) {
|
|
146014
|
+
switch (step.kind) {
|
|
146015
|
+
case "echo":
|
|
146016
|
+
return `${n}. echo: ${step.message}`;
|
|
146017
|
+
case "launch":
|
|
146018
|
+
return `${n}. launch: ${typeof step.app === "string" ? step.app : JSON.stringify(step.app)}`;
|
|
146019
|
+
case "run":
|
|
146020
|
+
return `${n}. run: ${step.flow}`;
|
|
146021
|
+
case "tap":
|
|
146022
|
+
case "long-press": {
|
|
146023
|
+
const target = step.selector ? selectorLabel(step.selector) : `(${step.x}, ${step.y})`;
|
|
146024
|
+
const times = step.kind === "tap" && step.times !== void 0 && step.times > 1 ? ` \xD7${step.times}` : "";
|
|
146025
|
+
const held = step.kind === "long-press" && step.duration !== void 0 ? ` for ${step.duration}ms` : "";
|
|
146026
|
+
return `${n}. ${step.kind}: ${target}${times}${held}`;
|
|
146027
|
+
}
|
|
146028
|
+
case "type":
|
|
146029
|
+
return `${n}. type: ${selectorLabel(step.into)} \u2190 "${step.text}"`;
|
|
146030
|
+
case "await":
|
|
146031
|
+
case "assert": {
|
|
146032
|
+
const tail = step.condition === "text" ? textConditionLabel(step.selector, step.expectedText, step.textMatch) : `${step.condition} ${selectorLabel(step.selector)}`;
|
|
146033
|
+
return `${n}. ${step.kind}: ${tail}`;
|
|
146034
|
+
}
|
|
146035
|
+
case "wait":
|
|
146036
|
+
return `${n}. wait: ${step.ms}ms`;
|
|
146037
|
+
case "when": {
|
|
146038
|
+
const cond = step.condition.kind === "platform" ? `platform ${step.condition.platform}` : step.condition.condition === "text" ? textConditionLabel(
|
|
146039
|
+
step.condition.selector,
|
|
146040
|
+
step.condition.expectedText,
|
|
146041
|
+
step.condition.textMatch
|
|
146042
|
+
) : `${step.condition.condition} ${selectorLabel(step.condition.selector)}`;
|
|
146043
|
+
const count2 = step.steps.length;
|
|
146044
|
+
return `${n}. when: ${cond} (${count2} step${count2 === 1 ? "" : "s"})`;
|
|
146045
|
+
}
|
|
146046
|
+
case "scroll-to":
|
|
146047
|
+
return `${n}. scroll-to: ${selectorLabel(step.target)} (${step.direction})`;
|
|
146048
|
+
case "pinch":
|
|
146049
|
+
return `${n}. pinch: scale ${step.scale}${step.selector ? ` on ${selectorLabel(step.selector)}` : ""}`;
|
|
146050
|
+
case "rotate":
|
|
146051
|
+
return `${n}. rotate: by ${step.by}\xB0${step.selector ? ` on ${selectorLabel(step.selector)}` : ""}`;
|
|
146052
|
+
case "snapshot":
|
|
146053
|
+
return `${n}. snapshot: ${step.name}`;
|
|
146054
|
+
case "idle":
|
|
146055
|
+
return `${n}. await: screen idle`;
|
|
146056
|
+
case "tool":
|
|
146057
|
+
default:
|
|
146058
|
+
return `${n}. tool: ${step.name} ${renderToolArgs(step.args)}${delayLabel(step)}`;
|
|
146059
|
+
}
|
|
146060
|
+
}
|
|
146061
|
+
|
|
146062
|
+
// ../tool-server/src/tools/flows/flow-add-step.ts
|
|
146063
|
+
var zodSchema63 = external_exports.object({
|
|
146064
|
+
name: external_exports.string().describe("Name of the flow being recorded \u2014 the one passed to flow-start-recording."),
|
|
146065
|
+
project_root: external_exports.string().describe(
|
|
146066
|
+
"Absolute path to the project root of the flow being recorded \u2014 the same value passed to flow-start-recording. Together with `name` it identifies which recording this step belongs to."
|
|
146067
|
+
),
|
|
146068
|
+
command: external_exports.string().describe('MCP tool name (e.g. "gesture-tap", "screenshot", "launch-app")'),
|
|
146069
|
+
args: external_exports.string().optional().describe(
|
|
146070
|
+
`Tool arguments as a JSON string, e.g. '{"udid": "ABC", "x": 0.5, "y": 0.3}'. Omit for tools with no arguments.`
|
|
146071
|
+
),
|
|
146072
|
+
delayMs: external_exports.number().int().min(0).optional().describe("Milliseconds to sleep before executing this step during replay.")
|
|
146073
|
+
});
|
|
146074
|
+
var REPLAY_TREE_SOURCES = {
|
|
146075
|
+
ios: "native-devtools",
|
|
146076
|
+
android: "android-devtools"
|
|
146077
|
+
};
|
|
146078
|
+
function recordedLaunchedApp(session, platform) {
|
|
146079
|
+
for (let i = session.flow.steps.length - 1; i >= 0; i--) {
|
|
146080
|
+
const step = session.flow.steps[i];
|
|
146081
|
+
if (step.kind === "launch") return appIdForPlatform(step.app, platform) ?? void 0;
|
|
146082
|
+
}
|
|
146083
|
+
return void 0;
|
|
146084
|
+
}
|
|
146085
|
+
function fallbackSourceWarning(source, platform) {
|
|
146086
|
+
const expected = REPLAY_TREE_SOURCES[platform];
|
|
146087
|
+
if (!expected || source === expected) return void 0;
|
|
146088
|
+
return `selector captured from the fallback ${source} tree (${expected} unavailable) \u2014 replay resolves against the full hierarchy, which may not match it`;
|
|
146089
|
+
}
|
|
146090
|
+
function platformOf(udid) {
|
|
146091
|
+
return typeof udid === "string" ? resolveDevice(udid).platform : void 0;
|
|
146092
|
+
}
|
|
146093
|
+
var UNSUPPORTED_PLATFORM = {
|
|
146094
|
+
divergence: "The recorder and the runner read different projections of the screen.",
|
|
146095
|
+
read: "No read-only tool is known to report the runner's projection on this platform \u2014 keep the step raw"
|
|
146096
|
+
};
|
|
146097
|
+
function retargetRemedy(idKind, condition) {
|
|
146098
|
+
if (condition === "hidden") {
|
|
146099
|
+
return `but this verdict says that tree still HAS the element, so retargeting at ${idKind} it definitely carries is the wrong direction \u2014 either narrow the selector until it matches only what you expect to leave, or gate on something that does leave, and prove it with \`flow-execute\`; or keep the step raw`;
|
|
146100
|
+
}
|
|
146101
|
+
return `so retarget the DIRECTIVE at ${idKind} the full hierarchy carries and prove it with \`flow-execute\`, or keep the step raw`;
|
|
146102
|
+
}
|
|
146103
|
+
function runnerSideReadClause(udid, condition) {
|
|
146104
|
+
const platform = platformOf(udid);
|
|
146105
|
+
if (platform === "ios") {
|
|
146106
|
+
return "No read-only tool reports the runner's projection on iOS \u2014 `native-find-views` and `native-full-hierarchy` return the RAW view tree, keeping the hidden, transparent, scroll-clipped and unlabelled container views the runner drops, and neither answers the question a selector asks: `native-find-views` matches `identifier`/`label`/`className` EXACTLY and takes no substring `text` or `role`, and `native-full-hierarchy` takes no matcher at all \u2014 it dumps the tree for you to read \u2014 " + retargetRemedy("an `id`", condition);
|
|
146107
|
+
}
|
|
146108
|
+
if (platform === "android") {
|
|
146109
|
+
return "No read-only tool exposes the runner's full hierarchy on Android \u2014 `describe` returns the trimmed tree the recorder read, not the runner's \u2014 " + retargetRemedy("a `resource-id`", condition);
|
|
146110
|
+
}
|
|
146111
|
+
if (platform === "chromium") {
|
|
146112
|
+
const settle = "No read-only tool exposes the runner's trimmed tree on Chromium \u2014 `describe` re-reads the same DOM on a shorter walk, so it both lists nodes the runner drops and omits nodes the runner keeps \u2014 so settle it by running the conversion: put the directive in a flow and `flow-execute` it. ";
|
|
146113
|
+
if (condition === "hidden") {
|
|
146114
|
+
return settle + "This verdict says that tree still HAS the element, so the usual chromium tips are the wrong direction here: a `scroll-to` before the check, or a switch to an `id`/`role` selector, only makes the directive match more surely. Absence from `describe` is not the element having left, either \u2014 on a dense page the recorder's walk stops at 5000 nodes where the runner's goes to 12000. Narrow the selector until it matches only what you expect to leave, or gate on something that does leave; or keep the step raw";
|
|
146115
|
+
}
|
|
146116
|
+
return settle + // Name both axes: `normRect` clamps each edge on its own, so a
|
|
146117
|
+
// horizontally scrolled node comes back zero-WIDTH at a normal height.
|
|
146118
|
+
"A zero-area frame in `describe` means off-viewport \u2014 zero height for a node above or below the viewport, zero width for one left or right of it, since the walker clamps each edge on its own \u2014 and the fix there is a `scroll-to` before the check rather than a different selector; a password field reaches the runner under the name `[password]`, so only an `id`/`role` selector can match it";
|
|
146119
|
+
}
|
|
146120
|
+
if (platform === "vega") {
|
|
146121
|
+
return "`describe` reads the same source the runner does, so re-run the wait rather than re-recording the selector";
|
|
146122
|
+
}
|
|
146123
|
+
return UNSUPPORTED_PLATFORM.read;
|
|
146124
|
+
}
|
|
146125
|
+
var SCREEN_MAY_HAVE_MOVED = " A screen that changed between the live wait and this re-probe reads the same way, so rule that out first.";
|
|
146126
|
+
function treeDivergenceFor(udid, condition) {
|
|
146127
|
+
const platform = platformOf(udid);
|
|
146128
|
+
if (platform === "ios") {
|
|
146129
|
+
return "The recorder reads the accessibility tree and the runner reads the full native view hierarchy; they overlap but neither contains the other." + SCREEN_MAY_HAVE_MOVED;
|
|
146130
|
+
}
|
|
146131
|
+
if (platform === "chromium") {
|
|
146132
|
+
if (condition === "hidden") {
|
|
146133
|
+
return "Both read the same DOM but project it differently, and here it is the RECORDER that never saw the element: the live wait passed on absence, and this verdict says the runner's tree holds it \u2014 so nothing the flow tree DROPS can be the cause. What is left is the recorder's own limit: its walk stops at 5000 nodes where the flow tree's goes to 12000, so on a dense page the element is past the end of what it read." + SCREEN_MAY_HAVE_MOVED;
|
|
146134
|
+
}
|
|
146135
|
+
return "Both read the same DOM but project it differently, and here it is the RUNNER's side to check: the live wait passed, so the recorder's tree did hold a matching element and its 5000-node walk limit is not what went wrong. The flow tree keeps only addressable nodes (id, label, value, clickable or focused) whose frame the walker did not clamp to zero area for being off-viewport, and it redacts a password field's name to `[password]`." + SCREEN_MAY_HAVE_MOVED;
|
|
146136
|
+
}
|
|
146137
|
+
if (platform === "android") {
|
|
146138
|
+
return "Both read the same `getHierarchy` dump from the android-devtools helper; this host then parses it two ways. `describe`'s interactables trim collapses a `testID`-only container into a passthrough and drops the node carrying the id, while borrowing a descendant's text into an unlabelled clickable's own label \u2014 where the flow adapter keeps every view with a `resource-id` or a label, and asks for 12000 nodes against the helper's 5000 default. So each holds elements the other drops." + SCREEN_MAY_HAVE_MOVED;
|
|
146139
|
+
}
|
|
146140
|
+
if (platform === "vega") {
|
|
146141
|
+
const cause = condition === "text" ? "disagreement means either the SCREEN changed between the live wait and this re-probe or the two sides elected different elements, as above \u2014 not that the two trees differ." : "disagreement means the SCREEN changed between the live wait and this re-probe, not that the two trees differ.";
|
|
146142
|
+
return "Both read the same automation-toolkit page source, and the flow tree only re-shapes it \u2014 it drops no element and its text hoist can only add matches \u2014 so on this platform a " + cause;
|
|
146143
|
+
}
|
|
146144
|
+
return UNSUPPORTED_PLATFORM.divergence;
|
|
146145
|
+
}
|
|
146146
|
+
function awaitStillNeeds(condition) {
|
|
146147
|
+
if (condition === "hidden") return "the element LEAVES that tree";
|
|
146148
|
+
if (condition === "text") return "the element THAT tree elects comes to match on it";
|
|
146149
|
+
return "the element reaches that tree";
|
|
146150
|
+
}
|
|
146151
|
+
function textTieClause(udid) {
|
|
146152
|
+
const order = platformOf(udid) === "ios" ? "and the two are flat lists built from different sources \u2014 the accessibility element order and the view-hierarchy walk \u2014 so neither order follows from the other" : "and the recorder's lists a container before its children where the runner's lists children before their container";
|
|
146153
|
+
return ` Check FIRST whether the selector matches more than one element, because a \`text\` check reads only one of them \u2014 the first visible match in reading order \u2014 and the two sides can elect DIFFERENT ones from the very same nodes: an exact frame tie is settled by which node its tree listed first, ${order}. The reason above quotes whichever element the RUNNER elected, so compare it against the one you meant. If that is what happened, both trees hold both elements and neither the tree differences nor a changed screen below explains anything \u2014 narrow the selector until it resolves a single node, and note that a longer \`await:\` timeout cannot help, since the text it read is already final.`;
|
|
146154
|
+
}
|
|
146155
|
+
var SPELLING_CLAUSE = "Both of those are about the selector exactly as recorded, so convert it in the strict map spelling (`{ text: \u2026 }` / `{ id: \u2026 }`, a straight copy of the step's `selector:`): a bare-string conversion (`{ visible: Continue }`) re-parses as a LOOSE selector \u2014 identifier first, text only as a fallback \u2014 which is a different check this probe never made.";
|
|
146156
|
+
var UNMET_WAIT_WARNING = "recorded, but the wait itself never held \u2014 `await-ui-element` reports an unmet condition by returning success:false instead of failing, so the step was written to the flow anyway. At replay an unmet wait FAILS the step and stops the run there, so re-record it once the condition can actually hold, and delete the failed step after `flow-finish-recording` rather than mid-recording: against a remote client the in-memory copy is authoritative and the next append writes the step straight back, and in host mode the recorder re-reads the file before each append, so an edit that renumbers the steps costs the finish the verdicts it would otherwise carry. The cross-tree re-probe was skipped: it asks whether a check that PASSED would survive conversion to `await:`/`assert:`, and this one did not pass";
|
|
146157
|
+
var UNREADABLE_WAIT_WARNING = "recorded, but this wait reached its deadline without a trustworthy read of the UI tree, so the condition was never judged \u2014 `await-ui-element` returns success:false for that too, and the step was written to the flow anyway. Either no read in the window could be trusted, or the reads went dark before the end and what they saw no longer describes it. Whether the condition holds is UNKNOWN, not known-bad: `toolResult.note` names the tree-source error where a fetch threw, and describes what was seen where the tree was merely empty or degraded. Get that source back and re-record the step to find out. Do not delete the step on this warning alone. The cross-tree re-probe was skipped: it asks whether a check that PASSED would survive conversion to `await:`/`assert:`, and this one never got an answer";
|
|
146158
|
+
var CANCELLED_WAIT_WARNING = "recorded, but this wait was cancelled before its deadline, so the condition was never settled \u2014 `await-ui-element` reports a cancelled wait as success:false, and the step was written to the flow anyway. Whether it holds is UNKNOWN, not known-bad: re-record the step to find out. The cross-tree re-probe was skipped for the same reason";
|
|
146159
|
+
function unmetWaitWarningFor(cause) {
|
|
146160
|
+
if (cause === "unreadable") return UNREADABLE_WAIT_WARNING;
|
|
146161
|
+
if (cause === "cancelled") return CANCELLED_WAIT_WARNING;
|
|
146162
|
+
return UNMET_WAIT_WARNING;
|
|
146163
|
+
}
|
|
146164
|
+
function indeterminateReasonCaveat(udid) {
|
|
146165
|
+
if (platformOf(udid) !== "ios") return "";
|
|
146166
|
+
return ". That reason may tell you to pass `bundleId` \u2014 it is quoted from the shared native-target error, and it does not apply here: the probe predicts an `await:`/`assert:` directive, and no directive takes a bundleId, so neither this probe nor the runner accepts one (the `bundleId` on this step reached the live wait only). What the runner's iOS tree needs is an app with argent's instrumentation loaded \u2014 relaunch it with `launch-app` or a flow `launch:` step. An app that cannot load it at all, such as a `com.apple.*` system app, can never be probed or converted: keep the check as a raw `tool:` step";
|
|
146167
|
+
}
|
|
146168
|
+
var CANCELLED_PROBE_WARNING = "recorded, but the re-probe against the tree the RUNNER reads was cancelled before it answered. The step itself ran and is written to the flow; only the verdict is missing, so whether it would convert to `await:`/`assert:` is UNKNOWN, not known-bad \u2014 record the wait again, uncancelled, before trusting the conversion";
|
|
146169
|
+
var PROBE_MAX_TREE_READ_MS = 2500;
|
|
146170
|
+
var PROBE_ASSERT_GRACE_MS = 1e3;
|
|
146171
|
+
var PROBE_BUDGET_MS = PROBE_ASSERT_GRACE_MS + 2 * PROBE_MAX_TREE_READ_MS;
|
|
146172
|
+
var MAX_PROBE_REASON_CHARS = 200;
|
|
146173
|
+
var PROBE_REASON_TAIL_CHARS = 60;
|
|
146174
|
+
function elisionMarker(dropped) {
|
|
146175
|
+
return `\u2026 (${dropped} more chars) \u2026`;
|
|
146176
|
+
}
|
|
146177
|
+
function cappedReason(reason) {
|
|
146178
|
+
if (reason.length <= MAX_PROBE_REASON_CHARS) return reason;
|
|
146179
|
+
const widestMarker = elisionMarker(reason.length).length;
|
|
146180
|
+
const tailChars = Math.max(
|
|
146181
|
+
0,
|
|
146182
|
+
Math.min(PROBE_REASON_TAIL_CHARS, MAX_PROBE_REASON_CHARS - widestMarker)
|
|
146183
|
+
);
|
|
146184
|
+
const headChars = Math.max(0, MAX_PROBE_REASON_CHARS - widestMarker - tailChars);
|
|
146185
|
+
const dropped = reason.length - headChars - tailChars;
|
|
146186
|
+
return `${reason.slice(0, headChars)}${elisionMarker(dropped)}${reason.slice(reason.length - tailChars)}`;
|
|
146187
|
+
}
|
|
146188
|
+
async function probeAgainstRunnerTree(registry2, ctx, args) {
|
|
146189
|
+
const selector = args.selector;
|
|
146190
|
+
const condition = args.condition;
|
|
146191
|
+
if (typeof condition !== "string" || selector === null || typeof selector !== "object") {
|
|
146192
|
+
return {};
|
|
146193
|
+
}
|
|
146194
|
+
if (typeof args.udid !== "string") return {};
|
|
146195
|
+
const device = resolveDevice(args.udid);
|
|
146196
|
+
const giveUp = new AbortController();
|
|
146197
|
+
const probeSignal = ctx?.signal ? AbortSignal.any([ctx.signal, giveUp.signal]) : giveUp.signal;
|
|
146198
|
+
const settled = await settleWithin(
|
|
146199
|
+
probeWhenCondition(
|
|
146200
|
+
// The loop reads the signal off ActionEnv, so pass it there as well.
|
|
146201
|
+
{ registry: registry2, ctx, device, signal: probeSignal },
|
|
146202
|
+
{
|
|
146203
|
+
condition,
|
|
146204
|
+
selector,
|
|
146205
|
+
expectedText: typeof args.expectedText === "string" ? args.expectedText : void 0,
|
|
146206
|
+
textMatch: args.textMatch
|
|
146207
|
+
}
|
|
146208
|
+
),
|
|
146209
|
+
PROBE_BUDGET_MS,
|
|
146210
|
+
ctx?.signal
|
|
146211
|
+
);
|
|
146212
|
+
giveUp.abort();
|
|
146213
|
+
if (settled.type === "aborted") return { warning: CANCELLED_PROBE_WARNING };
|
|
146214
|
+
const timedOut = settled.type === "timeout";
|
|
146215
|
+
const outcome = settled.type === "value" ? settled.value : {
|
|
146216
|
+
ok: false,
|
|
146217
|
+
indeterminate: true,
|
|
146218
|
+
reason: timedOut ? `the runner's tree was still being read ${PROBE_BUDGET_MS}ms in, which is longer than the recorder waits \u2014 the source is slow, not down` : `reading the runner's tree failed: ${settled.error}`
|
|
146219
|
+
};
|
|
146220
|
+
if (outcome.ok) return {};
|
|
146221
|
+
if (outcome.indeterminate) {
|
|
146222
|
+
return {
|
|
146223
|
+
// Deliberately NOT joined with treeDivergenceFor/runnerSideReadClause.
|
|
146224
|
+
// Nothing was compared, so claiming the two trees differ would send the
|
|
146225
|
+
// author to rewrite a selector that may be perfectly good.
|
|
146226
|
+
//
|
|
146227
|
+
// The reason is quoted whole rather than through `cappedReason`; see
|
|
146228
|
+
// {@link MAX_PROBE_REASON_CHARS}.
|
|
146229
|
+
warning: `this check could not be re-verified against the tree the RUNNER reads (${outcome.reason ?? "no reason given"}), so it passed against the tree \`${AWAIT_UI_ELEMENT_TOOL_ID}\` reads and nothing else. Whether it would convert to \`await:\`/\`assert:\` is UNKNOWN, not known-bad \u2014 ` + // A timeout and an outage need different next moves: "once that tree
|
|
146230
|
+
// source is back" is nonsense for a source that never left.
|
|
146231
|
+
(timedOut ? `re-record this step when the device is quieter, or settle the conversion directly by putting the directive in a flow and running \`flow-execute\`, which has no such ceiling` : `re-probe once that tree source is back before trusting the conversion` + indeterminateReasonCaveat(args.udid))
|
|
146232
|
+
};
|
|
146233
|
+
}
|
|
146234
|
+
return {
|
|
146235
|
+
warning: `recorded, but this condition does NOT hold against the tree the runner resolves directives against (${cappedReason(outcome.reason ?? "no match")}). As the raw \`tool: ${AWAIT_UI_ELEMENT_TOOL_ID}\` step it replays fine \u2014 it reads the same tree it just passed against. What conversion costs you depends on WHY the two disagree: if the trees really do differ over this element, an \`assert:\` conversion fails the same way (it reads that tree on the same short grace this probe just used), and an \`await:\` does too unless ${awaitStillNeeds(condition)} within its longer timeout; if the SCREEN simply moved on since the live wait, this verdict is no evidence against either \u2014 at replay the directive runs where that wait ran, not a moment after it.` + // Ahead of the tree stories: when it applies it makes all of them
|
|
146236
|
+
// inapplicable.
|
|
146237
|
+
(condition === "text" ? textTieClause(args.udid) : "") + " " + SPELLING_CLAUSE + ` ${treeDivergenceFor(args.udid, condition)} ${runnerSideReadClause(args.udid, condition)}`
|
|
146238
|
+
};
|
|
146239
|
+
}
|
|
146240
|
+
async function captureTapSelector(registry2, session, udid, point) {
|
|
146241
|
+
try {
|
|
146242
|
+
const device = resolveDevice(udid);
|
|
146243
|
+
const launched = recordedLaunchedApp(session, device.platform);
|
|
146244
|
+
const { tree, source } = await fetchFlowTree(registry2, device, launched);
|
|
146245
|
+
const node = nodeAtPoint(tree, point);
|
|
146246
|
+
if (!node) return { warning: "no element found under the tap; kept coordinates (brittle)" };
|
|
146247
|
+
const selector = deriveSelector(node);
|
|
146248
|
+
if (!selector)
|
|
146249
|
+
return { warning: "tapped element has no stable text/id; kept coordinates (brittle)" };
|
|
146250
|
+
const resolved = selectorToFrame(tree, selector);
|
|
146251
|
+
if (!resolved) {
|
|
146252
|
+
return {
|
|
146253
|
+
warning: `selector ${describeSelector(selector)} matches no element on this screen; kept coordinates (brittle)`
|
|
146254
|
+
};
|
|
146255
|
+
}
|
|
146256
|
+
if (!frameContains(resolved, point.x, point.y)) {
|
|
146257
|
+
return {
|
|
146258
|
+
warning: `selector ${describeSelector(selector)} resolves to a different element on this screen; kept coordinates (brittle)`
|
|
146259
|
+
};
|
|
146260
|
+
}
|
|
146261
|
+
return { selector, warning: fallbackSourceWarning(source, device.platform) };
|
|
146262
|
+
} catch (err) {
|
|
146263
|
+
return {
|
|
146264
|
+
warning: `selector capture failed (${err instanceof Error ? err.message : String(err)}); kept coordinates`
|
|
146265
|
+
};
|
|
146266
|
+
}
|
|
146267
|
+
}
|
|
146268
|
+
var RUN_TARGET_COMMAND = "flow-execute";
|
|
146269
|
+
async function rewriteSiblingFlowPath(session, args) {
|
|
146270
|
+
const flowPath = args.flow_path;
|
|
146271
|
+
if (typeof flowPath !== "string" || args.name !== void 0) return;
|
|
146272
|
+
const invalid = (detail) => new FailureError(
|
|
146273
|
+
`Cannot record a flow-execute of flow_path "${flowPath}": ${detail}. flow_path carries no file-input resolution through flow-add-step's opaque args \u2014 pass name + project_root for a flow saved beside the recording, or add a \`run: <relative path>.yaml\` step to the flow YAML by hand for a cross-directory target.`,
|
|
146274
|
+
{
|
|
146275
|
+
error_code: FAILURE_CODES.FLOW_FILE_INVALID,
|
|
146276
|
+
failure_stage: "flow_add_step_flow_path",
|
|
146277
|
+
failure_area: "tool_server",
|
|
146278
|
+
error_kind: "validation"
|
|
146279
|
+
}
|
|
146280
|
+
);
|
|
146281
|
+
if (!session || session.persist !== "host") {
|
|
146282
|
+
throw invalid(
|
|
146283
|
+
"the recording is not persisted on this host, so its siblings cannot be resolved here"
|
|
146284
|
+
);
|
|
146285
|
+
}
|
|
146286
|
+
if (flowPath.split(/[\\/]+/).includes("..")) {
|
|
146287
|
+
throw invalid(
|
|
146288
|
+
'flow paths must not contain ".." segments \u2014 sibling identity is decided lexically from this path, and a symlinked directory component would make the rewrite run a different file than the path opens'
|
|
146289
|
+
);
|
|
146290
|
+
}
|
|
146291
|
+
const ext = path33.extname(flowPath);
|
|
146292
|
+
const bareExtension = path33.basename(flowPath).toLowerCase() === ".yaml";
|
|
146293
|
+
if (!bareExtension && ext !== ".yaml") {
|
|
146294
|
+
throw invalid(
|
|
146295
|
+
ext.toLowerCase() === ".yaml" ? `flow files must use the lowercase .yaml extension, not "${ext}"` : "flow files must use the .yaml extension"
|
|
146296
|
+
);
|
|
146297
|
+
}
|
|
146298
|
+
const flowsDir = path33.dirname(session.filePath);
|
|
146299
|
+
if (path33.resolve(path33.dirname(flowPath)) !== path33.resolve(flowsDir)) {
|
|
146300
|
+
throw invalid(
|
|
146301
|
+
`it is not in the recording's flow directory ("${flowsDir}"), and a raw tool: step has no boundary to resolve a path through at replay`
|
|
146302
|
+
);
|
|
146303
|
+
}
|
|
146304
|
+
const stem = bareExtension ? "" : path33.basename(flowPath, ".yaml");
|
|
146305
|
+
assertSafeFlowName(stem);
|
|
146306
|
+
const projectRoot = args.project_root;
|
|
146307
|
+
if (typeof projectRoot !== "string" || !path33.isAbsolute(projectRoot)) {
|
|
146308
|
+
throw invalid(
|
|
146309
|
+
`project_root must be an absolute path (got ${typeof projectRoot === "string" ? `"${projectRoot}"` : "none"}) \u2014 a relative root would be resolved against the tool server's cwd, not the calling agent's`
|
|
146310
|
+
);
|
|
146311
|
+
}
|
|
146312
|
+
if (path33.resolve(flowsDirFor(projectRoot), `${stem}.yaml`) !== path33.resolve(flowPath)) {
|
|
146313
|
+
throw invalid(`project_root "${projectRoot}" does not resolve "${stem}" to it`);
|
|
146314
|
+
}
|
|
146315
|
+
const suppliedBase = path33.basename(flowPath);
|
|
146316
|
+
const spelling = await classifyOnDiskSpelling(flowsDir, suppliedBase);
|
|
146317
|
+
if (spelling.state !== "listed") {
|
|
146318
|
+
const recovery = spelling.state === "absent" ? `pass the basename exactly as it appears on disk` : spelling.addressable ? `pass flow_path with the on-disk basename "${spelling.actual}"` : `rename "${spelling.actual}" to "${suppliedBase}" to record it \u2014 flow files must be lowercase .yaml`;
|
|
146319
|
+
throw invalid(
|
|
146320
|
+
`the file must be named as it appears on disk \u2014 no directory entry is named "${suppliedBase}"` + (spelling.state === "case_folded" ? ` (this filesystem matched it case-insensitively to "${spelling.actual}")` : "") + `, so the recorded run: step would name a flow no case-sensitive checkout can find \u2014 ` + recovery
|
|
146321
|
+
);
|
|
146322
|
+
}
|
|
146323
|
+
delete args.flow_path;
|
|
146324
|
+
args.name = stem;
|
|
146325
|
+
}
|
|
146326
|
+
async function captureRunTarget(session, args) {
|
|
146327
|
+
const name = typeof args.name === "string" ? args.name : void 0;
|
|
146328
|
+
if (name === void 0) {
|
|
146329
|
+
return { warning: "flow-execute call had no flow name; kept the raw step" };
|
|
146330
|
+
}
|
|
146331
|
+
if (session.persist !== "host") {
|
|
146332
|
+
return {
|
|
146333
|
+
warning: `kept the raw flow-execute step \u2014 run: composition is host-resolved, so a remote recording can't reference "${name}" portably`
|
|
146334
|
+
};
|
|
146335
|
+
}
|
|
146336
|
+
try {
|
|
146337
|
+
assertSafeFlowName(name);
|
|
146338
|
+
const realFlowPath = await fs48.realpath(session.filePath);
|
|
146339
|
+
const flowsDir = path33.dirname(realFlowPath);
|
|
146340
|
+
const fragPath = path33.join(flowsDir, `${name}.yaml`);
|
|
146341
|
+
const projectRoot = args.project_root;
|
|
146342
|
+
if (typeof projectRoot !== "string" || !path33.isAbsolute(projectRoot)) {
|
|
146343
|
+
return {
|
|
146344
|
+
warning: `kept the raw flow-execute step \u2014 project_root must be an absolute path (got ${typeof projectRoot === "string" ? `"${projectRoot}"` : "none"}) to confirm "${name}" names the recording's own sibling`
|
|
146345
|
+
};
|
|
146346
|
+
}
|
|
146347
|
+
const spelling = await classifyOnDiskSpelling(flowsDir, `${name}.yaml`);
|
|
146348
|
+
if (spelling.state === "case_folded") {
|
|
146349
|
+
const recovery = spelling.addressable ? `re-run it as name "${path33.basename(spelling.actual, ".yaml")}" to record it` : `rename "${spelling.actual}" to "${name}.yaml" to record it \u2014 flow files must be lowercase .yaml`;
|
|
146350
|
+
return {
|
|
146351
|
+
warning: `kept the raw flow-execute step \u2014 no sibling is named "${name}.yaml" (this filesystem matched it case-insensitively to "${spelling.actual}"), so a run: ${name} step would name a flow no case-sensitive checkout can find \u2014 ${recovery}`
|
|
146352
|
+
};
|
|
146353
|
+
}
|
|
146354
|
+
parseFlow(await fs48.readFile(fragPath, "utf8"));
|
|
146355
|
+
let executedPath;
|
|
146356
|
+
try {
|
|
146357
|
+
executedPath = await fs48.realpath(path33.join(flowsDirFor(projectRoot), `${name}.yaml`));
|
|
146358
|
+
} catch {
|
|
146359
|
+
executedPath = void 0;
|
|
146360
|
+
}
|
|
146361
|
+
if (executedPath === void 0) {
|
|
146362
|
+
return {
|
|
146363
|
+
warning: `kept the raw flow-execute step \u2014 could not verify which file the live flow-execute ran ("${name}" has no canonical file in project_root's flows dir to compare the sibling against)`
|
|
146364
|
+
};
|
|
146365
|
+
}
|
|
146366
|
+
if (executedPath !== await fs48.realpath(fragPath)) {
|
|
146367
|
+
return {
|
|
146368
|
+
warning: `kept the raw flow-execute step \u2014 project_root "${projectRoot}" resolves "${name}" to "${executedPath}", not the recording's sibling "${fragPath}", so "${name}.yaml" beside the recording's real file is not the file the live flow-execute ran and a run: ${name} step would replay a different flow than the one that just ran`
|
|
146369
|
+
};
|
|
146370
|
+
}
|
|
146371
|
+
return { flow: `${name}.yaml` };
|
|
146372
|
+
} catch (err) {
|
|
146373
|
+
return {
|
|
146374
|
+
warning: `could not resolve "${name}" as a sibling fragment (${err instanceof Error ? err.message : String(err)}); kept the raw flow-execute step`
|
|
146375
|
+
};
|
|
146376
|
+
}
|
|
146377
|
+
}
|
|
146378
|
+
function createFlowAddStepTool(registry2) {
|
|
146379
|
+
return {
|
|
146380
|
+
id: "flow-add-step",
|
|
146381
|
+
interaction: {
|
|
146382
|
+
// Name the flow: recordings are concurrent, so several of these lines can
|
|
146383
|
+
// interleave in one log and "the recorded flow" would not identify which.
|
|
146384
|
+
startedMsg: ({ params }) => `Adding ${params.command} step to flow ${params.name}`,
|
|
146385
|
+
completedMsg: ({ params }) => `Added ${params.command} step to flow ${params.name}`,
|
|
146386
|
+
failedMsg: ({ params, failureSignal: failureSignal2 }) => `Failed to add ${params.command} step to flow ${params.name}: ${failureSignal2.error_code}`
|
|
146387
|
+
},
|
|
146388
|
+
description: `Execute a tool call and record it as a step in the flow named by \`name\` + \`project_root\` (the recording must already be open \u2014 see flow-start-recording). Use when recording a flow and you want to run and capture each action. A coordinate \`gesture-tap\` is recorded as a portable \`tap: { selector }\` step when the tapped element has stable text/identifier (otherwise coordinates are kept with a warning); a \`restart-app\` is recorded as a \`launch\` step (record one FIRST to make the flow a self-contained e2e flow; restart-app has no chromium support, so a chromium flow records as a fragment \u2014 add the \`launch: { chromium: <app path> }\` line to the YAML afterward, deleting the executionPrerequisite line if one was recorded: a flow that starts with a launch must not declare it).
|
|
146389
|
+
A recorded \`await-ui-element\` that PASSED is re-probed against the tree the RUNNER resolves \`await:\`/\`assert:\` directives against, which is NOT the tree the live call read; a wait that came back \`{ success: false }\` is not probed at all, and its warning says so; when the condition does not hold there the step is still recorded and \`message\` carries a warning to read before converting \u2014 whether the conversion actually breaks depends on WHY the two disagree, since a screen that moved on between the live wait and the re-probe reads the same way. If that tree could not be read at all, the warning says so instead: the conversion is UNKNOWN, not known-bad. The probe judges the selector exactly as recorded, so write the conversion in the strict map spelling (\`{ visible: { text: Continue } }\`, copying the step's \`selector:\`) \u2014 the bare-string spelling (\`{ visible: Continue }\`) re-parses as a loose selector that resolves identifier-first and falls back to text, which is a different check. \`message\` also warns when the live wait itself came back \`{ success: false }\` \u2014 that tool reports a failed wait by returning rather than throwing, so the step is recorded either way. That warning names the cause, because only one of them judges the condition: a genuine miss will stop the run at replay, while a wait whose tree source was unreadable, or one that was cancelled, observed nothing and leaves the condition UNKNOWN.
|
|
146390
|
+
Returns { message, toolResult, stepCount, recorded, savedTo } on success \u2014 \`message\` is \`Step added to "<name>" flow\` plus any warning about what was recorded (read it; a warning never means the step was skipped). If it fails an error is returned and nothing is recorded.
|
|
146391
|
+
If a step was recorded by mistake, remove it from the .yaml after \`flow-finish-recording\` rather than during the recording: against a remote client the in-memory copy is authoritative and every write serializes it over your edit, and in host mode a mid-recording edit renumbers the steps, which costs the finish the cross-tree verdicts anchored to them.`,
|
|
146392
|
+
// The recorded tool RUNS here, so this call lasts as long as whatever it
|
|
146393
|
+
// wraps, and the three it most often wraps declare this too. Without it the
|
|
146394
|
+
// MCP adapter capped the POST at 30s and retried the identical body four
|
|
146395
|
+
// more times — and every retry re-runs the action and appends another step,
|
|
146396
|
+
// because an aborted request still appends its first.
|
|
146397
|
+
longRunning: true,
|
|
146398
|
+
zodSchema: zodSchema63,
|
|
146399
|
+
services: () => ({}),
|
|
146400
|
+
async execute(_services, params, ctx) {
|
|
146401
|
+
const session = await requireRecordingSession(params.project_root, params.name);
|
|
146402
|
+
const args = params.args ? JSON.parse(params.args) : {};
|
|
146403
|
+
if (params.command === RUN_TARGET_COMMAND) await rewriteSiblingFlowPath(session, args);
|
|
146404
|
+
const isTap = params.command === "gesture-tap" && params.delayMs === void 0 && typeof args.udid === "string" && typeof args.x === "number" && typeof args.y === "number";
|
|
146405
|
+
let captured;
|
|
146406
|
+
if (isTap) {
|
|
146407
|
+
captured = await captureTapSelector(registry2, session, args.udid, {
|
|
146408
|
+
x: args.x,
|
|
146409
|
+
y: args.y
|
|
146410
|
+
});
|
|
146411
|
+
}
|
|
146412
|
+
const toolResult = await invokeSubTool(registry2, ctx, params.command, args);
|
|
146413
|
+
let waitWarning;
|
|
146414
|
+
if (params.command === AWAIT_UI_ELEMENT_TOOL_ID) {
|
|
146415
|
+
if (isUnmetUiWaitResult(params.command, toolResult)) {
|
|
146416
|
+
waitWarning = {
|
|
146417
|
+
warning: unmetWaitWarningFor(unmetUiWaitCause(toolResult)),
|
|
146418
|
+
kind: "wait"
|
|
146419
|
+
};
|
|
146420
|
+
} else {
|
|
146421
|
+
const probed = (await probeAgainstRunnerTree(registry2, ctx, args)).warning;
|
|
146422
|
+
if (probed) waitWarning = { warning: probed, kind: "conversion" };
|
|
146423
|
+
}
|
|
146424
|
+
}
|
|
146425
|
+
const runTarget = params.command === RUN_TARGET_COMMAND && params.delayMs === void 0 ? await captureRunTarget(session, args) : void 0;
|
|
146426
|
+
const strippedArgs = stripDeviceKeys(args);
|
|
146427
|
+
const isLaunch = params.command === "restart-app" && params.delayMs === void 0 && typeof strippedArgs.bundleId === "string" && Object.keys(strippedArgs).length === 1;
|
|
146428
|
+
const cc = args.clickCount;
|
|
146429
|
+
const tapTimes = isTap && typeof cc === "number" && Number.isInteger(cc) && cc >= 2 && cc <= 10 ? { times: cc } : {};
|
|
146430
|
+
let step;
|
|
146431
|
+
let warning;
|
|
146432
|
+
if (captured?.selector) {
|
|
146433
|
+
step = { kind: "tap", selector: captured.selector, ...tapTimes };
|
|
146434
|
+
warning = captured.warning;
|
|
146435
|
+
} else if (isTap) {
|
|
146436
|
+
step = { kind: "tap", x: args.x, y: args.y, ...tapTimes };
|
|
146437
|
+
warning = captured?.warning;
|
|
146438
|
+
} else if (isLaunch) {
|
|
146439
|
+
step = { kind: "launch", app: strippedArgs.bundleId };
|
|
146440
|
+
} else if (runTarget?.flow) {
|
|
146441
|
+
step = { kind: "run", flow: runTarget.flow };
|
|
146442
|
+
} else {
|
|
146443
|
+
warning = waitWarning?.warning ?? runTarget?.warning;
|
|
146444
|
+
step = {
|
|
146445
|
+
kind: "tool",
|
|
146446
|
+
name: params.command,
|
|
146447
|
+
args: strippedArgs,
|
|
146448
|
+
delayMs: params.delayMs
|
|
146449
|
+
};
|
|
146450
|
+
}
|
|
146451
|
+
const { savedTo, stepCount } = await appendStepToFlow(session, step);
|
|
146452
|
+
if (waitWarning) {
|
|
146453
|
+
(session.stepWarnings ??= /* @__PURE__ */ new Map()).set(stepCount, {
|
|
146454
|
+
...waitWarning,
|
|
146455
|
+
step: stepAnchor(step)
|
|
146456
|
+
});
|
|
146457
|
+
}
|
|
146458
|
+
return {
|
|
146459
|
+
message: `Step added to "${params.name}" flow${warning ? ` \u2014 ${warning}` : ""}`,
|
|
146460
|
+
toolResult,
|
|
146461
|
+
stepCount,
|
|
146462
|
+
recorded: summarizeStep(step, stepCount),
|
|
146463
|
+
// Host mode: a path. Client mode: the directive that carries the YAML
|
|
146464
|
+
// to the client, which IS the persistence mechanism there — the one
|
|
146465
|
+
// place the full file still has to travel per step.
|
|
146466
|
+
savedTo
|
|
146467
|
+
};
|
|
146468
|
+
}
|
|
146469
|
+
};
|
|
146470
|
+
}
|
|
146471
|
+
|
|
146472
|
+
// ../tool-server/src/tools/flows/flow-insert-echo.ts
|
|
146473
|
+
init_zod();
|
|
146474
|
+
var zodSchema64 = external_exports.object({
|
|
146475
|
+
name: external_exports.string().describe("Name of the flow being recorded \u2014 the one passed to flow-start-recording."),
|
|
146476
|
+
project_root: external_exports.string().describe(
|
|
146477
|
+
"Absolute path to the project root of the flow being recorded \u2014 the same value passed to flow-start-recording. Together with `name` it identifies which recording this echo belongs to."
|
|
146478
|
+
),
|
|
146479
|
+
message: external_exports.string().describe("Message to echo when the flow is replayed")
|
|
146480
|
+
});
|
|
146481
|
+
var flowInsertEchoTool = {
|
|
146482
|
+
id: "flow-add-echo",
|
|
146483
|
+
interaction: {
|
|
146484
|
+
// Name the flow: recordings are concurrent, so several of these lines can
|
|
146485
|
+
// interleave in one log and "the recorded flow" would not identify which.
|
|
146486
|
+
startedMsg: ({ params }) => `Adding note to flow ${params.name}`,
|
|
146487
|
+
completedMsg: ({ params }) => `Added note to flow ${params.name}`,
|
|
146488
|
+
failedMsg: ({ params, failureSignal: failureSignal2 }) => `Failed to add note to flow ${params.name}: ${failureSignal2.error_code}`
|
|
146489
|
+
},
|
|
146490
|
+
description: `Record an echo step in the flow named by \`name\` + \`project_root\`. Echo steps print a message when the flow is replayed \u2014 useful as labels between tool calls.
|
|
146491
|
+
Use when you want to annotate a recorded flow with a human-readable label or checkpoint message.
|
|
146492
|
+
Returns { message, stepCount, savedTo }. Fails if that flow has no recording in progress.`,
|
|
146493
|
+
zodSchema: zodSchema64,
|
|
146494
|
+
services: () => ({}),
|
|
146495
|
+
async execute(_services, params) {
|
|
146496
|
+
const session = await requireRecordingSession(params.project_root, params.name);
|
|
146497
|
+
const { savedTo, stepCount } = await appendStepToFlow(session, {
|
|
146498
|
+
kind: "echo",
|
|
146499
|
+
message: params.message
|
|
146500
|
+
});
|
|
146501
|
+
return {
|
|
146502
|
+
message: `Echo added to "${params.name}" flow`,
|
|
146503
|
+
stepCount,
|
|
146504
|
+
savedTo
|
|
146505
|
+
};
|
|
146506
|
+
}
|
|
146507
|
+
};
|
|
146508
|
+
|
|
146509
|
+
// ../tool-server/src/tools/flows/flow-run.ts
|
|
146510
|
+
init_zod();
|
|
146511
|
+
var fs51 = __toESM(require("node:fs/promises"));
|
|
146512
|
+
var path36 = __toESM(require("node:path"));
|
|
146513
|
+
init_src();
|
|
146514
|
+
|
|
146515
|
+
// ../tool-server/src/tools/flows/flow-nested-outcome.ts
|
|
146516
|
+
var FLOW_EXECUTE_TOOL_ID = "flow-execute";
|
|
146517
|
+
var RUN_SEQUENCE_TOOL_ID = "run-sequence";
|
|
146518
|
+
function isRecord(value) {
|
|
146519
|
+
return typeof value === "object" && value !== null;
|
|
146520
|
+
}
|
|
146521
|
+
function firstFailingStep(steps) {
|
|
146522
|
+
if (!Array.isArray(steps)) return void 0;
|
|
146523
|
+
for (const entry of steps) {
|
|
146524
|
+
if (!isRecord(entry)) continue;
|
|
146525
|
+
if (entry.status !== "fail" && entry.status !== "error") continue;
|
|
146526
|
+
const what = typeof entry.tool === "string" ? entry.tool : typeof entry.kind === "string" ? entry.kind : "step";
|
|
146527
|
+
const why = typeof entry.reason === "string" ? entry.reason : "no reason given";
|
|
146528
|
+
return `${what}: ${why}`;
|
|
146529
|
+
}
|
|
146530
|
+
return void 0;
|
|
146531
|
+
}
|
|
146532
|
+
function count(value) {
|
|
146533
|
+
return typeof value === "number" ? value : 0;
|
|
146534
|
+
}
|
|
146535
|
+
function flowExecuteOutcome(result) {
|
|
146536
|
+
const flow = typeof result.flow === "string" ? result.flow : "the composed flow";
|
|
146537
|
+
if (!("steps" in result) && typeof result.notice === "string") {
|
|
146538
|
+
const prerequisite = typeof result.executionPrerequisite === "string" && result.executionPrerequisite ? `: ${result.executionPrerequisite}` : "";
|
|
146539
|
+
return {
|
|
146540
|
+
status: "error",
|
|
146541
|
+
reason: `flow "${flow}" did not run \u2014 its execution prerequisite was not acknowledged${prerequisite}. Add prerequisiteAcknowledged: true to the step's args, or compose with run: instead.`
|
|
146542
|
+
};
|
|
146543
|
+
}
|
|
146544
|
+
if (result.aborted === true) {
|
|
146545
|
+
return { status: "skip", reason: `flow "${flow}" was aborted` };
|
|
146546
|
+
}
|
|
146547
|
+
if (result.ok === false) {
|
|
146548
|
+
const detail = firstFailingStep(result.steps);
|
|
146549
|
+
return {
|
|
146550
|
+
status: "fail",
|
|
146551
|
+
reason: `flow "${flow}" failed: ${count(result.passed)} passed, ${count(result.failed)} failed, ${count(result.errored)} errored${detail ? ` (${detail})` : ""}`
|
|
146552
|
+
};
|
|
146553
|
+
}
|
|
146554
|
+
return void 0;
|
|
146555
|
+
}
|
|
146556
|
+
function runSequenceOutcome(result) {
|
|
146557
|
+
const steps = result.steps;
|
|
146558
|
+
if (!Array.isArray(steps)) return void 0;
|
|
146559
|
+
const failed = steps.find((s) => isRecord(s) && typeof s.error === "string");
|
|
146560
|
+
if (failed && isRecord(failed)) {
|
|
146561
|
+
const tool = typeof failed.tool === "string" ? failed.tool : "step";
|
|
146562
|
+
return {
|
|
146563
|
+
status: "fail",
|
|
146564
|
+
reason: `run-sequence stopped at ${tool} after ${count(result.completed)} of ${count(result.total)} steps: ${String(failed.error)}`
|
|
146565
|
+
};
|
|
146566
|
+
}
|
|
146567
|
+
const total = count(result.total);
|
|
146568
|
+
if (total > 0 && steps.length < total) {
|
|
146569
|
+
return {
|
|
146570
|
+
status: "skip",
|
|
146571
|
+
reason: `run-sequence was aborted after ${count(result.completed)} of ${total} steps`
|
|
146572
|
+
};
|
|
146573
|
+
}
|
|
146574
|
+
return void 0;
|
|
146575
|
+
}
|
|
146576
|
+
var NESTED_ORCHESTRATORS = /* @__PURE__ */ new Map([
|
|
146577
|
+
[FLOW_EXECUTE_TOOL_ID, flowExecuteOutcome],
|
|
146578
|
+
[RUN_SEQUENCE_TOOL_ID, runSequenceOutcome]
|
|
146579
|
+
]);
|
|
146580
|
+
function isNestedOrchestratorTool(tool) {
|
|
146581
|
+
return NESTED_ORCHESTRATORS.has(tool);
|
|
146582
|
+
}
|
|
146583
|
+
function nestedOrchestratorOutcome(tool, result) {
|
|
146584
|
+
if (!isRecord(result)) return void 0;
|
|
146585
|
+
return NESTED_ORCHESTRATORS.get(tool)?.(result);
|
|
146586
|
+
}
|
|
146587
|
+
|
|
146271
146588
|
// ../tool-server/src/tools/flows/flow-visual.ts
|
|
146272
146589
|
var import_node_crypto8 = require("node:crypto");
|
|
146273
146590
|
var fs50 = __toESM(require("node:fs/promises"));
|