@swmansion/argent 0.17.1-next.1 → 0.17.1-next.3
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 +467 -118
- 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/SKILL.md +32 -4
|
Binary file
|
package/bin/darwin/ax-service
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/bin/tcp/ax-service
CHANGED
|
Binary file
|
package/dist/cli-cmds.mjs
CHANGED
package/dist/installer.mjs
CHANGED
|
@@ -17044,7 +17044,7 @@ var _CI_VENDOR_COUNT_FOR_TEST = vendors_default.length;
|
|
|
17044
17044
|
var SESSION_ID = randomUUID3();
|
|
17045
17045
|
function readCliVersion() {
|
|
17046
17046
|
if (true) {
|
|
17047
|
-
return "0.17.
|
|
17047
|
+
return "0.17.0";
|
|
17048
17048
|
}
|
|
17049
17049
|
return "0.0.0";
|
|
17050
17050
|
}
|
package/dist/tool-server.cjs
CHANGED
|
@@ -81164,7 +81164,7 @@ var require_websocket = __commonJS({
|
|
|
81164
81164
|
var http5 = require("http");
|
|
81165
81165
|
var net6 = require("net");
|
|
81166
81166
|
var tls = require("tls");
|
|
81167
|
-
var { randomBytes, createHash:
|
|
81167
|
+
var { randomBytes, createHash: createHash4 } = require("crypto");
|
|
81168
81168
|
var { Duplex, Readable } = require("stream");
|
|
81169
81169
|
var { URL: URL2 } = require("url");
|
|
81170
81170
|
var PerMessageDeflate2 = require_permessage_deflate();
|
|
@@ -81832,7 +81832,7 @@ var require_websocket = __commonJS({
|
|
|
81832
81832
|
abortHandshake(websocket, socket, "Invalid Upgrade header");
|
|
81833
81833
|
return;
|
|
81834
81834
|
}
|
|
81835
|
-
const digest =
|
|
81835
|
+
const digest = createHash4("sha1").update(key + GUID).digest("base64");
|
|
81836
81836
|
if (res.headers["sec-websocket-accept"] !== digest) {
|
|
81837
81837
|
abortHandshake(websocket, socket, "Invalid Sec-WebSocket-Accept header");
|
|
81838
81838
|
return;
|
|
@@ -82201,7 +82201,7 @@ var require_websocket_server = __commonJS({
|
|
|
82201
82201
|
var EventEmitter2 = require("events");
|
|
82202
82202
|
var http5 = require("http");
|
|
82203
82203
|
var { Duplex } = require("stream");
|
|
82204
|
-
var { createHash:
|
|
82204
|
+
var { createHash: createHash4 } = require("crypto");
|
|
82205
82205
|
var extension2 = require_extension();
|
|
82206
82206
|
var PerMessageDeflate2 = require_permessage_deflate();
|
|
82207
82207
|
var subprotocol2 = require_subprotocol();
|
|
@@ -82508,7 +82508,7 @@ var require_websocket_server = __commonJS({
|
|
|
82508
82508
|
);
|
|
82509
82509
|
}
|
|
82510
82510
|
if (this._state > RUNNING) return abortHandshake(socket, 503);
|
|
82511
|
-
const digest =
|
|
82511
|
+
const digest = createHash4("sha1").update(key + GUID).digest("base64");
|
|
82512
82512
|
const headers = [
|
|
82513
82513
|
"HTTP/1.1 101 Switching Protocols",
|
|
82514
82514
|
"Upgrade: websocket",
|
|
@@ -83162,10 +83162,10 @@ function avdRootCandidates() {
|
|
|
83162
83162
|
return candidates.filter((p) => Boolean(p && (0, import_node_path6.isAbsolute)(p)));
|
|
83163
83163
|
}
|
|
83164
83164
|
async function resolveAvdPath(avdName) {
|
|
83165
|
-
const { readFile:
|
|
83165
|
+
const { readFile: readFile12 } = await import("node:fs/promises");
|
|
83166
83166
|
for (const root2 of avdRootCandidates()) {
|
|
83167
83167
|
try {
|
|
83168
|
-
const content = await
|
|
83168
|
+
const content = await readFile12(`${root2}/${avdName}.ini`, "utf-8");
|
|
83169
83169
|
const raw = (0, import_ini.parse)(content).path;
|
|
83170
83170
|
if (typeof raw !== "string") continue;
|
|
83171
83171
|
const trimmed = raw.trim();
|
|
@@ -90969,7 +90969,7 @@ var require_png = __commonJS({
|
|
|
90969
90969
|
var Parser = require_parser_async();
|
|
90970
90970
|
var Packer = require_packer_async();
|
|
90971
90971
|
var PNGSync = require_png_sync();
|
|
90972
|
-
var
|
|
90972
|
+
var PNG4 = exports2.PNG = function(options) {
|
|
90973
90973
|
Stream2.call(this);
|
|
90974
90974
|
options = options || {};
|
|
90975
90975
|
this.width = options.width | 0;
|
|
@@ -90998,9 +90998,9 @@ var require_png = __commonJS({
|
|
|
90998
90998
|
this._parser.on("close", this._handleClose.bind(this));
|
|
90999
90999
|
this._packer.on("error", this.emit.bind(this, "error"));
|
|
91000
91000
|
};
|
|
91001
|
-
util.inherits(
|
|
91002
|
-
|
|
91003
|
-
|
|
91001
|
+
util.inherits(PNG4, Stream2);
|
|
91002
|
+
PNG4.sync = PNGSync;
|
|
91003
|
+
PNG4.prototype.pack = function() {
|
|
91004
91004
|
if (!this.data || !this.data.length) {
|
|
91005
91005
|
this.emit("error", "No data provided");
|
|
91006
91006
|
return this;
|
|
@@ -91012,7 +91012,7 @@ var require_png = __commonJS({
|
|
|
91012
91012
|
);
|
|
91013
91013
|
return this;
|
|
91014
91014
|
};
|
|
91015
|
-
|
|
91015
|
+
PNG4.prototype.parse = function(data, callback) {
|
|
91016
91016
|
if (callback) {
|
|
91017
91017
|
let onParsed, onError;
|
|
91018
91018
|
onParsed = function(parsedData) {
|
|
@@ -91030,27 +91030,27 @@ var require_png = __commonJS({
|
|
|
91030
91030
|
this.end(data);
|
|
91031
91031
|
return this;
|
|
91032
91032
|
};
|
|
91033
|
-
|
|
91033
|
+
PNG4.prototype.write = function(data) {
|
|
91034
91034
|
this._parser.write(data);
|
|
91035
91035
|
return true;
|
|
91036
91036
|
};
|
|
91037
|
-
|
|
91037
|
+
PNG4.prototype.end = function(data) {
|
|
91038
91038
|
this._parser.end(data);
|
|
91039
91039
|
};
|
|
91040
|
-
|
|
91040
|
+
PNG4.prototype._metadata = function(metadata) {
|
|
91041
91041
|
this.width = metadata.width;
|
|
91042
91042
|
this.height = metadata.height;
|
|
91043
91043
|
this.emit("metadata", metadata);
|
|
91044
91044
|
};
|
|
91045
|
-
|
|
91045
|
+
PNG4.prototype._gamma = function(gamma) {
|
|
91046
91046
|
this.gamma = gamma;
|
|
91047
91047
|
};
|
|
91048
|
-
|
|
91048
|
+
PNG4.prototype._handleClose = function() {
|
|
91049
91049
|
if (!this._parser.writable && !this._packer.readable) {
|
|
91050
91050
|
this.emit("close");
|
|
91051
91051
|
}
|
|
91052
91052
|
};
|
|
91053
|
-
|
|
91053
|
+
PNG4.bitblt = function(src, dst, srcX, srcY, width, height, deltaX, deltaY) {
|
|
91054
91054
|
srcX |= 0;
|
|
91055
91055
|
srcY |= 0;
|
|
91056
91056
|
width |= 0;
|
|
@@ -91072,11 +91072,11 @@ var require_png = __commonJS({
|
|
|
91072
91072
|
);
|
|
91073
91073
|
}
|
|
91074
91074
|
};
|
|
91075
|
-
|
|
91076
|
-
|
|
91075
|
+
PNG4.prototype.bitblt = function(dst, srcX, srcY, width, height, deltaX, deltaY) {
|
|
91076
|
+
PNG4.bitblt(this, dst, srcX, srcY, width, height, deltaX, deltaY);
|
|
91077
91077
|
return this;
|
|
91078
91078
|
};
|
|
91079
|
-
|
|
91079
|
+
PNG4.adjustGamma = function(src) {
|
|
91080
91080
|
if (src.gamma) {
|
|
91081
91081
|
for (let y = 0; y < src.height; y++) {
|
|
91082
91082
|
for (let x = 0; x < src.width; x++) {
|
|
@@ -91091,8 +91091,8 @@ var require_png = __commonJS({
|
|
|
91091
91091
|
src.gamma = 0;
|
|
91092
91092
|
}
|
|
91093
91093
|
};
|
|
91094
|
-
|
|
91095
|
-
|
|
91094
|
+
PNG4.prototype.adjustGamma = function() {
|
|
91095
|
+
PNG4.adjustGamma(this);
|
|
91096
91096
|
};
|
|
91097
91097
|
}
|
|
91098
91098
|
});
|
|
@@ -104493,7 +104493,7 @@ var _CI_VENDOR_COUNT_FOR_TEST = vendors_default.length;
|
|
|
104493
104493
|
var SESSION_ID = (0, import_node_crypto3.randomUUID)();
|
|
104494
104494
|
function readCliVersion() {
|
|
104495
104495
|
if (true) {
|
|
104496
|
-
return "0.17.
|
|
104496
|
+
return "0.17.0";
|
|
104497
104497
|
}
|
|
104498
104498
|
return "0.0.0";
|
|
104499
104499
|
}
|
|
@@ -105881,7 +105881,7 @@ var import_node_path5 = __toESM(require("node:path"));
|
|
|
105881
105881
|
var import_semver = __toESM(require_semver2());
|
|
105882
105882
|
|
|
105883
105883
|
// ../tool-server/package.json
|
|
105884
|
-
var version3 = "0.17.
|
|
105884
|
+
var version3 = "0.17.0";
|
|
105885
105885
|
|
|
105886
105886
|
// ../tool-server/src/utils/update-checker.ts
|
|
105887
105887
|
var import_update_core = __toESM(require_dist4());
|
|
@@ -133654,6 +133654,9 @@ function textMatches(actual, expected, mode) {
|
|
|
133654
133654
|
}
|
|
133655
133655
|
return mode === "equals" ? equalsCI(actual, expected) : includesCI(actual, expected);
|
|
133656
133656
|
}
|
|
133657
|
+
function hasOwnConstraint(selector) {
|
|
133658
|
+
return selector.text !== void 0 || selector.textMatches !== void 0 || selector.identifier !== void 0 || selector.role !== void 0;
|
|
133659
|
+
}
|
|
133657
133660
|
function matchNodeWithRegex(node, selector, textRegex) {
|
|
133658
133661
|
if (selector.text !== void 0) {
|
|
133659
133662
|
if (!includesCI(node.label, selector.text) && !includesCI(node.value, selector.text)) {
|
|
@@ -133676,15 +133679,111 @@ function matchNodeWithRegex(node, selector, textRegex) {
|
|
|
133676
133679
|
function selectorTextRegex(selector) {
|
|
133677
133680
|
return selector.textMatches === void 0 ? void 0 : uiTreeMatchInternals.createRegExp(selector.textMatches);
|
|
133678
133681
|
}
|
|
133679
|
-
|
|
133680
|
-
|
|
133681
|
-
|
|
133682
|
+
var WITHIN_EPS = 5e-3;
|
|
133683
|
+
function frameWithin(inner, outer) {
|
|
133684
|
+
return inner.x >= outer.x - WITHIN_EPS && inner.y >= outer.y - WITHIN_EPS && inner.x + inner.width <= outer.x + outer.width + WITHIN_EPS && inner.y + inner.height <= outer.y + outer.height + WITHIN_EPS;
|
|
133685
|
+
}
|
|
133686
|
+
var CONTAINMENT_GRID_N = 16;
|
|
133687
|
+
var CONTAINMENT_GRID_MIN = 32;
|
|
133688
|
+
function gridCell(coord) {
|
|
133689
|
+
const c = Math.floor(coord * CONTAINMENT_GRID_N);
|
|
133690
|
+
return c < 0 ? 0 : c >= CONTAINMENT_GRID_N ? CONTAINMENT_GRID_N - 1 : c;
|
|
133691
|
+
}
|
|
133692
|
+
function containmentTester(containers) {
|
|
133693
|
+
if (containers.length < CONTAINMENT_GRID_MIN) {
|
|
133694
|
+
return (node) => containers.some((c) => c !== node && frameWithin(node.frame, c.frame));
|
|
133695
|
+
}
|
|
133696
|
+
const cells = /* @__PURE__ */ new Map();
|
|
133697
|
+
for (const c of containers) {
|
|
133698
|
+
const f = c.frame;
|
|
133699
|
+
const colEnd = gridCell(f.x + f.width + WITHIN_EPS);
|
|
133700
|
+
const rowEnd = gridCell(f.y + f.height + WITHIN_EPS);
|
|
133701
|
+
for (let row = gridCell(f.y - WITHIN_EPS); row <= rowEnd; row++) {
|
|
133702
|
+
for (let col = gridCell(f.x - WITHIN_EPS); col <= colEnd; col++) {
|
|
133703
|
+
const key = row * CONTAINMENT_GRID_N + col;
|
|
133704
|
+
const bucket = cells.get(key);
|
|
133705
|
+
if (bucket) bucket.push(c);
|
|
133706
|
+
else cells.set(key, [c]);
|
|
133707
|
+
}
|
|
133708
|
+
}
|
|
133709
|
+
}
|
|
133710
|
+
return (node) => {
|
|
133711
|
+
const bucket = cells.get(gridCell(node.frame.y) * CONTAINMENT_GRID_N + gridCell(node.frame.x));
|
|
133712
|
+
return bucket !== void 0 && bucket.some((c) => c !== node && frameWithin(node.frame, c.frame));
|
|
133713
|
+
};
|
|
133714
|
+
}
|
|
133715
|
+
function frameAbove(a, b) {
|
|
133716
|
+
return a.y + a.height <= b.y + WITHIN_EPS;
|
|
133717
|
+
}
|
|
133718
|
+
function followKind(node, anchor) {
|
|
133719
|
+
const below = frameAbove(anchor, node);
|
|
133720
|
+
const above = frameAbove(node, anchor);
|
|
133721
|
+
if (below !== above) return below ? "below" : "no";
|
|
133722
|
+
const right = anchor.x + anchor.width <= node.x + WITHIN_EPS;
|
|
133723
|
+
const left = node.x + node.width <= anchor.x + WITHIN_EPS;
|
|
133724
|
+
return right !== left && right ? "band" : "no";
|
|
133725
|
+
}
|
|
133726
|
+
function frameAfter(node, anchor) {
|
|
133727
|
+
return followKind(node, anchor) !== "no";
|
|
133728
|
+
}
|
|
133729
|
+
function afterTester(anchors) {
|
|
133730
|
+
return (node) => anchors.some((a) => a !== node && frameAfter(node.frame, a.frame));
|
|
133731
|
+
}
|
|
133732
|
+
function comparePick(a, b) {
|
|
133733
|
+
return frameArea(a) - frameArea(b) || a.width - b.width || a.height - b.height;
|
|
133734
|
+
}
|
|
133735
|
+
function compareBandPick(a, b) {
|
|
133736
|
+
return a.x - b.x || a.y - b.y || comparePick(a, b);
|
|
133737
|
+
}
|
|
133738
|
+
function compareBelowPick(a, b) {
|
|
133739
|
+
return a.y - b.y || a.x - b.x || comparePick(a, b);
|
|
133740
|
+
}
|
|
133741
|
+
function nearestAfter(candidates, anchors) {
|
|
133742
|
+
if (candidates.length === 0 || anchors.length === 0) return [];
|
|
133743
|
+
const picked = /* @__PURE__ */ new Set();
|
|
133744
|
+
for (const anchor of anchors) {
|
|
133745
|
+
const af = anchor.frame;
|
|
133746
|
+
let band;
|
|
133747
|
+
let below;
|
|
133748
|
+
for (const c of candidates) {
|
|
133749
|
+
if (c === anchor) continue;
|
|
133750
|
+
const f = c.frame;
|
|
133751
|
+
const kind = followKind(f, af);
|
|
133752
|
+
if (kind === "band") {
|
|
133753
|
+
if (band === void 0 || compareBandPick(f, band.frame) < 0) band = c;
|
|
133754
|
+
} else if (kind === "below") {
|
|
133755
|
+
if (below === void 0 || compareBelowPick(f, below.frame) < 0) below = c;
|
|
133756
|
+
}
|
|
133757
|
+
}
|
|
133758
|
+
const best = band ?? below;
|
|
133759
|
+
if (best !== void 0) picked.add(best);
|
|
133760
|
+
}
|
|
133761
|
+
return candidates.filter((c) => picked.has(c));
|
|
133682
133762
|
}
|
|
133683
133763
|
function findAll(root2, selector) {
|
|
133684
|
-
const
|
|
133685
|
-
const
|
|
133686
|
-
|
|
133687
|
-
|
|
133764
|
+
const all = [];
|
|
133765
|
+
const collect = (node) => {
|
|
133766
|
+
all.push(node);
|
|
133767
|
+
for (const child of node.children) collect(child);
|
|
133768
|
+
};
|
|
133769
|
+
for (const child of root2.children) collect(child);
|
|
133770
|
+
return resolveSelector(all, selector);
|
|
133771
|
+
}
|
|
133772
|
+
var SELECTOR_RELATIONS = ["within", "after", "next"];
|
|
133773
|
+
var RELATION_RESOLVERS = {
|
|
133774
|
+
within: (matches2, scope) => matches2.filter(containmentTester(scope)),
|
|
133775
|
+
after: (matches2, scope) => matches2.filter(afterTester(scope)),
|
|
133776
|
+
next: (matches2, scope) => nearestAfter(matches2, scope)
|
|
133777
|
+
};
|
|
133778
|
+
function resolveSelector(all, selector) {
|
|
133779
|
+
const regex = selectorTextRegex(selector);
|
|
133780
|
+
let matches2 = all.filter((n) => matchNodeWithRegex(n, selector, regex));
|
|
133781
|
+
for (const relation of SELECTOR_RELATIONS) {
|
|
133782
|
+
const scope = selector[relation];
|
|
133783
|
+
if (scope === void 0) continue;
|
|
133784
|
+
matches2 = RELATION_RESOLVERS[relation](matches2, resolveSelector(all, scope));
|
|
133785
|
+
}
|
|
133786
|
+
return matches2;
|
|
133688
133787
|
}
|
|
133689
133788
|
function isVisible(node) {
|
|
133690
133789
|
return node.frame.width > 0 && node.frame.height > 0;
|
|
@@ -133765,6 +133864,13 @@ function exactFieldCount(node, selector, fullTextRegex) {
|
|
|
133765
133864
|
function selectorToFrame(root2, selector) {
|
|
133766
133865
|
const visible = findAll(root2, selector).filter(isVisible);
|
|
133767
133866
|
if (visible.length === 0) return void 0;
|
|
133867
|
+
if (!hasOwnConstraint(selector)) {
|
|
133868
|
+
let first;
|
|
133869
|
+
for (const n of visible) {
|
|
133870
|
+
if (first === void 0 || compareBelowPick(n.frame, first.frame) < 0) first = n;
|
|
133871
|
+
}
|
|
133872
|
+
return first?.frame;
|
|
133873
|
+
}
|
|
133768
133874
|
const fullTextRegex = fullConsumptionRegex(selector);
|
|
133769
133875
|
let best;
|
|
133770
133876
|
let bestExact = -1;
|
|
@@ -146213,6 +146319,18 @@ function clearActiveFlow() {
|
|
|
146213
146319
|
activeFlowName = null;
|
|
146214
146320
|
recordingSession = null;
|
|
146215
146321
|
}
|
|
146322
|
+
function selectorTree(sel) {
|
|
146323
|
+
const out = [];
|
|
146324
|
+
const walk = (s) => {
|
|
146325
|
+
out.push(s);
|
|
146326
|
+
for (const relation of SELECTOR_RELATIONS) {
|
|
146327
|
+
const nested = s[relation];
|
|
146328
|
+
if (nested !== void 0) walk(nested);
|
|
146329
|
+
}
|
|
146330
|
+
};
|
|
146331
|
+
walk(sel);
|
|
146332
|
+
return out;
|
|
146333
|
+
}
|
|
146216
146334
|
function isE2eFlow(flow) {
|
|
146217
146335
|
const first = flow.steps.find((s) => s.kind !== "echo");
|
|
146218
146336
|
return first?.kind === "launch";
|
|
@@ -146246,11 +146364,35 @@ function selectorToYaml(sel) {
|
|
|
146246
146364
|
"Cannot serialize flow selector: `text` must contain at least one visible character (icon-font/private-use and zero-width characters render as nothing). Select by identifier or role, or use a coordinate tap."
|
|
146247
146365
|
);
|
|
146248
146366
|
}
|
|
146249
|
-
|
|
146367
|
+
const scopeCount = SELECTOR_RELATIONS.filter((relation) => sel[relation] !== void 0).length;
|
|
146368
|
+
if (sel.any !== void 0) {
|
|
146369
|
+
if (sel.any !== true) {
|
|
146370
|
+
throw new Error(
|
|
146371
|
+
"Cannot serialize flow selector: `any` is the universal selector and takes only `true` \u2014 omit it to select by text/id/role."
|
|
146372
|
+
);
|
|
146373
|
+
}
|
|
146374
|
+
if (sel.text !== void 0 || sel.textMatches !== void 0 || sel.identifier || sel.role) {
|
|
146375
|
+
throw new Error(
|
|
146376
|
+
"Cannot serialize flow selector: `any` already matches every element, so it cannot be combined with text/id/role \u2014 keep one or the other."
|
|
146377
|
+
);
|
|
146378
|
+
}
|
|
146379
|
+
if (scopeCount === 0) {
|
|
146380
|
+
throw new Error(
|
|
146381
|
+
`Cannot serialize flow selector: \`any\` matches every element on screen, so it needs a scope (${SELECTOR_RELATIONS.join("/")}) to narrow what it selects.`
|
|
146382
|
+
);
|
|
146383
|
+
}
|
|
146384
|
+
} else if (scopeCount > 0 && sel.text === void 0 && sel.textMatches === void 0 && !sel.identifier && !sel.role) {
|
|
146385
|
+
throw new Error(
|
|
146386
|
+
`Cannot serialize flow selector: a scope (${SELECTOR_RELATIONS.join("/")}) only narrows where to look \u2014 the selector still needs its own text/id/role naming what to find there, or \`any: true\` for any element.`
|
|
146387
|
+
);
|
|
146388
|
+
}
|
|
146389
|
+
if (sel.loose && (sel.text === void 0 || sel.textMatches !== void 0 || sel.identifier !== void 0 || sel.role !== void 0 || sel.any !== void 0 || SELECTOR_RELATIONS.some((relation) => sel[relation] !== void 0))) {
|
|
146250
146390
|
const incompatible = [
|
|
146251
146391
|
sel.textMatches !== void 0 ? "textMatches" : void 0,
|
|
146252
146392
|
sel.identifier !== void 0 ? "identifier" : void 0,
|
|
146253
|
-
sel.role !== void 0 ? "role" : void 0
|
|
146393
|
+
sel.role !== void 0 ? "role" : void 0,
|
|
146394
|
+
sel.any !== void 0 ? "any" : void 0,
|
|
146395
|
+
...SELECTOR_RELATIONS.map((relation) => sel[relation] !== void 0 ? relation : void 0)
|
|
146254
146396
|
].filter((field) => field !== void 0);
|
|
146255
146397
|
throw new Error(
|
|
146256
146398
|
"Cannot serialize loose flow selector without changing its meaning: bare-string YAML can represent only a loose text-only selector" + (incompatible.length > 0 ? `; incompatible fields: ${incompatible.join(", ")}` : "") + "."
|
|
@@ -146259,16 +146401,30 @@ function selectorToYaml(sel) {
|
|
|
146259
146401
|
if (sel.loose && sel.text !== void 0 && sel.identifier === void 0 && sel.role === void 0) {
|
|
146260
146402
|
return sel.text;
|
|
146261
146403
|
}
|
|
146262
|
-
const { loose: _loose, identifier, textMatches: textMatches2, ...rest } = sel;
|
|
146404
|
+
const { loose: _loose, any: any2, identifier, textMatches: textMatches2, within, after, next, ...rest } = sel;
|
|
146405
|
+
const scopes = { within, after, next };
|
|
146263
146406
|
const out = { ...rest };
|
|
146407
|
+
if (any2) out.any = true;
|
|
146264
146408
|
if (textMatches2 !== void 0) out.text = { matches: textMatches2 };
|
|
146265
146409
|
if (identifier !== void 0) out.id = identifier;
|
|
146410
|
+
for (const relation of SELECTOR_RELATIONS) {
|
|
146411
|
+
const scope = scopes[relation];
|
|
146412
|
+
if (scope !== void 0) out[relation] = selectorToYaml(scope);
|
|
146413
|
+
}
|
|
146266
146414
|
return out;
|
|
146267
146415
|
}
|
|
146268
146416
|
function describeSelector(s) {
|
|
146269
|
-
|
|
146417
|
+
const { loose: _loose, any: any2, within, after, next, ...rest } = s;
|
|
146418
|
+
const scopes = { within, after, next };
|
|
146419
|
+
const fields = Object.entries(rest).map(
|
|
146270
146420
|
([k, v]) => k === "textMatches" ? `text=/${v}/` : `${k === "identifier" ? "id" : k}="${v}"`
|
|
146271
146421
|
).join(" ");
|
|
146422
|
+
const parts = [any2 ? "*" : void 0, fields || void 0].filter((p) => p !== void 0);
|
|
146423
|
+
for (const relation of SELECTOR_RELATIONS) {
|
|
146424
|
+
const scope = scopes[relation];
|
|
146425
|
+
if (scope !== void 0) parts.push(`${relation} (${describeSelector(scope)})`);
|
|
146426
|
+
}
|
|
146427
|
+
return parts.join(" ");
|
|
146272
146428
|
}
|
|
146273
146429
|
function describeTextExpectation(expectedText, textMatch, verbForm = "mode") {
|
|
146274
146430
|
const expected = expectedText ?? "";
|
|
@@ -146419,8 +146575,17 @@ function toYamlStep(step) {
|
|
|
146419
146575
|
return {
|
|
146420
146576
|
rotate: step.selector ? { on: selectorToYaml(step.selector), by: step.by } : { by: step.by }
|
|
146421
146577
|
};
|
|
146422
|
-
case "snapshot":
|
|
146423
|
-
|
|
146578
|
+
case "snapshot": {
|
|
146579
|
+
if (step.maxMismatch === void 0 && step.cropOn === void 0) {
|
|
146580
|
+
return { snapshot: step.name };
|
|
146581
|
+
}
|
|
146582
|
+
const body = {
|
|
146583
|
+
name: step.name
|
|
146584
|
+
};
|
|
146585
|
+
if (step.maxMismatch !== void 0) body.maxMismatch = step.maxMismatch;
|
|
146586
|
+
if (step.cropOn !== void 0) body.cropOn = selectorToYaml(step.cropOn);
|
|
146587
|
+
return { snapshot: body };
|
|
146588
|
+
}
|
|
146424
146589
|
case "tool":
|
|
146425
146590
|
default: {
|
|
146426
146591
|
const y = {
|
|
@@ -146501,8 +146666,22 @@ function rejectUnknownKeys(raw, body, allowed, where) {
|
|
|
146501
146666
|
`${where} has ${describeUnknownKeys(unknown2, allowed)} \u2014 allowed keys: ${allowed.join(", ")}`
|
|
146502
146667
|
);
|
|
146503
146668
|
}
|
|
146504
|
-
var SELECTOR_KEYS = [
|
|
146505
|
-
|
|
146669
|
+
var SELECTOR_KEYS = [
|
|
146670
|
+
"text",
|
|
146671
|
+
"id",
|
|
146672
|
+
"identifier",
|
|
146673
|
+
"role",
|
|
146674
|
+
"any",
|
|
146675
|
+
...SELECTOR_RELATIONS
|
|
146676
|
+
];
|
|
146677
|
+
var MAX_SELECTOR_SCOPES = 6;
|
|
146678
|
+
function parseSelector(raw, where, budget = { scopes: MAX_SELECTOR_SCOPES }) {
|
|
146679
|
+
if (budget.scopes < 0) {
|
|
146680
|
+
badEntry(
|
|
146681
|
+
raw,
|
|
146682
|
+
`${where}: a selector carries more than ${MAX_SELECTOR_SCOPES} scopes (${SELECTOR_RELATIONS.join("/")}) in total \u2014 check for a cyclic YAML alias (\`&s { \u2026, within: *s }\`)`
|
|
146683
|
+
);
|
|
146684
|
+
}
|
|
146506
146685
|
if (typeof raw === "string") {
|
|
146507
146686
|
const r2 = selectorSchema.safeParse({ text: raw });
|
|
146508
146687
|
if (!r2.success) badEntry(raw, `${where}: ${r2.error.issues[0]?.message ?? "invalid selector"}`);
|
|
@@ -146511,9 +146690,55 @@ function parseSelector(raw, where) {
|
|
|
146511
146690
|
if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
|
|
146512
146691
|
rejectUnknownKeys(raw, raw, SELECTOR_KEYS, `${where}: selector`);
|
|
146513
146692
|
}
|
|
146514
|
-
|
|
146515
|
-
|
|
146516
|
-
|
|
146693
|
+
const scopes = {};
|
|
146694
|
+
let universal = false;
|
|
146695
|
+
let fieldsRaw = raw;
|
|
146696
|
+
if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
|
|
146697
|
+
const restRaw = { ...raw };
|
|
146698
|
+
const present = SELECTOR_RELATIONS.filter((relation) => relation in restRaw);
|
|
146699
|
+
for (const relation of present) {
|
|
146700
|
+
budget.scopes--;
|
|
146701
|
+
scopes[relation] = parseSelector(restRaw[relation], `${where}.${relation}`, budget);
|
|
146702
|
+
delete restRaw[relation];
|
|
146703
|
+
}
|
|
146704
|
+
if ("any" in restRaw) {
|
|
146705
|
+
if (restRaw.any !== true) {
|
|
146706
|
+
badEntry(
|
|
146707
|
+
raw,
|
|
146708
|
+
`${where}: \`any\` takes only \`true\` \u2014 it is the CSS \`*\` universal selector (drop the key to select by text/id/role instead)`
|
|
146709
|
+
);
|
|
146710
|
+
}
|
|
146711
|
+
delete restRaw.any;
|
|
146712
|
+
if (Object.keys(restRaw).length > 0) {
|
|
146713
|
+
badEntry(
|
|
146714
|
+
raw,
|
|
146715
|
+
`${where}: \`any: true\` already matches every element \u2014 drop it, or drop the ${Object.keys(
|
|
146716
|
+
restRaw
|
|
146717
|
+
).map((k) => `\`${k}\``).join("/")} it makes redundant`
|
|
146718
|
+
);
|
|
146719
|
+
}
|
|
146720
|
+
if (present.length === 0) {
|
|
146721
|
+
badEntry(
|
|
146722
|
+
raw,
|
|
146723
|
+
`${where}: \`any: true\` matches every element on screen \u2014 pair it with a scope (${SELECTOR_RELATIONS.join(
|
|
146724
|
+
"/"
|
|
146725
|
+
)}) so it selects something specific`
|
|
146726
|
+
);
|
|
146727
|
+
}
|
|
146728
|
+
universal = true;
|
|
146729
|
+
} else if (present.length > 0 && Object.keys(restRaw).length === 0) {
|
|
146730
|
+
badEntry(
|
|
146731
|
+
raw,
|
|
146732
|
+
`${where}: a selector's \`${present.join("`/`")}\` only scopes where to look \u2014 the selector still needs its own text/id/role naming what to find there (or \`any: true\` for any element)`
|
|
146733
|
+
);
|
|
146734
|
+
}
|
|
146735
|
+
fieldsRaw = restRaw;
|
|
146736
|
+
}
|
|
146737
|
+
const attachScopes = (sel) => ({ ...sel, ...scopes });
|
|
146738
|
+
if (universal) return attachScopes({ any: true });
|
|
146739
|
+
let normalized = fieldsRaw;
|
|
146740
|
+
if (fieldsRaw !== null && typeof fieldsRaw === "object" && "id" in fieldsRaw) {
|
|
146741
|
+
const { id, ...rest } = fieldsRaw;
|
|
146517
146742
|
if ("identifier" in rest) {
|
|
146518
146743
|
badEntry(raw, `${where}: selector takes \`id\` or \`identifier\` (its alias), not both`);
|
|
146519
146744
|
}
|
|
@@ -146546,12 +146771,12 @@ function parseSelector(raw, where) {
|
|
|
146546
146771
|
if (!fields.success) {
|
|
146547
146772
|
badEntry(raw, `${where}: ${fields.error.issues[0]?.message ?? "invalid selector"}`);
|
|
146548
146773
|
}
|
|
146549
|
-
return { ...fields.data, textMatches: pattern };
|
|
146774
|
+
return attachScopes({ ...fields.data, textMatches: pattern });
|
|
146550
146775
|
}
|
|
146551
146776
|
}
|
|
146552
146777
|
const r = selectorSchema.safeParse(normalized);
|
|
146553
146778
|
if (!r.success) badEntry(raw, `${where}: ${r.error.issues[0]?.message ?? "invalid selector"}`);
|
|
146554
|
-
return r.data;
|
|
146779
|
+
return attachScopes(r.data);
|
|
146555
146780
|
}
|
|
146556
146781
|
var WAIT_CONDITIONS = ["exists", "visible", "hidden", "text"];
|
|
146557
146782
|
var TEXT_MATCH_MODES = Object.keys({
|
|
@@ -146702,11 +146927,14 @@ function parseTapTimes(raw, entry) {
|
|
|
146702
146927
|
}
|
|
146703
146928
|
return raw === 1 ? void 0 : raw;
|
|
146704
146929
|
}
|
|
146930
|
+
function hasSelectorField(obj) {
|
|
146931
|
+
return obj.text !== void 0 || obj.id !== void 0 || obj.identifier !== void 0 || obj.role !== void 0 || obj.any !== void 0 || SELECTOR_RELATIONS.some((relation) => obj[relation] !== void 0);
|
|
146932
|
+
}
|
|
146705
146933
|
function parseTarget(raw, where) {
|
|
146706
146934
|
if (raw !== null && typeof raw === "object") {
|
|
146707
146935
|
const obj = raw;
|
|
146708
146936
|
if (obj.x !== void 0 || obj.y !== void 0) {
|
|
146709
|
-
if (obj
|
|
146937
|
+
if (hasSelectorField(obj)) {
|
|
146710
146938
|
badEntry(raw, `${where} takes a selector or x/y coordinates, not both`);
|
|
146711
146939
|
}
|
|
146712
146940
|
if (typeof obj.x !== "number" || typeof obj.y !== "number") {
|
|
@@ -146729,7 +146957,7 @@ function parseTarget(raw, where) {
|
|
|
146729
146957
|
function parseTap(body, entry) {
|
|
146730
146958
|
const obj = body !== null && typeof body === "object" ? body : {};
|
|
146731
146959
|
if (obj.on !== void 0 || obj.times !== void 0) {
|
|
146732
|
-
if (obj
|
|
146960
|
+
if (hasSelectorField(obj)) {
|
|
146733
146961
|
badEntry(
|
|
146734
146962
|
entry,
|
|
146735
146963
|
'the tap options form takes a nested selector \u2014 e.g. tap: { on: { text: "Photo" }, times: 2 }'
|
|
@@ -146757,7 +146985,7 @@ function parseTap(body, entry) {
|
|
|
146757
146985
|
function parseLongPress(body, entry) {
|
|
146758
146986
|
const obj = body !== null && typeof body === "object" ? body : {};
|
|
146759
146987
|
if (obj.on !== void 0 || obj.duration !== void 0) {
|
|
146760
|
-
if (obj
|
|
146988
|
+
if (hasSelectorField(obj)) {
|
|
146761
146989
|
badEntry(
|
|
146762
146990
|
entry,
|
|
146763
146991
|
'the long-press options form takes a nested selector \u2014 e.g. long-press: { on: { text: "Row" }, duration: 1200 }'
|
|
@@ -146797,7 +147025,7 @@ function parsePinch(body, entry) {
|
|
|
146797
147025
|
);
|
|
146798
147026
|
}
|
|
146799
147027
|
const obj = body;
|
|
146800
|
-
if (obj
|
|
147028
|
+
if (hasSelectorField(obj)) {
|
|
146801
147029
|
badEntry(entry, 'pinch takes a nested selector \u2014 e.g. pinch: { on: "Map", scale: 3 }');
|
|
146802
147030
|
}
|
|
146803
147031
|
rejectUnknownKeys(entry, obj, ["on", "scale"], "pinch");
|
|
@@ -146864,13 +147092,11 @@ function parseWhenCondition(raw) {
|
|
|
146864
147092
|
}
|
|
146865
147093
|
const { timeout: _timeout, ...cond } = parseWaitFields(raw, "when");
|
|
146866
147094
|
const { selector, expectedText } = cond;
|
|
146867
|
-
|
|
146868
|
-
|
|
146869
|
-
|
|
146870
|
-
|
|
146871
|
-
|
|
146872
|
-
selector.role
|
|
146873
|
-
]) {
|
|
147095
|
+
const guardStrings = [expectedText];
|
|
147096
|
+
for (const s of selectorTree(selector)) {
|
|
147097
|
+
guardStrings.push(s.text, s.textMatches, s.identifier, s.role);
|
|
147098
|
+
}
|
|
147099
|
+
for (const s of guardStrings) {
|
|
146874
147100
|
if (s !== void 0 && s.includes(SECRET_PLACEHOLDER_MARKER)) {
|
|
146875
147101
|
badEntry(
|
|
146876
147102
|
{ when: raw },
|
|
@@ -146995,6 +147221,12 @@ function fromYamlStep(raw, whenDepth = 0) {
|
|
|
146995
147221
|
if (b.direction !== void 0 && (typeof b.direction !== "string" || !SCROLL_DIRECTIONS.includes(b.direction))) {
|
|
146996
147222
|
badEntry(raw, `scroll-to direction must be one of ${SCROLL_DIRECTIONS.join(", ")}`);
|
|
146997
147223
|
}
|
|
147224
|
+
if (b.target === void 0) {
|
|
147225
|
+
badEntry(
|
|
147226
|
+
raw,
|
|
147227
|
+
`scroll-to needs a \`target\` \u2014 its own \`within\` only anchors the gesture to a scroll container, e.g. scroll-to: { target: <selector>, within: { id: list } }. A selector scope (${SELECTOR_RELATIONS.join("/")}) goes inside \`target\`.`
|
|
147228
|
+
);
|
|
147229
|
+
}
|
|
146998
147230
|
const step = {
|
|
146999
147231
|
kind: "scroll-to",
|
|
147000
147232
|
target: parseSelector(b.target, "scroll-to.target"),
|
|
@@ -147008,7 +147240,12 @@ function fromYamlStep(raw, whenDepth = 0) {
|
|
|
147008
147240
|
if ("snapshot" in raw) {
|
|
147009
147241
|
const body = raw.snapshot;
|
|
147010
147242
|
if (body !== null && typeof body === "object" && !Array.isArray(body)) {
|
|
147011
|
-
rejectUnknownKeys(
|
|
147243
|
+
rejectUnknownKeys(
|
|
147244
|
+
raw,
|
|
147245
|
+
body,
|
|
147246
|
+
["name", "maxMismatch", "cropOn"],
|
|
147247
|
+
"snapshot"
|
|
147248
|
+
);
|
|
147012
147249
|
}
|
|
147013
147250
|
const b = typeof body === "string" ? { name: body } : body;
|
|
147014
147251
|
if (!b || typeof b !== "object" || typeof b.name !== "string" || !b.name) {
|
|
@@ -147031,6 +147268,9 @@ function fromYamlStep(raw, whenDepth = 0) {
|
|
|
147031
147268
|
}
|
|
147032
147269
|
step.maxMismatch = m;
|
|
147033
147270
|
}
|
|
147271
|
+
if (b.cropOn !== void 0) {
|
|
147272
|
+
step.cropOn = parseSelector(b.cropOn, "snapshot.cropOn");
|
|
147273
|
+
}
|
|
147034
147274
|
return step;
|
|
147035
147275
|
}
|
|
147036
147276
|
if ("tool" in raw) {
|
|
@@ -147985,7 +148225,16 @@ function probeWhenCondition(env, cond) {
|
|
|
147985
148225
|
return waitForCondition(env, cond, DEFAULT_ASSERT_TIMEOUT_MS);
|
|
147986
148226
|
}
|
|
147987
148227
|
function selectorAlternatives(sel) {
|
|
147988
|
-
|
|
148228
|
+
const { loose, any: _any2, within, after, next, ...own } = sel;
|
|
148229
|
+
const scopes = { within, after, next };
|
|
148230
|
+
let alts = loose && own.text !== void 0 ? [{ identifier: own.text }, { text: own.text }] : [own];
|
|
148231
|
+
for (const relation of SELECTOR_RELATIONS) {
|
|
148232
|
+
const scope = scopes[relation];
|
|
148233
|
+
if (scope === void 0) continue;
|
|
148234
|
+
const scopeAlts = selectorAlternatives(scope);
|
|
148235
|
+
alts = alts.flatMap((o) => scopeAlts.map((s) => ({ ...o, [relation]: s })));
|
|
148236
|
+
}
|
|
148237
|
+
return alts;
|
|
147989
148238
|
}
|
|
147990
148239
|
function flowFindAll(tree, sel) {
|
|
147991
148240
|
let fallback = [];
|
|
@@ -148439,9 +148688,11 @@ function assertReason(condition, selector, expectedText, textMatch, matches2) {
|
|
|
148439
148688
|
}
|
|
148440
148689
|
|
|
148441
148690
|
// ../tool-server/src/tools/flows/flow-visual.ts
|
|
148691
|
+
var import_node_crypto8 = require("node:crypto");
|
|
148442
148692
|
var fs44 = __toESM(require("node:fs/promises"));
|
|
148443
148693
|
var os12 = __toESM(require("node:os"));
|
|
148444
148694
|
var path32 = __toESM(require("node:path"));
|
|
148695
|
+
var import_pngjs3 = __toESM(require_png());
|
|
148445
148696
|
|
|
148446
148697
|
// ../tool-server/src/tools/screenshot-diff/screenshot-diff.ts
|
|
148447
148698
|
var import_promises11 = __toESM(require("fs/promises"));
|
|
@@ -150004,7 +150255,7 @@ async function diffPngFiles(options) {
|
|
|
150004
150255
|
decodePngFile(options.baselinePath),
|
|
150005
150256
|
decodePngFile(options.currentPath)
|
|
150006
150257
|
]);
|
|
150007
|
-
const normalized = normalizeToCommonSize(decodedBaseline, decodedCurrent);
|
|
150258
|
+
const normalized = options.normalizeSizes === false ? sameDimensionsOrNull(decodedBaseline, decodedCurrent) : normalizeToCommonSize(decodedBaseline, decodedCurrent);
|
|
150008
150259
|
if (!normalized) {
|
|
150009
150260
|
return summarizeResult(
|
|
150010
150261
|
buildDimensionMismatchResult({
|
|
@@ -150071,6 +150322,9 @@ function resolveDiffSettings(topMask) {
|
|
|
150071
150322
|
function summarizeResult(result) {
|
|
150072
150323
|
return { ...result, summary: formatScreenshotDiffSummary(result) };
|
|
150073
150324
|
}
|
|
150325
|
+
function sameDimensionsOrNull(baseline, current) {
|
|
150326
|
+
return baseline.width === current.width && baseline.height === current.height ? { baseline, current } : null;
|
|
150327
|
+
}
|
|
150074
150328
|
function buildDimensionMismatchResult(params) {
|
|
150075
150329
|
return {
|
|
150076
150330
|
totalPixels: params.baseline.width * params.baseline.height,
|
|
@@ -150490,6 +150744,15 @@ async function pngDimensions(file2) {
|
|
|
150490
150744
|
await fh.close();
|
|
150491
150745
|
}
|
|
150492
150746
|
}
|
|
150747
|
+
function cropIdentity(s) {
|
|
150748
|
+
return JSON.stringify([
|
|
150749
|
+
s.text ?? null,
|
|
150750
|
+
s.textMatches ?? null,
|
|
150751
|
+
s.identifier ?? null,
|
|
150752
|
+
s.role ?? null,
|
|
150753
|
+
s.loose ?? false
|
|
150754
|
+
]);
|
|
150755
|
+
}
|
|
150493
150756
|
function baselineDir(flowsDir, flowName) {
|
|
150494
150757
|
return path32.join(flowsDir, "__baselines__", flowName);
|
|
150495
150758
|
}
|
|
@@ -150506,80 +150769,153 @@ async function cleanupDiffDir(dir, keep) {
|
|
|
150506
150769
|
} catch {
|
|
150507
150770
|
}
|
|
150508
150771
|
}
|
|
150772
|
+
async function cropPngFile(src, dest, frame) {
|
|
150773
|
+
const png = import_pngjs3.PNG.sync.read(await fs44.readFile(src));
|
|
150774
|
+
const left = Math.max(0, Math.round(frame.x * png.width));
|
|
150775
|
+
const top = Math.max(0, Math.round(frame.y * png.height));
|
|
150776
|
+
const right = Math.min(png.width, Math.round((frame.x + frame.width) * png.width));
|
|
150777
|
+
const bottom = Math.min(png.height, Math.round((frame.y + frame.height) * png.height));
|
|
150778
|
+
const w = right - left;
|
|
150779
|
+
const h = bottom - top;
|
|
150780
|
+
if (w < 1 || h < 1) return null;
|
|
150781
|
+
const out = new import_pngjs3.PNG({ width: w, height: h });
|
|
150782
|
+
import_pngjs3.PNG.bitblt(png, out, left, top, w, h, 0, 0);
|
|
150783
|
+
await fs44.writeFile(dest, import_pngjs3.PNG.sync.write(out));
|
|
150784
|
+
return { w, h };
|
|
150785
|
+
}
|
|
150509
150786
|
async function runSnapshot(env, opts) {
|
|
150510
|
-
|
|
150511
|
-
|
|
150512
|
-
|
|
150787
|
+
if (opts.cropOn === void 0) {
|
|
150788
|
+
try {
|
|
150789
|
+
await settleTree(env);
|
|
150790
|
+
} catch {
|
|
150791
|
+
}
|
|
150513
150792
|
}
|
|
150514
150793
|
if (env.signal?.aborted) {
|
|
150515
150794
|
return { status: "skip", reason: "run aborted during snapshot settle" };
|
|
150516
150795
|
}
|
|
150796
|
+
let cropFrame;
|
|
150797
|
+
if (opts.cropOn !== void 0) {
|
|
150798
|
+
const frame = await waitForFrame(env, opts.cropOn);
|
|
150799
|
+
if (frame === "aborted") {
|
|
150800
|
+
return { status: "skip", reason: "run aborted while resolving cropOn" };
|
|
150801
|
+
}
|
|
150802
|
+
if (frame === void 0) {
|
|
150803
|
+
return { status: "fail", reason: offscreenHint(opts.cropOn) };
|
|
150804
|
+
}
|
|
150805
|
+
cropFrame = frame;
|
|
150806
|
+
}
|
|
150517
150807
|
const store = requireArtifacts(env.ctx);
|
|
150518
150808
|
const shot = await invokeOnDevice(env, "screenshot", {
|
|
150519
150809
|
scale: 1,
|
|
150520
150810
|
includeImageInContext: false
|
|
150521
150811
|
});
|
|
150522
|
-
const
|
|
150523
|
-
const
|
|
150524
|
-
const snapshotKey = `${opts.name}__${env.device.platform}-${w}x${h}`;
|
|
150812
|
+
const { w, h } = await pngDimensions(shot.image.hostPath);
|
|
150813
|
+
const cropSuffix = opts.cropOn === void 0 ? "" : `-crop-${(0, import_node_crypto8.createHash)("sha256").update(cropIdentity(opts.cropOn)).digest("hex").slice(0, 8)}`;
|
|
150814
|
+
const snapshotKey = `${opts.name}__${env.device.platform}-${w}x${h}${cropSuffix}`;
|
|
150525
150815
|
const key = `${snapshotKey}.png`;
|
|
150526
150816
|
const dir = baselineDir(opts.flowsDir, opts.flowName);
|
|
150527
150817
|
const baselinePath = path32.join(dir, key);
|
|
150528
|
-
|
|
150529
|
-
|
|
150530
|
-
|
|
150531
|
-
|
|
150532
|
-
|
|
150533
|
-
|
|
150534
|
-
|
|
150535
|
-
|
|
150536
|
-
snapshotKey
|
|
150537
|
-
|
|
150538
|
-
|
|
150539
|
-
}
|
|
150540
|
-
if (!exists2) {
|
|
150541
|
-
return {
|
|
150542
|
-
status: "fail",
|
|
150543
|
-
reason: `no baseline for "${opts.name}" on this device class \u2014 expected ${baselinePath}, nothing was compared. Run with updateBaselines (--update-baselines) to adopt the current screen, then review and commit it`,
|
|
150544
|
-
snapshotKey,
|
|
150545
|
-
artifacts: { current: shot.image }
|
|
150546
|
-
};
|
|
150547
|
-
}
|
|
150548
|
-
const outputDir = await fs44.mkdtemp(path32.join(os12.tmpdir(), "argent-flow-diff-"));
|
|
150549
|
-
let keepInOutputDir;
|
|
150818
|
+
let currentPath = shot.image.hostPath;
|
|
150819
|
+
let cropDir;
|
|
150820
|
+
let keepCropped = false;
|
|
150821
|
+
const currentArtifact = async () => {
|
|
150822
|
+
if (cropDir === void 0) return shot.image;
|
|
150823
|
+
keepCropped = true;
|
|
150824
|
+
return store.register(currentPath, {
|
|
150825
|
+
mimeType: "image/png",
|
|
150826
|
+
filename: `${snapshotKey}-current.png`
|
|
150827
|
+
});
|
|
150828
|
+
};
|
|
150550
150829
|
try {
|
|
150551
|
-
|
|
150552
|
-
|
|
150553
|
-
const
|
|
150830
|
+
if (cropFrame !== void 0) {
|
|
150831
|
+
cropDir = await fs44.mkdtemp(path32.join(os12.tmpdir(), "argent-flow-crop-"));
|
|
150832
|
+
const croppedPath = path32.join(cropDir, key);
|
|
150833
|
+
const cropped = await cropPngFile(shot.image.hostPath, croppedPath, cropFrame);
|
|
150834
|
+
if (cropped === null) {
|
|
150835
|
+
return {
|
|
150836
|
+
status: "fail",
|
|
150837
|
+
reason: `cropOn matched ${describeSelector(opts.cropOn)} but its on-screen region is empty at this resolution \u2014 nothing was compared`,
|
|
150838
|
+
snapshotKey,
|
|
150839
|
+
// No crop exists to show, so attach the full capture (already registered).
|
|
150840
|
+
artifacts: { current: shot.image }
|
|
150841
|
+
};
|
|
150842
|
+
}
|
|
150843
|
+
currentPath = croppedPath;
|
|
150844
|
+
}
|
|
150845
|
+
const exists2 = await fs44.access(baselinePath).then(() => true).catch(() => false);
|
|
150846
|
+
if (opts.updateBaselines) {
|
|
150847
|
+
await fs44.mkdir(dir, { recursive: true });
|
|
150848
|
+
await fs44.copyFile(currentPath, baselinePath);
|
|
150849
|
+
const baseline = await store.register(baselinePath, { mimeType: "image/png" });
|
|
150554
150850
|
return {
|
|
150555
|
-
status: "
|
|
150556
|
-
reason: `baseline
|
|
150851
|
+
status: "pass",
|
|
150852
|
+
reason: exists2 ? `baseline updated (${key})` : `baseline written (${key})`,
|
|
150557
150853
|
snapshotKey,
|
|
150558
|
-
artifacts: {
|
|
150559
|
-
baseline: await store.register(baselinePath, { mimeType: "image/png" }),
|
|
150560
|
-
current: shot.image
|
|
150561
|
-
}
|
|
150854
|
+
artifacts: { baseline }
|
|
150562
150855
|
};
|
|
150563
150856
|
}
|
|
150564
|
-
|
|
150565
|
-
|
|
150566
|
-
|
|
150567
|
-
|
|
150857
|
+
if (!exists2) {
|
|
150858
|
+
return {
|
|
150859
|
+
status: "fail",
|
|
150860
|
+
reason: `no baseline for "${opts.name}" on this device class \u2014 expected ${baselinePath}, nothing was compared. Run with updateBaselines (--update-baselines) to adopt the current screen, then review and commit it`,
|
|
150861
|
+
snapshotKey,
|
|
150862
|
+
artifacts: { current: await currentArtifact() }
|
|
150863
|
+
};
|
|
150568
150864
|
}
|
|
150569
|
-
const
|
|
150570
|
-
|
|
150571
|
-
|
|
150572
|
-
|
|
150573
|
-
|
|
150574
|
-
|
|
150575
|
-
|
|
150576
|
-
|
|
150865
|
+
const outputDir = await fs44.mkdtemp(path32.join(os12.tmpdir(), "argent-flow-diff-"));
|
|
150866
|
+
let keepInOutputDir;
|
|
150867
|
+
try {
|
|
150868
|
+
const result = await diffPngFiles({
|
|
150869
|
+
baselinePath,
|
|
150870
|
+
currentPath,
|
|
150871
|
+
outputDir,
|
|
150872
|
+
// A crop compares EVERY pixel of the element's region — no masking,
|
|
150873
|
+
// wherever the crop sits. Masking a crop's overlap with the screen's
|
|
150874
|
+
// top status-bar band would degenerate into comparing nothing for an
|
|
150875
|
+
// element inside the band (a vacuous pass); a crop overlapping the
|
|
150876
|
+
// band leans on best-effort pinStatusBar until frame resolution
|
|
150877
|
+
// asserts the element clears it.
|
|
150878
|
+
topMask: cropFrame === void 0 ? "status-bar" : "none",
|
|
150879
|
+
// Crop dimensions track the element — size drift must hard-fail below
|
|
150880
|
+
// instead of being resampled away like a full-screen scale difference.
|
|
150881
|
+
...cropFrame !== void 0 && { normalizeSizes: false }
|
|
150577
150882
|
});
|
|
150578
|
-
|
|
150883
|
+
if (result.dimensionMismatch) {
|
|
150884
|
+
const { expected, actual } = result.dimensionMismatch;
|
|
150885
|
+
return {
|
|
150886
|
+
status: "fail",
|
|
150887
|
+
reason: `baseline is ${expected.width}x${expected.height} but the ${opts.cropOn ? "cropOn region" : "capture"} is ${actual.width}x${actual.height} (${key}) \u2014 nothing was compared` + (opts.cropOn ? `. The element's size drifted \u2014 crop a fixed-size container, or re-adopt with updateBaselines` : ""),
|
|
150888
|
+
snapshotKey,
|
|
150889
|
+
artifacts: {
|
|
150890
|
+
baseline: await store.register(baselinePath, { mimeType: "image/png" }),
|
|
150891
|
+
current: await currentArtifact()
|
|
150892
|
+
}
|
|
150893
|
+
};
|
|
150894
|
+
}
|
|
150895
|
+
const within = result.mismatchPercentage <= opts.maxMismatch;
|
|
150896
|
+
const reason = `diff ${result.mismatchPercentage.toFixed(2)}% ${within ? "\u2264" : ">"} ${opts.maxMismatch}% (${key})`;
|
|
150897
|
+
if (within) {
|
|
150898
|
+
return { status: "pass", reason };
|
|
150899
|
+
}
|
|
150900
|
+
const artifacts = {
|
|
150901
|
+
baseline: await store.register(baselinePath, { mimeType: "image/png" }),
|
|
150902
|
+
current: await currentArtifact()
|
|
150903
|
+
};
|
|
150904
|
+
if (result.contextDiffPath) {
|
|
150905
|
+
artifacts.diff = await store.register(result.contextDiffPath, {
|
|
150906
|
+
mimeType: "image/png",
|
|
150907
|
+
filename: `${snapshotKey}-diff.png`
|
|
150908
|
+
});
|
|
150909
|
+
keepInOutputDir = result.contextDiffPath;
|
|
150910
|
+
}
|
|
150911
|
+
return { status: "fail", reason, snapshotKey, artifacts };
|
|
150912
|
+
} finally {
|
|
150913
|
+
await cleanupDiffDir(outputDir, keepInOutputDir);
|
|
150579
150914
|
}
|
|
150580
|
-
return { status: "fail", reason, snapshotKey, artifacts };
|
|
150581
150915
|
} finally {
|
|
150582
|
-
|
|
150916
|
+
if (cropDir !== void 0) {
|
|
150917
|
+
await cleanupDiffDir(cropDir, keepCropped ? currentPath : void 0);
|
|
150918
|
+
}
|
|
150583
150919
|
}
|
|
150584
150920
|
}
|
|
150585
150921
|
|
|
@@ -150789,14 +151125,21 @@ function createRunFlowTool(registry2) {
|
|
|
150789
151125
|
Steps run in order: \`launch\` starts an app from scratch (terminate + relaunch) and waits until it is
|
|
150790
151126
|
ready; \`tool\` calls dispatch through the registry; \`tap\`/\`long-press\`/\`type\` resolve a selector to an
|
|
150791
151127
|
element and act on it (\`tap: { on, times: 2 }\` double-taps; \`long-press: { on, duration }\` presses and
|
|
150792
|
-
holds; \`tap\`/\`long-press\` alternatively take a raw normalized point \u2014 bare \`{ x, y }\` or \`on: { x, y }
|
|
151128
|
+
holds; \`tap\`/\`long-press\` alternatively take a raw normalized point \u2014 bare \`{ x, y }\` or \`on: { x, y }\`;
|
|
151129
|
+
any selector may scope its matches geometrically, the CSS combinators read off frames: \`within: <selector>\`
|
|
151130
|
+
(descendant \u2014 inside that container's frame), \`after: <selector>\` (CSS \`~\` \u2014 following it in reading
|
|
151131
|
+
order), \`next: <selector>\` (CSS \`+\` \u2014 the nearest such follower, which unlike CSS reaches past a
|
|
151132
|
+
non-matching neighbour rather than failing), plus \`any: true\` (CSS \`*\` \u2014 legal only WITH a scope and
|
|
151133
|
+
never beside text/id/role). Scopes nest to disambiguate \u2014 \`within: { id: card, within: { id: list } }\`
|
|
151134
|
+
reads "inside card inside list", each container's frame inside the next);
|
|
150793
151135
|
\`scroll-to\` scrolls (momentum-free) until a target is visible; \`pinch\` zooms
|
|
150794
151136
|
(\`pinch: { on?, scale }\` \u2014 scale > 1 in, < 1 out; screen center when \`on\` is omitted); \`rotate\` is the
|
|
150795
151137
|
two-finger rotation gesture (\`rotate: { on?, by }\` \u2014 degrees, + clockwise, within \xB13000\xB0; screen center
|
|
150796
151138
|
when \`on\` is omitted; distinct from the \`rotate\` tool, which changes device orientation); \`await\` waits
|
|
150797
151139
|
for a UI condition; \`wait\` pauses for a fixed number of milliseconds; \`assert\` checks one now; \`snapshot\`
|
|
150798
|
-
diffs a screenshot
|
|
150799
|
-
|
|
151140
|
+
diffs a screenshot \u2014 or, with \`cropOn: <selector>\`, one element's cropped region \u2014 against a stored
|
|
151141
|
+
baseline (a missing baseline fails the step \u2014 set updateBaselines to adopt the current screen; a
|
|
151142
|
+
cropped element whose size drifted fails on dimensions); \`echo\` annotates; \`run\` executes a referenced fragment inline.
|
|
150800
151143
|
A \`when:\` block (condition + \`steps:\`, no else) runs its steps only if the condition holds \u2014
|
|
150801
151144
|
checked once with the short assert grace \u2014 for one-sided divergences like interstitials and coach
|
|
150802
151145
|
marks; a skipped block reports distinctly and failures inside an entered block are real failures.
|
|
@@ -150947,10 +151290,15 @@ function pushReport(state3, report) {
|
|
|
150947
151290
|
}
|
|
150948
151291
|
function selectorLabel2(sel) {
|
|
150949
151292
|
const parts = [];
|
|
151293
|
+
if (sel.any) parts.push("*");
|
|
150950
151294
|
if (sel.text !== void 0) parts.push(`"${sel.text}"`);
|
|
150951
151295
|
if (sel.textMatches !== void 0) parts.push(`/${sel.textMatches}/`);
|
|
150952
151296
|
if (sel.identifier) parts.push(`id=${sel.identifier}`);
|
|
150953
151297
|
if (sel.role) parts.push(`role=${sel.role}`);
|
|
151298
|
+
for (const relation of SELECTOR_RELATIONS) {
|
|
151299
|
+
const scope = sel[relation];
|
|
151300
|
+
if (scope !== void 0) parts.push(`${relation} (${selectorLabel2(scope)})`);
|
|
151301
|
+
}
|
|
150954
151302
|
return parts.join(" ");
|
|
150955
151303
|
}
|
|
150956
151304
|
function conditionLabel(cond, renderSelector) {
|
|
@@ -150987,7 +151335,7 @@ function stepTarget(step) {
|
|
|
150987
151335
|
return step.selector ? `${selectorLabel2(step.selector)} (${by})` : by;
|
|
150988
151336
|
}
|
|
150989
151337
|
case "snapshot":
|
|
150990
|
-
return `"${step.name}"`;
|
|
151338
|
+
return step.cropOn ? `"${step.name}" cropOn ${selectorLabel2(step.cropOn)}` : `"${step.name}"`;
|
|
150991
151339
|
default:
|
|
150992
151340
|
return void 0;
|
|
150993
151341
|
}
|
|
@@ -151191,7 +151539,8 @@ async function execLeafStep(state3, step, index, scope) {
|
|
|
151191
151539
|
flowName: state3.topFlowName,
|
|
151192
151540
|
name: step.name,
|
|
151193
151541
|
maxMismatch: step.maxMismatch ?? DEFAULT_MAX_MISMATCH,
|
|
151194
|
-
updateBaselines: state3.updateBaselines
|
|
151542
|
+
updateBaselines: state3.updateBaselines,
|
|
151543
|
+
cropOn: step.cropOn
|
|
151195
151544
|
});
|
|
151196
151545
|
return {
|
|
151197
151546
|
...base,
|
|
@@ -151799,7 +152148,7 @@ var dismissUpdateTool = {
|
|
|
151799
152148
|
};
|
|
151800
152149
|
|
|
151801
152150
|
// ../tool-server/src/tools/screenshot-diff/index.ts
|
|
151802
|
-
var
|
|
152151
|
+
var import_node_crypto9 = __toESM(require("node:crypto"));
|
|
151803
152152
|
var import_promises13 = __toESM(require("fs/promises"));
|
|
151804
152153
|
var import_node_os13 = __toESM(require("node:os"));
|
|
151805
152154
|
var import_path10 = __toESM(require("path"));
|
|
@@ -151885,7 +152234,7 @@ async function resolveOutputDir(params, options) {
|
|
|
151885
152234
|
const dir = import_path10.default.join(
|
|
151886
152235
|
import_node_os13.default.tmpdir(),
|
|
151887
152236
|
"argent-screenshot-diff",
|
|
151888
|
-
|
|
152237
|
+
import_node_crypto9.default.randomBytes(6).toString("hex")
|
|
151889
152238
|
);
|
|
151890
152239
|
await import_promises13.default.mkdir(dir, { recursive: true });
|
|
151891
152240
|
return dir;
|
|
@@ -151962,7 +152311,7 @@ async function captureLiveInput(params) {
|
|
|
151962
152311
|
} catch {
|
|
151963
152312
|
capture = await params.captureScreenshot(params.api, params.rotation, params.signal);
|
|
151964
152313
|
}
|
|
151965
|
-
const suffix =
|
|
152314
|
+
const suffix = import_node_crypto9.default.randomBytes(4).toString("hex");
|
|
151966
152315
|
const destination = import_path10.default.join(params.outputDir, `${params.name}-${suffix}.live.png`);
|
|
151967
152316
|
await import_promises13.default.mkdir(params.outputDir, { recursive: true });
|
|
151968
152317
|
await import_promises13.default.copyFile(capture.path, destination);
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -32,17 +32,41 @@ Beyond raw `tool:` steps and `echo:`, flows support declarative directives inter
|
|
|
32
32
|
| `await` | `- await: { visible: Home }` | wait for a UI condition |
|
|
33
33
|
| `wait` | `- wait: 500` | pause for a fixed number of milliseconds (last resort — prefer `await`) |
|
|
34
34
|
| `assert` | `- assert: { visible: Welcome }` | check a condition, hard-fail if it never holds |
|
|
35
|
-
| `snapshot` | `- snapshot: home` or `- snapshot: { name: home, maxMismatch: 0.5 }`
|
|
35
|
+
| `snapshot` | `- snapshot: home` or `- snapshot: { name: home, maxMismatch: 0.5, cropOn: { id: order-summary } }` | diff a screenshot — or one element's region — against a stored baseline |
|
|
36
36
|
| `run` | `- run: login` | execute another flow's steps inline (fragment or e2e) |
|
|
37
37
|
| `when` | `- when: { visible: "What's new" }` + `steps: [...]` | run a guarded step block only when the condition holds (no else) |
|
|
38
38
|
|
|
39
39
|
### Selectors
|
|
40
40
|
|
|
41
|
-
A **selector** is `{ text?, id?, role? }` (all-must-match; `text`/`role` are case-insensitive substrings, `id` matches the element's testID / accessibilityIdentifier / resource-id exactly, case-insensitive, also accepting the unqualified Android resource-id name — `submit` matches `com.example.app:id/submit`) — the same semantics `await-ui-element` uses, though that tool spells the `id` field `identifier` (flow YAML also accepts `identifier` as an alias for `id`, but `id` is the canonical spelling and what the recorder writes). A bare string is a _loose_ selector: it resolves **identifier-first, then falls back to text** (label/value), so `tap: Login` matches a `testID="Login"` or, failing that, visible text "Login" — no need to know which. Loose fallback applies uniformly to every selector slot (`tap`, `type.into`, `await`, `assert`, `scroll-to`). Use the map form to be strict: `{ id: submit-btn }` (identifier only) or `{ text: Login }` (text only, no fallback).
|
|
41
|
+
A **selector** is `{ text?, id?, role? }` plus the optional scopes below (all-must-match; `text`/`role` are case-insensitive substrings, `id` matches the element's testID / accessibilityIdentifier / resource-id exactly, case-insensitive, also accepting the unqualified Android resource-id name — `submit` matches `com.example.app:id/submit`) — the same semantics `await-ui-element` uses, though that tool spells the `id` field `identifier` (flow YAML also accepts `identifier` as an alias for `id`, but `id` is the canonical spelling and what the recorder writes). A bare string is a _loose_ selector: it resolves **identifier-first, then falls back to text** (label/value), so `tap: Login` matches a `testID="Login"` or, failing that, visible text "Login" — no need to know which. Loose fallback applies uniformly to every selector slot (`tap`, `type.into`, `await`, `assert`, `scroll-to`). Use the map form to be strict: `{ id: submit-btn }` (identifier only) or `{ text: Login }` (text only, no fallback).
|
|
42
42
|
|
|
43
43
|
`text` also takes a **regex matcher map** — `{ text: { matches: '^Order #\d+$' } }`, in any selector slot — for dynamic text no literal can pin. It tests each node's native **own** label/value (not the adapter-hoisted `subtreeText`), though on iOS a container's own label may itself aggregate descendant text, so a wrapper and its leaf can both match. Same regex rules as `text.in`'s `matches` (see _`await` and `assert`_): unanchored, **case-sensitive**, single-quoted, invalid pattern fails at parse. So `assert: { visible: { text: { matches: '^Taps: \d+$' } } }` asserts a counter is on screen with no locator at all, and `tap: { text: { matches: '^Order #\d+$' } }` taps a dynamic row — though a stable `id` stays the more robust action target.
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
#### Scopes — CSS combinators, read off frames
|
|
46
|
+
|
|
47
|
+
A map selector may also carry a **scope**: a nested selector naming another element the match must sit in a given spatial relation to. These are the geometric readings of the CSS combinators that survive a flattened tree — the child combinator `>` has no analog, since parent/child structure does not reach replay. They combine, and nest up to six scopes per selector:
|
|
48
|
+
|
|
49
|
+
| Scope | CSS | Reads as | Example |
|
|
50
|
+
| --------------- | ------- | --------------------------------------------------------------- | --------------------------------------------------------------------- |
|
|
51
|
+
| `within: <sel>` | `A B` | the match's frame sits **inside** the scope element's frame | `tap: { text: Delete, within: { id: profile-card } }` |
|
|
52
|
+
| `after: <sel>` | `A ~ B` | the match **follows** the scope element in reading order | `assert: { visible: { role: Button, after: { text: Danger zone } } }` |
|
|
53
|
+
| `next: <sel>` | `A + B` | as `after`, narrowed to the **nearest** follower of each anchor | `tap: { role: Switch, next: { text: Wi-Fi } }` |
|
|
54
|
+
|
|
55
|
+
And `any: true` is the CSS `*` universal selector. It carries no locator of its own, so the parser enforces two rules: it needs **at least one scope** (a bare `any: true` would match the whole screen), and it may **not** sit beside `text`/`id`/`role` (which it would only make redundant — write `{ role: Switch, next: … }`, not `{ any: true, role: Switch, next: … }`). Only the literal `true` is accepted. `assert: { hidden: { any: true, within: { id: empty-state } } }` asserts an empty container. It works for actions too — `tap: { any: true, next: { text: Airplane Mode } }` taps whatever sits right after that label — but on a real tree "whatever" includes spacers and wrappers, so **name the target** (`role`, `id`) whenever you can and keep `any` for conditions and for `next`, which reduces to a single element per anchor.
|
|
56
|
+
|
|
57
|
+
All of it is **visual (frame-based), not tree ancestry** — flow trees are flattened, and "inside the card" / "the switch after this label" mean what the screen shows — the same frame-based reading of "within" that `scroll-to`'s container anchor uses. Every scope needs a **distinct element** (nothing scopes itself, so `{ id: card, within: { id: card } }` needs two nested elements), the synthetic screen root never counts, and a scope only narrows _where_ to look — the selector still needs its own `text`/`id`/`role` naming _what_ to find there, or `any: true`. The nested slot takes every selector form: a bare string keeps the loose identifier-first fallback (`within: profile-card`), the map form stays strict, the regex matcher works (`within: { text: { matches: '^Card \d+$' } }`). Scopes are flow-YAML only; the raw `await-ui-element` tool's selector accepts none of them. Prefer a unique `id` on the target itself when one exists — reach for a scope when the target has no unique locator of its own (repeated row actions, per-card buttons, list cells).
|
|
58
|
+
|
|
59
|
+
**`within`** — `tap: { text: Delete, within: { id: profile-card } }` taps the Delete button in the profile card even when other cards show identical ones; `tap: { text: "Pin feed", within: { text: "For You" } }` picks one card's button out of a whole list. Scope to a container with a **tight frame** (a row, card, dialog, toast): a full-screen wrapper contains everything and scopes nothing. It chains outward: `{ text: Save, within: { id: cards, within: Settings } }` reads "Save inside cards inside Settings", each container's frame inside the next.
|
|
60
|
+
|
|
61
|
+
**`after` / `next`** — reading order is row-band aware: an element **follows** the anchor when it starts below the anchor's bottom edge, _or_ shares its row band and sits entirely to its right. That is what makes `{ role: Switch, next: { text: Wi-Fi } }` resolve the Wi-Fi row's own switch even though the taller switch's frame starts a couple of pixels _higher_ than the label's. `next` keeps only the nearest follower — a match in the anchor's own row beats anything on the rows below, leftmost first — while `after` keeps them all, so `assert: { hidden: { role: Button, after: { text: Danger zone } } }` holds when nothing button-like appears past that heading. Both union over anchors exactly as CSS does: with three rows on screen, `{ role: Switch, next: { role: AXStaticText } }` yields all three switches, one per label. Note that "follows" is **not transitive** — against a tall anchor, an element can follow something that itself follows the anchor without following the anchor directly — so nesting `after` scopes is not the same as chaining CSS `~`: `{ after: { …, after: … } }` can match elements a single `after` excludes. Nest them only when each link is a container-sized step. An element sitting _inside_ the anchor does not follow **that** anchor — containment is not reading order — so scope by `within` for that.
|
|
62
|
+
|
|
63
|
+
**Prefer the map form for a scope's anchor.** A bare-string scope (`next: wifi-row`) keeps the loose identifier-first fallback, and the runner takes the first pass that finds a _visible_ match — so if some unrelated element carries `testID="wifi-row"`, the identifier pass wins and the text pass never runs. Reproduced: with a decoy `testID="Wi-Fi"` elsewhere on screen, `tap: { role: Switch, next: Wi-Fi }` taps the decoy's neighbour and reports a pass. This is the ordinary identifier-first doctrine, but a scope makes a decoy likelier to "succeed": a decoy _container_ only wins if it actually holds a match, while a decoy _anchor_ wins if anything at all sits after it — which on a real screen it usually does. Spell an anchor you care about as a map: `next: { text: Wi-Fi }` or `next: { id: wifi-row }`.
|
|
64
|
+
|
|
65
|
+
One way `next` is deliberately **looser than CSS `+`**: where `A + B` matches nothing unless the very next sibling is a `B`, `next` keeps looking and returns the nearest match further on. That is what makes it survive the wrapper and spacer nodes a flattened tree is full of, but it also means a row that is _missing_ the control you asked for silently resolves to the next row's — `{ role: Switch, next: { text: Wi-Fi } }` on a Wi-Fi row rendered without a switch returns the _Bluetooth_ row's switch rather than failing. When a row may legitimately lack the control, assert it first (`assert: { visible: { role: Switch, within: { id: wifi-row } } }`) or scope by `within` instead.
|
|
66
|
+
|
|
67
|
+
Scopes compose, and it matters which one carries them. `{ role: Button, next: { text: Name, within: { id: card-b } } }` scopes the **anchor** — one label, so one pick, but that pick may land outside card-b. `{ role: Button, next: { text: Name }, within: { id: card-b } }` scopes the **target** — every label is still an anchor, but only card-b's buttons can be picked. They agree on a well-formed screen and diverge when card-b has no button: the first reaches on to the next card's, the second returns nothing. Scope the target when the container is the thing you trust. Conditions honor scopes like any other selector: `assert: { hidden: { text: Saved, within: { id: toast-area } } }` holds when nothing matching "Saved" is inside the toast area — matches elsewhere on screen don't count, and a missing scope element satisfies `hidden` (and fails `visible`/`exists`).
|
|
68
|
+
|
|
69
|
+
Selectors resolve against the **full native hierarchy** (iOS: the UIView tree; Android: the complete accessibility hierarchy including not-important views) — strictly more than `describe` or the raw `await-ui-element` tool see (both use the trimmed tree), with complete `testID`/`resource-id` coverage. So an `id` selector works even when `describe` collapses or omits the element — don't fall back to coordinate taps just because a testID isn't visible in `describe` output. And when several elements match — including wrappers whose native text aggregates descendant content — the action directives (`tap`, `type`, `scroll-to`) pick the **most specific** match: an exact text/identifier match beats a substring hit (for a regex matcher, a pattern consuming the element's whole text counts as exact), then the smallest frame wins. (A universal `any: true` selector has no field to be exact about, so its matches rank by reading order instead — the first element in the scope, which is the element a condition reads too. Where two matches share a top-left corner, an action breaks the tie toward the smaller, more specific one and a condition does not, so those two can name different elements.)
|
|
46
70
|
|
|
47
71
|
**Quote strings YAML would mangle.** An unquoted `#` starts a YAML comment — `tap: Order #1234` silently parses as `tap: Order` — and bare `yes`/`no`/`on`/`off`/numbers coerce to non-strings. When a selector or typed text contains `#`, `:`, quotes, or could read as a boolean/number, wrap it: `tap: "Order #1234"`.
|
|
48
72
|
|
|
@@ -65,7 +89,11 @@ For a custom poll interval or bundleId, drop to an explicit `- tool: await-ui-el
|
|
|
65
89
|
|
|
66
90
|
Never record a real credential into a flow — the YAML is committed to the repo. Use a secret placeholder instead: `type: { into: password, text: "{{secret:APP_PASSWORD}}" }`. The placeholder is stored verbatim (the YAML stays secret-free) and is resolved at run time by the tool-server from the `ARGENT_SECRET_APP_PASSWORD` environment variable — including agent-less `argent flow run` in CI, where the variable comes from the job's secrets.
|
|
67
91
|
|
|
68
|
-
`scroll-to` takes an optional `direction` (`up` | `down` | `left` | `right`, default `down` — so the common case is just `- scroll-to: <selector>`) and optionally a `within: <selector>` that anchors the scroll inside a specific container — required to drive a **nested** scroller (e.g. a horizontal carousel inside a vertical list), since the device can't be asked which container to scroll. It scrolls in bounded momentum-free increments, re-checks after each, and stops if a scroll reveals nothing new (end of the container). `tap`/`type` do **not** scroll — add a `scroll-to` before any target that may be off-screen. It's a no-op when the target is already visible, so a defensive `scroll-to` costs nothing on replay and keeps the flow working on smaller screens.
|
|
92
|
+
`scroll-to` takes an optional `direction` (`up` | `down` | `left` | `right`, default `down` — so the common case is just `- scroll-to: <selector>`) and optionally a `within: <selector>` that anchors the scroll inside a specific container — required to drive a **nested** scroller (e.g. a horizontal carousel inside a vertical list), since the device can't be asked which container to scroll. This step-level `within` (a sibling of `target`) anchors the _gesture_, and it is the **only** scope key the step body takes — `after:`/`next:`/`any:` beside `target` are rejected. It is distinct from a selector's scopes (see Selectors), which `target` may itself carry — `scroll-to: { target: { text: Delete, within: { id: cards } }, within: { id: settings-list } }` scrolls the settings list until the Delete button _inside the cards container_ is visible. It scrolls in bounded momentum-free increments, re-checks after each, and stops if a scroll reveals nothing new (end of the container). `tap`/`type` do **not** scroll — add a `scroll-to` before any target that may be off-screen. It's a no-op when the target is already visible, so a defensive `scroll-to` costs nothing on replay and keeps the flow working on smaller screens.
|
|
93
|
+
|
|
94
|
+
### `snapshot` cropping
|
|
95
|
+
|
|
96
|
+
`cropOn: <selector>` narrows a snapshot's comparison to one element's region — `- snapshot: { name: cart-total, cropOn: { id: order-summary } }`. The selector resolves like a directive target (settled tree, auto-wait, the standard not-found failure; selector-only, no point form), and the **cropped** image is what gets compared, stored as the baseline, and reported as the `current` artifact; the baseline filename still keys on the full capture's resolution — plus a `-crop-<hash>` selector suffix — so device-class drift is still caught. A cropped comparison never masks any region — every pixel of the crop is compared — so prefer elements clear of the top status-bar band (a crop overlapping it leans on best-effort status-bar pinning, and the clock/battery may diff). Crop **fixed-size containers**, addressed by `id`: a text selector resolves to the smallest matching node — typically the label itself, whose frame tracks text metrics — and the crop tracks the element's frame, so an element that grew or shrank by a pixel fails on dimensions ("nothing was compared") rather than on content. Baseline storage and seeding are covered under _Standalone runner_.
|
|
69
97
|
|
|
70
98
|
### TV targets (Vega)
|
|
71
99
|
|