fix(tests,web-test): не отдавать rmSync/cpSync пути с не-ASCII символами

На Windows fs.rmSync и fs.cpSync молча не делают ничего, когда не-ASCII символы
есть в самом аргументе пути (nodejs/node#61067). Для аудитории проекта это боевой
сценарий: кириллическое имя пользователя даёт кириллический %TEMP%, где раннер
создаёт воркспейсы, плюс кириллические имена объектов 1С в путях внутри кейсов.

Замерено на восьми сборках. Затронуты 22.18.0–22.23.2 (последняя LTS Jod) и
24.12.0–24.14.0; исправны 22.15.1, 24.15.0+, 25.x, 26.x. Фикс приехал в fs-слой
Node 24.15 и в ветку 22.x не бэкпортирован, поэтому «обновить Node» вопрос не
закрывает. Зависимость не монотонна по версиям (24.14 чинит rmSync, но не
cpSync) — гард по номеру версии невозможен, решает только сам путь.

Симптомы: rmSync молча ничего не удаляет; cpSync с не-ASCII приёмником молча
ничего не копирует; cpSync с не-ASCII источником валит процесс нативно
(0xC0000409) мимо try/catch. Не затронуты mkdirSync, readdirSync, lstatSync,
copyFileSync (включая перезапись), unlinkSync, rmdirSync — на них стоит обход.

Что ломалось: под кириллическим %TEMP% фикстуры не доезжали до воркспейсов
(meta-info — 15 ложных падений из 26, один кейс ложно-зелёный на пустом
воркспейсе); cfe-validate/module-state-flag-without-file был красным даже при
ASCII %TEMP% (кириллица в самом deletePath); --update-snapshots молча не сносил
старый эталон, то есть портил коммитимые артефакты.

Реализация — единый fsutil в двух побайтно одинаковых копиях (tests/common/ и
внутри автономного навыка web-test), раскатанный на все 42 точки вызова в 11
файлах: tests/skills/*, tests/web-test/*, hooks/test/run.mjs, движок web-test.
Предикат судит по resolve(p), а не по строке аргумента: относительный
ASCII-аргумент при не-ASCII cwd платформа роняет так же молча. Ретраи доживают
до ручного обхода, перезапись как у cpSync с force: true, настоящие ошибки не
глотаются, выживший после удаления путь — громкая ошибка.

Гард tests/skills/check-nonascii-fs.mjs в check-all.mjs проверяет хелпер, а не
платформу (поэтому зелёный и на исправной Node), сверяет хеши обеих копий и
печатает справкой состояние текущей сборки.

Co-Authored-By: androman.pro <5669019+andromanpro@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-08-22 19:27:02 +03:00
co-authored by androman.pro Claude Opus 5
parent 36bae190b2
commit 0442aa015a
16 changed files with 529 additions and 60 deletions
+116
View File
@@ -0,0 +1,116 @@
// tests/common/fsutil v1.0 — удаление и копирование, устойчивые к не-ASCII путям на Windows.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
// ─── fsutil:begin ───────────────────────────────────────────────────────────
// Ниже этого маркера файл побайтно совпадает со второй копией. Расхождение ловит
// tests/skills/check-nonascii-fs.mjs. Правку вносим в обе копии сразу.
import { cpSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync,
rmSync, rmdirSync, unlinkSync } from 'node:fs';
import { join, resolve } from 'node:path';
// На Windows fs.rmSync и fs.cpSync ломаются, когда не-ASCII символы есть в САМОМ аргументе
// пути (nodejs/node#61067, апстрим открыт). Снято на стенде из восьми сборок:
//
// сборка rmSync cpSync → приёмник cpSync ← источник
// 22.15.1 ок ок ок
// 22.18.0 … 22.23.2 (LTS Jod) ок молча ничего краш 0xC0000409
// 24.12.0, 24.13.0 молча молча ничего краш
// 24.14.0 ок молча ничего краш
// 24.15.0, 24.19.0, 25.9.0, 26.7.0 ок ок ок
//
// Это НЕ обход одного старого релиза: ветка 22 LTS сломана до сих пор — фикс приехал в
// fs-слой Node 24.15 и в 22.x не бэкпортирован, а README проекта рекомендует «Node.js 18+».
// Зависимость не монотонна по версиям (24.14 чинит rmSync, но не cpSync), поэтому гард по
// номеру версии невозможен в принципе: решает только сам путь. Снимать гард можно, лишь
// когда исправной станет вся поддерживаемая линейка, а не текущая машина разработчика.
//
// mkdirSync, readdirSync, lstatSync, existsSync, copyFileSync (включая перезапись),
// unlinkSync, rmdirSync с не-ASCII исправны на ВСЕХ сломанных сборках — на них и стоит
// ручной обход. Асинхронные fs.promises.rm/cp не затронуты.
const NON_ASCII = /[^\x00-\x7F]/;
const RETRY_CODES = new Set(['EBUSY', 'EPERM', 'ENOTEMPTY', 'EMFILE']);
// Проверяем РАЗРЕШЁННЫЙ путь, а не строку аргумента: относительный ASCII-аргумент при
// не-ASCII cwd платформа роняет так же молча, а по строке аргумента это не видно.
export function pathIsUnsafe(p) {
// Тест-шов только для check-nonascii-fs.mjs: без него ручной обход никогда не исполняется
// на macOS/Linux и остаётся непокрытым. В обычном прогоне переменная не задана.
if (process.env.CC1C_FSUTIL_FORCE_WALK === "1") return true;
return process.platform === 'win32' && NON_ASCII.test(resolve(p));
}
function sleepSync(ms) {
if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function withRetrySync(fn, maxRetries, retryDelay) {
for (let attempt = 0; ; attempt++) {
try { return fn(); }
catch (e) {
if (attempt >= maxRetries || !RETRY_CODES.has(e.code)) throw e;
sleepSync(retryDelay);
}
}
}
// Симлинк или junction на каталог: unlinkSync на нём даёт EPERM, снимается rmdirSync.
// Внутрь не заходим — rmSync тоже сносит саму ссылку, а не её цель.
function removeLeafSync(p) {
try { unlinkSync(p); }
catch (e) {
if (e.code !== 'EPERM' && e.code !== 'EISDIR') throw e;
rmdirSync(p);
}
}
function removeTreeWalkSync(dir, maxRetries, retryDelay) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const child = join(dir, entry.name);
if (entry.isDirectory()) removeTreeWalkSync(child, maxRetries, retryDelay);
else withRetrySync(() => removeLeafSync(child), maxRetries, retryDelay);
}
withRetrySync(() => rmdirSync(dir), maxRetries, retryDelay);
}
// Семантика rm -rf: файл, каталог или отсутствующий путь. opts.maxRetries / opts.retryDelay
// работают на обоих путях — на быстром их принимает сам rmSync, на ручном обходе повторяем сами.
export function removePathSync(target, opts = {}) {
const { maxRetries = 0, retryDelay = 100 } = opts;
if (!pathIsUnsafe(target)) {
rmSync(target, { recursive: true, force: true, maxRetries, retryDelay });
// Тихий отказ ровно здесь и есть болезнь: подтверждаем результат одним existsSync.
if (!existsSync(target)) return;
} else if (!existsSync(target)) {
return;
}
if (lstatSync(target).isDirectory()) removeTreeWalkSync(target, maxRetries, retryDelay);
else withRetrySync(() => removeLeafSync(target), maxRetries, retryDelay);
if (existsSync(target)) throw new Error(`Не удалось удалить путь: ${target}`);
}
function copyTreeWalkSync(src, dest) {
mkdirSync(dest, { recursive: true });
for (const entry of readdirSync(src, { withFileTypes: true })) {
const from = join(src, entry.name);
const to = join(dest, entry.name);
// Перезаписываем безусловно — как cpSync с дефолтным force: true.
if (entry.isDirectory()) copyTreeWalkSync(from, to);
else copyFileSync(from, to);
}
}
// Рекурсивная копия КАТАЛОГА с перезаписью. Для одиночного файла хватает copyFileSync:
// он не затронут и работает с не-ASCII на всех сборках.
export function copyTreeSync(src, dest) {
if (!pathIsUnsafe(src) && !pathIsUnsafe(dest)) {
// Ошибки не глотаем: настоящий ENOENT/EACCES должен долететь до вызывающего.
cpSync(src, dest, { recursive: true });
return;
}
copyTreeWalkSync(src, dest);
}
+21
View File
@@ -111,6 +111,7 @@ node tests/skills/check-inline-drift.mjs --list # реестр: семья →
| `check-form-purposes.mjs` | таблица назначений форм в `form-add`: состав видов, свойство «основная форма» и оба порта сходятся с `docs/1c-form-spec.md` |
| `check-positional-binding.mjs` | read-only навыки (`*-info` / `*-validate` / `cfe-diff`): позиционным остаётся только путь ко входу, лишний позиционный аргумент не перезаписывает указанный файл |
| `check-agent-portability.mjs` | исходники навыков не привязаны к конкретному AI-агенту: единственная разрешённая форма — плейсхолдер `${CLAUDE_SKILL_DIR}/`, который разворачивает `scripts/switch.py` |
| `check-nonascii-fs.mjs` | `fsutil`: удаление и копирование держат не-ASCII пути (кириллический `%TEMP%`, кириллические имена объектов 1С), обе копии модуля не разошлись |
`check-inline-drift.mjs` держит реестр семей внутри себя: у каждой семьи перечислены варианты, у
варианта — навык-эталон и список копий. Отклоняющийся вариант обязан иметь обоснование (`why`),
@@ -119,6 +120,24 @@ node tests/skills/check-inline-drift.mjs --list # реестр: семья →
Правишь общую утилиту — правишь эталон, копируешь во всех потребителей варианта, прогоняешь гард.
`check-nonascii-fs.mjs` держит `tests/common/fsutil.mjs` — единственное место, где тесты, хуки и
движок `web-test` удаляют и копируют файлы. На Windows `fs.rmSync` и `fs.cpSync` **молча** ничего
не делают, если не-ASCII символы есть в самом аргументе пути ([nodejs/node#61067][nafs]): фикстура
не доезжает до воркспейса, эталон не затирается перед `--update-snapshots`, `deletePath` не
срабатывает на объекте с кириллическим именем. Снапшоты этого не ловят — они видят только
последствия. Дыра открыта не в одной старой сборке: линейка 22 LTS не починена до сих пор, а
зависимость не монотонна по версиям (24.14 чинит `rmSync`, но не `cpSync`), поэтому гард по номеру
версии невозможен — решает только сам путь. Таблица замеров лежит в шапке модуля.
Гард проверяет наш хелпер, а не платформу, поэтому зелёный и на исправной Node; состояние текущей
сборки он печатает справочно. Две копии модуля (`tests/common/` и внутри навыка `web-test`, который
обязан быть автономным) сверяются по хешу тела ниже маркера `// ─── fsutil:begin`.
Дорабатываешь гард — помни: `cpSync` из не-ASCII **источника** на сломанных сборках валит процесс
нативно (`0xC0000409`) мимо `try/catch`. Пробовать платформу этим вариантом нельзя, только тихими.
[nafs]: https://github.com/nodejs/node/issues/61067
`check-format-versions.mjs` держит границы проверенного диапазона версий формата выгрузки. Раньше
допустимый список версий был независимым литералом в каждом валидаторе, и сверять его было не с
чем: волна 2.21 прошла по четырём валидаторам и молча обошла пятый — `form-validate` остался на
@@ -500,6 +519,8 @@ node tests/skills/runner.mjs --update-snapshots # все к
## Структура
```
tests/common/
fsutil.mjs # удаление и копирование, устойчивые к не-ASCII путям (см. гарды)
tests/skills/
runner.mjs # тест-раннер (snapshot-сравнение + интеграционные)
verify-snapshots.mjs # платформенная верификация снапшотов
+8 -5
View File
@@ -14,8 +14,11 @@
// После завершения база готова к /web-publish + web-test сессии.
import { execFile } from 'child_process';
import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'fs';
import { existsSync, mkdirSync, unlinkSync, readFileSync, writeFileSync } from 'fs';
import { join, resolve, dirname } from 'path';
// fs.rmSync/fs.cpSync напрямую не зовём: на Windows они молча ничего не делают,
// когда в пути есть не-ASCII символы. Подробности и таблица сборок — в самом модуле.
import { removePathSync } from '../common/fsutil.mjs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
@@ -135,11 +138,11 @@ export async function runSteps(steps, paths, runtime, log = console.log) {
try {
await execSkill(script, args, runtime);
if (inputFile && existsSync(inputFile)) rmSync(inputFile);
if (inputFile && existsSync(inputFile)) unlinkSync(inputFile);
const ms = Date.now() - stepT0;
log(` [${i + 1}/${steps.length}] OK ${step.name} (${(ms / 1000).toFixed(1)}s)`);
} catch (e) {
if (inputFile && existsSync(inputFile)) rmSync(inputFile);
if (inputFile && existsSync(inputFile)) unlinkSync(inputFile);
log(` [${i + 1}/${steps.length}] FAIL ${step.name}`);
log(` ${e.message.split('\n').join('\n ').substring(0, 1500)}`);
return { ok: false, elapsed: (Date.now() - t0) / 1000, failedAt: i };
@@ -208,13 +211,13 @@ async function runCli() {
if (existsSync(configSrc)) {
console.log(`Removing existing configSrc...`);
rmSync(configSrc, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
removePathSync(configSrc, { maxRetries: 5, retryDelay: 200 });
}
mkdirSync(configSrc, { recursive: true });
if (!opts.skipPlatform && existsSync(dbPath)) {
console.log(`Removing existing IB...`);
rmSync(dbPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
removePathSync(dbPath, { maxRetries: 5, retryDelay: 200 });
}
const buildSteps = await loadBuildSteps();
+1
View File
@@ -17,6 +17,7 @@ const GUARDS = [
['check-form-purposes.mjs', 'назначения форм в form-add: согласованы со спецификацией и между портами'],
['check-positional-binding.mjs', 'read-only навыки: позиционным остаётся только путь ко входу'],
['check-agent-portability.mjs', 'исходники навыков: без привязки к конкретному AI-агенту'],
['check-nonascii-fs.mjs', 'fsutil: удаление и копирование держат не-ASCII пути, копии не разошлись'],
];
let failed = 0;
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env node
// Гард хелпера tests/common/fsutil.mjs: удаление и копирование обязаны работать, когда в пути
// есть не-ASCII символы. На Windows fs.rmSync/fs.cpSync в такой ситуации МОЛЧА не делают ничего
// (nodejs/node#61067) — снапшот-тесты этого не ловят, они видят лишь последствия: «фикстура не
// доехала», «эталон не обновился», утёкшие воркспейсы.
//
// Гард проверяет НАШ хелпер, а не платформу, поэтому зелёный и на исправной сборке Node —
// именно это защищает его от гниения. Состояние самой платформы печатается справочно.
//
// Ловушка при доработке: cpSync из не-ASCII ИСТОЧНИКА на сломанных сборках валит процесс
// нативно (0xC0000409) мимо try/catch. Пробовать платформу этим вариантом нельзя — только
// тихими (rmSync и cpSync в не-ASCII приёмник).
//
// Выход 1 при нарушении. Запуск: node tests/skills/check-nonascii-fs.mjs
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync,
rmdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const IS_WIN = process.platform === 'win32';
// Ручной обход исполняется только на не-ASCII путях, а на macOS/Linux их не бывает по природе
// гарда. Шов включает обход на любой ОС — иначе половина кода была бы покрыта только на Windows.
process.env.CC1C_FSUTIL_FORCE_WALK = '1';
const { removePathSync, copyTreeSync } = await import('../common/fsutil.mjs');
let failures = 0;
const fail = (msg) => { console.log(` x ${msg}`); failures++; };
const pass = (msg) => console.log(` + ${msg}`);
const check = (cond, msg) => cond ? pass(msg) : fail(msg);
// Уборка сознательно НЕ через хелпер: он здесь подопытный, его отказ не должен маскироваться.
function nuke(p) {
if (!existsSync(p)) return;
for (const e of readdirSync(p, { withFileTypes: true })) {
const c = join(p, e.name);
if (e.isDirectory()) nuke(c); else { try { unlinkSync(c); } catch { rmdirSync(c); } }
}
rmdirSync(p);
}
const ROOTDIR = mkdtempSync(join(tmpdir(), 'nonascii-fs-'));
const CYR = join(ROOTDIR, 'Каталог Ы');
mkdirSync(CYR, { recursive: true });
const cwd0 = process.cwd();
// --- 1. Предикат -----------------------------------------------------------
console.log('\nПредикат pathIsUnsafe');
{
delete process.env.CC1C_FSUTIL_FORCE_WALK;
const mod = await import('../common/fsutil.mjs?noseam=1');
process.chdir(CYR);
// Главная ловушка: относительный ASCII-аргумент при не-ASCII cwd платформа роняет так же
// молча, поэтому судить по строке аргумента нельзя — только по разрешённому пути.
check(mod.pathIsUnsafe('жертва') === IS_WIN, 'не-ASCII в самом аргументе');
check(mod.pathIsUnsafe('victim') === IS_WIN, 'относительный ASCII-аргумент при не-ASCII cwd');
process.chdir(cwd0);
check(mod.pathIsUnsafe(join(ROOTDIR, 'plain')) === false, 'полностью ASCII-путь — быстрый путь');
process.env.CC1C_FSUTIL_FORCE_WALK = '1';
}
// --- 2. removePathSync -----------------------------------------------------
console.log('\nremovePathSync');
{
removePathSync(join(CYR, 'нет-такого'));
pass('отсутствующий путь — не ошибка (семантика force)');
const f = join(CYR, 'Файл.txt');
writeFileSync(f, 'x');
removePathSync(f);
check(!existsSync(f), 'одиночный файл');
const tree = join(CYR, 'Дерево');
mkdirSync(join(tree, 'Вложенный', 'Глубже'), { recursive: true });
writeFileSync(join(tree, 'Вложенный', 'Данные.xml'), 'x');
writeFileSync(join(tree, 'Вложенный', 'Глубже', 'plain.txt'), 'x');
removePathSync(tree);
check(!existsSync(tree), 'вложенное дерево с не-ASCII именами');
const rel = join(CYR, 'Отн');
mkdirSync(join(rel, 'victim'), { recursive: true });
writeFileSync(join(rel, 'victim', 'f.txt'), 'x');
process.chdir(rel);
removePathSync('victim');
process.chdir(cwd0);
check(!existsSync(join(rel, 'victim')), 'относительный ASCII-аргумент при не-ASCII cwd');
// Ретраи обязаны дожить до ручного обхода: если они теряются, под не-ASCII %TEMP%
// воркспейсы утекают ровно так же, как без гарда вообще.
if (IS_WIN) {
const busy = join(CYR, 'Занятый');
mkdirSync(busy, { recursive: true });
process.chdir(busy); // rmdir собственного cwd на Windows даёт EBUSY детерминированно
const t0 = Date.now();
let threw = false;
try { removePathSync(busy, { maxRetries: 3, retryDelay: 40 }); } catch { threw = true; }
const spent = Date.now() - t0;
process.chdir(cwd0);
check(threw, 'занятый каталог — отказ громкий, не тихий');
check(spent >= 120, `ретраи дожили до ручного обхода (ждали >=120 мс, потрачено ${spent} мс)`);
rmdirSync(busy);
} else {
console.log(' o ретраи: пропуск — детерминированный EBUSY есть только на Windows');
}
}
// --- 3. copyTreeSync -------------------------------------------------------
console.log('\ncopyTreeSync');
{
const src = join(CYR, 'Источник');
mkdirSync(join(src, 'Languages'), { recursive: true });
writeFileSync(join(src, 'Languages', 'Русский.xml'), 'RU');
writeFileSync(join(src, 'Configuration.xml'), 'CFG');
const dst = join(CYR, 'Приёмник');
copyTreeSync(src, dst);
check(readFileSync(join(dst, 'Configuration.xml'), 'utf8') === 'CFG'
&& readFileSync(join(dst, 'Languages', 'Русский.xml'), 'utf8') === 'RU',
'копия из не-ASCII источника в не-ASCII приёмник');
// Семантика cpSync — force: true. Обход, который не перезатирает, ломает её незаметно.
writeFileSync(join(dst, 'Configuration.xml'), 'СТАРОЕ');
copyTreeSync(src, dst);
check(readFileSync(join(dst, 'Configuration.xml'), 'utf8') === 'CFG',
'существующий файл перезаписывается (как cpSync с force: true)');
// Настоящая ошибка обязана долететь до вызывающего, а не раствориться в обходе.
let threw = false;
try { copyTreeSync(join(CYR, 'нет-источника'), join(CYR, 'куда')); } catch { threw = true; }
check(threw, 'отсутствующий источник — ошибка не проглочена');
}
// --- 4. Анти-дрейф копий ---------------------------------------------------
console.log('\nКопии fsutil совпадают');
{
const COPIES = [
'tests/common/fsutil.mjs',
'.claude/skills/web-test/scripts/engine/core/fsutil.mjs',
];
const MARKER = '// ─── fsutil:begin';
const hashes = COPIES.map((rel) => {
const text = readFileSync(join(ROOT, rel), 'utf8');
const at = text.indexOf(MARKER);
if (at < 0) { fail(`${rel}: нет маркера «${MARKER}»`); return null; }
return createHash('sha256').update(text.slice(at)).digest('hex');
});
if (hashes.every(Boolean)) {
const same = new Set(hashes).size === 1;
check(same, `тело ниже маркера идентично в ${COPIES.length} копиях (${hashes[0].slice(0, 12)})`);
if (!same) COPIES.forEach((c, i) => console.log(` ${hashes[i].slice(0, 12)} ${c}`));
}
}
// --- 5. Справка о самой платформе ------------------------------------------
// Только тихие варианты. cpSync из не-ASCII источника не трогаем — он валит процесс.
console.log('\nСостояние этой сборки Node (справочно, на вердикт не влияет)');
{
const probe = join(CYR, 'Проба');
mkdirSync(probe, { recursive: true });
writeFileSync(join(probe, 'f.txt'), 'x');
let rmBroken = false;
let cpBroken = false;
try { rmSync(join(probe, 'f.txt'), { force: true }); rmBroken = existsSync(join(probe, 'f.txt')); } catch {}
const asciiSrc = join(ROOTDIR, 'ascii-src');
mkdirSync(asciiSrc, { recursive: true });
writeFileSync(join(asciiSrc, 'f.txt'), 'x');
const cyrDst = join(CYR, 'Проба-приёмник');
try { cpSync(asciiSrc, cyrDst, { recursive: true }); cpBroken = !existsSync(join(cyrDst, 'f.txt')); }
catch { cpBroken = true; }
const verdict = rmBroken || cpBroken
? `ЗАТРОНУТА (rmSync: ${rmBroken ? 'молчит' : 'ок'}, cpSync: ${cpBroken ? 'молчит' : 'ок'}) — гард здесь несёт нагрузку`
: 'чистая — гард работает вхолостую, и это нормально';
console.log(` ${process.version} на ${process.platform}: ${verdict}`);
}
nuke(ROOTDIR);
console.log('');
if (failures) console.log(`${failures} НАРУШЕНИЙ: хелпер не держит не-ASCII пути.`);
else console.log('OK — fsutil держит не-ASCII пути, копии не разошлись');
process.exit(failures ? 1 : 0);
+5 -2
View File
@@ -25,10 +25,13 @@
// Mandatory=$false, связывание пройдёт и ТЕЛО ВЫПОЛНИТСЯ — гард остановил бы Apache и запустил
// 1С. Выход 1 при нарушении. Запуск: node tests/skills/check-positional-binding.mjs [--runtime python]
import { spawnSync } from 'node:child_process';
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdtempSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
// fs.rmSync/fs.cpSync напрямую не зовём: на Windows они молча ничего не делают,
// когда в пути есть не-ASCII символы. Подробности и таблица сборок — в самом модуле.
import { removePathSync } from '../common/fsutil.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const SKILLS_DIR = join(ROOT, '.claude', 'skills');
@@ -163,7 +166,7 @@ try {
}
}
} finally {
rmSync(work, { recursive: true, force: true });
removePathSync(work);
}
// --- Итог ---
+5 -2
View File
@@ -8,10 +8,13 @@
// свойства+свойства объекта+ТЧ+add+remove), сверяет что uuid существующих сущностей целы.
// Прогоняет оба рантайма. Выход 1 при нарушении. Запуск: node tests/skills/check-uuid-invariant.mjs [--runtime python]
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync, mkdtempSync, rmSync, mkdirSync } from 'node:fs';
import { readFileSync, writeFileSync, mkdtempSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
// fs.rmSync/fs.cpSync напрямую не зовём: на Windows они молча ничего не делают,
// когда в пути есть не-ASCII символы. Подробности и таблица сборок — в самом модуле.
import { removePathSync } from '../common/fsutil.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const IS_WIN = process.platform === 'win32';
@@ -132,7 +135,7 @@ for (const runtime of runtimes) {
}
runErrors++;
} finally {
if (work) try { rmSync(work, { recursive: true, force: true }); } catch {}
if (work) try { removePathSync(work); } catch {}
}
}
+23 -17
View File
@@ -3,11 +3,15 @@
// Usage: node tests/skills/runner.mjs [filter] [--update-snapshots] [--runtime python] [--json report.json] [--concurrency N] [--with-validation]
import { execFileSync, execFile } from 'child_process';
import { existsSync, mkdirSync, mkdtempSync, rmSync, readFileSync, writeFileSync,
readdirSync, statSync, cpSync, copyFileSync, chmodSync } from 'fs';
import { existsSync, mkdirSync, mkdtempSync, unlinkSync, readFileSync, writeFileSync,
readdirSync, statSync, copyFileSync, chmodSync } from 'fs';
import { createHash } from 'crypto';
import { join, resolve, dirname, relative, basename, extname } from 'path';
import { tmpdir, cpus } from 'os';
// fs.rmSync/fs.cpSync напрямую не зовём: на Windows они молча ничего не делают, когда в пути
// есть не-ASCII символы — кириллическое имя пользователя в %TEMP%, кириллическое имя объекта 1С
// в deletePath. Подробности и таблица сборок — в самом модуле.
import { removePathSync, copyTreeSync } from '../common/fsutil.mjs';
// ─── Paths ──────────────────────────────────────────────────────────────────
@@ -158,7 +162,7 @@ function ensureSetup(setupName, runtime, skillCasesDir) {
const want = fixtureStamp(EMPTY_CONFIGS[setupName]);
if (existsSync(cached)) {
if (existsSync(stamp) && readFileSync(stamp, 'utf8') === want) return cached;
rmSync(cached, { recursive: true, force: true });
removePathSync(cached);
}
mkdirSync(cached, { recursive: true });
@@ -167,7 +171,9 @@ function ensureSetup(setupName, runtime, skillCasesDir) {
execSkillRaw(runtime, script, ['-Name', 'TestConfig', '-OutputDir', cached, ...EMPTY_CONFIGS[setupName]]);
writeFileSync(stamp, want, 'utf8');
} catch (e) {
rmSync(cached, { recursive: true, force: true });
// Недоснятая фикстура, оставшаяся на диске, молча уехала бы в следующий прогон.
try { removePathSync(cached); }
catch (cleanupError) { console.warn(`Warning: failed to remove partial fixture ${cached}: ${cleanupError.message}`); }
throw new Error(`Failed to create ${setupName} fixture: ${e.message}`);
}
return cached;
@@ -249,7 +255,7 @@ function createWorkspace(fixturePath, readOnly) {
}
const tmp = mkdtempSync(join(tmpdir(), 'skill-test-'));
if (fixturePath) {
cpSync(fixturePath, tmp, { recursive: true });
copyTreeSync(fixturePath, tmp);
}
return { path: tmp, readOnly: false };
}
@@ -257,10 +263,10 @@ function createWorkspace(fixturePath, readOnly) {
function cleanupWorkspace(ws) {
if (ws.readOnly) return;
// On Windows, file handles from db-update (1cv8) may linger briefly after the
// process exits — rmSync then throws EBUSY. Retry a few times, then swallow:
// process exits — removal then throws EBUSY. Retry a few times, then swallow:
// a leaked tmp dir is preferable to crashing the entire runner.
try {
rmSync(ws.path, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
removePathSync(ws.path, { maxRetries: 10, retryDelay: 200 });
} catch (e) {
console.warn(`Warning: failed to clean workspace ${ws.path}: ${e.message}`);
}
@@ -664,8 +670,8 @@ function updateSnapshot(workDir, snapshotDir, snapshotConfig, caseData) {
// дорисовал бы эталон и сам породил противоречие с opt-out.
if (caseData?.noSnapshot) return;
// Remove old snapshot
if (existsSync(snapshotDir)) rmSync(snapshotDir, { recursive: true, force: true });
// Remove old snapshot. Тихий отказ здесь оставил бы в эталоне стейл — и он уехал бы в коммит.
if (existsSync(snapshotDir)) removePathSync(snapshotDir);
// Determine which files to snapshot — all files in workDir that were created by the skill
// For "workDir" root mode, we need to figure out what files the skill added.
@@ -798,7 +804,7 @@ async function runCaseAsync(testCase, opts) {
// deletePath step — убрать файл или каталог из workDir: так выражается состояние,
// которое навыки сами не создают (например, пометка свойства без файла модуля).
if (step.deletePath) {
rmSync(join(workDir, step.deletePath), { recursive: true, force: true });
removePathSync(join(workDir, step.deletePath));
continue;
}
const preScript = resolveScript(step.script, opts.runtime);
@@ -822,7 +828,7 @@ async function runCaseAsync(testCase, opts) {
} catch (e) {
throw new Error(`preRun step "${step.script}" failed: ${e.stderr || e.message}`);
}
if (preInputFile && existsSync(preInputFile)) rmSync(preInputFile);
if (preInputFile && existsSync(preInputFile)) unlinkSync(preInputFile);
}
}
@@ -849,7 +855,7 @@ async function runCaseAsync(testCase, opts) {
stderr = e.stderr || '';
}
if (inputFile && existsSync(inputFile)) rmSync(inputFile);
if (inputFile && existsSync(inputFile)) unlinkSync(inputFile);
// Assertions
const errors = [];
@@ -1049,7 +1055,7 @@ function runCase(testCase, opts) {
} catch (e) {
throw new Error(`preRun step "${step.script}" failed: ${e.stderr || e.message}`);
}
if (preInputFile && existsSync(preInputFile)) rmSync(preInputFile);
if (preInputFile && existsSync(preInputFile)) unlinkSync(preInputFile);
}
}
@@ -1076,7 +1082,7 @@ function runCase(testCase, opts) {
}
// Remove temp input file from workDir before snapshot comparison
if (inputFile && existsSync(inputFile)) rmSync(inputFile);
if (inputFile && existsSync(inputFile)) unlinkSync(inputFile);
// 4. Assertions
const errors = [];
@@ -1520,7 +1526,7 @@ async function runIntegrationOnce(test, opts, engine, labelEngine) {
break; // stop on first failure
}
if (inputFile && existsSync(inputFile)) rmSync(inputFile);
if (inputFile && existsSync(inputFile)) unlinkSync(inputFile);
// Post-step validation
if (opts.withValidation && step.validate) {
@@ -1546,8 +1552,8 @@ async function runIntegrationOnce(test, opts, engine, labelEngine) {
// Cache result if configured
if (test.cache && stepResults.every(s => s.passed)) {
const cachePath = join(CACHE, test.cache);
if (existsSync(cachePath)) rmSync(cachePath, { recursive: true, force: true });
cpSync(workDir, cachePath, { recursive: true });
if (existsSync(cachePath)) removePathSync(cachePath);
copyTreeSync(workDir, cachePath);
}
const allPassed = stepResults.every(s => s.passed);
+14 -11
View File
@@ -12,10 +12,13 @@
// типовой конфигурации (~3 мин на кейс, ноль информации). Такие гонять через --case.
import { execFileSync } from 'child_process';
import { existsSync, mkdirSync, mkdtempSync, rmSync, readFileSync, writeFileSync,
readdirSync, statSync, cpSync, copyFileSync, chmodSync } from 'fs';
import { existsSync, mkdirSync, mkdtempSync, unlinkSync, readFileSync, writeFileSync,
readdirSync, statSync, copyFileSync, chmodSync } from 'fs';
import { join, resolve, dirname, basename } from 'path';
import { tmpdir } from 'os';
// fs.rmSync/fs.cpSync напрямую не зовём: на Windows они молча ничего не делают, когда в пути
// есть не-ASCII символы. Подробности и таблица сборок — в самом модуле.
import { removePathSync, copyTreeSync } from '../common/fsutil.mjs';
// ─── Paths ──────────────────────────────────────────────────────────────────
@@ -638,7 +641,7 @@ function runPreSteps(preRun, workDir, runtime, log) {
// deletePath step — как в runner.mjs: состояние «файла нет» выражается удалением.
// Без этой ветки шаг проваливался в запуск скрипта и падал на step.script.split.
if (step.deletePath) {
rmSync(join(workDir, step.deletePath), { recursive: true, force: true });
removePathSync(join(workDir, step.deletePath));
log(`preRun: deletePath ${step.deletePath}`, true);
continue;
}
@@ -670,7 +673,7 @@ function runPreSteps(preRun, workDir, runtime, log) {
log(`preRun: ${stepName}`, false, e.stderr || e.message);
throw new Error(`preRun "${step.script}" failed: ${(e.stderr || e.message).substring(0, 500)}`);
}
if (preInputFile && existsSync(preInputFile)) rmSync(preInputFile);
if (preInputFile && existsSync(preInputFile)) unlinkSync(preInputFile);
}
}
@@ -909,7 +912,7 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) {
result.errors.push(`Fixture not found: ${fixturePath}`);
return result;
}
cpSync(fixturePath, workDir, { recursive: true });
copyTreeSync(fixturePath, workDir);
log(`fixture: ${fixtureName}`, true);
// Фикстура-конфигурация — такой же вход для платформы, как external-выгрузка. Раньше
// configDir приходил только из скилл-уровневого setup (empty-config), поэтому у навыков
@@ -926,7 +929,7 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) {
result.skipReason = `внешняя выгрузка недоступна на этой машине: ${extPath}`;
return result;
}
cpSync(extPath, workDir, { recursive: true });
copyTreeSync(extPath, workDir);
log(`external: ${extPath}`, true);
configDir = workDir;
}
@@ -1086,7 +1089,7 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) {
result.errors.push(`${skillName} failed: ${detail.substring(0, 500)}`);
return result;
}
if (inputFile && existsSync(inputFile)) rmSync(inputFile);
if (inputFile && existsSync(inputFile)) unlinkSync(inputFile);
// Режим совместимости конфигурации выше платформы — она такую не загрузит. Это свойство
// стенда, а не дефект кейса, поэтому пропускаем с причиной: иначе на машине без нужной
@@ -1133,7 +1136,7 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) {
return result;
}
const dcsTpl = join(erfDir, 'TestReport', 'Templates', 'ОсновнаяСхемаКомпоновкиДанных', 'Ext', 'Template.xml');
cpSync(tplPath, dcsTpl, { force: true });
copyFileSync(tplPath, dcsTpl);
try {
execSkill(opts.runtime, 'epf-build/scripts/epf-build', [
'-V8Path', opts.v8ctx.v8path,
@@ -1186,7 +1189,7 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) {
return result;
}
const tplDest = join(epfDir, 'TestProc', 'Templates', 'Макет', 'Ext', 'Template.xml');
cpSync(tplPath, tplDest, { force: true });
copyFileSync(tplPath, tplDest);
try {
execSkill(opts.runtime, 'epf-build/scripts/epf-build', [
'-V8Path', opts.v8ctx.v8path,
@@ -1531,8 +1534,8 @@ async function verifyCase(skillName, caseName, skillConfig, caseData, opts) {
result.errors.push(`Unexpected error: ${e.message}`);
} finally {
if (!opts.keep) {
try { rmSync(workDir, { recursive: true, force: true }); } catch {}
result.workDir = '(cleaned)';
// При неудаче оставляем в result реальный путь: остаток каталога виден в отчёте.
try { removePathSync(workDir); result.workDir = '(cleaned)'; } catch {}
}
}
+5 -2
View File
@@ -9,10 +9,13 @@
//
// Exit codes: 0 — the abort machinery works; 1 — it regressed; 2 — inconclusive (stand down).
import { spawn } from 'child_process';
import { existsSync, readdirSync, readFileSync, rmSync, mkdirSync } from 'fs';
import { existsSync, readdirSync, readFileSync, mkdirSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { tmpdir } from 'os';
// fs.rmSync/fs.cpSync are never called directly: on Windows they silently do nothing when
// the path argument contains non-ASCII characters. Build matrix and details live in the module.
import { removePathSync } from '../../common/fsutil.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(__dirname, '../../..');
@@ -124,6 +127,6 @@ if (failed.length) {
console.log(out.trim());
process.exit(1);
}
try { rmSync(reportDir, { recursive: true, force: true }); } catch {}
try { removePathSync(reportDir); } catch {}
console.log(`OK: ${checks.length}/${checks.length} — таймаут прерывает зависший тест, прогон продолжается, отчёт пишется`);
process.exit(0);
+9 -6
View File
@@ -23,10 +23,13 @@
// Cross-platform: на не-Windows можно задать env WEBTEST_HOOKS_RUNTIME=python,
// тогда зеркальные py-порты скиллов будут вызваны вместо ps1.
import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync, statSync } from 'fs';
import { existsSync, mkdirSync, unlinkSync, readFileSync, writeFileSync, statSync } from 'fs';
import { join, resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { createHash } from 'crypto';
// fs.rmSync/fs.cpSync напрямую не зовём: на Windows они молча ничего не делают,
// когда в пути есть не-ASCII символы. Подробности и таблица сборок — в самом модуле.
import { removePathSync } from '../common/fsutil.mjs';
import {
getProjectInfo,
loadBuildSteps,
@@ -162,8 +165,8 @@ async function buildEpf(spec, log) {
const formXml = join(formDir, 'Ext/Form.xml');
// Полный rebuild: чистим и собираем заново.
if (existsSync(srcDir)) rmSync(srcDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
if (existsSync(buildDir)) rmSync(buildDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
if (existsSync(srcDir)) removePathSync(srcDir, { maxRetries: 5, retryDelay: 200 });
if (existsSync(buildDir)) removePathSync(buildDir, { maxRetries: 5, retryDelay: 200 });
mkdirSync(srcDir, { recursive: true });
mkdirSync(buildDir, { recursive: true });
@@ -191,7 +194,7 @@ async function buildEpf(spec, log) {
['-JsonPath', formJsonPath, '-OutputPath', formXml],
RUNTIME,
);
rmSync(formJsonPath);
unlinkSync(formJsonPath);
log('form-compile OK');
// 4. epf-build
@@ -246,7 +249,7 @@ export async function prepare({ hookArgs, log, config }) {
// 2. Config rebuild
if (needConfig) {
log(`rebuild config XML → ${configSrc}`);
if (existsSync(configSrc)) rmSync(configSrc, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
if (existsSync(configSrc)) removePathSync(configSrc, { maxRetries: 5, retryDelay: 200 });
mkdirSync(configSrc, { recursive: true });
const paths = { workDir: configSrc, v8path, dbPath };
const r = await runSteps(buildSteps, paths, RUNTIME, log);
@@ -257,7 +260,7 @@ export async function prepare({ hookArgs, log, config }) {
// 3. DB reload
if (needData) {
log(`reload DB → ${dbPath}`);
if (existsSync(dbPath)) rmSync(dbPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
if (existsSync(dbPath)) removePathSync(dbPath, { maxRetries: 5, retryDelay: 200 });
const paths = { workDir: configSrc, v8path, dbPath };
const r = await runSteps(platformLoadSteps(), paths, RUNTIME, log);
if (!r.ok) throw new Error(`DB reload failed at step #${r.failedAt + 1}`);
+6 -3
View File
@@ -8,10 +8,13 @@
// node tests/web-test/_suite-root/check.mjs
//
// Exit codes: 0 — resolver behaves; 1 — a rule regressed.
import { mkdirSync, writeFileSync, rmSync } from 'fs';
import { mkdirSync, writeFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { tmpdir } from 'os';
// fs.rmSync/fs.cpSync are never called directly: on Windows they silently do nothing when
// the path argument contains non-ASCII characters. Build matrix and details live in the module.
import { removePathSync } from '../../common/fsutil.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(__dirname, '../../..');
@@ -20,7 +23,7 @@ const { findSuiteRoot } = await import(
);
const BASE = resolve(tmpdir(), 'web-test-suite-root-check');
rmSync(BASE, { recursive: true, force: true });
removePathSync(BASE);
/** Build a tree from a list of relative paths; a path ending in `/` is a dir, otherwise a file. */
function tree(name, entries) {
@@ -114,6 +117,6 @@ const rootOf = (r) => (r ? r.root : null);
check('repo: tests/web-test/ → itself', rootOf(findSuiteRoot(suite, { cwd: REPO })), suite);
}
rmSync(BASE, { recursive: true, force: true });
removePathSync(BASE);
console.log(failed ? `\n${failed} check(s) FAILED\n` : '\nall checks passed\n');
process.exit(failed ? 1 : 0);