|
- import fs from 'node:fs'
- import path from 'node:path'
- import { pathToFileURL } from 'node:url'
- import { expect, test, type Frame, type Page, type Route } from '@playwright/test'
- import {
- call, closeSession, evidence, fixture, openEditor, readyFrame, report,
- saveEditor, selectMesh, setRange, shot, signIn, work, writeFixture,
- } from './model-editor-closure-helpers'
-
- // Real copies, real uploads and real persistence. Only the two explicitly named
- // PUT failure cases intercept requests; originals 156/157 are never mutated.
- test.describe.configure({ mode: 'default' })
- test.setTimeout(240_000)
- const materials = path.resolve(work, '../../文档/素材')
- const downloads = path.join(report, 'downloads')
- const projectEndpoint = (id: string) => `tran/v1/content/projects/${id}`
- const controlledUri = /^(?:content:\/\/sha256\/[0-9a-f]{64}$|fsvc:\/\/[^/\s]+\/(?:model|image)\/)/
- const requestFailures = new WeakMap<Page, Array<Record<string, unknown>>>()
-
- test.beforeEach(async ({ page }) => {
- const failures: Array<Record<string, unknown>> = []
- requestFailures.set(page, failures)
- page.on('response', response => {
- if (response.status() >= 400) failures.push({ path: new URL(response.url()).pathname,
- method: response.request().method(), status: response.status() })
- })
- page.on('requestfailed', request => failures.push({ path: new URL(request.url()).pathname,
- method: request.method(), error: request.failure()?.errorText }))
- })
-
- test.afterEach(async ({ page }, info) => {
- // Paths/statuses only: do not persist authorization headers, tokens or login bodies.
- await info.attach('asset-closure-request-failures', { contentType: 'application/json',
- body: Buffer.from(JSON.stringify({ status: info.status, failures: requestFailures.get(page) || [] }, null, 2)) })
- })
-
- async function readProject(token: string, id: string) {
- const result = await call(projectEndpoint(id), token)
- expect(result.status, result.message).toBe(200)
- return { ...result.data, content: result.data.currentVersion.content, assets: result.data.currentVersion.assets }
- }
-
- async function isolatedProject(token: string, label: string) {
- const data = fixture()
- const sourceId = String(data.originals.fox.id)
- const source = await readProject(token, sourceId)
- const name = `闭环验收-${label}-${Date.now().toString(36)}`
- const copied = await call(`${projectEndpoint(sourceId)}/copy`, token, { name, version: source.project.version })
- expect(copied.status, copied.message).toBe(200)
- const id = String(copied.data.project.id)
- expect([sourceId, '156', '157']).not.toContain(id)
- // Read again immediately before writing so another completed fixture is retained.
- const latest = fixture()
- latest.extraProjectIds = [...new Set([...(latest.extraProjectIds || []), id])]
- latest.assetClosureModels = { ...(latest.assetClosureModels || {}), [label]: { id, name } }
- writeFixture(latest)
- return { id, name }
- }
-
- async function settle(frame: Frame) {
- await frame.waitForFunction(() => {
- const e = (window as any).editorApp
- return e?.apiEditorHydrated && e.apiState === 'ready' && !e.apiImportBusy && !e.apiCoverSavePending
- }, undefined, { timeout: 90_000 })
- }
-
- async function saveAndRead(page: Page, frame: Frame, token: string, id: string) {
- const savedResponse = page.waitForResponse(response =>
- response.request().method() === 'PUT' && new URL(response.url()).pathname.endsWith(`/content/projects/${id}`),
- { timeout: 40_000 })
- await saveEditor(frame)
- expect((await savedResponse).status()).toBe(200)
- await settle(frame)
- return readProject(token, id)
- }
-
- async function snapshot(frame: Frame) {
- return frame.evaluate(() => {
- const d = (window as any).editorApp.serializeProject()
- // Ignore camera damping between frames; compare all edited payload sections.
- return { name: d.name, modelId: d.modelId, modelResource: d.modelResource, model: d.model,
- environment: d.environment, timeline: d.timeline, blueprint: d.blueprint }
- })
- }
-
- async function exportedJson(page: Page, frame: Frame, filename: string) {
- fs.mkdirSync(downloads, { recursive: true })
- const waiting = page.waitForEvent('download')
- await frame.locator('[data-action="save-as"]').first().click()
- const downloaded = await waiting
- const target = path.join(downloads, filename)
- await downloaded.saveAs(target)
- return { target, document: JSON.parse(fs.readFileSync(target, 'utf8')) }
- }
-
- test('受控模型与外部环境 GLB 上传、JSON 往返和刷新重开', async ({ page }, info) => {
- const token = await signIn(page)
- try {
- const model = await isolatedProject(token, '资源往返')
- let frame = await openEditor(page, model.id)
- await frame.locator('#model-file-input').setInputFiles(path.join(materials, '09-Fox.glb'))
- await frame.waitForFunction(() => {
- const e = (window as any).editorApp
- return e.activeDescriptor?.sourceName === '09-Fox.glb' && e.apiState === 'ready'
- && !e.apiImportBusy && !e.apiCoverSavePending
- }, undefined, { timeout: 90_000 })
- const main = await readProject(token, model.id)
- const mainCode = main.content.modelResource.assetCode
- expect(main.content.modelResource.storageUri).toMatch(controlledUri)
- expect(main.assets.find((a: any) => a.assetCode === mainCode || a.code === mainCode)?.status).toBe('READY')
-
- await frame.locator('#outdoor-file-input').setInputFiles(path.join(materials, '01-Box.glb'))
- await frame.waitForFunction(() => {
- const e = (window as any).editorApp
- return e.outdoorSceneMeta?.sourceName === '01-Box.glb' && e.outdoorSceneMeta?.assetCode
- && e.apiState === 'ready' && !e.apiImportBusy && !e.apiCoverSavePending
- }, undefined, { timeout: 90_000 })
- // This tiny sample is a solid cube, not a walkable environment. Position and
- // scale its root with the UI so the screenshot can show both loaded assets.
- const environmentId = await frame.evaluate(() => (window as any).editorApp.outdoorRoot.uuid)
- await frame.locator(`[data-object-id="${environmentId}"]`).click()
- for (const [vector, axis, value] of [['position', 'x', '6'], ['position', 'z', '-7'],
- ['scale', 'x', '0.15'], ['scale', 'y', '0.15'], ['scale', 'z', '0.15']]) {
- const input = frame.locator(`[data-vector="${vector}"][data-axis="${axis}"]`)
- await input.fill(value)
- await input.press('Tab')
- }
- await selectMesh(frame)
- await frame.locator('[data-action="focus"]').first().click()
- const saved = await saveAndRead(page, frame, token, model.id)
- expect(saved.content.environment.mode).toBe('external')
- expect(saved.content.environment.assetCode).toMatch(/^ENV_MODEL_/)
- expect(saved.content.environment.storageUri).toMatch(controlledUri)
- expect(saved.content.modelResource.assetCode).toBe(mainCode)
- expect(saved.content.environment.resourceUrl).toBeUndefined()
-
- const exported = await exportedJson(page, frame, 'assets-roundtrip.json')
- expect(exported.document.environment.assetCode).toBe(saved.content.environment.assetCode)
- expect(exported.document.modelResource.assetCode).toBe(mainCode)
- await frame.locator('#project-file-input').setInputFiles(exported.target)
- await expect(frame.locator('#toast-stack .toast.success').filter({ hasText: /项目.*导入成功/ }).last()).toBeVisible()
- await settle(frame)
- await page.reload()
- frame = await readyFrame(page)
- const restored = await frame.evaluate(() => {
- const e = (window as any).editorApp
- let modelMeshes = 0; let environmentMeshes = 0
- e.modelPivot.traverse((o: any) => { if (o.isMesh) modelMeshes += 1 })
- e.outdoorRoot.traverse((o: any) => { if (o.isMesh) environmentMeshes += 1 })
- return { mainCode: e.activeDescriptor.assetCode, environment: e.outdoorSceneMeta,
- environmentPosition: e.outdoorRoot.position.toArray(), environmentScale: e.outdoorRoot.scale.toArray(),
- modelMeshes, environmentMeshes, nativeClips: e.nativeClips.length }
- })
- expect(restored.mainCode).toBe(mainCode)
- expect(restored.environment.assetCode).toBe(saved.content.environment.assetCode)
- expect(restored.environment.resourceUrl).toMatch(/^blob:/)
- expect(restored.environmentPosition).toEqual([6, 0, -7])
- expect(restored.environmentScale).toEqual([0.15, 0.15, 0.15])
- expect(restored.modelMeshes).toBeGreaterThan(0)
- expect(restored.environmentMeshes).toBeGreaterThan(0)
- expect(restored.nativeClips).toBeGreaterThan(0)
- await shot(page, info, '30-assets-environment-reopened')
- evidence('30-assets-roundtrip', { status: 'PASS', kind: 'e2e+api', testTitle: info.title,
- featureIds: ['project.import-glb', 'environment.external', 'project.import-json', 'project.export-json', 'project.save'], projectId: model.id, mainCode,
- environmentCode: restored.environment.assetCode, mainUri: saved.content.modelResource.storageUri,
- environmentUri: saved.content.environment.storageUri, modelMeshes: restored.modelMeshes,
- environmentMeshes: restored.environmentMeshes, nativeClips: restored.nativeClips,
- environmentPosition: restored.environmentPosition, environmentScale: restored.environmentScale,
- result: 'PASS', scope: '真实上传、注册、保存、JSON重导入、刷新后的实际网格和原生动画目录;未断言下游场景执行动画。' })
- } finally { await closeSession(page) }
- })
-
- test('非法 JSON 与未登记 blob 引用导入失败时保留当前文档', async ({ page }, info) => {
- const token = await signIn(page)
- try {
- const model = await isolatedProject(token, '拒绝非法资源')
- const frame = await openEditor(page, model.id)
- const original = await snapshot(frame)
- const server = await readProject(token, model.id)
- const exported = await exportedJson(page, frame, 'invalid-import-baseline.json')
- const invalid = { ...exported.document, modelId: 'unregistered-foreign-glb',
- modelResource: { kind: 'imported', id: 'unregistered-foreign-glb', assetCode: '', code: '',
- storageUri: '', assetKey: '', resourceUrl: 'blob:unregistered-foreign-page' } }
- for (const input of [{ name: 'invalid-syntax.json', value: '{invalid-json' },
- { name: 'unregistered-blob.json', value: JSON.stringify(invalid) }]) {
- await frame.locator('#project-file-input').setInputFiles({ name: input.name,
- mimeType: 'application/json', buffer: Buffer.from(input.value) })
- await expect(frame.locator('#toast-stack .toast.error').filter({ hasText: '导入失败' }).last()).toBeVisible()
- expect(await snapshot(frame)).toEqual(original)
- const after = await readProject(token, model.id)
- expect(after.project.version).toBe(server.project.version)
- expect(after.content).toEqual(server.content)
- }
- await shot(page, info, '31-invalid-import-keeps-model')
- evidence('31-invalid-import', { status: 'PASS', kind: 'e2e+api', testTitle: info.title, featureIds: ['project.import-json'], projectId: model.id, result: 'PASS',
- cases: ['JSON语法错误', '无受控资源编号/地址的跨页面blob引用'], retainedVersion: server.project.version })
- } finally { await closeSession(page) }
- })
-
- test('保存请求超时和 409 保留恢复副本,解除故障后可重试', async ({ page }, info) => {
- const token = await signIn(page)
- const intercepted: Array<{ mode: string; method: string; projectId: string }> = []
- let handler: ((route: Route) => Promise<void>) | null = null
- let url = ''
- try {
- const model = await isolatedProject(token, '保存失败恢复')
- const frame = await openEditor(page, model.id)
- await selectMesh(frame)
- url = `**/api/tran/v1/content/projects/${model.id}`
- for (const mode of ['timedout', '409']) {
- const baseline = await readProject(token, model.id)
- handler = async route => {
- if (route.request().method() !== 'PUT') return route.continue()
- intercepted.push({ mode, method: 'PUT', projectId: model.id })
- if (mode === 'timedout') await route.abort('timedout')
- else await route.fulfill({ status: 409, contentType: 'application/json', body: JSON.stringify({
- code: 409, message: '闭环验收:模拟乐观锁冲突,未执行写入', requestId: 'model-closure-fixture-conflict', data: null,
- }) })
- }
- await page.route(url, handler)
- await frame.locator('#object-name-input').fill(`闭环验收-保留编辑-${mode}`)
- await frame.locator('#object-name-input').press('Tab')
- const changed = await snapshot(frame)
- await frame.locator('[data-action="save"]').first().click()
- await frame.waitForFunction(expected => {
- const e = (window as any).editorApp
- return e.apiState === expected && !e.apiCoverSavePending
- }, mode === '409' ? 'conflict' : 'error')
- expect(await snapshot(frame)).toEqual(changed)
- const recovery = await frame.evaluate(() => {
- const e = (window as any).editorApp
- const value = JSON.parse(localStorage.getItem(e.apiRecoveryKey) || 'null')
- return { exists: !!value, version: value?.projectVersion, model: value?.document?.model }
- })
- expect(recovery.exists).toBe(true)
- expect(recovery.model).toEqual(changed.model)
- const afterFailure = await readProject(token, model.id)
- expect(afterFailure.project.version).toBe(baseline.project.version)
- expect(afterFailure.content).toEqual(baseline.content)
- if (mode === '409') await shot(page, info, '32-save-conflict-keeps-recovery')
- await page.unroute(url, handler); handler = null
- const retried = await saveAndRead(page, frame, token, model.id)
- expect(retried.project.version).toBeGreaterThan(baseline.project.version)
- expect(retried.content.model).toEqual(changed.model)
- }
- await shot(page, info, '33-save-retry-success')
- 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,
- scope: '真实页面保存及真实服务端重试;失败由浏览器定向注入 timedout/409,非宣称自然发生的服务故障。' })
- } finally {
- if (handler) await page.unroute(url, handler)
- await closeSession(page)
- }
- })
-
- test('直接发布同步截图封面、固定版本目录与真实离线步骤 HTML', async ({ page, context }, info) => {
- const token = await signIn(page)
- try {
- const model = await isolatedProject(token, '发布封面版本')
- const frame = await openEditor(page, model.id)
- const baseline = await readProject(token, model.id)
- const selected = await selectMesh(frame)
- // The source may contain a deliberately hidden mesh. Make this independent
- // copy visible through its actual tree control for the published-cover check.
- if (!await frame.evaluate(() => (window as any).editorApp.selected.visible)) {
- await frame.locator(`[data-object-id="${selected.uuid}"] .tree-eye`).click()
- }
- expect(await frame.evaluate(() => (window as any).editorApp.selected.visible)).toBe(true)
- await setRange(frame, '#material-color', '#eb348c')
- await frame.locator('[data-action="publish"]').first().click()
- const publishing = page.waitForResponse(response => response.request().method() === 'POST'
- && new URL(response.url()).pathname.endsWith(`/content/projects/${model.id}/publish`), { timeout: 60_000 })
- await frame.locator('[data-action="publish-model-asset"]').click()
- const response = await publishing
- expect(response.status(), (await response.json()).message).toBe(200)
- await settle(frame)
- const published = await readProject(token, model.id)
- expect(published.project.status).toBe('PUBLISHED')
- expect(published.project.coverUri).toMatch(controlledUri)
- expect(published.project.coverUri).not.toBe(baseline.project.coverUri)
- const versionId = String(published.project.currentVersionId)
- const catalog = await call(`tran/v1/content/catalog?relationType=SCENE_MODEL&size=100&keyword=${encodeURIComponent(published.project.name)}`, token)
- expect(catalog.status, catalog.message).toBe(200)
- const entry = catalog.data.records.find((item: any) => String(item.projectId) === model.id)
- expect(entry).toBeTruthy()
- expect(String(entry.versionId)).toBe(versionId)
- const fixed = await call(`${projectEndpoint(model.id)}/versions/${versionId}`, token)
- expect(fixed.status, fixed.message).toBe(200)
- expect(fixed.data.content.modelResource.assetCode).toBe(published.content.modelResource.assetCode)
- expect(fixed.data.assets.some((a: any) => (a.code || a.assetCode) === 'MODEL_COVER'
- && a.storageUri === published.project.coverUri && a.status === 'READY')).toBe(true)
- await shot(page, info, '34-published-current-cover')
-
- fs.mkdirSync(downloads, { recursive: true })
- const waiting = page.waitForEvent('download')
- await frame.locator('[data-action="export-html"]').click()
- const downloaded = await waiting
- const file = path.join(downloads, 'model-offline-steps.html')
- await downloaded.saveAs(file)
- expect(fs.readFileSync(file, 'utf8')).toContain('本文件不运行三维模型、时间轴动画或蓝图分支')
- const offline = await context.newPage()
- try {
- await offline.goto(pathToFileURL(file).href)
- await expect(offline.locator('body')).toContainText('离线步骤演示')
- await expect(offline.locator('body')).toContainText('不含三维模型和真实交互执行')
- await expect(offline.locator('#play')).toContainText('播放步骤')
- await offline.locator('#play').click()
- await expect(offline.locator('#play')).toContainText('暂停步骤')
- await offline.locator('#play').click()
- await shot(offline, info, '35-offline-step-demo-actual-file')
- } finally { await offline.close() }
- evidence('34-publish-fixed-version', { status: 'PASS', kind: 'e2e+api', testTitle: info.title,
- featureIds: ['publish.api', 'publish.cover', 'publish.html', 'publish.downstream'], projectId: model.id, result: 'PASS', versionId,
- coverUri: published.project.coverUri, priorCoverUri: baseline.project.coverUri,
- mainCode: published.content.modelResource.assetCode,
- scope: '目录可取得固定发布版本及同版封面;HTML已真实打开并确认演示边界,未声明下游动画执行或完整离线三维运行。' })
- } finally { await closeSession(page) }
- })
|