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.
 
 
 
 

196 lines
11 KiB

  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import { expect, test, type Frame, type Page } from '@playwright/test'
  4. import {
  5. call, closeSession, evidence, fixture, openEditor, readyFrame, report,
  6. saveEditor, selectMesh, shot, signIn, writeFixture,
  7. } from './model-editor-closure-helpers'
  8. test.setTimeout(180_000)
  9. test.afterEach(async ({ page }) => { await closeSession(page) })
  10. const endpoint = (id: string) => `tran/v1/content/projects/${id}`
  11. async function read(token: string, id: string) {
  12. const response = await call(endpoint(id), token)
  13. expect(response.status, response.message).toBe(200)
  14. return response.data
  15. }
  16. async function copyModel(token: string, label: string) {
  17. const sourceId = String(fixture().originals.fox.id)
  18. const source = await read(token, sourceId)
  19. const name = `闭环验收-${label}-${Date.now().toString(36)}`
  20. const copied = await call(`${endpoint(sourceId)}/copy`, token, { name, version: source.project.version })
  21. expect(copied.status, copied.message).toBe(200)
  22. const id = String(copied.data.project.id)
  23. expect(['156', '157', String(fixture().models.fox.id), String(fixture().models.truck.id)]).not.toContain(id)
  24. const latest = fixture()
  25. latest.extraProjectIds = [...new Set([...(latest.extraProjectIds || []), id])]
  26. latest.recoveryClosureModels = { ...(latest.recoveryClosureModels || {}), [label]: { id, name } }
  27. writeFixture(latest)
  28. return { id, name }
  29. }
  30. async function currentFrame(page: Page) {
  31. await expect.poll(() => page.frames().some(f => f.url().includes('/legacy-model-editor/editor.html'))).toBe(true)
  32. return page.frames().find(f => f.url().includes('/legacy-model-editor/editor.html'))!
  33. }
  34. async function editMeshName(frame: Frame, name: string) {
  35. await selectMesh(frame)
  36. await frame.locator('#object-name-input').fill(name)
  37. await frame.locator('#object-name-input').press('Tab')
  38. expect(await frame.evaluate(() => (window as any).editorApp.selected.name)).toBe(name)
  39. }
  40. function projectCommand(detail: any, description: string) {
  41. const p = detail.project
  42. return {
  43. type: p.type, code: p.code, name: p.name, description, categoryCode: p.categoryCode,
  44. coverUri: p.coverUri, content: detail.currentVersion.content, version: p.version,
  45. dependencies: detail.currentVersion.dependencies.map((item: any) => ({
  46. targetProjectId: item.targetProjectId, targetVersionId: item.targetVersionId,
  47. relationType: item.relationType, required: item.required,
  48. })),
  49. assets: detail.currentVersion.assets.map((item: any) => ({
  50. code: item.code, name: item.name, type: item.type, storageUri: item.storageUri,
  51. mimeType: item.mimeType, sizeBytes: item.sizeBytes, sha256: item.sha256,
  52. metadata: item.metadata, status: item.status, sortOrder: item.sortOrder,
  53. })),
  54. changeSummary: '闭环验收:模拟另一编辑器在隔离测试项目中保存元数据',
  55. }
  56. }
  57. test('真实未保存稿同版本恢复并显式保存;旧版本恢复稿下载隔离保留', async ({ page }, info) => {
  58. const token = await signIn(page)
  59. const model = await copyModel(token, '恢复副本')
  60. let frame = await openEditor(page, model.id)
  61. const baseline = await read(token, model.id)
  62. const restoredName = '闭环验收-真实未保存恢复'
  63. await editMeshName(frame, restoredName)
  64. // Reload exercises the actual beforeunload checkpoint. No storage state is seeded.
  65. await page.reload()
  66. frame = await currentFrame(page)
  67. await expect(frame.locator('#modal-title')).toHaveText('发现同版本未保存恢复副本')
  68. expect((await read(token, model.id)).project.version).toBe(baseline.project.version)
  69. await shot(page, info, '60-same-version-real-recovery')
  70. await frame.locator('[data-model-recovery-action="restore"]').click()
  71. frame = await readyFrame(page)
  72. expect(await frame.evaluate(() => (window as any).editorApp.apiRecoveryAwaitingExplicitSave)).toBe(true)
  73. await selectMesh(frame)
  74. await expect(frame.locator('#object-name-input')).toHaveValue(restoredName)
  75. expect((await read(token, model.id)).project.version).toBe(baseline.project.version)
  76. await saveEditor(frame)
  77. const restored = await read(token, model.id)
  78. expect(restored.project.version).toBeGreaterThan(baseline.project.version)
  79. expect(Object.values(restored.currentVersion.content.model.objects).some((o: any) => o.name === restoredName)).toBe(true)
  80. expect(await frame.evaluate(() => (window as any).editorApp.apiRecoveryAwaitingExplicitSave)).toBe(false)
  81. await shot(page, info, '61-recovered-draft-explicitly-saved')
  82. const staleName = '闭环验收-旧版本需人工合并'
  83. await editMeshName(frame, staleName)
  84. const concurrent = await call(endpoint(model.id), token,
  85. projectCommand(restored, '隔离项目的并发元数据更新,保留已保存模型内容'), 'PUT')
  86. expect(concurrent.status, concurrent.message).toBe(200)
  87. expect(concurrent.data.project.version).toBeGreaterThan(restored.project.version)
  88. await page.reload()
  89. frame = await currentFrame(page)
  90. await expect(frame.locator('#modal-title')).toHaveText('发现不同版本恢复副本')
  91. await expect(frame.locator('[data-model-recovery-action="restore"]')).toHaveCount(0)
  92. await shot(page, info, '62-stale-version-recovery-protection')
  93. const downloading = page.waitForEvent('download', { timeout: 30_000 })
  94. await frame.locator('[data-model-recovery-action="keep"]').click()
  95. const downloaded = await downloading
  96. const folder = path.join(report, 'downloads')
  97. fs.mkdirSync(folder, { recursive: true })
  98. const file = path.join(folder, `recovery-${model.id}-isolated.json`)
  99. await downloaded.saveAs(file)
  100. const retained = JSON.parse(fs.readFileSync(file, 'utf8'))
  101. expect(retained.projectId).toBe(model.id)
  102. expect(retained.projectVersion).toBe(restored.project.version)
  103. expect(Object.values(retained.document.model.objects).some((o: any) => o.name === staleName)).toBe(true)
  104. frame = await readyFrame(page)
  105. const after = await read(token, model.id)
  106. expect(after.project.version).toBe(concurrent.data.project.version)
  107. expect(after.currentVersion.content).toEqual(concurrent.data.currentVersion.content)
  108. const quarantined = await frame.evaluate(id => Object.keys(localStorage)
  109. .filter(key => key.startsWith(`dt-editor-project-api-quarantine-v1:${id}:`))
  110. .some(key => JSON.parse(localStorage.getItem(key) || 'null')?.document?.model), model.id)
  111. expect(quarantined).toBe(true)
  112. evidence('60-project-recovery', { status: 'PASS', kind: 'e2e+api', testTitle: info.title,
  113. featureIds: ['project.recovery'], projectId: model.id,
  114. sameVersion: baseline.project.version, explicitSaveVersion: restored.project.version,
  115. concurrentVersion: concurrent.data.project.version, retainedFilename: path.basename(file),
  116. scope: '通过真实beforeunload创建未保存稿;同版恢复后仅显式保存落库;旧版只下载隔离,不自动覆盖服务端。丢弃/跨标签竞态另由既有恢复合同测试覆盖。' })
  117. })
  118. test('受控 GLB 下载失败阻止编辑,重试成功;发布预检开关无写入且拒绝错误资源 JSON', async ({ page }, info) => {
  119. const token = await signIn(page)
  120. const model = await copyModel(token, '加载与预检')
  121. const baseline = await read(token, model.id)
  122. const assetUrl = `**/api/tran/v1/content/projects/${model.id}/assets/*/content`
  123. let failedDownloads = 0
  124. await page.route(assetUrl, async route => {
  125. failedDownloads += 1
  126. await route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({
  127. code: 503, message: '闭环验收:模拟受控GLB暂时不可用', data: null, requestId: 'closure-glb-unavailable',
  128. }) })
  129. })
  130. try {
  131. await page.goto(`/content/models/${model.id}/edit`)
  132. let frame = await currentFrame(page)
  133. await expect(frame.locator('#loading-overlay')).toContainText('模型工程加载失败')
  134. await expect(frame.locator('[data-action="save"]').first()).toBeDisabled()
  135. await expect(frame.locator('[data-action="publish"]').first()).toBeDisabled()
  136. expect(await frame.evaluate(() => (window as any).editorApp.apiEditorHydrated)).toBe(false)
  137. expect(failedDownloads).toBeGreaterThan(0)
  138. expect((await read(token, model.id)).project.version).toBe(baseline.project.version)
  139. await shot(page, info, '63-controlled-glb-download-failed')
  140. await page.unroute(assetUrl)
  141. const previousFrame = frame
  142. const detached = page.waitForEvent('framedetached', { predicate: candidate => candidate === previousFrame })
  143. await frame.locator('[data-host-model-load-retry]').click()
  144. await detached
  145. frame = await readyFrame(page)
  146. expect(await frame.evaluate(() => {
  147. let count = 0
  148. ;(window as any).editorApp.modelPivot.traverse((o: any) => { if (o.isMesh) count += 1 })
  149. return count
  150. })).toBeGreaterThan(0)
  151. await frame.locator('[data-action="focus"]').first().click()
  152. await shot(page, info, '64-controlled-glb-retry-loaded')
  153. const beforePanel = await read(token, model.id)
  154. await frame.locator('[data-action="publish"]').first().click()
  155. await expect(frame.locator('#publish-modal')).toHaveAttribute('aria-hidden', 'false')
  156. expect(await frame.locator('.publish-check').count()).toBeGreaterThanOrEqual(3)
  157. await expect(frame.locator('#publish-checks')).toContainText('模型资源已绑定')
  158. await shot(page, info, '65-publish-preflight-readonly')
  159. await frame.locator('#publish-modal [data-publish-close]').first().click()
  160. await expect(frame.locator('#publish-modal')).toHaveAttribute('aria-hidden', 'true')
  161. const afterPanel = await read(token, model.id)
  162. expect(afterPanel.project.version).toBe(beforePanel.project.version)
  163. expect(afterPanel.currentVersion.content).toEqual(beforePanel.currentVersion.content)
  164. const exportPromise = page.waitForEvent('download', { timeout: 30_000 })
  165. await frame.locator('[data-action="save-as"]').first().click()
  166. const exported = await exportPromise
  167. const temporary = path.join(report, 'downloads', `preflight-${model.id}.json`)
  168. fs.mkdirSync(path.dirname(temporary), { recursive: true })
  169. await exported.saveAs(temporary)
  170. const invalid = JSON.parse(fs.readFileSync(temporary, 'utf8'))
  171. invalid.modelResource.assetCode = 'CLOSURE_NONEXISTENT_CONTROLLED_MODEL'
  172. invalid.modelResource.code = 'CLOSURE_NONEXISTENT_CONTROLLED_MODEL'
  173. const originalCode = afterPanel.currentVersion.content.modelResource.assetCode
  174. await frame.locator('#project-file-input').setInputFiles({ name: 'missing-controlled-model.json',
  175. mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(invalid)) })
  176. await expect(frame.locator('.toast.error').filter({ hasText: '导入失败' }).last()).toBeVisible()
  177. expect(await frame.evaluate(() => (window as any).editorApp.activeDescriptor.assetCode)).toBe(originalCode)
  178. expect((await read(token, model.id)).project.version).toBe(beforePanel.project.version)
  179. evidence('63-loading-publish-preflight', { status: 'PASS', kind: 'e2e+api', testTitle: info.title,
  180. featureIds: ['resource.loading', 'publish.preflight'], projectId: model.id, failedDownloads,
  181. retainedVersion: beforePanel.project.version,
  182. scope: '真实受控下载定向503→中性失败与禁止编辑→解除故障后UI重试成功;预检面板开关不写项目,错误资源JSON在替换当前模型之前被拒绝。服务端发布时code/URI一致性拒绝由ContentProjectServiceTest合同回归覆盖,未把面板绿标代替服务端校验。' })
  183. } finally { await page.unroute(assetUrl) }
  184. })