diff --git a/.env.example b/.env.example index b5c1419..cddf415 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index 97d8c75..435bd7a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index ebf2c32..e8a2741 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/playwright.config.ts b/playwright.config.ts index 4e73a54..afd66b1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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: [ diff --git a/tests/content-control-plane-security.spec.ts b/tests/content-control-plane-security.spec.ts new file mode 100644 index 0000000..145a395 --- /dev/null +++ b/tests/content-control-plane-security.spec.ts @@ -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 => 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 => 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/) +}) diff --git a/tests/content-control-plane.ts b/tests/content-control-plane.ts new file mode 100644 index 0000000..ff2f5b4 --- /dev/null +++ b/tests/content-control-plane.ts @@ -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 => 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, +}) diff --git a/tests/content-legacy-api.spec.ts b/tests/content-legacy-api.spec.ts new file mode 100644 index 0000000..a31dea4 --- /dev/null +++ b/tests/content-legacy-api.spec.ts @@ -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 { + 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, operation: string): Promise { + const response = await responsePromise + expect(response.ok(), `${operation}应成功,实际 HTTP ${response.status()}`).toBeTruthy() + return response +} + +async function readEnvelope(response: Awaited>, operation: string): Promise { + const body = await response.json() as ApiEnvelope + 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 { + 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(response, `创建${type}工程`) + return { id: String(detail.project.id), code: String(detail.project.code) } +} + +async function cleanupProject( + request: APIRequestContext, + session: ContentApiSession, + projectId: string | null, +): Promise { + 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(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(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 | 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 + 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() +})