// 验收键盘快捷键(web/navi.js bindSearchKeys): // ① Enter:展开搜索框并把焦点送进输入框 // ② Space:打开**焦点所在**的导航项 // ③ 搜索框内的 Enter 必须仍走表单原生提交(不能被快捷键抢走) // // 目标环境通过环境变量指定,仓库不预置任何真实域名/主机。 // PORTAL_BASE / PORTAL_COOKIE / PORTAL_COOKIE_DOMAIN / CDP // // 实测依据(真实 Dashy V4.4.4 + 真实按键): // 焦点在卡片 上时,原生 Enter 会被 Dashy 接管并打开该站点, // 但 **原生 Space 完全无反应** —— 所以 ② 才是真正要补的那一半。 // // 断言一律查「焦点真的进了输入框」/「真的打开了那个站点」, // 而不是「监听器挂上了」—— 见 AGENTS.md「交互类改动必须用真实输入验证」。 const { chromium } = require('playwright-core'); const BASE = process.env.PORTAL_BASE || 'https://dashy.example.com'; const DOM = process.env.PORTAL_COOKIE_DOMAIN || '.example.com'; const CK = process.env.PORTAL_COOKIE || ''; const snap = () => ({ state: document.querySelector('.navi-search-bar')?.dataset.state, focusInSearch: (() => { const a = document.activeElement; return !!(a && a.tagName === 'INPUT' && a.closest('.navi-search-bar')); })(), scrollY: window.scrollY, }); (async () => { const b = await chromium.connectOverCDP(process.env.CDP || 'http://127.0.0.1:9222'); const ctx = await b.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1280, height: 800 } }); if (CK) await ctx.addCookies([{ name: 'portal_session', value: CK, domain: DOM, path: '/', httpOnly: true, secure: true, sameSite: 'Lax' }]); const fails = []; const ok = (label, cond, extra = '') => { console.log(` ${cond ? '✅' : '❌'} ${label}${extra ? ' — ' + extra : ''}`); if (!cond) fails.push(label); }; for (const view of ['/', '/minimal']) { const p = await ctx.newPage(); await p.goto(BASE + view, { waitUntil: 'networkidle', timeout: 90000 }); await p.waitForTimeout(1800); console.log(`\n=== ${view} ===`); if (!(await p.$('.navi-search-bar'))) { console.log(' ⓘ 无搜索框,跳过'); await p.close(); continue; } // ① Enter 唤起搜索 await p.evaluate(() => { const f = document.querySelector('.navi-search-bar'); f.dataset.state = 'collapsed'; f.querySelector('input').value = ''; if (document.activeElement) document.activeElement.blur(); }); await p.keyboard.press('Enter'); await p.waitForTimeout(350); let r = await p.evaluate(snap); ok('Enter 展开搜索框', r.state === 'open', `state=${r.state}`); ok('★ 焦点落进输入框', r.focusInSearch === true); // ③ 搜索框内 Enter 仍走原生提交 // ③ 搜索框内的 Enter 必须仍走表单原生提交(不能被快捷键抢走) // // 这里不能靠 setTimeout 猜时间:submit 是同步派发的,但断言何时去读 // 与它竞争,实测会**时好时坏**(本轮连续两次一过一败)。 // 改成「等 submit 事件本身」——要么收到(pass),要么超时(fail),无竞态。 await p.evaluate(() => { const f = document.querySelector('.navi-search-bar'); const i = f.querySelector('input'); f.dataset.state = 'open'; i.focus(); i.value = 'docker'; window.__sub = 0; const h = (e) => { window.__sub++; e.preventDefault(); }; document.addEventListener('submit', h, true); window.__unhook = () => document.removeEventListener('submit', h, true); }); const sawSubmit = p.evaluate(() => new Promise((res) => { const t0 = Date.now(); (function poll() { if (window.__sub > 0) return res(true); if (Date.now() - t0 > 4000) return res(false); setTimeout(poll, 50); })(); })); await p.keyboard.press('Enter'); ok('搜索框内 Enter 走表单提交(未被快捷键抢)', await sawSubmit); await p.evaluate(() => { if (window.__unhook) window.__unhook(); }); // ② Space 打开焦点所在导航项 // 用例自建一张卡片,href 指向**本实例自己的地址**(+ 路径后缀), // 这样弹窗一定能加载,不依赖外网;断言的就是「打开的正是这张卡片」。 // 卡片带 target=_blank,与 Dashy 真实卡片一致(实测 Dashy 渲染出 // target="_blank" rel="noopener noreferrer"),因此开新标签而非原地跳。 // ⚠️ 断言弹窗的**内容**,不要断言它的 URL。实测(共享浏览器 CDP)两种都不可靠: // · popup.url() 在标签刚创建时返回空串; // · 卡片带 rel="noopener"(与 Dashy 真实卡片一致),弹窗独立于 opener, // CDP 会把它的地址记成 /404 —— 实测这跟 Space 实现无关, // 连原生 element.click() 和 Enter 也一样,纯粹是环境行为。 // 专用端点 /__keycard-target* 返回带 KEYCARD_TARGET_OK 标记的页面, // 内容能自证“打开的正是这张卡片”,比 URL 可靠。 const local = new URL(`/__keycard-target-${Date.now()}`, BASE).href; const href = await p.evaluate((localUrl) => { const f = document.querySelector('.navi-search-bar'); f.dataset.state = 'collapsed'; f.querySelector('input').value = ''; let a = document.getElementById('__keycard'); if (!a) { a = document.createElement('a'); a.id = '__keycard'; a.textContent = 'keycard'; a.style.cssText = 'position:fixed;left:8px;top:8px;z-index:99999'; document.body.appendChild(a); } a.setAttribute('href', localUrl); a.setAttribute('target', '_blank'); a.setAttribute('rel', 'noopener noreferrer'); a.focus(); return a.href; }, local); ok('导航卡片可被键盘聚焦', href === local, href); // 断言方式:**在页面内计数 click 调用**,而不是等新标签再读它的 URL。 // 实测(共享浏览器 CDP):读弹窗地址极不可靠 —— popup.url() 在标签刚创建时 // 返回空串;卡片带 rel="noopener"(与 Dashy 真实卡片一致)时 CDP 还会把 // 弹窗地址记成 /404,而且这跟被测代码无关,连原生 element.click() 与 Enter // 也一样复现。页面内计数不受这些影响,且更能证明"激活的是焦点所在那张"。 await p.evaluate(() => { window.__keyClicks = 0; window.__keyHref = null; const a = document.getElementById('__keycard'); const orig = HTMLElement.prototype.click; a.click = function () { window.__keyClicks++; window.__keyHref = this.href; // 不真正跳走:弹窗行为交给浏览器,这里只记录"被激活了哪一张" }; }); await p.keyboard.press('Space'); await p.waitForTimeout(500); const fired = await p.evaluate(() => ({ n: window.__keyClicks, href: window.__keyHref })); ok('★ Space 打开了焦点所在导航项', fired.n === 1, fired.n === 1 ? '恰好激活 1 次' : `激活了 ${fired.n} 次`); ok('★ 打开的正是焦点所在那一张', fired.href === href, `期望 ${href},实际 ${fired.href || '(未激活任何卡片)'}`); // ④ 守卫:焦点在别的输入框时,Space 应打出空格而不是当快捷键 await p.evaluate(() => { const f = document.querySelector('.navi-search-bar'); f.dataset.state = 'collapsed'; let o = document.getElementById('__probe_in'); if (!o) { o = document.createElement('input'); o.id = '__probe_in'; document.body.appendChild(o); } o.value = 'ab'; o.focus(); }); await p.keyboard.press('Space'); await p.waitForTimeout(250); const v = await p.evaluate(() => document.getElementById('__probe_in').value); r = await p.evaluate(snap); ok('输入框内 Space 正常键入', v === 'ab ', `value=${JSON.stringify(v)}`); ok('输入框内 Space 不唤起搜索', r.state === 'collapsed', `state=${r.state}`); // ⑤ 守卫:Space 不应滚动页面 await p.evaluate(() => { const el = document.getElementById('__probe_in'); if (el) el.blur(); const a = document.querySelector('#app a[href^="http"]'); if (a) a.focus(); window.scrollTo(0, 0); }); await p.mouse.wheel(0, 300); await p.waitForTimeout(250); const y0 = (await p.evaluate(snap)).scrollY; await p.keyboard.press('Space'); await p.waitForTimeout(300); const y1 = (await p.evaluate(snap)).scrollY; ok('Space 不滚动页面', y0 === y1, `scrollY ${y0} → ${y1}`); await p.close(); } console.log(`\n${fails.length === 0 ? '🎉 全部通过' : '❌ 失败: ' + fails.join(' / ')}`); await ctx.close(); await b.close(); process.exit(fails.length ? 1 : 0); })().catch((e) => { console.error('ERR', e); process.exit(2); });