- bindSearchKeys:Enter 展开搜索并聚焦(搜索框已展开/焦点在可编辑元素时放行), Space 激活焦点所在的 a[href]/[role=button](preventDefault 防滚动) - bindSearchKeys/enablePeekGuard/mountViewSwitch 加幂等守卫: DOM 抖动场景下 HEAD 版泄漏 914 个 keydown 监听器,修复后 0 - MutationObserver 自触发加 selfWrite 计数 + 100ms 节流 - showWorkspaceBlockedModal 的 Escape 从 document 改挂 overlay - 验收脚本:verify-search-key.js(真实按键,页面内计数断言)、 verify-swipe.js(CDP 注入真实触摸,断言分组真的切换) - README/AGENTS:快捷键表、手势验收方法、改样式前先确认设计基准(§9)
179 lines
8.7 KiB
JavaScript
179 lines
8.7 KiB
JavaScript
// 验收键盘快捷键(web/navi.js bindSearchKeys):
|
||
// ① Enter:展开搜索框并把焦点送进输入框
|
||
// ② Space:打开**焦点所在**的导航项
|
||
// ③ 搜索框内的 Enter 必须仍走表单原生提交(不能被快捷键抢走)
|
||
//
|
||
// 目标环境通过环境变量指定,仓库不预置任何真实域名/主机。
|
||
// PORTAL_BASE / PORTAL_COOKIE / PORTAL_COOKIE_DOMAIN / CDP
|
||
//
|
||
// 实测依据(真实 Dashy V4.4.4 + 真实按键):
|
||
// 焦点在卡片 <a> 上时,原生 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); }); |