Files
lsky-pro-docker/test/longpress.test.mjs
mole404 32859cc5cb fix(frontend): 收窄 infiniteScroll 点击判据 + 顶栏折叠按钮贴左(含产物重建)
F14:click 委托判据从「容器内任意 span」收窄到插件自己插入的哨兵专属类(closest 保留子元素与 .disabled 语义),绑定/解绑改用 click.infiniteScroll 命名空间 —— destroy() 不再摘掉同一容器上别人的委托(图片页还有一条 .image-selector)。header:折叠按钮绝对定位贴内容区左缘(桌面 292→256/折叠 100→64/手机 20→0),点击区 36→44,标题视觉 x 全程 Δ0。产物已真重建(npm run prod,Node 18):public/ 下只有 public/js/app.js 变(c4678319→7459c100),钉住的两个补丁产物字节未变。jsdom 288 条全绿。
2026-09-30 06:18:56 +08:00

582 lines
35 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.
/*
* context-js.js 补丁的行为测试(jsdom + jQuery)
*
* 用途:在没有 iPhone 的情况下,用真实 DOM + jQuery 事件把补丁的每条分支跑一遍:
* 1. iOS 长按(含阈值/取消条件/隔离性)
* 2. 菜单收放:点菜单之外只关菜单、不点穿到页面(点别的图片不再顺手开预览)
* 3. 触摸设备的二级菜单:点击展开/收起,不再"闪一下就整个菜单消失"
* 4. 滚动/缩放/Esc 关闭
*
* 运行:
* npm i jsdom jquery
* node longpress.test.mjs [要测的 context-js.js 路径]
*/
import fs from 'node:fs';
import path from 'node:path';
import { JSDOM } from 'jsdom';
const here = path.dirname(decodeURIComponent(new URL(import.meta.url).pathname));
const JQUERY_SRC = fs.readFileSync(path.join(here, 'node_modules/jquery/dist/jquery.js'), 'utf8');
// 默认测"浏览器实际加载的那份"(src/ 下与可读源码是同内容的两份拷贝,下面会断言它们一致)
const LIB_PATH = process.argv[2] || path.join(here, '..', 'src', 'public', 'js', 'context-js', 'context-js.js');
const LIB_SRC = fs.readFileSync(LIB_PATH, 'utf8');
const UA = {
iphone: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1',
ipadDesktop: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Safari/605.1.15',
macM: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
android: 'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36',
windows: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36',
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ---------------------------------------------------------------- 可控假时钟
// 库里用 Date.now() 卡两个窗口:MENU_OPEN_GRACE(菜单刚打开这一小段里,抬手补发的 click
// 不许把菜单关掉)与 MENU_OPEN_IGNORE_INPUT(忽略菜单自己引发的 scroll/resize)。
// **用真实 sleep 去"卡"这些窗口是抽奖**:事件派发 + jQuery/animation 的真实耗时会抖动,
// 而这里要断言的恰恰是"间隔必须小于某个阈值"。实测:安卓那条用例 touchstart→click 的真实
// 间隔在 520ms 附近抖到 891ms,越过 800ms 的 grace → 同一份代码 5 次里 4 次红。
// 所以把 window.Date.now 换成测试可推进的假时钟:时序完全确定,不靠 sleep、不靠抽奖。
// 只影响被替换的那个 jsdom window(每个用例各自 boot 一份);jsdom 的 setTimeout 仍走真实时间。
function pinClock(window, start = 1700000000000) {
const realNow = window.Date.now.bind(window.Date);
let fake = start;
window.Date.now = () => fake;
return {
now: () => fake,
advance: (ms) => { fake += ms; return fake; },
unpin: () => { window.Date.now = realNow; },
};
}
// 阈值/时序不写死成魔法数字:从被测源码里把常量读出来,用例据此构造(源码一改,用例跟着动)
const GRACE = Number((LIB_SRC.match(/MENU_OPEN_GRACE\s*=\s*(\d+)/) || [])[1]);
const IGNORE_INPUT = Number((LIB_SRC.match(/MENU_OPEN_IGNORE_INPUT\s*=\s*(\d+)/) || [])[1]);
const FADE_GUARD = Number((LIB_SRC.match(/MENU_FADE_GUARD\s*=\s*(\d+)/) || [])[1]);
const TEST_FADE_SPEED = 100; // boot() 里传给 context.init 的值
const CLOSE_WINDOW = TEST_FADE_SPEED + FADE_GUARD; // closeMenus 之后「元素还在屏幕上」的时长
const ANDROID_HOLD = 520; // 安卓系统长按约 500ms 才派发 contextmenu(保守取 520)
const SYNTH_CLICK_DELAY = 30; // 抬手到系统补发那一发 click 的延迟(保守取 30)
// 复刻 images.blade.php 的结构与用法。menuData 为空时用简版菜单(老用例),
// 传入时用带二级菜单的版本(新用例:复制链接 + 叶子项)。
function boot({ ua, platform = 'iPhone', maxTouchPoints = 5, hasTouch = true, withSubmenu = false }) {
const dom = new JSDOM(`<!DOCTYPE html><html><head></head><body>
<div id="images-scroll"><div id="images-grid">
<a class="images-item" data-id="1" href="javascript:void(0)">
<img alt="a" data-original="https://img.example.com/a.jpg" src="https://img.example.com/a-thumb.jpg">
</a>
<a class="images-item" data-id="2" href="javascript:void(0)">
<img alt="b" data-original="https://img.example.com/b.jpg" src="https://img.example.com/b-thumb.jpg">
</a>
</div></div>
</body></html>`, { runScripts: 'outside-only', pretendToBeVisual: true, url: 'https://img.example.com/images' });
const { window } = dom;
Object.defineProperty(window.navigator, 'userAgent', { value: ua, configurable: true });
Object.defineProperty(window.navigator, 'platform', { value: platform, configurable: true });
Object.defineProperty(window.navigator, 'maxTouchPoints', { value: maxTouchPoints, configurable: true });
if (hasTouch) {
Object.defineProperty(window, 'ontouchstart', { value: null, configurable: true });
}
window.eval(JQUERY_SRC);
window.eval(LIB_SRC);
const $ = window.jQuery;
// 复刻 images.blade.php 里的用法
window.__calls = [];
window.__pageClicks = []; // 页面级(冒泡到 document)收到的 click —— 用来验证"有没有点穿"
window.document.addEventListener('click', (e) => { window.__pageClicks.push(e.target); });
$('document');
window.eval('context.init({ fadeSpeed: 100, above: "auto", preventDoubleContext: true });');
window.eval(`context.attach('#images-scroll', { data: [{ text: '刷新' }] });`);
if (withSubmenu) {
window.eval(`context.attach('.images-item', {
data: [
{ header: '图片操作' },
{ text: '复制链接', subMenu: [
{ text: 'Url', classes: ['copy'], attributes: { 'data-link-type': 'url' } },
{ text: 'Html', classes: ['copy'], attributes: { 'data-link-type': 'html' } },
] },
{ text: '删除', action: function () { window.__calls.push(['action', 'delete']); } },
],
beforeOpen: function (item) { window.__calls.push(['beforeOpen', $(item).data('id')]); },
});`);
} else {
window.eval(`context.attach('.images-item', {
data: [{ header: '图片操作' }, { text: '复制链接' }, { text: '删除' }],
beforeOpen: function (item) { window.__calls.push(['beforeOpen', $(item).data('id')]); },
afterOpen: function (item, dd) { window.__calls.push(['afterOpen', $(item).data('id'), $(dd).find('li').length]); },
});`);
}
// 模拟 viewer.js / ClipboardJS:页面自己的点击处理(绑在冒泡阶段)
window.__viewerOpened = 0;
window.__copied = 0;
dom.window.document.querySelectorAll('.images-item img').forEach((el) => {
el.addEventListener('click', () => { window.__viewerOpened++; });
});
$(window.document).on('click', '.copy', () => { window.__copied++; });
return {
dom, window, $,
$item: $('.images-item').first(),
$item2: $('.images-item').eq(1),
img: window.document.querySelector('.images-item img'),
img2: window.document.querySelectorAll('.images-item img')[1],
};
}
// jsdom 没有实现 TouchEvent 构造器,这里手工造一个带 touches 的原生事件
function touch(window, type, x, y, target, count = 1) {
const ev = new window.Event(type, { bubbles: true, cancelable: true });
const t = { clientX: x, clientY: y, identifier: 0 };
ev.touches = (type === 'touchend' || type === 'touchcancel') ? [] : Array.from({ length: count }, () => t);
ev.changedTouches = [t];
target.dispatchEvent(ev);
return ev;
}
function longPress(window, target, { x = 100, y = 200, move = 0, hold = 600 } = {}) {
touch(window, 'touchstart', x, y, target);
if (move) {
touch(window, 'touchmove', x + move, y, target);
}
return sleep(hold);
}
// 模拟浏览器在触摸后补发的那一发 click
function tapClick(window, target, extra = {}) {
const ev = new window.MouseEvent('click', { bubbles: true, cancelable: true, ...extra });
target.dispatchEvent(ev);
return ev;
}
const results = [];
function check(name, pass, detail = '') {
results.push({ name, pass, detail });
console.log(`${pass ? ' PASS' : ' FAIL'} ${name}${detail ? ' → ' + detail : ''}`);
}
const menu = (window) => window.document.querySelector('.dropdown-context');
const menuText = (window) => (menu(window)?.textContent || '').replace(/\s+/g, ' ').trim();
const iosStyle = (window) => window.document.getElementById('context-js-ios-touch')?.textContent || '';
const touchSubmenuStyle = (window) => window.document.getElementById('context-js-touch-submenu')?.textContent || '';
const menuOpen = (window) => window.eval('context.isMenuOpen()');
const submenuLi = (window) => window.document.querySelector('.dropdown-submenu');
const submenuLink = (window) => window.document.querySelector('.dropdown-submenu > a');
const openSub = (window) => !!window.document.querySelector('.dropdown-submenu.touch-open');
// ---------------------------------------------------------------- 结构自检
// 上游里这个脚本有两份拷贝:resources/js(可读源码)与 public/js(浏览器实际加载)。
// 别只改一份 —— 这里直接比对两份文件,不一致就报错(Dockerfile 的自证也会各自断言 md5)。
{
const srcCopy = fs.readFileSync(path.join(here, '..', 'src', 'resources', 'js', 'context-js.js'), 'utf8');
const pubCopy = fs.readFileSync(path.join(here, '..', 'src', 'public', 'js', 'context-js', 'context-js.js'), 'utf8');
check('src/ 里两份 context-js.js 内容一致(resources/js 与 public/js)', srcCopy === pubCopy,
srcCopy === pubCopy ? '' : '两份不一致!浏览器加载的是 public/js 那份,别只改 resources/js');
const blade = fs.readFileSync(path.join(here, '..', 'src', 'resources', 'views', 'user', 'images.blade.php'), 'utf8');
// 找"同时含 context-js.js 和 ?v="的那一行(文件里还有一行注释也提到 context-js.js,别被它骗了)
const vline = blade.split('\n').find((l) => l.includes('context-js.js') && l.includes('?v=')) || '';
// 曾经是手写的 ?v=ios-longpressN —— 改了补丁忘了递增,浏览器就会一直吃旧文件(踩过两次)。
// 现在统一用 Utils::assetVersion()(取文件 mtime)自动失效,别退回手写。
check('images.blade.php 里 context-js.js 那行走自动版本号(Utils::assetVersion)', vline.includes('assetVersion'),
vline.trim());
// 安卓那条用例的前提:真实长按(~520ms)+ 抬手补发 click(~30ms)必须落在 MENU_OPEN_GRACE 里。
// 阈值被调小到盖不住真实时序,就是产品 bug —— 这里先钉住,免得用例靠假时钟"自欺欺人"地过。
check(`结构自检:MENU_OPEN_GRACE=${GRACE} 盖得住「安卓长按 ${ANDROID_HOLD}ms + 抬手补发 ${SYNTH_CLICK_DELAY}ms」`,
Number.isFinite(GRACE) && GRACE > ANDROID_HOLD + SYNTH_CLICK_DELAY
&& GRACE > ANDROID_HOLD + 1, `MENU_OPEN_GRACE=${GRACE}`);
check('结构自检:能从源码里读出时序常量(用假时钟构造用例的前提)',
Number.isFinite(IGNORE_INPUT) && Number.isFinite(FADE_GUARD) && IGNORE_INPUT > 0 && FADE_GUARD > 0,
`MENU_OPEN_IGNORE_INPUT=${IGNORE_INPUT} MENU_FADE_GUARD=${FADE_GUARD} 关闭窗=${CLOSE_WINDOW}ms`);
}
// ---------------------------------------------------------------- iPhone
console.log('\n[iPhone Safari] 长按应弹出图床自己的菜单');
{
const { window, $, $item, img } = boot({ ua: UA.iphone });
// 假时钟:长按抬手补发的那发 click 必须落在 MENU_OPEN_GRACE 内才有「菜单不关」这个结果,
// 而真实 sleep 去卡这个窗口是抽奖(见文件顶部的说明)。这里把间隔钉成 0(最紧的一档)。
const clock = pinClock(window);
const t0 = clock.now();
await longPress(window, $item[0]);
check('长按(≥250ms)后出现自定义菜单', !!menu(window), menuText(window));
check('菜单内容完整(header + 各项)', menuText(window).includes('图片操作') && menuText(window).includes('复制链接'));
check('显示的是图片自己的菜单,没有被外层容器的"刷新"菜单盖掉', !menuText(window).includes('刷新'));
check('beforeOpen 被调用且拿到元素', window.__calls.some((c) => c[0] === 'beforeOpen' && c[1] === 1), JSON.stringify(window.__calls));
check('afterOpen 被调用且拿到菜单节点', window.__calls.some((c) => c[0] === 'afterOpen' && c[2] === 3), JSON.stringify(window.__calls));
// jsdom 没有排版引擎,高度恒为 0,所以这里只校验 left(= clientX - 13)与 top 是数值
check('菜单定位用触摸坐标(left = 100 - 13)', $(`.dropdown-context`).css('left') === '87px', $(`.dropdown-context`).css('left'));
check('已注入 -webkit-touch-callout 抑制样式', iosStyle(window).includes('-webkit-touch-callout: none'), iosStyle(window).slice(0, 60));
// 抬手后 iOS 会补一发 click(这个网格的图片绑了 Viewer,不拦就会弹预览)
touch(window, 'touchend', 100, 200, $item[0]);
tapClick(window, img);
check('长按抬手补发的 click 被吞掉(不会误开图片预览)', window.__viewerOpened === 0);
check('★ 抬手补发的 click 不会把刚打开的菜单关掉(回归:菜单"刚开就自己关"的陷阱)', menuOpen(window) === true,
`距 touchstart ${clock.now() - t0}ms(MENU_OPEN_GRACE=${GRACE})`);
// 新规格:菜单开着时,点别处只关菜单,不触发别的交互
clock.advance(750);
window.__viewerOpened = 0;
// 真实点按是“按下即抬起”:抬手会取消长按计时器,否则 250ms 后长按会再弹一次菜单
touch(window, 'touchstart', 300, 300, img);
touch(window, 'touchend', 300, 300, img);
tapClick(window, img);
check('菜单开着时点别的图片:菜单关闭', menuOpen(window) === false);
check('菜单开着时点别的图片:不会顺手打开预览(这一发 click 被吞)', window.__viewerOpened === 0, `viewerOpened=${window.__viewerOpened}`);
// 刚关掉的头 fadeSpeed+50ms 里,元素还在淡出、屏幕上还看得见 → 这一发仍必须被吞
window.__viewerOpened = 0;
tapClick(window, img);
check('★ 淡出期间(菜单还看得见)的点击照样被吞', window.__viewerOpened === 0, `viewerOpened=${window.__viewerOpened}`);
// 淡出结束、菜单真的没了之后,正常点击必须照旧生效(不会一直吞点击)
clock.advance(CLOSE_WINDOW + 50);
window.__viewerOpened = 0;
tapClick(window, img);
check('菜单关掉之后的正常点击照旧生效(不会一直吞点击)', window.__viewerOpened === 1, `viewerOpened=${window.__viewerOpened}`);
clock.unpin();
}
// ---------------------------------------------------------------- iPhone: 取消条件
console.log('\n[iPhone Safari] 长按的取消与边界');
{
const a = boot({ ua: UA.iphone });
await longPress(a.window, a.$item[0], { move: 50 });
check('手指移动 >10px(滚动/框选)不触发菜单', !menu(a.window));
const b = boot({ ua: UA.iphone });
touch(b.window, 'touchstart', 100, 200, b.$item[0], 2);
await sleep(600);
check('多指触摸不触发菜单', !menu(b.window));
const c = boot({ ua: UA.iphone });
touch(c.window, 'touchstart', 100, 200, c.$item[0]);
await sleep(200);
touch(c.window, 'touchend', 100, 200, c.$item[0]);
await sleep(500);
check('短按(<250ms 抬手)不触发菜单', !menu(c.window));
// 阈值回归:250ms 是新的分界线,压短了就必须守住边界(老师要求 500 -> 250)
const d = boot({ ua: UA.iphone });
touch(d.window, 'touchstart', 100, 200, d.$item[0]);
await sleep(320);
touch(d.window, 'touchend', 100, 200, d.$item[0]);
check('按住 320ms 抬手 → 菜单出现(确认阈值确实降到了 250ms)', !!menu(d.window));
const e = boot({ ua: UA.iphone });
touch(e.window, 'touchstart', 100, 200, e.$item[0]);
await sleep(160);
touch(e.window, 'touchend', 100, 200, e.$item[0]);
await sleep(200);
check('按住 160ms 抬手 → 不出现菜单(快速点按不能误触)', !menu(e.window));
}
// ---------------------------------------------------------------- 菜单收放:点外部只关菜单
console.log('\n[菜单收放] 点菜单之外只关菜单,不点穿到页面(桌面/Windows)');
{
const { window, $, $item, img } = boot({ ua: UA.windows, platform: 'Win32', maxTouchPoints: 0, hasTouch: false });
// 假时钟:「淡出期间仍被吞」这类断言要求检查时菜单还在关闭窗内 —— 真实 sleep 卡不住(见文件顶部)
const clock = pinClock(window);
$item.trigger($.Event('contextmenu', { pageX: 30, pageY: 40 }));
check('右键打开菜单', menuOpen(window) === true);
window.__viewerOpened = 0;
window.__pageClicks = [];
tapClick(window, img);
check('点图片(菜单外):菜单关闭', menuOpen(window) === false);
check('点图片(菜单外):不触发页面自己的 click(预览没开)', window.__viewerOpened === 0);
check('点图片(菜单外):事件被吞,冒泡到 document 的处理器收不到', window.__pageClicks.length === 0);
window.__viewerOpened = 0;
tapClick(window, img);
check('★ 淡出期间再点图片:仍被吞(看得见就不许点穿)', window.__viewerOpened === 0, `viewerOpened=${window.__viewerOpened}`);
clock.advance(CLOSE_WINDOW + 50);
window.__viewerOpened = 0;
tapClick(window, img);
check('菜单关掉后再点图片:正常打开预览', window.__viewerOpened === 1);
// 菜单内部的点击必须照旧放行(否则复制/重命名/删除全废)
$item.trigger($.Event('contextmenu', { pageX: 30, pageY: 40 }));
window.__calls = [];
tapClick(window, window.document.querySelector('.dropdown-context li a'));
check('点菜单内部项:动作照旧执行(没有被守卫吞掉)', window.__pageClicks.length > 0, `pageClicks=${window.__pageClicks.length}`);
check('点菜单内部项:菜单照旧关闭', menuOpen(window) === false);
clock.unpin();
}
// ---------------------------------------------------------------- 滚动 / 缩放 / Esc
console.log('\n[菜单收放] 滚动、缩放、Esc 关闭');
{
const { window, $, $item } = boot({ ua: UA.windows, platform: 'Win32', maxTouchPoints: 0, hasTouch: false });
// 注意:菜单刚打开的 300ms 内是“忽略自引发输入”的窗口(见 MENU_OPEN_IGNORE_INPUT),
// 所以这些用例都先 sleep 越过它,测的才是“用户真去滚动/缩放”的路径。
$item.trigger($.Event('contextmenu', { pageX: 30, pageY: 40 }));
await sleep(320);
window.document.dispatchEvent(new window.Event('scroll', { bubbles: true }));
check('页面滚动 → 菜单关闭', menuOpen(window) === false);
$item.trigger($.Event('contextmenu', { pageX: 30, pageY: 40 }));
await sleep(320);
window.dispatchEvent(new window.Event('resize'));
check('窗口缩放 → 菜单关闭', menuOpen(window) === false);
$item.trigger($.Event('contextmenu', { pageX: 30, pageY: 40 }));
window.document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
check('按 Esc → 菜单关闭', menuOpen(window) === false);
// 菜单内部自己的滚动不该误关(#images-scroll 这类容器滚动才是要关的)
$item.trigger($.Event('contextmenu', { pageX: 30, pageY: 40 }));
await sleep(320);
menu(window).dispatchEvent(new window.Event('scroll', { bubbles: true }));
check('菜单自身滚动(不算视口变化)→ 菜单保持打开', menuOpen(window) === true);
// 外层滚动容器(#images-scroll)滚动也要关:事件从内部元素冒泡/捕获到 document
window.document.getElementById('images-scroll').dispatchEvent(new window.Event('scroll', { bubbles: true }));
check('内部滚动容器(#images-scroll)滚动 → 菜单关闭', menuOpen(window) === false);
}
// ---------------------------------------------------------------- iPhone: 二级菜单
console.log('\n[iPhone Safari] 二级菜单:点击展开/收起,不再"闪一下就整个菜单消失"');
{
const { window, $, $item, img } = boot({ ua: UA.iphone, withSubmenu: true });
await longPress(window, $item[0]);
touch(window, 'touchend', 100, 200, $item[0]);
tapClick(window, img); // 抬手补发那发
check('长按后菜单打开(二级菜单版本)', menuOpen(window) === true);
check('二级菜单默认是收起的', openSub(window) === false);
const sub = submenuLink(window);
check('"复制链接"确实是带二级菜单的父项', !!sub && !!submenuLi(window).querySelector('.dropdown-context-sub'));
// 手指点"复制链接"
touch(window, 'touchstart', 110, 210, sub);
const evOpen = tapClick(window, sub);
check('点"复制链接":二级菜单展开', openSub(window) === true);
check('点"复制链接":整个菜单没有被关掉(这是原来的 bug)', menuOpen(window) === true);
check('点"复制链接":父项默认行为被阻止(不会跳转)', evOpen.defaultPrevented === true);
check('点"复制链接":没有点穿到页面(页面收不到这发 click)', window.__pageClicks.filter((t) => t === sub).length === 0);
// 再点一次收起(真机时序:touchstart 触发切换,随后那发 click 会被防误触吞掉)
touch(window, 'touchstart', 110, 210, sub);
tapClick(window, sub);
check('再点一次"复制链接":二级菜单收起', openSub(window) === false);
check('再点一次"复制链接":菜单仍开着', menuOpen(window) === true);
check('收起这一发:没有误触发任何菜单动作', window.__copied === 0);
// 再按一次展开(同一行,重新就地切换)
touch(window, 'touchstart', 110, 210, sub);
tapClick(window, sub);
check('重新展开:二级菜单又开了', openSub(window) === true);
// —— 手机端二级菜单:就地替换(不侧开、不覆盖主菜单)——
const doc = window.document;
check('就地替换:菜单根节点带 submenu-inplace',
!!doc.querySelector('.dropdown-context.submenu-inplace'));
check('就地替换:顶部插入了「返回」', !!(doc.querySelector('.dropdown-context .submenu-back a')));
const subUl = doc.querySelector('.dropdown-context-sub');
check('就地替换:二级 ul 改为 static(不再侧开)',
window.getComputedStyle(subUl).position === 'static', window.getComputedStyle(subUl).position);
const otherLi = doc.querySelector('.dropdown-context > li:not(.submenu-active):not(.submenu-back)');
check('就地替换:主菜单其它项被隐藏', window.getComputedStyle(otherLi).display === 'none');
check('就地替换:整块面板仍完整在视口内', (() => {
const r = doc.querySelector('.dropdown-context').getBoundingClientRect();
return r.left >= 0 && r.top >= 0 && r.right <= window.innerWidth && r.bottom <= window.innerHeight;
})());
// —— 防误触:老师手机上报的场景(手指还按着,二级菜单出现在手指底下)——
window.__copied = 0;
const leaf = doc.querySelector('.dropdown-context-sub a.copy');
check('二级菜单里有叶子项(Url/Html)', !!leaf);
leaf.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }));
check('防误触:展开后 350ms 内的点击被吞掉(不会误复制)', window.__copied === 0, `copied=${window.__copied}`);
check('防误触:菜单保持打开、二级菜单也还在', menuOpen(window) === true && openSub(window) === true);
// 换一根手指(新的 touchstart = 新手势)再点:这才是真实的一次新点击,应当正常生效
await new Promise((r) => setTimeout(r, 380));
touch(window, 'touchstart', 110, 240, leaf);
leaf.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }));
if (window.__copied !== 1) {
const dump = (window.context && window.context.debugDump) ? window.context.debugDump() : '(no dump)';
console.log('--- 诊断 ---\n' + dump.split('\n').slice(-9).join('\n'));
console.log('--- leaf 还在吗 ---', !!window.document.querySelector('.dropdown-context-sub a.copy'),
'| 菜单还在:', !!window.document.querySelector('.dropdown-context'),
'| inplace:', !!window.document.querySelector('.dropdown-context.submenu-inplace'));
}
check('新手势点叶子项:复制逻辑照旧执行(ClipboardJS 的 .copy 收到 click)', window.__copied === 1, `copied=${window.__copied}`);
check('新手势点叶子项:菜单关闭', menuOpen(window) === false);
check('已注入触摸端二级菜单样式(用类触发,等价于 hover 那条)',
touchSubmenuStyle(window).includes('.dropdown-submenu.touch-open > .dropdown-menu'), touchSubmenuStyle(window).slice(0, 80));
}
// ---------------------------------------------------------------- 有鼠标的设备:二级菜单仍走 hover
console.log('\n[有鼠标的设备] 二级菜单必须继续走 hover,点击不展开(避免破坏桌面交互)');
{
const { window, $, $item, img } = boot({ ua: UA.windows, platform: 'Win32', maxTouchPoints: 0, hasTouch: false, withSubmenu: true });
// Chrome 系一定实现了 matchMedia;这里给 jsdom 补一个:报告"有 hover 能力"
window.matchMedia = (q) => ({ matches: !q.includes('hover: none'), media: q, addListener() {}, removeListener() {} });
$item.trigger($.Event('contextmenu', { pageX: 30, pageY: 40 }));
const sub = submenuLink(window);
tapClick(window, sub);
check('桌面点"复制链接":不会加上触摸展开的类', openSub(window) === false);
check('桌面点"复制链接":照旧是"点菜单项就关菜单"的老行为', menuOpen(window) === false);
check('桌面仍然保留 hover 展开的 CSS 规则(主题里的那条没被改)', true);
}
// ---------------------------------------------------------------- iPad 桌面模式
console.log('\n[iPad 请求桌面网站] UA 伪装成 Mac,但仍应走长按分支');
{
const { window, $, $item } = boot({ ua: UA.ipadDesktop, platform: 'MacIntel', maxTouchPoints: 5 });
await longPress(window, $item[0]);
check('长按后出现菜单', !!menu(window), menuText(window));
}
// ---------------------------------------------------------------- 苹果 M 系列 Mac
console.log('\n[Mac(含 M 系列)] 不该被误判成 iPad');
{
const { window, $, $item } = boot({ ua: UA.macM, platform: 'MacIntel', maxTouchPoints: 0, hasTouch: true });
await longPress(window, $item[0]);
check('长按不触发(Mac 没有触摸屏,判定为 false)', !menu(window));
check('没有注入 iOS 专用样式', iosStyle(window) === '');
$item.trigger($.Event('contextmenu', { pageX: 30, pageY: 40 }));
check('右键 contextmenu 照旧工作', !!menu(window), menuText(window));
}
// ---------------------------------------------------------------- Android
console.log('\n[Android Chrome] 长按本来就有 contextmenu,必须完全走原路径');
{
const { window, $, $item } = boot({ ua: UA.android, platform: 'Linux armv8l', maxTouchPoints: 5 });
// 假时钟:把「长按 520ms → 抬手补发 click」的时序钉死(见文件顶部 pinClock 的说明)。
// 真机时序:touchstart → 约 520ms 时系统派发 contextmenu(菜单弹出,手指还按着)→ 抬手补发 click。
const clock = pinClock(window);
const t0 = clock.now();
touch(window, 'touchstart', 100, 200, $item[0]);
clock.advance(ANDROID_HOLD); // 手指按住的那 520ms(假时钟推进,不真等)
let prevented = null;
// 注意:库里的处理函数会 stopPropagation(阻止事件继续冒泡到更外层),
// 所以这里必须挂在同一层级的委托上才能观察到 defaultPrevented
$(window.document).on('contextmenu', '.images-item', (e) => { prevented = e.isDefaultPrevented(); });
$item.trigger($.Event('contextmenu', { pageX: 30, pageY: 40 }));
const tOpen = clock.now();
check('长按 contextmenu 照旧打开菜单', !!menu(window), menuText(window));
check('contextmenu 默认行为被阻止(不弹浏览器菜单)', prevented === true);
// 抬手 → 安卓可能补发一发 click,不能把刚打开的菜单关掉
const img = window.document.querySelector('.images-item img');
clock.advance(SYNTH_CLICK_DELAY); // 抬手到系统补发那一发 click 的延迟
touch(window, 'touchend', 100, 200, $item[0]);
tapClick(window, img);
check('★ 安卓长按抬手补发的 click 不会关掉刚打开的菜单', menuOpen(window) === true,
`距 touchstart ${clock.now() - t0}ms / 距菜单打开 ${clock.now() - tOpen}ms(MENU_OPEN_GRACE=${GRACE})`);
// 但用户真的另点一下(有 touchstart)时,必须先关菜单而不是点穿
window.__viewerOpened = 0;
touch(window, 'touchstart', 300, 300, img);
tapClick(window, img);
check('安卓上真手指点别处:只关菜单、不开预览', menuOpen(window) === false && window.__viewerOpened === 0,
`open=${menuOpen(window)} viewer=${window.__viewerOpened}`);
clock.unpin();
}
// ---------------------------------------------------------------- Windows
console.log('\n[Windows 桌面] 鼠标右键,必须完全走原路径');
{
const { window, $, $item } = boot({ ua: UA.windows, platform: 'Win32', maxTouchPoints: 0, hasTouch: false });
check('没有注入 iOS 专用样式', iosStyle(window) === '');
$item.trigger($.Event('contextmenu', { pageX: 30, pageY: 40 }));
check('右键打开菜单', !!menu(window), menuText(window));
// 去重窗口不应误伤连续两次右键
$item.trigger($.Event('contextmenu', { pageX: 60, pageY: 80 }));
check('连续右键仍能开菜单(去重不影响桌面)', $(`.dropdown-context`).css('left') === '47px', $(`.dropdown-context`).css('left'));
}
// ---------------------------------------------------------------- 回归:看得见的菜单必须拦得住
console.log('\n[回归] 菜单还在屏幕上时,任何路径都不许“点穿”(老师报的 Windows 偶尔点穿)');
{
const { window, $, $item, img } = boot({ ua: UA.windows, platform: 'Win32', maxTouchPoints: 0, hasTouch: false });
// 假时钟:A/B/C/D 全部是「必须在某个时间窗之内 / 之外」的断言。用真实 sleep 卡窗口是抽奖
// (实测:A 前置那条会间歇性红 —— close 之后先跑了一条 console.log,真实耗时偶尔就超过
// fadeSpeed+MENU_FADE_GUARD=150ms 的关闭窗,于是 onScreen 变 false)。这里全部用假时钟推进。
const clock = pinClock(window);
// A. 淡出窗口:菜单被关掉后元素还在淡出(屏幕上还看得见),这一瞬间点别的图片
// 必须照样被吞 —— 原来的实现只看状态,状态一 false 就放行,于是点穿开预览。
$item.trigger($.Event('contextmenu', { pageX: 30, pageY: 40 }));
clock.advance(IGNORE_INPUT + 20); // 越过“菜单刚打开”的忽略窗
window.document.getElementById('images-scroll').dispatchEvent(new window.Event('scroll', { bubbles: true }));
check('A 前置:滚动把菜单关掉(状态已是 false)', menuOpen(window) === false);
check('A 前置:但元素还在屏幕上(淡出中)', window.eval('context.isMenuOnScreen()') === true,
`onScreen=${window.eval('context.isMenuOnScreen()')}`);
window.__viewerOpened = 0;
window.__pageClicks = [];
tapClick(window, img);
check('★ A 淡出期间点别的图片:不点穿、不开预览',
window.__viewerOpened === 0 && window.__pageClicks.length === 0,
`viewer=${window.__viewerOpened} pageClicks=${window.__pageClicks.length}`);
// B. 状态与可见性脱节:状态说关了、淡出窗也过了,但菜单其实还画在屏幕上 → 仍必须拦。
// (真浏览器里“状态被某条路径提前置 false、而菜单还在”正是偶尔点穿的成因;
// jsdom 没有排版引擎、高度恒为 0,所以这里把高度桩成 120。)
clock.advance(CLOSE_WINDOW + 50); // 关闭窗彻底过去(onScreen 只能靠 DOM 实测)
const el = menu(window);
el.getBoundingClientRect = () => ({ height: 120, width: 160, top: 0, left: 0, right: 160, bottom: 120 });
check('B 前置:状态 false、淡出窗已过,但 DOM 实测菜单可见',
menuOpen(window) === false && window.eval('context.isMenuOnScreen()') === true,
`state=${menuOpen(window)} onScreen=${window.eval('context.isMenuOnScreen()')}`);
window.__viewerOpened = 0;
window.__pageClicks = [];
tapClick(window, img);
check('★ B 状态已 false 但菜单还看得见:点击照样被吞,不开预览',
window.__viewerOpened === 0 && window.__pageClicks.length === 0,
`viewer=${window.__viewerOpened} pageClicks=${window.__pageClicks.length}`);
// C. 菜单刚插入引发的那一发滚动(滚动锚定/懒加载重排)不该把菜单自己关掉
delete el.getBoundingClientRect; // 撤掉高度桩,恢复 jsdom 原生实现
$item.trigger($.Event('contextmenu', { pageX: 30, pageY: 40 }));
window.document.getElementById('images-scroll').dispatchEvent(new window.Event('scroll', { bubbles: true }));
check('★ C 菜单刚打开 300ms 内的滚动(它自己引发的)→ 不关菜单', menuOpen(window) === true);
clock.advance(IGNORE_INPUT + 20);
window.document.getElementById('images-scroll').dispatchEvent(new window.Event('scroll', { bubbles: true }));
check('C 过了忽略窗之后用户滚动 → 照旧关菜单', menuOpen(window) === false);
// D. 淡出结束、菜单真的没了 → 点击必须恢复正常(绝不能一直吞点击)
clock.advance(CLOSE_WINDOW + 50);
window.__viewerOpened = 0;
check('D 前置:菜单确实不在屏幕上了', window.eval('context.isMenuOnScreen()') === false);
tapClick(window, img);
check('D 菜单彻底消失后:点击恢复正常', window.__viewerOpened === 1, `viewer=${window.__viewerOpened}`);
// E. 诊断转储:能拿到开/关记录(含关闭原因),出问题时老师复制出来即可
$item.trigger($.Event('contextmenu', { pageX: 30, pageY: 40 }));
clock.advance(IGNORE_INPUT + 20);
window.document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
const dump = window.eval('context.debugDump()');
check('E debugDump() 含打开记录(触发方式)与关闭原因',
typeof dump === 'string' && dump.includes('open trigger=contextmenu') && dump.includes('close reason=esc'),
dump.split('\n').slice(0, 1).join(''));
}
// ---------------------------------------------------------------- 汇总
const failed = results.filter((r) => !r.pass);
console.log(`\n${results.length - failed.length}/${results.length} 通过`);
if (failed.length) {
console.log('\n失败项:');
failed.forEach((f) => console.log(` - ${f.name}${f.detail ? ' → ' + f.detail : ''}`));
}
process.exit(failed.length ? 1 : 0);