Skip to content
Open
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
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,10 @@ make vet # go vet

### End-to-End Tests

**E2E tests are non-negotiable.** Every major feature, bug fix, and refactor must include e2e tests that exercise the full stack (HTTP API with real SQLite). Even small changes merit e2e coverage when they touch API behavior, data flow between layers, or anything a user would notice if it broke. When in doubt, write the e2e test — the cost of a missing one is always higher than the cost of writing it.
Coverage of real behavior is non-negotiable; the lane is chosen to avoid the one expensive cost — the **browser**, not the Go backend. Two independent axes:

- **Browser only when you must.** Reserve Playwright for real rendering/layout: screenshots/video, `getBoundingClientRect`, scroll/sticky/overflow geometry, container queries, pointer drag, viewport emulation, canvas/xterm, computed CSS pixels. Everything else runs in **Vitest + jsdom** (`vp test`) — mount the real `App.svelte` via `frontend/src/test/appHarness.ts`. Mounting the whole app or using routing is not a reason to stay in Playwright.
- **Real Go backend by default; mocking is the exception.** Backend-dependent behavior (sync, persistence, capabilities, normalization, wire shape) must hit real Go (`frontend/tests/e2e-full/` or `internal/server/` Go tests) — don't assert backend-computed values through a hand-written fixture. Mock the API (`frontend/src/test/mockApiFetch.ts`, never fork the Playwright copy) only for the rare scenario the seeded server can't produce.

### Test Guidelines

Expand Down Expand Up @@ -168,7 +171,7 @@ make vet # go vet
- Use conventional commit messages whose subject explains the reason or user-visible outcome, not just the mechanical change. Good subjects answer "why does this commit exist?" (for example, `fix: restore workspace activity for launched agents`), while vague mechanics such as `fix: run agents under tmux` are not acceptable on their own
- Commit bodies must add any important context about the bug, regression, constraint, or tradeoff that motivated the change; do not rely on the diff to explain intent
- Run tests before committing when applicable
- Before pushing any frontend change, you must have run the full affected Playwright e2e suite locally after the final frontend/test edit; focused component tests, type checks, and CI-only verification are not enough.
- Before pushing any frontend change, you must have run the full affected suite locally after the final frontend/test edit — the full `vp test` Vitest run, plus the full affected Playwright e2e suite whenever the change touches Playwright specs or the shared mock fixtures they rely on; type checks and CI-only verification are not enough.
- Never push new workstreams unless explicitly asked. When addressing review feedback or CI failures on an existing PR, an agent may push after the fix is implemented and relevant local validation has run.

## Pull Requests
Expand Down
215 changes: 215 additions & 0 deletions frontend/src/App.activity-collapse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
// Threaded activity collapse behavior exercised through the real app shell
// with the API mocked at the fetch boundary.

import { cleanup, screen, waitFor } from "@testing-library/svelte";
import { fireEvent } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { installAppDomGlobals, mountApp, resetKeyboardModuleState, type MountedApp } from "./test/appHarness.js";
import { jsonResponse, type MockRouteOverride } from "./test/mockApiFetch.js";

function event(id: string, number: number, type: string, created: string): unknown {
return {
id,
cursor: id,
activity_type: type,
author: "marius",
body_preview: "",
created_at: created,
item_number: number,
item_state: "open",
item_title: number === 42 ? "Add browser regression coverage" : "Refactor theme system",
item_type: "pr",
item_url: `https://github.com/acme/widgets/pull/${number}`,
platform_host: "github.com",
repo_owner: "acme",
repo_name: "widgets",
repo: {
provider: "github",
platform_host: "github.com",
owner: "acme",
name: "widgets",
repo_path: "acme/widgets",
capabilities: {},
},
};
}

function activitySettings(viewMode: "flat" | "threaded"): MockRouteOverride {
return (req) => {
if (req.method !== "GET" || req.url.pathname !== "/api/v1/settings") return null;
return jsonResponse({
repos: [
{
provider: "github",
platform_host: "github.com",
owner: "acme",
name: "widgets",
repo_path: "acme/widgets",
is_glob: false,
matched_repo_count: 1,
},
],
activity: {
view_mode: viewMode,
time_range: "7d",
hide_closed: false,
hide_bots: false,
collapse_threads: false,
},
terminal: {
font_family: "",
font_size: 14,
scrollback: 1000,
line_height: 1,
letter_spacing: 0,
cursor_blink: true,
font_ligatures: false,
renderer: "xterm",
},
agents: [],
});
};
}

function activityItems(items: unknown[]): MockRouteOverride {
return (req) => {
if (req.method !== "GET" || req.url.pathname !== "/api/v1/activity") return null;
return jsonResponse({ capped: false, items });
};
}

const defaultEvents = [
event("a1", 42, "comment", "2026-03-30T14:00:00Z"),
event("a2", 42, "review", "2026-03-30T13:00:00Z"),
event("b1", 55, "comment", "2026-03-30T12:00:00Z"),
];

async function mountThreadedActivity(): Promise<MountedApp> {
const app = await mountApp("/?view=threaded", {
overrides: [activitySettings("threaded"), activityItems(defaultEvents)],
});
await waitFor(() => expect(itemRows()).toHaveLength(2));
return app;
}

function itemRows(): Element[] {
return Array.from(document.querySelectorAll(".item-row"));
}

function eventRows(): Element[] {
return Array.from(document.querySelectorAll(".event-row"));
}

describe("threaded activity collapse", () => {
vi.setConfig({ testTimeout: 20_000 });

beforeEach(() => {
installAppDomGlobals();
});

afterEach(async () => {
cleanup();
vi.unstubAllGlobals();
localStorage.clear();
await resetKeyboardModuleState();
});

it("collapses, drills into one item, and persists across reload", async () => {
const app = await mountThreadedActivity();
expect(eventRows().length).toBeGreaterThan(0);

await fireEvent.click(screen.getByRole("button", { name: "Collapse all" }));
await waitFor(() => expect(eventRows()).toHaveLength(0));
expect(itemRows()).toHaveLength(2);

// Drill into a single item via its caret.
await fireEvent.click(itemRows()[0]!.querySelector(".thread-caret")!);
await waitFor(() => expect(eventRows().length).toBeGreaterThan(0));

// Collapse-all wrote ?collapsed=1; a reload (remount at the current
// URL) restores the collapsed state and clears the session-only
// single-item override.
expect(window.location.search).toContain("collapsed=1");
app.unmount();

await mountApp(window.location.pathname + window.location.search, {
overrides: [activitySettings("threaded"), activityItems(defaultEvents)],
});
await waitFor(() => expect(itemRows()).toHaveLength(2));
expect(eventRows()).toHaveLength(0);
});

it("collapse control works while the side detail pane is open", async () => {
await mountThreadedActivity();

// Open a detail by clicking the item row body (not the caret).
await fireEvent.click(itemRows()[0]!.querySelector(".item-title")!);
await waitFor(() => expect(document.querySelector(".activity-detail")).not.toBeNull());
expect(document.querySelector(".activity-pane")).not.toBeNull();

// Opening the side pane switches the feed into compact mode, where the
// control keeps its accessible name (CSS hides only the text label —
// that visual hiding is asserted in the browser lane, see
// tests/e2e/activity-collapse-compact-label.spec.ts). Here we verify the
// behavior jsdom can see: compact mode is active and the control still
// collapses the threads.
expect(document.querySelector(".activity-feed--compact")).not.toBeNull();
const collapseBtn = screen.getByRole("button", { name: "Collapse all" });

await fireEvent.click(collapseBtn);
await waitFor(() => expect(eventRows()).toHaveLength(0));
});

it("expand all restores every item's events", async () => {
await mountThreadedActivity();
const initialCount = eventRows().length;
expect(initialCount).toBeGreaterThan(0);

await fireEvent.click(screen.getByRole("button", { name: "Collapse all" }));
await waitFor(() => expect(eventRows()).toHaveLength(0));

// The control flips to Expand all; clicking it brings every event back.
await fireEvent.click(screen.getByRole("button", { name: "Expand all" }));
await waitFor(() => expect(eventRows()).toHaveLength(initialCount));
});

// Starting from flat view mode, the test switches to Threaded through the
// View dropdown before exercising the collapse controls.
it("switches to threaded via the View dropdown, then collapse/expand all", async () => {
await mountApp("/", {
overrides: [activitySettings("flat"), activityItems(defaultEvents)],
});
await waitFor(() => expect(document.querySelector(".activity-table .activity-row")).not.toBeNull());

await fireEvent.click(screen.getByRole("button", { name: "View" }));
await fireEvent.click(screen.getByRole("button", { name: "Threaded" }));
await waitFor(() => expect(document.querySelector(".threaded-view .item-row")).not.toBeNull());

const initialCount = eventRows().length;
expect(initialCount).toBeGreaterThan(0);

await fireEvent.click(screen.getByRole("button", { name: "Collapse all" }));
await waitFor(() => expect(eventRows()).toHaveLength(0));
expect(document.querySelector(".threaded-view .item-row")).not.toBeNull();

await fireEvent.click(screen.getByRole("button", { name: "Expand all" }));
await waitFor(() => expect(eventRows().length).toBeGreaterThan(0));
});

it("a single caret expands only its own item after collapse all", async () => {
await mountThreadedActivity();
const fullCount = eventRows().length;
expect(fullCount).toBeGreaterThan(1);

await fireEvent.click(screen.getByRole("button", { name: "Collapse all" }));
await waitFor(() => expect(eventRows()).toHaveLength(0));

await fireEvent.click(itemRows()[0]!.querySelector(".thread-caret")!);

// Only the clicked item's events reappear; the rest stay collapsed.
await waitFor(() => expect(eventRows().length).toBeGreaterThan(0));
const partial = eventRows().length;
expect(partial).toBeLessThan(fullCount);
});
});
154 changes: 154 additions & 0 deletions frontend/src/App.activity-thread-runs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Same-author event runs collapse into a single summary row in threaded
// activity, rendered through the real app shell with the API mocked at the
// fetch boundary.

import { cleanup, waitFor } from "@testing-library/svelte";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { installAppDomGlobals, mountApp, resetKeyboardModuleState } from "./test/appHarness.js";
import { jsonResponse, type MockRouteOverride } from "./test/mockApiFetch.js";

const REPO = {
provider: "github",
platform_host: "github.com",
owner: "acme",
name: "widgets",
repo_path: "acme/widgets",
capabilities: {},
};

function prEvent(args: {
id: string;
type: "comment" | "review" | "commit" | "force_push" | "new_pr";
author: string;
createdAt: string;
}): unknown {
return {
id: args.id,
cursor: args.id,
activity_type: args.type,
author: args.author,
body_preview: "",
created_at: args.createdAt,
item_number: 42,
item_state: "open",
item_title: "Add browser regression coverage",
item_type: "pr",
item_url: "https://github.com/acme/widgets/pull/42",
platform_host: "github.com",
repo_owner: "acme",
repo_name: "widgets",
repo: REPO,
};
}

const settingsOverride: MockRouteOverride = (req) => {
if (req.method !== "GET" || req.url.pathname !== "/api/v1/settings") return null;
return jsonResponse({
repos: [
{
provider: "github",
platform_host: "github.com",
owner: "acme",
name: "widgets",
repo_path: "acme/widgets",
is_glob: false,
matched_repo_count: 1,
},
],
activity: {
view_mode: "threaded",
time_range: "7d",
hide_closed: false,
hide_bots: false,
collapse_threads: false,
},
terminal: {
font_family: "",
font_size: 14,
scrollback: 1000,
line_height: 1,
letter_spacing: 0,
cursor_blink: true,
font_ligatures: false,
renderer: "xterm",
},
agents: [],
});
};

function activityItems(items: unknown[]): MockRouteOverride {
return (req) => {
if (req.method !== "GET" || req.url.pathname !== "/api/v1/activity") return null;
return jsonResponse({ capped: false, items });
};
}

async function mountThreadedActivity(items: unknown[]): Promise<void> {
await mountApp("/?view=threaded", {
overrides: [settingsOverride, activityItems(items)],
});
await waitFor(() => expect(document.querySelector(".item-row")).not.toBeNull());
}

function collapsedEventRows(): Element[] {
return Array.from(document.querySelectorAll(".event-row.collapsed-event"));
}

function plainEventRows(): Element[] {
return Array.from(document.querySelectorAll(".event-row:not(.collapsed-event)"));
}

describe("threaded activity run collapse", () => {
vi.setConfig({ testTimeout: 20_000 });

beforeEach(() => {
installAppDomGlobals();
});

afterEach(async () => {
cleanup();
vi.unstubAllGlobals();
localStorage.clear();
await resetKeyboardModuleState();
});

it("collapses a run of three or more reviews from the same author", async () => {
await mountThreadedActivity([
prEvent({ id: "r5", type: "review", author: "alice", createdAt: "2026-04-27T15:00:00Z" }),
prEvent({ id: "r4", type: "review", author: "alice", createdAt: "2026-04-27T14:00:00Z" }),
prEvent({ id: "r3", type: "review", author: "alice", createdAt: "2026-04-27T13:00:00Z" }),
prEvent({ id: "r2", type: "review", author: "alice", createdAt: "2026-04-27T12:00:00Z" }),
prEvent({ id: "r1", type: "review", author: "alice", createdAt: "2026-04-27T11:00:00Z" }),
]);

const collapsed = collapsedEventRows().filter((row) => row.textContent?.includes("5 reviews"));
expect(collapsed).toHaveLength(1);
expect(collapsed[0]!.querySelector(".evt-review")).not.toBeNull();
expect(plainEventRows()).toHaveLength(0);
});

it("collapses comments and reviews into separate runs by event type", async () => {
await mountThreadedActivity([
prEvent({ id: "c3", type: "comment", author: "alice", createdAt: "2026-04-27T16:00:00Z" }),
prEvent({ id: "c2", type: "comment", author: "alice", createdAt: "2026-04-27T15:00:00Z" }),
prEvent({ id: "c1", type: "comment", author: "alice", createdAt: "2026-04-27T14:00:00Z" }),
prEvent({ id: "r3", type: "review", author: "alice", createdAt: "2026-04-27T13:00:00Z" }),
prEvent({ id: "r2", type: "review", author: "alice", createdAt: "2026-04-27T12:00:00Z" }),
prEvent({ id: "r1", type: "review", author: "alice", createdAt: "2026-04-27T11:00:00Z" }),
]);

expect(collapsedEventRows().filter((row) => row.textContent?.includes("3 comments"))).toHaveLength(1);
expect(collapsedEventRows().filter((row) => row.textContent?.includes("3 reviews"))).toHaveLength(1);
});

it("leaves short runs of comments unrolled", async () => {
await mountThreadedActivity([
prEvent({ id: "c2", type: "comment", author: "alice", createdAt: "2026-04-27T13:00:00Z" }),
prEvent({ id: "c1", type: "comment", author: "alice", createdAt: "2026-04-27T12:00:00Z" }),
]);

expect(collapsedEventRows()).toHaveLength(0);
expect(plainEventRows()).toHaveLength(2);
});
});
Loading
Loading