No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 
 
 

245 líneas
18 KiB

  1. import assert from 'node:assert/strict'
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. import { chromium, expect } from '@playwright/test'
  5. import { localCredentials } from './review-api.mjs'
  6. // Real UI navigation with existing fixtures; all business mutations are blocked.
  7. // Only the failure response for "save and leave" is simulated, never sent upstream.
  8. const reportDir = path.resolve('reports/training-return-tabs-20260906')
  9. fs.mkdirSync(path.join(reportDir, 'screenshots'), { recursive: true })
  10. const listPath = '/content/training-projects'
  11. const editPath = id => `${listPath}/${id}/legacy`
  12. const evidence = {
  13. title: '训练编排返回列表与页面标签聚焦回归', status: 'RUNNING', startedAt: new Date().toISOString(),
  14. environment: { browser: 'Microsoft Edge / Playwright', role: '真实管理员登录后切换教员', origins: ['http://127.0.0.1:6180', 'http://192.168.31.168:6180'] },
  15. checks: [], blockedWrites: [], pageErrors: [], projects: [],
  16. preparationNotes: [],
  17. scope: ['仅验证顶部返回列表及现有离开确认。打开已有训练 161/162,业务写请求全部由浏览器拦截。', '“保存并离开”的失败由浏览器模拟 HTTP 503;不代表真实服务端故障。没有真实保存、发布、上传或修改这两条项目。', '本轮未测试成功保存或成功发布;其业务路径未被本次代码修改。'],
  18. commands: [{ command: 'pnpm run build', status: 'PASS', details: '根代理执行,含 vue-tsc、Vite 与 iframe 校验;既有构建警告不影响退出码 0。' }, { command: 'git diff --check', status: 'PASS', details: '根代理执行,退出码 0。' }],
  19. }
  20. const credentials = localCredentials()
  21. const browser = await chromium.launch({ channel: 'msedge', headless: true })
  22. let currentPage, currentTitle = '准备'
  23. const save = () => fs.writeFileSync(path.join(reportDir, 'evidence.json'), `${JSON.stringify(evidence, null, 2)}\n`)
  24. function safeError(error) {
  25. let result = error instanceof Error ? error.message : String(error)
  26. for (const value of [credentials.username, credentials.password]) if (value) result = result.split(value).join('[已隐藏]')
  27. return result.replace(/Bearer\s+\S+/gi, 'Bearer [已隐藏]').replace(/eyJ[\w-]+\.[\w-]+\.[\w-]+/g, '[已隐藏]')
  28. }
  29. async function screenshot(page, name) {
  30. const relative = `screenshots/${name}.png`
  31. await page.screenshot({ path: path.join(reportDir, relative), fullPage: true })
  32. return relative
  33. }
  34. function passed(title, details, screenshots) { evidence.checks.push({ title, status: 'PASS', details, screenshots }); save() }
  35. async function tabs(page) {
  36. return page.evaluate(() => {
  37. const data = JSON.parse(localStorage.getItem('unreal-tran:web:tabs:v1') || '{}')
  38. const entries = Object.values(data).flatMap(bucket => bucket.tabs || [])
  39. 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' })) }
  40. })
  41. }
  42. function has(snapshot, target) { return snapshot.entries.some(tab => tab.key === target) }
  43. async function editorReady(page, id) {
  44. await expect(page.locator('.te-shell')).toBeVisible({ timeout: 90_000 })
  45. await page.waitForFunction(id => String(globalThis.__training?.project?.id) === String(id) && globalThis.__training?.sceneEditor?.container?.dataset?.state === 'ready', id, { timeout: 90_000 })
  46. }
  47. async function openEditor(page, base, id) { await page.goto(`${base}${editPath(id)}`); await editorReady(page, id) }
  48. async function openList(page, base) { await page.goto(`${base}${listPath}`); await expect(page.locator('.platform-shell')).toBeVisible() }
  49. async function activate(page, target) {
  50. const before = await tabs(page)
  51. const index = before.entries.findIndex(tab => tab.key === target)
  52. assert.ok(index >= 0, `Expected tab ${target}`)
  53. await page.locator('.page-tab-link').nth(index).click()
  54. await expect(page).toHaveURL(new RegExp(`${target.replaceAll('/', '\\/')}(?:\\?.*)?$`))
  55. }
  56. async function returnToList(page) { await page.locator('[data-action=return-to-list]').click() }
  57. async function expectReturned(page, closedId, retainedId) {
  58. await expect(page).toHaveURL(new RegExp(`${listPath.replaceAll('/', '\\/')}(?:\\?.*)?$`))
  59. await expect.poll(async () => has(await tabs(page), editPath(closedId))).toBe(false)
  60. const snapshot = await tabs(page)
  61. assert.ok(has(snapshot, listPath))
  62. if (retainedId) assert.ok(has(snapshot, editPath(retainedId)))
  63. await expect(page.locator('.training-project-card').first()).toBeVisible({ timeout: 30_000 })
  64. await expect(page.locator('.training-project-grid > .el-loading-mask')).toBeHidden({ timeout: 30_000 })
  65. await page.waitForFunction(() => [...document.querySelectorAll('.training-card-cover img')].every(image => image.complete), null, { timeout: 20_000 })
  66. return snapshot
  67. }
  68. async function makeContext(base) {
  69. const context = await browser.newContext({ viewport: { width: 1920, height: 1080 }, locale: 'zh-CN' })
  70. let token = ''
  71. const authResponses = []
  72. await context.route('**/*', async route => {
  73. const request = route.request(), pathname = new URL(request.url()).pathname
  74. if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && (pathname.startsWith('/api/tran/') || pathname.startsWith('/fileapi/'))) {
  75. evidence.blockedWrites.push({ origin: base, path: pathname, method: request.method(), response: 503, forwarded: false })
  76. return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'E2E_SIMULATED_FAILURE', message: '浏览器模拟保存失败:测试禁止写入现有项目', data: null }) })
  77. }
  78. return route.continue()
  79. })
  80. const page = await context.newPage()
  81. currentPage = page
  82. page.setDefaultTimeout(25_000)
  83. page.on('pageerror', error => evidence.pageErrors.push(safeError(error)))
  84. page.on('response', async response => {
  85. if (new URL(response.url()).pathname.startsWith('/api/auth/')) authResponses.push({ path: new URL(response.url()).pathname, status: response.status() })
  86. if (new URL(response.url()).pathname.startsWith('/api/auth/v1/auth/')) {
  87. const payload = await response.json().catch(() => null)
  88. if (payload?.data?.accessToken) token = payload.data.accessToken
  89. }
  90. })
  91. await page.route('**/src/features/guide-legacy/demo-src/training-editor.js*', async route => {
  92. const response = await route.fetch(), source = await response.text(), marker = 'const editor = new TrainingProjectEditor(root, options);'
  93. assert.ok(source.includes(marker))
  94. await route.fulfill({ response, body: source.replace(marker, `${marker} globalThis.__training = editor;`) })
  95. })
  96. async function login() {
  97. await page.goto(`${base}/login`)
  98. await page.locator('[data-role-code=admin]').click()
  99. await page.locator('input[name=username]').fill(credentials.username)
  100. await page.locator('input[name=password]').fill(credentials.password)
  101. for (let attempt = 0; attempt < 3; attempt++) {
  102. await page.getByRole('button', { name: '登录系统', exact: true }).click()
  103. try { await expect(page.locator('.platform-shell')).toBeVisible({ timeout: 20_000 }); return }
  104. catch { if (attempt === 2) throw new Error('真实登录三次未完成') }
  105. }
  106. }
  107. await login()
  108. for (let attempt = 0; attempt < 3; attempt++) {
  109. try {
  110. await page.goto(`${base}/profile`)
  111. const teacher = page.locator('.profile-role-switcher button').filter({ hasText: '教员' })
  112. await expect(teacher).toBeVisible({ timeout: 20_000 })
  113. if (!(await teacher.getAttribute('class')).includes('active')) await teacher.click()
  114. await expect(teacher).toHaveClass(/active/, { timeout: 20_000 })
  115. break
  116. } catch (error) {
  117. evidence.preparationNotes.push({ origin: base, attempt: attempt + 1, path: new URL(page.url()).pathname, authResponses: authResponses.slice(-12), error: safeError(error) })
  118. if (await page.locator('.platform-shell').isVisible()) await screenshot(page, `profile-retry-${attempt + 1}`)
  119. save()
  120. if (attempt === 2) throw error
  121. if (new URL(page.url()).pathname === '/login') await login()
  122. }
  123. }
  124. const readProject = async id => {
  125. let response
  126. for (let attempt = 0; attempt < 3; attempt++) {
  127. response = await fetch(`${base}/api/tran/v1/content/projects/${id}`, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(20_000) })
  128. if (response.status === 200) break
  129. evidence.preparationNotes.push({ origin: base, stage: 'readProject', path: `/api/tran/v1/content/projects/${id}`, attempt: attempt + 1, status: response.status })
  130. save()
  131. if (response.status !== 503 || attempt === 2) break
  132. await new Promise(resolve => setTimeout(resolve, 1200))
  133. }
  134. assert.equal(response.status, 200)
  135. const detail = (await response.json()).data, project = detail.project
  136. assert.ok(typeof detail.currentVersion?.checksum === 'string' && detail.currentVersion.checksum.length > 0, 'Actual content checksum is required for unchanged-project verification')
  137. return { id: String(project.id), version: project.version, currentVersionId: project.currentVersionId, publishedVersionId: project.publishedVersionId, coverUri: project.coverUri, checksum: detail.currentVersion.checksum }
  138. }
  139. return { context, page, readProject }
  140. }
  141. save()
  142. try {
  143. const base = evidence.environment.origins[0]
  144. const { context, page, readProject } = await makeContext(base)
  145. try {
  146. const beforeProjects = await Promise.all(['161', '162'].map(readProject))
  147. currentTitle = '手动切换标签保留原编辑页'
  148. await openList(page, base)
  149. await openEditor(page, base, '161')
  150. await openEditor(page, base, '162')
  151. const before = await tabs(page)
  152. assert.ok(has(before, listPath) && has(before, editPath('161')) && has(before, editPath('162')))
  153. await activate(page, editPath('161'))
  154. await editorReady(page, '161')
  155. const switched = await tabs(page)
  156. assert.ok(has(switched, editPath('161')) && has(switched, editPath('162')))
  157. passed(currentTitle, { before, after: switched, method: '真实页面标签点击' }, [await screenshot(page, '01-manual-switch-keeps-editors')])
  158. currentTitle = '顶部返回只关闭当前编辑标签,保留列表及另一编辑标签'
  159. await returnToList(page)
  160. const returned = await expectReturned(page, '161', '162')
  161. passed(currentTitle, returned, [await screenshot(page, '02-return-closes-current')])
  162. currentTitle = '刷新后已关闭标签不复活'
  163. await page.reload()
  164. await expect(page.locator('.platform-shell')).toBeVisible()
  165. const refreshed = await expectReturned(page, '161', '162')
  166. passed(currentTitle, refreshed, [await screenshot(page, '03-refresh-no-resurrection')])
  167. currentTitle = '未保存时 Escape 取消返回,原页与修改保留'
  168. await openEditor(page, base, '161')
  169. const input = page.locator('input[data-field=title]').filter({ visible: true }).first()
  170. const originalTitle = await input.inputValue()
  171. await input.fill(`${originalTitle}(标签返回临时测试)`)
  172. await input.press('Tab')
  173. await returnToList(page)
  174. await expect(page.locator('.el-message-box')).toContainText('离开训练编排?')
  175. const confirmation = await screenshot(page, '04-unsaved-return-confirmation')
  176. await page.keyboard.press('Escape')
  177. await expect(page.locator('.el-message-box')).toBeHidden()
  178. assert.equal(new URL(page.url()).pathname, editPath('161'))
  179. assert.ok(has(await tabs(page), editPath('161')))
  180. await expect(page.locator('input[data-field=title]').filter({ visible: true }).first()).toHaveValue(`${originalTitle}(标签返回临时测试)`)
  181. passed(currentTitle, await tabs(page), [confirmation, await screenshot(page, '05-escape-keeps-editor')])
  182. currentTitle = '保存并离开失败时不导航、不删除编辑标签(浏览器模拟 503)'
  183. await returnToList(page)
  184. await page.getByRole('button', { name: '保存并离开', exact: true }).click()
  185. const saveFailureMessage = page.locator('.el-message--error').filter({ hasText: '浏览器模拟保存失败' })
  186. await expect(saveFailureMessage).toBeVisible({ timeout: 45_000 })
  187. assert.equal(new URL(page.url()).pathname, editPath('161'))
  188. assert.ok(has(await tabs(page), editPath('161')))
  189. assert.ok(evidence.blockedWrites.some(write => write.method === 'PUT' && write.path.endsWith('/projects/161')))
  190. passed(currentTitle, { tabs: await tabs(page), writes: evidence.blockedWrites, simulated: true }, [await screenshot(page, '06-save-failure-keeps-editor')])
  191. await saveFailureMessage.locator('.el-message__closeBtn').click()
  192. currentTitle = '明确放弃修改后返回并关闭当前编辑标签'
  193. await returnToList(page)
  194. await page.getByRole('button', { name: '放弃修改', exact: true }).click()
  195. const discarded = await expectReturned(page, '161', '162')
  196. passed(currentTitle, discarded, [await screenshot(page, '07-discard-closes-current')])
  197. const afterProjects = await Promise.all(['161', '162'].map(readProject))
  198. assert.deepEqual(afterProjects, beforeProjects)
  199. evidence.projects = beforeProjects.map((before, index) => ({ ...before, unchanged: true, after: afterProjects[index] }))
  200. passed('服务端两项目版本、封面与内容校验值未变', { projects: evidence.projects, forwardedBusinessWrites: 0 }, [])
  201. } catch (error) {
  202. if (await page.locator('.platform-shell').isVisible()) evidence.failureScreenshot = await screenshot(page, 'failure-localhost')
  203. throw error
  204. } finally { await context.close() }
  205. currentTitle = '局域网地址返回列表也关闭当前编辑标签'
  206. const lan = evidence.environment.origins[1]
  207. const { context: lanContext, page: lanPage } = await makeContext(lan)
  208. try {
  209. await openList(lanPage, lan)
  210. await openEditor(lanPage, lan, '161')
  211. await returnToList(lanPage)
  212. passed(currentTitle, { origin: lan, tabs: await expectReturned(lanPage, '161') }, [await screenshot(lanPage, '08-lan-return-closes-current')])
  213. } catch (error) {
  214. if (await lanPage.locator('.platform-shell').isVisible()) evidence.failureScreenshot = await screenshot(lanPage, 'failure-lan')
  215. throw error
  216. } finally { await lanContext.close() }
  217. assert.deepEqual(evidence.pageErrors, [])
  218. evidence.status = 'PASS'
  219. } catch (error) {
  220. const screenshots = evidence.failureScreenshot ? [evidence.failureScreenshot] : []
  221. if (currentPage && !currentPage.isClosed() && await currentPage.locator('.platform-shell').isVisible().catch(() => false)) screenshots.push(await screenshot(currentPage, 'failure').catch(() => ''))
  222. evidence.checks.push({ title: currentTitle, status: 'FAIL', details: safeError(error), screenshots: screenshots.filter(Boolean) })
  223. evidence.status = 'FAIL'
  224. } finally {
  225. evidence.finishedAt = new Date().toISOString()
  226. save()
  227. await browser.close()
  228. }
  229. const escape = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char])
  230. const checks = evidence.checks.map(check => `<article><h3>${escape(check.title)} <span class="${check.status.toLowerCase()}">${check.status}</span></h3><details><summary>查看真实观察记录</summary><pre>${escape(JSON.stringify(check.details, null, 2))}</pre></details>${(check.screenshots || []).map(image => `<figure><a href="${image}" target="_blank"><img src="${image}" alt="${escape(check.title)}" loading="lazy"></a><figcaption>${escape(check.title)} · ${image}</figcaption></figure>`).join('')}</article>`).join('')
  231. const report = `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(evidence.title)}</title><style>*{box-sizing:border-box}body{margin:0;background:#f2f6f8;color:#203b46;font:15px/1.75 system-ui,"Microsoft YaHei",sans-serif}main{max-width:1180px;margin:auto;padding:28px 22px}header{background:#103c45;color:white;padding:26px 30px;border-radius:12px}h1{font-size:26px;margin:0 0 12px}h2{font-size:21px}h3{font-size:17px;overflow-wrap:anywhere}section,article{background:white;border:1px solid #dce6ec;border-radius:10px;padding:22px 26px;margin:20px 0}.pass{color:#14744f}.fail{color:#b5373e}header .pass{color:#a9f0d2}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f6f8fa;padding:15px;max-height:440px;overflow:auto;font-size:12px}summary{color:#137d6e;cursor:pointer}figure{margin:18px 0 0}img{display:block;width:100%;height:auto;border:1px solid #dce6ec}figcaption,footer{font-size:13px;color:#637f8c;overflow-wrap:anywhere}li,p{overflow-wrap:anywhere}@media(max-width:700px){main{padding:14px 10px}header,section,article{padding:18px}h1{font-size:23px}}</style></head><body><main><header><h1>${escape(evidence.title)} <span class="${evidence.status.toLowerCase()}">${evidence.status}</span></h1><p>真实教员身份 · 已有狐狸 161 / 牛奶卡车 162 · localhost 与局域网</p><p>通过 ${evidence.checks.filter(check => check.status === 'PASS').length} / ${evidence.checks.length} 项 · 业务真实写入 0 次</p></header><section><h2>资料 → 原型 → 设计 → 开发</h2><p>依据用户要求及现有标签约定:点击顶部“返回列表”完成导航后关闭当前编辑标签;导航取消或保存失败时保留。手动切换标签不关闭工程。</p><p>本次仅修改 LegacyTrainingPageView.vue 的返回回调,先等待路由与离开确认完成,再关闭捕获的原标签。保存、发布、标签 store 与 PageTabs 的路径保持现状。</p><ul>${evidence.scope.map(item => `<li>${escape(item)}</li>`).join('')}</ul></section><section><h2>测试与构建</h2>${evidence.commands.map(command => `<p><b>${escape(command.command)}:${command.status}</b> · ${escape(command.details)}</p>`).join('')}<p>JSON 证据:<a href="evidence.json">evidence.json</a>。所有截图来自实际浏览器,测试凭据没有写入报告。</p></section>${checks}<footer>开始 ${evidence.startedAt} · 完成 ${evidence.finishedAt} · 新增业务数据 0;浏览器内临时修改已放弃。</footer></main></body></html>`
  232. fs.writeFileSync(path.join(reportDir, 'index.html'), report)
  233. 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' }))
  234. if (evidence.status !== 'PASS') process.exitCode = 1