Files
portal/app/playwright/manage-project.js
T
Дмитрий efd3e73aa2 fix(supplier): manage-project.js — drop wrong status-switch click + recon live-smoke
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 исправлен.
2026-05-19 17:31:15 +03:00

406 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
});