@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,149 @@
|
|
|
1
|
+
import type { PatchRecord } from './recording-patcher';
|
|
2
|
+
import type { PluginContext } from '../src/native';
|
|
3
|
+
import type { StartContext } from './fake-native';
|
|
4
|
+
|
|
5
|
+
import { getSession } from './session';
|
|
6
|
+
|
|
7
|
+
type PluginExports = {
|
|
8
|
+
start?: (context: PluginContext) => void | Promise<void>;
|
|
9
|
+
stop?: () => void | Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type Held = {
|
|
13
|
+
patches: Set<PatchRecord>;
|
|
14
|
+
commands: Set<string>;
|
|
15
|
+
listeners: number;
|
|
16
|
+
direct: number;
|
|
17
|
+
timers: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
let started: { plugin: PluginExports; context: PluginContext; before: Held } | undefined;
|
|
21
|
+
|
|
22
|
+
function isPluginExports(value: unknown): value is PluginExports {
|
|
23
|
+
return typeof value === 'object' && value !== null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function held(): Held {
|
|
27
|
+
const { scope, listeners, flux } = getSession();
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
patches: new Set(scope.patcher.patches),
|
|
31
|
+
commands: new Set(scope.commands.registered.keys()),
|
|
32
|
+
listeners: listeners.active().length,
|
|
33
|
+
direct: flux.directListeners().length,
|
|
34
|
+
timers: scope.timers.pending(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function loadPlugin(): Promise<PluginExports> {
|
|
39
|
+
const { entry, root } = getSession();
|
|
40
|
+
|
|
41
|
+
if (!entry) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Failed to start the plugin: ${root} has no src/index.tsx or src/index.ts.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const module: unknown = await import(entry);
|
|
48
|
+
const plugin: unknown =
|
|
49
|
+
isPluginExports(module) && 'default' in module ? module.default : module;
|
|
50
|
+
|
|
51
|
+
if (!isPluginExports(plugin)) {
|
|
52
|
+
throw new Error(`Failed to start the plugin: ${entry} exports no plugin object.`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return plugin;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function describe(record: PatchRecord): string {
|
|
59
|
+
const target = Reflect.get(record.target, 'name');
|
|
60
|
+
|
|
61
|
+
return `patcher.${record.kind} on ${typeof target === 'string' && target ? `${target}.` : ''}${record.method}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// What the plugin added since `before` and still holds, as one line each.
|
|
65
|
+
function since(before: Held, scopeOnly: boolean): string[] {
|
|
66
|
+
const { scope, listeners, flux } = getSession();
|
|
67
|
+
|
|
68
|
+
return [
|
|
69
|
+
...scope.patcher.patches.filter((record) => !before.patches.has(record)).map(describe),
|
|
70
|
+
...[...scope.commands.registered.keys()]
|
|
71
|
+
.filter((name) => !before.commands.has(name))
|
|
72
|
+
.map((name) => `command /${name}`),
|
|
73
|
+
...listeners.active().slice(before.listeners),
|
|
74
|
+
...Array.from(
|
|
75
|
+
{ length: Math.max(scope.timers.pending() - before.timers, 0) },
|
|
76
|
+
() => 'timer',
|
|
77
|
+
),
|
|
78
|
+
...(scopeOnly ? [] : flux.directListeners().slice(before.direct)),
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @description Imports the plugin's entry once and runs its `start` with a `PluginContext`, as the client does when it is enabled.
|
|
84
|
+
* @param context Overrides for the context: its `platform`, `capabilities`, or the `native` bridge members the plugin calls.
|
|
85
|
+
* @returns A promise that settles once `start` has.
|
|
86
|
+
*/
|
|
87
|
+
export async function startPlugin(context: StartContext = {}): Promise<void> {
|
|
88
|
+
const { native, scope } = getSession();
|
|
89
|
+
const plugin = await loadPlugin();
|
|
90
|
+
const built = native.context(scope.addon.manifest, context);
|
|
91
|
+
|
|
92
|
+
started = { plugin, context: built, before: held() };
|
|
93
|
+
await plugin.start?.(built);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @description Lists what the plugin added since {@link startPlugin} and still holds: patches, commands, listeners and timers.
|
|
98
|
+
* Use it to check a feature undid itself without stopping the plugin, such as after a setting turned it off.
|
|
99
|
+
* @returns One line per thing held; empty when the plugin holds nothing.
|
|
100
|
+
*/
|
|
101
|
+
export function pluginHolds(): string[] {
|
|
102
|
+
if (!started) {
|
|
103
|
+
throw new Error('Failed to read what the plugin holds: call startPlugin() first.');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return since(started.before, true);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @description Runs the plugin's `stop`, then disposes what the plugin scope recorded, as the client does: its patches, commands,
|
|
111
|
+
* listeners and timers. The call rejects, which fails the test, listing what is still held after that, such as a
|
|
112
|
+
* change listener added directly to a Flux store, which Unbound does not remove either.
|
|
113
|
+
* @returns A promise that settles once `stop` has and nothing is left.
|
|
114
|
+
*/
|
|
115
|
+
export async function stopPlugin(): Promise<void> {
|
|
116
|
+
if (!started) {
|
|
117
|
+
throw new Error('Failed to stop the plugin: call startPlugin() first.');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { plugin, context, before } = started;
|
|
121
|
+
const { scope, listeners } = getSession();
|
|
122
|
+
|
|
123
|
+
started = void 0;
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await plugin.stop?.();
|
|
127
|
+
} finally {
|
|
128
|
+
context.dispose();
|
|
129
|
+
scope.patcher.unpatchAll();
|
|
130
|
+
scope.timers.clearAll();
|
|
131
|
+
listeners.clear();
|
|
132
|
+
scope.commands.unregisterAll();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const leaks = since(before, false);
|
|
136
|
+
|
|
137
|
+
if (leaks.length === 0) return;
|
|
138
|
+
|
|
139
|
+
throw new Error(
|
|
140
|
+
`${scope.addon.name} left ${leaks.length} thing(s) behind after stop():\n${leaks.map((leak) => ` - ${leak}`).join('\n')}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @description Forgets a started plugin without stopping it. The preset calls it before each test.
|
|
146
|
+
*/
|
|
147
|
+
export function forgetPlugin(): void {
|
|
148
|
+
started = void 0;
|
|
149
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { SettingsValues } from './fake-settings';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The plugin under test's own types, which type `render`'s `settings` and `locale` and `setLocale`. Declare them
|
|
5
|
+
* once, beside the generated typings:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import type { Settings } from '@unbound-app/settings';
|
|
9
|
+
*
|
|
10
|
+
* declare module '@unbound-app/api/testing' {
|
|
11
|
+
* interface PluginTypes { settings: Settings; locale: 'en-US' | 'de' }
|
|
12
|
+
* }
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
// oxlint-disable-next-line typescript/no-empty-object-type, typescript/consistent-type-definitions -- declaration merging needs an interface, empty until a plugin adds to it
|
|
16
|
+
export interface PluginTypes {}
|
|
17
|
+
|
|
18
|
+
type Declared<K extends PropertyKey, Fallback> =
|
|
19
|
+
PluginTypes extends Record<K, infer T> ? T : Fallback;
|
|
20
|
+
|
|
21
|
+
/** The plugin's settings, from {@link PluginTypes}; any settings object until it is declared. */
|
|
22
|
+
export type PluginSettings = Declared<'settings', SettingsValues>;
|
|
23
|
+
|
|
24
|
+
/** The plugin's locale codes, from {@link PluginTypes}; any string until they are declared. */
|
|
25
|
+
export type PluginLocale = Declared<'locale', string>;
|
|
26
|
+
|
|
27
|
+
/** Some of a plugin's settings, sections included in part: what a test overrides. */
|
|
28
|
+
export type SettingsOverrides<S = PluginSettings> = {
|
|
29
|
+
[K in keyof S]?: S[K] extends readonly unknown[]
|
|
30
|
+
? S[K]
|
|
31
|
+
: S[K] extends object
|
|
32
|
+
? SettingsOverrides<S[K]>
|
|
33
|
+
: S[K];
|
|
34
|
+
};
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// The bun preload of a plugin's tests: `preload = ["@unbound-app/api/testing/preset"]` in bunfig.toml.
|
|
2
|
+
import { afterEach, beforeEach, mock } from 'bun:test';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { createElement } from 'react';
|
|
6
|
+
import * as React from 'react';
|
|
7
|
+
import { plugin } from 'bun';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
assetNameOf,
|
|
11
|
+
findEntry,
|
|
12
|
+
findPluginRoot,
|
|
13
|
+
readLocaleTables,
|
|
14
|
+
readManifest,
|
|
15
|
+
} from './plugin-files';
|
|
16
|
+
import { createFakeDialogs, createFakeToasts, hasPlaceholders, KEY_LOOKUP } from './fake-feedback';
|
|
17
|
+
import { createFakeSettings, defineSettings, setting, type SettingsSchema } from './fake-settings';
|
|
18
|
+
import { createListenerRegistry, type ListenerRegistry } from './listener-registry';
|
|
19
|
+
import { createFakeReactNative, type HostPropsByName } from './fake-react-native';
|
|
20
|
+
import { installAddonGlobals, type FakeUnbound } from './install-addon-globals';
|
|
21
|
+
import { createFakeLocales, SOURCE_LOCALE } from './fake-locales';
|
|
22
|
+
import { createFakeAssets, createFakeFlux } from './fake-api';
|
|
23
|
+
import { createJsxRuntimes } from './unfrozen-elements';
|
|
24
|
+
import { resetSession, startSession } from './session';
|
|
25
|
+
import { createFakePlugins } from './fake-plugins';
|
|
26
|
+
import { forgetPlugin } from './plugin-lifecycle';
|
|
27
|
+
import { createFakeUi } from './fake-components';
|
|
28
|
+
import { createFakeNative } from './fake-native';
|
|
29
|
+
import { createFakeUtils } from './fake-utils';
|
|
30
|
+
import { unmountAll } from './test-renderer';
|
|
31
|
+
import { createFakeFs } from './fake-fs';
|
|
32
|
+
|
|
33
|
+
type SvgProps = Omit<HostPropsByName['SvgXml'], 'xml' | 'asset'>;
|
|
34
|
+
|
|
35
|
+
// The namespaces that serve nested modules, as `unbound.metro.components` is `@unbound-app/api/metro/components`.
|
|
36
|
+
const NESTED = new Map([
|
|
37
|
+
['metro', ['api', 'common', 'components', 'filters', 'stores']],
|
|
38
|
+
['utils', ['react']],
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const IMAGE = /\.(png|jpe?g|webp|gif)$/i;
|
|
42
|
+
const SVG = /\.svg$/i;
|
|
43
|
+
const SVG_ASSET = /\.svg\?asset$/i;
|
|
44
|
+
const SVG_ASSET_NAMESPACE = 'unbound-svg-asset';
|
|
45
|
+
|
|
46
|
+
function isSettingsSchema(value: unknown): value is SettingsSchema {
|
|
47
|
+
return (
|
|
48
|
+
typeof value === 'object' &&
|
|
49
|
+
value !== null &&
|
|
50
|
+
Reflect.get(value, 'kind') === 'settings' &&
|
|
51
|
+
typeof Reflect.get(value, 'properties') === 'object'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The plugin-facing `storage` and `metro` track their listeners, so `stopPlugin` sees any left behind.
|
|
56
|
+
function trackedNamespaces(unbound: FakeUnbound, listeners: ListenerRegistry) {
|
|
57
|
+
const { storage, metro } = unbound;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
storage: {
|
|
61
|
+
...storage,
|
|
62
|
+
onChange: (...args: Parameters<typeof storage.onChange>) =>
|
|
63
|
+
listeners.track('storage.onChange', storage.onChange(...args)),
|
|
64
|
+
},
|
|
65
|
+
metro: {
|
|
66
|
+
...metro,
|
|
67
|
+
onModuleLoad: (listener: Parameters<typeof metro.onModuleLoad>[0]) =>
|
|
68
|
+
listeners.track('metro.onModuleLoad', metro.onModuleLoad(listener)),
|
|
69
|
+
onModuleLoaded: (...args: Parameters<typeof metro.onModuleLoaded>) =>
|
|
70
|
+
listeners.track('metro.onModuleLoaded', metro.onModuleLoaded(...args)),
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Runs the export of the plugin's src/actions.ts a settings button names, as the client's panel does.
|
|
76
|
+
async function runAction(pluginRoot: string, name: string): Promise<void> {
|
|
77
|
+
const path = join(pluginRoot, 'src', 'actions.ts');
|
|
78
|
+
|
|
79
|
+
if (!existsSync(path)) {
|
|
80
|
+
throw new Error(`Failed to run action ${name}: ${pluginRoot} has no src/actions.ts.`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const actions: unknown = await import(path);
|
|
84
|
+
const action =
|
|
85
|
+
typeof actions === 'object' && actions !== null ? Reflect.get(actions, name) : void 0;
|
|
86
|
+
|
|
87
|
+
if (typeof action !== 'function') {
|
|
88
|
+
throw new Error(`Failed to run action ${name}: src/actions.ts exports no such function.`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
await action();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveImport(root: string, path: string, importer: string): string {
|
|
95
|
+
return path.startsWith('~/')
|
|
96
|
+
? join(root, 'src', path.slice(2))
|
|
97
|
+
: resolve(dirname(importer), path);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function svgComponent(asset: string, xml: string) {
|
|
101
|
+
function SvgImage(props: SvgProps) {
|
|
102
|
+
return createElement('SvgXml', { xml, asset, ...props });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
SvgImage.displayName = `Svg(${asset})`;
|
|
106
|
+
|
|
107
|
+
return SvgImage;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function readSettings(root: string): Promise<SettingsSchema | undefined> {
|
|
111
|
+
const path = join(root, 'settings.ts');
|
|
112
|
+
|
|
113
|
+
if (!existsSync(path)) return;
|
|
114
|
+
|
|
115
|
+
const module: unknown = await import(path);
|
|
116
|
+
const schema: unknown =
|
|
117
|
+
typeof module === 'object' && module !== null ? Reflect.get(module, 'default') : void 0;
|
|
118
|
+
|
|
119
|
+
if (!isSettingsSchema(schema)) {
|
|
120
|
+
throw new Error(`Failed to read ${path}: its default export is not defineSettings(...).`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return schema;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const root = findPluginRoot();
|
|
127
|
+
const manifest = readManifest(root);
|
|
128
|
+
const listeners = createListenerRegistry();
|
|
129
|
+
const tables = readLocaleTables(root);
|
|
130
|
+
const locales = tables.size ? createFakeLocales(tables) : undefined;
|
|
131
|
+
const source = tables.get(SOURCE_LOCALE) ?? {};
|
|
132
|
+
const lookup = locales
|
|
133
|
+
? { resolveMessage: locales.format, takesVars: (key: string) => hasPlaceholders(source[key]) }
|
|
134
|
+
: KEY_LOOKUP;
|
|
135
|
+
const toasts = createFakeToasts(() => lookup);
|
|
136
|
+
const dialogs = createFakeDialogs(() => lookup);
|
|
137
|
+
const flux = createFakeFlux(listeners);
|
|
138
|
+
const assets = createFakeAssets(listeners);
|
|
139
|
+
const native = createFakeNative();
|
|
140
|
+
const fs = createFakeFs();
|
|
141
|
+
const reactNative = createFakeReactNative(() => native.state.platform);
|
|
142
|
+
const scope = installAddonGlobals({
|
|
143
|
+
manifest,
|
|
144
|
+
unbound: {
|
|
145
|
+
toasts,
|
|
146
|
+
dialogs,
|
|
147
|
+
flux,
|
|
148
|
+
assets,
|
|
149
|
+
native: native.namespace,
|
|
150
|
+
ui: createFakeUi(),
|
|
151
|
+
utils: createFakeUtils(),
|
|
152
|
+
fs,
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const plugins = createFakePlugins(scope.addon.manifest);
|
|
157
|
+
|
|
158
|
+
scope.unbound.plugins = plugins.namespace;
|
|
159
|
+
Object.assign(scope.unbound, trackedNamespaces(scope.unbound, listeners));
|
|
160
|
+
// On device the scope's `React` is Discord's real one; the fake one cannot re-render.
|
|
161
|
+
Reflect.set(globalThis, 'React', React);
|
|
162
|
+
Object.assign(scope.metro.common, { React, ReactNative: reactNative });
|
|
163
|
+
Object.assign(scope.metro.stores, {
|
|
164
|
+
Accessibility: flux.getStore('Accessibility'),
|
|
165
|
+
Theme: flux.getStore('Theme'),
|
|
166
|
+
Users: flux.getStore('User'),
|
|
167
|
+
Guilds: flux.getStore('Guild'),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
mock.module('react-native', () => reactNative);
|
|
171
|
+
mock.module('@unbound-app/api', () => scope.unbound);
|
|
172
|
+
|
|
173
|
+
for (const [name, namespace] of Object.entries(scope.unbound)) {
|
|
174
|
+
mock.module(`@unbound-app/api/${name}`, () => namespace);
|
|
175
|
+
|
|
176
|
+
// A namespace that nests others serves each one at its own path, such as `@unbound-app/api/metro/components`.
|
|
177
|
+
for (const [nested, members] of Object.entries(namespace)) {
|
|
178
|
+
if (NESTED.get(name)?.includes(nested)) {
|
|
179
|
+
mock.module(`@unbound-app/api/${name}/${nested}`, () => members);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const [specifier, runtime] of Object.entries(createJsxRuntimes())) {
|
|
185
|
+
mock.module(specifier, () => runtime);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (locales) {
|
|
189
|
+
mock.module('@unbound-app/i18n', () => ({
|
|
190
|
+
messages: locales.messages,
|
|
191
|
+
format: locales.format,
|
|
192
|
+
useMessages: locales.useMessages,
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
mock.module('@unbound-app/settings/schema', () => ({
|
|
197
|
+
setting,
|
|
198
|
+
defineSettings,
|
|
199
|
+
default: { setting, defineSettings },
|
|
200
|
+
}));
|
|
201
|
+
|
|
202
|
+
const schema = await readSettings(root);
|
|
203
|
+
const settings = schema
|
|
204
|
+
? createFakeSettings(schema, scope.storage, scope.addon.id, {
|
|
205
|
+
translate: (key) => locales?.messages[key] ?? key,
|
|
206
|
+
runAction: (name) => runAction(root, name),
|
|
207
|
+
})
|
|
208
|
+
: undefined;
|
|
209
|
+
|
|
210
|
+
if (settings) {
|
|
211
|
+
mock.module('@unbound-app/settings', () => ({
|
|
212
|
+
settings: settings.settings,
|
|
213
|
+
useSetting: settings.useSetting,
|
|
214
|
+
resetSettings: settings.resetSettings,
|
|
215
|
+
SettingsPanel: settings.SettingsPanel,
|
|
216
|
+
getSettingsPanel: settings.getSettingsPanel,
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
plugin({
|
|
221
|
+
name: 'unbound-test-assets',
|
|
222
|
+
setup(build) {
|
|
223
|
+
build.onResolve({ filter: SVG_ASSET }, ({ path, importer }) => ({
|
|
224
|
+
path: resolveImport(root, path.slice(0, -'?asset'.length), importer),
|
|
225
|
+
namespace: SVG_ASSET_NAMESPACE,
|
|
226
|
+
}));
|
|
227
|
+
build.onLoad({ filter: /.*/, namespace: SVG_ASSET_NAMESPACE }, ({ path }) => ({
|
|
228
|
+
loader: 'object',
|
|
229
|
+
exports: { default: assets.getIdByName(assetNameOf(root, path), 'png') },
|
|
230
|
+
}));
|
|
231
|
+
build.onLoad({ filter: IMAGE }, ({ path }) => ({
|
|
232
|
+
loader: 'object',
|
|
233
|
+
exports: { default: assets.getIdByName(assetNameOf(root, path)) },
|
|
234
|
+
}));
|
|
235
|
+
build.onLoad({ filter: SVG }, ({ path }) => ({
|
|
236
|
+
loader: 'object',
|
|
237
|
+
exports: { default: svgComponent(assetNameOf(root, path), readFileSync(path, 'utf8')) },
|
|
238
|
+
}));
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
startSession({
|
|
243
|
+
root,
|
|
244
|
+
entry: findEntry(root),
|
|
245
|
+
scope,
|
|
246
|
+
listeners,
|
|
247
|
+
toasts,
|
|
248
|
+
dialogs,
|
|
249
|
+
flux,
|
|
250
|
+
assets,
|
|
251
|
+
locales,
|
|
252
|
+
settings,
|
|
253
|
+
native,
|
|
254
|
+
plugins,
|
|
255
|
+
fs,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
beforeEach(() => {
|
|
259
|
+
forgetPlugin();
|
|
260
|
+
resetSession();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
afterEach(unmountAll);
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { AnyObject, PatchParent, ReactPatcher } from '../src/patcher';
|
|
2
|
+
|
|
3
|
+
type PatchKind = 'before' | 'instead' | 'after';
|
|
4
|
+
|
|
5
|
+
// UNKNOWN: a patched render takes and returns whatever the component under test does.
|
|
6
|
+
type Method = (...args: never[]) => unknown;
|
|
7
|
+
|
|
8
|
+
/** The context the recording patcher hands a method patch. */
|
|
9
|
+
export type MethodContext = {
|
|
10
|
+
args: never[];
|
|
11
|
+
this: PatchParent;
|
|
12
|
+
original: Method;
|
|
13
|
+
result: unknown;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// UNKNOWN: a method patch returns whatever the test's patch returns.
|
|
17
|
+
type MethodCallback = (context: MethodContext) => unknown;
|
|
18
|
+
|
|
19
|
+
/** Where a patch lands once its target is known: the object holding the method, the method, and the callback to run. */
|
|
20
|
+
export type Placement = { parent: PatchParent; method: string; callback: MethodCallback };
|
|
21
|
+
|
|
22
|
+
/** Patches what `place` finds on the target, waiting for a lazy handle's module as the client's `patchWhenLoaded` does. */
|
|
23
|
+
export type Install = (
|
|
24
|
+
kind: PatchKind,
|
|
25
|
+
target: PatchParent,
|
|
26
|
+
place: (module: PatchParent) => Placement,
|
|
27
|
+
/** The method's name, listed for the patch while it waits for its module. */
|
|
28
|
+
method?: string,
|
|
29
|
+
) => () => void;
|
|
30
|
+
|
|
31
|
+
type Render = { parent: PatchParent; method: string; isClass: boolean };
|
|
32
|
+
|
|
33
|
+
// UNKNOWN: the props, instance and tree of a component under test are whatever it renders with.
|
|
34
|
+
type ReactContext = {
|
|
35
|
+
props: unknown;
|
|
36
|
+
this: unknown;
|
|
37
|
+
result: unknown;
|
|
38
|
+
original: ReactRender;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// UNKNOWN: renders the untyped component with untyped props.
|
|
42
|
+
type ReactRender = (props?: unknown) => unknown;
|
|
43
|
+
|
|
44
|
+
// UNKNOWN: a React patch returns a tree or new props.
|
|
45
|
+
type ReactCallback = (context: ReactContext) => unknown;
|
|
46
|
+
|
|
47
|
+
const MEMO = Symbol.for('react.memo');
|
|
48
|
+
const FORWARD_REF = Symbol.for('react.forward_ref');
|
|
49
|
+
|
|
50
|
+
function isCallback(value: unknown): value is ReactCallback {
|
|
51
|
+
return typeof value === 'function';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// UNKNOWN: any export, function or not, may be a class component.
|
|
55
|
+
function isClass(component: unknown): boolean {
|
|
56
|
+
return typeof component === 'function' && Boolean(component.prototype?.isReactComponent);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Unwraps `memo` to what React calls, as the client's patcher does: the inner function, a `forwardRef`'s `render`, or a class's `prototype.render`.
|
|
60
|
+
function renderOf(component: AnyObject | undefined): Render | undefined {
|
|
61
|
+
if (isClass(component)) {
|
|
62
|
+
return { parent: component?.prototype, method: 'render', isClass: true };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (component?.$$typeof === FORWARD_REF) {
|
|
66
|
+
return { parent: component, method: 'render', isClass: false };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (component?.$$typeof !== MEMO) return;
|
|
70
|
+
|
|
71
|
+
return typeof component.type === 'function' && !isClass(component.type)
|
|
72
|
+
? { parent: component, method: 'type', isClass: false }
|
|
73
|
+
: renderOf(component.type);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function locate(target: AnyObject, key: string | undefined): Render {
|
|
77
|
+
const component = key === undefined ? target : target[key];
|
|
78
|
+
const render =
|
|
79
|
+
key !== undefined && typeof component === 'function' && !isClass(component)
|
|
80
|
+
? { parent: target, method: key, isClass: false }
|
|
81
|
+
: renderOf(component);
|
|
82
|
+
|
|
83
|
+
if (!render) {
|
|
84
|
+
throw new TypeError(
|
|
85
|
+
`Failed to patch component ${key ?? String(component)}: pass the object holding a function component and its key.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return render;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @description Builds `patcher.react` on a recording patcher, so React patches wrap the render for real and are listed with the others.
|
|
94
|
+
* @param install The recording patcher's install, which defers a patch on a lazy handle until its module registers.
|
|
95
|
+
* @returns The `before`, `instead` and `after` hooks for React components.
|
|
96
|
+
*/
|
|
97
|
+
export function createReactPatcher(install: Install): ReactPatcher {
|
|
98
|
+
function hook(kind: PatchKind) {
|
|
99
|
+
return (target: AnyObject, ...args: unknown[]) => {
|
|
100
|
+
const [key, callback] = isCallback(args[0])
|
|
101
|
+
? [undefined, args[0]]
|
|
102
|
+
: [String(args[0]), args[1]];
|
|
103
|
+
|
|
104
|
+
if (!isCallback(callback)) {
|
|
105
|
+
throw new TypeError('Failed to patch a component: pass a callback.');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return install(
|
|
109
|
+
kind,
|
|
110
|
+
target,
|
|
111
|
+
(module) => {
|
|
112
|
+
const render = locate(module, key);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
parent: render.parent,
|
|
116
|
+
method: render.method,
|
|
117
|
+
callback(context) {
|
|
118
|
+
const props = render.isClass
|
|
119
|
+
? Reflect.get(context.this, 'props')
|
|
120
|
+
: context.args[0];
|
|
121
|
+
const rest = context.args.slice(1);
|
|
122
|
+
const value = callback({
|
|
123
|
+
props,
|
|
124
|
+
this: render.isClass ? context.this : undefined,
|
|
125
|
+
result: context.result,
|
|
126
|
+
original: (next = props) =>
|
|
127
|
+
render.isClass
|
|
128
|
+
? context.original()
|
|
129
|
+
: context.original(...([next, ...rest] as never[])),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (kind !== 'before') {
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return !render.isClass && value !== undefined
|
|
137
|
+
? [value, ...rest]
|
|
138
|
+
: undefined;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
key,
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const patcher: ReactPatcher = {
|
|
148
|
+
before: hook('before'),
|
|
149
|
+
instead: hook('instead'),
|
|
150
|
+
after: hook('after'),
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return patcher;
|
|
154
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// UNKNOWN: React hands the test renderer whatever the component under test gives it: state, props, refs and children.
|
|
2
|
+
/** A value React passes through the test renderer's hooks and renders, typed by the component, not by the renderer. */
|
|
3
|
+
export type ReactValue = unknown;
|