Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions app/desktop/src/__e2e__/chat-flow-ui.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -283,16 +283,25 @@ async function blockLiveBackends(page: Page): Promise<BackendRequestLog> {

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,
Expand All @@ -304,7 +313,7 @@ async function blockLiveBackends(page: Page): Promise<BackendRequestLog> {
return;
}

if (url.protocol === 'http:' || url.protocol === 'https:') {
if (decision.action === 'abort-external-http') {
await route.abort('blockedbyclient');
return;
}
Expand Down
70 changes: 70 additions & 0 deletions app/shared/src/testing/e2eDataModeContract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
buildE2EDataModeManifest,
classifyE2ERequest,
createE2EDataModeScenario,
resolveE2ERequestDecision,
validateE2EDataModeScenario,
} from './e2eDataModeContract';

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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,
});
});
});
76 changes: 76 additions & 0 deletions app/shared/src/testing/e2eDataModeContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[] = [],
Expand Down Expand Up @@ -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',
Expand Down
32 changes: 22 additions & 10 deletions app/web/src/__e2e__/chat-flow-contract.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<number> {
return page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
}
Expand Down Expand Up @@ -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() });
Expand All @@ -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',
Expand All @@ -213,7 +225,7 @@ async function installChatFlowHubStub(page: Page, options: ChatFlowHubStubOption
return;
}

await route.continue();
await route.abort('blockedbyclient');
});

return { endpoints, requests };
Expand Down
Loading
Loading