145 lines
12 KiB
JavaScript
145 lines
12 KiB
JavaScript
const assert = require('node:assert/strict')
|
|||
|
|
const fs = require('node:fs')
|
||
|
|
const { chromium } = require('/Users/macclaw/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
|
||
|
|
const directory = '/Volumes/Extreme SSD/yawan-storage/weknora/acceptance'
|
||
|
|
const origin = process.env.VERIFY_FRONTEND_ORIGIN || 'http://127.0.0.1:15173'
|
||
|
|
const apiOrigin = process.env.VERIFY_API_ORIGIN || 'http://127.0.0.1:18080'
|
||
|
|
|
||
|
|
async function main() {
|
||
|
|
const credentials = JSON.parse(fs.readFileSync(`${directory}/super-admin-login.json`, 'utf8'))
|
||
|
|
const login = await fetch(`${apiOrigin}/api/v1/auth/login`, {
|
||
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ email: credentials.email, password: credentials.password }),
|
||
|
|
})
|
||
|
|
assert.equal(login.status, 200)
|
||
|
|
const auth = await login.json()
|
||
|
|
const browser = await chromium.launch({ headless: true, channel: 'chrome' })
|
||
|
|
const checks = [], errors = [], writes = [], blockedWrites = []
|
||
|
|
const check = (name, result) => { checks.push({ name, passed: !!result }); assert.ok(result, name) }
|
||
|
|
try {
|
||
|
|
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||
|
|
await context.addInitScript(a => {
|
||
|
|
for (const [key, value] of Object.entries({
|
||
|
|
weknora_user: JSON.stringify({ ...a.user, tenant_id: String(a.user.tenant_id) }),
|
||
|
|
weknora_tenant: JSON.stringify({ ...a.active_tenant, id: '10059' }),
|
||
|
|
weknora_token: a.token, weknora_refresh_token: a.refresh_token,
|
||
|
|
weknora_memberships: JSON.stringify(a.memberships), 'weknora:new-user-guide-done:v1': '1', 'weknora:contextual-guide-agent-list:v1': '1',
|
||
|
|
})) localStorage.setItem(key, value)
|
||
|
|
}, auth)
|
||
|
|
let failList = true
|
||
|
|
let recoverySettings = { write_mode: 'auto', extraction_pending: false, extraction_exhausted: false, extraction_failures: 0, extraction_failure_limit: 8 }
|
||
|
|
let extractionRetries = 0
|
||
|
|
const now = new Date().toISOString()
|
||
|
|
const candidate = { id: 'memory-check', agent_id: '', status: 'pending', content: '请用清晰简短的表达。'.repeat(35), created_at: now }
|
||
|
|
const legacy = { id: 'legacy-check', owner_id: auth.user.id, agent_id: '', status: 'accepted', content: '历史采纳记录,尚未写入当前记忆库。', legacy_history_id: 'history-check', can_reconfirm: true, created_at: now }
|
||
|
|
const unavailable = { ...legacy, id: 'unavailable-check', content: '原 Agent 已删除的历史记录。', can_reconfirm: false, reconfirmation_unavailable_reason: '原 Agent 已删除,无法重新确认。' }
|
||
|
|
const linked = { ...legacy, id: 'linked-check', content: '已重新确认的历史记录。', can_reconfirm: false, native_memory_id: 'linked-native', reconfirmed_content: '采用简洁表达。', reconfirmed_at: now }
|
||
|
|
const item = { id: 'memory-item', kind: 'preference', content: '已成功重新加载的记忆', status: 'active', origin: 'manual', importance: 3, created_at: now }
|
||
|
|
// Only authentication reaches the live server as a write; all review writes stay in these fixtures.
|
||
|
|
await context.route('**/api/v1/**', async route => {
|
||
|
|
const request = route.request(), url = new URL(request.url()), path = url.pathname, method = request.method()
|
||
|
|
const ok = body => route.fulfill({ status: 200, json: { success: true, ...body } })
|
||
|
|
if (path === '/api/v1/access/me') return ok({ data: { administrator: false, restricted: true, permissions: ['agents.read', 'agents.memory'] } })
|
||
|
|
if (path === '/api/v1/memory/settings') return ok({ data: { workspace_enabled: true, user_enabled: true, effective: true, item_count: 1, max_items: 200, ...recoverySettings } })
|
||
|
|
if (path === '/api/v1/memory/extraction/retry' && method === 'POST') {
|
||
|
|
extractionRetries++
|
||
|
|
recoverySettings = { ...recoverySettings, extraction_exhausted: false, extraction_failures: 0 }
|
||
|
|
return ok({})
|
||
|
|
}
|
||
|
|
if (path === '/api/v1/memory/items') {
|
||
|
|
if (failList && url.searchParams.get('limit') === '20') return route.fulfill({ status: 503, json: { error: { message: '记忆服务暂不可用,请刷新重试' } } })
|
||
|
|
return ok({ data: url.searchParams.get('status') === 'active' ? [item] : [], total: url.searchParams.get('status') === 'active' ? 1 : 0 })
|
||
|
|
}
|
||
|
|
if (['/api/v1/memory/topics', '/api/v1/memory/documents'].includes(path)) return ok({ data: [], total: 0 })
|
||
|
|
if (path === '/api/v1/workbench/operations/records/memories') {
|
||
|
|
const rows = [candidate, legacy, unavailable, linked].filter(row => row.status === url.searchParams.get('status'))
|
||
|
|
return ok({ data: rows, total: rows.length })
|
||
|
|
}
|
||
|
|
if (path === '/api/v1/workbench/operations/memories/memory-check' && method === 'POST') {
|
||
|
|
const body = request.postDataJSON()
|
||
|
|
writes.push(body)
|
||
|
|
Object.assign(candidate, body)
|
||
|
|
return ok({ data: candidate })
|
||
|
|
}
|
||
|
|
if (path === '/api/v1/workbench/operations/memories/legacy-check' && method === 'POST') {
|
||
|
|
const body = request.postDataJSON()
|
||
|
|
writes.push(body)
|
||
|
|
Object.assign(legacy, { native_memory_id: 'new-native', reconfirmed_content: body.content, reconfirmed_at: now, can_reconfirm: false })
|
||
|
|
return ok({ data: legacy })
|
||
|
|
}
|
||
|
|
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||
|
|
blockedWrites.push({ path, method })
|
||
|
|
return route.fulfill({ status: 409, json: { error: 'Verification prevents live writes' } })
|
||
|
|
}
|
||
|
|
return route.continue()
|
||
|
|
})
|
||
|
|
const page = await context.newPage()
|
||
|
|
page.on('pageerror', error => errors.push(error.message))
|
||
|
|
await page.goto(`${origin}/platform/agents?tab=memory`)
|
||
|
|
const memory = page.locator('.memory-settings')
|
||
|
|
await memory.locator('.t-alert--error').waitFor()
|
||
|
|
check('failure_is_not_an_empty_memory_state', await memory.locator('.empty').count() === 0)
|
||
|
|
await page.screenshot({ path: `${directory}/memory-error-desktop.png`, animations: 'disabled' })
|
||
|
|
failList = false
|
||
|
|
await memory.getByRole('button', { name: '刷新', exact: true }).click()
|
||
|
|
await memory.getByText('已成功重新加载的记忆', { exact: true }).waitFor()
|
||
|
|
check('refresh_recovers_memory_list', await memory.locator('.t-alert--error').count() === 0)
|
||
|
|
await page.locator('.pending-memories').getByRole('button', { name: '审核', exact: true }).click()
|
||
|
|
const dialog = page.locator('.t-dialog:visible')
|
||
|
|
const text = dialog.locator('textarea')
|
||
|
|
await text.waitFor()
|
||
|
|
check('review_textarea_has_visible_label', await text.evaluate(element => Array.from(element.labels || []).some(label => label.textContent.includes('记忆内容'))))
|
||
|
|
check('long_candidate_not_truncated', await text.inputValue() === candidate.content)
|
||
|
|
check('long_candidate_cannot_be_accepted', await dialog.getByRole('button', { name: '确认并存入记忆' }).isDisabled())
|
||
|
|
check('long_candidate_can_be_rejected', await dialog.getByRole('button', { name: '拒绝', exact: true }).isEnabled())
|
||
|
|
await page.setViewportSize({ width: 390, height: 844 })
|
||
|
|
await page.screenshot({ path: `${directory}/memory-review-mobile.png`, animations: 'disabled' })
|
||
|
|
check('review_fits_mobile', await dialog.evaluate(element => { const r = element.getBoundingClientRect(); return r.left >= -1 && r.right <= innerWidth + 1 && r.top >= 0 && r.bottom <= innerHeight + 1 }))
|
||
|
|
await text.fill('采用简短清晰的表达,先给结论,再补充例子。')
|
||
|
|
await dialog.getByRole('button', { name: '确认并存入记忆' }).click()
|
||
|
|
await dialog.waitFor({ state: 'hidden' })
|
||
|
|
check('edited_candidate_is_accepted', writes.length === 1 && writes[0].status === 'accepted' && writes[0].content.length < 300)
|
||
|
|
await page.setViewportSize({ width: 1440, height: 1000 })
|
||
|
|
await page.locator('.pending-toolbar .t-select').click()
|
||
|
|
await page.getByText('已采纳', { exact: true }).last().click()
|
||
|
|
const historicalRow = page.locator('.pending-row').filter({ hasText: '历史采纳记录,尚未写入当前记忆库。' })
|
||
|
|
await historicalRow.getByRole('button', { name: '重新确认', exact: true }).waitFor()
|
||
|
|
check('only_eligible_legacy_history_has_reconfirmation', await page.locator('.pending-memories').getByRole('button', { name: '重新确认', exact: true }).count() === 1)
|
||
|
|
check('unavailable_history_and_reason_remain_visible', await page.getByText('原 Agent 已删除,无法重新确认。', { exact: true }).isVisible())
|
||
|
|
await historicalRow.getByRole('button', { name: '重新确认', exact: true }).click()
|
||
|
|
await dialog.getByText('重新确认历史记忆', { exact: true }).waitFor()
|
||
|
|
check('legacy_cannot_be_rejected_or_accepted_without_consent', await dialog.getByRole('button', { name: '拒绝', exact: true }).count() === 0 && await dialog.getByRole('button', { name: '重新确认并存入记忆', exact: true }).isDisabled())
|
||
|
|
await text.fill('先给结论,再用简短案例说明。')
|
||
|
|
await page.screenshot({ path: `${directory}/memory-legacy-confirmation-desktop.png`, animations: 'disabled' })
|
||
|
|
await page.setViewportSize({ width: 390, height: 844 })
|
||
|
|
await page.screenshot({ path: `${directory}/memory-legacy-confirmation-mobile.png`, animations: 'disabled' })
|
||
|
|
check('legacy_confirmation_fits_mobile', await dialog.evaluate(element => { const r = element.getBoundingClientRect(); return r.left >= -1 && r.right <= innerWidth + 1 && r.top >= 0 && r.bottom <= innerHeight + 1 && element.scrollWidth <= element.clientWidth + 1 }))
|
||
|
|
await dialog.locator('.review-consent').click()
|
||
|
|
await dialog.getByRole('button', { name: '重新确认并存入记忆', exact: true }).click()
|
||
|
|
await dialog.waitFor({ state: 'hidden' })
|
||
|
|
check('legacy_confirmation_sends_explicit_consent', writes.length === 2 && writes[1].reconfirm_legacy === true && writes[1].confirm_personal_scope === true && writes[1].content === '先给结论,再用简短案例说明。')
|
||
|
|
await historicalRow.getByText('已重新确认', { exact: true }).waitFor()
|
||
|
|
check('reconfirmed_history_keeps_original_and_final_wording', legacy.content === '历史采纳记录,尚未写入当前记忆库。' && (await historicalRow.innerText()).includes('先给结论,再用简短案例说明。') && await historicalRow.getByRole('button', { name: '重新确认', exact: true }).count() === 0)
|
||
|
|
recoverySettings = { ...recoverySettings, extraction_pending: true, extraction_exhausted: true, extraction_failures: 8 }
|
||
|
|
await memory.getByRole('button', { name: '刷新', exact: true }).click()
|
||
|
|
const recovery = memory.locator('.extraction-recovery')
|
||
|
|
await recovery.getByRole('button', { name: '重新尝试', exact: true }).waitFor()
|
||
|
|
check('exhausted_state_explains_retained_work', (await recovery.innerText()).includes('自动重试已停止') && (await recovery.innerText()).includes('待处理聊天记录已保留'))
|
||
|
|
await recovery.scrollIntoViewIfNeeded()
|
||
|
|
await page.screenshot({ path: `${directory}/memory-recovery-mobile.png`, animations: 'disabled' })
|
||
|
|
check('recovery_controls_fit_mobile', await recovery.evaluate(element => { const r = element.getBoundingClientRect(); return r.left >= -1 && r.right <= innerWidth + 1 && element.scrollWidth <= element.clientWidth + 1 }))
|
||
|
|
await page.setViewportSize({ width: 1440, height: 1000 })
|
||
|
|
await page.screenshot({ path: `${directory}/memory-recovery-desktop.png`, animations: 'disabled' })
|
||
|
|
await recovery.getByRole('button', { name: '重新尝试', exact: true }).click()
|
||
|
|
await recovery.getByText('仍有聊天记录等待提取,后台将继续处理。', { exact: true }).waitFor()
|
||
|
|
check('explicit_retry_refreshes_status_and_hides_button', extractionRetries === 1 && await recovery.getByRole('button', { name: '重新尝试', exact: true }).count() === 0)
|
||
|
|
check('no_live_business_writes', blockedWrites.length === 0)
|
||
|
|
check('no_runtime_errors', errors.length === 0)
|
||
|
|
fs.writeFileSync(`${directory}/memory-ui.json`, JSON.stringify({ origin, checks, errors, businessWrites: 'mocked in browser only' }, null, 2))
|
||
|
|
console.log(JSON.stringify({ checks, errors }, null, 2))
|
||
|
|
} finally {
|
||
|
|
await browser.close()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
main().catch(error => { console.error(error.stack); process.exitCode = 1 })
|