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.
 
 
 
 

574 rivejä
30 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 page.getByRole('button', { name: '新增唤醒词' }).click()
  355. let dialog = visibleDialog(page)
  356. await fillFormItem(dialog, '规则名称', name)
  357. await fillFormItem(dialog, '唤醒短语', phrase)
  358. await fillFormItem(dialog, '生效时段', '06:00-22:00')
  359. await fillFormItem(dialog, '首句回复', reply)
  360. await dialog.getByRole('button', { name: '保存配置' }).click()
  361. await expectSuccessMessage(page, '唤醒词配置已创建')
  362. await filterByKeyword(page, '搜索名称、唤醒短语、智能体或回复内容', phrase)
  363. let row = tableRowByText(page, phrase)
  364. await expect(row).toContainText('06:00-22:00')
  365. const created = await findResource(request, headers, 'wake-words', phrase, (item) => item.phrase === phrase)
  366. expect(created).not.toBeNull()
  367. resourceId = String(created?.id ?? '')
  368. expect(created?.activePeriod).toBe('06:00-22:00')
  369. activities.push({ module, action: '页面创建自定义 06:00-22:00 时段并真实落库', result: 'PASS', resourceId })
  370. await captureScreenshot(page, testInfo, '07-wakeword-custom-period-created')
  371. await row.getByRole('button', { name: '详情' }).click()
  372. dialog = visibleDialog(page)
  373. await expect(dialog).toContainText('06:00-22:00')
  374. await dialog.getByRole('button', { name: '测试唤醒' }).click()
  375. const alert = page.locator('.el-message-box:visible').last()
  376. await expect(alert).toContainText(reply)
  377. await expect(alert).toContainText(phrase)
  378. await alert.getByRole('button', { name: '知道了' }).click()
  379. await expect(alert).toBeHidden()
  380. await dialog.locator('.el-dialog__headerbtn').click()
  381. await expect(dialog).toBeHidden()
  382. activities.push({ module, action: '页面详情与真实唤醒测试', result: 'PASS', resourceId })
  383. row = tableRowByText(page, phrase)
  384. await row.getByRole('button', { name: '编辑' }).click()
  385. dialog = visibleDialog(page)
  386. await fillFormItem(dialog, '首句回复', `${reply} 请按更新后的步骤执行。`)
  387. await dialog.getByRole('button', { name: '保存配置' }).click()
  388. await expectSuccessMessage(page, '唤醒词配置已更新')
  389. await page.reload()
  390. await filterByKeyword(page, '搜索名称、唤醒短语、智能体或回复内容', phrase)
  391. row = tableRowByText(page, phrase)
  392. await expect(row).toContainText('06:00-22:00')
  393. await expect(row).toContainText('请按更新后的步骤执行')
  394. activities.push({ module, action: '页面编辑并刷新验证自定义时段未丢失', result: 'PASS', resourceId })
  395. await row.locator('.el-switch').click()
  396. await expectSuccessMessage(page, '唤醒词已停用')
  397. await page.reload()
  398. await filterByKeyword(page, '搜索名称、唤醒短语、智能体或回复内容', phrase)
  399. row = tableRowByText(page, phrase)
  400. await expect(row.locator('.el-switch')).not.toHaveClass(/is-checked/)
  401. activities.push({ module, action: '页面停用并刷新验证', result: 'PASS', resourceId })
  402. await captureScreenshot(page, testInfo, '08-wakeword-disabled-persisted')
  403. await row.getByRole('button', { name: '删除' }).click()
  404. await confirmMessageBox(page, '确认删除')
  405. await expectSuccessMessage(page, '唤醒词已删除')
  406. activities.push({ module, action: '页面删除自定义时段唤醒词', result: 'PASS', resourceId })
  407. resourceId = ''
  408. } catch (error) {
  409. primaryError = error
  410. activities.push({ module, action: '页面生命周期', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) })
  411. } finally {
  412. await cleanupBySuffix(request, headers, 'wake-words', activities, cleanupErrors)
  413. await attachLifecycleReport(testInfo, module, activities, cleanupErrors)
  414. }
  415. if (primaryError) throw primaryError
  416. expect(cleanupErrors).toEqual([])
  417. })
  418. test('工具管理:SSRF 拒绝、安全地址 CRUD/启动、条件可用性检测和状态筛选', async ({ page, request }, testInfo) => {
  419. const module = '工具管理'
  420. const activities: Activity[] = []
  421. const cleanupErrors: string[] = []
  422. const headers = await authHeaders(request)
  423. const name = `维修工单-${suffix}`
  424. const editedName = `${name}-修订`
  425. const safePath = `/ape2e/${suffix.toLowerCase()}`
  426. let resourceId = ''
  427. let primaryError: unknown
  428. await cleanupBySuffix(request, headers, 'tools', activities, cleanupErrors)
  429. try {
  430. await loginAsAdmin(page)
  431. await page.goto('/tools')
  432. await expect(page.getByRole('heading', { name: '工具管理' })).toBeVisible()
  433. await page.getByRole('button', { name: '新增业务工具' }).first().click()
  434. let dialog = visibleDialog(page)
  435. await fillFormItem(dialog, '工具名称', name)
  436. await fillFormItem(dialog, '用途说明', '装备维修工单与作业进度协同入口。')
  437. await dialog.getByText('高级接入参数(技术人员)').first().click()
  438. await fillFormItem(dialog, '原始工具地址', 'http://127.0.0.1:8001')
  439. await fillFormItem(dialog, '入口路径', safePath)
  440. await chooseSelectOption(page, dialog, '打开方式', '新窗口打开')
  441. await dialog.getByRole('button', { name: '保存工具' }).click()
  442. await expect(page.locator('.el-message--error').last()).toContainText(/本机|私有|内网|地址|禁止/)
  443. const unsafeLookup = await findResource(request, headers, 'tools', name, (item) => item.name === name)
  444. expect(unsafeLookup, '被 SSRF 策略拒绝的本机地址不得落库').toBeNull()
  445. activities.push({ module, action: '页面提交本机地址并由服务端 SSRF 策略拒绝', result: 'PASS', httpStatus: 400 })
  446. await fillFormItem(dialog, '原始工具地址', 'https://example.com')
  447. await dialog.getByRole('button', { name: '保存工具' }).click()
  448. await expectSuccessMessage(page, '工具已加入市场')
  449. let card = await cardByText(page, name)
  450. const created = await findResource(request, headers, 'tools', name, (item) => item.name === name)
  451. expect(created).not.toBeNull()
  452. resourceId = String(created?.id ?? '')
  453. expect(created?.domain).toBe('https://example.com')
  454. activities.push({ module, action: '页面创建安全 HTTPS 工具', result: 'PASS', resourceId })
  455. await captureScreenshot(page, testInfo, '09-tool-safe-url-created')
  456. await card.getByRole('button', { name: '编辑' }).click()
  457. dialog = visibleDialog(page)
  458. await fillFormItem(dialog, '工具名称', editedName)
  459. await fillFormItem(dialog, '用途说明', '装备维修工单、审批与进度协同入口(已修订)。')
  460. await dialog.getByRole('button', { name: '保存工具' }).click()
  461. await expectSuccessMessage(page, '工具配置已更新')
  462. await page.reload()
  463. await filterByKeyword(page, '搜索工具名称、分类或用途', editedName)
  464. card = await cardByText(page, editedName)
  465. await expect(card).toContainText('已修订')
  466. activities.push({ module, action: '页面编辑并刷新验证持久化', result: 'PASS', resourceId })
  467. const popupPromise = page.waitForEvent('popup')
  468. await card.getByRole('button', { name: '打开工具' }).click()
  469. const popup = await popupPromise
  470. await popup.waitForLoadState('domcontentloaded').catch(() => undefined)
  471. expect(popup.url()).toContain(`example.com${safePath}`)
  472. await popup.close()
  473. activities.push({ module, action: '页面通过后端 launch 地址安全启动新窗口', result: 'PASS', resourceId })
  474. const checkResponsePromise = page.waitForResponse((response) => response.request().method() === 'POST'
  475. && response.url().includes(`/api/v1/tools/${encodeURIComponent(resourceId)}/check`))
  476. await card.getByRole('button', { name: '测试可用性' }).click()
  477. const checkApiResponse = await checkResponsePromise
  478. await responseData(checkApiResponse, 200, '页面工具可用性检测')
  479. const checkMessage = page.locator('.el-message').last()
  480. await expect(checkMessage).toBeVisible({ timeout: 20_000 })
  481. const checkedResponse = await request.get(`/api/v1/tools/${encodeURIComponent(resourceId)}`, { headers })
  482. const checked = await responseData(checkedResponse, 200, '读取工具检测结果')
  483. expect(['available', 'unavailable']).toContain(String(checked.connectionStatus))
  484. const conditionLabel = checked.connectionStatus === 'available' ? '可正常使用' : '需要检查'
  485. const statusSelect = page.locator('.toolbar .el-select').last()
  486. await statusSelect.click()
  487. await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: conditionLabel }).click()
  488. await page.waitForTimeout(350)
  489. card = await cardByText(page, editedName)
  490. activities.push({
  491. module,
  492. action: '页面发起真实外部可用性检测并按实际结果筛选',
  493. result: 'PASS',
  494. resourceId,
  495. detail: `外部网络结果为“${conditionLabel}”;测试不把第三方网络可达性固定为成功条件。`,
  496. })
  497. await captureScreenshot(page, testInfo, '10-tool-conditional-check-filter')
  498. await card.locator('footer .el-switch').click()
  499. await expectSuccessMessage(page, '工具入口已停用')
  500. await page.reload()
  501. await filterByKeyword(page, '搜索工具名称、分类或用途', editedName)
  502. card = await cardByText(page, editedName)
  503. await expect(card).toHaveClass(/disabled/)
  504. activities.push({ module, action: '页面停用并刷新验证', result: 'PASS', resourceId })
  505. await card.getByRole('button', { name: '删除' }).click()
  506. await confirmMessageBox(page, '确认删除')
  507. await expectSuccessMessage(page, '工具已删除')
  508. activities.push({ module, action: '页面删除工具', result: 'PASS', resourceId })
  509. resourceId = ''
  510. } catch (error) {
  511. primaryError = error
  512. activities.push({ module, action: '页面生命周期', result: 'FAIL', detail: error instanceof Error ? error.message : String(error) })
  513. } finally {
  514. await cleanupBySuffix(request, headers, 'tools', activities, cleanupErrors)
  515. await attachLifecycleReport(testInfo, module, activities, cleanupErrors)
  516. }
  517. if (primaryError) throw primaryError
  518. expect(cleanupErrors).toEqual([])
  519. })
  520. })