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.
 
 
 
 

318 regels
18 KiB

  1. import fs from 'node:fs'
  2. import path from 'node:path'
  3. import { pathToFileURL } from 'node:url'
  4. import { expect, test, type Frame, type Page, type Route } from '@playwright/test'
  5. import {
  6. call, closeSession, evidence, fixture, openEditor, readyFrame, report,
  7. saveEditor, selectMesh, setRange, shot, signIn, work, writeFixture,
  8. } from './model-editor-closure-helpers'
  9. // Real copies, real uploads and real persistence. Only the two explicitly named
  10. // PUT failure cases intercept requests; originals 156/157 are never mutated.
  11. test.describe.configure({ mode: 'default' })
  12. test.setTimeout(240_000)
  13. const materials = path.resolve(work, '../../文档/素材')
  14. const downloads = path.join(report, 'downloads')
  15. const projectEndpoint = (id: string) => `tran/v1/content/projects/${id}`
  16. const controlledUri = /^(?:content:\/\/sha256\/[0-9a-f]{64}$|fsvc:\/\/[^/\s]+\/(?:model|image)\/)/
  17. const requestFailures = new WeakMap<Page, Array<Record<string, unknown>>>()
  18. test.beforeEach(async ({ page }) => {
  19. const failures: Array<Record<string, unknown>> = []
  20. requestFailures.set(page, failures)
  21. page.on('response', response => {
  22. if (response.status() >= 400) failures.push({ path: new URL(response.url()).pathname,
  23. method: response.request().method(), status: response.status() })
  24. })
  25. page.on('requestfailed', request => failures.push({ path: new URL(request.url()).pathname,
  26. method: request.method(), error: request.failure()?.errorText }))
  27. })
  28. test.afterEach(async ({ page }, info) => {
  29. // Paths/statuses only: do not persist authorization headers, tokens or login bodies.
  30. await info.attach('asset-closure-request-failures', { contentType: 'application/json',
  31. body: Buffer.from(JSON.stringify({ status: info.status, failures: requestFailures.get(page) || [] }, null, 2)) })
  32. })
  33. async function readProject(token: string, id: string) {
  34. const result = await call(projectEndpoint(id), token)
  35. expect(result.status, result.message).toBe(200)
  36. return { ...result.data, content: result.data.currentVersion.content, assets: result.data.currentVersion.assets }
  37. }
  38. async function isolatedProject(token: string, label: string) {
  39. const data = fixture()
  40. const sourceId = String(data.originals.fox.id)
  41. const source = await readProject(token, sourceId)
  42. const name = `闭环验收-${label}-${Date.now().toString(36)}`
  43. const copied = await call(`${projectEndpoint(sourceId)}/copy`, token, { name, version: source.project.version })
  44. expect(copied.status, copied.message).toBe(200)
  45. const id = String(copied.data.project.id)
  46. expect([sourceId, '156', '157']).not.toContain(id)
  47. // Read again immediately before writing so another completed fixture is retained.
  48. const latest = fixture()
  49. latest.extraProjectIds = [...new Set([...(latest.extraProjectIds || []), id])]
  50. latest.assetClosureModels = { ...(latest.assetClosureModels || {}), [label]: { id, name } }
  51. writeFixture(latest)
  52. return { id, name }
  53. }
  54. async function settle(frame: Frame) {
  55. await frame.waitForFunction(() => {
  56. const e = (window as any).editorApp
  57. return e?.apiEditorHydrated && e.apiState === 'ready' && !e.apiImportBusy && !e.apiCoverSavePending
  58. }, undefined, { timeout: 90_000 })
  59. }
  60. async function saveAndRead(page: Page, frame: Frame, token: string, id: string) {
  61. const savedResponse = page.waitForResponse(response =>
  62. response.request().method() === 'PUT' && new URL(response.url()).pathname.endsWith(`/content/projects/${id}`),
  63. { timeout: 40_000 })
  64. await saveEditor(frame)
  65. expect((await savedResponse).status()).toBe(200)
  66. await settle(frame)
  67. return readProject(token, id)
  68. }
  69. async function snapshot(frame: Frame) {
  70. return frame.evaluate(() => {
  71. const d = (window as any).editorApp.serializeProject()
  72. // Ignore camera damping between frames; compare all edited payload sections.
  73. return { name: d.name, modelId: d.modelId, modelResource: d.modelResource, model: d.model,
  74. environment: d.environment, timeline: d.timeline, blueprint: d.blueprint }
  75. })
  76. }
  77. async function exportedJson(page: Page, frame: Frame, filename: string) {
  78. fs.mkdirSync(downloads, { recursive: true })
  79. const waiting = page.waitForEvent('download')
  80. await frame.locator('[data-action="save-as"]').first().click()
  81. const downloaded = await waiting
  82. const target = path.join(downloads, filename)
  83. await downloaded.saveAs(target)
  84. return { target, document: JSON.parse(fs.readFileSync(target, 'utf8')) }
  85. }
  86. test('受控模型与外部环境 GLB 上传、JSON 往返和刷新重开', async ({ page }, info) => {
  87. const token = await signIn(page)
  88. try {
  89. const model = await isolatedProject(token, '资源往返')
  90. let frame = await openEditor(page, model.id)
  91. await frame.locator('#model-file-input').setInputFiles(path.join(materials, '09-Fox.glb'))
  92. await frame.waitForFunction(() => {
  93. const e = (window as any).editorApp
  94. return e.activeDescriptor?.sourceName === '09-Fox.glb' && e.apiState === 'ready'
  95. && !e.apiImportBusy && !e.apiCoverSavePending
  96. }, undefined, { timeout: 90_000 })
  97. const main = await readProject(token, model.id)
  98. const mainCode = main.content.modelResource.assetCode
  99. expect(main.content.modelResource.storageUri).toMatch(controlledUri)
  100. expect(main.assets.find((a: any) => a.assetCode === mainCode || a.code === mainCode)?.status).toBe('READY')
  101. await frame.locator('#outdoor-file-input').setInputFiles(path.join(materials, '01-Box.glb'))
  102. await frame.waitForFunction(() => {
  103. const e = (window as any).editorApp
  104. return e.outdoorSceneMeta?.sourceName === '01-Box.glb' && e.outdoorSceneMeta?.assetCode
  105. && e.apiState === 'ready' && !e.apiImportBusy && !e.apiCoverSavePending
  106. }, undefined, { timeout: 90_000 })
  107. // This tiny sample is a solid cube, not a walkable environment. Position and
  108. // scale its root with the UI so the screenshot can show both loaded assets.
  109. const environmentId = await frame.evaluate(() => (window as any).editorApp.outdoorRoot.uuid)
  110. await frame.locator(`[data-object-id="${environmentId}"]`).click()
  111. for (const [vector, axis, value] of [['position', 'x', '6'], ['position', 'z', '-7'],
  112. ['scale', 'x', '0.15'], ['scale', 'y', '0.15'], ['scale', 'z', '0.15']]) {
  113. const input = frame.locator(`[data-vector="${vector}"][data-axis="${axis}"]`)
  114. await input.fill(value)
  115. await input.press('Tab')
  116. }
  117. await selectMesh(frame)
  118. await frame.locator('[data-action="focus"]').first().click()
  119. const saved = await saveAndRead(page, frame, token, model.id)
  120. expect(saved.content.environment.mode).toBe('external')
  121. expect(saved.content.environment.assetCode).toMatch(/^ENV_MODEL_/)
  122. expect(saved.content.environment.storageUri).toMatch(controlledUri)
  123. expect(saved.content.modelResource.assetCode).toBe(mainCode)
  124. expect(saved.content.environment.resourceUrl).toBeUndefined()
  125. const exported = await exportedJson(page, frame, 'assets-roundtrip.json')
  126. expect(exported.document.environment.assetCode).toBe(saved.content.environment.assetCode)
  127. expect(exported.document.modelResource.assetCode).toBe(mainCode)
  128. await frame.locator('#project-file-input').setInputFiles(exported.target)
  129. await expect(frame.locator('#toast-stack .toast.success').filter({ hasText: /项目.*导入成功/ }).last()).toBeVisible()
  130. await settle(frame)
  131. await page.reload()
  132. frame = await readyFrame(page)
  133. const restored = await frame.evaluate(() => {
  134. const e = (window as any).editorApp
  135. let modelMeshes = 0; let environmentMeshes = 0
  136. e.modelPivot.traverse((o: any) => { if (o.isMesh) modelMeshes += 1 })
  137. e.outdoorRoot.traverse((o: any) => { if (o.isMesh) environmentMeshes += 1 })
  138. return { mainCode: e.activeDescriptor.assetCode, environment: e.outdoorSceneMeta,
  139. environmentPosition: e.outdoorRoot.position.toArray(), environmentScale: e.outdoorRoot.scale.toArray(),
  140. modelMeshes, environmentMeshes, nativeClips: e.nativeClips.length }
  141. })
  142. expect(restored.mainCode).toBe(mainCode)
  143. expect(restored.environment.assetCode).toBe(saved.content.environment.assetCode)
  144. expect(restored.environment.resourceUrl).toMatch(/^blob:/)
  145. expect(restored.environmentPosition).toEqual([6, 0, -7])
  146. expect(restored.environmentScale).toEqual([0.15, 0.15, 0.15])
  147. expect(restored.modelMeshes).toBeGreaterThan(0)
  148. expect(restored.environmentMeshes).toBeGreaterThan(0)
  149. expect(restored.nativeClips).toBeGreaterThan(0)
  150. await shot(page, info, '30-assets-environment-reopened')
  151. evidence('30-assets-roundtrip', { status: 'PASS', kind: 'e2e+api', testTitle: info.title,
  152. featureIds: ['project.import-glb', 'environment.external', 'project.import-json', 'project.export-json', 'project.save'], projectId: model.id, mainCode,
  153. environmentCode: restored.environment.assetCode, mainUri: saved.content.modelResource.storageUri,
  154. environmentUri: saved.content.environment.storageUri, modelMeshes: restored.modelMeshes,
  155. environmentMeshes: restored.environmentMeshes, nativeClips: restored.nativeClips,
  156. environmentPosition: restored.environmentPosition, environmentScale: restored.environmentScale,
  157. result: 'PASS', scope: '真实上传、注册、保存、JSON重导入、刷新后的实际网格和原生动画目录;未断言下游场景执行动画。' })
  158. } finally { await closeSession(page) }
  159. })
  160. test('非法 JSON 与未登记 blob 引用导入失败时保留当前文档', async ({ page }, info) => {
  161. const token = await signIn(page)
  162. try {
  163. const model = await isolatedProject(token, '拒绝非法资源')
  164. const frame = await openEditor(page, model.id)
  165. const original = await snapshot(frame)
  166. const server = await readProject(token, model.id)
  167. const exported = await exportedJson(page, frame, 'invalid-import-baseline.json')
  168. const invalid = { ...exported.document, modelId: 'unregistered-foreign-glb',
  169. modelResource: { kind: 'imported', id: 'unregistered-foreign-glb', assetCode: '', code: '',
  170. storageUri: '', assetKey: '', resourceUrl: 'blob:unregistered-foreign-page' } }
  171. for (const input of [{ name: 'invalid-syntax.json', value: '{invalid-json' },
  172. { name: 'unregistered-blob.json', value: JSON.stringify(invalid) }]) {
  173. await frame.locator('#project-file-input').setInputFiles({ name: input.name,
  174. mimeType: 'application/json', buffer: Buffer.from(input.value) })
  175. await expect(frame.locator('#toast-stack .toast.error').filter({ hasText: '导入失败' }).last()).toBeVisible()
  176. expect(await snapshot(frame)).toEqual(original)
  177. const after = await readProject(token, model.id)
  178. expect(after.project.version).toBe(server.project.version)
  179. expect(after.content).toEqual(server.content)
  180. }
  181. await shot(page, info, '31-invalid-import-keeps-model')
  182. evidence('31-invalid-import', { status: 'PASS', kind: 'e2e+api', testTitle: info.title, featureIds: ['project.import-json'], projectId: model.id, result: 'PASS',
  183. cases: ['JSON语法错误', '无受控资源编号/地址的跨页面blob引用'], retainedVersion: server.project.version })
  184. } finally { await closeSession(page) }
  185. })
  186. test('保存请求超时和 409 保留恢复副本,解除故障后可重试', async ({ page }, info) => {
  187. const token = await signIn(page)
  188. const intercepted: Array<{ mode: string; method: string; projectId: string }> = []
  189. let handler: ((route: Route) => Promise<void>) | null = null
  190. let url = ''
  191. try {
  192. const model = await isolatedProject(token, '保存失败恢复')
  193. const frame = await openEditor(page, model.id)
  194. await selectMesh(frame)
  195. url = `**/api/tran/v1/content/projects/${model.id}`
  196. for (const mode of ['timedout', '409']) {
  197. const baseline = await readProject(token, model.id)
  198. handler = async route => {
  199. if (route.request().method() !== 'PUT') return route.continue()
  200. intercepted.push({ mode, method: 'PUT', projectId: model.id })
  201. if (mode === 'timedout') await route.abort('timedout')
  202. else await route.fulfill({ status: 409, contentType: 'application/json', body: JSON.stringify({
  203. code: 409, message: '闭环验收:模拟乐观锁冲突,未执行写入', requestId: 'model-closure-fixture-conflict', data: null,
  204. }) })
  205. }
  206. await page.route(url, handler)
  207. await frame.locator('#object-name-input').fill(`闭环验收-保留编辑-${mode}`)
  208. await frame.locator('#object-name-input').press('Tab')
  209. const changed = await snapshot(frame)
  210. await frame.locator('[data-action="save"]').first().click()
  211. await frame.waitForFunction(expected => {
  212. const e = (window as any).editorApp
  213. return e.apiState === expected && !e.apiCoverSavePending
  214. }, mode === '409' ? 'conflict' : 'error')
  215. expect(await snapshot(frame)).toEqual(changed)
  216. const recovery = await frame.evaluate(() => {
  217. const e = (window as any).editorApp
  218. const value = JSON.parse(localStorage.getItem(e.apiRecoveryKey) || 'null')
  219. return { exists: !!value, version: value?.projectVersion, model: value?.document?.model }
  220. })
  221. expect(recovery.exists).toBe(true)
  222. expect(recovery.model).toEqual(changed.model)
  223. const afterFailure = await readProject(token, model.id)
  224. expect(afterFailure.project.version).toBe(baseline.project.version)
  225. expect(afterFailure.content).toEqual(baseline.content)
  226. if (mode === '409') await shot(page, info, '32-save-conflict-keeps-recovery')
  227. await page.unroute(url, handler); handler = null
  228. const retried = await saveAndRead(page, frame, token, model.id)
  229. expect(retried.project.version).toBeGreaterThan(baseline.project.version)
  230. expect(retried.content.model).toEqual(changed.model)
  231. }
  232. await shot(page, info, '33-save-retry-success')
  233. evidence('32-save-failure-recovery', { status: 'PASS', kind: 'e2e+api', testTitle: info.title, featureIds: ['project.conflict', 'project.save', 'feedback.toast'], projectId: model.id, result: 'PASS', intercepted,
  234. scope: '真实页面保存及真实服务端重试;失败由浏览器定向注入 timedout/409,非宣称自然发生的服务故障。' })
  235. } finally {
  236. if (handler) await page.unroute(url, handler)
  237. await closeSession(page)
  238. }
  239. })
  240. test('直接发布同步截图封面、固定版本目录与真实离线步骤 HTML', async ({ page, context }, info) => {
  241. const token = await signIn(page)
  242. try {
  243. const model = await isolatedProject(token, '发布封面版本')
  244. const frame = await openEditor(page, model.id)
  245. const baseline = await readProject(token, model.id)
  246. const selected = await selectMesh(frame)
  247. // The source may contain a deliberately hidden mesh. Make this independent
  248. // copy visible through its actual tree control for the published-cover check.
  249. if (!await frame.evaluate(() => (window as any).editorApp.selected.visible)) {
  250. await frame.locator(`[data-object-id="${selected.uuid}"] .tree-eye`).click()
  251. }
  252. expect(await frame.evaluate(() => (window as any).editorApp.selected.visible)).toBe(true)
  253. await setRange(frame, '#material-color', '#eb348c')
  254. await frame.locator('[data-action="publish"]').first().click()
  255. const publishing = page.waitForResponse(response => response.request().method() === 'POST'
  256. && new URL(response.url()).pathname.endsWith(`/content/projects/${model.id}/publish`), { timeout: 60_000 })
  257. await frame.locator('[data-action="publish-model-asset"]').click()
  258. const response = await publishing
  259. expect(response.status(), (await response.json()).message).toBe(200)
  260. await settle(frame)
  261. const published = await readProject(token, model.id)
  262. expect(published.project.status).toBe('PUBLISHED')
  263. expect(published.project.coverUri).toMatch(controlledUri)
  264. expect(published.project.coverUri).not.toBe(baseline.project.coverUri)
  265. const versionId = String(published.project.currentVersionId)
  266. const catalog = await call(`tran/v1/content/catalog?relationType=SCENE_MODEL&size=100&keyword=${encodeURIComponent(published.project.name)}`, token)
  267. expect(catalog.status, catalog.message).toBe(200)
  268. const entry = catalog.data.records.find((item: any) => String(item.projectId) === model.id)
  269. expect(entry).toBeTruthy()
  270. expect(String(entry.versionId)).toBe(versionId)
  271. const fixed = await call(`${projectEndpoint(model.id)}/versions/${versionId}`, token)
  272. expect(fixed.status, fixed.message).toBe(200)
  273. expect(fixed.data.content.modelResource.assetCode).toBe(published.content.modelResource.assetCode)
  274. expect(fixed.data.assets.some((a: any) => (a.code || a.assetCode) === 'MODEL_COVER'
  275. && a.storageUri === published.project.coverUri && a.status === 'READY')).toBe(true)
  276. await shot(page, info, '34-published-current-cover')
  277. fs.mkdirSync(downloads, { recursive: true })
  278. const waiting = page.waitForEvent('download')
  279. await frame.locator('[data-action="export-html"]').click()
  280. const downloaded = await waiting
  281. const file = path.join(downloads, 'model-offline-steps.html')
  282. await downloaded.saveAs(file)
  283. expect(fs.readFileSync(file, 'utf8')).toContain('本文件不运行三维模型、时间轴动画或蓝图分支')
  284. const offline = await context.newPage()
  285. try {
  286. await offline.goto(pathToFileURL(file).href)
  287. await expect(offline.locator('body')).toContainText('离线步骤演示')
  288. await expect(offline.locator('body')).toContainText('不含三维模型和真实交互执行')
  289. await expect(offline.locator('#play')).toContainText('播放步骤')
  290. await offline.locator('#play').click()
  291. await expect(offline.locator('#play')).toContainText('暂停步骤')
  292. await offline.locator('#play').click()
  293. await shot(offline, info, '35-offline-step-demo-actual-file')
  294. } finally { await offline.close() }
  295. evidence('34-publish-fixed-version', { status: 'PASS', kind: 'e2e+api', testTitle: info.title,
  296. featureIds: ['publish.api', 'publish.cover', 'publish.html', 'publish.downstream'], projectId: model.id, result: 'PASS', versionId,
  297. coverUri: published.project.coverUri, priorCoverUri: baseline.project.coverUri,
  298. mainCode: published.content.modelResource.assetCode,
  299. scope: '目录可取得固定发布版本及同版封面;HTML已真实打开并确认演示边界,未声明下游动画执行或完整离线三维运行。' })
  300. } finally { await closeSession(page) }
  301. })