Files
ArtPlayer/refactor/scripts/impact.test.mjs
T

206 lines
13 KiB
JavaScript

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'
// eslint-disable-next-line test/no-import-node-test -- Dependency and real Git change-selection contracts use the baseline runner.
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import YAML from 'yaml'
import { analyzeImpact, readImpactModel, validateImpactWorkflow } from './impact-model.mjs'
import { changedFiles } from './impact.mjs'
const root = fileURLToPath(new URL('../../', import.meta.url))
const actual = readImpactModel(root)
const cache = path.join(root, 'refactor/.cache')
function fixture(t) {
const directory = fs.mkdtempSync(path.join(cache, 'impact-test-'))
t.after(() => {
const relative = path.relative(cache, directory)
assert(relative.startsWith('impact-test-') && !relative.includes(path.sep))
fs.rmSync(directory, { recursive: true, force: true })
})
const write = (file, value) => {
fs.mkdirSync(path.dirname(path.join(directory, file)), { recursive: true })
fs.writeFileSync(path.join(directory, file), typeof value === 'string' ? value : JSON.stringify(value))
}
const names = ['artplayer', 'artplayer-plugin-a', 'artplayer-plugin-b', 'artplayer-vitepress']
const policy = { ...structuredClone(actual.policy), packages: names.map(name => ({ name })), coreValidationConsumers: names.slice(1), edges: [], examples: [], installedConsumerPackages: ['artplayer'] }
write('refactor/impact-policy.json', policy)
write('refactor/compatibility.md', 'Explicit core validation contract')
write('scripts/build-ts.js', '// Shared docs declaration consumer')
write('scripts/package-consumer.mjs', 'export const names = ["artplayer"]')
write('scripts/coverage-policy.json', { packages: ['artplayer'] })
write('package.json', { scripts: Object.fromEntries(policy.gates.map(gate => [gate.command.slice(5), 'echo fixture'])) })
for (const name of names) {
write(`packages/${name}/package.json`, { name, version: '1.0.0' })
write(`packages/${name}/src/index.ts`, 'export default function example() {}')
}
const git = args => execFileSync('git', args, { cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_AUTHOR_NAME: 'Impact Fixture', GIT_AUTHOR_EMAIL: 'impact@example.test', GIT_COMMITTER_NAME: 'Impact Fixture', GIT_COMMITTER_EMAIL: 'impact@example.test', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null' } }).trim()
const commit = (message) => {
git(['add', '.'])
git(['-c', 'core.hooksPath=', '-c', 'commit.gpgSign=false', 'commit', '-m', message])
return git(['rev-parse', 'HEAD'])
}
return { directory, names, policy, write, git, commit, model: () => readImpactModel(directory) }
}
test('Actual impact graph covers core ecosystem and real plugin/DOM/example consumers without inventing installed coverage', () => {
assert.equal(actual.packages.length, 22)
const core = analyzeImpact(actual, ['packages/artplayer/src/index.ts'])
assert.equal(core.affectedPackages.length, 22)
assert.equal(core.installedConsumerGaps.length, 19)
const ads = analyzeImpact(actual, ['packages/artplayer-plugin-ads/src/session.ts'])
assert.deepEqual(ads.affectedPackages, ['artplayer-plugin-ads', 'artplayer-vitepress'])
const danmuku = analyzeImpact(actual, ['packages/artplayer-plugin-danmuku/src/danmuku.js'])
assert(danmuku.affectedPackages.includes('artplayer-plugin-danmuku-mask'))
assert(danmuku.reasons.some(reason => reason.rule === 'explicit-integration-contract'))
for (const file of ['docs/compiled/artplayer-plugin-ads.legacy.js', 'docs/uncompiled/artplayer-plugin-ads/index.js', 'docs/assets/example/ads.js'])
assert.deepEqual(analyzeImpact(actual, [file]).affectedPackages, ads.affectedPackages)
validateImpactWorkflow(fs.readFileSync(path.join(root, '.github/workflows/nodejs.yml'), 'utf8'), actual)
})
test('Static imports and manifest aliases propagate transitively; contradictory ranges and unknown packages fail', (t) => {
const repo = fixture(t)
repo.write('packages/artplayer-plugin-a/src/index.ts', 'export { default } from "../../artplayer-plugin-b/src/index"')
repo.write('packages/artplayer-plugin-b/package.json', { name: 'artplayer-plugin-b', version: '1.0.0', dependencies: { alias: 'npm:artplayer@^1.0.0' } })
const model = repo.model()
assert(model.edges.some(edge => edge.consumer === 'artplayer-plugin-b' && edge.dependency === 'artplayer' && edge.kind === 'manifest-dependencies'))
assert.deepEqual(analyzeImpact(model, ['packages/artplayer-plugin-b/src/index.ts']).affectedPackages, ['artplayer-plugin-a', 'artplayer-plugin-b', 'artplayer-vitepress'])
repo.write('packages/artplayer-plugin-b/package.json', { name: 'artplayer-plugin-b', version: '1.0.0', peerDependencies: { artplayer: '^2.0.0' } })
assert.throws(repo.model, /range mismatch/)
repo.write('packages/artplayer-plugin-b/package.json', { name: 'artplayer-plugin-b', version: '1.0.0' })
repo.write('packages/artplayer-plugin-a/src/index.ts', 'import unknown from "artplayer-plugin-missing"')
assert.throws(repo.model, /Unknown workspace import/)
repo.write('packages/artplayer-plugin-new/package.json', { name: 'artplayer-plugin-new', version: '1.0.0' })
assert.throws(repo.model, /inventory changed/)
})
test('Policy cannot omit core consumers or claim stale installed-package coverage', (t) => {
const missingGate = fixture(t)
missingGate.policy.gates = []
missingGate.write('refactor/impact-policy.json', missingGate.policy)
assert.throws(missingGate.model, /Missing mandatory ecosystem gate/)
const repo = fixture(t)
repo.policy.coreValidationConsumers.pop()
repo.write('refactor/impact-policy.json', repo.policy)
assert.throws(repo.model, /every ecosystem validation consumer/)
repo.policy.coreValidationConsumers = repo.names.slice(1)
repo.write('refactor/impact-policy.json', repo.policy)
repo.write('scripts/package-consumer.mjs', 'export const names = ["artplayer", "artplayer-plugin-a"]')
assert.throws(repo.model, /consumer coverage changed/)
repo.write('scripts/package-consumer.mjs', 'export const names = computeNames()')
assert.throws(repo.model, /scope extraction/)
})
test('Each split CI invocation remains mandatory even when its sibling uses the same script', (t) => {
const repo = fixture(t)
const gates = structuredClone(repo.policy.gates)
for (let index = 0; index < gates.length; index++) {
repo.policy.gates = gates.filter((gate, candidate) => candidate !== index)
repo.write('refactor/impact-policy.json', repo.policy)
assert.throws(repo.model, /Missing mandatory ecosystem gate/)
}
repo.policy.gates = [...gates, gates[0]]
repo.write('refactor/impact-policy.json', repo.policy)
assert.throws(repo.model, /Duplicate impact gate/)
repo.policy.gates = structuredClone(gates)
repo.policy.gates.find(gate => gate.command === 'yarn test:browser:installed').condition = 'success()'
repo.write('refactor/impact-policy.json', repo.policy)
assert.throws(repo.model, /Only browser scopes/)
})
test('Shared/unknown files and computed imports conservatively expand; unsafe or unmapped package paths are rejected', (t) => {
for (const file of ['yarn.lock', 'scripts/new-build-stage.mjs', 'types/new-boundary.d.ts'])
assert.equal(analyzeImpact(actual, [file]).affectedPackages.length, 22)
const unknown = analyzeImpact(actual, ['new-shared-config.toml'])
assert.equal(unknown.affectedPackages.length, 22)
assert.equal(unknown.reviewRequired, true)
for (const file of ['../outside', '/absolute', 'C:/absolute', 'packages\\artplayer\\src\\index.ts'])
assert.throws(() => analyzeImpact(actual, [file]), /path|Path/)
assert.throws(() => analyzeImpact(actual, ['packages/unmapped/src/index.js']), /not mapped/)
const repo = fixture(t)
repo.write('packages/artplayer-plugin-a/src/index.ts', 'export const load = (name: string) => import(name)')
const model = repo.model()
assert.deepEqual(model.dynamicImports, ['packages/artplayer-plugin-a/src/index.ts'])
assert.equal(analyzeImpact(model, ['packages/artplayer-plugin-b/src/index.ts']).affectedPackages.length, 4)
repo.write('packages/artplayer-plugin-a/src/index.ts', 'export { from')
assert.throws(repo.model, /malformed dependency source/)
})
test('CI workflow cannot silently skip or soften required impact gates', () => {
const source = fs.readFileSync(path.join(root, '.github/workflows/nodejs.yml'), 'utf8')
for (const edit of [
(workflow) => { workflow.on.pull_request = { paths: ['packages/artplayer/**'] } },
(workflow) => { workflow.jobs.coverage.if = 'false' },
(workflow) => { workflow.jobs.coverage.if = false },
(workflow) => { workflow.jobs.coverage['continue-on-error'] = true },
(workflow) => { workflow.jobs.checks.steps.find(step => step.run?.startsWith('yarn ci:check')).if = 'false' },
(workflow) => { workflow.jobs.checks.steps.find(step => step.run?.startsWith('yarn ci:check')).run = 'echo yarn ci:check' },
(workflow) => { workflow.jobs.checks.steps.find(step => step.run?.startsWith('yarn ci:check')).run = 'if false; then\nyarn ci:check\nfi' },
(workflow) => { workflow.jobs.checks.steps.find(step => step.run?.startsWith('yarn ci:check')).run = 'yarn ci:check || true' },
(workflow) => { workflow.jobs.checks.defaults.run.shell = 'sh' },
(workflow) => { workflow.jobs.checks.steps.find(step => step.run?.startsWith('yarn ci:check')).if = false },
(workflow) => { workflow.jobs['browser-smoke'].steps.find(step => step.uses?.startsWith('actions/checkout@')).if = false },
(workflow) => { workflow.jobs['browser-smoke'].steps.find(step => step.uses?.startsWith('actions/checkout@')).with['fetch-depth'] = 1 },
(workflow) => { workflow.jobs['browser-smoke'].steps.find(step => step.run?.startsWith('yarn test:browser:source ')).if = 'success()' },
(workflow) => { workflow.jobs['browser-smoke'].steps.find(step => step.run?.startsWith('yarn test:browser:installed ')).run += ' --grep subset' },
(workflow) => { workflow.jobs['browser-smoke'].steps.find(step => step.run?.startsWith('yarn test:package --browser')).run = 'yarn test:package' },
(workflow) => { workflow.jobs['browser-consumers'].steps.find(step => step.run?.startsWith('yarn test:performance ')).run += ' || true' },
]) {
const workflow = YAML.parse(source)
edit(workflow)
assert.throws(() => validateImpactWorkflow(YAML.stringify(workflow), actual), /Path filters|Required|full history/)
}
})
test('Real Git selection includes both sides of renames, staged/unstaged/untracked paths and deletions', (t) => {
const repo = fixture(t)
repo.git(['init', '-b', 'main'])
repo.write('packages/artplayer-plugin-a/src/old.ts', 'old content')
repo.write('packages/artplayer-plugin-a/src/deleted.ts', 'delete later')
const base = repo.commit('base')
repo.git(['mv', 'packages/artplayer-plugin-a/src/old.ts', 'packages/artplayer-plugin-b/src/renamed.ts'])
repo.commit('rename across packages')
repo.write('packages/artplayer-plugin-a/src/staged.ts', 'staged')
repo.git(['add', 'packages/artplayer-plugin-a/src/staged.ts'])
repo.write('packages/artplayer-plugin-a/src/space name.ts', 'untracked')
repo.write('packages/artplayer-plugin-b/src/index.ts', 'unstaged edit')
fs.unlinkSync(path.join(repo.directory, 'packages/artplayer-plugin-a/src/deleted.ts'))
const range = changedFiles(repo.directory, { base })
assert.equal(range.origin, 'explicit-base')
assert.equal(range.mergeBase, base)
assert.deepEqual(range.files, [
'packages/artplayer-plugin-a/src/deleted.ts',
'packages/artplayer-plugin-a/src/old.ts',
'packages/artplayer-plugin-a/src/space name.ts',
'packages/artplayer-plugin-a/src/staged.ts',
'packages/artplayer-plugin-b/src/index.ts',
'packages/artplayer-plugin-b/src/renamed.ts',
])
assert.equal(range.worktreeChanged, true)
assert(!changedFiles(repo.directory).files.includes('packages/artplayer-plugin-a/src/old.ts'))
assert.throws(() => changedFiles(repo.directory, { base: 'missing-ref' }))
})
test('GitHub event base is read as data; absent, zero and unavailable event bases require full repository checks', (t) => {
const repo = fixture(t)
repo.git(['init', '-b', 'main'])
const base = repo.commit('base')
repo.write('packages/artplayer-plugin-a/src/index.ts', 'changed source')
repo.commit('change')
const eventPath = path.join(repo.directory, '.git/event.json')
const event = data => fs.writeFileSync(eventPath, JSON.stringify(data))
event({ pull_request: { base: { sha: base } } })
assert.deepEqual(changedFiles(repo.directory, { eventName: 'pull_request', eventPath }).files, ['packages/artplayer-plugin-a/src/index.ts'])
for (const before of ['0'.repeat(40), 'a'.repeat(40), undefined]) {
event({ before })
const range = changedFiles(repo.directory, { eventName: 'push', eventPath })
assert(range.origin.startsWith('full-repository-'))
assert(range.files.includes('packages/artplayer-plugin-b/src/index.ts'))
}
event({ before: '--not-a-sha' })
assert.throws(() => changedFiles(repo.directory, { eventName: 'push', eventPath }), /Invalid GitHub event base/)
})