// Проверка второго круга исправлений публичного сайта 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: ровно один
', 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) => `${s.kind}: ${s.caption}
`).join('\n')
const checks = results.map((r) => `
${r.ok ? '✓' : '✗'} ${r.name}${r.detail ? ` — ${r.detail}` : ''}`).join('\n')
const html = `Исправления сайта 2026-10-11 (круг 2)
Исправления публичного сайта test.food-market.kz — круг 2 (${new Date().toISOString().slice(0, 10)})
Проверка после деплоя (${new Date().toISOString()}). Пройдено: ${results.filter((r) => r.ok).length}/${results.length}.
Автопроверки
Скриншоты
${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('; '))