// 真实手势验收:CDP 注入触摸序列,断言分组真的切换了。 // AGENTS.md §5 要求:不能只断言"事件到了/监听器挂了",必须断言行为变化。 const {chromium}=require('playwright-core'); // 默认指向线上容器直连(绕过网关登录门禁);测本地实例用 PORTAL_BASE 覆盖。 const BASE = process.env.PORTAL_BASE || 'http://127.0.0.1:18080'; (async () => { const b = await chromium.connectOverCDP(process.env.CDP || 'http://127.0.0.1:9222'); const ctx = await b.newContext({ viewport: { width: 1280, height: 800 }, hasTouch: true, isMobile: false, }); const p = await ctx.newPage(); const cdp = await ctx.newCDPSession(p); await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 }); const fails = []; const ok = (name, pass, detail) => { console.log(` ${pass ? '\x1b[32m✅\x1b[0m' : '\x1b[31m❌\x1b[0m'} ${name}${detail ? ' — ' + detail : ''}`); if (!pass) fails.push(name); }; const errors = []; p.on('pageerror', e => errors.push(String(e).slice(0, 120))); await p.goto(BASE + '/minimal', { waitUntil: 'domcontentloaded', timeout: 60000 }); await p.waitForTimeout(4500); const meta = await p.evaluate(() => ({ swipeFlag: document.documentElement.dataset.naviSwipe || '(无)', heads: document.querySelectorAll('.minimal-headings-row .headings').length, selected: [...document.querySelectorAll('.minimal-headings-row .headings')].findIndex(e => e.classList.contains('selected')), selTitle: (document.querySelector('.minimal-headings-row .headings.selected .section-title') || {}).textContent || '', visibleItems: document.querySelectorAll('.minimal-section-inner.selected .item').length, touchAction: (() => { const e = document.querySelector('.minimal-home'); return e ? getComputedStyle(e).touchAction : '(无)'; })(), })); console.log(`\n════ ${BASE}/minimal ════`); console.log(` = ${meta.swipeFlag}`); console.log(` 页签数 = ${meta.heads} 当前选中 = #${meta.selected} (${JSON.stringify(meta.selTitle.trim())})`); console.log(` 当前分组可见卡片 = ${meta.visibleItems}`); console.log(` .minimal-home touch-action = ${meta.touchAction}`); ok('手势已启用(data-navi-swipe=on)', meta.swipeFlag === 'on', meta.swipeFlag); ok('至少两个分组可切换', meta.heads >= 2, `${meta.heads} 个页签`); const before = meta.selected; // ── 真触摸:向左滑(下一个分组)── const y = Math.round(800 * 0.55); const from = Math.round(1280 * 0.80), to = Math.round(1280 * 0.15); const pt = (x) => [{ x, y, radiusX: 12, radiusY: 12, force: 1, id: 1 }]; await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: pt(from) }); for (let i = 1; i <= 12; i++) { await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: pt(from + (to - from) * i / 12) }); await new Promise(r => setTimeout(r, 14)); } await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); await p.waitForTimeout(1200); const after = await p.evaluate(() => ({ selected: [...document.querySelectorAll('.minimal-headings-row .headings')].findIndex(e => e.classList.contains('selected')), selTitle: (document.querySelector('.minimal-headings-row .headings.selected .section-title') || {}).textContent || '', visibleItems: document.querySelectorAll('.minimal-section-inner.selected .item').length, innerSelected: (() => { const e = document.querySelector('.minimal-section-inner.selected'); return e ? e.className : '(无)'; })(), })); console.log(`\n 滑后:选中 = #${after.selected} (${JSON.stringify(after.selTitle.trim())}) 可见卡片 = ${after.visibleItems}`); ok('★ 真实触摸左滑切换了分组', after.selected !== before, `#${before} → #${after.selected}`); ok('★ 面板内容跟着换了', after.visibleItems !== meta.visibleItems || after.selected !== before, `卡片 ${meta.visibleItems} → ${after.visibleItems}`); // ── 再滑回来 ── const from2 = Math.round(1280 * 0.20), to2 = Math.round(1280 * 0.85); await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: pt(from2) }); for (let i = 1; i <= 12; i++) { await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: pt(from2 + (to2 - from2) * i / 12) }); await new Promise(r => setTimeout(r, 14)); } await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); await p.waitForTimeout(1200); const back = await p.evaluate(() => ({ selected: [...document.querySelectorAll('.minimal-headings-row .headings')].findIndex(e => e.classList.contains('selected')), })); ok('★ 反向右滑能切回', back.selected === before, `#${after.selected} → #${back.selected}`); // ── 竖向滚动不能被误判为横滑 ── const xm = Math.round(1280 * 0.5); const ptx = (yy) => [{ x: xm, y: yy, radiusX: 12, radiusY: 12, force: 1, id: 1 }]; const yStart = 600, yEnd = 200; await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: ptx(yStart) }); for (let i = 1; i <= 10; i++) { await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: ptx(yStart + (yEnd - yStart) * i / 10) }); await new Promise(r => setTimeout(r, 14)); } await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); await p.waitForTimeout(900); const afterV = await p.evaluate(() => [...document.querySelectorAll('.minimal-headings-row .headings')].findIndex(e => e.classList.contains('selected'))); ok('竖滑不误触发横滑切换', afterV === back.selected, `#${back.selected} → #${afterV}`); ok('无 pageerror', errors.length === 0, errors.join(' | ') || '0 个'); console.log(fails.length ? `\n\x1b[31m失败 ${fails.length} 项\x1b[0m` : '\n\x1b[32m全部通过\x1b[0m'); await p.close(); await ctx.close(); await b.close(); process.exit(fails.length ? 1 : 0); })().catch(e => { console.error(e); process.exit(2); });