|
- import fs from 'node:fs'
- import path from 'node:path'
- import { randomBytes } from 'node:crypto'
- import { execFileSync } from 'node:child_process'
-
- import type { APIRequestContext, APIResponse, TestInfo } from '@playwright/test'
-
- import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures'
- import { authHeaders, envelopeData, loginAsAdmin } from './helpers'
-
- type JsonRecord = Record<string, unknown>
- type Headers = Record<string, string>
-
- interface Activity {
- module: string
- action: string
- result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED'
- httpStatus?: number
- resourceId?: string
- detail?: string
- }
-
- const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object'
- ? value as JsonRecord
- : {}
-
- const responseData = async (response: APIResponse, expected: number | number[], label: string) => {
- const expectedStatuses = Array.isArray(expected) ? expected : [expected]
- expect(expectedStatuses, `${label}:HTTP ${response.status()}`).toContain(response.status())
- return asRecord(envelopeData(await response.json()))
- }
-
- const addPass = (
- activities: Activity[],
- module: string,
- action: string,
- response: APIResponse,
- resourceId?: string,
- detail?: string,
- ) => activities.push({ module, action, result: 'PASS', httpStatus: response.status(), resourceId, detail })
-
- async function waitForJob(
- request: APIRequestContext,
- headers: Headers,
- jobId: string,
- timeoutMs = 120_000,
- ) {
- const deadline = Date.now() + timeoutMs
- let latest: JsonRecord = {}
- while (Date.now() < deadline) {
- const response = await request.get(`/api/v1/jobs/${encodeURIComponent(jobId)}`, { headers })
- expect(response.ok(), `读取数字形象任务失败:HTTP ${response.status()}`).toBeTruthy()
- latest = asRecord(envelopeData(await response.json()))
- const status = String(latest.status ?? '').toLowerCase()
- if (status === 'succeeded') return latest
- if (status === 'failed' || status === 'cancelled') {
- throw new Error(`数字形象任务未成功:${String(latest.errorMessage ?? latest.message ?? status)}`)
- }
- await new Promise((resolve) => setTimeout(resolve, 750))
- }
- throw new Error(`数字形象任务在 ${timeoutMs}ms 内未完成,最后状态:${String(latest.status ?? 'unknown')}`)
- }
-
- function createShortWebm(outputPath: string) {
- fs.mkdirSync(path.dirname(outputPath), { recursive: true })
- const ffmpeg = process.env.FFMPEG_BINARY?.trim() || 'ffmpeg'
- execFileSync(ffmpeg, [
- '-y',
- '-hide_banner',
- '-loglevel', 'error',
- '-f', 'lavfi',
- '-i', 'testsrc2=size=640x360:rate=15',
- '-f', 'lavfi',
- '-i', 'sine=frequency=660:sample_rate=48000',
- '-t', '1.8',
- '-c:v', 'libvpx-vp9',
- '-deadline', 'realtime',
- '-cpu-used', '8',
- '-b:v', '420k',
- '-c:a', 'libopus',
- '-b:a', '48k',
- outputPath,
- ], { stdio: 'pipe', timeout: 60_000, windowsHide: true })
- const size = fs.statSync(outputPath).size
- expect(size, '本机 FFmpeg 应生成非空的真实 WebM 测试视频').toBeGreaterThan(10_000)
- }
-
- async function latestAgent(
- request: APIRequestContext,
- headers: Headers,
- agentId: string,
- ) {
- const response = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers })
- return { response, item: response.status() === 200 ? asRecord(envelopeData(await response.json())) : {} }
- }
-
- async function cleanupAgent(
- request: APIRequestContext,
- headers: Headers,
- agentId: string,
- activities: Activity[],
- cleanupErrors: string[],
- ) {
- try {
- const { response, item } = await latestAgent(request, headers, agentId)
- if (response.status() === 404) return
- if (response.status() !== 200) throw new Error(`读取失败 HTTP ${response.status()}`)
- let current = item
- if (String(current.status ?? '').toLowerCase() === 'published') {
- const disabled = await request.post(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/disable`, {
- headers,
- data: { dataVersion: Number(current.dataVersion) },
- })
- if (disabled.status() !== 200) throw new Error(`停用失败 HTTP ${disabled.status()}`)
- current = asRecord(envelopeData(await disabled.json()))
- }
- const removed = await request.delete(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, {
- headers,
- params: { dataVersion: String(Number(current.dataVersion)) },
- })
- if (removed.status() !== 200 && removed.status() !== 404) throw new Error(`删除失败 HTTP ${removed.status()}`)
- activities.push({ module: '智能体', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: agentId })
- } catch (error) {
- const message = `智能体清理异常:${error instanceof Error ? error.message : String(error)}`
- cleanupErrors.push(message)
- activities.push({ module: '智能体', action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: agentId, detail: message })
- }
- }
-
- async function cleanupAvatar(
- request: APIRequestContext,
- headers: Headers,
- avatarId: string,
- activities: Activity[],
- cleanupErrors: string[],
- ) {
- try {
- const detail = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers })
- if (detail.status() === 404) return
- if (detail.status() !== 200) throw new Error(`读取失败 HTTP ${detail.status()}`)
- const item = asRecord(envelopeData(await detail.json()))
- const removed = await request.delete(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, {
- headers,
- params: { dataVersion: String(Number(item.dataVersion)) },
- })
- if (removed.status() !== 200 && removed.status() !== 404) throw new Error(`删除失败 HTTP ${removed.status()}`)
- activities.push({ module: '数字形象', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: avatarId })
- } catch (error) {
- const message = `数字形象清理异常:${error instanceof Error ? error.message : String(error)}`
- cleanupErrors.push(message)
- activities.push({ module: '数字形象', action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: avatarId, detail: message })
- }
- }
-
- async function cleanupVideoResources(
- request: APIRequestContext,
- headers: Headers,
- refs: { videoId: string; folderId: string; projectId: string },
- activities: Activity[],
- cleanupErrors: string[],
- ) {
- const cleanupDetail = async (module: string, id: string, detailPath: string, deletePath: string) => {
- if (!id) return
- try {
- const detail = await request.get(detailPath, { headers })
- if (detail.status() === 404) return
- if (detail.status() !== 200) throw new Error(`读取失败 HTTP ${detail.status()}`)
- const item = asRecord(envelopeData(await detail.json()))
- const removed = await request.delete(deletePath, {
- headers,
- params: { dataVersion: String(Number(item.dataVersion)) },
- })
- if (removed.status() !== 200 && removed.status() !== 404) throw new Error(`删除失败 HTTP ${removed.status()}`)
- activities.push({ module, action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id })
- } catch (error) {
- const message = `${module}清理异常:${error instanceof Error ? error.message : String(error)}`
- cleanupErrors.push(message)
- activities.push({ module, action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: id, detail: message })
- }
- }
-
- await cleanupDetail('成片', refs.videoId, `/api/v1/videos/${encodeURIComponent(refs.videoId)}`, `/api/v1/videos/${encodeURIComponent(refs.videoId)}`)
-
- if (refs.folderId) {
- try {
- const response = await request.get('/api/v1/video-folders', { headers })
- if (response.status() === 200) {
- const payload = asRecord(envelopeData(await response.json()))
- const item = (Array.isArray(payload.items) ? payload.items : [])
- .map(asRecord)
- .find((candidate) => String(candidate.id ?? '') === refs.folderId)
- if (item) {
- const removed = await request.delete(`/api/v1/video-folders/${encodeURIComponent(refs.folderId)}`, {
- headers,
- params: { dataVersion: String(Number(item.dataVersion)) },
- })
- if (removed.status() !== 200 && removed.status() !== 404) throw new Error(`删除失败 HTTP ${removed.status()}`)
- activities.push({ module: '视频文件夹', action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: refs.folderId })
- }
- } else {
- throw new Error(`读取失败 HTTP ${response.status()}`)
- }
- } catch (error) {
- const message = `视频文件夹清理异常:${error instanceof Error ? error.message : String(error)}`
- cleanupErrors.push(message)
- activities.push({ module: '视频文件夹', action: '失败兜底清理', result: 'CLEANUP_FAILED', resourceId: refs.folderId, detail: message })
- }
- }
-
- await cleanupDetail('视频工程', refs.projectId, `/api/v1/video-projects/${encodeURIComponent(refs.projectId)}`, `/api/v1/video-projects/${encodeURIComponent(refs.projectId)}`)
- }
-
- async function attachReport(testInfo: TestInfo, activities: Activity[], cleanupErrors: string[]) {
- await attachJson(testInfo, '智能体公开交付生命周期与清理', {
- runId,
- runPrefix,
- activities,
- cleanupComplete: cleanupErrors.length === 0,
- cleanupErrors,
- security: '报告未记录管理员密码、访问令牌、会话令牌、WebSocket ticket 或一次性 API Key。',
- })
- }
-
- test('数字形象、七区配置、发布、公开问答、反馈、停用与清理完整闭环', async ({ page, request }, testInfo) => {
- test.setTimeout(600_000)
- const activities: Activity[] = []
- const cleanupErrors: string[] = []
- const headers = await authHeaders(request)
- // 原型的专属数字人链路明确要求上传本人视频,而不是静态头像图片。
- const fixturePath = path.resolve('../ai_person/public/avatar-previews/mechanical-male.mp4')
- expect(fs.existsSync(fixturePath), `数字形象测试素材不存在:${fixturePath}`).toBeTruthy()
-
- let avatarId = ''
- let agentId = ''
- const videoRefs = { videoId: '', folderId: '', projectId: '' }
- let generatedWebmPath = ''
- let captureJobId = ''
- let captureTitle = ''
- let primaryError: unknown
- const allowedOrigin = new URL(process.env.BASE_URL || 'http://127.0.0.1:8003').origin
- const accessPassword = `ApE2e!${randomBytes(12).toString('hex')}A1`
-
- try {
- const createdAvatarResponse = await request.post('/api/v1/avatars', {
- headers,
- multipart: {
- name: `${runPrefix}-维修教员形象`,
- resize: 'true',
- matting: 'off',
- file: {
- name: `${runPrefix}-avatar.mp4`,
- mimeType: 'video/mp4',
- buffer: fs.readFileSync(fixturePath),
- },
- },
- timeout: 120_000,
- })
- const avatarPayload = await responseData(createdAvatarResponse, 201, '上传创建数字形象')
- const createdAvatar = asRecord(avatarPayload.avatar)
- const avatarJob = asRecord(avatarPayload.job)
- avatarId = String(createdAvatar.id ?? '')
- const jobId = String(avatarJob.id ?? '')
- expect(avatarId).not.toBe('')
- expect(jobId).not.toBe('')
- addPass(activities, '数字形象', '上传原型视频并创建任务', createdAvatarResponse, avatarId)
-
- await waitForJob(request, headers, jobId)
- const readyResponse = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers })
- const readyAvatar = await responseData(readyResponse, 200, '刷新数字形象处理结果')
- expect(String(readyAvatar.status).toLowerCase()).toBe('ready')
- addPass(activities, '数字形象', '任务完成并持久化 READY 状态', readyResponse, avatarId)
-
- const editedAvatarResponse = await request.put(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, {
- headers,
- data: {
- name: `${runPrefix}-维修教员形象-已编辑`,
- role: '装备维修教员',
- specialty: '液压与电气检修',
- gender: '男',
- visibility: 'internal',
- dataVersion: Number(readyAvatar.dataVersion),
- },
- })
- const editedAvatar = await responseData(editedAvatarResponse, 200, '编辑数字形象')
- expect(editedAvatar.name).toBe(`${runPrefix}-维修教员形象-已编辑`)
- addPass(activities, '数字形象', '编辑并刷新持久化', editedAvatarResponse, avatarId)
-
- const ttsResponse = await request.get('/api/v1/capabilities', {
- headers,
- params: { capabilityType: 'TTS', enabled: 'true', page: '1', pageSize: '100' },
- })
- const ttsPage = await responseData(ttsResponse, 200, '读取正式 TTS 能力')
- const ttsItems = (Array.isArray(ttsPage.items) ? ttsPage.items : []).map(asRecord)
- const tts = ttsItems.find((item) => item.capabilityType === 'TTS' && item.enabled === true)
- const ttsId = String(tts?.id ?? '')
- expect(ttsId, '平台必须存在一项已启用的正式 TTS 能力').not.toBe('')
- addPass(activities, '视频工程', '选择平台正式 TTS 能力', ttsResponse, ttsId)
-
- const createProjectResponse = await request.post('/api/v1/video-projects', {
- headers,
- data: {
- name: `${runPrefix}-设备点检口播工程`,
- scriptText: '开始检修前,请先停机、断电、验电并确认液压系统已经卸压。',
- avatarId,
- voiceCapabilityId: ttsId,
- voiceSpeed: 1,
- orientation: 'portrait',
- backgroundType: 'workshop',
- subtitleEnabled: true,
- subtitleFont: 'Microsoft YaHei',
- subtitleFontSize: 42,
- subtitlePosition: 'bottom',
- durationMs: 1800,
- },
- })
- let videoProject = await responseData(createProjectResponse, 201, '创建视频工程')
- videoRefs.projectId = String(videoProject.id ?? '')
- expect(videoRefs.projectId).not.toBe('')
- expect(videoProject.voiceCapabilityId).toBe(ttsId)
- addPass(activities, '视频工程', '创建并绑定形象与正式 TTS', createProjectResponse, videoRefs.projectId)
-
- const updateProjectResponse = await request.put(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}`, {
- headers,
- data: {
- name: `${runPrefix}-设备点检标准口播工程`,
- scriptText: '停机、断电、验电、卸压后,确认压力表归零再开始设备点检。',
- voiceSpeed: 1.05,
- dataVersion: Number(videoProject.dataVersion),
- },
- })
- videoProject = await responseData(updateProjectResponse, 200, '编辑视频工程')
- expect(videoProject.voiceSpeed).toBe(1.05)
- addPass(activities, '视频工程', '编辑工程基本参数', updateProjectResponse, videoRefs.projectId)
-
- const timelineResponse = await request.put(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}/timeline`, {
- headers,
- data: {
- dataVersion: Number(videoProject.dataVersion),
- durationMs: 1800,
- tracks: [
- {
- code: 'AVATAR', name: '数字人', order: 0,
- clips: [{ id: `${runId}-avatar`.slice(0, 64), type: 'AVATAR', startMs: 0, endMs: 1800 }],
- },
- {
- code: 'MEDIA', name: '画面素材', order: 1,
- clips: [{ id: `${runId}-media`.slice(0, 64), type: 'TITLE', text: '设备点检安全提示', startMs: 0, endMs: 1800 }],
- },
- {
- code: 'AUDIO', name: '正式配音', order: 2,
- clips: [{ id: `${runId}-audio`.slice(0, 64), type: 'AUDIO', capabilityId: ttsId, startMs: 0, endMs: 1800 }],
- },
- {
- code: 'SUBTITLE', name: '同步字幕', order: 3,
- clips: [{ id: `${runId}-subtitle`.slice(0, 64), type: 'SUBTITLE', text: '停机、断电、验电、卸压', startMs: 0, endMs: 1800, style: { font: 'Microsoft YaHei', size: 42 } }],
- },
- ],
- },
- })
- videoProject = await responseData(timelineResponse, 200, '保存四轨时间线')
- expect(asRecord(videoProject.timeline).tracks).toBeInstanceOf(Array)
- addPass(activities, '视频工程', '保存四轨时间线', timelineResponse, videoRefs.projectId)
-
- const refreshedProjectResponse = await request.get(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}`, { headers })
- videoProject = await responseData(refreshedProjectResponse, 200, '刷新视频工程')
- expect((asRecord(videoProject.timeline).tracks as unknown[]).map((track) => String(asRecord(track).code)))
- .toEqual(['AVATAR', 'MEDIA', 'AUDIO', 'SUBTITLE'])
- addPass(activities, '视频工程', '刷新后四轨及片段保持一致', refreshedProjectResponse, videoRefs.projectId)
-
- const createFolderResponse = await request.post('/api/v1/video-folders', {
- headers,
- data: { name: `${runPrefix}-装备点检成片`, description: 'E2E 真实采集成片临时文件夹' },
- })
- let videoFolder = await responseData(createFolderResponse, 201, '创建视频文件夹')
- videoRefs.folderId = String(videoFolder.id ?? '')
- expect(videoRefs.folderId).not.toBe('')
- addPass(activities, '视频文件夹', '创建文件夹', createFolderResponse, videoRefs.folderId)
-
- const updateFolderResponse = await request.put(`/api/v1/video-folders/${encodeURIComponent(videoRefs.folderId)}`, {
- headers,
- data: {
- name: `${runPrefix}-装备点检正式成片`,
- description: '设备点检口播与安全培训成片',
- dataVersion: Number(videoFolder.dataVersion),
- },
- })
- videoFolder = await responseData(updateFolderResponse, 200, '编辑视频文件夹')
- expect(videoFolder.name).toBe(`${runPrefix}-装备点检正式成片`)
- addPass(activities, '视频文件夹', '编辑并持久化文件夹', updateFolderResponse, videoRefs.folderId)
-
- generatedWebmPath = testInfo.outputPath(`${runPrefix}-real-capture.webm`)
- createShortWebm(generatedWebmPath)
- captureTitle = `${runPrefix}-真实浏览器采集成片`
- const captureResponse = await request.post('/api/v1/captures', {
- headers,
- multipart: {
- kind: 'oral',
- title: captureTitle,
- avatarId,
- text: '停机、断电、验电、卸压后再进行设备点检。',
- speaker: '1',
- speed: '0.7',
- subtitleEnabled: 'true',
- file: {
- name: `${runPrefix}-real-capture.webm`,
- mimeType: 'video/webm',
- buffer: fs.readFileSync(generatedWebmPath),
- },
- },
- timeout: 120_000,
- })
- const capture = await responseData(captureResponse, 202, '提交真实 WebM 采集文件')
- captureJobId = String(capture.jobId ?? '')
- expect(captureJobId).toMatch(/^[a-f0-9]{32}$/)
- addPass(activities, '视频采集', '本机 FFmpeg WebM 提交后台转码', captureResponse, captureJobId)
- fs.rmSync(generatedWebmPath, { force: true })
- generatedWebmPath = ''
-
- const completedJob = await waitForJob(request, headers, captureJobId, 180_000)
- expect(String(completedJob.status).toLowerCase()).toBe('succeeded')
- addPass(activities, '制作队列', '真实 DB 任务完成', captureResponse, captureJobId)
-
- const jobListResponse = await request.get('/api/v1/jobs', {
- headers,
- params: { keyword: captureTitle, status: 'SUCCEEDED', page: '1', pageSize: '20' },
- })
- const jobList = await responseData(jobListResponse, 200, '筛选制作任务')
- expect((Array.isArray(jobList.items) ? jobList.items : []).map(asRecord)
- .some((item) => String(item.id ?? '') === captureJobId)).toBeTruthy()
- addPass(activities, '制作队列', '按标题和状态筛选任务', jobListResponse, captureJobId)
-
- const jobDetailResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(captureJobId)}`, { headers })
- const jobDetail = await responseData(jobDetailResponse, 200, '读取任务详情')
- expect(String(jobDetail.status).toLowerCase()).toBe('succeeded')
- addPass(activities, '制作队列', '读取完成状态与任务详情', jobDetailResponse, captureJobId)
-
- const jobEventsResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(captureJobId)}/events`, { headers })
- const jobEvents = await responseData(jobEventsResponse, 200, '读取任务事件')
- expect(Array.isArray(jobEvents.items) ? jobEvents.items.length : 0).toBeGreaterThan(0)
- addPass(activities, '制作队列', '读取真实任务事件流', jobEventsResponse, captureJobId)
-
- const jobResultResponse = await request.get(`/api/v1/jobs/${encodeURIComponent(captureJobId)}/result`, { headers })
- expect(jobResultResponse.status()).toBe(200)
- expect(String(jobResultResponse.headers()['content-type'] ?? '')).toContain('video/mp4')
- expect((await jobResultResponse.body()).length).toBeGreaterThan(10_000)
- addPass(activities, '制作队列', '下载真实任务结果', jobResultResponse, captureJobId)
-
- const commitResponse = await request.post(`/api/v1/videos/capture-jobs/${encodeURIComponent(captureJobId)}/commit`, {
- headers,
- data: {
- projectId: videoRefs.projectId,
- folderId: videoRefs.folderId,
- title: `${runPrefix}-设备点检安全口播`,
- description: '真实 WebM 经 FFmpeg 转码并提交为平台成片。',
- category: '装备维修',
- tags: ['设备点检', '安全培训', '设备点检'],
- visibility: 'private',
- },
- })
- let video = await responseData(commitResponse, 201, '提交采集任务为成片')
- videoRefs.videoId = String(video.id ?? '')
- expect(videoRefs.videoId).not.toBe('')
- expect(video.status).toBe('ready')
- expect(video.folderId).toBe(videoRefs.folderId)
- expect(video.tags).toEqual(['设备点检', '安全培训'])
- addPass(activities, '成片管理', '任务提交为真实持久化成片', commitResponse, videoRefs.videoId)
-
- const linkedProjectResponse = await request.get(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}`, { headers })
- const linkedProject = await responseData(linkedProjectResponse, 200, '刷新成片关联工程')
- expect(linkedProject.lastVideoId).toBe(videoRefs.videoId)
- expect(linkedProject.status).toBe('ready')
- addPass(activities, '视频工程', '工程回写最新成片与 READY 状态', linkedProjectResponse, videoRefs.projectId)
-
- const protectedFileResponse = await request.get(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}/file`, { headers })
- expect(protectedFileResponse.status()).toBe(200)
- expect(String(protectedFileResponse.headers()['content-type'] ?? '')).toContain('video/mp4')
- expect((await protectedFileResponse.body()).length).toBeGreaterThan(10_000)
- addPass(activities, '成片管理', '登录态下载真实 MP4 文件', protectedFileResponse, videoRefs.videoId)
-
- const updateVideoResponse = await request.put(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}`, {
- headers,
- data: {
- title: `${runPrefix}-设备点检标准安全口播`,
- description: '停机、断电、验电、卸压后的标准设备点检流程。',
- category: '维修工艺',
- tags: ['点检', '安全', '点检'],
- visibility: 'private',
- folderId: videoRefs.folderId,
- dataVersion: Number(video.dataVersion),
- },
- })
- video = await responseData(updateVideoResponse, 200, '编辑成片信息')
- expect(video.title).toBe(`${runPrefix}-设备点检标准安全口播`)
- expect(video.tags).toEqual(['点检', '安全'])
- addPass(activities, '成片管理', '编辑标题、分类、标签与文件夹', updateVideoResponse, videoRefs.videoId)
-
- const sharePassword = `Vs!${randomBytes(14).toString('hex')}A1`
- const shareResponse = await request.post(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}/share`, {
- headers,
- data: {
- expiresInSeconds: 3600,
- password: sharePassword,
- maxAccessCount: 1,
- dataVersion: Number(video.dataVersion),
- },
- })
- const limitedShare = await responseData(shareResponse, 201, '创建单次密码分享')
- const limitedToken = String(limitedShare.shareToken ?? '')
- expect(limitedToken).toContain('.')
- expect(String(limitedShare.tokenPreview ?? '')).not.toContain(limitedToken)
- addPass(activities, '成片分享', '签发密码保护且限访问一次的分享', shareResponse, videoRefs.videoId, '密码与完整令牌仅保存在内存')
-
- const rawFileDenied = await request.get(`/open/v1/videos/${encodeURIComponent(limitedToken)}/file`, {
- headers: { 'X-Share-Password': sharePassword },
- })
- expect(rawFileDenied.status()).toBe(404)
- addPass(activities, '成片分享', '未取得媒体 grant 时拒绝直接访问文件', rawFileDenied, videoRefs.videoId)
-
- const wrongPasswordResponse = await request.get(`/open/v1/videos/${encodeURIComponent(limitedToken)}`, {
- headers: { 'X-Share-Password': `${sharePassword}-wrong` },
- })
- expect(wrongPasswordResponse.status()).toBe(404)
- addPass(activities, '成片分享', '错误分享密码以不存在响应拒绝,避免枚举链接', wrongPasswordResponse, videoRefs.videoId)
-
- const limitedHeaders = { 'X-Share-Password': sharePassword }
- const limitedDetailResponse = await request.get(`/open/v1/videos/${encodeURIComponent(limitedToken)}`, { headers: limitedHeaders })
- const limitedDetail = await responseData(limitedDetailResponse, 200, '单次访问分享详情')
- expect(limitedDetail.title).toBe(`${runPrefix}-设备点检标准安全口播`)
- expect(limitedDetail.ownerId).toBeUndefined()
- const grantedFileUrl = String(limitedDetail.fileUrl ?? '')
- expect(grantedFileUrl).toContain('grantExpires=')
- expect(grantedFileUrl).toContain('grantSignature=')
- addPass(activities, '成片分享', '密码通过后签发短时媒体 grant', limitedDetailResponse, videoRefs.videoId)
-
- const grantedHeadResponse = await request.head(grantedFileUrl, { headers: limitedHeaders })
- expect(grantedHeadResponse.status()).toBe(200)
- addPass(activities, '成片分享', '媒体 grant 支持 HEAD', grantedHeadResponse, videoRefs.videoId)
-
- const grantedRangeResponse = await request.get(grantedFileUrl, {
- headers: { ...limitedHeaders, Range: 'bytes=0-1023' },
- })
- expect(grantedRangeResponse.status()).toBe(206)
- expect((await grantedRangeResponse.body()).length).toBeGreaterThan(0)
- addPass(activities, '成片分享', '媒体 grant 支持 Range 分段播放', grantedRangeResponse, videoRefs.videoId)
-
- const grantedFileResponse = await request.get(grantedFileUrl, { headers: limitedHeaders })
- expect(grantedFileResponse.status()).toBe(200)
- expect((await grantedFileResponse.body()).length).toBeGreaterThan(10_000)
- addPass(activities, '成片分享', '同一 grant 支持完整 GET 播放', grantedFileResponse, videoRefs.videoId)
-
- const exhaustedResponse = await request.get(`/open/v1/videos/${encodeURIComponent(limitedToken)}`, { headers: limitedHeaders })
- expect(exhaustedResponse.status()).toBe(404)
- addPass(activities, '成片分享', '第二次访问因 maxAccessCount=1 被拒绝', exhaustedResponse, videoRefs.videoId)
-
- const rotatedPassword = `Vr!${randomBytes(14).toString('hex')}A1`
- const rotateShareResponse = await request.post(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}/rotate-share`, {
- headers,
- data: {
- expiresInSeconds: 3600,
- password: rotatedPassword,
- maxAccessCount: 2,
- dataVersion: Number(limitedShare.dataVersion),
- },
- })
- const rotatedShare = await responseData(rotateShareResponse, 201, '轮换成片分享')
- const rotatedToken = String(rotatedShare.shareToken ?? '')
- expect(rotatedToken).not.toBe(limitedToken)
- addPass(activities, '成片分享', '轮换分享令牌', rotateShareResponse, videoRefs.videoId, '新旧完整令牌均未写入报告')
-
- const oldTokenResponse = await request.get(`/open/v1/videos/${encodeURIComponent(limitedToken)}`, { headers: limitedHeaders })
- expect(oldTokenResponse.status()).toBe(404)
- addPass(activities, '成片分享', '轮换后旧令牌立即失效', oldTokenResponse, videoRefs.videoId)
-
- await page.goto(`/share/videos/${encodeURIComponent(rotatedToken)}`)
- await expect(page.getByText('视频需要验证或链接暂不可用')).toBeVisible()
- const rotatedPasswordInput = page.locator('#share-password')
- await rotatedPasswordInput.fill(rotatedPassword)
- await page.getByRole('button', { name: '验证并播放' }).click()
- await expect(page.getByRole('heading', { name: `${runPrefix}-设备点检标准安全口播` })).toBeVisible({ timeout: 30_000 })
- const sharedVideo = page.locator('.shared-video-player video')
- await expect(sharedVideo).toBeVisible()
- await captureScreenshot(page, testInfo, 'protected-shared-video-page', [rotatedPasswordInput])
- addPass(activities, '成片分享', '公开分享页密码验证并展示成片信息', rotateShareResponse, videoRefs.videoId)
-
- const revokeShareResponse = await request.delete(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}/share`, {
- headers,
- data: { reason: 'APE2E 分享闭环完成' },
- })
- const revoked = await responseData(revokeShareResponse, 200, '停止成片分享')
- expect(revoked.revoked).toBe(true)
- addPass(activities, '成片分享', '停止分享', revokeShareResponse, videoRefs.videoId)
-
- const revokedTokenResponse = await request.get(`/open/v1/videos/${encodeURIComponent(rotatedToken)}`, {
- headers: { 'X-Share-Password': rotatedPassword },
- })
- expect(revokedTokenResponse.status()).toBe(404)
- addPass(activities, '成片分享', '停止后公开链接失效', revokedTokenResponse, videoRefs.videoId)
-
- const deleteVideoResponse = await request.delete(`/api/v1/videos/${encodeURIComponent(videoRefs.videoId)}`, {
- headers,
- params: { dataVersion: String(Number(rotatedShare.dataVersion)) },
- })
- await responseData(deleteVideoResponse, 200, '删除成片')
- const deletedVideoId = videoRefs.videoId
- videoRefs.videoId = ''
- addPass(activities, '成片管理', '删除成片', deleteVideoResponse, deletedVideoId)
- expect((await request.get(`/api/v1/videos/${encodeURIComponent(deletedVideoId)}`, { headers })).status()).toBe(404)
-
- const folderListResponse = await request.get('/api/v1/video-folders', { headers })
- const folderList = await responseData(folderListResponse, 200, '刷新文件夹版本')
- const currentFolder = (Array.isArray(folderList.items) ? folderList.items : []).map(asRecord)
- .find((item) => String(item.id ?? '') === videoRefs.folderId)
- expect(currentFolder).toBeTruthy()
- const deleteFolderResponse = await request.delete(`/api/v1/video-folders/${encodeURIComponent(videoRefs.folderId)}`, {
- headers,
- params: { dataVersion: String(Number(currentFolder?.dataVersion)) },
- })
- await responseData(deleteFolderResponse, 200, '删除视频文件夹')
- const deletedFolderId = videoRefs.folderId
- videoRefs.folderId = ''
- addPass(activities, '视频文件夹', '成片删除后逆序删除文件夹', deleteFolderResponse, deletedFolderId)
-
- const currentProjectResponse = await request.get(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}`, { headers })
- const currentProject = await responseData(currentProjectResponse, 200, '刷新工程版本')
- const deleteProjectResponse = await request.delete(`/api/v1/video-projects/${encodeURIComponent(videoRefs.projectId)}`, {
- headers,
- params: { dataVersion: String(Number(currentProject.dataVersion)) },
- })
- await responseData(deleteProjectResponse, 200, '删除视频工程')
- const deletedProjectId = videoRefs.projectId
- videoRefs.projectId = ''
- addPass(activities, '视频工程', '成片与文件夹删除后逆序删除工程', deleteProjectResponse, deletedProjectId)
- expect((await request.get(`/api/v1/video-projects/${encodeURIComponent(deletedProjectId)}`, { headers })).status()).toBe(404)
-
- const createdAgentResponse = await request.post('/api/v1/realtime-agents', {
- headers,
- data: {
- name: `${runPrefix}-公开维修教员`,
- expiresAt: '2035-08-15T12:00:00+08:00',
- },
- })
- let agent = await responseData(createdAgentResponse, 201, '创建智能体草稿')
- agentId = String(agent.id ?? '')
- expect(agentId).not.toBe('')
- expect(String(agent.apiKey ?? agent.issuedApiKey ?? '')).not.toBe('')
- addPass(activities, '智能体', '创建草稿并一次性签发密钥', createdAgentResponse, agentId, '密钥仅在内存校验,未写入报告')
-
- const saveSection = async (section: string, data: JsonRecord, label: string) => {
- const response = await request.put(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/sections/${section}`, {
- headers,
- data: { ...data, dataVersion: Number(agent.dataVersion) },
- })
- agent = await responseData(response, 200, label)
- addPass(activities, '智能体七区配置', label, response, agentId)
- }
-
- await saveSection('avatar', { avatarIds: [avatarId], avatarId }, '形象与声音区保存')
- await saveSection('core', {
- description: `${runPrefix} 装备维修公开问答验证`,
- systemPrompt: '你是装备维修数字教员。回答应准确、简洁,并优先引用知识库。',
- }, '基本信息区保存')
- await saveSection('identity', {
- llmModel: 'llm-local-main',
- knowledgeBaseIds: ['default'],
- knowledgeDocumentIds: [],
- interactionModes: ['text'],
- }, '模型与知识区保存')
- await saveSection('scene', {
- sceneBackgroundType: 'transparent',
- showInteractionButtons: true,
- background: '#0f2d32',
- }, '场景区保存')
- await saveSection('interaction', {
- welcomeMessage: '欢迎进入装备维修实训,请输入你的问题。',
- fallbackMessage: '暂未找到匹配资料,请换一种问法。',
- sensitiveReply: '该问题无法回答。',
- }, '交互区保存')
- await saveSection('ui', {
- uiPosition: 'fullscreen',
- brandVisible: true,
- uiConfig: { schemaVersion: 1, suggestedQuestions: ['如何开展设备点检?'] },
- }, '界面区保存')
- await saveSection('delivery', {
- accessMode: 'internal',
- allowedOrigins: [],
- concurrencyLimit: 3,
- brandVisible: true,
- }, '交付区保存')
-
- const persistedResponse = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers })
- agent = await responseData(persistedResponse, 200, '刷新智能体并核验七区持久化')
- expect(agent.avatarIds).toEqual([avatarId])
- expect(agent.knowledgeBaseIds).toEqual(['default'])
- expect(agent.interactionModes).toEqual(['text'])
- expect(agent.accessMode).toBe('internal')
- expect(agent.concurrencyLimit).toBe(3)
- addPass(activities, '智能体七区配置', '刷新后字段保持一致', persistedResponse, agentId)
-
- // 原型的「发布接入」是用户可编辑的正式配置,不能只在 API 层验证。
- await loginAsAdmin(page)
- await page.goto('/jobs')
- await page.getByRole('tab', { name: '全部任务' }).click()
- const jobSearch = page.getByRole('searchbox', { name: '搜索任务' })
- await jobSearch.fill(captureTitle)
- const jobRow = page.locator('tbody tr').filter({ hasText: captureTitle }).first()
- await expect(jobRow).toContainText('已完成', { timeout: 30_000 })
- await jobRow.getByRole('button', { name: '详情' }).click()
- const jobDialog = page.locator('.el-dialog:visible').filter({ hasText: '任务详情' }).last()
- await expect(jobDialog).toContainText(captureJobId)
- await expect(jobDialog).toContainText('任务已完成')
- await captureScreenshot(page, testInfo, 'capture-job-list-and-detail')
- addPass(activities, '制作队列', '页面筛选、完成状态与详情展示', jobDetailResponse, captureJobId)
-
- await page.goto(`/agents/${encodeURIComponent(agentId)}?tab=delivery`)
- await expect(page.getByText('访问策略', { exact: true })).toBeVisible()
- const accessPolicyCard = page.locator('.detail-card').filter({ hasText: '访问策略' }).last()
- await accessPolicyCard.locator('label').filter({ hasText: '访问方式' }).locator('select').selectOption('password')
- const passwordInput = accessPolicyCard.locator('input[type="password"]')
- await passwordInput.fill(accessPassword)
- const advanced = accessPolicyCard.locator('details.delivery-advanced')
- await advanced.locator('summary').click()
- await advanced.locator('textarea').fill(`${allowedOrigin}\n${allowedOrigin}`)
- await captureScreenshot(page, testInfo, 'agent-delivery-policy-before-save', [
- passwordInput,
- page.locator('.delivery-field pre'),
- ])
- await page.getByRole('button', { name: '保存配置' }).click()
- await expect(page.locator('.el-message').filter({ hasText: /配置已保存|保存成功/ }).last()).toBeVisible()
-
- const uiPersistedResponse = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers })
- agent = await responseData(uiPersistedResponse, 200, '刷新核验发布接入页面提交')
- expect(agent.accessMode).toBe('password')
- expect(agent.allowedOrigins).toEqual([allowedOrigin])
- expect(agent.brandVisible).toBe(true)
- expect(agent.accessPassword).toBeUndefined()
- await expect(passwordInput).toHaveValue('')
- addPass(activities, '智能体七区配置', '发布接入页面保存密码、Origin 去重和平台标识', uiPersistedResponse, agentId)
-
- const publishResponse = await request.post(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/publish`, {
- headers,
- data: { dataVersion: Number(agent.dataVersion) },
- })
- agent = await responseData(publishResponse, 200, '发布智能体')
- expect(agent.status).toBe('published')
- const slug = String(agent.slug ?? '')
- expect(slug).not.toBe('')
- addPass(activities, '智能体', '发布成功', publishResponse, agentId)
-
- const integrationResponse = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/integration`, { headers })
- const integration = await responseData(integrationResponse, 200, '读取接入信息')
- expect(String(integration.sessionEndpoint ?? '')).toContain(slug)
- expect(String(integration.chatEndpoint ?? '')).toContain(slug)
- addPass(activities, '智能体', '接入信息端点可用', integrationResponse, agentId)
-
- const extensionResponse = await request.put(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/expiration`, {
- headers,
- data: { extendDays: 30, dataVersion: Number(agent.dataVersion) },
- })
- agent = await responseData(extensionResponse, 200, '延长有效期')
- addPass(activities, '智能体', '延长有效期', extensionResponse, agentId)
-
- const rotateResponse = await request.post(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/rotate-key`, {
- headers,
- data: { dataVersion: Number(agent.dataVersion) },
- })
- agent = await responseData(rotateResponse, 200, '轮换一次性 API Key')
- expect(String(agent.apiKey ?? agent.issuedApiKey ?? '')).not.toBe('')
- addPass(activities, '智能体', '轮换一次性 API Key', rotateResponse, agentId, '新密钥仅在内存校验,未写入报告')
-
- const publicConfigResponse = await request.get(`/open/v1/realtime/${encodeURIComponent(slug)}`, {
- headers: { Origin: allowedOrigin },
- })
- const publicConfig = await responseData(publicConfigResponse, 200, '读取公开最小配置')
- expect(publicConfig.name).toBe(`${runPrefix}-公开维修教员`)
- expect(publicConfig.systemPrompt).toBeUndefined()
- expect(publicConfig.apiKeyPreview).toBeUndefined()
- addPass(activities, '公开访问', '读取公开最小配置且不泄露内部字段', publicConfigResponse, agentId)
-
- const rejectedSession = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/sessions`, {
- headers: { Origin: allowedOrigin },
- data: { password: `${accessPassword}-wrong` },
- })
- expect(rejectedSession.status()).toBe(401)
- addPass(activities, '公开访问', '错误访问密码被拒绝', rejectedSession, agentId)
-
- const sessionResponse = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/sessions`, {
- headers: { Origin: allowedOrigin },
- data: { password: accessPassword },
- })
- const session = await responseData(sessionResponse, 201, '创建公开会话')
- const sessionToken = String(session.sessionToken ?? '')
- expect(sessionToken).toMatch(/^dhs_/)
- expect(String(session.websocketTicket ?? '')).toMatch(/^dhw_/)
- addPass(activities, '公开访问', '创建短期会话与一次性 WebSocket ticket', sessionResponse, agentId)
-
- const chatPayload = {
- message: '请简要说明设备点检的主要目的。',
- sessionToken,
- clientMessageId: `${runId}-public-chat`.slice(0, 64),
- synthesizeAudio: false,
- }
- const firstChatResponse = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/chat`, {
- headers: { Origin: allowedOrigin },
- data: chatPayload,
- timeout: 120_000,
- })
- const firstChat = await responseData(firstChatResponse, 200, '公开文字问答')
- expect(String(firstChat.reply ?? '').length).toBeGreaterThan(0)
- expect(String(firstChat.requestId ?? '').length).toBeGreaterThan(0)
- addPass(activities, '公开访问', '真实文字问答并落库', firstChatResponse, agentId, `引用 ${Array.isArray(firstChat.citations) ? firstChat.citations.length : 0} 条`)
-
- const repeatedChatResponse = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/chat`, {
- headers: { Origin: allowedOrigin },
- data: chatPayload,
- timeout: 120_000,
- })
- const repeatedChat = await responseData(repeatedChatResponse, 200, '重复 clientMessageId 幂等回放')
- expect(repeatedChat.requestId).toBe(firstChat.requestId)
- addPass(activities, '公开访问', '相同 clientMessageId 不重复生成消息', repeatedChatResponse, agentId)
-
- const feedbackResponse = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/feedback`, {
- headers: { Origin: allowedOrigin },
- data: { sessionToken, satisfaction: 'satisfied' },
- })
- const feedback = await responseData(feedbackResponse, 200, '提交满意度')
- expect(feedback.satisfaction).toBe('satisfied')
- addPass(activities, '公开访问', '满意度反馈持久化', feedbackResponse, agentId)
-
- await page.goto(`/agents/manage?keyword=${encodeURIComponent(runPrefix)}`)
- await expect(page.locator('body')).toContainText(`${runPrefix}-公开维修教员`)
- const persistedAgentImage = page.getByAltText(`${runPrefix}-公开维修教员形象`).first()
- await expect(persistedAgentImage).toBeVisible()
- await expect.poll(
- () => persistedAgentImage.evaluate((image) => (image as HTMLImageElement).naturalWidth),
- { message: '智能体列表中的真实数字形象缩略图必须成功加载' },
- ).toBeGreaterThan(0)
- await captureScreenshot(page, testInfo, 'agent-published-and-persisted')
-
- await page.goto(`/live/${encodeURIComponent(slug)}`)
- await expect(page.locator('body')).toContainText(`${runPrefix}-公开维修教员`)
- const publicPassword = page.locator('input[type="password"]')
- await publicPassword.fill(accessPassword)
- await page.getByRole('textbox', { name: '维修问题' }).fill('请说明日常设备点检应关注哪些内容。')
- const publicSessionResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/open\/v1\/realtime\/[^/]+\/sessions$/.test(new URL(response.url()).pathname))
- const publicChatResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST' && /\/open\/v1\/realtime\/[^/]+\/chat$/.test(new URL(response.url()).pathname))
- await page.getByRole('button', { name: '发送问题' }).click()
- expect((await publicSessionResponsePromise).status(), 'Live 页面应成功创建受 Origin 约束的公开会话').toBe(201)
- expect((await publicChatResponsePromise).status(), 'Live 页面应成功完成真实文字问答').toBe(200)
- await expect(page.getByText('本次回答是否有帮助?')).toBeVisible({ timeout: 120_000 })
- await page.getByRole('button', { name: '满意', exact: true }).click()
- await expect(page.getByText('感谢反馈,已记录本次评价。')).toBeVisible()
- await captureScreenshot(page, testInfo, 'public-live-chat-and-feedback', [publicPassword])
- addPass(activities, '公开访问', 'Live 页面密码验证、真实问答与满意度反馈', feedbackResponse, agentId)
-
- const endResponse = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/end`, {
- headers: { Origin: allowedOrigin },
- data: { sessionToken, result: 'normal' },
- })
- const ended = await responseData(endResponse, 200, '结束公开会话')
- expect(ended.ended).toBe(true)
- addPass(activities, '公开访问', '正常结束会话', endResponse, agentId)
-
- const disableResponse = await request.post(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}/disable`, {
- headers,
- data: { dataVersion: Number(agent.dataVersion) },
- })
- agent = await responseData(disableResponse, 200, '停用智能体')
- expect(agent.status).toBe('disabled')
- addPass(activities, '智能体', '停用并持久化', disableResponse, agentId)
-
- const deniedAfterDisable = await request.post(`/open/v1/realtime/${encodeURIComponent(slug)}/sessions`, {
- headers: { Origin: allowedOrigin },
- data: { password: accessPassword },
- })
- expect([401, 404], `停用后不应再创建公开会话:HTTP ${deniedAfterDisable.status()}`).toContain(deniedAfterDisable.status())
- addPass(activities, '公开访问', '停用后公开会话被拒绝', deniedAfterDisable, agentId)
-
- const deleteAgentResponse = await request.delete(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, {
- headers,
- params: { dataVersion: String(Number(agent.dataVersion)) },
- })
- await responseData(deleteAgentResponse, 200, '删除智能体')
- addPass(activities, '智能体', '删除并从列表消失', deleteAgentResponse, agentId)
- const deletedAgentId = agentId
- agentId = ''
- const absentAgent = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(deletedAgentId)}`, { headers })
- expect(absentAgent.status()).toBe(404)
-
- const avatarDetailResponse = await request.get(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, { headers })
- const avatarDetail = await responseData(avatarDetailResponse, 200, '删除前刷新数字形象版本')
- const deleteAvatarResponse = await request.delete(`/api/v1/avatars/${encodeURIComponent(avatarId)}`, {
- headers,
- params: { dataVersion: String(Number(avatarDetail.dataVersion)) },
- timeout: 120_000,
- })
- await responseData(deleteAvatarResponse, 200, '删除数字形象')
- addPass(activities, '数字形象', '删除并确认无残留', deleteAvatarResponse, avatarId)
- const deletedAvatarId = avatarId
- avatarId = ''
- const absentAvatar = await request.get(`/api/v1/avatars/${encodeURIComponent(deletedAvatarId)}`, { headers })
- expect(absentAvatar.status()).toBe(404)
- } catch (error) {
- primaryError = error
- activities.push({
- module: '智能体公开交付',
- action: '主流程',
- result: 'FAIL',
- detail: error instanceof Error ? error.message : String(error),
- })
- } finally {
- if (generatedWebmPath) fs.rmSync(generatedWebmPath, { force: true })
- await cleanupVideoResources(request, headers, videoRefs, activities, cleanupErrors)
- if (agentId) await cleanupAgent(request, headers, agentId, activities, cleanupErrors)
- if (avatarId) await cleanupAvatar(request, headers, avatarId, activities, cleanupErrors)
- await attachReport(testInfo, activities, cleanupErrors)
- }
-
- if (primaryError) throw primaryError
- expect(cleanupErrors, '智能体/数字形象清理必须全部成功').toEqual([])
- })
|