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 { 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, operation: string): Promise { const response = await responsePromise expect(response.ok(), `${operation}应成功,实际 HTTP ${response.status()}`).toBeTruthy() return response } async function readEnvelope(response: Awaited>, operation: string): Promise { const body = await response.json() as ApiEnvelope 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 { 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(response, `创建${type}工程`) return { id: String(detail.project.id), code: String(detail.project.code) } } async function cleanupProject( request: APIRequestContext, session: ContentApiSession, projectId: string | null, ): Promise { 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(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(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 | 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 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() })