companion/@tooling/e2e/e2e/mocks.ts
2026-04-08 22:36:58 -07:00

200 lines
7 KiB
TypeScript

import type { Page, Route } from '@playwright/test';
export const E2E_SESSION_ID = 'e2e-session-abc123';
/**
* Two execution environments:
*
* Local dev: VITE_API_URL=http://localhost:3850 → browser fetches http://localhost:3850/session
* Playwright intercepts at the network level.
*
* Docker e2e: No VITE_API_URL → browser fetches http://companion-web:5850/api/session
* Playwright intercepts BEFORE nginx proxy.
*
* Socket.IO connections go via WebSocket upgrade — page.route() intercepts HTTP only,
* so socket connections reach the real server (or fail silently in headless mode).
*
* We use ** glob patterns to match any origin + any path prefix, so the same
* mock works in both local dev (localhost:3850) and Docker (companion-web:5850/api).
*/
/**
* Build an SSE body string from a list of segment texts.
* Produces the format the companion SSE parser expects.
*/
function buildSseBody(segments: Array<{ text: string; emotion?: string }>): string {
const events = segments
.map(
(seg, i) =>
`event: segment\ndata: ${JSON.stringify({ part_index: i, text: seg.text, emotion: seg.emotion ?? 'neutral' })}\n`,
)
.join('\n');
return `${events}\ndata: [DONE]\n\n`;
}
async function fulfillOrContinue(route: Route, status: number, body?: { contentType: string; body: string }): Promise<void> {
try {
if (body) {
await route.fulfill({ status, contentType: body.contentType, body: body.body });
} else {
await route.fulfill({ status });
}
} catch {
// Route may already be handled (navigation cancelled)
}
}
/**
* Stub AudioContext and AudioWorkletNode so PcmPlayer.init() resolves immediately
* in headless Playwright without hanging on addModule().
* Must be injected before page load via addInitScript.
*/
async function injectAudioStubs(page: Page): Promise<void> {
await page.addInitScript(`
(function () {
var audioWorkletStub = { addModule: function() { return Promise.resolve(); } };
var gainNodeStub = {
gain: { value: 1 },
connect: function() {},
disconnect: function() {},
};
function AudioContextStub() {}
Object.defineProperty(AudioContextStub.prototype, 'audioWorklet', {
get: function() { return audioWorkletStub; },
});
Object.defineProperty(AudioContextStub.prototype, 'destination', {
get: function() { return {}; },
});
Object.defineProperty(AudioContextStub.prototype, 'state', {
get: function() { return 'running'; },
});
AudioContextStub.prototype.createGain = function() { return gainNodeStub; };
AudioContextStub.prototype.resume = function() { return Promise.resolve(); };
function AudioWorkletNodeStub() {
this.port = { postMessage: function() {}, close: function() {}, onmessage: null };
}
AudioWorkletNodeStub.prototype.connect = function() {};
AudioWorkletNodeStub.prototype.disconnect = function() {};
window['AudioContext'] = AudioContextStub;
window['AudioWorkletNode'] = AudioWorkletNodeStub;
})();
`);
}
/**
* Register GET routes shared across all mock setups.
* Use ** glob to match any origin + optional /api prefix, covering both
* local dev (http://localhost:3850/session) and Docker (http://companion-web:5850/api/session).
* More specific patterns must be registered before less specific ones.
*/
async function setupCommonRoutes(page: Page): Promise<void> {
// History must be registered before session detail (more specific path)
await page.route('**/session/*/history', async (route) => {
if (route.request().method() !== 'GET') { await route.continue(); return; }
await fulfillOrContinue(route, 200, {
contentType: 'application/json',
body: JSON.stringify([]),
});
});
await page.route('**/session/*', async (route) => {
if (route.request().method() !== 'GET') { await route.continue(); return; }
const url = route.request().url();
// Do not intercept history sub-path — already handled above
if (url.includes('/history')) { await route.continue(); return; }
await fulfillOrContinue(route, 200, {
contentType: 'application/json',
body: JSON.stringify({
session_id: E2E_SESSION_ID,
persona_id: 'miku',
created_at: new Date().toISOString(),
last_activity_at: new Date().toISOString(),
}),
});
});
await page.route('**/personalities', async (route) => {
if (route.request().method() !== 'GET') { await route.continue(); return; }
await fulfillOrContinue(route, 200, {
contentType: 'application/json',
body: JSON.stringify([{ id: 'miku', slug: 'miku', name: 'Miku' }]),
});
});
}
/**
* Mock the companion API for a clean test session.
*
* Sets up:
* - POST /session → { session_id: E2E_SESSION_ID }
* - POST /chat → SSE segments from the provided response text
* - GET /session/:id/history → [] (empty)
* - GET /session/:id → session details
* - GET /personalities → persona list
*
* Note: Socket.IO WebSocket upgrades are not interceptable via page.route() —
* the voice connection state depends on whether the real API server is available.
*/
export async function setupApiMocks(
page: Page,
opts: { chatSegments?: Array<{ text: string; emotion?: string }> } = {},
): Promise<void> {
const segments = opts.chatSegments ?? [
{ text: 'Hello! How can I help you today?', emotion: 'friendly' },
];
await injectAudioStubs(page);
await setupCommonRoutes(page);
await page.route('**/session', async (route) => {
if (route.request().method() !== 'POST') { await route.continue(); return; }
await fulfillOrContinue(route, 200, {
contentType: 'application/json',
body: JSON.stringify({ session_id: E2E_SESSION_ID }),
});
});
await page.route('**/chat', async (route) => {
if (route.request().method() !== 'POST') { await route.continue(); return; }
await fulfillOrContinue(route, 200, {
contentType: 'text/event-stream',
body: buildSseBody(segments),
});
});
}
/**
* Mock a session creation failure (500 from server).
*/
export async function setupSessionErrorMock(page: Page): Promise<void> {
await injectAudioStubs(page);
await setupCommonRoutes(page);
await page.route('**/session', async (route) => {
if (route.request().method() !== 'POST') { await route.continue(); return; }
await fulfillOrContinue(route, 500);
});
}
/**
* Mock a chat request that returns a server error.
*/
export async function setupChatErrorMock(page: Page): Promise<void> {
await injectAudioStubs(page);
await setupCommonRoutes(page);
await page.route('**/session', async (route) => {
if (route.request().method() !== 'POST') { await route.continue(); return; }
await fulfillOrContinue(route, 200, {
contentType: 'application/json',
body: JSON.stringify({ session_id: E2E_SESSION_ID }),
});
});
await page.route('**/chat', async (route) => {
if (route.request().method() !== 'POST') { await route.continue(); return; }
await fulfillOrContinue(route, 500);
});
}