您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 
 
 

593 行
27 KiB

  1. import { createHash } from 'node:crypto'
  2. import type { APIRequestContext, APIResponse, TestInfo } from '@playwright/test'
  3. import { attachJson, captureScreenshot, expect, runPrefix, test } from './fixtures'
  4. import {
  5. adminAccessToken,
  6. envelopeData,
  7. fillFormItem,
  8. loginAsAdmin,
  9. tableRowByText,
  10. visibleDialog,
  11. } from './helpers'
  12. test.describe.configure({ mode: 'serial' })
  13. type JsonRecord = Record<string, unknown>
  14. type AssetName = 'logo' | 'favicon'
  15. type Headers = Record<string, string>
  16. interface AssetSnapshot {
  17. configured: boolean
  18. originalName: string | null
  19. mediaType: string | null
  20. sha256: string | null
  21. bytes: Buffer | null
  22. canRestore: boolean
  23. untouchedReason?: string
  24. }
  25. interface Activity {
  26. module: string
  27. action: string
  28. result: 'PASS' | 'SKIP' | 'CLEANED' | 'CLEANUP_FAILED'
  29. httpStatus?: number
  30. resourceId?: string
  31. detail?: string
  32. }
  33. const PNG_1X1 = Buffer.from(
  34. 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
  35. 'base64',
  36. )
  37. const TEST_PNG_SHA256 = createHash('sha256').update(PNG_1X1).digest('hex')
  38. const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' && !Array.isArray(value)
  39. ? value as JsonRecord
  40. : {}
  41. const authorized = (token: string): Headers => ({ Authorization: `Bearer ${token}` })
  42. const responseJson = async (response: APIResponse) => response.json().catch(() => ({}))
  43. function configData(value: unknown) {
  44. const envelope = asRecord(envelopeData(value))
  45. return asRecord(envelope.configuration ?? envelope.config ?? envelope.settings ?? envelope)
  46. }
  47. function generalSnapshot(value: unknown) {
  48. const root = configData(value)
  49. const general = asRecord(root.general ?? root.generalSettings ?? root)
  50. return {
  51. systemName: String(general.systemName ?? root.systemName ?? ''),
  52. shortName: String(general.shortName ?? root.shortName ?? ''),
  53. subtitle: String(general.subtitle ?? general.description ?? root.subtitle ?? root.description ?? ''),
  54. defaultTheme: String(general.defaultTheme ?? root.defaultTheme ?? 'light') === 'dark' ? 'dark' as const : 'light' as const,
  55. allowThemeSwitch: Boolean(general.allowThemeSwitch ?? root.allowThemeSwitch ?? true),
  56. knowledgeMenuVisible: Boolean(
  57. general.knowledgeMenuVisible
  58. ?? general.localKnowledgeMenuVisible
  59. ?? root.knowledgeMenuVisible
  60. ?? true,
  61. ),
  62. }
  63. }
  64. function configVersions(value: unknown) {
  65. const root = configData(value)
  66. const versions = asRecord(root.versions ?? root.dataVersions ?? root.versionMap)
  67. return {
  68. systemName: Number(versions.systemName ?? 0),
  69. shortName: Number(versions.shortName ?? 0),
  70. subtitle: Number(versions.subtitle ?? 0),
  71. defaultTheme: Number(versions.defaultTheme ?? 0),
  72. allowThemeSwitch: Number(versions.allowThemeSwitch ?? 0),
  73. knowledgeMenuVisible: Number(versions.knowledgeMenuVisible ?? 0),
  74. }
  75. }
  76. function assetMetadata(value: unknown, asset: AssetName) {
  77. const root = configData(value)
  78. const prefix = asset === 'logo' ? 'logo' : 'favicon'
  79. const assets = asRecord(root.assets)
  80. const nested = asRecord(assets[asset] ?? root[asset])
  81. return {
  82. configured: Boolean(nested.configured ?? root[`${prefix}Configured`] ?? false),
  83. originalName: String(nested.originalName ?? root[`${prefix}OriginalName`] ?? '').trim() || null,
  84. mediaType: String(nested.mediaType ?? root[`${prefix}MediaType`] ?? '').trim() || null,
  85. sha256: String(nested.sha256 ?? root[`${prefix}Sha256`] ?? '').trim().toLowerCase() || null,
  86. version: Number(nested.dataVersion ?? nested.version ?? root[`${prefix}DataVersion`] ?? 0),
  87. url: String(nested.url ?? root[`${prefix}Url`] ?? '').trim() || null,
  88. }
  89. }
  90. async function readConfiguration(request: APIRequestContext, token: string) {
  91. const response = await request.get('/api/auth/v1/system-config', { headers: authorized(token) })
  92. expect(response.status(), '读取完整系统配置快照').toBe(200)
  93. return responseJson(response)
  94. }
  95. async function snapshotAsset(
  96. request: APIRequestContext,
  97. token: string,
  98. configuration: unknown,
  99. asset: AssetName,
  100. ): Promise<AssetSnapshot> {
  101. const metadata = assetMetadata(configuration, asset)
  102. if (!metadata.configured) {
  103. return { ...metadata, bytes: null, canRestore: true }
  104. }
  105. const response = await request.get(`/api/auth/v1/system-config/assets/${asset}`, {
  106. headers: authorized(token),
  107. })
  108. if (response.status() !== 200) {
  109. return {
  110. ...metadata,
  111. bytes: null,
  112. canRestore: false,
  113. untouchedReason: `现有定制资源下载失败(HTTP ${response.status()}),为避免破坏不执行替换`,
  114. }
  115. }
  116. const bytes = await response.body()
  117. const digest = createHash('sha256').update(bytes).digest('hex')
  118. if (metadata.sha256 && digest !== metadata.sha256) {
  119. return {
  120. ...metadata,
  121. bytes: null,
  122. canRestore: false,
  123. untouchedReason: '现有定制资源下载内容与配置摘要不一致,为避免破坏不执行替换',
  124. }
  125. }
  126. return { ...metadata, bytes, sha256: metadata.sha256 ?? digest, canRestore: true }
  127. }
  128. async function restoreGeneral(request: APIRequestContext, token: string, original: unknown) {
  129. const current = await readConfiguration(request, token)
  130. return request.put('/api/auth/v1/system-config/general', {
  131. headers: authorized(token),
  132. data: {
  133. ...generalSnapshot(original),
  134. versions: configVersions(current),
  135. },
  136. })
  137. }
  138. function restoredFilename(asset: AssetName, snapshot: AssetSnapshot) {
  139. if (snapshot.originalName) return snapshot.originalName
  140. if (snapshot.mediaType === 'image/jpeg') return `${asset}.jpg`
  141. if (snapshot.mediaType?.includes('icon')) return `${asset}.ico`
  142. return `${asset}.png`
  143. }
  144. async function restoreAsset(
  145. request: APIRequestContext,
  146. token: string,
  147. asset: AssetName,
  148. snapshot: AssetSnapshot,
  149. ) {
  150. if (!snapshot.canRestore) return { status: 0, skipped: true }
  151. const currentPayload = await readConfiguration(request, token)
  152. const current = assetMetadata(currentPayload, asset)
  153. if (snapshot.configured) {
  154. if (!snapshot.bytes || !snapshot.mediaType) throw new Error(`${asset} 原资源备份不完整,无法恢复`)
  155. const response = await request.post(`/api/auth/v1/system-config/${asset}`, {
  156. headers: authorized(token),
  157. params: { version: String(current.version) },
  158. multipart: {
  159. file: {
  160. name: restoredFilename(asset, snapshot),
  161. mimeType: snapshot.mediaType,
  162. buffer: snapshot.bytes,
  163. },
  164. },
  165. })
  166. return { status: response.status(), skipped: false }
  167. }
  168. if (!current.configured) return { status: 200, skipped: false }
  169. const response = await request.delete(`/api/auth/v1/system-config/${asset}`, {
  170. headers: authorized(token),
  171. params: { version: String(current.version) },
  172. })
  173. return { status: response.status(), skipped: false }
  174. }
  175. async function attachActivities(testInfo: TestInfo, title: string, activities: Activity[]) {
  176. await attachJson(testInfo, title, {
  177. runPrefix,
  178. activities,
  179. security: '附件仅记录动作、状态和资源编号,不包含登录密码、访问令牌、一次性 API Key 或品牌资源二进制。',
  180. })
  181. }
  182. test('基础设置显示策略、知识入口与品牌资源经页面即时应用并精确恢复', async ({ page, request }, testInfo) => {
  183. test.setTimeout(180_000)
  184. const activities: Activity[] = []
  185. const token = await adminAccessToken(request)
  186. const original = await readConfiguration(request, token)
  187. const originalGeneral = generalSnapshot(original)
  188. const snapshots: Record<AssetName, AssetSnapshot> = {
  189. logo: await snapshotAsset(request, token, original, 'logo'),
  190. favicon: await snapshotAsset(request, token, original, 'favicon'),
  191. }
  192. const touched = new Set<AssetName>()
  193. let primaryError: unknown
  194. const targetTheme = originalGeneral.defaultTheme === 'light' ? 'dark' : 'light'
  195. const targetAllowThemeSwitch = !originalGeneral.allowThemeSwitch
  196. const targetKnowledgeVisible = !originalGeneral.knowledgeMenuVisible
  197. try {
  198. await page.addInitScript(() => {
  199. window.localStorage.removeItem('digital-human-theme')
  200. document.cookie = 'digital-human-theme=; Max-Age=0; Path=/; SameSite=Strict'
  201. })
  202. await loginAsAdmin(page)
  203. await page.goto('/settings/general')
  204. await expect(page.getByRole('heading', { name: '基础设置' })).toBeVisible()
  205. const themeRadios = page.locator('fieldset').filter({ hasText: '默认主题' }).locator('input[type="radio"]')
  206. await expect(themeRadios).toHaveCount(2)
  207. expect(await themeRadios.evaluateAll((nodes) => nodes.map((node) => (node as HTMLInputElement).value).sort())).toEqual(['dark', 'light'])
  208. await page.locator(`input[type="radio"][value="${targetTheme}"]`).check()
  209. const themeSwitch = page.locator('.system-switch-list label').filter({ hasText: '允许用户切换主题' })
  210. const knowledgeSwitch = page.locator('.system-switch-list label').filter({ hasText: '显示维修知识入口' })
  211. const setSwitch = async (scope: ReturnType<typeof page.locator>, value: boolean) => {
  212. const input = scope.locator('input[type="checkbox"]')
  213. if ((await input.isChecked()) !== value) await scope.locator('.el-switch').click()
  214. await expect(input).toBeChecked({ checked: value })
  215. }
  216. await setSwitch(themeSwitch, targetAllowThemeSwitch)
  217. await setSwitch(knowledgeSwitch, targetKnowledgeVisible)
  218. await captureScreenshot(page, testInfo, 'settings-display-policy-before-save')
  219. await page.getByRole('button', { name: '保存基础设置' }).click()
  220. await expect(page.getByText('基础设置已保存并立即生效').last()).toBeVisible()
  221. activities.push({ module: '基础设置', action: '页面保存默认主题、主题切换和维修知识入口', result: 'PASS' })
  222. const persisted = generalSnapshot(await readConfiguration(request, token))
  223. expect(persisted).toEqual({
  224. ...originalGeneral,
  225. defaultTheme: targetTheme,
  226. allowThemeSwitch: targetAllowThemeSwitch,
  227. knowledgeMenuVisible: targetKnowledgeVisible,
  228. })
  229. await expect(page.locator('html')).toHaveAttribute('data-theme', targetTheme)
  230. await expect(page.locator('.theme-toggle')).toHaveCount(targetAllowThemeSwitch ? 1 : 0)
  231. await expect(page.getByText('维修知识', { exact: true })).toHaveCount(targetKnowledgeVisible ? 1 : 0)
  232. await captureScreenshot(page, testInfo, 'settings-display-policy-immediate-application')
  233. for (const asset of ['logo', 'favicon'] as const) {
  234. const snapshot = snapshots[asset]
  235. if (!snapshot.canRestore) {
  236. activities.push({
  237. module: '基础设置',
  238. action: `${asset === 'logo' ? '系统 LOGO' : '网站图标'}上传与即时应用`,
  239. result: 'SKIP',
  240. detail: snapshot.untouchedReason,
  241. })
  242. continue
  243. }
  244. const selector = asset === 'logo'
  245. ? 'input[type="file"][accept="image/png,image/jpeg"]'
  246. : 'input[type="file"][accept*=".ico"]'
  247. await page.locator(selector).setInputFiles({
  248. name: `${runPrefix}-${asset}.png`,
  249. mimeType: 'image/png',
  250. buffer: PNG_1X1,
  251. })
  252. const dialog = visibleDialog(page)
  253. await expect(dialog).toContainText(asset === 'logo' ? '预览并上传系统 LOGO' : '预览并上传网站图标')
  254. await captureScreenshot(page, testInfo, `settings-${asset}-upload-preview`)
  255. // 点击确认后即把资源视为已触碰;即便网络响应已成功但成功提示断言失败,finally 也必须恢复。
  256. touched.add(asset)
  257. await dialog.getByRole('button', { name: '确认上传' }).click()
  258. await expect(page.getByText(asset === 'logo' ? '系统 LOGO已更新并立即生效' : '网站图标已更新并立即生效').last()).toBeVisible()
  259. const afterUpload = assetMetadata(await readConfiguration(request, token), asset)
  260. expect(afterUpload.configured).toBe(true)
  261. expect(afterUpload.sha256).toBe(TEST_PNG_SHA256)
  262. if (asset === 'logo') {
  263. await expect(page.locator('.brand-mark img').first()).toHaveAttribute('src', new RegExp(TEST_PNG_SHA256))
  264. } else {
  265. await expect(page.locator('link[rel~="icon"]')).toHaveAttribute('href', new RegExp(TEST_PNG_SHA256))
  266. }
  267. activities.push({
  268. module: '基础设置',
  269. action: `${asset === 'logo' ? '系统 LOGO' : '网站图标'}页面上传并即时应用`,
  270. result: 'PASS',
  271. httpStatus: 200,
  272. })
  273. }
  274. await captureScreenshot(page, testInfo, 'settings-brand-assets-immediate-application')
  275. } catch (error) {
  276. primaryError = error
  277. } finally {
  278. for (const asset of ['logo', 'favicon'] as const) {
  279. if (!touched.has(asset)) continue
  280. try {
  281. const result = await restoreAsset(request, token, asset, snapshots[asset])
  282. expect(result.status, `${asset} 恢复响应`).toBe(200)
  283. activities.push({ module: '基础设置', action: `恢复 ${asset} 原始资源`, result: 'CLEANED', httpStatus: result.status })
  284. } catch (error) {
  285. activities.push({
  286. module: '基础设置',
  287. action: `恢复 ${asset} 原始资源`,
  288. result: 'CLEANUP_FAILED',
  289. detail: error instanceof Error ? error.message : String(error),
  290. })
  291. if (!primaryError) primaryError = error
  292. }
  293. }
  294. try {
  295. const response = await restoreGeneral(request, token, original)
  296. expect(response.status(), '恢复基础设置原值').toBe(200)
  297. activities.push({ module: '基础设置', action: '恢复显示策略与知识入口原值', result: 'CLEANED', httpStatus: response.status() })
  298. } catch (error) {
  299. activities.push({
  300. module: '基础设置',
  301. action: '恢复显示策略与知识入口原值',
  302. result: 'CLEANUP_FAILED',
  303. detail: error instanceof Error ? error.message : String(error),
  304. })
  305. if (!primaryError) primaryError = error
  306. }
  307. await attachActivities(testInfo, '基础设置扩展与恢复', activities)
  308. }
  309. if (primaryError) throw primaryError
  310. const restored = await readConfiguration(request, token)
  311. expect(generalSnapshot(restored)).toEqual(originalGeneral)
  312. for (const asset of ['logo', 'favicon'] as const) {
  313. const snapshot = snapshots[asset]
  314. if (!snapshot.canRestore) continue
  315. const metadata = assetMetadata(restored, asset)
  316. expect(metadata.configured, `${asset} configured 精确恢复`).toBe(snapshot.configured)
  317. expect(metadata.sha256, `${asset} 内容摘要精确恢复`).toBe(snapshot.sha256)
  318. expect(metadata.mediaType, `${asset} MIME 精确恢复`).toBe(snapshot.mediaType)
  319. expect(metadata.originalName, `${asset} 原文件名精确恢复`).toBe(snapshot.originalName)
  320. }
  321. })
  322. async function apiData(response: APIResponse, expectedStatus: number, label: string) {
  323. expect(response.status(), label).toBe(expectedStatus)
  324. return asRecord(envelopeData(await responseJson(response)))
  325. }
  326. async function projectDetail(request: APIRequestContext, headers: Headers, projectId: string) {
  327. const response = await request.get(`/api/v1/agent-projects/${encodeURIComponent(projectId)}`, { headers })
  328. return { response, data: response.status() === 200 ? await apiData(response, 200, '读取项目详情') : {} }
  329. }
  330. async function agentDetail(request: APIRequestContext, headers: Headers, agentId: string) {
  331. const response = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers })
  332. return { response, data: response.status() === 200 ? await apiData(response, 200, '读取智能体详情') : {} }
  333. }
  334. async function cleanupProject(
  335. request: APIRequestContext,
  336. headers: Headers,
  337. projectId: string,
  338. activities: Activity[],
  339. ) {
  340. const current = await projectDetail(request, headers, projectId)
  341. if (current.response.status() === 404) return
  342. const response = await request.delete(`/api/v1/agent-projects/${encodeURIComponent(projectId)}`, {
  343. headers,
  344. params: { dataVersion: String(Number(current.data.dataVersion ?? 0)) },
  345. })
  346. activities.push({
  347. module: '智能体组别',
  348. action: '失败兜底删除项目',
  349. result: response.status() === 200 ? 'CLEANED' : 'CLEANUP_FAILED',
  350. httpStatus: response.status(),
  351. resourceId: projectId,
  352. })
  353. expect(response.status(), '失败兜底删除项目').toBe(200)
  354. }
  355. async function cleanupAgent(
  356. request: APIRequestContext,
  357. headers: Headers,
  358. agentId: string,
  359. activities: Activity[],
  360. ) {
  361. const current = await agentDetail(request, headers, agentId)
  362. if (current.response.status() === 404) return
  363. const response = await request.delete(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, {
  364. headers,
  365. params: { dataVersion: String(Number(current.data.dataVersion ?? 0)) },
  366. })
  367. activities.push({
  368. module: '智能体组别',
  369. action: '删除测试智能体',
  370. result: response.status() === 200 ? 'CLEANED' : 'CLEANUP_FAILED',
  371. httpStatus: response.status(),
  372. resourceId: agentId,
  373. })
  374. expect(response.status(), '删除测试智能体').toBe(200)
  375. }
  376. test('智能体组别页面完成创建编辑启停筛选成员归集组内创建与删除闭环', async ({ page, request }, testInfo) => {
  377. test.setTimeout(240_000)
  378. const activities: Activity[] = []
  379. const token = await adminAccessToken(request)
  380. const headers = authorized(token)
  381. const suffix = runPrefix.replace(/[^A-Za-z0-9]/g, '').slice(-10) || Date.now().toString().slice(-10)
  382. const projectCode = `APE2E-${suffix}`.slice(0, 24)
  383. const projectName = `APE2E智能体组别-${suffix}`
  384. const editedProjectName = `${projectName}-已编辑`
  385. const dependencyAgentName = `APE2E组别成员-${suffix}`
  386. const scopedAgentName = `APE2E组内创建-${suffix}`
  387. let projectId = ''
  388. const agentIds: string[] = []
  389. let primaryError: unknown
  390. try {
  391. await loginAsAdmin(page)
  392. await page.goto('/agents/projects')
  393. await expect(page.getByRole('heading', { name: '智能体组别管理' })).toBeVisible()
  394. await page.getByRole('button', { name: '新建项目' }).click()
  395. let dialog = visibleDialog(page)
  396. await fillFormItem(dialog, '项目名称', projectName)
  397. await fillFormItem(dialog, '项目编号', projectCode)
  398. await fillFormItem(dialog, '承担单位', '装备维修教研室')
  399. await fillFormItem(dialog, '项目说明', `${runPrefix} 装备维修数字教员建设与交付验证`)
  400. // 编号已知,提交前就登记为可能创建;即使成功提示未出现,finally 也能按编号查询并清理。
  401. projectId = projectCode
  402. await dialog.getByRole('button', { name: '保存' }).click()
  403. await expect(page.getByText('项目已创建').last()).toBeVisible()
  404. let detail = await projectDetail(request, headers, projectId)
  405. expect(detail.response.status()).toBe(200)
  406. expect(detail.data.name).toBe(projectName)
  407. expect(detail.data.status).toBe('enabled')
  408. activities.push({ module: '智能体组别', action: '页面创建项目并读取数据库结果', result: 'PASS', httpStatus: 201, resourceId: projectId })
  409. await captureScreenshot(page, testInfo, 'projects-created-and-persisted')
  410. let row = tableRowByText(page, projectName)
  411. await row.getByRole('button', { name: '编辑' }).click()
  412. dialog = visibleDialog(page)
  413. await fillFormItem(dialog, '项目名称', editedProjectName)
  414. await fillFormItem(dialog, '承担单位', '装备维修与智能教学中心')
  415. await fillFormItem(dialog, '项目说明', `${runPrefix} 已编辑:覆盖液压、电气与机械维修课目`)
  416. await dialog.getByRole('button', { name: '保存' }).click()
  417. await expect(page.getByText('项目已更新').last()).toBeVisible()
  418. detail = await projectDetail(request, headers, projectId)
  419. expect(detail.data.name).toBe(editedProjectName)
  420. expect(detail.data.unit).toBe('装备维修与智能教学中心')
  421. activities.push({ module: '智能体组别', action: '页面编辑项目并刷新核验', result: 'PASS', httpStatus: 200, resourceId: projectId })
  422. await captureScreenshot(page, testInfo, 'projects-edited-and-persisted')
  423. row = tableRowByText(page, editedProjectName)
  424. await row.getByRole('button', { name: '停用', exact: true }).click()
  425. await expect(page.getByText('项目已停用(仅归档标记,不影响项目下智能体对外服务)').last()).toBeVisible()
  426. detail = await projectDetail(request, headers, projectId)
  427. expect(detail.data.status).toBe('disabled')
  428. activities.push({ module: '智能体组别', action: '页面停用项目', result: 'PASS', httpStatus: 200, resourceId: projectId })
  429. const keyword = page.getByPlaceholder('搜索项目名称、编号、承担单位或负责人')
  430. await keyword.fill(editedProjectName)
  431. const statusSelect = page.locator('.toolbar .el-select')
  432. await statusSelect.click()
  433. await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '启用中' }).click()
  434. await expect(tableRowByText(page, editedProjectName)).toHaveCount(0)
  435. await statusSelect.click()
  436. await page.locator('.el-select-dropdown:visible .el-select-dropdown__item').filter({ hasText: '已停用' }).click()
  437. await expect(tableRowByText(page, editedProjectName)).toBeVisible()
  438. activities.push({ module: '智能体组别', action: '按关键词与启停状态筛选', result: 'PASS', resourceId: projectId })
  439. await captureScreenshot(page, testInfo, 'projects-disabled-filter-result')
  440. row = tableRowByText(page, editedProjectName)
  441. await row.getByRole('button', { name: '启用', exact: true }).click()
  442. await expect(page.getByText('项目已启用').last()).toBeVisible()
  443. detail = await projectDetail(request, headers, projectId)
  444. expect(detail.data.status).toBe('enabled')
  445. activities.push({ module: '智能体组别', action: '页面重新启用项目', result: 'PASS', httpStatus: 200, resourceId: projectId })
  446. const dependencyResponse = await request.post('/api/v1/realtime-agents', {
  447. headers,
  448. data: { name: dependencyAgentName, expiresAt: '2035-08-15T12:00:00+08:00' },
  449. })
  450. const dependency = await apiData(dependencyResponse, 201, '创建成员管理依赖智能体')
  451. const dependencyAgentId = String(dependency.id ?? '')
  452. expect(dependencyAgentId).not.toBe('')
  453. agentIds.push(dependencyAgentId)
  454. activities.push({ module: '智能体组别', action: 'API 创建未归属草稿智能体作为成员依赖', result: 'PASS', httpStatus: 201, resourceId: dependencyAgentId })
  455. await page.goto('/agents/projects')
  456. row = tableRowByText(page, editedProjectName)
  457. await expect(row).toBeVisible()
  458. await row.getByRole('button', { name: '管理成员' }).click()
  459. dialog = visibleDialog(page)
  460. const dependencyMember = dialog.locator('.member-row').filter({ hasText: dependencyAgentName })
  461. await expect(dependencyMember).toBeVisible()
  462. await dependencyMember.locator('input[type="checkbox"]').check()
  463. await expect(dialog.getByText('已选 1 个')).toBeVisible()
  464. await dialog.getByRole('button', { name: '保存成员' }).click()
  465. await expect(page.getByText('已更新项目成员,共 1 个智能体').last()).toBeVisible()
  466. const assignedDependency = await agentDetail(request, headers, dependencyAgentId)
  467. expect(assignedDependency.data.projectId).toBe(projectId)
  468. detail = await projectDetail(request, headers, projectId)
  469. expect(detail.data.agentCount).toBe(1)
  470. activities.push({ module: '智能体组别', action: '页面勾选成员并保存归属', result: 'PASS', httpStatus: 200, resourceId: dependencyAgentId })
  471. await captureScreenshot(page, testInfo, 'projects-member-assignment-persisted')
  472. row = tableRowByText(page, editedProjectName)
  473. await row.getByRole('button', { name: '新建智能体' }).click()
  474. dialog = visibleDialog(page)
  475. await expect(dialog).toContainText(`所属项目`)
  476. await expect(dialog).toContainText(`${editedProjectName} · ${projectCode}`)
  477. await expect(dialog.locator('select')).toHaveCount(0)
  478. await captureScreenshot(page, testInfo, 'projects-scoped-create-entry')
  479. await dialog.getByPlaceholder('例如:装备维修虚拟教员').fill(scopedAgentName)
  480. await dialog.getByRole('button', { name: '长期有效' }).click()
  481. const createdResponsePromise = page.waitForResponse((response) => (
  482. response.request().method() === 'POST'
  483. && new URL(response.url()).pathname === '/api/v1/realtime-agents'
  484. ))
  485. await dialog.getByRole('button', { name: '创建', exact: true }).click()
  486. const createdResponse = await createdResponsePromise
  487. expect(createdResponse.status(), '项目内入口创建智能体').toBe(201)
  488. const scopedAgent = asRecord(envelopeData(await createdResponse.json()))
  489. const scopedAgentId = String(scopedAgent.id ?? '')
  490. expect(scopedAgentId).not.toBe('')
  491. agentIds.push(scopedAgentId)
  492. expect(scopedAgent.projectId).toBe(projectId)
  493. await expect(page).toHaveURL(new RegExp(`/agents/${encodeURIComponent(scopedAgentId)}(?:\\?|$)`))
  494. await expect(page.locator('body')).toContainText(scopedAgentName)
  495. activities.push({ module: '智能体组别', action: '项目内入口锁定归属并真实创建草稿智能体', result: 'PASS', httpStatus: 201, resourceId: scopedAgentId })
  496. await captureScreenshot(page, testInfo, 'projects-scoped-agent-created', [
  497. page.locator('input[type="password"], pre, code, [data-testid*="secret"], [data-testid*="password"]'),
  498. ])
  499. await page.goto('/agents/projects')
  500. row = tableRowByText(page, editedProjectName)
  501. await expect(row).toContainText('共 2 个')
  502. detail = await projectDetail(request, headers, projectId)
  503. expect(detail.data.agentCount).toBe(2)
  504. await row.getByRole('button', { name: '删除' }).click()
  505. const confirmation = page.locator('.el-message-box:visible').last()
  506. await expect(confirmation).toContainText('下有 2 个智能体')
  507. await expect(confirmation).toContainText('删除项目只会解除归属')
  508. await captureScreenshot(page, testInfo, 'projects-delete-release-warning')
  509. await confirmation.getByRole('button', { name: '确认删除' }).click()
  510. await expect(page.getByText('项目已删除,2 个智能体转为未归属').last()).toBeVisible()
  511. projectId = ''
  512. await page.reload()
  513. await expect(tableRowByText(page, editedProjectName)).toHaveCount(0)
  514. const absentProject = await request.get(`/api/v1/agent-projects/${encodeURIComponent(projectCode)}`, { headers })
  515. expect(absentProject.status()).toBe(404)
  516. for (const agentId of agentIds) {
  517. const released = await agentDetail(request, headers, agentId)
  518. expect(released.response.status()).toBe(200)
  519. expect(String(released.data.projectId ?? '')).toBe('')
  520. }
  521. activities.push({ module: '智能体组别', action: '页面删除项目、刷新消失且成员仅解除归属', result: 'PASS', httpStatus: 200, resourceId: projectCode })
  522. await captureScreenshot(page, testInfo, 'projects-deleted-after-refresh')
  523. } catch (error) {
  524. primaryError = error
  525. } finally {
  526. try {
  527. if (projectId) await cleanupProject(request, headers, projectId, activities)
  528. } catch (error) {
  529. if (!primaryError) primaryError = error
  530. }
  531. for (const agentId of agentIds) {
  532. try {
  533. await cleanupAgent(request, headers, agentId, activities)
  534. } catch (error) {
  535. if (!primaryError) primaryError = error
  536. }
  537. }
  538. await attachActivities(testInfo, '智能体组别完整页面生命周期', activities)
  539. }
  540. if (primaryError) throw primaryError
  541. for (const agentId of agentIds) {
  542. const response = await request.get(`/api/v1/realtime-agents/${encodeURIComponent(agentId)}`, { headers })
  543. expect(response.status(), `测试智能体 ${agentId} 已清理`).toBe(404)
  544. }
  545. })