@@ -2,6 +2,11 @@ import type { TestContext } from 'vitest'
22import { tagsMatch } from './tags'
33import type { QuickPickleConfig } from '.'
44import 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
611interface 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
4047export 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
106127export 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 ( / ^ .+ ?F e a t u r e : / , '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