test(app): add E2E tests and improve form state with test coverage
This commit is contained in:
@@ -2,10 +2,12 @@ import { test, expect } from './fixtures';
|
|||||||
import { gotoHome, openSettings } from './helpers/app';
|
import { gotoHome, openSettings } from './helpers/app';
|
||||||
|
|
||||||
test('daemon is connected in settings', async ({ page }) => {
|
test('daemon is connected in settings', async ({ page }) => {
|
||||||
|
const daemonPort = process.env.E2E_DAEMON_PORT ?? '6767';
|
||||||
|
|
||||||
await gotoHome(page);
|
await gotoHome(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
|
|
||||||
await expect(page.getByText('localhost', { exact: true })).toBeVisible();
|
await expect(page.getByText('localhost', { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText('ws://localhost:6767/ws')).toBeVisible();
|
await expect(page.getByText(`ws://localhost:${daemonPort}/ws`)).toBeVisible();
|
||||||
await expect(page.getByText('Online', { exact: true })).toBeVisible();
|
await expect(page.getByText('Online', { exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { test, expect } from './fixtures';
|
||||||
|
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||||
|
import { createTempGitRepo } from './helpers/workspace';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
function addFakeMicrophone(page: Page) {
|
||||||
|
return page.addInitScript(() => {
|
||||||
|
const mic = {
|
||||||
|
active: 0,
|
||||||
|
getUserMediaCalls: 0,
|
||||||
|
stopCalls: 0,
|
||||||
|
lastRecorder: null as null | { state: string },
|
||||||
|
};
|
||||||
|
(window as any).__mic = mic;
|
||||||
|
|
||||||
|
const ensureMediaDevices = () => {
|
||||||
|
const nav = navigator as any;
|
||||||
|
if (!nav.mediaDevices) {
|
||||||
|
nav.mediaDevices = {};
|
||||||
|
}
|
||||||
|
if (typeof nav.mediaDevices.getUserMedia !== 'function') {
|
||||||
|
nav.mediaDevices.getUserMedia = async () => {
|
||||||
|
mic.getUserMediaCalls += 1;
|
||||||
|
mic.active += 1;
|
||||||
|
const track = {
|
||||||
|
stop: () => {
|
||||||
|
mic.stopCalls += 1;
|
||||||
|
mic.active = Math.max(0, mic.active - 1);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
getTracks: () => [track],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ensureMediaDevices();
|
||||||
|
|
||||||
|
class FakeMediaRecorder extends EventTarget {
|
||||||
|
public static isTypeSupported() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public state: 'inactive' | 'recording' = 'inactive';
|
||||||
|
public mimeType: string;
|
||||||
|
public ondataavailable: ((event: { data: Blob }) => void) | null = null;
|
||||||
|
public onerror: ((event: unknown) => void) | null = null;
|
||||||
|
|
||||||
|
constructor(_stream: unknown, options?: MediaRecorderOptions) {
|
||||||
|
super();
|
||||||
|
this.mimeType = options?.mimeType ?? 'audio/webm';
|
||||||
|
mic.lastRecorder = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public start() {
|
||||||
|
this.state = 'recording';
|
||||||
|
}
|
||||||
|
|
||||||
|
public stop() {
|
||||||
|
if (this.state !== 'recording') {
|
||||||
|
throw new Error('Not recording');
|
||||||
|
}
|
||||||
|
this.state = 'inactive';
|
||||||
|
try {
|
||||||
|
this.ondataavailable?.({
|
||||||
|
data: new Blob(['paseo-e2e-audio'], { type: this.mimeType }),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.onerror?.(err);
|
||||||
|
}
|
||||||
|
this.dispatchEvent(new Event('stop'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(window as any).MediaRecorder = FakeMediaRecorder;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('dictation hotkeys do not trigger on background screens', async ({ page }) => {
|
||||||
|
await addFakeMicrophone(page);
|
||||||
|
|
||||||
|
const repo = await createTempGitRepo();
|
||||||
|
try {
|
||||||
|
await gotoHome(page);
|
||||||
|
await setWorkingDirectory(page, repo.path);
|
||||||
|
await ensureHostSelected(page);
|
||||||
|
await createAgent(page, 'Respond with exactly: Hello');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/agent\//);
|
||||||
|
|
||||||
|
await page.keyboard.press('Control+d');
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
|
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
||||||
|
const active = await page.evaluate(() => (window as any).__mic.active as number);
|
||||||
|
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
expect(active).toBe(1);
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect
|
||||||
|
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||||
|
.toBe(0);
|
||||||
|
} finally {
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancel stops mic even if recorder is already inactive', async ({ page }) => {
|
||||||
|
await addFakeMicrophone(page);
|
||||||
|
|
||||||
|
const repo = await createTempGitRepo();
|
||||||
|
try {
|
||||||
|
await gotoHome(page);
|
||||||
|
await setWorkingDirectory(page, repo.path);
|
||||||
|
await ensureHostSelected(page);
|
||||||
|
await createAgent(page, 'Respond with exactly: Hello');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/agent\//);
|
||||||
|
|
||||||
|
await page.keyboard.press('Control+d');
|
||||||
|
await expect
|
||||||
|
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||||
|
.toBe(1);
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const mic = (window as any).__mic as { lastRecorder: null | { state: string } };
|
||||||
|
if (mic.lastRecorder) {
|
||||||
|
mic.lastRecorder.state = 'inactive';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect
|
||||||
|
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||||
|
.toBe(0);
|
||||||
|
} finally {
|
||||||
|
await repo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { test, expect } from './fixtures';
|
||||||
|
import { ensureHostSelected, gotoHome } from './helpers/app';
|
||||||
|
|
||||||
|
test('new agent auto-selects the previous host', async ({ page }) => {
|
||||||
|
await gotoHome(page);
|
||||||
|
await ensureHostSelected(page);
|
||||||
|
|
||||||
|
// Ensure preference is persisted so a full reload can restore it.
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const stored = localStorage.getItem('@paseo:create-agent-preferences');
|
||||||
|
if (!stored) return false;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
return parsed?.serverId === 'e2e-test-daemon';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await gotoHome(page);
|
||||||
|
|
||||||
|
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||||
|
await expect(input).toBeEditable();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('new agent respects serverId in the URL', async ({ page }) => {
|
||||||
|
await page.goto('/?serverId=e2e-test-daemon&serverId=e2e-test-daemon');
|
||||||
|
await expect(page.getByText('New Agent', { exact: true }).first()).toBeVisible();
|
||||||
|
|
||||||
|
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||||
|
await expect(input).toBeEditable();
|
||||||
|
});
|
||||||
|
|
||||||
@@ -45,6 +45,14 @@ function getParamValue(value: string | string[] | undefined) {
|
|||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
return trimmed.length > 0 ? trimmed : undefined;
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const entry of value) {
|
||||||
|
const trimmed = entry.trim();
|
||||||
|
if (trimmed.length > 0) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { __private__ } from "./use-agent-form-state";
|
||||||
|
|
||||||
|
describe("useAgentFormState", () => {
|
||||||
|
describe("__private__.combineInitialValues", () => {
|
||||||
|
it("returns undefined when no initial values and no initial server id", () => {
|
||||||
|
expect(__private__.combineInitialValues(undefined, null)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not inject a null serverId override when initialValues are present but serverId is absent", () => {
|
||||||
|
const combined = __private__.combineInitialValues({}, null);
|
||||||
|
expect(combined).toEqual({});
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects serverId from options when provided", () => {
|
||||||
|
expect(__private__.combineInitialValues({}, "daemon-1")).toEqual({
|
||||||
|
serverId: "daemon-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps other initial values without forcing serverId", () => {
|
||||||
|
const combined = __private__.combineInitialValues({ workingDir: "/repo" }, null);
|
||||||
|
expect(combined).toEqual({ workingDir: "/repo" });
|
||||||
|
expect(Object.prototype.hasOwnProperty.call(combined, "serverId")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects an explicit serverId override (including null) over initialServerId", () => {
|
||||||
|
expect(__private__.combineInitialValues({ serverId: null }, "daemon-1")).toEqual({
|
||||||
|
serverId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(__private__.combineInitialValues({ serverId: "daemon-2" }, "daemon-1")).toEqual({
|
||||||
|
serverId: "daemon-2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -193,6 +193,29 @@ function resolveFormState(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function combineInitialValues(
|
||||||
|
initialValues: FormInitialValues | undefined,
|
||||||
|
initialServerId: string | null
|
||||||
|
): FormInitialValues | undefined {
|
||||||
|
const hasExplicitServerId = initialValues?.serverId !== undefined;
|
||||||
|
const serverIdFromOptions = initialServerId === null ? undefined : initialServerId;
|
||||||
|
|
||||||
|
// If nobody provided initial values or an explicit serverId, let preferences drive defaults.
|
||||||
|
if (!initialValues && !hasExplicitServerId && serverIdFromOptions === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasExplicitServerId) {
|
||||||
|
return { ...initialValues, serverId: initialValues?.serverId };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverIdFromOptions !== undefined) {
|
||||||
|
return { ...initialValues, serverId: serverIdFromOptions };
|
||||||
|
}
|
||||||
|
|
||||||
|
return initialValues;
|
||||||
|
}
|
||||||
|
|
||||||
export function useAgentFormState(
|
export function useAgentFormState(
|
||||||
options: UseAgentFormStateOptions = {}
|
options: UseAgentFormStateOptions = {}
|
||||||
): UseAgentFormStateResult {
|
): UseAgentFormStateResult {
|
||||||
@@ -251,13 +274,7 @@ export function useAgentFormState(
|
|||||||
|
|
||||||
// Combine initialValues with initialServerId for resolution
|
// Combine initialValues with initialServerId for resolution
|
||||||
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
|
||||||
if (!initialValues && initialServerId === null) {
|
return combineInitialValues(initialValues, initialServerId);
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...initialValues,
|
|
||||||
serverId: initialValues?.serverId ?? initialServerId,
|
|
||||||
};
|
|
||||||
}, [initialValues, initialServerId]);
|
}, [initialValues, initialServerId]);
|
||||||
|
|
||||||
// Resolve form state when data sources change
|
// Resolve form state when data sources change
|
||||||
@@ -547,3 +564,8 @@ export function useAgentFormState(
|
|||||||
|
|
||||||
// Re-export for backwards compatibility
|
// Re-export for backwards compatibility
|
||||||
export type CreateAgentInitialValues = FormInitialValues;
|
export type CreateAgentInitialValues = FormInitialValues;
|
||||||
|
|
||||||
|
export const __private__ = {
|
||||||
|
combineInitialValues,
|
||||||
|
resolveFormState,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig, configDefaults } from "vitest/config";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "node",
|
||||||
exclude: ["e2e/**"],
|
exclude: [...configDefaults.exclude, "e2e/**"],
|
||||||
/**
|
/**
|
||||||
* Expo pulls in native tooling (xcode, etc.) that executes files relying on `process.send`.
|
* Expo pulls in native tooling (xcode, etc.) that executes files relying on `process.send`.
|
||||||
* Vitest's default worker pool uses worker_threads, which intentionally stub that API and
|
* Vitest's default worker pool uses worker_threads, which intentionally stub that API and
|
||||||
|
|||||||
Reference in New Issue
Block a user