diff --git a/app/src/pages/onboarding/Onboarding.tsx b/app/src/pages/onboarding/Onboarding.tsx index 23fb9b41c..cec4421ba 100644 --- a/app/src/pages/onboarding/Onboarding.tsx +++ b/app/src/pages/onboarding/Onboarding.tsx @@ -6,7 +6,6 @@ import { userApi } from '../../services/api/userApi'; import { getDefaultEnabledTools } from '../../utils/toolDefinitions'; import ContextGatheringStep from './steps/ContextGatheringStep'; import ReferralApplyStep from './steps/ReferralApplyStep'; -import ScreenPermissionsStep from './steps/ScreenPermissionsStep'; import SkillsStep from './steps/SkillsStep'; import WelcomeStep from './steps/WelcomeStep'; @@ -16,7 +15,6 @@ interface OnboardingProps { } interface OnboardingDraft { - accessibilityPermissionGranted: boolean; connectedSources: string[]; } @@ -29,7 +27,7 @@ function hasReferralFromProfile( return !!(user?.referral?.invitedBy || user?.referral?.invitedByCode); } -/** When referral is skipped, step index 1 (apply) is not shown — treat as screen permissions (2). */ +/** When referral is skipped, step index 1 (apply) is not shown — treat as skills (2). */ function resolveOnboardingStep(currentStep: number, skipReferralStep: boolean): number { if (skipReferralStep && currentStep === 1) { return 2; @@ -40,10 +38,7 @@ function resolveOnboardingStep(currentStep: number, skipReferralStep: boolean): const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => { const { setOnboardingCompletedFlag, setOnboardingTasks, snapshot } = useCoreState(); const [currentStep, setCurrentStep] = useState(0); - const [draft, setDraft] = useState({ - accessibilityPermissionGranted: false, - connectedSources: [], - }); + const [draft, setDraft] = useState({ connectedSources: [] }); /** Last session token for which referral stats prefetch finished (async path only). */ const [referralStatsToken, setReferralStatsToken] = useState(null); const [skipReferralFromStats, setSkipReferralFromStats] = useState(false); @@ -104,7 +99,7 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => { const handleNext = () => { const logical = resolveOnboardingStep(currentStep, skipReferralStep); - if (logical < 4) { + if (logical < 3) { setCurrentStep(logical + 1); } }; @@ -122,25 +117,27 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => { setCurrentStep(logical - 1); }; - const handleAccessibilityNext = (accessibilityPermissionGranted: boolean) => { - setDraft(prev => ({ ...prev, accessibilityPermissionGranted })); - handleNext(); - }; - const handleSkillsNext = async (connectedSources: string[]) => { console.debug('[onboarding:handleSkillsNext]', { connectedSources }); setDraft(prev => ({ ...prev, connectedSources })); - handleNext(); + if (connectedSources.length === 0) { + // No sources connected — skip context gathering and finish onboarding. + await handleContextNext(connectedSources); + } else { + handleNext(); + } }; - const handleContextNext = async () => { - console.debug('[onboarding:handleContextNext]', { connectedSources: draft.connectedSources }); + const handleContextNext = async (connectedSourcesOverride?: string[]) => { + const sources = connectedSourcesOverride ?? draft.connectedSources; + console.debug('[onboarding:handleContextNext]', { connectedSources: sources }); await setOnboardingTasks({ - accessibilityPermissionGranted: draft.accessibilityPermissionGranted, + accessibilityPermissionGranted: + snapshot.localState.onboardingTasks?.accessibilityPermissionGranted ?? false, localModelConsentGiven: false, localModelDownloadStarted: false, enabledTools: getDefaultEnabledTools(), - connectedSources: draft.connectedSources, + connectedSources: sources, updatedAtMs: Date.now(), }); @@ -185,10 +182,8 @@ const Onboarding = ({ onComplete, onDefer }: OnboardingProps) => { /> ); case 2: - return ; - case 3: return ; - case 4: + case 3: return ( void; - onBack?: () => void; -} - -const ScreenPermissionsStep = ({ onNext, onBack: _onBack }: ScreenPermissionsStepProps) => { - const { - status, - isLoading, - isRequestingPermissions, - isRestartingCore, - lastError, - requestPermission, - refreshPermissionsWithRestart, - } = useScreenIntelligenceState({ pollMs: 2000 }); - const [shouldAutoRefreshOnReturn, setShouldAutoRefreshOnReturn] = useState(false); - - const accessibilityPermission = status?.permissions.accessibility ?? 'unknown'; - const isGranted = accessibilityPermission === 'granted'; - - useEffect(() => { - if (!shouldAutoRefreshOnReturn) { - return; - } - - const refreshAfterReturn = () => { - if (document.visibilityState !== 'visible' || isLoading || isRestartingCore) { - return; - } - - if (isGranted) { - setShouldAutoRefreshOnReturn(false); - return; - } - - setShouldAutoRefreshOnReturn(false); - void refreshPermissionsWithRestart(); - }; - - window.addEventListener('focus', refreshAfterReturn); - document.addEventListener('visibilitychange', refreshAfterReturn); - - return () => { - window.removeEventListener('focus', refreshAfterReturn); - document.removeEventListener('visibilitychange', refreshAfterReturn); - }; - }, [ - isGranted, - isLoading, - isRestartingCore, - refreshPermissionsWithRestart, - shouldAutoRefreshOnReturn, - ]); - - const handleRequestPermissions = () => { - setShouldAutoRefreshOnReturn(true); - void requestPermission('accessibility'); - }; - - return ( -
-
-

- Screen & Accessibility Permissions -

-

- OpenHuman uses information from your screen to constantly build context about your - workflow and assist you with desktop actions. -

-
- -
-
-

Complete Privacy

-

- All screenshots and accessibility information gets processed locally by your local AI - model. No data is sent to any third party or cloud. -

-
-
-

- Current permission state -

-
- Accessibility - - {accessibilityPermission} - -
-
-
- - {!isGranted ? ( -
-
- - -
- onNext(isGranted)} /> - {(lastError || status?.permission_check_process_path) && ( -
- {shouldAutoRefreshOnReturn ? ( -

- After granting access in System Settings, return here and OpenHuman will refresh - automatically. -

- ) : null} - {lastError ?

{lastError}

: null} - {status?.permission_check_process_path ? ( -

- Grant access for: {status.permission_check_process_path} -

- ) : null} -
- )} -
- ) : ( - onNext(isGranted)} /> - )} -
- ); -}; - -export default ScreenPermissionsStep; diff --git a/app/src/pages/onboarding/steps/__tests__/ScreenPermissionsStep.test.tsx b/app/src/pages/onboarding/steps/__tests__/ScreenPermissionsStep.test.tsx deleted file mode 100644 index b0a150345..000000000 --- a/app/src/pages/onboarding/steps/__tests__/ScreenPermissionsStep.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { - type ScreenIntelligenceState, - useScreenIntelligenceState, -} from '../../../../features/screen-intelligence/useScreenIntelligenceState'; -import ScreenPermissionsStep from '../ScreenPermissionsStep'; - -vi.mock('../../../../features/screen-intelligence/useScreenIntelligenceState', () => ({ - useScreenIntelligenceState: vi.fn(), -})); - -const deniedState: ScreenIntelligenceState = { - status: { - platform_supported: true, - core_process: { pid: 4242, started_at_ms: 1712700000000 }, - permissions: { - screen_recording: 'unknown', - accessibility: 'denied', - input_monitoring: 'unknown', - }, - features: { screen_monitoring: true }, - session: { - active: false, - started_at_ms: null, - expires_at_ms: null, - remaining_ms: null, - ttl_secs: 300, - panic_hotkey: 'Cmd+Shift+.', - stop_reason: null, - frames_in_memory: 0, - last_capture_at_ms: null, - last_context: null, - vision_enabled: true, - vision_state: 'idle', - vision_queue_depth: 0, - last_vision_at_ms: null, - last_vision_summary: null, - }, - config: { - enabled: true, - capture_policy: 'hybrid', - policy_mode: 'all_except_blacklist', - baseline_fps: 1, - vision_enabled: true, - session_ttl_secs: 300, - panic_stop_hotkey: 'Cmd+Shift+.', - autocomplete_enabled: true, - use_vision_model: true, - keep_screenshots: false, - allowlist: [], - denylist: [], - }, - denylist: [], - is_context_blocked: false, - permission_check_process_path: '/tmp/openhuman-core-x86_64-apple-darwin', - }, - lastRestartSummary: null, - recentVisionSummaries: [], - captureTestResult: null, - isCaptureTestRunning: false, - isLoading: false, - isRequestingPermissions: false, - isRestartingCore: false, - isStartingSession: false, - isStoppingSession: false, - isLoadingVision: false, - isFlushingVision: false, - lastError: null, - refreshStatus: vi.fn().mockResolvedValue(null), - requestPermission: vi.fn().mockResolvedValue(null), - refreshPermissionsWithRestart: vi.fn().mockResolvedValue(null), - startSession: vi.fn().mockResolvedValue(null), - stopSession: vi.fn().mockResolvedValue(null), - refreshVision: vi.fn().mockResolvedValue([]), - flushVision: vi.fn().mockResolvedValue(undefined), - runCaptureTest: vi.fn().mockResolvedValue(undefined), - clearError: vi.fn(), -}; - -const originalVisibilityDescriptor = Object.getOwnPropertyDescriptor(document, 'visibilityState'); - -describe('ScreenPermissionsStep', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(useScreenIntelligenceState).mockReturnValue(deniedState); - }); - - afterEach(() => { - if (originalVisibilityDescriptor) { - Object.defineProperty(document, 'visibilityState', originalVisibilityDescriptor); - } - }); - - it('auto-refreshes permissions after returning from System Settings', async () => { - const onNext = vi.fn(); - - render( - - - - ); - - await screen.findByText('Screen & Accessibility Permissions'); - - fireEvent.click(screen.getByRole('button', { name: 'Request Permissions' })); - - expect(await screen.findByText(/OpenHuman will refresh automatically/i)).toBeInTheDocument(); - - Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' }); - fireEvent(window, new Event('focus')); - - await waitFor(() => { - expect(deniedState.refreshPermissionsWithRestart).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/app/test/e2e/specs/login-flow.spec.ts b/app/test/e2e/specs/login-flow.spec.ts index 4222f4b13..9f377c29c 100644 --- a/app/test/e2e/specs/login-flow.spec.ts +++ b/app/test/e2e/specs/login-flow.spec.ts @@ -9,13 +9,11 @@ * 3. App receives JWT, dispatches to Redux authSlice * 4. UserProvider calls GET /auth/me (mock server) * - * Phase 2 — Onboarding steps (6 steps in Onboarding.tsx): - * Step 0: WelcomeStep — "Continue" - * Step 1: LocalAIStep — "Continue" - * Step 2: ScreenPermissions — "Continue Without Permission" or "Continue" - * Step 3: ToolsStep — "Continue" - * Step 4: SkillsStep — "Finish Setup" - * Step 5: MnemonicStep — checkbox + "Finish Setup" + * Phase 2 — Onboarding steps (4 steps in Onboarding.tsx): + * Step 0: WelcomeStep — "Continue" + * Step 1: ReferralApplyStep — "Apply code" or "Skip for now" (conditional) + * Step 2: SkillsStep — "Continue" or "Skip for Now" + * Step 3: ContextGatheringStep — "Continue" (skipped if no sources connected) * * Phase 3 — Completion verification: * - App calls POST /settings/onboarding-complete (from SkillsStep) @@ -199,12 +197,10 @@ describe('Login flow — complete with mock data (Linux)', () => { // browser.execute() works, so we can interact with the WebView DOM. // // Steps in order: - // 0: WelcomeStep — "Continue" button - // 1: LocalAIStep — "Continue" - // 2: ScreenPermissions — "Continue Without Permission" or "Continue" - // 3: ToolsStep — "Continue" button - // 4: SkillsStep — "Finish Setup" button (fires onboarding-complete) - // 5: MnemonicStep — checkbox + "Finish Setup" button + // 0: WelcomeStep — "Continue" button + // 1: ReferralApplyStep — "Apply code" or "Skip for now" (conditional) + // 2: SkillsStep — "Continue" or "Skip for Now" + // 3: ContextGatheringStep — "Continue" (skipped if no sources connected) // ----------------------------------------------------------------------- it('onboarding overlay or home page is visible', async () => { @@ -256,41 +252,23 @@ describe('Login flow — complete with mock data (Linux)', () => { await browser.pause(2_000); } - // Step 1: LocalAIStep — "Continue" button + // Step 1: ReferralApplyStep — "Skip for now" (conditional, may be skipped automatically) { - const clicked = await clickFirstMatch(['Continue'], 10_000); - if (clicked) { - console.log(`[LoginFlow] LocalAIStep: clicked "${clicked}"`); - await browser.pause(2_000); - } - } - - // Step 2: ScreenPermissionsStep — click "Continue Without Permission" (no accessibility on Linux CI) - { - const clicked = await clickFirstMatch(['Continue Without Permission', 'Continue'], 10_000); - if (clicked) { - console.log(`[LoginFlow] ScreenPermissionsStep: clicked "${clicked}"`); - await browser.pause(2_000); - } - } - - // Step 3: ToolsStep — click "Continue" (keep defaults) - { - const toolsVisible = await textExists('Enable Tools'); - if (toolsVisible) { - const clicked = await clickFirstMatch(['Continue'], 10_000); + const referralVisible = await textExists('Referral code'); + if (referralVisible) { + const clicked = await clickFirstMatch(['Skip for now', 'Continue'], 10_000); if (clicked) { - console.log(`[LoginFlow] ToolsStep: clicked "${clicked}"`); + console.log(`[LoginFlow] ReferralApplyStep: clicked "${clicked}"`); await browser.pause(2_000); } } } - // Step 4: SkillsStep — click "Continue" (no skills connected in E2E) + // Step 2: SkillsStep — click "Skip for Now" (no skills connected in E2E) { - const skillsVisible = await textExists('Install Skills'); + const skillsVisible = await textExists('Connect Gmail'); if (skillsVisible) { - const clicked = await clickFirstMatch(['Continue'], 10_000); + const clicked = await clickFirstMatch(['Skip for Now', 'Continue'], 10_000); if (clicked) { console.log(`[LoginFlow] SkillsStep: clicked "${clicked}"`); await browser.pause(3_000); @@ -298,31 +276,13 @@ describe('Login flow — complete with mock data (Linux)', () => { } } - // Step 5: MnemonicStep — tick the checkbox and click "Finish Setup" + // Step 3: ContextGatheringStep — click "Continue" (skipped when no sources connected) { - const mnemonicVisible = await textExists('Your Recovery Phrase'); - if (mnemonicVisible) { - console.log('[LoginFlow] MnemonicStep: visible'); - - // Tick the "I have saved my recovery phrase" checkbox - try { - const checked = await browser.execute(() => { - const checkbox = document.querySelector('input[type="checkbox"]') as HTMLInputElement; - if (checkbox && !checkbox.checked) { - checkbox.click(); - return true; - } - return checkbox?.checked ?? false; - }); - console.log(`[LoginFlow] MnemonicStep: checkbox checked=${checked}`); - } catch (err) { - console.log('[LoginFlow] MnemonicStep: checkbox click failed:', err); - } - - await browser.pause(1_000); - const clicked = await clickFirstMatch(['Finish Setup'], 10_000); + const contextVisible = await textExists('Preparing Your Context'); + if (contextVisible) { + const clicked = await clickFirstMatch(['Continue'], 10_000); if (clicked) { - console.log(`[LoginFlow] MnemonicStep: clicked "${clicked}"`); + console.log(`[LoginFlow] ContextGatheringStep: clicked "${clicked}"`); await browser.pause(3_000); } }