From 702bf22ac1aba1815d657dcdf556b3e3575fabc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 19 May 2026 17:16:02 +0300 Subject: [PATCH] =?UTF-8?q?fix(supplier):=20manage-project.js=20robustness?= =?UTF-8?q?=20=E2=80=94=20conditional=20type/tab=20clicks=20+=20diag=20dum?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Найдено при Task 4 live-smoke form-канала: - type-select и вкладка «Список» кликались безусловно → re-click уже-активного значения ремоунтит Element UI tab-pane (textarea детачится). Теперь кликаем только при реальной смене значения + waitForTimeout после смены типа. - defensive: проверка непустого textarea после fill content. - diag: на status!=OK дамп фактически отправленного rt-project-save body в stderr. --- app/playwright/manage-project.js | 58 ++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/app/playwright/manage-project.js b/app/playwright/manage-project.js index fdff558c..96e48b96 100644 --- a/app/playwright/manage-project.js +++ b/app/playwright/manage-project.js @@ -124,14 +124,24 @@ async function fillForm(page, dto) { '"Ретро сайты" / "Ретро звонки" are not supported in Tier-2 form channel.', ); } - // Кликаем input чтобы открыть dropdown - await fieldByFor(page, 'type').locator('.el-select input.el-input__inner').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(); + // Тип меняем ТОЛЬКО если текущее значение ≠ нужное. Смена типа ремоунтит + // 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. @@ -154,13 +164,30 @@ async function fillForm(page, dto) { : null; if (contentLines) { const contentField = fieldByFor(page, 'content'); - // Убедиться что вкладка «Список» активна (она default, но на всякий случай) - const listTab = contentField.locator('.el-tabs__item', { hasText: 'Список' }); - const listTabCount = await listTab.count(); - if (listTabCount > 0) { - await listTab.click(); + // Вкладка «Список» — 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', + ); } - await contentField.locator('textarea.el-textarea__inner').fill(contentLines); } // --- 10. Limit (label for="limit") --- @@ -215,6 +242,9 @@ async function createOp(page, args) { 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 ?? '');