diff --git a/.gitea/workflows/e2e-chat.yml b/.gitea/workflows/e2e-chat.yml index 4e6e887d7..98a90688d 100644 --- a/.gitea/workflows/e2e-chat.yml +++ b/.gitea/workflows/e2e-chat.yml @@ -124,12 +124,10 @@ jobs: # renamed/moved spec or stray test.only can no longer green the lane. # - REQUIRE-LIVE guard in "Run Playwright E2E tests" → chat==true must # actually execute >=1 test, else exit 1. + # - chat-desktop.spec.ts asserts echo-runtime.lastRequest after each + # round-trip, so an optimistic client-side render cannot pass without + # a real A2A request reaching the server (core#2796 follow-up). # STILL BLOCKS PROMOTION: - # - The echo round-trip asserts on rendered "Echo: ..." text but never - # asserts the echo runtime actually RECEIVED the A2A request - # (fixtures/echo-runtime.ts exposes lastRequest, unused) — an - # optimistic client-side render could pass without a real round-trip. - # Add a server-received assertion before required. # - The "No-op pass" path (detect-changes chat!=true) is a legitimate # paths-filter skip, but a required gate needs it to be a neutral # check, not a green "success", so a skipped heavy lane can't be diff --git a/canvas/e2e/chat-desktop.spec.ts b/canvas/e2e/chat-desktop.spec.ts index aa48f6db9..bdd423f9e 100644 --- a/canvas/e2e/chat-desktop.spec.ts +++ b/canvas/e2e/chat-desktop.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "@playwright/test"; import type { Page } from "@playwright/test"; -import { startEchoRuntime } from "./fixtures/echo-runtime"; +import { startEchoRuntime, type EchoRuntime } from "./fixtures/echo-runtime"; import { seedWorkspace, startHeartbeat, cleanupWorkspace, runPsql } from "./fixtures/chat-seed"; /** Enter the Org-map view so the Canvas (React Flow graph) mounts. */ @@ -14,17 +14,18 @@ test.describe("Desktop ChatTab", () => { let cleanup: () => Promise = async () => {}; let workspaceId = ""; let workspaceName = ""; + let echoRuntime: EchoRuntime; test.beforeAll(async () => { - const echo = await startEchoRuntime(); - const ws = await seedWorkspace(echo.baseURL); + echoRuntime = await startEchoRuntime(); + const ws = await seedWorkspace(echoRuntime.baseURL); workspaceId = ws.id; workspaceName = ws.name; const stopHeartbeat = startHeartbeat(ws.id, ws.authToken); cleanup = async () => { stopHeartbeat(); - await echo.stop(); + await echoRuntime.stop(); }; }); @@ -86,6 +87,11 @@ test.describe("Desktop ChatTab", () => { await expect(chat.getByText("What is the weather?", { exact: true })).toBeVisible({ timeout: 5_000 }); await expect(chat.getByText("Echo: What is the weather?")).toBeVisible({ timeout: 15_000 }); + + // Regression guard: assert the echo runtime actually RECEIVED the A2A + // request, not just that the UI rendered something that looks like an echo. + expect(echoRuntime.lastRequest).not.toBeNull(); + expect(echoRuntime.lastRequest!.text).toBe("What is the weather?"); }); test("history persists across reload", async ({ page }) => { @@ -96,6 +102,10 @@ test.describe("Desktop ChatTab", () => { await expect(chat.getByText("Echo: Persistence test")).toBeVisible({ timeout: 15_000 }); + // Confirm the round-trip reached the echo runtime before reloading. + expect(echoRuntime.lastRequest).not.toBeNull(); + expect(echoRuntime.lastRequest!.text).toBe("Persistence test"); + await page.reload(); await enterMapView(page); await page.waitForSelector(".react-flow__node", { timeout: 10_000 }); @@ -126,6 +136,11 @@ test.describe("Desktop ChatTab", () => { await page.getByRole("button", { name: /Send/ }).first().click(); await expect(chat.getByText("Echo: Please read this file")).toBeVisible({ timeout: 15_000 }); + + // Confirm the file payload reached the echo runtime, not just the UI. + expect(echoRuntime.lastRequest).not.toBeNull(); + expect(echoRuntime.lastRequest!.text).toBe("Please read this file"); + expect(echoRuntime.lastRequest!.files).toHaveLength(1); }); }); @@ -133,17 +148,18 @@ test.describe("Desktop ChatTab — Markdown rendering", () => { let cleanup: () => Promise = async () => {}; let workspaceId = ""; let workspaceName = ""; + let echoRuntime: EchoRuntime; test.beforeAll(async () => { - const echo = await startEchoRuntime(); - const ws = await seedWorkspace(echo.baseURL); + echoRuntime = await startEchoRuntime(); + const ws = await seedWorkspace(echoRuntime.baseURL); workspaceId = ws.id; workspaceName = ws.name; const stopHeartbeat = startHeartbeat(ws.id, ws.authToken); cleanup = async () => { stopHeartbeat(); - await echo.stop(); + await echoRuntime.stop(); }; });