315 行
12 KiB
JavaScript
315 行
12 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { existsSync, mkdtempSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import {
|
|
collectNewTargetPaths,
|
|
getShanghaiDate,
|
|
parseNameStatusZ,
|
|
pathAliasMapFromManifest,
|
|
runValidation,
|
|
validateChangelogPath,
|
|
} from '../scripts/validate-changelog-filenames.mjs';
|
|
|
|
const SHANGHAI_NOW = new Date('2026-07-22T04:00:00.000Z');
|
|
const CLI_PATH = fileURLToPath(new URL('../scripts/validate-changelog-filenames.mjs', import.meta.url));
|
|
|
|
function validAdmin({ day = '22', month = '2026-07', issue = '5161', title = '文件名校验', type = '新增接口', client = '管理后台' } = {}) {
|
|
return `changelogs-v2/${month}/${day}_${issue}_${title}-${type}-${client}.md`;
|
|
}
|
|
|
|
function validMp({ day = '22', month = '2026-07', issue = '5161', title = '文件名校验', type = '修改接口', client = '小程序端' } = {}) {
|
|
return `changelogs-v2-mp/${month}/${day}_${issue}_${title}-${type}-${client}.md`;
|
|
}
|
|
|
|
function codes(file, now = SHANGHAI_NOW) {
|
|
return validateChangelogPath(file, now).map((error) => error.code);
|
|
}
|
|
|
|
test('accepts a valid v2 admin path', () => {
|
|
assert.deepEqual(validateChangelogPath(validAdmin(), SHANGHAI_NOW), []);
|
|
});
|
|
|
|
test('accepts a valid v2-mp path', () => {
|
|
assert.deepEqual(validateChangelogPath(validMp(), SHANGHAI_NOW), []);
|
|
});
|
|
|
|
test('uses Asia/Shanghai when UTC is still the previous day', () => {
|
|
const now = new Date('2026-07-21T16:05:00.000Z');
|
|
assert.equal(getShanghaiDate(now).isoDate, '2026-07-22');
|
|
assert.deepEqual(validateChangelogPath(validAdmin(), now), []);
|
|
});
|
|
|
|
test('accepts the real leap day', () => {
|
|
const now = new Date('2024-02-29T04:00:00.000Z');
|
|
assert.deepEqual(validateChangelogPath(validAdmin({ month: '2024-02', day: '29' }), now), []);
|
|
});
|
|
|
|
test('rejects a wrong day', () => {
|
|
assert.ok(codes(validAdmin({ day: '21' })).includes('E_DAY'));
|
|
});
|
|
|
|
test('rejects a wrong month directory', () => {
|
|
assert.ok(codes(validAdmin({ month: '2026-06' })).includes('E_MONTH'));
|
|
});
|
|
|
|
test('rejects a day greater than 31', () => {
|
|
assert.ok(codes(validAdmin({ day: '63' })).includes('E_DAY'));
|
|
});
|
|
|
|
test('rejects a missing issue number', () => {
|
|
assert.ok(codes('changelogs-v2/2026-07/22_标题-新增接口-管理后台.md').includes('E_ISSUE'));
|
|
});
|
|
|
|
test('rejects issue zero', () => {
|
|
assert.ok(codes(validAdmin({ issue: '0' })).includes('E_ISSUE'));
|
|
});
|
|
|
|
test('rejects a hash-prefixed issue', () => {
|
|
assert.ok(codes(validAdmin({ issue: '#5161' })).includes('E_ISSUE'));
|
|
});
|
|
|
|
test('rejects a non-numeric issue', () => {
|
|
assert.ok(codes(validAdmin({ issue: 'ABC' })).includes('E_ISSUE'));
|
|
});
|
|
|
|
test('rejects an unsupported change type', () => {
|
|
assert.ok(codes(validAdmin({ type: '新增字段' })).includes('E_CHANGE_TYPE'));
|
|
});
|
|
|
|
test('rejects the mini-program client in v2', () => {
|
|
assert.ok(codes(validAdmin({ client: '小程序端' })).includes('E_CLIENT'));
|
|
});
|
|
|
|
test('rejects the admin client in v2-mp', () => {
|
|
assert.ok(codes(validMp({ client: '管理后台' })).includes('E_CLIENT'));
|
|
});
|
|
|
|
test('rejects an empty title', () => {
|
|
assert.ok(codes(validAdmin({ title: '' })).includes('E_TITLE'));
|
|
});
|
|
|
|
test('rejects a backslash inside a controlled physical path', () => {
|
|
const physicalPath = 'changelogs-v2/2026-07\\22_5161_绕过-新增接口-管理后台.md';
|
|
assert.ok(codes(physicalPath).includes('E_STRUCTURE'));
|
|
});
|
|
|
|
test('rejects a root-level backslash pseudo path', () => {
|
|
const physicalPath = 'changelogs-v2\\2026-07\\22_5161_绕过-新增接口-管理后台.md';
|
|
assert.ok(codes(physicalPath).includes('E_STRUCTURE'));
|
|
});
|
|
|
|
test('exempts phase-one changelogs', () => {
|
|
assert.deepEqual(validateChangelogPath('changelogs/2026-07/99_no_issue_anything.md', SHANGHAI_NOW), []);
|
|
});
|
|
|
|
test('parses NUL-delimited Chinese and space-containing paths', () => {
|
|
const raw = Buffer.from(`A\0${validAdmin({ title: '中文 空格' })}\0M\0old file.md\0`, 'utf8');
|
|
assert.deepEqual(parseNameStatusZ(raw), [
|
|
{ status: 'A', targetPath: validAdmin({ title: '中文 空格' }) },
|
|
{ status: 'M', targetPath: 'old file.md' },
|
|
]);
|
|
});
|
|
|
|
test('exempts a modified historical bad path but protects a deleted published path', () => {
|
|
const modified = parseNameStatusZ(Buffer.from(
|
|
'M\0changelogs-v2/2026-07/63_历史坏文件.md\0',
|
|
'utf8',
|
|
));
|
|
assert.deepEqual(collectNewTargetPaths(modified), []);
|
|
assert.equal(runValidation(modified, SHANGHAI_NOW).errors.length, 0);
|
|
|
|
const deleted = parseNameStatusZ(Buffer.from(
|
|
'D\0changelogs-v2/2026-07/99_无issue.md\0',
|
|
'utf8',
|
|
));
|
|
assert.ok(runValidation(deleted, SHANGHAI_NOW).errors.some(({ code }) => code === 'E_PATH_STABILITY'));
|
|
});
|
|
|
|
test('validates only a copy target path', () => {
|
|
const records = parseNameStatusZ(Buffer.from(`C100\0old.md\0${validAdmin()}\0`, 'utf8'));
|
|
assert.deepEqual(collectNewTargetPaths(records), [validAdmin()]);
|
|
});
|
|
|
|
test('rejects a rename without a machine-readable compatibility alias', () => {
|
|
const source = 'changelogs-v2/2026-07/63_old.md';
|
|
const records = parseNameStatusZ(Buffer.from(`R100\0${source}\0${validAdmin()}\0`, 'utf8'));
|
|
assert.ok(runValidation(records, SHANGHAI_NOW).errors.some(({ code }) => code === 'E_PATH_STABILITY'));
|
|
});
|
|
|
|
test('accepts a rename and a legacy alias path when both are registered', () => {
|
|
const source = 'changelogs-v2/2026-07/63_old.md';
|
|
const target = validAdmin();
|
|
const pathAliases = pathAliasMapFromManifest({
|
|
schema: 'hl-changelog-path-aliases/v1',
|
|
aliases: [{ alias: source, canonical: target }],
|
|
});
|
|
const rename = parseNameStatusZ(Buffer.from(`R100\0${source}\0${target}\0`, 'utf8'));
|
|
assert.equal(runValidation(rename, SHANGHAI_NOW, { pathAliases }).errors.length, 0);
|
|
const compatibilityAddition = parseNameStatusZ(Buffer.from(`A\0${source}\0`, 'utf8'));
|
|
assert.equal(runValidation(compatibilityAddition, SHANGHAI_NOW, { pathAliases }).errors.length, 0);
|
|
});
|
|
|
|
test('rejects a rename target with an old day', () => {
|
|
const target = validAdmin({ day: '21' });
|
|
const records = parseNameStatusZ(Buffer.from(`R100\0changelogs-v2/2026-07/63_old.md\0${target}\0`, 'utf8'));
|
|
assert.ok(runValidation(records, SHANGHAI_NOW).errors.some((error) => error.code === 'E_DAY'));
|
|
});
|
|
|
|
test('aggregates all invalid paths instead of stopping at the first', () => {
|
|
const records = [
|
|
{ status: 'A', targetPath: validAdmin() },
|
|
{ status: 'A', targetPath: validMp() },
|
|
{ status: 'A', targetPath: validAdmin({ day: '21' }) },
|
|
{ status: 'A', targetPath: validMp({ issue: '0' }) },
|
|
];
|
|
const result = runValidation(records, SHANGHAI_NOW);
|
|
assert.equal(new Set(result.errors.map((error) => error.path)).size, 2);
|
|
assert.equal(result.checkedCount, 4);
|
|
});
|
|
|
|
test('real git diff ignores historical modifications and validates an added path', () => {
|
|
const repo = mkdtempSync(path.join(tmpdir(), 'hl-changelog-gate-'));
|
|
try {
|
|
execFileSync('git', ['init', '-q'], { cwd: repo });
|
|
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repo });
|
|
execFileSync('git', ['config', 'user.name', 'Gate Test'], { cwd: repo });
|
|
execFileSync('git', ['config', 'core.autocrlf', 'false'], { cwd: repo });
|
|
const oldPath = path.join(repo, 'changelogs-v2', '2026-07', '63_old.md');
|
|
mkdirSync(path.dirname(oldPath), { recursive: true });
|
|
writeFileSync(oldPath, 'old\n');
|
|
execFileSync('git', ['add', '.'], { cwd: repo });
|
|
execFileSync('git', ['commit', '-qm', 'base'], { cwd: repo });
|
|
const base = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
|
|
|
|
writeFileSync(oldPath, 'changed\n');
|
|
const addedPath = path.join(repo, ...validAdmin().split('/'));
|
|
writeFileSync(addedPath, 'new\n');
|
|
execFileSync('git', ['add', '.'], { cwd: repo });
|
|
execFileSync('git', ['commit', '-qm', 'head'], { cwd: repo });
|
|
const head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
|
|
const raw = execFileSync('git', ['diff', '--name-status', '-z', '--find-renames', base, head], { cwd: repo });
|
|
const result = runValidation(parseNameStatusZ(raw), SHANGHAI_NOW);
|
|
|
|
assert.equal(result.checkedCount, 1);
|
|
assert.equal(result.errors.length, 0);
|
|
} finally {
|
|
rmSync(repo, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('production CLI selects PR/push revisions safely and preserves exit-code semantics', async (t) => {
|
|
const repo = mkdtempSync(path.join(tmpdir(), 'hl-changelog-cli-'));
|
|
const eventPath = path.join(repo, 'event.json');
|
|
|
|
function git(...args) {
|
|
return execFileSync('git', args, { cwd: repo, encoding: 'utf8' }).trim();
|
|
}
|
|
|
|
function runCli(args) {
|
|
return execFileSync(process.execPath, [CLI_PATH, ...args], {
|
|
cwd: repo,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
}
|
|
|
|
function runCliResult(args) {
|
|
try {
|
|
return { status: 0, stdout: runCli(args), stderr: '' };
|
|
} catch (error) {
|
|
return {
|
|
status: error.status,
|
|
stdout: String(error.stdout ?? ''),
|
|
stderr: String(error.stderr ?? ''),
|
|
};
|
|
}
|
|
}
|
|
|
|
function writeEvent(value) {
|
|
writeFileSync(eventPath, typeof value === 'string' ? value : JSON.stringify(value));
|
|
}
|
|
|
|
try {
|
|
git('init', '-q');
|
|
git('config', 'user.email', 'test@example.com');
|
|
git('config', 'user.name', 'Gate Test');
|
|
git('config', 'core.autocrlf', 'false');
|
|
writeFileSync(path.join(repo, 'README.md'), 'base\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'base');
|
|
const base = git('rev-parse', 'HEAD');
|
|
|
|
const today = getShanghaiDate(new Date());
|
|
const validPath = validAdmin({ month: today.yearMonth, day: today.day });
|
|
const addedPath = path.join(repo, ...validPath.split('/'));
|
|
mkdirSync(path.dirname(addedPath), { recursive: true });
|
|
writeFileSync(addedPath, 'valid\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'valid');
|
|
const validHead = git('rev-parse', 'HEAD');
|
|
|
|
const wrongDay = today.day === '01' ? '02' : '01';
|
|
const invalidPath = validAdmin({ month: today.yearMonth, day: wrongDay, issue: '5162' });
|
|
writeFileSync(path.join(repo, ...invalidPath.split('/')), 'invalid\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'invalid');
|
|
const invalidHead = git('rev-parse', 'HEAD');
|
|
|
|
await t.test('PR event uses base...head and returns 0 for a legal addition', () => {
|
|
writeEvent({ pull_request: { base: { sha: base }, head: { sha: validHead } } });
|
|
const result = runCliResult(['--event', eventPath]);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.match(result.stdout, /validated 1 controlled changelog path/);
|
|
});
|
|
|
|
await t.test('push event uses before/after and returns 0 for a legal addition', () => {
|
|
writeEvent({ before: base, after: validHead });
|
|
const result = runCliResult(['--event', eventPath]);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
});
|
|
|
|
await t.test('new-branch push uses the empty tree', () => {
|
|
writeEvent({ before: '0'.repeat(base.length), after: validHead });
|
|
const result = runCliResult(['--event', eventPath]);
|
|
assert.equal(result.status, 0, result.stderr);
|
|
assert.match(result.stdout, /validated 1 controlled changelog path/);
|
|
});
|
|
|
|
await t.test('rule failure returns 1', () => {
|
|
writeEvent({ pull_request: { base: { sha: validHead }, head: { sha: invalidHead } } });
|
|
const result = runCliResult(['--event', eventPath]);
|
|
assert.equal(result.status, 1);
|
|
assert.match(result.stderr, /\[E_DAY\]/);
|
|
});
|
|
|
|
await t.test('missing event fields return 2', () => {
|
|
writeEvent({ pull_request: { base: { sha: base }, head: {} } });
|
|
assert.equal(runCliResult(['--event', eventPath]).status, 2);
|
|
});
|
|
|
|
await t.test('invalid JSON returns 2', () => {
|
|
writeEvent('{not-json');
|
|
assert.equal(runCliResult(['--event', eventPath]).status, 2);
|
|
});
|
|
|
|
await t.test('nonexistent event commit returns 2', () => {
|
|
writeEvent({ before: base, after: 'f'.repeat(base.length) });
|
|
assert.equal(runCliResult(['--event', eventPath]).status, 2);
|
|
});
|
|
|
|
await t.test('a Git option cannot be injected as a local ref', () => {
|
|
const result = runCliResult(['--base', '--output=gate-review', '--head', 'HEAD']);
|
|
assert.equal(result.status, 2);
|
|
assert.equal(existsSync(path.join(repo, 'gate-review')), false);
|
|
assert.equal(readdirSync(repo).some((name) => name.startsWith('gate-review')), false);
|
|
});
|
|
} finally {
|
|
rmSync(repo, { recursive: true, force: true });
|
|
}
|
|
});
|