Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,10 @@ jobs:
- name: Extension typecheck
if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }}
run: npm run extension:typecheck && npm run miner-extension:typecheck
# gittensory-extension has no test suite of its own yet; only the miner extension is covered (#4865).
- name: Extension tests
if: ${{ github.event_name == 'push' || needs.changes.outputs.ui == 'true' }}
run: npm run miner-extension:test
# `npm run ui:build` also regenerates apps/gittensory-ui/public/openapi.json (needed for a
# standalone build), but this step's trigger condition is a strict subset of "OpenAPI drift
# check" above (push || ui==true, vs. push || ui==true || uiContract==true), so whenever this
Expand Down
8 changes: 8 additions & 0 deletions apps/gittensory-miner-extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ The extension does not request the `unlimitedStorage` permission, so a paste is
being parsed or saved once it exceeds a conservative size bound well under `chrome.storage.local`'s default 10 MiB
quota, instead of silently failing to save or leaving storage partially written.

## Testing (#4865)

`npm run test` runs the extension's vitest suite (one `*.test.js` file per source file) under a real coverage
gate — each source file exposes its testable internals on `globalThis` behind a `__GITTENSORY_MINER_EXTENSION_TEST__`
guard (e.g. `background.js` → `globalThis.__gittensoryMinerBackgroundInternals`), so tests import the file directly
(a dynamic `import()`, after setting that flag and any needed `chrome.*`/`fetch` mocks) rather than needing a
separate browser-extension test harness. `vitest.config.ts` documents the measured coverage baseline.

## Host permissions

`manifest.json` grants `https://github.com/*` (for the issue-page content script) plus loopback host permissions —
Expand Down
341 changes: 341 additions & 0 deletions apps/gittensory-miner-extension/background.test.js

Large diffs are not rendered by default.

164 changes: 164 additions & 0 deletions apps/gittensory-miner-extension/content.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const BADGE_SELECTOR = "[data-gittensory-miner-opportunity-badge]";

function stubBadgeApi() {
globalThis.__gittensoryMinerOpportunityBadge = {
formatLastSyncedLabel: (savedAt) => (savedAt ? `last synced ${savedAt}` : null),
renderOpportunityBadgeMarkup: (badge, label) =>
badge ? `<strong>${badge.tier}</strong>${label ? `<span>${label}</span>` : ""}` : "",
};
}

/** Fresh import of content.js at a given pathname, with chrome.runtime.sendMessage mocked to
* resolve with `sendMessageResult`. Must be a dynamic import so the pathname/mock setup below
* runs before content.js's top-level auto-mount logic reads them. */
async function loadContentAt(pathname, sendMessageResult) {
vi.resetModules();
window.history.pushState({}, "", pathname);
globalThis.__GITTENSORY_MINER_EXTENSION_TEST__ = true;
globalThis.chrome = { runtime: { sendMessage: vi.fn().mockResolvedValue(sendMessageResult) } };
stubBadgeApi();
await import("./content.js");
return globalThis.__gittensoryMinerContentInternals;
}

describe("content.js", () => {
beforeEach(() => {
document.body.innerHTML = "";
});

afterEach(() => {
delete globalThis.chrome;
delete globalThis.__gittensoryMinerOpportunityBadge;
});

describe("matchGitHubIssueTarget", () => {
it("matches a GitHub issue URL", async () => {
const internals = await loadContentAt("/", { ok: false });
expect(internals.matchGitHubIssueTarget("/octocat/hello-world/issues/42")).toEqual({
kind: "issue",
owner: "octocat",
repo: "hello-world",
issueNumber: 42,
});
});

it("matches with a trailing slash or trailing segment", async () => {
const internals = await loadContentAt("/", { ok: false });
expect(internals.matchGitHubIssueTarget("/octocat/hello-world/issues/42/")).toMatchObject({ issueNumber: 42 });
});

it("returns null for a non-issue path", async () => {
const internals = await loadContentAt("/", { ok: false });
expect(internals.matchGitHubIssueTarget("/octocat/hello-world/pulls/42")).toBeNull();
expect(internals.matchGitHubIssueTarget("/octocat/hello-world")).toBeNull();
expect(internals.matchGitHubIssueTarget(null)).toBeNull();
});
});

describe("findIssueSidebar", () => {
it("prefers #partial-discussion-sidebar over the other fallbacks", async () => {
const internals = await loadContentAt("/", { ok: false });
document.body.innerHTML = `
<div id="partial-discussion-sidebar"></div>
<div data-testid="issue-sidebar"></div>
`;
expect(internals.findIssueSidebar()?.id).toBe("partial-discussion-sidebar");
});

it("falls back through the remaining selectors in order", async () => {
const internals = await loadContentAt("/", { ok: false });
document.body.innerHTML = `<div class="Layout-sidebar"></div>`;
expect(internals.findIssueSidebar()?.className).toBe("Layout-sidebar");
});

it("returns null when nothing matches", async () => {
const internals = await loadContentAt("/", { ok: false });
document.body.innerHTML = `<div></div>`;
expect(internals.findIssueSidebar()).toBeNull();
});
});

describe("renderOpportunityBadge", () => {
it("removes the container when the payload isn't watched", async () => {
const internals = await loadContentAt("/", { ok: false });
const container = document.createElement("aside");
document.body.appendChild(container);
internals.renderOpportunityBadge(container, { watched: false });
expect(document.body.contains(container)).toBe(false);
});

it("removes the container when watched but there's no badge", async () => {
const internals = await loadContentAt("/", { ok: false });
const container = document.createElement("aside");
document.body.appendChild(container);
internals.renderOpportunityBadge(container, { watched: true, badge: null });
expect(document.body.contains(container)).toBe(false);
});

it("removes the container when the badge API produces no markup", async () => {
const internals = await loadContentAt("/", { ok: false });
globalThis.__gittensoryMinerOpportunityBadge.renderOpportunityBadgeMarkup = () => "";
const container = document.createElement("aside");
document.body.appendChild(container);
internals.renderOpportunityBadge(container, { watched: true, badge: { tier: "High" } });
expect(document.body.contains(container)).toBe(false);
});

it("un-hides the container and fills it with markup when ready, including the last-synced label", async () => {
const internals = await loadContentAt("/", { ok: false });
const container = document.createElement("aside");
container.hidden = true;
document.body.appendChild(container);
internals.renderOpportunityBadge(container, { watched: true, badge: { tier: "High" }, savedAt: 555 }, 1000);
expect(container.hidden).toBe(false);
expect(container.innerHTML).toContain("High");
expect(container.innerHTML).toContain("last synced 555");
});
});

describe("auto-mount on import", () => {
it("mounts and populates the badge on a watched, ready issue page", async () => {
await loadContentAt("/octocat/hello-world/issues/42", {
ok: true,
payload: { watched: true, badge: { tier: "High" }, savedAt: 42 },
});
await vi.waitFor(() => {
const el = document.querySelector(BADGE_SELECTOR);
expect(el).not.toBeNull();
expect(el.hidden).toBe(false);
});
});

it("mounts into the issue sidebar host when present, otherwise floats in the body", async () => {
document.body.innerHTML = `<div id="partial-discussion-sidebar"></div>`;
await loadContentAt("/octocat/hello-world/issues/42", {
ok: true,
payload: { watched: true, badge: { tier: "High" } },
});
await vi.waitFor(() => {
const el = document.querySelector(BADGE_SELECTOR);
expect(el).not.toBeNull();
expect(document.getElementById("partial-discussion-sidebar").contains(el)).toBe(true);
expect(el.className).not.toContain("--floating");
});
});

it("removes the mounted container when the background responds ok:false", async () => {
await loadContentAt("/octocat/hello-world/issues/42", { ok: false });
await vi.waitFor(() => expect(document.querySelector(BADGE_SELECTOR)).toBeNull());
});

it("does not mount anything on a non-issue page", async () => {
await loadContentAt("/octocat/hello-world/pulls/1", { ok: false });
expect(document.querySelector(BADGE_SELECTOR)).toBeNull();
});

it("does not mount a second badge if one is already present", async () => {
document.body.innerHTML = `<aside ${"data-gittensory-miner-opportunity-badge"}="true"></aside>`;
await loadContentAt("/octocat/hello-world/issues/42", { ok: false });
expect(document.querySelectorAll(BADGE_SELECTOR)).toHaveLength(1);
});
});
});
Loading
Loading