@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.
- package/lib/DatameshWidget.d.ts +11 -0
- package/lib/DatameshWidget.js +35 -5
- package/lib/StoredNotebooks.d.ts +27 -5
- package/lib/StoredNotebooks.js +141 -24
- package/lib/auth/serverAuth.js +1 -0
- package/lib/auth/startSignIn.d.ts +11 -0
- package/lib/auth/startSignIn.js +16 -0
- package/lib/auth/tokens.d.ts +13 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +43 -7
- package/lib/share/client.d.ts +86 -0
- package/lib/share/client.js +206 -0
- package/lib/share/events.d.ts +7 -0
- package/lib/share/events.js +9 -0
- package/lib/share/notebook.d.ts +216 -0
- package/lib/share/notebook.js +355 -0
- package/lib/share/oceanumOnly.d.ts +72 -0
- package/lib/share/oceanumOnly.js +127 -0
- package/lib/share/plugin.d.ts +23 -0
- package/lib/share/plugin.js +981 -0
- package/lib/share/upload.d.ts +6 -0
- package/lib/share/upload.js +27 -0
- package/lib/share/widgets.d.ts +25 -0
- package/lib/share/widgets.js +154 -0
- package/package.json +5 -1
- package/schema/datamesh-connect.json +6 -0
- package/src/DatameshWidget.tsx +79 -2
- package/src/StoredNotebooks.tsx +274 -24
- package/src/__tests__/OceanumPanel.spec.tsx +159 -5
- package/src/__tests__/StoredNotebooks.spec.tsx +528 -0
- package/src/__tests__/oceanumOnly.spec.ts +317 -0
- package/src/__tests__/settingsSchema.spec.ts +11 -1
- package/src/__tests__/shareClient.spec.ts +328 -0
- package/src/__tests__/shareNotebook.spec.ts +624 -0
- package/src/__tests__/sharePlugin.spec.ts +664 -0
- package/src/auth/serverAuth.ts +1 -0
- package/src/auth/startSignIn.ts +23 -0
- package/src/auth/tokens.ts +13 -0
- package/src/index.ts +43 -7
- package/src/share/client.ts +291 -0
- package/src/share/events.ts +10 -0
- package/src/share/notebook.ts +525 -0
- package/src/share/oceanumOnly.ts +177 -0
- package/src/share/plugin.ts +1220 -0
- package/src/share/upload.ts +27 -0
- package/src/share/widgets.ts +217 -0
- package/style/index.css +212 -18
- package/lib/specStore.d.ts +0 -37
- package/lib/specStore.js +0 -57
- package/src/__tests__/specStore.spec.ts +0 -126
- 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
|
+
});
|
package/src/auth/serverAuth.ts
CHANGED
|
@@ -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
|
+
}
|