diff --git a/e2e/popup-autosize.spec.ts b/e2e/popup-autosize.spec.ts new file mode 100644 index 000000000..5f5c02f7c --- /dev/null +++ b/e2e/popup-autosize.spec.ts @@ -0,0 +1,213 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { chromium, type BrowserContext, type CDPSession, type Worker } from "@playwright/test"; +import { test, expect } from "./fixtures"; +import { headlessArgs } from "./launch-args"; + +const pathToExtension = path.resolve(__dirname, "../dist/ext"); + +// Chrome 默认缩放存成以 1.2 为底的对数级别;扩展 popup 跟随默认缩放。 +function zoomLevel(factor: number) { + return Math.log(factor) / Math.log(1.2); +} + +async function launchWithExtension(options: { zoom?: number; mobile?: boolean } = {}) { + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "pw-popup-autosize-")); + if (options.zoom) { + fs.mkdirSync(path.join(userDataDir, "Default"), { recursive: true }); + fs.writeFileSync( + path.join(userDataDir, "Default", "Preferences"), + JSON.stringify({ partition: { default_zoom_level: { x: zoomLevel(options.zoom) } } }) + ); + } + const context = await chromium.launchPersistentContext(userDataDir, { + headless: false, + args: [ + ...headlessArgs(), + `--disable-extensions-except=${pathToExtension}`, + `--load-extension=${pathToExtension}`, + "--disable-gpu", + ], + ...(options.mobile + ? { viewport: { width: 412, height: 915 }, isMobile: true, hasTouch: true, deviceScaleFactor: 2.625 } + : {}), + timeout: 60_000, + }); + await context.addInitScript(() => { + try { + localStorage.setItem("firstUse", "false"); + } catch { + // about:blank 等不透明来源无法访问 localStorage + } + }); + let [sw] = context.serviceWorkers(); + if (!sw) sw = await context.waitForEvent("serviceworker", { timeout: 14_000 }); + return { + context, + sw, + extensionId: sw.url().split("/")[2], + cleanup: async () => { + await context.close(); + fs.rmSync(userDataDir, { recursive: true, force: true }); + }, + }; +} + +async function installMatchingScripts(context: BrowserContext, extensionId: string, count: number) { + const page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/src/options.html`); + for (let i = 0; i < count; i++) { + const code = [ + "// ==UserScript==", + `// @name Autosize probe ${i}`, + "// @namespace e2e-popup-autosize", + "// @version 1.0", + "// @match https://example.com/*", + "// ==/UserScript==", + "", + ].join("\n"); + await page.evaluate( + (code) => + new Promise((resolve) => + chrome.runtime.sendMessage( + { + action: "serviceWorker/script/installByCode", + data: { uuid: crypto.randomUUID(), code, upsertBy: "user" }, + }, + resolve + ) + ), + code + ); + } + await page.close(); +} + +/** + * 以真实 action popup 打开(chrome.action.openPopup),而不是在标签页里加载 popup.html: + * 只有真正的 popup 窗口由浏览器按内容自动定宽定高,#1800 的塌缩只在这里出现。 + * Playwright 不把 popup 暴露成 Page,只能经 CDP Target 域附着求值。 + */ +async function openActionPopup(context: BrowserContext, sw: Worker) { + const tab = await context.newPage(); + await tab.route("https://example.com/**", (route) => + route.fulfill({ contentType: "text/html", body: "popup autosize" }) + ); + await tab.goto("https://example.com/"); + for (const p of context.pages()) if (p !== tab) await p.close(); + await tab.bringToFront(); + await sw.evaluate(async () => { + const [win] = await chrome.windows.getAll({ windowTypes: ["normal"] }); + await chrome.action.openPopup({ windowId: win.id }); + }); + + const cdp: CDPSession = await context.newCDPSession(tab); + let targetId: string | undefined; + await expect + .poll(async () => { + const { targetInfos } = await cdp.send("Target.getTargets"); + targetId = targetInfos.find((t) => t.url.includes("/src/popup.html"))?.targetId; + return targetId; + }) + .toBeTruthy(); + const { sessionId } = await cdp.send("Target.attachToTarget", { targetId: targetId!, flatten: false }); + + let seq = 0; + return function evaluate(expression: string): Promise { + const id = ++seq; + return new Promise((resolve, reject) => { + const onMessage = (event: { sessionId: string; message: string }) => { + if (event.sessionId !== sessionId) return; + const message = JSON.parse(event.message); + if (message.id !== id) return; + cdp.off("Target.receivedMessageFromTarget", onMessage); + if (message.error || message.result?.exceptionDetails) { + reject(new Error(JSON.stringify(message.error ?? message.result.exceptionDetails))); + } else { + resolve(message.result.result.value as T); + } + }; + cdp.on("Target.receivedMessageFromTarget", onMessage); + void cdp.send("Target.sendMessageToTarget", { + sessionId, + message: JSON.stringify({ id, method: "Runtime.evaluate", params: { expression, returnByValue: true } }), + }); + }); + }; +} + +type PopupLayout = { + contentWidth: number; + viewportHeight: number; + headerTop: number; + footerBottom: number; + innerScrollers: number; + documentScrollbarThemed: boolean; +}; + +// 页眉以 Header 组件的
为准,页脚为