${escape(check.title)} ${check.status}
查看真实观察记录
${escape(JSON.stringify(check.details, null, 2))}import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { chromium, expect } from '@playwright/test'
import { localCredentials } from './review-api.mjs'
// Real UI navigation with existing fixtures; all business mutations are blocked.
// Only the failure response for "save and leave" is simulated, never sent upstream.
const reportDir = path.resolve('reports/training-return-tabs-20260906')
fs.mkdirSync(path.join(reportDir, 'screenshots'), { recursive: true })
const listPath = '/content/training-projects'
const editPath = id => `${listPath}/${id}/legacy`
const evidence = {
title: '训练编排返回列表与页面标签聚焦回归', status: 'RUNNING', startedAt: new Date().toISOString(),
environment: { browser: 'Microsoft Edge / Playwright', role: '真实管理员登录后切换教员', origins: ['http://127.0.0.1:6180', 'http://192.168.31.168:6180'] },
checks: [], blockedWrites: [], pageErrors: [], projects: [],
preparationNotes: [],
scope: ['仅验证顶部返回列表及现有离开确认。打开已有训练 161/162,业务写请求全部由浏览器拦截。', '“保存并离开”的失败由浏览器模拟 HTTP 503;不代表真实服务端故障。没有真实保存、发布、上传或修改这两条项目。', '本轮未测试成功保存或成功发布;其业务路径未被本次代码修改。'],
commands: [{ command: 'pnpm run build', status: 'PASS', details: '根代理执行,含 vue-tsc、Vite 与 iframe 校验;既有构建警告不影响退出码 0。' }, { command: 'git diff --check', status: 'PASS', details: '根代理执行,退出码 0。' }],
}
const credentials = localCredentials()
const browser = await chromium.launch({ channel: 'msedge', headless: true })
let currentPage, currentTitle = '准备'
const save = () => fs.writeFileSync(path.join(reportDir, 'evidence.json'), `${JSON.stringify(evidence, null, 2)}\n`)
function safeError(error) {
let result = error instanceof Error ? error.message : String(error)
for (const value of [credentials.username, credentials.password]) if (value) result = result.split(value).join('[已隐藏]')
return result.replace(/Bearer\s+\S+/gi, 'Bearer [已隐藏]').replace(/eyJ[\w-]+\.[\w-]+\.[\w-]+/g, '[已隐藏]')
}
async function screenshot(page, name) {
const relative = `screenshots/${name}.png`
await page.screenshot({ path: path.join(reportDir, relative), fullPage: true })
return relative
}
function passed(title, details, screenshots) { evidence.checks.push({ title, status: 'PASS', details, screenshots }); save() }
async function tabs(page) {
return page.evaluate(() => {
const data = JSON.parse(localStorage.getItem('unreal-tran:web:tabs:v1') || '{}')
const entries = Object.values(data).flatMap(bucket => bucket.tabs || [])
return { path: location.pathname, entries: entries.map(tab => ({ key: tab.key, fullPath: tab.fullPath, title: tab.title, locked: tab.locked })), labels: [...document.querySelectorAll('.page-tab-link')].map(tab => ({ title: tab.textContent.trim(), active: tab.getAttribute('aria-selected') === 'true' })) }
})
}
function has(snapshot, target) { return snapshot.entries.some(tab => tab.key === target) }
async function editorReady(page, id) {
await expect(page.locator('.te-shell')).toBeVisible({ timeout: 90_000 })
await page.waitForFunction(id => String(globalThis.__training?.project?.id) === String(id) && globalThis.__training?.sceneEditor?.container?.dataset?.state === 'ready', id, { timeout: 90_000 })
}
async function openEditor(page, base, id) { await page.goto(`${base}${editPath(id)}`); await editorReady(page, id) }
async function openList(page, base) { await page.goto(`${base}${listPath}`); await expect(page.locator('.platform-shell')).toBeVisible() }
async function activate(page, target) {
const before = await tabs(page)
const index = before.entries.findIndex(tab => tab.key === target)
assert.ok(index >= 0, `Expected tab ${target}`)
await page.locator('.page-tab-link').nth(index).click()
await expect(page).toHaveURL(new RegExp(`${target.replaceAll('/', '\\/')}(?:\\?.*)?$`))
}
async function returnToList(page) { await page.locator('[data-action=return-to-list]').click() }
async function expectReturned(page, closedId, retainedId) {
await expect(page).toHaveURL(new RegExp(`${listPath.replaceAll('/', '\\/')}(?:\\?.*)?$`))
await expect.poll(async () => has(await tabs(page), editPath(closedId))).toBe(false)
const snapshot = await tabs(page)
assert.ok(has(snapshot, listPath))
if (retainedId) assert.ok(has(snapshot, editPath(retainedId)))
await expect(page.locator('.training-project-card').first()).toBeVisible({ timeout: 30_000 })
await expect(page.locator('.training-project-grid > .el-loading-mask')).toBeHidden({ timeout: 30_000 })
await page.waitForFunction(() => [...document.querySelectorAll('.training-card-cover img')].every(image => image.complete), null, { timeout: 20_000 })
return snapshot
}
async function makeContext(base) {
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 }, locale: 'zh-CN' })
let token = ''
const authResponses = []
await context.route('**/*', async route => {
const request = route.request(), pathname = new URL(request.url()).pathname
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && (pathname.startsWith('/api/tran/') || pathname.startsWith('/fileapi/'))) {
evidence.blockedWrites.push({ origin: base, path: pathname, method: request.method(), response: 503, forwarded: false })
return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'E2E_SIMULATED_FAILURE', message: '浏览器模拟保存失败:测试禁止写入现有项目', data: null }) })
}
return route.continue()
})
const page = await context.newPage()
currentPage = page
page.setDefaultTimeout(25_000)
page.on('pageerror', error => evidence.pageErrors.push(safeError(error)))
page.on('response', async response => {
if (new URL(response.url()).pathname.startsWith('/api/auth/')) authResponses.push({ path: new URL(response.url()).pathname, status: response.status() })
if (new URL(response.url()).pathname.startsWith('/api/auth/v1/auth/')) {
const payload = await response.json().catch(() => null)
if (payload?.data?.accessToken) token = payload.data.accessToken
}
})
await page.route('**/src/features/guide-legacy/demo-src/training-editor.js*', async route => {
const response = await route.fetch(), source = await response.text(), marker = 'const editor = new TrainingProjectEditor(root, options);'
assert.ok(source.includes(marker))
await route.fulfill({ response, body: source.replace(marker, `${marker} globalThis.__training = editor;`) })
})
async function login() {
await page.goto(`${base}/login`)
await page.locator('[data-role-code=admin]').click()
await page.locator('input[name=username]').fill(credentials.username)
await page.locator('input[name=password]').fill(credentials.password)
for (let attempt = 0; attempt < 3; attempt++) {
await page.getByRole('button', { name: '登录系统', exact: true }).click()
try { await expect(page.locator('.platform-shell')).toBeVisible({ timeout: 20_000 }); return }
catch { if (attempt === 2) throw new Error('真实登录三次未完成') }
}
}
await login()
for (let attempt = 0; attempt < 3; attempt++) {
try {
await page.goto(`${base}/profile`)
const teacher = page.locator('.profile-role-switcher button').filter({ hasText: '教员' })
await expect(teacher).toBeVisible({ timeout: 20_000 })
if (!(await teacher.getAttribute('class')).includes('active')) await teacher.click()
await expect(teacher).toHaveClass(/active/, { timeout: 20_000 })
break
} catch (error) {
evidence.preparationNotes.push({ origin: base, attempt: attempt + 1, path: new URL(page.url()).pathname, authResponses: authResponses.slice(-12), error: safeError(error) })
if (await page.locator('.platform-shell').isVisible()) await screenshot(page, `profile-retry-${attempt + 1}`)
save()
if (attempt === 2) throw error
if (new URL(page.url()).pathname === '/login') await login()
}
}
const readProject = async id => {
let response
for (let attempt = 0; attempt < 3; attempt++) {
response = await fetch(`${base}/api/tran/v1/content/projects/${id}`, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(20_000) })
if (response.status === 200) break
evidence.preparationNotes.push({ origin: base, stage: 'readProject', path: `/api/tran/v1/content/projects/${id}`, attempt: attempt + 1, status: response.status })
save()
if (response.status !== 503 || attempt === 2) break
await new Promise(resolve => setTimeout(resolve, 1200))
}
assert.equal(response.status, 200)
const detail = (await response.json()).data, project = detail.project
assert.ok(typeof detail.currentVersion?.checksum === 'string' && detail.currentVersion.checksum.length > 0, 'Actual content checksum is required for unchanged-project verification')
return { id: String(project.id), version: project.version, currentVersionId: project.currentVersionId, publishedVersionId: project.publishedVersionId, coverUri: project.coverUri, checksum: detail.currentVersion.checksum }
}
return { context, page, readProject }
}
save()
try {
const base = evidence.environment.origins[0]
const { context, page, readProject } = await makeContext(base)
try {
const beforeProjects = await Promise.all(['161', '162'].map(readProject))
currentTitle = '手动切换标签保留原编辑页'
await openList(page, base)
await openEditor(page, base, '161')
await openEditor(page, base, '162')
const before = await tabs(page)
assert.ok(has(before, listPath) && has(before, editPath('161')) && has(before, editPath('162')))
await activate(page, editPath('161'))
await editorReady(page, '161')
const switched = await tabs(page)
assert.ok(has(switched, editPath('161')) && has(switched, editPath('162')))
passed(currentTitle, { before, after: switched, method: '真实页面标签点击' }, [await screenshot(page, '01-manual-switch-keeps-editors')])
currentTitle = '顶部返回只关闭当前编辑标签,保留列表及另一编辑标签'
await returnToList(page)
const returned = await expectReturned(page, '161', '162')
passed(currentTitle, returned, [await screenshot(page, '02-return-closes-current')])
currentTitle = '刷新后已关闭标签不复活'
await page.reload()
await expect(page.locator('.platform-shell')).toBeVisible()
const refreshed = await expectReturned(page, '161', '162')
passed(currentTitle, refreshed, [await screenshot(page, '03-refresh-no-resurrection')])
currentTitle = '未保存时 Escape 取消返回,原页与修改保留'
await openEditor(page, base, '161')
const input = page.locator('input[data-field=title]').filter({ visible: true }).first()
const originalTitle = await input.inputValue()
await input.fill(`${originalTitle}(标签返回临时测试)`)
await input.press('Tab')
await returnToList(page)
await expect(page.locator('.el-message-box')).toContainText('离开训练编排?')
const confirmation = await screenshot(page, '04-unsaved-return-confirmation')
await page.keyboard.press('Escape')
await expect(page.locator('.el-message-box')).toBeHidden()
assert.equal(new URL(page.url()).pathname, editPath('161'))
assert.ok(has(await tabs(page), editPath('161')))
await expect(page.locator('input[data-field=title]').filter({ visible: true }).first()).toHaveValue(`${originalTitle}(标签返回临时测试)`)
passed(currentTitle, await tabs(page), [confirmation, await screenshot(page, '05-escape-keeps-editor')])
currentTitle = '保存并离开失败时不导航、不删除编辑标签(浏览器模拟 503)'
await returnToList(page)
await page.getByRole('button', { name: '保存并离开', exact: true }).click()
const saveFailureMessage = page.locator('.el-message--error').filter({ hasText: '浏览器模拟保存失败' })
await expect(saveFailureMessage).toBeVisible({ timeout: 45_000 })
assert.equal(new URL(page.url()).pathname, editPath('161'))
assert.ok(has(await tabs(page), editPath('161')))
assert.ok(evidence.blockedWrites.some(write => write.method === 'PUT' && write.path.endsWith('/projects/161')))
passed(currentTitle, { tabs: await tabs(page), writes: evidence.blockedWrites, simulated: true }, [await screenshot(page, '06-save-failure-keeps-editor')])
await saveFailureMessage.locator('.el-message__closeBtn').click()
currentTitle = '明确放弃修改后返回并关闭当前编辑标签'
await returnToList(page)
await page.getByRole('button', { name: '放弃修改', exact: true }).click()
const discarded = await expectReturned(page, '161', '162')
passed(currentTitle, discarded, [await screenshot(page, '07-discard-closes-current')])
const afterProjects = await Promise.all(['161', '162'].map(readProject))
assert.deepEqual(afterProjects, beforeProjects)
evidence.projects = beforeProjects.map((before, index) => ({ ...before, unchanged: true, after: afterProjects[index] }))
passed('服务端两项目版本、封面与内容校验值未变', { projects: evidence.projects, forwardedBusinessWrites: 0 }, [])
} catch (error) {
if (await page.locator('.platform-shell').isVisible()) evidence.failureScreenshot = await screenshot(page, 'failure-localhost')
throw error
} finally { await context.close() }
currentTitle = '局域网地址返回列表也关闭当前编辑标签'
const lan = evidence.environment.origins[1]
const { context: lanContext, page: lanPage } = await makeContext(lan)
try {
await openList(lanPage, lan)
await openEditor(lanPage, lan, '161')
await returnToList(lanPage)
passed(currentTitle, { origin: lan, tabs: await expectReturned(lanPage, '161') }, [await screenshot(lanPage, '08-lan-return-closes-current')])
} catch (error) {
if (await lanPage.locator('.platform-shell').isVisible()) evidence.failureScreenshot = await screenshot(lanPage, 'failure-lan')
throw error
} finally { await lanContext.close() }
assert.deepEqual(evidence.pageErrors, [])
evidence.status = 'PASS'
} catch (error) {
const screenshots = evidence.failureScreenshot ? [evidence.failureScreenshot] : []
if (currentPage && !currentPage.isClosed() && await currentPage.locator('.platform-shell').isVisible().catch(() => false)) screenshots.push(await screenshot(currentPage, 'failure').catch(() => ''))
evidence.checks.push({ title: currentTitle, status: 'FAIL', details: safeError(error), screenshots: screenshots.filter(Boolean) })
evidence.status = 'FAIL'
} finally {
evidence.finishedAt = new Date().toISOString()
save()
await browser.close()
}
const escape = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char])
const checks = evidence.checks.map(check => `${escape(check.title)} ${check.status}
查看真实观察记录
${escape(JSON.stringify(check.details, null, 2))}
真实教员身份 · 已有狐狸 161 / 牛奶卡车 162 · localhost 与局域网
通过 ${evidence.checks.filter(check => check.status === 'PASS').length} / ${evidence.checks.length} 项 · 业务真实写入 0 次
依据用户要求及现有标签约定:点击顶部“返回列表”完成导航后关闭当前编辑标签;导航取消或保存失败时保留。手动切换标签不关闭工程。
本次仅修改 LegacyTrainingPageView.vue 的返回回调,先等待路由与离开确认完成,再关闭捕获的原标签。保存、发布、标签 store 与 PageTabs 的路径保持现状。
${escape(command.command)}:${command.status} · ${escape(command.details)}
`).join('')}JSON 证据:evidence.json。所有截图来自实际浏览器,测试凭据没有写入报告。