Files
open-design/apps/web/tests/components/EntryShell.onboarding.test.tsx
802708f6c9 feat(web): land sign-in on Hosted and pitch free credits across entry points (#8567)
* feat(web): land sign-in on Hosted and pitch free credits across entry points

- Onboarding: drop the "Choose your model source" step. Signing in from the
  welcome screen finishes onboarding on OpenDesign Hosted; Local AI and API Key
  stay reachable from the welcome screen. Hosted select_runtime now reports
  step `connect` instead of `model_source`.
- Welcome screen: the pending sign-in shows one quiet "Sign-in page didn't
  open? Reopen" line (after 5s, or at once when the browser failed to open)
  instead of a nested card with two cancels, and the primary button no longer
  moves between idle and pending states.
- Home rail card: "Get free credits" pitch with a sparkle mark; the pending
  state turns the call to action into "Signing in…" with a single cancel and
  the same delayed reopen line. Dark mode keeps the button visible.
- Settings → Local CLI: callout reuses the free-credits copy; selecting a
  signed-out OpenDesign row selects it in place and offers Sign up / Sign in
  (no bounce to onboarding), committing the CLI once a session exists. Both
  buttons share the solid pill and show "Signing in… | Cancel".
- Fix the selected CLI card flashing on hover, and make the Home @ picker
  tabs clickable where they overlap the desktop window's drag strip.

* fix(web): drop pending OpenDesign pick when DeepSeek Harness setup commits

Installing DeepSeek Harness from its setup dialog committed agentId without
clearing amrPendingSelect, so the OpenDesign card stayed highlighted and a
later sign-in overwrote the DeepSeek choice with 'amr'. Clear the pending
intent alongside that commit and cover the sequence with a regression test.

---------

Co-authored-by: elifive555555 <296440099+elifive555555@users.noreply.github.com>
2026-10-09 11:45:47 +00:00

2548 lines
90 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { useState } from 'react';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
EntryShell,
ONBOARDING_LOCAL_AUTO_TEST_DELAY_MS,
} from '../../src/components/EntryShell';
import {
AMR_LOGIN_POLL_INTERVAL_MS,
AMR_LOGIN_STATUS_EVENT,
AMR_LOGIN_TIMEOUT_MS,
} from '../../src/components/amrLoginPolling';
import { I18nProvider } from '../../src/i18n';
import { fetchProjectFiles } from '../../src/providers/registry';
import type { AgentInfo, AppConfig } from '../../src/types';
import { setHomeHeroPrompt } from '../helpers/home-hero-lexical';
const analyticsMocks = vi.hoisted(() => ({
track: vi.fn(),
}));
vi.mock('../../src/analytics/provider', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/analytics/provider')>();
return {
...actual,
useAnalytics: () => ({
newRequestId: vi.fn(() => 'request-1'),
setConfigureGlobals: vi.fn(),
setConsent: vi.fn(),
setIdentity: vi.fn(),
track: analyticsMocks.track,
}),
useAppVersion: () => null,
};
});
const originalFetch = globalThis.fetch;
const originalResizeObserver = globalThis.ResizeObserver;
class ResizeObserverMock {
observe() {}
disconnect() {}
unobserve() {}
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function amrAgent(overrides: Partial<AgentInfo> = {}): AgentInfo {
return {
id: 'amr',
name: 'AMR',
bin: 'amr',
available: true,
models: [{ id: 'amr-model', label: 'AMR Model' }],
...overrides,
};
}
function cliAgent(overrides: Partial<AgentInfo> = {}): AgentInfo {
return {
id: 'claude-code',
name: 'Claude Code',
bin: 'claude',
available: true,
version: '1.0.0',
models: [{ id: 'sonnet', label: 'Sonnet' }],
...overrides,
};
}
// DeepSeek Harness before its companion is installed: unavailable, yet the
// picker still lists it so the user has somewhere to start setup from.
function dshSetupRequiredAgent(overrides: Partial<AgentInfo> = {}): AgentInfo {
return {
id: 'deepseek-harness',
name: 'DeepSeek Harness',
bin: 'deepseek-harness',
available: false,
path: '/usr/local/bin/deepseek-harness',
models: [{ id: 'deepseek-chat', label: 'DeepSeek Chat' }],
diagnostics: [
{
reason: 'runtime-profile-incompatible',
severity: 'error',
message: 'Companion setup required.',
},
],
...overrides,
};
}
function baseConfig(overrides: Partial<AppConfig> = {}): AppConfig {
return {
mode: 'daemon',
agentId: null,
agentModels: {},
apiProtocol: 'anthropic',
apiProtocolConfigs: {},
apiKey: '',
baseUrl: '',
model: '',
...overrides,
} as AppConfig;
}
function renderOnboarding(
overrides: Partial<React.ComponentProps<typeof EntryShell>> = {},
) {
window.history.replaceState(null, '', '/onboarding');
const props: React.ComponentProps<typeof EntryShell> = {
skills: [],
designTemplates: [],
designSystems: [],
projects: [],
templates: [],
promptTemplates: [],
defaultDesignSystemId: null,
connectors: [],
connectorsLoading: false,
config: baseConfig(),
agents: [amrAgent(), cliAgent()],
daemonLive: true,
onModeChange: vi.fn(),
onAgentChange: vi.fn(),
onAgentModelChange: vi.fn(),
onApiProtocolChange: vi.fn(),
onApiModelChange: vi.fn(),
onConfigPersist: vi.fn(),
onRefreshAgents: vi.fn(() => [amrAgent(), cliAgent()]),
onCreateProject: vi.fn(),
onBeginProjectCreation: () => ({ projectId: 'optimistic-project', rollback: () => undefined }),
onAmrBalanceGateBlockChange: () => undefined,
onCreatePluginShareProject: vi.fn(),
onImportClaudeDesign: vi.fn(),
onOpenProject: vi.fn(),
onOpenLiveArtifact: vi.fn(),
onDeleteProject: vi.fn(),
onRenameProject: vi.fn(),
onChangeDefaultDesignSystem: vi.fn(),
onPersistComposioKey: vi.fn(),
onOpenSettings: vi.fn(),
onCompleteOnboarding: vi.fn(),
...overrides,
};
function Harness() {
const [config, setConfig] = useState(props.config);
return (
<I18nProvider initial="en">
<EntryShell
{...props}
config={config}
onConfigPersist={(next) => {
props.onConfigPersist(next);
setConfig(next as AppConfig);
}}
/>
</I18nProvider>
);
}
render(
<Harness />,
);
return props;
}
function renderHome(
overrides: Partial<React.ComponentProps<typeof EntryShell>> = {},
path = '/',
) {
window.history.replaceState(null, '', path);
const props: React.ComponentProps<typeof EntryShell> = {
skills: [],
designTemplates: [],
designSystems: [],
projects: [],
templates: [],
promptTemplates: [],
defaultDesignSystemId: null,
connectors: [],
connectorsLoading: false,
config: baseConfig({
agentId: 'claude-code',
agentModels: { 'claude-code': { model: 'sonnet' } },
}),
agents: [cliAgent()],
daemonLive: true,
onModeChange: vi.fn(),
onAgentChange: vi.fn(),
onAgentModelChange: vi.fn(),
onApiProtocolChange: vi.fn(),
onApiModelChange: vi.fn(),
onConfigPersist: vi.fn(),
onRefreshAgents: vi.fn(() => [cliAgent()]),
onCreateProject: vi.fn(),
onBeginProjectCreation: () => ({ projectId: 'optimistic-project', rollback: () => undefined }),
onAmrBalanceGateBlockChange: () => undefined,
onCreatePluginShareProject: vi.fn(),
onImportClaudeDesign: vi.fn(),
onOpenProject: vi.fn(),
onOpenLiveArtifact: vi.fn(),
onDeleteProject: vi.fn(),
onRenameProject: vi.fn(),
onChangeDefaultDesignSystem: vi.fn(),
onPersistComposioKey: vi.fn(),
onOpenSettings: vi.fn(),
onCompleteOnboarding: vi.fn(),
...overrides,
};
render(
<I18nProvider initial="en">
<EntryShell {...props} />
</I18nProvider>,
);
return props;
}
function trackedEvents(name: string) {
return analyticsMocks.track.mock.calls.filter(([eventName]) => eventName === name);
}
function latestTrackedEvent<T extends Record<string, unknown>>(name: string): T {
const calls = trackedEvents(name);
expect(calls.length).toBeGreaterThan(0);
return calls[calls.length - 1]?.[1] as T;
}
function findTrackedEvent<T extends Record<string, unknown>>(
name: string,
predicate: (payload: T) => boolean,
): T {
const payload = trackedEvents(name)
.map(([, eventPayload]) => eventPayload as T)
.find(predicate);
expect(payload).toBeTruthy();
return payload as T;
}
function chooseOnboardingOption(label: string, option: string | RegExp) {
const chipField = screen
.getAllByText(label)
.map((node) => node.closest('.onboarding-chip-field'))
.find((node): node is HTMLElement => node instanceof HTMLElement);
if (chipField) {
const matcher = option instanceof RegExp ? option : new RegExp(option, 'i');
const chip = Array.from(chipField.querySelectorAll<HTMLButtonElement>('button')).find((button) =>
matcher.test(button.textContent ?? ''),
);
if (!(chip instanceof HTMLButtonElement)) {
throw new Error(`profile chip not found: ${label} / ${String(option)}`);
}
fireEvent.click(chip);
return;
}
const dropdownField = screen
.getAllByText(label)
.map((node) => node.closest('.onboarding-view__select-field'))
.find((node): node is HTMLElement => node instanceof HTMLElement);
if (!dropdownField) throw new Error(`profile field not found: ${label}`);
const trigger = dropdownField.querySelector('button');
if (!(trigger instanceof HTMLButtonElement)) {
throw new Error(`profile field trigger not found: ${label}`);
}
fireEvent.click(trigger);
fireEvent.click(
screen.getByRole('option', {
name: option instanceof RegExp ? option : new RegExp(option, 'i'),
}),
);
}
async function clickCloudSignIn() {
const signIn = await findCloudSignInButton();
fireEvent.click(signIn);
return signIn;
}
async function findCloudSignInButton() {
return screen.findByRole('button', { name: /Sign in \/ Sign up/i });
}
async function openLocalRuntimeSetup() {
fireEvent.click(await screen.findByRole('button', { name: /^Local AI$/i }));
expect(await screen.findByText('Local CLI')).toBeTruthy();
}
async function openByokRuntimeSetup() {
fireEvent.click(await screen.findByRole('button', { name: /^API Key$/i }));
expect(await screen.findByRole('heading', { name: 'Bring Your Own Key' })).toBeTruthy();
}
afterEach(() => {
cleanup();
globalThis.fetch = originalFetch;
globalThis.ResizeObserver = originalResizeObserver;
vi.useRealTimers();
analyticsMocks.track.mockReset();
window.sessionStorage.clear();
});
beforeEach(() => {
globalThis.fetch = originalFetch;
globalThis.ResizeObserver = ResizeObserverMock as typeof ResizeObserver;
analyticsMocks.track.mockReset();
});
describe('EntryShell settings menu', () => {
it('opens settings from the signed-out rail without duplicating the footer action', async () => {
globalThis.fetch = vi.fn(async (input) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url.endsWith('/api/community/discord')) {
return jsonResponse({
inviteCode: 'mHAjSMV6gz',
inviteUrl: 'https://discord.gg/mHAjSMV6gz',
onlineCount: 1234,
memberCount: 4321,
fetchedAt: Date.now(),
stale: false,
});
}
if (url.endsWith('/api/github/open-design')) {
return jsonResponse({
repo: 'nexu-io/open-design',
stargazers_count: 56100,
fetchedAt: Date.now(),
stale: false,
});
}
return jsonResponse({});
}) as typeof fetch;
const props = renderHome();
// The signed-out rail's own settings item (below 扩展) is the single
// settings entry — the #5517 footer carries none.
fireEvent.click(await screen.findByTestId('entry-settings-button'));
expect(props.onOpenSettings).toHaveBeenCalledWith();
expect(screen.getAllByTestId('entry-settings-button')).toHaveLength(1);
});
});
describe('EntryShell navigation shortcuts', () => {
afterEach(() => {
window.localStorage.removeItem('od.entry.railOpen');
});
it('leaves the rail unchanged when the composer owns Cmd/Ctrl+B', async () => {
window.localStorage.setItem('od.entry.railOpen', 'false');
renderHome();
const entry = document.querySelector('.entry');
expect(entry).toBeInstanceOf(HTMLElement);
expect(entry?.classList.contains('entry--rail-open')).toBe(false);
const editor = await screen.findByTestId('home-hero-input');
fireEvent.keyDown(editor, {
key: 'b',
...(/Mac|iPod|iPhone|iPad/.test(navigator.platform)
? { metaKey: true }
: { ctrlKey: true }),
});
expect(entry?.classList.contains('entry--rail-open')).toBe(false);
});
});
describe('EntryShell design systems view', () => {
it('leaves workspace-scoped design-system activation to the mounted tab', async () => {
const onDesignSystemsRefresh = vi.fn();
renderHome({ onDesignSystemsRefresh }, '/design-systems');
expect(await screen.findByTestId('entry-view-design-systems')).toHaveAttribute(
'data-active',
'true',
);
// DesignSystemsTab owns its Team SSE activation and fallback snapshot.
// Calling the App-level catalog refresh here as well creates a duplicate,
// differently-scoped request every time the route becomes active.
expect(onDesignSystemsRefresh).not.toHaveBeenCalled();
});
});
describe('EntryShell route scroll isolation', () => {
afterEach(() => {
window.localStorage.removeItem('od.entry.railOpen');
});
function entryScrollContainer(): HTMLElement {
const scrollContainer = document.querySelector('.entry-main--scroll');
expect(scrollContainer).toBeInstanceOf(HTMLElement);
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error('entry scroll container not found');
}
return scrollContainer;
}
// #5517 reshaped the rail: the flat `entry-nav-projects` button is gone, and
// its Drafts / All-projects replacements only mount under a workspace
// context this render has none of. Design systems is the nearest rail
// destination that survives in every state, and the reset it exercises is the
// same shared `.entry-main--scroll` element, so the spec's subject is intact.
it('resets the shared scroll offset when navigating away from Home', async () => {
window.localStorage.setItem('od.entry.railOpen', 'true');
renderHome();
const scrollContainer = entryScrollContainer();
scrollContainer.scrollTop = 280;
fireEvent.click(screen.getByTestId('entry-nav-design-systems'));
await waitFor(() => {
expect(
screen.getByTestId('entry-view-design-systems').getAttribute('data-active'),
).toBe('true');
});
expect(scrollContainer.scrollTop).toBe(0);
});
it('resets the shared scroll offset when navigating from Projects to Home', async () => {
window.localStorage.setItem('od.entry.railOpen', 'true');
renderHome({}, '/projects');
const scrollContainer = entryScrollContainer();
scrollContainer.scrollTop = 360;
fireEvent.click(screen.getByTestId('entry-nav-home'));
await waitFor(() => {
expect(screen.getByTestId('entry-view-home').getAttribute('data-active')).toBe('true');
});
expect(scrollContainer.scrollTop).toBe(0);
});
});
describe('EntryShell project reopen request priority', () => {
// The catalogue grid — and its background cover scan — lives on the 项目
// page now that Home carries no grid on either branch (OPEND-2683 /
// OPEND-3140); the invariant is unchanged, the surface moved.
it('aborts the 项目 page cover work, keeps hidden Projects idle, and lets the foreground files read finish', async () => {
const files = [{
name: 'index.html',
path: 'index.html',
kind: 'html' as const,
mtime: 1,
size: 1,
mime: 'text/html',
}];
const fileRequests: Array<RequestInit | undefined> = [];
const fetchMock = vi.fn(
async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
const url = typeof input === 'string'
? input
: input instanceof Request
? input.url
: String(input);
if (url === '/api/projects/project-reopen/files') {
// Single-flight (`lib/shared-cancellable-get`) gives every `/files`
// reader — cancellable or not — one shared request carrying the
// shared AbortSignal, so "is this the background scan?" is the
// request ordinal, not the presence of a signal. Request #1 is
// the 项目 page's cover scan and must hang until it is aborted; the
// foreground read that follows it must be answered.
const isBackgroundCoverScan = fileRequests.length === 0;
fileRequests.push(init);
if (isBackgroundCoverScan) {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => reject(new DOMException('Aborted', 'AbortError')),
{ once: true },
);
});
}
return jsonResponse({ files });
}
if (url.includes('/api/live-artifacts?projectId=project-reopen')) {
return jsonResponse({ liveArtifacts: [] });
}
if (url.endsWith('/api/community/discord')) {
return jsonResponse({
inviteCode: 'mHAjSMV6gz',
inviteUrl: 'https://discord.gg/mHAjSMV6gz',
onlineCount: 0,
memberCount: 0,
fetchedAt: Date.now(),
stale: false,
});
}
if (url.endsWith('/api/github/open-design')) {
return jsonResponse({
repo: 'nexu-io/open-design',
stargazers_count: 0,
fetchedAt: Date.now(),
stale: false,
});
}
return jsonResponse({});
},
);
globalThis.fetch = fetchMock as typeof fetch;
const onOpenProject = vi.fn((projectId: string) => {
expect(projectId).toBe('project-reopen');
// App leaves EntryShell when it opens ProjectView. Model that boundary
// directly so the mounted strip must cancel its background probe.
cleanup();
});
renderHome({
projects: [{
id: 'project-reopen',
name: 'Reopen project',
skillId: null,
designSystemId: null,
createdAt: 1,
updatedAt: 2,
status: { value: 'not_started' },
}],
onOpenProject,
}, '/drafts');
await waitFor(() => expect(fileRequests).toHaveLength(1));
const homeSignal = fileRequests[0]?.signal;
expect(homeSignal).toBeDefined();
expect(screen.getByTestId('recent-projects-strip')).toBeTruthy();
// DesignsTab is mounted under EntryShell's hidden Projects pane, but its
// own background files/live-artifact scans must remain dormant.
expect(
fetchMock.mock.calls.filter(([input]) =>
String(input).includes('/api/live-artifacts?projectId=project-reopen'),
),
).toHaveLength(0);
fireEvent.click(screen.getByRole('button', { name: /Reopen project/ }));
expect(onOpenProject).toHaveBeenCalledTimes(1);
expect(homeSignal?.aborted).toBe(true);
await expect(fetchProjectFiles('project-reopen')).resolves.toEqual(files);
expect(fileRequests).toHaveLength(2);
// The foreground read must own a live request of its own: it neither joins
// the abandoned scan's dead entry nor inherits its aborted signal.
const foregroundSignal = fileRequests[1]?.signal;
expect(foregroundSignal).toBeDefined();
expect(foregroundSignal).not.toBe(homeSignal);
expect(foregroundSignal?.aborted).toBe(false);
});
});
describe('EntryShell new project rail', () => {
// The rail's "+ New project" button (`entry-nav-new-project`) is gone in
// #5517's rail: `EntryShell` still passes `onNewProject` — with its
// `new_project_plus` ui_click — to `EntryNavRail`, but the rail never renders
// a control that calls it, so the button and that analytics event are both
// unreachable. The spec that drove it is therefore removed; opening the
// new-project modal is still covered by the Projects-view CTA below, which is
// the surviving entry point.
it('opens the new project modal from the Projects view new-project button', async () => {
const fetchMock = vi.fn(
async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url === '/api/projects' && init?.method === 'POST') {
return jsonResponse({
project: {
id: 'blank-project-from-projects',
name: 'Untitled',
createdAt: Date.now(),
updatedAt: Date.now(),
},
conversationId: 'conversation-2',
});
}
if (url.endsWith('/api/projects/project-existing/files')) {
return jsonResponse({ files: [] });
}
if (url.endsWith('/api/live-artifacts?projectId=project-existing')) {
return jsonResponse({ liveArtifacts: [] });
}
if (url.endsWith('/api/community/discord')) {
return jsonResponse({
inviteCode: 'mHAjSMV6gz',
inviteUrl: 'https://discord.gg/mHAjSMV6gz',
onlineCount: 0,
memberCount: 0,
fetchedAt: Date.now(),
stale: false,
});
}
if (url.endsWith('/api/github/open-design')) {
return jsonResponse({
repo: 'nexu-io/open-design',
stargazers_count: 0,
fetchedAt: Date.now(),
stale: false,
});
}
return jsonResponse({});
});
globalThis.fetch = fetchMock as typeof fetch;
// Start directly on the Projects view (/projects). The nav rail no longer
// has a single "Projects" button — the projects list is its own route,
// reachable via /projects or Home's "view all" — so drive the DesignsTab's
// own new-project CTA rather than a removed rail button.
const props = renderHome({
projects: [
{
id: 'project-existing',
name: 'Existing project',
skillId: null,
designSystemId: null,
createdAt: 1,
updatedAt: 2,
status: { value: 'not_started' },
},
],
}, '/projects');
fireEvent.click(screen.getByTestId('designs-new-project'));
await waitFor(() => {
expect(screen.getByTestId('new-project-modal')).toBeTruthy();
});
expect(screen.getByTestId('new-project-panel')).toBeTruthy();
expect(props.onOpenProject).not.toHaveBeenCalled();
expect(props.onCreateProject).not.toHaveBeenCalled();
const createCall = fetchMock.mock.calls.find(
([input, init]) => input === '/api/projects' && init?.method === 'POST',
);
expect(createCall).toBeUndefined();
expect(analyticsMocks.track).toHaveBeenCalledWith(
'ui_click',
expect.objectContaining({
page_name: 'projects',
area: 'list_controls',
element: 'create_project',
}),
undefined,
);
});
it('does not persist the modal hidden default Skill on an automatic OD Next route', async () => {
const onCreateProject = vi.fn(() => true);
renderHome({
skills: [{
id: 'agent-browser',
name: 'agent-browser',
description: 'Inspect rendered prototypes',
mode: 'prototype',
surface: 'web',
previewType: 'html',
designSystemRequired: true,
defaultFor: ['prototype'],
triggers: [],
upstream: null,
hasBody: true,
examplePrompt: '',
aggregatesExamples: false,
}],
projects: [{
id: 'project-existing',
name: 'Existing project',
skillId: null,
designSystemId: null,
createdAt: 1,
updatedAt: 2,
status: { value: 'not_started' },
}],
onCreateProject,
}, '/projects');
fireEvent.click(screen.getByTestId('designs-new-project'));
await screen.findByTestId('new-project-panel');
fireEvent.click(screen.getByTestId('create-project'));
await waitFor(() => expect(onCreateProject).toHaveBeenCalledTimes(1));
expect(onCreateProject).toHaveBeenCalledWith(expect.objectContaining({
skillId: null,
metadata: expect.objectContaining({ kind: 'prototype' }),
}));
});
});
describe('EntryShell Home submit handoff', () => {
it('keeps the Home run button in sending state until project creation resolves', async () => {
globalThis.fetch = vi.fn(async (input: Parameters<typeof fetch>[0]) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url.endsWith('/api/plugins')) return jsonResponse({ plugins: [] });
if (url.endsWith('/api/mcp/servers')) return jsonResponse({ servers: [] });
if (url.endsWith('/api/community/discord')) return jsonResponse({ stale: true });
if (url.endsWith('/api/github/open-design')) return jsonResponse({ stale: true });
return jsonResponse({});
}) as typeof fetch;
let resolveCreate: (accepted: boolean) => void = () => undefined;
const onCreateProject = vi.fn(
(_input: { pluginId?: string }) => new Promise<boolean>((resolve) => { resolveCreate = resolve; }),
);
renderHome({ onCreateProject });
await screen.findByTestId('home-hero-input');
setHomeHeroPrompt('Build a landing page');
const submit = await screen.findByTestId('home-hero-submit') as HTMLButtonElement;
fireEvent.click(submit);
await waitFor(() => expect(onCreateProject).toHaveBeenCalledTimes(1));
expect(onCreateProject).toHaveBeenCalledWith(expect.objectContaining({
pendingPrompt: 'Build a landing page',
conversationMode: 'design',
}));
// HomeView's hidden default-router identity is provenance, not an
// explicit user plugin choice on the public create contract.
expect(onCreateProject.mock.calls[0]?.[0]?.pluginId).toBeUndefined();
expect(submit.disabled).toBe(true);
// The arrow stays visually stable while creation is in flight: no spinner,
// no busy state — the disabled lock above is the whole sending treatment.
expect(submit.getAttribute('aria-busy')).toBe('false');
expect(submit.getAttribute('aria-label')).toBe('Run');
resolveCreate(true);
await waitFor(() => expect(submit.disabled).toBe(false));
});
});
describe('EntryShell onboarding OpenDesign AMR runtime', () => {
it('gates Home on an authoritative signed-out Cloud session without clearing saved setup', async () => {
globalThis.fetch = vi.fn(async () =>
jsonResponse({ loggedIn: false, profile: 'prod', configPath: '/x', user: null }),
) as typeof fetch;
const config = baseConfig({
onboardingCompleted: true,
mode: 'daemon',
agentId: 'amr',
model: 'claude-opus-4-5',
});
const props = renderHome({ config, amrLoggedIn: false });
expect(
await screen.findByRole('heading', { name: 'Welcome to OpenDesign' }),
).toBeTruthy();
expect(await screen.findByText('Free Credits')).toBeTruthy();
expect(screen.getByLabelText('New users get free starter credits to try DeepSeek V4.1 Flash.')).toBeTruthy();
expect(window.location.pathname).toBe('/onboarding');
expect(props.onConfigPersist).not.toHaveBeenCalled();
expect(props.onModeChange).not.toHaveBeenCalled();
expect(props.onAgentChange).not.toHaveBeenCalled();
});
it.each([
['Local CLI', baseConfig({ mode: 'daemon', agentId: 'claude-code' })],
['BYOK', baseConfig({
mode: 'api',
agentId: 'amr',
apiKey: 'persisted-key',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
})],
])('keeps Home available for signed-out %s execution', async (_label, config) => {
globalThis.fetch = vi.fn(async () => jsonResponse({})) as typeof fetch;
renderHome({ config, amrLoggedIn: false });
expect(await screen.findByTestId('home-hero-input')).toBeTruthy();
expect(window.location.pathname).toBe('/');
expect(
screen.queryByRole('heading', { name: 'Welcome to OpenDesign' }),
).toBeNull();
});
it('completes Hosted onboarding straight from the signed-in Continue', async () => {
globalThis.fetch = vi.fn(async () =>
jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
}),
) as typeof fetch;
const props = renderOnboarding();
const continueButton = await screen.findByRole('button', { name: /Continue \(signed in\)/i });
// The starter-credit ribbon is a signed-out pitch only.
expect(screen.queryByText('Free Credits')).toBeNull();
fireEvent.click(continueButton);
await waitFor(() => {
expect(props.onCompleteOnboarding).toHaveBeenCalledTimes(1);
});
expect(screen.queryByRole('heading', { name: 'About you' })).toBeNull();
expect(props.onModeChange).toHaveBeenCalledWith('daemon');
expect(props.onAgentChange).toHaveBeenCalledWith('amr');
expect(
findTrackedEvent<Record<string, unknown>>(
'ui_click',
(payload) => payload.element === 'amr_cloud',
),
).toMatchObject({
area: 'runtime',
action: 'select_runtime',
step_index: '1',
step_name: 'connect',
runtime_type: 'amr_cloud',
is_recommended: true,
});
expect(latestTrackedEvent('onboarding_complete_result')).toMatchObject({
result: 'completed',
exit_step_name: 'connect',
completion_type: 'completed_without_design_system',
runtime_type: 'amr_cloud',
has_about_you: false,
has_design_system_request: false,
source_count: 0,
});
expect(trackedEvents('onboarding_complete_result')).toHaveLength(1);
});
it('resumes a completed setup after passive reauthentication without changing its model source', async () => {
const onAmrLoginStatusChange = vi.fn();
globalThis.fetch = vi.fn(async () =>
jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
}),
) as typeof fetch;
const props = renderOnboarding({
config: baseConfig({
onboardingCompleted: true,
mode: 'api',
apiKey: 'persisted-key',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
}),
onAmrLoginStatusChange,
});
await waitFor(() => {
expect(props.onCompleteOnboarding).toHaveBeenCalledTimes(1);
});
expect(props.onModeChange).not.toHaveBeenCalled();
expect(props.onAgentChange).not.toHaveBeenCalled();
expect(onAmrLoginStatusChange).toHaveBeenCalledWith(
expect.objectContaining({ loggedIn: true }),
);
expect(
trackedEvents('page_view').filter(([, payload]) =>
(payload as Record<string, unknown>).page_name === 'onboarding',
),
).toHaveLength(0);
expect(trackedEvents('onboarding_complete_result')).toHaveLength(0);
});
it('switches a completed but invalid setup to Hosted after sign-in', async () => {
globalThis.fetch = vi.fn(async () =>
jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
}),
) as typeof fetch;
const props = renderOnboarding({
config: baseConfig({
onboardingCompleted: true,
mode: 'api',
apiKey: '',
baseUrl: '',
model: '',
}),
});
fireEvent.click(
await screen.findByRole('button', { name: /Continue \(signed in\)/i }),
);
await waitFor(() => {
expect(props.onCompleteOnboarding).toHaveBeenCalledTimes(1);
});
expect(props.onModeChange).toHaveBeenCalledWith('daemon');
expect(props.onAgentChange).toHaveBeenCalledWith('amr');
});
it('tests Local Agent on Continue, stays on failure, and retries on the next click', async () => {
let testCalls = 0;
const fetchMock = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
});
}
if (url.endsWith('/api/test/connection') && init?.method === 'POST') {
testCalls += 1;
return testCalls === 1
? jsonResponse({
ok: false,
kind: 'agent_spawn_failed',
latencyMs: 12,
model: 'sonnet',
agentName: 'Claude Code',
detail: 'process exited before responding',
})
: jsonResponse({
ok: true,
kind: 'success',
latencyMs: 12,
model: 'sonnet',
sample: 'pong',
agentName: 'Claude Code',
});
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
const props = renderOnboarding({
config: baseConfig({
agentId: 'claude-code',
agentModels: { 'claude-code': { model: 'sonnet' } },
}),
});
await openLocalRuntimeSetup();
const continueButton = screen.getByRole('button', { name: /^Continue$/i });
expect(continueButton.getAttribute('aria-disabled')).toBeNull();
fireEvent.click(continueButton);
expect(await screen.findByText(/Could not start Claude Code/i)).toBeTruthy();
expect(props.onCompleteOnboarding).not.toHaveBeenCalled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(testCalls).toBe(2);
expect(props.onCompleteOnboarding).toHaveBeenCalledTimes(1);
});
expect(props.onConfigPersist).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'daemon', agentId: 'claude-code' }),
);
expect(latestTrackedEvent('onboarding_complete_result')).toMatchObject({
result: 'completed',
exit_step_name: 'runtime_setup',
runtime_type: 'local_cli',
});
});
it('drops a Local Agent validation that lands after the user goes Back', async () => {
// Continue awaits a network round trip before it persists. Back stays
// enabled through that wait, so a late success must not resurrect the
// configuration the user just walked away from. Leaving the step also
// aborts the request; a mock that ignores its signal keeps this spec on
// the second line of defence, the one that judges a result that still
// arrives.
let releaseTest: ((value: Response) => void) | undefined;
let testCalls = 0;
globalThis.fetch = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
});
}
if (url.endsWith('/api/test/connection') && init?.method === 'POST') {
testCalls += 1;
return new Promise<Response>((resolve) => {
releaseTest = resolve;
});
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
const props = renderOnboarding({
config: baseConfig({
agentId: 'claude-code',
agentModels: { 'claude-code': { model: 'sonnet' } },
}),
});
await openLocalRuntimeSetup();
fireEvent.click(screen.getByRole('button', { name: /^Continue$/i }));
await waitFor(() => {
expect(testCalls).toBe(1);
});
fireEvent.click(screen.getByRole('button', { name: /^Back$/i }));
expect(await screen.findByRole('button', { name: /^Local AI$/i })).toBeTruthy();
await act(async () => {
releaseTest?.(
jsonResponse({
ok: true,
kind: 'success',
latencyMs: 12,
model: 'sonnet',
sample: 'pong',
agentName: 'Claude Code',
}),
);
await Promise.resolve();
});
expect(props.onConfigPersist).not.toHaveBeenCalled();
expect(props.onCompleteOnboarding).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: /^Local AI$/i })).toBeTruthy();
});
it('validates the selected Local Agent in the background so Continue does not wait on a spawn', async () => {
// The runtime smoke test spawns the agent CLI and waits for a real model
// reply: 7s for Claude Code and 12s for Codex CLI measured against a local
// daemon, against a 45s budget. Starting it only when Continue is pressed
// puts that whole cost between the click and the next screen, so it has to
// already be under way by the time the user clicks.
let testCalls = 0;
globalThis.fetch = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
});
}
if (url.endsWith('/api/test/connection') && init?.method === 'POST') {
testCalls += 1;
return jsonResponse({
ok: true,
kind: 'success',
latencyMs: 12,
model: 'sonnet',
sample: 'pong',
agentName: 'Claude Code',
});
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
const props = renderOnboarding({
config: baseConfig({
agentId: 'claude-code',
agentModels: { 'claude-code': { model: 'sonnet' } },
}),
});
await openLocalRuntimeSetup();
// Nobody has pressed Continue: the selection validates on its own.
await waitFor(() => {
expect(testCalls).toBe(1);
});
expect(props.onCompleteOnboarding).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: /^Continue$/i }));
await waitFor(() => {
expect(props.onCompleteOnboarding).toHaveBeenCalledTimes(1);
});
expect(props.onConfigPersist).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'daemon', agentId: 'claude-code' }),
);
// The click settled on the finished validation instead of spawning again.
expect(testCalls).toBe(1);
});
it('lets Continue join the in-flight Local Agent validation instead of dropping the click', async () => {
// Background validation must not turn Continue into a dead button: a click
// landing mid-flight joins the attempt already validating those inputs and
// finishes with it, rather than being swallowed or spawning a second agent.
let releaseTest: ((value: Response) => void) | undefined;
let testCalls = 0;
globalThis.fetch = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
});
}
if (url.endsWith('/api/test/connection') && init?.method === 'POST') {
testCalls += 1;
return new Promise<Response>((resolve) => {
releaseTest = resolve;
});
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
const props = renderOnboarding({
config: baseConfig({
agentId: 'claude-code',
agentModels: { 'claude-code': { model: 'sonnet' } },
}),
});
await openLocalRuntimeSetup();
await waitFor(() => {
expect(testCalls).toBe(1);
});
const continueButton = screen.getByRole('button', { name: /^Continue$/i });
expect(continueButton.hasAttribute('disabled')).toBe(false);
fireEvent.click(continueButton);
await act(async () => {
releaseTest?.(
jsonResponse({
ok: true,
kind: 'success',
latencyMs: 12,
model: 'sonnet',
sample: 'pong',
agentName: 'Claude Code',
}),
);
await Promise.resolve();
});
await waitFor(() => {
expect(props.onCompleteOnboarding).toHaveBeenCalledTimes(1);
});
expect(testCalls).toBe(1);
});
it('releases the agent child when the user leaves the setup step mid-validation', async () => {
// The background pass spawns a real agent CLI, so a validation the user
// walks away from is not free — left alone the daemon holds that child
// until its own timeout, and a few abandoned visits cost several spawns
// and model calls for nothing.
let testSignal: AbortSignal | undefined;
globalThis.fetch = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
});
}
if (url.endsWith('/api/test/connection') && init?.method === 'POST') {
testSignal = init.signal ?? undefined;
// Never settles: the abort is the only thing that can end this run.
return new Promise<Response>(() => {});
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
renderOnboarding({
config: baseConfig({
agentId: 'claude-code',
agentModels: { 'claude-code': { model: 'sonnet' } },
}),
});
await openLocalRuntimeSetup();
await waitFor(() => {
expect(testSignal).toBeDefined();
});
expect(testSignal?.aborted).toBe(false);
fireEvent.click(screen.getByRole('button', { name: /^Back$/i }));
await waitFor(() => {
expect(testSignal?.aborted).toBe(true);
});
});
it('restarts BYOK auto-validation against edited inputs instead of waiting the old one out', async () => {
// A held request must not decide when the replacement gets to start: the
// key the user is now looking at has to begin validating immediately, or
// it stays unproven until the abandoned run times out.
const signals: AbortSignal[] = [];
globalThis.fetch = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
});
}
if (url.endsWith('/api/provider/models') && init?.method === 'POST') {
return jsonResponse({
ok: true,
kind: 'success',
latencyMs: 10,
models: [{ id: 'gpt-test', label: 'GPT Test' }],
});
}
if (url.endsWith('/api/test/connection') && init?.method === 'POST') {
if (init.signal) signals.push(init.signal);
// Never settles, so only the abort can free the slot.
return new Promise<Response>(() => {});
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
renderOnboarding({
config: baseConfig({
mode: 'api',
apiProtocol: 'openai',
apiKey: 'test-api-key',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-test',
apiProviderBaseUrl: 'https://api.openai.com/v1',
}),
});
await openByokRuntimeSetup();
await waitFor(() => {
expect(signals).toHaveLength(1);
});
fireEvent.change(screen.getByLabelText('API key'), {
target: { value: 'rotated-api-key' },
});
await waitFor(() => {
expect(signals).toHaveLength(2);
});
expect(signals[0]?.aborted).toBe(true);
expect(signals[1]?.aborted).toBe(false);
});
it('validates again when the user returns to a selection whose check was cut short', async () => {
// Aborting writes no result by design, so the attempt's bookkeeping has to
// come back with it. Left behind, it claims these inputs were already
// validated and the panel stays on a "testing" line no run will ever
// finish — the automatic path is then dead until Continue is pressed.
let testCalls = 0;
globalThis.fetch = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
});
}
if (url.endsWith('/api/test/connection') && init?.method === 'POST') {
testCalls += 1;
// Never settles, so leaving is the only thing that ends the first run.
return new Promise<Response>(() => {});
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
renderOnboarding({
config: baseConfig({
agentId: 'claude-code',
agentModels: { 'claude-code': { model: 'sonnet' } },
}),
});
await openLocalRuntimeSetup();
await waitFor(() => {
expect(testCalls).toBe(1);
});
fireEvent.click(screen.getByRole('button', { name: /^Back$/i }));
expect(await screen.findByRole('button', { name: /^Local AI$/i })).toBeTruthy();
// Same agent, same model: the cut-short attempt must not count as proof.
fireEvent.click(screen.getByRole('button', { name: /^Local AI$/i }));
expect(await screen.findByText('Local CLI')).toBeTruthy();
await waitFor(() => {
expect(testCalls).toBe(2);
});
});
it('releases a BYOK request once its key is cleared and nothing can consume it', async () => {
// Staying on the step does not keep a request useful: an emptied key can
// no longer be validated, so the run holding the daemon has to go the same
// way it would if the user had walked out.
const signals: AbortSignal[] = [];
globalThis.fetch = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
});
}
if (url.endsWith('/api/provider/models') && init?.method === 'POST') {
return jsonResponse({
ok: true,
kind: 'success',
latencyMs: 10,
models: [{ id: 'gpt-test', label: 'GPT Test' }],
});
}
if (url.endsWith('/api/test/connection') && init?.method === 'POST') {
if (init.signal) signals.push(init.signal);
return new Promise<Response>(() => {});
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
renderOnboarding({
config: baseConfig({
mode: 'api',
apiProtocol: 'openai',
apiKey: 'test-api-key',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-test',
apiProviderBaseUrl: 'https://api.openai.com/v1',
}),
});
await openByokRuntimeSetup();
await waitFor(() => {
expect(signals).toHaveLength(1);
});
expect(signals[0]?.aborted).toBe(false);
fireEvent.change(screen.getByLabelText('API key'), { target: { value: '' } });
await waitFor(() => {
expect(signals[0]?.aborted).toBe(true);
});
expect(signals).toHaveLength(1);
});
it('does not spend a second spawn when the debounce outlives a manual Test', async () => {
// Pressing Test disturbs none of the auto-validation effect's inputs, so
// its debounce stays armed. A manual check that answers inside that window
// must still leave exactly one spawn behind.
let testCalls = 0;
globalThis.fetch = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
});
}
if (url.endsWith('/api/test/connection') && init?.method === 'POST') {
testCalls += 1;
return jsonResponse({
ok: true,
kind: 'success',
latencyMs: 1,
model: 'sonnet',
sample: 'pong',
agentName: 'Claude Code',
});
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
renderOnboarding({
config: baseConfig({
agentId: 'claude-code',
agentModels: { 'claude-code': { model: 'sonnet' } },
}),
});
await openLocalRuntimeSetup();
// Beat the debounce with a manual press that resolves immediately.
fireEvent.click(screen.getByRole('button', { name: /^Test$/i }));
await waitFor(() => {
expect(testCalls).toBe(1);
});
expect(await screen.findByText(/replied in/i)).toBeTruthy();
// Let the armed debounce fire; it must find the inputs already proven.
await new Promise((resolve) => setTimeout(resolve, ONBOARDING_LOCAL_AUTO_TEST_DELAY_MS + 250));
expect(testCalls).toBe(1);
});
it('does not validate a saved selection the step is still asking the user to set up', async () => {
// A setup-required entry is selectable and can be the saved selection, but
// the daemon resolves its binary and really tries to start the runtime, so
// validating it unprompted answers with a failure on the same screen that
// is telling the user to finish installing it. Nothing may be spent until
// the companion setup actually succeeds.
let testCalls = 0;
globalThis.fetch = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
});
}
if (url.endsWith('/api/test/connection') && init?.method === 'POST') {
testCalls += 1;
return jsonResponse({
ok: false,
kind: 'agent_spawn_failed',
latencyMs: 5,
model: 'deepseek-chat',
agentName: 'DeepSeek Harness',
detail: 'companion missing',
});
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
// Only the setup-required entry is installed, so the scan has no available
// agent to fall back to and the saved selection stays on it.
renderOnboarding({
agents: [amrAgent(), dshSetupRequiredAgent()],
onRefreshAgents: vi.fn(() => [amrAgent(), dshSetupRequiredAgent()]),
config: baseConfig({ agentId: 'deepseek-harness' }),
});
await openLocalRuntimeSetup();
expect(await screen.findByText('DeepSeek Harness')).toBeTruthy();
// Let the whole debounce window pass: nothing may be spawned across it.
await new Promise((resolve) => setTimeout(resolve, ONBOARDING_LOCAL_AUTO_TEST_DELAY_MS + 250));
expect(testCalls).toBe(0);
// And the step is indeed still asking for setup rather than offering to
// continue — the two states must not contradict each other.
expect(
screen.getByRole('button', { name: /^Continue$/i }).getAttribute('aria-disabled'),
).toBe('true');
});
it('does not auto-select OpenDesign AMR when the AMR runtime is unavailable', async () => {
globalThis.fetch = vi.fn(async () =>
jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' }),
) as typeof fetch;
const props = renderOnboarding({
agents: [cliAgent()],
onRefreshAgents: vi.fn(() => [cliAgent()]),
});
expect(await screen.findByRole('heading', { name: 'Welcome to OpenDesign' })).toBeTruthy();
expect(await findCloudSignInButton()).toBeTruthy();
expect(screen.queryByRole('button', { name: /OpenDesign AMR/i })).toBeNull();
await waitFor(() => {
expect(props.onAgentChange).not.toHaveBeenCalledWith('amr');
});
expect(
(screen.getByRole('button', { name: /Local AI/i }) as HTMLButtonElement).disabled,
).toBe(false);
expect(
(screen.getByRole('button', { name: /API Key/i }) as HTMLButtonElement).disabled,
).toBe(false);
expect(screen.queryByText('Sign in to continue')).toBeNull();
});
it('shows OpenDesign Cloud as the default connect surface when AMR is available', async () => {
globalThis.fetch = vi.fn(async () =>
jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' }),
) as typeof fetch;
renderOnboarding();
expect(screen.getByRole('heading', { name: 'Welcome to OpenDesign' })).toBeTruthy();
expect(await findCloudSignInButton()).toBeTruthy();
// No runtime card, no AMR version text, no "Sign in to continue" CTA.
expect(screen.queryByRole('button', { name: /OpenDesign AMR/i })).toBeNull();
expect(screen.queryByText('AMR v0.1.0')).toBeNull();
expect(screen.queryByRole('button', { name: /Sign in to continue/i })).toBeNull();
expect(screen.queryByRole('link', { name: /Authorize AMR/i })).toBeNull();
// Cloud stays primary while identity-independent setup paths remain available.
expect(
(screen.getByRole('button', { name: /Local AI/i }) as HTMLButtonElement).disabled,
).toBe(false);
expect(
(screen.getByRole('button', { name: /API Key/i }) as HTMLButtonElement).disabled,
).toBe(false);
expect(screen.queryByRole('button', { name: /OpenDesign AMR/i })).toBeNull();
expect(screen.queryByRole('link', { name: /Authorize AMR/i })).toBeNull();
expect(screen.queryByText('Not signed in')).toBeNull();
expect(screen.queryByRole('button', { name: /^Sign in$/i })).toBeNull();
});
it('keeps direct Local CLI setup active when delayed status discovers a Cloud login', async () => {
let releaseInitialStatus!: (response: Response) => void;
const initialStatus = new Promise<Response>((resolve) => {
releaseInitialStatus = resolve;
});
let statusCalls = 0;
globalThis.fetch = vi.fn(async (input) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
statusCalls += 1;
if (statusCalls === 1) return initialStatus;
return jsonResponse({
loggedIn: false,
loginInFlight: true,
authAttemptId: '11111111-1111-4111-8111-111111111111',
profile: 'prod',
user: null,
configPath: '/x',
});
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
renderOnboarding({
config: baseConfig({
agentId: 'claude-code',
agentModels: { 'claude-code': { model: 'sonnet' } },
}),
});
fireEvent.click(screen.getByRole('button', { name: /Local AI/i }));
expect(await screen.findByText('Local CLI')).toBeTruthy();
await act(async () => {
releaseInitialStatus(jsonResponse({
loggedIn: false,
loginInFlight: true,
authAttemptId: '11111111-1111-4111-8111-111111111111',
profile: 'prod',
user: null,
configPath: '/x',
}));
await Promise.resolve();
});
expect(screen.getByText('Local CLI')).toBeTruthy();
expect(
(screen.getByRole('button', { name: /^Continue$/i }) as HTMLButtonElement).disabled,
).toBe(false);
});
it('excludes AMR from the Local CLI agent list', async () => {
globalThis.fetch = vi.fn(async () =>
jsonResponse({
loggedIn: true,
profile: 'prod',
user: { id: 'u', email: 'user@example.com' },
configPath: '/x',
}),
) as typeof fetch;
renderOnboarding();
await openLocalRuntimeSetup();
const localPanel = screen.getByText('Local CLI').closest('.onboarding-view__setup-panel');
expect(localPanel?.textContent).toContain('Claude Code');
expect(localPanel?.textContent).not.toContain('AMR');
});
it('tests the selected Local CLI agent from onboarding', async () => {
const fetchMock = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
});
}
if (url.endsWith('/api/test/connection') && init?.method === 'POST') {
return jsonResponse({
ok: true,
kind: 'success',
latencyMs: 12,
model: 'sonnet',
sample: 'pong',
agentName: 'Claude Code',
});
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
renderOnboarding({
config: baseConfig({
agentId: 'claude-code',
agentCliEnv: { 'claude-code': { OPEN_DESIGN_TEST: '1' } },
agentModels: { 'claude-code': { model: 'sonnet', reasoning: 'high' } },
}),
agents: [amrAgent(), cliAgent()],
onRefreshAgents: vi.fn(() => [amrAgent(), cliAgent()]),
});
await openLocalRuntimeSetup();
fireEvent.click(screen.getByRole('button', { name: /^Test$/i }));
await waitFor(() => {
expect(screen.getByText(/Claude Code replied in 12 ms/i)).toBeTruthy();
});
const connectionTestCalls = fetchMock.mock.calls.filter(([url]) =>
String(url).endsWith('/api/test/connection'),
);
expect(connectionTestCalls).toHaveLength(1);
expect(JSON.parse(String(connectionTestCalls[0]?.[1]?.body))).toMatchObject({
mode: 'agent',
agentId: 'claude-code',
model: 'sonnet',
reasoning: 'high',
agentCliEnv: { 'claude-code': { OPEN_DESIGN_TEST: '1' } },
});
});
it('renders Local CLI test failures as alerts in onboarding', async () => {
const fetchMock = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
loggedIn: true,
profile: 'prod',
user: { id: 'u', email: 'user@example.com' },
configPath: '/x',
});
}
if (url.endsWith('/api/test/connection') && init?.method === 'POST') {
return jsonResponse({
ok: false,
kind: 'agent_not_installed',
latencyMs: 0,
agentName: 'Claude Code',
});
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
renderOnboarding({
config: baseConfig({ agentId: 'claude-code' }),
agents: [amrAgent(), cliAgent()],
onRefreshAgents: vi.fn(() => [amrAgent(), cliAgent()]),
});
await openLocalRuntimeSetup();
fireEvent.click(screen.getByRole('button', { name: /^Test$/i }));
const alert = await screen.findByRole('alert');
expect(alert.textContent).toContain('Claude Code is not installed or not in PATH.');
});
it('keeps AMR login pending while device authorization is waiting', async () => {
const fetchMock = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' });
}
if (url.endsWith('/api/integrations/vela/login') && init?.method === 'POST') {
return jsonResponse({ pid: 123 }, 202);
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
const props = renderOnboarding();
const signIn = await findCloudSignInButton();
vi.useFakeTimers();
fireEvent.click(signIn);
await act(async () => {});
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
'/api/integrations/vela/login',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: expect.any(String),
}),
);
});
const loginInit = fetchMock.mock.calls.find(([url]) =>
String(url).endsWith('/api/integrations/vela/login'),
)?.[1] as RequestInit;
expect(JSON.parse(String(loginInit.body))).toMatchObject({
attribution: {
entryId: expect.stringMatching(/^od-amr-/u),
sourceProduct: 'open_design',
sourceDetail: 'onboarding_amr_card',
},
});
expect(screen.getByText('Signing in…')).toBeTruthy();
expect(screen.queryByText('Not signed in')).toBeNull();
expect(signIn.hasAttribute('disabled')).toBe(true);
await vi.advanceTimersByTimeAsync(2000);
expect(screen.getByText('Signing in…')).toBeTruthy();
expect(props.onCompleteOnboarding).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: /Cancel sign-in/i })).toBeTruthy();
});
it('shows daemon startup errors when AMR sign-in fails immediately', async () => {
const startupError = 'profile "prod" api URL: is not configured';
const fetchMock = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' });
}
if (url.endsWith('/api/integrations/vela/login') && init?.method === 'POST') {
return jsonResponse({ error: startupError }, 500);
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
renderOnboarding();
await clickCloudSignIn();
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe(startupError);
});
expect(screen.queryByText('Sign-in failed.')).toBeNull();
expect(screen.queryByText('Signing in…')).toBeNull();
});
function mockPendingActivation(browserOpenFailed: boolean) {
let loginStarted = false;
globalThis.fetch = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse(
loginStarted
? {
loggedIn: false,
loginInFlight: true,
activationUrl: 'https://amr.example/activate',
browserOpenFailed,
profile: 'prod',
user: null,
configPath: '/x',
}
: { loggedIn: false, profile: 'prod', user: null, configPath: '/x' },
);
}
if (url.endsWith('/api/integrations/vela/login') && init?.method === 'POST') {
loginStarted = true;
return jsonResponse({ pid: 123 }, 202);
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
}
it('offers to reopen the sign-in page only after the browser has had a few seconds', async () => {
mockPendingActivation(false);
renderOnboarding();
const signIn = await findCloudSignInButton();
vi.useFakeTimers();
fireEvent.click(signIn);
await act(async () => {});
await vi.advanceTimersByTimeAsync(2100);
expect(screen.queryByText("Sign-in page didn't open?")).toBeNull();
await vi.advanceTimersByTimeAsync(3000);
const reopen = screen.getByRole('link', { name: /Reopen/i });
expect(screen.getByText("Sign-in page didn't open?")).toBeTruthy();
expect(reopen.getAttribute('href')).toBe('https://amr.example/activate');
expect(screen.getAllByRole('button', { name: /cancel/i })).toHaveLength(1);
});
it('shows the sign-in link right away when the browser could not be opened', async () => {
mockPendingActivation(true);
renderOnboarding();
const signIn = await findCloudSignInButton();
vi.useFakeTimers();
fireEvent.click(signIn);
await act(async () => {});
await vi.advanceTimersByTimeAsync(2100);
expect(screen.getByText('Couldn’t open the sign-in page')).toBeTruthy();
expect(
screen.getByText('Your browser didn’t open automatically. Please try signing in again.'),
).toBeTruthy();
expect(screen.getByRole('link', { name: /Open sign-in page/i }).getAttribute('href')).toBe(
'https://amr.example/activate',
);
});
it('clears AMR login pending when canceled and allows a fresh sign-in attempt', async () => {
const fetchMock = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' });
}
if (url.endsWith('/api/integrations/vela/login') && init?.method === 'POST') {
return jsonResponse({ pid: 123 }, 202);
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
renderOnboarding();
const signIn = await findCloudSignInButton();
fireEvent.click(signIn);
await act(async () => {});
expect(screen.getByText('Signing in…')).toBeTruthy();
expect(signIn.hasAttribute('disabled')).toBe(true);
expect(screen.queryByRole('button', { name: /Local AI/i })).toBeNull();
expect(screen.queryByRole('button', { name: /API Key/i })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /Cancel sign-in/i }));
await act(async () => {});
expect(screen.queryByText('Signing in…')).toBeNull();
// The landing CTA returns to its signed-out copy and is enabled again.
const cloudButton = await screen.findByRole('button', {
name: /Sign in \/ Sign up/i,
});
expect(cloudButton.hasAttribute('disabled')).toBe(false);
expect(
(screen.getByRole('button', { name: /Local AI/i }) as HTMLButtonElement).disabled,
).toBe(false);
expect(
(screen.getByRole('button', { name: /API Key/i }) as HTMLButtonElement).disabled,
).toBe(false);
fireEvent.click(cloudButton);
await act(async () => {});
expect(screen.getByText('Signing in…')).toBeTruthy();
const loginCalls = fetchMock.mock.calls.filter(
([input, init]) =>
String(input).endsWith('/api/integrations/vela/login') && init?.method === 'POST',
);
expect(loginCalls).toHaveLength(2);
});
it('preserves a pre-start cancel when the status refresh rejects', async () => {
const canonicalAuthAttemptId = '22222222-2222-4222-8222-222222222222';
const newerAuthAttemptId = '33333333-3333-4333-8333-333333333333';
let releaseLogin!: (response: Response) => void;
const heldLoginResponse = new Promise<Response>((resolve) => {
releaseLogin = resolve;
});
let statusCalls = 0;
const cancelAttemptIds: string[] = [];
const statusEvents: string[] = [];
const onStatusEvent = ((event: CustomEvent) => {
statusEvents.push(event.detail?.reason);
}) as EventListener;
window.addEventListener(AMR_LOGIN_STATUS_EVENT, onStatusEvent);
const fetchMock = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
statusCalls += 1;
if (cancelAttemptIds.length === 1) {
throw new Error('status unavailable');
}
const newerAttemptVisible = cancelAttemptIds.length >= 2;
return jsonResponse({
loggedIn: false,
loginInFlight: newerAttemptVisible,
authAttemptId: newerAttemptVisible ? newerAuthAttemptId : undefined,
profile: 'prod',
user: null,
configPath: '/x',
});
}
if (url.endsWith('/api/integrations/vela/login') && init?.method === 'POST') {
return heldLoginResponse;
}
if (url.endsWith('/api/integrations/vela/login/cancel') && init?.method === 'POST') {
const body = JSON.parse(String(init.body)) as { authAttemptId: string };
cancelAttemptIds.push(body.authAttemptId);
return jsonResponse({ canceled: false, pids: [] });
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
renderOnboarding();
const signIn = await findCloudSignInButton();
vi.useFakeTimers();
fireEvent.click(signIn);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/integrations/vela/login',
expect.objectContaining({ method: 'POST' }),
);
fireEvent.click(screen.getByRole('button', { name: /Cancel sign-in/i }));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(cancelAttemptIds).toHaveLength(1);
const statusCallsAfterEarlyCancel = statusCalls;
releaseLogin(jsonResponse({
pid: 123,
authAttemptId: canonicalAuthAttemptId,
}, 202));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
await vi.advanceTimersByTimeAsync(AMR_LOGIN_POLL_INTERVAL_MS);
window.removeEventListener(AMR_LOGIN_STATUS_EVENT, onStatusEvent);
expect(cancelAttemptIds).toEqual([
expect.stringMatching(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
),
canonicalAuthAttemptId,
]);
expect(statusCalls).toBeGreaterThan(statusCallsAfterEarlyCancel + 1);
expect(screen.getByText('Signing in…')).toBeTruthy();
expect(screen.getByRole('button', { name: /Cancel sign-in/i })).toBeTruthy();
expect(statusEvents).not.toContain('login-canceled');
expect(analyticsMocks.track).not.toHaveBeenCalledWith(
'amr_auth_result',
expect.objectContaining({ result: 'cancelled' }),
expect.anything(),
);
});
it('cancels AMR login and re-enables onboarding after the login timeout', async () => {
let loginStarted = false;
let authAttemptId: string | null = null;
const fetchMock = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
loggedIn: false,
loginInFlight: loginStarted,
profile: 'prod',
user: null,
configPath: '/x',
});
}
if (url.endsWith('/api/integrations/vela/login') && init?.method === 'POST') {
const body = JSON.parse(String(init.body)) as { authAttemptId?: string };
authAttemptId = body.authAttemptId ?? null;
expect(authAttemptId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
loginStarted = true;
return jsonResponse({ pid: 123, authAttemptId }, 202);
}
if (url.endsWith('/api/integrations/vela/login/cancel') && init?.method === 'POST') {
expect(JSON.parse(String(init.body))).toEqual({ authAttemptId });
loginStarted = false;
return jsonResponse({ canceled: true, pids: [123] });
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
const props = renderOnboarding();
const signIn = await findCloudSignInButton();
vi.useFakeTimers();
fireEvent.click(signIn);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/integrations/vela/login',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: expect.any(String),
}),
);
expect(screen.getByText('Signing in…')).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(AMR_LOGIN_TIMEOUT_MS);
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/integrations/vela/login/cancel',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ authAttemptId }),
}),
);
expect(screen.getByText('Sign-in failed.')).toBeTruthy();
expect(screen.queryByText('Signing in…')).toBeNull();
expect(
screen
.getByRole('button', { name: /Sign in \/ Sign up/i })
.hasAttribute('disabled'),
).toBe(false);
expect(props.onCompleteOnboarding).not.toHaveBeenCalled();
});
it('continues after AMR device authorization completes during polling', async () => {
let statusCalls = 0;
const fetchMock = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
statusCalls += 1;
return jsonResponse(
statusCalls >= 3
? {
loggedIn: true,
profile: 'prod',
user: { id: 'u', email: 'user@example.com' },
configPath: '/x',
}
: { loggedIn: false, profile: 'prod', user: null, configPath: '/x' },
);
}
if (url.endsWith('/api/integrations/vela/login') && init?.method === 'POST') {
return jsonResponse({ pid: 123 }, 202);
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
const props = renderOnboarding();
const signIn = await findCloudSignInButton();
vi.useFakeTimers();
fireEvent.click(signIn);
await act(async () => {});
expect(screen.getByText('Signing in…')).toBeTruthy();
await vi.advanceTimersByTimeAsync(2000);
await vi.waitFor(() => {
expect(props.onCompleteOnboarding).toHaveBeenCalledTimes(1);
});
});
it('refreshes workspace context, billing, and team projects as soon as onboarding sign-in completes', async () => {
// Onboarding's embedded AMR sign-in step (pollAmrLoginCompletion) used to
// fire only notifyAmrLoginStatusChanged() on success — unlike
// CloudSignInTip's finishSignedIn() and refreshWorkspaceSurfacesAfterOnboarding()
// (the two other places a sign-in completes), which fire all three
// workspace-refresh notifications. That gap left workspaceContext stale
// until finishOnboarding fired it later, so Home's rail briefly rendered
// in its signed-out shape (still showing "Sign in to use OpenDesign
// Cloud") right after a successful onboarding sign-in.
const { WORKSPACE_CONTEXT_REFRESH_EVENT, WORKSPACE_BILLING_REFRESH_EVENT, TEAM_PROJECTS_CHANGED_EVENT } =
await import('../../src/collab/useWorkspaceContext');
const contextRefresh = vi.fn();
const billingRefresh = vi.fn();
const teamProjectsChanged = vi.fn();
window.addEventListener(WORKSPACE_CONTEXT_REFRESH_EVENT, contextRefresh);
window.addEventListener(WORKSPACE_BILLING_REFRESH_EVENT, billingRefresh);
window.addEventListener(TEAM_PROJECTS_CHANGED_EVENT, teamProjectsChanged);
try {
let statusCalls = 0;
const fetchMock = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
statusCalls += 1;
return jsonResponse(
statusCalls >= 3
? {
loggedIn: true,
profile: 'prod',
user: { id: 'u', email: 'user@example.com' },
configPath: '/x',
}
: { loggedIn: false, profile: 'prod', user: null, configPath: '/x' },
);
}
if (url.endsWith('/api/integrations/vela/login') && init?.method === 'POST') {
return jsonResponse({ pid: 123 }, 202);
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
const props = renderOnboarding();
const signIn = await findCloudSignInButton();
vi.useFakeTimers();
fireEvent.click(signIn);
await act(async () => {});
expect(contextRefresh).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(2000);
await vi.waitFor(() => {
expect(props.onCompleteOnboarding).toHaveBeenCalledTimes(1);
});
expect(contextRefresh).toHaveBeenCalled();
expect(billingRefresh).toHaveBeenCalled();
expect(teamProjectsChanged).toHaveBeenCalled();
} finally {
window.removeEventListener(WORKSPACE_CONTEXT_REFRESH_EVENT, contextRefresh);
window.removeEventListener(WORKSPACE_BILLING_REFRESH_EVENT, billingRefresh);
window.removeEventListener(TEAM_PROJECTS_CHANGED_EVENT, teamProjectsChanged);
}
});
it('recovers from a transient status failure during login polling and still continues after authorization completes', async () => {
let statusCalls = 0;
const fetchMock = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
statusCalls += 1;
if (statusCalls === 2) throw new Error('temporary network failure');
return jsonResponse(
statusCalls >= 4
? {
loggedIn: true,
profile: 'prod',
user: { id: 'u', email: 'user@example.com' },
configPath: '/x',
}
: { loggedIn: false, profile: 'prod', user: null, configPath: '/x' },
);
}
if (url.endsWith('/api/integrations/vela/login') && init?.method === 'POST') {
return jsonResponse({ pid: 123 }, 202);
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
const props = renderOnboarding();
const signIn = await findCloudSignInButton();
vi.useFakeTimers();
fireEvent.click(signIn);
await act(async () => {});
expect(screen.getByText('Signing in…')).toBeTruthy();
await vi.advanceTimersByTimeAsync(2000);
expect(screen.getByText('Signing in…')).toBeTruthy();
await vi.advanceTimersByTimeAsync(4000);
await vi.waitFor(() => {
expect(props.onCompleteOnboarding).toHaveBeenCalledTimes(1);
});
});
it('continues normally when OpenDesign AMR is signed in', async () => {
globalThis.fetch = vi.fn(async () =>
jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
}),
) as typeof fetch;
const props = renderOnboarding();
const continueButton = await screen.findByRole('button', { name: /Continue \(signed in\)/i });
expect(screen.queryByText('user@example.com')).toBeNull();
expect(screen.queryByText('Authorized')).toBeNull();
expect(screen.queryByRole('link', { name: /Authorize AMR/i })).toBeNull();
fireEvent.click(continueButton);
await waitFor(() => {
expect(props.onCompleteOnboarding).toHaveBeenCalledTimes(1);
});
});
it('uses provider preferences instead of the first upstream model during BYOK onboarding', async () => {
globalThis.fetch = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
loggedIn: true,
profile: 'prod',
user: { id: 'u', email: 'user@example.com' },
configPath: '/x',
});
}
if (url.endsWith('/api/provider/models') && init?.method === 'POST') {
return jsonResponse({
ok: true,
kind: 'success',
latencyMs: 10,
models: [
{ id: 'upstream-first', label: 'Upstream First' },
{ id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' },
],
});
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
const props = renderOnboarding({
config: baseConfig({
apiProtocol: 'anthropic',
apiKey: 'test-api-key',
baseUrl: 'https://api.anthropic.com',
model: '',
apiProviderBaseUrl: 'https://api.anthropic.com',
}),
});
await openByokRuntimeSetup();
fireEvent.click(screen.getByRole('button', { name: /Fetch models/i }));
await waitFor(() => {
expect(props.onApiModelChange).toHaveBeenCalledWith('claude-sonnet-4-5');
});
expect(props.onApiModelChange).not.toHaveBeenCalledWith('upstream-first');
});
it('tests BYOK on Continue, stays on rate limit, and retries on the next click', async () => {
let testCalls = 0;
globalThis.fetch = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
});
}
if (url.endsWith('/api/provider/models') && init?.method === 'POST') {
return jsonResponse({
ok: true,
kind: 'success',
latencyMs: 10,
models: [{ id: 'gpt-test', label: 'GPT Test' }],
});
}
if (url.endsWith('/api/test/connection') && init?.method === 'POST') {
testCalls += 1;
return testCalls === 1
? jsonResponse({
ok: false,
kind: 'rate_limited',
latencyMs: 12,
model: 'gpt-test',
status: 429,
})
: jsonResponse({
ok: true,
kind: 'success',
latencyMs: 12,
model: 'gpt-test',
sample: 'Connected',
});
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
const props = renderOnboarding({
config: baseConfig({
mode: 'api',
apiProtocol: 'openai',
apiKey: 'test-api-key',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-test',
apiProviderBaseUrl: 'https://api.openai.com/v1',
}),
});
await openByokRuntimeSetup();
const continueButton = screen.getByRole('button', { name: /^Continue$/i });
expect(continueButton.getAttribute('aria-disabled')).toBeNull();
fireEvent.click(continueButton);
expect(await screen.findByText(/rate-limited the test/i)).toBeTruthy();
expect(props.onCompleteOnboarding).not.toHaveBeenCalled();
fireEvent.click(continueButton);
await waitFor(() => {
expect(testCalls).toBe(2);
expect(props.onCompleteOnboarding).toHaveBeenCalledTimes(1);
});
});
it('drops a BYOK validation that lands after its inputs changed', async () => {
// The inputs stay editable while the test is in flight. A success for the
// key the user has already replaced must not complete onboarding.
let releaseTest: ((value: Response) => void) | undefined;
let testCalls = 0;
globalThis.fetch = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
});
}
if (url.endsWith('/api/provider/models') && init?.method === 'POST') {
return jsonResponse({
ok: true,
kind: 'success',
latencyMs: 10,
models: [{ id: 'gpt-test', label: 'GPT Test' }],
});
}
if (url.endsWith('/api/test/connection') && init?.method === 'POST') {
testCalls += 1;
return new Promise<Response>((resolve) => {
releaseTest = resolve;
});
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
const props = renderOnboarding({
config: baseConfig({
mode: 'api',
apiProtocol: 'openai',
apiKey: 'test-api-key',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-test',
apiProviderBaseUrl: 'https://api.openai.com/v1',
}),
});
await openByokRuntimeSetup();
fireEvent.click(screen.getByRole('button', { name: /^Continue$/i }));
await waitFor(() => {
expect(testCalls).toBe(1);
});
fireEvent.change(screen.getByLabelText('API key'), {
target: { value: 'rotated-api-key' },
});
await act(async () => {
releaseTest?.(
jsonResponse({
ok: true,
kind: 'success',
latencyMs: 12,
model: 'gpt-test',
sample: 'Connected',
}),
);
await Promise.resolve();
});
expect(props.onCompleteOnboarding).not.toHaveBeenCalled();
expect(screen.getByRole('heading', { name: 'Bring Your Own Key' })).toBeTruthy();
});
it('persists the BYOK config before finishing onboarding', async () => {
globalThis.fetch = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
});
}
if (url.endsWith('/api/provider/models') && init?.method === 'POST') {
return jsonResponse({
ok: true,
kind: 'success',
latencyMs: 10,
models: [
{ id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' },
{ id: 'claude-opus-4-8', label: 'Claude Opus 4.8' },
],
});
}
if (url.endsWith('/api/test/connection') && init?.method === 'POST') {
return jsonResponse({
ok: true,
kind: 'success',
latencyMs: 12,
model: 'claude-opus-4-8',
sample: 'Connected',
});
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
const props = renderOnboarding();
fireEvent.click(await screen.findByRole('button', { name: /^API Key$/i }));
expect(await screen.findByRole('heading', { name: 'Bring Your Own Key' })).toBeTruthy();
fireEvent.change(screen.getByLabelText('API key'), { target: { value: 'test-api-key' } });
fireEvent.change(screen.getByLabelText('Base URL'), { target: { value: 'https://api.anthropic.com' } });
fireEvent.click(screen.getByRole('button', { name: /Fetch models/i }));
await waitFor(() => {
expect(screen.getByText('Fetched 2 models.')).toBeTruthy();
});
chooseOnboardingOption('Model', /claude-opus-4-8/i);
fireEvent.click(screen.getByRole('button', { name: /^Test$/i }));
await waitFor(() => {
expect(screen.getByText(/Connected\. Replied in 12 ms/i)).toBeTruthy();
});
fireEvent.click(screen.getByRole('button', { name: /^Continue$/i }));
expect(props.onModeChange).not.toHaveBeenCalled();
expect(props.onApiModelChange).toHaveBeenCalledWith('claude-opus-4-8');
expect(props.onConfigPersist).toHaveBeenCalled();
await waitFor(() => {
expect(props.onCompleteOnboarding).toHaveBeenCalledTimes(1);
});
expect((props.onConfigPersist as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[0]).toMatchObject({
mode: 'api',
apiProtocol: 'anthropic',
apiKey: 'test-api-key',
baseUrl: 'https://api.anthropic.com',
model: 'claude-opus-4-8',
apiProviderBaseUrl: null,
});
expect(latestTrackedEvent('onboarding_complete_result')).toMatchObject({
result: 'completed',
exit_step_name: 'runtime_setup',
runtime_type: 'byok',
});
});
it('lets Azure BYOK onboarding enter a custom deployment directly', async () => {
globalThis.fetch = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
loggedIn: true,
profile: 'prod',
user: { id: 'u', email: 'user@example.com' },
configPath: '/x',
});
}
if (url.endsWith('/api/test/connection') && init?.method === 'POST') {
const body = JSON.parse(String(init.body ?? '{}'));
expect(body).toMatchObject({
protocol: 'azure',
apiKey: 'azure-key',
baseUrl: 'https://example.openai.azure.com',
model: 'deployment-one',
});
return jsonResponse({
ok: true,
kind: 'success',
latencyMs: 11,
model: 'deployment-one',
sample: 'Connected',
});
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
const props = renderOnboarding({
config: baseConfig({
mode: 'api',
apiProtocol: 'azure',
apiProviderBaseUrl: '',
}),
});
await openByokRuntimeSetup();
expect(screen.getByRole('tab', { name: 'Azure OpenAI' }).getAttribute('aria-selected')).toBe(
'true',
);
expect((screen.getByRole('button', { name: /Fetch models/i }) as HTMLButtonElement).disabled).toBe(
true,
);
expect(screen.getAllByRole('button', { name: 'Azure OpenAI' }).length).toBeGreaterThan(0);
fireEvent.change(screen.getByLabelText('API key'), { target: { value: 'azure-key' } });
fireEvent.change(screen.getByLabelText('Base URL'), {
target: { value: 'https://example.openai.azure.com' },
});
fireEvent.change(screen.getByLabelText('Deployment name'), {
target: { value: 'deployment-one' },
});
fireEvent.click(screen.getByRole('button', { name: /^Test$/i }));
await waitFor(() => {
expect(screen.getByText(/Connected\. Replied in 11 ms/i)).toBeTruthy();
});
expect(props.onApiModelChange).toHaveBeenCalledWith('deployment-one');
expect((props.onConfigPersist as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[0]).toMatchObject({
mode: 'api',
apiProtocol: 'azure',
apiKey: 'azure-key',
apiProviderBaseUrl: '',
baseUrl: 'https://example.openai.azure.com',
model: 'deployment-one',
});
});
it('keeps the cloud sign-in landing stable while AMR detection is still in flight', async () => {
globalThis.fetch = vi.fn(async () =>
jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' }),
) as typeof fetch;
renderOnboarding({
agents: [cliAgent()], // AMR has not surfaced from the stream yet
agentsLoading: true, // cold-start detection stream still running
onRefreshAgents: vi.fn(() => [cliAgent()]),
});
expect(screen.getByRole('heading', { name: 'Welcome to OpenDesign' })).toBeTruthy();
const primary = screen.getByRole('button', { name: /Loading/i });
expect(primary).toBeTruthy();
expect(primary.getAttribute('aria-busy')).toBe('true');
expect((primary as HTMLButtonElement).disabled).toBe(true);
expect(document.querySelector('.onboarding-view__card--skeleton')).toBeNull();
expect(screen.queryByRole('button', { name: /OpenDesign AMR/i })).toBeNull();
expect(
(screen.getByRole('button', { name: /Local AI/i }) as HTMLButtonElement).disabled,
).toBe(false);
expect(
(screen.getByRole('button', { name: /API Key/i }) as HTMLButtonElement).disabled,
).toBe(false);
});
it('renders the cloud sign-in CTA and no legacy AMR card once AMR is available', async () => {
globalThis.fetch = vi.fn(async () =>
jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' }),
) as typeof fetch;
renderOnboarding({ agentsLoading: false });
expect(await findCloudSignInButton()).toBeTruthy();
expect(screen.queryByRole('button', { name: /OpenDesign AMR/i })).toBeNull();
expect(document.querySelector('.onboarding-view__card--skeleton')).toBeNull();
});
it('keeps the cloud sign-in CTA visible after detection settles without surfacing AMR', async () => {
globalThis.fetch = vi.fn(async () =>
jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' }),
) as typeof fetch;
renderOnboarding({
agents: [cliAgent()],
agentsLoading: false,
onRefreshAgents: vi.fn(() => [cliAgent()]),
});
expect(
await screen.findByRole('button', { name: /Sign in \/ Sign up/i }),
).toBeTruthy();
expect(screen.queryByRole('button', { name: /OpenDesign AMR/i })).toBeNull();
expect(document.querySelector('.onboarding-view__card--skeleton')).toBeNull();
});
it('shows no Skip affordance on the Connect step', async () => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL) =>
jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' }),
);
globalThis.fetch = fetchMock as typeof fetch;
const props = renderOnboarding();
await act(async () => {});
// "Skip for now" was removed — Connect is a required step. The Connect
// step exposes no secondary Skip/Back button, onboarding is not completed
// from here, and no skip telemetry fires.
expect(screen.queryByRole('button', { name: /Skip/i })).toBeNull();
expect(screen.queryByRole('button', { name: /^Back$/i })).toBeNull();
expect(props.onCompleteOnboarding).not.toHaveBeenCalled();
const skipClicks = trackedEvents('ui_click')
.map(([, payload]) => payload as Record<string, unknown>)
.filter((payload) => payload.element === 'skip');
expect(skipClicks).toHaveLength(0);
expect(trackedEvents('onboarding_complete_result')).toHaveLength(0);
});
});