Files
beaver_project/scripts/validate-filesystem-automation.mjs
2026-06-03 12:06:34 +08:00

396 lines
16 KiB
JavaScript
Executable File

#!/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<redacted>')
.replace(/(access[_-]?key["']?\s*[:=]\s*["']?)[^"',\s]+/gi, '$1<redacted>')
.replace(/(secret[_-]?key["']?\s*[:=]\s*["']?)[^"',\s]+/gi, '$1<redacted>')
.replace(/(password["']?\s*[:=]\s*["']?)[^"',\s]+/gi, '$1<redacted>');
}
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();