From c91126670c0a8339aa4a87840d13e093d0e6244a Mon Sep 17 00:00:00 2001 From: LTDigor Date: Wed, 29 Jul 2026 04:04:02 +0300 Subject: [PATCH] Fix popup expansion at root width --- src/popup/popup.css | 5 +- tests/browser/popup-width.browser.test.js | 286 ++++++++++++++++++++++ tests/unit/popup-css.test.js | 11 +- 3 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 tests/browser/popup-width.browser.test.js diff --git a/src/popup/popup.css b/src/popup/popup.css index a9b1124..ad2e6ae 100644 --- a/src/popup/popup.css +++ b/src/popup/popup.css @@ -1,6 +1,9 @@ :root { + width: 360px; + min-width: 360px; + max-width: 360px; + overflow-x: hidden; color-scheme: light; - scrollbar-gutter: stable; --bg: #f6f7f9; --surface: #ffffff; --surface-strong: #eef2f7; diff --git a/tests/browser/popup-width.browser.test.js b/tests/browser/popup-width.browser.test.js new file mode 100644 index 0000000..3818be7 --- /dev/null +++ b/tests/browser/popup-width.browser.test.js @@ -0,0 +1,286 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { + existsSync, + mkdtempSync, + readFile, + readFileSync, + rmSync +} from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { + extname, + join, + resolve, + sep +} from "node:path"; +import test from "node:test"; + +const root = process.cwd(); +const chromePath = findChrome(); +const rules = [ + "avito.ru/^$", + "linkedin.com/feed", + "2ch.org", + "leetcode.com/.*envType=daily-question.*", + "ozon.ru/^$" +]; + +test("rendered popup root remains 360px when blocked sites expand", async (t) => { + if (!chromePath) { + t.skip("Chrome or Chromium is required for popup layout regression testing"); + return; + } + + const server = createStaticServer(); + const profilePath = mkdtempSync(join(tmpdir(), "site-blocker-popup-test-")); + let browser; + let cdp; + + try { + const port = await listen(server); + + browser = spawn(chromePath, [ + "--headless=new", + "--remote-debugging-port=0", + `--user-data-dir=${profilePath}`, + "--no-first-run", + "--no-default-browser-check", + "--window-size=800,600", + `http://127.0.0.1:${port}/src/popup/popup.html` + ], { + stdio: "ignore" + }); + + const debuggerPort = await waitForDebuggerPort(profilePath, browser); + const target = await waitForPopupTarget(debuggerPort); + + cdp = new CdpClient(target.webSocketDebuggerUrl); + await cdp.open(); + await waitFor(async () => cdp.evaluate(` + document.readyState === "complete" && + Boolean(document.getElementById("rulesToggle")) + `)); + + for (const [index, rule] of rules.entries()) { + await cdp.evaluate(` + (() => { + const input = document.getElementById("siteInput"); + input.value = ${JSON.stringify(rule)}; + document.getElementById("ruleForm").requestSubmit(); + })() + `); + await waitFor(async () => ( + await cdp.evaluate("Number(document.getElementById('counter').textContent)") + ) === index + 1); + } + + const before = await measurePopup(cdp); + + await cdp.evaluate("document.getElementById('rulesToggle').click()"); + await waitFor(async () => ( + await cdp.evaluate("document.getElementById('rulesToggle').getAttribute('aria-expanded')") + ) === "true"); + + const after = await measurePopup(cdp); + + assert.equal(before.viewportWidth, 800); + assert.equal(after.viewportWidth, 800); + assert.equal(before.rootWidth, 360); + assert.equal(after.rootWidth, 360); + assert.equal(before.bodyWidth, 360); + assert.equal(after.bodyWidth, 360); + } finally { + cdp?.close(); + if (browser) { + browser.kill(); + await waitFor(() => browser.exitCode !== null); + } + await close(server); + rmSync(profilePath, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 100 + }); + } +}); + +async function measurePopup(cdp) { + return cdp.evaluate(` + (() => ({ + viewportWidth: innerWidth, + rootWidth: document.documentElement.getBoundingClientRect().width, + bodyWidth: document.body.getBoundingClientRect().width + }))() + `); +} + +function createStaticServer() { + return createServer((request, response) => { + const pathname = decodeURIComponent(new URL(request.url, "http://127.0.0.1").pathname); + const filePath = resolve(root, `.${pathname}`); + + if (!filePath.startsWith(`${root}${sep}`)) { + response.writeHead(403).end(); + return; + } + + readFile(filePath, (error, content) => { + if (error) { + response.writeHead(error.code === "ENOENT" ? 404 : 500).end(); + return; + } + + response.writeHead(200, { + "Content-Type": getContentType(filePath) + }); + response.end(content); + }); + }); +} + +function getContentType(filePath) { + return { + ".css": "text/css", + ".html": "text/html", + ".jpg": "image/jpeg", + ".js": "text/javascript", + ".png": "image/png" + }[extname(filePath)] || "application/octet-stream"; +} + +function listen(server) { + return new Promise((resolvePort, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + resolvePort(server.address().port); + }); + }); +} + +function close(server) { + if (!server.listening) return Promise.resolve(); + + return new Promise((resolveClose) => { + server.close(resolveClose); + }); +} + +async function waitForDebuggerPort(profilePath, browser) { + const portFile = join(profilePath, "DevToolsActivePort"); + + return waitFor(() => { + if (browser.exitCode !== null) { + throw new Error(`Chrome exited before DevTools started: ${browser.exitCode}`); + } + if (!existsSync(portFile)) return false; + + const [port] = readFileSync(portFile, "utf8").trim().split("\n"); + return Number(port); + }); +} + +async function waitForPopupTarget(port) { + return waitFor(async () => { + const response = await fetch(`http://127.0.0.1:${port}/json/list`); + const targets = await response.json(); + + return targets.find((target) => + target.type === "page" && + target.url.includes("/src/popup/popup.html") + ); + }); +} + +async function waitFor(check, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const result = await check(); + if (result) return result; + await new Promise((resolveWait) => setTimeout(resolveWait, 25)); + } + + throw new Error(`Condition was not met within ${timeoutMs}ms`); +} + +function findChrome() { + const candidates = [ + process.env.CHROME_PATH, + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser" + ].filter(Boolean); + + return candidates.find(existsSync); +} + +class CdpClient { + constructor(url) { + this.url = url; + this.nextId = 0; + this.pending = new Map(); + this.socket = null; + } + + open() { + return new Promise((resolveOpen, reject) => { + this.socket = new WebSocket(this.url); + this.socket.addEventListener("open", resolveOpen, { once: true }); + this.socket.addEventListener("error", reject, { once: true }); + this.socket.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + const pending = this.pending.get(message.id); + + if (!pending) return; + this.pending.delete(message.id); + + if (message.error) { + pending.reject(new Error(message.error.message)); + return; + } + + pending.resolve(message.result); + }); + }); + } + + async evaluate(expression) { + const result = await this.send("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true + }); + + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.text); + } + + return result.result.value; + } + + send(method, params = {}) { + return new Promise((resolveSend, reject) => { + const id = ++this.nextId; + + this.pending.set(id, { + resolve: resolveSend, + reject + }); + this.socket.send(JSON.stringify({ + id, + method, + params + })); + }); + } + + close() { + this.socket?.close(); + } +} diff --git a/tests/unit/popup-css.test.js b/tests/unit/popup-css.test.js index 0a38d87..4610954 100644 --- a/tests/unit/popup-css.test.js +++ b/tests/unit/popup-css.test.js @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -test("popup grows naturally without an internal blocked-sites scroll trap", async () => { +test("popup root stays 360px wide while blocked sites expand", async () => { const css = await readFile(new URL("../../src/popup/popup.css", import.meta.url), "utf8"); const rootRule = getRule(css, ":root"); const bodyRule = getRule(css, "body"); @@ -11,9 +11,14 @@ test("popup grows naturally without an internal blocked-sites scroll trap", asyn assert.match( rootRule, - /(^|\n)\s*scrollbar-gutter:\s*stable;/, - "opening a tall blocked-sites list must not add scrollbar width to the popup", + /(^|\n)\s*width:\s*360px;/, + "the root must own the popup width so Chrome cannot widen the viewport around a 360px body", ); + assert.match(rootRule, /(^|\n)\s*min-width:\s*360px;/); + assert.match(rootRule, /(^|\n)\s*max-width:\s*360px;/); + assert.match(rootRule, /(^|\n)\s*overflow-x:\s*hidden;/); + assert.doesNotMatch(rootRule, /scrollbar-gutter/); + assert.match(bodyRule, /(^|\n)\s*width:\s*360px;/); assert.doesNotMatch(bodyRule, /(^|\n)\s*height:\s*560px;/); assert.doesNotMatch(bodyRule, /(^|\n)\s*overflow:\s*hidden;/); assert.doesNotMatch(shellRule, /(^|\n)\s*height:\s*100vh;/);