diff --git a/src/bundled/agents/AgentControlPanel.test.tsx b/src/bundled/agents/AgentControlPanel.test.tsx new file mode 100644 index 000000000..86dc77f46 --- /dev/null +++ b/src/bundled/agents/AgentControlPanel.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment jsdom +import { stubAvatarBrowserApis } from "../../features/agents/avatar-testing"; +stubAvatarBrowserApis(); +import "@testing-library/jest-dom/vitest"; +import { afterEach, expect, it, vi } from "vitest"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import * as communityApi from "../../features/communities/api"; +import { createAgentControl } from "../../features/agents/control"; +import { controlFixture } from "../../features/agents/control-testing"; +import { AgentControlPanel } from "./AgentControlPanel"; + +const disposals: (() => void)[] = []; +afterEach(() => { + cleanup(); + for (const dispose of disposals.splice(0)) dispose(); + vi.restoreAllMocks(); +}); + +// Exercise the control panel callbacks before the replacement inventory mounts. +function setup( + _mode: string, + configure: (f: ReturnType) => void, +) { + const f = controlFixture(); + configure(f); + const control = createAgentControl(f.host); + disposals.push(() => control.dispose()); + render( + + {(state, _edit, _duplicate, _remove, _importedId, _label, onUseHere) => + state.status === "ready" && ( + <> +
+ +
+ + ) + } +
, + ); + return { f }; +} + +it("dialog actions choose the destination record when several local setups share a key", async () => { + const request = vi + .spyOn(communityApi, "communityRequest") + .mockImplementation(async (_destination, route) => + route === "agent-inventory" + ? { identities: [] } + : { + pubkey: "ab".repeat(32), + relayUrl: "wss://relay.example.test", + owner: "de".repeat(32), + signature: "fixture", + }, + ); + const { f } = setup("connected", (fixture) => { + fixture.data.parked = []; + fixture.agent.configured = false; + fixture.agent.enabled = false; + fixture.agent.status = "stopped"; + fixture.data.agents.unshift({ + ...structuredClone(fixture.agent), + id: "first-other-community", + relayUrl: "wss://elsewhere.example", + name: "Other saved name", + systemPrompt: "Other instructions", + }); + }); + const card = await screen.findByRole("article", { + name: "Agent Fixture agent", + }); + fireEvent.click(within(card).getByRole("button", { name: "Use here" })); + const setupDialog = await screen.findByRole("dialog", { + name: "Set up agent here", + }); + fireEvent.click( + within(setupDialog).getByRole("button", { name: "Use here" }), + ); + await waitFor(() => + expect( + screen.queryByRole("dialog", { name: "Set up agent here" }), + ).toBeNull(), + ); + expect( + f.calls.find((call) => call.action === "configure")?.payload, + ).toMatchObject({ id: "fixture-agent" }); + expect(request).toHaveBeenCalledWith( + "https://relay.example.test", + "resolve-agent-community", + { pubkey: f.agent.pubkey, owner: "de".repeat(32), confirmed: true }, + ); + expect( + f.data.agents.find((agent) => agent.id === "first-other-community") + ?.configured, + ).toBe(false); + expect(f.agent.enabled).toBe(false); +}); diff --git a/src/bundled/agents/AgentControlPanel.tsx b/src/bundled/agents/AgentControlPanel.tsx index 8205446a2..2ecb526f3 100644 --- a/src/bundled/agents/AgentControlPanel.tsx +++ b/src/bundled/agents/AgentControlPanel.tsx @@ -1,3 +1,6 @@ +import { Dialog } from "@base-ui/react/dialog"; +import { relayOrigin } from "../../features/communities/destination"; +import { LocalInventoryAction } from "./LocalInventoryAction"; import type { useIdentityNames } from "../../features/identity-names/react"; import { useAgentControl, @@ -49,6 +52,7 @@ export function AgentControlPanel({ remove: (agent: AgentView) => void, importedId: string | null, label: (agent: AgentView) => string, + onUseHere: (pubkey: string) => void, ) => ReactNode; }) { const [adding, setAdding] = useState<{ @@ -57,6 +61,17 @@ export function AgentControlPanel({ source?: AgentView; initialSettings?: CloneSettings; } | null>(null); + const [localPending, setLocalPending] = useState(false); + const [handover, setHandover] = useState<{ + pubkey: string; + destination: string; + } | null>(null); + useEffect(() => { + // A handover belongs to the community in which its action was selected. + setHandover((current) => + current?.destination === importDestination ? current : null, + ); + }, [importDestination]); const [importSections, setImportSections] = useState([]); const [importedId, setImportedId] = useState(null); const [selected, setSelected] = useState<{ @@ -76,7 +91,10 @@ export function AgentControlPanel({ }); const remove = (agent: AgentView) => setDeleting(agent.id); useAgentControlRefresh(control); - const state = useAgentControl(control); + const nativeState = useAgentControl(control); + const state = localPending + ? { ...nativeState, busy: true, pendingCredentialWrite: true } + : nativeState; useEffect(() => { if ( state.data?.agents.some( @@ -99,6 +117,16 @@ export function AgentControlPanel({ ...facts, { pubkey: agent.pubkey, name: agent.name, isAgent: true }, ]) ?? agent.name; + const localSource = + handover && + (state.data?.agents.find( + (agent) => + agent.pubkey === handover.pubkey && + !!agent.relayUrl && + !!handover.destination && + relayOrigin(agent.relayUrl) === relayOrigin(handover.destination), + ) ?? + state.data?.agents.find((agent) => agent.pubkey === handover.pubkey)); // Route selection takes precedence over card-local editing. Never guess among // multiple native records for the same public identity in this community. const routed = @@ -141,6 +169,7 @@ export function AgentControlPanel({ + + + + )} {adding && ( call.action === "configure")).toBe(false); - fireEvent.click(within(card).getByRole("button", { name: "Use here" })); + fireEvent.click(within(dialog).getByRole("button", { name: "Use here" })); await waitFor(() => expect(within(card).getByRole("button", { name: "Start" })).toBeEnabled(), ); diff --git a/src/bundled/agents/AgentsPage.tsx b/src/bundled/agents/AgentsPage.tsx index 8b3839c9b..c8defd8b0 100644 --- a/src/bundled/agents/AgentsPage.tsx +++ b/src/bundled/agents/AgentsPage.tsx @@ -140,7 +140,15 @@ export function AgentsPage({ : undefined } > - {(state, edit, duplicate, remove, importedId, label) => + {( + state, + edit, + duplicate, + remove, + importedId, + label, + onUseHere, + ) => state.status === "unavailable" ? ( library ) : state.data?.parked !== undefined ? ( @@ -153,9 +161,11 @@ export function AgentsPage({ importedId={importedId} control={control} connection={connection} + onUseHere={onUseHere} /> ) : ( ; state: AgentControlState; control: AgentControl; session: RelaySession; @@ -33,10 +40,13 @@ export function InventoryIdentityCard({ duplicate?: ((agent: AgentView) => void) | undefined; remove?: ((agent: AgentView) => void) | undefined; importedId: string | null; + onUseHere(pubkey: string): void; }) { const data = state.data; if (!data) return null; const avatar = row.avatar ?? publicProfiles.get(row.pubkey)?.picture; + const tile = decision.group === localHereGroup; + const setupAvailable = agentSetupConfirmationAvailable(); // The app runs every saved setup, so each keeps its controls whether or not // its community is the one currently selected or connected. const setups = localSetups(row, destination); @@ -61,16 +71,49 @@ export function InventoryIdentityCard({ /> ))} {row.unconfiguredSetups.map((agent) => ( - +

+ Earlier import needs setup: {agent.relayUrl || "No saved community"}. + Connect to the intended community and choose Use here. It stays + stopped. +

))} +
+ {decision.action === "use" && ( + <> + + {decision.blocked &&

{decision.blocked}

} + {!data.localInventoryActions ? ( +

+ Restart an updated desktop build to use local inventory actions. +

+ ) : ( + !setupAvailable &&

{agentSetupUnavailableMessage}

+ )} + + )} +
+ {decision.action === "wait" && ( +

+ {decision.blocked} +

+ )}
Identity & sources
diff --git a/src/bundled/agents/InventoryView.test.tsx b/src/bundled/agents/InventoryView.test.tsx new file mode 100644 index 000000000..ea615e424 --- /dev/null +++ b/src/bundled/agents/InventoryView.test.tsx @@ -0,0 +1,218 @@ +// @vitest-environment jsdom +import { stubAvatarBrowserApis } from "../../features/agents/avatar-testing"; +stubAvatarBrowserApis(); +import "@testing-library/jest-dom/vitest"; +import { npubEncode } from "nostr-tools/nip19"; +import { afterEach, expect, it, vi } from "vitest"; +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import { createAgentControl } from "../../features/agents/control"; +import { controlFixture } from "../../features/agents/control-testing"; +import { createRelaySession } from "../../features/relay/session"; +import { inventoryIdentities } from "./inventory-model"; +import { InventoryView } from "./InventoryView"; +const disposals: (() => void)[] = []; +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + for (const dispose of disposals.splice(0)) dispose(); +}); +function setup( + mode: string, + configure: (f: ReturnType) => void, + identities = ["cd".repeat(32)], + definitions: { id: string; name: string }[] = [], +) { + const f = controlFixture(); + configure(f); + const owned = createRelaySession({ + viewer: "de".repeat(32), + relayAuthor: "ef".repeat(32), + scope: "wss://relay.example.test", + query: async () => [], + media: () => undefined, + }); + const control = createAgentControl(f.host); + disposals.push(() => { + owned.dispose(); + control.dispose(); + }); + const rows = inventoryIdentities( + mode === "disconnected" + ? [] + : [ + { + pubkey: "cd".repeat(32), + name: "Not imported", + definitionId: "linked", + }, + ], + f.data, + (_key, fallback) => fallback, + ); + if (mode !== "disconnected") { + for (const key of identities) { + const row = rows.get(key); + if (row) + row.knownCommunities = new Set([ + ...row.knownCommunities, + "https://relay.example.test", + ]); + } + } + const onUseHere = vi.fn(); + const view = () => ( + {}} + importedId={null} + onUseHere={onUseHere} + /> + ); + const mounted = render(view()); + return { + f, + onUseHere, + rows, + redraw: () => mounted.rerender(view()), + }; +} +it("keeps parked discovery community unknown without claiming saved setup", async () => { + setup( + "connected", + (fixture) => { + fixture.data.parked = [ + { pubkey: "cd".repeat(32), name: "Saved", sources: ["installed"] }, + { pubkey: "ee".repeat(32), name: "Unresolved", sources: ["installed"] }, + ]; + }, + [], + ); + const group = await screen.findByRole("region", { + name: "Available to import", + }); + expect( + await within(group).findByRole("article", { name: "Agent Saved" }), + ).toBeVisible(); + expect( + within( + screen.getByRole("region", { name: "Available to import" }), + ).getByRole("article", { name: "Agent Unresolved" }), + ).toBeVisible(); +}); + +it("sorts displayed names within groups and profile cards without merging equal names", async () => { + const keys = ["11", "22", "33", "44"].map((s) => s.repeat(32)); + setup( + "connected", + (f) => { + f.data.agents = []; + f.data.parked = keys.map((pubkey, i) => ({ + pubkey, + name: ["Zebra", "beta", "Alpha", "Alpha"][i] ?? "", + sources: ["installed"], + })); + }, + keys, + [ + { id: "z", name: "Zebra profile" }, + { id: "a", name: "alpha profile" }, + ], + ); + const group = await screen.findByRole("region", { + name: "Available to import", + }); + const cards = within(group).getAllByRole("article"); + expect(cards.map((card) => card.getAttribute("aria-label"))).toEqual([ + "Agent Alpha", + "Agent Alpha", + "Agent beta", + "Agent Zebra", + ]); + expect( + cards.map((card) => card.querySelector("[data-public-key]")?.textContent), + ).toEqual(["33", "44", "22", "11"].map((key) => npubEncode(key.repeat(32)))); + const profiles = await screen.findByRole("region", { + name: "Profiles without identities", + }); + expect( + within(profiles) + .getAllByRole("article") + .map((card) => card.getAttribute("aria-label")), + ).toEqual(["Agent alpha profile", "Agent Zebra profile"]); + expect(within(group).getAllByRole("article")).toHaveLength(4); +}); + +it("renders four exclusive sections with all setups on one exact-key card", async () => { + setup("connected", (f) => { + const here = { ...f.agent }; + f.data.agents.push({ + ...here, + id: "same-key-other", + relayUrl: "wss://second.example", + }); + f.data.agents.push({ + ...here, + id: "other-key", + pubkey: "ee".repeat(32), + relayUrl: "wss://third.example", + }); + f.data.parked = [ + { + pubkey: here.pubkey.toUpperCase(), + name: "Shared", + sources: ["installed", "development"], + }, + { + pubkey: "ff".repeat(32), + name: "Importable", + sources: ["installed", "development"], + }, + ]; + }); + const local = await screen.findByRole("region", { + name: "Local agents in this community", + }); + const card = within(local).getByRole("article", { name: "Agent Shared" }); + expect(screen.getAllByRole("article", { name: "Agent Shared" })).toHaveLength( + 1, + ); + // Each saved setup keeps its own controls, including the one in another community. + expect(within(card).getAllByRole("button", { name: "Stop" })).toHaveLength(2); + expect( + within(card).getByText("Installed Buzz · Development Buzz"), + ).not.toBeVisible(); + fireEvent.click( + within(card).queryByText("Identity & sources") ?? + within(card).getByText("Identity & sources"), + ); + expect( + within(card).getByText("Installed Buzz · Development Buzz"), + ).toBeVisible(); + expect( + within( + screen.getByRole("region", { name: "Local agents in other communities" }), + ).getAllByRole("article"), + ).toHaveLength(1); + expect( + within( + screen.getByRole("region", { name: "Available to import" }), + ).getByRole("article", { name: "Agent Importable" }), + ).toBeVisible(); + expect( + within( + await screen.findByRole("region", { name: "Relay-only agents" }), + ).getByRole("article", { name: "Agent Not imported" }), + ).toBeVisible(); +}); diff --git a/src/bundled/agents/InventoryView.tsx b/src/bundled/agents/InventoryView.tsx index 453d9cd18..749226feb 100644 --- a/src/bundled/agents/InventoryView.tsx +++ b/src/bundled/agents/InventoryView.tsx @@ -29,6 +29,7 @@ export function InventoryView({ duplicate, remove, importedId, + onUseHere, children, }: { state: AgentControlState; @@ -42,6 +43,7 @@ export function InventoryView({ duplicate?: ((agent: AgentView) => void) | undefined; remove?: ((agent: AgentView) => void) | undefined; importedId: string | null; + onUseHere(pubkey: string): void; children?: ReactNode; }) { const data = state.data; @@ -77,10 +79,11 @@ export function InventoryView({

{group}

- {identities.map(({ row }) => ( + {identities.map(({ row, decision }) => ( ))}
diff --git a/src/bundled/agents/LocalInventoryAction.native.test.tsx b/src/bundled/agents/LocalInventoryAction.native.test.tsx new file mode 100644 index 000000000..42276e3e5 --- /dev/null +++ b/src/bundled/agents/LocalInventoryAction.native.test.tsx @@ -0,0 +1,150 @@ +// @vitest-environment jsdom +import { stubAvatarBrowserApis } from "../../features/agents/avatar-testing"; +stubAvatarBrowserApis(); +import "@testing-library/jest-dom/vitest"; +import { invoke } from "@tauri-apps/api/core"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import { + agentSetupConfirmationAvailable, + communityRequest, +} from "../../features/communities/api"; +import { createAgentControl } from "../../features/agents/control"; +import { controlFixture } from "../../features/agents/control-testing"; +import { createRelaySession } from "../../features/relay/session"; +import { inventoryIdentities } from "./inventory-model"; +import { InventoryView } from "./InventoryView"; +import { + agentSetupUnavailableMessage, + LocalInventoryAction, +} from "./LocalInventoryAction"; +import { ManagedAgentActions } from "./ManagedAgentActions"; + +// A packaged desktop connection: the real adapter selection, with no broker. +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(async (command: string) => { + throw new Error(`Unexpected native command ${command}`); + }), + isTauri: () => true, +})); +const destination = "https://relay.example.test"; +const owner = "de".repeat(32); +const disposals: (() => void)[] = []; +beforeEach(() => { + vi.stubGlobal("navigator", { ...navigator, platform: "MacIntel" }); + vi.stubEnv("VITE_BUZZ_LIVE", "0"); + vi.stubGlobal( + "fetch", + vi.fn(() => { + throw new Error("No development broker in this build"); + }), + ); +}); +afterEach(() => { + cleanup(); + for (const dispose of disposals.splice(0)) dispose(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.mocked(invoke).mockClear(); +}); + +function incompleteImport() { + const f = controlFixture(); + Object.assign(f.agent, { + configured: false, + enabled: false, + status: "stopped", + runningRevision: null, + }); + const control = createAgentControl(f.host); + disposals.push(() => control.dispose()); + return { f, control }; +} + +it("matches the packaged adapter, which cannot confirm agent setup", async () => { + expect(agentSetupConfirmationAvailable()).toBe(false); + await expect( + communityRequest(destination, "resolve-agent-community", { + pubkey: "ab".repeat(32), + owner, + confirmed: true, + }), + ).rejects.toThrow("This operation is unavailable on the packaged connection"); + expect(invoke).not.toHaveBeenCalled(); +}); + +it("explains the missing confirmation instead of offering Use here in the dialog", async () => { + const { f, control } = incompleteImport(); + await control.refresh(); + render( + {}} + onUsed={() => {}} + />, + ); + expect(screen.getByRole("status")).toHaveTextContent( + agentSetupUnavailableMessage, + ); + expect(screen.queryByRole("button", { name: "Use here" })).toBeNull(); +}); + +it("does not offer Use here for an imported identity that needs setup", async () => { + const { f, control } = incompleteImport(); + await control.refresh(); + const onUseHere = vi.fn(); + render( + , + ); + expect(screen.queryByRole("button", { name: "Use here" })).toBeNull(); + expect(screen.getByText(agentSetupUnavailableMessage)).toBeVisible(); + expect( + screen.queryByText(/Choose Use here to set up this identity/), + ).toBeNull(); +}); + +it("disables the inventory card's Use here and says why", async () => { + const { f, control } = incompleteImport(); + await control.refresh(); + const owned = createRelaySession({ + viewer: owner, + relayAuthor: "ef".repeat(32), + scope: "wss://relay.example.test", + query: async () => [], + media: () => undefined, + }); + disposals.push(() => owned.dispose()); + const onUseHere = vi.fn(); + render( + fallback)} + profiles={[]} + publicProfiles={new Map()} + edit={() => {}} + importedId={null} + onUseHere={onUseHere} + />, + ); + const card = await screen.findByRole("article", { + name: "Agent Fixture agent", + }); + expect(within(card).getByRole("button", { name: "Use here" })).toBeDisabled(); + expect(within(card).getByText(agentSetupUnavailableMessage)).toBeVisible(); + expect(onUseHere).not.toHaveBeenCalled(); +}); diff --git a/src/bundled/agents/LocalInventoryAction.tsx b/src/bundled/agents/LocalInventoryAction.tsx index 1212acc04..76e645b4a 100644 --- a/src/bundled/agents/LocalInventoryAction.tsx +++ b/src/bundled/agents/LocalInventoryAction.tsx @@ -4,9 +4,15 @@ import type { AgentView, CommunityResolution, } from "../../features/agents/control"; -import { communityRequest } from "../../features/communities/api"; +import { + agentSetupConfirmationAvailable, + communityRequest, +} from "../../features/communities/api"; import { Button } from "../../shared/design-system/ui/Button"; +export const agentSetupUnavailableMessage = + "This app version cannot finish setting up imported agents yet."; + /** Uses retained app custody, never a legacy preview or credential import. */ export function LocalInventoryAction({ control, @@ -35,6 +41,8 @@ export function LocalInventoryAction({ }, [onPending]); const [pending, setPending] = useState(false); const [error, setError] = useState(null); + if (!agentSetupConfirmationAvailable()) + return

{agentSetupUnavailableMessage}

; return ( <>

diff --git a/src/bundled/agents/ManagedAgentActions.tsx b/src/bundled/agents/ManagedAgentActions.tsx index 7144e68b7..de74847e8 100644 --- a/src/bundled/agents/ManagedAgentActions.tsx +++ b/src/bundled/agents/ManagedAgentActions.tsx @@ -7,6 +7,7 @@ import { type AgentControlState, type AgentView, } from "../../features/agents/control"; +import { agentSetupConfirmationAvailable } from "../../features/communities/api"; import { LocalInventoryAction } from "./LocalInventoryAction"; import { Button } from "../../shared/design-system/ui/Button"; import { agentProcessLabel } from "./agent-edit"; @@ -18,6 +19,7 @@ export function ManagedAgentActions({ imported, destination = "", owner = "", + onUseHere, }: { agent: AgentView; state: AgentControlState; @@ -25,8 +27,10 @@ export function ManagedAgentActions({ imported: boolean; destination?: string; owner?: string; + onUseHere?: ((pubkey: string) => void) | undefined; }) { const [settingUp, setSettingUp] = useState(false); + const setupAvailable = agentSetupConfirmationAvailable(); const details = useRef(null); const [checking, setChecking] = useState(false); // Describes one refreshed status; any later status change supersedes it. @@ -91,12 +95,21 @@ export function ManagedAgentActions({

Imported, not started.{" "} {agent.configured === false - ? "Choose Use here to set up this identity in a community." + ? setupAvailable + ? "Choose Use here to set up this identity in a community." + : "It is not set up in a community yet." : "Start it when you are ready."}

)} {agent.configured === false && - (state.data?.localInventoryActions && control.configureHere ? ( + (onUseHere && setupAvailable ? ( + + ) : state.data?.localInventoryActions && control.configureHere ? ( {}} importedId={null} + onUseHere={() => {}} /> ); const mounted = render(view()); diff --git a/src/bundled/agents/UnifiedInventory.tsx b/src/bundled/agents/UnifiedInventory.tsx index dbddac7cd..66ad26c66 100644 --- a/src/bundled/agents/UnifiedInventory.tsx +++ b/src/bundled/agents/UnifiedInventory.tsx @@ -21,6 +21,7 @@ export function UnifiedInventory({ duplicate, remove, importedId, + onUseHere, }: { state: AgentControlState; control: AgentControl; @@ -29,6 +30,7 @@ export function UnifiedInventory({ duplicate?: ((agent: AgentView) => void) | undefined; remove?: ((agent: AgentView) => void) | undefined; importedId: string | null; + onUseHere(pubkey: string): void; }) { const { agentLibrary: library, archives, profiles } = connection.session; const publicProfiles = useSyncExternalStore( @@ -101,6 +103,7 @@ export function UnifiedInventory({ duplicate={duplicate} remove={remove} importedId={importedId} + onUseHere={onUseHere} > {data.inventoryWarnings?.map((warning) => (

diff --git a/src/bundled/agents/inventory-decisions.ts b/src/bundled/agents/inventory-decisions.ts index 69081e69b..8df13cf06 100644 --- a/src/bundled/agents/inventory-decisions.ts +++ b/src/bundled/agents/inventory-decisions.ts @@ -30,5 +30,15 @@ export function inventoryDecision( : row.oldBuzzSources.length ? importGroup : relayGroup; - return { group }; + const action = + row.localIdentity && row.localSetups.size === 0 + ? destination + ? "use" + : "wait" + : "unavailable"; + const blocked = + action === "wait" + ? "Connect to a destination community to set up this identity." + : undefined; + return { group, action, blocked }; } diff --git a/src/features/communities/api.ts b/src/features/communities/api.ts index 91dea2b13..60cb57a29 100644 --- a/src/features/communities/api.ts +++ b/src/features/communities/api.ts @@ -13,6 +13,12 @@ export type CommunityInfo = { age_attestation_required: boolean; } | null; }; +/** + * Use here needs the destination to confirm the agent with its owner's key. + * Only the development broker serves that confirmation; the packaged adapter + * rejects it, so callers must not offer the action there. + */ +export const agentSetupConfirmationAvailable = () => !nativeIdentityEnabled(); export async function communityRequest( id: string, route: string, diff --git a/tests/browser/agent-use-here.spec.mjs b/tests/browser/agent-use-here.spec.mjs new file mode 100644 index 000000000..41425b0a1 --- /dev/null +++ b/tests/browser/agent-use-here.spec.mjs @@ -0,0 +1,47 @@ +import { test, expect } from "@playwright/test"; +import { createServer } from "./vite-server.mjs"; +import config from "../fixtures/agent-control.vite.mjs"; + +test("Use here opens a visible dialog at desktop and narrow widths", async ({ + page, +}) => { + const server = await createServer({ + ...config, + configFile: false, + logLevel: "error", + server: { host: "127.0.0.1", port: 0, strictPort: false }, + }); + await server.listen(); + try { + await page.goto( + `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/agent-control.html`, + ); + await page.evaluate(async () => { + const f = window.agentControlFixture; + f.data.parked = []; + Object.assign(f.agent, { + configured: false, + enabled: false, + status: "stopped", + runningRevision: null, + }); + await f.control.refresh(); + }); + for (const width of [1280, 390]) { + await page.setViewportSize({ width, height: 800 }); + await page.getByRole("button", { name: "Use here", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Set up agent here" }); + await expect(dialog).toHaveCSS("position", "fixed"); + await expect(dialog).toBeInViewport({ ratio: 1 }); + await expect( + dialog.getByRole("button", { name: "Use here", exact: true }), + ).toBeInViewport({ ratio: 1 }); + const close = dialog.getByRole("button", { name: "Close", exact: true }); + await expect(close).toBeInViewport({ ratio: 1 }); + await close.click(); + await expect(dialog).toHaveCount(0); + } + } finally { + await server.close(); + } +});