Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 69 additions & 20 deletions packages/launcher/e2e/respond.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Full keyed GUI flow: install → create instance → configure LLM → connect
// workspace → start → send a message → poll the workspace API for a real reply.
// Full keyed GUI flow: install → join the workspace on this device → create
// instance → configure LLM → bind to the workspace → start → send a message →
// poll the workspace API for a real reply.
//
// Gated so a cell without the needed credentials skips cleanly (not fails):
// - needs E2E_WS_TOKEN / E2E_WS_SLUG (workspace)
Expand All @@ -11,6 +12,8 @@ import { test, expect } from "./fixtures"
import { agentBySlug } from "./agents"
import {
haveWorkspaceCreds,
createPairingCode,
WS_SLUG,
sendMessage,
baselineCursor,
pollForReply,
Expand Down Expand Up @@ -192,11 +195,54 @@ test.describe("launcher full flow", () => {
})
}

// 2. Create an agent instance. The working directory is normally async-
// prefilled from listPaths(); fill it explicitly so Create never rejects
// on an empty path (the prefill can lose the race, esp. on Windows).
// 2. Join the workspace on this device, then create the agent instance.
//
// Joining is device-level: `connectWorkspace` refuses a workspace this
// device holds no pairing for, and the agent's Connect dialog offers only
// the ones it does. Every test gets a fresh HOME, so nothing is paired —
// redeem a code first, through the same Workspaces page a user would use.
// The code is minted at run time from the workspace token the reply
// assertion already needs, so no human has to stage one per run.
const pairingCode = await createPairingCode()
await page.getByTestId("nav-workspaces").click()
// Other pages can arrive here with the dialog already requested, so open it
// only when it isn't — its overlay would swallow the click that opens it.
const codeField = page.locator("#quick-connect-code")
if (!(await codeField.isVisible().catch(() => false)))
await page.getByTestId("workspace-join-open").click()
await codeField.fill(pairingCode)
await page.getByTestId("ws-pair-submit").click()
// Redeeming writes node.json; assert on that rather than on the card the
// page draws, so a rendering hiccup cannot read as a failed pairing.
await expect
.poll(
async () =>
page.evaluate(async () => {
const status = await (
window as unknown as {
api: {
getNodeStatus: () => Promise<{
workspaces?: Array<{ workspaceSlug?: string }>
}>
}
}
).api.getNodeStatus()
return (status.workspaces || []).map((w) => w.workspaceSlug)
}),
{ timeout: 60_000, intervals: [2_000] },
)
.toContain(WS_SLUG)

// Now the instance. The working directory is normally async-prefilled
// from listPaths(); fill it explicitly so Create never rejects on an
// empty path (the prefill can lose the race, esp. on Windows).
await page.getByTestId("nav-agents").click()
await page.getByTestId("new-agent-open").click()
await page.locator("#agent-type").selectOption(SLUG)
// Agent type is a Radix Select, not a native <select>: open it and pick the
// option. Leaving it on whatever it defaults to would build every agent as
// the first installed type instead of the one under test.
await page.getByTestId("agent-type").click()
await page.getByTestId(`agent-type-option-${SLUG}`).click()
await page.locator("#agent-name").fill(name)
await page.locator("#agent-working-directory").fill(homeDir)
await page.getByTestId("new-agent-create").click()
Expand All @@ -210,15 +256,14 @@ test.describe("launcher full flow", () => {
const hasKeyFields = await save
.isVisible({ timeout: 60_000 })
.catch(() => false)
// Success of the configure step = the Connect dialog has opened (its
// join-token toggle is visible). We assert on THAT rather than the Save
// button vanishing, because codex's dual-auth dialog transiently re-enters
// its loading state on Windows (footer unmounts briefly), which would
// false-positive a "dialog closed" check.
const joinToggle = page.getByTestId("ws-join-toggle")
// Success of the configure step = the Connect dialog has opened. We assert
// on THAT rather than the Save button vanishing, because codex's dual-auth
// dialog transiently re-enters its loading state on Windows (footer
// unmounts briefly), which would false-positive a "dialog closed" check.
const connectDialog = page.getByTestId("connect-ws")
if (hasKeyFields) {
await expect(async () => {
if (await joinToggle.isVisible().catch(() => false)) return
if (await connectDialog.isVisible().catch(() => false)) return
const fieldIds = await page.evaluate(() =>
Array.from(document.querySelectorAll('[id^="agent-config-"]')).map(
(e) => e.id,
Expand All @@ -233,7 +278,7 @@ test.describe("launcher full flow", () => {
if (val) await page.locator(`[id="${id}"]`).fill(val)
}
await save.click().catch(() => {})
await expect(joinToggle).toBeVisible({ timeout: 6_000 })
await expect(connectDialog).toBeVisible({ timeout: 6_000 })
}).toPass({ timeout: 60_000 })
} else {
await page.evaluate(
Expand All @@ -252,9 +297,9 @@ test.describe("launcher full flow", () => {
{ n: name, env: injectionEnv() },
)
await expect(async () => {
if (await joinToggle.isVisible().catch(() => false)) return
if (await connectDialog.isVisible().catch(() => false)) return
await page.keyboard.press("Escape")
await expect(joinToggle).toBeVisible({ timeout: 4_000 })
await expect(connectDialog).toBeVisible({ timeout: 4_000 })
}).toPass({ timeout: 30_000 })
}

Expand All @@ -273,10 +318,14 @@ test.describe("launcher full flow", () => {
)
}

// 4. Connect to the workspace (dialog auto-opens for a new agent).
await page.getByTestId("ws-join-toggle").click()
await page.locator("#workspace-url-or-token").fill(process.env.E2E_WS_TOKEN!)
await page.getByTestId("ws-join").click()
// 4. Bind the agent to the workspace (dialog auto-opens for a new agent).
// It lists only what this device is paired with — the workspace joined in
// step 2 — and has no token form of its own. An empty list means the
// pairing was lost between the two steps, which `ws-none-paired` tells
// apart from the dialog never opening at all.
const wsOption = page.getByTestId(`ws-option-${WS_SLUG}`)
await expect(wsOption).toBeVisible({ timeout: 60_000 })
await wsOption.click()

// 5. Ensure the agent is running + connected. Connecting triggers a daemon
// reload that AUTO-STARTS the agent, so clicking Start on an already-
Expand Down
28 changes: 28 additions & 0 deletions packages/launcher/e2e/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,34 @@ export function haveWorkspaceCreds(): boolean {
return !!TOKEN && !!SLUG
}

/** The workspace under test, as the launcher will list and bind it. */
export const WS_SLUG = SLUG

/**
* Mint a single-use node pairing code for this workspace.
*
* Joining is device-level now: the launcher redeems an XXXX-XXXX code to
* register this machine as a node, and only then can an agent be bound to the
* workspace. Codes are short-lived and single-use, so a run cannot carry one in
* a secret — it mints its own. `POST /v1/workspaces/{slug}/pairing-codes` is
* owner/admin gated, and a workspace or node token counts as a trusted machine
* credential there, so the token the reply assertion already needs is enough.
*/
export async function createPairingCode(): Promise<string> {
const res = await fetch(
`${BASE}/v1/workspaces/${encodeURIComponent(SLUG)}/pairing-codes`,
{ method: "POST", headers: headers() },
)
if (!res.ok)
throw new Error(
`POST /v1/workspaces/${SLUG}/pairing-codes ${res.status}: ${await res.text()}`,
)
const body = await res.json()
const code = body?.data?.code
if (!code) throw new Error(`pairing-codes returned no code: ${JSON.stringify(body)}`)
return code as string
}

async function fetchEvents(params: Record<string, string>): Promise<any> {
const qs = new URLSearchParams(params).toString()
const res = await fetch(`${BASE}/v1/events?${qs}`, { headers: headers() })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export function WorkspaceQuickConnect({
{t("workspaces.quickConnect.cancel")}
</Button>
<Button
data-testid="ws-pair-submit"
onClick={() => void handlePair()}
disabled={busy || normalizeCode(code).length !== PAIRING_CODE_LENGTH}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,10 @@ export function ConnectWorkspaceDialog({

return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
{/* The handle is on the shell rather than only on the list, so "the
dialog never opened" and "it opened with nothing to offer" stay
separate failures for the e2e matrix. */}
<DialogContent data-testid="connect-ws">
<DialogHeader>
<DialogTitle>
{t("agents.connectDialog.title", { name: agentName })}
Expand Down Expand Up @@ -206,7 +209,10 @@ export function ConnectWorkspaceDialog({
pairing form. A workspace that removed this device lands here too:
it is gone from the list the moment its pairing is. */
<>
<DialogBody className="items-center gap-2 py-10 text-center">
<DialogBody
className="items-center gap-2 py-10 text-center"
data-testid="ws-none-paired"
>
<Laptop className="size-6 text-muted-foreground" />
<p className="m-0 text-sm font-medium">
{t("agents.connectDialog.emptyTitle")}
Expand Down Expand Up @@ -264,6 +270,7 @@ export function ConnectWorkspaceDialog({
<button
key={ws.id}
type="button"
data-testid={`ws-option-${shortId}`}
data-active={active}
onMouseEnter={() => setCursor(i)}
onClick={() => doConnect(shortId)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,19 @@ export function NewAgentDialog({
<Field>
<FieldLabel>{t("agents.newDialog.agentType")}</FieldLabel>
<Select value={selectedType} onValueChange={setSelectedType}>
<SelectTrigger>
{/* The e2e matrix picks the type by slug, so the handle is on
the trigger and on every option — the label is translated
and the value lives in Radix state, not in the DOM. */}
<SelectTrigger data-testid="agent-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{supportedInstalled.map((c) => (
<SelectItem key={c.name} value={c.name}>
<SelectItem
key={c.name}
value={c.name}
data-testid={`agent-type-option-${c.name}`}
>
{c.label || c.name}
</SelectItem>
))}
Expand Down
2 changes: 1 addition & 1 deletion packages/launcher/src/renderer/pages/workspaces/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export default function Workspaces({ showToast }: Props): React.JSX.Element {
// Joins, never creates: the dialog takes a pairing code for a
// workspace that already exists. A device can hold several at once,
// so this stays available however many are listed.
<Button onClick={openQuick}>
<Button data-testid="workspace-join-open" onClick={openQuick}>
<Plus />
{t("workspaces.join")}
</Button>
Expand Down
Loading