diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fa2e77f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +name: CI + +on: + push: + pull_request: + +jobs: + checks: + name: checks + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: | + package-lock.json + web/package-lock.json + + - name: Install root dependencies + run: npm ci + + - name: Install frontend dependencies + run: npm ci + working-directory: web + + - name: Run backend tests + run: npm run test:backend + + - name: Run frontend tests + run: npm --prefix web test + + - name: Build frontend + run: npm --prefix web run build + + e2e: + name: e2e + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: | + package-lock.json + web/package-lock.json + + - name: Install root dependencies + run: npm ci + + - name: Install frontend dependencies + run: npm ci + working-directory: web + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + + - name: Run end-to-end tests + run: npm run test:e2e + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + if-no-files-found: ignore + + - name: Upload Playwright test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: test-results/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 526d916..07fcf65 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ node_modules/ /dist/ data/*.db +data/*.db-shm +data/*.db-wal +/playwright-report/ +/test-results/ diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts new file mode 100644 index 0000000..c70cc06 --- /dev/null +++ b/e2e/app.spec.ts @@ -0,0 +1,511 @@ +import { expect, test } from "@playwright/test"; +import { + column, + gotoApp, + makeMainTicket, + makeProject, + makeTicket, + openProjectSelector, + openSettings, + projectBoard, + projectCard, + resetAppState, + selectProject, +} from "./helpers"; + +test.beforeEach(async ({ request }) => { + await resetAppState(request); +}); + +test("loads the empty state and header controls", async ({ page }) => { + await gotoApp(page); + + await expect(page.getByTestId("project-selector-trigger")).toBeVisible(); + await expect(page.getByRole("button", { name: "Toggle theme" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Open Settings" })).toBeVisible(); + await expect(page.getByText("No projects selected")).toBeVisible(); + await expect(page.getByText("Select up to 3 projects from the dropdown above")).toBeVisible(); +}); + +test("restores stored project selection and cleans invalid saved ids", async ({ + page, + request, +}) => { + const project = makeProject({ id: "alpha", name: "Alpha" }); + await resetAppState(request, { + settings: { + selected_projects: JSON.stringify(["missing", "alpha"]), + }, + projects: [project], + tickets: [makeMainTicket(project.id)], + }); + + await gotoApp(page); + + await expect(projectBoard(page, project.id)).toBeVisible(); + await expect(page.getByTestId("project-selector-trigger")).toContainText("Alpha"); + + const settingsResponse = await request.get( + "http://127.0.0.1:3325/api/settings/selected_projects" + ); + const settingsJson = (await settingsResponse.json()) as { value: string | null }; + expect(settingsJson.value).toBe('["alpha"]'); +}); + +test("selects and unselects projects and enforces the three-project limit", async ({ + page, + request, +}) => { + const projects = [ + makeProject({ id: "alpha", name: "Alpha" }), + makeProject({ id: "beta", name: "Beta" }), + makeProject({ id: "gamma", name: "Gamma" }), + makeProject({ id: "delta", name: "Delta" }), + ]; + + await resetAppState(request, { + projects, + tickets: projects.map((project) => makeMainTicket(project.id)), + }); + + await gotoApp(page); + + await selectProject(page, "alpha"); + await expect(projectBoard(page, "alpha")).toBeVisible(); + await expect(page.getByTestId("project-selector-trigger")).toContainText("Alpha"); + + await page.getByTestId("project-option-alpha").click(); + await expect(page.getByText("No projects selected")).toBeVisible(); + + await page.mouse.click(10, 10); + await selectProject(page, "alpha"); + await selectProject(page, "beta"); + await selectProject(page, "gamma"); + await expect(projectBoard(page, "alpha")).toBeVisible(); + await expect(projectBoard(page, "beta")).toBeVisible(); + await expect(projectBoard(page, "gamma")).toBeVisible(); + + await page.getByTestId("project-option-delta").click(); + await expect(page.getByText("Maximum 3 projects can be selected")).toBeVisible(); + await expect(projectBoard(page, "delta")).toHaveCount(0); +}); + +test("opens settings, switches sections, and persists the PR polling interval", async ({ + page, + request, +}) => { + await gotoApp(page); + await openSettings(page); + + const intervalInput = page.locator("#pr-poll-interval"); + await expect(intervalInput).toBeVisible(); + await expect(intervalInput).toHaveValue("2"); + + await page.getByRole("button", { name: "Projects" }).click(); + await expect(page.getByText("Existing Projects")).toBeVisible(); + await page.getByRole("button", { name: "General" }).click(); + + await intervalInput.fill("0.25"); + await intervalInput.blur(); + await expect(intervalInput).toHaveValue("0.5"); + + await page.keyboard.press("Escape"); + await page.reload(); + await openSettings(page); + await expect(page.locator("#pr-poll-interval")).toHaveValue("0.5"); + + const response = await request.get("http://127.0.0.1:3325/api/settings/prPollInterval"); + const body = (await response.json()) as { value: string | null }; + expect(body.value).toBe("30000"); +}); + +test("creates, updates, and deletes a project from settings", async ({ page, request }) => { + await gotoApp(page); + await openSettings(page); + await page.getByRole("button", { name: "Projects" }).click(); + + await page.getByLabel("Project name").fill("Delta"); + await page.getByLabel("Project path").fill("/tmp/taskforce-e2e-project"); + await page.getByLabel("Post-worktree command").fill("pnpm install"); + await page.getByRole("button", { name: "Create Project" }).click(); + + await expect(page.getByText("Delta")).toBeVisible(); + const projectsResponse = await request.get("http://127.0.0.1:3325/api/projects"); + const projectsJson = (await projectsResponse.json()) as Array<{ id: string; name: string }>; + const createdProject = projectsJson.find((project) => project.name === "Delta"); + expect(createdProject).toBeTruthy(); + await page.keyboard.press("Escape"); + + await openProjectSelector(page); + await page.getByRole("button", { name: "Delta" }).click(); + await expect(page.getByRole("heading", { name: "Delta" })).toBeVisible(); + await expect( + page.locator('[data-testid^="ticket-card-"]').filter({ hasText: "main" }) + ).toBeVisible(); + + await openSettings(page); + await page.getByRole("button", { name: "Projects" }).click(); + const deltaCard = projectCard(page, createdProject!.id); + await deltaCard.getByLabel(/Post-worktree command/).fill("pnpm dev"); + await deltaCard.getByLabel(/Post-worktree command/).blur(); + await deltaCard.getByRole("switch").click(); + await deltaCard.getByRole("combobox").click(); + await page.getByRole("option", { name: "VS Code" }).click(); + await deltaCard.getByLabel(/New pane name/).fill("server"); + await deltaCard.getByRole("button", { name: /Add pane/ }).click(); + await page.keyboard.press("Escape"); + + await page.locator('[data-testid^="ticket-card-"]').filter({ hasText: "main" }).click(); + await expect(page.getByTestId("terminal-tab-server")).toBeVisible(); + await page.getByRole("button", { name: "Close Terminal Panel" }).click(); + + await openSettings(page); + await page.getByRole("button", { name: "Projects" }).click(); + const persistedCard = projectCard(page, createdProject!.id); + await expect(persistedCard.getByLabel(/Post-worktree command/)).toHaveValue("pnpm dev"); + await expect(persistedCard.getByRole("switch")).toHaveAttribute("aria-checked", "false"); + await expect(persistedCard.getByRole("combobox")).toContainText("VS Code"); + await persistedCard.getByRole("button", { name: "Remove pane server" }).click(); + await persistedCard.getByRole("button", { name: /Delete project Delta/ }).click(); + await expect(page.getByText("Delta")).toHaveCount(0); +}); + +test("renders git commit info and the board action menu", async ({ page, request }) => { + const project = makeProject({ id: "alpha", name: "Alpha" }); + await resetAppState(request, { + settings: { + selected_projects: JSON.stringify([project.id]), + }, + projects: [project], + tickets: [makeMainTicket(project.id)], + }); + + await gotoApp(page); + + await expect(projectBoard(page, project.id)).toContainText("Alpha"); + await expect(projectBoard(page, project.id)).toContainText("Initial test commit"); + await expect(projectBoard(page, project.id).locator(".font-mono")).toContainText(/[0-9a-f]{7}/); + + await page.getByRole("button", { name: "Open Alpha board menu" }).click(); + await expect(page.getByText("Pull")).toBeVisible(); + await expect(page.getByText("Add Ticket")).toBeVisible(); + await expect(page.getByText("Open Branch")).toBeVisible(); +}); + +test("creates tickets manually, from branch metadata, edits them, and deletes them", async ({ + page, + request, +}) => { + const project = makeProject({ id: "alpha", name: "Alpha", useWorktrees: false }); + const mainTicket = makeMainTicket(project.id); + + await resetAppState(request, { + settings: { + selected_projects: JSON.stringify([project.id]), + }, + projects: [project], + tickets: [mainTicket], + }); + + await gotoApp(page); + + await page.getByRole("button", { name: "Open Alpha board menu" }).click(); + await page.getByText("Add Ticket").click(); + await expect(page.getByRole("button", { name: "Create" })).toBeDisabled(); + await page.getByPlaceholder("Ticket title").fill("Ship dashboard"); + await page.getByPlaceholder("Description (optional)").fill("Build the new dashboard"); + await expect(page.getByRole("button", { name: "Create" })).toBeEnabled(); + await page.getByRole("button", { name: "Create" }).click(); + await expect(column(page, project.id, "To Do")).toContainText("Ship dashboard"); + + await page.route("**/api/tickets/pr-info?*", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + title: "Polish release flow", + headRefName: "release/polish-flow", + }), + }); + }); + + await page.getByRole("button", { name: "Open Alpha board menu" }).click(); + await page.getByText("Add Ticket").click(); + await page + .getByPlaceholder("PR link (optional, e.g., https://github.com/...)") + .evaluate((input, url) => { + const element = input as HTMLInputElement; + element.value = url; + element.dispatchEvent(new Event("input", { bubbles: true })); + const data = new DataTransfer(); + data.setData("text/plain", url); + element.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, clipboardData: data })); + }, "https://github.com/openai/taskforce/pull/12"); + await expect(page.getByPlaceholder("Ticket title")).toHaveValue("Polish release flow"); + await expect( + page.getByPlaceholder("Base branch (optional, defaults to current branch)") + ).toHaveValue("release/polish-flow"); + await page.getByRole("button", { name: "Create" }).click(); + await expect(column(page, project.id, "To Do")).toContainText("Polish release flow"); + await page.unroute("**/api/tickets/pr-info?*"); + + await page.getByRole("button", { name: "Open Alpha board menu" }).click(); + await page.getByText("Open Branch").click(); + await expect(page.getByRole("button", { name: "Open Branch" })).toBeDisabled(); + await page + .getByPlaceholder("Branch name (e.g., feature-x or origin/feature-x)") + .fill("hotfix/login"); + await page.getByPlaceholder("Description (optional)").fill("Hotfix branch"); + await page.getByRole("button", { name: "Open Branch" }).click(); + await expect(column(page, project.id, "To Do")).toContainText("hotfix/login"); + + const createdTicketCard = column(page, project.id, "To Do") + .locator('[data-testid^="ticket-card-"]') + .filter({ hasText: "Ship dashboard" }); + await createdTicketCard.hover(); + await createdTicketCard.getByRole("button", { name: "Edit Ship dashboard" }).click(); + await page.getByPlaceholder("Description (optional)").fill("Updated description"); + await page + .getByPlaceholder("PR link (optional, e.g., https://github.com/...)") + .fill("github.com/invalid"); + page.once("dialog", (dialog) => { + expect(dialog.message()).toContain("PR link must be a valid URL"); + void dialog.accept(); + }); + await page.getByRole("button", { name: "Update" }).click(); + await page + .getByPlaceholder("PR link (optional, e.g., https://github.com/...)") + .fill("https://github.com/openai/taskforce/pull/99"); + await page.getByRole("button", { name: "Update" }).click(); + await expect(column(page, project.id, "To Do")).toContainText("Updated description"); + + const branchCard = column(page, project.id, "To Do") + .locator('[data-testid^="ticket-card-"]') + .filter({ hasText: "hotfix/login" }); + await branchCard.hover(); + await branchCard.getByRole("button", { name: "Delete hotfix/login" }).click(); + await page.getByRole("button", { name: "Delete" }).click(); + await expect(column(page, project.id, "To Do")).not.toContainText("hotfix/login"); + await expect(page.getByRole("button", { name: "Delete main" })).toHaveCount(0); +}); + +test("moves tickets between columns and clears the manual override", async ({ page, request }) => { + const project = makeProject({ id: "alpha", name: "Alpha" }); + const ticket = makeTicket(project.id, { id: "ticket-1", title: "Move me" }); + + await resetAppState(request, { + settings: { + selected_projects: JSON.stringify([project.id]), + }, + projects: [project], + tickets: [makeMainTicket(project.id), ticket], + }); + + await gotoApp(page); + + await request.patch(`http://127.0.0.1:3325/api/tickets/${ticket.id}`, { + data: { column: "In Progress" }, + }); + await page.reload(); + const movedTicketCard = page.getByTestId(`ticket-card-${ticket.id}`); + await expect(column(page, project.id, "In Progress")).toContainText("Move me"); + await expect( + movedTicketCard.getByRole("button", { name: "Clear manual status override for Move me" }) + ).toBeVisible(); + + await request.patch(`http://127.0.0.1:3325/api/tickets/${ticket.id}`, { + data: { column: "Done" }, + }); + await page.reload(); + await expect(column(page, project.id, "Done")).toContainText("Move me"); + + await page + .getByTestId(`ticket-card-${ticket.id}`) + .getByRole("button", { name: "Clear manual status override for Move me" }) + .click(); + await expect(page.getByText("Override cleared")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Clear manual status override for Move me" }) + ).toHaveCount(0); +}); + +test("opens and closes the terminal panel, switches panes, and shows setup failure state", async ({ + page, + request, +}) => { + const project = makeProject({ + id: "alpha", + name: "Alpha", + panes: [{ name: "server" }], + }); + const readyTicket = makeTicket(project.id, { + id: "ticket-ready", + title: "Ready ticket", + worktreePath: project.path, + }); + const setupTicket = makeTicket(project.id, { + id: "ticket-setup", + title: "Setup ticket", + setupStatus: "running_post_command", + setupTmuxSession: "ticket-setup-session", + setupLogs: "Installing dependencies...", + }); + const failedTicket = makeTicket(project.id, { + id: "ticket-failed", + title: "Failed ticket", + setupStatus: "failed", + setupTmuxSession: "ticket-failed-session", + setupLogs: "npm ERR! failed", + setupError: "Setup exploded", + }); + + await resetAppState(request, { + settings: { + selected_projects: JSON.stringify([project.id]), + }, + projects: [project], + tickets: [makeMainTicket(project.id), readyTicket, setupTicket, failedTicket], + }); + + await gotoApp(page); + + await page.getByText("Ready ticket").click(); + await expect(page.getByTestId("terminal-panel")).toBeVisible(); + await expect( + page.getByTestId("terminal-panel").getByText("Terminal", { exact: true }) + ).toBeVisible(); + await expect(page.getByTestId("terminal-tab-claude")).toHaveClass(/border-primary/); + await page.getByTestId("terminal-tab-server").click(); + await expect(page.getByTestId("terminal-tab-server")).toHaveClass(/border-primary/); + await page.getByRole("button", { name: "Close Terminal Panel" }).click(); + await expect(page.getByTestId("terminal-panel")).toHaveCount(0); + + await page.getByText("Setup ticket").click(); + await expect( + page.getByTestId("terminal-panel").getByText("Setting Up", { exact: true }) + ).toBeVisible(); + await expect(page.getByTestId("terminal-tab-setup")).toHaveClass(/border-primary/); + + await page.getByText("Failed ticket").click(); + await expect( + page.getByTestId("terminal-panel").getByText("Setup Failed", { exact: true }) + ).toBeVisible(); + await expect(page.getByTestId("terminal-tab-setup")).toBeVisible(); +}); + +test("opens PR links in a new tab, shows editor toasts, renders PR badges, and supports PR suggestions", async ({ + page, + request, +}) => { + const project = makeProject({ + id: "alpha", + name: "Alpha", + editor: "vscode", + }); + const ticket = makeTicket(project.id, { + id: "ticket-pr", + title: "PR ticket", + prLink: "https://github.com/openai/taskforce/pull/42", + prState: JSON.stringify({ + state: "OPEN", + mergeable: "MERGEABLE", + reviewDecision: "APPROVED", + checksStatus: "SUCCESS", + isDraft: false, + lastCheckedAt: Date.now(), + }), + worktreePath: project.path, + }); + + await resetAppState(request, { + settings: { + selected_projects: JSON.stringify([project.id]), + }, + projects: [project], + tickets: [makeMainTicket(project.id), ticket], + }); + + await page.route("**/api/projects/alpha/pr-suggestions", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + title: "Suggested PR", + url: "https://github.com/openai/taskforce/pull/77", + headRefName: "feature/suggested-pr", + number: 77, + }, + ]), + }); + }); + + let openEditorCalls = 0; + await page.route("**/api/tickets/ticket-pr/open-editor", async (route) => { + openEditorCalls += 1; + await route.fulfill({ + status: openEditorCalls === 1 ? 200 : 500, + contentType: "application/json", + body: JSON.stringify( + openEditorCalls === 1 + ? { success: true } + : { success: false, error: "Editor command missing" } + ), + }); + }); + + await gotoApp(page); + await expect(page.getByText("Approved")).toBeVisible(); + await expect(page.getByText("Checks passing")).toBeVisible(); + await expect(page.getByTestId("pr-suggestion-77")).toBeVisible(); + await page.getByTestId("pr-suggestion-77").click(); + await expect(column(page, project.id, "To Do")).toContainText("Suggested PR"); + + const prCard = column(page, project.id, "To Do") + .locator('[data-testid^="ticket-card-"]') + .filter({ hasText: "PR ticket" }); + await prCard.hover(); + const popupPromise = page.waitForEvent("popup"); + await prCard.getByRole("button", { name: "Open PR for PR ticket" }).click(); + const popup = await popupPromise; + await expect(popup).toHaveURL("https://github.com/openai/taskforce/pull/42"); + await expect(page.getByTestId("terminal-panel")).toHaveCount(0); + + await prCard.getByRole("button", { name: "Open PR ticket in editor" }).click(); + await expect(page.getByText("Editor opened")).toBeVisible(); + await prCard.getByRole("button", { name: "Open PR ticket in editor" }).click(); + await expect(page.getByText("Failed to open editor")).toBeVisible(); + await expect(page.getByText("Editor command missing")).toBeVisible(); +}); + +test("clicking outside closes the project dropdown and terminal panel", async ({ + page, + request, +}) => { + const project = makeProject({ id: "alpha", name: "Alpha" }); + const ticket = makeTicket(project.id, { + id: "ticket-1", + title: "Outside click", + worktreePath: project.path, + }); + + await resetAppState(request, { + settings: { + selected_projects: JSON.stringify([project.id]), + }, + projects: [project], + tickets: [makeMainTicket(project.id), ticket], + }); + + await gotoApp(page); + + await openProjectSelector(page); + await expect(page.getByText("Select up to 3 projects")).toBeVisible(); + await page.mouse.click(10, 10); + await expect(page.getByText("Select up to 3 projects")).toHaveCount(0); + + await page.getByText("Outside click").click(); + await expect(page.getByTestId("terminal-panel")).toBeVisible(); + await page.mouse.click(10, 10); + await expect(page.getByTestId("terminal-panel")).toHaveCount(0); +}); diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 0000000..eb09022 --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,179 @@ +import type { APIRequestContext, Locator, Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { execSync } from "child_process"; + +interface SeedProject { + id: string; + name: string; + path: string; + createdAt?: number; + postWorktreeCommand?: string | null; + panes?: Array<{ name: string }>; + editor?: string | null; + useWorktrees?: boolean; +} + +interface SeedTicket { + id: string; + title: string; + column?: string; + createdAt?: number; + projectId?: string | null; + worktreePath?: string | null; + isMain?: boolean | null; + setupStatus?: string; + setupError?: string | null; + setupLogs?: string | null; + setupTmuxSession?: string | null; + description?: string | null; + statusOverride?: boolean | null; + prLink?: string | null; + prState?: string | null; +} + +export async function resetAppState( + request: APIRequestContext, + data: { + settings?: Record; + projects?: SeedProject[]; + tickets?: SeedTicket[]; + } = {} +) { + const response = await request.post("http://127.0.0.1:3325/api/e2e/reset", { + data: { + settings: data.settings ?? {}, + projects: data.projects ?? [], + tickets: data.tickets ?? [], + }, + }); + + expect(response.ok()).toBeTruthy(); +} + +export function createGitRepo(name: string, commitMessage: string) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "taskforce-e2e-")); + const repoPath = path.join(root, name); + + fs.mkdirSync(repoPath, { recursive: true }); + execSync("git init -b main", { cwd: repoPath, stdio: "ignore" }); + execSync('git config user.email "e2e@example.com"', { cwd: repoPath, stdio: "ignore" }); + execSync('git config user.name "Taskforce E2E"', { cwd: repoPath, stdio: "ignore" }); + fs.writeFileSync(path.join(repoPath, "README.md"), `# ${name}\n`); + execSync("git add README.md", { cwd: repoPath, stdio: "ignore" }); + execSync(`git commit -m "${commitMessage.replace(/"/g, '\\"')}"`, { + cwd: repoPath, + stdio: "ignore", + }); + execSync("git checkout -b feature/existing-branch", { cwd: repoPath, stdio: "ignore" }); + execSync("git checkout main", { cwd: repoPath, stdio: "ignore" }); + + const hash = execSync("git log -1 --format=%h", { cwd: repoPath, encoding: "utf-8" }).trim(); + + return { path: repoPath, hash }; +} + +export function makeProject(overrides: Partial = {}): SeedProject { + const repo = createGitRepo(overrides.name ?? "project", "Initial test commit"); + return { + id: overrides.id ?? "project-1", + name: overrides.name ?? "Alpha", + path: overrides.path ?? repo.path, + createdAt: overrides.createdAt ?? Date.now(), + panes: overrides.panes ?? [], + editor: overrides.editor ?? null, + postWorktreeCommand: overrides.postWorktreeCommand ?? null, + useWorktrees: overrides.useWorktrees ?? false, + }; +} + +export function makeMainTicket(projectId: string, overrides: Partial = {}): SeedTicket { + return { + id: overrides.id ?? `${projectId}-main`, + title: overrides.title ?? "main", + column: overrides.column ?? "To Do", + createdAt: overrides.createdAt ?? Date.now(), + projectId, + isMain: overrides.isMain ?? true, + setupStatus: overrides.setupStatus ?? "ready", + worktreePath: overrides.worktreePath ?? null, + description: overrides.description ?? null, + setupError: overrides.setupError ?? null, + setupLogs: overrides.setupLogs ?? null, + setupTmuxSession: overrides.setupTmuxSession ?? null, + statusOverride: overrides.statusOverride ?? null, + prLink: overrides.prLink ?? null, + prState: overrides.prState ?? null, + }; +} + +export function makeTicket(projectId: string, overrides: Partial = {}): SeedTicket { + return { + id: overrides.id ?? `${projectId}-ticket-${Math.random().toString(36).slice(2, 8)}`, + title: overrides.title ?? "Feature Ticket", + column: overrides.column ?? "To Do", + createdAt: overrides.createdAt ?? Date.now(), + projectId, + isMain: overrides.isMain ?? false, + setupStatus: overrides.setupStatus ?? "ready", + worktreePath: overrides.worktreePath ?? null, + description: overrides.description ?? null, + setupError: overrides.setupError ?? null, + setupLogs: overrides.setupLogs ?? null, + setupTmuxSession: overrides.setupTmuxSession ?? null, + statusOverride: overrides.statusOverride ?? null, + prLink: overrides.prLink ?? null, + prState: overrides.prState ?? null, + }; +} + +export async function gotoApp(page: Page) { + await page.goto("/"); + await expect(page.getByRole("img", { name: "Taskforce" })).toBeVisible(); +} + +export async function openProjectSelector(page: Page) { + await page.getByTestId("project-selector-trigger").click(); +} + +export async function selectProject(page: Page, projectId: string) { + const option = page.getByTestId(`project-option-${projectId}`); + if (!(await option.isVisible().catch(() => false))) { + await openProjectSelector(page); + } + await option.click(); +} + +export async function openSettings(page: Page) { + await page.getByRole("button", { name: "Open Settings" }).click(); + await expect(page.getByRole("dialog")).toBeVisible(); +} + +export function projectBoard(page: Page, projectId: string): Locator { + return page.getByTestId(`project-board-${projectId}`); +} + +export function projectCard(page: Page, projectId: string): Locator { + return page.getByTestId(`project-card-${projectId}`); +} + +function columnTestId(projectId: string, columnName: string) { + return `column-${projectId}-${columnName.toLowerCase().replace(/\s+/g, "-")}`; +} + +export function column(page: Page, projectId: string, columnName: string): Locator { + return page.getByTestId(columnTestId(projectId, columnName)); +} + +export async function dragTicket( + page: Page, + ticketId: string, + projectId: string, + columnName: string +) { + await page + .getByTestId(`ticket-card-${ticketId}`) + .dragTo(column(page, projectId, columnName), { targetPosition: { x: 120, y: 120 } }); +} diff --git a/e2e/test-scenarios.md b/e2e/test-scenarios.md new file mode 100644 index 0000000..ae8a5ba --- /dev/null +++ b/e2e/test-scenarios.md @@ -0,0 +1,522 @@ +# E2E Test Scenarios + +This file captures the end-to-end scenarios worth covering for the Taskforce app at `http://localhost:3326`. + +Current automated coverage is minimal: [`e2e/app.spec.ts`](/home/alex/Projects/taskforce/e2e/app.spec.ts) only checks the empty-state board. + +## 1. Empty State And Initial Load + +### Scenario: app loads with no selected projects + +Precondition: no `selected_projects` setting is stored, or the stored project IDs no longer exist. + +Steps: + +1. Open `/`. +2. Wait for the board area to render. + +Expected: + +1. The header is visible with the Taskforce logo, project selector, theme toggle, and settings button. +2. The board shows `No projects selected`. +3. The helper text says `Select up to 3 projects from the dropdown above`. + +### Scenario: stored project selection is restored on load + +Precondition: at least one valid project exists and `selected_projects` contains its ID. + +Steps: + +1. Open `/`. +2. Wait for projects and tickets to load. + +Expected: + +1. The corresponding project board is rendered automatically. +2. The project selector label reflects the restored selection. + +## 2. Project Selection + +### Scenario: select and unselect a project from the dropdown + +Precondition: at least one project exists. + +Steps: + +1. Open the `Select Projects` dropdown. +2. Select a project. +3. Close and reopen the dropdown. +4. Unselect the same project. + +Expected: + +1. Selecting a project renders its board. +2. The dropdown button label changes from `Select Projects` to the project name. +3. Unselecting removes the board and returns to the empty state. + +### Scenario: select multiple projects up to the limit + +Precondition: at least four projects exist. + +Steps: + +1. Open the project dropdown. +2. Select three distinct projects. +3. Attempt to select a fourth project. + +Expected: + +1. Exactly three boards are shown. +2. The fourth project cannot be selected. +3. The app shows the `Maximum 3 projects can be selected` toast. + +## 3. Settings And Project Management + +### Scenario: open settings and switch sections + +Steps: + +1. Click the settings button. +2. Verify the `General` section. +3. Switch to the `Projects` section. + +Expected: + +1. The settings dialog opens. +2. `General` shows the PR poll interval field. +3. `Projects` shows existing projects and the create-project form. + +### Scenario: update the PR polling interval + +Steps: + +1. Open settings. +2. In `General`, change the PR interval value. +3. Blur the field. +4. Reload the page and reopen settings. + +Expected: + +1. The new value is persisted. +2. The saved value is rehydrated after reload. +3. Values below `0.5` are clamped to `0.5`. + +### Scenario: create a new project + +Steps: + +1. Open settings. +2. Go to `Projects`. +3. Fill `Project name` and `/path/to/your/project`. +4. Optionally fill the post-worktree command and toggle `Use worktrees`. +5. Submit the form. + +Expected: + +1. The project appears in `Existing Projects`. +2. The project becomes available in the project selector. +3. A main ticket is created for the new project. + +### Scenario: edit project-level settings + +Precondition: at least one project exists. + +Steps: + +1. Open settings and go to `Projects`. +2. Change the post-worktree command. +3. Change the editor. +4. Toggle `Use worktrees`. + +Expected: + +1. Each change persists after closing and reopening settings. +2. The updated project configuration is reflected in subsequent ticket flows. + +### Scenario: manage custom terminal panes + +Precondition: at least one project exists. + +Steps: + +1. Open settings and go to `Projects`. +2. Add a custom pane name. +3. Close settings. +4. Open any ticket terminal panel. +5. Reopen settings and remove the custom pane. + +Expected: + +1. The custom pane appears as a terminal tab for that project's tickets. +2. Removing the pane removes the corresponding tab. +3. Duplicate pane names are rejected with a visible validation message. + +### Scenario: delete a project + +Precondition: at least one non-critical test project exists. + +Steps: + +1. Open settings and go to `Projects`. +2. Delete the project. + +Expected: + +1. The project disappears from settings and the project selector. +2. Its tickets are removed from the board. +3. Any invalid persisted selection is cleaned up on the next load. + +## 4. Board Header Actions + +### Scenario: board header shows git commit info + +Precondition: the selected project's path is a valid git repository. + +Steps: + +1. Select the project. +2. Wait for the board header to load. + +Expected: + +1. The board header shows the project name. +2. The latest short commit hash and commit message are visible. + +### Scenario: open board action menu + +Precondition: a project board is visible. + +Steps: + +1. Click the board menu button. + +Expected: + +1. The menu contains `Pull`, `Add Ticket`, and `Open Branch`. + +### Scenario: pull latest changes + +Precondition: a selected project exists and `git pull` is allowed in its repo. + +Steps: + +1. Open the board menu. +2. Click `Pull`. + +Expected: + +1. The pull action enters a loading state while running. +2. The action completes without breaking the board. +3. Commit info can refresh on subsequent polling or reload. + +## 5. Ticket Creation And Management + +### Scenario: open the create-ticket dialog + +Precondition: a project board is visible. + +Steps: + +1. Open the board menu. +2. Click `Add Ticket`. + +Expected: + +1. The dialog shows fields for title, description, PR link, and base branch. +2. The submit button is available only when the title is non-empty. + +### Scenario: create a ticket manually + +Precondition: a project board is visible. + +Steps: + +1. Open `Add Ticket`. +2. Fill the title and optional metadata. +3. Submit the form. + +Expected: + +1. A new ticket appears in `To Do`. +2. If worktrees are enabled, the ticket enters setup states and eventually becomes ready or failed. +3. If worktrees are disabled, the ticket is created directly in the ready state. + +### Scenario: create a ticket from PR metadata + +Precondition: the backend can resolve GitHub PR info through `gh`. + +Steps: + +1. Open `Add Ticket`. +2. Paste a valid GitHub PR URL. + +Expected: + +1. The PR fetch spinner appears while loading. +2. The title is auto-filled from the PR title. +3. The base branch is auto-filled from the PR head ref. + +### Scenario: open an existing branch as a ticket + +Precondition: a project board is visible. + +Steps: + +1. Open the board menu. +2. Click `Open Branch`. +3. Fill the branch name and optional description. +4. Submit. + +Expected: + +1. A ticket is created in `To Do`. +2. If worktrees are enabled, branch checkout/setup begins in the background. +3. If worktrees are disabled, the ticket is immediately ready. + +### Scenario: edit a ticket + +Precondition: at least one non-main ticket exists. + +Steps: + +1. Hover the ticket and click the edit icon. +2. Update the description and PR link. +3. Submit. + +Expected: + +1. The dialog closes successfully. +2. The updated description is shown on the card. +3. The updated PR link is persisted. +4. Invalid PR links without `http://` or `https://` are rejected. + +### Scenario: delete a ticket + +Precondition: at least one non-main ticket exists. + +Steps: + +1. Hover the ticket and click the delete icon. +2. Confirm the alert dialog. + +Expected: + +1. The ticket is removed from the board. +2. Cleanup runs for any associated tmux session or worktree. +3. Main tickets do not expose the delete action. + +## 6. Kanban Status Flow + +### Scenario: drag a ticket between columns + +Precondition: at least one movable ticket exists. + +Steps: + +1. Drag a non-main ticket from `To Do` to `In Progress`. +2. Drag the same ticket to `Done`. + +Expected: + +1. The card reorders visually in the new column. +2. The column counts update. +3. The backend persists the new column. +4. The timer resets when the ticket enters a new column. + +### Scenario: manual move enables status override + +Precondition: at least one ticket exists. + +Steps: + +1. Drag a ticket to a different column. + +Expected: + +1. The ticket receives manual status override in the backend. +2. The card shows the lock indicator. + +### Scenario: clear the manual status override + +Precondition: a ticket already has a manual status override. + +Steps: + +1. Click the lock icon on the ticket. + +Expected: + +1. The lock icon disappears. +2. The app shows `Override cleared`. +3. Automatic status tracking is re-enabled. + +## 7. Terminal Panel + +### Scenario: open and close the terminal panel from a ticket + +Precondition: a visible ticket exists. + +Steps: + +1. Click a ticket card. +2. Close the terminal panel using the close button. + +Expected: + +1. Clicking the card opens the terminal panel on the right. +2. The panel title reflects the ticket state, such as `Terminal`, `Setting Up`, or `Setup Failed`. +3. Closing the panel removes it from the layout. + +### Scenario: terminal defaults to the `claude` pane + +Precondition: a ready ticket exists. + +Steps: + +1. Open the ticket. + +Expected: + +1. The `claude` tab is selected by default. +2. The terminal session attaches for the selected task. + +### Scenario: switch between custom terminal panes + +Precondition: the project has at least one custom pane configured. + +Steps: + +1. Open the ticket terminal panel. +2. Click each pane tab. + +Expected: + +1. The active tab styling updates. +2. The terminal session switches to the requested pane. + +### Scenario: setup session tab appears during post-worktree setup + +Precondition: ticket setup reaches `running_post_command` and exposes a setup tmux session. + +Steps: + +1. Create a ticket that triggers setup. +2. Open the ticket while setup is running. + +Expected: + +1. A `setup` tab appears next to `claude`. +2. The app auto-selects `setup` when the task is opened in that state. +3. The `setup` tab shows a spinner while setup is in progress. + +### Scenario: setup failure is visible in the terminal panel + +Precondition: a ticket setup fails. + +Steps: + +1. Open the failed ticket. + +Expected: + +1. The panel title becomes `Setup Failed`. +2. The failed setup output is visible. +3. The `setup` tab is styled as an error state when present. + +## 8. Ticket Card Integrations + +### Scenario: open a PR from the ticket card + +Precondition: a ticket has a PR link. + +Steps: + +1. Click the PR icon on the ticket card. + +Expected: + +1. The PR opens in a new browser tab/window. +2. The click does not also open the terminal panel. + +### Scenario: open the ticket in the configured editor + +Precondition: a project editor is configured and a ready ticket exists. + +Steps: + +1. Hover the ticket card. +2. Click the editor icon. + +Expected: + +1. The backend attempts to open the editor for the ticket path. +2. Success shows the `Editor opened` toast. +3. Failure shows an error toast with a useful message. + +### Scenario: PR review and CI badges render on the ticket + +Precondition: a ticket has a PR link and PR status data. + +Steps: + +1. Select the project and wait for polling. + +Expected: + +1. The ticket shows the appropriate review badge, such as `Approved` or `Changes requested`. +2. The ticket shows the appropriate CI badge, such as `Checks passing` or `Conflicts`. + +## 9. Regression And Edge Cases + +### Scenario: clicking outside project dropdown closes it + +Steps: + +1. Open the project dropdown. +2. Click outside the dropdown. + +Expected: + +1. The dropdown closes. + +### Scenario: clicking outside the terminal panel closes it + +Precondition: the terminal panel is open. + +Steps: + +1. Click outside the panel. + +Expected: + +1. The panel closes. +2. Terminal sessions for the current selection are torn down. + +### Scenario: project selector recovers from deleted projects in saved settings + +Precondition: `selected_projects` contains an ID for a deleted project. + +Steps: + +1. Open the app. + +Expected: + +1. Invalid project IDs are filtered out. +2. The cleaned selection is written back to settings. + +### Scenario: PR suggestions render when available + +Precondition: `gh pr list` returns recent open PRs not already linked to tickets. + +Steps: + +1. Select a project with qualifying PRs. +2. Wait for suggestions to load. +3. Click one of the suggestion chips. + +Expected: + +1. Suggested PRs appear below the board header. +2. Clicking a suggestion creates a ticket from that PR. +3. The clicked suggestion disappears from the suggestion list. diff --git a/package-lock.json b/package-lock.json index ecde73d..75ac693 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "AGPL-3.0", "dependencies": { + "@playwright/test": "^1.58.2", "@types/ws": "^8.18.1", "better-sqlite3": "^12.6.0", "cors": "^2.8.5", @@ -1198,6 +1199,21 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.11", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz", @@ -4978,6 +4994,50 @@ "node": ">=0.10" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", diff --git a/package.json b/package.json index e89ef64..a9aca78 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,9 @@ "build": "tsc && cd web && npm run build", "start": "node dist/index.js", "test": "npm run test:backend && npm --prefix web test", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", "test:backend": "vitest run", "test:backend:watch": "vitest", "lint": "eslint .", @@ -31,6 +34,7 @@ }, "homepage": "https://github.com/alexandrelam/10x-claude#readme", "dependencies": { + "@playwright/test": "^1.58.2", "@types/ws": "^8.18.1", "better-sqlite3": "^12.6.0", "cors": "^2.8.5", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..61d3024 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,45 @@ +import { defineConfig } from "@playwright/test"; + +const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: false, + workers: 1, + reporter: "list", + use: { + baseURL: "http://127.0.0.1:3326", + trace: "on-first-retry", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { + browserName: "chromium", + ...(chromiumExecutablePath + ? { + launchOptions: { + executablePath: chromiumExecutablePath, + }, + } + : {}), + }, + }, + ], + webServer: [ + { + command: + "DATABASE_PATH=data/e2e.sqlite.db DISABLE_PR_POLLER=1 ENABLE_E2E_API=1 npx tsx src/index.ts", + url: "http://127.0.0.1:3325/health", + reuseExistingServer: false, + timeout: 120_000, + }, + { + command: "npm --prefix web run dev -- --host 127.0.0.1", + url: "http://127.0.0.1:3326", + reuseExistingServer: false, + timeout: 120_000, + }, + ], +}); diff --git a/src/db/index.ts b/src/db/index.ts index a6f1508..b17d1d8 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,6 +1,50 @@ import Database from "better-sqlite3"; import { drizzle } from "drizzle-orm/better-sqlite3"; +import path from "path"; +import fs from "fs"; import * as schema from "./schema.js"; -const sqlite = new Database("data/sqlite.db"); +const databasePath = process.env.DATABASE_PATH || "data/sqlite.db"; + +fs.mkdirSync(path.dirname(databasePath), { recursive: true }); + +const sqlite = new Database(databasePath); +sqlite.pragma("journal_mode = WAL"); +sqlite.exec(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + path TEXT NOT NULL, + created_at INTEGER NOT NULL, + post_worktree_command TEXT, + panes TEXT, + editor TEXT, + use_worktrees INTEGER NOT NULL DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS tickets ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + column TEXT NOT NULL DEFAULT 'To Do', + created_at INTEGER NOT NULL, + project_id TEXT, + worktree_path TEXT, + last_activity_at INTEGER, + is_main INTEGER, + setup_status TEXT NOT NULL DEFAULT 'ready', + setup_error TEXT, + setup_logs TEXT, + setup_tmux_session TEXT, + description TEXT, + status_override INTEGER, + pr_link TEXT, + pr_state TEXT + ); +`); + export const db = drizzle(sqlite, { schema }); diff --git a/src/index.ts b/src/index.ts index c844083..78a7b1b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import settingsRouter from "./routes/settings.js"; import projectsRouter from "./routes/projects.js"; import ticketsRouter from "./routes/tickets.js"; import trackingRouter from "./routes/tracking.js"; +import e2eRouter from "./routes/e2e.js"; import { startPrPoller } from "./pr-poller.js"; import { logger } from "./services/logger.js"; @@ -29,6 +30,9 @@ app.use("/api/settings", settingsRouter); app.use("/api/projects", projectsRouter); app.use("/api/tickets/track", trackingRouter); // Mount before /api/tickets app.use("/api/tickets", ticketsRouter); +if (process.env.ENABLE_E2E_API === "1") { + app.use("/api/e2e", e2eRouter); +} // Serve static files from web/dist (frontend build) app.use(express.static(path.join(__dirname, "../web/dist"))); @@ -43,5 +47,7 @@ setupPtyWebSocket(server); server.listen(port, "127.0.0.1", () => { logger.info(`Server running on http://localhost:${port}`); - startPrPoller(); + if (process.env.DISABLE_PR_POLLER !== "1") { + startPrPoller(); + } }); diff --git a/src/pr-poller.ts b/src/pr-poller.ts index fe10ce0..70d0f5f 100644 --- a/src/pr-poller.ts +++ b/src/pr-poller.ts @@ -92,11 +92,14 @@ function tick() { for (const row of rows) { if (!row.prLink) continue; - // Skip MERGED PRs checked within last hour (CLOSED can be reopened) + // Skip MERGED/CLOSED PRs checked within last hour if (row.prState) { try { const prev: PrState = JSON.parse(row.prState); - if (prev.state === "MERGED" && Date.now() - prev.lastCheckedAt < 3600000) { + if ( + (prev.state === "MERGED" || prev.state === "CLOSED") && + Date.now() - prev.lastCheckedAt < 3600000 + ) { continue; } } catch { diff --git a/src/routes/e2e.ts b/src/routes/e2e.ts new file mode 100644 index 0000000..36ae998 --- /dev/null +++ b/src/routes/e2e.ts @@ -0,0 +1,98 @@ +import { Router, Request, Response } from "express"; +import { db } from "../db/index.js"; +import { projects, settings, tickets } from "../db/schema.js"; + +const router = Router(); + +interface E2eProjectInput { + id: string; + name: string; + path: string; + createdAt?: number; + postWorktreeCommand?: string | null; + panes?: Array<{ name: string }>; + editor?: string | null; + useWorktrees?: boolean; +} + +interface E2eTicketInput { + id: string; + title: string; + column?: string; + createdAt?: number; + projectId?: string | null; + worktreePath?: string | null; + isMain?: boolean | null; + setupStatus?: string; + setupError?: string | null; + setupLogs?: string | null; + setupTmuxSession?: string | null; + description?: string | null; + statusOverride?: boolean | null; + prLink?: string | null; + prState?: string | null; +} + +router.post("/reset", async (req: Request, res: Response) => { + const { + settings: settingsInput = {}, + projects: projectsInput = [], + tickets: ticketsInput = [], + } = req.body as { + settings?: Record; + projects?: E2eProjectInput[]; + tickets?: E2eTicketInput[]; + }; + + await db.delete(tickets); + await db.delete(projects); + await db.delete(settings); + + const now = Date.now(); + + const settingsRows = Object.entries(settingsInput).map(([key, value]) => ({ key, value })); + if (settingsRows.length > 0) { + await db.insert(settings).values(settingsRows); + } + + if (projectsInput.length > 0) { + await db.insert(projects).values( + projectsInput.map((project) => ({ + id: project.id, + name: project.name, + path: project.path, + createdAt: project.createdAt ?? now, + postWorktreeCommand: project.postWorktreeCommand ?? null, + panes: JSON.stringify(project.panes ?? []), + editor: project.editor ?? null, + useWorktrees: project.useWorktrees !== false, + })) + ); + } + + if (ticketsInput.length > 0) { + await db.insert(tickets).values( + ticketsInput.map((ticket) => ({ + id: ticket.id, + title: ticket.title, + column: ticket.column ?? "To Do", + createdAt: ticket.createdAt ?? now, + projectId: ticket.projectId ?? null, + worktreePath: ticket.worktreePath ?? null, + isMain: ticket.isMain ?? false, + setupStatus: ticket.setupStatus ?? "ready", + setupError: ticket.setupError ?? null, + setupLogs: ticket.setupLogs ?? null, + setupTmuxSession: ticket.setupTmuxSession ?? null, + description: ticket.description ?? null, + statusOverride: ticket.statusOverride ?? null, + prLink: ticket.prLink ?? null, + prState: ticket.prState ?? null, + })) + ); + } + + res.json({ success: true }); +}); + +export default router; diff --git a/web/src/components/SettingsDialog.tsx b/web/src/components/SettingsDialog.tsx index a0a3139..4b4f73f 100644 --- a/web/src/components/SettingsDialog.tsx +++ b/web/src/components/SettingsDialog.tsx @@ -238,7 +238,7 @@ export function SettingsDialog({ onProjectsChange }: SettingsDialogProps) { return ( void handleOpenChange(nextOpen)}> - diff --git a/web/src/components/TerminalTabs.tsx b/web/src/components/TerminalTabs.tsx index abd2b40..e2bb3a2 100644 --- a/web/src/components/TerminalTabs.tsx +++ b/web/src/components/TerminalTabs.tsx @@ -34,6 +34,7 @@ export function TerminalTabs({
onRemovePane(project.id, pane.name)} className="hover:text-destructive" > @@ -153,6 +160,7 @@ export function ProjectCard({
{ @@ -167,7 +175,13 @@ export function ProjectCard({ }} className="text-sm h-8" /> -
diff --git a/web/src/components/task-board/GlobalHeader.tsx b/web/src/components/task-board/GlobalHeader.tsx index e52610f..ff3abdb 100644 --- a/web/src/components/task-board/GlobalHeader.tsx +++ b/web/src/components/task-board/GlobalHeader.tsx @@ -4,6 +4,7 @@ import { ThemeToggle } from "@/components/ThemeToggle"; import { SettingsDialog } from "@/components/SettingsDialog"; import { useClickOutside } from "@/hooks/useClickOutside"; import { cn } from "@/lib/utils"; +import { toast } from "sonner"; import type { Project } from "@/types"; interface GlobalHeaderProps { @@ -49,6 +50,8 @@ export function GlobalHeader({
)} - @@ -648,6 +660,7 @@ export function ProjectBoard({
diff --git a/web/src/components/task-board/TerminalPanel.tsx b/web/src/components/task-board/TerminalPanel.tsx index 50a1ec4..07e603c 100644 --- a/web/src/components/task-board/TerminalPanel.tsx +++ b/web/src/components/task-board/TerminalPanel.tsx @@ -79,6 +79,7 @@ export function TerminalPanel({ return (
@@ -86,7 +87,11 @@ export function TerminalPanel({
{getPanelTitle()}
{selectedTask.title}
- + )} + {!task.isMain && ( + + )} + {hasEditor && !isSetupInProgress && !isSetupFailed && ( + + )} + {hasOverride && ( + + )} + {!task.isMain && ( + + )} +
{task.description && (
{task.description}
@@ -155,78 +231,9 @@ export function TicketCard({ {!isSetupInProgress && !isSetupFailed && columnEnteredAt && (
- {formatElapsedTime(Date.now() - columnEnteredAt)} + {formatElapsedTime(now - columnEnteredAt)}
)} -
- {task.prLink && ( - - )} - {hasEditor && !isSetupInProgress && !isSetupFailed && ( - - )} - {hasOverride && ( - - )} - {!task.isMain && ( - - )} - {!task.isMain && ( - - )} -
); } diff --git a/web/src/hooks/useProjects.test.ts b/web/src/hooks/useProjects.test.ts index 9fdf534..9ceb244 100644 --- a/web/src/hooks/useProjects.test.ts +++ b/web/src/hooks/useProjects.test.ts @@ -112,6 +112,6 @@ describe("useProjects", () => { await waitFor(() => { expect(result.current.selectedProjectIds).toEqual(["p1"]); }); - expect(settingsSet).not.toHaveBeenCalled(); + expect(settingsSet).toHaveBeenCalledWith("selected_projects", '["p1"]'); }); }); diff --git a/web/src/hooks/useProjects.ts b/web/src/hooks/useProjects.ts index 001a0de..0b9f4ac 100644 --- a/web/src/hooks/useProjects.ts +++ b/web/src/hooks/useProjects.ts @@ -56,9 +56,13 @@ export function useProjects() { if (settingsData.value) { try { const projectIds = JSON.parse(settingsData.value) as string[]; - setStoredSelectedProjectIds( - projectIds.filter((id) => projectList.some((project) => project.id === id)) + const filteredProjectIds = projectIds.filter((id) => + projectList.some((project) => project.id === id) ); + setStoredSelectedProjectIds(filteredProjectIds); + if (filteredProjectIds.length !== projectIds.length) { + await settingsApi.set("selected_projects", JSON.stringify(filteredProjectIds)); + } return; } catch { // Fall through to old format