1+ import { Page , Locator , expect } from '@playwright/test' ;
2+ import { logger } from '../utils/logger' ;
3+ import * as fs from 'fs' ;
4+ import * as path from 'path' ;
5+
6+ export interface VisualCompareOptions {
7+ maxDiffPixels ?: number ;
8+ maxDiffPixelRatio ?: number ;
9+ threshold ?: number ;
10+ animations ?: 'disabled' | 'allow' ;
11+ mask ?: Locator [ ] ;
12+ fullPage ?: boolean ;
13+ }
14+
15+ export interface VisualTestResult {
16+ name : string ;
17+ passed : boolean ;
18+ diffPixels ?: number ;
19+ diffRatio ?: number ;
20+ actualPath ?: string ;
21+ expectedPath ?: string ;
22+ diffPath ?: string ;
23+ }
24+
25+ /**
26+ * Visual regression testing helper
27+ */
28+ export class VisualTesting {
29+ private snapshotDir : string ;
30+ private results : VisualTestResult [ ] = [ ] ;
31+
32+ constructor ( private page : Page ) {
33+ this . snapshotDir = path . join ( process . cwd ( ) , 'test-results' , 'screenshots' , 'snapshots' ) ;
34+ this . ensureSnapshotDir ( ) ;
35+ }
36+
37+ /**
38+ * Ensure snapshot directory exists
39+ */
40+ private ensureSnapshotDir ( ) : void {
41+ if ( ! fs . existsSync ( this . snapshotDir ) ) {
42+ fs . mkdirSync ( this . snapshotDir , { recursive : true } ) ;
43+ }
44+ }
45+
46+ /**
47+ * Compare full page screenshot with baseline
48+ */
49+ async compareFullPage ( name : string , options ?: VisualCompareOptions ) : Promise < void > {
50+ logger . info ( 'Comparing full page screenshot' , { name } ) ;
51+
52+ try {
53+ await expect ( this . page ) . toHaveScreenshot ( `${ name } .png` , {
54+ maxDiffPixels : options ?. maxDiffPixels || 100 ,
55+ maxDiffPixelRatio : options ?. maxDiffPixelRatio ,
56+ threshold : options ?. threshold || 0.2 ,
57+ animations : options ?. animations || 'disabled' ,
58+ mask : options ?. mask ,
59+ fullPage : options ?. fullPage !== false
60+ } ) ;
61+
62+ this . results . push ( {
63+ name,
64+ passed : true
65+ } ) ;
66+
67+ logger . info ( 'Visual comparison passed' , { name } ) ;
68+ } catch ( error : any ) {
69+ logger . error ( 'Visual comparison failed' , {
70+ name,
71+ error : error . message
72+ } ) ;
73+
74+ this . results . push ( {
75+ name,
76+ passed : false ,
77+ diffPixels : error . matcherResult ?. diffPixels ,
78+ diffRatio : error . matcherResult ?. diffRatio
79+ } ) ;
80+
81+ throw error ;
82+ }
83+ }
84+
85+ /**
86+ * Compare specific element screenshot
87+ */
88+ async compareElement ( selector : string , name : string , options ?: VisualCompareOptions ) : Promise < void > {
89+ logger . info ( 'Comparing element screenshot' , { selector, name } ) ;
90+
91+ try {
92+ const element = this . page . locator ( selector ) ;
93+ await element . scrollIntoViewIfNeeded ( ) ;
94+
95+ await expect ( element ) . toHaveScreenshot ( `${ name } -element.png` , {
96+ maxDiffPixels : options ?. maxDiffPixels || 50 ,
97+ maxDiffPixelRatio : options ?. maxDiffPixelRatio ,
98+ threshold : options ?. threshold || 0.2 ,
99+ animations : options ?. animations || 'disabled' ,
100+ mask : options ?. mask
101+ } ) ;
102+
103+ this . results . push ( {
104+ name : `${ name } -element` ,
105+ passed : true
106+ } ) ;
107+
108+ logger . info ( 'Element visual comparison passed' , { selector, name } ) ;
109+ } catch ( error : any ) {
110+ logger . error ( 'Element visual comparison failed' , {
111+ selector,
112+ name,
113+ error : error . message
114+ } ) ;
115+
116+ this . results . push ( {
117+ name : `${ name } -element` ,
118+ passed : false ,
119+ diffPixels : error . matcherResult ?. diffPixels ,
120+ diffRatio : error . matcherResult ?. diffRatio
121+ } ) ;
122+
123+ throw error ;
124+ }
125+ }
126+
127+ /**
128+ * Compare multiple elements
129+ */
130+ async compareElements ( selectors : string [ ] , baseName : string , options ?: VisualCompareOptions ) : Promise < void > {
131+ logger . info ( 'Comparing multiple elements' , { count : selectors . length , baseName } ) ;
132+
133+ for ( let i = 0 ; i < selectors . length ; i ++ ) {
134+ const name = `${ baseName } -${ i + 1 } ` ;
135+ await this . compareElement ( selectors [ i ] , name , options ) ;
136+ }
137+ }
138+
139+ /**
140+ * Compare viewport screenshot (visible area only)
141+ */
142+ async compareViewport ( name : string , options ?: VisualCompareOptions ) : Promise < void > {
143+ logger . info ( 'Comparing viewport screenshot' , { name } ) ;
144+
145+ try {
146+ await expect ( this . page ) . toHaveScreenshot ( `${ name } -viewport.png` , {
147+ maxDiffPixels : options ?. maxDiffPixels || 100 ,
148+ maxDiffPixelRatio : options ?. maxDiffPixelRatio ,
149+ threshold : options ?. threshold || 0.2 ,
150+ animations : options ?. animations || 'disabled' ,
151+ mask : options ?. mask ,
152+ fullPage : false
153+ } ) ;
154+
155+ this . results . push ( {
156+ name : `${ name } -viewport` ,
157+ passed : true
158+ } ) ;
159+
160+ logger . info ( 'Viewport visual comparison passed' , { name } ) ;
161+ } catch ( error : any ) {
162+ logger . error ( 'Viewport visual comparison failed' , {
163+ name,
164+ error : error . message
165+ } ) ;
166+
167+ this . results . push ( {
168+ name : `${ name } -viewport` ,
169+ passed : false
170+ } ) ;
171+
172+ throw error ;
173+ }
174+ }
175+
176+ /**
177+ * Mask dynamic elements before comparison
178+ */
179+ async compareWithMask ( name : string , maskSelectors : string [ ] , options ?: VisualCompareOptions ) : Promise < void > {
180+ logger . info ( 'Comparing with masked elements' , {
181+ name,
182+ maskCount : maskSelectors . length
183+ } ) ;
184+
185+ const masks = maskSelectors . map ( selector => this . page . locator ( selector ) ) ;
186+
187+ await this . compareFullPage ( name , {
188+ ...options ,
189+ mask : masks
190+ } ) ;
191+ }
192+
193+ /**
194+ * Compare after hiding elements
195+ */
196+ async compareWithHiddenElements ( name : string , hideSelectors : string [ ] , options ?: VisualCompareOptions ) : Promise < void > {
197+ logger . info ( 'Comparing with hidden elements' , {
198+ name,
199+ hideCount : hideSelectors . length
200+ } ) ;
201+
202+ // Hide elements
203+ for ( const selector of hideSelectors ) {
204+ await this . page . locator ( selector ) . evaluate ( ( el : HTMLElement ) => {
205+ el . style . visibility = 'hidden' ;
206+ } ) ;
207+ }
208+
209+ await this . compareFullPage ( name , options ) ;
210+
211+ // Restore elements
212+ for ( const selector of hideSelectors ) {
213+ await this . page . locator ( selector ) . evaluate ( ( el : HTMLElement ) => {
214+ el . style . visibility = 'visible' ;
215+ } ) ;
216+ }
217+ }
218+
219+ /**
220+ * Compare at different viewport sizes
221+ */
222+ async compareResponsive (
223+ name : string ,
224+ viewports : Array < { width : number ; height : number ; name : string } > ,
225+ options ?: VisualCompareOptions
226+ ) : Promise < void > {
227+ logger . info ( 'Comparing responsive views' , {
228+ name,
229+ viewportCount : viewports . length
230+ } ) ;
231+
232+ for ( const viewport of viewports ) {
233+ await this . page . setViewportSize ( {
234+ width : viewport . width ,
235+ height : viewport . height
236+ } ) ;
237+
238+ // Wait for any CSS transitions
239+ await this . page . waitForTimeout ( 500 ) ;
240+
241+ await this . compareFullPage ( `${ name } -${ viewport . name } ` , options ) ;
242+ }
243+ }
244+
245+ /**
246+ * Compare hover state
247+ */
248+ async compareHoverState ( selector : string , name : string , options ?: VisualCompareOptions ) : Promise < void > {
249+ logger . info ( 'Comparing hover state' , { selector, name } ) ;
250+
251+ const element = this . page . locator ( selector ) ;
252+ await element . hover ( ) ;
253+
254+ // Wait for hover animations
255+ await this . page . waitForTimeout ( 300 ) ;
256+
257+ await this . compareElement ( selector , `${ name } -hover` , options ) ;
258+ }
259+
260+ /**
261+ * Compare focus state
262+ */
263+ async compareFocusState ( selector : string , name : string , options ?: VisualCompareOptions ) : Promise < void > {
264+ logger . info ( 'Comparing focus state' , { selector, name } ) ;
265+
266+ const element = this . page . locator ( selector ) ;
267+ await element . focus ( ) ;
268+
269+ // Wait for focus styles
270+ await this . page . waitForTimeout ( 200 ) ;
271+
272+ await this . compareElement ( selector , `${ name } -focus` , options ) ;
273+ }
274+
275+ /**
276+ * Update baseline (accept current as new baseline)
277+ */
278+ async updateBaseline ( name : string ) : Promise < void > {
279+ logger . warn ( 'Updating baseline' , { name } ) ;
280+
281+ // Playwright automatically updates baselines with --update-snapshots flag
282+ // This method is for logging/documentation purposes
283+
284+ logger . info ( 'To update baselines, run tests with --update-snapshots flag' ) ;
285+ }
286+
287+ /**
288+ * Get all test results
289+ */
290+ getResults ( ) : VisualTestResult [ ] {
291+ return this . results ;
292+ }
293+
294+ /**
295+ * Get failed tests
296+ */
297+ getFailedTests ( ) : VisualTestResult [ ] {
298+ return this . results . filter ( r => ! r . passed ) ;
299+ }
300+
301+ /**
302+ * Get summary
303+ */
304+ getSummary ( ) : { total : number ; passed : number ; failed : number } {
305+ const total = this . results . length ;
306+ const passed = this . results . filter ( r => r . passed ) . length ;
307+ const failed = total - passed ;
308+
309+ return { total, passed, failed } ;
310+ }
311+
312+ /**
313+ * Clear results
314+ */
315+ clearResults ( ) : void {
316+ this . results = [ ] ;
317+ logger . debug ( 'Visual test results cleared' ) ;
318+ }
319+
320+ /**
321+ * Compare with custom diff threshold
322+ */
323+ async compareWithThreshold ( name : string , thresholdPercent : number , options ?: VisualCompareOptions ) : Promise < void > {
324+ logger . info ( 'Comparing with custom threshold' , {
325+ name,
326+ threshold : thresholdPercent
327+ } ) ;
328+
329+ await this . compareFullPage ( name , {
330+ ...options ,
331+ threshold : thresholdPercent / 100
332+ } ) ;
333+ }
334+
335+ /**
336+ * Batch compare multiple pages
337+ */
338+ async batchCompare (
339+ pages : Array < { url : string ; name : string } > ,
340+ options ?: VisualCompareOptions
341+ ) : Promise < void > {
342+ logger . info ( 'Batch comparing pages' , { count : pages . length } ) ;
343+
344+ for ( const pageInfo of pages ) {
345+ await this . page . goto ( pageInfo . url ) ;
346+ await this . page . waitForLoadState ( 'networkidle' ) ;
347+ await this . compareFullPage ( pageInfo . name , options ) ;
348+ }
349+ }
350+ }
0 commit comments