Browse Source

test(content): cover legacy editor API lifecycle

main
leiyun 2 weeks ago
parent
commit
510a9525c7
7 changed files with 583 additions and 5 deletions
  1. +8
    -0
      .env.example
  2. +1
    -0
      .gitignore
  3. +16
    -3
      README.md
  4. +23
    -2
      playwright.config.ts
  5. +53
    -0
      tests/content-control-plane-security.spec.ts
  6. +64
    -0
      tests/content-control-plane.ts
  7. +418
    -0
      tests/content-legacy-api.spec.ts

+ 8
- 0
.env.example View File

@@ -1,4 +1,12 @@
BASE_URL=http://127.0.0.1:6180
# Remote BASE_URL must use HTTPS. Set this to 1 only for an isolated trusted lab.
E2E_ALLOW_INSECURE_REMOTE=0
E2E_API_BASE_URL=
# Only set to 1 for an intentional, protected tunnel between local and remote.
E2E_ALLOW_CROSS_ENV_CONTROL_PLANE=0
# Trace contains authentication request bodies and is disabled by default. Only
# enable it in a protected local/CI workspace whose artifacts are access-controlled.
E2E_TRACE=0
E2E_ADMIN_USERNAME=
E2E_ADMIN_PASSWORD=



+ 1
- 0
.gitignore View File

@@ -4,6 +4,7 @@ node_modules/
!.env.example
artifacts/html-report/
artifacts/test-results/
artifacts/trace-inspect-*/
artifacts/results.json
artifacts/screenshots/*.png
!artifacts/screenshots/.gitkeep


+ 16
- 3
README.md View File

@@ -1,5 +1,18 @@
# Unreal Tran E2E

`BASE_URL` selects the browser application. Only the destructive real-API content
editor test uses `E2E_API_BASE_URL` to seed, assert, and clean up its own data.
That test rejects a local/remote environment mismatch; an intentional protected
tunnel requires `E2E_ALLOW_CROSS_ENV_CONTROL_PLANE=1`. A non-loopback control
plane must use HTTPS. UI-only, Mock, and read-only tests do not require this
variable. Playwright trace is disabled by default because it records authentication
bodies and tokens; set `E2E_TRACE=1` only for a protected local/CI run and restrict
access to its artifacts.

Remote `BASE_URL` also requires HTTPS because browser tests carry login/session
credentials. Only an isolated trusted lab may opt in to cleartext HTTP with
`E2E_ALLOW_INSECURE_REMOTE=1`.

本目录是独立的 Playwright E2E 工程,不参与前后端构建。测试默认访问
`http://127.0.0.1:6180`,也可通过 `BASE_URL` 指向局域网地址或其它环境。

@@ -58,9 +71,9 @@ pnpm test
- HTML 报告:`artifacts/html-report`
- JSON 报告:`artifacts/results.json`
- 成功与失败截图:`artifacts/screenshots`
- trace、video 与 Playwright 原始结果:`artifacts/test-results`
- video 与 Playwright 原始结果:`artifacts/test-results`;trace 默认关闭,只有显式设置 `E2E_TRACE=1` 才会生成。

成功截图以项目、测试文件、用例标题和检查点稳定命名;每个用例/重试开始时先清理自己的旧成功图,再生成新图并附加到 HTML/JSON 报告。截图覆盖登录、三种导航、三种主题、侧栏收展、七个系统页、菜单草稿操作、内容导航以及 CRUD 各成功节点。失败截图额外带 retry 次数和时间戳,trace 与 video 只在失败时保留
成功截图以项目、测试文件、用例标题和检查点稳定命名;每个用例/重试开始时先清理自己的旧成功图,再生成新图并附加到 HTML/JSON 报告。截图覆盖登录、三种导航、三种主题、侧栏收展、七个系统页、菜单草稿操作、内容导航以及 CRUD 各成功节点。失败截图额外带 retry 次数和时间戳;video 只在失败时保留,trace 默认关闭(显式设置 `E2E_TRACE=1` 时才在失败后保留)

## 测试分层说明

@@ -80,7 +93,7 @@ npm test -- teaching-module.spec.ts
教学实施真实环境只读联调与状态化 Mock 是两类测试:`teaching-module.spec.ts` 拦截 API,用于稳定验证 12 条前端状态机闭环,但不代表 Java、Redis 或 KingBase 联调;`teaching-live.spec.ts` 不拦截 API,只读取测试环境中保留的 `UAT-` 教学数据。真实联调用短期访问令牌注入 `sessionStorage`,测试文件已关闭 trace/video,且不会提交业务写请求或清理 UAT 数据。未配置令牌时自动跳过:

```powershell
$env:BASE_URL='http://<测试环境IP>:6180'
$env:BASE_URL='https://<测试环境域名>'
$env:E2E_LIVE_ACCESS_TOKEN='<短期访问令牌>'
npm test -- teaching-live.spec.ts
```


+ 23
- 2
playwright.config.ts View File

@@ -17,7 +17,26 @@ const loadLocalEnvironment = () => {

loadLocalEnvironment()

const baseURL = (process.env.BASE_URL ?? 'http://127.0.0.1:6180').replace(/\/$/, '')
const rawApplicationBaseUrl = process.env.BASE_URL?.trim() || 'http://127.0.0.1:6180'
let applicationUrl: URL
try {
applicationUrl = new URL(rawApplicationBaseUrl)
} catch {
throw new Error('BASE_URL must be an absolute HTTP(S) URL')
}
if (!['http:', 'https:'].includes(applicationUrl.protocol) || applicationUrl.username || applicationUrl.password) {
throw new Error('BASE_URL must be an HTTP(S) URL without embedded credentials')
}
const applicationLoopbackHosts = new Set(['127.0.0.1', 'localhost', '::1', '[::1]'])
if (
!applicationLoopbackHosts.has(applicationUrl.hostname.toLowerCase())
&& applicationUrl.protocol !== 'https:'
&& process.env.E2E_ALLOW_INSECURE_REMOTE !== '1'
) {
throw new Error('Remote BASE_URL must use HTTPS unless E2E_ALLOW_INSECURE_REMOTE=1 explicitly accepts the risk')
}

const baseURL = applicationUrl.toString().replace(/\/$/, '')

export default defineConfig({
testDir: './tests',
@@ -41,7 +60,9 @@ export default defineConfig({
actionTimeout: 10_000,
navigationTimeout: 20_000,
screenshot: 'off',
trace: 'retain-on-failure',
// Auth traces contain request bodies and bearer tokens. Keep them disabled
// by default; an operator may opt in only in a protected local/CI workspace.
trace: process.env.E2E_TRACE === '1' ? 'retain-on-failure' : 'off',
video: 'retain-on-failure',
},
projects: [


+ 53
- 0
tests/content-control-plane-security.spec.ts View File

@@ -0,0 +1,53 @@
import http from 'node:http'
import type { AddressInfo } from 'node:net'

import { expect, test } from '@playwright/test'

import { openContentControlPlane, resolveContentControlPlaneBaseUrl } from './content-control-plane'

const listen = (server: http.Server): Promise<number> => new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port))
})

const close = (server: http.Server): Promise<void> => new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()))
})

test('content control plane never forwards login bodies across redirects', async () => {
let redirectedRequests = 0
const redirectTarget = http.createServer((_request, response) => {
redirectedRequests += 1
response.writeHead(204).end()
})
const targetPort = await listen(redirectTarget)
const gateway = http.createServer((_request, response) => {
response.writeHead(307, { Location: `http://127.0.0.1:${targetPort}/captured` }).end()
})
const gatewayPort = await listen(gateway)
const request = await openContentControlPlane(`http://127.0.0.1:${gatewayPort}`)

try {
const response = await request.post('/login', {
data: { username: 'redirect-sentinel', password: 'must-not-be-forwarded' },
})
expect(response.status()).toBe(307)
expect(redirectedRequests).toBe(0)
} finally {
await request.dispose()
await close(gateway)
await close(redirectTarget)
}
})

test('content control plane rejects local/remote drift and cleartext remote UI', () => {
expect(() => resolveContentControlPlaneBaseUrl({
BASE_URL: 'https://ui.example.test',
E2E_API_BASE_URL: 'http://127.0.0.1:6100',
})).toThrow(/cross the local\/remote boundary/)

expect(() => resolveContentControlPlaneBaseUrl({
BASE_URL: 'http://ui.example.test',
E2E_API_BASE_URL: 'https://api.example.test',
})).toThrow(/Remote BASE_URL must use HTTPS/)
})

+ 64
- 0
tests/content-control-plane.ts View File

@@ -0,0 +1,64 @@
import { request as playwrightRequest } from '@playwright/test'
import type { APIRequestContext } from '@playwright/test'

const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]'])

const parseHttpUrl = (raw: string, label: string): URL => {
let url: URL
try {
url = new URL(raw)
} catch {
throw new Error(`${label} must be an absolute HTTP(S) URL`)
}
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
throw new Error(`${label} must be an HTTP(S) URL without embedded credentials`)
}
return url
}

const isLoopback = (url: URL): boolean => LOOPBACK_HOSTS.has(url.hostname.toLowerCase())

export const resolveContentControlPlaneBaseUrl = (
environment: NodeJS.ProcessEnv = process.env,
): string => {
const applicationUrl = parseHttpUrl(
environment.BASE_URL?.trim() || 'http://127.0.0.1:6180',
'BASE_URL',
)
const controlPlaneUrl = parseHttpUrl(
environment.E2E_API_BASE_URL?.trim() || 'http://127.0.0.1:6100',
'E2E_API_BASE_URL',
)

if (
!isLoopback(applicationUrl)
&& applicationUrl.protocol !== 'https:'
&& environment.E2E_ALLOW_INSECURE_REMOTE !== '1'
) {
throw new Error('Remote BASE_URL must use HTTPS unless E2E_ALLOW_INSECURE_REMOTE=1 explicitly accepts the risk')
}
if (!isLoopback(controlPlaneUrl) && controlPlaneUrl.protocol !== 'https:') {
throw new Error('E2E_API_BASE_URL must use HTTPS unless it targets a loopback host')
}
if (
isLoopback(applicationUrl) !== isLoopback(controlPlaneUrl)
&& environment.E2E_ALLOW_CROSS_ENV_CONTROL_PLANE !== '1'
) {
throw new Error(
'BASE_URL and E2E_API_BASE_URL cross the local/remote boundary; '
+ 'set E2E_ALLOW_CROSS_ENV_CONTROL_PLANE=1 only for an intentional protected tunnel',
)
}

return controlPlaneUrl.toString().replace(/\/$/, '')
}

export const openContentControlPlane = async (
baseURL = resolveContentControlPlaneBaseUrl(),
): Promise<APIRequestContext> => playwrightRequest.newContext({
baseURL,
timeout: 30_000,
// The gateway control plane is expected to be direct. Following a 307/308
// could forward a login body or a manually supplied bearer token elsewhere.
maxRedirects: 0,
})

+ 418
- 0
tests/content-legacy-api.spec.ts View File

@@ -0,0 +1,418 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

import type { APIRequestContext, Page, Response, TestInfo } from '@playwright/test'

import {
closeContentApiSession,
type ContentApiSession,
type ContentDetailRecord,
openContentApiSession,
readContentDetail,
} from './content-editor-api'
import { captureScreenshot, expect, test } from './fixtures'
import { loginAsAdmin } from './helpers'
import { openContentControlPlane } from './content-control-plane'

const glbPath = fileURLToPath(new URL('../../unreal_tran/tests/fixtures/LittlestTokyo.draco.glb', import.meta.url))
const glbName = path.basename(glbPath)
const runToken = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`.toUpperCase()
const modelName = `UTE2E iframe 接口模型 ${runToken}`
const savedModelName = `${modelName} 已保存`
const sceneName = `UTE2E DEMO 接口场景 ${runToken}`

interface ApiEnvelope<T> {
data?: T
message?: string
requestId?: string
}

interface CreatedProject {
id: string
code: string
}

const responsePath = (response: Response) => new URL(response.url()).pathname

const projectMutation = (response: Response, method: 'PUT' | 'POST', projectId: string, suffix = '') => (
response.request().method() === method
&& responsePath(response) === `/api/tran/v1/content/projects/${projectId}${suffix}`
)

async function expectSuccessfulResponse(responsePromise: Promise<Response>, operation: string): Promise<Response> {
const response = await responsePromise
expect(response.ok(), `${operation}应成功,实际 HTTP ${response.status()}`).toBeTruthy()
return response
}

async function readEnvelope<T>(response: Awaited<ReturnType<APIRequestContext['post']>>, operation: string): Promise<T> {
const body = await response.json() as ApiEnvelope<T>
if (!response.ok() || body.data === undefined) {
throw new Error(`${operation}失败(HTTP ${response.status()}):${body.message || '响应没有 data'}${body.requestId ? `,requestId=${body.requestId}` : ''}`)
}
return body.data
}

async function createProject(
request: APIRequestContext,
session: ContentApiSession,
type: 'MODEL' | 'SCENE',
name: string,
): Promise<CreatedProject> {
const content = type === 'MODEL'
? { resourceUri: '', nodeCount: 0, materialCount: 0, animationCount: 0 }
: { sceneType: '', environment: '', objectCount: 0, objects: [] }
const response = await request.post('/api/tran/v1/content/projects', {
headers: session.headers,
data: {
type,
name,
description: 'Playwright 验证 DEMO 体验下的服务端工程、资产、版本与发布闭环',
categoryCode: type === 'MODEL' ? 'EQUIPMENT' : 'WORKSHOP',
coverUri: '',
content,
dependencies: [],
assets: [],
version: 0,
},
})
const detail = await readEnvelope<ContentDetailRecord>(response, `创建${type}工程`)
return { id: String(detail.project.id), code: String(detail.project.code) }
}

async function cleanupProject(
request: APIRequestContext,
session: ContentApiSession,
projectId: string | null,
): Promise<string> {
if (!projectId) return 'not-created'
let response = await request.get(`/api/tran/v1/content/projects/${projectId}`, { headers: session.headers })
for (let attempt = 1; response.status() === 503 && attempt < 3; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, attempt * 1_000))
response = await request.get(`/api/tran/v1/content/projects/${projectId}`, { headers: session.headers })
}
if (response.status() === 404) return 'already-removed'
let detail = await readEnvelope<ContentDetailRecord>(response, `清理前读取工程 ${projectId}`)
if (detail.project.status === 'PUBLISHED') {
response = await request.post(`/api/tran/v1/content/projects/${projectId}/review`, {
headers: session.headers,
data: { action: 'REVOKE', version: detail.project.version, comment: 'UTE2E 完成后撤回临时发布数据' },
})
detail = await readEnvelope<ContentDetailRecord>(response, `撤回工程 ${projectId}`)
}
if (detail.project.publishedVersionId) {
throw new Error(`工程 ${projectId} 清理前仍残留 publishedVersionId=${detail.project.publishedVersionId}`)
}
response = await request.delete(`/api/tran/v1/content/projects/${projectId}`, {
headers: session.headers,
params: { version: String(detail.project.version) },
})
if (!response.ok() && response.status() !== 404) {
const body = await response.text()
throw new Error(`删除工程 ${projectId} 失败(HTTP ${response.status()}):${body.slice(0, 300)}`)
}
return response.status() === 404 ? 'already-removed' : 'removed'
}

const findFixedModelReference = (
value: unknown,
projectId: string,
versionId: string,
): Record<string, unknown> | null => {
if (Array.isArray(value)) {
for (const item of value) {
const found = findFixedModelReference(item, projectId, versionId)
if (found) return found
}
return null
}
if (!value || typeof value !== 'object') return null
const record = value as Record<string, unknown>
if (String(record.targetProjectId || '') === projectId && String(record.targetVersionId || '') === versionId) return record
for (const item of Object.values(record)) {
const found = findFixedModelReference(item, projectId, versionId)
if (found) return found
}
return null
}

async function waitForModelReady(page: Page, projectId: string) {
const iframe = page.locator(`iframe[data-model-project-id="${projectId}"]`)
await expect(iframe).toBeVisible({ timeout: 30_000 })
const src = await iframe.getAttribute('src')
expect(src).toContain('api=1')
expect(src).toContain(`projectId=${projectId}`)
expect(src).not.toMatch(/token|authorization|bearer/i)
await expect.poll(async () => {
const iframeHandle = await iframe.elementHandle()
const contentFrame = await iframeHandle?.contentFrame()
if (!contentFrame?.url()) return ''
return new URL(contentFrame.url()).pathname
}, {
message: '模型 iframe 必须停留在 editor.html,不得被入口守卫重定向',
timeout: 30_000,
}).toBe('/legacy-model-editor/editor.html')
const editor = page.frameLocator(`iframe[data-model-project-id="${projectId}"]`)
await expect(editor.locator('.editor-shell[data-model-api-mode="api"]')).toHaveAttribute('data-model-api-state', 'ready', { timeout: 180_000 })
await expect(editor.locator('#loading-overlay')).toHaveClass(/hidden/, { timeout: 180_000 })
await expect(editor.locator('#render-host canvas')).toBeVisible()
return editor
}

async function waitForSceneReady(page: Page) {
const shell = page.locator('.se-shell[data-api-controlled="true"]')
await expect(shell).toBeVisible({ timeout: 180_000 })
await expect(shell).toHaveAttribute('data-ready', 'true', { timeout: 180_000 })
await expect(shell.locator('#se-viewport canvas')).toBeVisible({ timeout: 180_000 })
await expect(shell).not.toHaveClass(/model-loading/, { timeout: 180_000 })
return shell
}

test('模型 iframe 与场景 DEMO 通过原生 Fetch 完成资产、固定依赖、刷新恢复和发布闭环', async ({ page }, testInfo: TestInfo) => {
test.setTimeout(10 * 60_000)
expect(fs.existsSync(glbPath), `真实 GLB fixture 不存在:${glbPath}`).toBeTruthy()

// Seed/assert/cleanup through the gateway directly. The behavior under test
// remains the browser's same-origin native Fetch path through :6180; keeping
// the control plane off Vite's dev proxy avoids unrelated APIRequestContext
// keep-alive/encoding differences on Windows.
const apiRequest = await openContentControlPlane()

let session: ContentApiSession | null = null
let modelProject: CreatedProject | null = null
let sceneProject: CreatedProject | null = null
let testFailure: unknown = null
const cleanup: string[] = []

try {
session = await openContentApiSession(apiRequest)
modelProject = await createProject(apiRequest, session, 'MODEL', modelName)
await loginAsAdmin(page)

const migrationResponse = page.waitForResponse(
(response) => projectMutation(response, 'PUT', modelProject!.id),
{ timeout: 180_000 },
)
await page.goto(`/content/models/${modelProject.id}/edit`)
let modelEditor = await waitForModelReady(page, modelProject.id)
const modelMigration = await expectSuccessfulResponse(migrationResponse, '浅模型首次迁移保存')
expect(modelMigration.request().resourceType()).toBe('fetch')
expect(modelMigration.frame().url()).toContain('/legacy-model-editor/editor.html')

let modelDetail = await readContentDetail(apiRequest, session, modelProject.id)
expect(modelDetail.currentVersion.content.schema).toBe('digital-twin-editor-project')
expect(modelDetail.currentVersion.content.version).toBe(4)
expect(modelDetail.project.name).toBe(modelName)
expect(modelDetail.currentVersion.content.name).toBe(modelName)
expect(modelDetail.currentVersion.assets.some((asset) => asset.type === 'MODEL_FILE')).toBeTruthy()

const renameResponse = page.waitForResponse(
(response) => projectMutation(response, 'PUT', modelProject!.id),
{ timeout: 120_000 },
)
await modelEditor.locator('#project-name').fill(savedModelName)
await modelEditor.locator('#project-name').press('Enter')
await expectSuccessfulResponse(renameResponse, '模型改名保存')
await page.reload({ waitUntil: 'domcontentloaded' })
modelEditor = await waitForModelReady(page, modelProject.id)
await expect(modelEditor.locator('#project-name')).toHaveValue(savedModelName)

const uploadResponse = page.waitForResponse((response) => (
response.request().method() === 'POST'
&& responsePath(response) === `/api/tran/v1/content/projects/${modelProject!.id}/assets/upload`
), { timeout: 300_000 })
const uploadAssetRegistrationResponse = page.waitForResponse((response) => {
if (!projectMutation(response, 'PUT', modelProject!.id)) return false
try {
const payload = response.request().postDataJSON() as {
content?: { modelResource?: { sourceName?: string } }
assets?: Array<{ code?: string; uploadTicket?: string }>
}
return payload.content?.modelResource?.sourceName !== glbName
&& Boolean(payload.assets?.some((asset) => asset.uploadTicket))
} catch {
return false
}
}, { timeout: 300_000 })
const uploadDocumentSaveResponse = page.waitForResponse((response) => {
if (!projectMutation(response, 'PUT', modelProject!.id)) return false
try {
const payload = response.request().postDataJSON() as {
content?: { modelResource?: { sourceName?: string; assetCode?: string; code?: string } }
}
return payload.content?.modelResource?.sourceName === glbName
&& Boolean(payload.content.modelResource.assetCode || payload.content.modelResource.code)
} catch {
return false
}
}, { timeout: 300_000 })
await modelEditor.locator('#model-file-input').setInputFiles(glbPath)
const uploadedResponse = await expectSuccessfulResponse(uploadResponse, '模型 GLB 上传')
const uploadedEnvelope = await uploadedResponse.json() as ApiEnvelope<{
code?: string
storageUri?: string
uploadTicket?: string
}>
const uploadedCode = String(uploadedEnvelope.data?.code || '')
const uploadedStorageUri = String(uploadedEnvelope.data?.storageUri || '')
expect(uploadedCode, '上传响应必须返回稳定资产编码').toBeTruthy()
expect(uploadedStorageUri, '上传响应必须返回稳定存储地址').toBeTruthy()
expect(uploadedEnvelope.data?.uploadTicket, '上传响应必须返回一次性绑定票据').toMatch(/^v1\./)
const uploadAssetRegistration = await expectSuccessfulResponse(
uploadAssetRegistrationResponse,
'上传票据即时写入工程资产',
)
const uploadDocumentSave = await expectSuccessfulResponse(uploadDocumentSaveResponse, '上传模型绑定到工程文档')
expect(
uploadAssetRegistration.request(),
'票据即时登记与模型文档绑定必须是两次独立 PUT',
).not.toBe(uploadDocumentSave.request())
const uploadAssetRegistrationPayload = uploadAssetRegistration.request().postDataJSON() as {
content?: { modelResource?: { sourceName?: string } }
assets?: Array<{ code?: string; storageUri?: string; uploadTicket?: string }>
}
const registeredUpload = uploadAssetRegistrationPayload.assets?.find((asset) => asset.code === uploadedCode)
expect(registeredUpload?.uploadTicket, '第一次 PUT 必须携带上传票据').toBe(uploadedEnvelope.data?.uploadTicket)
expect(registeredUpload?.storageUri, '第一次 PUT 必须登记上传响应中的存储地址').toBe(uploadedStorageUri)
expect(
uploadAssetRegistrationPayload.content?.modelResource?.sourceName,
'第一次 PUT 不得提前把尚未解析完成的 GLB 绑定到文档',
).not.toBe(glbName)
const uploadDocumentPayload = uploadDocumentSave.request().postDataJSON() as {
content?: { modelResource?: { assetCode?: string; code?: string; storageUri?: string } }
}
expect(
String(uploadDocumentPayload.content?.modelResource?.assetCode || uploadDocumentPayload.content?.modelResource?.code || ''),
).toBe(uploadedCode)
expect(uploadDocumentPayload.content?.modelResource?.storageUri).toBe(uploadedStorageUri)
await expect(modelEditor.locator('.editor-shell')).toHaveAttribute('data-model-api-state', 'ready', { timeout: 180_000 })
await expect(modelEditor.locator('.editor-shell')).toHaveAttribute('data-model-last-operation', 'import-model-commit')
await expect(modelEditor.locator('.editor-shell')).toHaveAttribute('data-model-import-busy', 'false')

modelDetail = await readContentDetail(apiRequest, session, modelProject.id)
const uploadedModelAsset = modelDetail.currentVersion.assets.find((asset) => asset.name === glbName)
expect(uploadedModelAsset, '上传资产应进入模型工程当前版本').toBeTruthy()
expect(uploadedModelAsset!.storageUri).toMatch(/^(?:content:\/\/sha256\/|fsvc:\/\/)/)
expect(uploadedModelAsset!.sha256).toMatch(/^[0-9a-f]{64}$/)
expect(JSON.stringify(modelDetail.currentVersion.content)).not.toContain('blob:')

await modelEditor.locator('[data-action="publish"]').click()
await expect(modelEditor.locator('#publish-modal.open')).toBeVisible()
const modelPublishSave = page.waitForResponse(
(response) => projectMutation(response, 'PUT', modelProject!.id),
{ timeout: 180_000 },
)
const modelPublish = page.waitForResponse(
(response) => projectMutation(response, 'POST', modelProject!.id, '/publish'),
{ timeout: 180_000 },
)
await modelEditor.locator('[data-action="publish-model-asset"]').click()
await expectSuccessfulResponse(modelPublishSave, '模型发布前强制保存')
await expectSuccessfulResponse(modelPublish, '模型发布')
await expect(modelEditor.locator('#publish-ready-copy')).toContainText('接口发布成功', { timeout: 60_000 })

modelDetail = await readContentDetail(apiRequest, session, modelProject.id)
expect(modelDetail.project.status).toBe('PUBLISHED')
expect(modelDetail.project.publishedVersionId).toBe(modelDetail.currentVersion.id)
const publishedModelVersionId = String(modelDetail.project.publishedVersionId)
const publishedAssetCode = uploadedModelAsset!.code
await captureScreenshot(page, testInfo, 'model-iframe-api-published')

sceneProject = await createProject(apiRequest, session, 'SCENE', sceneName)
await page.goto(`/content/scenes/${sceneProject.id}/edit`)
let sceneEditor = await waitForSceneReady(page)
await sceneEditor.locator('[data-left-tab="assets"]').click()
const publishedCard = sceneEditor.locator(
`[data-target-project-id="${modelProject.id}"][data-target-version-id="${publishedModelVersionId}"]`,
)
await expect(publishedCard).toBeVisible({ timeout: 120_000 })
await expect(publishedCard).toHaveAttribute('data-asset-code', publishedAssetCode)
await publishedCard.dblclick()
await expect(sceneEditor).not.toHaveClass(/model-loading/, { timeout: 180_000 })
await expect.poll(async () => page.evaluate(({ projectId, versionId }) => {
const diagnostics = (globalThis as typeof globalThis & {
__sceneEditorDiagnostics?: () => { importedObjects?: Array<{ targetProjectId?: string; targetVersionId?: string }> } | null
}).__sceneEditorDiagnostics?.()
return diagnostics?.importedObjects?.some((item) => item.targetProjectId === projectId && item.targetVersionId === versionId) || false
}, { projectId: modelProject!.id, versionId: publishedModelVersionId }), { timeout: 180_000 }).toBeTruthy()

const sceneSave = page.waitForResponse(
(response) => projectMutation(response, 'PUT', sceneProject!.id),
{ timeout: 180_000 },
)
await sceneEditor.locator('[data-action="save"]').click()
const savedSceneResponse = await expectSuccessfulResponse(sceneSave, '场景保存')
expect(savedSceneResponse.request().resourceType()).toBe('fetch')
expect(savedSceneResponse.frame()).toBe(page.mainFrame())

let sceneDetail = await readContentDetail(apiRequest, session, sceneProject.id)
expect(sceneDetail.currentVersion.content.schema).toBe('edit3dv4.scene')
const fixedReference = findFixedModelReference(sceneDetail.currentVersion.content, modelProject.id, publishedModelVersionId)
expect(fixedReference, '场景 JSON 应保存精确模型项目/版本引用').toBeTruthy()
expect(fixedReference!.assetCode).toBe(publishedAssetCode)
expect(String(fixedReference!.resourceUrl || '')).not.toContain('blob:')
const modelDependencies = sceneDetail.currentVersion.dependencies.filter((item) => item.relationType === 'SCENE_MODEL')
expect(modelDependencies).toHaveLength(1)
expect(modelDependencies[0].targetProjectId).toBe(modelProject.id)
expect(modelDependencies[0].targetVersionId).toBe(publishedModelVersionId)
expect(modelDependencies[0].required).toBeTruthy()

await page.reload({ waitUntil: 'domcontentloaded' })
sceneEditor = await waitForSceneReady(page)
await expect.poll(async () => page.evaluate((projectId) => {
const diagnostics = (globalThis as typeof globalThis & {
__sceneEditorDiagnostics?: () => { importedObjects?: Array<{ targetProjectId?: string }> } | null
}).__sceneEditorDiagnostics?.()
return diagnostics?.importedObjects?.some((item) => item.targetProjectId === projectId) || false
}, modelProject!.id), { timeout: 180_000 }).toBeTruthy()

const scenePublishSave = page.waitForResponse(
(response) => projectMutation(response, 'PUT', sceneProject!.id),
{ timeout: 180_000 },
)
const scenePublish = page.waitForResponse(
(response) => projectMutation(response, 'POST', sceneProject!.id, '/publish'),
{ timeout: 180_000 },
)
await sceneEditor.locator('[data-action="publish"]').click()
await expectSuccessfulResponse(scenePublishSave, '场景发布前保存')
await expectSuccessfulResponse(scenePublish, '场景发布')
await expect(sceneEditor.locator('.se-toast.success').filter({ hasText: '场景已发布' }).last()).toBeVisible({ timeout: 60_000 })
sceneDetail = await readContentDetail(apiRequest, session, sceneProject.id)
expect(sceneDetail.project.status).toBe('PUBLISHED')
expect(sceneDetail.project.publishedVersionId).toBe(sceneDetail.currentVersion.id)
await captureScreenshot(page, testInfo, 'scene-demo-api-published')
} catch (error) {
testFailure = error
} finally {
await page.goto('/').catch(() => undefined)
if (session) {
// The end-to-end path may outlive a short access token (especially when a
// deliberate timeout is being diagnosed). Use a fresh session for cleanup
// so temporary projects are still removed after the functional failure.
let cleanupSession = session
try {
cleanupSession = await openContentApiSession(apiRequest)
} catch {
// Fall back to the original session; cleanupProject still retries a
// transient authorization-service 503 before reporting the real failure.
}
// 先删场景解除 SCENE_MODEL 入向引用,再删模型。
for (const [label, id] of [['scene', sceneProject?.id || null], ['model', modelProject?.id || null]] as const) {
try {
cleanup.push(`${label}:${await cleanupProject(apiRequest, cleanupSession, id)}`)
} catch (error) {
cleanup.push(`${label}:failed:${error instanceof Error ? error.message : String(error)}`)
}
}
if (cleanupSession !== session) await closeContentApiSession(apiRequest, cleanupSession)
await closeContentApiSession(apiRequest, session)
}
await apiRequest.dispose()
await testInfo.attach('cleanup-result', { body: Buffer.from(cleanup.join('\n')), contentType: 'text/plain' })
}

if (testFailure) throw testFailure
expect(cleanup.every((item) => !item.includes(':failed:')), cleanup.join('\n')).toBeTruthy()
})

Loading…
Cancel
Save