Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions e2e/popup-autosize.spec.ts
Original file line number Diff line number Diff line change
@@ -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: "<title>popup autosize</title>" })
);
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<T>(expression: string): Promise<T> {
const id = ++seq;
return new Promise<T>((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 组件的 <header> 为准,页脚为 <footer>;文档滚到底部后两者仍须在可视区内。
// 文档是唯一滚动容器(避免双滚动条),其滚动条沿用主题化的 scrollbar-custom 样式。
const measureLayout = `(() => {
window.scrollTo(0, document.documentElement.scrollHeight);
const header = document.querySelector("header");
const footer = document.querySelector("footer");
if (!header || !footer) return null;
const innerScrollers = Array.from(document.body.querySelectorAll("*")).filter((el) => {
const style = getComputedStyle(el);
return /(auto|scroll)/.test(style.overflowY) && el.scrollHeight > el.clientHeight;
}).length;
return {
contentWidth: document.documentElement.clientWidth,
viewportHeight: window.innerHeight,
headerTop: Math.round(header.getBoundingClientRect().top),
footerBottom: Math.round(footer.getBoundingClientRect().bottom),
innerScrollers,
documentScrollbarThemed: getComputedStyle(document.documentElement).scrollbarWidth === "thin",
};
})()`;

test.describe("Popup 窗口自适应尺寸", () => {
// 默认缩放 125% 时 popup 窗口最高 600 DIP = 480 CSS px,内容超过窗口高度后文档出现滚动:
// 曾经宽度塌缩成 25px 细条(#1800),且页脚被挤出可视区。
test("125% 缩放且脚本较多时宽度保持 360px,页眉页脚始终可见", async () => {
const { context, sw, extensionId, cleanup } = await launchWithExtension({ zoom: 1.25 });
try {
await installMatchingScripts(context, extensionId, 12);
const evaluate = await openActionPopup(context, sw);

await expect
.poll(() => evaluate<PopupLayout | null>(measureLayout), { timeout: 15_000 })
.toMatchObject({
contentWidth: 360,
innerScrollers: 0,
documentScrollbarThemed: true,
});
const layout = (await evaluate<PopupLayout>(measureLayout))!;
expect(layout.headerTop).toBeGreaterThanOrEqual(0);
expect(layout.footerBottom).toBeLessThanOrEqual(layout.viewportHeight + 1);
} finally {
await cleanup();
}
});

// 移动端(如 Edge Android)popup 被撑满整屏:宽度须填满(#686),页脚须贴底而不是悬在内容下方。
test("移动端整屏 popup 宽度填满且页脚贴底", async () => {
const { context, extensionId, cleanup } = await launchWithExtension({ mobile: true });
try {
const page = await context.newPage();
await page.goto(`chrome-extension://${extensionId}/src/popup.html`);
await expect(page.locator("footer")).toBeVisible();

const layout = await page.evaluate(() => ({
contentWidth: document.documentElement.clientWidth,
viewportHeight: window.innerHeight,
footerBottom: Math.round(document.querySelector("footer")!.getBoundingClientRect().bottom),
}));
expect(layout.contentWidth).toBe(412);
expect(layout.footerBottom).toBe(layout.viewportHeight);
} finally {
await cleanup();
}
});
});
24 changes: 11 additions & 13 deletions src/pages/popup.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<!-- 文档是 popup 唯一的滚动容器(见 popup/App.tsx 根容器),滚动条样式挂在 html 上 -->
<html lang="en" class="scrollbar-custom">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
Expand All @@ -11,20 +12,17 @@
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%;
}
</style>
</head>
Expand Down
40 changes: 0 additions & 40 deletions src/pages/popup/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, screen, fireEvent, act } from "@testing-library/react";
import fs from "fs";
import path from "path";
import { initTestLanguage } from "@Tests/initTestLanguage";
import { t } from "@App/locales/locales";
import { ExtVersion } from "@App/app/const";
Expand Down Expand Up @@ -522,16 +520,6 @@ describe("Popup 菜单展开数量为 0 时的菜单位置", () => {
});
});

describe("Popup 移动端宽度适配 (#686 Edge Android)", () => {
it("popup.html 通过媒体查询在移动端(视口 >360px)将宽度切换为 100%", () => {
const html = fs.readFileSync(path.join(process.cwd(), "src/pages/popup.html"), "utf8");
// 桌面端 popup 视口恒为 360px、不命中;移动端被撑满(>360px)命中后切换 100% 宽度
expect(html).toMatch(/width:\s*360px/);
expect(html).toMatch(/@media\s*\(min-width:\s*365px\)/);
expect(html).toMatch(/width:\s*100%/);
});
});

describe("Popup 页脚版本号可达性", () => {
it("无新版本且检查状态空闲时,版本号是可键盘聚焦的按钮(非 span),点击触发检查更新", () => {
const handleVersionClick = vi.fn();
Expand Down Expand Up @@ -653,31 +641,3 @@ describe("Popup 反馈问题链接", () => {
expect(url.searchParams.get("browser")).toBe("Windows 11 + Chrome 143.0.7499.96 (arm64)");
});
});

describe("Popup 滚动区域(避免双滚动条)", () => {
it("根容器受最大高度约束并裁剪溢出,仅脚本列表区可滚动", () => {
mockData = makeData({ fullScriptCount: 0 });

const { container } = render(<App />);
const root = container.firstElementChild as HTMLElement;

// 根容器需有最大高度约束并裁剪溢出,避免浏览器 popup 自身再出现一条滚动条
expect(cls(root)).toMatch(/max-h-\[/);
expect(cls(root)).toMatch(/overflow-hidden/);

// 整个 popup 仅应有一个 overflow-auto 滚动区,且占据剩余空间(flex-1)
const scrollers = Array.from(container.querySelectorAll("*")).filter((el) => /overflow-auto/.test(cls(el)));
expect(scrollers).toHaveLength(1);
expect(cls(scrollers[0])).toMatch(/flex-1/);
});

it("脚本列表滚动区应应用自定义滚动条样式(明暗主题由 CSS 变量自适应)", () => {
mockData = makeData({ fullScriptCount: 0 });

const { container } = render(<App />);
const scrollers = Array.from(container.querySelectorAll("*")).filter((el) => /overflow-auto/.test(cls(el)));

expect(scrollers).toHaveLength(1);
expect(cls(scrollers[0])).toMatch(/scrollbar-custom/);
});
});
15 changes: 9 additions & 6 deletions src/pages/popup/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,13 @@ export default function App() {
}

return (
<div className="w-full max-h-[600px] flex flex-col bg-background text-foreground overflow-hidden">
{/* 固定顶部区域:警告、黑名单、Header、错误/公告、搜索(不参与滚动) */}
<div className="shrink-0">
// 高度交给浏览器:popup 窗口按内容自适应、超出窗口上限时由文档滚动。不能写死 600px 上限——
// 窗口上限是 600 DIP,缩放 125% 时只有 480 CSS px,写死会把页眉页脚挤出可视区(#1800);
// 也不能用 vh 当上限,自适应时视口高度依赖内容,会被夹到最小高度。
// 桌面 popup 的视口高度本就等于内容高度,min-h-screen 不会把它撑高;移动端整屏 popup 靠它让页脚贴底。
<div className="w-full min-h-screen flex flex-col bg-background text-foreground">
{/* 吸顶区域:警告、黑名单、Header、错误/公告、搜索 */}
<div className="shrink-0 sticky top-0 z-10 bg-background">
{/* 顶部警告区:UserScripts API 不可用引导 / 申请权限 / Edge 移动端二维码 / 黑名单 */}
<PopupWarnings />
{/* 本页不会运行脚本时说明原因,取代「列出一堆并没有在跑的脚本」 */}
Expand Down Expand Up @@ -141,8 +145,7 @@ export default function App() {
)}
{data.showSearch && <SearchBar value={data.searchQuery} onChange={data.handleSearch} />}
</div>
{/* 唯一滚动区域:脚本列表(占据剩余空间) */}
<div className="flex-1 min-h-0 overflow-auto scrollbar-custom">
<div className="flex-1">
<AccordionPrimitive.Root
type="multiple"
defaultValue={data.fullBackScriptCount > 0 ? ["current", "background"] : ["current"]}
Expand Down Expand Up @@ -846,7 +849,7 @@ function Footer({
const hasNewVersion = versionCompare(ExtVersion, checkUpdate.version) === VersionCompare.LESS;

return (
<footer className="h-9 shrink-0 px-4 flex items-center border-t border-border">
<footer className="h-9 shrink-0 sticky bottom-0 z-10 px-4 flex items-center border-t border-border bg-background">
{hasNewVersion ? (
<button
type="button"
Expand Down
Loading