From dce8bbb08df1ab92affcf2040330b212f5a88ec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 21 Oct 2026 11:23:23 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=E6=97=A7?= =?UTF-8?q?=E7=89=88=20Chrome=20=E4=B8=8B=20popup=20=E5=AE=BD=E5=BA=A6?= =?UTF-8?q?=E5=A1=8C=E7=BC=A9=E6=88=90=2025px=20=E7=BB=86=E6=9D=A1=20(#180?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit popup.html 用 @media (min-width: 365px) 在宽视口切到 width:100% 来适配移动端(#686), 但桌面 popup 的窗口宽度由内容内在宽度反推:出现经典滚动条时视口变成 360px + 滚动条宽而命中, width:100% 让内在宽度趋近 0,Chrome 150 及以下的 autosize 算法(151 起 AutoSizeUsesScrollWidthForOverflow 才改用 scroll width)把窗口夹到最小 25px 后不再撑开。 改为 html 固定 width:360px + min-width:100%:内在宽度恒为 360px 与视口解耦, 百分比 min-width 只在移动端视口被撑满设备宽度时生效,保留 #686 的填满行为。 --- src/pages/popup.html | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/pages/popup.html b/src/pages/popup.html index 173492d22..8e3c36e16 100644 --- a/src/pages/popup.html +++ b/src/pages/popup.html @@ -11,20 +11,18 @@ margin: 0; padding: 0; border: 0; - width: 360px; min-height: 150px; max-height: 600px; } - /* 桌面端 popup 的视口宽度恒等于 body 宽度(360px),永远不会命中此查询,行为不变; - 移动端(如 Edge Android)popup 被强制撑满设备宽度,命中后填满外层容器,消除右侧留白(#686)。 - 阈值取 365px:高于桌面固定宽度 360px(桌面不命中),且低于任何比 360px 更宽的手机视口; - 360px 手机本身已被固定宽度精确填满,无需命中。 */ - @media (min-width: 365px) { - html, - body { - width: 100%; - max-height: none; - } + /* 桌面 popup 的窗口宽度由内容的内在宽度反推,宽度规则不能依赖视口: + 曾用 @media (min-width) 在宽视口切到 width:100%,出现经典(非覆盖式)滚动条时 + 视口会变成 360px + 滚动条宽而命中,Chrome 150 及以下据此算出的内在宽度趋近 0, + popup 卡在 25px 细条(#1800)。 + 固定 width 让内在宽度恒为 360px;min-width 的百分比不参与内在宽度, + 只在移动端(如 Edge Android)视口被强制撑满设备宽度时生效,消除右侧留白(#686)。 */ + html { + width: 360px; + min-width: 100%; } From 0b380f9eee71024aa1e46e93c74b9997620a7c10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 21 Oct 2026 12:44:46 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20popup=20=E9=AB=98=E5=BA=A6?= =?UTF-8?q?=E6=94=B9=E7=94=B1=E6=B5=8F=E8=A7=88=E5=99=A8=E8=87=AA=E9=80=82?= =?UTF-8?q?=E5=BA=94=EF=BC=8C=E4=BF=AE=E5=A4=8D=E7=BC=A9=E6=94=BE=E5=A4=A7?= =?UTF-8?q?=E4=BA=8E=20100%=20=E6=97=B6=E9=A1=B5=E7=9C=89=E9=A1=B5?= =?UTF-8?q?=E8=84=9A=E8=A2=AB=E6=8C=A4=E5=87=BA=E3=80=81=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=E9=A1=B5=E8=84=9A=E4=B8=8D=E8=B4=B4=E5=BA=95=20(#1800?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根容器写死 max-h-[600px](CSS px)并在内部列表滚动,但 popup 窗口上限是 600 DIP: 默认缩放 125% 时只有 480 CSS px,内容被裁出窗口后整个文档滚动,页眉滚走、页脚版本号被挤出可视区; 这条文档滚动条在经典滚动条平台上还会把视口撑宽,正是旧版 Chrome 宽度塌缩(#1800)的触发条件。 移动端整屏 popup 下内容高度不足时,页脚悬在内容下方而不是贴底。 改为文档作为唯一滚动容器:去掉固定高度上限与内部 overflow 滚动区,警告/页眉区吸顶、页脚吸底, 根容器 min-h-screen 让移动端页脚贴底;滚动条样式 scrollbar-custom 移到 html 上。 不用 vh 做上限:popup 自适应时视口高度依赖内容,实测会被夹到 150px 最小高度。 测试:新增 e2e/popup-autosize.spec.ts,用真实 action popup(chrome.action.openPopup + CDP) 在 125% 缩放 + 12 个匹配脚本下断言内容宽 360、页眉页脚在可视区、无内部滚动区且文档滚动条已主题化, 并在移动端模拟下断言宽度填满(#686)、页脚贴底。 删除 App.test.tsx 中已失效的结构断言:根容器 max-h/overflow-hidden、列表区 overflow-auto(契约已改为文档滚动), 以及上一提交改掉的 popup.html @media 文件内容断言;对应行为由上述 e2e 以真实布局覆盖。 --- e2e/popup-autosize.spec.ts | 213 +++++++++++++++++++++++++++++++++++ src/pages/popup.html | 4 +- src/pages/popup/App.test.tsx | 40 ------- src/pages/popup/App.tsx | 15 ++- 4 files changed, 224 insertions(+), 48 deletions(-) create mode 100644 e2e/popup-autosize.spec.ts 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 组件的
为准,页脚为