Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ src-tauri/gen/apple/
.cache/
.yalc
.yalc.lock
yalc.lock

# signing artifacts (never commit provisioning profiles)
*.provisionprofile
Expand Down
1 change: 1 addition & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@import 'tailwindcss';
@source "../node_modules/@iblai/web-containers/dist";
@import 'tw-animate-css';
@tailwind utilities;

Expand Down
96 changes: 96 additions & 0 deletions app/onboarding/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';

const { state, push } = vi.hoisted(() => ({
state: { tenantKey: 'acme' },
push: vi.fn(),
}));

vi.mock('@/hooks/use-onboarding', () => ({
useOnboardingAccess: () => ({ canAccess: true, tenantKey: state.tenantKey }),
}));

vi.mock('@/hooks/use-user', () => ({ useUsername: () => 'tester' }));
vi.mock('next/navigation', () => ({ useRouter: () => ({ push }) }));

vi.mock('@/lib/initial-loader', () => ({ hideInitialLoader: vi.fn() }));
vi.mock('@/components/spinner', () => ({ Spinner: () => <div>spinner</div> }));

// The OS-local final screen; stub it so the page test doesn't pull the data layer.
vi.mock('@/components/onboarding/onboarding-create-agent-step', () => ({
OnboardingCreateAgentStep: () => <div>create-agent-step</div>,
}));

// The route renders the SDK wizard from @iblai/iblai-js/web-containers; stub it,
// expose onComplete, and render whatever final step the page passes. The wizard
// persists the answers itself (default), so the page no longer wires that.
vi.mock('@iblai/iblai-js/web-containers', () => ({
OnboardingWizard: ({
tenant,
username,
onComplete,
renderFinalStep,
}: {
tenant: string;
username: string;
onComplete: (agentId: string | null) => void;
renderFinalStep?: (context: unknown) => React.ReactNode;
}) => (
<div>
<div>
onboarding-wizard:{tenant}:{username}
</div>
<button type="button" onClick={() => onComplete('agent-1')}>
complete-with-agent
</button>
<button type="button" onClick={() => onComplete(null)}>
complete-no-agent
</button>
<div>
{renderFinalStep?.({
tenant,
username,
answers: { organizationName: '', sector: null },
goBack: () => {},
complete: () => {},
})}
</div>
</div>
),
}));

import OnboardingPage from '../page';

describe('OnboardingPage', () => {
beforeEach(() => {
vi.clearAllMocks();
state.tenantKey = 'acme';
});

it('renders the SDK wizard with the tenant + username and the OS create-agent final step', () => {
render(<OnboardingPage />);
expect(
screen.getByText('onboarding-wizard:acme:tester'),
).toBeInTheDocument();
expect(screen.getByText('create-agent-step')).toBeInTheDocument();
});

it('shows a loader while the current tenant resolves', () => {
state.tenantKey = '';
render(<OnboardingPage />);
expect(screen.getByText('spinner')).toBeInTheDocument();
expect(screen.queryByText(/onboarding-wizard/)).not.toBeInTheDocument();
});

it('redirects to the agent explore page on complete (tenant-level when no agent)', () => {
render(<OnboardingPage />);
fireEvent.click(
screen.getByRole('button', { name: 'complete-with-agent' }),
);
expect(push).toHaveBeenCalledWith('/platform/acme/agent-1/explore');

fireEvent.click(screen.getByRole('button', { name: 'complete-no-agent' }));
expect(push).toHaveBeenCalledWith('/platform/acme/explore');
});
});
53 changes: 53 additions & 0 deletions app/onboarding/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
'use client';

import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { OnboardingWizard } from '@iblai/iblai-js/web-containers';

import { Spinner } from '@/components/spinner';
import { hideInitialLoader } from '@/lib/initial-loader';
import { useOnboardingAccess } from '@/hooks/use-onboarding';
import { useUsername } from '@/hooks/use-user';
import { OnboardingCreateAgentStep } from '@/components/onboarding/onboarding-create-agent-step';

/**
* The `/onboarding` route renders the SDK onboarding wizard
* (`@iblai/iblai-js/web-containers`). The app supplies identity (tenant +
* username) and the create-agent final step; the wizard persists the answers
* itself (under the user's platform metadata `onboarding` key) since no
* `onAnswersSubmit` is passed. On completion it redirects to an agent's explore
* page. It performs NO automatic redirects into onboarding; access gating is
* wired up elsewhere. While the current tenant resolves, a brief loader shows.
*/
export default function OnboardingPage() {
const { tenantKey } = useOnboardingAccess();
const username = useUsername();
const router = useRouter();

useEffect(() => {
hideInitialLoader();
}, []);

if (!tenantKey) {
return (
<div className="flex h-dvh w-screen items-center justify-center">
<Spinner className="h-14 w-14" />
</div>
);
}

return (
<OnboardingWizard
tenant={tenantKey}
username={username ?? ''}
onComplete={(agentId) =>
router.push(
agentId
? `/platform/${tenantKey}/${agentId}/explore`
: `/platform/${tenantKey}/explore`,
)
}
renderFinalStep={(context) => <OnboardingCreateAgentStep {...context} />}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';

const { mockCreateMentor, mockUnwrap } = vi.hoisted(() => ({
mockCreateMentor: vi.fn(),
mockUnwrap: vi.fn(),
}));
const mockUseCreateMentorMutation = vi.fn();
const mockUseGetMentorsQuery = vi.fn();

vi.mock('@iblai/iblai-js/data-layer', () => ({
useCreateMentorMutation: (...args: unknown[]) =>
mockUseCreateMentorMutation(...args),
useGetMentorsQuery: (...args: unknown[]) => mockUseGetMentorsQuery(...args),
}));

// Stub the SDK shell pieces so the test doesn't load the whole web-containers bundle.
vi.mock('@iblai/iblai-js/web-containers', () => ({
StepHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
onboardingPrimaryButtonClass: 'btn',
}));

vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('@/lib/config', () => ({
config: { iblTemplateMentor: () => 'ai-mentor' },
}));

import { OnboardingCreateAgentStep } from '../onboarding-create-agent-step';

const baseContext = {
tenant: 'acme',
username: 'tester',
answers: { organizationName: 'Acme', segment: 'higher_education' },
suggestedAgent: { name: 'Campus Assistant', description: 'Helps students.' },
goBack: vi.fn(),
};

beforeEach(() => {
vi.clearAllMocks();
mockUnwrap.mockResolvedValue({ unique_id: 'new-agent' });
mockCreateMentor.mockReturnValue({ unwrap: mockUnwrap });
mockUseCreateMentorMutation.mockReturnValue([
mockCreateMentor,
{ isLoading: false },
]);
mockUseGetMentorsQuery.mockReturnValue({
data: { count: 1, results: [{ unique_id: 'existing-agent' }] },
isLoading: false,
});
});

describe('OnboardingCreateAgentStep', () => {
it('prefills name + description from the segment suggestion', () => {
render(<OnboardingCreateAgentStep {...baseContext} complete={vi.fn()} />);
expect(screen.getByText('Make your first agent')).toBeInTheDocument();
expect((screen.getByLabelText('Name') as HTMLInputElement).value).toBe(
'Campus Assistant',
);
expect(
(screen.getByLabelText('Description') as HTMLTextAreaElement).value,
).toBe('Helps students.');
});

it('kicks off creation without waiting and completes with an existing agent id', async () => {
// Slow endpoint — never resolves, to prove we don't wait for it.
mockUnwrap.mockReturnValue(new Promise(() => {}));
const complete = vi.fn();
render(<OnboardingCreateAgentStep {...baseContext} complete={complete} />);

fireEvent.click(screen.getByRole('button', { name: 'Create agent' }));

await waitFor(() => expect(mockCreateMentor).toHaveBeenCalledTimes(1));
const payload = mockCreateMentor.mock.calls[0][0];
expect(payload.org).toBe('acme');
expect(payload.userId).toBe('tester');
expect(payload.formData.new_mentor_name).toBe('Campus Assistant');
expect(payload.formData.mentor_visibility).toBe(
'viewable_by_tenant_students',
);
expect(payload.formData.metadata).toEqual({ category: null });
expect(payload.formData.categories).toBeUndefined();

// Completes IMMEDIATELY with the EXISTING agent's id (not the new one).
expect(complete).toHaveBeenCalledWith('existing-agent');
});

it('completes with null when the tenant has no agents yet', () => {
mockUseGetMentorsQuery.mockReturnValue({
data: { count: 0, results: [] },
isLoading: false,
});
const complete = vi.fn();
render(<OnboardingCreateAgentStep {...baseContext} complete={complete} />);
fireEvent.click(screen.getByRole('button', { name: 'Create agent' }));
expect(complete).toHaveBeenCalledWith(null);
});

it('swaps the form for a loading state once submitted', () => {
// Slow endpoint that never resolves — the loading state must show regardless.
mockUnwrap.mockReturnValue(new Promise(() => {}));
render(<OnboardingCreateAgentStep {...baseContext} complete={vi.fn()} />);

fireEvent.click(screen.getByRole('button', { name: 'Create agent' }));

expect(screen.getByRole('status')).toHaveTextContent(
'Creating your agent…',
);
// The form is replaced.
expect(screen.queryByLabelText('Name')).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Create agent' }),
).not.toBeInTheDocument();
});
});
Loading
Loading