diff --git a/MARKETING_PLAN.md b/MARKETING_PLAN.md new file mode 100644 index 0000000..eefa610 --- /dev/null +++ b/MARKETING_PLAN.md @@ -0,0 +1,235 @@ +# BehaviorDiff Marketing Plan for openClaw + +## Product Positioning + +**One-liner:** BehaviorDiff is a free, self-hosted GitHub Action that shows you what your PR *does* — not just what it *changed*. + +**Category:** Developer tools / Visual regression testing / Code review automation + +**Key differentiator:** The only open-source tool that combines video behavior diffing, DOM timeline comparison, visual regression testing, performance metrics, accessibility auditing, and network monitoring in a single GitHub Action — with zero SaaS dependencies. + +--- + +## Target Market + +### Primary: Engineering teams using AI code generation (TAM: ~2M+ teams) + +**Who they are:** +- Teams of 3-50 developers using GitHub for code review +- Heavy users of AI coding tools (Copilot, Cursor, Claude Code, Devin, etc.) +- Building web applications (React, Next.js, Vue, Angular, Svelte) +- Spending increasing time reviewing AI-generated PRs they don't fully understand + +**Why they need BehaviorDiff:** +- AI generates large, complex PRs that are hard to review from code diffs alone +- Reviewers can't mentally render CSS/layout changes from a text diff +- They need to know "did this PR break anything visible?" — not just "did the code change?" +- They want to catch visual regressions, a11y issues, and console errors before merge + +**Where they spend time:** +- GitHub, Twitter/X, Hacker News, Reddit (r/webdev, r/reactjs, r/programming) +- Dev.to, Hashnode, YouTube (web dev channels) +- Discord (Vercel, Next.js, React, Svelte communities) +- LinkedIn (engineering managers, tech leads) + +### Secondary: Open-source maintainers overwhelmed by contributions + +**Who they are:** +- Maintainers of popular UI libraries and web frameworks +- Receiving many external contributions, can't manually test each one +- Need automated "did this break the UI?" checks + +### Tertiary: QA teams looking to replace expensive SaaS tools + +**Who they are:** +- Teams paying $300-2000/month for Percy, Chromatic, or Applitools +- Want the same capabilities without the vendor lock-in or cost +- Privacy-conscious teams that can't send screenshots to third-party servers + +--- + +## Marketing Channels & Strategy + +### 1. GitHub / Open Source Ecosystem (Primary channel) + +**Strategy:** Make BehaviorDiff the obvious choice when people search for visual regression testing on GitHub. + +**Actions:** +- Publish to GitHub Marketplace as a free Action +- Add to `awesome-github-actions`, `awesome-regression-testing`, and `awesome-playwright` lists +- Create starter templates for popular frameworks (Next.js, Vite, Create React App) +- Add clear "Add to your repo in 2 minutes" onboarding in README +- Engage with issues/PRs in competitor repos (BackstopJS, Lost Pixel) to offer alternatives + +**Content guidelines:** +- README should lead with a screenshot of the PR comment showing a real diff +- Include a GIF showing the video behavior diff feature (this is the differentiator) +- Keep setup instructions to under 10 lines of YAML +- Show a real before/after example — not just config documentation + +### 2. Twitter/X (Developer audience) + +**Strategy:** Build awareness through visual, shareable content that demonstrates the "aha moment." + +**Content types:** +- Short video demos (30-60 seconds) showing: "This PR looks fine in the code diff. But watch what BehaviorDiff caught..." followed by the visual/video diff +- Side-by-side comparison threads: "Percy charges $399/mo for this. BehaviorDiff does it for free." +- "AI generated this PR" threads showing how BehaviorDiff catches what code review misses +- Engagement with AI coding tool tweets (Copilot, Cursor, Claude announcements) + +**Content guidelines:** +- Always include a visual (screenshot, GIF, or short video) +- Use concrete numbers: "Caught 3 visual regressions, 2 a11y violations, 1 new console error — in 45 seconds" +- Tag relevant accounts (@GitHub, @playwright, popular dev influencers) +- Post 3-4 times per week, mix of demos, tips, and engagement +- Tone: technical but accessible, no hype, let the product speak + +**Sample posts:** +1. "Your AI just generated a 500-line PR. Can you spot the CSS regression in the diff? Neither could I. BehaviorDiff can. [screenshot of PR comment]" +2. "We added video diffing to BehaviorDiff. Now you can see exactly how your PR changes user interactions — frame by frame. [GIF demo]" +3. "Stop paying $400/mo for screenshot comparisons. BehaviorDiff runs on your own GitHub Actions runners. Free. Open source. Zero SaaS. [comparison table]" + +### 3. Hacker News / Reddit (Launch & thought leadership) + +**Strategy:** Launch with a "Show HN" post and follow up with educational content. + +**Launch post:** +- Title: "Show HN: BehaviorDiff – See what your PR does, not just what it changed (free GitHub Action)" +- Focus on the novel angle: behavioral diffing vs. visual diffing +- Emphasize: open source, self-hosted, no vendor lock-in, AI-era code review +- Include a direct link to a sample PR comment showing real output + +**Reddit strategy:** +- r/webdev: "How we catch visual regressions in AI-generated PRs without paying for Percy" +- r/reactjs: "Free GitHub Action for visual + video + a11y diffing on every PR" +- r/programming: Technical deep-dive on DOM diffing algorithms +- r/selfhosted: "Self-hosted alternative to Percy/Chromatic for visual regression testing" + +**Content guidelines:** +- Be genuinely helpful, not promotional +- Share the technical approach and architecture decisions +- Respond to every comment promptly +- Link to the repo, not a landing page + +### 4. Dev.to / Hashnode / Blog (SEO & education) + +**Strategy:** Own the search terms "visual regression testing GitHub Actions", "video diffing PR", "behavioral diffing". + +**Article series:** +1. "Why Code Diffs Fail at Catching Visual Bugs (and What to Do Instead)" — Problem awareness +2. "Building a Video Diffing Pipeline with Playwright" — Technical deep-dive +3. "How to Add Visual Regression Testing to Any React App in 5 Minutes" — Tutorial +4. "BehaviorDiff vs Percy vs Chromatic: The Open-Source Alternative" — Comparison +5. "DOM Timeline Diffing: Catching Interaction Bugs That Screenshots Miss" — Unique feature spotlight + +**Content guidelines:** +- Each article should be self-contained and useful even without using BehaviorDiff +- Include code examples, screenshots, and real-world scenarios +- Optimize for search: "visual regression testing github actions", "playwright visual diffing", "free alternative to percy" +- Cross-post to Dev.to, Hashnode, and Medium for maximum reach +- 1 article every 2 weeks + +### 5. YouTube / Video Content (Demos & tutorials) + +**Strategy:** Short, focused demo videos that show the product in action. + +**Video types:** +- "BehaviorDiff in 60 Seconds" — Quick demo reel +- "Setup Guide: Add Visual Regression Testing to Your Next.js App" — Tutorial (5 min) +- "What Percy Won't Show You: Video Behavior Diffing" — Feature spotlight (3 min) +- "Reviewing AI-Generated PRs with BehaviorDiff" — Use case demo (5 min) + +**Content guidelines:** +- Keep videos under 5 minutes unless it's a full tutorial +- Show real GitHub PR workflows, not synthetic demos +- Use screen recordings with minimal editing +- Include timestamps in description +- Add to YouTube + embed in blog posts + +### 6. Discord / Community (Grassroots adoption) + +**Strategy:** Be helpful in communities where the target audience already hangs out. + +**Communities:** +- Vercel Discord (Next.js users) +- Svelte Discord +- Playwright Discord +- Reactiflux (React Discord) +- GitHub Community forums + +**Approach:** +- Answer questions about visual regression testing +- Share BehaviorDiff when it genuinely solves someone's stated problem +- Don't spam — build credibility first through helpful contributions + +--- + +## Competitive Positioning + +| Feature | BehaviorDiff | Percy | Chromatic | BackstopJS | +|---------|-------------|-------|-----------|------------| +| Price | Free | $399+/mo | $149+/mo | Free | +| Self-hosted | Yes | No | No | Yes | +| Video behavior diffing | Yes | No | No | No | +| DOM timeline diffing | Yes | No | No | No | +| Performance metrics | Yes | No | No | No | +| Accessibility auditing | Yes | No | No | No | +| Console/network monitoring | Yes | No | No | No | +| Screenshot comparison | Yes | Yes | Yes | Yes | +| AI noise reduction | Yes (heuristic) | Yes (ML) | Yes (ML) | No | +| Cross-browser | Chromium | All | Chromium | Chromium | + +**Key messages:** +1. "Behavioral diffing, not just visual diffing" — We test what the app *does*, not just how it *looks* +2. "Built for the AI era" — When AI writes the code, you need behavioral verification +3. "Free forever, self-hosted" — No SaaS, no vendor lock-in, your data stays on your runners + +--- + +## Content Calendar (First 8 Weeks) + +| Week | Channel | Content | +|------|---------|---------| +| 1 | GitHub | Publish to Marketplace, update README with demo GIF | +| 1 | HN | Show HN launch post | +| 1 | Twitter/X | Launch thread with demo video | +| 2 | Reddit | Posts in r/webdev and r/reactjs | +| 2 | Blog | "Why Code Diffs Fail at Catching Visual Bugs" | +| 3 | Twitter/X | Video behavior diffing spotlight (GIF) | +| 3 | YouTube | "BehaviorDiff in 60 Seconds" demo | +| 4 | Blog | "How to Add Visual Regression Testing in 5 Minutes" | +| 4 | Discord | Begin community engagement in Vercel/Playwright | +| 5 | Twitter/X | Percy comparison thread | +| 5 | Blog | "Building a Video Diffing Pipeline with Playwright" | +| 6 | Reddit | Technical deep-dive in r/programming | +| 6 | YouTube | Full setup tutorial video | +| 7 | Blog | "BehaviorDiff vs Percy vs Chromatic" comparison | +| 7 | Twitter/X | AI-generated PR review demo thread | +| 8 | Blog | "DOM Timeline Diffing" feature spotlight | +| 8 | HN | Follow-up post on adoption / lessons learned | + +--- + +## Metrics to Track + +- GitHub stars (target: 500 in first 8 weeks) +- GitHub Marketplace installs +- Weekly active repos using the Action (via GitHub API) +- Blog post views and social shares +- Twitter/X impressions and engagement rate +- Hacker News upvotes and comment engagement +- Inbound GitHub issues (indicator of real usage) + +--- + +## Key Messaging for openClaw Automation + +When generating content, always anchor on these themes: + +1. **The problem:** "AI writes code faster than humans can review it. You need to verify behavior, not just read diffs." +2. **The solution:** "BehaviorDiff captures what your app does — screenshots, videos, DOM, performance, a11y, network — and shows you exactly what changed." +3. **The differentiator:** "The only tool that diffs video behavior between branches. See how interactions change, frame by frame." +4. **The economic angle:** "Free and self-hosted. No $400/month SaaS bills. No screenshots leaving your infrastructure." +5. **The AI angle:** "Built for teams where AI generates more code than humans. When you can't read every line, test the behavior." + +Tone: Confident but not aggressive. Technical but accessible. Show, don't tell. Always include a visual. diff --git a/action.yml b/action.yml index 7367a08..ec14fde 100644 --- a/action.yml +++ b/action.yml @@ -12,6 +12,22 @@ inputs: required: false default: ${{ github.token }} +outputs: + report-path: + description: 'Path to the generated markdown report' + diff-json-path: + description: 'Path to the full diff result JSON' + has-regressions: + description: 'Whether performance regressions were detected' + has-visual-changes: + description: 'Whether visual changes were detected' + has-video-changes: + description: 'Whether video behavior differences were detected' + risk-level: + description: 'Overall risk level (low, medium, high)' + should-block: + description: 'Whether the PR should be blocked from merging' + runs: using: 'node20' main: 'dist/index.js' diff --git a/src/config.ts b/src/config.ts index ee0d913..041c80f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -58,14 +58,16 @@ export function parseConfig(configPath: string): BehaviorDiffConfig { bundleSize: parsed.thresholds?.bundleSize, }, interactions: parsed.interactions, + disableAnimations: parsed.disableAnimations ?? true, }; } function parsePages(raw: unknown): BehaviorDiffConfig['pages'] | undefined { if (!Array.isArray(raw)) return undefined; - return raw.map((p: Record) => ({ + return raw.map((p: Record) => ({ url: p.url, name: p.name ?? p.url, + mask: Array.isArray(p.mask) ? p.mask : undefined, })); } diff --git a/src/diff/dom-timeline.ts b/src/diff/dom-timeline.ts new file mode 100644 index 0000000..637ceac --- /dev/null +++ b/src/diff/dom-timeline.ts @@ -0,0 +1,57 @@ +import type { DomSnapshot, DomTimelineDiff, DomTimelineChange } from '../types'; +import { diffDom } from './dom'; + +/** + * Compare DOM snapshot timelines between base and PR branches. + * Matches snapshots by label, then diffs each pair to show + * how behavior diverges at each interaction checkpoint. + */ +export function diffDomTimelines( + baseTimeline: DomSnapshot[], + prTimeline: DomSnapshot[], + pageName: string, + viewportName: string +): DomTimelineDiff { + const snapshotDiffs: DomTimelineChange[] = []; + + // Match snapshots by label + for (const baseSnap of baseTimeline) { + const prSnap = prTimeline.find((s) => s.label === baseSnap.label); + if (!prSnap) continue; + + const changes = diffDom(baseSnap.dom, prSnap.dom); + if (changes.length > 0) { + snapshotDiffs.push({ + fromLabel: baseSnap.label, + toLabel: prSnap.label, + changes, + }); + } + } + + // Check for snapshots only in PR (new interaction states) + for (const prSnap of prTimeline) { + const exists = baseTimeline.some((s) => s.label === prSnap.label); + if (!exists) { + snapshotDiffs.push({ + fromLabel: '(not present)', + toLabel: prSnap.label, + changes: [ + { + type: 'added', + path: '/', + details: `New interaction state "${prSnap.label}" introduced in PR`, + }, + ], + }); + } + } + + return { + pageName, + viewport: viewportName, + baseSnapshots: baseTimeline.length, + prSnapshots: prTimeline.length, + snapshotDiffs, + }; +} diff --git a/src/diff/index.ts b/src/diff/index.ts index 7bfc5da..92ab9e3 100644 --- a/src/diff/index.ts +++ b/src/diff/index.ts @@ -4,6 +4,8 @@ import type { BehaviorDiffConfig, DiffResult, PageDiffResult, + VideoDiff, + DomTimelineDiff, } from '../types'; import { diffMetrics } from './metrics'; import { diffScreenshots } from './visual'; @@ -11,16 +13,18 @@ import { diffDom } from './dom'; import { diffNetwork } from './network'; import { diffAccessibility } from './a11y'; import { diffConsole } from './console'; +import { diffVideos } from './video'; +import { diffDomTimelines } from './dom-timeline'; /** * Run all diffs between base and PR captures. */ -export function diffCaptures( +export async function diffCaptures( base: BranchCapture, pr: BranchCapture, config: BehaviorDiffConfig, outputDir: string -): DiffResult { +): Promise { const pages: PageDiffResult[] = []; // Match pages by name + viewport @@ -46,6 +50,29 @@ export function diffCaptures( dom ); + // Video diffing — only when both branches have videos + let video: VideoDiff | undefined; + if (basePage.videoPath && prPage.videoPath) { + video = await diffVideos( + basePage.videoPath, + prPage.videoPath, + outputDir, + basePage.pageName, + basePage.viewport.name + ); + } + + // DOM timeline diffing — only when both branches have timeline data + let domTimeline: DomTimelineDiff | undefined; + if (basePage.domTimeline.length > 0 && prPage.domTimeline.length > 0) { + domTimeline = diffDomTimelines( + basePage.domTimeline, + prPage.domTimeline, + basePage.pageName, + basePage.viewport.name + ); + } + pages.push({ pageName: basePage.pageName, viewport: basePage.viewport.name, @@ -55,6 +82,8 @@ export function diffCaptures( dom, accessibility, visual, + video, + domTimeline, }); } @@ -76,6 +105,7 @@ export function diffCaptures( ), hasVisualChanges: pages.some((p) => p.visual.diffPixelCount > 0), hasDomChanges: pages.some((p) => p.dom.length > 0), + hasVideoChanges: pages.some((p) => p.video && p.video.maxDiffPercentage > 0.1), }; return { pages, summary }; diff --git a/src/diff/video.ts b/src/diff/video.ts new file mode 100644 index 0000000..8f9699f --- /dev/null +++ b/src/diff/video.ts @@ -0,0 +1,209 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import pixelmatch from 'pixelmatch'; +import { PNG } from 'pngjs'; +import { chromium } from 'playwright'; +import type { VideoDiff, VideoFrameDiff } from '../types'; + +/** + * Extract frames from a video file using Playwright's screenshot capability + * on a page playing the video. Returns paths to extracted PNG frames. + */ +async function extractFrames( + videoPath: string, + outputDir: string, + intervalMs: number = 500 +): Promise<{ framePath: string; timestampMs: number }[]> { + fs.mkdirSync(outputDir, { recursive: true }); + + const browser = await chromium.launch({ args: ['--no-sandbox'] }); + const context = await browser.newContext(); + const page = await context.newPage(); + + const absolutePath = path.resolve(videoPath); + const frames: { framePath: string; timestampMs: number }[] = []; + + try { + // Create a minimal HTML page that plays the video + const html = ` + + +`; + + const htmlPath = path.join(outputDir, '_player.html'); + fs.writeFileSync(htmlPath, html); + await page.goto(`file://${htmlPath}`); + + // Wait for video metadata to load + const duration = await page.evaluate(`new Promise((resolve, reject) => { + const v = document.getElementById('v'); + if (v.readyState >= 1) return resolve(v.duration * 1000); + v.addEventListener('loadedmetadata', () => resolve(v.duration * 1000)); + v.addEventListener('error', () => reject(new Error('Video load failed'))); + })`); + + const durationMs = duration as number; + if (!durationMs || durationMs <= 0) return frames; + + // Resize viewport to match video dimensions + const dims = await page.evaluate(`(() => { + const v = document.getElementById('v'); + return { width: v.videoWidth, height: v.videoHeight }; + })()`); + const { width, height } = dims as { width: number; height: number }; + if (width > 0 && height > 0) { + await page.setViewportSize({ width, height }); + } + + // Seek to each interval and capture a frame + const totalFrames = Math.floor(durationMs / intervalMs) + 1; + for (let i = 0; i < totalFrames; i++) { + const seekMs = Math.min(i * intervalMs, durationMs); + + await page.evaluate(`new Promise((resolve) => { + const v = document.getElementById('v'); + v.currentTime = ${seekMs / 1000}; + v.addEventListener('seeked', () => resolve(), { once: true }); + })`); + + const framePath = path.join(outputDir, `frame-${String(i).padStart(4, '0')}.png`); + await page.screenshot({ path: framePath }); + frames.push({ framePath, timestampMs: seekMs }); + } + + // Clean up temp file + fs.unlinkSync(htmlPath); + } finally { + await browser.close(); + } + + return frames; +} + +/** + * Compare two sets of extracted frames and produce per-frame diff data. + */ +function compareFrames( + baseFrames: { framePath: string; timestampMs: number }[], + prFrames: { framePath: string; timestampMs: number }[], + diffOutputDir: string, + pageName: string, + viewportName: string +): VideoFrameDiff[] { + fs.mkdirSync(diffOutputDir, { recursive: true }); + const frameDiffs: VideoFrameDiff[] = []; + + // Compare frames by index (matching timestamps) + const count = Math.min(baseFrames.length, prFrames.length); + for (let i = 0; i < count; i++) { + const baseImg = PNG.sync.read(fs.readFileSync(baseFrames[i].framePath)); + const prImg = PNG.sync.read(fs.readFileSync(prFrames[i].framePath)); + + // Handle size mismatch by using the smaller dimensions + const width = Math.min(baseImg.width, prImg.width); + const height = Math.min(baseImg.height, prImg.height); + + if (width === 0 || height === 0) continue; + + // Resize images to matching dimensions if needed + const baseData = cropImageData(baseImg, width, height); + const prData = cropImageData(prImg, width, height); + + const diffPng = new PNG({ width, height }); + const mismatch = pixelmatch(baseData, prData, diffPng.data, width, height, { + threshold: 0.1, + }); + + const totalPixels = width * height; + const diffPercentage = (mismatch / totalPixels) * 100; + + const diffPath = path.join( + diffOutputDir, + `video-diff-${pageName}-${viewportName}-${String(i).padStart(4, '0')}.png` + ); + fs.writeFileSync(diffPath, PNG.sync.write(diffPng)); + + frameDiffs.push({ + frameIndex: i, + timestampMs: baseFrames[i].timestampMs, + diffPercentage, + diffPixelCount: mismatch, + diffImagePath: diffPath, + }); + } + + return frameDiffs; +} + +/** + * Crop image data to a target size (top-left aligned). + */ +function cropImageData(img: PNG, targetWidth: number, targetHeight: number): Buffer { + if (img.width === targetWidth && img.height === targetHeight) { + return img.data as unknown as Buffer; + } + + const out = Buffer.alloc(targetWidth * targetHeight * 4); + for (let y = 0; y < targetHeight; y++) { + for (let x = 0; x < targetWidth; x++) { + const srcIdx = (y * img.width + x) * 4; + const dstIdx = (y * targetWidth + x) * 4; + out[dstIdx] = img.data[srcIdx]; + out[dstIdx + 1] = img.data[srcIdx + 1]; + out[dstIdx + 2] = img.data[srcIdx + 2]; + out[dstIdx + 3] = img.data[srcIdx + 3]; + } + } + return out; +} + +/** + * Diff two videos by extracting frames and comparing them. + * Returns a VideoDiff with per-frame diffs and a divergence timeline. + */ +export async function diffVideos( + baseVideoPath: string, + prVideoPath: string, + outputDir: string, + pageName: string, + viewportName: string, + frameIntervalMs: number = 500 +): Promise { + const baseFramesDir = path.join(outputDir, 'video-frames', 'base', pageName, viewportName); + const prFramesDir = path.join(outputDir, 'video-frames', 'pr', pageName, viewportName); + const diffDir = path.join(outputDir, 'video-diffs'); + + // Extract frames from both videos in sequence (need separate browser instances) + const baseFrames = await extractFrames(baseVideoPath, baseFramesDir, frameIntervalMs); + const prFrames = await extractFrames(prVideoPath, prFramesDir, frameIntervalMs); + + // Compare frame by frame + const frameDiffs = compareFrames(baseFrames, prFrames, diffDir, pageName, viewportName); + + // Build divergence timeline (only frames with non-trivial differences) + const divergenceTimeline = frameDiffs + .filter((fd) => fd.diffPercentage > 0.1) + .map((fd) => ({ + timestampMs: fd.timestampMs, + diffPercentage: fd.diffPercentage, + })); + + const maxDiff = frameDiffs.length > 0 + ? Math.max(...frameDiffs.map((fd) => fd.diffPercentage)) + : 0; + const avgDiff = frameDiffs.length > 0 + ? frameDiffs.reduce((sum, fd) => sum + fd.diffPercentage, 0) / frameDiffs.length + : 0; + + return { + pageName, + viewport: viewportName, + baseVideoPath, + prVideoPath, + totalFramesCompared: frameDiffs.length, + frameDiffs, + maxDiffPercentage: maxDiff, + avgDiffPercentage: avgDiff, + divergenceTimeline, + }; +} diff --git a/src/index.ts b/src/index.ts index 124d144..1899a35 100644 --- a/src/index.ts +++ b/src/index.ts @@ -89,7 +89,7 @@ async function run(): Promise { // --- Diff --- core.startGroup('Computing diffs'); - const diffResult = diffCaptures(baseCapture, prCapture, config, outputDir); + const diffResult = await diffCaptures(baseCapture, prCapture, config, outputDir); core.endGroup(); // Save full diff result as JSON @@ -127,6 +127,7 @@ async function run(): Promise { core.setOutput('diff-json-path', diffJsonPath); core.setOutput('has-regressions', diffResult.summary.totalRegressions > 0); core.setOutput('has-visual-changes', diffResult.summary.hasVisualChanges); + core.setOutput('has-video-changes', diffResult.summary.hasVideoChanges); core.setOutput('risk-level', intelligentDiff.riskAssessment.overallRisk); core.setOutput('should-block', intelligentDiff.riskAssessment.shouldBlock); diff --git a/src/intelligence/causality.ts b/src/intelligence/causality.ts index d4793c5..aa6e62d 100644 --- a/src/intelligence/causality.ts +++ b/src/intelligence/causality.ts @@ -15,6 +15,9 @@ export function detectCausality(signals: Signal[], diff: DiffResult): CausalityL // Rule 4: A11y violations → DOM changes links.push(...detectA11yDomCausality(signals)); + // Rule 5: DOM timeline changes → Video differences + links.push(...detectDomTimelineVideoCausality(signals)); + return links; } @@ -87,3 +90,30 @@ function detectA11yDomCausality(signals: Signal[]): CausalityLink[] { return links; } +function detectDomTimelineVideoCausality(signals: Signal[]): CausalityLink[] { + const links: CausalityLink[] = []; + + const signalsByPage = new Map(); + for (const signal of signals) { + const key = `${signal.pageName}/${signal.viewport}`; + if (!signalsByPage.has(key)) signalsByPage.set(key, []); + signalsByPage.get(key)!.push(signal); + } + + for (const [, pageSignals] of signalsByPage) { + const domTimelineSignal = pageSignals.find((s) => s.type === 'dom-timeline'); + const videoSignal = pageSignals.find((s) => s.type === 'video'); + + if (domTimelineSignal && videoSignal) { + links.push({ + cause: domTimelineSignal, + effect: videoSignal, + confidence: 'high', + explanation: 'DOM structure changes during interaction caused visible video differences', + }); + } + } + + return links; +} + diff --git a/src/intelligence/noise-reduction.ts b/src/intelligence/noise-reduction.ts index c3d0b9d..a4e285f 100644 --- a/src/intelligence/noise-reduction.ts +++ b/src/intelligence/noise-reduction.ts @@ -53,6 +53,7 @@ export function applyNoiseReduction( ), hasVisualChanges: cleanedPages.some((p) => p.visual.diffPercentage > config.visualDiffThreshold), hasDomChanges: cleanedPages.some((p) => p.dom.length > 0), + hasVideoChanges: cleanedPages.some((p) => p.video && p.video.maxDiffPercentage > config.visualDiffThreshold), }; return { pages: cleanedPages, summary }; diff --git a/src/intelligence/risk.ts b/src/intelligence/risk.ts index 5722e8d..8722baf 100644 --- a/src/intelligence/risk.ts +++ b/src/intelligence/risk.ts @@ -9,12 +9,18 @@ export function assessRisk(signals: Signal[], causality: CausalityLink[]): RiskA majorVisualChanges: signals.filter((s) => s.type === 'visual' && s.severity === 'high').length, }; + // Video behavior changes factor into risk + const majorVideoChanges = signals.filter( + (s) => s.type === 'video' && s.severity === 'high' + ).length; + // Calculate risk score (0-100) let riskScore = 0; riskScore += riskFactors.criticalA11y * 25; // Critical a11y = 25 points each riskScore += riskFactors.newErrors * 20; // Console errors = 20 points each riskScore += riskFactors.performanceRegressions * 15; // Perf regressions = 15 points each riskScore += riskFactors.majorVisualChanges * 10; // Major visual = 10 points each + riskScore += majorVideoChanges * 12; // Major video behavior changes = 12 points each // Causality increases risk (shows cascading effects) riskScore += causality.length * 5; diff --git a/src/intelligence/signals.ts b/src/intelligence/signals.ts index d73826d..152bbf3 100644 --- a/src/intelligence/signals.ts +++ b/src/intelligence/signals.ts @@ -10,6 +10,8 @@ export function extractSignals(diff: DiffResult): Signal[] { signals.push(...extractVisualSignals(page)); signals.push(...extractDomSignals(page)); signals.push(...extractNetworkSignals(page)); + signals.push(...extractVideoSignals(page)); + signals.push(...extractDomTimelineSignals(page)); } return signals; @@ -141,6 +143,68 @@ function extractNetworkSignals(page: PageDiffResult): Signal[] { return signals; } +function extractVideoSignals(page: PageDiffResult): Signal[] { + if (!page.video || page.video.maxDiffPercentage <= 0.1) return []; + + const video = page.video; + const severity = + video.maxDiffPercentage > 10 ? ('high' as const) : + video.maxDiffPercentage > 3 ? ('medium' as const) : ('low' as const); + + const signals: Signal[] = [ + { + type: 'video' as const, + severity, + confidence: 'medium' as const, + userFacing: true, + description: `Video behavior differs: max ${video.maxDiffPercentage.toFixed(1)}% across ${video.totalFramesCompared} frames`, + context: `${video.divergenceTimeline.length} divergence points`, + pageName: page.pageName, + viewport: page.viewport, + }, + ]; + + // High divergence at specific points is especially notable + const highDivergencePoints = video.divergenceTimeline.filter((p) => p.diffPercentage > 10); + if (highDivergencePoints.length > 0) { + signals.push({ + type: 'video' as const, + severity: 'high' as const, + confidence: 'medium' as const, + userFacing: true, + description: `${highDivergencePoints.length} video frame${highDivergencePoints.length > 1 ? 's' : ''} with >10% visual difference during interaction`, + pageName: page.pageName, + viewport: page.viewport, + }); + } + + return signals; +} + +function extractDomTimelineSignals(page: PageDiffResult): Signal[] { + if (!page.domTimeline || page.domTimeline.snapshotDiffs.length === 0) return []; + + const totalChanges = page.domTimeline.snapshotDiffs.reduce( + (sum, s) => sum + s.changes.length, 0 + ); + + const severity = + totalChanges > 20 ? ('high' as const) : + totalChanges > 5 ? ('medium' as const) : ('low' as const); + + return [ + { + type: 'dom-timeline' as const, + severity, + confidence: 'medium' as const, + userFacing: true, + description: `DOM structure diverges during interaction: ${totalChanges} change${totalChanges !== 1 ? 's' : ''} across ${page.domTimeline.snapshotDiffs.length} checkpoint${page.domTimeline.snapshotDiffs.length !== 1 ? 's' : ''}`, + pageName: page.pageName, + viewport: page.viewport, + }, + ]; +} + // Helper functions function isUserFacingMetric(metricName: string): boolean { const userFacingMetrics = [ diff --git a/src/report/opinionated-markdown.ts b/src/report/opinionated-markdown.ts index 0e11793..7d05832 100644 --- a/src/report/opinionated-markdown.ts +++ b/src/report/opinionated-markdown.ts @@ -1,4 +1,4 @@ -import type { IntelligentDiff, Signal, MetricDelta, DiffResult } from '../types'; +import type { IntelligentDiff, Signal, MetricDelta, DiffResult, VideoDiff, DomTimelineDiff } from '../types'; const COMMENT_MARKER = ''; @@ -9,6 +9,8 @@ export function generateOpinionatedMarkdown(intelligent: IntelligentDiff): strin '', generateDecisionSection(intelligent), generateUserFacingSection(intelligent), + generateVideoSection(intelligent), + generateDomTimelineSection(intelligent), generateSafeChangesSection(intelligent), generateCausalitySection(intelligent), generateDetailedDataSection(intelligent), @@ -87,6 +89,85 @@ function generateUserFacingSection(intelligent: IntelligentDiff): string { return lines.join('\n'); } +function generateVideoSection(intelligent: IntelligentDiff): string { + const pagesWithVideo = intelligent.original.pages.filter( + (p) => p.video && p.video.totalFramesCompared > 0 + ); + + if (pagesWithVideo.length === 0) return ''; + + const lines = ['## Video Behavior Diff', '']; + + for (const page of pagesWithVideo) { + const video = page.video!; + const hasChanges = video.maxDiffPercentage > 0.1; + + if (!hasChanges) { + lines.push(`- **${page.pageName}** (${page.viewport}): No behavioral differences detected across ${video.totalFramesCompared} frames`); + continue; + } + + lines.push(`### ${page.pageName} (${page.viewport})`); + lines.push(''); + lines.push(`- Frames compared: ${video.totalFramesCompared}`); + lines.push(`- Max difference: ${video.maxDiffPercentage.toFixed(2)}%`); + lines.push(`- Avg difference: ${video.avgDiffPercentage.toFixed(2)}%`); + + if (video.divergenceTimeline.length > 0) { + lines.push(''); + lines.push('**Divergence Timeline:**'); + lines.push(''); + lines.push('| Time (ms) | Diff % |'); + lines.push('|-----------|--------|'); + for (const point of video.divergenceTimeline) { + const bar = '█'.repeat(Math.min(20, Math.round(point.diffPercentage / 5))); + lines.push(`| ${point.timestampMs} | ${point.diffPercentage.toFixed(2)}% ${bar} |`); + } + } + + lines.push(''); + } + + return lines.join('\n'); +} + +function generateDomTimelineSection(intelligent: IntelligentDiff): string { + const pagesWithTimeline = intelligent.original.pages.filter( + (p) => p.domTimeline && p.domTimeline.snapshotDiffs.length > 0 + ); + + if (pagesWithTimeline.length === 0) return ''; + + const lines = ['## DOM Timeline Changes', '']; + lines.push('*Structural differences at each interaction checkpoint:*'); + lines.push(''); + + for (const page of pagesWithTimeline) { + const timeline = page.domTimeline!; + lines.push(`### ${page.pageName} (${page.viewport})`); + lines.push(''); + + for (const snap of timeline.snapshotDiffs) { + const label = snap.fromLabel === '(not present)' + ? `New state: "${snap.toLabel}"` + : `At "${snap.fromLabel}"`; + lines.push(`**${label}:** ${snap.changes.length} change${snap.changes.length !== 1 ? 's' : ''}`); + + const shown = snap.changes.slice(0, 5); + for (const change of shown) { + const icon = change.type === 'added' ? '+' : change.type === 'removed' ? '-' : '~'; + lines.push(`- \`${icon}\` ${change.details}`); + } + if (snap.changes.length > 5) { + lines.push(`- *...and ${snap.changes.length - 5} more*`); + } + lines.push(''); + } + } + + return lines.join('\n'); +} + function generateSafeChangesSection(intelligent: IntelligentDiff): string { const { safeChanges } = intelligent; @@ -180,12 +261,15 @@ function generatePagesTable(diff: DiffResult): string { if (diff.pages.length === 0) return ''; const lines = [ - '| Page | Viewport | Visual | DOM | Errors | A11y |', - '|------|----------|--------|-----|--------|------|', + '| Page | Viewport | Visual | Video | DOM | Errors | A11y |', + '|------|----------|--------|-------|-----|--------|------|', ]; for (const page of diff.pages) { const visual = page.visual.diffPixelCount > 0 ? 'Yes' : 'No'; + const video = page.video + ? (page.video.maxDiffPercentage > 0.1 ? `${page.video.maxDiffPercentage.toFixed(1)}%` : 'No diff') + : '-'; const dom = page.dom.length > 0 ? `${page.dom.length}` : '-'; const errors = page.console.newErrors.length > 0 ? String(page.console.newErrors.length) : '-'; const a11y = @@ -193,7 +277,7 @@ function generatePagesTable(diff: DiffResult): string { ? String(page.accessibility.newViolations.length) : '-'; - lines.push(`| ${page.pageName} | ${page.viewport} | ${visual} | ${dom} | ${errors} | ${a11y} |`); + lines.push(`| ${page.pageName} | ${page.viewport} | ${visual} | ${video} | ${dom} | ${errors} | ${a11y} |`); } return lines.join('\n'); diff --git a/src/runner/capture.ts b/src/runner/capture.ts index 46253c2..7de1c68 100644 --- a/src/runner/capture.ts +++ b/src/runner/capture.ts @@ -12,6 +12,7 @@ import type { ConsoleEntry, NetworkRequest, DomNode, + DomSnapshot, AccessibilityViolation, } from '../types'; @@ -33,7 +34,9 @@ export async function captureBranch(options: CaptureOptions): Promise Promise + interactionFn?: (page: Page) => Promise, + disableAnimations?: boolean ): Promise { const pageDir = path.join(outputDir, pageConfig.name, viewport.name); fs.mkdirSync(pageDir, { recursive: true }); @@ -148,14 +153,48 @@ async function capturePage( const fullUrl = `${baseUrl}${pageConfig.url}`; await page.goto(fullUrl, { waitUntil: 'networkidle' }); - // Collect performance metrics + // Collect performance metrics (before stabilization CSS) const performance = await collectPerformanceMetrics(page, networkRequests); + // Stabilize: disable animations and transitions to prevent flaky diffs + if (disableAnimations !== false) { + await page.addStyleTag({ + content: `*, *::before, *::after { + animation-duration: 0s !important; + animation-delay: 0s !important; + transition-duration: 0s !important; + transition-delay: 0s !important; + scroll-behavior: auto !important; + }`, + }); + } + + // Stabilize: mask dynamic content selectors + if (pageConfig.mask && pageConfig.mask.length > 0) { + const maskCSS = pageConfig.mask + .map((selector) => `${selector} { visibility: hidden !important; }`) + .join('\n'); + await page.addStyleTag({ content: maskCSS }); + } + + // Wait for fonts to finish loading + await page.evaluate('document.fonts.ready').catch(() => {}); + // Scroll through the entire page to trigger lazy-loaded / intersection-observer content await scrollToBottom(page); + // Capture DOM snapshots during interactions + const domTimeline: DomSnapshot[] = []; + // Run interactions if defined if (interactionFn) { + // Snapshot before interaction + domTimeline.push({ + timestamp: Date.now(), + label: 'before-interaction', + dom: await serializeDom(page), + }); + try { await interactionFn(page); // Wait for any network activity to settle after interaction @@ -163,13 +202,20 @@ async function capturePage( } catch (err) { core.warning(`Interaction failed for ${pageConfig.name}: ${err}`); } + + // Snapshot after interaction + domTimeline.push({ + timestamp: Date.now(), + label: 'after-interaction', + dom: await serializeDom(page), + }); } // Take screenshot const screenshotPath = path.join(pageDir, 'screenshot.png'); await page.screenshot({ path: screenshotPath, fullPage: true }); - // Serialize DOM + // Serialize DOM (final state) const dom = await serializeDom(page); // Run accessibility audit @@ -197,6 +243,7 @@ async function capturePage( console: consoleEntries, network: networkRequests, dom, + domTimeline, accessibility, }; } diff --git a/src/types.ts b/src/types.ts index 7f85a24..9557780 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,7 @@ export interface Viewport { export interface PageConfig { url: string; name: string; + mask?: string[]; } export interface BehaviorDiffConfig { @@ -30,6 +31,7 @@ export interface BehaviorDiffConfig { bundleSize?: number; }; interactions?: string; + disableAnimations?: boolean; intelligence?: { noiseReduction?: { visualDiffThreshold?: number; @@ -96,6 +98,12 @@ export interface AccessibilityViolation { target: string[]; } +export interface DomSnapshot { + timestamp: number; + label: string; + dom: DomNode; +} + export interface PageCapture { pageName: string; pageUrl: string; @@ -108,6 +116,7 @@ export interface PageCapture { console: ConsoleEntry[]; network: NetworkRequest[]; dom: DomNode; + domTimeline: DomSnapshot[]; accessibility: AccessibilityViolation[]; } @@ -164,6 +173,40 @@ export interface VisualDiff { prScreenshotPath: string; } +export interface VideoFrameDiff { + frameIndex: number; + timestampMs: number; + diffPercentage: number; + diffPixelCount: number; + diffImagePath: string; +} + +export interface VideoDiff { + pageName: string; + viewport: string; + baseVideoPath: string; + prVideoPath: string; + totalFramesCompared: number; + frameDiffs: VideoFrameDiff[]; + maxDiffPercentage: number; + avgDiffPercentage: number; + divergenceTimeline: { timestampMs: number; diffPercentage: number }[]; +} + +export interface DomTimelineChange { + fromLabel: string; + toLabel: string; + changes: DomChange[]; +} + +export interface DomTimelineDiff { + pageName: string; + viewport: string; + baseSnapshots: number; + prSnapshots: number; + snapshotDiffs: DomTimelineChange[]; +} + export interface PageDiffResult { pageName: string; viewport: string; @@ -173,6 +216,8 @@ export interface PageDiffResult { dom: DomChange[]; accessibility: AccessibilityDiff; visual: VisualDiff; + video?: VideoDiff; + domTimeline?: DomTimelineDiff; } export interface DiffResult { @@ -185,6 +230,7 @@ export interface DiffResult { resolvedA11yViolations: number; hasVisualChanges: boolean; hasDomChanges: boolean; + hasVideoChanges: boolean; }; } @@ -195,7 +241,7 @@ export type ChangeCategory = 'user-facing' | 'safe' | 'unknown'; export type ConfidenceLevel = 'high' | 'medium' | 'low'; export interface Signal { - type: 'metric' | 'console' | 'a11y' | 'visual' | 'dom' | 'network'; + type: 'metric' | 'console' | 'a11y' | 'visual' | 'dom' | 'network' | 'video' | 'dom-timeline'; severity: 'critical' | 'high' | 'medium' | 'low'; confidence: ConfidenceLevel; userFacing: boolean; diff --git a/tests/diff-dom-timeline.test.ts b/tests/diff-dom-timeline.test.ts new file mode 100644 index 0000000..9f6e876 --- /dev/null +++ b/tests/diff-dom-timeline.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { diffDomTimelines } from '../src/diff/dom-timeline'; +import type { DomNode, DomSnapshot } from '../src/types'; + +function node( + tag: string, + attrs: Record = {}, + children: DomNode[] = [], + textContent?: string +): DomNode { + return { tag, attributes: attrs, children, textContent }; +} + +function snap(label: string, dom: DomNode, timestamp = 0): DomSnapshot { + return { timestamp, label, dom }; +} + +describe('diffDomTimelines', () => { + it('returns empty diffs for identical timelines', () => { + const tree = node('div', {}, [node('h1', {}, [], 'Hello')]); + const base = [snap('before-interaction', tree), snap('after-interaction', tree)]; + const pr = [snap('before-interaction', tree), snap('after-interaction', tree)]; + + const result = diffDomTimelines(base, pr, 'home', 'desktop'); + expect(result.snapshotDiffs).toHaveLength(0); + expect(result.pageName).toBe('home'); + expect(result.viewport).toBe('desktop'); + }); + + it('detects changes at a specific checkpoint', () => { + const baseTree = node('div', {}, [node('h1', {}, [], 'Hello')]); + const prTree = node('div', {}, [node('h1', {}, [], 'Hello'), node('p', {}, [], 'New')]); + + const base = [snap('after-interaction', baseTree)]; + const pr = [snap('after-interaction', prTree)]; + + const result = diffDomTimelines(base, pr, 'home', 'desktop'); + expect(result.snapshotDiffs.length).toBeGreaterThan(0); + expect(result.snapshotDiffs[0].fromLabel).toBe('after-interaction'); + }); + + it('detects new interaction states in PR', () => { + const tree = node('div', {}, [node('h1', {}, [], 'Hello')]); + const base = [snap('before-interaction', tree)]; + const pr = [snap('before-interaction', tree), snap('after-interaction', tree)]; + + const result = diffDomTimelines(base, pr, 'home', 'desktop'); + const newState = result.snapshotDiffs.find( + (s) => s.fromLabel === '(not present)' + ); + expect(newState).toBeDefined(); + expect(newState?.toLabel).toBe('after-interaction'); + }); + + it('matches snapshots by label regardless of order', () => { + const tree1 = node('div', {}, [node('h1', {}, [], 'Before')]); + const tree2 = node('div', {}, [node('h1', {}, [], 'After')]); + const tree2Changed = node('div', {}, [node('h1', {}, [], 'After Changed')]); + + const base = [snap('after-interaction', tree2), snap('before-interaction', tree1)]; + const pr = [snap('before-interaction', tree1), snap('after-interaction', tree2Changed)]; + + const result = diffDomTimelines(base, pr, 'login', 'mobile'); + + // before-interaction should have no changes (identical) + // after-interaction should have changes + expect(result.snapshotDiffs.length).toBeGreaterThan(0); + const afterDiff = result.snapshotDiffs.find((s) => s.fromLabel === 'after-interaction'); + expect(afterDiff).toBeDefined(); + expect(afterDiff!.changes.length).toBeGreaterThan(0); + }); + + it('reports correct metadata', () => { + const tree = node('div', {}, [node('span', {}, [], 'Test')]); + const base = [snap('a', tree), snap('b', tree)]; + const pr = [snap('a', tree), snap('b', tree), snap('c', tree)]; + + const result = diffDomTimelines(base, pr, 'page', 'vp'); + expect(result.baseSnapshots).toBe(2); + expect(result.prSnapshots).toBe(3); + }); +}); diff --git a/tests/diff-video-signals.test.ts b/tests/diff-video-signals.test.ts new file mode 100644 index 0000000..784bd69 --- /dev/null +++ b/tests/diff-video-signals.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest'; +import { extractSignals } from '../src/intelligence/signals'; +import type { DiffResult, PageDiffResult, VideoDiff, DomTimelineDiff } from '../src/types'; + +function basePage(overrides: Partial = {}): PageDiffResult { + return { + pageName: 'home', + viewport: 'desktop', + metrics: [], + console: { newErrors: [], resolvedErrors: [], newWarnings: [], resolvedWarnings: [] }, + network: { newEndpoints: [], removedEndpoints: [], requestCountDelta: 0, totalBytesDelta: 0 }, + dom: [], + accessibility: { newViolations: [], resolvedViolations: [], unchangedViolations: [] }, + visual: { + pageName: 'home', viewport: 'desktop', diffImagePath: '', diffPixelCount: 0, + diffPercentage: 0, baseScreenshotPath: '', prScreenshotPath: '', + }, + ...overrides, + }; +} + +function wrapDiff(pages: PageDiffResult[]): DiffResult { + return { + pages, + summary: { + totalRegressions: 0, totalImprovements: 0, newErrors: 0, + newA11yViolations: 0, resolvedA11yViolations: 0, + hasVisualChanges: false, hasDomChanges: false, hasVideoChanges: false, + }, + }; +} + +describe('video signal extraction', () => { + it('extracts no signal when no video diff', () => { + const diff = wrapDiff([basePage()]); + const signals = extractSignals(diff); + const videoSignals = signals.filter((s) => s.type === 'video'); + expect(videoSignals).toHaveLength(0); + }); + + it('extracts no signal when video diff is below threshold', () => { + const video: VideoDiff = { + pageName: 'home', viewport: 'desktop', baseVideoPath: '', prVideoPath: '', + totalFramesCompared: 10, frameDiffs: [], maxDiffPercentage: 0.05, avgDiffPercentage: 0.02, + divergenceTimeline: [], + }; + const diff = wrapDiff([basePage({ video })]); + const signals = extractSignals(diff); + const videoSignals = signals.filter((s) => s.type === 'video'); + expect(videoSignals).toHaveLength(0); + }); + + it('extracts low severity signal for small video difference', () => { + const video: VideoDiff = { + pageName: 'home', viewport: 'desktop', baseVideoPath: '', prVideoPath: '', + totalFramesCompared: 10, frameDiffs: [], maxDiffPercentage: 2.0, avgDiffPercentage: 1.0, + divergenceTimeline: [{ timestampMs: 500, diffPercentage: 2.0 }], + }; + const diff = wrapDiff([basePage({ video })]); + const signals = extractSignals(diff); + const videoSignals = signals.filter((s) => s.type === 'video'); + expect(videoSignals).toHaveLength(1); + expect(videoSignals[0].severity).toBe('low'); + expect(videoSignals[0].userFacing).toBe(true); + }); + + it('extracts high severity signal with high divergence points', () => { + const video: VideoDiff = { + pageName: 'home', viewport: 'desktop', baseVideoPath: '', prVideoPath: '', + totalFramesCompared: 10, frameDiffs: [], maxDiffPercentage: 15.0, avgDiffPercentage: 8.0, + divergenceTimeline: [ + { timestampMs: 500, diffPercentage: 15.0 }, + { timestampMs: 1000, diffPercentage: 12.0 }, + ], + }; + const diff = wrapDiff([basePage({ video })]); + const signals = extractSignals(diff); + const videoSignals = signals.filter((s) => s.type === 'video'); + expect(videoSignals.length).toBeGreaterThanOrEqual(2); + expect(videoSignals.some((s) => s.severity === 'high')).toBe(true); + }); +}); + +describe('DOM timeline signal extraction', () => { + it('extracts no signal when no timeline diff', () => { + const diff = wrapDiff([basePage()]); + const signals = extractSignals(diff); + const timelineSignals = signals.filter((s) => s.type === 'dom-timeline'); + expect(timelineSignals).toHaveLength(0); + }); + + it('extracts signal when timeline has changes', () => { + const domTimeline: DomTimelineDiff = { + pageName: 'home', viewport: 'desktop', baseSnapshots: 2, prSnapshots: 2, + snapshotDiffs: [ + { + fromLabel: 'after-interaction', toLabel: 'after-interaction', + changes: [ + { type: 'added', path: '/div/p', details: 'Added

' }, + { type: 'changed', path: '/div/h1', details: 'Text changed' }, + ], + }, + ], + }; + const diff = wrapDiff([basePage({ domTimeline })]); + const signals = extractSignals(diff); + const timelineSignals = signals.filter((s) => s.type === 'dom-timeline'); + expect(timelineSignals).toHaveLength(1); + expect(timelineSignals[0].userFacing).toBe(true); + expect(timelineSignals[0].description).toContain('2 change'); + }); + + it('assigns higher severity for many changes', () => { + const changes = Array.from({ length: 25 }, (_, i) => ({ + type: 'added' as const, path: `/div/el-${i}`, details: `Added `, + })); + const domTimeline: DomTimelineDiff = { + pageName: 'home', viewport: 'desktop', baseSnapshots: 2, prSnapshots: 2, + snapshotDiffs: [{ fromLabel: 'after-interaction', toLabel: 'after-interaction', changes }], + }; + const diff = wrapDiff([basePage({ domTimeline })]); + const signals = extractSignals(diff); + const timelineSignals = signals.filter((s) => s.type === 'dom-timeline'); + expect(timelineSignals[0].severity).toBe('high'); + }); +});