From aaeb97af7cfcda668c214ffab14a573aa26618c8 Mon Sep 17 00:00:00 2001 From: Tate McCauley Date: Tue, 3 Mar 2026 08:43:59 -0700 Subject: [PATCH 1/3] Refactor getCurrentTab message handling for improved clarity and efficiency. Ensure requestId is mandatory for responses and streamline response structure. Enhance error handling for better debugging. --- Extension/src/background/messages.js | 167 +++++++++------------------ 1 file changed, 57 insertions(+), 110 deletions(-) diff --git a/Extension/src/background/messages.js b/Extension/src/background/messages.js index 7da9857..bb287eb 100644 --- a/Extension/src/background/messages.js +++ b/Extension/src/background/messages.js @@ -63,127 +63,74 @@ export function registerMessageListeners() { return true; } - if (request.action === "getCurrentTab") { - if (request.requestId) { - (async () => { - try { - const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true }); - let targetTab = activeTabs[0]; + // getCurrentTab: caller must send requestId; response is sent via getCurrentTabResponse (MV3-safe). + if (request.action === "getCurrentTab" && request.requestId) { + (async () => { + try { + const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true }); + let targetTab = activeTabs[0]; - if ( - !targetTab || - !targetTab.url || - !/^https?:\/\//i.test(targetTab.url) || - targetTab.url.startsWith("chrome-extension://") || - targetTab.url.startsWith("chrome://") || - targetTab.url.startsWith("edge://") || - targetTab.url.startsWith("about:") - ) { - const allTabs = await chrome.tabs.query({ currentWindow: true }); - for (const t of allTabs) { - if ( - t.url && - /^https?:\/\//i.test(t.url) && - !t.url.startsWith("chrome-extension://") && - !t.url.startsWith("chrome://") && - !t.url.startsWith("edge://") && - !t.url.startsWith("about:") - ) { - targetTab = t; - break; - } + if ( + !targetTab || + !targetTab.url || + !/^https?:\/\//i.test(targetTab.url) || + targetTab.url.startsWith("chrome-extension://") || + targetTab.url.startsWith("chrome://") || + targetTab.url.startsWith("edge://") || + targetTab.url.startsWith("about:") + ) { + const allTabs = await chrome.tabs.query({ currentWindow: true }); + for (const t of allTabs) { + if ( + t.url && + /^https?:\/\//i.test(t.url) && + !t.url.startsWith("chrome-extension://") && + !t.url.startsWith("chrome://") && + !t.url.startsWith("edge://") && + !t.url.startsWith("about:") + ) { + targetTab = t; + break; } } - - let response; - if (targetTab && targetTab.url) { - const url = targetTab.url; - if (!/^https?:\/\//i.test(url)) { - response = { url, title: targetTab.title, securityData: null }; - } else { - const result = await getCachedOrScan(url); - response = { - url, - title: targetTab.title, - securityData: result || null, - }; - } - } else { - response = { url: null, title: null, securityData: null }; - } - - chrome.runtime - .sendMessage({ - action: "getCurrentTabResponse", - requestId: request.requestId, - data: response, - }) - .catch(() => {}); - } catch (error) { - console.error("Error in getCurrentTab handler:", error); - chrome.runtime - .sendMessage({ - action: "getCurrentTabResponse", - requestId: request.requestId, - data: { url: null, title: null, securityData: null, error: error.message }, - }) - .catch(() => {}); } - })(); - return false; - } else { - (async () => { - try { - const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true }); - let targetTab = activeTabs[0]; - - if ( - !targetTab || - !targetTab.url || - !/^https?:\/\//i.test(targetTab.url) || - targetTab.url.startsWith("chrome-extension://") || - targetTab.url.startsWith("chrome://") || - targetTab.url.startsWith("edge://") || - targetTab.url.startsWith("about:") - ) { - const allTabs = await chrome.tabs.query({ currentWindow: true }); - for (const t of allTabs) { - if ( - t.url && - /^https?:\/\//i.test(t.url) && - !t.url.startsWith("chrome-extension://") && - !t.url.startsWith("chrome://") && - !t.url.startsWith("edge://") && - !t.url.startsWith("about:") - ) { - targetTab = t; - break; - } - } - } - if (targetTab && targetTab.url) { - const url = targetTab.url; - if (!/^https?:\/\//i.test(url)) { - sendResponse({ url, title: targetTab.title, securityData: null }); - return; - } + let response; + if (targetTab && targetTab.url) { + const url = targetTab.url; + if (!/^https?:\/\//i.test(url)) { + response = { url, title: targetTab.title, securityData: null }; + } else { const result = await getCachedOrScan(url); - sendResponse({ + response = { url, title: targetTab.title, securityData: result || null, - }); - } else { - sendResponse({ url: null, title: null, securityData: null }); + }; } - } catch (error) { - console.error("Error in getCurrentTab handler:", error); - sendResponse({ url: null, title: null, securityData: null, error: error.message }); + } else { + response = { url: null, title: null, securityData: null }; } - })(); - return true; - } + + chrome.runtime + .sendMessage({ + action: "getCurrentTabResponse", + requestId: request.requestId, + data: response, + }) + .catch(() => {}); + } catch (error) { + console.error("Error in getCurrentTab handler:", error); + chrome.runtime + .sendMessage({ + action: "getCurrentTabResponse", + requestId: request.requestId, + data: { url: null, title: null, securityData: null, error: error.message }, + }) + .catch(() => {}); + } + })(); + return false; } return false; From 68b1c9f894a38c276eaef2521c95de3f1dbfe299 Mon Sep 17 00:00:00 2001 From: Tate McCauley Date: Tue, 3 Mar 2026 09:36:48 -0700 Subject: [PATCH 2/3] added timestamp to ci --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f925b0..3af3ce4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,7 @@ jobs: USE_SCORING_TEST_DATA: "1" PORT: "3000" run: | + E2E_START=$(date +%s) node Server/server.js & SERVER_PID=$! sleep 6 @@ -59,3 +60,6 @@ jobs: } console.log('Full-system scan OK, safetyScore:', d.safetyScore); " + E2E_END=$(date +%s) + E2E_DURATION=$((E2E_END - E2E_START)) + echo "E2E test duration: ${E2E_DURATION}s" From 040415c9de6f1f69bbad0a3f24aaf0b3705925b5 Mon Sep 17 00:00:00 2001 From: Tate McCauley Date: Wed, 4 Mar 2026 09:25:21 -0700 Subject: [PATCH 3/3] Enhance CI workflow by adding steps to install and test Extension dependencies. Introduce a Bill of Materials document detailing project components and dependencies. Add unit tests for the Extension's scan functionality, improving test coverage and reliability. Update constants and message handling for better performance and error management. --- .github/workflows/ci.yml | 6 + Docs/bill-of-materials.md | 75 ++++++++ Extension/__tests__/scan.test.js | 222 ++++++++++++++++++++++ Extension/src/background/constants.js | 3 + Extension/src/background/messages.js | 63 +++--- Extension/src/background/scan.js | 51 ++++- Extension/src/components/tabs/HomeTab.jsx | 126 ++++-------- 7 files changed, 410 insertions(+), 136 deletions(-) create mode 100644 Docs/bill-of-materials.md create mode 100644 Extension/__tests__/scan.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3af3ce4..d176eb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,12 @@ jobs: - name: Run Server unit tests run: cd Server && npm test + - name: Install Extension dependencies + run: cd Extension && npm ci + + - name: Run Extension unit tests + run: cd Extension && npm test + - name: Run full-system scan (E2E) env: USE_SCORING_TEST_DATA: "1" diff --git a/Docs/bill-of-materials.md b/Docs/bill-of-materials.md new file mode 100644 index 0000000..f3979d7 --- /dev/null +++ b/Docs/bill-of-materials.md @@ -0,0 +1,75 @@ +# Bill of Materials + +| Field | Value | +|-------|-------| +| Artifact ID | ART-021 | +| Artifact Title | Project Management | +| Revision | 01 | +| Revision Date | 25 FEB 2026 | +| Prepared by | Tate McCauley | +| Checked by | Cam Gedris | +| Purpose | Preview of future work plans from our Jira instance | + +--- + +## Bill of Materials + +### Deliverables + +| Item # | Part / Description | Quantity | Notes | +|--------|--------------------|----------|-------| +| 1 | Chrome Extension (netstar-extension) | 1 | React 19, MV3; popup, background, content script | +| 2 | Node.js Server (Express API) | 1 | `/scan` endpoint; validates targets, spawns scoring engine | +| 3 | Python Scoring Engine | 1 | scoring_main.py, score_engine.py, data_fetch.py, scoring_logic.py | + +### Runtime / Prerequisites + +| Item # | Part / Description | Quantity | Notes | +|--------|--------------------|----------|-------| +| 4 | Node.js | 1 | v18+ required (Extension + Server) | +| 5 | Python | 1 | 3.x required (Scoring Engine) | +| 6 | Chrome / Chromium browser | 1 | Edge, Brave, etc. for Extension | + +### Extension — Production Dependencies + +| Item # | Part / Description | Quantity | Notes | +|--------|--------------------|----------|-------| +| 7 | react | 19.1.0 | UI framework | +| 8 | react-dom | 19.1.0 | React DOM renderer | +| 9 | @radix-ui/react-progress, react-slot, react-switch | 3 | Accessible UI primitives | +| 10 | lucide-react | ^0.552.0 | Icons | +| 11 | tailwind-merge, tailwind-scrollbar, tailwindcss-animate | 3 | Styling utilities | +| 12 | class-variance-authority, clsx | 2 | Component styling (cva/clsx) | + +### Extension — Build / Dev Dependencies + +| Item # | Part / Description | Quantity | Notes | +|--------|--------------------|----------|-------| +| 13 | Vite | ^5.4.21 | Build tool | +| 14 | @crxjs/vite-plugin | ^2.2.0 | Chrome extension build | +| 15 | @vitejs/plugin-react | ^4.3.1 | React HMR/build | +| 16 | Tailwind CSS, PostCSS, Autoprefixer | 3 | Styles (Tailwind ^4.1.17) | +| 17 | Jest | ^30.2.0 | Unit tests | +| 18 | JSDoc | ^4.0.5 | Documentation | + +### Server — Dependencies + +| Item # | Part / Description | Quantity | Notes | +|--------|--------------------|----------|-------| +| 19 | express | ^5.1.0 | HTTP API | +| 20 | jest, supertest | 2 | Testing (dev) | + +### Scoring Engine — Dependencies + +| Item # | Part / Description | Quantity | Notes | +|--------|--------------------|----------|-------| +| 21 | Python standard library + curl | — | Telemetry (cert, DNS, RDAP, etc.) via curl | +| 22 | pytest, pytest-cov, pytest-mock | 3 | Testing (requirements.txt) | + +### Documentation + +| Item # | Part / Description | Quantity | Notes | +|--------|--------------------|----------|-------| +| 23 | Docs (deployment, testing, url-sanitization-policy, extension-server-flow) | 4+ | NetSTAR-Shield/Docs | +| 24 | Extension docs (architecture, development, theme, content, caching) | 5+ | Extension/docs | +| 25 | READMEs (project, Extension, Server, Scoring Engine) | 4 | Top-level and per-component | diff --git a/Extension/__tests__/scan.test.js b/Extension/__tests__/scan.test.js new file mode 100644 index 0000000..8483bde --- /dev/null +++ b/Extension/__tests__/scan.test.js @@ -0,0 +1,222 @@ +import { jest, describe, it, expect, beforeEach, afterEach } from "@jest/globals"; + +// ─── Mocks ───────────────────────────────────────────────────────────── +// We use jest.unstable_mockModule for ESM. Constants are mocked with a +// short timeout so the abort test completes quickly. + +const TEST_TIMEOUT_MS = 200; + +jest.unstable_mockModule("../src/background/constants.js", () => ({ + SCAN_API_BASE: "http://test-server:3000", + CACHE_DURATION_MS: 300_000, + SCAN_FETCH_TIMEOUT_MS: TEST_TIMEOUT_MS, +})); + +// The urlNormalize module is real (no mock needed). + +// Global chrome mock — getCachedOrScan uses chrome.storage.local +const storageBacking = {}; +globalThis.chrome = { + storage: { + local: { + get: jest.fn((key) => { + const k = typeof key === "string" ? key : Object.keys(key)[0]; + return Promise.resolve(k in storageBacking ? { [k]: storageBacking[k] } : {}); + }), + set: jest.fn((obj) => { + Object.assign(storageBacking, obj); + return Promise.resolve(); + }), + remove: jest.fn((key) => { + delete storageBacking[key]; + return Promise.resolve(); + }), + }, + }, +}; + +// Import after mocks are registered +const { performSecurityScan, getCachedOrScan } = await import( + "../src/background/scan.js" +); + +// ─── Helpers ─────────────────────────────────────────────────────────── + +function mockFetchResponse(body, { ok = true, status = 200 } = {}) { + globalThis.fetch = jest.fn(() => + Promise.resolve({ + ok, + status, + json: () => Promise.resolve(body), + }) + ); +} + +function clearStorageBacking() { + for (const key of Object.keys(storageBacking)) delete storageBacking[key]; +} + +// ─── Tests ───────────────────────────────────────────────────────────── + +beforeEach(() => { + jest.clearAllMocks(); + clearStorageBacking(); +}); + +// ═══ performSecurityScan ═══════════════════════════════════════════════ + +describe("performSecurityScan", () => { + it("returns { safetyScore, indicators, timestamp } on success", async () => { + mockFetchResponse({ safetyScore: 85, indicators: [{ id: "cert" }] }); + + const result = await performSecurityScan("https://www.example.com/path"); + + expect(result).toEqual( + expect.objectContaining({ + safetyScore: 85, + indicators: [{ id: "cert" }], + }) + ); + expect(typeof result.timestamp).toBe("number"); + }); + + it("uses the normalized domain in the fetch URL", async () => { + mockFetchResponse({ safetyScore: 90, indicators: [] }); + + await performSecurityScan("https://www.Example.COM/page?q=1"); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + const url = globalThis.fetch.mock.calls[0][0]; + expect(url).toContain("domain=example.com"); + expect(url).not.toContain("www."); + }); + + it("falls back to aggregatedScore when safetyScore is missing", async () => { + mockFetchResponse({ aggregatedScore: 70, indicators: [] }); + + const result = await performSecurityScan("example.com"); + + expect(result.safetyScore).toBe(70); + }); + + it("rejects with a timeout error when fetch never resolves", async () => { + globalThis.fetch = jest.fn( + () => new Promise((_, reject) => { + // Listen for the abort signal to reject, like a real fetch would. + const opts = globalThis.fetch.mock.calls[0]?.[1]; + if (opts?.signal) { + opts.signal.addEventListener("abort", () => + reject(Object.assign(new Error("The operation was aborted"), { name: "AbortError" })) + ); + } + }) + ); + + await expect(performSecurityScan("example.com")).rejects.toThrow( + /timed out/i + ); + }, TEST_TIMEOUT_MS + 5000); + + it("rejects with the original error on network failure", async () => { + globalThis.fetch = jest.fn(() => Promise.reject(new Error("Network down"))); + + await expect(performSecurityScan("example.com")).rejects.toThrow( + "Network down" + ); + }); + + it("passes an AbortSignal to fetch", async () => { + mockFetchResponse({ safetyScore: 50, indicators: [] }); + + await performSecurityScan("example.com"); + + const opts = globalThis.fetch.mock.calls[0][1]; + expect(opts).toBeDefined(); + expect(opts.signal).toBeInstanceOf(AbortSignal); + }); +}); + +// ═══ getCachedOrScan ═══════════════════════════════════════════════════ + +describe("getCachedOrScan", () => { + it("returns cached data on cache hit (no fetch)", async () => { + const cached = { + safetyScore: 92, + indicators: [], + timestamp: Date.now(), + }; + const cacheKey = "scan_example.com"; + storageBacking[cacheKey] = cached; + + globalThis.fetch = jest.fn(); + + const result = await getCachedOrScan("https://www.example.com"); + + expect(result).toEqual(cached); + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(chrome.storage.local.set).not.toHaveBeenCalled(); + }); + + it("fetches and caches on cache miss", async () => { + mockFetchResponse({ safetyScore: 77, indicators: [{ id: "dns" }] }); + + const result = await getCachedOrScan("https://example.com"); + + expect(result.safetyScore).toBe(77); + expect(result.indicators).toEqual([{ id: "dns" }]); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(chrome.storage.local.set).toHaveBeenCalledTimes(1); + + const setArg = chrome.storage.local.set.mock.calls[0][0]; + expect(Object.keys(setArg)[0]).toBe("scan_example.com"); + }); + + it("removes expired cache and fetches fresh data", async () => { + const expired = { + safetyScore: 50, + indicators: [], + timestamp: Date.now() - 600_000, // 10 min ago, well past 5 min TTL + }; + storageBacking["scan_example.com"] = expired; + + mockFetchResponse({ safetyScore: 88, indicators: [] }); + + const result = await getCachedOrScan("https://example.com"); + + expect(chrome.storage.local.remove).toHaveBeenCalledWith("scan_example.com"); + expect(result.safetyScore).toBe(88); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + }); + + it("deduplicates concurrent in-flight requests", async () => { + let resolveFetch; + globalThis.fetch = jest.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + + // Launch both before awaiting either — storage.get is async so we need + // to let microtasks drain so both calls reach the in-flight check. + const p1 = getCachedOrScan("https://example.com"); + const p2 = getCachedOrScan("https://example.com"); + + // Wait a tick so the async storage calls resolve and fetch is invoked + await new Promise((r) => setTimeout(r, 50)); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + + resolveFetch({ + ok: true, + status: 200, + json: () => Promise.resolve({ safetyScore: 60, indicators: [] }), + }); + + const [r1, r2] = await Promise.all([p1, p2]); + + expect(r1.safetyScore).toBe(60); + expect(r2.safetyScore).toBe(60); + expect(r1).toBe(r2); + }); +}); diff --git a/Extension/src/background/constants.js b/Extension/src/background/constants.js index 88f837e..a5540d0 100644 --- a/Extension/src/background/constants.js +++ b/Extension/src/background/constants.js @@ -20,3 +20,6 @@ export const CACHE_DURATION_MS = 1000 * 5 * 60; // 5 minutes // Score threshold to trigger a warning notification export const ALERT_THRESHOLD = 60; +// Max time to wait for the scan API before aborting the fetch +export const SCAN_FETCH_TIMEOUT_MS = 10_000; // 10 seconds + diff --git a/Extension/src/background/messages.js b/Extension/src/background/messages.js index bb287eb..a41a71d 100644 --- a/Extension/src/background/messages.js +++ b/Extension/src/background/messages.js @@ -22,17 +22,9 @@ export function registerMessageListeners() { if (request.action === "scanUrl") { (async () => { try { - // ── Manual-scan validation ──────────────────────────────────── - // Block inputs that don't contain a letter-based TLD (e.g. - // ".com", ".org", ".co.uk"). This rejects: - // • Single words with no dot ("hello") - // • Plain IP addresses ("192.168.1.1", "::1") - // • Strings with only numeric TLDs or no TLD at all - // - // Plain IPs are intentionally blocked for now; this may change - // in a future version. See Docs/url-sanitization-policy.md. - // The server also validates and will return 400 for anything - // that slips past this check. + // Block inputs that don't contain a letter-based TLD (e.g. ".com", ".org"). + // Rejects single words, plain IPs, and strings with no TLD. + // See Docs/url-sanitization-policy.md. if (!/\.[a-z]{2,}/i.test(request.url)) { console.log("Invalid URL entered:", request.url); sendResponse({ @@ -44,28 +36,33 @@ export function registerMessageListeners() { } const result = await getCachedOrScan(request.url); - - const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); - if (tabs[0]) { - updateIcon(tabs[0].id, result.safetyScore); - updateRecentScans(request.url, result.safetyScore); - await maybeShowRiskNotification(request.url, result.safetyScore); - } - sendResponse(result); + + // Fire-and-forget side-effects so the popup is unblocked immediately + void (async () => { + try { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tabs[0]) { + updateIcon(tabs[0].id, result.safetyScore); + updateRecentScans(request.url, result.safetyScore); + await maybeShowRiskNotification(request.url, result.safetyScore); + } + } catch (e) { + console.error("[NetSTAR] scanUrl side-effects error:", e); + } + })(); } catch (error) { console.error("Error in scanUrl:", error); sendResponse({ error: true, message: error.message }); } })(); - // Return true to indicate we will send a response asynchronously return true; } - // getCurrentTab: caller must send requestId; response is sent via getCurrentTabResponse (MV3-safe). - if (request.action === "getCurrentTab" && request.requestId) { + if (request.action === "getCurrentTab") { (async () => { + const t0 = Date.now(); try { const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true }); let targetTab = activeTabs[0]; @@ -112,25 +109,15 @@ export function registerMessageListeners() { response = { url: null, title: null, securityData: null }; } - chrome.runtime - .sendMessage({ - action: "getCurrentTabResponse", - requestId: request.requestId, - data: response, - }) - .catch(() => {}); + console.log("[NetSTAR][timing] getCurrentTab: done", Date.now() - t0, "ms"); + sendResponse(response); } catch (error) { - console.error("Error in getCurrentTab handler:", error); - chrome.runtime - .sendMessage({ - action: "getCurrentTabResponse", - requestId: request.requestId, - data: { url: null, title: null, securityData: null, error: error.message }, - }) - .catch(() => {}); + console.error("[NetSTAR] getCurrentTab error:", Date.now() - t0, "ms", error); + sendResponse({ url: null, title: null, securityData: null, error: error.message }); } })(); - return false; + + return true; } return false; diff --git a/Extension/src/background/scan.js b/Extension/src/background/scan.js index 1eab987..fe2cd5f 100644 --- a/Extension/src/background/scan.js +++ b/Extension/src/background/scan.js @@ -1,27 +1,54 @@ -import { CACHE_DURATION_MS, SCAN_API_BASE } from "./constants.js"; +import { CACHE_DURATION_MS, SCAN_API_BASE, SCAN_FETCH_TIMEOUT_MS } from "./constants.js"; import { normalizeScanDomain } from "./urlNormalize.js"; +/** In-flight scan promises by cache key so concurrent callers for the same domain share one request. */ +const inFlightByKey = new Map(); + /** * Cache and scan entry points */ export async function getCachedOrScan(url) { + const t0 = Date.now(); const domain = normalizeScanDomain(url); const cacheKey = `scan_${encodeURIComponent(domain || url)}`; const data = await chrome.storage.local.get(cacheKey); - const now = Date.now(); + console.log("[NetSTAR][timing] getCachedOrScan: after storage.get", Date.now() - t0, "ms"); + const now = Date.now(); if (data[cacheKey]) { const cached = data[cacheKey]; if (now - cached.timestamp < CACHE_DURATION_MS) { + console.log("[NetSTAR][timing] getCachedOrScan: cache HIT, returning", Date.now() - t0, "ms"); return cached; } else { await chrome.storage.local.remove(cacheKey); + console.log("[NetSTAR][timing] getCachedOrScan: cache expired, removed", Date.now() - t0, "ms"); } + } else { + console.log("[NetSTAR][timing] getCachedOrScan: cache MISS", Date.now() - t0, "ms"); + } + + // Reuse in-flight request for the same domain so we don't double-scan (e.g. React Strict Mode / double mount). + if (inFlightByKey.has(cacheKey)) { + console.log("[NetSTAR][timing] getCachedOrScan: reusing in-flight request for", cacheKey); + return inFlightByKey.get(cacheKey); } - const result = await performSecurityScan(url); - await chrome.storage.local.set({ [cacheKey]: result }); - return result; + const promise = (async () => { + try { + const beforeScan = Date.now(); + const result = await performSecurityScan(url); + console.log("[NetSTAR][timing] getCachedOrScan: after performSecurityScan", Date.now() - beforeScan, "ms (total", Date.now() - t0, "ms)"); + + await chrome.storage.local.set({ [cacheKey]: result }); + console.log("[NetSTAR][timing] getCachedOrScan: after storage.set, done", Date.now() - t0, "ms"); + return result; + } finally { + inFlightByKey.delete(cacheKey); + } + })(); + inFlightByKey.set(cacheKey, promise); + return promise; } /** @@ -36,11 +63,16 @@ export async function performSecurityScan(url) { ? `${SCAN_API_BASE}/scan?domain=${encodeURIComponent(domain)}` : `${SCAN_API_BASE}/scan?url=${encodeURIComponent(url)}`; const startedAt = Date.now(); - console.log("[NetSTAR][scan] Requesting:", endpoint); + console.log("[NetSTAR][timing] performSecurityScan: fetch start", endpoint); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), SCAN_FETCH_TIMEOUT_MS); try { - const response = await fetch(endpoint); + const response = await fetch(endpoint, { signal: controller.signal }); + clearTimeout(timeoutId); const elapsedMs = Date.now() - startedAt; + console.log("[NetSTAR][timing] performSecurityScan: fetch done", elapsedMs, "ms"); console.log("[NetSTAR][scan] Response:", { endpoint, @@ -66,7 +98,12 @@ export async function performSecurityScan(url) { timestamp: Date.now(), }; } catch (error) { + clearTimeout(timeoutId); const elapsedMs = Date.now() - startedAt; + if (error.name === "AbortError") { + console.error("[NetSTAR][scan] Timed out:", { endpoint, elapsedMs }); + throw new Error(`Scan request timed out after ${SCAN_FETCH_TIMEOUT_MS}ms`); + } console.error("[NetSTAR][scan] Failed:", { endpoint, elapsedMs, error }); throw error; } diff --git a/Extension/src/components/tabs/HomeTab.jsx b/Extension/src/components/tabs/HomeTab.jsx index 771c3af..fff4044 100644 --- a/Extension/src/components/tabs/HomeTab.jsx +++ b/Extension/src/components/tabs/HomeTab.jsx @@ -64,15 +64,26 @@ const INDICATORS_OPEN_KEY = "indicatorsOpen"; * /> * ``` */ +function hostnameFromUrl(urlString) { + try { + const u = new URL(urlString.includes("://") ? urlString : `https://${urlString}`); + return u.hostname; + } catch { + return "this site"; + } +} + export function HomeTab({ mode, onNavigate, forceShowIndicators, overrideUrl, overrideSecurityData }) { - /** Current website URL hostname */ - const [currentUrl, setCurrentUrl] = useState(""); - - /** Security safety score (0-100), default is 87 */ - const [safetyScore, setSafetyScore] = useState(87); + /** Current website URL hostname. Initialize from override when landing after a manual scan. */ + const [currentUrl, setCurrentUrl] = useState(() => (overrideUrl ? hostnameFromUrl(overrideUrl) : "")); + + /** Security safety score (0-100). Initialize from override when landing after a manual scan so the score shows immediately. */ + const [safetyScore, setSafetyScore] = useState(() => + overrideSecurityData?.safetyScore !== undefined ? overrideSecurityData.safetyScore : 87 + ); - /** Complete security scan data including indicators and metadata, or null if not loaded */ - const [securityData, setSecurityData] = useState(null); + /** Complete security scan data including indicators and metadata. Initialize from override so we don't show loading after a manual scan. */ + const [securityData, setSecurityData] = useState(() => overrideSecurityData ?? null); /** * State for whether the "What We Checked" indicators section is expanded @@ -123,21 +134,11 @@ export function HomeTab({ mode, onNavigate, forceShowIndicators, overrideUrl, ov useEffect(() => { let isMounted = true; - // Helper: display a URL string as hostname for the UI. - const setHostnameFromUrl = (urlString) => { - try { - const u = new URL(urlString.includes("://") ? urlString : `https://${urlString}`); - setCurrentUrl(u.hostname); - } catch { - setCurrentUrl("this site"); - } - }; - const run = async () => { // If the popup is showing a manual scan target, prefer that over "current tab". if (overrideUrl) { - setHostnameFromUrl(overrideUrl); + setCurrentUrl(hostnameFromUrl(overrideUrl)); if (overrideSecurityData?.safetyScore !== undefined) { setSafetyScore(overrideSecurityData.safetyScore); @@ -169,87 +170,30 @@ export function HomeTab({ mode, onNavigate, forceShowIndicators, overrideUrl, ov // Default behavior: Get current tab URL and security data. if (typeof chrome !== "undefined" && chrome.runtime) { try { - const response = await new Promise((resolve, reject) => { - let resolved = false; - const requestId = `getCurrentTab_${Date.now()}_${Math.random()}`; - - // Set up a one-time message listener for the response - const messageListener = (message) => { - if (message.action === "getCurrentTabResponse" && message.requestId === requestId) { - chrome.runtime.onMessage.removeListener(messageListener); - if (!resolved) { - resolved = true; - resolve(message.data); - } - return true; - } - }; - - chrome.runtime.onMessage.addListener(messageListener); - - // Send the request - chrome.runtime.sendMessage( - { - action: "getCurrentTab", - requestId: requestId, - }, - (response) => { - const callbackError = chrome.runtime.lastError; - - // If we got a response synchronously, use it - if (response && typeof response === "object" && response.url !== undefined) { - chrome.runtime.onMessage.removeListener(messageListener); - if (!resolved) { - resolved = true; - resolve(response); - } - return; - } - - // Check for port closed error - expected in Manifest V3 with async handlers - if (callbackError) { - const errorMsg = callbackError.message || String(callbackError); - if ( - errorMsg.includes("message port closed") || - errorMsg.includes("The message port closed before a response was received") - ) { - // Wait for message listener to receive the response - return; - } - - // Other fatal errors - if ( - errorMsg.includes("Receiving end does not exist") || - errorMsg.includes("Could not establish connection") || - errorMsg.includes("Extension context invalidated") - ) { - chrome.runtime.onMessage.removeListener(messageListener); - if (!resolved) { - resolved = true; - reject(callbackError); - } - return; - } - } - } - ); - - // Timeout fallback - setTimeout(() => { - if (!resolved) { - chrome.runtime.onMessage.removeListener(messageListener); - resolved = true; + const t0 = Date.now(); + const TIMEOUT_MS = 12_000; + const response = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(null), TIMEOUT_MS); + + chrome.runtime.sendMessage({ action: "getCurrentTab" }, (resp) => { + clearTimeout(timer); + if (chrome.runtime.lastError) { + console.error("[NetSTAR] getCurrentTab error:", chrome.runtime.lastError.message); resolve(null); + return; } - }, 3000); + resolve(resp ?? null); + }); }); if (!isMounted || !response) return; if (response.url) { - setHostnameFromUrl(response.url); + const elapsedMs = Date.now() - t0; + console.log("[NetSTAR][timing] popup: getCurrentTab end-to-end", elapsedMs, "ms"); + + setCurrentUrl(hostnameFromUrl(response.url)); - // Update safety score from background script if available if (response.securityData?.safetyScore !== undefined) { setSafetyScore(response.securityData.safetyScore); setSecurityData(response.securityData);