You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

419 line
21 KiB

  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import { fileURLToPath } from 'node:url'
  4. import type { APIRequestContext, Page, Response, TestInfo } from '@playwright/test'
  5. import {
  6. closeContentApiSession,
  7. type ContentApiSession,
  8. type ContentDetailRecord,
  9. openContentApiSession,
  10. readContentDetail,
  11. } from './content-editor-api'
  12. import { captureScreenshot, expect, test } from './fixtures'
  13. import { loginAsAdmin } from './helpers'
  14. import { openContentControlPlane } from './content-control-plane'
  15. const glbPath = fileURLToPath(new URL('../../unreal_tran/tests/fixtures/LittlestTokyo.draco.glb', import.meta.url))
  16. const glbName = path.basename(glbPath)
  17. const runToken = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`.toUpperCase()
  18. const modelName = `UTE2E iframe 接口模型 ${runToken}`
  19. const savedModelName = `${modelName} 已保存`
  20. const sceneName = `UTE2E DEMO 接口场景 ${runToken}`
  21. interface ApiEnvelope<T> {
  22. data?: T
  23. message?: string
  24. requestId?: string
  25. }
  26. interface CreatedProject {
  27. id: string
  28. code: string
  29. }
  30. const responsePath = (response: Response) => new URL(response.url()).pathname
  31. const projectMutation = (response: Response, method: 'PUT' | 'POST', projectId: string, suffix = '') => (
  32. response.request().method() === method
  33. && responsePath(response) === `/api/tran/v1/content/projects/${projectId}${suffix}`
  34. )
  35. async function expectSuccessfulResponse(responsePromise: Promise<Response>, operation: string): Promise<Response> {
  36. const response = await responsePromise
  37. expect(response.ok(), `${operation}应成功,实际 HTTP ${response.status()}`).toBeTruthy()
  38. return response
  39. }
  40. async function readEnvelope<T>(response: Awaited<ReturnType<APIRequestContext['post']>>, operation: string): Promise<T> {
  41. const body = await response.json() as ApiEnvelope<T>
  42. if (!response.ok() || body.data === undefined) {
  43. throw new Error(`${operation}失败(HTTP ${response.status()}):${body.message || '响应没有 data'}${body.requestId ? `,requestId=${body.requestId}` : ''}`)
  44. }
  45. return body.data
  46. }
  47. async function createProject(
  48. request: APIRequestContext,
  49. session: ContentApiSession,
  50. type: 'MODEL' | 'SCENE',
  51. name: string,
  52. ): Promise<CreatedProject> {
  53. const content = type === 'MODEL'
  54. ? { resourceUri: '', nodeCount: 0, materialCount: 0, animationCount: 0 }
  55. : { sceneType: '', environment: '', objectCount: 0, objects: [] }
  56. const response = await request.post('/api/tran/v1/content/projects', {
  57. headers: session.headers,
  58. data: {
  59. type,
  60. name,
  61. description: 'Playwright 验证 DEMO 体验下的服务端工程、资产、版本与发布闭环',
  62. categoryCode: type === 'MODEL' ? 'EQUIPMENT' : 'WORKSHOP',
  63. coverUri: '',
  64. content,
  65. dependencies: [],
  66. assets: [],
  67. version: 0,
  68. },
  69. })
  70. const detail = await readEnvelope<ContentDetailRecord>(response, `创建${type}工程`)
  71. return { id: String(detail.project.id), code: String(detail.project.code) }
  72. }
  73. async function cleanupProject(
  74. request: APIRequestContext,
  75. session: ContentApiSession,
  76. projectId: string | null,
  77. ): Promise<string> {
  78. if (!projectId) return 'not-created'
  79. let response = await request.get(`/api/tran/v1/content/projects/${projectId}`, { headers: session.headers })
  80. for (let attempt = 1; response.status() === 503 && attempt < 3; attempt += 1) {
  81. await new Promise((resolve) => setTimeout(resolve, attempt * 1_000))
  82. response = await request.get(`/api/tran/v1/content/projects/${projectId}`, { headers: session.headers })
  83. }
  84. if (response.status() === 404) return 'already-removed'
  85. let detail = await readEnvelope<ContentDetailRecord>(response, `清理前读取工程 ${projectId}`)
  86. if (detail.project.status === 'PUBLISHED') {
  87. response = await request.post(`/api/tran/v1/content/projects/${projectId}/review`, {
  88. headers: session.headers,
  89. data: { action: 'REVOKE', version: detail.project.version, comment: 'UTE2E 完成后撤回临时发布数据' },
  90. })
  91. detail = await readEnvelope<ContentDetailRecord>(response, `撤回工程 ${projectId}`)
  92. }
  93. if (detail.project.publishedVersionId) {
  94. throw new Error(`工程 ${projectId} 清理前仍残留 publishedVersionId=${detail.project.publishedVersionId}`)
  95. }
  96. response = await request.delete(`/api/tran/v1/content/projects/${projectId}`, {
  97. headers: session.headers,
  98. params: { version: String(detail.project.version) },
  99. })
  100. if (!response.ok() && response.status() !== 404) {
  101. const body = await response.text()
  102. throw new Error(`删除工程 ${projectId} 失败(HTTP ${response.status()}):${body.slice(0, 300)}`)
  103. }
  104. return response.status() === 404 ? 'already-removed' : 'removed'
  105. }
  106. const findFixedModelReference = (
  107. value: unknown,
  108. projectId: string,
  109. versionId: string,
  110. ): Record<string, unknown> | null => {
  111. if (Array.isArray(value)) {
  112. for (const item of value) {
  113. const found = findFixedModelReference(item, projectId, versionId)
  114. if (found) return found
  115. }
  116. return null
  117. }
  118. if (!value || typeof value !== 'object') return null
  119. const record = value as Record<string, unknown>
  120. if (String(record.targetProjectId || '') === projectId && String(record.targetVersionId || '') === versionId) return record
  121. for (const item of Object.values(record)) {
  122. const found = findFixedModelReference(item, projectId, versionId)
  123. if (found) return found
  124. }
  125. return null
  126. }
  127. async function waitForModelReady(page: Page, projectId: string) {
  128. const iframe = page.locator(`iframe[data-model-project-id="${projectId}"]`)
  129. await expect(iframe).toBeVisible({ timeout: 30_000 })
  130. const src = await iframe.getAttribute('src')
  131. expect(src).toContain('api=1')
  132. expect(src).toContain(`projectId=${projectId}`)
  133. expect(src).not.toMatch(/token|authorization|bearer/i)
  134. await expect.poll(async () => {
  135. const iframeHandle = await iframe.elementHandle()
  136. const contentFrame = await iframeHandle?.contentFrame()
  137. if (!contentFrame?.url()) return ''
  138. return new URL(contentFrame.url()).pathname
  139. }, {
  140. message: '模型 iframe 必须停留在 editor.html,不得被入口守卫重定向',
  141. timeout: 30_000,
  142. }).toBe('/legacy-model-editor/editor.html')
  143. const editor = page.frameLocator(`iframe[data-model-project-id="${projectId}"]`)
  144. await expect(editor.locator('.editor-shell[data-model-api-mode="api"]')).toHaveAttribute('data-model-api-state', 'ready', { timeout: 180_000 })
  145. await expect(editor.locator('#loading-overlay')).toHaveClass(/hidden/, { timeout: 180_000 })
  146. await expect(editor.locator('#render-host canvas')).toBeVisible()
  147. return editor
  148. }
  149. async function waitForSceneReady(page: Page) {
  150. const shell = page.locator('.se-shell[data-api-controlled="true"]')
  151. await expect(shell).toBeVisible({ timeout: 180_000 })
  152. await expect(shell).toHaveAttribute('data-ready', 'true', { timeout: 180_000 })
  153. await expect(shell.locator('#se-viewport canvas')).toBeVisible({ timeout: 180_000 })
  154. await expect(shell).not.toHaveClass(/model-loading/, { timeout: 180_000 })
  155. return shell
  156. }
  157. test('模型 iframe 与场景 DEMO 通过原生 Fetch 完成资产、固定依赖、刷新恢复和发布闭环', async ({ page }, testInfo: TestInfo) => {
  158. test.setTimeout(10 * 60_000)
  159. expect(fs.existsSync(glbPath), `真实 GLB fixture 不存在:${glbPath}`).toBeTruthy()
  160. // Seed/assert/cleanup through the gateway directly. The behavior under test
  161. // remains the browser's same-origin native Fetch path through :6180; keeping
  162. // the control plane off Vite's dev proxy avoids unrelated APIRequestContext
  163. // keep-alive/encoding differences on Windows.
  164. const apiRequest = await openContentControlPlane()
  165. let session: ContentApiSession | null = null
  166. let modelProject: CreatedProject | null = null
  167. let sceneProject: CreatedProject | null = null
  168. let testFailure: unknown = null
  169. const cleanup: string[] = []
  170. try {
  171. session = await openContentApiSession(apiRequest)
  172. modelProject = await createProject(apiRequest, session, 'MODEL', modelName)
  173. await loginAsAdmin(page)
  174. const migrationResponse = page.waitForResponse(
  175. (response) => projectMutation(response, 'PUT', modelProject!.id),
  176. { timeout: 180_000 },
  177. )
  178. await page.goto(`/content/models/${modelProject.id}/edit`)
  179. let modelEditor = await waitForModelReady(page, modelProject.id)
  180. const modelMigration = await expectSuccessfulResponse(migrationResponse, '浅模型首次迁移保存')
  181. expect(modelMigration.request().resourceType()).toBe('fetch')
  182. expect(modelMigration.frame().url()).toContain('/legacy-model-editor/editor.html')
  183. let modelDetail = await readContentDetail(apiRequest, session, modelProject.id)
  184. expect(modelDetail.currentVersion.content.schema).toBe('digital-twin-editor-project')
  185. expect(modelDetail.currentVersion.content.version).toBe(4)
  186. expect(modelDetail.project.name).toBe(modelName)
  187. expect(modelDetail.currentVersion.content.name).toBe(modelName)
  188. expect(modelDetail.currentVersion.assets.some((asset) => asset.type === 'MODEL_FILE')).toBeTruthy()
  189. const renameResponse = page.waitForResponse(
  190. (response) => projectMutation(response, 'PUT', modelProject!.id),
  191. { timeout: 120_000 },
  192. )
  193. await modelEditor.locator('#project-name').fill(savedModelName)
  194. await modelEditor.locator('#project-name').press('Enter')
  195. await expectSuccessfulResponse(renameResponse, '模型改名保存')
  196. await page.reload({ waitUntil: 'domcontentloaded' })
  197. modelEditor = await waitForModelReady(page, modelProject.id)
  198. await expect(modelEditor.locator('#project-name')).toHaveValue(savedModelName)
  199. const uploadResponse = page.waitForResponse((response) => (
  200. response.request().method() === 'POST'
  201. && responsePath(response) === `/api/tran/v1/content/projects/${modelProject!.id}/assets/upload`
  202. ), { timeout: 300_000 })
  203. const uploadAssetRegistrationResponse = page.waitForResponse((response) => {
  204. if (!projectMutation(response, 'PUT', modelProject!.id)) return false
  205. try {
  206. const payload = response.request().postDataJSON() as {
  207. content?: { modelResource?: { sourceName?: string } }
  208. assets?: Array<{ code?: string; uploadTicket?: string }>
  209. }
  210. return payload.content?.modelResource?.sourceName !== glbName
  211. && Boolean(payload.assets?.some((asset) => asset.uploadTicket))
  212. } catch {
  213. return false
  214. }
  215. }, { timeout: 300_000 })
  216. const uploadDocumentSaveResponse = page.waitForResponse((response) => {
  217. if (!projectMutation(response, 'PUT', modelProject!.id)) return false
  218. try {
  219. const payload = response.request().postDataJSON() as {
  220. content?: { modelResource?: { sourceName?: string; assetCode?: string; code?: string } }
  221. }
  222. return payload.content?.modelResource?.sourceName === glbName
  223. && Boolean(payload.content.modelResource.assetCode || payload.content.modelResource.code)
  224. } catch {
  225. return false
  226. }
  227. }, { timeout: 300_000 })
  228. await modelEditor.locator('#model-file-input').setInputFiles(glbPath)
  229. const uploadedResponse = await expectSuccessfulResponse(uploadResponse, '模型 GLB 上传')
  230. const uploadedEnvelope = await uploadedResponse.json() as ApiEnvelope<{
  231. code?: string
  232. storageUri?: string
  233. uploadTicket?: string
  234. }>
  235. const uploadedCode = String(uploadedEnvelope.data?.code || '')
  236. const uploadedStorageUri = String(uploadedEnvelope.data?.storageUri || '')
  237. expect(uploadedCode, '上传响应必须返回稳定资产编码').toBeTruthy()
  238. expect(uploadedStorageUri, '上传响应必须返回稳定存储地址').toBeTruthy()
  239. expect(uploadedEnvelope.data?.uploadTicket, '上传响应必须返回一次性绑定票据').toMatch(/^v1\./)
  240. const uploadAssetRegistration = await expectSuccessfulResponse(
  241. uploadAssetRegistrationResponse,
  242. '上传票据即时写入工程资产',
  243. )
  244. const uploadDocumentSave = await expectSuccessfulResponse(uploadDocumentSaveResponse, '上传模型绑定到工程文档')
  245. expect(
  246. uploadAssetRegistration.request(),
  247. '票据即时登记与模型文档绑定必须是两次独立 PUT',
  248. ).not.toBe(uploadDocumentSave.request())
  249. const uploadAssetRegistrationPayload = uploadAssetRegistration.request().postDataJSON() as {
  250. content?: { modelResource?: { sourceName?: string } }
  251. assets?: Array<{ code?: string; storageUri?: string; uploadTicket?: string }>
  252. }
  253. const registeredUpload = uploadAssetRegistrationPayload.assets?.find((asset) => asset.code === uploadedCode)
  254. expect(registeredUpload?.uploadTicket, '第一次 PUT 必须携带上传票据').toBe(uploadedEnvelope.data?.uploadTicket)
  255. expect(registeredUpload?.storageUri, '第一次 PUT 必须登记上传响应中的存储地址').toBe(uploadedStorageUri)
  256. expect(
  257. uploadAssetRegistrationPayload.content?.modelResource?.sourceName,
  258. '第一次 PUT 不得提前把尚未解析完成的 GLB 绑定到文档',
  259. ).not.toBe(glbName)
  260. const uploadDocumentPayload = uploadDocumentSave.request().postDataJSON() as {
  261. content?: { modelResource?: { assetCode?: string; code?: string; storageUri?: string } }
  262. }
  263. expect(
  264. String(uploadDocumentPayload.content?.modelResource?.assetCode || uploadDocumentPayload.content?.modelResource?.code || ''),
  265. ).toBe(uploadedCode)
  266. expect(uploadDocumentPayload.content?.modelResource?.storageUri).toBe(uploadedStorageUri)
  267. await expect(modelEditor.locator('.editor-shell')).toHaveAttribute('data-model-api-state', 'ready', { timeout: 180_000 })
  268. await expect(modelEditor.locator('.editor-shell')).toHaveAttribute('data-model-last-operation', 'import-model-commit')
  269. await expect(modelEditor.locator('.editor-shell')).toHaveAttribute('data-model-import-busy', 'false')
  270. modelDetail = await readContentDetail(apiRequest, session, modelProject.id)
  271. const uploadedModelAsset = modelDetail.currentVersion.assets.find((asset) => asset.name === glbName)
  272. expect(uploadedModelAsset, '上传资产应进入模型工程当前版本').toBeTruthy()
  273. expect(uploadedModelAsset!.storageUri).toMatch(/^(?:content:\/\/sha256\/|fsvc:\/\/)/)
  274. expect(uploadedModelAsset!.sha256).toMatch(/^[0-9a-f]{64}$/)
  275. expect(JSON.stringify(modelDetail.currentVersion.content)).not.toContain('blob:')
  276. await modelEditor.locator('[data-action="publish"]').click()
  277. await expect(modelEditor.locator('#publish-modal.open')).toBeVisible()
  278. const modelPublishSave = page.waitForResponse(
  279. (response) => projectMutation(response, 'PUT', modelProject!.id),
  280. { timeout: 180_000 },
  281. )
  282. const modelPublish = page.waitForResponse(
  283. (response) => projectMutation(response, 'POST', modelProject!.id, '/publish'),
  284. { timeout: 180_000 },
  285. )
  286. await modelEditor.locator('[data-action="publish-model-asset"]').click()
  287. await expectSuccessfulResponse(modelPublishSave, '模型发布前强制保存')
  288. await expectSuccessfulResponse(modelPublish, '模型发布')
  289. await expect(modelEditor.locator('#publish-ready-copy')).toContainText('接口发布成功', { timeout: 60_000 })
  290. modelDetail = await readContentDetail(apiRequest, session, modelProject.id)
  291. expect(modelDetail.project.status).toBe('PUBLISHED')
  292. expect(modelDetail.project.publishedVersionId).toBe(modelDetail.currentVersion.id)
  293. const publishedModelVersionId = String(modelDetail.project.publishedVersionId)
  294. const publishedAssetCode = uploadedModelAsset!.code
  295. await captureScreenshot(page, testInfo, 'model-iframe-api-published')
  296. sceneProject = await createProject(apiRequest, session, 'SCENE', sceneName)
  297. await page.goto(`/content/scenes/${sceneProject.id}/edit`)
  298. let sceneEditor = await waitForSceneReady(page)
  299. await sceneEditor.locator('[data-left-tab="assets"]').click()
  300. const publishedCard = sceneEditor.locator(
  301. `[data-target-project-id="${modelProject.id}"][data-target-version-id="${publishedModelVersionId}"]`,
  302. )
  303. await expect(publishedCard).toBeVisible({ timeout: 120_000 })
  304. await expect(publishedCard).toHaveAttribute('data-asset-code', publishedAssetCode)
  305. await publishedCard.dblclick()
  306. await expect(sceneEditor).not.toHaveClass(/model-loading/, { timeout: 180_000 })
  307. await expect.poll(async () => page.evaluate(({ projectId, versionId }) => {
  308. const diagnostics = (globalThis as typeof globalThis & {
  309. __sceneEditorDiagnostics?: () => { importedObjects?: Array<{ targetProjectId?: string; targetVersionId?: string }> } | null
  310. }).__sceneEditorDiagnostics?.()
  311. return diagnostics?.importedObjects?.some((item) => item.targetProjectId === projectId && item.targetVersionId === versionId) || false
  312. }, { projectId: modelProject!.id, versionId: publishedModelVersionId }), { timeout: 180_000 }).toBeTruthy()
  313. const sceneSave = page.waitForResponse(
  314. (response) => projectMutation(response, 'PUT', sceneProject!.id),
  315. { timeout: 180_000 },
  316. )
  317. await sceneEditor.locator('[data-action="save"]').click()
  318. const savedSceneResponse = await expectSuccessfulResponse(sceneSave, '场景保存')
  319. expect(savedSceneResponse.request().resourceType()).toBe('fetch')
  320. expect(savedSceneResponse.frame()).toBe(page.mainFrame())
  321. let sceneDetail = await readContentDetail(apiRequest, session, sceneProject.id)
  322. expect(sceneDetail.currentVersion.content.schema).toBe('edit3dv4.scene')
  323. const fixedReference = findFixedModelReference(sceneDetail.currentVersion.content, modelProject.id, publishedModelVersionId)
  324. expect(fixedReference, '场景 JSON 应保存精确模型项目/版本引用').toBeTruthy()
  325. expect(fixedReference!.assetCode).toBe(publishedAssetCode)
  326. expect(String(fixedReference!.resourceUrl || '')).not.toContain('blob:')
  327. const modelDependencies = sceneDetail.currentVersion.dependencies.filter((item) => item.relationType === 'SCENE_MODEL')
  328. expect(modelDependencies).toHaveLength(1)
  329. expect(modelDependencies[0].targetProjectId).toBe(modelProject.id)
  330. expect(modelDependencies[0].targetVersionId).toBe(publishedModelVersionId)
  331. expect(modelDependencies[0].required).toBeTruthy()
  332. await page.reload({ waitUntil: 'domcontentloaded' })
  333. sceneEditor = await waitForSceneReady(page)
  334. await expect.poll(async () => page.evaluate((projectId) => {
  335. const diagnostics = (globalThis as typeof globalThis & {
  336. __sceneEditorDiagnostics?: () => { importedObjects?: Array<{ targetProjectId?: string }> } | null
  337. }).__sceneEditorDiagnostics?.()
  338. return diagnostics?.importedObjects?.some((item) => item.targetProjectId === projectId) || false
  339. }, modelProject!.id), { timeout: 180_000 }).toBeTruthy()
  340. const scenePublishSave = page.waitForResponse(
  341. (response) => projectMutation(response, 'PUT', sceneProject!.id),
  342. { timeout: 180_000 },
  343. )
  344. const scenePublish = page.waitForResponse(
  345. (response) => projectMutation(response, 'POST', sceneProject!.id, '/publish'),
  346. { timeout: 180_000 },
  347. )
  348. await sceneEditor.locator('[data-action="publish"]').click()
  349. await expectSuccessfulResponse(scenePublishSave, '场景发布前保存')
  350. await expectSuccessfulResponse(scenePublish, '场景发布')
  351. await expect(sceneEditor.locator('.se-toast.success').filter({ hasText: '场景已发布' }).last()).toBeVisible({ timeout: 60_000 })
  352. sceneDetail = await readContentDetail(apiRequest, session, sceneProject.id)
  353. expect(sceneDetail.project.status).toBe('PUBLISHED')
  354. expect(sceneDetail.project.publishedVersionId).toBe(sceneDetail.currentVersion.id)
  355. await captureScreenshot(page, testInfo, 'scene-demo-api-published')
  356. } catch (error) {
  357. testFailure = error
  358. } finally {
  359. await page.goto('/').catch(() => undefined)
  360. if (session) {
  361. // The end-to-end path may outlive a short access token (especially when a
  362. // deliberate timeout is being diagnosed). Use a fresh session for cleanup
  363. // so temporary projects are still removed after the functional failure.
  364. let cleanupSession = session
  365. try {
  366. cleanupSession = await openContentApiSession(apiRequest)
  367. } catch {
  368. // Fall back to the original session; cleanupProject still retries a
  369. // transient authorization-service 503 before reporting the real failure.
  370. }
  371. // 先删场景解除 SCENE_MODEL 入向引用,再删模型。
  372. for (const [label, id] of [['scene', sceneProject?.id || null], ['model', modelProject?.id || null]] as const) {
  373. try {
  374. cleanup.push(`${label}:${await cleanupProject(apiRequest, cleanupSession, id)}`)
  375. } catch (error) {
  376. cleanup.push(`${label}:failed:${error instanceof Error ? error.message : String(error)}`)
  377. }
  378. }
  379. if (cleanupSession !== session) await closeContentApiSession(apiRequest, cleanupSession)
  380. await closeContentApiSession(apiRequest, session)
  381. }
  382. await apiRequest.dispose()
  383. await testInfo.attach('cleanup-result', { body: Buffer.from(cleanup.join('\n')), contentType: 'text/plain' })
  384. }
  385. if (testFailure) throw testFailure
  386. expect(cleanup.every((item) => !item.includes(':failed:')), cleanup.join('\n')).toBeTruthy()
  387. })