396 lines
16 KiB
JavaScript
Executable File
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();
|