|
| 1 | +/** |
| 2 | + * ObjectUI |
| 3 | + * Copyright (c) 2024-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * This source code is licensed under the MIT license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. |
| 7 | + */ |
| 8 | + |
| 9 | +/** |
| 10 | + * Native shell mode, as the real `App` wires it to its router. |
| 11 | + * |
| 12 | + * The ObjectStack mobile shell injects `window.ObjectStackNative` before the |
| 13 | + * Console's scripts run. With a fake bridge installed before `App` renders, |
| 14 | + * the Console must post `ready()` once, after the first route has rendered, |
| 15 | + * post `routeChanged` with the router path on every navigation, and move its |
| 16 | + * own router (no document reload) when the shell dispatches |
| 17 | + * `objectstack-native:navigate`. Without the global, every one of those is |
| 18 | + * absent: that is the control half of each case. |
| 19 | + * |
| 20 | + * `App` is the real module under the real `BrowserRouter`; the pages and the |
| 21 | + * shell chrome are stubs, as in `App.docsPortalLazy.test.tsx`, because this |
| 22 | + * file is about the router wiring, not about what the pages draw. |
| 23 | + */ |
| 24 | + |
| 25 | +import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest'; |
| 26 | +import { render, screen, cleanup, act } from '@testing-library/react'; |
| 27 | +import type { ReactNode } from 'react'; |
| 28 | +import { |
| 29 | + NATIVE_HOST_GLOBAL, |
| 30 | + NATIVE_NAVIGATE_EVENT, |
| 31 | + resetNativeHostEventsForTests, |
| 32 | + type ObjectStackNative, |
| 33 | +} from '../../../../packages/mobile/src/nativeHost'; |
| 34 | +import { resetNativeHostWatchForTests } from '../../../../packages/mobile/src/useNativeHost'; |
| 35 | + |
| 36 | +const { passthrough, stub } = vi.hoisted(() => { |
| 37 | + const passthrough = ({ children }: { children?: ReactNode }) => <>{children}</>; |
| 38 | + const stub = (testid: string) => () => <div data-testid={testid} />; |
| 39 | + return { passthrough, stub }; |
| 40 | +}); |
| 41 | + |
| 42 | +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ |
| 43 | + ...(await importOriginal<Record<string, unknown>>()), |
| 44 | + ConsoleShell: passthrough, |
| 45 | + ConsoleToaster: () => null, |
| 46 | + LoadingScreen: stub('loading-screen'), |
| 47 | + RedirectWithSplash: stub('redirect-with-splash'), |
| 48 | + RequireAiSurface: passthrough, |
| 49 | + SystemRedirect: () => null, |
| 50 | + DefaultHomeLayout: passthrough, |
| 51 | + DefaultHomePage: stub('home-page'), |
| 52 | + DefaultOrganizationsLayout: passthrough, |
| 53 | + DefaultOrganizationsPage: stub('organizations-page'), |
| 54 | + DefaultOrganizationLayout: stub('organization-layout'), |
| 55 | + DefaultMembersPage: stub('members-page'), |
| 56 | + DefaultInvitationsPage: stub('invitations-page'), |
| 57 | + DefaultSettingsPage: stub('settings-page'), |
| 58 | + DefaultAcceptInvitationPage: stub('accept-invitation-page'), |
| 59 | + DefaultAiChatPage: stub('ai-chat-page'), |
| 60 | + StudioDesignSurface: stub('studio-design-surface'), |
| 61 | + BuilderLanding: stub('builder-landing'), |
| 62 | + getProductName: () => 'ObjectOS', |
| 63 | + getFaviconUrl: () => '', |
| 64 | +})); |
| 65 | + |
| 66 | +vi.mock('@object-ui/auth', async (importOriginal) => ({ |
| 67 | + ...(await importOriginal<Record<string, unknown>>()), |
| 68 | + AuthProvider: passthrough, |
| 69 | + useAuth: () => ({ user: { id: 'u1' } }), |
| 70 | +})); |
| 71 | + |
| 72 | +vi.mock('../AppContent', () => ({ |
| 73 | + AppContent: stub('app-content'), |
| 74 | + ConsolePermissionsScope: ({ children }: { children?: ReactNode }) => <>{children}</>, |
| 75 | +})); |
| 76 | +vi.mock('../components/ProtectedRoute', () => ({ ProtectedRoute: passthrough })); |
| 77 | +vi.mock('../components/RootLandingRedirect', () => ({ RootLandingRedirect: stub('root-landing') })); |
| 78 | +vi.mock('../components/SetupRoute', () => ({ SetupRoute: stub('setup-route') })); |
| 79 | +vi.mock('../components/FormPage', () => ({ FormPage: stub('form-page') })); |
| 80 | +vi.mock('../components/InternalFormRoute', () => ({ InternalFormRoute: stub('internal-form-route') })); |
| 81 | +vi.mock('../components/MetadataHmrReloader', () => ({ MetadataHmrReloader: () => null })); |
| 82 | +vi.mock('../pages/auth/LoginPage', () => ({ LoginPage: stub('login-page') })); |
| 83 | +vi.mock('../pages/auth/RegisterPage', () => ({ RegisterPage: stub('register-page') })); |
| 84 | +vi.mock('../pages/auth/ForgotPasswordPage', () => ({ ForgotPasswordPage: stub('forgot-page') })); |
| 85 | +vi.mock('../pages/auth/ResetPasswordPage', () => ({ ResetPasswordPage: stub('reset-page') })); |
| 86 | +vi.mock('../pages/auth/SetPasswordPage', () => ({ SetPasswordPage: stub('set-password-page') })); |
| 87 | +vi.mock('../pages/auth/VerifyEmailPage', () => ({ VerifyEmailPage: stub('verify-email-page') })); |
| 88 | +vi.mock('../pages/auth/VerifyEmailPromptPage', () => ({ VerifyEmailPromptPage: stub('verify-prompt-page') })); |
| 89 | +vi.mock('../pages/auth/OAuthConsentPage', () => ({ OAuthConsentPage: stub('oauth-consent-page') })); |
| 90 | +vi.mock('../pages/auth/DeviceAuthPage', () => ({ DeviceAuthPage: stub('device-auth-page') })); |
| 91 | +vi.mock('../dev/DevMasterDetail', () => ({ DevMasterDetail: stub('dev-master-detail') })); |
| 92 | +vi.mock('../dev/DevLists', () => ({ DevLists: stub('dev-lists') })); |
| 93 | +vi.mock('../dev/DevModal', () => ({ DevModal: stub('dev-modal') })); |
| 94 | +vi.mock('../dev/DevLookup', () => ({ DevLookup: stub('dev-lookup') })); |
| 95 | +vi.mock('../dev/DevRowActions', () => ({ DevRowActions: stub('dev-row-actions') })); |
| 96 | +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); |
| 97 | + |
| 98 | +import { App } from '../App'; |
| 99 | + |
| 100 | +/** The fake bridge: the contract's members, recording what the Console posts. */ |
| 101 | +function installBridge() { |
| 102 | + const events: string[] = []; |
| 103 | + const bridge: ObjectStackNative = { |
| 104 | + version: 1, |
| 105 | + platform: 'android', |
| 106 | + appVersion: '2.0.0', |
| 107 | + capabilities: ['getPushToken', 'haptic', 'openExternal', 'navigate', 'resetServer'], |
| 108 | + safeArea: { top: 24, bottom: 16, left: 0, right: 0 }, |
| 109 | + has: () => true, |
| 110 | + getPushToken: async () => null, |
| 111 | + haptic: async () => null, |
| 112 | + openExternal: async () => null, |
| 113 | + navigate: async () => null, |
| 114 | + resetServer: async () => null, |
| 115 | + // What the page shows at the moment `ready` arrives: the shell lifts its |
| 116 | + // loading cover on it, so the first route must already be on screen. |
| 117 | + ready: () => events.push(`ready:${screen.queryByTestId('login-page') ? 'login-page' : 'nothing'}`), |
| 118 | + routeChanged: (route: string) => events.push(`route:${route}`), |
| 119 | + }; |
| 120 | + (window as unknown as Record<string, unknown>)[NATIVE_HOST_GLOBAL] = bridge; |
| 121 | + return events; |
| 122 | +} |
| 123 | + |
| 124 | +function dispatchNavigate(route: unknown): CustomEvent { |
| 125 | + const event = new CustomEvent(NATIVE_NAVIGATE_EVENT, { cancelable: true, detail: { route } }); |
| 126 | + act(() => { |
| 127 | + window.dispatchEvent(event); |
| 128 | + }); |
| 129 | + return event; |
| 130 | +} |
| 131 | + |
| 132 | +beforeEach(() => { |
| 133 | + resetNativeHostEventsForTests(); |
| 134 | + resetNativeHostWatchForTests(); |
| 135 | + window.history.pushState({}, '', '/login'); |
| 136 | +}); |
| 137 | + |
| 138 | +afterEach(() => { |
| 139 | + cleanup(); |
| 140 | + delete (window as unknown as Record<string, unknown>)[NATIVE_HOST_GLOBAL]; |
| 141 | + window.history.pushState({}, '', '/'); |
| 142 | +}); |
| 143 | + |
| 144 | +describe('the Console in the mobile shell', () => { |
| 145 | + it('posts ready once, after the first route rendered, and the route the router is on', async () => { |
| 146 | + const events = installBridge(); |
| 147 | + render(<App />); |
| 148 | + expect(await screen.findByTestId('login-page')).toBeInTheDocument(); |
| 149 | + expect(events).toEqual(['ready:login-page', 'route:/login']); |
| 150 | + expect(document.documentElement.getAttribute('data-os-native-shell')).toBe('android'); |
| 151 | + }); |
| 152 | + |
| 153 | + it('the shell navigate event moves the router without a reload, and posts that route once', async () => { |
| 154 | + const events = installBridge(); |
| 155 | + render(<App />); |
| 156 | + await screen.findByTestId('login-page'); |
| 157 | + const before = window.history.length; |
| 158 | + |
| 159 | + const event = dispatchNavigate('/register?from=push'); |
| 160 | + expect(event.defaultPrevented).toBe(true); |
| 161 | + expect(await screen.findByTestId('register-page')).toBeInTheDocument(); |
| 162 | + // The router pushed a history entry: an SPA move, not a document load. |
| 163 | + expect(window.location.pathname).toBe('/register'); |
| 164 | + expect(window.history.length).toBe(before + 1); |
| 165 | + expect(events).toEqual(['ready:login-page', 'route:/login', 'route:/register?from=push']); |
| 166 | + }); |
| 167 | + |
| 168 | + it('a route that is not an in-app path is left to the shell', async () => { |
| 169 | + const events = installBridge(); |
| 170 | + render(<App />); |
| 171 | + await screen.findByTestId('login-page'); |
| 172 | + const event = dispatchNavigate('//evil.example/register'); |
| 173 | + expect(event.defaultPrevented).toBe(false); |
| 174 | + expect(window.location.pathname).toBe('/login'); |
| 175 | + expect(events).toEqual(['ready:login-page', 'route:/login']); |
| 176 | + }); |
| 177 | +}); |
| 178 | + |
| 179 | +describe('control: the Console in a browser behaves as today', () => { |
| 180 | + it('posts nothing, marks nothing, and leaves the navigate event alone', async () => { |
| 181 | + render(<App />); |
| 182 | + expect(await screen.findByTestId('login-page')).toBeInTheDocument(); |
| 183 | + expect(document.documentElement.hasAttribute('data-os-native-shell')).toBe(false); |
| 184 | + const event = dispatchNavigate('/register'); |
| 185 | + expect(event.defaultPrevented).toBe(false); |
| 186 | + expect(window.location.pathname).toBe('/login'); |
| 187 | + }); |
| 188 | +}); |
0 commit comments