nuxt-spec 0.4.0-alpha.2 → 0.4.0-alpha.3

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 (44) 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/config/index.d.ts +36 -36
  10. package/config/index.mjs +147 -147
  11. package/config/report/report-entry.html +6 -6
  12. package/config/report/report-head.html +34 -34
  13. package/config/report/report-screenshot.html +8 -8
  14. package/config/report/report-tail.html +4 -4
  15. package/config/templates/.github/workflows/ci-test-update.yml.template +105 -105
  16. package/config/templates/.github/workflows/ci-test-verify.yml.template +68 -68
  17. package/config/templates/.gitlab/ci/ci-test-update.yml.template +69 -69
  18. package/config/templates/.gitlab/ci/ci-test-verify.yml.template +41 -41
  19. package/config/templates/.gitlab-ci.yml.template +6 -6
  20. package/config/templates/pnpm-workspace.yaml.template +19 -19
  21. package/config/templates/vitest.config.ts.template +5 -5
  22. package/config/utils/merge.mjs +43 -43
  23. package/config/utils/options.d.mts +59 -59
  24. package/config/utils/options.mjs +116 -116
  25. package/config/utils/reporter.mjs +409 -409
  26. package/config/utils/warnings.mjs +38 -38
  27. package/modules/spec-options.ts +23 -23
  28. package/nuxt.config.ts +20 -20
  29. package/package.json +17 -23
  30. package/utils/e2e.ts +131 -131
  31. package/utils/helpers/check-params.ts +44 -44
  32. package/utils/helpers/location-hint.ts +30 -30
  33. package/utils/helpers/screenshot-utils.ts +27 -27
  34. package/utils/index.ts +12 -12
  35. package/utils/playwright.ts +28 -28
  36. package/utils/screenshot.ts +201 -201
  37. package/bin/_internal/prepublish-check.js +0 -16
  38. package/bin/cli.js +0 -53
  39. package/bin/commands/ci.js +0 -124
  40. package/bin/commands/setup.js +0 -367
  41. package/bin/commands/update.js +0 -126
  42. package/bin/helpers/commands.js +0 -82
  43. package/bin/helpers/constants.js +0 -8
  44. package/bin/helpers/git.js +0 -73
@@ -1,409 +1,409 @@
1
- // HTML test report for Nuxt Spec
2
- // - `NuxtSpecHtmlReporter` is a Vitest reporter that renders a self-contained HTML file
3
- // into `<root>/test/__reports__/report-YYYYMMDDHHMMSS.html` after each test run
4
- // - `nuxtSpecReportPlugin` is a Vite plugin that appends the reporter to whatever reporters
5
- // are in effect (CLI `--reporter`, user config or Vitest defaults)
6
- // - `open` option (always|failed|never) controls opening the report in browser
7
-
8
- import { exec } from 'node:child_process'
9
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
10
- import { platform } from 'node:os'
11
- import { relative, resolve } from 'node:path'
12
- import { fileURLToPath, pathToFileURL } from 'node:url'
13
-
14
- /** Artifact type recorded by `compareScreenshot` on mismatch */
15
- export const SCREENSHOT_ARTIFACT_TYPE = 'nuxt-spec:screenshot'
16
-
17
- /** Relative path (from Vitest root) where report files are stored */
18
- export const REPORT_DIR = 'test/__reports__'
19
-
20
- const OPEN_MODES = ['always', 'failed', 'never']
21
-
22
- // marker used to identify the reporter instance even if this module is loaded more than once
23
- const REPORTER_MARK = Symbol.for('nuxt-spec:html-reporter')
24
-
25
- const templatesDir = fileURLToPath(new URL('../report/', import.meta.url))
26
- const loadTemplate = name => readFileSync(resolve(templatesDir, name), 'utf-8')
27
-
28
- /**
29
- * Vite plugin that registers `NuxtSpecHtmlReporter`.
30
- * The `configureVitest` hook runs after Vitest merged CLI and config reporters,
31
- * but before reporters are instantiated, so the HTML reporter is always appended.
32
- * @param {ReporterOptions} [options] - reporter options
33
- */
34
- export function nuxtSpecReportPlugin(options = {}) {
35
- return {
36
- name: 'nuxt-spec:html-report',
37
- configureVitest({ vitest }) {
38
- addReporter(vitest.config, options)
39
- },
40
- }
41
- }
42
-
43
- /**
44
- * Append the Nuxt Spec HTML reporter into resolved Vitest config.
45
- * The hook is called once per project, so duplicates must be avoided.
46
- * @param {{ reporters?: unknown[] }} config - resolved Vitest config
47
- * @param {ReporterOptions} [options] - reporter options
48
- */
49
- export function addReporter(config, options = {}) {
50
- config.reporters ??= []
51
- if (config.reporters.some(r => r?.[REPORTER_MARK])) return
52
- config.reporters.push(new NuxtSpecHtmlReporter(options))
53
- }
54
-
55
- /**
56
- * @typedef {{ open?: string }} ReporterOptions
57
- */
58
-
59
- export class NuxtSpecHtmlReporter {
60
- [REPORTER_MARK] = true
61
-
62
- /** @param {ReporterOptions} [options] */
63
- constructor({ open } = {}) {
64
- this.open = open
65
- }
66
-
67
- onInit(vitest) {
68
- this.vitest = vitest
69
- }
70
-
71
- onTestRunStart() {
72
- this.startedAt = new Date()
73
- }
74
-
75
- onTestRunEnd(testModules, unhandledErrors, reason) {
76
- const startedAt = this.startedAt ?? new Date()
77
- const root = this.vitest?.config?.root ?? process.cwd()
78
- const watch = !!this.vitest?.config?.watch
79
- const log = (msg) => {
80
- if (this.vitest?.logger) this.vitest.logger.log(msg)
81
- else console.log(msg)
82
- }
83
-
84
- const data = collectReportData(testModules, unhandledErrors, reason, root)
85
- const html = renderReport(data, reportTimestamp(startedAt, true), new Date().toISOString())
86
-
87
- const reportDir = resolve(root, REPORT_DIR)
88
- const reportPath = resolve(reportDir, `report-${reportTimestamp(startedAt)}.html`)
89
- try {
90
- mkdirSync(reportDir, { recursive: true })
91
- writeFileSync(reportPath, html)
92
- } catch (error) {
93
- log(`\n[Nuxt Spec] Failed to write HTML report: ${error?.message ?? error}`)
94
- return
95
- }
96
-
97
- log(`\n[Nuxt Spec] Test report available at:\n${pathToFileURL(reportPath).href}`)
98
-
99
- const shouldOpen = shouldOpenReport({
100
- mode: this.open,
101
- hasFailure: data.hasFailure,
102
- ci: !!process.env.CI,
103
- watch,
104
- })
105
- if (shouldOpen) {
106
- log('[Nuxt Spec] Opening report in default browser...')
107
- openInBrowser(reportPath, () => log('[Nuxt Spec] Failed to automatically open report'))
108
- }
109
- }
110
- }
111
-
112
- /**
113
- * Decide whether the report should be opened in the default browser.
114
- * Never opens in CI or watch mode. Invalid `mode` falls back to `failed`.
115
- * @param {{ mode?: string, hasFailure: boolean, ci: boolean, watch: boolean }} options
116
- */
117
- export function shouldOpenReport({ mode, hasFailure, ci, watch }) {
118
- if (ci || watch) return false
119
- const normalized = mode?.trim().toLowerCase() || 'failed'
120
- const effective = OPEN_MODES.includes(normalized) ? normalized : 'failed'
121
- if (effective === 'always') return true
122
- if (effective === 'never') return false
123
- return hasFailure
124
- }
125
-
126
- /**
127
- * @typedef {{ message: string, diff?: string, stack?: string }} ReportError
128
- * @typedef {{ fileName: string, message: string, baselineUri?: string, actualUri?: string }} ReportScreenshot
129
- * @typedef {{ project: string, file: string, name: string, errors: ReportError[], screenshots: ReportScreenshot[] }} ReportFailure
130
- * @typedef {{ project: string, file: string, suite: string, errors: ReportError[] }} ReportModuleError
131
- * @typedef {{
132
- * total: number,
133
- * passed: number,
134
- * failed: number,
135
- * skipped: number,
136
- * reason: 'passed' | 'failed' | 'interrupted',
137
- * failures: ReportFailure[],
138
- * moduleErrors: ReportModuleError[],
139
- * unhandledErrors: ReportError[],
140
- * hasFailure: boolean,
141
- * }} ReportData
142
- */
143
-
144
- /**
145
- * Transform Vitest reported entities into plain report data.
146
- * @param {ReadonlyArray<any>} testModules - `TestModule` instances from `onTestRunEnd`
147
- * @param {ReadonlyArray<any>} unhandledErrors - serialized unhandled errors
148
- * @param {'passed' | 'failed' | 'interrupted'} reason - run end reason
149
- * @param {string} root - project root used to compute relative paths
150
- * @returns {ReportData}
151
- */
152
- export function collectReportData(testModules, unhandledErrors, reason, root) {
153
- /** @type {ReportData} */
154
- const data = {
155
- total: 0,
156
- passed: 0,
157
- failed: 0,
158
- skipped: 0,
159
- reason,
160
- failures: [],
161
- moduleErrors: [],
162
- unhandledErrors: (unhandledErrors ?? []).map(toErrorData),
163
- hasFailure: false,
164
- }
165
-
166
- for (const testModule of testModules ?? []) {
167
- const project = testModule.project?.name || ''
168
- const file = toRelative(root, testModule.moduleId)
169
-
170
- // errors outside of tests - module collection errors or failed `beforeAll`/`afterAll` hooks
171
- const moduleErrors = testModule.errors?.() ?? []
172
- if (moduleErrors.length > 0) {
173
- data.moduleErrors.push({ project, file, suite: '', errors: moduleErrors.map(toErrorData) })
174
- }
175
- for (const testSuite of testModule.children?.allSuites() ?? []) {
176
- const suiteErrors = testSuite.errors?.() ?? []
177
- if (suiteErrors.length > 0) {
178
- data.moduleErrors.push({ project, file, suite: testSuite.fullName ?? testSuite.name, errors: suiteErrors.map(toErrorData) })
179
- }
180
- }
181
-
182
- for (const testCase of testModule.children?.allTests() ?? []) {
183
- const result = testCase.result()
184
- if (result.state === 'skipped') {
185
- data.skipped++
186
- continue
187
- }
188
- data.total++
189
- if (result.state === 'passed') {
190
- data.passed++
191
- } else if (result.state === 'failed') {
192
- data.failed++
193
- data.failures.push({
194
- project,
195
- file,
196
- name: testCase.fullName ?? testCase.name,
197
- errors: (result.errors ?? []).map(toErrorData),
198
- screenshots: (testCase.artifacts?.() ?? [])
199
- .filter(a => a?.type === SCREENSHOT_ARTIFACT_TYPE)
200
- .map(toScreenshotData),
201
- })
202
- }
203
- }
204
- }
205
-
206
- data.hasFailure = data.failed > 0
207
- || data.moduleErrors.length > 0
208
- || data.unhandledErrors.length > 0
209
- || reason !== 'passed'
210
-
211
- return data
212
- }
213
-
214
- /**
215
- * Render report data (see `collectReportData`) into a self-contained HTML document.
216
- * @param {ReportData} data
217
- * @param {string} titleTimestamp - timestamp displayed in the report title
218
- * @param {string} footerTimestamp - timestamp displayed in the report footer
219
- */
220
- export function renderReport(data, titleTimestamp, footerTimestamp) {
221
- const entries = []
222
-
223
- for (const failure of data.failures) {
224
- entries.push(renderEntry({
225
- title: failure.name,
226
- location: formatLocation(failure.project, failure.file),
227
- errors: failure.errors,
228
- screenshots: failure.screenshots,
229
- }))
230
- }
231
-
232
- for (const moduleError of data.moduleErrors) {
233
- entries.push(renderEntry({
234
- title: moduleError.suite ? `Error in suite: ${moduleError.suite}` : 'Error in test module',
235
- location: formatLocation(moduleError.project, moduleError.file),
236
- errors: moduleError.errors,
237
- }))
238
- }
239
-
240
- for (const error of data.unhandledErrors) {
241
- entries.push(renderEntry({
242
- title: 'Unhandled error',
243
- location: 'Error was thrown outside of a test',
244
- errors: [error],
245
- }))
246
- }
247
-
248
- const head = fillTemplate(loadTemplate('report-head.html'), {
249
- TIMESTAMP: escapeHtml(titleTimestamp),
250
- STATUS: data.hasFailure ? 'failed' : 'passed',
251
- SUMMARY: escapeHtml(formatSummary(data)),
252
- })
253
- const tail = fillTemplate(loadTemplate('report-tail.html'), {
254
- TIMESTAMP: escapeHtml(footerTimestamp),
255
- })
256
-
257
- return head + entries.join('') + tail
258
- }
259
-
260
- /**
261
- * Build the one-line summary, e.g. `5/5 tests passed` or `3/5 tests passed, 2 failed`.
262
- * @param {ReportData} data
263
- */
264
- export function formatSummary(data) {
265
- const parts = [`${data.passed}/${data.total} tests passed`]
266
- if (data.failed > 0) parts.push(`${data.failed} failed`)
267
- if (data.skipped > 0) parts.push(`${data.skipped} skipped`)
268
- if (data.moduleErrors.length > 0) parts.push(`${data.moduleErrors.length} module/suite error(s)`)
269
- if (data.unhandledErrors.length > 0) parts.push(`${data.unhandledErrors.length} unhandled error(s)`)
270
- let summary = parts.join(', ')
271
- if (data.reason === 'interrupted') summary += ' (test run was interrupted)'
272
- else if (data.hasFailure && data.failed === 0 && data.moduleErrors.length === 0 && data.unhandledErrors.length === 0) {
273
- summary += ' (test run failed)'
274
- }
275
- return summary
276
- }
277
-
278
- function renderEntry({ title, location, errors, screenshots = [] }) {
279
- return fillTemplate(loadTemplate('report-entry.html'), {
280
- TITLE: escapeHtml(title),
281
- LOCATION: escapeHtml(location),
282
- ERRORS: errors.map(renderError).join('\n'),
283
- SCREENSHOTS: screenshots.map(renderScreenshot).join('\n'),
284
- })
285
- }
286
-
287
- function renderError(error) {
288
- let html = `<pre class="message">${escapeHtml(error.message)}</pre>`
289
- if (error.diff) {
290
- html += `<details open><summary>Diff</summary><pre class="diff">${escapeHtml(error.diff)}</pre></details>`
291
- }
292
- if (error.stack) {
293
- html += `<details><summary>Stack trace</summary><pre class="stack">${escapeHtml(error.stack)}</pre></details>`
294
- }
295
- return html
296
- }
297
-
298
- function renderScreenshot(screenshot) {
299
- // attachments may be missing (e.g. stripped by Vitest), show at least the message
300
- if (!screenshot.baselineUri || !screenshot.actualUri) {
301
- return `<div class="screenshot"><h3>${escapeHtml(screenshot.fileName)}</h3><p class="message">${escapeHtml(screenshot.message)}</p></div>`
302
- }
303
- return fillTemplate(loadTemplate('report-screenshot.html'), {
304
- FILE_NAME: escapeHtml(screenshot.fileName),
305
- MESSAGE: escapeHtml(screenshot.message),
306
- BASELINE_URI: screenshot.baselineUri,
307
- ACTUAL_URI: screenshot.actualUri,
308
- })
309
- }
310
-
311
- function toErrorData(error) {
312
- const name = error?.name && error.name !== 'Error' ? `${error.name}: ` : ''
313
- return {
314
- message: stripAnsi(`${name}${error?.message ?? String(error)}`),
315
- diff: error?.diff ? stripAnsi(error.diff) : undefined,
316
- stack: error?.stack ? stripAnsi(error.stack) : undefined,
317
- }
318
- }
319
-
320
- function toScreenshotData(artifact) {
321
- const attachments = artifact.attachments ?? []
322
- const findUri = (name) => {
323
- const attachment = attachments.find(a => a?.name === name)
324
- if (!attachment?.body || typeof attachment.body !== 'string') return undefined
325
- if (attachment.bodyEncoding === 'utf-8') return undefined
326
- // only accept valid base64 payload and an image MIME type to keep the `src` attribute safe
327
- if (!BASE64_REGEX.test(attachment.body)) return undefined
328
- const contentType = IMAGE_MIME_REGEX.test(attachment.contentType ?? '') ? attachment.contentType : 'image/png'
329
- return `data:${contentType};base64,${attachment.body}`
330
- }
331
- return {
332
- fileName: artifact.fileName ?? '',
333
- message: artifact.message ?? '',
334
- baselineUri: findUri('baseline'),
335
- actualUri: findUri('actual'),
336
- }
337
- }
338
-
339
- function formatLocation(project, file) {
340
- return project ? `[${project}] ${file}` : file
341
- }
342
-
343
- function toRelative(root, path) {
344
- if (!path) return ''
345
- const rel = relative(root, path)
346
- return rel && !rel.startsWith('..') ? rel.replaceAll('\\', '/') : path
347
- }
348
-
349
- function openInBrowser(path, onError) {
350
- const openCmd
351
- = platform() === 'darwin'
352
- ? `open "${path}"`
353
- : platform() === 'win32'
354
- ? `start "" "${path}"`
355
- : `xdg-open "${path}"`
356
- exec(openCmd, (err) => {
357
- if (err) onError()
358
- })
359
- }
360
-
361
- // replace `{{KEY}}` placeholders
362
- // function replacer avoids special `$` patterns of String.replace
363
- function fillTemplate(template, values) {
364
- return template.replace(/\{\{(\w+)\}\}/g, (match, key) => key in values ? values[key] : match)
365
- }
366
-
367
- // timestamp string
368
- // separators=false produces YYYYMMDDHHMMSS for report file name
369
- // separators=true produces YYYY-MM-DD HH:MM:SS for report title
370
- export function reportTimestamp(date, separators = false) {
371
- const pad2 = n => String(n).padStart(2, '0')
372
- const dateSeparator = separators ? '-' : ''
373
- const midSeparator = separators ? ' ' : ''
374
- const timeSeparator = separators ? ':' : ''
375
- return [
376
- date.getFullYear(),
377
- dateSeparator,
378
- pad2(date.getMonth() + 1),
379
- dateSeparator,
380
- pad2(date.getDate()),
381
- midSeparator,
382
- pad2(date.getHours()),
383
- timeSeparator,
384
- pad2(date.getMinutes()),
385
- timeSeparator,
386
- pad2(date.getSeconds()),
387
- ].join('')
388
- }
389
-
390
- const BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/
391
- const IMAGE_MIME_REGEX = /^image\/[a-z0-9.+-]+$/i
392
-
393
- // eslint-disable-next-line no-control-regex
394
- const ANSI_REGEX = /\u001B\[[0-9;?]*[ -/]*[@-~]/g
395
-
396
- function stripAnsi(value) {
397
- return String(value).replace(ANSI_REGEX, '')
398
- }
399
-
400
- // helper to escape a string for safe interpolation into the HTML report
401
- function escapeHtml(value) {
402
- return String(value ?? '').replace(/[&<>"']/g, char => ({
403
- '&': '&amp;',
404
- '<': '&lt;',
405
- '>': '&gt;',
406
- '"': '&quot;',
407
- '\'': '&#39;',
408
- }[char] ?? char))
409
- }
1
+ // HTML test report for Nuxt Spec
2
+ // - `NuxtSpecHtmlReporter` is a Vitest reporter that renders a self-contained HTML file
3
+ // into `<root>/test/__reports__/report-YYYYMMDDHHMMSS.html` after each test run
4
+ // - `nuxtSpecReportPlugin` is a Vite plugin that appends the reporter to whatever reporters
5
+ // are in effect (CLI `--reporter`, user config or Vitest defaults)
6
+ // - `open` option (always|failed|never) controls opening the report in browser
7
+
8
+ import { exec } from 'node:child_process'
9
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
10
+ import { platform } from 'node:os'
11
+ import { relative, resolve } from 'node:path'
12
+ import { fileURLToPath, pathToFileURL } from 'node:url'
13
+
14
+ /** Artifact type recorded by `compareScreenshot` on mismatch */
15
+ export const SCREENSHOT_ARTIFACT_TYPE = 'nuxt-spec:screenshot'
16
+
17
+ /** Relative path (from Vitest root) where report files are stored */
18
+ export const REPORT_DIR = 'test/__reports__'
19
+
20
+ const OPEN_MODES = ['always', 'failed', 'never']
21
+
22
+ // marker used to identify the reporter instance even if this module is loaded more than once
23
+ const REPORTER_MARK = Symbol.for('nuxt-spec:html-reporter')
24
+
25
+ const templatesDir = fileURLToPath(new URL('../report/', import.meta.url))
26
+ const loadTemplate = name => readFileSync(resolve(templatesDir, name), 'utf-8')
27
+
28
+ /**
29
+ * Vite plugin that registers `NuxtSpecHtmlReporter`.
30
+ * The `configureVitest` hook runs after Vitest merged CLI and config reporters,
31
+ * but before reporters are instantiated, so the HTML reporter is always appended.
32
+ * @param {ReporterOptions} [options] - reporter options
33
+ */
34
+ export function nuxtSpecReportPlugin(options = {}) {
35
+ return {
36
+ name: 'nuxt-spec:html-report',
37
+ configureVitest({ vitest }) {
38
+ addReporter(vitest.config, options)
39
+ },
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Append the Nuxt Spec HTML reporter into resolved Vitest config.
45
+ * The hook is called once per project, so duplicates must be avoided.
46
+ * @param {{ reporters?: unknown[] }} config - resolved Vitest config
47
+ * @param {ReporterOptions} [options] - reporter options
48
+ */
49
+ export function addReporter(config, options = {}) {
50
+ config.reporters ??= []
51
+ if (config.reporters.some(r => r?.[REPORTER_MARK])) return
52
+ config.reporters.push(new NuxtSpecHtmlReporter(options))
53
+ }
54
+
55
+ /**
56
+ * @typedef {{ open?: string }} ReporterOptions
57
+ */
58
+
59
+ export class NuxtSpecHtmlReporter {
60
+ [REPORTER_MARK] = true
61
+
62
+ /** @param {ReporterOptions} [options] */
63
+ constructor({ open } = {}) {
64
+ this.open = open
65
+ }
66
+
67
+ onInit(vitest) {
68
+ this.vitest = vitest
69
+ }
70
+
71
+ onTestRunStart() {
72
+ this.startedAt = new Date()
73
+ }
74
+
75
+ onTestRunEnd(testModules, unhandledErrors, reason) {
76
+ const startedAt = this.startedAt ?? new Date()
77
+ const root = this.vitest?.config?.root ?? process.cwd()
78
+ const watch = !!this.vitest?.config?.watch
79
+ const log = (msg) => {
80
+ if (this.vitest?.logger) this.vitest.logger.log(msg)
81
+ else console.log(msg)
82
+ }
83
+
84
+ const data = collectReportData(testModules, unhandledErrors, reason, root)
85
+ const html = renderReport(data, reportTimestamp(startedAt, true), new Date().toISOString())
86
+
87
+ const reportDir = resolve(root, REPORT_DIR)
88
+ const reportPath = resolve(reportDir, `report-${reportTimestamp(startedAt)}.html`)
89
+ try {
90
+ mkdirSync(reportDir, { recursive: true })
91
+ writeFileSync(reportPath, html)
92
+ } catch (error) {
93
+ log(`\n[Nuxt Spec] Failed to write HTML report: ${error?.message ?? error}`)
94
+ return
95
+ }
96
+
97
+ log(`\n[Nuxt Spec] Test report available at:\n${pathToFileURL(reportPath).href}`)
98
+
99
+ const shouldOpen = shouldOpenReport({
100
+ mode: this.open,
101
+ hasFailure: data.hasFailure,
102
+ ci: !!process.env.CI,
103
+ watch,
104
+ })
105
+ if (shouldOpen) {
106
+ log('[Nuxt Spec] Opening report in default browser...')
107
+ openInBrowser(reportPath, () => log('[Nuxt Spec] Failed to automatically open report'))
108
+ }
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Decide whether the report should be opened in the default browser.
114
+ * Never opens in CI or watch mode. Invalid `mode` falls back to `failed`.
115
+ * @param {{ mode?: string, hasFailure: boolean, ci: boolean, watch: boolean }} options
116
+ */
117
+ export function shouldOpenReport({ mode, hasFailure, ci, watch }) {
118
+ if (ci || watch) return false
119
+ const normalized = mode?.trim().toLowerCase() || 'failed'
120
+ const effective = OPEN_MODES.includes(normalized) ? normalized : 'failed'
121
+ if (effective === 'always') return true
122
+ if (effective === 'never') return false
123
+ return hasFailure
124
+ }
125
+
126
+ /**
127
+ * @typedef {{ message: string, diff?: string, stack?: string }} ReportError
128
+ * @typedef {{ fileName: string, message: string, baselineUri?: string, actualUri?: string }} ReportScreenshot
129
+ * @typedef {{ project: string, file: string, name: string, errors: ReportError[], screenshots: ReportScreenshot[] }} ReportFailure
130
+ * @typedef {{ project: string, file: string, suite: string, errors: ReportError[] }} ReportModuleError
131
+ * @typedef {{
132
+ * total: number,
133
+ * passed: number,
134
+ * failed: number,
135
+ * skipped: number,
136
+ * reason: 'passed' | 'failed' | 'interrupted',
137
+ * failures: ReportFailure[],
138
+ * moduleErrors: ReportModuleError[],
139
+ * unhandledErrors: ReportError[],
140
+ * hasFailure: boolean,
141
+ * }} ReportData
142
+ */
143
+
144
+ /**
145
+ * Transform Vitest reported entities into plain report data.
146
+ * @param {ReadonlyArray<any>} testModules - `TestModule` instances from `onTestRunEnd`
147
+ * @param {ReadonlyArray<any>} unhandledErrors - serialized unhandled errors
148
+ * @param {'passed' | 'failed' | 'interrupted'} reason - run end reason
149
+ * @param {string} root - project root used to compute relative paths
150
+ * @returns {ReportData}
151
+ */
152
+ export function collectReportData(testModules, unhandledErrors, reason, root) {
153
+ /** @type {ReportData} */
154
+ const data = {
155
+ total: 0,
156
+ passed: 0,
157
+ failed: 0,
158
+ skipped: 0,
159
+ reason,
160
+ failures: [],
161
+ moduleErrors: [],
162
+ unhandledErrors: (unhandledErrors ?? []).map(toErrorData),
163
+ hasFailure: false,
164
+ }
165
+
166
+ for (const testModule of testModules ?? []) {
167
+ const project = testModule.project?.name || ''
168
+ const file = toRelative(root, testModule.moduleId)
169
+
170
+ // errors outside of tests - module collection errors or failed `beforeAll`/`afterAll` hooks
171
+ const moduleErrors = testModule.errors?.() ?? []
172
+ if (moduleErrors.length > 0) {
173
+ data.moduleErrors.push({ project, file, suite: '', errors: moduleErrors.map(toErrorData) })
174
+ }
175
+ for (const testSuite of testModule.children?.allSuites() ?? []) {
176
+ const suiteErrors = testSuite.errors?.() ?? []
177
+ if (suiteErrors.length > 0) {
178
+ data.moduleErrors.push({ project, file, suite: testSuite.fullName ?? testSuite.name, errors: suiteErrors.map(toErrorData) })
179
+ }
180
+ }
181
+
182
+ for (const testCase of testModule.children?.allTests() ?? []) {
183
+ const result = testCase.result()
184
+ if (result.state === 'skipped') {
185
+ data.skipped++
186
+ continue
187
+ }
188
+ data.total++
189
+ if (result.state === 'passed') {
190
+ data.passed++
191
+ } else if (result.state === 'failed') {
192
+ data.failed++
193
+ data.failures.push({
194
+ project,
195
+ file,
196
+ name: testCase.fullName ?? testCase.name,
197
+ errors: (result.errors ?? []).map(toErrorData),
198
+ screenshots: (testCase.artifacts?.() ?? [])
199
+ .filter(a => a?.type === SCREENSHOT_ARTIFACT_TYPE)
200
+ .map(toScreenshotData),
201
+ })
202
+ }
203
+ }
204
+ }
205
+
206
+ data.hasFailure = data.failed > 0
207
+ || data.moduleErrors.length > 0
208
+ || data.unhandledErrors.length > 0
209
+ || reason !== 'passed'
210
+
211
+ return data
212
+ }
213
+
214
+ /**
215
+ * Render report data (see `collectReportData`) into a self-contained HTML document.
216
+ * @param {ReportData} data
217
+ * @param {string} titleTimestamp - timestamp displayed in the report title
218
+ * @param {string} footerTimestamp - timestamp displayed in the report footer
219
+ */
220
+ export function renderReport(data, titleTimestamp, footerTimestamp) {
221
+ const entries = []
222
+
223
+ for (const failure of data.failures) {
224
+ entries.push(renderEntry({
225
+ title: failure.name,
226
+ location: formatLocation(failure.project, failure.file),
227
+ errors: failure.errors,
228
+ screenshots: failure.screenshots,
229
+ }))
230
+ }
231
+
232
+ for (const moduleError of data.moduleErrors) {
233
+ entries.push(renderEntry({
234
+ title: moduleError.suite ? `Error in suite: ${moduleError.suite}` : 'Error in test module',
235
+ location: formatLocation(moduleError.project, moduleError.file),
236
+ errors: moduleError.errors,
237
+ }))
238
+ }
239
+
240
+ for (const error of data.unhandledErrors) {
241
+ entries.push(renderEntry({
242
+ title: 'Unhandled error',
243
+ location: 'Error was thrown outside of a test',
244
+ errors: [error],
245
+ }))
246
+ }
247
+
248
+ const head = fillTemplate(loadTemplate('report-head.html'), {
249
+ TIMESTAMP: escapeHtml(titleTimestamp),
250
+ STATUS: data.hasFailure ? 'failed' : 'passed',
251
+ SUMMARY: escapeHtml(formatSummary(data)),
252
+ })
253
+ const tail = fillTemplate(loadTemplate('report-tail.html'), {
254
+ TIMESTAMP: escapeHtml(footerTimestamp),
255
+ })
256
+
257
+ return head + entries.join('') + tail
258
+ }
259
+
260
+ /**
261
+ * Build the one-line summary, e.g. `5/5 tests passed` or `3/5 tests passed, 2 failed`.
262
+ * @param {ReportData} data
263
+ */
264
+ export function formatSummary(data) {
265
+ const parts = [`${data.passed}/${data.total} tests passed`]
266
+ if (data.failed > 0) parts.push(`${data.failed} failed`)
267
+ if (data.skipped > 0) parts.push(`${data.skipped} skipped`)
268
+ if (data.moduleErrors.length > 0) parts.push(`${data.moduleErrors.length} module/suite error(s)`)
269
+ if (data.unhandledErrors.length > 0) parts.push(`${data.unhandledErrors.length} unhandled error(s)`)
270
+ let summary = parts.join(', ')
271
+ if (data.reason === 'interrupted') summary += ' (test run was interrupted)'
272
+ else if (data.hasFailure && data.failed === 0 && data.moduleErrors.length === 0 && data.unhandledErrors.length === 0) {
273
+ summary += ' (test run failed)'
274
+ }
275
+ return summary
276
+ }
277
+
278
+ function renderEntry({ title, location, errors, screenshots = [] }) {
279
+ return fillTemplate(loadTemplate('report-entry.html'), {
280
+ TITLE: escapeHtml(title),
281
+ LOCATION: escapeHtml(location),
282
+ ERRORS: errors.map(renderError).join('\n'),
283
+ SCREENSHOTS: screenshots.map(renderScreenshot).join('\n'),
284
+ })
285
+ }
286
+
287
+ function renderError(error) {
288
+ let html = `<pre class="message">${escapeHtml(error.message)}</pre>`
289
+ if (error.diff) {
290
+ html += `<details open><summary>Diff</summary><pre class="diff">${escapeHtml(error.diff)}</pre></details>`
291
+ }
292
+ if (error.stack) {
293
+ html += `<details><summary>Stack trace</summary><pre class="stack">${escapeHtml(error.stack)}</pre></details>`
294
+ }
295
+ return html
296
+ }
297
+
298
+ function renderScreenshot(screenshot) {
299
+ // attachments may be missing (e.g. stripped by Vitest), show at least the message
300
+ if (!screenshot.baselineUri || !screenshot.actualUri) {
301
+ return `<div class="screenshot"><h3>${escapeHtml(screenshot.fileName)}</h3><p class="message">${escapeHtml(screenshot.message)}</p></div>`
302
+ }
303
+ return fillTemplate(loadTemplate('report-screenshot.html'), {
304
+ FILE_NAME: escapeHtml(screenshot.fileName),
305
+ MESSAGE: escapeHtml(screenshot.message),
306
+ BASELINE_URI: screenshot.baselineUri,
307
+ ACTUAL_URI: screenshot.actualUri,
308
+ })
309
+ }
310
+
311
+ function toErrorData(error) {
312
+ const name = error?.name && error.name !== 'Error' ? `${error.name}: ` : ''
313
+ return {
314
+ message: stripAnsi(`${name}${error?.message ?? String(error)}`),
315
+ diff: error?.diff ? stripAnsi(error.diff) : undefined,
316
+ stack: error?.stack ? stripAnsi(error.stack) : undefined,
317
+ }
318
+ }
319
+
320
+ function toScreenshotData(artifact) {
321
+ const attachments = artifact.attachments ?? []
322
+ const findUri = (name) => {
323
+ const attachment = attachments.find(a => a?.name === name)
324
+ if (!attachment?.body || typeof attachment.body !== 'string') return undefined
325
+ if (attachment.bodyEncoding === 'utf-8') return undefined
326
+ // only accept valid base64 payload and an image MIME type to keep the `src` attribute safe
327
+ if (!BASE64_REGEX.test(attachment.body)) return undefined
328
+ const contentType = IMAGE_MIME_REGEX.test(attachment.contentType ?? '') ? attachment.contentType : 'image/png'
329
+ return `data:${contentType};base64,${attachment.body}`
330
+ }
331
+ return {
332
+ fileName: artifact.fileName ?? '',
333
+ message: artifact.message ?? '',
334
+ baselineUri: findUri('baseline'),
335
+ actualUri: findUri('actual'),
336
+ }
337
+ }
338
+
339
+ function formatLocation(project, file) {
340
+ return project ? `[${project}] ${file}` : file
341
+ }
342
+
343
+ function toRelative(root, path) {
344
+ if (!path) return ''
345
+ const rel = relative(root, path)
346
+ return rel && !rel.startsWith('..') ? rel.replaceAll('\\', '/') : path
347
+ }
348
+
349
+ function openInBrowser(path, onError) {
350
+ const openCmd
351
+ = platform() === 'darwin'
352
+ ? `open "${path}"`
353
+ : platform() === 'win32'
354
+ ? `start "" "${path}"`
355
+ : `xdg-open "${path}"`
356
+ exec(openCmd, (err) => {
357
+ if (err) onError()
358
+ })
359
+ }
360
+
361
+ // replace `{{KEY}}` placeholders
362
+ // function replacer avoids special `$` patterns of String.replace
363
+ function fillTemplate(template, values) {
364
+ return template.replace(/\{\{(\w+)\}\}/g, (match, key) => key in values ? values[key] : match)
365
+ }
366
+
367
+ // timestamp string
368
+ // separators=false produces YYYYMMDDHHMMSS for report file name
369
+ // separators=true produces YYYY-MM-DD HH:MM:SS for report title
370
+ export function reportTimestamp(date, separators = false) {
371
+ const pad2 = n => String(n).padStart(2, '0')
372
+ const dateSeparator = separators ? '-' : ''
373
+ const midSeparator = separators ? ' ' : ''
374
+ const timeSeparator = separators ? ':' : ''
375
+ return [
376
+ date.getFullYear(),
377
+ dateSeparator,
378
+ pad2(date.getMonth() + 1),
379
+ dateSeparator,
380
+ pad2(date.getDate()),
381
+ midSeparator,
382
+ pad2(date.getHours()),
383
+ timeSeparator,
384
+ pad2(date.getMinutes()),
385
+ timeSeparator,
386
+ pad2(date.getSeconds()),
387
+ ].join('')
388
+ }
389
+
390
+ const BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/
391
+ const IMAGE_MIME_REGEX = /^image\/[a-z0-9.+-]+$/i
392
+
393
+ // eslint-disable-next-line no-control-regex
394
+ const ANSI_REGEX = /\u001B\[[0-9;?]*[ -/]*[@-~]/g
395
+
396
+ function stripAnsi(value) {
397
+ return String(value).replace(ANSI_REGEX, '')
398
+ }
399
+
400
+ // helper to escape a string for safe interpolation into the HTML report
401
+ function escapeHtml(value) {
402
+ return String(value ?? '').replace(/[&<>"']/g, char => ({
403
+ '&': '&amp;',
404
+ '<': '&lt;',
405
+ '>': '&gt;',
406
+ '"': '&quot;',
407
+ '\'': '&#39;',
408
+ }[char] ?? char))
409
+ }