mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-09 03:17:46 -08:00
494 lines
15 KiB
JavaScript
494 lines
15 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
import { execFileSync, spawnSync } from 'node:child_process'
|
|
import fs from 'node:fs'
|
|
import http from 'node:http'
|
|
import path from 'node:path'
|
|
import process from 'node:process'
|
|
// eslint-disable-next-line test/no-import-node-test -- Exercise the real local generation and draft filesystem contracts.
|
|
import test from 'node:test'
|
|
import vm from 'node:vm'
|
|
import ts from 'typescript'
|
|
import { runTranslation } from '../scripts/documentation/cli.ts'
|
|
import { buildCorpus } from '../scripts/documentation/corpus.ts'
|
|
import {
|
|
atomicWrite,
|
|
ownedPath,
|
|
read,
|
|
sha256,
|
|
} from '../scripts/documentation/files.ts'
|
|
import {
|
|
protectMarkdown,
|
|
splitTranslation,
|
|
} from '../scripts/documentation/markdown.ts'
|
|
import { translateChunk } from '../scripts/documentation/remote.ts'
|
|
import {
|
|
applyTranslationDraft,
|
|
createTranslationDraft,
|
|
translationPlan,
|
|
validateTranslationDraft,
|
|
} from '../scripts/documentation/translation.ts'
|
|
|
|
const root = process.cwd()
|
|
const cache = path.resolve('refactor/.cache')
|
|
const docs = 'packages/artplayer-vitepress/docs'
|
|
const source
|
|
= '# 说明\n\nKeep `api` and [link](./path).\n\n```md\n## inside code\n::: not an admonition\n```\n\n说明正文\n'
|
|
function fixture() {
|
|
const directory = fs.mkdtempSync(path.join(cache, 'ai-docs-test-'))
|
|
for (const [file, text] of [
|
|
[`${docs}/index.md`, source],
|
|
[`${docs}/start/option.md`, source],
|
|
[`${docs}/en/index.md`, 'Original index\r\n'],
|
|
[`${docs}/en/start/option.md`, 'Original option\r\n'],
|
|
[`${docs}/en/manual.md`, 'Preserve manually maintained extra page'],
|
|
])
|
|
atomicWrite(ownedPath(directory, file), text)
|
|
return directory
|
|
}
|
|
function remove(directory) {
|
|
const relative = path.relative(cache, fs.realpathSync(directory))
|
|
assert(relative.startsWith('ai-docs-test-') && !relative.includes(path.sep))
|
|
fs.rmSync(directory, { recursive: true, force: true })
|
|
}
|
|
function snapshot(directory) {
|
|
return ['index.md', 'start/option.md', 'manual.md'].map(file =>
|
|
fs.readFileSync(ownedPath(directory, `${docs}/en/${file}`), 'utf8'),
|
|
)
|
|
}
|
|
const translator = async chunk => chunk.replaceAll('说明', 'Description')
|
|
|
|
test('old translator deletes English before failure and corrupts legitimate Markdown code; old final 429 returns undefined', async () => {
|
|
const old = execFileSync(
|
|
'git',
|
|
['show', '647f3e7f12b1026f7d0de0830080d2d17b23d14d:scripts/trans-docs.js'],
|
|
{ encoding: 'utf8' },
|
|
)
|
|
const parsed = ts.createSourceFile(
|
|
'old.js',
|
|
old,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
ts.ScriptKind.JS,
|
|
)
|
|
const functionCode = name =>
|
|
parsed.statements
|
|
.find(node => ts.isFunctionDeclaration(node) && node.name.text === name)
|
|
.getText(parsed)
|
|
const directory = fixture()
|
|
try {
|
|
const outputRoot = ownedPath(directory, `${docs}/en`)
|
|
const context = vm.createContext({
|
|
fs: {
|
|
...fs,
|
|
rmSync(target, options) {
|
|
assert.equal(path.resolve(target), outputRoot)
|
|
fs.rmSync(target, options)
|
|
},
|
|
},
|
|
path,
|
|
rootDir: ownedPath(directory, 'packages/artplayer-vitepress'),
|
|
outputRoot,
|
|
indexFile: 'docs/index.md',
|
|
processFile: async () => {
|
|
throw new Error('API failure')
|
|
},
|
|
console: { log() {} },
|
|
})
|
|
await assert.rejects(
|
|
vm.runInContext(`${functionCode('main')} main()`, context),
|
|
/API failure/,
|
|
)
|
|
assert(!fs.existsSync(path.join(outputRoot, 'manual.md')))
|
|
const damaged = vm.runInNewContext(
|
|
`${functionCode('fixMarkdownCodeBlocks')} fixMarkdownCodeBlocks(input)`,
|
|
{ input: source },
|
|
)
|
|
assert.notEqual(damaged.content, source)
|
|
assert(damaged.fixes > 0)
|
|
const rateLimited = vm.runInNewContext(
|
|
`${functionCode('callDeepSeekAPI')} callDeepSeekAPI('text','fixture',1)`,
|
|
{
|
|
MAX_RETRIES: 3,
|
|
REQUEST_TIMEOUT: 60000,
|
|
API_URL: 'fixture',
|
|
API_KEY: 'fixture',
|
|
AbortController,
|
|
fetch: async () => ({ status: 429 }),
|
|
setTimeout: (callback, ms) => {
|
|
if (ms !== 60000)
|
|
queueMicrotask(callback)
|
|
return 1
|
|
},
|
|
clearTimeout() {},
|
|
console: { log() {}, error() {} },
|
|
},
|
|
)
|
|
assert.equal(await rateLimited, undefined)
|
|
}
|
|
finally {
|
|
remove(directory)
|
|
}
|
|
})
|
|
|
|
test('Markdown protection preserves code and rejects lost markers or inline code/link changes; chunks stay bounded', () => {
|
|
const protectedSource = protectMarkdown(source)
|
|
assert(!protectedSource.masked.includes('inside code'))
|
|
assert.equal(protectedSource.restore(protectedSource.masked), source)
|
|
assert.throws(
|
|
() => protectedSource.restore('lost all blocks'),
|
|
/lost or duplicated/,
|
|
)
|
|
assert.throws(
|
|
() =>
|
|
protectedSource.restore(
|
|
protectedSource.masked.replace('`api`', '`wrong`'),
|
|
),
|
|
/changed code/,
|
|
)
|
|
assert.throws(
|
|
() =>
|
|
protectedSource.restore(
|
|
protectedSource.masked.replace('./path', './wrong'),
|
|
),
|
|
/changed code/,
|
|
)
|
|
assert.throws(() => protectMarkdown('```js\nunterminated'), /Unclosed/)
|
|
const chunks = splitTranslation(
|
|
`${'😀'.repeat(500)}\n${protectedSource.masked}`,
|
|
128,
|
|
)
|
|
assert(
|
|
chunks.every(
|
|
chunk =>
|
|
chunk.length > 0
|
|
&& chunk.length <= 128
|
|
&& !/[\uD800-\uDBFF]$/.test(chunk),
|
|
),
|
|
)
|
|
assert(chunks.some(chunk => chunk.includes('ARTPLAYER_KEEP_')))
|
|
})
|
|
|
|
test('failed or damaged translation only leaves a failed draft and never changes existing English', async () => {
|
|
for (const translate of [
|
|
async () => {
|
|
throw new Error('request failed')
|
|
},
|
|
async () => 'lost placeholders',
|
|
]) {
|
|
const directory = fixture()
|
|
try {
|
|
const before = snapshot(directory)
|
|
await assert.rejects(
|
|
createTranslationDraft(directory, translate, 2),
|
|
/draft failed/,
|
|
)
|
|
assert.deepEqual(snapshot(directory), before)
|
|
const drafts = fs.readdirSync(
|
|
ownedPath(directory, 'refactor/.cache/translations'),
|
|
)
|
|
const manifest = JSON.parse(
|
|
read(
|
|
ownedPath(
|
|
directory,
|
|
`refactor/.cache/translations/${drafts[0]}/manifest.json`,
|
|
),
|
|
),
|
|
)
|
|
assert.equal(manifest.status, 'failed')
|
|
}
|
|
finally {
|
|
remove(directory)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('all 13 existing translation sources retain protected structure through chunking', () => {
|
|
const entries = translationPlan(root)
|
|
assert.equal(entries.length, 13)
|
|
for (const entry of entries) {
|
|
const text = read(ownedPath(root, entry.source))
|
|
const protectedSource = protectMarkdown(text)
|
|
assert.equal(
|
|
protectedSource.restore(protectedSource.masked),
|
|
text,
|
|
entry.relative,
|
|
)
|
|
protectedSource.restore(
|
|
splitTranslation(protectedSource.masked).join('\n\n'),
|
|
)
|
|
}
|
|
})
|
|
|
|
test('a failing translation cancels and awaits its active sibling before returning', async () => {
|
|
const directory = fixture()
|
|
let calls = 0
|
|
let siblingStopped = false
|
|
try {
|
|
const before = snapshot(directory)
|
|
await assert.rejects(
|
|
createTranslationDraft(
|
|
directory,
|
|
async (_chunk, signal) => {
|
|
if (++calls === 1) {
|
|
await new Promise(resolve => setImmediate(resolve))
|
|
throw new Error('First request failed')
|
|
}
|
|
await new Promise(resolve =>
|
|
signal.addEventListener('abort', resolve, { once: true }),
|
|
)
|
|
await new Promise(resolve => setImmediate(resolve))
|
|
siblingStopped = true
|
|
signal.throwIfAborted()
|
|
return ''
|
|
},
|
|
2,
|
|
),
|
|
/First request failed/,
|
|
)
|
|
assert.equal(calls, 2)
|
|
assert.equal(siblingStopped, true)
|
|
assert.deepEqual(snapshot(directory), before)
|
|
}
|
|
finally {
|
|
remove(directory)
|
|
}
|
|
})
|
|
|
|
test('rollback refuses to overwrite a concurrent English edit', async () => {
|
|
const directory = fixture()
|
|
try {
|
|
const draft = await createTranslationDraft(directory, translator)
|
|
let written
|
|
assert.throws(
|
|
() =>
|
|
applyTranslationDraft(directory, draft, (file, content) => {
|
|
if (written) {
|
|
atomicWrite(written, 'Concurrent author edit')
|
|
throw new Error('Second write failed')
|
|
}
|
|
atomicWrite(file, content)
|
|
written = file
|
|
}),
|
|
error =>
|
|
error instanceof AggregateError
|
|
&& error.errors.some(item => /Concurrent change/.test(item.message)),
|
|
)
|
|
assert.equal(read(written), 'Concurrent author edit')
|
|
assert.equal(snapshot(directory)[1], 'Original option\r\n')
|
|
}
|
|
finally {
|
|
remove(directory)
|
|
}
|
|
})
|
|
|
|
test('draft validation permits reviewed prose edits and apply preserves unselected pages', async () => {
|
|
const directory = fixture()
|
|
try {
|
|
const before = snapshot(directory)
|
|
const draft = await createTranslationDraft(directory, translator)
|
|
assert.deepEqual(snapshot(directory), before)
|
|
const file = ownedPath(draft, 'index.md')
|
|
atomicWrite(
|
|
file,
|
|
read(file).replace('Description', 'Reviewed description'),
|
|
)
|
|
assert.throws(
|
|
() => applyTranslationDraft(directory, draft),
|
|
/without validation/,
|
|
)
|
|
validateTranslationDraft(directory, draft)
|
|
applyTranslationDraft(directory, draft)
|
|
const after = snapshot(directory)
|
|
assert(after[0].includes('Reviewed description'))
|
|
assert(after[1].includes('Description'))
|
|
assert.equal(after[2], before[2])
|
|
assert.throws(
|
|
() => applyTranslationDraft(directory, draft),
|
|
/not complete/,
|
|
)
|
|
}
|
|
finally {
|
|
remove(directory)
|
|
}
|
|
})
|
|
|
|
test('stale sources, changed targets, path tampering and mid-apply failures are rejected without wiping English', async () => {
|
|
for (const mode of ['source', 'target', 'mapping', 'write-failure']) {
|
|
const directory = fixture()
|
|
try {
|
|
const draft = await createTranslationDraft(directory, translator)
|
|
if (mode === 'source') {
|
|
atomicWrite(
|
|
ownedPath(directory, `${docs}/index.md`),
|
|
'# Edited source',
|
|
)
|
|
}
|
|
if (mode === 'target') {
|
|
atomicWrite(
|
|
ownedPath(directory, `${docs}/en/index.md`),
|
|
'Manual English edit',
|
|
)
|
|
}
|
|
if (mode === 'mapping') {
|
|
const file = ownedPath(draft, 'manifest.json')
|
|
const manifest = JSON.parse(read(file))
|
|
manifest.entries[0].target = '../escape.md'
|
|
atomicWrite(file, JSON.stringify(manifest))
|
|
}
|
|
const before = snapshot(directory)
|
|
let writes = 0
|
|
assert.throws(() =>
|
|
applyTranslationDraft(directory, draft, (file, content) => {
|
|
if (mode === 'write-failure' && ++writes === 2)
|
|
throw new Error('Injected rename failure')
|
|
atomicWrite(file, content)
|
|
}),
|
|
)
|
|
assert.deepEqual(snapshot(directory), before)
|
|
}
|
|
finally {
|
|
remove(directory)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('owned paths reject traversal and a junction outside the fixture root', () => {
|
|
const directory = fixture()
|
|
const external = fixture()
|
|
const link = path.join(directory, 'linked')
|
|
try {
|
|
assert.throws(() => ownedPath(directory, '../escape.md'), /Invalid/)
|
|
fs.symlinkSync(
|
|
external,
|
|
link,
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
)
|
|
assert.throws(() => ownedPath(directory, 'linked/new.md'), /escapes/)
|
|
fs.unlinkSync(link)
|
|
fs.symlinkSync(path.join(external, 'missing'), link, process.platform === 'win32' ? 'junction' : 'dir')
|
|
assert.throws(() => ownedPath(directory, 'linked/new.md'))
|
|
}
|
|
finally {
|
|
if (fs.lstatSync(link, { throwIfNoEntry: false }))
|
|
fs.unlinkSync(link)
|
|
remove(directory)
|
|
remove(external)
|
|
}
|
|
})
|
|
|
|
test('remote request rejects final rate limits, invalid/empty responses and auth without unbounded retries', async () => {
|
|
for (const [status, body, callsExpected] of [
|
|
[429, {}, 3],
|
|
[401, {}, 1],
|
|
[200, {}, 1],
|
|
[200, { choices: [{ message: { content: '' } }] }, 1],
|
|
]) {
|
|
let calls = 0
|
|
await assert.rejects(
|
|
translateChunk('fixture', {
|
|
key: 'fixture-key',
|
|
wait: async () => {},
|
|
request: async (_url, options) => {
|
|
calls++
|
|
assert.equal(options.headers.Authorization, 'Bearer fixture-key')
|
|
return Response.json(body, { status })
|
|
},
|
|
}),
|
|
)
|
|
assert.equal(calls, callsExpected)
|
|
}
|
|
let calls = 0
|
|
const text = await translateChunk('fixture', {
|
|
key: 'fixture-key',
|
|
wait: async () => {},
|
|
request: async () =>
|
|
++calls === 1
|
|
? new Response('', { status: 503 })
|
|
: Response.json({ choices: [{ message: { content: 'Translated' } }] }),
|
|
})
|
|
assert.equal(text, 'Translated')
|
|
assert.equal(calls, 2)
|
|
let invalidCalls = 0
|
|
await assert.rejects(translateChunk('fixture', {
|
|
key: 'fixture-key',
|
|
request: async () => {
|
|
invalidCalls++
|
|
return new Response('Invalid provider response body')
|
|
},
|
|
}), /Invalid translation JSON response/)
|
|
assert.equal(invalidCalls, 1)
|
|
})
|
|
|
|
test('timeout covers reading a real HTTP response body, not only receiving its headers', async () => {
|
|
const server = http.createServer((_request, response) => {
|
|
response.writeHead(200, { 'Content-Type': 'application/json' })
|
|
response.write('{"choices":[')
|
|
})
|
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
try {
|
|
const address = server.address()
|
|
await assert.rejects(
|
|
translateChunk('fixture', {
|
|
key: 'fixture-key',
|
|
timeoutMs: 80,
|
|
retries: 1,
|
|
request: (_url, options) =>
|
|
fetch(`http://127.0.0.1:${address.port}`, options),
|
|
}),
|
|
/abort|timed out/i,
|
|
)
|
|
}
|
|
finally {
|
|
server.closeAllConnections()
|
|
await new Promise(resolve => server.close(resolve))
|
|
}
|
|
})
|
|
|
|
test('offline corpus is exact and deterministic; default translation and unknown flags never call the network', async () => {
|
|
const outputs = buildCorpus(root)
|
|
assert.deepEqual(outputs, buildCorpus(root))
|
|
for (const [file, content] of outputs)
|
|
assert.equal(read(ownedPath(root, file)), content)
|
|
const manifest = JSON.parse(outputs.get('docs/llms.manifest.json'))
|
|
for (const entry of manifest.sources) {
|
|
const text = read(ownedPath(root, entry.file))
|
|
assert.equal(sha256(text), entry.sha256Lf)
|
|
assert(outputs.get('docs/llms.txt').includes(text))
|
|
}
|
|
const original = globalThis.fetch
|
|
globalThis.fetch = async () => {
|
|
throw new Error('Unexpected network')
|
|
}
|
|
const directory = fixture()
|
|
try {
|
|
const before = snapshot(directory)
|
|
/* eslint-disable no-console -- Silence the CLI plan while checking its no-network behavior. */
|
|
const originalLog = console.log
|
|
try {
|
|
console.log = () => {}
|
|
await runTranslation([], directory)
|
|
}
|
|
finally {
|
|
console.log = originalLog
|
|
}
|
|
/* eslint-enable no-console */
|
|
await assert.rejects(runTranslation(['--unknown'], directory), /Use yarn/)
|
|
assert.deepEqual(snapshot(directory), before)
|
|
assert.equal(translationPlan(directory).length, 2)
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
['scripts/build-llm.js', '--check'],
|
|
{
|
|
cwd: root,
|
|
env: { ...process.env, DEEPSEEK_API_KEY: '' },
|
|
encoding: 'utf8',
|
|
},
|
|
)
|
|
assert.equal(result.status, 0, result.stderr)
|
|
}
|
|
finally {
|
|
globalThis.fetch = original
|
|
remove(directory)
|
|
}
|
|
})
|