From 0442aa015a0b9b8d08047ca79e8362a3d786dbeb Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sat, 22 Aug 2026 19:27:02 +0300 Subject: [PATCH] =?UTF-8?q?fix(tests,web-test):=20=D0=BD=D0=B5=20=D0=BE?= =?UTF-8?q?=D1=82=D0=B4=D0=B0=D0=B2=D0=B0=D1=82=D1=8C=20rmSync/cpSync=20?= =?UTF-8?q?=D0=BF=D1=83=D1=82=D0=B8=20=D1=81=20=D0=BD=D0=B5-ASCII=20=D1=81?= =?UTF-8?q?=D0=B8=D0=BC=D0=B2=D0=BE=D0=BB=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На 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) --- .../web-test/scripts/engine/core/fsutil.mjs | 116 +++++++++++ .../web-test/scripts/engine/core/session.mjs | 11 +- .../scripts/engine/recording/narration.mjs | 7 +- hooks/test/run.mjs | 10 +- tests/common/fsutil.mjs | 116 +++++++++++ tests/skills/README.md | 21 ++ tests/skills/build-webtest-db.mjs | 13 +- tests/skills/check-all.mjs | 1 + tests/skills/check-nonascii-fs.mjs | 184 ++++++++++++++++++ tests/skills/check-positional-binding.mjs | 7 +- tests/skills/check-uuid-invariant.mjs | 7 +- tests/skills/runner.mjs | 40 ++-- tests/skills/verify-snapshots.mjs | 25 +-- tests/web-test/_hang/check.mjs | 7 +- tests/web-test/_hooks.mjs | 15 +- tests/web-test/_suite-root/check.mjs | 9 +- 16 files changed, 529 insertions(+), 60 deletions(-) create mode 100644 .claude/skills/web-test/scripts/engine/core/fsutil.mjs create mode 100644 tests/common/fsutil.mjs create mode 100644 tests/skills/check-nonascii-fs.mjs diff --git a/.claude/skills/web-test/scripts/engine/core/fsutil.mjs b/.claude/skills/web-test/scripts/engine/core/fsutil.mjs new file mode 100644 index 000000000..ddab41985 --- /dev/null +++ b/.claude/skills/web-test/scripts/engine/core/fsutil.mjs @@ -0,0 +1,116 @@ +// web-test core/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); +} diff --git a/.claude/skills/web-test/scripts/engine/core/session.mjs b/.claude/skills/web-test/scripts/engine/core/session.mjs index 03660a7b0..9e3cdce24 100644 --- a/.claude/skills/web-test/scripts/engine/core/session.mjs +++ b/.claude/skills/web-test/scripts/engine/core/session.mjs @@ -1,10 +1,11 @@ -// web-test core/session v1.20 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry. +// web-test core/session v1.21 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { chromium } from 'playwright'; import { softDeadline } from './deadline.mjs'; -import { statSync, mkdirSync, readdirSync, rmSync } from 'fs'; +import { statSync, mkdirSync, readdirSync } from 'fs'; import { join as pathJoin } from 'path'; +import { removePathSync } from './fsutil.mjs'; import { tmpdir } from 'os'; import { browser, page, sessionPrefix, seanceId, recorder, highlightMode, @@ -271,7 +272,7 @@ export async function disconnect() { setSeanceId(null); // Clean up persistent user data dir if (persistentUserDataDir) { - try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {} + try { removePathSync(persistentUserDataDir); } catch {} setPersistentUserDataDir(null); } } @@ -696,7 +697,7 @@ export async function abortContext(name, { logoutMs = 3000, closeMs = 5000, park setActiveContextName(null); setActiveMode(null); if (persistentUserDataDir) { - try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {} + try { removePathSync(persistentUserDataDir); } catch {} setPersistentUserDataDir(null); } return out; @@ -715,7 +716,7 @@ export async function abortContext(name, { logoutMs = 3000, closeMs = 5000, park setActiveContextName(null); setActiveMode(null); if (persistentUserDataDir) { - try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {} + try { removePathSync(persistentUserDataDir); } catch {} setPersistentUserDataDir(null); } return out; diff --git a/.claude/skills/web-test/scripts/engine/recording/narration.mjs b/.claude/skills/web-test/scripts/engine/recording/narration.mjs index 6da6803b0..23b7ec7d9 100644 --- a/.claude/skills/web-test/scripts/engine/recording/narration.mjs +++ b/.claude/skills/web-test/scripts/engine/recording/narration.mjs @@ -1,9 +1,10 @@ -// web-test recording/narration v1.17 — Post-process: generate TTS audio for captions and merge with recorded video. +// web-test recording/narration v1.18 — Post-process: generate TTS audio for captions and merge with recorded video. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { execFileSync } from 'child_process'; -import { existsSync as fsExistsSync, mkdirSync, readFileSync, rmSync, statSync } from 'fs'; +import { existsSync as fsExistsSync, mkdirSync, readFileSync, statSync } from 'fs'; import { extname, join as pathJoin } from 'path'; +import { removePathSync } from '../core/fsutil.mjs'; import { tmpdir } from 'os'; import { lastCaptions, lastRecordingDuration, resolveProjectPath, @@ -191,6 +192,6 @@ export async function addNarration(videoPath, opts = {}) { } finally { // Cleanup temp directory - try { rmSync(tempDir, { recursive: true, force: true }); } catch {} + try { removePathSync(tempDir); } catch {} } } diff --git a/hooks/test/run.mjs b/hooks/test/run.mjs index e2bb77f37..a84945a35 100644 --- a/hooks/test/run.mjs +++ b/hooks/test/run.mjs @@ -9,9 +9,11 @@ import { getEditMode, getSuggesterMode } from '../common/project.mjs'; import { processInput as guard } from '../support-guard.mjs'; import { processInput as suggest } from '../skill-suggester.mjs'; import { execFileSync } from 'node:child_process'; -import { rmSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +// 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 '../../tests/common/fsutil.mjs'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; // Repo root derived from this file's own location (/hooks/test/run.mjs) so the @@ -206,10 +208,10 @@ console.log('=== skill-suggester: PostToolUse nudge ==='); { const SYNTH = join(REPO, 'test-tmp', 'hooks-synth'); const THR = join(REPO, 'test-tmp', 'hooks-throttle'); - rmSync(THR, { recursive: true, force: true }); + removePathSync(THR); mkdirSync(THR, { recursive: true }); // suggester reads skillSuggester from .v8-project.json; clear synth project file → default on - rmSync(join(SYNTH, '.v8-project.json'), { force: true }); + removePathSync(join(SYNTH, '.v8-project.json')); // sniff fixtures mkdirSync(join(SYNTH, 'Catalogs', 'Obj', 'Forms', 'F', 'Ext'), { recursive: true }); @@ -271,7 +273,7 @@ console.log('=== skill-suggester: PostToolUse nudge ==='); writeFileSync(join(SYNTH, '.v8-project.json'), JSON.stringify({ skillSuggester: 'off' })); const rOff = suggest({ tool_name: 'Read', session_id: 'F', cwd: SYNTH, tool_input: { file_path: join(SYNTH, 'Catalogs', 'Locked.xml') } }, { throttleDir: THR }); check('suggest skillSuggester=off → silent', rOff.stdout === '', rOff.stdout); - rmSync(join(SYNTH, '.v8-project.json'), { force: true }); + removePathSync(join(SYNTH, '.v8-project.json')); } console.log(`\n${fail === 0 ? 'ALL OK' : 'FAILURES'}: ${pass} passed, ${fail} failed`); diff --git a/tests/common/fsutil.mjs b/tests/common/fsutil.mjs new file mode 100644 index 000000000..a6c8feaa7 --- /dev/null +++ b/tests/common/fsutil.mjs @@ -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); +} diff --git a/tests/skills/README.md b/tests/skills/README.md index ffb9957de..4256f47cb 100644 --- a/tests/skills/README.md +++ b/tests/skills/README.md @@ -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 # платформенная верификация снапшотов diff --git a/tests/skills/build-webtest-db.mjs b/tests/skills/build-webtest-db.mjs index addfd7d6d..1a17e4cce 100644 --- a/tests/skills/build-webtest-db.mjs +++ b/tests/skills/build-webtest-db.mjs @@ -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(); diff --git a/tests/skills/check-all.mjs b/tests/skills/check-all.mjs index e9e16bcf0..a7fa36dcb 100644 --- a/tests/skills/check-all.mjs +++ b/tests/skills/check-all.mjs @@ -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; diff --git a/tests/skills/check-nonascii-fs.mjs b/tests/skills/check-nonascii-fs.mjs new file mode 100644 index 000000000..fdbc87fd2 --- /dev/null +++ b/tests/skills/check-nonascii-fs.mjs @@ -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); diff --git a/tests/skills/check-positional-binding.mjs b/tests/skills/check-positional-binding.mjs index a363525ff..de2cd2ae2 100644 --- a/tests/skills/check-positional-binding.mjs +++ b/tests/skills/check-positional-binding.mjs @@ -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); } // --- Итог --- diff --git a/tests/skills/check-uuid-invariant.mjs b/tests/skills/check-uuid-invariant.mjs index 5056587e0..3c235d660 100644 --- a/tests/skills/check-uuid-invariant.mjs +++ b/tests/skills/check-uuid-invariant.mjs @@ -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 {} } } diff --git a/tests/skills/runner.mjs b/tests/skills/runner.mjs index af01efb08..bc47b88db 100644 --- a/tests/skills/runner.mjs +++ b/tests/skills/runner.mjs @@ -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); diff --git a/tests/skills/verify-snapshots.mjs b/tests/skills/verify-snapshots.mjs index 75a0b2c48..914d399f2 100644 --- a/tests/skills/verify-snapshots.mjs +++ b/tests/skills/verify-snapshots.mjs @@ -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 {} } } diff --git a/tests/web-test/_hang/check.mjs b/tests/web-test/_hang/check.mjs index 7f6f5072e..5f603b282 100644 --- a/tests/web-test/_hang/check.mjs +++ b/tests/web-test/_hang/check.mjs @@ -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); diff --git a/tests/web-test/_hooks.mjs b/tests/web-test/_hooks.mjs index 212a73bdf..240a1e6a6 100644 --- a/tests/web-test/_hooks.mjs +++ b/tests/web-test/_hooks.mjs @@ -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}`); diff --git a/tests/web-test/_suite-root/check.mjs b/tests/web-test/_suite-root/check.mjs index bd0c2b850..59b5a5593 100644 --- a/tests/web-test/_suite-root/check.mjs +++ b/tests/web-test/_suite-root/check.mjs @@ -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);