mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-18 08:39:40 +03:00
feat(web-test): ловить стартовый блокер 1С вместо ложного диагноза
Когда у 1С нет свободной лицензии, клиент рисует блокирующий стартовый диалог ВМЕСТО приложения. Движок этого не видел: ждал свой маркер инициализации 60с, молча проваливался в waitForTimeout(5000) и ВОЗВРАЩАЛ УСПЕХ. Слот оставался без seanceId, а первое же обращение выдавало заведомую ложь: navigateSection: "Склад" not found. Section panel is in icon-only mode… Поймано на себе: спайки утекли сеансами → упёрлись в лимит → час гонялись за несуществующим «режимом отображения панели». Конфигом это не лечится: число свободных лицензий — не свойство стенда. Замеры дали 3, потом 1, потому что на той же машине работала параллельная сессия со своими сеансами. Предсказать нельзя — надо ловить. - waitForClientOrStartupBlock: один waitForFunction ждёт, что наступит раньше — клиент (#themesCell_theme_0) или видимый стартовый диалог (#messageBoxText). Бросает ТОЛЬКО по положительной улике: отсутствие клиента уликой не является (страница логина легитимна, поведение там не изменилось). Якоря — id, текст платформы лишь цитируется в сообщение, поэтому смена локали детект не сломает. Перед обвинением — переподтверждение через 600мс (защита от мигания при отрисовке оболочки). - openAndSettle убирает третью копию тех же трёх строк и делает обязательное: при блокировке слот НЕ должен пережить ошибку. Он регистрируется до ожидания, а ensureContext в раннере — это `if (hasContext(name)) return`, так что битый слот молча обслуживал бы все следующие тесты диалогом отказа. - test.mjs: дефолтный контекст поднимается во внешнем try, у которого только finally, а run.mjs не оборачивает cmdTest — бросок оттуда дал бы голый стек и НИКАКОГО отчёта. Теперь: строка, отчёт, освобождение сеансов, выход 1. - Кнопки диалога не нажимаем сознательно: его автозапуск по обратному отсчёту может завершить чужой сеанс на общей машине. Причина зафиксирована в тексте ошибки, чтобы это не «починили» кликом. Проверено вживую: - шаг 0 (до кода): на здоровом старте #messageBoxText не существует вовсе — ни в загруженном клиенте, ни в момент загрузки (опрос 100мс); - позитивный контроль на НАСТОЯЩЕМ отказе: бросает за 1.1–13.5с вместо 66, цитирует диалог и список сеансов, слот из реестра убран — 6/6; - негативный контроль: полный регресс 25/25, ноль ложных срабатываний; - путь connect() (exec/run/start) — клиент поднимается штатно. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// web-test cli/commands/test v1.6 — regression test runner
|
||||
// web-test cli/commands/test v1.7 — 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';
|
||||
@@ -394,7 +394,21 @@ export async function cmdTest(rawArgs) {
|
||||
// Connect: create default context up front (hosts beforeAll / hooks). It is NOT permanently
|
||||
// pinned — under a maxContexts cap it becomes an LRU eviction candidate unless it is listed in
|
||||
// pinnedContexts. Register it in the LRU order.
|
||||
await ensureContext(defaultContextName);
|
||||
//
|
||||
// This one call needs its own catch: it sits in a try that has only a `finally`, and run.mjs
|
||||
// does not wrap cmdTest — so a throw here would escape as a raw stack trace and skip the
|
||||
// report entirely. A blocked startup (e.g. no free 1C licence) dooms the whole run anyway,
|
||||
// so say it once, keep the report, and leave.
|
||||
try {
|
||||
await ensureContext(defaultContextName);
|
||||
} catch (e) {
|
||||
W.write(`\n!! не удалось открыть контекст "${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.
|
||||
await softDeadline(shutdownAll(), 15000, 'shutdown');
|
||||
process.exit(1);
|
||||
}
|
||||
touchLru(lruOrder, defaultContextName);
|
||||
|
||||
const ctx = buildContext({ noRecord: false });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// web-test core/session v1.18 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry.
|
||||
// web-test core/session v1.19 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry.
|
||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
@@ -53,6 +53,63 @@ function findExtension(overridePath) {
|
||||
|
||||
/* isConnected moved to core/state.mjs */
|
||||
|
||||
/**
|
||||
* Wait for the 1C client to come up — or for the startup shell to say why it won't.
|
||||
*
|
||||
* Why this exists: when 1C has no free licence it renders a blocking startup dialog INSTEAD of
|
||||
* the application. The old code just waited out INIT_TIMEOUT and returned success, leaving a
|
||||
* session-less slot behind; the first engine call then produced a plainly wrong diagnosis
|
||||
* ("Section panel is in icon-only mode…"). Measured: 66s wasted, then a lie.
|
||||
*
|
||||
* Contract: throw ONLY on positive evidence of a startup dialog. A missing client marker is NOT
|
||||
* evidence — a login page legitimately never shows it (that is what the fallback below is for).
|
||||
*
|
||||
* Anchors are ids, never text: the platform's wording is locale-dependent and only gets quoted
|
||||
* into the message. Verified on this stand — on a healthy start #messageBoxText does not exist at
|
||||
* all, neither in the loaded client nor at any point while the shell boots (polled every 100ms).
|
||||
* The offsetWidth/text conjuncts guard the case where a future build pre-renders it hidden, the
|
||||
* way the shell already pre-renders its login form. offsetWidth (not offsetParent — that is null
|
||||
* for position:fixed, and #ps0win is an overlay) matches the convention in errors.mjs.
|
||||
*/
|
||||
async function waitForClientOrStartupBlock(pg, url, timeout = INIT_TIMEOUT) {
|
||||
let outcome;
|
||||
try {
|
||||
outcome = await pg.waitForFunction(() => {
|
||||
if (document.querySelector('#themesCell_theme_0')) return 'client';
|
||||
const box = document.querySelector('#messageBoxText');
|
||||
if (box && box.offsetWidth > 0 && box.textContent.trim()) return 'blocked';
|
||||
return false;
|
||||
}, null, { timeout }).then(h => h.jsonValue());
|
||||
} catch {
|
||||
// Neither appeared: unchanged legacy behaviour — a login page or a slow start is not an error.
|
||||
await pg.waitForTimeout(5000);
|
||||
return;
|
||||
}
|
||||
if (outcome === 'client') return;
|
||||
|
||||
// Re-confirm before accusing: costs 600ms on a path that is already lost, and buys immunity to
|
||||
// a dialog that merely flickered while the shell drew itself.
|
||||
await pg.waitForTimeout(600);
|
||||
const evidence = await pg.evaluate(() => {
|
||||
// innerText, not textContent: the latter concatenates without any rendered whitespace
|
||||
// ("лицензии!Выберите…", "Веб-клиентсеанс: 5") — unreadable in an error message.
|
||||
const visibleText = (el) => (el && el.offsetWidth > 0) ? (el.innerText || '').trim() : '';
|
||||
if (document.querySelector('#themesCell_theme_0')) return null; // the client won after all
|
||||
const text = visibleText(document.querySelector('#messageBoxText'));
|
||||
return text ? { text, seances: visibleText(document.querySelector('#seancesToFinish')) } : null;
|
||||
}).catch(() => null);
|
||||
if (!evidence) return; // flicker — carry on exactly as before
|
||||
|
||||
const oneLine = (s) => s.replace(/\s+/g, ' ').trim().slice(0, 300);
|
||||
throw new Error(
|
||||
`1C startup blocked before the web client loaded: "${oneLine(evidence.text)}"` +
|
||||
(evidence.seances ? `\n Сеансы, предложенные платформой к завершению: ${oneLine(evidence.seances)}` : '') +
|
||||
'\n Движок кнопки этого диалога не нажимает: его автозапуск по обратному отсчёту может' +
|
||||
' завершить чужой сеанс на этой машине.' +
|
||||
`\n Если это нехватка лицензии — освободите сеансы 1С и повторите. URL: ${url}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open browser and navigate to 1C web client URL.
|
||||
* Waits for initialization (themesCell_theme_0 selector) and attempts to close startup modals.
|
||||
@@ -103,13 +160,8 @@ export async function connect(url, { extensionPath } = {}) {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||
}
|
||||
|
||||
// Wait for 1C to initialize — detect by section panel appearance
|
||||
try {
|
||||
await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT });
|
||||
} catch {
|
||||
// Fallback: wait fixed time if selector doesn't appear (e.g. login page)
|
||||
await page.waitForTimeout(5000);
|
||||
}
|
||||
// Wait for 1C to initialize — or fail fast if the startup shell blocks the client
|
||||
await waitForClientOrStartupBlock(page, url);
|
||||
|
||||
// Try to close startup modals (Путеводитель etc.)
|
||||
await closeModals();
|
||||
@@ -294,14 +346,30 @@ function attachSessionListeners(pg, slot, name) {
|
||||
* Use this from run.mjs cmdTest only — exec/run/start use connect() and stay on the
|
||||
* legacy persistent-context path.
|
||||
*/
|
||||
/**
|
||||
* Navigate the active slot to `url` and settle: client up, or a startup block raised.
|
||||
*
|
||||
* On a block the slot MUST NOT survive. It is registered before this runs, and the runner's
|
||||
* ensureContext is `if (browser.hasContext(name)) return;` — so a broken slot left in the registry
|
||||
* would silently serve every later test the refusal dialog, i.e. exactly the blindness being fixed.
|
||||
* abortContext also handles the tab-mode last-page teardown, so the next createContext relaunches.
|
||||
*/
|
||||
async function openAndSettle(name, url) {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||
try {
|
||||
await waitForClientOrStartupBlock(page, url);
|
||||
} catch (e) {
|
||||
await softDeadline(abortContext(name), 10000, 'abortContext(startup-block)');
|
||||
throw e;
|
||||
}
|
||||
await closeModals();
|
||||
return await getPageState();
|
||||
}
|
||||
|
||||
export async function createContext(name, url, { extensionPath, isolation = 'tab' } = {}) {
|
||||
if (contexts.has(name)) {
|
||||
await setActiveContext(name);
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
|
||||
catch { await page.waitForTimeout(5000); }
|
||||
await closeModals();
|
||||
return await getPageState();
|
||||
return await openAndSettle(name, url);
|
||||
}
|
||||
|
||||
if (!['tab', 'window'].includes(isolation)) {
|
||||
@@ -370,12 +438,7 @@ export async function createContext(name, url, { extensionPath, isolation = 'tab
|
||||
attachSessionListeners(newPage, slot, name);
|
||||
activateSlot(name);
|
||||
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
|
||||
try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); }
|
||||
catch { await page.waitForTimeout(5000); }
|
||||
await closeModals();
|
||||
|
||||
return await getPageState();
|
||||
return await openAndSettle(name, url);
|
||||
}
|
||||
|
||||
/** Switch the active context. Subsequent browser API calls operate on this context's page. */
|
||||
|
||||
Reference in New Issue
Block a user