#!/usr/bin/env node /** * Headless Playwright водит UI «Мои проекты» supplier-портала crm.bp-gr.ru. * * Input (JSON через stdin): * {operation: "create"|"update"|"list", login, password, url, skipLogin?, dto?, externalId?} * * Output (JSON через stdout): * - create: {external_id: "12345"} * - update: {ok: true} * - list: {projects: [...]} * * Exit codes: * 0 — success * 1 — auth failed * 2 — DOM/селектор не найден (контракт UI сменился — escalation cause) * 3 — timeout * 4 — invalid input или другая ошибка * * Spec §4.3. * * KNOWN GAPS (Tier-2 MVP, зафиксированы по recon 2026-05-19): * - workdays: поле add-project форм НЕ содержит чекбоксы дней недели (только slider «Период» * часы 0-24). DTO.workdays игнорируется; портал применяет дефолт (все 7 дней). * Для точной настройки workdays используйте Tier-1 (AJAX). * - regions: форма требует имена регионов, DTO несёт int[] id. Mapping id→name не реализован. * Tier-2 всегда передаёт пустой массив регионов (нет фильтрации). Регионы должны быть * настроены вручную или через Tier-1. */ const { chromium } = require('playwright'); const TIMEOUT_MS = 90_000; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Возвращает локатор form-item по значению атрибута for= у label. * Стратегия: .el-form-item:has(.el-form-item__label[for=""]) */ function fieldByFor(page, attrFor) { return page.locator(`.el-form-item:has(.el-form-item__label[for="${attrFor}"])`); } // --------------------------------------------------------------------------- // Login // --------------------------------------------------------------------------- async function login(page, args) { // skipLogin: args.url — статическая фикстура формы (тестовый режим), // открываем её напрямую и не логинимся. if (args.skipLogin) { await page.goto(args.url, { waitUntil: 'load', timeout: TIMEOUT_MS }); return; } await page.goto(args.url, { waitUntil: 'load', timeout: TIMEOUT_MS }); await page.fill('#loginform-username', args.login); await page.fill('#loginform-password', args.password); await Promise.all([ page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }), page.click('button[type=submit]'), ]); } // --------------------------------------------------------------------------- // fillForm — Element UI label-for локаторы (recon 2026-05-19) // --------------------------------------------------------------------------- async function fillForm(page, dto) { // NOTE: статус active/paused НЕ выставляется через форму. Единственный // .el-switch на форме — это include/exclude регионов («Включить/Исключить», // recon 2026-05-19 row 6), НЕ статус проекта. Статус задаётся дефолтом // портала (active). dto.active игнорируется в Tier-2; switch не трогаем // (regions skip — см. ниже). Verified live 2026-05-19. // --- 1. Tag --- if (dto.tag !== undefined && dto.tag !== null) { await fieldByFor(page, 'tag').locator('input.el-input__inner').fill(String(dto.tag)); } // --- 2. Platforms (srcrt) — B1/B2/B3 checkboxes --- // Initial: все три checked. Нужно включить только те, что в dto.platforms, остальные выключить. const platformContainer = fieldByFor(page, 'srcrt'); for (const p of ['B1', 'B2', 'B3']) { const wanted = (dto.platforms || []).includes(p); // Identification — по `.el-checkbox__label` textContent (per recon-doc // 2026-05-19-rt-project-form-locators.md row 2: реальный портал НЕ имеет // `data-platform`-атрибута, inputs без `name`). Whitespace-tolerant `^\s*B1\s*$`. const cb = platformContainer.locator('.el-checkbox').filter({ has: page.locator('.el-checkbox__label', { hasText: new RegExp(`^\\s*${p}\\s*$`) }), }).first(); const cbClass = await cb.getAttribute('class').catch(() => ''); const isChecked = (cbClass || '').includes('is-checked'); if (!!isChecked !== wanted) { await cb.click(); } } // --- 3. Name (label for="name") --- // В реальном портале dto.name заполняется в поле «Название проекта», // а dto.uniqueKey (список сайтов/номеров) — в textarea «content». // manage-project.js получает dto.name напрямую. if (dto.name !== undefined) { await fieldByFor(page, 'name').locator('input.el-input__inner').fill(String(dto.name)); } // --- 4. Type select (label for="type") --- // El-select readonly input. Клик открывает popup в body > .el-select-dropdown. const signalTypeMap = { site: 'Сайты', call: 'Звонки', sms: 'СМС' }; const signalLabel = signalTypeMap[dto.signal_type]; if (!signalLabel) { throw new Error( `Unsupported signal_type "${dto.signal_type}". Supported: site, call, sms. ` + '"Ретро сайты" / "Ретро звонки" are not supported in Tier-2 form channel.', ); } // Тип меняем ТОЛЬКО если текущее значение ≠ нужное. Смена типа ремоунтит // content tab-pane (Сайты/Звонки/СМС — разные поля сбора) → если сразу // после type-select заполнять content, fill попадёт в detached textarea // (Vue ещё не закончил ре-рендер) → rt-project-save уходит с пустым // `content` → портал «Введите домены». Verified live 2026-05-19. const typeInput = fieldByFor(page, 'type').locator('.el-select input.el-input__inner'); const currentType = (await typeInput.inputValue().catch(() => '')).trim(); if (currentType !== signalLabel) { await typeInput.click(); // Dropdown рендерится снаружи формы в body — ждём его появления const dropdownOption = page.locator('.el-select-dropdown__item', { hasText: new RegExp(`^${signalLabel}$`), }); await dropdownOption.waitFor({ state: 'visible', timeout: TIMEOUT_MS }); await dropdownOption.click(); // Ждём, пока Vue завершит ре-рендер content tab-pane после смены типа. await page.waitForTimeout(1000); } // --- 7. Regions (label for="regions") — SKIP, gap зафиксирован в JSDoc --- // DTO несёт int[] id; форма требует имена. Mapping не реализован для MVP. if (dto.regions && dto.regions.length > 0) { process.stderr.write( JSON.stringify({ warning: 'regions skipped in Tier-2 form channel: DTO carries int[] ids but form requires region names. ' + 'Region filtering will not be applied. Configure regions manually or use Tier-1.', regions_received: dto.regions, }) + '\n', ); } // --- 9. Content — список сайтов/номеров/отправителей (label for="content") --- // Вкладка «Список» (default active). dto.domains — массив строк или dto.uniqueKey — строка. const contentLines = dto.domains && dto.domains.length ? dto.domains.join('\n') : dto.uniqueKey ? String(dto.uniqueKey) : null; if (contentLines) { const contentField = fieldByFor(page, 'content'); // Вкладка «Список» — default active. Кликаем ТОЛЬКО если она НЕ активна: // клик по вкладке Element UI ремоунтит tab-pane → textarea детачится, // и последующий .fill() гонится с ре-рендером (домены теряются → // rt-project-save уходит с пустым `content` → портал «Введите домены»). // Verified live 2026-05-19: re-click активной вкладки ломал save. const listTab = contentField.locator('.el-tabs__item', { hasText: 'Список' }).first(); if ((await listTab.count()) > 0) { const tabClass = (await listTab.getAttribute('class')) || ''; if (!tabClass.includes('is-active')) { await listTab.click(); await contentField.locator('textarea.el-textarea__inner') .waitFor({ state: 'visible', timeout: TIMEOUT_MS }); } } const contentTa = contentField.locator('textarea.el-textarea__inner'); await contentTa.fill(contentLines); // Defensive: убедиться, что значение действительно осело в textarea // (если поле детачнулось ре-рендером — fill уйдёт в пустоту). const filledValue = await contentTa.inputValue(); if (filledValue.trim() === '') { throw new Error( 'Content textarea empty after fill — likely tab/type re-render race; domains lost', ); } } // --- 10. Limit (label for="limit") --- if (dto.limit !== undefined) { await fieldByFor(page, 'limit').locator('input.el-input__inner').fill(String(dto.limit)); } // NOTE: workdays — gap зафиксирован в JSDoc. Форма add-project не содержит // чекбоксы дней недели. dto.workdays игнорируется. if (dto.workdays && dto.workdays.length !== 7) { process.stderr.write( JSON.stringify({ warning: 'workdays ignored in Tier-2 form channel: add-project form has no workdays field. ' + 'Portal will apply default (all 7 days). Configure workdays manually or use Tier-1.', workdays_received: dto.workdays, }) + '\n', ); } } // --------------------------------------------------------------------------- // createOp // --------------------------------------------------------------------------- async function createOp(page, args) { await login(page, args); if (!args.skipLogin) { await page.goto( args.url.replace(/\/$/, '') + '/admin/visit/rt', { waitUntil: 'load', timeout: TIMEOUT_MS }, ); // Кнопка «Добавить проект» — recon: label [title="Добавить проект"] await page.locator('button:has-text("Добавить проект")').click(); // Ждём появления формы — label for="name" внутри .el-form await page.locator('.el-form-item__label[for="name"]').waitFor({ state: 'visible', timeout: TIMEOUT_MS, }); } await fillForm(page, args.dto); // Кликаем «Сохранить» + перехватываем ответ rt-project-save const [saveResponse] = await Promise.all([ page.waitForResponse( (r) => r.url().endsWith('/admin/visit/rt-project-save') && r.request().method() === 'POST', { timeout: TIMEOUT_MS }, ), page.locator('.v-dialog--active button:has-text("Сохранить")').click(), ]); const body = await saveResponse.json(); if (body.status !== 'OK') { // DIAG: дамп фактически отправленного тела — для расследования "Введите домены" const sentBody = saveResponse.request().postData(); process.stderr.write(JSON.stringify({ diag_sent_body: sentBody }) + '\n'); throw new Error(`Portal rejected save: ${body.message || 'unknown error'}`); } const externalId = String(body.id ?? ''); if (!externalId) { throw new Error('Portal returned status=OK but empty id'); } return { external_id: externalId }; } // --------------------------------------------------------------------------- // updateOp // --------------------------------------------------------------------------- async function updateOp(page, args) { await login(page, args); if (!args.skipLogin) { await page.goto( args.url.replace(/\/$/, '') + '/admin/visit/rt', { waitUntil: 'load', timeout: TIMEOUT_MS }, ); } // Найти строку таблицы по externalId и кликнуть кнопку редактирования. // Реальная таблица портала — Vuetify data-table; строки по data-id или текстовому совпадению. // Стратегия 1: строка с атрибутом data-id const rowLocator = page.locator(`tr[data-id="${args.externalId}"], [data-id="${args.externalId}"]`); const rowCount = await rowLocator.count(); if (rowCount > 0) { await rowLocator.first().locator('button').first().click(); } else { // Стратегия 2: найти строку содержащую текст externalId и кликнуть edit-кнопку await page.locator(`tr:has-text("${args.externalId}")`).first().locator('button').first().click(); } // Дождаться формы await page.locator('.el-form-item__label[for="name"]').waitFor({ state: 'visible', timeout: TIMEOUT_MS, }); await fillForm(page, args.dto); // Перехватываем ответ rt-project-save при update (тот же endpoint) const [saveResponse] = await Promise.all([ page.waitForResponse( (r) => r.url().endsWith('/admin/visit/rt-project-save') && r.request().method() === 'POST', { timeout: TIMEOUT_MS }, ), page.locator('.v-dialog--active button:has-text("Сохранить")').click(), ]); const body = await saveResponse.json(); if (body.status !== 'OK') { throw new Error(`Portal rejected update: ${body.message || 'unknown error'}`); } return { ok: true }; } // --------------------------------------------------------------------------- // listOp // --------------------------------------------------------------------------- async function listOp(page, args) { await login(page, args); if (!args.skipLogin) { await page.goto( args.url.replace(/\/$/, '') + '/admin/visit/rt', { waitUntil: 'load', timeout: TIMEOUT_MS }, ); } // Стратегия 1: Vuex state (если доступен) const projects = await page.evaluate(() => { try { if (window.app && window.app.$store && window.app.$store.state) { const st = window.app.$store.state; const list = st.projects || st.rtProjects || st.visitProjects || null; if (Array.isArray(list)) { return list.map((p) => ({ id: parseInt(p.id, 10), name: p.name || p.title || null, platform: p.platform || null, signal_type: p.type || p.signal_type || null, unique_key: p.content || p.unique_key || null, })); } } } catch (_) { /* Vuex недоступен */ } return null; }); if (projects !== null) { return { projects }; } // Стратегия 2: DOM-скрейп таблицы // Реальная таблица портала: строки tr с data-id или стандартные td const rows = await page.locator('table tbody tr[data-id], .v-data-table tbody tr[data-id]').evaluateAll( (nodes) => nodes.map((n) => ({ id: parseInt(n.dataset.id || '0', 10), name: n.querySelector('td:nth-child(2)') ? n.querySelector('td:nth-child(2)').textContent.trim() : null, })), ); if (rows.length > 0) { return { projects: rows }; } // Стратегия 3: фикстура / пустая страница — возвращаем пустой массив return { projects: [] }; } // --------------------------------------------------------------------------- // Entry point // --------------------------------------------------------------------------- async function run(args) { // browser.launch ВНУТРИ try: отказ запуска браузера классифицируется как // exit 4 + JSON stderr, а не уходит unhandled-rejection'ом в exit 1 // (двойник «login rejected»). FN-SESSION, приёмка 22.06.2026. let browser = null; try { browser = await chromium.launch({ headless: true }); const ctx = await browser.newContext(); const page = await ctx.newPage(); let out; switch (args.operation) { case 'create': out = await createOp(page, args); break; case 'update': out = await updateOp(page, args); break; case 'list': out = await listOp(page, args); break; default: throw new Error('Unknown operation: ' + args.operation); } process.stdout.write(JSON.stringify(out)); process.exit(0); } catch (err) { process.stderr.write(JSON.stringify({ error: err.message })); if (err.message.includes('Timeout')) process.exit(3); if ( err.message.toLowerCase().includes('selector') || err.message.toLowerCase().includes('locator') ) process.exit(2); if ( err.message.toLowerCase().includes('login') || err.message.toLowerCase().includes('auth') ) process.exit(1); process.exit(4); } finally { if (browser) { await browser.close(); } } } let input = ''; process.stdin.on('data', (c) => { input += c; }); process.stdin.on('end', () => { let args; try { args = JSON.parse(input); } catch (e) { process.stderr.write(JSON.stringify({ error: 'invalid JSON on stdin' })); process.exit(4); } if (!args.operation || !args.url) { process.stderr.write(JSON.stringify({ error: 'missing required: operation, url' })); process.exit(4); } // Top-level guard: rejection не пойманная в run() → чистый exit 4 + JSON stderr. run(args).catch((err) => { const message = err && err.message ? err.message : String(err); process.stderr.write(JSON.stringify({ error: message })); process.exit(4); }); });