diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..747e95f5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,83 @@ +# Contributing + +## Development setup + +**Prerequisites:** Node.js 24+, pnpm 10+ + +```bash +git clone https://github.com/gordon-code/github-tracker.git +cd github-tracker +pnpm install +pnpm run dev +``` + +The dev server starts at `http://localhost:5173`. You'll need a GitHub OAuth app client ID in `.env` (copy `.env.example` and fill in your value). + +## Running checks + +```bash +pnpm test # unit tests (Vitest) +pnpm test:e2e # Playwright E2E tests (chromium) +pnpm run typecheck # TypeScript validation +pnpm run screenshot # Capture dashboard screenshot (saves to docs/) +``` + +CI runs typecheck, unit tests, and E2E tests on every PR. Make sure they pass locally before pushing. + +To run a specific test file: + +```bash +pnpm test -- tests/path/to/test.ts +``` + +## Code style + +**TypeScript:** strict mode throughout. Don't use `any` — if you're reaching for it, there's usually a better type. + +**SolidJS patterns:** +- Use `createMemo` for derived state; don't recompute inside JSX +- Use `` and ``/`` instead of ternaries or early returns +- Early returns in components break SolidJS reactivity — use `` as a wrapper instead + +**UI components:** +- Tailwind v4 + daisyUI v5 for styling — use semantic classes (`btn`, `card`, `badge`) over raw utilities where possible +- @kobalte/core for interactive primitives that need accessibility (Select, Tabs, Dialog) +- Don't reach for a custom implementation when Kobalte has a well-tested one + +**Validation:** Zod v4 for all runtime validation. Note that nested `.default({})` doesn't apply inner field defaults — be explicit. + +## Testing + +Tests live in `tests/` and mirror the `src/` directory structure. Test files end in `.test.ts` or `.test.tsx`. + +Factory helpers in `tests/helpers/index.tsx` (`makeIssue`, `makePullRequest`, `makeWorkflowRun`) give you typed test fixtures — use them instead of hand-rolling objects. + +A few things to know: +- `createResource` error state is unreliable in happy-dom; use manual signals with `onMount` + async functions instead +- Kobalte Select uses `aria-labelledby`, which overrides `aria-label` — query by regex in tests +- If you're testing auth state, call `vi.resetModules()` and use dynamic imports — `auth.ts` reads localStorage at module scope + +## Branch and commit conventions + +Branch from `main`. Use one of these prefixes: + +- `feat/` — new functionality +- `fix/` — bug fixes +- `docs/` — documentation only +- `refactor/` — code changes with no behavior change +- `test/` — test additions or fixes +- `chore/` — build, deps, tooling + +Commits follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +type(scope): description +``` + +Scope is optional. Use imperative mood: "add feature", not "adds feature" or "added feature". + +## Pull requests + +All PRs target `main` on `gordon-code/github-tracker`. Keep PRs focused — one feature or fix per PR makes review faster and reverts cleaner. + +In the PR body, describe what changed and why. CI runs typecheck, unit tests, and E2E tests automatically. PRs need a passing CI run before merge. diff --git a/README.md b/README.md index 8bf61dac..d3d08014 100644 --- a/README.md +++ b/README.md @@ -4,41 +4,85 @@ # GitHub Tracker -Dashboard SPA tracking GitHub issues, PRs, and GHA workflow runs across multiple repos/orgs. Built with SolidJS on Cloudflare Workers. +A dashboard for tracking GitHub issues, PRs, and Actions workflow runs across many repos and orgs. Built with SolidJS, deployed on Cloudflare Workers. + +**Live demo:** https://gh.gordoncode.dev + +![Dashboard](docs/dashboard-screenshot.png) ## Features -- **Issues Tab** — Open issues where you're the creator, assignee, or mentioned. Sortable, filterable, paginated. Dependency Dashboard issues hidden by default (toggleable). -- **Pull Requests Tab** — Open PRs with CI check status indicators (green/yellow/red dots). Draft badges, reviewer names. -- **Actions Tab** — GHA workflow runs grouped by repo and workflow. Accordion collapse, PR run toggle. -- **Onboarding Wizard** — Single-step repo selection with search filtering and bulk select. -- **PAT Authentication** — Optional Personal Access Token login as alternative to OAuth. Client-side format validation, detailed token creation instructions for classic and fine-grained PATs. -- **Settings Page** — Refresh interval, notification preferences, theme (light/dark/system), density, GitHub Actions limits. Shows current auth method and hides OAuth-specific options for PAT users. -- **Desktop Notifications** — New item alerts with per-type toggles and batching. -- **Ignore System** — Hide specific items with an "N ignored" badge and unignore popover. -- **Dark Mode** — System-aware with flash prevention via inline script + CSP SHA-256 hash. -- **ETag Caching** — Conditional requests (304s are free against GitHub's rate limit). -- **Auto-refresh** — Background polling keeps data fresh even in hidden tabs (requires notifications scope for efficient 304 change detection); hot poll pauses to save API budget. +### Issues -## Tech Stack +Open issues where you're the creator, assignee, or mentioned. A scope filter lets you toggle between "Involves me" and all activity in the repo. Role badges (author, assignee, mentioned) appear on each item. Dependency Dashboard issues — typically noisy bot aggregators — are hidden by default with a toggle to show them. Filterable, sortable, and paginated. -- **Frontend:** SolidJS + Tailwind CSS v4 + TypeScript (strict) -- **Build:** Vite 8 + @cloudflare/vite-plugin -- **Hosting:** Cloudflare Workers (static assets + OAuth endpoint) -- **API:** @octokit/core with throttling, retry, pagination plugins -- **State:** localStorage (config/view) + IndexedDB (API cache with ETags) -- **Testing:** Vitest 4 (happy-dom for browser, @cloudflare/vitest-pool-workers for Worker) -- **Package Manager:** pnpm +### Pull Requests -## Development +Open PRs with CI status dots (green/yellow/red), review decision badges, size badges (XS–XXL by lines changed), and draft indicators. A "blocked" filter catches PRs where checks are failing or a review requested changes. The scope filter works here too. Reviewer avatars stack for multiple reviewers. -```sh -pnpm install -pnpm run dev # Start Vite dev server -pnpm test # Run unit/component tests -pnpm run typecheck # TypeScript check -pnpm run build # Production build (~241KB JS, ~31KB CSS) -``` +### Actions + +Workflow runs grouped by repo and workflow name, with duration, triggering actor, and conclusion badges. Accordion collapse per group. A toggle hides runs triggered by PRs so you can focus on branch/schedule runs. + +### Personal Summary Strip + +A row of clickable stat chips at the top of the dashboard: assigned issues, PRs awaiting your review, PRs ready to merge, blocked PRs, and running Actions. Clicking any chip applies the matching filter on the relevant tab. + +### Multi-User Tracking + +Track other GitHub users' activity alongside your own. Add up to 10 users; each gets an independent global search across all selected repos. Bot accounts (GitHub App bots) are supported and labelled with a bot badge. Items surface the tracked user's avatar so you can see at a glance who triggered what. + +### Monitor-All Mode + +Per-repo opt-in to see all open issues and PRs in that repo, not just ones involving tracked users. Useful for repos where you want full visibility without filtering by involvement. Monitored repos show a "Monitoring all" badge on their group header. + +### Upstream Repo Discovery + +When you sign in, the app searches for repos you've interacted with that aren't in your selected list and offers to add them as "upstream" repos. These are included in issue/PR fetches but excluded from workflow run polling. + +### Hot Polling + +A second, faster poll loop (default 30s, configurable 10–120s) targets only in-flight items — PRs with pending CI checks and actively running workflow runs. These are updated via minimal GraphQL `nodes()` queries and individual REST calls rather than full re-fetches, keeping API usage low during active development. + +### Desktop Notifications + +Browser notifications for new issues, PRs, and failed runs. Per-type toggles in settings. Notification permission requested on first enable. Uses the GitHub Notifications API as a change-detection gate when the `notifications` scope is available. + +### Repo Pinning and Reordering + +Lock repos to the top of each tab's list so they don't shift around as activity changes. Drag-to-reorder within the locked set. Lock controls appear on hover on desktop, always visible on mobile. + +### State Visibility + +Shimmer animations on items being updated by the hot poll, flash highlights when values change (check status, review decision), and an inline peek on collapsed repo headers that shows what changed for 3 seconds. All animations respect `prefers-reduced-motion`. + +### Star Counts + +Star counts appear in repo group headers, fetched as part of the standard data refresh. + +### Themes + +9 themes: auto (follows system), corporate, cupcake, light, nord, dim, dracula, dark, forest. Theme is applied immediately on selection with no page reload. + +### Ignore System + +Hide specific items with a persistent ignore list. An "N ignored" badge on the repo group header lets you see what's hidden and unignore items without leaving the tab. + +### ETag Caching and Auto-Refresh + +Conditional requests using `If-None-Match` headers — GitHub doesn't count 304 responses against the rate limit. Background polling keeps data fresh even when the tab is hidden (when the notifications scope is available for efficient change detection). + +## Tech Stack + +- SolidJS + @solidjs/router +- Tailwind CSS v4 + daisyUI v5 +- @kobalte/core (accessible headless UI primitives) +- TypeScript (strict) +- Vite 8 + @cloudflare/vite-plugin +- GitHub GraphQL + REST APIs via @octokit/core +- Cloudflare Workers (static assets + OAuth token exchange) +- Vitest 4 (happy-dom) + Playwright (E2E) +- pnpm ## Project Structure @@ -46,46 +90,57 @@ pnpm run build # Production build (~241KB JS, ~31KB CSS) src/ app/ components/ - dashboard/ # DashboardPage, IssuesTab, PullRequestsTab, ActionsTab, ItemRow, WorkflowRunRow, IgnoreBadge + dashboard/ # DashboardPage, IssuesTab, PullRequestsTab, ActionsTab, + # ItemRow, WorkflowRunRow, WorkflowSummaryCard, IgnoreBadge, + # PersonalSummaryStrip layout/ # Header, TabBar, FilterBar onboarding/ # OnboardingWizard, OrgSelector, RepoSelector - settings/ # SettingsPage (7 config sections + data management) - shared/ # FilterInput, LoadingSpinner, StatusDot - pages/ # LoginPage, OAuthCallback + settings/ # SettingsPage, TrackedUsersSection, ThemePicker, Section, SettingRow + shared/ # 18 shared components: FilterInput, FilterChips, StatusDot, + # ReviewBadge, SizeBadge, RoleBadge, SortDropdown, PaginationControls, + # LoadingSpinner, SkeletonRows, ToastContainer, NotificationDrawer, + # RepoLockControls, UserAvatarBadge, ExpandCollapseButtons, + # RepoGitHubLink, ChevronIcon, ExternalLinkIcon + lib/ # 14 modules: format, errors, notifications, oauth, pat, url, + # flashDetection, grouping, reorderHighlight, collections, + # emoji, label-colors, sentry, github-emoji-map.json + pages/ # LoginPage, OAuthCallback, PrivacyPage services/ - api.ts # GitHub API methods (fetchOrgs, fetchRepos, fetchIssues, fetchPRs, fetchWorkflowRuns) + api.ts # GitHub API methods — issues, PRs, workflow runs, user validation, + # upstream repo discovery, tracked user search github.ts # Octokit client factory with ETag caching and rate limit tracking - poll.ts # Poll coordinator with background refresh + hot poll for in-flight items + poll.ts # Poll coordinator: 5-min full refresh + hot poll loop stores/ - auth.ts # OAuth token management (localStorage persistence, validateToken) + auth.ts # OAuth/PAT token management, localStorage persistence cache.ts # IndexedDB cache with TTL eviction and ETag support config.ts # Zod v4-validated config with localStorage persistence - view.ts # View state (tabs, sorting, ignored items, filters) - lib/ - pat.ts # PAT format validation and token creation instruction constants - notifications.ts # Desktop notification permission, detection, and dispatch + view.ts # View state (tabs, sorting, filters, ignored items, locked repos) worker/ index.ts # OAuth token exchange endpoint, CORS, security headers -tests/ - fixtures/ # GitHub API response fixtures (orgs, repos, issues, PRs, runs) - services/ # API service, Octokit client, and poll coordinator tests - stores/ # Config and cache store tests - components/ # ItemRow and IssuesTab component tests - lib/ # Notification tests - worker/ # Worker OAuth endpoint tests +tests/ # 1522 unit/component tests across 69 test files +e2e/ # 14 E2E tests across 2 spec files +``` + +## Development + +```sh +pnpm install +pnpm run dev # Start Vite dev server +pnpm test # Run unit/component tests +pnpm test:e2e # Run Playwright E2E tests +pnpm run typecheck # TypeScript check +pnpm run build # Production build +pnpm run screenshot # Capture dashboard screenshot ``` ## Security -- Strict CSP: `script-src 'self'` (SHA-256 exception for dark mode script only) -- PAT tokens stored in `localStorage` (same key as OAuth tokens) — single-user personal dashboard threat model -- OAuth CSRF protection via `crypto.getRandomValues` state parameter -- CORS locked to exact origin (strict equality, no substring matching) -- Access token stored in `localStorage` under app-specific key; CSP prevents XSS token theft -- Token validation on page load via `GET /user`; 401 clears auth immediately (no silent refresh) -- All GitHub API strings auto-escaped by SolidJS JSX (no innerHTML) -- `repo` scope granted (required for private repos) — app never performs write operations +OAuth tokens are stored in `localStorage` under an app-specific key — this is standard for single-user personal dashboards and matches the threat model here. CSP headers block script injection via Cloudflare (`script-src 'self'` with a SHA-256 exception for the dark-mode initialization script only). An Octokit hook blocks all non-GET requests except `POST /graphql` as a read-only guard — the `repo` scope is required for private repo access, but the app never performs writes. OAuth state is generated with `crypto.getRandomValues` and verified on callback. Token validation runs on every page load via `GET /user`; a 401 clears the stored token immediately. ## Deployment See [DEPLOY.md](./DEPLOY.md) for Cloudflare, OAuth App, and CI/CD setup. + +## Contributing + +See [CONTRIBUTING.md](./CONTRIBUTING.md). diff --git a/docs/dashboard-screenshot.png b/docs/dashboard-screenshot.png new file mode 100644 index 00000000..ad5ef9f0 Binary files /dev/null and b/docs/dashboard-screenshot.png differ diff --git a/e2e/capture-screenshot.spec.ts b/e2e/capture-screenshot.spec.ts new file mode 100644 index 00000000..e8d80cfd --- /dev/null +++ b/e2e/capture-screenshot.spec.ts @@ -0,0 +1,475 @@ +import { test } from "@playwright/test"; + +const RESET_AT = new Date(Date.now() + 3600_000).toISOString(); + +// ── Synthetic data ──────────────────────────────────────────────────────────── + +// GraphQL node IDs are base64-encoded strings like "PR_kgDOBsomeId" +const lightPRNodes = [ + { + id: "PR_kgDOBcAcmeCorp001", + databaseId: 100001, + number: 247, + title: "feat: migrate authentication to passkey support", + state: "OPEN", + isDraft: false, + url: "https://github.com/acme-corp/web-platform/pull/247", + createdAt: "2026-03-28T09:15:00Z", + updatedAt: "2026-04-03T10:30:00Z", + author: { login: "jdoe", avatarUrl: "https://avatars.githubusercontent.com/u/12345?v=4" }, + repository: { nameWithOwner: "acme-corp/web-platform", stargazerCount: 1842 }, + headRefName: "feat/passkey-auth", + baseRefName: "main", + reviewDecision: "APPROVED", + labels: { nodes: [{ name: "feature", color: "0075ca" }, { name: "security", color: "e4e669" }] }, + }, + { + id: "PR_kgDOBcAcmeCorp002", + databaseId: 100002, + number: 312, + title: "fix: resolve N+1 query in user profile endpoint", + state: "OPEN", + isDraft: false, + url: "https://github.com/acme-corp/api-gateway/pull/312", + createdAt: "2026-04-01T14:20:00Z", + updatedAt: "2026-04-03T08:45:00Z", + author: { login: "msmith", avatarUrl: "https://avatars.githubusercontent.com/u/67890?v=4" }, + repository: { nameWithOwner: "acme-corp/api-gateway", stargazerCount: 573 }, + headRefName: "fix/n-plus-one-profile", + baseRefName: "main", + reviewDecision: "CHANGES_REQUESTED", + labels: { nodes: [{ name: "bug", color: "d73a4a" }, { name: "performance", color: "fef2c0" }] }, + }, + { + id: "PR_kgDOBcAcmeCorp003", + databaseId: 100003, + number: 89, + title: "chore: update design token naming to match Figma variables", + state: "OPEN", + isDraft: true, + url: "https://github.com/acme-corp/design-system/pull/89", + createdAt: "2026-04-02T11:00:00Z", + updatedAt: "2026-04-03T09:00:00Z", + author: { login: "jdoe", avatarUrl: "https://avatars.githubusercontent.com/u/12345?v=4" }, + repository: { nameWithOwner: "acme-corp/design-system", stargazerCount: 228 }, + headRefName: "chore/design-token-rename", + baseRefName: "main", + reviewDecision: "REVIEW_REQUIRED", + labels: { nodes: [{ name: "design", color: "bfd4f2" }] }, + }, + { + id: "PR_kgDOBJdoe004", + databaseId: 100004, + number: 15, + title: "docs: add fish shell configuration and plugin bootstrap", + state: "OPEN", + isDraft: false, + url: "https://github.com/jdoe/dotfiles/pull/15", + createdAt: "2026-03-30T16:45:00Z", + updatedAt: "2026-04-02T20:10:00Z", + author: { login: "rlee", avatarUrl: "https://avatars.githubusercontent.com/u/99001?v=4" }, + repository: { nameWithOwner: "jdoe/dotfiles", stargazerCount: 47 }, + headRefName: "docs/fish-shell-guide", + baseRefName: "main", + reviewDecision: "APPROVED", + labels: { nodes: [{ name: "documentation", color: "0075ca" }] }, + }, + { + id: "PR_kgDOBOpenStack005", + databaseId: 100005, + number: 4821, + title: "perf: parallelize volume attachment in compute scheduler", + state: "OPEN", + isDraft: false, + url: "https://github.com/openstack/nova/pull/4821", + createdAt: "2026-03-25T07:30:00Z", + updatedAt: "2026-04-03T11:15:00Z", + author: { login: "jdoe", avatarUrl: "https://avatars.githubusercontent.com/u/12345?v=4" }, + repository: { nameWithOwner: "openstack/nova", stargazerCount: 5102 }, + headRefName: "perf/parallel-volume-attach", + baseRefName: "master", + reviewDecision: "REVIEW_REQUIRED", + labels: { nodes: [{ name: "performance", color: "fef2c0" }, { name: "compute", color: "c5def5" }] }, + }, + { + id: "PR_kgDOBcAcmeCorp006", + databaseId: 100006, + number: 248, + title: "refactor: extract shared rate-limiting middleware", + state: "OPEN", + isDraft: false, + url: "https://github.com/acme-corp/web-platform/pull/248", + createdAt: "2026-04-03T08:00:00Z", + updatedAt: "2026-04-03T12:00:00Z", + author: { login: "jdoe", avatarUrl: "https://avatars.githubusercontent.com/u/12345?v=4" }, + repository: { nameWithOwner: "acme-corp/web-platform", stargazerCount: 1842 }, + headRefName: "refactor/rate-limit-middleware", + baseRefName: "main", + reviewDecision: null, + labels: { nodes: [{ name: "refactor", color: "e4e669" }] }, + }, +]; + +const heavyPRNodes = [ + { + databaseId: 100001, + headRefOid: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + headRepository: { owner: { login: "jdoe" }, nameWithOwner: "jdoe/web-platform-fork" }, + mergeStateStatus: "CLEAN", + assignees: { nodes: [] }, + reviewRequests: { nodes: [] }, + latestReviews: { + totalCount: 2, + nodes: [{ author: { login: "msmith" } }, { author: { login: "rlee" } }], + }, + additions: 312, + deletions: 47, + changedFiles: 8, + comments: { totalCount: 6 }, + reviewThreads: { totalCount: 1 }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + }, + { + databaseId: 100002, + headRefOid: "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", + headRepository: { owner: { login: "msmith" }, nameWithOwner: "acme-corp/api-gateway" }, + mergeStateStatus: "BLOCKED", + assignees: { nodes: [{ login: "jdoe" }] }, + reviewRequests: { nodes: [] }, + latestReviews: { + totalCount: 1, + nodes: [{ author: { login: "jdoe" } }], + }, + additions: 89, + deletions: 23, + changedFiles: 4, + comments: { totalCount: 12 }, + reviewThreads: { totalCount: 3 }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + }, + { + databaseId: 100003, + headRefOid: "c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", + headRepository: { owner: { login: "jdoe" }, nameWithOwner: "acme-corp/design-system" }, + mergeStateStatus: "DRAFT", + assignees: { nodes: [] }, + reviewRequests: { nodes: [{ requestedReviewer: { login: "msmith" } }] }, + latestReviews: { totalCount: 0, nodes: [] }, + additions: 1240, + deletions: 890, + changedFiles: 31, + comments: { totalCount: 2 }, + reviewThreads: { totalCount: 0 }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "PENDING" } } }] }, + }, + { + databaseId: 100004, + headRefOid: "d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5", + headRepository: { owner: { login: "rlee" }, nameWithOwner: "jdoe/dotfiles" }, + mergeStateStatus: "CLEAN", + assignees: { nodes: [] }, + reviewRequests: { nodes: [] }, + latestReviews: { + totalCount: 1, + nodes: [{ author: { login: "jdoe" } }], + }, + additions: 45, + deletions: 8, + changedFiles: 3, + comments: { totalCount: 1 }, + reviewThreads: { totalCount: 0 }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + }, + { + databaseId: 100005, + headRefOid: "e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6", + headRepository: { owner: { login: "jdoe" }, nameWithOwner: "openstack/nova" }, + mergeStateStatus: "BLOCKED", + assignees: { nodes: [] }, + reviewRequests: { nodes: [{ requestedReviewer: { login: "msmith" } }, { requestedReviewer: { login: "rlee" } }] }, + latestReviews: { totalCount: 0, nodes: [] }, + additions: 678, + deletions: 142, + changedFiles: 14, + comments: { totalCount: 8 }, + reviewThreads: { totalCount: 2 }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] }, + }, + { + databaseId: 100006, + headRefOid: "f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1", + headRepository: { owner: { login: "jdoe" }, nameWithOwner: "acme-corp/web-platform" }, + mergeStateStatus: "UNKNOWN", + assignees: { nodes: [] }, + reviewRequests: { nodes: [{ requestedReviewer: { login: "msmith" } }] }, + latestReviews: { totalCount: 0, nodes: [] }, + additions: 156, + deletions: 34, + changedFiles: 6, + comments: { totalCount: 0 }, + reviewThreads: { totalCount: 0 }, + commits: { nodes: [{ commit: { statusCheckRollup: null } }] }, + }, +]; + +const lightIssueNodes = [ + { + databaseId: 200001, + number: 1023, + title: "OAuth login fails on Safari when third-party cookies are blocked", + state: "OPEN", + url: "https://github.com/acme-corp/web-platform/issues/1023", + createdAt: "2026-03-29T10:00:00Z", + updatedAt: "2026-04-02T15:30:00Z", + author: { login: "bwilson", avatarUrl: "https://avatars.githubusercontent.com/u/44444?v=4" }, + assignees: { nodes: [{ login: "jdoe" }] }, + labels: { nodes: [{ name: "bug", color: "d73a4a" }, { name: "auth", color: "e4e669" }] }, + comments: { totalCount: 14 }, + repository: { nameWithOwner: "acme-corp/web-platform", stargazerCount: 1842 }, + }, + { + databaseId: 200002, + number: 445, + title: "Add request tracing headers for distributed debugging", + state: "OPEN", + url: "https://github.com/acme-corp/api-gateway/issues/445", + createdAt: "2026-04-01T09:20:00Z", + updatedAt: "2026-04-03T11:00:00Z", + author: { login: "jdoe", avatarUrl: "https://avatars.githubusercontent.com/u/12345?v=4" }, + assignees: { nodes: [] }, + labels: { nodes: [{ name: "enhancement", color: "a2eeef" }, { name: "observability", color: "c5def5" }] }, + comments: { totalCount: 3 }, + repository: { nameWithOwner: "acme-corp/api-gateway", stargazerCount: 573 }, + }, + { + databaseId: 200003, + number: 62, + title: "Button component missing aria-disabled when loading state active", + state: "OPEN", + url: "https://github.com/acme-corp/design-system/issues/62", + createdAt: "2026-03-31T13:45:00Z", + updatedAt: "2026-04-01T09:00:00Z", + author: { login: "jdoe", avatarUrl: "https://avatars.githubusercontent.com/u/12345?v=4" }, + assignees: { nodes: [{ login: "jdoe" }] }, + labels: { nodes: [{ name: "accessibility", color: "7057ff" }, { name: "bug", color: "d73a4a" }] }, + comments: { totalCount: 7 }, + repository: { nameWithOwner: "acme-corp/design-system", stargazerCount: 228 }, + }, + { + databaseId: 200004, + number: 7834, + title: "Live migration fails when instance has SR-IOV NIC attached", + state: "OPEN", + url: "https://github.com/openstack/nova/issues/7834", + createdAt: "2026-03-20T08:00:00Z", + updatedAt: "2026-04-03T10:00:00Z", + author: { login: "kpatel", avatarUrl: "https://avatars.githubusercontent.com/u/55555?v=4" }, + assignees: { nodes: [{ login: "jdoe" }, { login: "msmith" }] }, + labels: { nodes: [{ name: "bug", color: "d73a4a" }, { name: "compute", color: "c5def5" }, { name: "high-priority", color: "e11d48" }] }, + comments: { totalCount: 31 }, + repository: { nameWithOwner: "openstack/nova", stargazerCount: 5102 }, + }, +]; + +const workflowRunsResponse = { + total_count: 8, + workflow_runs: [ + { + id: 9001, + name: "CI", + status: "completed", + conclusion: "success", + event: "push", + workflow_id: 801, + head_sha: "a1b2c3d4", + head_branch: "main", + run_number: 412, + html_url: "https://github.com/acme-corp/web-platform/actions/runs/9001", + created_at: "2026-04-03T09:00:00Z", + updated_at: "2026-04-03T09:12:00Z", + run_started_at: "2026-04-03T09:00:30Z", + completed_at: "2026-04-03T09:12:00Z", + run_attempt: 1, + display_title: "feat: passkey auth", + actor: { login: "jdoe" }, + }, + { + id: 9002, + name: "Deploy Preview", + status: "completed", + conclusion: "failure", + event: "pull_request", + workflow_id: 802, + head_sha: "b2c3d4e5", + head_branch: "fix/n-plus-one-profile", + run_number: 87, + html_url: "https://github.com/acme-corp/api-gateway/actions/runs/9002", + created_at: "2026-04-03T08:30:00Z", + updated_at: "2026-04-03T08:41:00Z", + run_started_at: "2026-04-03T08:30:15Z", + completed_at: "2026-04-03T08:41:00Z", + run_attempt: 1, + display_title: "fix: resolve N+1 query", + actor: { login: "msmith" }, + }, + { + id: 9003, + name: "CI", + status: "in_progress", + conclusion: null, + event: "pull_request", + workflow_id: 803, + head_sha: "c3d4e5f6", + head_branch: "chore/design-token-rename", + run_number: 34, + html_url: "https://github.com/acme-corp/design-system/actions/runs/9003", + created_at: "2026-04-03T11:55:00Z", + updated_at: "2026-04-03T12:01:00Z", + run_started_at: "2026-04-03T11:55:30Z", + completed_at: null, + run_attempt: 1, + display_title: "chore: design token rename", + actor: { login: "jdoe" }, + }, + ], +}; + +// ── Test ────────────────────────────────────────────────────────────────────── + +test("capture dashboard screenshot", async ({ page }) => { + // 1a. Seed localStorage before any navigation + await page.addInitScript(() => { + // Clear any stale view state (e.g. notification drawer open from a previous run) + localStorage.removeItem("github-tracker:view"); + localStorage.setItem("github-tracker:auth-token", "fake-screenshot-token"); + localStorage.setItem( + "github-tracker:config", + JSON.stringify({ + onboardingComplete: true, + selectedOrgs: ["acme-corp", "jdoe", "openstack"], + selectedRepos: [ + { owner: "acme-corp", name: "web-platform", fullName: "acme-corp/web-platform" }, + { owner: "acme-corp", name: "api-gateway", fullName: "acme-corp/api-gateway" }, + { owner: "acme-corp", name: "design-system", fullName: "acme-corp/design-system" }, + { owner: "jdoe", name: "dotfiles", fullName: "jdoe/dotfiles" }, + { owner: "jdoe", name: "blog", fullName: "jdoe/blog" }, + { owner: "openstack", name: "nova", fullName: "openstack/nova" }, + ], + trackedUsers: [ + { + login: "jdoe", + avatarUrl: "https://avatars.githubusercontent.com/u/12345?v=4", + name: "Jane Doe", + type: "user", + }, + ], + theme: "dark", + }) + ); + }); + + // 1b. Mock all GitHub API routes before navigation. + // Routes are matched in reverse registration order (last registered = highest priority). + // Register the catch-all FIRST so specific routes registered after it take priority. + // The catch-all aborts unmocked requests so they fail loudly instead of silently succeeding. + await page.route("https://api.github.com/**", (route) => route.abort()); + + await page.route("https://api.github.com/notifications*", (route) => + route.fulfill({ status: 200, json: [] }) + ); + + await page.route("https://api.github.com/repos/*/*/actions/runs*", (route) => + route.fulfill({ + status: 200, + json: workflowRunsResponse, + }) + ); + + await page.route("https://api.github.com/graphql", async (route) => { + const body = route.request().postDataJSON() as { variables?: Record } | null; + const variables = body?.variables ?? {}; + + // Heavy backfill: HEAVY_PR_BACKFILL_QUERY uses $ids variable + if ("ids" in variables) { + return route.fulfill({ + status: 200, + json: { + data: { + nodes: heavyPRNodes, + rateLimit: { limit: 5000, remaining: 4900, resetAt: RESET_AT }, + }, + }, + }); + } + + // Light combined search: LIGHT_COMBINED_SEARCH_QUERY uses issueQ/prInvQ/prRevQ variables + if ("issueQ" in variables || "prInvQ" in variables || "prRevQ" in variables) { + return route.fulfill({ + status: 200, + json: { + data: { + issues: { + issueCount: lightIssueNodes.length, + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: lightIssueNodes, + }, + prInvolves: { + issueCount: lightPRNodes.length, + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: lightPRNodes, + }, + prReviewReq: { + issueCount: 0, + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [], + }, + rateLimit: { limit: 5000, remaining: 4950, resetAt: RESET_AT }, + }, + }, + }); + } + + // Fallback: minimal rate limit response + return route.fulfill({ + status: 200, + json: { + data: { + rateLimit: { limit: 5000, remaining: 4900, resetAt: RESET_AT }, + }, + }, + }); + }); + + // /user registered last so it has the highest priority (matched before the catch-all) + await page.route("https://api.github.com/user", (route) => + route.fulfill({ + status: 200, + json: { + login: "jdoe", + name: "Jane Doe", + avatar_url: "https://avatars.githubusercontent.com/u/12345?v=4", + id: 12345, + }, + }) + ); + + // 1c. Navigate and capture + await page.goto("/dashboard"); + await page.getByRole("tablist").waitFor(); + + // Switch to Pull Requests tab for a richer screenshot + await page.getByRole("tab", { name: /pull requests/i }).click(); + await page.getByRole("tab", { name: /pull requests/i, selected: true }).waitFor(); + + // Wait for repo group headers to render (visible even when collapsed) + await page.getByText("acme-corp/web-platform").first().waitFor(); + + // Expand a repo group by clicking its header button (scoped to avoid notification bell) + const repoGroupBtn = page.getByRole("button", { expanded: false }).filter({ hasText: "acme-corp/web-platform" }); + if (await repoGroupBtn.isVisible()) { + await repoGroupBtn.click(); + await page.getByRole("button", { expanded: true }).filter({ hasText: "acme-corp/web-platform" }).waitFor(); + } + + await page.screenshot({ path: "docs/dashboard-screenshot.png" }); +}); diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 00000000..68185738 --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,60 @@ +import type { Page } from "@playwright/test"; + +/** + * Register API route interceptors and inject auth + config into localStorage BEFORE navigation. + * OAuth App uses permanent tokens stored in localStorage — no refresh endpoint needed. + * The app calls validateToken() on load, which GETs /user to verify the token. + */ +export async function setupAuth(page: Page) { + // Catch-all: abort any unmocked GitHub API request so failures are loud + await page.route("https://api.github.com/**", (route) => route.abort()); + + // Intercept /user validation (called by validateToken on page load) + await page.route("https://api.github.com/user", (route) => + route.fulfill({ + status: 200, + json: { + login: "testuser", + name: "Test User", + avatar_url: "https://github.com/testuser.png", + }, + }) + ); + await page.route( + "https://api.github.com/repos/*/*/actions/runs*", + (route) => + route.fulfill({ + status: 200, + json: { total_count: 0, workflow_runs: [] }, + }) + ); + await page.route("https://api.github.com/notifications*", (route) => + route.fulfill({ status: 200, json: [] }) + ); + await page.route("https://api.github.com/graphql", (route) => + route.fulfill({ + status: 200, + json: { + 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" }, + }, + }, + }) + ); + + // Seed localStorage with auth token and config before the page loads + await page.addInitScript(() => { + localStorage.setItem("github-tracker:auth-token", "fake-token"); + localStorage.setItem( + "github-tracker:config", + JSON.stringify({ + selectedOrgs: ["testorg"], + selectedRepos: [{ owner: "testorg", name: "testrepo", fullName: "testorg/testrepo" }], + onboardingComplete: true, + }) + ); + }); +} diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index 31e8a4ed..614f6945 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -1,48 +1,5 @@ -import { test, expect, type Page } from "@playwright/test"; - -/** - * Register API route interceptors and inject auth + config into localStorage BEFORE navigation. - * OAuth App uses permanent tokens stored in localStorage — no refresh endpoint needed. - * The app calls validateToken() on load, which GETs /user to verify the token. - */ -async function setupAuth(page: Page) { - await page.route("https://api.github.com/user", (route) => - route.fulfill({ - status: 200, - json: { - login: "testuser", - name: "Test User", - avatar_url: "https://github.com/testuser.png", - }, - }) - ); - await page.route("https://api.github.com/notifications*", (route) => - route.fulfill({ status: 200, json: [] }) - ); - await page.route("https://api.github.com/graphql", (route) => - route.fulfill({ - status: 200, - json: { - data: { - search: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, - rateLimit: { limit: 5000, remaining: 5000, resetAt: new Date(Date.now() + 3600000).toISOString() }, - }, - }, - }) - ); - - await page.addInitScript(() => { - localStorage.setItem("github-tracker:auth-token", "ghu_fake"); - localStorage.setItem( - "github-tracker:config", - JSON.stringify({ - selectedOrgs: ["testorg"], - selectedRepos: [{ owner: "testorg", name: "testrepo", fullName: "testorg/testrepo" }], - onboardingComplete: true, - }) - ); - }); -} +import { test, expect } from "@playwright/test"; +import { setupAuth } from "./helpers"; // ── Settings page renders ──────────────────────────────────────────────────── diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index c12585b9..22b05fd1 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -1,58 +1,5 @@ -import { test, expect, type Page } from "@playwright/test"; - -/** - * Register API route interceptors and inject auth + config into localStorage BEFORE navigation. - * OAuth App uses permanent tokens stored in localStorage — no refresh endpoint needed. - * The app calls validateToken() on load, which GETs /user to verify the token. - */ -async function setupAuth(page: Page) { - // Intercept /user validation (called by validateToken on page load) - await page.route("https://api.github.com/user", (route) => - route.fulfill({ - status: 200, - json: { - login: "testuser", - name: "Test User", - avatar_url: "https://github.com/testuser.png", - }, - }) - ); - await page.route( - "https://api.github.com/repos/*/actions/runs*", - (route) => - route.fulfill({ - status: 200, - json: { total_count: 0, workflow_runs: [] }, - }) - ); - await page.route("https://api.github.com/notifications*", (route) => - route.fulfill({ status: 200, json: [] }) - ); - await page.route("https://api.github.com/graphql", (route) => - route.fulfill({ - status: 200, - json: { - data: { - search: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, - rateLimit: { limit: 5000, remaining: 5000, resetAt: new Date(Date.now() + 3600000).toISOString() }, - }, - }, - }) - ); - - // Seed localStorage with auth token and config before the page loads - await page.addInitScript(() => { - localStorage.setItem("github-tracker:auth-token", "ghu_fake"); - localStorage.setItem( - "github-tracker:config", - JSON.stringify({ - selectedOrgs: ["testorg"], - selectedRepos: [{ owner: "testorg", name: "testrepo", fullName: "testorg/testrepo" }], - onboardingComplete: true, - }) - ); - }); -} +import { test, expect } from "@playwright/test"; +import { setupAuth } from "./helpers"; // ── Login page ─────────────────────────────────────────────────────────────── @@ -72,12 +19,15 @@ test("OAuth callback flow completes and redirects", async ({ page }) => { sessionStorage.setItem("github-tracker:oauth-state", state); }, fakeState); + // Catch-all: abort any unmocked GitHub API request so failures are loud + await page.route("https://api.github.com/**", (route) => route.abort()); + // Mock the token exchange endpoint and the /user validation await page.route("**/api/oauth/token", (route) => route.fulfill({ status: 200, json: { - access_token: "ghu_fake", + access_token: "fake-token", token_type: "bearer", scope: "repo read:org notifications", }, @@ -105,14 +55,24 @@ test("OAuth callback flow completes and redirects", async ({ page }) => { }) ); }); - // Also intercept downstream dashboard API calls + // Intercept downstream dashboard API calls + await page.route( + "https://api.github.com/repos/*/*/actions/runs*", + (route) => + route.fulfill({ + status: 200, + json: { total_count: 0, workflow_runs: [] }, + }) + ); await page.route("https://api.github.com/graphql", (route) => route.fulfill({ status: 200, json: { data: { - search: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, - rateLimit: { limit: 5000, remaining: 5000, resetAt: new Date(Date.now() + 3600000).toISOString() }, + 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" }, }, }, }) @@ -207,3 +167,24 @@ test("dashboard shows empty state with no data", async ({ page }) => { // The issues tab content area should render (even if empty) await expect(page.getByRole("main")).toBeVisible(); }); + +test("OG and Twitter meta tags are present", async ({ page }) => { + await page.goto("/login"); + await expect(page.locator('meta[property="og:title"]')).toHaveAttribute("content", "GitHub Tracker"); + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute("content", /social-preview\.png$/); + await expect(page.locator('meta[name="twitter:card"]')).toHaveAttribute("content", "summary_large_image"); + await expect(page.locator('meta[name="description"]')).toHaveAttribute("content", /Dashboard for tracking/); +}); + +test("unknown path redirects to login when unauthenticated", async ({ page }) => { + await page.goto("/this-path-does-not-exist"); + // catch-all → Navigate "/" → RootRedirect → validateToken() fails → Navigate "/login" + await expect(page).toHaveURL(/\/login/, { timeout: 10000 }); +}); + +test("unknown path redirects to dashboard when authenticated", async ({ page }) => { + await setupAuth(page); + await page.goto("/this-path-does-not-exist"); + // catch-all → Navigate "/" → RootRedirect → validateToken() succeeds → Navigate "/dashboard" + await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 }); +}); diff --git a/index.html b/index.html index 5f121633..816538e0 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,19 @@ GitHub Tracker + + + + + + + + + + + + + diff --git a/package.json b/package.json index 598102a3..e19fac9c 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "deploy": "wrangler deploy", "typecheck": "tsc --noEmit", "test:e2e": "E2E_PORT=$(node -e \"const s=require('net').createServer();s.listen(0,()=>{console.log(s.address().port);s.close()})\") playwright test", - "test:waf": "bash scripts/waf-smoke-test.sh" + "test:waf": "bash scripts/waf-smoke-test.sh", + "screenshot": "pnpm exec playwright test --config playwright.config.screenshot.ts" }, "dependencies": { "@kobalte/core": "0.13.11", diff --git a/playwright.config.screenshot.ts b/playwright.config.screenshot.ts new file mode 100644 index 00000000..c6dc3cbb --- /dev/null +++ b/playwright.config.screenshot.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from "@playwright/test"; + +const port = Number(process.env.E2E_PORT) || 5173; + +export default defineConfig({ + testDir: "./e2e", + testMatch: ["**/capture-screenshot.spec.ts"], + timeout: 60_000, + use: { + baseURL: `http://localhost:${port}`, + viewport: { width: 1280, height: 800 }, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"], channel: "chrome" }, + }, + ], + webServer: { + command: `pnpm exec vite dev --port ${port} --strictPort`, + url: `http://localhost:${port}`, + timeout: 120_000, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/playwright.config.ts b/playwright.config.ts index 6b72203c..4b2d684f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -4,6 +4,7 @@ const port = Number(process.env.E2E_PORT) || 5173; export default defineConfig({ testDir: "./e2e", + testIgnore: ["**/capture-screenshot.spec.ts"], reporter: [["html", { open: "never" }]], use: { baseURL: `http://localhost:${port}`, diff --git a/src/app/App.tsx b/src/app/App.tsx index 6d63ee16..39731bd8 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -190,6 +190,7 @@ export default function App() { } /> } /> + } /> diff --git a/tests/components/App.test.tsx b/tests/components/App.test.tsx index 21501722..062aaa87 100644 --- a/tests/components/App.test.tsx +++ b/tests/components/App.test.tsx @@ -171,7 +171,7 @@ describe("App", () => { }); }); - it("all routes are registered: /, /login, /oauth/callback, /onboarding, /dashboard, /settings", () => { + it("all routes are registered: /, /login, /oauth/callback, /onboarding, /dashboard, /settings, /*", () => { expect(() => render(() => )).not.toThrow(); });