import type { APIResponse, TestInfo } from '@playwright/test' import { attachJson, captureScreenshot, expect, runPrefix, test } from './fixtures' import { adminAccessToken, envelopeData, loginAsAdmin } from './helpers' type JsonRecord = Record interface IntegrationSnapshot { knowledgeProviderMode: 'local' | 'hybrid' | 'third_party' thirdPartyKnowledgeEnabled: boolean thirdPartyKnowledgeName: string thirdPartyKnowledgeBaseUrl: string thirdPartyKnowledgeApiPath: string thirdPartyKnowledgeAuthType: 'none' | 'bearer' | 'api_key' thirdPartyKnowledgeBaseId: string thirdPartyKnowledgeTimeoutSeconds: number thirdPartyKnowledgeKeyConfigured: boolean } interface Activity { module: string action: string result: 'PASS' | 'CLEANED' | 'CLEANUP_FAILED' httpStatus?: number detail?: string } const asRecord = (value: unknown): JsonRecord => value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {} function normalize(value: unknown): IntegrationSnapshot { const root = asRecord(envelopeData(value)) const mode = String(root.knowledgeProviderMode || 'local') const authType = String(root.thirdPartyKnowledgeAuthType || 'none') return { knowledgeProviderMode: mode === 'hybrid' || mode === 'third_party' ? mode : 'local', thirdPartyKnowledgeEnabled: Boolean(root.thirdPartyKnowledgeEnabled), thirdPartyKnowledgeName: String(root.thirdPartyKnowledgeName || ''), thirdPartyKnowledgeBaseUrl: String(root.thirdPartyKnowledgeBaseUrl || ''), thirdPartyKnowledgeApiPath: String(root.thirdPartyKnowledgeApiPath || '/api/v1/knowledge/search'), thirdPartyKnowledgeAuthType: authType === 'bearer' || authType === 'api_key' ? authType : 'none', thirdPartyKnowledgeBaseId: String(root.thirdPartyKnowledgeBaseId || ''), thirdPartyKnowledgeTimeoutSeconds: Number(root.thirdPartyKnowledgeTimeoutSeconds || 15), thirdPartyKnowledgeKeyConfigured: Boolean(root.thirdPartyKnowledgeKeyConfigured), } } function savePayload(value: IntegrationSnapshot) { return { ...value, // The API intentionally never returns the credential. An empty value // means keep the existing encrypted secret during both test and restore. thirdPartyKnowledgeApiKey: '', } } async function responseBody(response: APIResponse) { return response.json().catch(() => ({})) } async function fillLabel(page: import('@playwright/test').Page, label: string, value: string) { const field = page.locator('label').filter({ hasText: label }).first() const input = field.locator('input:not([type="hidden"]), textarea').first() await expect(input, `应显示字段:${label}`).toBeVisible() await input.fill(value) } async function recordScreenshot( page: import('@playwright/test').Page, testInfo: TestInfo, label: string, ) { await captureScreenshot(page, testInfo, label, [page.locator('input[type="password"]')]) } test('三方配置通过页面保存、格式检查、联调失败态与刷新持久化,并精确恢复原配置', async ({ page, request }, testInfo) => { const activities: Activity[] = [] const token = await adminAccessToken(request) const headers = { Authorization: `Bearer ${token}` } const initialResponse = await request.get('/api/v1/settings/general', { headers }) expect(initialResponse.status(), '读取三方配置快照').toBe(200) const snapshot = normalize(await responseBody(initialResponse)) const testName = `${runPrefix}-装备知识中台`.slice(0, 80) const testSettings: IntegrationSnapshot = { knowledgeProviderMode: 'local', thirdPartyKnowledgeEnabled: false, thirdPartyKnowledgeName: testName, thirdPartyKnowledgeBaseUrl: 'https://demo.invalid', thirdPartyKnowledgeApiPath: '/api/v1/knowledge/search', thirdPartyKnowledgeAuthType: 'none', thirdPartyKnowledgeBaseId: `equipment-${runPrefix.toLowerCase()}`.slice(0, 160), thirdPartyKnowledgeTimeoutSeconds: 17, thirdPartyKnowledgeKeyConfigured: snapshot.thirdPartyKnowledgeKeyConfigured, } let restored = false try { await loginAsAdmin(page) await page.goto('/settings/integrations') await expect(page.getByRole('heading', { name: '三方配置' })).toBeVisible() await expect(page.locator('.management-loading')).toHaveCount(0, { timeout: 15_000 }) await page.locator('input[type="radio"][value="local"]').check() const enabled = page.locator('.integration-enable-row input[type="checkbox"]') if (await enabled.isChecked()) await page.locator('.integration-enable-row .el-switch').click() await fillLabel(page, '服务名称', testSettings.thirdPartyKnowledgeName) await page.locator('.integration-advanced-settings > summary').click() await fillLabel(page, '知识库标识', testSettings.thirdPartyKnowledgeBaseId) await fillLabel(page, '服务地址', testSettings.thirdPartyKnowledgeBaseUrl) await fillLabel(page, '检索接口路径', testSettings.thirdPartyKnowledgeApiPath) await fillLabel(page, '请求超时', String(testSettings.thirdPartyKnowledgeTimeoutSeconds)) await page.locator('label').filter({ hasText: '鉴权方式' }).locator('select').selectOption('none') await page.getByRole('button', { name: '检查格式' }).click() await expect(page.getByText('配置格式检查通过,可保存后进入接口联调', { exact: true }).last()).toBeVisible() await recordScreenshot(page, testInfo, '三方配置-01-页面填写与格式检查') const savedResponsePromise = page.waitForResponse((response) => ( response.request().method() === 'PUT' && response.url().includes('/api/v1/settings/general') )) await page.getByRole('button', { name: '保存配置' }).click() const savedResponse = await savedResponsePromise expect(savedResponse.status(), await savedResponse.text()).toBe(200) await expect(page.getByText('第三方知识库配置已保存', { exact: true }).last()).toBeVisible() activities.push({ module: '三方配置', action: '页面保存禁用的本地策略与安全占位地址', result: 'PASS', httpStatus: 200 }) await page.reload() await expect(page.locator('.management-loading')).toHaveCount(0, { timeout: 15_000 }) await expect(page.locator('label').filter({ hasText: '服务名称' }).locator('input')).toHaveValue(testName) await expect(page.locator('.integration-status-grid')).toContainText('平台本地知识库') await page.locator('.integration-advanced-settings > summary').click() await expect(page.locator('label').filter({ hasText: '服务地址' }).locator('input')).toHaveValue('https://demo.invalid') await expect(page.locator('label').filter({ hasText: '知识库标识' }).locator('input')).toHaveValue(testSettings.thirdPartyKnowledgeBaseId) activities.push({ module: '三方配置', action: '刷新页面后配置仍持久化', result: 'PASS' }) await recordScreenshot(page, testInfo, '三方配置-02-刷新后持久化') const testResponsePromise = page.waitForResponse((response) => ( response.request().method() === 'POST' && response.url().includes('/api/v1/settings/knowledge-provider/test') )) await page.getByRole('button', { name: '保存并测试连接' }).click() const testResponse = await testResponsePromise expect(testResponse.status(), '安全占位域名联调不得造成服务端 5xx').toBeLessThan(500) await expect(page.locator('.integration-test-result')).toBeVisible() activities.push({ module: '三方配置', action: '页面保存并执行真实服务端连接测试', result: 'PASS', httpStatus: testResponse.status(), detail: 'demo.invalid 是不可达安全占位地址;预期呈现可解释失败态,不伪造第三方成功。', }) await recordScreenshot(page, testInfo, '三方配置-03-真实联调结果') } finally { const restoreResponse = await request.put('/api/v1/settings/general', { headers, data: savePayload(snapshot), }) if (restoreResponse.status() === 200) { restored = true activities.push({ module: '三方配置', action: '恢复测试前完整非敏感配置且保留原加密凭证', result: 'CLEANED', httpStatus: 200 }) } else { activities.push({ module: '三方配置', action: '恢复测试前配置', result: 'CLEANUP_FAILED', httpStatus: restoreResponse.status(), detail: await restoreResponse.text() }) } await attachJson(testInfo, '三方配置页面提交活动', activities) } expect(restored, '全局三方配置必须恢复,避免污染后续用例').toBeTruthy() const finalResponse = await request.get('/api/v1/settings/general', { headers }) expect(finalResponse.status()).toBe(200) expect(normalize(await responseBody(finalResponse))).toEqual(snapshot) })