From 53b60d320200e1ff0ccdeab64ec6187358e4c762 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 13 Aug 2026 13:33:11 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=E2=9C=A8=20=E6=9C=AC=E5=9C=B0=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E6=94=B9=E7=94=A8=E5=B8=B8=E9=A9=BB=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=EF=BC=9A=E6=96=B0=E5=A2=9E=20session.mjs=20=E4=B8=8E=20drive.m?= =?UTF-8?q?js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 启动一次浏览器常驻,之后逐条命令附着驱动,验证一件事不再需要 先写一个 spec 再整体重跑。 - session.mjs:start/status/stop,默认无头,--headed 供人工旁观 - drive.mjs:open/goto/click/fill/snapshot/shot/eval/sw/storage/install 等 - 端口向内核申请(listen(0))、profile 走 mkdtemp、证据落各自的 scenario 目录,多个 worktree 可并发验证 - 会话全程记录 console/pageerror 与操作,报告直接引用 --- e2e/drive.mjs | 358 ++++++++++++++++++++++++++++++++++++++++++++++++ e2e/session.mjs | 345 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 703 insertions(+) create mode 100644 e2e/drive.mjs create mode 100644 e2e/session.mjs diff --git a/e2e/drive.mjs b/e2e/drive.mjs new file mode 100644 index 000000000..a54ed57c8 --- /dev/null +++ b/e2e/drive.mjs @@ -0,0 +1,358 @@ +#!/usr/bin/env node +/** + * 驱动一个已经启动的本地验证会话(见 session.mjs)。 + * + * 每次调用都是一个短命进程:附着到会话的 CDP 端口,做一件事,记一笔,然后退出。 + * 浏览器状态留在会话里,所以可以一条一条地探查,而不是把整段操作先写成 spec 再重跑。 + * + * 每条命令都会追加到 /actions.log —— 报告里的「如何驱动」直接来自它。 + */ +import process from "node:process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; +import { liveSessions, readSession, isAlive, scenarioDir } from "./session.mjs"; + +const require = createRequire(import.meta.url); +const { chromium } = require("@playwright/test"); + +const __filename = fileURLToPath(import.meta.url); +const REPO_ROOT = path.resolve(path.dirname(__filename), ".."); + +function resolveSession(explicit) { + if (explicit) { + const session = readSession(explicit); + if (!session) fail(`没有会话:${explicit},先跑 node e2e/session.mjs start ${explicit}`); + if (!isAlive(session)) fail(`会话已退出:${explicit},重新 start`); + return session; + } + const live = liveSessions(); + if (live.length === 1) return live[0]; + if (live.length === 0) fail("没有存活的会话,先跑 node e2e/session.mjs start "); + fail(`有多个会话,需要指定 --scenario:${live.map((s) => s.scenario).join(", ")}`); +} + +function fail(message) { + console.error(`✗ ${message}`); + process.exit(1); +} + +function activeFile(session) { + return path.join(scenarioDir(session.scenario), ".active"); +} + +function readActive(session) { + try { + return fs.readFileSync(activeFile(session), "utf8").trim(); + } catch { + return ""; + } +} + +function writeActive(session, url) { + fs.writeFileSync(activeFile(session), url); +} + +function logAction(session, line) { + fs.appendFileSync(path.join(scenarioDir(session.scenario), "actions.log"), `${new Date().toISOString()} ${line}\n`); +} + +/** 扩展页优先:storage / sendMessage 只有在扩展来源的页面里才能调 chrome.* */ +function extensionPages(context, session) { + return context.pages().filter((page) => page.url().startsWith(`chrome-extension://${session.extensionId}/`)); +} + +async function anyExtensionPage(context, session) { + const [existing] = extensionPages(context, session); + if (existing) return existing; + const page = await context.newPage(); + await page.goto(`chrome-extension://${session.extensionId}/src/options.html`, { waitUntil: "domcontentloaded" }); + return page; +} + +/** + * 按 URL 而不是下标定位当前页:扩展自己会开引导页,下标随时会错位。 + * 依次退让到「同源同路径」(hash 路由跳转)和最后一个页面。 + */ +function activePage(context, session) { + const pages = context.pages(); + if (!pages.length) fail("会话里没有打开的页面,先 open 或 goto"); + const wanted = readActive(session); + if (!wanted) return pages[pages.length - 1]; + const exact = pages.filter((page) => page.url() === wanted).pop(); + if (exact) return exact; + const base = wanted.split("#")[0]; + const sameDocument = pages.filter((page) => page.url().split("#")[0] === base).pop(); + return sameDocument ?? pages[pages.length - 1]; +} + +const PAGE_ALIASES = { + options: "src/options.html", + popup: "src/popup.html", + editor: "src/options.html#/script/editor", + logger: "src/options.html#/logger", + setting: "src/options.html#/setting", + tools: "src/options.html#/tools", + subscribe: "src/options.html#/subscribe", + install: "src/install.html", + import: "src/import.html", + batchupdate: "src/batchupdate.html", +}; + +/** + * 字符串形式的用户代码:带 return 的当成函数体,否则当成表达式。 + * 两种写法都要能用 await。 + */ +function wrapEvalSource(source) { + return /\breturn\b/.test(source) ? `(async () => { ${source} })()` : `(async () => (${source}))()`; +} + +function print(value) { + console.log(typeof value === "string" ? value : JSON.stringify(value, null, 2)); +} + +async function run() { + const argv = process.argv.slice(2); + const scenarioFlagIndex = argv.indexOf("--scenario"); + let explicitScenario; + if (scenarioFlagIndex !== -1) { + explicitScenario = argv[scenarioFlagIndex + 1]; + argv.splice(scenarioFlagIndex, 2); + } + const [command, ...args] = argv; + if (!command || command === "help") return usage(); + + const session = resolveSession(explicitScenario); + const dir = scenarioDir(session.scenario); + + // console 只读会话记录的日志文件,不必附着浏览器 + if (command === "console") { + const file = path.join(dir, "console.log"); + if (!fs.existsSync(file)) return console.log("(还没有 console 输出)"); + const lines = fs.readFileSync(file, "utf8").trimEnd().split("\n"); + const count = parseInt(args[0], 10) || 50; + return console.log(lines.slice(-count).join("\n")); + } + + const browser = await chromium.connectOverCDP(session.cdp); + const context = browser.contexts()[0]; + + try { + switch (command) { + case "open": { + const target = args[0] ?? "options"; + const suffix = PAGE_ALIASES[target] ?? target.replace(/^\//, ""); + const url = `chrome-extension://${session.extensionId}/${suffix}`; + const page = await context.newPage(); + await page.goto(url, { waitUntil: "domcontentloaded" }); + writeActive(session, page.url()); + logAction(session, `open ${target} → ${url}`); + console.log(`✓ ${await page.title()} — ${url}`); + break; + } + case "goto": { + if (!args[0]) fail("goto 需要一个 URL"); + const page = activePage(context, session); + await page.goto(args[0], { waitUntil: "domcontentloaded" }); + writeActive(session, page.url()); + logAction(session, `goto ${args[0]}`); + console.log(`✓ ${await page.title()} — ${page.url()}`); + break; + } + case "click": { + const page = activePage(context, session); + await page.locator(args[0]).first().click({ timeout: 10_000 }); + logAction(session, `click ${args[0]}`); + console.log(`✓ clicked ${args[0]}`); + break; + } + case "fill": { + const page = activePage(context, session); + await page.locator(args[0]).first().fill(args.slice(1).join(" "), { timeout: 10_000 }); + logAction(session, `fill ${args[0]}`); + console.log(`✓ filled ${args[0]}`); + break; + } + case "press": { + const page = activePage(context, session); + await page.keyboard.press(args[0]); + logAction(session, `press ${args[0]}`); + console.log(`✓ pressed ${args[0]}`); + break; + } + case "wait": { + const page = activePage(context, session); + await page + .locator(args[0]) + .first() + .waitFor({ timeout: parseInt(args[1], 10) || 15_000 }); + logAction(session, `wait ${args[0]}`); + console.log(`✓ appeared ${args[0]}`); + break; + } + case "text": { + const page = activePage(context, session); + const text = await page + .locator(args[0] ?? "body") + .first() + .innerText(); + logAction(session, `text ${args[0] ?? "body"}`); + console.log(text); + break; + } + case "shot": { + const page = activePage(context, session); + const shots = path.join(dir, "shots"); + fs.mkdirSync(shots, { recursive: true }); + const seq = String(fs.readdirSync(shots).filter((f) => f.endsWith(".png")).length + 1).padStart(2, "0"); + const file = path.join(shots, `${seq}-${args[0] ?? "shot"}.png`); + await page.screenshot({ path: file, fullPage: args.includes("--full") }); + logAction(session, `shot → ${path.relative(REPO_ROOT, file)}`); + console.log(`✓ ${path.relative(REPO_ROOT, file)}`); + break; + } + case "eval": { + const page = activePage(context, session); + const result = await page.evaluate(wrapEvalSource(args.join(" "))); + logAction(session, `eval ${args.join(" ").slice(0, 80)}`); + print(result); + break; + } + case "sw": { + let [worker] = context.serviceWorkers(); + if (!worker) worker = await context.waitForEvent("serviceworker", { timeout: 15_000 }); + const result = await worker.evaluate(wrapEvalSource(args.join(" "))); + logAction(session, `sw ${args.join(" ").slice(0, 80)}`); + print(result); + break; + } + case "storage": { + const page = await anyExtensionPage(context, session); + const key = args[0] ?? null; + const result = await page.evaluate((k) => new Promise((resolve) => chrome.storage.local.get(k, resolve)), key); + logAction(session, `storage ${key ?? "(all)"}`); + print(key ? result[key] : Object.keys(result)); + break; + } + case "install": { + if (!args[0]) fail("install 需要一个 .user.js 文件路径"); + const code = fs.readFileSync(path.resolve(args[0]), "utf8"); + const page = await anyExtensionPage(context, session); + // 走 SW 消息而不是 Monaco 粘贴:编辑器路径依赖剪贴板和渲染时序,脆弱得多 + const result = await page.evaluate( + (source) => + chrome.runtime.sendMessage({ + action: "serviceWorker/script/installByCode", + data: { uuid: crypto.randomUUID(), code: source, upsertBy: "user" }, + }), + code + ); + logAction(session, `install ${args[0]} → ${JSON.stringify(result)}`); + print(result); + break; + } + case "snapshot": { + const page = activePage(context, session); + const items = await page.evaluate((scopeSelector) => { + const root = document.querySelector(scopeSelector) ?? document.body; + const INTERACTIVE = + 'a,button,input,select,textarea,summary,[role="button"],[role="tab"],[role="link"],' + + '[role="menuitem"],[role="switch"],[role="checkbox"],[role="radio"],[role="option"],' + + '[contenteditable="true"],[data-testid]'; + const label = (el) => + (el.getAttribute("aria-label") || el.innerText || el.value || el.placeholder || el.title || "") + .trim() + .replace(/\s+/g, " ") + .slice(0, 60); + // 选择器优先级:testid > 稳定 id > 可见文本。Tailwind 的 class 串没有定位价值, + // Radix 自动生成的 id(radix-_r_0_)每次渲染都会变,当作不存在。 + const selectorFor = (el) => { + const testId = el.getAttribute("data-testid"); + if (testId) return `[data-testid="${testId}"]`; + const stableId = el.id && !/[^\w-]/.test(el.id) && !el.id.startsWith("radix-") ? el.id : null; + if (stableId) return `#${stableId}`; + const text = label(el); + if (text && !text.includes('"')) return `text="${text}"`; + return null; + }; + return [...root.querySelectorAll(INTERACTIVE)] + .filter((el) => { + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }) + .map((el) => ({ + selector: selectorFor(el), + label: label(el), + tag: el.tagName.toLowerCase(), + role: el.getAttribute("role") || "", + disabled: el.disabled === true || el.getAttribute("aria-disabled") === "true", + })) + .filter((item) => item.selector); + }, args[0] ?? "body"); + + const seen = new Set(); + const unique = items.filter((item) => !seen.has(item.selector) && seen.add(item.selector)); + if (!unique.length) console.log("(这个范围里没有可交互元素,换个 scope 或先 wait)"); + for (const item of unique) { + const kind = item.role ? `${item.tag}/${item.role}` : item.tag; + console.log(`${item.disabled ? "✗" : " "} ${item.selector.padEnd(46)} ${kind.padEnd(14)} ${item.label}`); + } + logAction(session, `snapshot ${args[0] ?? "body"} → ${unique.length} 个可交互元素`); + break; + } + case "pages": { + const current = activePage(context, session); + context.pages().forEach((page, i) => console.log(`${page === current ? "→" : " "} ${i} ${page.url()}`)); + break; + } + case "use": { + const index = parseInt(args[0], 10); + const target = context.pages()[index]; + if (Number.isNaN(index) || !target) fail(`没有第 ${args[0]} 个页面,先看 pages`); + writeActive(session, target.url()); + console.log(`✓ 当前页 ${index} — ${target.url()}`); + break; + } + case "close": { + const page = activePage(context, session); + await page.close(); + writeActive(session, ""); + logAction(session, "close"); + console.log("✓ closed"); + break; + } + default: + usage(); + process.exitCode = 1; + } + } finally { + // 只断开控制连接,不要关掉会话浏览器 + await browser.close(); + } +} + +function usage() { + console.log(`驱动本地验证会话(默认自动选中唯一存活的会话,多个时用 --scenario ) + + open + goto 当前页导航 + click 点击 + fill 填入 + press 按键,如 Enter、ControlOrMeta+a + wait [ms] 等元素出现 + snapshot [scope] 列出可交互元素及可直接用的 selector(点之前先看这个) + text [selector] 取 innerText(默认 body) + shot [name] [--full] 截图到 /shots/ + eval 在当前页执行(带 return 当函数体,否则当表达式) + sw 在扩展 Service Worker 里执行 + storage [key] 读 chrome.storage.local + install 经 SW 装一个脚本 + pages / use / close 页面管理 + console [n] 会话记录的最近 n 行 console/pageerror`); +} + +run().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/e2e/session.mjs b/e2e/session.mjs new file mode 100644 index 000000000..dcb9f5d52 --- /dev/null +++ b/e2e/session.mjs @@ -0,0 +1,345 @@ +#!/usr/bin/env node +/** + * 本地验证会话的生命周期管理。 + * + * 启动一个常驻的、加载了 dist/ext 的浏览器,把连接信息写进 + * `e2e/scratch//.session.json`,之后由 `drive.mjs` 逐条命令附着上去操作 —— + * 验证一件事不再需要先写一个 spec 再整体重跑。 + * + * 默认无头:验证过程不该抢占桌面焦点,也才能在多个 worktree 里同时跑。 + * `--headed` 只为人工旁观保留。 + * + * 并发的唯一全局资源是 CDP 端口,所以这里向内核要一个空闲端口(listen(0))而不是写死; + * profile 走 mkdtemp,evidence 走各自的 scenario 目录,两者天然隔离。 + */ +import process from "node:process"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { chromium } = require("@playwright/test"); + +const __filename = fileURLToPath(import.meta.url); +const E2E_DIR = path.dirname(__filename); +const REPO_ROOT = path.resolve(E2E_DIR, ".."); +const SCRATCH_DIR = path.join(E2E_DIR, "scratch"); +const EXTENSION_DIR = path.join(REPO_ROOT, "dist", "ext"); + +const SESSION_FILE = ".session.json"; +const CONSOLE_LOG = "console.log"; +const DAEMON_LOG = "daemon.log"; + +export function scenarioDir(scenario) { + return path.join(SCRATCH_DIR, scenario); +} + +export function readSession(scenario) { + const file = path.join(scenarioDir(scenario), SESSION_FILE); + if (!fs.existsSync(file)) return null; + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch { + // 半写入的会话文件等同于没有会话 + return null; + } +} + +export function isAlive(session) { + if (!session?.pid) return false; + try { + process.kill(session.pid, 0); + return true; + } catch { + return false; + } +} + +/** 列出本 worktree 下所有仍然存活的会话 */ +export function liveSessions() { + if (!fs.existsSync(SCRATCH_DIR)) return []; + return fs + .readdirSync(SCRATCH_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => readSession(entry.name)) + .filter((session) => session && isAlive(session)); +} + +async function freePort() { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const { port } = server.address(); + await new Promise((resolve) => server.close(resolve)); + return port; +} + +function requireBuiltExtension() { + if (!fs.existsSync(path.join(EXTENSION_DIR, "manifest.json"))) { + console.error(`✗ 没有找到 ${path.relative(REPO_ROOT, EXTENSION_DIR)}/manifest.json`); + console.error(" 先构建扩展:pnpm run dev(或 pnpm run build)"); + process.exit(1); + } +} + +// 预先标记「非首次使用」,避免新手引导欢迎弹窗的模态遮罩拦截后续操作。 +function dismissOnboarding() { + try { + localStorage.setItem("firstUse", "false"); + } catch { + // about:blank 等不透明来源无法访问 localStorage,忽略即可 + } +} + +/** + * 常驻进程本体:持有浏览器直到收到 SIGTERM。 + */ +async function serve(scenario, { headed }) { + requireBuiltExtension(); + + const dir = scenarioDir(scenario); + fs.mkdirSync(dir, { recursive: true }); + const consolePath = path.join(dir, CONSOLE_LOG); + const appendConsole = (line) => fs.appendFileSync(consolePath, `${new Date().toISOString()} ${line}\n`); + + const port = await freePort(); + const profile = fs.mkdtempSync(path.join(os.tmpdir(), `sc-verify-${scenario}-`)); + + const launch = (extraArgs) => + chromium.launchPersistentContext(profile, { + headless: false, + args: [ + `--disable-extensions-except=${EXTENSION_DIR}`, + `--load-extension=${EXTENSION_DIR}`, + "--disable-gpu", + ...(headed ? [] : ["--headless=new"]), + ...extraArgs, + ], + timeout: 60_000, + }); + + // 阶段一:授权 userScripts 后立刻关掉。userScripts 是可选权限,不授权则页面脚本 + // 永远不会注入;而 updateExtensionConfiguration 会重载扩展,重载期间它自己的页面 + // 会 ERR_BLOCKED_BY_CLIENT。授权落在 profile 里,所以重启一次即可干净接管。 + { + const setup = await launch([]); + let [bg] = setup.serviceWorkers(); + if (!bg) bg = await setup.waitForEvent("serviceworker", { timeout: 30_000 }); + const id = bg.url().split("/")[2]; + const grantPage = await setup.newPage(); + await grantPage.goto("chrome://extensions/"); + await grantPage.waitForLoadState("domcontentloaded"); + await grantPage.waitForFunction(() => !!chrome.developerPrivate, { timeout: 10_000 }); + await grantPage.evaluate(async (extId) => { + await chrome.developerPrivate.updateExtensionConfiguration({ extensionId: extId, userScriptsAccess: true }); + }, id); + await setup.close(); + } + + // 阶段二:真正对外提供服务的实例,带 CDP 端口 + const context = await launch([`--remote-debugging-port=${port}`]); + await context.addInitScript(dismissOnboarding); + + let [worker] = context.serviceWorkers(); + if (!worker) worker = await context.waitForEvent("serviceworker", { timeout: 30_000 }); + const extensionId = worker.url().split("/")[2]; + + // 扩展安装后会自己弹引导页,且是在 SW 起来之后异步开的;不清掉的话 drive.mjs pages + // 里全是噪声。等一小会儿再扫一次,覆盖这个时间差。 + // 必须在写会话文件之前扫干净:会话一旦对外可见,drive.mjs 打开的页面就不能再被误关。 + const sweepStrayTabs = async () => { + for (const page of context.pages()) { + if (!page.url().startsWith("about:blank")) await page.close().catch(() => {}); + } + }; + await sweepStrayTabs(); + await new Promise((resolve) => setTimeout(resolve, 2_500)); + await sweepStrayTabs(); + + // 会话全程记录 console/pageerror:drive.mjs 每条命令都是新进程,事后附着看不到历史输出。 + const watch = (page) => { + const where = () => { + const url = page.url(); + return url.startsWith(`chrome-extension://${extensionId}/`) + ? url.slice(`chrome-extension://${extensionId}/`.length) + : url; + }; + page.on("console", (msg) => appendConsole(`[${msg.type()}] (${where()}) ${msg.text()}`)); + page.on("pageerror", (err) => appendConsole(`[pageerror] (${where()}) ${err.message}`)); + }; + context.pages().forEach(watch); + context.on("page", watch); + + const session = { + scenario, + port, + extensionId, + profile, + headed: !!headed, + pid: process.pid, + startedAt: new Date().toISOString(), + cdp: `http://127.0.0.1:${port}`, + }; + fs.writeFileSync(path.join(dir, SESSION_FILE), `${JSON.stringify(session, null, 2)}\n`); + appendConsole(`[session] started ${headed ? "headed" : "headless"} on port ${port}, extension ${extensionId}`); + + let closing = false; + const shutdown = async () => { + if (closing) return; + closing = true; + appendConsole("[session] stopping"); + try { + await context.close(); + } catch { + // 浏览器可能已被外部关闭 + } + fs.rmSync(profile, { recursive: true, force: true }); + fs.rmSync(path.join(dir, SESSION_FILE), { force: true }); + process.exit(0); + }; + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); + // 人工关掉 headed 窗口时,进程也应随之退出,不留下悬空的会话文件 + context.on("close", shutdown); +} + +async function start(scenario, { headed }) { + requireBuiltExtension(); + + const existing = readSession(scenario); + if (existing && isAlive(existing)) { + console.log(`会话已在运行:${scenario} (pid ${existing.pid}, port ${existing.port})`); + return; + } + + const dir = scenarioDir(scenario); + fs.mkdirSync(dir, { recursive: true }); + fs.rmSync(path.join(dir, SESSION_FILE), { force: true }); + + const logFd = fs.openSync(path.join(dir, DAEMON_LOG), "a"); + const child = spawn(process.execPath, [__filename, "__serve", scenario, ...(headed ? ["--headed"] : [])], { + detached: true, + stdio: ["ignore", logFd, logFd], + }); + child.unref(); + + const deadline = Date.now() + 90_000; + while (Date.now() < deadline) { + const session = readSession(scenario); + if (session) { + console.log(`✓ 会话已启动:${scenario}`); + console.log(` 模式 ${session.headed ? "headed(可见)" : "headless(不可见)"}`); + console.log(` CDP ${session.cdp}`); + console.log(` 扩展 ID ${session.extensionId}`); + console.log(` 证据目录 ${path.relative(REPO_ROOT, dir)}/`); + console.log(` 下一步 node e2e/drive.mjs open options`); + return; + } + if (child.exitCode !== null) break; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + console.error(`✗ 会话启动失败,看 ${path.relative(REPO_ROOT, path.join(dir, DAEMON_LOG))}`); + process.exit(1); +} + +async function stop(scenario) { + const session = readSession(scenario); + if (!session) { + console.log(`没有会话:${scenario}`); + return; + } + if (!isAlive(session)) { + fs.rmSync(path.join(scenarioDir(scenario), SESSION_FILE), { force: true }); + fs.rmSync(session.profile, { recursive: true, force: true }); + console.log(`清理了残留会话文件:${scenario}`); + return; + } + process.kill(session.pid, "SIGTERM"); + const deadline = Date.now() + 20_000; + while (Date.now() < deadline && isAlive(session)) { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + // 守护进程负责清理 profile 与会话文件;它没做完就兜底,避免留下几百 MB 的 profile + fs.rmSync(path.join(scenarioDir(scenario), SESSION_FILE), { force: true }); + fs.rmSync(session.profile, { recursive: true, force: true }); + console.log(`✓ 已停止:${scenario}`); +} + +function status(scenario) { + const sessions = scenario ? [readSession(scenario)].filter(Boolean) : liveSessions(); + if (!sessions.length) { + console.log(scenario ? `没有会话:${scenario}` : "没有存活的会话"); + return; + } + for (const session of sessions) { + const alive = isAlive(session); + console.log( + `${alive ? "●" : "○"} ${session.scenario} port=${session.port} ext=${session.extensionId} ` + + `${session.headed ? "headed" : "headless"} pid=${session.pid}${alive ? "" : "(已退出)"}` + ); + } +} + +function usage() { + console.log(`本地验证会话 + + node e2e/session.mjs start [--headed] 启动常驻浏览器(默认不可见) + node e2e/session.mjs status [] 查看会话 + node e2e/session.mjs stop | --all 停止并清理 + node e2e/session.mjs list 等同 status + +证据与会话文件都落在 e2e/scratch//(已 gitignore)。 +用 node e2e/drive.mjs 驱动会话。`); +} + +async function main() { + const argv = process.argv.slice(2); + const command = argv[0]; + const headed = argv.includes("--headed"); + const positional = argv.slice(1).filter((arg) => !arg.startsWith("--")); + const scenario = positional[0]; + + switch (command) { + case "__serve": + await serve(scenario, { headed }); + break; + case "start": + if (!scenario) { + console.error("✗ 需要 scenario 名:node e2e/session.mjs start "); + process.exit(1); + } + await start(scenario, { headed }); + break; + case "stop": + if (argv.includes("--all")) { + for (const session of liveSessions()) await stop(session.scenario); + } else if (!scenario) { + console.error("✗ 需要 scenario 名,或用 --all"); + process.exit(1); + } else { + await stop(scenario); + } + break; + case "status": + case "list": + status(scenario); + break; + default: + usage(); + process.exit(command ? 1 : 0); + } +} + +// 被 drive.mjs 作为模块导入时不执行 CLI +if (process.argv[1] === __filename) { + main().catch((error) => { + console.error(error); + process.exit(1); + }); +} From 949879383bdc02d5f770fb6c59d87d7be7eeaf8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 13 Aug 2026 13:33:21 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20e2e=EF=BC=9A=E6=97=A0?= =?UTF-8?q?=E5=A4=B4=E6=A8=A1=E5=BC=8F=E6=94=B9=E4=B8=BA=20E2E=5FHEADED=20?= =?UTF-8?q?=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原先 9 处把 "--headless=new" 写死在 args 里,而启动参数会覆盖 Playwright 的 headless 选项,所以 headless: false(含 --debug/PWDEBUG) 其实一直开不出窗口。统一走 headlessArgs(),E2E_HEADED=1 才可见。 --- e2e/agent-fixtures.ts | 5 +++-- e2e/fixtures.ts | 17 ++++++++++++++--- e2e/gm-api.spec.ts | 5 +++-- e2e/server-fixtures.ts | 5 +++-- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/e2e/agent-fixtures.ts b/e2e/agent-fixtures.ts index c524acf1f..32e757b4c 100644 --- a/e2e/agent-fixtures.ts +++ b/e2e/agent-fixtures.ts @@ -2,6 +2,7 @@ import fs from "fs"; import os from "os"; import path from "path"; import { test as base, expect, chromium, type BrowserContext, type Route } from "@playwright/test"; +import { headlessArgs } from "./fixtures"; export { expect }; const pathToExtension = path.resolve(__dirname, "../dist/ext"); @@ -113,7 +114,7 @@ export const test = base.extend({ // Phase 1: 启用 userScripts + 写入 mock model 配置 const ctx1 = await chromium.launchPersistentContext(userDataDir, { headless: false, - args: ["--headless=new", ...chromeArgs], + args: [...headlessArgs(), ...chromeArgs], timeout: 60_000, chromiumSandbox, }); @@ -174,7 +175,7 @@ export const test = base.extend({ // 每个测试使用独立的 profile,避免脚本和路由泄漏到后续测试。 const context = await chromium.launchPersistentContext(userDataDir, { headless: false, - args: ["--headless=new", ...chromeArgs], + args: [...headlessArgs(), ...chromeArgs], timeout: 60_000, chromiumSandbox, ...getProxyOptions(), diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index fbf124422..b79fcbb0f 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -25,6 +25,17 @@ const chromeArgs = [ "--disable-gpu", ]; +/** + * 无头是默认:跑用例不该抢占桌面焦点,也才能让多个 worktree 同时跑。 + * `E2E_HEADED=1` 开出可见窗口,只为人工旁观用。 + * + * 必须作为启动参数下发:`--headless=new` 会覆盖 Playwright 的 `headless` 选项, + * 所以单独把 `headless` 设成 false(含 `--debug`/PWDEBUG)并不会开出窗口。 + */ +export function headlessArgs(): string[] { + return process.env.E2E_HEADED ? [] : ["--headless=new"]; +} + // CI(GitHub Actions)跑在非 root 用户下不会自动应用 --no-sandbox,关掉沙箱能省下每次 // launchPersistentContext 的 sandbox/fork 开销;本地开发机上仍保留沙箱隔离。 const chromiumSandbox = !process.env.CI; @@ -50,7 +61,7 @@ export const test = base.extend<{ context: async ({}, use) => { const context = await chromium.launchPersistentContext("", { headless: false, - args: ["--headless=new", ...chromeArgs], + args: [...headlessArgs(), ...chromeArgs], timeout: 60_000, chromiumSandbox, ...getProxyOptions(), @@ -96,7 +107,7 @@ export const testWithUserScripts = base.extend< const ctx1 = await chromium.launchPersistentContext(userDataDir, { headless: false, - args: ["--headless=new", ...chromeArgs], + args: [...headlessArgs(), ...chromeArgs], timeout: 60_000, chromiumSandbox, }); @@ -129,7 +140,7 @@ export const testWithUserScripts = base.extend< const context = await chromium.launchPersistentContext(userDataDir, { headless: false, - args: ["--headless=new", ...chromeArgs], + args: [...headlessArgs(), ...chromeArgs], timeout: 60_000, chromiumSandbox, ...getProxyOptions(), diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index de2674058..79131e2b5 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -4,6 +4,7 @@ import os from "os"; import { createServer } from "http"; import type { AddressInfo } from "net"; import { test as base, expect, chromium, type BrowserContext } from "@playwright/test"; +import { headlessArgs } from "./fixtures"; import { autoApprovePermissions, installScriptByCode } from "./utils"; const MOCK_CONNECT_HOST = "127.0.0.1"; @@ -45,7 +46,7 @@ const test = base.extend< const ctx1 = await chromium.launchPersistentContext(userDataDir, { headless: false, - args: ["--headless=new", ...chromeArgs], + args: [...headlessArgs(), ...chromeArgs], timeout: 60_000, chromiumSandbox, }); @@ -78,7 +79,7 @@ const test = base.extend< const context = await chromium.launchPersistentContext(userDataDir, { headless: false, - args: ["--headless=new", ...chromeArgs], + args: [...headlessArgs(), ...chromeArgs], timeout: 60_000, chromiumSandbox, }); diff --git a/e2e/server-fixtures.ts b/e2e/server-fixtures.ts index 6e5f1f5c4..8d2249ebd 100644 --- a/e2e/server-fixtures.ts +++ b/e2e/server-fixtures.ts @@ -4,6 +4,7 @@ import path from "path"; import { createServer, type IncomingMessage, type ServerResponse, type Server } from "http"; import type { AddressInfo } from "net"; import { test as base, expect, chromium, type BrowserContext } from "@playwright/test"; +import { headlessArgs } from "./fixtures"; /** * 共享网络测试 fixture。 @@ -59,7 +60,7 @@ export const test = base.extend< const ctx1 = await chromium.launchPersistentContext(userDataDir, { headless: false, - args: ["--headless=new", ...chromeArgs], + args: [...headlessArgs(), ...chromeArgs], timeout: 60_000, chromiumSandbox, }); @@ -92,7 +93,7 @@ export const test = base.extend< const context = await chromium.launchPersistentContext(userDataDir, { headless: false, - args: ["--headless=new", ...chromeArgs], + args: [...headlessArgs(), ...chromeArgs], timeout: 60_000, chromiumSandbox, }); From 598bd2b969a25f8a9c83e757b53814be7d75ff5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 13 Aug 2026 13:33:31 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=F0=9F=93=84=20=E9=AA=8C=E8=AF=81=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E6=96=87=E6=A1=A3=E6=94=B9=E5=86=99=E4=B8=BA=E3=80=8C?= =?UTF-8?q?=E9=A9=B1=E5=8A=A8=E5=B8=B8=E9=A9=BB=E4=BC=9A=E8=AF=9D=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verification.md 以驱动会话为默认形式,写 spec 收敛为「顺序/时序即契约」 时的例外;驱动方法拆到 references/verification-methods.md;e2e/README.md 新增 §8 会话手册(命令表、证据目录、并发、能力边界)。 --- AGENTS.md | 2 +- docs/README.md | 2 +- docs/references/verification-methods.md | 90 +++++ .../verification-report-template.md | 245 +++--------- docs/verification.md | 358 ++++-------------- e2e/README.md | 84 +++- 6 files changed, 318 insertions(+), 463 deletions(-) create mode 100644 docs/references/verification-methods.md diff --git a/AGENTS.md b/AGENTS.md index d24b40c81..06c0adcd3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ instead of copying its content here. | add or change localized content | [`docs/translation.md`](docs/translation.md) — plus the matching `docs/references/terminology-.md` when one exists | | add, edit, reorganize, or review any tracked contributor Markdown (this file, `docs/*`, `.github/*.md`, package- and source-local READMEs) | [`docs/DOC-MAINTENANCE.md`](docs/DOC-MAINTENANCE.md) — *if you can't grep it on this branch, don't claim it* | | open or update a pull request | [`docs/pull-request.md`](docs/pull-request.md) | -| manually confirm a feature works | [`docs/verification.md`](docs/verification.md) — a throwaway scratch script against the built extension, not the committed suite | +| manually confirm a feature works | [`docs/verification.md`](docs/verification.md) — drive a throwaway session against the built extension, not the committed suite | ## DeepWiki Context diff --git a/docs/README.md b/docs/README.md index f34c011f5..a4aaa7b7c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ | [`pull-request.md`](./pull-request.md) | PR 描述指南:代理与贡献者使用的详细章节、按变更类型取舍规则、验证与审查信息要求。 | | [`design.md`](./design.md) | 设计系统参考:主题机制、shadcn 组件选型、新建页面配方总览;令牌完整值拆到 [`references/design-tokens.md`](./references/design-tokens.md),组件清单拆到 [`references/design-components.md`](./references/design-components.md),布局/响应式/动效/状态/无障碍范式拆到 [`references/design-patterns.md`](./references/design-patterns.md)。**做页面/对话框/区块前先读。** | | [`../e2e/README.md`](../e2e/README.md) | E2E 测试台手册:两条赛道(committed smoke / gitignored scratch)、浏览器与 profile 隔离、fixtures 与 helper 清单、协议 mock、`E2E_*` 环境变量、产物与失败排查路径。**跑 / 写 E2E 或一次性验证脚本前先读。** | -| [`verification.md`](./verification.md) | 功能验证指南:用一次性 scratch 脚本驱动真实扩展做端到端验证(不跑全量 E2E、不加永久用例);报告模板拆到 [`references/verification-report-template.md`](./references/verification-report-template.md),调试 FAQ 拆到 [`references/verification-debugging.md`](./references/verification-debugging.md)。**验证改动是否真正跑通时读。** | +| [`verification.md`](./verification.md) | 功能验证指南:启一个常驻会话(默认无头、可多 worktree 并发)逐条命令驱动真实扩展,只在需要复现顺序/时序时才写 spec,不跑全量 E2E、不加永久用例;驱动方法(GM API in-page self-test、SW 消息、主题)拆到 [`references/verification-methods.md`](./references/verification-methods.md),报告模板拆到 [`references/verification-report-template.md`](./references/verification-report-template.md),调试 FAQ 拆到 [`references/verification-debugging.md`](./references/verification-debugging.md)。**验证改动是否真正跑通时读。** | | [`architecture.md`](./architecture.md) | 内部原理总览:多进程模型、消息传递;各子系统深入拆到 [`references/architecture-services.md`](./references/architecture-services.md)(服务层)、[`references/architecture-data.md`](./references/architecture-data.md)(数据层)、[`references/architecture-gm-api.md`](./references/architecture-gm-api.md)(GM API)、[`references/architecture-execution.md`](./references/architecture-execution.md)(脚本执行)、[`references/architecture-build.md`](./references/architecture-build.md)(构建管线)、[`references/architecture-agent.md`](./references/architecture-agent.md)(Agent 子系统)。 | | [`cloud-sync.md`](./cloud-sync.md) | 云同步实现说明:同步文件语义、主流程、状态合并、provider 差异、错误分类、retry 策略和维护注意事项。 | | [`DOC-MAINTENANCE.md`](./DOC-MAINTENANCE.md) | 文档维护与事实核对指南:组织规则、逐条核对清单、跨文档政策一致性核对、隐私清理、以及在 resolved final tree 上的复核方法,覆盖全部 tracked 的 agent/contributor Markdown(不止 `AGENTS.md` + `docs/*`,还包括 `.github/*.md`、package-local README)。**改/审文档前先读。** | diff --git a/docs/references/verification-methods.md b/docs/references/verification-methods.md new file mode 100644 index 000000000..24c27744a --- /dev/null +++ b/docs/references/verification-methods.md @@ -0,0 +1,90 @@ +# Verification methods + +[`../verification.md`](../verification.md) chooses the form; this file holds the patterns that reach behaviour the UI does not expose directly. Each is written twice where the two forms differ: driving a session ([`../../e2e/README.md`](../../e2e/README.md#8-verification-sessions)) and authoring a spec. Failures and gotchas are [`verification-debugging.md`](verification-debugging.md)'s. + +## Script execution: GM APIs and injection + +Making a userscript actually inject and run needs two things: the `userScripts` permission granted, and the permission prompt answered. + +A session grants `userScripts` at `start`, so injection works out of the box. It does **not** auto-approve prompts — a GM API that needs a grant opens `confirm.html`, which you answer like any other page: + +```bash +node e2e/drive.mjs pages # 找到 confirm.html +node e2e/drive.mjs use +node e2e/drive.mjs click "[data-testid=confirm-duration-permanent]" +node e2e/drive.mjs click "[data-testid=confirm-allow]" +``` + +In a spec, `testWithUserScripts` and `autoApprovePermissions` solve both ([`../../e2e/README.md`](../../e2e/README.md#3-harness-chain)) — import them rather than re-deriving the launch dance. + +### The in-page self-test pattern + +A userscript runs assertions in the page and prints a summary line the harness parses from the console. The bundled scripts in [`../../example/tests/`](../../example/tests/) do this; the line varies by script, and each emits a `通过`/`Passed` and a `失败`/`Failed` count: + +``` +总计: 12 | 通过: 12 | 失败: 0 # inject_content_test.js / sandbox_test.js (combined line) +总测试数: 12 / 通过: 12 / 失败: 0 # gm_api_sync_test.js / gm_api_async_test.js (counts on separate lines) +Total: 12 | Passed: 12 | Failed: 0 # window_message_test.js (English) +``` + +Collect and assert on it — this regex matches all three layouts: + +```ts +const logs: string[] = []; +let passed = -1; +let failed = -1; +page.on("console", (msg) => { + const text = msg.text(); + logs.push(text); + const pass = text.match(/(通过|Passed)[::]\s*(\d+)/); + const fail = text.match(/(失败|Failed)[::]\s*(\d+)/); + if (pass) passed = parseInt(pass[2], 10); + if (fail) failed = parseInt(fail[2], 10); +}); +// ...navigate to the target page, then: +expect(failed, logs.join("\n")).toBe(0); +expect(passed).toBeGreaterThan(0); +``` + +For a new GM API, write a small self-test userscript in the same style. In a session, `node e2e/drive.mjs install ` installs it through the Service Worker and `node e2e/drive.mjs console` shows the summary line the script printed; in a spec, use `installScriptByCode`. Keep the script inside the scenario directory — it is verification scaffolding, not a committed example. + +## Behaviour fired from extension UI + +The self-test pattern covers only what a userscript observes in the page. Some behaviour is fired from extension UI — a `GM_registerMenuCommand` menu is triggered from the popup. Clicking that button is not drivable ([`verification-debugging.md`](verification-debugging.md#common-gotchas)); sending the message it sends is. + +Clients talk to the Service Worker via `chrome.runtime.sendMessage({ action, data })`, where `action` is `/` and the reply is wrapped as `{ code, data }` — payload is `res.data`, a truthy `code` means error ([`../../packages/message/client.ts`](../../packages/message/client.ts)). Read the tab coordinates you need (`tabId`/`frameId`/`documentId`) from a prior `getPopupData` call. + +```ts +// from a chrome-extension:// page (e.g. options.html); poll until the async registration shows up +const res = await chrome.runtime.sendMessage({ + action: "serviceWorker/popup/getPopupData", + data: { tabId, url }, +}); +const script = res.data.scriptList.find((s) => s.menus.some((m) => m.name === "your-menu")); +await chrome.runtime.sendMessage({ + action: "serviceWorker/popup/menuClick", + data: { uuid: script.uuid, menus: script.menus }, // menus carry the target tabId/frameId/documentId +}); +``` + +From a session the same call is one command, since `eval` already runs on an extension page: + +```bash +node e2e/drive.mjs open options +node e2e/drive.mjs eval "const r = await chrome.runtime.sendMessage({action:'serviceWorker/popup/getPopupData', data:{tabId, url}}); return r.data.scriptList" +``` + +This drives the real SW → content → sandbox → callback path, behaviourally identical to the popup button, which discards the DOM event and calls the same message. It is a substitution: the verdict row names it and says the popup's own click path was not covered. + +## A UI change across light and dark theme + +The theme is stored in `localStorage` under `lightMode` with value `"light"` / `"dark"` / `"auto"` ([`../../src/pages/components/theme-provider.tsx`](../../src/pages/components/theme-provider.tsx), and [`../../src/pages/common.ts`](../../src/pages/common.ts), which reads the same key during pre-render to avoid a theme flash). Setting it before the page's own scripts run — `context.addInitScript` — is what applies the theme on first paint instead of flashing the default. + +Confirm that timing for a `chrome-extension://` page in your own setup before relying on it: `addInitScript` timing relative to an extension page's bootstrap can differ from a normal web page. Capture one screenshot per theme as separate evidence; one theme's screenshot does not show the other renders correctly. + +A session has no `addInitScript` hook of its own, so set the key and reload — the pre-render read in `common.ts` then picks it up before first paint: + +```bash +node e2e/drive.mjs eval "localStorage.setItem('lightMode','dark'); return location.reload()" +node e2e/drive.mjs shot settings-dark +``` diff --git a/docs/references/verification-report-template.md b/docs/references/verification-report-template.md index e4de2904a..c83ed4ffe 100644 --- a/docs/references/verification-report-template.md +++ b/docs/references/verification-report-template.md @@ -1,218 +1,97 @@ -# Verification record template + -Before running the browser, create a short verification record in the scenario directory, for example -`e2e/scratch//report.md`. Keep the reusable template headings in English, but write the actual -record content in the user's language. Update it as the run proceeds instead of filling it in only at the end. +# Local verification: -**The snippet below is a filled *example* of the `## Acceptance Evidence` shape** — it shows what a completed -one looks like, not a second section to add. The full template further down has its own heading; use that one -and fill it following this example. - -Evidence is organized **one `###` section per `Verdict` row**, not by artifact type. A reader arrives from a -`V2` row and finds every screenshot, log line, and fixture that decides `V2` in one place, in the order they -were observed. Verdict labels stay in the `Verdict` table and are not repeated here. - -This record exists so a reader can judge whether the implementation is correct, so **evidence is embedded, not -linked**: scrolling `report.md` top to bottom should show the pixels and the deciding log lines without opening -a single side file. A bare link is the fallback for artifacts that genuinely cannot render inline (archives, -binaries, multi-megabyte logs), and it carries a note saying what it holds. - -~~~md -## Acceptance Evidence - -### V1 · The `/` route mounts and lists installed scripts - -![Options root](screenshots/v1-options-root.png) -The script list rendered with the view toggle visible — the route mounted, rather than falling through to a -blank shell. - -```text -[verify] options url = chrome-extension:///src/options.html#/ -``` - -### V2 · `/settings` renders correctly in light and dark - -| Light | Dark | -| --- | --- | -| ![Settings light](screenshots/v2-settings-light.png) | ![Settings dark](screenshots/v2-settings-dark.png) | - -Readable contrast in both themes — the shell picked up the theme tokens instead of falling back to one palette. -One theme's screenshot alone would not show this. - - - -The full navigation from the script list to the settings page. The decisive frames, because a video is neither -skimmable nor playable in every viewer: - -![Before navigation](screenshots/v2-nav-01-list.png) -The settings entry, enabled, before the click. - -![After navigation](screenshots/v2-nav-02-settings.png) -The route changed and the content painted, after the click. - -### V3 · Importing a backup restores every script in it - -`resources/import.yaml` — the input this run consumed: - -```yaml -scripts: - - name: demo-script - source: https://example.com/demo.user.js -``` - -```text -[verify] script count after import = 3 -``` - -Three scripts in the file, three in the list. Full capture: [console.log](console.log) — no unexpected errors -during the run. -~~~ +## Mode -Use this shape: +`verifying a change` | `reproducing a bug` -```md -# Local E2E Verification Record: +## Goal / problem -## Mode +. -`verify-change` | `reproduce-bug` +## Verdict -## Goals / Problem + -- (verify) What behavior should hold, and why it might not -- (reproduce) **Expected:** … **Actual:** … +| # | Requirement / bug claim | Verdict | Real / substituted | How observed | Check it yourself | +|---|---|---|---|---|---| +| V1 | `` | holds / does not hold / not observed | real, or `substituted: ` | `` | `` | -## Reproduction Steps +Summary: . -1. … -2. … +| Label | Use it when | Requires | +|---|---|---| +| `holds` | you observed the behaviour at runtime | the deciding observation, and how a reader reaches it | +| `does not hold` | you observed it failing, or the bug reproducing | the failing output, assertion diff or error screenshot | +| `not observed` | you never reached the check | what stopped it | -## Minimal Reproduction +An unreached check is never `holds`; a run that verified two of three claims is reported as two of three. -- Smallest script/page/steps that trigger it (link `resources/…`) +## Authorization -## Task List + -- [ ] Prerequisite checks passed -- [ ] Built and loaded the real extension -- [ ] Opened target page and confirmed stable anchor -- [ ] Saved screenshots, videos, and logs -- [ ] Every Verdict row filled +| # | Substitute or effect | The user's authorization, verbatim | +|---|---|---| +| V1 | `` | `` | -## Execution Log +## Reproduction steps -| Step | Status | Evidence | Notes | -| --- | --- | --- | --- | -| Open options page | Pending | - | - | + -## Verdict +1. `` -| # | Claim under verification | Verdict | How observed | Check it yourself | -| --- | --- | --- | --- | --- | -| V1 | | holds / does not hold / not observed | | `` | +## Acceptance evidence -Summary: + -- (reproduce) Scratch asserts the **desired** behavior (stays red) or the **current buggy** contract - (passes green; the fix must flip it) — say which +### V1 · `` -## Blockers +```text +[verify] +``` -- None +. Full capture: ``. -## Acceptance Evidence +| Light | Dark | +|---|---| +| `![Settings light](screenshots/v1-light.png)` | `![Settings dark](screenshots/v1-dark.png)` | -### V1 · + - +## Evidence index -## Persistent Data Changes +- Screenshots/video: `` +- Logs: `` +- Resources: `` -| Change | Forward | Backward / backup | Before-after check | -| --- | --- | --- | --- | -| | | | | +## Persistent data changes -## Integrity & Cleanup + -- HEAD at start / end: `` / `` -- `git status --porcelain` at end: `` -- Artifacts, processes, and external data created — and how each was cleaned up: `` -- Redaction performed before saving: `` +| Change | Forward | Backward/backup | Before/after query | +|---|---|---|---| +| `` | `` | `` | `` | -## Evidence Index +## Execution record -- Screenshots / video: -- Logs: -- Resources / data snapshots: -``` +| Step | Status | Evidence/blocker | +|---|---|---| +| `` | pending / passed / failed / blocked | `` | -Fill `Verdict` last — the honest result, per *Step 4 — Report honestly* in -[`verification.md`](../verification.md). Execution Log `Status` moves `Pending` → `Pass` / `Fail` / `Blocked`. +## Integrity and cleanup -### Verdicts are per claim, and there are three of them +- Initial/final HEAD: `` / `` +- Final `git status --porcelain=v1`: `` +- Created artifacts/processes/external data and cleanup: `` +- Redaction performed: `` -One row per claim you set out to verify — split a compound claim rather than averaging it into one row. The -three labels are not interchangeable: +## Evidence rules -| Label | Use it when | Requires | -| --- | --- | --- | -| `holds` | you observed the behavior at runtime | the deciding observation *and* a command a reader can re-run | -| `does not hold` | you observed it failing, or observed the bug reproducing | the failing output, assertion diff, or error screenshot | -| `not observed` | you never reached the check — blocked, out of scope, environment missing | a `Blockers` entry saying what stopped it | - -`not observed` is the one that keeps a report honest: an unreached check is **never** `holds`. A run where two -claims held and one was never exercised is reported as exactly that, not as a pass. When the cause was an -unconfigured environment, name the service and the *variable names* that were missing — never their values. - -The `Check it yourself` column exists so a reviewer can reproduce a row without reconstructing the run; if a row -has no such command, say why in `How observed` rather than leaving it blank. - -### Sections to drop when they don't apply - -- `verify-change` mode: drop `Reproduction Steps` and `Minimal Reproduction`. In `reproduce-bug` mode fill - `Expected`/`Actual` and keep them, so a later reader or AI can re-trigger the bug from `report.md` alone - without reading the code. -- `Persistent Data Changes`: keep only when the run wrote data that outlives it — a real cloud-sync provider, an - imported backup, an OPFS/IndexedDB migration. An ephemeral browser profile that the harness deletes is not a - persistent change. Note the blast radius honestly: "only this test profile" is a valid, useful entry. -- `Integrity & Cleanup`: keep whenever the run touched a real external target or left anything behind. It is - what lets a reviewer confirm the verification didn't quietly modify the working tree or leave a live process - or real remote data around. - -Keep the checklist factual: - -- Start with unchecked tasks that describe what you intend to verify. -- Check items only after the corresponding command/assertion has actually passed. -- If a step is blocked, leave its checkbox unchecked and add a concrete entry under `Blockers`: what failed, - where it failed, and what evidence was captured. - -### Inside an Acceptance Evidence section - -One `###` per `Verdict` row, headed `V · `, holding everything that decides that row — commands, -output, screenshots, fixtures — in the order you observed them. Rules that follow from that: - -- A claim with no evidence section is `not observed`, not `holds`. If a row genuinely needs no artifact beyond - its `Check it yourself` command, say so in one line rather than omitting the section. -- One artifact can back two rows; put it under the row it decides and reference it from the other rather than - pasting it twice. -- Don't restate the verdict word here — the `Verdict` table owns it, and two copies drift apart. -- `Evidence Index` at the end is a **pointer list**, not a second copy: paths, and which row each backs. The - pixels and the deciding lines stay inline in the V sections. - -Keep the evidence embedded: - -- **Screenshots** — `![alt](screenshots/….png)` plus a caption line stating what it proves. Put paired shots - (before/after, light/dark) in a two-column table so the comparison is one glance, not two scrolls. -- **Videos** — ``. This renders as a player only in - viewers that allow inline HTML, and a recording is slow to review either way, so capture the deciding moments - as `page.screenshot()` calls *during* the run and embed those stills next to the video. The stills, not the - recording, are what carries the verdict. -- **Logs** — paste the lines the verdict rests on into a fenced block, then link the full capture for the rest. - A link alone forces the reader to reconstruct which line mattered. -- **Resources** — paste short text fixtures (YAML/JSON/userscript) inline in a fenced block. Link only what is - large or binary, and say what it contains. -- Sanitize tokens, cookies, and real credentials *before* pasting log or resource content inline — embedding - puts it in front of every reader. -- Keep every path relative to `report.md`. The scenario directory, not `report.md` alone, is the unit you hand - to a reviewer; moving the file out of it breaks every embed. +- Every `holds` names how the target was driven — command, or launch plus steps — and the deciding observation. A session already wrote that record to `actions.log`; quote it rather than reconstructing it from memory. +- Where a claim changes state beyond the driven surface, that observation is an independent read: extension storage from an extension page, or the page console. +- Embed decisive text and images inline; scrolling this file should reach a verdict without opening a side file. Link only archives, binaries and full captures, each with a note on what it holds. +- One artifact can back two rows: put it under the row it decides and reference it from the other. +- Keep failed and unchecked steps visible. Redact tokens, cookies and real credentials before saving, and again before embedding. +- Keep every path relative to this file; the scenario directory, not `report.md` alone, is what you hand to a reviewer. diff --git a/docs/verification.md b/docs/verification.md index 1002fb866..eeb0f60e2 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -1,311 +1,119 @@ -# Functional Verification Guide - -> **What this owns.** How to *confirm a change actually works* — or *reproduce a reported bug* — by driving the -> **real built extension** end-to-end, written so an AI coding tool (Claude, Codex, …) or a human can do it -> without inventing a workflow. Both modes use the same harness; the per-scenario `report.md` (below) is the -> consultable record of what was verified or reproduced. This is deliberately **lightweight**: one-shot scratch -> scripts and local-only evidence — kept out of Git and never deleted as part of a run; cleanup is the user's call. -> -> **What this is NOT.** It is *not* the test-suite reference and *not* the harness manual. Vitest mechanics live -> in [develop.md § Testing](./develop.md#testing); the E2E harness itself — fixtures, isolation, protocol mocks, -> `E2E_*` environment variables, artifact locations — is owned by [`e2e/README.md`](../e2e/README.md); the -> TDD-first principle and engineering rules live in -> [AGENTS.md § Engineering Principles](../AGENTS.md#engineering-principles). +# Feature verification ## When to skip this guide -This guide is **workflow routing**, not a TDD blanket exception — it applies when a change needs the built -extension, real Chrome APIs, or cross-context behavior to observe. Skip it (and rely on typecheck + the -relevant committed test instead) for: +Use targeted committed tests alone when they fully observe the changed logic — pure logic, parsers, utilities, +docs, comments, types, and anything the committed suite already proves. Use this guide when the behaviour +depends on cross-context wiring (Service Worker ↔ Content ↔ Inject ↔ Offscreen ↔ Sandbox) or a real Chrome API a +unit test cannot exercise, or when reproducing a runtime-only bug. It does not replace TDD. -- Doc-only, comment-only, or type-only changes with no runtime behavior to observe. -- Pure logic that a targeted unit test already exercises completely (a parser, a utility function, a reducer) — - write/run that test instead of driving a browser for it. -- Any change fully reproducible and provable by a targeted committed test without the built extension. +Verification is not how the committed suite grows: do not run `pnpm run test:e2e` to check one thing, and do not +add an `e2e/*.spec.ts` as part of it. Promotion is a separate decision +([`references/develop-testing.md`](references/develop-testing.md#choosing-a-test-boundary)). -If you're unsure whether a change needs the built extension, the deciding question is: *does this depend on -cross-context wiring or a real browser API that a unit test can't exercise?* If no, a targeted unit test is -the whole verification; don't reach for this guide's scratch-script workflow just to "be thorough." +## Workflow -## The one rule: verification ≠ growing the E2E suite +1. Run `pnpm run typecheck` and `pnpm test -- --run `; run `pnpm test` only when the blast radius is not + confirmed local or a gate requires it. +2. Build the extension with `pnpm run dev` (or `pnpm run build`). The session loads `dist/ext`, so a stale build + silently verifies old code — `session.mjs` refuses to start when `dist/ext/manifest.json` is missing, but it + cannot tell you the build is *old*. +3. Start a session and drive it. Everything it produces lands in `e2e/scratch//`, which is gitignored. -The full E2E suite is **heavy** (two-phase browser launch, real network fetches, multi-minute timeouts). When -you only want to *check that a feature works*, do not pay that cost and do not leave anything behind: + ```bash + node e2e/session.mjs start # 常驻浏览器,默认无头 + node e2e/drive.mjs open options # 一条命令一次操作 + node e2e/drive.mjs snapshot # 要点什么,先看有哪些可交互元素 + node e2e/drive.mjs click '[data-testid="theme-toggle"]' + node e2e/drive.mjs shot after-toggle + node e2e/session.mjs stop + ``` -- ❌ **Never** run the whole suite (`pnpm run test:e2e`) just to verify one thing *during casual verification*. - This rule scopes this guide's workflow — it is not a release/CI policy; CI and pre-release gates run the full - suite as their own separate, deliberate check. -- ❌ **Never** add a permanent `e2e/*.spec.ts` as part of casual verification. -- ✅ Write a **throwaway scratch script** under `e2e/scratch/` (git-ignored), run it, and keep any evidence local. +4. Before running, create `report.md` in that directory from + [`references/verification-report-template.md`](references/verification-report-template.md); update it as + evidence arrives. +5. Record how the target was driven, deciding runtime observations, gaps and shortest user reproduction steps. + `actions.log` already holds the driving record verbatim — quote it rather than reconstructing it. -Promoting a scenario into the permanent suite is a *separate, deliberate* decision — only when it deserves -permanent regression coverage. The criteria live in -[develop-testing.md § Choosing a test boundary](./references/develop-testing.md#choosing-a-test-boundary). +## Choosing the form -**Reproducing a bug you intend to fix is *not* "casual verification."** A scratch reproduction is the *确定 bug -存在* step in [`../AGENTS.md`](../AGENTS.md)'s TDD / Confirm-before-you-fix policy. In the general case it -confirms the bug is real but is not itself the required test — promote it into a committed failing test before -fixing. Only under that policy's infeasible-automated-coverage exception (criteria in -[develop-testing.md § When TDD doesn't apply](./references/develop-testing.md#when-tdd-doesnt-apply)) does the -scratch reproduction — its `report.md`, screenshots, and observations — stand as the required evidence on its -own. +Drive the live session by default. Author a spec only when the extra cost buys something. -Choose the reproduction method by what the bug depends on: a failing unit test for pure logic/parser/utility -bugs; this guide's scratch-script workflow (above) when it depends on the built extension, browser APIs, or -cross-context behavior. +| To observe the target | You author | +|---|---| +| a one-off state, visual or console check, however many steps it takes | nothing — drive the session | +| a sequence that must be replayed identically, or where timing/concurrency *is* the contract | a scratch spec | +| a flow worth protecting from regression forever | a committed `e2e/*.spec.ts`, as a separate decision | -## Prerequisite gate (cheap signals first, proportional to risk) +A session survives between commands, so exploring costs one command per question instead of one edit-and-rerun +cycle per question. A spec earns its cost when the *ordering* is the thing under test — `drive.mjs` gives no +guarantee about the gap between two invocations. -Driving a browser is the *last* check, not the first. Confirm the cheap signals are green before you build — -but scale which signals proportionally, not mechanically: +Scratch specs still run through the scratch config, and still belong to a scenario directory: ```bash -pnpm run typecheck # tsc --noEmit — always -pnpm test -- --run path/to/file.test.ts # targeted unit test(s) for the change — the default -pnpm test # full Vitest suite — only when the blast radius isn't confirmed - # local (shared utils, config, public interfaces), or a gate requires it +pnpm exec playwright test --config playwright.scratch.config.ts -g "" ``` -Green unit tests do **not** mean the feature works — they mean the units you tested behave. Cross-context wiring -(Service Worker ↔ Content ↔ Inject ↔ Offscreen ↔ Sandbox) and real Chrome APIs only exercise in a loaded -extension. That gap is exactly what this guide closes. - -## Step 1 — Build a loadable extension - -```bash -pnpm run dev # development build with source maps → writes dist/ext -# or: pnpm run build # production build, also → dist/ext -``` - -Every fixture loads `dist/ext`, so a stale build silently verifies old code — rebuild first. Setup details and -what a rebuild does or doesn't require live in [`e2e/README.md § Setup`](../e2e/README.md#2-setup). - -## Step 2 — Write a scratch verification script - -Each verification gets its own scenario directory **`e2e/scratch//`** holding the script *and* every -artifact it produces. The scripts reuse the committed harness, so you write almost no boilerplate — the -fixtures, the page openers, the script installer, and the environment variables are catalogued in -[`e2e/README.md`](../e2e/README.md). The short version: - -```ts -import { test, expect } from "../../fixtures"; // context + extensionId, onboarding dismissed -import { openOptionsPage } from "../../utils"; // page openers, installScriptByCode, … -``` - -### Evidence location - -Keep the script and all of its throwaway evidence together under **`e2e/scratch//`**: - -- the script itself: `e2e/scratch//*.spec.ts` -- screenshots: `e2e/scratch//screenshots/*.png` -- videos: `e2e/scratch//videos/*.webm` -- logs / notes / short verification reports: `e2e/scratch//*.md` or `*.log` -- additional test resources: `e2e/scratch//resources/` +## Driving the session -`e2e/scratch/` is git-ignored, so these files are local evidence only and must not be committed. Keep them out -of `test-results/` as well: Playwright wipes that directory at the start of every run, so evidence parked there -disappears the next time anyone runs the suite. Do not put verification screenshots, videos, or notes under -`docs/` or committed source directories unless you are deliberately adding permanent documentation assets. +[`../e2e/README.md`](../e2e/README.md#8-verification-sessions) owns the command reference. What matters for a +verdict: -Use `resources/` for any extra local inputs or outputs needed to understand or reproduce the run, for example: +- **Observe from a path the driven surface does not share.** `drive.mjs storage` reads `chrome.storage.local` + from an extension page, and `drive.mjs sw` evaluates inside the Service Worker — neither goes through the UI + you just clicked. +- **The session records continuously.** Page `console` and `pageerror` land in `console.log` for the whole + session lifetime, including output produced before you thought to look. Every `drive.mjs` command appends to + `actions.log`. +- **Screenshots are captured while the run is alive**, into `/shots/`, numbered in capture order. +- **`sw` runs *inside* the Service Worker**, so `chrome.runtime.sendMessage` there does not reach the extension + — send those from an extension page with `drive.mjs eval`. -- inline userscripts copied out of a scratch file for readability -- mock API responses, fixture JSON/YAML, import/export files, generated ZIPs, or downloaded artifacts -- temporary HTML pages, saved network payloads, or before/after data snapshots +Sessions are headless: verification must not steal desktop focus, and several worktrees verify at once. Add +`--headed` only to watch by eye. -Surface these resources in `report.md` — short text fixtures pasted into a fenced block, anything large or -binary as a relative link such as `[Exported backup](resources/backup.zip)`. Keep secrets and real credentials -out of the resource directory; sanitize them before saving evidence, and again before pasting any of it inline. +## Running more than one at a time -`report.md` is read to decide whether the implementation is correct, so embed the evidence instead of linking -it: screenshots as `![alt](screenshots/….png)`, videos as `