All files / packages/design-system-2/scripts/vrt-core flow-runner.ts

3.37% Statements 12/356
0.61% Branches 1/162
2.17% Functions 1/46
3.76% Lines 12/319

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687                                    1x     1x       1x 1x 2x 2x 2x 4x     1x             1x   1x     1x                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  
import * as fs from 'fs';
import * as path from 'path';
import { chromium, Browser, BrowserContext, Page } from '@playwright/test';
import {
  Flow,
  Step,
  ScreenshotStep,
  RunnerConfig,
  ScreenshotResult,
  DiffConfig,
  DiffScreenshotResult,
  StyleDiff,
} from './flow';
import { diffScreenshots, snapshotStyles, compareStyles, isNoise } from './differ';
 
// GitHub masks secrets only in job logs, not in the PR comment or report, so every message is scrubbed here.
// Username and password are replaced with their secret names; the org URL stays visible since it's informational.
 
export const SECRETS_HINT = 'Actual values: the ORG_FARM_* GitHub repo secrets (or your local .env).';
 
export function redact(text: string): string {
  const pairs: [string | undefined, string][] = [
    [process.env.ORG_FARM_PASSWORD, '<ORG_FARM_PASSWORD>'],
    [process.env.ORG_FARM_USERNAME, '<ORG_FARM_USERNAME>'],
  ];
  let out = text;
  for (const [raw, name] of pairs) {
    const value = raw?.trim();
    Iif (!value) continue;
    for (const v of new Set([value, encodeURIComponent(value)])) {
      out = out.split(v).join(name);
    }
  }
  return out;
}
 
// The org can't be used: unreachable, or login failed. Stops the run; the PR comment says so.
export class OrgUnavailableError extends Error {}
 
// Fixed desktop viewport so headless CI and local runs capture the same page layout.
const VIEWPORT = { width: 1920, height: 1080 };
 
const secs = (ms: number): string => `${(ms / 1000).toFixed(1)}s`;
 
// Short label for a step in timing logs, e.g. "click .slds-button".
const describeStep = (step: Step): string => {
  const [key, value] = Object.entries(step)[0];
  const v = value as { selector?: string; until?: string };
  const detail = typeof value === 'object' && value ? (v.selector ?? v.until ?? '') : String(value);
  return `${key} ${detail}`.trim().slice(0, 80);
};
 
class ScreenshotManager {
  private baseDir: string;
 
  constructor(baseDir: string = 'screenshots') {
    this.baseDir = baseDir;
    this.ensureDirectories();
  }
 
  private ensureDirectories(): void {
    const currentDir = path.join(this.baseDir, 'current');
 
    if (fs.existsSync(currentDir)) {
      fs.rmSync(currentDir, { recursive: true });
    }
 
    const dirs = [this.baseDir, path.join(this.baseDir, 'baseline'), currentDir];
    dirs.forEach((dir) => {
      fs.mkdirSync(dir, { recursive: true });
    });
  }
 
  getCurrentPath(name: string): string {
    return path.join(this.baseDir, 'current', `${name}.png`);
  }
 
  getBaselinePath(name: string): string {
    return path.join(this.baseDir, 'baseline', `${name}.png`);
  }
 
  saveScreenshot(name: string, buffer: Buffer): ScreenshotResult {
    const filePath = this.getCurrentPath(name);
    fs.writeFileSync(filePath, buffer);
    return {
      name,
      path: filePath,
      timestamp: new Date(),
    };
  }
 
  promoteToBaseline(name: string): void {
    const currentPath = this.getCurrentPath(name);
    const baselinePath = this.getBaselinePath(name);
    if (fs.existsSync(currentPath)) {
      fs.copyFileSync(currentPath, baselinePath);
    }
  }
 
  hasBaseline(name: string): boolean {
    return fs.existsSync(this.getBaselinePath(name));
  }
}
 
export class FlowRunner {
  private config: RunnerConfig;
  private screenshotManager: ScreenshotManager;
  private browser: Browser | null = null;
  private page: Page | null = null;
 
  constructor(config: RunnerConfig) {
    this.config = config;
    this.screenshotManager = new ScreenshotManager(config.screenshotDir);
  }
 
  async init(): Promise<void> {
    this.browser = await chromium.launch({
      headless: this.config.headless ?? true,
      // A headed window that is covered or in the background stops painting, which hangs page.screenshot.
      args: [
        '--disable-backgrounding-occluded-windows',
        '--disable-renderer-backgrounding',
        '--disable-background-timer-throttling',
      ],
    });
  }
 
  async close(): Promise<void> {
    if (this.browser) {
      await this.browser.close();
      this.browser = null;
      this.page = null;
    }
  }
 
  async runFlow(flow: Flow): Promise<ScreenshotResult[]> {
    if (!this.browser) {
      throw new Error('Runner not initialized. Call init() first.');
    }
 
    console.log(`Running flow: ${flow.name}`);
    const screenshots: ScreenshotResult[] = [];
 
    // Fresh context per flow so logins always start logged-out (a shared session
    // would skip the login page on back-to-back flows against the same org).
    let context: BrowserContext | null = null;
    try {
      context = await this.browser.newContext({ viewport: VIEWPORT });
      // Pre-grant permissions Salesforce prompts for on load (e.g. notifications) — an
      // unanswered prompt blocks navigation/screenshots.
      await context.grantPermissions(['notifications', 'geolocation', 'clipboard-read', 'clipboard-write']);
      this.page = await context.newPage();
 
      for (const step of flow.steps) {
        const result = await this.executeStep(step);
        if (result) {
          result.flowName = flow.name;
          screenshots.push(result);
        }
      }
    } finally {
      this.page = null;
      if (context) await context.close();
    }
 
    return screenshots;
  }
 
  // Runs the flow once, injecting bundle A then bundle B at each screenshot point
  // on the same page load so only the CSS differs between shots (immune to page
  // drift). The flow's own `cssFile` steps are skipped since diffConfig drives
  // injection here instead.
  async runFlowDifferential(flow: Flow, diffConfig: DiffConfig): Promise<DiffScreenshotResult[]> {
    if (!this.browser) {
      throw new Error('Runner not initialized. Call init() first.');
    }
 
    const cssA = fs.readFileSync(diffConfig.cssAPath, 'utf8');
    const cssB = fs.readFileSync(diffConfig.cssBPath, 'utf8');
    const labelA = diffConfig.labelA ?? 'A';
    const labelB = diffConfig.labelB ?? 'B';
    const changedSelectors = diffConfig.changedSelectors ?? [];
 
    console.log(`Running flow (differential): ${flow.name}  [${labelA} vs ${labelB}]`);
    const results: DiffScreenshotResult[] = [];
    // Timing data: every step, plus per-page totals (steps leading up to a screenshot + the shot itself).
    const flowStart = Date.now();
    const stepTimes: { label: string; ms: number }[] = [];
    const pageTimes: { name: string; navMs: number; shotMs: number }[] = [];
    let navMs = 0;
 
    let context: BrowserContext | null = null;
    try {
      context = await this.browser.newContext({ viewport: VIEWPORT });
      // Pre-grant permissions Salesforce prompts for on load (e.g. notifications) — an
      // unanswered prompt blocks navigation/screenshots.
      await context.grantPermissions(['notifications', 'geolocation', 'clipboard-read', 'clipboard-write']);
      this.page = await context.newPage();
      const page = this.page;
 
      // Steps before the flow's second goto are the login.
      let gotos = 0;
      for (let i = 0; i < flow.steps.length; i++) {
        const step = flow.steps[i];
        if ('goto' in step) gotos++;
        try {
          // Differential mode supplies both bundles itself, so skip the flow's own.
          if ('cssFile' in step) {
            console.log('  → cssFile: skipped (differential mode injects A/B itself)');
            continue;
          }
 
          if ('screenshot' in step) {
            const name = step.screenshot;
            const fullPage = step.fullPage ?? false;
            console.log(`  → diff screenshot: ${name}${fullPage ? ' (fullPage)' : ''}`);
            const shotStart = Date.now();
            let mark = shotStart;
            const phases: string[] = [];
            // Style evidence is best-effort: on error the page falls back to pixels + declared components.
            let styleOk = true;
            const styleStep = async <T>(fn: () => Promise<T>): Promise<T | undefined> => {
              if (!styleOk) return undefined;
              try {
                return await fn();
              } catch (err) {
                styleOk = false;
                console.log(
                  `    ⚠ style snapshot skipped: ${(err instanceof Error ? err.message : String(err)).split('\n')[0]}`,
                );
                return undefined;
              }
            };
            const lap = (phase: string) => {
              const now = Date.now();
              phases.push(`${phase} ${secs(now - mark)}`);
              mark = now;
            };
 
            // Never capture mid-load. With `replay`, the page is reloaded per shot and the transient
            // state (e.g. a toast) is rebuilt only after the CSS is injected, so it can't expire before the capture.
            const prepare = async () => {
              if (step.replay) {
                await page.reload();
                await page.waitForTimeout(3000);
              }
              await this.waitForPageLoaded(page, name);
              await this.stripNativeSheet(page);
            };
            const replayState = async () => {
              if (step.replay) for (const inner of step.replay) await this.executeStep(inner);
            };
            await prepare();
            lap('load');
 
            // Remove only our injected tag after shot A so shot B starts from the
            // same clean DOM.
            const handleA = await this.injectCss(page, cssA);
            await this.waitForStable(page);
            lap('settleA');
            await replayState();
            const shotA = await page.screenshot({ fullPage, animations: 'disabled' });
            // Replayed pages rebuild the DOM per shot, so their elements can't be aligned A to B.
            if (!step.replay) await styleStep(() => snapshotStyles(page, 'A'));
            await handleA.evaluate((el) => (el as Element).remove());
            lap('shotA');
 
            if (step.replay) await prepare();
            const handleB = await this.injectCss(page, cssB);
            await this.waitForStable(page);
            lap('settleB');
            await replayState();
            const shotB = await page.screenshot({ fullPage, animations: 'disabled' });
            if (!step.replay) await styleStep(() => snapshotStyles(page, 'B'));
            await handleB.evaluate((el) => (el as Element).remove());
            lap('shotB');
 
            // The native sheet is gone and ours was just removed; re-apply baseline CSS so later
            // click/hover steps run on a styled page (layout intact) instead of a bare one.
            const handleKeep = await this.injectCss(page, cssA);
            await handleKeep.evaluate((el) => (el as Element).setAttribute('data-vrt-interact', ''));
            // Re-read A to measure runtime drift (same CSS, different time) so it can be subtracted.
            let styleDiff: StyleDiff | undefined;
            if (!step.replay && (await styleStep(() => snapshotStyles(page, 'C')))) {
              styleDiff = await styleStep(() => compareStyles(page, diffConfig.changedRules ?? {}));
            }
            lap('styles');
 
            const shotAPath = this.screenshotManager.saveScreenshot(`${name}.${labelA}`, shotA).path;
            const shotBPath = this.screenshotManager.saveScreenshot(`${name}.${labelB}`, shotB).path;
 
            // DOM is identical for A and B (same load), so one query here covers both.
            const matchedSelectors = await this.matchChangedSelectors(changedSelectors);
 
            const diff = await diffScreenshots(page, shotA, shotB);
 
            let diffPath: string | null = null;
            if (diff.diffPng) {
              diffPath = path.join(path.dirname(shotBPath), `${name}.diff.png`);
              fs.writeFileSync(diffPath, diff.diffPng);
            }
 
            if (diff.dimensionMismatch) {
              console.log(
                `    ⚠ dimension mismatch (page reflowed between shots): ` +
                  `${labelA}=${diff.dimensionMismatch.a.join('x')} ` +
                  `${labelB}=${diff.dimensionMismatch.b.join('x')} — diff skipped`,
              );
            } else {
              console.log(
                `    diff: ${diff.changedPixels}/${diff.totalPixels} px ` +
                  `(${(diff.ratio * 100).toFixed(3)}%) changed`,
              );
            }
 
            const { noise, reason } = isNoise(styleDiff, matchedSelectors.length, changedSelectors, diff);
            const why = {
              'no-css': 'this PR changes no SLDS CSS',
              unreachable: 'no changed rule matches this page',
              style: 'no SLDS component computed style differs',
            };
            if (noise) console.log(`    noise: ${why[reason!]}, ignoring pixel diff`);
            else if (diff.changedPixels > 0 && styleDiff?.comparable && styleDiff.total > 0) {
              console.log(
                `    style: ${styleDiff.total} element(s) changed [${styleDiff.components.join(', ')}]`,
              );
            }
 
            results.push({
              name,
              styleDiff,
              noise,
              noiseReason: reason,
              flowName: flow.name,
              shotAPath,
              shotBPath,
              diffPath,
              changedPixels: diff.changedPixels,
              totalPixels: diff.totalPixels,
              ratio: diff.ratio,
              dimensionMismatch: diff.dimensionMismatch,
              matchedSelectors,
              components: step.components,
            });
            lap('diff');
            const shotMs = Date.now() - shotStart;
            console.log(`    ⏱ ${secs(shotMs)} (${phases.join(', ')}); steps before it ${secs(navMs)}`);
            stepTimes.push({ label: `screenshot ${name}`, ms: shotMs });
            pageTimes.push({ name, navMs, shotMs });
            navMs = 0;
            continue;
          }
 
          const stepStart = Date.now();
          await this.executeStep(step);
          const ms = Date.now() - stepStart;
          if (ms >= 1000) console.log(`    ⏱ ${secs(ms)}`);
          stepTimes.push({ label: describeStep(step), ms });
          navMs += ms;
        } catch (e) {
          // Redact before truncating, so a cut-off secret can't slip through.
          const raw = `${describeStep(step)} failed: ${(e instanceof Error ? e.message : String(e)).split('\n')[0]}`;
          const clean = redact(raw);
          const error = clean.slice(0, 300) + (clean !== raw ? ` ${SECRETS_HINT}` : '');
          // Only a failed login is fatal (every page needs the session); any other failure just marks its pages.
          const onLoginPage = await page
            .locator('#username')
            .isVisible()
            .catch(() => false);
          if (gotos <= 1 || onLoginPage)
            throw new OrgUnavailableError(/login failed/i.test(error) ? error : `Login failed. ${error}`, {
              cause: e,
            });
          // Pages up to the next goto share this page state: the first is "not captured", the rest depend on it.
          let next = i + 1;
          while (next < flow.steps.length && !('goto' in flow.steps[next])) next++;
          const lost = flow.steps.slice(i, next).filter((s): s is ScreenshotStep => 'screenshot' in s);
          console.log(`    ✗ ${error}`);
          lost.forEach((s, k) => {
            const missed = k === 0 ? { error } : { dependsOn: lost[0].screenshot };
            console.log(
              `    ✗ ${s.screenshot}: ${k === 0 ? 'not captured' : `skipped (depends on ${lost[0].screenshot})`}`,
            );
            results.push({
              name: s.screenshot,
              flowName: flow.name,
              shotAPath: '',
              shotBPath: '',
              diffPath: null,
              changedPixels: 0,
              totalPixels: 0,
              ratio: 0,
              components: s.components,
              missed,
            });
          });
          navMs = 0;
          i = next - 1;
        }
      }
      this.logTimingSummary(Date.now() - flowStart, pageTimes, stepTimes);
    } finally {
      this.page = null;
      if (context) await context.close();
    }
 
    return results;
  }
 
  private logTimingSummary(
    totalMs: number,
    pageTimes: { name: string; navMs: number; shotMs: number }[],
    stepTimes: { label: string; ms: number }[],
  ): void {
    console.log(`\nTiming: flow took ${secs(totalMs)}`);
    console.log('  per page (steps before it + A/B capture):');
    for (const p of pageTimes) {
      console.log(
        `    ${secs(p.navMs + p.shotMs).padStart(7)}  ${p.name}  (steps ${secs(p.navMs)}, capture ${secs(p.shotMs)})`,
      );
    }
    console.log('  slowest steps:');
    for (const s of [...stepTimes].sort((a, b) => b.ms - a.ms).slice(0, 10)) {
      console.log(`    ${secs(s.ms).padStart(7)}  ${s.label}`);
    }
  }
 
  // Strips the org's native SLDS2 sheet so it can't mask the diff. Scoped to the exact filename: a bare
  // `href*="slds"` match also caught Aura's app.css (via an embedded "sldsTokens" name) and broke page chrome.
  private async stripNativeSheet(page: Page): Promise<void> {
    await page.evaluate(() => {
      // Leave an anchor where the native sheet sat so injected CSS keeps its place in the cascade.
      document.querySelectorAll('link[href*="/slds-plus.css"]').forEach((el) => {
        if (!document.querySelector('meta[data-vrt-anchor]')) {
          const anchor = document.createElement('meta');
          anchor.setAttribute('data-vrt-anchor', '');
          el.before(anchor);
        }
        el.remove();
      });
      // Drop the between-shots style from a previous screenshot so it can't bleed into shot A.
      document.querySelectorAll('style[data-vrt-interact]').forEach((el) => el.remove());
    });
  }
 
  // Blocks until no spinner is visible (searched through shadow DOM) for ~1s, so shots never show a loading state.
  private async waitForPageLoaded(page: Page, name: string, timeoutMs = 120_000): Promise<void> {
    const visibleSpinners = `(() => {
      const sel = '.slds-spinner, .slds-spinner_container';
      let n = 0;
      const found = [];
      const stack = [document];
      while (stack.length) {
        const root = stack.pop();
        for (const el of root.querySelectorAll('*')) {
          if (el.matches(sel)) {
            const box = el.getBoundingClientRect();
            const cs = getComputedStyle(el);
            if (box.width > 0 && box.height > 0 && cs.display !== 'none' && cs.visibility !== 'hidden' && cs.opacity !== '0') {
              n++;
              found.push(el.tagName + '.' + String(el.className).slice(0, 60) + ' @' + Math.round(box.top) + ',' + Math.round(box.left) + ' ' + Math.round(box.width) + 'x' + Math.round(box.height));
            }
          }
          if (el.shadowRoot) stack.push(el.shadowRoot);
        }
      }
      return { n, found };
    })()`;
    const deadline = Date.now() + timeoutMs;
    let quietChecks = 0;
    let lastFound: string[] = [];
    while (Date.now() < deadline) {
      const { n, found } = (await page.evaluate(visibleSpinners)) as { n: number; found: string[] };
      lastFound = found;
      quietChecks = n === 0 ? quietChecks + 1 : 0;
      if (quietChecks >= 3) return;
      await page.waitForTimeout(500);
    }
    throw new Error(
      `Page still showing a loading spinner after ${timeoutMs / 1000}s, before screenshot "${name}": ${lastFound.join(' | ')}`,
    );
  }
 
  // Adds a style tag at the native sheet's original spot (not the end of <head>) so component CSS that
  // overrode it in the live org still wins; falls back to appending if no anchor was left.
  private async injectCss(page: Page, content: string) {
    const handle = await page.addStyleTag({ content });
    await handle.evaluate((el) => document.querySelector('meta[data-vrt-anchor]')?.after(el as Element));
    return handle;
  }
 
  // Waits for the page to actually settle after a style swap, instead of a fixed
  // guess — network idle catches icon-sprite/font fetches, document.fonts.ready
  // catches font-swap reflow. Network idle is best-effort: some Lightning pages
  // poll in the background forever, so a timeout here just proceeds anyway.
  private async waitForStable(page: Page): Promise<void> {
    try {
      // Short cap: timing logs showed Lightning pages never go idle, so this always ran to the timeout.
      await page.waitForLoadState('networkidle', { timeout: 2000 });
    } catch {
      /* no-op: background polling never quiets down — proceed anyway */
    }
    await page.evaluate(() => document.fonts.ready);
  }
 
  private async executeStep(step: Step): Promise<ScreenshotResult | null> {
    if (!this.page) throw new Error('Page not initialized');
 
    if ('goto' in step) {
      // Strip any trailing slash on the base so `${base}/${path}` can't produce `//` — a
      // double slash after the host breaks Salesforce's Lightning router on deep links
      // (it silently stays on the current view), even though the shell "loads" fine.
      const base = this.config.baseUrl.replace(/\/+$/, '');
      const url = step.goto.startsWith('http') ? step.goto : `${base}${step.goto}`;
      console.log(`  → goto: ${redact(url)}`);
      try {
        await this.page.goto(url);
      } catch (e) {
        // Most common CI failure: the target org/env is down or expired. Rethrow with a
        // clear hint so the job log says why, instead of a bare Playwright net::ERR_.
        const detail = e instanceof Error ? e.message : String(e);
        throw new Error(
          `Target env unreachable at ${url} — the configured org may be down or expired; reconfigure a live env. (${detail})`,
          { cause: e },
        );
      }
      return null;
    }
 
    if ('click' in step) {
      console.log(`  → click: ${step.click.selector}`);
      await this.page.click(step.click.selector);
      return null;
    }
 
    if ('blur' in step) {
      console.log(`  → blur: ${step.blur.selector}`);
      await this.page.locator(step.blur.selector).first().blur();
      return null;
    }
 
    if ('retry' in step) {
      const { steps, until, timeout = 10000, attempts = 3 } = step.retry;
      for (let attempt = 1; attempt <= attempts; attempt++) {
        for (const inner of steps) await this.executeStep(inner);
        try {
          await this.page.waitForSelector(until, { timeout });
          await this.waitForPageLoaded(this.page, until, timeout);
          return null;
        } catch (e) {
          if (attempt === attempts) throw e;
          console.log(`  → retry: "${until}" not ready, reloading (attempt ${attempt}/${attempts})`);
          await this.page.reload();
          await this.page.waitForTimeout(6000);
        }
      }
      return null;
    }
 
    if ('type' in step) {
      console.log(`  → type into ${step.type.selector}`);
      await this.page.fill(step.type.selector, step.type.text);
      return null;
    }
 
    if ('wait' in step) {
      console.log(`  → wait: ${step.wait}ms`);
      await this.page.waitForTimeout(step.wait);
      return null;
    }
 
    if ('waitForSelector' in step) {
      const { selector, timeout } = step.waitForSelector;
      console.log(`  → waitForSelector: ${selector}`);
      try {
        await this.page.waitForSelector(selector, { timeout: timeout ?? 30000 });
      } catch (e) {
        // Bad or expired org creds leave us on the login page; say that, not a bare selector timeout.
        if ((await this.page.locator('#username').count()) > 0) {
          throw new Error(
            `Still on the Salesforce login page, so login failed (org expired or ORG_FARM_* credentials invalid). Timed out waiting for "${selector}".`,
            { cause: e },
          );
        }
        throw e;
      }
      return null;
    }
 
    if ('screenshot' in step) {
      const fullPage = step.fullPage ?? false;
      console.log(`  → screenshot: ${step.screenshot}${fullPage ? ' (fullPage)' : ''}`);
      await this.waitForPageLoaded(this.page, step.screenshot);
 
      const buffer = await this.page.screenshot({ fullPage });
      return this.screenshotManager.saveScreenshot(step.screenshot, buffer);
    }
 
    if ('style' in step) {
      console.log(`  → style: injecting CSS`);
      await this.page.addStyleTag({ content: step.style });
      return null;
    }
 
    if ('dismiss' in step) {
      const { selectors = [], pressEscape = true } = step.dismiss;
      console.log(`  → dismiss: best-effort overlay close`);
 
      // Escape closes most SLDS modals/popovers without needing a selector.
      if (pressEscape) {
        try {
          await this.page.keyboard.press('Escape');
        } catch {
          /* no-op: nothing focused / nothing to close */
        }
      }
 
      for (const selector of selectors) {
        try {
          const el = this.page.locator(selector).first();
          if (await el.isVisible({ timeout: 1000 })) {
            await el.click({ timeout: 2000 });
            console.log(`    dismissed via ${selector}`);
          }
        } catch {
          /* no-op: selector absent or not clickable — modal simply isn't there */
        }
      }
      return null;
    }
 
    if ('cssFile' in step) {
      const { path, aggressive } = step.cssFile;
      console.log(`  → cssFile: ${path}${aggressive ? ' (aggressive mode)' : ''}`);
 
      if (aggressive) {
        await this.page.evaluate(() => {
          document.querySelectorAll('link[href*="/slds-plus.css"]').forEach((el: Element) => el.remove());
        });
      }
 
      const cssContent = fs.readFileSync(path, 'utf8');
      await this.page.addStyleTag({ content: cssContent });
      return null;
    }
 
    return null;
  }
 
  // Returns which of the PR's changed selectors match on the current page.
  // Selectors querySelectorAll can't parse (e.g. ::pseudo, :hover) are skipped
  // rather than thrown — attribution is best-effort, never a hard gate.
  private async matchChangedSelectors(selectors: string[]): Promise<{ selector: string; count: number }[]> {
    if (!this.page || selectors.length === 0) return [];
 
    const matched = await this.page.evaluate((sels: string[]) => {
      const hits: { selector: string; count: number }[] = [];
      for (const sel of sels) {
        try {
          // Counts across the full composed tree, not just light DOM — SDS
          // components render inside native Shadow DOM, which querySelectorAll
          // can't see past. Iterative (no inner named function) since Playwright
          // serializes only this callback's own source into the page.
          let n = 0;
          const stack: ParentNode[] = [document];
          while (stack.length > 0) {
            const root = stack.pop() as ParentNode;
            for (const el of root.querySelectorAll('*')) {
              if (el.matches(sel)) n++;
              if (el.shadowRoot) stack.push(el.shadowRoot);
            }
          }
          if (n > 0) hits.push({ selector: sel, count: n });
        } catch {
          /* unparseable selector (pseudo/state) — skip, can't test statically */
        }
      }
      return hits;
    }, selectors);
 
    if (matched.length > 0) {
      console.log(`    attribution: ${matched.length} changed selector(s) present on this page`);
    }
    return matched;
  }
}