efd3e73aa2
Task 4 live-smoke выявил: единственный .el-switch формы — include/exclude регионов (regions_reverse), НЕ статус active/paused. Старый код кликал его по dto.active → ошибочно ставил regions_reverse. Статус — дефолт портала (active), UI-switch для него нет → switch-блок удалён. recon-doc 2026-05-19-rt-project-form-locators.md: +секция Live-smoke (domain-формат валидируется, multi-source save = N проектов, switch = regions, type/tab re-render); row 6 исправлен.
406 lines
18 KiB
JavaScript
406 lines
18 KiB
JavaScript
#!/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="<attrFor>"])
|
||
*/
|
||
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) {
|
||
const browser = await chromium.launch({ headless: true });
|
||
try {
|
||
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 {
|
||
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);
|
||
}
|
||
run(args);
|
||
});
|