Improve Playwright E2E test quality (#1210)
* 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.
This commit is contained in:
@@ -2,15 +2,12 @@
|
|||||||
// Sessions history is daemon-global — any agent created by a prior spec hides the empty state.
|
// Sessions history is daemon-global — any agent created by a prior spec hides the empty state.
|
||||||
// If the beforeAll probe below fails, a spec sorted before this file is creating agents.
|
// If the beforeAll probe below fails, a spec sorted before this file is creating agents.
|
||||||
import { test } from "./fixtures";
|
import { test } from "./fixtures";
|
||||||
import {
|
import { connectSeedClient } from "./helpers/seed-client";
|
||||||
connectArchiveTabDaemonClient,
|
import { expectSessionsEmptyState, openSessions } from "./helpers/archive-tab";
|
||||||
expectSessionsEmptyState,
|
|
||||||
openSessions,
|
|
||||||
} from "./helpers/archive-tab";
|
|
||||||
|
|
||||||
test.describe("Sessions screen empty state", () => {
|
test.describe("Sessions screen empty state", () => {
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
const client = await connectArchiveTabDaemonClient();
|
const client = await connectSeedClient();
|
||||||
try {
|
try {
|
||||||
const history = await client.fetchAgentHistory({ page: { limit: 1 } });
|
const history = await client.fetchAgentHistory({ page: { limit: 1 } });
|
||||||
if (history.entries.length > 0) {
|
if (history.entries.length > 0) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { test } from "./fixtures";
|
import { test } from "./fixtures";
|
||||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||||
|
import { getServerId } from "./helpers/server-id";
|
||||||
import {
|
import {
|
||||||
expectProviderInstalledInSettings,
|
expectProviderInstalledInSettings,
|
||||||
installAcpCatalogProvider,
|
installAcpCatalogProvider,
|
||||||
@@ -12,19 +13,11 @@ const ACP_PROVIDER = {
|
|||||||
name: "Hermes",
|
name: "Hermes",
|
||||||
};
|
};
|
||||||
|
|
||||||
function getSeededServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("ACP provider catalog", () => {
|
test.describe("ACP provider catalog", () => {
|
||||||
test("adds a catalog provider from settings", async ({ page }) => {
|
test("adds a catalog provider from settings", async ({ page }) => {
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
await openSettingsHost(page, getSeededServerId());
|
await openSettingsHost(page, getServerId());
|
||||||
await openAddProviderModal(page);
|
await openAddProviderModal(page);
|
||||||
|
|
||||||
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
|
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { test } from "./fixtures";
|
import { test } from "./fixtures";
|
||||||
|
import { connectSeedClient } from "./helpers/seed-client";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { createTempGitRepo } from "./helpers/workspace";
|
||||||
import {
|
import {
|
||||||
archiveAgentFromDaemon,
|
archiveAgentFromDaemon,
|
||||||
archiveAgentFromSessions,
|
archiveAgentFromSessions,
|
||||||
clickSessionRow,
|
clickSessionRow,
|
||||||
closeWorkspaceAgentTab,
|
closeWorkspaceAgentTab,
|
||||||
connectArchiveTabDaemonClient,
|
|
||||||
createIdleAgent,
|
createIdleAgent,
|
||||||
expectArchivedAgentFocused,
|
expectArchivedAgentFocused,
|
||||||
expectSessionRowArchived,
|
expectSessionRowArchived,
|
||||||
@@ -21,14 +21,14 @@ import {
|
|||||||
} from "./helpers/archive-tab";
|
} from "./helpers/archive-tab";
|
||||||
|
|
||||||
test.describe("Archive tab reconciliation", () => {
|
test.describe("Archive tab reconciliation", () => {
|
||||||
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
let client: Awaited<ReturnType<typeof connectSeedClient>>;
|
||||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
|
|
||||||
test.describe.configure({ timeout: 300_000 });
|
test.describe.configure({ timeout: 300_000 });
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
tempRepo = await createTempGitRepo("archive-tab-");
|
tempRepo = await createTempGitRepo("archive-tab-");
|
||||||
client = await connectArchiveTabDaemonClient();
|
client = await connectSeedClient();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test.afterAll(async () => {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { expect, test, type Page } from "./fixtures";
|
import { expect, test, type Page } from "./fixtures";
|
||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
|
||||||
import { composerLocator, expectComposerVisible, submitMessage } from "./helpers/composer";
|
import { composerLocator, expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
|
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import {
|
import {
|
||||||
expectSessionRowArchived,
|
expectSessionRowArchived,
|
||||||
expectWorkspaceTabHidden,
|
expectWorkspaceTabHidden,
|
||||||
@@ -11,22 +9,12 @@ import {
|
|||||||
} from "./helpers/archive-tab";
|
} from "./helpers/archive-tab";
|
||||||
|
|
||||||
interface SlashCommandScenario {
|
interface SlashCommandScenario {
|
||||||
agent: { id: string };
|
agentId: string;
|
||||||
client: TerminalPerfDaemonClient;
|
|
||||||
cwd: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REPLACEMENT_PROMPT = "Replacement prompt after slash clear.";
|
const REPLACEMENT_PROMPT = "Replacement prompt after slash clear.";
|
||||||
|
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withOpenReadyMockAgent(
|
async function withOpenReadyMockAgent(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: {
|
input: {
|
||||||
@@ -36,68 +24,23 @@ async function withOpenReadyMockAgent(
|
|||||||
},
|
},
|
||||||
run: (scenario: SlashCommandScenario) => Promise<void>,
|
run: (scenario: SlashCommandScenario) => Promise<void>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const repo = await createTempGitRepo("client-slash-command-");
|
const session = await seedMockAgentWorkspace({
|
||||||
const client = await connectTerminalClient();
|
repoPrefix: "client-slash-command-",
|
||||||
|
|
||||||
try {
|
|
||||||
await openProject(client, repo.path);
|
|
||||||
const agent = await createReadyMockAgent(client, {
|
|
||||||
cwd: repo.path,
|
|
||||||
title: input.title,
|
|
||||||
model: input.model,
|
|
||||||
modeId: input.modeId,
|
|
||||||
});
|
|
||||||
await openActiveAgentTab(page, { cwd: repo.path, agentId: agent.id });
|
|
||||||
|
|
||||||
await run({ agent, client, cwd: repo.path, title: input.title });
|
|
||||||
} finally {
|
|
||||||
await client.close();
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openProject(client: TerminalPerfDaemonClient, cwd: string): Promise<void> {
|
|
||||||
const opened = await client.openProject(cwd);
|
|
||||||
if (!opened.workspace) {
|
|
||||||
throw new Error(opened.error ?? `Failed to open project ${cwd}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createReadyMockAgent(
|
|
||||||
client: TerminalPerfDaemonClient,
|
|
||||||
input: {
|
|
||||||
cwd: string;
|
|
||||||
title: string;
|
|
||||||
model?: string;
|
|
||||||
modeId?: string;
|
|
||||||
},
|
|
||||||
): Promise<{ id: string }> {
|
|
||||||
const agent = await client.createAgent({
|
|
||||||
provider: "mock",
|
|
||||||
cwd: input.cwd,
|
|
||||||
title: input.title,
|
title: input.title,
|
||||||
modeId: input.modeId ?? "load-test",
|
model: input.model,
|
||||||
model: input.model ?? "ten-second-stream",
|
modeId: input.modeId,
|
||||||
initialPrompt: "Prepare a client slash command test agent.",
|
initialPrompt: "Prepare a client slash command test agent.",
|
||||||
});
|
});
|
||||||
return { id: agent.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openActiveAgentTab(
|
try {
|
||||||
page: Page,
|
await openAgentRoute(page, session);
|
||||||
input: { cwd: string; agentId: string },
|
await expectWorkspaceTabVisible(page, session.agentId);
|
||||||
): Promise<void> {
|
await expectComposerVisible(page);
|
||||||
const agentUrl = `${buildHostWorkspaceRoute(
|
|
||||||
getServerId(),
|
await run({ agentId: session.agentId, title: input.title });
|
||||||
input.cwd,
|
} finally {
|
||||||
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
|
await session.cleanup();
|
||||||
await page.goto(agentUrl);
|
}
|
||||||
await page.waitForURL(
|
|
||||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
);
|
|
||||||
await expectWorkspaceTabVisible(page, input.agentId);
|
|
||||||
await expectComposerVisible(page);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runClientSlashCommand(page: Page, command: "/quit" | "/clear"): Promise<void> {
|
async function runClientSlashCommand(page: Page, command: "/quit" | "/clear"): Promise<void> {
|
||||||
@@ -169,9 +112,9 @@ async function waitForReplacementAgentId(page: Page, oldAgentId: string): Promis
|
|||||||
|
|
||||||
test.describe("Client slash commands", () => {
|
test.describe("Client slash commands", () => {
|
||||||
test("slash quit archives the active agent and removes its tab", async ({ page }) => {
|
test("slash quit archives the active agent and removes its tab", async ({ page }) => {
|
||||||
await withOpenReadyMockAgent(page, { title: "Slash quit e2e" }, async ({ agent, title }) => {
|
await withOpenReadyMockAgent(page, { title: "Slash quit e2e" }, async ({ agentId, title }) => {
|
||||||
await runClientSlashCommand(page, "/quit");
|
await runClientSlashCommand(page, "/quit");
|
||||||
await expectWorkspaceTabHidden(page, agent.id);
|
await expectWorkspaceTabHidden(page, agentId);
|
||||||
await expectAgentArchivedInSessions(page, title);
|
await expectAgentArchivedInSessions(page, title);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -180,9 +123,9 @@ test.describe("Client slash commands", () => {
|
|||||||
await withOpenReadyMockAgent(
|
await withOpenReadyMockAgent(
|
||||||
page,
|
page,
|
||||||
{ title: "Slash quit autocomplete e2e" },
|
{ title: "Slash quit autocomplete e2e" },
|
||||||
async ({ agent, title }) => {
|
async ({ agentId, title }) => {
|
||||||
await selectClientSlashCommand(page, "/qu", "/exit");
|
await selectClientSlashCommand(page, "/qu", "/exit");
|
||||||
await expectWorkspaceTabHidden(page, agent.id);
|
await expectWorkspaceTabHidden(page, agentId);
|
||||||
await expectAgentArchivedInSessions(page, title);
|
await expectAgentArchivedInSessions(page, title);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -192,12 +135,12 @@ test.describe("Client slash commands", () => {
|
|||||||
await withOpenReadyMockAgent(
|
await withOpenReadyMockAgent(
|
||||||
page,
|
page,
|
||||||
{ title: "Slash clear e2e", model: "ten-second-stream", modeId: "load-test" },
|
{ title: "Slash clear e2e", model: "ten-second-stream", modeId: "load-test" },
|
||||||
async ({ agent, title }) => {
|
async ({ agentId, title }) => {
|
||||||
await runClientSlashCommand(page, "/clear");
|
await runClientSlashCommand(page, "/clear");
|
||||||
await expectWorkspaceTabHidden(page, agent.id);
|
await expectWorkspaceTabHidden(page, agentId);
|
||||||
await expectReplacementDraftMatchesPreviousSetup(page);
|
await expectReplacementDraftMatchesPreviousSetup(page);
|
||||||
await createAgentFromReplacementDraft(page);
|
await createAgentFromReplacementDraft(page);
|
||||||
await waitForReplacementAgentId(page, agent.id);
|
await waitForReplacementAgentId(page, agentId);
|
||||||
await expectAgentArchivedInSessions(page, title);
|
await expectAgentArchivedInSessions(page, title);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,6 @@
|
|||||||
import { expect, test } from "./fixtures";
|
import { expect, test } from "./fixtures";
|
||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
|
||||||
import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions";
|
import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions";
|
||||||
import { connectTerminalClient } from "./helpers/terminal-perf";
|
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
|
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Codex plan approval", () => {
|
test.describe("Codex plan approval", () => {
|
||||||
test("shows a single actionable plan panel and removes it after implementation starts", async ({
|
test("shows a single actionable plan panel and removes it after implementation starts", async ({
|
||||||
@@ -18,29 +8,14 @@ test.describe("Codex plan approval", () => {
|
|||||||
}) => {
|
}) => {
|
||||||
test.setTimeout(180_000);
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
const repo = await createTempGitRepo("codex-plan-approval-");
|
const session = await seedMockAgentWorkspace({
|
||||||
const client = await connectTerminalClient();
|
repoPrefix: "codex-plan-approval-",
|
||||||
|
title: "Codex plan approval e2e",
|
||||||
|
initialPrompt: "Emit synthetic plan approval.",
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const workspaceResult = await client.openProject(repo.path);
|
await openAgentRoute(page, session);
|
||||||
if (!workspaceResult.workspace) {
|
|
||||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const agent = await client.createAgent({
|
|
||||||
provider: "mock",
|
|
||||||
cwd: repo.path,
|
|
||||||
title: "Codex plan approval e2e",
|
|
||||||
modeId: "load-test",
|
|
||||||
model: "ten-second-stream",
|
|
||||||
initialPrompt: "Emit synthetic plan approval.",
|
|
||||||
});
|
|
||||||
|
|
||||||
const agentUrl = `${buildHostWorkspaceRoute(
|
|
||||||
getServerId(),
|
|
||||||
repo.path,
|
|
||||||
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
|
||||||
await page.goto(agentUrl);
|
|
||||||
|
|
||||||
await waitForPermissionPrompt(page, 120_000);
|
await waitForPermissionPrompt(page, 120_000);
|
||||||
|
|
||||||
@@ -54,8 +29,7 @@ test.describe("Codex plan approval", () => {
|
|||||||
});
|
});
|
||||||
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
|
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await session.cleanup();
|
||||||
await repo.cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,16 +23,12 @@ import {
|
|||||||
expectGithubAttachmentPill,
|
expectGithubAttachmentPill,
|
||||||
openGithubWorkspace,
|
openGithubWorkspace,
|
||||||
} from "./helpers/composer";
|
} from "./helpers/composer";
|
||||||
import {
|
import { delayBrowserAgentCreatedStatus, openNewWorkspaceComposer } from "./helpers/new-workspace";
|
||||||
connectNewWorkspaceDaemonClient,
|
|
||||||
delayBrowserAgentCreatedStatus,
|
|
||||||
openNewWorkspaceComposer,
|
|
||||||
openProjectViaDaemon,
|
|
||||||
} from "./helpers/new-workspace";
|
|
||||||
import { gotoAppShell } from "./helpers/app";
|
import { gotoAppShell } from "./helpers/app";
|
||||||
import { waitForSidebarHydration, switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
import { waitForSidebarHydration, switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { seedWorkspace } from "./helpers/seed-client";
|
||||||
import { hasGithubAuth, createTempGithubRepo } from "./helpers/github-fixtures";
|
import { hasGithubAuth, createTempGithubRepo } from "./helpers/github-fixtures";
|
||||||
|
import { getServerId } from "./helpers/server-id";
|
||||||
|
|
||||||
const MINIMAL_PNG = Buffer.from(
|
const MINIMAL_PNG = Buffer.from(
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||||
@@ -237,27 +233,23 @@ test.describe("Composer attachments", () => {
|
|||||||
|
|
||||||
test("composer is locked while new workspace agent is being created", async ({ page }) => {
|
test("composer is locked while new workspace agent is being created", async ({ page }) => {
|
||||||
test.setTimeout(120_000);
|
test.setTimeout(120_000);
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = getServerId();
|
||||||
if (!serverId) throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo("attach-lock-");
|
|
||||||
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
|
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
|
||||||
const daemonClient = await connectNewWorkspaceDaemonClient();
|
const workspace = await seedWorkspace({ repoPrefix: "attach-lock-" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const openedProject = await openProjectViaDaemon(daemonClient, repo.path);
|
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await switchWorkspaceViaSidebar({
|
await switchWorkspaceViaSidebar({
|
||||||
page,
|
page,
|
||||||
serverId,
|
serverId,
|
||||||
targetWorkspacePath: openedProject.workspaceId,
|
targetWorkspacePath: workspace.workspaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await openNewWorkspaceComposer(page, {
|
await openNewWorkspaceComposer(page, {
|
||||||
projectKey: openedProject.projectKey,
|
projectKey: workspace.projectId,
|
||||||
projectDisplayName: openedProject.projectDisplayName,
|
projectDisplayName: workspace.projectDisplayName,
|
||||||
});
|
});
|
||||||
await fillComposerDraft(page, "lock test prompt");
|
await fillComposerDraft(page, "lock test prompt");
|
||||||
const createButton = page
|
const createButton = page
|
||||||
@@ -277,8 +269,7 @@ test.describe("Composer attachments", () => {
|
|||||||
await expectComposerEditable(page);
|
await expectComposerEditable(page);
|
||||||
} finally {
|
} finally {
|
||||||
agentCreatedDelay.release();
|
agentCreatedDelay.release();
|
||||||
await daemonClient.close().catch(() => undefined);
|
await workspace.cleanup();
|
||||||
await repo.cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
|
||||||
import { expect, test, type Page } from "./fixtures";
|
import { expect, test, type Page } from "./fixtures";
|
||||||
import { composerLocator, expectComposerVisible } from "./helpers/composer";
|
import { composerLocator, expectComposerVisible } from "./helpers/composer";
|
||||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
|
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||||
|
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||||
|
|
||||||
const TEST_COMMANDS = [
|
const TEST_COMMANDS = [
|
||||||
{
|
{
|
||||||
@@ -99,28 +98,8 @@ async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDaemonPort(): string {
|
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
if (!daemonPort || daemonPort === "6767") {
|
|
||||||
throw new Error("E2E_DAEMON_PORT must point at the isolated e2e daemon.");
|
|
||||||
}
|
|
||||||
return daemonPort;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function installListCommandsStub(page: Page): Promise<void> {
|
async function installListCommandsStub(page: Page): Promise<void> {
|
||||||
await page.routeWebSocket(new RegExp(`:${escapeRegex(getDaemonPort())}\\b`), (ws) => {
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||||
const server = ws.connectToServer();
|
const server = ws.connectToServer();
|
||||||
|
|
||||||
ws.onMessage((message) => {
|
ws.onMessage((message) => {
|
||||||
@@ -163,58 +142,26 @@ async function installListCommandsStub(page: Page): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openProject(client: TerminalPerfDaemonClient, cwd: string): Promise<void> {
|
|
||||||
const opened = await client.openProject(cwd);
|
|
||||||
if (!opened.workspace) {
|
|
||||||
throw new Error(opened.error ?? `Failed to open project ${cwd}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cleanupWithin(timeoutMs: number, cleanup: () => Promise<void>): Promise<void> {
|
|
||||||
const operation = cleanup().catch(() => undefined);
|
|
||||||
await Promise.race([operation, new Promise<void>((resolve) => setTimeout(resolve, timeoutMs))]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openReadyMockAgent(
|
async function openReadyMockAgent(
|
||||||
page: Page,
|
page: Page,
|
||||||
options?: { expectWorkspaceTab?: boolean },
|
options?: { expectWorkspaceTab?: boolean },
|
||||||
): Promise<{
|
): Promise<{
|
||||||
cleanup: () => Promise<void>;
|
cleanup: () => Promise<void>;
|
||||||
}> {
|
}> {
|
||||||
const repo = await createTempGitRepo("autocomplete-popover-");
|
const session = await seedMockAgentWorkspace({
|
||||||
const client = await connectTerminalClient();
|
repoPrefix: "autocomplete-popover-",
|
||||||
|
title: "Autocomplete popover regression",
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await openProject(client, repo.path);
|
await openAgentRoute(page, session);
|
||||||
const agent = await client.createAgent({
|
|
||||||
provider: "mock",
|
|
||||||
cwd: repo.path,
|
|
||||||
title: "Autocomplete popover regression",
|
|
||||||
modeId: "load-test",
|
|
||||||
model: "ten-second-stream",
|
|
||||||
});
|
|
||||||
const route = `${buildHostWorkspaceRoute(
|
|
||||||
getServerId(),
|
|
||||||
repo.path,
|
|
||||||
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
|
||||||
await page.goto(route);
|
|
||||||
await page.waitForURL(
|
|
||||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
);
|
|
||||||
if (options?.expectWorkspaceTab !== false) {
|
if (options?.expectWorkspaceTab !== false) {
|
||||||
await expectWorkspaceTabVisible(page, agent.id);
|
await expectWorkspaceTabVisible(page, session.agentId);
|
||||||
}
|
}
|
||||||
await expectComposerVisible(page);
|
await expectComposerVisible(page);
|
||||||
return {
|
return { cleanup: session.cleanup };
|
||||||
cleanup: async () => {
|
|
||||||
await cleanupWithin(2_000, () => client.close());
|
|
||||||
await cleanupWithin(2_000, () => repo.cleanup());
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await cleanupWithin(2_000, () => client.close());
|
await session.cleanup();
|
||||||
await cleanupWithin(2_000, () => repo.cleanup());
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { test } from "./fixtures";
|
import { test } from "./fixtures";
|
||||||
import { gotoAppShell } from "./helpers/app";
|
import { gotoAppShell } from "./helpers/app";
|
||||||
|
import { getServerId } from "./helpers/server-id";
|
||||||
import {
|
import {
|
||||||
loadRealDaemonState,
|
loadRealDaemonState,
|
||||||
injectDesktopBridge,
|
injectDesktopBridge,
|
||||||
@@ -17,20 +18,12 @@ import {
|
|||||||
expectDaemonStatusVersion,
|
expectDaemonStatusVersion,
|
||||||
} from "./helpers/desktop-updates";
|
} from "./helpers/desktop-updates";
|
||||||
|
|
||||||
function getSeededServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No Playwright Electron runner exists; we simulate the desktop bridge via
|
// No Playwright Electron runner exists; we simulate the desktop bridge via
|
||||||
// addInitScript so Electron-gated UI activates without a real Electron process.
|
// addInitScript so Electron-gated UI activates without a real Electron process.
|
||||||
test.describe("Desktop updates", () => {
|
test.describe("Desktop updates", () => {
|
||||||
test("update banner appears in the sidebar when an app update is available", async ({ page }) => {
|
test("update banner appears in the sidebar when an app update is available", async ({ page }) => {
|
||||||
await injectDesktopBridge(page, {
|
await injectDesktopBridge(page, {
|
||||||
serverId: getSeededServerId(),
|
serverId: getServerId(),
|
||||||
updateAvailable: true,
|
updateAvailable: true,
|
||||||
latestVersion: "1.2.3",
|
latestVersion: "1.2.3",
|
||||||
});
|
});
|
||||||
@@ -41,7 +34,7 @@ test.describe("Desktop updates", () => {
|
|||||||
|
|
||||||
test("clicking install shows the installing state on the callout", async ({ page }) => {
|
test("clicking install shows the installing state on the callout", async ({ page }) => {
|
||||||
await injectDesktopBridge(page, {
|
await injectDesktopBridge(page, {
|
||||||
serverId: getSeededServerId(),
|
serverId: getServerId(),
|
||||||
updateAvailable: true,
|
updateAvailable: true,
|
||||||
latestVersion: "1.2.3",
|
latestVersion: "1.2.3",
|
||||||
slowInstall: true,
|
slowInstall: true,
|
||||||
@@ -58,7 +51,7 @@ test.describe("Desktop daemon management", () => {
|
|||||||
test("disabling built-in daemon management shows confirm dialog with correct copy", async ({
|
test("disabling built-in daemon management shows confirm dialog with correct copy", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const serverId = getSeededServerId();
|
const serverId = getServerId();
|
||||||
await injectDesktopBridge(page, {
|
await injectDesktopBridge(page, {
|
||||||
serverId,
|
serverId,
|
||||||
manageBuiltInDaemon: true,
|
manageBuiltInDaemon: true,
|
||||||
@@ -74,7 +67,7 @@ test.describe("Desktop daemon management", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => {
|
test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => {
|
||||||
const serverId = getSeededServerId();
|
const serverId = getServerId();
|
||||||
await injectDesktopBridge(page, {
|
await injectDesktopBridge(page, {
|
||||||
serverId,
|
serverId,
|
||||||
manageBuiltInDaemon: true,
|
manageBuiltInDaemon: true,
|
||||||
@@ -89,7 +82,7 @@ test.describe("Desktop daemon management", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("confirming the dialog disables built-in daemon management", async ({ page }) => {
|
test("confirming the dialog disables built-in daemon management", async ({ page }) => {
|
||||||
const serverId = getSeededServerId();
|
const serverId = getServerId();
|
||||||
await injectDesktopBridge(page, {
|
await injectDesktopBridge(page, {
|
||||||
serverId,
|
serverId,
|
||||||
manageBuiltInDaemon: true,
|
manageBuiltInDaemon: true,
|
||||||
@@ -106,7 +99,7 @@ test.describe("Desktop daemon management", () => {
|
|||||||
test("daemon status panel renders version, PID, and log path from the real daemon", async ({
|
test("daemon status panel renders version, PID, and log path from the real daemon", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const serverId = getSeededServerId();
|
const serverId = getServerId();
|
||||||
const realState = await loadRealDaemonState();
|
const realState = await loadRealDaemonState();
|
||||||
await injectDesktopBridge(page, {
|
await injectDesktopBridge(page, {
|
||||||
serverId,
|
serverId,
|
||||||
@@ -124,7 +117,7 @@ test.describe("Desktop daemon management", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("stopping and restarting the daemon updates the PID", async ({ page }) => {
|
test("stopping and restarting the daemon updates the PID", async ({ page }) => {
|
||||||
const serverId = getSeededServerId();
|
const serverId = getServerId();
|
||||||
const realState = await loadRealDaemonState();
|
const realState = await loadRealDaemonState();
|
||||||
await injectDesktopBridge(page, {
|
await injectDesktopBridge(page, {
|
||||||
serverId,
|
serverId,
|
||||||
|
|||||||
@@ -9,41 +9,31 @@ import {
|
|||||||
openFileFromExplorer,
|
openFileFromExplorer,
|
||||||
} from "./helpers/file-explorer";
|
} from "./helpers/file-explorer";
|
||||||
import { gotoWorkspace } from "./helpers/launcher";
|
import { gotoWorkspace } from "./helpers/launcher";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||||
import {
|
|
||||||
connectWorkspaceSetupClient,
|
|
||||||
type WorkspaceSetupDaemonClient,
|
|
||||||
} from "./helpers/workspace-setup";
|
|
||||||
|
|
||||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
let workspace: SeededWorkspace;
|
||||||
let workspaceId: string;
|
|
||||||
let seedClient: WorkspaceSetupDaemonClient;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
tempRepo = await createTempGitRepo("file-explorer-collapse-", {
|
workspace = await seedWorkspace({
|
||||||
files: [
|
repoPrefix: "file-explorer-collapse-",
|
||||||
{ path: "assets/logo.png", content: "image bytes for explorer e2e\n" },
|
repo: {
|
||||||
{ path: "docs/guide.md", content: "# Guide\n" },
|
files: [
|
||||||
],
|
{ path: "assets/logo.png", content: "image bytes for explorer e2e\n" },
|
||||||
|
{ path: "docs/guide.md", content: "# Guide\n" },
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
seedClient = await connectWorkspaceSetupClient();
|
|
||||||
const result = await seedClient.openProject(tempRepo.path);
|
|
||||||
if (!result.workspace) {
|
|
||||||
throw new Error(result.error ?? "Failed to seed workspace");
|
|
||||||
}
|
|
||||||
workspaceId = result.workspace.id;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test.afterAll(async () => {
|
||||||
await seedClient?.close();
|
await workspace?.cleanup();
|
||||||
await tempRepo?.cleanup();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("File explorer collapse", () => {
|
test.describe("File explorer collapse", () => {
|
||||||
test("collapses an opened image file parent folder and still expands other folders", async ({
|
test("collapses an opened image file parent folder and still expands other folders", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await openFileExplorer(page);
|
await openFileExplorer(page);
|
||||||
|
|
||||||
await expandFolder(page, "assets");
|
await expandFolder(page, "assets");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { test as base, expect, type Page } from "@playwright/test";
|
import { test as base, expect, type Page } from "@playwright/test";
|
||||||
|
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||||
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
|
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
|
||||||
|
|
||||||
@@ -17,20 +18,8 @@ const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>(
|
|||||||
},
|
},
|
||||||
paseoE2ESetup: [
|
paseoE2ESetup: [
|
||||||
async ({ page }, provide, testInfo) => {
|
async ({ page }, provide, testInfo) => {
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
const daemonPort = getE2EDaemonPort();
|
||||||
const metroPort = process.env.E2E_METRO_PORT;
|
const metroPort = process.env.E2E_METRO_PORT;
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error(
|
|
||||||
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
|
|
||||||
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (daemonPort === "6767") {
|
|
||||||
throw new Error(
|
|
||||||
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
|
|
||||||
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!metroPort) {
|
if (!metroPort) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
|
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { loadDaemonClientConstructor } from "./daemon-client-loader";
|
|
||||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
|
||||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
|
||||||
|
|
||||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||||
|
|
||||||
@@ -13,147 +9,6 @@ export interface ScrollMetrics {
|
|||||||
distanceFromBottom: number;
|
distanceFromBottom: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SeededAgent {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
expectedTailText: string;
|
|
||||||
url: string;
|
|
||||||
workspaceUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DaemonClientInstance {
|
|
||||||
connect(): Promise<void>;
|
|
||||||
close(): Promise<void>;
|
|
||||||
createAgent(options: {
|
|
||||||
provider: string;
|
|
||||||
model: string;
|
|
||||||
thinkingOptionId: string;
|
|
||||||
modeId: string;
|
|
||||||
cwd: string;
|
|
||||||
title: string;
|
|
||||||
initialPrompt: string;
|
|
||||||
}): Promise<{ id: string }>;
|
|
||||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
|
||||||
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDaemonWsUrl(): string {
|
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
|
||||||
}
|
|
||||||
return `ws://127.0.0.1:${daemonPort}/ws`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildReplyBlock(label: string, lineCount = 14): string {
|
|
||||||
return Array.from({ length: lineCount }, (_, index) => {
|
|
||||||
const line = (index + 1).toString().padStart(2, "0");
|
|
||||||
return `${label} line ${line} anchor verification text keeps wrapping stable across resize and composer growth.`;
|
|
||||||
}).join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildProtocolMessage(label: string, lineCount = 14): string {
|
|
||||||
return [
|
|
||||||
"For every message in this chat, reply with exactly the text after the final line `REPLY:`.",
|
|
||||||
"Do not add extra words, bullets, markdown fences, or tool calls.",
|
|
||||||
"REPLY:",
|
|
||||||
buildReplyBlock(label, lineCount),
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildReplyMessage(label: string, lineCount = 14): string {
|
|
||||||
return ["REPLY:", buildReplyBlock(label, lineCount)].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createReplyTurn(label: string): {
|
|
||||||
message: string;
|
|
||||||
expectedReply: string;
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
message: buildReplyMessage(label),
|
|
||||||
expectedReply: buildReplyBlock(label),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DaemonClientConfig {
|
|
||||||
url: string;
|
|
||||||
clientId: string;
|
|
||||||
clientType: "cli";
|
|
||||||
webSocketFactory?: NodeWebSocketFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
|
|
||||||
const DaemonClient = await loadDaemonClientConstructor<
|
|
||||||
DaemonClientConfig,
|
|
||||||
DaemonClientInstance
|
|
||||||
>();
|
|
||||||
const webSocketFactory = createNodeWebSocketFactory();
|
|
||||||
const client = new DaemonClient({
|
|
||||||
url: getDaemonWsUrl(),
|
|
||||||
clientId: `app-e2e-${randomUUID()}`,
|
|
||||||
clientType: "cli",
|
|
||||||
webSocketFactory,
|
|
||||||
});
|
|
||||||
await client.connect();
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function seedBottomAnchorAgent(input: {
|
|
||||||
client: DaemonClientInstance;
|
|
||||||
cwd: string;
|
|
||||||
title?: string;
|
|
||||||
turnCount?: number;
|
|
||||||
lineCount?: number;
|
|
||||||
}): Promise<SeededAgent> {
|
|
||||||
const title = input.title ?? `bottom-anchor-${Date.now()}`;
|
|
||||||
const turnCount = Math.max(3, input.turnCount ?? 5);
|
|
||||||
const lineCount = Math.max(14, input.lineCount ?? 14);
|
|
||||||
const created = await input.client.createAgent({
|
|
||||||
provider: "codex",
|
|
||||||
model: "gpt-5.4-mini",
|
|
||||||
thinkingOptionId: "low",
|
|
||||||
modeId: "full-access",
|
|
||||||
cwd: input.cwd,
|
|
||||||
title,
|
|
||||||
initialPrompt: buildProtocolMessage(`${title}-turn-00`, lineCount),
|
|
||||||
});
|
|
||||||
const initialFinish = await input.client.waitForFinish(created.id, 120000);
|
|
||||||
if (initialFinish.status !== "idle") {
|
|
||||||
throw new Error(
|
|
||||||
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let expectedTailText = buildReplyBlock(`${title}-turn-00`, lineCount);
|
|
||||||
for (let index = 1; index < turnCount; index += 1) {
|
|
||||||
const label = `${title}-turn-${index.toString().padStart(2, "0")}`;
|
|
||||||
expectedTailText = buildReplyBlock(label, lineCount);
|
|
||||||
await input.client.sendAgentMessage(created.id, buildReplyMessage(label, lineCount));
|
|
||||||
const finish = await input.client.waitForFinish(created.id, 120000);
|
|
||||||
if (finish.status !== "idle") {
|
|
||||||
throw new Error(
|
|
||||||
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: created.id,
|
|
||||||
title,
|
|
||||||
expectedTailText,
|
|
||||||
url: `${buildHostWorkspaceRoute(getServerId(), input.cwd)}?open=${encodeURIComponent(`agent:${created.id}`)}`,
|
|
||||||
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVisibleChatScroll(page: Page) {
|
function getVisibleChatScroll(page: Page) {
|
||||||
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||||
}
|
}
|
||||||
@@ -190,48 +45,6 @@ export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
|
|
||||||
const scrollViewport = getVisibleChatScroll(page);
|
|
||||||
await expect(scrollViewport).toHaveCount(1, { timeout: 30000 });
|
|
||||||
let remaining = Math.max(0, pixels);
|
|
||||||
while (remaining > 0) {
|
|
||||||
const delta = Math.min(240, remaining);
|
|
||||||
await scrollViewport.evaluate((element: Element, step: number) => {
|
|
||||||
const scrollContainer = element as HTMLElement;
|
|
||||||
scrollContainer.dispatchEvent(
|
|
||||||
new WheelEvent("wheel", {
|
|
||||||
deltaY: -step,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
|
|
||||||
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
|
|
||||||
}, delta);
|
|
||||||
remaining -= delta;
|
|
||||||
|
|
||||||
if ((await readScrollMetrics(page)).distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise<void> {
|
|
||||||
await expect(getVisibleChatScroll(page)).toBeVisible({ timeout: 60000 });
|
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
|
||||||
timeout: 60000,
|
|
||||||
});
|
|
||||||
await expect(page.getByTestId("agent-loading")).toHaveCount(0, { timeout: 60000 });
|
|
||||||
if (expectedTailText) {
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const metrics = await readScrollMetrics(page);
|
|
||||||
return metrics.contentHeight;
|
|
||||||
})
|
|
||||||
.toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectNearBottom(page: Page): Promise<void> {
|
export async function expectNearBottom(page: Page): Promise<void> {
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
@@ -241,15 +54,6 @@ export async function expectNearBottom(page: Page): Promise<void> {
|
|||||||
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectDetachedFromBottom(page: Page): Promise<void> {
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const metrics = await readScrollMetrics(page);
|
|
||||||
return metrics.distanceFromBottom;
|
|
||||||
})
|
|
||||||
.toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function waitForContentGrowth(
|
export async function waitForContentGrowth(
|
||||||
page: Page,
|
page: Page,
|
||||||
previousContentHeight: number,
|
previousContentHeight: number,
|
||||||
@@ -262,11 +66,3 @@ export async function waitForContentGrowth(
|
|||||||
.toBeGreaterThan(previousContentHeight);
|
.toBeGreaterThan(previousContentHeight);
|
||||||
return readScrollMetrics(page);
|
return readScrollMetrics(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getChatContainerKey(page: Page): Promise<string | null> {
|
|
||||||
return getVisibleChatScroll(page).evaluate((element) => {
|
|
||||||
const nativeId = (element as HTMLElement).id;
|
|
||||||
const prefix = "agent-chat-scroll-";
|
|
||||||
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { escapeRegex } from "./regex";
|
||||||
function escapeRegex(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
export const gotoAppShell = async (page: Page) => {
|
export const gotoAppShell = async (page: Page) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||||
import { loadDaemonClientConstructor } from "./daemon-client-loader";
|
import { getE2EDaemonPort } from "./daemon-port";
|
||||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
import { getServerId } from "./server-id";
|
||||||
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
||||||
import {
|
import {
|
||||||
buildHostAgentDetailRoute,
|
buildHostAgentDetailRoute,
|
||||||
@@ -16,90 +16,40 @@ export interface ArchiveTabAgent {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ArchiveTabDaemonClient {
|
|
||||||
connect(): Promise<void>;
|
|
||||||
close(): Promise<void>;
|
|
||||||
createAgent(options: {
|
|
||||||
provider: string;
|
|
||||||
model: string;
|
|
||||||
thinkingOptionId?: string;
|
|
||||||
modeId: string;
|
|
||||||
cwd: string;
|
|
||||||
title: string;
|
|
||||||
initialPrompt?: string;
|
|
||||||
}): Promise<{ id: string }>;
|
|
||||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
|
||||||
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
|
||||||
waitForAgentUpsert(
|
|
||||||
agentId: string,
|
|
||||||
predicate: (snapshot: { status: string }) => boolean,
|
|
||||||
timeout?: number,
|
|
||||||
): Promise<{ status: string }>;
|
|
||||||
fetchAgentHistory(options?: {
|
|
||||||
page?: { limit: number };
|
|
||||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDaemonPort(): string {
|
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
|
||||||
}
|
|
||||||
if (daemonPort === "6767") {
|
|
||||||
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
|
|
||||||
}
|
|
||||||
return daemonPort;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDaemonWsUrl(): string {
|
|
||||||
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSeededStoragePayload() {
|
function buildSeededStoragePayload() {
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
return {
|
return {
|
||||||
daemon: buildSeededHost({
|
daemon: buildSeededHost({
|
||||||
serverId: getServerId(),
|
serverId: getServerId(),
|
||||||
endpoint: `127.0.0.1:${getDaemonPort()}`,
|
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||||
nowIso,
|
nowIso,
|
||||||
}),
|
}),
|
||||||
preferences: buildCreateAgentPreferences(getServerId()),
|
preferences: buildCreateAgentPreferences(getServerId()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ArchiveTabDaemonClientConfig {
|
/**
|
||||||
url: string;
|
* The slice of a daemon client `createIdleAgent` needs: spawn an agent and await
|
||||||
clientId: string;
|
* its idle upsert. The shared seed client satisfies it, so a spec can seed an
|
||||||
clientType: "cli";
|
* idle agent from the same client it uses for everything else.
|
||||||
webSocketFactory?: NodeWebSocketFactory;
|
*/
|
||||||
}
|
export interface IdleAgentSeedClient {
|
||||||
|
createAgent(options: {
|
||||||
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
|
provider: string;
|
||||||
const DaemonClient = await loadDaemonClientConstructor<
|
model: string;
|
||||||
ArchiveTabDaemonClientConfig,
|
modeId: string;
|
||||||
ArchiveTabDaemonClient
|
cwd: string;
|
||||||
>();
|
title: string;
|
||||||
const webSocketFactory = createNodeWebSocketFactory();
|
}): Promise<{ id: string }>;
|
||||||
const client = new DaemonClient({
|
waitForAgentUpsert(
|
||||||
url: getDaemonWsUrl(),
|
agentId: string,
|
||||||
clientId: `app-e2e-archive-tab-${randomUUID()}`,
|
predicate: (snapshot: { status: string }) => boolean,
|
||||||
clientType: "cli",
|
timeout?: number,
|
||||||
webSocketFactory,
|
): Promise<{ status: string }>;
|
||||||
});
|
|
||||||
await client.connect();
|
|
||||||
return client;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createIdleAgent(
|
export async function createIdleAgent(
|
||||||
client: ArchiveTabDaemonClient,
|
client: IdleAgentSeedClient,
|
||||||
input: { cwd: string; title: string },
|
input: { cwd: string; title: string },
|
||||||
): Promise<ArchiveTabAgent> {
|
): Promise<ArchiveTabAgent> {
|
||||||
const created = await client.createAgent({
|
const created = await client.createAgent({
|
||||||
@@ -125,7 +75,7 @@ export async function createIdleAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function archiveAgentFromDaemon(
|
export async function archiveAgentFromDaemon(
|
||||||
client: ArchiveTabDaemonClient,
|
client: { archiveAgent(agentId: string): Promise<{ archivedAt: string }> },
|
||||||
agentId: string,
|
agentId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await client.archiveAgent(agentId);
|
await client.archiveAgent(agentId);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { createTempGitRepo } from "./workspace";
|
import { createTempGitRepo } from "./workspace";
|
||||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf";
|
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
|
||||||
import { connectWorkspaceSetupClient, openHomeWithProject } from "./workspace-setup";
|
import { connectWorkspaceSetupClient, openHomeWithProject } from "./workspace-setup";
|
||||||
import { selectWorkspaceInSidebar } from "./sidebar";
|
import { selectWorkspaceInSidebar } from "./sidebar";
|
||||||
|
import { getServerId } from "./server-id";
|
||||||
import { waitForTabBar } from "./launcher";
|
import { waitForTabBar } from "./launcher";
|
||||||
|
|
||||||
function composerInput(page: Page) {
|
function composerInput(page: Page) {
|
||||||
@@ -145,7 +146,7 @@ export async function selectGithubOption(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MockAgentSetup {
|
export interface MockAgentSetup {
|
||||||
client: TerminalPerfDaemonClient;
|
client: SeedDaemonClient;
|
||||||
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
|
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,11 +155,10 @@ export async function startRunningMockAgent(
|
|||||||
page: Page,
|
page: Page,
|
||||||
opts: { prefix: string; model: string; prompt: string },
|
opts: { prefix: string; model: string; prompt: string },
|
||||||
): Promise<MockAgentSetup> {
|
): Promise<MockAgentSetup> {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = getServerId();
|
||||||
if (!serverId) throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo(opts.prefix);
|
const repo = await createTempGitRepo(opts.prefix);
|
||||||
const client = await connectTerminalClient();
|
const client = await connectSeedClient();
|
||||||
const opened = await client.openProject(repo.path);
|
const opened = await client.openProject(repo.path);
|
||||||
if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project");
|
if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project");
|
||||||
const agent = await client.createAgent({
|
const agent = await client.createAgent({
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { getE2EDaemonPort } from "./daemon-port";
|
||||||
|
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||||
|
|
||||||
export async function loadDaemonClientConstructor<ClientConfig, ClientInstance>(): Promise<
|
export async function loadDaemonClientConstructor<ClientConfig, ClientInstance>(): Promise<
|
||||||
new (config: ClientConfig) => ClientInstance
|
new (config: ClientConfig) => ClientInstance
|
||||||
@@ -13,3 +16,40 @@ export async function loadDaemonClientConstructor<ClientConfig, ClientInstance>(
|
|||||||
};
|
};
|
||||||
return mod.DaemonClient;
|
return mod.DaemonClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface E2EDaemonClientConfig {
|
||||||
|
url: string;
|
||||||
|
clientId: string;
|
||||||
|
clientType: "cli";
|
||||||
|
appVersion?: string;
|
||||||
|
webSocketFactory?: NodeWebSocketFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDaemonWsUrl(): string {
|
||||||
|
return `ws://127.0.0.1:${getE2EDaemonPort()}/ws`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectDaemonClientOptions {
|
||||||
|
clientIdPrefix: string;
|
||||||
|
appVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects an in-test daemon client over the isolated E2E daemon's WebSocket.
|
||||||
|
* The port-6767 guard keeps tests off the developer daemon. Each helper passes
|
||||||
|
* its own typed client interface as the generic.
|
||||||
|
*/
|
||||||
|
export async function connectDaemonClient<ClientInstance extends { connect(): Promise<void> }>(
|
||||||
|
options: ConnectDaemonClientOptions,
|
||||||
|
): Promise<ClientInstance> {
|
||||||
|
const DaemonClient = await loadDaemonClientConstructor<E2EDaemonClientConfig, ClientInstance>();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: resolveDaemonWsUrl(),
|
||||||
|
clientId: `${options.clientIdPrefix}-${randomUUID()}`,
|
||||||
|
clientType: "cli",
|
||||||
|
appVersion: options.appVersion,
|
||||||
|
webSocketFactory: createNodeWebSocketFactory(),
|
||||||
|
});
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { escapeRegex } from "./regex";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the isolated E2E daemon's port, which Playwright's globalSetup
|
||||||
|
* publishes into the environment before any spec runs. Helpers and specs that
|
||||||
|
* build daemon WebSocket URLs, route patterns, or host endpoints share this
|
||||||
|
* accessor instead of re-reading the env var.
|
||||||
|
*
|
||||||
|
* The port-6767 guard is a hard guardrail: 6767 is the developer's default
|
||||||
|
* daemon, which manages real agents. The e2e port is never legitimately 6767,
|
||||||
|
* so refusing it here keeps every test off the developer daemon.
|
||||||
|
*/
|
||||||
|
export function getE2EDaemonPort(): string {
|
||||||
|
const port = process.env.E2E_DAEMON_PORT;
|
||||||
|
if (!port) {
|
||||||
|
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
|
||||||
|
}
|
||||||
|
if (port === "6767") {
|
||||||
|
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon (6767).");
|
||||||
|
}
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playwright `routeWebSocket` matcher for a WebSocket on `port`. Matches the
|
||||||
|
* `:<port>` segment at a word boundary, so it catches the URL regardless of
|
||||||
|
* host or path. Use this when intercepting connections to an arbitrary port
|
||||||
|
* (e.g. blocking an unreachable test host); for the E2E daemon itself, prefer
|
||||||
|
* `daemonWsRoutePattern()`.
|
||||||
|
*/
|
||||||
|
export function wsRoutePatternForPort(port: string): RegExp {
|
||||||
|
return new RegExp(`:${escapeRegex(port)}\\b`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `routeWebSocket` matcher for the isolated E2E daemon's WebSocket. */
|
||||||
|
export function daemonWsRoutePattern(): RegExp {
|
||||||
|
return wsRoutePatternForPort(getE2EDaemonPort());
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { openSettings } from "./app";
|
import { openSettings } from "./app";
|
||||||
|
import { getE2EDaemonPort } from "./daemon-port";
|
||||||
import { openSettingsHost } from "./settings";
|
import { openSettingsHost } from "./settings";
|
||||||
|
|
||||||
interface DaemonApiStatus {
|
interface DaemonApiStatus {
|
||||||
@@ -26,9 +27,8 @@ export interface RealDaemonState {
|
|||||||
* E2E_PASEO_HOME directory. Call this in Node test code (not in the browser).
|
* E2E_PASEO_HOME directory. Call this in Node test code (not in the browser).
|
||||||
*/
|
*/
|
||||||
export async function loadRealDaemonState(): Promise<RealDaemonState> {
|
export async function loadRealDaemonState(): Promise<RealDaemonState> {
|
||||||
const port = process.env.E2E_DAEMON_PORT;
|
const port = getE2EDaemonPort();
|
||||||
const paseoHome = process.env.E2E_PASEO_HOME;
|
const paseoHome = process.env.E2E_PASEO_HOME;
|
||||||
if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first");
|
|
||||||
if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — globalSetup must run first");
|
if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — globalSetup must run first");
|
||||||
|
|
||||||
const resp = await fetch(`http://127.0.0.1:${port}/api/status`);
|
const resp = await fetch(`http://127.0.0.1:${port}/api/status`);
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
import { createTempGitRepo } from "./workspace";
|
import { createTempGitRepo } from "./workspace";
|
||||||
|
import { getServerId } from "./server-id";
|
||||||
|
|
||||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Navigate to a workspace and wait for the tab bar to appear. */
|
/** Navigate to a workspace and wait for the tab bar to appear. */
|
||||||
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
|
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
|
||||||
const route = buildHostWorkspaceRoute(getServerId(), cwd);
|
const route = buildHostWorkspaceRoute(getServerId(), cwd);
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
|
import { seedWorkspace, type SeedDaemonClient } from "./seed-client";
|
||||||
|
import { getServerId } from "./server-id";
|
||||||
|
|
||||||
|
export interface MockAgentWorkspace {
|
||||||
|
agentId: string;
|
||||||
|
cwd: string;
|
||||||
|
client: SeedDaemonClient;
|
||||||
|
cleanup(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MockAgentOptions {
|
||||||
|
repoPrefix: string;
|
||||||
|
title: string;
|
||||||
|
initialPrompt?: string;
|
||||||
|
model?: string;
|
||||||
|
modeId?: string;
|
||||||
|
featureValues?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds a temp git repo, opens it as a project, and creates a ready mock-provider
|
||||||
|
* agent in it via the daemon. Returns the agent id plus a cleanup that closes the
|
||||||
|
* client and removes the repo. Pair with {@link openAgentRoute} to drive the UI.
|
||||||
|
*/
|
||||||
|
export async function seedMockAgentWorkspace(
|
||||||
|
options: MockAgentOptions,
|
||||||
|
): Promise<MockAgentWorkspace> {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix });
|
||||||
|
try {
|
||||||
|
const agent = await workspace.client.createAgent({
|
||||||
|
provider: "mock",
|
||||||
|
cwd: workspace.repoPath,
|
||||||
|
title: options.title,
|
||||||
|
modeId: options.modeId ?? "load-test",
|
||||||
|
model: options.model ?? "ten-second-stream",
|
||||||
|
initialPrompt: options.initialPrompt,
|
||||||
|
featureValues: options.featureValues,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
agentId: agent.id,
|
||||||
|
cwd: workspace.repoPath,
|
||||||
|
client: workspace.client,
|
||||||
|
cleanup: workspace.cleanup,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await workspace.cleanup();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAgentRoute(cwd: string, agentId: string): string {
|
||||||
|
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
|
||||||
|
`agent:${agentId}`,
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */
|
||||||
|
export async function openAgentRoute(
|
||||||
|
page: Page,
|
||||||
|
input: { cwd: string; agentId: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await page.goto(buildAgentRoute(input.cwd, input.agentId));
|
||||||
|
await page.waitForURL(
|
||||||
|
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||||
import { loadDaemonClientConstructor } from "./daemon-client-loader";
|
import { connectDaemonClient } from "./daemon-client-loader";
|
||||||
|
import { daemonWsRoutePattern } from "./daemon-port";
|
||||||
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
|
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
|
||||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
|
||||||
|
|
||||||
type NewWorkspaceDaemonClient = Pick<
|
type NewWorkspaceDaemonClient = Pick<
|
||||||
InternalDaemonClient,
|
InternalDaemonClient,
|
||||||
@@ -16,13 +15,6 @@ type NewWorkspaceDaemonClient = Pick<
|
|||||||
| "openProject"
|
| "openProject"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
interface NewWorkspaceDaemonClientConfig {
|
|
||||||
url: string;
|
|
||||||
clientId: string;
|
|
||||||
clientType: "cli";
|
|
||||||
webSocketFactory?: NodeWebSocketFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
|
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
|
||||||
|
|
||||||
export interface OpenedProject {
|
export interface OpenedProject {
|
||||||
@@ -32,21 +24,6 @@ export interface OpenedProject {
|
|||||||
workspaceName: string;
|
workspaceName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDaemonPort(): string {
|
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
|
||||||
}
|
|
||||||
if (daemonPort === "6767") {
|
|
||||||
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
|
|
||||||
}
|
|
||||||
return daemonPort;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDaemonWsUrl(): string {
|
|
||||||
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireWorkspace(payload: OpenProjectPayload) {
|
function requireWorkspace(payload: OpenProjectPayload) {
|
||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
throw new Error(payload.error);
|
throw new Error(payload.error);
|
||||||
@@ -69,19 +46,9 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
|
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
|
||||||
const DaemonClient = await loadDaemonClientConstructor<
|
return connectDaemonClient<NewWorkspaceDaemonClient>({
|
||||||
NewWorkspaceDaemonClientConfig,
|
clientIdPrefix: "app-e2e-new-workspace",
|
||||||
NewWorkspaceDaemonClient
|
|
||||||
>();
|
|
||||||
const webSocketFactory = createNodeWebSocketFactory();
|
|
||||||
const client = new DaemonClient({
|
|
||||||
url: getDaemonWsUrl(),
|
|
||||||
clientId: `app-e2e-new-workspace-${randomUUID()}`,
|
|
||||||
clientType: "cli",
|
|
||||||
webSocketFactory,
|
|
||||||
});
|
});
|
||||||
await client.connect();
|
|
||||||
return client;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openProjectViaDaemon(
|
export async function openProjectViaDaemon(
|
||||||
@@ -316,12 +283,7 @@ export interface AgentCreatedDelayControl {
|
|||||||
export async function delayBrowserAgentCreatedStatus(
|
export async function delayBrowserAgentCreatedStatus(
|
||||||
page: Page,
|
page: Page,
|
||||||
): Promise<AgentCreatedDelayControl> {
|
): Promise<AgentCreatedDelayControl> {
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
const daemonPortPattern = daemonWsRoutePattern();
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
|
||||||
const createRequestIds = new Set<string>();
|
const createRequestIds = new Set<string>();
|
||||||
const delayedForwards: Array<() => void> = [];
|
const delayedForwards: Array<() => void> = [];
|
||||||
let releaseRequested = false;
|
let releaseRequested = false;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import type { WebSocketRoute } from "@playwright/test";
|
import type { WebSocketRoute } from "@playwright/test";
|
||||||
import { gotoAppShell, openSettings } from "./app";
|
import { gotoAppShell, openSettings } from "./app";
|
||||||
|
import { daemonWsRoutePattern } from "./daemon-port";
|
||||||
|
|
||||||
// --- Navigation ---
|
// --- Navigation ---
|
||||||
|
|
||||||
@@ -170,19 +171,13 @@ export async function unblockPaseoConfigWrites(repoPath: string): Promise<void>
|
|||||||
|
|
||||||
// --- WebSocket helpers ---
|
// --- WebSocket helpers ---
|
||||||
|
|
||||||
function buildDaemonPortPattern(): RegExp {
|
|
||||||
const port = process.env.E2E_DAEMON_PORT;
|
|
||||||
if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first");
|
|
||||||
return new RegExp(`:${port.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Proxies all daemon WS traffic transparently until a read_project_config_request
|
// Proxies all daemon WS traffic transparently until a read_project_config_request
|
||||||
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
|
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
|
||||||
// connections pass through so the Reload action can succeed.
|
// connections pass through so the Reload action can succeed.
|
||||||
export async function installReadTransportFailure(page: Page): Promise<void> {
|
export async function installReadTransportFailure(page: Page): Promise<void> {
|
||||||
let armed = true;
|
let armed = true;
|
||||||
|
|
||||||
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||||
const server = ws.connectToServer();
|
const server = ws.connectToServer();
|
||||||
|
|
||||||
ws.onMessage((message) => {
|
ws.onMessage((message) => {
|
||||||
@@ -230,7 +225,7 @@ export async function installDaemonConnectionGate(
|
|||||||
let acceptingConnections = true;
|
let acceptingConnections = true;
|
||||||
const activeSockets = new Set<WebSocketRoute>();
|
const activeSockets = new Set<WebSocketRoute>();
|
||||||
|
|
||||||
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||||
if (!acceptingConnections) {
|
if (!acceptingConnections) {
|
||||||
void ws.close({ code: 1001 });
|
void ws.close({ code: 1001 });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Escape a literal string so it can be embedded safely inside a `RegExp`.
|
||||||
|
* Used across the suite to build dynamic patterns from daemon ports, URLs,
|
||||||
|
* workspace routes, and user-visible text without regex injection.
|
||||||
|
*/
|
||||||
|
export function escapeRegex(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
@@ -4,14 +4,15 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { expectComposerEditable, submitMessage } from "./composer";
|
import { expectComposerEditable, submitMessage } from "./composer";
|
||||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf";
|
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
|
||||||
|
import { getServerId } from "./server-id";
|
||||||
|
|
||||||
export type RewindFlowProvider = "claude" | "codex" | "opencode" | "pi";
|
export type RewindFlowProvider = "claude" | "codex" | "opencode" | "pi";
|
||||||
export type RewindFlowMode = "conversation" | "files" | "both";
|
export type RewindFlowMode = "conversation" | "files" | "both";
|
||||||
|
|
||||||
export interface AgentHandle {
|
export interface AgentHandle {
|
||||||
page: Page;
|
page: Page;
|
||||||
client: TerminalPerfDaemonClient;
|
client: SeedDaemonClient;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
provider: RewindFlowProvider;
|
provider: RewindFlowProvider;
|
||||||
@@ -33,14 +34,6 @@ interface ProviderLaunchConfig {
|
|||||||
const SEND_TIMEOUT_MS = 240_000;
|
const SEND_TIMEOUT_MS = 240_000;
|
||||||
const REWIND_TIMEOUT_MS = 120_000;
|
const REWIND_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
|
function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case "claude":
|
case "claude":
|
||||||
@@ -173,7 +166,7 @@ export async function launchAgent(input: {
|
|||||||
writeFileSync(`${input.cwd}/README.md`, "# Paseo rewind flow\n", "utf8");
|
writeFileSync(`${input.cwd}/README.md`, "# Paseo rewind flow\n", "utf8");
|
||||||
execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" });
|
execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" });
|
||||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" });
|
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" });
|
||||||
const client = await connectTerminalClient();
|
const client = await connectSeedClient();
|
||||||
const opened = await client.openProject(input.cwd);
|
const opened = await client.openProject(input.cwd);
|
||||||
if (!opened.workspace) {
|
if (!opened.workspace) {
|
||||||
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
|
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
|
||||||
@@ -324,7 +317,7 @@ export async function cleanupRewindFlow(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchTimelineEpoch(handle: AgentHandle): Promise<string | undefined> {
|
async function fetchTimelineEpoch(handle: AgentHandle): Promise<string | undefined> {
|
||||||
const client = handle.client as TerminalPerfDaemonClient & {
|
const client = handle.client as SeedDaemonClient & {
|
||||||
fetchAgentTimeline: (
|
fetchAgentTimeline: (
|
||||||
agentId: string,
|
agentId: string,
|
||||||
options?: { direction?: "head" | "tail"; projection?: "canonical"; limit?: number },
|
options?: { direction?: "head" | "tail"; projection?: "canonical"; limit?: number },
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { connectDaemonClient } from "./daemon-client-loader";
|
||||||
|
import { createTempDirectory, createTempGitRepo } from "./workspace";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The general-purpose E2E daemon client used to seed and drive state out of
|
||||||
|
* band (workspaces, agents, terminals) while the UI is exercised through the
|
||||||
|
* browser. Domain-specific helpers wrap it for their own flows; specs should
|
||||||
|
* prefer those wrappers over reaching for this client directly.
|
||||||
|
*/
|
||||||
|
export interface SeedDaemonClient {
|
||||||
|
connect(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
openProject(cwd: string): Promise<{
|
||||||
|
workspace: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
projectId: string;
|
||||||
|
projectDisplayName: string;
|
||||||
|
projectRootPath: string;
|
||||||
|
workspaceDirectory: string;
|
||||||
|
} | null;
|
||||||
|
error: string | null;
|
||||||
|
}>;
|
||||||
|
createTerminal(
|
||||||
|
cwd: string,
|
||||||
|
name?: string,
|
||||||
|
): Promise<{
|
||||||
|
terminal: { id: string; name: string; cwd: string } | null;
|
||||||
|
error: string | null;
|
||||||
|
}>;
|
||||||
|
createAgent(options: {
|
||||||
|
provider: string;
|
||||||
|
cwd: string;
|
||||||
|
title?: string;
|
||||||
|
modeId?: string;
|
||||||
|
model?: string;
|
||||||
|
thinkingOptionId?: string;
|
||||||
|
featureValues?: Record<string, unknown>;
|
||||||
|
initialPrompt?: string;
|
||||||
|
}): Promise<{ id: string; status: string }>;
|
||||||
|
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||||
|
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
|
||||||
|
}>;
|
||||||
|
updateAgent(agentId: string, updates: { name?: string }): Promise<void>;
|
||||||
|
waitForAgentUpsert(
|
||||||
|
agentId: string,
|
||||||
|
predicate: (snapshot: { status: string }) => boolean,
|
||||||
|
timeout?: number,
|
||||||
|
): Promise<{ status: string }>;
|
||||||
|
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||||
|
waitForFinish(
|
||||||
|
agentId: string,
|
||||||
|
timeout?: number,
|
||||||
|
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||||
|
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||||
|
fetchAgentHistory(options?: {
|
||||||
|
page?: { limit: number };
|
||||||
|
}): Promise<{ entries: Array<{ id: string }> }>;
|
||||||
|
subscribeTerminal(
|
||||||
|
terminalId: string,
|
||||||
|
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||||
|
sendTerminalInput(
|
||||||
|
terminalId: string,
|
||||||
|
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
|
||||||
|
): void;
|
||||||
|
onTerminalStreamEvent(
|
||||||
|
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
|
||||||
|
): () => void;
|
||||||
|
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function connectSeedClient(): Promise<SeedDaemonClient> {
|
||||||
|
return connectDaemonClient<SeedDaemonClient>({
|
||||||
|
clientIdPrefix: "seed",
|
||||||
|
appVersion: loadAppVersion(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A temp project opened as a workspace, with a seed client connected to drive
|
||||||
|
* it out of band. `cleanup` closes the client and removes the project. This is
|
||||||
|
* the canonical bootstrap for specs that need a real workspace plus daemon
|
||||||
|
* access; domain helpers (e.g. mock-agent) build on it rather than re-rolling
|
||||||
|
* the trio. `repoPath` is the project root on disk (git repo or plain dir).
|
||||||
|
*/
|
||||||
|
export interface SeededWorkspace {
|
||||||
|
client: SeedDaemonClient;
|
||||||
|
repoPath: string;
|
||||||
|
workspaceId: string;
|
||||||
|
workspaceName: string;
|
||||||
|
workspaceDirectory: string;
|
||||||
|
/** Stable project identity the daemon groups workspaces under. */
|
||||||
|
projectId: string;
|
||||||
|
/** Project label the UI shows (owner/repo for known remotes, else basename). */
|
||||||
|
projectDisplayName: string;
|
||||||
|
cleanup(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedWorkspace(options: {
|
||||||
|
repoPrefix: string;
|
||||||
|
/** Repo fixture options; only applies to git projects (the default). */
|
||||||
|
repo?: Parameters<typeof createTempGitRepo>[1];
|
||||||
|
/** Set to false to seed a plain non-git directory instead of a git repo. */
|
||||||
|
git?: boolean;
|
||||||
|
}): Promise<SeededWorkspace> {
|
||||||
|
const project =
|
||||||
|
options.git === false
|
||||||
|
? await createTempDirectory(options.repoPrefix)
|
||||||
|
: await createTempGitRepo(options.repoPrefix, options.repo);
|
||||||
|
const client = await connectSeedClient();
|
||||||
|
try {
|
||||||
|
const opened = await client.openProject(project.path);
|
||||||
|
if (!opened.workspace) {
|
||||||
|
throw new Error(opened.error ?? `Failed to open project ${project.path}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
repoPath: project.path,
|
||||||
|
workspaceId: opened.workspace.id,
|
||||||
|
workspaceName: opened.workspace.name,
|
||||||
|
workspaceDirectory: opened.workspace.workspaceDirectory,
|
||||||
|
projectId: opened.workspace.projectId,
|
||||||
|
projectDisplayName: opened.workspace.projectDisplayName,
|
||||||
|
cleanup: async () => {
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
await project.cleanup().catch(() => undefined);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
await project.cleanup().catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAppVersion(): string {
|
||||||
|
const packageJsonPath = path.resolve(__dirname, "../../package.json");
|
||||||
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
||||||
|
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
||||||
|
throw new Error(`Missing app version in ${packageJsonPath}`);
|
||||||
|
}
|
||||||
|
return packageJson.version;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Resolves the isolated E2E daemon's server id, which Playwright's globalSetup
|
||||||
|
* publishes into the environment before any spec runs. Helpers and specs that
|
||||||
|
* build host routes or `sidebar-workspace-row-${serverId}:${id}` selectors share
|
||||||
|
* this accessor instead of re-reading the env var.
|
||||||
|
*/
|
||||||
|
export function getServerId(): string {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||||
|
}
|
||||||
|
return serverId;
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { requireServerId } from "./sidebar";
|
import { escapeRegex } from "./regex";
|
||||||
|
import { getServerId } from "./server-id";
|
||||||
function escapeRegex(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
const SECTION_LABELS = {
|
const SECTION_LABELS = {
|
||||||
general: "General",
|
general: "General",
|
||||||
@@ -113,13 +110,13 @@ export async function expectHostSettingsUrl(page: Page, serverId: string): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyLegacyHostSettingsRedirect(page: Page): Promise<void> {
|
export async function verifyLegacyHostSettingsRedirect(page: Page): Promise<void> {
|
||||||
const serverId = requireServerId();
|
const serverId = getServerId();
|
||||||
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
|
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
|
||||||
await expectHostSettingsUrl(page, serverId);
|
await expectHostSettingsUrl(page, serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openCompactSettingsHost(page: Page): Promise<void> {
|
export async function openCompactSettingsHost(page: Page): Promise<void> {
|
||||||
const serverId = requireServerId();
|
const serverId = getServerId();
|
||||||
await openSettingsHost(page, serverId);
|
await openSettingsHost(page, serverId);
|
||||||
await expectHostSettingsUrl(page, serverId);
|
await expectHostSettingsUrl(page, serverId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import { getServerId } from "./server-id";
|
||||||
export function requireServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function selectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
|
export async function selectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||||
const row = page.getByTestId(`sidebar-workspace-row-${requireServerId()}:${workspaceId}`);
|
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||||
await row.click();
|
await row.click();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, type Page } from "../fixtures";
|
import { expect, type Page } from "../fixtures";
|
||||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||||
|
import { wsRoutePatternForPort } from "./daemon-port";
|
||||||
|
|
||||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||||
@@ -78,7 +79,7 @@ class StartupScenario {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const port of this.blockedEndpointPorts) {
|
for (const port of this.blockedEndpointPorts) {
|
||||||
await this.page.routeWebSocket(new RegExp(`:${escapeRegex(port)}\\b`), async (ws) => {
|
await this.page.routeWebSocket(wsRoutePatternForPort(port), async (ws) => {
|
||||||
await ws.close({ code: 1008, reason: "Blocked unreachable startup test host." });
|
await ws.close({ code: 1008, reason: "Blocked unreachable startup test host." });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -227,7 +228,3 @@ function buildStoredHost(input: {
|
|||||||
function buildStoredCreateAgentPreferences(serverId: string) {
|
function buildStoredCreateAgentPreferences(serverId: string) {
|
||||||
return buildCreateAgentPreferences(serverId);
|
return buildCreateAgentPreferences(serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
import { createTempGitRepo } from "./workspace";
|
import { createTempGitRepo } from "./workspace";
|
||||||
import {
|
import { navigateToTerminal, setupDeterministicPrompt } from "./terminal-perf";
|
||||||
connectTerminalClient,
|
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
|
||||||
navigateToTerminal,
|
|
||||||
setupDeterministicPrompt,
|
|
||||||
type TerminalPerfDaemonClient,
|
|
||||||
} from "./terminal-perf";
|
|
||||||
|
|
||||||
interface TempRepo {
|
interface TempRepo {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -19,12 +15,12 @@ export interface TerminalInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class TerminalE2EHarness {
|
export class TerminalE2EHarness {
|
||||||
readonly client: TerminalPerfDaemonClient;
|
readonly client: SeedDaemonClient;
|
||||||
readonly tempRepo: TempRepo;
|
readonly tempRepo: TempRepo;
|
||||||
readonly workspaceId: string;
|
readonly workspaceId: string;
|
||||||
|
|
||||||
private constructor(input: {
|
private constructor(input: {
|
||||||
client: TerminalPerfDaemonClient;
|
client: SeedDaemonClient;
|
||||||
tempRepo: TempRepo;
|
tempRepo: TempRepo;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -35,7 +31,7 @@ export class TerminalE2EHarness {
|
|||||||
|
|
||||||
static async create(input: { tempPrefix: string }): Promise<TerminalE2EHarness> {
|
static async create(input: { tempPrefix: string }): Promise<TerminalE2EHarness> {
|
||||||
const tempRepo = await createTempGitRepo(input.tempPrefix);
|
const tempRepo = await createTempGitRepo(input.tempPrefix);
|
||||||
const client = await connectTerminalClient();
|
const client = await connectSeedClient();
|
||||||
const seedResult = await client.openProject(tempRepo.path);
|
const seedResult = await client.openProject(tempRepo.path);
|
||||||
if (!seedResult.workspace) {
|
if (!seedResult.workspace) {
|
||||||
await client.close().catch(() => {});
|
await client.close().catch(() => {});
|
||||||
|
|||||||
@@ -1,111 +1,6 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import path from "node:path";
|
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { loadDaemonClientConstructor } from "./daemon-client-loader";
|
|
||||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
|
||||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
|
import { getServerId } from "./server-id";
|
||||||
export interface TerminalPerfDaemonClient {
|
|
||||||
connect(): Promise<void>;
|
|
||||||
close(): Promise<void>;
|
|
||||||
openProject(cwd: string): Promise<{
|
|
||||||
workspace: { id: string; name: string; projectRootPath: string } | null;
|
|
||||||
error: string | null;
|
|
||||||
}>;
|
|
||||||
createTerminal(
|
|
||||||
cwd: string,
|
|
||||||
name?: string,
|
|
||||||
): Promise<{
|
|
||||||
terminal: { id: string; name: string; cwd: string } | null;
|
|
||||||
error: string | null;
|
|
||||||
}>;
|
|
||||||
createAgent(options: {
|
|
||||||
provider: string;
|
|
||||||
cwd: string;
|
|
||||||
title?: string;
|
|
||||||
modeId?: string;
|
|
||||||
model?: string;
|
|
||||||
thinkingOptionId?: string;
|
|
||||||
featureValues?: Record<string, unknown>;
|
|
||||||
initialPrompt?: string;
|
|
||||||
}): Promise<{ id: string; status: string }>;
|
|
||||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
|
||||||
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
|
|
||||||
}>;
|
|
||||||
updateAgent(agentId: string, updates: { name?: string }): Promise<void>;
|
|
||||||
waitForAgentUpsert(
|
|
||||||
agentId: string,
|
|
||||||
predicate: (snapshot: { status: string }) => boolean,
|
|
||||||
timeout?: number,
|
|
||||||
): Promise<{ status: string }>;
|
|
||||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
|
||||||
waitForFinish(
|
|
||||||
agentId: string,
|
|
||||||
timeout?: number,
|
|
||||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
|
||||||
subscribeTerminal(
|
|
||||||
terminalId: string,
|
|
||||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
|
||||||
sendTerminalInput(
|
|
||||||
terminalId: string,
|
|
||||||
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
|
|
||||||
): void;
|
|
||||||
onTerminalStreamEvent(
|
|
||||||
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
|
|
||||||
): () => void;
|
|
||||||
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDaemonWsUrl(): string {
|
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
|
||||||
}
|
|
||||||
return `ws://127.0.0.1:${daemonPort}/ws`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TerminalPerfDaemonClientConfig {
|
|
||||||
url: string;
|
|
||||||
clientId: string;
|
|
||||||
clientType: "cli";
|
|
||||||
appVersion?: string;
|
|
||||||
webSocketFactory?: NodeWebSocketFactory;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
|
|
||||||
const DaemonClient = await loadDaemonClientConstructor<
|
|
||||||
TerminalPerfDaemonClientConfig,
|
|
||||||
TerminalPerfDaemonClient
|
|
||||||
>();
|
|
||||||
const webSocketFactory = createNodeWebSocketFactory();
|
|
||||||
const client = new DaemonClient({
|
|
||||||
url: getDaemonWsUrl(),
|
|
||||||
clientId: `terminal-perf-${randomUUID()}`,
|
|
||||||
clientType: "cli",
|
|
||||||
appVersion: loadAppVersion(),
|
|
||||||
webSocketFactory,
|
|
||||||
});
|
|
||||||
await client.connect();
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadAppVersion(): string {
|
|
||||||
const packageJsonPath = path.resolve(__dirname, "../../package.json");
|
|
||||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
|
||||||
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
|
||||||
throw new Error(`Missing app version in ${packageJsonPath}`);
|
|
||||||
}
|
|
||||||
return packageJson.version;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string {
|
export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { realpath } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
import { waitForTabBar } from "./launcher";
|
import { waitForTabBar } from "./launcher";
|
||||||
import { selectWorkspaceInSidebar } from "./sidebar";
|
import { selectWorkspaceInSidebar } from "./sidebar";
|
||||||
import { createTempGitRepo } from "./workspace";
|
import { createTempGitRepo, resolveTempRoot } from "./workspace";
|
||||||
import {
|
import {
|
||||||
connectWorkspaceSetupClient,
|
connectWorkspaceSetupClient,
|
||||||
openHomeWithProject,
|
openHomeWithProject,
|
||||||
@@ -49,7 +48,7 @@ export function createWithWorkspace(page: Page): WithWorkspaceHandle {
|
|||||||
|
|
||||||
let workspacePath = repo.path;
|
let workspacePath = repo.path;
|
||||||
if (options?.worktree) {
|
if (options?.worktree) {
|
||||||
const tempRoot = await realpath("/tmp");
|
const tempRoot = await resolveTempRoot();
|
||||||
workspacePath = path.join(
|
workspacePath = path.join(
|
||||||
tempRoot,
|
tempRoot,
|
||||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
|||||||
@@ -1,58 +1,25 @@
|
|||||||
import { realpathSync } from "node:fs";
|
import { realpathSync } from "node:fs";
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
|
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
|
||||||
import { gotoAppShell } from "./app";
|
import { gotoAppShell } from "./app";
|
||||||
import { loadDaemonClientConstructor } from "./daemon-client-loader";
|
import { connectDaemonClient } from "./daemon-client-loader";
|
||||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
import { getServerId } from "./server-id";
|
||||||
import { switchWorkspaceViaSidebar } from "./workspace-ui";
|
import { switchWorkspaceViaSidebar } from "./workspace-ui";
|
||||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||||
|
|
||||||
interface WorkspaceSetupDaemonClient {
|
type WorkspaceSetupDaemonClient = Pick<
|
||||||
connect(): Promise<void>;
|
InternalDaemonClient,
|
||||||
close(): Promise<void>;
|
| "close"
|
||||||
openProject(cwd: string): Promise<{
|
| "connect"
|
||||||
workspace: {
|
| "createPaseoWorktree"
|
||||||
id: string;
|
| "fetchAgent"
|
||||||
name: string;
|
| "fetchAgents"
|
||||||
workspaceDirectory: string;
|
| "fetchWorkspaces"
|
||||||
projectRootPath: string;
|
| "listTerminals"
|
||||||
} | null;
|
| "openProject"
|
||||||
error: string | null;
|
| "subscribeRawMessages"
|
||||||
}>;
|
>;
|
||||||
createPaseoWorktree(input: { cwd: string; worktreeSlug?: string }): Promise<{
|
|
||||||
workspace: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
workspaceDirectory: string;
|
|
||||||
projectRootPath: string;
|
|
||||||
} | null;
|
|
||||||
error: string | null;
|
|
||||||
}>;
|
|
||||||
fetchWorkspaces(): Promise<{
|
|
||||||
entries: Array<{
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
workspaceDirectory: string;
|
|
||||||
projectRootPath: string;
|
|
||||||
}>;
|
|
||||||
}>;
|
|
||||||
fetchAgents(): Promise<{
|
|
||||||
entries: Array<{
|
|
||||||
agent: { id: string; cwd: string; workspaceId?: string | null };
|
|
||||||
}>;
|
|
||||||
}>;
|
|
||||||
fetchAgent(agentId: string): Promise<{
|
|
||||||
agent: { id: string; cwd: string } | null;
|
|
||||||
project: unknown;
|
|
||||||
} | null>;
|
|
||||||
listTerminals(cwd: string): Promise<{
|
|
||||||
cwd?: string;
|
|
||||||
terminals: Array<{ id: string; cwd: string; name: string }>;
|
|
||||||
error?: string | null;
|
|
||||||
}>;
|
|
||||||
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkspaceSetupProgressPayload = Extract<
|
export type WorkspaceSetupProgressPayload = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
@@ -61,43 +28,30 @@ export type WorkspaceSetupProgressPayload = Extract<
|
|||||||
|
|
||||||
export type { WorkspaceSetupDaemonClient };
|
export type { WorkspaceSetupDaemonClient };
|
||||||
|
|
||||||
function getDaemonWsUrl(): string {
|
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
return connectDaemonClient<WorkspaceSetupDaemonClient>({ clientIdPrefix: "workspace-setup" });
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
|
||||||
}
|
|
||||||
return `ws://127.0.0.1:${daemonPort}/ws`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
|
export async function openProjectViaDaemon(
|
||||||
const DaemonClient = await loadDaemonClientConstructor<
|
client: WorkspaceSetupDaemonClient,
|
||||||
{
|
repoPath: string,
|
||||||
url: string;
|
): Promise<{ id: string; name: string; workspaceDirectory: string }> {
|
||||||
clientId: string;
|
const result = await client.openProject(repoPath);
|
||||||
clientType: "cli";
|
if (!result.workspace || result.error) {
|
||||||
webSocketFactory?: NodeWebSocketFactory;
|
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
|
||||||
},
|
}
|
||||||
WorkspaceSetupDaemonClient
|
return {
|
||||||
>();
|
id: result.workspace.id,
|
||||||
const webSocketFactory = createNodeWebSocketFactory();
|
name: result.workspace.name,
|
||||||
const client = new DaemonClient({
|
workspaceDirectory: result.workspace.workspaceDirectory,
|
||||||
url: getDaemonWsUrl(),
|
};
|
||||||
clientId: `workspace-setup-${randomUUID()}`,
|
|
||||||
clientType: "cli",
|
|
||||||
webSocketFactory,
|
|
||||||
});
|
|
||||||
await client.connect();
|
|
||||||
return client;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedProjectForWorkspaceSetup(
|
export async function seedProjectForWorkspaceSetup(
|
||||||
client: WorkspaceSetupDaemonClient,
|
client: WorkspaceSetupDaemonClient,
|
||||||
repoPath: string,
|
repoPath: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const result = await client.openProject(repoPath);
|
await openProjectViaDaemon(client, repoPath);
|
||||||
if (!result.workspace || result.error) {
|
|
||||||
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function projectNameFromPath(repoPath: string): string {
|
export function projectNameFromPath(repoPath: string): string {
|
||||||
@@ -304,11 +258,11 @@ export async function navigateToWorkspaceViaSidebar(
|
|||||||
page: Page,
|
page: Page,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
await switchWorkspaceViaSidebar({
|
||||||
if (!serverId) {
|
page,
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
serverId: getServerId(),
|
||||||
}
|
targetWorkspacePath: workspaceId,
|
||||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspaceId });
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {
|
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { gotoHome } from "./app";
|
import { gotoHome } from "./app";
|
||||||
|
import { escapeRegex } from "./regex";
|
||||||
|
|
||||||
export async function openNewAgentComposer(page: Page): Promise<void> {
|
export async function openNewAgentComposer(page: Page): Promise<void> {
|
||||||
await gotoHome(page);
|
await gotoHome(page);
|
||||||
@@ -23,10 +24,6 @@ export function workspaceLabelFromPath(value: string): string {
|
|||||||
return parts[parts.length - 1] ?? normalized;
|
return parts[parts.length - 1] ?? normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
function candidateWorkspaceIds(inputPath: string): string[] {
|
function candidateWorkspaceIds(inputPath: string): string[] {
|
||||||
const trimmed = inputPath.replace(/\/+$/, "");
|
const trimmed = inputPath.replace(/\/+$/, "");
|
||||||
const candidates = new Set<string>([trimmed]);
|
const candidates = new Set<string>([trimmed]);
|
||||||
|
|||||||
@@ -9,6 +9,20 @@ interface TempRepo {
|
|||||||
cleanup: () => Promise<void>;
|
cleanup: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TempDirectory {
|
||||||
|
path: string;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The temp root for E2E fixtures. On macOS we resolve symlinks (/tmp →
|
||||||
|
* /private/tmp) so fixture paths match the daemon's resolved paths; on Windows
|
||||||
|
* `/tmp` doesn't exist, so fall back to the OS temp dir.
|
||||||
|
*/
|
||||||
|
export async function resolveTempRoot(): Promise<string> {
|
||||||
|
return process.platform === "win32" ? tmpdir() : await realpath("/tmp");
|
||||||
|
}
|
||||||
|
|
||||||
async function configureRemote(input: {
|
async function configureRemote(input: {
|
||||||
repoPath: string;
|
repoPath: string;
|
||||||
withRemote: boolean;
|
withRemote: boolean;
|
||||||
@@ -22,6 +36,15 @@ async function configureRemote(input: {
|
|||||||
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
|
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
|
||||||
|
if (originUrl) {
|
||||||
|
// Relabel origin to a display URL after the local tracking remote is set
|
||||||
|
// up, so project grouping shows the remote's owner/repo while branch
|
||||||
|
// tracking refs still resolve locally (no fetch from the synthetic URL).
|
||||||
|
execSync(`git remote set-url origin ${JSON.stringify(originUrl)}`, {
|
||||||
|
cwd: repoPath,
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (originUrl) {
|
if (originUrl) {
|
||||||
@@ -44,9 +67,7 @@ export const createTempGitRepo = async (
|
|||||||
},
|
},
|
||||||
): Promise<TempRepo> => {
|
): Promise<TempRepo> => {
|
||||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
||||||
// Resolve symlinks (macOS: /tmp → /private/tmp) so paths match the daemon's resolved paths.
|
const repoPath = await mkdtemp(path.join(await resolveTempRoot(), prefix));
|
||||||
const tempRoot = process.platform === "win32" ? tmpdir() : await realpath("/tmp");
|
|
||||||
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
|
|
||||||
const withRemote = options?.withRemote ?? false;
|
const withRemote = options?.withRemote ?? false;
|
||||||
|
|
||||||
execSync("git init -b main", { cwd: repoPath, stdio: "ignore" });
|
execSync("git init -b main", { cwd: repoPath, stdio: "ignore" });
|
||||||
@@ -110,6 +131,21 @@ export const createTempGitRepo = async (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plain (non-git) directory opened as a project. The daemon shows its
|
||||||
|
* basename as the project name, since there's no remote to group under.
|
||||||
|
*/
|
||||||
|
export async function createTempDirectory(prefix = "paseo-e2e-dir-"): Promise<TempDirectory> {
|
||||||
|
const dirPath = await mkdtemp(path.join(await resolveTempRoot(), prefix));
|
||||||
|
await writeFile(path.join(dirPath, "README.md"), "# Temp Directory\n");
|
||||||
|
return {
|
||||||
|
path: dirPath,
|
||||||
|
cleanup: async () => {
|
||||||
|
await rm(dirPath, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{
|
export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{
|
||||||
currentBranch: string;
|
currentBranch: string;
|
||||||
hasAncestor: (ref: string) => boolean;
|
hasAncestor: (ref: string) => boolean;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { test, expect } from "./fixtures";
|
import { test, expect } from "./fixtures";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import {
|
import {
|
||||||
gotoWorkspace,
|
gotoWorkspace,
|
||||||
assertNewChatTileVisible,
|
assertNewChatTileVisible,
|
||||||
@@ -16,30 +15,19 @@ import {
|
|||||||
terminalSurfaceLocator,
|
terminalSurfaceLocator,
|
||||||
} from "./helpers/launcher";
|
} from "./helpers/launcher";
|
||||||
import { expectComposerVisible, composerLocator } from "./helpers/composer";
|
import { expectComposerVisible, composerLocator } from "./helpers/composer";
|
||||||
import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf";
|
import { expectTerminalSurfaceVisible, setupDeterministicPrompt } from "./helpers/terminal-perf";
|
||||||
import {
|
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||||
connectTerminalClient,
|
|
||||||
setupDeterministicPrompt,
|
|
||||||
type TerminalPerfDaemonClient,
|
|
||||||
} from "./helpers/terminal-perf";
|
|
||||||
|
|
||||||
// ─── Shared state ──────────────────────────────────────────────────────────
|
// ─── Shared state ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
let workspace: SeededWorkspace;
|
||||||
let workspaceId: string;
|
|
||||||
let seedClient: TerminalPerfDaemonClient;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
tempRepo = await createTempGitRepo("launcher-e2e-");
|
workspace = await seedWorkspace({ repoPrefix: "launcher-e2e-" });
|
||||||
seedClient = await connectTerminalClient();
|
|
||||||
const result = await seedClient.openProject(tempRepo.path);
|
|
||||||
if (!result.workspace) throw new Error(result.error ?? "Failed to seed workspace");
|
|
||||||
workspaceId = result.workspace.id;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test.afterAll(async () => {
|
||||||
if (seedClient) await seedClient.close();
|
await workspace?.cleanup();
|
||||||
if (tempRepo) await tempRepo.cleanup();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -48,7 +36,7 @@ test.afterAll(async () => {
|
|||||||
|
|
||||||
test.describe("Tab creation", () => {
|
test.describe("Tab creation", () => {
|
||||||
test("Cmd+T opens a new agent tab with composer", async ({ page }) => {
|
test("Cmd+T opens a new agent tab with composer", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
|
||||||
await pressNewTabShortcut(page);
|
await pressNewTabShortcut(page);
|
||||||
|
|
||||||
@@ -56,7 +44,7 @@ test.describe("Tab creation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("opening two new tabs creates two draft tabs", async ({ page }) => {
|
test("opening two new tabs creates two draft tabs", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
|
||||||
const countBefore = await countTabsOfKind(page, "draft");
|
const countBefore = await countTabsOfKind(page, "draft");
|
||||||
|
|
||||||
@@ -75,7 +63,7 @@ test.describe("Tab creation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
|
||||||
await clickNewChat(page);
|
await clickNewChat(page);
|
||||||
|
|
||||||
@@ -88,7 +76,7 @@ test.describe("Tab creation", () => {
|
|||||||
|
|
||||||
test("clicking terminal button creates a standalone terminal", async ({ page }) => {
|
test("clicking terminal button creates a standalone terminal", async ({ page }) => {
|
||||||
test.setTimeout(45_000);
|
test.setTimeout(45_000);
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
|
||||||
await clickNewTerminal(page);
|
await clickNewTerminal(page);
|
||||||
|
|
||||||
@@ -100,7 +88,7 @@ test.describe("Tab creation", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("tab bar shows action buttons per pane", async ({ page }) => {
|
test("tab bar shows action buttons per pane", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await assertSingleNewTabButton(page);
|
await assertSingleNewTabButton(page);
|
||||||
await assertNewChatTileVisible(page);
|
await assertNewChatTileVisible(page);
|
||||||
await assertTerminalTileVisible(page);
|
await assertTerminalTileVisible(page);
|
||||||
@@ -117,26 +105,16 @@ test.describe("Terminal title propagation", () => {
|
|||||||
// must re-render before the assertion deadline. Allow retries.
|
// must re-render before the assertion deadline. Allow retries.
|
||||||
test.describe.configure({ retries: 2 });
|
test.describe.configure({ retries: 2 });
|
||||||
|
|
||||||
let client: TerminalPerfDaemonClient;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
client = await connectTerminalClient();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
if (client) await client.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.skip("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
|
test.skip("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
const result = await client.createTerminal(tempRepo.path, "title-test");
|
const result = await workspace.client.createTerminal(workspace.repoPath, "title-test");
|
||||||
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
const terminalId = result.terminal.id;
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Navigate to workspace and open a terminal
|
// Navigate to workspace and open a terminal
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await clickNewTerminal(page);
|
await clickNewTerminal(page);
|
||||||
|
|
||||||
await expectTerminalSurfaceVisible(page);
|
await expectTerminalSurfaceVisible(page);
|
||||||
@@ -153,19 +131,19 @@ test.describe("Terminal title propagation", () => {
|
|||||||
// Wait for the tab to reflect the new title
|
// Wait for the tab to reflect the new title
|
||||||
await waitForTabWithTitle(page, testTitle, 15_000);
|
await waitForTabWithTitle(page, testTitle, 15_000);
|
||||||
} finally {
|
} finally {
|
||||||
await client.killTerminal(terminalId).catch(() => {});
|
await workspace.client.killTerminal(terminalId).catch(() => {});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip("title debouncing coalesces rapid changes", async ({ page }) => {
|
test.skip("title debouncing coalesces rapid changes", async ({ page }) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
const result = await client.createTerminal(tempRepo.path, "debounce-test");
|
const result = await workspace.client.createTerminal(workspace.repoPath, "debounce-test");
|
||||||
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||||
const terminalId = result.terminal.id;
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
await clickNewTerminal(page);
|
await clickNewTerminal(page);
|
||||||
|
|
||||||
await expectTerminalSurfaceVisible(page);
|
await expectTerminalSurfaceVisible(page);
|
||||||
@@ -188,7 +166,7 @@ test.describe("Terminal title propagation", () => {
|
|||||||
// The tab should eventually settle on the final title
|
// The tab should eventually settle on the final title
|
||||||
await waitForTabWithTitle(page, finalTitle, 15_000);
|
await waitForTabWithTitle(page, finalTitle, 15_000);
|
||||||
} finally {
|
} finally {
|
||||||
await client.killTerminal(terminalId).catch(() => {});
|
await workspace.client.killTerminal(terminalId).catch(() => {});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -199,7 +177,7 @@ test.describe("Terminal title propagation", () => {
|
|||||||
|
|
||||||
test.describe("Tab transitions (no flash)", () => {
|
test.describe("Tab transitions (no flash)", () => {
|
||||||
test("New agent tab transition has no blank intermediate tab state", async ({ page }) => {
|
test("New agent tab transition has no blank intermediate tab state", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
|
||||||
// Sample tabs at high frequency across the transition
|
// Sample tabs at high frequency across the transition
|
||||||
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page), 2_000, 30);
|
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page), 2_000, 30);
|
||||||
@@ -221,7 +199,7 @@ test.describe("Tab transitions (no flash)", () => {
|
|||||||
|
|
||||||
test("Terminal transition completes within visual budget", async ({ page }) => {
|
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||||
test.setTimeout(30_000);
|
test.setTimeout(30_000);
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
|
||||||
const elapsed = await measureTileTransition(
|
const elapsed = await measureTileTransition(
|
||||||
page,
|
page,
|
||||||
@@ -237,7 +215,7 @@ test.describe("Tab transitions (no flash)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("New agent tab click shows composer without flash", async ({ page }) => {
|
test("New agent tab click shows composer without flash", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
|
||||||
const elapsed = await measureTileTransition(
|
const elapsed = await measureTileTransition(
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
selectPickerOptionByKeyboard,
|
selectPickerOptionByKeyboard,
|
||||||
} from "./helpers/new-workspace";
|
} from "./helpers/new-workspace";
|
||||||
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
||||||
|
import { getServerId } from "./helpers/server-id";
|
||||||
import {
|
import {
|
||||||
expectSidebarWorkspaceSelected,
|
expectSidebarWorkspaceSelected,
|
||||||
expectWorkspaceHeader,
|
expectWorkspaceHeader,
|
||||||
@@ -61,10 +62,7 @@ test.describe("New workspace flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("sidebar workspace navigation updates URL and header", async ({ page }) => {
|
test("sidebar workspace navigation updates URL and header", async ({ page }) => {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = getServerId();
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstRepo = await createTempGitRepo("workspace-nav-a-");
|
const firstRepo = await createTempGitRepo("workspace-nav-a-");
|
||||||
const secondRepo = await createTempGitRepo("workspace-nav-b-");
|
const secondRepo = await createTempGitRepo("workspace-nav-b-");
|
||||||
@@ -118,10 +116,7 @@ test.describe("New workspace flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("same-project workspaces switch content without requiring refresh", async ({ page }) => {
|
test("same-project workspaces switch content without requiring refresh", async ({ page }) => {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = getServerId();
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const repo = await createTempGitRepo("workspace-nav-same-project-");
|
const repo = await createTempGitRepo("workspace-nav-same-project-");
|
||||||
|
|
||||||
@@ -201,10 +196,7 @@ test.describe("New workspace flow", () => {
|
|||||||
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one agent tab", async ({
|
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one agent tab", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = getServerId();
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempRepo = await createTempGitRepo("new-workspace-");
|
const tempRepo = await createTempGitRepo("new-workspace-");
|
||||||
|
|
||||||
@@ -276,10 +268,7 @@ test.describe("New workspace flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("redirects to the optimistic draft tab before agent creation resolves", async ({ page }) => {
|
test("redirects to the optimistic draft tab before agent creation resolves", async ({ page }) => {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = getServerId();
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempRepo = await createTempGitRepo("new-workspace-optimistic-");
|
const tempRepo = await createTempGitRepo("new-workspace-optimistic-");
|
||||||
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
|
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
|
||||||
@@ -355,10 +344,7 @@ test.describe("New workspace flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
|
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = getServerId();
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempRepo = await createTempGitRepo("new-workspace-ref-", {
|
const tempRepo = await createTempGitRepo("new-workspace-ref-", {
|
||||||
branches: ["main", "dev"],
|
branches: ["main", "dev"],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { gotoWorkspace } from "./helpers/launcher";
|
|||||||
import { hasGithubAuth, createTempGithubRepo, type GhRepoFixture } from "./helpers/github-fixtures";
|
import { hasGithubAuth, createTempGithubRepo, type GhRepoFixture } from "./helpers/github-fixtures";
|
||||||
import {
|
import {
|
||||||
connectWorkspaceSetupClient,
|
connectWorkspaceSetupClient,
|
||||||
|
openProjectViaDaemon,
|
||||||
type WorkspaceSetupDaemonClient,
|
type WorkspaceSetupDaemonClient,
|
||||||
} from "./helpers/workspace-setup";
|
} from "./helpers/workspace-setup";
|
||||||
|
|
||||||
@@ -50,11 +51,8 @@ test.describe("PR pane", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const pr of repoFixture.prs) {
|
for (const pr of repoFixture.prs) {
|
||||||
const result = await seedClient.openProject(pr.localPath);
|
const workspace = await openProjectViaDaemon(seedClient, pr.localPath);
|
||||||
if (!result.workspace) {
|
workspaceByTitle.set(pr.title, workspace.id);
|
||||||
throw new Error(result.error ?? `Failed to open project ${pr.localPath}`);
|
|
||||||
}
|
|
||||||
workspaceByTitle.set(pr.title, result.workspace.id);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { chmod, readFile } from "node:fs/promises";
|
import { chmod, readFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { expect, test as base } from "./fixtures";
|
import { expect, test as base } from "./fixtures";
|
||||||
import { connectNewWorkspaceDaemonClient, openProjectViaDaemon } from "./helpers/new-workspace";
|
import { seedWorkspace } from "./helpers/seed-client";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import {
|
import {
|
||||||
blockPaseoConfigWrites,
|
blockPaseoConfigWrites,
|
||||||
bumpPaseoConfigOnDisk,
|
bumpPaseoConfigOnDisk,
|
||||||
@@ -63,38 +62,36 @@ const initialPaseoConfig = {
|
|||||||
|
|
||||||
const test = base.extend<ProjectsSettingsFixtures>({
|
const test = base.extend<ProjectsSettingsFixtures>({
|
||||||
editableProject: async ({ page: _page }, provide) => {
|
editableProject: async ({ page: _page }, provide) => {
|
||||||
const client = await connectNewWorkspaceDaemonClient();
|
const workspace = await seedWorkspace({
|
||||||
const repo = await createTempGitRepo("projects-settings-", {
|
repoPrefix: "projects-settings-",
|
||||||
paseoConfig: initialPaseoConfig,
|
repo: { paseoConfig: initialPaseoConfig },
|
||||||
});
|
});
|
||||||
const openedProject = await openProjectViaDaemon(client, repo.path);
|
|
||||||
|
|
||||||
await provide({
|
await provide({
|
||||||
name: openedProject.projectDisplayName,
|
name: workspace.projectDisplayName,
|
||||||
path: repo.path,
|
path: workspace.repoPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
await client.close();
|
|
||||||
// Defensive: restore directory write permission in case the test left it blocked
|
// Defensive: restore directory write permission in case the test left it blocked
|
||||||
// (write_failed test), so that repo.cleanup() can remove files inside.
|
// (write_failed test), so that cleanup can remove files inside.
|
||||||
await chmod(repo.path, 0o755).catch(() => undefined);
|
await chmod(workspace.repoPath, 0o755).catch(() => undefined);
|
||||||
await repo.cleanup();
|
await workspace.cleanup();
|
||||||
},
|
},
|
||||||
gitlabRemoteProject: async ({ page: _page }, provide) => {
|
gitlabRemoteProject: async ({ page: _page }, provide) => {
|
||||||
const client = await connectNewWorkspaceDaemonClient();
|
const workspace = await seedWorkspace({
|
||||||
const repo = await createTempGitRepo("projects-settings-gitlab-", {
|
repoPrefix: "projects-settings-gitlab-",
|
||||||
paseoConfig: initialPaseoConfig,
|
repo: {
|
||||||
originUrl: "https://gitlab.com/acme/app.git",
|
paseoConfig: initialPaseoConfig,
|
||||||
|
originUrl: "https://gitlab.com/acme/app.git",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const openedProject = await openProjectViaDaemon(client, repo.path);
|
|
||||||
|
|
||||||
await provide({
|
await provide({
|
||||||
name: openedProject.projectDisplayName,
|
name: workspace.projectDisplayName,
|
||||||
path: repo.path,
|
path: workspace.repoPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
await client.close();
|
await workspace.cleanup();
|
||||||
await repo.cleanup();
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { expect, test, type Page } from "./fixtures";
|
import { expect, test, type Page } from "./fixtures";
|
||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||||
import { connectTerminalClient } from "./helpers/terminal-perf";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import {
|
import {
|
||||||
composerLocator,
|
composerLocator,
|
||||||
expectComposerDraft,
|
expectComposerDraft,
|
||||||
@@ -12,50 +10,24 @@ import {
|
|||||||
|
|
||||||
// UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`.
|
// UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`.
|
||||||
|
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
|
|
||||||
const agentUrl = `${buildHostWorkspaceRoute(
|
|
||||||
getServerId(),
|
|
||||||
input.cwd,
|
|
||||||
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
|
|
||||||
await page.goto(agentUrl);
|
|
||||||
await page.waitForURL(
|
|
||||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
);
|
|
||||||
await expectComposerVisible(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
|
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
|
||||||
await expect(page.getByTestId("user-message")).toHaveCount(expected);
|
await expect(page.getByTestId("user-message")).toHaveCount(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Rewind sheet", () => {
|
test.describe("Rewind sheet", () => {
|
||||||
test("rewinds from a user message sheet option", async ({ page }) => {
|
test("rewinds from a user message sheet option", async ({ page }) => {
|
||||||
const repo = await createTempGitRepo("rewind-e2e-");
|
|
||||||
const client = await connectTerminalClient();
|
|
||||||
const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn.";
|
const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn.";
|
||||||
const secondPrompt = "Prepare deleted rewind turn assistant content.";
|
const secondPrompt = "Prepare deleted rewind turn assistant content.";
|
||||||
const replacementPrompt = "emit 1 coalesced agent stream updates for replacement rewind turn.";
|
const replacementPrompt = "emit 1 coalesced agent stream updates for replacement rewind turn.";
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "rewind-e2e-",
|
||||||
|
title: "Rewind e2e",
|
||||||
|
initialPrompt: firstPrompt,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.openProject(repo.path);
|
await openAgentRoute(page, session);
|
||||||
const agent = await client.createAgent({
|
await expectComposerVisible(page);
|
||||||
provider: "mock",
|
|
||||||
cwd: repo.path,
|
|
||||||
title: "Rewind e2e",
|
|
||||||
modeId: "load-test",
|
|
||||||
model: "ten-second-stream",
|
|
||||||
initialPrompt: firstPrompt,
|
|
||||||
});
|
|
||||||
await openAgent(page, { cwd: repo.path, agentId: agent.id });
|
|
||||||
|
|
||||||
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
|
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
|
||||||
await expectUserMessageCount(page, 1);
|
await expectUserMessageCount(page, 1);
|
||||||
@@ -116,31 +88,25 @@ test.describe("Rewind sheet", () => {
|
|||||||
await expectComposerDraft(page, replacementPrompt);
|
await expectComposerDraft(page, replacementPrompt);
|
||||||
await expectUserMessageCount(page, 1);
|
await expectUserMessageCount(page, 1);
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await session.cleanup();
|
||||||
await repo.cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("surfaces rewind failures without crashing the page", async ({ page }) => {
|
test("surfaces rewind failures without crashing the page", async ({ page }) => {
|
||||||
const repo = await createTempGitRepo("rewind-failure-e2e-");
|
|
||||||
const client = await connectTerminalClient();
|
|
||||||
const firstPrompt = "emit 1 coalesced agent stream updates for failed rewind turn.";
|
const firstPrompt = "emit 1 coalesced agent stream updates for failed rewind turn.";
|
||||||
const rewindError = "No file checkpoint found for message rewind-failure-e2e.";
|
const rewindError = "No file checkpoint found for message rewind-failure-e2e.";
|
||||||
|
const session = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "rewind-failure-e2e-",
|
||||||
|
title: "Rewind failure e2e",
|
||||||
|
initialPrompt: firstPrompt,
|
||||||
|
featureValues: {
|
||||||
|
mockRewindError: rewindError,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.openProject(repo.path);
|
await openAgentRoute(page, session);
|
||||||
const agent = await client.createAgent({
|
await expectComposerVisible(page);
|
||||||
provider: "mock",
|
|
||||||
cwd: repo.path,
|
|
||||||
title: "Rewind failure e2e",
|
|
||||||
modeId: "load-test",
|
|
||||||
model: "ten-second-stream",
|
|
||||||
featureValues: {
|
|
||||||
mockRewindError: rewindError,
|
|
||||||
},
|
|
||||||
initialPrompt: firstPrompt,
|
|
||||||
});
|
|
||||||
await openAgent(page, { cwd: repo.path, agentId: agent.id });
|
|
||||||
|
|
||||||
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
|
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
|
||||||
|
|
||||||
@@ -160,8 +126,7 @@ test.describe("Rewind sheet", () => {
|
|||||||
await page.getByTestId("rewind-menu-trigger").first().click();
|
await page.getByTestId("rewind-menu-trigger").first().click();
|
||||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await session.cleanup();
|
||||||
await repo.cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { test } from "./fixtures";
|
import { test } from "./fixtures";
|
||||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||||
|
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||||
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
|
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
|
||||||
|
import { getServerId } from "./helpers/server-id";
|
||||||
import {
|
import {
|
||||||
expectSettingsHeader,
|
expectSettingsHeader,
|
||||||
openSettingsHost,
|
openSettingsHost,
|
||||||
@@ -16,28 +18,12 @@ import {
|
|||||||
expectLocalHostEntryFirst,
|
expectLocalHostEntryFirst,
|
||||||
} from "./helpers/settings";
|
} from "./helpers/settings";
|
||||||
|
|
||||||
function getSeededServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSeededDaemonPort(): string {
|
|
||||||
const port = process.env.E2E_DAEMON_PORT;
|
|
||||||
if (!port) {
|
|
||||||
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
|
|
||||||
}
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Settings host page", () => {
|
test.describe("Settings host page", () => {
|
||||||
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
|
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const serverId = getSeededServerId();
|
const serverId = getServerId();
|
||||||
const port = getSeededDaemonPort();
|
const port = getE2EDaemonPort();
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
@@ -51,7 +37,7 @@ test.describe("Settings host page", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
|
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
|
||||||
const serverId = getSeededServerId();
|
const serverId = getServerId();
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
@@ -65,7 +51,7 @@ test.describe("Settings host page", () => {
|
|||||||
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
|
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const serverId = getSeededServerId();
|
const serverId = getServerId();
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await openSettings(page);
|
await openSettings(page);
|
||||||
@@ -85,7 +71,7 @@ test.describe("Settings host page", () => {
|
|||||||
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
|
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const serverId = getSeededServerId();
|
const serverId = getServerId();
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
|
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
|
||||||
@@ -97,7 +83,7 @@ test.describe("Settings host page", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
|
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
|
||||||
const serverId = getSeededServerId();
|
const serverId = getServerId();
|
||||||
|
|
||||||
// Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the
|
// Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the
|
||||||
// seeded host to the local daemon. `manageBuiltInDaemon: false` (returned
|
// seeded host to the local daemon. `manageBuiltInDaemon: false` (returned
|
||||||
|
|||||||
@@ -1,21 +1,9 @@
|
|||||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
import { expect, test } from "./fixtures";
|
import { expect, test } from "./fixtures";
|
||||||
import {
|
import { createIdleAgent, openWorkspaceWithAgents } from "./helpers/archive-tab";
|
||||||
archiveAgentFromDaemon,
|
|
||||||
connectArchiveTabDaemonClient,
|
|
||||||
createIdleAgent,
|
|
||||||
openWorkspaceWithAgents,
|
|
||||||
} from "./helpers/archive-tab";
|
|
||||||
import { waitForTabBar, expectAgentTabActive } from "./helpers/launcher";
|
import { waitForTabBar, expectAgentTabActive } from "./helpers/launcher";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { seedWorkspace } from "./helpers/seed-client";
|
||||||
|
import { getServerId } from "./helpers/server-id";
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pressSettingsToggleShortcut(page: import("@playwright/test").Page) {
|
async function pressSettingsToggleShortcut(page: import("@playwright/test").Page) {
|
||||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||||
@@ -61,20 +49,17 @@ test.describe("Settings toggle tab regression", () => {
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
const client = await connectArchiveTabDaemonClient();
|
const workspace = await seedWorkspace({ repoPrefix: "settings-toggle-tab-" });
|
||||||
const repo = await createTempGitRepo("settings-toggle-tab-");
|
|
||||||
const agentIds: string[] = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const firstAgent = await createIdleAgent(client, {
|
const firstAgent = await createIdleAgent(workspace.client, {
|
||||||
cwd: repo.path,
|
cwd: workspace.repoPath,
|
||||||
title: `settings-toggle-a-${Date.now()}`,
|
title: `settings-toggle-a-${Date.now()}`,
|
||||||
});
|
});
|
||||||
const secondAgent = await createIdleAgent(client, {
|
const secondAgent = await createIdleAgent(workspace.client, {
|
||||||
cwd: repo.path,
|
cwd: workspace.repoPath,
|
||||||
title: `settings-toggle-b-${Date.now()}`,
|
title: `settings-toggle-b-${Date.now()}`,
|
||||||
});
|
});
|
||||||
agentIds.push(firstAgent.id, secondAgent.id);
|
|
||||||
|
|
||||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||||
await waitForTabBar(page);
|
await waitForTabBar(page);
|
||||||
@@ -89,7 +74,7 @@ test.describe("Settings toggle tab regression", () => {
|
|||||||
await expectSendBehavior(page, "interrupt");
|
await expectSendBehavior(page, "interrupt");
|
||||||
|
|
||||||
await pressSettingsToggleShortcut(page);
|
await pressSettingsToggleShortcut(page);
|
||||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path));
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, workspace.repoPath));
|
||||||
await waitForTabBar(page);
|
await waitForTabBar(page);
|
||||||
await expectAgentTabActive(page, secondAgent.id);
|
await expectAgentTabActive(page, secondAgent.id);
|
||||||
|
|
||||||
@@ -97,11 +82,7 @@ test.describe("Settings toggle tab regression", () => {
|
|||||||
await waitForTabBar(page);
|
await waitForTabBar(page);
|
||||||
await expectAgentTabActive(page, secondAgent.id);
|
await expectAgentTabActive(page, secondAgent.id);
|
||||||
} finally {
|
} finally {
|
||||||
for (const agentId of agentIds) {
|
await workspace.cleanup();
|
||||||
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
|
||||||
}
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,31 +90,28 @@ test.describe("Settings toggle tab regression", () => {
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
const client = await connectArchiveTabDaemonClient();
|
const workspace = await seedWorkspace({ repoPrefix: "agent-route-refresh-" });
|
||||||
const repo = await createTempGitRepo("agent-route-refresh-");
|
|
||||||
const agentIds: string[] = [];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const firstAgent = await createIdleAgent(client, {
|
const firstAgent = await createIdleAgent(workspace.client, {
|
||||||
cwd: repo.path,
|
cwd: workspace.repoPath,
|
||||||
title: `agent-route-refresh-a-${Date.now()}`,
|
title: `agent-route-refresh-a-${Date.now()}`,
|
||||||
});
|
});
|
||||||
const secondAgent = await createIdleAgent(client, {
|
const secondAgent = await createIdleAgent(workspace.client, {
|
||||||
cwd: repo.path,
|
cwd: workspace.repoPath,
|
||||||
title: `agent-route-refresh-b-${Date.now()}`,
|
title: `agent-route-refresh-b-${Date.now()}`,
|
||||||
});
|
});
|
||||||
agentIds.push(firstAgent.id, secondAgent.id);
|
|
||||||
|
|
||||||
await openAgentRouteAndExpectFocused({
|
await openAgentRouteAndExpectFocused({
|
||||||
page,
|
page,
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: repo.path,
|
workspaceId: workspace.repoPath,
|
||||||
agentId: firstAgent.id,
|
agentId: firstAgent.id,
|
||||||
});
|
});
|
||||||
await openAgentRouteAndExpectFocused({
|
await openAgentRouteAndExpectFocused({
|
||||||
page,
|
page,
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: repo.path,
|
workspaceId: workspace.repoPath,
|
||||||
agentId: secondAgent.id,
|
agentId: secondAgent.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,11 +121,7 @@ test.describe("Settings toggle tab regression", () => {
|
|||||||
await expectAgentTabActive(page, secondAgent.id);
|
await expectAgentTabActive(page, secondAgent.id);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
for (const agentId of agentIds) {
|
await workspace.cleanup();
|
||||||
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
|
||||||
}
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { test, expect, type Page } from "./fixtures";
|
import { test, expect, type Page } from "./fixtures";
|
||||||
import { gotoAppShell } from "./helpers/app";
|
import { gotoAppShell } from "./helpers/app";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { seedWorkspace } from "./helpers/seed-client";
|
||||||
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
|
|
||||||
import { captureWsSessionFrames } from "./helpers/rename";
|
import { captureWsSessionFrames } from "./helpers/rename";
|
||||||
|
import { getServerId } from "./helpers/server-id";
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function workspaceRowTestId(workspaceId: string): string {
|
function workspaceRowTestId(workspaceId: string): string {
|
||||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||||
@@ -21,21 +13,6 @@ function workspaceRenameModalTestId(workspaceId: string, suffix: string): string
|
|||||||
return `sidebar-workspace-rename-modal-${getServerId()}:${workspaceId}-${suffix}`;
|
return `sidebar-workspace-rename-modal-${getServerId()}:${workspaceId}-${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openProjectViaDaemon(
|
|
||||||
client: Awaited<ReturnType<typeof connectWorkspaceSetupClient>>,
|
|
||||||
cwd: string,
|
|
||||||
): Promise<{ id: string; name: string; workspaceDirectory: string }> {
|
|
||||||
const result = await client.openProject(cwd);
|
|
||||||
if (!result.workspace || result.error) {
|
|
||||||
throw new Error(result.error ?? `Failed to open project ${cwd}`);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: String(result.workspace.id),
|
|
||||||
name: result.workspace.name,
|
|
||||||
workspaceDirectory: result.workspace.workspaceDirectory,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openRenameModal(page: Page, workspaceId: string) {
|
async function openRenameModal(page: Page, workspaceId: string) {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
|
const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`);
|
||||||
@@ -59,12 +36,10 @@ test.describe("Sidebar workspace rename", () => {
|
|||||||
test("renaming via kebab updates the branch name on disk and in the sidebar", async ({
|
test("renaming via kebab updates the branch name on disk and in the sidebar", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-" });
|
||||||
const repo = await createTempGitRepo("sidebar-rename-");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
expect(workspace.workspaceName).toBe("main");
|
||||||
expect(workspace.name).toBe("main");
|
|
||||||
|
|
||||||
const renameRequests = captureWsSessionFrames(
|
const renameRequests = captureWsSessionFrames(
|
||||||
page,
|
page,
|
||||||
@@ -76,18 +51,18 @@ test.describe("Sidebar workspace rename", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toBeVisible({
|
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const input = await openRenameModal(page, workspace.id);
|
const input = await openRenameModal(page, workspace.workspaceId);
|
||||||
await expect(input).toHaveValue("main");
|
await expect(input).toHaveValue("main");
|
||||||
await input.fill("Feature Rename 2");
|
await input.fill("Feature Rename 2");
|
||||||
|
|
||||||
await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click();
|
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
|
||||||
|
|
||||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||||
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText(
|
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText(
|
||||||
"feature-rename-2",
|
"feature-rename-2",
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
);
|
);
|
||||||
@@ -99,40 +74,42 @@ test.describe("Sidebar workspace rename", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const currentBranchOnDisk = execSync("git branch --show-current", {
|
const currentBranchOnDisk = execSync("git branch --show-current", {
|
||||||
cwd: repo.path,
|
cwd: workspace.repoPath,
|
||||||
stdio: "pipe",
|
stdio: "pipe",
|
||||||
})
|
})
|
||||||
.toString()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
expect(currentBranchOnDisk).toBe("feature-rename-2");
|
expect(currentBranchOnDisk).toBe("feature-rename-2");
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await workspace.cleanup();
|
||||||
await repo.cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rename surfaces server errors inline and keeps the modal open", async ({ page }) => {
|
test("rename surfaces server errors inline and keeps the modal open", async ({ page }) => {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const workspace = await seedWorkspace({
|
||||||
const repo = await createTempGitRepo("sidebar-rename-error-", { branches: ["taken"] });
|
repoPrefix: "sidebar-rename-error-",
|
||||||
|
repo: { branches: ["taken"] },
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
const input = await openRenameModal(page, workspace.id);
|
const input = await openRenameModal(page, workspace.workspaceId);
|
||||||
await expect(input).toHaveValue("main");
|
await expect(input).toHaveValue("main");
|
||||||
|
|
||||||
await input.fill("taken");
|
await input.fill("taken");
|
||||||
await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click();
|
await page.getByTestId(workspaceRenameModalTestId(workspace.workspaceId, "submit")).click();
|
||||||
|
|
||||||
const errorNode = page.getByTestId(workspaceRenameModalTestId(workspace.id, "error"));
|
const errorNode = page.getByTestId(
|
||||||
|
workspaceRenameModalTestId(workspace.workspaceId, "error"),
|
||||||
|
);
|
||||||
await expect(errorNode).toBeVisible({ timeout: 15_000 });
|
await expect(errorNode).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(errorNode).toContainText(/already exists|branch/i);
|
await expect(errorNode).toContainText(/already exists|branch/i);
|
||||||
await expect(input).toBeVisible();
|
await expect(input).toBeVisible();
|
||||||
await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText("main");
|
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toContainText(
|
||||||
|
"main",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await workspace.cleanup();
|
||||||
await repo.cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import { execSync } from "node:child_process";
|
|
||||||
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { test, expect } from "./fixtures";
|
import { test, expect } from "./fixtures";
|
||||||
import { gotoAppShell } from "./helpers/app";
|
import { gotoAppShell } from "./helpers/app";
|
||||||
@@ -10,59 +7,17 @@ import {
|
|||||||
expectMobileAgentSidebarVisible,
|
expectMobileAgentSidebarVisible,
|
||||||
openMobileAgentSidebar,
|
openMobileAgentSidebar,
|
||||||
} from "./helpers/sidebar";
|
} from "./helpers/sidebar";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { seedWorkspace } from "./helpers/seed-client";
|
||||||
import { expectWorkspaceHeader } from "./helpers/workspace-ui";
|
import { expectWorkspaceHeader } from "./helpers/workspace-ui";
|
||||||
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
|
import { getServerId } from "./helpers/server-id";
|
||||||
|
import { escapeRegex } from "./helpers/regex";
|
||||||
|
|
||||||
function getServerId(): string {
|
const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git";
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWorkspaceRowTestId(workspaceId: string): string {
|
function getWorkspaceRowTestId(workspaceId: string): string {
|
||||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
function setGitHubRemote(repoPath: string): void {
|
|
||||||
execSync("git remote set-url origin https://github.com/test-owner/test-repo.git", {
|
|
||||||
cwd: repoPath,
|
|
||||||
stdio: "ignore",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createTempDirectory(prefix = "paseo-e2e-dir-") {
|
|
||||||
const tempRoot = process.platform === "win32" ? tmpdir() : await realpath("/tmp");
|
|
||||||
const dirPath = await mkdtemp(path.join(tempRoot, prefix));
|
|
||||||
await writeFile(path.join(dirPath, "README.md"), "# Temp Directory\n");
|
|
||||||
return {
|
|
||||||
path: dirPath,
|
|
||||||
cleanup: async () => {
|
|
||||||
await rm(dirPath, { recursive: true, force: true });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openProjectViaDaemon(
|
|
||||||
client: Awaited<ReturnType<typeof connectWorkspaceSetupClient>>,
|
|
||||||
cwd: string,
|
|
||||||
): Promise<{ id: string; name: string }> {
|
|
||||||
const result = await client.openProject(cwd);
|
|
||||||
if (!result.workspace || result.error) {
|
|
||||||
throw new Error(result.error ?? `Failed to open project ${cwd}`);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: result.workspace.id,
|
|
||||||
name: result.workspace.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openWorkspaceFromSidebar(
|
async function openWorkspaceFromSidebar(
|
||||||
page: import("@playwright/test").Page,
|
page: import("@playwright/test").Page,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
@@ -92,15 +47,15 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo
|
|||||||
|
|
||||||
test.describe("Sidebar workspace list", () => {
|
test.describe("Sidebar workspace list", () => {
|
||||||
test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => {
|
test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const workspace = await seedWorkspace({
|
||||||
const repo = await createTempGitRepo("sidebar-remote-", { withRemote: true });
|
repoPrefix: "sidebar-remote-",
|
||||||
|
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setGitHubRemote(repo.path);
|
|
||||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await waitForSidebarProject(page, "test-owner/test-repo");
|
await waitForSidebarProject(page, "test-owner/test-repo");
|
||||||
await waitForSidebarWorkspace(page, workspace.id);
|
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||||
|
|
||||||
const projectRow = page
|
const projectRow = page
|
||||||
.locator('[data-testid^="sidebar-project-row-"]')
|
.locator('[data-testid^="sidebar-project-row-"]')
|
||||||
@@ -108,81 +63,73 @@ test.describe("Sidebar workspace list", () => {
|
|||||||
.first();
|
.first();
|
||||||
|
|
||||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(projectRow).not.toContainText(path.basename(repo.path));
|
await expect(projectRow).not.toContainText(path.basename(workspace.repoPath));
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await workspace.cleanup();
|
||||||
await repo.cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("project shows workspace under it", async ({ page }) => {
|
test("project shows workspace under it", async ({ page }) => {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-workspace-under-project-" });
|
||||||
const repo = await createTempGitRepo("sidebar-workspace-under-project-");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
|
|
||||||
await waitForSidebarProject(page, path.basename(repo.path));
|
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||||
await waitForSidebarWorkspace(page, workspace.id);
|
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await workspace.cleanup();
|
||||||
await repo.cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("non-git project shows directory name", async ({ page }) => {
|
test("non-git project shows directory name", async ({ page }) => {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-directory-", git: false });
|
||||||
const project = await createTempDirectory("sidebar-directory-");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await openProjectViaDaemon(client, project.path);
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
|
|
||||||
const projectRow = await waitForSidebarProject(page, path.basename(project.path));
|
const directoryName = path.basename(workspace.repoPath);
|
||||||
await expect(projectRow).toContainText(path.basename(project.path));
|
const projectRow = await waitForSidebarProject(page, directoryName);
|
||||||
|
await expect(projectRow).toContainText(directoryName);
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await workspace.cleanup();
|
||||||
await project.cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("workspace header shows correct title and subtitle", async ({ page }) => {
|
test("workspace header shows correct title and subtitle", async ({ page }) => {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const workspace = await seedWorkspace({
|
||||||
const repo = await createTempGitRepo("sidebar-header-", { withRemote: true });
|
repoPrefix: "sidebar-header-",
|
||||||
|
repo: { withRemote: true, originUrl: GITHUB_REMOTE_URL },
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setGitHubRemote(repo.path);
|
|
||||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await waitForSidebarProject(page, "test-owner/test-repo");
|
await waitForSidebarProject(page, "test-owner/test-repo");
|
||||||
await waitForSidebarWorkspace(page, workspace.id);
|
await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||||
await openWorkspaceFromSidebar(page, workspace.id);
|
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||||
|
|
||||||
await expectWorkspaceHeader(page, {
|
await expectWorkspaceHeader(page, {
|
||||||
title: workspace.name,
|
title: workspace.workspaceName,
|
||||||
subtitle: "test-owner/test-repo",
|
subtitle: "test-owner/test-repo",
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await workspace.cleanup();
|
||||||
await repo.cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("git project shows branch name in workspace row", async ({ page }) => {
|
test("git project shows branch name in workspace row", async ({ page }) => {
|
||||||
const client = await connectWorkspaceSetupClient();
|
const workspace = await seedWorkspace({ repoPrefix: "sidebar-branch-" });
|
||||||
const repo = await createTempGitRepo("sidebar-branch-");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await waitForSidebarProject(page, path.basename(repo.path));
|
await waitForSidebarProject(page, path.basename(workspace.repoPath));
|
||||||
|
|
||||||
expect(workspace.name).toBe("main");
|
expect(workspace.workspaceName).toBe("main");
|
||||||
await expect(await waitForSidebarWorkspace(page, workspace.id)).toContainText("main");
|
await expect(await waitForSidebarWorkspace(page, workspace.workspaceId)).toContainText(
|
||||||
|
"main",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await workspace.cleanup();
|
||||||
await repo.cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,8 @@
|
|||||||
import { test } from "./fixtures";
|
import { test } from "./fixtures";
|
||||||
|
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||||
|
import { getServerId } from "./helpers/server-id";
|
||||||
import { startupScenario } from "./helpers/startup-dsl";
|
import { startupScenario } from "./helpers/startup-dsl";
|
||||||
|
|
||||||
function getE2EDaemonPort(): string {
|
|
||||||
const port = process.env.E2E_DAEMON_PORT;
|
|
||||||
if (!port) {
|
|
||||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
|
||||||
}
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getE2EServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Startup loading presentation", () => {
|
test.describe("Startup loading presentation", () => {
|
||||||
test("mobile reconnect keeps connection recovery actions visible", async ({ page }) => {
|
test("mobile reconnect keeps connection recovery actions visible", async ({ page }) => {
|
||||||
const startup = await startupScenario(page)
|
const startup = await startupScenario(page)
|
||||||
@@ -49,7 +35,7 @@ test.describe("Startup loading presentation", () => {
|
|||||||
test("host-route refresh does not render route chrome around the bootstrap splash", async ({
|
test("host-route refresh does not render route chrome around the bootstrap splash", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const serverId = getE2EServerId();
|
const serverId = getServerId();
|
||||||
const startup = await startupScenario(page)
|
const startup = await startupScenario(page)
|
||||||
.withPendingDesktopDaemon()
|
.withPendingDesktopDaemon()
|
||||||
.withBlockedPort(getE2EDaemonPort())
|
.withBlockedPort(getE2EDaemonPort())
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { test, expect } from "./fixtures";
|
import { test, expect } from "./fixtures";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { TerminalE2EHarness } from "./helpers/terminal-dsl";
|
||||||
import {
|
import {
|
||||||
connectTerminalClient,
|
|
||||||
navigateToTerminal,
|
|
||||||
setupDeterministicPrompt,
|
|
||||||
waitForTerminalContent,
|
waitForTerminalContent,
|
||||||
measureKeystrokeLatency,
|
measureKeystrokeLatency,
|
||||||
computePercentile,
|
computePercentile,
|
||||||
round2,
|
round2,
|
||||||
type TerminalPerfDaemonClient,
|
|
||||||
type LatencySample,
|
type LatencySample,
|
||||||
} from "./helpers/terminal-perf";
|
} from "./helpers/terminal-perf";
|
||||||
|
|
||||||
@@ -20,43 +16,26 @@ const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1";
|
|||||||
const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip;
|
const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip;
|
||||||
|
|
||||||
terminalPerfDescribe("Terminal wire performance", () => {
|
terminalPerfDescribe("Terminal wire performance", () => {
|
||||||
let client: TerminalPerfDaemonClient;
|
let harness: TerminalE2EHarness;
|
||||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
|
||||||
let workspaceId: string;
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
tempRepo = await createTempGitRepo("perf-");
|
harness = await TerminalE2EHarness.create({ tempPrefix: "perf-" });
|
||||||
client = await connectTerminalClient();
|
|
||||||
// Seed the workspace in the daemon so the app can resolve the path
|
|
||||||
const seedResult = await client.openProject(tempRepo.path);
|
|
||||||
if (!seedResult.workspace) throw new Error(seedResult.error ?? "Failed to seed workspace");
|
|
||||||
workspaceId = seedResult.workspace.id;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test.afterAll(async () => {
|
||||||
if (client) {
|
await harness?.cleanup();
|
||||||
await client.close();
|
|
||||||
}
|
|
||||||
if (tempRepo) {
|
|
||||||
await tempRepo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("throughput: bulk terminal output renders within budget", async ({ page }, testInfo) => {
|
test("throughput: bulk terminal output renders within budget", async ({ page }, testInfo) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
const result = await client.createTerminal(tempRepo.path, "throughput");
|
const created = await harness.createTerminal({ name: "throughput" });
|
||||||
if (!result.terminal) {
|
|
||||||
throw new Error(`Failed to create terminal: ${result.error}`);
|
|
||||||
}
|
|
||||||
const terminalId = result.terminal.id;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigateToTerminal(page, { workspaceId, terminalId });
|
await harness.openTerminal(page, { terminalId: created.id });
|
||||||
await setupDeterministicPrompt(page);
|
await harness.setupPrompt(page);
|
||||||
|
|
||||||
const sentinel = `PERF_DONE_${Date.now()}`;
|
const sentinel = `PERF_DONE_${Date.now()}`;
|
||||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
const terminal = harness.terminalSurface(page);
|
||||||
const startMs = Date.now();
|
const startMs = Date.now();
|
||||||
|
|
||||||
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
|
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
|
||||||
@@ -97,25 +76,20 @@ terminalPerfDescribe("Terminal wire performance", () => {
|
|||||||
`${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`,
|
`${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`,
|
||||||
).toBeLessThan(THROUGHPUT_BUDGET_MS);
|
).toBeLessThan(THROUGHPUT_BUDGET_MS);
|
||||||
} finally {
|
} finally {
|
||||||
await client.killTerminal(terminalId).catch(() => {});
|
await harness.killTerminal(created.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keystroke latency: echo round-trip under budget", async ({ page }, testInfo) => {
|
test("keystroke latency: echo round-trip under budget", async ({ page }, testInfo) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
const result = await client.createTerminal(tempRepo.path, "latency");
|
const created = await harness.createTerminal({ name: "latency" });
|
||||||
if (!result.terminal) {
|
|
||||||
throw new Error(`Failed to create terminal: ${result.error}`);
|
|
||||||
}
|
|
||||||
const terminalId = result.terminal.id;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigateToTerminal(page, { workspaceId, terminalId });
|
await harness.openTerminal(page, { terminalId: created.id });
|
||||||
await setupDeterministicPrompt(page);
|
await harness.setupPrompt(page);
|
||||||
|
|
||||||
// Ensure clean prompt state
|
// Ensure clean prompt state
|
||||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
const terminal = harness.terminalSurface(page);
|
||||||
await terminal.press("Control+c");
|
await terminal.press("Control+c");
|
||||||
await page.waitForTimeout(200);
|
await page.waitForTimeout(200);
|
||||||
|
|
||||||
@@ -166,7 +140,7 @@ terminalPerfDescribe("Terminal wire performance", () => {
|
|||||||
`Keystroke p95 latency should be under ${KEYSTROKE_P95_BUDGET_MS}ms`,
|
`Keystroke p95 latency should be under ${KEYSTROKE_P95_BUDGET_MS}ms`,
|
||||||
).toBeLessThan(KEYSTROKE_P95_BUDGET_MS);
|
).toBeLessThan(KEYSTROKE_P95_BUDGET_MS);
|
||||||
} finally {
|
} finally {
|
||||||
await client.killTerminal(terminalId).catch(() => {});
|
await harness.killTerminal(created.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { expect, test, type Page } from "./fixtures";
|
import { expect, test, type Page } from "./fixtures";
|
||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||||
import { connectTerminalClient } from "./helpers/terminal-perf";
|
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import {
|
import {
|
||||||
composerLocator,
|
composerLocator,
|
||||||
expectComposerEditable,
|
expectComposerEditable,
|
||||||
@@ -10,27 +8,6 @@ import {
|
|||||||
submitMessage,
|
submitMessage,
|
||||||
} from "./helpers/composer";
|
} from "./helpers/composer";
|
||||||
|
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
|
|
||||||
const agentUrl = `${buildHostWorkspaceRoute(
|
|
||||||
getServerId(),
|
|
||||||
input.cwd,
|
|
||||||
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
|
|
||||||
await page.goto(agentUrl);
|
|
||||||
await page.waitForURL(
|
|
||||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
);
|
|
||||||
await expectComposerVisible(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
|
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
|
||||||
await expect(page.getByTestId("user-message")).toHaveCount(expected, { timeout: 15_000 });
|
await expect(page.getByTestId("user-message")).toHaveCount(expected, { timeout: 15_000 });
|
||||||
}
|
}
|
||||||
@@ -50,8 +27,10 @@ async function expectNoLoadingRegressionAfterIdle(page: Page): Promise<void> {
|
|||||||
|
|
||||||
test.describe("User message UI contract", () => {
|
test.describe("User message UI contract", () => {
|
||||||
test("dedupes mock provider user_message echoes across multi-turn sends", async ({ page }) => {
|
test("dedupes mock provider user_message echoes across multi-turn sends", async ({ page }) => {
|
||||||
const repo = await createTempGitRepo("user-message-contract-e2e-");
|
const session = await seedMockAgentWorkspace({
|
||||||
const client = await connectTerminalClient();
|
repoPrefix: "user-message-contract-e2e-",
|
||||||
|
title: "User message contract e2e",
|
||||||
|
});
|
||||||
const prompts = [
|
const prompts = [
|
||||||
"emit 1 coalesced agent stream updates for user message contract turn one.",
|
"emit 1 coalesced agent stream updates for user message contract turn one.",
|
||||||
"emit 1 coalesced agent stream updates for user message contract turn two.",
|
"emit 1 coalesced agent stream updates for user message contract turn two.",
|
||||||
@@ -59,15 +38,8 @@ test.describe("User message UI contract", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.openProject(repo.path);
|
await openAgentRoute(page, session);
|
||||||
const agent = await client.createAgent({
|
await expectComposerVisible(page);
|
||||||
provider: "mock",
|
|
||||||
cwd: repo.path,
|
|
||||||
title: "User message contract e2e",
|
|
||||||
modeId: "load-test",
|
|
||||||
model: "ten-second-stream",
|
|
||||||
});
|
|
||||||
await openAgent(page, { cwd: repo.path, agentId: agent.id });
|
|
||||||
|
|
||||||
for (let index = 0; index < prompts.length; index += 1) {
|
for (let index = 0; index < prompts.length; index += 1) {
|
||||||
const prompt = prompts[index]!;
|
const prompt = prompts[index]!;
|
||||||
@@ -85,8 +57,7 @@ test.describe("User message UI contract", () => {
|
|||||||
await expectUserMessageCount(page, 3);
|
await expectUserMessageCount(page, 3);
|
||||||
await expectIdleComposer(page);
|
await expectIdleComposer(page);
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await session.cleanup();
|
||||||
await repo.cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { test, expect, type Page } from "./fixtures";
|
import { test, expect, type Page } from "./fixtures";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { seedWorkspace } from "./helpers/seed-client";
|
||||||
import {
|
import { createIdleAgent, expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||||
connectArchiveTabDaemonClient,
|
|
||||||
createIdleAgent,
|
|
||||||
expectWorkspaceTabVisible,
|
|
||||||
} from "./helpers/archive-tab";
|
|
||||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||||
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
|
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||||
|
import { getServerId } from "./helpers/server-id";
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openAgentInWorkspace(page: Page, agent: { id: string; cwd: string }) {
|
async function openAgentInWorkspace(page: Page, agent: { id: string; cwd: string }) {
|
||||||
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.cwd));
|
await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.cwd));
|
||||||
@@ -34,13 +23,12 @@ test.describe("Workspace agent tab rename", () => {
|
|||||||
}) => {
|
}) => {
|
||||||
test.setTimeout(120_000);
|
test.setTimeout(120_000);
|
||||||
|
|
||||||
const client = await connectArchiveTabDaemonClient();
|
const workspace = await seedWorkspace({ repoPrefix: "workspace-agent-rename-" });
|
||||||
const repo = await createTempGitRepo("workspace-agent-rename-");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const initialTitle = `agent-rename-${randomUUID().slice(0, 8)}`;
|
const initialTitle = `agent-rename-${randomUUID().slice(0, 8)}`;
|
||||||
const agent = await createIdleAgent(client, {
|
const agent = await createIdleAgent(workspace.client, {
|
||||||
cwd: repo.path,
|
cwd: workspace.repoPath,
|
||||||
title: initialTitle,
|
title: initialTitle,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,8 +69,7 @@ test.describe("Workspace agent tab rename", () => {
|
|||||||
expect(lastFrame.name).toBe(renamed);
|
expect(lastFrame.name).toBe(renamed);
|
||||||
expect(lastFrame.requestId.length).toBeGreaterThan(0);
|
expect(lastFrame.requestId.length).toBeGreaterThan(0);
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await workspace.cleanup();
|
||||||
await repo.cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { test, expect, type Page } from "./fixtures";
|
import { test, expect, type Page } from "./fixtures";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
|
||||||
import { expectComposerVisible, submitMessage } from "./helpers/composer";
|
import { expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
|
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
||||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||||
import { captureWsSessionFrames } from "./helpers/rename";
|
import { captureWsSessionFrames } from "./helpers/rename";
|
||||||
|
import { getServerId } from "./helpers/server-id";
|
||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
interface WorkspaceTabProbeRecord {
|
interface WorkspaceTabProbeRecord {
|
||||||
@@ -20,14 +20,6 @@ interface CapturedCreateAgentFrame {
|
|||||||
configTitle: string | null;
|
configTitle: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getServerId(): string {
|
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function installWorkspaceTabProbe(page: Page): Promise<void> {
|
async function installWorkspaceTabProbe(page: Page): Promise<void> {
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
type ProbeRecord = WorkspaceTabProbeRecord;
|
type ProbeRecord = WorkspaceTabProbeRecord;
|
||||||
@@ -122,10 +114,7 @@ function recordHasLoadingTitle(record: WorkspaceTabProbeRecord): boolean {
|
|||||||
return record.tabs.some(tabHasLoadingTitle);
|
return record.tabs.some(tabHasLoadingTitle);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForCreatedAgentId(
|
async function waitForCreatedAgentId(client: SeedDaemonClient, cwd: string): Promise<string> {
|
||||||
client: TerminalPerfDaemonClient,
|
|
||||||
cwd: string,
|
|
||||||
): Promise<string> {
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
@@ -146,7 +135,7 @@ async function waitForCreatedAgentId(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchActiveAgentTitle(
|
async function fetchActiveAgentTitle(
|
||||||
client: TerminalPerfDaemonClient,
|
client: SeedDaemonClient,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const result = await client.fetchAgents({ scope: "active" });
|
const result = await client.fetchAgents({ scope: "active" });
|
||||||
@@ -179,15 +168,9 @@ test.describe("Workspace agent title handoff", () => {
|
|||||||
test.setTimeout(120_000);
|
test.setTimeout(120_000);
|
||||||
await page.setViewportSize({ width: 1440, height: 900 });
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
|
||||||
const client = await connectTerminalClient();
|
const workspace = await seedWorkspace({ repoPrefix: "workspace-title-handoff-" });
|
||||||
const repo = await createTempGitRepo("workspace-title-handoff-");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const opened = await client.openProject(repo.path);
|
|
||||||
if (!opened.workspace) {
|
|
||||||
throw new Error(opened.error ?? "Failed to open test workspace");
|
|
||||||
}
|
|
||||||
|
|
||||||
const createFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => {
|
const createFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => {
|
||||||
const config = (inner.config ?? {}) as Record<string, unknown>;
|
const config = (inner.config ?? {}) as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
@@ -196,7 +179,7 @@ test.describe("Workspace agent title handoff", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto(buildHostWorkspaceRoute(getServerId(), opened.workspace.id));
|
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||||
await waitForWorkspaceTabsVisible(page);
|
await waitForWorkspaceTabsVisible(page);
|
||||||
await page.getByTestId("workspace-new-agent-tab").click();
|
await page.getByTestId("workspace-new-agent-tab").click();
|
||||||
await expectComposerVisible(page);
|
await expectComposerVisible(page);
|
||||||
@@ -206,7 +189,7 @@ test.describe("Workspace agent title handoff", () => {
|
|||||||
await installWorkspaceTabProbe(page);
|
await installWorkspaceTabProbe(page);
|
||||||
await submitMessage(page, `${promptTitle}\n\nMake the UI state deterministic.`);
|
await submitMessage(page, `${promptTitle}\n\nMake the UI state deterministic.`);
|
||||||
|
|
||||||
const agentId = await waitForCreatedAgentId(client, repo.path);
|
const agentId = await waitForCreatedAgentId(workspace.client, workspace.repoPath);
|
||||||
await expect
|
await expect
|
||||||
.poll(() => countCreateFramesForPrompt(createFrames, promptTitle), {
|
.poll(() => countCreateFramesForPrompt(createFrames, promptTitle), {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
@@ -219,9 +202,9 @@ test.describe("Workspace agent title handoff", () => {
|
|||||||
|
|
||||||
await waitForPromptTabAgentActions(page, promptTitle);
|
await waitForPromptTabAgentActions(page, promptTitle);
|
||||||
|
|
||||||
await client.updateAgent(agentId, { name: generatedTitle });
|
await workspace.client.updateAgent(agentId, { name: generatedTitle });
|
||||||
await expect
|
await expect
|
||||||
.poll(() => fetchActiveAgentTitle(client, agentId), { timeout: 10_000 })
|
.poll(() => fetchActiveAgentTitle(workspace.client, agentId), { timeout: 10_000 })
|
||||||
.toBe(generatedTitle);
|
.toBe(generatedTitle);
|
||||||
await expect(page.getByRole("button", { name: generatedTitle }).first()).toBeVisible({
|
await expect(page.getByRole("button", { name: generatedTitle }).first()).toBeVisible({
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
@@ -232,8 +215,7 @@ test.describe("Workspace agent title handoff", () => {
|
|||||||
expect(records.some((record) => recordHasTabLabel(record, generatedTitle))).toBe(true);
|
expect(records.some((record) => recordHasTabLabel(record, generatedTitle))).toBe(true);
|
||||||
expect(records.filter(recordHasLoadingTitle)).toEqual([]);
|
expect(records.filter(recordHasLoadingTitle)).toEqual([]);
|
||||||
} finally {
|
} finally {
|
||||||
await client.close().catch(() => undefined);
|
await workspace.cleanup();
|
||||||
await repo.cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, type Page, test } from "./fixtures";
|
import { expect, test } from "./fixtures";
|
||||||
import { clickNewChat, clickNewTerminal } from "./helpers/launcher";
|
import { clickNewChat, clickNewTerminal } from "./helpers/launcher";
|
||||||
|
import { captureWsSessionFrames } from "./helpers/rename";
|
||||||
import {
|
import {
|
||||||
expectTerminalSurfaceVisible,
|
expectTerminalSurfaceVisible,
|
||||||
focusTerminalSurface,
|
focusTerminalSurface,
|
||||||
@@ -8,53 +9,13 @@ import {
|
|||||||
waitForTerminalContent,
|
waitForTerminalContent,
|
||||||
} from "./helpers/terminal-perf";
|
} from "./helpers/terminal-perf";
|
||||||
|
|
||||||
async function installCreateAgentRequestRecorder(page: Page): Promise<void> {
|
interface CreateAgentFrame {
|
||||||
await page.addInitScript(() => {
|
initialPrompt: string | null;
|
||||||
const requests: unknown[] = [];
|
cwd: string | null;
|
||||||
(
|
|
||||||
window as typeof window & {
|
|
||||||
__paseoE2eCreateAgentRequests?: unknown[];
|
|
||||||
}
|
|
||||||
).__paseoE2eCreateAgentRequests = requests;
|
|
||||||
const originalSend = WebSocket.prototype.send;
|
|
||||||
WebSocket.prototype.send = function (data) {
|
|
||||||
if (typeof data === "string") {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data) as {
|
|
||||||
type?: unknown;
|
|
||||||
message?: { type?: unknown };
|
|
||||||
};
|
|
||||||
if (parsed.type === "session" && parsed.message?.type === "create_agent_request") {
|
|
||||||
requests.push(parsed.message);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore non-JSON frames.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return originalSend.call(this, data);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRecordedCreateAgentCwd(page: Page, message: string): Promise<string | null> {
|
function cwdForPrompt(frames: CreateAgentFrame[], prompt: string): string | null {
|
||||||
return page.evaluate((expectedMessage) => {
|
return frames.find((frame) => frame.initialPrompt === prompt)?.cwd ?? null;
|
||||||
const requests =
|
|
||||||
(
|
|
||||||
window as typeof window & {
|
|
||||||
__paseoE2eCreateAgentRequests?: Array<{
|
|
||||||
initialPrompt?: string;
|
|
||||||
config?: { cwd?: string };
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
).__paseoE2eCreateAgentRequests ?? [];
|
|
||||||
|
|
||||||
for (const request of requests) {
|
|
||||||
if (request.initialPrompt === expectedMessage) {
|
|
||||||
return request.config?.cwd ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Workspace cwd correctness", () => {
|
test.describe("Workspace cwd correctness", () => {
|
||||||
@@ -78,7 +39,14 @@ test.describe("Workspace cwd correctness", () => {
|
|||||||
test("draft tab creates an agent in the workspace cwd", async ({ page, withWorkspace }) => {
|
test("draft tab creates an agent in the workspace cwd", async ({ page, withWorkspace }) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
await installCreateAgentRequestRecorder(page);
|
const createAgentFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => {
|
||||||
|
const config = (inner.config ?? {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null,
|
||||||
|
cwd: typeof config.cwd === "string" ? config.cwd : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const workspace = await withWorkspace({ prefix: "workspace-cwd-draft-agent-" });
|
const workspace = await withWorkspace({ prefix: "workspace-cwd-draft-agent-" });
|
||||||
await workspace.navigateTo();
|
await workspace.navigateTo();
|
||||||
|
|
||||||
@@ -97,7 +65,7 @@ test.describe("Workspace cwd correctness", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => getRecordedCreateAgentCwd(page, message), { timeout: 30_000 })
|
.poll(() => cwdForPrompt(createAgentFrames, message), { timeout: 30_000 })
|
||||||
.toBe(workspace.repoPath);
|
.toBe(workspace.repoPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,14 @@ import type { WebSocketRoute } from "@playwright/test";
|
|||||||
import { expect, test, type Page } from "./fixtures";
|
import { expect, test, type Page } from "./fixtures";
|
||||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||||
import {
|
import {
|
||||||
archiveAgentFromDaemon,
|
|
||||||
connectArchiveTabDaemonClient,
|
|
||||||
createIdleAgent,
|
createIdleAgent,
|
||||||
expectWorkspaceTabHidden,
|
expectWorkspaceTabHidden,
|
||||||
expectWorkspaceTabVisible,
|
expectWorkspaceTabVisible,
|
||||||
openWorkspaceWithAgents,
|
openWorkspaceWithAgents,
|
||||||
} from "./helpers/archive-tab";
|
} from "./helpers/archive-tab";
|
||||||
import {
|
|
||||||
archiveLocalWorkspaceFromDaemon,
|
|
||||||
connectNewWorkspaceDaemonClient,
|
|
||||||
openProjectViaDaemon,
|
|
||||||
} from "./helpers/new-workspace";
|
|
||||||
import { expectComposerVisible } from "./helpers/composer";
|
import { expectComposerVisible } from "./helpers/composer";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||||
|
import { seedWorkspace } from "./helpers/seed-client";
|
||||||
import {
|
import {
|
||||||
getVisibleWorkspaceAgentTabIds,
|
getVisibleWorkspaceAgentTabIds,
|
||||||
expectOnlyWorkspaceAgentTabsVisible,
|
expectOnlyWorkspaceAgentTabsVisible,
|
||||||
@@ -37,13 +31,10 @@ import {
|
|||||||
expectWorkspaceDeckEntryCount,
|
expectWorkspaceDeckEntryCount,
|
||||||
} from "./helpers/workspace-ui";
|
} from "./helpers/workspace-ui";
|
||||||
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||||
|
import { getServerId } from "./helpers/server-id";
|
||||||
|
|
||||||
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function expectNoLoadingWorkspacePane(
|
async function expectNoLoadingWorkspacePane(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { label: string; durationMs?: number },
|
input: { label: string; durationMs?: number },
|
||||||
@@ -88,11 +79,11 @@ async function closeFirstVisibleDraftTab(page: Page): Promise<void> {
|
|||||||
await closeButton.first().click();
|
await closeButton.first().click();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installDaemonWebSocketGate(page: Page, daemonPort: string) {
|
async function installDaemonWebSocketGate(page: Page) {
|
||||||
let acceptingConnections = true;
|
let acceptingConnections = true;
|
||||||
const activeSockets = new Set<WebSocketRoute>();
|
const activeSockets = new Set<WebSocketRoute>();
|
||||||
|
|
||||||
await page.routeWebSocket(new RegExp(`:${escapeRegex(daemonPort)}\\b`), (ws) => {
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||||
if (!acceptingConnections) {
|
if (!acceptingConnections) {
|
||||||
void ws.close({ code: 1008, reason: "Blocked by workspace reconnect regression test." });
|
void ws.close({ code: 1008, reason: "Blocked by workspace reconnect regression test." });
|
||||||
return;
|
return;
|
||||||
@@ -166,32 +157,17 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("keeps the workspace rendered while reconnecting to the host", async ({ page }) => {
|
test("keeps the workspace rendered while reconnecting to the host", async ({ page }) => {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = getServerId();
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const daemonGate = await installDaemonWebSocketGate(page, daemonPort);
|
const daemonGate = await installDaemonWebSocketGate(page);
|
||||||
|
|
||||||
const workspaceClient = await connectNewWorkspaceDaemonClient();
|
const workspace = await seedWorkspace({ repoPrefix: "workspace-reconnect-" });
|
||||||
const archiveClient = await connectArchiveTabDaemonClient();
|
|
||||||
const workspaceIds = new Set<string>();
|
|
||||||
const agentIds: string[] = [];
|
|
||||||
const repo = await createTempGitRepo("workspace-reconnect-");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const workspace = await openProjectViaDaemon(workspaceClient, repo.path);
|
const agent = await createIdleAgent(workspace.client, {
|
||||||
workspaceIds.add(workspace.workspaceId);
|
cwd: workspace.repoPath,
|
||||||
|
|
||||||
const agent = await createIdleAgent(archiveClient, {
|
|
||||||
cwd: repo.path,
|
|
||||||
title: `workspace-reconnect-${Date.now()}`,
|
title: `workspace-reconnect-${Date.now()}`,
|
||||||
});
|
});
|
||||||
agentIds.push(agent.id);
|
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
@@ -231,31 +207,16 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expectComposerVisible(page);
|
await expectComposerVisible(page);
|
||||||
} finally {
|
} finally {
|
||||||
daemonGate.restore();
|
daemonGate.restore();
|
||||||
for (const agentId of agentIds) {
|
await workspace.cleanup();
|
||||||
await archiveAgentFromDaemon(archiveClient, agentId).catch(() => undefined);
|
|
||||||
}
|
|
||||||
for (const workspaceId of workspaceIds) {
|
|
||||||
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
|
|
||||||
}
|
|
||||||
await archiveClient.close().catch(() => undefined);
|
|
||||||
await workspaceClient.close().catch(() => undefined);
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("cold offline workspace route gates the screen interior but keeps settings reachable", async ({
|
test("cold offline workspace route gates the screen interior but keeps settings reachable", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = getServerId();
|
||||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
if (!daemonPort) {
|
|
||||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.routeWebSocket(new RegExp(`:${escapeRegex(daemonPort)}\\b`), async (ws) => {
|
await page.routeWebSocket(daemonWsRoutePattern(), async (ws) => {
|
||||||
await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." });
|
await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -272,22 +233,12 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("cold workspace URL keeps sidebar workspace navigation functional", async ({ page }) => {
|
test("cold workspace URL keeps sidebar workspace navigation functional", async ({ page }) => {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = getServerId();
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceClient = await connectNewWorkspaceDaemonClient();
|
const firstWorkspace = await seedWorkspace({ repoPrefix: "workspace-cold-url-a-" });
|
||||||
const workspaceIds = new Set<string>();
|
const secondWorkspace = await seedWorkspace({ repoPrefix: "workspace-cold-url-b-" });
|
||||||
const firstRepo = await createTempGitRepo("workspace-cold-url-a-");
|
|
||||||
const secondRepo = await createTempGitRepo("workspace-cold-url-b-");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const firstWorkspace = await openProjectViaDaemon(workspaceClient, firstRepo.path);
|
|
||||||
const secondWorkspace = await openProjectViaDaemon(workspaceClient, secondRepo.path);
|
|
||||||
workspaceIds.add(firstWorkspace.workspaceId);
|
|
||||||
workspaceIds.add(secondWorkspace.workspaceId);
|
|
||||||
|
|
||||||
await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
|
await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||||
@@ -304,45 +255,28 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
for (const workspaceId of workspaceIds) {
|
await secondWorkspace.cleanup();
|
||||||
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
|
await firstWorkspace.cleanup();
|
||||||
}
|
|
||||||
await workspaceClient.close().catch(() => undefined);
|
|
||||||
await secondRepo.cleanup();
|
|
||||||
await firstRepo.cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
|
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = getServerId();
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceClient = await connectNewWorkspaceDaemonClient();
|
const firstWorkspace = await seedWorkspace({ repoPrefix: "workspace-nav-reg-a-" });
|
||||||
const archiveClient = await connectArchiveTabDaemonClient();
|
const secondWorkspace = await seedWorkspace({ repoPrefix: "workspace-nav-reg-b-" });
|
||||||
const workspaceIds = new Set<string>();
|
|
||||||
const agentIds: string[] = [];
|
|
||||||
const firstRepo = await createTempGitRepo("workspace-nav-reg-a-");
|
|
||||||
const secondRepo = await createTempGitRepo("workspace-nav-reg-b-");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const firstWorkspace = await openProjectViaDaemon(workspaceClient, firstRepo.path);
|
const firstAgent = await createIdleAgent(firstWorkspace.client, {
|
||||||
const secondWorkspace = await openProjectViaDaemon(workspaceClient, secondRepo.path);
|
cwd: firstWorkspace.repoPath,
|
||||||
workspaceIds.add(firstWorkspace.workspaceId);
|
|
||||||
workspaceIds.add(secondWorkspace.workspaceId);
|
|
||||||
|
|
||||||
const firstAgent = await createIdleAgent(archiveClient, {
|
|
||||||
cwd: firstRepo.path,
|
|
||||||
title: `workspace-nav-a-${Date.now()}`,
|
title: `workspace-nav-a-${Date.now()}`,
|
||||||
});
|
});
|
||||||
const secondAgent = await createIdleAgent(archiveClient, {
|
const secondAgent = await createIdleAgent(secondWorkspace.client, {
|
||||||
cwd: secondRepo.path,
|
cwd: secondWorkspace.repoPath,
|
||||||
title: `workspace-nav-b-${Date.now()}`,
|
title: `workspace-nav-b-${Date.now()}`,
|
||||||
});
|
});
|
||||||
agentIds.push(firstAgent.id, secondAgent.id);
|
|
||||||
|
|
||||||
await gotoAppShell(page);
|
await gotoAppShell(page);
|
||||||
await waitForSidebarHydration(page);
|
await waitForSidebarHydration(page);
|
||||||
@@ -487,16 +421,8 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
`workspace-tab-agent_${firstAgent.id}`,
|
`workspace-tab-agent_${firstAgent.id}`,
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
for (const agentId of agentIds) {
|
await secondWorkspace.cleanup();
|
||||||
await archiveAgentFromDaemon(archiveClient, agentId).catch(() => undefined);
|
await firstWorkspace.cleanup();
|
||||||
}
|
|
||||||
for (const workspaceId of workspaceIds) {
|
|
||||||
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
|
|
||||||
}
|
|
||||||
await archiveClient.close().catch(() => undefined);
|
|
||||||
await workspaceClient.close().catch(() => undefined);
|
|
||||||
await secondRepo.cleanup();
|
|
||||||
await firstRepo.cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||||
import { expect, test } from "./fixtures";
|
import { expect, test } from "./fixtures";
|
||||||
import {
|
import { createIdleAgent } from "./helpers/archive-tab";
|
||||||
archiveAgentFromDaemon,
|
|
||||||
connectArchiveTabDaemonClient,
|
|
||||||
createIdleAgent,
|
|
||||||
} from "./helpers/archive-tab";
|
|
||||||
import { expectComposerVisible } from "./helpers/composer";
|
import { expectComposerVisible } from "./helpers/composer";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { seedWorkspace } from "./helpers/seed-client";
|
||||||
|
import { getServerId } from "./helpers/server-id";
|
||||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||||
|
|
||||||
test.describe("Workspace pane mounting", () => {
|
test.describe("Workspace pane mounting", () => {
|
||||||
@@ -14,21 +11,15 @@ test.describe("Workspace pane mounting", () => {
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
const serverId = process.env.E2E_SERVER_ID;
|
const serverId = getServerId();
|
||||||
if (!serverId) {
|
|
||||||
throw new Error("E2E_SERVER_ID is not set.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = await connectArchiveTabDaemonClient();
|
const workspace = await seedWorkspace({ repoPrefix: "pane-remount-" });
|
||||||
const repo = await createTempGitRepo("pane-remount-");
|
|
||||||
let agentId: string | null = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const agent = await createIdleAgent(client, {
|
const agent = await createIdleAgent(workspace.client, {
|
||||||
cwd: repo.path,
|
cwd: workspace.repoPath,
|
||||||
title: `pane-remount-${Date.now()}`,
|
title: `pane-remount-${Date.now()}`,
|
||||||
});
|
});
|
||||||
agentId = agent.id;
|
|
||||||
|
|
||||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
|
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
|
||||||
await page.waitForURL(
|
await page.waitForURL(
|
||||||
@@ -54,11 +45,7 @@ test.describe("Workspace pane mounting", () => {
|
|||||||
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
|
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
|
||||||
expect(originalStillConnected).toBe(true);
|
expect(originalStillConnected).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
if (agentId) {
|
await workspace.cleanup();
|
||||||
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
|
||||||
}
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
findWorktreeWorkspaceForProject,
|
findWorktreeWorkspaceForProject,
|
||||||
navigateToWorkspaceViaSidebar,
|
navigateToWorkspaceViaSidebar,
|
||||||
openHomeWithProject,
|
openHomeWithProject,
|
||||||
|
seedProjectForWorkspaceSetup,
|
||||||
} from "./helpers/workspace-setup";
|
} from "./helpers/workspace-setup";
|
||||||
|
|
||||||
test.describe("Workspace setup runtime authority", () => {
|
test.describe("Workspace setup runtime authority", () => {
|
||||||
@@ -21,7 +22,7 @@ test.describe("Workspace setup runtime authority", () => {
|
|||||||
const repo = await createTempGitRepo("workspace-setup-chat-");
|
const repo = await createTempGitRepo("workspace-setup-chat-");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.openProject(repo.path);
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||||
cwd: repo.path,
|
cwd: repo.path,
|
||||||
worktreeSlug: `setup-chat-${Date.now()}`,
|
worktreeSlug: `setup-chat-${Date.now()}`,
|
||||||
@@ -48,7 +49,7 @@ test.describe("Workspace setup runtime authority", () => {
|
|||||||
const repo = await createTempGitRepo("workspace-setup-terminal-");
|
const repo = await createTempGitRepo("workspace-setup-terminal-");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.openProject(repo.path);
|
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||||
|
|
||||||
// Create workspace via daemon API since the new workspace screen
|
// Create workspace via daemon API since the new workspace screen
|
||||||
// no longer has a standalone terminal button
|
// no longer has a standalone terminal button
|
||||||
|
|||||||
@@ -1,50 +1,42 @@
|
|||||||
import { test, expect } from "./fixtures";
|
import { test, expect } from "./fixtures";
|
||||||
import { createTempGitRepo } from "./helpers/workspace";
|
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
|
||||||
import { connectTerminalClient, navigateToTerminal } from "./helpers/terminal-perf";
|
|
||||||
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
|
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||||
|
|
||||||
test.describe("Workspace terminal tab rename", () => {
|
test.describe("Workspace terminal tab rename", () => {
|
||||||
|
let harness: TerminalE2EHarness;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
harness = await TerminalE2EHarness.create({ tempPrefix: "workspace-terminal-rename-" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await harness?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
test("right-click rename sends terminal.rename.request and updates the tab label", async ({
|
test("right-click rename sends terminal.rename.request and updates the tab label", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
const client = await connectTerminalClient();
|
const renameFrames = captureWsSessionFrames(page, "terminal.rename.request", (inner) => ({
|
||||||
const repo = await createTempGitRepo("workspace-terminal-rename-");
|
terminalId: String(inner.terminalId ?? ""),
|
||||||
|
title: String(inner.title ?? ""),
|
||||||
|
requestId: String(inner.requestId ?? ""),
|
||||||
|
}));
|
||||||
|
|
||||||
try {
|
await withTerminalInApp(page, harness, { name: "rename-target" }, async (terminal) => {
|
||||||
const seeded = await client.openProject(repo.path);
|
const tab = page.getByTestId(`workspace-tab-terminal_${terminal.id}`).first();
|
||||||
if (!seeded.workspace) {
|
|
||||||
throw new Error(seeded.error ?? "Failed to seed workspace");
|
|
||||||
}
|
|
||||||
const workspaceId = seeded.workspace.id;
|
|
||||||
|
|
||||||
const created = await client.createTerminal(repo.path);
|
|
||||||
if (!created.terminal) {
|
|
||||||
throw new Error(created.error ?? "Failed to create terminal");
|
|
||||||
}
|
|
||||||
const terminalId = created.terminal.id;
|
|
||||||
|
|
||||||
const renameFrames = captureWsSessionFrames(page, "terminal.rename.request", (inner) => ({
|
|
||||||
terminalId: String(inner.terminalId ?? ""),
|
|
||||||
title: String(inner.title ?? ""),
|
|
||||||
requestId: String(inner.requestId ?? ""),
|
|
||||||
}));
|
|
||||||
|
|
||||||
await navigateToTerminal(page, { workspaceId, terminalId });
|
|
||||||
|
|
||||||
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
|
|
||||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
await tab.click({ button: "right" });
|
await tab.click({ button: "right" });
|
||||||
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({
|
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminal.id}`)).toBeVisible({
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
|
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminal.id}-rename`);
|
||||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||||
await renameItem.click();
|
await renameItem.click();
|
||||||
|
|
||||||
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`;
|
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminal.id}`;
|
||||||
const input = renameModalInput(page, modalPrefix);
|
const input = renameModalInput(page, modalPrefix);
|
||||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
@@ -56,12 +48,9 @@ test.describe("Workspace terminal tab rename", () => {
|
|||||||
|
|
||||||
expect(renameFrames.length).toBeGreaterThan(0);
|
expect(renameFrames.length).toBeGreaterThan(0);
|
||||||
const lastFrame = renameFrames.at(-1)!;
|
const lastFrame = renameFrames.at(-1)!;
|
||||||
expect(lastFrame.terminalId).toBe(terminalId);
|
expect(lastFrame.terminalId).toBe(terminal.id);
|
||||||
expect(lastFrame.title).toBe("My Renamed Terminal");
|
expect(lastFrame.title).toBe("My Renamed Terminal");
|
||||||
expect(lastFrame.requestId.length).toBeGreaterThan(0);
|
expect(lastFrame.requestId.length).toBeGreaterThan(0);
|
||||||
} finally {
|
});
|
||||||
await client.close();
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user