import { test, expect, type Page } from '@playwright/test' import crypto from 'node:crypto' import { action, call, closeSession, diagnostics, evidence, freshScene, openEditor, ready, save, shot } from './scene-editor-closure-helpers' // @ts-ignore Credentials and temporary-user passwords remain in the private fixture only. import { fixture, writeFixture, login } from '../tools/scene-editor-closure-live.mjs' type Kind = 'update' | 'publish' | 'no-scene' const grants: Record = { update: ['dashboard.view', 'content.scene', 'content.scene.update'], publish: ['dashboard.view', 'content.scene', 'content.scene.publish'], 'no-scene': ['dashboard.view'], } // Each mutation reloads the current fixture synchronously, so parallel independent // test authors do not overwrite project registrations made while an API call waits. function registerPrivateUser(user: any) { const current = fixture() current.extraUsers = [...(current.extraUsers || []), user] writeFixture(current) } function registerPrivateRole(id: string) { const current = fixture() current.roleIds = [...(current.roleIds || []), id] writeFixture(current) } async function prepareActor(page: Page, kind: Kind, includeReadOnlyRole = false) { const maintenance = await login('root') const rootToken = maintenance.accessToken let issuedToken: string | null = null let prepared = false try { const me = await call('auth/v1/auth/me', rootToken) expect(me.status).toBe(200) const roles = await call('auth/v1/roles/all', rootToken) expect(roles.status).toBe(200) const teacher = (roles.data.records || roles.data).find((role: any) => role.code === 'teacher') expect(teacher?.id).toBeTruthy() const suffix = crypto.randomBytes(4).toString('hex') const role = await call('auth/v1/roles', rootToken, { code: `scene_${kind.replace('-', '_')}_${suffix}`, name: `场景验收·${kind === 'update' ? '仅编辑' : kind === 'publish' ? '仅发布' : '无场景权限'}-${suffix}`, shortName: '测', description: 'UTE2E-SCENE-CLOSURE-20260906', dataScope: 'ALL', enabled: true, sortOrder: 95, departmentScopeIds: [], }) expect(role.status, 'Create isolated role').toBe(200) registerPrivateRole(role.data.id) const permissionSave = await call(`auth/v1/permissions/roles/${role.data.id}`, rootToken, { permissionCodes: grants[kind], version: role.data.dataVersion ?? role.data.version, }, 'PUT') expect(permissionSave.status, 'Save isolated action permissions').toBe(200) let readOnlyRole: any = null if (includeReadOnlyRole) { const extra = await call('auth/v1/roles', rootToken, { code: `scene_read_${suffix}`, name: `场景验收·仅查看-${suffix}`, shortName: '测', description: 'UTE2E-SCENE-CLOSURE-20260906', dataScope: 'ALL', enabled: true, sortOrder: 96, departmentScopeIds: [], }) expect(extra.status).toBe(200) readOnlyRole = extra.data registerPrivateRole(readOnlyRole.id) const saved = await call(`auth/v1/permissions/roles/${readOnlyRole.id}`, rootToken, { permissionCodes: ['dashboard.view', 'content.scene'], version: readOnlyRole.dataVersion ?? readOnlyRole.version, }, 'PUT') expect(saved.status).toBe(200) } const password = `Sc!${crypto.randomBytes(7).toString('hex')}` const created = await call('auth/v1/users', rootToken, { username: `scene_perm_${suffix}`, displayName: `场景权限验收${suffix.slice(0, 4)}`, departmentId: me.data.departmentId, roleIds: [teacher.id, role.data.id, ...(readOnlyRole ? [readOnlyRole.id] : [])], defaultRoleId: teacher.id, enabled: true, password, remark: 'UTE2E-SCENE-CLOSURE-20260906', }) expect(created.status, 'Create isolated multi-role account').toBe(200) registerPrivateUser({ ...created.data, password }) const signedIn = await call('auth/v1/auth/login', null, { userId: created.data.id, password, roleCode: 'teacher', departmentId: me.data.departmentId, rememberMe: false, }) expect(signedIn.status, 'Sign in through the existing teacher entry').toBe(200) let session = signedIn.data issuedToken = session.accessToken const switched = await call('auth/v1/auth/active-role', session.accessToken, { roleId: role.data.id }, 'PUT') expect(switched.status, 'Activate the isolated limited role').toBe(200) if (switched.data?.accessToken) session = switched.data issuedToken = session.accessToken const actor = await call('auth/v1/auth/me', session.accessToken) expect(actor.status).toBe(200) expect(actor.data.authorizationMode).toBe('SINGLE_ACTIVE') expect(String(actor.data.activeRoleId)).toBe(String(role.data.id)) expect(actor.data.permissions).not.toContain('content.model') expect(actor.data.permissions).not.toContain('content.model.update') expect(actor.data.permissions).not.toContain(kind === 'update' ? 'content.scene.publish' : 'content.scene.update') await page.addInitScript(({ accessToken, refreshToken }) => { if (!sessionStorage.getItem('scene-permission-closure-initialized')) { sessionStorage.setItem('unreal-tran:web:access-token:v1', accessToken) sessionStorage.setItem('unreal-tran:web:refresh-token:v1', refreshToken || '') sessionStorage.setItem('scene-permission-closure-initialized', '1') } }, { accessToken: session.accessToken, refreshToken: session.refreshToken }) const copy = kind === 'no-scene' ? null : await freshScene(rootToken, 'fox', `权限-${kind}`) prepared = true return { token: session.accessToken as string, copy, permissionCodes: grants[kind], roleId: role.data.id, readOnlyRole } } finally { if (!prepared && issuedToken) await call('auth/v1/auth/logout', issuedToken, {}).catch(() => {}) await call('auth/v1/auth/logout', rootToken, {}).catch(() => {}) } } test.afterEach(async ({ page }) => closeSession(page)) test('仅场景编辑权限:固定模型可加载和保存,不能发布或访问模型页面', async ({ page }, info) => { const actor = await prepareActor(page, 'update'), id = actor.copy.project.id await openEditor(page, id) const before = await diagnostics(page) expect(before.readOnly).toBe(false) const model = before.objects.find((object: any) => object.targetProjectId === '157') expect(model).toBeTruthy() expect(model.parts.length).toBeGreaterThan(0) await expect(action(page, 'save')).toBeEnabled() await expect(action(page, 'publish')).toBeDisabled() await expect(action(page, 'new')).toBeDisabled() await page.locator(`#se-tree [data-object-id="${model.sceneId}"]`).click() await action(page, 'focus').click() const name = `仅场景编辑权限保存-${id}` const input = page.locator('[data-inspector-field="name"]') await input.fill(name) await input.press('Tab') await save(page) const saved = await call(`tran/v1/content/projects/${id}`, actor.token) expect(saved.status).toBe(200) const dependencies = (values: any[]) => values.map(value => [value.targetProjectId, value.targetVersionId, value.relationType, value.required]) expect(dependencies(saved.data.currentVersion.dependencies)).toEqual(dependencies(actor.copy.currentVersion.dependencies)) expect(saved.data.currentVersion.content.objects.find((object: any) => object.sceneId === model.sceneId).name).toBe(name) await page.reload(); await ready(page) expect((await diagnostics(page)).objects.find((object: any) => object.sceneId === model.sceneId).name).toBe(name) await page.locator(`#se-tree [data-object-id="${model.sceneId}"]`).click() await action(page, 'focus').click() const forbiddenModel = await call('tran/v1/content/projects/157', actor.token) expect(forbiddenModel.status).toBe(403) const forbiddenPublish = await call(`tran/v1/content/projects/${id}/publish`, actor.token, { version: saved.data.project.version }) expect(forbiddenPublish.status).toBe(403) await shot(page, info, '51-scene-update-only-saved') evidence('scene-update-only', { featureIds: ['permission.update', 'permission.publish', 'publish.fixed-model'], status: 'PASS', kind: 'browser', testTitle: info.title, projectId: id, permissionCodes: actor.permissionCodes, dependencies: dependencies(saved.data.currentVersion.dependencies), savedName: name, directModelReadStatus: forbiddenModel.status, directPublishStatus: forbiddenPublish.status }) }) test('仅场景发布权限:浏览器发布已存副本,不发送保存或上传请求', async ({ page }, info) => { const actor = await prepareActor(page, 'publish'), id = actor.copy.project.id const writes: Array<{ method: string; path: string }> = [] page.on('request', request => { const pathname = new URL(request.url()).pathname if (pathname.startsWith(`/api/tran/v1/content/projects/${id}`) && !['GET', 'HEAD'].includes(request.method())) { writes.push({ method: request.method(), path: pathname }) } }) await openEditor(page, id) expect((await diagnostics(page)).readOnly).toBe(true) await expect(action(page, 'save')).toBeDisabled() await expect(action(page, 'publish')).toBeEnabled() const response = page.waitForResponse(value => value.url().endsWith(`/content/projects/${id}/publish`) && value.request().method() === 'POST') await action(page, 'publish').click() expect((await response).status()).toBe(200) await expect.poll(async () => (await diagnostics(page)).publishing).toBe(false) await expect(action(page, 'save')).toBeDisabled() const published = await call(`tran/v1/content/projects/${id}`, actor.token) expect(published.status).toBe(200) expect(published.data.project.status).toBe('PUBLISHED') expect(published.data.currentVersion.content).toEqual(actor.copy.currentVersion.content) expect(writes).toEqual([{ method: 'POST', path: `/api/tran/v1/content/projects/${id}/publish` }]) await shot(page, info, '52-scene-publish-only-no-save') evidence('scene-publish-only', { featureIds: ['permission.publish', 'publish.only', 'publish.fixed-model'], status: 'PASS', kind: 'browser', testTitle: info.title, projectId: id, permissionCodes: actor.permissionCodes, versionId: published.data.project.publishedVersionId, writes }) }) test('无场景页面权限:编辑路由和直接内容接口均拒绝访问', async ({ page }, info) => { const actor = await prepareActor(page, 'no-scene'), id = fixture().originals.fox.id await page.goto(`/content/scenes/${id}/edit`) await expect(page).not.toHaveURL(new RegExp(`/content/scenes/${id}/edit`)) await expect(page.locator('.se-shell')).toHaveCount(0) const denied = await call(`tran/v1/content/projects/${id}`, actor.token) expect(denied.status).toBe(403) await shot(page, info, '53-scene-route-access-denied') evidence('scene-no-page-permission', { featureIds: ['permission.publish'], status: 'PASS', kind: 'browser', testTitle: info.title, permissionCodes: actor.permissionCodes, route: new URL(page.url()).pathname, directReadStatus: denied.status }) }) async function editFoxName(page: Page, name: string) { const model = (await diagnostics(page)).objects.find((object: any) => object.targetProjectId === '157') expect(model).toBeTruthy() await page.locator(`#se-tree [data-object-id="${model.sceneId}"]`).click() const input = page.locator('[data-inspector-field="name"]') await input.fill(name); await input.press('Tab') return model.sceneId as string } async function openRoleDialog(page: Page) { await page.getByRole('button', { name: /^账号菜单:/ }).click() await page.getByRole('menuitem', { name: '切换角色', exact: true }).click() const dialog = page.getByRole('dialog', { name: '切换角色', exact: true }) await expect(dialog).toBeVisible() return dialog } test('后台场景未保存时切换角色:关闭确认保留草稿,保存切换后重建只读权限', async ({ page }, info) => { const actor = await prepareActor(page, 'update', true), id = actor.copy.project.id const roleRequests: string[] = [] page.on('request', request => { if (new URL(request.url()).pathname === '/api/auth/v1/auth/active-role' && request.method() === 'PUT') roleRequests.push('PUT') }) await openEditor(page, id) const name = `后台场景保存后切换-${id}` const objectId = await editFoxName(page, name) await page.getByRole('tab', { name: '工作总览', exact: true }).click() let switcher = await openRoleDialog(page) await switcher.getByRole('button', { name: new RegExp(actor.readOnlyRole.name) }).click() const leave = page.getByRole('dialog', { name: '离开场景编辑器', exact: true }) await expect(leave).toBeVisible() await leave.locator('.el-message-box__headerbtn').click() await expect(leave).not.toBeVisible() expect(roleRequests).toEqual([]) const unchangedRole = await call('auth/v1/auth/me', actor.token) expect(String(unchangedRole.data.activeRoleId)).toBe(String(actor.roleId)) const unchangedProject = await call(`tran/v1/content/projects/${id}`, actor.token) expect(unchangedProject.data.project.version).toBe(actor.copy.project.version) expect(unchangedProject.data.currentVersion.content).toEqual(actor.copy.currentVersion.content) await switcher.getByRole('button', { name: '关闭', exact: true }).click() await page.getByRole('tab', { name: '数字场景编辑', exact: true }).click() await ready(page) expect((await diagnostics(page)).dirty).toBe(true) expect((await diagnostics(page)).objects.find((object: any) => object.sceneId === objectId).name).toBe(name) await shot(page, info, '54-scene-role-switch-cancel-keeps-draft') await page.getByRole('tab', { name: '工作总览', exact: true }).click() switcher = await openRoleDialog(page) await switcher.getByRole('button', { name: new RegExp(actor.readOnlyRole.name) }).click() await expect(leave).toBeVisible() await leave.getByRole('button', { name: '保存并离开', exact: true }).click() await expect(page).toHaveURL(/\/dashboard$/) await expect(page.getByRole('button', { name: /^账号菜单:/ })).toContainText(actor.readOnlyRole.name) expect(roleRequests).toEqual(['PUT']) const token = await page.evaluate(() => sessionStorage.getItem('unreal-tran:web:access-token:v1')) const switched = await call('auth/v1/auth/me', token) expect(String(switched.data.activeRoleId)).toBe(String(actor.readOnlyRole.id)) expect(switched.data.permissions).not.toContain('content.scene.update') await openEditor(page, id) expect((await diagnostics(page)).readOnly).toBe(true) expect((await diagnostics(page)).objects.find((object: any) => object.sceneId === objectId).name).toBe(name) await expect(action(page, 'save')).toBeDisabled() await expect(action(page, 'publish')).toBeDisabled() const saved = await call(`tran/v1/content/projects/${id}`, token) expect(saved.data.project.version).toBe(actor.copy.project.version + 1) expect(saved.data.currentVersion.content.objects.find((object: any) => object.sceneId === objectId).name).toBe(name) await shot(page, info, '55-scene-role-switch-saved-readonly') evidence('scene-role-switch', { featureIds: ['leave.role-switch', 'leave.cancel', 'leave.save', 'permission.readonly'], status: 'PASS', kind: 'browser', testTitle: info.title, projectId: id, savedName: name, roleChangeRequests: roleRequests.length, savedVersion: saved.data.project.version, readOnly: true }) }) async function recoveries(page: Page, id: string) { return page.evaluate(projectId => Object.keys(localStorage) .filter(key => key.includes(`edit3dv4:scene-api-recovery:${projectId}:session:`)) .map(key => { const value = JSON.parse(localStorage.getItem(key) || '{}') return { key, nonce: value.nonce, version: value.projectVersion, name: value.document?.objects?.find((object: any) => object.targetProjectId === '157')?.name } }), id) } test('双标签各自修改场景:保存和明确丢弃当前页均保留另一页真实恢复稿', async ({ page, context }, info) => { const actor = await prepareActor(page, 'update'), id = actor.copy.project.id const other = await context.newPage() try { await other.addInitScript(token => { sessionStorage.setItem('unreal-tran:web:access-token:v1', token) }, actor.token) await openEditor(page, id) await openEditor(other, id) const nameA = `标签A已保存-${id}`, nameB = `标签B保持未保存-${id}` await editFoxName(page, nameA) await editFoxName(other, nameB) await expect.poll(async () => (await recoveries(page, id)).map(value => value.name).sort()).toEqual([nameA, nameB].sort()) const originalB = (await recoveries(page, id)).find(value => value.name === nameB)! const before = await call(`tran/v1/content/projects/${id}`, actor.token) expect(before.data.project.version).toBe(actor.copy.project.version) expect(before.data.currentVersion.content).toEqual(actor.copy.currentVersion.content) await save(page) await expect.poll(async () => recoveries(page, id)).toEqual([originalB]) expect((await diagnostics(other)).dirty).toBe(true) expect((await diagnostics(other)).objects.find((object: any) => object.targetProjectId === '157').name).toBe(nameB) const saved = await call(`tran/v1/content/projects/${id}`, actor.token) expect(saved.data.project.version).toBe(actor.copy.project.version + 1) expect(saved.data.currentVersion.content.objects.find((object: any) => object.targetProjectId === '157').name).toBe(nameA) await editFoxName(page, `标签A明确丢弃-${id}`) await expect.poll(async () => (await recoveries(page, id)).length).toBe(2) await page.reload() const recovery = page.getByRole('dialog', { name: '恢复未提交场景', exact: true }) await expect(recovery).toBeVisible() await recovery.getByRole('button', { name: '删除副本并使用服务端', exact: true }).click() await ready(page) await expect.poll(async () => recoveries(other, id)).toEqual([originalB]) const final = await call(`tran/v1/content/projects/${id}`, actor.token) expect(final.data.project.version).toBe(saved.data.project.version) expect(final.data.currentVersion.content).toEqual(saved.data.currentVersion.content) expect((await diagnostics(other)).dirty).toBe(true) await shot(other, info, '56-scene-other-tab-recovery-preserved') evidence('scene-multi-session', { featureIds: ['recovery.multi-session', 'recovery.autobuffer', 'recovery.discard', 'project.save'], status: 'PASS', kind: 'browser', testTitle: info.title, projectId: id, savedName: nameA, otherDraftName: originalB.name, otherDraftVersion: originalB.version, remainingRecoveryCount: 1, serverVersion: final.data.project.version }) } finally { await other.close() } })