/* cdp-verify-batch.mjs — 验证三列表(账号列表/视频列表/AI写脚本列表)批量操作 UI(09-01) * 覆盖点:①左下角「批量操作」图标(check-square)存在 * ②点击后每行出现勾选框 + 删除(trash)/取消(x) 图标 + 全选 + 计数 * ③勾选计数正确、全选正确、取消恢复常态 * 方法:CDP 9223 → 复用 8900 标签页 → 模拟点击(不真实调用删除 API) * 运行:node cdp-verify-batch.mjs */ const CDP = 'http://127.0.0.1:9223'; async function getWs(targetId) { const tabs = await (await fetch(CDP + '/json')).json(); const t = tabs.find((x) => x.id === targetId); if (!t) throw new Error('target not found: ' + targetId); return t.webSocketDebuggerUrl; } function cdp(ws, method, params = {}) { return new Promise((resolve, reject) => { const id = ++cdp._id; const w = new WebSocket(ws); const timer = setTimeout(() => { try { w.close(); } catch (e) {} reject(new Error('timeout: ' + method)); }, 30000); w.onopen = () => w.send(JSON.stringify({ id, method, params })); w.onmessage = (ev) => { const m = JSON.parse(ev.data); if (m.id !== id) return; clearTimeout(timer); try { w.close(); } catch (e) {} m.error ? reject(new Error(method + ': ' + JSON.stringify(m.error))) : resolve(m.result); }; w.onerror = () => { clearTimeout(timer); reject(new Error('ws error: ' + method)); }; }); } cdp._id = 0; async function evalJs(ws, expr) { const r = await cdp(ws, 'Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }); if (r.exceptionDetails) throw new Error('eval err: ' + JSON.stringify(r.exceptionDetails).slice(0, 300)); return r.result && r.result.value; } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function waitFor(ws, expr, timeout = 40000, label = '') { const t0 = Date.now(); while (Date.now() - t0 < timeout) { try { if (await evalJs(ws, expr)) return true; } catch (e) {} await sleep(800); } throw new Error('waitFor timeout: ' + label + ' -> ' + expr); } let pass = 0, fail = 0; function ok(cond, label) { if (cond) { pass++; console.log(' ✅ ' + label); } else { fail++; console.log(' ❌ FAIL: ' + label); } } // 对单个列表页执行批量操作 UI 全链路验证 async function verifyList(ws, hash, label, rowSelector) { console.log(`\n【${label}】 ${hash}`); await evalJs(ws, `location.hash = '${hash}'`); await cdp(ws, 'Page.reload', { ignoreCache: true }); await waitFor(ws, `!!document.querySelector('${rowSelector}')`, 40000, label + ' 列表渲染'); await sleep(400); // ① 常态:左下角批量操作图标按钮存在(含 svg 图标) const init = await evalJs(ws, `(() => { const bar = document.querySelector('.list-batchbar'); const btn = bar && bar.querySelector('[data-batch-open]'); return { hasBar: !!bar, hasBtn: !!btn, hasIcon: !!(btn && btn.querySelector('.ic')), btnText: btn ? btn.textContent.trim() : '' }; })()`); ok(init.hasBar && init.hasBtn, `左下角批量操作条存在`); ok(init.hasIcon, `批量操作按钮为图标(非文字,文本="${init.btnText}")`); ok(init.btnText.trim() === '', `按钮无文字残留(纯图标)`); // ② 点击批量操作图标 → 进入批量模式 await evalJs(ws, `document.querySelector('[data-batch-open]').click()`); await waitFor(ws, `!!document.querySelector('.batch-cb-all')`, 20000, label + ' 批量模式进入'); await sleep(300); const mode = await evalJs(ws, `(() => { const rows = document.querySelectorAll('tbody tr .batch-cb').length; const delBtn = document.querySelector('[data-batch-del]'); const cancelBtn = document.querySelector('[data-batch-cancel]'); return { rowCbs: rows, hasDel: !!delBtn, delIcon: !!(delBtn && delBtn.querySelector('.ic')), hasCancel: !!cancelBtn, cancelIcon: !!(cancelBtn && cancelBtn.querySelector('.ic')), hasAll: !!document.querySelector('.batch-cb-all'), delText: delBtn ? delBtn.textContent.trim() : '', }; })()`); ok(mode.rowCbs > 0, `每行出现勾选框(${mode.rowCbs} 行)`); ok(mode.hasDel && mode.delIcon, `删除按钮为图标(trash)`); ok(mode.hasCancel && mode.cancelIcon, `取消按钮为图标(x)`); ok(mode.hasAll, `表头全选框存在`); ok(mode.delText === '', `删除按钮无文字残留(纯图标)`); // ③ 勾选 2 行 → 计数更新 await evalJs(ws, `(() => { const cbs = document.querySelectorAll('.batch-cb'); cbs[0].click(); cbs[1].click(); return true; })()`); await sleep(200); const cnt = await evalJs(ws, `document.querySelector('.batch-count-n').textContent`); ok(cnt === '2', `勾选 2 行计数显示 2(实际 ${cnt})`); // ④ 全选 → 计数=行数 await evalJs(ws, `document.querySelector('.batch-cb-all').click()`); await sleep(200); const cntAll = await evalJs(ws, `document.querySelector('.batch-count-n').textContent`); const totalRows = await evalJs(ws, `document.querySelectorAll('.batch-cb').length`); ok(cntAll === String(totalRows), `全选计数=行数 ${totalRows}(实际 ${cntAll})`); // ⑤ 取消 → 恢复常态(无勾选框) await evalJs(ws, `document.querySelector('[data-batch-cancel]').click()`); await sleep(400); const back = await evalJs(ws, `(() => { const bar = document.querySelector('.list-batchbar'); return { hasCbs: document.querySelectorAll('.batch-cb').length > 0, hasOpen: !!(bar && bar.querySelector('[data-batch-open]')) }; })()`); ok(!back.hasCbs && back.hasOpen, `取消后恢复常态(勾选框消失,批量操作图标回归)`); } async function main() { const tabs = await (await fetch(CDP + '/json')).json(); const wb = tabs.find((t) => t.url.includes('localhost:8900') || t.url.includes('127.0.0.1:8900')); if (!wb) throw new Error('未找到 8900 工作台标签页,请先在内置浏览器打开工作台'); const ws = await getWs(wb.id); console.log('▶ 复用标签页:', wb.url); await verifyList(ws, '#/accounts', '账号列表', 'tbody tr a.link'); await verifyList(ws, '#/account/3', '账号详情-视频列表(俊希)', 'tbody tr .td-id a'); await verifyList(ws, '#/rewrites', 'AI写脚本列表', 'tbody tr [data-rewrite]'); console.log(`\n==== ${fail === 0 ? '全部 PASS' : '存在 FAIL ' + fail + ' 项'}(PASS ${pass} / FAIL ${fail})====`); process.exit(fail === 0 ? 0 : 1); } main().catch((e) => { console.error('脚本异常:', e.message); process.exit(1); });