fix(thumbnail): [PKG-TOOL-THUMB-04] supply public types and installed module entries

This commit is contained in:
Harvey Zhao committed 2026-09-13 05:16:36 +08:00
1 parent 15485b6420
commit 3bff236145
33 files changed
+2466 -164

No files matched your search

@@ -0,0 +1,114 @@
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 { verifyThumbnailContract } from './thumbnail-contract.mjs'
import { hash, readMember } from './releases.mjs'
async function main() {
assert.equal(process.env.npm_config_user_agent?.split(' ')[0], 'yarn/1.22.22', 'Use yarn test:thumbnail-types-package')
const yarn = process.env.npm_execpath
assert(yarn && fs.existsSync(yarn))
const output = fs.mkdtempSync(path.join(workspace, 'refactor/.cache/thumbnail-package-types-'))
console.log(`Thumbnail installed package evidence: ${output}`)
const contract = verifyThumbnailContract()
const frozen = path.join(output, 'frozen-workspace')
fs.mkdirSync(frozen)
const prefix = 'packages/artplayer-tool-thumbnail/'
for (const [file, text] of contract.source) {
if (!file.startsWith(prefix)) continue
const target = path.join(frozen, file.slice(prefix.length))
fs.mkdirSync(path.dirname(target), { recursive: true })
fs.writeFileSync(target, text)
}
const packages = []
const matrix = []
const runtime = []
for (const [label, folder] of [['workspace-fixture', frozen], ['candidate', path.join(workspace, 'packages/artplayer-tool-thumbnail')]]) {
const archive = path.join(output, `${label}.tgz`)
fs.writeFileSync(path.join(output, `${label}-pack.log`), run([yarn, 'pack', '--filename', archive], folder))
const manifest = JSON.parse(readMember(archive, 'package/package.json'))
const files = packedFiles(archive)
if (label === 'candidate') {
checkFiles(manifest, files, [...contract.source.keys()].filter(file => file.startsWith(`${prefix}dist/`)).map(file => `package/${file.slice(prefix.length)}`))
assert(files.includes('package/THIRD_PARTY_NOTICES'))
assert.equal(readMember(archive, 'package/THIRD_PARTY_NOTICES').toString(), fs.readFileSync(path.join(folder, 'THIRD_PARTY_NOTICES'), 'utf8'))
}
else {
assert.equal(manifest.main, undefined)
assert(!files.includes(`package/${manifest.module}`))
assert(!files.includes(`package/${manifest.types}`))
}
const pkg = { label, name: manifest.name, archive, sha256: hash(fs.readFileSync(archive)), manifest, files: Object.fromEntries(files.map(member => [member, hash(readMember(archive, member))])) }
packages.push(pkg)
const consumer = consumerDirectory()
try {
writeJson(path.join(consumer, 'package.json'), { name: 'thumbnail-isolated-consumer', private: true, dependencies: { [pkg.name]: `file:${archive.replaceAll('\\', '/')}` } })
fs.writeFileSync(path.join(output, `${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, `${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, `${label}-yarn.lock`), lock)
const installed = path.join(consumer, 'node_modules', pkg.name)
assert.equal(fs.realpathSync(installed), installed, 'Consumer must not use workspace links')
for (const [member, expected] of Object.entries(pkg.files))
assert.equal(hash(fs.readFileSync(path.join(installed, member.slice(8)))), expected, member)
const runtimeFile = path.join(consumer, 'runtime.cjs')
fs.writeFileSync(runtimeFile, `const assert = require('node:assert/strict');
const value = require('${pkg.name}');
assert.equal(typeof value, 'function');
assert.equal(value.default, undefined);
assert.equal(value.DEFAULTS.number, 60);
${label === 'candidate' ? `const legacy = require('${pkg.name}/legacy'); assert.equal(typeof legacy, 'function'); assert.equal(legacy.default, undefined);` : ''}
(async () => {
let imported;
try { imported = await import('${pkg.name}'); }
catch (error) { ${label === 'candidate' ? 'throw error;' : `assert.equal(error.code, 'ERR_MODULE_NOT_FOUND'); console.log(JSON.stringify({ commonjs: 'function', esm: error.code, typesMissing: true })); return;`} }
${label === 'candidate' ? `assert.equal(typeof imported.default, 'function'); assert.deepEqual(Object.keys(imported), ['default']); const legacy = await import('${pkg.name}/legacy'); assert.equal(typeof legacy.default, 'function'); console.log(JSON.stringify({ commonjs: 'function', esm: 'function', legacy: 'function', ssrImport: true }));` : `throw new Error('Frozen missing ESM unexpectedly loaded');`}
})().catch(error => { console.error(error); process.exitCode = 1; });`)
const observation = run([runtimeFile], consumer)
fs.writeFileSync(path.join(output, `${label}-runtime.log`), observation)
runtime.push({ package: label, ...JSON.parse(observation.trim()) })
const modes = [[ts, 'node10-commonjs'], [ts, 'nodenext-cjs'], [ts, 'nodenext-esm'], [ts, 'bundler-esm'], [compat, 'node10-commonjs']]
if (label === 'candidate') modes.push([ts, 'nodenext-cjs-no-interop'], [compat, 'node10-commonjs-no-interop'])
for (const [compiler, mode] of modes) {
const next = mode.startsWith('nodenext')
const noInterop = mode.endsWith('no-interop')
const filename = path.join(consumer, `consumer.${next ? mode.includes('-cjs') ? 'cts' : 'mts' : 'ts'}`)
const options = { strict: true, noEmit: true, skipLibCheck: false, types: [], esModuleInterop: !noInterop, 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 source = label === 'candidate' ? fs.readFileSync(path.join(workspace, noInterop ? 'test/types/thumbnail-commonjs.cts' : 'test/types/thumbnail.ts'), 'utf8') : `import Thumbnail from '${pkg.name}'; new Thumbnail({ fileInput: document.createElement('input') });`
function compile(code) {
fs.writeFileSync(filename, code)
const program = compiler.createProgram([filename], options)
for (const file of program.getSourceFiles()) {
const real = fs.realpathSync(file.fileName)
assert(real.startsWith(fs.realpathSync(consumer) + path.sep) || (program.isSourceFileDefaultLibrary(file) && path.dirname(real) === fs.realpathSync(path.dirname(compiler.sys.getExecutingFilePath()))), `Type escaped installed consumer: ${real}`)
}
return compiler.getPreEmitDiagnostics(program).map(item => ({ code: item.code, message: compiler.flattenDiagnosticMessageText(item.messageText, '\n').replaceAll(consumer.replaceAll('\\', '/'), '<consumer>') }))
}
const diagnostics = compile(source)
writeJson(path.join(output, `${label}-${compiler.version}-${mode}.json`), diagnostics)
if (label === 'candidate') assert.deepEqual(diagnostics, [], `${compiler.version} ${mode}`)
else {
assert.deepEqual(diagnostics.map(item => item.code), [mode === 'node10-commonjs' ? 2307 : 7016], 'Only the exact frozen missing entry/declaration diagnostic is expected')
}
const negative = label === 'candidate' ? compile(source.replaceAll(/\/\/ @ts-expect-error[^\n]*\n/g, '')) : []
if (label === 'candidate') assert.equal(negative.length, noInterop ? 1 : 10)
matrix.push({ package: label, compiler: compiler.version, mode, diagnostics, negative })
}
if (label === 'candidate') fs.cpSync(installed, path.join(output, 'installed-artifacts'), { recursive: true })
}
finally { removeConsumer(consumer) }
}
writeJson(path.join(output, 'report.json'), { task: 'PKG-TOOL-THUMB-04', scope: 'Frozen Git input fixture and candidate Yarn pack installed outside workspace. The frozen fixture is not an original npm archive; no recovered 3.5.31 complete archive is claimed. Candidate root/legacy runtime, offline frozen install, all files and strict consumers verified.', packages, matrix, runtime })
console.log(`Thumbnail installed types verified: ${matrix.length} compiler cases; ${output}`)
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
+74
View File
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
// eslint-disable-next-line test/no-import-node-test -- Public type and editor contract runner.
import test from 'node:test'
import ts from 'typescript'
import compat from 'typescript-compat'
import { ESLint } from 'eslint'
import { checkConsumer } from '../../scripts/typecheck.mjs'
import { checkPluginEditorDeclaration, generatePluginEditorDeclaration } from '../../scripts/plugin-editor-types.mjs'
const modes = [[ts, 'node10-commonjs'], [ts, 'nodenext-cjs'], [ts, 'nodenext-esm'], [ts, 'bundler-esm'], [compat, 'node10-commonjs']]
const invalid = source => source.replaceAll(/\/\/ @ts-expect-error[^\n]*\n/g, '')
test('Thumbnail public declarations check actual callbacks, custom events and invalid calls in current and old TS', () => {
const source = fs.readFileSync('test/types/thumbnail.ts', 'utf8')
for (const [compiler, mode] of modes) {
assert.deepEqual(checkConsumer(compiler, mode, source), [], `${compiler.version} ${mode}`)
assert.equal(checkConsumer(compiler, mode, invalid(source)).length, 10, `${compiler.version} ${mode} negatives`)
}
const commonjs = fs.readFileSync('test/types/thumbnail-commonjs.cts', 'utf8')
for (const [compiler, mode] of [[ts, 'nodenext-cjs'], [compat, 'node10-commonjs']]) {
assert.deepEqual(checkConsumer(compiler, mode, commonjs), [])
assert.equal(checkConsumer(compiler, mode, invalid(commonjs)).length, 1)
}
assert.deepEqual(checkConsumer(ts, 'node10-commonjs', fs.readFileSync('test/types/thumbnail-source.ts', 'utf8')), [], 'Source and declaration constructors must be mutually assignable')
})
test('Thumbnail editor declaration is generated with its exact uppercase global and works in old TS', async () => {
const source = fs.readFileSync('packages/artplayer-tool-thumbnail/types/artplayer-tool-thumbnail.d.ts', 'utf8')
const code = generatePluginEditorDeclaration(source, 'ArtplayerToolThumbnail')
const file = 'docs/assets/ts/artplayer-tool-thumbnail.d.ts'
const [formatted] = await new ESLint({ fix: true, fixTypes: ['layout'] }).lintText(code, { filePath: file })
assert.equal(formatted.errorCount, 0)
assert.equal(fs.readFileSync(file, 'utf8').replaceAll('\r\n', '\n'), formatted.output || code)
const consumer = `const input = document.createElement('input');
const options: ArtplayerToolThumbnail.Option = { fileInput: input };
const tool: ArtplayerToolThumbnail = new ArtplayerToolThumbnail(options);
tool.on('update', (url, progress) => url + progress.toFixed());
tool.on('custom', (value: number) => value.toFixed());
const promise: Promise<void> = tool.start();
// @ts-expect-error No lowercase global exists.
const bad = new artplayerToolThumbnail(options);
// @ts-expect-error Invalid known event payload.
tool.emit('update', 12, 'url');`
for (const compiler of [ts, compat]) {
const core = fs.readFileSync('docs/assets/ts/artplayer.d.ts', 'utf8')
assert.deepEqual(checkPluginEditorDeclaration(code, core, consumer, compiler), [])
assert.equal(checkPluginEditorDeclaration(code, core, invalid(consumer), compiler).length, 2)
}
assert.throws(() => generatePluginEditorDeclaration(source.replace('declare class ArtplayerToolThumbnail', 'declare class WrongClass'), 'ArtplayerToolThumbnail'), /Unexpected CommonJS editor class/)
})
test('Full editor generation bundles explicit local MediaBunny types instead of publishing an invalid media global', () => {
const source = fs.readFileSync('packages/artplayer-proxy-mediabunny/types/artplayer-proxy-mediabunny.d.ts', 'utf8')
const media = fs.readFileSync('packages/artplayer-proxy-mediabunny/types/media.d.ts', 'utf8')
const core = fs.readFileSync('docs/assets/ts/artplayer.d.ts', 'utf8')
const frozenGenerator = `${source.replace(/^import.*$/gm, '')}\nexport = artplayerProxyMediabunny;\nexport as namespace artplayerProxyMediabunny;`
const code = generatePluginEditorDeclaration(source, 'artplayerProxyMediabunny', { './media': media })
const consumer = `declare const player: artplayerProxyMediabunny.MediaBunnyPlayer;
const canvas: (art: Artplayer) => HTMLCanvasElement = artplayerProxyMediabunny();
const time: number | undefined = player.mediabunny?.currentTime;
// @ts-expect-error HLS levels are read from state, not directly from the player alias.
const bad = player.mediabunny?.levels;`
for (const compiler of [ts, compat]) {
assert(checkPluginEditorDeclaration(frozenGenerator, core, '', compiler).some(item => item.code === 2307), 'The old generator leaves a missing relative type import')
assert.deepEqual(checkPluginEditorDeclaration(code, core, consumer, compiler), [])
assert.equal(checkPluginEditorDeclaration(code, core, invalid(consumer), compiler).length, 1)
}
assert.throws(() => generatePluginEditorDeclaration(source, 'artplayerProxyMediabunny'), /Unsupported editor type re-export/)
assert.throws(() => generatePluginEditorDeclaration(source, 'artplayerProxyMediabunny', { './media': media + '\nexport interface Extra {}' }), /explicitly cover/)
const common = fs.readFileSync('docs/assets/js/common.js', 'utf8')
assert(common.includes('./assets/ts/artplayer-tool-thumbnail.d.ts'))
assert(!common.includes('./assets/ts/media.d.ts'))
})