@oceanum/oceanumlab 4.7.0 → 4.7.2

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 (81) hide show
  1. package/README.md +102 -11
  2. package/lib/DatameshUI.d.ts +1 -6
  3. package/lib/DatameshUI.js +1 -9
  4. package/lib/DatameshWidget.d.ts +18 -2
  5. package/lib/DatameshWidget.js +80 -12
  6. package/lib/StoredNotebooks.d.ts +20 -0
  7. package/lib/StoredNotebooks.js +112 -0
  8. package/lib/Tabs.d.ts +26 -0
  9. package/lib/Tabs.js +45 -0
  10. package/lib/aiBackend.d.ts +1 -1
  11. package/lib/aiBackend.js +1 -1
  12. package/lib/auth/accountWidget.d.ts +35 -0
  13. package/lib/auth/accountWidget.js +95 -0
  14. package/lib/auth/claims.d.ts +3 -0
  15. package/lib/auth/claims.js +25 -0
  16. package/lib/auth/plugin.d.ts +9 -0
  17. package/lib/auth/plugin.js +126 -0
  18. package/lib/auth/serverAuth.d.ts +79 -0
  19. package/lib/auth/serverAuth.js +254 -0
  20. package/lib/auth/startSignIn.d.ts +11 -0
  21. package/lib/auth/startSignIn.js +16 -0
  22. package/lib/auth/tokens.d.ts +102 -0
  23. package/lib/auth/tokens.js +9 -0
  24. package/lib/chatRouter.d.ts +13 -1
  25. package/lib/chatRouter.js +9 -2
  26. package/lib/constants.d.ts +1 -1
  27. package/lib/constants.js +1 -1
  28. package/lib/datameshUiUrl.d.ts +2 -2
  29. package/lib/datameshUiUrl.js +6 -6
  30. package/lib/index.d.ts +8 -1
  31. package/lib/index.js +58 -17
  32. package/lib/share/client.d.ts +84 -0
  33. package/lib/share/client.js +205 -0
  34. package/lib/share/events.d.ts +7 -0
  35. package/lib/share/events.js +9 -0
  36. package/lib/share/notebook.d.ts +184 -0
  37. package/lib/share/notebook.js +277 -0
  38. package/lib/share/oceanumOnly.d.ts +49 -0
  39. package/lib/share/oceanumOnly.js +90 -0
  40. package/lib/share/plugin.d.ts +21 -0
  41. package/lib/share/plugin.js +883 -0
  42. package/lib/share/widgets.d.ts +25 -0
  43. package/lib/share/widgets.js +154 -0
  44. package/package.json +13 -1
  45. package/schema/datamesh-connect.json +3 -12
  46. package/src/DatameshUI.tsx +1 -10
  47. package/src/DatameshWidget.tsx +143 -19
  48. package/src/StoredNotebooks.tsx +206 -0
  49. package/src/Tabs.tsx +102 -0
  50. package/src/__tests__/DatameshWidget.spec.tsx +33 -20
  51. package/src/__tests__/OceanumPanel.spec.tsx +157 -0
  52. package/src/__tests__/StoredNotebooks.spec.tsx +157 -0
  53. package/src/__tests__/Tabs.spec.tsx +168 -0
  54. package/src/__tests__/aiBackend.spec.ts +5 -6
  55. package/src/__tests__/chatRouter.spec.ts +9 -15
  56. package/src/__tests__/datameshUiUrl.spec.ts +4 -3
  57. package/src/__tests__/oceanumOnly.spec.ts +193 -0
  58. package/src/__tests__/pluginArity.spec.ts +24 -0
  59. package/src/__tests__/serverAuth.spec.ts +277 -0
  60. package/src/__tests__/settingsSchema.spec.ts +73 -0
  61. package/src/__tests__/shareClient.spec.ts +314 -0
  62. package/src/__tests__/shareNotebook.spec.ts +451 -0
  63. package/src/__tests__/sharePlugin.spec.ts +426 -0
  64. package/src/aiBackend.ts +1 -1
  65. package/src/auth/accountWidget.ts +113 -0
  66. package/src/auth/claims.ts +30 -0
  67. package/src/auth/plugin.ts +155 -0
  68. package/src/auth/serverAuth.ts +342 -0
  69. package/src/auth/startSignIn.ts +23 -0
  70. package/src/auth/tokens.ts +111 -0
  71. package/src/chatRouter.ts +8 -4
  72. package/src/constants.ts +1 -1
  73. package/src/datameshUiUrl.ts +6 -6
  74. package/src/index.ts +87 -36
  75. package/src/share/client.ts +288 -0
  76. package/src/share/events.ts +10 -0
  77. package/src/share/notebook.ts +416 -0
  78. package/src/share/oceanumOnly.ts +126 -0
  79. package/src/share/plugin.ts +1096 -0
  80. package/src/share/widgets.ts +217 -0
  81. package/style/index.css +266 -13
@@ -0,0 +1,206 @@
1
+ import * as React from 'react';
2
+
3
+ import { IOceanumAuth } from './auth/tokens';
4
+ import { SpecStoreClient } from './share/client';
5
+ import { notebooksChanged } from './share/events';
6
+ import { ISpecSummary, partitionSummaries } from './share/notebook';
7
+ import { SPEC_ID_ATTRIBUTE } from './share/plugin';
8
+
9
+ export interface IStoredNotebooksProps {
10
+ auth: IOceanumAuth;
11
+ /**
12
+ * Open a stored notebook. Undefined where nothing has registered a way to open one;
13
+ * the list then reads rather than offering a click that does nothing.
14
+ */
15
+ onOpen?: (item: ISpecSummary) => void;
16
+ /**
17
+ * Start signing in the way this host shows it; without it the auth's own `signIn()`
18
+ * is used, which on a JupyterLab server shows no code dialog.
19
+ */
20
+ onSignIn?: () => void;
21
+ }
22
+
23
+ type Load =
24
+ | { state: 'loading' }
25
+ | { state: 'error'; message: string }
26
+ | { state: 'ready'; items: ISpecSummary[] };
27
+
28
+ function Section({
29
+ title,
30
+ items,
31
+ empty,
32
+ onOpen
33
+ }: {
34
+ title: string;
35
+ items: readonly ISpecSummary[];
36
+ empty: string;
37
+ onOpen?: (item: ISpecSummary) => void;
38
+ }): React.ReactElement {
39
+ // Each row carries its record id so the share plugin's context menu (Open, Rename,
40
+ // Share, Delete) can tell which one was clicked.
41
+ const rowAttributes = (item: ISpecSummary) => ({
42
+ [SPEC_ID_ATTRIBUTE]: item.id,
43
+ title: item.description ?? undefined
44
+ });
45
+ return (
46
+ <div className="oceanum-notebooks-section">
47
+ <div className="oceanum-notebooks-heading">
48
+ {title}
49
+ <span className="oceanum-notebooks-count">{items.length}</span>
50
+ </div>
51
+ {items.length === 0 ? (
52
+ <div className="oceanum-text-empty">{empty}</div>
53
+ ) : (
54
+ <ul className="oceanum-notebooks-list">
55
+ {items.map(item => (
56
+ <li key={item.id}>
57
+ {/* Names come from other users: React escapes these, never set as HTML. */}
58
+ {onOpen ? (
59
+ <button
60
+ type="button"
61
+ className="oceanum-notebooks-item"
62
+ onClick={() => onOpen(item)}
63
+ {...rowAttributes(item)}
64
+ >
65
+ <span className="oceanum-notebooks-name">
66
+ {item.name || 'Untitled'}
67
+ </span>
68
+ </button>
69
+ ) : (
70
+ <div
71
+ className="oceanum-notebooks-item oceanum-notebooks-item-static"
72
+ {...rowAttributes(item)}
73
+ >
74
+ <span className="oceanum-notebooks-name">
75
+ {item.name || 'Untitled'}
76
+ </span>
77
+ </div>
78
+ )}
79
+ </li>
80
+ ))}
81
+ </ul>
82
+ )}
83
+ </div>
84
+ );
85
+ }
86
+
87
+ /**
88
+ * The notebooks this user has stored on Oceanum.io, theirs and those shared with them.
89
+ */
90
+ export function StoredNotebooks({
91
+ auth,
92
+ onOpen,
93
+ onSignIn
94
+ }: IStoredNotebooksProps): React.ReactElement {
95
+ const [load, setLoad] = React.useState<Load>({ state: 'loading' });
96
+ const [user, setUser] = React.useState(auth.user);
97
+ // Bumped whenever the store changes, so the list is fetched again.
98
+ const [version, setVersion] = React.useState(0);
99
+
100
+ // Reload whenever the signed-in user changes, so signing in or out does not leave a
101
+ // stale list from the previous session on screen; and whenever this extension has
102
+ // created, renamed or deleted a record.
103
+ React.useEffect(() => {
104
+ const onUser = (): void => setUser(auth.user);
105
+ const onChanged = (): void => setVersion(v => v + 1);
106
+ auth.userChanged.connect(onUser);
107
+ notebooksChanged.connect(onChanged);
108
+ return () => {
109
+ auth.userChanged.disconnect(onUser);
110
+ notebooksChanged.disconnect(onChanged);
111
+ };
112
+ }, [auth]);
113
+
114
+ React.useEffect(() => {
115
+ let cancelled = false;
116
+ const specs = auth.urls?.specs ?? null;
117
+ if (!user || !specs) {
118
+ setLoad({ state: 'ready', items: [] });
119
+ return;
120
+ }
121
+ setLoad({ state: 'loading' });
122
+ void (async () => {
123
+ try {
124
+ const client = new SpecStoreClient({
125
+ specsUrl: specs,
126
+ getAccessToken: () => auth.getAccessToken()
127
+ });
128
+ const items = await client.list();
129
+ if (!cancelled) {
130
+ setLoad({ state: 'ready', items });
131
+ }
132
+ } catch (error) {
133
+ if (!cancelled) {
134
+ setLoad({
135
+ state: 'error',
136
+ message:
137
+ error instanceof Error
138
+ ? error.message
139
+ : 'Could not reach Oceanum.io.'
140
+ });
141
+ }
142
+ }
143
+ })();
144
+ return () => {
145
+ cancelled = true;
146
+ };
147
+ }, [auth, user, version]);
148
+
149
+ if (!user) {
150
+ const signIn = (): void => {
151
+ if (onSignIn) {
152
+ onSignIn();
153
+ return;
154
+ }
155
+ auth.signIn().catch(error => {
156
+ console.warn('Oceanum.io sign-in could not start.', error);
157
+ });
158
+ };
159
+ return (
160
+ <div className="oceanum-text-empty">
161
+ {/* A link rather than a button to match the panel's other actions, so it
162
+ carries the role and the keys a button would have. */}
163
+ <a
164
+ role="button"
165
+ tabIndex={0}
166
+ onClick={signIn}
167
+ onKeyDown={event => {
168
+ if (event.key === 'Enter' || event.key === ' ') {
169
+ event.preventDefault();
170
+ signIn();
171
+ }
172
+ }}
173
+ >
174
+ Sign in
175
+ </a>{' '}
176
+ to Oceanum.io to see your notebooks.
177
+ </div>
178
+ );
179
+ }
180
+ if (load.state === 'loading') {
181
+ return <div className="oceanum-text-empty">Loading…</div>;
182
+ }
183
+ if (load.state === 'error') {
184
+ // Never render the sections alongside an error: their empty messages would claim
185
+ // the account has no notebooks when the load simply failed.
186
+ return <div className="oceanum-text-error">{load.message}</div>;
187
+ }
188
+
189
+ const { mine, shared } = partitionSummaries(load.items, user.email);
190
+ return (
191
+ <div className="oceanum-notebooks">
192
+ <Section
193
+ title="My notebooks"
194
+ items={mine}
195
+ empty="You have not saved any notebooks yet."
196
+ onOpen={onOpen}
197
+ />
198
+ <Section
199
+ title="Shared with me"
200
+ items={shared}
201
+ empty="No notebooks have been shared with you."
202
+ onOpen={onOpen}
203
+ />
204
+ </div>
205
+ );
206
+ }
package/src/Tabs.tsx ADDED
@@ -0,0 +1,102 @@
1
+ import * as React from 'react';
2
+
3
+ export interface ITab {
4
+ /** Stable key, also used as the restored selection value. */
5
+ id: string;
6
+ label: string;
7
+ render: () => React.ReactElement;
8
+ }
9
+
10
+ export interface ITabsProps {
11
+ tabs: readonly ITab[];
12
+ /** Selected tab id. Falls back to the first tab when it names no tab. */
13
+ selected: string;
14
+ onSelect: (id: string) => void;
15
+ }
16
+
17
+ /**
18
+ * A tab strip over a single visible pane.
19
+ *
20
+ * JupyterLab users work from the keyboard, so the strip follows the WAI-ARIA tabs
21
+ * pattern: one tab stop for the whole strip, arrows to move between tabs, Home and End
22
+ * to reach the ends.
23
+ *
24
+ * Every pane stays mounted and the inactive ones are hidden. Unmounting would be
25
+ * cheaper, but the AI pane holds the conversation in component state and the Datamesh
26
+ * pane holds a workspace subscription, so switching tabs would silently discard a chat
27
+ * the user is in the middle of.
28
+ */
29
+ export function Tabs({
30
+ tabs,
31
+ selected,
32
+ onSelect
33
+ }: ITabsProps): React.ReactElement {
34
+ const index = Math.max(
35
+ 0,
36
+ tabs.findIndex(tab => tab.id === selected)
37
+ );
38
+ const active = tabs[index];
39
+
40
+ const onKeyDown = (event: React.KeyboardEvent): void => {
41
+ const last = tabs.length - 1;
42
+ let next: number | null = null;
43
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
44
+ next = index === last ? 0 : index + 1;
45
+ } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
46
+ next = index === 0 ? last : index - 1;
47
+ } else if (event.key === 'Home') {
48
+ next = 0;
49
+ } else if (event.key === 'End') {
50
+ next = last;
51
+ }
52
+ if (next === null) {
53
+ return;
54
+ }
55
+ // Otherwise the arrow keys also scroll the panel.
56
+ event.preventDefault();
57
+ onSelect(tabs[next].id);
58
+ };
59
+
60
+ return (
61
+ <div className="oceanum-tabs">
62
+ <div
63
+ className="oceanum-tabs-strip"
64
+ role="tablist"
65
+ aria-label="Oceanum.io"
66
+ >
67
+ {tabs.map((tab, i) => (
68
+ <button
69
+ key={tab.id}
70
+ id={`oceanum-tab-${tab.id}`}
71
+ className={
72
+ i === index ? 'oceanum-tab oceanum-tab-selected' : 'oceanum-tab'
73
+ }
74
+ role="tab"
75
+ type="button"
76
+ aria-selected={i === index}
77
+ aria-controls={`oceanum-tabpanel-${tab.id}`}
78
+ // Only the selected tab is in the tab order, so Tab moves past the strip
79
+ // rather than through every tab in it.
80
+ tabIndex={i === index ? 0 : -1}
81
+ onClick={() => onSelect(tab.id)}
82
+ onKeyDown={onKeyDown}
83
+ >
84
+ {tab.label}
85
+ </button>
86
+ ))}
87
+ </div>
88
+ {tabs.map(tab => (
89
+ <div
90
+ key={tab.id}
91
+ className="oceanum-tabpanel"
92
+ id={`oceanum-tabpanel-${tab.id}`}
93
+ role="tabpanel"
94
+ aria-labelledby={`oceanum-tab-${tab.id}`}
95
+ hidden={tab.id !== active.id}
96
+ >
97
+ {tab.render()}
98
+ </div>
99
+ ))}
100
+ </div>
101
+ );
102
+ }
@@ -199,12 +199,16 @@ describe('DatameshConnectWidget: Oceanum AI with the host sign-in', () => {
199
199
  oceanum.setSignedIn(true);
200
200
  await until(() => expect(chat(node)).not.toBeNull());
201
201
  expect(notice(node)).toBeNull();
202
- expect(requests).toEqual([
203
- {
204
- url: 'https://ai.example.test/api/capabilities',
205
- headers: { Authorization: 'Bearer a-jwt' }
206
- }
207
- ]);
202
+ // Also awaited: the chat renders before the capabilities call resolves, so asserting
203
+ // this straight after the chat appears is a race that a slow run loses.
204
+ await until(() =>
205
+ expect(requests).toEqual([
206
+ {
207
+ url: 'https://ai.example.test/api/capabilities',
208
+ headers: { Authorization: 'Bearer a-jwt' }
209
+ }
210
+ ])
211
+ );
208
212
  // One token request for the capabilities, however many components watch.
209
213
  expect(oceanum.accessToken).toHaveBeenCalledTimes(1);
210
214
 
@@ -237,7 +241,9 @@ describe('DatameshConnectWidget: Oceanum AI with the host sign-in', () => {
237
241
  // Signing in is a change, not a poll: it may run the token command once.
238
242
  oceanum.setSignedIn(true);
239
243
  await until(() => expect(chat(node)).not.toBeNull());
240
- expect(oceanum.executed).toEqual([ACCESS_TOKEN_COMMAND]);
244
+ // Awaited: the chat renders before the token command resolves, so asserting straight
245
+ // after it appears is a race that a slow run loses.
246
+ await until(() => expect(oceanum.executed).toEqual([ACCESS_TOKEN_COMMAND]));
241
247
 
242
248
  await settle(2000);
243
249
  expect(oceanum.executed).toEqual([ACCESS_TOKEN_COMMAND]);
@@ -248,7 +254,8 @@ describe('DatameshConnectWidget: Oceanum AI with the host sign-in', () => {
248
254
  const node = mount(oceanum.commands);
249
255
 
250
256
  await until(() => expect(chat(node)).not.toBeNull());
251
- expect(requests).toHaveLength(1);
257
+ // Awaited: the capabilities request resolves separately from the chat rendering.
258
+ await until(() => expect(requests).toHaveLength(1));
252
259
 
253
260
  // The host refreshed the token and told the registry, as it does.
254
261
  oceanum.setToken('refreshed-jwt');
@@ -264,12 +271,15 @@ describe('DatameshConnectWidget: Oceanum AI with the host sign-in', () => {
264
271
  const node = mount(oceanum.commands, { pasted: 'pasted-token' });
265
272
 
266
273
  await until(() => expect(chat(node)).not.toBeNull());
267
- expect(requests).toEqual([
268
- {
269
- url: 'https://ai.oceanum.io/api/capabilities',
270
- headers: { 'X-Datamesh-Token': 'pasted-token' }
271
- }
272
- ]);
274
+ // Awaited: the capabilities request resolves separately from the chat rendering.
275
+ await until(() =>
276
+ expect(requests).toEqual([
277
+ {
278
+ url: 'https://ai.oceanum.io/api/capabilities',
279
+ headers: { 'X-Datamesh-Token': 'pasted-token' }
280
+ }
281
+ ])
282
+ );
273
283
  expect(oceanum.executed).toEqual([]);
274
284
  });
275
285
 
@@ -283,12 +293,15 @@ describe('DatameshConnectWidget: Oceanum AI with the host sign-in', () => {
283
293
  const node = mount(oceanum.commands, { pasted: '' });
284
294
 
285
295
  await until(() => expect(chat(node)).not.toBeNull());
286
- expect(requests).toEqual([
287
- {
288
- url: 'https://ai.oceanum.io/api/capabilities',
289
- headers: { Authorization: 'Bearer a-jwt' }
290
- }
291
- ]);
296
+ // Awaited: the capabilities request resolves separately from the chat rendering.
297
+ await until(() =>
298
+ expect(requests).toEqual([
299
+ {
300
+ url: 'https://ai.oceanum.io/api/capabilities',
301
+ headers: { Authorization: 'Bearer a-jwt' }
302
+ }
303
+ ])
304
+ );
292
305
  });
293
306
 
294
307
  it('keeps the chat hidden when the capabilities say there is no code', async () => {
@@ -0,0 +1,157 @@
1
+ import { JupyterFrontEnd } from '@jupyterlab/application';
2
+ import { CommandRegistry } from '@lumino/commands';
3
+ import { Signal } from '@lumino/signaling';
4
+ import { Widget } from '@lumino/widgets';
5
+ import { LabIcon } from '@jupyterlab/ui-components';
6
+
7
+ import { DatameshConnectWidget } from '../DatameshWidget';
8
+
9
+ const icon = new LabIcon({
10
+ name: 'oceanum:test-icon',
11
+ svgstr: '<svg xmlns="http://www.w3.org/2000/svg"></svg>'
12
+ });
13
+
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
+ }
20
+
21
+ function panel(options: IPanelOptions = {}): DatameshConnectWidget {
22
+ const commands = new CommandRegistry();
23
+ if (options.signedIn !== undefined) {
24
+ // What the host provides: sign-out is visible only while signed in, which is how
25
+ // the sidebar reads the state without running anything.
26
+ commands.addCommand('oceanum-auth:sign-out', {
27
+ execute: () => undefined,
28
+ isVisible: () => options.signedIn === true
29
+ });
30
+ commands.addCommand('oceanum-auth:access-token', {
31
+ execute: () => null,
32
+ isVisible: () => false
33
+ });
34
+ }
35
+ const app = {
36
+ shell: { currentWidget: null },
37
+ commands
38
+ } as unknown as JupyterFrontEnd;
39
+ return new DatameshConnectWidget({
40
+ app,
41
+ name: 'Datamesh Connect',
42
+ icon,
43
+ openDatameshUI: (): void => undefined,
44
+ datameshUiUrl: () => 'https://ui.datamesh.example.com',
45
+ datameshUiFrame: (): Window | null => null,
46
+ datameshToken: () => options.token ?? '',
47
+ aiBackendUrl: () => 'https://ai.example.com',
48
+ settingsChanged: new Signal<unknown, void>({}),
49
+ commands,
50
+ getCurrentWidget: () => new Widget(),
51
+ auth: null
52
+ } as unknown as ConstructorParameters<typeof DatameshConnectWidget>[0]);
53
+ }
54
+
55
+ async function mount(
56
+ options: IPanelOptions = {}
57
+ ): Promise<DatameshConnectWidget> {
58
+ const widget = panel(options);
59
+ Widget.attach(widget, document.body);
60
+ await new Promise(resolve => setTimeout(resolve, 100));
61
+ return widget;
62
+ }
63
+
64
+ /**
65
+ * The panel used to render the Datamesh workspace and the AI chat one above the other.
66
+ * They are now tabs. `DatameshWidget.spec.tsx` covers the AI chat's behaviour but never
67
+ * touches the Datamesh surface, so nothing else here would notice if rearranging the
68
+ * two lost one of them.
69
+ */
70
+ beforeAll(() => {
71
+ // The AI chat scrolls to its last message, and every pane renders whether or not it
72
+ // is the selected one. jsdom has no scrollIntoView, and the error would come back as
73
+ // React unmounting the whole sidebar.
74
+ (Element.prototype as unknown as Record<string, unknown>).scrollIntoView =
75
+ (): void => undefined;
76
+ });
77
+
78
+ const tabLabels = (widget: DatameshConnectWidget): (string | null)[] =>
79
+ Array.from(widget.node.querySelectorAll('[role="tab"]')).map(
80
+ node => node.textContent
81
+ );
82
+
83
+ describe('the Oceanum panel', () => {
84
+ it('offers no Oceanum AI tab when the chat has no credential', async () => {
85
+ // AIChatPanel renders nothing without one, so a labelled tab would open on the
86
+ // same emptiness the panel showed before there were tabs, but now advertised.
87
+ const widget = await mount();
88
+ const labels = Array.from(widget.node.querySelectorAll('[role="tab"]')).map(
89
+ node => node.textContent
90
+ );
91
+
92
+ expect(labels).toEqual(['Notebooks', 'Datamesh']);
93
+ expect(widget.node.querySelector('#oceanum-tabpanel-ai')).toBeNull();
94
+
95
+ widget.dispose();
96
+ });
97
+
98
+ it('offers no Oceanum AI tab for a pasted token alone, until someone signs in', async () => {
99
+ // The token would reach the backend, but the tab waits for a sign-in.
100
+ const withToken = await mount({ token: 'a-datamesh-token' });
101
+ expect(tabLabels(withToken)).toEqual(['Notebooks', 'Datamesh']);
102
+ withToken.dispose();
103
+
104
+ const signedOut = await mount({
105
+ token: 'a-datamesh-token',
106
+ signedIn: false
107
+ });
108
+ expect(tabLabels(signedOut)).toEqual(['Notebooks', 'Datamesh']);
109
+ signedOut.dispose();
110
+ });
111
+
112
+ it('offers the Oceanum AI tab once the host says the user is signed in', async () => {
113
+ const widget = await mount({ signedIn: true });
114
+
115
+ expect(tabLabels(widget)).toEqual(['Notebooks', 'Datamesh', 'Oceanum AI']);
116
+
117
+ widget.dispose();
118
+ });
119
+
120
+ it('still renders the Datamesh workspace section', async () => {
121
+ const widget = await mount();
122
+
123
+ expect(
124
+ widget.node.querySelector('.datamesh-workspace-header')
125
+ ).not.toBeNull();
126
+ expect(
127
+ widget.node.querySelector('.datamesh-connect-workspace')
128
+ ).not.toBeNull();
129
+ expect(widget.node.textContent).toContain('Datamesh Workspace');
130
+
131
+ widget.dispose();
132
+ });
133
+
134
+ it('opens on Notebooks, and announces a change so it can be persisted', async () => {
135
+ const widget = await mount();
136
+ const seen: string[] = [];
137
+ widget.tabChanged.connect((_, id) => seen.push(id));
138
+
139
+ expect(widget.selectedTab).toBe('notebooks');
140
+ widget.selectTab('datamesh');
141
+ expect(widget.selectedTab).toBe('datamesh');
142
+ expect(seen).toEqual(['datamesh']);
143
+
144
+ // Selecting the same tab again is not a change.
145
+ widget.selectTab('datamesh');
146
+ expect(seen).toEqual(['datamesh']);
147
+
148
+ widget.dispose();
149
+ });
150
+
151
+ it('explains the Notebooks tab rather than failing when sign-in is unavailable', async () => {
152
+ // auth is null on a host with no Oceanum environment.
153
+ const widget = await mount();
154
+ expect(widget.node.textContent).toContain('sign-in is not configured');
155
+ widget.dispose();
156
+ });
157
+ });