@learncard/partner-connect 0.5.2 → 0.6.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/README.md +395 -83
- package/dist/index.d.ts +257 -25
- package/dist/partner-connect.esm.js +3294 -431
- package/dist/partner-connect.esm.js.map +1 -1
- package/dist/partner-connect.js +3295 -430
- package/dist/partner-connect.js.map +1 -1
- package/dist/partner-connect.mjs +3294 -431
- package/dist/partner-connect.mjs.map +1 -1
- package/package.json +3 -2
- package/src/docs-snippets.test.ts +79 -0
- package/src/index.ts +496 -33
- package/src/mock-host.ts +2447 -523
- package/src/mock-mode.test.ts +1337 -7
- package/src/origin-resolution.test.ts +229 -0
- package/src/types.ts +130 -11
package/src/mock-mode.test.ts
CHANGED
|
@@ -9,10 +9,286 @@ import { vi } from 'vitest';
|
|
|
9
9
|
|
|
10
10
|
import type { MockInstance } from 'vitest';
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
PartnerConnect,
|
|
14
|
+
PartnerConnectError,
|
|
15
|
+
createPartnerConnect,
|
|
16
|
+
decodeManifestFromUrl,
|
|
17
|
+
isEmbedded,
|
|
18
|
+
previewCompiledTemplate,
|
|
19
|
+
} from './index';
|
|
20
|
+
import type { CapturedAppManifest, ConsentRequest } from './types';
|
|
13
21
|
|
|
14
22
|
const flush = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0));
|
|
15
23
|
|
|
24
|
+
describe('practice identity, reset and icons', () => {
|
|
25
|
+
const namespace = 'practice-features';
|
|
26
|
+
const clients: PartnerConnect[] = [];
|
|
27
|
+
const create = (appId?: string, ui = false): PartnerConnect => {
|
|
28
|
+
const client = createPartnerConnect({
|
|
29
|
+
mock: true,
|
|
30
|
+
mockOptions: { namespace, appId, ui, log: false },
|
|
31
|
+
});
|
|
32
|
+
clients.push(client);
|
|
33
|
+
return client;
|
|
34
|
+
};
|
|
35
|
+
let title: string;
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
title = document.title;
|
|
38
|
+
});
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
clients.splice(0).forEach(client => client.destroy());
|
|
41
|
+
document.title = title;
|
|
42
|
+
document.querySelectorAll('[data-test-icon]').forEach(node => node.remove());
|
|
43
|
+
vi.unstubAllGlobals();
|
|
44
|
+
vi.useRealTimers();
|
|
45
|
+
});
|
|
46
|
+
const icon = (rel: string, href: string, sizes = ''): void => {
|
|
47
|
+
const link = document.createElement('link');
|
|
48
|
+
link.rel = rel;
|
|
49
|
+
link.href = href;
|
|
50
|
+
link.setAttribute('sizes', sizes);
|
|
51
|
+
link.setAttribute('data-test-icon', '');
|
|
52
|
+
document.head.appendChild(link);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
it('persists generated keys per title and uses explicit app IDs exactly', async () => {
|
|
56
|
+
document.title = 'First practice app';
|
|
57
|
+
const first = create();
|
|
58
|
+
await first.requestIdentity();
|
|
59
|
+
const key = first.getCapturedManifest()?.appKey;
|
|
60
|
+
expect(key).toBeTruthy();
|
|
61
|
+
expect(create().getCapturedManifest()?.appKey).toBe(key);
|
|
62
|
+
document.title = 'Second practice app';
|
|
63
|
+
const second = create();
|
|
64
|
+
await second.requestIdentity();
|
|
65
|
+
expect(second.getCapturedManifest()?.appKey).not.toBe(key);
|
|
66
|
+
const explicit = create('My Exact ID');
|
|
67
|
+
await explicit.requestIdentity();
|
|
68
|
+
expect(explicit.getCapturedManifest()?.appKey).toBe('My Exact ID');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('resets only the current app and generates a fresh key on next capture', async () => {
|
|
72
|
+
document.title = 'Reset app';
|
|
73
|
+
const first = create();
|
|
74
|
+
await first.incrementCounter('points', 4);
|
|
75
|
+
const key = first.getCapturedManifest()?.appKey;
|
|
76
|
+
const second = create('other');
|
|
77
|
+
await second.incrementCounter('points', 9);
|
|
78
|
+
first.resetPracticeMode();
|
|
79
|
+
expect(first.getCapturedManifest()).toBeUndefined();
|
|
80
|
+
expect((await first.getCounter('points')).value).toBe(0);
|
|
81
|
+
expect(first.getCapturedManifest()?.appKey).not.toBe(key);
|
|
82
|
+
expect((await second.getCounter('points')).value).toBe(9);
|
|
83
|
+
expect(second.getCapturedManifest()?.appKey).toBe('other');
|
|
84
|
+
expect(() => createPartnerConnect({ mock: false }).resetPracticeMode()).not.toThrow();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('confirms in place, focuses Cancel, supports cancellation and clears the HUD', async () => {
|
|
88
|
+
const client = create('reset-ui', true);
|
|
89
|
+
await client.requestIdentity();
|
|
90
|
+
const click = (selector: string): void => {
|
|
91
|
+
document.querySelector<HTMLButtonElement>(selector)!.click();
|
|
92
|
+
};
|
|
93
|
+
click('.lc-mock-hud-pill');
|
|
94
|
+
click('.lc-mock-hud-reset');
|
|
95
|
+
expect(document.activeElement?.textContent).toBe('Cancel');
|
|
96
|
+
expect(document.querySelector('.lc-mock-hud')?.textContent).toContain(
|
|
97
|
+
'Start over with this app?'
|
|
98
|
+
);
|
|
99
|
+
click('.lc-mock-hud-reset-cancel');
|
|
100
|
+
expect(document.querySelector('.lc-mock-hud-reset')).not.toBeNull();
|
|
101
|
+
click('.lc-mock-hud-reset');
|
|
102
|
+
document.activeElement?.dispatchEvent(
|
|
103
|
+
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
|
|
104
|
+
);
|
|
105
|
+
expect(document.querySelector('.lc-mock-hud-reset')).not.toBeNull();
|
|
106
|
+
click('.lc-mock-hud-reset');
|
|
107
|
+
click('.lc-mock-hud-reset-confirm');
|
|
108
|
+
expect(client.getCapturedManifest()).toBeUndefined();
|
|
109
|
+
expect(document.querySelector<HTMLElement>('.lc-mock-hud')?.style.display).toBe('none');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('ranks Apple icons above large icons, ignores masks, and falls back to favicon', async () => {
|
|
113
|
+
icon('icon', '/small.png', '16x16');
|
|
114
|
+
icon('shortcut icon', '/large.png', '256x256');
|
|
115
|
+
icon('mask-icon', '/mask.svg', 'any');
|
|
116
|
+
icon('apple-touch-icon-precomposed', '/apple.png', '32x32');
|
|
117
|
+
const apple = create('apple');
|
|
118
|
+
await apple.requestIdentity();
|
|
119
|
+
expect(apple.getCapturedManifest()?.suggestedIconUrl).toBe(
|
|
120
|
+
new URL('/apple.png', location.href).href
|
|
121
|
+
);
|
|
122
|
+
document.querySelector('[rel="apple-touch-icon-precomposed"]')?.remove();
|
|
123
|
+
const large = create('large');
|
|
124
|
+
await large.requestIdentity();
|
|
125
|
+
expect(large.getCapturedManifest()?.suggestedIconUrl).toBe(
|
|
126
|
+
new URL('/large.png', location.href).href
|
|
127
|
+
);
|
|
128
|
+
document.querySelectorAll('[data-test-icon]').forEach(node => node.remove());
|
|
129
|
+
const fallback = create('fallback');
|
|
130
|
+
await fallback.requestIdentity();
|
|
131
|
+
expect(fallback.getCapturedManifest()?.suggestedIconUrl).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('only keeps the guessed /favicon.ico when it actually loads', async () => {
|
|
135
|
+
vi.useFakeTimers();
|
|
136
|
+
const stubImage = (outcome: 'load' | 'error') =>
|
|
137
|
+
vi.stubGlobal(
|
|
138
|
+
'Image',
|
|
139
|
+
class {
|
|
140
|
+
naturalWidth = 32;
|
|
141
|
+
naturalHeight = 32;
|
|
142
|
+
crossOrigin = '';
|
|
143
|
+
onload: (() => void) | null = null;
|
|
144
|
+
onerror: (() => void) | null = null;
|
|
145
|
+
removeAttribute(): void {}
|
|
146
|
+
set src(_value: string) {
|
|
147
|
+
setTimeout(() => (outcome === 'load' ? this.onload : this.onerror)?.(), 0);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
stubImage('error');
|
|
153
|
+
const missing = create('favicon-missing');
|
|
154
|
+
await missing.requestIdentity();
|
|
155
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
156
|
+
expect(missing.getCapturedManifest()?.suggestedIconUrl).toBeUndefined();
|
|
157
|
+
|
|
158
|
+
stubImage('load');
|
|
159
|
+
const present = create('favicon-present');
|
|
160
|
+
await present.requestIdentity();
|
|
161
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
162
|
+
expect(present.getCapturedManifest()?.suggestedIconUrl).toBe(
|
|
163
|
+
new URL('/favicon.ico', location.href).href
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('rasterizes an icon and prefers the captured data in the avatar', async () => {
|
|
168
|
+
const data = 'data:image/webp;base64,aGVsbG8=';
|
|
169
|
+
vi.stubGlobal(
|
|
170
|
+
'Image',
|
|
171
|
+
class {
|
|
172
|
+
naturalWidth = 256;
|
|
173
|
+
naturalHeight = 128;
|
|
174
|
+
onload: (() => void) | null = null;
|
|
175
|
+
onerror: (() => void) | null = null;
|
|
176
|
+
set src(_value: string) {
|
|
177
|
+
queueMicrotask(() => this.onload?.());
|
|
178
|
+
}
|
|
179
|
+
removeAttribute(): void {}
|
|
180
|
+
}
|
|
181
|
+
);
|
|
182
|
+
const drawImage = vi.fn();
|
|
183
|
+
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
|
184
|
+
drawImage,
|
|
185
|
+
} as unknown as CanvasRenderingContext2D);
|
|
186
|
+
vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue(data);
|
|
187
|
+
const client = create('raster', true);
|
|
188
|
+
await client.requestIdentity();
|
|
189
|
+
await flush();
|
|
190
|
+
expect(client.getCapturedManifest()?.suggestedIconDataUrl).toBe(data);
|
|
191
|
+
expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 32, 128, 64);
|
|
192
|
+
document.querySelector<HTMLButtonElement>('.lc-mock-hud-pill')!.click();
|
|
193
|
+
expect(document.querySelector<HTMLImageElement>('.lc-mock-hud-avatar img')?.src).toBe(data);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('drops oversized image data from publish links without changing stored captures', async () => {
|
|
197
|
+
const client = create('long-url');
|
|
198
|
+
await client.requestIdentity();
|
|
199
|
+
const manifests = readManifestMap(namespace);
|
|
200
|
+
manifests['long-url'].suggestedIconDataUrl = `data:image/png;base64,${'x'.repeat(8000)}`;
|
|
201
|
+
localStorage.setItem(`${namespace}:manifests`, JSON.stringify(manifests));
|
|
202
|
+
const url = client.getPublishUrl()!;
|
|
203
|
+
expect(url.length).toBeLessThan(7000);
|
|
204
|
+
expect(
|
|
205
|
+
decodeManifestFromUrl(new URL(url).searchParams.get('manifest')!)?.suggestedIconDataUrl
|
|
206
|
+
).toBeUndefined();
|
|
207
|
+
expect(client.getCapturedManifest()?.suggestedIconDataUrl).toBeTruthy();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('clears practice credentials, template versions and publish dismissal', async () => {
|
|
211
|
+
const client = create('reset-records');
|
|
212
|
+
const template = { name: 'First badge' };
|
|
213
|
+
await client.sendCredential({ alias: 'badge', template });
|
|
214
|
+
await client.sendCredential({ alias: 'badge', template: { name: 'Updated badge' } });
|
|
215
|
+
localStorage.setItem(
|
|
216
|
+
`${namespace}:publish-dismissed-at:reset-records`,
|
|
217
|
+
new Date().toISOString()
|
|
218
|
+
);
|
|
219
|
+
client.resetPracticeMode();
|
|
220
|
+
expect(localStorage.getItem(`${namespace}:publish-dismissed-at:reset-records`)).toBeNull();
|
|
221
|
+
expect(
|
|
222
|
+
(await client.checkUserHasCredential({ templateAlias: 'badge' })).hasCredential
|
|
223
|
+
).toBe(false);
|
|
224
|
+
expect((await client.sendCredential({ alias: 'badge', template })).templateVersion).toBe(1);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('falls back to session identity and resets when storage throws', async () => {
|
|
228
|
+
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
|
229
|
+
throw new Error('blocked');
|
|
230
|
+
});
|
|
231
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
|
232
|
+
throw new Error('blocked');
|
|
233
|
+
});
|
|
234
|
+
const client = create();
|
|
235
|
+
await client.requestIdentity();
|
|
236
|
+
const key = client.getCapturedManifest()?.appKey;
|
|
237
|
+
await client.requestIdentity();
|
|
238
|
+
expect(client.getCapturedManifest()?.appKey).toBe(key);
|
|
239
|
+
client.resetPracticeMode();
|
|
240
|
+
expect(client.getCapturedManifest()).toBeUndefined();
|
|
241
|
+
await client.requestIdentity();
|
|
242
|
+
expect(client.getCapturedManifest()?.appKey).not.toBe(key);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('reads the largest same-origin manifest icon and cancels pending image work on destroy', async () => {
|
|
246
|
+
vi.useFakeTimers();
|
|
247
|
+
icon('manifest', '/app.webmanifest');
|
|
248
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
249
|
+
ok: true,
|
|
250
|
+
json: async () => ({
|
|
251
|
+
icons: [
|
|
252
|
+
{ src: '/tiny.png', sizes: '16x16' },
|
|
253
|
+
{ src: '/big.png', sizes: '512x512' },
|
|
254
|
+
],
|
|
255
|
+
}),
|
|
256
|
+
});
|
|
257
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
258
|
+
const client = create('manifest-icon');
|
|
259
|
+
await client.requestIdentity();
|
|
260
|
+
await vi.advanceTimersByTimeAsync(4001);
|
|
261
|
+
expect(client.getCapturedManifest()?.suggestedIconUrl).toBe(
|
|
262
|
+
new URL('/big.png', location.href).href
|
|
263
|
+
);
|
|
264
|
+
expect(client.getCapturedManifest()?.suggestedIconDataUrl).toBeUndefined();
|
|
265
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
266
|
+
await client.requestIdentity();
|
|
267
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
268
|
+
client.destroy();
|
|
269
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
270
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('aborts a pending manifest fetch without recreating a reset capture', async () => {
|
|
274
|
+
vi.useFakeTimers();
|
|
275
|
+
icon('manifest', '/pending.webmanifest');
|
|
276
|
+
vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => undefined)));
|
|
277
|
+
const client = create('pending-icon');
|
|
278
|
+
await client.requestIdentity();
|
|
279
|
+
client.resetPracticeMode();
|
|
280
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
281
|
+
expect(client.getCapturedManifest()).toBeUndefined();
|
|
282
|
+
client.destroy();
|
|
283
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const readManifestMap = (namespace: string): Record<string, CapturedAppManifest> => {
|
|
288
|
+
const raw = localStorage.getItem(`${namespace}:manifests`);
|
|
289
|
+
return raw ? (JSON.parse(raw) as Record<string, CapturedAppManifest>) : {};
|
|
290
|
+
};
|
|
291
|
+
|
|
16
292
|
let errorSpy: MockInstance;
|
|
17
293
|
|
|
18
294
|
beforeEach(() => {
|
|
@@ -20,6 +296,9 @@ beforeEach(() => {
|
|
|
20
296
|
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
21
297
|
try {
|
|
22
298
|
localStorage.clear();
|
|
299
|
+
// Tests that install a trusted `ancestorOrigins` persist an
|
|
300
|
+
// `lc_host_override`, which would otherwise leak into later cases.
|
|
301
|
+
sessionStorage.clear();
|
|
23
302
|
} catch {
|
|
24
303
|
// localStorage may be unavailable in some environments.
|
|
25
304
|
}
|
|
@@ -27,7 +306,9 @@ beforeEach(() => {
|
|
|
27
306
|
|
|
28
307
|
afterEach(() => {
|
|
29
308
|
vi.restoreAllMocks();
|
|
30
|
-
document
|
|
309
|
+
document
|
|
310
|
+
.querySelectorAll('.lc-mock-toast, .lc-mock-stack, .lc-mock-hud')
|
|
311
|
+
.forEach(node => node.remove());
|
|
31
312
|
});
|
|
32
313
|
|
|
33
314
|
describe('isEmbedded', () => {
|
|
@@ -41,7 +322,7 @@ describe('isEmbedded', () => {
|
|
|
41
322
|
const originalTop = Object.getOwnPropertyDescriptor(window, 'top');
|
|
42
323
|
Object.defineProperty(window, 'top', {
|
|
43
324
|
configurable: true,
|
|
44
|
-
get: () => ({} as Window
|
|
325
|
+
get: () => ({}) as Window,
|
|
45
326
|
});
|
|
46
327
|
|
|
47
328
|
try {
|
|
@@ -80,6 +361,19 @@ describe('mock mode activation', () => {
|
|
|
80
361
|
expect(createPartnerConnect({ mock: true }).isMocked()).toBe(true);
|
|
81
362
|
});
|
|
82
363
|
|
|
364
|
+
it('reports active host and publish origins across mock and real modes', () => {
|
|
365
|
+
const mocked = createPartnerConnect({ mock: true, mockOptions: { ui: false } });
|
|
366
|
+
expect(mocked.getActiveHostOrigin()).toBeNull();
|
|
367
|
+
expect(mocked.getPublishOrigin()).toBe('https://learncard.app');
|
|
368
|
+
|
|
369
|
+
const real = createPartnerConnect({
|
|
370
|
+
mock: false,
|
|
371
|
+
hostOrigin: 'https://staging.learncard.app',
|
|
372
|
+
});
|
|
373
|
+
expect(real.getActiveHostOrigin()).toBe('https://staging.learncard.app');
|
|
374
|
+
expect(real.getPublishOrigin()).toBe('https://staging.learncard.app');
|
|
375
|
+
});
|
|
376
|
+
|
|
83
377
|
it('does NOT auto-mock on non-local standalone hosts (deploy previews, production)', async () => {
|
|
84
378
|
const original = Object.getOwnPropertyDescriptor(window, 'location');
|
|
85
379
|
Object.defineProperty(window, 'location', {
|
|
@@ -121,6 +415,34 @@ describe('mock mode activation', () => {
|
|
|
121
415
|
}
|
|
122
416
|
}
|
|
123
417
|
});
|
|
418
|
+
|
|
419
|
+
it('auto-mocks on app-builder preview hosts but never on their published hosts', () => {
|
|
420
|
+
const original = Object.getOwnPropertyDescriptor(window, 'location');
|
|
421
|
+
const cases: Array<[string, boolean]> = [
|
|
422
|
+
['abc123.lovableproject.com', true],
|
|
423
|
+
['id-preview--0f3c.lovable.app', true],
|
|
424
|
+
['8f2c.picard.replit.dev', true],
|
|
425
|
+
['my-app.lovable.app', false],
|
|
426
|
+
['my-app.replit.app', false],
|
|
427
|
+
['my-app.vercel.app', false],
|
|
428
|
+
];
|
|
429
|
+
|
|
430
|
+
for (const [hostname, mocked] of cases) {
|
|
431
|
+
Object.defineProperty(window, 'location', {
|
|
432
|
+
configurable: true,
|
|
433
|
+
value: { hostname, search: '', href: `https://${hostname}/` },
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
expect({ hostname, mocked: createPartnerConnect().isMocked() }).toEqual({
|
|
438
|
+
hostname,
|
|
439
|
+
mocked,
|
|
440
|
+
});
|
|
441
|
+
} finally {
|
|
442
|
+
if (original) Object.defineProperty(window, 'location', original);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
});
|
|
124
446
|
});
|
|
125
447
|
|
|
126
448
|
describe('embedded parent classification', () => {
|
|
@@ -130,7 +452,7 @@ describe('embedded parent classification', () => {
|
|
|
130
452
|
const embedIn = (ancestorOrigin: string | null, hostname = 'localhost'): void => {
|
|
131
453
|
Object.defineProperty(window, 'top', {
|
|
132
454
|
configurable: true,
|
|
133
|
-
get: () => ({} as Window
|
|
455
|
+
get: () => ({}) as Window,
|
|
134
456
|
});
|
|
135
457
|
Object.defineProperty(window, 'location', {
|
|
136
458
|
configurable: true,
|
|
@@ -198,6 +520,41 @@ describe('embedded parent classification', () => {
|
|
|
198
520
|
expect(identity.user.did).toBeDefined();
|
|
199
521
|
});
|
|
200
522
|
|
|
523
|
+
it('auto-mocks inside an app-builder editor frame on a non-local host', () => {
|
|
524
|
+
embedIn('https://lovable.dev', 'my-app.lovable.app');
|
|
525
|
+
expect(createPartnerConnect().isMocked()).toBe(true);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('auto-mocks inside an app-builder editor when the browser hides ancestor origins', async () => {
|
|
529
|
+
embedIn(null, 'my-app.lovable.app');
|
|
530
|
+
const originalReferrer = Object.getOwnPropertyDescriptor(document, 'referrer');
|
|
531
|
+
Object.defineProperty(document, 'referrer', {
|
|
532
|
+
configurable: true,
|
|
533
|
+
get: () => 'https://lovable.dev/projects/abc',
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
try {
|
|
537
|
+
const lc = createPartnerConnect({ hostProbeTimeout: 40 });
|
|
538
|
+
await lc.requestIdentity();
|
|
539
|
+
expect(lc.isMocked()).toBe(true);
|
|
540
|
+
} finally {
|
|
541
|
+
if (originalReferrer) Object.defineProperty(document, 'referrer', originalReferrer);
|
|
542
|
+
else delete (document as { referrer?: string }).referrer;
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('does not auto-mock a published app inside an unrelated frame', async () => {
|
|
547
|
+
embedIn('https://preview-shell.example.com', 'my-app.lovable.app');
|
|
548
|
+
const lc = createPartnerConnect();
|
|
549
|
+
expect(lc.isMocked()).toBe(false);
|
|
550
|
+
await expect(lc.requestIdentity()).rejects.toMatchObject({ code: 'LC_NOT_EMBEDDED' });
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('uses the real host when a preview-host app is embedded in LearnCard', () => {
|
|
554
|
+
embedIn('https://learncard.app', 'abc123.lovableproject.com');
|
|
555
|
+
expect(createPartnerConnect().isMocked()).toBe(false);
|
|
556
|
+
});
|
|
557
|
+
|
|
201
558
|
it('stays in real-host mode when the probe is answered', async () => {
|
|
202
559
|
embedIn('http://localhost');
|
|
203
560
|
|
|
@@ -257,10 +614,277 @@ describe('mock responses', () => {
|
|
|
257
614
|
await expect(lc.requestConsent('lc:contract:abc')).resolves.toEqual({ granted: true });
|
|
258
615
|
});
|
|
259
616
|
|
|
617
|
+
it('auto-grants declarative requestConsent and summarizes scopes in the toast', async () => {
|
|
618
|
+
const lc = createPartnerConnect({ mockOptions: { ui: true } });
|
|
619
|
+
|
|
620
|
+
await expect(
|
|
621
|
+
lc.requestConsent({
|
|
622
|
+
read: {
|
|
623
|
+
credentialCategories: ['Achievement', 'Skill'],
|
|
624
|
+
personalFields: ['name'],
|
|
625
|
+
},
|
|
626
|
+
})
|
|
627
|
+
).resolves.toEqual({ granted: true });
|
|
628
|
+
|
|
629
|
+
await flush();
|
|
630
|
+
expect(document.body.textContent).toContain('Achievement, Skill');
|
|
631
|
+
expect(document.body.textContent).toContain('name');
|
|
632
|
+
});
|
|
633
|
+
|
|
260
634
|
it('reports a ready sync status so onSyncComplete resolves', async () => {
|
|
261
635
|
const lc = createPartnerConnect({ mockOptions: { ui: false } });
|
|
262
636
|
await expect(lc.getSyncStatus()).resolves.toMatchObject({ status: 'ready' });
|
|
263
637
|
});
|
|
638
|
+
|
|
639
|
+
it('validates inline credential templates offline', () => {
|
|
640
|
+
const lc = createPartnerConnect({ mockOptions: { ui: false } });
|
|
641
|
+
|
|
642
|
+
expect(
|
|
643
|
+
lc.validateCredentialTemplate(
|
|
644
|
+
{
|
|
645
|
+
name: 'Completed {{courseName}}',
|
|
646
|
+
description: 'Awarded for finishing {{courseName}}.',
|
|
647
|
+
achievementType: 'Course',
|
|
648
|
+
criteria: { narrative: 'Finished all modules' },
|
|
649
|
+
},
|
|
650
|
+
{ courseName: 'Intro to Baking' }
|
|
651
|
+
)
|
|
652
|
+
).toEqual({ valid: true, errors: [] });
|
|
653
|
+
|
|
654
|
+
const invalid = lc.validateCredentialTemplate(
|
|
655
|
+
{
|
|
656
|
+
name: 'Completed {{courseName}}',
|
|
657
|
+
description: 'Awarded for finishing {{courseName}}.',
|
|
658
|
+
},
|
|
659
|
+
{ wrongKey: 'Intro to Baking' }
|
|
660
|
+
);
|
|
661
|
+
|
|
662
|
+
expect(invalid.valid).toBe(false);
|
|
663
|
+
expect(invalid.errors).toEqual(
|
|
664
|
+
expect.arrayContaining([
|
|
665
|
+
expect.objectContaining({ path: 'templateData.courseName' }),
|
|
666
|
+
expect.objectContaining({ path: 'templateData.wrongKey' }),
|
|
667
|
+
])
|
|
668
|
+
);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('previews compiled templates offline via the instance method', () => {
|
|
672
|
+
const lc = createPartnerConnect({ mockOptions: { ui: false } });
|
|
673
|
+
|
|
674
|
+
const preview = lc.previewCompiledTemplate(
|
|
675
|
+
{
|
|
676
|
+
name: 'Completed {{courseName}}',
|
|
677
|
+
description: 'Awarded for finishing {{courseName}}.',
|
|
678
|
+
credits: { earned: '{{earnedCredits}}' },
|
|
679
|
+
},
|
|
680
|
+
{ courseName: 'Intro to Baking', earnedCredits: 3 }
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
expect(preview).toMatchObject({ valid: true, errors: [] });
|
|
684
|
+
expect(preview.compiled).toMatchObject({
|
|
685
|
+
name: 'Completed {{courseName}}',
|
|
686
|
+
credentialSubject: expect.objectContaining({
|
|
687
|
+
achievement: expect.objectContaining({ name: 'Completed {{courseName}}' }),
|
|
688
|
+
creditsEarned: '{{earnedCredits}}',
|
|
689
|
+
}),
|
|
690
|
+
});
|
|
691
|
+
expect(preview.rendered).toMatchObject({
|
|
692
|
+
name: 'Completed Intro to Baking',
|
|
693
|
+
credentialSubject: expect.objectContaining({
|
|
694
|
+
achievement: expect.objectContaining({ name: 'Completed Intro to Baking' }),
|
|
695
|
+
creditsEarned: 3,
|
|
696
|
+
}),
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it('previews compiled templates offline via the standalone export and returns data errors', () => {
|
|
701
|
+
const invalidTemplate = previewCompiledTemplate({ description: 'Missing name' } as never);
|
|
702
|
+
expect(invalidTemplate.valid).toBe(false);
|
|
703
|
+
expect(invalidTemplate.compiled).toBeUndefined();
|
|
704
|
+
|
|
705
|
+
const invalidData = previewCompiledTemplate(
|
|
706
|
+
{
|
|
707
|
+
name: 'Completed {{courseName}}',
|
|
708
|
+
description: 'Awarded for finishing {{courseName}}.',
|
|
709
|
+
},
|
|
710
|
+
{ wrongKey: 'Intro to Baking' }
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
expect(invalidData.valid).toBe(false);
|
|
714
|
+
expect(invalidData.compiled).toBeDefined();
|
|
715
|
+
expect(invalidData.rendered).toBeUndefined();
|
|
716
|
+
expect(invalidData.errors).toEqual(
|
|
717
|
+
expect.arrayContaining([
|
|
718
|
+
expect.objectContaining({ path: 'templateData.courseName' }),
|
|
719
|
+
expect.objectContaining({ path: 'templateData.wrongKey' }),
|
|
720
|
+
])
|
|
721
|
+
);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it('rejects invalid inline templates before posting to the host', async () => {
|
|
725
|
+
const originalTop = Object.getOwnPropertyDescriptor(window, 'top');
|
|
726
|
+
const originalLocation = Object.getOwnPropertyDescriptor(window, 'location');
|
|
727
|
+
|
|
728
|
+
Object.defineProperty(window, 'top', {
|
|
729
|
+
configurable: true,
|
|
730
|
+
get: () => ({}) as Window,
|
|
731
|
+
});
|
|
732
|
+
Object.defineProperty(window, 'location', {
|
|
733
|
+
configurable: true,
|
|
734
|
+
value: {
|
|
735
|
+
hostname: 'learncard.app',
|
|
736
|
+
search: '',
|
|
737
|
+
href: 'https://learncard.app/',
|
|
738
|
+
ancestorOrigins: ['https://learncard.app'],
|
|
739
|
+
},
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
const postMessageSpy = vi.spyOn(window.parent, 'postMessage');
|
|
743
|
+
|
|
744
|
+
try {
|
|
745
|
+
const lc = createPartnerConnect({ mock: false });
|
|
746
|
+
|
|
747
|
+
await expect(
|
|
748
|
+
lc.sendCredential({
|
|
749
|
+
alias: 'course-complete',
|
|
750
|
+
template: {
|
|
751
|
+
description: 'Missing name',
|
|
752
|
+
} as unknown as Parameters<typeof lc.validateCredentialTemplate>[0],
|
|
753
|
+
})
|
|
754
|
+
).rejects.toMatchObject({ code: 'TEMPLATE_INVALID' });
|
|
755
|
+
|
|
756
|
+
expect(postMessageSpy).not.toHaveBeenCalled();
|
|
757
|
+
} finally {
|
|
758
|
+
postMessageSpy.mockRestore();
|
|
759
|
+
if (originalTop) Object.defineProperty(window, 'top', originalTop);
|
|
760
|
+
if (originalLocation) Object.defineProperty(window, 'location', originalLocation);
|
|
761
|
+
}
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it('rejects invalid declarative consent scopes before posting to the host', async () => {
|
|
765
|
+
const originalTop = Object.getOwnPropertyDescriptor(window, 'top');
|
|
766
|
+
const originalLocation = Object.getOwnPropertyDescriptor(window, 'location');
|
|
767
|
+
|
|
768
|
+
Object.defineProperty(window, 'top', {
|
|
769
|
+
configurable: true,
|
|
770
|
+
get: () => ({}) as Window,
|
|
771
|
+
});
|
|
772
|
+
Object.defineProperty(window, 'location', {
|
|
773
|
+
configurable: true,
|
|
774
|
+
value: {
|
|
775
|
+
hostname: 'learncard.app',
|
|
776
|
+
search: '',
|
|
777
|
+
href: 'https://learncard.app/',
|
|
778
|
+
ancestorOrigins: ['https://learncard.app'],
|
|
779
|
+
},
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
const postMessageSpy = vi.spyOn(window.parent, 'postMessage');
|
|
783
|
+
|
|
784
|
+
try {
|
|
785
|
+
const lc = createPartnerConnect({ mock: false });
|
|
786
|
+
|
|
787
|
+
await expect(
|
|
788
|
+
lc.requestConsent({
|
|
789
|
+
read: { credentialCategories: ['NotARealCategory' as never] },
|
|
790
|
+
})
|
|
791
|
+
).rejects.toMatchObject({ code: 'CONSENT_SCOPES_INVALID' });
|
|
792
|
+
|
|
793
|
+
expect(postMessageSpy).not.toHaveBeenCalled();
|
|
794
|
+
} finally {
|
|
795
|
+
postMessageSpy.mockRestore();
|
|
796
|
+
if (originalTop) Object.defineProperty(window, 'top', originalTop);
|
|
797
|
+
if (originalLocation) Object.defineProperty(window, 'location', originalLocation);
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it('forwards raw declarative consent scopes to the host payload', async () => {
|
|
802
|
+
const originalTop = Object.getOwnPropertyDescriptor(window, 'top');
|
|
803
|
+
const originalLocation = Object.getOwnPropertyDescriptor(window, 'location');
|
|
804
|
+
|
|
805
|
+
Object.defineProperty(window, 'top', {
|
|
806
|
+
configurable: true,
|
|
807
|
+
get: () => ({}) as Window,
|
|
808
|
+
});
|
|
809
|
+
Object.defineProperty(window, 'location', {
|
|
810
|
+
configurable: true,
|
|
811
|
+
value: {
|
|
812
|
+
hostname: 'learncard.app',
|
|
813
|
+
search: '',
|
|
814
|
+
href: 'https://learncard.app/',
|
|
815
|
+
ancestorOrigins: ['https://learncard.app'],
|
|
816
|
+
},
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
let capturedMessage: unknown;
|
|
820
|
+
const postMessageSpy = vi
|
|
821
|
+
.spyOn(window.parent, 'postMessage')
|
|
822
|
+
.mockImplementation((message: unknown) => {
|
|
823
|
+
capturedMessage = message;
|
|
824
|
+
|
|
825
|
+
const request = message as {
|
|
826
|
+
action?: string;
|
|
827
|
+
protocol?: string;
|
|
828
|
+
requestId?: string;
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
if (request.action !== 'REQUEST_CONSENT' || !request.requestId) return;
|
|
832
|
+
|
|
833
|
+
window.dispatchEvent(
|
|
834
|
+
new MessageEvent('message', {
|
|
835
|
+
origin: 'https://learncard.app',
|
|
836
|
+
data: {
|
|
837
|
+
protocol: request.protocol,
|
|
838
|
+
requestId: request.requestId,
|
|
839
|
+
type: 'SUCCESS',
|
|
840
|
+
data: { granted: true },
|
|
841
|
+
},
|
|
842
|
+
})
|
|
843
|
+
);
|
|
844
|
+
});
|
|
845
|
+
const scopes: ConsentRequest = {
|
|
846
|
+
read: {
|
|
847
|
+
credentialCategories: ['Achievement', 'Skill'],
|
|
848
|
+
personalFields: ['name'],
|
|
849
|
+
},
|
|
850
|
+
write: {
|
|
851
|
+
credentialCategories: ['Achievement'],
|
|
852
|
+
},
|
|
853
|
+
reason: 'Personalize your training plan',
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
try {
|
|
857
|
+
const lc = createPartnerConnect({ mock: false });
|
|
858
|
+
await expect(lc.requestConsent(scopes, { redirect: true })).resolves.toEqual({
|
|
859
|
+
granted: true,
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
expect(postMessageSpy).toHaveBeenCalledWith(
|
|
863
|
+
expect.objectContaining({
|
|
864
|
+
action: 'REQUEST_CONSENT',
|
|
865
|
+
payload: {
|
|
866
|
+
scopes,
|
|
867
|
+
redirect: true,
|
|
868
|
+
},
|
|
869
|
+
}),
|
|
870
|
+
'https://learncard.app'
|
|
871
|
+
);
|
|
872
|
+
|
|
873
|
+
expect(capturedMessage).toEqual(
|
|
874
|
+
expect.objectContaining({
|
|
875
|
+
action: 'REQUEST_CONSENT',
|
|
876
|
+
payload: {
|
|
877
|
+
scopes,
|
|
878
|
+
redirect: true,
|
|
879
|
+
},
|
|
880
|
+
})
|
|
881
|
+
);
|
|
882
|
+
} finally {
|
|
883
|
+
postMessageSpy.mockRestore();
|
|
884
|
+
if (originalTop) Object.defineProperty(window, 'top', originalTop);
|
|
885
|
+
if (originalLocation) Object.defineProperty(window, 'location', originalLocation);
|
|
886
|
+
}
|
|
887
|
+
});
|
|
264
888
|
});
|
|
265
889
|
|
|
266
890
|
describe('mock counters', () => {
|
|
@@ -377,6 +1001,99 @@ describe('mock coherence (reads reflect this session writes)', () => {
|
|
|
377
1001
|
expect(second.credentialUri).toBe(first.credentialUri);
|
|
378
1002
|
});
|
|
379
1003
|
|
|
1004
|
+
it('issues inline template credentials end-to-end and checkUserHasCredential stays coherent by alias', async () => {
|
|
1005
|
+
const lc = createPartnerConnect({ mock: true, mockOptions: { ui: false } });
|
|
1006
|
+
|
|
1007
|
+
const issued = (await lc.sendCredential({
|
|
1008
|
+
alias: 'course-complete',
|
|
1009
|
+
template: {
|
|
1010
|
+
name: 'Completed {{courseName}}',
|
|
1011
|
+
description: 'Awarded for finishing {{courseName}}.',
|
|
1012
|
+
achievementType: 'Course',
|
|
1013
|
+
criteria: { narrative: 'Finished all modules' },
|
|
1014
|
+
},
|
|
1015
|
+
templateData: { courseName: 'Intro to Baking' },
|
|
1016
|
+
})) as { credentialUri: string; boostUri: string; templateVersion?: number };
|
|
1017
|
+
|
|
1018
|
+
expect(issued.credentialUri).toContain('lc:mock:credential');
|
|
1019
|
+
expect(issued.boostUri).toContain('course-complete');
|
|
1020
|
+
expect(issued.templateVersion).toBe(1);
|
|
1021
|
+
|
|
1022
|
+
const check = await lc.checkUserHasCredential({ templateAlias: 'course-complete' });
|
|
1023
|
+
expect(check.hasCredential).toBe(true);
|
|
1024
|
+
|
|
1025
|
+
const context = await lc.requestLearnerContext({ format: 'structured' });
|
|
1026
|
+
const credential = context.raw?.credentials[0] as { name?: string; _mock?: boolean };
|
|
1027
|
+
|
|
1028
|
+
expect(credential.name).toBe('Completed Intro to Baking');
|
|
1029
|
+
expect(credential._mock).toBe(true);
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
it('bumps inline template version when the template changes under the same alias', async () => {
|
|
1033
|
+
const lc = createPartnerConnect({ mock: true, mockOptions: { ui: false } });
|
|
1034
|
+
|
|
1035
|
+
const first = (await lc.sendCredential({
|
|
1036
|
+
alias: 'course-complete',
|
|
1037
|
+
template: {
|
|
1038
|
+
name: 'Completed {{courseName}}',
|
|
1039
|
+
description: 'Awarded for finishing {{courseName}}.',
|
|
1040
|
+
},
|
|
1041
|
+
templateData: { courseName: 'Intro to Baking' },
|
|
1042
|
+
})) as { templateVersion?: number };
|
|
1043
|
+
|
|
1044
|
+
const second = (await lc.sendCredential({
|
|
1045
|
+
alias: 'course-complete',
|
|
1046
|
+
template: {
|
|
1047
|
+
name: 'Graduated {{courseName}}',
|
|
1048
|
+
description: 'Awarded for finishing {{courseName}}.',
|
|
1049
|
+
},
|
|
1050
|
+
templateData: { courseName: 'Intro to Baking' },
|
|
1051
|
+
})) as { templateVersion?: number };
|
|
1052
|
+
|
|
1053
|
+
expect(first.templateVersion).toBe(1);
|
|
1054
|
+
expect(second.templateVersion).toBe(2);
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
it('mock host rejects invalid inline events even when sendAppEvent is called directly', async () => {
|
|
1058
|
+
const lc = createPartnerConnect({ mock: true, mockOptions: { ui: false } });
|
|
1059
|
+
|
|
1060
|
+
await expect(
|
|
1061
|
+
lc.sendAppEvent({
|
|
1062
|
+
type: 'send-credential',
|
|
1063
|
+
alias: 'course-complete',
|
|
1064
|
+
template: {
|
|
1065
|
+
name: 'Completed {{courseName}}',
|
|
1066
|
+
},
|
|
1067
|
+
templateData: { wrongKey: 'Intro to Baking' },
|
|
1068
|
+
} as never)
|
|
1069
|
+
).rejects.toMatchObject({ code: 'TEMPLATE_DATA_INVALID' });
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
it('mock rejections use PartnerConnectError instances', async () => {
|
|
1073
|
+
const lc = createPartnerConnect({ mock: true, mockOptions: { ui: false } });
|
|
1074
|
+
|
|
1075
|
+
await expect(
|
|
1076
|
+
lc.sendAppEvent({
|
|
1077
|
+
type: 'send-credential',
|
|
1078
|
+
alias: 'course-complete',
|
|
1079
|
+
template: { name: 'Completed {{courseName}}' },
|
|
1080
|
+
templateData: { wrongKey: 'Intro to Baking' },
|
|
1081
|
+
} as never)
|
|
1082
|
+
).rejects.toBeInstanceOf(PartnerConnectError);
|
|
1083
|
+
|
|
1084
|
+
try {
|
|
1085
|
+
await lc.sendAppEvent({
|
|
1086
|
+
type: 'send-credential',
|
|
1087
|
+
alias: 'course-complete',
|
|
1088
|
+
template: { name: 'Completed {{courseName}}' },
|
|
1089
|
+
templateData: { wrongKey: 'Intro to Baking' },
|
|
1090
|
+
} as never);
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
expect(error).toBeInstanceOf(PartnerConnectError);
|
|
1093
|
+
expect(error).toMatchObject({ code: 'TEMPLATE_DATA_INVALID' });
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
|
|
380
1097
|
it('initiateTemplateIssue populates recipients and issuance status', async () => {
|
|
381
1098
|
const lc = createPartnerConnect({ mockOptions: { ui: false } });
|
|
382
1099
|
await lc.initiateTemplateIssue('boost-xyz', ['alice', 'bob']);
|
|
@@ -393,6 +1110,413 @@ describe('mock coherence (reads reflect this session writes)', () => {
|
|
|
393
1110
|
});
|
|
394
1111
|
});
|
|
395
1112
|
|
|
1113
|
+
describe('captured app manifest + publish URL', () => {
|
|
1114
|
+
it('accumulates and dedupes manifest data across mixed successful calls', async () => {
|
|
1115
|
+
document.title = ' Mock Partner App ';
|
|
1116
|
+
|
|
1117
|
+
const icon = document.createElement('link');
|
|
1118
|
+
icon.rel = 'icon';
|
|
1119
|
+
icon.href = '/favicon.ico';
|
|
1120
|
+
document.head.appendChild(icon);
|
|
1121
|
+
|
|
1122
|
+
try {
|
|
1123
|
+
const lc = createPartnerConnect({
|
|
1124
|
+
mock: true,
|
|
1125
|
+
mockOptions: { ui: false, namespace: 'manifest-mixed' },
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
await lc.requestIdentity();
|
|
1129
|
+
await lc.requestConsent({
|
|
1130
|
+
read: {
|
|
1131
|
+
credentialCategories: ['Skill', 'Achievement'],
|
|
1132
|
+
personalFields: ['name'],
|
|
1133
|
+
},
|
|
1134
|
+
reason: 'First reason wins',
|
|
1135
|
+
});
|
|
1136
|
+
await lc.requestConsent({
|
|
1137
|
+
read: {
|
|
1138
|
+
credentialCategories: ['Achievement', 'Skill'],
|
|
1139
|
+
personalFields: ['name'],
|
|
1140
|
+
},
|
|
1141
|
+
reason: 'Second reason should be ignored',
|
|
1142
|
+
});
|
|
1143
|
+
await lc.sendCredential({ templateAlias: 'legacy-template' });
|
|
1144
|
+
await lc.launchFeature('/wallet');
|
|
1145
|
+
await lc.launchFeature('/wallet');
|
|
1146
|
+
await lc.askCredentialSearch({
|
|
1147
|
+
query: [],
|
|
1148
|
+
challenge: 'challenge',
|
|
1149
|
+
domain: 'example.test',
|
|
1150
|
+
});
|
|
1151
|
+
const issued = (await lc.sendCredential({
|
|
1152
|
+
alias: 'course-complete',
|
|
1153
|
+
template: {
|
|
1154
|
+
name: 'Completed {{courseName}}',
|
|
1155
|
+
description: 'Awarded for finishing {{courseName}}.',
|
|
1156
|
+
},
|
|
1157
|
+
templateData: { courseName: 'Algebra' },
|
|
1158
|
+
})) as { credentialUri: string };
|
|
1159
|
+
await lc.askCredentialSpecific(issued.credentialUri);
|
|
1160
|
+
await lc.initiateTemplateIssue('boost-xyz', ['alice']);
|
|
1161
|
+
await lc.requestLearnerContext({ format: 'structured' });
|
|
1162
|
+
await lc.sendNotification({ title: 'Hello' });
|
|
1163
|
+
await lc.incrementCounter('coins', 5);
|
|
1164
|
+
await lc.getCounter('coins');
|
|
1165
|
+
await lc.getCounters(['coins', 'stars']);
|
|
1166
|
+
|
|
1167
|
+
const manifest = lc.getCapturedManifest();
|
|
1168
|
+
expect(manifest).toBeDefined();
|
|
1169
|
+
expect(manifest?.appUrl).toBe(`${window.location.origin}${window.location.pathname}`);
|
|
1170
|
+
expect(manifest?.suggestedName).toBe('Mock Partner App');
|
|
1171
|
+
expect(manifest?.suggestedIconUrl).toBe(
|
|
1172
|
+
new URL('/favicon.ico', window.location.href).toString()
|
|
1173
|
+
);
|
|
1174
|
+
expect(manifest?.permissions.sort()).toEqual(
|
|
1175
|
+
[
|
|
1176
|
+
'credential_by_id',
|
|
1177
|
+
'credential_search',
|
|
1178
|
+
'launch_feature',
|
|
1179
|
+
'request_consent',
|
|
1180
|
+
'request_identity',
|
|
1181
|
+
'send_credential',
|
|
1182
|
+
'template_issuance',
|
|
1183
|
+
].sort()
|
|
1184
|
+
);
|
|
1185
|
+
expect(manifest?.featuresLaunched).toEqual(['/wallet']);
|
|
1186
|
+
expect(manifest?.counterKeys.sort()).toEqual(['coins', 'stars'].sort());
|
|
1187
|
+
expect(manifest?.usedLearnerContext).toBe(true);
|
|
1188
|
+
expect(manifest?.usedNotifications).toBe(true);
|
|
1189
|
+
expect(manifest?.consentRequests).toHaveLength(1);
|
|
1190
|
+
expect(manifest?.consentRequests[0].reason).toBe('First reason wins');
|
|
1191
|
+
expect(manifest?.templates).toHaveLength(1);
|
|
1192
|
+
expect(manifest?.templates[0]).toMatchObject({
|
|
1193
|
+
alias: 'course-complete',
|
|
1194
|
+
version: 1,
|
|
1195
|
+
});
|
|
1196
|
+
expect(manifest?.firstCapturedAt).toBeDefined();
|
|
1197
|
+
expect(manifest?.lastUpdatedAt).toBeDefined();
|
|
1198
|
+
} finally {
|
|
1199
|
+
icon.remove();
|
|
1200
|
+
}
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
it('upserts inline templates by alias and stores the latest version + template body', async () => {
|
|
1204
|
+
const lc = createPartnerConnect({
|
|
1205
|
+
mock: true,
|
|
1206
|
+
mockOptions: { ui: false, namespace: 'manifest-inline-upsert' },
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
await lc.sendCredential({
|
|
1210
|
+
alias: 'course-complete',
|
|
1211
|
+
template: {
|
|
1212
|
+
name: 'Completed {{courseName}}',
|
|
1213
|
+
description: 'Awarded for finishing {{courseName}}.',
|
|
1214
|
+
},
|
|
1215
|
+
templateData: { courseName: 'Algebra' },
|
|
1216
|
+
});
|
|
1217
|
+
await lc.sendCredential({
|
|
1218
|
+
alias: 'course-complete',
|
|
1219
|
+
template: {
|
|
1220
|
+
name: 'Graduated {{courseName}}',
|
|
1221
|
+
description: 'Awarded for finishing {{courseName}}.',
|
|
1222
|
+
},
|
|
1223
|
+
templateData: { courseName: 'Algebra' },
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
expect(lc.getCapturedManifest()?.templates).toEqual([
|
|
1227
|
+
expect.objectContaining({
|
|
1228
|
+
alias: 'course-complete',
|
|
1229
|
+
version: 2,
|
|
1230
|
+
template: expect.objectContaining({ name: 'Graduated {{courseName}}' }),
|
|
1231
|
+
}),
|
|
1232
|
+
]);
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
it('builds publish URLs as soon as any manifest has been captured', async () => {
|
|
1236
|
+
const lc = createPartnerConnect({
|
|
1237
|
+
mock: true,
|
|
1238
|
+
hostOrigin: ['capacitor://localhost', 'https://staging.learncard.app'],
|
|
1239
|
+
mockOptions: { ui: false, namespace: 'publish-threshold' },
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
expect(lc.getPublishUrl()).toBeUndefined();
|
|
1243
|
+
|
|
1244
|
+
await lc.requestIdentity();
|
|
1245
|
+
expect(lc.getPublishUrl()).toContain(
|
|
1246
|
+
'https://staging.learncard.app/app-store/developer/submit?manifest='
|
|
1247
|
+
);
|
|
1248
|
+
|
|
1249
|
+
await lc.launchFeature('/wallet');
|
|
1250
|
+
const publishUrl = lc.getPublishUrl();
|
|
1251
|
+
expect(publishUrl).toContain(
|
|
1252
|
+
'https://staging.learncard.app/app-store/developer/submit?manifest='
|
|
1253
|
+
);
|
|
1254
|
+
|
|
1255
|
+
const encodedManifest = publishUrl?.split('manifest=')[1];
|
|
1256
|
+
expect(encodedManifest).toBeDefined();
|
|
1257
|
+
expect(decodeManifestFromUrl(encodedManifest ?? '')).toMatchObject({
|
|
1258
|
+
permissions: expect.arrayContaining(['request_identity', 'launch_feature']),
|
|
1259
|
+
});
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
it('suppresses the publish prompt for 24 hours after dismissal', async () => {
|
|
1263
|
+
const namespace = 'publish-dismissal';
|
|
1264
|
+
|
|
1265
|
+
const first = createPartnerConnect({
|
|
1266
|
+
mock: true,
|
|
1267
|
+
mockOptions: { ui: true, namespace },
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
await first.requestIdentity();
|
|
1271
|
+
await first.launchFeature('/wallet');
|
|
1272
|
+
await flush();
|
|
1273
|
+
|
|
1274
|
+
expect(document.body.textContent).toContain('Publish to LearnCard');
|
|
1275
|
+
|
|
1276
|
+
const closeButton = document.querySelector('.lc-mock-close') as HTMLButtonElement | null;
|
|
1277
|
+
expect(closeButton).not.toBeNull();
|
|
1278
|
+
closeButton?.click();
|
|
1279
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
1280
|
+
expect(document.body.textContent).not.toContain('Publish to LearnCard');
|
|
1281
|
+
|
|
1282
|
+
first.destroy();
|
|
1283
|
+
|
|
1284
|
+
const second = createPartnerConnect({
|
|
1285
|
+
mock: true,
|
|
1286
|
+
mockOptions: { ui: true, namespace },
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
await second.requestIdentity();
|
|
1290
|
+
await second.launchFeature('/wallet');
|
|
1291
|
+
await flush();
|
|
1292
|
+
|
|
1293
|
+
expect(document.body.textContent).not.toContain('Publish to LearnCard');
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
it('isolates captured manifests for different document titles on the same origin', async () => {
|
|
1297
|
+
const namespace = 'manifest-fingerprint-isolation';
|
|
1298
|
+
|
|
1299
|
+
document.title = 'First Local App';
|
|
1300
|
+
const first = createPartnerConnect({
|
|
1301
|
+
mock: true,
|
|
1302
|
+
mockOptions: { ui: false, namespace },
|
|
1303
|
+
});
|
|
1304
|
+
await first.requestIdentity();
|
|
1305
|
+
await first.launchFeature('/wallet');
|
|
1306
|
+
|
|
1307
|
+
document.title = 'Second Local App';
|
|
1308
|
+
const second = createPartnerConnect({
|
|
1309
|
+
mock: true,
|
|
1310
|
+
mockOptions: { ui: false, namespace },
|
|
1311
|
+
});
|
|
1312
|
+
await second.sendCredential({
|
|
1313
|
+
alias: 'course-complete',
|
|
1314
|
+
template: {
|
|
1315
|
+
name: 'Completed {{courseName}}',
|
|
1316
|
+
description: 'Awarded for finishing {{courseName}}.',
|
|
1317
|
+
},
|
|
1318
|
+
templateData: { courseName: 'Biology' },
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
expect(first.getCapturedManifest()).toMatchObject({
|
|
1322
|
+
suggestedName: 'First Local App',
|
|
1323
|
+
permissions: expect.arrayContaining(['request_identity', 'launch_feature']),
|
|
1324
|
+
templates: [],
|
|
1325
|
+
});
|
|
1326
|
+
expect(second.getCapturedManifest()).toMatchObject({
|
|
1327
|
+
suggestedName: 'Second Local App',
|
|
1328
|
+
templates: [expect.objectContaining({ alias: 'course-complete' })],
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
expect(readManifestMap(namespace)).toMatchObject({
|
|
1332
|
+
'first-local-app': expect.objectContaining({ suggestedName: 'First Local App' }),
|
|
1333
|
+
'second-local-app': expect.objectContaining({ suggestedName: 'Second Local App' }),
|
|
1334
|
+
});
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
it('restores the original manifest when switching back to a previous app title', async () => {
|
|
1338
|
+
const namespace = 'manifest-fingerprint-restore';
|
|
1339
|
+
|
|
1340
|
+
document.title = 'App One';
|
|
1341
|
+
const first = createPartnerConnect({
|
|
1342
|
+
mock: true,
|
|
1343
|
+
mockOptions: { ui: false, namespace },
|
|
1344
|
+
});
|
|
1345
|
+
await first.requestIdentity();
|
|
1346
|
+
await first.launchFeature('/wallet');
|
|
1347
|
+
|
|
1348
|
+
document.title = 'App Two';
|
|
1349
|
+
const second = createPartnerConnect({
|
|
1350
|
+
mock: true,
|
|
1351
|
+
mockOptions: { ui: false, namespace },
|
|
1352
|
+
});
|
|
1353
|
+
await second.sendCredential({ templateAlias: 'team-badge' });
|
|
1354
|
+
|
|
1355
|
+
document.title = 'App One';
|
|
1356
|
+
const restored = createPartnerConnect({
|
|
1357
|
+
mock: true,
|
|
1358
|
+
mockOptions: { ui: false, namespace },
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
expect(restored.getCapturedManifest()).toMatchObject({
|
|
1362
|
+
suggestedName: 'App One',
|
|
1363
|
+
featuresLaunched: ['/wallet'],
|
|
1364
|
+
permissions: expect.arrayContaining(['request_identity', 'launch_feature']),
|
|
1365
|
+
templates: [],
|
|
1366
|
+
});
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
it('prefers mockOptions.appId over the title fingerprint heuristic', async () => {
|
|
1370
|
+
const namespace = 'manifest-explicit-app-id';
|
|
1371
|
+
|
|
1372
|
+
document.title = 'Ignored Title';
|
|
1373
|
+
const lc = createPartnerConnect({
|
|
1374
|
+
mock: true,
|
|
1375
|
+
mockOptions: { ui: false, namespace, appId: 'custom-manifest-slot' },
|
|
1376
|
+
});
|
|
1377
|
+
await lc.requestIdentity();
|
|
1378
|
+
|
|
1379
|
+
expect(readManifestMap(namespace)).toMatchObject({
|
|
1380
|
+
'custom-manifest-slot': expect.objectContaining({ suggestedName: 'Ignored Title' }),
|
|
1381
|
+
});
|
|
1382
|
+
expect(readManifestMap(namespace)['ignored-title']).toBeUndefined();
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
it('warns when the same fingerprint is reused across different page paths', () => {
|
|
1386
|
+
const namespace = 'manifest-fingerprint-warning';
|
|
1387
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
1388
|
+
const originalLocation = Object.getOwnPropertyDescriptor(window, 'location');
|
|
1389
|
+
|
|
1390
|
+
document.title = 'Shared App';
|
|
1391
|
+
localStorage.setItem(
|
|
1392
|
+
`${namespace}:manifests`,
|
|
1393
|
+
JSON.stringify({
|
|
1394
|
+
'shared-app': {
|
|
1395
|
+
manifestVersion: 1,
|
|
1396
|
+
appUrl: 'http://localhost/other-path',
|
|
1397
|
+
suggestedName: 'Shared App',
|
|
1398
|
+
permissions: ['request_identity'],
|
|
1399
|
+
templates: [],
|
|
1400
|
+
consentRequests: [],
|
|
1401
|
+
featuresLaunched: [],
|
|
1402
|
+
counterKeys: [],
|
|
1403
|
+
usedLearnerContext: false,
|
|
1404
|
+
usedNotifications: false,
|
|
1405
|
+
firstCapturedAt: '2026-01-01T00:00:00.000Z',
|
|
1406
|
+
lastUpdatedAt: '2026-01-01T00:00:00.000Z',
|
|
1407
|
+
},
|
|
1408
|
+
})
|
|
1409
|
+
);
|
|
1410
|
+
|
|
1411
|
+
Object.defineProperty(window, 'location', {
|
|
1412
|
+
configurable: true,
|
|
1413
|
+
value: {
|
|
1414
|
+
origin: 'http://localhost',
|
|
1415
|
+
pathname: '/current-path',
|
|
1416
|
+
hostname: 'localhost',
|
|
1417
|
+
search: '',
|
|
1418
|
+
href: 'http://localhost/current-path',
|
|
1419
|
+
},
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
try {
|
|
1423
|
+
createPartnerConnect({ mock: true, mockOptions: { ui: false, namespace } });
|
|
1424
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
1425
|
+
expect.stringContaining('Two apps may be sharing mock state')
|
|
1426
|
+
);
|
|
1427
|
+
} finally {
|
|
1428
|
+
if (originalLocation) Object.defineProperty(window, 'location', originalLocation);
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
it('migrates the legacy single-manifest key into the fingerprinted map and removes it', () => {
|
|
1433
|
+
const namespace = 'manifest-legacy-migration';
|
|
1434
|
+
const legacyManifest: CapturedAppManifest = {
|
|
1435
|
+
manifestVersion: 1,
|
|
1436
|
+
appUrl: 'http://localhost:4321',
|
|
1437
|
+
suggestedName: 'Legacy App',
|
|
1438
|
+
permissions: ['request_identity'],
|
|
1439
|
+
templates: [],
|
|
1440
|
+
consentRequests: [],
|
|
1441
|
+
featuresLaunched: [],
|
|
1442
|
+
counterKeys: [],
|
|
1443
|
+
usedLearnerContext: false,
|
|
1444
|
+
usedNotifications: false,
|
|
1445
|
+
firstCapturedAt: '2026-01-01T00:00:00.000Z',
|
|
1446
|
+
lastUpdatedAt: '2026-01-01T00:00:00.000Z',
|
|
1447
|
+
};
|
|
1448
|
+
|
|
1449
|
+
localStorage.setItem(`${namespace}:manifest`, JSON.stringify(legacyManifest));
|
|
1450
|
+
document.title = 'Legacy App';
|
|
1451
|
+
|
|
1452
|
+
const lc = createPartnerConnect({
|
|
1453
|
+
mock: true,
|
|
1454
|
+
mockOptions: { ui: false, namespace },
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
expect(lc.getCapturedManifest()).toEqual({ ...legacyManifest, appKey: expect.any(String) });
|
|
1458
|
+
expect(localStorage.getItem(`${namespace}:manifest`)).toBeNull();
|
|
1459
|
+
expect(readManifestMap(namespace)).toMatchObject({
|
|
1460
|
+
'legacy-app': expect.objectContaining({ suggestedName: 'Legacy App' }),
|
|
1461
|
+
});
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
it("does not let one app's publish dismissal suppress another app on the same origin", async () => {
|
|
1465
|
+
const namespace = 'publish-dismissal-per-app';
|
|
1466
|
+
|
|
1467
|
+
document.title = 'Dismissed App';
|
|
1468
|
+
const first = createPartnerConnect({
|
|
1469
|
+
mock: true,
|
|
1470
|
+
mockOptions: { ui: true, namespace },
|
|
1471
|
+
});
|
|
1472
|
+
await first.requestIdentity();
|
|
1473
|
+
await first.launchFeature('/wallet');
|
|
1474
|
+
await flush();
|
|
1475
|
+
|
|
1476
|
+
const closeButton = document.querySelector('.lc-mock-close') as HTMLButtonElement | null;
|
|
1477
|
+
expect(closeButton).not.toBeNull();
|
|
1478
|
+
closeButton?.click();
|
|
1479
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
1480
|
+
|
|
1481
|
+
first.destroy();
|
|
1482
|
+
|
|
1483
|
+
document.title = 'Fresh App';
|
|
1484
|
+
const second = createPartnerConnect({
|
|
1485
|
+
mock: true,
|
|
1486
|
+
mockOptions: { ui: true, namespace },
|
|
1487
|
+
});
|
|
1488
|
+
await second.requestIdentity();
|
|
1489
|
+
await second.launchFeature('/wallet');
|
|
1490
|
+
await flush();
|
|
1491
|
+
|
|
1492
|
+
expect(document.body.textContent).toContain('Publish to LearnCard');
|
|
1493
|
+
expect(
|
|
1494
|
+
localStorage.getItem(`${namespace}:publish-dismissed-at:dismissed-app`)
|
|
1495
|
+
).toBeTruthy();
|
|
1496
|
+
expect(localStorage.getItem(`${namespace}:publish-dismissed-at:fresh-app`)).toBeNull();
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
it('caches the initial title after first manifest access instead of switching slots later', async () => {
|
|
1500
|
+
const namespace = 'manifest-title-cache';
|
|
1501
|
+
|
|
1502
|
+
document.title = 'Initial Title App';
|
|
1503
|
+
const lc = createPartnerConnect({
|
|
1504
|
+
mock: true,
|
|
1505
|
+
mockOptions: { ui: false, namespace },
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
await lc.requestIdentity();
|
|
1509
|
+
document.title = 'Changed Route Title';
|
|
1510
|
+
await lc.launchFeature('/wallet');
|
|
1511
|
+
|
|
1512
|
+
expect(lc.getCapturedManifest()).toMatchObject({
|
|
1513
|
+
suggestedName: 'Initial Title App',
|
|
1514
|
+
featuresLaunched: ['/wallet'],
|
|
1515
|
+
});
|
|
1516
|
+
expect(Object.keys(readManifestMap(namespace))).toEqual(['initial-title-app']);
|
|
1517
|
+
});
|
|
1518
|
+
});
|
|
1519
|
+
|
|
396
1520
|
describe('mock seeding', () => {
|
|
397
1521
|
it('seeds identity did returned by requestIdentity', async () => {
|
|
398
1522
|
const lc = createPartnerConnect({
|
|
@@ -435,15 +1559,155 @@ describe('mock UI', () => {
|
|
|
435
1559
|
expect(toastCount()).toBe(1);
|
|
436
1560
|
});
|
|
437
1561
|
|
|
438
|
-
it('
|
|
1562
|
+
it('does not inject the manifest HUD when ui is disabled', async () => {
|
|
1563
|
+
const lc = createPartnerConnect({ mockOptions: { ui: false, namespace: 'hud-hidden' } });
|
|
1564
|
+
await lc.requestIdentity();
|
|
1565
|
+
await flush();
|
|
1566
|
+
expect(document.querySelector('.lc-mock-hud')).toBeNull();
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
it('shows a collapsible manifest HUD and persists its expanded state', async () => {
|
|
1570
|
+
const namespace = 'hud-persisted-state';
|
|
1571
|
+
document.title = 'HUD Persisted State';
|
|
1572
|
+
const lc = createPartnerConnect({ mock: true, mockOptions: { ui: true, namespace } });
|
|
1573
|
+
|
|
1574
|
+
await lc.requestIdentity();
|
|
1575
|
+
await flush();
|
|
1576
|
+
|
|
1577
|
+
const collapsedButton = document.querySelector(
|
|
1578
|
+
'.lc-mock-hud-pill'
|
|
1579
|
+
) as HTMLButtonElement | null;
|
|
1580
|
+
expect(collapsedButton?.textContent).toContain('Practice mode');
|
|
1581
|
+
expect(document.querySelector('.lc-mock-hud-card')).toBeNull();
|
|
1582
|
+
|
|
1583
|
+
collapsedButton?.click();
|
|
1584
|
+
await flush();
|
|
1585
|
+
expect(document.querySelector('.lc-mock-hud-card')?.textContent).toContain(
|
|
1586
|
+
'Sign in with LearnCard'
|
|
1587
|
+
);
|
|
1588
|
+
expect(
|
|
1589
|
+
localStorage.getItem(`${namespace}:manifest-hud-collapsed:hud-persisted-state`)
|
|
1590
|
+
).toBe('false');
|
|
1591
|
+
|
|
1592
|
+
lc.destroy();
|
|
1593
|
+
|
|
1594
|
+
const next = createPartnerConnect({ mock: true, mockOptions: { ui: true, namespace } });
|
|
1595
|
+
await next.requestIdentity();
|
|
1596
|
+
await flush();
|
|
1597
|
+
expect(document.querySelector('.lc-mock-hud-card')).not.toBeNull();
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1600
|
+
const expandHud = async (
|
|
1601
|
+
lc: ReturnType<typeof createPartnerConnect>,
|
|
1602
|
+
namespace: string
|
|
1603
|
+
): Promise<void> => {
|
|
1604
|
+
localStorage.setItem(`${namespace}:manifest-hud-collapsed:${namespace}`, 'false');
|
|
1605
|
+
await lc.requestIdentity();
|
|
1606
|
+
await flush();
|
|
1607
|
+
};
|
|
1608
|
+
|
|
1609
|
+
const HUD_HINT = 'Local LearnCard? Add ?lc_publish_override=http://localhost:3000';
|
|
1610
|
+
|
|
1611
|
+
it('hints at lc_publish_override when publishing to production from localhost', async () => {
|
|
1612
|
+
const namespace = 'hud-hint-shown';
|
|
1613
|
+
const lc = createPartnerConnect({
|
|
1614
|
+
mock: true,
|
|
1615
|
+
mockOptions: { ui: true, namespace, appId: namespace },
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
await expandHud(lc, namespace);
|
|
1619
|
+
|
|
1620
|
+
expect(document.querySelector('.lc-mock-hud-publish-hint')?.textContent).toBe(HUD_HINT);
|
|
1621
|
+
|
|
1622
|
+
lc.destroy();
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
it('hides the hint when an explicit publishOrigin is set', async () => {
|
|
1626
|
+
const namespace = 'hud-hint-explicit';
|
|
1627
|
+
const lc = createPartnerConnect({
|
|
1628
|
+
mock: true,
|
|
1629
|
+
mockOptions: {
|
|
1630
|
+
ui: true,
|
|
1631
|
+
namespace,
|
|
1632
|
+
appId: namespace,
|
|
1633
|
+
publishOrigin: 'http://localhost:3000',
|
|
1634
|
+
},
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1637
|
+
await expandHud(lc, namespace);
|
|
1638
|
+
|
|
1639
|
+
expect(document.querySelector('.lc-mock-hud')).not.toBeNull();
|
|
1640
|
+
expect(document.querySelector('.lc-mock-hud-publish-hint')).toBeNull();
|
|
1641
|
+
|
|
1642
|
+
lc.destroy();
|
|
1643
|
+
});
|
|
1644
|
+
|
|
1645
|
+
it('hides the hint when an lc_publish_override is already active', async () => {
|
|
1646
|
+
const namespace = 'hud-hint-override';
|
|
1647
|
+
sessionStorage.setItem('lc_publish_override', 'http://localhost:3000');
|
|
1648
|
+
|
|
1649
|
+
const lc = createPartnerConnect({
|
|
1650
|
+
mock: true,
|
|
1651
|
+
mockOptions: { ui: true, namespace, appId: namespace },
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
await expandHud(lc, namespace);
|
|
1655
|
+
|
|
1656
|
+
expect(lc.getPublishOrigin()).toBe('http://localhost:3000');
|
|
1657
|
+
expect(document.querySelector('.lc-mock-hud-publish-hint')).toBeNull();
|
|
1658
|
+
|
|
1659
|
+
lc.destroy();
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
it('renders a positive-tone toast for requestConsent with scopes', async () => {
|
|
439
1663
|
const lc = createPartnerConnect({ mockOptions: { ui: true } });
|
|
440
|
-
await lc.requestConsent();
|
|
1664
|
+
await lc.requestConsent({ read: { credentialCategories: ['Achievement'] } });
|
|
441
1665
|
await flush();
|
|
442
1666
|
expect(document.querySelector('.lc-mock-toast--positive')).not.toBeNull();
|
|
443
1667
|
});
|
|
444
1668
|
|
|
1669
|
+
it('warns when requestConsent never says what it needs', async () => {
|
|
1670
|
+
const namespace = 'consent-setup';
|
|
1671
|
+
const lc = createPartnerConnect({
|
|
1672
|
+
mock: true,
|
|
1673
|
+
mockOptions: { ui: true, namespace, appId: namespace, publishPrompt: false },
|
|
1674
|
+
});
|
|
1675
|
+
localStorage.setItem(`${namespace}:manifest-hud-collapsed:${namespace}`, 'false');
|
|
1676
|
+
|
|
1677
|
+
await lc.requestConsent();
|
|
1678
|
+
await flush();
|
|
1679
|
+
|
|
1680
|
+
const warning = document.querySelector('.lc-mock-toast--warning');
|
|
1681
|
+
expect(warning?.textContent).toContain('Choose what to ask for');
|
|
1682
|
+
expect(document.querySelector('.lc-mock-toast--positive')).toBeNull();
|
|
1683
|
+
expect(document.querySelector('.lc-mock-hud-card')?.textContent).toContain(
|
|
1684
|
+
'Not set up yet'
|
|
1685
|
+
);
|
|
1686
|
+
expect(document.querySelector('.lc-mock-hud-setup-dot')).not.toBeNull();
|
|
1687
|
+
|
|
1688
|
+
await lc.requestConsent({ read: { credentialCategories: ['Achievement'] } });
|
|
1689
|
+
await flush();
|
|
1690
|
+
expect(document.querySelector('.lc-mock-hud-card')?.textContent).not.toContain(
|
|
1691
|
+
'Not set up yet'
|
|
1692
|
+
);
|
|
1693
|
+
|
|
1694
|
+
lc.destroy();
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1697
|
+
it('treats an existing consent contract as set up', async () => {
|
|
1698
|
+
const namespace = 'consent-contract';
|
|
1699
|
+
const lc = createPartnerConnect({
|
|
1700
|
+
mock: true,
|
|
1701
|
+
mockOptions: { ui: true, namespace, appId: namespace, publishPrompt: false },
|
|
1702
|
+
});
|
|
1703
|
+
await lc.requestConsent('lc:contract:1');
|
|
1704
|
+
await flush();
|
|
1705
|
+
expect(document.querySelector('.lc-mock-toast--warning')).toBeNull();
|
|
1706
|
+
lc.destroy();
|
|
1707
|
+
});
|
|
1708
|
+
|
|
445
1709
|
it('surfaces a toast for every mocked action (not just console)', async () => {
|
|
446
|
-
const lc = createPartnerConnect({ mockOptions: { ui: true } });
|
|
1710
|
+
const lc = createPartnerConnect({ mockOptions: { ui: true, publishPrompt: false } });
|
|
447
1711
|
|
|
448
1712
|
await lc.requestIdentity();
|
|
449
1713
|
await lc.launchFeature('/wallet');
|
|
@@ -473,6 +1737,72 @@ describe('mock UI', () => {
|
|
|
473
1737
|
expect(toastText()).toContain('×3');
|
|
474
1738
|
});
|
|
475
1739
|
|
|
1740
|
+
it('keeps at most three transient notices on screen at once', async () => {
|
|
1741
|
+
const lc = createPartnerConnect({ mockOptions: { ui: true, publishPrompt: false } });
|
|
1742
|
+
|
|
1743
|
+
await lc.requestIdentity();
|
|
1744
|
+
await lc.launchFeature('/wallet');
|
|
1745
|
+
await lc.getSyncStatus();
|
|
1746
|
+
await lc.sendNotification({ title: 'Hi' });
|
|
1747
|
+
await lc.getCounter('coins');
|
|
1748
|
+
await flush();
|
|
1749
|
+
|
|
1750
|
+
const visible = Array.from(document.querySelectorAll('.lc-mock-toast')).filter(
|
|
1751
|
+
node => !node.classList.contains('lc-mock-toast--hidden')
|
|
1752
|
+
);
|
|
1753
|
+
expect(toastCount()).toBe(5);
|
|
1754
|
+
expect(visible).toHaveLength(3);
|
|
1755
|
+
expect(visible[visible.length - 1]?.textContent).toContain('coins');
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
it('lists what the app uses in plain language and offers a publish link', async () => {
|
|
1759
|
+
const namespace = 'hud-plain-language';
|
|
1760
|
+
const lc = createPartnerConnect({
|
|
1761
|
+
mock: true,
|
|
1762
|
+
mockOptions: { ui: true, namespace, appId: namespace, publishPrompt: false },
|
|
1763
|
+
});
|
|
1764
|
+
localStorage.setItem(`${namespace}:manifest-hud-collapsed:${namespace}`, 'false');
|
|
1765
|
+
|
|
1766
|
+
await lc.requestIdentity();
|
|
1767
|
+
await lc.launchFeature('/ai/topics?shortCircuitStep=newTopic');
|
|
1768
|
+
await lc.requestLearnerContext();
|
|
1769
|
+
await flush();
|
|
1770
|
+
|
|
1771
|
+
const card = document.querySelector('.lc-mock-hud-card');
|
|
1772
|
+
expect(card?.textContent).toContain('Sign in with LearnCard');
|
|
1773
|
+
expect(card?.textContent).toContain('/ai/topics');
|
|
1774
|
+
expect(card?.textContent).not.toContain('shortCircuitStep');
|
|
1775
|
+
expect(card?.textContent).not.toContain('request_identity');
|
|
1776
|
+
expect(
|
|
1777
|
+
(document.querySelector('.lc-mock-hud-publish') as HTMLAnchorElement | null)?.href
|
|
1778
|
+
).toContain('/app-store/developer/submit?manifest=');
|
|
1779
|
+
|
|
1780
|
+
lc.destroy();
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
it('keeps the publish card link in sync as the app uses more features', async () => {
|
|
1784
|
+
const namespace = 'publish-link-sync';
|
|
1785
|
+
const lc = createPartnerConnect({
|
|
1786
|
+
mock: true,
|
|
1787
|
+
mockOptions: { ui: true, namespace, appId: namespace },
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
await lc.requestIdentity();
|
|
1791
|
+
await lc.launchFeature('/wallet');
|
|
1792
|
+
await lc.requestLearnerContext();
|
|
1793
|
+
await flush();
|
|
1794
|
+
|
|
1795
|
+
const href =
|
|
1796
|
+
(
|
|
1797
|
+
document.querySelector(
|
|
1798
|
+
'.lc-mock-toast--publish .lc-mock-action'
|
|
1799
|
+
) as HTMLAnchorElement | null
|
|
1800
|
+
)?.href ?? '';
|
|
1801
|
+
expect(href).toBe(lc.getPublishUrl());
|
|
1802
|
+
|
|
1803
|
+
lc.destroy();
|
|
1804
|
+
});
|
|
1805
|
+
|
|
476
1806
|
it('cleans up injected DOM on destroy', async () => {
|
|
477
1807
|
const lc = createPartnerConnect({ mockOptions: { ui: true } });
|
|
478
1808
|
await lc.requestConsent();
|