@codeyam-editor/codeyam-editor 0.1.15 → 0.1.16
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/npm/app-port-drift.js +23 -0
- package/npm/fleet-add-failure-evidence.js +309 -0
- package/npm/fleet-add-failure.js +51 -311
- package/npm/fleet-build-data.js +47 -20
- package/npm/fleet-preview-health.js +156 -0
- package/npm/fleet-revision.js +50 -0
- package/npm/fleet-sticky-fields.js +124 -0
- package/npm/launch-config.js +43 -142
- package/npm/scenario-check.js +68 -383
- package/npm/scenario-issues.js +47 -0
- package/npm/scenario-launch.js +349 -0
- package/npm/scenario-playwright.js +42 -321
- package/npm/scenario-targeting.js +338 -0
- package/npm/vm-forwards.js +158 -0
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-Ceop11Fv.js → ContainerInspector-Cig2PDjT.js} +1 -1
- package/ui/dist/assets/{EditorShell-DjI5cEF_.js → EditorShell-DOuUJNSm.js} +76 -74
- package/ui/dist/assets/{ScenarioDataPanel-D-nWT7xD.js → ScenarioDataPanel-BRUYhl1o.js} +10 -10
- package/ui/dist/assets/{ScenarioDataPanelFullPage-ZNFZcTQU.js → ScenarioDataPanelFullPage-DjwRM8HJ.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-CcbccxCW.js +26 -0
- package/ui/dist/assets/{index-ClrdYzlw.js → index-Bkrhkuf5.js} +6 -6
- package/ui/dist/assets/index-Dw4EB88N.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/ScenarioRenderer-BW6QLGFW.js +0 -26
- package/ui/dist/assets/index-B3SMxYUB.css +0 -1
|
@@ -3,6 +3,21 @@ const {
|
|
|
3
3
|
isCanvasDominated,
|
|
4
4
|
shouldStopWaitingForImages,
|
|
5
5
|
} = require("./scenario-metrics");
|
|
6
|
+
const {
|
|
7
|
+
candidateLabelSummary,
|
|
8
|
+
noMatchGuidance,
|
|
9
|
+
describeMatchCandidates,
|
|
10
|
+
resolveTextTarget,
|
|
11
|
+
pickBestCandidate,
|
|
12
|
+
pageOf,
|
|
13
|
+
targetBox,
|
|
14
|
+
pointIn,
|
|
15
|
+
devicePixelRatioOf,
|
|
16
|
+
outsideTargetMessage,
|
|
17
|
+
outsideTargetError,
|
|
18
|
+
pointerGeometry,
|
|
19
|
+
locatorPointGeometry,
|
|
20
|
+
} = require("./scenario-targeting");
|
|
6
21
|
const fs = require("fs");
|
|
7
22
|
|
|
8
23
|
// PROTOTYPE (improve35 capture diagnostics): append a per-phase timing line to
|
|
@@ -887,6 +902,22 @@ async function waitForReadySignal(target, signal, timeoutMs) {
|
|
|
887
902
|
}
|
|
888
903
|
}
|
|
889
904
|
|
|
905
|
+
// Start watching for `frame` to navigate, from the moment its ready signal
|
|
906
|
+
// arrived. A navigation after that point means something reloaded the page
|
|
907
|
+
// under the capture (a dev server's live reload): the frame about to be shot is
|
|
908
|
+
// no longer the one that signalled ready. Returns `{ navigated() }`, read just
|
|
909
|
+
// before the screenshot. A page that cannot report navigations (a stubbed test
|
|
910
|
+
// target) reads as never navigated.
|
|
911
|
+
function watchNavigationAfterReady(page, frame) {
|
|
912
|
+
let navigated = false;
|
|
913
|
+
if (page && typeof page.on === "function") {
|
|
914
|
+
page.on("framenavigated", (navigatedFrame) => {
|
|
915
|
+
if (navigatedFrame === frame) navigated = true;
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
return { navigated: () => navigated };
|
|
919
|
+
}
|
|
920
|
+
|
|
890
921
|
// `preflight` is injectable so unit tests that drive a mock page can stay
|
|
891
922
|
// network-free; production callers use the default real reachability check.
|
|
892
923
|
async function loadScenarioInIframe(
|
|
@@ -1039,219 +1070,6 @@ async function loadScenarioTopLevel(
|
|
|
1039
1070
|
}
|
|
1040
1071
|
}
|
|
1041
1072
|
|
|
1042
|
-
// The CSS selector for "clickable / interactive" elements — buttons, links,
|
|
1043
|
-
// role=button, form controls, <summary>, and anything with an onclick. Shared
|
|
1044
|
-
// by the candidate-label collector (below) and the text-target resolver
|
|
1045
|
-
// (`resolveTextTarget`), which prefers an interactive element over a plain
|
|
1046
|
-
// text node when a click/press label matches both.
|
|
1047
|
-
const INTERACTIVE_SELECTOR =
|
|
1048
|
-
"button, a[href], [role=button], input, select, textarea, summary, [onclick]";
|
|
1049
|
-
|
|
1050
|
-
// Collect up to 20 distinct visible labels of interactive elements on the
|
|
1051
|
-
// page — buttons, links, role=button, form controls, <summary>, and anything
|
|
1052
|
-
// with an onclick. Used to build an ACTIONABLE error when an interaction's
|
|
1053
|
-
// target matches nothing: the agent reliably knows a label it rendered, so
|
|
1054
|
-
// listing the real candidates turns a silent blank capture into a "did you
|
|
1055
|
-
// mean one of these?" hint. Pure read (no clicks); falls back to value /
|
|
1056
|
-
// aria-label / placeholder when an element has no text.
|
|
1057
|
-
async function collectInteractiveLabels(frame) {
|
|
1058
|
-
return frame.evaluate((selector) => {
|
|
1059
|
-
const nodes = Array.from(document.querySelectorAll(selector));
|
|
1060
|
-
const labels = nodes
|
|
1061
|
-
.map((node) => {
|
|
1062
|
-
const text =
|
|
1063
|
-
(node.innerText || node.textContent || "").trim() ||
|
|
1064
|
-
(typeof node.value === "string" ? node.value.trim() : "") ||
|
|
1065
|
-
(node.getAttribute && node.getAttribute("aria-label")) ||
|
|
1066
|
-
(node.getAttribute && node.getAttribute("placeholder")) ||
|
|
1067
|
-
"";
|
|
1068
|
-
return String(text).trim();
|
|
1069
|
-
})
|
|
1070
|
-
.filter((label) => label.length > 0);
|
|
1071
|
-
return Array.from(new Set(labels)).slice(0, 20);
|
|
1072
|
-
}, INTERACTIVE_SELECTOR);
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
// How many candidate labels an error message may echo. Deliberately tighter
|
|
1076
|
-
// than `collectInteractiveLabels`' own 20-label read cap: the labels are the
|
|
1077
|
-
// USER'S page content, so every one printed is a line of their app leaked into
|
|
1078
|
-
// the terminal. Enough to answer "did you mean one of these?", not enough to
|
|
1079
|
-
// transcribe the page.
|
|
1080
|
-
const CANDIDATE_LABEL_CAP = 8;
|
|
1081
|
-
|
|
1082
|
-
// Render the candidate labels for an error message, capped and counted. Shared
|
|
1083
|
-
// by the no-match and action-failed paths so the echo policy lives in one place.
|
|
1084
|
-
async function candidateLabelSummary(frame) {
|
|
1085
|
-
const candidates = await collectInteractiveLabels(frame);
|
|
1086
|
-
if (candidates.length === 0) return "(none found on page)";
|
|
1087
|
-
const shown = candidates.slice(0, CANDIDATE_LABEL_CAP);
|
|
1088
|
-
const rest = candidates.length - shown.length;
|
|
1089
|
-
return rest > 0 ? `${shown.join(", ")} (+${rest} more)` : shown.join(", ");
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
// Compose the "no element matched" error, branched on the form the caller
|
|
1093
|
-
// ACTUALLY passed. Pure and string-returning so both branches are asserted
|
|
1094
|
-
// without a browser.
|
|
1095
|
-
//
|
|
1096
|
-
// The substring advice ("prefer an exact CSS selector") only applies to a text
|
|
1097
|
-
// label. Printing it at a caller who already passed `#id` contradicts its own
|
|
1098
|
-
// input and reads as misdirection — the failure mode that sent two sessions
|
|
1099
|
-
// hunting a selector bug when the page had simply not hydrated. The candidate
|
|
1100
|
-
// labels ride with the text branch for the same reason: they answer "did you
|
|
1101
|
-
// mean one of these?", a question only a text match can have asked, and every
|
|
1102
|
-
// label printed is a line of the user's own page echoed into the terminal.
|
|
1103
|
-
//
|
|
1104
|
-
// A text miss on a canvas-dominated page is not a spelling problem: the label
|
|
1105
|
-
// is painted into the canvas and is not page text at all, so an empty
|
|
1106
|
-
// candidate list would only send the author hunting a typo. That branch names
|
|
1107
|
-
// the canvas and the position form instead.
|
|
1108
|
-
function noMatchGuidance({ targetDesc, matchedByText, candidateLabels, canvasDominated = false }) {
|
|
1109
|
-
const queryParamHint =
|
|
1110
|
-
`Many filter/status/sort states are also reachable directly via a ` +
|
|
1111
|
-
`URL query param (e.g. add "?status=active" to the path) with no ` +
|
|
1112
|
-
`interaction at all.`;
|
|
1113
|
-
if (matchedByText && canvasDominated) {
|
|
1114
|
-
return (
|
|
1115
|
-
`preview-interact: no element matched ${targetDesc}, and this page is ` +
|
|
1116
|
-
`canvas-dominated (it paints into a canvas with no visible page text), ` +
|
|
1117
|
-
`so the label is painted on the canvas and cannot be matched as text. ` +
|
|
1118
|
-
`Target the canvas by position instead: ` +
|
|
1119
|
-
`{"selector":"canvas","position":{"x":120,"y":40}} — a point in CSS ` +
|
|
1120
|
-
`pixels from the canvas's top-left corner, read off the frame captured ` +
|
|
1121
|
-
`just before this step.`
|
|
1122
|
-
);
|
|
1123
|
-
}
|
|
1124
|
-
if (matchedByText) {
|
|
1125
|
-
return (
|
|
1126
|
-
`preview-interact: no element matched ${targetDesc}. ` +
|
|
1127
|
-
`Text is matched as a case-insensitive SUBSTRING, so a misspelled or ` +
|
|
1128
|
-
`over-specific label matches nothing — prefer an exact/role/testid CSS ` +
|
|
1129
|
-
`selector (e.g. {"selector":"[data-testid=\\"save\\"]"}) for a precise ` +
|
|
1130
|
-
`target. ${queryParamHint} ` +
|
|
1131
|
-
`Candidate interactive labels: ${candidateLabels}`
|
|
1132
|
-
);
|
|
1133
|
-
}
|
|
1134
|
-
return (
|
|
1135
|
-
`preview-interact: no element matched ${targetDesc}. ` +
|
|
1136
|
-
`That is already an exact CSS selector, so nothing on the page matches ` +
|
|
1137
|
-
`it as written — confirm the element is present in THIS state (it may be ` +
|
|
1138
|
-
`behind a tab, dialog, or conditional render), that the id/attribute is ` +
|
|
1139
|
-
`spelled as the markup emits it, and that the page rendered and hydrated ` +
|
|
1140
|
-
`at all. ${queryParamHint}`
|
|
1141
|
-
);
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
// Describe the elements a substring text match resolved, so an ambiguity
|
|
1145
|
-
// warning can name the competing controls (e.g. the preset button "Bet" vs the
|
|
1146
|
-
// disclosure button "…or bet on"). Reads each matched element's inner text via
|
|
1147
|
-
// Playwright's `allInnerTexts`, caps the list at 5, and trims each to a single
|
|
1148
|
-
// short line. Pure read; degrades to a count-only description if the locator
|
|
1149
|
-
// API or the page can't produce texts.
|
|
1150
|
-
async function describeMatchCandidates(baseLocator, matchCount) {
|
|
1151
|
-
const CAP = 5;
|
|
1152
|
-
const MAX_LEN = 60;
|
|
1153
|
-
let texts = [];
|
|
1154
|
-
try {
|
|
1155
|
-
if (typeof baseLocator.allInnerTexts === "function") {
|
|
1156
|
-
texts = await baseLocator.allInnerTexts();
|
|
1157
|
-
}
|
|
1158
|
-
} catch (_) {
|
|
1159
|
-
texts = [];
|
|
1160
|
-
}
|
|
1161
|
-
const described = texts
|
|
1162
|
-
.map((t) => String(t).replace(/\s+/g, " ").trim())
|
|
1163
|
-
.filter((t) => t.length > 0)
|
|
1164
|
-
.slice(0, CAP)
|
|
1165
|
-
.map((t) => (t.length > MAX_LEN ? `${t.slice(0, MAX_LEN)}…` : t))
|
|
1166
|
-
.map((t) => `"${t}"`);
|
|
1167
|
-
if (described.length === 0) {
|
|
1168
|
-
return [`${matchCount} elements (text unavailable)`];
|
|
1169
|
-
}
|
|
1170
|
-
if (matchCount > described.length) {
|
|
1171
|
-
described.push(`…and ${matchCount - described.length} more`);
|
|
1172
|
-
}
|
|
1173
|
-
return described;
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
// Drive a single user-style interaction against the settled frame before the
|
|
1177
|
-
// screenshot, so an interactive state (expanded accordion, open modal, filled
|
|
1178
|
-
// field) can be captured without editing app source.
|
|
1179
|
-
//
|
|
1180
|
-
// The target is matched by visible `text` (preferred — the agent reliably
|
|
1181
|
-
// knows the label it rendered) or a CSS `selector`. `action` is click / fill /
|
|
1182
|
-
// press; `value` carries the text for `fill` or the key for `press` (e.g.
|
|
1183
|
-
// `Enter`). On a no-match target this THROWS with the list of candidate
|
|
1184
|
-
// interactive labels — the capture script's outer catch turns that into a
|
|
1185
|
-
// failed capture with an actionable message, never a silent blank screenshot.
|
|
1186
|
-
//
|
|
1187
|
-
// Resolve a visible-text target to the element most likely intended, instead
|
|
1188
|
-
// of blindly taking the first substring hit. A bare
|
|
1189
|
-
// `getByText(text, { exact: false })` is a case-insensitive SUBSTRING match, so
|
|
1190
|
-
// a label can land on a plain text node or a placeholder rather than the chip /
|
|
1191
|
-
// button the agent meant (observed: a `"Japan"` click hit placeholder text, not
|
|
1192
|
-
// the chip; a `"Bet"` click hit a disclosure button described "or bet on"). We
|
|
1193
|
-
// try increasingly-loose locators in priority order and take the first that
|
|
1194
|
-
// matches anything:
|
|
1195
|
-
// 1. exact + interactive (for click/press)
|
|
1196
|
-
// 2. exact
|
|
1197
|
-
// 3. substring + interactive (for click/press)
|
|
1198
|
-
// 4. substring
|
|
1199
|
-
// so an exact, clickable element wins over a substring plain-text node. Returns
|
|
1200
|
-
// the winning tier's locator plus its match count; when nothing matches at all,
|
|
1201
|
-
// returns the substring locator (count 0) so the caller's zero-match error path
|
|
1202
|
-
// still fires with its candidate-label hint.
|
|
1203
|
-
async function resolveTextTarget(frame, text, action) {
|
|
1204
|
-
const preferInteractive = action === "click" || action === "press";
|
|
1205
|
-
const exactBase = frame.getByText(text, { exact: true });
|
|
1206
|
-
const substrBase = frame.getByText(text, { exact: false });
|
|
1207
|
-
|
|
1208
|
-
const tiers = [];
|
|
1209
|
-
// `.and()` (Playwright ≥1.34) intersects two locators to the elements
|
|
1210
|
-
// matching both — here, the text element that is ALSO interactive. Guard on
|
|
1211
|
-
// its presence so a locator without `.and` degrades to text-only tiers.
|
|
1212
|
-
if (preferInteractive && typeof exactBase.and === "function") {
|
|
1213
|
-
const interactive = frame.locator(INTERACTIVE_SELECTOR);
|
|
1214
|
-
tiers.push(exactBase.and(interactive));
|
|
1215
|
-
tiers.push(exactBase);
|
|
1216
|
-
tiers.push(substrBase.and(interactive));
|
|
1217
|
-
tiers.push(substrBase);
|
|
1218
|
-
} else {
|
|
1219
|
-
tiers.push(exactBase);
|
|
1220
|
-
tiers.push(substrBase);
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
for (const loc of tiers) {
|
|
1224
|
-
const count = await loc.count();
|
|
1225
|
-
if (count > 0) {
|
|
1226
|
-
return { baseLocator: loc, matchCount: count };
|
|
1227
|
-
}
|
|
1228
|
-
}
|
|
1229
|
-
return { baseLocator: substrBase, matchCount: 0 };
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
// Pick the single element to act on from a resolved locator. When more than one
|
|
1233
|
-
// candidate remains within the winning tier, prefer a VISIBLE element over a
|
|
1234
|
-
// hidden one rather than acting on raw `.first()` (a hidden duplicate — an
|
|
1235
|
-
// off-screen menu clone, an aria-hidden mirror — is almost never the intended
|
|
1236
|
-
// target). Degrades to `.first()` when the locator API lacks a visible filter
|
|
1237
|
-
// or no candidate is visible.
|
|
1238
|
-
async function pickBestCandidate(baseLocator, matchCount) {
|
|
1239
|
-
if (matchCount <= 1) {
|
|
1240
|
-
return baseLocator.first();
|
|
1241
|
-
}
|
|
1242
|
-
try {
|
|
1243
|
-
if (typeof baseLocator.filter === "function") {
|
|
1244
|
-
const visible = baseLocator.filter({ visible: true });
|
|
1245
|
-
if ((await visible.count()) > 0) {
|
|
1246
|
-
return visible.first();
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1249
|
-
} catch (_) {
|
|
1250
|
-
// Locator API without a `{ visible: true }` filter — fall through.
|
|
1251
|
-
}
|
|
1252
|
-
return baseLocator.first();
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
1073
|
// Text matching prefers an EXACT, INTERACTIVE element over a looser substring /
|
|
1256
1074
|
// plain-text hit (see `resolveTextTarget`), so the first attempt lands on the
|
|
1257
1075
|
// chip or button the agent meant. When a genuine ambiguity survives that
|
|
@@ -1281,108 +1099,6 @@ function isCanvasInteraction(interaction) {
|
|
|
1281
1099
|
);
|
|
1282
1100
|
}
|
|
1283
1101
|
|
|
1284
|
-
// The page that owns `frame` — its `mouse` and `keyboard` are page-level, and a
|
|
1285
|
-
// locator's `boundingBox()` is already in that page's viewport coordinates, so
|
|
1286
|
-
// a point measured from the box can be handed to `page.mouse` unchanged.
|
|
1287
|
-
function pageOf(frame) {
|
|
1288
|
-
return typeof frame.page === "function" ? frame.page() : frame;
|
|
1289
|
-
}
|
|
1290
|
-
|
|
1291
|
-
// `drag` / `wheel` need the target's box to turn a target-relative point into a
|
|
1292
|
-
// viewport point. A target that is not rendered has no box, and acting at
|
|
1293
|
-
// (0,0) instead would silently drive whatever is in the corner.
|
|
1294
|
-
async function targetBox(locator, targetDesc) {
|
|
1295
|
-
if (typeof locator.scrollIntoViewIfNeeded === "function") {
|
|
1296
|
-
await locator.scrollIntoViewIfNeeded().catch(() => {});
|
|
1297
|
-
}
|
|
1298
|
-
const box = await locator.boundingBox();
|
|
1299
|
-
if (!box) {
|
|
1300
|
-
throw new Error(`${targetDesc} has no bounding box (not rendered or not visible)`);
|
|
1301
|
-
}
|
|
1302
|
-
return box;
|
|
1303
|
-
}
|
|
1304
|
-
|
|
1305
|
-
// A target-relative point resolved to viewport coordinates, plus whether it
|
|
1306
|
-
// lies inside the box. Positions are CSS pixels from the target's top-left, so
|
|
1307
|
-
// a point measured off a device-pixel screenshot overshoots the box and would
|
|
1308
|
-
// drive whatever sits beside the target; `inside` lets the caller refuse it.
|
|
1309
|
-
// No point means the centre, which is always inside.
|
|
1310
|
-
function pointIn(box, point) {
|
|
1311
|
-
if (point && typeof point.x === "number" && typeof point.y === "number") {
|
|
1312
|
-
const inside =
|
|
1313
|
-
point.x >= 0 && point.y >= 0 && point.x < box.width && point.y < box.height;
|
|
1314
|
-
return { x: box.x + point.x, y: box.y + point.y, inside };
|
|
1315
|
-
}
|
|
1316
|
-
return { x: box.x + box.width / 2, y: box.y + box.height / 2, inside: true };
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
// The page's device pixel ratio — how many screenshot pixels make one CSS
|
|
1320
|
-
// pixel. `null` when the frame cannot answer, so a report never guesses.
|
|
1321
|
-
async function devicePixelRatioOf(frame) {
|
|
1322
|
-
try {
|
|
1323
|
-
const ratio = await frame.evaluate(() => window.devicePixelRatio);
|
|
1324
|
-
return typeof ratio === "number" && ratio > 0 ? ratio : null;
|
|
1325
|
-
} catch (_) {
|
|
1326
|
-
return null;
|
|
1327
|
-
}
|
|
1328
|
-
}
|
|
1329
|
-
|
|
1330
|
-
// Why a point is not where its author thought: name the target's real CSS size
|
|
1331
|
-
// and, when screenshots are scaled, the divisor that converts a coordinate
|
|
1332
|
-
// measured off one into the CSS pixels a position is written in.
|
|
1333
|
-
function outsideTargetMessage(label, point, targetDesc, box, devicePixelRatio) {
|
|
1334
|
-
const scale =
|
|
1335
|
-
devicePixelRatio && devicePixelRatio !== 1
|
|
1336
|
-
? `screenshots are ${devicePixelRatio}× device pixels — divide screenshot coordinates by ${devicePixelRatio}`
|
|
1337
|
-
: "positions are CSS pixels from the target's top-left";
|
|
1338
|
-
const size = `${Math.round(box.width)}×${Math.round(box.height)} CSS px`;
|
|
1339
|
-
return `${label} (${point.x},${point.y}) is outside ${targetDesc} (${size}; ${scale})`;
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
// A start point outside the target can never act on it, so it is refused —
|
|
1343
|
-
// loudly, like a target that matches nothing. Flagged so `performInteraction`
|
|
1344
|
-
// rethrows it without the candidate-labels hint, which is about a missing
|
|
1345
|
-
// target, not a misplaced point.
|
|
1346
|
-
async function outsideTargetError(frame, action, label, point, targetDesc, box) {
|
|
1347
|
-
const error = new Error(
|
|
1348
|
-
`preview-interact: action "${action}" refused: ` +
|
|
1349
|
-
outsideTargetMessage(label, point, targetDesc, box, await devicePixelRatioOf(frame)),
|
|
1350
|
-
);
|
|
1351
|
-
error.outsideTarget = true;
|
|
1352
|
-
return error;
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
// Where a pointer step landed, in the target's own CSS-pixel frame, beside the
|
|
1356
|
-
// target's size — what a report needs to answer "where did the pointer go?"
|
|
1357
|
-
// without opening a captured frame. `end` is a drag's release point.
|
|
1358
|
-
function pointerGeometry(box, start, end) {
|
|
1359
|
-
const relative = (p) => ({ x: Math.round(p.x - box.x), y: Math.round(p.y - box.y) });
|
|
1360
|
-
const geometry = {
|
|
1361
|
-
targetBox: { width: Math.round(box.width), height: Math.round(box.height) },
|
|
1362
|
-
landedAt: relative(start),
|
|
1363
|
-
};
|
|
1364
|
-
if (end) geometry.landedTo = relative(end);
|
|
1365
|
-
return geometry;
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
// `click` / `hover` hand `position` to Playwright, which does not check that it
|
|
1369
|
-
// lies inside the target — so measure the box first and refuse a point outside
|
|
1370
|
-
// it. A position-less step aims at the centre; its box is best-effort (no box
|
|
1371
|
-
// means no geometry, and Playwright's own actionability error stands).
|
|
1372
|
-
async function locatorPointGeometry(frame, locator, targetDesc, action, position) {
|
|
1373
|
-
if (!position) {
|
|
1374
|
-
if (typeof locator.boundingBox !== "function") return undefined;
|
|
1375
|
-
const box = await locator.boundingBox().catch(() => null);
|
|
1376
|
-
return box ? pointerGeometry(box, pointIn(box)) : undefined;
|
|
1377
|
-
}
|
|
1378
|
-
const box = await targetBox(locator, targetDesc);
|
|
1379
|
-
const at = pointIn(box, position);
|
|
1380
|
-
if (!at.inside) {
|
|
1381
|
-
throw await outsideTargetError(frame, action, "position", position, targetDesc, box);
|
|
1382
|
-
}
|
|
1383
|
-
return pointerGeometry(box, at);
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
1102
|
// Page-level keyboard input. Deliberately not `locator.press`: a `<canvas>`
|
|
1387
1103
|
// without `tabindex` never receives focus, so a locator-scoped press is
|
|
1388
1104
|
// delivered nowhere. A preceding click on the canvas gives the app focus.
|
|
@@ -1460,6 +1176,16 @@ async function resolveInteractionTarget(
|
|
|
1460
1176
|
return { locator, targetDesc };
|
|
1461
1177
|
}
|
|
1462
1178
|
|
|
1179
|
+
// Drive a single user-style interaction against the settled frame before the
|
|
1180
|
+
// screenshot, so an interactive state (expanded accordion, open modal, filled
|
|
1181
|
+
// field) can be captured without editing app source.
|
|
1182
|
+
//
|
|
1183
|
+
// The target is matched by visible `text` (preferred — the agent reliably
|
|
1184
|
+
// knows the label it rendered) or a CSS `selector`. `action` is click / fill /
|
|
1185
|
+
// press; `value` carries the text for `fill` or the key for `press` (e.g.
|
|
1186
|
+
// `Enter`). On a no-match target this THROWS with the list of candidate
|
|
1187
|
+
// interactive labels — the capture script's outer catch turns that into a
|
|
1188
|
+
// failed capture with an actionable message, never a silent blank screenshot.
|
|
1463
1189
|
async function performInteraction(
|
|
1464
1190
|
frame,
|
|
1465
1191
|
interaction,
|
|
@@ -1749,15 +1475,10 @@ module.exports = {
|
|
|
1749
1475
|
waitForNetworkQuiet,
|
|
1750
1476
|
waitForStablePage,
|
|
1751
1477
|
waitForReadySignal,
|
|
1478
|
+
watchNavigationAfterReady,
|
|
1752
1479
|
loadScenarioInIframe,
|
|
1753
1480
|
loadScenarioTopLevel,
|
|
1754
|
-
collectInteractiveLabels,
|
|
1755
|
-
candidateLabelSummary,
|
|
1756
|
-
noMatchGuidance,
|
|
1757
|
-
describeMatchCandidates,
|
|
1758
1481
|
isCanvasInteraction,
|
|
1759
|
-
pointIn,
|
|
1760
|
-
devicePixelRatioOf,
|
|
1761
1482
|
resolveInteractionTarget,
|
|
1762
1483
|
performInteraction,
|
|
1763
1484
|
pixelSnapshot,
|