diff --git a/.claude/skills/web-test/regress.md b/.claude/skills/web-test/regress.md
index 9869aacc..c0cc8abe 100644
--- a/.claude/skills/web-test/regress.md
+++ b/.claude/skills/web-test/regress.md
@@ -10,6 +10,8 @@ node $RUN test
... [flags]
Positional args are test paths (files and/or dirs, multiple allowed). URL is NOT positional — it comes from `webtest.config.mjs`; override with `--url=`.
+`webtest.config.mjs` and `_hooks.mjs` are looked up by walking UP from the given path to the nearest directory holding either of them — the suite root. So `test tests/myapp/sales/` and `test tests/myapp/sales/01-order.test.mjs` work without `--url=`, taking the config and the hooks of `tests/myapp/`. The climb stops at a directory with `.git` / `.v8-project.json`, otherwise at the working directory. Paths from different suites in one run are refused — their config and hooks would be ambiguous.
+
Tests live next to the project they cover (not inside the skill). Convention: `tests/` at the project root, with `_hooks.mjs` and `webtest.config.mjs` at the suite root. Tests are ES modules with `*.test.mjs` suffix.
## When to choose `test` over `exec`
@@ -69,7 +71,7 @@ tests//
01-end-to-end.test.mjs # multi-user
```
-Per-folder `_hooks.mjs` / `webtest.config.mjs` inside the application subfolder are NOT supported — only the application-root copies are loaded.
+Per-folder `_hooks.mjs` / `webtest.config.mjs` inside the application subfolder are NOT supported — only the application-root copies are loaded, whichever subfolder you point the runner at. (A nested folder that DOES carry its own `webtest.config.mjs` is not a sub-suite — it becomes a separate suite root, and its parent's hooks no longer apply.)
## Test file anatomy
diff --git a/.claude/skills/web-test/scripts/cli/commands/test.mjs b/.claude/skills/web-test/scripts/cli/commands/test.mjs
index 59c0c7d2..8c3c1782 100644
--- a/.claude/skills/web-test/scripts/cli/commands/test.mjs
+++ b/.claude/skills/web-test/scripts/cli/commands/test.mjs
@@ -1,4 +1,4 @@
-// web-test cli/commands/test v1.8 — regression test runner
+// web-test cli/commands/test v1.9 — regression test runner
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { existsSync, writeFileSync, mkdirSync, renameSync, copyFileSync, unlinkSync } from 'fs';
import { resolve, dirname, basename, relative } from 'path';
@@ -9,6 +9,7 @@ import { createAssertions } from '../test-runner/assertions.mjs';
import { buildSeverityIndex } from '../test-runner/severity.mjs';
import { writeAllure, buildJUnit, syncAllureExtras } from '../test-runner/reporters.mjs';
import { discoverTests, resetState } from '../test-runner/discover.mjs';
+import { findSuiteRoot, startDirOf } from '../test-runner/suite-root.mjs';
import { planEviction, touchLru, dropLru } from '../test-runner/context-pool.mjs';
export async function cmdTest(rawArgs) {
@@ -78,12 +79,24 @@ export async function cmdTest(rawArgs) {
die(`Test path not found: "${p}". To run a subset use --grep= / --tags=, or pass an existing dir/file.`);
}
- // Load config if exists. config (webtest.config.mjs) and hooks (_hooks.mjs) resolve from
- // the FIRST path's directory — list paths from the same suite folder.
- const firstPath = resolve(testPaths[0]);
- const isFile = firstPath.endsWith('.test.mjs');
- const testDir = isFile ? dirname(firstPath) : firstPath;
- const configPath = resolve(testDir, 'webtest.config.mjs');
+ // Suite root — the directory `webtest.config.mjs`, `_hooks.mjs`, `_allure/` and report paths
+ // all hang off. It is NOT the passed path: walking up to the nearest marker is what makes
+ // `test tests/myapp/sales/` work, as docs/web-test-regression-spec.md has always promised.
+ // Resolving from the passed path instead lost the hooks of any subfolder run — silently, so
+ // the run went ahead against an unprepared stand.
+ const startDirs = testPaths.map(p => startDirOf(p));
+ const roots = startDirs.map(d => findSuiteRoot(d));
+ // Paths from different suites must not share hooks — that used to resolve to "first path
+ // wins", silently running suite B's tests under suite A's preparation.
+ const distinct = [...new Set(roots.map(r => r?.root ?? null))];
+ if (distinct.length > 1) {
+ const lines = testPaths.map((p, i) => ` ${p} → ${roots[i]?.root ?? '(корень не найден)'}`);
+ die(`Paths belong to different suites — config and hooks would be ambiguous:\n${lines.join('\n')}\n` +
+ `Run them separately, or pass one suite root and narrow with --grep= / --tags=.`);
+ }
+ const suiteRoot = roots[0]?.root ?? startDirs[0];
+ const suiteRootFound = !!roots[0];
+ const configPath = resolve(suiteRoot, 'webtest.config.mjs');
let config = {};
if (existsSync(configPath)) {
const mod = await import('file:///' + configPath.replace(/\\/g, '/'));
@@ -103,7 +116,16 @@ export async function cmdTest(rawArgs) {
if (url) contextSpecs[defaultContextName] = { ...contextSpecs[defaultContextName], url };
} else {
const fallbackUrl = url || config.url;
- if (!fallbackUrl) die('No URL provided and no webtest.config.mjs found');
+ // Name the real problem: with no suite root there is no config to take a URL from — and,
+ // more dangerously, no `_hooks.mjs` either. The old wording talked only about the URL and
+ // sent readers looking in the wrong place.
+ if (!fallbackUrl) {
+ die(suiteRootFound
+ ? `No URL: ${configPath} defines neither "contexts" nor "url", and --url= was not given.`
+ : `Suite root not found above "${testPaths[0]}" — no webtest.config.mjs / _hooks.mjs up to ` +
+ `the repository (or working) directory, so there is no URL and no stand preparation.\n` +
+ `Pass the suite root (e.g. tests/myapp/) and narrow with --grep= / --tags=, or give --url=.`);
+ }
contextSpecs.default = { url: fallbackUrl };
}
if (!contextSpecs[defaultContextName]) {
@@ -175,7 +197,7 @@ export async function cmdTest(rawArgs) {
}
const reportDir = opts.reportDir
? resolve(opts.reportDir)
- : (opts.report && !reportToStdout ? dirname(resolve(opts.report)) : testDir);
+ : (opts.report && !reportToStdout ? dirname(resolve(opts.report)) : suiteRoot);
if (opts.screenshot !== 'off') {
try { mkdirSync(reportDir, { recursive: true }); } catch {}
// 1C-error screenshots (taken inside the action wrapper) default to a single
@@ -194,7 +216,10 @@ export async function cmdTest(rawArgs) {
for (const file of testFiles) {
const mod = await import('file:///' + file.replace(/\\/g, '/'));
const base = {
- file: relative(testDir, file).replace(/\\/g, '/'),
+ // Relative to the SUITE ROOT, not to the passed path — otherwise the same test gets a
+ // different id depending on how it was launched (`sales/01-x.test.mjs` vs `01-x.test.mjs`),
+ // and Allure history / JUnit trends treat the two as unrelated tests.
+ file: relative(suiteRoot, file).replace(/\\/g, '/'),
name: mod.name || basename(file, '.test.mjs'),
tags: mod.tags || [],
timeout: mod.timeout || opts.timeout,
@@ -229,7 +254,7 @@ export async function cmdTest(rawArgs) {
});
// Load hooks
- const hooksPath = resolve(testDir, '_hooks.mjs');
+ const hooksPath = resolve(suiteRoot, '_hooks.mjs');
let hooks = {};
if (existsSync(hooksPath)) {
hooks = await import('file:///' + hooksPath.replace(/\\/g, '/'));
@@ -239,7 +264,17 @@ export async function cmdTest(rawArgs) {
// In `--report -` mode the machine JSON/XML takes over stdout, so progress moves to stderr.
const W = reportToStdout ? process.stderr : process.stdout;
W.write(`\nweb-test -- ${url}\n`);
- W.write(`Running ${filtered.length} tests from ${relative(process.cwd(), testDir).replace(/\\/g, '/') || '.'}/\n\n`);
+ // Always name the resolved suite root: a climb that landed on the wrong directory is then
+ // visible in the first line of output instead of being diagnosed from symptoms later.
+ const rel = (p) => relative(process.cwd(), p).replace(/\\/g, '/') || '.';
+ const shownPaths = testPaths.map(p => rel(resolve(p))).filter(p => p !== rel(suiteRoot));
+ W.write(`Running ${filtered.length} tests from ${rel(suiteRoot)}/`);
+ W.write(shownPaths.length ? ` (paths: ${shownPaths.join(', ')})\n\n` : `\n\n`);
+ if (!suiteRootFound) {
+ // Not fatal — a one-off test outside any suite is legitimate. But a missing suite root also
+ // means no `_hooks.mjs` was even looked for above, so the stand is whatever it was.
+ process.stderr.write(`! no suite root (webtest.config.mjs / _hooks.mjs) found above ${rel(startDirs[0])} — running without hooks\n`);
+ }
const startedAt = new Date().toISOString();
const results = [];
@@ -819,10 +854,10 @@ export async function cmdTest(rawArgs) {
if (opts.format === 'allure') {
// Guard against a result-producing path that skipped recordResult; normally a no-op.
if (!allureWritten) writeAllure(results, reportDir, severityIndex);
- syncAllureExtras(testDir, reportDir);
+ syncAllureExtras(suiteRoot, reportDir);
} else if (opts.format === 'junit') {
- if (reportToStdout) process.stdout.write(buildJUnit(report, testDir) + '\n');
- else writeFileSync(resolve(opts.report), buildJUnit(report, testDir));
+ if (reportToStdout) process.stdout.write(buildJUnit(report, suiteRoot) + '\n');
+ else writeFileSync(resolve(opts.report), buildJUnit(report, suiteRoot));
} else if (reportToStdout) {
out(report);
} else if (opts.report) {
diff --git a/.claude/skills/web-test/scripts/cli/test-runner/suite-root.mjs b/.claude/skills/web-test/scripts/cli/test-runner/suite-root.mjs
new file mode 100644
index 00000000..12b45cdb
--- /dev/null
+++ b/.claude/skills/web-test/scripts/cli/test-runner/suite-root.mjs
@@ -0,0 +1,55 @@
+// web-test cli/test-runner/suite-root v1.0 — locate the suite root above a given test path
+// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
+import { existsSync, statSync } from 'fs';
+import { resolve, dirname } from 'path';
+
+// Files that MARK a suite root. Both count, not just the config: `webtest.config.mjs` is
+// optional (a single-URL suite may pass --url= instead), and a suite that ships only
+// `_hooks.mjs` must still be found — otherwise its stand preparation is silently skipped,
+// which is worse than any URL error.
+const MARKERS = ['webtest.config.mjs', '_hooks.mjs'];
+
+// Files that BOUND the climb. A boundary never selects a root — it only stops the search,
+// so a wrong boundary degrades to "root not found" (= the pre-v1.9 behaviour plus a clear
+// message) and can never produce a wrong root. `package.json` is deliberately absent: it
+// occurs nested and would stop the climb below a legitimate suite root.
+const BOUNDARIES = ['.git', '.v8-project.json'];
+
+const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
+
+/**
+ * Walk up from `startPath` looking for a suite root.
+ *
+ * @param {string} startPath A test file or directory (absolute or cwd-relative).
+ * @param {{cwd?: string}} [opts]
+ * @returns {{root: string, marker: string} | null} null when no marker was found within bounds.
+ *
+ * Stops after examining the first directory that contains `.git` / `.v8-project.json`
+ * (that directory IS examined for markers), or — when neither is met — after examining `cwd`.
+ * A path outside `cwd` degenerates to the filesystem root; the marker requirement still
+ * makes a wrong hit unlikely, and the resolved root is printed in the run banner.
+ */
+export function findSuiteRoot(startPath, { cwd = process.cwd() } = {}) {
+ const full = resolve(startPath);
+ let dir = isDir(full) ? full : dirname(full);
+ const cwdAbs = resolve(cwd);
+
+ while (true) {
+ for (const m of MARKERS) {
+ if (existsSync(resolve(dir, m))) return { root: dir, marker: m };
+ }
+ const atBoundary = BOUNDARIES.some(b => existsSync(resolve(dir, b))) || dir === cwdAbs;
+ const parent = dirname(dir);
+ if (atBoundary || parent === dir) return null;
+ dir = parent;
+ }
+}
+
+/**
+ * The directory a path contributes to root resolution — its own dir for a file, itself for
+ * a directory. Also the fallback root when no marker is found (pre-v1.9 behaviour).
+ */
+export function startDirOf(testPath) {
+ const full = resolve(testPath);
+ return isDir(full) ? full : dirname(full);
+}
diff --git a/.gitignore b/.gitignore
index ca82df12..6fd40ee7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,6 +32,9 @@ __pycache__/
.claude/skills/web-test/scripts/node_modules/
.claude/skills/web-test/.browser-session.json
+# Маркер отработавшего prepare() в фикстуре _suite-root
+tests/web-test/_suite-root/prepare-ran.txt
+
# Скриншоты и видео (артефакты тестирования web-test)
*.png
*.mp4
diff --git a/docs/web-test-regression-guide.md b/docs/web-test-regression-guide.md
index 5755425c..b49ddbe0 100644
--- a/docs/web-test-regression-guide.md
+++ b/docs/web-test-regression-guide.md
@@ -254,6 +254,15 @@ export default async function({
23 passed, 1 failed, 0 skipped (3m 42s)
```
+### Прогон части набора
+
+```
+> Прогони только сценарии из папки 03-приходные-накладные
+> Прогони только тесты с тегом контрагенты
+```
+
+Подмножество выбирается тремя способами: путём к подпапке или отдельному файлу, тегом (`--tags=`) или фильтром по имени теста (`--grep=`). Путь к подпапке работает наравне с остальными — конфиг и подготовка стенда всё равно берутся из корня набора (папки приложения в `tests/`), движок находит его сам, поднимаясь вверх. Отдельного URL или флагов для этого не нужно.
+
### Подробный отчёт
```
diff --git a/docs/web-test-regression-spec.md b/docs/web-test-regression-spec.md
index 8e510091..defb3891 100644
--- a/docs/web-test-regression-spec.md
+++ b/docs/web-test-regression-spec.md
@@ -29,12 +29,22 @@ node run.mjs test ... [флаги]
| `--report=path` | (нет) | Записать машинный отчёт в файл (JSON или XML для `--format=junit`) |
| `--report=-` | (нет) | Машинный отчёт в stdout (`-` = stdout); человеческий прогресс уходит в stderr |
| `--format=fmt` | json | Формат отчёта: `json` / `allure` / `junit` |
-| `--report-dir=path` | dirname(report) / testDir | Каталог для скриншотов, видео, Allure-результатов |
+| `--report-dir=path` | dirname(report) / корень сьюта | Каталог для скриншотов, видео, Allure-результатов |
| `--screenshot=strategy` | on-failure | `on-failure` / `every-step` / `off` |
| `--record` | false | Записывать видео для каждого теста (mp4 в `--report-dir`) |
| `-- ` | — | Всё после `--` пробрасывается в `_hooks.mjs` как `hookArgs` (см. §6.1) |
-URL не передаётся позиционно — он берётся из `webtest.config.mjs` в каталоге тестов, а флаг `--url=` переопределяет URL дефолтного контекста. `webtest.config.mjs` и `_hooks.mjs` резолвятся от каталога **первого** пути, поэтому перечисляемые файлы должны лежать в одной папке сьюта.
+URL не передаётся позиционно — он берётся из `webtest.config.mjs`, а флаг `--url=` переопределяет URL дефолтного контекста.
+
+### Резолв корня сьюта
+
+`webtest.config.mjs` и `_hooks.mjs` резолвятся не от переданного пути, а от **корня сьюта**: от каталога пути движок поднимается вверх до первого каталога, где лежит `webtest.config.mjs` или `_hooks.mjs`. Именно поэтому запуск подкаталога (`test tests/myapp/sales/`) и отдельного файла работает без `--url=`. Подъём ограничен каталогом с `.git` или `.v8-project.json` (сам каталог проверяется), а если их нет — текущим рабочим каталогом; выше поиск не идёт. Не нашли маркер — корнем считается переданный каталог (тогда хуков нет, и движок пишет об этом предупреждение в stderr).
+
+Маркером служат **оба** файла, а не только конфиг: конфиг необязателен (§7), и сьют, у которого есть только `_hooks.mjs`, иначе молча остался бы без подготовки стенда.
+
+Если переданные пути принадлежат разным сьютам (корни не совпали) — прогон не стартует: конфиг и хуки были бы взяты от первого пути, то есть чужие. Запускайте сьюты отдельно.
+
+Найденный корень печатается в шапке прогона, рядом — переданные пути, если они от него отличаются.
### Валидация CLI
@@ -200,7 +210,7 @@ export default async function({ clerk, manager, step }) {
ctx.testInfo = {
name, // 'Навигация по разделам' (с подставленными params)
file, // '01-navigation.test.mjs' (basename)
- filePath, // '01-navigation.test.mjs' (relative к testDir, разделитель '/')
+ filePath, // '01-navigation.test.mjs' (relative к корню сьюта, разделитель '/')
tags, // ['nav', 'smoke']
timeout, // 60000 (ms)
attempt, // 1..maxAttempts (1-based)
@@ -320,7 +330,7 @@ assert.noErrors(state, msg?)
## 6. Хуки
-Все хуки определяются в `_hooks.mjs` в корне каталога тестов.
+Все хуки определяются в `_hooks.mjs` в корне сьюта (§1 «Резолв корня сьюта»).
### Три уровня
@@ -451,7 +461,7 @@ node run.mjs test tests/myapp/ --bail -- --rebuild-stand --reload-data
## 7. Файл конфигурации
-`webtest.config.mjs` в корне каталога тестов. Необязателен — если отсутствует, URL должен быть передан через CLI.
+`webtest.config.mjs` в корне сьюта (§1 «Резолв корня сьюта»). Необязателен — если отсутствует, URL должен быть передан через CLI.
```js
export default {
@@ -782,7 +792,7 @@ await step('Кладовщик проверяет статус', async () => {
Движок всегда заполняет следующие метки (`labels`):
- **`tag`** — по одному на каждый элемент `mod.tags[]`. Готовая фильтрация в Allure-отчёте без дополнительной разметки.
-- **`suite`** — `dirname(t.filePath)`. Тесты в корне `testDir` идут под `'root'`, тесты в подкаталоге `sales/` — под `'sales'`. Это даёт левую группировку отчёта без ручной разметки.
+- **`suite`** — `dirname(t.filePath)`. Тесты в корне сьюта идут под `'root'`, тесты в подкаталоге `sales/` — под `'sales'`. Это даёт левую группировку отчёта без ручной разметки.
- **`severity`** — резолв в порядке приоритета:
1. `export const severity = 'critical'` в самом тесте, **если значение валидное** (одно из `blocker | critical | normal | minor | trivial`). Если экспорт задан, но значение невалидное — пункт пропускается и идём в (3); резолв через теги (пункт 2) при этом **не выполняется** (хотел бы автор иначе — он бы не объявлял `severity`).
2. Иначе **максимальный ранг** среди тегов теста (стандартные имена `blocker | critical | normal | minor | trivial` напрямую, либо через `config.severity`-маппинг).
@@ -792,9 +802,9 @@ await step('Кладовщик проверяет статус', async () => {
Пример: `tags: ['smoke', 'recording']` + `severity: { critical: ['smoke'], minor: ['recording'] }` → severity = `critical` (5 > 2).
-#### Доп. файлы Allure через `/_allure/`
+#### Доп. файлы Allure через `<корень сьюта>/_allure/`
-Движок ищет каталог `_allure/` рядом с тестами и копирует все его файлы в `reportDir` перед генерацией отчёта. Конвенция для статичной настройки Allure, для которой нет места внутри JSON-файла теста:
+Движок ищет каталог `_allure/` в корне сьюта и копирует все его файлы в `reportDir` перед генерацией отчёта. Конвенция для статичной настройки Allure, для которой нет места внутри JSON-файла теста:
| Файл | Назначение |
|------|-----------|
@@ -922,7 +932,7 @@ export default async function({ fillFields, getFormState, assert }, { type, fiel
## 14. Обнаружение тестов
-`testDir` (первый позиционный аргумент после URL) — каталог, в котором живут тесты. Сборщик рекурсивно обходит дерево и собирает файлы по правилам ниже.
+Позиционные аргументы — пути к тестам; каталог, от которого считаются относительные пути в отчёте, — корень сьюта (§1 «Резолв корня сьюта»). Сборщик рекурсивно обходит дерево и собирает файлы по правилам ниже.
```
tests/myapp/
@@ -944,14 +954,13 @@ tests/myapp/
| Шаблон имени | Только `*.test.mjs` |
| Несколько путей | `node run.mjs test a.test.mjs b.test.mjs dir/` — наборы объединяются, дублируются и сортируются |
| Порядок | Сортировка по полному относительному пути (`sales/01` идёт до `warehouse/01`) |
-| `file` в отчёте | `relative(testDir, file)` с разделителем `/`, например `sales/01-order-create.test.mjs` |
+| `file` в отчёте | `relative(<корень сьюта>, file)` с разделителем `/`, например `sales/01-order-create.test.mjs` |
| Фильтр по пути с CLI | `node run.mjs test tests/myapp/sales/` запустит только подкаталог |
| Конкретный файл | `node run.mjs test tests/myapp/sales/01-order-create.test.mjs` |
### Чего НЕТ (сознательное упрощение)
-- **`_hooks.mjs` на уровне подкаталога.** Движок ищет `_hooks.mjs` только в корне `testDir`. Подкаталоги свои хуки не получают.
-- **`webtest.config.mjs` на уровне подкаталога.** Тоже только в корне.
+- **`_hooks.mjs` / `webtest.config.mjs` на уровне подкаталога.** Оба берутся только из корня сьюта — того каталога, где лежит ближайший из них (§1 «Резолв корня сьюта»). Подкаталоги своих копий не получают; вложенный каталог со своим `webtest.config.mjs` — это уже отдельный сьют.
- **Многоуровневой Suite-разметки из дерева каталогов.** Allure-метка `suite` строится только по первому уровню (`dirname(filePath)`); более глубокую группировку делайте через `tags`.
- **Контекста по умолчанию на уровне подкаталога.** Каждый тест объявляет `context` / `contexts` сам; от пути контексты не наследуются.
@@ -1117,7 +1126,8 @@ JSON-отчёт (`tests[]`, полная структура — §9) для ка
| Термин | Определение |
|--------|-------------|
-| **testDir** | Каталог тестов, переданный позиционным аргументом движку. Корень для discovery, `_hooks.mjs`, `webtest.config.mjs`, `_allure/`. |
+| **Test path** | Путь к тесту или каталогу тестов, переданный позиционным аргументом. Корень только для discovery — что именно запускать. |
+| **Suite root (корень сьюта)** | Каталог, найденный подъёмом от test path до первого `webtest.config.mjs` / `_hooks.mjs` (§1). От него берутся конфиг, хуки, `_allure/`, каталог отчёта по умолчанию и относительные пути `file` в отчёте. Не зависит от того, запустили сьют целиком или один его подкаталог, — поэтому ID теста в отчёте стабилен. |
| **Context (BrowserContext)** | Изолированная сессия Playwright. Куки/состояние/страница независимы. В рамках одного теста используется один или несколько контекстов. |
| **Active context** | Контекст, на котором сейчас оперируют функции browser-API. Переключается `setActiveContext`. |
| **Primary context** | Контекст, активный на входе в тест. Декларация (`mod.context` или `mod.contexts[0]`). Зафиксирован в `testInfo.primaryContext`. |
diff --git a/tests/web-test/_suite-root/_hooks.mjs b/tests/web-test/_suite-root/_hooks.mjs
new file mode 100644
index 00000000..3c5e6bc5
--- /dev/null
+++ b/tests/web-test/_suite-root/_hooks.mjs
@@ -0,0 +1,15 @@
+// Hooks for the suite-root fixture. They prepare nothing — the point is that they RUN at all
+// when the runner was pointed at `nested/`. Losing hooks is the silent half of the bug this
+// fixture guards: without them a run proceeds against an unprepared stand and still goes green.
+// `prepare` writes a marker file the caller can assert on.
+import { writeFileSync } from 'fs';
+import { resolve, dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+export const MARKER = resolve(HERE, 'prepare-ran.txt');
+
+export async function prepare({ log }) {
+ writeFileSync(MARKER, new Date().toISOString() + '\n');
+ log('suite-root fixture: prepare() ran — hooks were resolved from the suite root');
+}
diff --git a/tests/web-test/_suite-root/check.mjs b/tests/web-test/_suite-root/check.mjs
new file mode 100644
index 00000000..bd0c2b85
--- /dev/null
+++ b/tests/web-test/_suite-root/check.mjs
@@ -0,0 +1,119 @@
+// web-test _suite-root/check v1.0 — offline verdict for the suite-root resolver
+// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
+//
+// findSuiteRoot decides where `webtest.config.mjs` and `_hooks.mjs` are loaded from. Getting it
+// wrong is silent: the run picks up someone else's hooks, or none at all, and still goes green.
+// Every rule of the climb is pinned here on throw-away trees — no 1C stand, no browser.
+//
+// node tests/web-test/_suite-root/check.mjs
+//
+// Exit codes: 0 — resolver behaves; 1 — a rule regressed.
+import { mkdirSync, writeFileSync, rmSync } from 'fs';
+import { resolve, dirname } from 'path';
+import { fileURLToPath } from 'url';
+import { tmpdir } from 'os';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const REPO = resolve(__dirname, '../../..');
+const { findSuiteRoot } = await import(
+ 'file:///' + resolve(REPO, '.claude/skills/web-test/scripts/cli/test-runner/suite-root.mjs').replace(/\\/g, '/')
+);
+
+const BASE = resolve(tmpdir(), 'web-test-suite-root-check');
+rmSync(BASE, { recursive: true, force: true });
+
+/** Build a tree from a list of relative paths; a path ending in `/` is a dir, otherwise a file. */
+function tree(name, entries) {
+ const root = resolve(BASE, name);
+ for (const e of entries) {
+ const full = resolve(root, e);
+ if (e.endsWith('/')) mkdirSync(full, { recursive: true });
+ else { mkdirSync(dirname(full), { recursive: true }); writeFileSync(full, '// fixture\n'); }
+ }
+ return root;
+}
+
+let failed = 0;
+function check(label, actual, expected) {
+ const a = actual === null ? 'null' : actual;
+ const e = expected === null ? 'null' : expected;
+ if (a === e) { console.log(` ok ${label}`); return; }
+ console.log(` FAIL ${label}\n expected: ${e}\n actual: ${a}`);
+ failed++;
+}
+const rootOf = (r) => (r ? r.root : null);
+
+// 1. Config one level up — the IRG case: `test tests/irg/00-smoke/`.
+{
+ const t = tree('t1', ['suite/webtest.config.mjs', 'suite/00-smoke/x.test.mjs']);
+ check('config one level up', rootOf(findSuiteRoot(resolve(t, 'suite/00-smoke'), { cwd: t })), resolve(t, 'suite'));
+}
+
+// 2. Two levels up, and the marker is _hooks.mjs only — a suite with no config still resolves,
+// otherwise its stand preparation would be silently skipped.
+{
+ const t = tree('t2', ['suite/_hooks.mjs', 'suite/a/b/x.test.mjs']);
+ check('hooks-only marker, two levels up', rootOf(findSuiteRoot(resolve(t, 'suite/a/b'), { cwd: t })), resolve(t, 'suite'));
+}
+
+// 3. A file path resolves from its own directory.
+{
+ const t = tree('t3', ['suite/webtest.config.mjs', 'suite/a/x.test.mjs']);
+ check('file path → its directory', rootOf(findSuiteRoot(resolve(t, 'suite/a/x.test.mjs'), { cwd: t })), resolve(t, 'suite'));
+}
+
+// 4. Nearest marker wins — a nested suite is not swallowed by its parent (the `_hang/` case).
+{
+ const t = tree('t4', ['suite/webtest.config.mjs', 'suite/inner/webtest.config.mjs', 'suite/inner/x.test.mjs']);
+ check('nearest marker wins', rootOf(findSuiteRoot(resolve(t, 'suite/inner'), { cwd: t })), resolve(t, 'suite/inner'));
+}
+
+// 5-6. Boundaries stop the climb: a config above `.git` / `.v8-project.json` is NOT ours.
+{
+ const t = tree('t5', ['webtest.config.mjs', 'proj/.git/', 'proj/a/x.test.mjs']);
+ check('.git bounds the climb', rootOf(findSuiteRoot(resolve(t, 'proj/a'), { cwd: t })), null);
+}
+{
+ const t = tree('t6', ['webtest.config.mjs', 'proj/.v8-project.json', 'proj/a/x.test.mjs']);
+ check('.v8-project.json bounds the climb', rootOf(findSuiteRoot(resolve(t, 'proj/a'), { cwd: t })), null);
+}
+
+// 7. The boundary directory is itself examined — a marker sitting next to `.git` is found.
+{
+ const t = tree('t7', ['proj/.git/', 'proj/webtest.config.mjs', 'proj/a/x.test.mjs']);
+ check('boundary dir is inclusive', rootOf(findSuiteRoot(resolve(t, 'proj/a'), { cwd: t })), resolve(t, 'proj'));
+}
+
+// 8. With no repo marker, cwd bounds the climb — nothing above the working directory is picked up.
+{
+ const t = tree('t8', ['webtest.config.mjs', 'work/a/x.test.mjs']);
+ check('cwd bounds the climb', rootOf(findSuiteRoot(resolve(t, 'work/a'), { cwd: resolve(t, 'work') })), null);
+}
+
+// 9. cwd is inclusive too.
+{
+ const t = tree('t9', ['work/webtest.config.mjs', 'work/a/x.test.mjs']);
+ check('cwd is inclusive', rootOf(findSuiteRoot(resolve(t, 'work/a'), { cwd: resolve(t, 'work') })), resolve(t, 'work'));
+}
+
+// 10. No marker anywhere → null, and the caller falls back to the passed directory.
+{
+ const t = tree('t10', ['proj/.git/', 'proj/a/x.test.mjs']);
+ check('no marker → null', rootOf(findSuiteRoot(resolve(t, 'proj/a'), { cwd: t })), null);
+}
+
+// 11. Real repo: this fixture's own directory carries the markers, so `nested/` climbs one level.
+{
+ const here = resolve(REPO, 'tests/web-test/_suite-root/nested');
+ check('repo fixture: nested/ → _suite-root/', rootOf(findSuiteRoot(here, { cwd: REPO })), dirname(here));
+}
+
+// 12. Real repo: the flat suite resolves to itself.
+{
+ const suite = resolve(REPO, 'tests/web-test');
+ check('repo: tests/web-test/ → itself', rootOf(findSuiteRoot(suite, { cwd: REPO })), suite);
+}
+
+rmSync(BASE, { recursive: true, force: true });
+console.log(failed ? `\n${failed} check(s) FAILED\n` : '\nall checks passed\n');
+process.exit(failed ? 1 : 0);
diff --git a/tests/web-test/_suite-root/nested/01-nested.test.mjs b/tests/web-test/_suite-root/nested/01-nested.test.mjs
new file mode 100644
index 00000000..9205ef73
--- /dev/null
+++ b/tests/web-test/_suite-root/nested/01-nested.test.mjs
@@ -0,0 +1,22 @@
+// The only test of the suite-root fixture. Kept as light as possible — it is not here to test
+// the application, it is here to be run as `test tests/web-test/_suite-root/nested/` and prove
+// that config (URL) and hooks were resolved one level up. Before v1.9 that invocation died with
+// "No URL provided and no webtest.config.mjs found".
+import { existsSync } from 'fs';
+import { resolve, dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const SUITE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+
+export const name = 'suite-root: конфиг и хуки подхвачены на уровень выше';
+export const tags = ['suite-root'];
+export const timeout = 60000;
+
+export default async function({ getPageState, assert, log }) {
+ const state = await getPageState();
+ const names = (state.sections || []).map(s => s.name);
+ log('sections: ' + names.join(', '));
+ assert.ok(names.length >= 1, 'сеанс открыт → URL взят из конфига в корне сюиты');
+ assert.ok(existsSync(resolve(SUITE_ROOT, 'prepare-ran.txt')),
+ 'prepare() отработал → _hooks.mjs взят из корня сюиты, а не потерян');
+}
diff --git a/tests/web-test/_suite-root/webtest.config.mjs b/tests/web-test/_suite-root/webtest.config.mjs
new file mode 100644
index 00000000..41afa8f6
--- /dev/null
+++ b/tests/web-test/_suite-root/webtest.config.mjs
@@ -0,0 +1,10 @@
+// Config for the suite-root fixture only. Its whole job is to sit HERE, one level above
+// `nested/`, so that `run.mjs test tests/web-test/_suite-root/nested/` has to climb to find it.
+// The stand must already be published (this fixture exercises path resolution, not the stand).
+export default {
+ contexts: {
+ a: { url: 'http://localhost:9191/webtest-runner/ru_RU' },
+ },
+ defaultContext: 'a',
+ timeout: 60000,
+};