@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.
@@ -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
- await waitForPredicate(frame, step);
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,
@@ -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
- function noMatchGuidance({ targetDesc, matchedByText, candidateLabels }) {
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(frame, interaction, { warnings } = {}) {
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
- async function waitForPredicate(frame, predicate, { defaultTimeoutMs = 8000 } = {}) {
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
- if (typeof text === "string" && text.length > 0) {
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
  };