blob: 5a45731bc5fa91540ae601897711bbadf0ea9ecc [file]
// Exercise T08 against a local bare Git remote, a schema-shaped Codex mock,
// and a GitHub CLI mock. Nothing is sent to GitHub or to a model service.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const {execFileSync, spawnSync} = require('node:child_process');
const {hash, loadRecords, stateFileFor} = require('./state.cjs');
const {structure} = require('./structure.cjs');
const sourceRoot = path.resolve(__dirname, '../..');
const sourcePath = process.env.I18N_SIM_SOURCE_PATH
|| 'docs/admin-manual/system-tables/information_schema/engines.md';
assert(sourcePath.startsWith('docs/') && /\.mdx?$/.test(sourcePath)
&& !sourcePath.split('/').includes('..'), 'Simulation source must be a Dev document');
const targetPath = `i18n/ja/docusaurus-plugin-content-docs/current/${sourcePath.slice(5)}`;
const runId = '123456';
const pages = Number(process.env.I18N_SIM_PAGES || '1');
const scenario = process.env.I18N_SIM_SCENARIO || 'retry';
assert([1, 10].includes(pages), 'Simulation supports one or ten pages');
assert(['success', 'retry', 'fallback', 'incremental'].includes(scenario), 'Unknown simulation scenario');
assert(scenario !== 'incremental' || pages === 1, 'Incremental simulation uses one page');
// A merge that lands on master after discovery: unrelated, to the selected page,
// or to the translation configuration.
const drift = process.env.I18N_SIM_MASTER_DRIFT || '';
assert(['', 'unrelated', 'page', 'config'].includes(drift), 'Unknown master drift');
assert(!drift || pages === 1, 'Master drift simulation uses one page');
function command(binary, args, options = {}) {
return execFileSync(binary, args, {encoding: 'utf8', ...options}).trim();
}
function writeExecutable(file, contents) {
fs.writeFileSync(file, contents, {mode: 0o755});
}
function codexMock(mode, repo) {
return `#!/usr/bin/env node
const fs = require('node:fs');
const args = process.argv.slice(2);
const answer = args[args.indexOf('--output-last-message') + 1];
const prompt = fs.readFileSync(0, 'utf8');
const marker = 'Translation input:\\n';
const payload = JSON.parse(prompt.slice(prompt.indexOf(marker) + marker.length));
const {extractUnits} = require(${JSON.stringify(path.join(repo, 'scripts/i18n/translation-engine.cjs'))});
(async () => {
if (payload.changes) {
const translations = payload.changes.map(change => ({id: change.id,
text: ${JSON.stringify(mode)} === 'invalid' ? '' : '訳' + change.text}));
fs.writeFileSync(answer, JSON.stringify({translations}));
process.stdout.write(JSON.stringify({type: 'turn.completed', usage: {total_tokens: 100000}}) + '\\n');
return;
}
let translation = payload.document;
const units = await extractUnits(translation, ${JSON.stringify(path.extname(sourcePath))});
for (const unit of [...units].reverse()) translation = translation.slice(0, unit.start)
+ '訳' + unit.text + translation.slice(unit.end);
// Like a real model, localize a declared front matter language.
translation = translation.replace(/("language":\\s*")en(")/, '$1ja$2');
if (${JSON.stringify(mode)} === 'invalid') translation = '';
if (${JSON.stringify(mode)} === 'retry' && payload.feedback.length === 0)
translation = translation.replace(/^#{1,6} .*$/gm, '');
if (${JSON.stringify(mode)} === 'fallback') translation += '\\n' + '\x60\x60\x60sql\\nSELECT 42;\\n\x60\x60\x60\\n';
fs.writeFileSync(answer, JSON.stringify({translation}));
process.stdout.write(JSON.stringify({type: 'turn.completed', usage: {total_tokens: 100000}}) + '\\n');
})().catch(error => {console.error(error.message);process.exitCode = 1;});
`;
}
const ghMock = `#!/usr/bin/env node
const assert = require('node:assert/strict');
const fs = require('node:fs');
const args = process.argv.slice(2);
if (args[0] === 'api' && args.some(arg => arg.startsWith('repos/apache/doris-website/pulls?'))) {
process.stdout.write('[[]]\\n');
} else if (args[0] === 'api' && args[1] === 'user') {
process.stdout.write(JSON.stringify({login: 'local-simulator', id: 12345}) + '\\n');
} else if (args[0] === 'auth' && args[1] === 'setup-git') {
process.exit(0);
} else if (args[0] === 'pr' && args[1] === 'create') {
const value = flag => args[args.indexOf(flag) + 1];
assert(!args.includes('--draft'));
assert.equal(value('--base'), 'master');
assert.equal(value('--repo'), 'apache/doris-website');
const labels = args.flatMap((arg, index) => arg === '--label' ? [args[index + 1]] : []);
assert.deepEqual(labels, ['translate-ja', 'ai-translate']);
const body = fs.readFileSync(value('--body-file'), 'utf8');
assert.match(body, /japanese-translation-automation:v1 bot-head=[a-f0-9]{40}/);
const record = {draft: false, base: value('--base'), head: value('--head'), labels, body,
url: 'https://example.invalid/apache/doris-website/pull/1'};
fs.writeFileSync(process.env.I18N_SIM_PR_RECORD, JSON.stringify(record, null, 2) + '\\n');
process.stdout.write(record.url + '\\n');
} else {
process.stderr.write('Unexpected simulated gh command: ' + args.join(' ') + '\\n');
process.exit(1);
}
`;
function runNode(root, script, args, env, logDir, expectedStatus = 0) {
const result = spawnSync(process.execPath, [path.join(root, script), ...args], {
cwd: root, env, encoding: 'utf8', timeout: 1200000, maxBuffer: 16 * 1024 * 1024,
});
const name = path.basename(script, '.cjs') + (env.I18N_SIM_PHASE || '');
fs.writeFileSync(path.join(logDir, `${name}.log`), `${result.stdout || ''}${result.stderr || ''}`);
assert(!result.error, `${name}: ${result.error?.message}`);
assert.equal(result.status, expectedStatus, `${name} returned ${result.status}: ${result.stderr}`);
return result;
}
async function main() {
const output = process.argv[2] ? path.resolve(process.argv[2])
: fs.mkdtempSync(path.join(os.tmpdir(), 'doris-ja-workflow-sim-'));
assert(process.argv.length <= 3, 'Usage: node scripts/i18n/simulate-workflow.cjs [empty-output-dir]');
if (fs.existsSync(output)) assert.equal(fs.readdirSync(output).length, 0, 'Output directory must be empty');
fs.mkdirSync(output, {recursive: true});
const remote = path.join(output, 'remote.git');
const repo = path.join(output, 'repo');
const bin = path.join(output, 'bin');
const scan = path.join(output, 'scan');
const logs = path.join(output, 'logs');
const auth = path.join(output, 'codex-home');
for (const dir of [bin, scan, logs, auth]) fs.mkdirSync(dir, {recursive: true});
command('git', ['clone', '--bare', '--shared', '-q', sourceRoot, remote]);
command('git', ['update-ref', 'refs/heads/master', command('git', ['rev-parse', 'HEAD'], {cwd: sourceRoot})], {cwd: remote});
command('git', ['symbolic-ref', 'HEAD', 'refs/heads/master'], {cwd: remote});
command('git', ['clone', '--shared', '-q', remote, repo]);
command('git', ['config', '--local', 'user.name', 'Local Simulator'], {cwd: repo});
command('git', ['config', '--local', 'user.email', 'local@example.invalid'], {cwd: repo});
command('git', ['config', '--local', `url.file://${remote}.insteadOf`,
'https://github.com/apache/doris-website.git'], {cwd: repo});
fs.symlinkSync(fs.realpathSync(path.join(sourceRoot, 'node_modules')), path.join(repo, 'node_modules'), 'dir');
// Include uncommitted implementation changes only in the disposable clone.
for (const file of ['scripts/i18n', 'scripts/check_md_links_single.py', '.github/workflows'])
fs.cpSync(path.join(sourceRoot, file), path.join(repo, file), {recursive: true});
let selectedPath = sourcePath, incrementalTarget;
if (scenario === 'incremental') {
selectedPath = 'docs/translation-simulation/incremental.md';
const targetPath = `i18n/ja/docusaurus-plugin-content-docs/current/${selectedPath.slice(5)}`;
const source = '# Guide\n\nDoris guide.\n\n'
+ Array.from('ABCDEFGH', letter => `Paragraph ${letter}.`).join('\n\n') + '\n';
incrementalTarget = '# ガイド\n\nDoris のガイド。\n\n'
+ Array.from('ABCDEFGH', letter => `訳Paragraph ${letter}.`).join('\n\n') + '\n';
for (const file of [selectedPath, targetPath]) fs.mkdirSync(path.dirname(path.join(repo, file)), {recursive: true});
fs.writeFileSync(path.join(repo, selectedPath), source);
command('git', ['add', '.'], {cwd: repo});
command('git', ['commit', '-qm', 'Incremental simulation baseline source'], {cwd: repo});
const record = {pluginId: 'default', version: 'current', docId: 'translation-simulation/incremental',
locale: 'ja', sourceLocale: 'en', sourcePath: selectedPath, targetPath,
sourceCommit: command('git', ['rev-parse', 'HEAD'], {cwd: repo}), sourceHash: hash(source),
sourceTechnicalHash: (await structure(source, '.md')).technicalHash, targetHash: hash(incrementalTarget),
ownership: 'machine', reviewStatus: 'machine', translatedAt: new Date().toISOString(),
glossaryVersion: JSON.parse(fs.readFileSync(path.join(repo, 'translations/glossary/ja.json'), 'utf8')).version};
const statePath = path.join(repo, stateFileFor(record));
fs.mkdirSync(path.dirname(statePath), {recursive: true});
fs.writeFileSync(statePath, JSON.stringify({schemaVersion: 1, page: record}, null, 2) + '\n');
fs.writeFileSync(path.join(repo, targetPath), incrementalTarget);
fs.writeFileSync(path.join(repo, selectedPath), source.replace('Paragraph A.', 'Updated paragraph A.'));
} else if (pages === 10) {
selectedPath = 'docs/translation-simulation/';
fs.mkdirSync(path.join(repo, selectedPath), {recursive: true});
for (let index = 0; index < pages; index++) fs.writeFileSync(path.join(repo, selectedPath, `page-${index}.md`),
`# Guide ${index}\n\nDoris guide.\n\n\x60\x60\x60sql\nSELECT ${index};\n\x60\x60\x60\n`);
} else {
// Force the selected real page into the pending queue in this clone.
const record = [...loadRecords(repo, 'ja').values()].find(page => page.targetPath === targetPath);
const stateFile = path.join(repo, 'translations/state/ja/docs.json');
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
state.pages = state.pages.filter(page => page.targetPath !== targetPath);
fs.writeFileSync(stateFile, JSON.stringify(state, null, 2) + '\n');
if (record) fs.rmSync(path.join(repo, stateFileFor(record)), {force: true});
fs.rmSync(path.join(repo, targetPath), {force: true});
}
command('git', ['add', '.'], {cwd: repo});
command('git', ['commit', '-qm', 'Local simulation implementation and source fixture'], {cwd: repo});
command('git', ['push', '-q', 'origin', 'HEAD:master'], {cwd: repo});
fs.writeFileSync(path.join(auth, 'auth.json'), '{}\n');
writeExecutable(path.join(bin, 'gh'), ghMock);
writeExecutable(path.join(bin, 'codex-valid'), codexMock(scenario, repo));
writeExecutable(path.join(bin, 'codex-invalid'), codexMock('invalid', repo));
const env = {...process.env, PATH: `${bin}:${process.env.PATH}`, GH_TOKEN: 'local-simulation',
GITHUB_REPOSITORY: 'apache/doris-website', GITHUB_EVENT_NAME: 'workflow_dispatch',
GITHUB_RUN_ID: runId, I18N_LOCALE: 'ja', I18N_PR_AUTH_KIND: 'pat', I18N_MODE: pages === 1 ? 'validate' : 'generate',
I18N_PATH: selectedPath, I18N_MAX_PAGES: String(pages),
I18N_LIMIT: '20', DORIS_ENABLE_JA: 'true', CODEX_HOME: auth,
I18N_SIM_PR_RECORD: path.join(output, 'pull-request.json')};
runNode(repo, 'scripts/i18n/workflow-prs.cjs', [scan], env, logs);
runNode(repo, 'scripts/i18n/workflow-scan.cjs', [scan], env, logs);
const plan = JSON.parse(fs.readFileSync(path.join(scan, 'plan.json'), 'utf8'));
assert.equal(plan.selected.length, pages);
if (drift) {
const other = path.join(output, 'other');
command('git', ['clone', '--shared', '-q', remote, other]);
command('git', ['config', '--local', 'user.name', 'Local Simulator'], {cwd: other});
command('git', ['config', '--local', 'user.email', 'local@example.invalid'], {cwd: other});
const file = {unrelated: 'blog/translation-simulation-drift.md', config: 'translations/glossary/ja.json',
page: plan.tasks.find(task => task.id === plan.selected[0]).sourcePath}[drift];
fs.mkdirSync(path.dirname(path.join(other, file)), {recursive: true});
fs.appendFileSync(path.join(other, file), drift === 'config' ? '\n' : '\nMaster drift simulation.\n');
command('git', ['add', '.'], {cwd: other});
command('git', ['commit', '-qm', `Master drift simulation: ${drift}`], {cwd: other});
command('git', ['push', '-q', 'origin', 'HEAD:master'], {cwd: other});
}
if (drift === 'page' || drift === 'config') {
// Neither model sees a request: the page is skipped, or the batch stops first.
const results = ['invalid', 'valid'].map(kind => {
const result = runNode(repo, 'scripts/i18n/workflow-generate.cjs', [scan],
{...env, CODEX_BIN: path.join(bin, `codex-${kind}`), I18N_SIM_PHASE: `-${kind}`}, logs, 1);
const file = path.join(scan, 'generation.json');
if (drift === 'config') {
assert.match(result.stderr, /master changed translations\/glossary\/ja\.json after the source commit/);
assert(!fs.existsSync(file));
return {exitCode: result.status, error: result.stderr.trim()};
}
const manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
assert.equal(manifest.candidates.length, 0);
assert.equal(manifest.usedTokens, 0);
assert.match(manifest.failures[0].error, /master changed this page after the source commit/);
assert.equal(manifest.master.moved, true);
return {exitCode: result.status, error: manifest.failures[0].error, master: manifest.master};
});
const report = {kind: 'local simulation; no external model or GitHub writes', output, drift,
sourceCommit: plan.sourceCommit, selected: plan.selected, sourcePath: selectedPath, generation: results,
pullRequest: null};
fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify(report, null, 2) + '\n');
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
return;
}
const failed = {...env, CODEX_BIN: path.join(bin, 'codex-invalid'), I18N_SIM_PHASE: '-invalid'};
runNode(repo, 'scripts/i18n/workflow-generate.cjs', [scan], failed, logs, pages === 1 ? 1 : 0);
const failure = JSON.parse(fs.readFileSync(path.join(scan, 'generation.json'), 'utf8'));
assert.equal(failure.candidates.length, 0);
assert.match(failure.failures[0].error, /No usable document after three attempts/);
assert.equal(failure.liveCall, true);
assert.equal(failure.usedTokens, pages * 300000);
const valid = {...env, CODEX_BIN: path.join(bin, 'codex-valid'), I18N_SIM_PHASE: '-valid'};
runNode(repo, 'scripts/i18n/workflow-generate.cjs', [scan], valid, logs);
const generation = JSON.parse(fs.readFileSync(path.join(scan, 'generation.json'), 'utf8'));
assert.equal(generation.candidates.length, pages);
assert.equal(generation.failures.length, 0);
const requests = generation.candidates.reduce((sum, candidate) => sum + candidate.requests, 0);
assert(Number.isInteger(requests) && requests > 0);
assert.equal(generation.usedTokens, requests * 100000);
const attempts = {success: 1, retry: 2, fallback: 3, incremental: 1}[scenario];
assert.equal(requests, pages * attempts);
assert(generation.candidates.every(candidate => candidate.generation.attempts === attempts
&& candidate.generation.acceptedWithWarnings === (scenario === 'fallback')));
runNode(repo, 'scripts/i18n/workflow-publish.cjs', [scan], env, logs);
const pr = JSON.parse(fs.readFileSync(env.I18N_SIM_PR_RECORD, 'utf8'));
assert.match(pr.body, /Shared PR admission checks: passed/);
if (drift === 'unrelated') {
// Published from the source commit; the PR checks run against current master.
assert.equal(generation.master.moved, true);
assert.match(pr.body, /Master moved to `[a-f0-9]{40}` during generation; none of its 1 changed files affect this batch/);
assert.match(pr.body, /Master was at `[a-f0-9]{40}` when publishing; none of its 1 changed files/);
} else assert.equal(generation.master.moved, false);
assert.equal(pr.head, `i18n/ja-automated-${runId}`);
const branch = command('git', ['rev-parse', `refs/heads/${pr.head}`], {cwd: remote});
const candidate = generation.candidates[0];
const target = command('git', ['show', `${branch}:${candidate.targetPath}`], {cwd: remote});
assert.match(target, /[\u3040-\u30ff\u3400-\u9fff]/);
if (scenario === 'incremental') {
assert.equal(candidate.generation.mode, 'incremental');
assert.equal(candidate.generation.sourceChange.ratio, 0.1);
assert.equal(target, incrementalTarget.replace('訳Paragraph A.', '訳Updated paragraph A.').trim());
}
const state = [...loadRecords(repo, 'ja').values()];
assert(state.some(page => page.sourcePath === candidate.sourcePath && page.reviewStatus === 'machine'
&& page.sourceHash === candidate.sourceHash && page.targetHash === candidate.targetHash));
const expectedFiles = [...new Set(generation.candidates.flatMap(item => [item.targetPath, item.statePath]))].sort();
const committedFiles = command('git', ['diff', '--name-only', plan.sourceCommit, branch], {cwd: repo}).split('\n').sort();
assert.deepEqual(committedFiles, expectedFiles);
const report = {kind: 'local simulation; no external model or GitHub writes', output, drift: drift || null,
master: generation.master,
sourceCommit: plan.sourceCommit, selected: plan.selected, sourcePath: selectedPath, scenario,
invalidModel: {exitCode: pages === 1 ? 1 : 0, error: failure.failures[0].error, usedTokens: failure.usedTokens},
generation: {candidates: generation.candidates.length, failures: generation.failures.length,
requests, attemptsPerDocument: attempts, retainedWithWarnings: scenario === 'fallback' ? pages : 0,
usedTokens: generation.usedTokens},
documents: generation.candidates.map(item => ({sourcePath: item.sourcePath,
sourceChars: fs.readFileSync(path.join(repo, item.sourcePath), 'utf8').length,
mode: item.generation.mode, sourceChange: item.generation.sourceChange,
attempts: item.generation.attempts, warnings: item.generation.warnings.length})),
admission: {status: 'passed', checks: ['translation state', 'syntax', 'internal links']},
pullRequest: {url: pr.url, draft: pr.draft, branch: pr.head, commit: branch, files: committedFiles, labels: pr.labels}};
fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify(report, null, 2) + '\n');
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
}
main().catch(error => {console.error(error); process.exitCode = 1;});