feat: add download toast, e2e permission tests, and update favicons
- Add download toast component with progress indicator and auto-dismiss - Add download store for managing file download state - Add e2e tests for permission prompts (allow/deny flows) - Add global setup for e2e tests with isolated daemon instance - Update favicon assets with new design - Refactor agent-stream-view to use shared tool-call-details component - Simplify file-explorer-pane component - Clean up unused screenshots and PRODUCTION.md 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -2,7 +2,7 @@ import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
const consoleEntries = new WeakMap<Page, string[]>();
|
||||
|
||||
test.beforeEach(({ page }) => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const entries: string[] = [];
|
||||
consoleEntries.set(page, entries);
|
||||
|
||||
@@ -13,6 +13,21 @@ test.beforeEach(({ page }) => {
|
||||
page.on('pageerror', (error) => {
|
||||
entries.push(`[pageerror] ${error.message}`);
|
||||
});
|
||||
|
||||
// Set up test daemon connection if available
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (daemonPort) {
|
||||
const testDaemon = {
|
||||
id: 'e2e-test-daemon',
|
||||
label: 'localhost',
|
||||
wsUrl: `ws://localhost:${daemonPort}/ws`,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await page.addInitScript((daemon) => {
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
}, testDaemon);
|
||||
}
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { spawn, type ChildProcess, execSync } from 'node:child_process';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Failed to acquire port')));
|
||||
return;
|
||||
}
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, timeout = 15000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(port, 'localhost', () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.on('error', reject);
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
throw new Error(`Server did not start on port ${port} within ${timeout}ms`);
|
||||
}
|
||||
|
||||
let daemonProcess: ChildProcess | null = null;
|
||||
let paseoHome: string | null = null;
|
||||
|
||||
export default async function globalSetup() {
|
||||
const port = await getAvailablePort();
|
||||
paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-'));
|
||||
|
||||
const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
|
||||
const tsxBin = execSync('which tsx').toString().trim();
|
||||
|
||||
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||
PASEO_CORS_ORIGINS: 'http://localhost:8081',
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
daemonProcess.stdout?.on('data', (data: Buffer) => {
|
||||
console.log(`[daemon] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
daemonProcess.stderr?.on('data', (data: Buffer) => {
|
||||
console.error(`[daemon] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
await waitForServer(port);
|
||||
|
||||
process.env.E2E_DAEMON_PORT = String(port);
|
||||
console.log(`[e2e] Test daemon started on port ${port}, home: ${paseoHome}`);
|
||||
|
||||
return async () => {
|
||||
if (daemonProcess) {
|
||||
daemonProcess.kill('SIGTERM');
|
||||
daemonProcess = null;
|
||||
}
|
||||
if (paseoHome) {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
paseoHome = null;
|
||||
}
|
||||
console.log('[e2e] Test daemon stopped');
|
||||
};
|
||||
}
|
||||
@@ -59,3 +59,105 @@ export const createAgent = async (page: Page, message: string) => {
|
||||
await page.waitForURL(/\/agent\//);
|
||||
await expect(page.getByText(message, { exact: true })).toBeVisible();
|
||||
};
|
||||
|
||||
export interface AgentConfig {
|
||||
directory: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
mode?: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export const selectProvider = async (page: Page, provider: string) => {
|
||||
const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
|
||||
await expect(providerLabel).toBeVisible();
|
||||
await providerLabel.click();
|
||||
|
||||
const option = page.getByText(provider, { exact: true }).first();
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
};
|
||||
|
||||
export const selectModel = async (page: Page, model: string) => {
|
||||
const modelLabel = page.getByText('MODEL', { exact: true }).first();
|
||||
await expect(modelLabel).toBeVisible();
|
||||
await modelLabel.click();
|
||||
|
||||
// Wait for the model dropdown to open
|
||||
const searchInput = page.getByRole('textbox', { name: /search model/i });
|
||||
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Type to search/filter models
|
||||
await searchInput.fill(model);
|
||||
|
||||
// Wait for a matching option to appear (partial match via regex)
|
||||
const option = page.getByText(new RegExp(model, 'i')).first();
|
||||
await expect(option).toBeVisible({ timeout: 30000 });
|
||||
await option.click();
|
||||
|
||||
// Wait for dropdown to close
|
||||
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
|
||||
};
|
||||
|
||||
export const selectMode = async (page: Page, mode: string) => {
|
||||
const modeLabel = page.getByText('MODE', { exact: true }).first();
|
||||
await expect(modeLabel).toBeVisible();
|
||||
await modeLabel.click();
|
||||
|
||||
// Wait for the mode dropdown to open
|
||||
const searchInput = page.getByRole('textbox', { name: /search mode/i });
|
||||
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Type to filter modes
|
||||
await searchInput.fill(mode);
|
||||
|
||||
// Click the matching option (use last() since the field label also contains the mode text)
|
||||
const option = page.getByText(mode, { exact: true }).last();
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
|
||||
// Wait for dropdown to close
|
||||
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
|
||||
};
|
||||
|
||||
export const createAgentWithConfig = async (page: Page, config: AgentConfig) => {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, config.directory);
|
||||
|
||||
if (config.provider) {
|
||||
await selectProvider(page, config.provider);
|
||||
}
|
||||
|
||||
if (config.model) {
|
||||
await selectModel(page, config.model);
|
||||
}
|
||||
|
||||
if (config.mode) {
|
||||
await selectMode(page, config.mode);
|
||||
}
|
||||
|
||||
await createAgent(page, config.prompt);
|
||||
};
|
||||
|
||||
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
|
||||
const promptText = page.getByText('How would you like to proceed?').first();
|
||||
await expect(promptText).toBeVisible({ timeout });
|
||||
};
|
||||
|
||||
export const allowPermission = async (page: Page) => {
|
||||
const allowButton = page.getByText('Allow', { exact: true }).first();
|
||||
await expect(allowButton).toBeVisible({ timeout: 5000 });
|
||||
await allowButton.click();
|
||||
};
|
||||
|
||||
export const denyPermission = async (page: Page) => {
|
||||
const denyButton = page.getByText('Deny', { exact: true }).first();
|
||||
await expect(denyButton).toBeVisible({ timeout: 5000 });
|
||||
await denyButton.click();
|
||||
};
|
||||
|
||||
export async function waitForAgentIdle(page: Page, timeout = 30000) {
|
||||
const stopButton = page.getByRole('button', { name: /stop|cancel/i });
|
||||
await expect(stopButton).not.toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ export const createTempGitRepo = async (prefix = 'paseo-e2e-'): Promise<TempRepo
|
||||
|
||||
execSync('git init', { cwd: repoPath, stdio: 'ignore' });
|
||||
await writeFile(path.join(repoPath, 'README.md'), '# Temp Repo\n');
|
||||
execSync('git add README.md', { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: 'ignore' });
|
||||
|
||||
return {
|
||||
path: repoPath,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { test, expect } from './fixtures';
|
||||
import {
|
||||
createAgentWithConfig,
|
||||
waitForPermissionPrompt,
|
||||
allowPermission,
|
||||
denyPermission,
|
||||
waitForAgentIdle,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
const FILE_CONTENT = 'Hello from permission test';
|
||||
|
||||
test.describe('permission prompts', () => {
|
||||
test('allow permission creates the file', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const uniqueFilename = `test-allow-${Date.now()}.txt`;
|
||||
const filePath = path.join(repo.path, uniqueFilename);
|
||||
const prompt = `Create a file named "${uniqueFilename}" with the content "${FILE_CONTENT}". Do not add any extra content.`;
|
||||
|
||||
try {
|
||||
await createAgentWithConfig(page, {
|
||||
directory: repo.path,
|
||||
model: 'haiku',
|
||||
mode: 'Always Ask',
|
||||
prompt,
|
||||
});
|
||||
|
||||
await waitForPermissionPrompt(page, 30000);
|
||||
await allowPermission(page);
|
||||
|
||||
// Wait for file to be created
|
||||
await expect
|
||||
.poll(() => existsSync(filePath), {
|
||||
message: `File ${filePath} should exist after allowing permission`,
|
||||
timeout: 10000,
|
||||
})
|
||||
.toBe(true);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('deny permission does not create the file', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const uniqueFilename = `test-deny-${Date.now()}.txt`;
|
||||
const filePath = path.join(repo.path, uniqueFilename);
|
||||
const prompt = `Create a file named "${uniqueFilename}" with the content "${FILE_CONTENT}". Do not add any extra content.`;
|
||||
|
||||
try {
|
||||
await createAgentWithConfig(page, {
|
||||
directory: repo.path,
|
||||
model: 'haiku',
|
||||
mode: 'Always Ask',
|
||||
prompt,
|
||||
});
|
||||
|
||||
await waitForPermissionPrompt(page, 30000);
|
||||
await denyPermission(page);
|
||||
await waitForAgentIdle(page);
|
||||
|
||||
expect(existsSync(filePath)).toBe(false);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user