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)