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
63 changes: 63 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: CI

on:
push:
branches: [main]
paths:
- 'dashboard/**'
- '.github/workflows/ci.yml'
pull_request:
paths:
- 'dashboard/**'
- '.github/workflows/ci.yml'

env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

permissions:
contents: read

jobs:
test-e2e:
name: Playwright Tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: dashboard
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
cache: npm
cache-dependency-path: dashboard/package-lock.json

- name: Install dashboard dependencies
run: npm ci

- name: Cache Playwright browsers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('dashboard/package-lock.json') }}

- name: Install Playwright browsers and OS dependencies
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium

- name: Install Playwright OS dependencies only
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium

- name: Run Playwright tests
run: npx playwright test

- name: Upload Playwright report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ !cancelled() }}
with:
name: playwright-report
path: dashboard/playwright-report/
retention-days: 30
2 changes: 2 additions & 0 deletions dashboard/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ build/
.env.*
!.env.example
.dev.vars
playwright-report/
test-results/
88 changes: 88 additions & 0 deletions dashboard/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"typecheck": "svelte-kit sync && tsc --noEmit"
"typecheck": "svelte-kit sync && tsc --noEmit",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@sveltejs/kit": "^2.15.0",
"svelte": "^5.0.0",
"uplot": "^1.6.31"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.3",
"@playwright/test": "^1.49.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^26.0.0",
Expand Down
24 changes: 24 additions & 0 deletions dashboard/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? [['github'], ['html']] : 'html',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
});
6 changes: 4 additions & 2 deletions dashboard/src/app.css
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
:root {
--color-bg: #ffffff;
--color-text: #1a1a1a;
--color-muted: #6b7280;
--color-muted: #475569; /* slate-600, 6.8:1 on white — WCAG AA safe */
--color-border: #e5e7eb;
--color-accent: #3b82f6;
--color-accent-faint: rgba(59, 130, 246, 0.08);
--color-link: #1d4ed8; /* blue-700, 6.1:1 on white — WCAG AA safe for text links */
--font-mono: 'SFMono-Regular', 'Consolas', 'Liberation Mono', monospace;
}

@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0f172a;
--color-text: #f1f5f9;
--color-muted: #94a3b8;
--color-muted: #94a3b8; /* 6.6:1 on #0f172a — WCAG AA safe */
--color-border: #1e293b;
--color-accent: #60a5fa;
--color-accent-faint: rgba(96, 165, 250, 0.12);
--color-link: #93c5fd; /* blue-300, 9:1 on #0f172a — WCAG AA safe for text links */
}
}

Expand Down
4 changes: 2 additions & 2 deletions dashboard/src/routes/[owner]/[repo]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
}

.breadcrumb a {
color: var(--color-accent);
color: var(--color-link);
text-decoration: none;
}

Expand Down Expand Up @@ -148,7 +148,7 @@
.metric-tabs button.active {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
color: #0f172a; /* near-black: 4.7:1 on blue-500, 6.6:1 on blue-400 — WCAG AA both modes */
}

.metric-tabs button:not(.active):hover {
Expand Down
5 changes: 5 additions & 0 deletions dashboard/static/_headers
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: default-src 'self'; img-src 'self' https://avatars.githubusercontent.com; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
39 changes: 39 additions & 0 deletions dashboard/tests/a11y/axe.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { mockApi } from '../helpers.js';

for (const colorScheme of ['light', 'dark'] as const) {
test.describe(`projects listing — ${colorScheme} mode`, () => {
test.beforeEach(async ({ page }) => {
await mockApi(page);
await page.emulateMedia({ colorScheme });
await page.goto('/');
// Wait for the async load function to resolve and a project card to render
await page.waitForSelector('.card');
});

test('has no WCAG 2.0 AA violations', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
});
});

test.describe(`project detail page — ${colorScheme} mode`, () => {
test.beforeEach(async ({ page }) => {
await mockApi(page);
await page.emulateMedia({ colorScheme });
await page.goto('/testorg/repo');
// The tablist is part of the page template and appears once the load function resolves
await page.waitForSelector('[role="tablist"]');
});

test('has no WCAG 2.0 AA violations', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
});
});
}
45 changes: 45 additions & 0 deletions dashboard/tests/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Page } from '@playwright/test';

export const MOCK_PROJECT = {
id: 1,
owner_id: 1,
github_repo_id: 1,
repo_name: 'repo',
full_slug: 'testorg/repo',
installation_id: 1,
default_branch: 'main',
badge_enabled: 0,
created_at: '2026-01-01T00:00:00Z',
owner_login: 'testorg',
owner_type: 'Organization',
owner_avatar_url: null, // null avoids external image requests in tests
};

export const MOCK_TREND_EMPTY = {
project: 'testorg/repo',
branch: 'main',
metric: 'coverage',
data: [],
};

/**
* Intercepts all /api/* requests so tests run without a live Worker backend.
* Register this before page.goto() so routes are in place before any fetch fires.
*
* Playwright matches routes in LIFO order — the last-registered handler has the
* highest priority. Register the catch-all first so specific routes registered
* afterwards take precedence over it.
*/
export async function mockApi(page: Page): Promise<void> {
// Catch-all registered first = lowest priority; absorbs unmocked /api/* requests
await page.route('**/api/**', (route) =>
route.fulfill({ status: 404, body: 'Not found' }),
);
// Specific routes registered last = highest priority (override the catch-all)
await page.route('**/api/projects/testorg/repo/metrics*', (route) =>
route.fulfill({ json: MOCK_TREND_EMPTY }),
);
await page.route('**/api/projects', (route) =>
route.fulfill({ json: [MOCK_PROJECT] }),
);
}
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Hono } from 'hono';
import { secureHeaders } from 'hono/secure-headers';
import type { Bindings, Variables } from './types';
import { rollupAndPrune } from './db/rollup';

Expand All @@ -11,6 +12,8 @@ import admin from './routes/admin';

const app = new Hono<{ Bindings: Bindings; Variables: Variables }>();

app.use('*', secureHeaders());

app.route('/api/ci', ci);
app.route('/api/baseline', baseline);
app.route('/api', api);
Expand Down
3 changes: 2 additions & 1 deletion src/lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ export async function getInstallationToken(
);

if (!res.ok) {
throw new Error(`Failed to mint installation token: ${res.status} ${await res.text()}`);
console.error(`Failed to mint installation token: ${res.status}`, await res.text());
throw new Error('Failed to mint installation token');
}

const data = await res.json() as { token: string; expires_at: string };
Expand Down
Loading
Loading