nuxt-spec 0.3.4 → 0.4.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +13 -13
- package/app/app.vue +10 -10
- package/app/components/NuxtSpecApiTestComponent.vue +24 -24
- package/app/components/NuxtSpecTestComponent.vue +9 -9
- package/app/components/index.d.ts +7 -7
- package/app/components/index.ts +2 -2
- package/app/utils/vitest-utils.ts +5 -5
- package/bin/_internal/prepublish-check.js +16 -16
- package/bin/cli.js +44 -44
- package/bin/helpers/commands.js +82 -82
- package/bin/setup.js +366 -287
- package/bin/update.js +94 -94
- package/config/index.d.ts +36 -33
- package/config/index.mjs +140 -132
- package/config/report/report-entry.html +6 -0
- package/config/report/report-head.html +34 -0
- package/{utils/screenshot/report-entry.html → config/report/report-screenshot.html} +8 -8
- package/config/report/report-tail.html +4 -0
- package/config/templates/pnpm-workspace.yaml.template +19 -19
- package/config/templates/vitest.config.ts.template +5 -5
- package/config/utils/merge.mjs +43 -43
- package/config/utils/options.d.mts +59 -0
- package/config/utils/options.mjs +116 -0
- package/config/utils/reporter.mjs +409 -0
- package/config/utils/warnings.mjs +38 -33
- package/modules/spec-options.ts +23 -0
- package/nuxt.config.ts +20 -24
- package/package.json +33 -31
- package/utils/e2e.ts +131 -131
- package/utils/helpers/check-params.ts +44 -41
- package/utils/helpers/location-hint.ts +30 -30
- package/utils/helpers/screenshot-utils.ts +27 -0
- package/utils/index.ts +12 -12
- package/utils/playwright.ts +28 -20
- package/utils/screenshot.ts +201 -172
- package/utils/screenshot/report-head.html +0 -24
- package/utils/screenshot/report-tail.html +0 -4
- package/utils/screenshot/report-utils.ts +0 -236
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { loadVitestConfig } from 'nuxt-spec/config'
|
|
2
|
-
|
|
3
|
-
export default loadVitestConfig({
|
|
4
|
-
// custom config here
|
|
5
|
-
})
|
|
1
|
+
import { loadVitestConfig } from 'nuxt-spec/config'
|
|
2
|
+
|
|
3
|
+
export default loadVitestConfig({
|
|
4
|
+
// custom config here
|
|
5
|
+
})
|
package/config/utils/merge.mjs
CHANGED
|
@@ -1,43 +1,43 @@
|
|
|
1
|
-
// custom merger function based on defu
|
|
2
|
-
// allows working with the "projects" array properly
|
|
3
|
-
// user-defined config overrides are merged by "name"
|
|
4
|
-
|
|
5
|
-
// for consistent and predictable results, passing "include" or "browser.instances"
|
|
6
|
-
// will result in OVERRIDE instead of merging with nuxt-spec defaults
|
|
7
|
-
|
|
8
|
-
import { createDefu } from 'defu'
|
|
9
|
-
|
|
10
|
-
// in real Vitest config, "name" is nested inside another "test" object
|
|
11
|
-
const getProjectName = project => project?.name ?? project?.test?.name
|
|
12
|
-
|
|
13
|
-
export const mergeConfig = createDefu((obj, key, value) => {
|
|
14
|
-
if (key === 'projects' && Array.isArray(obj[key]) && Array.isArray(value)) {
|
|
15
|
-
const defaults = obj[key]
|
|
16
|
-
const overrides = value
|
|
17
|
-
|
|
18
|
-
// override default values if user-defined config specifies them
|
|
19
|
-
obj[key] = defaults.map((defaultProject) => {
|
|
20
|
-
const override = overrides.find(o => getProjectName(o) === getProjectName(defaultProject))
|
|
21
|
-
return override ? mergeProject(override, defaultProject) : defaultProject
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
// add any user projects that don't exist in defaults
|
|
25
|
-
for (const override of overrides) {
|
|
26
|
-
if (!defaults.some(d => getProjectName(d) === getProjectName(override))) {
|
|
27
|
-
obj[key].push(override)
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
return true
|
|
32
|
-
}
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
// Keys where user value should fully replace the default, not merge
|
|
36
|
-
const overrideKeys = new Set(['include', 'instances'])
|
|
37
|
-
|
|
38
|
-
const mergeProject = createDefu((obj, key, value) => {
|
|
39
|
-
if (overrideKeys.has(key)) {
|
|
40
|
-
obj[key] = value
|
|
41
|
-
return true
|
|
42
|
-
}
|
|
43
|
-
})
|
|
1
|
+
// custom merger function based on defu
|
|
2
|
+
// allows working with the "projects" array properly
|
|
3
|
+
// user-defined config overrides are merged by "name"
|
|
4
|
+
|
|
5
|
+
// for consistent and predictable results, passing "include" or "browser.instances"
|
|
6
|
+
// will result in OVERRIDE instead of merging with nuxt-spec defaults
|
|
7
|
+
|
|
8
|
+
import { createDefu } from 'defu'
|
|
9
|
+
|
|
10
|
+
// in real Vitest config, "name" is nested inside another "test" object
|
|
11
|
+
const getProjectName = project => project?.name ?? project?.test?.name
|
|
12
|
+
|
|
13
|
+
export const mergeConfig = createDefu((obj, key, value) => {
|
|
14
|
+
if (key === 'projects' && Array.isArray(obj[key]) && Array.isArray(value)) {
|
|
15
|
+
const defaults = obj[key]
|
|
16
|
+
const overrides = value
|
|
17
|
+
|
|
18
|
+
// override default values if user-defined config specifies them
|
|
19
|
+
obj[key] = defaults.map((defaultProject) => {
|
|
20
|
+
const override = overrides.find(o => getProjectName(o) === getProjectName(defaultProject))
|
|
21
|
+
return override ? mergeProject(override, defaultProject) : defaultProject
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// add any user projects that don't exist in defaults
|
|
25
|
+
for (const override of overrides) {
|
|
26
|
+
if (!defaults.some(d => getProjectName(d) === getProjectName(override))) {
|
|
27
|
+
obj[key].push(override)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Keys where user value should fully replace the default, not merge
|
|
36
|
+
const overrideKeys = new Set(['include', 'instances'])
|
|
37
|
+
|
|
38
|
+
const mergeProject = createDefu((obj, key, value) => {
|
|
39
|
+
if (overrideKeys.has(key)) {
|
|
40
|
+
obj[key] = value
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export type HtmlReportOpenMode = 'always' | 'failed' | 'never'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Nuxt Spec options set via `spec` key in nuxt.config.ts.
|
|
5
|
+
* Each option can also be set via respective NUXT_SPEC_* env variable,
|
|
6
|
+
* which takes precedence over the value from nuxt.config.ts.
|
|
7
|
+
*/
|
|
8
|
+
export interface NuxtSpecOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Include `@nuxt/hints` module.
|
|
11
|
+
* Env: `NUXT_SPEC_HINTS_ENABLED`
|
|
12
|
+
* @default true
|
|
13
|
+
*/
|
|
14
|
+
hints?: boolean
|
|
15
|
+
/**
|
|
16
|
+
* WebSocket endpoint of an external Playwright server used by `e2e` and `browser` projects.
|
|
17
|
+
* Env: `NUXT_SPEC_EXTERNAL_PLAYWRIGHT`
|
|
18
|
+
*/
|
|
19
|
+
externalPlaywright?: string
|
|
20
|
+
/** HTML test report settings */
|
|
21
|
+
htmlReport?: {
|
|
22
|
+
/**
|
|
23
|
+
* Generate HTML report after each test run.
|
|
24
|
+
* Env: `NUXT_SPEC_HTML_REPORT`
|
|
25
|
+
* @default true
|
|
26
|
+
*/
|
|
27
|
+
enabled?: boolean
|
|
28
|
+
/**
|
|
29
|
+
* When to open the report in the default browser.
|
|
30
|
+
* Env: `NUXT_SPEC_HTML_REPORT_OPEN`
|
|
31
|
+
* @default 'failed'
|
|
32
|
+
*/
|
|
33
|
+
open?: HtmlReportOpenMode
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Plain text patterns of log messages that should be omitted.
|
|
37
|
+
* Env: `NUXT_SPEC_MESSAGE_FILTERS` (comma-separated, added to values from nuxt.config.ts)
|
|
38
|
+
*/
|
|
39
|
+
messageFilters?: string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ResolvedNuxtSpecOptions {
|
|
43
|
+
hints: boolean
|
|
44
|
+
externalPlaywright: string | undefined
|
|
45
|
+
htmlReport: {
|
|
46
|
+
enabled: boolean
|
|
47
|
+
open: HtmlReportOpenMode
|
|
48
|
+
}
|
|
49
|
+
messageFilters: string[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export declare const NUXT_SPEC_DEFAULTS: ResolvedNuxtSpecOptions
|
|
53
|
+
|
|
54
|
+
export declare function resolveSpecOptions(
|
|
55
|
+
fromConfig?: NuxtSpecOptions | false | null,
|
|
56
|
+
env?: Record<string, string | undefined>,
|
|
57
|
+
): ResolvedNuxtSpecOptions
|
|
58
|
+
|
|
59
|
+
export declare function loadSpecOptions(cwd?: string): Promise<ResolvedNuxtSpecOptions>
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Nuxt Spec options resolution
|
|
2
|
+
// - values can be set via `spec` key in nuxt.config.ts or via NUXT_SPEC_* env variables
|
|
3
|
+
// - precedence: env variables > nuxt.config.ts > defaults
|
|
4
|
+
|
|
5
|
+
import { loadNuxtConfig } from 'nuxt/kit'
|
|
6
|
+
|
|
7
|
+
export const NUXT_SPEC_DEFAULTS = {
|
|
8
|
+
hints: true,
|
|
9
|
+
externalPlaywright: undefined,
|
|
10
|
+
htmlReport: {
|
|
11
|
+
enabled: true,
|
|
12
|
+
open: 'failed',
|
|
13
|
+
},
|
|
14
|
+
messageFilters: [],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const HTML_REPORT_OPEN_MODES = ['always', 'failed', 'never']
|
|
18
|
+
|
|
19
|
+
// raw `spec` values per cwd - nuxt.config.ts is only loaded once
|
|
20
|
+
const specCache = new Map()
|
|
21
|
+
|
|
22
|
+
// options are resolved by both the Nuxt module and the Vitest config in the same process
|
|
23
|
+
const reportedWarnings = new Set()
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve final Nuxt Spec options.
|
|
27
|
+
* @param {import('./options.d.mts').NuxtSpecOptions | false | null} [fromConfig] - `spec` key from nuxt.config.ts
|
|
28
|
+
* @param {Record<string, string | undefined>} [env] - env variables
|
|
29
|
+
* @returns {import('./options.d.mts').ResolvedNuxtSpecOptions}
|
|
30
|
+
*/
|
|
31
|
+
export function resolveSpecOptions(fromConfig, env = process.env) {
|
|
32
|
+
const config = isObject(fromConfig) ? fromConfig : {}
|
|
33
|
+
const htmlReport = checkType('htmlReport', config.htmlReport, 'object') ?? {}
|
|
34
|
+
return {
|
|
35
|
+
hints: toBoolean(env.NUXT_SPEC_HINTS_ENABLED)
|
|
36
|
+
?? checkType('hints', config.hints, 'boolean')
|
|
37
|
+
?? NUXT_SPEC_DEFAULTS.hints,
|
|
38
|
+
externalPlaywright: env.NUXT_SPEC_EXTERNAL_PLAYWRIGHT
|
|
39
|
+
|| checkType('externalPlaywright', config.externalPlaywright, 'string')
|
|
40
|
+
|| NUXT_SPEC_DEFAULTS.externalPlaywright,
|
|
41
|
+
htmlReport: {
|
|
42
|
+
enabled: toBoolean(env.NUXT_SPEC_HTML_REPORT)
|
|
43
|
+
?? checkType('htmlReport.enabled', htmlReport.enabled, 'boolean')
|
|
44
|
+
?? NUXT_SPEC_DEFAULTS.htmlReport.enabled,
|
|
45
|
+
open: env.NUXT_SPEC_HTML_REPORT_OPEN
|
|
46
|
+
? toOpenMode('NUXT_SPEC_HTML_REPORT_OPEN', env.NUXT_SPEC_HTML_REPORT_OPEN)
|
|
47
|
+
: toOpenMode('htmlReport.open', htmlReport.open),
|
|
48
|
+
},
|
|
49
|
+
messageFilters: [
|
|
50
|
+
...(checkType('messageFilters', config.messageFilters, 'array') ?? []),
|
|
51
|
+
...(env.NUXT_SPEC_MESSAGE_FILTERS?.split(',') ?? []),
|
|
52
|
+
]
|
|
53
|
+
.filter((f) => {
|
|
54
|
+
if (typeof f === 'string') return true
|
|
55
|
+
warnInvalid('messageFilters', f, 'string', 'Value is ignored.')
|
|
56
|
+
return false
|
|
57
|
+
})
|
|
58
|
+
.map(f => f.trim())
|
|
59
|
+
// empty pattern would match (and swallow) every message
|
|
60
|
+
.filter(Boolean),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Load `spec` key from nuxt.config.ts (including extended layers)
|
|
66
|
+
* and resolve final Nuxt Spec options.
|
|
67
|
+
* @param {string} [cwd] - directory with nuxt.config.ts
|
|
68
|
+
* @returns {Promise<import('./options.d.mts').ResolvedNuxtSpecOptions>}
|
|
69
|
+
*/
|
|
70
|
+
export async function loadSpecOptions(cwd = process.cwd()) {
|
|
71
|
+
if (!specCache.has(cwd)) {
|
|
72
|
+
specCache.set(cwd, loadNuxtConfig({ cwd })
|
|
73
|
+
.then(config => config.spec)
|
|
74
|
+
.catch((error) => {
|
|
75
|
+
console.warn(`[Nuxt Spec] Failed to read \`spec\` options from nuxt.config: ${error?.message ?? error}`)
|
|
76
|
+
return undefined
|
|
77
|
+
}))
|
|
78
|
+
}
|
|
79
|
+
return resolveSpecOptions(await specCache.get(cwd))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isObject(value) {
|
|
83
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// returns the value if it matches the expected type, otherwise warns and returns undefined
|
|
87
|
+
function checkType(option, value, type) {
|
|
88
|
+
if (value === undefined) return undefined
|
|
89
|
+
const matches = type === 'object'
|
|
90
|
+
? isObject(value)
|
|
91
|
+
: type === 'array' ? Array.isArray(value) : typeof value === type
|
|
92
|
+
if (matches) return value
|
|
93
|
+
warnInvalid(option, value, type)
|
|
94
|
+
return undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function toOpenMode(option, value) {
|
|
98
|
+
if (value === undefined) return NUXT_SPEC_DEFAULTS.htmlReport.open
|
|
99
|
+
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : value
|
|
100
|
+
if (HTML_REPORT_OPEN_MODES.includes(normalized)) return normalized
|
|
101
|
+
warnInvalid(option, value, HTML_REPORT_OPEN_MODES.map(m => `'${m}'`).join(' | '))
|
|
102
|
+
return NUXT_SPEC_DEFAULTS.htmlReport.open
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function warnInvalid(option, value, expected, outcome = 'Falling back to default.') {
|
|
106
|
+
const message = `[Nuxt Spec] Invalid value ${JSON.stringify(value) ?? String(value)} for \`${option}\` (expected ${expected}). ${outcome}`
|
|
107
|
+
if (reportedWarnings.has(message)) return
|
|
108
|
+
reportedWarnings.add(message)
|
|
109
|
+
console.warn(message)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// unset or empty = not defined, only explicit 'false' disables
|
|
113
|
+
function toBoolean(value) {
|
|
114
|
+
if (value === undefined || value === '') return undefined
|
|
115
|
+
return value !== 'false'
|
|
116
|
+
}
|
|
@@ -0,0 +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
|
+
'&': '&',
|
|
404
|
+
'<': '<',
|
|
405
|
+
'>': '>',
|
|
406
|
+
'"': '"',
|
|
407
|
+
'\'': ''',
|
|
408
|
+
}[char] ?? char))
|
|
409
|
+
}
|