open-code-review/scripts/github-actions/check-translation-sync.test.js
KBS 0c97538465
ci: add translation-sync guardrails for READMEs and docs (#455)
* ci: add translation-sync guardrails for READMEs and docs

Add a check-translation-sync script + tests wired into CI: a blocking
check that all five README.<locale>.md files share an identical level-2
heading structure (compared by structure not translated text, and
code-fence-aware), and a non-blocking warning when a docs/en page changes
without its zh/ja counterparts. Addresses #419.

* ci: address review feedback on translation-sync guardrails

- Move the translation-sync job out of ci.yml into its own
  .github/workflows/translation-sync.yml, scoped with paths: filters
  (README*.md, pages/src/content/docs/**, the checker scripts) so it
  only runs on translation changes and never blocks the core pipeline.
- runReadmeCheck: short-circuit with exit 1 when any expected README*.md
  is missing, before the structure comparison, so a missing file can no
  longer mask a real divergence.
- extractHeadings: add a TODO(commonmark) note that closing fences are
  matched by fence char only, not by length.
- test: drop the dead en/zh fixtures and the void statements in
  testReorderedHeadingFails; keep the en2/zh2 pair that actually drives
  the assertion.

* ci: fix length-unaware code-fence parsing and pin node image

Address the automated review comments beyond the maintainer's minor note:

- extractHeadings: track the opening fence length and only close on a
  same-char run that is at least as long (per CommonMark). Previously a
  block opened with ```` and containing an inner ``` closed early, so
  lines inside the block could be misread as headings (or real headings
  dropped), producing false-positive structure-divergence errors. Adds a
  regression test covering the inner-shorter-fence case.
- translation-sync.yml: pin node:24.18.0 instead of the mutable node:24
  major tag, matching the golang:1.26.5 pin used by the other jobs.
2026-07-23 16:07:27 +08:00

286 lines
9.5 KiB
JavaScript

#!/usr/bin/env node
"use strict";
// Unit tests for scripts/github-actions/check-translation-sync.js.
//
// Run via: node scripts/github-actions/check-translation-sync.test.js
// (also wired as `npm run test:github-actions`).
//
// Plain Node + assert, no external deps and no `node --test`, mirroring
// post-review-comments.test.js so both run on node >= 14.
const assert = require("assert");
const path = require("path");
const {
extractHeadings,
level2Headings,
structuralSignature,
compareReadmeStructures,
counterpartPaths,
findMissingTranslations,
getChangedFiles,
splitList,
} = require(path.join(__dirname, "check-translation-sync.js"));
// A minimal but realistic README skeleton: an intro H1, three `##` sections,
// one of them with a `###` subheading, plus a fenced bash block whose `#`
// comment lines must NOT be parsed as headings.
function readme(headings) {
// headings: array of "## Text" / "### Text" / "# Text" lines already formed.
return [
"# Project",
"",
"Intro paragraph.",
"",
"```bash",
"# this is a shell comment, not a heading",
"## also not a heading",
"echo hi",
"```",
"",
...headings.flatMap((h) => [h, "", "body text", ""]),
].join("\n");
}
function testExtractHeadingsSkipsCodeFences() {
const md = readme(["## Alpha", "### Alpha One", "## Beta"]);
const headings = extractHeadings(md);
// Only the real headings: H1 + 2x H2 + 1x H3. The fenced `#`/`##` lines are ignored.
assert.deepStrictEqual(
headings.map((h) => `${h.level}:${h.text}`),
["1:Project", "2:Alpha", "3:Alpha One", "2:Beta"]
);
assert.deepStrictEqual(level2Headings(md), ["Alpha", "Beta"]);
assert.deepStrictEqual(structuralSignature(md), [1, 2, 3, 2]);
}
function testExtractHeadingsTildeFenceAndTrailingHashes() {
const md = ["# T", "~~~", "## fenced", "~~~", "## Real ##"].join("\n");
assert.deepStrictEqual(
extractHeadings(md).map((h) => `${h.level}:${h.text}`),
["1:T", "2:Real"] // closing `## Real ##` trailing hashes stripped
);
}
function testExtractHeadingsInnerShorterFenceDoesNotClose() {
// CommonMark: a closing fence must be at least as long as the opening one.
// A block opened with ```` (4 backticks) must NOT be closed by an inner ```
// (3 backticks); everything up to the matching 4-backtick line stays code.
const md = [
"# Title",
"````", // open, length 4
"## not a heading (inside code)",
"```", // shorter than opening -> does NOT close
"## also inside code",
"````", // length >= 4 -> real close
"## Real Heading",
].join("\n");
assert.deepStrictEqual(
extractHeadings(md).map((h) => `${h.level}:${h.text}`),
["1:Title", "2:Real Heading"]
);
}
// --- README structure comparison -------------------------------------------
function testIdenticalStructurePasses() {
// Same outline, DIFFERENT heading text (simulating translations). Must pass:
// the check compares structure, not text.
const en = readme(["## What is it?", "### Details", "## Usage"]);
const zh = readme(["## 这是什么?", "### 细节", "## 使用方法"]);
const ja = readme(["## これは何ですか?", "### 詳細", "## 使い方"]);
const { ok, errors } = compareReadmeStructures([
{ name: "README.md", content: en },
{ name: "README.zh-CN.md", content: zh },
{ name: "README.ja-JP.md", content: ja },
]);
assert.strictEqual(ok, true, JSON.stringify(errors));
assert.strictEqual(errors.length, 0);
}
function testAddedHeadingFails() {
const en = readme(["## Alpha", "## Beta"]);
const zh = readme(["## Alpha", "## Beta", "## Gamma"]); // extra ##
const { ok, errors } = compareReadmeStructures([
{ name: "README.md", content: en },
{ name: "README.zh-CN.md", content: zh },
]);
assert.strictEqual(ok, false);
assert.strictEqual(errors.length, 1);
assert.strictEqual(errors[0].file, "README.zh-CN.md");
assert.match(errors[0].message, /level-2 \(##\) heading/);
assert.match(errors[0].message, /has 3 level-2 .* has 2/);
}
function testDroppedHeadingFails() {
const en = readme(["## Alpha", "## Beta", "## Gamma"]);
const ja = readme(["## Alpha", "## Gamma"]); // dropped Beta
const { ok, errors } = compareReadmeStructures([
{ name: "README.md", content: en },
{ name: "README.ja-JP.md", content: ja },
]);
assert.strictEqual(ok, false);
assert.strictEqual(errors[0].file, "README.ja-JP.md");
assert.match(errors[0].message, /has 2 level-2 .* has 3/);
}
function testReorderedHeadingFails() {
// Two `##` sections with different sub-structure; swapping them keeps the
// level-2 COUNT identical but changes the outline order -> must fail.
// The sub-structure must differ so the reorder is detectable structurally:
// same level-2 count, different heading-level outline.
const en2 = readme(["## Alpha", "### A1", "## Beta"]); // [1,2,3,2]
const zh2 = readme(["## Beta", "## Alpha", "### A1"]); // [1,2,2,3]
const { ok, errors } = compareReadmeStructures([
{ name: "README.md", content: en2 },
{ name: "README.zh-CN.md", content: zh2 },
]);
assert.strictEqual(ok, false);
assert.strictEqual(errors[0].file, "README.zh-CN.md");
assert.match(errors[0].message, /reordered or its level changed/);
assert.match(errors[0].message, /heading #3/); // first divergence position
}
function testMissingReferenceOnlyIsFine() {
// A single file (only the reference) cannot diverge.
const { ok } = compareReadmeStructures([
{ name: "README.md", content: readme(["## A"]) },
]);
assert.strictEqual(ok, true);
}
// --- docs en -> zh/ja counterpart sync -------------------------------------
function testCounterpartPaths() {
assert.deepStrictEqual(
counterpartPaths("pages/src/content/docs/en/integrations/ci.md"),
[
{ locale: "zh", path: "pages/src/content/docs/zh/integrations/ci.md" },
{ locale: "ja", path: "pages/src/content/docs/ja/integrations/ci.md" },
]
);
}
function testEnOnlyChangeWarns() {
const changed = [
"pages/src/content/docs/en/faq.md",
"internal/agent/agent.go",
];
const warnings = findMissingTranslations(changed);
// Both zh and ja counterparts missing -> two warnings.
assert.strictEqual(warnings.length, 2);
assert.deepStrictEqual(
warnings.map((w) => w.counterpart).sort(),
[
"pages/src/content/docs/ja/faq.md",
"pages/src/content/docs/zh/faq.md",
]
);
}
function testEnWithBothCounterpartsNoWarn() {
const changed = [
"pages/src/content/docs/en/faq.md",
"pages/src/content/docs/zh/faq.md",
"pages/src/content/docs/ja/faq.md",
];
assert.deepStrictEqual(findMissingTranslations(changed), []);
}
function testEnWithOnlyOneCounterpartWarnsForTheOther() {
const changed = [
"pages/src/content/docs/en/mcp.md",
"pages/src/content/docs/zh/mcp.md", // ja still missing
];
const warnings = findMissingTranslations(changed);
assert.strictEqual(warnings.length, 1);
assert.strictEqual(warnings[0].locale, "ja");
assert.strictEqual(warnings[0].counterpart, "pages/src/content/docs/ja/mcp.md");
}
function testNonDocsAndNonEnChangesIgnored() {
const changed = [
"pages/src/content/docs/zh/faq.md", // zh-only change: not our trigger
"README.md",
"pages/src/content/docs/en/logo.png", // en but not markdown
];
assert.deepStrictEqual(findMissingTranslations(changed), []);
}
// --- changed-files resolution ----------------------------------------------
function testGetChangedFilesFromExplicitEnv() {
const env = { OCR_CHANGED_FILES: "a.md\npages/src/content/docs/en/x.md\n" };
assert.deepStrictEqual(getChangedFiles(env), [
"a.md",
"pages/src/content/docs/en/x.md",
]);
}
function testGetChangedFilesFromGitDiff() {
const calls = [];
const fakeExec = (cmd, args) => {
calls.push({ cmd, args });
return "pages/src/content/docs/en/faq.md\ninternal/x.go\n";
};
const env = { OCR_BASE_SHA: "base", OCR_HEAD_SHA: "head" };
const out = getChangedFiles(env, fakeExec);
assert.deepStrictEqual(out, [
"pages/src/content/docs/en/faq.md",
"internal/x.go",
]);
assert.strictEqual(calls[0].cmd, "git");
assert.deepStrictEqual(calls[0].args, ["diff", "--name-only", "base...head"]);
}
function testGetChangedFilesGitFailureIsNonFatal() {
const env = { OCR_BASE_SHA: "base", OCR_HEAD_SHA: "head" };
const boom = () => {
throw new Error("no merge base");
};
// Silence the expected ::warning annotation this path emits.
const orig = console.log;
console.log = () => {};
try {
assert.deepStrictEqual(getChangedFiles(env, boom), []);
} finally {
console.log = orig;
}
}
function testGetChangedFilesEmptyWhenNoInputs() {
assert.deepStrictEqual(getChangedFiles({}), []);
}
function testSplitList() {
assert.deepStrictEqual(splitList("a\nb,c\n\n d "), ["a", "b", "c", "d"]);
}
function main() {
testExtractHeadingsSkipsCodeFences();
testExtractHeadingsTildeFenceAndTrailingHashes();
testExtractHeadingsInnerShorterFenceDoesNotClose();
testIdenticalStructurePasses();
testAddedHeadingFails();
testDroppedHeadingFails();
testReorderedHeadingFails();
testMissingReferenceOnlyIsFine();
testCounterpartPaths();
testEnOnlyChangeWarns();
testEnWithBothCounterpartsNoWarn();
testEnWithOnlyOneCounterpartWarnsForTheOther();
testNonDocsAndNonEnChangesIgnored();
testGetChangedFilesFromExplicitEnv();
testGetChangedFilesFromGitDiff();
testGetChangedFilesGitFailureIsNonFatal();
testGetChangedFilesEmptyWhenNoInputs();
testSplitList();
console.log("All check-translation-sync tests passed.");
}
try {
main();
} catch (err) {
console.error(err);
process.exit(1);
}