diff --git a/playwriter/src/clean-html.ts b/playwriter/src/clean-html.ts index a42f9ee..a24a00b 100644 --- a/playwriter/src/clean-html.ts +++ b/playwriter/src/clean-html.ts @@ -1,6 +1,6 @@ import { Page, Locator } from 'playwright-core' -import { createPatch } from 'diff' import { formatHtmlForPrompt } from './htmlrewrite.js' +import { createSmartDiff } from './diff-utils.js' export interface GetCleanHTMLOptions { locator: Locator | Page @@ -81,16 +81,15 @@ export async function getCleanHTML(options: GetCleanHTMLOptions): Promise { + it('returns no-change when content is identical', () => { + const result = createSmartDiff({ + oldContent: 'hello\nworld', + newContent: 'hello\nworld', + }) + expect(result.type).toBe('no-change') + expect(result.content).toBe('No changes detected since last snapshot') + }) + + it('returns diff for small changes', () => { + const result = createSmartDiff({ + oldContent: 'line1\nline2\nline3\nline4\nline5', + newContent: 'line1\nline2-modified\nline3\nline4\nline5', + }) + expect(result.type).toBe('diff') + expect(result.content).toContain('-line2') + expect(result.content).toContain('+line2-modified') + }) + + it('returns full content when changes exceed 50% threshold', () => { + const result = createSmartDiff({ + oldContent: 'a\nb\nc\nd', + newContent: 'x\ny\nz\nw', + }) + expect(result.type).toBe('full') + expect(result.content).toContain('Content changed significantly') + expect(result.content).toContain('x\ny\nz\nw') + }) + + it('respects custom threshold', () => { + // 2 out of 4 lines changed = 50% + const oldContent = 'a\nb\nc\nd' + const newContent = 'a\nX\nY\nd' + + // With 40% threshold, should return full (50% >= 40%) + const resultLow = createSmartDiff({ + oldContent, + newContent, + threshold: 0.4, + }) + expect(resultLow.type).toBe('full') + + // With 60% threshold, should return diff (50% < 60%) + const resultHigh = createSmartDiff({ + oldContent, + newContent, + threshold: 0.6, + }) + expect(resultHigh.type).toBe('diff') + }) + + it('uses custom label in diff output', () => { + const result = createSmartDiff({ + oldContent: 'line1\nline2\nline3\nline4\nline5', + newContent: 'line1\nmodified\nline3\nline4\nline5', + label: 'snapshot', + threshold: 0.5, // 20% change, will return diff + }) + expect(result.type).toBe('diff') + expect(result.content).toContain('--- snapshot') + expect(result.content).toContain('+++ snapshot') + }) + + it('handles empty old content (all additions)', () => { + const result = createSmartDiff({ + oldContent: '', + newContent: 'new\ncontent\nhere', + }) + // 100% new content should trigger full + expect(result.type).toBe('full') + expect(result.content).toContain('new\ncontent\nhere') + }) + + it('handles empty new content (all deletions)', () => { + const result = createSmartDiff({ + oldContent: 'old\ncontent\nhere', + newContent: '', + }) + expect(result.type).toBe('full') + expect(result.content).toContain('100%') + }) + + it('handles single line changes', () => { + const result = createSmartDiff({ + oldContent: 'single line', + newContent: 'modified line', + }) + // 100% change on single line + expect(result.type).toBe('full') + }) + + it('calculates percentage correctly in output', () => { + // 4 lines changed out of 5 = 80% + const result = createSmartDiff({ + oldContent: 'a\nb\nc\nd\ne', + newContent: 'a\nX\nY\nZ\nW', + }) + expect(result.type).toBe('full') + expect(result.content).toContain('(80% of lines)') + }) + + it('includes context lines in diff output', () => { + const result = createSmartDiff({ + oldContent: 'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10', + newContent: 'line1\nline2\nline3\nline4\nMODIFIED\nline6\nline7\nline8\nline9\nline10', + threshold: 0.9, // Force diff + }) + expect(result.type).toBe('diff') + // Should have context around the change + expect(result.content).toContain('line4') + expect(result.content).toContain('-line5') + expect(result.content).toContain('+MODIFIED') + expect(result.content).toContain('line6') + }) +}) diff --git a/playwriter/src/diff-utils.ts b/playwriter/src/diff-utils.ts new file mode 100644 index 0000000..986e5fb --- /dev/null +++ b/playwriter/src/diff-utils.ts @@ -0,0 +1,75 @@ +import { structuredPatch } from 'diff' + +export interface SmartDiffResult { + type: 'diff' | 'full' | 'no-change' + content: string +} + +export interface CreateSmartDiffOptions { + oldContent: string + newContent: string + /** Threshold ratio (0-1) above which full content is returned instead of diff. Default 0.5 (50%) */ + threshold?: number + /** Label for the diff output */ + label?: string +} + +/** + * Creates a smart diff that returns full content when changes exceed threshold. + * + * When more than `threshold` (default 50%) of lines have changed, showing a diff + * is not useful - we return the full new content instead. + */ +export function createSmartDiff(options: CreateSmartDiffOptions): SmartDiffResult { + const { oldContent, newContent, threshold = 0.5, label = 'content' } = options + + const patch = structuredPatch(label, label, oldContent, newContent, 'previous', 'current', { + context: 3, + }) + + // Count added and removed lines + let addedLines = 0 + let removedLines = 0 + for (const hunk of patch.hunks) { + for (const line of hunk.lines) { + if (line.startsWith('+')) { + addedLines++ + } else if (line.startsWith('-')) { + removedLines++ + } + } + } + + // No changes + if (addedLines === 0 && removedLines === 0) { + return { type: 'no-change', content: 'No changes detected since last snapshot' } + } + + // Calculate change ratio: use max(added, removed) since a replacement counts as both + // This ensures the ratio stays in 0-100% range + const oldLineCount = oldContent.split('\n').length + const newLineCount = newContent.split('\n').length + const maxLines = Math.max(oldLineCount, newLineCount, 1) // Avoid division by zero + const changedLines = Math.max(addedLines, removedLines) + const changeRatio = Math.min(changedLines / maxLines, 1) // Cap at 100% + + if (changeRatio >= threshold) { + const percentChanged = Math.round(changeRatio * 100) + return { + type: 'full', + content: `Content changed significantly (${percentChanged}% of lines). Full new content:\n\n${newContent}`, + } + } + + // Build unified diff string from structured patch + const diffLines: string[] = [ + `--- ${label} (previous)`, + `+++ ${label} (current)`, + ] + for (const hunk of patch.hunks) { + diffLines.push(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`) + diffLines.push(...hunk.lines) + } + + return { type: 'diff', content: diffLines.join('\n') } +} diff --git a/playwriter/src/executor.ts b/playwriter/src/executor.ts index 0e4b603..63dd500 100644 --- a/playwriter/src/executor.ts +++ b/playwriter/src/executor.ts @@ -12,7 +12,7 @@ import util from 'node:util' import { createRequire } from 'node:module' import { fileURLToPath } from 'node:url' import vm from 'node:vm' -import { createPatch } from 'diff' +import { createSmartDiff } from './diff-utils.js' import { getCdpUrl } from './utils.js' import { waitForPageLoad, WaitForPageLoadOptions, WaitForPageLoadResult } from './wait-for-page-load.js' import { getCDPSessionForPage, CDPSession, ICDPSession } from './cdp-session.js' @@ -427,11 +427,13 @@ export class PlaywrightExecutor { this.lastSnapshots.set(targetPage, snapshotStr) return 'No previous snapshot available. This is the first call for this page. Full snapshot stored for next diff.' } - const patch = createPatch('snapshot', previousSnapshot, snapshotStr, 'previous', 'current', { context: 3 }) - if (patch.split('\n').length <= 4) { - return 'No changes detected since last snapshot' - } - return patch + const diffResult = createSmartDiff({ + oldContent: previousSnapshot, + newContent: snapshotStr, + label: 'snapshot', + }) + this.lastSnapshots.set(targetPage, snapshotStr) + return diffResult.content } this.lastSnapshots.set(targetPage, snapshotStr)