@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
@@ -1,22 +1,202 @@
1
1
  import * as React from 'react';
2
2
 
3
3
  import { IOceanumAuth } from './auth/tokens';
4
- import { ISpecSummary, listNotebooks, partitionNotebooks } from './specStore';
4
+ import { SpecStoreClient } from './share/client';
5
+ import { notebooksChanged } from './share/events';
6
+ import {
7
+ INotebookDemoItem,
8
+ INotebookDemoSection,
9
+ ISpecSummary,
10
+ parseNotebookDemo,
11
+ partitionSummaries
12
+ } from './share/notebook';
13
+ import { SPEC_ID_ATTRIBUTE } from './share/plugin';
14
+
15
+ /** Example rows carry their record id here, not in SPEC_ID_ATTRIBUTE: see Examples. */
16
+ export const EXAMPLE_ID_ATTRIBUTE = 'data-example-id';
5
17
 
6
18
  export interface IStoredNotebooksProps {
7
19
  auth: IOceanumAuth;
8
20
  /**
9
- * Open a stored notebook, when this host can. Undefined where nothing has registered
10
- * a way to open one — today that is native JupyterLab, which has no spec store drive
11
- * until OCE-182. The list then reads rather than offering an action that does nothing.
21
+ * Open a stored notebook. Undefined where nothing has registered a way to open one;
22
+ * the list then reads rather than offering a click that does nothing.
12
23
  */
13
24
  onOpen?: (item: ISpecSummary) => void;
25
+ /** Open a copy of an example, as `onOpen` does for a stored notebook. */
26
+ onOpenExample?: (item: INotebookDemoItem) => void;
27
+ /** Upload a notebook from the user's computer; no Upload button without it. */
28
+ onUpload?: () => void;
29
+ /**
30
+ * Whether the examples are listed, or folded under their heading. Default true. Null
31
+ * while the setting is still loading: the Examples wait for it, so examples a user
32
+ * has hidden never show for a moment first.
33
+ */
34
+ showExamples?: boolean | null;
35
+ /**
36
+ * Record the user's choice to show or hide the examples. The Examples heading offers
37
+ * its switch only where this is given: a switch that could not keep its setting
38
+ * would flip back on the next load.
39
+ */
40
+ onShowExamplesChange?: (show: boolean) => void;
41
+ /**
42
+ * Start signing in the way this host shows it; without it the auth's own `signIn()`
43
+ * is used, which on a JupyterLab server shows no code dialog.
44
+ */
45
+ onSignIn?: () => void;
14
46
  }
15
47
 
48
+ /** The deployment's Notebook Demo record, which lists the Examples. */
49
+ type Examples =
50
+ | { state: 'none' }
51
+ | { state: 'error'; message: string }
52
+ | { state: 'ready'; sections: INotebookDemoSection[] };
53
+
16
54
  type Load =
17
55
  | { state: 'loading' }
18
56
  | { state: 'error'; message: string }
19
- | { state: 'ready'; items: ISpecSummary[] };
57
+ | { state: 'ready'; items: ISpecSummary[]; examples: Examples };
58
+
59
+ /**
60
+ * Read the Notebook Demo record, if the deployment names one. Never rejects: a failure
61
+ * here is shown in the Examples section and leaves the rest of the tab alone.
62
+ */
63
+ async function loadExamples(
64
+ auth: IOceanumAuth,
65
+ specsUrl: string,
66
+ id: string | null
67
+ ): Promise<Examples> {
68
+ if (!id) {
69
+ return { state: 'none' };
70
+ }
71
+ try {
72
+ const client = new SpecStoreClient({
73
+ specsUrl,
74
+ getAccessToken: () => auth.getAccessToken(),
75
+ specType: 'notebook-demo'
76
+ });
77
+ const record = await client.get(id);
78
+ return { state: 'ready', sections: parseNotebookDemo(record.spec) };
79
+ } catch (error) {
80
+ return {
81
+ state: 'error',
82
+ message:
83
+ error instanceof Error ? error.message : 'Could not reach Oceanum.io.'
84
+ };
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The curated examples. Their rows deliberately leave out SPEC_ID_ATTRIBUTE and the
90
+ * stored-notebook row class: the share plugin's row menu (Open, Rename, Share, Delete)
91
+ * is bound to those, and none of it applies to Oceanum's own example records.
92
+ */
93
+ function ExampleSections({
94
+ examples,
95
+ onOpen,
96
+ show,
97
+ onShowChange
98
+ }: {
99
+ examples: Examples;
100
+ onOpen?: (item: INotebookDemoItem) => void;
101
+ show: boolean | null;
102
+ onShowChange?: (show: boolean) => void;
103
+ }): React.ReactElement | null {
104
+ // Not yet known, or hidden with no switch to bring them back: nothing to show.
105
+ if (examples.state === 'none' || show === null || (!show && !onShowChange)) {
106
+ return null;
107
+ }
108
+ const count =
109
+ examples.state === 'ready'
110
+ ? examples.sections.reduce(
111
+ (total, section) => total + section.items.length,
112
+ 0
113
+ )
114
+ : null;
115
+ if (count === 0) {
116
+ return null;
117
+ }
118
+ const heading = (
119
+ <div className="oceanum-notebooks-heading">
120
+ Examples
121
+ <span className="oceanum-notebooks-heading-end">
122
+ {count !== null && (
123
+ <span className="oceanum-notebooks-count">{count}</span>
124
+ )}
125
+ {onShowChange && (
126
+ <button
127
+ type="button"
128
+ role="switch"
129
+ aria-checked={show}
130
+ aria-label="Show examples"
131
+ title="Show examples"
132
+ className="oceanum-notebooks-switch"
133
+ onClick={() => onShowChange(!show)}
134
+ >
135
+ <span className="oceanum-notebooks-switch-thumb" />
136
+ </button>
137
+ )}
138
+ </span>
139
+ </div>
140
+ );
141
+ // Hidden: the heading stays, so the switch is there to bring them back.
142
+ if (!show) {
143
+ return <div className="oceanum-notebooks-section">{heading}</div>;
144
+ }
145
+ if (examples.state === 'error') {
146
+ return (
147
+ <div className="oceanum-notebooks-section">
148
+ {heading}
149
+ <div className="oceanum-text-error">
150
+ The examples could not be loaded. {examples.message}
151
+ </div>
152
+ </div>
153
+ );
154
+ }
155
+ return (
156
+ <div className="oceanum-notebooks-section">
157
+ {heading}
158
+ {examples.sections.map((section, index) => (
159
+ <div key={index}>
160
+ {/* Titles come from the record: React escapes them, never set as HTML. */}
161
+ <div className="oceanum-notebooks-subheading">{section.title}</div>
162
+ <ul className="oceanum-notebooks-list">
163
+ {section.items.map((item, position) => {
164
+ const attributes = {
165
+ [EXAMPLE_ID_ATTRIBUTE]: item.id,
166
+ title: item.summary
167
+ };
168
+ const name = (
169
+ <span className="oceanum-notebooks-name">{item.title}</span>
170
+ );
171
+ return (
172
+ // A record may list the same example twice; the id alone is no key.
173
+ <li key={`${position}:${item.id}`}>
174
+ {onOpen ? (
175
+ <button
176
+ type="button"
177
+ className="oceanum-notebooks-example"
178
+ onClick={() => onOpen(item)}
179
+ {...attributes}
180
+ >
181
+ {name}
182
+ </button>
183
+ ) : (
184
+ <div
185
+ className="oceanum-notebooks-example oceanum-notebooks-item-static"
186
+ {...attributes}
187
+ >
188
+ {name}
189
+ </div>
190
+ )}
191
+ </li>
192
+ );
193
+ })}
194
+ </ul>
195
+ </div>
196
+ ))}
197
+ </div>
198
+ );
199
+ }
20
200
 
21
201
  function Section({
22
202
  title,
@@ -29,6 +209,12 @@ function Section({
29
209
  empty: string;
30
210
  onOpen?: (item: ISpecSummary) => void;
31
211
  }): React.ReactElement {
212
+ // Each row carries its record id so the share plugin's context menu (Open, Rename,
213
+ // Share, Delete) can tell which one was clicked.
214
+ const rowAttributes = (item: ISpecSummary) => ({
215
+ [SPEC_ID_ATTRIBUTE]: item.id,
216
+ title: item.description ?? undefined
217
+ });
32
218
  return (
33
219
  <div className="oceanum-notebooks-section">
34
220
  <div className="oceanum-notebooks-heading">
@@ -47,7 +233,7 @@ function Section({
47
233
  type="button"
48
234
  className="oceanum-notebooks-item"
49
235
  onClick={() => onOpen(item)}
50
- title={item.description ?? undefined}
236
+ {...rowAttributes(item)}
51
237
  >
52
238
  <span className="oceanum-notebooks-name">
53
239
  {item.name || 'Untitled'}
@@ -56,7 +242,7 @@ function Section({
56
242
  ) : (
57
243
  <div
58
244
  className="oceanum-notebooks-item oceanum-notebooks-item-static"
59
- title={item.description ?? undefined}
245
+ {...rowAttributes(item)}
60
246
  >
61
247
  <span className="oceanum-notebooks-name">
62
248
  {item.name || 'Untitled'}
@@ -76,18 +262,29 @@ function Section({
76
262
  */
77
263
  export function StoredNotebooks({
78
264
  auth,
79
- onOpen
265
+ onOpen,
266
+ onOpenExample,
267
+ onUpload,
268
+ showExamples = true,
269
+ onShowExamplesChange,
270
+ onSignIn
80
271
  }: IStoredNotebooksProps): React.ReactElement {
81
272
  const [load, setLoad] = React.useState<Load>({ state: 'loading' });
82
273
  const [user, setUser] = React.useState(auth.user);
274
+ // Bumped whenever the store changes, so the list is fetched again.
275
+ const [version, setVersion] = React.useState(0);
83
276
 
84
277
  // Reload whenever the signed-in user changes, so signing in or out does not leave a
85
- // stale list from the previous session on screen.
278
+ // stale list from the previous session on screen; and whenever this extension has
279
+ // created, renamed or deleted a record.
86
280
  React.useEffect(() => {
87
281
  const onUser = (): void => setUser(auth.user);
282
+ const onChanged = (): void => setVersion(v => v + 1);
88
283
  auth.userChanged.connect(onUser);
284
+ notebooksChanged.connect(onChanged);
89
285
  return () => {
90
286
  auth.userChanged.disconnect(onUser);
287
+ notebooksChanged.disconnect(onChanged);
91
288
  };
92
289
  }, [auth]);
93
290
 
@@ -95,23 +292,24 @@ export function StoredNotebooks({
95
292
  let cancelled = false;
96
293
  const specs = auth.urls?.specs ?? null;
97
294
  if (!user || !specs) {
98
- setLoad({ state: 'ready', items: [] });
295
+ setLoad({ state: 'ready', items: [], examples: { state: 'none' } });
99
296
  return;
100
297
  }
101
298
  setLoad({ state: 'loading' });
102
299
  void (async () => {
103
300
  try {
104
- const token = await auth.getAccessToken();
105
- if (cancelled) {
106
- return;
107
- }
108
- if (!token) {
109
- setLoad({ state: 'ready', items: [] });
110
- return;
111
- }
112
- const items = await listNotebooks(specs, token);
301
+ const client = new SpecStoreClient({
302
+ specsUrl: specs,
303
+ getAccessToken: () => auth.getAccessToken()
304
+ });
305
+ // Only the record the deployment names: every signed-in user can create
306
+ // notebook-demo records, so the examples never come from a listing.
307
+ const [items, examples] = await Promise.all([
308
+ client.list(),
309
+ loadExamples(auth, specs, auth.environment?.notebookDemo ?? null)
310
+ ]);
113
311
  if (!cancelled) {
114
- setLoad({ state: 'ready', items });
312
+ setLoad({ state: 'ready', items, examples });
115
313
  }
116
314
  } catch (error) {
117
315
  if (!cancelled) {
@@ -128,12 +326,36 @@ export function StoredNotebooks({
128
326
  return () => {
129
327
  cancelled = true;
130
328
  };
131
- }, [auth, user]);
329
+ }, [auth, user, version]);
132
330
 
133
331
  if (!user) {
332
+ const signIn = (): void => {
333
+ if (onSignIn) {
334
+ onSignIn();
335
+ return;
336
+ }
337
+ auth.signIn().catch(error => {
338
+ console.warn('Oceanum.io sign-in could not start.', error);
339
+ });
340
+ };
134
341
  return (
135
342
  <div className="oceanum-text-empty">
136
- Sign in to Oceanum.io to see your notebooks.
343
+ {/* A link rather than a button to match the panel's other actions, so it
344
+ carries the role and the keys a button would have. */}
345
+ <a
346
+ role="button"
347
+ tabIndex={0}
348
+ onClick={signIn}
349
+ onKeyDown={event => {
350
+ if (event.key === 'Enter' || event.key === ' ') {
351
+ event.preventDefault();
352
+ signIn();
353
+ }
354
+ }}
355
+ >
356
+ Sign in
357
+ </a>{' '}
358
+ to Oceanum.io to see your notebooks.
137
359
  </div>
138
360
  );
139
361
  }
@@ -146,9 +368,31 @@ export function StoredNotebooks({
146
368
  return <div className="oceanum-text-error">{load.message}</div>;
147
369
  }
148
370
 
149
- const { mine, shared } = partitionNotebooks(load.items, user.email);
371
+ const { mine, shared } = partitionSummaries(load.items, user.email);
372
+ // A public example is readable by everyone, so the listing returns it as shared with
373
+ // every signed-in user; it is shown under Examples instead.
374
+ const exampleIds = new Set(
375
+ load.examples.state === 'ready'
376
+ ? load.examples.sections.flatMap(section =>
377
+ section.items.map(item => item.id)
378
+ )
379
+ : []
380
+ );
150
381
  return (
151
382
  <div className="oceanum-notebooks">
383
+ {/* Without a spec store the upload command does nothing. */}
384
+ {onUpload && auth.urls?.specs ? (
385
+ <div className="oceanum-notebooks-actions">
386
+ <button
387
+ type="button"
388
+ className="oceanum-notebooks-upload"
389
+ title="Upload a notebook from this computer, to save on Oceanum.io"
390
+ onClick={onUpload}
391
+ >
392
+ Upload notebook…
393
+ </button>
394
+ </div>
395
+ ) : null}
152
396
  <Section
153
397
  title="My notebooks"
154
398
  items={mine}
@@ -157,10 +401,16 @@ export function StoredNotebooks({
157
401
  />
158
402
  <Section
159
403
  title="Shared with me"
160
- items={shared}
404
+ items={shared.filter(item => !exampleIds.has(item.id))}
161
405
  empty="No notebooks have been shared with you."
162
406
  onOpen={onOpen}
163
407
  />
408
+ <ExampleSections
409
+ examples={load.examples}
410
+ onOpen={onOpenExample}
411
+ show={showExamples}
412
+ onShowChange={onShowExamplesChange}
413
+ />
164
414
  </div>
165
415
  );
166
416
  }
@@ -11,8 +11,29 @@ const icon = new LabIcon({
11
11
  svgstr: '<svg xmlns="http://www.w3.org/2000/svg"></svg>'
12
12
  });
13
13
 
14
- function panel(): DatameshConnectWidget {
14
+ interface IPanelOptions {
15
+ /** The `datameshToken` setting value. */
16
+ token?: string;
17
+ /** Register the host's sign-in commands, and say whether someone is signed in. */
18
+ signedIn?: boolean;
19
+ /** Widget props to use instead of the defaults below. */
20
+ props?: Record<string, unknown>;
21
+ }
22
+
23
+ function panel(options: IPanelOptions = {}): DatameshConnectWidget {
15
24
  const commands = new CommandRegistry();
25
+ if (options.signedIn !== undefined) {
26
+ // What the host provides: sign-out is visible only while signed in, which is how
27
+ // the sidebar reads the state without running anything.
28
+ commands.addCommand('oceanum-auth:sign-out', {
29
+ execute: () => undefined,
30
+ isVisible: () => options.signedIn === true
31
+ });
32
+ commands.addCommand('oceanum-auth:access-token', {
33
+ execute: () => null,
34
+ isVisible: () => false
35
+ });
36
+ }
16
37
  const app = {
17
38
  shell: { currentWidget: null },
18
39
  commands
@@ -24,17 +45,20 @@ function panel(): DatameshConnectWidget {
24
45
  openDatameshUI: (): void => undefined,
25
46
  datameshUiUrl: () => 'https://ui.datamesh.example.com',
26
47
  datameshUiFrame: (): Window | null => null,
27
- datameshToken: () => '',
48
+ datameshToken: () => options.token ?? '',
28
49
  aiBackendUrl: () => 'https://ai.example.com',
29
50
  settingsChanged: new Signal<unknown, void>({}),
30
51
  commands,
31
52
  getCurrentWidget: () => new Widget(),
32
- auth: null
53
+ auth: null,
54
+ ...options.props
33
55
  } as unknown as ConstructorParameters<typeof DatameshConnectWidget>[0]);
34
56
  }
35
57
 
36
- async function mount(): Promise<DatameshConnectWidget> {
37
- const widget = panel();
58
+ async function mount(
59
+ options: IPanelOptions = {}
60
+ ): Promise<DatameshConnectWidget> {
61
+ const widget = panel(options);
38
62
  Widget.attach(widget, document.body);
39
63
  await new Promise(resolve => setTimeout(resolve, 100));
40
64
  return widget;
@@ -46,6 +70,19 @@ async function mount(): Promise<DatameshConnectWidget> {
46
70
  * touches the Datamesh surface, so nothing else here would notice if rearranging the
47
71
  * two lost one of them.
48
72
  */
73
+ beforeAll(() => {
74
+ // The AI chat scrolls to its last message, and every pane renders whether or not it
75
+ // is the selected one. jsdom has no scrollIntoView, and the error would come back as
76
+ // React unmounting the whole sidebar.
77
+ (Element.prototype as unknown as Record<string, unknown>).scrollIntoView =
78
+ (): void => undefined;
79
+ });
80
+
81
+ const tabLabels = (widget: DatameshConnectWidget): (string | null)[] =>
82
+ Array.from(widget.node.querySelectorAll('[role="tab"]')).map(
83
+ node => node.textContent
84
+ );
85
+
49
86
  describe('the Oceanum panel', () => {
50
87
  it('offers no Oceanum AI tab when the chat has no credential', async () => {
51
88
  // AIChatPanel renders nothing without one, so a labelled tab would open on the
@@ -61,6 +98,28 @@ describe('the Oceanum panel', () => {
61
98
  widget.dispose();
62
99
  });
63
100
 
101
+ it('offers no Oceanum AI tab for a pasted token alone, until someone signs in', async () => {
102
+ // The token would reach the backend, but the tab waits for a sign-in.
103
+ const withToken = await mount({ token: 'a-datamesh-token' });
104
+ expect(tabLabels(withToken)).toEqual(['Notebooks', 'Datamesh']);
105
+ withToken.dispose();
106
+
107
+ const signedOut = await mount({
108
+ token: 'a-datamesh-token',
109
+ signedIn: false
110
+ });
111
+ expect(tabLabels(signedOut)).toEqual(['Notebooks', 'Datamesh']);
112
+ signedOut.dispose();
113
+ });
114
+
115
+ it('offers the Oceanum AI tab once the host says the user is signed in', async () => {
116
+ const widget = await mount({ signedIn: true });
117
+
118
+ expect(tabLabels(widget)).toEqual(['Notebooks', 'Datamesh', 'Oceanum AI']);
119
+
120
+ widget.dispose();
121
+ });
122
+
64
123
  it('still renders the Datamesh workspace section', async () => {
65
124
  const widget = await mount();
66
125
 
@@ -99,3 +158,98 @@ describe('the Oceanum panel', () => {
99
158
  widget.dispose();
100
159
  });
101
160
  });
161
+
162
+ describe('the Examples switch in the Oceanum panel', () => {
163
+ const DEMO_ID = '3e4f5a6b-4444-4a2b-8c3d-9e8f7a6b5c4d';
164
+ const EXAMPLE = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
165
+ const originalFetch = globalThis.fetch;
166
+ afterEach(() => {
167
+ globalThis.fetch = originalFetch;
168
+ });
169
+
170
+ const until = async (test: () => boolean): Promise<void> => {
171
+ const deadline = Date.now() + 4000;
172
+ while (!test()) {
173
+ if (Date.now() > deadline) {
174
+ throw new Error('timed out');
175
+ }
176
+ await new Promise(resolve => setTimeout(resolve, 25));
177
+ }
178
+ };
179
+
180
+ it('saves the choice, and follows the setting once it has changed', async () => {
181
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
182
+ const demo = String(input).includes('/specs/notebook-demo/');
183
+ const data: unknown = demo
184
+ ? {
185
+ id: DEMO_ID,
186
+ name: 'Notebook Demo',
187
+ description: null as string | null,
188
+ modified: '2026-09-22T10:00:00',
189
+ creator: null as string | null,
190
+ spec: {
191
+ kind: 'notebook-demo',
192
+ version: 1,
193
+ sections: [
194
+ { title: 'Start', items: [{ id: EXAMPLE, title: 'Query' }] }
195
+ ]
196
+ }
197
+ }
198
+ : [];
199
+ return { ok: true, status: 200, json: async () => data } as Response;
200
+ }) as typeof fetch;
201
+ const auth = {
202
+ environment: { notebookDemo: DEMO_ID },
203
+ urls: { specs: 'https://specs.example.com' },
204
+ ready: Promise.resolve(),
205
+ user: {
206
+ sub: 'auth0|1',
207
+ email: 'me@example.com',
208
+ name: null as string | null,
209
+ activeOrg: null as string | null,
210
+ colorScheme: null as string | null
211
+ },
212
+ userChanged: new Signal<unknown, unknown>({}),
213
+ tokenChanged: new Signal<unknown, unknown>({}),
214
+ signIn: async (): Promise<void> => undefined,
215
+ signOut: async (): Promise<void> => undefined,
216
+ getAccessToken: async (): Promise<string> => 'tok'
217
+ };
218
+ // What the host keeps: the setting, and the signal it emits when it changes.
219
+ // Null until the host's settings load.
220
+ let shown: boolean | null = null;
221
+ const saved: boolean[] = [];
222
+ const settingsChanged = new Signal<unknown, void>({});
223
+ const widget = await mount({
224
+ props: {
225
+ auth,
226
+ settingsChanged,
227
+ showExamples: () => shown,
228
+ setShowExamples: (show: boolean) => saved.push(show)
229
+ }
230
+ });
231
+ const toggle = (): HTMLButtonElement | null =>
232
+ widget.node.querySelector<HTMLButtonElement>('[role="switch"]');
233
+ // The tab has loaded, but the examples wait for the setting.
234
+ await until(() => widget.node.querySelector('.oceanum-notebooks') !== null);
235
+ expect(toggle()).toBeNull();
236
+ expect(widget.node.querySelector('[data-example-id]')).toBeNull();
237
+
238
+ shown = true;
239
+ settingsChanged.emit();
240
+ await until(() => toggle() !== null);
241
+ expect(toggle()!.getAttribute('aria-checked')).toBe('true');
242
+ expect(widget.node.querySelector('[data-example-id]')).not.toBeNull();
243
+
244
+ toggle()!.click();
245
+ expect(saved).toEqual([false]);
246
+
247
+ // The host saves the setting and says so; the panel reads it again.
248
+ shown = false;
249
+ settingsChanged.emit();
250
+ await until(() => toggle()?.getAttribute('aria-checked') === 'false');
251
+ expect(widget.node.querySelector('[data-example-id]')).toBeNull();
252
+
253
+ widget.dispose();
254
+ });
255
+ });