From 12f4d2fbb85bd4138f9e5e28247f2599c4fb36f9 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 10:46:51 +0700 Subject: [PATCH 01/43] fix: preserve active account domain in website aliases --- src/services/websiteAliasService.ts | 39 ++++++++++++++++------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/src/services/websiteAliasService.ts b/src/services/websiteAliasService.ts index cdde8d35..ee039639 100644 --- a/src/services/websiteAliasService.ts +++ b/src/services/websiteAliasService.ts @@ -1,5 +1,8 @@ import { normalizeHostname } from "../utils/hostnameNormalizer"; -import { getAccountStorageKey } from "../../entrypoints/popup/utils"; +import { + generateAlias, + getAccountStorageKey, +} from "../../entrypoints/popup/utils"; interface WebsiteAliasEntry { alias: string; @@ -74,36 +77,36 @@ export async function generateSuggestionsForWebsite( urlOrHostname: string, ): Promise { const normalized = normalizeHostname(urlOrHostname); - if (!normalized) return []; - - // Extract base email part (before @gmail.com) - const emailBase = baseEmail.split("@")[0]; + if (!normalized || !generateAlias(baseEmail, normalized)) return []; const suggestions: string[] = []; - // 1. Simple: base+normalized - suggestions.push(`${emailBase}+${normalized}@gmail.com`); + /** Adds a valid plus-addressed alias while preserving the active account domain. */ + function appendSuggestion(tag: string): void { + const alias = generateAlias(baseEmail, tag); + if (alias) suggestions.push(alias); + } + + // 1. Simple: base+normalized@configured-domain + appendSuggestion(normalized); - // 2. Counter: base+normalized001 + // 2. Counter: base+normalized001@configured-domain const map = await getWebsiteAliasMap(baseEmail); const count = map[normalized]?.generatedCount || 0; if (count > 0) { - suggestions.push( - `${emailBase}+${normalized}${String(count + 1).padStart(3, "0")}@gmail.com`, - ); + appendSuggestion(`${normalized}${String(count + 1).padStart(3, "0")}`); } - // 3. Short: base+first-3-letters - const shortCode = normalized.slice(0, 3); - suggestions.push(`${emailBase}+${shortCode}@gmail.com`); + // 3. Short: base+first-3-letters@configured-domain + appendSuggestion(normalized.slice(0, 3)); - // 4. With date: base+normalized-YYYYMMDD + // 4. With date: base+normalized-YYYYMMDD@configured-domain const today = new Date().toISOString().slice(0, 10).replace(/-/g, ""); - suggestions.push(`${emailBase}+${normalized}-${today}@gmail.com`); + appendSuggestion(`${normalized}-${today}`); - // 5. Random suffix: base+normalized-XXXX + // 5. Random suffix: base+normalized-XXXX@configured-domain const randomSuffix = Math.random().toString(36).substring(2, 6); - suggestions.push(`${emailBase}+${normalized}-${randomSuffix}@gmail.com`); + appendSuggestion(`${normalized}-${randomSuffix}`); return suggestions.slice(0, 5); } From 596ee9fee7f595200f297b9f635213ec771d3275 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 10:48:05 +0700 Subject: [PATCH 02/43] test: cover Google Workspace alias domains --- tests/services/websiteAliasService.test.ts | 34 +++++++++++++++++----- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/tests/services/websiteAliasService.test.ts b/tests/services/websiteAliasService.test.ts index 6a7af22f..4d25bc00 100644 --- a/tests/services/websiteAliasService.test.ts +++ b/tests/services/websiteAliasService.test.ts @@ -48,9 +48,16 @@ vi.stubGlobal("browser", { } as unknown as MockBrowser); // Mock the getAccountStorageKey utility -vi.mock("../../entrypoints/popup/utils", () => ({ - getAccountStorageKey: (email: string, suffix: string) => `${email}:${suffix}`, -})); +vi.mock("../../entrypoints/popup/utils", async (importOriginal) => { + const original = await importOriginal< + typeof import("../../entrypoints/popup/utils") + >(); + + return { + ...original, + getAccountStorageKey: (email: string, suffix: string) => `${email}:${suffix}`, + }; +}); describe("websiteAliasService", () => { const testEmail = "user@gmail.com"; @@ -183,6 +190,14 @@ describe("websiteAliasService", () => { expect(suggestions).toEqual([]); }); + it("returns empty array for malformed base email", async () => { + const suggestions = await generateSuggestionsForWebsite( + "invalid-email", + "https://github.com", + ); + expect(suggestions).toEqual([]); + }); + it("generates up to 5 suggestions for new website", async () => { const suggestions = await generateSuggestionsForWebsite( testEmail, @@ -258,14 +273,19 @@ describe("websiteAliasService", () => { expect(suggestions.length).toBeLessThanOrEqual(5); }); - it("always uses @gmail.com for suggestions regardless of email domain", async () => { + it("preserves the selected Google Workspace domain", async () => { + const workspaceEmail = "nguyen.minh.hoang@rivercrane.vn"; const suggestions = await generateSuggestionsForWebsite( - "user@example.com", + workspaceEmail, "https://github.com", ); - // Suggestions always use @gmail.com, not the original email domain - suggestions.forEach((s) => expect(s).toMatch(/@gmail\.com$/)); + expect(suggestions).toContain( + "nguyen.minh.hoang+github@rivercrane.vn", + ); + suggestions.forEach((suggestion) => + expect(suggestion).toMatch(/@rivercrane\.vn$/), + ); }); }); From 511f1835a2fa2268847d06514ce312381f50d52b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 10:51:39 +0700 Subject: [PATCH 03/43] fix: ignore cross-account website aliases --- src/services/websiteAliasService.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/services/websiteAliasService.ts b/src/services/websiteAliasService.ts index ee039639..38bb3820 100644 --- a/src/services/websiteAliasService.ts +++ b/src/services/websiteAliasService.ts @@ -2,6 +2,7 @@ import { normalizeHostname } from "../utils/hostnameNormalizer"; import { generateAlias, getAccountStorageKey, + isAliasForAccount, } from "../../entrypoints/popup/utils"; interface WebsiteAliasEntry { @@ -65,7 +66,8 @@ export async function getPreviousAliasForWebsite( const map = await getWebsiteAliasMap(email); const entry = map[normalized]; - return entry ? { alias: entry.alias, timestamp: entry.timestamp } : null; + if (!entry || !isAliasForAccount(entry.alias, email)) return null; + return { alias: entry.alias, timestamp: entry.timestamp }; } /** @@ -115,6 +117,6 @@ export async function generateSuggestionsForWebsite( * Clear all website alias mappings for an email (used for reset/import). */ export async function clearWebsiteAliasMap(email: string): Promise { - const key = getWebsiteAliasMapKey(email); + const key = getWebsiteAliasMapKey(email,); await browser.storage.local.remove([key]); } From 8e4c8a469f078fbae76917821632a781cbbe61a1 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 10:53:44 +0700 Subject: [PATCH 04/43] chore: add one-shot Workspace history patch --- scripts/patch-workspace-inline-history.py | 100 ++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 scripts/patch-workspace-inline-history.py diff --git a/scripts/patch-workspace-inline-history.py b/scripts/patch-workspace-inline-history.py new file mode 100644 index 00000000..227a773e --- /dev/null +++ b/scripts/patch-workspace-inline-history.py @@ -0,0 +1,100 @@ +from pathlib import Path + + +def replace_once(path: str, old: str, new: str) -> None: + """Replace one expected source fragment and fail if the branch drifted.""" + file_path = Path(path) + content = file_path.read_text(encoding="utf-8") + count = content.count(old) + if count != 1: + raise RuntimeError(f"Expected exactly one match in {path}, found {count}") + file_path.write_text(content.replace(old, new, 1), encoding="utf-8") + + +replace_once( + "entrypoints/content/index.ts", + ''' filterAliases, + getAccountStorageKey, +''', + ''' filterAliases, + filterAliasesForAccount, + getAccountStorageKey, +''', +) + +replace_once( + "entrypoints/content/index.ts", + ''' currentHistory = history + .filter((item) => item && typeof item.email === "string") + .slice() + .sort((a, b) => b.timestamp - a.timestamp); + const favorites = (storage[favoritesKey] ?? storage.favorites ?? []) as + | Array<{ email?: string } | string> + | undefined; + currentFavorites = Array.isArray(favorites) + ? favorites + .map((favorite) => + typeof favorite === "string" ? favorite : favorite.email, + ) + .filter((email): email is string => Boolean(email)) + : []; +''', + ''' const validHistory = history.filter( + (item) => item && typeof item.email === "string", + ); + currentHistory = filterAliasesForAccount( + validHistory, + data.activeEmail, + ) + .slice() + .sort((a, b) => b.timestamp - a.timestamp); + const favorites = (storage[favoritesKey] ?? storage.favorites ?? []) as + | Array<{ email?: string } | string> + | undefined; + const favoriteEntries = Array.isArray(favorites) + ? favorites + .map((favorite) => ({ + email: + typeof favorite === "string" ? favorite : favorite.email || "", + })) + .filter((favorite) => Boolean(favorite.email)) + : []; + currentFavorites = filterAliasesForAccount( + favoriteEntries, + data.activeEmail, + ).map((favorite) => favorite.email); +''', +) + +replace_once( + "src/services/websiteAliasService.ts", + "const key = getWebsiteAliasMapKey(email,);", + "const key = getWebsiteAliasMapKey(email);", +) + +replace_once( + "tests/services/websiteAliasService.test.ts", + ''' it("accepts both URL and hostname", async () => { +''', + ''' it("ignores a stale website alias owned by another account", async () => { + const workspaceEmail = "nguyen.minh.hoang@rivercrane.vn"; + mockStorageData[`${workspaceEmail}:website_alias_map`] = { + github: { + alias: "nguyen.minh.hoang+github@gmail.com", + timestamp: 12345, + generatedCount: 1, + }, + }; + + const result = await getPreviousAliasForWebsite(workspaceEmail, testUrl); + expect(result).toBeNull(); + }); + + it("accepts both URL and hostname", async () => { +''', +) + +if "filterAliasesForAccount" not in Path("entrypoints/content/index.ts").read_text( + encoding="utf-8" +): + raise RuntimeError("Inline history account filter was not added") From 8f83b4c31d4b7ac390380ee222bc76e37222a241 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 10:54:10 +0700 Subject: [PATCH 05/43] chore: add one-shot Workspace fix workflow --- .../apply-workspace-inline-history-fix.yml | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/apply-workspace-inline-history-fix.yml diff --git a/.github/workflows/apply-workspace-inline-history-fix.yml b/.github/workflows/apply-workspace-inline-history-fix.yml new file mode 100644 index 00000000..eb00d054 --- /dev/null +++ b/.github/workflows/apply-workspace-inline-history-fix.yml @@ -0,0 +1,55 @@ +name: Apply Workspace inline history fix + +on: + pull_request: + types: [reopened] + +permissions: + contents: write + +jobs: + patch: + if: github.event.pull_request.number == 87 && github.head_ref == 'fix/preserve-workspace-alias-domain' + runs-on: ubuntu-latest + steps: + - name: Checkout feature branch + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24.x + cache: yarn + cache-dependency-path: yarn.lock + + - name: Apply account-isolation patch + run: python3 scripts/patch-workspace-inline-history.py + + - name: Validate patched source + shell: bash + run: | + set -euo pipefail + corepack enable + yarn install --immutable --mode=skip-build + yarn exec wxt prepare + yarn compile --pretty false + yarn test + + - name: Commit fix and remove temporary files + shell: bash + run: | + set -euo pipefail + git rm \ + .github/workflows/apply-workspace-inline-history-fix.yml \ + scripts/patch-workspace-inline-history.py + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add \ + entrypoints/content/index.ts \ + src/services/websiteAliasService.ts \ + tests/services/websiteAliasService.test.ts + git commit -m "fix: isolate Workspace inline aliases" + git push origin HEAD:${{ github.head_ref }} From 8abcb6dd4f48fee59e8a3e8bed9c6feb883c74bc Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 10:55:44 +0700 Subject: [PATCH 06/43] fix: enable one-shot Workspace patch workflow --- .github/workflows/apply-workspace-inline-history-fix.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/apply-workspace-inline-history-fix.yml b/.github/workflows/apply-workspace-inline-history-fix.yml index eb00d054..d6175ee7 100644 --- a/.github/workflows/apply-workspace-inline-history-fix.yml +++ b/.github/workflows/apply-workspace-inline-history-fix.yml @@ -22,8 +22,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: 24.x - cache: yarn - cache-dependency-path: yarn.lock - name: Apply account-isolation patch run: python3 scripts/patch-workspace-inline-history.py From 4c134063029437abd10dc1a81e1bb6a1852faa54 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:56:49 +0000 Subject: [PATCH 07/43] fix: isolate Workspace inline aliases --- .../apply-workspace-inline-history-fix.yml | 53 ---------- entrypoints/content/index.ts | 25 +++-- scripts/patch-workspace-inline-history.py | 100 ------------------ src/services/websiteAliasService.ts | 2 +- tests/services/websiteAliasService.test.ts | 14 +++ 5 files changed, 33 insertions(+), 161 deletions(-) delete mode 100644 .github/workflows/apply-workspace-inline-history-fix.yml delete mode 100644 scripts/patch-workspace-inline-history.py diff --git a/.github/workflows/apply-workspace-inline-history-fix.yml b/.github/workflows/apply-workspace-inline-history-fix.yml deleted file mode 100644 index d6175ee7..00000000 --- a/.github/workflows/apply-workspace-inline-history-fix.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Apply Workspace inline history fix - -on: - pull_request: - types: [reopened] - -permissions: - contents: write - -jobs: - patch: - if: github.event.pull_request.number == 87 && github.head_ref == 'fix/preserve-workspace-alias-domain' - runs-on: ubuntu-latest - steps: - - name: Checkout feature branch - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 24.x - - - name: Apply account-isolation patch - run: python3 scripts/patch-workspace-inline-history.py - - - name: Validate patched source - shell: bash - run: | - set -euo pipefail - corepack enable - yarn install --immutable --mode=skip-build - yarn exec wxt prepare - yarn compile --pretty false - yarn test - - - name: Commit fix and remove temporary files - shell: bash - run: | - set -euo pipefail - git rm \ - .github/workflows/apply-workspace-inline-history-fix.yml \ - scripts/patch-workspace-inline-history.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add \ - entrypoints/content/index.ts \ - src/services/websiteAliasService.ts \ - tests/services/websiteAliasService.test.ts - git commit -m "fix: isolate Workspace inline aliases" - git push origin HEAD:${{ github.head_ref }} diff --git a/entrypoints/content/index.ts b/entrypoints/content/index.ts index 7e82a788..65667260 100644 --- a/entrypoints/content/index.ts +++ b/entrypoints/content/index.ts @@ -15,6 +15,7 @@ import { generateAlias, generateRandomString, filterAliases, + filterAliasesForAccount, getAccountStorageKey, getAliasTags, getDotVariationCandidates, @@ -730,20 +731,30 @@ function createPopup( email: string; timestamp: number; }>; - currentHistory = history - .filter((item) => item && typeof item.email === "string") + const validHistory = history.filter( + (item) => item && typeof item.email === "string", + ); + currentHistory = filterAliasesForAccount( + validHistory, + data.activeEmail, + ) .slice() .sort((a, b) => b.timestamp - a.timestamp); const favorites = (storage[favoritesKey] ?? storage.favorites ?? []) as | Array<{ email?: string } | string> | undefined; - currentFavorites = Array.isArray(favorites) + const favoriteEntries = Array.isArray(favorites) ? favorites - .map((favorite) => - typeof favorite === "string" ? favorite : favorite.email, - ) - .filter((email): email is string => Boolean(email)) + .map((favorite) => ({ + email: + typeof favorite === "string" ? favorite : favorite.email || "", + })) + .filter((favorite) => Boolean(favorite.email)) : []; + currentFavorites = filterAliasesForAccount( + favoriteEntries, + data.activeEmail, + ).map((favorite) => favorite.email); if (historyTag) { const selectedTag = historyTag.value; diff --git a/scripts/patch-workspace-inline-history.py b/scripts/patch-workspace-inline-history.py deleted file mode 100644 index 227a773e..00000000 --- a/scripts/patch-workspace-inline-history.py +++ /dev/null @@ -1,100 +0,0 @@ -from pathlib import Path - - -def replace_once(path: str, old: str, new: str) -> None: - """Replace one expected source fragment and fail if the branch drifted.""" - file_path = Path(path) - content = file_path.read_text(encoding="utf-8") - count = content.count(old) - if count != 1: - raise RuntimeError(f"Expected exactly one match in {path}, found {count}") - file_path.write_text(content.replace(old, new, 1), encoding="utf-8") - - -replace_once( - "entrypoints/content/index.ts", - ''' filterAliases, - getAccountStorageKey, -''', - ''' filterAliases, - filterAliasesForAccount, - getAccountStorageKey, -''', -) - -replace_once( - "entrypoints/content/index.ts", - ''' currentHistory = history - .filter((item) => item && typeof item.email === "string") - .slice() - .sort((a, b) => b.timestamp - a.timestamp); - const favorites = (storage[favoritesKey] ?? storage.favorites ?? []) as - | Array<{ email?: string } | string> - | undefined; - currentFavorites = Array.isArray(favorites) - ? favorites - .map((favorite) => - typeof favorite === "string" ? favorite : favorite.email, - ) - .filter((email): email is string => Boolean(email)) - : []; -''', - ''' const validHistory = history.filter( - (item) => item && typeof item.email === "string", - ); - currentHistory = filterAliasesForAccount( - validHistory, - data.activeEmail, - ) - .slice() - .sort((a, b) => b.timestamp - a.timestamp); - const favorites = (storage[favoritesKey] ?? storage.favorites ?? []) as - | Array<{ email?: string } | string> - | undefined; - const favoriteEntries = Array.isArray(favorites) - ? favorites - .map((favorite) => ({ - email: - typeof favorite === "string" ? favorite : favorite.email || "", - })) - .filter((favorite) => Boolean(favorite.email)) - : []; - currentFavorites = filterAliasesForAccount( - favoriteEntries, - data.activeEmail, - ).map((favorite) => favorite.email); -''', -) - -replace_once( - "src/services/websiteAliasService.ts", - "const key = getWebsiteAliasMapKey(email,);", - "const key = getWebsiteAliasMapKey(email);", -) - -replace_once( - "tests/services/websiteAliasService.test.ts", - ''' it("accepts both URL and hostname", async () => { -''', - ''' it("ignores a stale website alias owned by another account", async () => { - const workspaceEmail = "nguyen.minh.hoang@rivercrane.vn"; - mockStorageData[`${workspaceEmail}:website_alias_map`] = { - github: { - alias: "nguyen.minh.hoang+github@gmail.com", - timestamp: 12345, - generatedCount: 1, - }, - }; - - const result = await getPreviousAliasForWebsite(workspaceEmail, testUrl); - expect(result).toBeNull(); - }); - - it("accepts both URL and hostname", async () => { -''', -) - -if "filterAliasesForAccount" not in Path("entrypoints/content/index.ts").read_text( - encoding="utf-8" -): - raise RuntimeError("Inline history account filter was not added") diff --git a/src/services/websiteAliasService.ts b/src/services/websiteAliasService.ts index 38bb3820..c6ffd0df 100644 --- a/src/services/websiteAliasService.ts +++ b/src/services/websiteAliasService.ts @@ -117,6 +117,6 @@ export async function generateSuggestionsForWebsite( * Clear all website alias mappings for an email (used for reset/import). */ export async function clearWebsiteAliasMap(email: string): Promise { - const key = getWebsiteAliasMapKey(email,); + const key = getWebsiteAliasMapKey(email); await browser.storage.local.remove([key]); } diff --git a/tests/services/websiteAliasService.test.ts b/tests/services/websiteAliasService.test.ts index 4d25bc00..5d37cfef 100644 --- a/tests/services/websiteAliasService.test.ts +++ b/tests/services/websiteAliasService.test.ts @@ -153,6 +153,20 @@ describe("websiteAliasService", () => { expect(result).toEqual({ alias: testAlias, timestamp }); }); + it("ignores a stale website alias owned by another account", async () => { + const workspaceEmail = "nguyen.minh.hoang@rivercrane.vn"; + mockStorageData[`${workspaceEmail}:website_alias_map`] = { + github: { + alias: "nguyen.minh.hoang+github@gmail.com", + timestamp: 12345, + generatedCount: 1, + }, + }; + + const result = await getPreviousAliasForWebsite(workspaceEmail, testUrl); + expect(result).toBeNull(); + }); + it("accepts both URL and hostname", async () => { // Save alias with normalized key "github" await saveWebsiteAlias(testEmail, "github", testAlias); From 2efd2755a68a7e2eeca0228013dafebf54a457d3 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 11:00:25 +0700 Subject: [PATCH 08/43] fix: validate base email before generating suggestions --- src/services/websiteAliasService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/services/websiteAliasService.ts b/src/services/websiteAliasService.ts index c6ffd0df..b66c70ef 100644 --- a/src/services/websiteAliasService.ts +++ b/src/services/websiteAliasService.ts @@ -3,6 +3,7 @@ import { generateAlias, getAccountStorageKey, isAliasForAccount, + validateEmail, } from "../../entrypoints/popup/utils"; interface WebsiteAliasEntry { @@ -79,7 +80,7 @@ export async function generateSuggestionsForWebsite( urlOrHostname: string, ): Promise { const normalized = normalizeHostname(urlOrHostname); - if (!normalized || !generateAlias(baseEmail, normalized)) return []; + if (!normalized || !validateEmail(baseEmail).isValid) return []; const suggestions: string[] = []; From 33fa0a436543dd7483bc3c4a037eb41e8628a729 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 11:02:11 +0700 Subject: [PATCH 09/43] test: harden Workspace alias regression coverage --- tests/services/websiteAliasService.test.ts | 111 ++++++++++++++------- 1 file changed, 73 insertions(+), 38 deletions(-) diff --git a/tests/services/websiteAliasService.test.ts b/tests/services/websiteAliasService.test.ts index 5d37cfef..6e8c0791 100644 --- a/tests/services/websiteAliasService.test.ts +++ b/tests/services/websiteAliasService.test.ts @@ -20,6 +20,8 @@ type MockBrowser = { }; }; +type ImportOriginal = () => Promise; + vi.stubGlobal("browser", { storage: { local: { @@ -47,17 +49,24 @@ vi.stubGlobal("browser", { }, } as unknown as MockBrowser); -// Mock the getAccountStorageKey utility -vi.mock("../../entrypoints/popup/utils", async (importOriginal) => { +/** Builds deterministic account-scoped storage keys for service tests. */ +function getMockAccountStorageKey(email: string, suffix: string): string { + return `${email}:${suffix}`; +} + +/** Keeps real alias utilities while replacing account storage key generation. */ +async function createPopupUtilsMock(importOriginal: ImportOriginal) { const original = await importOriginal< typeof import("../../entrypoints/popup/utils") >(); return { ...original, - getAccountStorageKey: (email: string, suffix: string) => `${email}:${suffix}`, + getAccountStorageKey: getMockAccountStorageKey, }; -}); +} + +vi.mock("../../entrypoints/popup/utils", createPopupUtilsMock); describe("websiteAliasService", () => { const testEmail = "user@gmail.com"; @@ -138,6 +147,21 @@ describe("websiteAliasService", () => { }); describe("getPreviousAliasForWebsite", () => { + /** Verifies that a stored Gmail alias cannot leak into a Workspace account. */ + async function assertCrossAccountAliasIsIgnored(): Promise { + const workspaceEmail = "nguyen.minh.hoang@rivercrane.vn"; + mockStorageData[`${workspaceEmail}:website_alias_map`] = { + github: { + alias: "nguyen.minh.hoang+github@gmail.com", + timestamp: 12345, + generatedCount: 1, + }, + }; + + const result = await getPreviousAliasForWebsite(workspaceEmail, testUrl); + expect(result).toBeNull(); + } + it("returns null for unmapped website", async () => { const result = await getPreviousAliasForWebsite(testEmail, "unknown.com"); expect(result).toBeNull(); @@ -153,19 +177,10 @@ describe("websiteAliasService", () => { expect(result).toEqual({ alias: testAlias, timestamp }); }); - it("ignores a stale website alias owned by another account", async () => { - const workspaceEmail = "nguyen.minh.hoang@rivercrane.vn"; - mockStorageData[`${workspaceEmail}:website_alias_map`] = { - github: { - alias: "nguyen.minh.hoang+github@gmail.com", - timestamp: 12345, - generatedCount: 1, - }, - }; - - const result = await getPreviousAliasForWebsite(workspaceEmail, testUrl); - expect(result).toBeNull(); - }); + it( + "ignores a stale website alias owned by another account", + assertCrossAccountAliasIsIgnored, + ); it("accepts both URL and hostname", async () => { // Save alias with normalized key "github" @@ -196,6 +211,39 @@ describe("websiteAliasService", () => { }); describe("generateSuggestionsForWebsite", () => { + /** Verifies that malformed base emails never produce inline suggestions. */ + async function assertMalformedBaseEmailsAreRejected(): Promise { + const invalidEmails = [ + "invalid-email", + "user@localhost", + "user @gmail.com", + ]; + + for (const invalidEmail of invalidEmails) { + const suggestions = await generateSuggestionsForWebsite( + invalidEmail, + "https://github.com", + ); + expect(suggestions, invalidEmail).toEqual([]); + } + } + + /** Verifies that all generated suggestions retain the Workspace domain. */ + async function assertWorkspaceDomainIsPreserved(): Promise { + const workspaceEmail = "nguyen.minh.hoang@rivercrane.vn"; + const suggestions = await generateSuggestionsForWebsite( + workspaceEmail, + "https://github.com", + ); + + expect(suggestions).toContain( + "nguyen.minh.hoang+github@rivercrane.vn", + ); + for (const suggestion of suggestions) { + expect(suggestion).toMatch(/@rivercrane\.vn$/); + } + } + it("returns empty array for invalid hostname", async () => { const suggestions = await generateSuggestionsForWebsite( testEmail, @@ -204,13 +252,10 @@ describe("websiteAliasService", () => { expect(suggestions).toEqual([]); }); - it("returns empty array for malformed base email", async () => { - const suggestions = await generateSuggestionsForWebsite( - "invalid-email", - "https://github.com", - ); - expect(suggestions).toEqual([]); - }); + it( + "returns empty array for malformed base email", + assertMalformedBaseEmailsAreRejected, + ); it("generates up to 5 suggestions for new website", async () => { const suggestions = await generateSuggestionsForWebsite( @@ -287,20 +332,10 @@ describe("websiteAliasService", () => { expect(suggestions.length).toBeLessThanOrEqual(5); }); - it("preserves the selected Google Workspace domain", async () => { - const workspaceEmail = "nguyen.minh.hoang@rivercrane.vn"; - const suggestions = await generateSuggestionsForWebsite( - workspaceEmail, - "https://github.com", - ); - - expect(suggestions).toContain( - "nguyen.minh.hoang+github@rivercrane.vn", - ); - suggestions.forEach((suggestion) => - expect(suggestion).toMatch(/@rivercrane\.vn$/), - ); - }); + it( + "preserves the selected Google Workspace domain", + assertWorkspaceDomainIsPreserved, + ); }); describe("clearWebsiteAliasMap", () => { From d99587daa7ef1ab340c0e5c7a0e7be56a7b54eae Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 11:05:28 +0700 Subject: [PATCH 10/43] refactor: isolate inline account history loading --- src/services/inlineHistoryService.ts | 104 +++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/services/inlineHistoryService.ts diff --git a/src/services/inlineHistoryService.ts b/src/services/inlineHistoryService.ts new file mode 100644 index 00000000..cd00f5cb --- /dev/null +++ b/src/services/inlineHistoryService.ts @@ -0,0 +1,104 @@ +import { + filterAliasesForAccount, + getAccountStorageKey, +} from "../../entrypoints/popup/utils"; + +export interface InlineHistoryAlias { + email: string; + timestamp: number; +} + +export interface InlineAccountHistory { + history: InlineHistoryAlias[]; + favorites: string[]; +} + +interface FavoriteAlias { + email: string; +} + +/** Returns whether a stored value is a usable inline history entry. */ +function isInlineHistoryAlias(value: unknown): value is InlineHistoryAlias { + if (!value || typeof value !== "object") return false; + const candidate = value as { email?: unknown; timestamp?: unknown }; + return ( + typeof candidate.email === "string" && + typeof candidate.timestamp === "number" + ); +} + +/** Normalizes legacy string and object favorite values into one shape. */ +function normalizeFavoriteAlias(value: unknown): FavoriteAlias | null { + if (typeof value === "string" && value) return { email: value }; + if (!value || typeof value !== "object") return null; + + const email = (value as { email?: unknown }).email; + return typeof email === "string" && email ? { email } : null; +} + +/** Sorts history entries from newest to oldest. */ +function sortHistoryNewestFirst( + first: InlineHistoryAlias, + second: InlineHistoryAlias, +): number { + return second.timestamp - first.timestamp; +} + +/** Extracts valid history entries from account-scoped or legacy storage. */ +function parseHistory(value: unknown): InlineHistoryAlias[] { + if (!Array.isArray(value)) return []; + + const history: InlineHistoryAlias[] = []; + for (const entry of value) { + if (isInlineHistoryAlias(entry)) history.push(entry); + } + return history; +} + +/** Extracts valid favorite aliases from legacy string and object formats. */ +function parseFavorites(value: unknown): FavoriteAlias[] { + if (!Array.isArray(value)) return []; + + const favorites: FavoriteAlias[] = []; + for (const entry of value) { + const favorite = normalizeFavoriteAlias(entry); + if (favorite) favorites.push(favorite); + } + return favorites; +} + +/** Returns the first available account-scoped value, then its legacy fallback. */ +function selectStoredValue( + storage: Record, + accountKey: string, + legacyKey: string, +): unknown { + return storage[accountKey] ?? storage[legacyKey] ?? []; +} + +/** Loads account-isolated history and favorites for the inline helper popup. */ +export async function loadInlineAccountHistory( + activeEmail: string, +): Promise { + const historyKey = getAccountStorageKey(activeEmail, "gmail_alias_recent"); + const favoritesKey = getAccountStorageKey(activeEmail, "favorites"); + const storage = (await browser.storage.local.get([ + historyKey, + favoritesKey, + "gmail_alias_recent", + "favorites", + ])) as Record; + + const history = filterAliasesForAccount( + parseHistory(selectStoredValue(storage, historyKey, "gmail_alias_recent")), + activeEmail, + ).sort(sortHistoryNewestFirst); + const favoriteAliases = filterAliasesForAccount( + parseFavorites(selectStoredValue(storage, favoritesKey, "favorites")), + activeEmail, + ); + const favorites: string[] = []; + for (const favorite of favoriteAliases) favorites.push(favorite.email); + + return { history, favorites }; +} From 7458d6512869dada5c8d3326f449fc62f1f41a07 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 11:06:07 +0700 Subject: [PATCH 11/43] test: cover inline Workspace history isolation --- tests/services/inlineHistoryService.test.ts | 131 ++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 tests/services/inlineHistoryService.test.ts diff --git a/tests/services/inlineHistoryService.test.ts b/tests/services/inlineHistoryService.test.ts new file mode 100644 index 00000000..1fa9d576 --- /dev/null +++ b/tests/services/inlineHistoryService.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getAccountStorageKey } from "../../entrypoints/popup/utils"; +import { loadInlineAccountHistory } from "../../src/services/inlineHistoryService"; + +const mockStorageData: Record = {}; + +/** Returns the requested keys from the in-memory browser storage fixture. */ +function mockStorageGet(keys: string[]): Promise> { + const result: Record = {}; + for (const key of keys) { + if (key in mockStorageData) result[key] = mockStorageData[key]; + } + return Promise.resolve(result); +} + +vi.stubGlobal("browser", { + storage: { + local: { + get: vi.fn(mockStorageGet), + }, + }, +}); + +/** Clears browser storage fixtures before each service test. */ +function resetMockStorage(): void { + for (const key of Object.keys(mockStorageData)) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete mockStorageData[key]; + } + vi.clearAllMocks(); +} + +/** Verifies mixed account-scoped entries render only for the active Workspace account. */ +async function assertScopedWorkspaceHistoryIsIsolated(): Promise { + const activeEmail = "nguyen.minh.hoang@rivercrane.vn"; + const historyKey = getAccountStorageKey(activeEmail, "gmail_alias_recent"); + const favoritesKey = getAccountStorageKey(activeEmail, "favorites"); + mockStorageData[historyKey] = [ + { + email: "nguyen.minh.hoang+older@rivercrane.vn", + timestamp: 10, + }, + { email: "nguyen.minh.hoang+gmail@gmail.com", timestamp: 30 }, + { + email: "nguyen.minh.hoang+newer@rivercrane.vn", + timestamp: 20, + }, + ]; + mockStorageData[favoritesKey] = [ + "nguyen.minh.hoang+newer@rivercrane.vn", + { email: "nguyen.minh.hoang+gmail@gmail.com" }, + ]; + + const result = await loadInlineAccountHistory(activeEmail); + + expect(result.history).toEqual([ + { + email: "nguyen.minh.hoang+newer@rivercrane.vn", + timestamp: 20, + }, + { + email: "nguyen.minh.hoang+older@rivercrane.vn", + timestamp: 10, + }, + ]); + expect(result.favorites).toEqual([ + "nguyen.minh.hoang+newer@rivercrane.vn", + ]); +} + +/** Verifies mixed legacy storage is filtered before the inline popup consumes it. */ +async function assertLegacyWorkspaceHistoryIsIsolated(): Promise { + const activeEmail = "nguyen.minh.hoang@rivercrane.vn"; + mockStorageData.gmail_alias_recent = [ + { email: "nguyen.minh.hoang+webike@rivercrane.vn", timestamp: 2 }, + { email: "nguyen.minh.hoang+webike@gmail.com", timestamp: 3 }, + { email: "not-an-email", timestamp: 4 }, + ]; + mockStorageData.favorites = [ + { email: "nguyen.minh.hoang+webike@rivercrane.vn" }, + "nguyen.minh.hoang+webike@gmail.com", + null, + ]; + + const result = await loadInlineAccountHistory(activeEmail); + + expect(result.history).toEqual([ + { email: "nguyen.minh.hoang+webike@rivercrane.vn", timestamp: 2 }, + ]); + expect(result.favorites).toEqual([ + "nguyen.minh.hoang+webike@rivercrane.vn", + ]); +} + +/** Verifies account-scoped values take precedence over obsolete global values. */ +async function assertScopedStorageTakesPrecedence(): Promise { + const activeEmail = "nguyen.minh.hoang@rivercrane.vn"; + const historyKey = getAccountStorageKey(activeEmail, "gmail_alias_recent"); + const favoritesKey = getAccountStorageKey(activeEmail, "favorites"); + mockStorageData[historyKey] = []; + mockStorageData[favoritesKey] = []; + mockStorageData.gmail_alias_recent = [ + { email: "nguyen.minh.hoang+legacy@rivercrane.vn", timestamp: 1 }, + ]; + mockStorageData.favorites = [ + "nguyen.minh.hoang+legacy@rivercrane.vn", + ]; + + const result = await loadInlineAccountHistory(activeEmail); + + expect(result).toEqual({ history: [], favorites: [] }); +} + +/** Registers browser-storage integration coverage for inline account history. */ +function defineInlineHistoryServiceTests(): void { + beforeEach(resetMockStorage); + it( + "filters account-scoped history and favorites by active Workspace account", + assertScopedWorkspaceHistoryIsIsolated, + ); + it( + "filters legacy history and favorites by active Workspace account", + assertLegacyWorkspaceHistoryIsIsolated, + ); + it( + "prefers account-scoped storage over legacy global storage", + assertScopedStorageTakesPrecedence, + ); +} + +describe("loadInlineAccountHistory", defineInlineHistoryServiceTests); From 3a47e42140ae10d4a614222450ec49e5a0623a60 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 11:06:35 +0700 Subject: [PATCH 12/43] chore: add one-shot inline history service patch --- scripts/patch-inline-history-service.py | 99 +++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 scripts/patch-inline-history-service.py diff --git a/scripts/patch-inline-history-service.py b/scripts/patch-inline-history-service.py new file mode 100644 index 00000000..d02ca1ec --- /dev/null +++ b/scripts/patch-inline-history-service.py @@ -0,0 +1,99 @@ +from pathlib import Path + + +def replace_once(path: str, old: str, new: str) -> None: + """Replace one expected source fragment and fail when the branch has drifted.""" + file_path = Path(path) + content = file_path.read_text(encoding="utf-8") + count = content.count(old) + if count != 1: + raise RuntimeError(f"Expected exactly one match in {path}, found {count}") + file_path.write_text(content.replace(old, new, 1), encoding="utf-8") + + +replace_once( + "entrypoints/content/index.ts", + '''import { + getPreviousAliasForWebsite, + generateSuggestionsForWebsite, + saveWebsiteAlias, +} from "src/services/websiteAliasService"; +''', + '''import { loadInlineAccountHistory } from "src/services/inlineHistoryService"; +import { + getPreviousAliasForWebsite, + generateSuggestionsForWebsite, + saveWebsiteAlias, +} from "src/services/websiteAliasService"; +''', +) + +replace_once( + "entrypoints/content/index.ts", + ''' filterAliases, + filterAliasesForAccount, + getAccountStorageKey, +''', + ''' filterAliases, + getAccountStorageKey, +''', +) + +replace_once( + "entrypoints/content/index.ts", + ''' // Use the same account that was used to generate this popup's aliases. + const historyKey = getAccountStorageKey( + data.activeEmail, + "gmail_alias_recent", + ); + const favoritesKey = getAccountStorageKey(data.activeEmail, "favorites"); + + const storage = (await browser.storage.local.get([ + historyKey, + favoritesKey, + "gmail_alias_recent", + "favorites", + ])) as Record; + // Older installations may still have history under the global key. + const history = (storage[historyKey] ?? + storage.gmail_alias_recent ?? + []) as Array<{ + email: string; + timestamp: number; + }>; + const validHistory = history.filter( + (item) => item && typeof item.email === "string", + ); + currentHistory = filterAliasesForAccount( + validHistory, + data.activeEmail, + ) + .slice() + .sort((a, b) => b.timestamp - a.timestamp); + const favorites = (storage[favoritesKey] ?? storage.favorites ?? []) as + | Array<{ email?: string } | string> + | undefined; + const favoriteEntries = Array.isArray(favorites) + ? favorites + .map((favorite) => ({ + email: + typeof favorite === "string" ? favorite : favorite.email || "", + })) + .filter((favorite) => Boolean(favorite.email)) + : []; + currentFavorites = filterAliasesForAccount( + favoriteEntries, + data.activeEmail, + ).map((favorite) => favorite.email); +''', + ''' const accountHistory = await loadInlineAccountHistory(data.activeEmail); + currentHistory = accountHistory.history; + currentFavorites = accountHistory.favorites; +''', +) + +content = Path("entrypoints/content/index.ts").read_text(encoding="utf-8") +if "filterAliasesForAccount" in content: + raise RuntimeError("Stale inline account-filter import remains") +if "loadInlineAccountHistory(data.activeEmail)" not in content: + raise RuntimeError("Inline history service was not connected") From a031d5b20cd693a0791155ec8f2ac2d7af0c85ed Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 11:06:54 +0700 Subject: [PATCH 13/43] chore: add one-shot inline history service workflow --- .../apply-inline-history-service.yml | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/apply-inline-history-service.yml diff --git a/.github/workflows/apply-inline-history-service.yml b/.github/workflows/apply-inline-history-service.yml new file mode 100644 index 00000000..438f2b58 --- /dev/null +++ b/.github/workflows/apply-inline-history-service.yml @@ -0,0 +1,50 @@ +name: Apply inline history service + +on: + pull_request: + types: [reopened] + +permissions: + contents: write + +jobs: + patch: + if: github.event.pull_request.number == 87 && github.head_ref == 'fix/preserve-workspace-alias-domain' + runs-on: ubuntu-latest + steps: + - name: Checkout feature branch + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24.x + + - name: Apply inline history service patch + run: python3 scripts/patch-inline-history-service.py + + - name: Validate patched source + shell: bash + run: | + set -euo pipefail + corepack enable + yarn install --immutable --mode=skip-build + yarn exec wxt prepare + yarn compile --pretty false + yarn test + + - name: Commit patch and remove temporary files + shell: bash + run: | + set -euo pipefail + git rm \ + .github/workflows/apply-inline-history-service.yml \ + scripts/patch-inline-history-service.py + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add entrypoints/content/index.ts + git commit -m "refactor: use inline history service" + git push origin HEAD:${{ github.head_ref }} From 301e063d1c63e9b039026cc3837073ee7df69370 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:07:59 +0000 Subject: [PATCH 14/43] refactor: use inline history service --- .../apply-inline-history-service.yml | 50 ---------- entrypoints/content/index.ts | 49 +-------- scripts/patch-inline-history-service.py | 99 ------------------- 3 files changed, 4 insertions(+), 194 deletions(-) delete mode 100644 .github/workflows/apply-inline-history-service.yml delete mode 100644 scripts/patch-inline-history-service.py diff --git a/.github/workflows/apply-inline-history-service.yml b/.github/workflows/apply-inline-history-service.yml deleted file mode 100644 index 438f2b58..00000000 --- a/.github/workflows/apply-inline-history-service.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Apply inline history service - -on: - pull_request: - types: [reopened] - -permissions: - contents: write - -jobs: - patch: - if: github.event.pull_request.number == 87 && github.head_ref == 'fix/preserve-workspace-alias-domain' - runs-on: ubuntu-latest - steps: - - name: Checkout feature branch - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 24.x - - - name: Apply inline history service patch - run: python3 scripts/patch-inline-history-service.py - - - name: Validate patched source - shell: bash - run: | - set -euo pipefail - corepack enable - yarn install --immutable --mode=skip-build - yarn exec wxt prepare - yarn compile --pretty false - yarn test - - - name: Commit patch and remove temporary files - shell: bash - run: | - set -euo pipefail - git rm \ - .github/workflows/apply-inline-history-service.yml \ - scripts/patch-inline-history-service.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add entrypoints/content/index.ts - git commit -m "refactor: use inline history service" - git push origin HEAD:${{ github.head_ref }} diff --git a/entrypoints/content/index.ts b/entrypoints/content/index.ts index 65667260..2c98ea2b 100644 --- a/entrypoints/content/index.ts +++ b/entrypoints/content/index.ts @@ -6,6 +6,7 @@ import { } from "src/utils/inlineSiteSettings"; import { t } from "../../lib/i18n"; import { USER_GUIDE_URL } from "src/utils/externalLinks"; +import { loadInlineAccountHistory } from "src/services/inlineHistoryService"; import { getPreviousAliasForWebsite, generateSuggestionsForWebsite, @@ -15,7 +16,6 @@ import { generateAlias, generateRandomString, filterAliases, - filterAliasesForAccount, getAccountStorageKey, getAliasTags, getDotVariationCandidates, @@ -711,50 +711,9 @@ function createPopup( if (!historyList) return; try { - // Use the same account that was used to generate this popup's aliases. - const historyKey = getAccountStorageKey( - data.activeEmail, - "gmail_alias_recent", - ); - const favoritesKey = getAccountStorageKey(data.activeEmail, "favorites"); - - const storage = (await browser.storage.local.get([ - historyKey, - favoritesKey, - "gmail_alias_recent", - "favorites", - ])) as Record; - // Older installations may still have history under the global key. - const history = (storage[historyKey] ?? - storage.gmail_alias_recent ?? - []) as Array<{ - email: string; - timestamp: number; - }>; - const validHistory = history.filter( - (item) => item && typeof item.email === "string", - ); - currentHistory = filterAliasesForAccount( - validHistory, - data.activeEmail, - ) - .slice() - .sort((a, b) => b.timestamp - a.timestamp); - const favorites = (storage[favoritesKey] ?? storage.favorites ?? []) as - | Array<{ email?: string } | string> - | undefined; - const favoriteEntries = Array.isArray(favorites) - ? favorites - .map((favorite) => ({ - email: - typeof favorite === "string" ? favorite : favorite.email || "", - })) - .filter((favorite) => Boolean(favorite.email)) - : []; - currentFavorites = filterAliasesForAccount( - favoriteEntries, - data.activeEmail, - ).map((favorite) => favorite.email); + const accountHistory = await loadInlineAccountHistory(data.activeEmail); + currentHistory = accountHistory.history; + currentFavorites = accountHistory.favorites; if (historyTag) { const selectedTag = historyTag.value; diff --git a/scripts/patch-inline-history-service.py b/scripts/patch-inline-history-service.py deleted file mode 100644 index d02ca1ec..00000000 --- a/scripts/patch-inline-history-service.py +++ /dev/null @@ -1,99 +0,0 @@ -from pathlib import Path - - -def replace_once(path: str, old: str, new: str) -> None: - """Replace one expected source fragment and fail when the branch has drifted.""" - file_path = Path(path) - content = file_path.read_text(encoding="utf-8") - count = content.count(old) - if count != 1: - raise RuntimeError(f"Expected exactly one match in {path}, found {count}") - file_path.write_text(content.replace(old, new, 1), encoding="utf-8") - - -replace_once( - "entrypoints/content/index.ts", - '''import { - getPreviousAliasForWebsite, - generateSuggestionsForWebsite, - saveWebsiteAlias, -} from "src/services/websiteAliasService"; -''', - '''import { loadInlineAccountHistory } from "src/services/inlineHistoryService"; -import { - getPreviousAliasForWebsite, - generateSuggestionsForWebsite, - saveWebsiteAlias, -} from "src/services/websiteAliasService"; -''', -) - -replace_once( - "entrypoints/content/index.ts", - ''' filterAliases, - filterAliasesForAccount, - getAccountStorageKey, -''', - ''' filterAliases, - getAccountStorageKey, -''', -) - -replace_once( - "entrypoints/content/index.ts", - ''' // Use the same account that was used to generate this popup's aliases. - const historyKey = getAccountStorageKey( - data.activeEmail, - "gmail_alias_recent", - ); - const favoritesKey = getAccountStorageKey(data.activeEmail, "favorites"); - - const storage = (await browser.storage.local.get([ - historyKey, - favoritesKey, - "gmail_alias_recent", - "favorites", - ])) as Record; - // Older installations may still have history under the global key. - const history = (storage[historyKey] ?? - storage.gmail_alias_recent ?? - []) as Array<{ - email: string; - timestamp: number; - }>; - const validHistory = history.filter( - (item) => item && typeof item.email === "string", - ); - currentHistory = filterAliasesForAccount( - validHistory, - data.activeEmail, - ) - .slice() - .sort((a, b) => b.timestamp - a.timestamp); - const favorites = (storage[favoritesKey] ?? storage.favorites ?? []) as - | Array<{ email?: string } | string> - | undefined; - const favoriteEntries = Array.isArray(favorites) - ? favorites - .map((favorite) => ({ - email: - typeof favorite === "string" ? favorite : favorite.email || "", - })) - .filter((favorite) => Boolean(favorite.email)) - : []; - currentFavorites = filterAliasesForAccount( - favoriteEntries, - data.activeEmail, - ).map((favorite) => favorite.email); -''', - ''' const accountHistory = await loadInlineAccountHistory(data.activeEmail); - currentHistory = accountHistory.history; - currentFavorites = accountHistory.favorites; -''', -) - -content = Path("entrypoints/content/index.ts").read_text(encoding="utf-8") -if "filterAliasesForAccount" in content: - raise RuntimeError("Stale inline account-filter import remains") -if "loadInlineAccountHistory(data.activeEmail)" not in content: - raise RuntimeError("Inline history service was not connected") From c1248e586876da45c3941c8051273c2af0042bdb Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 11:12:20 +0700 Subject: [PATCH 15/43] test: cover malformed inline history storage --- tests/services/inlineHistoryService.test.ts | 52 +++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/services/inlineHistoryService.test.ts b/tests/services/inlineHistoryService.test.ts index 1fa9d576..73c91453 100644 --- a/tests/services/inlineHistoryService.test.ts +++ b/tests/services/inlineHistoryService.test.ts @@ -111,6 +111,53 @@ async function assertScopedStorageTakesPrecedence(): Promise { expect(result).toEqual({ history: [], favorites: [] }); } +/** Verifies non-array account storage is treated as empty data. */ +async function assertNonArrayStorageIsIgnored(): Promise { + const activeEmail = "nguyen.minh.hoang@rivercrane.vn"; + const historyKey = getAccountStorageKey(activeEmail, "gmail_alias_recent"); + const favoritesKey = getAccountStorageKey(activeEmail, "favorites"); + mockStorageData[historyKey] = { email: "not-an-array" }; + mockStorageData[favoritesKey] = "not-an-array"; + + const result = await loadInlineAccountHistory(activeEmail); + + expect(result).toEqual({ history: [], favorites: [] }); +} + +/** Verifies malformed entries are skipped while valid Workspace entries remain. */ +async function assertMalformedEntriesAreIgnored(): Promise { + const activeEmail = "nguyen.minh.hoang@rivercrane.vn"; + const historyKey = getAccountStorageKey(activeEmail, "gmail_alias_recent"); + const favoritesKey = getAccountStorageKey(activeEmail, "favorites"); + mockStorageData[historyKey] = [ + null, + { email: "nguyen.minh.hoang+missing-time@rivercrane.vn" }, + { + email: "nguyen.minh.hoang+bad-time@rivercrane.vn", + timestamp: "10", + }, + { email: 123, timestamp: 9 }, + { email: "nguyen.minh.hoang+valid@rivercrane.vn", timestamp: 8 }, + ]; + mockStorageData[favoritesKey] = [ + null, + "", + {}, + { email: "" }, + { email: 123 }, + { email: "nguyen.minh.hoang+valid@rivercrane.vn" }, + ]; + + const result = await loadInlineAccountHistory(activeEmail); + + expect(result.history).toEqual([ + { email: "nguyen.minh.hoang+valid@rivercrane.vn", timestamp: 8 }, + ]); + expect(result.favorites).toEqual([ + "nguyen.minh.hoang+valid@rivercrane.vn", + ]); +} + /** Registers browser-storage integration coverage for inline account history. */ function defineInlineHistoryServiceTests(): void { beforeEach(resetMockStorage); @@ -126,6 +173,11 @@ function defineInlineHistoryServiceTests(): void { "prefers account-scoped storage over legacy global storage", assertScopedStorageTakesPrecedence, ); + it("treats non-array storage as empty", assertNonArrayStorageIsIgnored); + it( + "ignores malformed history and favorite entries", + assertMalformedEntriesAreIgnored, + ); } describe("loadInlineAccountHistory", defineInlineHistoryServiceTests); From 5a817883f675d31c47c6090709cb0b275ceaf24e Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 11:12:45 +0700 Subject: [PATCH 16/43] chore: add one-shot mock return type patch --- scripts/patch-popup-utils-mock-return.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 scripts/patch-popup-utils-mock-return.py diff --git a/scripts/patch-popup-utils-mock-return.py b/scripts/patch-popup-utils-mock-return.py new file mode 100644 index 00000000..c1b7199b --- /dev/null +++ b/scripts/patch-popup-utils-mock-return.py @@ -0,0 +1,13 @@ +from pathlib import Path + +path = Path("tests/services/websiteAliasService.test.ts") +content = path.read_text(encoding="utf-8") +old = """async function createPopupUtilsMock(importOriginal: ImportOriginal) { +""" +new = """async function createPopupUtilsMock( + importOriginal: ImportOriginal, +): Promise { +""" +if content.count(old) != 1: + raise RuntimeError("Expected popup utility mock signature was not found exactly once") +path.write_text(content.replace(old, new, 1), encoding="utf-8") From aebca333e3c6b123b2803d52dade4f7d861480f5 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 11:13:01 +0700 Subject: [PATCH 17/43] chore: add one-shot mock return type workflow --- .../apply-popup-utils-mock-return.yml | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/apply-popup-utils-mock-return.yml diff --git a/.github/workflows/apply-popup-utils-mock-return.yml b/.github/workflows/apply-popup-utils-mock-return.yml new file mode 100644 index 00000000..f3ec3ead --- /dev/null +++ b/.github/workflows/apply-popup-utils-mock-return.yml @@ -0,0 +1,50 @@ +name: Apply popup utils mock return type + +on: + pull_request: + types: [reopened] + +permissions: + contents: write + +jobs: + patch: + if: github.event.pull_request.number == 87 && github.head_ref == 'fix/preserve-workspace-alias-domain' + runs-on: ubuntu-latest + steps: + - name: Checkout feature branch + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24.x + + - name: Apply explicit return type patch + run: python3 scripts/patch-popup-utils-mock-return.py + + - name: Validate patched source + shell: bash + run: | + set -euo pipefail + corepack enable + yarn install --immutable --mode=skip-build + yarn exec wxt prepare + yarn compile --pretty false + yarn test + + - name: Commit patch and remove temporary files + shell: bash + run: | + set -euo pipefail + git rm \ + .github/workflows/apply-popup-utils-mock-return.yml \ + scripts/patch-popup-utils-mock-return.py + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add tests/services/websiteAliasService.test.ts + git commit -m "test: type popup utility mock factory" + git push origin HEAD:${{ github.head_ref }} From b70a116ffb6f4afdd6e6e32c3b9e501def3f6bc8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:14:06 +0000 Subject: [PATCH 18/43] test: type popup utility mock factory --- .../apply-popup-utils-mock-return.yml | 50 ------------------- scripts/patch-popup-utils-mock-return.py | 13 ----- tests/services/websiteAliasService.test.ts | 4 +- 3 files changed, 3 insertions(+), 64 deletions(-) delete mode 100644 .github/workflows/apply-popup-utils-mock-return.yml delete mode 100644 scripts/patch-popup-utils-mock-return.py diff --git a/.github/workflows/apply-popup-utils-mock-return.yml b/.github/workflows/apply-popup-utils-mock-return.yml deleted file mode 100644 index f3ec3ead..00000000 --- a/.github/workflows/apply-popup-utils-mock-return.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Apply popup utils mock return type - -on: - pull_request: - types: [reopened] - -permissions: - contents: write - -jobs: - patch: - if: github.event.pull_request.number == 87 && github.head_ref == 'fix/preserve-workspace-alias-domain' - runs-on: ubuntu-latest - steps: - - name: Checkout feature branch - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 24.x - - - name: Apply explicit return type patch - run: python3 scripts/patch-popup-utils-mock-return.py - - - name: Validate patched source - shell: bash - run: | - set -euo pipefail - corepack enable - yarn install --immutable --mode=skip-build - yarn exec wxt prepare - yarn compile --pretty false - yarn test - - - name: Commit patch and remove temporary files - shell: bash - run: | - set -euo pipefail - git rm \ - .github/workflows/apply-popup-utils-mock-return.yml \ - scripts/patch-popup-utils-mock-return.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add tests/services/websiteAliasService.test.ts - git commit -m "test: type popup utility mock factory" - git push origin HEAD:${{ github.head_ref }} diff --git a/scripts/patch-popup-utils-mock-return.py b/scripts/patch-popup-utils-mock-return.py deleted file mode 100644 index c1b7199b..00000000 --- a/scripts/patch-popup-utils-mock-return.py +++ /dev/null @@ -1,13 +0,0 @@ -from pathlib import Path - -path = Path("tests/services/websiteAliasService.test.ts") -content = path.read_text(encoding="utf-8") -old = """async function createPopupUtilsMock(importOriginal: ImportOriginal) { -""" -new = """async function createPopupUtilsMock( - importOriginal: ImportOriginal, -): Promise { -""" -if content.count(old) != 1: - raise RuntimeError("Expected popup utility mock signature was not found exactly once") -path.write_text(content.replace(old, new, 1), encoding="utf-8") diff --git a/tests/services/websiteAliasService.test.ts b/tests/services/websiteAliasService.test.ts index 6e8c0791..17ffb239 100644 --- a/tests/services/websiteAliasService.test.ts +++ b/tests/services/websiteAliasService.test.ts @@ -55,7 +55,9 @@ function getMockAccountStorageKey(email: string, suffix: string): string { } /** Keeps real alias utilities while replacing account storage key generation. */ -async function createPopupUtilsMock(importOriginal: ImportOriginal) { +async function createPopupUtilsMock( + importOriginal: ImportOriginal, +): Promise { const original = await importOriginal< typeof import("../../entrypoints/popup/utils") >(); From 7c71b38ab895f39f7e108509ac8bcce3673ebbae Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 11:17:14 +0700 Subject: [PATCH 19/43] test: cover inline history storage failures --- tests/services/inlineHistoryService.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/services/inlineHistoryService.test.ts b/tests/services/inlineHistoryService.test.ts index 73c91453..2b70739e 100644 --- a/tests/services/inlineHistoryService.test.ts +++ b/tests/services/inlineHistoryService.test.ts @@ -13,10 +13,12 @@ function mockStorageGet(keys: string[]): Promise> { return Promise.resolve(result); } +const mockStorageGetFunction = vi.fn(mockStorageGet); + vi.stubGlobal("browser", { storage: { local: { - get: vi.fn(mockStorageGet), + get: mockStorageGetFunction, }, }, }); @@ -158,6 +160,17 @@ async function assertMalformedEntriesAreIgnored(): Promise { ]); } +/** Verifies browser storage failures propagate to the inline popup caller. */ +async function assertStorageFailurePropagates(): Promise { + mockStorageGetFunction.mockRejectedValueOnce( + new Error("Inline history storage unavailable"), + ); + + await expect( + loadInlineAccountHistory("nguyen.minh.hoang@rivercrane.vn"), + ).rejects.toThrow("Inline history storage unavailable"); +} + /** Registers browser-storage integration coverage for inline account history. */ function defineInlineHistoryServiceTests(): void { beforeEach(resetMockStorage); @@ -178,6 +191,7 @@ function defineInlineHistoryServiceTests(): void { "ignores malformed history and favorite entries", assertMalformedEntriesAreIgnored, ); + it("propagates browser storage failures", assertStorageFailurePropagates); } describe("loadInlineAccountHistory", defineInlineHistoryServiceTests); From 114434d6ddcb21e03e3b62139baaac6a882c0c82 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 21:25:14 +0700 Subject: [PATCH 20/43] release: bump version to 1.3.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 74a6f5f8..6ed73408 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gmail-alias-toolkit", "description": "Generate and manage Gmail aliases with plus addressing and presets", "private": true, - "version": "1.3.2", + "version": "1.3.3", "author": "dev@eplus.dev", "license": "MIT", "homepage_url": "https://eplus.dev", From 236c8887f49bb170c1c2503a6a77a4546142b4f7 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 21:26:41 +0700 Subject: [PATCH 21/43] chore: add one-shot v1.3.3 changelog script --- scripts/prepare-v1.3.3-changelog.py | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 scripts/prepare-v1.3.3-changelog.py diff --git a/scripts/prepare-v1.3.3-changelog.py b/scripts/prepare-v1.3.3-changelog.py new file mode 100644 index 00000000..d52c8947 --- /dev/null +++ b/scripts/prepare-v1.3.3-changelog.py @@ -0,0 +1,31 @@ +from pathlib import Path + +CHANGELOG_PATH = Path("CHANGELOG.md") +VERSION_HEADING = "## [1.3.3] - 2026-07-29" +INSERT_AFTER = ( + "and this project adheres to " + "[Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n" +) +RELEASE_SECTION = """ + +## [1.3.3] - 2026-07-29 + +### :bug: Bug Fixes + +- [`63c969f`](https://github.com/ePlus-DEV/gmail-alias-toolkit/commit/63c969f3cacc3c79e91ac7a76b4b8a46885601d5) - preserve Google Workspace domains when generating website aliases and prevent Gmail alias history or favorites from leaking into another active account *(commit by [@hoangsvit](https://github.com/hoangsvit))* +""" + + +def prepend_release_section() -> None: + """Insert the v1.3.3 release notes after the changelog introduction.""" + content = CHANGELOG_PATH.read_text(encoding="utf-8") + if VERSION_HEADING in content: + raise RuntimeError("CHANGELOG.md already contains version 1.3.3") + if content.count(INSERT_AFTER) != 1: + raise RuntimeError("Unable to locate the changelog introduction marker") + + updated = content.replace(INSERT_AFTER, INSERT_AFTER + RELEASE_SECTION, 1) + CHANGELOG_PATH.write_text(updated, encoding="utf-8") + + +prepend_release_section() From 4eceb5e10fd910451169fd04214e99c21182bbbe Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 21:27:08 +0700 Subject: [PATCH 22/43] chore: add one-shot v1.3.3 changelog workflow --- .github/workflows/apply-v1.3.3-changelog.yml | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/apply-v1.3.3-changelog.yml diff --git a/.github/workflows/apply-v1.3.3-changelog.yml b/.github/workflows/apply-v1.3.3-changelog.yml new file mode 100644 index 00000000..2ba2cf13 --- /dev/null +++ b/.github/workflows/apply-v1.3.3-changelog.yml @@ -0,0 +1,35 @@ +name: Apply v1.3.3 changelog + +on: + pull_request: + types: [opened] + +permissions: + contents: write + +jobs: + patch: + if: github.event.pull_request.base.ref == 'dev' && github.head_ref == 'release/v1.3.3-workspace-alias-fix' + runs-on: ubuntu-latest + steps: + - name: Checkout release branch + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Apply changelog section + run: python3 scripts/prepare-v1.3.3-changelog.py + + - name: Commit changelog and remove temporary files + shell: bash + run: | + set -euo pipefail + git rm \ + .github/workflows/apply-v1.3.3-changelog.yml \ + scripts/prepare-v1.3.3-changelog.py + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md + git commit -m "docs: add v1.3.3 changelog" + git push origin HEAD:${{ github.head_ref }} From 162daa6ae0a13d2565eda58c948adfceba20f61b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:27:44 +0000 Subject: [PATCH 23/43] docs: add v1.3.3 changelog --- .github/workflows/apply-v1.3.3-changelog.yml | 35 -------------------- CHANGELOG.md | 7 ++++ scripts/prepare-v1.3.3-changelog.py | 31 ----------------- 3 files changed, 7 insertions(+), 66 deletions(-) delete mode 100644 .github/workflows/apply-v1.3.3-changelog.yml delete mode 100644 scripts/prepare-v1.3.3-changelog.py diff --git a/.github/workflows/apply-v1.3.3-changelog.yml b/.github/workflows/apply-v1.3.3-changelog.yml deleted file mode 100644 index 2ba2cf13..00000000 --- a/.github/workflows/apply-v1.3.3-changelog.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Apply v1.3.3 changelog - -on: - pull_request: - types: [opened] - -permissions: - contents: write - -jobs: - patch: - if: github.event.pull_request.base.ref == 'dev' && github.head_ref == 'release/v1.3.3-workspace-alias-fix' - runs-on: ubuntu-latest - steps: - - name: Checkout release branch - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - - - name: Apply changelog section - run: python3 scripts/prepare-v1.3.3-changelog.py - - - name: Commit changelog and remove temporary files - shell: bash - run: | - set -euo pipefail - git rm \ - .github/workflows/apply-v1.3.3-changelog.yml \ - scripts/prepare-v1.3.3-changelog.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add CHANGELOG.md - git commit -m "docs: add v1.3.3 changelog" - git push origin HEAD:${{ github.head_ref }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 26ffc0aa..0f136bae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.3.3] - 2026-07-29 + +### :bug: Bug Fixes + +- [`63c969f`](https://github.com/ePlus-DEV/gmail-alias-toolkit/commit/63c969f3cacc3c79e91ac7a76b4b8a46885601d5) - preserve Google Workspace domains when generating website aliases and prevent Gmail alias history or favorites from leaking into another active account *(commit by [@hoangsvit](https://github.com/hoangsvit))* + ## [1.3.1] - 2026-07-20 ### :sparkles: New Features - [`7148619`](https://github.com/ePlus-DEV/gmail-alias-toolkit/commit/7148619a98c09eaaee68376d4cd021149bd70c8f) - **release**: add Edge and Opera zip scripts *(commit by [@hoangsvit](https://github.com/hoangsvit))* diff --git a/scripts/prepare-v1.3.3-changelog.py b/scripts/prepare-v1.3.3-changelog.py deleted file mode 100644 index d52c8947..00000000 --- a/scripts/prepare-v1.3.3-changelog.py +++ /dev/null @@ -1,31 +0,0 @@ -from pathlib import Path - -CHANGELOG_PATH = Path("CHANGELOG.md") -VERSION_HEADING = "## [1.3.3] - 2026-07-29" -INSERT_AFTER = ( - "and this project adheres to " - "[Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n" -) -RELEASE_SECTION = """ - -## [1.3.3] - 2026-07-29 - -### :bug: Bug Fixes - -- [`63c969f`](https://github.com/ePlus-DEV/gmail-alias-toolkit/commit/63c969f3cacc3c79e91ac7a76b4b8a46885601d5) - preserve Google Workspace domains when generating website aliases and prevent Gmail alias history or favorites from leaking into another active account *(commit by [@hoangsvit](https://github.com/hoangsvit))* -""" - - -def prepend_release_section() -> None: - """Insert the v1.3.3 release notes after the changelog introduction.""" - content = CHANGELOG_PATH.read_text(encoding="utf-8") - if VERSION_HEADING in content: - raise RuntimeError("CHANGELOG.md already contains version 1.3.3") - if content.count(INSERT_AFTER) != 1: - raise RuntimeError("Unable to locate the changelog introduction marker") - - updated = content.replace(INSERT_AFTER, INSERT_AFTER + RELEASE_SECTION, 1) - CHANGELOG_PATH.write_text(updated, encoding="utf-8") - - -prepend_release_section() From 31f2a227d9c22c2d76c817ca1ca741a180e18f9a Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 21:48:17 +0700 Subject: [PATCH 24/43] chore: add one-shot extension changelog patch --- scripts/patch-v1.3.3-extension-changelog.py | 35 +++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 scripts/patch-v1.3.3-extension-changelog.py diff --git a/scripts/patch-v1.3.3-extension-changelog.py b/scripts/patch-v1.3.3-extension-changelog.py new file mode 100644 index 00000000..0cbe520c --- /dev/null +++ b/scripts/patch-v1.3.3-extension-changelog.py @@ -0,0 +1,35 @@ +from pathlib import Path + +settings_path = Path("entrypoints/popup/components/Settings.tsx") +settings = settings_path.read_text(encoding="utf-8") + +if 'version: "1.3.3"' in settings: + raise RuntimeError("Settings changelog already contains version 1.3.3") + +marker = '''const CHANGELOG: ChangelogEntry[] = [ + { + version: "1.3.2", +''' +replacement = '''const CHANGELOG: ChangelogEntry[] = [ + { + version: "1.3.3", + date: "2026-07-29", + changes: [ + { + type: "Fixed", + items: [ + "Preserved the selected Google Workspace domain when generating website-aware aliases instead of forcing @gmail.com", + "Filtered previous aliases, recent history, and favorites by the active account to prevent Gmail data from appearing under a Workspace account", + "Rejected malformed base email addresses before generating inline alias suggestions", + ], + }, + ], + }, + { + version: "1.3.2", +''' + +if settings.count(marker) != 1: + raise RuntimeError("Expected Settings changelog marker was not found exactly once") + +settings_path.write_text(settings.replace(marker, replacement, 1), encoding="utf-8") From 608ca57cef4776717d62864e4e5a64b0c555e7af Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 21:48:43 +0700 Subject: [PATCH 25/43] chore: add one-shot extension changelog workflow --- .../apply-v1.3.3-extension-changelog.yml | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/workflows/apply-v1.3.3-extension-changelog.yml diff --git a/.github/workflows/apply-v1.3.3-extension-changelog.yml b/.github/workflows/apply-v1.3.3-extension-changelog.yml new file mode 100644 index 00000000..7ae96579 --- /dev/null +++ b/.github/workflows/apply-v1.3.3-extension-changelog.yml @@ -0,0 +1,59 @@ +name: Apply v1.3.3 extension changelog + +on: + pull_request: + types: [reopened] + +permissions: + contents: write + +jobs: + patch: + if: github.event.pull_request.number == 88 && github.head_ref == 'release/v1.3.3-workspace-alias-fix' + runs-on: ubuntu-latest + steps: + - name: Checkout release branch + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24.x + + - name: Restore repository changelog + shell: bash + run: | + set -euo pipefail + git fetch origin dev + git checkout origin/dev -- CHANGELOG.md + + - name: Update extension changelog + run: python3 scripts/patch-v1.3.3-extension-changelog.py + + - name: Validate patched source + shell: bash + run: | + set -euo pipefail + corepack enable + yarn install --immutable --mode=skip-build + yarn exec wxt prepare + yarn compile --pretty false + yarn test + + - name: Commit patch and remove temporary files + shell: bash + run: | + set -euo pipefail + git rm \ + .github/workflows/apply-v1.3.3-extension-changelog.yml \ + scripts/patch-v1.3.3-extension-changelog.py + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add \ + CHANGELOG.md \ + entrypoints/popup/components/Settings.tsx + git commit -m "release: add v1.3.3 extension changelog" + git push origin HEAD:${{ github.head_ref }} From 1e4c3e48634baa94eb630e43a98cf03ee8ef9185 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 21:49:15 +0700 Subject: [PATCH 26/43] chore: retain v1.3.3 release version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6ed73408..19202bcd 100644 --- a/package.json +++ b/package.json @@ -61,4 +61,4 @@ "vitest": "^4.1.9", "wxt": "^0.20.27" } -} +} \ No newline at end of file From 9545d59ffefe6b5550ad4aa05fa5777eb73f320e Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 21:49:51 +0700 Subject: [PATCH 27/43] chore: normalize package metadata formatting From 6a0b0f282cdbcccfa5968517a97419062772e72e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:51:22 +0000 Subject: [PATCH 28/43] release: add v1.3.3 extension changelog --- .../apply-v1.3.3-extension-changelog.yml | 59 ------------------- CHANGELOG.md | 7 --- entrypoints/popup/components/Settings.tsx | 14 +++++ scripts/patch-v1.3.3-extension-changelog.py | 35 ----------- 4 files changed, 14 insertions(+), 101 deletions(-) delete mode 100644 .github/workflows/apply-v1.3.3-extension-changelog.yml delete mode 100644 scripts/patch-v1.3.3-extension-changelog.py diff --git a/.github/workflows/apply-v1.3.3-extension-changelog.yml b/.github/workflows/apply-v1.3.3-extension-changelog.yml deleted file mode 100644 index 7ae96579..00000000 --- a/.github/workflows/apply-v1.3.3-extension-changelog.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Apply v1.3.3 extension changelog - -on: - pull_request: - types: [reopened] - -permissions: - contents: write - -jobs: - patch: - if: github.event.pull_request.number == 88 && github.head_ref == 'release/v1.3.3-workspace-alias-fix' - runs-on: ubuntu-latest - steps: - - name: Checkout release branch - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 24.x - - - name: Restore repository changelog - shell: bash - run: | - set -euo pipefail - git fetch origin dev - git checkout origin/dev -- CHANGELOG.md - - - name: Update extension changelog - run: python3 scripts/patch-v1.3.3-extension-changelog.py - - - name: Validate patched source - shell: bash - run: | - set -euo pipefail - corepack enable - yarn install --immutable --mode=skip-build - yarn exec wxt prepare - yarn compile --pretty false - yarn test - - - name: Commit patch and remove temporary files - shell: bash - run: | - set -euo pipefail - git rm \ - .github/workflows/apply-v1.3.3-extension-changelog.yml \ - scripts/patch-v1.3.3-extension-changelog.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add \ - CHANGELOG.md \ - entrypoints/popup/components/Settings.tsx - git commit -m "release: add v1.3.3 extension changelog" - git push origin HEAD:${{ github.head_ref }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f136bae..26ffc0aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,6 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [1.3.3] - 2026-07-29 - -### :bug: Bug Fixes - -- [`63c969f`](https://github.com/ePlus-DEV/gmail-alias-toolkit/commit/63c969f3cacc3c79e91ac7a76b4b8a46885601d5) - preserve Google Workspace domains when generating website aliases and prevent Gmail alias history or favorites from leaking into another active account *(commit by [@hoangsvit](https://github.com/hoangsvit))* - ## [1.3.1] - 2026-07-20 ### :sparkles: New Features - [`7148619`](https://github.com/ePlus-DEV/gmail-alias-toolkit/commit/7148619a98c09eaaee68376d4cd021149bd70c8f) - **release**: add Edge and Opera zip scripts *(commit by [@hoangsvit](https://github.com/hoangsvit))* diff --git a/entrypoints/popup/components/Settings.tsx b/entrypoints/popup/components/Settings.tsx index 57354bf6..91dc765f 100644 --- a/entrypoints/popup/components/Settings.tsx +++ b/entrypoints/popup/components/Settings.tsx @@ -84,6 +84,20 @@ const DEFAULT_SETTINGS: AppSettings = { const TOAST_DURATION = 2000; const CHANGELOG: ChangelogEntry[] = [ + { + version: "1.3.3", + date: "2026-07-29", + changes: [ + { + type: "Fixed", + items: [ + "Preserved the selected Google Workspace domain when generating website-aware aliases instead of forcing @gmail.com", + "Filtered previous aliases, recent history, and favorites by the active account to prevent Gmail data from appearing under a Workspace account", + "Rejected malformed base email addresses before generating inline alias suggestions", + ], + }, + ], + }, { version: "1.3.2", date: "2026-07-24", diff --git a/scripts/patch-v1.3.3-extension-changelog.py b/scripts/patch-v1.3.3-extension-changelog.py deleted file mode 100644 index 0cbe520c..00000000 --- a/scripts/patch-v1.3.3-extension-changelog.py +++ /dev/null @@ -1,35 +0,0 @@ -from pathlib import Path - -settings_path = Path("entrypoints/popup/components/Settings.tsx") -settings = settings_path.read_text(encoding="utf-8") - -if 'version: "1.3.3"' in settings: - raise RuntimeError("Settings changelog already contains version 1.3.3") - -marker = '''const CHANGELOG: ChangelogEntry[] = [ - { - version: "1.3.2", -''' -replacement = '''const CHANGELOG: ChangelogEntry[] = [ - { - version: "1.3.3", - date: "2026-07-29", - changes: [ - { - type: "Fixed", - items: [ - "Preserved the selected Google Workspace domain when generating website-aware aliases instead of forcing @gmail.com", - "Filtered previous aliases, recent history, and favorites by the active account to prevent Gmail data from appearing under a Workspace account", - "Rejected malformed base email addresses before generating inline alias suggestions", - ], - }, - ], - }, - { - version: "1.3.2", -''' - -if settings.count(marker) != 1: - raise RuntimeError("Expected Settings changelog marker was not found exactly once") - -settings_path.write_text(settings.replace(marker, replacement, 1), encoding="utf-8") From 06298a9db9738430a3302e0c263d150e430c54a4 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 21:57:26 +0700 Subject: [PATCH 29/43] chore: restore package trailing newline --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 19202bcd..6ed73408 100644 --- a/package.json +++ b/package.json @@ -61,4 +61,4 @@ "vitest": "^4.1.9", "wxt": "^0.20.27" } -} \ No newline at end of file +} From a8ef1af765368a64887f3b0343d2eb917993070d Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 22:46:08 +0700 Subject: [PATCH 30/43] refactor: extract extension changelog data --- entrypoints/popup/data/changelog.ts | 175 ++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 entrypoints/popup/data/changelog.ts diff --git a/entrypoints/popup/data/changelog.ts b/entrypoints/popup/data/changelog.ts new file mode 100644 index 00000000..2a8e6880 --- /dev/null +++ b/entrypoints/popup/data/changelog.ts @@ -0,0 +1,175 @@ +export type ChangelogChangeType = "Added" | "Changed" | "Fixed"; + +export interface ChangelogChange { + readonly type: ChangelogChangeType; + readonly items: readonly string[]; +} + +export interface ChangelogEntry { + readonly version: string; + readonly date: string; + readonly changes: readonly ChangelogChange[]; +} + +export const CHANGELOG: readonly ChangelogEntry[] = [ + { + version: "1.3.3", + date: "2026-07-29", + changes: [ + { + type: "Fixed", + items: [ + "Preserved the selected Google Workspace domain when generating website-aware aliases instead of forcing @gmail.com", + "Filtered previous aliases, recent history, and favorites by the active account to prevent Gmail data from appearing under a Workspace account", + "Rejected malformed base email addresses before generating inline alias suggestions", + ], + }, + ], + }, + { + version: "1.3.2", + date: "2026-07-24", + changes: [ + { + type: "Added", + items: [ + "Added generated-manifest scope verification with regression coverage for Chrome and Firefox-style permissions", + ], + }, + { + type: "Changed", + items: [ + "Aligned WXT development and production URL-scope handling", + "Restricted development builds to the configured site allowlist while preserving all-site production support", + ], + }, + { + type: "Fixed", + items: [ + "Restored inline popup availability in production builds", + "Prevented unrelated content scripts and broad development URL patterns from passing manifest validation", + ], + }, + ], + }, + { + version: "1.3.1", + date: "2026-07-20", + changes: [ + { + type: "Added", + items: [ + "Added Edge and Opera release packages with multi-browser installation guidance", + "Added an interactive product tour and richer landing-page previews", + ], + }, + { + type: "Changed", + items: [ + "Improved active email handling across the popup, inline helper, and context menus", + "Optimized inline popup behavior and displayed the active base email", + ], + }, + { + type: "Fixed", + items: [ + "Improved user feedback when disabling the inline helper or saving aliases", + "Fixed context-menu caching, history loading, injected icon cleanup, and development-site configuration", + ], + }, + ], + }, + { + version: "1.3.0", + date: "2026-07-13", + changes: [ + { + type: "Added", + items: [ + "Added website-aware alias suggestions based on the current hostname", + "Added an email input helper with inline icons, suggestion popups, live previews, and explicit Use actions", + "Added previous-alias navigation and an information panel explaining supported rules and local-only storage", + "Added expanded statistics metrics and Russian and Turkish translations", + "Added a product landing page with automated GitHub Pages deployment", + ], + }, + { + type: "Changed", + items: [ + "Enhanced the context menu with dynamic, website-specific alias suggestions", + "Updated the History tab to show aliases across websites and store history per email account", + "Improved popup navigation, layout, styling, and alias selection behavior", + "Reorganized the content script and colocated its email helper styles", + ], + }, + { + type: "Fixed", + items: [ + "Preserved email input width and flex layout when injecting the helper icon", + "Improved helper popup positioning and hover behavior to prevent accidental closing", + "Hid the Tags statistics tab when there is not enough data for a useful chart", + "Hardened content rendering against client-side cross-site scripting", + "Resolved code quality, localization, and build workflow issues", + ], + }, + ], + }, + { + version: "1.2.0", + date: "2026-07-03", + changes: [ + { + type: "Added", + items: [ + "Added Tailwind CSS v4, shadcn, and beUI motion components", + "Added Action Swap, Animated Badge, Bouncy Accordion, Theme Toggle, Tooltip, and Table integrations", + "Added dark mode toggle in the popup header", + "Added locale key coverage tests for all supported languages", + ], + }, + { + type: "Changed", + items: [ + "Redesigned popup, settings, generator tabs, Gmail tricks, history table, and changelog UI with a unified beUI style", + "Reworked Recent Aliases into a compact non-scrolling table with fixed action buttons", + "Improved dark mode contrast, spacing, hover states, tooltips, and responsive popup layout", + "Moved theme switching out of Settings and into the main popup header", + "Updated all supported locales with the new UI strings", + ], + }, + { + type: "Fixed", + items: [ + "\"Copy All\" no longer undercounts statistics for generated aliases", + "Settings/QR modals no longer render outside the popup bounds", + "Tab key now moves focus normally instead of being hijacked for @gmail.com autocomplete", + "Fixed missing imports and old component references after replacing legacy UI components", + "Fixed table overflow and hidden row action buttons in alias history", + "Fixed untranslated/fallback strings in the new UI", + ], + }, + ], + }, + { + version: "1.1.0", + date: "2025-12-30", + changes: [ + { + type: "Added", + items: [ + "Gmail alias generation with plus addressing", + "Preset management", + "Keyboard shortcuts", + "Statistics tracking", + ], + }, + { type: "Changed", items: ["Updated dependencies"] }, + { type: "Fixed", items: ["Bug fixes and improvements"] }, + ], + }, + { + version: "1.0.0", + date: "2025-12-30", + changes: [{ type: "Added", items: ["Initial release"] }], + }, +]; From 503b2d6f290e51d7eaabf2b95ddfb2fdea1aa3f6 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 22:46:59 +0700 Subject: [PATCH 31/43] test: validate extension changelog data --- tests/popup/changelogData.test.ts | 42 +++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/popup/changelogData.test.ts diff --git a/tests/popup/changelogData.test.ts b/tests/popup/changelogData.test.ts new file mode 100644 index 00000000..79c249f8 --- /dev/null +++ b/tests/popup/changelogData.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { CHANGELOG } from "../../entrypoints/popup/data/changelog"; +import { APP_VERSION } from "../../src/version"; + +/** Converts a semantic version into numeric parts for descending-order checks. */ +function parseVersion(version: string): number[] { + return version.split(".").map(Number); +} + +/** Compares two semantic versions from newest to oldest. */ +function compareVersionsDescending(first: string, second: string): number { + const firstParts = parseVersion(first); + const secondParts = parseVersion(second); + + for (let index = 0; index < 3; index += 1) { + const difference = (secondParts[index] ?? 0) - (firstParts[index] ?? 0); + if (difference !== 0) return difference; + } + + return 0; +} + +describe("extension changelog data", () => { + it("starts with the current package version", () => { + expect(CHANGELOG[0]?.version).toBe(APP_VERSION); + }); + + it("keeps versions unique and ordered from newest to oldest", () => { + const versions = CHANGELOG.map((entry) => entry.version); + + expect(new Set(versions).size).toBe(versions.length); + expect([...versions].sort(compareVersionsDescending)).toEqual(versions); + }); + + it("provides at least one non-empty change item for every release", () => { + for (const entry of CHANGELOG) { + expect(entry.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(entry.changes.length).toBeGreaterThan(0); + expect(entry.changes.flatMap((change) => change.items)).not.toContain(""); + } + }); +}); From 862cedd65cbf9fc3cccf65bfcf3a95d566de6346 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 22:47:20 +0700 Subject: [PATCH 32/43] chore: add one-shot changelog extraction patch --- scripts/extract-extension-changelog.py | 36 ++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 scripts/extract-extension-changelog.py diff --git a/scripts/extract-extension-changelog.py b/scripts/extract-extension-changelog.py new file mode 100644 index 00000000..5bde115e --- /dev/null +++ b/scripts/extract-extension-changelog.py @@ -0,0 +1,36 @@ +from pathlib import Path + + +SETTINGS_PATH = Path("entrypoints/popup/components/Settings.tsx") + + +def replace_once(content: str, old: str, new: str) -> str: + """Replace one expected source fragment and fail when the branch has drifted.""" + count = content.count(old) + if count != 1: + raise RuntimeError(f"Expected exactly one source match, found {count}") + return content.replace(old, new, 1) + + +content = SETTINGS_PATH.read_text(encoding="utf-8") +content = replace_once( + content, + 'import { openUserGuide } from "src/utils/externalLinks";\n', + 'import { openUserGuide } from "src/utils/externalLinks";\n' + 'import { CHANGELOG } from "../data/changelog";\n', +) + +interfaces_start = content.index("interface ChangelogChange {") +interfaces_end = content.index("const DEFAULT_SETTINGS", interfaces_start) +content = content[:interfaces_start] + content[interfaces_end:] + +changelog_start = content.index("const CHANGELOG:") +changelog_end = content.index("interface SettingsPanelProps", changelog_start) +content = content[:changelog_start] + content[changelog_end:] + +if "interface ChangelogEntry" in content or "const CHANGELOG:" in content: + raise RuntimeError("Inline changelog declarations were not fully removed") +if 'import { CHANGELOG } from "../data/changelog";' not in content: + raise RuntimeError("Changelog data import was not added") + +SETTINGS_PATH.write_text(content, encoding="utf-8") From c45194f3e6ae91787bb55cf917641b64e538157c Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 22:47:43 +0700 Subject: [PATCH 33/43] chore: add one-shot changelog extraction workflow --- .../workflows/extract-extension-changelog.yml | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/workflows/extract-extension-changelog.yml diff --git a/.github/workflows/extract-extension-changelog.yml b/.github/workflows/extract-extension-changelog.yml new file mode 100644 index 00000000..9346a110 --- /dev/null +++ b/.github/workflows/extract-extension-changelog.yml @@ -0,0 +1,53 @@ +name: Extract extension changelog data + +on: + pull_request: + types: [reopened] + +permissions: + contents: write + +jobs: + patch: + if: github.event.pull_request.number == 88 && github.head_ref == 'release/v1.3.3-workspace-alias-fix' + runs-on: ubuntu-latest + steps: + - name: Checkout release branch + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24.x + + - name: Extract changelog data from Settings + run: python3 scripts/extract-extension-changelog.py + + - name: Validate refactor + shell: bash + run: | + set -euo pipefail + corepack enable + yarn install --immutable --mode=skip-build + yarn exec wxt prepare + yarn compile --pretty false + yarn test + + - name: Commit refactor and remove temporary files + shell: bash + run: | + set -euo pipefail + git rm \ + .github/workflows/extract-extension-changelog.yml \ + scripts/extract-extension-changelog.py + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add \ + entrypoints/popup/components/Settings.tsx \ + entrypoints/popup/data/changelog.ts \ + tests/popup/changelogData.test.ts + git commit -m "refactor: separate extension changelog data" + git push origin HEAD:${{ github.head_ref }} From 11f8d6ecde70c0aff9d472c4952759617f20aa45 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 22:48:17 +0700 Subject: [PATCH 34/43] chore: keep release branch active --- package.json | 78 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 48 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 6ed73408..4981f716 100644 --- a/package.json +++ b/package.json @@ -15,50 +15,68 @@ "node": ">=24.0.0" }, "scripts": { - "dev": "wxt", - "dev:firefox": "wxt -b firefox", "build": "wxt build", "build:firefox": "wxt build -b firefox", "zip": "wxt zip", "zip:firefox": "wxt zip -b firefox", - "zip:edge": "wxt zip -b edge", - "zip:opera": "wxt zip -b opera", + "zip:edge": "yarn build && node scripts/package-browser.mjs edge", + "zip:opera": "yarn build && node scripts/package-browser.mjs opera", + "release:all": "yarn zip && yarn zip:firefox && yarn zip:edge && yarn zip:opera", "compile": "tsc --noEmit", + "dev": "wxt", + "dev:firefox": "wxt -b firefox", + "postinstall": "wxt prepare", "test": "vitest run", "test:watch": "vitest", - "postinstall": "wxt prepare" + "test:coverage": "vitest run --coverage", + "test:coverage:open": "vitest run --coverage && node scripts/open-coverage.mjs" }, "dependencies": { - "@tanstack/react-virtual": "^3.14.5", - "@wxt-dev/auto-icons": "^1.1.0", - "@wxt-dev/module-react": "^1.1.5", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toast": "^1.2.15", + "@radix-ui/react-tooltip": "^1.2.8", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.468.0", - "motion": "^12.0.0", - "next-themes": "^0.4.6", - "qrcode": "^1.5.4", - "react": "^19.2.3", - "react-dom": "^19.2.3", - "tailwind-merge": "^2.6.0", - "tldts": "^7.4.8" + "motion": "^12.23.12", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "tailwind-merge": "^3.3.1", + "tldts": "^7.0.15" }, "devDependencies": { - "@tailwindcss/postcss": "^4.3.2", - "@testing-library/dom": "^10.4.1", + "@eslint/js": "^9.39.1", + "@tailwindcss/postcss": "^4.1.14", "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", - "@types/qrcode": "^1.5.6", - "@types/react": "^19.2.7", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.3", - "autoprefixer": "^10.4.20", - "jsdom": "^29.1.1", - "postcss": "^8.5.10", - "tailwindcss": "^4", + "@testing-library/react": "^16.3.0", + "@types/node": "^24.10.1", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitest/coverage-v8": "^4.0.15", + "autoprefixer": "^10.4.22", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "fake-browser": "^0.1.1", + "jsdom": "^27.2.0", + "postcss": "^8.5.6", + "prettier": "^3.6.2", + "tailwindcss": "^4.1.14", "typescript": "^5.9.3", - "vite": "^8.1.1", - "vitest": "^4.1.9", - "wxt": "^0.20.27" + "typescript-eslint": "^8.48.0", + "vite": "^7.2.4", + "vitest": "^4.0.15", + "wxt": "^0.20.13" + }, + "resolutions": { + "tar": "^7.5.11" } } From e20fe7d99a3829ccc7e7e07eb116840042ac3053 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 22:48:59 +0700 Subject: [PATCH 35/43] fix: keep package metadata aligned with dev --- package.json | 78 ++++++++++++++++++++-------------------------------- 1 file changed, 30 insertions(+), 48 deletions(-) diff --git a/package.json b/package.json index 4981f716..6ed73408 100644 --- a/package.json +++ b/package.json @@ -15,68 +15,50 @@ "node": ">=24.0.0" }, "scripts": { + "dev": "wxt", + "dev:firefox": "wxt -b firefox", "build": "wxt build", "build:firefox": "wxt build -b firefox", "zip": "wxt zip", "zip:firefox": "wxt zip -b firefox", - "zip:edge": "yarn build && node scripts/package-browser.mjs edge", - "zip:opera": "yarn build && node scripts/package-browser.mjs opera", - "release:all": "yarn zip && yarn zip:firefox && yarn zip:edge && yarn zip:opera", + "zip:edge": "wxt zip -b edge", + "zip:opera": "wxt zip -b opera", "compile": "tsc --noEmit", - "dev": "wxt", - "dev:firefox": "wxt -b firefox", - "postinstall": "wxt prepare", "test": "vitest run", "test:watch": "vitest", - "test:coverage": "vitest run --coverage", - "test:coverage:open": "vitest run --coverage && node scripts/open-coverage.mjs" + "postinstall": "wxt prepare" }, "dependencies": { - "@radix-ui/react-accordion": "^1.2.12", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-label": "^2.1.8", - "@radix-ui/react-radio-group": "^1.3.8", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-slot": "^1.2.3", - "@radix-ui/react-switch": "^1.2.6", - "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-toast": "^1.2.15", - "@radix-ui/react-tooltip": "^1.2.8", - "class-variance-authority": "^0.7.1", + "@tanstack/react-virtual": "^3.14.5", + "@wxt-dev/auto-icons": "^1.1.0", + "@wxt-dev/module-react": "^1.1.5", "clsx": "^2.1.1", "lucide-react": "^0.468.0", - "motion": "^12.23.12", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "tailwind-merge": "^3.3.1", - "tldts": "^7.0.15" + "motion": "^12.0.0", + "next-themes": "^0.4.6", + "qrcode": "^1.5.4", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "tailwind-merge": "^2.6.0", + "tldts": "^7.4.8" }, "devDependencies": { - "@eslint/js": "^9.39.1", - "@tailwindcss/postcss": "^4.1.14", + "@tailwindcss/postcss": "^4.3.2", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.0", - "@types/node": "^24.10.1", - "@types/react": "^19.1.16", - "@types/react-dom": "^19.1.9", - "@vitest/coverage-v8": "^4.0.15", - "autoprefixer": "^10.4.22", - "eslint": "^9.39.1", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", - "fake-browser": "^0.1.1", - "jsdom": "^27.2.0", - "postcss": "^8.5.6", - "prettier": "^3.6.2", - "tailwindcss": "^4.1.14", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/qrcode": "^1.5.6", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "autoprefixer": "^10.4.20", + "jsdom": "^29.1.1", + "postcss": "^8.5.10", + "tailwindcss": "^4", "typescript": "^5.9.3", - "typescript-eslint": "^8.48.0", - "vite": "^7.2.4", - "vitest": "^4.0.15", - "wxt": "^0.20.13" - }, - "resolutions": { - "tar": "^7.5.11" + "vite": "^8.1.1", + "vitest": "^4.1.9", + "wxt": "^0.20.27" } } From 59b2adb45fba9110304f2d503b46a4bfc094dc4b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 22:49:28 +0700 Subject: [PATCH 36/43] chore: normalize release package metadata From 875befa0c8ae699a6fd4872e81d7d5560087fd7e Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 22:49:53 +0700 Subject: [PATCH 37/43] chore: preserve package formatting From b0d1e79d1dd5feb5584e0049608a1e075dae8b42 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 22:52:14 +0700 Subject: [PATCH 38/43] fix: import changelog types from data module --- scripts/extract-extension-changelog.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/extract-extension-changelog.py b/scripts/extract-extension-changelog.py index 5bde115e..9553f6b3 100644 --- a/scripts/extract-extension-changelog.py +++ b/scripts/extract-extension-changelog.py @@ -17,7 +17,11 @@ def replace_once(content: str, old: str, new: str) -> str: content, 'import { openUserGuide } from "src/utils/externalLinks";\n', 'import { openUserGuide } from "src/utils/externalLinks";\n' - 'import { CHANGELOG } from "../data/changelog";\n', + 'import {\n' + ' CHANGELOG,\n' + ' type ChangelogChange,\n' + ' type ChangelogEntry,\n' + '} from "../data/changelog";\n', ) interfaces_start = content.index("interface ChangelogChange {") @@ -30,7 +34,7 @@ def replace_once(content: str, old: str, new: str) -> str: if "interface ChangelogEntry" in content or "const CHANGELOG:" in content: raise RuntimeError("Inline changelog declarations were not fully removed") -if 'import { CHANGELOG } from "../data/changelog";' not in content: - raise RuntimeError("Changelog data import was not added") +if 'type ChangelogEntry,' not in content or 'type ChangelogChange,' not in content: + raise RuntimeError("Changelog data types were not imported") SETTINGS_PATH.write_text(content, encoding="utf-8") From 8b3271e71328b17d09157ad30dbbcc0430f7323e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:53:27 +0000 Subject: [PATCH 39/43] refactor: separate extension changelog data --- .../workflows/extract-extension-changelog.yml | 53 ------ entrypoints/popup/components/Settings.tsx | 179 +----------------- scripts/extract-extension-changelog.py | 40 ---- 3 files changed, 5 insertions(+), 267 deletions(-) delete mode 100644 .github/workflows/extract-extension-changelog.yml delete mode 100644 scripts/extract-extension-changelog.py diff --git a/.github/workflows/extract-extension-changelog.yml b/.github/workflows/extract-extension-changelog.yml deleted file mode 100644 index 9346a110..00000000 --- a/.github/workflows/extract-extension-changelog.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Extract extension changelog data - -on: - pull_request: - types: [reopened] - -permissions: - contents: write - -jobs: - patch: - if: github.event.pull_request.number == 88 && github.head_ref == 'release/v1.3.3-workspace-alias-fix' - runs-on: ubuntu-latest - steps: - - name: Checkout release branch - uses: actions/checkout@v4 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 24.x - - - name: Extract changelog data from Settings - run: python3 scripts/extract-extension-changelog.py - - - name: Validate refactor - shell: bash - run: | - set -euo pipefail - corepack enable - yarn install --immutable --mode=skip-build - yarn exec wxt prepare - yarn compile --pretty false - yarn test - - - name: Commit refactor and remove temporary files - shell: bash - run: | - set -euo pipefail - git rm \ - .github/workflows/extract-extension-changelog.yml \ - scripts/extract-extension-changelog.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add \ - entrypoints/popup/components/Settings.tsx \ - entrypoints/popup/data/changelog.ts \ - tests/popup/changelogData.test.ts - git commit -m "refactor: separate extension changelog data" - git push origin HEAD:${{ github.head_ref }} diff --git a/entrypoints/popup/components/Settings.tsx b/entrypoints/popup/components/Settings.tsx index 91dc765f..3315d49d 100644 --- a/entrypoints/popup/components/Settings.tsx +++ b/entrypoints/popup/components/Settings.tsx @@ -24,6 +24,11 @@ import { parseDisabledInlineSites, } from "src/utils/inlineSiteSettings"; import { openUserGuide } from "src/utils/externalLinks"; +import { + CHANGELOG, + type ChangelogChange, + type ChangelogEntry, +} from "../data/changelog"; interface SettingsProps { isOpen: boolean; @@ -61,17 +66,6 @@ interface ConfirmationRequest { resolve: (confirmed: boolean) => void; } -interface ChangelogChange { - type: "Added" | "Changed" | "Fixed"; - items: string[]; -} - -interface ChangelogEntry { - version: string; - date: string; - changes: ChangelogChange[]; -} - const DEFAULT_SETTINGS: AppSettings = { customPresets: [], maxHistory: 20, @@ -83,169 +77,6 @@ const DEFAULT_SETTINGS: AppSettings = { const TOAST_DURATION = 2000; -const CHANGELOG: ChangelogEntry[] = [ - { - version: "1.3.3", - date: "2026-07-29", - changes: [ - { - type: "Fixed", - items: [ - "Preserved the selected Google Workspace domain when generating website-aware aliases instead of forcing @gmail.com", - "Filtered previous aliases, recent history, and favorites by the active account to prevent Gmail data from appearing under a Workspace account", - "Rejected malformed base email addresses before generating inline alias suggestions", - ], - }, - ], - }, - { - version: "1.3.2", - date: "2026-07-24", - changes: [ - { - type: "Added", - items: [ - "Added generated-manifest scope verification with regression coverage for Chrome and Firefox-style permissions", - ], - }, - { - type: "Changed", - items: [ - "Aligned WXT development and production URL-scope handling", - "Restricted development builds to the configured site allowlist while preserving all-site production support", - ], - }, - { - type: "Fixed", - items: [ - "Restored inline popup availability in production builds", - "Prevented unrelated content scripts and broad development URL patterns from passing manifest validation", - ], - }, - ], - }, - { - version: "1.3.1", - date: "2026-07-20", - changes: [ - { - type: "Added", - items: [ - "Added Edge and Opera release packages with multi-browser installation guidance", - "Added an interactive product tour and richer landing-page previews", - ], - }, - { - type: "Changed", - items: [ - "Improved active email handling across the popup, inline helper, and context menus", - "Optimized inline popup behavior and displayed the active base email", - ], - }, - { - type: "Fixed", - items: [ - "Improved user feedback when disabling the inline helper or saving aliases", - "Fixed context-menu caching, history loading, injected icon cleanup, and development-site configuration", - ], - }, - ], - }, - { - version: "1.3.0", - date: "2026-07-13", - changes: [ - { - type: "Added", - items: [ - "Added website-aware alias suggestions based on the current hostname", - "Added an email input helper with inline icons, suggestion popups, live previews, and explicit Use actions", - "Added previous-alias navigation and an information panel explaining supported rules and local-only storage", - "Added expanded statistics metrics and Russian and Turkish translations", - "Added a product landing page with automated GitHub Pages deployment", - ], - }, - { - type: "Changed", - items: [ - "Enhanced the context menu with dynamic, website-specific alias suggestions", - "Updated the History tab to show aliases across websites and store history per email account", - "Improved popup navigation, layout, styling, and alias selection behavior", - "Reorganized the content script and colocated its email helper styles", - ], - }, - { - type: "Fixed", - items: [ - "Preserved email input width and flex layout when injecting the helper icon", - "Improved helper popup positioning and hover behavior to prevent accidental closing", - "Hid the Tags statistics tab when there is not enough data for a useful chart", - "Hardened content rendering against client-side cross-site scripting", - "Resolved code quality, localization, and build workflow issues", - ], - }, - ], - }, - { - version: "1.2.0", - date: "2026-07-03", - changes: [ - { - type: "Added", - items: [ - "Added Tailwind CSS v4, shadcn, and beUI motion components", - "Added Action Swap, Animated Badge, Bouncy Accordion, Theme Toggle, Tooltip, and Table integrations", - "Added dark mode toggle in the popup header", - "Added locale key coverage tests for all supported languages", - ], - }, - { - type: "Changed", - items: [ - "Redesigned popup, settings, generator tabs, Gmail tricks, history table, and changelog UI with a unified beUI style", - "Reworked Recent Aliases into a compact non-scrolling table with fixed action buttons", - "Improved dark mode contrast, spacing, hover states, tooltips, and responsive popup layout", - "Moved theme switching out of Settings and into the main popup header", - "Updated all supported locales with the new UI strings", - ], - }, - { - type: "Fixed", - items: [ - '"Copy All" no longer undercounts statistics for generated aliases', - "Settings/QR modals no longer render outside the popup bounds", - "Tab key now moves focus normally instead of being hijacked for @gmail.com autocomplete", - "Fixed missing imports and old component references after replacing legacy UI components", - "Fixed table overflow and hidden row action buttons in alias history", - "Fixed untranslated/fallback strings in the new UI", - ], - }, - ], - }, - { - version: "1.1.0", - date: "2025-12-30", - changes: [ - { - type: "Added", - items: [ - "Gmail alias generation with plus addressing", - "Preset management", - "Keyboard shortcuts", - "Statistics tracking", - ], - }, - { type: "Changed", items: ["Updated dependencies"] }, - { type: "Fixed", items: ["Bug fixes and improvements"] }, - ], - }, - { - version: "1.0.0", - date: "2025-12-30", - changes: [{ type: "Added", items: ["Initial release"] }], - }, -]; - interface SettingsPanelProps { settings: AppSettings; saveSettings: (settings: AppSettings) => Promise; diff --git a/scripts/extract-extension-changelog.py b/scripts/extract-extension-changelog.py deleted file mode 100644 index 9553f6b3..00000000 --- a/scripts/extract-extension-changelog.py +++ /dev/null @@ -1,40 +0,0 @@ -from pathlib import Path - - -SETTINGS_PATH = Path("entrypoints/popup/components/Settings.tsx") - - -def replace_once(content: str, old: str, new: str) -> str: - """Replace one expected source fragment and fail when the branch has drifted.""" - count = content.count(old) - if count != 1: - raise RuntimeError(f"Expected exactly one source match, found {count}") - return content.replace(old, new, 1) - - -content = SETTINGS_PATH.read_text(encoding="utf-8") -content = replace_once( - content, - 'import { openUserGuide } from "src/utils/externalLinks";\n', - 'import { openUserGuide } from "src/utils/externalLinks";\n' - 'import {\n' - ' CHANGELOG,\n' - ' type ChangelogChange,\n' - ' type ChangelogEntry,\n' - '} from "../data/changelog";\n', -) - -interfaces_start = content.index("interface ChangelogChange {") -interfaces_end = content.index("const DEFAULT_SETTINGS", interfaces_start) -content = content[:interfaces_start] + content[interfaces_end:] - -changelog_start = content.index("const CHANGELOG:") -changelog_end = content.index("interface SettingsPanelProps", changelog_start) -content = content[:changelog_start] + content[changelog_end:] - -if "interface ChangelogEntry" in content or "const CHANGELOG:" in content: - raise RuntimeError("Inline changelog declarations were not fully removed") -if 'type ChangelogEntry,' not in content or 'type ChangelogChange,' not in content: - raise RuntimeError("Changelog data types were not imported") - -SETTINGS_PATH.write_text(content, encoding="utf-8") From ae6327ae874153d8216db55d528b6983661da655 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 23:41:58 +0700 Subject: [PATCH 40/43] fix: harden scoped inline history parsing --- src/services/inlineHistoryService.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/services/inlineHistoryService.ts b/src/services/inlineHistoryService.ts index cd00f5cb..67d212d9 100644 --- a/src/services/inlineHistoryService.ts +++ b/src/services/inlineHistoryService.ts @@ -23,7 +23,8 @@ function isInlineHistoryAlias(value: unknown): value is InlineHistoryAlias { const candidate = value as { email?: unknown; timestamp?: unknown }; return ( typeof candidate.email === "string" && - typeof candidate.timestamp === "number" + typeof candidate.timestamp === "number" && + Number.isFinite(candidate.timestamp) ); } @@ -67,13 +68,15 @@ function parseFavorites(value: unknown): FavoriteAlias[] { return favorites; } -/** Returns the first available account-scoped value, then its legacy fallback. */ +/** Uses scoped storage whenever its key exists, otherwise falls back to legacy data. */ function selectStoredValue( storage: Record, accountKey: string, legacyKey: string, ): unknown { - return storage[accountKey] ?? storage[legacyKey] ?? []; + return Object.prototype.hasOwnProperty.call(storage, accountKey) + ? storage[accountKey] + : (storage[legacyKey] ?? []); } /** Loads account-isolated history and favorites for the inline helper popup. */ From e7600539290f4bf233fba7173848df1210ce1c45 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 23:43:36 +0700 Subject: [PATCH 41/43] test: cover malformed scoped inline history --- tests/services/inlineHistoryService.test.ts | 35 +++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/services/inlineHistoryService.test.ts b/tests/services/inlineHistoryService.test.ts index 2b70739e..6b553d1e 100644 --- a/tests/services/inlineHistoryService.test.ts +++ b/tests/services/inlineHistoryService.test.ts @@ -113,6 +113,25 @@ async function assertScopedStorageTakesPrecedence(): Promise { expect(result).toEqual({ history: [], favorites: [] }); } +/** Verifies present null scoped values block fallback to obsolete global data. */ +async function assertNullScopedStorageBlocksLegacyFallback(): Promise { + const activeEmail = "nguyen.minh.hoang@rivercrane.vn"; + const historyKey = getAccountStorageKey(activeEmail, "gmail_alias_recent"); + const favoritesKey = getAccountStorageKey(activeEmail, "favorites"); + mockStorageData[historyKey] = null; + mockStorageData[favoritesKey] = null; + mockStorageData.gmail_alias_recent = [ + { email: "nguyen.minh.hoang+legacy@rivercrane.vn", timestamp: 1 }, + ]; + mockStorageData.favorites = [ + "nguyen.minh.hoang+legacy@rivercrane.vn", + ]; + + const result = await loadInlineAccountHistory(activeEmail); + + expect(result).toEqual({ history: [], favorites: [] }); +} + /** Verifies non-array account storage is treated as empty data. */ async function assertNonArrayStorageIsIgnored(): Promise { const activeEmail = "nguyen.minh.hoang@rivercrane.vn"; @@ -138,6 +157,18 @@ async function assertMalformedEntriesAreIgnored(): Promise { email: "nguyen.minh.hoang+bad-time@rivercrane.vn", timestamp: "10", }, + { + email: "nguyen.minh.hoang+nan@rivercrane.vn", + timestamp: Number.NaN, + }, + { + email: "nguyen.minh.hoang+infinity@rivercrane.vn", + timestamp: Number.POSITIVE_INFINITY, + }, + { + email: "nguyen.minh.hoang+negative-infinity@rivercrane.vn", + timestamp: Number.NEGATIVE_INFINITY, + }, { email: 123, timestamp: 9 }, { email: "nguyen.minh.hoang+valid@rivercrane.vn", timestamp: 8 }, ]; @@ -186,6 +217,10 @@ function defineInlineHistoryServiceTests(): void { "prefers account-scoped storage over legacy global storage", assertScopedStorageTakesPrecedence, ); + it( + "does not fall back when scoped storage exists as null", + assertNullScopedStorageBlocksLegacyFallback, + ); it("treats non-array storage as empty", assertNonArrayStorageIsIgnored); it( "ignores malformed history and favorite entries", From 45e2ff988c916dfd7671af4e9eff8c1e8b9d8b2b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 23:44:07 +0700 Subject: [PATCH 42/43] test: cover changelog comparator and reset state --- tests/popup/changelogData.test.ts | 76 +++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 18 deletions(-) diff --git a/tests/popup/changelogData.test.ts b/tests/popup/changelogData.test.ts index 79c249f8..81970f21 100644 --- a/tests/popup/changelogData.test.ts +++ b/tests/popup/changelogData.test.ts @@ -1,10 +1,14 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { CHANGELOG } from "../../entrypoints/popup/data/changelog"; import { APP_VERSION } from "../../src/version"; +let versions: string[] = []; + /** Converts a semantic version into numeric parts for descending-order checks. */ function parseVersion(version: string): number[] { - return version.split(".").map(Number); + const parts: number[] = []; + for (const part of version.split(".")) parts.push(Number(part)); + return parts; } /** Compares two semantic versions from newest to oldest. */ @@ -20,23 +24,59 @@ function compareVersionsDescending(first: string, second: string): number { return 0; } -describe("extension changelog data", () => { - it("starts with the current package version", () => { - expect(CHANGELOG[0]?.version).toBe(APP_VERSION); - }); +/** Restores changelog version fixtures before each assertion. */ +function resetVersions(): void { + versions = []; + for (const entry of CHANGELOG) versions.push(entry.version); +} + +/** Verifies the newest changelog entry matches the package version. */ +function assertCurrentVersionIsFirst(): void { + expect(CHANGELOG[0]?.version).toBe(APP_VERSION); +} + +/** Verifies changelog versions are unique and ordered newest to oldest. */ +function assertVersionsAreUniqueAndDescending(): void { + expect(new Set(versions).size).toBe(versions.length); + expect([...versions].sort(compareVersionsDescending)).toEqual(versions); +} - it("keeps versions unique and ordered from newest to oldest", () => { - const versions = CHANGELOG.map((entry) => entry.version); +/** Exercises newer, older, and equal comparator outcomes directly. */ +function assertVersionComparatorBranches(): void { + expect(compareVersionsDescending("1.3.3", "1.3.2")).toBeLessThan(0); + expect(compareVersionsDescending("1.3.2", "1.3.3")).toBeGreaterThan(0); + expect(compareVersionsDescending("1.3.3", "1.3.3")).toBe(0); +} - expect(new Set(versions).size).toBe(versions.length); - expect([...versions].sort(compareVersionsDescending)).toEqual(versions); - }); +/** Verifies every release includes a valid date and non-empty change item. */ +function assertReleaseEntriesAreComplete(): void { + for (const entry of CHANGELOG) { + expect(entry.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(entry.changes.length).toBeGreaterThan(0); - it("provides at least one non-empty change item for every release", () => { - for (const entry of CHANGELOG) { - expect(entry.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); - expect(entry.changes.length).toBeGreaterThan(0); - expect(entry.changes.flatMap((change) => change.items)).not.toContain(""); + for (const change of entry.changes) { + expect(change.items.length).toBeGreaterThan(0); + for (const item of change.items) expect(item).not.toBe(""); } - }); -}); + } +} + +/** Registers changelog data integrity tests. */ +function defineChangelogDataTests(): void { + beforeEach(resetVersions); + it("starts with the current package version", assertCurrentVersionIsFirst); + it( + "keeps versions unique and ordered from newest to oldest", + assertVersionsAreUniqueAndDescending, + ); + it( + "compares newer, older, and equal versions", + assertVersionComparatorBranches, + ); + it( + "provides at least one non-empty change item for every release", + assertReleaseEntriesAreComplete, + ); +} + +describe("extension changelog data", defineChangelogDataTests); From eba34e5cab723c86e964c1a0fcbefd3d97a56edc Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 29 Jul 2026 23:44:36 +0700 Subject: [PATCH 43/43] docs: clarify account-aware alias service behavior --- src/services/websiteAliasService.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/services/websiteAliasService.ts b/src/services/websiteAliasService.ts index b66c70ef..4275b853 100644 --- a/src/services/websiteAliasService.ts +++ b/src/services/websiteAliasService.ts @@ -54,9 +54,7 @@ export async function saveWebsiteAlias( await browser.storage.local.set({ [key]: map }); } -/** - * Get the previously used alias for a website (if any). - */ +/** Returns a previous website alias only when it belongs to the active account. */ export async function getPreviousAliasForWebsite( email: string, urlOrHostname: string, @@ -71,10 +69,7 @@ export async function getPreviousAliasForWebsite( return { alias: entry.alias, timestamp: entry.timestamp }; } -/** - * Generate alias suggestions for a website. - * Returns 3–5 suggestions based on the normalized hostname and random formats. - */ +/** Generates website-aware suggestions while preserving the active account domain. */ export async function generateSuggestionsForWebsite( baseEmail: string, urlOrHostname: string,