import fs from 'node:fs' import path from 'node:path' import { expect, test, type Frame, type Page } from '@playwright/test' import { call, closeSession, evidence, fixture, openEditor, readyFrame, report, saveEditor, selectMesh, shot, signIn, writeFixture, } from './model-editor-closure-helpers' test.setTimeout(180_000) test.afterEach(async ({ page }) => { await closeSession(page) }) const endpoint = (id: string) => `tran/v1/content/projects/${id}` async function read(token: string, id: string) { const response = await call(endpoint(id), token) expect(response.status, response.message).toBe(200) return response.data } async function copyModel(token: string, label: string) { const sourceId = String(fixture().originals.fox.id) const source = await read(token, sourceId) const name = `闭环验收-${label}-${Date.now().toString(36)}` const copied = await call(`${endpoint(sourceId)}/copy`, token, { name, version: source.project.version }) expect(copied.status, copied.message).toBe(200) const id = String(copied.data.project.id) expect(['156', '157', String(fixture().models.fox.id), String(fixture().models.truck.id)]).not.toContain(id) const latest = fixture() latest.extraProjectIds = [...new Set([...(latest.extraProjectIds || []), id])] latest.recoveryClosureModels = { ...(latest.recoveryClosureModels || {}), [label]: { id, name } } writeFixture(latest) return { id, name } } async function currentFrame(page: Page) { await expect.poll(() => page.frames().some(f => f.url().includes('/legacy-model-editor/editor.html'))).toBe(true) return page.frames().find(f => f.url().includes('/legacy-model-editor/editor.html'))! } async function editMeshName(frame: Frame, name: string) { await selectMesh(frame) await frame.locator('#object-name-input').fill(name) await frame.locator('#object-name-input').press('Tab') expect(await frame.evaluate(() => (window as any).editorApp.selected.name)).toBe(name) } function projectCommand(detail: any, description: string) { const p = detail.project return { type: p.type, code: p.code, name: p.name, description, categoryCode: p.categoryCode, coverUri: p.coverUri, content: detail.currentVersion.content, version: p.version, dependencies: detail.currentVersion.dependencies.map((item: any) => ({ targetProjectId: item.targetProjectId, targetVersionId: item.targetVersionId, relationType: item.relationType, required: item.required, })), assets: detail.currentVersion.assets.map((item: any) => ({ code: item.code, name: item.name, type: item.type, storageUri: item.storageUri, mimeType: item.mimeType, sizeBytes: item.sizeBytes, sha256: item.sha256, metadata: item.metadata, status: item.status, sortOrder: item.sortOrder, })), changeSummary: '闭环验收:模拟另一编辑器在隔离测试项目中保存元数据', } } test('真实未保存稿同版本恢复并显式保存;旧版本恢复稿下载隔离保留', async ({ page }, info) => { const token = await signIn(page) const model = await copyModel(token, '恢复副本') let frame = await openEditor(page, model.id) const baseline = await read(token, model.id) const restoredName = '闭环验收-真实未保存恢复' await editMeshName(frame, restoredName) // Reload exercises the actual beforeunload checkpoint. No storage state is seeded. await page.reload() frame = await currentFrame(page) await expect(frame.locator('#modal-title')).toHaveText('发现同版本未保存恢复副本') expect((await read(token, model.id)).project.version).toBe(baseline.project.version) await shot(page, info, '60-same-version-real-recovery') await frame.locator('[data-model-recovery-action="restore"]').click() frame = await readyFrame(page) expect(await frame.evaluate(() => (window as any).editorApp.apiRecoveryAwaitingExplicitSave)).toBe(true) await selectMesh(frame) await expect(frame.locator('#object-name-input')).toHaveValue(restoredName) expect((await read(token, model.id)).project.version).toBe(baseline.project.version) await saveEditor(frame) const restored = await read(token, model.id) expect(restored.project.version).toBeGreaterThan(baseline.project.version) expect(Object.values(restored.currentVersion.content.model.objects).some((o: any) => o.name === restoredName)).toBe(true) expect(await frame.evaluate(() => (window as any).editorApp.apiRecoveryAwaitingExplicitSave)).toBe(false) await shot(page, info, '61-recovered-draft-explicitly-saved') const staleName = '闭环验收-旧版本需人工合并' await editMeshName(frame, staleName) const concurrent = await call(endpoint(model.id), token, projectCommand(restored, '隔离项目的并发元数据更新,保留已保存模型内容'), 'PUT') expect(concurrent.status, concurrent.message).toBe(200) expect(concurrent.data.project.version).toBeGreaterThan(restored.project.version) await page.reload() frame = await currentFrame(page) await expect(frame.locator('#modal-title')).toHaveText('发现不同版本恢复副本') await expect(frame.locator('[data-model-recovery-action="restore"]')).toHaveCount(0) await shot(page, info, '62-stale-version-recovery-protection') const downloading = page.waitForEvent('download', { timeout: 30_000 }) await frame.locator('[data-model-recovery-action="keep"]').click() const downloaded = await downloading const folder = path.join(report, 'downloads') fs.mkdirSync(folder, { recursive: true }) const file = path.join(folder, `recovery-${model.id}-isolated.json`) await downloaded.saveAs(file) const retained = JSON.parse(fs.readFileSync(file, 'utf8')) expect(retained.projectId).toBe(model.id) expect(retained.projectVersion).toBe(restored.project.version) expect(Object.values(retained.document.model.objects).some((o: any) => o.name === staleName)).toBe(true) frame = await readyFrame(page) const after = await read(token, model.id) expect(after.project.version).toBe(concurrent.data.project.version) expect(after.currentVersion.content).toEqual(concurrent.data.currentVersion.content) const quarantined = await frame.evaluate(id => Object.keys(localStorage) .filter(key => key.startsWith(`dt-editor-project-api-quarantine-v1:${id}:`)) .some(key => JSON.parse(localStorage.getItem(key) || 'null')?.document?.model), model.id) expect(quarantined).toBe(true) evidence('60-project-recovery', { status: 'PASS', kind: 'e2e+api', testTitle: info.title, featureIds: ['project.recovery'], projectId: model.id, sameVersion: baseline.project.version, explicitSaveVersion: restored.project.version, concurrentVersion: concurrent.data.project.version, retainedFilename: path.basename(file), scope: '通过真实beforeunload创建未保存稿;同版恢复后仅显式保存落库;旧版只下载隔离,不自动覆盖服务端。丢弃/跨标签竞态另由既有恢复合同测试覆盖。' }) }) test('受控 GLB 下载失败阻止编辑,重试成功;发布预检开关无写入且拒绝错误资源 JSON', async ({ page }, info) => { const token = await signIn(page) const model = await copyModel(token, '加载与预检') const baseline = await read(token, model.id) const assetUrl = `**/api/tran/v1/content/projects/${model.id}/assets/*/content` let failedDownloads = 0 await page.route(assetUrl, async route => { failedDownloads += 1 await route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 503, message: '闭环验收:模拟受控GLB暂时不可用', data: null, requestId: 'closure-glb-unavailable', }) }) }) try { await page.goto(`/content/models/${model.id}/edit`) let frame = await currentFrame(page) await expect(frame.locator('#loading-overlay')).toContainText('模型工程加载失败') await expect(frame.locator('[data-action="save"]').first()).toBeDisabled() await expect(frame.locator('[data-action="publish"]').first()).toBeDisabled() expect(await frame.evaluate(() => (window as any).editorApp.apiEditorHydrated)).toBe(false) expect(failedDownloads).toBeGreaterThan(0) expect((await read(token, model.id)).project.version).toBe(baseline.project.version) await shot(page, info, '63-controlled-glb-download-failed') await page.unroute(assetUrl) const previousFrame = frame const detached = page.waitForEvent('framedetached', { predicate: candidate => candidate === previousFrame }) await frame.locator('[data-host-model-load-retry]').click() await detached frame = await readyFrame(page) expect(await frame.evaluate(() => { let count = 0 ;(window as any).editorApp.modelPivot.traverse((o: any) => { if (o.isMesh) count += 1 }) return count })).toBeGreaterThan(0) await frame.locator('[data-action="focus"]').first().click() await shot(page, info, '64-controlled-glb-retry-loaded') const beforePanel = await read(token, model.id) await frame.locator('[data-action="publish"]').first().click() await expect(frame.locator('#publish-modal')).toHaveAttribute('aria-hidden', 'false') expect(await frame.locator('.publish-check').count()).toBeGreaterThanOrEqual(3) await expect(frame.locator('#publish-checks')).toContainText('模型资源已绑定') await shot(page, info, '65-publish-preflight-readonly') await frame.locator('#publish-modal [data-publish-close]').first().click() await expect(frame.locator('#publish-modal')).toHaveAttribute('aria-hidden', 'true') const afterPanel = await read(token, model.id) expect(afterPanel.project.version).toBe(beforePanel.project.version) expect(afterPanel.currentVersion.content).toEqual(beforePanel.currentVersion.content) const exportPromise = page.waitForEvent('download', { timeout: 30_000 }) await frame.locator('[data-action="save-as"]').first().click() const exported = await exportPromise const temporary = path.join(report, 'downloads', `preflight-${model.id}.json`) fs.mkdirSync(path.dirname(temporary), { recursive: true }) await exported.saveAs(temporary) const invalid = JSON.parse(fs.readFileSync(temporary, 'utf8')) invalid.modelResource.assetCode = 'CLOSURE_NONEXISTENT_CONTROLLED_MODEL' invalid.modelResource.code = 'CLOSURE_NONEXISTENT_CONTROLLED_MODEL' const originalCode = afterPanel.currentVersion.content.modelResource.assetCode await frame.locator('#project-file-input').setInputFiles({ name: 'missing-controlled-model.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(invalid)) }) await expect(frame.locator('.toast.error').filter({ hasText: '导入失败' }).last()).toBeVisible() expect(await frame.evaluate(() => (window as any).editorApp.activeDescriptor.assetCode)).toBe(originalCode) expect((await read(token, model.id)).project.version).toBe(beforePanel.project.version) evidence('63-loading-publish-preflight', { status: 'PASS', kind: 'e2e+api', testTitle: info.title, featureIds: ['resource.loading', 'publish.preflight'], projectId: model.id, failedDownloads, retainedVersion: beforePanel.project.version, scope: '真实受控下载定向503→中性失败与禁止编辑→解除故障后UI重试成功;预检面板开关不写项目,错误资源JSON在替换当前模型之前被拒绝。服务端发布时code/URI一致性拒绝由ContentProjectServiceTest合同回归覆盖,未把面板绿标代替服务端校验。' }) } finally { await page.unroute(assetUrl) } })