@oceanum/oceanumlab 4.8.0 → 4.10.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/StoredNotebooks.js +31 -39
- package/lib/auth/tokens.d.ts +0 -6
- package/lib/share/fromDatamesh.d.ts +82 -0
- package/lib/share/fromDatamesh.js +379 -0
- package/lib/share/notebook.d.ts +6 -12
- package/lib/share/notebook.js +17 -37
- package/lib/share/plugin.js +58 -2
- package/package.json +1 -1
- package/schema/datamesh-connect.json +1 -1
- package/src/StoredNotebooks.tsx +58 -77
- package/src/__tests__/OceanumPanel.spec.tsx +12 -17
- package/src/__tests__/StoredNotebooks.spec.tsx +80 -87
- package/src/__tests__/fromDatamesh.spec.ts +301 -0
- package/src/__tests__/shareNotebook.spec.ts +32 -89
- package/src/__tests__/sharePlugin.spec.ts +57 -2
- package/src/auth/tokens.ts +0 -6
- package/src/share/fromDatamesh.ts +477 -0
- package/src/share/notebook.ts +21 -52
- package/src/share/plugin.ts +69 -1
- package/style/index.css +0 -7
|
@@ -86,7 +86,10 @@ describe('the Notebooks tab while signed in', () => {
|
|
|
86
86
|
const fetches: string[] = [];
|
|
87
87
|
const originalFetch = globalThis.fetch;
|
|
88
88
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
89
|
-
|
|
89
|
+
// Counts the notebook listing only; each load also lists the demos.
|
|
90
|
+
if (String(input).endsWith('/specs/notebook')) {
|
|
91
|
+
fetches.push(String(input));
|
|
92
|
+
}
|
|
90
93
|
return {
|
|
91
94
|
ok: true,
|
|
92
95
|
status: 200,
|
|
@@ -130,53 +133,41 @@ describe('the Notebooks tab while signed in', () => {
|
|
|
130
133
|
|
|
131
134
|
describe('the Examples in the Notebooks tab', () => {
|
|
132
135
|
const MINE = '6f1c1a52-4b8e-4c0f-9a57-1d2e3f4a5b6c';
|
|
133
|
-
const
|
|
134
|
-
const
|
|
136
|
+
const QUERY = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
|
|
137
|
+
const PLOT = '1c2d3e4f-2222-4a2b-8c3d-9e8f7a6b5c4d';
|
|
135
138
|
const SHARED = '2d3e4f5a-3333-4a2b-8c3d-9e8f7a6b5c4d';
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
const summary = (
|
|
139
|
+
const SPECTRA = '3e4f5a6b-4444-4a2b-8c3d-9e8f7a6b5c4d';
|
|
140
|
+
|
|
141
|
+
const summary = (
|
|
142
|
+
id: string,
|
|
143
|
+
name: string,
|
|
144
|
+
creator: string | null,
|
|
145
|
+
description: string | null = null
|
|
146
|
+
) => ({
|
|
139
147
|
id,
|
|
140
148
|
name,
|
|
141
|
-
description
|
|
149
|
+
description,
|
|
142
150
|
modified: '2026-09-12T10:00:00',
|
|
143
151
|
creator
|
|
144
152
|
});
|
|
145
153
|
|
|
146
|
-
// The
|
|
154
|
+
// The user's notebooks.
|
|
147
155
|
const LISTING = [
|
|
148
156
|
summary(MINE, 'Mine', 'me@example.com'),
|
|
149
|
-
summary(PUBLIC_EXAMPLE, 'Query (example)', null),
|
|
150
157
|
summary(SHARED, 'Shared', null)
|
|
151
158
|
];
|
|
152
159
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
{
|
|
160
|
-
title: 'Getting started',
|
|
161
|
-
items: [
|
|
162
|
-
{
|
|
163
|
-
id: PUBLIC_EXAMPLE,
|
|
164
|
-
title: 'Query a datasource',
|
|
165
|
-
summary: 'One line'
|
|
166
|
-
},
|
|
167
|
-
{ id: OTHER_EXAMPLE, title: 'Plot waves' }
|
|
168
|
-
]
|
|
169
|
-
},
|
|
170
|
-
// A curator's own example is also theirs: it stays in My notebooks.
|
|
171
|
-
{ title: 'Advanced', items: [{ id: MINE, title: 'Spectra' }] }
|
|
172
|
-
]
|
|
173
|
-
}
|
|
174
|
-
};
|
|
160
|
+
// The Notebook Demo specs, each one an example notebook, in no particular order.
|
|
161
|
+
const DEMOS = [
|
|
162
|
+
summary(PLOT, '10 Plot waves', null),
|
|
163
|
+
summary(SPECTRA, 'Spectra', null),
|
|
164
|
+
summary(QUERY, '2 Query a datasource', null, 'One line')
|
|
165
|
+
];
|
|
175
166
|
|
|
176
167
|
const ok = (data: unknown): Response =>
|
|
177
168
|
({ ok: true, status: 200, json: async () => data }) as unknown as Response;
|
|
178
169
|
|
|
179
|
-
function signedIn(
|
|
170
|
+
function signedIn(): IOceanumAuth {
|
|
180
171
|
const auth = signedOut();
|
|
181
172
|
(auth as { user: IOceanumUser | null }).user = {
|
|
182
173
|
sub: 'auth0|1',
|
|
@@ -186,20 +177,19 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
186
177
|
colorScheme: null
|
|
187
178
|
};
|
|
188
179
|
auth.getAccessToken = async () => 'tok';
|
|
189
|
-
(auth as { environment: unknown }).environment =
|
|
190
|
-
? { notebookDemo }
|
|
191
|
-
: {};
|
|
180
|
+
(auth as { environment: unknown }).environment = {};
|
|
192
181
|
return auth;
|
|
193
182
|
}
|
|
194
183
|
|
|
195
184
|
const originalFetch = globalThis.fetch;
|
|
196
185
|
let fetches: string[];
|
|
197
|
-
|
|
186
|
+
const DEMO_LISTING = 'https://specs.example.com/specs/notebook-demo';
|
|
187
|
+
function stubFetch(demos: () => Response = () => ok(DEMOS)): void {
|
|
198
188
|
fetches = [];
|
|
199
189
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
200
190
|
const url = String(input);
|
|
201
191
|
fetches.push(url);
|
|
202
|
-
return url
|
|
192
|
+
return url === DEMO_LISTING ? demos() : ok(LISTING);
|
|
203
193
|
}) as typeof fetch;
|
|
204
194
|
}
|
|
205
195
|
afterEach(() => {
|
|
@@ -268,73 +258,83 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
268
258
|
Widget.detach(widget);
|
|
269
259
|
});
|
|
270
260
|
|
|
271
|
-
it('lists
|
|
261
|
+
it('lists every Notebook Demo spec as an example, ordered by name', async () => {
|
|
272
262
|
stubFetch();
|
|
273
|
-
const widget = await render(signedIn(
|
|
263
|
+
const widget = await render(signedIn());
|
|
274
264
|
|
|
275
265
|
const examples = section(widget, 'Examples')!;
|
|
276
266
|
expect(examples).not.toBeNull();
|
|
277
|
-
expect(
|
|
278
|
-
Array.from(
|
|
279
|
-
examples.querySelectorAll('.oceanum-notebooks-subheading'),
|
|
280
|
-
node => node.textContent
|
|
281
|
-
)
|
|
282
|
-
).toEqual(['Getting started', 'Advanced']);
|
|
283
267
|
const rows = Array.from(
|
|
284
268
|
examples.querySelectorAll<HTMLElement>('[data-example-id]')
|
|
285
269
|
);
|
|
270
|
+
// Numbered as people number them: 2 before 10.
|
|
286
271
|
expect(rows.map(row => row.textContent)).toEqual([
|
|
287
|
-
'Query a datasource',
|
|
288
|
-
'Plot waves',
|
|
272
|
+
'2 Query a datasource',
|
|
273
|
+
'10 Plot waves',
|
|
289
274
|
'Spectra'
|
|
290
275
|
]);
|
|
291
276
|
expect(rows.map(row => row.getAttribute('data-example-id'))).toEqual([
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
277
|
+
QUERY,
|
|
278
|
+
PLOT,
|
|
279
|
+
SPECTRA
|
|
295
280
|
]);
|
|
296
281
|
expect(rows[0].getAttribute('title')).toBe('One line');
|
|
297
282
|
expect(
|
|
298
283
|
examples.querySelector('.oceanum-notebooks-count')?.textContent
|
|
299
284
|
).toBe('3');
|
|
300
|
-
expect(fetches).toContain(
|
|
301
|
-
`https://specs.example.com/specs/notebook-demo/${DEMO_ID}`
|
|
302
|
-
);
|
|
285
|
+
expect(fetches).toContain(DEMO_LISTING);
|
|
303
286
|
widget.dispose();
|
|
304
287
|
});
|
|
305
288
|
|
|
306
|
-
it('
|
|
289
|
+
it('leaves My notebooks and Shared with me as listed', async () => {
|
|
307
290
|
stubFetch();
|
|
308
|
-
const widget = await render(signedIn(
|
|
291
|
+
const widget = await render(signedIn());
|
|
309
292
|
|
|
293
|
+
expect(idsIn(section(widget, 'My notebooks'), 'data-spec-id')).toEqual([
|
|
294
|
+
MINE
|
|
295
|
+
]);
|
|
310
296
|
expect(idsIn(section(widget, 'Shared with me'), 'data-spec-id')).toEqual([
|
|
311
297
|
SHARED
|
|
312
298
|
]);
|
|
299
|
+
widget.dispose();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('shows no Examples section when there are no demos', async () => {
|
|
303
|
+
stubFetch(() => ok([]));
|
|
304
|
+
const widget = await render(signedIn());
|
|
305
|
+
|
|
306
|
+
expect(section(widget, 'Examples')).toBeNull();
|
|
307
|
+
widget.dispose();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('shows no Examples, and no error, where the store has no Notebook Demo type', async () => {
|
|
311
|
+
stubFetch(
|
|
312
|
+
() => ({ ok: false, status: 404, json: async () => ({}) }) as Response
|
|
313
|
+
);
|
|
314
|
+
const widget = await render(signedIn());
|
|
315
|
+
|
|
316
|
+
expect(section(widget, 'Examples')).toBeNull();
|
|
313
317
|
expect(idsIn(section(widget, 'My notebooks'), 'data-spec-id')).toEqual([
|
|
314
318
|
MINE
|
|
315
319
|
]);
|
|
316
320
|
widget.dispose();
|
|
317
321
|
});
|
|
318
322
|
|
|
319
|
-
it('asks for
|
|
323
|
+
it('asks for no demos while signed out', async () => {
|
|
320
324
|
stubFetch();
|
|
321
|
-
const widget =
|
|
325
|
+
const widget = new Harness(signedOut());
|
|
326
|
+
Widget.attach(widget, document.body);
|
|
327
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
322
328
|
|
|
323
|
-
expect(
|
|
324
|
-
expect(fetches).toEqual(['https://specs.example.com/specs/notebook']);
|
|
325
|
-
// Without a record, a public example is just another shared notebook.
|
|
326
|
-
expect(idsIn(section(widget, 'Shared with me'), 'data-spec-id')).toEqual([
|
|
327
|
-
PUBLIC_EXAMPLE,
|
|
328
|
-
SHARED
|
|
329
|
-
]);
|
|
329
|
+
expect(fetches).toEqual([]);
|
|
330
330
|
widget.dispose();
|
|
331
331
|
});
|
|
332
332
|
|
|
333
|
-
it('says so when the
|
|
333
|
+
it('says so when the demos cannot be listed, and still lists the rest', async () => {
|
|
334
334
|
stubFetch(
|
|
335
|
-
() => ({ ok: false, status:
|
|
335
|
+
() => ({ ok: false, status: 500, json: async () => ({}) }) as Response
|
|
336
336
|
);
|
|
337
|
-
const widget = await render(signedIn(
|
|
337
|
+
const widget = await render(signedIn());
|
|
338
338
|
|
|
339
339
|
const examples = section(widget, 'Examples')!;
|
|
340
340
|
expect(
|
|
@@ -345,7 +345,6 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
345
345
|
MINE
|
|
346
346
|
]);
|
|
347
347
|
expect(idsIn(section(widget, 'Shared with me'), 'data-spec-id')).toEqual([
|
|
348
|
-
PUBLIC_EXAMPLE,
|
|
349
348
|
SHARED
|
|
350
349
|
]);
|
|
351
350
|
widget.dispose();
|
|
@@ -354,7 +353,7 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
354
353
|
it('keeps example rows out of the stored-notebook row menu', async () => {
|
|
355
354
|
// Its Rename and Delete would act on Oceanum's own example records.
|
|
356
355
|
stubFetch();
|
|
357
|
-
const widget = await render(signedIn(
|
|
356
|
+
const widget = await render(signedIn(), () => undefined);
|
|
358
357
|
|
|
359
358
|
const rows = section(widget, 'Examples')!.querySelectorAll('button');
|
|
360
359
|
expect(rows).toHaveLength(3);
|
|
@@ -367,13 +366,13 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
367
366
|
it('opens an example through onOpenExample, with its title', async () => {
|
|
368
367
|
stubFetch();
|
|
369
368
|
const opened: INotebookDemoItem[] = [];
|
|
370
|
-
const widget = await render(signedIn(
|
|
369
|
+
const widget = await render(signedIn(), item => opened.push(item));
|
|
371
370
|
|
|
372
371
|
section(widget, 'Examples')!
|
|
373
|
-
.querySelector<HTMLButtonElement>(`[data-example-id="${
|
|
372
|
+
.querySelector<HTMLButtonElement>(`[data-example-id="${PLOT}"]`)!
|
|
374
373
|
.click();
|
|
375
374
|
|
|
376
|
-
expect(opened).toEqual([{ id:
|
|
375
|
+
expect(opened).toEqual([{ id: PLOT, title: '10 Plot waves' }]);
|
|
377
376
|
widget.dispose();
|
|
378
377
|
});
|
|
379
378
|
|
|
@@ -385,7 +384,7 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
385
384
|
it('puts a switch on the Examples heading that asks to hide them', async () => {
|
|
386
385
|
stubFetch();
|
|
387
386
|
const changes: boolean[] = [];
|
|
388
|
-
const widget = await render(signedIn(
|
|
387
|
+
const widget = await render(signedIn(), undefined, {
|
|
389
388
|
showExamples: true,
|
|
390
389
|
onShowExamplesChange: show => changes.push(show)
|
|
391
390
|
});
|
|
@@ -408,7 +407,7 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
408
407
|
it('keeps hidden examples folded under their heading, with the switch to bring them back', async () => {
|
|
409
408
|
stubFetch();
|
|
410
409
|
const changes: boolean[] = [];
|
|
411
|
-
const widget = await render(signedIn(
|
|
410
|
+
const widget = await render(signedIn(), undefined, {
|
|
412
411
|
showExamples: false,
|
|
413
412
|
onShowExamplesChange: show => changes.push(show)
|
|
414
413
|
});
|
|
@@ -418,14 +417,8 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
418
417
|
expect(
|
|
419
418
|
examples.querySelector('.oceanum-notebooks-count')?.textContent
|
|
420
419
|
).toBe('3');
|
|
421
|
-
// Hidden, not forgotten: the
|
|
422
|
-
|
|
423
|
-
expect(fetches).toContain(
|
|
424
|
-
`https://specs.example.com/specs/notebook-demo/${DEMO_ID}`
|
|
425
|
-
);
|
|
426
|
-
expect(idsIn(section(widget, 'Shared with me'), 'data-spec-id')).toEqual([
|
|
427
|
-
SHARED
|
|
428
|
-
]);
|
|
420
|
+
// Hidden, not forgotten: the demos are still listed, for the count.
|
|
421
|
+
expect(fetches).toContain(DEMO_LISTING);
|
|
429
422
|
|
|
430
423
|
const toggle = switchIn(widget)!;
|
|
431
424
|
expect(toggle.getAttribute('aria-checked')).toBe('false');
|
|
@@ -437,9 +430,9 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
437
430
|
|
|
438
431
|
it('shows no error for examples the user has hidden', async () => {
|
|
439
432
|
stubFetch(
|
|
440
|
-
() => ({ ok: false, status:
|
|
433
|
+
() => ({ ok: false, status: 500, json: async () => ({}) }) as Response
|
|
441
434
|
);
|
|
442
|
-
const widget = await render(signedIn(
|
|
435
|
+
const widget = await render(signedIn(), undefined, {
|
|
443
436
|
showExamples: false,
|
|
444
437
|
onShowExamplesChange: () => undefined
|
|
445
438
|
});
|
|
@@ -452,7 +445,7 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
452
445
|
|
|
453
446
|
it('offers no switch where the choice could not be kept', async () => {
|
|
454
447
|
stubFetch();
|
|
455
|
-
const widget = await render(signedIn(
|
|
448
|
+
const widget = await render(signedIn());
|
|
456
449
|
|
|
457
450
|
expect(switchIn(widget)).toBeNull();
|
|
458
451
|
expect(
|
|
@@ -464,7 +457,7 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
464
457
|
it('shows no examples while the setting is still loading', async () => {
|
|
465
458
|
// Otherwise examples a user has hidden would show for a moment on every reload.
|
|
466
459
|
stubFetch();
|
|
467
|
-
const widget = await render(signedIn(
|
|
460
|
+
const widget = await render(signedIn(), undefined, {
|
|
468
461
|
showExamples: null,
|
|
469
462
|
onShowExamplesChange: () => undefined
|
|
470
463
|
});
|
|
@@ -479,7 +472,7 @@ describe('the Examples in the Notebooks tab', () => {
|
|
|
479
472
|
|
|
480
473
|
it('shows nothing for hidden examples that no switch could bring back', async () => {
|
|
481
474
|
stubFetch();
|
|
482
|
-
const widget = await render(signedIn(
|
|
475
|
+
const widget = await render(signedIn(), undefined, {
|
|
483
476
|
showExamples: false
|
|
484
477
|
});
|
|
485
478
|
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodePayload,
|
|
3
|
+
encodePayload,
|
|
4
|
+
FRAGMENT_KEY,
|
|
5
|
+
hashWithoutPayload,
|
|
6
|
+
IDatameshNotebookRequest,
|
|
7
|
+
MAX_DEPTH,
|
|
8
|
+
MAX_ITEMS,
|
|
9
|
+
notebookFromDatamesh,
|
|
10
|
+
notebookName,
|
|
11
|
+
payloadFromHash,
|
|
12
|
+
pyIdentifier,
|
|
13
|
+
pyLiteral,
|
|
14
|
+
pyString,
|
|
15
|
+
queryCellSource,
|
|
16
|
+
queryFields
|
|
17
|
+
} from '../share/fromDatamesh';
|
|
18
|
+
import { removeMath, replaceMath } from '@jupyterlab/rendermime/lib/latex';
|
|
19
|
+
import { marked } from 'marked';
|
|
20
|
+
|
|
21
|
+
const QUERY = {
|
|
22
|
+
id: 'abc123',
|
|
23
|
+
label: 'era5_wind10m_abc123',
|
|
24
|
+
datasource: 'era5_wind10m',
|
|
25
|
+
description: 'ERA5 10m wind',
|
|
26
|
+
variables: ['u10', 'v10'],
|
|
27
|
+
timefilter: { times: ['2024-01-01T00:00:00Z', null] },
|
|
28
|
+
geofilter: { type: 'bbox', geom: [170, -45, 175, -40] }
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function request(items: unknown[], name?: string): IDatameshNotebookRequest {
|
|
32
|
+
return { v: 1, name, items } as IDatameshNotebookRequest;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('payload encoding', () => {
|
|
36
|
+
it('round-trips through base64url, including non-ASCII text', () => {
|
|
37
|
+
const req = request(
|
|
38
|
+
[{ ...QUERY, description: 'Vent à 10 m — 風' }],
|
|
39
|
+
'Ōtaki'
|
|
40
|
+
);
|
|
41
|
+
const encoded = encodePayload(req);
|
|
42
|
+
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
43
|
+
expect(decodePayload(encoded)).toEqual(req);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('rejects text that is not a payload', () => {
|
|
47
|
+
expect(() => decodePayload('!!!')).toThrow('not valid');
|
|
48
|
+
expect(() => decodePayload(btoa('not json'))).toThrow('not valid');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('rejects other versions, empty and oversized workspaces, and bad items', () => {
|
|
52
|
+
const enc = (v: unknown) =>
|
|
53
|
+
encodePayload(v as unknown as IDatameshNotebookRequest);
|
|
54
|
+
expect(() => decodePayload(enc({ v: 2, items: [QUERY] }))).toThrow(
|
|
55
|
+
'newer Datamesh UI'
|
|
56
|
+
);
|
|
57
|
+
expect(() => decodePayload(enc({ v: 1, items: [] }))).toThrow('no queries');
|
|
58
|
+
expect(() =>
|
|
59
|
+
decodePayload(enc({ v: 1, items: Array(MAX_ITEMS + 1).fill(QUERY) }))
|
|
60
|
+
).toThrow('too many');
|
|
61
|
+
expect(() =>
|
|
62
|
+
decodePayload(enc({ v: 1, items: [{ datasource: 3 }] }))
|
|
63
|
+
).toThrow('not valid');
|
|
64
|
+
expect(() =>
|
|
65
|
+
decodePayload(enc({ v: 1, items: [{ datasource: ' ' }] }))
|
|
66
|
+
).toThrow('not valid');
|
|
67
|
+
expect(() => decodePayload(enc({ v: 1, name: 5, items: [QUERY] }))).toThrow(
|
|
68
|
+
'not valid'
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('fragment handling', () => {
|
|
74
|
+
it('finds the payload among other hash keys and removes only it', () => {
|
|
75
|
+
const hash = `#foo=1&${FRAGMENT_KEY}=abc_-`;
|
|
76
|
+
expect(payloadFromHash(hash)).toBe('abc_-');
|
|
77
|
+
expect(hashWithoutPayload(hash)).toBe('#foo=1');
|
|
78
|
+
expect(hashWithoutPayload(`#${FRAGMENT_KEY}=x`)).toBe('');
|
|
79
|
+
expect(payloadFromHash('')).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('keeps every other part of the hash exactly as it was', () => {
|
|
83
|
+
expect(hashWithoutPayload(`#my-heading&${FRAGMENT_KEY}=x`)).toBe(
|
|
84
|
+
'#my-heading'
|
|
85
|
+
);
|
|
86
|
+
expect(hashWithoutPayload(`#a=b%20c&${FRAGMENT_KEY}=x&d`)).toBe(
|
|
87
|
+
'#a=b%20c&d'
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('payload limits', () => {
|
|
93
|
+
const nested = (depth: number): unknown => {
|
|
94
|
+
let v: unknown = 'x'.repeat(80);
|
|
95
|
+
for (let i = 0; i < depth; i++) {
|
|
96
|
+
v = [v];
|
|
97
|
+
}
|
|
98
|
+
return v;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
it('rejects nesting deeper than real queries use', () => {
|
|
102
|
+
const enc = (depth: number) =>
|
|
103
|
+
encodePayload({
|
|
104
|
+
v: 1,
|
|
105
|
+
items: [{ datasource: 'abc', variables: nested(depth) }]
|
|
106
|
+
} as unknown as IDatameshNotebookRequest);
|
|
107
|
+
expect(() => decodePayload(enc(20))).not.toThrow();
|
|
108
|
+
expect(() => decodePayload(enc(MAX_DEPTH))).toThrow('not valid');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('renders deep values in linear time (was 2^depth)', () => {
|
|
112
|
+
const started = Date.now();
|
|
113
|
+
pyLiteral(nested(MAX_DEPTH));
|
|
114
|
+
expect(Date.now() - started).toBeLessThan(1000);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('pyLiteral', () => {
|
|
119
|
+
it('writes Python, not JSON, constants', () => {
|
|
120
|
+
expect(pyLiteral({ a: null, b: true, c: false, d: 1.5 })).toBe(
|
|
121
|
+
'{"a": None, "b": True, "c": False, "d": 1.5}'
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('keeps "null" and "true" inside strings as text', () => {
|
|
126
|
+
expect(pyLiteral(['null', 'true'])).toBe('["null", "true"]');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('escapes strings so they cannot end the literal', () => {
|
|
130
|
+
const nasty = '"); import os; os.system("x") #\n\'\'\'\\';
|
|
131
|
+
const lit = pyLiteral(nasty);
|
|
132
|
+
expect(lit).toBe(JSON.stringify(nasty));
|
|
133
|
+
expect(lit).not.toContain('\n');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('wraps long values over several indented lines', () => {
|
|
137
|
+
const lit = pyLiteral({
|
|
138
|
+
geofilter: { type: 'feature', geom: { coordinates: Array(30).fill(1) } }
|
|
139
|
+
});
|
|
140
|
+
expect(lit.split('\n').length).toBeGreaterThan(3);
|
|
141
|
+
expect(lit).toContain('\n "geofilter": {');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('pyIdentifier', () => {
|
|
146
|
+
it('makes valid, unique names', () => {
|
|
147
|
+
const taken = new Set<string>();
|
|
148
|
+
expect(pyIdentifier('ERA5 wind-10m.v2', taken)).toBe('era5_wind_10m_v2');
|
|
149
|
+
expect(pyIdentifier('ERA5 wind-10m.v2', taken)).toBe('era5_wind_10m_v2_2');
|
|
150
|
+
expect(pyIdentifier('2024 swell', taken)).toBe('ds_2024_swell');
|
|
151
|
+
expect(pyIdentifier('import', taken)).toBe('import_');
|
|
152
|
+
expect(pyIdentifier('datamesh', taken)).toBe('datamesh_');
|
|
153
|
+
expect(pyIdentifier('—', taken)).toBe('data');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('query cells', () => {
|
|
158
|
+
it('passes only fields oceanum-python Query accepts', () => {
|
|
159
|
+
expect(Object.keys(queryFields(QUERY)).sort()).toEqual(
|
|
160
|
+
[
|
|
161
|
+
'datasource',
|
|
162
|
+
'description',
|
|
163
|
+
'geofilter',
|
|
164
|
+
'timefilter',
|
|
165
|
+
'variables'
|
|
166
|
+
].sort()
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('queries a filtered selection', () => {
|
|
171
|
+
const src = queryCellSource(QUERY, 'wind');
|
|
172
|
+
expect(src).toContain('# ERA5 10m wind');
|
|
173
|
+
expect(src).toContain('wind = datamesh.query({');
|
|
174
|
+
expect(src).toContain('"times": ["2024-01-01T00:00:00Z", None]');
|
|
175
|
+
expect(src).not.toContain('"label"');
|
|
176
|
+
expect(src.endsWith('\nwind')).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('opens an unfiltered datasource lazily', () => {
|
|
180
|
+
const src = queryCellSource(
|
|
181
|
+
{ datasource: 'era5_wind10m', variables: [], functions: [] },
|
|
182
|
+
'wind'
|
|
183
|
+
);
|
|
184
|
+
expect(src).toBe('wind = datamesh.load_datasource("era5_wind10m")\nwind');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('keeps a multi-line description on one comment line', () => {
|
|
188
|
+
const src = queryCellSource(
|
|
189
|
+
{ ...QUERY, description: 'a\nprint("pwned")' },
|
|
190
|
+
'x'
|
|
191
|
+
);
|
|
192
|
+
expect(src.split('\n')[0]).toBe('# a print("pwned")');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Every character Python's str.splitlines() breaks on, which IPython uses to split
|
|
197
|
+
// a cell into lines (reviewer-found injection through the comment line).
|
|
198
|
+
const SPLITLINES = [
|
|
199
|
+
0x0a, 0x0b, 0x0c, 0x0d, 0x1c, 0x1d, 0x1e, 0x85, 0x2028, 0x2029
|
|
200
|
+
].map(c => String.fromCharCode(c));
|
|
201
|
+
|
|
202
|
+
describe('line breaks cannot escape a comment or a string', () => {
|
|
203
|
+
it.each(SPLITLINES.map(c => [c.charCodeAt(0).toString(16), c]))(
|
|
204
|
+
'U+%s in a description or label stays inside the comment',
|
|
205
|
+
(_hex, sep) => {
|
|
206
|
+
for (const item of [
|
|
207
|
+
{ ...QUERY, description: `wind${sep}import os` },
|
|
208
|
+
{ ...QUERY, description: undefined, label: `wind${sep}import os` }
|
|
209
|
+
]) {
|
|
210
|
+
const src = queryCellSource(item, 'x');
|
|
211
|
+
const comment = src.split('\n')[0];
|
|
212
|
+
expect(comment).toBe('# wind import os');
|
|
213
|
+
for (const c of SPLITLINES.filter(s => s !== '\n')) {
|
|
214
|
+
expect(src).not.toContain(c);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
it('escapes C1 controls and U+2028/9 inside string literals and keys', () => {
|
|
221
|
+
const text = SPLITLINES.join('') + String.fromCharCode(0, 0x7f, 0x9f);
|
|
222
|
+
const lit = pyLiteral({ [text]: text });
|
|
223
|
+
// Only printable ASCII is left: every control and separator is a \u escape.
|
|
224
|
+
expect(pyString(text)).toMatch(/^[\x20-\x7e]*$/);
|
|
225
|
+
// pyLiteral's own wrapping adds plain newlines, and nothing else.
|
|
226
|
+
expect(lit).toMatch(/^[\x20-\x7e\n]*$/);
|
|
227
|
+
expect(lit).toContain('\\u0085');
|
|
228
|
+
expect(lit).toContain('\\u2028');
|
|
229
|
+
// Still the same value once parsed (JSON reads \u escapes as Python does).
|
|
230
|
+
expect(JSON.parse(pyString(text))).toBe(text);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('strips them from the Markdown heading too', () => {
|
|
234
|
+
const nb = notebookFromDatamesh(
|
|
235
|
+
request([QUERY], `a${SPLITLINES.join('')}b`)
|
|
236
|
+
);
|
|
237
|
+
expect(String(nb.cells[0].source).split('\n')[0]).toBe('# a b');
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe('the Markdown heading, rendered as JupyterLab renders it', () => {
|
|
242
|
+
it.each([
|
|
243
|
+
'[x](https://e.example) ',
|
|
244
|
+
'https://2130706433/login',
|
|
245
|
+
'www.evil.example',
|
|
246
|
+
'me@evil.example',
|
|
247
|
+
'<img src=x onerror=alert(1)>',
|
|
248
|
+
'[x](javascript:alert(1))',
|
|
249
|
+
'`code` **bold** _em_ ~~s~~',
|
|
250
|
+
'@@0@@',
|
|
251
|
+
'run @@12@@ of 20',
|
|
252
|
+
'Cost $5 and $6'
|
|
253
|
+
])('%s renders as plain text', async name => {
|
|
254
|
+
const nb = notebookFromDatamesh(request([QUERY], name));
|
|
255
|
+
// JupyterLab's own Markdown path: math placeholders, marked, math restored.
|
|
256
|
+
const { text, math } = removeMath(String(nb.cells[0].source));
|
|
257
|
+
const html = replaceMath(await marked.parse(text, { gfm: true }), math);
|
|
258
|
+
const heading = html.split('\n')[0];
|
|
259
|
+
expect(heading).toMatch(/^<h1>[^<]*<\/h1>$/);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe('optional text fields', () => {
|
|
264
|
+
it('accepts null name, label and description', () => {
|
|
265
|
+
const enc = encodePayload({
|
|
266
|
+
v: 1,
|
|
267
|
+
name: null,
|
|
268
|
+
items: [{ ...QUERY, label: null, description: null }]
|
|
269
|
+
} as unknown as IDatameshNotebookRequest);
|
|
270
|
+
const req = decodePayload(enc);
|
|
271
|
+
expect(req.name).toBeUndefined();
|
|
272
|
+
const nb = notebookFromDatamesh(req);
|
|
273
|
+
expect(nb.cells[2].source).toContain('era5_wind10m = datamesh.query(');
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('notebookFromDatamesh', () => {
|
|
278
|
+
it('builds an untrusted, unlinked notebook with one cell per query', () => {
|
|
279
|
+
const nb = notebookFromDatamesh(
|
|
280
|
+
request([QUERY, { ...QUERY, label: undefined }], 'My <b>work</b>space')
|
|
281
|
+
);
|
|
282
|
+
expect(nb.nbformat).toBe(4);
|
|
283
|
+
expect(nb.metadata).toEqual({ language_info: { name: 'python' } });
|
|
284
|
+
expect(nb.cells).toHaveLength(4);
|
|
285
|
+
expect(nb.cells[0].source).toContain('# My <b>work<\\/b>space');
|
|
286
|
+
expect(nb.cells[1].source).toContain('datamesh = Connector()');
|
|
287
|
+
expect(nb.cells[2].source).toContain(
|
|
288
|
+
'era5_wind10m_abc123 = datamesh.query'
|
|
289
|
+
);
|
|
290
|
+
expect(nb.cells[3].source).toContain('era5_wind10m = datamesh.query');
|
|
291
|
+
for (const cell of nb.cells) {
|
|
292
|
+
expect(cell.metadata).toEqual({});
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('names the notebook after the workspace, else the single query', () => {
|
|
297
|
+
expect(notebookName(request([QUERY], ' Coast '))).toBe('Coast');
|
|
298
|
+
expect(notebookName(request([QUERY]))).toBe('era5_wind10m_abc123');
|
|
299
|
+
expect(notebookName(request([QUERY, QUERY]))).toBe('Datamesh workspace');
|
|
300
|
+
});
|
|
301
|
+
});
|