@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.
@@ -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,