Checkpoint: v4.0 media service, compose deploy, and verified docs
这个提交包含在:
419
tests/e2e/helpers/mockApp.ts
普通文件
419
tests/e2e/helpers/mockApp.ts
普通文件
@@ -0,0 +1,419 @@
|
||||
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;
|
||||
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;
|
||||
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[];
|
||||
mediaSession: MockMediaSession | null;
|
||||
nextVideoId: 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,
|
||||
totalSessions: 12,
|
||||
totalMinutes: 320,
|
||||
totalShots: 280,
|
||||
currentStreak: 5,
|
||||
longestStreak: 12,
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
lastSignedIn: nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildStats(user: MockUser) {
|
||||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
|
||||
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 = operations.map((operation) => {
|
||||
switch (operation) {
|
||||
case "auth.me":
|
||||
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 "plan.active":
|
||||
return trpcResult(null);
|
||||
case "plan.list":
|
||||
return trpcResult([]);
|
||||
case "video.list":
|
||||
return trpcResult(state.videos);
|
||||
case "analysis.list":
|
||||
return trpcResult(state.analyses);
|
||||
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 });
|
||||
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("/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;
|
||||
}
|
||||
) {
|
||||
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,
|
||||
createdAt: nowIso(),
|
||||
},
|
||||
],
|
||||
mediaSession: null,
|
||||
nextVideoId: 100,
|
||||
};
|
||||
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "play", {
|
||||
configurable: true,
|
||||
value: async () => undefined,
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
addTrack() {}
|
||||
|
||||
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.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;
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户