@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.
- package/ambient/image-modules.d.ts +58 -0
- package/package.json +27 -4
- package/src/_internal.d.ts +2775 -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,260 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BuilderInput,
|
|
3
|
+
ChoiceInput,
|
|
4
|
+
ChoiceMap,
|
|
5
|
+
CommandContext,
|
|
6
|
+
OptionBuilder,
|
|
7
|
+
OptionSettings,
|
|
8
|
+
OptionSpec,
|
|
9
|
+
CommandTree,
|
|
10
|
+
OptionBuilders,
|
|
11
|
+
OptionKind,
|
|
12
|
+
OptionValue,
|
|
13
|
+
Reply,
|
|
14
|
+
} from '../src/commands';
|
|
15
|
+
import type { AddonHandle, AddonLogger, AddonManifest } from '../src/global';
|
|
16
|
+
|
|
17
|
+
import { createRecordingPatcher, type RecordingPatcher } from './recording-patcher';
|
|
18
|
+
import { createFakeMetro, type FakeMetro, type FakeModule } from './fake-metro';
|
|
19
|
+
import { createMemoryStorage, type MemoryStorage } from './memory-storage';
|
|
20
|
+
import { createFakeTimers, type FakeTimers } from './fake-timers';
|
|
21
|
+
import { createFakeReact, type FakeReact } from './fake-react';
|
|
22
|
+
|
|
23
|
+
type LogLevel = keyof AddonLogger;
|
|
24
|
+
|
|
25
|
+
/** A line the fake `logger` received. */
|
|
26
|
+
export type LogEntry = {
|
|
27
|
+
level: LogLevel;
|
|
28
|
+
args: unknown[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** A command's parsed options, by name, as its `execute` receives them. */
|
|
32
|
+
export type CommandArgs = Record<string, OptionValue<OptionKind> | undefined>;
|
|
33
|
+
|
|
34
|
+
/** The fake `commands`: register records each command tree by its root names. */
|
|
35
|
+
export type FakeCommands = {
|
|
36
|
+
/** The registered command trees, by root name. */
|
|
37
|
+
registered: Map<string, object>;
|
|
38
|
+
register: <T>(commands: CommandTree<T>) => () => void;
|
|
39
|
+
unregister: (name: string) => void;
|
|
40
|
+
unregisterAll: () => void;
|
|
41
|
+
/** The option builders; each returns its kind and settings. */
|
|
42
|
+
option: OptionBuilders;
|
|
43
|
+
/**
|
|
44
|
+
* Runs a registered command as Discord would, with parsed args.
|
|
45
|
+
* @param path The command's name, with its subcommands after spaces, such as `'star'` or `'tags add'`.
|
|
46
|
+
* @param args The parsed options, such as `{ user }`.
|
|
47
|
+
* @param context Where it runs; a guild text channel when omitted.
|
|
48
|
+
*/
|
|
49
|
+
run: (path: string, args?: CommandArgs, context?: Partial<CommandContext>) => Promise<Reply>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The fake `unbound`: the scoped namespaces the kit fakes, plus any a test adds. */
|
|
53
|
+
export type FakeUnbound = {
|
|
54
|
+
storage: MemoryStorage;
|
|
55
|
+
patcher: RecordingPatcher;
|
|
56
|
+
metro: FakeMetro;
|
|
57
|
+
commands: FakeCommands;
|
|
58
|
+
[namespace: string]: FakeModule;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type InstallOptions = {
|
|
62
|
+
/** The manifest the fake `addon` reads; `id` also names the default Bucket. */
|
|
63
|
+
manifest?: Partial<AddonManifest>;
|
|
64
|
+
/** Namespaces to add to or replace on `unbound`, such as `assets` or `fs`. */
|
|
65
|
+
unbound?: Partial<FakeUnbound>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** The installed fake Addon scope, and `uninstall` to restore the previous globals. */
|
|
69
|
+
export type InstalledAddonGlobals = {
|
|
70
|
+
React: FakeReact;
|
|
71
|
+
unbound: FakeUnbound;
|
|
72
|
+
addon: AddonHandle;
|
|
73
|
+
patcher: RecordingPatcher;
|
|
74
|
+
logger: AddonLogger;
|
|
75
|
+
commands: FakeCommands;
|
|
76
|
+
/** Every line `logger` received, in order. */
|
|
77
|
+
logs: LogEntry[];
|
|
78
|
+
storage: MemoryStorage;
|
|
79
|
+
metro: FakeMetro;
|
|
80
|
+
/** The scoped `setTimeout`, `setInterval` and their clears, installed as globals. */
|
|
81
|
+
timers: FakeTimers;
|
|
82
|
+
uninstall: () => void;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const LOG_LEVELS: LogLevel[] = ['log', 'info', 'success', 'warn', 'error', 'debug'];
|
|
86
|
+
|
|
87
|
+
function createLogger(logs: LogEntry[]): AddonLogger {
|
|
88
|
+
const entries = LOG_LEVELS.map((level) => [
|
|
89
|
+
level,
|
|
90
|
+
(...args: unknown[]) => void logs.push({ level, args }),
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
return Object.fromEntries(entries) as AddonLogger;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const DEFAULT_CONTEXT: CommandContext = {
|
|
97
|
+
channel: { id: '100000000000000001', type: 0, name: 'general', guild_id: '100000000000000002' },
|
|
98
|
+
guild: { id: '100000000000000002', name: 'Test Guild' },
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Builds the spec the client's builders return: the kind, `required`, `description` and the kind's settings.
|
|
102
|
+
function spec<K extends OptionKind>(kind: K): OptionBuilder<K> {
|
|
103
|
+
return <const R extends boolean = false>(input: BuilderInput<K, R> = {}) => ({
|
|
104
|
+
kind,
|
|
105
|
+
required: (input.required ?? false) as R,
|
|
106
|
+
description: input.description,
|
|
107
|
+
settings: input,
|
|
108
|
+
choices: undefined,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// `choice` maps each value to its label's key; its typed `M` exists only for inference.
|
|
113
|
+
function choice<const M extends ChoiceMap, const R extends boolean = false>(
|
|
114
|
+
choices: M,
|
|
115
|
+
settings: ChoiceInput<R> = {},
|
|
116
|
+
): OptionSpec<'string', R, keyof M & string> {
|
|
117
|
+
const none: OptionSettings<'string'> = {};
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
kind: 'string',
|
|
121
|
+
required: (settings.required ?? false) as R,
|
|
122
|
+
description: settings.description,
|
|
123
|
+
settings: none,
|
|
124
|
+
choices,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function createOptionBuilders(): OptionBuilders {
|
|
129
|
+
return {
|
|
130
|
+
string: spec('string'),
|
|
131
|
+
integer: spec('integer'),
|
|
132
|
+
number: spec('number'),
|
|
133
|
+
boolean: spec('boolean'),
|
|
134
|
+
user: spec('user'),
|
|
135
|
+
channel: spec('channel'),
|
|
136
|
+
role: spec('role'),
|
|
137
|
+
choice,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function findCommand(registered: FakeCommands['registered'], path: string) {
|
|
142
|
+
const [root = '', ...rest] = path.split(' ').filter(Boolean);
|
|
143
|
+
let node: unknown = registered.get(root);
|
|
144
|
+
|
|
145
|
+
for (const name of rest) {
|
|
146
|
+
const subcommands: unknown = node ? Reflect.get(node, 'subcommands') : void 0;
|
|
147
|
+
|
|
148
|
+
node = subcommands ? Reflect.get(subcommands, name) : void 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const execute: unknown = node ? Reflect.get(node, 'execute') : void 0;
|
|
152
|
+
|
|
153
|
+
if (typeof execute !== 'function') {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Failed to run command /${path}: it is not registered. Registered: ${[...registered.keys()].join(', ') || 'none'}.`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { node, execute };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function createCommands(): FakeCommands {
|
|
163
|
+
const registered: FakeCommands['registered'] = new Map();
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
registered,
|
|
167
|
+
option: createOptionBuilders(),
|
|
168
|
+
async run(path, args = {}, context = {}) {
|
|
169
|
+
const { node, execute } = findCommand(registered, path);
|
|
170
|
+
|
|
171
|
+
return execute.call(node, args, { ...DEFAULT_CONTEXT, ...context });
|
|
172
|
+
},
|
|
173
|
+
register(commands) {
|
|
174
|
+
const roots = Object.keys(commands);
|
|
175
|
+
|
|
176
|
+
for (const root of roots) registered.set(root, Reflect.get(commands, root));
|
|
177
|
+
|
|
178
|
+
return () => {
|
|
179
|
+
for (const root of roots) registered.delete(root);
|
|
180
|
+
};
|
|
181
|
+
},
|
|
182
|
+
unregister: (name) => void registered.delete(name),
|
|
183
|
+
unregisterAll: () => registered.clear(),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function createAddon(manifest: AddonManifest): AddonHandle {
|
|
188
|
+
let enabled = true;
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
id: manifest.id,
|
|
192
|
+
name: manifest.name,
|
|
193
|
+
version: manifest.version,
|
|
194
|
+
authors: manifest.authors,
|
|
195
|
+
manifest,
|
|
196
|
+
started: true,
|
|
197
|
+
failed: false,
|
|
198
|
+
isEnabled: () => enabled,
|
|
199
|
+
isPlugin: () => true,
|
|
200
|
+
enable: () => void (enabled = true),
|
|
201
|
+
disable: () => void (enabled = false),
|
|
202
|
+
reload: async () => {},
|
|
203
|
+
toJSON: () => manifest,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* @description Installs a fake Addon scope on `globalThis`, so plugin code under test reads `addon`, `patcher`,
|
|
209
|
+
* `logger`, `commands`, `unbound`, `React` and the scoped timers as it would on device. Pass `unbound` to `mock.module('@unbound-app/api')`.
|
|
210
|
+
* @param options The fake addon's manifest and extra `unbound` namespaces.
|
|
211
|
+
* @returns The globals, the recorded logs, and `uninstall` to restore what was there before.
|
|
212
|
+
*/
|
|
213
|
+
export function installAddonGlobals(options: InstallOptions = {}): InstalledAddonGlobals {
|
|
214
|
+
const manifest: AddonManifest = {
|
|
215
|
+
id: 'test-addon',
|
|
216
|
+
name: 'Test Addon',
|
|
217
|
+
description: '',
|
|
218
|
+
authors: [],
|
|
219
|
+
main: 'index.js',
|
|
220
|
+
version: '1.0.0',
|
|
221
|
+
...options.manifest,
|
|
222
|
+
};
|
|
223
|
+
const logs: LogEntry[] = [];
|
|
224
|
+
const storage = createMemoryStorage(manifest.id);
|
|
225
|
+
const metro = createFakeMetro();
|
|
226
|
+
const patcher = createRecordingPatcher(metro.whenLoaded);
|
|
227
|
+
const commands = createCommands();
|
|
228
|
+
const timers = createFakeTimers();
|
|
229
|
+
const unbound: FakeUnbound = { storage, patcher, metro, commands, ...options.unbound };
|
|
230
|
+
const globals = {
|
|
231
|
+
React: createFakeReact(),
|
|
232
|
+
unbound,
|
|
233
|
+
addon: createAddon(manifest),
|
|
234
|
+
patcher,
|
|
235
|
+
logger: createLogger(logs),
|
|
236
|
+
commands,
|
|
237
|
+
setTimeout: timers.setTimeout,
|
|
238
|
+
setInterval: timers.setInterval,
|
|
239
|
+
clearTimeout: timers.clearTimeout,
|
|
240
|
+
clearInterval: timers.clearInterval,
|
|
241
|
+
};
|
|
242
|
+
const previous = new Map<string, PropertyDescriptor | undefined>();
|
|
243
|
+
|
|
244
|
+
for (const [name, value] of Object.entries(globals)) {
|
|
245
|
+
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
|
246
|
+
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function uninstall() {
|
|
250
|
+
for (const [name, descriptor] of previous) {
|
|
251
|
+
if (descriptor) {
|
|
252
|
+
Object.defineProperty(globalThis, name, descriptor);
|
|
253
|
+
} else {
|
|
254
|
+
Reflect.deleteProperty(globalThis, name);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return { ...globals, logs, storage, metro, timers, uninstall };
|
|
260
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** The listeners a plugin added through the fake API and has not removed, for `stopPlugin`'s leak check. */
|
|
2
|
+
export type ListenerRegistry = {
|
|
3
|
+
/** Records a listener; the returned function removes it, calling `remove` once. */
|
|
4
|
+
track: (label: string, remove: () => void) => () => void;
|
|
5
|
+
/** The label of each listener still added, in the order they were added. */
|
|
6
|
+
active: () => string[];
|
|
7
|
+
/** Removes every listener still added. */
|
|
8
|
+
clear: () => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type Listener = { label: string; remove: () => void };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @description Creates a registry of the listeners a plugin holds.
|
|
15
|
+
* @returns The registry.
|
|
16
|
+
*/
|
|
17
|
+
export function createListenerRegistry(): ListenerRegistry {
|
|
18
|
+
const listeners = new Map<symbol, Listener>();
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
track(label, remove) {
|
|
22
|
+
const id = Symbol(label);
|
|
23
|
+
|
|
24
|
+
listeners.set(id, { label, remove });
|
|
25
|
+
|
|
26
|
+
return () => {
|
|
27
|
+
if (!listeners.delete(id)) return;
|
|
28
|
+
|
|
29
|
+
remove();
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
active: () => [...listeners.values()].map(({ label }) => label),
|
|
33
|
+
clear() {
|
|
34
|
+
for (const { remove } of listeners.values()) remove();
|
|
35
|
+
|
|
36
|
+
listeners.clear();
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { Bucket, SettingsPayload, SettingsPredicate } from '../src/storage';
|
|
2
|
+
import type { SettingsObject, SettingsValue } from '../src/global';
|
|
3
|
+
import type { Widen } from '../src/_types';
|
|
4
|
+
|
|
5
|
+
/** In-memory Buckets: `storage.getBucket(name)` without a device, and the settings each holds. */
|
|
6
|
+
export type MemoryStorage = {
|
|
7
|
+
/** Every Bucket's settings, by Bucket name. A Bucket left empty has no entry, as on disk. */
|
|
8
|
+
data: Map<string, SettingsObject>;
|
|
9
|
+
/** Returns a Bucket by name, or the default Bucket when `name` is omitted. */
|
|
10
|
+
getBucket: (name?: string) => Bucket;
|
|
11
|
+
/** Returns a Bucket by name. Without a renderer there is nothing to re-render. */
|
|
12
|
+
useBucket: (name: string, predicate?: SettingsPredicate) => Bucket;
|
|
13
|
+
/** Calls `callback` for each change the predicate accepts, until the returned function runs. */
|
|
14
|
+
onChange: (
|
|
15
|
+
predicate: SettingsPredicate,
|
|
16
|
+
callback: (payload: SettingsPayload) => void,
|
|
17
|
+
) => () => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function isSettingsObject(value: SettingsValue | undefined): value is SettingsObject {
|
|
21
|
+
return typeof value === 'object' && value !== null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @description Creates in-memory Buckets that keep settings for the life of a test, with the client storage's semantics.
|
|
26
|
+
* @param defaultName The Bucket `getBucket()` returns without a name, such as the plugin's id.
|
|
27
|
+
* @returns The storage, with each Bucket's settings readable from `data`.
|
|
28
|
+
*/
|
|
29
|
+
export function createMemoryStorage(defaultName: string = 'test-addon'): MemoryStorage {
|
|
30
|
+
const data = new Map<string, SettingsObject>();
|
|
31
|
+
const listeners = new Set<(payload: SettingsPayload) => void>();
|
|
32
|
+
|
|
33
|
+
function emit(payload: SettingsPayload) {
|
|
34
|
+
for (const listener of listeners) listener(payload);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function get<T>(store: string, key: string, def: T): Widen<T> {
|
|
38
|
+
let result: SettingsValue | undefined = data.get(store);
|
|
39
|
+
|
|
40
|
+
for (const segment of key.split('.')) {
|
|
41
|
+
const next: SettingsValue | undefined = isSettingsObject(result)
|
|
42
|
+
? result[segment]
|
|
43
|
+
: void 0;
|
|
44
|
+
|
|
45
|
+
if (next === void 0) {
|
|
46
|
+
return def as Widen<T>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
result = next;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return result as Widen<T>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function set(store: string, key: string, value: SettingsValue) {
|
|
56
|
+
const keys = key.split('.');
|
|
57
|
+
const last = keys.pop() ?? key;
|
|
58
|
+
let current = data.get(store);
|
|
59
|
+
|
|
60
|
+
if (!current) {
|
|
61
|
+
current = {};
|
|
62
|
+
data.set(store, current);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const segment of keys) {
|
|
66
|
+
const next: SettingsValue = (current[segment] ??= {});
|
|
67
|
+
|
|
68
|
+
if (!isSettingsObject(next)) {
|
|
69
|
+
throw new TypeError(`Cannot set ${store}.${key}: ${segment} is not an object.`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
current = next;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
current[last] = value;
|
|
76
|
+
emit({ store, key, value });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function remove(store: string, key: string) {
|
|
80
|
+
const keys = key.split('.');
|
|
81
|
+
const last = keys.pop() ?? key;
|
|
82
|
+
const parents: SettingsObject[] = [];
|
|
83
|
+
let current: SettingsValue | undefined = data.get(store);
|
|
84
|
+
|
|
85
|
+
for (const segment of keys) {
|
|
86
|
+
if (!isSettingsObject(current)) return;
|
|
87
|
+
|
|
88
|
+
parents.push(current);
|
|
89
|
+
current = current[segment];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!isSettingsObject(current) || current[last] === undefined) return;
|
|
93
|
+
|
|
94
|
+
delete current[last];
|
|
95
|
+
|
|
96
|
+
for (let index = keys.length - 1; index >= 0; index--) {
|
|
97
|
+
const parent = parents[index];
|
|
98
|
+
const segment = keys[index];
|
|
99
|
+
|
|
100
|
+
if (!parent || segment === undefined) {
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const child = parent[segment];
|
|
105
|
+
|
|
106
|
+
if (!isSettingsObject(child) || Object.keys(child).length > 0) {
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
delete parent[segment];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (Object.keys(data.get(store) ?? {}).length === 0) {
|
|
114
|
+
data.delete(store);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
emit({ store, key, value: undefined });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function clear(store: string) {
|
|
121
|
+
if (!data.delete(store)) return;
|
|
122
|
+
|
|
123
|
+
emit({ store, key: null, value: undefined });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function getBucket(name: string = defaultName): Bucket {
|
|
127
|
+
return {
|
|
128
|
+
get: (key, def) => get(name, key, def),
|
|
129
|
+
set: (key, value) => set(name, key, value),
|
|
130
|
+
toggle: (key, def) => set(name, key, !get(name, key, def)),
|
|
131
|
+
remove: (key) => remove(name, key),
|
|
132
|
+
clear: () => clear(name),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function onChange(predicate: SettingsPredicate, callback: (payload: SettingsPayload) => void) {
|
|
137
|
+
function listener(payload: SettingsPayload) {
|
|
138
|
+
if (predicate(payload)) {
|
|
139
|
+
callback(payload);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
listeners.add(listener);
|
|
144
|
+
|
|
145
|
+
return () => void listeners.delete(listener);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { data, getBucket, useBucket: (name) => getBucket(name), onChange };
|
|
149
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
import type { AddonAuthor, AddonManifest } from '../src/global';
|
|
5
|
+
import type { LocaleTable } from './fake-locales';
|
|
6
|
+
import type { AnyObject } from '../src/patcher';
|
|
7
|
+
|
|
8
|
+
const ENTRY_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js'];
|
|
9
|
+
|
|
10
|
+
function isRecord(value: unknown): value is AnyObject {
|
|
11
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// UNKNOWN: a JSON file holds anything; each caller narrows it with a guard.
|
|
15
|
+
function readJson(path: string): unknown {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
18
|
+
} catch (error) {
|
|
19
|
+
throw new Error(`Failed to read ${path}: ${(error as Error).message}`, { cause: error });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function nearestManifest(from: string): string | undefined {
|
|
24
|
+
for (let dir = resolve(from); ; dir = dirname(dir)) {
|
|
25
|
+
if (existsSync(join(dir, 'manifest.json'))) {
|
|
26
|
+
return dir;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (dirname(dir) === dir) return;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @description Finds the plugin under test: `UNBOUND_PLUGIN_ROOT`, else the folder holding the nearest
|
|
35
|
+
* `manifest.json` above the first test file, else above the working directory.
|
|
36
|
+
* @param testFile The first test file bun runs, `Bun.main`.
|
|
37
|
+
* @returns The plugin's directory; throws when there is no manifest above either.
|
|
38
|
+
*/
|
|
39
|
+
export function findPluginRoot(testFile: string = Bun.main): string {
|
|
40
|
+
const configured = process.env.UNBOUND_PLUGIN_ROOT;
|
|
41
|
+
|
|
42
|
+
if (configured) {
|
|
43
|
+
return resolve(configured);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const found = nearestManifest(dirname(testFile)) ?? nearestManifest(process.cwd());
|
|
47
|
+
|
|
48
|
+
if (!found) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Failed to find the plugin under test: no manifest.json above ${testFile} or ${process.cwd()}.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return found;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @description Reads the fields of a plugin's manifest.json that the fake `addon` serves.
|
|
59
|
+
* @param root The plugin's directory.
|
|
60
|
+
* @returns The manifest's string fields and authors; throws when it is not a JSON object.
|
|
61
|
+
*/
|
|
62
|
+
export function readManifest(root: string): Partial<AddonManifest> {
|
|
63
|
+
const path = join(root, 'manifest.json');
|
|
64
|
+
const value = readJson(path);
|
|
65
|
+
|
|
66
|
+
if (!isRecord(value)) {
|
|
67
|
+
throw new Error(`Failed to read ${path}: it is not a JSON object.`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const manifest: Partial<AddonManifest> = {};
|
|
71
|
+
|
|
72
|
+
for (const key of ['id', 'name', 'description', 'version', 'main'] as const) {
|
|
73
|
+
const field = value[key];
|
|
74
|
+
|
|
75
|
+
if (typeof field === 'string') {
|
|
76
|
+
manifest[key] = field;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (Array.isArray(value.authors)) {
|
|
81
|
+
manifest.authors = value.authors.filter(
|
|
82
|
+
(author): author is AddonAuthor =>
|
|
83
|
+
isRecord(author) &&
|
|
84
|
+
typeof author.name === 'string' &&
|
|
85
|
+
typeof author.id === 'string' &&
|
|
86
|
+
author.id !== '' &&
|
|
87
|
+
Number.isInteger(Number(author.id)),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return manifest;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @description Reads every `i18n/<code>.json` of a plugin, each a JSON object of message keys to strings.
|
|
96
|
+
* @param root The plugin's directory.
|
|
97
|
+
* @returns Each table by locale code; empty when the plugin has no `i18n/`. Throws naming an invalid file.
|
|
98
|
+
*/
|
|
99
|
+
export function readLocaleTables(root: string): Map<string, LocaleTable> {
|
|
100
|
+
const directory = join(root, 'i18n');
|
|
101
|
+
const tables = new Map<string, LocaleTable>();
|
|
102
|
+
|
|
103
|
+
if (!existsSync(directory)) {
|
|
104
|
+
return tables;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const file of readdirSync(directory).filter((name) => name.endsWith('.json'))) {
|
|
108
|
+
const path = join(directory, file);
|
|
109
|
+
const value = readJson(path);
|
|
110
|
+
|
|
111
|
+
if (!isRecord(value)) {
|
|
112
|
+
throw new Error(`Failed to read ${path}: it is not a JSON object of strings.`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const table: LocaleTable = {};
|
|
116
|
+
|
|
117
|
+
for (const [key, message] of Object.entries(value)) {
|
|
118
|
+
if (typeof message !== 'string') {
|
|
119
|
+
throw new Error(`Failed to read ${path}: ${key} is not a string.`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
table[key] = message;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
tables.set(basename(file, '.json'), table);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return tables;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @description Finds a plugin's entry as the build does: `src/index` with a TypeScript or JavaScript extension.
|
|
133
|
+
* @param root The plugin's directory.
|
|
134
|
+
* @returns The entry's path, or `undefined` when there is none.
|
|
135
|
+
*/
|
|
136
|
+
export function findEntry(root: string): string | undefined {
|
|
137
|
+
return ENTRY_EXTENSIONS.map((extension) => join(root, 'src', `index${extension}`)).find(
|
|
138
|
+
(path) => existsSync(path),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @description Names the asset an image file is, as the build does: its path without extension or `@2x`/`@3x`,
|
|
144
|
+
* relative to `assets/` inside it and to the plugin root outside it.
|
|
145
|
+
* @param root The plugin's directory.
|
|
146
|
+
* @param path The image's absolute path.
|
|
147
|
+
* @returns The asset name, such as `icons/star`.
|
|
148
|
+
*/
|
|
149
|
+
export function assetNameOf(root: string, path: string): string {
|
|
150
|
+
const assets = join(root, 'assets');
|
|
151
|
+
const base = path.startsWith(assets + sep) ? assets : root;
|
|
152
|
+
const name = relative(base, path).slice(0, -extname(path).length).split(sep).join('/');
|
|
153
|
+
|
|
154
|
+
return name.endsWith('@2x') || name.endsWith('@3x') ? name.slice(0, -'@2x'.length) : name;
|
|
155
|
+
}
|