Source: runner/pageCapture.js

/**
 * pageCapture.js — Page-level artifact capture helpers
 *
 * Extracts DOM snapshot, screenshot, and bounding-box capture logic from
 * executeTest so each concern is independently testable and the main
 * execution function stays focused on orchestration.
 *
 * Exports:
 *   waitForStable(page, opts)        — S3-02: MutationObserver DOM stability wait
 *   captureDomSnapshot(page)
 *   captureScreenshot(page, runId, stepIndex, { failed })
 *   captureBoundingBoxes(page)
 */

import path from "path";
import fs from "fs";
import { SHOTS_DIR } from "./config.js";


/**
 * waitForStable(page, opts) → Promise<void>
 *
 * S3-02 — DOM stability wait using MutationObserver.
 *
 * Modern SPAs (React, Vue, Angular, Next.js) and apps with streaming AI
 * responses, skeleton screens, or async data fetches settle at variable
 * times. Using a fixed `waitForTimeout` causes tests to assert on
 * partially-rendered pages, producing false failures.
 *
 * This helper installs a MutationObserver on `document.body` that counts
 * every DOM mutation. It polls until `stableSec` consecutive seconds pass
 * with no new mutations (or `timeoutSec` is reached), then disconnects
 * cleanly. The observer and mutation counter are stored on `window` so
 * they survive across evaluate() calls and can be cleaned up reliably.
 *
 * Based on the Assrt `agent.ts` pattern referenced in NEXT_STEPS S3-02.
 *
 * @param {Object} page  - Playwright Page instance
 * @param {object}  [opts]
 * @param {number}  [opts.timeoutSec=30]  - Maximum wait in seconds
 * @param {number}  [opts.stableSec=2]    - Quiet period required to declare stable
 * @returns {Promise<void>}
 */
export async function waitForStable(page, { timeoutSec = 30, stableSec = 2 } = {}) {
  // Install the MutationObserver in the page context. Stored on window so
  // subsequent evaluate() calls can read the counter and clean up.
  await page.evaluate(() => {
    // Guard: if a previous waitForStable call was interrupted, disconnect it
    // first so we don't accumulate multiple observers.
    if (window.__sentri_observer) {
      try { window.__sentri_observer.disconnect(); } catch {}
    }
    window.__sentri_mutations = 0;
    window.__sentri_observer = new MutationObserver(mutations => {
      window.__sentri_mutations += mutations.length;
    });
    window.__sentri_observer.observe(document.body, {
      childList:     true,
      subtree:       true,
      characterData: true,
      attributes:    true,
    });
  }).catch(() => {
    // If evaluate fails (page navigating, closed) — swallow and continue.
    // The caller's own timeout / test runner will handle truly broken pages.
  });

  const start = Date.now();
  let lastCount = -1;
  let stableSince = Date.now();

  while (Date.now() - start < timeoutSec * 1000) {
    await new Promise(r => setTimeout(r, 500));

    let count = lastCount;
    try {
      count = await page.evaluate(() => window.__sentri_mutations ?? -1);
    } catch {
      // Page closed or navigated mid-poll — treat as stable and exit
      break;
    }

    if (count !== lastCount) {
      // DOM is still mutating — reset the stability clock
      lastCount = count;
      stableSince = Date.now();
    } else if (Date.now() - stableSince >= stableSec * 1000) {
      // No mutations for stableSec seconds — DOM has settled
      break;
    }
  }

  // Always disconnect and clean up, even on timeout
  await page.evaluate(() => {
    try { window.__sentri_observer?.disconnect(); } catch {}
    delete window.__sentri_observer;
    delete window.__sentri_mutations;
  }).catch(() => {});
}

/**
 *
 * Serialises a shallow representation of the current DOM (max depth 4)
 * for debugging and AI context.  Returns null on any failure.
 */
export async function captureDomSnapshot(page) {
  return page.evaluate(() => {
    function serialize(node, depth = 0) {
      if (depth > 4 || !node) return null;
      if (node.nodeType === Node.TEXT_NODE) {
        const t = node.textContent?.trim();
        return t ? { type: "text", text: t.slice(0, 80) } : null;
      }
      if (node.nodeType !== Node.ELEMENT_NODE) return null;
      const el = node;
      const tag = el.tagName.toLowerCase();
      if (["script","style","noscript","svg","path"].includes(tag)) return null;
      const attrs = {};
      for (const a of el.attributes) {
        if (["id","class","href","src","type","role","aria-label","name"].includes(a.name))
          attrs[a.name] = a.value.slice(0, 60);
      }
      const children = [];
      for (const child of el.childNodes) {
        const c = serialize(child, depth + 1);
        if (c) children.push(c);
        if (children.length >= 6) break;
      }
      return { type: "element", tag, attrs, children };
    }
    return serialize(document.body);
  }).catch(() => null);
}

/**
 * captureScreenshot(page, runId, stepIndex, opts) → { base64, artifactPath }
 *
 * Takes a PNG screenshot, writes it to disk, and returns both the base64
 * string (for SSE) and the artifact path (for the DB).
 *
 * @param {Object}  page
 * @param {string}  runId
 * @param {number}  stepIndex    — test index within the run
 * @param {Object}  [opts]
 * @param {boolean} [opts.failed]     — appends "-fail" to the filename
 * @param {number}  [opts.stepNumber] — per-step capture (DIF-016): appends "-s{N}" to the filename
 */
export async function captureScreenshot(page, runId, stepIndex, { failed = false, stepNumber } = {}) {
  const suffix = failed ? "-fail" : stepNumber != null ? `-s${stepNumber}` : "";
  const shotName = `${runId}-step${stepIndex}${suffix}.png`;
  const shotPath = path.join(SHOTS_DIR, shotName);
  const buf = await page.screenshot({ type: "png", fullPage: false });
  fs.writeFileSync(shotPath, buf);
  return {
    base64: buf.toString("base64"),
    artifactPath: `/artifacts/screenshots/${shotName}`,
  };
}

/**
 * captureBoundingBoxes(page) → Array<{ x, y, width, height }>
 *
 * Collects bounding boxes of the last interacted / focused elements so
 * the frontend OverlayCanvas can draw highlights.
 */
export async function captureBoundingBoxes(page) {
  try {
    return await page.evaluate(() => {
      const boxes = [];
      // Prefer the currently-focused element
      const focused = document.activeElement;
      if (focused && focused !== document.body && focused !== document.documentElement) {
        const r = focused.getBoundingClientRect();
        if (r.width > 0 && r.height > 0) {
          boxes.push({ x: r.x, y: r.y, width: r.width, height: r.height });
        }
      }
      // Also collect any elements with aria-selected / data-testid that are visible
      if (boxes.length === 0) {
        const candidates = document.querySelectorAll(
          "button:focus, input:focus, [aria-selected='true'], [data-focused='true']"
        );
        for (const el of candidates) {
          const r = el.getBoundingClientRect();
          if (r.width > 0 && r.height > 0) {
            boxes.push({ x: r.x, y: r.y, width: r.width, height: r.height });
            if (boxes.length >= 3) break;
          }
        }
      }
      return boxes;
    }).catch(() => []);
  } catch {
    return [];
  }
}