@hot-updater/test-utils 1.0.0-rc.16 → 1.0.0-rc.21
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/README.md +104 -17
- package/dist/index.d.mts +236 -158
- package/dist/index.mjs +3051 -1704
- package/dist/node.cjs +210 -0
- package/dist/node.d.cts +49 -4
- package/dist/node.d.mts +49 -4
- package/dist/node.mjs +203 -1
- package/dist/react-native.cjs +177 -0
- package/dist/react-native.d.cts +104 -0
- package/dist/react-native.d.mts +104 -0
- package/dist/react-native.mjs +174 -0
- package/package.json +44 -6
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _hot_updater_protocol = require("@hot-updater/protocol");
|
|
3
|
+
//#region src/setupClientPlugins.ts
|
|
4
|
+
/**
|
|
5
|
+
* Runs client plugins in tests the way the SDK runs them in an app, against
|
|
6
|
+
* an in-memory device and a server the test answers.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { setupClientPlugin } from "@hot-updater/test-utils/react-native";
|
|
10
|
+
*
|
|
11
|
+
* const runtime = setupClientPlugin(myPlugin());
|
|
12
|
+
* runtime.hooks.onAppReady(launch);
|
|
13
|
+
* await runtime.settled();
|
|
14
|
+
* expect(runtime.requests[0]?.json()).toEqual(expectedEvent);
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* It sets plugins up with the plugin host from `@hot-updater/protocol`,
|
|
18
|
+
* the same code the app runs, so they get the same context and rules:
|
|
19
|
+
* unique ids, `setup` once per runtime, storage scoped to the plugin's id
|
|
20
|
+
* with values up to 64 KB, `fetch` relative to `baseURL` with
|
|
21
|
+
* `requestHeaders` and `requestTimeout`, and hooks the SDK never waits for,
|
|
22
|
+
* whose throws and rejections are reported, not raised. It imports neither
|
|
23
|
+
* React Native nor a test runner, so it works under Vitest and Jest.
|
|
24
|
+
*/
|
|
25
|
+
const storageItems = /* @__PURE__ */ new WeakMap();
|
|
26
|
+
/** A new device's empty plugin storage. */
|
|
27
|
+
const createTestStorage = () => {
|
|
28
|
+
const items = /* @__PURE__ */ new Map();
|
|
29
|
+
const storage = {
|
|
30
|
+
get: (pluginId, key) => items.get((0, _hot_updater_protocol.pluginStorageKey)(pluginId, key)) ?? null,
|
|
31
|
+
set: (pluginId, key, value) => {
|
|
32
|
+
if (value !== null && typeof value !== "string") throw new TypeError("[HotUpdater] Plugin storage values are strings or null.");
|
|
33
|
+
const storageKey = (0, _hot_updater_protocol.pluginStorageKey)(pluginId, key);
|
|
34
|
+
if (value === null) items.delete(storageKey);
|
|
35
|
+
else items.set(storageKey, value);
|
|
36
|
+
},
|
|
37
|
+
entries: (pluginId) => {
|
|
38
|
+
const prefix = (0, _hot_updater_protocol.pluginStorageKey)(pluginId, "");
|
|
39
|
+
return Object.fromEntries([...items].filter(([storageKey]) => storageKey.startsWith(prefix)).map(([storageKey, value]) => [storageKey.slice(prefix.length), value]));
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
storageItems.set(storage, items);
|
|
43
|
+
return storage;
|
|
44
|
+
};
|
|
45
|
+
const read = (value) => typeof value === "function" ? value() : value;
|
|
46
|
+
const abortError = (signal) => {
|
|
47
|
+
const reason = signal?.reason;
|
|
48
|
+
if (reason instanceof Error && reason.name === "AbortError") return reason;
|
|
49
|
+
const error = /* @__PURE__ */ new Error("The operation was aborted.");
|
|
50
|
+
error.name = "AbortError";
|
|
51
|
+
return error;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Resolves after every pending microtask, on a macrotask that fake timers
|
|
55
|
+
* leave alone.
|
|
56
|
+
*/
|
|
57
|
+
const nextMacrotask = () => new Promise((resolve) => {
|
|
58
|
+
const Channel = globalThis.MessageChannel;
|
|
59
|
+
if (Channel === void 0) {
|
|
60
|
+
setTimeout(resolve, 0);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const { port1, port2 } = new Channel();
|
|
64
|
+
port1.addEventListener("message", () => {
|
|
65
|
+
port1.close();
|
|
66
|
+
resolve();
|
|
67
|
+
}, { once: true });
|
|
68
|
+
port1.start();
|
|
69
|
+
port2.postMessage(null);
|
|
70
|
+
});
|
|
71
|
+
/** Sets plugins up as `HotUpdater.init({ plugins })` does, in a new runtime. */
|
|
72
|
+
const setupClientPlugins = (plugins, options = {}) => {
|
|
73
|
+
const storage = options.storage ?? createTestStorage();
|
|
74
|
+
const items = storageItems.get(storage);
|
|
75
|
+
if (items === void 0) throw new TypeError("[HotUpdater] Pass a storage from createTestStorage() or an earlier runtime.");
|
|
76
|
+
const baseURL = options.baseURL ?? "https://hot-updater.example.com";
|
|
77
|
+
const respond = options.respond ?? (() => new Response(null, { status: 204 }));
|
|
78
|
+
const requests = [];
|
|
79
|
+
const errors = [];
|
|
80
|
+
const pending = /* @__PURE__ */ new Set();
|
|
81
|
+
const track = (work) => {
|
|
82
|
+
const settledWork = work.then(() => void 0, () => void 0).finally(() => {
|
|
83
|
+
pending.delete(settledWork);
|
|
84
|
+
});
|
|
85
|
+
pending.add(settledWork);
|
|
86
|
+
};
|
|
87
|
+
const recordRequest = async (url, init) => {
|
|
88
|
+
const request = new Request(url, init);
|
|
89
|
+
const body = typeof init.body === "string" ? init.body : init.body === void 0 || init.body === null ? null : await request.text();
|
|
90
|
+
const base = await (0, _hot_updater_protocol.resolveBaseURL)(baseURL);
|
|
91
|
+
const headers = {};
|
|
92
|
+
request.headers.forEach((value, name) => {
|
|
93
|
+
headers[name] = value;
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
url,
|
|
97
|
+
path: url.startsWith(`${base}/`) ? url.slice(base.length) : url,
|
|
98
|
+
method: request.method,
|
|
99
|
+
headers,
|
|
100
|
+
body,
|
|
101
|
+
json: () => {
|
|
102
|
+
if (body === null) throw new SyntaxError("The request has no body to parse as JSON.");
|
|
103
|
+
return JSON.parse(body);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
const answer = (request, signal) => new Promise((resolve, reject) => {
|
|
108
|
+
const abort = () => reject(abortError(signal));
|
|
109
|
+
if (signal?.aborted) {
|
|
110
|
+
abort();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
114
|
+
Promise.resolve().then(() => respond(request)).then(resolve, reject).finally(() => signal?.removeEventListener("abort", abort));
|
|
115
|
+
});
|
|
116
|
+
const host = (0, _hot_updater_protocol.createPluginHost)({
|
|
117
|
+
fetch: (url, init) => {
|
|
118
|
+
const response = recordRequest(url, init).then((request) => {
|
|
119
|
+
requests.push(request);
|
|
120
|
+
return answer(request, init.signal);
|
|
121
|
+
});
|
|
122
|
+
track(response);
|
|
123
|
+
return response;
|
|
124
|
+
},
|
|
125
|
+
platform: options.platform ?? "ios",
|
|
126
|
+
isDebugBuild: options.isDebugBuild ?? false,
|
|
127
|
+
sdkVersion: options.sdkVersion ?? "0.0.0-test",
|
|
128
|
+
getInstallId: () => read(options.installId ?? "00000000-0000-4000-8000-000000000000"),
|
|
129
|
+
getAppVersion: () => read(options.appVersion === void 0 ? "1.0.0" : options.appVersion),
|
|
130
|
+
getBundleId: () => read(options.bundleId ?? "00000000-0000-0000-0000-000000000000"),
|
|
131
|
+
getChannel: () => read(options.channel ?? "production"),
|
|
132
|
+
getCohort: () => read(options.cohort ?? "1"),
|
|
133
|
+
getFingerprintHash: () => read(options.fingerprintHash === void 0 ? null : options.fingerprintHash),
|
|
134
|
+
getStorageItem: (key) => items.get(key) ?? null,
|
|
135
|
+
setStorageItem: (key, value) => {
|
|
136
|
+
if (value === null) items.delete(key);
|
|
137
|
+
else items.set(key, value);
|
|
138
|
+
},
|
|
139
|
+
now: () => options.now ? options.now() : Date.now()
|
|
140
|
+
});
|
|
141
|
+
host.configurePlugins(plugins, {
|
|
142
|
+
baseURL,
|
|
143
|
+
requestHeaders: options.requestHeaders,
|
|
144
|
+
requestTimeout: options.requestTimeout,
|
|
145
|
+
onError: (error) => {
|
|
146
|
+
errors.push(error);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
const call = (name) => (payload) => {
|
|
150
|
+
for (const settlement of host.dispatchPluginHook(name, payload)) track(settlement);
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
hooks: {
|
|
154
|
+
onAppReady: call("onAppReady"),
|
|
155
|
+
onUpdateCheck: call("onUpdateCheck"),
|
|
156
|
+
onBundleDownloaded: call("onBundleDownloaded"),
|
|
157
|
+
onUpdateError: call("onUpdateError")
|
|
158
|
+
},
|
|
159
|
+
listens: (name) => host.hasPluginHook(name),
|
|
160
|
+
requests,
|
|
161
|
+
storage,
|
|
162
|
+
errors,
|
|
163
|
+
settled: async () => {
|
|
164
|
+
for (;;) {
|
|
165
|
+
await nextMacrotask();
|
|
166
|
+
if (pending.size === 0) return;
|
|
167
|
+
await Promise.all(pending);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
/** Sets one plugin up; see `setupClientPlugins`. */
|
|
173
|
+
const setupClientPlugin = (plugin, options) => setupClientPlugins([plugin], options);
|
|
174
|
+
//#endregion
|
|
175
|
+
exports.createTestStorage = createTestStorage;
|
|
176
|
+
exports.setupClientPlugin = setupClientPlugin;
|
|
177
|
+
exports.setupClientPlugins = setupClientPlugins;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { AppReadyResult, BundleDownloadedInfo, HotUpdaterBaseURL, HotUpdaterClientPlugin, UpdateCheckResult, UpdateError } from "@hot-updater/protocol";
|
|
2
|
+
//#region src/setupClientPlugins.d.ts
|
|
3
|
+
/** A value the test fixes, or reads each time a plugin asks for it. */
|
|
4
|
+
type TestValue<T> = T | (() => T);
|
|
5
|
+
interface ClientPluginTestOptions {
|
|
6
|
+
/** The server plugins fetch from. Default: `https://hot-updater.example.com`. */
|
|
7
|
+
readonly baseURL?: HotUpdaterBaseURL;
|
|
8
|
+
/** Headers the SDK adds to every plugin request, as `init` and `wrap` take them. */
|
|
9
|
+
readonly requestHeaders?: Record<string, string>;
|
|
10
|
+
/** Milliseconds before a plugin request fails with `Request timed out`. Default: 5000. */
|
|
11
|
+
readonly requestTimeout?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Answers each plugin request. Default: `204 No Content`. Throw or reject
|
|
14
|
+
* to fail the request the way a network error does.
|
|
15
|
+
*/
|
|
16
|
+
readonly respond?: (request: ClientPluginTestRequest) => Response | Promise<Response>;
|
|
17
|
+
/** Default: `00000000-0000-4000-8000-000000000000`. */
|
|
18
|
+
readonly installId?: TestValue<string>;
|
|
19
|
+
/** Default: `ios`. */
|
|
20
|
+
readonly platform?: "ios" | "android";
|
|
21
|
+
/** The native app version. Default: `1.0.0`. */
|
|
22
|
+
readonly appVersion?: TestValue<string | null>;
|
|
23
|
+
/** Default: `0.0.0-test`. */
|
|
24
|
+
readonly sdkVersion?: string;
|
|
25
|
+
/** Default: false. */
|
|
26
|
+
readonly isDebugBuild?: boolean;
|
|
27
|
+
/** The running bundle. Default: `00000000-0000-0000-0000-000000000000`. */
|
|
28
|
+
readonly bundleId?: TestValue<string>;
|
|
29
|
+
/** Default: `production`. */
|
|
30
|
+
readonly channel?: TestValue<string>;
|
|
31
|
+
/** Default: `1`. */
|
|
32
|
+
readonly cohort?: TestValue<string>;
|
|
33
|
+
/** Default: null. */
|
|
34
|
+
readonly fingerprintHash?: TestValue<string | null>;
|
|
35
|
+
/** The clock plugins read. Default: `Date.now()`, so fake timers move it. */
|
|
36
|
+
readonly now?: () => number;
|
|
37
|
+
/**
|
|
38
|
+
* The device's plugin storage. Pass an earlier runtime's `storage` to set
|
|
39
|
+
* the plugins up again on the same device, as a relaunch does.
|
|
40
|
+
*/
|
|
41
|
+
readonly storage?: ClientPluginTestStorage;
|
|
42
|
+
}
|
|
43
|
+
/** A request a plugin made with `context.fetch`, as the server receives it. */
|
|
44
|
+
interface ClientPluginTestRequest {
|
|
45
|
+
readonly url: string;
|
|
46
|
+
/** The URL after `baseURL`, such as `/events`. */
|
|
47
|
+
readonly path: string;
|
|
48
|
+
readonly method: string;
|
|
49
|
+
/** Lowercase header names, `requestHeaders` included. */
|
|
50
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
51
|
+
/** The body as text, or null without one. */
|
|
52
|
+
readonly body: string | null;
|
|
53
|
+
/** The body parsed as JSON. */
|
|
54
|
+
json<T = unknown>(): T;
|
|
55
|
+
}
|
|
56
|
+
/** A device's plugin storage, which outlives a runtime as native storage does. */
|
|
57
|
+
interface ClientPluginTestStorage {
|
|
58
|
+
/** What a plugin stored under `key`, or null. */
|
|
59
|
+
get(pluginId: string, key: string): string | null;
|
|
60
|
+
/** Stores a value for a plugin, as an earlier launch would have; `null` removes it. */
|
|
61
|
+
set(pluginId: string, key: string, value: string | null): void;
|
|
62
|
+
/** Every key and value a plugin stored. */
|
|
63
|
+
entries(pluginId: string): Record<string, string>;
|
|
64
|
+
}
|
|
65
|
+
/** The hooks the SDK calls, called the same way. */
|
|
66
|
+
interface ClientPluginTestHooks {
|
|
67
|
+
onAppReady(result: AppReadyResult): void;
|
|
68
|
+
onUpdateCheck(result: UpdateCheckResult): void;
|
|
69
|
+
onBundleDownloaded(info: BundleDownloadedInfo): void;
|
|
70
|
+
onUpdateError(error: UpdateError): void;
|
|
71
|
+
}
|
|
72
|
+
/** One JavaScript runtime with the plugins set up, as after `HotUpdater.init`. */
|
|
73
|
+
interface ClientPluginTestRuntime {
|
|
74
|
+
/**
|
|
75
|
+
* Calls a hook on every plugin that returned it, as the SDK does: without
|
|
76
|
+
* waiting for it, and with a throw or rejection recorded in `errors`
|
|
77
|
+
* instead of raised. Await `settled()` for what the hooks started.
|
|
78
|
+
*/
|
|
79
|
+
readonly hooks: ClientPluginTestHooks;
|
|
80
|
+
/** Whether any plugin returned this hook from `setup`. */
|
|
81
|
+
listens(name: keyof ClientPluginTestHooks): boolean;
|
|
82
|
+
/** The requests plugins made, in order. */
|
|
83
|
+
readonly requests: readonly ClientPluginTestRequest[];
|
|
84
|
+
readonly storage: ClientPluginTestStorage;
|
|
85
|
+
/**
|
|
86
|
+
* What the SDK reported to `onError`: each `setup` or hook that threw or
|
|
87
|
+
* rejected, with the plugin's error as `cause`.
|
|
88
|
+
*/
|
|
89
|
+
readonly errors: readonly Error[];
|
|
90
|
+
/**
|
|
91
|
+
* Resolves once the hooks called so far, and the requests they started,
|
|
92
|
+
* have settled. It does not run timers: with fake timers, advance them to
|
|
93
|
+
* reach a retry or a delay.
|
|
94
|
+
*/
|
|
95
|
+
settled(): Promise<void>;
|
|
96
|
+
}
|
|
97
|
+
/** A new device's empty plugin storage. */
|
|
98
|
+
declare const createTestStorage: () => ClientPluginTestStorage;
|
|
99
|
+
/** Sets plugins up as `HotUpdater.init({ plugins })` does, in a new runtime. */
|
|
100
|
+
declare const setupClientPlugins: (plugins: readonly HotUpdaterClientPlugin[], options?: ClientPluginTestOptions) => ClientPluginTestRuntime;
|
|
101
|
+
/** Sets one plugin up; see `setupClientPlugins`. */
|
|
102
|
+
declare const setupClientPlugin: (plugin: HotUpdaterClientPlugin, options?: ClientPluginTestOptions) => ClientPluginTestRuntime;
|
|
103
|
+
//#endregion
|
|
104
|
+
export { ClientPluginTestHooks, ClientPluginTestOptions, ClientPluginTestRequest, ClientPluginTestRuntime, ClientPluginTestStorage, createTestStorage, setupClientPlugin, setupClientPlugins };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { AppReadyResult, BundleDownloadedInfo, HotUpdaterBaseURL, HotUpdaterClientPlugin, UpdateCheckResult, UpdateError } from "@hot-updater/protocol";
|
|
2
|
+
//#region src/setupClientPlugins.d.ts
|
|
3
|
+
/** A value the test fixes, or reads each time a plugin asks for it. */
|
|
4
|
+
type TestValue<T> = T | (() => T);
|
|
5
|
+
interface ClientPluginTestOptions {
|
|
6
|
+
/** The server plugins fetch from. Default: `https://hot-updater.example.com`. */
|
|
7
|
+
readonly baseURL?: HotUpdaterBaseURL;
|
|
8
|
+
/** Headers the SDK adds to every plugin request, as `init` and `wrap` take them. */
|
|
9
|
+
readonly requestHeaders?: Record<string, string>;
|
|
10
|
+
/** Milliseconds before a plugin request fails with `Request timed out`. Default: 5000. */
|
|
11
|
+
readonly requestTimeout?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Answers each plugin request. Default: `204 No Content`. Throw or reject
|
|
14
|
+
* to fail the request the way a network error does.
|
|
15
|
+
*/
|
|
16
|
+
readonly respond?: (request: ClientPluginTestRequest) => Response | Promise<Response>;
|
|
17
|
+
/** Default: `00000000-0000-4000-8000-000000000000`. */
|
|
18
|
+
readonly installId?: TestValue<string>;
|
|
19
|
+
/** Default: `ios`. */
|
|
20
|
+
readonly platform?: "ios" | "android";
|
|
21
|
+
/** The native app version. Default: `1.0.0`. */
|
|
22
|
+
readonly appVersion?: TestValue<string | null>;
|
|
23
|
+
/** Default: `0.0.0-test`. */
|
|
24
|
+
readonly sdkVersion?: string;
|
|
25
|
+
/** Default: false. */
|
|
26
|
+
readonly isDebugBuild?: boolean;
|
|
27
|
+
/** The running bundle. Default: `00000000-0000-0000-0000-000000000000`. */
|
|
28
|
+
readonly bundleId?: TestValue<string>;
|
|
29
|
+
/** Default: `production`. */
|
|
30
|
+
readonly channel?: TestValue<string>;
|
|
31
|
+
/** Default: `1`. */
|
|
32
|
+
readonly cohort?: TestValue<string>;
|
|
33
|
+
/** Default: null. */
|
|
34
|
+
readonly fingerprintHash?: TestValue<string | null>;
|
|
35
|
+
/** The clock plugins read. Default: `Date.now()`, so fake timers move it. */
|
|
36
|
+
readonly now?: () => number;
|
|
37
|
+
/**
|
|
38
|
+
* The device's plugin storage. Pass an earlier runtime's `storage` to set
|
|
39
|
+
* the plugins up again on the same device, as a relaunch does.
|
|
40
|
+
*/
|
|
41
|
+
readonly storage?: ClientPluginTestStorage;
|
|
42
|
+
}
|
|
43
|
+
/** A request a plugin made with `context.fetch`, as the server receives it. */
|
|
44
|
+
interface ClientPluginTestRequest {
|
|
45
|
+
readonly url: string;
|
|
46
|
+
/** The URL after `baseURL`, such as `/events`. */
|
|
47
|
+
readonly path: string;
|
|
48
|
+
readonly method: string;
|
|
49
|
+
/** Lowercase header names, `requestHeaders` included. */
|
|
50
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
51
|
+
/** The body as text, or null without one. */
|
|
52
|
+
readonly body: string | null;
|
|
53
|
+
/** The body parsed as JSON. */
|
|
54
|
+
json<T = unknown>(): T;
|
|
55
|
+
}
|
|
56
|
+
/** A device's plugin storage, which outlives a runtime as native storage does. */
|
|
57
|
+
interface ClientPluginTestStorage {
|
|
58
|
+
/** What a plugin stored under `key`, or null. */
|
|
59
|
+
get(pluginId: string, key: string): string | null;
|
|
60
|
+
/** Stores a value for a plugin, as an earlier launch would have; `null` removes it. */
|
|
61
|
+
set(pluginId: string, key: string, value: string | null): void;
|
|
62
|
+
/** Every key and value a plugin stored. */
|
|
63
|
+
entries(pluginId: string): Record<string, string>;
|
|
64
|
+
}
|
|
65
|
+
/** The hooks the SDK calls, called the same way. */
|
|
66
|
+
interface ClientPluginTestHooks {
|
|
67
|
+
onAppReady(result: AppReadyResult): void;
|
|
68
|
+
onUpdateCheck(result: UpdateCheckResult): void;
|
|
69
|
+
onBundleDownloaded(info: BundleDownloadedInfo): void;
|
|
70
|
+
onUpdateError(error: UpdateError): void;
|
|
71
|
+
}
|
|
72
|
+
/** One JavaScript runtime with the plugins set up, as after `HotUpdater.init`. */
|
|
73
|
+
interface ClientPluginTestRuntime {
|
|
74
|
+
/**
|
|
75
|
+
* Calls a hook on every plugin that returned it, as the SDK does: without
|
|
76
|
+
* waiting for it, and with a throw or rejection recorded in `errors`
|
|
77
|
+
* instead of raised. Await `settled()` for what the hooks started.
|
|
78
|
+
*/
|
|
79
|
+
readonly hooks: ClientPluginTestHooks;
|
|
80
|
+
/** Whether any plugin returned this hook from `setup`. */
|
|
81
|
+
listens(name: keyof ClientPluginTestHooks): boolean;
|
|
82
|
+
/** The requests plugins made, in order. */
|
|
83
|
+
readonly requests: readonly ClientPluginTestRequest[];
|
|
84
|
+
readonly storage: ClientPluginTestStorage;
|
|
85
|
+
/**
|
|
86
|
+
* What the SDK reported to `onError`: each `setup` or hook that threw or
|
|
87
|
+
* rejected, with the plugin's error as `cause`.
|
|
88
|
+
*/
|
|
89
|
+
readonly errors: readonly Error[];
|
|
90
|
+
/**
|
|
91
|
+
* Resolves once the hooks called so far, and the requests they started,
|
|
92
|
+
* have settled. It does not run timers: with fake timers, advance them to
|
|
93
|
+
* reach a retry or a delay.
|
|
94
|
+
*/
|
|
95
|
+
settled(): Promise<void>;
|
|
96
|
+
}
|
|
97
|
+
/** A new device's empty plugin storage. */
|
|
98
|
+
declare const createTestStorage: () => ClientPluginTestStorage;
|
|
99
|
+
/** Sets plugins up as `HotUpdater.init({ plugins })` does, in a new runtime. */
|
|
100
|
+
declare const setupClientPlugins: (plugins: readonly HotUpdaterClientPlugin[], options?: ClientPluginTestOptions) => ClientPluginTestRuntime;
|
|
101
|
+
/** Sets one plugin up; see `setupClientPlugins`. */
|
|
102
|
+
declare const setupClientPlugin: (plugin: HotUpdaterClientPlugin, options?: ClientPluginTestOptions) => ClientPluginTestRuntime;
|
|
103
|
+
//#endregion
|
|
104
|
+
export { ClientPluginTestHooks, ClientPluginTestOptions, ClientPluginTestRequest, ClientPluginTestRuntime, ClientPluginTestStorage, createTestStorage, setupClientPlugin, setupClientPlugins };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createPluginHost, pluginStorageKey, resolveBaseURL } from "@hot-updater/protocol";
|
|
2
|
+
//#region src/setupClientPlugins.ts
|
|
3
|
+
/**
|
|
4
|
+
* Runs client plugins in tests the way the SDK runs them in an app, against
|
|
5
|
+
* an in-memory device and a server the test answers.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { setupClientPlugin } from "@hot-updater/test-utils/react-native";
|
|
9
|
+
*
|
|
10
|
+
* const runtime = setupClientPlugin(myPlugin());
|
|
11
|
+
* runtime.hooks.onAppReady(launch);
|
|
12
|
+
* await runtime.settled();
|
|
13
|
+
* expect(runtime.requests[0]?.json()).toEqual(expectedEvent);
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* It sets plugins up with the plugin host from `@hot-updater/protocol`,
|
|
17
|
+
* the same code the app runs, so they get the same context and rules:
|
|
18
|
+
* unique ids, `setup` once per runtime, storage scoped to the plugin's id
|
|
19
|
+
* with values up to 64 KB, `fetch` relative to `baseURL` with
|
|
20
|
+
* `requestHeaders` and `requestTimeout`, and hooks the SDK never waits for,
|
|
21
|
+
* whose throws and rejections are reported, not raised. It imports neither
|
|
22
|
+
* React Native nor a test runner, so it works under Vitest and Jest.
|
|
23
|
+
*/
|
|
24
|
+
const storageItems = /* @__PURE__ */ new WeakMap();
|
|
25
|
+
/** A new device's empty plugin storage. */
|
|
26
|
+
const createTestStorage = () => {
|
|
27
|
+
const items = /* @__PURE__ */ new Map();
|
|
28
|
+
const storage = {
|
|
29
|
+
get: (pluginId, key) => items.get(pluginStorageKey(pluginId, key)) ?? null,
|
|
30
|
+
set: (pluginId, key, value) => {
|
|
31
|
+
if (value !== null && typeof value !== "string") throw new TypeError("[HotUpdater] Plugin storage values are strings or null.");
|
|
32
|
+
const storageKey = pluginStorageKey(pluginId, key);
|
|
33
|
+
if (value === null) items.delete(storageKey);
|
|
34
|
+
else items.set(storageKey, value);
|
|
35
|
+
},
|
|
36
|
+
entries: (pluginId) => {
|
|
37
|
+
const prefix = pluginStorageKey(pluginId, "");
|
|
38
|
+
return Object.fromEntries([...items].filter(([storageKey]) => storageKey.startsWith(prefix)).map(([storageKey, value]) => [storageKey.slice(prefix.length), value]));
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
storageItems.set(storage, items);
|
|
42
|
+
return storage;
|
|
43
|
+
};
|
|
44
|
+
const read = (value) => typeof value === "function" ? value() : value;
|
|
45
|
+
const abortError = (signal) => {
|
|
46
|
+
const reason = signal?.reason;
|
|
47
|
+
if (reason instanceof Error && reason.name === "AbortError") return reason;
|
|
48
|
+
const error = /* @__PURE__ */ new Error("The operation was aborted.");
|
|
49
|
+
error.name = "AbortError";
|
|
50
|
+
return error;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Resolves after every pending microtask, on a macrotask that fake timers
|
|
54
|
+
* leave alone.
|
|
55
|
+
*/
|
|
56
|
+
const nextMacrotask = () => new Promise((resolve) => {
|
|
57
|
+
const Channel = globalThis.MessageChannel;
|
|
58
|
+
if (Channel === void 0) {
|
|
59
|
+
setTimeout(resolve, 0);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const { port1, port2 } = new Channel();
|
|
63
|
+
port1.addEventListener("message", () => {
|
|
64
|
+
port1.close();
|
|
65
|
+
resolve();
|
|
66
|
+
}, { once: true });
|
|
67
|
+
port1.start();
|
|
68
|
+
port2.postMessage(null);
|
|
69
|
+
});
|
|
70
|
+
/** Sets plugins up as `HotUpdater.init({ plugins })` does, in a new runtime. */
|
|
71
|
+
const setupClientPlugins = (plugins, options = {}) => {
|
|
72
|
+
const storage = options.storage ?? createTestStorage();
|
|
73
|
+
const items = storageItems.get(storage);
|
|
74
|
+
if (items === void 0) throw new TypeError("[HotUpdater] Pass a storage from createTestStorage() or an earlier runtime.");
|
|
75
|
+
const baseURL = options.baseURL ?? "https://hot-updater.example.com";
|
|
76
|
+
const respond = options.respond ?? (() => new Response(null, { status: 204 }));
|
|
77
|
+
const requests = [];
|
|
78
|
+
const errors = [];
|
|
79
|
+
const pending = /* @__PURE__ */ new Set();
|
|
80
|
+
const track = (work) => {
|
|
81
|
+
const settledWork = work.then(() => void 0, () => void 0).finally(() => {
|
|
82
|
+
pending.delete(settledWork);
|
|
83
|
+
});
|
|
84
|
+
pending.add(settledWork);
|
|
85
|
+
};
|
|
86
|
+
const recordRequest = async (url, init) => {
|
|
87
|
+
const request = new Request(url, init);
|
|
88
|
+
const body = typeof init.body === "string" ? init.body : init.body === void 0 || init.body === null ? null : await request.text();
|
|
89
|
+
const base = await resolveBaseURL(baseURL);
|
|
90
|
+
const headers = {};
|
|
91
|
+
request.headers.forEach((value, name) => {
|
|
92
|
+
headers[name] = value;
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
url,
|
|
96
|
+
path: url.startsWith(`${base}/`) ? url.slice(base.length) : url,
|
|
97
|
+
method: request.method,
|
|
98
|
+
headers,
|
|
99
|
+
body,
|
|
100
|
+
json: () => {
|
|
101
|
+
if (body === null) throw new SyntaxError("The request has no body to parse as JSON.");
|
|
102
|
+
return JSON.parse(body);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
const answer = (request, signal) => new Promise((resolve, reject) => {
|
|
107
|
+
const abort = () => reject(abortError(signal));
|
|
108
|
+
if (signal?.aborted) {
|
|
109
|
+
abort();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
113
|
+
Promise.resolve().then(() => respond(request)).then(resolve, reject).finally(() => signal?.removeEventListener("abort", abort));
|
|
114
|
+
});
|
|
115
|
+
const host = createPluginHost({
|
|
116
|
+
fetch: (url, init) => {
|
|
117
|
+
const response = recordRequest(url, init).then((request) => {
|
|
118
|
+
requests.push(request);
|
|
119
|
+
return answer(request, init.signal);
|
|
120
|
+
});
|
|
121
|
+
track(response);
|
|
122
|
+
return response;
|
|
123
|
+
},
|
|
124
|
+
platform: options.platform ?? "ios",
|
|
125
|
+
isDebugBuild: options.isDebugBuild ?? false,
|
|
126
|
+
sdkVersion: options.sdkVersion ?? "0.0.0-test",
|
|
127
|
+
getInstallId: () => read(options.installId ?? "00000000-0000-4000-8000-000000000000"),
|
|
128
|
+
getAppVersion: () => read(options.appVersion === void 0 ? "1.0.0" : options.appVersion),
|
|
129
|
+
getBundleId: () => read(options.bundleId ?? "00000000-0000-0000-0000-000000000000"),
|
|
130
|
+
getChannel: () => read(options.channel ?? "production"),
|
|
131
|
+
getCohort: () => read(options.cohort ?? "1"),
|
|
132
|
+
getFingerprintHash: () => read(options.fingerprintHash === void 0 ? null : options.fingerprintHash),
|
|
133
|
+
getStorageItem: (key) => items.get(key) ?? null,
|
|
134
|
+
setStorageItem: (key, value) => {
|
|
135
|
+
if (value === null) items.delete(key);
|
|
136
|
+
else items.set(key, value);
|
|
137
|
+
},
|
|
138
|
+
now: () => options.now ? options.now() : Date.now()
|
|
139
|
+
});
|
|
140
|
+
host.configurePlugins(plugins, {
|
|
141
|
+
baseURL,
|
|
142
|
+
requestHeaders: options.requestHeaders,
|
|
143
|
+
requestTimeout: options.requestTimeout,
|
|
144
|
+
onError: (error) => {
|
|
145
|
+
errors.push(error);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
const call = (name) => (payload) => {
|
|
149
|
+
for (const settlement of host.dispatchPluginHook(name, payload)) track(settlement);
|
|
150
|
+
};
|
|
151
|
+
return {
|
|
152
|
+
hooks: {
|
|
153
|
+
onAppReady: call("onAppReady"),
|
|
154
|
+
onUpdateCheck: call("onUpdateCheck"),
|
|
155
|
+
onBundleDownloaded: call("onBundleDownloaded"),
|
|
156
|
+
onUpdateError: call("onUpdateError")
|
|
157
|
+
},
|
|
158
|
+
listens: (name) => host.hasPluginHook(name),
|
|
159
|
+
requests,
|
|
160
|
+
storage,
|
|
161
|
+
errors,
|
|
162
|
+
settled: async () => {
|
|
163
|
+
for (;;) {
|
|
164
|
+
await nextMacrotask();
|
|
165
|
+
if (pending.size === 0) return;
|
|
166
|
+
await Promise.all(pending);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
/** Sets one plugin up; see `setupClientPlugins`. */
|
|
172
|
+
const setupClientPlugin = (plugin, options) => setupClientPlugins([plugin], options);
|
|
173
|
+
//#endregion
|
|
174
|
+
export { createTestStorage, setupClientPlugin, setupClientPlugins };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hot-updater/test-utils",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.21",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Test utilities for Hot Updater packages",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -22,6 +22,16 @@
|
|
|
22
22
|
"default": "./dist/node.cjs"
|
|
23
23
|
}
|
|
24
24
|
},
|
|
25
|
+
"./react-native": {
|
|
26
|
+
"import": {
|
|
27
|
+
"types": "./dist/react-native.d.mts",
|
|
28
|
+
"default": "./dist/react-native.mjs"
|
|
29
|
+
},
|
|
30
|
+
"require": {
|
|
31
|
+
"types": "./dist/react-native.d.cts",
|
|
32
|
+
"default": "./dist/react-native.cjs"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
25
35
|
"./package.json": "./package.json"
|
|
26
36
|
},
|
|
27
37
|
"files": [
|
|
@@ -35,21 +45,49 @@
|
|
|
35
45
|
},
|
|
36
46
|
"author": "gronxb <gron1gh1@gmail.com> (https://github.com/gronxb)",
|
|
37
47
|
"peerDependencies": {
|
|
48
|
+
"@electric-sql/pglite": "*",
|
|
38
49
|
"execa": "*",
|
|
50
|
+
"mysql2": "*",
|
|
51
|
+
"pg": "*",
|
|
39
52
|
"vitest": "^4.0.0",
|
|
40
|
-
"@hot-updater/
|
|
41
|
-
"@hot-updater/plugin-core": "^1.0.0-rc.
|
|
53
|
+
"@hot-updater/plugin-insights": "^1.0.0-rc.21",
|
|
54
|
+
"@hot-updater/plugin-core": "^1.0.0-rc.21",
|
|
55
|
+
"@hot-updater/protocol": "^1.0.0-rc.21",
|
|
56
|
+
"@hot-updater/server": "^1.0.0-rc.21"
|
|
57
|
+
},
|
|
58
|
+
"peerDependenciesMeta": {
|
|
59
|
+
"@electric-sql/pglite": {
|
|
60
|
+
"optional": true
|
|
61
|
+
},
|
|
62
|
+
"@hot-updater/plugin-insights": {
|
|
63
|
+
"optional": true
|
|
64
|
+
},
|
|
65
|
+
"@hot-updater/server": {
|
|
66
|
+
"optional": true
|
|
67
|
+
},
|
|
68
|
+
"mysql2": {
|
|
69
|
+
"optional": true
|
|
70
|
+
},
|
|
71
|
+
"pg": {
|
|
72
|
+
"optional": true
|
|
73
|
+
}
|
|
42
74
|
},
|
|
43
75
|
"devDependencies": {
|
|
76
|
+
"@electric-sql/pglite": "0.4.1",
|
|
44
77
|
"@types/node": "^20",
|
|
78
|
+
"@types/pg": "^8.11.10",
|
|
45
79
|
"execa": "9.5.2",
|
|
46
80
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
81
|
+
"mysql2": "3.19.1",
|
|
82
|
+
"pg": "8.13.1",
|
|
47
83
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
48
84
|
"vitest": "4.1.4",
|
|
49
85
|
"workspace-tools": "^0.41.7",
|
|
50
|
-
"@hot-updater/
|
|
51
|
-
"@hot-updater/plugin-core": "1.0.0-rc.
|
|
52
|
-
"@hot-updater/js": "1.0.0-rc.
|
|
86
|
+
"@hot-updater/protocol": "1.0.0-rc.21",
|
|
87
|
+
"@hot-updater/plugin-core": "1.0.0-rc.21",
|
|
88
|
+
"@hot-updater/js": "1.0.0-rc.21",
|
|
89
|
+
"@hot-updater/server": "1.0.0-rc.21",
|
|
90
|
+
"@hot-updater/plugin-insights": "1.0.0-rc.21"
|
|
53
91
|
},
|
|
54
92
|
"inlinedDependencies": {
|
|
55
93
|
"@nodelib/fs.scandir": "2.1.5",
|