From 9dc539b21e3f9c68fb5c4aa60b9bd12ce3113c35 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 17:04:57 -0700 Subject: [PATCH 1/2] Add tests for onboarding's local Ollama connect and a skip escape hatch Covers the Ollama (local) provider card end to end (default URL prefilled, submitting sends it as baseURL with the fixed placeholder key, and the account lands on the workbench) and a "Skip for now" control that hands off without hitting the credential-complete route. --- apps/web/test/onboarding.test.tsx | 165 +++++++++++++++++++++++++++++- 1 file changed, 164 insertions(+), 1 deletion(-) diff --git a/apps/web/test/onboarding.test.tsx b/apps/web/test/onboarding.test.tsx index bb9b79c8..1749b9c8 100644 --- a/apps/web/test/onboarding.test.tsx +++ b/apps/web/test/onboarding.test.tsx @@ -10,7 +10,10 @@ import { act, createElement } from "react"; import { createRoot } from "react-dom/client"; import type { Root } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; -import { supportedCredentialProviders } from "@workbench/hub-client/credential-test"; +import { + OLLAMA_PLACEHOLDER_SECRET, + supportedCredentialProviders, +} from "@workbench/hub-client/credential-test"; import { App } from "../src/app"; import { NavigationProvider } from "../src/navigation"; @@ -1123,3 +1126,163 @@ describe("OnboardingPage resuming a bench_unseeded account", () => { ); }); }); + +describe("connecting a local Ollama instance from onboarding", () => { + const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + function findByRoleText( + container: HTMLElement, + role: string, + text: string, + ): HTMLElement { + const match = Array.from( + container.querySelectorAll(`[role="${role}"]`), + ).find((el) => el.textContent?.includes(text)); + if (match === undefined) { + throw new Error(`no [role="${role}"] element containing "${text}"`); + } + return match; + } + + function findButtonByText(container: HTMLElement, text: string): HTMLElement { + const match = Array.from(container.querySelectorAll("button")).find((el) => + el.textContent?.includes(text), + ); + if (match === undefined) { + throw new Error(`no button containing "${text}"`); + } + return match; + } + + test("picking the Ollama card prefills its base URL, and submitting connects it and marks the tenant as having a usable model", async () => { + let completeRequestBody: unknown = null; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + if (url === "/api/onboarding/provision") { + return json({ kind: "existing-member", seeded: false }); + } + if (url === "/api/onboarding/complete") { + completeRequestBody = JSON.parse((init?.body as string) ?? "{}"); + // The hub's own connect route is what actually marks the tenant + // as having a usable model (persisting the credential + seeding + // its catalog) — this response is what that route answers once + // it has. The onboarding page's job, proven below, is sending + // the URL as `baseURL` with the fixed Ollama placeholder secret, + // and moving on once this comes back. + return json({ + kind: "ready", + tenantId: "ten_1", + tenantSlug: "ada-user1", + deployed: ["echo"], + pending: [], + }); + } + throw new Error(`unexpected fetch: ${url}`); + }) as unknown as typeof fetch; + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const { navigate, calls } = trackedNavigate(); + try { + act(() => { + root.render( + , + ); + }); + await settle(); + + act(() => { + findByRoleText(container, "radio", "Ollama (local)").click(); + }); + + const urlInput = container.querySelector( + "#onboarding-provider-url", + ); + expect(urlInput).not.toBeNull(); + expect(urlInput?.value).toBe("http://localhost:11434"); + + act(() => { + findButtonByText(container, "Connect this address").click(); + }); + await settle(); + + expect(completeRequestBody).toEqual({ + provider: "ollama", + apiKey: OLLAMA_PLACEHOLDER_SECRET, + baseURL: "http://localhost:11434", + }); + expect(calls).toEqual(["/"]); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); +}); + +describe("skipping the onboarding credential step", () => { + const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + function findButtonByText(container: HTMLElement, text: string): HTMLElement { + const match = Array.from(container.querySelectorAll("button")).find((el) => + el.textContent?.includes(text), + ); + if (match === undefined) { + throw new Error(`no button containing "${text}"`); + } + return match; + } + + test("skipping hands off to the workbench without connecting a provider", async () => { + // A bench with no provider ready yet — this is the anticipated + // `bench_unseeded` state, not an error (see `handleSkip`'s own + // comment): skipping must never call the credential-complete route. + globalThis.fetch = (async (url: string) => { + if (url === "/api/onboarding/provision") { + return json({ kind: "existing-member", seeded: false }); + } + throw new Error(`unexpected fetch: ${url}`); + }) as unknown as typeof fetch; + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const { navigate, calls } = trackedNavigate(); + try { + act(() => { + root.render( + , + ); + }); + await settle(); + + act(() => { + findButtonByText(container, "Skip for now").click(); + }); + + expect(calls).toEqual(["/"]); + } finally { + act(() => root.unmount()); + container.remove(); + } + }); +}); From 3259680869eabcabdb0f5b14b38a95ef1dab09f2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 17:05:09 -0700 Subject: [PATCH 2/2] Onboarding: let a person skip the credential step with nothing to connect yet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring-your-own-AI already covers Ollama and every other supported provider, but the credential step had no way through for someone whose provider isn't ready yet (a local Ollama that isn't running, or no key in hand at all). Provisioning already treats that as bench_unseeded, not an error, so skipping just hands off to the workbench the same way a confirmed credential does — the no-usable-model banner there is what tells the person, honestly, that a connection still needs finishing. --- apps/web/src/app.css | 17 ++++++++++++++++ apps/web/src/pages/onboarding-page.tsx | 27 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/apps/web/src/app.css b/apps/web/src/app.css index f44313b9..e3b8c29c 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -2125,6 +2125,23 @@ select:disabled, border-radius: 0; } +/* The escape hatch below the form: low-emphasis on purpose, since + connecting a provider is still the primary path — this is only for the + account that genuinely has nothing to paste yet. */ +.onboarding-credential-skip { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + margin-top: -0.25rem; +} + +.onboarding-credential-skip-hint { + margin: 0; + font-size: 0.8125rem; + color: var(--muted-foreground); +} + /* The one-click paths sit above the key forms, side by side in a two-column row so both fit without stacking the page tall — each card stays square-cornered like the rest of the wizard, with the shell diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 5dfd06c4..fda1c7b7 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -337,6 +337,18 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { [urlValue], ); + // Not every account has a provider ready the moment they land here — an + // Ollama instance that is not running yet is the exact case this build + // exists for. Provisioning already treats that as an anticipated state + // (`bench_unseeded`, not an error), so the wizard should not be the one + // hard-blocking control: skipping just hands off to `/` the same way a + // confirmed credential does, and the no-usable-model banner there (CL-6568) + // is what tells them, honestly, that a connection still needs finishing — + // this screen does not need to be the only place that can say so. + const handleSkip = useCallback(() => { + navigate("/"); + }, [navigate]); + const handleSubmitCredential = useCallback( (event: FormEvent) => { event.preventDefault(); @@ -598,6 +610,21 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { : "Connect this key"} +
+ +

+ No provider ready yet? You can connect one anytime from Settings → + AI providers. +

+