You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

582 regels
31 KiB

  1. import fs from 'node:fs'
  2. import type { APIRequestContext, Locator, Response as BrowserResponse, TestInfo } from '@playwright/test'
  3. import { attachJson, captureScreenshot, expect, runId, runPrefix, test } from './fixtures'
  4. import {
  5. authHeaders,
  6. chooseSelectOption,
  7. confirmMessageBox,
  8. envelopeData,
  9. fillFormItem,
  10. loginAsAdmin,
  11. tableRowByText,
  12. visibleDialog,
  13. } from './helpers'
  14. test.use({ trace: 'off', video: 'off' })
  15. type JsonRecord = Record<string, unknown>
  16. type Headers = Record<string, string>
  17. type ResourceKind = 'sensitive-words' | 'hot-words' | 'wake-words' | 'tools'
  18. interface Activity {
  19. module: string
  20. action: string
  21. result: 'PASS' | 'FAIL' | 'CLEANED' | 'CLEANUP_FAILED'
  22. httpStatus?: number
  23. resourceId?: string
  24. detail?: string
  25. }
  26. interface JsonResponse {
  27. status(): number
  28. json(): Promise<unknown>
  29. }
  30. const suffix = (runId.replace(/[^A-Za-z0-9]/g, '').slice(-8) || 'MANUAL').toUpperCase()
  31. const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' && !Array.isArray(value)
  32. ? value as JsonRecord
  33. : {}
  34. const asRecords = (value: unknown): JsonRecord[] => Array.isArray(value) ? value.map(asRecord) : []
  35. async function responseData(response: JsonResponse, expected: number | number[], label: string) {
  36. const statuses = Array.isArray(expected) ? expected : [expected]
  37. expect(statuses, `${label}:HTTP ${response.status()}`).toContain(response.status())
  38. return asRecord(envelopeData(await response.json()))
  39. }
  40. async function expectSuccessMessage(page: { locator: (selector: string) => Locator }, text: string | RegExp) {
  41. await expect(page.locator('.el-message--success').filter({ hasText: text }).last()).toBeVisible()
  42. }
  43. async function filterByKeyword(page: import('@playwright/test').Page, placeholder: string, keyword: string) {
  44. const input = page.getByPlaceholder(placeholder)
  45. await input.fill(keyword)
  46. await page.waitForTimeout(350)
  47. }
  48. async function findResource(
  49. request: APIRequestContext,
  50. headers: Headers,
  51. kind: ResourceKind,
  52. keyword: string,
  53. predicate: (item: JsonRecord) => boolean,
  54. ) {
  55. const response = await request.get(`/api/v1/${kind}`, {
  56. headers,
  57. params: { keyword, page: '1', pageSize: '200' },
  58. })
  59. const data = await responseData(response, 200, `读取 ${kind} 列表`)
  60. return asRecords(data.items).find(predicate) || null
  61. }
  62. async function cleanupBySuffix(
  63. request: APIRequestContext,
  64. headers: Headers,
  65. kind: ResourceKind,
  66. activities: Activity[],
  67. cleanupErrors: string[],
  68. ) {
  69. try {
  70. const response = await request.get(`/api/v1/${kind}`, {
  71. headers,
  72. params: { keyword: suffix, page: '1', pageSize: '200' },
  73. })
  74. const data = await responseData(response, 200, `清理前读取 ${kind}`)
  75. for (const item of asRecords(data.items)) {
  76. const haystack = [item.name, item.word, item.term, item.phrase, item.category]
  77. .map((value) => String(value ?? '').toUpperCase())
  78. .join(' ')
  79. if (!haystack.includes(suffix)) continue
  80. const id = String(item.id ?? '')
  81. const dataVersion = Number(item.dataVersion)
  82. if (!id || !Number.isInteger(dataVersion)) continue
  83. const removed = await request.delete(`/api/v1/${kind}/${encodeURIComponent(id)}`, {
  84. headers,
  85. params: { dataVersion: String(dataVersion) },
  86. })
  87. if (![200, 404].includes(removed.status())) throw new Error(`${id} 删除失败:HTTP ${removed.status()}`)
  88. activities.push({ module: kind, action: '失败兜底清理', result: 'CLEANED', httpStatus: removed.status(), resourceId: id })
  89. }
  90. } catch (error) {
  91. const message = `${kind} 兜底清理异常:${error instanceof Error ? error.message : String(error)}`
  92. cleanupErrors.push(message)
  93. activities.push({ module: kind, action: '失败兜底清理', result: 'CLEANUP_FAILED', detail: message })
  94. }
  95. }
  96. async function attachLifecycleReport(
  97. testInfo: TestInfo,
  98. title: string,
  99. activities: Activity[],
  100. cleanupErrors: string[],
  101. ) {
  102. await attachJson(testInfo, `${title}-页面生命周期与清理`, {
  103. runId,
  104. runPrefix,
  105. title,
  106. activities,
  107. cleanupComplete: cleanupErrors.length === 0,
  108. cleanupErrors,
  109. security: '全程使用真实 API;报告未记录登录密码、访问令牌、下载对象地址或外部服务凭证。',
  110. })
  111. }
  112. async function cardByText(page: import('@playwright/test').Page, text: string) {
  113. const card = page.locator('.tool-card').filter({ hasText: text }).first()
  114. await expect(card).toBeVisible()
  115. return card
  116. }
  117. test.describe('原型运营页面真实交互生命周期', () => {
  118. test('会话记录:组合筛选、无结果空态、日期范围校验与重置', async ({ page, request }, testInfo) => {
  119. const module = '会话记录'
  120. const activities: Activity[] = []
  121. const cleanupErrors: string[] = []
  122. const headers = await authHeaders(request)
  123. await loginAsAdmin(page)
  124. await page.goto('/agents/chat-history')
  125. await expect(page.getByRole('heading', { name: '会话记录' })).toBeVisible()
  126. const listResponsePromise = page.waitForResponse((response) => response.request().method() === 'GET'
  127. && response.url().includes('/api/v1/chat-sessions?')
  128. && response.url().includes(encodeURIComponent(`NO-SUCH-${suffix}`)))
  129. await page.getByPlaceholder('搜索会话编号、访客、智能体或聊天内容').fill(`NO-SUCH-${suffix}`)
  130. const listResponse = await listResponsePromise
  131. const listData = await responseData(listResponse, 200, '页面会话关键词筛选')
  132. expect(listData.total).toBe(0)
  133. await expect(page.getByText('没有符合条件的聊天记录')).toBeVisible()
  134. activities.push({ module, action: '页面关键词筛选并展示真实空态', result: 'PASS', httpStatus: listResponse.status() })
  135. const selects = page.locator('.filter-row .el-select')
  136. await selects.nth(1).click()
  137. await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '网页端' }).click()
  138. await selects.nth(2).click()
  139. await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '正常结束' }).click()
  140. await page.waitForTimeout(350)
  141. await expect(page.getByText('没有符合条件的聊天记录')).toBeVisible()
  142. activities.push({ module, action: '页面渠道与会话结果组合筛选', result: 'PASS' })
  143. const startDate = page.getByPlaceholder('开始日期')
  144. const endDate = page.getByPlaceholder('结束日期')
  145. await startDate.fill('2026-08-16')
  146. await startDate.press('Enter')
  147. await endDate.fill('2026-08-15')
  148. await endDate.press('Enter')
  149. await expect(page.getByRole('alert')).toContainText('开始日期不能晚于结束日期')
  150. activities.push({ module, action: '页面拦截无效日期范围', result: 'PASS' })
  151. await captureScreenshot(page, testInfo, '00-chat-history-filter-empty-validation')
  152. await page.getByRole('button', { name: '重置' }).click()
  153. await expect(page.getByRole('alert')).toHaveCount(0)
  154. await expect(page.getByPlaceholder('搜索会话编号、访客、智能体或聊天内容')).toHaveValue('')
  155. const direct = await request.get('/api/v1/chat-sessions', { headers, params: { page: '1', pageSize: '20' } })
  156. await responseData(direct, 200, '重置后读取会话列表')
  157. activities.push({ module, action: '页面重置全部筛选条件', result: 'PASS', httpStatus: direct.status() })
  158. await attachLifecycleReport(testInfo, module, activities, cleanupErrors)
  159. })
  160. test('敏感词:单条增改启停、批量预览提交、筛选导出与公式注入防护', async ({ page, request }, testInfo) => {
  161. const module = '敏感词库'
  162. const activities: Activity[] = []
  163. const cleanupErrors: string[] = []
  164. const headers = await authHeaders(request)
  165. const manualWord = `违规拆卸-${suffix}`
  166. const editedReply = `已拦截 ${suffix} 测试规则,请遵循安全作业规程。`
  167. const formulaWord = `=2+2-${suffix}`
  168. const importedWord = `带电测试-${suffix}`
  169. let primaryError: unknown
  170. await cleanupBySuffix(request, headers, 'sensitive-words', activities, cleanupErrors)
  171. try {
  172. await loginAsAdmin(page)
  173. await page.goto('/knowledge/sensitive-words')
  174. await expect(page.getByRole('heading', { name: '敏感词库' })).toBeVisible()
  175. await page.getByRole('button', { name: '新增敏感词' }).click()
  176. let dialog = visibleDialog(page)
  177. await fillFormItem(dialog, '敏感词或表达式', manualWord)
  178. await fillFormItem(dialog, '所属分类', `E2E安全-${suffix}`)
  179. await chooseSelectOption(page, dialog, '风险等级', '高风险')
  180. await fillFormItem(dialog, '安全回复', '请立即停止该操作,并联系现场安全负责人。')
  181. await fillFormItem(dialog, '管理备注', `页面创建 ${suffix}`)
  182. await dialog.getByRole('button', { name: '保存规则' }).click()
  183. await expectSuccessMessage(page, '敏感词规则已创建')
  184. await filterByKeyword(page, '搜索敏感词、安全回复或备注', manualWord)
  185. let row = tableRowByText(page, manualWord)
  186. await expect(row).toBeVisible()
  187. await captureScreenshot(page, testInfo, '01-sensitive-created')
  188. const created = await findResource(request, headers, 'sensitive-words', manualWord, (item) => item.word === manualWord)
  189. expect(created, '页面创建的敏感词必须真实落库').not.toBeNull()
  190. activities.push({ module, action: '页面新增并刷新列表', result: 'PASS', resourceId: String(created?.id ?? '') })
  191. await row.getByRole('button', { name: '编辑' }).click()
  192. dialog = visibleDialog(page)
  193. await fillFormItem(dialog, '安全回复', editedReply)
  194. await fillFormItem(dialog, '管理备注', `页面编辑后持久化 ${suffix}`)
  195. await dialog.getByRole('button', { name: '保存规则' }).click()
  196. await expectSuccessMessage(page, '敏感词规则已更新')
  197. await page.reload()
  198. await filterByKeyword(page, '搜索敏感词、安全回复或备注', manualWord)
  199. row = tableRowByText(page, manualWord)
  200. await expect(row).toContainText(editedReply)
  201. activities.push({ module, action: '页面编辑并刷新验证持久化', result: 'PASS', resourceId: String(created?.id ?? '') })
  202. const statusSwitch = row.locator('.el-switch').first()
  203. await statusSwitch.click()
  204. await expectSuccessMessage(page, '敏感词规则已停用')
  205. await page.reload()
  206. await filterByKeyword(page, '搜索敏感词、安全回复或备注', manualWord)
  207. row = tableRowByText(page, manualWord)
  208. await expect(row.locator('.el-switch').first()).not.toHaveClass(/is-checked/)
  209. activities.push({ module, action: '页面停用并刷新验证持久化', result: 'PASS', resourceId: String(created?.id ?? '') })
  210. await page.getByRole('button', { name: '批量导入' }).click()
  211. dialog = visibleDialog(page)
  212. await fillFormItem(dialog, '词表内容', `${formulaWord}|E2E导入-${suffix}|高\n${importedWord}|E2E导入-${suffix}|中`)
  213. await expect(dialog.getByText('识别到 2 条')).toBeVisible()
  214. await expect(dialog.getByText('可导入 2 条,重复跳过 0 条')).toBeVisible()
  215. await captureScreenshot(page, testInfo, '02-sensitive-import-preview')
  216. const previewResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST'
  217. && response.url().includes('/api/v1/sensitive-words/import/preview'))
  218. const commitResponsePromise: Promise<BrowserResponse | Error> = page.waitForResponse((response) => response.request().method() === 'POST'
  219. && response.url().includes('/api/v1/sensitive-words/import/commit')).catch((error: unknown) => error instanceof Error ? error : new Error(String(error)))
  220. await dialog.getByRole('button', { name: '导入 2 条' }).click()
  221. const previewResponse = await previewResponsePromise
  222. await responseData(previewResponse, 200, '页面敏感词导入预检')
  223. const commitResponse = await commitResponsePromise
  224. if (commitResponse instanceof Error) throw commitResponse
  225. const commitData = await responseData(commitResponse, 200, '页面敏感词导入提交')
  226. expect(commitData).toMatchObject({ imported: 2, skipped: 0, total: 2 })
  227. await expectSuccessMessage(page, /已导入 2 条敏感词/)
  228. activities.push({ module, action: '页面批量预览并提交两条真实数据', result: 'PASS', httpStatus: commitResponse.status() })
  229. await filterByKeyword(page, '搜索敏感词、安全回复或备注', formulaWord)
  230. await expect(tableRowByText(page, formulaWord)).toBeVisible()
  231. const downloadPromise = page.waitForEvent('download')
  232. await page.getByRole('button', { name: '导出', exact: true }).click()
  233. const download = await downloadPromise
  234. const downloadedPath = await download.path()
  235. expect(downloadedPath, '敏感词导出必须生成可读取的 CSV 文件').toBeTruthy()
  236. const csv = fs.readFileSync(downloadedPath!, 'utf8').replace(/^\uFEFF/, '')
  237. expect(csv).toContain(`'${formulaWord}`)
  238. expect(csv).not.toContain(`\n${manualWord},`)
  239. activities.push({ module, action: '按页面当前筛选导出 CSV', result: 'PASS', detail: '筛选仅包含公式测试词;危险公式首字符已由服务端转义。' })
  240. await captureScreenshot(page, testInfo, '03-sensitive-filtered-exported')
  241. await page.reload()
  242. await filterByKeyword(page, '搜索敏感词、安全回复或备注', manualWord)
  243. row = tableRowByText(page, manualWord)
  244. await row.getByRole('button', { name: '删除' }).click()
  245. await confirmMessageBox(page, '确认删除')
  246. await expectSuccessMessage(page, '敏感词已删除')
  247. await expect(tableRowByText(page, manualWord)).toHaveCount(0)
  248. activities.push({ module, action: '页面删除单条规则', result: 'PASS', resourceId: String(created?.id ?? '') })
  249. } catch (error) {
  250. primaryError = error
  251. activities.push({ module, action: '页面生命周期', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) })
  252. } finally {
  253. await cleanupBySuffix(request, headers, 'sensitive-words', activities, cleanupErrors)
  254. const residue = await findResource(request, headers, 'sensitive-words', suffix, (item) => [item.word, item.category].some((value) => String(value ?? '').includes(suffix)))
  255. if (residue) cleanupErrors.push(`敏感词残留:${String(residue.id ?? '')}`)
  256. await attachLifecycleReport(testInfo, module, activities, cleanupErrors)
  257. }
  258. if (primaryError) throw primaryError
  259. expect(cleanupErrors).toEqual([])
  260. })
  261. test('热词:页面创建、别名去重、编辑、状态筛选、刷新持久化与删除', async ({ page, request }, testInfo) => {
  262. const module = '热词管理'
  263. const activities: Activity[] = []
  264. const cleanupErrors: string[] = []
  265. const headers = await authHeaders(request)
  266. const term = `液压泵-${suffix}`
  267. const editedAlias = `液压棒-${suffix}`
  268. let resourceId = ''
  269. let primaryError: unknown
  270. await cleanupBySuffix(request, headers, 'hot-words', activities, cleanupErrors)
  271. try {
  272. await loginAsAdmin(page)
  273. await page.goto('/knowledge/hot-words')
  274. await expect(page.getByRole('heading', { name: '热词管理' })).toBeVisible()
  275. await page.getByRole('button', { name: '新增热词' }).click()
  276. let dialog = visibleDialog(page)
  277. await fillFormItem(dialog, '标准热词', term)
  278. await fillFormItem(dialog, '所属分类', `液压系统-${suffix}`)
  279. await fillFormItem(dialog, '同音词 / 易错词', `液压蹦-${suffix}\n液压崩-${suffix}\n液压蹦-${suffix}`)
  280. await fillFormItem(dialog, '识别说明', '装备维修语音识别纠偏页面测试。')
  281. await dialog.getByRole('button', { name: '保存热词' }).click()
  282. await expectSuccessMessage(page, '热词已创建')
  283. await filterByKeyword(page, '搜索热词、同音词、分类、智能体或说明', term)
  284. let row = tableRowByText(page, term)
  285. await expect(row).toContainText(`液压蹦-${suffix}`)
  286. await expect(row.locator('.alias-list span')).toHaveCount(2)
  287. const created = await findResource(request, headers, 'hot-words', term, (item) => item.term === term)
  288. expect(created).not.toBeNull()
  289. resourceId = String(created?.id ?? '')
  290. expect(created?.aliases).toEqual([`液压蹦-${suffix}`, `液压崩-${suffix}`])
  291. activities.push({ module, action: '页面创建并由服务端持久化去重别名', result: 'PASS', resourceId })
  292. await captureScreenshot(page, testInfo, '04-hotword-created-aliases')
  293. await row.getByRole('button', { name: '编辑' }).click()
  294. dialog = visibleDialog(page)
  295. await fillFormItem(dialog, '同音词 / 易错词', `${editedAlias}\n泵体异响-${suffix}`)
  296. await fillFormItem(dialog, '识别说明', '页面编辑后的专业术语与易错词。')
  297. await dialog.getByRole('button', { name: '保存热词' }).click()
  298. await expectSuccessMessage(page, '热词已更新')
  299. await page.reload()
  300. await filterByKeyword(page, '搜索热词、同音词、分类、智能体或说明', term)
  301. row = tableRowByText(page, term)
  302. await expect(row).toContainText(editedAlias)
  303. activities.push({ module, action: '页面编辑并刷新验证别名持久化', result: 'PASS', resourceId })
  304. await row.locator('.el-switch').click()
  305. await expectSuccessMessage(page, '热词已停用')
  306. const statusSelect = page.locator('.toolbar .el-select').filter({ hasText: /全部状态|已停用/ }).last()
  307. await statusSelect.click()
  308. await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '已停用' }).click()
  309. await page.waitForTimeout(350)
  310. row = tableRowByText(page, term)
  311. await expect(row).toBeVisible()
  312. await page.reload()
  313. await filterByKeyword(page, '搜索热词、同音词、分类、智能体或说明', term)
  314. row = tableRowByText(page, term)
  315. await expect(row.locator('.el-switch')).not.toHaveClass(/is-checked/)
  316. activities.push({ module, action: '页面停用、状态筛选并刷新验证', result: 'PASS', resourceId })
  317. await captureScreenshot(page, testInfo, '05-hotword-disabled-filtered')
  318. await row.getByRole('button', { name: '详情' }).click()
  319. dialog = visibleDialog(page)
  320. await expect(dialog).toContainText(editedAlias)
  321. await captureScreenshot(page, testInfo, '06-hotword-detail-persisted')
  322. await dialog.locator('.el-dialog__headerbtn').click()
  323. row = tableRowByText(page, term)
  324. await row.getByRole('button', { name: '删除' }).click()
  325. await confirmMessageBox(page, '确认删除')
  326. await expectSuccessMessage(page, '热词已删除')
  327. activities.push({ module, action: '页面详情读取与删除', result: 'PASS', resourceId })
  328. resourceId = ''
  329. } catch (error) {
  330. primaryError = error
  331. activities.push({ module, action: '页面生命周期', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) })
  332. } finally {
  333. await cleanupBySuffix(request, headers, 'hot-words', activities, cleanupErrors)
  334. await attachLifecycleReport(testInfo, module, activities, cleanupErrors)
  335. }
  336. if (primaryError) throw primaryError
  337. expect(cleanupErrors).toEqual([])
  338. })
  339. test('唤醒词:自定义时段页面 CRUD、文本规则验证、启停、刷新持久化与清理', async ({ page, request }, testInfo) => {
  340. const module = '唤醒词库'
  341. const activities: Activity[] = []
  342. const cleanupErrors: string[] = []
  343. const headers = await authHeaders(request)
  344. const name = `早晚班巡检-${suffix}`
  345. const phrase = `设备巡检${suffix.slice(-4)}`
  346. const reply = `您好,${suffix} 设备巡检流程已启动。`
  347. let resourceId = ''
  348. let primaryError: unknown
  349. await cleanupBySuffix(request, headers, 'wake-words', activities, cleanupErrors)
  350. try {
  351. await loginAsAdmin(page)
  352. await page.goto('/agents/wake-words')
  353. await expect(page.getByRole('heading', { name: '唤醒词库' })).toBeVisible()
  354. await expect(page.getByRole('columnheader', { name: '生效时段' })).toBeVisible()
  355. await expect(page.getByRole('columnheader', { name: '触发次数' })).toBeVisible()
  356. await expect(page.getByRole('columnheader', { name: '最近触发' })).toBeVisible()
  357. await expect(page.getByRole('columnheader', { name: '触发记录' })).toHaveCount(0)
  358. await page.getByRole('button', { name: '新增唤醒词' }).click()
  359. let dialog = visibleDialog(page)
  360. await fillFormItem(dialog, '规则名称', name)
  361. await fillFormItem(dialog, '唤醒短语', phrase)
  362. await fillFormItem(dialog, '生效时段', '06:00-22:00')
  363. await fillFormItem(dialog, '首句回复', reply)
  364. await dialog.getByRole('button', { name: '保存配置' }).click()
  365. await expectSuccessMessage(page, '唤醒词配置已创建')
  366. await filterByKeyword(page, '搜索名称或唤醒短语', phrase)
  367. let row = tableRowByText(page, phrase)
  368. await expect(row).toContainText('06:00-22:00')
  369. const created = await findResource(request, headers, 'wake-words', phrase, (item) => item.phrase === phrase)
  370. expect(created).not.toBeNull()
  371. resourceId = String(created?.id ?? '')
  372. expect(created?.activePeriod).toBe('06:00-22:00')
  373. activities.push({ module, action: '页面创建自定义 06:00-22:00 时段并真实落库', result: 'PASS', resourceId })
  374. await captureScreenshot(page, testInfo, '07-wakeword-custom-period-created')
  375. await row.getByRole('button', { name: '详情' }).click()
  376. dialog = visibleDialog(page)
  377. await expect(dialog).toContainText('06:00-22:00')
  378. await dialog.getByRole('button', { name: '验证规则' }).click()
  379. dialog = visibleDialog(page)
  380. await expect(dialog).toContainText('不会调用麦克风或语音识别')
  381. await fillFormItem(dialog, '待验证短语', `${phrase}-不匹配`)
  382. await dialog.getByRole('button', { name: '开始验证' }).click()
  383. await expect(dialog).toContainText('未匹配')
  384. await fillFormItem(dialog, '待验证短语', phrase)
  385. await dialog.getByRole('button', { name: '开始验证' }).click()
  386. await expect(dialog).toContainText('匹配成功')
  387. await expect(dialog).toContainText(reply)
  388. await dialog.getByRole('button', { name: '关闭' }).click()
  389. await expect(dialog).toBeHidden()
  390. activities.push({ module, action: '页面详情与文本规则验证', result: 'PASS', resourceId })
  391. row = tableRowByText(page, phrase)
  392. await row.getByRole('button', { name: '编辑' }).click()
  393. dialog = visibleDialog(page)
  394. await fillFormItem(dialog, '首句回复', `${reply} 请按更新后的步骤执行。`)
  395. await dialog.getByRole('button', { name: '保存配置' }).click()
  396. await expectSuccessMessage(page, '唤醒词配置已更新')
  397. await page.reload()
  398. await filterByKeyword(page, '搜索名称或唤醒短语', phrase)
  399. row = tableRowByText(page, phrase)
  400. await expect(row).toContainText('06:00-22:00')
  401. await expect(row).toContainText('请按更新后的步骤执行')
  402. activities.push({ module, action: '页面编辑并刷新验证自定义时段未丢失', result: 'PASS', resourceId })
  403. await row.locator('.el-switch').click()
  404. await expectSuccessMessage(page, '唤醒词已停用')
  405. await page.reload()
  406. await filterByKeyword(page, '搜索名称或唤醒短语', phrase)
  407. row = tableRowByText(page, phrase)
  408. await expect(row.locator('.el-switch')).not.toHaveClass(/is-checked/)
  409. activities.push({ module, action: '页面停用并刷新验证', result: 'PASS', resourceId })
  410. await captureScreenshot(page, testInfo, '08-wakeword-disabled-persisted')
  411. await row.getByRole('button', { name: '删除' }).click()
  412. await confirmMessageBox(page, '确认删除')
  413. await expectSuccessMessage(page, '唤醒词已删除')
  414. activities.push({ module, action: '页面删除自定义时段唤醒词', result: 'PASS', resourceId })
  415. resourceId = ''
  416. } catch (error) {
  417. primaryError = error
  418. activities.push({ module, action: '页面生命周期', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) })
  419. } finally {
  420. await cleanupBySuffix(request, headers, 'wake-words', activities, cleanupErrors)
  421. await attachLifecycleReport(testInfo, module, activities, cleanupErrors)
  422. }
  423. if (primaryError) throw primaryError
  424. expect(cleanupErrors).toEqual([])
  425. })
  426. test('工具管理:SSRF 拒绝、安全地址 CRUD/启动、条件可用性检测和状态筛选', async ({ page, request }, testInfo) => {
  427. const module = '工具管理'
  428. const activities: Activity[] = []
  429. const cleanupErrors: string[] = []
  430. const headers = await authHeaders(request)
  431. const name = `维修工单-${suffix}`
  432. const editedName = `${name}-修订`
  433. const safePath = `/ape2e/${suffix.toLowerCase()}`
  434. let resourceId = ''
  435. let primaryError: unknown
  436. await cleanupBySuffix(request, headers, 'tools', activities, cleanupErrors)
  437. try {
  438. await loginAsAdmin(page)
  439. await page.goto('/tools')
  440. await expect(page.getByRole('heading', { name: '工具管理' })).toBeVisible()
  441. await page.getByRole('button', { name: '新增业务工具' }).first().click()
  442. let dialog = visibleDialog(page)
  443. await fillFormItem(dialog, '工具名称', name)
  444. await fillFormItem(dialog, '用途说明', '装备维修工单与作业进度协同入口。')
  445. await dialog.getByText('高级接入参数(技术人员)').first().click()
  446. await fillFormItem(dialog, '原始工具地址', 'http://127.0.0.1:8001')
  447. await fillFormItem(dialog, '入口路径', safePath)
  448. await chooseSelectOption(page, dialog, '打开方式', '新窗口打开')
  449. await dialog.getByRole('button', { name: '保存工具' }).click()
  450. await expect(page.locator('.el-message--error').last()).toContainText(/本机|私有|内网|地址|禁止/)
  451. const unsafeLookup = await findResource(request, headers, 'tools', name, (item) => item.name === name)
  452. expect(unsafeLookup, '被 SSRF 策略拒绝的本机地址不得落库').toBeNull()
  453. activities.push({ module, action: '页面提交本机地址并由服务端 SSRF 策略拒绝', result: 'PASS', httpStatus: 400 })
  454. await fillFormItem(dialog, '原始工具地址', 'https://example.com')
  455. await dialog.getByRole('button', { name: '保存工具' }).click()
  456. await expectSuccessMessage(page, '工具已加入市场')
  457. let card = await cardByText(page, name)
  458. const created = await findResource(request, headers, 'tools', name, (item) => item.name === name)
  459. expect(created).not.toBeNull()
  460. resourceId = String(created?.id ?? '')
  461. expect(created?.domain).toBe('https://example.com')
  462. activities.push({ module, action: '页面创建安全 HTTPS 工具', result: 'PASS', resourceId })
  463. await captureScreenshot(page, testInfo, '09-tool-safe-url-created')
  464. await card.getByRole('button', { name: '编辑' }).click()
  465. dialog = visibleDialog(page)
  466. await fillFormItem(dialog, '工具名称', editedName)
  467. await fillFormItem(dialog, '用途说明', '装备维修工单、审批与进度协同入口(已修订)。')
  468. await dialog.getByRole('button', { name: '保存工具' }).click()
  469. await expectSuccessMessage(page, '工具配置已更新')
  470. await page.reload()
  471. await filterByKeyword(page, '搜索工具名称、分类或用途', editedName)
  472. card = await cardByText(page, editedName)
  473. await expect(card).toContainText('已修订')
  474. activities.push({ module, action: '页面编辑并刷新验证持久化', result: 'PASS', resourceId })
  475. const popupPromise = page.waitForEvent('popup')
  476. await card.getByRole('button', { name: '打开工具' }).click()
  477. const popup = await popupPromise
  478. await popup.waitForLoadState('domcontentloaded').catch(() => undefined)
  479. expect(popup.url()).toContain(`example.com${safePath}`)
  480. await popup.close()
  481. activities.push({ module, action: '页面通过后端 launch 地址安全启动新窗口', result: 'PASS', resourceId })
  482. const checkResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST'
  483. && response.url().includes(`/api/v1/tools/${encodeURIComponent(resourceId)}/check`))
  484. await card.getByRole('button', { name: '测试可用性' }).click()
  485. const checkApiResponse = await checkResponsePromise
  486. await responseData(checkApiResponse, 200, '页面工具可用性检测')
  487. const checkMessage = page.locator('.el-message').last()
  488. await expect(checkMessage).toBeVisible({ timeout: 20_000 })
  489. const checkedResponse = await request.get(`/api/v1/tools/${encodeURIComponent(resourceId)}`, { headers })
  490. const checked = await responseData(checkedResponse, 200, '读取工具检测结果')
  491. expect(['available', 'unavailable']).toContain(String(checked.connectionStatus))
  492. const conditionLabel = checked.connectionStatus === 'available' ? '可正常使用' : '需要检查'
  493. const statusSelect = page.locator('.toolbar .el-select').last()
  494. await statusSelect.click()
  495. await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: conditionLabel }).click()
  496. await page.waitForTimeout(350)
  497. card = await cardByText(page, editedName)
  498. activities.push({
  499. module,
  500. action: '页面发起真实外部可用性检测并按实际结果筛选',
  501. result: 'PASS',
  502. resourceId,
  503. detail: `外部网络结果为“${conditionLabel}”;测试不把第三方网络可达性固定为成功条件。`,
  504. })
  505. await captureScreenshot(page, testInfo, '10-tool-conditional-check-filter')
  506. await card.locator('footer .el-switch').click()
  507. await expectSuccessMessage(page, '工具入口已停用')
  508. await page.reload()
  509. await filterByKeyword(page, '搜索工具名称、分类或用途', editedName)
  510. card = await cardByText(page, editedName)
  511. await expect(card).toHaveClass(/disabled/)
  512. activities.push({ module, action: '页面停用并刷新验证', result: 'PASS', resourceId })
  513. await card.getByRole('button', { name: '删除' }).click()
  514. await confirmMessageBox(page, '确认删除')
  515. await expectSuccessMessage(page, '工具已删除')
  516. activities.push({ module, action: '页面删除工具', result: 'PASS', resourceId })
  517. resourceId = ''
  518. } catch (error) {
  519. primaryError = error
  520. activities.push({ module, action: '页面生命周期', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) })
  521. } finally {
  522. await cleanupBySuffix(request, headers, 'tools', activities, cleanupErrors)
  523. await attachLifecycleReport(testInfo, module, activities, cleanupErrors)
  524. }
  525. if (primaryError) throw primaryError
  526. expect(cleanupErrors).toEqual([])
  527. })
  528. })