Skip to content

Commit 9f7e408

Browse files
fix(mobile): the native bridge platform is an open set, and a newer bridge protocol reads as no bridge (#29475)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbGJyQF5yrTTG9tojdS1NGJy)_ Fixes #29330 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:原生壳桥的 `platform` 类型从 `ios | android` 放开为开放集合(桌面壳可报其它值);新增桥协议版本门:壳报的 `version` 大于 1 或不是数字时,Console 当作没有壳、走浏览器路径;写明 `ready` 的含义;README 示例去掉按壳切换文案的分支。 - **为什么改**:桌面壳即将上线且报 `desktop`,旧的封闭类型会让消费方写出漏掉它的穷举分支;`version` 从未被读,将来协议破坏性升级时旧 Console 会误调新协议;壳将依赖 `ready` 揭开加载遮罩(mobile#162),需要明确它在鉴权加载态就会发出。 - **风险与回滚**:运行时只多一道版本判断,现有壳都报版本 1,行为不变;类型放宽只可能让对 `platform` 做穷举 switch 的代码编译不过,全仓只有 Console 一处把它原样写进属性。回滚即 revert 本 PR。 - **先看**:`ui/packages/mobile/src/nativeHost.ts`(`getNativeHost` 的版本门与 `platform` 类型)→ `ui/apps/console/src/__tests__/App.nativeShellOpenSet-29330.test.tsx` → `ui/apps/console/src/__tests__/main.nativeBridgeVersion-29330.test.tsx`。 - **两分钟验证**:在 `ui/` 下跑 `pnpm exec vitest run apps/console/src/__tests__/App.nativeShellOpenSet-29330.test.tsx`,预期 4 个用例全绿(桌面壳、鉴权加载态时的 ready、版本 2 不进壳模式及其对照)。 - **需要你做的**:无,席位落地。 ## What changes - **`ObjectStackNative.platform` is an open set.** The type widens from `'ios' | 'android'` to `'ios' | 'android' | (string & {})`, so the two known values still autocomplete and the desktop shell's value type-checks. The runtime already treated it as open: `getNativeHost` never checked it, the Console writes it verbatim into `data-os-native-shell`, and the call wrappers gate on `has()` only. The README and the pending `.changeset/29039-mobile-native-shell-mode.md` now say the attribute carries the shell's platform string, an open set. - **Bridge protocol version gate.** The bridge type says `version` "bumps only on a breaking change". `nativeHost.ts` gains `NATIVE_BRIDGE_VERSION = 1` (module export used by the tests; not added to the package entry, which this PR leaves untouched). `getNativeHost()` answers `null` for a host whose `version` is not a number (NaN included) or is greater than 1. Every reader goes through it, so such a host gets the browser behaviour: no shell mode, no `ready`, no `routeChanged`, no navigate listener, the PWA manifest kept, and (since #29326 merged) the service worker treated as in a browser. - **What `ready` means, decided: document it, don't move it.** `ready` means "the Console has painted its own UI", which may be a loading fallback such as `AuthGuard`'s spinner on a cold start to a protected route. The shell lifts its loading cover on `ready`, so the cover hands over to the Console's own spinner; waiting for `AuthGuard` would hold the native cover through a network round-trip. Written on the `ObjectStackNative.ready` member, the `postNativeReady` and `useNativeHostRouting` docblocks, and the README's bridge section. The timing is unchanged and now pinned. - **README nit.** The `SaveButton` example drops `host ? 'Save' : 'Save changes'` and keeps one label, following the "viewport and capability, not host" rule. ## Tests - `ui/apps/console/src/__tests__/App.nativeShellOpenSet-29330.test.tsx` (new, the real `App`, `ProtectedRoute` and `AuthGuard` under the real `BrowserRouter`): - a `platform: 'desktop'` bridge announcing `haptic` and `openExternal` only: shell mode on, `ready` posted once, `data-os-native-shell="desktop"`, the two announced calls reach the shell and `getPushToken`, `navigate` and `resetServer` resolve `unavailable` and are never called; - a cold start to `/` with the session read still open: `ready` arrives while `AuthGuard`'s loading fallback is on screen and before the route renders; - a `version: 2` bridge: no root attribute, no `ready`, no route post, calls `unavailable`; control: the same bridge at `version: 1` is the shell. - `ui/apps/console/src/__tests__/main.nativeBridgeVersion-29330.test.tsx` (new, the real `main.tsx` with its boot calls and its manifest icon step stubbed): a `version: 2` bridge starts the manifest step and the generated manifest replaces the static one; controls: `version: 1` never starts the step and leaves no manifest, no bridge behaves like `version: 2`. - `ui/packages/mobile/src/__tests__/nativeHost.test.tsx` (extended): a `desktop` bridge is a bridge; `NATIVE_BRIDGE_VERSION` is 1; a newer, string, NaN or missing version is `null` and its calls are `unavailable` and uncalled; versions 1 and 0 are the bridge; `useNativeHostRouting` posts nothing and installs no listener for a version 2 bridge. ## Acceptance notes - **Reverse checks.** Version gate ablated (the `typeof version` line replaced): 7 cases red across the three files (main version 2, App version 2, four mobile detection cases, mobile routing case), restored byte-identical to HEAD. Old closed union restored on `platform`: `tsc --noEmit` in `ui/apps/console` reds the desktop fixture with TS2322 "Type 'string' is not assignable to type '"ios" | "android"'", restored byte-identical. - **Widening, not narrowing, for readers.** `git grep` of the bridge symbols across `ui/`, `packages/` and `apps/` finds only `@object-ui/mobile` and the Console; the one reader of `platform` is `NativeShellRouting`, which writes it into an attribute. No exhaustive switch exists to break. - **Expected red: `check-empty-changeset`.** This PR edits the pending `.changeset/29039-mobile-native-shell-mode.md`, wording only: `data-os-native-shell="ios|android"` becomes the shell's platform string, an open set. That is the gate's deliberate-correction class: the old sentence is false after this PR, so it is corrected here rather than restored, by the card's ruling. The gate stays red by design; it is not one of the required contexts. - **Surface addition.** The manifest half of the version-gate pin lives where the decision lives, `main.tsx`, so it has its own test file beside the App-level one. No source file outside the card's surface changed; `App.tsx` and `index.ts` are untouched. - **Merged with `main` after #29326** (service worker), which also edits `ui/packages/mobile/README.md` and `main.tsx`; the merge was clean and the version gate now also covers its worker decision through `getNativeHost()`. A second merge brought #29405, which moved the manifest into an async step after first paint; the manifest test waits for that step and pins whether it starts. ## Verification All readings at head `28cb3792` (`main` merged at `a6441636`; the 13 `main` commits since touch none of this PR's 8 files). - Touched suites, from `ui/`: `pnpm exec vitest run` of the two new console files, `App.nativeShellMode-29039.test.tsx`, `packages/mobile/` and `scripts/__tests__/check-vi-mock-inherit.test.ts`: 13 files, 220 tests passed. - `pnpm --filter @object-ui/mobile run type-check` and `pnpm --filter @object-ui/console run type-check` (both cover the test files): exit 0. `eslint` on the five touched source and test files: exit 0. - Derived gates: `node scripts/pm/dispatch-gates.mjs --commands` lists 34; all 34 run and reconciled with `--ran` (0 NOT-MEASURED). 33 exit 0, including `pnpm --dir ui exec vitest run scripts/` (169 files, 4921 tests) and `check-sdui-manifest`; `check-empty-changeset` exits 1, the expected deliberate-correction red above. - Changed lines: 524 (+515 / -9) in 8 files, under the 3000-line threshold. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jSpg4SQPs6XFLsrBhv)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8118be4 commit 9f7e408

8 files changed

Lines changed: 515 additions & 9 deletions

File tree

‎.changeset/29039-mobile-native-shell-mode.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ The ObjectStack mobile app hosts the web Console in a WebView and injects a brid
1313
- **`@object-ui/mobile` gains the bridge type and detection.** `ObjectStackNative` types exactly the members of the shell's bridge contract (`version`, `platform`, `appVersion`, `capabilities`, `safeArea`, `has`, the five calls and the two events). `useNativeHost()` returns it, or `null` outside the shell; it reads the global lazily and re-checks at `DOMContentLoaded`, `load` and a few points after `load`, so a late injection still arrives. `getNativeHost()` is the same read outside React.
1414
- **Typed calls that never throw.** `nativeGetPushToken`, `nativeHaptic`, `nativeOpenExternal`, `nativeNavigate` and `nativeResetServer` check `has(capability)` and that the method exists, and resolve a `NativeCallResult`: `{ status: 'unavailable' }`, `{ status: 'ok', value }`, or `{ status: 'failed', code, message }`.
1515
- **Router wiring.** `useNativeHostRouting({ route, navigate })` posts `ready()` once per page load after the first route has rendered, posts `routeChanged(route)` once per route change, and answers the shell's cancelable `objectstack-native:navigate` window event by moving the router to `detail.route` (an in-app path only) and cancelling the event, so a notification tap does not reload the Console. Outside the shell it installs nothing.
16-
- **The Console uses it.** In the shell the Console posts those events from its router, marks the root element with `data-os-native-shell="ios|android"`, and drops the PWA install manifest. It adds no safe-area padding: the shell already pads its WebView. Without the global the Console behaves exactly as before.
16+
- **The Console uses it.** In the shell the Console posts those events from its router, marks the root element with `data-os-native-shell` set to the shell's platform string (an open set: `ios` and `android` from the mobile app, another value from another shell), and drops the PWA install manifest. It adds no safe-area padding: the shell already pads its WebView. Without the global the Console behaves exactly as before.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/mobile': patch
3+
---
4+
5+
fix(mobile): the native bridge's `platform` is an open set, and a bridge speaking a newer protocol reads as no bridge
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
The ObjectStack desktop shell hosts the Console through the same `window.ObjectStackNative` bridge as the mobile app, and reports a platform other than `ios` or `android`.
11+
12+
- **`ObjectStackNative.platform` widens** from `'ios' | 'android'` to `'ios' | 'android' | (string & {})`. The two known values still autocomplete. Code that switches exhaustively over the old two values now has to handle another string; branch on a capability (`has()`) or the viewport instead of the platform. The Console's root attribute `data-os-native-shell` carries the value verbatim.
13+
- **Bridge protocol version gate.** The bridge's `version` bumps only on a breaking change, and this package speaks version `1`. `getNativeHost()` and `useNativeHost()` now answer `null` for a bridge whose `version` is greater than `1` or not a number, so a page keeps its browser behaviour (no shell mode, no `ready`, the PWA manifest kept) instead of calling a protocol it cannot read.
14+
- **What `ready()` means, written down.** `ready` is posted when the Console has painted its own UI, which may be a loading fallback such as the auth guard's spinner while the session is read on a cold start. The shell lifts its loading cover on it, so the cover hands over to the Console's spinner rather than staying up through the session round-trip. Nothing about when it is posted changes.
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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 beyond the mobile app, as the real `App` wires it.
11+
*
12+
* `ObjectStackNative.platform` is an open set: the desktop shell reports a
13+
* value other than `ios` or `android`, and announces only the capabilities it
14+
* has. The Console must treat that host exactly like the mobile one: shell
15+
* mode on, `ready` posted once, the root attribute carrying the platform
16+
* verbatim, and no call to a method the shell did not announce.
17+
*
18+
* Two more pins sit beside it:
19+
*
20+
* - `ready` means "the Console has painted its own UI". On a cold start to a
21+
* protected route that UI is the auth guard's loading fallback, and `ready`
22+
* is posted while it is still on screen, so the shell's loading cover hands
23+
* over to the Console's spinner instead of waiting out the session read.
24+
* - A bridge speaking a newer protocol than this Console (`version` 2) reads
25+
* as no bridge: no shell mode and no `ready`.
26+
*
27+
* `App` is the real module under the real `BrowserRouter`, and so are
28+
* `ProtectedRoute` and `AuthGuard`; the pages and the shell chrome are stubs,
29+
* as in `App.nativeShellMode-29039.test.tsx`.
30+
*/
31+
32+
import { describe, expect, it, vi, afterEach, beforeEach, type Mock } from 'vitest';
33+
import { render, screen, cleanup } from '@testing-library/react';
34+
import type { ReactNode } from 'react';
35+
import {
36+
NATIVE_HOST_GLOBAL,
37+
nativeGetPushToken,
38+
nativeHaptic,
39+
nativeNavigate,
40+
nativeOpenExternal,
41+
nativeResetServer,
42+
resetNativeHostEventsForTests,
43+
type ObjectStackNative,
44+
} from '../../../../packages/mobile/src/nativeHost';
45+
import { resetNativeHostWatchForTests } from '../../../../packages/mobile/src/useNativeHost';
46+
47+
const { passthrough, stub, authState } = vi.hoisted(() => {
48+
const passthrough = ({ children }: { children?: ReactNode }) => <>{children}</>;
49+
const stub = (testid: string) => () => <div data-testid={testid} />;
50+
/** What the session read has answered so far; `isLoading` is a cold start. */
51+
const authState = { isLoading: false };
52+
return { passthrough, stub, authState };
53+
});
54+
55+
vi.mock('@object-ui/app-shell', async (importOriginal) => ({
56+
...(await importOriginal<Record<string, unknown>>()),
57+
ConsoleShell: passthrough,
58+
ConsoleToaster: () => null,
59+
ConnectedShell: passthrough,
60+
RequireOrganization: passthrough,
61+
LoadingFallback: stub('auth-loading-fallback'),
62+
LoadingScreen: stub('loading-screen'),
63+
RedirectWithSplash: stub('redirect-with-splash'),
64+
RequireAiSurface: passthrough,
65+
SystemRedirect: () => null,
66+
DefaultHomeLayout: passthrough,
67+
DefaultHomePage: stub('home-page'),
68+
DefaultOrganizationsLayout: passthrough,
69+
DefaultOrganizationsPage: stub('organizations-page'),
70+
DefaultOrganizationLayout: stub('organization-layout'),
71+
DefaultMembersPage: stub('members-page'),
72+
DefaultInvitationsPage: stub('invitations-page'),
73+
DefaultSettingsPage: stub('settings-page'),
74+
DefaultAcceptInvitationPage: stub('accept-invitation-page'),
75+
DefaultAiChatPage: stub('ai-chat-page'),
76+
StudioDesignSurface: stub('studio-design-surface'),
77+
BuilderLanding: stub('builder-landing'),
78+
getProductName: () => 'ObjectOS',
79+
getFaviconUrl: () => '',
80+
}));
81+
82+
// The real `AuthGuard` reads the real auth context. The provider is replaced
83+
// by one that publishes `authState`, so a test can hold the session read open.
84+
vi.mock('@object-ui/auth', async (importOriginal) => {
85+
const actual = await importOriginal<typeof import('@object-ui/auth')>();
86+
const { AuthCtx } = await import('../../../../packages/auth/src/AuthContext');
87+
function AuthStateProvider({ children }: { children?: ReactNode }) {
88+
// Outside any provider `useAuth` answers its signed-out defaults.
89+
const signedOut = actual.useAuth();
90+
return <AuthCtx.Provider value={{ ...signedOut, isLoading: authState.isLoading }}>{children}</AuthCtx.Provider>;
91+
}
92+
return { ...actual, AuthProvider: AuthStateProvider };
93+
});
94+
95+
vi.mock('../AppContent', () => ({
96+
AppContent: stub('app-content'),
97+
ConsolePermissionsScope: ({ children }: { children?: ReactNode }) => <>{children}</>,
98+
ConsolePermissionsRequest: () => null,
99+
}));
100+
vi.mock('../components/RootLandingRedirect', () => ({ RootLandingRedirect: stub('root-landing') }));
101+
vi.mock('../components/SetupRoute', () => ({ SetupRoute: stub('setup-route') }));
102+
vi.mock('../components/FormPage', () => ({ FormPage: stub('form-page') }));
103+
vi.mock('../components/InternalFormRoute', () => ({ InternalFormRoute: stub('internal-form-route') }));
104+
vi.mock('../components/MetadataHmrReloader', () => ({ MetadataHmrReloader: () => null }));
105+
vi.mock('../pages/auth/LoginPage', () => ({ LoginPage: stub('login-page') }));
106+
vi.mock('../pages/auth/RegisterPage', () => ({ RegisterPage: stub('register-page') }));
107+
vi.mock('../pages/auth/ForgotPasswordPage', () => ({ ForgotPasswordPage: stub('forgot-page') }));
108+
vi.mock('../pages/auth/ResetPasswordPage', () => ({ ResetPasswordPage: stub('reset-page') }));
109+
vi.mock('../pages/auth/SetPasswordPage', () => ({ SetPasswordPage: stub('set-password-page') }));
110+
vi.mock('../pages/auth/VerifyEmailPage', () => ({ VerifyEmailPage: stub('verify-email-page') }));
111+
vi.mock('../pages/auth/VerifyEmailPromptPage', () => ({ VerifyEmailPromptPage: stub('verify-prompt-page') }));
112+
vi.mock('../pages/auth/OAuthConsentPage', () => ({ OAuthConsentPage: stub('oauth-consent-page') }));
113+
vi.mock('../pages/auth/DeviceAuthPage', () => ({ DeviceAuthPage: stub('device-auth-page') }));
114+
vi.mock('../dev/DevMasterDetail', () => ({ DevMasterDetail: stub('dev-master-detail') }));
115+
vi.mock('../dev/DevLists', () => ({ DevLists: stub('dev-lists') }));
116+
vi.mock('../dev/DevModal', () => ({ DevModal: stub('dev-modal') }));
117+
vi.mock('../dev/DevLookup', () => ({ DevLookup: stub('dev-lookup') }));
118+
vi.mock('../dev/DevRowActions', () => ({ DevRowActions: stub('dev-row-actions') }));
119+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
120+
121+
import { App } from '../App';
122+
123+
type FakeBridge = ObjectStackNative & {
124+
getPushToken: Mock<ObjectStackNative['getPushToken']>;
125+
haptic: Mock<ObjectStackNative['haptic']>;
126+
openExternal: Mock<ObjectStackNative['openExternal']>;
127+
navigate: Mock<ObjectStackNative['navigate']>;
128+
resetServer: Mock<ObjectStackNative['resetServer']>;
129+
};
130+
131+
/**
132+
* A fake bridge recording what the Console posts. `ready` notes which of the
133+
* two first screens is up when it arrives: the shell lifts its cover on it.
134+
*/
135+
function installBridge({
136+
platform,
137+
version = 1,
138+
capabilities,
139+
}: {
140+
platform: string;
141+
version?: number;
142+
capabilities: readonly string[];
143+
}): { bridge: FakeBridge; events: string[] } {
144+
const events: string[] = [];
145+
const onScreen = () =>
146+
screen.queryByTestId('auth-loading-fallback')
147+
? 'auth-loading-fallback'
148+
: screen.queryByTestId('login-page')
149+
? 'login-page'
150+
: 'nothing';
151+
const bridge: FakeBridge = {
152+
version,
153+
platform,
154+
appVersion: '1.0.0',
155+
capabilities,
156+
safeArea: { top: 0, bottom: 0, left: 0, right: 0 },
157+
has: (capability: string) => capabilities.includes(capability),
158+
getPushToken: vi.fn(async () => null),
159+
haptic: vi.fn(async () => null),
160+
openExternal: vi.fn(async () => null),
161+
navigate: vi.fn(async () => null),
162+
resetServer: vi.fn(async () => null),
163+
ready: () => events.push(`ready:${onScreen()}`),
164+
routeChanged: (route: string) => events.push(`route:${route}`),
165+
};
166+
(window as unknown as Record<string, unknown>)[NATIVE_HOST_GLOBAL] = bridge;
167+
return { bridge, events };
168+
}
169+
170+
beforeEach(() => {
171+
resetNativeHostEventsForTests();
172+
resetNativeHostWatchForTests();
173+
authState.isLoading = false;
174+
window.history.pushState({}, '', '/login');
175+
});
176+
177+
afterEach(() => {
178+
cleanup();
179+
delete (window as unknown as Record<string, unknown>)[NATIVE_HOST_GLOBAL];
180+
window.history.pushState({}, '', '/');
181+
});
182+
183+
describe('a shell whose platform is neither ios nor android', () => {
184+
it('desktop with a capability subset: shell mode on, ready once, the platform verbatim, nothing unannounced called', async () => {
185+
const { bridge, events } = installBridge({ platform: 'desktop', capabilities: ['haptic', 'openExternal'] });
186+
const { rerender } = render(<App />);
187+
expect(await screen.findByTestId('login-page')).toBeInTheDocument();
188+
189+
expect(document.documentElement.getAttribute('data-os-native-shell')).toBe('desktop');
190+
expect(events).toEqual(['ready:login-page', 'route:/login']);
191+
192+
// Every typed call, from inside the running Console: the announced ones
193+
// reach the shell, the rest resolve `unavailable` without a call.
194+
await expect(nativeHaptic('success')).resolves.toEqual({ status: 'ok', value: null });
195+
await expect(nativeOpenExternal('https://example.com')).resolves.toEqual({ status: 'ok', value: null });
196+
await expect(nativeGetPushToken()).resolves.toEqual({ status: 'unavailable' });
197+
await expect(nativeNavigate('/home')).resolves.toEqual({ status: 'unavailable' });
198+
await expect(nativeResetServer()).resolves.toEqual({ status: 'unavailable' });
199+
200+
rerender(<App />);
201+
expect(events.filter((e) => e.startsWith('ready:'))).toHaveLength(1);
202+
expect(bridge.haptic).toHaveBeenCalledTimes(1);
203+
expect(bridge.openExternal).toHaveBeenCalledTimes(1);
204+
expect(bridge.getPushToken).not.toHaveBeenCalled();
205+
expect(bridge.navigate).not.toHaveBeenCalled();
206+
expect(bridge.resetServer).not.toHaveBeenCalled();
207+
});
208+
});
209+
210+
describe('what ready means: the Console has painted its own UI', () => {
211+
it('a cold start to a protected route posts ready while the auth guard still shows its loading fallback', async () => {
212+
authState.isLoading = true;
213+
window.history.pushState({}, '', '/');
214+
const { events } = installBridge({ platform: 'ios', capabilities: [] });
215+
render(<App />);
216+
expect(await screen.findByTestId('auth-loading-fallback')).toBeInTheDocument();
217+
// The session is still being read: the guard has not let the route through.
218+
expect(screen.queryByTestId('root-landing')).not.toBeInTheDocument();
219+
expect(events).toEqual(['ready:auth-loading-fallback', 'route:/']);
220+
});
221+
});
222+
223+
describe('a bridge speaking a newer protocol is no bridge', () => {
224+
it('version 2: no shell mode, no ready, no route posts', async () => {
225+
const { events } = installBridge({ platform: 'ios', version: 2, capabilities: ['haptic'] });
226+
render(<App />);
227+
expect(await screen.findByTestId('login-page')).toBeInTheDocument();
228+
expect(document.documentElement.hasAttribute('data-os-native-shell')).toBe(false);
229+
expect(events).toEqual([]);
230+
await expect(nativeHaptic('success')).resolves.toEqual({ status: 'unavailable' });
231+
});
232+
233+
it('control: version 1, the same bridge otherwise, is the shell', async () => {
234+
const { events } = installBridge({ platform: 'ios', version: 1, capabilities: ['haptic'] });
235+
render(<App />);
236+
expect(await screen.findByTestId('login-page')).toBeInTheDocument();
237+
expect(document.documentElement.getAttribute('data-os-native-shell')).toBe('ios');
238+
expect(events).toEqual(['ready:login-page', 'route:/login']);
239+
});
240+
});

0 commit comments

Comments
 (0)