#!/usr/bin/env node import { createRequire } from 'node:module'; import { execFileSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; const require = createRequire(import.meta.url); const { chromium } = require('playwright'); const now = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14); const username = process.env.BEAVER_VALIDATE_USERNAME || `fsauto${now}`; const password = process.env.BEAVER_VALIDATE_PASSWORD || 'TestPass123!'; const email = process.env.BEAVER_VALIDATE_EMAIL || `${username}@example.test`; const portalUrl = (process.env.BEAVER_VALIDATE_PORTAL_URL || 'http://127.0.0.1:3081').replace(/\/$/, ''); const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined; const hostResolverRules = process.env.BEAVER_VALIDATE_HOST_RESOLVER_RULES || process.env.BEAVER_SMOKE_HOST_RESOLVER_RULES || ''; const headless = process.env.BEAVER_VALIDATE_HEADLESS !== '0'; const verifyMinio = process.env.BEAVER_VALIDATE_VERIFY_MINIO === '1'; const cleanupEnabled = process.env.BEAVER_VALIDATE_CLEANUP !== '0'; const screenshotDir = process.env.BEAVER_VALIDATE_SCREENSHOT_DIR || '/tmp'; const reportPath = process.env.BEAVER_VALIDATE_REPORT || ''; const taskScope = `ui-delete-${Date.now()}`; const deleteTargets = [ { root: 'uploads', path: 'uploads/delete-me-uploads.txt', dir: 'uploads', name: 'delete-me-uploads.txt' }, { root: 'outputs', path: 'outputs/delete-me-outputs.txt', dir: 'outputs', name: 'delete-me-outputs.txt' }, { root: 'shared', path: 'shared/delete-me-shared.txt', dir: 'shared', name: 'delete-me-shared.txt' }, { root: 'tasks', path: `tasks/${taskScope}/delete-me-tasks.txt`, dir: `tasks/${taskScope}`, name: 'delete-me-tasks.txt' }, ]; const pageChecks = [ { name: 'Files', path: '/files', marker: /文件管理|Files/ }, { name: 'Tasks', path: '/tasks', marker: /任务|Tasks/ }, { name: 'Tools/MCP', path: '/mcp', marker: /工具|Tools/ }, { name: 'Logs', path: '/logs', marker: /运行日志|Runtime Logs/ }, { name: 'Agents', path: '/agents', marker: /智能体|Agents/ }, { name: 'Skills', path: '/skills', marker: /技能|Skills/ }, ]; const launchArgs = []; if (hostResolverRules) { launchArgs.push(`--host-resolver-rules=${hostResolverRules}`); } const failedResponses = []; const failedRequests = []; const consoleErrors = []; const pageEvidence = []; function remember(list, value) { list.push(value); if (list.length > 80) { list.shift(); } } function sanitize(value) { return String(value) .replace(/(Authorization:\s*Bearer\s+)[^\s"']+/gi, '$1') .replace(/(access[_-]?key["']?\s*[:=]\s*["']?)[^"',\s]+/gi, '$1') .replace(/(secret[_-]?key["']?\s*[:=]\s*["']?)[^"',\s]+/gi, '$1') .replace(/(password["']?\s*[:=]\s*["']?)[^"',\s]+/gi, '$1'); } async function clickFirstVisible(locators) { for (const locator of locators) { if (await locator.first().isVisible().catch(() => false)) { await locator.first().click(); return true; } } return false; } async function registerAndHandoff(page) { const registerUrl = new URL('/register', portalUrl); registerUrl.searchParams.set('next', '/files'); await page.goto(registerUrl.toString(), { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.locator('#username').fill(username); await page.locator('#email').fill(email); await page.locator('#password').fill(password); await page.locator('#confirmPassword').fill(password); await page.locator('form').first().locator('button[type="submit"]').click(); await page.waitForResponse( (response) => response.url().includes('/api/runtime/register'), { timeout: 180000 }, ); await page.getByText(/配置模型|Model Setup/).waitFor({ timeout: 180000 }); const skipped = await clickFirstVisible([ page.getByRole('button', { name: /跳过|Skip/i }), page.locator('button.secondary-button'), ]); if (!skipped) { throw new Error('Model setup skip button was not found'); } await page.waitForURL(/\/files$/, { timeout: 90000 }); await page.waitForLoadState('domcontentloaded', { timeout: 30000 }).catch(() => {}); await page.getByText(/文件管理|Files/).waitFor({ timeout: 30000 }); const token = await page.evaluate(() => window.localStorage.getItem('beaver_access_token') || ''); if (!token) { throw new Error('No Beaver access token found after app handoff'); } const me = await apiFetch(page, token, '/api/auth/me'); if (me.status !== 200 || me.body?.username !== username) { throw new Error(`App auth confirmation failed: ${JSON.stringify(me)}`); } assertAppHost(page.url()); return token; } function assertAppHost(url) { const current = new URL(url); const portal = new URL(portalUrl); if (current.host === portal.host || current.pathname.startsWith('/login') || current.pathname.startsWith('/register')) { throw new Error(`Browser is not on the app instance after handoff: ${url}`); } } async function apiFetch(page, token, path, options = {}) { return page.evaluate(async ({ token, path, options }) => { const response = await fetch(path, { ...options, headers: { ...(options.headers || {}), Authorization: `Bearer ${token}`, }, }); const text = await response.text(); let body = null; try { body = text ? JSON.parse(text) : null; } catch { body = text; } return { status: response.status, body }; }, { token, path, options }); } async function createDeleteTargets(page, token) { const result = await page.evaluate(async ({ token, deleteTargets, taskScope }) => { const headers = { Authorization: `Bearer ${token}` }; const mkdir = await fetch(`/api/user-files/mkdir?path=${encodeURIComponent(`tasks/${taskScope}`)}`, { method: 'POST', headers, }); if (!mkdir.ok && mkdir.status !== 409) { throw new Error(`mkdir failed: ${mkdir.status}`); } const uploaded = []; for (const target of deleteTargets) { const form = new FormData(); form.set('path', target.dir); form.set('file', new File([`delete target ${target.path}`], target.name, { type: 'text/plain' })); const response = await fetch('/api/user-files/upload', { method: 'POST', headers, body: form, }); const body = await response.json().catch(() => ({})); if (!response.ok || body.path !== target.path) { throw new Error(`upload failed for ${target.path}: ${response.status} ${JSON.stringify(body)}`); } uploaded.push(body.path); } return uploaded; }, { token, deleteTargets, taskScope }); if (result.length !== deleteTargets.length) { throw new Error(`Unexpected upload target count: ${result.length}`); } } async function waitForRootEntries(page) { await page.getByText(/文件管理|Files/).waitFor({ timeout: 30000 }); for (const root of ['uploads', 'outputs', 'shared', 'tasks']) { await page.locator('button').filter({ hasText: root }).first().waitFor({ state: 'visible', timeout: 30000 }); } } async function navigateToRoot(page) { await page.goto(`${new URL(page.url()).origin}/files`, { waitUntil: 'domcontentloaded', timeout: 30000 }); await waitForRootEntries(page); } async function openDirectory(page, name) { const row = page.locator('button').filter({ hasText: name }).first(); await row.waitFor({ state: 'visible', timeout: 30000 }); await row.click(); await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}); } async function deleteFromFilesPage(page, target) { await navigateToRoot(page); await openDirectory(page, target.root); if (target.root === 'tasks') { await openDirectory(page, taskScope); } const row = page.locator('button').filter({ hasText: target.name }).first(); await row.waitFor({ state: 'visible', timeout: 30000 }); await row.hover(); await row.locator('[title="Delete"], [title="删除"]').last().click({ force: true }); await page.waitForFunction( (name) => !document.body.innerText.includes(name), target.name, { timeout: 30000 }, ); } async function verifyDeletedViaApi(page, token) { for (const target of deleteTargets) { const preview = await apiFetch(page, token, `/api/user-files/preview?path=${encodeURIComponent(target.path)}`); if (preview.status !== 404) { throw new Error(`Expected preview 404 after UI delete for ${target.path}, got ${preview.status}`); } } const roots = await apiFetch(page, token, '/api/user-files/browse'); if (roots.status !== 200) { throw new Error(`Root browse failed after UI deletion: ${roots.status}`); } const names = new Set((roots.body?.items || []).map((item) => item.name)); for (const root of ['uploads', 'outputs', 'shared', 'tasks']) { if (!names.has(root)) { throw new Error(`Virtual root disappeared after UI deletion: ${root}`); } } } function verifyDeletedInMinio() { if (!verifyMinio) { return false; } const endpoint = process.env.BEAVER_VALIDATE_MINIO_ENDPOINT || process.env.BEAVER_SMOKE_MINIO_ENDPOINT || 'http://127.0.0.1:9000'; const accessKey = process.env.BEAVER_VALIDATE_MINIO_ACCESS_KEY || process.env.BEAVER_SMOKE_MINIO_ACCESS_KEY || process.env.BEAVER_MINIO_ROOT_USER || ''; const secretKey = process.env.BEAVER_VALIDATE_MINIO_SECRET_KEY || process.env.BEAVER_SMOKE_MINIO_SECRET_KEY || process.env.BEAVER_MINIO_ROOT_PASSWORD || ''; const bucket = process.env.BEAVER_VALIDATE_MINIO_BUCKET || process.env.BEAVER_SMOKE_MINIO_BUCKET || process.env.BEAVER_USER_FILES_BUCKET || 'beaver-user-files'; const network = process.env.BEAVER_VALIDATE_MINIO_NETWORK || process.env.BEAVER_SMOKE_MINIO_NETWORK || ''; if (!accessKey || !secretKey) { throw new Error('MinIO verification requested but access key or secret key is missing'); } const checks = deleteTargets .map((target) => `if mc stat ${shellQuote(`beaver/${bucket}/users/${username}/${target.path}`)} >/dev/null 2>&1; then echo ${shellQuote(`object still exists: ${target.path}`)}; exit 1; fi`) .join(' && '); const containerArgs = ['run', '--rm', '--entrypoint', '/bin/sh']; if (network) { containerArgs.push('--network', network); } containerArgs.push( 'minio/mc:latest', '-lc', [ `mc alias set beaver ${shellQuote(endpoint)} ${shellQuote(accessKey)} ${shellQuote(secretKey)} >/dev/null`, checks, ].join(' && '), ); execFileSync('docker', containerArgs, { stdio: 'pipe' }); return true; } async function checkPage(page, appOrigin, check) { const beforeResponseCount = failedResponses.length; const beforeConsoleCount = consoleErrors.length; await page.goto(`${appOrigin}${check.path}`, { waitUntil: 'domcontentloaded', timeout: 30000 }); await page.getByText(check.marker).first().waitFor({ timeout: 30000 }); if (check.path === '/agents') { const subagentTab = page.getByRole('tab', { name: /Persistent sub-agents|Persistent Sub-Agents|Sub-Agent/i }).first(); if (await subagentTab.isVisible().catch(() => false)) { await subagentTab.click(); await page.getByText(/Sub-Agent|sub-agent|子智能体/i).first().waitFor({ timeout: 30000 }); } } await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}); assertAppHost(page.url()); const newResponses = failedResponses.slice(beforeResponseCount); const newConsoleErrors = consoleErrors.slice(beforeConsoleCount); const hardFailures = newResponses.filter((entry) => entry.status >= 500 || (entry.url.includes('/api/user-files/') && entry.status >= 400)); if (hardFailures.length) { throw new Error(`Page ${check.name} had failing responses: ${hardFailures.map((item) => `${item.status} ${item.url}`).join('; ')}`); } const fileConsoleErrors = newConsoleErrors.filter((entry) => /user[-_ ]?files|filesystem|minio|storage/i.test(entry.text)); if (fileConsoleErrors.length) { throw new Error(`Page ${check.name} had file-system-related console errors: ${fileConsoleErrors.map((item) => item.text).join('; ')}`); } pageEvidence.push({ name: check.name, path: check.path, finalUrl: page.url(), failedResponseCount: newResponses.length }); } async function cleanupDisposableUser() { const token = process.env.BEAVER_VALIDATE_DEPLOY_TOKEN || process.env.DEPLOY_CONTROL_API_TOKEN || ''; const deployUrl = (process.env.BEAVER_VALIDATE_DEPLOY_CONTROL_URL || process.env.DEPLOY_CONTROL_URL || 'http://127.0.0.1:8090').replace(/\/$/, ''); if (!cleanupEnabled || !token) { return { attempted: false, reason: cleanupEnabled ? 'missing deploy-control token' : 'disabled' }; } const response = await fetch(`${deployUrl}/api/instances/${encodeURIComponent(username)}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', 'X-Purge-Data': '1', 'X-Purge-User-Files': '1', }, }); const body = await response.json().catch(() => ({})); return { attempted: true, status: response.status, ok: response.ok, body }; } function shellQuote(value) { return `'${String(value).replace(/'/g, "'\\''")}'`; } async function main() { const browser = await chromium.launch({ headless, executablePath, args: launchArgs }); const page = await browser.newPage({ viewport: { width: 1365, height: 900 } }); page.on('dialog', (dialog) => dialog.accept()); page.on('response', (response) => { if (response.status() >= 400) { remember(failedResponses, { status: response.status(), url: response.url() }); } }); page.on('requestfailed', (request) => { remember(failedRequests, { url: request.url(), error: request.failure()?.errorText || '' }); }); page.on('console', (message) => { if (message.type() === 'error') { remember(consoleErrors, { text: sanitize(message.text()) }); } }); let cleanup = { attempted: false, reason: 'not reached' }; try { const token = await registerAndHandoff(page); const appOrigin = new URL(page.url()).origin; await waitForRootEntries(page); await createDeleteTargets(page, token); for (const target of deleteTargets) { await deleteFromFilesPage(page, target); } await verifyDeletedViaApi(page, token); const minioVerified = verifyDeletedInMinio(); for (const check of pageChecks) { await checkPage(page, appOrigin, check); } const finalScreenshot = `${screenshotDir}/beaver-filesystem-validation-${username}.png`; await page.screenshot({ path: finalScreenshot, fullPage: true }).catch(() => {}); cleanup = await cleanupDisposableUser(); const result = { ok: true, username, email, deletedRoots: deleteTargets.map((target) => target.root), deletedPaths: deleteTargets.map((target) => target.path), checkedPages: pageEvidence, minioVerified, cleanup, screenshot: finalScreenshot, failedResponses, failedRequests, consoleErrors, }; if (reportPath) { writeFileSync(reportPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8'); } console.log(JSON.stringify(result, null, 2)); } catch (error) { const errorScreenshot = `${screenshotDir}/beaver-filesystem-validation-${username}-error.png`; await page.screenshot({ path: errorScreenshot, fullPage: true }).catch(() => {}); cleanup = await cleanupDisposableUser().catch((cleanupError) => ({ attempted: true, ok: false, error: sanitize(cleanupError) })); const result = { ok: false, username, email, currentUrl: page.url(), error: sanitize(error), cleanup, screenshot: errorScreenshot, failedResponses, failedRequests, consoleErrors, }; if (reportPath) { writeFileSync(reportPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8'); } console.error(JSON.stringify(result, null, 2)); process.exitCode = 1; } finally { await browser.close(); } } await main();