mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* 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>
2548 lines
90 KiB
TypeScript
2548 lines
90 KiB
TypeScript
// @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);
|
||
});
|
||
});
|