mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-19 11:21:20 -08:00
test: [ENG-08] add verified TS coverage and paired performance reports
This commit is contained in:
1 parent
8ed0a5228c
commit
4a9a26d4fc
38 files changed
+24335
-61
No files matched your search
@@ -8,6 +8,7 @@ import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { build } from 'vite'
|
||||
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
|
||||
import { performanceHtml, performanceScript } from '../../scripts/performance-fixture.mjs'
|
||||
import { getEntryFile } from '../../scripts/projects.js'
|
||||
import { getGlobalName, getViteBuildConfig } from '../../scripts/utils.js'
|
||||
|
||||
@@ -84,6 +85,7 @@ function send(req, res, bytes, filename) {
|
||||
}
|
||||
|
||||
add('/assets/sample/video.mp4', fs.readFileSync(path.join(workspace, 'docs/assets/sample/video.mp4')), { kind: 'docs-media', file: 'docs/assets/sample/video.mp4' })
|
||||
add('/test/performance.js', Buffer.from(performanceScript()), { kind: 'adapted-baseline-fixture', file: 'refactor/fixtures/performance.js' })
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
try {
|
||||
@@ -127,6 +129,10 @@ const server = http.createServer((req, res) => {
|
||||
send(req, res, Buffer.from(html), 'player.html')
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/test/performance.html') {
|
||||
send(req, res, Buffer.from(performanceHtml(url.searchParams.get('variant'))), 'performance.html')
|
||||
return
|
||||
}
|
||||
assert(!/^\/(?:test|candidate|published|compiled|uncompiled)\//.test(url.pathname), 'Unmapped test artifact')
|
||||
const root = fs.realpathSync(path.join(workspace, 'docs'))
|
||||
let filename = path.resolve(root, `.${decodeURIComponent(url.pathname)}`)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
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'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Exercise the real V8 collection and remapping pipeline.
|
||||
import { test } from 'node:test'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { normalizeCoverageReports } from '../scripts/coverage-maps.mjs'
|
||||
import { analyzeCoverage, sourceInventory } from '../scripts/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, value) => fs.writeFileSync(file, `${JSON.stringify(value)}\n`)
|
||||
|
||||
test('real TS source maps retain unexecuted branches and files across both test loaders', () => {
|
||||
const cache = path.join(root, 'refactor/.cache/coverage')
|
||||
fs.mkdirSync(cache, { recursive: true })
|
||||
const fixture = fs.mkdtempSync(path.join(cache, 'canary-'))
|
||||
const source = path.join(fixture, 'packages/artplayer/src')
|
||||
fs.mkdirSync(source, { recursive: true })
|
||||
fs.writeFileSync(path.join(source, 'probe.ts'), `export default function probe(value: boolean): string {
|
||||
if (value) {
|
||||
return 'taken'
|
||||
}
|
||||
return 'missed'
|
||||
}
|
||||
`)
|
||||
fs.writeFileSync(path.join(source, 'support.ts'), 'export default function support(): number { return 42 }\n')
|
||||
fs.writeFileSync(path.join(source, 'index.ts'), 'export { default } from \'./probe\'\n')
|
||||
fs.writeFileSync(path.join(source, 'never.ts'), 'export default function never(): number { return 99 }\n')
|
||||
fs.writeFileSync(path.join(source, 'types.ts'), 'export interface Example { value: boolean }\n')
|
||||
const entry = path.join(fixture, 'run.mjs')
|
||||
const loader = pathToFileURL(path.join(root, 'test/helpers/load.js')).href
|
||||
fs.writeFileSync(entry, `import assert from 'node:assert/strict'
|
||||
import { loadModules, loadPackage } from ${JSON.stringify(loader)}
|
||||
const { probe, support } = await loadModules({ probe: 'packages/artplayer/src/probe', support: 'packages/artplayer/src/support' }, ${JSON.stringify(fixture)})
|
||||
assert.equal(support(), 42)
|
||||
const packaged = (await loadPackage('artplayer', ${JSON.stringify(fixture)})).default
|
||||
for (const fn of [probe, packaged]) {
|
||||
assert.equal(fn(true), 'taken')
|
||||
if (process.argv[2] === 'complete') assert.equal(fn(false), 'missed')
|
||||
}
|
||||
`)
|
||||
const policy = { schemaVersion: 1, packages: ['artplayer'], exclude: {}, minimum: { 'packages/artplayer/src/probe.ts': { lines: 100, branches: 100, functions: 100 } } }
|
||||
const inventory = sourceInventory(fixture, policy)
|
||||
assert.deepEqual(inventory.excluded.map(item => item.file), ['packages/artplayer/src/types.ts'])
|
||||
for (const mode of ['incomplete', 'complete']) {
|
||||
const output = path.join(fixture, mode)
|
||||
const rawDirectory = path.join(output, 'raw')
|
||||
const moduleDirectory = path.join(output, 'modules')
|
||||
const mappedDirectory = path.join(output, 'normalized')
|
||||
const collected = spawnSync(process.execPath, [entry, mode], {
|
||||
cwd: fixture,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, NODE_V8_COVERAGE: rawDirectory, ARTPLAYER_COVERAGE_DIR: moduleDirectory },
|
||||
})
|
||||
assert.equal(collected.status, 0, collected.stderr)
|
||||
const maps = normalizeCoverageReports(rawDirectory, mappedDirectory, { root: fixture, moduleDirectory, inventory })
|
||||
assert(maps.modules >= 2 && maps.reports.some(item => item.normalizedSources > 0))
|
||||
const config = path.join(output, 'c8.json')
|
||||
write(config, {
|
||||
'all': true,
|
||||
'exclude-after-remap': true,
|
||||
'include': ['packages/artplayer/src/**'],
|
||||
'exclude': inventory.excluded.map(item => item.file),
|
||||
'src': ['packages/artplayer/src'],
|
||||
'extension': ['.ts'],
|
||||
'reporter': ['json', 'json-summary'],
|
||||
'temp-directory': mappedDirectory,
|
||||
'reports-dir': path.join(output, 'report'),
|
||||
})
|
||||
const rendered = spawnSync(process.execPath, [require.resolve('c8/bin/c8.js'), 'report', '--config', config], { cwd: fixture, encoding: 'utf8' })
|
||||
assert.equal(rendered.status, 0, rendered.stderr)
|
||||
const summary = read(path.join(output, 'report/coverage-summary.json'))
|
||||
const analyzed = analyzeCoverage(fixture, summary, inventory, policy)
|
||||
const probe = analyzed.files.find(item => item.file.endsWith('/probe.ts'))
|
||||
assert(probe.metrics.functions.total > 0 && probe.metrics.branches.total > 0)
|
||||
assert.equal(analyzed.files.find(item => item.file.endsWith('/never.ts')).metrics.lines.covered, 0, 'Never imported file must remain in the denominator with zero coverage')
|
||||
if (mode === 'complete') {
|
||||
assert.deepEqual(analyzed.violations, [])
|
||||
}
|
||||
else {
|
||||
assert(analyzed.violations.some(item => item.metric === 'branches'))
|
||||
const full = read(path.join(output, 'report/coverage-final.json'))[path.join(source, 'probe.ts')]
|
||||
const missed = Object.entries(full.statementMap).filter(([, location]) => location.start.line === 5)
|
||||
assert(missed.length > 0 && missed.every(([id]) => full.s[id] === 0), 'Unexecuted TS return must not become 100% covered')
|
||||
const reportFile = maps.reports.find(item => item.modules > 0).file
|
||||
const original = read(path.join(rawDirectory, reportFile))
|
||||
const module = original.result.find(item => item.url.startsWith(pathToFileURL(moduleDirectory).href))
|
||||
const mutated = structuredClone(original)
|
||||
const mapped = mutated['source-map-cache'][module.url].data
|
||||
const index = mapped.sources.findIndex(item => item.endsWith('/probe.ts'))
|
||||
assert(index >= 0)
|
||||
mapped.sourcesContent[index] += '\n// stale source'
|
||||
const corrupt = path.join(output, 'corrupt')
|
||||
fs.mkdirSync(corrupt)
|
||||
write(path.join(corrupt, reportFile), mutated)
|
||||
assert.throws(() => normalizeCoverageReports(corrupt, path.join(output, 'stale'), { root: fixture, moduleDirectory, inventory }), /Stale mapped source/)
|
||||
delete mutated['source-map-cache'][module.url]
|
||||
write(path.join(corrupt, reportFile), mutated)
|
||||
assert.throws(() => normalizeCoverageReports(corrupt, path.join(output, 'missing'), { root: fixture, moduleDirectory, inventory }), /Missing source map/)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('coverage gates reject missing files, empty branch counts and ignored counters', () => {
|
||||
const file = 'packages/artplayer/src/lifecycle/scope.ts'
|
||||
const inventory = { files: [{ file, sha256: 'test' }], excluded: [] }
|
||||
const policy = { minimum: { [file]: { branches: 90 } } }
|
||||
const counters = () => Object.fromEntries(['lines', 'branches', 'functions', 'statements'].map(metric => [metric, { covered: 10, total: 10, skipped: 0, pct: 100 }]))
|
||||
const key = path.join(root, file)
|
||||
assert.deepEqual(analyzeCoverage(root, { [key]: counters() }, inventory, policy).violations, [])
|
||||
assert.throws(() => analyzeCoverage(root, {}, inventory, policy), /source inventory differs/)
|
||||
const empty = counters()
|
||||
empty.branches = { covered: 0, total: 0, skipped: 0, pct: 100 }
|
||||
assert.equal(analyzeCoverage(root, { [key]: empty }, inventory, policy).violations.length, 1)
|
||||
const ignored = counters()
|
||||
ignored.lines.skipped = 1
|
||||
assert.throws(() => analyzeCoverage(root, { [key]: ignored }, inventory, policy), /Invalid or ignored lines/)
|
||||
const low = counters()
|
||||
low.branches.covered = 1
|
||||
assert.equal(analyzeCoverage(root, { [key]: low }, inventory, policy).violations[0].actual, 10, 'Recompute percentage instead of trusting a stale pct field')
|
||||
})
|
||||
+29
-2
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import vm from 'node:vm'
|
||||
import { build as esbuild } from 'esbuild'
|
||||
@@ -11,7 +12,25 @@ import { getEntryFile } from '../../scripts/projects.js'
|
||||
import { getGlobalName, getViteBuildConfig } from '../../scripts/utils.js'
|
||||
|
||||
export const workspace = fileURLToPath(new URL('../../', import.meta.url))
|
||||
const importCode = code => import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`)
|
||||
function coverageDirectory() {
|
||||
const output = process.env.ARTPLAYER_COVERAGE_DIR
|
||||
if (!output)
|
||||
return undefined
|
||||
const root = path.resolve(workspace, 'refactor/.cache/coverage')
|
||||
const directory = path.resolve(output, String(process.pid))
|
||||
assert(directory.startsWith(root + path.sep), 'Coverage modules must stay inside the workspace coverage cache')
|
||||
fs.mkdirSync(directory, { recursive: true })
|
||||
return directory
|
||||
}
|
||||
|
||||
function importCode(code) {
|
||||
const directory = coverageDirectory()
|
||||
if (!directory)
|
||||
return import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`)
|
||||
const filename = path.join(directory, `${hash(code)}.mjs`)
|
||||
fs.writeFileSync(filename, code)
|
||||
return import(pathToFileURL(filename))
|
||||
}
|
||||
|
||||
export function resolveSource(name, root = workspace) {
|
||||
const files = ['.js', '.ts'].map(extension => path.resolve(root, name + extension)).filter(file => fs.existsSync(file))
|
||||
@@ -25,7 +44,9 @@ export async function loadModules(exports, root = workspace) {
|
||||
assert(/^[\w$]+$/.test(alias) && /^[\w$]+$/.test(name), 'Invalid test export')
|
||||
return `export { ${name} as ${alias} } from ${JSON.stringify(resolveSource(file, root))};`
|
||||
}).join('\n')
|
||||
const { outputFiles } = await esbuild({ stdin: { contents, resolveDir: root }, bundle: true, write: false, platform: 'node', format: 'esm' })
|
||||
const directory = coverageDirectory()
|
||||
const coverage = directory ? { outfile: path.join(directory, 'modules.mjs'), sourcemap: 'inline', treeShaking: false } : {}
|
||||
const { outputFiles } = await esbuild({ stdin: { contents, resolveDir: root }, bundle: true, write: false, platform: 'node', format: 'esm', ...coverage })
|
||||
return importCode(outputFiles[0].contents)
|
||||
}
|
||||
|
||||
@@ -33,6 +54,12 @@ export async function compilePackage(name, format = 'es', root = workspace) {
|
||||
const project = path.join(root, 'packages', name)
|
||||
const config = getViteBuildConfig({ entry: getEntryFile(project), name: getGlobalName(name), format, fileName: 'index.js', minify: false })
|
||||
config.build.write = false
|
||||
const directory = coverageDirectory()
|
||||
if (directory) {
|
||||
config.build.outDir = directory
|
||||
config.build.sourcemap = 'inline'
|
||||
config.build.rollupOptions.treeshake = false
|
||||
}
|
||||
const result = await viteBuild({ root: project, ...config })
|
||||
const chunks = (Array.isArray(result) ? result : [result]).flatMap(item => item.output).filter(item => item.type === 'chunk')
|
||||
assert.equal(chunks.length, 1, 'Source fixture loader requires one self-contained JS chunk')
|
||||
|
||||
@@ -4,11 +4,78 @@ import { test } from 'node:test'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const file = 'packages/artplayer/src/lifecycle/instance'
|
||||
const { beginLifecycle, destroyInstance, duringTemplateMount, finishLifecycle, getScope, urlMix } = await loadModules({
|
||||
...Object.fromEntries(['beginLifecycle', 'destroyInstance', 'duringTemplateMount', 'finishLifecycle', 'getScope'].map(name => [name, { file, name }])),
|
||||
const { beginLifecycle, destroyInstance, duringTemplateMount, finishLifecycle, getScope, getFinalizationScope, ownContainer, isClosing, urlMix } = await loadModules({
|
||||
...Object.fromEntries(['beginLifecycle', 'destroyInstance', 'duringTemplateMount', 'finishLifecycle', 'getScope', 'getFinalizationScope', 'ownContainer', 'isClosing'].map(name => [name, { file, name }])),
|
||||
urlMix: 'packages/artplayer/src/player/urlMix',
|
||||
})
|
||||
|
||||
test('container reservations prevent concurrent mounts and release after failed rollback', () => {
|
||||
const container = {}
|
||||
const calls = []
|
||||
const first = { isDestroy: false, reset() {}, emit() {
|
||||
calls.push('destroy')
|
||||
} }
|
||||
const second = { isDestroy: false, reset() {}, emit() {} }
|
||||
assert.throws(() => getScope(first), /has not been initialized/)
|
||||
beginLifecycle(first)
|
||||
beginLifecycle(second)
|
||||
assert.equal(isClosing(first), false)
|
||||
ownContainer(first, container, () => {
|
||||
assert(isClosing(first))
|
||||
assert.throws(() => ownContainer(second, container, () => {}), /multiple instances/)
|
||||
calls.push('rollback')
|
||||
})
|
||||
assert.throws(() => ownContainer(second, container, () => {}), /multiple instances/)
|
||||
destroyInstance(first, [], false, false, true)
|
||||
assert.deepEqual(calls, ['destroy', 'rollback'])
|
||||
ownContainer(second, container, () => {
|
||||
throw new Error('Successful mounting must discard rollback')
|
||||
})
|
||||
assert(finishLifecycle(second))
|
||||
destroyInstance(second, [], true, false)
|
||||
assert.equal(finishLifecycle(second), false)
|
||||
})
|
||||
|
||||
test('direct scope disposal finalizes retained resources once without a destroy phase', () => {
|
||||
const owner = {}
|
||||
const calls = []
|
||||
beginLifecycle(owner)
|
||||
getFinalizationScope(owner).add(() => {
|
||||
calls.push('finalized')
|
||||
})
|
||||
getScope(owner).dispose()
|
||||
assert(isClosing(owner))
|
||||
assert(getFinalizationScope(owner).closed)
|
||||
getScope(owner).dispose()
|
||||
assert.deepEqual(calls, ['finalized'])
|
||||
})
|
||||
|
||||
test('cleanup failures preserve the first value and still finalize, roll back and release reservations', (context) => {
|
||||
const failures = [new Error('scope'), new Error('finalizer'), new Error('rollback')]
|
||||
const warnings = []
|
||||
context.mock.method(console, 'warn', (...args) => warnings.push(args))
|
||||
const container = {}
|
||||
const owner = { isDestroy: false, reset() {}, emit() {} }
|
||||
beginLifecycle(owner)
|
||||
getScope(owner).add(() => {
|
||||
throw failures[0]
|
||||
})
|
||||
getFinalizationScope(owner).add(() => {
|
||||
throw failures[1]
|
||||
})
|
||||
ownContainer(owner, container, () => {
|
||||
throw failures[2]
|
||||
})
|
||||
assert.throws(() => destroyInstance(owner, [owner], true, false, true), error => error === failures[0])
|
||||
assert.deepEqual(warnings, failures.slice(1).map(error => ['Additional ArtPlayer cleanup failure:', error]))
|
||||
assert(owner.isDestroy && getScope(owner).closed && getFinalizationScope(owner).closed)
|
||||
const replacement = { isDestroy: false, reset() {}, emit() {} }
|
||||
beginLifecycle(replacement)
|
||||
ownContainer(replacement, container, () => {})
|
||||
finishLifecycle(replacement)
|
||||
destroyInstance(replacement, [], true, false)
|
||||
})
|
||||
|
||||
test('mounting template cleanup precedes destroy without exposing an early public property', () => {
|
||||
const calls = []
|
||||
const owner = { isDestroy: false, reset() {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Verify evidence rejection without synthetic benchmark claims.
|
||||
import { test } from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { hash } from '../refactor/scripts/releases.mjs'
|
||||
import { verifyPerformanceArtifacts } from '../scripts/performance-artifacts.mjs'
|
||||
import { performanceHtml, performanceScript } from '../scripts/performance-fixture.mjs'
|
||||
import { validatePairedPerformance } from '../scripts/performance-report.mjs'
|
||||
|
||||
const baseline = JSON.parse(fs.readFileSync(new URL('../refactor/baselines/performance.json', import.meta.url), 'utf8')).runs[0]
|
||||
function fixture() {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
environment: { browser: 'fixture', version: '1', platform: 'fixture' },
|
||||
runs: Array.from({ length: 3 }, (_, group) => (group % 2 ? ['candidate', 'published'] : ['published', 'candidate']).map((variant) => {
|
||||
const measurements = structuredClone(baseline)
|
||||
measurements.scripts = [`/${variant}/artplayer.js`, `/${variant}/artplayer-plugin-chapter.js`, '/test/performance.js']
|
||||
if (variant === 'candidate') {
|
||||
for (const resource of measurements.resources) {
|
||||
resource.immediatelyAfter = []
|
||||
resource.after.timers = []
|
||||
resource.after.lateCallbacks = []
|
||||
resource.after.lateResizeEvents = 0
|
||||
}
|
||||
}
|
||||
return { group, variant, measurements }
|
||||
})).flat(),
|
||||
}
|
||||
}
|
||||
|
||||
test('paired performance requires comparable cohorts, real playback and strict candidate cleanup', () => {
|
||||
assert.equal(validatePairedPerformance(fixture()).reviewRequired, false)
|
||||
for (const mutate of [
|
||||
report => report.runs.pop(),
|
||||
report => report.runs.reverse(),
|
||||
report => report.runs[1].measurements.environment.hardwareConcurrency++,
|
||||
report => report.runs[1].measurements.samples[2].mediaProgress = 0,
|
||||
report => report.runs[1].measurements.visibilityChanges.push('hidden'),
|
||||
report => report.runs[1].measurements.scripts[0] = '/published/artplayer.js',
|
||||
report => report.runs[1].measurements.resources[0].immediatelyAfter.push({ kind: 'timeout' }),
|
||||
report => report.runs[1].measurements.resources[0].after.timers.push({ kind: 'interval' }),
|
||||
report => report.runs[1].measurements.resources[0].after.lateCallbacks.push({ kind: 'timeout' }),
|
||||
report => report.runs[1].measurements.resources[0].after.lateResizeEvents++,
|
||||
]) {
|
||||
const report = fixture()
|
||||
mutate(report)
|
||||
assert.throws(() => validatePairedPerformance(report))
|
||||
}
|
||||
const slow = fixture()
|
||||
for (const run of slow.runs.filter(run => run.variant === 'candidate')) {
|
||||
for (const sample of run.measurements.samples) {
|
||||
sample.constructorMs += 20
|
||||
sample.readyMs += 20
|
||||
}
|
||||
}
|
||||
const comparison = validatePairedPerformance(slow)
|
||||
assert(comparison.reviewRequired && comparison.groups.every(group => group.reviewSignals.some(signal => signal.metric === 'constructorMs')))
|
||||
})
|
||||
|
||||
test('performance adapter changes only delivery while retaining the frozen measurement procedure', () => {
|
||||
const frozen = fs.readFileSync(new URL('../refactor/fixtures/performance.js', import.meta.url), 'utf8')
|
||||
const adapted = performanceScript()
|
||||
const before = 'const response = await fetch(\'/reports/performance\', { method: \'POST\', headers: { \'Content-Type\': \'application/json\' }, body: JSON.stringify(report) })'
|
||||
const after = 'window.artplayerPerformanceReport = report; const response = { ok: true }'
|
||||
assert.equal(adapted.replace(after, before), frozen)
|
||||
assert.match(performanceHtml('candidate'), /src="\/candidate\/artplayer.js"/)
|
||||
assert.match(performanceHtml('published'), /src="\/published\/artplayer-plugin-chapter.js"/)
|
||||
assert.throws(() => performanceHtml('../uncompiled'))
|
||||
})
|
||||
|
||||
test('performance refuses stale sources, build tools and altered installed bundles', () => {
|
||||
const cache = fileURLToPath(new URL('../refactor/.cache/performance/', import.meta.url))
|
||||
fs.mkdirSync(cache, { recursive: true })
|
||||
const root = fs.mkdtempSync(path.join(cache, 'guard-'))
|
||||
const output = path.join(root, 'candidate')
|
||||
const snapshot = path.join(output, 'build')
|
||||
const write = (file, content) => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, content)
|
||||
}
|
||||
const toolchain = {
|
||||
'package.json': JSON.stringify({ packageManager: 'yarn@1.22.22', devDependencies: {}, resolutions: {} }),
|
||||
'yarn.lock': '# fixture\n',
|
||||
'scripts/build.js': 'build fixture\n',
|
||||
'scripts/utils.js': 'utils fixture\n',
|
||||
'scripts/projects.js': 'projects fixture\n',
|
||||
}
|
||||
for (const [file, content] of Object.entries(toolchain)) {
|
||||
write(path.join(root, file), content)
|
||||
write(path.join(snapshot, file), content)
|
||||
}
|
||||
const names = ['artplayer', 'artplayer-plugin-chapter']
|
||||
const packages = names.map((name) => {
|
||||
const entry = `packages/${name}/src/index.ts`
|
||||
write(path.join(root, entry), 'export default 1\n')
|
||||
write(path.join(snapshot, entry), 'export default 1\r\n')
|
||||
const manifest = `packages/${name}/package.json`
|
||||
write(path.join(root, manifest), JSON.stringify({ name, version: '1.0.0' }))
|
||||
write(path.join(snapshot, manifest), JSON.stringify({ name, version: '1.0.0' }))
|
||||
const bytes = 'installed fixture\n'
|
||||
write(path.join(output, `artifacts/${name}/dist/${name}.js`), bytes)
|
||||
return { name, sha256: 'archive fixture', files: { [`package/dist/${name}.js`]: hash(bytes) } }
|
||||
})
|
||||
const map = path.join(output, 'browser-artifacts.json')
|
||||
write(map, JSON.stringify(Object.fromEntries(names.map(name => [name, `artifacts/${name}/dist/${name}.js`]))))
|
||||
write(path.join(output, 'report.json'), JSON.stringify({ task: 'ENG-07', knownTypeBlockers: 0, node: process.versions.node, packages }))
|
||||
assert.equal(verifyPerformanceArtifacts(root, map).inputs.length, 2)
|
||||
const source = path.join(root, 'packages/artplayer/src/index.ts')
|
||||
write(source, 'export default 2\n')
|
||||
assert.throws(() => verifyPerformanceArtifacts(root, map), /Stale package source/)
|
||||
write(source, 'export default 1\n')
|
||||
write(path.join(root, 'scripts/build.js'), 'changed compiler\n')
|
||||
assert.throws(() => verifyPerformanceArtifacts(root, map), /Stale build input/)
|
||||
write(path.join(root, 'scripts/build.js'), toolchain['scripts/build.js'])
|
||||
write(path.join(output, 'artifacts/artplayer/dist/artplayer.js'), 'changed bundle\n')
|
||||
assert.throws(() => verifyPerformanceArtifacts(root, map), /Installed performance artifact changed/)
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
# Paired performance and cleanup
|
||||
|
||||
Run `yarn test:package` first. Set `ARTPLAYER_BROWSER_ARTIFACTS` to the resulting
|
||||
`refactor/.cache/packages/run-*/browser-artifacts.json`, then run
|
||||
`yarn test:performance`. The command starts its own local server; do not run it
|
||||
alongside other browser tests or CPU-heavy builds. All three engines run with one
|
||||
worker, no trace/video recording and the same media and viewport within a pair.
|
||||
|
||||
Each engine runs three alternating published/candidate pairs. Each side retains
|
||||
one warm-up and five timed samples for both core and core + chapter, followed by
|
||||
separate instrumented resource probes. `scripts/performance-fixture.mjs` adapts
|
||||
only the frozen BASE-06 fixture's script URLs and report transport. Its original
|
||||
files remain unchanged and their existing baseline tests still run.
|
||||
|
||||
The candidate must come from an isolated package installation. Artifact hashes,
|
||||
source snapshots, package inputs and build tools are checked before measurement;
|
||||
an old candidate cannot silently stand in for changed source. Raw, gzip 9 and
|
||||
Brotli 6 compare matching modern, legacy and ESM files from the same installation.
|
||||
Timing uses the exact JS files selected by the artifact map.
|
||||
|
||||
Reports are in `refactor/.cache/performance/run-<engine>-*/report.json` and
|
||||
`summary.md`. Playwright evidence is in `refactor/.cache/performance/browser/`.
|
||||
CI uploads both and appends summaries to the workflow run. Failed or incomplete
|
||||
runs remain recorded and are not promoted to a passing baseline.
|
||||
|
||||
A successful test proves the measurement contract and candidate resource cleanup.
|
||||
`review-required` means size or timing thresholds were exceeded; it is not release
|
||||
approval. CORE-22 and release reviews must resolve the corresponding risks before
|
||||
publishing. These counters do not measure native heap/GPU memory, real mobile
|
||||
hardware or screen-reader behavior. See `refactor/coverage-performance.md`.
|
||||
@@ -0,0 +1,72 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { compareArtifactSizes, verifyPerformanceArtifacts } from '../../scripts/performance-artifacts.mjs'
|
||||
import { validatePairedPerformance } from '../../scripts/performance-report.mjs'
|
||||
import { performanceSummary } from '../../scripts/performance-summary.mjs'
|
||||
import { expect, test } from '../browser/fixtures.js'
|
||||
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url))
|
||||
|
||||
test('installed core and chapter: three paired timing groups with strict candidate cleanup', async ({ page, browser, browserName, request, diagnostics }, testInfo) => {
|
||||
const artifacts = verifyPerformanceArtifacts(root, process.env.ARTPLAYER_BROWSER_ARTIFACTS)
|
||||
const manifest = await (await request.get('/test/manifest.json')).json()
|
||||
for (const input of artifacts.inputs) {
|
||||
const resource = manifest.resources[`/candidate/${input.name}.js`]
|
||||
expect(resource.source.kind).toBe('artifact')
|
||||
expect(resource.sha256).toBe(input.sha256)
|
||||
}
|
||||
const output = path.join(root, 'refactor/.cache/performance')
|
||||
fs.mkdirSync(output, { recursive: true })
|
||||
const directory = fs.mkdtempSync(path.join(output, `run-${browserName}-`))
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
task: 'ENG-08',
|
||||
capturedAt: new Date().toISOString(),
|
||||
environment: { browser: browserName, version: browser.version(), platform: process.platform, node: process.version },
|
||||
inputs: artifacts.inputs,
|
||||
toolchain: artifacts.toolchain,
|
||||
manifest,
|
||||
sizes: await compareArtifactSizes(root, artifacts),
|
||||
runs: [],
|
||||
status: 'incomplete',
|
||||
}
|
||||
const file = path.join(directory, 'report.json')
|
||||
const save = () => fs.writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`)
|
||||
save()
|
||||
try {
|
||||
for (let group = 0; group < 3; group++) {
|
||||
for (const variant of group % 2 ? ['candidate', 'published'] : ['published', 'candidate']) {
|
||||
await page.goto(`/test/performance.html?variant=${variant}`)
|
||||
await page.bringToFront()
|
||||
await page.locator('#run').click()
|
||||
await page.waitForFunction(() => window.artplayerPerformanceReport, null, { timeout: 25000 })
|
||||
const measurements = await page.evaluate(() => window.artplayerPerformanceReport)
|
||||
report.runs.push({ group, variant, measurements })
|
||||
save()
|
||||
}
|
||||
}
|
||||
Object.assign(report, validatePairedPerformance(report))
|
||||
report.reviewRequired ||= report.sizes.some(item => item.reviewSignals.length > 0)
|
||||
expect(diagnostics.errors).toEqual([])
|
||||
expect(diagnostics.consoleErrors).toEqual([])
|
||||
report.status = report.reviewRequired ? 'review-required' : 'passed'
|
||||
save()
|
||||
const summary = performanceSummary(report)
|
||||
fs.writeFileSync(path.join(directory, 'summary.md'), summary)
|
||||
await testInfo.attach('performance-summary', { body: summary, contentType: 'text/markdown' })
|
||||
if (process.env.GITHUB_STEP_SUMMARY)
|
||||
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary)
|
||||
}
|
||||
catch (error) {
|
||||
report.status = 'failed'
|
||||
report.failure = error.message
|
||||
save()
|
||||
throw error
|
||||
}
|
||||
finally {
|
||||
await testInfo.attach('paired-performance', { path: file, contentType: 'application/json' })
|
||||
process.stdout.write(`Paired performance (${browserName}): ${path.relative(root, file)}; ${report.status}\n`)
|
||||
}
|
||||
})
|
||||
@@ -103,7 +103,9 @@ test('timers finish or cancel independently and reject work after disposal', (co
|
||||
timeout(scope, () => calls.push('disposed'), 20)
|
||||
context.mock.timers.tick(5)
|
||||
scope.dispose()
|
||||
timeout(scope, () => calls.push('late'), 0)
|
||||
const release = timeout(scope, () => calls.push('late'), 0)
|
||||
release()
|
||||
release()
|
||||
context.mock.timers.tick(100)
|
||||
assert.deepEqual(calls, ['finished'])
|
||||
})
|
||||
@@ -131,7 +133,9 @@ test('queued RAF is guarded after cancellation, timestamp is forwarded and compl
|
||||
animationFrame(scope, time => calls.push(time))
|
||||
scope.dispose()
|
||||
callbacks[2](24)
|
||||
animationFrame(scope, () => calls.push('late'))
|
||||
const release = animationFrame(scope, () => calls.push('late'))
|
||||
release()
|
||||
release()
|
||||
assert.deepEqual(calls, [12])
|
||||
assert.deepEqual(cancelled, [1, 2, 3])
|
||||
assert.equal(callbacks.length, 3)
|
||||
@@ -160,7 +164,9 @@ test('DOM once reentry, receiver, object listener, external abort and captured o
|
||||
controller.abort()
|
||||
target.dispatchEvent(new Event('object'))
|
||||
scope.dispose()
|
||||
listen(scope, target, 'object', listener)
|
||||
const release = listen(scope, target, 'object', listener)
|
||||
release()
|
||||
release()
|
||||
target.dispatchEvent(new Event('object'))
|
||||
assert.equal(calls, 2)
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user