diff --git a/docs/agent-control.md b/docs/agent-control.md index 951ee2c90..adab185e3 100644 --- a/docs/agent-control.md +++ b/docs/agent-control.md @@ -1,8 +1,8 @@ # Local agent controls The Agents page uses one app-owned native controller for creating, importing, -editing and running local agents. Managed cards are keyed by exact identity and -community. Browser-only access keeps the read-only old library; it cannot run +editing and running local agents. Inventory cards join records by exact public key; +managed actions remain keyed by native identity/community record. Browser-only access keeps the read-only old library; it cannot run agents. The only product entry point is ordinary desktop startup. ## Normal desktop workflow @@ -16,16 +16,17 @@ configuration and persistent native settings. Coordinate the native rebuild/rela quit other Foundation copies first. Saved enabled agents can restore on startup. Keep imported agents disabled and old Buzz running until an attended handover. -Open **Agents → My agents** for imported identities, their destination community, -process evidence and visible **Start / Stop**. **Edit**, **Duplicate**, and -**Delete** are in the card’s three-dot menu. Duplicate seeds Create with editable -settings and a fresh identity; write-only environment values require re-entry. -Delete stops the local process and removes this app's settings and Keychain key -after confirmation. It does not archive the relay identity or erase messages. -Deployed remote records are refused. -Same-key identities at different destinations have separate -cards; actions use native ID/revision, never the display name. Managed controls -remain available when the old library is disconnected, unavailable or archived. +Open **Agents** for discovered and imported identities grouped by known community +associations. Each identity card keeps the configured destinations’ **Start / Stop** +controls. **Edit**, **Duplicate**, and **Delete** are in the card’s three-dot menu. +Duplicate seeds Create with editable settings and a fresh identity; write-only +environment values require re-entry. Delete stops the local process and removes +this app's settings and Keychain key after confirmation. The key remains while +another setup of the same identity still uses it. Delete does not archive the +relay identity or erase messages. Deployed remote records are refused. Actions +use native ID/revision, never the display name. Older hosts without parked +inventory retain the **My agents** and read-only library sections. Managed +controls remain available when discovery is disconnected, unavailable or archived. **Add agent** shares the Edit fields and model browser. In the development desktop, Create generates a native key, obtains the captured viewer's owner authorization, @@ -50,12 +51,38 @@ worker count. With **Each thread** conversation context, separate threads can use different workers while retaining separate histories. Existing saved agents keep their settings; add the variable and restart them to enable more workers. -**Not imported from old Buzz** is a separate collapsible section. Expanding it -loads installed identities for the connected community; already-managed exact -identities are excluded. Each remaining row says **Not imported** and has its own -**Import** action. Source/destination overrides and source warnings stay under -Import options. Import focuses the imported card and says **Imported, not started**. -It does not start a listener, invite an agent or change the old library. +**Clone to this community** opens the existing creation dialog with only the old +agent’s name and resolved instructions. Review that text for embedded secrets. +Runtime settings and workspace use this app’s defaults and remain editable. +Clone generates a new native identity; it does not copy identity keys, environment +values, command arguments, paths, history or membership. Saving leaves the new +agent stopped. The source is read-only and no legacy credential access occurs. + +**Import** preserves the selected old-installation identity and private key. It +requires an explicit destination and a fresh source/destination-bound preview. +Successful import saves a configured, stopped setup. It does not start a listener, +invite an agent, or modify the source installation. An identity already held +locally cannot be imported again into another community; use **Clone** instead. +The native prepare and commit boundaries both enforce that exact-key rule. + +The unified inventory offers Import only under **Available to import**, once per +exact public key. Multiple old installations require an explicit source choice. +The selected row opens the import review. Without a selected community, such +as in Personal space, the review asks for the destination before it loads a +fresh preview. Each startup reads every old installation again: an identity +deleted from all of them, or whose installation is removed, leaves the list. A +damaged source keeps its previous entries. Source read failures remain visible. +Older hosts retain the separate installation browser as a compatibility path. + +**Use here** is recovery for older incomplete local imports, not a normal next +step after Import. It retains the identity/key, requires owner-authorized community +confirmation, and leaves the recovered setup stopped with app-launch start off. +The development broker and the packaged desktop app both provide that +confirmation; the desktop app signs it natively (`relay_agent_resolve`). Native +code refuses a new +community when that identity already has a configured setup elsewhere. A retry +for the already recovered destination is harmless. Existing historical setups +remain visible and controllable; this rule does not move or delete them. Local team-linked imports snapshot the deployment team's instructions from the chosen library's `agents/teams.json`, alongside the resolved persona prompt. @@ -74,7 +101,7 @@ perform the same attended old-Buzz handover as for a fresh import. To use an agent, open a channel and select it from **@ mentions**. Both mention menus include the selected community’s people directory alongside channel members -and managed agents. Directory reads are bounded; narrow the search for more people. +and configured managed agents. Directory reads are bounded; narrow the search for more people. A nonmember is labeled **Not in channel · Choose whether to add when you send**. Selection alone does nothing. Send asks, as block/buzz desktop does: **Invite** or **Do nothing**. Without add permission, the only action is **Send anyway**. @@ -89,7 +116,7 @@ before a new add; unknown outcomes are never silently replaced. Channel, thread, and forum-channel composers share this behavior. DM participants and session admission rules are unchanged. -A confirmed outgoing channel or thread mention now starts an exact imported local +Once an identity is configured by Import or legacy **Use here** recovery, a confirmed outgoing channel or thread mention starts that exact local agent (public key + community), without a separate Start click. Import itself remains non-starting. Stop cancels earlier pending mention wakes and active work; a later deliberate mention can start the agent again. Plain name text without @@ -554,8 +581,10 @@ Settings says **Shell setup not verified**; Buzz does not check it before Start. Snapshots name the deciding key (`launchModelEnv`/`launchProviderEnv`, including `DATABRICKS_MODEL` or a hidden provider behind a blank buzz-agent model) and omit the resolved value. -- Import previews only the chosen installed/development library and requires an - explicit secure **Destination community** origin. Old Buzz ignores saved relay +- Local browsing reads only the chosen installed/development library without a + destination. Native keeps no pending import for that read and invalidates any + prior import token. An actionable import preview requires an explicit secure + **Destination community** origin. Old Buzz ignores saved relay pins at runtime; blank, stale or malformed saved pins do not route or hide identities here. Native validates the chosen destination, shows it beside each exact key, and retains it with the preview token through commit. Source or @@ -818,3 +847,50 @@ configuration. The current internal release repository builds the old desktop; its generic build environment injection is not a Pi resource-bundling contract for this app. Signed bundling, automatic employee provisioning and release pipeline migration require separate release work; no release is published here. + +### Community setup confirmation + +Local installation import validates the source configuration, owner authorization +and private key. It does not require relay inventory or a community confirmation. +The imported agent stays stopped. + +For explicit setup in a community, the broker can sign the selected owner's +intent for an identity/community pair. Native code verifies that signature against +the retained source-owner authorization. This does not establish channel membership, +key availability or exclusive community membership. It does not reserve a community +before import. Setup recovery cannot add another community to an identity that +already has a configured setup elsewhere; copying that agent requires Clone. + +Joined-community discovery reads each joined community's scoped inventory without +selecting it or opening a relay session. Exact keys appear once with all known +associations. Each failed community read has its own warning and Refresh retry; +successful reads remain visible. Discovery does not provide credentials, an import +source, or permission to extend an existing local identity into another community. + +### Local inventory actions + +Startup copies only identity names, public keys and source labels into a durable +inventory. It does not read keys, configure a setup, or start an imported agent. +Import copies the selected local key and settings, independent of relay inventory. +New imports require a destination and are saved configured but stopped. +**Use here** only recovers older incomplete imports. **Start** remains a separate +action; a later deliberate mention can also start a configured agent. Existing +saved setups without the configured flag keep their prior behavior. + +The unified card's **Import** opens the existing installation form with its exact +identity and known local source selected. The source is fixed during review; +if the preview fails or the identity is unavailable there, you can choose +another source. **Clone** +from a local source or imported identity opens a review of only its name and +instructions; creation generates a fresh key. Clone never imports the old key. + +Community groups show known associations, not exclusive membership or admission. +An inventory failure does not block local Import or setup confirmation. Every +configured setup keeps Start, Stop and Edit, whichever community is selected. +Cards for other communities also offer Clone to bring a new identity here, +without changing the source. +Archived discovery rows remain hidden after sources join, except where local +controls must remain reachable. Linked profiles remain visible on identity cards. + +Before starting an imported identity, stop the old agent and disable its automatic +startup in the old application. Do not run duplicate copies of the same identity. diff --git a/docs/agents.md b/docs/agents.md index a63d813b4..a3bbb1e3d 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -23,14 +23,14 @@ add-existing membership, Save/recovery and all runner management are out of V1. - Only definition ID/name, identity public key/name/definition link, and optional avatar artwork leave the host. Prompts, configuration, credentials and execution receipts are not projected. This is local library evidence, **not verified ownership**. -- Selected definitions remain one card each, including definitions without an - identity. Exact linked keys remain available in each card’s identity disclosure, including namesakes; unlinked and - unmatched identities use Custom agents/Other identities groupings. Unlike old Buzz's - runtime-dependent representative selection, this read-only view shows all - non-archived linked keys and has no profile/start action or running badge. -- Confirmed relay archives hide identity rows, not definition cards. Missing - archive evidence is labeled; it does not erase the saved library. This is - display behavior, never mention permission. +- The library shows one tile per exact identity, grouped only by explicit profile + links. Each tile discloses its full public key. Profiles with no linked identity + appear separately; an archived identity does not become an empty profile. +- Only distinct keys with the same displayed name need a short npub suffix. Names + alone never create a profile group. Suffix collisions extend deterministically using + the complete inventory, including identities hidden by archive filtering. +- Missing archive evidence keeps identities visible. Archive filtering affects + display only, never mention permission or runtime control. - One lazy host read per opening/Refresh; no polling or relay-directory startup scan. Concurrent host requests coalesce. Read caps: 8 MiB / 2000 records; malformed/missing files fail visibly without echoing their contents. The host @@ -520,5 +520,21 @@ service. Each relay session binds its own view. A ready native record takes precedence only in its matching community; otherwise the ready legacy display inventory supplies the name, then the public profile. Plugin disable restores public-profile names. These labels never change identity keys, membership, -credentials, or runtime admission. Profile panels consume this view; other name -surfaces are being migrated separately. +credentials, or runtime admission. Profile panels, messages, mention choices, +activity, conversation labels and new notifications consume this view. Mention +parsing still uses signed identity evidence before resolving its visible label. + +### Additive community inventory + +The active session reads the owner's kind-30175 profiles and kind-30177 identities +from its accessible relay. It also retains the local library reader. The inventory +joins exact public keys, not equal names; explicit profile references use the +publisher's slug mapping only when local definitions do not collide. Local names +and artwork win for matching keys. Native configuration still wins within its +matching community. A failed source leaves the other source visible with a warning. + +Discovery is not global coverage, verified membership, credentials, or execution +status. Native cards keep their controls. Other known identities appear in a +read-only section. Each card offers its own Import; the separate installation +browser appears only for repair. +No keys, config, memory, membership, or runtime state are changed by discovery. diff --git a/src/app/agent-control.integration.test.ts b/src/app/agent-control.integration.test.ts index eb5b2f923..aaba33d57 100644 --- a/src/app/agent-control.integration.test.ts +++ b/src/app/agent-control.integration.test.ts @@ -113,9 +113,21 @@ it("retains working injected control across Agents disable/re-enable and communi .find((entry) => entry.pluginId === "buzz.agents"); // The production registration wrapper passes its injected capability as props. const component = page?.component as unknown as () => { - props: { control: AgentControl }; + props: { + control: AgentControl; + communities: Pick< + typeof services.communities, + "snapshot" | "subscribe" + >; + }; }; - return component().props.control; + const props = component().props; + expect(Object.keys(props.communities).sort()).toEqual([ + "snapshot", + "subscribe", + ]); + expect(props.communities.snapshot()).toBe(services.communities.snapshot()); + return props.control; }; expect(injectedControl()).toBe(control); await control.refresh(); diff --git a/src/bundled/agents/AgentsPage.test.tsx b/src/bundled/agents/AgentsPage.test.tsx index 12fe0e510..248c4690a 100644 --- a/src/bundled/agents/AgentsPage.test.tsx +++ b/src/bundled/agents/AgentsPage.test.tsx @@ -561,13 +561,14 @@ it("focuses the imported managed identity without starting it", async () => { fireEvent.click( await screen.findByRole("button", { name: "Import Fixture agent" }), ); - const notice = await screen.findByText( - "Imported, not started. Start it when you are ready.", - ); + const notice = await screen.findByText(/Imported, not started\./); const imported = notice.closest("article"); if (!imported) throw Error("Imported card missing"); expect(imported).toHaveTextContent("wss://third.example"); expect(notice.parentElement).toHaveFocus(); + expect( + within(imported).queryByRole("button", { name: "Use here" }), + ).toBeNull(); expect(within(imported).getByRole("button", { name: "Start" })).toBeEnabled(); expect(f.calls.some((call) => call.action === "start")).toBe(false); }); @@ -1241,9 +1242,7 @@ it("credential import keeps real Stop controls reachable without trapping the ed await gate; }); await waitFor(() => expect(control.snapshot().busy).toBe(false)); - expect( - screen.queryByText("Imported, not started. Start it when you are ready."), - ).toBeNull(); + expect(screen.queryByText(/Imported, not started\./)).toBeNull(); await act(async () => control.refresh()); const imported = control .snapshot() @@ -2680,9 +2679,7 @@ it("uses snapshot capabilities rather than JS wrappers and retains older-host im fireEvent.click( await screen.findByRole("button", { name: "Import Fixture agent" }), ); - const notice = await screen.findByText( - "Imported, not started. Start it when you are ready.", - ); + const notice = await screen.findByText(/Imported, not started\./); const card = notice.closest("article"); if (!card) throw Error("Imported card missing"); expect(within(card).getByRole("button", { name: "Start" })).toBeEnabled(); diff --git a/src/bundled/agents/InventoryIdentityCard.tsx b/src/bundled/agents/InventoryIdentityCard.tsx index 391310649..255005905 100644 --- a/src/bundled/agents/InventoryIdentityCard.tsx +++ b/src/bundled/agents/InventoryIdentityCard.tsx @@ -187,7 +187,6 @@ export function InventoryIdentityCard({ state.busy || state.status !== "ready" || !!decision.blocked || - !data.localInventoryActions || !control.configureHere } onClick={() => onUseHere(row.pubkey, "use")} @@ -195,11 +194,6 @@ export function InventoryIdentityCard({ Use here {decision.blocked &&
{decision.blocked}
} - {!data.localInventoryActions && ( -- Restart an updated desktop build to use local inventory actions. -
- )} > )} {(tile || decision.action === "clone") && cloneAction} diff --git a/src/bundled/agents/ManagedAgentActions.tsx b/src/bundled/agents/ManagedAgentActions.tsx index 05e6cd7dc..058cd1c58 100644 --- a/src/bundled/agents/ManagedAgentActions.tsx +++ b/src/bundled/agents/ManagedAgentActions.tsx @@ -109,7 +109,7 @@ export function ManagedAgentActions({ > Use here - ) : state.data?.localInventoryActions && control.configureHere ? ( + ) : control.configureHere ? (Update the desktop app to set up this imported identity.
- ))} + ) : null)} {agent.startOnAppLaunch && (Starts with this app.
)} diff --git a/tests/browser/agent-control.spec.mjs b/tests/browser/agent-control.spec.mjs index 23c74ded6..847be81d5 100644 --- a/tests/browser/agent-control.spec.mjs +++ b/tests/browser/agent-control.spec.mjs @@ -1191,3 +1191,340 @@ test("rejected import preview keeps inputs and recovers through Load agents", as await server.close(); } }); + +test("card Import opens a focused review and restores focus after dismissal", async ({ + page, +}, testInfo) => { + 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.route("**/api/relay/*/agent-inventory", (route) => + route.fulfill({ json: { identities: [] } }), + ); + await page.goto( + `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/agent-control.html`, + ); + await closeEditor(page); + await page.evaluate(async () => { + const fixture = window.agentControlFixture; + fixture.data.parked = [ + { + pubkey: "cd".repeat(32), + name: "Selected import", + sources: ["development"], + }, + ]; + await fixture.control.refresh(); + }); + const card = page.getByRole("article", { name: "Agent Selected import" }); + await expect(card).toBeVisible(); + // A tall inventory must not put the selected import review below the viewport. + await page + .getByRole("region", { name: "My agents" }) + .evaluate((element) => { + element.style.minHeight = "2500px"; + }); + await card.getByRole("button", { name: "Import", exact: true }).click(); + const dialog = page.getByRole("dialog", { + name: "Import Selected import?", + }); + await expect(dialog).toBeInViewport(); + await expect + .poll(() => dialog.evaluate((el) => el.contains(document.activeElement))) + .toBe(true); + await expect( + page.getByRole("button", { name: "Import from another installation" }), + ).toHaveCount(0); + await expect( + dialog.getByRole("button", { name: "Import agent" }), + ).toBeEnabled(); + await expect( + dialog.getByText("Development Buzz", { exact: true }), + ).toBeVisible(); + await expect(dialog.getByLabel("Source library")).toHaveCount(0); + await expect( + dialog.getByRole("button", { name: /Clone|Load agents/ }), + ).toHaveCount(0); + await expect(dialog.getByText("Identity", { exact: true })).toHaveCount(0); + await dialog.getByRole("button", { name: "Cancel" }).focus(); + await page.keyboard.press("Shift+Tab"); + await expect( + dialog.getByRole("button", { name: "Import agent" }), + ).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused(); + await dialog.screenshot({ + path: testInfo.outputPath("import-healthy.png"), + }); + await page.setViewportSize({ width: 360, height: 480 }); + await dialog + .getByRole("button", { name: "Import agent" }) + .scrollIntoViewIfNeeded(); + await expect( + dialog.getByRole("button", { name: "Import agent" }), + ).toBeInViewport(); + expect( + await dialog.evaluate((el) => el.scrollWidth <= el.clientWidth), + ).toBe(true); + await dialog.screenshot({ path: testInfo.outputPath("import-narrow.png") }); + await page.setViewportSize({ width: 1440, height: 950 }); + await expect(dialog).toHaveAttribute("aria-modal", "true"); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + const trigger = card.getByRole("button", { name: "Import", exact: true }); + await expect(trigger).toBeFocused(); + await trigger.click(); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); + await expect(dialog).toHaveCount(0); + await expect(trigger).toBeFocused(); + await page.evaluate(() => { + const fixture = window.agentControlFixture; + const commit = fixture.host.commitImport; + const gate = new Promise((resolve) => { + fixture.releaseImport = resolve; + }); + fixture.host.commitImport = async (...args) => { + await gate; + return commit(...args); + }; + }); + await trigger.click(); + await dialog + .getByRole("button", { name: "Import agent", exact: true }) + .click(); + await expect( + dialog.getByRole("button", { name: "Importing…" }), + ).toBeDisabled(); + await expect(dialog).toHaveAttribute("aria-modal", "false"); + const local = page.getByRole("article", { + name: "Agent Fixture agent", + exact: true, + }); + await expect( + local.getByRole("button", { name: "Stop", exact: true }), + ).toBeEnabled(); + await local.getByRole("button", { name: "Stop", exact: true }).click(); + await page.evaluate(() => window.agentControlFixture.releaseImport()); + // The host's rejection explains the conflicting Stop. + await expect(dialog.getByRole("alert")).toContainText( + "Could not confirm the operation", + ); + await expect(dialog).toHaveAttribute("aria-modal", "true"); + await dialog.screenshot({ path: testInfo.outputPath("import-error.png") }); + await dialog.getByRole("button", { name: "Cancel", exact: true }).click(); + await page.evaluate(() => window.agentControlFixture.control.refresh()); + await expect(dialog).toHaveCount(0); + await expect( + page + .getByRole("article", { name: "Agent Fixture agent", exact: true }) + .filter({ + hasText: + "npub1ehxumnwdehxumnwdehxumnwdehxumnwdehxumnwdehxumnwdehxskccvaq", + }), + ).toContainText("Process stopped"); + } finally { + await page + .evaluate(() => window.agentControlFixture?.releaseImport?.()) + .catch(() => {}); + await server.close(); + } +}); + +test("inventory keeps current-community tiles and compact rows without repeated detail", async ({ + page, +}, testInfo) => { + 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.route("**/api/relay/register", (route) => { + expect(route.request().postDataJSON()).toEqual({ + url: "https://relay.example.test", + }); + return route.fulfill({ json: {} }); + }); + await page.route("**/api/relay/*/agent-inventory", (route) => + route.fulfill({ + json: { identities: ["ef".repeat(32), "12".repeat(32)] }, + }), + ); + await page.route("**/api/relay/*/query", (route) => { + expect(route.request().postDataJSON()).toEqual([ + { kinds: [0], authors: ["ef".repeat(32), "12".repeat(32)], limit: 500 }, + ]); + return route.fulfill({ json: [] }); + }); + await page.goto( + `http://127.0.0.1:${server.httpServer.address().port}/tests/fixtures/agent-control.html`, + ); + await closeEditor(page); + await page.evaluate(async () => { + const fixture = window.agentControlFixture; + fixture.data.agents.push({ + ...fixture.data.agents[0], + id: "other-community", + pubkey: "56".repeat(32), + name: "Other community agent", + relayUrl: "wss://other.example", + }); + fixture.data.parked = [ + { + pubkey: "cd".repeat(32), + name: "Research assistant with a longer name", + sources: ["development"], + }, + { + pubkey: "34".repeat(32), + name: "Release helper", + sources: ["installed", "development"], + }, + ]; + await fixture.control.refresh(); + }); + const inventory = page.getByRole("region", { + name: "My agents", + exact: true, + }); + const imports = inventory.getByRole("region", { + name: "Available to import", + exact: true, + }); + const relay = inventory.getByRole("region", { + name: "Relay-only agents", + exact: true, + }); + await expect(imports.getByRole("article")).toHaveCount(2); + await expect(relay.getByRole("article")).toHaveCount(2); + await expect(inventory.getByText(/No import source confirmed/)).toHaveCount( + 0, + ); + await expect(inventory.locator("[data-public-key]:visible")).toHaveCount(0); + await expect( + inventory.getByText( + /Known community:|Found in old Buzz:|Discovery does not import/, + ), + ).toHaveCount(0); + const local = inventory.getByRole("article", { + name: "Agent Fixture agent", + exact: true, + }); + await expect( + local.getByRole("button", { name: "Stop", exact: true }), + ).toBeEnabled(); + expect(await local.evaluate((el) => getComputedStyle(el).display)).toBe( + "flex", + ); + const other = inventory + .getByRole("region", { + name: "Local agents in other communities", + exact: true, + }) + .getByRole("article"); + await expect( + inventory.getByRole("heading", { + level: 3, + name: "https://other.example", + exact: true, + }), + ).toBeVisible(); + await expect(other.getByRole("heading", { level: 4 })).toHaveText( + "Other community agent", + ); + await expect(relay.getByRole("heading", { level: 3 })).toHaveCount(1); + await expect(relay.getByRole("region").getByRole("article")).toHaveCount(2); + await expect(other).toHaveClass(/agent-inventory-row/); + await expect( + other.getByText("wss://other.example", { exact: true }), + ).toHaveCount(0); + // The app runs this setup, so its row keeps its own lifecycle control. + await expect( + other.getByRole("button", { name: "Stop", exact: true }), + ).toHaveCount(1); + await expect( + other.getByRole("button", { name: "Use here", exact: true }), + ).toHaveCount(0); + await expect( + other.getByRole("button", { name: "Clone", exact: true }), + ).toBeVisible(); + const first = imports.getByRole("article").first(); + const second = imports.getByRole("article").nth(1); + for (const width of [1200, 390]) { + await page.setViewportSize({ width, height: 1100 }); + const otherBox = await other.boundingBox(); + expect(otherBox.width).toBeGreaterThan(width === 1200 ? 600 : 250); + const cloneBox = await other + .getByRole("button", { name: "Clone", exact: true }) + .boundingBox(); + expect(cloneBox.x + cloneBox.width).toBeLessThanOrEqual( + otherBox.x + otherBox.width, + ); + const firstBox = await first.boundingBox(); + const secondBox = await second.boundingBox(); + expect(firstBox.x).toBe(secondBox.x); + expect(firstBox.width).toBe(secondBox.width); + expect(secondBox.y).toBeGreaterThanOrEqual(firstBox.y + firstBox.height); + expect(firstBox.height).toBeLessThan(width === 1200 ? 100 : 180); + expect( + await inventory.evaluate((el) => el.scrollWidth <= el.clientWidth), + ).toBe(true); + const relayRow = relay.getByRole("article").first(); + const nameBox = await relayRow.getByRole("heading").boundingBox(); + const detailBox = await relayRow.locator("summary").boundingBox(); + expect(detailBox.x).toBeGreaterThan(nameBox.x + nameBox.width); + expect( + Math.abs( + detailBox.y + detailBox.height / 2 - nameBox.y - nameBox.height / 2, + ), + ).toBeLessThan(2); + await inventory.screenshot({ + path: testInfo.outputPath(`inventory-${width}.png`), + }); + } + await other + .getByLabel("Details for Other community agent", { exact: true }) + .click(); + await expect( + other.getByRole("button", { name: "Clone", exact: true }), + ).toBeVisible(); + await expect(other.locator("[data-public-key]")).toBeVisible(); + // Edit, Duplicate and Delete stay reachable for a setup outside this community. + await expect( + other.getByRole("button", { name: "Actions for Other community agent" }), + ).toBeVisible(); + const details = first.getByLabel("Details for Release helper", { + exact: true, + }); + await expect( + imports.getByRole("button", { name: "Clone", exact: true }), + ).toHaveCount(0); + await details.click(); + await expect( + first.getByRole("button", { name: "Clone", exact: true }), + ).toBeVisible(); + await expect(first.locator("[data-public-key]")).toBeVisible(); + expect( + await inventory.evaluate((el) => el.scrollWidth <= el.clientWidth), + ).toBe(true); + await details.click(); + const source = first.getByLabel("Old Buzz installation"); + await expect( + first.getByRole("button", { name: "Import", exact: true }), + ).toBeDisabled(); + await source.selectOption("installed"); + await expect( + first.getByRole("button", { name: "Import", exact: true }), + ).toBeEnabled(); + } finally { + await server.close(); + } +}); diff --git a/tests/browser/channel-tabs.spec.mjs b/tests/browser/channel-tabs.spec.mjs index 65b586e0e..8188eabf0 100644 --- a/tests/browser/channel-tabs.spec.mjs +++ b/tests/browser/channel-tabs.spec.mjs @@ -298,18 +298,18 @@ test("crowded tab strip scrolls only horizontally with Add tab fixed and a thin }); const initial = await add.boundingBox(); const height = (await header.boundingBox()).height; - const tabBounds = await workspace.getByRole("tab").first().boundingBox(); + const tabBounds = await list.getByRole("tab").first().boundingBox(); const expectTabPosition = async () => { - const bounds = await workspace.getByRole("tab").last().boundingBox(); + const bounds = await list.getByRole("tab").last().boundingBox(); expect(bounds.y).toBeCloseTo(tabBounds.y, 1); expect(bounds.height).toBeCloseTo(tabBounds.height, 1); }; for (let i = 0; i < 5; i++) { await add.click(); - await expect(workspace.getByRole("tab")).toHaveCount(i + 2); + await expect(list.getByRole("tab")).toHaveCount(i + 2); await expect(workspace.getByRole("searchbox")).toBeFocused(); } - const tabs = workspace.getByRole("tab"); + const tabs = list.getByRole("tab"); const closeButtons = workspace.getByRole("button", { name: "Close New tab tab", exact: true, @@ -346,14 +346,14 @@ test("crowded tab strip scrolls only horizontally with Add tab fixed and a thin expect(geometry.trackHeight).toBe("4px"); expect((await add.boundingBox()).x).toBeCloseTo(initial.x, 1); expect((await header.boundingBox()).height).toBeCloseTo(height, 1); - await workspace.getByRole("tab").last().press("Home"); - await expect(workspace.getByRole("tab").first()).toBeFocused(); + await list.getByRole("tab").last().press("Home"); + await expect(list.getByRole("tab").first()).toBeFocused(); await expect(closeButtons.first()).toHaveCSS("opacity", "1"); await expect(list).toHaveJSProperty("scrollTop", 0); await expectTabPosition(); expect((await add.boundingBox()).x).toBeCloseTo(initial.x, 1); - await workspace.getByRole("tab").first().press("End"); - await expect(workspace.getByRole("tab").last()).toBeFocused(); + await list.getByRole("tab").first().press("End"); + await expect(list.getByRole("tab").last()).toBeFocused(); await expect(list).toHaveJSProperty("scrollTop", 0); await expectTabPosition(); expect((await add.boundingBox()).x).toBeCloseTo(initial.x, 1); diff --git a/tests/fixtures/agent-control.tsx b/tests/fixtures/agent-control.tsx index 6a549c261..d47bfeae0 100644 --- a/tests/fixtures/agent-control.tsx +++ b/tests/fixtures/agent-control.tsx @@ -81,7 +81,13 @@ window.fetch = async (input, init) => { attachmentUploads: true, }); if (url.endsWith("/query")) { - const filters = JSON.parse(String(init?.body)) as { kinds?: number[] }[]; + const filters = JSON.parse(String(init?.body)) as { + kinds?: number[]; + authors?: string[]; + }[]; + // Profile reads for discovered inventory identities stay routable too. + if (!filters.some((filter) => filter.authors?.includes(profileViewer))) + return networkFetch(input, init); return Response.json( filters.some((filter) => filter.kinds?.includes(0)) && profiles.has(id) ? [profiles.get(id)]