Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.
 
 
 
 

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