food-market/tests/e2e/tools/site-fixes-verify-2.mjs
nns 5c5f92de52
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Public / Build + push Public (test + latest) (push) Waiting to run
Docker Public / Deploy :test → test.food-market.kz (push) Blocked by required conditions
docs(site): отчёт и скриншоты проверки круга 2 (44/44) + verify-скрипт
Проверка на живом test.food-market.kz после деплоя: React #418 ушёл, дефолтный
«Бизнес» 10 000, чужих систем учёта 0 (Kaspi/маркетплейсы только «в разработке»),
типографика KB, sitemap со слэшами, мобильные правки (Android «Назад», ползунки
−/+, состав панели), честные /status и /kb/billing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-11 01:29:07 +05:00

170 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

// Проверка второго круга исправлений публичного сайта test.food-market.kz
// (как человек: Playwright, десктоп 1440 + моб. 390). Вывод — docs/site-fixes-2026-10-11/.
import { chromium } from '@playwright/test'
import { mkdirSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
const BASE = process.env.SITE_URL ?? 'https://test.food-market.kz'
const OUT = resolve('/home/nns/food-market', 'docs/site-fixes-2026-10-11')
mkdirSync(OUT, { recursive: true })
const results = []
const shots = []
const ok = (name, cond, detail = '') => { results.push({ name, ok: !!cond, detail }); console.log(`${cond ? '✓' : '✗'} ${name} ${detail}`) }
const norm = (s) => (s || '').replace(/[\s    ]/g, '')
const browser = await chromium.launch({ args: ['--ignore-certificate-errors'] })
// Сбор ошибок консоли/страницы по URL.
async function consoleErrorsFor(ctx, path) {
const errs = []
const p = await ctx.newPage()
p.on('console', (m) => { if (m.type() === 'error') errs.push(m.text()) })
p.on('pageerror', (e) => errs.push(String(e)))
await p.goto(BASE + path, { waitUntil: 'networkidle' }).catch(() => {})
await p.waitForTimeout(1200)
await p.close()
return errs
}
// ---------- DESKTOP 1440x900 @2x ----------
const dctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, ignoreHTTPSErrors: true })
const d = await dctx.newPage()
async function shotD(path, file, caption) {
await d.goto(BASE + path, { waitUntil: 'networkidle' }).catch(() => {})
await d.waitForTimeout(700)
await d.screenshot({ path: resolve(OUT, file) })
shots.push({ file, caption, kind: 'Десктоп 1440' })
}
// #2 — консоль чистая (React #418 ушёл)
for (const [path, label] of [['/', 'главная'], ['/pricing', '/pricing'], ['/signup?plan=business&shops=3&cashes=4&warehouses=1&employees=5', '/signup?plan=business+params'], ['/signup?plan=business', '/signup?plan=business (без параметров)']]) {
const errs = await consoleErrorsFor(dctx, path)
const react418 = errs.filter((e) => /#418|hydrat|Minified React error/i.test(e))
ok(`консоль чистая: ${label}`, errs.length === 0, errs.length ? errs.slice(0, 2).join(' | ') : '')
ok(`нет React #418 (hydration): ${label}`, react418.length === 0, react418.join(' | '))
}
// #3 — plan=business без параметров → «Бизнес» + дефолтная конфигурация 1/1/1/5 = 10 000
await d.goto(`${BASE}/signup?plan=business`, { waitUntil: 'networkidle' }).catch(() => {})
await d.waitForTimeout(1300)
ok('plan=business без параметров: выбран «Бизнес»', await d.locator('label:has(input[name="plan"]:checked):has-text("Бизнес")').count() > 0)
const cfg = (await d.locator('div:has-text("Ваша конфигурация"):has-text("Магазинов")').last().innerText().catch(() => '')) || ''
ok('plan=business по умолчанию: цена 10 000', norm(cfg).includes('10000'), norm(cfg).slice(0, 40))
ok('plan=business по умолчанию: Магазинов 1 / Складов 1 / Сотрудников 5', /Магазинов:\s*1/.test(cfg) && /Складов:\s*1/.test(cfg) && /Сотрудников:\s*5/.test(cfg))
await d.screenshot({ path: resolve(OUT, 'd-signup-business-default.png'), fullPage: true }); shots.push({ file: 'd-signup-business-default.png', caption: '/signup?plan=business без параметров — «Бизнес» с дефолтом 1/1/1/5 = 10 000 ₸', kind: 'Десктоп 1440' })
// plan=network → «Сеть»; plan=xyz → «Старт»; plan=start tile «5 000 ₸/мес»
await d.goto(`${BASE}/signup?plan=network`, { waitUntil: 'networkidle' }).catch(() => {}); await d.waitForTimeout(1200)
ok('plan=network: выбран «Сеть»', await d.locator('label:has(input[name="plan"]:checked):has-text("Сеть")').count() > 0)
await d.goto(`${BASE}/signup?plan=xyz`, { waitUntil: 'networkidle' }).catch(() => {}); await d.waitForTimeout(1200)
ok('plan=xyz: откат на «Старт»', await d.locator('label:has(input[name="plan"]:checked):has-text("Старт")').count() > 0)
const startTile = norm(await d.locator('label:has(input[name="plan"]):has-text("Старт")').first().innerText().catch(() => ''))
ok('плитка «Старт»: цена «5 000 ₸/мес»', startTile.includes('5000₸/мес'), startTile)
// #4 — типографика KB (prose-md) + billing честный
await d.goto(`${BASE}/kb/billing`, { waitUntil: 'networkidle' }).catch(() => {}); await d.waitForTimeout(500)
ok('KB: markdown отрисован (h2 в .prose-md)', await d.locator('.prose-md h2').count() > 0)
ok('KB: списки отрисованы (ul li)', await d.locator('.prose-md ul li').count() > 0)
const billTxt = await d.locator('.prose-md').innerText().catch(() => '')
ok('KB billing: нет «Настройки → Подписка → История»', !/Настройки.{0,3}Подписка.{0,3}История/.test(billTxt))
ok('KB billing: нет «тг», цены в «₸»', !/\bтг\b/.test(billTxt) && billTxt.includes('₸'))
await d.screenshot({ path: resolve(OUT, 'd-kb-billing.png'), fullPage: true }); shots.push({ file: 'd-kb-billing.png', caption: 'KB «Тарифы и оплата» — markdown-типографика, честный текст (без несуществующего биллинг-UI), ₸', kind: 'Десктоп 1440' })
await shotD('/kb/faq', 'd-kb-faq.png', 'KB FAQ — типографика, «бесплатный период», TLS без версии')
// blog: один h1
await d.goto(`${BASE}/blog/quick-start`, { waitUntil: 'networkidle' }).catch(() => {}); await d.waitForTimeout(500)
ok('блог quick-start: ровно один <h1>', await d.locator('article h1, h1').count() === 1, `h1=${await d.locator('h1').count()}`)
await d.screenshot({ path: resolve(OUT, 'd-blog-quickstart.png'), fullPage: true }); shots.push({ file: 'd-blog-quickstart.png', caption: 'Блог «Быстрый старт» — один заголовок, без чужих систем учёта', kind: 'Десктоп 1440' })
// бренды: системы учёта 0, Kaspi только как «в разработке»
for (const [path, label] of [['/', 'главная'], ['/integrations', '/integrations'], ['/import', '/import'], ['/kb/faq', '/kb/faq'], ['/blog/launch', '/blog/launch']]) {
await d.goto(BASE + path, { waitUntil: 'networkidle' }).catch(() => {}); await d.waitForTimeout(600)
const body = await d.locator('body').innerText().catch(() => '')
ok(`нет чужих систем учёта (МойСклад/1С): ${label}`, !/мойсклад|мой склад|moysklad|1с\b|1c\b/i.test(body), '')
}
await d.goto(`${BASE}/integrations`, { waitUntil: 'networkidle' }).catch(() => {}); await d.waitForTimeout(500)
const integ = await d.locator('body').innerText().catch(() => '')
ok('/integrations: Kaspi присутствует (в блоке «в разработке»)', /kaspi/i.test(integ))
ok('/integrations: блок «В разработке» есть', /в разработке/i.test(integ))
await d.screenshot({ path: resolve(OUT, 'd-integrations.png'), fullPage: true }); shots.push({ file: 'd-integrations.png', caption: '/integrations — платёжки/маркетплейсы только в «В разработке»; «уже работает» без чужих систем учёта', kind: 'Десктоп 1440' })
// #5 pricing: Популярный + 90 дней + конструктор id; changelog без жаргона; status без фикции
await d.goto(`${BASE}/pricing`, { waitUntil: 'networkidle' }).catch(() => {}); await d.waitForTimeout(700)
const priceBody = await d.locator('body').innerText().catch(() => '')
ok('/pricing: бейдж «Популярный» на конструкторе', await d.locator('#configurator :text("Популярный")').count() > 0)
ok('/pricing: «90 дней бесплатно» в конструкторе', /90 дней бесплатно/.test(priceBody))
ok('/pricing: якорь #configurator есть', await d.locator('#configurator').count() > 0)
ok('/pricing: ползунки с кнопками −/+', await d.locator('#configurator button[aria-label^="Увеличить"]').count() >= 3)
await d.screenshot({ path: resolve(OUT, 'd-pricing.png'), fullPage: true }); shots.push({ file: 'd-pricing.png', caption: '/pricing — конструктор «Бизнес»: бейдж «Популярный», «90 дней бесплатно», кнопки −/+', kind: 'Десктоп 1440' })
await d.goto(`${BASE}/changelog`, { waitUntil: 'networkidle' }).catch(() => {}); await d.waitForTimeout(400)
const chg = await d.locator('body').innerText().catch(() => '')
ok('/changelog: без жаргона (Phase/SuperAdmin/retention/tenant)', !/phase\s*\d|superadmin|retention|tenant/i.test(chg))
await d.screenshot({ path: resolve(OUT, 'd-changelog.png'), fullPage: true }); shots.push({ file: 'd-changelog.png', caption: '/changelog («Что нового») — человеческие формулировки без внутреннего жаргона', kind: 'Десктоп 1440' })
await d.goto(`${BASE}/status`, { waitUntil: 'networkidle' }).catch(() => {}); await d.waitForTimeout(400)
const st = await d.locator('body').innerText().catch(() => '')
ok('/status: нет фиктивного «● Работает»', !/●\s*Работает/.test(st))
ok('/status: честная пометка «в разработке»', /в разработке/i.test(st))
await d.screenshot({ path: resolve(OUT, 'd-status.png'), fullPage: true }); shots.push({ file: 'd-status.png', caption: '/status — без фиктивных зелёных индикаторов, честно «в разработке» + контакты', kind: 'Десктоп 1440' })
// sitemap — слэши + kb/blog
const sm = await (await dctx.request.get(`${BASE}/sitemap.xml`, { ignoreHTTPSErrors: true }).catch(() => null))?.text?.().catch(() => '') || ''
ok('sitemap: канонические URL со слэшем', /\/features\/<\/loc>/.test(sm) && !/\/features<\/loc>/.test(sm))
ok('sitemap: статьи KB включены', /\/kb\/billing\//.test(sm) && /\/kb\/faq\//.test(sm))
ok('sitemap: статьи блога включены', /\/blog\/launch\//.test(sm) && /\/blog\/quick-start\//.test(sm))
// ---------- MOBILE 390x844 @3x ----------
const mctx = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, ignoreHTTPSErrors: true, isMobile: true, hasTouch: true })
const m = await mctx.newPage()
await m.goto(BASE + '/', { waitUntil: 'networkidle' }).catch(() => {}); await m.waitForTimeout(800)
ok('моб. главная: нет горизонтальной прокрутки', await m.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1))
// CTA «Начать бесплатно» в одну строку
const ctaH = await m.locator('header a[href="/signup"]').first().evaluate((el) => el.getBoundingClientRect().height).catch(() => 0)
ok('моб. шапка: CTA «Начать бесплатно» в одну строку', ctaH > 0 && ctaH < 56, `h=${Math.round(ctaH)}px`)
await m.screenshot({ path: resolve(OUT, 'm-home-closed.png') }); shots.push({ file: 'm-home-closed.png', caption: 'Моб. главная 390px — CTA не переносится, гамбургер в шапке', kind: 'Моб. 390' })
const burger = m.locator('#mobile-menu-btn')
await burger.click().catch(() => {}); await m.waitForTimeout(400)
ok('моб. меню открывается', await m.locator('#mobile-menu:not(.hidden)').count() > 0)
ok('моб. меню: есть «Интеграции»', await m.locator('#mobile-menu a[href="/integrations"]').count() > 0)
ok('моб. меню: есть «Статус»', await m.locator('#mobile-menu a[href="/status"]').count() > 0)
ok('моб. меню: есть «Что нового» (changelog)', await m.locator('#mobile-menu a[href="/changelog"]').count() > 0)
await m.screenshot({ path: resolve(OUT, 'm-menu-open.png') }); shots.push({ file: 'm-menu-open.png', caption: 'Моб. меню — полный состав + Статус/Что нового внизу', kind: 'Моб. 390' })
// #1 Android «Назад» закрывает меню (history.back при открытой панели)
const urlBefore = m.url()
await m.goBack().catch(() => {}); await m.waitForTimeout(500)
ok('моб. «Назад» закрывает меню (не уводит со страницы)', (await m.locator('#mobile-menu.hidden').count() > 0) && m.url().replace(/#.*$/, '') === urlBefore.replace(/#.*$/, ''), m.url())
await m.screenshot({ path: resolve(OUT, 'm-after-back.png') }); shots.push({ file: 'm-after-back.png', caption: 'Моб. после системной «Назад» — панель закрыта, остались на главной', kind: 'Моб. 390' })
// конструктор на моб. — кнопки −/+ и ручка
await m.goto(`${BASE}/pricing`, { waitUntil: 'networkidle' }).catch(() => {}); await m.waitForTimeout(700)
ok('моб. /pricing: кнопки −/+ у ползунков', await m.locator('#configurator button[aria-label^="Увеличить"]').count() >= 3)
await m.locator('#configurator button[aria-label^="Увеличить: Складов"]').first().click().catch(() => {}); await m.waitForTimeout(300)
await m.screenshot({ path: resolve(OUT, 'm-pricing-configurator.png'), fullPage: true }); shots.push({ file: 'm-pricing-configurator.png', caption: 'Моб. конструктор — крупные ручки + кнопки −/+ (значение меняется кнопкой, не случайным свайпом)', kind: 'Моб. 390' })
await m.goto(`${BASE}/signup?plan=business`, { waitUntil: 'networkidle' }).catch(() => {}); await m.waitForTimeout(1200)
await m.screenshot({ path: resolve(OUT, 'm-signup-business-default.png'), fullPage: true }); shots.push({ file: 'm-signup-business-default.png', caption: 'Моб. /signup?plan=business без параметров — «Бизнес» с дефолтом 10 000 ₸', kind: 'Моб. 390' })
await browser.close()
// index.html
const rows = shots.map((s) => `<figure><figcaption>${s.kind}: ${s.caption}</figcaption><img src="${s.file}" loading="lazy"></figure>`).join('\n')
const checks = results.map((r) => `<li class="${r.ok ? 'ok' : 'no'}">${r.ok ? '✓' : '✗'} ${r.name}${r.detail ? ` — <span class="dt">${r.detail}</span>` : ''}</li>`).join('\n')
const html = `<!doctype html><meta charset="utf-8"><title>Исправления сайта 2026-10-11 (круг 2)</title>
<style>body{font:15px system-ui;margin:0;padding:24px;background:#f8fafc;color:#0f172a;max-width:1100px;margin:auto}
h1{font-size:22px} figure{margin:0 0 24px;background:#fff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden}
figcaption{padding:8px 12px;font-size:13px;color:#475569;background:#f1f5f9} img{display:block;width:100%}
ul{background:#fff;border:1px solid #e2e8f0;border-radius:10px;padding:14px 32px} li{margin:4px 0}
.ok{color:#16a34a} .no{color:#dc2626;font-weight:700} .dt{color:#64748b;font-size:12px}</style>
<h1>Исправления публичного сайта test.food-market.kz — круг 2 (${new Date().toISOString().slice(0, 10)})</h1>
<p>Проверка после деплоя (${new Date().toISOString()}). Пройдено: ${results.filter((r) => r.ok).length}/${results.length}.</p>
<h2>Автопроверки</h2><ul>${checks}</ul>
<h2>Скриншоты</h2>${rows}`
writeFileSync(resolve(OUT, 'index.html'), html)
writeFileSync(resolve(OUT, 'verify.json'), JSON.stringify({ results, shots }, null, 2))
console.log(`\n[site-verify-2] ${results.filter((r) => r.ok).length}/${results.length} passed → ${OUT}/index.html`)
const failed = results.filter((r) => !r.ok)
if (failed.length) console.log('FAILED:', failed.map((f) => f.name).join('; '))