nuxt-spec 0.2.3 → 0.2.4-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/utils/index.d.ts CHANGED
@@ -1,70 +1,70 @@
1
- import type { NuxtPage } from '@nuxt/test-utils'
2
-
3
- /**
4
- * Visit a specified URL and return the page instance for further interaction.
5
- *
6
- * @param pageName - Path segment appended to the base URL (e.g. `'about'` → `/<about>`)
7
- * @returns Playwright page instance after navigation and hydration
8
- */
9
- export declare function gotoPage(pageName: string): Promise<NuxtPage>
10
-
11
- /**
12
- * Extract inner HTML content from a specified element on a given page.
13
- *
14
- * @param page - Playwright page instance, or a page name string (will call `gotoPage` internally)
15
- * @param element - CSS selector identifying the target element
16
- * @returns The inner HTML of the matched element
17
- */
18
- export declare function getDataHtml(page: NuxtPage | string, element: string): Promise<string>
19
-
20
- /**
21
- * Execute an API call by clicking a trigger element, wait for a successful
22
- * response matching the target URL, then extract the inner HTML from the
23
- * response element.
24
- *
25
- * @param page - Playwright page instance, or a page name string (will call `gotoPage` internally)
26
- * @param triggerElement - CSS selector for the clickable element that triggers the API request
27
- * @param targetUrl - Substring matched against the response URL to identify the expected API call
28
- * @param responseElement - CSS selector for the element displaying the API response
29
- * @returns The inner HTML of the response element
30
- */
31
- export declare function getAPIResultHtml(
32
- page: NuxtPage | string,
33
- triggerElement: string,
34
- targetUrl: string,
35
- responseElement: string,
36
- ): Promise<string>
37
-
38
- export interface CompareScreenshotOptions {
39
- /** Name of the PNG file used for baseline storage and comparison (defaults to route and `index.png` for `/`) */
40
- fileName?: string
41
- /** Directory for baseline/current screenshots, relative to project root (defaults to `test/e2e`) */
42
- targetDir?: string
43
- /** CSS selector for a specific element to capture (defaults to full page) */
44
- selector?: string
45
- /** Max ratio of different pixels (0–1). Default: 0 (exact match) */
46
- maxDiffPixelRatio?: number
47
- /** Max absolute number of different pixels. Takes precedence over `maxDiffPixelRatio` when set. Default: 0 (exact match) */
48
- maxDiffPixels?: number
49
- /** Per-pixel color distance threshold (0–1). Lower = stricter. Default: 0.1 */
50
- threshold?: number
51
- }
52
-
53
- /**
54
- * Capture a browser screenshot and compare it against a stored baseline PNG.
55
- * When run with `-u` / `--update`, or when no baseline exists yet, the current
56
- * screenshot is saved as the new baseline.
57
- *
58
- * Comparison uses pixelmatch for perceptual pixel diffing. By default,
59
- * zero differing pixels are allowed (exact match). Set `maxDiffPixelRatio`
60
- * or `maxDiffPixels` to tolerate cross-platform rendering differences.
61
- *
62
- * @param page - Playwright page instance obtained from `createPage()`
63
- * @param options - extra options (see `CompareScreenshotOptions`)
64
- * @returns `true` when the screenshot matches the baseline (or a new baseline was saved)
65
- * @throws Fails the current Vitest test when a mismatch is detected
66
- */
67
- export declare function compareScreenshot(
68
- page: NuxtPage,
69
- options?: CompareScreenshotOptions,
70
- ): Promise<boolean>
1
+ import type { NuxtPage } from '@nuxt/test-utils'
2
+
3
+ /**
4
+ * Visit a specified URL and return the page instance for further interaction.
5
+ *
6
+ * @param pageName - Path segment appended to the base URL (e.g. `'about'` → `/<about>`)
7
+ * @returns Playwright page instance after navigation and hydration
8
+ */
9
+ export declare function gotoPage(pageName: string): Promise<NuxtPage>
10
+
11
+ /**
12
+ * Extract inner HTML content from a specified element on a given page.
13
+ *
14
+ * @param page - Playwright page instance, or a page name string (will call `gotoPage` internally)
15
+ * @param element - CSS selector identifying the target element
16
+ * @returns The inner HTML of the matched element
17
+ */
18
+ export declare function getDataHtml(page: NuxtPage | string, element: string): Promise<string>
19
+
20
+ /**
21
+ * Execute an API call by clicking a trigger element, wait for a successful
22
+ * response matching the target URL, then extract the inner HTML from the
23
+ * response element.
24
+ *
25
+ * @param page - Playwright page instance, or a page name string (will call `gotoPage` internally)
26
+ * @param triggerElement - CSS selector for the clickable element that triggers the API request
27
+ * @param targetUrl - Substring matched against the response URL to identify the expected API call
28
+ * @param responseElement - CSS selector for the element displaying the API response
29
+ * @returns The inner HTML of the response element
30
+ */
31
+ export declare function getAPIResultHtml(
32
+ page: NuxtPage | string,
33
+ triggerElement: string,
34
+ targetUrl: string,
35
+ responseElement: string,
36
+ ): Promise<string>
37
+
38
+ export interface CompareScreenshotOptions {
39
+ /** Name of the PNG file used for baseline storage and comparison (defaults to route and `index.png` for `/`) */
40
+ fileName?: string
41
+ /** Directory for baseline/current screenshots, relative to project root (defaults to `test/e2e`) */
42
+ targetDir?: string
43
+ /** CSS selector for a specific element to capture (defaults to full page) */
44
+ selector?: string
45
+ /** Max ratio of different pixels (0–1). Default: 0 (exact match) */
46
+ maxDiffPixelRatio?: number
47
+ /** Max absolute number of different pixels. Takes precedence over `maxDiffPixelRatio` when set. Default: 0 (exact match) */
48
+ maxDiffPixels?: number
49
+ /** Per-pixel color distance threshold (0–1). Lower = stricter. Default: 0.1 */
50
+ threshold?: number
51
+ }
52
+
53
+ /**
54
+ * Capture a browser screenshot and compare it against a stored baseline PNG.
55
+ * When run with `-u` / `--update`, or when no baseline exists yet, the current
56
+ * screenshot is saved as the new baseline.
57
+ *
58
+ * Comparison uses pixelmatch for perceptual pixel diffing. By default,
59
+ * zero differing pixels are allowed (exact match). Set `maxDiffPixelRatio`
60
+ * or `maxDiffPixels` to tolerate cross-platform rendering differences.
61
+ *
62
+ * @param page - Playwright page instance obtained from `createPage()`
63
+ * @param options - extra options (see `CompareScreenshotOptions`)
64
+ * @returns `true` when the screenshot matches the baseline (or a new baseline was saved)
65
+ * @throws Fails the current Vitest test when a mismatch is detected
66
+ */
67
+ export declare function compareScreenshot(
68
+ page: NuxtPage,
69
+ options?: CompareScreenshotOptions,
70
+ ): Promise<boolean>
package/utils/index.ts CHANGED
@@ -1,12 +1,12 @@
1
- import { gotoPage, getDataHtml, getAPIResultHtml } from './e2e'
2
- import { compareScreenshot } from './screenshot'
3
- import type { CompareScreenshotOptions } from './screenshot'
4
-
5
- export {
6
- compareScreenshot,
7
- gotoPage,
8
- getDataHtml,
9
- getAPIResultHtml,
10
- }
11
-
12
- export type { CompareScreenshotOptions }
1
+ import { gotoPage, getDataHtml, getAPIResultHtml } from './e2e'
2
+ import { compareScreenshot } from './screenshot'
3
+ import type { CompareScreenshotOptions } from './screenshot'
4
+
5
+ export {
6
+ compareScreenshot,
7
+ gotoPage,
8
+ getDataHtml,
9
+ getAPIResultHtml,
10
+ }
11
+
12
+ export type { CompareScreenshotOptions }
@@ -0,0 +1,8 @@
1
+ <div class="failure">
2
+ <h2>{{FILE_NAME}}</h2>
3
+ <p class="message">{{MESSAGE}}</p>
4
+ <div class="pair">
5
+ <figure><figcaption>Baseline image</figcaption><img src="{{BASELINE_URI}}" alt="Baseline image for reference"></figure>
6
+ <figure><figcaption>Actual screenshot</figcaption><img src="{{ACTUAL_URI}}" alt="Actual screenshot taken"></figure>
7
+ </div>
8
+ </div>
@@ -0,0 +1,24 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Visual Regression Report</title>
6
+ <style>
7
+ body { font-family: system-ui, sans-serif; margin: 2rem; background: #f5f5f5; color: #222; }
8
+ h1 { font-size: 1.4rem; }
9
+ .failure { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 1rem; margin: 1rem 0; box-shadow: 0 1px 3px rgba(0, 0, 0, .1); }
10
+ .failure h2 { font-size: 1rem; margin: 0 0 .5rem; }
11
+ .failure .message { color: #b00; font-size: .85rem; margin: 0 0 .75rem; }
12
+ .pair { display: flex; gap: 1rem; flex-wrap: wrap; }
13
+ .pair figure { margin: 0; flex: 1 1 0; min-width: 240px; }
14
+ .pair figcaption { font-size: .8rem; margin-bottom: .25rem; font-weight: bold; }
15
+ .pair figure:first-child figcaption { color: #0b0; }
16
+ .pair figure:last-child figcaption { color: #b00; }
17
+ .pair img { max-width: 100%; background: #fff; }
18
+ .pair figure:first-child img { border: 2px solid #0b0; }
19
+ .pair figure:last-child img { border: 2px solid #b00; }
20
+ </style>
21
+ </head>
22
+ <body>
23
+ <h1>Visual Regression Report - {{TIMESTAMP}}</h1>
24
+ <!-- BEGIN failed screenshot entries -->
@@ -0,0 +1,4 @@
1
+ <!-- END failed screenshot entries -->
2
+ <div>Generated by <a href="https://github.com/nuxt/nuxt-spec">Nuxt Spec</a> @ {{TIMESTAMP}}</div>
3
+ </body>
4
+ </html>
@@ -0,0 +1,210 @@
1
+ import { exec } from 'node:child_process'
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
3
+ import { platform } from 'node:os'
4
+ import { resolve, sep } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import type { DecodedPng } from 'fast-png'
7
+
8
+ const templatesDir = resolve(fileURLToPath(import.meta.url), '..')
9
+ const REPORT_ENTRY = readFileSync(resolve(templatesDir, 'report-entry.html'), 'utf-8')
10
+ const REPORT_PATH = resolve(process.cwd(), '.report-path')
11
+ const REPORT_LOCK = resolve(process.cwd(), '.report-lock')
12
+
13
+ // create report file on first call
14
+ // protected from parallel execution issues
15
+ export function ensureReportCreated(targetDir = 'test/e2e'): void {
16
+ withConcurrentLock(() => {
17
+ let reportPath: string
18
+
19
+ if (existsSync(REPORT_PATH)) {
20
+ // Keep exactly one shared report per test run; first writer wins.
21
+ reportPath = readFileSync(REPORT_PATH, 'utf-8').trim()
22
+ } else {
23
+ const root = process.cwd()
24
+ const dir = resolveWithin(root, targetDir)
25
+ const fileTimestamp = process.env.SCREENSHOT_REPORT_TIMESTAMP ?? reportTimestamp(new Date())
26
+ reportPath = resolve(dir, '__current__', `report-${fileTimestamp}.html`)
27
+ writeFileSync(REPORT_PATH, reportPath)
28
+ }
29
+
30
+ process.env.SCREENSHOT_REPORT_PATH = reportPath
31
+ if (!reportPath || existsSync(reportPath)) return
32
+
33
+ const titleTimestamp = process.env.SCREENSHOT_REPORT_TITLE ?? reportTimestamp(new Date(), true)
34
+ mkdirSync(resolve(reportPath, '..'), { recursive: true })
35
+
36
+ const template = readFileSync(resolve(templatesDir, 'report-head.html'), 'utf-8')
37
+ const report = template.replace('{{TIMESTAMP}}', titleTimestamp)
38
+ writeFileSync(reportPath, report)
39
+ })
40
+ }
41
+
42
+ // append a side-by-side baseline/actual comparison
43
+ // to the HTML report if the screenshots don't match
44
+ // protected from parallel execution issues
45
+ export function appendToReport(fileName: string, message: string, baseline: Uint8Array, actual: Uint8Array): void {
46
+ const baselineUri = `data:image/png;base64,${Buffer.from(baseline).toString('base64')}`
47
+ const actualUri = `data:image/png;base64,${Buffer.from(actual).toString('base64')}`
48
+
49
+ const entry = REPORT_ENTRY
50
+ .replace('{{FILE_NAME}}', escapeHtml(fileName))
51
+ .replace('{{MESSAGE}}', escapeHtml(message))
52
+ .replace('{{BASELINE_URI}}', baselineUri)
53
+ .replace('{{ACTUAL_URI}}', actualUri)
54
+
55
+ withConcurrentLock(() => {
56
+ const reportPath = process.env.SCREENSHOT_REPORT_PATH || (existsSync(REPORT_PATH)
57
+ ? readFileSync(REPORT_PATH, 'utf-8').trim()
58
+ : '')
59
+ if (!reportPath || !existsSync(reportPath)) return
60
+
61
+ appendFileSync(reportPath, entry)
62
+ })
63
+ }
64
+
65
+ // Vitest globalSetup entry point
66
+ // - computes stable timestamp values and exposes them via env variables
67
+ // - the report file itself is created lazily on first compareScreenshot call
68
+ // - provides a callback to close the HTML report once tests are finished (if it was created)
69
+ export function screenshotSetup() {
70
+ const NOW = new Date()
71
+ const fileTimestamp = reportTimestamp(NOW)
72
+ const titleTimestamp = reportTimestamp(NOW, true)
73
+
74
+ process.env.SCREENSHOT_REPORT_TIMESTAMP = fileTimestamp
75
+ process.env.SCREENSHOT_REPORT_TITLE = titleTimestamp
76
+
77
+ return () => {
78
+ if (!existsSync(REPORT_PATH)) return
79
+
80
+ const reportPath = readFileSync(REPORT_PATH, 'utf-8').trim()
81
+ unlinkSync(REPORT_PATH)
82
+
83
+ if (!reportPath) return
84
+ if (!existsSync(reportPath)) return
85
+
86
+ let footer = readFileSync(resolve(templatesDir, 'report-tail.html'), 'utf-8')
87
+ footer = footer.replace('{{TIMESTAMP}}', new Date().toISOString())
88
+ appendFileSync(reportPath, footer)
89
+ console.log(`\n(nuxt-spec) Visual regression report available at:\nfile://${reportPath}`)
90
+
91
+ if (!process.env.CI) {
92
+ console.log('(nuxt-spec) Opening report in default browser...')
93
+ const openCmd = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'start' : 'xdg-open'
94
+ exec(`${openCmd} "${reportPath}"`, (err) => {
95
+ if (err) {
96
+ console.log('(nuxt-spec) Failed to automatically open report')
97
+ }
98
+ })
99
+ }
100
+
101
+ console.log('\n')
102
+ }
103
+ }
104
+
105
+ // helper to keep user-provided targetDir inside the current project root
106
+ export function resolveWithin(base: string, segment: string): string {
107
+ const target = resolve(base, segment)
108
+ if (target !== base && !target.startsWith(base + sep)) {
109
+ throw new Error(`Invalid path: "${segment}" resolves outside of "${base}"`)
110
+ }
111
+ return target
112
+ }
113
+
114
+ // helper for bridging difference between Vitest PNG saving and fast-png encoding
115
+ export function toRGBA(img: DecodedPng): Uint8Array {
116
+ const { width, height, data, channels = 4 } = img
117
+ if (channels === 4) return data as Uint8Array
118
+ const pixels = width * height
119
+ const rgba = new Uint8Array(pixels * 4)
120
+ for (let i = 0; i < pixels; i++) {
121
+ rgba[i * 4] = data[i * 3]
122
+ rgba[i * 4 + 1] = data[i * 3 + 1]
123
+ rgba[i * 4 + 2] = data[i * 3 + 2]
124
+ rgba[i * 4 + 3] = 255
125
+ }
126
+ return rgba
127
+ }
128
+
129
+ // protection from parallel execution issues
130
+ function withConcurrentLock<T>(fn: () => T): T {
131
+ const start = Date.now()
132
+
133
+ while (true) {
134
+ try {
135
+ // try acquiring the lock
136
+ writeFileSync(REPORT_LOCK, String(process.pid), { flag: 'wx' })
137
+ break
138
+ } catch (error) {
139
+ // we only expect "file already exists" error
140
+ // otherwise it is an issue
141
+ const code = (error as NodeJS.ErrnoException).code
142
+ if (code !== 'EEXIST') throw error
143
+
144
+ try {
145
+ const lockAge = Date.now() - statSync(REPORT_LOCK).mtimeMs
146
+ if (lockAge > 30_000) {
147
+ // stale lock
148
+ unlinkSync(REPORT_LOCK)
149
+ continue
150
+ }
151
+ } catch {
152
+ // lock disappeared between checks, retry acquisition
153
+ }
154
+
155
+ if (Date.now() - start > 10_000) {
156
+ // not successful in 10 minutes, give up
157
+ throw new Error('Timed out while waiting for screenshot report lock', {
158
+ cause: error,
159
+ })
160
+ }
161
+
162
+ // sleep before next attempt
163
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25)
164
+ }
165
+ }
166
+
167
+ try {
168
+ return fn()
169
+ } finally {
170
+ try {
171
+ unlinkSync(REPORT_LOCK)
172
+ } catch {
173
+ // lock file may already be removed
174
+ }
175
+ }
176
+ }
177
+
178
+ // timestamp string
179
+ // separators=false produces YYYYMMDDHHMMSS for report file name
180
+ // separators=true produces YYYY-MM-DD HH:MM:SS for report title
181
+ function reportTimestamp(date: Date, separators = false): string {
182
+ const pad2 = (n: number) => String(n).padStart(2, '0')
183
+ const dateSeparator = separators ? '-' : ''
184
+ const midSeparator = separators ? ' ' : ''
185
+ const timeSeparator = separators ? ':' : ''
186
+ return [
187
+ date.getFullYear(),
188
+ dateSeparator,
189
+ pad2(date.getMonth() + 1),
190
+ dateSeparator,
191
+ pad2(date.getDate()),
192
+ midSeparator,
193
+ pad2(date.getHours()),
194
+ timeSeparator,
195
+ pad2(date.getMinutes()),
196
+ timeSeparator,
197
+ pad2(date.getSeconds()),
198
+ ].join('')
199
+ }
200
+
201
+ // helper to escape a string for safe interpolation into the HTML report
202
+ function escapeHtml(value: string): string {
203
+ return value.replace(/[&<>"']/g, char => ({
204
+ '&': '&amp;',
205
+ '<': '&lt;',
206
+ '>': '&gt;',
207
+ '"': '&quot;',
208
+ '\'': '&#39;',
209
+ }[char] ?? char))
210
+ }
@@ -1,89 +1,104 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
- import { resolve } from 'node:path'
3
- import { expect } from 'vitest'
4
- import { decode, type DecodedPng } from 'fast-png'
5
- import pixelmatch from 'pixelmatch'
6
- import type { NuxtPage } from '@nuxt/test-utils'
7
-
8
- export interface CompareScreenshotOptions {
9
- /** Name of the PNG file used for baseline storage and comparison (defaults to route and `index.png` for `/`) */
10
- fileName?: string
11
- /** Directory for baseline/current screenshots, relative to project root (defaults to `test/e2e`) */
12
- targetDir?: string
13
- /** CSS selector for a specific element to capture (defaults to full page) */
14
- selector?: string
15
- /** Max ratio of different pixels (0–1). Default: 0 (exact match) */
16
- maxDiffPixelRatio?: number
17
- /** Max absolute number of different pixels. Takes precedence over `maxDiffPixelRatio` when set. Default: 0 (exact match) */
18
- maxDiffPixels?: number
19
- /** Per-pixel color distance threshold (0–1). Lower = stricter. Default: 0.1 */
20
- threshold?: number
21
- }
22
-
23
- // capture a browser screenshot and compare it against a stored baseline PNG
24
- export async function compareScreenshot(page: NuxtPage, options?: CompareScreenshotOptions): Promise<boolean> {
25
- const dir = resolve(process.cwd(), options?.targetDir ?? 'test/e2e')
26
- const baselineDir = resolve(dir, '__baseline__')
27
- const currentDir = resolve(dir, '__current__')
28
-
29
- const route = page.url().substring(page.url().lastIndexOf('/') + 1) || 'index'
30
- const fileName = options?.fileName ?? `${route}.png`
31
-
32
- // capture element specified by locator or a full-page screenshot as PNG
33
- const screenshot = options?.selector
34
- ? await page.locator(options.selector).screenshot()
35
- : await page.screenshot({ fullPage: true })
36
- const baselinePath = resolve(baselineDir, fileName)
37
-
38
- // always save the current screenshot for inspection
39
- mkdirSync(currentDir, { recursive: true })
40
- writeFileSync(resolve(currentDir, fileName), screenshot)
41
-
42
- // @ts-expect-error - this is reliable way of reading Vitest "update" flag
43
- const updating = expect.getState().snapshotState?._updateSnapshot === 'all'
44
- if (updating || !existsSync(baselinePath)) {
45
- // save new baseline screenshot
46
- mkdirSync(baselineDir, { recursive: true })
47
- writeFileSync(baselinePath, screenshot)
48
- return true
49
- }
50
-
51
- // compare against stored baseline PNG using pixelmatch
52
- const baseline = readFileSync(baselinePath)
53
- const baselineImg = decode(baseline)
54
- const actualImg = decode(screenshot)
55
- const { width, height } = baselineImg
56
-
57
- if (actualImg.width !== width || actualImg.height !== height) {
58
- expect.fail(`Screenshot size mismatch: expected ${width}x${height}, got ${actualImg.width}x${actualImg.height}. Actual saved to: ${resolve(currentDir, fileName)}`)
59
- }
60
-
61
- const diffCount = pixelmatch(toRGBA(baselineImg), toRGBA(actualImg), undefined, width, height, {
62
- threshold: options?.threshold ?? 0.1,
63
- })
64
-
65
- const totalPixels = width * height
66
- const maxAllowed = options?.maxDiffPixels ?? Math.ceil(totalPixels * (options?.maxDiffPixelRatio ?? 0))
67
-
68
- if (diffCount > maxAllowed) {
69
- const ratio = (diffCount / totalPixels * 100).toFixed(2)
70
- expect.fail(`Screenshot mismatch: ${diffCount} pixels differ (${ratio}%), allowed ${maxAllowed}. Actual saved to: ${resolve(currentDir, fileName)}`)
71
- }
72
-
73
- return true
74
- }
75
-
76
- // helper for bridging difference between Vitest PNG saving and fast-png encoding
77
- function toRGBA(img: DecodedPng): Uint8Array {
78
- const { width, height, data, channels = 4 } = img
79
- if (channels === 4) return data as Uint8Array
80
- const pixels = width * height
81
- const rgba = new Uint8Array(pixels * 4)
82
- for (let i = 0; i < pixels; i++) {
83
- rgba[i * 4] = data[i * 3]
84
- rgba[i * 4 + 1] = data[i * 3 + 1]
85
- rgba[i * 4 + 2] = data[i * 3 + 2]
86
- rgba[i * 4 + 3] = 255
87
- }
88
- return rgba
89
- }
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { decode } from 'fast-png'
4
+ import { expect } from 'vitest'
5
+ import { appendToReport, ensureReportCreated, resolveWithin, screenshotSetup, toRGBA } from './screenshot/report-utils'
6
+ import pixelmatch from 'pixelmatch'
7
+ import type { NuxtPage } from '@nuxt/test-utils'
8
+
9
+ export interface CompareScreenshotOptions {
10
+ /** Name of the PNG file used for baseline storage and comparison (defaults to route and `index.png` for `/`) */
11
+ fileName?: string
12
+ /** Directory for baseline/current screenshots, relative to project root (defaults to `test/e2e`) */
13
+ targetDir?: string
14
+ /** CSS selector for a specific element to capture (defaults to full page) */
15
+ selector?: string
16
+ /** Max ratio of different pixels (0–1). Default: 0 (exact match) */
17
+ maxDiffPixelRatio?: number
18
+ /** Max absolute number of different pixels. Takes precedence over `maxDiffPixelRatio` when set. Default: 0 (exact match) */
19
+ maxDiffPixels?: number
20
+ /** Per-pixel color distance threshold (0–1). Lower = stricter. Default: 0.1 */
21
+ threshold?: number
22
+ }
23
+
24
+ // capture a browser screenshot and compare it against a stored baseline PNG
25
+ export async function compareScreenshot(page: NuxtPage, options?: CompareScreenshotOptions): Promise<boolean> {
26
+ const root = process.cwd()
27
+
28
+ // ensure the target directory stays within the project root
29
+ const dir = resolveWithin(root, options?.targetDir ?? 'test/e2e')
30
+
31
+ // create report file on first call
32
+ ensureReportCreated(dir)
33
+
34
+ const baselineDir = resolve(dir, '__baseline__')
35
+ const currentDir = resolve(dir, '__current__')
36
+
37
+ const route = page.url().substring(page.url().lastIndexOf('/') + 1) || 'index'
38
+ const fileName = options?.fileName ?? `${route}.png`
39
+
40
+ // warning on custom non-png file extensions
41
+ if (!fileName.toLowerCase().endsWith('.png')) {
42
+ console.warn(`Screenshots from \`compareScreenshot\` are always saved as PNG. Consider different file name than '${fileName}'.`)
43
+ }
44
+
45
+ // ensure the file name cannot escape its target directory
46
+ const baselinePath = resolveWithin(baselineDir, fileName)
47
+ const currentPath = resolveWithin(currentDir, fileName)
48
+
49
+ // capture element specified by locator or a full-page screenshot as PNG
50
+ const screenshot = options?.selector
51
+ ? await page.locator(options.selector).screenshot()
52
+ : await page.screenshot({ fullPage: true })
53
+
54
+ // always save the current screenshot for inspection
55
+ mkdirSync(currentDir, { recursive: true })
56
+ writeFileSync(currentPath, screenshot)
57
+
58
+ // @ts-expect-error - this is reliable way of reading Vitest "update" flag
59
+ const updating = expect.getState().snapshotState?._updateSnapshot === 'all'
60
+ if (updating || !existsSync(baselinePath)) {
61
+ // save new baseline screenshot
62
+ mkdirSync(baselineDir, { recursive: true })
63
+ writeFileSync(baselinePath, screenshot)
64
+ return true
65
+ }
66
+
67
+ // compare against stored baseline PNG using pixelmatch
68
+ const baseline = readFileSync(baselinePath)
69
+ const baselineImg = decode(baseline)
70
+ const actualImg = decode(screenshot)
71
+ const { width, height } = baselineImg
72
+
73
+ if (actualImg.width !== width || actualImg.height !== height) {
74
+ const message = `Screenshot size mismatch: expected ${width}x${height}, got ${actualImg.width}x${actualImg.height}. Actual saved to: ${currentPath}`
75
+ appendToReport(fileName, message, baseline, screenshot)
76
+ expect.fail(message)
77
+ }
78
+
79
+ const diffCount = pixelmatch(toRGBA(baselineImg), toRGBA(actualImg), undefined, width, height, {
80
+ threshold: options?.threshold ?? 0.1,
81
+ })
82
+
83
+ const totalPixels = width * height
84
+ const maxAllowed = options?.maxDiffPixels ?? Math.ceil(totalPixels * (options?.maxDiffPixelRatio ?? 0))
85
+
86
+ if (diffCount > maxAllowed) {
87
+ const ratio = (diffCount / totalPixels * 100).toFixed(2)
88
+ const message = `Screenshot mismatch: ${diffCount} pixels differ (${ratio}%), allowed ${maxAllowed}. Actual saved to: ${currentPath}`
89
+ appendToReport(fileName, message, baseline, screenshot)
90
+ expect.fail(message)
91
+ }
92
+
93
+ return true
94
+ }
95
+
96
+ // Vitest globalSetup entry point
97
+ // - computes stable timestamp values and exposes them via env variables
98
+ // - the report file itself is created lazily on first compareScreenshot call
99
+ // - provides a callback to close the HTML report once tests are finished (if it was created)
100
+ export default function setup() {
101
+ // the function itself is defined in the helper file
102
+ // to avoid imports here and there
103
+ return screenshotSetup()
104
+ }