feat(web-test): ловить диалог авторизации + чистая диагностика в start/run

Второй вход в ту же ловушку: публикация без пользователя (нет Usr= в vrd).
Клиент показывает диалог «Пользователь/Пароль/Войти», ввод учётных данных
движок не поддерживает — но вместо ошибки он ждал 67.7с, а затем closeModals()
жал Escape и диалог ИСЧЕЗАЛ. Оставалась пустая страница, объявленная здоровым
стартом, и дальше та же ложь про «режим отображения панели».

Разведка изменила постановку в двух местах:

1. Улику затирал сам движок. Замерено: одного Escape достаточно, чтобы форма
   логина пропала. Поэтому детект обязан отработать ДО closeModals.
2. Комментарий «страница логина легитимна» оказался фикцией: войти руками через
   start было невозможно и раньше — движок сам уничтожал форму. Значит падать
   тут ничего не ломает, и развилка «в раннере падать, в интерактиве нет»
   отпадает. Комментарий переписан, чтобы следующий читатель не поверил ему
   больше, чем коду.

Хуже, чем с лицензией: на диалоге авторизации сеанс 1С УСПЕВАЕТ создаться и
держит лицензию впустую. Поэтому connect() освобождает его (disconnect) перед
тем, как ошибка уйдёт наверх: cmdStart зовёт connect без catch, run.mjs не
оборачивает команду, а убийство процесса лицензию не освобождает.

- session.mjs (v1.19→v1.20): +1 якорь #authWindow в тот же предикат, своё
  сообщение с рецептом (-UserName → Usr=/Pwd=), очистка в connect().
- start.mjs (v1.0→v1.1), run.mjs (v1.0→v1.1): стартовый блокер — это диагноз,
  а не крах. Три строки и код 1 вместо стека, который указывал внутрь
  session.mjs и читался бы как поломка движка (модель пошла бы чинить не то).

Проверено вживую:
- шаг 0 (до кода): #authWindow не существует на здоровых стартах — ни в клиенте,
  ни при загрузке; проверено и на bpdemo с автологином (19.5с, опрос 100мс);
- позитивный контроль на bpdemo-auth (BP_DEMO без Usr=): бросок за 2.7с вместо
  67.7с, слот убран из реестра, лицензия вернулась — 6/6;
- то же через connect(): сообщение, браузер закрыт, сеанс освобождён;
- негативный контроль: полный регресс 25/25, ноль ложных срабатываний; start на
  bpdemo (автологин) поднимается штатно;
- попутно на испорченном прогоне (сам съел лицензию соседней сессией) детект
  отработал в раннере на НАСТОЯЩЕМ дефиците: два мультиконтекстных теста упали
  с внятной причиной, остальные 23 прошли — прогон поехал дальше.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-07-15 21:14:13 +03:00
parent 9bec6171d3
commit 2a71e6c980
3 changed files with 67 additions and 15 deletions
@@ -1,4 +1,4 @@
// web-test cli/commands/run v1.0 — autonomous connect → exec → disconnect (no server)
// web-test cli/commands/run v1.1 — autonomous connect → exec → disconnect (no server)
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { readFileSync } from 'fs';
import { resolve } from 'path';
@@ -13,7 +13,14 @@ export async function cmdRun(url, fileOrDash) {
? await readStdin()
: readFileSync(resolve(fileOrDash), 'utf-8');
await browser.connect(url);
// Same as cmdStart: a startup blocker is a diagnosis, not a crash. connect() has already
// released the seance and closed the browser; a stack trace pointing into session.mjs would
// read as an engine bug and send the reader off to debug the wrong thing.
try {
await browser.connect(url);
} catch (e) {
die(e.message);
}
const result = await executeScript(code);
await browser.disconnect();
@@ -1,4 +1,4 @@
// web-test cli/commands/start v1.0
// web-test cli/commands/start v1.1
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import http from 'http';
import { writeFileSync } from 'fs';
@@ -10,7 +10,15 @@ import { handleRequest } from '../server.mjs';
export async function cmdStart(url) {
if (!url) die('Usage: node src/run.mjs start <url>');
const state = await browser.connect(url);
// A startup blocker (no free 1C licence, publication asking for credentials) is a diagnosis,
// not a crash — connect() already released the seance and closed the browser, so print the
// message and leave instead of dumping a stack trace that reads like an engine failure.
let state;
try {
state = await browser.connect(url);
} catch (e) {
die(e.message);
}
const httpServer = http.createServer(handleRequest);
httpServer.listen(0, '127.0.0.1', () => {
@@ -1,4 +1,4 @@
// web-test core/session v1.19 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry.
// web-test core/session v1.20 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry.
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import { chromium } from 'playwright';
@@ -61,15 +61,23 @@ function findExtension(overridePath) {
* 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).
* Two blockers are recognised, both by id:
* #messageBoxText — the startup message box (no free licence, and any other startup error)
* #authWindow — the login dialog: the publication wants credentials, which the engine
* cannot supply. Landing here used to be treated as legitimate ("login
* page"), but that was fiction: closeModals() presses Escape 5x right after
* this wait, which dismisses the dialog and leaves a blank page reported as
* a healthy start. Nobody could ever log in by hand either.
*
* Contract: throw ONLY on positive evidence — a visible dialog. A missing client marker is NOT
* evidence (an unknown or merely slow start must keep its old behaviour), hence the fallback.
*
* 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.
* into the message. Verified on this stand — on a healthy start neither #messageBoxText nor
* #authWindow exists at all, in the loaded client or at any point while the shell boots (polled
* every 100ms, including bpdemo's 19.5s auto-login boot). The offsetWidth/text conjuncts guard a
* future build that pre-renders them hidden. 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;
@@ -78,6 +86,8 @@ async function waitForClientOrStartupBlock(pg, url, timeout = INIT_TIMEOUT) {
if (document.querySelector('#themesCell_theme_0')) return 'client';
const box = document.querySelector('#messageBoxText');
if (box && box.offsetWidth > 0 && box.textContent.trim()) return 'blocked';
const auth = document.querySelector('#authWindow');
if (auth && auth.offsetWidth > 0) return 'auth';
return false;
}, null, { timeout }).then(h => h.jsonValue());
} catch {
@@ -96,11 +106,28 @@ async function waitForClientOrStartupBlock(pg, url, timeout = INIT_TIMEOUT) {
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;
if (text) return { kind: 'blocked', text, seances: visibleText(document.querySelector('#seancesToFinish')) };
const auth = document.querySelector('#authWindow');
if (auth && auth.offsetWidth > 0) return { kind: 'auth', text: visibleText(auth) };
return null;
}).catch(() => null);
if (!evidence) return; // flicker — carry on exactly as before
const oneLine = (s) => s.replace(/\s+/g, ' ').trim().slice(0, 300);
if (evidence.kind === 'auth') {
// The publication asks a human for credentials. The engine cannot answer, and until now it
// did something worse than fail: it waited 66s and then closeModals()'s Escape dismissed the
// dialog, leaving a blank page reported as a healthy start. Note a seance IS already created
// here (unlike the licence case) and holds a licence — callers release it before rethrowing.
throw new Error(
`1C requires interactive login before the web client loads: "${oneLine(evidence.text)}"` +
'\n Движок ввод учётных данных не поддерживает — опубликуйте базу с пользователем' +
' (web-publish -UserName … → Usr=/Pwd= в vrd) либо укажите его в строке соединения.' +
`\n URL: ${url}`
);
}
throw new Error(
`1C startup blocked before the web client loaded: "${oneLine(evidence.text)}"` +
(evidence.seances ? `\n Сеансы, предложенные платформой к завершению: ${oneLine(evidence.seances)}` : '') +
@@ -160,8 +187,18 @@ export async function connect(url, { extensionPath } = {}) {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT });
}
// Wait for 1C to initialize — or fail fast if the startup shell blocks the client
await waitForClientOrStartupBlock(page, url);
// Wait for 1C to initialize — or fail fast if the startup shell blocks the client.
// MUST run before closeModals(): that presses Escape 5x, which dismisses the auth dialog and
// destroys the evidence (measured — one Escape blanks the page).
try {
await waitForClientOrStartupBlock(page, url);
} catch (e) {
// On the auth dialog a 1C seance already exists and holds a licence, and killing the process
// does NOT release it. cmdStart has no catch and run.mjs does not wrap the command, so the
// error escapes and the process dies — release the seance here, while we still can.
await softDeadline(disconnect(), 20000, 'disconnect(startup-block)');
throw e;
}
// Try to close startup modals (Путеводитель etc.)
await closeModals();