// 一次性截图脚本 —— 用系统 Chrome 的 remote debugging 协议直接通信, // 避免装 puppeteer/playwright(项目 pnpm store 状态不允许)。 // // 通过 Fetch.requestPaused 拦截 /api/auth/session 注入 mock next-auth session, // 让客户端 useSession() 以为已登录,从而能触发 VoteModal / 进入 /me 页。 import { spawn } from "node:child_process"; import { writeFile, mkdir } from "node:fs/promises"; import { setTimeout as wait } from "node:timers/promises"; const CHROME = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"; const PORT = 9333; const PROFILE = "C:\\Users\\10419\\AppData\\Local\\Temp\\cs-voting-shot"; const OUT_DIR = "d:\\ClaudeProjects\\虚拟明星\\UI-UX\\docs\\screenshots\\voting-refactor"; const ORIGIN = "http://localhost:3000"; const MOCK_SESSION = { user: { id: "mock-user", name: "测试用户" }, expires: new Date(Date.now() + 86400 * 1000).toISOString(), }; async function launchChrome() { const proc = spawn( CHROME, [ `--headless=new`, `--disable-gpu`, `--remote-debugging-port=${PORT}`, `--user-data-dir=${PROFILE}`, `--window-size=1500,900`, `--hide-scrollbars`, `--no-first-run`, `--no-default-browser-check`, `about:blank`, ], { stdio: "ignore", detached: true }, ); proc.unref(); for (let i = 0; i < 30; i++) { try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) return proc.pid; } catch (_e) { void _e; } await wait(300); } throw new Error("Chrome remote debugging did not come up"); } async function killChrome(pid) { try { process.kill(pid); } catch { /* */ } spawn("taskkill", ["/F", "/PID", String(pid), "/T"], { stdio: "ignore" }); } async function openPage() { const r = await fetch(`http://127.0.0.1:${PORT}/json/new?about:blank`, { method: "PUT", }); return await r.json(); } class CDP { constructor(wsUrl) { this.ws = null; this.wsUrl = wsUrl; this.id = 0; this.pending = new Map(); this.listeners = new Set(); } async connect() { this.ws = new WebSocket(this.wsUrl); await new Promise((res, rej) => { this.ws.addEventListener("open", () => res(), { once: true }); this.ws.addEventListener("error", (e) => rej(e), { once: true }); }); this.ws.addEventListener("message", (ev) => { const msg = JSON.parse(ev.data); if (msg.id && this.pending.has(msg.id)) { const { resolve, reject } = this.pending.get(msg.id); this.pending.delete(msg.id); if (msg.error) reject(new Error(msg.error.message)); else resolve(msg.result); } else if (msg.method) { for (const cb of this.listeners) cb(msg); } }); } on(cb) { this.listeners.add(cb); return () => this.listeners.delete(cb); } send(method, params = {}) { const id = ++this.id; return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.ws.send(JSON.stringify({ id, method, params })); }); } close() { this.ws.close(); } } function b64(str) { return Buffer.from(str).toString("base64"); } /** 安装 /api/auth/session 拦截器 —— 返回 mock session 让 useSession 觉得已登录。 * /me 路由会调用 next-auth/server,这个对客户端透明 —— 但 /me 页是 server component 包了 * client MeContent,如果 server 端 redirect 我们抓 not handled。简单做法:直接 navigate * 到 /me 仍可能 redirect,但 hero / 卡片 / vote modal 这些纯 client 用 useSession 都 OK。 */ async function setupSessionMock(cdp) { await cdp.send("Fetch.enable", { patterns: [{ urlPattern: "*/api/auth/session*" }], }); cdp.on(async (msg) => { if (msg.method !== "Fetch.requestPaused") return; const { requestId, request } = msg.params; if (request.url.includes("/api/auth/session")) { const body = b64(JSON.stringify(MOCK_SESSION)); await cdp.send("Fetch.fulfillRequest", { requestId, responseCode: 200, responseHeaders: [ { name: "Content-Type", value: "application/json" }, { name: "Cache-Control", value: "no-store" }, ], body, }); } else { await cdp.send("Fetch.continueRequest", { requestId }); } }); } async function waitForLoad(cdp, ms = 2000) { await wait(ms); } async function setLocalStorage(cdp, items) { for (const [key, value] of Object.entries(items)) { await cdp.send("Runtime.evaluate", { expression: `localStorage.setItem(${JSON.stringify(key)}, ${JSON.stringify(value)})`, }); } } async function clearLocalStorage(cdp) { await cdp.send("Runtime.evaluate", { expression: `localStorage.clear()` }); } async function navigate(cdp, url, settleMs = 2500) { await cdp.send("Page.navigate", { url }); await waitForLoad(cdp, settleMs); } async function pauseVideos(cdp) { await cdp.send("Runtime.evaluate", { expression: `document.querySelectorAll('video').forEach(v => { try { v.pause(); v.currentTime = 0.5; } catch{} });`, }); } async function screenshotFull(cdp, path) { const r = await cdp.send("Page.captureScreenshot", { format: "png" }); await writeFile(path, Buffer.from(r.data, "base64")); console.log(`[ok] ${path}`); } async function screenshotElement(cdp, selector, path, padding = 8) { const r = await cdp.send("Runtime.evaluate", { expression: `(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return null; const r = el.getBoundingClientRect(); return { x: r.left, y: r.top, width: r.width, height: r.height }; })()`, returnByValue: true, }); if (!r.result || !r.result.value) { console.log(`[skip] no element ${selector} for ${path}`); return false; } const b = r.result.value; const cap = await cdp.send("Page.captureScreenshot", { format: "png", clip: { x: Math.max(0, b.x - padding), y: Math.max(0, b.y - padding), width: Math.max(1, b.width + padding * 2), height: Math.max(1, b.height + padding * 2), scale: 1, }, }); await writeFile(path, Buffer.from(cap.data, "base64")); console.log(`[ok] ${path} (${selector})`); return true; } function makeVoteState(ids) { return JSON.stringify({ state: { votedArtists: ids }, version: 0 }); } async function main() { await mkdir(OUT_DIR, { recursive: true }); console.log("[boot] launching Chrome..."); const pid = await launchChrome(); console.log(`[boot] Chrome pid=${pid} port=${PORT}`); try { const target = await openPage(); const cdp = new CDP(target.webSocketDebuggerUrl); await cdp.connect(); await cdp.send("Page.enable"); await cdp.send("Network.enable"); await cdp.send("Runtime.enable"); await cdp.send("Emulation.setDeviceMetricsOverride", { width: 1500, height: 900, deviceScaleFactor: 1, mobile: false, }); await setupSessionMock(cdp); // ===== 1. Hero 进度三态(裁切胶囊 + 整图各一份)===== // 1a. 0/12 (已登录但未投) // user-data-dir 复用会带上上次的 localStorage —— 必须先进 ORIGIN 上下文再 clear await navigate(cdp, ORIGIN, 500); await clearLocalStorage(cdp); await navigate(cdp, ORIGIN); await pauseVideos(cdp); await wait(800); await screenshotFull(cdp, `${OUT_DIR}\\01a-hero-0of12.png`); await screenshotElement( cdp, "[data-hero-vote-progress]", `${OUT_DIR}\\01a-progress-0of12.png`, 12, ); // 1b. 5/12 await setLocalStorage(cdp, { "cyber-star-vote": makeVoteState(["001", "002", "003", "004", "005"]), }); await navigate(cdp, ORIGIN); await pauseVideos(cdp); await wait(800); await screenshotFull(cdp, `${OUT_DIR}\\01b-hero-5of12.png`); await screenshotElement( cdp, "[data-hero-vote-progress]", `${OUT_DIR}\\01b-progress-5of12.png`, 12, ); // 1c. 12/12 await setLocalStorage(cdp, { "cyber-star-vote": makeVoteState([ "001", "002", "003", "004", "005", "006", "007", "008", "009", "010", "011", "012", ]), }); await navigate(cdp, ORIGIN); await pauseVideos(cdp); await wait(800); await screenshotFull(cdp, `${OUT_DIR}\\01c-hero-12of12.png`); await screenshotElement( cdp, "[data-hero-vote-progress]", `${OUT_DIR}\\01c-progress-12of12.png`, 12, ); // ===== 2. 艺人卡片角标对比 ===== // 投了 1/3/5 — 卡片网格里能看到混合态(已投紫框✓ vs 未投灰框) await setLocalStorage(cdp, { "cyber-star-vote": makeVoteState(["001", "003", "005"]), }); await navigate(cdp, ORIGIN); await pauseVideos(cdp); await cdp.send("Runtime.evaluate", { expression: `document.getElementById('artists')?.scrollIntoView({behavior:'instant',block:'start'}); window.scrollBy(0, 100);`, }); await wait(1000); await screenshotFull(cdp, `${OUT_DIR}\\02-artist-cards-mixed.png`); // ===== 3. /me 页 ===== // useSession returns mock session → MeContent 应该正常渲染 // 但 /me 是 server component:它在 server 端调 auth() — 我们 intercept 仅作用 client。 // 实际 server component 不会用到 fetch /api/auth/session,它用 cookies 直接验。 // 没有 next-auth cookie → server redirect。我们尝试,如果失败就截 redirect 后状态。 await navigate(cdp, `${ORIGIN}/me`, 1500); // 检查是否被重定向 const urlInfo = await cdp.send("Runtime.evaluate", { expression: "location.pathname", returnByValue: true, }); if (urlInfo.result.value !== "/me") { console.log( `[note] /me redirected to ${urlInfo.result.value} — server auth() not bypassed`, ); // 在 hash 模式下不会 redirect; 强制 navigate 后端 client only render // 退而求其次:直接构造一个空白 page 在客户端 render MeContent — 无法,跳过 } await screenshotFull(cdp, `${OUT_DIR}\\03-me-page.png`); // ===== 4. VoteModal 正常态(未投 + 未满) ===== await setLocalStorage(cdp, { "cyber-star-vote": makeVoteState([]), }); await navigate(cdp, ORIGIN); await pauseVideos(cdp); await wait(1200); // 滚到卡片区点第一张卡的投票按钮 await cdp.send("Runtime.evaluate", { expression: `document.getElementById('artists')?.scrollIntoView({behavior:'instant',block:'start'}); window.scrollBy(0, 100);`, }); await wait(500); const clicked = await cdp.send("Runtime.evaluate", { expression: `(() => { const btns = Array.from(document.querySelectorAll('button')); const target = btns.find(b => (b.textContent || '').trim() === '投票'); if (target) { target.click(); return true; } return false; })()`, returnByValue: true, }); console.log(`[note] vote modal trigger: ${clicked.result.value}`); await wait(1000); await screenshotFull(cdp, `${OUT_DIR}\\04-vote-modal-normal.png`); await screenshotElement( cdp, '[role="dialog"]', `${OUT_DIR}\\04-vote-modal-cropped.png`, 24, ); cdp.close(); console.log("[done]"); } finally { await killChrome(pid); } } main().catch((e) => { console.error(e); process.exit(1); });