mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
* chore(e2e): begin Playwright test-quality pass
* test(e2e): migrate terminal-performance spec onto TerminalE2EHarness
Replace the hand-rolled daemon-client lifecycle (connectTerminalClient +
openProject + createTerminal/navigateToTerminal/killTerminal) with the shared
TerminalE2EHarness, matching terminal-keystroke-stress.spec.ts. The spec now
seeds the workspace and terminals through the harness vocabulary instead of
duplicating setup, cutting the file by ~40 lines with no behavior change.
* test(e2e): extract shared mock-agent workspace helper
Five specs reimplemented the same setup: seed a temp repo, open it as a
project, create a mock-provider agent, then navigate to its workspace route.
Extract seedMockAgentWorkspace + openAgentRoute into helpers/mock-agent.ts and
migrate the rewind-menu and user-message-contract UI-contract specs onto them,
dropping their local getServerId/openAgent/inline-seed duplication.
* test(e2e): migrate terminal tab rename spec onto TerminalE2EHarness
Replace the spec's hand-rolled daemon client setup (connectTerminalClient +
openProject + createTerminal + navigateToTerminal + manual cleanup) with the
shared TerminalE2EHarness and withTerminalInApp helpers, matching the other
terminal specs. Behavior and assertions are unchanged.
* test(e2e): share openProjectViaDaemon across sidebar specs
Both sidebar-workspace and sidebar-workspace-rename rolled their own
identical openProjectViaDaemon helper against the workspace-setup daemon
client. Promote a single shared helper into helpers/workspace-setup.ts
(returning id/name/workspaceDirectory) and have seedProjectForWorkspaceSetup
delegate to it, removing the duplicated open-project logic.
* test(e2e): migrate client-slash-commands spec onto shared mock-agent helper
Replace the spec's hand-rolled openProject/createReadyMockAgent/
openActiveAgentTab/getServerId setup with seedMockAgentWorkspace and
openAgentRoute from helpers/mock-agent, matching the rewind-menu and
user-message-contract specs. Drops ~60 lines of duplicated daemon-client
wiring; the test bodies now read as domain intent.
* test(e2e): converge remaining workspace-setup specs onto shared daemon helpers
Replace the last inline openProject + null-check blocks with the shared
openProjectViaDaemon helper in the file-explorer-collapse and pr-pane specs,
and route workspace-setup-runtime project registration through
seedProjectForWorkspaceSetup. Behavior is identical; the seeding vocabulary now
lives entirely in helpers/workspace-setup.ts.
* test(e2e): migrate composer-autocomplete spec onto shared mock-agent helper
Replace the spec's hand-rolled daemon client setup (connectTerminalClient +
openProject + createAgent + route building + bespoke cleanup) with
seedMockAgentWorkspace + openAgentRoute from helpers/mock-agent, matching the
client-slash-commands migration. Drops the local getServerId, openProject, and
cleanupWithin helpers.
* test(e2e): migrate codex-plan-approval spec onto shared mock-agent helper
Replace the hand-rolled daemon client, project open, agent creation, and
manual route building with seedMockAgentWorkspace + openAgentRoute. The spec
now reads as intent: seed a mock agent, open it, approve the plan, assert the
panel clears.
* test(e2e): converge daemon-client bootstrap onto one shared factory
The five e2e helpers each rolled their own daemon-client connect logic —
duplicating the ws-url resolution, node WebSocket factory, client
construction, and connect call, plus a redeclared config interface.
Extract a single connectDaemonClient factory in daemon-client-loader.ts
that each helper delegates to with its own typed client interface. This
also propagates the port-6767 safety guard (previously only in two
helpers) to all of them, so no test client can target the developer
daemon. Specs are untouched; behavior is preserved.
* test(e2e): promote shared daemon seed client out of terminal-perf helper
The general-purpose E2E daemon client (workspace/agent/terminal seeding and
driving) had grown inside terminal-perf.ts under the name
TerminalPerfDaemonClient, even though most consumers — mock-agent, composer,
rewind-flow, and the launcher/title-handoff specs — have nothing to do with
terminal performance. Move the interface and its connect factory into a
neutrally-named helpers/seed-client.ts (SeedDaemonClient / connectSeedClient)
and point every consumer at it. terminal-perf.ts keeps only its terminal page
helpers. Pure rename/relocate; no behavior change.
* test(e2e): converge E2E_SERVER_ID lookup onto one shared helper
The server-id env accessor was copy-pasted as a local `getServerId`
function in six helpers and five specs (plus a `requireServerId` twin in
the sidebar helper). Extract a single `helpers/server-id.ts` accessor and
route every caller through it, so a new spec imports the vocabulary
instead of re-deriving it. Pure refactor — identical lookup behavior.
* test(e2e): finish converging server-id lookup onto the shared helper
The previous pass extracted helpers/server-id.ts but only routed callers
that wrapped the env read in a local function. Eight specs still re-derived
the lookup inline — some as their own copy-pasted getSeededServerId/
getE2EServerId functions, some as bare `process.env.E2E_SERVER_ID` blocks
inside test bodies. Route them all through getServerId() so a new spec
imports the vocabulary instead of re-deriving it. Pure refactor — identical
lookup behavior; daemon-port reads are left untouched.
* test(e2e): converge workspace seeding onto a shared seedWorkspace helper
Extract the repeated temp-repo + seed-client + openProject bootstrap into
seedWorkspace() in helpers/seed-client.ts, returning a {client, repoPath,
workspaceId, cleanup} handle. seedMockAgentWorkspace and the agent-title-handoff
spec now build on it instead of re-rolling the trio, so the open-project error
handling and teardown live in one place.
* test(e2e): converge launcher-tab spec onto the shared seedWorkspace helper
Replace the hand-rolled createTempGitRepo + connectSeedClient + openProject
trio in beforeAll with seedWorkspace(), and drop the redundant second seed
client in the terminal-title block in favor of the shared workspace.client.
* test(e2e): converge file-explorer-collapse onto the shared seedWorkspace helper
Teach seedWorkspace to forward createTempGitRepo options (files/branches/
remote) so file-seeding specs can drop their hand-rolled
createTempGitRepo + connect + openProjectViaDaemon trio. Migrate
file-explorer-collapse onto it, removing its bespoke WorkspaceSetup client setup.
* test(e2e): converge non-git project setup onto a shared workspace helper
Promote the non-git createTempDirectory out of sidebar-workspace.spec.ts
into helpers/workspace.ts next to createTempGitRepo, and dedupe the
copy-pasted temp-root resolution (workspace.ts, with-workspace.ts, and the
spec) into one shared resolveTempRoot(). The spec drops its low-level
node:fs imports and reads in domain terms.
* test(e2e): converge agent-tab-rename spec onto the shared seedWorkspace helper
Drop the bespoke daemon-client + temp-repo + manual-cleanup trio in
workspace-agent-tab-rename and seed through seedWorkspace, matching the
other converged specs. createIdleAgent now takes a minimal structural
client interface so it accepts either the archive-tab client or the
shared seed client (type-only; the existing archive-tab callers are
unchanged). Verified by running the spec on Desktop Chrome.
* test(e2e): converge pane-remount spec onto the shared seedWorkspace helper
Drop the bespoke archive-tab daemon client + temp-repo + manual-cleanup
trio in workspace-pane-remount and seed through seedWorkspace, matching
the other converged specs. createIdleAgent already accepts the shared
seed client structurally, so the only behavior change is teardown now
goes through workspace.cleanup(). Verified by running the spec on
Desktop Chrome.
* test(e2e): converge sidebar-workspace-rename onto the shared seedWorkspace helper
Expose workspaceName and workspaceDirectory on SeededWorkspace (sourced from
the open-project response) so branch-rename specs can read the resolved branch
name and checkout directory without a bespoke client. Migrate
sidebar-workspace-rename off its hand-rolled connectWorkspaceSetupClient +
createTempGitRepo + openProjectViaDaemon trio, dropping the per-test
client/repo cleanup in favor of seedWorkspace's single cleanup handle.
* test(e2e): converge sidebar-workspace list onto the shared seedWorkspace helper
Migrate all five "Sidebar workspace list" tests off their hand-rolled
connectWorkspaceSetupClient + createTempGitRepo/createTempDirectory +
openProjectViaDaemon trio onto seedWorkspace, collapsing each test's
client/repo cleanup into the single seedWorkspace cleanup handle and dropping
the spec-local setGitHubRemote/execSync machinery.
Two small helper additions make the full file converge:
- seedWorkspace gains a `git: false` option that seeds a plain non-git
directory (via createTempDirectory) instead of a git repo, so the non-git
project test gets the same single-handle treatment.
- createTempGitRepo's configureRemote now relabels origin to a display URL
when both `withRemote` and `originUrl` are given: it sets up the local
tracking remote, pushes, then `git remote set-url` to the GitHub URL. This
reproduces the prior withRemote + setGitHubRemote git state exactly (real
local tracking refs, GitHub origin URL for project grouping) in one fixture
call, so the GitHub-remote tests are behavior-preserving.
* test(e2e): converge projects-settings onto the shared seedWorkspace helper
Replace the per-fixture connectNewWorkspaceDaemonClient + createTempGitRepo +
openProjectViaDaemon trio with seedWorkspace(), and expose the daemon's
projectId/projectDisplayName on SeededWorkspace so fixtures can read the
project label directly. All 9 specs pass.
* test(e2e): converge composer-attachments onto the shared seedWorkspace helper
Replace the inline connectNewWorkspaceDaemonClient + createTempGitRepo +
openProjectViaDaemon trio in the "composer is locked while new workspace agent
is being created" test with a single seedWorkspace() call, dropping the manual
client.close()/repo.cleanup() teardown in favor of workspace.cleanup(). The
test still passes against a real daemon.
* test(e2e): converge settings-toggle-tab-regression onto the shared seedWorkspace helper
Both tests rolled their own daemon client + temp git repo + manual agent
archive cleanup. Replace that trio with seedWorkspace(), drive idle agents
through workspace.client, and route off workspace.repoPath, leaving cleanup
to workspace.cleanup(). Matches the pattern already used by
workspace-pane-remount and workspace-agent-tab-rename.
* test(e2e): converge workspace-navigation-regression onto the shared seedWorkspace helper
Replace the bespoke connect/openProject/createTempGitRepo/archive trios in
the reconnect, cold-URL, and sidebar-navigation tests with seedWorkspace(),
matching the other migrated specs. Cleanup collapses to workspace.cleanup().
* test(e2e): drop orphaned dead code from agent-bottom-anchor helper
The agent-bottom-anchor spec was removed in a prior cleanup, but its
helper kept a private daemon-client interface and connect fn (duplicating
the shared seed client), seedBottomAnchorAgent, the reply-message builders,
and several scroll helpers that no spec references anymore. Only
readScrollMetrics, expectNearBottom, and waitForContentGrowth are still
used (by agent-stream.ts); keep those and delete the rest.
* test(e2e): converge archive-tab daemon client onto the shared seed client
Fold archiveAgent and fetchAgentHistory into the canonical SeedDaemonClient
and route the archive-tab and sessions-empty specs through connectSeedClient,
deleting the bespoke ArchiveTabDaemonClient wrapper. Both specs only need
general-purpose agent seed/drive operations, so they now share one client
interface instead of re-declaring their own.
* test(e2e): derive workspace-setup daemon client from the real client type
Replace the hand-rolled WorkspaceSetupDaemonClient interface with a
Pick<InternalDaemonClient, ...> over the real daemon client, matching the
pattern already used by the new-workspace helper. The 45-line re-declaration
of RPC method signatures could silently drift from the protocol; deriving it
from the source of truth keeps the test client honest and shrinks the helper.
* test(e2e): converge duplicated escapeRegex onto one shared helper
Seven byte-identical copies of escapeRegex lived across three specs and four
helpers. Extract a single helpers/regex.ts and route every caller through it,
so the suite has one regex-escaping primitive instead of re-declaring the same
pure function per file.
* test(e2e): converge E2E_DAEMON_PORT resolution onto one shared accessor
The isolated test daemon's port was re-read from the environment in ~10
places — three local helper functions in specs, two inline blocks, and
several helper modules — each repeating the "throw if unset" check and,
in the safety-critical paths, the "refuse the developer daemon (6767)"
guard.
Add helpers/daemon-port.ts exporting getE2EDaemonPort(), mirroring
getServerId(), and route every reader through it. The 6767 guard now
applies everywhere: the test port is never legitimately 6767, so
refusing it uniformly keeps every spec off the developer daemon.
While here, route the two inline port-regex escapes through the existing
escapeRegex helper instead of hand-inlining the same pattern.
* test(e2e): capture create-agent cwd via shared WS-frame helper
workspace-cwd's draft-agent test rolled its own request recorder by
monkeypatching WebSocket.prototype.send and stashing frames on a
window global, then reading them back through page.evaluate. Replace
that with captureWsSessionFrames — the same outbound-frame helper four
other specs already use for create_agent_request — so the assertion
reads the cwd directly and the spec drops the browser-side internals
reach-around. No behavior change; the three cwd cases still pass.
* test(e2e): converge daemon WS-route regex onto one shared helper
Five sites rebuilt the Playwright routeWebSocket matcher for the E2E
daemon inline as `new RegExp(`:${escapeRegex(getE2EDaemonPort())}\b`)`
(new-workspace, project-settings, composer-autocomplete, and two in
workspace-navigation-regression), and startup-dsl rolled the same regex
for arbitrary blocked test-host ports. Add daemonWsRoutePattern() and
wsRoutePatternForPort(port) to daemon-port.ts — whose docstring already
promised route patterns live here — and point every site at them.
The emitted regex is byte-identical, so interception behavior is
unchanged; composer-autocomplete still passes. Drops the now-unused
escapeRegex/getE2EDaemonPort imports and the redundant daemonPort locals.
436 lines
14 KiB
TypeScript
436 lines
14 KiB
TypeScript
import { expect, type Page } from "@playwright/test";
|
|
import { escapeRegex } from "./regex";
|
|
|
|
export const gotoAppShell = async (page: Page) => {
|
|
await page.goto("/");
|
|
};
|
|
|
|
export const gotoHome = async (page: Page) => {
|
|
await gotoAppShell(page);
|
|
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
|
const entryButton = page
|
|
.getByText("Add a project", { exact: true })
|
|
.or(page.getByText("Add project", { exact: true }))
|
|
.or(page.getByText("New agent", { exact: true }))
|
|
.first();
|
|
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(await composer.isVisible().catch(() => false)) ||
|
|
(await entryButton.isVisible().catch(() => false)),
|
|
{ timeout: 10_000 },
|
|
)
|
|
.toBe(true);
|
|
|
|
if (!(await composer.isVisible().catch(() => false))) {
|
|
await entryButton.click();
|
|
}
|
|
|
|
await expect(composer).toBeVisible({ timeout: 30_000 });
|
|
};
|
|
|
|
export const openSettings = async (page: Page) => {
|
|
// Navigate through the real app control so route changes stay aligned with UI behavior.
|
|
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
|
await expect(settingsButton).toBeVisible();
|
|
await settingsButton.click();
|
|
await expect(page).toHaveURL(/\/settings\/general$/);
|
|
};
|
|
|
|
export const setWorkingDirectory = async (page: Page, directory: string) => {
|
|
const workingDirectorySelect = page
|
|
.locator('[data-testid="working-directory-select"]:visible')
|
|
.first();
|
|
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
|
|
|
|
const legacyInput = page.getByRole("textbox", { name: "/path/to/project" }).first();
|
|
const directorySearchInput = page.getByRole("textbox", { name: /search directories/i }).first();
|
|
const worktreePicker = page.getByTestId("worktree-attach-picker");
|
|
const worktreeSheetTitle = page.getByText("Select worktree", { exact: true }).first();
|
|
const closeBottomSheet = async () => {
|
|
const bottomSheetBackdrop = page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
|
const bottomSheetHandle = page.getByRole("slider", { name: "Bottom sheet handle" }).first();
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
if (!(await bottomSheetBackdrop.isVisible())) {
|
|
return;
|
|
}
|
|
await bottomSheetBackdrop.click({ force: true });
|
|
await page.keyboard.press("Escape").catch(() => undefined);
|
|
await page.waitForTimeout(200);
|
|
}
|
|
if (await bottomSheetBackdrop.isVisible()) {
|
|
const box = await bottomSheetHandle.boundingBox();
|
|
if (box) {
|
|
const startX = box.x + box.width / 2;
|
|
const startY = box.y + box.height / 2;
|
|
await page.mouse.move(startX, startY);
|
|
await page.mouse.down();
|
|
await page.mouse.move(startX, startY + 400);
|
|
await page.mouse.up();
|
|
await page.waitForTimeout(200);
|
|
}
|
|
}
|
|
};
|
|
const closeWorktreeSheetIfOpen = async () => {
|
|
if (!(await worktreeSheetTitle.isVisible()) && !(await worktreePicker.isVisible())) {
|
|
return;
|
|
}
|
|
const attachToggle = page.getByTestId("worktree-attach-toggle");
|
|
if (await attachToggle.isVisible()) {
|
|
await attachToggle.click({ force: true });
|
|
await page.waitForTimeout(200);
|
|
}
|
|
await closeBottomSheet();
|
|
};
|
|
|
|
await closeWorktreeSheetIfOpen();
|
|
|
|
const pickerInputVisible = async () =>
|
|
(await directorySearchInput.isVisible().catch(() => false)) ||
|
|
(await legacyInput.isVisible().catch(() => false));
|
|
|
|
if (!(await pickerInputVisible())) {
|
|
await closeBottomSheet();
|
|
await workingDirectorySelect.click({ force: true });
|
|
if (!(await pickerInputVisible())) {
|
|
await closeBottomSheet();
|
|
await workingDirectorySelect.click({ force: true });
|
|
}
|
|
await expect.poll(async () => pickerInputVisible(), { timeout: 10000 }).toBe(true);
|
|
}
|
|
|
|
const trimmedDirectory = directory.replace(/\/+$/, "");
|
|
const activeInput = (await directorySearchInput.isVisible().catch(() => false))
|
|
? directorySearchInput
|
|
: legacyInput;
|
|
|
|
await activeInput.fill(trimmedDirectory);
|
|
|
|
if (activeInput === directorySearchInput) {
|
|
// Combobox custom rows can be either plain path labels or prefixed labels.
|
|
const plainOption = page
|
|
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, "i"))
|
|
.first();
|
|
const prefixedUseOption = page
|
|
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, "i"))
|
|
.first();
|
|
|
|
if (await plainOption.isVisible().catch(() => false)) {
|
|
await plainOption.click({ force: true });
|
|
} else if (await prefixedUseOption.isVisible().catch(() => false)) {
|
|
await prefixedUseOption.click({ force: true });
|
|
} else {
|
|
// Fallback: accept highlighted option (directory suggestion).
|
|
await activeInput.press("Enter");
|
|
}
|
|
} else {
|
|
// Legacy path picker fallback.
|
|
await activeInput.press("Enter");
|
|
}
|
|
|
|
// Wait for picker to close.
|
|
await expect(activeInput).not.toBeVisible({ timeout: 10000 });
|
|
|
|
const directoryCandidates = new Set<string>([trimmedDirectory]);
|
|
if (trimmedDirectory.startsWith("/var/")) {
|
|
directoryCandidates.add(`/private${trimmedDirectory}`);
|
|
}
|
|
if (trimmedDirectory.startsWith("/private/var/")) {
|
|
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ""));
|
|
}
|
|
const basename = trimmedDirectory.split("/").findLast(Boolean) ?? trimmedDirectory;
|
|
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const text = await workingDirectorySelect.innerText().catch(() => "");
|
|
if (text.includes(basename)) return true;
|
|
for (const candidate of directoryCandidates) {
|
|
if (text.includes(candidate)) return true;
|
|
}
|
|
return false;
|
|
},
|
|
{ timeout: 30000 },
|
|
)
|
|
.toBe(true);
|
|
};
|
|
|
|
export const ensureHostSelected = async (page: Page) => {
|
|
const input = page.getByRole("textbox", { name: "Message agent..." });
|
|
await expect(input).toBeVisible();
|
|
|
|
if (await input.isEditable()) {
|
|
return;
|
|
}
|
|
|
|
const selectHostLabel = page.getByText("Select host", { exact: true });
|
|
if (await selectHostLabel.isVisible()) {
|
|
await selectHostLabel.click();
|
|
|
|
// We enforce a single seeded daemon, so the option should be unambiguous.
|
|
const localhostOption = page.getByText("localhost", { exact: true }).first();
|
|
const daemonIdOption = page
|
|
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
|
|
.first();
|
|
|
|
if (await localhostOption.isVisible()) {
|
|
await localhostOption.click();
|
|
} else {
|
|
await expect(daemonIdOption).toBeVisible();
|
|
await daemonIdOption.click();
|
|
}
|
|
}
|
|
|
|
await expect(input).toBeEditable();
|
|
};
|
|
|
|
export const createAgent = async (page: Page, message: string) => {
|
|
const input = page.getByRole("textbox", { name: "Message agent..." });
|
|
await expect(input).toBeEditable();
|
|
await preferFastThinkingOption(page);
|
|
await input.fill(message);
|
|
await input.press("Enter");
|
|
|
|
// The composer may remain on the draft screen briefly while the initial run starts,
|
|
// so assert the user-visible result instead of forcing one route shape here.
|
|
await expect(page).toHaveURL(/\/(workspace|agent|new-agent)(\/|$|\?)/, { timeout: 30000 });
|
|
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
|
|
timeout: 30000,
|
|
});
|
|
};
|
|
|
|
async function preferFastThinkingOption(page: Page): Promise<void> {
|
|
const providerTrigger = page
|
|
.locator(
|
|
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
|
|
)
|
|
.first();
|
|
if (await providerTrigger.isVisible().catch(() => false)) {
|
|
const providerText = ((await providerTrigger.innerText().catch(() => "")) ?? "").trim();
|
|
if (!/codex/i.test(providerText)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const thinkingTrigger = page.getByTestId("agent-thinking-selector").first();
|
|
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
|
|
return;
|
|
}
|
|
|
|
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => "")) ?? "")
|
|
.trim()
|
|
.toLowerCase();
|
|
if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) {
|
|
return;
|
|
}
|
|
|
|
await thinkingTrigger.click();
|
|
const menu = page.getByTestId("agent-thinking-menu").first();
|
|
if (!(await menu.isVisible().catch(() => false))) {
|
|
return;
|
|
}
|
|
|
|
const preferredLabels = ["low", "minimal", "off", "medium"];
|
|
let selected = false;
|
|
for (const label of preferredLabels) {
|
|
const option = menu
|
|
.getByRole("button", { name: new RegExp(`^${escapeRegex(label)}$`, "i") })
|
|
.first();
|
|
if (await option.isVisible().catch(() => false)) {
|
|
await option.click({ force: true });
|
|
selected = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!selected) {
|
|
const options = menu.getByRole("button");
|
|
const count = await options.count();
|
|
for (let index = 0; index < count; index += 1) {
|
|
const option = options.nth(index);
|
|
const label = ((await option.innerText().catch(() => "")) ?? "").trim();
|
|
if (!label) {
|
|
continue;
|
|
}
|
|
if (label.toLowerCase() === currentThinkingLabel) {
|
|
continue;
|
|
}
|
|
await option.click({ force: true });
|
|
selected = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!selected) {
|
|
await page.keyboard.press("Escape").catch(() => undefined);
|
|
return;
|
|
}
|
|
|
|
await expect(menu).not.toBeVisible({ timeout: 5000 });
|
|
}
|
|
|
|
export interface AgentConfig {
|
|
directory: string;
|
|
provider?: string;
|
|
model?: string;
|
|
mode?: string;
|
|
prompt: string;
|
|
}
|
|
|
|
export const selectProvider = async (page: Page, provider: string) => {
|
|
const normalizedProvider = provider.trim();
|
|
if (!normalizedProvider) {
|
|
throw new Error("Provider must be a non-empty string.");
|
|
}
|
|
|
|
const providerTrigger = page
|
|
.locator(
|
|
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
|
|
)
|
|
.first();
|
|
if (
|
|
await providerTrigger
|
|
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i"))
|
|
.first()
|
|
.isVisible()
|
|
.catch(() => false)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (await providerTrigger.isVisible().catch(() => false)) {
|
|
await providerTrigger.click();
|
|
} else {
|
|
const providerLabel = page.getByText("PROVIDER", { exact: true }).first();
|
|
await expect(providerLabel).toBeVisible();
|
|
await providerLabel.click();
|
|
}
|
|
|
|
const dialog = page.getByRole("dialog").last();
|
|
const searchInput = dialog.getByRole("textbox", { name: /search provider/i }).first();
|
|
if (await searchInput.isVisible().catch(() => false)) {
|
|
await searchInput.fill(normalizedProvider);
|
|
}
|
|
|
|
const option = dialog.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i")).first();
|
|
await expect(option).toBeVisible();
|
|
await option.click();
|
|
};
|
|
|
|
export const selectModel = async (page: Page, model: string) => {
|
|
const normalizedModel = model.trim();
|
|
if (!normalizedModel) {
|
|
throw new Error("Model must be a non-empty string.");
|
|
}
|
|
|
|
const modelTrigger = page
|
|
.locator(
|
|
'[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible',
|
|
)
|
|
.first();
|
|
if (
|
|
await modelTrigger
|
|
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
|
|
.first()
|
|
.isVisible()
|
|
.catch(() => false)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (await modelTrigger.isVisible().catch(() => false)) {
|
|
await modelTrigger.click();
|
|
} else {
|
|
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(normalizedModel);
|
|
|
|
const dialog = page.getByRole("dialog");
|
|
const exactOption = dialog
|
|
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
|
|
.first();
|
|
const exactVisible = await exactOption.isVisible().catch(() => false);
|
|
if (exactVisible) {
|
|
await exactOption.click({ force: true });
|
|
} else {
|
|
// Modern labels include version suffixes (for example "Haiku 4.5"), so
|
|
// select the first filtered result using keyboard confirm.
|
|
await searchInput.press("Enter");
|
|
}
|
|
|
|
// Wait for dropdown to close
|
|
if (await searchInput.isVisible().catch(() => false)) {
|
|
await page.keyboard.press("Escape").catch(() => undefined);
|
|
}
|
|
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
|
|
};
|
|
|
|
export const selectMode = async (page: Page, mode: string) => {
|
|
const modeTrigger = page
|
|
.locator(
|
|
'[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible',
|
|
)
|
|
.first();
|
|
if (await modeTrigger.isVisible().catch(() => false)) {
|
|
await modeTrigger.click();
|
|
} else {
|
|
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);
|
|
|
|
const dialog = page.getByRole("dialog");
|
|
const option = dialog.getByText(new RegExp(`^${escapeRegex(mode)}$`, "i")).first();
|
|
await expect(option).toBeVisible();
|
|
await option.click({ force: true });
|
|
|
|
// 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 createAgentInRepo = async (
|
|
page: Page,
|
|
config: Pick<AgentConfig, "directory" | "prompt">,
|
|
) => {
|
|
await gotoHome(page);
|
|
await ensureHostSelected(page);
|
|
await setWorkingDirectory(page, config.directory);
|
|
await createAgent(page, config.prompt);
|
|
};
|