@oceanum/oceanumlab 4.7.1 → 4.8.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.
Files changed (51) hide show
  1. package/lib/DatameshWidget.d.ts +11 -0
  2. package/lib/DatameshWidget.js +35 -5
  3. package/lib/StoredNotebooks.d.ts +27 -5
  4. package/lib/StoredNotebooks.js +141 -24
  5. package/lib/auth/serverAuth.js +1 -0
  6. package/lib/auth/startSignIn.d.ts +11 -0
  7. package/lib/auth/startSignIn.js +16 -0
  8. package/lib/auth/tokens.d.ts +13 -0
  9. package/lib/index.d.ts +1 -1
  10. package/lib/index.js +43 -7
  11. package/lib/share/client.d.ts +86 -0
  12. package/lib/share/client.js +206 -0
  13. package/lib/share/events.d.ts +7 -0
  14. package/lib/share/events.js +9 -0
  15. package/lib/share/notebook.d.ts +216 -0
  16. package/lib/share/notebook.js +355 -0
  17. package/lib/share/oceanumOnly.d.ts +72 -0
  18. package/lib/share/oceanumOnly.js +127 -0
  19. package/lib/share/plugin.d.ts +23 -0
  20. package/lib/share/plugin.js +981 -0
  21. package/lib/share/upload.d.ts +6 -0
  22. package/lib/share/upload.js +27 -0
  23. package/lib/share/widgets.d.ts +25 -0
  24. package/lib/share/widgets.js +154 -0
  25. package/package.json +5 -1
  26. package/schema/datamesh-connect.json +6 -0
  27. package/src/DatameshWidget.tsx +79 -2
  28. package/src/StoredNotebooks.tsx +274 -24
  29. package/src/__tests__/OceanumPanel.spec.tsx +159 -5
  30. package/src/__tests__/StoredNotebooks.spec.tsx +528 -0
  31. package/src/__tests__/oceanumOnly.spec.ts +317 -0
  32. package/src/__tests__/settingsSchema.spec.ts +11 -1
  33. package/src/__tests__/shareClient.spec.ts +328 -0
  34. package/src/__tests__/shareNotebook.spec.ts +624 -0
  35. package/src/__tests__/sharePlugin.spec.ts +664 -0
  36. package/src/auth/serverAuth.ts +1 -0
  37. package/src/auth/startSignIn.ts +23 -0
  38. package/src/auth/tokens.ts +13 -0
  39. package/src/index.ts +43 -7
  40. package/src/share/client.ts +291 -0
  41. package/src/share/events.ts +10 -0
  42. package/src/share/notebook.ts +525 -0
  43. package/src/share/oceanumOnly.ts +177 -0
  44. package/src/share/plugin.ts +1220 -0
  45. package/src/share/upload.ts +27 -0
  46. package/src/share/widgets.ts +217 -0
  47. package/style/index.css +212 -18
  48. package/lib/specStore.d.ts +0 -37
  49. package/lib/specStore.js +0 -57
  50. package/src/__tests__/specStore.spec.ts +0 -126
  51. package/src/specStore.ts +0 -82
@@ -0,0 +1,624 @@
1
+ import type { INotebookContent } from '@jupyterlab/nbformat';
2
+
3
+ import {
4
+ buildSpecBody,
5
+ isShareableEmail,
6
+ isSpecId,
7
+ nameFromPath,
8
+ notebookFromRecord,
9
+ notebookFromUpload,
10
+ parseNotebookDemo,
11
+ parseShareEmails,
12
+ parseTimestamp,
13
+ partitionSummaries,
14
+ readLink,
15
+ reportForShareFailures,
16
+ sanitizeName,
17
+ shareLink,
18
+ uniqueNotebookPath,
19
+ unlinkedNotebookFromRecord,
20
+ withoutTrust
21
+ } from '../share/notebook';
22
+
23
+ const SPECS = 'https://specs.example.com';
24
+ const ID = '6f1c1a52-4b8e-4c0f-9a57-1d2e3f4a5b6c';
25
+
26
+ function notebook(
27
+ outputText = 'hello',
28
+ metadata: object = {}
29
+ ): INotebookContent {
30
+ return {
31
+ nbformat: 4,
32
+ nbformat_minor: 5,
33
+ metadata: {
34
+ kernelspec: { name: 'python', display_name: 'Python' },
35
+ ...metadata
36
+ },
37
+ cells: [
38
+ { cell_type: 'markdown', source: '# Title', metadata: {} },
39
+ {
40
+ cell_type: 'code',
41
+ source: 'print(1)',
42
+ metadata: {},
43
+ execution_count: 3,
44
+ outputs: [{ output_type: 'stream', name: 'stdout', text: outputText }]
45
+ }
46
+ ]
47
+ };
48
+ }
49
+
50
+ describe('isSpecId', () => {
51
+ it('accepts UUIDs only', () => {
52
+ expect(isSpecId(ID)).toBe(true);
53
+ expect(isSpecId(ID.toUpperCase())).toBe(true);
54
+ expect(isSpecId(`${ID}/permissions`)).toBe(false);
55
+ expect(isSpecId('../x')).toBe(false);
56
+ expect(isSpecId('')).toBe(false);
57
+ expect(isSpecId(42)).toBe(false);
58
+ });
59
+ });
60
+
61
+ describe('readLink', () => {
62
+ it('reads a link for the same spec store', () => {
63
+ expect(
64
+ readLink({ spec_id: ID, specs_url: SPECS, description: 'd' }, SPECS)
65
+ ).toEqual({
66
+ spec_id: ID,
67
+ specs_url: SPECS,
68
+ description: 'd'
69
+ });
70
+ });
71
+
72
+ it('ignores links to another spec store or with a bad id', () => {
73
+ expect(
74
+ readLink({ spec_id: ID, specs_url: 'https://specs.dev' }, SPECS)
75
+ ).toBeNull();
76
+ expect(readLink({ spec_id: 'nope', specs_url: SPECS }, SPECS)).toBeNull();
77
+ expect(readLink(undefined, SPECS)).toBeNull();
78
+ expect(readLink([ID], SPECS)).toBeNull();
79
+ });
80
+ });
81
+
82
+ describe('buildSpecBody', () => {
83
+ it('uses the file name and drops the link from the stored notebook', () => {
84
+ const content = notebook('hi', {
85
+ oceanum: { spec_id: ID, specs_url: SPECS, description: 'Wave stats' }
86
+ });
87
+ const result = buildSpecBody(content, 'Oceanum/Waves (1).ipynb', SPECS);
88
+ expect(result.ok).toBe(true);
89
+ if (!result.ok) {
90
+ return;
91
+ }
92
+ expect(result.strippedOutputs).toBe(false);
93
+ expect(result.body.name).toBe('Waves (1)');
94
+ expect(result.body.description).toBe('Wave stats');
95
+ expect(result.body.spec.metadata.oceanum).toBeUndefined();
96
+ expect(result.body.spec.metadata.kernelspec).toBeDefined();
97
+ expect(content.metadata.oceanum).toBeDefined();
98
+ });
99
+
100
+ it('strips code-cell outputs when the body is over the limit', () => {
101
+ const content = notebook('x'.repeat(5000));
102
+ const result = buildSpecBody(content, 'big.ipynb', SPECS, 2000);
103
+ expect(result.ok).toBe(true);
104
+ if (!result.ok) {
105
+ return;
106
+ }
107
+ expect(result.strippedOutputs).toBe(true);
108
+ expect(result.bytes).toBeLessThanOrEqual(2000);
109
+ const code = result.body.spec.cells[1] as { outputs: unknown[] };
110
+ expect(code.outputs).toEqual([]);
111
+ expect(
112
+ (result.body.spec.cells[1] as { execution_count: unknown })
113
+ .execution_count
114
+ ).toBeNull();
115
+ expect(result.body.spec.cells[0]).toEqual(content.cells[0]);
116
+ // The input notebook is not modified.
117
+ expect((content.cells[1] as { outputs: unknown[] }).outputs).toHaveLength(
118
+ 1
119
+ );
120
+ });
121
+
122
+ it('refuses a notebook that is too large even without outputs', () => {
123
+ const content = notebook();
124
+ content.cells[0].source = 'y'.repeat(5000);
125
+ const result = buildSpecBody(content, 'big.ipynb', SPECS, 2000);
126
+ expect(result.ok).toBe(false);
127
+ expect(result.bytes).toBeGreaterThan(2000);
128
+ });
129
+
130
+ it('measures UTF-8 bytes, not characters', () => {
131
+ const content = notebook('');
132
+ content.cells[0].source = '\u{1F30A}'.repeat(400); // 4 bytes each
133
+ const size = JSON.stringify({
134
+ name: 'n',
135
+ description: null,
136
+ spec: content
137
+ }).length;
138
+ const result = buildSpecBody(content, 'n.ipynb', SPECS, size + 100);
139
+ expect(result.ok).toBe(false);
140
+ });
141
+ });
142
+
143
+ describe('notebookFromRecord', () => {
144
+ const base = {
145
+ id: ID,
146
+ name: 'Waves',
147
+ modified: '2026-09-12T10:00:00',
148
+ creator: null as string | null
149
+ };
150
+
151
+ it('sets the oceanum link', () => {
152
+ const content = notebookFromRecord(
153
+ { ...base, description: 'Wave stats', spec: notebook() },
154
+ SPECS
155
+ );
156
+ expect(content.metadata.oceanum).toEqual({
157
+ spec_id: ID,
158
+ specs_url: SPECS,
159
+ description: 'Wave stats'
160
+ });
161
+ expect(content.cells).toHaveLength(2);
162
+ });
163
+
164
+ it('replaces a stale link and omits an empty description', () => {
165
+ const spec = notebook('', { oceanum: { spec_id: 'old', specs_url: 'x' } });
166
+ const content = notebookFromRecord(
167
+ { ...base, description: null, spec },
168
+ SPECS
169
+ );
170
+ expect(content.metadata.oceanum).toEqual({
171
+ spec_id: ID,
172
+ specs_url: SPECS
173
+ });
174
+ });
175
+
176
+ it('rejects records that are not notebooks', () => {
177
+ for (const spec of [
178
+ {},
179
+ [],
180
+ null,
181
+ { nbformat: 4, cells: {} },
182
+ { nbformat: 3 }
183
+ ]) {
184
+ expect(() =>
185
+ notebookFromRecord({ ...base, description: null, spec }, SPECS)
186
+ ).toThrow(/not a valid notebook/);
187
+ }
188
+ });
189
+ });
190
+
191
+ describe('unlinkedNotebookFromRecord', () => {
192
+ const base = {
193
+ id: ID,
194
+ name: 'Waves',
195
+ description: 'Wave stats',
196
+ modified: '2026-09-12T10:00:00',
197
+ creator: null as string | null
198
+ };
199
+
200
+ it('links the copy to nothing, even when the stored notebook carries a link', () => {
201
+ const spec = notebook('', { oceanum: { spec_id: ID, specs_url: SPECS } });
202
+ const content = unlinkedNotebookFromRecord({ ...base, spec });
203
+ expect(content.metadata).not.toHaveProperty('oceanum');
204
+ expect(content.metadata.kernelspec).toBeDefined();
205
+ expect(content.cells).toHaveLength(2);
206
+ });
207
+
208
+ it('arrives untrusted', () => {
209
+ const spec = notebook();
210
+ spec.cells[1].metadata = { trusted: true, tags: ['keep'] };
211
+ const content = unlinkedNotebookFromRecord({ ...base, spec });
212
+ expect(content.cells[1].metadata).toEqual({ tags: ['keep'] });
213
+ });
214
+
215
+ it('rejects records that are not notebooks', () => {
216
+ expect(() =>
217
+ unlinkedNotebookFromRecord({ ...base, spec: { nbformat: 3 } })
218
+ ).toThrow(/not a valid notebook/);
219
+ });
220
+ });
221
+
222
+ describe('parseNotebookDemo', () => {
223
+ const OTHER = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
224
+ const demo = (sections: unknown, extra: object = {}): unknown => ({
225
+ kind: 'notebook-demo',
226
+ version: 1,
227
+ sections,
228
+ ...extra
229
+ });
230
+
231
+ let warn: jest.SpyInstance;
232
+ beforeEach(() => {
233
+ warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
234
+ });
235
+ afterEach(() => {
236
+ warn.mockRestore();
237
+ });
238
+
239
+ it('keeps sections and items in record order', () => {
240
+ const sections = parseNotebookDemo(
241
+ demo(
242
+ [
243
+ {
244
+ title: 'Getting started',
245
+ items: [
246
+ { id: ID, title: 'Query', summary: 'One line' },
247
+ { id: OTHER, title: 'Plot', note: 'ignored' }
248
+ ]
249
+ },
250
+ { title: 'Waves', items: [{ id: OTHER, title: 'Spectra' }] }
251
+ ],
252
+ { owner: 'ignored' }
253
+ )
254
+ );
255
+ expect(sections).toEqual([
256
+ {
257
+ title: 'Getting started',
258
+ items: [
259
+ { id: ID, title: 'Query', summary: 'One line' },
260
+ { id: OTHER, title: 'Plot' }
261
+ ]
262
+ },
263
+ { title: 'Waves', items: [{ id: OTHER, title: 'Spectra' }] }
264
+ ]);
265
+ expect(warn).not.toHaveBeenCalled();
266
+ });
267
+
268
+ it('skips items without a record id or a title', () => {
269
+ const sections = parseNotebookDemo(
270
+ demo([
271
+ {
272
+ title: 'Mixed',
273
+ items: [
274
+ { id: '../eidos', title: 'Not a record id' },
275
+ { id: ID, title: ' ' },
276
+ { id: ID },
277
+ 'not an item',
278
+ { id: OTHER, title: 'Kept', summary: 42 }
279
+ ]
280
+ }
281
+ ])
282
+ );
283
+ expect(sections).toEqual([
284
+ { title: 'Mixed', items: [{ id: OTHER, title: 'Kept' }] }
285
+ ]);
286
+ });
287
+
288
+ it('drops sections with no title or nothing left to list', () => {
289
+ const sections = parseNotebookDemo(
290
+ demo([
291
+ { title: 'Empty', items: [] },
292
+ { title: 'All invalid', items: [{ id: 'x', title: 'y' }] },
293
+ { title: '', items: [{ id: ID, title: 'Untitled section' }] },
294
+ { title: 'No items' }
295
+ ])
296
+ );
297
+ expect(sections).toEqual([]);
298
+ expect(warn).not.toHaveBeenCalled();
299
+ });
300
+
301
+ it('reads nothing from a record of another kind or version, and says so once', () => {
302
+ for (const spec of [
303
+ demo([], { kind: 'workspace' }),
304
+ demo([], { version: 2 }),
305
+ { kind: 'notebook-demo', version: 1, sections: {} },
306
+ null,
307
+ [],
308
+ 'notebook-demo'
309
+ ]) {
310
+ warn.mockClear();
311
+ expect(parseNotebookDemo(spec)).toEqual([]);
312
+ expect(warn).toHaveBeenCalledTimes(1);
313
+ }
314
+ });
315
+ });
316
+
317
+ describe('names and paths', () => {
318
+ it('derives the record name from a path', () => {
319
+ expect(nameFromPath('Oceanum/Waves.ipynb')).toBe('Waves');
320
+ expect(nameFromPath('a/b/c.IPYNB')).toBe('c');
321
+ expect(nameFromPath('.ipynb')).toBe('Untitled');
322
+ });
323
+
324
+ it('sanitizes names to one path segment', () => {
325
+ expect(sanitizeName('a/b\\c')).toBe('a-b-c');
326
+ expect(sanitizeName(' Waves.ipynb ')).toBe('Waves');
327
+ expect(sanitizeName('..')).toBe('Untitled');
328
+ expect(sanitizeName(' ')).toBe('Untitled');
329
+ });
330
+
331
+ it('picks a unique path in the Oceanum folder', () => {
332
+ expect(uniqueNotebookPath('Waves', [])).toBe('Oceanum/Waves.ipynb');
333
+ expect(uniqueNotebookPath('Waves', ['Waves.ipynb'])).toBe(
334
+ 'Oceanum/Waves (1).ipynb'
335
+ );
336
+ expect(
337
+ uniqueNotebookPath('Wa/ves', [
338
+ 'Wa-ves.ipynb',
339
+ 'Wa-ves (1).ipynb',
340
+ 'Other.ipynb'
341
+ ])
342
+ ).toBe('Oceanum/Wa-ves (2).ipynb');
343
+ });
344
+ });
345
+
346
+ describe('partitionSummaries', () => {
347
+ const item = (id: string, creator: string | null, modified: string) => ({
348
+ id,
349
+ name: id,
350
+ description: null as string | null,
351
+ creator,
352
+ modified
353
+ });
354
+
355
+ it('splits by creator, case-insensitively, newest first', () => {
356
+ const { mine, shared } = partitionSummaries(
357
+ [
358
+ item('a', 'me@example.com', '2026-01-01T00:00:00'),
359
+ item('b', 'other@example.com', '2026-03-01T00:00:00'),
360
+ item('c', 'Me@Example.com', '2026-02-01T00:00:00'),
361
+ item('d', null, '2026-01-02T00:00:00')
362
+ ],
363
+ 'me@example.com'
364
+ );
365
+ expect(mine.map(i => i.id)).toEqual(['c', 'a']);
366
+ expect(shared.map(i => i.id)).toEqual(['b', 'd']);
367
+ });
368
+
369
+ it('treats everything as shared when there is no email to compare', () => {
370
+ const { mine, shared } = partitionSummaries(
371
+ [item('a', 'me@example.com', '2026-01-01T00:00:00')],
372
+ null
373
+ );
374
+ expect(mine).toEqual([]);
375
+ expect(shared.map(i => i.id)).toEqual(['a']);
376
+ });
377
+ });
378
+
379
+ describe('parseTimestamp', () => {
380
+ it('treats naive timestamps as UTC', () => {
381
+ expect(parseTimestamp('2026-09-12T10:00:00')?.toISOString()).toBe(
382
+ '2026-09-12T10:00:00.000Z'
383
+ );
384
+ expect(parseTimestamp('2026-09-12T10:00:00+02:00')?.toISOString()).toBe(
385
+ '2026-09-12T08:00:00.000Z'
386
+ );
387
+ expect(parseTimestamp('garbage')).toBeNull();
388
+ });
389
+ });
390
+
391
+ describe('isShareableEmail', () => {
392
+ it('accepts plain addresses and refuses glob characters', () => {
393
+ expect(isShareableEmail('b@example.com')).toBe(true);
394
+ expect(isShareableEmail('*@example.com')).toBe(false);
395
+ expect(isShareableEmail('b@exam?le.com')).toBe(false);
396
+ expect(isShareableEmail('b@[example].com')).toBe(false);
397
+ expect(isShareableEmail('not an email')).toBe(false);
398
+ expect(isShareableEmail('')).toBe(false);
399
+ });
400
+ });
401
+
402
+ describe('shareLink', () => {
403
+ it('keeps lab/index.html on a JupyterLite page, from an absolute or path base URL', () => {
404
+ const expected = `https://notebook.example.com/sub/lab/index.html?oceanum-notebook=${ID}`;
405
+ expect(
406
+ shareLink(
407
+ 'https://notebook.example.com/sub/',
408
+ 'https://notebook.example.com/sub/lab/index.html',
409
+ ID
410
+ )
411
+ ).toBe(expected);
412
+ expect(
413
+ shareLink(
414
+ '/sub/',
415
+ 'https://notebook.example.com/sub/lab/index.html?x=1',
416
+ ID
417
+ )
418
+ ).toBe(expected);
419
+ });
420
+
421
+ it('links to lab on a JupyterLab server, which serves no index.html', () => {
422
+ expect(
423
+ shareLink('http://localhost:8888/', 'http://localhost:8888/lab', ID)
424
+ ).toBe(`http://localhost:8888/lab?oceanum-notebook=${ID}`);
425
+ expect(
426
+ shareLink(
427
+ 'https://hub.example.com/user/me/',
428
+ 'https://hub.example.com/user/me/lab/tree/Oceanum/Waves.ipynb',
429
+ ID
430
+ )
431
+ ).toBe(`https://hub.example.com/user/me/lab?oceanum-notebook=${ID}`);
432
+ });
433
+ });
434
+
435
+ describe('withoutTrust', () => {
436
+ function trusted(): INotebookContent {
437
+ const content = notebook();
438
+ content.cells = content.cells.map(cell => ({
439
+ ...cell,
440
+ metadata: { ...cell.metadata, trusted: true, tags: ['keep'] }
441
+ }));
442
+ return content;
443
+ }
444
+
445
+ it('removes trusted from every cell and keeps other metadata', () => {
446
+ const cells = withoutTrust(trusted()).cells;
447
+
448
+ expect(cells.every(cell => !('trusted' in cell.metadata))).toBe(true);
449
+ expect(
450
+ cells.every(cell => (cell.metadata.tags as string[])[0] === 'keep')
451
+ ).toBe(true);
452
+ });
453
+
454
+ it('does not mutate the input', () => {
455
+ const content = trusted();
456
+
457
+ withoutTrust(content);
458
+
459
+ expect(content.cells[0].metadata.trusted).toBe(true);
460
+ });
461
+
462
+ it('is applied when opening a record, so shared outputs render sanitised', () => {
463
+ const content = notebookFromRecord(
464
+ {
465
+ id: ID,
466
+ name: 'n',
467
+ description: null,
468
+ modified: '2026-09-12T10:00:00',
469
+ creator: null,
470
+ spec: trusted() as never
471
+ },
472
+ SPECS
473
+ );
474
+
475
+ expect(content.cells.some(cell => 'trusted' in cell.metadata)).toBe(false);
476
+ });
477
+
478
+ it('is applied when saving, so other clients never receive a trust claim', () => {
479
+ const result = buildSpecBody(trusted(), 'n.ipynb', SPECS);
480
+
481
+ expect(
482
+ result.ok &&
483
+ result.body.spec.cells.some(cell => 'trusted' in cell.metadata)
484
+ ).toBe(false);
485
+ });
486
+ });
487
+
488
+ describe('parseShareEmails', () => {
489
+ it('splits on commas, semicolons, spaces and new lines', () => {
490
+ const raw =
491
+ 'a@example.com, b@example.com;c@example.com d@example.com\ne@example.com';
492
+ expect(parseShareEmails(raw)).toEqual({
493
+ emails: [
494
+ 'a@example.com',
495
+ 'b@example.com',
496
+ 'c@example.com',
497
+ 'd@example.com',
498
+ 'e@example.com'
499
+ ],
500
+ invalid: []
501
+ });
502
+ });
503
+
504
+ it('lower-cases addresses, because the store matches the claim case-sensitively', () => {
505
+ expect(parseShareEmails('Alice@Example.COM').emails).toEqual([
506
+ 'alice@example.com'
507
+ ]);
508
+ });
509
+
510
+ it('removes duplicates, keeping the order first written', () => {
511
+ expect(
512
+ parseShareEmails('b@example.com a@example.com B@example.com').emails
513
+ ).toEqual(['b@example.com', 'a@example.com']);
514
+ });
515
+
516
+ it('separates the addresses it cannot grant, reported as they were typed', () => {
517
+ const parsed = parseShareEmails(
518
+ 'good@example.com, nope, *@example.com, No@Good'
519
+ );
520
+ expect(parsed.emails).toEqual(['good@example.com']);
521
+ expect(parsed.invalid).toEqual(['nope', '*@example.com', 'No@Good']);
522
+ });
523
+
524
+ it('ignores surrounding and repeated separators', () => {
525
+ expect(parseShareEmails(' ,, a@example.com ,, ')).toEqual({
526
+ emails: ['a@example.com'],
527
+ invalid: []
528
+ });
529
+ });
530
+
531
+ it('returns nothing for empty input', () => {
532
+ expect(parseShareEmails(' ')).toEqual({ emails: [], invalid: [] });
533
+ });
534
+ });
535
+
536
+ describe('reportForShareFailures', () => {
537
+ it('keeps the original error for a failed public share, which names nobody', () => {
538
+ // The public grant's entity is '', so a message built from entity names would read
539
+ // "Could not share with ." and lose the real reason.
540
+ expect(reportForShareFailures(1, [''], false)).toEqual({
541
+ kind: 'original'
542
+ });
543
+ });
544
+
545
+ it('keeps the original error when the session needs signing in again', () => {
546
+ // Anything synthetic is not a SpecStoreError, so the reporter would not offer
547
+ // sign-in.
548
+ expect(
549
+ reportForShareFailures(3, ['a@example.com', 'b@example.com'], true)
550
+ ).toEqual({ kind: 'original' });
551
+ });
552
+
553
+ it('keeps the original error when every grant failed', () => {
554
+ expect(
555
+ reportForShareFailures(2, ['a@example.com', 'b@example.com'], false)
556
+ ).toEqual({ kind: 'original' });
557
+ });
558
+
559
+ it('never builds a message that names nobody, even with grants left over', () => {
560
+ // Isolates the unnamed-entity guard: here the all-failed condition does not apply,
561
+ // so only that guard stops the caller describing a failure as "with ".
562
+ expect(reportForShareFailures(2, [''], false)).toEqual({
563
+ kind: 'original'
564
+ });
565
+ });
566
+
567
+ it('names the failures when some of them worked', () => {
568
+ expect(reportForShareFailures(3, ['b@example.com'], false)).toEqual({
569
+ kind: 'partial',
570
+ named: ['b@example.com']
571
+ });
572
+ });
573
+
574
+ it('ignores an unnamed grant when deciding who to name', () => {
575
+ expect(reportForShareFailures(3, ['', 'b@example.com'], false)).toEqual({
576
+ kind: 'partial',
577
+ named: ['b@example.com']
578
+ });
579
+ });
580
+ });
581
+
582
+ describe('notebookFromUpload', () => {
583
+ const linkedAndTrusted: INotebookContent = {
584
+ nbformat: 4,
585
+ nbformat_minor: 5,
586
+ metadata: {
587
+ oceanum: { spec_id: ID, specs_url: SPECS },
588
+ kernelspec: { name: 'python', display_name: 'Python' }
589
+ },
590
+ cells: [
591
+ { cell_type: 'markdown', source: 'hi', metadata: { trusted: true } }
592
+ ]
593
+ };
594
+
595
+ it('drops the oceanum link and cell trust, and keeps everything else', () => {
596
+ const content = notebookFromUpload(JSON.stringify(linkedAndTrusted));
597
+
598
+ expect(content.metadata).toEqual({
599
+ kernelspec: { name: 'python', display_name: 'Python' }
600
+ });
601
+ expect(content.cells).toEqual([
602
+ { cell_type: 'markdown', source: 'hi', metadata: {} }
603
+ ]);
604
+ });
605
+
606
+ it.each([
607
+ ['text that is not JSON', '{'],
608
+ ['a JSON array', '[]'],
609
+ ['nbformat 3', JSON.stringify({ ...linkedAndTrusted, nbformat: 3 })],
610
+ ['no cells', JSON.stringify({ nbformat: 4, metadata: {} })],
611
+ ['no metadata', JSON.stringify({ nbformat: 4, cells: [] })],
612
+ [
613
+ 'a cell that is not a cell',
614
+ JSON.stringify({ nbformat: 4, metadata: {}, cells: [null, {}] })
615
+ ]
616
+ ])('refuses %s, without quoting the file', (_, text) => {
617
+ expect(() => notebookFromUpload(text)).toThrow(/not a Jupyter notebook/);
618
+ try {
619
+ notebookFromUpload(text);
620
+ } catch (error) {
621
+ expect((error as Error).message).not.toContain(text);
622
+ }
623
+ });
624
+ });