From 8ccd562625f312a3e2b3ce6334bafe8fc954d27d Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Thu, 16 Jul 2026 11:15:02 +0300 Subject: [PATCH] =?UTF-8?q?fix(web-test):=20resetState=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D1=87=D0=B0=D0=B5=D1=82=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BB=20=D0=B8=20=D0=BD=D0=B5=20=D1=81=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=20=D1=80=D0=B0=D0=B1=D0=BE=D1=87=D0=B8=D0=B9?= =?UTF-8?q?=20=D1=81=D1=82=D0=BE=D0=BB=20=D0=B3=D1=80=D1=8F=D0=B7=D1=8C?= =?UTF-8?q?=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resetState молча сдавался: крутил 10 попыток closeForm, игнорировал их результат и ничего не возвращал. Контекст с чужой открытой формой уходил в пул как «чистый», следующий тест кликал в него (заявка пилота: Сц.3 роняет форму → Сц.5 падает на чужой). - resetState возвращает вердикт {clean, attempts, form, title, modal}. «Чисто» определяется не как «форм нет» (form == null — верно только для пустого стола), а как «закрывать нечего»: closeForm.nothingToClose. Замерено на стенде пилота — рабочий стол там это form=5, formCount=3, openForms=[5,6,7], и старое правило объявляло бы чистый контекст грязным ПОСЛЕ КАЖДОГО теста (clean:false за 8.7с). Теперь clean:true за 0.9с, одна итерация вместо десяти. - resetOrAbort читает вердикт: не clean → !-строка с именем оставшейся формы + abortContext. Ровно логика, уже работавшая для пробоя дедлайна. - Диагностика буферизуется и печатается ПОД строкой своего теста: cleanup идёт до записи результата, поэтому раньше !-строки вставали над тестом и приписывались предыдущему (на этом купилась и сама сессия). - Ранний выход по closed:false НЕ вводим: A/B на живом наборе показал, что грязная «Приходная накладная» отдаёт closed:false на первой попытке и закрывается на следующей — выход прерывал бы контекст зря. Проверено: контракт стабом 5/5; на стенде пилота ложное срабатывание снято (8.8с→0.9с), стопка форм разбирается до стола; полный регресс 25/25, ноль строк «not clean» (было две — «Тестовые ошибки»). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../web-test/scripts/cli/commands/test.mjs | 60 +++++++++++++++---- .../scripts/cli/test-runner/discover.mjs | 47 +++++++++++++-- 2 files changed, 92 insertions(+), 15 deletions(-) diff --git a/.claude/skills/web-test/scripts/cli/commands/test.mjs b/.claude/skills/web-test/scripts/cli/commands/test.mjs index decec627..59c0c7d2 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.7 — regression test runner +// web-test cli/commands/test v1.8 — 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'; @@ -245,6 +245,20 @@ export async function cmdTest(rawArgs) { const results = []; let passCount = 0, failCount = 0, skipCount = 0; + // Per-test diagnostics are BUFFERED and flushed right after that test's ✓/✗ line. + // A test's cleanup runs before its result is printed, so writing straight to the stream put + // `! …` lines ABOVE the test they belong to — i.e. visually under the PREVIOUS test's result. + // Anyone reading the log (a model included) attributes them to the wrong test; that misreading + // already cost this session a wrong conclusion. Outside a test (hooks, final teardown) there is + // nothing to attach to, so lines go straight out. + let diagSink = null; + const emit = (line) => { if (diagSink) diagSink.push(line); else W.write(line); }; + const flushDiag = () => { + if (!diagSink) return; + for (const line of diagSink) W.write(line); + diagSink = null; + }; + /** * Bounded best-effort await: the replacement for `try { await x } catch {}`. * Same tolerance for failure, but a call that never settles can no longer stall the run, @@ -252,23 +266,37 @@ export async function cmdTest(rawArgs) { */ async function bounded(promise, ms, label) { const r = await softDeadline(promise, ms, label); - if (!r.ok) W.write(` ! ${label}: ${r.timedOut ? `timed out after ${ms}ms` : r.err.message.split('\n')[0]}\n`); + if (!r.ok) emit(` ! ${label}: ${r.timedOut ? `timed out after ${ms}ms` : r.err.message.split('\n')[0]}\n`); return r; } /** - * Reset one context between tests. If the reset breaches its deadline, the slot's UI state is - * unknown — reusing it would leak dirty state into the next test, which is the WORST outcome - * of a badly-sized budget: silent drift instead of a visible error. So destroy it; the next - * test recreates a clean one via ensureContext. A deadline that is merely too tight then costs - * a context relaunch, never a wrong test result. + * Reset one context between tests — and only reuse it if the reset actually WORKED. + * + * Reusing a context whose UI was not cleaned leaks someone else's open form into the next test: + * silent drift instead of a visible error, the worst possible outcome. Two ways to end up there, + * and both must lead here: + * - the reset breached its deadline (badly-sized budget, wedged page); + * - the reset ran to completion but did not clean anything (a modal that refuses to close) — + * this one used to pass as success, because `bounded` only reports timeouts and throws. + * Either way: destroy the slot, ensureContext recreates a clean one. The cost is a relaunch, + * never a wrong test result. */ async function resetOrAbort(cn, ctx) { const sw = await bounded(browser.setActiveContext(cn), D.setActive, `setActiveContext(${cn})`); if (!sw.ok) return false; const r = await bounded(resetState(ctx), D.resetState, `resetState(${cn})`); - if (r.ok) return true; - W.write(` ! context "${cn}" осталось несброшенным — прерываю его, следующий тест получит чистый\n`); + if (r.ok && r.value?.clean) return true; + + if (r.ok) { + // Name what stayed open — otherwise the next investigation starts from archaeology. + const v = r.value || {}; + const what = v.title ? `"${v.title}"` : `#${v.form}`; + emit(` ! resetState(${cn}): not clean — form ${what}${v.modal ? ' (modal)' : ''} still open` + + ` after ${v.attempts} close attempt(s)` + + `${v.lastError ? `, last error: ${v.lastError.message.split('\n')[0]}` : ''}\n`); + } + emit(` ! context "${cn}" left dirty — aborting it, the next test gets a fresh one\n`); await bounded(browser.abortContext(cn), D.closeContext, `abortContext(${cn})`); dropLru(lruOrder, cn); return false; @@ -402,7 +430,7 @@ export async function cmdTest(rawArgs) { try { await ensureContext(defaultContextName); } catch (e) { - W.write(`\n!! не удалось открыть контекст "${defaultContextName}": ${e.message}\n\n`); + W.write(`\n!! cannot open context "${defaultContextName}": ${e.message}\n\n`); try { writeFinalReport('aborted'); } catch {} // process.exit skips the `finally` below, and killing the process does NOT release a 1C // seance — so release what we hold explicitly before leaving. @@ -428,6 +456,8 @@ export async function cmdTest(rawArgs) { let testIdx = 0; for (const t of filtered) { testIdx++; + // Buffer this test's diagnostics; they are flushed under its own result line below. + diagSink = []; const declaredContexts = t.contexts && t.contexts.length ? t.contexts : [t.context || defaultContextName]; @@ -435,6 +465,7 @@ export async function cmdTest(rawArgs) { if (t.skip) { const reason = typeof t.skip === 'string' ? t.skip : ''; W.write(` ○ ${t.name}${reason ? ` (skip: ${reason})` : ' (skip)'}\n`); + flushDiag(); recordResult({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'skipped', duration: 0, attempts: 0, steps: [], output: '', error: null, screenshot: null }); skipCount++; continue; @@ -483,6 +514,7 @@ export async function cmdTest(rawArgs) { touchLru(lruOrder, testContextNames); } catch (e) { W.write(` ✗ ${t.name} (context setup failed: ${e.message})\n`); + flushDiag(); recordResult({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'failed', duration: 0, attempts: 0, steps: [], output: '', error: { message: e.message }, screenshot: null }); failCount++; if (opts.bail) break; @@ -635,7 +667,7 @@ export async function cmdTest(rawArgs) { diagnosis = { verdict, probe, net: diag?.net }; e.message = `${e.message} — ${lines[0]}`; output.push(...lines); - W.write(lines.map(l => ` ${l}\n`).join('')); + emit(lines.map(l => ` ${l}\n`).join('')); } const dead = diagnosis && (diagnosis.verdict === 'hang' || diagnosis.verdict === 'browser-dead'); @@ -728,9 +760,15 @@ export async function cmdTest(rawArgs) { if (lastError?.screenshot) W.write(` screenshot: ${lastError.screenshot}\n`); } + flushDiag(); + if (opts.bail && testResult.status === 'failed') break; } + // Out of the per-test scope (also on `break`): afterAll and the final teardown have no test + // to nest under, so their diagnostics go straight to the stream again. + flushDiag(); + if (hooks.afterAll) await bounded(hooks.afterAll(ctx), D.hooks, 'hooks.afterAll'); } finally { diff --git a/.claude/skills/web-test/scripts/cli/test-runner/discover.mjs b/.claude/skills/web-test/scripts/cli/test-runner/discover.mjs index 558a6eb6..76650fb0 100644 --- a/.claude/skills/web-test/scripts/cli/test-runner/discover.mjs +++ b/.claude/skills/web-test/scripts/cli/test-runner/discover.mjs @@ -1,4 +1,4 @@ -// web-test cli/test-runner/discover v1.1 — test file discovery + state reset between tests +// web-test cli/test-runner/discover v1.3 — test file discovery + state reset between tests // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { existsSync, readdirSync } from 'fs'; import { resolve } from 'path'; @@ -29,15 +29,54 @@ export function discoverTests(testPaths) { return [...new Set(files)].sort(); } +/** + * Return the context to a clean desktop between tests — and REPORT whether that worked. + * + * The verdict is the point. closeForm does not throw when a form refuses to close: it returns + * `{closed:false}`, and this loop used to drop that on the floor, so a context with someone else's + * modal still open went back into the pool as "clean" and the next test clicked into it. Measured + * on the pilot's stand: 10 idle iterations, `closed:false` every time, state unchanged — and the + * runner called it a success. + * + * @returns {Promise<{clean: boolean, attempts: number, form?: any, title?: string, modal?: boolean, lastError?: Error}>} + * `clean:false` also when the check itself failed — not being able to confirm is not being clean. + */ export async function resetState(ctx) { try { if (typeof ctx.dismissPendingErrors === 'function') await ctx.dismissPendingErrors(); } catch {} + let attempts = 0; + let lastError = null; for (let i = 0; i < 10; i++) { try { const state = await ctx.getFormState(); // form === null means no form open (desktop). form === 0 is a real background form // 1C exposes in some states — must still close it to fully reset. - if (state.form == null) break; - await ctx.closeForm({ save: false }); - } catch { break; } + if (state.form == null) return { clean: true, attempts }; + attempts++; + const r = await ctx.closeForm({ save: false }); + // The platform found nothing closable → this is the desktop, however many forms sit on it. + // Without this the check would be "form == null", which is only true for an EMPTY desktop: + // on a real application the home page keeps its own forms (measured: form=5, formCount=3, + // no cross), so the old rule declared a perfectly clean context dirty after every test. + if (r?.nothingToClose) return { clean: true, attempts, desktop: true }; + // Deliberately NOT bailing out on `closed:false`: measured A/B on the live suite — a dirty + // «Приходная накладная *» reports closed:false on the first round and closes on a later one, + // so an early exit aborted a context that was about to be clean. `closed` compares form + // numbers, so an intermediate step (a popup going away) reads as "nothing happened" even + // though progress was made. The verdict below judges the END state, which is what matters. + } catch (e) { lastError = e; break; } + } + + // Control check — the loop proves nothing on its own: it can also exit via `catch` above. + try { + const state = await ctx.getFormState(); + if (state.form == null) return { clean: true, attempts }; + return { + clean: false, attempts, lastError, + form: state.form, + title: state.activeTab || null, + modal: !!state.modal, + }; + } catch (e) { + return { clean: false, attempts, lastError: lastError || e }; } }