|
|
|
@@ -0,0 +1,418 @@ |
|
|
|
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() |
|
|
|
}) |