4dd40f609f
create/update/list через headless Chromium по образцу refresh-session.js. Селекторы зафиксированы из recon-снапшота rt-add-project-form.yml (Task 1). stdin/stdout JSON, exit codes 0/1/2/3/4 (success/auth/selector/timeout/input). Фикстурный тест против локального HTML — без живого портала. Runner — встроенный node:test (app/playwright не использует @playwright/test, только playwright core); skipLogin режим открывает фикстуру напрямую. Spec §4.3. Task 6 of 12. Node-тесты 2/2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
171 lines
6.3 KiB
JavaScript
171 lines
6.3 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.
|
|
*/
|
|
const { chromium } = require('playwright');
|
|
|
|
const TIMEOUT_MS = 90_000;
|
|
|
|
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]'),
|
|
]);
|
|
}
|
|
|
|
async function fillForm(page, dto) {
|
|
const activeChecked = await page.locator('input[name=active]').isChecked();
|
|
if (activeChecked !== !!dto.active) await page.locator('input[name=active]').click();
|
|
|
|
if (dto.tag) await page.fill('input[name=tag]', dto.tag);
|
|
|
|
for (const p of ['B1', 'B2', 'B3']) {
|
|
const wanted = (dto.platforms || []).includes(p);
|
|
const sel = `input[name="platform[]"][value="${p}"]`;
|
|
const checked = await page.locator(sel).isChecked();
|
|
if (checked !== wanted) await page.locator(sel).click();
|
|
}
|
|
|
|
await page.fill('input[name=name]', dto.name);
|
|
|
|
const signalLabel = { site: 'Сайты', call: 'Звонки', sms: 'СМС' }[dto.signal_type] || 'Сайты';
|
|
await page.selectOption('select[name=signal_type]', { label: signalLabel });
|
|
|
|
if (dto.region_mode === 'exclude') {
|
|
await page.locator('input[name=region_mode][value=exclude]').click();
|
|
}
|
|
|
|
if (dto.domains && dto.domains.length) {
|
|
await page.fill('textarea[name=domains]', dto.domains.join('\n'));
|
|
}
|
|
|
|
await page.fill('input[name=limit]', String(dto.limit));
|
|
|
|
for (let d = 1; d <= 7; d++) {
|
|
const wanted = (dto.workdays || [1, 2, 3, 4, 5, 6, 7]).includes(d);
|
|
const sel = `input[name="workdays[]"][value="${d}"]`;
|
|
const checked = await page.locator(sel).isChecked();
|
|
if (checked !== wanted) await page.locator(sel).click();
|
|
}
|
|
}
|
|
|
|
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 });
|
|
await page.click('button:has-text("Добавить проект")');
|
|
await page.waitForSelector('#add-project-modal', { state: 'visible', timeout: TIMEOUT_MS });
|
|
}
|
|
|
|
await fillForm(page, args.dto);
|
|
const beforeRows = await page.locator('#projects-table tbody tr').count();
|
|
await page.click('#save-btn');
|
|
await page.waitForFunction(
|
|
(before) => document.querySelectorAll('#projects-table tbody tr').length > before,
|
|
beforeRows,
|
|
{ timeout: TIMEOUT_MS },
|
|
);
|
|
|
|
const newRow = page.locator('#projects-table tbody tr').last();
|
|
const externalId = await newRow.getAttribute('data-id');
|
|
|
|
return { external_id: externalId };
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
const row = page.locator(`#projects-table tbody tr[data-id="${args.externalId}"]`);
|
|
await row.locator('button.edit').click();
|
|
await page.waitForSelector('#add-project-modal', { state: 'visible', timeout: TIMEOUT_MS });
|
|
await fillForm(page, args.dto);
|
|
await page.click('#save-btn');
|
|
await page.waitForSelector('#add-project-modal', { state: 'hidden', timeout: TIMEOUT_MS });
|
|
|
|
return { ok: true };
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
const rows = await page.locator('#projects-table tbody tr').evaluateAll((nodes) =>
|
|
nodes.map((n) => ({
|
|
id: parseInt(n.dataset.id, 10),
|
|
name: n.querySelector('td:nth-child(2)') ? n.querySelector('td:nth-child(2)').textContent : null,
|
|
})),
|
|
);
|
|
|
|
return { projects: rows };
|
|
}
|
|
|
|
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);
|
|
});
|