From ea7a5df1ae93b625118aa65944a989f1e2ff8e94 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 20:47:06 +0000 Subject: [PATCH 01/14] test(e2e): add boards list + board task flows (stubbed) --- frontend/cypress/e2e/board_tasks.cy.ts | 140 +++++++++++++++++++++++++ frontend/cypress/e2e/boards_list.cy.ts | 68 ++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 frontend/cypress/e2e/board_tasks.cy.ts create mode 100644 frontend/cypress/e2e/boards_list.cy.ts diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts new file mode 100644 index 00000000..937682f8 --- /dev/null +++ b/frontend/cypress/e2e/board_tasks.cy.ts @@ -0,0 +1,140 @@ +/// + +describe("/boards/:id task board", () => { + const apiBase = "**/api/v1"; + const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; + + const originalDefaultCommandTimeout = Cypress.config("defaultCommandTimeout"); + + beforeEach(() => { + Cypress.config("defaultCommandTimeout", 20_000); + }); + + afterEach(() => { + Cypress.config("defaultCommandTimeout", originalDefaultCommandTimeout); + }); + + function stubEmptySse() { + // Any SSE endpoint should not hang the UI in tests. + cy.intercept("GET", `${apiBase}/**/stream*`, { + statusCode: 200, + headers: { "content-type": "text/event-stream" }, + body: "", + }); + } + + it("auth negative: signed-out user is redirected to sign-in", () => { + cy.visit("/boards/b1"); + cy.location("pathname", { timeout: 30_000 }).should("match", /\/sign-in/); + }); + + it("happy path: renders tasks from snapshot and can create a task (stubbed)", () => { + stubEmptySse(); + + cy.intercept("GET", `${apiBase}/boards/b1/snapshot*`, { + statusCode: 200, + body: { + board: { + id: "b1", + name: "Demo Board", + slug: "demo-board", + description: "Demo", + gateway_id: "g1", + board_group_id: null, + board_type: "general", + objective: null, + success_metrics: null, + target_date: null, + goal_confirmed: true, + goal_source: "test", + organization_id: "o1", + created_at: "2026-02-11T00:00:00Z", + updated_at: "2026-02-11T00:00:00Z", + }, + tasks: [ + { + id: "t1", + board_id: "b1", + title: "Inbox task", + description: "", + status: "inbox", + priority: "medium", + due_at: null, + assigned_agent_id: null, + depends_on_task_ids: [], + created_by_user_id: null, + in_progress_at: null, + created_at: "2026-02-11T00:00:00Z", + updated_at: "2026-02-11T00:00:00Z", + blocked_by_task_ids: [], + is_blocked: false, + assignee: null, + approvals_count: 0, + approvals_pending_count: 0, + }, + ], + agents: [], + approvals: [], + chat_messages: [], + pending_approvals_count: 0, + }, + }).as("snapshot"); + + cy.intercept("GET", `${apiBase}/boards/b1/group-snapshot*`, { + statusCode: 200, + body: { group: null, boards: [] }, + }); + + cy.intercept("POST", `${apiBase}/boards/b1/tasks`, (req) => { + // Minimal assertion the UI sends expected fields. + expect(req.body).to.have.property("title"); + req.reply({ + statusCode: 200, + body: { + id: "t2", + board_id: "b1", + title: req.body.title, + description: req.body.description ?? "", + status: "inbox", + priority: req.body.priority ?? "medium", + due_at: null, + assigned_agent_id: null, + depends_on_task_ids: [], + created_by_user_id: null, + in_progress_at: null, + created_at: "2026-02-11T00:00:00Z", + updated_at: "2026-02-11T00:00:00Z", + blocked_by_task_ids: [], + is_blocked: false, + assignee: null, + approvals_count: 0, + approvals_pending_count: 0, + }, + }); + }).as("createTask"); + + cy.visit("/sign-in"); + cy.clerkLoaded(); + cy.clerkSignIn({ strategy: "email_code", identifier: email }); + + cy.visit("/boards/b1"); + cy.waitForAppLoaded(); + + cy.wait(["@snapshot"]); + + // Existing task visible. + cy.contains("Inbox task").should("be.visible"); + + // Open create task flow. + cy.contains("button", /create task|new task|add task|\+/i) + .first() + .click({ force: true }); + + cy.get("input").filter('[placeholder*="Title"], [name*="title"], [id*="title"], input[type="text"]').first().type("New task"); + + cy.contains("button", /create|save|add/i).click({ force: true }); + cy.wait(["@createTask"]); + + cy.contains("New task").should("be.visible"); + }); +}); diff --git a/frontend/cypress/e2e/boards_list.cy.ts b/frontend/cypress/e2e/boards_list.cy.ts new file mode 100644 index 00000000..e8a01387 --- /dev/null +++ b/frontend/cypress/e2e/boards_list.cy.ts @@ -0,0 +1,68 @@ +/// + +describe("/boards", () => { + const apiBase = "**/api/v1"; + const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; + + const originalDefaultCommandTimeout = Cypress.config("defaultCommandTimeout"); + + beforeEach(() => { + Cypress.config("defaultCommandTimeout", 20_000); + }); + + afterEach(() => { + Cypress.config("defaultCommandTimeout", originalDefaultCommandTimeout); + }); + + it("auth negative: signed-out user is redirected to sign-in", () => { + cy.visit("/boards"); + cy.location("pathname", { timeout: 30_000 }).should("match", /\/sign-in/); + }); + + it("happy path: signed-in user sees boards list", () => { + cy.intercept("GET", `${apiBase}/boards*`, { + statusCode: 200, + body: { + items: [ + { + id: "b1", + name: "Demo Board", + slug: "demo-board", + description: "Demo", + gateway_id: "g1", + board_group_id: null, + board_type: "general", + objective: null, + success_metrics: null, + target_date: null, + goal_confirmed: true, + goal_source: "test", + organization_id: "o1", + created_at: "2026-02-11T00:00:00Z", + updated_at: "2026-02-11T00:00:00Z", + }, + ], + total: 1, + limit: 200, + offset: 0, + }, + }).as("boards"); + + cy.intercept("GET", `${apiBase}/board-groups*`, { + statusCode: 200, + body: { items: [], total: 0, limit: 200, offset: 0 }, + }).as("boardGroups"); + + cy.visit("/sign-in"); + cy.clerkLoaded(); + cy.clerkSignIn({ strategy: "email_code", identifier: email }); + + cy.visit("/boards"); + cy.waitForAppLoaded(); + + cy.wait(["@boards", "@boardGroups"]); + + cy.contains(/boards/i).should("be.visible"); + cy.contains("Demo Board").should("be.visible"); + }); +}); From 584dac18552cedef1d9d50b5f5348c24387bb498 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 23:42:26 +0000 Subject: [PATCH 02/14] test(e2e): cover task status update + delete (stubbed) --- frontend/cypress/e2e/board_tasks.cy.ts | 70 ++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts index 937682f8..aedc6f10 100644 --- a/frontend/cypress/e2e/board_tasks.cy.ts +++ b/frontend/cypress/e2e/board_tasks.cy.ts @@ -2,7 +2,8 @@ describe("/boards/:id task board", () => { const apiBase = "**/api/v1"; - const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; + const email = + Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; const originalDefaultCommandTimeout = Cypress.config("defaultCommandTimeout"); @@ -28,7 +29,7 @@ describe("/boards/:id task board", () => { cy.location("pathname", { timeout: 30_000 }).should("match", /\/sign-in/); }); - it("happy path: renders tasks from snapshot and can create a task (stubbed)", () => { + it("happy path: renders tasks from snapshot and supports create + status update + delete (stubbed)", () => { stubEmptySse(); cy.intercept("GET", `${apiBase}/boards/b1/snapshot*`, { @@ -113,6 +114,38 @@ describe("/boards/:id task board", () => { }); }).as("createTask"); + cy.intercept("PATCH", `${apiBase}/boards/b1/tasks/t1`, (req) => { + expect(req.body).to.have.property("status"); + req.reply({ + statusCode: 200, + body: { + id: "t1", + board_id: "b1", + title: "Inbox task", + description: "", + status: req.body.status, + priority: "medium", + due_at: null, + assigned_agent_id: null, + depends_on_task_ids: [], + created_by_user_id: null, + in_progress_at: null, + created_at: "2026-02-11T00:00:00Z", + updated_at: "2026-02-11T00:00:01Z", + blocked_by_task_ids: [], + is_blocked: false, + assignee: null, + approvals_count: 0, + approvals_pending_count: 0, + }, + }); + }).as("updateTask"); + + cy.intercept("DELETE", `${apiBase}/boards/b1/tasks/t1`, { + statusCode: 200, + body: { ok: true }, + }).as("deleteTask"); + cy.visit("/sign-in"); cy.clerkLoaded(); cy.clerkSignIn({ strategy: "email_code", identifier: email }); @@ -130,11 +163,42 @@ describe("/boards/:id task board", () => { .first() .click({ force: true }); - cy.get("input").filter('[placeholder*="Title"], [name*="title"], [id*="title"], input[type="text"]').first().type("New task"); + cy.get("input") + .filter( + '[placeholder*="Title"], [name*="title"], [id*="title"], input[type="text"]', + ) + .first() + .type("New task"); cy.contains("button", /create|save|add/i).click({ force: true }); cy.wait(["@createTask"]); cy.contains("New task").should("be.visible"); + + // Open edit task dialog. + cy.contains("Inbox task").click({ force: true }); + cy.contains("Edit task").should("be.visible"); + + // Change status via Status select. + cy.contains("label", "Status") + .parent() + .within(() => { + cy.get("button").first().click({ force: true }); + }); + + cy.contains("In progress").click({ force: true }); + + cy.contains("button", /save changes/i).click({ force: true }); + cy.wait(["@updateTask"]); + + // Delete task via delete dialog. + cy.contains("button", /^Delete task$/).click({ force: true }); + cy.get('[aria-label="Delete task"]').should("be.visible"); + cy.get('[aria-label="Delete task"]').within(() => { + cy.contains("button", /^Delete task$/).click({ force: true }); + }); + cy.wait(["@deleteTask"]); + + cy.contains("Inbox task").should("not.exist"); }); }); From 4a6f77eed36ed81459fc0efda99a9adf3f0e2aeb Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 12 Feb 2026 09:10:42 +0000 Subject: [PATCH 03/14] test(e2e): select New task button by aria-label --- frontend/cypress/e2e/board_tasks.cy.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts index aedc6f10..464dce21 100644 --- a/frontend/cypress/e2e/board_tasks.cy.ts +++ b/frontend/cypress/e2e/board_tasks.cy.ts @@ -159,9 +159,8 @@ describe("/boards/:id task board", () => { cy.contains("Inbox task").should("be.visible"); // Open create task flow. - cy.contains("button", /create task|new task|add task|\+/i) - .first() - .click({ force: true }); + // Board page uses an icon-only button with aria-label="New task". + cy.get('button[aria-label="New task"]').click({ force: true }); cy.get("input") .filter( From c6dedc93b3c8374a0cc31e95298d8d43820847ee Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 01:29:04 +0530 Subject: [PATCH 04/14] feat: enhance task editing and loading states in board tasks and boards list tests --- frontend/cypress/e2e/board_tasks.cy.ts | 106 +++++++++++++++++++++---- frontend/cypress/e2e/boards_list.cy.ts | 37 ++++++++- 2 files changed, 126 insertions(+), 17 deletions(-) diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts index 464dce21..13e3f7c5 100644 --- a/frontend/cypress/e2e/board_tasks.cy.ts +++ b/frontend/cypress/e2e/board_tasks.cy.ts @@ -24,6 +24,14 @@ describe("/boards/:id task board", () => { }); } + function openEditTaskDialog() { + cy.get('button[title="Edit task"]', { timeout: 20_000 }) + .should("be.visible") + .and("not.be.disabled") + .click({ force: true }); + cy.get('[aria-label="Edit task"]', { timeout: 20_000 }).should("be.visible"); + } + it("auth negative: signed-out user is redirected to sign-in", () => { cy.visit("/boards/b1"); cy.location("pathname", { timeout: 30_000 }).should("match", /\/sign-in/); @@ -32,6 +40,51 @@ describe("/boards/:id task board", () => { it("happy path: renders tasks from snapshot and supports create + status update + delete (stubbed)", () => { stubEmptySse(); + cy.intercept("GET", `${apiBase}/organizations/me/member*`, { + statusCode: 200, + body: { + id: "m1", + organization_id: "o1", + user_id: "u1", + role: "owner", + all_boards_read: true, + all_boards_write: true, + created_at: "2026-02-11T00:00:00Z", + updated_at: "2026-02-11T00:00:00Z", + board_access: [{ board_id: "b1", can_read: true, can_write: true }], + }, + }).as("membership"); + + cy.intercept("GET", `${apiBase}/users/me*`, { + statusCode: 200, + body: { + id: "u1", + clerk_user_id: "clerk_u1", + email, + name: "Jane Test", + preferred_name: "Jane", + timezone: "America/New_York", + is_super_admin: false, + }, + }).as("me"); + + cy.intercept("GET", `${apiBase}/organizations/me/list*`, { + statusCode: 200, + body: [ + { id: "o1", name: "Personal", role: "owner", is_active: true }, + ], + }).as("organizations"); + + cy.intercept("GET", `${apiBase}/tags*`, { + statusCode: 200, + body: { items: [], total: 0, limit: 200, offset: 0 }, + }).as("tags"); + + cy.intercept("GET", `${apiBase}/organizations/me/custom-fields*`, { + statusCode: 200, + body: [], + }).as("customFields"); + cy.intercept("GET", `${apiBase}/boards/b1/snapshot*`, { statusCode: 200, body: { @@ -146,6 +199,11 @@ describe("/boards/:id task board", () => { body: { ok: true }, }).as("deleteTask"); + cy.intercept("GET", `${apiBase}/boards/b1/tasks/t1/comments*`, { + statusCode: 200, + body: { items: [], total: 0, limit: 200, offset: 0 }, + }).as("taskComments"); + cy.visit("/sign-in"); cy.clerkLoaded(); cy.clerkSignIn({ strategy: "email_code", identifier: email }); @@ -153,7 +211,14 @@ describe("/boards/:id task board", () => { cy.visit("/boards/b1"); cy.waitForAppLoaded(); - cy.wait(["@snapshot"]); + cy.wait([ + "@snapshot", + "@membership", + "@me", + "@organizations", + "@tags", + "@customFields", + ]); // Existing task visible. cy.contains("Inbox task").should("be.visible"); @@ -162,36 +227,45 @@ describe("/boards/:id task board", () => { // Board page uses an icon-only button with aria-label="New task". cy.get('button[aria-label="New task"]').click({ force: true }); - cy.get("input") - .filter( - '[placeholder*="Title"], [name*="title"], [id*="title"], input[type="text"]', - ) - .first() - .type("New task"); - - cy.contains("button", /create|save|add/i).click({ force: true }); + cy.contains('[role="dialog"]', "New task") + .should("be.visible") + .within(() => { + cy.contains("label", "Title").parent().find("input").type("New task"); + cy.contains("button", /^Create task$/).click({ force: true }); + }); cy.wait(["@createTask"]); cy.contains("New task").should("be.visible"); // Open edit task dialog. cy.contains("Inbox task").click({ force: true }); - cy.contains("Edit task").should("be.visible"); + cy.wait(["@taskComments"]); + cy.contains(/task detail/i).should("be.visible"); + openEditTaskDialog(); // Change status via Status select. - cy.contains("label", "Status") - .parent() - .within(() => { - cy.get("button").first().click({ force: true }); - }); + cy.get('[aria-label="Edit task"]').within(() => { + cy.contains("label", "Status") + .parent() + .within(() => { + cy.get('[role="combobox"]').first().click({ force: true }); + }); + }); cy.contains("In progress").click({ force: true }); cy.contains("button", /save changes/i).click({ force: true }); cy.wait(["@updateTask"]); + cy.get('[aria-label="Edit task"]').should("not.exist"); + + // Save closes the edit dialog; reopen it from task detail. + cy.contains(/task detail/i).should("be.visible"); + openEditTaskDialog(); // Delete task via delete dialog. - cy.contains("button", /^Delete task$/).click({ force: true }); + cy.get('[aria-label="Edit task"]').within(() => { + cy.contains("button", /^Delete task$/).click({ force: true }); + }); cy.get('[aria-label="Delete task"]').should("be.visible"); cy.get('[aria-label="Delete task"]').within(() => { cy.contains("button", /^Delete task$/).click({ force: true }); diff --git a/frontend/cypress/e2e/boards_list.cy.ts b/frontend/cypress/e2e/boards_list.cy.ts index e8a01387..c68a8ab8 100644 --- a/frontend/cypress/e2e/boards_list.cy.ts +++ b/frontend/cypress/e2e/boards_list.cy.ts @@ -20,6 +20,41 @@ describe("/boards", () => { }); it("happy path: signed-in user sees boards list", () => { + cy.intercept("GET", `${apiBase}/organizations/me/member*`, { + statusCode: 200, + body: { + id: "m1", + organization_id: "o1", + user_id: "u1", + role: "owner", + all_boards_read: true, + all_boards_write: true, + created_at: "2026-02-11T00:00:00Z", + updated_at: "2026-02-11T00:00:00Z", + board_access: [], + }, + }).as("membership"); + + cy.intercept("GET", `${apiBase}/users/me*`, { + statusCode: 200, + body: { + id: "u1", + clerk_user_id: "clerk_u1", + email, + name: "Jane Test", + preferred_name: "Jane", + timezone: "America/New_York", + is_super_admin: false, + }, + }).as("me"); + + cy.intercept("GET", `${apiBase}/organizations/me/list*`, { + statusCode: 200, + body: [ + { id: "o1", name: "Personal", role: "owner", is_active: true }, + ], + }).as("organizations"); + cy.intercept("GET", `${apiBase}/boards*`, { statusCode: 200, body: { @@ -60,7 +95,7 @@ describe("/boards", () => { cy.visit("/boards"); cy.waitForAppLoaded(); - cy.wait(["@boards", "@boardGroups"]); + cy.wait(["@membership", "@me", "@organizations", "@boards", "@boardGroups"]); cy.contains(/boards/i).should("be.visible"); cy.contains("Demo Board").should("be.visible"); From 1045267d4ce3b26d42cc00f8338c810b528f6aeb Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 01:39:04 +0530 Subject: [PATCH 05/14] feat: add groupSnapshot alias and handle hydration mismatch in E2E tests --- frontend/cypress/e2e/board_tasks.cy.ts | 3 ++- frontend/cypress/support/e2e.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts index 13e3f7c5..441b65cf 100644 --- a/frontend/cypress/e2e/board_tasks.cy.ts +++ b/frontend/cypress/e2e/board_tasks.cy.ts @@ -137,7 +137,7 @@ describe("/boards/:id task board", () => { cy.intercept("GET", `${apiBase}/boards/b1/group-snapshot*`, { statusCode: 200, body: { group: null, boards: [] }, - }); + }).as("groupSnapshot"); cy.intercept("POST", `${apiBase}/boards/b1/tasks`, (req) => { // Minimal assertion the UI sends expected fields. @@ -213,6 +213,7 @@ describe("/boards/:id task board", () => { cy.wait([ "@snapshot", + "@groupSnapshot", "@membership", "@me", "@organizations", diff --git a/frontend/cypress/support/e2e.ts b/frontend/cypress/support/e2e.ts index 369182a5..a1b252f3 100644 --- a/frontend/cypress/support/e2e.ts +++ b/frontend/cypress/support/e2e.ts @@ -5,6 +5,15 @@ import { addClerkCommands } from "@clerk/testing/cypress"; +// Clerk/Next.js occasionally throws a non-deterministic hydration mismatch +// on /sign-in. Ignore this known UI noise so E2E assertions can proceed. +Cypress.on("uncaught:exception", (err) => { + if (err?.message?.includes("Hydration failed")) { + return false; + } + return true; +}); + addClerkCommands({ Cypress, cy }); import "./commands"; From e3404d859006d56426258ff04e0ae481578c70ef Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 02:24:51 +0530 Subject: [PATCH 06/14] feat: implement local authentication flow and update related tests --- .github/workflows/ci.yml | 25 +- backend/.env.test | 38 +++ frontend/cypress.config.ts | 14 -- frontend/cypress/e2e/activity_feed.cy.ts | 57 ++--- frontend/cypress/e2e/activity_smoke.cy.ts | 6 +- frontend/cypress/e2e/clerk_login.cy.ts | 16 -- frontend/cypress/e2e/local_auth_login.cy.ts | 49 ++++ frontend/cypress/e2e/organizations.cy.ts | 98 ++++++-- frontend/cypress/support/commands.ts | 216 ++---------------- frontend/cypress/support/e2e.ts | 11 +- frontend/src/app/sign-in/[[...rest]]/page.tsx | 7 + frontend/src/lib/gateway-form.test.ts | 4 +- 12 files changed, 245 insertions(+), 296 deletions(-) create mode 100644 backend/.env.test delete mode 100644 frontend/cypress/e2e/clerk_login.cy.ts create mode 100644 frontend/cypress/e2e/local_auth_login.cy.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b96b859..773db02a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,9 +102,8 @@ jobs: - name: Run backend checks env: # Keep CI builds deterministic. - NEXT_TELEMETRY_DISABLED: "1" - AUTH_MODE: "clerk" - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} + AUTH_MODE: "local" + LOCAL_AUTH_TOKEN: "ci-local-auth-token-0123456789-0123456789-0123456789x" run: | make backend-lint make backend-coverage @@ -113,10 +112,8 @@ jobs: env: # Keep CI builds deterministic. NEXT_TELEMETRY_DISABLED: "1" - NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} - NEXT_PUBLIC_AUTH_MODE: "clerk" - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} + NEXT_PUBLIC_API_URL: "http://localhost:8000" + NEXT_PUBLIC_AUTH_MODE: "local" run: | make frontend-lint make frontend-typecheck @@ -218,11 +215,9 @@ jobs: - name: Start frontend (dev server) env: - NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} - NEXT_PUBLIC_AUTH_MODE: "clerk" + NEXT_PUBLIC_API_URL: "http://localhost:8000" + NEXT_PUBLIC_AUTH_MODE: "local" NEXT_TELEMETRY_DISABLED: "1" - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} run: | cd frontend npm run dev -- --hostname 0.0.0.0 --port 3000 & @@ -235,13 +230,9 @@ jobs: - name: Run Cypress E2E env: - NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} - NEXT_PUBLIC_AUTH_MODE: "clerk" + NEXT_PUBLIC_API_URL: "http://localhost:8000" + NEXT_PUBLIC_AUTH_MODE: "local" NEXT_TELEMETRY_DISABLED: "1" - # Clerk testing tokens (official @clerk/testing Cypress integration) - CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - # Also set for the app itself. - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }} run: | cd frontend npm run e2e -- --browser chrome diff --git a/backend/.env.test b/backend/.env.test new file mode 100644 index 00000000..70f9a7ee --- /dev/null +++ b/backend/.env.test @@ -0,0 +1,38 @@ +# Commit-safe backend test environment. +# Usage: +# cd backend +# uv run --env-file .env.test uvicorn app.main:app --reload --port 8000 + +ENVIRONMENT=dev +LOG_LEVEL=INFO +LOG_FORMAT=text +LOG_USE_UTC=false +REQUEST_LOG_SLOW_MS=1000 +REQUEST_LOG_INCLUDE_HEALTH=false + +# Local backend -> local Postgres (adjust host/port if needed) +DATABASE_URL=postgresql+psycopg://postgres:postgres@localhost:5432/mission_control_test +CORS_ORIGINS=http://localhost:3000 +BASE_URL= + +# Auth mode: local for test/dev +AUTH_MODE=local +# Must be non-placeholder and >= 50 chars +LOCAL_AUTH_TOKEN=test-local-token-0123456789-0123456789-0123456789x + +# Clerk settings kept empty in local auth mode +CLERK_SECRET_KEY= +CLERK_API_URL=https://api.clerk.com +CLERK_VERIFY_IAT=true +CLERK_LEEWAY=10.0 + +# Database +DB_AUTO_MIGRATE=true + +# Queue / dispatch +RQ_REDIS_URL=redis://localhost:6379/0 +RQ_QUEUE_NAME=default +RQ_DISPATCH_THROTTLE_SECONDS=15.0 +RQ_DISPATCH_MAX_RETRIES=3 + +GATEWAY_MIN_VERSION=2026.02.9 diff --git a/frontend/cypress.config.ts b/frontend/cypress.config.ts index 18baa6bb..46b1c779 100644 --- a/frontend/cypress.config.ts +++ b/frontend/cypress.config.ts @@ -1,28 +1,14 @@ import { defineConfig } from "cypress"; -import { clerkSetup } from "@clerk/testing/cypress"; export default defineConfig({ - env: { - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: - process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, - // Optional overrides. - CLERK_ORIGIN: process.env.CYPRESS_CLERK_ORIGIN, - CLERK_TEST_EMAIL: process.env.CYPRESS_CLERK_TEST_EMAIL, - CLERK_TEST_OTP: process.env.CYPRESS_CLERK_TEST_OTP, - }, e2e: { baseUrl: "http://localhost:3000", specPattern: "cypress/e2e/**/*.cy.{js,jsx,ts,tsx}", supportFile: "cypress/support/e2e.ts", - // Clerk helpers perform async work inside `cy.then()`. CI can be slow enough - // that Cypress' 4s default command timeout flakes. defaultCommandTimeout: 20_000, retries: { runMode: 2, openMode: 0, }, - setupNodeEvents(on, config) { - return clerkSetup({ config }); - }, }, }); diff --git a/frontend/cypress/e2e/activity_feed.cy.ts b/frontend/cypress/e2e/activity_feed.cy.ts index 77a06bf1..4245b4a1 100644 --- a/frontend/cypress/e2e/activity_feed.cy.ts +++ b/frontend/cypress/e2e/activity_feed.cy.ts @@ -1,22 +1,11 @@ /// -// Clerk/Next.js occasionally triggers a hydration mismatch on the SignIn route in CI. -// This is non-deterministic UI noise for these tests; ignore it so assertions can proceed. -Cypress.on("uncaught:exception", (err) => { - if (err.message?.includes("Hydration failed")) { - return false; - } - return true; -}); - describe("/activity feed", () => { const apiBase = "**/api/v1"; - const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; const originalDefaultCommandTimeout = Cypress.config("defaultCommandTimeout"); beforeEach(() => { - // Clerk's Cypress helpers perform async work inside `cy.then()`. // CI can be slow enough that the default 4s command timeout flakes. Cypress.config("defaultCommandTimeout", 20_000); }); @@ -49,6 +38,30 @@ describe("/activity feed", () => { function stubBoardBootstrap() { // Some app bootstraps happen before we get to the /activity call. // Keep these stable so the page always reaches the activity request. + cy.intercept("GET", `${apiBase}/users/me*`, { + statusCode: 200, + body: { + id: "u1", + clerk_user_id: "local-auth-user", + email: "local@example.com", + name: "Local User", + preferred_name: "Local User", + timezone: "UTC", + }, + }).as("usersMe"); + + cy.intercept("GET", `${apiBase}/organizations/me/list*`, { + statusCode: 200, + body: [ + { + id: "org1", + name: "Testing Org", + is_active: true, + role: "owner", + }, + ], + }).as("orgsList"); + cy.intercept("GET", `${apiBase}/organizations/me/member*`, { statusCode: 200, body: { organization_id: "org1", role: "owner" }, @@ -77,10 +90,11 @@ describe("/activity feed", () => { cy.contains(/live feed/i).should("be.visible"); } - it("auth negative: signed-out user is redirected to sign-in", () => { - // SignedOutPanel runs in redirect mode on this page. + it("auth negative: signed-out user sees auth prompt", () => { cy.visit("/activity"); - cy.location("pathname", { timeout: 20_000 }).should("match", /\/sign-in/); + cy.contains(/sign in to view the feed|local authentication/i, { + timeout: 20_000, + }).should("be.visible"); }); it("happy path: renders task comment cards", () => { @@ -107,10 +121,7 @@ describe("/activity feed", () => { stubStreamsEmpty(); - cy.visit("/sign-in"); - cy.clerkLoaded(); - cy.clerkSignIn({ strategy: "email_code", identifier: email }); - + cy.loginWithLocalAuth(); cy.visit("/activity"); assertSignedInAndLanded(); cy.wait("@activityList", { timeout: 20_000 }); @@ -131,10 +142,7 @@ describe("/activity feed", () => { stubStreamsEmpty(); - cy.visit("/sign-in"); - cy.clerkLoaded(); - cy.clerkSignIn({ strategy: "email_code", identifier: email }); - + cy.loginWithLocalAuth(); cy.visit("/activity"); assertSignedInAndLanded(); cy.wait("@activityList", { timeout: 20_000 }); @@ -152,10 +160,7 @@ describe("/activity feed", () => { stubStreamsEmpty(); - cy.visit("/sign-in"); - cy.clerkLoaded(); - cy.clerkSignIn({ strategy: "email_code", identifier: email }); - + cy.loginWithLocalAuth(); cy.visit("/activity"); assertSignedInAndLanded(); cy.wait("@activityList", { timeout: 20_000 }); diff --git a/frontend/cypress/e2e/activity_smoke.cy.ts b/frontend/cypress/e2e/activity_smoke.cy.ts index ae60a51a..4da0469e 100644 --- a/frontend/cypress/e2e/activity_smoke.cy.ts +++ b/frontend/cypress/e2e/activity_smoke.cy.ts @@ -1,6 +1,8 @@ describe("/activity page", () => { - it("signed-out user is redirected to sign-in", () => { + it("signed-out user sees an auth prompt", () => { cy.visit("/activity"); - cy.location("pathname", { timeout: 20_000 }).should("match", /\/sign-in/); + cy.contains(/local authentication|sign in to mission control/i, { + timeout: 20_000, + }).should("be.visible"); }); }); diff --git a/frontend/cypress/e2e/clerk_login.cy.ts b/frontend/cypress/e2e/clerk_login.cy.ts deleted file mode 100644 index 2f928ac0..00000000 --- a/frontend/cypress/e2e/clerk_login.cy.ts +++ /dev/null @@ -1,16 +0,0 @@ -describe("Clerk login", () => { - it("user can sign in via Clerk testing commands", () => { - const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; - - // Prereq per Clerk docs: visit a non-protected page that loads Clerk. - cy.visit("/sign-in"); - cy.clerkLoaded(); - - cy.clerkSignIn({ strategy: "email_code", identifier: email }); - - // After login, user should be able to access protected route. - cy.visit("/activity"); - cy.waitForAppLoaded(); - cy.contains(/live feed/i).should("be.visible"); - }); -}); diff --git a/frontend/cypress/e2e/local_auth_login.cy.ts b/frontend/cypress/e2e/local_auth_login.cy.ts new file mode 100644 index 00000000..7df170ed --- /dev/null +++ b/frontend/cypress/e2e/local_auth_login.cy.ts @@ -0,0 +1,49 @@ +describe("Local auth login", () => { + it("user with local auth token can access protected route", () => { + cy.intercept("GET", "**/api/v1/users/me*", { + statusCode: 200, + body: { + id: "u1", + clerk_user_id: "local-auth-user", + email: "local@example.com", + name: "Local User", + preferred_name: "Local User", + timezone: "UTC", + }, + }).as("usersMe"); + + cy.intercept("GET", "**/api/v1/organizations/me/list*", { + statusCode: 200, + body: [ + { + id: "org1", + name: "Testing Org", + is_active: true, + role: "owner", + }, + ], + }).as("orgsList"); + + cy.intercept("GET", "**/api/v1/organizations/me/member*", { + statusCode: 200, + body: { organization_id: "org1", role: "owner" }, + }).as("orgMeMember"); + + cy.intercept("GET", "**/api/v1/boards*", { + statusCode: 200, + body: { + items: [{ id: "b1", name: "Testing", updated_at: "2026-02-07T00:00:00Z" }], + }, + }).as("boardsList"); + + cy.intercept("GET", "**/api/v1/boards/b1/snapshot*", { + statusCode: 200, + body: { tasks: [], agents: [], approvals: [], chat_messages: [] }, + }).as("boardSnapshot"); + + cy.loginWithLocalAuth(); + cy.visit("/activity"); + cy.waitForAppLoaded(); + cy.contains(/live feed/i).should("be.visible"); + }); +}); diff --git a/frontend/cypress/e2e/organizations.cy.ts b/frontend/cypress/e2e/organizations.cy.ts index c2ab5303..f302d252 100644 --- a/frontend/cypress/e2e/organizations.cy.ts +++ b/frontend/cypress/e2e/organizations.cy.ts @@ -1,36 +1,88 @@ describe("Organizations (PR #61)", () => { - const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; + const apiBase = "**/api/v1"; - it("negative: signed-out user is redirected to sign-in when opening /organization", () => { + function stubOrganizationApis() { + cy.intercept("GET", `${apiBase}/users/me*`, { + statusCode: 200, + body: { + id: "u1", + clerk_user_id: "local-auth-user", + email: "local@example.com", + name: "Local User", + preferred_name: "Local User", + timezone: "UTC", + }, + }).as("usersMe"); + + cy.intercept("GET", `${apiBase}/organizations/me/list*`, { + statusCode: 200, + body: [ + { + id: "org1", + name: "Testing Org", + is_active: true, + role: "member", + }, + ], + }).as("orgsList"); + + cy.intercept("GET", `${apiBase}/organizations/me/member*`, { + statusCode: 200, + body: { + id: "membership-1", + user_id: "u1", + organization_id: "org1", + role: "member", + }, + }).as("orgMembership"); + + cy.intercept("GET", `${apiBase}/organizations/me`, { + statusCode: 200, + body: { id: "org1", name: "Testing Org" }, + }).as("orgMe"); + + cy.intercept("GET", `${apiBase}/organizations/me/members*`, { + statusCode: 200, + body: { + items: [ + { + id: "membership-1", + user_id: "u1", + role: "member", + user: { + id: "u1", + email: "local@example.com", + name: "Local User", + preferred_name: "Local User", + }, + }, + ], + }, + }).as("orgMembers"); + + cy.intercept("GET", `${apiBase}/boards*`, { + statusCode: 200, + body: { items: [] }, + }).as("boardsList"); + } + + it("negative: signed-out user sees auth prompt when opening /organization", () => { cy.visit("/organization"); - cy.location("pathname", { timeout: 30_000 }).should("match", /\/sign-in/); + cy.contains(/sign in to manage your organization|local authentication/i, { + timeout: 30_000, + }).should("be.visible"); }); it("positive: signed-in user can view /organization and sees correct invite permissions", () => { - // Story (positive): a signed-in user can reach the organization page. - // Story (negative within flow): non-admin users cannot invite members. - cy.visit("/sign-in"); - cy.clerkLoaded(); - cy.clerkSignIn({ strategy: "email_code", identifier: email }); - + stubOrganizationApis(); + cy.loginWithLocalAuth(); cy.visit("/organization"); cy.waitForAppLoaded(); cy.contains(/members\s*&\s*invites/i).should("be.visible"); - - // Deterministic assertion across roles: - // - if user is admin: invite button enabled - // - else: invite button disabled with the correct tooltip cy.contains("button", /invite member/i) .should("be.visible") - .then(($btn) => { - const isDisabled = $btn.is(":disabled"); - if (isDisabled) { - cy.wrap($btn) - .should("have.attr", "title") - .and("match", /only organization admins can invite/i); - } else { - cy.wrap($btn).should("not.be.disabled"); - } - }); + .should("be.disabled") + .and("have.attr", "title") + .and("match", /only organization admins can invite/i); }); }); diff --git a/frontend/cypress/support/commands.ts b/frontend/cypress/support/commands.ts index 0848d34a..83eae8e4 100644 --- a/frontend/cypress/support/commands.ts +++ b/frontend/cypress/support/commands.ts @@ -1,46 +1,9 @@ /// -type ClerkOtpLoginOptions = { - clerkOrigin: string; - email: string; - otp: string; -}; - const APP_LOAD_TIMEOUT_MS = 30_000; - -function getEnv(name: string, fallback?: string): string { - const value = Cypress.env(name) as string | undefined; - if (value) return value; - if (fallback !== undefined) return fallback; - throw new Error( - `Missing Cypress env var ${name}. ` + - `Set it via CYPRESS_${name}=... in CI/local before running Clerk login tests.`, - ); -} - -function clerkOriginFromPublishableKey(): string { - const key = getEnv("NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"); - - // pk_test_ OR pk_live_<...> - const m = /^pk_(?:test|live)_(.+)$/.exec(key); - if (!m) throw new Error(`Unexpected Clerk publishable key format: ${key}`); - - const decoded = atob(m[1]); // e.g. beloved-ghost-73.clerk.accounts.dev$ - const domain = decoded.replace(/\$$/, ""); - - // Some flows redirect to *.accounts.dev (no clerk. subdomain) - const normalized = domain.replace(".clerk.accounts.dev", ".accounts.dev"); - return `https://${normalized}`; -} - -function normalizeOrigin(value: string): string { - try { - const url = new URL(value); - return url.origin; - } catch { - return value.replace(/\/$/, ""); - } -} +const LOCAL_AUTH_STORAGE_KEY = "mc_local_auth_token"; +const DEFAULT_LOCAL_AUTH_TOKEN = + "cypress-local-auth-token-0123456789-0123456789-0123456789x"; Cypress.Commands.add("waitForAppLoaded", () => { cy.get("[data-cy='route-loader']", { @@ -52,153 +15,19 @@ Cypress.Commands.add("waitForAppLoaded", () => { }).should("have.attr", "aria-hidden", "true"); }); -Cypress.Commands.add("loginWithClerkOtp", () => { - const clerkOrigin = normalizeOrigin( - getEnv("CLERK_ORIGIN", clerkOriginFromPublishableKey()), - ); - const email = getEnv("CLERK_TEST_EMAIL", "jane+clerk_test@example.com"); - const otp = getEnv("CLERK_TEST_OTP", "424242"); +Cypress.Commands.add("loginWithLocalAuth", (token = DEFAULT_LOCAL_AUTH_TOKEN) => { + cy.visit("/", { + onBeforeLoad(win) { + win.sessionStorage.setItem(LOCAL_AUTH_STORAGE_KEY, token); + }, + }); +}); - const opts: ClerkOtpLoginOptions = { clerkOrigin, email, otp }; - - // Navigate to a dedicated sign-in route that renders Clerk SignIn top-level. - // Cypress cannot reliably drive Clerk modal/iframe flows. - cy.visit("/sign-in"); - - const emailSelector = - 'input[type="email"], input[name="identifier"], input[autocomplete="email"]'; - const otpSelector = - 'input[autocomplete="one-time-code"], input[name*="code"], input[name^="code"], input[name^="code."], input[inputmode="numeric"]'; - const continueSelector = 'button[type="submit"], button'; - const methodSelector = /email|code|otp|send code|verification|verify|use email/i; - - const fillEmailStep = (email: string) => { - cy.get(emailSelector, { timeout: 20_000 }) - .first() - .clear() - .type(email, { delay: 10 }); - - cy.contains(continueSelector, /continue|sign in|send|next/i, { timeout: 20_000 }) - .should("be.visible") - .click({ force: true }); - }; - - const maybeSelectEmailCodeMethod = () => { - cy.get("body").then(($body) => { - const hasOtp = $body.find(otpSelector).length > 0; - if (hasOtp) return; - - const candidates = $body - .find("button,a") - .toArray() - .filter((el) => methodSelector.test((el.textContent || "").trim())); - - if (candidates.length > 0) { - cy.wrap(candidates[0]).click({ force: true }); - } - }); - }; - - const waitForOtpOrMethod = () => { - cy.get("body", { timeout: 60_000 }).should(($body) => { - const hasOtp = $body.find(otpSelector).length > 0; - const hasMethod = $body - .find("button,a") - .toArray() - .some((el) => methodSelector.test((el.textContent || "").trim())); - expect( - hasOtp || hasMethod, - "waiting for OTP input or verification method UI", - ).to.equal(true); - }); - }; - - const fillOtpAndSubmit = (otp: string) => { - waitForOtpOrMethod(); - maybeSelectEmailCodeMethod(); - - cy.get(otpSelector, { timeout: 60_000 }).first().clear().type(otp, { delay: 10 }); - - cy.get("body").then(($body) => { - const hasSubmit = $body - .find(continueSelector) - .toArray() - .some((el) => /verify|continue|sign in|confirm/i.test(el.textContent || "")); - if (hasSubmit) { - cy.contains(continueSelector, /verify|continue|sign in|confirm/i, { timeout: 20_000 }) - .should("be.visible") - .click({ force: true }); - } - }); - }; - - // Clerk SignIn can start on our app origin and then redirect to Clerk-hosted UI. - // Do email step first, then decide where the OTP step lives based on the *current* origin. - fillEmailStep(opts.email); - - cy.location("origin", { timeout: 60_000 }).then((origin) => { - const current = normalizeOrigin(origin); - if (current === opts.clerkOrigin) { - cy.origin( - opts.clerkOrigin, - { args: { otp: opts.otp } }, - ({ otp }) => { - const otpSelector = - 'input[autocomplete="one-time-code"], input[name*="code"], input[name^="code"], input[name^="code."], input[inputmode="numeric"]'; - const continueSelector = 'button[type="submit"], button'; - const methodSelector = /email|code|otp|send code|verification|verify|use email/i; - - const maybeSelectEmailCodeMethod = () => { - cy.get("body").then(($body) => { - const hasOtp = $body.find(otpSelector).length > 0; - if (hasOtp) return; - - const candidates = $body - .find("button,a") - .toArray() - .filter((el) => methodSelector.test((el.textContent || "").trim())); - - if (candidates.length > 0) { - cy.wrap(candidates[0]).click({ force: true }); - } - }); - }; - - const waitForOtpOrMethod = () => { - cy.get("body", { timeout: 60_000 }).should(($body) => { - const hasOtp = $body.find(otpSelector).length > 0; - const hasMethod = $body - .find("button,a") - .toArray() - .some((el) => methodSelector.test((el.textContent || "").trim())); - expect( - hasOtp || hasMethod, - "waiting for OTP input or verification method UI", - ).to.equal(true); - }); - }; - - waitForOtpOrMethod(); - maybeSelectEmailCodeMethod(); - - cy.get(otpSelector, { timeout: 60_000 }).first().clear().type(otp, { delay: 10 }); - - cy.get("body").then(($body) => { - const hasSubmit = $body - .find(continueSelector) - .toArray() - .some((el) => /verify|continue|sign in|confirm/i.test(el.textContent || "")); - if (hasSubmit) { - cy.contains(continueSelector, /verify|continue|sign in|confirm/i, { timeout: 20_000 }) - .should("be.visible") - .click({ force: true }); - } - }); - }, - ); - } else { - fillOtpAndSubmit(opts.otp); - } +Cypress.Commands.add("logoutLocalAuth", () => { + cy.visit("/", { + onBeforeLoad(win) { + win.sessionStorage.removeItem(LOCAL_AUTH_STORAGE_KEY); + }, }); }); @@ -212,15 +41,14 @@ declare global { waitForAppLoaded(): Chainable; /** - * Logs in via the real Clerk SignIn page using deterministic OTP credentials. - * - * Optional env vars (CYPRESS_*): - * - CLERK_ORIGIN (e.g. https://.accounts.dev) - * - NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY (used to derive origin when CLERK_ORIGIN not set) - * - CLERK_TEST_EMAIL (default: jane+clerk_test@example.com) - * - CLERK_TEST_OTP (default: 424242) + * Seeds session storage with a local auth token for local-auth mode. */ - loginWithClerkOtp(): Chainable; + loginWithLocalAuth(token?: string): Chainable; + + /** + * Clears local auth token from session storage. + */ + logoutLocalAuth(): Chainable; } } } diff --git a/frontend/cypress/support/e2e.ts b/frontend/cypress/support/e2e.ts index 369182a5..bf3c9830 100644 --- a/frontend/cypress/support/e2e.ts +++ b/frontend/cypress/support/e2e.ts @@ -3,8 +3,13 @@ /// -import { addClerkCommands } from "@clerk/testing/cypress"; - -addClerkCommands({ Cypress, cy }); +// Next.js hydration mismatch can happen non-deterministically in CI/dev mode. +// Ignore this specific runtime error so E2E assertions can continue. +Cypress.on("uncaught:exception", (err) => { + if (err.message?.includes("Hydration failed")) { + return false; + } + return true; +}); import "./commands"; diff --git a/frontend/src/app/sign-in/[[...rest]]/page.tsx b/frontend/src/app/sign-in/[[...rest]]/page.tsx index 196d303a..ce55bcda 100644 --- a/frontend/src/app/sign-in/[[...rest]]/page.tsx +++ b/frontend/src/app/sign-in/[[...rest]]/page.tsx @@ -3,10 +3,17 @@ import { useSearchParams } from "next/navigation"; import { SignIn } from "@clerk/nextjs"; +import { isLocalAuthMode } from "@/auth/localAuth"; import { resolveSignInRedirectUrl } from "@/auth/redirects"; +import { LocalAuthLogin } from "@/components/organisms/LocalAuthLogin"; export default function SignInPage() { const searchParams = useSearchParams(); + + if (isLocalAuthMode()) { + return ; + } + const forceRedirectUrl = resolveSignInRedirectUrl( searchParams.get("redirect_url"), ); diff --git a/frontend/src/lib/gateway-form.test.ts b/frontend/src/lib/gateway-form.test.ts index 80164d5e..bca3c16a 100644 --- a/frontend/src/lib/gateway-form.test.ts +++ b/frontend/src/lib/gateway-form.test.ts @@ -46,7 +46,9 @@ describe("validateGatewayUrl", () => { }); it("accepts userinfo URLs with explicit port", () => { - expect(validateGatewayUrl("ws://user:pass@gateway.example.com:8080")).toBeNull(); + expect( + validateGatewayUrl("ws://user:pass@gateway.example.com:8080"), + ).toBeNull(); }); it("accepts userinfo URLs with IPv6 host and explicit port", () => { From 42b361ddd95ee9357d897f5e4938cdcf419a4030 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 02:32:42 +0530 Subject: [PATCH 07/14] test(frontend): stabilize auth boundary tests for local auth mode --- frontend/src/app/activity/page.test.tsx | 30 +++++++++++++++++------- frontend/src/app/approvals/page.test.tsx | 30 +++++++++++++++++------- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/frontend/src/app/activity/page.test.tsx b/frontend/src/app/activity/page.test.tsx index 981934c1..388a5826 100644 --- a/frontend/src/app/activity/page.test.tsx +++ b/frontend/src/app/activity/page.test.tsx @@ -62,19 +62,33 @@ vi.mock("@clerk/nextjs", () => { describe("/activity auth boundary", () => { it("renders without ClerkProvider runtime errors when publishable key is a placeholder", () => { + const previousAuthMode = process.env.NEXT_PUBLIC_AUTH_MODE; + const previousPublishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; + // Simulate CI/secretless env where an arbitrary placeholder value may be present. // AuthProvider should treat this as disabled, and the auth wrappers must not render // Clerk SignedOut/SignedIn components. + process.env.NEXT_PUBLIC_AUTH_MODE = "local"; process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = "placeholder"; + window.sessionStorage.clear(); - render( - - - - - , - ); + try { + render( + + + + + , + ); - expect(screen.getByText(/sign in to view the feed/i)).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: /local authentication/i }), + ).toBeInTheDocument(); + expect(screen.getByLabelText(/access token/i)).toBeInTheDocument(); + } finally { + process.env.NEXT_PUBLIC_AUTH_MODE = previousAuthMode; + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = previousPublishableKey; + window.sessionStorage.clear(); + } }); }); diff --git a/frontend/src/app/approvals/page.test.tsx b/frontend/src/app/approvals/page.test.tsx index f747b5bf..cb812fac 100644 --- a/frontend/src/app/approvals/page.test.tsx +++ b/frontend/src/app/approvals/page.test.tsx @@ -59,16 +59,30 @@ vi.mock("@clerk/nextjs", () => { describe("/approvals auth boundary", () => { it("renders without ClerkProvider runtime errors when publishable key is a placeholder", () => { + const previousAuthMode = process.env.NEXT_PUBLIC_AUTH_MODE; + const previousPublishableKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; + + process.env.NEXT_PUBLIC_AUTH_MODE = "local"; process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = "placeholder"; + window.sessionStorage.clear(); - render( - - - - - , - ); + try { + render( + + + + + , + ); - expect(screen.getByText(/sign in to view approvals/i)).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: /local authentication/i }), + ).toBeInTheDocument(); + expect(screen.getByLabelText(/access token/i)).toBeInTheDocument(); + } finally { + process.env.NEXT_PUBLIC_AUTH_MODE = previousAuthMode; + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = previousPublishableKey; + window.sessionStorage.clear(); + } }); }); From 08341d26acdc89979946d21398482e5a73bc44f5 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 02:39:53 +0530 Subject: [PATCH 08/14] test(frontend): stabilize onboarding polling assertion --- frontend/src/components/BoardOnboardingChat.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/components/BoardOnboardingChat.test.tsx b/frontend/src/components/BoardOnboardingChat.test.tsx index 20487678..88dd5b9a 100644 --- a/frontend/src/components/BoardOnboardingChat.test.tsx +++ b/frontend/src/components/BoardOnboardingChat.test.tsx @@ -86,6 +86,9 @@ describe("BoardOnboardingChat polling", () => { ); await screen.findByText("What should we prioritize?"); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Option A" })).toBeEnabled(); + }); const callsBeforeWait = getOnboardingMock.mock.calls.length; await act(async () => { From b35980618d46ed43d7374e9b3753da82681cb679 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 02:40:54 +0530 Subject: [PATCH 09/14] test(e2e): switch board task spec to local auth --- frontend/cypress/e2e/board_tasks.cy.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts index 441b65cf..d368d587 100644 --- a/frontend/cypress/e2e/board_tasks.cy.ts +++ b/frontend/cypress/e2e/board_tasks.cy.ts @@ -2,8 +2,7 @@ describe("/boards/:id task board", () => { const apiBase = "**/api/v1"; - const email = - Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; + const email = "local-auth-user@example.com"; const originalDefaultCommandTimeout = Cypress.config("defaultCommandTimeout"); @@ -32,9 +31,11 @@ describe("/boards/:id task board", () => { cy.get('[aria-label="Edit task"]', { timeout: 20_000 }).should("be.visible"); } - it("auth negative: signed-out user is redirected to sign-in", () => { + it("auth negative: signed-out user is shown local auth login", () => { cy.visit("/boards/b1"); - cy.location("pathname", { timeout: 30_000 }).should("match", /\/sign-in/); + cy.contains("h1", /local authentication/i, { timeout: 30_000 }).should( + "be.visible", + ); }); it("happy path: renders tasks from snapshot and supports create + status update + delete (stubbed)", () => { @@ -204,10 +205,7 @@ describe("/boards/:id task board", () => { body: { items: [], total: 0, limit: 200, offset: 0 }, }).as("taskComments"); - cy.visit("/sign-in"); - cy.clerkLoaded(); - cy.clerkSignIn({ strategy: "email_code", identifier: email }); - + cy.loginWithLocalAuth(); cy.visit("/boards/b1"); cy.waitForAppLoaded(); From c03affe3e0a3954c09b3c7b728d1f9489bc71e5f Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 02:50:11 +0530 Subject: [PATCH 10/14] test(e2e): switch boards list spec to local auth --- frontend/cypress/e2e/boards_list.cy.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/frontend/cypress/e2e/boards_list.cy.ts b/frontend/cypress/e2e/boards_list.cy.ts index c68a8ab8..b1585cd2 100644 --- a/frontend/cypress/e2e/boards_list.cy.ts +++ b/frontend/cypress/e2e/boards_list.cy.ts @@ -2,7 +2,7 @@ describe("/boards", () => { const apiBase = "**/api/v1"; - const email = Cypress.env("CLERK_TEST_EMAIL") || "jane+clerk_test@example.com"; + const email = "local-auth-user@example.com"; const originalDefaultCommandTimeout = Cypress.config("defaultCommandTimeout"); @@ -14,9 +14,11 @@ describe("/boards", () => { Cypress.config("defaultCommandTimeout", originalDefaultCommandTimeout); }); - it("auth negative: signed-out user is redirected to sign-in", () => { + it("auth negative: signed-out user is shown local auth login", () => { cy.visit("/boards"); - cy.location("pathname", { timeout: 30_000 }).should("match", /\/sign-in/); + cy.contains("h1", /local authentication/i, { timeout: 30_000 }).should( + "be.visible", + ); }); it("happy path: signed-in user sees boards list", () => { @@ -88,10 +90,7 @@ describe("/boards", () => { body: { items: [], total: 0, limit: 200, offset: 0 }, }).as("boardGroups"); - cy.visit("/sign-in"); - cy.clerkLoaded(); - cy.clerkSignIn({ strategy: "email_code", identifier: email }); - + cy.loginWithLocalAuth(); cy.visit("/boards"); cy.waitForAppLoaded(); From aa2ceba576e2dd551706d78527ecbfc4a5d136f6 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 02:50:57 +0530 Subject: [PATCH 11/14] test(e2e): scope board task SSE stubs to known endpoints --- frontend/cypress/e2e/board_tasks.cy.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts index d368d587..cc397e71 100644 --- a/frontend/cypress/e2e/board_tasks.cy.ts +++ b/frontend/cypress/e2e/board_tasks.cy.ts @@ -15,12 +15,23 @@ describe("/boards/:id task board", () => { }); function stubEmptySse() { - // Any SSE endpoint should not hang the UI in tests. - cy.intercept("GET", `${apiBase}/**/stream*`, { + // Keep known board-related SSE endpoints quiet in tests. + const emptySse = { statusCode: 200, headers: { "content-type": "text/event-stream" }, body: "", - }); + }; + + cy.intercept("GET", `${apiBase}/boards/*/tasks/stream*`, emptySse).as( + "tasksStream", + ); + cy.intercept("GET", `${apiBase}/boards/*/approvals/stream*`, emptySse).as( + "approvalsStream", + ); + cy.intercept("GET", `${apiBase}/boards/*/memory/stream*`, emptySse).as( + "memoryStream", + ); + cy.intercept("GET", `${apiBase}/agents/stream*`, emptySse).as("agentsStream"); } function openEditTaskDialog() { From 33b705a9356d090363b85972793a0a751b61d1d8 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 02:59:15 +0530 Subject: [PATCH 12/14] test(e2e): remove force click for edit-task actionability --- frontend/cypress/e2e/board_tasks.cy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts index cc397e71..567523a0 100644 --- a/frontend/cypress/e2e/board_tasks.cy.ts +++ b/frontend/cypress/e2e/board_tasks.cy.ts @@ -38,7 +38,7 @@ describe("/boards/:id task board", () => { cy.get('button[title="Edit task"]', { timeout: 20_000 }) .should("be.visible") .and("not.be.disabled") - .click({ force: true }); + .click(); cy.get('[aria-label="Edit task"]', { timeout: 20_000 }).should("be.visible"); } From 8b7c728f32aac15d57a5c53b9476457156ab97c0 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 03:01:15 +0530 Subject: [PATCH 13/14] test(e2e): replace forced clicks with actionability checks --- frontend/cypress/e2e/board_tasks.cy.ts | 31 +++++++++++++++++++------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts index 567523a0..5c5d9145 100644 --- a/frontend/cypress/e2e/board_tasks.cy.ts +++ b/frontend/cypress/e2e/board_tasks.cy.ts @@ -235,20 +235,26 @@ describe("/boards/:id task board", () => { // Open create task flow. // Board page uses an icon-only button with aria-label="New task". - cy.get('button[aria-label="New task"]').click({ force: true }); + cy.get('button[aria-label="New task"]') + .should("be.visible") + .and("not.be.disabled") + .click(); cy.contains('[role="dialog"]', "New task") .should("be.visible") .within(() => { cy.contains("label", "Title").parent().find("input").type("New task"); - cy.contains("button", /^Create task$/).click({ force: true }); + cy.contains("button", /^Create task$/) + .should("be.visible") + .and("not.be.disabled") + .click(); }); cy.wait(["@createTask"]); cy.contains("New task").should("be.visible"); // Open edit task dialog. - cy.contains("Inbox task").click({ force: true }); + cy.contains("Inbox task").scrollIntoView().should("be.visible").click(); cy.wait(["@taskComments"]); cy.contains(/task detail/i).should("be.visible"); openEditTaskDialog(); @@ -258,13 +264,16 @@ describe("/boards/:id task board", () => { cy.contains("label", "Status") .parent() .within(() => { - cy.get('[role="combobox"]').first().click({ force: true }); + cy.get('[role="combobox"]').first().should("be.visible").click(); }); }); - cy.contains("In progress").click({ force: true }); + cy.contains("In progress").should("be.visible").click(); - cy.contains("button", /save changes/i).click({ force: true }); + cy.contains("button", /save changes/i) + .should("be.visible") + .and("not.be.disabled") + .click(); cy.wait(["@updateTask"]); cy.get('[aria-label="Edit task"]').should("not.exist"); @@ -274,11 +283,17 @@ describe("/boards/:id task board", () => { // Delete task via delete dialog. cy.get('[aria-label="Edit task"]').within(() => { - cy.contains("button", /^Delete task$/).click({ force: true }); + cy.contains("button", /^Delete task$/) + .should("be.visible") + .and("not.be.disabled") + .click(); }); cy.get('[aria-label="Delete task"]').should("be.visible"); cy.get('[aria-label="Delete task"]').within(() => { - cy.contains("button", /^Delete task$/).click({ force: true }); + cy.contains("button", /^Delete task$/) + .should("be.visible") + .and("not.be.disabled") + .click(); }); cy.wait(["@deleteTask"]); From 9e4be7192e568cf12061f39d7dc7d824c9169f45 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 25 Feb 2026 03:14:56 +0530 Subject: [PATCH 14/14] test(e2e): ensure delete task button is visible and scrolls into view --- frontend/cypress/e2e/board_tasks.cy.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/cypress/e2e/board_tasks.cy.ts b/frontend/cypress/e2e/board_tasks.cy.ts index 5c5d9145..c347c077 100644 --- a/frontend/cypress/e2e/board_tasks.cy.ts +++ b/frontend/cypress/e2e/board_tasks.cy.ts @@ -284,6 +284,7 @@ describe("/boards/:id task board", () => { // Delete task via delete dialog. cy.get('[aria-label="Edit task"]').within(() => { cy.contains("button", /^Delete task$/) + .scrollIntoView() .should("be.visible") .and("not.be.disabled") .click(); @@ -291,6 +292,7 @@ describe("/boards/:id task board", () => { cy.get('[aria-label="Delete task"]').should("be.visible"); cy.get('[aria-label="Delete task"]').within(() => { cy.contains("button", /^Delete task$/) + .scrollIntoView() .should("be.visible") .and("not.be.disabled") .click();