@learncard/partner-connect 0.5.2 → 0.6.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.
@@ -9,10 +9,286 @@ import { vi } from 'vitest';
9
9
 
10
10
  import type { MockInstance } from 'vitest';
11
11
 
12
- import { PartnerConnect, createPartnerConnect, isEmbedded } from './index';
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.querySelectorAll('.lc-mock-toast, .lc-mock-stack').forEach(node => node.remove());
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('renders a positive-tone toast for requestConsent', async () => {
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();