diff --git a/src/content/github/file/main.tsx b/src/content/github/file/main.tsx index 0e81545..4e8e7ed 100644 --- a/src/content/github/file/main.tsx +++ b/src/content/github/file/main.tsx @@ -18,6 +18,10 @@ import { lineSelector, noVirtLineSelector, } from "./utils/constants"; +import { + waitForElement, + ElementNotFoundError, +} from "./utils/waitForElement"; import { animateAndAnnotateLines, clearAnimation, @@ -78,9 +82,15 @@ async function main(): Promise { print("file not detected at current URL"); return; } - globals.coverageButton = createCoverageButton(); + globals.coverageButton = await createCoverageButton(); await process(urlMetadata); } catch (e) { + if (e instanceof ElementNotFoundError) { + // Raw button not present on this page (e.g. binary files or GitHub DOM + // not yet rendered). This is expected on some file types — skip silently. + print("Raw button not found, skipping coverage overlay"); + return; + } if (Sentry) { Sentry.captureException(e); } @@ -162,16 +172,24 @@ async function process(metadata: FileMetadata): Promise { previousElement: globals.coverageButton!, selectedOptions: selectedFlags, onClick: handleFlagClick, - }).then(({ button, list }) => { - globals.flagsButton = button; - globals.flagsDrop = new Drop({ - target: button, - content: list, - classes: "drop-theme-arrows codecov-z1 codecov-bg-white", - position: "bottom right", - openOn: "click", + }) + .then(({ button, list }) => { + globals.flagsButton = button; + globals.flagsDrop = new Drop({ + target: button, + content: list, + classes: "drop-theme-arrows codecov-z1 codecov-bg-white", + position: "bottom right", + openOn: "click", + }); + }) + .catch((e) => { + if (e instanceof ElementNotFoundError) { + print("Raw button not found, skipping flags dropdown"); + return; + } + throw e; }); - }); } const components = await getComponents(metadata); @@ -201,16 +219,24 @@ async function process(metadata: FileMetadata): Promise { previousElement: globals.coverageButton!, onClick: handleComponentClick, selectedOptions: selectedComponents, - }).then(({ button, list }) => { - globals.componentsButton = button; - globals.componentsDrop = new Drop({ - target: button, - content: list, - classes: "drop-theme-arrows codecov-z1 codecov-bg-white", - position: "bottom right", - openOn: "click", + }) + .then(({ button, list }) => { + globals.componentsButton = button; + globals.componentsDrop = new Drop({ + target: button, + content: list, + classes: "drop-theme-arrows codecov-z1 codecov-bg-white", + position: "bottom right", + openOn: "click", + }); + }) + .catch((e) => { + if (e instanceof ElementNotFoundError) { + print("Raw button not found, skipping components dropdown"); + return; + } + throw e; }); - }); } // If commit sha is defined use that, otherwise just branch name @@ -291,11 +317,8 @@ async function process(metadata: FileMetadata): Promise { animateAndAnnotateLines(noVirtLineSelector, annotateLine); } -function createCoverageButton() { - const rawButton = document.querySelector('[data-testid="raw-button"]'); - if (!rawButton) { - throw new Error("Raw button not found"); - } +async function createCoverageButton() { + const rawButton = await waitForElement('[data-testid="raw-button"]'); const codecovButton = rawButton.cloneNode(true) as HTMLElement; codecovButton.addEventListener("click", (event) => { event.preventDefault(); diff --git a/src/content/github/file/utils/dropdown.tsx b/src/content/github/file/utils/dropdown.tsx index 0a23048..a92e658 100644 --- a/src/content/github/file/utils/dropdown.tsx +++ b/src/content/github/file/utils/dropdown.tsx @@ -1,6 +1,7 @@ import browser from "webextension-polyfill"; import _ from "lodash"; import React from "dom-chef"; +import { waitForElement } from "./waitForElement"; export async function createDropdown({ title, @@ -17,10 +18,7 @@ export async function createDropdown({ previousElement: HTMLElement; selectedOptions: string[]; }) { - const rawButton = document.querySelector('[data-testid="raw-button"]'); - if (!rawButton) { - throw new Error("Raw button not found"); - } + const rawButton = await waitForElement('[data-testid="raw-button"]'); const dropdownButton = rawButton.cloneNode(true) as HTMLElement; const textNode = dropdownButton.querySelector('[data-component="text"]'); if (!textNode || !textNode.parentElement) diff --git a/src/content/github/file/utils/waitForElement.ts b/src/content/github/file/utils/waitForElement.ts new file mode 100644 index 0000000..dc02f3a --- /dev/null +++ b/src/content/github/file/utils/waitForElement.ts @@ -0,0 +1,45 @@ +/** + * Thrown by waitForElement when the element does not appear within the timeout. + */ +export class ElementNotFoundError extends Error { + constructor(selector: string, timeoutMs: number) { + super(`Element "${selector}" not found within ${timeoutMs}ms`); + this.name = "ElementNotFoundError"; + } +} + +/** + * Waits for a DOM element matching `selector` to appear in the document. + * Uses MutationObserver for efficiency. Resolves with the element when found, + * or rejects with an ElementNotFoundError after `timeoutMs` milliseconds. + */ +export function waitForElement( + selector: string, + timeoutMs = 5000 +): Promise { + return new Promise((resolve, reject) => { + const existing = document.querySelector(selector); + if (existing) { + resolve(existing); + return; + } + + let timeoutId: ReturnType; + + const observer = new MutationObserver(() => { + const el = document.querySelector(selector); + if (el) { + clearTimeout(timeoutId); + observer.disconnect(); + resolve(el); + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + + timeoutId = setTimeout(() => { + observer.disconnect(); + reject(new ElementNotFoundError(selector, timeoutMs)); + }, timeoutMs); + }); +}