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

+6
View File
@@ -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)}`)
+126
View File
@@ -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
View File
@@ -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')
+69 -2
View File
@@ -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() {
+120
View File
@@ -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/)
})
+30
View File
@@ -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`.
+72
View File
@@ -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`)
}
})
+9 -3
View File
@@ -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)
})