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))}
${(check.screenshots || []).map(image => `
${escape(check.title)}
${escape(check.title)} · ${image}
`).join('')}
`).join('') const report = `${escape(evidence.title)}

${escape(evidence.title)} ${evidence.status}

真实教员身份 · 已有狐狸 161 / 牛奶卡车 162 · localhost 与局域网

通过 ${evidence.checks.filter(check => check.status === 'PASS').length} / ${evidence.checks.length} 项 · 业务真实写入 0 次

资料 → 原型 → 设计 → 开发

依据用户要求及现有标签约定:点击顶部“返回列表”完成导航后关闭当前编辑标签;导航取消或保存失败时保留。手动切换标签不关闭工程。

本次仅修改 LegacyTrainingPageView.vue 的返回回调,先等待路由与离开确认完成,再关闭捕获的原标签。保存、发布、标签 store 与 PageTabs 的路径保持现状。

测试与构建

${evidence.commands.map(command => `

${escape(command.command)}:${command.status} · ${escape(command.details)}

`).join('')}

JSON 证据:evidence.json。所有截图来自实际浏览器,测试凭据没有写入报告。

${checks}
` fs.writeFileSync(path.join(reportDir, 'index.html'), report) console.log(JSON.stringify({ status: evidence.status, passed: evidence.checks.filter(check => check.status === 'PASS').length, checks: evidence.checks.length, screenshots: evidence.checks.flatMap(check => check.screenshots || []).length, blockedWrites: evidence.blockedWrites.length, report: 'reports/training-return-tabs-20260906/index.html' })) if (evidence.status !== 'PASS') process.exitCode = 1