diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 8dd4192d..2345a7c1 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -289,9 +289,13 @@ export function applyHotPollPRUpdates( // long as the underlying failure (e.g. a secondary-rate-limit stall) persists. export const DEP_BODY_FAILURE_COOLDOWN_MS = 5 * 60 * 1000; const _depBodyFailureCooldown = new Map(); +const _dashboardBodyCache = new Map(); +const _dashboardBodyFailureCooldown = new Map(); +let _bodyFetchAuthGen = 0; // Clear dashboard data and stop polling on logout to prevent cross-user data leakage onAuthCleared(() => { + _bodyFetchAuthGen++; resetDashboardData(); setHasFetchedFresh(false); setJiraIssues([]); @@ -305,6 +309,8 @@ onAuthCleared(() => { _fetchingDashboardBodies = false; _fetchingDepBodies = false; _depBodyFailureCooldown.clear(); + _dashboardBodyCache.clear(); + _dashboardBodyFailureCooldown.clear(); setApprovingPRIds(new Set()); recentlyApprovedPRs.clear(); setMarkingReadyPRIds(new Set()); @@ -374,6 +380,7 @@ async function pollFetch(): Promise { applyRecentApprovalGuard(pr, currentLogin); } + let resolvedPullRequests: PullRequest[]; if (phaseOneFired) { // Phase 1 fired — use fine-grained merge for the light→enriched // transition. Only update heavy fields to avoid re-rendering the @@ -422,17 +429,19 @@ async function pollFetch(): Promise { } } })); + resolvedPullRequests = unwrap(dashboardData.pullRequests).map((pr) => ({ ...pr })); } else { // Phase 1 did NOT fire (cached data existed or subsequent poll). // Full atomic replacement — all fields (light + heavy) may have // changed since the last cycle. Preserve scroll position: SolidJS // DOM updates are synchronous within the setter, so save/restore // around it to prevent scroll reset from DOM rebuild. - const pullRequests = fallbackToPreviousEnrichment(dashboardData.pullRequests, data.pullRequests); + const pullRequests = fallbackToPreviousEnrichment(unwrap(dashboardData.pullRequests), data.pullRequests); for (const pr of pullRequests) { applyRecentReadyGuard(pr); applyRecentApprovalGuard(pr, currentLogin); } + resolvedPullRequests = pullRequests; withScrollLock(() => { setDashboardData({ issues: data.issues, @@ -446,19 +455,19 @@ async function pollFetch(): Promise { const filteredRuns = config.enableActions ? data.workflowRuns : []; const hotPullRequests = recentlyMarkedReadyPRs.size > 0 || recentlyApprovedPRs.size > 0 - ? data.pullRequests.map((pr) => + ? resolvedPullRequests.map((pr) => (recentlyMarkedReadyPRs.has(pr.id) || recentlyApprovedPRs.has(pr.id)) && pr.nodeId ? { ...pr, enriched: true, checkStatus: "pending" as const } : pr ) - : data.pullRequests; + : resolvedPullRequests; rebuildHotSets({ ...data, pullRequests: hotPullRequests, workflowRuns: filteredRuns }); // Persist for stale-while-revalidate on full page reload. // Errors are transient and not persisted. Deferred to avoid blocking paint. const cachePayload = { _v: CACHE_VERSION, issues: data.issues, - pullRequests: data.pullRequests, + pullRequests: resolvedPullRequests, workflowRuns: filteredRuns, lastRefreshedAt: now.toISOString(), }; @@ -1339,31 +1348,74 @@ export default function DashboardPage() { const octokit = getClient(); if (!octokit) return; _fetchingDashboardBodies = true; + const authGen = _bodyFetchAuthGen; void (async () => { try { const dashboardIssues = findDashboardIssues(dashboardData.issues, trackedBotLogins()); + const activeNodeIds = new Set(dashboardIssues.map((di) => di.nodeId)); + for (const k of [..._dashboardBodyCache.keys()]) { + if (!activeNodeIds.has(k)) _dashboardBodyCache.delete(k); + } + for (const k of [..._dashboardBodyFailureCooldown.keys()]) { + if (!activeNodeIds.has(k)) _dashboardBodyFailureCooldown.delete(k); + } + const depRepos = new Set(visibleDependencyPullRequests().map((pr) => pr.repoFullName)); const relevant = dashboardIssues.filter((di) => depRepos.has(di.repoFullName)); - if (relevant.length === 0) return; + if (relevant.length === 0) { + if (abandonedDepsMap().size > 0) setAbandonedDepsMap(new Map()); + if (dashboardIssueUrls().size > 0) setDashboardIssueUrls(new Map()); + return; + } - const nodeIds = relevant.map((di) => di.nodeId); - const { bodies: bodyMap } = await fetchDashboardIssueBodies(octokit, nodeIds); + const now = Date.now(); + const toFetch = relevant.filter((di) => { + if (_dashboardBodyCache.get(di.nodeId)?.updatedAt === di.updatedAt) return false; + const failedAt = _dashboardBodyFailureCooldown.get(di.nodeId); + return !failedAt || now - failedAt >= DEP_BODY_FAILURE_COOLDOWN_MS; + }); + + let failedCount = 0; + if (toFetch.length > 0) { + const { bodies: bodyMap, failedIds } = await fetchDashboardIssueBodies( + octokit, + toFetch.map((di) => di.nodeId) + ); + if (authGen !== _bodyFetchAuthGen || getClient() !== octokit) return; + failedCount = failedIds.size; + for (const di of toFetch) { + if (failedIds.has(di.nodeId)) { + _dashboardBodyFailureCooldown.set(di.nodeId, now); + } else { + _dashboardBodyFailureCooldown.delete(di.nodeId); + } + if (bodyMap.has(di.nodeId)) { + _dashboardBodyCache.set(di.nodeId, { + updatedAt: di.updatedAt, + deps: parseAbandonedSection(bodyMap.get(di.nodeId) ?? ""), + }); + } + } + } const newAbandonedMap = new Map(); const newUrlMap = new Map(); for (const di of relevant) { - const body = bodyMap.get(di.nodeId); - if (body != null) { - newAbandonedMap.set(di.repoFullName, parseAbandonedSection(body)); + const cached = _dashboardBodyCache.get(di.nodeId); + if (cached) { + newAbandonedMap.set(di.repoFullName, cached.deps); } newUrlMap.set(di.repoFullName, di.htmlUrl); } - // Don't replace valid data with empty results from a failed fetch - if (bodyMap.size === 0 && relevant.length > 0) return; - setAbandonedDepsMap(newAbandonedMap); setDashboardIssueUrls(newUrlMap); + // Don't replace valid data with empty results from a failed fetch + if (!(newAbandonedMap.size === 0 && abandonedDepsMap().size > 0 && failedCount > 0)) { + setAbandonedDepsMap(newAbandonedMap); + } } finally { - _fetchingDashboardBodies = false; + if (authGen === _bodyFetchAuthGen) { + _fetchingDashboardBodies = false; + } } })(); }, @@ -1408,12 +1460,14 @@ export default function DashboardPage() { } _fetchingDepBodies = true; + const authGen = _bodyFetchAuthGen; const effectStart = Date.now(); console.debug(`[dashboard] depBodies effect: fetch started for ${toFetch.length} PRs at ${effectStart}`); void (async () => { try { const nodeIds = toFetch.map((pr) => pr.nodeId!); const { bodies: bodyMap, failedIds } = await fetchDepPRBodies(octokit, nodeIds); + if (authGen !== _bodyFetchAuthGen || getClient() !== octokit) return; console.debug(`[dashboard] depBodies effect: fetch resolved after ${Date.now() - effectStart}ms`, { requested: toFetch.length, returned: bodyMap.size, failed: failedIds.size }); // Record/clear per-PR cooldown so a batch that errored or timed out isn't @@ -1441,7 +1495,9 @@ export default function DashboardPage() { console.debug(`[dashboard] depBodies effect: depMeta updated, size=${merged.size}`); setTimeout(() => persistDepMeta(merged), 0); } finally { - _fetchingDepBodies = false; + if (authGen === _bodyFetchAuthGen) { + _fetchingDepBodies = false; + } console.debug(`[dashboard] depBodies effect: guard released after ${Date.now() - effectStart}ms`); } })(); diff --git a/src/app/lib/dependency-dashboard.ts b/src/app/lib/dependency-dashboard.ts index 083f8495..0ea5d731 100644 --- a/src/app/lib/dependency-dashboard.ts +++ b/src/app/lib/dependency-dashboard.ts @@ -20,7 +20,7 @@ export function escapeRegex(s: string): string { export function findDashboardIssues( issues: Issue[], botLogins: Set -): { issueNumber: number; nodeId: string; repoFullName: string; htmlUrl: string }[] { +): { issueNumber: number; nodeId: string; repoFullName: string; htmlUrl: string; updatedAt: string }[] { const allBotLogins = new Set([...KNOWN_DEP_BOT_LOGINS, ...botLogins]); return issues .filter((issue) => { @@ -35,6 +35,7 @@ export function findDashboardIssues( nodeId: issue.nodeId!, repoFullName: issue.repoFullName, htmlUrl: issue.htmlUrl, + updatedAt: issue.updatedAt, })); } diff --git a/src/app/lib/notifications.ts b/src/app/lib/notifications.ts index 1956ab73..41ff6205 100644 --- a/src/app/lib/notifications.ts +++ b/src/app/lib/notifications.ts @@ -100,10 +100,14 @@ function fireNotification( tag: string, url?: string ): void { - const n = new Notification(title, { body, tag, icon: "/assets/logo-192.png" }); - if (url) { - const safeUrl = url; - n.onclick = () => openUrl(safeUrl); + try { + const n = new Notification(title, { body, tag, icon: "/assets/logo-192.png" }); + if (url) { + const safeUrl = url; + n.onclick = () => openUrl(safeUrl); + } + } catch (err) { + console.debug("[notifications] Notification constructor failed:", err); } } diff --git a/src/app/lib/sentry.ts b/src/app/lib/sentry.ts index 8753c215..a24c12b9 100644 --- a/src/app/lib/sentry.ts +++ b/src/app/lib/sentry.ts @@ -27,7 +27,37 @@ const ALLOWED_CONSOLE_PREFIXES = [ "[notifications]", ]; +const BACKGROUND_POLL_SOURCES = new Set([ + "prEnrichment", + "dashboardBodies", + "depPRBodies", + "pr-pagination", + "poll-cycle", + "hot-poll-pr-batch", + "hot-poll-pr-fetch", + "hot-poll-run-fetch", +]); + +function isBrowserFetchDisconnectMessage(value: string | undefined): boolean { + if (!value) return false; + return ( + /^(?:Failed to fetch|Load failed)(?:\s*\([^)]+\))?$/i.test(value) || + /^NetworkError when attempting to fetch resource\.?$/i.test(value) + ); +} + export function beforeSendHandler(event: ErrorEvent): ErrorEvent | null { + const source = event.tags?.source; + if (typeof source === "string" && BACKGROUND_POLL_SOURCES.has(source)) { + const values = event.exception?.values; + if ( + values && + values.length > 0 && + values.every((ex) => isBrowserFetchDisconnectMessage(ex.value)) + ) { + return null; + } + } // Strip OAuth params from captured URLs if (event.request?.url) { event.request.url = scrubUrl(event.request.url); diff --git a/src/app/services/api.ts b/src/app/services/api.ts index e0e32fcf..b917bf57 100644 --- a/src/app/services/api.ts +++ b/src/app/services/api.ts @@ -1205,7 +1205,9 @@ export async function fetchPREnrichment( export const GRAPHQL_BODY_FETCH_TIMEOUT_MS = 20_000; -export class GraphqlFetchTimeoutError extends Error {} +export class GraphqlFetchTimeoutError extends Error { + override readonly name = "GraphqlFetchTimeoutError"; +} export function raceWithTimeout(promise: Promise, ms: number, controller: AbortController): Promise { let timeoutId: ReturnType | undefined; diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index 21212867..ddad4387 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -4823,3 +4823,526 @@ describe("DashboardPage — Mark PR ready integration", () => { expect(errorsLib.pushNotification).not.toHaveBeenCalled(); }); }); + +// ── Fallback PR Enrichment Propagation ─────────────────────────────────────── + +describe("DashboardPage — fallbackToPreviousEnrichment propagation to hot sets and localStorage", () => { + it("retains fallback-merged enrichment in rebuildHotSets and localStorage when a second poll cycle returns enriched: false on a previously-enriched PR", async () => { + const enrichedPR = makePullRequest({ + id: 501, + number: 501, + title: "Feature with pending checks", + repoFullName: "owner/repo", + userLogin: "otheruser", + state: "OPEN", + draft: false, + enriched: true, + nodeId: "PR_kwDOtest0501", + headSha: "1111111111111111111111111111111111111111", + checkStatus: "pending", + reviewDecision: "REVIEW_REQUIRED", + autoMergeMethod: "SQUASH", + assigneeLogins: ["alice"], + reviewerLogins: ["bob"], + }); + + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [enrichedPR], + workflowRuns: [], + errors: [], + }); + + render(() => ); + + await waitFor(() => { + expect(pollService.rebuildHotSets).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(localStorage.getItem("github-tracker:dashboard")).not.toBeNull(); + }); + + vi.mocked(pollService.rebuildHotSets).mockClear(); + localStorage.removeItem("github-tracker:dashboard"); + + // Second poll cycle: prEnrichment batch fails and returns enriched: false + const unenrichedPR = makePullRequest({ + id: 501, + number: 501, + title: "Feature with pending checks", + repoFullName: "owner/repo", + userLogin: "otheruser", + state: "OPEN", + draft: false, + enriched: false, + nodeId: "PR_kwDOtest0501", + headSha: "", + checkStatus: null, + reviewDecision: null, + autoMergeMethod: null, + assigneeLogins: [], + reviewerLogins: [], + }); + + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [unenrichedPR], + workflowRuns: [], + errors: [], + }); + + await capturedFetchAll!(); + + expect(pollService.rebuildHotSets).toHaveBeenCalledWith( + expect.objectContaining({ + pullRequests: [ + expect.objectContaining({ + id: 501, + nodeId: "PR_kwDOtest0501", + enriched: true, + checkStatus: "pending", + autoMergeMethod: "SQUASH", + assigneeLogins: ["alice"], + reviewerLogins: ["bob"], + }), + ], + }) + ); + + await waitFor(() => { + const raw = localStorage.getItem("github-tracker:dashboard"); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!) as { pullRequests: Array> }; + expect(parsed.pullRequests).toHaveLength(1); + expect(parsed.pullRequests[0]).toMatchObject({ + id: 501, + nodeId: "PR_kwDOtest0501", + enriched: true, + checkStatus: "pending", + autoMergeMethod: "SQUASH", + assigneeLogins: ["alice"], + reviewerLogins: ["bob"], + }); + }); + }); +}); + +// ── DashboardBodies Caching and Cooldown ───────────────────────────────────── + +describe("DashboardPage — dashboardBodies caching and cooldown", () => { + beforeEach(() => { + vi.mocked(pollService.createPollCoordinator).mockImplementation((_getInterval, fetchAll) => { + const [lastRefreshAt, setLastRefreshAt] = createSignal(null); + const wrappedFetchAll = async () => { + const res = await fetchAll(); + setLastRefreshAt(new Date()); + return res; + }; + capturedFetchAll = wrappedFetchAll; + void wrappedFetchAll().catch(() => {}); + return { + isRefreshing: () => false, + lastRefreshAt, + manualRefresh: vi.fn(), + destroy: vi.fn(), + }; + }); + }); + + const depPRInRepo = () => + makePullRequest({ + id: 901, + nodeId: "PR_DEP_901", + repoFullName: "owner/repo", + title: "Bump axios from 0.27 to 1.0", + userLogin: "dependabot[bot]", + headRef: "dependabot/npm_and_yarn/axios-1.0.0", + }); + + it("caches parsed dashboard issue bodies by (nodeId, updatedAt), skips re-fetching when updatedAt is unchanged, and re-fetches when updatedAt changes", async () => { + const graphqlSpy = vi.fn().mockResolvedValue({ + nodes: [ + { + id: "DASH_1", + body: [ + "## Abandoned", + "", + "| Datasource | Package | Last Updated |", + "| --- | --- | --- |", + "| npm | axios | 2020-01-01 |", + ].join("\n"), + }, + ], + rateLimit: null, + }); + vi.mocked(githubService.getClient).mockReturnValue( + { graphql: graphqlSpy } as unknown as ReturnType + ); + + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [ + makeIssue({ + id: 10, + number: 10, + title: "Dependency Dashboard", + userLogin: "renovate[bot]", + nodeId: "DASH_1", + repoFullName: "owner/repo", + state: "OPEN", + updatedAt: "2026-04-01T00:00:00Z", + }), + ], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => expect(graphqlSpy).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Second poll cycle with identical updatedAt — cache hit, no GraphQL call + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [ + makeIssue({ + id: 10, + number: 10, + title: "Dependency Dashboard", + userLogin: "renovate[bot]", + nodeId: "DASH_1", + repoFullName: "owner/repo", + state: "OPEN", + updatedAt: "2026-04-01T00:00:00Z", + }), + ], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [], + }); + await capturedFetchAll!(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(graphqlSpy).toHaveBeenCalledTimes(1); + + // Third poll cycle with updated updatedAt — cache invalidated, re-fetches immediately + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [ + makeIssue({ + id: 10, + number: 10, + title: "Dependency Dashboard", + userLogin: "renovate[bot]", + nodeId: "DASH_1", + repoFullName: "owner/repo", + state: "OPEN", + updatedAt: "2026-04-02T00:00:00Z", + }), + ], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [], + }); + await capturedFetchAll!(); + await waitFor(() => expect(graphqlSpy).toHaveBeenCalledTimes(2)); + }); + + it("caches null-body dashboard issues by (nodeId, updatedAt) so they are not re-fetched on subsequent polls", async () => { + const graphqlSpy = vi.fn().mockResolvedValue({ + nodes: [{ id: "DASH_NULL", body: null }], + rateLimit: null, + }); + vi.mocked(githubService.getClient).mockReturnValue( + { graphql: graphqlSpy } as unknown as ReturnType + ); + + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [ + makeIssue({ + id: 11, + number: 11, + title: "Dependency Dashboard", + userLogin: "renovate[bot]", + nodeId: "DASH_NULL", + repoFullName: "owner/repo", + state: "OPEN", + updatedAt: "2026-04-01T00:00:00Z", + }), + ], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => expect(graphqlSpy).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setTimeout(resolve, 50)); + + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [ + makeIssue({ + id: 11, + number: 11, + title: "Dependency Dashboard", + userLogin: "renovate[bot]", + nodeId: "DASH_NULL", + repoFullName: "owner/repo", + state: "OPEN", + updatedAt: "2026-04-01T00:00:00Z", + }), + ], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [], + }); + await capturedFetchAll!(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(graphqlSpy).toHaveBeenCalledTimes(1); + }); + + it("suppresses retries for failedIds until DEP_BODY_FAILURE_COOLDOWN_MS elapses", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const graphqlSpy = vi.fn() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValue({ + nodes: [{ id: "DASH_FAIL", body: "" }], + rateLimit: null, + }); + vi.mocked(githubService.getClient).mockReturnValue( + { graphql: graphqlSpy } as unknown as ReturnType + ); + + const makeDashIssue = () => + makeIssue({ + id: 12, + number: 12, + title: "Dependency Dashboard", + userLogin: "renovate[bot]", + nodeId: "DASH_FAIL", + repoFullName: "owner/repo", + state: "OPEN", + updatedAt: "2026-04-01T00:00:00Z", + }); + + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [makeDashIssue()], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => expect(graphqlSpy).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Second poll within the 5-minute cooldown window — retry is suppressed + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [makeDashIssue()], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [], + }); + await capturedFetchAll!(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(graphqlSpy).toHaveBeenCalledTimes(1); + + // Advance clock past DEP_BODY_FAILURE_COOLDOWN_MS — retry proceeds + vi.setSystemTime(new Date(Date.now() + DEP_BODY_FAILURE_COOLDOWN_MS)); + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [makeDashIssue()], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [], + }); + await capturedFetchAll!(); + await waitFor(() => expect(graphqlSpy).toHaveBeenCalledTimes(2)); + } finally { + vi.useRealTimers(); + } + }); + + it("prunes _dashboardBodyCache and _dashboardBodyFailureCooldown when a dashboard issue leaves dashboardData.issues and later reappears", async () => { + const graphqlSpy = vi.fn() + .mockRejectedValueOnce(new Error("transient fail")) + .mockResolvedValue({ + nodes: [{ id: "DASH_PRUNE", body: "" }], + rateLimit: null, + }); + vi.mocked(githubService.getClient).mockReturnValue( + { graphql: graphqlSpy } as unknown as ReturnType + ); + + const makeDashIssue = () => + makeIssue({ + id: 13, + number: 13, + title: "Dependency Dashboard", + userLogin: "renovate[bot]", + nodeId: "DASH_PRUNE", + repoFullName: "owner/repo", + state: "OPEN", + updatedAt: "2026-04-01T00:00:00Z", + }); + + // Poll 1: fails and records cooldown on DASH_PRUNE + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [makeDashIssue()], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [], + }); + render(() => ); + await waitFor(() => expect(graphqlSpy).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Poll 2: dashboard issue is closed/absent — prunes DASH_PRUNE from cooldown and cache + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [], + }); + await capturedFetchAll!(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Poll 3: dashboard issue reappears within cooldown window — re-fetched immediately because cooldown was pruned + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [makeDashIssue()], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [], + }); + await capturedFetchAll!(); + await waitFor(() => expect(graphqlSpy).toHaveBeenCalledTimes(2)); + }); + + it("clears _dashboardBodyCache on onAuthCleared and ignores in-flight fetch resolution after logout", async () => { + let resolveInFlight: ((val: unknown) => void) | null = null; + const graphqlSpy = vi.fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveInFlight = resolve; + }) + ) + .mockResolvedValue({ + nodes: [ + { + id: "DASH_AUTH", + body: "## Detected Dependencies\n\n`abandoned-pkg` is Marked as abandoned\n", + }, + ], + rateLimit: null, + }); + vi.mocked(githubService.getClient).mockReturnValue( + { graphql: graphqlSpy } as unknown as ReturnType + ); + + const makeDashIssue = () => + makeIssue({ + id: 14, + number: 14, + title: "Dependency Dashboard", + userLogin: "renovate[bot]", + nodeId: "DASH_AUTH", + repoFullName: "owner/repo", + state: "OPEN", + updatedAt: "2026-04-01T00:00:00Z", + }); + + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [makeDashIssue()], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => expect(graphqlSpy).toHaveBeenCalledTimes(1)); + + // Fire onAuthCleared while first fetch is still in flight + for (const cb of authClearCallbacks) cb(); + + // Now resolve the stale in-flight request — its result must be ignored (not cached) + resolveInFlight!({ + nodes: [ + { + id: "DASH_AUTH", + body: "## Detected Dependencies\n\n`stale-pkg` is Marked as abandoned\n", + }, + ], + rateLimit: null, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Subsequent mount/poll for the new session must re-fetch DASH_AUTH (not hit a stale cache entry) + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [makeDashIssue()], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [], + }); + render(() => ); + await waitFor(() => expect(graphqlSpy).toHaveBeenCalledTimes(2)); + }); + + it("clears abandonedDepsMap and dashboardIssueUrls when relevant dashboard issues become empty", async () => { + const user = userEvent.setup(); + const graphqlSpy = vi.fn().mockResolvedValue({ + nodes: [ + { + id: "DASH_CLEAR", + body: [ + "## Abandoned", + "", + "| Datasource | Package | Last Updated |", + "| --- | --- | --- |", + "| npm | axios | 2020-01-01 |", + ].join("\n"), + }, + ], + rateLimit: null, + }); + vi.mocked(githubService.getClient).mockReturnValue( + { graphql: graphqlSpy } as unknown as ReturnType + ); + + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [ + makeIssue({ + id: 15, + number: 15, + title: "Dependency Dashboard", + userLogin: "renovate[bot]", + nodeId: "DASH_CLEAR", + repoFullName: "owner/repo", + state: "OPEN", + updatedAt: "2026-04-01T00:00:00Z", + }), + ], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [], + }); + + render(() => ); + await waitFor(() => expect(graphqlSpy).toHaveBeenCalledTimes(1)); + await user.click(screen.getByRole("tab", { name: /Dependencies/ })); + await waitFor(() => { + expect(screen.getByText(/abandoned/i)).toBeDefined(); + }); + + // Next refresh has no dashboard issues (relevant.length === 0) while the dependency PR remains open — + // clears abandonedDepsMap and dashboardIssueUrls so the abandoned badge is removed from the PR row + vi.mocked(pollService.fetchAllData).mockResolvedValue({ + issues: [], + pullRequests: [depPRInRepo()], + workflowRuns: [], + errors: [], + }); + await capturedFetchAll!(); + await waitFor(() => { + expect(screen.getByText("axios: 0.27 → 1.0")).toBeDefined(); + expect(screen.queryByText(/abandoned/i)).toBeNull(); + }); + }); +}); diff --git a/tests/lib/dependency-dashboard.test.ts b/tests/lib/dependency-dashboard.test.ts index ae34d3e2..42fa1760 100644 --- a/tests/lib/dependency-dashboard.test.ts +++ b/tests/lib/dependency-dashboard.test.ts @@ -38,6 +38,7 @@ describe("findDashboardIssues", () => { htmlUrl: "https://github.com/owner/repo/issues/42", repoFullName: "owner/repo", number: 42, + updatedAt: "2026-04-10T12:34:56Z", }); const result = findDashboardIssues([issue], NO_BOT_LOGINS); expect(result).toHaveLength(1); @@ -46,9 +47,23 @@ describe("findDashboardIssues", () => { nodeId: "I_renovate_dash_1", repoFullName: "owner/repo", htmlUrl: "https://github.com/owner/repo/issues/42", + updatedAt: "2026-04-10T12:34:56Z", }); }); + it("preserves updatedAt from matching issues", () => { + const issue = makeIssue({ + title: "Dependency Dashboard", + state: "OPEN", + userLogin: "renovate[bot]", + nodeId: "I_renovate_dash_updated", + updatedAt: "2026-05-01T08:30:00Z", + }); + const result = findDashboardIssues([issue], NO_BOT_LOGINS); + expect(result).toHaveLength(1); + expect(result[0]?.updatedAt).toBe("2026-05-01T08:30:00Z"); + }); + it("finds issues from tracked bot logins", () => { const issue = makeIssue({ title: "Dependency Dashboard", diff --git a/tests/lib/notifications.test.ts b/tests/lib/notifications.test.ts index c95f5353..75d03665 100644 --- a/tests/lib/notifications.test.ts +++ b/tests/lib/notifications.test.ts @@ -296,4 +296,35 @@ describe("dispatchNotifications", () => { openSpy.mockRestore(); focusSpy.mockRestore(); }); + + it("does not throw when Notification constructor throws TypeError: Illegal constructor", () => { + NotificationMock.mockImplementation(() => { + throw new TypeError( + "Failed to construct 'Notification': Illegal constructor. Use ServiceWorkerRegistration.showNotification() instead." + ); + }); + + expect(() => + dispatchNotifications(makeNewItems({ issues: [makeIssue(1)] }), makeConfig()) + ).not.toThrow(); + expect(NotificationMock).toHaveBeenCalledTimes(1); + }); + + it("continues dispatching subsequent notifications when the first Notification constructor call throws", () => { + NotificationMock.mockImplementationOnce(() => { + throw new TypeError( + "Failed to construct 'Notification': Illegal constructor. Use ServiceWorkerRegistration.showNotification() instead." + ); + }); + + expect(() => + dispatchNotifications( + makeNewItems({ issues: [makeIssue(1)], pullRequests: [makePr(2)] }), + makeConfig() + ) + ).not.toThrow(); + expect(NotificationMock).toHaveBeenCalledTimes(2); + expect(NotificationMock.mock.calls[0][0]).toContain("Issue 1"); + expect(NotificationMock.mock.calls[1][0]).toContain("PR 2"); + }); }); diff --git a/tests/lib/sentry.test.ts b/tests/lib/sentry.test.ts index 7dba48e8..04329a72 100644 --- a/tests/lib/sentry.test.ts +++ b/tests/lib/sentry.test.ts @@ -215,6 +215,114 @@ describe("beforeSendHandler", () => { expect(result!.exception!.values![0].value).toContain("client_secret=[REDACTED]"); expect(result!.exception!.values![0].value).toContain("ghu_[REDACTED]"); }); + + it("drops transient browser fetch disconnect errors when event.tags.source is a background poll source", () => { + const backgroundSources = [ + "prEnrichment", + "dashboardBodies", + "depPRBodies", + "pr-pagination", + "poll-cycle", + "hot-poll-pr-batch", + "hot-poll-pr-fetch", + "hot-poll-run-fetch", + ]; + + for (const source of backgroundSources) { + expect( + beforeSendHandler({ + tags: { source }, + exception: { values: [{ type: "TypeError", value: "Failed to fetch" }] }, + } as never) + ).toBeNull(); + + expect( + beforeSendHandler({ + tags: { source }, + exception: { + values: [ + { type: "TypeError", value: "Failed to fetch (api.github.com)" }, + { type: "HttpError", value: "Failed to fetch" }, + ], + }, + } as never) + ).toBeNull(); + + expect( + beforeSendHandler({ + tags: { source }, + exception: { + values: [{ type: "TypeError", value: "NetworkError when attempting to fetch resource." }], + }, + } as never) + ).toBeNull(); + + expect( + beforeSendHandler({ + tags: { source }, + exception: { + values: [ + { type: "TypeError", value: "Load failed" }, + { type: "TypeError", value: "Load failed (api.github.com)" }, + ], + }, + } as never) + ).toBeNull(); + } + }); + + it("preserves transient browser fetch disconnect errors when event.tags.source is interactive or absent", () => { + const interactiveSources = [ + "oauth-callback", + "pat-validation", + "pat-replace", + "jira-api-token-connect", + ]; + + for (const source of interactiveSources) { + const event = { + tags: { source }, + exception: { values: [{ type: "TypeError", value: "Failed to fetch" }] }, + }; + expect(beforeSendHandler(event as never)).not.toBeNull(); + } + + const untaggedEvent = { + exception: { values: [{ type: "TypeError", value: "Failed to fetch" }] }, + }; + expect(beforeSendHandler(untaggedEvent as never)).not.toBeNull(); + }); + + it("preserves GraphqlFetchTimeoutError, HTTP status errors, code TypeErrors, and prefix lookalikes on background poll sources", () => { + const preservedValues = [ + { type: "GraphqlFetchTimeoutError", value: "GraphQL request exceeded 20000ms" }, + { type: "HttpError", value: "Bad Gateway (502)" }, + { type: "HttpError", value: "Resource not accessible by integration (403)" }, + { type: "TypeError", value: "Cannot read properties of undefined (reading 'nodes')" }, + { type: "Error", value: "Failed to fetch user profile: 500" }, + { type: "Error", value: "Load failed for module /assets/index.js" }, + ]; + + for (const ex of preservedValues) { + const event = { + tags: { source: "prEnrichment" }, + exception: { values: [ex] }, + }; + expect(beforeSendHandler(event as never)).not.toBeNull(); + } + + // Mixed chain where one entry is a non-disconnect error must also be preserved + const mixedEvent = { + tags: { source: "poll-cycle" }, + exception: { + values: [ + { type: "TypeError", value: "Failed to fetch" }, + { type: "HttpError", value: "HttpError: 500 Internal Server Error" }, + ], + }, + }; + expect(beforeSendHandler(mixedEvent as never)).not.toBeNull(); + }); }); describe("beforeBreadcrumbHandler", () => { diff --git a/tests/services/api-dashboard-bodies.test.ts b/tests/services/api-dashboard-bodies.test.ts index bb7987e3..4643fc00 100644 --- a/tests/services/api-dashboard-bodies.test.ts +++ b/tests/services/api-dashboard-bodies.test.ts @@ -1,6 +1,10 @@ import "fake-indexeddb/auto"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { fetchDashboardIssueBodies, GRAPHQL_BODY_FETCH_TIMEOUT_MS } from "../../src/app/services/api"; +import { + fetchDashboardIssueBodies, + GRAPHQL_BODY_FETCH_TIMEOUT_MS, + GraphqlFetchTimeoutError, +} from "../../src/app/services/api"; import { getClient } from "../../src/app/services/github"; import { pushNotification } from "../../src/app/lib/errors"; import { captureException } from "@sentry/solid"; @@ -276,9 +280,15 @@ describe("fetchDashboardIssueBodies — hung request timeout", () => { expect(result.failedIds.has("N_1")).toBe(true); expect(warnSpy).toHaveBeenCalled(); expect(captureException).toHaveBeenCalledWith( - expect.any(Error), + expect.objectContaining({ + name: "GraphqlFetchTimeoutError", + message: `GraphQL request exceeded ${GRAPHQL_BODY_FETCH_TIMEOUT_MS}ms`, + }), { tags: { source: "dashboardBodies" } } ); + const capturedErr = vi.mocked(captureException).mock.calls[0]?.[0]; + expect(capturedErr).toBeInstanceOf(GraphqlFetchTimeoutError); + expect((capturedErr as Error).name).toBe("GraphqlFetchTimeoutError"); expect(pushNotification).toHaveBeenCalledWith( "dashboardBodies", expect.stringContaining("could not be loaded"),