@unbound-app/api 0.11.0 → 1.1.1

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.
Files changed (72) hide show
  1. package/ambient/image-modules.d.ts +58 -0
  2. package/package.json +27 -4
  3. package/src/_internal.d.ts +2775 -139
  4. package/src/{utils.d.ts → _types.d.ts} +10 -0
  5. package/src/assets.d.ts +64 -26
  6. package/src/builtins.d.ts +17 -0
  7. package/src/commands.d.ts +101 -25
  8. package/src/dialogs.d.ts +90 -25
  9. package/src/flux.d.ts +317 -0
  10. package/src/fonts.d.ts +51 -0
  11. package/src/fs.d.ts +15 -24
  12. package/src/global.d.ts +173 -11
  13. package/src/i18n.d.ts +33 -31
  14. package/src/iconPacks.d.ts +43 -0
  15. package/src/index.d.ts +14 -0
  16. package/src/marketplace.d.ts +50 -0
  17. package/src/metro/api.d.ts +8 -0
  18. package/src/metro/common.d.ts +34 -5
  19. package/src/metro/components.d.ts +34 -25
  20. package/src/metro/filters.d.ts +31 -16
  21. package/src/metro/index.d.ts +26079 -83
  22. package/src/metro/stores.d.ts +5 -0
  23. package/src/native.d.ts +77 -5
  24. package/src/patcher.d.ts +214 -57
  25. package/src/plugins.d.ts +55 -0
  26. package/src/settings.d.ts +8 -16
  27. package/src/shims.d.ts +5 -0
  28. package/src/storage.d.ts +80 -224
  29. package/src/sync.d.ts +51 -0
  30. package/src/themes.d.ts +43 -0
  31. package/src/toasts.d.ts +62 -9
  32. package/src/ui.d.ts +650 -0
  33. package/src/updates.d.ts +25 -0
  34. package/src/utils/index.d.ts +53 -0
  35. package/src/utils/react/index.d.ts +82 -0
  36. package/testing/fake-api.ts +256 -0
  37. package/testing/fake-components.ts +183 -0
  38. package/testing/fake-feedback.ts +258 -0
  39. package/testing/fake-fs.ts +54 -0
  40. package/testing/fake-java.ts +513 -0
  41. package/testing/fake-locales.ts +85 -0
  42. package/testing/fake-metro.ts +346 -0
  43. package/testing/fake-native-bridge.ts +98 -0
  44. package/testing/fake-native.ts +211 -0
  45. package/testing/fake-objc.ts +388 -0
  46. package/testing/fake-plugins.ts +176 -0
  47. package/testing/fake-react-native-modules.ts +362 -0
  48. package/testing/fake-react-native.ts +180 -0
  49. package/testing/fake-react.ts +54 -0
  50. package/testing/fake-settings-panel.ts +173 -0
  51. package/testing/fake-settings.ts +428 -0
  52. package/testing/fake-timers.ts +74 -0
  53. package/testing/fake-utils.ts +216 -0
  54. package/testing/icu-format.ts +132 -0
  55. package/testing/icu-parse.ts +277 -0
  56. package/testing/index.ts +42 -0
  57. package/testing/install-addon-globals.ts +260 -0
  58. package/testing/listener-registry.ts +39 -0
  59. package/testing/memory-storage.ts +149 -0
  60. package/testing/plugin-files.ts +155 -0
  61. package/testing/plugin-lifecycle.ts +149 -0
  62. package/testing/plugin-types.ts +34 -0
  63. package/testing/preset.ts +263 -0
  64. package/testing/react-patcher.ts +154 -0
  65. package/testing/react-value.ts +3 -0
  66. package/testing/recorders.ts +183 -0
  67. package/testing/recording-patcher.ts +173 -0
  68. package/testing/render.ts +84 -0
  69. package/testing/session.ts +77 -0
  70. package/testing/test-node.ts +163 -0
  71. package/testing/test-renderer.ts +753 -0
  72. package/testing/unfrozen-elements.ts +49 -0
@@ -0,0 +1,346 @@
1
+ import * as React from 'react';
2
+
3
+ import type { AnyObject } from '../src/patcher';
4
+
5
+ import { createFakeReactNative } from './fake-react-native';
6
+ import { createFakeComponents } from './fake-components';
7
+
8
+ /** A module the fake metro serves: the exports a plugin finds. */
9
+ export type FakeModule = AnyObject;
10
+
11
+ /** A fake metro filter. `path` marks a file-path filter, which matches the path a module was registered under. */
12
+ export type FakeFilter = ((module: FakeModule) => boolean) & { path?: string };
13
+
14
+ type LazyOptions = {
15
+ signal?: AbortSignal;
16
+ };
17
+
18
+ type FindOptions = {
19
+ lazy?: boolean;
20
+ all?: boolean;
21
+ };
22
+
23
+ type FindAll = { all: true };
24
+
25
+ type Presence = 'loaded' | 'missing';
26
+
27
+ const LAZY_MARKER = '__METRO_LAZY__';
28
+
29
+ // The props each `metro.common` member is found by, as in the client's metro/common.ts.
30
+ const COMMON_PROPS = {
31
+ Reanimated: ['useAnimatedStyle', 'useSharedValue'],
32
+ Gestures: ['PanGestureHandler'],
33
+ Clipboard: ['setString', 'getString', 'setImage', 'getImage'],
34
+ MarkdownParser: ['parse', 'parseToAST', 'reactParserFor'],
35
+ Screens: ['FullWindowOverlay'],
36
+ SVG: ['Svg', 'Path'],
37
+ Commands: ['getBuiltInCommands'],
38
+ Moment: ['isMoment'],
39
+ Util: ['inspect'],
40
+ Assets: ['registerAsset'],
41
+ ImageSources: ['getImageSourcesFromImageProps'],
42
+ Clyde: ['createBotMessage'],
43
+ Constants: ['Fonts', 'Endpoints'],
44
+ Radius: ['Radius'],
45
+ Theme: ['colors', 'internal'],
46
+ REST: ['getAPIBaseURL'],
47
+ i18n: ['getLanguages', 'getSystemLocale', 'intl'],
48
+ };
49
+
50
+ // The props each `metro.api` member is found by, as in the client's metro/api.ts.
51
+ const API_PROPS = {
52
+ Messages: ['sendMessage', 'receiveMessage'],
53
+ Linking: ['openURL', 'openSettings'],
54
+ Profiles: ['showUserProfile'],
55
+ AsyncUsers: ['fetchProfile'],
56
+ Invites: ['acceptInviteAndTransitionToInviteChannel'],
57
+ OAuth2: ['openOAuth2Modal'],
58
+ ActionSheets: ['openLazy', 'hideActionSheet'],
59
+ UserProfiles: ['getUserProfileActionSheetKey', 'showUserProfileActionSheetPostConnection'],
60
+ };
61
+
62
+ /** What a lazy handle looks its module up by. */
63
+ type LazyLookup = {
64
+ resolve: () => FakeModule | undefined;
65
+ filter: FakeFilter;
66
+ };
67
+
68
+ type Wait = {
69
+ filter: FakeFilter;
70
+ onLoad: (module: FakeModule) => void;
71
+ };
72
+
73
+ /** A metro over modules a test registers, with the finders and filters plugins call. */
74
+ export type FakeMetro = {
75
+ /** Adds a module, settling any `findLazy` or `onModuleLoaded` waiting for it and calling `onModuleLoad` listeners. */
76
+ register: (module: FakeModule, path?: string) => void;
77
+ /** Forgets every registered module and waiting `findLazy` or `onModuleLoaded`, so a lazy handle made earlier finds only what is registered next. */
78
+ reset: () => void;
79
+ /** Discord's Flux stores, by the name `metro.stores` gives them, such as `Users`; the preset binds each to the seeded store. */
80
+ stores: Record<string, FakeModule>;
81
+ /** Discord's components: each module a bag of fake components that draw their children and are found by name, such as `Discord.Text`. */
82
+ components: FakeModule;
83
+ /** Discord's API modules, each a lazy handle found by the same props as on device, such as `Messages`. */
84
+ api: Record<keyof typeof API_PROPS, FakeModule>;
85
+ /** Discord's common modules, each a lazy handle found by the same props as on device, such as `Theme`; `React` and `ReactNative` are the fakes. */
86
+ common: Record<keyof typeof COMMON_PROPS, FakeModule> & {
87
+ React: FakeModule;
88
+ ReactNative: FakeModule;
89
+ };
90
+ filters: {
91
+ byProps: (...props: string[]) => FakeFilter;
92
+ byName: (name: string) => FakeFilter;
93
+ byDisplayName: (name: string) => FakeFilter;
94
+ byTypeName: (name: string) => FakeFilter;
95
+ byFilePath: (path: string) => FakeFilter;
96
+ all: (...parts: FakeFilter[]) => FakeFilter;
97
+ any: (...parts: FakeFilter[]) => FakeFilter;
98
+ not: (part: FakeFilter) => FakeFilter;
99
+ };
100
+ /** A lazy handle to the first registered module the filter matches, as on device; `lazy: false` gives the module now, and `all: true` every match now. */
101
+ find: {
102
+ (filter: FakeFilter, options: FindAll): FakeModule[];
103
+ (filter: FakeFilter, options?: FindOptions): FakeModule | undefined;
104
+ };
105
+ findByProps: (...args: (string | FindOptions)[]) => FakeModule | undefined;
106
+ findByName: (...args: (string | FindOptions)[]) => FakeModule | undefined;
107
+ findByFilePath: (...args: (string | FindOptions)[]) => FakeModule | undefined;
108
+ /** `loaded` once a registered module matches a lazy handle, `missing` before; `undefined` for anything else. */
109
+ // UNKNOWN: a plugin may pass any value, and only a lazy handle has a presence.
110
+ getModulePresence: (handle: unknown) => Presence | undefined;
111
+ findLazy: (
112
+ filter: FakeFilter,
113
+ options?: LazyOptions,
114
+ ) => FakeModule | Promise<FakeModule | undefined>;
115
+ onModuleLoaded: (filter: FakeFilter, onLoad: (module: FakeModule) => void) => () => void;
116
+ /** Calls `onLoad` with the module a lazy handle finds, at once when one is registered, else when it registers; a plain object is passed at once. Returns what stops waiting. */
117
+ whenLoaded: (target: FakeModule, onLoad: (module: FakeModule) => void) => () => void;
118
+ onModuleLoad: (listener: (module: FakeModule) => void) => () => void;
119
+ };
120
+
121
+ function names(args: (string | FindOptions)[]): string[] {
122
+ return args.filter((arg) => typeof arg === 'string');
123
+ }
124
+
125
+ // The client rejects a file-path filter inside a combination, because it matches the raw module record, not exports.
126
+ function assertCombinable(kind: 'all' | 'any' | 'not', part: FakeFilter) {
127
+ if (part.path !== undefined) {
128
+ throw new TypeError(
129
+ `Failed to combine filters: ${kind}() cannot take a raw filter such as byFilePath.`,
130
+ );
131
+ }
132
+ }
133
+
134
+ function isEager(args: (string | FindOptions)[]): boolean {
135
+ return args.some((arg) => typeof arg === 'object' && arg.lazy === false);
136
+ }
137
+
138
+ // A lookup the plugin makes while it loads, as on device: each access reads the module registered by then.
139
+ function lazyHandle(resolve: () => FakeModule | undefined, description: string): FakeModule {
140
+ const current = () => resolve() ?? {};
141
+
142
+ return new Proxy<FakeModule>(
143
+ {},
144
+ {
145
+ get: (_target, key) => Reflect.get(current(), key),
146
+ has: (_target, key) => key in current(),
147
+ ownKeys: () => Reflect.ownKeys(current()),
148
+ getOwnPropertyDescriptor(_target, key) {
149
+ if (key === LAZY_MARKER) {
150
+ return { value: true, configurable: true };
151
+ }
152
+
153
+ const descriptor = Reflect.getOwnPropertyDescriptor(current(), key);
154
+
155
+ return descriptor && { ...descriptor, configurable: true };
156
+ },
157
+ set(_target, key, value) {
158
+ const module = resolve();
159
+
160
+ if (!module) {
161
+ throw new Error(
162
+ `Failed to set ${String(key)}: no module matches ${description}. Call metro.register() before the plugin starts.`,
163
+ );
164
+ }
165
+
166
+ return Reflect.set(module, key, value);
167
+ },
168
+ },
169
+ );
170
+ }
171
+
172
+ /**
173
+ * @description Creates a metro that finds only the modules a test registers.
174
+ * @returns The fake metro.
175
+ */
176
+ export function createFakeMetro(): FakeMetro {
177
+ const modules: FakeModule[] = [];
178
+ const paths = new Map<FakeModule, string>();
179
+ const waits = new Set<Wait>();
180
+ const listeners = new Set<(module: FakeModule) => void>();
181
+
182
+ const filters: FakeMetro['filters'] = {
183
+ byProps:
184
+ (...props) =>
185
+ (module) =>
186
+ props.every((prop) => prop in module),
187
+ byName: (name) => (module) => 'name' in module && module.name === name,
188
+ byDisplayName: (name) => (module) => module.displayName === name,
189
+ byTypeName: (name) => (module) => module.type?.name === name,
190
+ byFilePath: (path) =>
191
+ Object.assign((module: FakeModule) => paths.get(module) === path, { path }),
192
+ all: (...parts) => {
193
+ for (const part of parts) assertCombinable('all', part);
194
+
195
+ return (module) => parts.every((part) => part(module));
196
+ },
197
+ any: (...parts) => {
198
+ for (const part of parts) assertCombinable('any', part);
199
+
200
+ return (module) => parts.some((part) => part(module));
201
+ },
202
+ not: (part) => {
203
+ assertCombinable('not', part);
204
+
205
+ return (module) => !part(module);
206
+ },
207
+ };
208
+
209
+ const handles = new WeakMap<FakeModule, LazyLookup>();
210
+
211
+ function first(filter: FakeFilter): FakeModule | undefined {
212
+ return modules.find((module) => filter(module));
213
+ }
214
+
215
+ function find(filter: FakeFilter, options: FindAll): FakeModule[];
216
+ function find(filter: FakeFilter, options?: FindOptions): FakeModule | undefined;
217
+ function find(filter: FakeFilter, options: FindOptions = {}) {
218
+ return lookup(filter, 'the filter', [options]);
219
+ }
220
+
221
+ function lookup(filter: FakeFilter, description: string, options: (string | FindOptions)[]) {
222
+ if (options.some((arg) => typeof arg === 'object' && arg.all)) {
223
+ return modules.filter((module) => filter(module));
224
+ }
225
+
226
+ if (isEager(options)) {
227
+ return first(filter);
228
+ }
229
+
230
+ const resolve = () => first(filter);
231
+ const handle = lazyHandle(resolve, description);
232
+
233
+ handles.set(handle, { resolve, filter });
234
+
235
+ return handle;
236
+ }
237
+
238
+ function handlesOf<T extends Record<string, string[]>>(namespace: string, byProps: T) {
239
+ return Object.fromEntries(
240
+ Object.entries(byProps).map(([name, props]) => [
241
+ name,
242
+ lookup(filters.byProps(...props), `metro.${namespace}.${name}`, []),
243
+ ]),
244
+ ) as Record<keyof T, FakeModule>;
245
+ }
246
+
247
+ function onModuleLoaded(filter: FakeFilter, onLoad: (module: FakeModule) => void) {
248
+ const found = first(filter);
249
+
250
+ if (found) {
251
+ onLoad(found);
252
+
253
+ return () => void 0;
254
+ }
255
+
256
+ const wait: Wait = { filter, onLoad };
257
+
258
+ waits.add(wait);
259
+
260
+ return () => void waits.delete(wait);
261
+ }
262
+
263
+ const common = {
264
+ ...handlesOf('common', COMMON_PROPS),
265
+ React,
266
+ ReactNative: createFakeReactNative(),
267
+ };
268
+
269
+ return {
270
+ stores: {},
271
+ components: createFakeComponents(),
272
+ api: handlesOf('api', API_PROPS),
273
+ common,
274
+ filters,
275
+ find,
276
+ findByProps: (...args) =>
277
+ lookup(filters.byProps(...names(args)), `props ${names(args).join(', ')}`, args),
278
+ findByName: (...args) =>
279
+ lookup(filters.byName(names(args)[0]), `name ${names(args)[0]}`, args),
280
+ findByFilePath: (...args) =>
281
+ lookup(filters.byFilePath(names(args)[0]), `path ${names(args)[0]}`, args),
282
+ getModulePresence(handle) {
283
+ const lazy = typeof handle === 'object' && handle ? handles.get(handle) : undefined;
284
+
285
+ if (!lazy) return;
286
+
287
+ return lazy.resolve() ? 'loaded' : 'missing';
288
+ },
289
+ findLazy(filter, { signal } = {}) {
290
+ const found = first(filter);
291
+
292
+ if (found) {
293
+ return found;
294
+ }
295
+
296
+ return new Promise((resolve) => {
297
+ const wait: Wait = { filter, onLoad: resolve };
298
+
299
+ waits.add(wait);
300
+ signal?.addEventListener('abort', () => {
301
+ waits.delete(wait);
302
+ resolve(undefined);
303
+ });
304
+ });
305
+ },
306
+ onModuleLoaded,
307
+ onModuleLoad(listener) {
308
+ listeners.add(listener);
309
+
310
+ return () => void listeners.delete(listener);
311
+ },
312
+ whenLoaded(target, onLoad) {
313
+ const lazy = handles.get(target);
314
+
315
+ if (!lazy) {
316
+ onLoad(target);
317
+
318
+ return () => void 0;
319
+ }
320
+
321
+ return onModuleLoaded(lazy.filter, onLoad);
322
+ },
323
+ reset() {
324
+ modules.length = 0;
325
+ paths.clear();
326
+ waits.clear();
327
+ listeners.clear();
328
+ },
329
+ register(module, path) {
330
+ modules.push(module);
331
+
332
+ if (path !== undefined) {
333
+ paths.set(module, path);
334
+ }
335
+
336
+ for (const wait of waits) {
337
+ if (wait.filter(module)) {
338
+ waits.delete(wait);
339
+ wait.onLoad(module);
340
+ }
341
+ }
342
+
343
+ for (const listener of listeners) listener(module);
344
+ },
345
+ };
346
+ }
@@ -0,0 +1,98 @@
1
+ import type { NativeCancelablePromise, NativeHandle } from '../src/native';
2
+
3
+ /** One call a plugin made to a fake native bridge, in order. */
4
+ export type NativeCall = {
5
+ bridge: 'objc' | 'java' | 'hooks';
6
+ /** The bridge member called, such as `invoke`. */
7
+ method: string;
8
+ /** The class the call reached, or `unknown` when the target is not a registered object. */
9
+ target: string;
10
+ /** The selector (Objective-C) or method or field name (Java), empty when the call has none. */
11
+ name: string;
12
+ args: unknown[];
13
+ };
14
+
15
+ /** The calls plugins made to the fake bridges since the last reset. */
16
+ export type NativeCallLog = {
17
+ calls: NativeCall[];
18
+ record: (call: NativeCall) => void;
19
+ };
20
+
21
+ /**
22
+ * @description Creates the list the fake bridges append each call to.
23
+ * @returns The log.
24
+ */
25
+ export function createCallLog(): NativeCallLog {
26
+ const calls: NativeCall[] = [];
27
+
28
+ return { calls, record: (call) => void calls.push(call) };
29
+ }
30
+
31
+ /**
32
+ * @description Wraps a settled call in the promise the bridge returns, which rejects when the call throws.
33
+ * @param run The call.
34
+ * @returns A promise with a `cancel` that does nothing, since the call has run.
35
+ */
36
+ export function settled<T>(run: () => T): NativeCancelablePromise<T> {
37
+ let promise: Promise<T>;
38
+
39
+ try {
40
+ promise = Promise.resolve(run());
41
+ } catch (error) {
42
+ promise = Promise.reject(error);
43
+ }
44
+
45
+ return Object.assign(promise, { cancel: () => {} });
46
+ }
47
+
48
+ /**
49
+ * @description Gives a bridge the members a test did not fake as functions that throw naming the member, so a plugin reaching one fails loudly.
50
+ * @param name The bridge's name, such as `objc`.
51
+ * @param members The members the kit fakes.
52
+ * @returns The bridge.
53
+ */
54
+ export function withMissingMembers<T extends object>(name: string, members: Partial<T>): T {
55
+ // UNKNOWN: the members are a partial bridge; the proxy answers the rest, so it has the whole bridge's shape.
56
+ return new Proxy(members, {
57
+ get(target, key, receiver) {
58
+ if (typeof key !== 'string' || key in target) {
59
+ return Reflect.get(target, key, receiver);
60
+ }
61
+
62
+ return () => {
63
+ throw new Error(
64
+ `Failed to call native.${name}.${key}: the test kit does not fake it. Pass your own with startPlugin({ native: { ${name}: { ${key} } } }).`,
65
+ );
66
+ };
67
+ },
68
+ }) as T;
69
+ }
70
+
71
+ /**
72
+ * @description Lays a test's own members over a fake bridge: a member the test gave wins, and every other one is the fake's.
73
+ * @template T The bridge type.
74
+ * @param base The fake bridge.
75
+ * @param overrides The members the test gave.
76
+ * @returns A new bridge, which a plugin sees in place of the fake.
77
+ */
78
+ export function overlayBridge<T extends object>(base: T, overrides: Partial<T> = {}): T {
79
+ // A new object each time, so what a plugin keys by its bridge, such as cached class descriptions, starts empty for each start.
80
+ return new Proxy(base, {
81
+ get: (target, key, receiver) =>
82
+ Reflect.get(key in overrides ? overrides : target, key, receiver),
83
+ has: (target, key) => key in overrides || key in target,
84
+ });
85
+ }
86
+
87
+ /** Any fake object that stands for a native handle. */
88
+ export type Brandable = { constructor: { name: string } };
89
+
90
+ /**
91
+ * @description Types a fake's own object as the opaque handle a native bridge hands out, which has no runtime shape.
92
+ * @template T The handle type.
93
+ * @param value The fake object that stands for the handle.
94
+ * @returns The same object, as a handle.
95
+ */
96
+ export function brand<T extends NativeHandle>(value: Brandable): T {
97
+ return value as T;
98
+ }
@@ -0,0 +1,211 @@
1
+ import type {
2
+ NativePluginBridge,
3
+ NativePluginCapability,
4
+ NativePluginPlatform,
5
+ PluginContext,
6
+ } from '../src/native';
7
+ import type { AddonManifest } from '../src/global';
8
+ import type { FakeModule } from './fake-metro';
9
+
10
+ import { createCallLog, overlayBridge, type NativeCallLog } from './fake-native-bridge';
11
+ import { createFakeJava, type FakeJava } from './fake-java';
12
+ import { createFakeObjc, type FakeObjc } from './fake-objc';
13
+
14
+ /** What `startPlugin` takes to build the `PluginContext` its `start` receives; the rest is filled in as the client would. */
15
+ export type StartContext = Partial<Omit<PluginContext, 'native'>> & {
16
+ /** The bridge members the plugin calls; the others are empty objects. */
17
+ native?: Partial<NativePluginBridge>;
18
+ };
19
+
20
+ /** The fake `native` namespace's state, which a test sets through the `native` recorder. */
21
+ export type NativeState = {
22
+ /** The host platform `definePlatform` selects by and `Platform.OS` reports; `ios` until a test sets it. */
23
+ platform: NativePluginPlatform;
24
+ /** The native modules `getNativeModule` finds, by name. */
25
+ modules: Map<string, unknown>;
26
+ /** The capabilities `native.has` reports. */
27
+ capabilities: NativePluginCapability[];
28
+ };
29
+
30
+ /** The fake `native`, its state, and the `PluginContext` builder. */
31
+ export type FakeNative = {
32
+ namespace: FakeModule;
33
+ state: NativeState;
34
+ /** The fake Objective-C bridge a plugin gets as `context.native.objc` on iOS. */
35
+ objc: FakeObjc;
36
+ /** The fake Java and hooks bridges a plugin gets as `context.native.java` and `hooks` on Android. */
37
+ java: FakeJava;
38
+ /** Every call a plugin made to the fake bridges, in order. */
39
+ calls: NativeCallLog['calls'];
40
+ /** Puts the platform back to `ios` and forgets modules, capabilities, classes, objects, hooks and calls. */
41
+ reset: () => void;
42
+ /** Builds the context `start` receives: the manifest, the platform and capabilities from the state, and `overrides` on top. */
43
+ context: (manifest: AddonManifest, overrides?: StartContext) => PluginContext;
44
+ };
45
+
46
+ // A bridge member the test did not give: an empty object it can fill in, since the plugin reads only what it calls.
47
+ function emptyMember<T>(): T {
48
+ return Object.create(null);
49
+ }
50
+
51
+ const PLATFORMS: readonly NativePluginPlatform[] = ['ios', 'android'];
52
+
53
+ class NativePluginUnavailableError extends Error {
54
+ readonly code = 'NATIVE_PLUGIN_UNAVAILABLE';
55
+ }
56
+
57
+ class NativePluginCapabilityError extends Error {
58
+ readonly code = 'NATIVE_PLUGIN_CAPABILITY_DENIED';
59
+ readonly capability: NativePluginCapability;
60
+
61
+ constructor(capability: NativePluginCapability) {
62
+ super(`Native plugin capability is not declared: ${capability}`);
63
+ this.capability = capability;
64
+ }
65
+ }
66
+
67
+ class NativePluginUnsupportedPlatformError extends Error {
68
+ readonly code = 'NATIVE_PLUGIN_PLATFORM_EXCLUDED';
69
+ readonly platforms: readonly NativePluginPlatform[];
70
+
71
+ constructor(platforms: readonly NativePluginPlatform[], platform: string) {
72
+ super(`This plugin supports ${platforms.join(', ')}; the host platform is ${platform}.`);
73
+ this.platforms = platforms;
74
+ }
75
+ }
76
+
77
+ class NativePluginVersionError extends Error {
78
+ readonly code = 'NATIVE_PLUGIN_API_VERSION_UNSUPPORTED';
79
+ readonly installedApi: string;
80
+ readonly requiredApi: string;
81
+
82
+ constructor(requiredApi: string, installedApi: string) {
83
+ super(`Native plugin API ${requiredApi} is required, but ${installedApi} is installed.`);
84
+ this.installedApi = installedApi;
85
+ this.requiredApi = requiredApi;
86
+ }
87
+ }
88
+
89
+ class NativePluginDisposedError extends Error {
90
+ readonly code = 'NATIVE_PLUGIN_SCOPE_DISPOSED';
91
+
92
+ constructor() {
93
+ super('The native plugin scope has been disposed.');
94
+ }
95
+ }
96
+
97
+ /**
98
+ * @description Creates the fake `native`: `definePlatform` selects by the state's platform on each call of a function handler, `getNativeModule` reads its modules,
99
+ * and the error classes and metadata modules exist as on device.
100
+ * @returns The namespace, its state, and the context builder.
101
+ */
102
+ export function createFakeNative(): FakeNative {
103
+ const state: NativeState = { platform: 'ios', modules: new Map(), capabilities: [] };
104
+ const log = createCallLog();
105
+ const objc = createFakeObjc(log);
106
+ const java = createFakeJava(log);
107
+
108
+ function select<Handler>(handlers: Partial<Record<NativePluginPlatform, Handler>>): Handler {
109
+ const handler = handlers[state.platform];
110
+
111
+ if (handler === undefined) {
112
+ throw new NativePluginUnsupportedPlatformError(
113
+ PLATFORMS.filter((name) => handlers[name] !== undefined),
114
+ state.platform,
115
+ );
116
+ }
117
+
118
+ return handler;
119
+ }
120
+
121
+ // A plugin selects as it imports platform.ts, before a test sets the platform, so a function handler
122
+ // is a forwarder to the current platform's handler. Any other value is read now.
123
+ function definePlatform<Handler>(
124
+ handlers: Partial<Record<NativePluginPlatform, Handler>>,
125
+ ): Handler {
126
+ const selected = select(handlers);
127
+
128
+ if (typeof selected !== 'function') {
129
+ return selected;
130
+ }
131
+
132
+ return new Proxy(selected, {
133
+ apply: (_target, self, args) => Reflect.apply(select(handlers) as never, self, args),
134
+ construct: (_target, args) => Reflect.construct(select(handlers) as never, args),
135
+ get: (_target, key) => Reflect.get(select(handlers) as never, key),
136
+ has: (_target, key) => Reflect.has(select(handlers) as never, key),
137
+ });
138
+ }
139
+
140
+ const namespace: FakeModule = {
141
+ NativePluginUnavailableError,
142
+ NativePluginCapabilityError,
143
+ NativePluginUnsupportedPlatformError,
144
+ NativePluginVersionError,
145
+ NativePluginDisposedError,
146
+ BundleInfo: {},
147
+ BundleManager: {},
148
+ DeviceInfo: {},
149
+ definePlatform,
150
+ getNativeModule: (...names: string[]) =>
151
+ names.map((name) => state.modules.get(name)).find(Boolean),
152
+ getNativeResources: () => undefined,
153
+ getRuntimeProperties: () => ({}),
154
+ has: (capability: NativePluginCapability) => state.capabilities.includes(capability),
155
+ reload: async () => {},
156
+ };
157
+
158
+ return {
159
+ namespace,
160
+ state,
161
+ objc,
162
+ java,
163
+ calls: log.calls,
164
+ reset() {
165
+ objc.reset();
166
+ java.reset();
167
+ log.calls.length = 0;
168
+ state.platform = 'ios';
169
+ state.modules.clear();
170
+ state.capabilities = [];
171
+ },
172
+ context(manifest, overrides = {}) {
173
+ const { native, ...rest } = overrides;
174
+ const capabilities = rest.capabilities ?? state.capabilities;
175
+ const bridge: NativePluginBridge = {
176
+ apiVersion: '1.0.0',
177
+ abiVersion: '1',
178
+ capabilities,
179
+ ffi: emptyMember(),
180
+ fabric: emptyMember(),
181
+ runtime: emptyMember(),
182
+ debug: emptyMember(),
183
+ ui: emptyMember(),
184
+ ...native,
185
+ // The overrides lie over the fake's members, so one the test gave wins and the rest stay.
186
+ objc: overlayBridge(
187
+ state.platform === 'ios' ? objc.bridge : emptyMember(),
188
+ native?.objc,
189
+ ),
190
+ java: overlayBridge(
191
+ state.platform === 'android' ? java.bridge : emptyMember(),
192
+ native?.java,
193
+ ),
194
+ hooks: overlayBridge(
195
+ state.platform === 'android' ? java.hooks : emptyMember(),
196
+ native?.hooks,
197
+ ),
198
+ };
199
+
200
+ return {
201
+ manifest,
202
+ id: manifest.id,
203
+ platform: state.platform,
204
+ capabilities,
205
+ dispose: () => {},
206
+ ...rest,
207
+ native: bridge,
208
+ };
209
+ },
210
+ };
211
+ }