diff --git a/frontend/cypress/e2e/activity_feed.cy.ts b/frontend/cypress/e2e/activity_feed.cy.ts
new file mode 100644
index 00000000..522730cc
--- /dev/null
+++ b/frontend/cypress/e2e/activity_feed.cy.ts
@@ -0,0 +1,94 @@
+describe("/activity feed", () => {
+ const apiBase = "**/api/v1";
+
+ function stubStreamEmpty() {
+ // Return a minimal SSE response that ends immediately.
+ cy.intercept(
+ "GET",
+ `${apiBase}/activity/task-comments/stream*`,
+ {
+ statusCode: 200,
+ headers: {
+ "content-type": "text/event-stream",
+ },
+ body: "",
+ },
+ ).as("activityStream");
+ }
+
+ it("happy path: renders task comment cards", () => {
+ cy.intercept("GET", `${apiBase}/activity/task-comments*`, {
+ statusCode: 200,
+ body: {
+ items: [
+ {
+ id: "c1",
+ message: "Hello world",
+ agent_name: "Kunal",
+ agent_role: "QA 2",
+ board_id: "b1",
+ board_name: "Testing",
+ task_id: "t1",
+ task_title: "CI hardening",
+ created_at: "2026-02-07T00:00:00Z",
+ },
+ {
+ id: "c2",
+ message: "Second comment",
+ agent_name: "Riya",
+ agent_role: "QA",
+ board_id: "b1",
+ board_name: "Testing",
+ task_id: "t2",
+ task_title: "Coverage policy",
+ created_at: "2026-02-07T00:01:00Z",
+ },
+ ],
+ },
+ }).as("activityList");
+
+ stubStreamEmpty();
+
+ cy.visit("/activity", {
+ onBeforeLoad(win) {
+ win.localStorage.clear();
+ },
+ });
+
+ cy.wait("@activityList");
+
+ cy.contains(/live feed/i).should("be.visible");
+ cy.contains("CI hardening").should("be.visible");
+ cy.contains("Coverage policy").should("be.visible");
+ cy.contains("Hello world").should("be.visible");
+ });
+
+ it("empty state: shows waiting message when no items", () => {
+ cy.intercept("GET", `${apiBase}/activity/task-comments*`, {
+ statusCode: 200,
+ body: { items: [] },
+ }).as("activityList");
+
+ stubStreamEmpty();
+
+ cy.visit("/activity");
+ cy.wait("@activityList");
+
+ cy.contains(/waiting for new comments/i).should("be.visible");
+ });
+
+ it("error state: shows failure UI when API errors", () => {
+ cy.intercept("GET", `${apiBase}/activity/task-comments*`, {
+ statusCode: 500,
+ body: { detail: "boom" },
+ }).as("activityList");
+
+ stubStreamEmpty();
+
+ cy.visit("/activity");
+ cy.wait("@activityList");
+
+ // UI uses query.error.message or fallback.
+ cy.contains(/unable to load feed|boom/i).should("be.visible");
+ });
+});
diff --git a/frontend/src/auth/clerk.tsx b/frontend/src/auth/clerk.tsx
index 02cff2c8..e5e4483e 100644
--- a/frontend/src/auth/clerk.tsx
+++ b/frontend/src/auth/clerk.tsx
@@ -19,20 +19,29 @@ import type { ComponentProps } from "react";
import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey";
+function isE2EAuthBypassEnabled(): boolean {
+ // Used only for Cypress E2E to keep tests secretless and deterministic.
+ // When enabled, we treat the user as signed in and skip Clerk entirely.
+ return process.env.NEXT_PUBLIC_E2E_AUTH_BYPASS === "1";
+}
+
export function isClerkEnabled(): boolean {
// IMPORTANT: keep this in sync with AuthProvider; otherwise components like
// may render without a and crash during prerender.
+ if (isE2EAuthBypassEnabled()) return false;
return isLikelyValidClerkPublishableKey(
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
);
}
export function SignedIn(props: { children: ReactNode }) {
+ if (isE2EAuthBypassEnabled()) return <>{props.children}>;
if (!isClerkEnabled()) return null;
return {props.children};
}
export function SignedOut(props: { children: ReactNode }) {
+ if (isE2EAuthBypassEnabled()) return null;
if (!isClerkEnabled()) return <>{props.children}>;
return {props.children};
}
@@ -58,6 +67,15 @@ export function useUser() {
}
export function useAuth() {
+ if (isE2EAuthBypassEnabled()) {
+ return {
+ isLoaded: true,
+ isSignedIn: true,
+ userId: "e2e-user",
+ sessionId: "e2e-session",
+ getToken: async () => "e2e-token",
+ } as const;
+ }
if (!isClerkEnabled()) {
return {
isLoaded: true,