@parity/product-sdk-host 0.11.0 → 0.13.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,386 @@
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
+ * Not modeled: the PAPI `chain` JSON-RPC surface behind `getHostProvider()` —
14
+ * there's no chain-read fake, by design; the host owns RPC selection — and the
15
+ * `chat` / `entropy` / `notifications` / `payment` / `permissions` /
16
+ * `resourceAllocation` / `theme` domains. Touching an unmodeled domain throws a
17
+ * descriptive error rather than failing with `undefined is not a function`.
18
+ *
19
+ * @packageDocumentation
20
+ */
21
+ import type { ObservableLike, Observer, Subscription, TrUApiClient } from "@parity/truapi";
22
+ import { okAsync } from "neverthrow";
23
+
24
+ import { setTruApiClient } from "./transport.js";
25
+
26
+ export { setTruApiClient };
27
+
28
+ /**
29
+ * The public surface of a generated truapi domain client. `keyof` skips private
30
+ * members, so this is the shape a plain object can implement — the classes
31
+ * themselves are unimplementable outside truapi because of their
32
+ * `private transport`.
33
+ */
34
+ type PublicSurface<C> = { [K in keyof C]: C[K] };
35
+
36
+ /**
37
+ * `TrUApiClient` seen through public surfaces only. The fake is checked against
38
+ * this member-by-member, so drift from the generated client — a renamed method,
39
+ * a changed request or response type — fails compilation here instead of
40
+ * surfacing as a runtime mismatch in consumers' tests.
41
+ */
42
+ type PublicTruApiClient = { [D in keyof TrUApiClient]: PublicSurface<TrUApiClient[D]> };
43
+
44
+ /** Inlined to keep the `truapi` facade out of this entry's bundle. */
45
+ function toHex(bytes: Uint8Array): `0x${string}` {
46
+ return `0x${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
47
+ }
48
+
49
+ function fakeSubscription(): Subscription {
50
+ return { unsubscribe: () => {}, subscriptionId: "fake-subscription" };
51
+ }
52
+
53
+ /**
54
+ * Wrap a `subscribe` implementation into a full `ObservableLike`. The
55
+ * `Symbol.observable` interop member only lands on the object when the symbol
56
+ * is polyfilled (e.g. by rxjs) — same as the generated client — and nothing in
57
+ * the SDK reads it; it exists to satisfy the interface.
58
+ */
59
+ function makeObservable<Item, Reason = never>(
60
+ subscribe: (observer?: Partial<Observer<Item, Reason>>) => Subscription,
61
+ ): ObservableLike<Item, Reason> {
62
+ const observable: ObservableLike<Item, Reason> = {
63
+ subscribe,
64
+ [Symbol.observable]() {
65
+ return observable;
66
+ },
67
+ };
68
+ return observable;
69
+ }
70
+
71
+ /** An `ObservableLike` that never emits. Drive statement delivery with `createFakeStatementTransport`. */
72
+ function inertObservable<Item, Reason = never>(): ObservableLike<Item, Reason> {
73
+ return makeObservable(() => fakeSubscription());
74
+ }
75
+
76
+ /**
77
+ * An `ObservableLike` that emits `item` once, asynchronously. The microtask defer
78
+ * matters: the caller assigns its subscription handle from the return value, so
79
+ * the item must arrive after `subscribe()` returns, not during it.
80
+ */
81
+ function oneShotObservable<Item>(item: Item): ObservableLike<Item> {
82
+ return makeObservable((observer) => {
83
+ queueMicrotask(() => observer?.next?.(item));
84
+ return fakeSubscription();
85
+ });
86
+ }
87
+
88
+ /**
89
+ * A domain the fake deliberately doesn't model (see the module header). Any
90
+ * member access throws with a pointer here, instead of the bare TypeError an
91
+ * empty stub would give. The empty-object cast is the one concession a Proxy
92
+ * needs; every modeled domain is checked structurally.
93
+ */
94
+ function notModeled<D extends keyof TrUApiClient>(domain: D): PublicSurface<TrUApiClient[D]> {
95
+ return new Proxy({} as PublicSurface<TrUApiClient[D]>, {
96
+ get(_target, member) {
97
+ // Stay quiet for inspection probes (console.log, await-resolution).
98
+ if (typeof member === "symbol" || member === "then") return undefined;
99
+ throw new Error(
100
+ `createFakeTruApiClient: \`${domain}.${member}\` is not modeled by the fake. See the @parity/product-sdk-host/testing module docs for what is covered.`,
101
+ );
102
+ },
103
+ });
104
+ }
105
+
106
+ /** Deterministic `0x`-prefixed preimage key derived from a hex value (FNV-1a). */
107
+ function preimageKey(hexValue: string): `0x${string}` {
108
+ let h = 0x811c9dc5;
109
+ for (let i = 0; i < hexValue.length; i++) {
110
+ h ^= hexValue.charCodeAt(i);
111
+ h = Math.imul(h, 0x01000193);
112
+ }
113
+ return `0x${(h >>> 0).toString(16).padStart(8, "0")}`;
114
+ }
115
+
116
+ /** Options for {@link createFakeTruApiClient}. */
117
+ export interface CreateFakeTruApiClientOptions {
118
+ /** `account.getUserId` primary username. Default `"alice.dot"`. */
119
+ primaryUsername?: string;
120
+ /** Product-account public key. Default 32 bytes of `0x11`. */
121
+ publicKey?: Uint8Array;
122
+ /** Bytes returned by the signing domain (signatures / proofs / transactions). Default 64 bytes of `0x22`. */
123
+ signature?: Uint8Array;
124
+ /** Whether `system.featureSupported` reports the chain supported. Default `true`. */
125
+ chainSupported?: boolean;
126
+ /** Seed the in-memory host localStorage, keyed by storage key. */
127
+ localStorage?: Record<string, Uint8Array>;
128
+ /** Legacy accounts returned by `account.getLegacyAccounts`. Default none. */
129
+ legacyAccounts?: Array<{ publicKey: Uint8Array; name: string }>;
130
+ /** Seed the in-memory preimage store, keyed by the `0x` preimage key. */
131
+ preimages?: Record<string, Uint8Array>;
132
+ }
133
+
134
+ /**
135
+ * Build a fake `TrUApiClient` covering the domains the host accessors use:
136
+ * `localStorage` (real in-memory KV), `account` / `signing` (canned data),
137
+ * `statementStore`, `preimage`, and `system`. Unmodeled domains (`chain` et al —
138
+ * see the module header) throw on member access.
139
+ */
140
+ export function createFakeTruApiClient(options?: CreateFakeTruApiClientOptions): TrUApiClient {
141
+ const primaryUsername = options?.primaryUsername ?? "alice.dot";
142
+ const publicKey = toHex(options?.publicKey ?? new Uint8Array(32).fill(0x11));
143
+ const signature = toHex(options?.signature ?? new Uint8Array(64).fill(0x22));
144
+ const chainSupported = options?.chainSupported ?? true;
145
+
146
+ // Real in-memory KV (hex values) so getHostLocalStorage()/createLocalKvStore() round-trip.
147
+ const kv = new Map<string, `0x${string}`>();
148
+ for (const [key, value] of Object.entries(options?.localStorage ?? {})) {
149
+ kv.set(key, toHex(value));
150
+ }
151
+
152
+ const legacyAccounts = (options?.legacyAccounts ?? []).map((a) => ({
153
+ publicKey: toHex(a.publicKey),
154
+ name: a.name,
155
+ }));
156
+
157
+ // Preimage store (hex key -> hex value), for getPreimageManager() and submit/lookup round-trips.
158
+ const preimages = new Map<string, `0x${string}`>();
159
+ for (const [key, value] of Object.entries(options?.preimages ?? {})) {
160
+ preimages.set(key, toHex(value));
161
+ }
162
+
163
+ // Typed against the generated client's public surface: every method below is
164
+ // structurally checked, so a truapi signature change breaks this file's build,
165
+ // not a consumer's test run.
166
+ const client: PublicTruApiClient = {
167
+ localStorage: {
168
+ read: ({ key }) => okAsync({ value: kv.get(key) }),
169
+ write: ({ key, value }) => {
170
+ kv.set(key, value);
171
+ return okAsync(undefined);
172
+ },
173
+ clear: ({ key }) => {
174
+ kv.delete(key);
175
+ return okAsync(undefined);
176
+ },
177
+ },
178
+ account: {
179
+ getUserId: () => okAsync({ primaryUsername }),
180
+ requestLogin: () => okAsync("Success"),
181
+ getAccount: () => okAsync({ account: { publicKey } }),
182
+ getAccountAlias: () =>
183
+ okAsync({ context: toHex(new Uint8Array([1])), alias: toHex(new Uint8Array([2])) }),
184
+ getLegacyAccounts: () => okAsync({ accounts: legacyAccounts }),
185
+ createAccountProof: () => okAsync({ proof: signature }),
186
+ connectionStatusSubscribe: () => inertObservable(),
187
+ },
188
+ signing: {
189
+ createTransaction: () => okAsync({ transaction: signature }),
190
+ createTransactionWithLegacyAccount: () => okAsync({ transaction: signature }),
191
+ signRaw: () => okAsync({ signature }),
192
+ signRawWithLegacyAccount: () => okAsync({ signature }),
193
+ signPayload: () => okAsync({ signature }),
194
+ signPayloadWithLegacyAccount: () => okAsync({ signature }),
195
+ },
196
+ statementStore: {
197
+ subscribe: () => inertObservable(),
198
+ createProof: () =>
199
+ okAsync({ proof: { tag: "Sr25519", value: { signature, signer: publicKey } } }),
200
+ createProofAuthorized: () =>
201
+ okAsync({ proof: { tag: "Sr25519", value: { signature, signer: publicKey } } }),
202
+ submit: () => okAsync(undefined),
203
+ },
204
+ system: {
205
+ handshake: () => okAsync(undefined),
206
+ featureSupported: () => okAsync({ supported: chainSupported }),
207
+ navigateTo: () => okAsync(undefined),
208
+ },
209
+ preimage: {
210
+ lookupSubscribe: ({ request: { key } }) =>
211
+ oneShotObservable({ value: preimages.get(key) }),
212
+ submit: (value) => {
213
+ const key = preimageKey(value);
214
+ preimages.set(key, value);
215
+ return okAsync(key);
216
+ },
217
+ },
218
+ chain: notModeled("chain"),
219
+ chat: notModeled("chat"),
220
+ entropy: notModeled("entropy"),
221
+ notifications: notModeled("notifications"),
222
+ payment: notModeled("payment"),
223
+ permissions: notModeled("permissions"),
224
+ resourceAllocation: notModeled("resourceAllocation"),
225
+ theme: notModeled("theme"),
226
+ };
227
+
228
+ // A plain downcast, not an `unknown` bridge: each generated class is
229
+ // assignable to its `PublicSurface`, and TS has already verified every fake
230
+ // member against the generated signatures in the annotation above.
231
+ return client as TrUApiClient;
232
+ }
233
+
234
+ /**
235
+ * A `beforeEach`-registered test hook, e.g. Vitest's `onTestFinished`. Read off
236
+ * `globalThis` so we couple to no test framework: present under Vitest's globals
237
+ * mode, absent everywhere else (Jest, `node:test`, plain scripts).
238
+ */
239
+ type TestFinishedHook = (fn: () => void) => void;
240
+
241
+ /** Handle returned by {@link createFakeHost}. */
242
+ export interface FakeHost extends Disposable {
243
+ /** The injected fake client (also what `getTruApi()` returns). */
244
+ client: TrUApiClient;
245
+ /** Clear the override. Idempotent; safe to call more than once. */
246
+ dispose(): void;
247
+ /** `using host = createFakeHost()` restores the real client at scope end. */
248
+ [Symbol.dispose](): void;
249
+ }
250
+
251
+ /**
252
+ * Inject a fake client via {@link setTruApiClient} and return a disposer.
253
+ *
254
+ * Cleanup is guaranteed three ways, so a forgotten reset can't leak the override
255
+ * into the next test or file: prefer `using` (scope-end disposal), otherwise the
256
+ * handle self-registers `onTestFinished` when created inside a Vitest run, and
257
+ * `dispose()` is always there to call by hand.
258
+ *
259
+ * @example
260
+ * ```ts
261
+ * import { createFakeHost } from "@parity/product-sdk-host/testing";
262
+ *
263
+ * test("host storage is reachable", async () => {
264
+ * using host = createFakeHost({ primaryUsername: "carol.dot" });
265
+ * // getHostLocalStorage(), getAccountsProvider(), a default SignerManager, etc.
266
+ * // resolve the fake; the real client is restored when the test scope ends.
267
+ * });
268
+ * ```
269
+ */
270
+ export function createFakeHost(options?: CreateFakeTruApiClientOptions): FakeHost {
271
+ const client = createFakeTruApiClient(options);
272
+ setTruApiClient(client);
273
+
274
+ let disposed = false;
275
+ const dispose = () => {
276
+ if (disposed) return;
277
+ disposed = true;
278
+ setTruApiClient(null);
279
+ };
280
+
281
+ // Best-effort auto-cleanup for the common case (a bare `const host =
282
+ // createFakeHost()` with no `using` and no manual reset). Under Vitest's
283
+ // globals mode `onTestFinished` is on `globalThis`; elsewhere it's absent and
284
+ // we quietly rely on `using` / manual `dispose()`.
285
+ const onTestFinished = (globalThis as { onTestFinished?: TestFinishedHook }).onTestFinished;
286
+ if (typeof onTestFinished === "function") {
287
+ try {
288
+ onTestFinished(dispose);
289
+ } catch {
290
+ // Not inside a running test (e.g. called at module scope) — ignore.
291
+ }
292
+ }
293
+
294
+ return {
295
+ client,
296
+ dispose,
297
+ [Symbol.dispose]: dispose,
298
+ };
299
+ }
300
+
301
+ if (import.meta.vitest) {
302
+ // Round-trip guard: drive the fake through the *real* host accessors.
303
+ const { describe, test, expect, afterEach } = import.meta.vitest;
304
+ const { getHostLocalStorage, getStatementStore, isInsideContainer } = await import(
305
+ "./container.js"
306
+ );
307
+ const { getAccountsProvider } = await import("./accounts.js");
308
+ const { getPreimageManager } = await import("./truapi.js");
309
+
310
+ const lookupOnce = (
311
+ manager: NonNullable<Awaited<ReturnType<typeof getPreimageManager>>>,
312
+ key: `0x${string}`,
313
+ ) =>
314
+ new Promise<Uint8Array | null>((resolve) => {
315
+ manager.lookup(key, (preimage) => {
316
+ if (preimage !== null) resolve(preimage);
317
+ });
318
+ });
319
+
320
+ afterEach(() => setTruApiClient(null));
321
+
322
+ describe("createFakeHost / createFakeTruApiClient", () => {
323
+ test("host localStorage round-trips through the real adapter", async () => {
324
+ createFakeHost();
325
+ const ls = await getHostLocalStorage();
326
+ expect(ls).not.toBeNull();
327
+ await ls?.writeString("k", "v");
328
+ expect(await ls?.readString("k")).toBe("v");
329
+ await ls?.writeJSON("j", { a: 1 });
330
+ expect(await ls?.readJSON("j")).toEqual({ a: 1 });
331
+ expect(await ls?.readString("missing")).toBe("");
332
+ });
333
+
334
+ test("seeded localStorage is readable", async () => {
335
+ createFakeHost({ localStorage: { greeting: new TextEncoder().encode("hi") } });
336
+ const ls = await getHostLocalStorage();
337
+ expect(await ls?.readString("greeting")).toBe("hi");
338
+ });
339
+
340
+ test("accounts provider resolves the configured user", async () => {
341
+ createFakeHost({ primaryUsername: "carol.dot" });
342
+ const accounts = await getAccountsProvider();
343
+ expect(accounts).not.toBeNull();
344
+ const userId = await accounts?.getUserId().match(
345
+ (v) => v,
346
+ () => null,
347
+ );
348
+ expect(userId?.primaryUsername).toBe("carol.dot");
349
+ });
350
+
351
+ test("statement store resolves", async () => {
352
+ createFakeHost();
353
+ expect(await getStatementStore()).not.toBeNull();
354
+ });
355
+
356
+ test("preimage manager reads seeded bytes and round-trips submit -> lookup", async () => {
357
+ createFakeHost({ preimages: { "0xabc": new Uint8Array([1, 2, 3]) } });
358
+ const pm = await getPreimageManager();
359
+ expect(pm).not.toBeNull();
360
+
361
+ const seeded = await lookupOnce(pm!, "0xabc");
362
+ expect(Array.from(seeded ?? [])).toEqual([1, 2, 3]);
363
+
364
+ const key = await pm!.submit(new Uint8Array([9, 9]));
365
+ const back = await lookupOnce(pm!, key);
366
+ expect(Array.from(back ?? [])).toEqual([9, 9]);
367
+ });
368
+
369
+ test("isInsideContainer is true while set, false after dispose", async () => {
370
+ const host = createFakeHost();
371
+ expect(await isInsideContainer()).toBe(true);
372
+ host.dispose();
373
+ expect(await isInsideContainer()).toBe(false);
374
+ expect(await getHostLocalStorage()).toBeNull();
375
+ });
376
+
377
+ test("Symbol.dispose clears the override and dispose is idempotent", async () => {
378
+ {
379
+ using host = createFakeHost();
380
+ expect(await isInsideContainer()).toBe(true);
381
+ host.dispose(); // explicit + scope-end Symbol.dispose must not double-clear badly
382
+ }
383
+ expect(await isInsideContainer()).toBe(false);
384
+ });
385
+ });
386
+ }
package/src/theme.ts CHANGED
@@ -1,74 +1,52 @@
1
1
  // Copyright 2026 Parity Technologies (UK) Ltd.
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  /**
4
- * Higher-level wrapper for the host's theme subscription.
4
+ * Higher-level wrapper for the host's theme subscription, backed by
5
+ * `truApi.theme.subscribe`.
5
6
  *
6
- * `hostApi.themeSubscribe` is reachable via {@link getTruApi}, but consumers
7
- * have to wire the subscription envelope themselves. `getThemeProvider`
8
- * returns the `@novasamatech/host-api-wrapper` theme provider object directly,
9
- * giving callers a `subscribeTheme(cb)` method that resolves to a typed
10
- * {@link ThemeMode} — a `{ name, variant }` struct where `variant` is
11
- * `"Light" | "Dark"` — and yields a `Subscription<void>` handle.
12
- *
13
- * @remarks
14
- * As of `host-api(-wrapper)` v0.8 the theme payload is a struct, not a flat
15
- * `"light" | "dark"` string: read {@link ThemeMode.variant} for the
16
- * light/dark value (now capitalized) and {@link ThemeMode.name} for the
17
- * active theme name (`Default`, or `Custom` carrying a string id).
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`).
18
12
  *
19
13
  * @module
20
14
  */
21
15
 
22
- import { createLogger } from "@parity/product-sdk-logger";
23
-
24
- import type {
25
- createThemeProvider,
26
- ThemeMode as NovasamaThemeMode,
27
- } from "@novasamatech/host-api-wrapper";
28
-
29
- const log = createLogger("host:theme");
16
+ import type { HostThemeSubscribeItem, TrUApiClient } from "@parity/truapi";
30
17
 
31
- /**
32
- * Host theme provider handle. Exposes `subscribeTheme(callback)` which
33
- * receives a typed {@link ThemeMode} struct on every change and returns a
34
- * `Subscription<void>` (`unsubscribe` + `onInterrupt`).
35
- *
36
- * Type identical to `createThemeProvider()` from
37
- * `@novasamatech/host-api-wrapper`.
38
- */
39
- export type ThemeProvider = ReturnType<typeof createThemeProvider>;
18
+ import { getClient, subscribeWithInterrupt } from "./transport.js";
19
+ import type { HostSubscription } from "./types.js";
40
20
 
41
21
  /**
42
- * Host theme value. Re-exported from `@novasamatech/host-api-wrapper`.
43
- *
44
- * A `{ name, variant }` struct as of v0.8 (previously a flat
45
- * `"light" | "dark"` string).
22
+ * Host theme value. A `{ name, variant }` struct re-exported from
23
+ * `@parity/truapi`.
46
24
  */
47
- export type ThemeMode = NovasamaThemeMode;
25
+ export type ThemeMode = HostThemeSubscribeItem;
48
26
 
49
- /** Light/dark variant of the active theme: `"Light" | "Dark"`. */
50
- export type ThemeVariant = ThemeMode["variant"];
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";
51
29
 
52
30
  /**
53
- * Active theme name: `{ tag: "Default" }`, or `{ tag: "Custom", value }`
54
- * carrying the custom theme's string id.
31
+ * Host theme provider handle. `subscribeTheme(callback)` receives a typed
32
+ * {@link ThemeMode} on every change and returns a {@link HostSubscription}.
55
33
  */
56
- export type ThemeName = ThemeMode["name"];
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
+ }
57
46
 
58
47
  /**
59
- * Get the host theme provider.
60
- *
61
- * Returns the theme-subscription handle exported by
62
- * `@novasamatech/host-api-wrapper`, or `null` if the package is unavailable
63
- * (running outside a host container or the optional peer dep isn't
64
- * installed).
65
- *
66
- * Implementation note: upstream `@novasamatech/host-api-wrapper` exports only
67
- * the `createThemeProvider` factory and no `themeProvider` singleton, so
68
- * this getter constructs a fresh instance on each call (unlike
69
- * {@link getPreimageManager} or {@link getHostLocalStorage}, which return
70
- * upstream singletons). The constructed provider is cheap to allocate; it
71
- * only opens a subscription when `subscribeTheme` is called.
48
+ * Get the host theme provider, backed by `truApi.theme.*`. Returns `null` when
49
+ * running outside a host container.
72
50
  *
73
51
  * @returns The theme provider, or `null` if unavailable.
74
52
  *
@@ -87,20 +65,14 @@ export type ThemeName = ThemeMode["name"];
87
65
  * ```
88
66
  */
89
67
  export async function getThemeProvider(): Promise<ThemeProvider | null> {
90
- try {
91
- const sdk = await import("@novasamatech/host-api-wrapper");
92
- return sdk.createThemeProvider();
93
- } catch (err) {
94
- log.debug("getThemeProvider unavailable", err);
95
- return null;
96
- }
68
+ const client = await getClient();
69
+ return client ? adaptThemeProvider(client) : null;
97
70
  }
98
71
 
99
72
  if (import.meta.vitest) {
100
73
  const { test, expect } = import.meta.vitest;
101
74
 
102
- test("getThemeProvider returns provider when SDK is available", async () => {
103
- const provider = await getThemeProvider();
104
- expect(provider === null || typeof provider === "object").toBe(true);
75
+ test("getThemeProvider returns null outside a container", async () => {
76
+ expect(await getThemeProvider()).toBeNull();
105
77
  });
106
78
  }
@@ -0,0 +1,159 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Access to the in-house TruAPI client (`@parity/truapi`) for the host package.
5
+ *
6
+ * Environment detection and the lazily-built, cached client come from
7
+ * `@parity/truapi/sandbox`; this module layers the product-sdk-specific glue on
8
+ * top — an async {@link getClient} accessor and {@link subscribeWithInterrupt},
9
+ * which adapts a truapi stream into the host's {@link HostSubscription} shape.
10
+ *
11
+ * @module
12
+ */
13
+
14
+ import type { ObservableLike, TrUApiClient } from "@parity/truapi";
15
+ import {
16
+ getClientSync as sandboxGetClientSync,
17
+ isCorrectEnvironment as sandboxIsCorrectEnvironment,
18
+ } from "@parity/truapi/sandbox";
19
+
20
+ import type { HostSubscription } from "./types.js";
21
+
22
+ // Test-only override. When set — via `setTruApiClient`, exposed through
23
+ // `@parity/product-sdk-host/testing` — every host accessor resolves this client
24
+ // instead of the sandbox one. `null` in production, so the branches below are
25
+ // no-ops there.
26
+ let clientOverride: TrUApiClient | null = null;
27
+
28
+ function isProductionBuild(): boolean {
29
+ try {
30
+ // Must stay a plain `process.env.NODE_ENV` member expression: bundlers
31
+ // (Vite, esbuild, webpack) substitute it textually, which is how this
32
+ // check works in browser builds where `process` doesn't exist.
33
+ return process.env.NODE_ENV === "production";
34
+ } catch {
35
+ // No `process` and no bundler define — can't tell, stay quiet.
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Test-only seam: force {@link getClient} / {@link getClientSync} to return
42
+ * `client`, and {@link isCorrectEnvironment} to report `true`. Pass `null` to
43
+ * restore normal detection. Exposed through `@parity/product-sdk-host/testing`,
44
+ * not the package's main entry.
45
+ *
46
+ * Calling this in a production build silently reroutes every host accessor to
47
+ * the injected client, so we warn — it almost always means a `/testing` import
48
+ * leaked into a production path.
49
+ */
50
+ export function setTruApiClient(client: TrUApiClient | null): void {
51
+ if (client !== null && isProductionBuild()) {
52
+ console.warn(
53
+ "[product-sdk] setTruApiClient() was called in a production build. This is a test-only seam from @parity/product-sdk-host/testing; a leaked import will silently reroute all host access to the injected client.",
54
+ );
55
+ }
56
+ clientOverride = client;
57
+ }
58
+
59
+ /**
60
+ * Synchronous TruAPI client accessor. Returns the injected test client when one
61
+ * is set, otherwise the sandbox client (`null` outside a host container).
62
+ */
63
+ export function getClientSync(): TrUApiClient | null {
64
+ return clientOverride ?? sandboxGetClientSync();
65
+ }
66
+
67
+ /**
68
+ * Host-container detection. `true` when a test client is injected, otherwise the
69
+ * sandbox heuristic (iframe / webview marker / injected message port).
70
+ */
71
+ export function isCorrectEnvironment(): boolean {
72
+ return clientOverride !== null || sandboxIsCorrectEnvironment();
73
+ }
74
+
75
+ /**
76
+ * Get the TruAPI client. Returns `null` outside a host container. Async wrapper
77
+ * over {@link getClientSync} for the host wrappers that already `await` it.
78
+ */
79
+ export async function getClient(): Promise<TrUApiClient | null> {
80
+ return getClientSync();
81
+ }
82
+
83
+ /**
84
+ * Adapt a truapi `ObservableLike` stream into the host's callback-style
85
+ * {@link HostSubscription} (`unsubscribe` + `onInterrupt`). `onNext` fires for
86
+ * each item; the registered `onInterrupt` callback fires when the host ends the
87
+ * subscription server-side — which the generated client surfaces as either
88
+ * `complete` (a host interrupt frame) or `error` (transport close). Shared by
89
+ * the statement-store and preimage adapters, which both expose this shape.
90
+ */
91
+ export function subscribeWithInterrupt<Item, Reason = never>(
92
+ observable: ObservableLike<Item, Reason>,
93
+ onNext: (item: Item) => void,
94
+ ): HostSubscription {
95
+ let interruptCallback: ((reason?: unknown) => void) | undefined;
96
+ const sub = observable.subscribe({
97
+ next: onNext,
98
+ error: (reason) => interruptCallback?.(reason),
99
+ complete: () => interruptCallback?.(),
100
+ });
101
+ return {
102
+ unsubscribe: () => sub.unsubscribe(),
103
+ onInterrupt: (callback) => {
104
+ interruptCallback = callback;
105
+ return () => {
106
+ if (interruptCallback === callback) interruptCallback = undefined;
107
+ };
108
+ },
109
+ };
110
+ }
111
+
112
+ if (import.meta.vitest) {
113
+ const { test, expect, afterEach } = import.meta.vitest;
114
+
115
+ afterEach(() => setTruApiClient(null));
116
+
117
+ // Environment detection and client building are covered by `@parity/truapi`'s
118
+ // own sandbox tests; here we only assert the local glue degrades outside a
119
+ // host container.
120
+ test("getClientSync returns null outside a container", () => {
121
+ expect(getClientSync()).toBeNull();
122
+ });
123
+
124
+ test("getClient resolves null outside a container", async () => {
125
+ expect(await getClient()).toBeNull();
126
+ });
127
+
128
+ test("setTruApiClient overrides the client and container detection", async () => {
129
+ const fake = {} as TrUApiClient;
130
+ setTruApiClient(fake);
131
+ expect(getClientSync()).toBe(fake);
132
+ expect(await getClient()).toBe(fake);
133
+ expect(isCorrectEnvironment()).toBe(true);
134
+
135
+ setTruApiClient(null);
136
+ expect(getClientSync()).toBeNull();
137
+ expect(isCorrectEnvironment()).toBe(false);
138
+ });
139
+
140
+ test("setTruApiClient warns when injecting in a production build", () => {
141
+ const original = process.env.NODE_ENV;
142
+ const warnings: string[] = [];
143
+ const realWarn = console.warn;
144
+ console.warn = (...args: unknown[]) => void warnings.push(String(args[0]));
145
+ try {
146
+ process.env.NODE_ENV = "production";
147
+ setTruApiClient({} as TrUApiClient);
148
+ expect(warnings).toHaveLength(1);
149
+ expect(warnings[0]).toContain("production build");
150
+
151
+ // Clearing the override must not warn.
152
+ setTruApiClient(null);
153
+ expect(warnings).toHaveLength(1);
154
+ } finally {
155
+ console.warn = realWarn;
156
+ process.env.NODE_ENV = original;
157
+ }
158
+ });
159
+ }