200 lines
7 KiB
TypeScript
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);
|
|
});
|
|
}
|