@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.
- package/ambient/image-modules.d.ts +58 -0
- package/package.json +27 -4
- package/src/_internal.d.ts +2771 -139
- package/src/{utils.d.ts → _types.d.ts} +10 -0
- package/src/assets.d.ts +64 -26
- package/src/builtins.d.ts +17 -0
- package/src/commands.d.ts +101 -25
- package/src/dialogs.d.ts +90 -25
- package/src/flux.d.ts +317 -0
- package/src/fonts.d.ts +51 -0
- package/src/fs.d.ts +15 -24
- package/src/global.d.ts +173 -11
- package/src/i18n.d.ts +33 -31
- package/src/iconPacks.d.ts +43 -0
- package/src/index.d.ts +14 -0
- package/src/marketplace.d.ts +50 -0
- package/src/metro/api.d.ts +8 -0
- package/src/metro/common.d.ts +34 -5
- package/src/metro/components.d.ts +34 -25
- package/src/metro/filters.d.ts +31 -16
- package/src/metro/index.d.ts +26079 -83
- package/src/metro/stores.d.ts +5 -0
- package/src/native.d.ts +77 -5
- package/src/patcher.d.ts +214 -57
- package/src/plugins.d.ts +55 -0
- package/src/settings.d.ts +8 -16
- package/src/shims.d.ts +5 -0
- package/src/storage.d.ts +80 -224
- package/src/sync.d.ts +51 -0
- package/src/themes.d.ts +43 -0
- package/src/toasts.d.ts +62 -9
- package/src/ui.d.ts +650 -0
- package/src/updates.d.ts +25 -0
- package/src/utils/index.d.ts +53 -0
- package/src/utils/react/index.d.ts +82 -0
- package/testing/fake-api.ts +256 -0
- package/testing/fake-components.ts +183 -0
- package/testing/fake-feedback.ts +258 -0
- package/testing/fake-fs.ts +54 -0
- package/testing/fake-java.ts +513 -0
- package/testing/fake-locales.ts +85 -0
- package/testing/fake-metro.ts +346 -0
- package/testing/fake-native-bridge.ts +98 -0
- package/testing/fake-native.ts +211 -0
- package/testing/fake-objc.ts +388 -0
- package/testing/fake-plugins.ts +176 -0
- package/testing/fake-react-native-modules.ts +362 -0
- package/testing/fake-react-native.ts +180 -0
- package/testing/fake-react.ts +54 -0
- package/testing/fake-settings-panel.ts +173 -0
- package/testing/fake-settings.ts +428 -0
- package/testing/fake-timers.ts +74 -0
- package/testing/fake-utils.ts +216 -0
- package/testing/icu-format.ts +132 -0
- package/testing/icu-parse.ts +277 -0
- package/testing/index.ts +42 -0
- package/testing/install-addon-globals.ts +260 -0
- package/testing/listener-registry.ts +39 -0
- package/testing/memory-storage.ts +149 -0
- package/testing/plugin-files.ts +155 -0
- package/testing/plugin-lifecycle.ts +149 -0
- package/testing/plugin-types.ts +34 -0
- package/testing/preset.ts +263 -0
- package/testing/react-patcher.ts +154 -0
- package/testing/react-value.ts +3 -0
- package/testing/recorders.ts +183 -0
- package/testing/recording-patcher.ts +173 -0
- package/testing/render.ts +84 -0
- package/testing/session.ts +77 -0
- package/testing/test-node.ts +163 -0
- package/testing/test-renderer.ts +753 -0
- package/testing/unfrozen-elements.ts +49 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { NativePluginCapability, NativePluginPlatform } from '../src/native';
|
|
2
|
+
import type { CommandArgs, LogEntry } from './install-addon-globals';
|
|
3
|
+
import type { OpenedDialog, ShownToast } from './fake-feedback';
|
|
4
|
+
import type { CommandContext, Reply } from '../src/commands';
|
|
5
|
+
import type { NativeCall } from './fake-native-bridge';
|
|
6
|
+
import type { PatchRecord } from './recording-patcher';
|
|
7
|
+
import type { PluginLocale } from './plugin-types';
|
|
8
|
+
import type { PluginSpec } from './fake-plugins';
|
|
9
|
+
import type { FakeModule } from './fake-metro';
|
|
10
|
+
import type { FluxAction } from '../src/flux';
|
|
11
|
+
import type { StoreSeeds } from './fake-api';
|
|
12
|
+
import type { FakeJava } from './fake-java';
|
|
13
|
+
import type { FakeObjc } from './fake-objc';
|
|
14
|
+
|
|
15
|
+
import { getSession } from './session';
|
|
16
|
+
|
|
17
|
+
/** Every toast the plugin showed this test, in order. */
|
|
18
|
+
export type ToastRecorder = { readonly shown: readonly ShownToast[] };
|
|
19
|
+
|
|
20
|
+
/** Every dialog the plugin opened this test, in order, each with `respond` to answer it. */
|
|
21
|
+
export type DialogRecorder = { readonly opened: readonly OpenedDialog[] };
|
|
22
|
+
|
|
23
|
+
/** The plugin's registered commands, and `run` to call one as Discord would. */
|
|
24
|
+
export type CommandRecorder = {
|
|
25
|
+
readonly registered: ReadonlyMap<string, object>;
|
|
26
|
+
/** Runs `/path` with parsed `args`, in a guild text channel unless `context` says otherwise. */
|
|
27
|
+
run: (path: string, args?: CommandArgs, context?: Partial<CommandContext>) => Promise<Reply>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** The patches the plugin holds right now, each with its kind, target and method. */
|
|
31
|
+
export type PatchRecorder = { readonly applied: readonly PatchRecord[] };
|
|
32
|
+
|
|
33
|
+
/** The fake Flux: the actions the plugin dispatched, and the stores a test seeds. */
|
|
34
|
+
export type FluxRecorder = {
|
|
35
|
+
readonly dispatched: readonly FluxAction[];
|
|
36
|
+
/** Adds stores, or replaces a seeded store's methods and tells its readers. */
|
|
37
|
+
seed: (stores: StoreSeeds) => void;
|
|
38
|
+
/** Calls a seeded store's change listeners. */
|
|
39
|
+
emit: (name: string) => void;
|
|
40
|
+
/** Sends an action to the plugin's interceptors and listeners. */
|
|
41
|
+
dispatch: (action: FluxAction) => Promise<void>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** The fake metro's modules: `register` one for the plugin to find, such as `{ sendMessage }`. */
|
|
45
|
+
export type MetroRecorder = { register: (module: FakeModule, path?: string) => void };
|
|
46
|
+
|
|
47
|
+
/** The fake `native`: the platform `definePlatform` selects by, and the modules and capabilities it reports. */
|
|
48
|
+
export type NativeRecorder = {
|
|
49
|
+
/** Switches the host platform, `ios` until set; `definePlatform` and `Platform.OS` follow it. */
|
|
50
|
+
setPlatform: (platform: NativePluginPlatform) => void;
|
|
51
|
+
/** Gives `getNativeModule(name)` a module to return. */
|
|
52
|
+
// UNKNOWN: a native module has whatever shape the plugin reads.
|
|
53
|
+
registerModule: (name: string, module: unknown) => void;
|
|
54
|
+
/** Sets what `native.has` reports. */
|
|
55
|
+
setCapabilities: (capabilities: NativePluginCapability[]) => void;
|
|
56
|
+
/** The Objective-C bridge a plugin gets on iOS: register classes and objects for its calls to reach. */
|
|
57
|
+
readonly objc: FakeObjc;
|
|
58
|
+
/** The Java bridge a plugin gets on Android: register classes and objects for its calls to reach. */
|
|
59
|
+
readonly java: FakeJava;
|
|
60
|
+
/** Every `objc`, `java` and `hooks` call the plugin made this test, in order. */
|
|
61
|
+
readonly calls: readonly NativeCall[];
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** The plugins the fake `plugins` lists: the plugin under test is there, started; a test sets the others. */
|
|
65
|
+
export type PluginRecorder = {
|
|
66
|
+
/** Adds an installed plugin, or changes the one with this id; `plugins.get(id)` reads it. */
|
|
67
|
+
set: (id: string, spec?: PluginSpec) => void;
|
|
68
|
+
/** Removes an installed plugin. */
|
|
69
|
+
remove: (id: string) => void;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The fake `fs`: its in-memory files, by absolute path; a test may read, add or remove them. */
|
|
73
|
+
export type FsRecorder = { readonly files: Map<string, string> };
|
|
74
|
+
|
|
75
|
+
/** Every line the plugin's `logger` received this test. */
|
|
76
|
+
export type LogRecorder = { readonly entries: readonly LogEntry[] };
|
|
77
|
+
|
|
78
|
+
// Each recorder reads the session on every access, so it always shows the current test's fakes.
|
|
79
|
+
function createRecorders() {
|
|
80
|
+
const toasts: ToastRecorder = {
|
|
81
|
+
get shown() {
|
|
82
|
+
return getSession().toasts.shown;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
const dialogs: DialogRecorder = {
|
|
86
|
+
get opened() {
|
|
87
|
+
return getSession().dialogs.opened;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
const commands: CommandRecorder = {
|
|
91
|
+
get registered() {
|
|
92
|
+
return getSession().scope.commands.registered;
|
|
93
|
+
},
|
|
94
|
+
run: (path, args, context) => getSession().scope.commands.run(path, args, context),
|
|
95
|
+
};
|
|
96
|
+
const patches: PatchRecorder = {
|
|
97
|
+
get applied() {
|
|
98
|
+
return getSession().scope.patcher.patches;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
const flux: FluxRecorder = {
|
|
102
|
+
get dispatched() {
|
|
103
|
+
return getSession().flux.dispatched;
|
|
104
|
+
},
|
|
105
|
+
seed: (stores) => getSession().flux.seed(stores),
|
|
106
|
+
emit: (name) => getSession().flux.emit(name),
|
|
107
|
+
dispatch: (action) => getSession().flux.dispatch(action),
|
|
108
|
+
};
|
|
109
|
+
const metro: MetroRecorder = {
|
|
110
|
+
register: (module, path) => getSession().scope.metro.register(module, path),
|
|
111
|
+
};
|
|
112
|
+
const native: NativeRecorder = {
|
|
113
|
+
setPlatform: (platform) => void (getSession().native.state.platform = platform),
|
|
114
|
+
registerModule: (name, module) => void getSession().native.state.modules.set(name, module),
|
|
115
|
+
setCapabilities: (capabilities) =>
|
|
116
|
+
void (getSession().native.state.capabilities = capabilities),
|
|
117
|
+
get objc() {
|
|
118
|
+
return getSession().native.objc;
|
|
119
|
+
},
|
|
120
|
+
get java() {
|
|
121
|
+
return getSession().native.java;
|
|
122
|
+
},
|
|
123
|
+
get calls() {
|
|
124
|
+
return getSession().native.calls;
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
const plugins: PluginRecorder = {
|
|
128
|
+
set: (id, spec) => getSession().plugins.set(id, spec),
|
|
129
|
+
remove: (id) => getSession().plugins.remove(id),
|
|
130
|
+
};
|
|
131
|
+
const fs: FsRecorder = {
|
|
132
|
+
get files() {
|
|
133
|
+
return getSession().fs.files;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
const logs: LogRecorder = {
|
|
137
|
+
get entries() {
|
|
138
|
+
return getSession().scope.logs;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return { toasts, dialogs, commands, patches, flux, metro, native, plugins, fs, logs };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const { toasts, dialogs, commands, patches, flux, metro, native, plugins, fs, logs } =
|
|
146
|
+
createRecorders();
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @description Switches the locale `@unbound-app/i18n` reads, re-rendering every mounted component that reads it.
|
|
150
|
+
* A code the plugin ships no file for reads en-US, as on device. The preset puts en-US back before each test.
|
|
151
|
+
* @param code The locale code, such as `de`.
|
|
152
|
+
*/
|
|
153
|
+
export function setLocale(code: PluginLocale): void {
|
|
154
|
+
const { locales } = getSession();
|
|
155
|
+
|
|
156
|
+
if (!locales) {
|
|
157
|
+
throw new Error('Failed to set the locale: the plugin has no i18n/en-US.json.');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
locales.setLocale(String(code));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @description Adds members to a fake of `@unbound-app/api`, so the plugin reads them. `mock.module('@unbound-app/api')` in a test
|
|
165
|
+
* replaces the whole module instead, dropping every fake the preset installed.
|
|
166
|
+
* @param path The namespace, or a nested one with dots, such as `ui` or `metro.components`.
|
|
167
|
+
* @param members The members to add or replace.
|
|
168
|
+
*/
|
|
169
|
+
export function extendApi(path: string, members: FakeModule): void {
|
|
170
|
+
let target: unknown = getSession().scope.unbound;
|
|
171
|
+
|
|
172
|
+
for (const name of path.split('.')) {
|
|
173
|
+
target = typeof target === 'object' && target !== null ? Reflect.get(target, name) : void 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (typeof target !== 'object' || target === null) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Failed to extend @unbound-app/api ${path}: the fake has no such namespace.`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
Object.assign(target, members);
|
|
183
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { PatchParent, ReactPatcher, after, before, instead } from '../src/patcher';
|
|
2
|
+
|
|
3
|
+
import { createReactPatcher, type Install, type MethodContext } from './react-patcher';
|
|
4
|
+
|
|
5
|
+
type PatchKind = 'before' | 'instead' | 'after';
|
|
6
|
+
|
|
7
|
+
/** One patch a recording patcher applied, kept until it is removed. */
|
|
8
|
+
export type PatchRecord = {
|
|
9
|
+
kind: PatchKind;
|
|
10
|
+
/** The object holding the method; the lazy handle while the patch is pending. */
|
|
11
|
+
target: PatchParent;
|
|
12
|
+
/** The patched method; empty while a React patch waits, which finds the render only once its module registers. */
|
|
13
|
+
method: string;
|
|
14
|
+
/** True while the patch waits for its lazy handle's module to register. */
|
|
15
|
+
pending: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** A patcher that really patches, and records each patch it holds in `patches`. */
|
|
19
|
+
export type RecordingPatcher = {
|
|
20
|
+
before: typeof before;
|
|
21
|
+
instead: typeof instead;
|
|
22
|
+
after: typeof after;
|
|
23
|
+
/** React patches, which record the render they wrap: a `memo`'s `type`, a `forwardRef`'s or class's `render`. */
|
|
24
|
+
react: ReactPatcher;
|
|
25
|
+
/** Removes every patch this patcher holds. */
|
|
26
|
+
unpatchAll: () => void;
|
|
27
|
+
patches: PatchRecord[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// UNKNOWN: a patched method returns whatever the code under test returns.
|
|
31
|
+
type Method = (...args: never[]) => unknown;
|
|
32
|
+
|
|
33
|
+
type Context = MethodContext;
|
|
34
|
+
|
|
35
|
+
// UNKNOWN: a patch callback returns whatever the test's patch returns.
|
|
36
|
+
type Callback = (context: Context) => unknown;
|
|
37
|
+
|
|
38
|
+
type Hook = {
|
|
39
|
+
original: Method;
|
|
40
|
+
callbacks: Map<PatchRecord, Callback>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function run(hook: Hook, kind: PatchKind, context: Context) {
|
|
44
|
+
for (const [record, callback] of hook.callbacks) {
|
|
45
|
+
if (record.kind !== kind) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const returned = callback.call(context.this, context);
|
|
50
|
+
|
|
51
|
+
if (kind === 'before') {
|
|
52
|
+
if (Array.isArray(returned)) {
|
|
53
|
+
context.args = returned as never[];
|
|
54
|
+
}
|
|
55
|
+
} else if (returned !== undefined) {
|
|
56
|
+
context.result = returned;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function hook(target: PatchParent, method: string): Hook {
|
|
62
|
+
const created: Hook = { original: Reflect.get(target, method), callbacks: new Map() };
|
|
63
|
+
|
|
64
|
+
function patched(this: PatchParent, ...args: never[]) {
|
|
65
|
+
const context: Context = {
|
|
66
|
+
args,
|
|
67
|
+
this: this,
|
|
68
|
+
original: (...next: never[]) => created.original.apply(this, next),
|
|
69
|
+
result: null,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
run(created, 'before', context);
|
|
73
|
+
|
|
74
|
+
if ([...created.callbacks.keys()].some((record) => record.kind === 'instead')) {
|
|
75
|
+
run(created, 'instead', context);
|
|
76
|
+
} else {
|
|
77
|
+
context.result = context.original(...context.args);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
run(created, 'after', context);
|
|
81
|
+
|
|
82
|
+
return context.result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
Reflect.set(target, method, patched);
|
|
86
|
+
|
|
87
|
+
return created;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Calls `onLoad` with the module a lazy handle resolves to once one is registered, at once for a plain object; returns what stops waiting. */
|
|
91
|
+
export type WhenLoaded = (target: PatchParent, onLoad: (module: PatchParent) => void) => () => void;
|
|
92
|
+
|
|
93
|
+
// A plain object is the target already, so nothing waits.
|
|
94
|
+
function patchNow(target: PatchParent, onLoad: (module: PatchParent) => void): () => void {
|
|
95
|
+
onLoad(target);
|
|
96
|
+
|
|
97
|
+
return () => {};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @description Creates a patcher whose patches wrap the method for real and are listed in `patches` until removed.
|
|
102
|
+
* A patch on a lazy handle whose module is not registered yet is listed as pending and applies when it registers.
|
|
103
|
+
* @param whenLoaded Resolves a lazy handle to its module, as the fake metro does; omitted, every target is taken as it is.
|
|
104
|
+
* @returns The patcher.
|
|
105
|
+
*/
|
|
106
|
+
export function createRecordingPatcher(whenLoaded: WhenLoaded = patchNow): RecordingPatcher {
|
|
107
|
+
const patches: PatchRecord[] = [];
|
|
108
|
+
// Keyed by the resolved module, so a handle patches whichever module is registered for it in this session.
|
|
109
|
+
const hooks = new Map<PatchParent, Map<string, Hook>>();
|
|
110
|
+
const waiting = new Map<PatchRecord, () => void>();
|
|
111
|
+
|
|
112
|
+
function unpatch(record: PatchRecord) {
|
|
113
|
+
const index = patches.indexOf(record);
|
|
114
|
+
|
|
115
|
+
if (index === -1) return;
|
|
116
|
+
|
|
117
|
+
patches.splice(index, 1);
|
|
118
|
+
waiting.get(record)?.();
|
|
119
|
+
waiting.delete(record);
|
|
120
|
+
|
|
121
|
+
const methods = hooks.get(record.target);
|
|
122
|
+
const hooked = methods?.get(record.method);
|
|
123
|
+
|
|
124
|
+
if (!methods || !hooked) return;
|
|
125
|
+
|
|
126
|
+
hooked.callbacks.delete(record);
|
|
127
|
+
|
|
128
|
+
if (hooked.callbacks.size > 0) return;
|
|
129
|
+
|
|
130
|
+
Reflect.set(record.target, record.method, hooked.original);
|
|
131
|
+
methods.delete(record.method);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const install: Install = (kind, target, place, method = '') => {
|
|
135
|
+
const record: PatchRecord = { kind, target, method, pending: true };
|
|
136
|
+
|
|
137
|
+
patches.push(record);
|
|
138
|
+
waiting.set(
|
|
139
|
+
record,
|
|
140
|
+
whenLoaded(target, (module) => {
|
|
141
|
+
const placed = place(module);
|
|
142
|
+
const { parent, callback } = placed;
|
|
143
|
+
const methods = hooks.get(parent) ?? new Map<string, Hook>();
|
|
144
|
+
const hooked = methods.get(placed.method) ?? hook(parent, placed.method);
|
|
145
|
+
|
|
146
|
+
waiting.delete(record);
|
|
147
|
+
hooks.set(parent, methods);
|
|
148
|
+
methods.set(placed.method, hooked);
|
|
149
|
+
hooked.callbacks.set(record, callback);
|
|
150
|
+
Object.assign(record, { target: parent, method: placed.method, pending: false });
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return () => unpatch(record);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
function patch(kind: PatchKind) {
|
|
158
|
+
return (target: PatchParent, method: string, callback: Callback) =>
|
|
159
|
+
install(kind, target, (module) => ({ parent: module, method, callback }), method);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
patches,
|
|
164
|
+
before: patch('before') as RecordingPatcher['before'],
|
|
165
|
+
instead: patch('instead') as RecordingPatcher['instead'],
|
|
166
|
+
after: patch('after') as RecordingPatcher['after'],
|
|
167
|
+
react: createReactPatcher(install),
|
|
168
|
+
unpatchAll: () => {
|
|
169
|
+
// `unpatch` splices `patches`, so walk a copy.
|
|
170
|
+
for (const record of patches.slice()) unpatch(record);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import type { PluginLocale, SettingsOverrides } from './plugin-types';
|
|
4
|
+
import type { SettingValue } from './fake-settings';
|
|
5
|
+
import type { FakeModule } from './fake-metro';
|
|
6
|
+
import type { StoreSeeds } from './fake-api';
|
|
7
|
+
|
|
8
|
+
import { act, createTestRenderer } from './test-renderer';
|
|
9
|
+
import { queriesOf, type Queries } from './test-node';
|
|
10
|
+
import { getSession } from './session';
|
|
11
|
+
|
|
12
|
+
/** What `render` sets up before the first render. */
|
|
13
|
+
export type RenderOptions = {
|
|
14
|
+
/** Settings to store first; the rest read their defaults. */
|
|
15
|
+
settings?: SettingsOverrides;
|
|
16
|
+
/** The locale `@unbound-app/i18n` reads. */
|
|
17
|
+
locale?: PluginLocale;
|
|
18
|
+
/** Flux stores `flux.getStore` and `flux.useStore` read, such as `{ User: { getCurrentUser: () => user } }`. */
|
|
19
|
+
stores?: StoreSeeds;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** A mounted tree: queries over it, and the operations a test drives it with. */
|
|
23
|
+
export type RenderResult = Queries & {
|
|
24
|
+
/** Renders a new element in place; components of the same type keep their state. */
|
|
25
|
+
rerender: (element: ReactNode) => void;
|
|
26
|
+
/** Unmounts the tree, running every effect's cleanup. The preset unmounts what a test leaves mounted. */
|
|
27
|
+
unmount: () => void;
|
|
28
|
+
/** Runs an interaction with renders held back, then renders once. */
|
|
29
|
+
act: typeof act;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function isLeaf(value: unknown): value is SettingValue {
|
|
33
|
+
return typeof value !== 'object' || value === null || Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function applySettings(overrides: FakeModule, prefix = '') {
|
|
37
|
+
const { settings } = getSession();
|
|
38
|
+
|
|
39
|
+
if (!settings) {
|
|
40
|
+
throw new Error('Failed to apply render settings: the plugin has no settings.ts.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
44
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
45
|
+
|
|
46
|
+
if (isLeaf(value)) {
|
|
47
|
+
settings.set(path, value);
|
|
48
|
+
} else {
|
|
49
|
+
applySettings(value, path);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @description Renders an element with the plugin's fakes, after storing `settings`, switching to `locale` and
|
|
56
|
+
* seeding `stores`. Host components from `react-native` stay plain nodes `find` reaches by name.
|
|
57
|
+
* @param element The element, such as `<GreetingTitle />`.
|
|
58
|
+
* @param options The settings, locale and stores to set up first.
|
|
59
|
+
* @returns Queries over the tree, and `rerender`, `unmount` and `act`.
|
|
60
|
+
*/
|
|
61
|
+
export function render(element: ReactNode, options: RenderOptions = {}): RenderResult {
|
|
62
|
+
const session = getSession();
|
|
63
|
+
|
|
64
|
+
if (options.stores) {
|
|
65
|
+
session.flux.seed(options.stores);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (options.settings) {
|
|
69
|
+
applySettings(options.settings);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (options.locale !== undefined) {
|
|
73
|
+
session.locales?.setLocale(String(options.locale));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const renderer = createTestRenderer(element);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
...queriesOf(renderer.root),
|
|
80
|
+
rerender: (next) => act(() => renderer.update(next)),
|
|
81
|
+
unmount: renderer.unmount,
|
|
82
|
+
act,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { InstalledAddonGlobals } from './install-addon-globals';
|
|
2
|
+
import type { FakeDialogs, FakeToasts } from './fake-feedback';
|
|
3
|
+
import type { ListenerRegistry } from './listener-registry';
|
|
4
|
+
import type { FakeAssets, FakeFlux } from './fake-api';
|
|
5
|
+
import type { FakeSettings } from './fake-settings';
|
|
6
|
+
import type { FakeLocales } from './fake-locales';
|
|
7
|
+
import type { FakePlugins } from './fake-plugins';
|
|
8
|
+
import type { FakeNative } from './fake-native';
|
|
9
|
+
import type { FakeFs } from './fake-fs';
|
|
10
|
+
|
|
11
|
+
import { unmountAll } from './test-renderer';
|
|
12
|
+
|
|
13
|
+
/** The plugin under test and every fake the preset installed for it. */
|
|
14
|
+
export type TestSession = {
|
|
15
|
+
root: string;
|
|
16
|
+
entry: string | undefined;
|
|
17
|
+
scope: InstalledAddonGlobals;
|
|
18
|
+
listeners: ListenerRegistry;
|
|
19
|
+
toasts: FakeToasts;
|
|
20
|
+
dialogs: FakeDialogs;
|
|
21
|
+
flux: FakeFlux;
|
|
22
|
+
assets: FakeAssets;
|
|
23
|
+
locales: FakeLocales | undefined;
|
|
24
|
+
settings: FakeSettings | undefined;
|
|
25
|
+
native: FakeNative;
|
|
26
|
+
plugins: FakePlugins;
|
|
27
|
+
fs: FakeFs;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
let active: TestSession | undefined;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @description Makes a session the one the recorders, `render` and `startPlugin` read. The preset calls it once.
|
|
34
|
+
* @param session The session.
|
|
35
|
+
*/
|
|
36
|
+
export function startSession(session: TestSession): void {
|
|
37
|
+
active = session;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @description Returns the session the preset started.
|
|
42
|
+
* @returns The session; throws naming the bunfig line when the preset is not loaded.
|
|
43
|
+
*/
|
|
44
|
+
export function getSession(): TestSession {
|
|
45
|
+
if (!active) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
'The Unbound test preset is not loaded. Add `preload = ["@unbound-app/api/testing/preset"]` under [test] in bunfig.toml.',
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return active;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @description Puts every fake back to how the preset made it: no mounted trees, patches, commands, listeners, timers,
|
|
56
|
+
* metro modules, files, stored settings, toasts, dialogs, stores or logs, the platform iOS, and en-US.
|
|
57
|
+
*/
|
|
58
|
+
export function resetSession(): void {
|
|
59
|
+
const session = getSession();
|
|
60
|
+
const { scope } = session;
|
|
61
|
+
|
|
62
|
+
unmountAll();
|
|
63
|
+
scope.patcher.unpatchAll();
|
|
64
|
+
scope.commands.unregisterAll();
|
|
65
|
+
scope.timers.clearAll();
|
|
66
|
+
scope.metro.reset();
|
|
67
|
+
session.listeners.clear();
|
|
68
|
+
session.native.reset();
|
|
69
|
+
session.plugins.reset();
|
|
70
|
+
session.fs.files.clear();
|
|
71
|
+
scope.storage.data.clear();
|
|
72
|
+
scope.logs.length = 0;
|
|
73
|
+
session.flux.reset();
|
|
74
|
+
session.toasts.shown.length = 0;
|
|
75
|
+
session.dialogs.opened.length = 0;
|
|
76
|
+
session.locales?.setLocale('en-US');
|
|
77
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { HostName, HostPropsByName } from './fake-react-native';
|
|
2
|
+
import type { Fiber, Props } from './test-renderer';
|
|
3
|
+
|
|
4
|
+
/** The props of a host or component this kit has no type for: whatever the code under test gave it. */
|
|
5
|
+
type AnyProps = Props;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What `find` and `findAll` match, as `findComponent` does: a host or component name (a host's name, else
|
|
9
|
+
* `displayName`, else the function's name), or a predicate over each node.
|
|
10
|
+
*/
|
|
11
|
+
export type Matcher = string | ((node: TestNode) => boolean);
|
|
12
|
+
|
|
13
|
+
/** Finds rendered nodes below a node. A host name types the props; pass `P` to type a component's. */
|
|
14
|
+
export type Queries = {
|
|
15
|
+
/** The first node below that matches, depth first; throws naming what did render when none does. */
|
|
16
|
+
find: {
|
|
17
|
+
<N extends HostName>(name: N): TestNode<HostPropsByName[N]>;
|
|
18
|
+
<P = AnyProps>(match: Matcher): TestNode<P>;
|
|
19
|
+
};
|
|
20
|
+
/** Every node below that matches, depth first. */
|
|
21
|
+
findAll: {
|
|
22
|
+
<N extends HostName>(name: N): TestNode<HostPropsByName[N]>[];
|
|
23
|
+
<P = AnyProps>(match: Matcher): TestNode<P>[];
|
|
24
|
+
};
|
|
25
|
+
/** The text rendered below, joined. */
|
|
26
|
+
text: () => string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** A rendered host element or component, read live: after a re-render it shows the new props and children. */
|
|
30
|
+
export type TestNode<P = AnyProps> = Queries & {
|
|
31
|
+
/** The element type: a host name such as `'Text'`, or the component. */
|
|
32
|
+
type: unknown;
|
|
33
|
+
/** The host name, or the component's `displayName` or function name. */
|
|
34
|
+
name: string;
|
|
35
|
+
props: P;
|
|
36
|
+
/** The nodes and strings rendered directly inside, Fragments and Providers flattened away. */
|
|
37
|
+
children: (TestNode | string)[];
|
|
38
|
+
parent: TestNode | undefined;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const nodes = new WeakMap<Fiber, TestNode>();
|
|
42
|
+
|
|
43
|
+
function visible(fibers: Fiber[]): Fiber[] {
|
|
44
|
+
return fibers.flatMap((fiber) =>
|
|
45
|
+
fiber.kind === 'wrapper' ? visible(fiber.children) : [fiber],
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function texts(fiber: Fiber): string {
|
|
50
|
+
return fiber.kind === 'text' ? fiber.text : fiber.children.map(texts).join('');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function matches(match: Matcher, node: TestNode): boolean {
|
|
54
|
+
return typeof match === 'string' ? node.name === match : match(node);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function describe(match: Matcher): string {
|
|
58
|
+
return typeof match === 'string' ? `"${match}"` : 'the predicate';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function walk(fiber: Fiber, visit: (node: TestNode) => boolean) {
|
|
62
|
+
for (const child of visible(fiber.children)) {
|
|
63
|
+
if (child.kind === 'text') {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (visit(nodeOf(child)) || walk(child, visit)) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @description Builds the queries over the nodes rendered below a fiber.
|
|
77
|
+
* @param fiber The fiber, such as a renderer's root.
|
|
78
|
+
* @returns `find`, `findAll` and `text`.
|
|
79
|
+
*/
|
|
80
|
+
export function queriesOf(fiber: Fiber): Queries {
|
|
81
|
+
function findAll(match: Matcher): TestNode[] {
|
|
82
|
+
const found: TestNode[] = [];
|
|
83
|
+
|
|
84
|
+
walk(fiber, (node) => {
|
|
85
|
+
if (matches(match, node)) {
|
|
86
|
+
found.push(node);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return false;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return found;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function find(match: Matcher): TestNode {
|
|
96
|
+
let found: TestNode | undefined;
|
|
97
|
+
|
|
98
|
+
walk(fiber, (node) => {
|
|
99
|
+
found = matches(match, node) ? node : void 0;
|
|
100
|
+
|
|
101
|
+
return Boolean(found);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (!found) {
|
|
105
|
+
const rendered = new Set<string>();
|
|
106
|
+
|
|
107
|
+
walk(fiber, (node) => {
|
|
108
|
+
rendered.add(node.name);
|
|
109
|
+
|
|
110
|
+
return false;
|
|
111
|
+
});
|
|
112
|
+
throw new Error(
|
|
113
|
+
`No rendered node matches ${describe(match)}. Rendered: ${[...rendered].join(', ') || 'nothing'}.`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return found;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
find: find,
|
|
122
|
+
findAll: findAll,
|
|
123
|
+
text: () => texts(fiber),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @description Wraps a fiber as a {@link TestNode}, once per fiber, so the same node is returned across renders.
|
|
129
|
+
* @param fiber A host or component fiber.
|
|
130
|
+
* @returns The node.
|
|
131
|
+
*/
|
|
132
|
+
export function nodeOf(fiber: Fiber): TestNode {
|
|
133
|
+
const cached = nodes.get(fiber);
|
|
134
|
+
|
|
135
|
+
if (cached) {
|
|
136
|
+
return cached;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const node: TestNode = {
|
|
140
|
+
...queriesOf(fiber),
|
|
141
|
+
type: fiber.type,
|
|
142
|
+
name: fiber.name,
|
|
143
|
+
get props() {
|
|
144
|
+
return fiber.props;
|
|
145
|
+
},
|
|
146
|
+
get children() {
|
|
147
|
+
return visible(fiber.children).map((child) =>
|
|
148
|
+
child.kind === 'text' ? child.text : nodeOf(child),
|
|
149
|
+
);
|
|
150
|
+
},
|
|
151
|
+
get parent() {
|
|
152
|
+
let parent = fiber.parent;
|
|
153
|
+
|
|
154
|
+
while (parent?.kind === 'wrapper') parent = parent.parent;
|
|
155
|
+
|
|
156
|
+
return parent && parent.kind !== 'root' ? nodeOf(parent) : void 0;
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
nodes.set(fiber, node);
|
|
161
|
+
|
|
162
|
+
return node;
|
|
163
|
+
}
|