Skip to content
Merged
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
186 changes: 184 additions & 2 deletions e2e/dependencies.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ function makeDepPR(overrides: Record<string, unknown> = {}) {
};
}

function graphqlWithDepPRs(prs: Record<string, unknown>[]) {
function graphqlWithDepPRs(prs: Record<string, unknown>[], issues: Record<string, unknown>[] = []) {
return {
data: {
issues: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
issues: { issueCount: issues.length, pageInfo: { hasNextPage: false, endCursor: null }, nodes: issues },
prInvolves: {
issueCount: prs.length,
pageInfo: { hasNextPage: false, endCursor: null },
Expand All @@ -41,6 +41,69 @@ function graphqlWithDepPRs(prs: Record<string, unknown>[]) {
};
}

function makeDashboardIssue(overrides: Record<string, unknown> = {}) {
return {
id: "issue_dashboard_1",
databaseId: 5001,
number: 1,
title: "Dependency Dashboard",
state: "OPEN",
url: "https://github.com/testorg/testrepo/issues/1",
createdAt: recentDate,
updatedAt: recentDate,
author: { login: "renovate[bot]", avatarUrl: "https://avatars.githubusercontent.com/in/2740" },
labels: { nodes: [] },
assignees: { nodes: [] },
comments: { totalCount: 0 },
repository: { nameWithOwner: "testorg/testrepo", stargazerCount: 10 },
...overrides,
};
}

const ABANDONED_LODASH_BODY = `## Abandoned

| Datasource | Package | Last Updated |
| --- | --- | --- |
| npm | lodash | 2025-01-01 |
`;

/**
* Registers a single /graphql handler that serves the light combined search response for
* every call EXCEPT the dashboard-issue-body follow-up query, which it detects by inspecting
* the GraphQL query text itself — not `variables.request?.apiSource`. That field is consumed
* internally by @octokit/graphql as reserved request-config (used only for the app's own
* analytics hook, see github.ts) and never reaches the serialized wire body. Both
* DASHBOARD_ISSUE_BODIES_QUERY and DEP_PR_BODIES_QUERY (and the heavy PR backfill/enrichment
* query) share the same `$ids` variable name, so `"ids" in variables` alone can't disambiguate
* them either — the query string's selection set (`... on Issue { id body }`, unique to the
* dashboard-body query) is the only reliable signal, confirmed by inspecting the actual
* serialized request bodies at runtime.
*/
async function mockGraphqlWithDashboardBody(
page: import("@playwright/test").Page,
prs: Record<string, unknown>[],
issues: Record<string, unknown>[],
dashboardIssueId: string,
dashboardBody: string
) {
await page.route("https://api.github.com/graphql", (route) => {
const parsed = route.request().postDataJSON() as { query?: string } | null;
const query = parsed?.query ?? "";
if (query.includes("on Issue { id body }")) {
return route.fulfill({
status: 200,
json: {
data: {
nodes: [{ id: dashboardIssueId, body: dashboardBody }],
rateLimit: { limit: 5000, remaining: 4999, resetAt: "2099-01-01T00:00:00Z" },
},
},
});
}
return route.fulfill({ status: 200, json: graphqlWithDepPRs(prs, issues) });
});
}

// ── Dependencies tab visibility ─────────────────────────────────────────────

test("dependencies tab auto-appears when dep bot PRs exist", async ({ page }) => {
Expand Down Expand Up @@ -71,6 +134,83 @@ test("settings toggle hides the dependencies tab", async ({ page }) => {
await expect(page.getByRole("tab", { name: /dependencies/i })).toHaveCount(0);
});

// ── Bot-detection signal isolation ──────────────────────────────────────────

test("author-login-only detection: dependabot[bot] PR appears in Dependencies, not Pull Requests", async ({ page }) => {
const pr = makeDepPR({
author: { login: "dependabot[bot]", avatarUrl: "https://avatars.githubusercontent.com/in/2740" },
headRefName: "some-unrelated-branch",
title: "Fix something unrelated",
labels: { nodes: [] },
});

await setupAuth(page);
await page.route("https://api.github.com/graphql", (route) =>
route.fulfill({ status: 200, json: graphqlWithDepPRs([pr]) })
);
await page.goto("/dashboard");

await expect(page.getByRole("tab", { name: /dependencies/i })).toBeVisible({ timeout: 10_000 });
await page.getByRole("tab", { name: /dependencies/i }).click();
// Only the "mergeable" status group is expanded by default; these light, recent
// PRs land in "Needs Action" (collapsed), so expand all groups before asserting.
await page.getByLabel("Expand all repos").click();
await expect(page.getByText(pr.title)).toBeVisible();

await page.getByRole("tab", { name: /pull requests/i }).click();
await expect(page.getByText(pr.title)).toHaveCount(0);
});

test("branch-prefix-only detection: renovate/ branch PR appears in Dependencies, not Pull Requests", async ({ page }) => {
const pr = makeDepPR({
author: { login: "some-human" },
headRefName: "renovate/foo-1.x",
title: "Fix something unrelated",
labels: { nodes: [] },
});

await setupAuth(page);
await page.route("https://api.github.com/graphql", (route) =>
route.fulfill({ status: 200, json: graphqlWithDepPRs([pr]) })
);
await page.goto("/dashboard");

await expect(page.getByRole("tab", { name: /dependencies/i })).toBeVisible({ timeout: 10_000 });
await page.getByRole("tab", { name: /dependencies/i }).click();
// Only the "mergeable" status group is expanded by default; these light, recent
// PRs land in "Needs Action" (collapsed), so expand all groups before asserting.
await page.getByLabel("Expand all repos").click();
await expect(page.getByText(pr.title)).toBeVisible();

await page.getByRole("tab", { name: /pull requests/i }).click();
await expect(page.getByText(pr.title)).toHaveCount(0);
});

test("label-only detection: dependencies-labeled PR appears in Dependencies, not Pull Requests", async ({ page }) => {
const pr = makeDepPR({
author: { login: "some-human" },
headRefName: "some-feature",
title: "Fix something unrelated",
labels: { nodes: [{ name: "dependencies" }] },
});

await setupAuth(page);
await page.route("https://api.github.com/graphql", (route) =>
route.fulfill({ status: 200, json: graphqlWithDepPRs([pr]) })
);
await page.goto("/dashboard");

await expect(page.getByRole("tab", { name: /dependencies/i })).toBeVisible({ timeout: 10_000 });
await page.getByRole("tab", { name: /dependencies/i }).click();
// Only the "mergeable" status group is expanded by default; these light, recent
// PRs land in "Needs Action" (collapsed), so expand all groups before asserting.
await page.getByLabel("Expand all repos").click();
await expect(page.getByText(pr.title)).toBeVisible();

await page.getByRole("tab", { name: /pull requests/i }).click();
await expect(page.getByText(pr.title)).toHaveCount(0);
});

// ── Status groups ───────────────────────────────────────────────────────────

test("status groups render correctly", async ({ page }) => {
Expand Down Expand Up @@ -103,3 +243,45 @@ test("status groups render correctly", async ({ page }) => {
await expect(page.getByText("Needs Action")).toBeVisible();
await expect(page.getByText("Stale")).toBeVisible();
});

// ── Abandoned-dependency pill ────────────────────────────────────────────────

test("abandoned dep badge links to the Dependency Dashboard issue when matched", async ({ page }) => {
const dashboardIssue = makeDashboardIssue();
// makeDepPR()'s default title is "Bump lodash from 4.17.20 to 4.17.21" — matches
// the "lodash" row in ABANDONED_LODASH_BODY via matchAbandonedToPr()'s substring match.
const pr = makeDepPR();

await setupAuth(page);
await mockGraphqlWithDashboardBody(page, [pr], [dashboardIssue], dashboardIssue.id, ABANDONED_LODASH_BODY);
await page.goto("/dashboard");

await expect(page.getByRole("tab", { name: /dependencies/i })).toBeVisible({ timeout: 10_000 });
await page.getByRole("tab", { name: /dependencies/i }).click();
await page.getByLabel("Expand all repos").click();

// The dashboard-issue-body follow-up query only fires after the first full poll
// completes, so the badge starts as absent/plain-text and becomes a link asynchronously.
const abandonedLink = page.getByRole("link", { name: /abandoned/i });
await expect(abandonedLink).toBeVisible({ timeout: 10_000 });
await expect(abandonedLink).toHaveAttribute("href", dashboardIssue.url);
});

test("abandoned dep badge renders as plain text when title-suffix heuristic matches with no dashboard issue", async ({ page }) => {
const pr = makeDepPR({
title: "Bump left-pad from 1.0.0 to 1.0.1 - abandoned",
});

await setupAuth(page);
await page.route("https://api.github.com/graphql", (route) =>
route.fulfill({ status: 200, json: graphqlWithDepPRs([pr]) })
);
await page.goto("/dashboard");

await expect(page.getByRole("tab", { name: /dependencies/i })).toBeVisible({ timeout: 10_000 });
await page.getByRole("tab", { name: /dependencies/i }).click();
await page.getByLabel("Expand all repos").click();

await expect(page.getByText("Abandoned")).toBeVisible();
await expect(page.getByRole("link", { name: /abandoned/i })).toHaveCount(0);
});
100 changes: 100 additions & 0 deletions e2e/notifications.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { test, expect, type Page } from "@playwright/test";
import { setupAuth } from "./helpers";

const emptyGraphqlFixture = {
data: {
issues: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
prInvolves: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
prReviewReq: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] },
rateLimit: { limit: 5000, remaining: 4999, resetAt: "2099-01-01T00:00:00Z" },
},
};

/**
* Fakes a real GitHub primary-rate-limit response on the FIRST /graphql POST so
* @octokit/plugin-throttling's onRateLimit handler fires (github.ts:86-98), which calls
* pushNotification("rate-limit", ..., "warning", true). The plugin then auto-retries
* once internally (retryCount < 1 => true); the SECOND POST returns normal fixture data
* so the dashboard loads successfully afterward.
*/
async function triggerRateLimitOnFirstGraphqlCall(page: Page) {
let callCount = 0;
await page.route("https://api.github.com/graphql", (route) => {
callCount += 1;
if (callCount === 1) {
const resetEpochSeconds = Math.floor(Date.now() / 1000) + 1;
return route.fulfill({
status: 403,
headers: {
"x-ratelimit-limit": "5000",
"x-ratelimit-remaining": "0",
"x-ratelimit-reset": String(resetEpochSeconds),
// Cross-origin fetch() only exposes safelisted response headers to JS unless the
// server opts in via Access-Control-Expose-Headers (the real GitHub API does this) —
// without it, @octokit/plugin-throttling can't see x-ratelimit-remaining and never
// fires onRateLimit; the request just fails and surfaces as a generic api error instead.
"access-control-expose-headers": "x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset",
},
json: { message: "API rate limit exceeded for token." },
});
}
return route.fulfill({ status: 200, json: emptyGraphqlFixture });
});
}

async function openDrawerFromBell(page: Page) {
await page.goto("/dashboard");

const bell = page.getByRole("button", { name: /Notifications, \d+ unread/i });
await expect(bell).toBeVisible({ timeout: 15_000 });
await bell.click();

const overlay = page.getByTestId("notification-overlay");
await expect(overlay).toBeVisible();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// ToastContainer renders the same notification as a toast concurrently (expected,
// per Task 7 plan) — scope to the dialog to avoid a strict-mode double match.
await expect(dialog.getByText(/Rate limit hit/i)).toBeVisible();
return overlay;
}

// ── Open/close animation via real rate-limit-triggered notification ─────────

test("rate-limit notification opens the drawer and Escape closes it", async ({ page }) => {
await setupAuth(page);
await triggerRateLimitOnFirstGraphqlCall(page);

const overlay = await openDrawerFromBell(page);

await page.keyboard.press("Escape");
await expect(overlay).toBeHidden();
});

test("rate-limit notification opens the drawer and the close button closes it", async ({ page }) => {
await setupAuth(page);
await triggerRateLimitOnFirstGraphqlCall(page);

const overlay = await openDrawerFromBell(page);

await page.getByLabel("Close notifications").click();
await expect(overlay).toBeHidden();
});

// ── prefers-reduced-motion ───────────────────────────────────────────────────

test("drawer still opens and closes correctly under prefers-reduced-motion", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await setupAuth(page);
await triggerRateLimitOnFirstGraphqlCall(page);

const overlay = await openDrawerFromBell(page);

// Under prefers-reduced-motion, index.css disables the drawer keyframe animation
// entirely (`.drawer-overlay[data-expanded] { animation: none }`), unlike the
// normal `overlay-fade-in` animation applied without the media query match.
await expect(overlay).toHaveCSS("animation-name", "none");

await page.getByLabel("Close notifications").click();
await expect(overlay).toBeHidden();
});
5 changes: 5 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
packages:
- "mcp"

allowBuilds:
esbuild: true
sharp: true
workerd: true

strictDepBuilds: false
38 changes: 11 additions & 27 deletions src/app/components/dashboard/IgnoreBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createSignal, For, Show } from "solid-js";
import { Popover } from "@kobalte/core/popover";
import type { IgnoredItem } from "../../stores/view";
import { Tooltip } from "../shared/Tooltip";

Expand Down Expand Up @@ -28,12 +29,6 @@ function formatDate(ts: number): string {
export default function IgnoreBadge(props: IgnoreBadgeProps) {
const [open, setOpen] = createSignal(false);

function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
setOpen(false);
}
}

function handleUnignoreAll() {
for (const item of props.items) {
props.onUnignore(item.id);
Expand All @@ -43,35 +38,24 @@ export default function IgnoreBadge(props: IgnoreBadgeProps) {

return (
<Show when={props.items.length > 0}>
<div class="relative">
<Popover open={open()} onOpenChange={setOpen} placement="bottom-end">
<Tooltip content={`${props.items.length} ignored item${props.items.length === 1 ? "" : "s"}`}>
<button
onClick={() => setOpen((v) => !v)}
<Popover.Trigger
as="button"
class="btn btn-ghost btn-sm compact:btn-xs relative"
aria-haspopup="true"
aria-expanded={open()}
aria-label={`${props.items.length} ignored items`}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 compact:h-3.5 compact:w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z" clip-rule="evenodd" />
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z" />
</svg>
<span class="badge badge-neutral badge-xs absolute -top-1 -right-1 compact:text-[8px] compact:-top-0.5 compact:-right-0.5 compact:px-0.5 compact:min-w-3 compact:h-3">{props.items.length}</span>
</button>
</Popover.Trigger>
</Tooltip>

<Show when={open()}>
{/* Backdrop */}
<div
class="fixed inset-0 z-10"
onClick={handleBackdropClick}
aria-hidden="true"
/>

{/* Popover */}
<div
class="absolute right-0 top-full mt-1 z-20 w-80 bg-base-100 border border-base-300 rounded-lg shadow-lg"
role="dialog"
<Popover.Portal>
<Popover.Content
class="w-80 bg-base-100 border border-base-300 rounded-lg shadow-lg z-50"
aria-label="Ignored items"
>
<div class="px-3 py-2 border-b border-base-300 text-xs font-semibold text-base-content/60 uppercase tracking-wide">
Expand Down Expand Up @@ -120,9 +104,9 @@ export default function IgnoreBadge(props: IgnoreBadgeProps) {
Unignore All
</button>
</div>
</div>
</Show>
</div>
</Popover.Content>
</Popover.Portal>
</Popover>
</Show>
);
}
Loading