test: [ENG-08] add verified TS coverage and paired performance reports

This commit is contained in:
Harvey Zhao committed 2026-09-11 22:01:48 +08:00
1 parent 8ed0a5228c
commit 4a9a26d4fc
38 files changed
+24335 -61

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const hash = value => createHash('sha256').update(value).digest('hex')
function inside(directory, file) {
const relative = path.relative(directory, file)
return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)
}
// Keep the original V8 files intact. c8's remap filter expects filesystem paths,
// while Node's source-map cache contains file URLs (including on Windows).
export function normalizeCoverageReports(rawDirectory, mappedDirectory, { root, moduleDirectory, inventory }) {
assert(!fs.existsSync(mappedDirectory), 'Normalized coverage must use a fresh directory')
fs.mkdirSync(mappedDirectory)
const sources = new Map([...inventory.files, ...inventory.excluded].map(item => [path.resolve(root, item.file), item]))
const reports = []
let moduleCount = 0
for (const file of fs.readdirSync(rawDirectory).filter(file => file.endsWith('.json')).sort()) {
const raw = fs.readFileSync(path.join(rawDirectory, file))
const report = JSON.parse(raw)
assert(Array.isArray(report.result), `Invalid V8 report: ${file}`)
const cache = report['source-map-cache'] || {}
const modules = report.result.filter(item => item.url.startsWith('file:') && inside(moduleDirectory, fileURLToPath(item.url)))
for (const module of modules) {
const map = cache[module.url]?.data
assert(map?.version === 3 && typeof map.mappings === 'string' && map.mappings.length > 0 && Array.isArray(map.sources) && map.sources.length > 0, `Missing source map: ${module.url}`)
assert.equal(map.sourcesContent?.length, map.sources.length, `Missing source contents: ${module.url}`)
for (const [index, source] of map.sources.entries()) {
const absolute = source.startsWith('file:') ? fileURLToPath(source) : source
assert(path.isAbsolute(absolute), `Unresolved source map path: ${source}`)
const enrolled = sources.get(path.normalize(absolute))
if (enrolled) {
assert.equal(typeof map.sourcesContent[index], 'string', `Missing mapped source: ${source}`)
assert.equal(hash(map.sourcesContent[index].replaceAll('\r\n', '\n')), enrolled.sha256, `Stale mapped source: ${source}`)
}
}
moduleCount++
}
let normalizedSources = 0
for (const entry of Object.values(cache)) {
if (!entry.data?.sources)
continue
entry.data.sources = entry.data.sources.map((source) => {
if (!source.startsWith('file:'))
return source
normalizedSources++
return fileURLToPath(source)
})
}
const normalized = `${JSON.stringify(report)}\n`
fs.writeFileSync(path.join(mappedDirectory, file), normalized)
reports.push({ file, rawSha256: hash(raw), normalizedSha256: hash(normalized), modules: modules.length, normalizedSources })
}
assert(moduleCount > 0, 'No source-mapped test modules were executed')
return { modules: moduleCount, reports }
}
+13
View File
@@ -0,0 +1,13 @@
{
"schemaVersion": 1,
"packages": ["artplayer", "artplayer-plugin-chapter"],
"exclude": {
"packages/artplayer/src/libs/screenfull.js": "Vendored dependency with separate provenance and browser compatibility tests."
},
"minimum": {
"packages/artplayer/src/lifecycle/scope.ts": { "lines": 95, "branches": 90, "functions": 95 },
"packages/artplayer/src/lifecycle/resources.ts": { "lines": 95, "branches": 90, "functions": 95 },
"packages/artplayer/src/lifecycle/instance.ts": { "lines": 95, "branches": 90, "functions": 95 },
"packages/artplayer/src/source/operation.ts": { "lines": 90, "branches": 85, "functions": 90 }
}
}
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import ts from 'typescript'
const walk = directory => fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => entry.isDirectory() ? walk(path.join(directory, entry.name)) : [path.join(directory, entry.name)])
const relative = (root, file) => path.relative(root, file).split(path.sep).join('/')
export function sourceInventory(root, policy) {
assert.equal(policy.schemaVersion, 1)
assert(policy.packages.length > 0)
const files = []
const excluded = []
for (const name of policy.packages) {
assert(/^artplayer(?:-[a-z-]+)?$/.test(name), `Invalid coverage package: ${name}`)
for (const file of walk(path.join(root, 'packages', name, 'src')).filter(file => /\.[jt]s$/.test(file))) {
const name = relative(root, file)
const source = fs.readFileSync(file, 'utf8').replaceAll('\r\n', '\n')
const sha256 = createHash('sha256').update(source).digest('hex')
const emitted = file.endsWith('.d.ts') ? '' : ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ESNext, module: ts.ModuleKind.ESNext } }).outputText.trim()
const reason = policy.exclude[name] || (/^(?:export\s*\{\s*\}\s*;?)?$/.test(emitted) ? 'Type-only module; no emitted runtime code.' : undefined)
if (reason) {
excluded.push({ file: name, sha256, reason })
}
else {
assert(!/\b(?:c8|v8|istanbul)\s+ignore\b/i.test(source), `Undocumented coverage suppression: ${name}`)
files.push({ file: name, sha256 })
}
}
}
for (const name of Object.keys(policy.exclude))
assert(excluded.some(item => item.file === name), `Stale coverage exclusion: ${name}`)
for (const name of Object.keys(policy.minimum))
assert(files.some(item => item.file === name), `Critical coverage file is absent or excluded: ${name}`)
return { files: files.sort((a, b) => a.file.localeCompare(b.file)), excluded }
}
export function analyzeCoverage(root, summary, inventory, policy) {
const normalized = new Map(Object.entries(summary).filter(([file]) => file !== 'total').map(([file, value]) => [relative(root, file), value]))
assert.deepEqual([...normalized.keys()].sort(), inventory.files.map(item => item.file).sort(), 'Coverage source inventory differs; a source map or zero-coverage file is missing')
const violations = []
const files = inventory.files.map((source) => {
const metrics = normalized.get(source.file)
const values = {}
for (const metric of ['lines', 'branches', 'functions', 'statements']) {
const { covered, total, skipped } = metrics[metric]
assert(Number.isInteger(total) && total >= 0 && Number.isInteger(covered) && covered >= 0 && covered <= total && skipped === 0, `Invalid or ignored ${metric}: ${source.file}`)
const percent = total ? covered * 100 / total : 100
values[metric] = { covered, total, percent }
const minimum = policy.minimum[source.file]?.[metric]
if (minimum !== undefined && (total === 0 || percent < minimum))
violations.push({ file: source.file, metric, minimum, actual: percent, total })
}
return { ...source, metrics: values }
})
return { files, excluded: inventory.excluded, minimum: policy.minimum, violations }
}
+76
View File
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { normalizeCoverageReports } from './coverage-maps.mjs'
import { analyzeCoverage, sourceInventory } from './coverage-report.mjs'
const root = fileURLToPath(new URL('../', import.meta.url))
const require = createRequire(import.meta.url)
const read = file => JSON.parse(fs.readFileSync(file, 'utf8'))
const write = (file, data) => fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`)
export function runCoverage() {
assert.equal(process.env.npm_config_user_agent?.split(' ')[0], 'yarn/1.22.22', 'Run yarn test:coverage with the pinned Yarn')
const policy = read(path.join(root, 'scripts/coverage-policy.json'))
const inventory = sourceInventory(root, policy)
const unit = read(path.join(root, 'package.json')).scripts['test:unit']
assert(unit.startsWith('node --test '), 'Coverage expects the shared explicit Node unit test list')
const tests = unit.slice('node --test '.length).split(' ')
assert(tests.length > 0 && tests.every(file => /^test\/[a-z-]+\.test\.js$/.test(file)), 'Unsupported unit test command; update the coverage runner with it')
const cache = path.join(root, 'refactor/.cache/coverage')
fs.mkdirSync(cache, { recursive: true })
const output = fs.mkdtempSync(path.join(cache, 'run-'))
const configFile = path.join(output, 'c8.json')
const config = {
'all': true,
'exclude-after-remap': true,
'include': policy.packages.map(name => `packages/${name}/src/**`),
'exclude': inventory.excluded.map(item => item.file),
'src': policy.packages.map(name => `packages/${name}/src`),
'extension': ['.js', '.ts'],
'reporter': ['json', 'json-summary', 'text-summary', 'html', 'lcovonly'],
'reports-dir': path.join(output, 'report'),
'temp-directory': path.join(output, 'normalized'),
}
write(configFile, config)
write(path.join(output, 'inventory.json'), inventory)
const started = new Date().toISOString()
const moduleDirectory = path.join(output, 'modules')
const rawDirectory = path.join(output, 'raw')
const result = spawnSync(process.execPath, ['--test', ...tests], {
cwd: root,
stdio: 'inherit',
env: { ...process.env, NODE_V8_COVERAGE: rawDirectory, ARTPLAYER_COVERAGE_DIR: moduleDirectory },
})
const report = { task: 'ENG-08', started, node: process.version, c8: read(require.resolve('c8/package.json')).version, output: path.relative(root, output).replaceAll('\\', '/'), tests, testExitCode: result.status, status: 'failed' }
write(path.join(cache, 'latest.json'), { output: report.output })
write(path.join(output, 'report.json'), report)
if (result.error || result.status !== 0) {
throw result.error || new Error(`Coverage unit tests failed: ${result.status}`)
}
report.maps = normalizeCoverageReports(rawDirectory, config['temp-directory'], { root, moduleDirectory, inventory })
write(path.join(output, 'report.json'), report)
const rendered = spawnSync(process.execPath, [require.resolve('c8/bin/c8.js'), 'report', '--config', configFile], { cwd: root, stdio: 'inherit' })
assert(!rendered.error && rendered.status === 0, 'Coverage report generation failed')
Object.assign(report, analyzeCoverage(root, read(path.join(output, 'report/coverage-summary.json')), inventory, policy))
report.status = report.violations.length ? 'failed' : 'passed'
write(path.join(output, 'report.json'), report)
console.log(`Coverage: ${report.files.length} runtime files, ${report.excluded.length} documented/type-only exclusions; ${report.violations.length} gate violations. Report: ${report.output}/report/index.html`)
assert.deepEqual(report.violations, [], 'Critical lifecycle coverage is below the configured gate')
return report
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
assert.equal(process.argv.length, 2, 'Use yarn test:coverage')
runCoverage()
}
catch (error) {
console.error(error)
process.exitCode = 1
}
}
+88
View File
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { ensureArchive, hash, readMember } from '../refactor/scripts/releases.mjs'
import { measureBytes } from '../refactor/scripts/sizes.mjs'
const read = file => JSON.parse(fs.readFileSync(file, 'utf8'))
const walk = directory => fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => entry.isDirectory() ? walk(path.join(directory, entry.name)) : [path.join(directory, entry.name)])
function digest(file) {
const bytes = fs.readFileSync(file)
return hash(/\.(?:[cm]?[jt]s|json|css|less|html|svg|txt)$/.test(file) ? bytes.toString('utf8').replaceAll('\r\n', '\n') : bytes)
}
export function verifyPerformanceArtifacts(root, mapFile) {
assert(mapFile, 'Run yarn test:package and set ARTPLAYER_BROWSER_ARTIFACTS to its browser-artifacts.json')
const directory = path.dirname(path.resolve(mapFile))
const artifacts = read(mapFile)
const report = read(path.join(directory, 'report.json'))
assert.equal(report.task, 'ENG-07', 'Performance requires isolated installation evidence')
assert.equal(report.knownTypeBlockers, 0)
assert.equal(report.node, process.versions.node, 'Build and benchmark Node versions differ')
const toolchain = {}
const currentManifest = read(path.join(root, 'package.json'))
const buildManifest = read(path.join(directory, 'build/package.json'))
for (const key of ['packageManager', 'devDependencies', 'resolutions'])
assert.deepEqual(buildManifest[key], currentManifest[key], `Build toolchain changed: ${key}`)
for (const file of ['yarn.lock', 'scripts/build.js', 'scripts/utils.js', 'scripts/projects.js']) {
toolchain[file] = digest(path.join(root, file))
assert.equal(digest(path.join(directory, 'build', file)), toolchain[file], `Stale build input: ${file}`)
}
const inputs = []
for (const name of ['artplayer', 'artplayer-plugin-chapter']) {
const pkg = report.packages.find(pkg => pkg.name === name)
assert(pkg && artifacts[name], `Missing installed package: ${name}`)
const file = path.resolve(directory, artifacts[name])
const member = `package/dist/${path.basename(file)}`
assert.equal(hash(fs.readFileSync(file)), pkg.files[member], `Installed performance artifact changed: ${name}`)
const sourceHashes = {}
for (const folder of ['src', 'public']) {
const current = path.join(root, 'packages', name, folder)
const snapshot = path.join(directory, 'build/packages', name, folder)
if (!fs.existsSync(current)) {
assert(!fs.existsSync(snapshot), `Source directory was removed: ${name}/${folder}`)
continue
}
const inventory = location => Object.fromEntries(walk(location).map(file => [path.relative(location, file).replaceAll('\\', '/'), digest(file)]))
const expected = inventory(current)
assert.deepEqual(inventory(snapshot), expected, `Stale package source: ${name}/${folder}; rerun yarn test:package`)
Object.assign(sourceHashes, Object.fromEntries(Object.entries(expected).map(([file, digest]) => [`${folder}/${file}`, digest])))
}
for (const file of ['package.json', 'THIRD_PARTY_NOTICES', 'tsconfig.json']) {
const current = path.join(root, 'packages', name, file)
const snapshot = path.join(directory, 'build/packages', name, file)
assert.equal(fs.existsSync(current), fs.existsSync(snapshot), `Package input removed or added: ${name}/${file}`)
if (fs.existsSync(current)) {
sourceHashes[file] = digest(current)
assert.equal(digest(snapshot), sourceHashes[file], `Stale package input: ${name}/${file}`)
}
}
inputs.push({ name, file, member, sha256: pkg.files[member], archiveSha256: pkg.sha256, sourceHashes })
}
return { directory, inputs, toolchain, packages: report.packages }
}
export async function compareArtifactSizes(root, artifacts) {
const releases = read(path.join(root, 'refactor/baselines/releases.json')).releases
const comparisons = []
for (const pkg of artifacts.packages) {
const release = releases.find(release => release.name === pkg.name)
const archive = await ensureArchive(release)
for (const extension of ['js', 'legacy.js', 'mjs']) {
const member = `package/dist/${pkg.name}.${extension}`
assert(release.files[member] && pkg.files[member], `Missing matching size entry: ${member}`)
const before = measureBytes(readMember(archive, member))
const after = measureBytes(fs.readFileSync(path.join(artifacts.directory, 'artifacts', pkg.name, member.slice('package/'.length))))
assert.equal(before.sha256, release.files[member])
assert.equal(after.sha256, pkg.files[member])
const reviewSignals = ['raw', 'gzip9', 'brotli6'].flatMap((metric) => {
const deltaBytes = after[metric] - before[metric]
const allowanceBytes = Math.max(before[metric] * 0.05, 1024)
return deltaBytes > allowanceBytes ? [{ metric, deltaBytes, allowanceBytes }] : []
})
comparisons.push({ name: pkg.name, member, before, after, reviewSignals })
}
}
return comparisons
}
+24
View File
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { fileURLToPath } from 'node:url'
// Reuse the frozen BASE-06 measurement body; only adapt transport and bundle URLs.
const directory = fileURLToPath(new URL('../refactor/fixtures/', import.meta.url))
function replaceOnce(source, before, after) {
assert.equal(source.split(before).length, 2, `Performance fixture adapter drift: ${before}`)
return source.replace(before, after)
}
export function performanceScript() {
const source = fs.readFileSync(`${directory}/performance.js`, 'utf8')
return replaceOnce(source, 'const response = await fetch(\'/reports/performance\', { method: \'POST\', headers: { \'Content-Type\': \'application/json\' }, body: JSON.stringify(report) })', 'window.artplayerPerformanceReport = report; const response = { ok: true }')
}
export function performanceHtml(variant) {
assert(['published', 'candidate'].includes(variant), 'Invalid performance variant')
let source = fs.readFileSync(`${directory}/performance.html`, 'utf8')
source = replaceOnce(source, '/releases/artplayer/dist/artplayer.js', `/${variant}/artplayer.js`)
source = replaceOnce(source, '/releases/artplayer-plugin-chapter/dist/artplayer-plugin-chapter.js', `/${variant}/artplayer-plugin-chapter.js`)
source = replaceOnce(source, '/fixtures/performance.js', '/test/performance.js')
return source.replaceAll('published performance baseline', `${variant} performance comparison`).replaceAll('Published performance and resource baseline', `${variant}: performance and resource comparison`)
}
+28
View File
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict'
import { comparePerformance, validatePerformanceMeasurements } from '../refactor/scripts/performance.mjs'
export function validatePairedPerformance(report) {
assert.equal(report.schemaVersion, 1)
assert(report.environment.browser && report.environment.version && report.environment.platform, 'Missing benchmark environment')
assert.deepEqual(report.runs.map(({ group, variant }) => ({ group, variant })), Array.from({ length: 3 }, (_, group) => (group % 2 ? ['candidate', 'published'] : ['published', 'candidate']).map(variant => ({ group, variant }))).flat(), 'Require three alternating paired groups')
for (const run of report.runs) {
validatePerformanceMeasurements(run.measurements)
assert.deepEqual(run.measurements.environment, report.runs[0].measurements.environment, 'Different browser/viewport/hardware cohort')
assert.deepEqual(run.measurements.scripts, [`/${run.variant}/artplayer.js`, `/${run.variant}/artplayer-plugin-chapter.js`, '/test/performance.js'], 'Unexpected benchmark scripts')
if (run.variant === 'candidate') {
for (const resource of run.measurements.resources) {
assert.deepEqual(resource.immediatelyAfter, [], 'Candidate left active timers at destroy')
assert.deepEqual(resource.after.timers, [], 'Candidate retained timers after destroy')
assert.deepEqual(resource.after.lateCallbacks, [], 'Candidate executed callbacks after destroy')
assert.equal(resource.after.lateResizeEvents, 0, 'Candidate dispatched late resize')
}
}
}
const groups = Array.from({ length: 3 }, (_, group) => {
const pair = report.runs.filter(run => run.group === group)
const before = pair.find(run => run.variant === 'published').measurements
const after = pair.find(run => run.variant === 'candidate').measurements
return { group, ...comparePerformance(before, after) }
})
return { groups, reviewRequired: groups.some(group => group.reviewSignals.length > 0) }
}
+27
View File
@@ -0,0 +1,27 @@
import { summarizePerformance } from '../refactor/scripts/performance.mjs'
export function performanceSummary(report) {
const lines = [
`## ArtPlayer performance: ${report.environment.browser} ${report.environment.version}`,
'',
`Status: **${report.status}**. Timing and size signals require review; lifecycle checks must pass independently.`,
'',
'| Group | Configuration | Metric | Published median (ms) | Candidate median (ms) | Review |',
'| --- | --- | --- | ---: | ---: | --- |',
]
for (const group of report.groups) {
const before = summarizePerformance(report.runs.find(run => run.group === group.group && run.variant === 'published').measurements)
for (const [configuration, metrics] of Object.entries(group.summary)) {
for (const [metric, result] of Object.entries(metrics)) {
const review = group.reviewSignals.some(signal => signal.configuration === configuration && signal.metric === metric)
lines.push(`| ${group.group + 1} | ${configuration} | ${metric} | ${before[configuration][metric].median.toFixed(2)} | ${result.median.toFixed(2)} | ${review ? 'Required' : '-'} |`)
}
}
}
lines.push('', '| Package entry | Raw bytes (old → new) | gzip 9 | Brotli 6 | Review |', '| --- | ---: | ---: | ---: | --- |')
for (const size of report.sizes)
lines.push(`| ${size.member} | ${size.before.raw} → ${size.after.raw} | ${size.before.gzip9} → ${size.after.gzip9} | ${size.before.brotli6} → ${size.after.brotli6} | ${size.reviewSignals.length ? 'Required' : '-'} |`)
const resources = report.runs.filter(run => run.variant === 'candidate').reduce((count, run) => count + run.measurements.resources.length, 0)
lines.push('', `${resources} candidate resource probes passed: no retained timers, late callbacks, RAF, proxy entries, instances or DOM after destruction. This does not measure native heap or GPU memory.`, '', 'One warm-up and five measured samples per configuration, per group; three alternating paired groups. Compression is per file, not HTTP transfer size.', '')
return lines.join('\n')
}