refactor(vtt-thumbnail): [PKG-VTT-THUMB-04] preserve legacy types and expose async runtime entry

This commit is contained in:
Harvey Zhao committed 2026-09-13 09:30:57 +08:00
1 parent 7f2906ae52
commit db358a3ae4
33 files changed
+3864 -98

No files matched your search

@@ -0,0 +1,108 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import ts from 'typescript'
import compat from 'typescript-compat'
import { checkFiles, packedFiles } from '../../scripts/package-check.mjs'
import { consumerDirectory, removeConsumer, run, workspace, writeJson } from '../../scripts/package-consumer.mjs'
import { hash, readMember } from './releases.mjs'
import { verifyVttThumbnailContract } from './vtt-thumbnail-contract.mjs'
async function main() {
assert.equal(process.env.npm_config_user_agent?.split(' ')[0], 'yarn/1.22.22', 'Use yarn test:vtt-thumbnail-types-package')
const yarn = process.env.npm_execpath
assert(yarn && fs.existsSync(yarn))
const output = fs.mkdtempSync(path.join(workspace, 'refactor/.cache/vtt-thumbnail-package-types-'))
console.log(`VTT thumbnail isolated package evidence: ${output}`)
const contract = await verifyVttThumbnailContract()
const packages = []
for (const name of ['artplayer', 'artplayer-plugin-vtt-thumbnail']) {
const archive = path.join(output, `${name}.tgz`)
fs.writeFileSync(path.join(output, `${name}-pack.log`), run([yarn, 'pack', '--filename', archive], path.join(workspace, 'packages', name)))
const manifest = JSON.parse(readMember(archive, 'package/package.json'))
const files = packedFiles(archive)
checkFiles(manifest, files, name === contract.baseline.release.name ? Object.keys(contract.baseline.release.files) : [])
packages.push({ name, archive, sha256: hash(fs.readFileSync(archive)), files: Object.fromEntries(files.map(member => [member, hash(readMember(archive, member))])) })
}
const matrix = []
const core = packages[0]
const candidates = [
...[...contract.baseline.previous].reverse().concat(contract.baseline.release).map(release => ({ ...release, archive: contract.archives.get(release.version), label: `published-${release.version}` })),
{ ...packages[1], label: 'candidate' },
]
for (const plugin of candidates) {
const consumer = consumerDirectory()
try {
writeJson(path.join(consumer, 'package.json'), { name: 'vtt-thumbnail-isolated-consumer', private: true, dependencies: Object.fromEntries([core, plugin].map(pkg => [pkg.name, `file:${pkg.archive.replaceAll('\\', '/')}`])) })
fs.copyFileSync(path.join(workspace, 'yarn.lock'), path.join(consumer, 'yarn.lock'))
fs.writeFileSync(path.join(output, `${plugin.label}-install.log`), run([yarn, 'install', '--offline', '--ignore-scripts', '--non-interactive'], consumer))
const lock = fs.readFileSync(path.join(consumer, 'yarn.lock'))
fs.writeFileSync(path.join(output, `${plugin.label}-frozen-install.log`), run([yarn, 'install', '--offline', '--frozen-lockfile', '--ignore-scripts', '--non-interactive'], consumer))
assert.deepEqual(fs.readFileSync(path.join(consumer, 'yarn.lock')), lock)
fs.writeFileSync(path.join(output, `${plugin.label}-yarn.lock`), lock)
for (const pkg of [core, plugin]) {
const root = path.join(consumer, 'node_modules', pkg.name)
assert.equal(fs.realpathSync(root), root, 'Installed package must not be a workspace link')
for (const [member, expected] of Object.entries(pkg.files))
assert.equal(hash(fs.readFileSync(path.join(root, member.slice(8)))), expected, `Installed bytes differ: ${member}`)
}
const runtimeFile = path.join(consumer, 'exports.cjs')
fs.writeFileSync(runtimeFile, plugin.label === 'candidate'
? `const assert = require('node:assert/strict');
for (const name of ['artplayer-plugin-vtt-thumbnail', 'artplayer-plugin-vtt-thumbnail/legacy', 'artplayer-plugin-vtt-thumbnail/runtime']) { const factory = require(name); assert.equal(typeof factory, 'function'); assert.equal(factory.default, factory); assert.equal(typeof factory({}), 'function'); }
assert.equal(require('artplayer-plugin-vtt-thumbnail/runtime'), require('artplayer-plugin-vtt-thumbnail'));
Promise.all(['artplayer-plugin-vtt-thumbnail', 'artplayer-plugin-vtt-thumbnail/runtime', 'artplayer-plugin-vtt-thumbnail/legacy'].map(name => import(name))).then(([root, runtime, legacy]) => { assert.deepEqual(Object.keys(root), ['default']); assert.deepEqual(Object.keys(runtime), ['default']); assert.equal(runtime.default, root.default); assert.equal(root.default.default, root.default); assert.equal(legacy.default, require('artplayer-plugin-vtt-thumbnail/legacy')); assert.equal(legacy.default.default, legacy.default); }).catch(error => { console.error(error); process.exitCode = 1; });`
: plugin.version === '1.0.0'
? `const assert = require('node:assert/strict'); assert.throws(() => require('artplayer-plugin-vtt-thumbnail'), error => error.name === 'SyntaxError' && error.message.includes('Invalid regular expression')); console.log('Expected historical 1.0.0 invalid runtime syntax preserved');`
: `const assert = require('node:assert/strict'); const value = require('artplayer-plugin-vtt-thumbnail'); assert.equal(typeof value, '${plugin.version === '1.1.0' ? 'function' : 'object'}'); assert.equal(typeof (value.default || value)({}), 'function');`)
fs.writeFileSync(path.join(output, `${plugin.label}-runtime.log`), run([runtimeFile], consumer))
const candidateModes = [[ts, 'node10-commonjs'], [ts, 'nodenext-cjs'], [ts, 'nodenext-esm'], [ts, 'bundler-esm'], [compat, 'node10-commonjs']]
if (plugin.label === 'candidate')
candidateModes.push([ts, 'node10-commonjs-no-interop'], [compat, 'node10-commonjs-no-interop'])
for (const [compiler, mode] of (plugin.label === 'candidate' ? candidateModes : [[ts, 'node10-commonjs'], [compat, 'node10-commonjs']])) {
const next = mode.startsWith('nodenext')
const extension = next ? mode.endsWith('-cjs') ? 'cts' : 'mts' : 'ts'
const source = fs.readFileSync(path.join(workspace, plugin.label === 'candidate' ? 'test/types/vtt-thumbnail-public.ts' : 'refactor/fixtures/consumers/vtt-thumbnail-published.ts'), 'utf8')
const filename = path.join(consumer, `consumer.${extension}`)
const historical = path.join(consumer, `historical.${extension}`)
fs.copyFileSync(path.join(workspace, 'refactor/fixtures/consumers/vtt-thumbnail-published.ts'), historical)
const files = plugin.label === 'candidate' ? [filename, historical] : [filename]
if (mode === 'nodenext-cjs' && plugin.label === 'candidate') {
const commonjs = path.join(consumer, 'commonjs.cts')
fs.writeFileSync(commonjs, `import vtt = require('artplayer-plugin-vtt-thumbnail'); import legacy = require('artplayer-plugin-vtt-thumbnail/legacy'); import runtime = require('artplayer-plugin-vtt-thumbnail/runtime'); vtt.default({}); legacy.default({}); runtime({}); runtime.default({});`)
files.push(commonjs)
}
const options = { strict: true, noEmit: true, skipLibCheck: false, types: [], esModuleInterop: !mode.endsWith('-no-interop'), 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 }
function compile(code) {
fs.writeFileSync(filename, code)
const program = compiler.createProgram(files, options)
for (const file of program.getSourceFiles()) {
const actual = fs.realpathSync(file.fileName)
assert(actual.startsWith(fs.realpathSync(consumer) + path.sep) || (program.isSourceFileDefaultLibrary(file) && path.dirname(actual) === fs.realpathSync(path.dirname(compiler.sys.getExecutingFilePath()))), `Types escaped installed consumer: ${actual}`)
}
return compiler.getPreEmitDiagnostics(program).map(item => ({ code: item.code, message: compiler.flattenDiagnosticMessageText(item.messageText, '\n') }))
}
const diagnostics = compile(source)
const expected = []
const historicalFailure = false
writeJson(path.join(output, `${plugin.label}-${compiler.version}-${mode}-diagnostics.json`), { diagnostics, historicalFailure })
assert.deepEqual(diagnostics.map(item => item.code), expected, `${plugin.label} ${compiler.version} ${mode}`)
const invalid = plugin.label === 'candidate' ? compile(source.replaceAll(/\/\/ @ts-expect-error[^\n]*\n/g, '')) : []
if (plugin.label === 'candidate')
assert.equal(invalid.length, 10, 'Installed declarations must reject all invalid uses')
matrix.push({ plugin: plugin.label, compiler: compiler.version, mode, historicalFailure, diagnostics, invalid })
}
}
finally {
removeConsumer(consumer)
}
}
writeJson(path.join(output, 'report.json'), { suite: 'vtt-thumbnail-isolated-package-types', introducedBy: 'PKG-VTT-THUMB-04', scope: 'Five actual published packages (classic default-import consumers on TS 4.3 and 5.9) and packed candidate (seven compiler modes) installed outside the workspace with packed core. Offline/frozen reinstall and byte identity are verified. Candidate provides an opt-in accurate runtime entry; 1.0.0 invalid bundle syntax is retained. Historical NodeNext variants, raw require type replacements, device and full distribution acceptance remain separate.', packages, published: candidates.slice(0, 5).map(pkg => ({ label: pkg.label, archive: pkg.archive, sha256: hash(fs.readFileSync(pkg.archive)), missingEntrypoints: pkg.missingEntrypoints })), matrix })
console.log(`VTT thumbnail installed types passed: ${matrix.length} cases; ${output}`)
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
@@ -0,0 +1,81 @@
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 -- Compatibility declaration runner.
import test from 'node:test'
import { ESLint } from 'eslint'
import ts from 'typescript'
import compat from 'typescript-compat'
import { checkPluginEditorDeclaration, generatePluginEditorDeclaration } from '../../scripts/plugin-editor-types.mjs'
import { checkConsumer } from '../../scripts/typecheck.mjs'
import { readMember } from './releases.mjs'
import { verifyVttThumbnailContract } from './vtt-thumbnail-contract.mjs'
const modes = [[ts, 'node10-commonjs'], [ts, 'nodenext-cjs'], [ts, 'nodenext-esm'], [ts, 'bundler-esm'], [compat, 'node10-commonjs']]
test('VTT generated editor globals retain legacy calls and expose separately named data and runtime types', async () => {
const source = fs.readFileSync('packages/artplayer-plugin-vtt-thumbnail/types/artplayer-plugin-vtt-thumbnail.d.ts', 'utf8')
const generated = generatePluginEditorDeclaration(source, 'artplayerPluginVttThumbnail')
const file = 'docs/assets/ts/artplayer-plugin-vtt-thumbnail.d.ts'
const [formatted] = await new ESLint({ fix: true, fixTypes: ['layout'] }).lintText(generated, { filePath: file })
assert.equal(formatted.errorCount, 0)
assert.equal(fs.readFileSync(file, 'utf8').replaceAll('\r\n', '\n'), formatted.output || generated)
const core = fs.readFileSync('docs/assets/ts/artplayer.d.ts', 'utf8')
const consumer = `declare const art: Artplayer;
const options: artplayerPluginVttThumbnail.Option = { vtt: '/cues.vtt' };
const result: artplayerPluginVttThumbnail.Result = artplayerPluginVttThumbnail(options)(art);
declare const runtime: artplayerPluginVttThumbnail.RuntimeFactory;
const pending: Promise<artplayerPluginVttThumbnail.Result> = runtime(options)(art);
// @ts-expect-error Legacy extraction stays synchronous.
const changed: Promise<artplayerPluginVttThumbnail.Result> = result;
// @ts-expect-error URL must be text.
artplayerPluginVttThumbnail({ vtt: 12 });`
for (const compiler of [ts, compat]) {
assert.deepEqual(checkPluginEditorDeclaration(generated, core, consumer, compiler), [])
assert.equal(checkPluginEditorDeclaration(generated, core, consumer.replaceAll(/\/\/ @ts-expect-error[^\n]*\n/g, ''), compiler).length, 2)
}
})
test('VTT public legacy extraction remains exact while runtime entry gives cast-free Promise types', () => {
const source = fs.readFileSync('test/types/vtt-thumbnail-public.ts', 'utf8')
for (const [compiler, mode] of modes) {
assert.deepEqual(checkConsumer(compiler, mode, source), [], `${compiler.version} ${mode}`)
assert.equal(checkConsumer(compiler, mode, source.replaceAll(/\/\/ @ts-expect-error[^\n]*\n/g, '')).length, 10)
}
})
test('VTT runtime CommonJS declaration supports direct and default calls without an interop cast', () => {
assert.deepEqual(checkConsumer(ts, 'nodenext-cjs', `import runtime = require('artplayer-plugin-vtt-thumbnail/runtime');
import legacy = require('artplayer-plugin-vtt-thumbnail');
import type Artplayer from 'artplayer'; declare const art: Artplayer;
const first = runtime({})(art); const second = runtime.default({})(art);
const promise: Promise<{name: 'artplayerPluginVttThumbnail'}> = first;
legacy.default({})(art).name; void [promise, second];`), [])
})
test('VTT all five historical declarations keep the same default-import consumers and replaceable old factory', async () => {
const contract = await verifyVttThumbnailContract()
const consumer = fs.readFileSync('refactor/fixtures/consumers/vtt-thumbnail-published.ts', 'utf8')
for (const release of [contract.baseline.release, ...contract.baseline.previous]) {
const old = readMember(contract.archives.get(release.version), `package/${release.manifest.types.replace(/^\.\//, '')}`).toString()
for (const compiler of [ts, compat]) {
const filename = path.resolve('test/types/vtt-historical-consumer.ts')
const declaration = path.resolve('test/types/vtt-historical.d.ts')
const options = { strict: true, noEmit: true, skipLibCheck: false, types: [], esModuleInterop: true, module: compiler.ModuleKind.CommonJS, moduleResolution: compiler.ModuleResolutionKind.NodeJs, target: compiler.ScriptTarget.ES2020, lib: ['lib.es2020.d.ts', 'lib.dom.d.ts'] }
function historical(source) {
const host = compiler.createCompilerHost(options)
const read = host.getSourceFile.bind(host)
host.getSourceFile = (file, language, ...rest) => path.resolve(file) === filename
? compiler.createSourceFile(file, source.replace('from \'artplayer-plugin-vtt-thumbnail\'', 'from \'./vtt-historical\''), language, true)
: path.resolve(file) === declaration ? compiler.createSourceFile(file, old, language, true) : read(file, language, ...rest)
const exists = host.fileExists.bind(host)
host.fileExists = file => path.resolve(file) === declaration || exists(file)
return compiler.getPreEmitDiagnostics(compiler.createProgram([filename, declaration], options, host)).map(error => error.code)
}
assert.deepEqual(historical(consumer), [])
assert.deepEqual(historical(`${consumer}\nvtt();`), [2554])
assert.deepEqual(historical(`${consumer}\nconst absent: Parameters<typeof vtt>[0] = undefined;`), [2322])
assert.deepEqual(checkConsumer(compiler, 'node10-commonjs', consumer), [])
}
}
})