@parity/product-sdk-host 0.0.0-dev.312.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/src/testing.ts ADDED
@@ -0,0 +1,493 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Test fakes for `@parity/product-sdk-host`.
5
+ *
6
+ * The host reaches its container through one cached `TrUApiClient`.
7
+ * `createFakeHost()` injects a fake via `setTruApiClient` so every accessor
8
+ * (`getHostLocalStorage`, `getAccountsProvider`, `getStatementStore`, `getTruApi`,
9
+ * …) resolves against it and `isInsideContainer()` reports `true` — which also
10
+ * makes a default `SignerManager`, `local-storage` auto-detection, and the
11
+ * `statement-store` / `cloud-storage` host paths testable.
12
+ *
13
+ * Of the `chain` domain only `getChainInfo` is modeled, so host chain discovery
14
+ * (and `getChainAPI()` on top of it) resolves in tests; see the `chainInfo`
15
+ * option. Not modeled: the rest of the PAPI `chain` JSON-RPC surface behind
16
+ * `getHostProvider()` — there's no chain-read fake, by design; the host owns RPC
17
+ * selection — and the `chat` / `coinPayment` / `entropy` / `notifications` /
18
+ * `payment` / `permissions` / `resourceAllocation` / `theme` domains. Touching
19
+ * an unmodeled domain throws a descriptive error rather than failing with
20
+ * `undefined is not a function`.
21
+ *
22
+ * @packageDocumentation
23
+ */
24
+ import type { ObservableLike, Observer, Subscription, TrUApiClient } from "@parity/truapi";
25
+ import { errAsync, okAsync } from "neverthrow";
26
+
27
+ import type { HostChainIdentifier } from "./chain-discovery.js";
28
+
29
+ import { setTruApiClient } from "./transport.js";
30
+
31
+ export { setTruApiClient };
32
+
33
+ /**
34
+ * The public surface of a generated truapi domain client. `keyof` skips private
35
+ * members, so this is the shape a plain object can implement — the classes
36
+ * themselves are unimplementable outside truapi because of their
37
+ * `private transport`.
38
+ */
39
+ type PublicSurface<C> = { [K in keyof C]: C[K] };
40
+
41
+ /**
42
+ * `TrUApiClient` seen through public surfaces only. The fake is checked against
43
+ * this member-by-member, so drift from the generated client — a renamed method,
44
+ * a changed request or response type — fails compilation here instead of
45
+ * surfacing as a runtime mismatch in consumers' tests.
46
+ */
47
+ type PublicTruApiClient = { [D in keyof TrUApiClient]: PublicSurface<TrUApiClient[D]> };
48
+
49
+ /** Inlined to keep the `truapi` facade out of this entry's bundle. */
50
+ function toHex(bytes: Uint8Array): `0x${string}` {
51
+ return `0x${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
52
+ }
53
+
54
+ function fakeSubscription(): Subscription {
55
+ return { unsubscribe: () => {}, subscriptionId: "fake-subscription" };
56
+ }
57
+
58
+ /**
59
+ * Wrap a `subscribe` implementation into a full `ObservableLike`. The
60
+ * `Symbol.observable` interop member only lands on the object when the symbol
61
+ * is polyfilled (e.g. by rxjs) — same as the generated client — and nothing in
62
+ * the SDK reads it; it exists to satisfy the interface.
63
+ */
64
+ function makeObservable<Item, Reason = never>(
65
+ subscribe: (observer?: Partial<Observer<Item, Reason>>) => Subscription,
66
+ ): ObservableLike<Item, Reason> {
67
+ const observable: ObservableLike<Item, Reason> = {
68
+ subscribe,
69
+ [Symbol.observable]() {
70
+ return observable;
71
+ },
72
+ };
73
+ return observable;
74
+ }
75
+
76
+ /** An `ObservableLike` that never emits. Drive statement delivery with `createFakeStatementTransport`. */
77
+ function inertObservable<Item, Reason = never>(): ObservableLike<Item, Reason> {
78
+ return makeObservable(() => fakeSubscription());
79
+ }
80
+
81
+ /**
82
+ * An `ObservableLike` that emits `item` once, asynchronously. The microtask defer
83
+ * matters: the caller assigns its subscription handle from the return value, so
84
+ * the item must arrive after `subscribe()` returns, not during it.
85
+ */
86
+ function oneShotObservable<Item>(item: Item): ObservableLike<Item> {
87
+ return makeObservable((observer) => {
88
+ queueMicrotask(() => observer?.next?.(item));
89
+ return fakeSubscription();
90
+ });
91
+ }
92
+
93
+ /**
94
+ * A domain the fake deliberately doesn't model (see the module header). Any
95
+ * member access throws with a pointer here, instead of the bare TypeError an
96
+ * empty stub would give. The empty-object cast is the one concession a Proxy
97
+ * needs; every modeled domain is checked structurally.
98
+ *
99
+ * `modeled` carries the members that _are_ implemented, for a domain where the
100
+ * fake covers some calls but not the whole surface.
101
+ */
102
+ function notModeled<D extends keyof TrUApiClient>(
103
+ domain: D,
104
+ modeled?: Partial<PublicSurface<TrUApiClient[D]>>,
105
+ ): PublicSurface<TrUApiClient[D]> {
106
+ return new Proxy((modeled ?? {}) as PublicSurface<TrUApiClient[D]>, {
107
+ get(target, member) {
108
+ if (member in target) return target[member as keyof typeof target];
109
+ // Stay quiet for inspection probes (console.log, await-resolution).
110
+ if (typeof member === "symbol" || member === "then") return undefined;
111
+ throw new Error(
112
+ `createFakeTruApiClient: \`${domain}.${member}\` is not modeled by the fake. See the @parity/product-sdk-host/testing module docs for what is covered.`,
113
+ );
114
+ },
115
+ });
116
+ }
117
+
118
+ /** Deterministic `0x`-prefixed preimage key derived from a hex value (FNV-1a). */
119
+ function preimageKey(hexValue: string): `0x${string}` {
120
+ let h = 0x811c9dc5;
121
+ for (let i = 0; i < hexValue.length; i++) {
122
+ h ^= hexValue.charCodeAt(i);
123
+ h = Math.imul(h, 0x01000193);
124
+ }
125
+ return `0x${(h >>> 0).toString(16).padStart(8, "0")}`;
126
+ }
127
+
128
+ /**
129
+ * What a fake host reports through `chain.getChainInfo`: the network id it is
130
+ * configured for, and a genesis hash per chain role it serves.
131
+ *
132
+ * Roles left out are answered `NotSupported`, exactly as a host that does not
133
+ * serve them. Use the genesis hashes the descriptors expose (e.g.
134
+ * `paseo_asset_hub.genesis`) if the test drives `getChainAPI()`, since the
135
+ * environment is derived by matching the asset hub genesis against the bundle.
136
+ */
137
+ export interface FakeChainInfo {
138
+ /** Ecosystem the fake host claims, e.g. `"paseo"`. */
139
+ network: string;
140
+ /** Genesis hash per chain role served. */
141
+ chains: Partial<Record<HostChainIdentifier, `0x${string}`>>;
142
+ }
143
+
144
+ /** Options for {@link createFakeTruApiClient}. */
145
+ export interface CreateFakeTruApiClientOptions {
146
+ /** `account.getUserId` primary username. Default `"alice.dot"`. */
147
+ primaryUsername?: string;
148
+ /** Product-account public key. Default 32 bytes of `0x11`. */
149
+ publicKey?: Uint8Array;
150
+ /** Bytes returned by the signing domain (signatures / proofs / transactions). Default 64 bytes of `0x22`. */
151
+ signature?: Uint8Array;
152
+ /** Whether `system.featureSupported` reports the chain supported. Default `true`. */
153
+ chainSupported?: boolean;
154
+ /** Seed the in-memory host localStorage, keyed by storage key. */
155
+ localStorage?: Record<string, Uint8Array>;
156
+ /** Legacy accounts returned by `account.getLegacyAccounts`. Default none. */
157
+ legacyAccounts?: Array<{ publicKey: Uint8Array; name: string }>;
158
+ /** Seed the in-memory preimage store, keyed by the `0x` preimage key. */
159
+ preimages?: Record<string, Uint8Array>;
160
+ /**
161
+ * What `chain.getChainInfo` reports. Omit to model a host predating chain
162
+ * discovery: the call is refused as `Unsupported`, so `getHostChainInfo`
163
+ * resolves `null` and `getChainAPI` needs an explicit environment.
164
+ */
165
+ chainInfo?: FakeChainInfo;
166
+ }
167
+
168
+ /**
169
+ * Build a fake `TrUApiClient` covering the domains the host accessors use:
170
+ * `localStorage` (real in-memory KV), `account` / `signing` (canned data),
171
+ * `statementStore`, `preimage`, and `system`. Unmodeled domains (`chain` et al —
172
+ * see the module header) throw on member access.
173
+ */
174
+ export function createFakeTruApiClient(options?: CreateFakeTruApiClientOptions): TrUApiClient {
175
+ const primaryUsername = options?.primaryUsername ?? "alice.dot";
176
+ const publicKey = toHex(options?.publicKey ?? new Uint8Array(32).fill(0x11));
177
+ const signature = toHex(options?.signature ?? new Uint8Array(64).fill(0x22));
178
+ const chainSupported = options?.chainSupported ?? true;
179
+ const chainInfo = options?.chainInfo;
180
+
181
+ // Real in-memory KV (hex values) so getHostLocalStorage()/createLocalKvStore() round-trip.
182
+ const kv = new Map<string, `0x${string}`>();
183
+ for (const [key, value] of Object.entries(options?.localStorage ?? {})) {
184
+ kv.set(key, toHex(value));
185
+ }
186
+
187
+ const legacyAccounts = (options?.legacyAccounts ?? []).map((a) => ({
188
+ publicKey: toHex(a.publicKey),
189
+ name: a.name,
190
+ }));
191
+
192
+ // Preimage store (hex key -> hex value), for getPreimageManager() and submit/lookup round-trips.
193
+ const preimages = new Map<string, `0x${string}`>();
194
+ for (const [key, value] of Object.entries(options?.preimages ?? {})) {
195
+ preimages.set(key, toHex(value));
196
+ }
197
+
198
+ // Typed against the generated client's public surface: every method below is
199
+ // structurally checked, so a truapi signature change breaks this file's build,
200
+ // not a consumer's test run.
201
+ const client: PublicTruApiClient = {
202
+ localStorage: {
203
+ read: ({ key }) => okAsync({ value: kv.get(key) }),
204
+ write: ({ key, value }) => {
205
+ kv.set(key, value);
206
+ return okAsync(undefined);
207
+ },
208
+ clear: ({ key }) => {
209
+ kv.delete(key);
210
+ return okAsync(undefined);
211
+ },
212
+ },
213
+ account: {
214
+ getUserId: () => okAsync({ primaryUsername }),
215
+ requestLogin: () => okAsync("Success"),
216
+ getAccount: () => okAsync({ account: { publicKey } }),
217
+ getAccountAlias: () =>
218
+ okAsync({ context: toHex(new Uint8Array([1])), alias: toHex(new Uint8Array([2])) }),
219
+ getLegacyAccounts: () => okAsync({ accounts: legacyAccounts }),
220
+ createAccountProof: () =>
221
+ okAsync({
222
+ proof: signature,
223
+ contextualAlias: {
224
+ context: toHex(new Uint8Array([1])),
225
+ alias: toHex(new Uint8Array([2])),
226
+ },
227
+ ringIndex: 0,
228
+ ringRevision: 0,
229
+ }),
230
+ registerRingVrfKey: () => okAsync(publicKey),
231
+ listRingVrfKeys: () => okAsync([]),
232
+ ringVrfSign: () => okAsync(signature),
233
+ signVrf: () => okAsync({ preOutput: publicKey, proof: signature }),
234
+ connectionStatusSubscribe: () => inertObservable(),
235
+ },
236
+ signing: {
237
+ createTransaction: () => okAsync({ transaction: signature }),
238
+ createTransactionWithLegacyAccount: () => okAsync({ transaction: signature }),
239
+ signRaw: () => okAsync({ signature }),
240
+ signRawWithLegacyAccount: () => okAsync({ signature }),
241
+ signPayload: () => okAsync({ signature }),
242
+ signPayloadWithLegacyAccount: () => okAsync({ signature }),
243
+ },
244
+ statementStore: {
245
+ subscribe: () => inertObservable(),
246
+ createProof: () =>
247
+ okAsync({ proof: { tag: "Sr25519", value: { signature, signer: publicKey } } }),
248
+ createProofAuthorized: () =>
249
+ okAsync({ proof: { tag: "Sr25519", value: { signature, signer: publicKey } } }),
250
+ submit: () => okAsync(undefined),
251
+ },
252
+ system: {
253
+ handshake: () => okAsync(undefined),
254
+ featureSupported: () => okAsync({ supported: chainSupported }),
255
+ navigateTo: () => okAsync(undefined),
256
+ },
257
+ preimage: {
258
+ lookupSubscribe: ({ request: { key } }) =>
259
+ oneShotObservable({ value: preimages.get(key) }),
260
+ submit: (value) => {
261
+ const key = preimageKey(value);
262
+ preimages.set(key, value);
263
+ return okAsync(key);
264
+ },
265
+ },
266
+ // Only `getChainInfo` is modeled; the rest of the domain still throws.
267
+ chain: notModeled("chain", {
268
+ getChainInfo: ({ chain }) => {
269
+ if (!chainInfo) return errAsync({ tag: "Unsupported" } as const);
270
+ const genesisHash = chainInfo.chains[chain];
271
+ if (!genesisHash) {
272
+ return errAsync({
273
+ tag: "Domain",
274
+ value: { tag: "V1", value: { tag: "NotSupported" } },
275
+ } as const);
276
+ }
277
+ return okAsync({ network: chainInfo.network, chain, genesisHash });
278
+ },
279
+ }),
280
+ chat: notModeled("chat"),
281
+ coinPayment: notModeled("coinPayment"),
282
+ entropy: notModeled("entropy"),
283
+ notifications: notModeled("notifications"),
284
+ payment: notModeled("payment"),
285
+ permissions: notModeled("permissions"),
286
+ resourceAllocation: notModeled("resourceAllocation"),
287
+ theme: notModeled("theme"),
288
+ };
289
+
290
+ // A plain downcast, not an `unknown` bridge: each generated class is
291
+ // assignable to its `PublicSurface`, and TS has already verified every fake
292
+ // member against the generated signatures in the annotation above.
293
+ return client as TrUApiClient;
294
+ }
295
+
296
+ /**
297
+ * A `beforeEach`-registered test hook, e.g. Vitest's `onTestFinished`. Read off
298
+ * `globalThis` so we couple to no test framework: present under Vitest's globals
299
+ * mode, absent everywhere else (Jest, `node:test`, plain scripts).
300
+ */
301
+ type TestFinishedHook = (fn: () => void) => void;
302
+
303
+ /** Handle returned by {@link createFakeHost}. */
304
+ export interface FakeHost extends Disposable {
305
+ /** The injected fake client (also what `getTruApi()` returns). */
306
+ client: TrUApiClient;
307
+ /** Clear the override. Idempotent; safe to call more than once. */
308
+ dispose(): void;
309
+ /** `using host = createFakeHost()` restores the real client at scope end. */
310
+ [Symbol.dispose](): void;
311
+ }
312
+
313
+ /**
314
+ * Inject a fake client via {@link setTruApiClient} and return a disposer.
315
+ *
316
+ * Cleanup is guaranteed three ways, so a forgotten reset can't leak the override
317
+ * into the next test or file: prefer `using` (scope-end disposal), otherwise the
318
+ * handle self-registers `onTestFinished` when created inside a Vitest run, and
319
+ * `dispose()` is always there to call by hand.
320
+ *
321
+ * @example
322
+ * ```ts
323
+ * import { createFakeHost } from "@parity/product-sdk-host/testing";
324
+ *
325
+ * test("host storage is reachable", async () => {
326
+ * using host = createFakeHost({ primaryUsername: "carol.dot" });
327
+ * // getHostLocalStorage(), getAccountsProvider(), a default SignerManager, etc.
328
+ * // resolve the fake; the real client is restored when the test scope ends.
329
+ * });
330
+ * ```
331
+ */
332
+ export function createFakeHost(options?: CreateFakeTruApiClientOptions): FakeHost {
333
+ const client = createFakeTruApiClient(options);
334
+ setTruApiClient(client);
335
+
336
+ let disposed = false;
337
+ const dispose = () => {
338
+ if (disposed) return;
339
+ disposed = true;
340
+ setTruApiClient(null);
341
+ };
342
+
343
+ // Best-effort auto-cleanup for the common case (a bare `const host =
344
+ // createFakeHost()` with no `using` and no manual reset). Under Vitest's
345
+ // globals mode `onTestFinished` is on `globalThis`; elsewhere it's absent and
346
+ // we quietly rely on `using` / manual `dispose()`.
347
+ const onTestFinished = (globalThis as { onTestFinished?: TestFinishedHook }).onTestFinished;
348
+ if (typeof onTestFinished === "function") {
349
+ try {
350
+ onTestFinished(dispose);
351
+ } catch {
352
+ // Not inside a running test (e.g. called at module scope) — ignore.
353
+ }
354
+ }
355
+
356
+ return {
357
+ client,
358
+ dispose,
359
+ [Symbol.dispose]: dispose,
360
+ };
361
+ }
362
+
363
+ if (import.meta.vitest) {
364
+ // Round-trip guard: drive the fake through the *real* host accessors.
365
+ const { describe, test, expect, afterEach } = import.meta.vitest;
366
+ const { getHostLocalStorage, getStatementStore, isInsideContainer } = await import(
367
+ "./container.js"
368
+ );
369
+ const { getAccountsProvider } = await import("./accounts.js");
370
+ const { getHostChainInfo } = await import("./chain-discovery.js");
371
+ const { getPreimageManager } = await import("./truapi.js");
372
+
373
+ const lookupOnce = (
374
+ manager: NonNullable<Awaited<ReturnType<typeof getPreimageManager>>>,
375
+ key: `0x${string}`,
376
+ ) =>
377
+ new Promise<Uint8Array | null>((resolve) => {
378
+ manager.lookup(key, (preimage) => {
379
+ if (preimage !== null) resolve(preimage);
380
+ });
381
+ });
382
+
383
+ afterEach(() => setTruApiClient(null));
384
+
385
+ describe("createFakeHost / createFakeTruApiClient", () => {
386
+ test("host localStorage round-trips through the real adapter", async () => {
387
+ createFakeHost();
388
+ const ls = await getHostLocalStorage();
389
+ expect(ls).not.toBeNull();
390
+ await ls?.writeString("k", "v");
391
+ expect(await ls?.readString("k")).toBe("v");
392
+ await ls?.writeJSON("j", { a: 1 });
393
+ expect(await ls?.readJSON("j")).toEqual({ a: 1 });
394
+ expect(await ls?.readString("missing")).toBe("");
395
+ });
396
+
397
+ test("seeded localStorage is readable", async () => {
398
+ createFakeHost({ localStorage: { greeting: new TextEncoder().encode("hi") } });
399
+ const ls = await getHostLocalStorage();
400
+ expect(await ls?.readString("greeting")).toBe("hi");
401
+ });
402
+
403
+ test("accounts provider resolves the configured user", async () => {
404
+ createFakeHost({ primaryUsername: "carol.dot" });
405
+ const accounts = await getAccountsProvider();
406
+ expect(accounts).not.toBeNull();
407
+ const userId = await accounts?.getUserId().match(
408
+ (v) => v,
409
+ () => null,
410
+ );
411
+ expect(userId?.primaryUsername).toBe("carol.dot");
412
+ });
413
+
414
+ test("signVrf round-trips through the real adapter", async () => {
415
+ // The fake is the only way a product can test a VRF flow: there is no
416
+ // dev-provider implementation and the e2e test host does not expose the
417
+ // call. So the fake's wire shape has to stay decodable by the adapter.
418
+ createFakeHost();
419
+ const accounts = await getAccountsProvider();
420
+ const signature = await accounts
421
+ ?.signVrf({ dotNsIdentifier: "app.dot" }, new Uint8Array([1]), [
422
+ { label: new Uint8Array([2]), value: new Uint8Array([3]) },
423
+ ])
424
+ .match(
425
+ (s) => s,
426
+ () => null,
427
+ );
428
+ expect(signature?.preOutput).toBeInstanceOf(Uint8Array);
429
+ expect(signature?.proof).toBeInstanceOf(Uint8Array);
430
+ expect(signature?.preOutput).toHaveLength(32);
431
+ expect(signature?.proof).toHaveLength(64);
432
+ });
433
+
434
+ test("chain discovery is refused by default, so a legacy host is modeled", async () => {
435
+ createFakeHost();
436
+ // Refused, not unmodeled: a bare `getChainAPI("paseo")` in a consumer
437
+ // test must not warn about the fake on every call.
438
+ expect(await getHostChainInfo(["AssetHub"])).toBeNull();
439
+ });
440
+
441
+ test("chainInfo drives discovery, with unserved roles left out", async () => {
442
+ createFakeHost({
443
+ chainInfo: { network: "paseo", chains: { AssetHub: "0xaa", Bulletin: "0xbb" } },
444
+ });
445
+ expect(await getHostChainInfo(["AssetHub", "Bulletin", "People"])).toEqual({
446
+ network: "paseo",
447
+ chains: { AssetHub: "0xaa", Bulletin: "0xbb" },
448
+ });
449
+ });
450
+
451
+ test("the rest of the chain domain still reports itself unmodeled", async () => {
452
+ const host = createFakeHost();
453
+ expect(() => (host.client.chain as { chainName?: unknown }).chainName).toThrow(
454
+ /not modeled by the fake/,
455
+ );
456
+ });
457
+
458
+ test("statement store resolves", async () => {
459
+ createFakeHost();
460
+ expect(await getStatementStore()).not.toBeNull();
461
+ });
462
+
463
+ test("preimage manager reads seeded bytes and round-trips submit -> lookup", async () => {
464
+ createFakeHost({ preimages: { "0xabc": new Uint8Array([1, 2, 3]) } });
465
+ const pm = await getPreimageManager();
466
+ expect(pm).not.toBeNull();
467
+
468
+ const seeded = await lookupOnce(pm!, "0xabc");
469
+ expect(Array.from(seeded ?? [])).toEqual([1, 2, 3]);
470
+
471
+ const key = await pm!.submit(new Uint8Array([9, 9]));
472
+ const back = await lookupOnce(pm!, key);
473
+ expect(Array.from(back ?? [])).toEqual([9, 9]);
474
+ });
475
+
476
+ test("isInsideContainer is true while set, false after dispose", async () => {
477
+ const host = createFakeHost();
478
+ expect(await isInsideContainer()).toBe(true);
479
+ host.dispose();
480
+ expect(await isInsideContainer()).toBe(false);
481
+ expect(await getHostLocalStorage()).toBeNull();
482
+ });
483
+
484
+ test("Symbol.dispose clears the override and dispose is idempotent", async () => {
485
+ {
486
+ using host = createFakeHost();
487
+ expect(await isInsideContainer()).toBe(true);
488
+ host.dispose(); // explicit + scope-end Symbol.dispose must not double-clear badly
489
+ }
490
+ expect(await isInsideContainer()).toBe(false);
491
+ });
492
+ });
493
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,78 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Higher-level wrapper for the host's theme subscription, backed by
5
+ * `truApi.theme.subscribe`.
6
+ *
7
+ * `getThemeProvider` returns a handle whose `subscribeTheme(cb)` delivers a
8
+ * typed {@link ThemeMode} — a `{ name, variant }` struct where `variant` is
9
+ * `"Light" | "Dark"` and `name` is `{ tag: "Default" }` or
10
+ * `{ tag: "Custom", value }` — and yields a {@link HostSubscription}
11
+ * (`unsubscribe` + `onInterrupt`).
12
+ *
13
+ * @module
14
+ */
15
+
16
+ import type { HostThemeSubscribeItem, TrUApiClient } from "@parity/truapi";
17
+
18
+ import { getClient, subscribeWithInterrupt } from "./transport.js";
19
+ import type { HostSubscription } from "./types.js";
20
+
21
+ /**
22
+ * Host theme value. A `{ name, variant }` struct re-exported from
23
+ * `@parity/truapi`.
24
+ */
25
+ export type ThemeMode = HostThemeSubscribeItem;
26
+
27
+ /** Light/dark variant of the active theme (`"Light" | "Dark"`) and the active theme name. Re-exported from `@parity/truapi`. */
28
+ export type { ThemeName, ThemeVariant } from "@parity/truapi";
29
+
30
+ /**
31
+ * Host theme provider handle. `subscribeTheme(callback)` receives a typed
32
+ * {@link ThemeMode} on every change and returns a {@link HostSubscription}.
33
+ */
34
+ export interface ThemeProvider {
35
+ subscribeTheme(callback: (theme: ThemeMode) => void): HostSubscription;
36
+ }
37
+
38
+ /** Build a {@link ThemeProvider} over a TruAPI client's `theme` domain. */
39
+ function adaptThemeProvider(client: TrUApiClient): ThemeProvider {
40
+ return {
41
+ subscribeTheme(callback) {
42
+ return subscribeWithInterrupt(client.theme.subscribe(), callback);
43
+ },
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Get the host theme provider, backed by `truApi.theme.*`. Returns `null` when
49
+ * running outside a host container.
50
+ *
51
+ * @returns The theme provider, or `null` if unavailable.
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * import { getThemeProvider } from "@parity/product-sdk-host";
56
+ *
57
+ * const provider = await getThemeProvider();
58
+ * if (provider) {
59
+ * const sub = provider.subscribeTheme((theme) => {
60
+ * document.documentElement.dataset.theme = theme.variant.toLowerCase();
61
+ * if (theme.name.tag === "Custom") loadCustomTheme(theme.name.value);
62
+ * });
63
+ * // sub.unsubscribe() to stop listening
64
+ * }
65
+ * ```
66
+ */
67
+ export async function getThemeProvider(): Promise<ThemeProvider | null> {
68
+ const client = await getClient();
69
+ return client ? adaptThemeProvider(client) : null;
70
+ }
71
+
72
+ if (import.meta.vitest) {
73
+ const { test, expect } = import.meta.vitest;
74
+
75
+ test("getThemeProvider returns null outside a container", async () => {
76
+ expect(await getThemeProvider()).toBeNull();
77
+ });
78
+ }