diff --git a/doc/connections/slack-setup.md b/doc/connections/slack-setup.md index 18c8544ce6c..e167017e84f 100644 --- a/doc/connections/slack-setup.md +++ b/doc/connections/slack-setup.md @@ -24,6 +24,10 @@ Creation, installation, message waiting, and success use the same production components and server evidence as the own-app flow. A click or OAuth return does not constitute successful configuration. +Reopening a completed Slack setup retains its Slack handoff. **Done** opens the +saved connection settings without repeating activation or reactivating a paused +bot. Unfinished setups still complete their normal server verification first. + ## Cloud deployment prerequisites Managed setup is disabled by default. Qualification requires all of: diff --git a/tests/e2e/chat-adapters-ui-providers.spec.ts b/tests/e2e/chat-adapters-ui-providers.spec.ts index 56f515e0e61..5b4679b6ec4 100644 --- a/tests/e2e/chat-adapters-ui-providers.spec.ts +++ b/tests/e2e/chat-adapters-ui-providers.spec.ts @@ -20,6 +20,22 @@ import { GITHUB_PRIVATE_KEY_PASTE_FIXTURE, } from "./chat-adapters-ui.shared"; +async function expectCompletedSlackSetupCloses(page: Page, seed: Seed) { + let finishRequests = 0; + await page.route("**/api/chat-endpoints/endpoint-slack/finish", async route => { + finishRequests++; + await route.fulfill({ status: 409, json: { error: "This connection is not waiting for a setup test" } }); + }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&toolHref=%2Fapps%2Fconnect%3Fsource%3Dslack&resume=endpoint-slack`); + await expect(page.getByRole("heading", { name: "Success", exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "Open Slack", exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Done", exact: true }).click(); + await expect(page).toHaveURL(/\/apps\/chat\/endpoint-slack\/settings$/); + await page.reload(); + await expect(page.getByRole("link", { name: "Open Slack", exact: true })).toBeVisible(); + expect(finishRequests).toBe(0); +} + async function findConnectorBySearch(page: Page, query: string, slug: string) { const search = page.getByRole("searchbox", { name: "Search connectors" }); await expect(search).toBeVisible(); @@ -187,6 +203,8 @@ test.describe.serial("native chat adapter UI", () => { await expect(page.getByRole("navigation", { name: "Connection setup progress" }).getByRole("button")).toHaveCount(2); await page.getByRole("button", { name: "Done", exact: true }).click(); await expect(page).toHaveURL(/\/apps\/chat\/endpoint-slack\/settings$/); + await expectCompletedSlackSetupCloses(page, seed); + expect(mock.slackCreations).toBe(1); expect(mock.slackInstallations).toBe(1); }); test("Slack: a failed manager refresh reloads workspace authorization before retry", async ({ page }) => { const slack = PROVIDERS.find(provider => provider.provider === "slack")!; @@ -379,6 +397,8 @@ test.describe.serial("native chat adapter UI", () => { await page.screenshot({ path: test.info().outputPath("automatic-slack-success.png"), fullPage: true }); await page.getByRole("button", { name: "Done", exact: true }).click(); await expect(page).toHaveURL(/\/apps\/chat\/endpoint-slack\/settings$/); + await expectCompletedSlackSetupCloses(page, seed); + expect(mock.slackCreations).toBe(1); expect(mock.slackInstallations).toBe(2); const stored = await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage } })); expect(stored).not.toContain("fixture-config-token"); }); diff --git a/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx b/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx index dc6c52282e3..e2bae6a68e9 100644 --- a/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx +++ b/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({ createLinkIntent: vi.fn(), confirmIdentityLink: vi.fn(), pushToast: vi.fn(), + navigate: vi.fn(), setBreadcrumbs: vi.fn(), search: "", setParams: vi.fn(), @@ -57,7 +58,7 @@ vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: false }), })); vi.mock("@/lib/router", () => ({ - useNavigate: () => vi.fn(), + useNavigate: () => mocks.navigate, useLocation: () => ({ state: null }), useParams: () => ({ endpointId: "endpoint-a", tab: mocks.tab }), useSearchParams: () => [new URLSearchParams(mocks.search), mocks.setParams], @@ -505,6 +506,47 @@ describe("chat setup and identity-link clipboard actions", () => { expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("4Send a message"); }); + it.each( + (["managed", "automatic", "manual"] as const).flatMap(method => + (["active", "paused"] as const).map(status => ({ method, status }))), + )("opens settings when Done resumes a completed $method Slack connection that is $status", async ({ method, status }) => { + const endpoint = await render("slack"); + const saved: ChatEndpoint = { ...endpoint, status, providerAccountId: "workspace-a", setup: { + ...endpoint.setup!, step: "complete", slackSetupMethod: method, + slackRegistration: { status: "configured", appId: "ATEST" }, + slackAccount: { externalUserId: "UPERSON", paperclipUserId: "owner-user", status: "linked", welcomeStatus: "sent", dmChannelId: "DTEST" }, + } }; + mocks.get.mockResolvedValue(saved); + mocks.finishSlackSetup.mockRejectedValue(new Error("This connection is not waiting for a setup test")); + flushSync(() => client.setQueryData(["chat-endpoint-setup-resume", endpoint.id], saved)); + await settle(); + + await click("Done"); + + expect(mocks.finishSlackSetup).not.toHaveBeenCalled(); + expect(mocks.navigate).toHaveBeenCalledWith("/apps/chat/endpoint-a/settings"); + expect(mocks.pushToast).not.toHaveBeenCalled(); + }); + + it.each(["managed", "automatic"] as const)("still finalizes an unfinished %s Slack setup before opening settings", async method => { + const endpoint = await render("slack"); + const saved: ChatEndpoint = { ...endpoint, status: "verifying", providerAccountId: "workspace-a", setup: { + ...endpoint.setup!, step: "test", slackSetupMethod: method, + webhookVerifiedAt: "2026-01-01T00:00:00Z", + slackRegistration: { status: "configured", appId: "ATEST" }, + slackAccount: { externalUserId: "UPERSON", paperclipUserId: "owner-user", status: "linked", welcomeStatus: "sent", dmChannelId: "DTEST" }, + } }; + mocks.get.mockResolvedValue(saved); + mocks.finishSlackSetup.mockResolvedValue({ ...saved, status: "active", setup: { ...saved.setup, step: "complete" } }); + flushSync(() => client.setQueryData(["chat-endpoint-setup-resume", endpoint.id], saved)); + await settle(); + + await click("Done"); + + expect(mocks.finishSlackSetup).toHaveBeenCalledExactlyOnceWith("endpoint-a"); + expect(mocks.navigate).toHaveBeenCalledWith("/apps/chat/endpoint-a/settings"); + }); + it("keeps settings focused on channel access and the test-message action", async () => { mocks.tab = "settings"; const endpoint = await render("slack", true); diff --git a/ui/src/pages/apps/chat/ChatEndpointSetup.tsx b/ui/src/pages/apps/chat/ChatEndpointSetup.tsx index 3932cee316e..727c2e0c1e1 100644 --- a/ui/src/pages/apps/chat/ChatEndpointSetup.tsx +++ b/ui/src/pages/apps/chat/ChatEndpointSetup.tsx @@ -475,6 +475,13 @@ function ChatSdkEndpointSetup() { tone: "error", }), }); + const finishSetup = () => { + if (provider === "slack" && endpoint?.setup?.step === "complete") { + navigate(`/apps/chat/${endpoint.id}/settings`); + return; + } + testConnection.mutate(); + }; const repairing = isChatEndpointRepairing( endpoint, @@ -671,7 +678,7 @@ function ChatSdkEndpointSetup() {
-