nuxt-spec 0.2.4-alpha → 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/README.md CHANGED
@@ -384,6 +384,30 @@ For detailed description, see [utils.d.ts](https://github.com/AloisSeckar/nuxt-s
384
384
 
385
385
  The `compareScreenshot` function usage results into HTML report file being automatically created. The file is generated within the specified `__current__` directory as `report_YYYYMMDDHHMMSS.html`. It contains all failed screenshots comparison. When test suite is over, file is attempted to be opened in system default browser (unless Node operates in `CI` mode).
386
386
 
387
+ ### Notice on non-default setups
388
+
389
+ The creation of the report file and it's proper wrap-up at the end is ensured via `globalSetup` function passed into default Vitest E2E suite defined by Nuxt Spec. If you need to override the default `e2e` project, you also need to make sure to call the setup function manually.
390
+
391
+ Add following into your `vitest.config.ts`:
392
+
393
+ ```ts
394
+ // vitest.config.ts
395
+ import { loadVitestConfig } from 'nuxt-spec/config'
396
+
397
+ // resolve path to Nuxt Spec's setup function
398
+ const screenshotReportSetup = fileURLToPath(new URL('../utils/screenshot.ts', import.meta.resolve('nuxt-spec/config')))
399
+
400
+ export default loadVitestConfig({
401
+ // whatever e2e test you are defining
402
+ test: {
403
+ // provide it to your `compareScreenshot` using test suite
404
+ globalSetup: [screenshotReportSetup],
405
+ // other config
406
+ },
407
+ // other config
408
+ })
409
+ ```
410
+
387
411
  ## Contact
388
412
 
389
413
  Use GitHub issues to report bugs or suggest improvements. I will be more than happy to address them.
package/config/index.mjs CHANGED
@@ -11,7 +11,7 @@ import { playwright } from '@vitest/browser-playwright'
11
11
  import vue from '@vitejs/plugin-vue'
12
12
 
13
13
  // absolute path so it works from nuxt-ignis package
14
- const screenshotReportSetup = fileURLToPath(new URL('../utils/screenshot-report.ts', import.meta.url))
14
+ const screenshotReportSetup = fileURLToPath(new URL('../utils/screenshot.ts', import.meta.url))
15
15
 
16
16
  export async function loadVitestConfig(userVitestConfig, projects = true) {
17
17
  const baseConfig = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuxt-spec",
3
- "version": "0.2.4-alpha",
3
+ "version": "0.2.4-alpha.2",
4
4
  "description": "Test-pack layer for Nuxt Applications",
5
5
  "repository": {
6
6
  "type": "git",
package/utils/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { gotoPage, getDataHtml, getAPIResultHtml } from './e2e'
2
- import { compareScreenshot } from './screenshot-compare'
3
- import type { CompareScreenshotOptions } from './screenshot-compare'
2
+ import { compareScreenshot } from './screenshot'
3
+ import type { CompareScreenshotOptions } from './screenshot'
4
4
 
5
5
  export {
6
6
  compareScreenshot,
@@ -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,9 +1,8 @@
1
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
- import { resolve, sep } from 'node:path'
3
- import { fileURLToPath } from 'node:url'
4
- import { decode, type DecodedPng } from 'fast-png'
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { decode } from 'fast-png'
5
4
  import { expect } from 'vitest'
6
- import { ensureReportCreated, getInjection } from './screenshot-report'
5
+ import { appendToReport, ensureReportCreated, resolveWithin, screenshotSetup, toRGBA } from './screenshot/report-utils'
7
6
  import pixelmatch from 'pixelmatch'
8
7
  import type { NuxtPage } from '@nuxt/test-utils'
9
8
 
@@ -24,14 +23,14 @@ export interface CompareScreenshotOptions {
24
23
 
25
24
  // capture a browser screenshot and compare it against a stored baseline PNG
26
25
  export async function compareScreenshot(page: NuxtPage, options?: CompareScreenshotOptions): Promise<boolean> {
27
- // create report file on first call
28
- ensureReportCreated()
29
-
30
26
  const root = process.cwd()
31
27
 
32
28
  // ensure the target directory stays within the project root
33
29
  const dir = resolveWithin(root, options?.targetDir ?? 'test/e2e')
34
30
 
31
+ // create report file on first call
32
+ ensureReportCreated(dir)
33
+
35
34
  const baselineDir = resolve(dir, '__baseline__')
36
35
  const currentDir = resolve(dir, '__current__')
37
36
 
@@ -94,58 +93,12 @@ export async function compareScreenshot(page: NuxtPage, options?: CompareScreens
94
93
  return true
95
94
  }
96
95
 
97
- // helper to make sure passed options are not escaping from cwd
98
- export function resolveWithin(base: string, segment: string): string {
99
- const target = resolve(base, segment)
100
- if (target !== base && !target.startsWith(base + sep)) {
101
- throw new Error(`Invalid path: "${segment}" resolves outside of "${base}"`)
102
- }
103
- return target
104
- }
105
-
106
- // escape a string for safe interpolation into the HTML report
107
- function escapeHtml(value: string): string {
108
- return value.replace(/[&<>"']/g, char => ({
109
- '&': '&amp;',
110
- '<': '&lt;',
111
- '>': '&gt;',
112
- '"': '&quot;',
113
- '\'': '&#39;',
114
- }[char] ?? char))
115
- }
116
-
117
- const HTML_DIR = resolve(fileURLToPath(import.meta.url), '..', 'html')
118
- const REPORT_ENTRY = readFileSync(resolve(HTML_DIR, 'report-entry.html'), 'utf-8')
119
-
120
- // template for screenshot comparison entry
121
- // append a side-by-side baseline/actual comparison entry to the HTML report
122
- function appendToReport(fileName: string, message: string, baseline: Uint8Array, actual: Uint8Array): void {
123
- const reportPath = getInjection('screenshotReportPath')
124
- if (!reportPath || !existsSync(reportPath)) return
125
-
126
- const baselineUri = `data:image/png;base64,${Buffer.from(baseline).toString('base64')}`
127
- const actualUri = `data:image/png;base64,${Buffer.from(actual).toString('base64')}`
128
-
129
- const entry = REPORT_ENTRY
130
- .replace('{{FILE_NAME}}', escapeHtml(fileName))
131
- .replace('{{MESSAGE}}', escapeHtml(message))
132
- .replace('{{BASELINE_URI}}', baselineUri)
133
- .replace('{{ACTUAL_URI}}', actualUri)
134
-
135
- appendFileSync(reportPath, entry)
136
- }
137
-
138
- // helper for bridging difference between Vitest PNG saving and fast-png encoding
139
- function toRGBA(img: DecodedPng): Uint8Array {
140
- const { width, height, data, channels = 4 } = img
141
- if (channels === 4) return data as Uint8Array
142
- const pixels = width * height
143
- const rgba = new Uint8Array(pixels * 4)
144
- for (let i = 0; i < pixels; i++) {
145
- rgba[i * 4] = data[i * 3]
146
- rgba[i * 4 + 1] = data[i * 3 + 1]
147
- rgba[i * 4 + 2] = data[i * 3 + 2]
148
- rgba[i * 4 + 3] = 255
149
- }
150
- return rgba
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()
151
104
  }
@@ -1,116 +0,0 @@
1
- import { exec } from 'node:child_process'
2
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
- import { platform } from 'node:os'
4
- import { resolve } from 'node:path'
5
- import { fileURLToPath } from 'node:url'
6
- import { inject } from 'vitest'
7
- import type { ProvidedContext } from 'vitest'
8
- import type { TestProject } from 'vitest/node'
9
-
10
- declare module 'vitest' {
11
- interface ProvidedContext {
12
- screenshotReportPath: string
13
- screenshotReportTitle: string
14
- }
15
- }
16
-
17
- const templatesDir = resolve(fileURLToPath(import.meta.url), '..', 'html')
18
-
19
- // timestamp string
20
- // separators=false produces YYYYMMDDHHMMSS for report file name
21
- // separators=true produces YYYY-MM-DD HH:MM:SS for report title
22
- function reportTimestamp(date: Date, separators = false): string {
23
- const pad2 = (n: number) => String(n).padStart(2, '0')
24
- const dateSeparator = separators ? '-' : ''
25
- const midSeparator = separators ? ' ' : ''
26
- const timeSeparator = separators ? ':' : ''
27
- return [
28
- date.getFullYear(),
29
- dateSeparator,
30
- pad2(date.getMonth() + 1),
31
- dateSeparator,
32
- pad2(date.getDate()),
33
- midSeparator,
34
- pad2(date.getHours()),
35
- timeSeparator,
36
- pad2(date.getMinutes()),
37
- timeSeparator,
38
- pad2(date.getSeconds()),
39
- ].join('')
40
- }
41
-
42
- // read the header template and replace the timestamp placeholder
43
- function reportScaffold(titleTimestamp: string): string {
44
- const template = readFileSync(resolve(templatesDir, 'report-head.html'), 'utf-8')
45
- return template.replace('{{TIMESTAMP}}', titleTimestamp)
46
- }
47
-
48
- // resolve the value created in the Vitest globalSetup helper
49
- // (provide/inject is preferred, with process.env kept as a fallback)
50
- export function getInjection(key: keyof ProvidedContext): string | undefined {
51
- try {
52
- const injected = inject(key)
53
- if (injected) return injected
54
- } catch (e) {
55
- // `inject` is unavailable outside of the Vitest worker context
56
- console.debug(`(nuxt-spec) getInjection(${key}) - error during Vitest injection`)
57
- console.error((e as Error).message || e)
58
- }
59
-
60
- console.debug(`(nuxt-spec) getInjection(${key}) - falling back to process.env`)
61
-
62
- // env variable fallbacks
63
- switch (key) {
64
- case 'screenshotReportPath':
65
- return process.env.SCREENSHOT_REPORT_PATH
66
- case 'screenshotReportTitle':
67
- return process.env.SCREENSHOT_REPORT_TITLE
68
- default:
69
- return undefined
70
- }
71
- }
72
-
73
- // create report file on first call, using the path computed in setup()
74
- export function ensureReportCreated(): void {
75
- const reportPath = getInjection('screenshotReportPath')
76
- if (!reportPath || existsSync(reportPath)) return
77
-
78
- const titleTimestamp = getInjection('screenshotReportTitle') ?? reportTimestamp(new Date(), true)
79
-
80
- writeFileSync(reportPath, reportScaffold(titleTimestamp))
81
- }
82
-
83
- // Vitest globalSetup entry point
84
- // - computes the report path and exposes it via provide/inject
85
- // - the report file is created lazily on first compareScreenshot call
86
- // - closes the HTML report via a callback once tests are finished (if it was created)
87
- export default function setup({ provide }: TestProject) {
88
- const dir = resolve(process.cwd(), 'test/e2e', '__current__')
89
- mkdirSync(dir, { recursive: true })
90
-
91
- const NOW = new Date()
92
- const reportPath = resolve(dir, `report-${reportTimestamp(NOW)}.html`)
93
- provide('screenshotReportPath', reportPath)
94
- provide('screenshotReportTitle', reportTimestamp(NOW, true))
95
-
96
- return () => {
97
- if (!existsSync(reportPath)) return
98
-
99
- let footer = readFileSync(resolve(templatesDir, 'report-tail.html'), 'utf-8')
100
- footer = footer.replace('{{TIMESTAMP}}', new Date().toISOString())
101
- appendFileSync(reportPath, footer)
102
- console.log(`\n(nuxt-spec) Visual regression report available at:\nfile://${reportPath}`)
103
-
104
- if (!process.env.CI) {
105
- console.log('(nuxt-spec) Opening report in default browser...')
106
- const openCmd = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'start' : 'xdg-open'
107
- exec(`${openCmd} "${reportPath}"`, (err) => {
108
- if (err) {
109
- console.log('(nuxt-spec) Failed to automatically open report')
110
- }
111
- })
112
- }
113
-
114
- console.log('\n')
115
- }
116
- }
File without changes
File without changes
File without changes