@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,664 @@
1
+ /**
2
+ * The share plugin's commands, driven through a real CommandRegistry against fakes.
3
+ *
4
+ * These cover what reading cannot: which record or notebook a command decides to act
5
+ * on. Two of them are regressions for defects a review found, both marked below.
6
+ */
7
+ import type { JupyterFrontEnd } from '@jupyterlab/application';
8
+ import type { IDocumentManager } from '@jupyterlab/docmanager';
9
+ import type { INotebookContent } from '@jupyterlab/nbformat';
10
+ import type { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
11
+ import { CommandRegistry } from '@lumino/commands';
12
+ import type { Widget } from '@lumino/widgets';
13
+ import { Signal } from '@lumino/signaling';
14
+
15
+ import type { IOceanumAuth, IOceanumUser } from '../auth/tokens';
16
+ import type { ISpecRecord } from '../share/notebook';
17
+ import { CommandIDs, sharePlugin } from '../share/plugin';
18
+
19
+ /** What every dialog answers. Accepting with no value is "chose nothing". */
20
+ const mockDialog: { accept: boolean; value: unknown } = {
21
+ accept: true,
22
+ value: null
23
+ };
24
+
25
+ jest.mock('@jupyterlab/apputils', () => {
26
+ const actual = jest.requireActual('@jupyterlab/apputils');
27
+ const answer = async () => ({
28
+ button: { accept: mockDialog.accept },
29
+ value: mockDialog.value
30
+ });
31
+ const quiet = (): void => undefined;
32
+ return {
33
+ ...actual,
34
+ showDialog: answer,
35
+ InputDialog: { ...actual.InputDialog, getText: answer },
36
+ Notification: {
37
+ ...actual.Notification,
38
+ error: quiet,
39
+ success: quiet,
40
+ warning: quiet,
41
+ info: quiet
42
+ }
43
+ };
44
+ });
45
+
46
+ /** What the file picker answers: a file, or null for a cancelled picker. */
47
+ const mockPicked: { file: { name: string; text: string } | null } = {
48
+ file: null
49
+ };
50
+ const mockPickerCalls = { count: 0 };
51
+
52
+ jest.mock('../share/upload', () => ({
53
+ chooseNotebookFile: async () => {
54
+ mockPickerCalls.count++;
55
+ const picked = mockPicked.file;
56
+ return picked && { name: picked.name, text: async () => picked.text };
57
+ }
58
+ }));
59
+
60
+ const originalFetch = globalThis.fetch;
61
+ afterEach(() => {
62
+ mockPicked.file = null;
63
+ mockPickerCalls.count = 0;
64
+ globalThis.fetch = originalFetch;
65
+ mockDialog.accept = true;
66
+ mockDialog.value = null;
67
+ });
68
+
69
+ const SPECS = 'https://specs.example.com';
70
+ const ID_A = '6f1c1a52-4b8e-4c0f-9a57-1d2e3f4a5b6c';
71
+ const ID_B = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
72
+
73
+ const USER: IOceanumUser = {
74
+ sub: 'auth0|1',
75
+ email: 'me@example.com',
76
+ name: null,
77
+ activeOrg: null,
78
+ colorScheme: null
79
+ };
80
+
81
+ const notebook = (link?: string): INotebookContent => ({
82
+ nbformat: 4,
83
+ nbformat_minor: 5,
84
+ cells: [],
85
+ metadata: link ? { oceanum: { spec_id: link, specs_url: SPECS } } : {}
86
+ });
87
+
88
+ const record = (id: string, name: string): ISpecRecord => ({
89
+ id,
90
+ name,
91
+ description: null,
92
+ modified: '2026-09-12T10:00:00',
93
+ creator: 'me@example.com',
94
+ spec: notebook()
95
+ });
96
+
97
+ /**
98
+ * Records the spec store calls a command makes, and answers them. A record holds
99
+ * `stored[id]` when given, else an empty notebook.
100
+ */
101
+ function store(stored: Record<string, INotebookContent> = {}) {
102
+ const calls: string[] = [];
103
+ const bodies: Record<string, unknown>[] = [];
104
+ const names: Record<string, string> = { [ID_A]: 'Alpha', [ID_B]: 'Beta' };
105
+ const fetcher = async (
106
+ input: RequestInfo | URL,
107
+ init?: RequestInit
108
+ ): Promise<Response> => {
109
+ const url = String(input);
110
+ const method = init?.method ?? 'GET';
111
+ const id = url.split('/').pop() ?? '';
112
+ calls.push(`${method} ${id}`);
113
+ if (init?.body) {
114
+ bodies.push(JSON.parse(String(init.body)));
115
+ }
116
+ return {
117
+ ok: true,
118
+ status: method === 'DELETE' ? 204 : 200,
119
+ json: async () =>
120
+ method === 'GET' && id === 'notebook'
121
+ ? [record(ID_A, 'Alpha'), record(ID_B, 'Beta')]
122
+ : {
123
+ ...record(id, names[id] ?? 'Alpha'),
124
+ spec: stored[id] ?? notebook()
125
+ }
126
+ } as unknown as Response;
127
+ };
128
+ return { calls, bodies, fetcher };
129
+ }
130
+
131
+ /** A notebook panel with just the surface the plugin touches. */
132
+ function panel(id: string, path: string, link?: string): NotebookPanel {
133
+ const saveState = new Signal<unknown, string>({});
134
+ const metadata: Record<string, unknown> = link
135
+ ? { oceanum: { spec_id: link, specs_url: SPECS } }
136
+ : {};
137
+ return {
138
+ id,
139
+ context: {
140
+ path,
141
+ ready: Promise.resolve(),
142
+ saveState,
143
+ save: async (): Promise<void> => undefined,
144
+ model: {
145
+ toJSON: () => notebook(link),
146
+ getMetadata: (key: string) => metadata[key],
147
+ setMetadata: (key: string, value: unknown) => {
148
+ metadata[key] = value;
149
+ },
150
+ deleteMetadata: (key: string) => {
151
+ delete metadata[key];
152
+ },
153
+ dirty: false
154
+ }
155
+ }
156
+ } as unknown as NotebookPanel;
157
+ }
158
+
159
+ interface IHarness {
160
+ commands: CommandRegistry;
161
+ calls: string[];
162
+ bodies: Record<string, unknown>[];
163
+ saved: string[];
164
+ /** What each save wrote, by path. */
165
+ written: Record<string, unknown>;
166
+ opened: string[];
167
+ /** The widgets `openOrReveal` returned, in order; set `isDisposed` to close one. */
168
+ widgets: { id: string; isDisposed: boolean }[];
169
+ activated: string[];
170
+ renamed: [string, string][];
171
+ /** What a right-click last landed on; never cleared, as JupyterLab does not. */
172
+ setHit: (node: HTMLElement | null) => void;
173
+ setCurrent: (panel: NotebookPanel | null) => void;
174
+ }
175
+
176
+ function activate(
177
+ options: {
178
+ user?: IOceanumUser | null;
179
+ panels?: NotebookPanel[];
180
+ current?: NotebookPanel | null;
181
+ folder?: Record<string, unknown>;
182
+ stored?: Record<string, INotebookContent>;
183
+ } = {}
184
+ ): IHarness {
185
+ const commands = new CommandRegistry();
186
+ const { calls, bodies, fetcher } = store(options.stored);
187
+ const saved: string[] = [];
188
+ const written: Record<string, unknown> = {};
189
+ const opened: string[] = [];
190
+ const widgets: { id: string; isDisposed: boolean }[] = [];
191
+ const activated: string[] = [];
192
+ const renamed: [string, string][] = [];
193
+ const panels = options.panels ?? [];
194
+ let current = options.current ?? null;
195
+ let hit: HTMLElement | null = null;
196
+ const folder = options.folder ?? {};
197
+
198
+ const auth = {
199
+ environment: { fileManagement: 'local' },
200
+ urls: { specs: SPECS },
201
+ ready: Promise.resolve(),
202
+ user: options.user === undefined ? USER : options.user,
203
+ userChanged: new Signal<IOceanumAuth, IOceanumUser | null>(
204
+ {} as IOceanumAuth
205
+ ),
206
+ tokenChanged: new Signal<IOceanumAuth, string | null>({} as IOceanumAuth),
207
+ signIn: async (): Promise<void> => undefined,
208
+ signOut: async (): Promise<void> => undefined,
209
+ getAccessToken: async (): Promise<string> => 'tok'
210
+ } as unknown as IOceanumAuth;
211
+
212
+ const app = {
213
+ commands,
214
+ restored: Promise.resolve(),
215
+ shell: {
216
+ get currentWidget() {
217
+ return current;
218
+ },
219
+ activateById: (id: string): void => {
220
+ activated.push(id);
221
+ },
222
+ widgets: (): Widget[] => []
223
+ },
224
+ contextMenu: { addItem: (): void => undefined },
225
+ contextMenuHitTest: (test: (node: HTMLElement) => boolean) => {
226
+ for (let node = hit; node; node = node.parentElement) {
227
+ if (test(node)) {
228
+ return node;
229
+ }
230
+ }
231
+ return undefined;
232
+ }
233
+ } as unknown as JupyterFrontEnd;
234
+
235
+ const contents = {
236
+ get: async (path: string) => {
237
+ if (path === 'Oceanum') {
238
+ return {
239
+ type: 'directory',
240
+ content: Object.keys(folder).map(name => ({ name }))
241
+ };
242
+ }
243
+ const name = path.replace('Oceanum/', '');
244
+ if (!(name in folder)) {
245
+ throw new Error('not found');
246
+ }
247
+ return { type: 'notebook', content: folder[name] };
248
+ },
249
+ save: async (path: string, model?: { content?: unknown }) => {
250
+ saved.push(path);
251
+ written[path] = model?.content;
252
+ // Like the real drive, the folder lists the file from now on.
253
+ if (path.startsWith('Oceanum/')) {
254
+ folder[path.replace('Oceanum/', '')] = model?.content;
255
+ }
256
+ return { path };
257
+ },
258
+ newUntitled: async () => ({ path: 'Untitled Folder' }),
259
+ rename: async () => ({ path: 'Oceanum' })
260
+ };
261
+ const docManager = {
262
+ services: { contents },
263
+ openOrReveal: (path: string) => {
264
+ opened.push(path);
265
+ const widget = { id: `widget-${widgets.length}`, isDisposed: false };
266
+ widgets.push(widget);
267
+ return widget;
268
+ },
269
+ rename: async (from: string, to: string): Promise<void> => {
270
+ renamed.push([from, to]);
271
+ },
272
+ deleteFile: async (): Promise<void> => undefined
273
+ } as unknown as IDocumentManager;
274
+
275
+ const tracker = {
276
+ currentWidget: null as NotebookPanel | null,
277
+ forEach: (fn: (p: NotebookPanel) => void) => panels.forEach(fn),
278
+ find: (test: (p: NotebookPanel) => boolean) => panels.find(test) ?? null,
279
+ widgetAdded: new Signal<INotebookTracker, NotebookPanel>(
280
+ {} as INotebookTracker
281
+ ),
282
+ currentChanged: new Signal<INotebookTracker, NotebookPanel | null>(
283
+ {} as INotebookTracker
284
+ )
285
+ } as unknown as INotebookTracker;
286
+ (tracker as { currentWidget: NotebookPanel | null }).currentWidget = current;
287
+
288
+ // The plugin builds its own client; point its fetch at the fake store. A top-level
289
+ // afterEach puts the real one back.
290
+ globalThis.fetch = fetcher as unknown as typeof fetch;
291
+
292
+ const palette = { addItem: (): void => undefined };
293
+ sharePlugin.activate(
294
+ app,
295
+ auth,
296
+ docManager,
297
+ tracker,
298
+ palette,
299
+ null,
300
+ null
301
+ ) as void;
302
+
303
+ return {
304
+ commands,
305
+ calls,
306
+ bodies,
307
+ saved,
308
+ written,
309
+ opened,
310
+ widgets,
311
+ activated,
312
+ renamed,
313
+ setHit: node => {
314
+ hit = node;
315
+ },
316
+ setCurrent: value => {
317
+ current = value;
318
+ (tracker as { currentWidget: NotebookPanel | null }).currentWidget =
319
+ value;
320
+ }
321
+ };
322
+ }
323
+
324
+ describe('renaming a record', () => {
325
+ it('patches just the name, and moves the open notebook to match', async () => {
326
+ const open = panel('panel-a', 'notebooks/Alpha.ipynb', ID_A);
327
+ const harness = activate({ current: open, panels: [open] });
328
+ mockDialog.value = 'Renamed';
329
+
330
+ await harness.commands.execute(CommandIDs.rename, { id: ID_A });
331
+
332
+ // Read once for the dialog's current name, then one patch. No PUT: the notebook
333
+ // is not sent back, so a change another writer made to it cannot be overwritten.
334
+ expect(harness.calls).toEqual([`GET ${ID_A}`, `PATCH ${ID_A}`]);
335
+ expect(harness.bodies).toEqual([{ name: 'Renamed' }]);
336
+ expect(harness.renamed).toEqual([
337
+ ['notebooks/Alpha.ipynb', 'notebooks/Renamed.ipynb']
338
+ ]);
339
+ });
340
+
341
+ it('leaves everything alone when the name is unchanged', async () => {
342
+ const harness = activate();
343
+ mockDialog.value = 'Alpha';
344
+
345
+ await harness.commands.execute(CommandIDs.rename, { id: ID_A });
346
+
347
+ expect(harness.calls).toEqual([`GET ${ID_A}`]);
348
+ });
349
+ });
350
+
351
+ /** A Notebooks tab row, as StoredNotebooks renders it. */
352
+ function row(id: string): HTMLElement {
353
+ const node = document.createElement('button');
354
+ node.className = 'oceanum-notebooks-item';
355
+ node.setAttribute('data-spec-id', id);
356
+ return node;
357
+ }
358
+
359
+ describe('the share commands, invoked from the File menu or the palette', () => {
360
+ // Regression: JupyterLab keeps the last contextmenu event for the life of the page
361
+ // and never clears it, so a hit test answers long after its menu has gone. Reading
362
+ // it unconditionally let the last right-clicked row decide what File > … acted on.
363
+ it('ignore a row that was right-clicked earlier', async () => {
364
+ const current = panel('panel-a', 'notebooks/Alpha.ipynb', ID_A);
365
+ const harness = activate({ current, panels: [current] });
366
+ harness.setHit(row(ID_B));
367
+
368
+ expect(harness.commands.label(CommandIDs.open)).toBe('Open from Oceanum…');
369
+ expect(harness.commands.label(CommandIDs.delete)).toBe(
370
+ 'Delete from Oceanum'
371
+ );
372
+
373
+ await harness.commands.execute(CommandIDs.delete);
374
+
375
+ // The current notebook's record, not the stale row's.
376
+ expect(harness.calls).toContain(`GET ${ID_A}`);
377
+ expect(harness.calls).not.toContain(`GET ${ID_B}`);
378
+ });
379
+
380
+ it('ignore a tab that was right-clicked earlier when saving', async () => {
381
+ const a = panel('panel-a', 'notebooks/Alpha.ipynb', ID_A);
382
+ const b = panel('panel-b', 'notebooks/Beta.ipynb', ID_B);
383
+ const harness = activate({ current: b, panels: [a, b] });
384
+ const tab = document.createElement('li');
385
+ tab.dataset.id = 'panel-a';
386
+ harness.setHit(tab);
387
+
388
+ await harness.commands.execute(CommandIDs.save);
389
+
390
+ expect(harness.calls).toEqual([`PUT ${ID_B}`]);
391
+ });
392
+
393
+ it('open the picker rather than a stale row', async () => {
394
+ const harness = activate();
395
+ harness.setHit(row(ID_B));
396
+
397
+ await harness.commands.execute(CommandIDs.open);
398
+
399
+ // The picker lists; it does not read one record.
400
+ expect(harness.calls).toEqual(['GET notebook']);
401
+ });
402
+ });
403
+
404
+ describe('the share commands, invoked from a Notebooks row', () => {
405
+ it('act on the row under the pointer', async () => {
406
+ const harness = activate();
407
+ harness.setHit(row(ID_B));
408
+
409
+ expect(harness.commands.label(CommandIDs.open, { source: 'row' })).toBe(
410
+ 'Open'
411
+ );
412
+ expect(
413
+ harness.commands.isEnabled(CommandIDs.delete, { source: 'row' })
414
+ ).toBe(true);
415
+
416
+ await harness.commands.execute(CommandIDs.open, { source: 'row' });
417
+
418
+ expect(harness.calls).toContain(`GET ${ID_B}`);
419
+ });
420
+
421
+ it('are disabled while signed out', async () => {
422
+ const harness = activate({ user: null });
423
+ harness.setHit(row(ID_B));
424
+
425
+ for (const command of Object.values(CommandIDs)) {
426
+ expect(harness.commands.isEnabled(command, { source: 'row' })).toBe(
427
+ false
428
+ );
429
+ }
430
+ });
431
+ });
432
+
433
+ describe('opening a record whose local copy already exists', () => {
434
+ // Regression: a second copy is named "<name> (1)", and a record takes its name from
435
+ // its file on every save, so opening the same notebook twice renamed it on Oceanum.
436
+ it('reuses the file an earlier session left, so the record keeps its name', async () => {
437
+ const harness = activate({
438
+ folder: { 'Alpha.ipynb': notebook(ID_A) }
439
+ });
440
+
441
+ await harness.commands.execute(CommandIDs.open, { id: ID_A });
442
+
443
+ expect(harness.saved).toEqual(['Oceanum/Alpha.ipynb']);
444
+ expect(harness.opened).toEqual(['Oceanum/Alpha.ipynb']);
445
+ });
446
+
447
+ it('finds the copy under a numbered name too', async () => {
448
+ const harness = activate({
449
+ folder: {
450
+ 'Alpha.ipynb': notebook(ID_B),
451
+ 'Alpha (1).ipynb': notebook(ID_A)
452
+ }
453
+ });
454
+
455
+ await harness.commands.execute(CommandIDs.open, { id: ID_A });
456
+
457
+ expect(harness.saved).toEqual(['Oceanum/Alpha (1).ipynb']);
458
+ });
459
+
460
+ it('still takes a free name when the file belongs to another record', async () => {
461
+ const harness = activate({
462
+ folder: { 'Alpha.ipynb': notebook(ID_B) }
463
+ });
464
+
465
+ await harness.commands.execute(CommandIDs.open, { id: ID_A });
466
+
467
+ expect(harness.saved).toEqual(['Oceanum/Alpha (1).ipynb']);
468
+ });
469
+ });
470
+
471
+ describe('opening an example', () => {
472
+ // As stored on Oceanum: linked to its own record, with a trusted cell.
473
+ const example: INotebookContent = {
474
+ ...notebook(ID_A),
475
+ cells: [
476
+ {
477
+ cell_type: 'code',
478
+ source: 'print(1)',
479
+ metadata: { trusted: true },
480
+ outputs: [],
481
+ execution_count: null
482
+ }
483
+ ]
484
+ };
485
+
486
+ it('writes an untrusted copy linked to nothing, named from its title', async () => {
487
+ const harness = activate({ stored: { [ID_A]: example } });
488
+
489
+ await harness.commands.execute(CommandIDs.openExample, {
490
+ id: ID_A,
491
+ title: 'Query a datasource'
492
+ });
493
+
494
+ // Read, never written back: the example's record is Oceanum's.
495
+ expect(harness.calls).toEqual([`GET ${ID_A}`]);
496
+ const path = 'Oceanum/Query a datasource.ipynb';
497
+ expect(harness.saved).toEqual([path]);
498
+ expect(harness.opened).toEqual([path]);
499
+ const content = harness.written[path] as INotebookContent;
500
+ // A linked copy's first save would PUT to the example's record and be refused.
501
+ expect(content.metadata).not.toHaveProperty('oceanum');
502
+ expect(content.cells[0].metadata).toEqual({});
503
+ });
504
+
505
+ it('never overwrites a file already there', async () => {
506
+ const harness = activate({
507
+ folder: { 'Query a datasource.ipynb': notebook() }
508
+ });
509
+
510
+ await harness.commands.execute(CommandIDs.openExample, {
511
+ id: ID_A,
512
+ title: 'Query a datasource'
513
+ });
514
+
515
+ expect(harness.saved).toEqual(['Oceanum/Query a datasource (1).ipynb']);
516
+ });
517
+
518
+ it('brings its open copy forward instead of writing another', async () => {
519
+ const harness = activate();
520
+ const args = { id: ID_A, title: 'Example' };
521
+
522
+ await harness.commands.execute(CommandIDs.openExample, args);
523
+ await harness.commands.execute(CommandIDs.openExample, args);
524
+
525
+ expect(harness.saved).toEqual(['Oceanum/Example.ipynb']);
526
+ expect(harness.activated).toEqual([harness.widgets[0].id]);
527
+ });
528
+
529
+ it('writes one copy when opened twice before the first finishes', async () => {
530
+ const harness = activate();
531
+ const args = { id: ID_A, title: 'Example' };
532
+
533
+ await Promise.all([
534
+ harness.commands.execute(CommandIDs.openExample, args),
535
+ harness.commands.execute(CommandIDs.openExample, args)
536
+ ]);
537
+
538
+ expect(harness.saved).toEqual(['Oceanum/Example.ipynb']);
539
+ });
540
+
541
+ it('writes a fresh copy once the last one was closed', async () => {
542
+ const harness = activate();
543
+ const args = { id: ID_A, title: 'Example' };
544
+
545
+ await harness.commands.execute(CommandIDs.openExample, args);
546
+ harness.widgets[0].isDisposed = true;
547
+ await harness.commands.execute(CommandIDs.openExample, args);
548
+
549
+ expect(harness.saved).toEqual([
550
+ 'Oceanum/Example.ipynb',
551
+ 'Oceanum/Example (1).ipynb'
552
+ ]);
553
+ expect(harness.activated).toEqual([]);
554
+ });
555
+
556
+ it('falls back to the record name when given no title', async () => {
557
+ const harness = activate();
558
+
559
+ await harness.commands.execute(CommandIDs.openExample, { id: ID_A });
560
+
561
+ expect(harness.saved).toEqual(['Oceanum/Alpha.ipynb']);
562
+ });
563
+
564
+ it('ignores an id that is not a record id', async () => {
565
+ const harness = activate();
566
+
567
+ await harness.commands.execute(CommandIDs.openExample, {
568
+ id: '../eidos',
569
+ title: 'Nope'
570
+ });
571
+
572
+ expect(harness.calls).toEqual([]);
573
+ expect(harness.saved).toEqual([]);
574
+ });
575
+
576
+ it('is disabled while signed out', () => {
577
+ const harness = activate({ user: null });
578
+
579
+ expect(harness.commands.isEnabled(CommandIDs.openExample)).toBe(false);
580
+ });
581
+ });
582
+
583
+ describe('uploading a notebook', () => {
584
+ // As downloaded from someone else's record: linked to it, with a trusted cell.
585
+ const downloaded: INotebookContent = {
586
+ ...notebook(ID_B),
587
+ cells: [
588
+ {
589
+ cell_type: 'code',
590
+ source: 'print(1)',
591
+ metadata: { trusted: true },
592
+ outputs: [],
593
+ execution_count: null
594
+ }
595
+ ]
596
+ };
597
+
598
+ it('writes an untrusted copy linked to nothing into the Oceanum folder, and opens it', async () => {
599
+ mockPicked.file = {
600
+ name: 'analysis.ipynb',
601
+ text: JSON.stringify(downloaded)
602
+ };
603
+ const harness = activate();
604
+
605
+ await harness.commands.execute(CommandIDs.upload);
606
+
607
+ const path = 'Oceanum/analysis.ipynb';
608
+ expect(harness.saved).toEqual([path]);
609
+ expect(harness.opened).toEqual([path]);
610
+ const content = harness.written[path] as INotebookContent;
611
+ // Linked, its first save would write over the record it was downloaded from.
612
+ expect(content.metadata).not.toHaveProperty('oceanum');
613
+ expect(content.cells[0].metadata).toEqual({});
614
+ // Nothing reaches Oceanum until the notebook is saved.
615
+ expect(harness.calls).toEqual([]);
616
+ });
617
+
618
+ it('never overwrites a file already there', async () => {
619
+ mockPicked.file = {
620
+ name: 'analysis.ipynb',
621
+ text: JSON.stringify(notebook())
622
+ };
623
+ const harness = activate({ folder: { 'analysis.ipynb': notebook() } });
624
+
625
+ await harness.commands.execute(CommandIDs.upload);
626
+
627
+ expect(harness.saved).toEqual(['Oceanum/analysis (1).ipynb']);
628
+ });
629
+
630
+ it.each([
631
+ ['not JSON', 'not json at all'],
632
+ ['JSON that is not a notebook', JSON.stringify({ cells: [] })],
633
+ ['an nbformat 3 notebook', JSON.stringify({ ...notebook(), nbformat: 3 })]
634
+ ])('writes nothing for %s', async (_, text) => {
635
+ mockPicked.file = { name: 'bad.ipynb', text };
636
+ const harness = activate();
637
+
638
+ await harness.commands.execute(CommandIDs.upload);
639
+
640
+ expect(harness.saved).toEqual([]);
641
+ expect(harness.opened).toEqual([]);
642
+ });
643
+
644
+ it('writes nothing when the picker is cancelled', async () => {
645
+ const harness = activate();
646
+
647
+ await harness.commands.execute(CommandIDs.upload);
648
+
649
+ expect(mockPickerCalls.count).toBe(1);
650
+ expect(harness.saved).toEqual([]);
651
+ });
652
+
653
+ it('asks for sign-in first, and writes nothing when declined', async () => {
654
+ mockPicked.file = { name: 'a.ipynb', text: JSON.stringify(notebook()) };
655
+ mockDialog.accept = false;
656
+ const harness = activate({ user: null });
657
+
658
+ await harness.commands.execute(CommandIDs.upload);
659
+
660
+ expect(mockPickerCalls.count).toBe(0);
661
+ expect(harness.saved).toEqual([]);
662
+ expect(harness.commands.isEnabled(CommandIDs.upload)).toBe(false);
663
+ });
664
+ });
@@ -119,6 +119,7 @@ export function readServerAuthOption(
119
119
  oceanumDomain:
120
120
  typeof config.oceanumDomain === 'string' ? config.oceanumDomain : '',
121
121
  signInRedirect: false,
122
+ fileManagement: config.fileManagement === 'oceanum' ? 'oceanum' : 'local',
122
123
  urls
123
124
  };
124
125
  }
@@ -0,0 +1,23 @@
1
+ import type { CommandRegistry } from '@lumino/commands';
2
+
3
+ import { CommandIDs } from './accountWidget';
4
+ import type { IOceanumAuth } from './tokens';
5
+
6
+ /**
7
+ * Start signing in the way this host shows it.
8
+ *
9
+ * On a JupyterLab server the device sign-in command both starts the flow and shows the
10
+ * code dialog; `auth.signIn()` alone starts the flow and shows nothing, so the user is
11
+ * left with a "Signing in…" top bar and no code. notebook.oceanum.io disables that
12
+ * command and provides an auth whose `signIn()` opens the Oceanum sign-in itself.
13
+ */
14
+ export async function startSignIn(
15
+ commands: CommandRegistry,
16
+ auth: IOceanumAuth
17
+ ): Promise<void> {
18
+ if (commands.hasCommand(CommandIDs.signIn)) {
19
+ await commands.execute(CommandIDs.signIn);
20
+ return;
21
+ }
22
+ await auth.signIn();
23
+ }