Skip to content

Commit e617638

Browse files
committed
feat: added VisualWorld base class
1 parent 4ea00c5 commit e617638

File tree

5 files changed

+275
-3
lines changed

5 files changed

+275
-3
lines changed

.changeset/wise-chairs-judge.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"quickpickle": minor
3+
---
4+
5+
Added new VisualWorld base class and interface
6+
7+
* the VisualWorld class provides some basic functionality related to screenshots,
8+
so that any descendant classes will not have to do things like parse their own
9+
screenshot filenames or implement their own image diff solution.
10+
11+
* the VisualWorldInterface defines a set of helper methods to be implemented by
12+
descendant classes.

packages/main/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,13 @@
4242
"test": "vitest --run"
4343
},
4444
"dependencies": {
45+
"@a11y-tools/aria-roles": "^1.0.0",
46+
"@coderosh/image-size": "^2.0.1",
4547
"@cucumber/cucumber-expressions": "^18.0.1",
4648
"@cucumber/gherkin": "^32.1.0",
4749
"@cucumber/messages": "^27.2.0",
4850
"@cucumber/tag-expressions": "^6.1.2",
51+
"buffer": "^6.0.3",
4952
"lodash": "^4.17.21",
5053
"lodash-es": "^4.17.21"
5154
},
@@ -58,12 +61,11 @@
5861
"@types/lodash": "^4.17.16",
5962
"@types/lodash-es": "^4.17.12",
6063
"@types/node": "^22.15.17",
61-
"@vitest/browser": "^3.1.3",
6264
"playwright": "^1.52.0",
6365
"rollup": "^4.40.2",
6466
"typescript": "^5.8.3",
6567
"vite": "^6.3.5",
66-
"vitest": "^3.1.3"
68+
"vitest": "^3.1.4"
6769
},
6870
"peerDependencies": {
6971
"vitest": "^1.0.0 || >=2.0.0"

packages/main/rollup.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export default {
3232
}),
3333
],
3434
external: [
35+
'@coderosh/image-size',
36+
/^buffer/,
37+
'pixelmatch',
3538
'@cucumber/cucumber-expressions',
3639
'@cucumber/gherkin',
3740
'@cucumber/messages',

packages/main/src/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,19 @@ import { DataTable } from './models/DataTable';
1515
import { DocString } from './models/DocString';
1616
import { normalizeTags, tagsMatch } from './tags';
1717

18-
export { setWorldConstructor, getWorldConstructor, QuickPickleWorld, QuickPickleWorldInterface } from './world';
18+
export {
19+
setWorldConstructor,
20+
getWorldConstructor,
21+
QuickPickleWorld,
22+
QuickPickleWorldInterface,
23+
ScreenshotComparisonOptions,
24+
VisualConfigSetting,
25+
VisualWorld,
26+
VisualWorldInterface,
27+
VisualDiffResult,
28+
defaultScreenshotComparisonOptions,
29+
InfoConstructor,
30+
} from './world';
1931
export { DocString, DataTable }
2032
export { explodeTags, tagsMatch, normalizeTags, applyHooks }
2133
export { defineParameterType } from './steps'

packages/main/src/world.ts

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import type { TestContext } from 'vitest'
22
import { tagsMatch } from './tags'
33
import type { QuickPickleConfig } from '.'
44
import sanitize from './shims/path-sanitizer'
5+
import pixelmatch, { type PixelmatchOptions } from 'pixelmatch';
6+
import type { AriaRole } from '@a11y-tools/aria-roles';
7+
export type AriaRoleExtended = AriaRole & 'element'|'input'
8+
import imageSize from '@coderosh/image-size'
9+
import { Buffer } from 'buffer'
510

611
interface Common {
712
info: {
@@ -35,6 +40,8 @@ export interface QuickPickleWorldInterface {
3540
tagsMatch(tags: string[]): string[]|null // function to check if the Scenario tags match the given tags
3641
sanitizePath:typeof sanitize // function to sanitize a path string, from npm package "path-sanitizer" (shims)
3742
fullPath(path:string):string // function to ensure that a filepath is valid and a subdirectory of the project root
43+
wait(ms:number):Promise<void> // function to wait for a given time
44+
3845
}
3946

4047
export type InfoConstructor = Omit<QuickPickleWorldInterface['info'], 'errors'> & { common:Common }
@@ -81,6 +88,20 @@ export class QuickPickleWorld implements QuickPickleWorldInterface {
8188
return `${this._projectRoot}/${this.sanitizePath(path)}`
8289
}
8390

91+
/**
92+
* A helper function for when you really just need to wait.
93+
*
94+
* @deprecated Waiting for arbitrary amounts of time makes your tests flaky! There are
95+
* usually better ways to wait for something to happen, and this functionality will be
96+
* removed from the API as soon we're sure nobody will **EVER** want to use it again.
97+
* (That may be a long time.)
98+
*
99+
* @param ms milliseconds to wait
100+
*/
101+
async wait(ms:number) {
102+
await new Promise(r => setTimeout(r, ms))
103+
}
104+
84105
toString() {
85106
let parts = [
86107
this.constructor.name,
@@ -105,4 +126,226 @@ export function getWorldConstructor() {
105126

106127
export function setWorldConstructor(constructor: WorldConstructor) {
107128
worldConstructor = constructor
129+
}
130+
131+
export type VisualDiffResult = {
132+
pass: boolean,
133+
diff: Buffer,
134+
diffPercentage: number,
135+
}
136+
137+
export type ScreenshotComparisonOptions = any & Partial<PixelmatchOptions> & {
138+
maxDiffPercentage?: number
139+
}
140+
141+
export const defaultScreenshotComparisonOptions:ScreenshotComparisonOptions = {
142+
maxDiffPercentage: 0,
143+
threshold: 0.1,
144+
alpha: 0.6
145+
}
146+
147+
export interface VisualConfigSetting {
148+
screenshotDir?: string,
149+
screenshotOpts?: Partial<ScreenshotComparisonOptions>
150+
}
151+
152+
interface StubVisualWorldInterface extends QuickPickleWorldInterface {
153+
154+
/**
155+
* The directory where screenshots are saved, relative to the project root.
156+
*/
157+
screenshotDir:string
158+
159+
/**
160+
* The filename for a screenshot based on the current Scenario.
161+
*/
162+
screenshotFilename:string
163+
164+
/**
165+
* The full path to a screenshot file, from the root of the file system,
166+
* based on the current Scenario.
167+
*/
168+
screenshotPath:string
169+
170+
/**
171+
* The full path to a screenshot file, from the root of the file system,
172+
* based on the custom name provided, and including information on any
173+
* exploded tags as necessary.
174+
*
175+
* @param name
176+
*/
177+
getScreenshotPath(name?:string):string
178+
179+
/**
180+
* A helper function to compare two screenshots, for visual regression testing.
181+
* If the screenshots do not match, the difference should be returned as a Buffer.
182+
*/
183+
screenshotDiff(actual:Buffer, expected:Buffer, options?:any):Promise<VisualDiffResult>
184+
185+
}
186+
187+
export interface VisualWorldInterface extends StubVisualWorldInterface {
188+
189+
/**
190+
* A helper method for getting an element, which should work across different testing libraries.
191+
* The "Locator" interface used should be whatever is compatible with the testing library
192+
* being integrated by your World Constructor. This is intended as an 80% solution for
193+
* behavioral tests, so that a single step definition can get an element based on a variety
194+
* of factors, e.g. (in Playwright syntax):
195+
*
196+
* @example getLocator(page, 'Cancel', 'button') => page.getByRole('button', { name: 'Cancel' })
197+
* @example getLocator(page, 'Search', 'input') => page.getByLabel('Search').or(page.getByPlaceholder('Search'))
198+
* @example getLocator(page, 'ul.fourteen-points li', 'element', 'Open covenants of peace') => page.locator('ul.fourteen-points li').filter({ hasText: 'Open covenants of peace' })
199+
*
200+
* @param locator Locator
201+
* The container inside which to search for the required element.
202+
* @param identifier string
203+
* A string that identifies the element to be found. For ARIA roles this is the "name" attribute,
204+
* for role="input" it is the label or placeholder, and for role="element" it is the CSS selector.
205+
* @param role string
206+
* An ARIA role, or "input" to get an input by label or placeholder, or "element" to get an element by css selector.
207+
* @param text string
208+
* A string that the element must contain.
209+
*/
210+
getLocator(locator:any, identifier:string, role:AriaRoleExtended, text?:string|null):any
211+
212+
/**
213+
* Sets a value on a form element based on its type (select, checkbox/radio, or other input).
214+
* The "Locator" interface used should be whatever is compatible with the testing library
215+
* being integrated by your World Constructor. This is intended as an 80% solution for
216+
* behavioral tests, so that a single step definition can get an element based on a variety
217+
* of factors, e.g.:
218+
*
219+
* @example setValue(<SelectInput>, "Option 1, Option 2") => Selects multiple options in a select element
220+
* @example setValue(<RadioInput>, "true") => Checks a checkbox/radio item
221+
* @example setValue(<CheckboxInput>, "false") => Unchecks a checkbox item
222+
* @example setValue(<TextInput>, "Some text") => Fills a text input with "Some text"
223+
* @example setValue(<NumberInput>, 5) => Sets a number input to the number 5
224+
*
225+
* @param locator Locator
226+
* The Locator for the form element
227+
* @param value string|any
228+
* The value to set can be string or other value type
229+
*/
230+
setValue(locator:any, value:string|any):Promise<void>
231+
232+
/**
233+
* Scrolls an element by a number of pixels in a given direction.
234+
*
235+
* @param locator Locator
236+
* The locator that should be scrolled
237+
* @param direction "up"|"down"|"left"|"right"
238+
* The direction to scroll, i.e. "up", "down", "left", "right"
239+
* @param px
240+
* A number of pixels to scroll
241+
*/
242+
scroll(locator:any, direction:string, px:number):Promise<void>
243+
244+
/**
245+
* A helper method for parsing text on a page or in an element.
246+
* Can be used to check for the presence OR absence of visible OR hidden text.
247+
*
248+
* Examples:
249+
* @example expectText(locator, 'text', true, true) // expect that a locator with the text is visible (and there may be hidden ones)
250+
* @example expectText(locator, 'text', false, true) // expect that NO locator with the text is visible (but there may be hidden ones)
251+
* @example expectText(locator, 'text', true, false) // expect that a HIDDEN locator with the text IS FOUND on the page (but there may be visible ones)
252+
* @example expectText(locator, 'text', false, false) // expect that NO hidden locator with the text is found on the page (but there may be visible ones)
253+
*
254+
* @param locator the locator to check
255+
* @param text the text to be found
256+
* @param toBePresent whether a locator with the text should be present
257+
* @param toBeVisible whether the locator with the text should be visible
258+
* @returns void
259+
*/
260+
expectText(locator:any, text:string, toBePresent:boolean, toBeVisible:boolean):Promise<void>;
261+
262+
/**
263+
* A helper function for parsing elements on a page or in an element.
264+
* Can be used to check for the presence OR absence of visible OR hidden elements.
265+
* Examples:
266+
* @example expectElement(locator, true) // expect that an element is visible (and there may be hidden ones)
267+
* @example expectElement(locator, false) // expect that NO element is visible (but there may be hidden ones)
268+
* @example expectElement(locator, true, false) // expect that a HIDDEN element IS FOUND on the page (but there may be visible ones)
269+
* @example expectElement(locator, false, false) // expect that NO hidden element is found on the page (but there may be visible ones)
270+
*
271+
* @param locator the locator to check
272+
* @param toBePresent whether an element should be present
273+
* @param toBeVisible whether the element should be visible
274+
*/
275+
expectElement(locator:any, toBePresent:boolean, toBeVisible:boolean):Promise<void>;
276+
277+
/**
278+
* A helper function to get a screenshot of the current page or an element.
279+
* Depending on the implementation, it may also save a screenshot to disk.
280+
*/
281+
screenshot(opts?:{name?:string,locator?:any}):Promise<Buffer>
282+
283+
/**
284+
* A helper function to test whether two screenshots match. The "Locator" interface used
285+
* should be whatever is compatible with the testing library being integrated by your World Constructor.
286+
*
287+
* @param locator the locator to check
288+
* @param screenshotName the name of the screenshot to compare against
289+
*/
290+
expectScreenshotMatch(locator:any, screenshotName:string, options?:any):Promise<void>
291+
292+
}
293+
294+
295+
export class VisualWorld extends QuickPickleWorld implements StubVisualWorldInterface {
296+
297+
constructor(context:TestContext, info:InfoConstructor) {
298+
super(context,info)
299+
}
300+
301+
async init() {}
302+
303+
get screenshotDir() {
304+
return this.sanitizePath(this.worldConfig.screenshotDir)
305+
}
306+
307+
get screenshotFilename() {
308+
return `${this.toString().replace(/^.+?Feature: /, 'Feature: ').replace(' ' + this.info.step, '')}.png`
309+
}
310+
311+
get screenshotPath() {
312+
return this.fullPath(`${this.screenshotDir}/${this.screenshotFilename}`)
313+
}
314+
315+
getScreenshotPath(name?:string) {
316+
if (!name) return this.screenshotPath
317+
let explodedTags = this.info.explodedIdx ? `_(${this.info.tags.join(',')})` : ''
318+
return this.fullPath(`${this.screenshotDir}/${name}${explodedTags}.png`)
319+
}
320+
321+
async screenshotDiff(actual:Buffer, expected:Buffer, opts:any): Promise<VisualDiffResult> {
322+
323+
// Convert Buffer to ArrayBuffer if needed
324+
const actualBuffer = actual instanceof Buffer ?
325+
actual.buffer.slice(actual.byteOffset, actual.byteOffset + actual.byteLength) :
326+
actual;
327+
328+
const expectedBuffer = expected instanceof Buffer ?
329+
expected.buffer.slice(expected.byteOffset, expected.byteOffset + expected.byteLength) :
330+
expected;
331+
332+
const { width, height } = await imageSize(actualBuffer);
333+
const diff = Buffer.from([])
334+
335+
const mismatchedPixels = pixelmatch(
336+
new Uint8Array(actualBuffer),
337+
new Uint8Array(expectedBuffer),
338+
diff,
339+
width,
340+
height,
341+
opts
342+
);
343+
344+
const totalPixels = width * height;
345+
const diffPercentage = (mismatchedPixels / totalPixels) * 100;
346+
const pass = diffPercentage <= opts.maxDiffPercentage;
347+
348+
return { pass, diff, diffPercentage };
349+
}
350+
108351
}

0 commit comments

Comments
 (0)