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>>() test.beforeEach(async ({ page }) => { const failures: Array> = [] 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) | 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) } })