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", () => {