nuxt-spec 0.3.4 → 0.4.0-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.
Files changed (49) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +13 -13
  3. package/app/app.vue +10 -10
  4. package/app/components/NuxtSpecApiTestComponent.vue +24 -24
  5. package/app/components/NuxtSpecTestComponent.vue +9 -9
  6. package/app/components/index.d.ts +7 -7
  7. package/app/components/index.ts +2 -2
  8. package/app/utils/vitest-utils.ts +5 -5
  9. package/bin/_internal/prepublish-check.js +16 -16
  10. package/bin/cli.js +53 -44
  11. package/bin/commands/ci.js +124 -0
  12. package/bin/commands/setup.js +367 -0
  13. package/bin/commands/update.js +126 -0
  14. package/bin/helpers/commands.js +82 -82
  15. package/bin/helpers/constants.js +8 -0
  16. package/bin/helpers/git.js +73 -0
  17. package/config/index.d.ts +36 -33
  18. package/config/index.mjs +147 -132
  19. package/config/report/report-entry.html +6 -0
  20. package/config/report/report-head.html +34 -0
  21. package/{utils/screenshot/report-entry.html → config/report/report-screenshot.html} +8 -8
  22. package/config/report/report-tail.html +4 -0
  23. package/config/templates/.github/workflows/ci-test-update.yml.template +105 -0
  24. package/config/templates/.github/workflows/ci-test-verify.yml.template +68 -0
  25. package/config/templates/.gitlab/ci/ci-test-update.yml.template +69 -0
  26. package/config/templates/.gitlab/ci/ci-test-verify.yml.template +41 -0
  27. package/config/templates/.gitlab-ci.yml.template +6 -0
  28. package/config/templates/pnpm-workspace.yaml.template +19 -19
  29. package/config/templates/vitest.config.ts.template +5 -5
  30. package/config/utils/merge.mjs +43 -43
  31. package/config/utils/options.d.mts +59 -0
  32. package/config/utils/options.mjs +116 -0
  33. package/config/utils/reporter.mjs +409 -0
  34. package/config/utils/warnings.mjs +38 -33
  35. package/modules/spec-options.ts +23 -0
  36. package/nuxt.config.ts +20 -24
  37. package/package.json +37 -31
  38. package/utils/e2e.ts +131 -131
  39. package/utils/helpers/check-params.ts +44 -41
  40. package/utils/helpers/location-hint.ts +30 -30
  41. package/utils/helpers/screenshot-utils.ts +27 -0
  42. package/utils/index.ts +12 -12
  43. package/utils/playwright.ts +28 -20
  44. package/utils/screenshot.ts +201 -172
  45. package/bin/setup.js +0 -287
  46. package/bin/update.js +0 -94
  47. package/utils/screenshot/report-head.html +0 -24
  48. package/utils/screenshot/report-tail.html +0 -4
  49. package/utils/screenshot/report-utils.ts +0 -236
@@ -1,172 +1,201 @@
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 { GotoOptions, NuxtPage } from '@nuxt/test-utils'
8
- import { checkNumberParam, checkPageParam, checkStringParam } from './helpers/check-params'
9
- import { gotoPage } from './e2e'
10
-
11
- /**
12
- * Extra settings object for `compareScreenshot()` function.
13
- * All properties are optional.
14
- */
15
- export type CompareScreenshotOptions = {
16
- /** Event to be awaited before NuxtPage instance is returned (defaults to `'hydration'`) */
17
- waitUntil?: GotoOptions['waitUntil']
18
- /** Name of the PNG file used for baseline storage and comparison (defaults to route and `index.png` for `/`) */
19
- fileName?: string
20
- /** Directory for baseline/current screenshots, relative to project root (defaults to `test/e2e`) */
21
- targetDir?: string
22
- /** CSS selector for a specific element to capture (defaults to full page) */
23
- selector?: string
24
- /** Max ratio of different pixels (0–1). Default: 0 (exact match) */
25
- maxDiffPixelRatio?: number
26
- /** Max absolute number of different pixels. Takes precedence over `maxDiffPixelRatio` when set. Default: 0 (exact match) */
27
- maxDiffPixels?: number
28
- /** Per-pixel color distance threshold (0–1). Lower = stricter. Default: 0.1 */
29
- threshold?: number
30
- }
31
-
32
- /**
33
- * Capture a browser screenshot and compare it against a stored baseline PNG.
34
- * When run with `-u` / `--update`, or when no baseline exists yet, the current
35
- * screenshot is saved as the new baseline.
36
- *
37
- * Comparison uses pixelmatch for perceptual pixel diffing. By default,
38
- * zero differing pixels are allowed (exact match). Set `maxDiffPixelRatio`
39
- * or `maxDiffPixels` to tolerate cross-platform rendering differences.
40
- *
41
- * @param page - Playwright page instance, or a page name string (will call `gotoPage` internally)
42
- * @param options - Optional extra settings (see `CompareScreenshotOptions`)
43
- * @returns `true` when the screenshot matches the baseline (or a new baseline was saved)
44
- * @throws Fails the current Vitest test when a mismatch is detected
45
- */
46
- export async function compareScreenshot(page: NuxtPage | string, options?: CompareScreenshotOptions): Promise<boolean> {
47
- const { waitUntil, fileName, targetDir, selector, maxDiffPixelRatio, maxDiffPixels, threshold } = options || {}
48
-
49
- // get NuxtPage instance
50
- const pageInstance = typeof page === 'string' ? await gotoPage(page, { waitUntil }) : page
51
- checkPageParam('compareScreenshot:pageInstance', pageInstance)
52
-
53
- // verify params
54
- if (typeof page === 'string') {
55
- checkStringParam('compareScreenshot:page', page)
56
- }
57
- if (waitUntil) {
58
- checkStringParam('compareScreenshot:options.waitUntil', waitUntil)
59
- }
60
- if (fileName) {
61
- checkStringParam('compareScreenshot:options.fileName', fileName)
62
- }
63
- if (targetDir) {
64
- checkStringParam('compareScreenshot:options.targetDir', targetDir)
65
- }
66
- if (selector) {
67
- checkStringParam('compareScreenshot:options.selector', selector)
68
- }
69
- if (maxDiffPixelRatio !== undefined) {
70
- checkNumberParam('compareScreenshot:options.maxDiffPixelRatio', maxDiffPixelRatio)
71
- }
72
- if (maxDiffPixels !== undefined) {
73
- checkNumberParam('compareScreenshot:options.maxDiffPixels', maxDiffPixels)
74
- }
75
- if (threshold !== undefined) {
76
- checkNumberParam('compareScreenshot:options.threshold', threshold)
77
- }
78
-
79
- // ensure the target directory stays within the project root
80
- const root = process.cwd()
81
- const dir = resolveWithin(root, targetDir ?? 'test/e2e')
82
- mkdirSync(dir, { recursive: true })
83
-
84
- // ensure baseline/current directories exist
85
- const baselineDir = resolve(dir, '__baseline__')
86
- mkdirSync(baselineDir, { recursive: true })
87
- const currentDir = resolve(dir, '__current__')
88
- mkdirSync(currentDir, { recursive: true })
89
-
90
- // create report file on first call
91
- ensureReportCreated(dir)
92
-
93
- // compute screenshot file name
94
- const route = pageInstance.url().substring(pageInstance.url().lastIndexOf('/') + 1) || 'index'
95
- const screenshotFile = fileName ?? `${route}.png`
96
-
97
- // warning on custom non-png file extensions
98
- if (!screenshotFile.toLowerCase().endsWith('.png')) {
99
- console.warn(`Screenshots from \`compareScreenshot\` are always saved as PNG. Consider different file name than '${screenshotFile}'.`)
100
- }
101
-
102
- // ensure the file name cannot escape its target directory
103
- const baselinePath = resolveWithin(baselineDir, screenshotFile)
104
- const currentPath = resolveWithin(currentDir, screenshotFile)
105
-
106
- // capture element specified by locator or a full-page screenshot as PNG
107
- const screenshot = selector
108
- ? await pageInstance.locator(selector).screenshot()
109
- : await pageInstance.screenshot({ fullPage: true })
110
-
111
- // always save the current screenshot for inspection
112
- writeFileSync(currentPath, screenshot)
113
-
114
- // save baseline if not exist yet
115
- if (!existsSync(baselinePath)) {
116
- writeFileSync(baselinePath, screenshot)
117
- return true
118
- }
119
-
120
- // @ts-expect-error - this is reliable way of reading Vitest "update" flag
121
- const updateFlag = expect.getState().snapshotState?._updateSnapshot === 'all'
122
-
123
- // compare against stored baseline PNG using pixelmatch
124
- const baseline = readFileSync(baselinePath)
125
- const baselineImg = decode(baseline)
126
- const actualImg = decode(screenshot)
127
- const { width, height } = baselineImg
128
-
129
- if (actualImg.width !== width || actualImg.height !== height) {
130
- // overwrite baseline if Vitest update flag is set
131
- if (updateFlag) {
132
- writeFileSync(baselinePath, screenshot)
133
- return true
134
- }
135
- // otherwise report failure
136
- 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)
138
- expect.fail(message)
139
- }
140
-
141
- const diffCount = pixelmatch(toRGBA(baselineImg), toRGBA(actualImg), undefined, width, height, {
142
- threshold: threshold ?? 0.1,
143
- })
144
-
145
- const totalPixels = width * height
146
- const maxAllowed = maxDiffPixels ?? Math.ceil(totalPixels * (maxDiffPixelRatio ?? 0))
147
-
148
- if (diffCount > maxAllowed) {
149
- // overwrite baseline if Vitest update flag is set
150
- if (updateFlag) {
151
- writeFileSync(baselinePath, screenshot)
152
- return true
153
- }
154
- // otherwise report failure
155
- const ratio = (diffCount / totalPixels * 100).toFixed(2)
156
- const message = `Screenshot mismatch: ${diffCount} pixels differ (${ratio}%), allowed ${maxAllowed}. Actual saved to: ${currentPath}`
157
- appendToReport(screenshotFile, message, baseline, screenshot)
158
- expect.fail(message)
159
- }
160
-
161
- return true
162
- }
163
-
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()
172
- }
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { decode } from 'fast-png'
4
+ import { expect, recordArtifact, TestRunner } from 'vitest'
5
+ import type { TestArtifactBase, TestAttachment } from 'vitest'
6
+ import { resolveWithin, toRGBA } from './helpers/screenshot-utils'
7
+ import pixelmatch from 'pixelmatch'
8
+ import type { GotoOptions, NuxtPage } from '@nuxt/test-utils'
9
+ import { checkNumberParam, checkPageParam, checkStringParam } from './helpers/check-params'
10
+ import { gotoPage } from './e2e'
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
+
31
+ /**
32
+ * Extra settings object for `compareScreenshot()` function.
33
+ * All properties are optional.
34
+ */
35
+ export type CompareScreenshotOptions = {
36
+ /** Event to be awaited before NuxtPage instance is returned (defaults to `'hydration'`) */
37
+ waitUntil?: GotoOptions['waitUntil']
38
+ /** Name of the PNG file used for baseline storage and comparison (defaults to route and `index.png` for `/`) */
39
+ fileName?: string
40
+ /** Directory for baseline/current screenshots, relative to project root (defaults to `test/e2e`) */
41
+ targetDir?: string
42
+ /** CSS selector for a specific element to capture (defaults to full page) */
43
+ selector?: string
44
+ /** Max ratio of different pixels (0–1). Default: 0 (exact match) */
45
+ maxDiffPixelRatio?: number
46
+ /** Max absolute number of different pixels. Takes precedence over `maxDiffPixelRatio` when set. Default: 0 (exact match) */
47
+ maxDiffPixels?: number
48
+ /** Per-pixel color distance threshold (0–1). Lower = stricter. Default: 0.1 */
49
+ threshold?: number
50
+ }
51
+
52
+ /**
53
+ * Capture a browser screenshot and compare it against a stored baseline PNG.
54
+ * When run with `-u` / `--update`, or when no baseline exists yet, the current
55
+ * screenshot is saved as the new baseline.
56
+ *
57
+ * Comparison uses pixelmatch for perceptual pixel diffing. By default,
58
+ * zero differing pixels are allowed (exact match). Set `maxDiffPixelRatio`
59
+ * or `maxDiffPixels` to tolerate cross-platform rendering differences.
60
+ *
61
+ * @param page - Playwright page instance, or a page name string (will call `gotoPage` internally)
62
+ * @param options - Optional extra settings (see `CompareScreenshotOptions`)
63
+ * @returns `true` when the screenshot matches the baseline (or a new baseline was saved)
64
+ * @throws Fails the current Vitest test when a mismatch is detected
65
+ */
66
+ export async function compareScreenshot(page: NuxtPage | string, options?: CompareScreenshotOptions): Promise<boolean> {
67
+ const { waitUntil, fileName, targetDir, selector, maxDiffPixelRatio, maxDiffPixels, threshold } = options || {}
68
+
69
+ // get NuxtPage instance
70
+ const pageInstance = typeof page === 'string' ? await gotoPage(page, { waitUntil }) : page
71
+ checkPageParam('compareScreenshot:pageInstance', pageInstance)
72
+
73
+ // verify params
74
+ if (typeof page === 'string') {
75
+ checkStringParam('compareScreenshot:page', page)
76
+ }
77
+ if (waitUntil) {
78
+ checkStringParam('compareScreenshot:options.waitUntil', waitUntil)
79
+ }
80
+ if (fileName) {
81
+ checkStringParam('compareScreenshot:options.fileName', fileName)
82
+ }
83
+ if (targetDir) {
84
+ checkStringParam('compareScreenshot:options.targetDir', targetDir)
85
+ }
86
+ if (selector) {
87
+ checkStringParam('compareScreenshot:options.selector', selector)
88
+ }
89
+ if (maxDiffPixelRatio !== undefined) {
90
+ checkNumberParam('compareScreenshot:options.maxDiffPixelRatio', maxDiffPixelRatio)
91
+ }
92
+ if (maxDiffPixels !== undefined) {
93
+ checkNumberParam('compareScreenshot:options.maxDiffPixels', maxDiffPixels)
94
+ }
95
+ if (threshold !== undefined) {
96
+ checkNumberParam('compareScreenshot:options.threshold', threshold)
97
+ }
98
+
99
+ // ensure the target directory stays within the project root
100
+ const root = process.cwd()
101
+ const dir = resolveWithin(root, targetDir ?? 'test/e2e')
102
+ mkdirSync(dir, { recursive: true })
103
+
104
+ // ensure baseline/current directories exist
105
+ const baselineDir = resolve(dir, '__baseline__')
106
+ mkdirSync(baselineDir, { recursive: true })
107
+ const currentDir = resolve(dir, '__current__')
108
+ mkdirSync(currentDir, { recursive: true })
109
+
110
+ // compute screenshot file name
111
+ const route = pageInstance.url().substring(pageInstance.url().lastIndexOf('/') + 1) || 'index'
112
+ const screenshotFile = fileName ?? `${route}.png`
113
+
114
+ // warning on custom non-png file extensions
115
+ if (!screenshotFile.toLowerCase().endsWith('.png')) {
116
+ console.warn(`[Nuxt Spec] Screenshots from \`compareScreenshot\` are always saved as PNG. Consider different file name than '${screenshotFile}'.`)
117
+ }
118
+
119
+ // ensure the file name cannot escape its target directory
120
+ const baselinePath = resolveWithin(baselineDir, screenshotFile)
121
+ const currentPath = resolveWithin(currentDir, screenshotFile)
122
+
123
+ // capture element specified by locator or a full-page screenshot as PNG
124
+ const screenshot = selector
125
+ ? await pageInstance.locator(selector).screenshot()
126
+ : await pageInstance.screenshot({ fullPage: true })
127
+
128
+ // always save the current screenshot for inspection
129
+ writeFileSync(currentPath, screenshot)
130
+
131
+ // save baseline if not exist yet
132
+ if (!existsSync(baselinePath)) {
133
+ writeFileSync(baselinePath, screenshot)
134
+ return true
135
+ }
136
+
137
+ // @ts-expect-error - this is reliable way of reading Vitest "update" flag
138
+ const updateFlag = expect.getState().snapshotState?._updateSnapshot === 'all'
139
+
140
+ // compare against stored baseline PNG using pixelmatch
141
+ const baseline = readFileSync(baselinePath)
142
+ const baselineImg = decode(baseline)
143
+ const actualImg = decode(screenshot)
144
+ const { width, height } = baselineImg
145
+
146
+ if (actualImg.width !== width || actualImg.height !== height) {
147
+ // overwrite baseline if Vitest update flag is set
148
+ if (updateFlag) {
149
+ writeFileSync(baselinePath, screenshot)
150
+ return true
151
+ }
152
+ // otherwise report failure
153
+ const message = `Screenshot size mismatch: expected ${width}x${height}, got ${actualImg.width}x${actualImg.height}. Actual saved to: ${currentPath}`
154
+ await reportMismatch(screenshotFile, message, baseline, screenshot)
155
+ expect.fail(message)
156
+ }
157
+
158
+ const diffCount = pixelmatch(toRGBA(baselineImg), toRGBA(actualImg), undefined, width, height, {
159
+ threshold: threshold ?? 0.1,
160
+ })
161
+
162
+ const totalPixels = width * height
163
+ const maxAllowed = maxDiffPixels ?? Math.ceil(totalPixels * (maxDiffPixelRatio ?? 0))
164
+
165
+ if (diffCount > maxAllowed) {
166
+ // overwrite baseline if Vitest update flag is set
167
+ if (updateFlag) {
168
+ writeFileSync(baselinePath, screenshot)
169
+ return true
170
+ }
171
+ // otherwise report failure
172
+ const ratio = (diffCount / totalPixels * 100).toFixed(2)
173
+ const message = `Screenshot mismatch: ${diffCount} pixels differ (${ratio}%), allowed ${maxAllowed}. Actual saved to: ${currentPath}`
174
+ await reportMismatch(screenshotFile, message, baseline, screenshot)
175
+ expect.fail(message)
176
+ }
177
+
178
+ return true
179
+ }
180
+
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
+ }
201
+ }