diff --git a/app/desktop/src/__e2e__/chat-flow-ui.spec.ts b/app/desktop/src/__e2e__/chat-flow-ui.spec.ts index b9a088a1..35077a19 100644 --- a/app/desktop/src/__e2e__/chat-flow-ui.spec.ts +++ b/app/desktop/src/__e2e__/chat-flow-ui.spec.ts @@ -1,8 +1,8 @@ import { expect, test, type Page } from '@playwright/test'; import { assertE2EDataModeScenario, - classifyE2ERequest, createE2EDataModeScenario, + resolveE2ERequestDecision, type E2ERequestPhase, type E2EObservedRequest, } from '../../../shared/src/testing/e2eDataModeContract'; @@ -283,16 +283,25 @@ async function blockLiveBackends(page: Page): Promise { await page.route('**/*', async (route) => { const request = route.request(); - const url = new URL(request.url()); - const boundary = classifyE2ERequest(request.url(), DESKTOP_MOCK_CHAT_FLOW_SCENARIO); + const decision = resolveE2ERequestDecision(DESKTOP_MOCK_CHAT_FLOW_SCENARIO, { + method: request.method(), + url: request.url(), + phase, + }); - if (boundary === 'app') { + if (decision.action === 'continue') { await route.continue(); return; } - if (boundary === 'hub' || boundary === 'local-edge' || boundary === 'tokendance-id' || boundary === 'gateway') { - backendRequests.push({ method: request.method(), url: request.url(), phase }); + if (decision.shouldRecord) { + backendRequests.push(decision.request); + } + + if ( + decision.action === 'fulfill-scenario-backend' || + decision.action === 'block-forbidden-backend' + ) { await route.fulfill({ contentType: 'application/json', status: 503, @@ -304,7 +313,7 @@ async function blockLiveBackends(page: Page): Promise { return; } - if (url.protocol === 'http:' || url.protocol === 'https:') { + if (decision.action === 'abort-external-http') { await route.abort('blockedbyclient'); return; } diff --git a/app/shared/src/testing/e2eDataModeContract.test.ts b/app/shared/src/testing/e2eDataModeContract.test.ts index d5237582..62cba793 100644 --- a/app/shared/src/testing/e2eDataModeContract.test.ts +++ b/app/shared/src/testing/e2eDataModeContract.test.ts @@ -3,6 +3,7 @@ import { buildE2EDataModeManifest, classifyE2ERequest, createE2EDataModeScenario, + resolveE2ERequestDecision, validateE2EDataModeScenario, } from './e2eDataModeContract'; @@ -63,6 +64,28 @@ describe('e2e data-mode contract', () => { 'desktop-chat-flow forbids local-edge request during workbench-runtime GET http://127.0.0.1:3210/v1/health', ], }); + expect(resolveE2ERequestDecision(scenario, { + method: 'GET', + url: 'http://127.0.0.1:3210/v1/health', + phase: 'entry-preflight', + })).toMatchObject({ + boundary: 'local-edge', + phase: 'entry-preflight', + allowed: true, + action: 'fulfill-scenario-backend', + shouldRecord: true, + }); + expect(resolveE2ERequestDecision(scenario, { + method: 'GET', + url: 'http://127.0.0.1:3210/v1/health', + phase: 'workbench-runtime', + })).toMatchObject({ + boundary: 'local-edge', + phase: 'workbench-runtime', + allowed: false, + action: 'block-forbidden-backend', + shouldRecord: true, + }); }); it('rejects stubbed approved-real Web scenarios that claim real execution or direct Local Edge', () => { @@ -99,6 +122,16 @@ describe('e2e data-mode contract', () => { ok: false, errors: ['web-stubbed-hub-replay-smoke forbids local-edge request GET http://127.0.0.1:3210/v1/runs'], }); + expect(resolveE2ERequestDecision(scenario, { + method: 'GET', + url: 'http://127.0.0.1:3210/v1/runs', + })).toMatchObject({ + boundary: 'local-edge', + phase: 'workbench-runtime', + allowed: false, + action: 'block-forbidden-backend', + shouldRecord: true, + }); }); it('keeps Mobile mock preview offline and blocks Local Edge', () => { @@ -216,4 +249,41 @@ describe('e2e data-mode contract', () => { expect(classifyE2ERequest('https://api.vectorcontrol.tech/v1/chat/completions')).toBe('gateway'); expect(classifyE2ERequest('http://127.0.0.1:5174/')).toBe('app'); }); + + it('resolves route actions without duplicating data-mode switch logic in Playwright specs', () => { + const scenario = createE2EDataModeScenario({ + name: 'web-stubbed-hub-replay-smoke', + surface: 'web', + dataMode: 'approved-real', + dataSource: 'stubbed-hub-session', + appOrigin: 'http://127.0.0.1:5174', + hubOrigin: 'http://localhost:8080', + mockAdapterUsed: true, + }); + + expect(resolveE2ERequestDecision(scenario, { + method: 'GET', + url: 'http://127.0.0.1:5174/', + })).toMatchObject({ + boundary: 'app', + action: 'continue', + shouldRecord: false, + }); + expect(resolveE2ERequestDecision(scenario, { + method: 'GET', + url: 'http://localhost:8080/client/auth/me', + })).toMatchObject({ + boundary: 'hub', + action: 'fulfill-scenario-backend', + shouldRecord: true, + }); + expect(resolveE2ERequestDecision(scenario, { + method: 'GET', + url: 'https://example.invalid/telemetry', + })).toMatchObject({ + boundary: 'other-http', + action: 'abort-external-http', + shouldRecord: false, + }); + }); }); diff --git a/app/shared/src/testing/e2eDataModeContract.ts b/app/shared/src/testing/e2eDataModeContract.ts index e791ca4e..76b8972b 100644 --- a/app/shared/src/testing/e2eDataModeContract.ts +++ b/app/shared/src/testing/e2eDataModeContract.ts @@ -26,12 +26,27 @@ export type E2ERequestPhase = | 'workbench-runtime' | 'manifest-preflight'; +export type E2ERequestAction = + | 'continue' + | 'fulfill-scenario-backend' + | 'block-forbidden-backend' + | 'abort-external-http'; + export interface E2EObservedRequest { method: string; url: string; phase?: E2ERequestPhase; } +export interface E2ERequestDecision { + request: E2EObservedRequest; + boundary: E2ERequestBoundary; + phase: E2ERequestPhase; + allowed: boolean; + action: E2ERequestAction; + shouldRecord: boolean; +} + export interface E2EDataModeScenarioInput { name: string; surface: E2ESurface; @@ -167,6 +182,58 @@ export function isE2ERequestAllowed(scenario: E2EDataModeScenario, request: E2EO return isBoundaryAllowed(scenario, classifyE2ERequest(request.url, scenario), request); } +export function resolveE2ERequestDecision( + scenario: E2EDataModeScenario, + request: E2EObservedRequest, +): E2ERequestDecision { + const phase = request.phase ?? 'workbench-runtime'; + const normalizedRequest = { ...request, phase }; + const boundary = classifyE2ERequest(request.url, scenario); + + if (boundary === 'non-http' || boundary === 'app') { + return { + request: normalizedRequest, + boundary, + phase, + allowed: true, + action: 'continue', + shouldRecord: false, + }; + } + + const allowed = isBoundaryAllowed(scenario, boundary, normalizedRequest); + if (allowed) { + return { + request: normalizedRequest, + boundary, + phase, + allowed, + action: 'fulfill-scenario-backend', + shouldRecord: true, + }; + } + + if (isKnownBackendBoundary(boundary)) { + return { + request: normalizedRequest, + boundary, + phase, + allowed, + action: 'block-forbidden-backend', + shouldRecord: true, + }; + } + + return { + request: normalizedRequest, + boundary, + phase, + allowed, + action: 'abort-external-http', + shouldRecord: false, + }; +} + export function buildE2EDataModeManifest( scenario: E2EDataModeScenario, requests: E2EObservedRequest[] = [], @@ -269,6 +336,15 @@ function usesHubBackplane(scenario: E2EDataModeScenario): boolean { ); } +function isKnownBackendBoundary(boundary: E2ERequestBoundary): boolean { + return ( + boundary === 'hub' || + boundary === 'local-edge' || + boundary === 'tokendance-id' || + boundary === 'gateway' + ); +} + const DEFAULT_APP_HOSTS = new Set([ 'localhost:5173', '127.0.0.1:5173', diff --git a/app/web/src/__e2e__/chat-flow-contract.spec.ts b/app/web/src/__e2e__/chat-flow-contract.spec.ts index 1eb21362..2bfcdabe 100644 --- a/app/web/src/__e2e__/chat-flow-contract.spec.ts +++ b/app/web/src/__e2e__/chat-flow-contract.spec.ts @@ -1,8 +1,8 @@ import { expect, test, type Page, type Route } from '@playwright/test'; import { assertE2EDataModeScenario, - classifyE2ERequest, createE2EDataModeScenario, + resolveE2ERequestDecision, type E2EObservedRequest, } from '../../../shared/src/testing/e2eDataModeContract'; @@ -123,17 +123,24 @@ function collectPageDiagnostics(page: Page): void { console.log(`[pageerror] ${error.message}`); }); page.on('requestfailed', (request) => { - console.log(`[requestfailed] ${request.method()} ${request.url()} ${request.failure()?.errorText ?? ''}`); + const errorText = request.failure()?.errorText ?? ''; + if (isExpectedRequestFailure(request.url(), errorText)) return; + console.log(`[requestfailed] ${request.method()} ${request.url()} ${errorText}`); }); } function isExpectedBrowserDiagnostic(text: string): boolean { return ( text.includes("The Content Security Policy directive 'frame-ancestors' is ignored") || - text.includes("WebSocket connection to 'ws://localhost:8080/client/ws") + text.includes("WebSocket connection to 'ws://localhost:8080/client/ws") || + text.includes('Failed to load resource: net::ERR_BLOCKED_BY_CLIENT') ); } +function isExpectedRequestFailure(url: string, errorText: string): boolean { + return errorText.includes('ERR_BLOCKED_BY_CLIENT') && url.startsWith('https://fonts.googleapis.com/'); +} + async function horizontalOverflow(page: Page): Promise { return page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); } @@ -182,16 +189,22 @@ async function installChatFlowHubStub(page: Page, options: ChatFlowHubStubOption await page.route('**/*', async (route) => { const request = route.request(); const url = new URL(request.url()); - const boundary = classifyE2ERequest(request.url(), WEB_CHAT_FLOW_SCENARIO); + const decision = resolveE2ERequestDecision(WEB_CHAT_FLOW_SCENARIO, { + method: request.method(), + url: request.url(), + }); - if (boundary === 'app') { + if (decision.action === 'continue') { await route.continue(); return; } - if (boundary === 'hub') { + if (decision.shouldRecord) { + requests.push(decision.request); + } + + if (decision.action === 'fulfill-scenario-backend' && decision.boundary === 'hub') { endpoints.add(`${request.method()} ${url.pathname}`); - requests.push({ method: request.method(), url: request.url() }); if (request.method() === 'OPTIONS') { await route.fulfill({ status: 204, headers: corsHeaders() }); @@ -202,8 +215,7 @@ async function installChatFlowHubStub(page: Page, options: ChatFlowHubStubOption return; } - if (boundary === 'local-edge' || boundary === 'tokendance-id' || boundary === 'gateway') { - requests.push({ method: request.method(), url: request.url() }); + if (decision.action === 'block-forbidden-backend') { await route.fulfill({ status: 503, contentType: 'application/json', @@ -213,7 +225,7 @@ async function installChatFlowHubStub(page: Page, options: ChatFlowHubStubOption return; } - await route.continue(); + await route.abort('blockedbyclient'); }); return { endpoints, requests }; diff --git a/app/web/src/__e2e__/task-contract.spec.ts b/app/web/src/__e2e__/task-contract.spec.ts index 839a9eb3..1aa074b9 100644 --- a/app/web/src/__e2e__/task-contract.spec.ts +++ b/app/web/src/__e2e__/task-contract.spec.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { buildE2EDataModeManifest, createE2EDataModeScenario, + resolveE2ERequestDecision, type E2EObservedRequest, } from '../../../shared/src/testing/e2eDataModeContract'; @@ -28,14 +29,48 @@ test.describe('Web Hub task approval/artifact contract', () => { requests: [], }; - await page.route('http://localhost:8080/**', async (route) => { + await page.route('**/*', async (route) => { const request = route.request(); + const decision = resolveE2ERequestDecision(WEB_TASK_CONTRACT_SCENARIO, { + method: request.method(), + url: request.url(), + }); + + if (decision.action === 'continue') { + return route.continue(); + } + + if (decision.shouldRecord) { + requested.requests.push(decision.request); + } + + if (decision.action === 'block-forbidden-backend') { + return route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ code: 'blocked_by_e2e_data_mode_contract' }), + headers: corsHeaders(), + }); + } + + if (decision.action === 'abort-external-http') { + return route.abort('blockedbyclient'); + } + + if (decision.boundary !== 'hub') { + return route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ code: 'unexpected_e2e_backend_boundary' }), + headers: corsHeaders(), + }); + } + const url = new URL(request.url()); requested.endpoints.add(`${request.method()} ${url.pathname}`); - requested.requests.push({ method: request.method(), url: request.url() }); if (request.method() === 'OPTIONS') { - return route.fulfill({ status: 204 }); + return route.fulfill({ status: 204, headers: corsHeaders() }); } if (url.pathname === '/client/auth/me') { @@ -287,11 +322,15 @@ function json(body: unknown): { status: 200, contentType: 'application/json', body: JSON.stringify(body), - headers: { - 'access-control-allow-origin': '*', - 'access-control-allow-headers': 'authorization,content-type', - 'access-control-allow-methods': 'GET,POST,PATCH,PUT,DELETE,OPTIONS', - }, + headers: corsHeaders(), + }; +} + +function corsHeaders(): Record { + return { + 'access-control-allow-origin': '*', + 'access-control-allow-headers': 'authorization,content-type', + 'access-control-allow-methods': 'GET,POST,PATCH,PUT,DELETE,OPTIONS', }; } diff --git a/app/web/src/__e2e__/web-stubbed-hub-replay-smoke.spec.ts b/app/web/src/__e2e__/web-stubbed-hub-replay-smoke.spec.ts index c16bea03..0eb4cab6 100644 --- a/app/web/src/__e2e__/web-stubbed-hub-replay-smoke.spec.ts +++ b/app/web/src/__e2e__/web-stubbed-hub-replay-smoke.spec.ts @@ -3,8 +3,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { buildE2EDataModeManifest, - classifyE2ERequest, createE2EDataModeScenario, + resolveE2ERequestDecision, type E2EObservedRequest, } from '../../../shared/src/testing/e2eDataModeContract'; @@ -169,7 +169,9 @@ function collectPageDiagnostics(page: Page): void { console.log(`[pageerror] ${error.message}`); }); page.on('requestfailed', (request) => { - console.log(`[requestfailed] ${request.method()} ${request.url()} ${request.failure()?.errorText ?? ''}`); + const errorText = request.failure()?.errorText ?? ''; + if (isExpectedRequestFailure(request.url(), errorText)) return; + console.log(`[requestfailed] ${request.method()} ${request.url()} ${errorText}`); }); } @@ -178,10 +180,15 @@ function isExpectedBrowserDiagnostic(text: string): boolean { text.includes("The Content Security Policy directive 'frame-ancestors' is ignored") || text.includes("WebSocket connection to 'ws://localhost:8080/client/ws") || text.includes('Failed to load resource: the server responded with a status of 503') || - text.includes('[API] target_inventory_unavailable (HTTP 503): Hub target inventory unavailable') + text.includes('[API] target_inventory_unavailable (HTTP 503): Hub target inventory unavailable') || + text.includes('Failed to load resource: net::ERR_BLOCKED_BY_CLIENT') ); } +function isExpectedRequestFailure(url: string, errorText: string): boolean { + return errorText.includes('ERR_BLOCKED_BY_CLIENT') && url.startsWith('https://fonts.googleapis.com/'); +} + type HubScenario = 'no-target' | 'healthy-target' | 'target-error'; interface HubSmokeOptions { @@ -242,15 +249,21 @@ async function installHubStub(page: Page, scenario: HubScenario = 'healthy-targe await page.route('**/*', async (route) => { const request = route.request(); const url = new URL(request.url()); - const boundary = classifyE2ERequest(request.url(), WEB_HUB_SMOKE_SCENARIO); + const decision = resolveE2ERequestDecision(WEB_HUB_SMOKE_SCENARIO, { + method: request.method(), + url: request.url(), + }); - if (boundary === 'app') { + if (decision.action === 'continue') { return route.continue(); } - if (boundary === 'hub') { + if (decision.shouldRecord) { + requests.push(decision.request); + } + + if (decision.action === 'fulfill-scenario-backend' && decision.boundary === 'hub') { endpoints.add(`${request.method()} ${url.pathname}`); - requests.push({ method: request.method(), url: request.url() }); if (request.method() === 'OPTIONS') { return route.fulfill({ status: 204, headers: corsHeaders() }); @@ -259,8 +272,7 @@ async function installHubStub(page: Page, scenario: HubScenario = 'healthy-targe return fulfillHubRoute(page, route, url.pathname, scenario); } - if (boundary === 'local-edge' || boundary === 'tokendance-id' || boundary === 'gateway') { - requests.push({ method: request.method(), url: request.url() }); + if (decision.action === 'block-forbidden-backend') { return route.fulfill({ status: 503, contentType: 'application/json', @@ -269,7 +281,7 @@ async function installHubStub(page: Page, scenario: HubScenario = 'healthy-targe }); } - return route.continue(); + return route.abort('blockedbyclient'); }); return { endpoints, requests }; diff --git a/docs/progress/MASTER.md b/docs/progress/MASTER.md index cecd3eee..70d20c0e 100644 --- a/docs/progress/MASTER.md +++ b/docs/progress/MASTER.md @@ -24,7 +24,7 @@ | Phase | Name | Milestone URL | Open | Closed | Total | |:--|:--|:--|--:|--:|--:| -| 1 | Evidence Contract Foundation | https://github.com/TokenDanceLab/AgentHub/milestone/17 | 3 | 1 | 4 | +| 1 | Evidence Contract Foundation | https://github.com/TokenDanceLab/AgentHub/milestone/17 | 2 | 2 | 4 | | 2 | Shared Chat Timeline Hardening | https://github.com/TokenDanceLab/AgentHub/milestone/18 | 4 | 0 | 4 | | 3 | Desktop/Web Boundary And Backend Truth | https://github.com/TokenDanceLab/AgentHub/milestone/19 | 3 | 0 | 3 | | 4 | Real E2E And Visual QA Closure | https://github.com/TokenDanceLab/AgentHub/milestone/20 | 3 | 0 | 3 | @@ -35,8 +35,8 @@ | Task ID | Issue | Title | Status | |:--|:--|:--|:--| | T1.1 | #378 | Define chat-flow evidence manifest contract | closed via #395 | -| T1.2 | #379 | Align Visual QA viewports and report shape | implementation pending PR | -| T1.3 | #380 | Reuse data-mode boundary helper in acceptance gates | open | +| T1.2 | #379 | Align Visual QA viewports and report shape | closed via #396 | +| T1.3 | #380 | Reuse data-mode boundary helper in acceptance gates | implementation pending PR | | T1.4 | #381 | Document evidence bundle without rule duplication | open | | T2.1 | #382 | Add golden mixed-source transcript fixtures | open | | T2.2 | #383 | Harden optimistic send and auto-follow contract | open | @@ -61,7 +61,7 @@ gh issue list -R TokenDanceLab/AgentHub --label "spec-driven" --state all --json ## Phase Checklist -- [ ] Phase 1: Evidence Contract Foundation (1/4 tasks) - [milestone](https://github.com/TokenDanceLab/AgentHub/milestone/17) +- [ ] Phase 1: Evidence Contract Foundation (2/4 tasks) - [milestone](https://github.com/TokenDanceLab/AgentHub/milestone/17) - [ ] Phase 2: Shared Chat Timeline Hardening (0/4 tasks) - [milestone](https://github.com/TokenDanceLab/AgentHub/milestone/18) - [ ] Phase 3: Desktop/Web Boundary And Backend Truth (0/3 tasks) - [milestone](https://github.com/TokenDanceLab/AgentHub/milestone/19) - [ ] Phase 4: Real E2E And Visual QA Closure (0/3 tasks) - [milestone](https://github.com/TokenDanceLab/AgentHub/milestone/20) @@ -70,7 +70,7 @@ gh issue list -R TokenDanceLab/AgentHub --label "spec-driven" --state all --json ## Current Status **Active Phase**: Phase 1 -**Active Task**: T1.2 - Align Visual QA viewports and report shape (#379), implementation pending PR +**Active Task**: T1.3 - Reuse data-mode boundary helper in acceptance gates (#380) **Blockers**: None for GitHub Standard. GitHub Project board requires refreshed `project` scope and is intentionally skipped. ## Governance Status @@ -88,8 +88,8 @@ Per-task telemetry is stored in GitHub issue comments before task closure. Adapt ## Next Steps -1. Validate and merge #379 into `dev/delicious233`. -2. Continue Phase 1 with #380 and #381 after #379 is merged. +1. Validate and merge #380 into `dev/delicious233`. +2. Continue Phase 1 with #381 after #380 is merged. 3. Keep full Web Visual QA brand-shell failure scoped to the Visual QA/design acceptance lane; do not overclaim it as green. ## Session Log @@ -99,3 +99,4 @@ Per-task telemetry is stored in GitHub issue comments before task closure. Adapt | 2026-06-28 | spec setup | Created analysis docs, plan docs, GitHub milestones #17-#21, and task issues #378-#393 from branch `spec/real-foundation-hardening`. | | 2026-06-28 | T1.1 implementation | Added shared chat-flow evidence manifest contract and tests; full shared Vitest passed, targeted TypeScript passed, broad `app/shared lint` remains blocked by pre-existing story/test type debt unrelated to this task. | | 2026-06-28 | T1.2 implementation | Aligned Web Visual QA desktop scenes to `1440x810`, added screenshot/DOM-metrics report output, tightened the real-e2e verifier, and generated a failing Visual QA report that now records the remaining shell brand-image assertion. | +| 2026-06-28 | T1.3 implementation | Added shared E2E request-decision helper and reused it from Desktop/Web Playwright boundary gates; Desktop chat-flow and Web stubbed Hub E2E passed with `real_tested=false` boundaries. |