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('translation chunks preserve tables and nested lists at a size boundary', () => { const blocks = [ '| Field | Meaning |\n| --- | --- |\n| first | one |\n| second | two |\n| third | three |', '- first\n - nested one\n - nested two\n- second\n- third', '1. first\n 1. nested\n2. second\n3. third', '> first\n>\n> - nested one\n> - nested two\n>\n> last', ] for (const block of blocks) { const input = `${'prefix '.repeat(13)}\n\n${block}\n\nAfterwards.\n` const protectedSource = protectMarkdown(input) const chunks = splitTranslation(protectedSource.masked, 128) assert(chunks.every(chunk => chunk.length <= 128)) assert(chunks.some(chunk => chunk.includes(block)), 'Keep the whole structural block in one chunk') assert.doesNotThrow(() => protectedSource.restore(chunks.join('\n\n'))) } }) test('oversized structural blocks fail explicitly instead of emitting damaged chunks', () => { for (const block of [ `| Field | Meaning |\n| --- | --- |\n${'| name | value |\n'.repeat(20)}`, '- entry\n'.repeat(30), '1. entry\n'.repeat(30), '> paragraph\n'.repeat(30), ]) { assert.throws(() => splitTranslation(block, 128), /structural block exceeds translation chunk limit/) const protectedSource = protectMarkdown(block) assert.doesNotThrow(() => protectedSource.restore(splitTranslation(protectedSource.masked, 1024).join('\n\n'))) } }) 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) } })