nuxt-spec 0.3.2 → 0.3.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.
- 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/cli.js +38 -38
- package/bin/helpers/commands.js +52 -52
- package/bin/prepublish-check.js +16 -16
- package/bin/setup.js +270 -270
- package/bin/update.js +75 -75
- package/config/index.d.ts +33 -33
- package/config/index.mjs +132 -114
- package/config/templates/pnpm-workspace.yaml.template +4 -4
- package/config/templates/vitest.config.ts.template +5 -5
- package/config/utils/merge.mjs +43 -43
- package/config/utils/warnings.mjs +33 -33
- package/nuxt.config.ts +24 -24
- package/package.json +27 -25
- package/utils/e2e.ts +131 -131
- package/utils/helpers/check-params.ts +41 -41
- package/utils/helpers/location-hint.ts +30 -30
- package/utils/index.ts +12 -12
- package/utils/playwright.ts +20 -0
- package/utils/screenshot/report-entry.html +8 -8
- package/utils/screenshot/report-head.html +24 -24
- package/utils/screenshot/report-tail.html +4 -4
- package/utils/screenshot/report-utils.ts +236 -236
- package/utils/screenshot.ts +172 -172
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- END failed screenshot entries -->
|
|
2
|
-
<div>Generated by <a href="https://github.com/nuxt/nuxt-spec">Nuxt Spec</a> @ {{TIMESTAMP}}</div>
|
|
3
|
-
</body>
|
|
4
|
-
</html>
|
|
1
|
+
<!-- END failed screenshot entries -->
|
|
2
|
+
<div>Generated by <a href="https://github.com/nuxt/nuxt-spec">Nuxt Spec</a> @ {{TIMESTAMP}}</div>
|
|
3
|
+
</body>
|
|
4
|
+
</html>
|
|
@@ -1,236 +1,236 @@
|
|
|
1
|
-
import { exec } from 'node:child_process'
|
|
2
|
-
import { appendFileSync, existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
3
|
-
import { platform } from 'node:os'
|
|
4
|
-
import { resolve, sep } from 'node:path'
|
|
5
|
-
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
6
|
-
import type { DecodedPng } from 'fast-png'
|
|
7
|
-
|
|
8
|
-
const templatesDir = resolve(fileURLToPath(import.meta.url), '..')
|
|
9
|
-
const REPORT_ENTRY = readFileSync(resolve(templatesDir, 'report-entry.html'), 'utf-8')
|
|
10
|
-
const REPORT_PATH = resolve(process.cwd(), '.nuxt/.nuxt-spec-report-path')
|
|
11
|
-
const REPORT_LOCK = resolve(process.cwd(), '.nuxt/.nuxt-spec-report-lock')
|
|
12
|
-
|
|
13
|
-
// create report file on first call
|
|
14
|
-
// protected from parallel execution issues
|
|
15
|
-
export function ensureReportCreated(targetDir = 'test/e2e'): void {
|
|
16
|
-
withConcurrentLock(() => {
|
|
17
|
-
let reportPath: string
|
|
18
|
-
|
|
19
|
-
if (!existsSync(targetDir)) {
|
|
20
|
-
throw new Error(`Target directory "${targetDir}" does not exist.`)
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
if (existsSync(REPORT_PATH)) {
|
|
24
|
-
// report file was already created
|
|
25
|
-
reportPath = readFileSync(REPORT_PATH, 'utf-8').trim()
|
|
26
|
-
// this shouldn't happen...
|
|
27
|
-
if (!existsSync(reportPath)) {
|
|
28
|
-
throw new Error(`Invalid data in "${REPORT_PATH}": Report file "${reportPath}" does not exist.`)
|
|
29
|
-
}
|
|
30
|
-
} else {
|
|
31
|
-
// for the first time, prepare and store new time-stamped file path
|
|
32
|
-
const fileTimestamp = process.env.SCREENSHOT_REPORT_TIMESTAMP ?? reportTimestamp(new Date())
|
|
33
|
-
reportPath = resolve(targetDir, '__current__', `report-${fileTimestamp}.html`)
|
|
34
|
-
writeFileSync(REPORT_PATH, reportPath)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// store the path for `appendToReport`
|
|
38
|
-
process.env.SCREENSHOT_REPORT_PATH = reportPath
|
|
39
|
-
|
|
40
|
-
// report file was already created
|
|
41
|
-
if (existsSync(reportPath)) return
|
|
42
|
-
|
|
43
|
-
// create new report file from template
|
|
44
|
-
const titleTimestamp = process.env.SCREENSHOT_REPORT_TITLE ?? reportTimestamp(new Date(), true)
|
|
45
|
-
const template = readFileSync(resolve(templatesDir, 'report-head.html'), 'utf-8')
|
|
46
|
-
const report = template.replace('{{TIMESTAMP}}', titleTimestamp)
|
|
47
|
-
writeFileSync(reportPath, report)
|
|
48
|
-
})
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// append a side-by-side baseline/actual comparison
|
|
52
|
-
// to the HTML report if the screenshots don't match
|
|
53
|
-
// protected from parallel execution issues
|
|
54
|
-
export function appendToReport(fileName: string, message: string, baseline: Uint8Array, actual: Uint8Array): void {
|
|
55
|
-
const baselineUri = `data:image/png;base64,${Buffer.from(baseline).toString('base64')}`
|
|
56
|
-
const actualUri = `data:image/png;base64,${Buffer.from(actual).toString('base64')}`
|
|
57
|
-
|
|
58
|
-
const entry = REPORT_ENTRY
|
|
59
|
-
.replace('{{FILE_NAME}}', escapeHtml(fileName))
|
|
60
|
-
.replace('{{MESSAGE}}', escapeHtml(message))
|
|
61
|
-
.replace('{{BASELINE_URI}}', baselineUri)
|
|
62
|
-
.replace('{{ACTUAL_URI}}', actualUri)
|
|
63
|
-
|
|
64
|
-
withConcurrentLock(() => {
|
|
65
|
-
const reportPath = process.env.SCREENSHOT_REPORT_PATH || (existsSync(REPORT_PATH)
|
|
66
|
-
? readFileSync(REPORT_PATH, 'utf-8').trim()
|
|
67
|
-
: '')
|
|
68
|
-
if (!reportPath || !existsSync(reportPath)) return
|
|
69
|
-
|
|
70
|
-
appendFileSync(reportPath, entry)
|
|
71
|
-
})
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Vitest globalSetup entry point
|
|
75
|
-
// - computes stable timestamp values and exposes them via env variables
|
|
76
|
-
// - the report file itself is created lazily on first compareScreenshot call
|
|
77
|
-
// - provides a callback to close the HTML report once tests are finished (if it was created)
|
|
78
|
-
export function screenshotSetup() {
|
|
79
|
-
const NOW = new Date()
|
|
80
|
-
const fileTimestamp = reportTimestamp(NOW)
|
|
81
|
-
const titleTimestamp = reportTimestamp(NOW, true)
|
|
82
|
-
|
|
83
|
-
process.env.SCREENSHOT_REPORT_TIMESTAMP = fileTimestamp
|
|
84
|
-
process.env.SCREENSHOT_REPORT_TITLE = titleTimestamp
|
|
85
|
-
|
|
86
|
-
// callback that is executed upon Vitest teardown phase
|
|
87
|
-
return () => {
|
|
88
|
-
if (!existsSync(REPORT_PATH)) return
|
|
89
|
-
|
|
90
|
-
const reportPath = readFileSync(REPORT_PATH, 'utf-8').trim()
|
|
91
|
-
unlinkSync(REPORT_PATH)
|
|
92
|
-
|
|
93
|
-
if (!reportPath) return
|
|
94
|
-
if (!existsSync(reportPath)) return
|
|
95
|
-
|
|
96
|
-
// add "success" / "error" message as a conclusion
|
|
97
|
-
const reportBody = readFileSync(reportPath, 'utf-8')
|
|
98
|
-
const hasFailure = reportBody.includes('<div class="failure"')
|
|
99
|
-
if (hasFailure) {
|
|
100
|
-
appendFileSync(reportPath, '<p style="color: #b00; font-weight: 700; font-size: 18px;">Tests finished with errors</p>\n')
|
|
101
|
-
} else {
|
|
102
|
-
appendFileSync(reportPath, '<p style="color: #0b0; font-weight: 700; font-size: 18px;">All tests have passed</p>\n')
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// wrap the report up with a footer
|
|
106
|
-
let footer = readFileSync(resolve(templatesDir, 'report-tail.html'), 'utf-8')
|
|
107
|
-
footer = footer.replace('{{TIMESTAMP}}', new Date().toISOString())
|
|
108
|
-
appendFileSync(reportPath, footer)
|
|
109
|
-
console.log(`\n(nuxt-spec) Visual regression report available at:\n${pathToFileURL(reportPath).href}`)
|
|
110
|
-
|
|
111
|
-
if (!process.env.CI) {
|
|
112
|
-
console.log('(nuxt-spec) Opening report in default browser...')
|
|
113
|
-
const openCmd
|
|
114
|
-
= platform() === 'darwin'
|
|
115
|
-
? `open "${reportPath}"`
|
|
116
|
-
: platform() === 'win32'
|
|
117
|
-
? `start "" "${reportPath}"`
|
|
118
|
-
: `xdg-open "${reportPath}"`
|
|
119
|
-
exec(openCmd, (err) => {
|
|
120
|
-
if (err) {
|
|
121
|
-
console.log('(nuxt-spec) Failed to automatically open report')
|
|
122
|
-
}
|
|
123
|
-
})
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
console.log('\n')
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// helper to keep user-provided targetDir inside the current project root
|
|
131
|
-
export function resolveWithin(base: string, segment: string): string {
|
|
132
|
-
const target = resolve(base, segment)
|
|
133
|
-
if (target !== base && !target.startsWith(base + sep)) {
|
|
134
|
-
throw new Error(`Invalid path: "${segment}" resolves outside of "${base}"`)
|
|
135
|
-
}
|
|
136
|
-
return target
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// helper for bridging difference between Vitest PNG saving and fast-png encoding
|
|
140
|
-
export function toRGBA(img: DecodedPng): Uint8Array {
|
|
141
|
-
const { width, height, data, channels = 4 } = img
|
|
142
|
-
if (channels === 4) return data as Uint8Array
|
|
143
|
-
const pixels = width * height
|
|
144
|
-
const rgba = new Uint8Array(pixels * 4)
|
|
145
|
-
for (let i = 0; i < pixels; i++) {
|
|
146
|
-
const src = i * 3
|
|
147
|
-
rgba[i * 4 + 0] = data[src + 0] ?? 0
|
|
148
|
-
rgba[i * 4 + 1] = data[src + 1] ?? 0
|
|
149
|
-
rgba[i * 4 + 2] = data[src + 2] ?? 0
|
|
150
|
-
rgba[i * 4 + 3] = 255
|
|
151
|
-
}
|
|
152
|
-
return rgba
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// protection from parallel execution issues
|
|
156
|
-
function withConcurrentLock<T>(fn: () => T): T {
|
|
157
|
-
const start = Date.now()
|
|
158
|
-
|
|
159
|
-
while (true) {
|
|
160
|
-
try {
|
|
161
|
-
// try acquiring the lock
|
|
162
|
-
writeFileSync(REPORT_LOCK, String(process.pid), { flag: 'wx' })
|
|
163
|
-
break
|
|
164
|
-
} catch (error) {
|
|
165
|
-
// we only expect "file already exists" error
|
|
166
|
-
// otherwise it is an issue
|
|
167
|
-
const code = (error as NodeJS.ErrnoException).code
|
|
168
|
-
if (code !== 'EEXIST') throw error
|
|
169
|
-
|
|
170
|
-
try {
|
|
171
|
-
const lockAge = Date.now() - statSync(REPORT_LOCK).mtimeMs
|
|
172
|
-
if (lockAge > 30_000) {
|
|
173
|
-
// stale lock
|
|
174
|
-
unlinkSync(REPORT_LOCK)
|
|
175
|
-
continue
|
|
176
|
-
}
|
|
177
|
-
} catch {
|
|
178
|
-
// lock disappeared between checks, retry acquisition
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
if (Date.now() - start > 10_000) {
|
|
182
|
-
// not successful in 10 minutes, give up
|
|
183
|
-
throw new Error('Timed out while waiting for screenshot report lock', {
|
|
184
|
-
cause: error,
|
|
185
|
-
})
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// sleep before next attempt
|
|
189
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25)
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
try {
|
|
194
|
-
return fn()
|
|
195
|
-
} finally {
|
|
196
|
-
try {
|
|
197
|
-
unlinkSync(REPORT_LOCK)
|
|
198
|
-
} catch {
|
|
199
|
-
// lock file may already be removed
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// timestamp string
|
|
205
|
-
// separators=false produces YYYYMMDDHHMMSS for report file name
|
|
206
|
-
// separators=true produces YYYY-MM-DD HH:MM:SS for report title
|
|
207
|
-
function reportTimestamp(date: Date, separators = false): string {
|
|
208
|
-
const pad2 = (n: number) => String(n).padStart(2, '0')
|
|
209
|
-
const dateSeparator = separators ? '-' : ''
|
|
210
|
-
const midSeparator = separators ? ' ' : ''
|
|
211
|
-
const timeSeparator = separators ? ':' : ''
|
|
212
|
-
return [
|
|
213
|
-
date.getFullYear(),
|
|
214
|
-
dateSeparator,
|
|
215
|
-
pad2(date.getMonth() + 1),
|
|
216
|
-
dateSeparator,
|
|
217
|
-
pad2(date.getDate()),
|
|
218
|
-
midSeparator,
|
|
219
|
-
pad2(date.getHours()),
|
|
220
|
-
timeSeparator,
|
|
221
|
-
pad2(date.getMinutes()),
|
|
222
|
-
timeSeparator,
|
|
223
|
-
pad2(date.getSeconds()),
|
|
224
|
-
].join('')
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// helper to escape a string for safe interpolation into the HTML report
|
|
228
|
-
function escapeHtml(value: string): string {
|
|
229
|
-
return value.replace(/[&<>"']/g, char => ({
|
|
230
|
-
'&': '&',
|
|
231
|
-
'<': '<',
|
|
232
|
-
'>': '>',
|
|
233
|
-
'"': '"',
|
|
234
|
-
'\'': ''',
|
|
235
|
-
}[char] ?? char))
|
|
236
|
-
}
|
|
1
|
+
import { exec } from 'node:child_process'
|
|
2
|
+
import { appendFileSync, existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { platform } from 'node:os'
|
|
4
|
+
import { resolve, sep } from 'node:path'
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
6
|
+
import type { DecodedPng } from 'fast-png'
|
|
7
|
+
|
|
8
|
+
const templatesDir = resolve(fileURLToPath(import.meta.url), '..')
|
|
9
|
+
const REPORT_ENTRY = readFileSync(resolve(templatesDir, 'report-entry.html'), 'utf-8')
|
|
10
|
+
const REPORT_PATH = resolve(process.cwd(), '.nuxt/.nuxt-spec-report-path')
|
|
11
|
+
const REPORT_LOCK = resolve(process.cwd(), '.nuxt/.nuxt-spec-report-lock')
|
|
12
|
+
|
|
13
|
+
// create report file on first call
|
|
14
|
+
// protected from parallel execution issues
|
|
15
|
+
export function ensureReportCreated(targetDir = 'test/e2e'): void {
|
|
16
|
+
withConcurrentLock(() => {
|
|
17
|
+
let reportPath: string
|
|
18
|
+
|
|
19
|
+
if (!existsSync(targetDir)) {
|
|
20
|
+
throw new Error(`Target directory "${targetDir}" does not exist.`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (existsSync(REPORT_PATH)) {
|
|
24
|
+
// report file was already created
|
|
25
|
+
reportPath = readFileSync(REPORT_PATH, 'utf-8').trim()
|
|
26
|
+
// this shouldn't happen...
|
|
27
|
+
if (!existsSync(reportPath)) {
|
|
28
|
+
throw new Error(`Invalid data in "${REPORT_PATH}": Report file "${reportPath}" does not exist.`)
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
// for the first time, prepare and store new time-stamped file path
|
|
32
|
+
const fileTimestamp = process.env.SCREENSHOT_REPORT_TIMESTAMP ?? reportTimestamp(new Date())
|
|
33
|
+
reportPath = resolve(targetDir, '__current__', `report-${fileTimestamp}.html`)
|
|
34
|
+
writeFileSync(REPORT_PATH, reportPath)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// store the path for `appendToReport`
|
|
38
|
+
process.env.SCREENSHOT_REPORT_PATH = reportPath
|
|
39
|
+
|
|
40
|
+
// report file was already created
|
|
41
|
+
if (existsSync(reportPath)) return
|
|
42
|
+
|
|
43
|
+
// create new report file from template
|
|
44
|
+
const titleTimestamp = process.env.SCREENSHOT_REPORT_TITLE ?? reportTimestamp(new Date(), true)
|
|
45
|
+
const template = readFileSync(resolve(templatesDir, 'report-head.html'), 'utf-8')
|
|
46
|
+
const report = template.replace('{{TIMESTAMP}}', titleTimestamp)
|
|
47
|
+
writeFileSync(reportPath, report)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// append a side-by-side baseline/actual comparison
|
|
52
|
+
// to the HTML report if the screenshots don't match
|
|
53
|
+
// protected from parallel execution issues
|
|
54
|
+
export function appendToReport(fileName: string, message: string, baseline: Uint8Array, actual: Uint8Array): void {
|
|
55
|
+
const baselineUri = `data:image/png;base64,${Buffer.from(baseline).toString('base64')}`
|
|
56
|
+
const actualUri = `data:image/png;base64,${Buffer.from(actual).toString('base64')}`
|
|
57
|
+
|
|
58
|
+
const entry = REPORT_ENTRY
|
|
59
|
+
.replace('{{FILE_NAME}}', escapeHtml(fileName))
|
|
60
|
+
.replace('{{MESSAGE}}', escapeHtml(message))
|
|
61
|
+
.replace('{{BASELINE_URI}}', baselineUri)
|
|
62
|
+
.replace('{{ACTUAL_URI}}', actualUri)
|
|
63
|
+
|
|
64
|
+
withConcurrentLock(() => {
|
|
65
|
+
const reportPath = process.env.SCREENSHOT_REPORT_PATH || (existsSync(REPORT_PATH)
|
|
66
|
+
? readFileSync(REPORT_PATH, 'utf-8').trim()
|
|
67
|
+
: '')
|
|
68
|
+
if (!reportPath || !existsSync(reportPath)) return
|
|
69
|
+
|
|
70
|
+
appendFileSync(reportPath, entry)
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Vitest globalSetup entry point
|
|
75
|
+
// - computes stable timestamp values and exposes them via env variables
|
|
76
|
+
// - the report file itself is created lazily on first compareScreenshot call
|
|
77
|
+
// - provides a callback to close the HTML report once tests are finished (if it was created)
|
|
78
|
+
export function screenshotSetup() {
|
|
79
|
+
const NOW = new Date()
|
|
80
|
+
const fileTimestamp = reportTimestamp(NOW)
|
|
81
|
+
const titleTimestamp = reportTimestamp(NOW, true)
|
|
82
|
+
|
|
83
|
+
process.env.SCREENSHOT_REPORT_TIMESTAMP = fileTimestamp
|
|
84
|
+
process.env.SCREENSHOT_REPORT_TITLE = titleTimestamp
|
|
85
|
+
|
|
86
|
+
// callback that is executed upon Vitest teardown phase
|
|
87
|
+
return () => {
|
|
88
|
+
if (!existsSync(REPORT_PATH)) return
|
|
89
|
+
|
|
90
|
+
const reportPath = readFileSync(REPORT_PATH, 'utf-8').trim()
|
|
91
|
+
unlinkSync(REPORT_PATH)
|
|
92
|
+
|
|
93
|
+
if (!reportPath) return
|
|
94
|
+
if (!existsSync(reportPath)) return
|
|
95
|
+
|
|
96
|
+
// add "success" / "error" message as a conclusion
|
|
97
|
+
const reportBody = readFileSync(reportPath, 'utf-8')
|
|
98
|
+
const hasFailure = reportBody.includes('<div class="failure"')
|
|
99
|
+
if (hasFailure) {
|
|
100
|
+
appendFileSync(reportPath, '<p style="color: #b00; font-weight: 700; font-size: 18px;">Tests finished with errors</p>\n')
|
|
101
|
+
} else {
|
|
102
|
+
appendFileSync(reportPath, '<p style="color: #0b0; font-weight: 700; font-size: 18px;">All tests have passed</p>\n')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// wrap the report up with a footer
|
|
106
|
+
let footer = readFileSync(resolve(templatesDir, 'report-tail.html'), 'utf-8')
|
|
107
|
+
footer = footer.replace('{{TIMESTAMP}}', new Date().toISOString())
|
|
108
|
+
appendFileSync(reportPath, footer)
|
|
109
|
+
console.log(`\n(nuxt-spec) Visual regression report available at:\n${pathToFileURL(reportPath).href}`)
|
|
110
|
+
|
|
111
|
+
if (!process.env.CI) {
|
|
112
|
+
console.log('(nuxt-spec) Opening report in default browser...')
|
|
113
|
+
const openCmd
|
|
114
|
+
= platform() === 'darwin'
|
|
115
|
+
? `open "${reportPath}"`
|
|
116
|
+
: platform() === 'win32'
|
|
117
|
+
? `start "" "${reportPath}"`
|
|
118
|
+
: `xdg-open "${reportPath}"`
|
|
119
|
+
exec(openCmd, (err) => {
|
|
120
|
+
if (err) {
|
|
121
|
+
console.log('(nuxt-spec) Failed to automatically open report')
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log('\n')
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// helper to keep user-provided targetDir inside the current project root
|
|
131
|
+
export function resolveWithin(base: string, segment: string): string {
|
|
132
|
+
const target = resolve(base, segment)
|
|
133
|
+
if (target !== base && !target.startsWith(base + sep)) {
|
|
134
|
+
throw new Error(`Invalid path: "${segment}" resolves outside of "${base}"`)
|
|
135
|
+
}
|
|
136
|
+
return target
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// helper for bridging difference between Vitest PNG saving and fast-png encoding
|
|
140
|
+
export function toRGBA(img: DecodedPng): Uint8Array {
|
|
141
|
+
const { width, height, data, channels = 4 } = img
|
|
142
|
+
if (channels === 4) return data as Uint8Array
|
|
143
|
+
const pixels = width * height
|
|
144
|
+
const rgba = new Uint8Array(pixels * 4)
|
|
145
|
+
for (let i = 0; i < pixels; i++) {
|
|
146
|
+
const src = i * 3
|
|
147
|
+
rgba[i * 4 + 0] = data[src + 0] ?? 0
|
|
148
|
+
rgba[i * 4 + 1] = data[src + 1] ?? 0
|
|
149
|
+
rgba[i * 4 + 2] = data[src + 2] ?? 0
|
|
150
|
+
rgba[i * 4 + 3] = 255
|
|
151
|
+
}
|
|
152
|
+
return rgba
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// protection from parallel execution issues
|
|
156
|
+
function withConcurrentLock<T>(fn: () => T): T {
|
|
157
|
+
const start = Date.now()
|
|
158
|
+
|
|
159
|
+
while (true) {
|
|
160
|
+
try {
|
|
161
|
+
// try acquiring the lock
|
|
162
|
+
writeFileSync(REPORT_LOCK, String(process.pid), { flag: 'wx' })
|
|
163
|
+
break
|
|
164
|
+
} catch (error) {
|
|
165
|
+
// we only expect "file already exists" error
|
|
166
|
+
// otherwise it is an issue
|
|
167
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
168
|
+
if (code !== 'EEXIST') throw error
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const lockAge = Date.now() - statSync(REPORT_LOCK).mtimeMs
|
|
172
|
+
if (lockAge > 30_000) {
|
|
173
|
+
// stale lock
|
|
174
|
+
unlinkSync(REPORT_LOCK)
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
// lock disappeared between checks, retry acquisition
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (Date.now() - start > 10_000) {
|
|
182
|
+
// not successful in 10 minutes, give up
|
|
183
|
+
throw new Error('Timed out while waiting for screenshot report lock', {
|
|
184
|
+
cause: error,
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// sleep before next attempt
|
|
189
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
return fn()
|
|
195
|
+
} finally {
|
|
196
|
+
try {
|
|
197
|
+
unlinkSync(REPORT_LOCK)
|
|
198
|
+
} catch {
|
|
199
|
+
// lock file may already be removed
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// timestamp string
|
|
205
|
+
// separators=false produces YYYYMMDDHHMMSS for report file name
|
|
206
|
+
// separators=true produces YYYY-MM-DD HH:MM:SS for report title
|
|
207
|
+
function reportTimestamp(date: Date, separators = false): string {
|
|
208
|
+
const pad2 = (n: number) => String(n).padStart(2, '0')
|
|
209
|
+
const dateSeparator = separators ? '-' : ''
|
|
210
|
+
const midSeparator = separators ? ' ' : ''
|
|
211
|
+
const timeSeparator = separators ? ':' : ''
|
|
212
|
+
return [
|
|
213
|
+
date.getFullYear(),
|
|
214
|
+
dateSeparator,
|
|
215
|
+
pad2(date.getMonth() + 1),
|
|
216
|
+
dateSeparator,
|
|
217
|
+
pad2(date.getDate()),
|
|
218
|
+
midSeparator,
|
|
219
|
+
pad2(date.getHours()),
|
|
220
|
+
timeSeparator,
|
|
221
|
+
pad2(date.getMinutes()),
|
|
222
|
+
timeSeparator,
|
|
223
|
+
pad2(date.getSeconds()),
|
|
224
|
+
].join('')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// helper to escape a string for safe interpolation into the HTML report
|
|
228
|
+
function escapeHtml(value: string): string {
|
|
229
|
+
return value.replace(/[&<>"']/g, char => ({
|
|
230
|
+
'&': '&',
|
|
231
|
+
'<': '<',
|
|
232
|
+
'>': '>',
|
|
233
|
+
'"': '"',
|
|
234
|
+
'\'': ''',
|
|
235
|
+
}[char] ?? char))
|
|
236
|
+
}
|