@unbound-app/api 0.11.0 → 1.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.
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 +2771 -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,53 @@
1
+ // Auto-generated by generate-plugin-sdk.ts - DO NOT EDIT
2
+
3
+ import type { PredicateResult } from '../_types';
4
+ import * as React$1 from 'react';
5
+ import { ReactElement } from 'react';
6
+ /**
7
+ * @description Finds every node in an object graph a predicate matches, depth-first from the root. Cycle-safe.
8
+ * @template T The matched nodes' type.
9
+ * @param root The node to start from; it is tested too.
10
+ * @param predicate Tests each non-nullish node.
11
+ * @param options Which keys to descend into or skip, and how deep.
12
+ * @returns Every match, in visiting order.
13
+ */
14
+ export declare function filterInTree<T = TreeNode>(
15
+ root: TreeNode,
16
+ predicate: TreePredicate,
17
+ options?: TreeSearchOptions,
18
+ ): T[];
19
+ /**
20
+ * @description Finds the first node in an object graph a predicate matches, depth-first from the root. Cycle-safe.
21
+ * @template T The matched node's type.
22
+ * @param root The node to start from; it is tested too.
23
+ * @param predicate Tests each non-nullish node.
24
+ * @param options Which keys to descend into or skip, and how deep.
25
+ * @returns The first match, or `undefined` when none matches.
26
+ */
27
+ export declare function findInTree<T = TreeNode>(
28
+ root: TreeNode,
29
+ predicate: TreePredicate,
30
+ options?: TreeSearchOptions,
31
+ ): T | undefined;
32
+ /**
33
+ * Picks an element: a component name, matched against its `displayName` or `name` through `memo` and `forwardRef`
34
+ * wrappers (or a host type such as `RCTView`), or a predicate on the element.
35
+ * @template P The element's props.
36
+ */
37
+ export type ComponentMatch<P = TreeNode> =
38
+ | string
39
+ | ((element: React$1.ReactElement<P>) => PredicateResult);
40
+ /** A node in an object graph, such as a React tree. Discord's trees carry no static types, so a predicate reads them unchecked. */
41
+ export type TreeNode = any;
42
+ /** Tests one node of a tree; a truthy result is a match. */
43
+ export type TreePredicate = (node: TreeNode) => PredicateResult;
44
+ /** How a tree search descends. */
45
+ export type TreeSearchOptions = {
46
+ /** The only keys descended into; every own key when omitted. Array items are always walked. */
47
+ walkable?: string[];
48
+ /** Keys never descended into. */
49
+ ignore?: string[];
50
+ /** How many levels below the root to descend. Defaults to 100. */
51
+ maxDepth?: number;
52
+ };
53
+ export * as react from './react/index';
@@ -0,0 +1,82 @@
1
+ // Auto-generated by generate-plugin-sdk.ts - DO NOT EDIT
2
+
3
+ import type { PredicateResult } from '../../_types';
4
+ import * as React$1 from 'react';
5
+ import { ReactElement } from 'react';
6
+
7
+ /** Compares two values one level deep: each own key, or each entry of a Map, Set or array, by `Object.is`. Pass it as `isEqual`. */
8
+ export declare const shallow: <T>(previous: T, next: T) => boolean;
9
+ /** Wraps a selector so a result shallow-equal to its last one returns the last one, which skips the re-render. */
10
+ export declare const useShallow: <S, U>(selector: (state: S) => U) => (state: S) => U;
11
+ /**
12
+ * @description Finds every element in a rendered tree that a component name or predicate picks, walking only `props` and `children`.
13
+ * @template P The elements' props.
14
+ * @param tree A render result, such as `result` in an `after` patch.
15
+ * @param match A component name, or a predicate on each element.
16
+ * @returns Every matching element, outermost first.
17
+ */
18
+ export declare function filterComponents<P = TreeNode>(
19
+ tree: TreeNode,
20
+ match: ComponentMatch<P>,
21
+ ): React$1.ReactElement<P>[];
22
+ /**
23
+ * @description Finds every node in a React element or fiber tree a predicate matches, searching only downward.
24
+ * Start from an element, a fiber, or a class instance's `_reactInternals`.
25
+ * @template T The matched nodes' type.
26
+ * @param root The element or fiber to start from; it is tested too.
27
+ * @param predicate Tests each non-nullish node.
28
+ * @param options `walkable` adds keys to the React ones (such as `return`), `ignore` skips keys, `maxDepth` defaults to 100.
29
+ * @returns Every match, in visiting order.
30
+ */
31
+ export declare function filterInTree<T = TreeNode>(
32
+ root: TreeNode,
33
+ predicate: TreePredicate,
34
+ options?: TreeSearchOptions,
35
+ ): T[];
36
+ /**
37
+ * @description Finds the first element in a rendered tree that a component name or predicate picks, walking only `props` and `children`.
38
+ * @template P The element's props.
39
+ * @param tree A render result, such as `result` in an `after` patch.
40
+ * @param match A component name, or a predicate on each element.
41
+ * @returns The first matching element, or `undefined` when none matches.
42
+ */
43
+ export declare function findComponent<P = TreeNode>(
44
+ tree: TreeNode,
45
+ match: ComponentMatch<P>,
46
+ ): React$1.ReactElement<P> | undefined;
47
+ /**
48
+ * @description Finds the first node in a React element or fiber tree a predicate matches, searching only downward.
49
+ * Start from an element, a fiber, or a class instance's `_reactInternals`.
50
+ * @template T The matched node's type.
51
+ * @param root The element or fiber to start from; it is tested too.
52
+ * @param predicate Tests each non-nullish node.
53
+ * @param options `walkable` adds keys to the React ones (such as `return`), `ignore` skips keys, `maxDepth` defaults to 100.
54
+ * @returns The first match, or `undefined` when none matches.
55
+ */
56
+ export declare function findInTree<T = TreeNode>(
57
+ root: TreeNode,
58
+ predicate: TreePredicate,
59
+ options?: TreeSearchOptions,
60
+ ): T | undefined;
61
+ /**
62
+ * Picks an element: a component name, matched against its `displayName` or `name` through `memo` and `forwardRef`
63
+ * wrappers (or a host type such as `RCTView`), or a predicate on the element.
64
+ * @template P The element's props.
65
+ */
66
+ export type ComponentMatch<P = TreeNode> =
67
+ | string
68
+ | ((element: React$1.ReactElement<P>) => PredicateResult);
69
+ /** A node in an object graph, such as a React tree. Discord's trees carry no static types, so a predicate reads them unchecked. */
70
+ export type TreeNode = any;
71
+ /** Tests one node of a tree; a truthy result is a match. */
72
+ export type TreePredicate = (node: TreeNode) => PredicateResult;
73
+ /** How a tree search descends. */
74
+ export type TreeSearchOptions = {
75
+ /** The only keys descended into; every own key when omitted. Array items are always walked. */
76
+ walkable?: string[];
77
+ /** Keys never descended into. */
78
+ ignore?: string[];
79
+ /** How many levels below the root to descend. Defaults to 100. */
80
+ maxDepth?: number;
81
+ };
82
+ export {};
@@ -0,0 +1,256 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ import type { ListenerRegistry } from './listener-registry';
4
+ import type { FluxAction, FluxStore } from '../src/flux';
5
+ import type { FakeModule } from './fake-metro';
6
+
7
+ /** A seeded store's methods and fields, whatever the test gives it, such as `getCurrentUser`. */
8
+ type StoreSeed = FakeModule;
9
+
10
+ /** The Flux stores to seed, by name with or without `Store`, such as `{ User: { getCurrentUser: () => user } }`. */
11
+ export type StoreSeeds = Record<string, StoreSeed>;
12
+
13
+ /** The fake `flux`: stores the test seeds, and a dispatcher that records each action. */
14
+ export type FakeFlux = {
15
+ /** Every action `dispatch` sent, in order. */
16
+ dispatched: FluxAction[];
17
+ /** Adds stores, or replaces a seeded store's methods and emits its change; `getStore` reads them on each access. */
18
+ seed: (stores: StoreSeeds) => void;
19
+ /** Calls a seeded store's change listeners, re-rendering each `useStore` reading it. */
20
+ emit: (name: string) => void;
21
+ getStore: (name: string) => FluxStore & StoreSeed;
22
+ /** The seeded stores that have a change listener added with `addChangeListener` rather than `onStoreChange`, which Unbound does not remove when a plugin stops. */
23
+ directListeners: () => string[];
24
+ /** Reads `selector(stores)` on each render, and re-renders when a store emits; `deps` and `isEqual` change nothing a test sees. */
25
+ useStore: <S extends FluxStore | readonly FluxStore[], T>(
26
+ stores: S,
27
+ selector: (stores: S) => T,
28
+ ) => T;
29
+ onStoreChange: (store: FluxStore, listener: () => void) => () => void;
30
+ onAction: (type: string, listener: (action: FluxAction) => void) => () => void;
31
+ interceptAction: (
32
+ type: string,
33
+ interceptor: (action: FluxAction) => boolean | void,
34
+ ) => () => void;
35
+ dispatch: (action: FluxAction) => Promise<void>;
36
+ reset: () => void;
37
+ };
38
+
39
+ /** The fake `assets`: every name has a stable id, so an image import equals `assets.getIdByName(name)`. */
40
+ export type FakeAssets = {
41
+ getIdByName: (name: string, type?: AssetType) => number;
42
+ getByName: (name: string, type?: AssetType) => FakeAsset;
43
+ register: (options: AssetRegistration) => RegisteredAsset;
44
+ onAssetRegistered: (listener: AssetListener) => () => void;
45
+ };
46
+
47
+ /** The type an asset is asked for by. */
48
+ export type AssetType = 'svg' | 'png';
49
+
50
+ /** An asset `getByName` returns. */
51
+ export type FakeAsset = { id: number; name: string; type: string };
52
+
53
+ /** What `register` takes: the asset's name, and its type. */
54
+ export type AssetRegistration = { name: string; type?: string };
55
+
56
+ /** What `register` returns: the asset's id, and how to take it out again. */
57
+ export type RegisteredAsset = { id: number; unregister: () => void };
58
+
59
+ /** Called with each asset registered, and its id. */
60
+ export type AssetListener = (asset: AssetRegistration, id: number) => void;
61
+
62
+ /**
63
+ * @description Gives an asset name a stable fake id, the same in every run.
64
+ * @param name The asset's name, such as `icon` for `assets/icon.png`.
65
+ * @param type The type it was asked for; an SVG's `?asset` raster asks for `png`.
66
+ * @returns A positive id.
67
+ */
68
+ export function fakeAssetId(name: string, type: string = ''): number {
69
+ let hash = 0x811c9dc5;
70
+
71
+ for (const char of `${type}:${name}`) {
72
+ hash = Math.imul(hash ^ (char.codePointAt(0) ?? 0), 0x01000193);
73
+ }
74
+
75
+ return 1000 + ((hash >>> 0) % 1_000_000);
76
+ }
77
+
78
+ function shortName(name: string): string {
79
+ return name.endsWith('Store') ? name.slice(0, -'Store'.length) : name;
80
+ }
81
+
82
+ /**
83
+ * @description Creates the fake `flux`, whose listeners go through `listeners` so `stopPlugin` sees any left behind.
84
+ * @param listeners The registry the plugin's listeners are tracked in.
85
+ * @returns The flux.
86
+ */
87
+ export function createFakeFlux(listeners: ListenerRegistry): FakeFlux {
88
+ const stores = new Map<string, FluxStore & StoreSeed>();
89
+ const actionListeners = new Map<string, Set<(action: FluxAction) => void>>();
90
+ const interceptors = new Map<string, Set<(action: FluxAction) => boolean | void>>();
91
+ const dispatched: FluxAction[] = [];
92
+ const direct = new Map<() => void, string>();
93
+ let internal = 0;
94
+
95
+ // The kit's own subscriptions (`useStore`, `onStoreChange`) go through here, so only a plugin's own count as direct.
96
+ function subscribed<T>(run: () => T): T {
97
+ internal++;
98
+
99
+ try {
100
+ return run();
101
+ } finally {
102
+ internal--;
103
+ }
104
+ }
105
+
106
+ function storeOf(name: string, seed: StoreSeed): FluxStore & StoreSeed {
107
+ const changeListeners = new Set<() => void>();
108
+
109
+ return {
110
+ getName: () => `${name}Store`,
111
+ addChangeListener(listener) {
112
+ changeListeners.add(listener);
113
+
114
+ if (internal === 0) {
115
+ direct.set(listener, `${name}Store.addChangeListener`);
116
+ }
117
+ },
118
+ removeChangeListener(listener) {
119
+ changeListeners.delete(listener);
120
+ direct.delete(listener);
121
+ },
122
+ emitChange: () => {
123
+ for (const listener of changeListeners) listener();
124
+ },
125
+ ...seed,
126
+ };
127
+ }
128
+
129
+ function seeded(name: string) {
130
+ const store = stores.get(shortName(name));
131
+
132
+ if (!store) {
133
+ throw new Error(
134
+ `Failed to read Flux store ${name}: seed it with render(..., { stores: { ${shortName(name)}: {...} } }).`,
135
+ );
136
+ }
137
+
138
+ return store;
139
+ }
140
+
141
+ function subscribe<T>(map: Map<string, Set<T>>, type: string, listener: T, label: string) {
142
+ const set = map.get(type) ?? new Set<T>();
143
+
144
+ map.set(type, set);
145
+ set.add(listener);
146
+
147
+ return listeners.track(label, () => void set.delete(listener));
148
+ }
149
+
150
+ return {
151
+ dispatched,
152
+ seed(next) {
153
+ for (const [name, seed] of Object.entries(next)) {
154
+ const existing = stores.get(shortName(name));
155
+
156
+ // The same object keeps its change listeners, so a `useStore` reading it re-renders.
157
+ if (existing) {
158
+ Object.assign(existing, seed);
159
+ existing.emitChange();
160
+ } else {
161
+ stores.set(shortName(name), storeOf(shortName(name), seed));
162
+ }
163
+ }
164
+ },
165
+ emit: (name) => seeded(name).emitChange(),
166
+ // Lazy, as on device: a store read at import resolves on first property access.
167
+ // Every operation lands on the seeded store, so a patch made through the proxy shows in later reads.
168
+ getStore: (name) =>
169
+ new Proxy<FluxStore & StoreSeed>(storeOf(shortName(name), {}), {
170
+ get: (_target, key) => Reflect.get(seeded(name), key),
171
+ set: (_target, key, value) => Reflect.set(seeded(name), key, value),
172
+ has: (_target, key) => key in seeded(name),
173
+ deleteProperty: (_target, key) => Reflect.deleteProperty(seeded(name), key),
174
+ defineProperty: (_target, key, descriptor) =>
175
+ Reflect.defineProperty(seeded(name), key, descriptor),
176
+ }),
177
+ directListeners: () => [...direct.values()],
178
+ useStore(watched, selector) {
179
+ const [, forceUpdate] = useState({});
180
+ const list = [watched].flat();
181
+
182
+ useEffect(() => {
183
+ const update = () => forceUpdate({});
184
+
185
+ subscribed(() => {
186
+ for (const store of list) store.addChangeListener(update);
187
+ });
188
+
189
+ return () => {
190
+ for (const store of list) store.removeChangeListener(update);
191
+ };
192
+ }, list);
193
+
194
+ return selector(watched);
195
+ },
196
+ onStoreChange(store, listener) {
197
+ subscribed(() => store.addChangeListener(listener));
198
+
199
+ return listeners.track(`flux.onStoreChange(${store.getName()})`, () =>
200
+ store.removeChangeListener(listener),
201
+ );
202
+ },
203
+ onAction: (type, listener) =>
204
+ subscribe(actionListeners, type, listener, `flux.onAction(${type})`),
205
+ interceptAction: (type, interceptor) =>
206
+ subscribe(interceptors, type, interceptor, `flux.interceptAction(${type})`),
207
+ async dispatch(action) {
208
+ dispatched.push(action);
209
+
210
+ for (const interceptor of interceptors.get(action.type) ?? []) {
211
+ if (interceptor(action) === true) return;
212
+ }
213
+
214
+ for (const listener of actionListeners.get(action.type) ?? []) listener(action);
215
+ },
216
+ reset() {
217
+ stores.clear();
218
+ direct.clear();
219
+ actionListeners.clear();
220
+ interceptors.clear();
221
+ dispatched.length = 0;
222
+ },
223
+ };
224
+ }
225
+
226
+ /**
227
+ * @description Creates the fake `assets`.
228
+ * @param listeners The registry registrations and listeners are tracked in.
229
+ * @returns The assets.
230
+ */
231
+ export function createFakeAssets(listeners: ListenerRegistry): FakeAssets {
232
+ const registered = new Set<AssetListener>();
233
+
234
+ return {
235
+ getIdByName: (name, type) => fakeAssetId(name, type),
236
+ getByName: (name, type) => ({ id: fakeAssetId(name, type), name, type: type ?? 'png' }),
237
+ register(options) {
238
+ const id = fakeAssetId(options.name, options.type);
239
+
240
+ for (const listener of registered) listener(options, id);
241
+
242
+ return {
243
+ id,
244
+ unregister: listeners.track(`assets.register(${options.name})`, () => {}),
245
+ };
246
+ },
247
+ onAssetRegistered(listener) {
248
+ registered.add(listener);
249
+
250
+ return listeners.track(
251
+ 'assets.onAssetRegistered',
252
+ () => void registered.delete(listener),
253
+ );
254
+ },
255
+ };
256
+ }
@@ -0,0 +1,183 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import type { FakeModule } from './fake-metro';
4
+
5
+ type ComponentProps = { children?: ReactNode } & FakeModule;
6
+
7
+ /** A fake Discord component: it draws its children, and the text prop of a button or row, and `find` reaches it by the name it was made with. */
8
+ export type FakeComponent = ((props: ComponentProps) => ReactNode) & FakeModule;
9
+
10
+ const BAGS = [
11
+ 'AvatarPiles',
12
+ 'Avatars',
13
+ 'BackdropFilters',
14
+ 'BottomSheets',
15
+ 'DefaultAvatars',
16
+ 'Discord',
17
+ 'FlashList',
18
+ 'Forms',
19
+ 'Media',
20
+ 'Portal',
21
+ 'SafeArea',
22
+ 'Video',
23
+ ] as const;
24
+
25
+ const UI = [
26
+ 'ButtonRow',
27
+ 'CheckboxRow',
28
+ 'ChecklistRow',
29
+ 'ColorRow',
30
+ 'DurationRow',
31
+ 'EntityRow',
32
+ 'InputRow',
33
+ 'ListRow',
34
+ 'MultilineRow',
35
+ 'NumberRow',
36
+ 'Page',
37
+ 'PaletteRow',
38
+ 'PickerRow',
39
+ 'RadioRow',
40
+ 'ResetRow',
41
+ 'RestartRow',
42
+ 'Row',
43
+ 'SchemaPanel',
44
+ 'SecretRow',
45
+ 'Section',
46
+ 'SegmentedRow',
47
+ 'SliderRow',
48
+ 'StepperRow',
49
+ 'SwitchRow',
50
+ 'UrlRow',
51
+ ] as const;
52
+
53
+ // The components that draw a string prop as their text on device, so `text()` and `find` see it.
54
+ const TEXT_PROPS: Record<string, string[]> = {
55
+ Button: ['text'],
56
+ RowButton: ['label', 'subLabel'],
57
+ TableRow: ['label', 'subLabel'],
58
+ TableRadioRow: ['label', 'subLabel'],
59
+ TableSwitchRow: ['label', 'subLabel'],
60
+ TableCheckboxRow: ['label', 'subLabel'],
61
+ };
62
+
63
+ function isCapitalised(key: string): boolean {
64
+ return key !== '' && key[0] === key[0]?.toUpperCase() && key[0] !== key[0]?.toLowerCase();
65
+ }
66
+
67
+ /**
68
+ * @description Creates a component that draws its children and is named `name`, so `find(name)` reaches it with its props.
69
+ * A capitalised member, such as `ActionSheetRow.Icon`, is another component named `ActionSheetRow.Icon`.
70
+ * @param name The name `find` matches.
71
+ * @returns The component.
72
+ */
73
+ export function createFakeComponent(name: string): FakeComponent {
74
+ const members = new Map<string, FakeComponent>();
75
+
76
+ const textProps = TEXT_PROPS[name] ?? [];
77
+
78
+ function Component(props: ComponentProps): ReactNode {
79
+ const drawn = textProps.map((key) => props[key]).filter((text) => typeof text === 'string');
80
+
81
+ return drawn.length ? [...drawn, props.children] : (props.children ?? null);
82
+ }
83
+
84
+ Component.displayName = name;
85
+
86
+ return new Proxy(Component as FakeComponent, {
87
+ get(target, key, receiver) {
88
+ if (typeof key !== 'string' || key in target || !isCapitalised(key)) {
89
+ return Reflect.get(target, key, receiver);
90
+ }
91
+
92
+ const member = members.get(key) ?? createFakeComponent(`${name}.${key}`);
93
+
94
+ members.set(key, member);
95
+
96
+ return member;
97
+ },
98
+ });
99
+ }
100
+
101
+ /**
102
+ * @description Creates a module of fake components: any capitalised name is a component of that name, and a test may set its own.
103
+ * @param members Members that are not components, such as `createStyles`.
104
+ * @returns The module.
105
+ */
106
+ function createComponentBag(members: FakeModule = {}): FakeModule {
107
+ const components = new Map<string, unknown>(Object.entries(members));
108
+
109
+ return new Proxy<FakeModule>(
110
+ {},
111
+ {
112
+ get(_target, key) {
113
+ if (typeof key !== 'string') {
114
+ return undefined;
115
+ }
116
+
117
+ if (!isCapitalised(key)) {
118
+ return components.get(key);
119
+ }
120
+
121
+ const component = components.get(key) ?? createFakeComponent(key);
122
+
123
+ components.set(key, component);
124
+
125
+ return component;
126
+ },
127
+ set(_target, key, value) {
128
+ if (typeof key === 'string') {
129
+ components.set(key, value);
130
+ }
131
+
132
+ return true;
133
+ },
134
+ has: (_target, key) =>
135
+ typeof key === 'string' && (isCapitalised(key) || components.has(key)),
136
+ },
137
+ );
138
+ }
139
+
140
+ // `createStyles` of Discord's design module returns a hook of the styles; a factory is called with no theme.
141
+ function createStyles<T>(styles: T | (() => T)): () => T {
142
+ return () => (typeof styles === 'function' ? (styles as () => T)() : styles);
143
+ }
144
+
145
+ /**
146
+ * @description Creates the fake `metro.components`: each module a bag of fake components, so `Discord.Text` and `BottomSheets.ActionSheet`
147
+ * render as nodes named after themselves, with `Discord.createStyles` returning the styles it is given.
148
+ * @returns The namespace.
149
+ */
150
+ export function createFakeComponents(): FakeModule {
151
+ const bags = Object.fromEntries(BAGS.map((name) => [name, createComponentBag()]));
152
+
153
+ return {
154
+ ...bags,
155
+ Discord: createComponentBag({ createStyles }),
156
+ HelpMessage: createFakeComponent('HelpMessage'),
157
+ Switch: createFakeComponent('Switch'),
158
+ HelpMessageTypes: {
159
+ 0: 'WARNING',
160
+ 1: 'INFO',
161
+ 2: 'ERROR',
162
+ 3: 'SUCCESS',
163
+ WARNING: 0,
164
+ INFO: 1,
165
+ ERROR: 2,
166
+ SUCCESS: 3,
167
+ },
168
+ DESIGN_COMPONENTS: [],
169
+ // UNKNOWN: any value may be tested for being renderable.
170
+ isRenderable: (value: unknown) =>
171
+ typeof value === 'function' ||
172
+ (typeof value === 'object' && value !== null && '$$typeof' in value),
173
+ registerDesignFallback: () => {},
174
+ };
175
+ }
176
+
177
+ /**
178
+ * @description Creates the fake `ui`: each settings row and layout component draws its children and is named after itself.
179
+ * @returns The namespace.
180
+ */
181
+ export function createFakeUi(): FakeModule {
182
+ return Object.fromEntries(UI.map((name) => [name, createFakeComponent(name)]));
183
+ }