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:
Mohamed Boudra
2026-01-16 20:23:07 +07:00
parent f733f2852d
commit 420f25f1e9
39 changed files with 877 additions and 931 deletions
+16 -1
View File
@@ -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) => {
+88
View File
@@ -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');
};
}
+102
View File
@@ -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 });
}
+2
View File
@@ -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();
}
});
});