nuxt-spec 0.3.3 → 0.4.0-alpha.1

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.
@@ -1,13 +1,33 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
  import { decode } from 'fast-png'
4
- import { expect } from 'vitest'
5
- import { appendToReport, ensureReportCreated, resolveWithin, screenshotSetup, toRGBA } from './screenshot/report-utils'
4
+ import { expect, recordArtifact, TestRunner } from 'vitest'
5
+ import type { TestArtifactBase, TestAttachment } from 'vitest'
6
+ import { resolveWithin, toRGBA } from './helpers/screenshot-utils'
6
7
  import pixelmatch from 'pixelmatch'
7
8
  import type { GotoOptions, NuxtPage } from '@nuxt/test-utils'
8
9
  import { checkNumberParam, checkPageParam, checkStringParam } from './helpers/check-params'
9
10
  import { gotoPage } from './e2e'
10
11
 
12
+ /**
13
+ * Test artifact recorded by `compareScreenshot()` on mismatch.
14
+ * It is consumed by the Nuxt Spec HTML reporter.
15
+ */
16
+ export interface NuxtSpecScreenshotArtifact extends TestArtifactBase {
17
+ type: 'nuxt-spec:screenshot'
18
+ /** Name of the compared PNG file */
19
+ fileName: string
20
+ /** Description of the mismatch */
21
+ message: string
22
+ attachments: Array<TestAttachment & { name: 'baseline' | 'actual' }>
23
+ }
24
+
25
+ declare module 'vitest' {
26
+ interface TestArtifactRegistry {
27
+ 'nuxt-spec:screenshot': NuxtSpecScreenshotArtifact
28
+ }
29
+ }
30
+
11
31
  /**
12
32
  * Extra settings object for `compareScreenshot()` function.
13
33
  * All properties are optional.
@@ -87,16 +107,13 @@ export async function compareScreenshot(page: NuxtPage | string, options?: Compa
87
107
  const currentDir = resolve(dir, '__current__')
88
108
  mkdirSync(currentDir, { recursive: true })
89
109
 
90
- // create report file on first call
91
- ensureReportCreated(dir)
92
-
93
110
  // compute screenshot file name
94
111
  const route = pageInstance.url().substring(pageInstance.url().lastIndexOf('/') + 1) || 'index'
95
112
  const screenshotFile = fileName ?? `${route}.png`
96
113
 
97
114
  // warning on custom non-png file extensions
98
115
  if (!screenshotFile.toLowerCase().endsWith('.png')) {
99
- console.warn(`Screenshots from \`compareScreenshot\` are always saved as PNG. Consider different file name than '${screenshotFile}'.`)
116
+ console.warn(`[Nuxt Spec] Screenshots from \`compareScreenshot\` are always saved as PNG. Consider different file name than '${screenshotFile}'.`)
100
117
  }
101
118
 
102
119
  // ensure the file name cannot escape its target directory
@@ -134,7 +151,7 @@ export async function compareScreenshot(page: NuxtPage | string, options?: Compa
134
151
  }
135
152
  // otherwise report failure
136
153
  const message = `Screenshot size mismatch: expected ${width}x${height}, got ${actualImg.width}x${actualImg.height}. Actual saved to: ${currentPath}`
137
- appendToReport(screenshotFile, message, baseline, screenshot)
154
+ await reportMismatch(screenshotFile, message, baseline, screenshot)
138
155
  expect.fail(message)
139
156
  }
140
157
 
@@ -154,19 +171,31 @@ export async function compareScreenshot(page: NuxtPage | string, options?: Compa
154
171
  // otherwise report failure
155
172
  const ratio = (diffCount / totalPixels * 100).toFixed(2)
156
173
  const message = `Screenshot mismatch: ${diffCount} pixels differ (${ratio}%), allowed ${maxAllowed}. Actual saved to: ${currentPath}`
157
- appendToReport(screenshotFile, message, baseline, screenshot)
174
+ await reportMismatch(screenshotFile, message, baseline, screenshot)
158
175
  expect.fail(message)
159
176
  }
160
177
 
161
178
  return true
162
179
  }
163
180
 
164
- // Vitest globalSetup entry point
165
- // - computes stable timestamp values and exposes them via env variables
166
- // - the report file itself is created lazily on first compareScreenshot call
167
- // - provides a callback to close the HTML report once tests are finished (if it was created)
168
- export default function setup() {
169
- // the function itself is defined in the helper file
170
- // to avoid imports here and there
171
- return screenshotSetup()
181
+ // pass the baseline/actual pair to the Nuxt Spec HTML reporter via Vitest test artifacts
182
+ // best-effort only - it must never mask the actual assertion failure
183
+ async function reportMismatch(fileName: string, message: string, baseline: Uint8Array, actual: Uint8Array): Promise<void> {
184
+ // undefined when called outside of a running test (e.g. in `beforeAll`)
185
+ const test = TestRunner.getCurrentTest()
186
+ if (!test) return
187
+
188
+ try {
189
+ await recordArtifact(test, {
190
+ type: 'nuxt-spec:screenshot',
191
+ fileName,
192
+ message,
193
+ attachments: [
194
+ { name: 'baseline', contentType: 'image/png', body: baseline },
195
+ { name: 'actual', contentType: 'image/png', body: actual },
196
+ ],
197
+ })
198
+ } catch {
199
+ // reporting is optional
200
+ }
172
201
  }
@@ -1,24 +0,0 @@
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 -->
@@ -1,4 +0,0 @@
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>
@@ -1,236 +0,0 @@
1
- import { exec } from 'node:child_process'
2
- import { appendFileSync, existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
3
- import { platform } from 'node:os'
4
- import { resolve, sep } from 'node:path'
5
- import { fileURLToPath, pathToFileURL } 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(), '.nuxt/.nuxt-spec-report-path')
11
- const REPORT_LOCK = resolve(process.cwd(), '.nuxt/.nuxt-spec-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(targetDir)) {
20
- throw new Error(`Target directory "${targetDir}" does not exist.`)
21
- }
22
-
23
- if (existsSync(REPORT_PATH)) {
24
- // report file was already created
25
- reportPath = readFileSync(REPORT_PATH, 'utf-8').trim()
26
- // this shouldn't happen...
27
- if (!existsSync(reportPath)) {
28
- throw new Error(`Invalid data in "${REPORT_PATH}": Report file "${reportPath}" does not exist.`)
29
- }
30
- } else {
31
- // for the first time, prepare and store new time-stamped file path
32
- const fileTimestamp = process.env.SCREENSHOT_REPORT_TIMESTAMP ?? reportTimestamp(new Date())
33
- reportPath = resolve(targetDir, '__current__', `report-${fileTimestamp}.html`)
34
- writeFileSync(REPORT_PATH, reportPath)
35
- }
36
-
37
- // store the path for `appendToReport`
38
- process.env.SCREENSHOT_REPORT_PATH = reportPath
39
-
40
- // report file was already created
41
- if (existsSync(reportPath)) return
42
-
43
- // create new report file from template
44
- const titleTimestamp = process.env.SCREENSHOT_REPORT_TITLE ?? reportTimestamp(new Date(), true)
45
- const template = readFileSync(resolve(templatesDir, 'report-head.html'), 'utf-8')
46
- const report = template.replace('{{TIMESTAMP}}', titleTimestamp)
47
- writeFileSync(reportPath, report)
48
- })
49
- }
50
-
51
- // append a side-by-side baseline/actual comparison
52
- // to the HTML report if the screenshots don't match
53
- // protected from parallel execution issues
54
- export function appendToReport(fileName: string, message: string, baseline: Uint8Array, actual: Uint8Array): void {
55
- const baselineUri = `data:image/png;base64,${Buffer.from(baseline).toString('base64')}`
56
- const actualUri = `data:image/png;base64,${Buffer.from(actual).toString('base64')}`
57
-
58
- const entry = REPORT_ENTRY
59
- .replace('{{FILE_NAME}}', escapeHtml(fileName))
60
- .replace('{{MESSAGE}}', escapeHtml(message))
61
- .replace('{{BASELINE_URI}}', baselineUri)
62
- .replace('{{ACTUAL_URI}}', actualUri)
63
-
64
- withConcurrentLock(() => {
65
- const reportPath = process.env.SCREENSHOT_REPORT_PATH || (existsSync(REPORT_PATH)
66
- ? readFileSync(REPORT_PATH, 'utf-8').trim()
67
- : '')
68
- if (!reportPath || !existsSync(reportPath)) return
69
-
70
- appendFileSync(reportPath, entry)
71
- })
72
- }
73
-
74
- // Vitest globalSetup entry point
75
- // - computes stable timestamp values and exposes them via env variables
76
- // - the report file itself is created lazily on first compareScreenshot call
77
- // - provides a callback to close the HTML report once tests are finished (if it was created)
78
- export function screenshotSetup() {
79
- const NOW = new Date()
80
- const fileTimestamp = reportTimestamp(NOW)
81
- const titleTimestamp = reportTimestamp(NOW, true)
82
-
83
- process.env.SCREENSHOT_REPORT_TIMESTAMP = fileTimestamp
84
- process.env.SCREENSHOT_REPORT_TITLE = titleTimestamp
85
-
86
- // callback that is executed upon Vitest teardown phase
87
- return () => {
88
- if (!existsSync(REPORT_PATH)) return
89
-
90
- const reportPath = readFileSync(REPORT_PATH, 'utf-8').trim()
91
- unlinkSync(REPORT_PATH)
92
-
93
- if (!reportPath) return
94
- if (!existsSync(reportPath)) return
95
-
96
- // add "success" / "error" message as a conclusion
97
- const reportBody = readFileSync(reportPath, 'utf-8')
98
- const hasFailure = reportBody.includes('<div class="failure"')
99
- if (hasFailure) {
100
- appendFileSync(reportPath, '<p style="color: #b00; font-weight: 700; font-size: 18px;">Tests finished with errors</p>\n')
101
- } else {
102
- appendFileSync(reportPath, '<p style="color: #0b0; font-weight: 700; font-size: 18px;">All tests have passed</p>\n')
103
- }
104
-
105
- // wrap the report up with a footer
106
- let footer = readFileSync(resolve(templatesDir, 'report-tail.html'), 'utf-8')
107
- footer = footer.replace('{{TIMESTAMP}}', new Date().toISOString())
108
- appendFileSync(reportPath, footer)
109
- console.log(`\n(nuxt-spec) Visual regression report available at:\n${pathToFileURL(reportPath).href}`)
110
-
111
- if (!process.env.CI) {
112
- console.log('(nuxt-spec) Opening report in default browser...')
113
- const openCmd
114
- = platform() === 'darwin'
115
- ? `open "${reportPath}"`
116
- : platform() === 'win32'
117
- ? `start "" "${reportPath}"`
118
- : `xdg-open "${reportPath}"`
119
- exec(openCmd, (err) => {
120
- if (err) {
121
- console.log('(nuxt-spec) Failed to automatically open report')
122
- }
123
- })
124
- }
125
-
126
- console.log('\n')
127
- }
128
- }
129
-
130
- // helper to keep user-provided targetDir inside the current project root
131
- export function resolveWithin(base: string, segment: string): string {
132
- const target = resolve(base, segment)
133
- if (target !== base && !target.startsWith(base + sep)) {
134
- throw new Error(`Invalid path: "${segment}" resolves outside of "${base}"`)
135
- }
136
- return target
137
- }
138
-
139
- // helper for bridging difference between Vitest PNG saving and fast-png encoding
140
- export function toRGBA(img: DecodedPng): Uint8Array {
141
- const { width, height, data, channels = 4 } = img
142
- if (channels === 4) return data as Uint8Array
143
- const pixels = width * height
144
- const rgba = new Uint8Array(pixels * 4)
145
- for (let i = 0; i < pixels; i++) {
146
- const src = i * 3
147
- rgba[i * 4 + 0] = data[src + 0] ?? 0
148
- rgba[i * 4 + 1] = data[src + 1] ?? 0
149
- rgba[i * 4 + 2] = data[src + 2] ?? 0
150
- rgba[i * 4 + 3] = 255
151
- }
152
- return rgba
153
- }
154
-
155
- // protection from parallel execution issues
156
- function withConcurrentLock<T>(fn: () => T): T {
157
- const start = Date.now()
158
-
159
- while (true) {
160
- try {
161
- // try acquiring the lock
162
- writeFileSync(REPORT_LOCK, String(process.pid), { flag: 'wx' })
163
- break
164
- } catch (error) {
165
- // we only expect "file already exists" error
166
- // otherwise it is an issue
167
- const code = (error as NodeJS.ErrnoException).code
168
- if (code !== 'EEXIST') throw error
169
-
170
- try {
171
- const lockAge = Date.now() - statSync(REPORT_LOCK).mtimeMs
172
- if (lockAge > 30_000) {
173
- // stale lock
174
- unlinkSync(REPORT_LOCK)
175
- continue
176
- }
177
- } catch {
178
- // lock disappeared between checks, retry acquisition
179
- }
180
-
181
- if (Date.now() - start > 10_000) {
182
- // not successful in 10 minutes, give up
183
- throw new Error('Timed out while waiting for screenshot report lock', {
184
- cause: error,
185
- })
186
- }
187
-
188
- // sleep before next attempt
189
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25)
190
- }
191
- }
192
-
193
- try {
194
- return fn()
195
- } finally {
196
- try {
197
- unlinkSync(REPORT_LOCK)
198
- } catch {
199
- // lock file may already be removed
200
- }
201
- }
202
- }
203
-
204
- // timestamp string
205
- // separators=false produces YYYYMMDDHHMMSS for report file name
206
- // separators=true produces YYYY-MM-DD HH:MM:SS for report title
207
- function reportTimestamp(date: Date, separators = false): string {
208
- const pad2 = (n: number) => String(n).padStart(2, '0')
209
- const dateSeparator = separators ? '-' : ''
210
- const midSeparator = separators ? ' ' : ''
211
- const timeSeparator = separators ? ':' : ''
212
- return [
213
- date.getFullYear(),
214
- dateSeparator,
215
- pad2(date.getMonth() + 1),
216
- dateSeparator,
217
- pad2(date.getDate()),
218
- midSeparator,
219
- pad2(date.getHours()),
220
- timeSeparator,
221
- pad2(date.getMinutes()),
222
- timeSeparator,
223
- pad2(date.getSeconds()),
224
- ].join('')
225
- }
226
-
227
- // helper to escape a string for safe interpolation into the HTML report
228
- function escapeHtml(value: string): string {
229
- return value.replace(/[&<>"']/g, char => ({
230
- '&': '&amp;',
231
- '<': '&lt;',
232
- '>': '&gt;',
233
- '"': '&quot;',
234
- '\'': '&#39;',
235
- }[char] ?? char))
236
- }