@learncard/partner-connect 0.3.10 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +184 -0
- package/dist/index.d.ts +209 -3
- package/dist/partner-connect.esm.js +822 -9
- package/dist/partner-connect.esm.js.map +1 -1
- package/dist/partner-connect.js +822 -8
- package/dist/partner-connect.js.map +1 -1
- package/dist/partner-connect.mjs +822 -9
- package/dist/partner-connect.mjs.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +274 -4
- package/src/mock-host.ts +887 -0
- package/src/mock-mode.test.ts +478 -0
- package/src/types.ts +158 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for standalone mock mode and embed detection in
|
|
3
|
+
* @learncard/partner-connect. These run in jsdom, where `window.self` equals
|
|
4
|
+
* `window.top` (i.e. not embedded), so 'auto' mock mode is active by default.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { PartnerConnect, createPartnerConnect, isEmbedded } from './index';
|
|
8
|
+
|
|
9
|
+
const flush = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0));
|
|
10
|
+
|
|
11
|
+
let errorSpy: jest.SpyInstance;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
15
|
+
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
16
|
+
try {
|
|
17
|
+
localStorage.clear();
|
|
18
|
+
} catch {
|
|
19
|
+
// localStorage may be unavailable in some environments.
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
jest.restoreAllMocks();
|
|
25
|
+
document.querySelectorAll('.lc-mock-toast, .lc-mock-stack').forEach(node => node.remove());
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('isEmbedded', () => {
|
|
29
|
+
it('returns false at the top level (jsdom is not embedded)', () => {
|
|
30
|
+
expect(isEmbedded()).toBe(false);
|
|
31
|
+
expect(PartnerConnect.isEmbedded()).toBe(false);
|
|
32
|
+
expect(createPartnerConnect().isEmbedded()).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns true when window.self differs from window.top', () => {
|
|
36
|
+
const originalTop = Object.getOwnPropertyDescriptor(window, 'top');
|
|
37
|
+
Object.defineProperty(window, 'top', {
|
|
38
|
+
configurable: true,
|
|
39
|
+
get: () => ({} as Window),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
expect(isEmbedded()).toBe(true);
|
|
44
|
+
} finally {
|
|
45
|
+
if (originalTop) Object.defineProperty(window, 'top', originalTop);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('standalone with no host (not embedded, not mocking)', () => {
|
|
51
|
+
it('fails fast with LC_NOT_EMBEDDED instead of timing out', async () => {
|
|
52
|
+
const lc = createPartnerConnect({ mock: false });
|
|
53
|
+
await expect(lc.requestIdentity()).rejects.toMatchObject({ code: 'LC_NOT_EMBEDDED' });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('logs an actionable breadcrumb once, not per call', async () => {
|
|
57
|
+
const lc = createPartnerConnect({ mock: false });
|
|
58
|
+
await lc.requestIdentity().catch(() => undefined);
|
|
59
|
+
await lc.getSyncStatus().catch(() => undefined);
|
|
60
|
+
await lc.incrementCounter('coins', 1).catch(() => undefined);
|
|
61
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('mock mode activation', () => {
|
|
66
|
+
it('auto-activates when not embedded', () => {
|
|
67
|
+
expect(createPartnerConnect().isMocked()).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('can be forced off', () => {
|
|
71
|
+
expect(createPartnerConnect({ mock: false }).isMocked()).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('can be forced on', () => {
|
|
75
|
+
expect(createPartnerConnect({ mock: true }).isMocked()).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('does NOT auto-mock on non-local standalone hosts (deploy previews, production)', async () => {
|
|
79
|
+
const original = Object.getOwnPropertyDescriptor(window, 'location');
|
|
80
|
+
Object.defineProperty(window, 'location', {
|
|
81
|
+
configurable: true,
|
|
82
|
+
value: {
|
|
83
|
+
hostname: 'my-app.netlify.app',
|
|
84
|
+
search: '',
|
|
85
|
+
href: 'https://my-app.netlify.app/',
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const lc = createPartnerConnect();
|
|
91
|
+
expect(lc.isMocked()).toBe(false);
|
|
92
|
+
await expect(lc.requestIdentity()).rejects.toMatchObject({
|
|
93
|
+
code: 'LC_NOT_EMBEDDED',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(createPartnerConnect({ mock: true }).isMocked()).toBe(true);
|
|
97
|
+
expect(createPartnerConnect({ mock: 'standalone' }).isMocked()).toBe(true);
|
|
98
|
+
} finally {
|
|
99
|
+
if (original) Object.defineProperty(window, 'location', original);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('auto-mocks on *.local and *.localhost dev hosts', () => {
|
|
104
|
+
const original = Object.getOwnPropertyDescriptor(window, 'location');
|
|
105
|
+
|
|
106
|
+
for (const hostname of ['myapp.local', 'myapp.localhost']) {
|
|
107
|
+
Object.defineProperty(window, 'location', {
|
|
108
|
+
configurable: true,
|
|
109
|
+
value: { hostname, search: '', href: `http://${hostname}/` },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
expect(createPartnerConnect().isMocked()).toBe(true);
|
|
114
|
+
} finally {
|
|
115
|
+
if (original) Object.defineProperty(window, 'location', original);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('embedded parent classification', () => {
|
|
122
|
+
let originalTop: PropertyDescriptor | undefined;
|
|
123
|
+
let originalLocation: PropertyDescriptor | undefined;
|
|
124
|
+
|
|
125
|
+
const embedIn = (ancestorOrigin: string | null, hostname = 'localhost'): void => {
|
|
126
|
+
Object.defineProperty(window, 'top', {
|
|
127
|
+
configurable: true,
|
|
128
|
+
get: () => ({} as Window),
|
|
129
|
+
});
|
|
130
|
+
Object.defineProperty(window, 'location', {
|
|
131
|
+
configurable: true,
|
|
132
|
+
value: {
|
|
133
|
+
hostname,
|
|
134
|
+
search: '',
|
|
135
|
+
href: `http://${hostname}/`,
|
|
136
|
+
...(ancestorOrigin ? { ancestorOrigins: [ancestorOrigin] } : {}),
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
beforeEach(() => {
|
|
142
|
+
originalTop = Object.getOwnPropertyDescriptor(window, 'top');
|
|
143
|
+
originalLocation = Object.getOwnPropertyDescriptor(window, 'location');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
afterEach(() => {
|
|
147
|
+
if (originalTop) Object.defineProperty(window, 'top', originalTop);
|
|
148
|
+
if (originalLocation) Object.defineProperty(window, 'location', originalLocation);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('never mocks when the parent is a configured LearnCard origin', () => {
|
|
152
|
+
embedIn('https://learncard.app');
|
|
153
|
+
expect(createPartnerConnect().isMocked()).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('auto-mocks immediately inside a foreign (non-LearnCard) iframe on a local dev host', () => {
|
|
157
|
+
embedIn('https://storybook.example.com');
|
|
158
|
+
expect(createPartnerConnect().isMocked()).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('fails fast instead of hanging inside a foreign iframe when mocking is off', async () => {
|
|
162
|
+
embedIn('https://storybook.example.com');
|
|
163
|
+
const lc = createPartnerConnect({ mock: false });
|
|
164
|
+
await expect(lc.requestIdentity()).rejects.toMatchObject({ code: 'LC_NOT_EMBEDDED' });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('probes an ambiguous localhost parent and mocks when nothing answers', async () => {
|
|
168
|
+
embedIn('http://localhost:6006');
|
|
169
|
+
const lc = createPartnerConnect({ hostProbeTimeout: 40 });
|
|
170
|
+
expect(lc.isMocked()).toBe(false);
|
|
171
|
+
|
|
172
|
+
const identity = await lc.requestIdentity();
|
|
173
|
+
expect(lc.isMocked()).toBe(true);
|
|
174
|
+
expect(identity.user.did).toBeDefined();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("mock: 'standalone' mocks inside a foreign iframe even on a non-local host", () => {
|
|
178
|
+
embedIn('https://preview-shell.example.com', 'my-app.lovable.app');
|
|
179
|
+
expect(createPartnerConnect({ mock: 'standalone' }).isMocked()).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("mock: 'standalone' uses the real host when embedded in a LearnCard origin", () => {
|
|
183
|
+
embedIn('https://learncard.app', 'my-app.lovable.app');
|
|
184
|
+
expect(createPartnerConnect({ mock: 'standalone' }).isMocked()).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("mock: 'standalone' probes an ambiguous parent on non-local hosts and mocks when unanswered", async () => {
|
|
188
|
+
embedIn('http://localhost:6006', 'my-app.lovable.app');
|
|
189
|
+
const lc = createPartnerConnect({ mock: 'standalone', hostProbeTimeout: 40 });
|
|
190
|
+
|
|
191
|
+
const identity = await lc.requestIdentity();
|
|
192
|
+
expect(lc.isMocked()).toBe(true);
|
|
193
|
+
expect(identity.user.did).toBeDefined();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('stays in real-host mode when the probe is answered', async () => {
|
|
197
|
+
embedIn('http://localhost');
|
|
198
|
+
|
|
199
|
+
const answerProbe = (event: MessageEvent): void => {
|
|
200
|
+
const data = event.data as { action?: string; requestId?: string; protocol?: string };
|
|
201
|
+
if (data?.action !== 'GET_SYNC_STATUS' || !data.requestId) return;
|
|
202
|
+
window.postMessage(
|
|
203
|
+
{
|
|
204
|
+
protocol: data.protocol,
|
|
205
|
+
requestId: data.requestId,
|
|
206
|
+
type: 'SUCCESS',
|
|
207
|
+
data: { status: 'ready' },
|
|
208
|
+
},
|
|
209
|
+
'http://localhost'
|
|
210
|
+
);
|
|
211
|
+
};
|
|
212
|
+
window.addEventListener('message', answerProbe);
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const lc = createPartnerConnect({ hostProbeTimeout: 500 });
|
|
216
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
217
|
+
expect(lc.isMocked()).toBe(false);
|
|
218
|
+
} finally {
|
|
219
|
+
window.removeEventListener('message', answerProbe);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('mock responses', () => {
|
|
225
|
+
it('resolves requestIdentity with the configured fake DID', async () => {
|
|
226
|
+
const lc = createPartnerConnect({ mockOptions: { did: 'did:web:test:me', ui: false } });
|
|
227
|
+
const identity = await lc.requestIdentity();
|
|
228
|
+
expect(identity.token).toContain('mock-token');
|
|
229
|
+
expect(identity.user.did).toBe('did:web:test:me');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('resolves raw sendCredential with a mock credential id', async () => {
|
|
233
|
+
const lc = createPartnerConnect({ mockOptions: { ui: false } });
|
|
234
|
+
const res = (await lc.sendCredential({
|
|
235
|
+
credentialSubject: { achievement: { name: 'Course Completion' } },
|
|
236
|
+
})) as { credentialId: string };
|
|
237
|
+
expect(res.credentialId).toContain('mock-credential');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('resolves template sendCredential with mock URIs', async () => {
|
|
241
|
+
const lc = createPartnerConnect({ mockOptions: { ui: false } });
|
|
242
|
+
const res = (await lc.sendCredential({ templateAlias: 'course-completion' })) as {
|
|
243
|
+
credentialUri: string;
|
|
244
|
+
boostUri: string;
|
|
245
|
+
};
|
|
246
|
+
expect(res.credentialUri).toContain('lc:mock:credential');
|
|
247
|
+
expect(res.boostUri).toContain('course-completion');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('auto-grants requestConsent', async () => {
|
|
251
|
+
const lc = createPartnerConnect({ mockOptions: { ui: false } });
|
|
252
|
+
await expect(lc.requestConsent('lc:contract:abc')).resolves.toEqual({ granted: true });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('reports a ready sync status so onSyncComplete resolves', async () => {
|
|
256
|
+
const lc = createPartnerConnect({ mockOptions: { ui: false } });
|
|
257
|
+
await expect(lc.getSyncStatus()).resolves.toMatchObject({ status: 'ready' });
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('mock counters', () => {
|
|
262
|
+
it('increments, reads, and persists counters', async () => {
|
|
263
|
+
const lc = createPartnerConnect({ mockOptions: { ui: false } });
|
|
264
|
+
|
|
265
|
+
const first = await lc.incrementCounter('coins', 10);
|
|
266
|
+
expect(first).toEqual({ key: 'coins', previousValue: 0, newValue: 10 });
|
|
267
|
+
|
|
268
|
+
const second = await lc.incrementCounter('coins', -3);
|
|
269
|
+
expect(second.newValue).toBe(7);
|
|
270
|
+
|
|
271
|
+
const read = await lc.getCounter('coins');
|
|
272
|
+
expect(read.value).toBe(7);
|
|
273
|
+
expect(read.updatedAt).not.toBeNull();
|
|
274
|
+
|
|
275
|
+
const all = await lc.getCounters(['coins']);
|
|
276
|
+
expect(all.counters[0]).toMatchObject({ key: 'coins', value: 7 });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('persists counters across instances via localStorage', async () => {
|
|
280
|
+
const a = createPartnerConnect({ mockOptions: { ui: false } });
|
|
281
|
+
await a.incrementCounter('spins', 5);
|
|
282
|
+
|
|
283
|
+
const b = createPartnerConnect({ mockOptions: { ui: false } });
|
|
284
|
+
const read = await b.getCounter('spins');
|
|
285
|
+
expect(read.value).toBe(5);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('mock coherence (reads reflect this session writes)', () => {
|
|
290
|
+
it('checkUserHasCredential reflects a template credential issued this session', async () => {
|
|
291
|
+
const lc = createPartnerConnect({ mockOptions: { ui: false } });
|
|
292
|
+
|
|
293
|
+
const before = await lc.checkUserHasCredential({ templateAlias: 'algebra' });
|
|
294
|
+
expect(before.hasCredential).toBe(false);
|
|
295
|
+
|
|
296
|
+
await lc.sendCredential({ templateAlias: 'algebra' });
|
|
297
|
+
|
|
298
|
+
const after = await lc.checkUserHasCredential({ templateAlias: 'algebra' });
|
|
299
|
+
expect(after.hasCredential).toBe(true);
|
|
300
|
+
expect(after.credentialUri).toBeDefined();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('learner context and credential search include issued credentials', async () => {
|
|
304
|
+
const lc = createPartnerConnect({ mockOptions: { ui: false } });
|
|
305
|
+
await lc.sendCredential({ templateAlias: 'algebra' });
|
|
306
|
+
|
|
307
|
+
const context = await lc.requestLearnerContext({ format: 'structured' });
|
|
308
|
+
expect(context.raw?.credentials.length).toBe(1);
|
|
309
|
+
|
|
310
|
+
const search = await lc.askCredentialSearch({ query: [], challenge: 'c', domain: 'd' });
|
|
311
|
+
expect(search.verifiablePresentation?.verifiableCredential.length).toBe(1);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('learner context omits raw data unless format is structured', async () => {
|
|
315
|
+
const lc = createPartnerConnect({ mockOptions: { ui: false } });
|
|
316
|
+
await lc.sendCredential({ templateAlias: 'algebra' });
|
|
317
|
+
|
|
318
|
+
const context = await lc.requestLearnerContext();
|
|
319
|
+
expect(context.raw).toBeUndefined();
|
|
320
|
+
expect(context.prompt).toContain('algebra');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('learner context respects includeCredentials: false', async () => {
|
|
324
|
+
const lc = createPartnerConnect({ mockOptions: { ui: false } });
|
|
325
|
+
await lc.sendCredential({
|
|
326
|
+
templateAlias: 'algebra',
|
|
327
|
+
templateData: { name: 'Algebra 101' },
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const context = await lc.requestLearnerContext({
|
|
331
|
+
includeCredentials: false,
|
|
332
|
+
format: 'structured',
|
|
333
|
+
});
|
|
334
|
+
expect(context.raw?.credentials.length).toBe(0);
|
|
335
|
+
expect(context.prompt).not.toContain('Algebra 101');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('AI sessions reuse one topic: first call creates it, later calls report isNewTopic false', async () => {
|
|
339
|
+
const lc = createPartnerConnect({ mockOptions: { ui: false } });
|
|
340
|
+
const summaryData = {
|
|
341
|
+
title: 'Session',
|
|
342
|
+
summary: 'Mock summary',
|
|
343
|
+
learned: [],
|
|
344
|
+
nextSteps: [],
|
|
345
|
+
reflections: [],
|
|
346
|
+
skills: [],
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const first = await lc.sendAiSessionCredential({ sessionTitle: 'One', summaryData });
|
|
350
|
+
expect(first.isNewTopic).toBe(true);
|
|
351
|
+
expect(first.topicCredentialUri).toBeDefined();
|
|
352
|
+
|
|
353
|
+
const second = await lc.sendAiSessionCredential({ sessionTitle: 'Two', summaryData });
|
|
354
|
+
expect(second.isNewTopic).toBe(false);
|
|
355
|
+
expect(second.topicUri).toBe(first.topicUri);
|
|
356
|
+
expect(second.topicCredentialUri).toBeUndefined();
|
|
357
|
+
expect(second.sessionCredentialUri).not.toBe(first.sessionCredentialUri);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('preventDuplicateClaim returns the existing credential', async () => {
|
|
361
|
+
const lc = createPartnerConnect({ mockOptions: { ui: false } });
|
|
362
|
+
const first = (await lc.sendCredential({
|
|
363
|
+
templateAlias: 'algebra',
|
|
364
|
+
preventDuplicateClaim: true,
|
|
365
|
+
})) as { credentialUri: string };
|
|
366
|
+
const second = (await lc.sendCredential({
|
|
367
|
+
templateAlias: 'algebra',
|
|
368
|
+
preventDuplicateClaim: true,
|
|
369
|
+
})) as { credentialUri: string; alreadyClaimed?: boolean };
|
|
370
|
+
|
|
371
|
+
expect(second.alreadyClaimed).toBe(true);
|
|
372
|
+
expect(second.credentialUri).toBe(first.credentialUri);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('initiateTemplateIssue populates recipients and issuance status', async () => {
|
|
376
|
+
const lc = createPartnerConnect({ mockOptions: { ui: false } });
|
|
377
|
+
await lc.initiateTemplateIssue('boost-xyz', ['alice', 'bob']);
|
|
378
|
+
|
|
379
|
+
const recipients = await lc.getTemplateRecipients({ boostUri: 'boost-xyz' });
|
|
380
|
+
expect(recipients.total).toBe(2);
|
|
381
|
+
|
|
382
|
+
const status = await lc.getTemplateIssuanceStatus({
|
|
383
|
+
boostUri: 'boost-xyz',
|
|
384
|
+
recipient: 'alice',
|
|
385
|
+
});
|
|
386
|
+
expect(status.sent).toBe(true);
|
|
387
|
+
expect(status.status).toBe('pending');
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
describe('mock seeding', () => {
|
|
392
|
+
it('seeds identity did returned by requestIdentity', async () => {
|
|
393
|
+
const lc = createPartnerConnect({
|
|
394
|
+
mockOptions: { ui: false, identity: { did: 'did:web:seed:me', name: 'Ada' } },
|
|
395
|
+
});
|
|
396
|
+
const identity = await lc.requestIdentity();
|
|
397
|
+
expect(identity.user.did).toBe('did:web:seed:me');
|
|
398
|
+
expect((identity.user as { name?: string }).name).toBe('Ada');
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('seeds held credentials so happy-path reads work immediately', async () => {
|
|
402
|
+
const lc = createPartnerConnect({
|
|
403
|
+
mockOptions: {
|
|
404
|
+
ui: false,
|
|
405
|
+
credentials: [{ templateAlias: 'algebra', name: 'Algebra 101' }],
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
const check = await lc.checkUserHasCredential({ templateAlias: 'algebra' });
|
|
409
|
+
expect(check.hasCredential).toBe(true);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('seeds initial counter values', async () => {
|
|
413
|
+
const lc = createPartnerConnect({ mockOptions: { ui: false, counters: { coins: 50 } } });
|
|
414
|
+
const { value } = await lc.getCounter('coins');
|
|
415
|
+
expect(value).toBe(50);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe('mock UI', () => {
|
|
420
|
+
const toastCount = (): number => document.querySelectorAll('.lc-mock-toast').length;
|
|
421
|
+
const toastText = (): string =>
|
|
422
|
+
Array.from(document.querySelectorAll('.lc-mock-toast'))
|
|
423
|
+
.map(n => n.textContent ?? '')
|
|
424
|
+
.join('\n');
|
|
425
|
+
|
|
426
|
+
it('renders a claim toast for sendCredential when ui is enabled', async () => {
|
|
427
|
+
const lc = createPartnerConnect({ mockOptions: { ui: true } });
|
|
428
|
+
await lc.sendCredential({ templateAlias: 'badge' });
|
|
429
|
+
await flush();
|
|
430
|
+
expect(toastCount()).toBe(1);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('renders a positive-tone toast for requestConsent', async () => {
|
|
434
|
+
const lc = createPartnerConnect({ mockOptions: { ui: true } });
|
|
435
|
+
await lc.requestConsent();
|
|
436
|
+
await flush();
|
|
437
|
+
expect(document.querySelector('.lc-mock-toast--positive')).not.toBeNull();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('surfaces a toast for every mocked action (not just console)', async () => {
|
|
441
|
+
const lc = createPartnerConnect({ mockOptions: { ui: true } });
|
|
442
|
+
|
|
443
|
+
await lc.requestIdentity();
|
|
444
|
+
await lc.launchFeature('/wallet');
|
|
445
|
+
await lc.askCredentialSearch({ query: [], challenge: 'c', domain: 'd' });
|
|
446
|
+
await lc.askCredentialSpecific('id');
|
|
447
|
+
await lc.initiateTemplateIssue('boost');
|
|
448
|
+
await lc.requestLearnerContext();
|
|
449
|
+
await lc.getSyncStatus();
|
|
450
|
+
await lc.checkUserHasCredential({ templateAlias: 't' });
|
|
451
|
+
await lc.getTemplateIssuanceStatus({ templateAlias: 't', recipient: 'r' });
|
|
452
|
+
await lc.getTemplateRecipients({ templateAlias: 't' });
|
|
453
|
+
await lc.sendNotification({ title: 'Hi' });
|
|
454
|
+
await lc.getCounter('coins');
|
|
455
|
+
await flush();
|
|
456
|
+
|
|
457
|
+
expect(toastCount()).toBe(12);
|
|
458
|
+
expect(toastText()).toContain('/wallet');
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('coalesces identical repeated toasts into one with a count', async () => {
|
|
462
|
+
const lc = createPartnerConnect({ mockOptions: { ui: true } });
|
|
463
|
+
await lc.getSyncStatus();
|
|
464
|
+
await lc.getSyncStatus();
|
|
465
|
+
await lc.getSyncStatus();
|
|
466
|
+
await flush();
|
|
467
|
+
expect(toastCount()).toBe(1);
|
|
468
|
+
expect(toastText()).toContain('×3');
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('cleans up injected DOM on destroy', async () => {
|
|
472
|
+
const lc = createPartnerConnect({ mockOptions: { ui: true } });
|
|
473
|
+
await lc.requestConsent();
|
|
474
|
+
await flush();
|
|
475
|
+
lc.destroy();
|
|
476
|
+
expect(toastCount()).toBe(0);
|
|
477
|
+
});
|
|
478
|
+
});
|
package/src/types.ts
CHANGED
|
@@ -100,6 +100,163 @@ export interface PartnerConnectOptions {
|
|
|
100
100
|
* Request timeout in milliseconds (default: 30000)
|
|
101
101
|
*/
|
|
102
102
|
requestTimeout?: number;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Controls automatic **standalone mock mode**.
|
|
106
|
+
*
|
|
107
|
+
* When the SDK runs outside of a LearnCard host (i.e. as a top-level page,
|
|
108
|
+
* not embedded in an iframe), there is no host to answer `postMessage`
|
|
109
|
+
* requests, so every call would hang until it times out. Mock mode makes
|
|
110
|
+
* the SDK simulate the host locally so your app is fully buildable,
|
|
111
|
+
* demo-able, and testable without being embedded — and then behaves
|
|
112
|
+
* identically (real host) once embedded, with no code changes.
|
|
113
|
+
*
|
|
114
|
+
* - `'auto'` **(default)**: mock only when **no LearnCard host is present
|
|
115
|
+
* AND the app is running on a local dev host** (`localhost`,
|
|
116
|
+
* `127.0.0.1`, `[::1]`, `*.localhost`, `*.local`). This covers local
|
|
117
|
+
* dev and local Storybook, but deliberately never fabricates identity
|
|
118
|
+
* or consent on a production or preview origin. Each mocked call
|
|
119
|
+
* surfaces a labeled toast plus a console log so it's clear the host
|
|
120
|
+
* is simulated.
|
|
121
|
+
* - `'standalone'`: mock whenever **no LearnCard host is present**, on
|
|
122
|
+
* any origin — including remote deploy previews (Netlify, Lovable,
|
|
123
|
+
* Vercel, …) — and use the real host when embedded in LearnCard. The
|
|
124
|
+
* one-flag setting for apps that must demo standalone anywhere. Only
|
|
125
|
+
* choose it when a user opening your app's URL directly should see
|
|
126
|
+
* simulated data.
|
|
127
|
+
* - `true`: always mock, **even when embedded in a real LearnCard host**.
|
|
128
|
+
* Use this for CI and tests; for previews that should go real once
|
|
129
|
+
* embedded, prefer `'standalone'`.
|
|
130
|
+
* - `false`: never mock. Standalone calls reject immediately with
|
|
131
|
+
* `LC_NOT_EMBEDDED`. Set this in production builds meant to run only
|
|
132
|
+
* inside LearnCard.
|
|
133
|
+
*
|
|
134
|
+
* @default 'auto'
|
|
135
|
+
*/
|
|
136
|
+
mock?: boolean | 'auto' | 'standalone';
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Fine-grained configuration for standalone mock mode. Ignored when mock
|
|
140
|
+
* mode is not active. See {@link MockHostOptions}.
|
|
141
|
+
*/
|
|
142
|
+
mockOptions?: MockHostOptions;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* How long (ms) to wait for the host to answer the one-time presence
|
|
146
|
+
* probe used when the SDK is embedded in an iframe whose parent cannot
|
|
147
|
+
* be confirmed as LearnCard (e.g. a same-origin Storybook canvas on
|
|
148
|
+
* localhost). Only used with `mock: 'auto'` on local dev hosts.
|
|
149
|
+
*
|
|
150
|
+
* @default 1500
|
|
151
|
+
*/
|
|
152
|
+
hostProbeTimeout?: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Options controlling the behavior of standalone mock mode.
|
|
157
|
+
*
|
|
158
|
+
* All fields are optional; mock mode works out-of-the-box with sensible
|
|
159
|
+
* defaults (visible UI, console logging, and localStorage-backed counters).
|
|
160
|
+
*/
|
|
161
|
+
export interface MockHostOptions {
|
|
162
|
+
/**
|
|
163
|
+
* Render lightweight visual feedback in the page: a fake credential-claim
|
|
164
|
+
* modal / toast for `sendCredential`, and a "mock consent" banner for
|
|
165
|
+
* `requestConsent`. Set to `false` for a headless mock (logs only).
|
|
166
|
+
*
|
|
167
|
+
* @default true
|
|
168
|
+
*/
|
|
169
|
+
ui?: boolean;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Log every simulated host interaction to the console with a clear
|
|
173
|
+
* `[LearnCard SDK · MOCK]` prefix.
|
|
174
|
+
*
|
|
175
|
+
* @default true
|
|
176
|
+
*/
|
|
177
|
+
log?: boolean;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Persist counters (`incrementCounter` / `getCounter` / `getCounters`) to
|
|
181
|
+
* `localStorage` so values survive page reloads, mirroring the real host's
|
|
182
|
+
* durable per-user counters. Falls back to in-memory storage when
|
|
183
|
+
* `localStorage` is unavailable.
|
|
184
|
+
*
|
|
185
|
+
* @default true
|
|
186
|
+
*/
|
|
187
|
+
persist?: boolean;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The fake DID returned by `requestIdentity()` (and used as the mock
|
|
191
|
+
* user's identity) while in mock mode.
|
|
192
|
+
*
|
|
193
|
+
* @default 'did:web:mock.learncard.app:user'
|
|
194
|
+
*/
|
|
195
|
+
did?: string;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Namespace used to scope persisted mock data (counters, claimed
|
|
199
|
+
* credentials) in `localStorage`. Change this if you run multiple mock
|
|
200
|
+
* apps on the same origin and want isolated state.
|
|
201
|
+
*
|
|
202
|
+
* @default 'lc-mock'
|
|
203
|
+
*/
|
|
204
|
+
namespace?: string;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Seed the mock user's identity. Superseded per-field over the legacy
|
|
208
|
+
* `did` option. Extra fields are returned as-is from `requestIdentity()`.
|
|
209
|
+
*/
|
|
210
|
+
identity?: MockIdentitySeed;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Pre-populate the mock with credentials the user already holds (or has
|
|
214
|
+
* issued to others). This lets you demo "happy path" states — e.g. a
|
|
215
|
+
* "you already earned this" banner — without performing an action first.
|
|
216
|
+
* Reads like `checkUserHasCredential`, `getTemplateRecipients`,
|
|
217
|
+
* `requestLearnerContext`, and `askCredentialSearch` reflect these.
|
|
218
|
+
*/
|
|
219
|
+
credentials?: MockCredentialSeed[];
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Initial counter values, applied only when a counter has no persisted
|
|
223
|
+
* value yet (so incremented values survive reloads).
|
|
224
|
+
*/
|
|
225
|
+
counters?: Record<string, number>;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Seed shape for the mock user's identity (see {@link MockHostOptions.identity}).
|
|
230
|
+
*/
|
|
231
|
+
export interface MockIdentitySeed {
|
|
232
|
+
did?: string;
|
|
233
|
+
name?: string;
|
|
234
|
+
[key: string]: unknown;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Seed shape for a pre-populated mock credential (see
|
|
239
|
+
* {@link MockHostOptions.credentials}).
|
|
240
|
+
*/
|
|
241
|
+
export interface MockCredentialSeed {
|
|
242
|
+
/** Template alias this credential was issued from. */
|
|
243
|
+
templateAlias?: string;
|
|
244
|
+
|
|
245
|
+
/** Boost URI this credential was issued from. */
|
|
246
|
+
boostUri?: string;
|
|
247
|
+
|
|
248
|
+
/** Human-readable credential name (used in toasts and mock VC data). */
|
|
249
|
+
name?: string;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Recipient identifier (profileId or DID). Defaults to the mock user
|
|
253
|
+
* (i.e. a credential the user holds). Set this to model credentials the
|
|
254
|
+
* user has issued to other people.
|
|
255
|
+
*/
|
|
256
|
+
recipient?: string;
|
|
257
|
+
|
|
258
|
+
/** Claim status. @default 'claimed' */
|
|
259
|
+
status?: 'pending' | 'claimed' | 'revoked';
|
|
103
260
|
}
|
|
104
261
|
|
|
105
262
|
/**
|
|
@@ -568,6 +725,7 @@ export interface GetCountersResponse {
|
|
|
568
725
|
*/
|
|
569
726
|
export type ErrorCode =
|
|
570
727
|
| 'LC_TIMEOUT'
|
|
728
|
+
| 'LC_NOT_EMBEDDED'
|
|
571
729
|
| 'LC_UNAUTHENTICATED'
|
|
572
730
|
| 'CREDENTIAL_NOT_FOUND'
|
|
573
731
|
| 'USER_REJECTED'
|