|
- import fs from 'node:fs'
- import path from 'node:path'
- import { fileURLToPath } from 'node:url'
-
- import type { APIRequestContext, Page, Response, TestInfo } from '@playwright/test'
-
- import {
- closeContentApiSession,
- type ContentApiSession,
- type ContentDetailRecord,
- openContentApiSession,
- readContentDetail,
- } from './content-editor-api'
- import { captureScreenshot, expect, test } from './fixtures'
- import { loginAsAdmin } from './helpers'
- import { openContentControlPlane } from './content-control-plane'
-
- const glbPath = fileURLToPath(new URL('../../unreal_tran/tests/fixtures/LittlestTokyo.draco.glb', import.meta.url))
- const glbName = path.basename(glbPath)
- const runToken = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`.toUpperCase()
- const modelName = `UTE2E iframe 接口模型 ${runToken}`
- const savedModelName = `${modelName} 已保存`
- const sceneName = `UTE2E DEMO 接口场景 ${runToken}`
-
- interface ApiEnvelope<T> {
- data?: T
- message?: string
- requestId?: string
- }
-
- interface CreatedProject {
- id: string
- code: string
- }
-
- const responsePath = (response: Response) => new URL(response.url()).pathname
-
- const projectMutation = (response: Response, method: 'PUT' | 'POST', projectId: string, suffix = '') => (
- response.request().method() === method
- && responsePath(response) === `/api/tran/v1/content/projects/${projectId}${suffix}`
- )
-
- async function expectSuccessfulResponse(responsePromise: Promise<Response>, operation: string): Promise<Response> {
- const response = await responsePromise
- expect(response.ok(), `${operation}应成功,实际 HTTP ${response.status()}`).toBeTruthy()
- return response
- }
-
- async function readEnvelope<T>(response: Awaited<ReturnType<APIRequestContext['post']>>, operation: string): Promise<T> {
- const body = await response.json() as ApiEnvelope<T>
- if (!response.ok() || body.data === undefined) {
- throw new Error(`${operation}失败(HTTP ${response.status()}):${body.message || '响应没有 data'}${body.requestId ? `,requestId=${body.requestId}` : ''}`)
- }
- return body.data
- }
-
- async function createProject(
- request: APIRequestContext,
- session: ContentApiSession,
- type: 'MODEL' | 'SCENE',
- name: string,
- ): Promise<CreatedProject> {
- const content = type === 'MODEL'
- ? { resourceUri: '', nodeCount: 0, materialCount: 0, animationCount: 0 }
- : { sceneType: '', environment: '', objectCount: 0, objects: [] }
- const response = await request.post('/api/tran/v1/content/projects', {
- headers: session.headers,
- data: {
- type,
- name,
- description: 'Playwright 验证 DEMO 体验下的服务端工程、资产、版本与发布闭环',
- categoryCode: type === 'MODEL' ? 'EQUIPMENT' : 'WORKSHOP',
- coverUri: '',
- content,
- dependencies: [],
- assets: [],
- version: 0,
- },
- })
- const detail = await readEnvelope<ContentDetailRecord>(response, `创建${type}工程`)
- return { id: String(detail.project.id), code: String(detail.project.code) }
- }
-
- async function cleanupProject(
- request: APIRequestContext,
- session: ContentApiSession,
- projectId: string | null,
- ): Promise<string> {
- if (!projectId) return 'not-created'
- let response = await request.get(`/api/tran/v1/content/projects/${projectId}`, { headers: session.headers })
- for (let attempt = 1; response.status() === 503 && attempt < 3; attempt += 1) {
- await new Promise((resolve) => setTimeout(resolve, attempt * 1_000))
- response = await request.get(`/api/tran/v1/content/projects/${projectId}`, { headers: session.headers })
- }
- if (response.status() === 404) return 'already-removed'
- let detail = await readEnvelope<ContentDetailRecord>(response, `清理前读取工程 ${projectId}`)
- if (detail.project.status === 'PUBLISHED') {
- response = await request.post(`/api/tran/v1/content/projects/${projectId}/review`, {
- headers: session.headers,
- data: { action: 'REVOKE', version: detail.project.version, comment: 'UTE2E 完成后撤回临时发布数据' },
- })
- detail = await readEnvelope<ContentDetailRecord>(response, `撤回工程 ${projectId}`)
- }
- if (detail.project.publishedVersionId) {
- throw new Error(`工程 ${projectId} 清理前仍残留 publishedVersionId=${detail.project.publishedVersionId}`)
- }
- response = await request.delete(`/api/tran/v1/content/projects/${projectId}`, {
- headers: session.headers,
- params: { version: String(detail.project.version) },
- })
- if (!response.ok() && response.status() !== 404) {
- const body = await response.text()
- throw new Error(`删除工程 ${projectId} 失败(HTTP ${response.status()}):${body.slice(0, 300)}`)
- }
- return response.status() === 404 ? 'already-removed' : 'removed'
- }
-
- const findFixedModelReference = (
- value: unknown,
- projectId: string,
- versionId: string,
- ): Record<string, unknown> | null => {
- if (Array.isArray(value)) {
- for (const item of value) {
- const found = findFixedModelReference(item, projectId, versionId)
- if (found) return found
- }
- return null
- }
- if (!value || typeof value !== 'object') return null
- const record = value as Record<string, unknown>
- if (String(record.targetProjectId || '') === projectId && String(record.targetVersionId || '') === versionId) return record
- for (const item of Object.values(record)) {
- const found = findFixedModelReference(item, projectId, versionId)
- if (found) return found
- }
- return null
- }
-
- async function waitForModelReady(page: Page, projectId: string) {
- const iframe = page.locator(`iframe[data-model-project-id="${projectId}"]`)
- await expect(iframe).toBeVisible({ timeout: 30_000 })
- const src = await iframe.getAttribute('src')
- expect(src).toContain('api=1')
- expect(src).toContain(`projectId=${projectId}`)
- expect(src).not.toMatch(/token|authorization|bearer/i)
- await expect.poll(async () => {
- const iframeHandle = await iframe.elementHandle()
- const contentFrame = await iframeHandle?.contentFrame()
- if (!contentFrame?.url()) return ''
- return new URL(contentFrame.url()).pathname
- }, {
- message: '模型 iframe 必须停留在 editor.html,不得被入口守卫重定向',
- timeout: 30_000,
- }).toBe('/legacy-model-editor/editor.html')
- const editor = page.frameLocator(`iframe[data-model-project-id="${projectId}"]`)
- await expect(editor.locator('.editor-shell[data-model-api-mode="api"]')).toHaveAttribute('data-model-api-state', 'ready', { timeout: 180_000 })
- await expect(editor.locator('#loading-overlay')).toHaveClass(/hidden/, { timeout: 180_000 })
- await expect(editor.locator('#render-host canvas')).toBeVisible()
- return editor
- }
-
- async function waitForSceneReady(page: Page) {
- const shell = page.locator('.se-shell[data-api-controlled="true"]')
- await expect(shell).toBeVisible({ timeout: 180_000 })
- await expect(shell).toHaveAttribute('data-ready', 'true', { timeout: 180_000 })
- await expect(shell.locator('#se-viewport canvas')).toBeVisible({ timeout: 180_000 })
- await expect(shell).not.toHaveClass(/model-loading/, { timeout: 180_000 })
- return shell
- }
-
- test('模型 iframe 与场景 DEMO 通过原生 Fetch 完成资产、固定依赖、刷新恢复和发布闭环', async ({ page }, testInfo: TestInfo) => {
- test.setTimeout(10 * 60_000)
- expect(fs.existsSync(glbPath), `真实 GLB fixture 不存在:${glbPath}`).toBeTruthy()
-
- // Seed/assert/cleanup through the gateway directly. The behavior under test
- // remains the browser's same-origin native Fetch path through :6180; keeping
- // the control plane off Vite's dev proxy avoids unrelated APIRequestContext
- // keep-alive/encoding differences on Windows.
- const apiRequest = await openContentControlPlane()
-
- let session: ContentApiSession | null = null
- let modelProject: CreatedProject | null = null
- let sceneProject: CreatedProject | null = null
- let testFailure: unknown = null
- const cleanup: string[] = []
-
- try {
- session = await openContentApiSession(apiRequest)
- modelProject = await createProject(apiRequest, session, 'MODEL', modelName)
- await loginAsAdmin(page)
-
- const migrationResponse = page.waitForResponse(
- (response) => projectMutation(response, 'PUT', modelProject!.id),
- { timeout: 180_000 },
- )
- await page.goto(`/content/models/${modelProject.id}/edit`)
- let modelEditor = await waitForModelReady(page, modelProject.id)
- const modelMigration = await expectSuccessfulResponse(migrationResponse, '浅模型首次迁移保存')
- expect(modelMigration.request().resourceType()).toBe('fetch')
- expect(modelMigration.frame().url()).toContain('/legacy-model-editor/editor.html')
-
- let modelDetail = await readContentDetail(apiRequest, session, modelProject.id)
- expect(modelDetail.currentVersion.content.schema).toBe('digital-twin-editor-project')
- expect(modelDetail.currentVersion.content.version).toBe(4)
- expect(modelDetail.project.name).toBe(modelName)
- expect(modelDetail.currentVersion.content.name).toBe(modelName)
- expect(modelDetail.currentVersion.assets.some((asset) => asset.type === 'MODEL_FILE')).toBeTruthy()
-
- const renameResponse = page.waitForResponse(
- (response) => projectMutation(response, 'PUT', modelProject!.id),
- { timeout: 120_000 },
- )
- await modelEditor.locator('#project-name').fill(savedModelName)
- await modelEditor.locator('#project-name').press('Enter')
- await expectSuccessfulResponse(renameResponse, '模型改名保存')
- await page.reload({ waitUntil: 'domcontentloaded' })
- modelEditor = await waitForModelReady(page, modelProject.id)
- await expect(modelEditor.locator('#project-name')).toHaveValue(savedModelName)
-
- const uploadResponse = page.waitForResponse((response) => (
- response.request().method() === 'POST'
- && responsePath(response) === `/api/tran/v1/content/projects/${modelProject!.id}/assets/upload`
- ), { timeout: 300_000 })
- const uploadAssetRegistrationResponse = page.waitForResponse((response) => {
- if (!projectMutation(response, 'PUT', modelProject!.id)) return false
- try {
- const payload = response.request().postDataJSON() as {
- content?: { modelResource?: { sourceName?: string } }
- assets?: Array<{ code?: string; uploadTicket?: string }>
- }
- return payload.content?.modelResource?.sourceName !== glbName
- && Boolean(payload.assets?.some((asset) => asset.uploadTicket))
- } catch {
- return false
- }
- }, { timeout: 300_000 })
- const uploadDocumentSaveResponse = page.waitForResponse((response) => {
- if (!projectMutation(response, 'PUT', modelProject!.id)) return false
- try {
- const payload = response.request().postDataJSON() as {
- content?: { modelResource?: { sourceName?: string; assetCode?: string; code?: string } }
- }
- return payload.content?.modelResource?.sourceName === glbName
- && Boolean(payload.content.modelResource.assetCode || payload.content.modelResource.code)
- } catch {
- return false
- }
- }, { timeout: 300_000 })
- await modelEditor.locator('#model-file-input').setInputFiles(glbPath)
- const uploadedResponse = await expectSuccessfulResponse(uploadResponse, '模型 GLB 上传')
- const uploadedEnvelope = await uploadedResponse.json() as ApiEnvelope<{
- code?: string
- storageUri?: string
- uploadTicket?: string
- }>
- const uploadedCode = String(uploadedEnvelope.data?.code || '')
- const uploadedStorageUri = String(uploadedEnvelope.data?.storageUri || '')
- expect(uploadedCode, '上传响应必须返回稳定资产编码').toBeTruthy()
- expect(uploadedStorageUri, '上传响应必须返回稳定存储地址').toBeTruthy()
- expect(uploadedEnvelope.data?.uploadTicket, '上传响应必须返回一次性绑定票据').toMatch(/^v1\./)
- const uploadAssetRegistration = await expectSuccessfulResponse(
- uploadAssetRegistrationResponse,
- '上传票据即时写入工程资产',
- )
- const uploadDocumentSave = await expectSuccessfulResponse(uploadDocumentSaveResponse, '上传模型绑定到工程文档')
- expect(
- uploadAssetRegistration.request(),
- '票据即时登记与模型文档绑定必须是两次独立 PUT',
- ).not.toBe(uploadDocumentSave.request())
- const uploadAssetRegistrationPayload = uploadAssetRegistration.request().postDataJSON() as {
- content?: { modelResource?: { sourceName?: string } }
- assets?: Array<{ code?: string; storageUri?: string; uploadTicket?: string }>
- }
- const registeredUpload = uploadAssetRegistrationPayload.assets?.find((asset) => asset.code === uploadedCode)
- expect(registeredUpload?.uploadTicket, '第一次 PUT 必须携带上传票据').toBe(uploadedEnvelope.data?.uploadTicket)
- expect(registeredUpload?.storageUri, '第一次 PUT 必须登记上传响应中的存储地址').toBe(uploadedStorageUri)
- expect(
- uploadAssetRegistrationPayload.content?.modelResource?.sourceName,
- '第一次 PUT 不得提前把尚未解析完成的 GLB 绑定到文档',
- ).not.toBe(glbName)
- const uploadDocumentPayload = uploadDocumentSave.request().postDataJSON() as {
- content?: { modelResource?: { assetCode?: string; code?: string; storageUri?: string } }
- }
- expect(
- String(uploadDocumentPayload.content?.modelResource?.assetCode || uploadDocumentPayload.content?.modelResource?.code || ''),
- ).toBe(uploadedCode)
- expect(uploadDocumentPayload.content?.modelResource?.storageUri).toBe(uploadedStorageUri)
- await expect(modelEditor.locator('.editor-shell')).toHaveAttribute('data-model-api-state', 'ready', { timeout: 180_000 })
- await expect(modelEditor.locator('.editor-shell')).toHaveAttribute('data-model-last-operation', 'import-model-commit')
- await expect(modelEditor.locator('.editor-shell')).toHaveAttribute('data-model-import-busy', 'false')
-
- modelDetail = await readContentDetail(apiRequest, session, modelProject.id)
- const uploadedModelAsset = modelDetail.currentVersion.assets.find((asset) => asset.name === glbName)
- expect(uploadedModelAsset, '上传资产应进入模型工程当前版本').toBeTruthy()
- expect(uploadedModelAsset!.storageUri).toMatch(/^(?:content:\/\/sha256\/|fsvc:\/\/)/)
- expect(uploadedModelAsset!.sha256).toMatch(/^[0-9a-f]{64}$/)
- expect(JSON.stringify(modelDetail.currentVersion.content)).not.toContain('blob:')
-
- await modelEditor.locator('[data-action="publish"]').click()
- await expect(modelEditor.locator('#publish-modal.open')).toBeVisible()
- const modelPublishSave = page.waitForResponse(
- (response) => projectMutation(response, 'PUT', modelProject!.id),
- { timeout: 180_000 },
- )
- const modelPublish = page.waitForResponse(
- (response) => projectMutation(response, 'POST', modelProject!.id, '/publish'),
- { timeout: 180_000 },
- )
- await modelEditor.locator('[data-action="publish-model-asset"]').click()
- await expectSuccessfulResponse(modelPublishSave, '模型发布前强制保存')
- await expectSuccessfulResponse(modelPublish, '模型发布')
- await expect(modelEditor.locator('#publish-ready-copy')).toContainText('接口发布成功', { timeout: 60_000 })
-
- modelDetail = await readContentDetail(apiRequest, session, modelProject.id)
- expect(modelDetail.project.status).toBe('PUBLISHED')
- expect(modelDetail.project.publishedVersionId).toBe(modelDetail.currentVersion.id)
- const publishedModelVersionId = String(modelDetail.project.publishedVersionId)
- const publishedAssetCode = uploadedModelAsset!.code
- await captureScreenshot(page, testInfo, 'model-iframe-api-published')
-
- sceneProject = await createProject(apiRequest, session, 'SCENE', sceneName)
- await page.goto(`/content/scenes/${sceneProject.id}/edit`)
- let sceneEditor = await waitForSceneReady(page)
- await sceneEditor.locator('[data-left-tab="assets"]').click()
- const publishedCard = sceneEditor.locator(
- `[data-target-project-id="${modelProject.id}"][data-target-version-id="${publishedModelVersionId}"]`,
- )
- await expect(publishedCard).toBeVisible({ timeout: 120_000 })
- await expect(publishedCard).toHaveAttribute('data-asset-code', publishedAssetCode)
- await publishedCard.dblclick()
- await expect(sceneEditor).not.toHaveClass(/model-loading/, { timeout: 180_000 })
- await expect.poll(async () => page.evaluate(({ projectId, versionId }) => {
- const diagnostics = (globalThis as typeof globalThis & {
- __sceneEditorDiagnostics?: () => { importedObjects?: Array<{ targetProjectId?: string; targetVersionId?: string }> } | null
- }).__sceneEditorDiagnostics?.()
- return diagnostics?.importedObjects?.some((item) => item.targetProjectId === projectId && item.targetVersionId === versionId) || false
- }, { projectId: modelProject!.id, versionId: publishedModelVersionId }), { timeout: 180_000 }).toBeTruthy()
-
- const sceneSave = page.waitForResponse(
- (response) => projectMutation(response, 'PUT', sceneProject!.id),
- { timeout: 180_000 },
- )
- await sceneEditor.locator('[data-action="save"]').click()
- const savedSceneResponse = await expectSuccessfulResponse(sceneSave, '场景保存')
- expect(savedSceneResponse.request().resourceType()).toBe('fetch')
- expect(savedSceneResponse.frame()).toBe(page.mainFrame())
-
- let sceneDetail = await readContentDetail(apiRequest, session, sceneProject.id)
- expect(sceneDetail.currentVersion.content.schema).toBe('edit3dv4.scene')
- const fixedReference = findFixedModelReference(sceneDetail.currentVersion.content, modelProject.id, publishedModelVersionId)
- expect(fixedReference, '场景 JSON 应保存精确模型项目/版本引用').toBeTruthy()
- expect(fixedReference!.assetCode).toBe(publishedAssetCode)
- expect(String(fixedReference!.resourceUrl || '')).not.toContain('blob:')
- const modelDependencies = sceneDetail.currentVersion.dependencies.filter((item) => item.relationType === 'SCENE_MODEL')
- expect(modelDependencies).toHaveLength(1)
- expect(modelDependencies[0].targetProjectId).toBe(modelProject.id)
- expect(modelDependencies[0].targetVersionId).toBe(publishedModelVersionId)
- expect(modelDependencies[0].required).toBeTruthy()
-
- await page.reload({ waitUntil: 'domcontentloaded' })
- sceneEditor = await waitForSceneReady(page)
- await expect.poll(async () => page.evaluate((projectId) => {
- const diagnostics = (globalThis as typeof globalThis & {
- __sceneEditorDiagnostics?: () => { importedObjects?: Array<{ targetProjectId?: string }> } | null
- }).__sceneEditorDiagnostics?.()
- return diagnostics?.importedObjects?.some((item) => item.targetProjectId === projectId) || false
- }, modelProject!.id), { timeout: 180_000 }).toBeTruthy()
-
- const scenePublishSave = page.waitForResponse(
- (response) => projectMutation(response, 'PUT', sceneProject!.id),
- { timeout: 180_000 },
- )
- const scenePublish = page.waitForResponse(
- (response) => projectMutation(response, 'POST', sceneProject!.id, '/publish'),
- { timeout: 180_000 },
- )
- await sceneEditor.locator('[data-action="publish"]').click()
- await expectSuccessfulResponse(scenePublishSave, '场景发布前保存')
- await expectSuccessfulResponse(scenePublish, '场景发布')
- await expect(sceneEditor.locator('.se-toast.success').filter({ hasText: '场景已发布' }).last()).toBeVisible({ timeout: 60_000 })
- sceneDetail = await readContentDetail(apiRequest, session, sceneProject.id)
- expect(sceneDetail.project.status).toBe('PUBLISHED')
- expect(sceneDetail.project.publishedVersionId).toBe(sceneDetail.currentVersion.id)
- await captureScreenshot(page, testInfo, 'scene-demo-api-published')
- } catch (error) {
- testFailure = error
- } finally {
- await page.goto('/').catch(() => undefined)
- if (session) {
- // The end-to-end path may outlive a short access token (especially when a
- // deliberate timeout is being diagnosed). Use a fresh session for cleanup
- // so temporary projects are still removed after the functional failure.
- let cleanupSession = session
- try {
- cleanupSession = await openContentApiSession(apiRequest)
- } catch {
- // Fall back to the original session; cleanupProject still retries a
- // transient authorization-service 503 before reporting the real failure.
- }
- // 先删场景解除 SCENE_MODEL 入向引用,再删模型。
- for (const [label, id] of [['scene', sceneProject?.id || null], ['model', modelProject?.id || null]] as const) {
- try {
- cleanup.push(`${label}:${await cleanupProject(apiRequest, cleanupSession, id)}`)
- } catch (error) {
- cleanup.push(`${label}:failed:${error instanceof Error ? error.message : String(error)}`)
- }
- }
- if (cleanupSession !== session) await closeContentApiSession(apiRequest, cleanupSession)
- await closeContentApiSession(apiRequest, session)
- }
- await apiRequest.dispose()
- await testInfo.attach('cleanup-result', { body: Buffer.from(cleanup.join('\n')), contentType: 'text/plain' })
- }
-
- if (testFailure) throw testFailure
- expect(cleanup.every((item) => !item.includes(':failed:')), cleanup.join('\n')).toBeTruthy()
- })
|