@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.
@@ -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
- fetches.push(String(input));
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 PUBLIC_EXAMPLE = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
134
- const OTHER_EXAMPLE = '1c2d3e4f-2222-4a2b-8c3d-9e8f7a6b5c4d';
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 DEMO_ID = '3e4f5a6b-4444-4a2b-8c3d-9e8f7a6b5c4d';
137
-
138
- const summary = (id: string, name: string, creator: string | null) => ({
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: null as string | null,
149
+ description,
142
150
  modified: '2026-09-12T10:00:00',
143
151
  creator
144
152
  });
145
153
 
146
- // The listing returns a public example as shared with every signed-in user.
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
- const DEMO = {
154
- ...summary(DEMO_ID, 'Notebook Demo', null),
155
- spec: {
156
- kind: 'notebook-demo',
157
- version: 1,
158
- sections: [
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(notebookDemo?: string): IOceanumAuth {
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 = notebookDemo
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
- function stubFetch(demo: () => Response = () => ok(DEMO)): void {
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.includes('/specs/notebook-demo/') ? demo() : ok(LISTING);
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 the record’s examples in order, under their section titles', async () => {
261
+ it('lists every Notebook Demo spec as an example, ordered by name', async () => {
272
262
  stubFetch();
273
- const widget = await render(signedIn(DEMO_ID));
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
- PUBLIC_EXAMPLE,
293
- OTHER_EXAMPLE,
294
- MINE
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('moves examples out of Shared with me, but leaves My notebooks alone', async () => {
289
+ it('leaves My notebooks and Shared with me as listed', async () => {
307
290
  stubFetch();
308
- const widget = await render(signedIn(DEMO_ID));
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 nothing more and shows no Examples where none is configured', async () => {
323
+ it('asks for no demos while signed out', async () => {
320
324
  stubFetch();
321
- const widget = await render(signedIn());
325
+ const widget = new Harness(signedOut());
326
+ Widget.attach(widget, document.body);
327
+ await new Promise(resolve => setTimeout(resolve, 100));
322
328
 
323
- expect(section(widget, 'Examples')).toBeNull();
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 record cannot be read, and still lists the rest', async () => {
333
+ it('says so when the demos cannot be listed, and still lists the rest', async () => {
334
334
  stubFetch(
335
- () => ({ ok: false, status: 404, json: async () => ({}) }) as Response
335
+ () => ({ ok: false, status: 500, json: async () => ({}) }) as Response
336
336
  );
337
- const widget = await render(signedIn(DEMO_ID));
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(DEMO_ID), () => undefined);
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(DEMO_ID), item => opened.push(item));
369
+ const widget = await render(signedIn(), item => opened.push(item));
371
370
 
372
371
  section(widget, 'Examples')!
373
- .querySelector<HTMLButtonElement>(`[data-example-id="${OTHER_EXAMPLE}"]`)!
372
+ .querySelector<HTMLButtonElement>(`[data-example-id="${PLOT}"]`)!
374
373
  .click();
375
374
 
376
- expect(opened).toEqual([{ id: OTHER_EXAMPLE, title: 'Plot waves' }]);
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(DEMO_ID), undefined, {
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(DEMO_ID), undefined, {
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 record is still read, and its examples stay out of
422
- // Shared with me.
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: 404, json: async () => ({}) }) as Response
433
+ () => ({ ok: false, status: 500, json: async () => ({}) }) as Response
441
434
  );
442
- const widget = await render(signedIn(DEMO_ID), undefined, {
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(DEMO_ID));
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(DEMO_ID), undefined, {
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(DEMO_ID), undefined, {
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) ![y](https://e.example/p.png)',
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 &lt;b&gt;work&lt;\\/b&gt;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
+ });