# Jenkins Playwright Pipeline Dashboard — Implementation Plan

> **Status: COMPLETE** — All 11 tasks implemented and verified. 28 tests passing. Branch: `feat/jenkins-playwright-dashboard`

**Goal:** Build a local dashboard that polls Jenkins for Playwright test pipeline statuses and displays them in a color-coded card grid.

**Architecture:** Node.js backend (Express + Drizzle ORM + SQLite) polls Jenkins on a schedule, stores runs/stages/URLs, and serves a REST API. React frontend (Vite + Tailwind) renders a single-page dashboard with auto-refreshing card grid. Separate discovery script uses `gh` CLI to find pipelines.

**Tech Stack:** TypeScript, Express, Drizzle ORM, better-sqlite3, dotenv, Vite, React, Tailwind CSS, Vitest

**Spec:** `docs/superpowers/specs/2026-03-16-jenkins-playwright-dashboard-design.md`

### Post-plan improvements (discovered during real-world testing)
- `.env` file support via dotenv
- GitHub discovery caching in SQLite (24h TTL, `--force` to bypass)
- Org folder in Jenkins URLs (`/job/{org}/job/{repo}/job/{branch}`)
- Master-only branch filtering in discovery
- Split `stagePattern` (E2E) and `jenkinsfilePattern` (playwrightTest) config
- Handle Jenkins `FAILED` status (not just `FAILURE`)
- Detect cascaded E2E failures as yellow (upstream stage failed first)
- Run-level `IN_PROGRESS` detection (wfapi only returns started stages)
- Blue pulsing border + BUILDING label for in-progress pipelines
- Poll cycle summary logging
- Cleaner single-line error logging

---

## Chunk 1: Project Foundation & Database

### Task 1: Project Setup & Dependencies

**Files:**
- Modify: `package.json`
- Create: `tsconfig.json`
- Create: `tsconfig.server.json`
- Create: `drizzle.config.ts`
- Create: `config.json`
- Create: `.gitignore`

- [x] **Step 1: Update package.json with dependencies and scripts**

```json
{
  "name": "dash",
  "version": "1.0.0",
  "description": "Jenkins Playwright Pipeline Dashboard",
  "type": "module",
  "scripts": {
    "dev": "concurrently \"pnpm dev:server\" \"pnpm dev:client\"",
    "dev:server": "tsx watch server/index.ts",
    "dev:client": "cd client && vite",
    "discover": "tsx server/discover.ts",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "tsx server/db/migrate.ts",
    "test": "vitest run",
    "test:watch": "vitest"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "packageManager": "pnpm@10.29.2",
  "dependencies": {
    "better-sqlite3": "^11.0.0",
    "concurrently": "^9.0.0",
    "cors": "^2.8.5",
    "drizzle-orm": "^0.39.0",
    "express": "^4.21.0"
  },
  "devDependencies": {
    "@types/better-sqlite3": "^7.6.12",
    "@types/cors": "^2.8.17",
    "@types/express": "^5.0.0",
    "@types/node": "^22.0.0",
    "drizzle-kit": "^0.30.0",
    "tsx": "^4.19.0",
    "typescript": "^5.7.0",
    "vitest": "^3.0.0"
  }
}
```

- [x] **Step 2: Create tsconfig.json**

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true,
    "outDir": "dist",
    "rootDir": ".",
    "resolveJsonModule": true,
    "declaration": true
  },
  "include": ["server/**/*.ts", "drizzle.config.ts"],
  "exclude": ["node_modules", "client", "dist"]
}
```

- [x] **Step 3: Create tsconfig.server.json**

```json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "rootDir": "server"
  },
  "include": ["server/**/*.ts"]
}
```

- [x] **Step 4: Create drizzle.config.ts**

```typescript
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./server/db/schema.ts",
  out: "./server/db/migrations",
  dialect: "sqlite",
  dbCredentials: {
    url: "./data/dash.db",
  },
});
```

- [x] **Step 5: Create config.json**

```json
{
  "jenkins": {
    "url": "https://jenkins.example.com",
    "user": "",
    "token": ""
  },
  "polling": {
    "intervalSeconds": 30
  },
  "playwright": {
    "stagePattern": "playwrightTest",
    "urlPatterns": ["Playwright Report Link:\\s*(https?://\\S+)"]
  },
  "github": {
    "org": ""
  }
}
```

- [x] **Step 6: Create .gitignore**

```
node_modules/
dist/
data/
.superpowers/
*.db
*.db-journal
```

- [x] **Step 7: Install dependencies**

Run: `pnpm install`
Expected: Clean install, lock file generated.

- [x] **Step 8: Commit**

```bash
git add package.json pnpm-lock.yaml tsconfig.json tsconfig.server.json drizzle.config.ts config.json .gitignore
git commit -m "feat: project setup with dependencies and config"
```

---

### Task 2: Database Schema (Drizzle)

**Files:**
- Create: `server/db/schema.ts`
- Create: `server/db/index.ts`
- Create: `server/db/migrate.ts`
- Create: `server/config.ts`
- Test: `server/db/schema.test.ts`

- [x] **Step 1: Create server/config.ts (shared config loader)**

```typescript
import { readFileSync } from "fs";
import { join } from "path";

export interface Config {
  jenkins: {
    url: string;
    user: string;
    token: string;
  };
  polling: {
    intervalSeconds: number;
  };
  playwright: {
    stagePattern: string;
    urlPatterns: string[];
  };
  github: {
    org: string;
  };
}

export function loadConfig(): Config {
  const configPath = join(process.cwd(), "config.json");
  const raw = readFileSync(configPath, "utf-8");
  const config: Config = JSON.parse(raw);

  // Environment variable overrides
  if (process.env.JENKINS_URL) config.jenkins.url = process.env.JENKINS_URL;
  if (process.env.JENKINS_USER) config.jenkins.user = process.env.JENKINS_USER;
  if (process.env.JENKINS_TOKEN) config.jenkins.token = process.env.JENKINS_TOKEN;

  return config;
}
```

- [x] **Step 2: Create server/db/schema.ts**

```typescript
import { sqliteTable, text, integer, unique } from "drizzle-orm/sqlite-core";

export const pipelines = sqliteTable("pipelines", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  name: text("name").notNull(),
  jenkinsUrl: text("jenkins_url").notNull().unique(),
  lastPolledAt: integer("last_polled_at"),
  createdAt: integer("created_at").notNull().$defaultFn(() => Date.now()),
});

export const runs = sqliteTable(
  "runs",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    pipelineId: integer("pipeline_id")
      .notNull()
      .references(() => pipelines.id, { onDelete: "cascade" }),
    buildNumber: integer("build_number").notNull(),
    status: text("status", { enum: ["green", "red", "yellow", "building"] }).notNull(),
    startedAt: integer("started_at"),
    durationMs: integer("duration_ms"),
    fetchedAt: integer("fetched_at").notNull().$defaultFn(() => Date.now()),
  },
  (table) => [unique().on(table.pipelineId, table.buildNumber)]
);

export const stages = sqliteTable("stages", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  runId: integer("run_id")
    .notNull()
    .references(() => runs.id, { onDelete: "cascade" }),
  name: text("name").notNull(),
  status: text("status", {
    enum: ["SUCCESS", "FAILURE", "IN_PROGRESS", "NOT_EXECUTED"],
  }).notNull(),
  durationMs: integer("duration_ms"),
  isPlaywright: integer("is_playwright").notNull().default(0),
});

export const extractedUrls = sqliteTable("extracted_urls", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  runId: integer("run_id")
    .notNull()
    .references(() => runs.id, { onDelete: "cascade" }),
  stageId: integer("stage_id")
    .notNull()
    .references(() => stages.id, { onDelete: "cascade" }),
  url: text("url").notNull(),
  label: text("label"),
  createdAt: integer("created_at").notNull().$defaultFn(() => Date.now()),
});
```

- [x] **Step 3: Create server/db/index.ts**

```typescript
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { join } from "path";
import { mkdirSync } from "fs";
import * as schema from "./schema.js";

export function createDb(dbPath?: string) {
  const resolvedPath = dbPath ?? join(process.cwd(), "data", "dash.db");
  if (!dbPath) {
    mkdirSync(join(process.cwd(), "data"), { recursive: true });
  }
  const sqlite = new Database(resolvedPath);
  sqlite.pragma("journal_mode = WAL");
  sqlite.pragma("foreign_keys = ON");
  return drizzle(sqlite, { schema });
}

// Default instance for production use
export const db = createDb();
export type DB = ReturnType<typeof createDb>;
```

- [x] **Step 4: Create server/db/migrate.ts**

```typescript
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { db } from "./index.js";
import { join } from "path";

migrate(db, { migrationsFolder: join(process.cwd(), "server/db/migrations") });
console.log("Migrations applied successfully.");
```

- [x] **Step 5: Generate initial migration**

Run: `pnpm db:generate`
Expected: Migration files created in `server/db/migrations/`.

- [x] **Step 6: Create data directory and run migration**

Run: `mkdir -p data && pnpm db:migrate`
Expected: "Migrations applied successfully."

- [x] **Step 7: Write test for schema**

Create `server/db/schema.test.ts`:

```typescript
import { describe, it, expect, beforeEach } from "vitest";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { eq } from "drizzle-orm";
import * as schema from "./schema.js";
import { join } from "path";

function createTestDb() {
  const sqlite = new Database(":memory:");
  sqlite.pragma("foreign_keys = ON");
  const db = drizzle(sqlite, { schema });
  migrate(db, { migrationsFolder: join(process.cwd(), "server/db/migrations") });
  return db;
}

describe("schema", () => {
  let db: ReturnType<typeof createTestDb>;

  beforeEach(() => {
    db = createTestDb();
  });

  it("inserts and retrieves a pipeline", () => {
    const result = db
      .insert(schema.pipelines)
      .values({ name: "my-repo/main", jenkinsUrl: "https://jenkins.example.com/job/my-repo/job/main" })
      .returning()
      .get();

    expect(result.name).toBe("my-repo/main");
    expect(result.id).toBe(1);
  });

  it("enforces unique jenkins_url on pipelines", () => {
    const url = "https://jenkins.example.com/job/repo/job/main";
    db.insert(schema.pipelines).values({ name: "repo/main", jenkinsUrl: url }).run();

    expect(() => {
      db.insert(schema.pipelines).values({ name: "repo/main-dup", jenkinsUrl: url }).run();
    }).toThrow();
  });

  it("enforces unique (pipeline_id, build_number) on runs", () => {
    const pipeline = db
      .insert(schema.pipelines)
      .values({ name: "repo/main", jenkinsUrl: "https://j.example.com/job/repo/job/main" })
      .returning()
      .get();

    db.insert(schema.runs)
      .values({ pipelineId: pipeline.id, buildNumber: 1, status: "green" })
      .run();

    expect(() => {
      db.insert(schema.runs)
        .values({ pipelineId: pipeline.id, buildNumber: 1, status: "red" })
        .run();
    }).toThrow();
  });

  it("cascades deletes from runs to stages and extracted_urls", () => {
    const pipeline = db
      .insert(schema.pipelines)
      .values({ name: "repo/main", jenkinsUrl: "https://j.example.com/job/repo" })
      .returning()
      .get();

    const run = db
      .insert(schema.runs)
      .values({ pipelineId: pipeline.id, buildNumber: 1, status: "red" })
      .returning()
      .get();

    const stage = db
      .insert(schema.stages)
      .values({ runId: run.id, name: "playwrightTest", status: "FAILURE", isPlaywright: 1 })
      .returning()
      .get();

    db.insert(schema.extractedUrls)
      .values({ runId: run.id, stageId: stage.id, url: "https://report.example.com" })
      .run();

    // Delete the run
    db.delete(schema.runs).where(eq(schema.runs.id, run.id)).run();

    // Stages and URLs should be gone
    const remainingStages = db.select().from(schema.stages).all();
    const remainingUrls = db.select().from(schema.extractedUrls).all();

    expect(remainingStages).toHaveLength(0);
    expect(remainingUrls).toHaveLength(0);
  });
});
```

- [x] **Step 8: Run tests to verify schema works**

Run: `pnpm test server/db/schema.test.ts`
Expected: All 4 tests pass.

- [x] **Step 9: Commit**

```bash
git add server/db/ server/config.ts
git commit -m "feat: database schema with Drizzle ORM and migrations"
```

---

## Chunk 2: Jenkins Client & URL Extractor

### Task 3: URL Extractor

**Files:**
- Create: `server/poller/url-extractor.ts`
- Test: `server/poller/url-extractor.test.ts`

- [x] **Step 1: Write failing test for URL extractor**

Create `server/poller/url-extractor.test.ts`:

```typescript
import { describe, it, expect } from "vitest";
import { extractUrls } from "./url-extractor.js";

describe("extractUrls", () => {
  it("extracts labeled URLs from console output", () => {
    const consoleText = `
Running tests...
Playwright Report Link: https://playwright.theorchard.io/api/bucket/prod/report/frontend-auth-main-QA-1958/merged-report
Tests completed.
    `;

    const results = extractUrls(consoleText, []);
    expect(results).toEqual([
      {
        label: "Playwright Report Link",
        url: "https://playwright.theorchard.io/api/bucket/prod/report/frontend-auth-main-QA-1958/merged-report",
      },
    ]);
  });

  it("extracts multiple URLs from different lines", () => {
    const consoleText = `
Playwright Report Link: https://playwright.example.com/report/123
Trace File: https://playwright.example.com/trace/123.zip
    `;

    const results = extractUrls(consoleText, []);
    expect(results).toHaveLength(2);
    expect(results[0].label).toBe("Playwright Report Link");
    expect(results[1].label).toBe("Trace File");
  });

  it("uses custom patterns from config", () => {
    const consoleText = `
[INFO] artifact uploaded to https://artifacts.example.com/build/99/report.html
    `;

    const customPatterns = ["artifact uploaded to (https?://\\S+)"];
    const results = extractUrls(consoleText, customPatterns);
    expect(results).toHaveLength(1);
    expect(results[0].url).toBe("https://artifacts.example.com/build/99/report.html");
    // Label is extracted from text before the URL
    expect(results[0].label).toContain("artifact uploaded to");
  });

  it("returns empty array when no URLs found", () => {
    const results = extractUrls("no urls here", []);
    expect(results).toEqual([]);
  });
});
```

- [x] **Step 2: Run test to verify it fails**

Run: `pnpm test server/poller/url-extractor.test.ts`
Expected: FAIL — module not found.

- [x] **Step 3: Implement url-extractor.ts**

```typescript
export interface ExtractedUrl {
  label: string;
  url: string;
}

// Default pattern: matches lines like "Some Label: https://..."
// Only matches labels that look like human-readable text (letters, spaces, dashes)
const DEFAULT_PATTERN = /^([\w][\w\s-]{1,60}):\s*(https?:\/\/\S+)/;

export function extractUrls(
  consoleText: string,
  customPatterns: string[]
): ExtractedUrl[] {
  const results: ExtractedUrl[] = [];
  const seen = new Set<string>();
  const lines = consoleText.split("\n");

  // Compile custom patterns once
  const compiledCustom = customPatterns.map((p) => new RegExp(p));

  for (const line of lines) {
    const trimmed = line.trim();
    if (!trimmed) continue;

    // Try custom patterns first (more specific)
    let matched = false;
    for (const regex of compiledCustom) {
      const match = trimmed.match(regex);
      if (match && match[1]) {
        const url = match[1];
        if (!seen.has(url)) {
          // Extract label: text before the URL, cleaned up
          const labelEnd = trimmed.indexOf(url);
          const label = trimmed.substring(0, labelEnd).replace(/[\s:]+$/, "").trim();
          results.push({ label: label || "Link", url });
          seen.add(url);
        }
        matched = true;
        break;
      }
    }
    if (matched) continue;

    // Try default pattern: "Human Label: https://..."
    const defaultMatch = trimmed.match(DEFAULT_PATTERN);
    if (defaultMatch) {
      const label = defaultMatch[1].trim();
      const url = defaultMatch[2];
      if (!seen.has(url)) {
        results.push({ label, url });
        seen.add(url);
      }
    }
  }

  return results;
}
```

- [x] **Step 4: Run tests to verify they pass**

Run: `pnpm test server/poller/url-extractor.test.ts`
Expected: All 4 tests pass.

- [x] **Step 5: Commit**

```bash
git add server/poller/url-extractor.ts server/poller/url-extractor.test.ts
git commit -m "feat: URL extractor for Playwright console output"
```

---

### Task 4: Jenkins Client

**Files:**
- Create: `server/poller/jenkins-client.ts`
- Test: `server/poller/jenkins-client.test.ts`

- [x] **Step 1: Write failing test for Jenkins client**

Create `server/poller/jenkins-client.test.ts`:

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import { JenkinsClient } from "./jenkins-client.js";

// Mock global fetch
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);

describe("JenkinsClient", () => {
  let client: JenkinsClient;

  beforeEach(() => {
    mockFetch.mockReset();
    client = new JenkinsClient({
      url: "https://jenkins.example.com",
      user: "admin",
      token: "secret",
    });
  });

  it("fetches runs for a pipeline", async () => {
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: async () => [
        {
          id: "42",
          name: "#42",
          status: "SUCCESS",
          startTimeMillis: 1710600000000,
          durationMillis: 120000,
        },
      ],
    });

    const runs = await client.getRuns("/job/my-repo/job/main");
    expect(runs).toHaveLength(1);
    expect(runs[0].id).toBe("42");
    expect(mockFetch).toHaveBeenCalledWith(
      "https://jenkins.example.com/job/my-repo/job/main/wfapi/runs",
      expect.objectContaining({
        headers: expect.objectContaining({
          Authorization: expect.stringContaining("Basic"),
        }),
      })
    );
  });

  it("fetches stages for a build", async () => {
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: async () => ({
        stages: [
          { name: "Build", status: "SUCCESS", durationMillis: 30000 },
          { name: "playwrightTest", status: "FAILURE", durationMillis: 90000 },
        ],
      }),
    });

    const result = await client.getRunDetail("/job/my-repo/job/main", 42);
    expect(result.stages).toHaveLength(2);
    expect(result.stages[1].name).toBe("playwrightTest");
  });

  it("fetches console text for a build", async () => {
    mockFetch.mockResolvedValueOnce({
      ok: true,
      text: async () => "Playwright Report Link: https://example.com/report",
    });

    const text = await client.getConsoleText("/job/my-repo/job/main", 42);
    expect(text).toContain("Playwright Report Link");
  });

  it("throws on non-ok response", async () => {
    mockFetch.mockResolvedValueOnce({
      ok: false,
      status: 500,
      statusText: "Internal Server Error",
    });

    await expect(client.getRuns("/job/repo/job/main")).rejects.toThrow("Jenkins API error: 500");
  });
});
```

- [x] **Step 2: Run test to verify it fails**

Run: `pnpm test server/poller/jenkins-client.test.ts`
Expected: FAIL — module not found.

- [x] **Step 3: Implement jenkins-client.ts**

```typescript
export interface JenkinsCredentials {
  url: string;
  user: string;
  token: string;
}

export interface JenkinsRun {
  id: string;
  name: string;
  status: string;
  startTimeMillis: number;
  durationMillis: number;
}

export interface JenkinsStage {
  name: string;
  status: string;
  durationMillis: number;
}

export interface JenkinsRunDetail {
  stages: JenkinsStage[];
}

export class JenkinsClient {
  private baseUrl: string;
  private authHeader: string;

  constructor(credentials: JenkinsCredentials) {
    this.baseUrl = credentials.url.replace(/\/$/, "");
    this.authHeader =
      "Basic " + Buffer.from(`${credentials.user}:${credentials.token}`).toString("base64");
  }

  private async request(path: string): Promise<Response> {
    const url = `${this.baseUrl}${path}`;
    const response = await fetch(url, {
      headers: {
        Authorization: this.authHeader,
        Accept: "application/json",
      },
    });

    if (!response.ok) {
      throw new Error(`Jenkins API error: ${response.status}`);
    }

    return response;
  }

  async getRuns(jobPath: string): Promise<JenkinsRun[]> {
    const response = await this.request(`${jobPath}/wfapi/runs`);
    return response.json() as Promise<JenkinsRun[]>;
  }

  async getRunDetail(jobPath: string, buildNumber: number): Promise<JenkinsRunDetail> {
    const response = await this.request(`${jobPath}/${buildNumber}/wfapi/describe`);
    return response.json() as Promise<JenkinsRunDetail>;
  }

  async getConsoleText(jobPath: string, buildNumber: number): Promise<string> {
    const response = await this.request(`${jobPath}/${buildNumber}/consoleText`);
    return response.text();
  }
}
```

- [x] **Step 4: Run tests to verify they pass**

Run: `pnpm test server/poller/jenkins-client.test.ts`
Expected: All 4 tests pass.

- [x] **Step 5: Commit**

```bash
git add server/poller/jenkins-client.ts server/poller/jenkins-client.test.ts
git commit -m "feat: Jenkins API client for runs, stages, and console text"
```

---

## Chunk 3: Poller Service

### Task 5: Poller Core Logic

**Files:**
- Create: `server/poller/index.ts`
- Test: `server/poller/poller.test.ts`

- [x] **Step 1: Write failing test for poller logic**

Create `server/poller/poller.test.ts`:

```typescript
import { describe, it, expect, vi, beforeEach } from "vitest";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { eq } from "drizzle-orm";
import { join } from "path";
import * as schema from "../db/schema.js";
import { pollPipeline, deriveStatus } from "./index.js";
import type { JenkinsClient } from "./jenkins-client.js";

function createTestDb() {
  const sqlite = new Database(":memory:");
  sqlite.pragma("foreign_keys = ON");
  const db = drizzle(sqlite, { schema });
  migrate(db, { migrationsFolder: join(process.cwd(), "server/db/migrations") });
  return db;
}

function createMockJenkinsClient(overrides: Partial<JenkinsClient> = {}): JenkinsClient {
  return {
    getRuns: vi.fn().mockResolvedValue([]),
    getRunDetail: vi.fn().mockResolvedValue({ stages: [] }),
    getConsoleText: vi.fn().mockResolvedValue(""),
    ...overrides,
  } as unknown as JenkinsClient;
}

describe("deriveStatus", () => {
  it("returns green when all stages pass", () => {
    const stages = [
      { name: "Build", status: "SUCCESS", durationMillis: 1000 },
      { name: "playwrightTest", status: "SUCCESS", durationMillis: 2000 },
    ];
    expect(deriveStatus(stages, "playwrighttest")).toBe("green");
  });

  it("returns red when a playwright stage fails", () => {
    const stages = [
      { name: "Build", status: "SUCCESS", durationMillis: 1000 },
      { name: "playwrightTest", status: "FAILURE", durationMillis: 2000 },
    ];
    expect(deriveStatus(stages, "playwrighttest")).toBe("red");
  });

  it("returns yellow when a non-playwright stage fails", () => {
    const stages = [
      { name: "Build", status: "FAILURE", durationMillis: 1000 },
      { name: "playwrightTest", status: "NOT_EXECUTED", durationMillis: 0 },
    ];
    expect(deriveStatus(stages, "playwrighttest")).toBe("yellow");
  });

  it("returns red when multiple playwright stages and one fails", () => {
    const stages = [
      { name: "playwrightTest QA", status: "SUCCESS", durationMillis: 1000 },
      { name: "playwrightTest Staging", status: "FAILURE", durationMillis: 2000 },
    ];
    expect(deriveStatus(stages, "playwrighttest")).toBe("red");
  });

  it("returns building when run is still in progress", () => {
    const stages = [
      { name: "Build", status: "SUCCESS", durationMillis: 1000 },
      { name: "playwrightTest", status: "IN_PROGRESS", durationMillis: 0 },
    ];
    expect(deriveStatus(stages, "playwrighttest")).toBe("building");
  });
});

describe("pollPipeline", () => {
  let db: ReturnType<typeof createTestDb>;

  beforeEach(() => {
    db = createTestDb();
  });

  it("inserts a new run with stages when build number changes", async () => {
    const pipeline = db
      .insert(schema.pipelines)
      .values({ name: "repo/main", jenkinsUrl: "/job/repo/job/main" })
      .returning()
      .get();

    const client = createMockJenkinsClient({
      getRuns: vi.fn().mockResolvedValue([
        { id: "10", name: "#10", status: "SUCCESS", startTimeMillis: 1710600000000, durationMillis: 120000 },
      ]),
      getRunDetail: vi.fn().mockResolvedValue({
        stages: [
          { name: "Build", status: "SUCCESS", durationMillis: 30000 },
          { name: "playwrightTest", status: "SUCCESS", durationMillis: 90000 },
        ],
      }),
    });

    await pollPipeline(db, client, pipeline, "playwrighttest", []);

    const runs = db.select().from(schema.runs).where(eq(schema.runs.pipelineId, pipeline.id)).all();
    expect(runs).toHaveLength(1);
    expect(runs[0].buildNumber).toBe(10);
    expect(runs[0].status).toBe("green");

    const stages = db.select().from(schema.stages).all();
    expect(stages).toHaveLength(2);
    expect(stages[1].isPlaywright).toBe(1);
  });

  it("skips polling when build number has not changed", async () => {
    const pipeline = db
      .insert(schema.pipelines)
      .values({ name: "repo/main", jenkinsUrl: "/job/repo/job/main" })
      .returning()
      .get();

    db.insert(schema.runs)
      .values({ pipelineId: pipeline.id, buildNumber: 10, status: "green" })
      .run();

    const client = createMockJenkinsClient({
      getRuns: vi.fn().mockResolvedValue([
        { id: "10", name: "#10", status: "SUCCESS", startTimeMillis: 1710600000000, durationMillis: 120000 },
      ]),
    });

    await pollPipeline(db, client, pipeline, "playwrighttest", []);

    // getRunDetail should not have been called — build number unchanged
    expect(client.getRunDetail).not.toHaveBeenCalled();
  });

  it("fetches console text and extracts URLs for red runs", async () => {
    const pipeline = db
      .insert(schema.pipelines)
      .values({ name: "repo/main", jenkinsUrl: "/job/repo/job/main" })
      .returning()
      .get();

    const client = createMockJenkinsClient({
      getRuns: vi.fn().mockResolvedValue([
        { id: "11", name: "#11", status: "FAILURE", startTimeMillis: 1710600000000, durationMillis: 120000 },
      ]),
      getRunDetail: vi.fn().mockResolvedValue({
        stages: [
          { name: "Build", status: "SUCCESS", durationMillis: 30000 },
          { name: "playwrightTest", status: "FAILURE", durationMillis: 90000 },
        ],
      }),
      getConsoleText: vi.fn().mockResolvedValue(
        "Playwright Report Link: https://playwright.example.com/report/11"
      ),
    });

    await pollPipeline(db, client, pipeline, "playwrighttest", []);

    const urls = db.select().from(schema.extractedUrls).all();
    expect(urls).toHaveLength(1);
    expect(urls[0].url).toBe("https://playwright.example.com/report/11");
    expect(urls[0].label).toBe("Playwright Report Link");
  });
});
```

- [x] **Step 2: Run test to verify it fails**

Run: `pnpm test server/poller/poller.test.ts`
Expected: FAIL — `pollPipeline` and `deriveStatus` not found.

- [x] **Step 3: Implement poller/index.ts**

```typescript
import { eq, and, desc, lt } from "drizzle-orm";
import type { DB } from "../db/index.js";
import * as schema from "../db/schema.js";
import type { JenkinsClient, JenkinsStage } from "./jenkins-client.js";
import { extractUrls } from "./url-extractor.js";

export function deriveStatus(
  stages: JenkinsStage[],
  stagePattern: string
): "green" | "red" | "yellow" | "building" {
  const pattern = stagePattern.toLowerCase();

  let hasPlaywrightFailure = false;
  let hasOtherFailure = false;
  let hasInProgress = false;

  for (const stage of stages) {
    const isPlaywright = stage.name.toLowerCase().includes(pattern);

    if (stage.status === "IN_PROGRESS") {
      hasInProgress = true;
    } else if (stage.status === "FAILURE") {
      if (isPlaywright) {
        hasPlaywrightFailure = true;
      } else {
        hasOtherFailure = true;
      }
    }
  }

  if (hasInProgress) return "building";
  if (hasPlaywrightFailure) return "red";
  if (hasOtherFailure) return "yellow";
  return "green";
}

export async function pollPipeline(
  db: DB,
  client: JenkinsClient,
  pipeline: typeof schema.pipelines.$inferSelect,
  stagePattern: string,
  urlPatterns: string[]
): Promise<void> {
  const jenkinsRuns = await client.getRuns(pipeline.jenkinsUrl);
  if (jenkinsRuns.length === 0) return;

  const latestJenkinsRun = jenkinsRuns[0];
  const buildNumber = parseInt(latestJenkinsRun.id, 10);

  // Smart skip: check if we already have this build
  const existingRun = db
    .select()
    .from(schema.runs)
    .where(
      and(
        eq(schema.runs.pipelineId, pipeline.id),
        eq(schema.runs.buildNumber, buildNumber)
      )
    )
    .get();

  if (existingRun && existingRun.status !== "building") return;

  // Fetch stage details
  const detail = await client.getRunDetail(pipeline.jenkinsUrl, buildNumber);
  const status = deriveStatus(detail.stages, stagePattern);

  // If we had a "building" run, update it; otherwise insert new
  if (existingRun) {
    db.update(schema.runs)
      .set({
        status,
        startedAt: latestJenkinsRun.startTimeMillis,
        durationMs: latestJenkinsRun.durationMillis,
        fetchedAt: Date.now(),
      })
      .where(eq(schema.runs.id, existingRun.id))
      .run();

    // Delete old stages, re-insert
    db.delete(schema.stages).where(eq(schema.stages.runId, existingRun.id)).run();
    db.delete(schema.extractedUrls).where(eq(schema.extractedUrls.runId, existingRun.id)).run();

    await insertStagesAndUrls(db, client, existingRun.id, pipeline.jenkinsUrl, buildNumber, detail.stages, stagePattern, status, urlPatterns);
  } else {
    const run = db
      .insert(schema.runs)
      .values({
        pipelineId: pipeline.id,
        buildNumber,
        status,
        startedAt: latestJenkinsRun.startTimeMillis,
        durationMs: latestJenkinsRun.durationMillis,
      })
      .returning()
      .get();

    await insertStagesAndUrls(db, client, run.id, pipeline.jenkinsUrl, buildNumber, detail.stages, stagePattern, status, urlPatterns);
  }

  // Update last polled time
  db.update(schema.pipelines)
    .set({ lastPolledAt: Date.now() })
    .where(eq(schema.pipelines.id, pipeline.id))
    .run();
}

async function insertStagesAndUrls(
  db: DB,
  client: JenkinsClient,
  runId: number,
  jenkinsUrl: string,
  buildNumber: number,
  jenkinsStages: JenkinsStage[],
  stagePattern: string,
  status: string,
  urlPatterns: string[]
): Promise<void> {
  const pattern = stagePattern.toLowerCase();

  for (const s of jenkinsStages) {
    const isPlaywright = s.name.toLowerCase().includes(pattern) ? 1 : 0;

    db.insert(schema.stages)
      .values({
        runId,
        name: s.name,
        status: s.status as "SUCCESS" | "FAILURE" | "IN_PROGRESS" | "NOT_EXECUTED",
        durationMs: s.durationMillis,
        isPlaywright,
      })
      .run();
  }

  // Only fetch console text for red runs (playwright failures)
  if (status === "red") {
    const consoleText = await client.getConsoleText(jenkinsUrl, buildNumber);
    const urls = extractUrls(consoleText, urlPatterns);

    // Find playwright stage IDs to associate URLs with
    const playwrightStages = db
      .select()
      .from(schema.stages)
      .where(and(eq(schema.stages.runId, runId), eq(schema.stages.isPlaywright, 1)))
      .all();

    const firstPlaywrightStageId = playwrightStages[0]?.id;

    if (firstPlaywrightStageId) {
      for (const u of urls) {
        db.insert(schema.extractedUrls)
          .values({
            runId,
            stageId: firstPlaywrightStageId,
            url: u.url,
            label: u.label,
          })
          .run();
      }
    }
  }
}

export function pruneOldRuns(db: DB, pipelineId: number, keepCount: number = 50): void {
  const runsToKeep = db
    .select({ id: schema.runs.id })
    .from(schema.runs)
    .where(eq(schema.runs.pipelineId, pipelineId))
    .orderBy(desc(schema.runs.buildNumber))
    .limit(keepCount)
    .all();

  if (runsToKeep.length < keepCount) return;

  const oldestKeptBuildNumber = db
    .select({ buildNumber: schema.runs.buildNumber })
    .from(schema.runs)
    .where(eq(schema.runs.pipelineId, pipelineId))
    .orderBy(desc(schema.runs.buildNumber))
    .limit(1)
    .offset(keepCount - 1)
    .get();

  if (!oldestKeptBuildNumber) return;

  db.delete(schema.runs)
    .where(
      and(
        eq(schema.runs.pipelineId, pipelineId),
        lt(schema.runs.buildNumber, oldestKeptBuildNumber.buildNumber)
      )
    )
    .run();
}

// Tracks consecutive errors per pipeline for exponential backoff
const pipelineErrors = new Map<number, { count: number; nextRetryAt: number }>();

// Exposed for API to report connection health
export let pollerHealthy = true;

function shouldSkipDueToBackoff(pipelineId: number): boolean {
  const state = pipelineErrors.get(pipelineId);
  if (!state || state.count < 3) return false;
  return Date.now() < state.nextRetryAt;
}

function recordSuccess(pipelineId: number): void {
  pipelineErrors.delete(pipelineId);
}

function recordError(pipelineId: number): void {
  const state = pipelineErrors.get(pipelineId) ?? { count: 0, nextRetryAt: 0 };
  state.count++;
  if (state.count >= 3) {
    // Exponential backoff: 60s, 120s, 240s, max 300s
    const backoffMs = Math.min(60_000 * Math.pow(2, state.count - 3), 300_000);
    state.nextRetryAt = Date.now() + backoffMs;
  }
  pipelineErrors.set(pipelineId, state);
}

export interface Poller {
  stop: () => void;
}

export function startPoller(
  db: DB,
  client: JenkinsClient,
  stagePattern: string,
  urlPatterns: string[],
  intervalSeconds: number
): Poller {
  const intervalMs = intervalSeconds * 1000;

  const poll = async () => {
    const allPipelines = db.select().from(schema.pipelines).all();
    const delayPerPipeline = intervalMs / Math.max(allPipelines.length, 1);
    let totalErrors = 0;

    for (let i = 0; i < allPipelines.length; i++) {
      const p = allPipelines[i];

      if (shouldSkipDueToBackoff(p.id)) {
        continue;
      }

      try {
        await pollPipeline(db, client, p, stagePattern, urlPatterns);
        recordSuccess(p.id);
      } catch (err) {
        console.error(`Error polling ${p.name}:`, err);
        recordError(p.id);
        totalErrors++;
      }

      // Stagger: wait between pipelines
      if (i < allPipelines.length - 1) {
        await new Promise((r) => setTimeout(r, delayPerPipeline));
      }
    }

    // Healthy if at least some pipelines are reachable
    pollerHealthy = totalErrors < allPipelines.length;

    // Prune old runs once per cycle
    for (const p of allPipelines) {
      pruneOldRuns(db, p.id);
    }
  };

  // Run immediately, then on interval
  poll();
  const timer = setInterval(poll, intervalMs);

  return { stop: () => clearInterval(timer) };
}
```

- [x] **Step 4: Run tests to verify they pass**

Run: `pnpm test server/poller/poller.test.ts`
Expected: All 8 tests pass (5 deriveStatus + 3 pollPipeline).

- [x] **Step 5: Commit**

```bash
git add server/poller/index.ts server/poller/poller.test.ts
git commit -m "feat: poller service with staggered polling, smart skip, and data retention"
```

---

## Chunk 4: REST API

### Task 6: Express API Routes

**Files:**
- Create: `server/routes/pipelines.ts`
- Create: `server/index.ts`
- Test: `server/routes/pipelines.test.ts`

- [x] **Step 1: Write failing test for API routes**

Create `server/routes/pipelines.test.ts`:

```typescript
import { describe, it, expect, beforeEach } from "vitest";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { join } from "path";
import * as schema from "../db/schema.js";
import { buildPipelinesResponse, buildRunsResponse } from "./pipelines.js";

function createTestDb() {
  const sqlite = new Database(":memory:");
  sqlite.pragma("foreign_keys = ON");
  const db = drizzle(sqlite, { schema });
  migrate(db, { migrationsFolder: join(process.cwd(), "server/db/migrations") });
  return db;
}

describe("buildPipelinesResponse", () => {
  let db: ReturnType<typeof createTestDb>;

  beforeEach(() => {
    db = createTestDb();
  });

  it("returns pipelines with latest completed run", () => {
    const pipeline = db
      .insert(schema.pipelines)
      .values({ name: "repo/main", jenkinsUrl: "https://j.example.com/job/repo/job/main" })
      .returning()
      .get();

    const run = db
      .insert(schema.runs)
      .values({ pipelineId: pipeline.id, buildNumber: 10, status: "red", startedAt: 1710600000000, durationMs: 120000 })
      .returning()
      .get();

    const stage = db
      .insert(schema.stages)
      .values({ runId: run.id, name: "playwrightTest", status: "FAILURE", durationMs: 90000, isPlaywright: 1 })
      .returning()
      .get();

    db.insert(schema.extractedUrls)
      .values({ runId: run.id, stageId: stage.id, url: "https://report.example.com", label: "Playwright Report Link" })
      .run();

    const result = buildPipelinesResponse(db);

    expect(result.pipelines).toHaveLength(1);
    expect(result.pipelines[0].name).toBe("repo/main");
    expect(result.pipelines[0].latestRun?.status).toBe("red");
    expect(result.pipelines[0].latestRun?.failedStages).toEqual(["playwrightTest"]);
    expect(result.pipelines[0].latestRun?.extractedUrls).toHaveLength(1);
    expect(result.pipelines[0].latestRun?.isBuilding).toBe(false);
  });

  it("returns previous completed run when latest is building", () => {
    const pipeline = db
      .insert(schema.pipelines)
      .values({ name: "repo/main", jenkinsUrl: "https://j.example.com/job/repo/job/main" })
      .returning()
      .get();

    db.insert(schema.runs)
      .values({ pipelineId: pipeline.id, buildNumber: 10, status: "green", startedAt: 1710600000000, durationMs: 120000 })
      .run();

    db.insert(schema.runs)
      .values({ pipelineId: pipeline.id, buildNumber: 11, status: "building", startedAt: 1710700000000 })
      .run();

    const result = buildPipelinesResponse(db);

    expect(result.pipelines[0].latestRun?.status).toBe("green");
    expect(result.pipelines[0].latestRun?.isBuilding).toBe(true);
  });

  it("returns null latestRun with isBuilding when only building run exists", () => {
    const pipeline = db
      .insert(schema.pipelines)
      .values({ name: "repo/main", jenkinsUrl: "https://j.example.com/job/repo/job/main" })
      .returning()
      .get();

    db.insert(schema.runs)
      .values({ pipelineId: pipeline.id, buildNumber: 1, status: "building" })
      .run();

    const result = buildPipelinesResponse(db);

    expect(result.pipelines[0].latestRun).toBeNull();
    expect(result.pipelines[0].isBuilding).toBe(true);
  });
});

describe("buildRunsResponse", () => {
  let db: ReturnType<typeof createTestDb>;

  beforeEach(() => {
    db = createTestDb();
  });

  it("returns runs with stages for a pipeline", () => {
    const pipeline = db
      .insert(schema.pipelines)
      .values({ name: "repo/main", jenkinsUrl: "https://j.example.com/job/repo/job/main" })
      .returning()
      .get();

    const run = db
      .insert(schema.runs)
      .values({ pipelineId: pipeline.id, buildNumber: 10, status: "green", startedAt: 1710600000000, durationMs: 120000 })
      .returning()
      .get();

    db.insert(schema.stages)
      .values({ runId: run.id, name: "Build", status: "SUCCESS", durationMs: 30000, isPlaywright: 0 })
      .run();

    const result = buildRunsResponse(db, pipeline.id);

    expect(result.runs).toHaveLength(1);
    expect(result.runs[0].stages).toHaveLength(1);
    expect(result.runs[0].buildNumber).toBe(10);
  });
});
```

- [x] **Step 2: Run test to verify it fails**

Run: `pnpm test server/routes/pipelines.test.ts`
Expected: FAIL — module not found.

- [x] **Step 3: Implement routes/pipelines.ts**

```typescript
import { Router } from "express";
import { eq, desc, and, ne } from "drizzle-orm";
import type { DB } from "../db/index.js";
import * as schema from "../db/schema.js";
import { pollerHealthy } from "../poller/index.js";

export interface PipelineResponse {
  id: number;
  name: string;
  jenkinsUrl: string;
  latestRun: {
    buildNumber: number;
    status: string;
    failedStages: string[];
    startedAt: number | null;
    durationMs: number | null;
    extractedUrls: { url: string; label: string | null; stageName: string }[];
    isBuilding: boolean;
  } | null;
  isBuilding: boolean;
}

export function buildPipelinesResponse(db: DB): {
  pipelines: PipelineResponse[];
  lastPolledAt: number | null;
  healthy: boolean;
} {
  const allPipelines = db.select().from(schema.pipelines).all();
  const pipelines: PipelineResponse[] = [];

  let lastPolledAt: number | null = null;

  for (const p of allPipelines) {
    if (p.lastPolledAt && (!lastPolledAt || p.lastPolledAt > lastPolledAt)) {
      lastPolledAt = p.lastPolledAt;
    }

    // Check if there's a building run
    const buildingRun = db
      .select()
      .from(schema.runs)
      .where(and(eq(schema.runs.pipelineId, p.id), eq(schema.runs.status, "building")))
      .get();

    const isBuilding = !!buildingRun;

    // Get latest completed run
    const latestRun = db
      .select()
      .from(schema.runs)
      .where(and(eq(schema.runs.pipelineId, p.id), ne(schema.runs.status, "building")))
      .orderBy(desc(schema.runs.buildNumber))
      .limit(1)
      .get();

    if (!latestRun) {
      pipelines.push({
        id: p.id,
        name: p.name,
        jenkinsUrl: p.jenkinsUrl,
        latestRun: null,
        isBuilding,
      });
      continue;
    }

    // Get failed stages
    const failedStages = db
      .select({ name: schema.stages.name })
      .from(schema.stages)
      .where(and(eq(schema.stages.runId, latestRun.id), eq(schema.stages.status, "FAILURE")))
      .all()
      .map((s) => s.name);

    // Get extracted URLs with stage names
    const urls = db
      .select({
        url: schema.extractedUrls.url,
        label: schema.extractedUrls.label,
        stageName: schema.stages.name,
      })
      .from(schema.extractedUrls)
      .innerJoin(schema.stages, eq(schema.extractedUrls.stageId, schema.stages.id))
      .where(eq(schema.extractedUrls.runId, latestRun.id))
      .all();

    pipelines.push({
      id: p.id,
      name: p.name,
      jenkinsUrl: p.jenkinsUrl,
      latestRun: {
        buildNumber: latestRun.buildNumber,
        status: latestRun.status,
        failedStages,
        startedAt: latestRun.startedAt,
        durationMs: latestRun.durationMs,
        extractedUrls: urls,
        isBuilding,
      },
      isBuilding,
    });
  }

  return { pipelines, lastPolledAt, healthy: pollerHealthy };
}

export function buildRunsResponse(db: DB, pipelineId: number) {
  const allRuns = db
    .select()
    .from(schema.runs)
    .where(eq(schema.runs.pipelineId, pipelineId))
    .orderBy(desc(schema.runs.buildNumber))
    .limit(50)
    .all();

  const runs = allRuns.map((run) => {
    const stagesData = db
      .select()
      .from(schema.stages)
      .where(eq(schema.stages.runId, run.id))
      .all();

    const urls = db
      .select()
      .from(schema.extractedUrls)
      .where(eq(schema.extractedUrls.runId, run.id))
      .all();

    return {
      buildNumber: run.buildNumber,
      status: run.status,
      startedAt: run.startedAt,
      durationMs: run.durationMs,
      stages: stagesData.map((s) => ({
        name: s.name,
        status: s.status,
        durationMs: s.durationMs,
        isPlaywright: !!s.isPlaywright,
      })),
      extractedUrls: urls.map((u) => ({ url: u.url, label: u.label })),
    };
  });

  return { runs };
}

export function createPipelinesRouter(db: DB): Router {
  const router = Router();

  router.get("/pipelines", (_req, res) => {
    const result = buildPipelinesResponse(db);
    res.json(result);
  });

  router.get("/pipelines/:id/runs", (req, res) => {
    const id = parseInt(req.params.id, 10);
    if (isNaN(id)) {
      res.status(400).json({ error: "Invalid pipeline ID" });
      return;
    }

    const pipeline = db
      .select()
      .from(schema.pipelines)
      .where(eq(schema.pipelines.id, id))
      .get();

    if (!pipeline) {
      res.status(404).json({ error: "Pipeline not found" });
      return;
    }

    const result = buildRunsResponse(db, id);
    res.json(result);
  });

  return router;
}
```

- [x] **Step 4: Run tests to verify they pass**

Run: `pnpm test server/routes/pipelines.test.ts`
Expected: All 4 tests pass.

- [x] **Step 5: Create server/index.ts (Express server entry point)**

```typescript
import express from "express";
import cors from "cors";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { join } from "path";
import { db } from "./db/index.js";
import { createPipelinesRouter } from "./routes/pipelines.js";
import { JenkinsClient } from "./poller/jenkins-client.js";
import { startPoller } from "./poller/index.js";
import { loadConfig } from "./config.js";

// Auto-migrate on startup
migrate(db, { migrationsFolder: join(process.cwd(), "server/db/migrations") });

const config = loadConfig();
const app = express();
const port = 3001;

app.use(cors());
app.use("/api", createPipelinesRouter(db));

const client = new JenkinsClient(config.jenkins);
const poller = startPoller(
  db,
  client,
  config.playwright.stagePattern.toLowerCase(),
  config.playwright.urlPatterns,
  config.polling.intervalSeconds
);

app.listen(port, () => {
  console.log(`Dashboard API running on http://localhost:${port}`);
  console.log(`Polling ${config.jenkins.url} every ${config.polling.intervalSeconds}s`);
});

process.on("SIGINT", () => {
  console.log("Shutting down...");
  poller.stop();
  process.exit(0);
});
```

- [x] **Step 6: Commit**

```bash
git add server/routes/ server/index.ts
git commit -m "feat: REST API endpoints for pipelines and runs"
```

---

## Chunk 5: React Frontend

### Task 7: Frontend Setup

**Files:**
- Create: `client/index.html`
- Create: `client/vite.config.ts`
- Create: `client/tsconfig.json`
- Create: `client/package.json`
- Create: `client/postcss.config.js`
- Create: `client/tailwind.config.js`
- Create: `client/src/main.tsx`
- Create: `client/src/index.css`
- Create: `client/src/App.tsx`

- [x] **Step 1: Create client/package.json**

```json
{
  "name": "dash-client",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build"
  },
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "@vitejs/plugin-react": "^4.3.0",
    "autoprefixer": "^10.4.20",
    "postcss": "^8.4.49",
    "tailwindcss": "^3.4.0",
    "typescript": "^5.7.0",
    "vite": "^6.0.0"
  }
}
```

- [x] **Step 2: Create client/vite.config.ts**

```typescript
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      "/api": "http://localhost:3001",
    },
  },
});
```

- [x] **Step 3: Create client/tsconfig.json**

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "resolveJsonModule": true
  },
  "include": ["src"]
}
```

- [x] **Step 4: Create client/tailwind.config.js**

```javascript
/** @type {import('tailwindcss').Config} */
export default {
  content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
  theme: {
    extend: {},
  },
  plugins: [],
};
```

- [x] **Step 5: Create client/postcss.config.js**

```javascript
export default {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};
```

- [x] **Step 6: Create client/index.html**

```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Playwright Pipeline Dashboard</title>
  </head>
  <body class="bg-gray-950 text-gray-100">
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
```

- [x] **Step 7: Create client/src/index.css**

```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

- [x] **Step 8: Create client/src/main.tsx**

```tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import { App } from "./App";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>
);
```

- [x] **Step 9: Create client/src/App.tsx (shell)**

```tsx
import { Dashboard } from "./components/Dashboard";

export function App() {
  return (
    <div className="min-h-screen bg-gray-950">
      <Dashboard />
    </div>
  );
}
```

- [x] **Step 10: Install client dependencies**

Run: `cd client && pnpm install && cd ..`
Expected: Clean install.

- [x] **Step 11: Commit**

```bash
git add client/
git commit -m "feat: frontend project setup with Vite, React, Tailwind"
```

---

### Task 8: Frontend Types & API Hook

**Files:**
- Create: `client/src/types.ts`
- Create: `client/src/hooks/usePipelines.ts`

- [x] **Step 1: Create client/src/types.ts**

```typescript
export interface ExtractedUrl {
  url: string;
  label: string | null;
  stageName: string;
}

export interface LatestRun {
  buildNumber: number;
  status: "green" | "red" | "yellow";
  failedStages: string[];
  startedAt: number | null;
  durationMs: number | null;
  extractedUrls: ExtractedUrl[];
  isBuilding: boolean;
}

export interface Pipeline {
  id: number;
  name: string;
  jenkinsUrl: string;
  latestRun: LatestRun | null;
  isBuilding: boolean;
}

export interface PipelinesResponse {
  pipelines: Pipeline[];
  lastPolledAt: number | null;
  healthy: boolean;
}

export type StatusFilter = "all" | "red" | "yellow" | "green";
```

- [x] **Step 2: Create client/src/hooks/usePipelines.ts**

```typescript
import { useState, useEffect, useCallback } from "react";
import type { PipelinesResponse } from "../types";

export function usePipelines(refreshIntervalMs: number = 10_000) {
  const [data, setData] = useState<PipelinesResponse | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);

  const fetchPipelines = useCallback(async () => {
    try {
      const res = await fetch("/api/pipelines");
      if (!res.ok) throw new Error(`API error: ${res.status}`);
      const json: PipelinesResponse = await res.json();
      setData(json);
      setError(null);
    } catch (err) {
      setError(err instanceof Error ? err.message : "Unknown error");
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => {
    fetchPipelines();
    const interval = setInterval(fetchPipelines, refreshIntervalMs);
    return () => clearInterval(interval);
  }, [fetchPipelines, refreshIntervalMs]);

  return { data, error, loading };
}
```

- [x] **Step 3: Commit**

```bash
git add client/src/types.ts client/src/hooks/
git commit -m "feat: frontend types and API polling hook"
```

---

### Task 9: Dashboard Components

**Files:**
- Create: `client/src/components/FilterBar.tsx`
- Create: `client/src/components/PipelineCard.tsx`
- Create: `client/src/components/Dashboard.tsx`

- [x] **Step 1: Create client/src/components/FilterBar.tsx**

```tsx
import type { StatusFilter, Pipeline } from "../types";

interface FilterBarProps {
  pipelines: Pipeline[];
  activeFilter: StatusFilter;
  onFilterChange: (filter: StatusFilter) => void;
}

export function FilterBar({ pipelines, activeFilter, onFilterChange }: FilterBarProps) {
  const counts = {
    all: pipelines.length,
    red: pipelines.filter((p) => p.latestRun?.status === "red").length,
    yellow: pipelines.filter((p) => p.latestRun?.status === "yellow").length,
    green: pipelines.filter((p) => p.latestRun?.status === "green").length,
  };

  const filters: { key: StatusFilter; label: string; color: string; activeColor: string }[] = [
    { key: "all", label: "All", color: "text-gray-400", activeColor: "bg-gray-700 text-white" },
    { key: "red", label: "Failing", color: "text-red-400", activeColor: "bg-red-900/50 text-red-300" },
    { key: "yellow", label: "Other Issues", color: "text-yellow-400", activeColor: "bg-yellow-900/50 text-yellow-300" },
    { key: "green", label: "Passing", color: "text-green-400", activeColor: "bg-green-900/50 text-green-300" },
  ];

  return (
    <div className="flex gap-2 flex-wrap">
      {filters.map((f) => (
        <button
          key={f.key}
          onClick={() => onFilterChange(f.key)}
          className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
            activeFilter === f.key ? f.activeColor : `${f.color} hover:bg-gray-800`
          }`}
        >
          {f.label} ({counts[f.key]})
        </button>
      ))}
    </div>
  );
}
```

- [x] **Step 2: Create client/src/components/PipelineCard.tsx**

```tsx
import type { Pipeline } from "../types";

interface PipelineCardProps {
  pipeline: Pipeline;
}

const STATUS_STYLES = {
  red: {
    border: "border-red-500",
    bg: "bg-red-950/30",
    dot: "text-red-400",
    text: "text-red-400",
  },
  yellow: {
    border: "border-yellow-500",
    bg: "bg-yellow-950/30",
    dot: "text-yellow-400",
    text: "text-yellow-400",
  },
  green: {
    border: "border-green-500/30",
    bg: "bg-green-950/20",
    dot: "text-green-400",
    text: "text-green-400",
  },
};

function relativeTime(timestamp: number | null): string {
  if (!timestamp) return "";
  const diff = Date.now() - timestamp;
  const minutes = Math.floor(diff / 60000);
  if (minutes < 1) return "just now";
  if (minutes < 60) return `${minutes}m ago`;
  const hours = Math.floor(minutes / 60);
  if (hours < 24) return `${hours}h ago`;
  return `${Math.floor(hours / 24)}d ago`;
}

export function PipelineCard({ pipeline }: PipelineCardProps) {
  const { latestRun, isBuilding } = pipeline;
  const status = latestRun?.status ?? "green";
  const style = STATUS_STYLES[status] ?? STATUS_STYLES.green;

  return (
    <a
      href={pipeline.jenkinsUrl}
      target="_blank"
      rel="noopener noreferrer"
      className={`block rounded-lg border p-4 ${style.border} ${style.bg} hover:brightness-110 transition-all`}
    >
      <div className="flex items-center justify-between mb-2">
        <h3 className={`font-bold text-sm truncate ${style.text}`}>{pipeline.name}</h3>
        <span className={`text-lg ${style.dot} ${isBuilding ? "animate-pulse" : ""}`}>●</span>
      </div>

      {latestRun?.failedStages && latestRun.failedStages.length > 0 && (
        <p className="text-gray-400 text-xs mb-2">
          {latestRun.failedStages.join(", ")} FAILED
        </p>
      )}

      {latestRun?.extractedUrls && latestRun.extractedUrls.length > 0 && (
        <div className="mb-2">
          {latestRun.extractedUrls.map((u, i) => (
            <a
              key={i}
              href={u.url}
              target="_blank"
              rel="noopener noreferrer"
              onClick={(e) => e.stopPropagation()}
              className="text-blue-400 text-xs hover:underline block truncate"
            >
              {u.label || "Report"}
            </a>
          ))}
        </div>
      )}

      <div className="text-gray-500 text-xs flex justify-between">
        <span>#{latestRun?.buildNumber ?? "—"}</span>
        <span>{relativeTime(latestRun?.startedAt ?? null)}</span>
      </div>
    </a>
  );
}
```

- [x] **Step 3: Create client/src/components/Dashboard.tsx**

```tsx
import { useState } from "react";
import { usePipelines } from "../hooks/usePipelines";
import { FilterBar } from "./FilterBar";
import { PipelineCard } from "./PipelineCard";
import type { StatusFilter, Pipeline } from "../types";

const STATUS_ORDER: Record<string, number> = { red: 0, yellow: 1, green: 2 };

function sortPipelines(pipelines: Pipeline[]): Pipeline[] {
  return [...pipelines].sort((a, b) => {
    const aStatus = a.latestRun?.status ?? "green";
    const bStatus = b.latestRun?.status ?? "green";
    return (STATUS_ORDER[aStatus] ?? 3) - (STATUS_ORDER[bStatus] ?? 3);
  });
}

export function Dashboard() {
  const { data, error, loading } = usePipelines();
  const [filter, setFilter] = useState<StatusFilter>("all");

  if (loading && !data) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <p className="text-gray-400">Loading pipelines...</p>
      </div>
    );
  }

  if (error && !data) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <p className="text-red-400">Error: {error}</p>
      </div>
    );
  }

  const pipelines = data?.pipelines ?? [];
  const filtered =
    filter === "all"
      ? pipelines
      : pipelines.filter((p) => p.latestRun?.status === filter);
  const sorted = sortPipelines(filtered);

  return (
    <div className="max-w-7xl mx-auto px-4 py-6">
      <header className="mb-6 flex items-center justify-between">
        <h1 className="text-xl font-bold text-gray-100">Playwright Pipeline Monitor</h1>
        <div className="flex items-center gap-3 text-xs text-gray-500">
          {data?.lastPolledAt && (
            <span>Last poll: {new Date(data.lastPolledAt).toLocaleTimeString()}</span>
          )}
          <span className={data?.healthy ? "text-green-400" : "text-red-400"}>
            {data?.healthy ? "● Connected" : "● Disconnected"}
          </span>
        </div>
      </header>

      <div className="mb-4">
        <FilterBar pipelines={pipelines} activeFilter={filter} onFilterChange={setFilter} />
      </div>

      {sorted.length === 0 ? (
        <div className="text-center py-12 text-gray-500">
          {pipelines.length === 0
            ? "No pipelines configured. Run `pnpm discover` to find pipelines."
            : "No pipelines match this filter."}
        </div>
      ) : (
        <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
          {sorted.map((p) => (
            <PipelineCard key={p.id} pipeline={p} />
          ))}
        </div>
      )}
    </div>
  );
}
```

- [x] **Step 4: Verify frontend builds**

Run: `cd client && npx vite build && cd ..`
Expected: Build succeeds with no TypeScript errors.

- [x] **Step 5: Commit**

```bash
git add client/src/components/
git commit -m "feat: dashboard UI with filter bar, pipeline cards, and auto-refresh"
```

---

## Chunk 6: Pipeline Discovery & Final Integration

### Task 10: Pipeline Discovery Script

**Files:**
- Create: `server/discover.ts`
- Test: `server/discover.test.ts`

- [x] **Step 1: Write failing test for discovery logic**

Create `server/discover.test.ts`:

```typescript
import { describe, it, expect } from "vitest";
import { containsPlaywrightTest, parseJenkinsJobUrl } from "./discover.js";

describe("containsPlaywrightTest", () => {
  it("detects playwrightTest call in Jenkinsfile content", () => {
    const content = `
pipeline {
  stages {
    stage('Test') {
      steps {
        playwrightTest()
      }
    }
  }
}
    `;
    expect(containsPlaywrightTest(content, "playwrightTest")).toBe(true);
  });

  it("returns false when no playwrightTest call", () => {
    const content = `
pipeline {
  stages {
    stage('Build') {
      steps {
        sh 'npm run build'
      }
    }
  }
}
    `;
    expect(containsPlaywrightTest(content, "playwrightTest")).toBe(false);
  });
});

describe("parseJenkinsJobUrl", () => {
  it("constructs Jenkins multibranch job URL from repo name", () => {
    const result = parseJenkinsJobUrl("https://jenkins.example.com", "my-repo", "main");
    expect(result).toBe("https://jenkins.example.com/job/my-repo/job/main");
  });
});
```

- [x] **Step 2: Run test to verify it fails**

Run: `pnpm test server/discover.test.ts`
Expected: FAIL — module not found.

- [x] **Step 3: Implement server/discover.ts**

```typescript
import { execSync } from "child_process";
import { eq } from "drizzle-orm";
import { db } from "./db/index.js";
import * as schema from "./db/schema.js";
import { loadConfig } from "./config.js";

export function containsPlaywrightTest(content: string, pattern: string): boolean {
  return content.toLowerCase().includes(pattern.toLowerCase());
}

export function parseJenkinsJobUrl(jenkinsBaseUrl: string, repoName: string, branch: string): string {
  const base = jenkinsBaseUrl.replace(/\/$/, "");
  return `${base}/job/${repoName}/job/${branch}`;
}

function ghExec(args: string): string {
  return execSync(`gh ${args}`, { encoding: "utf-8" }).trim();
}

async function discover() {
  const config = loadConfig();
  const org = config.github.org;
  const jenkinsUrl = config.jenkins.url;
  const pattern = config.playwright.stagePattern;

  if (!org) {
    console.error("Error: github.org not set in config.json");
    process.exit(1);
  }

  console.log(`Scanning repos in org: ${org}`);

  // Get all repos
  const reposJson = ghExec(`repo list ${org} --json name --limit 1000`);
  const repos: { name: string }[] = JSON.parse(reposJson);

  console.log(`Found ${repos.length} repos`);

  let discovered = 0;

  for (const repo of repos) {
    try {
      // Check if Jenkinsfile exists
      const contentJson = ghExec(
        `api repos/${org}/${repo.name}/contents/Jenkinsfile --jq '.content'`
      );

      if (!contentJson) continue;

      // Decode base64 content
      const content = Buffer.from(contentJson, "base64").toString("utf-8");

      if (!containsPlaywrightTest(content, pattern)) continue;

      console.log(`Found playwrightTest in ${repo.name}`);

      // Query Jenkins for branches (multibranch pipeline)
      // For each branch, create a pipeline entry
      try {
        const branchesResponse = await fetch(
          `${jenkinsUrl}/job/${repo.name}/api/json?tree=jobs[name]`,
          {
            headers: {
              Authorization:
                "Basic " +
                Buffer.from(`${config.jenkins.user}:${config.jenkins.token}`).toString("base64"),
            },
          }
        );

        if (branchesResponse.ok) {
          const data = (await branchesResponse.json()) as { jobs?: { name: string }[] };
          const branches = data.jobs?.map((j) => j.name) ?? ["main"];

          for (const branch of branches) {
            const jobUrl = parseJenkinsJobUrl(jenkinsUrl, repo.name, branch);
            const pipelineName = `${repo.name}/${branch}`;

            // Upsert
            const existing = db
              .select()
              .from(schema.pipelines)
              .where(eq(schema.pipelines.jenkinsUrl, jobUrl))
              .get();

            if (!existing) {
              db.insert(schema.pipelines)
                .values({ name: pipelineName, jenkinsUrl: jobUrl })
                .run();
              console.log(`  Added: ${pipelineName}`);
              discovered++;
            }
          }
        }
      } catch (err) {
        // If Jenkins doesn't have this as a multibranch, add as single pipeline
        const jobUrl = `${jenkinsUrl}/job/${repo.name}`;
        const existing = db
          .select()
          .from(schema.pipelines)
          .where(eq(schema.pipelines.jenkinsUrl, jobUrl))
          .get();

        if (!existing) {
          db.insert(schema.pipelines)
            .values({ name: repo.name, jenkinsUrl: jobUrl })
            .run();
          console.log(`  Added: ${repo.name}`);
          discovered++;
        }
      }
    } catch {
      // Repo doesn't have a Jenkinsfile, skip
      continue;
    }
  }

  console.log(`\nDiscovery complete. Added ${discovered} new pipelines.`);
}

// Run if executed directly
const isMain = process.argv[1]?.endsWith("discover.ts") || process.argv[1]?.endsWith("discover.js");
if (isMain) {
  discover().catch(console.error);
}
```

- [x] **Step 4: Run tests to verify they pass**

Run: `pnpm test server/discover.test.ts`
Expected: All 3 tests pass.

- [x] **Step 5: Commit**

```bash
git add server/discover.ts server/discover.test.ts
git commit -m "feat: pipeline discovery script using gh CLI"
```

---

### Task 11: Final Integration & Smoke Test

**Files:**
- Modify: `package.json` (verify scripts)
- Modify: `.gitignore` (add data/)

- [x] **Step 1: Run all tests**

Run: `pnpm test`
Expected: All tests pass (schema, url-extractor, jenkins-client, poller, routes, discover).

- [x] **Step 2: Verify server starts (without Jenkins)**

Run: `pnpm dev:server`
Expected: Server starts on port 3001, logs polling errors (expected — no Jenkins configured). Ctrl+C to stop.

- [x] **Step 3: Verify frontend builds and starts**

Run: `pnpm dev:client`
Expected: Vite dev server starts on port 5173. Open http://localhost:5173 — shows "No pipelines configured" message. Ctrl+C to stop.

- [x] **Step 4: Verify concurrent dev mode**

Run: `pnpm dev`
Expected: Both server and client start. Dashboard loads at http://localhost:5173 and proxies API calls to server. Ctrl+C to stop.

- [x] **Step 5: Final commit**

```bash
git add -A
git commit -m "feat: complete Jenkins Playwright Pipeline Dashboard v1"
```
