mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-09 03:39:41 -08:00
217 lines
11 KiB
JavaScript
217 lines
11 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { execFileSync, spawnSync } from 'node:child_process'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import process from 'node:process'
|
|
// eslint-disable-next-line test/no-import-node-test -- Exercise scaffold rollback and actual generated consumers.
|
|
import test from 'node:test'
|
|
import { fileURLToPath } from 'node:url'
|
|
import vm from 'node:vm'
|
|
import { parseHTML } from 'linkedom'
|
|
import { createPlugin } from '../scripts/plugin/cli.ts'
|
|
import { publishPlugin } from '../scripts/plugin/publish.ts'
|
|
import { renderPlugin } from '../scripts/plugin/render.ts'
|
|
|
|
const repository = fileURLToPath(new URL('../', import.meta.url))
|
|
const cache = path.join(repository, 'refactor/.cache')
|
|
const baseline = '07d5bfef2e815e8038362bb836b9c496c7ef9862'
|
|
const name = 'scaffold-test'
|
|
const packageName = `artplayer-plugin-${name}`
|
|
|
|
function fixture(t) {
|
|
fs.mkdirSync(cache, { recursive: true })
|
|
const root = fs.mkdtempSync(path.join(cache, 'plugin-scaffold-'))
|
|
fs.mkdirSync(path.join(root, 'packages'))
|
|
t.after(() => {
|
|
assert.equal(fs.realpathSync(root), root)
|
|
assert.equal(path.dirname(root), fs.realpathSync(cache))
|
|
assert.ok(path.basename(root).startsWith('plugin-scaffold-'))
|
|
fs.rmSync(root, { recursive: true, force: true })
|
|
})
|
|
return root
|
|
}
|
|
|
|
function write(root, relative, text) {
|
|
const file = path.join(root, relative)
|
|
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
fs.writeFileSync(file, text)
|
|
return file
|
|
}
|
|
|
|
function run(args, cwd) {
|
|
const env = { ...process.env }
|
|
delete env.NODE_TEST_CONTEXT
|
|
const result = spawnSync(process.execPath, args, { cwd, env, encoding: 'utf8', timeout: 120000 })
|
|
assert.equal(result.status, 0, `${args.join(' ')}\n${result.stdout}\n${result.stderr}\n${result.error || ''}`)
|
|
return result.stdout
|
|
}
|
|
|
|
test('historical generator overwrites an existing example and produces a mismatched demo link', (t) => {
|
|
const root = fixture(t)
|
|
const files = execFileSync('git', ['ls-tree', '-r', '--name-only', baseline, 'scripts/plugin'], { cwd: repository, encoding: 'utf8' }).trim().split(/\r?\n/)
|
|
for (const relative of files)
|
|
write(root, relative, execFileSync('git', ['show', `${baseline}:${relative}`], { cwd: repository }))
|
|
write(root, 'package.json', '{"type":"module"}')
|
|
const example = write(root, 'docs/assets/example/scaffold.test.js', '// owned example')
|
|
run(['scripts/plugin/create.js', name], root)
|
|
assert.notEqual(fs.readFileSync(example, 'utf8'), '// owned example')
|
|
const readme = fs.readFileSync(path.join(root, `packages/${packageName}/README.md`), 'utf8')
|
|
assert.match(readme, /example=scaffold-test/)
|
|
assert.equal(fs.existsSync(path.join(root, 'docs/assets/example/scaffold-test.js')), false)
|
|
})
|
|
|
|
test('rendering creates consistent names and deterministic typed templates without writing', () => {
|
|
const files = renderPlugin(name)
|
|
assert.deepEqual(files, renderPlugin(name))
|
|
assert.ok(files.has(`packages/${packageName}/src/index.ts`))
|
|
assert.ok(files.has('docs/assets/example/scaffold.test.js'))
|
|
assert.match(files.get(`packages/${packageName}/README.md`), /example=scaffold.test/)
|
|
for (const [file, content] of files) {
|
|
assert.doesNotMatch(file, /\{\{|\.tpl$/)
|
|
assert.doesNotMatch(content, /\{\{/)
|
|
}
|
|
for (const invalid of ['', '-', '-name', 'name-', 'two--words', '../escape', 'Upper', 'foo/bar', 'a_b'])
|
|
assert.throws(() => renderPlugin(invalid), /lowercase/)
|
|
})
|
|
|
|
test('unknown placeholders and duplicate rendered paths fail before publishing', (t) => {
|
|
const root = fixture(t)
|
|
const template = path.join(root, 'template')
|
|
write(root, 'template/index.ts.tpl', '{{toString}}')
|
|
assert.throws(() => renderPlugin(name, template), /Unknown template placeholder/)
|
|
fs.writeFileSync(path.join(template, 'index.ts.tpl'), 'export {}')
|
|
write(root, 'template/index.ts', 'export {}')
|
|
assert.throws(() => renderPlugin(name, template), /Duplicate template output/)
|
|
assert.deepEqual(fs.readdirSync(path.join(root, 'packages')), [])
|
|
})
|
|
|
|
test('existing packages and examples are preserved including empty package directories', (t) => {
|
|
const root = fixture(t)
|
|
const example = write(root, 'docs/assets/example/scaffold.test.js', '// owned example')
|
|
assert.throws(() => publishPlugin(root, name, renderPlugin(name)), /overwrite/)
|
|
assert.equal(fs.readFileSync(example, 'utf8'), '// owned example')
|
|
assert.deepEqual(fs.readdirSync(path.join(root, 'packages')), [])
|
|
fs.mkdirSync(path.join(root, `packages/${packageName}`))
|
|
assert.throws(() => publishPlugin(root, name, renderPlugin(name)), /already exists/)
|
|
})
|
|
|
|
test('partial write failure removes only generated files and newly created directories', (t) => {
|
|
const root = fixture(t)
|
|
const sentinel = write(root, 'docs/keep.txt', 'keep')
|
|
let count = 0
|
|
assert.throws(() => publishPlugin(root, name, renderPlugin(name), (from, to) => {
|
|
if (++count === 5)
|
|
throw new Error('injected write failure')
|
|
fs.linkSync(from, to)
|
|
}), /injected write failure/)
|
|
assert.deepEqual(fs.readdirSync(path.join(root, 'packages')), [])
|
|
assert.deepEqual(fs.readdirSync(path.join(root, 'docs')), ['keep.txt'])
|
|
assert.equal(fs.readFileSync(sentinel, 'utf8'), 'keep')
|
|
})
|
|
|
|
test('a concurrent example collision cannot overwrite the other writer', (t) => {
|
|
const root = fixture(t)
|
|
assert.throws(() => publishPlugin(root, name, renderPlugin(name), (from, to) => {
|
|
if (to.endsWith('scaffold.test.js'))
|
|
fs.writeFileSync(to, '// concurrent writer', { flag: 'wx' })
|
|
fs.linkSync(from, to)
|
|
}), /preserve changed outputs/)
|
|
assert.deepEqual(fs.readdirSync(path.join(root, 'packages')), [])
|
|
assert.equal(fs.readFileSync(path.join(root, 'docs/assets/example/scaffold.test.js'), 'utf8'), '// concurrent writer')
|
|
})
|
|
|
|
test('rollback retains an externally edited generated file and reports its recovery path', (t) => {
|
|
const root = fixture(t)
|
|
let first
|
|
assert.throws(() => publishPlugin(root, name, renderPlugin(name), (from, to) => {
|
|
if (first) {
|
|
fs.writeFileSync(first, 'external edit')
|
|
throw new Error('injected interruption')
|
|
}
|
|
fs.linkSync(from, to)
|
|
first = to
|
|
}), error => error.message.includes(first) && error.cause.message === 'injected interruption')
|
|
assert.equal(fs.readFileSync(first, 'utf8'), 'external edit')
|
|
assert.deepEqual(fs.readdirSync(path.join(root, 'packages')), [packageName])
|
|
})
|
|
|
|
test('redirected output directories are rejected before writing through them', (t) => {
|
|
const root = fixture(t)
|
|
const outside = fixture(t)
|
|
fs.symlinkSync(outside, path.join(root, 'docs'), 'junction')
|
|
assert.throws(() => publishPlugin(root, name, renderPlugin(name)), /redirected output/)
|
|
assert.deepEqual(fs.readdirSync(outside), ['packages'])
|
|
assert.deepEqual(fs.readdirSync(path.join(root, 'packages')), [])
|
|
fs.unlinkSync(path.join(root, 'docs'))
|
|
})
|
|
|
|
test('CLI validates arguments and builds a complete package without installing or changing the lock', (t) => {
|
|
const root = fixture(t)
|
|
const lock = write(root, 'yarn.lock', '# existing lock\n')
|
|
assert.throws(() => createPlugin(root, [name, 'extra']), /exactly one/)
|
|
createPlugin(root, ['--help'])
|
|
assert.deepEqual(fs.readdirSync(path.join(root, 'packages')), [])
|
|
createPlugin(root, [name])
|
|
assert.equal(fs.readFileSync(lock, 'utf8'), '# existing lock\n')
|
|
for (const [relative, content] of renderPlugin(name))
|
|
assert.equal(fs.readFileSync(path.join(root, relative), 'utf8'), content)
|
|
})
|
|
|
|
test('legacy CLI path uses its repository root even when launched from another working directory', (t) => {
|
|
const root = fixture(t)
|
|
const elsewhere = fixture(t)
|
|
fs.cpSync(path.join(repository, 'scripts/plugin'), path.join(root, 'scripts/plugin'), { recursive: true })
|
|
write(root, 'package.json', '{"type":"module"}')
|
|
const command = path.join(root, 'scripts/plugin/create.js')
|
|
run([command, name], elsewhere)
|
|
assert.ok(fs.existsSync(path.join(root, `packages/${packageName}/src/index.ts`)))
|
|
assert.deepEqual(fs.readdirSync(path.join(elsewhere, 'packages')), [])
|
|
const invalid = spawnSync(process.execPath, [command, '../escape'], { cwd: elsewhere, encoding: 'utf8' })
|
|
assert.equal(invalid.status, 1)
|
|
assert.match(invalid.stderr, /lowercase/)
|
|
})
|
|
|
|
test('generated package passes real production builds, consumers, strict types and stylesheet lifecycle', (t) => {
|
|
const root = fixture(t)
|
|
createPlugin(root, [name])
|
|
fs.copyFileSync(path.join(repository, 'tsconfig.base.json'), path.join(root, 'tsconfig.base.json'))
|
|
const pkg = path.join(root, `packages/${packageName}`)
|
|
const compiler = path.join(repository, 'node_modules/typescript/bin/tsc')
|
|
run([path.join(repository, 'node_modules/eslint/bin/eslint.js'), '--no-ignore', '--max-warnings', '0', 'src', 'types', 'test/plugin.test.mjs', 'package.json'], pkg)
|
|
run([compiler, '-p', 'tsconfig.json', '--noEmit'], pkg)
|
|
const output = run([path.join(repository, 'scripts/build.js'), packageName], root)
|
|
assert.match(output, /Finished building 1 package/)
|
|
const testOutput = run(['--test', 'test/plugin.test.mjs'], pkg)
|
|
assert.match(testOutput, /pass 1/)
|
|
const consumer = `import plugin from '${packageName}'
|
|
import legacy from '${packageName}/legacy'
|
|
import type Artplayer from 'artplayer'
|
|
declare const art: Artplayer
|
|
const result: {name:'artplayerPluginScaffoldTest'} = plugin({})(art)
|
|
legacy.default()(art)
|
|
// @ts-expect-error The scaffold owns no option fields yet.
|
|
plugin({unknown: true})
|
|
// @ts-expect-error Registration requires an ArtPlayer instance.
|
|
plugin()({})
|
|
void result
|
|
`
|
|
write(pkg, 'test/consumer.mts', consumer)
|
|
write(pkg, 'test/consumer.cts', consumer.replace(`import plugin from '${packageName}'`, `import plugin = require('${packageName}')`))
|
|
write(pkg, 'test/tsconfig.json', JSON.stringify({ compilerOptions: { strict: true, noEmit: true, types: [], module: 'NodeNext', moduleResolution: 'NodeNext', target: 'ES2020', esModuleInterop: true }, include: ['consumer.mts', 'consumer.cts'] }))
|
|
run([compiler, '-p', 'test/tsconfig.json'], pkg)
|
|
write(pkg, 'test/classic.ts', consumer.replaceAll(`'${packageName}/legacy'`, `'../types/${packageName}'`).replaceAll(`'${packageName}'`, `'../types/${packageName}'`))
|
|
write(pkg, 'test/tsconfig.classic.json', JSON.stringify({ compilerOptions: { strict: true, noEmit: true, types: [], module: 'CommonJS', moduleResolution: 'Node', target: 'ES2020', esModuleInterop: true }, files: ['classic.ts'] }))
|
|
run([path.join(repository, 'node_modules/typescript-compat/bin/tsc'), '-p', 'test/tsconfig.classic.json'], pkg)
|
|
const { document } = parseHTML('<html><head></head><body></body></html>')
|
|
const context = vm.createContext({ document })
|
|
for (const suffix of ['.js', '.legacy.js']) {
|
|
const code = fs.readFileSync(path.join(pkg, `dist/${packageName}${suffix}`), 'utf8')
|
|
vm.runInNewContext(code, {}) // Importing the UMD bundle without a DOM must work.
|
|
vm.runInContext(code, context)
|
|
vm.runInContext(code, context)
|
|
assert.equal(context.artplayerPluginScaffoldTest.default, context.artplayerPluginScaffoldTest)
|
|
assert.equal(context.artplayerPluginScaffoldTest()({}).name, 'artplayerPluginScaffoldTest')
|
|
}
|
|
assert.equal(document.querySelectorAll(`style#${packageName}`).length, 1)
|
|
})
|