@symbiote-native/web-browser 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
1
  // @symbiote-native/web-browser/angular: the Angular entry over the framework-agnostic core.
2
2
  // Nothing here holds per-instance state or hands back a subscription, so there is no lifecycle for
3
3
  // an injectable service to own — see the React entry for the full reasoning.
4
- export * from '../core';
4
+ export * from '../core/index.js';
@@ -1,2 +1,2 @@
1
- export { getCustomTabsSupportingBrowsersAsync, warmUpAsync, mayInitWithUrlAsync, coolDownAsync, openBrowserAsync, dismissBrowser, openAuthSessionAsync, dismissAuthSession, } from './web-browser';
2
- export { WebBrowserResultType, WebBrowserPresentationStyle } from './types';
1
+ export { getCustomTabsSupportingBrowsersAsync, warmUpAsync, mayInitWithUrlAsync, coolDownAsync, openBrowserAsync, dismissBrowser, openAuthSessionAsync, dismissAuthSession, } from './web-browser.js';
2
+ export { WebBrowserResultType, WebBrowserPresentationStyle } from './types.js';
@@ -5,8 +5,8 @@
5
5
  // options are gone from IWebBrowserOpenOptions.
6
6
  import { Platform, UnavailabilityError } from 'expo-modules-core';
7
7
  import { AppState, Linking, processColor } from 'react-native';
8
- import { expoWebBrowser } from './native-module';
9
- import { WebBrowserResultType, } from './types';
8
+ import { expoWebBrowser } from './native-module.js';
9
+ import { WebBrowserResultType, } from './types.js';
10
10
  const NATIVE_MODULE_NAME = 'expo-web-browser';
11
11
  const EMPTY_CUSTOM_TABS_PACKAGES = {
12
12
  defaultBrowserPackage: undefined,
@@ -4,4 +4,4 @@
4
4
  // core and never surfaces as something a caller subscribes to or tears down, so there is nothing
5
5
  // for a hook to wrap. Hence a plain re-export, the same shape packages/secure-store's React entry
6
6
  // has for the same reason.
7
- export * from '../core';
7
+ export * from '../core/index.js';
@@ -1,4 +1,4 @@
1
1
  // @symbiote-native/web-browser/vue: the Vue entry over the framework-agnostic core.
2
2
  // Nothing here holds per-instance state or hands back a subscription, so there is no lifecycle for
3
3
  // a composable to own — see the React entry for the full reasoning.
4
- export * from '../core';
4
+ export * from '../core/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/web-browser",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "description": "expo-web-browser wrapped for SymbioteNative — one framework-agnostic core, built once and reachable from the React, Vue, and Angular adapters. In-app browser (SFSafariViewController / Android Custom Tabs) and the OAuth auth-session flow.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,7 +40,10 @@
40
40
  "src",
41
41
  "build",
42
42
  "build-ngc",
43
- "native-link.json"
43
+ "native-link.json",
44
+ "!src/**/*.test.*",
45
+ "!src/**/*.spec.*",
46
+ "!src/**/*.detox.*"
44
47
  ],
45
48
  "publishConfig": {
46
49
  "access": "public"
@@ -56,9 +59,9 @@
56
59
  "react": ">=19.0.0",
57
60
  "react-native": ">=0.86",
58
61
  "vue": ">=3.5.0",
59
- "@symbiote-native/angular": "0.6.1",
60
62
  "@symbiote-native/react": "0.2.8",
61
- "@symbiote-native/vue": "0.3.8"
63
+ "@symbiote-native/vue": "0.3.8",
64
+ "@symbiote-native/angular": "0.6.2"
62
65
  },
63
66
  "peerDependenciesMeta": {
64
67
  "@symbiote-native/angular": {
@@ -92,11 +95,11 @@
92
95
  "@vue/runtime-core": "^3.5.13",
93
96
  "react": "19.2.3",
94
97
  "typescript": "~6.0.0",
98
+ "@symbiote-native/angular": "0.6.2",
99
+ "@symbiote-native/react": "0.2.8",
95
100
  "@symbiote-native/test-utils": "0.1.6",
96
- "@symbiote-native/angular": "0.6.1",
97
- "@symbiote-native/vue": "0.3.8",
98
101
  "@symbiote-native/engine": "0.1.7",
99
- "@symbiote-native/react": "0.2.8"
102
+ "@symbiote-native/vue": "0.3.8"
100
103
  },
101
104
  "scripts": {
102
105
  "typecheck": "tsc --build",
@@ -1,295 +0,0 @@
1
- import { afterEach, describe, expect, it, vi } from 'vitest';
2
-
3
- const FAKE_NATIVE_WEB_BROWSER = {
4
- openBrowserAsync: vi.fn(async () => ({ type: 'opened' })),
5
- dismissBrowser: vi.fn(async () => ({ type: 'dismiss' })),
6
- openAuthSessionAsync: vi.fn(async () => ({ type: 'success', url: 'myapp://callback' })),
7
- dismissAuthSession: vi.fn(() => undefined),
8
- warmUpAsync: vi.fn(async () => ({ servicePackage: 'com.android.chrome' })),
9
- coolDownAsync: vi.fn(async () => ({ servicePackage: 'com.android.chrome' })),
10
- mayInitWithUrlAsync: vi.fn(async () => ({ servicePackage: 'com.android.chrome' })),
11
- getCustomTabsSupportingBrowsersAsync: vi.fn(async () => ({
12
- defaultBrowserPackage: 'com.android.chrome',
13
- preferredBrowserPackage: 'com.android.chrome',
14
- browserPackages: ['com.android.chrome'],
15
- servicePackages: ['com.android.chrome'],
16
- })),
17
- };
18
-
19
- const fakePlatform = { OS: 'ios' as 'ios' | 'android' };
20
-
21
- // The real ExpoWebBrowser native module only exists on device — resolving it via
22
- // requireNativeModule() at import time would throw in this headless run, so the module-lookup file
23
- // is faked in place of expo-modules-core's runtime resolution, the same pattern
24
- // packages/secure-store/src/core/secure-store.test.ts uses.
25
- vi.mock('./native-module', () => ({
26
- expoWebBrowser: FAKE_NATIVE_WEB_BROWSER,
27
- }));
28
-
29
- // expo-modules-core's real entry transitively imports 'react-native', whose Flow-typed source
30
- // Vitest's Oxc transform can't parse — so only the members used as values are faked.
31
- vi.mock('expo-modules-core', () => ({
32
- Platform: fakePlatform,
33
- UnavailabilityError: class UnavailabilityError extends Error {
34
- constructor(moduleName: string, propertyName: string) {
35
- super(`${propertyName} is not available on ${moduleName}`);
36
- }
37
- },
38
- }));
39
-
40
- const appStateListeners = new Set<(state: string) => void>();
41
- const urlListeners = new Set<(event: { url: string }) => void>();
42
-
43
- // Same reason as the expo-modules-core fake above: react-native's own source can't be parsed here.
44
- // AppState and Linking are faked as real, drivable event sources because the Android auth-session
45
- // polyfill's whole behavior is the race between them.
46
- vi.mock('react-native', () => ({
47
- AppState: {
48
- currentState: 'active',
49
- addEventListener: (_type: string, handler: (state: string) => void) => {
50
- appStateListeners.add(handler);
51
- return { remove: () => appStateListeners.delete(handler) };
52
- },
53
- },
54
- Linking: {
55
- addEventListener: (_type: string, handler: (event: { url: string }) => void) => {
56
- urlListeners.add(handler);
57
- return { remove: () => urlListeners.delete(handler) };
58
- },
59
- },
60
- processColor: (value?: string) => (value === undefined ? undefined : `processed:${value}`),
61
- }));
62
-
63
- const {
64
- getCustomTabsSupportingBrowsersAsync,
65
- warmUpAsync,
66
- mayInitWithUrlAsync,
67
- coolDownAsync,
68
- openBrowserAsync,
69
- dismissBrowser,
70
- openAuthSessionAsync,
71
- dismissAuthSession,
72
- } = await import('./web-browser');
73
-
74
- function emitAppState(state: string): void {
75
- for (const listener of [...appStateListeners]) listener(state);
76
- }
77
-
78
- function emitUrl(url: string): void {
79
- for (const listener of [...urlListeners]) listener({ url });
80
- }
81
-
82
- // Lets every already-queued microtask and the polyfill's own promise chain settle.
83
- function flush(): Promise<void> {
84
- return new Promise(resolve => setTimeout(resolve, 0));
85
- }
86
-
87
- afterEach(() => {
88
- fakePlatform.OS = 'ios';
89
- vi.clearAllMocks();
90
- });
91
-
92
- describe('openBrowserAsync', () => {
93
- it('passes the url and options through to the native module', async () => {
94
- await expect(openBrowserAsync('https://example.com', { showTitle: true })).resolves.toEqual({
95
- type: 'opened',
96
- });
97
- expect(FAKE_NATIVE_WEB_BROWSER.openBrowserAsync).toHaveBeenCalledWith('https://example.com', {
98
- showTitle: true,
99
- });
100
- });
101
-
102
- // A color reaches native as a platform integer, never as the CSS string the caller wrote.
103
- it('runs the three color options through processColor', async () => {
104
- await openBrowserAsync('https://example.com', {
105
- toolbarColor: '#ffffff',
106
- secondaryToolbarColor: 'blue',
107
- controlsColor: 'red',
108
- });
109
- expect(FAKE_NATIVE_WEB_BROWSER.openBrowserAsync).toHaveBeenCalledWith('https://example.com', {
110
- toolbarColor: 'processed:#ffffff',
111
- secondaryToolbarColor: 'processed:blue',
112
- controlsColor: 'processed:red',
113
- });
114
- });
115
-
116
- it('throws an UnavailabilityError-shaped error when the native method is absent', async () => {
117
- const { openBrowserAsync: native } = FAKE_NATIVE_WEB_BROWSER;
118
- // @ts-expect-error -- simulating a platform where the native module has no such method
119
- FAKE_NATIVE_WEB_BROWSER.openBrowserAsync = undefined;
120
-
121
- await expect(openBrowserAsync('https://example.com')).rejects.toThrow(
122
- 'openBrowserAsync is not available on expo-web-browser',
123
- );
124
-
125
- FAKE_NATIVE_WEB_BROWSER.openBrowserAsync = native;
126
- });
127
- });
128
-
129
- describe('dismissBrowser', () => {
130
- it('delegates to the native module', async () => {
131
- await expect(dismissBrowser()).resolves.toEqual({ type: 'dismiss' });
132
- expect(FAKE_NATIVE_WEB_BROWSER.dismissBrowser).toHaveBeenCalled();
133
- });
134
-
135
- // Android's Custom Tabs offer no way to close a tab programmatically, so the native module
136
- // registers no dismissBrowser there at all.
137
- it('throws where dismissing is not available', () => {
138
- const { dismissBrowser: native } = FAKE_NATIVE_WEB_BROWSER;
139
- // @ts-expect-error -- simulating Android, where the native module has no such method
140
- FAKE_NATIVE_WEB_BROWSER.dismissBrowser = undefined;
141
-
142
- expect(() => dismissBrowser()).toThrow('dismissBrowser is not available on expo-web-browser');
143
-
144
- FAKE_NATIVE_WEB_BROWSER.dismissBrowser = native;
145
- });
146
- });
147
-
148
- describe('the Custom Tabs service functions', () => {
149
- it('delegate to the native module on Android', async () => {
150
- fakePlatform.OS = 'android';
151
-
152
- await expect(warmUpAsync('com.android.chrome')).resolves.toEqual({
153
- servicePackage: 'com.android.chrome',
154
- });
155
- expect(FAKE_NATIVE_WEB_BROWSER.warmUpAsync).toHaveBeenCalledWith('com.android.chrome');
156
-
157
- await coolDownAsync('com.android.chrome');
158
- expect(FAKE_NATIVE_WEB_BROWSER.coolDownAsync).toHaveBeenCalledWith('com.android.chrome');
159
-
160
- await mayInitWithUrlAsync('https://example.com', 'com.android.chrome');
161
- expect(FAKE_NATIVE_WEB_BROWSER.mayInitWithUrlAsync).toHaveBeenCalledWith(
162
- 'https://example.com',
163
- 'com.android.chrome',
164
- );
165
-
166
- await expect(getCustomTabsSupportingBrowsersAsync()).resolves.toMatchObject({
167
- browserPackages: ['com.android.chrome'],
168
- });
169
- });
170
-
171
- it('resolve empty off Android without touching the native module', async () => {
172
- await expect(warmUpAsync()).resolves.toEqual({});
173
- await expect(coolDownAsync()).resolves.toEqual({});
174
- await expect(mayInitWithUrlAsync('https://example.com')).resolves.toEqual({});
175
- expect(FAKE_NATIVE_WEB_BROWSER.warmUpAsync).not.toHaveBeenCalled();
176
- expect(FAKE_NATIVE_WEB_BROWSER.coolDownAsync).not.toHaveBeenCalled();
177
- expect(FAKE_NATIVE_WEB_BROWSER.mayInitWithUrlAsync).not.toHaveBeenCalled();
178
- });
179
-
180
- it('report the browser list as unavailable when the native module has no such method', async () => {
181
- fakePlatform.OS = 'android';
182
- const { getCustomTabsSupportingBrowsersAsync: native } = FAKE_NATIVE_WEB_BROWSER;
183
- // @ts-expect-error -- simulating iOS, which registers the stub without the `Async` suffix
184
- FAKE_NATIVE_WEB_BROWSER.getCustomTabsSupportingBrowsersAsync = undefined;
185
-
186
- await expect(getCustomTabsSupportingBrowsersAsync()).rejects.toThrow(
187
- 'getCustomTabsSupportingBrowsersAsync is not available on expo-web-browser',
188
- );
189
-
190
- FAKE_NATIVE_WEB_BROWSER.getCustomTabsSupportingBrowsersAsync = native;
191
- });
192
- });
193
-
194
- describe('openAuthSessionAsync on a platform with a native auth session', () => {
195
- it('hands the url, redirect url and processed options to the native module', async () => {
196
- await expect(
197
- openAuthSessionAsync('https://login.example', 'myapp://callback', {
198
- preferEphemeralSession: true,
199
- controlsColor: 'red',
200
- }),
201
- ).resolves.toEqual({ type: 'success', url: 'myapp://callback' });
202
-
203
- expect(FAKE_NATIVE_WEB_BROWSER.openAuthSessionAsync).toHaveBeenCalledWith(
204
- 'https://login.example',
205
- 'myapp://callback',
206
- { preferEphemeralSession: true, controlsColor: 'processed:red' },
207
- );
208
- });
209
-
210
- it('throws an UnavailabilityError-shaped error when the native method is absent', async () => {
211
- const { openAuthSessionAsync: native } = FAKE_NATIVE_WEB_BROWSER;
212
- // @ts-expect-error -- simulating a platform where the native module has no such method
213
- FAKE_NATIVE_WEB_BROWSER.openAuthSessionAsync = undefined;
214
-
215
- await expect(openAuthSessionAsync('https://login.example')).rejects.toThrow(
216
- 'openAuthSessionAsync is not available on expo-web-browser',
217
- );
218
-
219
- FAKE_NATIVE_WEB_BROWSER.openAuthSessionAsync = native;
220
- });
221
- });
222
-
223
- // Android has no native auth session: a Custom Tab is opened and the result is whichever comes
224
- // first, the deep link back into the app or the app returning to the foreground.
225
- describe('openAuthSessionAsync on Android', () => {
226
- it('resolves with the redirect once the deep link arrives', async () => {
227
- fakePlatform.OS = 'android';
228
- // Android's native module has neither of these; without removing them the polyfill would never
229
- // be reached and the `finally` would dismiss a browser that cannot be dismissed.
230
- const { openAuthSessionAsync: nativeAuth, dismissBrowser: nativeDismiss } =
231
- FAKE_NATIVE_WEB_BROWSER;
232
- // @ts-expect-error -- simulating Android's native surface
233
- FAKE_NATIVE_WEB_BROWSER.openAuthSessionAsync = undefined;
234
- // @ts-expect-error -- simulating Android's native surface
235
- FAKE_NATIVE_WEB_BROWSER.dismissBrowser = undefined;
236
-
237
- const session = openAuthSessionAsync('https://login.example', 'myapp://callback');
238
- await flush();
239
- emitUrl('myapp://callback?code=abc');
240
-
241
- await expect(session).resolves.toEqual({
242
- type: 'success',
243
- url: 'myapp://callback?code=abc',
244
- });
245
- expect(FAKE_NATIVE_WEB_BROWSER.openBrowserAsync).toHaveBeenCalledWith(
246
- 'https://login.example',
247
- {},
248
- );
249
-
250
- // Release the browser-wait promise the race left pending, so the next session can start.
251
- emitAppState('active');
252
- await flush();
253
-
254
- FAKE_NATIVE_WEB_BROWSER.openAuthSessionAsync = nativeAuth;
255
- FAKE_NATIVE_WEB_BROWSER.dismissBrowser = nativeDismiss;
256
- });
257
-
258
- it('resolves as dismissed when the app returns to the foreground without a redirect', async () => {
259
- fakePlatform.OS = 'android';
260
- const { openAuthSessionAsync: nativeAuth, dismissBrowser: nativeDismiss } =
261
- FAKE_NATIVE_WEB_BROWSER;
262
- // @ts-expect-error -- simulating Android's native surface
263
- FAKE_NATIVE_WEB_BROWSER.openAuthSessionAsync = undefined;
264
- // @ts-expect-error -- simulating Android's native surface
265
- FAKE_NATIVE_WEB_BROWSER.dismissBrowser = undefined;
266
-
267
- const session = openAuthSessionAsync('https://login.example', 'myapp://callback');
268
- await flush();
269
- emitAppState('active');
270
-
271
- await expect(session).resolves.toEqual({ type: 'dismiss' });
272
- // The redirect listener is torn down again, so a second session is allowed to start.
273
- expect(urlListeners.size).toBe(0);
274
-
275
- FAKE_NATIVE_WEB_BROWSER.openAuthSessionAsync = nativeAuth;
276
- FAKE_NATIVE_WEB_BROWSER.dismissBrowser = nativeDismiss;
277
- });
278
- });
279
-
280
- describe('dismissAuthSession', () => {
281
- it('dismisses the native auth session where one exists', () => {
282
- dismissAuthSession();
283
- expect(FAKE_NATIVE_WEB_BROWSER.dismissAuthSession).toHaveBeenCalled();
284
- expect(FAKE_NATIVE_WEB_BROWSER.dismissBrowser).not.toHaveBeenCalled();
285
- });
286
-
287
- it('falls back to dismissing the browser where there is no native auth session', () => {
288
- fakePlatform.OS = 'android';
289
-
290
- dismissAuthSession();
291
-
292
- expect(FAKE_NATIVE_WEB_BROWSER.dismissBrowser).toHaveBeenCalled();
293
- expect(FAKE_NATIVE_WEB_BROWSER.dismissAuthSession).not.toHaveBeenCalled();
294
- });
295
- });