文件
tennis-training-hub/tests/e2e/helpers/mockApp.ts
2026-03-16 19:19:46 +08:00

1003 行
31 KiB
TypeScript

import type { Page, Route } from "@playwright/test";
type MockUser = {
id: number;
openId: string;
email: string;
name: string;
loginMethod: string;
role: string;
skillLevel: string;
trainingGoals: string | null;
ntrpRating: number;
manualNtrpRating: number | null;
manualNtrpCapturedAt: string | null;
heightCm: number | null;
weightKg: number | null;
sprintSpeedScore: number | null;
explosivePowerScore: number | null;
agilityScore: number | null;
enduranceScore: number | null;
flexibilityScore: number | null;
coreStabilityScore: number | null;
shoulderMobilityScore: number | null;
hipMobilityScore: number | null;
assessmentNotes: string | null;
totalSessions: number;
totalMinutes: number;
totalShots: number;
currentStreak: number;
longestStreak: number;
createdAt: string;
updatedAt: string;
lastSignedIn: string;
};
type MockMediaSession = {
id: string;
userId: string;
title: string;
status: string;
archiveStatus: string;
format: string;
mimeType: string;
qualityPreset: string;
facingMode: string;
deviceKind: string;
reconnectCount: number;
uploadedSegments: number;
uploadedBytes: number;
durationMs: number;
streamConnected: boolean;
viewerCount?: number;
playback: {
webmUrl?: string;
mp4Url?: string;
webmSize?: number;
mp4Size?: number;
ready: boolean;
previewUrl?: string;
};
markers: Array<{
id: string;
type: string;
label: string;
timestampMs: number;
confidence?: number;
createdAt: string;
}>;
};
type MockAppState = {
authenticated: boolean;
user: MockUser;
videos: any[];
analyses: any[];
tasks: any[];
activePlan: {
id: number;
title: string;
skillLevel: string;
durationDays: number;
exercises: Array<{
day: number;
name: string;
category: string;
duration: number;
description: string;
tips: string;
sets: number;
reps: number;
}>;
version: number;
adjustmentNotes: string | null;
} | null;
mediaSession: MockMediaSession | null;
liveRuntime: {
role: "idle" | "owner" | "viewer";
runtimeSession: any | null;
};
nextVideoId: number;
nextTaskId: number;
authMeNullResponsesAfterLogin: number;
viewerSignalConflictRemaining: number;
};
function trpcResult(json: unknown) {
return { result: { data: { json } } };
}
function nowIso() {
return new Date("2026-03-14T12:00:00.000Z").toISOString();
}
function buildUser(name = "TestPlayer"): MockUser {
return {
id: 1,
openId: "username_test_001",
email: "test@example.com",
name,
loginMethod: "username",
role: "user",
skillLevel: "beginner",
trainingGoals: null,
ntrpRating: 2.8,
manualNtrpRating: 2.5,
manualNtrpCapturedAt: nowIso(),
heightCm: 178,
weightKg: 68,
sprintSpeedScore: 4,
explosivePowerScore: 4,
agilityScore: 4,
enduranceScore: 3,
flexibilityScore: 3,
coreStabilityScore: 4,
shoulderMobilityScore: 3,
hipMobilityScore: 4,
assessmentNotes: "每周可练 3 次,右肩偶尔偏紧。",
totalSessions: 12,
totalMinutes: 320,
totalShots: 280,
currentStreak: 5,
longestStreak: 12,
createdAt: nowIso(),
updatedAt: nowIso(),
lastSignedIn: nowIso(),
};
}
function buildStats(user: MockUser) {
const hasSystemNtrp = user.ntrpRating != null;
return {
ntrpRating: user.ntrpRating,
totalSessions: user.totalSessions,
totalMinutes: user.totalMinutes,
totalShots: user.totalShots,
ratingHistory: [
{ createdAt: nowIso(), rating: 2.4, dimensionScores: {} },
{ createdAt: nowIso(), rating: 2.6, dimensionScores: {} },
{ createdAt: nowIso(), rating: 2.8, dimensionScores: {} },
],
recentAnalyses: [
{
id: 10,
createdAt: nowIso(),
overallScore: 82,
exerciseType: "forehand",
shotCount: 18,
},
],
recentLiveSessions: [],
dailyTraining: [
{
trainingDate: "2026-03-13",
totalMinutes: 48,
sessionCount: 2,
effectiveActions: 36,
averageScore: 80,
},
{
trainingDate: "2026-03-14",
totalMinutes: 32,
sessionCount: 1,
effectiveActions: 18,
averageScore: 84,
},
],
achievements: [
{
key: "training_day_1",
name: "开练",
description: "完成首个训练日",
progressPct: 100,
unlocked: true,
},
{
key: "analyses_1",
name: "分析首秀",
description: "完成首个分析会话",
progressPct: 100,
unlocked: true,
},
{
key: "live_analysis_5",
name: "实时观察者",
description: "完成 5 次实时分析",
progressPct: 40,
unlocked: false,
},
],
latestNtrpSnapshot: {
rating: user.ntrpRating,
createdAt: nowIso(),
dimensionScores: {
poseAccuracy: 82,
strokeConsistency: 78,
footwork: 74,
fluidity: 79,
timing: 77,
matchReadiness: 70,
},
},
trainingProfileStatus: {
hasSystemNtrp,
isComplete: true,
missingFields: [],
effectiveNtrp: user.ntrpRating,
ntrpSource: hasSystemNtrp ? "system" : "manual",
assessmentSnapshot: {
heightCm: user.heightCm,
weightKg: user.weightKg,
sprintSpeedScore: user.sprintSpeedScore,
explosivePowerScore: user.explosivePowerScore,
agilityScore: user.agilityScore,
enduranceScore: user.enduranceScore,
flexibilityScore: user.flexibilityScore,
coreStabilityScore: user.coreStabilityScore,
shoulderMobilityScore: user.shoulderMobilityScore,
hipMobilityScore: user.hipMobilityScore,
assessmentNotes: user.assessmentNotes,
},
},
};
}
async function readTrpcInput(route: Route, operationIndex: number) {
const url = new URL(route.request().url());
const rawSearchInput = url.searchParams.get("input");
if (rawSearchInput) {
const parsed = JSON.parse(rawSearchInput);
return parsed?.json ?? parsed?.[operationIndex]?.json ?? null;
}
const postData = route.request().postData();
if (!postData) return null;
const parsed = JSON.parse(postData);
return parsed?.json ?? parsed?.[operationIndex]?.json ?? parsed?.[String(operationIndex)]?.json ?? null;
}
function buildMediaSession(user: MockUser, title: string): MockMediaSession {
return {
id: "session-e2e",
userId: String(user.id),
title,
status: "created",
archiveStatus: "idle",
format: "webm",
mimeType: "video/webm",
qualityPreset: "balanced",
facingMode: "environment",
deviceKind: "mobile",
reconnectCount: 0,
uploadedSegments: 0,
uploadedBytes: 0,
durationMs: 0,
streamConnected: true,
playback: {
ready: false,
},
markers: [],
};
}
function createTask(state: MockAppState, input: {
type: string;
title: string;
status?: string;
progress?: number;
message?: string;
result?: any;
error?: string | null;
}) {
const task = {
id: `task-${state.nextTaskId++}`,
userId: state.user.id,
type: input.type,
status: input.status ?? "succeeded",
title: input.title,
message: input.message ?? "任务执行完成",
progress: input.progress ?? 100,
result: input.result ?? null,
error: input.error ?? null,
attempts: input.status === "failed" ? 2 : 1,
maxAttempts: input.type === "media_finalize" ? 90 : 3,
startedAt: nowIso(),
completedAt: input.status === "queued" || input.status === "running" ? null : nowIso(),
createdAt: nowIso(),
updatedAt: nowIso(),
};
state.tasks = [task, ...state.tasks];
return task;
}
async function fulfillJson(route: Route, body: unknown) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
}
async function handleTrpc(route: Route, state: MockAppState) {
const url = new URL(route.request().url());
const operations = url.pathname.replace("/api/trpc/", "").split(",");
const results = await Promise.all(operations.map(async (operation, operationIndex) => {
switch (operation) {
case "auth.me":
if (state.authenticated && state.authMeNullResponsesAfterLogin > 0) {
state.authMeNullResponsesAfterLogin -= 1;
return trpcResult(null);
}
return trpcResult(state.authenticated ? state.user : null);
case "auth.loginWithUsername":
state.authenticated = true;
return trpcResult({ user: state.user, isNew: false });
case "profile.stats":
return trpcResult(buildStats(state.user));
case "profile.update": {
const input = await readTrpcInput(route, operationIndex);
state.user = {
...state.user,
...input,
updatedAt: nowIso(),
manualNtrpCapturedAt:
input?.manualNtrpRating !== undefined
? input.manualNtrpRating == null
? null
: nowIso()
: state.user.manualNtrpCapturedAt,
};
return trpcResult({ success: true });
}
case "plan.active":
return trpcResult(state.activePlan);
case "plan.list":
return trpcResult(state.activePlan ? [state.activePlan] : []);
case "plan.generate": {
const input = await readTrpcInput(route, operationIndex);
const durationDays = Number(input?.durationDays ?? 7);
const skillLevel = input?.skillLevel ?? state.user.skillLevel;
state.activePlan = {
id: 200,
title: `${state.user.name} 的训练计划`,
skillLevel,
durationDays,
version: 1,
adjustmentNotes: null,
exercises: [
{
day: 1,
name: "正手影子挥拍",
category: "影子挥拍",
duration: 15,
description: "练习完整引拍和收拍动作。",
tips: "保持重心稳定,击球点在身体前侧。",
sets: 3,
reps: 12,
},
{
day: 1,
name: "交叉步移动",
category: "脚步移动",
duration: 12,
description: "强化启动和回位节奏。",
tips: "每次移动后快速回到准备姿势。",
sets: 4,
reps: 10,
},
],
};
return trpcResult({
taskId: createTask(state, {
type: "training_plan_generate",
title: `${durationDays}天训练计划生成`,
result: {
kind: "training_plan_generate",
planId: state.activePlan.id,
plan: state.activePlan,
},
}).id,
});
}
case "plan.adjust":
return trpcResult({
taskId: createTask(state, {
type: "training_plan_adjust",
title: "训练计划调整",
result: {
kind: "training_plan_adjust",
adjustmentNotes: "已根据最近分析结果调整训练重点。",
},
}).id,
});
case "video.list":
return trpcResult(state.videos);
case "video.upload": {
const input = await readTrpcInput(route, operationIndex);
const video = {
id: state.nextVideoId++,
title: input?.title || `实时分析录像 ${state.nextVideoId}`,
url: `/uploads/${state.nextVideoId}.${input?.format || "webm"}`,
format: input?.format || "webm",
fileSize: input?.fileSize || 1024 * 1024,
duration: input?.duration || 60,
exerciseType: input?.exerciseType || "live_analysis",
analysisStatus: "completed",
createdAt: nowIso(),
};
state.videos = [video, ...state.videos];
return trpcResult({ videoId: video.id, url: video.url });
}
case "analysis.list":
return trpcResult(state.analyses);
case "analysis.liveSessionList":
return trpcResult([]);
case "analysis.runtimeGet":
return trpcResult(state.liveRuntime);
case "analysis.runtimeAcquire":
if (state.liveRuntime.runtimeSession?.status === "active" && state.liveRuntime.role === "viewer") {
return trpcResult(state.liveRuntime);
}
state.liveRuntime = {
role: "owner",
runtimeSession: {
id: 501,
title: "实时分析 正手",
sessionMode: "practice",
mediaSessionId: state.mediaSession?.id || null,
status: "active",
startedAt: nowIso(),
endedAt: null,
lastHeartbeatAt: nowIso(),
snapshot: {
phase: "analyzing",
currentAction: "forehand",
rawAction: "forehand",
visibleSegments: 1,
unknownSegments: 0,
durationMs: 1500,
feedback: ["节奏稳定"],
},
},
};
return trpcResult(state.liveRuntime);
case "analysis.runtimeHeartbeat": {
const input = await readTrpcInput(route, operationIndex);
if (state.liveRuntime.runtimeSession) {
state.liveRuntime.runtimeSession = {
...state.liveRuntime.runtimeSession,
mediaSessionId: input?.mediaSessionId ?? state.liveRuntime.runtimeSession.mediaSessionId,
snapshot: input?.snapshot ?? state.liveRuntime.runtimeSession.snapshot,
lastHeartbeatAt: nowIso(),
};
}
return trpcResult(state.liveRuntime);
}
case "analysis.runtimeRelease":
state.liveRuntime = { role: "idle", runtimeSession: null };
return trpcResult({ success: true, runtimeSession: null });
case "analysis.liveSessionSave":
return trpcResult({ sessionId: 1, trainingRecordId: 1 });
case "task.list":
return trpcResult(state.tasks);
case "task.get": {
const rawInput = url.searchParams.get("input");
const parsedInput = rawInput ? JSON.parse(rawInput) : {};
const taskId = parsedInput.json?.taskId || parsedInput[0]?.json?.taskId;
return trpcResult(state.tasks.find((task) => task.id === taskId) || null);
}
case "task.retry": {
const rawInput = url.searchParams.get("input");
const parsedInput = rawInput ? JSON.parse(rawInput) : {};
const taskId = parsedInput.json?.taskId || parsedInput[0]?.json?.taskId;
const task = state.tasks.find((item) => item.id === taskId);
if (task) {
task.status = "succeeded";
task.progress = 100;
task.error = null;
task.message = "任务执行完成";
}
return trpcResult({ task });
}
case "task.createMediaFinalize": {
if (state.mediaSession) {
state.mediaSession.status = "archived";
state.mediaSession.archiveStatus = "completed";
state.mediaSession.playback = {
ready: true,
webmUrl: "/media/assets/sessions/session-e2e/recording.webm",
mp4Url: "/media/assets/sessions/session-e2e/recording.mp4",
webmSize: 2_400_000,
mp4Size: 1_800_000,
previewUrl: "/media/assets/sessions/session-e2e/recording.webm",
};
state.videos = [
{
id: state.nextVideoId++,
title: state.mediaSession.title,
url: state.mediaSession.playback.webmUrl,
format: "webm",
fileSize: state.mediaSession.playback.webmSize,
exerciseType: "recording",
analysisStatus: "completed",
createdAt: nowIso(),
},
...state.videos,
];
}
return trpcResult({
taskId: createTask(state, {
type: "media_finalize",
title: "录制归档",
result: {
kind: "media_finalize",
sessionId: state.mediaSession?.id,
videoId: state.videos[0]?.id,
url: state.videos[0]?.url,
},
}).id,
});
}
case "analysis.getCorrections":
return trpcResult({
taskId: createTask(state, {
type: "pose_correction_multimodal",
title: "动作纠正",
result: {
corrections: "## 动作概览\n整体节奏稳定,建议继续优化击球点前置。",
report: {
priorityFixes: [
{
title: "击球点前置",
why: "击球点略靠后会影响挥拍连贯性。",
howToPractice: "每组 8 次影子挥拍,刻意在身体前侧完成触球动作。",
successMetric: "连续 3 组都能稳定在身体前侧完成挥拍。",
},
],
},
},
}).id,
});
case "video.registerExternal":
if (state.mediaSession?.playback.webmUrl || state.mediaSession?.playback.mp4Url) {
state.videos = [
{
id: state.nextVideoId++,
title: state.mediaSession.title,
url: state.mediaSession.playback.webmUrl || state.mediaSession.playback.mp4Url,
format: "webm",
fileSize: state.mediaSession.playback.webmSize || 1024 * 1024,
exerciseType: "recording",
analysisStatus: "completed",
createdAt: nowIso(),
},
...state.videos,
];
}
return trpcResult({ videoId: state.nextVideoId, url: state.mediaSession?.playback.webmUrl });
case "achievement.list":
return trpcResult(buildStats(state.user).achievements);
case "rating.current":
return trpcResult({
rating: state.user.ntrpRating,
latestSnapshot: buildStats(state.user).latestNtrpSnapshot,
});
case "rating.history":
return trpcResult([
{
id: 1,
rating: 2.4,
triggerType: "daily",
createdAt: nowIso(),
dimensionScores: {
poseAccuracy: 72,
strokeConsistency: 70,
footwork: 66,
fluidity: 69,
timing: 68,
matchReadiness: 60,
},
sourceSummary: { analyses: 1, liveSessions: 0, totalEffectiveActions: 12, totalPk: 0, activeDays: 1 },
},
{
id: 2,
rating: state.user.ntrpRating,
triggerType: "daily",
createdAt: nowIso(),
dimensionScores: buildStats(state.user).latestNtrpSnapshot.dimensionScores,
sourceSummary: { analyses: 2, liveSessions: 1, totalEffectiveActions: 36, totalPk: 0, activeDays: 2 },
},
]);
default:
return trpcResult(null);
}
}));
await fulfillJson(route, results);
}
async function handleMedia(route: Route, state: MockAppState) {
const url = new URL(route.request().url());
const path = url.pathname;
if (path === "/media/health") {
await fulfillJson(route, { ok: true, timestamp: nowIso() });
return;
}
if (path === "/media/sessions" && route.request().method() === "POST") {
state.mediaSession = buildMediaSession(state.user, "E2E 录制");
await fulfillJson(route, { session: state.mediaSession });
return;
}
if (!state.mediaSession) {
await route.fulfill({ status: 404, body: "not found" });
return;
}
if (path.endsWith("/viewer-signal")) {
if (state.viewerSignalConflictRemaining > 0) {
state.viewerSignalConflictRemaining -= 1;
await route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({ error: "viewer stream not ready" }),
});
return;
}
state.mediaSession.viewerCount = (state.mediaSession.viewerCount || 0) + 1;
await fulfillJson(route, { viewerId: `viewer-${state.mediaSession.viewerCount}`, type: "answer", sdp: "mock-answer" });
return;
}
if (path.endsWith("/signal")) {
state.mediaSession.status = "recording";
await fulfillJson(route, { type: "answer", sdp: "mock-answer" });
return;
}
if (path.endsWith("/segments")) {
const buffer = (await route.request().postDataBuffer()) || Buffer.from("");
state.mediaSession.uploadedSegments += 1;
state.mediaSession.uploadedBytes += buffer.length || 1024;
state.mediaSession.durationMs += 60_000;
state.mediaSession.status = "recording";
await fulfillJson(route, { session: state.mediaSession });
return;
}
if (path.endsWith("/markers")) {
state.mediaSession.markers.push({
id: `marker-${state.mediaSession.markers.length + 1}`,
type: "manual",
label: "手动剪辑点",
timestampMs: 12_000,
createdAt: nowIso(),
});
await fulfillJson(route, { session: state.mediaSession });
return;
}
if (path.endsWith("/finalize")) {
state.mediaSession.status = "finalizing";
state.mediaSession.archiveStatus = "queued";
await fulfillJson(route, { session: state.mediaSession });
return;
}
if (path === `/media/sessions/${state.mediaSession.id}`) {
state.mediaSession.status = "archived";
state.mediaSession.archiveStatus = "completed";
state.mediaSession.playback = {
ready: true,
webmUrl: "/media/assets/sessions/session-e2e/recording.webm",
mp4Url: "/media/assets/sessions/session-e2e/recording.mp4",
webmSize: 2_400_000,
mp4Size: 1_800_000,
previewUrl: "/media/assets/sessions/session-e2e/recording.webm",
};
await fulfillJson(route, { session: state.mediaSession });
return;
}
if (path.startsWith("/media/assets/")) {
await route.fulfill({
status: 200,
contentType: path.endsWith(".mp4") ? "video/mp4" : "video/webm",
body: "",
});
return;
}
await route.fulfill({ status: 404, body: "not found" });
}
export async function installAppMocks(
page: Page,
options?: {
authenticated?: boolean;
videos?: any[];
analyses?: any[];
userName?: string;
authMeNullResponsesAfterLogin?: number;
liveViewerMode?: boolean;
viewerSignalConflictOnce?: boolean;
}
) {
const seededViewerSession = options?.liveViewerMode ? buildMediaSession(buildUser(options?.userName), "其他设备实时分析") : null;
const state: MockAppState = {
authenticated: options?.authenticated ?? false,
user: buildUser(options?.userName),
videos: options?.videos ?? [
{
id: 1,
title: "正手训练样例",
url: "/media/assets/sessions/demo/recording.webm",
format: "webm",
fileSize: 3_400_000,
exerciseType: "forehand",
analysisStatus: "completed",
createdAt: nowIso(),
},
],
analyses: options?.analyses ?? [
{
id: 8,
videoId: 1,
overallScore: 84,
shotCount: 16,
avgSwingSpeed: 6.2,
strokeConsistency: 82,
framesAnalyzed: 180,
keyMoments: [
{ frame: 45, type: "shot", description: "建议保留:正手启动" },
{ frame: 110, type: "shot", description: "建议保留:击球后收拍" },
],
createdAt: nowIso(),
},
],
tasks: [],
activePlan: null,
mediaSession: seededViewerSession,
liveRuntime: options?.liveViewerMode
? {
role: "viewer",
runtimeSession: {
id: 777,
title: "其他设备实时分析",
sessionMode: "practice",
mediaSessionId: seededViewerSession?.id || null,
status: "active",
startedAt: nowIso(),
endedAt: null,
lastHeartbeatAt: nowIso(),
snapshot: {
phase: "analyzing",
title: "其他设备实时分析",
sessionMode: "practice",
qualityPreset: "balanced",
facingMode: "environment",
deviceKind: "mobile",
avatarEnabled: true,
avatarKey: "gorilla",
avatarLabel: "猩猩",
updatedAt: Date.parse(nowIso()),
currentAction: "forehand",
rawAction: "forehand",
durationMs: 3200,
visibleSegments: 2,
unknownSegments: 0,
archivedVideoCount: 1,
feedback: ["同步观看测试数据"],
liveScore: {
overall: 82,
posture: 80,
balance: 78,
technique: 84,
footwork: 76,
consistency: 79,
confidence: 88,
},
stabilityMeta: {
windowFrames: 24,
windowShare: 1,
windowProgress: 1,
switchCount: 1,
stableMs: 1800,
rawVolatility: 0.12,
pending: false,
candidateMs: 0,
},
recentSegments: [
{
actionType: "forehand",
isUnknown: false,
startMs: 800,
endMs: 2800,
durationMs: 2000,
confidenceAvg: 0.82,
score: 84,
peakScore: 88,
frameCount: 24,
issueSummary: ["击球点略靠后"],
keyFrames: [1000, 1800, 2600],
clipLabel: "正手挥拍 00:00 - 00:02",
},
],
},
},
}
: {
role: "idle",
runtimeSession: null,
},
nextVideoId: 100,
nextTaskId: 1,
authMeNullResponsesAfterLogin: options?.authMeNullResponsesAfterLogin ?? 0,
viewerSignalConflictRemaining: options?.viewerSignalConflictOnce ? 1 : 0,
};
await page.addInitScript(() => {
const buildFakeLandmarks = () => {
const points = Array.from({ length: 33 }, () => ({
x: 0.5,
y: 0.5,
z: 0,
visibility: 0.99,
}));
points[0] = { x: 0.5, y: 0.15, z: 0, visibility: 0.99 };
points[11] = { x: 0.42, y: 0.28, z: 0, visibility: 0.99 };
points[12] = { x: 0.58, y: 0.28, z: 0, visibility: 0.99 };
points[13] = { x: 0.36, y: 0.42, z: 0, visibility: 0.99 };
points[14] = { x: 0.64, y: 0.42, z: 0, visibility: 0.99 };
points[15] = { x: 0.3, y: 0.54, z: 0, visibility: 0.99 };
points[16] = { x: 0.7, y: 0.52, z: 0, visibility: 0.99 };
points[23] = { x: 0.45, y: 0.58, z: 0, visibility: 0.99 };
points[24] = { x: 0.55, y: 0.58, z: 0, visibility: 0.99 };
points[25] = { x: 0.44, y: 0.76, z: 0, visibility: 0.99 };
points[26] = { x: 0.56, y: 0.76, z: 0, visibility: 0.99 };
points[27] = { x: 0.42, y: 0.94, z: 0, visibility: 0.99 };
points[28] = { x: 0.58, y: 0.94, z: 0, visibility: 0.99 };
return points;
};
class FakePose {
callback = null;
constructor(_config: unknown) {}
setOptions() {}
onResults(callback: (results: { poseLandmarks: ReturnType<typeof buildFakeLandmarks> }) => void) {
this.callback = callback;
}
async send() {
this.callback?.({ poseLandmarks: buildFakeLandmarks() });
}
close() {}
}
Object.defineProperty(window, "__TEST_MEDIAPIPE_FACTORY__", {
configurable: true,
value: async () => ({ Pose: FakePose }),
});
Object.defineProperty(HTMLMediaElement.prototype, "play", {
configurable: true,
value: async () => undefined,
});
Object.defineProperty(HTMLCanvasElement.prototype, "captureStream", {
configurable: true,
value: () => new MediaStream(),
});
class FakeMediaRecorder extends EventTarget {
state = "inactive";
mimeType = "video/webm";
constructor(_stream: MediaStream, options?: { mimeType?: string }) {
super();
this.mimeType = options?.mimeType || "video/webm";
}
static isTypeSupported() {
return true;
}
start() {
this.state = "recording";
}
requestData() {
if (this.state !== "recording") return;
const event = new Event("dataavailable") as Event & { data?: Blob };
event.data = new Blob(["segment"], { type: this.mimeType });
const handler = (this as unknown as { ondataavailable?: (evt: Event & { data?: Blob }) => void }).ondataavailable;
handler?.(event);
this.dispatchEvent(event);
}
stop() {
if (this.state === "inactive") return;
this.requestData();
this.state = "inactive";
const stopEvent = new Event("stop");
const handler = (this as unknown as { onstop?: () => void }).onstop;
handler?.();
this.dispatchEvent(stopEvent);
}
}
class FakeRTCPeerConnection extends EventTarget {
connectionState = "new";
iceGatheringState = "new";
localDescription: { type: string; sdp: string } | null = null;
remoteDescription: { type: string; sdp: string } | null = null;
onconnectionstatechange: (() => void) | null = null;
ontrack: ((event: { streams: MediaStream[] }) => void) | null = null;
addTrack() {}
addTransceiver() {}
async createOffer() {
return { type: "offer", sdp: "mock-offer" };
}
async setLocalDescription(description: { type: string; sdp: string }) {
this.localDescription = description;
this.iceGatheringState = "complete";
this.dispatchEvent(new Event("icegatheringstatechange"));
}
async setRemoteDescription(description: { type: string; sdp: string }) {
this.remoteDescription = description;
this.connectionState = "connected";
this.ontrack?.({ streams: [new MediaStream()] });
this.onconnectionstatechange?.();
}
close() {
this.connectionState = "closed";
this.onconnectionstatechange?.();
}
}
Object.defineProperty(window, "MediaRecorder", {
configurable: true,
value: FakeMediaRecorder,
});
Object.defineProperty(window, "RTCPeerConnection", {
configurable: true,
value: FakeRTCPeerConnection,
});
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: {
getUserMedia: async () => new MediaStream(),
enumerateDevices: async () => [
{ deviceId: "cam-1", kind: "videoinput", label: "Front Camera", groupId: "g1" },
{ deviceId: "cam-2", kind: "videoinput", label: "Back Camera", groupId: "g1" },
],
addEventListener: () => undefined,
removeEventListener: () => undefined,
},
});
});
await page.route("**/api/trpc/**", (route) => handleTrpc(route, state));
await page.route("**/media/**", (route) => handleMedia(route, state));
return state;
}