// tests/content-fill.test.js // 用 jsdom(真实 DOM)测试 content.js 的登录表单探测与填充引擎: // 字段猜测、隐藏元素跳过、Shadow DOM 穿透、HTML 片段定位、 // 自动提交/验证码拦截、失败次数限制、多环境浮窗、SPA 重试、手动填充消息。 // 运行:node --test tests/content-fill.test.js // // jsdom 不实现布局(offsetWidth/offsetParent 恒为 0/null),因此这里统一把元素视为"可见", // 需要模拟隐藏时用 hide() 单独覆盖 —— 这样能真实覆盖 content.js 里的可见性判断分支。 const { describe, it } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const { JSDOM } = require("jsdom"); const UTILS_CODE = fs.readFileSync(path.join(__dirname, "..", "dist", "utils.js"), "utf8"); const CONTENT_CODE = fs.readFileSync(path.join(__dirname, "..", "dist", "content.js"), "utf8"); const tick = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // autoSubmit 的最小延迟是 300ms(content.js 里 Math.max(300, delayMs)) const AFTER_SUBMIT = 400; function createChromeMock({ configs = [], attempts = {}, activeEnvId = null } = {}) { const state = { configs, activeEnvId, attempts: new Map(Object.entries(attempts)), calls: [], messageListener: null, // 每个帧注入 content.js 都会注册一个监听器;allFrames 场景下用最后一个(最内层帧) messageListeners: [], }; const api = { runtime: { id: "mock-extension-id", onMessage: { addListener: (fn) => { state.messageListener = fn; state.messageListeners.push(fn); }, }, sendMessage: (message) => { state.calls.push(message.action); switch (message.action) { case "loadAllConfigs": return Promise.resolve({ configs: state.configs, activeEnvId: state.activeEnvId }); case "getVaultStatus": return Promise.resolve({ hasVault: false, unlocked: true, pendingImport: false }); case "getDomainAttempts": { const key = `${message.envId}::${message.domain}`; return Promise.resolve({ attempts: state.attempts.get(key) || { count: 0, lastAttempt: 0 } }); } case "incrementDomainAttempts": { const key = `${message.envId}::${message.domain}`; const cur = state.attempts.get(key) || { count: 0, lastAttempt: 0 }; cur.count += 1; cur.lastAttempt = Date.now(); state.attempts.set(key, cur); return Promise.resolve({ attempts: cur }); } case "resetDomainAttempts": return Promise.resolve({ success: true }); default: return Promise.resolve({}); } }, }, i18n: { getMessage: (key) => key }, storage: { local: { get: async () => ({}), set: async () => {}, remove: async () => {} }, session: { get: async () => ({}), set: async () => {}, remove: async () => {} }, }, }; return { api, state }; } /** * jsdom 没有布局引擎(offsetParent 恒为 null、offsetWidth 恒为 0), * 而 content.js 会用它们判断元素是否可见。这里按 realm 逐个打补丁: * 主窗口与每个 iframe 都各有自己的 HTMLElement.prototype,必须分别处理。 */ function patchLayout(win) { Object.defineProperty(win.HTMLElement.prototype, "offsetWidth", { get() { return 100; }, configurable: true, }); Object.defineProperty(win.HTMLElement.prototype, "offsetHeight", { get() { return 20; }, configurable: true, }); Object.defineProperty(win.HTMLElement.prototype, "offsetParent", { get() { return this.parentElement || this.ownerDocument.body; }, configurable: true, }); // jsdom 未实现 CSS.escape(真实浏览器有) if (!win.CSS) win.CSS = {}; if (!win.CSS.escape) win.CSS.escape = (s) => String(s).replace(/[^a-zA-Z0-9_\u00a0-\uffff-]/g, (c) => "\\" + c); } /** * 在 jsdom 页面里执行 utils.js + content.js(与浏览器一致:按普通脚本顺序执行)。 * setup 回调在注入脚本之前执行,用于准备"脚本加载前就必须存在"的结构(例如 Shadow Root)。 */ function loadContentPage({ html = "", url = "https://example.com/login", configs = [], attempts = {}, activeEnvId = null, timing, setup, } = {}) { // pretendToBeVisual: 让 jsdom 提供 requestAnimationFrame(content.js 的填充完成 toast 依赖它), // 否则 showFillToast() 会抛 ReferenceError。 const dom = new JSDOM(`${html}`, { url, runScripts: "dangerously", pretendToBeVisual: true, }); const win = dom.window; patchLayout(win); // 缩短时间常量(30 秒超时 / 4 秒结果观察),否则这些用例要真等半分钟 if (timing) win.__autoLoginTiming = timing; const chrome = createChromeMock({ configs, attempts, activeEnvId }); win.chrome = chrome.api; if (setup) setup(win, win.document); const inject = (code) => { const el = win.document.createElement("script"); el.textContent = code; win.document.head.appendChild(el); }; inject(UTILS_CODE); inject(CONTENT_CODE); return { dom, win, doc: win.document, chrome }; } /** 模拟元素不可见(隐藏域 / display:none) */ function hide(el) { Object.defineProperty(el, "offsetWidth", { get: () => 0, configurable: true }); Object.defineProperty(el, "offsetParent", { get: () => null, configurable: true }); return el; } function makeConfig(overrides = {}) { return { id: "cfg1", envId: "env1", envName: "测试", domain: "example.com", alias: null, username: "auto-user", password: "auto-pass", autoSubmit: false, usernameSelector: null, passwordSelector: null, submitSelector: null, enabled: true, delayMs: null, ...overrides, }; } describe("content - 字段探测", () => { it("标准登录表单:用户名 / 密码 / 提交按钮都能找到", () => { const ctx = loadContentPage({ html: `
`, }); assert.equal(ctx.win.findPasswordField({}).getAttribute("name"), "pass"); assert.equal(ctx.win.findUsernameField({}).getAttribute("name"), "username"); assert.equal( ctx.win.findSubmitButton({}, ctx.doc.querySelector("input[type=password]")).textContent, "登录", ); }); it("显式选择器优先于自动探测", () => { const ctx = loadContentPage({ html: `
`, }); const config = { passwordSelector: "#right", usernameSelector: "#u2" }; assert.equal(ctx.win.findPasswordField(config).id, "right"); assert.equal(ctx.win.findUsernameField(config).id, "u2"); }); it("用户名按 name / placeholder 关键词猜测", () => { const ctx = loadContentPage({ html: `
`, }); assert.equal(ctx.win.findUsernameField({}).id, "account"); }); it("隐藏的密码框被跳过,选可见的那个", () => { const ctx = loadContentPage({ html: `
`, }); hide(ctx.doc.getElementById("ghost")); assert.equal(ctx.win.findPasswordField({}).id, "real"); }); it("页面上没有密码框时返回 null,fillForm 返回 false", () => { const ctx = loadContentPage({ html: `
` }); assert.equal(ctx.win.findPasswordField({}), null); assert.equal(ctx.win.fillForm({ username: "u", password: "p", autoSubmit: false }, false), false); }); it("提交按钮按文案识别(非 type=submit)", () => { const ctx = loadContentPage({ html: `
`, }); assert.equal(ctx.win.findSubmitButton({}, ctx.doc.querySelector("input[type=password]")).id, "btn"); }); it("能穿透 Shadow DOM 找到登录框", () => { const ctx = loadContentPage({ html: `
` }); const shadow = ctx.doc.getElementById("host").attachShadow({ mode: "open" }); shadow.innerHTML = `
`; assert.equal(ctx.win.findPasswordField({}).getAttribute("name"), "pass"); assert.equal(ctx.win.findUsernameField({}).getAttribute("name"), "user"); }); it("resolveElement 支持选择器与整段 HTML 片段两种写法", () => { const ctx = loadContentPage({ html: `
`, }); assert.equal(ctx.win.resolveElement("input[name=pass]").id, "pwd"); // 从 HTML 片段反推选择器(用户直接粘贴元素代码的场景) assert.equal(ctx.win.resolveElement('').id, "pwd"); }); }); describe("content - 填充与提交", () => { it("填充用户名和密码,并对两个字段派发 input/change 事件", () => { const ctx = loadContentPage({ html: `
`, }); const events = []; for (const id of ["u", "p"]) { const el = ctx.doc.getElementById(id); el.addEventListener("input", () => events.push(`${id}:input`)); el.addEventListener("change", () => events.push(`${id}:change`)); } assert.equal(ctx.win.fillForm({ username: "alice", password: "secret", autoSubmit: false }, false), true); assert.equal(ctx.doc.getElementById("u").value, "alice"); assert.equal(ctx.doc.getElementById("p").value, "secret"); assert.deepEqual(events, ["u:input", "u:change", "p:input", "p:change"]); }); it("autoSubmit 为真时延迟点击提交按钮", async () => { const ctx = loadContentPage({ html: `
`, }); const btn = ctx.doc.getElementById("btn"); let clicks = 0; btn.addEventListener("click", () => clicks++); ctx.win.fillForm({ username: "u", password: "p", autoSubmit: true }, true); assert.equal(clicks, 0, "不应立即点击"); await tick(AFTER_SUBMIT); assert.equal(clicks, 1); }); it("检测到验证码时不自动提交,并弹出提示", async () => { const ctx = loadContentPage({ html: `
`, }); const btn = ctx.doc.getElementById("btn"); let clicks = 0; btn.addEventListener("click", () => clicks++); ctx.win.fillForm({ username: "u", password: "p", autoSubmit: true }, true); await tick(AFTER_SUBMIT); assert.equal(clicks, 0, "有验证码时不能自动提交"); assert.ok(ctx.doc.body.innerHTML.includes("captchaToastTitle"), "应显示验证码提示浮窗"); }); it("没有提交按钮但在 form 内时回退到 requestSubmit", async () => { const ctx = loadContentPage({ html: `
`, }); let submitted = 0; const form = ctx.doc.querySelector("form"); Object.defineProperty(form, "requestSubmit", { value: () => { submitted++; }, configurable: true, }); ctx.win.fillForm({ username: "u", password: "p", autoSubmit: true }, true); await tick(AFTER_SUBMIT); assert.equal(submitted, 1); }); }); describe("content - 自动填充主流程", () => { const LOGIN_HTML = `
`; it("域名命中配置时自动填充", async () => { const ctx = loadContentPage({ html: LOGIN_HTML, url: "https://example.com/login", configs: [makeConfig()], }); await tick(60); assert.equal(ctx.doc.getElementById("u").value, "auto-user"); assert.equal(ctx.doc.getElementById("p").value, "auto-pass"); }); it("域名不匹配时什么都不做", async () => { const ctx = loadContentPage({ html: LOGIN_HTML, url: "https://other.com/login", configs: [makeConfig()], }); await tick(60); assert.equal(ctx.doc.getElementById("u").value, ""); assert.equal(ctx.doc.getElementById("p").value, ""); }); it("配置被禁用时不填充", async () => { const ctx = loadContentPage({ html: LOGIN_HTML, configs: [makeConfig({ enabled: false })] }); await tick(60); assert.equal(ctx.doc.getElementById("u").value, ""); }); it("未达失败上限时自动提交并累加计数", async () => { const ctx = loadContentPage({ html: LOGIN_HTML, configs: [makeConfig({ autoSubmit: true })] }); let clicks = 0; ctx.doc.getElementById("btn").addEventListener("click", () => clicks++); await tick(AFTER_SUBMIT); assert.equal(ctx.doc.getElementById("u").value, "auto-user"); assert.equal(clicks, 1); assert.ok(ctx.chrome.state.calls.includes("incrementDomainAttempts")); }); it("达到上限后只填充、不自动提交,并给出可见提示", async () => { const ctx = loadContentPage({ html: LOGIN_HTML, configs: [makeConfig({ autoSubmit: true })], attempts: { "env1::example.com": { count: 3, lastAttempt: Date.now() } }, }); let clicks = 0; ctx.doc.getElementById("btn").addEventListener("click", () => clicks++); await tick(AFTER_SUBMIT); assert.equal(ctx.doc.getElementById("u").value, "auto-user", "仍然填充"); assert.equal(clicks, 0, "不应自动提交"); assert.ok(!ctx.chrome.state.calls.includes("incrementDomainAttempts")); // 用户必须能看懂"为什么只填不提交",否则会以为扩展坏了 assert.ok(ctx.doc.body.innerHTML.includes("attemptToastTitle"), "应显示暂停提示浮窗"); }); it("命中配置但页面上没有登录框时不消耗计数(浏览站内页面不会被误扣)", async () => { const ctx = loadContentPage({ html: `
登录成功后的站内页面
`, configs: [makeConfig({ autoSubmit: true })], timing: { fillTimeoutMs: 120 }, }); await tick(500); assert.ok(!ctx.chrome.state.calls.includes("incrementDomainAttempts"), "没有提交就不应计数"); assert.ok(ctx.chrome.state.calls.includes("resetDomainAttempts"), "始终没有登录框 → 清掉旧计数"); }); it("只有真正触发提交之后才累加计数", async () => { const ctx = loadContentPage({ html: LOGIN_HTML, configs: [makeConfig({ autoSubmit: true })] }); ctx.doc.getElementById("btn").addEventListener("click", () => {}); await tick(150); assert.ok(!ctx.chrome.state.calls.includes("incrementDomainAttempts"), "提交前不应计数"); await tick(300); // 提交延迟 300ms assert.ok(ctx.chrome.state.calls.includes("incrementDomainAttempts"), "提交后才计数"); }); it("提交后登录框消失视为登录成功并重置计数(SPA 场景)", async () => { const ctx = loadContentPage({ html: LOGIN_HTML, configs: [makeConfig({ autoSubmit: true })], timing: { submitResultCheckMs: 120 }, }); ctx.doc.getElementById("btn").addEventListener("click", () => {}); await tick(330); // 300ms 时已提交 assert.ok(ctx.chrome.state.calls.includes("incrementDomainAttempts"), "提交后先计数"); // 模拟 SPA 登录成功:登录表单从页面上消失 ctx.doc.querySelector("form").remove(); await tick(160); assert.ok(ctx.chrome.state.calls.includes("resetDomainAttempts"), "登录成功应重置计数"); }); it("提交后登录框仍在(登录失败)不重置计数", async () => { const ctx = loadContentPage({ html: LOGIN_HTML, configs: [makeConfig({ autoSubmit: true })], timing: { submitResultCheckMs: 120 }, }); ctx.doc.getElementById("btn").addEventListener("click", () => {}); await tick(560); // 覆盖 300ms 提交 + 120ms 结果观察 assert.ok(ctx.chrome.state.calls.includes("incrementDomainAttempts")); assert.ok(!ctx.chrome.state.calls.includes("resetDomainAttempts"), "登录框仍在,视为失败,不应重置"); }); it("被禁用的配置不再阻止计数重置", async () => { const ctx = loadContentPage({ url: "https://example.com/login/page", html: `
`, configs: [ makeConfig({ id: "a", domain: "example.com/login/*" }), makeConfig({ id: "b", domain: "example.com", enabled: false }), ], }); // 从 /login/page 跳到 /dashboard:只有被禁用的那条还匹配旧地址 ctx.dom.reconfigure({ url: "https://example.com/dashboard" }); ctx.doc.getElementById("app").textContent = "route changed"; // 触发 MutationObserver await tick(80); assert.ok(ctx.chrome.state.calls.includes("resetDomainAttempts"), "启用中的配置离开匹配范围应重置"); }); it("失败计数超过冷却时间后重新计数并允许提交", async () => { const ctx = loadContentPage({ html: LOGIN_HTML, configs: [makeConfig({ autoSubmit: true })], attempts: { "env1::example.com": { count: 3, lastAttempt: Date.now() - 10 * 60 * 1000 } }, }); let clicks = 0; ctx.doc.getElementById("btn").addEventListener("click", () => clicks++); await tick(AFTER_SUBMIT); assert.equal(clicks, 1, "冷却后应重新允许提交"); }); }); describe("content - 多环境命中", () => { const LOGIN_HTML = `
`; it("多个环境命中时弹出选择浮窗,点击后填充对应账号", async () => { const ctx = loadContentPage({ html: LOGIN_HTML, configs: [ makeConfig({ id: "c1", envId: "e1", envName: "个人", username: "personal@x.com" }), makeConfig({ id: "c2", envId: "e2", envName: "生产", username: "prod@x.com" }), ], }); await tick(60); const picker = ctx.doc.getElementById("auto-login-env-picker"); assert.ok(picker, "应出现多环境选择浮窗"); assert.equal(ctx.doc.getElementById("u").value, "", "选择前不填充"); const buttons = [...picker.querySelectorAll("button")]; const prodBtn = buttons.find((b) => b.textContent.includes("生产")); prodBtn.dispatchEvent(new ctx.win.MouseEvent("click", { bubbles: true })); await tick(60); assert.equal(ctx.doc.getElementById("u").value, "prod@x.com"); assert.equal(ctx.doc.getElementById("auto-login-env-picker"), null, "选择后浮窗关闭"); }); it("点击取消关闭浮窗且不填充", async () => { const ctx = loadContentPage({ html: LOGIN_HTML, configs: [makeConfig({ id: "c1" }), makeConfig({ id: "c2", envId: "e2", envName: "生产" })], }); await tick(60); const picker = ctx.doc.getElementById("auto-login-env-picker"); const cancelBtn = [...picker.querySelectorAll("button")].pop(); cancelBtn.dispatchEvent(new ctx.win.MouseEvent("click", { bubbles: true })); assert.equal(ctx.doc.getElementById("auto-login-env-picker"), null); assert.equal(ctx.doc.getElementById("u").value, ""); }); }); describe("content - SPA 延迟渲染", () => { it("表单晚于脚本出现时,重试后仍能填充", async () => { const ctx = loadContentPage({ html: `
`, configs: [makeConfig()], }); await tick(80); assert.equal(ctx.doc.querySelectorAll("input").length, 0, "此时还没有表单"); // 模拟 SPA 前端稍后渲染出登录表单 ctx.doc.getElementById("app").innerHTML = `
`; await tick(700); assert.equal(ctx.doc.getElementById("u").value, "auto-user"); }); it("Shadow DOM 内部后渲染的登录框也能填充(light DOM 完全没变)", async () => { let shadow; loadContentPage({ html: `
`, configs: [makeConfig()], setup: (win, doc) => { shadow = doc.getElementById("host").attachShadow({ mode: "open" }); }, }); // 首轮扫描:发现空的 shadow root 并开始监听它 await tick(80); assert.equal(shadow.querySelectorAll("input").length, 0); // 只改 shadow 内部,body 上的 MutationObserver 感知不到这种变化 shadow.innerHTML = `
`; await tick(700); assert.equal(shadow.getElementById("u").value, "auto-user"); }); }); describe("content - iframe 内的登录表单(allFrames)", () => { const FORM_HTML = `
`; /** 在页面里创建同源 iframe,并按 allFrames 的方式往帧内注入脚本 */ function injectIntoFrame(ctx, html) { const iframe = ctx.doc.createElement("iframe"); ctx.doc.body.appendChild(iframe); const iwin = iframe.contentWindow; const idoc = iframe.contentDocument; idoc.body.innerHTML = html; patchLayout(iwin); // iframe 是独立的 realm,需要单独打布局补丁 iwin.chrome = ctx.chrome.api; for (const code of [UTILS_CODE, CONTENT_CODE]) { const el = idoc.createElement("script"); el.textContent = code; idoc.head.appendChild(el); } return { iwin, idoc }; } it("iframe 内的表单能被填充(按 iframe 自己的地址匹配)", async () => { // iframe 在 jsdom 里是 about:blank,用通配规则让它命中;顶层帧没有表单所以不会误填 const ctx = loadContentPage({ html: `
`, configs: [makeConfig({ domain: "*" })] }); const { idoc } = injectIntoFrame(ctx, FORM_HTML); await tick(150); assert.equal(idoc.getElementById("u").value, "auto-user"); assert.equal(idoc.getElementById("p").value, "auto-pass"); }); it("子帧命中多个环境时不弹浮窗,按当前激活环境填充(顶层帧照旧弹窗)", async () => { const ctx = loadContentPage({ html: `
`, configs: [ makeConfig({ id: "c1", envId: "e1", envName: "个人", domain: "*", username: "personal@x.com" }), makeConfig({ id: "c2", envId: "e2", envName: "生产", domain: "*", username: "prod@x.com" }), ], activeEnvId: "e2", }); const { idoc } = injectIntoFrame(ctx, FORM_HTML); await tick(150); assert.equal(idoc.getElementById("u").value, "prod@x.com", "子帧应按激活环境选用凭据"); assert.equal(idoc.getElementById("auto-login-env-picker"), null, "子帧不弹浮窗"); assert.ok(ctx.doc.getElementById("auto-login-env-picker"), "顶层帧仍然弹浮窗"); }); it("子帧收到 manualFillMulti 时同样按激活环境填充", async () => { const ctx = loadContentPage({ html: `
`, configs: [ makeConfig({ id: "c1", envId: "e1", domain: "*", username: "personal@x.com" }), makeConfig({ id: "c2", envId: "e2", domain: "*", username: "prod@x.com" }), ], activeEnvId: "e2", }); const { idoc } = injectIntoFrame(ctx, FORM_HTML); // 最后注册的监听器来自最内层帧 const listeners = ctx.chrome.state.messageListeners; const frameListener = listeners[listeners.length - 1]; frameListener({ action: "manualFillMulti", configs: [...ctx.chrome.state.configs] }, {}, () => {}); await tick(150); assert.equal(idoc.getElementById("u").value, "prod@x.com"); assert.equal(idoc.getElementById("auto-login-env-picker"), null); }); it("pickConfigForFrame:优先当前激活环境,其次第一条", () => { const ctx = loadContentPage({ html: "" }); const a = makeConfig({ id: "a", envId: "e1" }); const b = makeConfig({ id: "b", envId: "e2" }); assert.equal(ctx.win.pickConfigForFrame([a, b], "e2").id, "b"); assert.equal(ctx.win.pickConfigForFrame([a, b], "e9").id, "a"); assert.equal(ctx.win.pickConfigForFrame([a, b], null).id, "a"); }); }); describe("content - 手动填充消息", () => { const LOGIN_HTML = `
`; it("manualFill 填充指定配置并回包 success", async () => { const ctx = loadContentPage({ html: LOGIN_HTML }); let response = null; ctx.chrome.state.messageListener( { action: "manualFill", config: makeConfig({ username: "manual@x.com" }) }, {}, (r) => { response = r; }, ); await tick(30); // 注意:response 由 jsdom realm 创建,跨 realm 不能用 deepEqual(原型不同) assert.equal(response.success, true); assert.equal(ctx.doc.getElementById("u").value, "manual@x.com"); }); it("manualFill 找不到表单时回包诊断信息", async () => { const ctx = loadContentPage({ html: `
没有表单
` }); let response = null; ctx.chrome.state.messageListener({ action: "manualFill", config: makeConfig() }, {}, (r) => { response = r; }); await tick(30); assert.equal(response.success, false); assert.equal(response.diag.total, 0); assert.equal(response.diag.forms, 0); }); it("manualFillMulti 弹出多环境浮窗", async () => { const ctx = loadContentPage({ html: LOGIN_HTML }); let response = null; ctx.chrome.state.messageListener( { action: "manualFillMulti", configs: [makeConfig({ id: "c1" }), makeConfig({ id: "c2", envId: "e2", envName: "生产" })], }, {}, (r) => { response = r; }, ); await tick(30); assert.equal(response.success, true); assert.ok(ctx.doc.getElementById("auto-login-env-picker")); }); });