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
5 changes: 4 additions & 1 deletion src/popup/popup.css
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
286 changes: 286 additions & 0 deletions tests/browser/popup-width.browser.test.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
11 changes: 8 additions & 3 deletions tests/unit/popup-css.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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;/);
Expand Down
Loading