fix(tests): verify-snapshots понимает inputRaw, inputEncoding и версию формата EPF/ERF

Полный прогон верификации давал шесть падений; четыре из них — дефекты самого инструмента.

Вход кейса. Файл писался только из caseData.input и только UTF-8, поэтому кейсы, задающие вход
через inputRaw (сырая строка) и inputEncoding (UTF-16, cp1251), оставляли навык без входного
файла: meta-compile/encoding-utf16-bom и interface-edit/bad-definition-file падали на «Cannot bind
argument to parameter». Хуже того, кейс, у которого нет ни input, ни preRun, ни params, отбрасывался
в discoverCases как «ничего не подающий навыку» — inputRaw там тоже не учитывался, и такие кейсы
в верификацию не попадали вовсе (meta-compile/bad-encoding-cp1251,
subsystem-compile/bad-definition-file). Теперь оба ключа поддержаны, encodeInput перенесён копией
из runner.mjs — общего модуля у раннеров нет, и README предписывает добавлять ключ в оба.

Это ровно та «тихая дыра», о которой README предупреждает: ключ, известный лишь одному раннеру,
даёт кейс, зелёный в функциональном прогоне и не доезжающий до платформы в верификации.

Версия формата. Гейт вызывался только при наличии каталога конфигурации, поэтому кейсы
epf-init/format-221 и erf-init/format-221 доходили до сборки и падали на стенде без 8.5 — красным,
хотя это свойство стенда. Ядро гейта выделено и читает версию из шапки любого MetaDataObject,
включая исходник обработки и отчёта; в EPF-ветке проверка выполняется до epf-build.

Проверено точечно: два кейса с inputRaw проходят, два format-221 честно пропускаются по версии
платформы, два ранее невидимых кейса берутся в прогон и проходят. Функциональный раннер не задет:
role-compile 26/26, meta-compile 85/85.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-08-22 18:46:15 +03:00
co-authored by Claude Opus 5
parent 9c66e89372
commit 66a11d1dba
+81 -25
View File
@@ -732,6 +732,39 @@ function compatibilityGap(configDir, v8path) {
return null;
}
// ── Вход кейса: те же ключи, что понимает runner.mjs ────────────────────────
// DSL один, реализации две, и ключ, известный лишь одному раннеру, даёт тихую дыру: кейс зелёный
// в функциональном прогоне и не доезжает до платформы в верификации. Так и вышло с inputRaw —
// verify писал вход только из caseData.input и только UTF-8, поэтому навык оставался без входного
// файла. Копия encodeInput из runner.mjs (общего модуля у раннеров нет) — держать одинаковыми.
// Байты входного файла в заданной кодировке. Нужно для кейсов про кодировку: writeFileSync
// пишет только UTF-8, а навык обязан одинаково вести себя на UTF-16 с BOM (принять) и на
// cp1251 (отвергнуть, а не молча подменить кириллицу на U+FFFD). cp1251 в Node нет — кодируем
// формулой по диапазонам, которые встречаются в кейсах (ASCII + кириллица); прочее — ошибка кейса.
function encodeInput(text, encoding) {
if (!encoding || encoding === 'utf-8' || encoding === 'utf8') return Buffer.from(text, 'utf8');
if (encoding === 'utf-16le') return Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(text, 'utf16le')]);
if (encoding === 'utf-16be') {
const le = Buffer.from(text, 'utf16le');
const be = Buffer.alloc(le.length);
for (let i = 0; i < le.length; i += 2) { be[i] = le[i + 1]; be[i + 1] = le[i]; }
return Buffer.concat([Buffer.from([0xfe, 0xff]), be]);
}
if (encoding === 'cp1251') {
const out = Buffer.alloc(text.length);
for (let i = 0; i < text.length; i++) {
const c = text.codePointAt(i);
if (c < 0x80) out[i] = c;
else if (c >= 0x410 && c <= 0x44f) out[i] = c - 0x350;
else if (c === 0x401) out[i] = 0xa8;
else if (c === 0x451) out[i] = 0xb8;
else throw new Error(`inputEncoding cp1251: символ U+${c.toString(16)} вне поддержанного набора (ASCII + кириллица)`);
}
return out;
}
throw new Error(`inputEncoding: неизвестная кодировка "${encoding}"`);
}
// Версия формата выгрузки против платформы: формат 2.21 не загрузится на 8.3.27, даже если
// режим совместимости платформе подходит. Без этой проверки кейс на 2.21 скипался только на
// 8.3.24 (по совместимости), а на 8.3.27 доходил до загрузки и падал — постоянный ложный
@@ -740,9 +773,16 @@ function compatibilityGap(configDir, v8path) {
// Эталон — таблица «Лестница версий» из docs/1c-configuration-spec.md (§7.1); разбор — копия
// такого же в check-format-versions.mjs, держать одинаковыми.
function formatGap(configDir, v8path) {
const cfgFile = join(configDir, 'Configuration.xml');
if (!existsSync(cfgFile)) return null;
const fm = /<MetaDataObject[^>]*\sversion="(\d+\.\d+)"/.exec(readFileSync(cfgFile, 'utf8'));
return formatGapForXml(join(configDir, 'Configuration.xml'), v8path);
}
// Ядро гейта: версия формата берётся из шапки любого MetaDataObject — Configuration.xml для
// конфигурации, исходник обработки/отчёта для EPF/ERF. Раньше проверка вызывалась только при
// наличии каталога конфигурации, поэтому кейсы epf-init/erf-init на формате 2.21 доходили до
// сборки и падали на стенде без 8.5 — красным, хотя это свойство стенда.
function formatGapForXml(xmlFile, v8path) {
if (!existsSync(xmlFile)) return null;
const fm = /<MetaDataObject[^>]*\sversion="(\d+\.\d+)"/.exec(readFileSync(xmlFile, 'utf8'));
const pm = /(\d+\.\d+\.\d+)/.exec(v8path || '');
if (!fm || !pm) return null;
@@ -1008,9 +1048,14 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) {
// ── Step 4: Main skill script ──
let inputFile = null;
if (caseData.input !== undefined) {
if (caseData.inputRaw !== undefined) {
// inputRaw пишется дословно: негативный кейс про битый JSON через input невыразим —
// JSON.stringify всегда даёт валидный документ.
inputFile = join(workDir, '__input.json');
writeFileSync(inputFile, JSON.stringify(caseData.input, null, 2), 'utf8');
writeFileSync(inputFile, encodeInput(caseData.inputRaw, caseData.inputEncoding));
} else if (caseData.input !== undefined) {
inputFile = join(workDir, '__input.json');
writeFileSync(inputFile, encodeInput(JSON.stringify(caseData.input, null, 2), caseData.inputEncoding));
}
try {
@@ -1198,6 +1243,13 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) {
result.errors.push(`EPF/ERF source not found: ${sourceFile}`);
return result;
}
const versionSkip = formatGapForXml(sourceFile, opts.v8ctx.v8path);
if (versionSkip) {
result.skipped = true;
result.skipReason = versionSkip;
log('epf-build', true, `skipped (${versionSkip})`);
return result;
}
const outDir = join(workDir, '__build');
mkdirSync(outDir, { recursive: true });
const outFile = join(outDir, `${name}${epfExt}`);
@@ -1531,7 +1583,10 @@ function discoverCases(skillFilter, caseFilter) {
// без этой ветки такие кейсы молча выпадали из платформенной проверки — то есть ровно
// из той, ради которой этот файл и существует.
const hasFixtureSetup = typeof caseData.setup === 'string' && caseData.setup.startsWith('fixture:');
if (caseData.input === undefined && !caseData.preRun && !caseData.params && !hasFixtureSetup) continue;
// inputRaw — такой же вход, как input: кейс, подающий навыку сырую строку, отбрасывался
// здесь как «ничего не подаёт» и в верификацию не попадал вовсе.
const hasInput = caseData.input !== undefined || caseData.inputRaw !== undefined;
if (!hasInput && !caseData.preRun && !caseData.params && !hasFixtureSetup) continue;
results.push({ skill: skillDir, caseName, caseData, skillConfig });
}
@@ -1653,25 +1708,6 @@ async function main() {
const passed = results.filter(r => r.passed).length;
const skipped = results.filter(r => r.skipped).length;
const failed = results.filter(r => !r.passed && !r.skipped).length;
console.log(`\n${'='.repeat(60)}`);
console.log(`Results: ${passed} passed, ${failed} failed`
+ (skipped ? `, ${skipped} skipped` : '') + ` out of ${results.length}`);
// «Прошло» и «проверено платформой» — разные вещи: часть кейсов законно идёт мимо неё
// (навык отказал, выход не конфигурация, external). Пока эта доля не названа, отчёт читается
// как «всё проверено», хотя платформу спрашивали не у всех.
const onPlatform = results.filter(r => r.platformChecked).length;
// Только успешные: у падения обращение к платформе было — оно и не удалось, мешать их
// с «мимо платформы» значит завышать долю непроверенного.
const offPlatform = results.filter(r => r.passed && !r.skipped && !r.platformChecked);
console.log(` из них проверено платформой: ${onPlatform}; без обращения к платформе: ${offPlatform.length}`);
const byReason = {};
for (const r of offPlatform) {
const key = r.noPlatformReason || 'причина не указана';
(byReason[key] = byReason[key] || []).push(`${r.skill}/${r.case}`);
}
for (const [reason, list] of Object.entries(byReason)) {
console.log(`${list.length}${reason}`);
}
for (const r of results.filter(x => x.skipped)) {
console.log(` \u25cb ${r.skill}/${r.case} \u2014 ${r.skipReason}`);
}
@@ -1683,6 +1719,26 @@ async function main() {
}
writeReport(results);
console.log(`\n${'='.repeat(60)}`);
console.log(`Results: ${passed} passed, ${failed} failed`
+ (skipped ? `, ${skipped} skipped` : '') + ` out of ${results.length}`);
// «Прошло» и «проверено платформой» — разные вещи: часть кейсов законно идёт мимо неё
// (навык отказал, выход не конфигурация, external). Пока эта доля не названа, отчёт читается
// как «всё проверено», хотя платформу спрашивали не у всех.
const onPlatform = results.filter(r => r.platformChecked).length;
// Только успешные: у падения обращение к платформе было — оно и не удалось, мешать его
// с «мимо платформы» значит завышать долю непроверенного.
const offPlatform = results.filter(r => r.passed && !r.skipped && !r.platformChecked);
const byReason = {};
for (const r of offPlatform) {
const key = r.noPlatformReason || 'причина не указана';
(byReason[key] = byReason[key] || []).push(`${r.skill}/${r.case}`);
}
console.log(` из них проверено платформой: ${onPlatform}; прошло без обращения к платформе: ${offPlatform.length}`);
for (const [reason, list] of Object.entries(byReason)) {
console.log(`${list.length}${reason}`);
}
const unverified = opts.strict ? skipped : 0;
if (unverified) console.error(`\n--strict: ${unverified} кейс(ов) пропущено — считаем падением`);
process.exit(failed + unverified > 0 ? 1 : 0);