Files
HomeDash/deploy/verify-search-key.js
JianFeeeee 270445a516 feat(navi): Enter 唤起搜索框 + Space 激活焦点卡片;监听器幂等守卫
- 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)
2026-10-02 11:24:55 +08:00

179 lines
8.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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.

// 验收键盘快捷键(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); });