nuxt-spec 0.2.3 → 0.2.4-alpha

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-compare'
3
+ import type { CompareScreenshotOptions } from './screenshot-compare'
4
+
5
+ export {
6
+ compareScreenshot,
7
+ gotoPage,
8
+ getDataHtml,
9
+ getAPIResultHtml,
10
+ }
11
+
12
+ export type { CompareScreenshotOptions }
@@ -1,89 +1,151 @@
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 { 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'
5
+ import { expect } from 'vitest'
6
+ import { ensureReportCreated, getInjection } from './screenshot-report'
7
+ import pixelmatch from 'pixelmatch'
8
+ import type { NuxtPage } from '@nuxt/test-utils'
9
+
10
+ export interface CompareScreenshotOptions {
11
+ /** Name of the PNG file used for baseline storage and comparison (defaults to route and `index.png` for `/`) */
12
+ fileName?: string
13
+ /** Directory for baseline/current screenshots, relative to project root (defaults to `test/e2e`) */
14
+ targetDir?: string
15
+ /** CSS selector for a specific element to capture (defaults to full page) */
16
+ selector?: string
17
+ /** Max ratio of different pixels (0–1). Default: 0 (exact match) */
18
+ maxDiffPixelRatio?: number
19
+ /** Max absolute number of different pixels. Takes precedence over `maxDiffPixelRatio` when set. Default: 0 (exact match) */
20
+ maxDiffPixels?: number
21
+ /** Per-pixel color distance threshold (0–1). Lower = stricter. Default: 0.1 */
22
+ threshold?: number
23
+ }
24
+
25
+ // capture a browser screenshot and compare it against a stored baseline PNG
26
+ export async function compareScreenshot(page: NuxtPage, options?: CompareScreenshotOptions): Promise<boolean> {
27
+ // create report file on first call
28
+ ensureReportCreated()
29
+
30
+ const root = process.cwd()
31
+
32
+ // ensure the target directory stays within the project root
33
+ const dir = resolveWithin(root, options?.targetDir ?? 'test/e2e')
34
+
35
+ const baselineDir = resolve(dir, '__baseline__')
36
+ const currentDir = resolve(dir, '__current__')
37
+
38
+ const route = page.url().substring(page.url().lastIndexOf('/') + 1) || 'index'
39
+ const fileName = options?.fileName ?? `${route}.png`
40
+
41
+ // warning on custom non-png file extensions
42
+ if (!fileName.toLowerCase().endsWith('.png')) {
43
+ console.warn(`Screenshots from \`compareScreenshot\` are always saved as PNG. Consider different file name than '${fileName}'.`)
44
+ }
45
+
46
+ // ensure the file name cannot escape its target directory
47
+ const baselinePath = resolveWithin(baselineDir, fileName)
48
+ const currentPath = resolveWithin(currentDir, fileName)
49
+
50
+ // capture element specified by locator or a full-page screenshot as PNG
51
+ const screenshot = options?.selector
52
+ ? await page.locator(options.selector).screenshot()
53
+ : await page.screenshot({ fullPage: true })
54
+
55
+ // always save the current screenshot for inspection
56
+ mkdirSync(currentDir, { recursive: true })
57
+ writeFileSync(currentPath, screenshot)
58
+
59
+ // @ts-expect-error - this is reliable way of reading Vitest "update" flag
60
+ const updating = expect.getState().snapshotState?._updateSnapshot === 'all'
61
+ if (updating || !existsSync(baselinePath)) {
62
+ // save new baseline screenshot
63
+ mkdirSync(baselineDir, { recursive: true })
64
+ writeFileSync(baselinePath, screenshot)
65
+ return true
66
+ }
67
+
68
+ // compare against stored baseline PNG using pixelmatch
69
+ const baseline = readFileSync(baselinePath)
70
+ const baselineImg = decode(baseline)
71
+ const actualImg = decode(screenshot)
72
+ const { width, height } = baselineImg
73
+
74
+ if (actualImg.width !== width || actualImg.height !== height) {
75
+ const message = `Screenshot size mismatch: expected ${width}x${height}, got ${actualImg.width}x${actualImg.height}. Actual saved to: ${currentPath}`
76
+ appendToReport(fileName, message, baseline, screenshot)
77
+ expect.fail(message)
78
+ }
79
+
80
+ const diffCount = pixelmatch(toRGBA(baselineImg), toRGBA(actualImg), undefined, width, height, {
81
+ threshold: options?.threshold ?? 0.1,
82
+ })
83
+
84
+ const totalPixels = width * height
85
+ const maxAllowed = options?.maxDiffPixels ?? Math.ceil(totalPixels * (options?.maxDiffPixelRatio ?? 0))
86
+
87
+ if (diffCount > maxAllowed) {
88
+ const ratio = (diffCount / totalPixels * 100).toFixed(2)
89
+ const message = `Screenshot mismatch: ${diffCount} pixels differ (${ratio}%), allowed ${maxAllowed}. Actual saved to: ${currentPath}`
90
+ appendToReport(fileName, message, baseline, screenshot)
91
+ expect.fail(message)
92
+ }
93
+
94
+ return true
95
+ }
96
+
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
151
+ }
@@ -0,0 +1,116 @@
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
+ }