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
12 changes: 12 additions & 0 deletions src/popup/popup.css
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,18 @@ h2 {
color: var(--danger);
}

.remove-button--undo {
background: rgba(37, 99, 235, 0.1);
color: var(--accent);
font-size: 12px;
font-weight: 800;
}

.remove-button--undo:hover {
background: rgba(37, 99, 235, 0.2);
color: var(--accent);
}

.confirm-dialog {
width: min(318px, calc(100vw - 28px));
padding: 0;
Expand Down
51 changes: 35 additions & 16 deletions src/popup/popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ let currentUnblockChallenge = null;
let currentBlockedRule = null;
let currentBlockedUrl = null;
let currentTabId = null;
let freshRules = new Set();

function createMemoryStorage() {
let state = { blockedSites: [], temporaryUnblocks: {} };
Expand Down Expand Up @@ -138,13 +139,31 @@ function render(sites, temporaryUnblocks = currentTemporaryUnblocks) {
};

const remove = documentRef.createElement("button");
remove.className = "remove-button";
const isFreshRule = freshRules.has(site);
remove.className = isFreshRule ?
"remove-button remove-button--undo" :
"remove-button";
remove.type = "button";
remove.textContent = "×";
remove.setAttribute("aria-label", `Remove ${site}`);
remove.title = "Remove blocked site";
remove.textContent = isFreshRule ? "Undo" : "×";
remove.setAttribute(
"aria-label",
isFreshRule ? `Undo adding ${site}` : `Remove ${site}`
);
remove.title = isFreshRule ?
"Undo adding this blocked site" :
"Remove blocked site";

remove.onclick = () => {
if (isFreshRule) {
return removeBlockedSite(site)
.then(() => {
freshRules.delete(site);
})
.catch((error) => {
setHint(getErrorMessage(error, "Could not remove blocked site."), true);
});
}

openRemoveDialog(site);
};

Expand Down Expand Up @@ -184,13 +203,6 @@ function getCurrentUnblockableSite(sites) {
return null;
}

function save(sites, callback) {
return storage.set({ blockedSites: sites }).then(() => {
render(sites, currentTemporaryUnblocks);
return callback?.();
});
}

function removeBlockedSite(site) {
const nextSites = [...currentSites];
const nextTemporaryUnblocks = { ...currentTemporaryUnblocks };
Expand Down Expand Up @@ -533,14 +545,21 @@ function saveRule(rule, successMessage, duplicateMessage, callback) {
return callback?.();
}

return save([...sites, rule], () => {
input.value = "";
setHint(successMessage);
return callback?.();
});
const nextSites = [...sites, rule];

return storage
.set({ blockedSites: nextSites })
.then(() => {
freshRules.add(rule);
render(nextSites, currentTemporaryUnblocks);
input.value = "";
setHint(successMessage);
return callback?.();
});
});
}


async function openBlockPage(tabId, site, blockedUrl = "") {
if (!tabId) return;

Expand Down
6 changes: 6 additions & 0 deletions tests/unit/popup-css.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ test("popup renders a compact custom-image summary", async () => {
);
});

test("fresh rule remove action uses undo styling", async () => {
const css = await readFile(new URL("../../src/popup/popup.css", import.meta.url), "utf8");

assert.match(css, /\.remove-button--undo\s*{[^}]*background:\s*rgba\(37,\s*99,\s*235,\s*0\.1\);/s);
});

function getRule(css, selector) {
const match = css.match(new RegExp(`${selector}\\s*{([^}]*)}`));

Expand Down
247 changes: 247 additions & 0 deletions tests/unit/popup-current-site.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,253 @@ test("popup falls back to the default preview when saved image data is invalid",
assert.equal(elements.imageHint.classList.has("is-error"), true);
});

test("freshly added rules can be undone immediately without challenge", async (t) => {
const { elements, state, cleanup } = await setupPopupTest({
activeTab: {
id: 42,
url: "https://example.com/"
}
});
t.after(cleanup);

elements.siteInput.value = "example.com";
await elements.ruleForm.onsubmit({
preventDefault() {}
});

const removeButton = elements.list.children[0].children[2];
assert.equal(removeButton.textContent, "Undo");
assert.equal(removeButton.className.includes("remove-button--undo"), true);
assert.equal(removeButton["aria-label"], "Undo adding example.com");
assert.equal(removeButton.title, "Undo adding this blocked site");
assert.equal(elements.unblockDialog.open, false);

await removeButton.onclick();

assert.deepEqual(state.blockedSites, []);
assert.equal(elements.unblockDialog.open, false);
assert.equal(elements.formHint.textContent, "Blocked site removed.");
});

test("multiple fresh rules each get Undo controls and can be undone independently", async (t) => {
const { elements, state, cleanup } = await setupPopupTest({
activeTab: {
id: 42,
url: "https://example.com/"
}
});
t.after(cleanup);

elements.siteInput.value = "example.com";
await elements.ruleForm.onsubmit({
preventDefault() {}
});

elements.siteInput.value = "openai.com";
await elements.ruleForm.onsubmit({
preventDefault() {}
});

assert.deepEqual(state.blockedSites, ["example.com", "openai.com"]);

const firstRemoveButton = elements.list.children[0].children[2];
const secondRemoveButton = elements.list.children[1].children[2];

assert.equal(firstRemoveButton.textContent, "Undo");
assert.equal(secondRemoveButton.textContent, "Undo");
assert.equal(firstRemoveButton["aria-label"], "Undo adding example.com");
assert.equal(secondRemoveButton["aria-label"], "Undo adding openai.com");

await firstRemoveButton.onclick();

assert.deepEqual(state.blockedSites, ["openai.com"]);
assert.equal(elements.list.children[0].children[2].textContent, "Undo");
});

test("preloaded and duplicate rules retain the challenge-dialog removal path", async (t) => {
const { elements, cleanup } = await setupPopupTest({
activeTab: {
id: 42,
url: "https://example.com/"
},
initialState: {
blockedSites: ["example.com"]
}
});
t.after(cleanup);

elements.siteInput.value = "example.com";
await elements.ruleForm.onsubmit({
preventDefault() {}
});

const removeButton = elements.list.children[0].children[2];
assert.equal(removeButton.textContent, "×");
assert.equal(removeButton["aria-label"], "Remove example.com");
assert.equal(removeButton.title, "Remove blocked site");
assert.equal(elements.formHint.textContent, "This site is already in the list.");

await removeButton.onclick();
await Promise.resolve();

assert.equal(elements.unblockDialog.open, true);
assert.equal(elements.challengeDialogTitle.textContent, "Remove blocked site?");
});

test("removing a fresh rule clears temporary unblock state without challenge", async (t) => {
const now = 1_700_000_000_000;
const { elements, state, cleanup } = await setupPopupTest({
activeTab: {
id: 42,
url: "https://example.com/"
},
now
});
t.after(cleanup);

elements.siteInput.value = "example.com";
await elements.ruleForm.onsubmit({
preventDefault() {}
});

elements.currentUnblockBtn.onclick();
await Promise.resolve();
await Promise.resolve();
answerCurrentChallenge(elements);
await submitUnblockForm(elements);

assert.deepEqual(state.temporaryUnblocks, {
"example.com": now + 5 * 60 * 1000
});
assert.equal(elements.currentBlockStatus.textContent.startsWith("Unblocked until "), true);

const removeButton = elements.list.children[0].children[2];
await removeButton.onclick();

assert.deepEqual(state.temporaryUnblocks, {});
assert.deepEqual(state.blockedSites, []);
assert.equal(elements.unblockDialog.open, false);
});

test("a re-added rule is fresh again and stays undoable", async (t) => {
const { elements, state, cleanup } = await setupPopupTest({
activeTab: {
id: 42,
url: "https://example.com/"
}
});
t.after(cleanup);

elements.siteInput.value = "example.com";
await elements.ruleForm.onsubmit({
preventDefault() {}
});

const removeButton = elements.list.children[0].children[2];
await removeButton.onclick();

assert.deepEqual(state.blockedSites, []);

elements.siteInput.value = "example.com";
await elements.ruleForm.onsubmit({
preventDefault() {}
});

const readdRemoveButton = elements.list.children[0].children[2];
assert.equal(readdRemoveButton.textContent, "Undo");
await readdRemoveButton.onclick();
assert.deepEqual(state.blockedSites, []);
});

test("fresh rule does not stay fresh after reopening popup", async (t) => {
const firstSession = await setupPopupTest({
activeTab: {
id: 42,
url: "https://example.com/"
}
});
const { cleanup: cleanupFirstSession } = firstSession;
t.after(() => {
cleanupFirstSession();
});

firstSession.elements.siteInput.value = "example.com";
await firstSession.elements.ruleForm.onsubmit({
preventDefault() {}
});

assert.deepEqual(firstSession.state.blockedSites, ["example.com"]);

const { elements, state, cleanup } = await setupPopupTest({
activeTab: {
id: 42,
url: "https://example.com/"
},
initialState: firstSession.state
});
t.after(cleanup);

const removeButton = elements.list.children[0].children[2];
assert.equal(removeButton.textContent, "×");
assert.equal(removeButton.className.includes("remove-button--undo"), false);
assert.equal(removeButton["aria-label"], "Remove example.com");
assert.equal(removeButton.title, "Remove blocked site");

await removeButton.onclick();
await Promise.resolve();

assert.equal(elements.unblockDialog.open, true);
assert.equal(elements.challengeDialogTitle.textContent, "Remove blocked site?");
assert.deepEqual(state.blockedSites, ["example.com"]);
});

test("a failed fresh Undo keeps the rule and retries successfully", async (t) => {
const { elements, chrome, state, cleanup } = await setupPopupTest({
activeTab: {
id: 42,
url: "https://example.com/"
}
});
t.after(cleanup);

elements.siteInput.value = "example.com";
await elements.ruleForm.onsubmit({
preventDefault() {}
});

const removeButton = elements.list.children[0].children[2];
const originalSet = chrome.storage.local.set;
const originalLastError = chrome.runtime.lastError;
let shouldFail = true;

chrome.storage.local.set = (nextState, callback) => {
if (shouldFail) {
shouldFail = false;
chrome.runtime.lastError = { message: "Storage unavailable" };
callback?.();
return;
}

return originalSet(nextState, callback);
};

await removeButton.onclick();

assert.equal(elements.formHint.textContent, "Storage unavailable");
assert.equal(elements.formHint.classList.has("is-error"), true);
assert.equal(removeButton.textContent, "Undo");
assert.equal(removeButton["aria-label"], "Undo adding example.com");
assert.deepEqual(state.blockedSites, ["example.com"]);

chrome.storage.local.set = originalSet;
chrome.runtime.lastError = originalLastError;
await removeButton.onclick();

assert.deepEqual(state.blockedSites, []);
assert.equal(elements.formHint.classList.has("is-error"), false);
assert.equal(elements.formHint.textContent, "Blocked site removed.");
});

test("temporarily unblocking a blocked site confirms with block image preview", async (t) => {
const now = 1_700_000_000_000;
const { elements, chrome, state, cleanup } = await setupPopupTest({
Expand Down
Loading