mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-11 22:06:17 -08:00
test(BASE-06): capture performance and teardown resource baselines
This commit is contained in:
1 parent
c7a375e146
commit
e01f39ffa0
14 files changed
+3036
-18
No files matched your search
@@ -37,7 +37,7 @@ function sendBytes(req, res, bytes, filename) {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, `http://127.0.0.1:${port}`)
|
||||
const reportName = { '/reports/api': 'api', '/reports/lifecycle': 'lifecycle', '/reports/dom': 'dom', '/reports/ssr': 'ssr' }[url.pathname]
|
||||
const reportName = { '/reports/api': 'api', '/reports/lifecycle': 'lifecycle', '/reports/dom': 'dom', '/reports/ssr': 'ssr', '/reports/performance': 'performance' }[url.pathname]
|
||||
if (req.method === 'POST' && reportName) {
|
||||
assert.equal(req.headers.origin, `http://127.0.0.1:${port}`, 'Unexpected report origin')
|
||||
const chunks = []
|
||||
@@ -62,7 +62,7 @@ const server = http.createServer(async (req, res) => {
|
||||
if (reportName === 'lifecycle') {
|
||||
report.capture.media = Object.fromEntries(['video.mp4', 'video2.mp4'].map(name => [name, hash(fs.readFileSync(path.join(root, 'docs/assets/sample', name)))]))
|
||||
}
|
||||
if (['dom', 'ssr'].includes(reportName)) report.capture.media = { 'video.mp4': hash(fs.readFileSync(path.join(root, 'docs/assets/sample/video.mp4'))) }
|
||||
if (['dom', 'ssr', 'performance'].includes(reportName)) report.capture.media = { 'video.mp4': hash(fs.readFileSync(path.join(root, 'docs/assets/sample/video.mp4'))) }
|
||||
const output = path.join(refactorDir, '.cache/reports')
|
||||
fs.mkdirSync(output, { recursive: true })
|
||||
fs.writeFileSync(path.join(output, `${reportName}.json`), `${JSON.stringify(report, null, 2)}\n`)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { validatePublishedCapture } from './api.mjs'
|
||||
import { hash, refactorDir } from './releases.mjs'
|
||||
|
||||
export const timingThresholds = {
|
||||
constructorMs: { relative: 0.25, absoluteMs: 2 },
|
||||
readyMs: { relative: 0.25, absoluteMs: 50 },
|
||||
playResolvedMs: { relative: 0.25, absoluteMs: 10 },
|
||||
destroyMs: { relative: 0.25, absoluteMs: 2 },
|
||||
}
|
||||
export function validatePerformance(report) {
|
||||
assert.equal(report.kind, 'performance')
|
||||
validatePublishedCapture(report, 'performance')
|
||||
assert.deepEqual(report.errors, [])
|
||||
assert.deepEqual(report.unhandled, [])
|
||||
assert.equal(report.environment.visibility, 'visible')
|
||||
assert.deepEqual(report.visibilityChanges, [], 'Background timing is not comparable')
|
||||
assert.equal(report.remainingInstances, 0)
|
||||
assert.equal(report.capture.media['video.mp4'], hash(fs.readFileSync(path.resolve(refactorDir, '../docs/assets/sample/video.mp4'))))
|
||||
assert.deepEqual(report.samples.map(({ configuration, round, warmup }) => ({ configuration, round, warmup })), Array.from({ length: 6 }, (_, round) => ['core', 'chapter'].map(configuration => ({ configuration, round, warmup: round === 0 }))).flat())
|
||||
for (const sample of report.samples) {
|
||||
for (const key of [...Object.keys(timingThresholds), 'playbackObservedMs']) assert(Number.isFinite(sample[key]) && sample[key] >= 0, `Invalid ${key}`)
|
||||
assert(sample.readyMs >= sample.constructorMs && sample.playbackObservedMs >= sample.playResolvedMs)
|
||||
assert(Number.isFinite(sample.mediaProgress) && sample.mediaProgress >= 0.15 && Number.isFinite(sample.videoWidth) && sample.videoWidth > 0, 'Missing real playback progress')
|
||||
assert(sample.nodes > 0 && sample.proxyCleanupEntries > 0)
|
||||
assert.deepEqual(sample.afterDestroy, { nodes: 0, instances: 0, proxyCleanupEntries: 0, paused: true, sourceAttribute: null }, 'Basic lifecycle cleanup failed')
|
||||
}
|
||||
assert.deepEqual(report.resources.map(({ configuration, round }) => ({ configuration, round })), Array.from({ length: 3 }, (_, round) => ['core', 'chapter'].map(configuration => ({ configuration, round }))).flat())
|
||||
for (const resource of report.resources) {
|
||||
assert.equal(resource.useRaf, true)
|
||||
assert(resource.before.frames >= 2 && resource.before.proxyCleanupEntries > 0)
|
||||
assert.equal(resource.before.proxyCalls, 1)
|
||||
assert(resource.before.timers.some(timer => timer.kind === 'raf'), 'RAF probe was not active')
|
||||
assert(resource.after.observedMs >= 350, 'Insufficient teardown observation')
|
||||
for (const key of ['nodes', 'instances', 'proxyCleanupEntries', 'framesAfterDestroy']) assert.equal(resource.after[key], 0, `Resource cleanup failed: ${key}`)
|
||||
assert.equal(resource.after.proxyCalls, 1, 'Removed proxy listener still responds')
|
||||
// Late timeout work remains evidence, not a normal candidate cleanup requirement.
|
||||
assert(Array.isArray(resource.after.timers) && Array.isArray(resource.after.lateCallbacks))
|
||||
}
|
||||
}
|
||||
export function summarizePerformance(report) {
|
||||
return Object.fromEntries(['core', 'chapter'].map(configuration => {
|
||||
const samples = report.samples.filter(sample => sample.configuration === configuration && !sample.warmup)
|
||||
return [configuration, Object.fromEntries(Object.keys(timingThresholds).map(metric => {
|
||||
const values = samples.map(sample => sample[metric]).sort((a, b) => a - b)
|
||||
return [metric, { count: values.length, min: values[0], median: values[Math.floor(values.length / 2)], max: values.at(-1) }]
|
||||
}))]
|
||||
}))
|
||||
}
|
||||
export function resourceProfile(report) {
|
||||
return report.resources.map(({ configuration, round, after }) => ({ configuration, round, timers: after.timers, lateCallbacks: after.lateCallbacks, lateResizeEvents: after.lateResizeEvents, framesAfterDestroy: after.framesAfterDestroy, proxyCalls: after.proxyCalls, nodes: after.nodes, instances: after.instances }))
|
||||
}
|
||||
export function comparePerformance(before, after) {
|
||||
const cohort = report => ({ ...report.environment, visibility: undefined })
|
||||
assert.deepEqual(cohort(after), cohort(before), 'Different browser/viewport/hardware cohort; rerun paired samples')
|
||||
const previous = summarizePerformance(before)
|
||||
const current = summarizePerformance(after)
|
||||
const reviewSignals = []
|
||||
for (const configuration of ['core', 'chapter']) {
|
||||
for (const [metric, threshold] of Object.entries(timingThresholds)) {
|
||||
const deltaMs = current[configuration][metric].median - previous[configuration][metric].median
|
||||
const allowanceMs = Math.max(previous[configuration][metric].median * threshold.relative, threshold.absoluteMs)
|
||||
if (deltaMs > allowanceMs) reviewSignals.push({ configuration, metric, deltaMs, allowanceMs })
|
||||
}
|
||||
}
|
||||
return { summary: current, reviewSignals, resourceProfileChanged: JSON.stringify(resourceProfile(after)) !== JSON.stringify(resourceProfile(before)) }
|
||||
}
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const frozen = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/performance.json'), 'utf8'))
|
||||
for (const report of frozen.runs) validatePerformance(report)
|
||||
const index = process.argv.indexOf('--compare')
|
||||
if (index !== -1) {
|
||||
assert(process.argv[index + 1], '--compare requires a report path')
|
||||
const report = JSON.parse(fs.readFileSync(process.argv[index + 1], 'utf8'))
|
||||
validatePerformance(report)
|
||||
console.log(JSON.stringify(comparePerformance(frozen.runs[0], report), null, 2))
|
||||
console.log('Timing signals require repeat paired measurement and review, not automatic baseline replacement')
|
||||
}
|
||||
else {
|
||||
assert(process.argv.slice(2).every(arg => arg === '--check'), 'Use --check or --compare <report>')
|
||||
console.log(`Frozen performance evidence verified: ${frozen.runs.length} runs; no browser rerun or optimization claim`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { comparePerformance, summarizePerformance, validatePerformance } from './performance.mjs'
|
||||
import { publishedSizes, validateSizes } from './sizes.mjs'
|
||||
import { refactorDir } from './releases.mjs'
|
||||
|
||||
const baseline = () => JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/performance.json'), 'utf8'))
|
||||
|
||||
test('Performance evidence rejects background, fake playback and broken teardown', () => {
|
||||
const frozen = baseline()
|
||||
for (const report of frozen.runs) validatePerformance(report)
|
||||
for (const mutate of [
|
||||
report => report.visibilityChanges.push('hidden'),
|
||||
report => report.samples[2].mediaProgress = 0,
|
||||
report => report.samples[2].afterDestroy.instances = 1,
|
||||
report => report.resources[0].after.framesAfterDestroy = 1,
|
||||
report => report.resources[0].after.observedMs = 20,
|
||||
report => report.resources[0].after.proxyCalls = 2,
|
||||
report => report.samples.pop(),
|
||||
]) {
|
||||
const changed = structuredClone(frozen.runs[0])
|
||||
mutate(changed)
|
||||
assert.throws(() => validatePerformance(changed))
|
||||
}
|
||||
})
|
||||
|
||||
test('Performance comparison excludes warm-up, detects large regression and allows documented resource fixes', () => {
|
||||
const report = baseline().runs[0]
|
||||
const warmup = structuredClone(report)
|
||||
warmup.samples[0].constructorMs = 1000
|
||||
assert.deepEqual(summarizePerformance(warmup), summarizePerformance(report))
|
||||
const slower = structuredClone(report)
|
||||
for (const sample of slower.samples) {
|
||||
sample.constructorMs += 10
|
||||
sample.readyMs += 10
|
||||
}
|
||||
assert(comparePerformance(report, slower).reviewSignals.some(signal => signal.metric === 'constructorMs'))
|
||||
const fixed = structuredClone(report)
|
||||
for (const resource of fixed.resources) {
|
||||
resource.after.timers = []
|
||||
resource.after.lateCallbacks = []
|
||||
resource.after.lateResizeEvents = 0
|
||||
}
|
||||
validatePerformance(fixed)
|
||||
assert.equal(comparePerformance(report, fixed).resourceProfileChanged, true)
|
||||
const otherDevice = structuredClone(report)
|
||||
otherDevice.environment.hardwareConcurrency++
|
||||
assert.throws(() => comparePerformance(report, otherDevice))
|
||||
})
|
||||
|
||||
test('Published compression sizes are reproducible and inventory cannot omit a library', async () => {
|
||||
const report = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/sizes.json'), 'utf8'))
|
||||
validateSizes(report)
|
||||
assert.deepEqual(await publishedSizes(), report.published)
|
||||
const missing = structuredClone(report)
|
||||
missing.workspaceObserved = missing.workspaceObserved.filter(item => item.name !== 'artplayer-plugin-chapter')
|
||||
assert.throws(() => validateSizes(missing))
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { brotliCompressSync, constants, gzipSync } from 'node:zlib'
|
||||
import { ensureArchive, hash, readMember, refactorDir } from './releases.mjs'
|
||||
|
||||
const reportPath = path.join(refactorDir, 'baselines/sizes.json')
|
||||
export function measureBytes(bytes) {
|
||||
return { sha256: hash(bytes), raw: bytes.length, gzip9: gzipSync(bytes, { level: 9 }).length, brotli6: brotliCompressSync(bytes, { params: { [constants.BROTLI_PARAM_QUALITY]: 6 } }).length }
|
||||
}
|
||||
export async function publishedSizes() {
|
||||
const releases = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/releases.json'), 'utf8')).releases
|
||||
const result = []
|
||||
for (const release of releases) {
|
||||
const archive = await ensureArchive(release)
|
||||
for (const member of Object.keys(release.files).filter(name => /\.(m?js)$/.test(name)).sort()) {
|
||||
const sizes = measureBytes(readMember(archive, member))
|
||||
assert.equal(sizes.sha256, release.files[member])
|
||||
result.push({ name: release.name, version: release.version, path: member, ...sizes })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
export function validateSizes(report) {
|
||||
assert.equal(report.schemaVersion, 1)
|
||||
for (const source of ['published', 'workspaceObserved']) {
|
||||
for (const item of report[source]) {
|
||||
assert(/^[a-f0-9]{64}$/.test(item.sha256))
|
||||
for (const key of ['raw', 'gzip9', 'brotli6']) assert(Number.isInteger(item[key]) && item[key] > 0, 'Invalid bundle size')
|
||||
}
|
||||
}
|
||||
const packages = JSON.parse(fs.readFileSync(path.join(refactorDir, 'package-inventory.json'), 'utf8')).packages.filter(pkg => pkg.name !== 'artplayer-vitepress').map(pkg => pkg.name).sort()
|
||||
assert.deepEqual([...new Set(report.workspaceObserved.map(item => item.name))].sort(), packages, 'Missing library size inventory')
|
||||
}
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const published = await publishedSizes()
|
||||
if (process.argv.includes('--capture')) {
|
||||
assert(!fs.existsSync(reportPath), 'Do not overwrite historical size measurements')
|
||||
const distribution = JSON.parse(fs.readFileSync(path.join(refactorDir, 'baselines/distribution.json'), 'utf8'))
|
||||
const workspaceObserved = distribution.packages.flatMap(pkg => pkg.resources.filter(resource => resource.path.startsWith('dist/') && /\.(m?js)$/.test(resource.path)).map(resource => {
|
||||
const bytes = fs.readFileSync(path.resolve(refactorDir, '../packages', pkg.name, resource.path))
|
||||
assert.equal(hash(bytes), resource.sha256, 'Observed dist changed since BASE-05; record a new source snapshot')
|
||||
return { name: pkg.name, version: pkg.manifest.version, path: resource.path, ...measureBytes(bytes) }
|
||||
}))
|
||||
const report = { schemaVersion: 1, task: 'BASE-06', sourceCommit: 'c7a375e1', capturedAt: new Date().toISOString(), node: process.version, zlib: process.versions.zlib, brotli: process.versions.brotli,
|
||||
note: 'Independent file compression: gzip level 9, Brotli quality 6. Not production HTTP transfer size. Workspace files match BASE-05 observed hashes and may be stale; do not treat them as verified npm releases. Documentation site has no library bundle entry.',
|
||||
reviewThreshold: { relative: 0.05, absoluteBytes: 1024, rule: 'Review if same-file increase exceeds max(5 percent, 1024 bytes); not an optimization or automatic release verdict' }, published, workspaceObserved }
|
||||
validateSizes(report)
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`)
|
||||
}
|
||||
else {
|
||||
assert(process.argv.slice(2).every(arg => arg === '--check'), 'Use --capture or --check')
|
||||
const frozen = JSON.parse(fs.readFileSync(reportPath, 'utf8'))
|
||||
validateSizes(frozen)
|
||||
assert.deepEqual(published, frozen.published, 'Published bundle sizes changed')
|
||||
console.log(`Recomputed ${published.length} published JS sizes; workspace inventory remains historical`)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user