code-foundry 1.16.0 → 1.18.0
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/.github/workflows/qualified-foundry-publish.yml +144 -0
- package/CHANGELOG.md +14 -0
- package/README.md +2 -0
- package/docs/PERFORMANCE.md +11 -4
- package/docs/README.md +1 -0
- package/docs/product-quality.md +173 -0
- package/docs/qualified-publication.md +111 -0
- package/package.json +1 -1
- package/src/commands/qualified-publication.mjs +361 -0
- package/src/lib/product-quality.mjs +685 -0
- package/src/quality.mjs +17 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
5
|
+
import {
|
|
6
|
+
existsSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
mkdtempSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
realpathSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
statSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from 'node:fs'
|
|
16
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
17
|
+
import { pathToFileURL } from 'node:url'
|
|
18
|
+
import { tmpdir } from 'node:os'
|
|
19
|
+
import { gzipSync } from 'node:zlib'
|
|
20
|
+
|
|
21
|
+
/** @typedef {Record<string, any>} Profile */
|
|
22
|
+
/** @param {unknown} condition @param {string} message @returns {asserts condition} */
|
|
23
|
+
function requireValue(condition, message) {
|
|
24
|
+
if (!condition) throw new Error(message)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Refuse traversal and symlinks, including symlinked parents. @param {string} root @param {string} path */
|
|
28
|
+
export function ownedPath(root, path) {
|
|
29
|
+
requireValue(
|
|
30
|
+
typeof path === 'string' && path.length > 0 && !isAbsolute(path),
|
|
31
|
+
'Expected a relative repository path'
|
|
32
|
+
)
|
|
33
|
+
const base = realpathSync(root)
|
|
34
|
+
const destination = resolve(base, path)
|
|
35
|
+
const rel = relative(base, destination)
|
|
36
|
+
requireValue(
|
|
37
|
+
rel !== '' &&
|
|
38
|
+
rel !== '..' &&
|
|
39
|
+
!rel.startsWith('../') &&
|
|
40
|
+
!rel.startsWith('..\\') &&
|
|
41
|
+
!isAbsolute(rel),
|
|
42
|
+
'Path escapes its root'
|
|
43
|
+
)
|
|
44
|
+
let current = base
|
|
45
|
+
for (const part of rel.split(/[\\/]/)) {
|
|
46
|
+
current = join(current, part)
|
|
47
|
+
let entry
|
|
48
|
+
try {
|
|
49
|
+
entry = lstatSync(current)
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (/** @type {NodeJS.ErrnoException} */ (error).code !== 'ENOENT') throw error
|
|
52
|
+
}
|
|
53
|
+
requireValue(!entry?.isSymbolicLink(), 'Symlinks are not accepted in quality inputs or outputs')
|
|
54
|
+
}
|
|
55
|
+
return destination
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {unknown} value @param {string} name */
|
|
59
|
+
function budget(value, name) {
|
|
60
|
+
requireValue(
|
|
61
|
+
typeof value === 'number' && Number.isFinite(value) && value >= 0,
|
|
62
|
+
`${name} must be a finite nonnegative budget`
|
|
63
|
+
)
|
|
64
|
+
return value
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @param {string} value */
|
|
68
|
+
function decode(value) {
|
|
69
|
+
return value.replace(/&(#x[0-9a-f]+|#\d+|amp|quot|apos|lt|gt);/gi, (_, entity) => {
|
|
70
|
+
if (entity[0] === '#')
|
|
71
|
+
return String.fromCodePoint(
|
|
72
|
+
entity[1].toLowerCase() === 'x' ? parseInt(entity.slice(2), 16) : Number(entity.slice(1))
|
|
73
|
+
)
|
|
74
|
+
return /** @type {Record<string,string>} */ ({
|
|
75
|
+
amp: '&',
|
|
76
|
+
quot: '"',
|
|
77
|
+
apos: "'",
|
|
78
|
+
lt: '<',
|
|
79
|
+
gt: '>',
|
|
80
|
+
})[entity.toLowerCase()]
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Conservative generated-HTML tag reader, not a DOM implementation.
|
|
85
|
+
* Raw-text HTML elements and comments cannot create fake metadata or links.
|
|
86
|
+
* @param {string} html
|
|
87
|
+
*/
|
|
88
|
+
export function readTags(html) {
|
|
89
|
+
const clean = html
|
|
90
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
91
|
+
.replace(
|
|
92
|
+
/(<(script|style|textarea|template|xmp|iframe|noembed|noframes|noscript)\b(?:"[^"]*"|'[^']*'|[^'">])*>)[\s\S]*?<\/\2\s*>/gi,
|
|
93
|
+
'$1'
|
|
94
|
+
)
|
|
95
|
+
.replace(
|
|
96
|
+
/(<title\b(?:"[^"]*"|'[^']*'|[^'">])*>)([\s\S]*?)<\/title\s*>/gi,
|
|
97
|
+
(_, open, content) => `${open}${content.replace(/<[^>]*>/g, '')}</title>`
|
|
98
|
+
)
|
|
99
|
+
/** @type {Array<{ name: string, attrs: Record<string,string> }>} */
|
|
100
|
+
const tags = []
|
|
101
|
+
for (const match of clean.matchAll(/<([a-z][a-z0-9:-]*)\b((?:"[^"]*"|'[^']*'|[^'">])*)>/gi)) {
|
|
102
|
+
/** @type {Record<string,string>} */
|
|
103
|
+
const attrs = Object.create(null)
|
|
104
|
+
for (const attr of match[2].matchAll(
|
|
105
|
+
/([a-z_:][-\w:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/gi
|
|
106
|
+
)) {
|
|
107
|
+
const key = attr[1].toLowerCase()
|
|
108
|
+
requireValue(!(key in attrs), `Duplicate HTML attribute: ${key}`)
|
|
109
|
+
attrs[key] = decode(attr[2] ?? attr[3] ?? attr[4] ?? '')
|
|
110
|
+
}
|
|
111
|
+
tags.push({ name: match[1].toLowerCase(), attrs })
|
|
112
|
+
}
|
|
113
|
+
return { clean, tags }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** @param {string} root @param {Profile} profile */
|
|
117
|
+
export function checkStaticSite(root, profile) {
|
|
118
|
+
const dist = ownedPath(root, profile.dist)
|
|
119
|
+
requireValue(statSync(dist).isDirectory(), 'Static dist must be a directory')
|
|
120
|
+
const origin = new URL(profile.origin)
|
|
121
|
+
requireValue(
|
|
122
|
+
['http:', 'https:'].includes(origin.protocol) &&
|
|
123
|
+
!origin.username &&
|
|
124
|
+
!origin.password &&
|
|
125
|
+
origin.pathname === '/' &&
|
|
126
|
+
!origin.search &&
|
|
127
|
+
!origin.hash,
|
|
128
|
+
'origin must be an HTTP(S) origin without credentials or a path'
|
|
129
|
+
)
|
|
130
|
+
requireValue(
|
|
131
|
+
Array.isArray(profile.routes) && profile.routes.length > 0,
|
|
132
|
+
'Static routes must be nonempty'
|
|
133
|
+
)
|
|
134
|
+
const limits = profile.budgets ?? {}
|
|
135
|
+
const htmlBudget = budget(limits.htmlBytes, 'htmlBytes')
|
|
136
|
+
const scriptBudget = budget(limits.javascriptBytes, 'javascriptBytes')
|
|
137
|
+
const imageBudget = budget(limits.imageBytes, 'imageBytes')
|
|
138
|
+
/** @type {Map<string, { file: string, clean: string, tags: ReturnType<typeof readTags>['tags'] }>} */
|
|
139
|
+
const routes = new Map()
|
|
140
|
+
/** @type {Map<string, string[]>} */
|
|
141
|
+
const routeAssets = new Map()
|
|
142
|
+
for (const route of profile.routes) {
|
|
143
|
+
requireValue(
|
|
144
|
+
typeof route.path === 'string' && route.path.startsWith('/') && !route.path.startsWith('//'),
|
|
145
|
+
'Routes must be origin-relative paths'
|
|
146
|
+
)
|
|
147
|
+
const url = new URL(route.path, origin)
|
|
148
|
+
requireValue(
|
|
149
|
+
url.origin === origin.origin && !url.search && !url.hash && !routes.has(url.pathname),
|
|
150
|
+
'Duplicate or invalid static route'
|
|
151
|
+
)
|
|
152
|
+
const javascriptAssets = route.javascriptAssets ?? []
|
|
153
|
+
requireValue(
|
|
154
|
+
Array.isArray(javascriptAssets) &&
|
|
155
|
+
javascriptAssets.every((asset) => typeof asset === 'string' && asset.length > 0),
|
|
156
|
+
`${url.pathname}: javascriptAssets must be a list of nonempty strings`
|
|
157
|
+
)
|
|
158
|
+
const file = ownedPath(dist, route.html)
|
|
159
|
+
const html = readFileSync(file, 'utf8')
|
|
160
|
+
routes.set(url.pathname, { file, ...readTags(html) })
|
|
161
|
+
routeAssets.set(url.pathname, javascriptAssets)
|
|
162
|
+
}
|
|
163
|
+
/** @type {Array<{ route: string, htmlBytes: number, javascriptBytes: number, imageBytes: number }>} */
|
|
164
|
+
const metrics = []
|
|
165
|
+
for (const [path, page] of routes) {
|
|
166
|
+
const canonical = new URL(path, origin).href
|
|
167
|
+
requireValue(
|
|
168
|
+
!page.tags.some((tag) => tag.name === 'base'),
|
|
169
|
+
`${path}: base elements require a repository-owned checker`
|
|
170
|
+
)
|
|
171
|
+
const title = page.clean.match(/<title(?:\s[^>]*)?>([^<]*)<\/title\s*>/i)?.[1]?.trim()
|
|
172
|
+
requireValue(title, `${path}: missing or empty title`)
|
|
173
|
+
const descriptions = page.tags.filter(
|
|
174
|
+
(tag) => tag.name === 'meta' && tag.attrs.name?.toLowerCase() === 'description'
|
|
175
|
+
)
|
|
176
|
+
requireValue(
|
|
177
|
+
descriptions.length === 1 && descriptions[0].attrs.content?.trim(),
|
|
178
|
+
`${path}: expected one nonempty meta description`
|
|
179
|
+
)
|
|
180
|
+
const canonicals = page.tags.filter(
|
|
181
|
+
(tag) =>
|
|
182
|
+
tag.name === 'link' && tag.attrs.rel?.toLowerCase().split(/\s+/).includes('canonical')
|
|
183
|
+
)
|
|
184
|
+
requireValue(
|
|
185
|
+
canonicals.length === 1 && canonicals[0].attrs.href === canonical,
|
|
186
|
+
`${path}: canonical URL mismatch`
|
|
187
|
+
)
|
|
188
|
+
requireValue(
|
|
189
|
+
!page.tags.some(
|
|
190
|
+
(tag) =>
|
|
191
|
+
tag.name === 'meta' &&
|
|
192
|
+
['robots', 'googlebot'].includes(tag.attrs.name?.toLowerCase()) &&
|
|
193
|
+
/(?:^|[,\s])noindex(?:$|[,\s])/i.test(tag.attrs.content ?? '')
|
|
194
|
+
),
|
|
195
|
+
`${path}: unexpected noindex`
|
|
196
|
+
)
|
|
197
|
+
const scripts = new Set(routeAssets.get(path))
|
|
198
|
+
const images = new Set()
|
|
199
|
+
for (const tag of page.tags) {
|
|
200
|
+
if (tag.name === 'script' && tag.attrs.src) scripts.add(tag.attrs.src)
|
|
201
|
+
if (tag.name === 'img' && tag.attrs.src && !tag.attrs.src.startsWith('data:'))
|
|
202
|
+
images.add(tag.attrs.src)
|
|
203
|
+
if (['img', 'source'].includes(tag.name) && tag.attrs.srcset) {
|
|
204
|
+
requireValue(
|
|
205
|
+
!tag.attrs.srcset.includes('data:'),
|
|
206
|
+
'Data-URL srcsets require a repository-owned checker'
|
|
207
|
+
)
|
|
208
|
+
for (const candidate of tag.attrs.srcset.split(',')) {
|
|
209
|
+
const resource = candidate.trim().split(/\s+/)[0]
|
|
210
|
+
requireValue(resource, `${path}: srcset contains an empty candidate`)
|
|
211
|
+
images.add(resource)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (tag.name !== 'a' || !tag.attrs.href) continue
|
|
215
|
+
const target = new URL(tag.attrs.href, canonical)
|
|
216
|
+
if (!['http:', 'https:'].includes(target.protocol) || target.origin !== origin.origin)
|
|
217
|
+
continue
|
|
218
|
+
const targetPage = routes.get(target.pathname)
|
|
219
|
+
if (targetPage) {
|
|
220
|
+
if (target.hash) {
|
|
221
|
+
const id = decodeURIComponent(target.hash.slice(1))
|
|
222
|
+
requireValue(
|
|
223
|
+
targetPage.tags.some(
|
|
224
|
+
(entry) => entry.attrs.id === id || (entry.name === 'a' && entry.attrs.name === id)
|
|
225
|
+
),
|
|
226
|
+
`${path}: missing anchor ${target.hash}`
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
const asset = ownedPath(dist, decodeURIComponent(target.pathname).replace(/^\//, ''))
|
|
231
|
+
requireValue(
|
|
232
|
+
existsSync(asset) && lstatSync(asset).isFile(),
|
|
233
|
+
`${path}: broken internal link ${target.pathname}`
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const assetBytes = (/** @type {Set<string>} */ assets) =>
|
|
238
|
+
[...assets].reduce((sum, resource) => {
|
|
239
|
+
const url = new URL(resource, canonical)
|
|
240
|
+
requireValue(
|
|
241
|
+
url.origin === origin.origin,
|
|
242
|
+
`${path}: external resource needs a measured repository-owned budget: ${url.origin}`
|
|
243
|
+
)
|
|
244
|
+
const file = ownedPath(dist, decodeURIComponent(url.pathname).replace(/^\//, ''))
|
|
245
|
+
requireValue(lstatSync(file).isFile(), 'Budget assets must be regular files')
|
|
246
|
+
return sum + statSync(file).size
|
|
247
|
+
}, 0)
|
|
248
|
+
const inlineBytes = [
|
|
249
|
+
...readFileSync(page.file, 'utf8')
|
|
250
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
251
|
+
.matchAll(/<script\b(?:"[^"]*"|'[^']*'|[^'">])*?>([\s\S]*?)<\/script\s*>/gi),
|
|
252
|
+
].reduce((sum, match) => sum + Buffer.byteLength(match[1]), 0)
|
|
253
|
+
const result = {
|
|
254
|
+
route: path,
|
|
255
|
+
htmlBytes: statSync(page.file).size,
|
|
256
|
+
javascriptBytes: assetBytes(scripts) + inlineBytes,
|
|
257
|
+
imageBytes: assetBytes(images),
|
|
258
|
+
}
|
|
259
|
+
requireValue(
|
|
260
|
+
result.htmlBytes <= htmlBudget &&
|
|
261
|
+
result.javascriptBytes <= scriptBudget &&
|
|
262
|
+
result.imageBytes <= imageBudget,
|
|
263
|
+
`${path}: resource budget exceeded`
|
|
264
|
+
)
|
|
265
|
+
metrics.push(result)
|
|
266
|
+
}
|
|
267
|
+
if (profile.sitemap) {
|
|
268
|
+
const sitemap = readFileSync(ownedPath(dist, profile.sitemap), 'utf8')
|
|
269
|
+
requireValue(!/<!DOCTYPE|<!ENTITY/i.test(sitemap), 'Sitemap entities are not supported')
|
|
270
|
+
const sitemapWithoutComments = sitemap.replace(/<!--[\s\S]*?-->/g, '')
|
|
271
|
+
const locations = new Set(
|
|
272
|
+
[
|
|
273
|
+
...sitemapWithoutComments.matchAll(
|
|
274
|
+
/<url\b[^>]*>[\s\S]*?<loc>\s*([^<]+)\s*<\/loc>[\s\S]*?<\/url\s*>/gi
|
|
275
|
+
),
|
|
276
|
+
].map((match) => decode(match[1].trim()))
|
|
277
|
+
)
|
|
278
|
+
for (const path of routes.keys())
|
|
279
|
+
requireValue(locations.has(new URL(path, origin).href), `${path}: absent from sitemap`)
|
|
280
|
+
const robots = readFileSync(ownedPath(dist, profile.robots ?? 'robots.txt'), 'utf8')
|
|
281
|
+
requireValue(
|
|
282
|
+
robots
|
|
283
|
+
.split(/\r?\n/)
|
|
284
|
+
.some((line) => line.trim() === `Sitemap: ${new URL(profile.sitemap, origin).href}`),
|
|
285
|
+
'robots.txt must reference the checked sitemap'
|
|
286
|
+
)
|
|
287
|
+
requireValue(!/^Disallow:\s*\/\s*$/im.test(robots), 'robots.txt blocks the entire site')
|
|
288
|
+
}
|
|
289
|
+
if (profile.redirects) {
|
|
290
|
+
requireValue(
|
|
291
|
+
Array.isArray(profile.redirects.rules) && profile.redirects.rules.length > 0,
|
|
292
|
+
'Redirect expectations must be nonempty'
|
|
293
|
+
)
|
|
294
|
+
const lines = readFileSync(ownedPath(dist, profile.redirects.file), 'utf8')
|
|
295
|
+
.split(/\r?\n/)
|
|
296
|
+
.map((line) => line.trim())
|
|
297
|
+
.filter((line) => line && !line.startsWith('#'))
|
|
298
|
+
for (const rule of profile.redirects.rules) {
|
|
299
|
+
requireValue(
|
|
300
|
+
typeof rule.from === 'string' &&
|
|
301
|
+
rule.from.startsWith('/') &&
|
|
302
|
+
typeof rule.to === 'string' &&
|
|
303
|
+
[301, 302, 303, 307, 308].includes(rule.status),
|
|
304
|
+
'Invalid redirect expectation'
|
|
305
|
+
)
|
|
306
|
+
// Exact simple Cloudflare/Netlify-style _redirects lines only; this is not a deployed routing probe.
|
|
307
|
+
const matches = lines.filter((line) => line.split(/\s+/)[0] === rule.from)
|
|
308
|
+
requireValue(
|
|
309
|
+
matches.length === 1 &&
|
|
310
|
+
matches[0].split(/\s+/).join(' ') === `${rule.from} ${rule.to} ${rule.status}`,
|
|
311
|
+
`Missing or ambiguous redirect: ${rule.from}`
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return metrics
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** @param {unknown} argv @param {string} root @param {string} output @param {number} timeout */
|
|
319
|
+
export function runQualityCommand(argv, root, output, timeout = 120_000) {
|
|
320
|
+
requireValue(
|
|
321
|
+
Array.isArray(argv) &&
|
|
322
|
+
argv.length > 0 &&
|
|
323
|
+
argv.every((value) => typeof value === 'string' && value.length > 0),
|
|
324
|
+
'Commands must be nonempty argv arrays'
|
|
325
|
+
)
|
|
326
|
+
const args = /** @type {string[]} */ (argv).map((value) => value.replaceAll('{output}', output))
|
|
327
|
+
const result = spawnSync(args[0], args.slice(1), {
|
|
328
|
+
cwd: root,
|
|
329
|
+
encoding: 'utf8',
|
|
330
|
+
timeout,
|
|
331
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
332
|
+
env: { ...process.env, CI: 'true', CODE_FOUNDRY_QUALITY_OUTPUT_DIR: output },
|
|
333
|
+
})
|
|
334
|
+
if (result.stdout) process.stderr.write(result.stdout)
|
|
335
|
+
if (result.stderr) process.stderr.write(result.stderr)
|
|
336
|
+
requireValue(
|
|
337
|
+
!result.error && result.status === 0,
|
|
338
|
+
`Quality command failed: ${args[0]} (${result.status ?? result.error?.message})`
|
|
339
|
+
)
|
|
340
|
+
return result.stdout
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** @param {string} root @param {Profile} profile @param {string} output */
|
|
344
|
+
export function checkWorker(root, profile, output) {
|
|
345
|
+
requireValue(
|
|
346
|
+
Array.isArray(profile.files) && profile.files.length > 0,
|
|
347
|
+
'Worker bundle files are required'
|
|
348
|
+
)
|
|
349
|
+
runQualityCommand(profile.buildCommand, root, output)
|
|
350
|
+
let rawBytes = 0
|
|
351
|
+
let gzipBytes = 0
|
|
352
|
+
for (const name of new Set(/** @type {string[]} */ (profile.files))) {
|
|
353
|
+
const file = ownedPath(output, name)
|
|
354
|
+
requireValue(lstatSync(file).isFile(), 'Worker bundle must be a regular file')
|
|
355
|
+
const data = readFileSync(file)
|
|
356
|
+
rawBytes += data.length
|
|
357
|
+
gzipBytes += gzipSync(data).length
|
|
358
|
+
}
|
|
359
|
+
requireValue(rawBytes > 0, 'Worker bundle is empty')
|
|
360
|
+
requireValue(
|
|
361
|
+
rawBytes <= budget(profile.budgets?.rawBytes, 'rawBytes'),
|
|
362
|
+
'Worker raw-byte budget exceeded'
|
|
363
|
+
)
|
|
364
|
+
requireValue(
|
|
365
|
+
gzipBytes <= budget(profile.budgets?.gzipBytes, 'gzipBytes'),
|
|
366
|
+
'Worker gzip budget exceeded'
|
|
367
|
+
)
|
|
368
|
+
runQualityCommand(profile.runtimeCommand, root, output)
|
|
369
|
+
return { rawBytes, gzipBytes }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** @param {string} root @param {Profile} profile @param {string} output */
|
|
373
|
+
export function checkPackage(root, profile, output) {
|
|
374
|
+
const manifest = JSON.parse(readFileSync(ownedPath(root, 'package.json'), 'utf8'))
|
|
375
|
+
requireValue(
|
|
376
|
+
typeof manifest.name === 'string' && typeof manifest.version === 'string',
|
|
377
|
+
'Package identity is required'
|
|
378
|
+
)
|
|
379
|
+
requireValue(
|
|
380
|
+
Array.isArray(profile.imports) &&
|
|
381
|
+
profile.imports.length > 0 &&
|
|
382
|
+
profile.imports.every(
|
|
383
|
+
(/** @type {unknown} */ value) =>
|
|
384
|
+
typeof value === 'string' &&
|
|
385
|
+
(value === manifest.name || value.startsWith(`${manifest.name}/`))
|
|
386
|
+
),
|
|
387
|
+
'Imports must exercise the candidate package exports'
|
|
388
|
+
)
|
|
389
|
+
const runtimes = profile.runtimes ?? ['node']
|
|
390
|
+
requireValue(
|
|
391
|
+
Array.isArray(runtimes) &&
|
|
392
|
+
runtimes.length > 0 &&
|
|
393
|
+
runtimes.every((/** @type {unknown} */ value) =>
|
|
394
|
+
['node', 'bun'].includes(/** @type {string} */ (value))
|
|
395
|
+
),
|
|
396
|
+
'Package runtimes must be a nonempty node/bun list'
|
|
397
|
+
)
|
|
398
|
+
// Pack outside the consumer repository so test installations cannot become package input.
|
|
399
|
+
const temporary = mkdtempSync(join(tmpdir(), 'foundry-package-quality-'))
|
|
400
|
+
try {
|
|
401
|
+
const pack = JSON.parse(
|
|
402
|
+
runQualityCommand(
|
|
403
|
+
['npm', 'pack', '--ignore-scripts', '--json', '--pack-destination', temporary],
|
|
404
|
+
root,
|
|
405
|
+
output
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
requireValue(
|
|
409
|
+
Array.isArray(pack) && pack.length === 1 && /^[A-Za-z0-9._-]+\.tgz$/.test(pack[0].filename),
|
|
410
|
+
'Expected one package archive'
|
|
411
|
+
)
|
|
412
|
+
requireValue(
|
|
413
|
+
pack[0].files.every(
|
|
414
|
+
(/** @type {{path:string}} */ file) => !/^\.code-foundry(?:\/|$)/.test(file.path)
|
|
415
|
+
),
|
|
416
|
+
'Exclude .code-foundry quality evidence from the published package'
|
|
417
|
+
)
|
|
418
|
+
const archive = ownedPath(temporary, pack[0].filename)
|
|
419
|
+
const archiveSha256 = createHash('sha256').update(readFileSync(archive)).digest('hex')
|
|
420
|
+
const consumer = join(temporary, 'consumer')
|
|
421
|
+
mkdirSync(consumer)
|
|
422
|
+
writeFileSync(join(consumer, 'package.json'), '{"private":true,"type":"module"}\n')
|
|
423
|
+
const args = [
|
|
424
|
+
'npm',
|
|
425
|
+
'install',
|
|
426
|
+
'--ignore-scripts',
|
|
427
|
+
'--no-audit',
|
|
428
|
+
'--no-fund',
|
|
429
|
+
'--package-lock=false',
|
|
430
|
+
]
|
|
431
|
+
if (profile.offline !== false) args.push('--offline')
|
|
432
|
+
args.push(archive)
|
|
433
|
+
runQualityCommand(args, consumer, output)
|
|
434
|
+
const script = `for (const name of ${JSON.stringify(profile.imports)}) await import(name)\n`
|
|
435
|
+
writeFileSync(join(consumer, 'smoke.mjs'), script)
|
|
436
|
+
for (const runtime of runtimes)
|
|
437
|
+
runQualityCommand(
|
|
438
|
+
[runtime === 'node' ? process.execPath : 'bun', 'smoke.mjs'],
|
|
439
|
+
consumer,
|
|
440
|
+
output
|
|
441
|
+
)
|
|
442
|
+
requireValue(
|
|
443
|
+
createHash('sha256').update(readFileSync(archive)).digest('hex') === archiveSha256,
|
|
444
|
+
'Package archive changed during compatibility testing'
|
|
445
|
+
)
|
|
446
|
+
return {
|
|
447
|
+
name: manifest.name,
|
|
448
|
+
version: manifest.version,
|
|
449
|
+
archiveSha256,
|
|
450
|
+
node: process.version,
|
|
451
|
+
runtimes,
|
|
452
|
+
}
|
|
453
|
+
} finally {
|
|
454
|
+
rmSync(temporary, { recursive: true, force: true })
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Native Playwright tests retain repo-owned journeys and screenshot baselines.
|
|
459
|
+
* @param {string} root @param {Profile} profile @param {string} output
|
|
460
|
+
*/
|
|
461
|
+
export function browserFiles(root, profile, output) {
|
|
462
|
+
requireValue(
|
|
463
|
+
Array.isArray(profile.routes) && profile.routes.length > 0,
|
|
464
|
+
'Browser routes are required'
|
|
465
|
+
)
|
|
466
|
+
const url = new URL(profile.baseURL)
|
|
467
|
+
requireValue(
|
|
468
|
+
['http:', 'https:'].includes(url.protocol) &&
|
|
469
|
+
!url.username &&
|
|
470
|
+
!url.password &&
|
|
471
|
+
url.pathname === '/' &&
|
|
472
|
+
!url.search &&
|
|
473
|
+
!url.hash,
|
|
474
|
+
'browser baseURL must be an HTTP(S) origin without credentials or a path'
|
|
475
|
+
)
|
|
476
|
+
requireValue(
|
|
477
|
+
profile.allowRemote === true || ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname),
|
|
478
|
+
'Remote browser checks require allowRemote: true'
|
|
479
|
+
)
|
|
480
|
+
const journeys = ownedPath(root, profile.journeys)
|
|
481
|
+
requireValue(lstatSync(journeys).isFile(), 'A repository-owned journey suite is required')
|
|
482
|
+
const snapshots = ownedPath(root, profile.snapshots ?? '.github/quality-snapshots')
|
|
483
|
+
const routes = profile.routes.map((/** @type {Profile} */ route) => {
|
|
484
|
+
requireValue(
|
|
485
|
+
typeof route.id === 'string' && /^[a-z0-9-]+$/.test(route.id),
|
|
486
|
+
'Route IDs must be safe snapshot names'
|
|
487
|
+
)
|
|
488
|
+
requireValue(
|
|
489
|
+
typeof route.path === 'string' &&
|
|
490
|
+
route.path.startsWith('/') &&
|
|
491
|
+
new URL(route.path, url).origin === url.origin,
|
|
492
|
+
'Browser routes must stay on the selected origin'
|
|
493
|
+
)
|
|
494
|
+
requireValue(
|
|
495
|
+
typeof route.readySelector === 'string' && route.readySelector.length > 0,
|
|
496
|
+
'A readySelector is required for each browser route'
|
|
497
|
+
)
|
|
498
|
+
return route
|
|
499
|
+
})
|
|
500
|
+
requireValue(
|
|
501
|
+
new Set(routes.map((/** @type {Profile} */ route) => route.id)).size === routes.length,
|
|
502
|
+
'Duplicate browser route IDs'
|
|
503
|
+
)
|
|
504
|
+
if (profile.webServerCommand !== undefined)
|
|
505
|
+
requireValue(
|
|
506
|
+
typeof profile.webServerCommand === 'string' && profile.webServerCommand.length > 0,
|
|
507
|
+
'webServerCommand must be a nonempty command string'
|
|
508
|
+
)
|
|
509
|
+
const spec = `import { createRequire } from 'node:module'\nconst require = createRequire(${JSON.stringify(join(root, 'package.json'))})\nconst { test, expect } = require('@playwright/test')\nconst AxeBuilder = require('@axe-core/playwright').default\nawait import(${JSON.stringify(pathToFileURL(journeys).href)})\nfor (const route of ${JSON.stringify(routes)}) {\n test('quality: ' + route.id, async ({ page }) => {\n const errors = []\n page.on('pageerror', error => errors.push(error.message))\n const response = await page.goto(route.path)\n expect(response?.ok()).toBeTruthy()\n await expect(page.locator(route.readySelector)).toBeVisible()\n const accessibility = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze()\n expect(accessibility.violations).toEqual([])\n if (route.visual === true) await expect(page).toHaveScreenshot(route.id + '.png', { animations: 'disabled' })\n expect(errors).toEqual([])\n })\n}\n`
|
|
510
|
+
const config = {
|
|
511
|
+
testDir: output,
|
|
512
|
+
testMatch: 'quality.spec.mjs',
|
|
513
|
+
outputDir: join(output, 'results'),
|
|
514
|
+
workers: 1,
|
|
515
|
+
retries: 0,
|
|
516
|
+
updateSnapshots: 'none',
|
|
517
|
+
ignoreSnapshots: false,
|
|
518
|
+
reporter: [['json', { outputFile: join(output, 'playwright.json') }]],
|
|
519
|
+
snapshotPathTemplate: `${snapshots}/{arg}{ext}`,
|
|
520
|
+
use: {
|
|
521
|
+
baseURL: profile.baseURL,
|
|
522
|
+
browserName: 'chromium',
|
|
523
|
+
viewport: { width: 1280, height: 720 },
|
|
524
|
+
trace: 'retain-on-failure',
|
|
525
|
+
},
|
|
526
|
+
...(profile.webServerCommand
|
|
527
|
+
? {
|
|
528
|
+
webServer: {
|
|
529
|
+
command: profile.webServerCommand,
|
|
530
|
+
cwd: root,
|
|
531
|
+
url: profile.baseURL,
|
|
532
|
+
reuseExistingServer: false,
|
|
533
|
+
timeout: 120_000,
|
|
534
|
+
},
|
|
535
|
+
}
|
|
536
|
+
: {}),
|
|
537
|
+
}
|
|
538
|
+
return { spec, config }
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** @param {string} root @param {Profile} profile @param {string} output */
|
|
542
|
+
export function checkBrowser(root, profile, output) {
|
|
543
|
+
const require = createRequire(join(root, 'package.json'))
|
|
544
|
+
const cli = join(dirname(require.resolve('@playwright/test/package.json')), 'cli.js')
|
|
545
|
+
require.resolve('@axe-core/playwright')
|
|
546
|
+
const { spec, config } = browserFiles(root, profile, output)
|
|
547
|
+
writeFileSync(join(output, 'quality.spec.mjs'), spec)
|
|
548
|
+
writeFileSync(
|
|
549
|
+
join(output, 'playwright.config.mjs'),
|
|
550
|
+
`export default ${JSON.stringify(config, null, 2)}\n`
|
|
551
|
+
)
|
|
552
|
+
runQualityCommand(
|
|
553
|
+
[process.execPath, cli, 'test', '--config', join(output, 'playwright.config.mjs')],
|
|
554
|
+
root,
|
|
555
|
+
output,
|
|
556
|
+
600_000
|
|
557
|
+
)
|
|
558
|
+
const report = JSON.parse(readFileSync(join(output, 'playwright.json'), 'utf8'))
|
|
559
|
+
validateBrowserReport(
|
|
560
|
+
report,
|
|
561
|
+
profile.routes.map((/** @type {Profile} */ route) => `quality: ${route.id}`)
|
|
562
|
+
)
|
|
563
|
+
return { tests: report.stats.expected, report: join(output, 'playwright.json') }
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Reject expected-failure annotations as well as skips and incomplete reports.
|
|
567
|
+
* @param {Profile} report @param {string[]} expectedRoutes
|
|
568
|
+
*/
|
|
569
|
+
export function validateBrowserReport(report, expectedRoutes) {
|
|
570
|
+
/** @type {Profile[]} */
|
|
571
|
+
const specs = []
|
|
572
|
+
const visit = (/** @type {Profile} */ suite) => {
|
|
573
|
+
specs.push(...(suite.specs ?? []))
|
|
574
|
+
for (const child of suite.suites ?? []) visit(child)
|
|
575
|
+
}
|
|
576
|
+
for (const suite of report.suites ?? []) visit(suite)
|
|
577
|
+
requireValue(
|
|
578
|
+
report.stats?.unexpected === 0 &&
|
|
579
|
+
report.stats?.flaky === 0 &&
|
|
580
|
+
report.stats?.skipped === 0 &&
|
|
581
|
+
report.stats?.expected > expectedRoutes.length,
|
|
582
|
+
'Browser checks need passing routes and at least one journey, without skips or flakes'
|
|
583
|
+
)
|
|
584
|
+
requireValue(
|
|
585
|
+
specs.length > expectedRoutes.length,
|
|
586
|
+
'Browser report has no repository-owned journey'
|
|
587
|
+
)
|
|
588
|
+
for (const title of expectedRoutes)
|
|
589
|
+
requireValue(
|
|
590
|
+
specs.filter((spec) => spec.title === title).length === 1,
|
|
591
|
+
`Missing or duplicate browser route: ${title}`
|
|
592
|
+
)
|
|
593
|
+
for (const spec of specs) {
|
|
594
|
+
requireValue(
|
|
595
|
+
spec.ok === true && Array.isArray(spec.tests) && spec.tests.length > 0,
|
|
596
|
+
'Browser specification did not pass'
|
|
597
|
+
)
|
|
598
|
+
for (const item of spec.tests)
|
|
599
|
+
requireValue(
|
|
600
|
+
item.expectedStatus === 'passed' &&
|
|
601
|
+
item.status === 'expected' &&
|
|
602
|
+
Array.isArray(item.results) &&
|
|
603
|
+
item.results.length > 0 &&
|
|
604
|
+
item.results.every((/** @type {Profile} */ value) => value.status === 'passed'),
|
|
605
|
+
'Expected failures and incomplete browser results are not accepted'
|
|
606
|
+
)
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** @param {string} root @param {Profile} config @param {string} [phase] */
|
|
611
|
+
export function runProductQuality(root, config, phase = 'build') {
|
|
612
|
+
requireValue(
|
|
613
|
+
config.schema_version === 1 && Array.isArray(config.profiles) && config.profiles.length > 0,
|
|
614
|
+
'Expected a version-1 quality manifest with profiles'
|
|
615
|
+
)
|
|
616
|
+
requireValue(['build', 'browser', 'deployed'].includes(phase), 'Unknown quality phase')
|
|
617
|
+
const ids = config.profiles.map((/** @type {Profile} */ profile) => profile.id)
|
|
618
|
+
requireValue(
|
|
619
|
+
ids.every((/** @type {unknown} */ id) => typeof id === 'string' && /^[a-z0-9-]+$/.test(id)) &&
|
|
620
|
+
new Set(ids).size === ids.length,
|
|
621
|
+
'Profile IDs must be unique safe names'
|
|
622
|
+
)
|
|
623
|
+
for (const profile of config.profiles) {
|
|
624
|
+
requireValue(
|
|
625
|
+
['static-site', 'web-app', 'worker', 'package'].includes(profile.type),
|
|
626
|
+
`Unknown quality profile: ${profile.type}`
|
|
627
|
+
)
|
|
628
|
+
requireValue(
|
|
629
|
+
['build', 'browser', 'deployed'].includes(
|
|
630
|
+
profile.phase ?? (profile.type === 'web-app' ? 'browser' : 'build')
|
|
631
|
+
),
|
|
632
|
+
'Unknown profile phase'
|
|
633
|
+
)
|
|
634
|
+
}
|
|
635
|
+
const profiles = config.profiles.filter(
|
|
636
|
+
(/** @type {Profile} */ profile) =>
|
|
637
|
+
(profile.phase ?? (profile.type === 'web-app' ? 'browser' : 'build')) === phase
|
|
638
|
+
)
|
|
639
|
+
requireValue(profiles.length > 0, 'No profiles apply to the selected phase')
|
|
640
|
+
const outputRoot = ownedPath(root, '.code-foundry/quality')
|
|
641
|
+
mkdirSync(outputRoot, { recursive: true })
|
|
642
|
+
const output = mkdtempSync(join(outputRoot, `${phase}-`))
|
|
643
|
+
/** @type {Profile[]} */
|
|
644
|
+
const results = []
|
|
645
|
+
const source = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' })
|
|
646
|
+
const dirty = spawnSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' })
|
|
647
|
+
const result = {
|
|
648
|
+
schema_version: 1,
|
|
649
|
+
run_id: randomUUID(),
|
|
650
|
+
phase,
|
|
651
|
+
status: 'failed',
|
|
652
|
+
profiles: results,
|
|
653
|
+
source_sha: source.status === 0 ? source.stdout.trim() : null,
|
|
654
|
+
dirty: dirty.status === 0 ? dirty.stdout.length > 0 : null,
|
|
655
|
+
config_sha256: createHash('sha256').update(JSON.stringify(config)).digest('hex'),
|
|
656
|
+
}
|
|
657
|
+
try {
|
|
658
|
+
for (const command of config.prepare?.[phase] ?? []) runQualityCommand(command, root, output)
|
|
659
|
+
for (const profile of profiles) {
|
|
660
|
+
const directory = join(output, profile.id)
|
|
661
|
+
mkdirSync(directory)
|
|
662
|
+
try {
|
|
663
|
+
let metrics
|
|
664
|
+
if (profile.type === 'static-site') metrics = checkStaticSite(root, profile)
|
|
665
|
+
else if (profile.type === 'worker') metrics = checkWorker(root, profile, directory)
|
|
666
|
+
else if (profile.type === 'package') metrics = checkPackage(root, profile, directory)
|
|
667
|
+
else metrics = checkBrowser(root, profile, directory)
|
|
668
|
+
result.profiles.push({ id: profile.id, type: profile.type, status: 'passed', metrics })
|
|
669
|
+
} catch (error) {
|
|
670
|
+
result.profiles.push({
|
|
671
|
+
id: profile.id,
|
|
672
|
+
type: profile.type,
|
|
673
|
+
status: 'failed',
|
|
674
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
675
|
+
})
|
|
676
|
+
throw error
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
result.status = 'passed'
|
|
680
|
+
return result
|
|
681
|
+
} finally {
|
|
682
|
+
writeFileSync(join(output, 'summary.json'), JSON.stringify(result, null, 2) + '\n')
|
|
683
|
+
console.error(`Quality evidence: ${relative(root, output)}/summary.json`)
|
|
684
|
+
}
|
|
685
|
+
}
|