Skip to content
Draft
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
28 changes: 28 additions & 0 deletions .github/workflows/feedback-loop.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: Browser feedback loop

on:
push:
branches: [main]
pull_request:

jobs:
scenario:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --filter harness exec playwright install --with-deps chromium
- run: pnpm test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: browser-feedback-loop
path: |
packages/harness/playwright-report/
packages/harness/test-results/
retention-days: 7
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ pnpm-debug.log*
.claude/settings.local.json
.playwright-mcp/
packages/desktop/release/
packages/harness/test-results/
packages/harness/playwright-report/
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ Close the loop before declaring done. Available tools:
- `pnpm test` — vitest in both packages
- `pnpm lint` — oxlint
- `pnpm fmt:check` — oxfmt
- `pnpm test:e2e` — browser scenario through the built web app, real API, sync engine, and temporary SQLite cache. Install Chromium once with `pnpm --filter harness exec playwright install chromium`. Failure traces, screenshots, and diagnostics go to `packages/harness/test-results/` and `playwright-report/`.
- `pnpm scenario` — interactive isolated board; enter `queued`, `queue-failed`, `offline`, or `in-flight` to drive upstream transitions. See `packages/harness/README.md`.
- `pnpm dev:web` — runs server + web concurrently (long-running; start in background). For agent work prefer this over `pnpm dev`, which also spawns an Electron window. Stdout/stderr is tee'd to `.logs/server.log` and `.logs/web.log` — tail or read these to inspect output. Logs reset on each start; accumulate across hot reloads within a session.

For UI/UX changes, type-checks aren't enough — drive the app via the **Playwright MCP** (`.mcp.json`):
For UI/UX changes, type-checks aren't enough — run `pnpm test:e2e` for repeatable scenarios. For interactive exploration, drive the app via the **Playwright MCP** (`.mcp.json`):

- `browser_navigate` to `http://localhost:7200`
- `browser_snapshot` for the accessibility tree (preferred over screenshots for assertions)
Expand All @@ -30,3 +32,7 @@ A config file with valid tokens already exists at `~/.config/github-dashboard/co
Things `oxfmt`/`oxlint` won't catch but we still care about:

- **Blank lines around React hook calls.** Separate `useEffect`, `useMemo`, `useCallback`, etc. from surrounding code and from each other with a blank line above and below. Multi-statement hook bodies are easier to scan when they aren't visually fused to neighboring lines.

## Review size

Prefer small, focused commits and stacked PRs over large implementations. Keep verification infrastructure and product behavior changes in separate layers, each independently verifiable.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,7 @@ TUI state lives in XDG dirs and is safe to delete:
### Desktop/web sync engine scope

The desktop and web clients use `packages/sync` for all GitHub access, including authentication, list synchronization, comments and file diffs. Supported writes are approval, draft/ready toggling, auto-merge toggling and notification dismissal. Direct merge/close, title editing, CI reruns, posting conversation comments, closed-PR search, colleague browsing, and detailed commit/check lists are currently unavailable; use “Open in browser” for those operations. The local Hono HTTP API remains the renderer transport, but owns no GitHub client or live cache.

### Repeatable feedback loop

Run `pnpm scenario` to develop against a controllable local GitHub simulator, or `pnpm test:e2e` to check queue transitions in Chromium through the real sync engine and SQLite cache. Both use disposable configuration and fake credentials. See [the harness guide](packages/harness/README.md) for setup, controls, and failure artifacts.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"prepare": "husky",
"cli": "node packages/cli/dist/index.js",
"dev:cli": "pnpm --filter cli dev",
"scenario": "pnpm --filter web build && pnpm --filter harness scenario"
"scenario": "pnpm --filter web build && pnpm --filter harness scenario",
"test:e2e": "pnpm --filter sync build && pnpm --filter web build && pnpm --filter harness test:e2e"
},
"devDependencies": {
"@typescript/native-preview": "7.0.0-dev.20260420.1",
Expand Down
8 changes: 8 additions & 0 deletions packages/harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,11 @@ Enter `queued`, then `queue-failed`, to move the fixture PR into and out of the
The current board changes its queue icon; the dedicated merge-queue section in #84 is a subsequent product change. This harness supplies the repeatable state transitions for developing it.

`pnpm --filter harness test` checks the simulator through the real engine: transitions, outage, recovery, and persisted snapshots across restart. Unsupported GitHub calls fail with 501 so new engine behavior requires an explicit fixture update.

## Browser feedback loop

Install Chromium once with `pnpm --filter harness exec playwright install chromium`, then run `pnpm test:e2e` from the root. No MCP connection or real GitHub access is required.

The browser check runs the built renderer and real API/engine on disposable ports, then changes the upstream fixture. It verifies SQLite snapshots, normal GUI polling (without reloading), queue entry/exit, failure preservation, recovery, and absence of duplicate PRs. Browser console errors, runtime exceptions, unexpected external requests and unsuccessful API responses fail the check.

Failures retain a Playwright trace and screenshot in `packages/harness/test-results/`. The HTML report includes API responses, upstream requests, and server logs; open it with `pnpm --filter harness exec playwright show-report`. The same check runs in CI and uploads its report on failure.
4 changes: 3 additions & 1 deletion packages/harness/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "vitest run",
"scenario": "tsx --conditions=development src/run.ts"
"scenario": "tsx --conditions=development src/run.ts",
"test:e2e": "playwright test"
},
"dependencies": {
"sync": "workspace:*"
},
"devDependencies": {
"@playwright/test": "^1.65.0",
"@types/node": "^25.5.0",
"tsx": "^4.19.3",
"vitest": "^4.1.2"
Expand Down
17 changes: 17 additions & 0 deletions packages/harness/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { defineConfig } from "@playwright/test";

export default defineConfig({
testDir: "./src",
testMatch: "**/*.e2e.ts",
workers: 1,
retries: 0,
timeout: 90_000,
expect: { timeout: 20_000 },
reporter: [["list"], ["html", { open: "never" }]],
use: {
browserName: "chromium",
viewport: { width: 1440, height: 900 },
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
});
89 changes: 89 additions & 0 deletions packages/harness/src/board.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { test, expect } from "@playwright/test";
import { startScenario } from "./runtime.js";
import { SCENARIO_TITLE } from "./github.js";

test("polls queue entry, queue failure, and recovery through the real sync engine", async ({
page,
}, testInfo) => {
const scenario = await startScenario();
const errors: string[] = [];
const apiResponses: { url: string; status: number }[] = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("response", (response) => {
if (response.url().includes("/api/")) {
apiResponses.push({ url: response.url(), status: response.status() });
}
});
await page.route("**/*", (route) => {
if (new URL(route.request().url()).origin !== scenario.url) {
errors.push(`Unexpected external request: ${route.request().url()}`);
return route.abort();
}
return route.continue();
});
try {
await scenario.syncPrs();
await page.goto(scenario.url);
const card = page.getByRole("listitem").filter({ hasText: SCENARIO_TITLE });
await expect(card).toHaveCount(1);
await expect(card.getByText("passing", { exact: true })).toBeVisible();
await expect(
card.getByRole("img", { name: "Ready for review", exact: true }),
).toBeVisible();

scenario.setPhase("queued");
await scenario.syncPrs();
expect(await scenario.persistedPrs()).toMatchObject([
{ number: 101, inMergeQueue: true, ciStatus: "pending" },
]);
// No page reload or response mocking: the renderer's normal poll must
// discover the engine's newly persisted snapshot.
await expect(
card.getByRole("img", { name: "In merge queue", exact: true }),
).toBeVisible();
await expect(card.getByText("pending", { exact: true })).toBeVisible();
await expect(card).toHaveCount(1);

scenario.setPhase("queue-failed");
await scenario.syncPrs();
expect(await scenario.persistedPrs()).toMatchObject([
{ number: 101, inMergeQueue: false, ciStatus: "failure" },
]);
await expect(
card.getByRole("img", { name: "In merge queue", exact: true }),
).toHaveCount(0);
await expect(card.getByText("failing", { exact: true })).toBeVisible();
await expect(card).toHaveCount(1);

scenario.setPhase("offline");
await expect(scenario.syncPrs()).rejects.toThrow("Scenario sync failed");
expect(await scenario.persistedPrs()).toMatchObject([
{ number: 101, ciStatus: "failure" },
]);
await expect(card.getByText("failing", { exact: true })).toBeVisible();

scenario.setPhase("in-flight");
await scenario.syncPrs();
await expect(card.getByText("passing", { exact: true })).toBeVisible();
await expect(card).toHaveCount(1);
expect(await scenario.persistedPrs()).toHaveLength(1);
expect(errors).toEqual([]);
expect(apiResponses.length).toBeGreaterThan(3);
expect(apiResponses.every((response) => response.status === 200)).toBe(
true,
);
} finally {
await testInfo.attach("scenario-diagnostics", {
body: JSON.stringify(
{ ...scenario.diagnostics(), errors, apiResponses },
null,
2,
),
contentType: "application/json",
});
await scenario.close();
}
});
16 changes: 14 additions & 2 deletions packages/web/src/components/PrStateIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,13 @@ export function PrStateIcon({

switch (status) {
case "merge-queue":
return <GitMerge className="h-4 w-4 shrink-0 text-amber-500" />;
return (
<GitMerge
role="img"
aria-label="In merge queue"
className="h-4 w-4 shrink-0 text-amber-500"
/>
);
case "merged":
return <GitPullRequest className="h-4 w-4 shrink-0 text-purple-500" />;
case "closed":
Expand All @@ -50,6 +56,12 @@ export function PrStateIcon({
</svg>
);
case "ready":
return <GitPullRequest className="h-4 w-4 shrink-0 text-green-600" />;
return (
<GitPullRequest
role="img"
aria-label="Ready for review"
className="h-4 w-4 shrink-0 text-green-600"
/>
);
}
}
28 changes: 28 additions & 0 deletions pnpm-lock.yaml

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

Loading