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

+35
View File
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
// eslint-disable-next-line test/no-import-node-test -- This fixture exercises the repository's Node test runner.
import { test } from 'node:test'
import { checkFiles, publishedConsumer } from '../scripts/package-check.mjs'
import { removeConsumer, runtimeConsumer } from '../scripts/package-consumer.mjs'
test('Package checks reject missing files, missing wildcard exports and internal configuration', () => {
const manifest = { name: 'fixture', main: './dist/main.js', module: './dist/main.mjs', types: './types/main.d.ts', legacy: './dist/legacy.js', exports: { '.': './dist/main.js', './lang/*': './dist/lang/*.js' } }
const files = ['package/dist/main.js', 'package/dist/main.mjs', 'package/types/main.d.ts', 'package/dist/legacy.js', 'package/dist/lang/fr.js']
checkFiles(manifest, files)
for (const missing of files)
assert.throws(() => checkFiles(manifest, files.filter(file => file !== missing)), /Missing/)
assert.throws(() => checkFiles(manifest, [...files, 'package/tsconfig.json']), /tsconfig/)
assert.throws(() => checkFiles(manifest, files, ['package/dist/old.js']), /Historical/)
})
test('Actual isolated runtime rejects removed default exports and required files', async () => {
const { dir } = await publishedConsumer()
try {
assert.equal(runtimeConsumer(dir).checks.length, 23)
const esm = path.join(dir, 'node_modules/artplayer-plugin-chapter/dist/artplayer-plugin-chapter.mjs')
const original = fs.readFileSync(esm)
fs.writeFileSync(esm, 'export const removedDefault = true\n')
assert.throws(() => runtimeConsumer(dir), /Command failed/)
fs.writeFileSync(esm, original)
const entry = path.join(dir, 'node_modules/artplayer-plugin-chapter/dist/artplayer-plugin-chapter.js')
fs.unlinkSync(entry)
assert.throws(() => runtimeConsumer(dir), /Command failed/)
}
finally {
removeConsumer(dir)
}
})
+44
View File
@@ -0,0 +1,44 @@
# Installed package checks
Run `yarn test:package` with the pinned Node and Yarn after a frozen install. The
initial scope is core and chapter; extend `names` in `scripts/package-consumer.mjs`
and add package-specific consumers when migrating another package.
`scripts/package-check.mjs` copies source into an ignored build snapshot, rebuilds
all three formats and core languages using repository scripts, and runs Yarn pack.
It checks manifest targets, historical distribution files, archive members and
unexpected source/configuration files. It then installs the tarballs in a fresh
OS temporary directory outside the repository, using the root lock to resolve
runtime dependencies offline, and repeats installation with a frozen consumer lock.
Install hooks are disabled; packages with such hooks are rejected until covered.
Only the build snapshot links workspace build tools. The consumer has no workspace
package links, `NODE_PATH` is cleared, installed files must match archive hashes,
and compiler inputs may only resolve inside that consumer or the pinned compiler's
standard library. Temporary consumers are cleaned up after success or failure.
`runtime.cjs` tests CJS, ESM, UMD, AMD, legacy and language entrypoints, SSR import,
export restrictions and shared Emitter behavior. Public property descriptors and
default configuration are compared against the fixed published core/chapter.
This is a targeted compatibility check, not a complete behavioral API comparison.
Public, chapter-options, language and legacy-plugin TypeScript consumers run with
TS 5.9.3 (Node10, NodeNext CJS/ESM and Bundler) and TS 4.3.5 (Node10). Exact known
diagnostics are referenced by `known-types.json` and `test/types/known-diagnostics.json`.
Unexpected errors and unexpectedly removed errors both require investigation.
Remove a known case when its owning task fixes it; keep frozen release evidence intact.
`yarn test:package:release` additionally rejects any remaining known type errors.
Passing this command alone is not authorization or sufficient evidence to publish.
Reports, archives, installed artifact copies and build/install logs are under
`refactor/.cache/packages/run-*`; `latest.json` points to the last successful
compatibility run (a strict release rejection still leaves its report). For browser
validation set `ARTPLAYER_BROWSER_ARTIFACTS` to that run's `browser-artifacts.json`
and run `yarn test:browser`. The service then uses installed package bytes without
falling back to source. CI performs this sequence and uploads only reports and
artifacts, excluding the build snapshot's node_modules link.
`test/package-check.test.js` verifies missing targets, leaked configuration and
removed historical files, then deliberately removes an actual published default
export and required entry in an isolated consumer to prove runtime failures are
detected. It is part of `yarn test:node`.
+8
View File
@@ -0,0 +1,8 @@
{
"note": "Exact published diagnostic references, not release waivers. Remove a case only with a verified fix; never edit the frozen baseline to accept a candidate regression.",
"cases": [
{ "mode": "node10-commonjs", "fixture": "legacy-plugin.ts", "risk": "BASE-TYPE-03" },
{ "mode": "nodenext-esm", "fixture": "legacy-plugin.ts", "risk": "BASE-TYPE-01" },
{ "mode": "nodenext-esm", "fixture": "language.ts", "risk": "BASE-TYPE-01" }
]
}
+108
View File
@@ -0,0 +1,108 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const process = require('node:process')
const { pathToFileURL } = require('node:url')
const vm = require('node:vm');
(async () => {
const checks = []
const observations = {}
const check = (id, value) => {
assert(value, id)
checks.push(id)
}
const root = __dirname
const load = relative => fs.readFileSync(path.join(root, 'node_modules', relative), 'utf8')
const core = require('artplayer')
const chapter = require('artplayer-plugin-chapter')
check('DIST.cjs', typeof core === 'function' && core.version === JSON.parse(fs.readFileSync(path.join(root, 'expected.json'))).version && typeof chapter({}) === 'function')
check('DIST.cjs-legacy', require('artplayer/legacy').version === core.version && typeof require('artplayer-plugin-chapter/legacy')({}) === 'function')
const esm = await import('artplayer')
const pluginEsm = await import('artplayer-plugin-chapter')
check('DIST.esm', esm.default.version === core.version && typeof pluginEsm.default({}) === 'function')
check('DIST.esm-default-only', Object.keys(esm).join() === 'default' && Object.keys(pluginEsm).join() === 'default')
const legacy = await import('artplayer/legacy')
const pluginLegacy = await import('artplayer-plugin-chapter/legacy')
check('DIST.esm-legacy', legacy.default.version === core.version && typeof pluginLegacy.default({}) === 'function')
for (const [name, filename, globalName] of [
['core', 'artplayer/dist/artplayer.js', 'Artplayer'],
['core-legacy', 'artplayer/dist/artplayer.legacy.js', 'Artplayer'],
['chapter', 'artplayer-plugin-chapter/dist/artplayer-plugin-chapter.js', 'artplayerPluginChapter'],
['chapter-legacy', 'artplayer-plugin-chapter/dist/artplayer-plugin-chapter.legacy.js', 'artplayerPluginChapter'],
]) {
const code = load(filename)
const global = vm.createContext({ console })
vm.runInContext(code, global, { timeout: 2000 })
check(`DIST.global-${name}`, typeof global[globalName] === 'function')
let value
let calls = 0
const define = (factory) => {
calls++
value = factory()
}
define.amd = {}
const amd = vm.createContext({ console, define })
vm.runInContext(code, amd, { timeout: 2000 })
check(`DIST.amd-${name}`, calls === 1 && typeof value === 'function' && amd[globalName] === value)
}
check('SSR.import-template', core.html.includes('art-video-player') && esm.default.html === core.html && typeof core.STYLE === 'string' && core.instances.length === 0)
observations.constructorErrors = []
for (const Class of [core, esm.default, legacy.default]) {
for (const useSSR of [false, true]) {
let error
try {
Reflect.construct(Class, [{ container: '#server', url: 'video.mp4', useSSR }])
}
catch (caught) { error = caught }
assert.equal(error?.message, 'Artplayer can only be used in the browser environment')
observations.constructorErrors.push({ useSSR, name: error.name, message: error.message })
}
}
check('SSR.constructor-browser-only', observations.constructorErrors.length === 6)
observations.languages = []
const languages = fs.readdirSync(path.join(root, 'node_modules/artplayer/dist/i18n')).filter(file => file.endsWith('.mjs')).map(file => file.slice(0, -4)).sort()
for (const name of languages) {
const cjs = require(`artplayer/i18n/${name}`)
const module = await import(`artplayer/i18n/${name}`)
assert.deepEqual(module.default, cjs)
const globalName = `artplayerI18n${name.replace(/(^|-)([a-z])/g, (_, _prefix, char) => char.toUpperCase())}`
const context = vm.createContext({})
vm.runInContext(load(`artplayer/dist/i18n/${name}.js`), context, { timeout: 2000 })
assert.equal(JSON.stringify(context[globalName]), JSON.stringify(cjs))
observations.languages.push({ name, globalName, keys: Object.keys(cjs).sort(), play: cjs.Play })
}
check('DIST.i18n-cjs-esm-global', languages.length > 0)
observations.blockedDeepImports = []
for (const specifier of ['artplayer/dist/artplayer.js', 'artplayer/types/artplayer.d.ts', 'artplayer-plugin-chapter/dist/artplayer-plugin-chapter.js']) {
let code
try {
require.resolve(specifier)
}
catch (error) { code = error.code }
assert.equal(code, 'ERR_PACKAGE_PATH_NOT_EXPORTED')
observations.blockedDeepImports.push({ specifier, code })
}
check('DIST.exports-boundary', observations.blockedDeepImports.length === 3)
observations.resolutions = {}
for (const specifier of ['artplayer', 'artplayer/legacy', 'artplayer-plugin-chapter', 'artplayer-plugin-chapter/legacy']) {
const resolved = require.resolve(specifier)
assert(resolved.startsWith(path.join(root, 'node_modules') + path.sep), 'Escaped isolated packages')
observations.resolutions[specifier] = path.relative(root, resolved).replaceAll('\\', '/')
}
// CDN-style absolute URLs bypass package exports; verify the actual files remain readable imports.
const direct = await import(pathToFileURL(path.join(root, 'node_modules/artplayer/dist/artplayer.mjs')).href)
check('DIST.direct-esm-file', direct.default === esm.default)
const { emitterContracts } = await import('./emitter.mjs')
for (const [id, run] of Object.entries(emitterContracts)) {
run(core.Emitter)
checks.push(id)
}
const shape = value => Object.fromEntries(Object.entries(Object.getOwnPropertyDescriptors(value)).map(([key, d]) => [key, { enumerable: d.enumerable, configurable: d.configurable, writable: d.writable, get: typeof d.get, set: typeof d.set, value: typeof d.value }]))
observations.api = { static: shape(core), prototype: shape(core.prototype), emitter: shape(core.Emitter.prototype), factory: shape(chapter), defaults: JSON.parse(JSON.stringify(core.option, (_, value) => typeof value === 'function' ? '$function' : value)) }
process.stdout.write(JSON.stringify({ checks, observations }))
})().catch((error) => {
console.error(error)
process.exitCode = 1
})