@codeyam-editor/codeyam-editor 0.1.14 → 0.1.15
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/agent-cli-install.js +102 -0
- package/npm/cloud.js +50 -53
- package/npm/editor.js +23 -0
- package/npm/fleet-add-failure.js +53 -2
- package/npm/fleet-auth.js +17 -1
- package/npm/launch-config.js +20 -3
- package/npm/launcher-boot-log.js +57 -0
- package/npm/operator-host-disk.js +44 -0
- package/npm/scenario-check.js +172 -1
- package/npm/scenario-metrics.js +101 -0
- package/npm/scenario-playwright.js +77 -5
- package/npm/utils.js +140 -21
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-9w8TlRwl.js → ContainerInspector-Ceop11Fv.js} +1 -1
- package/ui/dist/assets/EditorShell-DjI5cEF_.js +366 -0
- package/ui/dist/assets/ScenarioDataPanel-D-nWT7xD.js +21 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-DkysGs6X.js → ScenarioDataPanelFullPage-ZNFZcTQU.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-BW6QLGFW.js +26 -0
- package/ui/dist/assets/index-B3SMxYUB.css +1 -0
- package/ui/dist/assets/{index-CqKB9WVm.js → index-ClrdYzlw.js} +6 -6
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-CmEoMxKh.js +0 -366
- package/ui/dist/assets/ScenarioDataPanel-BM7COEfv.js +0 -21
- package/ui/dist/assets/ScenarioRenderer-M3HRiuEn.js +0 -26
- package/ui/dist/assets/index-Bk0fHYF3.css +0 -1
package/npm/scenario-check.js
CHANGED
|
@@ -352,6 +352,8 @@ const {
|
|
|
352
352
|
frameHasVisualContent,
|
|
353
353
|
buildSettleAdvisory,
|
|
354
354
|
describeBlankReason,
|
|
355
|
+
stripStateSelector,
|
|
356
|
+
collectCssRuleMatches,
|
|
355
357
|
} = require("./scenario-metrics");
|
|
356
358
|
|
|
357
359
|
// Decode a PNG screenshot buffer to `{ width, height, data }` (RGBA) with the
|
|
@@ -710,6 +712,97 @@ function readStackReadySignal() {
|
|
|
710
712
|
}
|
|
711
713
|
}
|
|
712
714
|
|
|
715
|
+
// Read the app's declared rendered-sources signal from `.codeyam/stack.json`
|
|
716
|
+
// (`capture.renderedSourcesSignal`): a JS expression returning the source paths
|
|
717
|
+
// of the widgets the app actually drew. A canvas app routes every scenario
|
|
718
|
+
// through one entry, so static analysis cannot tell which widgets a seed
|
|
719
|
+
// reaches; only the running app can. Never throws; an unreadable stack.json or
|
|
720
|
+
// a missing/empty value declares no signal (null).
|
|
721
|
+
function readStackRenderedSourcesSignal() {
|
|
722
|
+
try {
|
|
723
|
+
const raw = fs.readFileSync(path.join(".codeyam", "stack.json"), "utf8");
|
|
724
|
+
const stack = JSON.parse(raw);
|
|
725
|
+
return normalizeRenderedSourcesSignal(stack && stack.capture);
|
|
726
|
+
} catch (_) {
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function normalizeRenderedSourcesSignal(source) {
|
|
732
|
+
return source &&
|
|
733
|
+
typeof source.renderedSourcesSignal === "string" &&
|
|
734
|
+
source.renderedSourcesSignal.trim()
|
|
735
|
+
? source.renderedSourcesSignal.trim()
|
|
736
|
+
: null;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// The rendered-sources signal this capture evaluates: one injected through
|
|
740
|
+
// config (unit tests) wins; otherwise stack.json's.
|
|
741
|
+
function resolveRenderedSourcesSignal(config, readStack = readStackRenderedSourcesSignal) {
|
|
742
|
+
return config && typeof config.renderedSourcesSignal === "string"
|
|
743
|
+
? normalizeRenderedSourcesSignal(config)
|
|
744
|
+
: readStack();
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// The record beside a screenshot: `<screenshot>.rendered.json`. The editor's
|
|
748
|
+
// staleness check (`rendered_sources.rs`) reads the same spelling.
|
|
749
|
+
function renderedSourcesRecordPath(outputPath) {
|
|
750
|
+
return `${outputPath}.rendered.json`;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Evaluate the rendered-sources signal in the captured frame and record the
|
|
754
|
+
// answer beside the screenshot as `{"sources": [...]}`. Any earlier record is
|
|
755
|
+
// removed first, so a recapture that yields no usable answer never leaves the
|
|
756
|
+
// previous capture's report standing. An empty array means "no information"
|
|
757
|
+
// (no record, no warning); a throw or a non-array / non-string value is a
|
|
758
|
+
// warning — non-blocking, because the editor then simply keeps the full static
|
|
759
|
+
// render set. Returns the warning string, or null.
|
|
760
|
+
async function recordRenderedSources(frame, signal, outputPath, { fsImpl = fs } = {}) {
|
|
761
|
+
const recordPath = renderedSourcesRecordPath(outputPath);
|
|
762
|
+
try {
|
|
763
|
+
fsImpl.rmSync(recordPath, { force: true });
|
|
764
|
+
} catch (_) {}
|
|
765
|
+
if (!signal) return null;
|
|
766
|
+
let outcome;
|
|
767
|
+
try {
|
|
768
|
+
outcome = await frame.evaluate((expr) => {
|
|
769
|
+
try {
|
|
770
|
+
return { value: (0, eval)(expr) };
|
|
771
|
+
} catch (e) {
|
|
772
|
+
return { error: String((e && e.message) || e) };
|
|
773
|
+
}
|
|
774
|
+
}, signal);
|
|
775
|
+
} catch (e) {
|
|
776
|
+
outcome = { error: String((e && e.message) || e) };
|
|
777
|
+
}
|
|
778
|
+
const { sources, warning } = renderedSourcesFromOutcome(outcome, signal);
|
|
779
|
+
if (sources) {
|
|
780
|
+
fsImpl.writeFileSync(recordPath, `${JSON.stringify({ sources }, null, 2)}\n`);
|
|
781
|
+
}
|
|
782
|
+
return warning;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Judge what the signal evaluated to: `{ sources }` (sorted, deduped, blanks
|
|
786
|
+
// dropped) for a usable answer, `{ warning }` for a throw or a value that is not
|
|
787
|
+
// an array of strings, and neither for an empty answer — "no information",
|
|
788
|
+
// which the editor already treats as "keep the static render set".
|
|
789
|
+
function renderedSourcesFromOutcome(outcome, signal) {
|
|
790
|
+
if (outcome && outcome.error != null) {
|
|
791
|
+
return {
|
|
792
|
+
warning: `rendered-sources: \`${signal}\` threw (${outcome.error}); staleness keeps this scenario's full static render set. Fix capture.renderedSourcesSignal in .codeyam/stack.json or the app code that publishes it.`,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
const value = outcome ? outcome.value : undefined;
|
|
796
|
+
if (!Array.isArray(value) || value.some((s) => typeof s !== "string")) {
|
|
797
|
+
const shape = Array.isArray(value) ? "an array with non-string entries" : typeof value;
|
|
798
|
+
return {
|
|
799
|
+
warning: `rendered-sources: \`${signal}\` returned ${shape}, not an array of source paths; staleness keeps this scenario's full static render set.`,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
const sources = [...new Set(value.map((s) => s.trim()).filter((s) => s.length > 0))].sort();
|
|
803
|
+
return sources.length > 0 ? { sources, warning: null } : { warning: null };
|
|
804
|
+
}
|
|
805
|
+
|
|
713
806
|
// The readiness this capture waits on: a `readySignal` injected through config
|
|
714
807
|
// (unit tests, and any caller that knows better) wins; otherwise stack.json's.
|
|
715
808
|
function resolveCaptureReadiness(config, readStack = readStackReadySignal) {
|
|
@@ -834,6 +927,36 @@ async function resolvePixelClip(frame, interaction, resolve = resolveInteraction
|
|
|
834
927
|
}
|
|
835
928
|
}
|
|
836
929
|
|
|
930
|
+
// The canvas form of a flow `waitFor` (`{"canvas": true}`): hold until two
|
|
931
|
+
// consecutive screenshots match. The shot is clipped to the page's canvas when
|
|
932
|
+
// one resolves, as the other canvas settles are, so an unrelated spinner
|
|
933
|
+
// elsewhere cannot keep it unsettled; with no canvas it uses the viewport.
|
|
934
|
+
// THROWS naming the settle and the bound when the frame never stops changing —
|
|
935
|
+
// the same fail-loud contract the DOM wait keeps, never a silent pass.
|
|
936
|
+
async function waitForCanvasSettle(
|
|
937
|
+
page,
|
|
938
|
+
frame,
|
|
939
|
+
step,
|
|
940
|
+
{
|
|
941
|
+
defaultTimeoutMs = 8000,
|
|
942
|
+
resolveClip = resolvePixelClip,
|
|
943
|
+
pixelSettle = waitForPixelStable,
|
|
944
|
+
} = {},
|
|
945
|
+
) {
|
|
946
|
+
const { timeoutMs } = step || {};
|
|
947
|
+
const bound =
|
|
948
|
+
typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : defaultTimeoutMs;
|
|
949
|
+
const { clip } = await resolveClip(frame, { action: "click", selector: "canvas" });
|
|
950
|
+
if (!(await pixelSettle(page, { clip, timeoutMs: bound }))) {
|
|
951
|
+
throw new Error(
|
|
952
|
+
`waitFor: the canvas frame did not settle (two consecutive equal ` +
|
|
953
|
+
`screenshots) within ${bound}ms — something on it is still moving ` +
|
|
954
|
+
`(a spinner or a running animation). Raise timeoutMs, or drop the ` +
|
|
955
|
+
`wait and capture.`,
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
837
960
|
// Which signal proved the effect: `dom` wins when both moved, because it is the
|
|
838
961
|
// stronger and more specific evidence. `null` when neither moved.
|
|
839
962
|
function interactionEffectSignal(beforeFingerprint, afterFingerprint, pixelsChanged) {
|
|
@@ -1869,6 +1992,28 @@ function resolveFlowDownloadDir(config) {
|
|
|
1869
1992
|
);
|
|
1870
1993
|
}
|
|
1871
1994
|
|
|
1995
|
+
// The transient record of which stylesheet rules matched the frame at
|
|
1996
|
+
// `outputPath`. The editor folds it into the `.png.hash` sidecar so an edit to
|
|
1997
|
+
// a rule that styled nothing in this frame leaves the screenshot fresh.
|
|
1998
|
+
function cssRuleMatchesPath(outputPath) {
|
|
1999
|
+
return `${outputPath}.css-matches.json`;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
// Walk the settled DOM and write the record for the frame just captured.
|
|
2003
|
+
// Stack assumption: per-rule CSS tracking needs a DOM at capture; a failed walk
|
|
2004
|
+
// writes nothing, and no record means whole-file stylesheet digests — so every
|
|
2005
|
+
// failure here is swallowed rather than failing the capture.
|
|
2006
|
+
async function recordCssRuleMatches(frame, outputPath) {
|
|
2007
|
+
try {
|
|
2008
|
+
const matches = await frame.evaluate(
|
|
2009
|
+
`(${collectCssRuleMatches.toString()})(${stripStateSelector.toString()})`,
|
|
2010
|
+
);
|
|
2011
|
+
fs.writeFileSync(cssRuleMatchesPath(outputPath), JSON.stringify(matches));
|
|
2012
|
+
} catch (_) {
|
|
2013
|
+
// No record is the safe outcome.
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
|
|
1872
2017
|
// Close the capture browser, but never wait on it forever: a browser wedged in
|
|
1873
2018
|
// teardown (a download mid-flight, a crashed renderer) used to hold the whole
|
|
1874
2019
|
// capture past its budget with every frame already on disk. Past `boundMs` the
|
|
@@ -2056,7 +2201,11 @@ async function runFlowSteps(page, initialFrame, steps, ctx) {
|
|
|
2056
2201
|
break;
|
|
2057
2202
|
}
|
|
2058
2203
|
case "waitFor":
|
|
2059
|
-
|
|
2204
|
+
if (step.canvas === true) {
|
|
2205
|
+
await waitForCanvasSettle(page, frame, step);
|
|
2206
|
+
} else {
|
|
2207
|
+
await waitForPredicate(frame, step);
|
|
2208
|
+
}
|
|
2060
2209
|
break;
|
|
2061
2210
|
case "capture":
|
|
2062
2211
|
if (step.outputPath) {
|
|
@@ -2772,7 +2921,22 @@ async function runScenarioCheck(
|
|
|
2772
2921
|
// recoverable rather than destructive.
|
|
2773
2922
|
if (outputPath && loaded && !devServerPlaceholder) {
|
|
2774
2923
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
2924
|
+
// A record left by an earlier capture describes an earlier frame, so it
|
|
2925
|
+
// goes before this shot; the one written after always describes THIS one.
|
|
2926
|
+
fs.rmSync(cssRuleMatchesPath(outputPath), { force: true });
|
|
2775
2927
|
const shot = await page.screenshot({ path: outputPath, fullPage: false });
|
|
2928
|
+
await recordCssRuleMatches(frame, outputPath);
|
|
2929
|
+
// Ask the app which widget sources it drew in THIS frame, and record the
|
|
2930
|
+
// answer beside the screenshot it describes. Only when the app signalled
|
|
2931
|
+
// ready — a frame that never finished painting reports nothing useful.
|
|
2932
|
+
if (readyOutcome.ready) {
|
|
2933
|
+
const renderedWarning = await recordRenderedSources(
|
|
2934
|
+
frame,
|
|
2935
|
+
resolveRenderedSourcesSignal(config),
|
|
2936
|
+
outputPath,
|
|
2937
|
+
);
|
|
2938
|
+
if (renderedWarning) interactionWarnings.push(renderedWarning);
|
|
2939
|
+
}
|
|
2776
2940
|
// A canvas-dominated page passed the DOM blank gate on its canvas's
|
|
2777
2941
|
// bounding box alone, so check the pixels it actually painted. A frame
|
|
2778
2942
|
// that is one flat color is an unpainted (or cleared-to-black) canvas.
|
|
@@ -2922,12 +3086,14 @@ async function main() {
|
|
|
2922
3086
|
|
|
2923
3087
|
module.exports = {
|
|
2924
3088
|
runScenarioCheck,
|
|
3089
|
+
recordCssRuleMatches,
|
|
2925
3090
|
classifyInteractionEffect,
|
|
2926
3091
|
interactionEffectSignal,
|
|
2927
3092
|
buildStepEffect,
|
|
2928
3093
|
classifyKeyTarget,
|
|
2929
3094
|
fingerprintHtml,
|
|
2930
3095
|
resolvePixelClip,
|
|
3096
|
+
waitForCanvasSettle,
|
|
2931
3097
|
mergeVisibleTextLength,
|
|
2932
3098
|
runFlowSteps,
|
|
2933
3099
|
saveFlowDownload,
|
|
@@ -2975,6 +3141,11 @@ module.exports = {
|
|
|
2975
3141
|
resolveReadySignal,
|
|
2976
3142
|
readStackReadySignal,
|
|
2977
3143
|
resolveCaptureReadiness,
|
|
3144
|
+
readStackRenderedSourcesSignal,
|
|
3145
|
+
resolveRenderedSourcesSignal,
|
|
3146
|
+
renderedSourcesRecordPath,
|
|
3147
|
+
recordRenderedSources,
|
|
3148
|
+
renderedSourcesFromOutcome,
|
|
2978
3149
|
readySignalTimeoutIssue,
|
|
2979
3150
|
canvasBlankIssue,
|
|
2980
3151
|
decodeScreenshotPng,
|
package/npm/scenario-metrics.js
CHANGED
|
@@ -267,7 +267,108 @@ function frameHasVisualContent(image) {
|
|
|
267
267
|
return Math.round(max) - Math.round(min) >= MIN_CONTENT_LUMA_SPREAD;
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
// Reduce a selector to the part a settled DOM can answer. Pseudo-elements
|
|
271
|
+
// (`::before`, `::placeholder`, legacy `:after`) and interaction-state
|
|
272
|
+
// pseudo-classes (`:hover`, `:focus-visible`, …) are dropped, so
|
|
273
|
+
// `.btn:hover::after` asks whether a `.btn` exists. Deliberately
|
|
274
|
+
// over-inclusive: the staleness check ignores edits only to rules that
|
|
275
|
+
// matched NOTHING, so counting a rule as matched can cost a recapture but
|
|
276
|
+
// never hides one. An emptied compound (`:hover` alone) becomes `*`.
|
|
277
|
+
function stripStateSelector(selector) {
|
|
278
|
+
const stripped = String(selector)
|
|
279
|
+
.replace(/::[\w-]+(\([^)]*\))?/g, "")
|
|
280
|
+
.replace(/:(before|after|first-line|first-letter)(?![\w-])/gi, "")
|
|
281
|
+
.replace(
|
|
282
|
+
/:(hover|focus-visible|focus-within|focus|active|visited|target|link|any-link)(?![\w-])/gi,
|
|
283
|
+
"",
|
|
284
|
+
);
|
|
285
|
+
return stripped
|
|
286
|
+
.split(",")
|
|
287
|
+
.map((part) => {
|
|
288
|
+
const trimmed = part.trim();
|
|
289
|
+
if (trimmed === "") return "*";
|
|
290
|
+
return /[>+~]$/.test(trimmed) ? `${trimmed} *` : trimmed;
|
|
291
|
+
})
|
|
292
|
+
.join(", ");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Runs IN THE PAGE (via `frame.evaluate`), so it is self-contained and takes
|
|
296
|
+
// the selector stripper as an argument rather than closing over it. Records,
|
|
297
|
+
// per stylesheet, which style rules matched an element in the captured DOM.
|
|
298
|
+
// Rules inside `@media` / `@supports` / `@container` / `@layer` are tested
|
|
299
|
+
// whatever their condition, so a viewport difference never under-counts.
|
|
300
|
+
//
|
|
301
|
+
// A sheet is named by Vite's `data-vite-dev-id` (an absolute source path) or
|
|
302
|
+
// its href's pathname; an unnamed inline `<style>` is skipped, since no
|
|
303
|
+
// project file can be resolved from it. Cross-origin sheets whose `cssRules`
|
|
304
|
+
// throw are skipped too — they are not project source. Shadow roots with their
|
|
305
|
+
// own styles and `adoptedStyleSheets` are invisible to this walk, so either
|
|
306
|
+
// marks the whole record `unsupported` and the editor falls back to digesting
|
|
307
|
+
// whole files. A nested style rule marks its sheet `unsupported` the same way.
|
|
308
|
+
function collectCssRuleMatches(strip) {
|
|
309
|
+
const out = { unsupported: false, sheets: [] };
|
|
310
|
+
if (document.adoptedStyleSheets && document.adoptedStyleSheets.length > 0) {
|
|
311
|
+
out.unsupported = true;
|
|
312
|
+
}
|
|
313
|
+
for (const el of Array.from(document.querySelectorAll("*"))) {
|
|
314
|
+
const root = el.shadowRoot;
|
|
315
|
+
if (
|
|
316
|
+
root &&
|
|
317
|
+
((root.adoptedStyleSheets && root.adoptedStyleSheets.length > 0) ||
|
|
318
|
+
root.querySelector("style, link[rel~='stylesheet']"))
|
|
319
|
+
) {
|
|
320
|
+
out.unsupported = true;
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const matches = (selectorText) => {
|
|
325
|
+
try {
|
|
326
|
+
return document.querySelector(strip(selectorText)) !== null;
|
|
327
|
+
} catch (_) {
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
332
|
+
let rules;
|
|
333
|
+
try {
|
|
334
|
+
rules = sheet.cssRules;
|
|
335
|
+
} catch (_) {
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const owner = sheet.ownerNode;
|
|
339
|
+
const viteId =
|
|
340
|
+
owner && owner.getAttribute ? owner.getAttribute("data-vite-dev-id") : null;
|
|
341
|
+
let id = viteId;
|
|
342
|
+
if (!id && sheet.href) {
|
|
343
|
+
try {
|
|
344
|
+
id = new URL(sheet.href).pathname;
|
|
345
|
+
} catch (_) {
|
|
346
|
+
id = null;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (!id) continue;
|
|
350
|
+
const entry = { id, matched: [], unmatched: [], unsupported: false };
|
|
351
|
+
const walk = (list) => {
|
|
352
|
+
for (const rule of Array.from(list)) {
|
|
353
|
+
if (typeof CSSStyleRule !== "undefined" && rule instanceof CSSStyleRule) {
|
|
354
|
+
(matches(rule.selectorText) ? entry.matched : entry.unmatched).push(
|
|
355
|
+
rule.selectorText,
|
|
356
|
+
);
|
|
357
|
+
if (rule.cssRules && rule.cssRules.length > 0) entry.unsupported = true;
|
|
358
|
+
} else if (rule.cssRules) {
|
|
359
|
+
walk(rule.cssRules);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
walk(rules);
|
|
364
|
+
out.sheets.push(entry);
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
|
|
270
369
|
module.exports = {
|
|
370
|
+
stripStateSelector,
|
|
371
|
+
collectCssRuleMatches,
|
|
271
372
|
hasLoadingMarkers,
|
|
272
373
|
hasRenderableContent,
|
|
273
374
|
isCanvasDominated,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const {
|
|
2
2
|
hasLoadingMarkers,
|
|
3
|
+
isCanvasDominated,
|
|
3
4
|
shouldStopWaitingForImages,
|
|
4
5
|
} = require("./scenario-metrics");
|
|
5
6
|
const fs = require("fs");
|
|
@@ -1099,11 +1100,27 @@ async function candidateLabelSummary(frame) {
|
|
|
1099
1100
|
// labels ride with the text branch for the same reason: they answer "did you
|
|
1100
1101
|
// mean one of these?", a question only a text match can have asked, and every
|
|
1101
1102
|
// label printed is a line of the user's own page echoed into the terminal.
|
|
1102
|
-
|
|
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 }) {
|
|
1103
1109
|
const queryParamHint =
|
|
1104
1110
|
`Many filter/status/sort states are also reachable directly via a ` +
|
|
1105
1111
|
`URL query param (e.g. add "?status=active" to the path) with no ` +
|
|
1106
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
|
+
}
|
|
1107
1124
|
if (matchedByText) {
|
|
1108
1125
|
return (
|
|
1109
1126
|
`preview-interact: no element matched ${targetDesc}. ` +
|
|
@@ -1381,7 +1398,11 @@ async function performKeyboardInteraction(frame, { action, value }) {
|
|
|
1381
1398
|
// Resolve a step's target to the single element to act on. Shared by
|
|
1382
1399
|
// `performInteraction` and by callers that need the target's box BEFORE
|
|
1383
1400
|
// acting (the pixel-effect signal clips its before/after shots to it).
|
|
1384
|
-
async function resolveInteractionTarget(
|
|
1401
|
+
async function resolveInteractionTarget(
|
|
1402
|
+
frame,
|
|
1403
|
+
interaction,
|
|
1404
|
+
{ warnings, canvasProbe = probeCanvasDominated } = {},
|
|
1405
|
+
) {
|
|
1385
1406
|
const { action, selector, text } = interaction || {};
|
|
1386
1407
|
|
|
1387
1408
|
let baseLocator;
|
|
@@ -1415,6 +1436,7 @@ async function resolveInteractionTarget(frame, interaction, { warnings } = {}) {
|
|
|
1415
1436
|
candidateLabels: matchedByText
|
|
1416
1437
|
? await candidateLabelSummary(frame)
|
|
1417
1438
|
: null,
|
|
1439
|
+
canvasDominated: matchedByText ? await canvasProbe(frame) : false,
|
|
1418
1440
|
}),
|
|
1419
1441
|
);
|
|
1420
1442
|
}
|
|
@@ -1591,14 +1613,31 @@ async function waitForPixelStable(
|
|
|
1591
1613
|
// turns a never-appearing predicate into an actionable failure rather than an
|
|
1592
1614
|
// infinite hang. `text` is matched case-insensitively as a substring (the
|
|
1593
1615
|
// agent reliably knows the copy it rendered); `selector` is a CSS selector.
|
|
1594
|
-
|
|
1616
|
+
//
|
|
1617
|
+
// A text predicate on a canvas-dominated page is refused at once rather than
|
|
1618
|
+
// after the full bound: canvas-painted words are never DOM text, so the wait
|
|
1619
|
+
// could only time out. A hybrid page (DOM text beside a canvas) is not
|
|
1620
|
+
// canvas-dominated and keeps the ordinary wait; a canvas that mounts late is
|
|
1621
|
+
// caught on the timeout path, which re-checks and appends the same guidance.
|
|
1622
|
+
// A selector predicate is never refused — naming the canvas element itself is
|
|
1623
|
+
// a legitimate wait for that element. `canvasProbe` is injectable so unit
|
|
1624
|
+
// tests classify the page without a browser.
|
|
1625
|
+
async function waitForPredicate(
|
|
1626
|
+
frame,
|
|
1627
|
+
predicate,
|
|
1628
|
+
{ defaultTimeoutMs = 8000, canvasProbe = probeCanvasDominated } = {},
|
|
1629
|
+
) {
|
|
1595
1630
|
const { text, selector, timeoutMs } = predicate || {};
|
|
1596
1631
|
const bound =
|
|
1597
1632
|
typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : defaultTimeoutMs;
|
|
1598
1633
|
|
|
1599
1634
|
let locator;
|
|
1600
1635
|
let desc;
|
|
1601
|
-
|
|
1636
|
+
const byText = typeof text === "string" && text.length > 0;
|
|
1637
|
+
if (byText) {
|
|
1638
|
+
if (await canvasProbe(frame)) {
|
|
1639
|
+
throw new Error(`waitFor: predicate text "${text}" ${canvasTextWaitGuidance()}`);
|
|
1640
|
+
}
|
|
1602
1641
|
locator = frame.getByText(text, { exact: false }).first();
|
|
1603
1642
|
desc = `text "${text}"`;
|
|
1604
1643
|
} else if (typeof selector === "string" && selector.length > 0) {
|
|
@@ -1611,12 +1650,44 @@ async function waitForPredicate(frame, predicate, { defaultTimeoutMs = 8000 } =
|
|
|
1611
1650
|
try {
|
|
1612
1651
|
await locator.waitFor({ state: "visible", timeout: bound });
|
|
1613
1652
|
} catch (_) {
|
|
1653
|
+
const lateCanvas = byText && (await canvasProbe(frame));
|
|
1614
1654
|
throw new Error(
|
|
1615
|
-
`waitFor: predicate ${desc} did not become visible within ${bound}ms
|
|
1655
|
+
`waitFor: predicate ${desc} did not become visible within ${bound}ms` +
|
|
1656
|
+
(lateCanvas ? `. The page is now canvas-dominated: ${canvasTextWaitGuidance()}` : ""),
|
|
1616
1657
|
);
|
|
1617
1658
|
}
|
|
1618
1659
|
}
|
|
1619
1660
|
|
|
1661
|
+
// Is the frame canvas-dominated right now — visible canvas/svg/video and no
|
|
1662
|
+
// visible text? Reads the same content state the blank gate classifies on.
|
|
1663
|
+
// Best-effort: a frame that cannot be measured reads as NOT canvas, so the
|
|
1664
|
+
// caller keeps its ordinary DOM behavior instead of refusing on a guess.
|
|
1665
|
+
async function probeCanvasDominated(frame) {
|
|
1666
|
+
try {
|
|
1667
|
+
const state = await collectContentState(frame);
|
|
1668
|
+
const visible = await collectVisibleTextLength(frame);
|
|
1669
|
+
if (typeof visible === "number") state.visibleTextLength = visible;
|
|
1670
|
+
return isCanvasDominated(state);
|
|
1671
|
+
} catch (_) {
|
|
1672
|
+
return false;
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// The redirect a text wait on a canvas page gets: why it cannot work, and the
|
|
1677
|
+
// three forms that do.
|
|
1678
|
+
function canvasTextWaitGuidance() {
|
|
1679
|
+
return (
|
|
1680
|
+
`cannot be awaited: this page is canvas-dominated (it paints into a ` +
|
|
1681
|
+
`canvas with no visible page text), and canvas-painted words are never ` +
|
|
1682
|
+
`in the DOM, so a text wait could only time out. Instead: wait for the ` +
|
|
1683
|
+
`painted frame to stop changing with {"waitFor":{"canvas":true,"timeoutMs":8000}}; ` +
|
|
1684
|
+
`click by position on the canvas ` +
|
|
1685
|
+
`({"click":{"selector":"canvas","position":{"x":120,"y":40}}}, CSS pixels ` +
|
|
1686
|
+
`from its top-left, read off the frame captured just before the step); or ` +
|
|
1687
|
+
`drop the wait and capture — the capture already settles on pixels.`
|
|
1688
|
+
);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1620
1691
|
// Drive an ordered sequence of interactions against the settled frame, settling
|
|
1621
1692
|
// the page between each so a later step sees the DOM the earlier one produced.
|
|
1622
1693
|
// This is the persisted-scenario path (`scenario.interactions`): unlike the
|
|
@@ -1693,5 +1764,6 @@ module.exports = {
|
|
|
1693
1764
|
samePixels,
|
|
1694
1765
|
waitForPixelStable,
|
|
1695
1766
|
waitForPredicate,
|
|
1767
|
+
probeCanvasDominated,
|
|
1696
1768
|
performInteractionSequence,
|
|
1697
1769
|
};
|