import { createServer } from 'vite'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import fs from 'node:fs/promises'; import assert from 'node:assert/strict'; const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url); const { chromium } = require('playwright'); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const evidence = path.resolve(process.env.QA_OUTPUT); await fs.mkdir(path.join(evidence, 'empty-env'), { recursive: true }); const server = await createServer({ configFile: false, root, envDir: path.join(evidence, 'empty-env'), cacheDir: path.join(evidence, 'vite-cache'), esbuild: { jsx: 'automatic' }, server: { host: '127.0.0.1', port: 0 } }); await server.listen(); const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_EXECUTABLE }); const page = await browser.newPage({ viewport: { width: 1200, height: 1000 }, timezoneId: 'America/Los_Angeles' }); const errors = [], writes = [], queries = []; page.on('pageerror', e => errors.push(e.message)); const record = { id: 'synthetic-event', kind: 'event', time_ms: Date.parse('2026-10-06T12:00:00Z'), session_id: 'synthetic-session', content: '合成记忆:长者年轻时在杭州工作,喜欢在院子里喝茶。完整正文保留。', memory_info: { summary: '合成记忆正文', original_messages: ['合成聊天原文'] }, digest: 'a'.repeat(64) }; let deleted = false, failDelete = true, failRead = false, canWrite = true, slow = false; await page.route('**/*', async route => { const req = route.request(), url = new URL(req.url()); if (url.hostname !== '127.0.0.1') return route.abort(); if (!url.pathname.startsWith('/api/')) return route.continue(); const body = req.postDataJSON(); if (url.pathname.endsWith('/viking/query')) { queries.push(body); if (slow) await new Promise(resolve => setTimeout(resolve, 500)); if (failRead) return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'VIKING_QUERY_FAILED' }) }); return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ data: { items: body.elder_id === 'elder-a' && !deleted ? [{ ...record, kind: body.kind }] : [], can_write: canWrite, limited: false, limit: body.limit } }) }); } if (url.pathname.endsWith('/viking/delete')) { writes.push(body); if (failDelete) return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'VIKING_DELETE_UNCONFIRMED' }) }); deleted = true; return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ data: { deleted: true } }) }); } throw new Error(`Unexpected endpoint: ${url.pathname}`); }); try { await page.goto(`${server.resolvedUrls.local[0]}test/viking-ui/`); await page.getByRole('button', { name: '删除这条火山记忆' }).waitFor(); await page.getByText('2026/10/06 20:00:00', { exact: false }).first().waitFor(); await page.screenshot({ path: path.join(evidence, 'desktop.png'), fullPage: true }); await page.getByLabel('搜索火山记忆', { exact: true }).fill('年轻时的工作'); await page.getByLabel('记录开始日期').fill('2026-10-01'); await page.getByLabel('记录结束日期').fill('2026-10-07'); await page.getByRole('button', { name: '查询火山库' }).click(); await page.getByRole('button', { name: '删除这条火山记忆' }).waitFor({ state: 'visible' }); assert.equal(queries.at(-1).query, '年轻时的工作'); assert.equal(queries.at(-1).start_time_ms, Date.parse('2026-10-01T00:00:00+08:00')); assert.equal(queries.at(-1).end_time_ms, Date.parse('2026-10-07T23:59:59.999+08:00')); await page.getByRole('button', { name: '删除这条火山记忆' }).click(); const dialog = page.getByRole('dialog'); assert(await dialog.getByRole('button', { name: '确认删除', exact: true }).isDisabled()); await dialog.getByLabel('删除原因', { exact: true }).fill('合成数据清理'); await dialog.getByRole('checkbox').check(); await dialog.getByRole('button', { name: '确认删除', exact: true }).click(); await dialog.getByText('还未确认火山端删除成功。', { exact: false }).waitFor(); await page.screenshot({ path: path.join(evidence, 'delete-unconfirmed.png'), fullPage: true }); failDelete = false; await dialog.getByRole('button', { name: /确认删除/ }).click(); await page.getByText('已核验:这条记忆已从火山 Viking 库删除。').waitFor(); assert.equal(writes.length, 2); assert.equal(writes[0].operation_id, writes[1].operation_id); assert.equal(writes[0].digest, record.digest); assert.equal(writes[0].elder_id, 'elder-a'); deleted = false; canWrite = false; await page.getByRole('button', { name: '刷新火山记忆' }).click(); await page.getByText('当前账号可查看,删除需要记忆修改权限。', { exact: false }).waitFor(); assert(await page.getByRole('button', { name: '删除这条火山记忆' }).isDisabled()); failRead = true; await page.getByRole('button', { name: '刷新火山记忆' }).click(); await page.getByText('火山记忆库暂时无法读取,请稍后重试。').waitFor(); assert.equal(await page.getByText('火山库没有返回符合条件的聊天记忆').count(), 0); failRead = false; canWrite = true; await page.getByRole('button', { name: '刷新火山记忆' }).click(); await page.getByRole('button', { name: '删除这条火山记忆' }).waitFor(); slow = true; await page.getByRole('button', { name: '刷新火山记忆' }).click(); await page.getByRole('button', { name: '切换合成长者' }).click(); await page.getByText('火山库没有返回符合条件的聊天记忆').waitFor(); assert.equal(await page.getByRole('button', { name: '删除这条火山记忆' }).count(), 0); slow = false; await page.getByRole('button', { name: '切换合成长者' }).click(); await page.getByRole('button', { name: '删除这条火山记忆' }).waitFor(); await page.setViewportSize({ width: 390, height: 844 }); assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); await page.screenshot({ path: path.join(evidence, 'mobile.png'), fullPage: true }); assert.deepEqual(errors, []); await fs.writeFile(path.join(evidence, 'result.json'), JSON.stringify({ passed: true, synthetic_only: true, checked: ['search', 'Beijing-date-range', 'confirmation', 'failure-feedback', 'idempotent-retry', 'read-only', 'read-failure', 'elder-switch-race', 'mobile'], writes: writes.length }, null, 2)); console.log('PASS: Viking memory browser flow (synthetic API only)'); } catch (e) { await page.screenshot({ path: path.join(evidence, 'failed.png'), fullPage: true }); console.log(JSON.stringify({ writes, buttons: await page.getByRole('button').allTextContents(), dialogs: await page.getByRole('dialog').count() })); throw e; } finally { await browser.close(); await server.close(); }