Skip to content

Commit cead779

Browse files
committed
feat: adds VisualWorld interfaces and class
1 parent 389a8c2 commit cead779

File tree

9 files changed

+118
-68
lines changed

9 files changed

+118
-68
lines changed

.changeset/small-experts-stick.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@quickpickle/playwright": minor
3+
"quickpickle": minor
4+
---
5+
6+
This release adds new interfaces and world constructor class for a "VisualWorld"
7+
to QuickPickle main library. All visual testing libraries (Playwright, Vitest Browser)
8+
should implement the VisualWorldInterface, and may extend the VisualWorld class,
9+
which encapsulates helpful functionality for screenshots.

packages/main/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export {
2727
VisualDiffResult,
2828
defaultScreenshotComparisonOptions,
2929
InfoConstructor,
30+
AriaRoleExtended,
3031
} from './world';
3132
export { DocString, DataTable }
3233
export { explodeTags, tagsMatch, normalizeTags, applyHooks }

packages/main/src/shims/png.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// PNG shim that works in both browser and Node.js environments
2+
// Uses dynamic imports instead of require()
3+
4+
// Define the PNG constructor type
5+
interface PNGConstructor {
6+
new (options?: { width?: number; height?: number }): PNGInstance;
7+
sync: {
8+
read(data: Buffer): PNGInstance;
9+
write(png: PNGInstance): Buffer;
10+
};
11+
}
12+
13+
interface PNGInstance {
14+
width: number;
15+
height: number;
16+
data: Buffer;
17+
}
18+
19+
// Check if we're in a browser environment
20+
const isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
21+
22+
// Create a promise that resolves to the PNG constructor
23+
let pngPromise: Promise<PNGConstructor>;
24+
25+
if (isBrowser) {
26+
// Browser environment - use browser build
27+
pngPromise = import('pngjs/browser').then(mod => mod.PNG);
28+
} else {
29+
// Node.js environment - use main build
30+
pngPromise = import('pngjs').then(mod => mod.PNG);
31+
}
32+
33+
// Cache the PNG constructor once loaded
34+
let cachedPNG: PNGConstructor | null = null;
35+
36+
export async function getPNG(): Promise<PNGConstructor> {
37+
if (!cachedPNG) {
38+
cachedPNG = await pngPromise;
39+
}
40+
return cachedPNG;
41+
}
42+

packages/main/src/world.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import type { QuickPickleConfig } from '.'
44
import sanitize from './shims/path-sanitizer'
55
import pixelmatch, { type PixelmatchOptions } from 'pixelmatch';
66
import type { AriaRole } from '@a11y-tools/aria-roles';
7-
export type AriaRoleExtended = AriaRole & 'element'|'input'
7+
export type AriaRoleExtended = AriaRole|'element'|'input'
88
import { Buffer } from 'buffer'
9-
import { PNG } from 'pngjs/browser'
9+
import { getPNG } from './shims/png.js'
10+
import { defaultsDeep } from 'lodash-es';
1011

1112
interface Common {
1213
info: {
@@ -168,6 +169,11 @@ interface StubVisualWorldInterface extends QuickPickleWorldInterface {
168169
*/
169170
screenshotPath:string
170171

172+
/**
173+
* The options for the default screenshot comparisons.
174+
*/
175+
screenshotOptions:Partial<ScreenshotComparisonOptions>
176+
171177
/**
172178
* The full path to a screenshot file, from the root of the file system,
173179
* based on the custom name provided, and including information on any
@@ -290,6 +296,8 @@ export interface VisualWorldInterface extends StubVisualWorldInterface {
290296
*/
291297
expectScreenshotMatch(locator:any, screenshotName:string, options?:any):Promise<void>
292298

299+
screenshotOptions:Partial<ScreenshotComparisonOptions>
300+
293301
}
294302

295303

@@ -313,6 +321,10 @@ export class VisualWorld extends QuickPickleWorld implements StubVisualWorldInte
313321
return this.fullPath(`${this.screenshotDir}/${this.screenshotFilename}`)
314322
}
315323

324+
get screenshotOptions() {
325+
return defaultsDeep((this.worldConfig.screenshotOptions || {}), (this.worldConfig.screenshotOpts || {}), defaultScreenshotComparisonOptions)
326+
}
327+
316328
getScreenshotPath(name?:string) {
317329
if (!name) return this.screenshotPath
318330
let explodedTags = this.info.explodedIdx ? `_(${this.info.tags.join(',')})` : ''
@@ -321,6 +333,9 @@ export class VisualWorld extends QuickPickleWorld implements StubVisualWorldInte
321333

322334
async screenshotDiff(actual:Buffer, expected:Buffer, opts:any): Promise<VisualDiffResult> {
323335

336+
// Get the PNG constructor using the shim
337+
const PNG = await getPNG()
338+
324339
// Parse PNG images to get raw pixel data
325340
const actualPng = PNG.sync.read(actual)
326341
const expectedPng = PNG.sync.read(expected)

packages/playwright/src/PlaywrightWorld.ts

Lines changed: 11 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { chromium, firefox, Locator, webkit, type Browser, type BrowserContext, type Page } from 'playwright';
2-
import { normalizeTags, QuickPickleWorld, QuickPickleWorldInterface } from 'quickpickle';
2+
import { normalizeTags, VisualWorld, VisualWorldInterface, ScreenshotComparisonOptions, AriaRoleExtended } from 'quickpickle';
33
import { After } from 'quickpickle';
44
import type { TestContext } from 'vitest';
55
import { defaultsDeep } from 'lodash-es'
66
import { InfoConstructor } from 'quickpickle/dist/world';
7+
import { Buffer } from 'buffer';
78

89
import { expect } from '@playwright/test';
910
import { ScreenshotSetting } from './snapshotMatcher';
@@ -56,7 +57,7 @@ export type PlaywrightWorldConfig = typeof defaultPlaywrightWorldConfig & {
5657
browserSizes: Record<string,string>
5758
}
5859

59-
export class PlaywrightWorld extends QuickPickleWorld {
60+
export class PlaywrightWorld extends VisualWorld implements VisualWorldInterface {
6061
browser!: Browser
6162
browserContext!: BrowserContext
6263
page!: Page
@@ -161,25 +162,6 @@ export class PlaywrightWorld extends QuickPickleWorld {
161162
return this.worldConfig
162163
}
163164

164-
get screenshotDir() {
165-
return this.sanitizePath(this.worldConfig.screenshotDir)
166-
}
167-
168-
get screenshotPath() {
169-
return this.fullPath(`${this.worldConfig.screenshotDir}/${this.screenshotFilename}`)
170-
}
171-
172-
get screenshotFilename() {
173-
return `${this.toString().replace(/^.+?Feature: /, 'Feature: ').replace(' ' + this.info.step, '')}.png`
174-
}
175-
176-
/**
177-
* @deprecated Use `screenshotPath` instead
178-
*/
179-
get fullScreenshotPath() {
180-
return this.screenshotPath
181-
}
182-
183165
/**
184166
* Gets a locator based on a certain logic
185167
* @example getLocator(page, 'Cancel', 'button') => page.getByRole('button', { name: 'Cancel' })
@@ -192,7 +174,7 @@ export class PlaywrightWorld extends QuickPickleWorld {
192174
* @param text Optional text to match inside the locator
193175
* @returns Promise<void>
194176
*/
195-
getLocator(el:Locator|Page, identifier:string, role:string|'element'|'input', text:string|null=null) {
177+
getLocator(el:Locator|Page, identifier:string, role:AriaRoleExtended, text:string|null=null) {
196178
let locator:Locator
197179
if (role === 'element') locator = el.locator(identifier)
198180
else if (role === 'input') locator = el.getByLabel(identifier).or(el.getByPlaceholder(identifier))
@@ -240,7 +222,7 @@ export class PlaywrightWorld extends QuickPickleWorld {
240222
* @param px The number of pixels to scroll (defaults to 100)
241223
* @returns Promise<void>
242224
*/
243-
async scroll(direction:"up"|"down"|"left"|"right", px = 100) {
225+
async scroll(locator:Locator|Page, direction:"up"|"down"|"left"|"right", px = 100) {
244226
let horiz = direction.includes('t')
245227
if (horiz) await this.page.mouse.wheel(direction === 'right' ? px : -px, 0)
246228
else await this.page.mouse.wheel(0, direction === 'down' ? px : -px)
@@ -322,14 +304,15 @@ export class PlaywrightWorld extends QuickPickleWorld {
322304
}
323305
}
324306

325-
async screenshot(opts?:{
326-
name?:string
327-
locator?:Locator
328-
}) {
307+
async screenshot(opts?:{name?:string,locator?:any}):Promise<Buffer> {
329308
let explodedTags = this.info.explodedIdx ? `_(${this.info.tags.join(',')})` : ''
330309
let path = opts?.name ? this.fullPath(`${this.screenshotDir}/${opts.name}${explodedTags}.png`) : this.screenshotPath
331310
let locator = opts?.locator ?? this.page
332-
return await locator.screenshot({ path, ...this.worldConfig.screenshotOpts })
311+
return await locator.screenshot({ path, ...this.screenshotOptions })
312+
}
313+
314+
async expectScreenshotMatch(locator:Locator|Page, screenshotName:string, options?:Partial<ScreenshotComparisonOptions>):Promise<void> {
315+
await expect(locator).toMatchScreenshot(screenshotName, defaultsDeep(options || {}, this.screenshotOptions))
333316
}
334317

335318
}

packages/playwright/src/actions.steps.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ When('I enter/fill (in )the following( fields):', async function (world:Playwrig
105105
value = role
106106
role = 'input'
107107
}
108-
let locator = world.getLocator(world.page, identifier, role)
108+
let locator = world.getLocator(world.page, identifier, role as any)
109109
await world.setValue(locator, value)
110110
}
111111
})
@@ -145,11 +145,11 @@ When('I wait (for ){float} second(s)', async function (world:PlaywrightWorld, nu
145145

146146
When('I scroll down/up/left/right', async function (world:PlaywrightWorld) {
147147
let direction = world.info.step?.match(/(down|up|left|right)$/)![0] as 'down'|'up'|'left'|'right'
148-
await world.scroll(direction)
148+
await world.scroll(world.page, direction)
149149
})
150150
When('I scroll down/up/left/right {int}(px)( pixels)', async function (world:PlaywrightWorld, num) {
151151
let direction = world.info.step?.match(/(down|up|left|right)(?= \d)/)![0] as 'down'|'up'|'left'|'right'
152-
await world.scroll(direction, num)
152+
await world.scroll(world.page, direction, num)
153153
})
154154

155155
// ================
@@ -162,11 +162,11 @@ Then('(I )take (a )screenshot named {string}', async function (world:PlaywrightW
162162
await world.screenshot({ name })
163163
})
164164
Then('(I )take (a )screenshot of the {string} {word}', async function (world:PlaywrightWorld, identifier:string, role:string) {
165-
let locator = world.getLocator(world.page, identifier, role)
165+
let locator = world.getLocator(world.page, identifier, role as any)
166166
await world.screenshot({ locator })
167167
})
168168
Then('(I )take (a )screenshot of the {string} {word} named {string}', async function (world:PlaywrightWorld, identifier:string, role:string, name:string) {
169-
let locator = world.getLocator(world.page, identifier, role)
169+
let locator = world.getLocator(world.page, identifier, role as any)
170170
await world.screenshot({ locator, name })
171171
})
172172

0 commit comments

Comments
 (0)