@tangle-network/blueprint-ui 0.3.1 → 0.5.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.
@@ -0,0 +1,317 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { TangleIframeClient } from './tangleIframeClient';
4
+ import { HARNESS_ORIGIN } from './testing';
5
+
6
+ const PARENT_ORIGIN = HARNESS_ORIGIN;
7
+
8
+ /**
9
+ * Drive the client against a fake parent that lives in the same window.
10
+ * Production flow goes iframe → window.parent (a different window); these
11
+ * tests collapse the two windows for assertability while keeping the
12
+ * exact protocol surface.
13
+ */
14
+ function setupFakeParent() {
15
+ const captured: object[] = [];
16
+ const originalParent = window.parent;
17
+ Object.defineProperty(window, 'parent', {
18
+ configurable: true,
19
+ get: () => ({
20
+ postMessage: (message: object) => {
21
+ captured.push(message);
22
+ },
23
+ }),
24
+ });
25
+ const restore = () => {
26
+ Object.defineProperty(window, 'parent', {
27
+ configurable: true,
28
+ value: originalParent,
29
+ });
30
+ };
31
+ const sendFromParent = (data: object) =>
32
+ window.dispatchEvent(new MessageEvent('message', { data, origin: PARENT_ORIGIN }));
33
+ return { captured, sendFromParent, restore };
34
+ }
35
+
36
+ describe('TangleIframeClient', () => {
37
+ let fake: ReturnType<typeof setupFakeParent>;
38
+ let client: TangleIframeClient;
39
+
40
+ beforeEach(() => {
41
+ fake = setupFakeParent();
42
+ client = new TangleIframeClient({
43
+ parentOrigin: PARENT_ORIGIN,
44
+ appId: 'test-app',
45
+ requestTimeoutMs: 1_000,
46
+ });
47
+ });
48
+
49
+ afterEach(() => {
50
+ client.uninstall();
51
+ fake.restore();
52
+ });
53
+
54
+ it('posts a versioned handshake on install', () => {
55
+ client.install();
56
+ expect(fake.captured[0]).toEqual({
57
+ kind: 'tangle.app.handshake',
58
+ appId: 'test-app',
59
+ version: '1',
60
+ });
61
+ });
62
+
63
+ it('emits a wallet snapshot when the parent broadcasts accountChanged', () => {
64
+ client.install();
65
+ const seen: unknown[] = [];
66
+ client.subscribe('wallet', (snap) => seen.push(snap));
67
+ fake.sendFromParent({
68
+ kind: 'tangle.app.accountChanged',
69
+ account: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
70
+ });
71
+ expect(seen).toEqual([
72
+ {
73
+ address: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
74
+ chainId: null,
75
+ isConnected: true,
76
+ },
77
+ ]);
78
+ });
79
+
80
+ it('emits a service snapshot when the parent broadcasts serviceContext', () => {
81
+ client.install();
82
+ const seen: unknown[] = [];
83
+ client.subscribe('service', (snap) => seen.push(snap));
84
+ fake.sendFromParent({
85
+ kind: 'tangle.app.serviceContext',
86
+ blueprintId: '12',
87
+ serviceId: '42',
88
+ operators: [
89
+ {
90
+ address: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8',
91
+ rpcAddress: 'http://op1:8000',
92
+ status: 'active',
93
+ },
94
+ ],
95
+ jobs: [{ index: 0, name: 'invoke' }],
96
+ mode: 'cloud',
97
+ });
98
+ expect(seen).toHaveLength(1);
99
+ const snap = seen[0] as {
100
+ blueprintId: string;
101
+ serviceId: string;
102
+ operators: unknown[];
103
+ jobs: unknown[];
104
+ };
105
+ expect(snap.blueprintId).toBe('12');
106
+ expect(snap.serviceId).toBe('42');
107
+ expect(snap.operators).toHaveLength(1);
108
+ expect(snap.jobs).toHaveLength(1);
109
+ });
110
+
111
+ it('routes callJob requests + resolves on success terminal status', async () => {
112
+ client.install();
113
+ fake.sendFromParent({
114
+ kind: 'tangle.app.handshakeAck',
115
+ appId: 'test-app',
116
+ protocolVersion: '1',
117
+ });
118
+ const call = client.callJob({
119
+ jobIndex: 0,
120
+ inputs: { prompt: 'hi' },
121
+ });
122
+ // First captured message is the handshake; the callJob is somewhere after.
123
+ const outbound = await vi.waitFor(() => {
124
+ const msg = fake.captured.find(
125
+ (c) => (c as { kind?: string }).kind === 'tangle.app.callJob',
126
+ );
127
+ if (!msg) throw new Error('callJob not posted yet');
128
+ return msg as { correlationId: string; inputs: Record<string, unknown> };
129
+ });
130
+ expect(outbound.inputs).toEqual({ prompt: 'hi' });
131
+ // Reply with terminal success
132
+ fake.sendFromParent({
133
+ kind: 'tangle.app.jobResult',
134
+ correlationId: outbound.correlationId,
135
+ status: 'success',
136
+ data: { text: 'hello world' },
137
+ });
138
+ const result = await call;
139
+ expect(result.status).toBe('success');
140
+ expect(result.data).toEqual({ text: 'hello world' });
141
+ });
142
+
143
+ it('accumulates streaming chunks in invocation state', async () => {
144
+ client.install();
145
+ fake.sendFromParent({
146
+ kind: 'tangle.app.handshakeAck',
147
+ appId: 'test-app',
148
+ protocolVersion: '1',
149
+ });
150
+ const jobEvents: unknown[] = [];
151
+ client.subscribe('job', (inv) => jobEvents.push(inv));
152
+ const call = client.callJob({
153
+ jobIndex: 0,
154
+ inputs: { prompt: 'stream' },
155
+ stream: true,
156
+ });
157
+ const outbound = await vi.waitFor(() => {
158
+ const msg = fake.captured.find(
159
+ (c) => (c as { kind?: string }).kind === 'tangle.app.callJob',
160
+ );
161
+ if (!msg) throw new Error('callJob not posted yet');
162
+ return msg as { correlationId: string };
163
+ });
164
+ // Stream chunks then terminal
165
+ fake.sendFromParent({
166
+ kind: 'tangle.app.jobResult',
167
+ correlationId: outbound.correlationId,
168
+ status: 'streaming',
169
+ chunk: 'hel',
170
+ });
171
+ fake.sendFromParent({
172
+ kind: 'tangle.app.jobResult',
173
+ correlationId: outbound.correlationId,
174
+ status: 'streaming',
175
+ chunk: 'lo',
176
+ });
177
+ fake.sendFromParent({
178
+ kind: 'tangle.app.jobResult',
179
+ correlationId: outbound.correlationId,
180
+ status: 'success',
181
+ data: { text: 'hello' },
182
+ });
183
+ const result = await call;
184
+ expect(result.chunks).toEqual(['hel', 'lo']);
185
+ expect(result.data).toEqual({ text: 'hello' });
186
+ // Job events: initial pending + 2 streaming + final success = 4
187
+ expect(jobEvents).toHaveLength(4);
188
+ });
189
+
190
+ it('rejects callJob on parent error', async () => {
191
+ client.install();
192
+ fake.sendFromParent({
193
+ kind: 'tangle.app.handshakeAck',
194
+ appId: 'test-app',
195
+ protocolVersion: '1',
196
+ });
197
+ const call = client.callJob({ jobIndex: 0, inputs: {} });
198
+ const outbound = await vi.waitFor(() => {
199
+ const msg = fake.captured.find(
200
+ (c) => (c as { kind?: string }).kind === 'tangle.app.callJob',
201
+ );
202
+ if (!msg) throw new Error('callJob not posted yet');
203
+ return msg as { correlationId: string };
204
+ });
205
+ fake.sendFromParent({
206
+ kind: 'tangle.app.jobResult',
207
+ correlationId: outbound.correlationId,
208
+ status: 'error',
209
+ error: 'operator-unavailable',
210
+ });
211
+ await expect(call).rejects.toThrow('operator-unavailable');
212
+ });
213
+
214
+ it('ignores parent messages from untrusted origins', () => {
215
+ client.install();
216
+ const seen: unknown[] = [];
217
+ client.subscribe('wallet', (snap) => seen.push(snap));
218
+ window.dispatchEvent(
219
+ new MessageEvent('message', {
220
+ data: {
221
+ kind: 'tangle.app.accountChanged',
222
+ account: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef',
223
+ },
224
+ origin: 'https://evil.example.com',
225
+ }),
226
+ );
227
+ expect(seen).toEqual([]);
228
+ });
229
+
230
+ it('forwards signTypedData through the bridge with the full EIP-712 payload', async () => {
231
+ client.install();
232
+ fake.sendFromParent({
233
+ kind: 'tangle.app.handshakeAck',
234
+ appId: 'test-app',
235
+ protocolVersion: '1',
236
+ });
237
+ fake.sendFromParent({ kind: 'tangle.app.chainChanged', chainId: 84532 });
238
+ const payload = {
239
+ domain: {
240
+ name: 'TradingArena',
241
+ version: '1',
242
+ chainId: 84532,
243
+ verifyingContract:
244
+ '0x0000000000000000000000000000000000000abc' as `0x${string}`,
245
+ },
246
+ types: {
247
+ Envelope: [
248
+ { name: 'trader', type: 'address' },
249
+ { name: 'qty', type: 'uint256' },
250
+ { name: 'price', type: 'uint256' },
251
+ { name: 'nonce', type: 'uint256' },
252
+ ],
253
+ },
254
+ primaryType: 'Envelope',
255
+ message: {
256
+ trader: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
257
+ qty: '100',
258
+ price: '4200',
259
+ nonce: '7',
260
+ } as Readonly<Record<string, unknown>>,
261
+ };
262
+ const signed = client.signTypedData(payload);
263
+ const outbound = await vi.waitFor(() => {
264
+ const msg = fake.captured.find(
265
+ (c) => (c as { kind?: string }).kind === 'tangle.app.signTypedData',
266
+ );
267
+ if (!msg) throw new Error('signTypedData not posted yet');
268
+ return msg as {
269
+ correlationId: string;
270
+ primaryType: string;
271
+ domain: { chainId?: number };
272
+ types: Record<string, unknown>;
273
+ message: Record<string, unknown>;
274
+ };
275
+ });
276
+ expect(outbound.primaryType).toBe('Envelope');
277
+ expect(outbound.domain.chainId).toBe(84532);
278
+ expect(outbound.types).toHaveProperty('Envelope');
279
+ expect((outbound.message as { qty: string }).qty).toBe('100');
280
+ fake.sendFromParent({
281
+ kind: 'tangle.app.signTypedDataResult',
282
+ correlationId: outbound.correlationId,
283
+ ok: true,
284
+ data: { signature: '0xabcdef' as `0x${string}` },
285
+ });
286
+ await expect(signed).resolves.toBe('0xabcdef');
287
+ });
288
+
289
+ it('emits chain context as part of the service snapshot', () => {
290
+ client.install();
291
+ const seen: unknown[] = [];
292
+ client.subscribe('service', (snap) => seen.push(snap));
293
+ fake.sendFromParent({
294
+ kind: 'tangle.app.serviceContext',
295
+ blueprintId: '12',
296
+ serviceId: '42',
297
+ operators: [],
298
+ jobs: [],
299
+ mode: null,
300
+ chain: {
301
+ id: 84532,
302
+ name: 'Base Sepolia',
303
+ rpcUrl: 'https://sepolia.base.org',
304
+ blockExplorerUrl: 'https://sepolia.basescan.org',
305
+ nativeCurrency: {
306
+ name: 'Sepolia Ether',
307
+ symbol: 'ETH',
308
+ decimals: 18,
309
+ },
310
+ },
311
+ });
312
+ expect(seen).toHaveLength(1);
313
+ const snap = seen[0] as { chain: { id: number; rpcUrl: string } | null };
314
+ expect(snap.chain?.id).toBe(84532);
315
+ expect(snap.chain?.rpcUrl).toBe('https://sepolia.base.org');
316
+ });
317
+ });