test(packages): ENG-07 verify installed tarball contracts and browsers

This commit is contained in:
Harvey Zhao committed 2026-09-10 23:04:19 +08:00
1 parent 12b047f9a6
commit a0286c4e95
17 files changed
+702 -9

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { ensureArchive, hash, readMember } from '../refactor/scripts/releases.mjs'
import { consumerDirectory, names, readJson, removeConsumer, run, runtimeConsumer, typeConsumers, workspace, writeJson } from './package-consumer.mjs'
export function checkFiles(manifest, files, historical = []) {
for (const field of ['main', 'module', 'types', 'legacy']) {
assert(typeof manifest[field] === 'string', `Missing ${field}: ${manifest.name}`)
assert(files.includes(`package/${manifest[field].replace(/^\.\//, '')}`), `Missing ${field} file: ${manifest.name}`)
}
function visit(value) {
if (typeof value === 'string') {
assert(value.startsWith('./') && !value.includes('..'), 'Invalid export target')
const pattern = new RegExp(`^package/${value.slice(2).split('*').map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.+')}\$`)
assert(files.some(file => pattern.test(file)), `Missing export target: ${value}`)
}
else if (value && typeof value === 'object') {
for (const child of Object.values(value)) visit(child)
}
}
visit(manifest.exports)
for (const file of historical.filter(file => /^package\/(?:dist|types)\//.test(file)))
assert(files.includes(file), `Historical distribution file removed: ${file}`)
assert(!files.some(file => /^package\/(?:src|node_modules)\//.test(file)), 'Source/dependencies leaked into package')
assert(!files.includes('package/tsconfig.json'), 'Implementation tsconfig leaked into package')
}
export function packedFiles(archive) {
const args = { encoding: 'utf8', windowsHide: true }
const names = execFileSync('tar', ['-tzf', archive], args).trim().split(/\r?\n/)
const entries = execFileSync('tar', ['-tvzf', archive], args).trim().split(/\r?\n/)
assert.equal(names.length, entries.length, 'Archive listing mismatch')
assert.equal(new Set(names).size, names.length, 'Duplicate archive members')
return names.filter((name, index) => {
const type = entries[index][0]
assert(['d', '-'].includes(type), 'Package links and special files are unsupported')
assert((type === 'd' && name === 'package') || name.startsWith('package/'), 'Invalid package root')
assert(!name.includes('\\') && !name.split('/').includes('..'), 'Unsafe package member')
return type === '-'
}).sort()
}
export async function publishedConsumer() {
const dir = consumerDirectory()
const releases = readJson(path.join(workspace, 'refactor/baselines/releases.json')).releases
try {
for (const release of releases) {
const archive = await ensureArchive(release)
for (const [member, digest] of Object.entries(release.files)) {
const bytes = readMember(archive, member)
assert.equal(hash(bytes), digest)
const file = path.join(dir, 'node_modules', release.name, member.slice(8))
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, bytes)
}
}
return { dir, releases }
}
catch (error) {
removeConsumer(dir)
throw error
}
}
export async function checkPackages({ release = false } = {}) {
assert.equal(process.env.npm_config_user_agent?.split(' ')[0], 'yarn/1.22.22', 'Run yarn test:package with the pinned Yarn')
const yarn = process.env.npm_execpath
assert(yarn && fs.existsSync(yarn), 'Missing Yarn executable')
const parent = path.join(workspace, 'refactor/.cache/packages')
fs.mkdirSync(parent, { recursive: true })
const output = fs.mkdtempSync(path.join(parent, 'run-'))
const snapshot = path.join(output, 'build')
fs.mkdirSync(snapshot)
for (const file of ['package.json', 'yarn.lock']) fs.copyFileSync(path.join(workspace, file), path.join(snapshot, file))
fs.cpSync(path.join(workspace, 'scripts'), path.join(snapshot, 'scripts'), { recursive: true })
fs.symlinkSync(path.join(workspace, 'node_modules'), path.join(snapshot, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir')
for (const name of names) {
const source = path.join(workspace, 'packages', name)
fs.cpSync(source, path.join(snapshot, 'packages', name), { recursive: true, filter: file => !['dist', 'node_modules'].includes(path.basename(file)) })
}
fs.writeFileSync(path.join(output, 'build.log'), run(['scripts/build.js', ...names], snapshot) + run(['scripts/build-i18n.js'], snapshot))
const baseline = await publishedConsumer()
const installed = consumerDirectory()
try {
const oldRuntime = runtimeConsumer(baseline.dir)
const packages = []
for (const name of names) {
const archive = path.join(output, `${name}.tgz`)
fs.writeFileSync(path.join(output, `${name}-pack.log`), run([yarn, 'pack', '--filename', archive], path.join(snapshot, 'packages', name)))
const files = packedFiles(archive)
const manifest = JSON.parse(readMember(archive, 'package/package.json'))
assert.equal(manifest.name, name)
assert(!['preinstall', 'install', 'postinstall'].some(hook => manifest.scripts?.[hook]), 'Add lifecycle fixtures before accepting install hooks')
checkFiles(manifest, files, Object.keys(baseline.releases.find(r => r.name === name).files))
packages.push({ name, version: manifest.version, archive: path.basename(archive), sha256: hash(fs.readFileSync(archive)), files: Object.fromEntries(files.map(file => [file, hash(readMember(archive, file))])) })
}
writeJson(path.join(installed, 'package.json'), { private: true, name: 'artplayer-isolated-consumer', dependencies: Object.fromEntries(packages.map(pkg => [pkg.name, `file:${path.join(output, pkg.archive).replaceAll('\\', '/')}`])) })
fs.copyFileSync(path.join(workspace, 'yarn.lock'), path.join(installed, 'yarn.lock'))
fs.writeFileSync(path.join(output, 'install.log'), run([yarn, 'install', '--offline', '--ignore-scripts', '--non-interactive'], installed))
const lock = fs.readFileSync(path.join(installed, 'yarn.lock'))
fs.writeFileSync(path.join(output, 'frozen-install.log'), run([yarn, 'install', '--offline', '--frozen-lockfile', '--ignore-scripts', '--non-interactive'], installed))
assert.deepEqual(fs.readFileSync(path.join(installed, 'yarn.lock')), lock, 'Frozen consumer lock changed')
fs.writeFileSync(path.join(output, 'consumer-yarn.lock'), lock)
const artifacts = {}
for (const pkg of packages) {
const root = path.join(installed, 'node_modules', pkg.name)
assert.equal(fs.realpathSync(root), root, 'Installed package must not be a workspace link')
for (const [member, digest] of Object.entries(pkg.files))
assert.equal(hash(fs.readFileSync(path.join(root, member.slice(8)))), digest, `Installed bytes differ: ${member}`)
fs.cpSync(root, path.join(output, 'artifacts', pkg.name), { recursive: true })
artifacts[pkg.name] = `./artifacts/${pkg.name}/dist/${pkg.name}.js`
}
const runtime = runtimeConsumer(installed)
assert.deepEqual(runtime.observations.api, oldRuntime.observations.api, 'Published API shape/defaults changed')
const types = typeConsumers(installed)
const report = { task: 'ENG-07', capturedAt: new Date().toISOString(), node: process.versions.node, packages, runtime, publishedRuntime: oldRuntime, types, knownTypeBlockers: types.reduce((sum, result) => sum + result.diagnostics.length, 0) }
writeJson(path.join(output, 'report.json'), report)
writeJson(path.join(output, 'browser-artifacts.json'), artifacts)
writeJson(path.join(parent, 'latest.json'), { output: path.relative(workspace, output).replaceAll('\\', '/') })
console.log(`Installed tarball contracts passed: ${runtime.checks.length} runtime checks; ${types.filter(t => !t.diagnostics.length).length}/${types.length} type modes. Report: ${output}`)
if (release)
assert.equal(report.knownTypeBlockers, 0, 'Known type blockers remain; this candidate is not release-ready')
return report
}
finally {
removeConsumer(installed)
removeConsumer(baseline.dir)
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
checkPackages({ release: process.argv.includes('--release') }).catch((error) => {
console.error(error)
process.exitCode = 1
})
}
+90
View File
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
import compat from 'typescript-compat'
export const workspace = fileURLToPath(new URL('../', import.meta.url))
export const names = ['artplayer', 'artplayer-plugin-chapter']
export const readJson = file => JSON.parse(fs.readFileSync(file, 'utf8'))
export const writeJson = (file, data) => fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`)
export const run = (args, cwd) => execFileSync(process.execPath, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 120000, maxBuffer: 8 * 1024 * 1024, windowsHide: true, env: { ...process.env, NODE_PATH: '' } })
export function consumerDirectory() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'artplayer-consumer-'))
assert(!path.resolve(dir).startsWith(path.resolve(workspace)), 'Consumer must be outside workspace')
return dir
}
export function removeConsumer(dir) {
assert.equal(path.dirname(fs.realpathSync(dir)), fs.realpathSync(os.tmpdir()))
assert(path.basename(dir).startsWith('artplayer-consumer-'), 'Refusing unrelated temporary cleanup')
fs.rmSync(dir, { recursive: true, force: true })
}
export function runtimeConsumer(dir) {
fs.copyFileSync(path.join(workspace, 'test/package/runtime.cjs'), path.join(dir, 'runtime.cjs'))
fs.copyFileSync(path.join(workspace, 'test/contracts/emitter.js'), path.join(dir, 'emitter.mjs'))
writeJson(path.join(dir, 'expected.json'), { version: readJson(path.join(dir, 'node_modules/artplayer/package.json')).version })
return JSON.parse(run(['runtime.cjs'], dir))
}
export function typeConsumers(dir) {
const results = []
for (const [compiler, mode] of [[ts, 'node10-commonjs'], [ts, 'nodenext-cjs'], [ts, 'bundler-esm'], [compat, 'node10-commonjs'], [ts, 'nodenext-esm']]) {
const next = mode.startsWith('nodenext')
const extension = next ? (mode.endsWith('-cjs') ? 'cts' : 'mts') : 'ts'
const inputs = [...(mode === 'nodenext-esm' ? ['public'] : ['public', 'chapter-options']), 'language', 'legacy-plugin']
const files = inputs.map((name) => {
const file = path.join(dir, `${name}.${extension}`)
const folder = ['language', 'legacy-plugin'].includes(name) ? 'refactor/fixtures/consumers' : 'test/types'
fs.copyFileSync(path.join(workspace, folder, `${name}.ts`), file)
return file
})
const options = {
strict: true,
noEmit: true,
skipLibCheck: false,
types: [],
esModuleInterop: true,
target: compiler.ScriptTarget.ES2020,
lib: ['lib.es2020.d.ts', 'lib.dom.d.ts'],
module: next ? compiler.ModuleKind.NodeNext : mode === 'bundler-esm' ? compiler.ModuleKind.ESNext : compiler.ModuleKind.CommonJS,
moduleResolution: next ? compiler.ModuleResolutionKind.NodeNext : mode === 'bundler-esm' ? compiler.ModuleResolutionKind.Bundler : compiler.ModuleResolutionKind.NodeJs,
}
const program = compiler.createProgram(files, options)
const libDir = path.dirname(compiler.sys.getExecutingFilePath())
for (const file of program.getSourceFiles()) {
const actual = fs.realpathSync(file.fileName)
assert(actual.startsWith(fs.realpathSync(dir) + path.sep)
|| (path.dirname(actual) === fs.realpathSync(libDir) && /^lib\..*\.d\.ts$/.test(path.basename(actual))), `Type resolution escaped consumer: ${file.fileName}`)
}
const diagnostics = compiler.getPreEmitDiagnostics(program).map(d => ({
file: d.file ? `test/types/${path.basename(d.file.fileName).replace(/\.[cm]ts$/, '.ts').replace(/^public\.ts$/, 'consumer.ts')}` : null,
code: d.code,
line: d.file && d.start !== undefined ? d.file.getLineAndCharacterOfPosition(d.start).line + 1 : null,
message: compiler.flattenDiagnosticMessageText(d.messageText, '\n').replaceAll(`${dir.replaceAll('\\', '/')}/node_modules/`, '<workspace>/packages/'),
}))
const expected = mode === 'nodenext-esm' ? readJson(path.join(workspace, 'test/types/known-diagnostics.json')).diagnostics : []
const historical = readJson(path.join(workspace, 'refactor/baselines/consumers.json')).types
const known = readJson(path.join(workspace, 'test/package/known-types.json')).cases
for (const fixture of ['language.ts', 'legacy-plugin.ts']) {
if (!known.some(item => item.fixture === fixture && item.mode === mode))
continue
const original = historical.find(result => result.mode === mode && result.fixture === fixture)
for (const diagnostic of original.diagnostics) {
expected.push({ ...diagnostic, file: `test/types/${fixture}`, message: compiler === compat
? diagnostic.message.split('\n')[0]
: diagnostic.message.replaceAll('<consumer>/node_modules/', '<workspace>/packages/') })
}
}
const ordered = values => [...values].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.code - b.code)
assert.deepEqual(ordered(diagnostics), ordered(expected), `Packed type regression: TS ${compiler.version} ${mode}`)
results.push({ compiler: compiler.version, mode, diagnostics, declarations: program.getSourceFiles().filter(f => f.fileName.includes('/node_modules/')).map(f => path.relative(dir, f.fileName).replaceAll('\\', '/')).sort() })
}
return results
}