mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-11 22:06:17 -08:00
refactor(docs): [SITE-AI-DOCS-01] make generation offline and translation draft based
This commit is contained in:
1 parent
647f3e7f12
commit
078cd9ef72
27 files changed
+8642
-4263
No files matched your search
@@ -146,3 +146,14 @@ semantic editor declarations. `yarn test:danmuku-types-package` packs and instal
|
||||
the packages outside the workspace, verifies exact member bytes and frozen offline
|
||||
reinstallation, and checks historical and current compiler consumers. These type
|
||||
checks do not replace the native browser suite or final distribution acceptance.
|
||||
|
||||
## Documentation pipeline regressions
|
||||
|
||||
`node --test test/documentation-pipeline.test.js` covers the frozen old translator's
|
||||
delete-before-request, broken fence repair and exhausted 429 behavior; the new
|
||||
draft workflow checks failure, worker cancellation/join, reviewed apply, stale
|
||||
inputs, path escape, rollback and concurrent edits. Existing source Markdown
|
||||
round-trips and the offline source corpus are checked against actual inputs.
|
||||
A loopback HTTP server verifies a stalled response body times out. Mock responses
|
||||
test bounded retries and invalid data; no paid translation is performed and these
|
||||
tests do not certify English prose quality. Included in `test:node`.
|
||||
@@ -0,0 +1,493 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user