@oceanum/oceanumlab 4.6.2 → 4.7.1
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/README.md +102 -11
- package/lib/DatameshUI.d.ts +1 -6
- package/lib/DatameshUI.js +1 -9
- package/lib/DatameshWidget.d.ts +24 -2
- package/lib/DatameshWidget.js +171 -45
- package/lib/StoredNotebooks.d.ts +16 -0
- package/lib/StoredNotebooks.js +84 -0
- package/lib/Tabs.d.ts +26 -0
- package/lib/Tabs.js +45 -0
- package/lib/aiBackend.d.ts +66 -0
- package/lib/aiBackend.js +101 -0
- package/lib/auth/accountWidget.d.ts +35 -0
- package/lib/auth/accountWidget.js +95 -0
- package/lib/auth/claims.d.ts +3 -0
- package/lib/auth/claims.js +25 -0
- package/lib/auth/plugin.d.ts +9 -0
- package/lib/auth/plugin.js +126 -0
- package/lib/auth/serverAuth.d.ts +79 -0
- package/lib/auth/serverAuth.js +253 -0
- package/lib/auth/tokens.d.ts +95 -0
- package/lib/auth/tokens.js +9 -0
- package/lib/chatRouter.d.ts +17 -1
- package/lib/chatRouter.js +34 -9
- package/lib/constants.d.ts +1 -1
- package/lib/constants.js +1 -1
- package/lib/datameshUiUrl.d.ts +2 -2
- package/lib/datameshUiUrl.js +8 -16
- package/lib/httpUrl.d.ts +9 -0
- package/lib/httpUrl.js +20 -0
- package/lib/index.d.ts +8 -1
- package/lib/index.js +67 -15
- package/lib/specStore.d.ts +37 -0
- package/lib/specStore.js +57 -0
- package/package.json +9 -1
- package/schema/datamesh-connect.json +3 -6
- package/src/DatameshUI.tsx +1 -10
- package/src/DatameshWidget.tsx +294 -59
- package/src/StoredNotebooks.tsx +166 -0
- package/src/Tabs.tsx +102 -0
- package/src/__tests__/DatameshWidget.spec.tsx +393 -0
- package/src/__tests__/OceanumPanel.spec.tsx +101 -0
- package/src/__tests__/Tabs.spec.tsx +168 -0
- package/src/__tests__/aiBackend.spec.ts +271 -0
- package/src/__tests__/chatRouter.spec.ts +253 -17
- package/src/__tests__/datameshUiUrl.spec.ts +4 -3
- package/src/__tests__/pluginArity.spec.ts +24 -0
- package/src/__tests__/serverAuth.spec.ts +277 -0
- package/src/__tests__/settingsSchema.spec.ts +73 -0
- package/src/__tests__/specStore.spec.ts +126 -0
- package/src/aiBackend.ts +150 -0
- package/src/auth/accountWidget.ts +113 -0
- package/src/auth/claims.ts +30 -0
- package/src/auth/plugin.ts +155 -0
- package/src/auth/serverAuth.ts +341 -0
- package/src/auth/tokens.ts +104 -0
- package/src/chatRouter.ts +41 -9
- package/src/constants.ts +1 -1
- package/src/datameshUiUrl.ts +8 -15
- package/src/httpUrl.ts +23 -0
- package/src/index.ts +99 -33
- package/src/specStore.ts +82 -0
- package/style/index.css +158 -0
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
|
+
}
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sidebar against a fake host and a fake backend: whether the Oceanum AI
|
|
3
|
+
* chat appears, what the capabilities request is signed with, and -- above all
|
|
4
|
+
* -- that watching for a sign-in runs no commands.
|
|
5
|
+
*
|
|
6
|
+
* Every `CommandRegistry.execute` emits `commandExecuted`, and JupyterLab's
|
|
7
|
+
* modal command palette closes itself on any of those. So the sidebar reads
|
|
8
|
+
* `isVisible`/`isEnabled` and listens for `commandChanged`, and only asks for
|
|
9
|
+
* the token when a request needs one.
|
|
10
|
+
*/
|
|
11
|
+
import { ModalCommandPalette } from '@jupyterlab/apputils';
|
|
12
|
+
import { addIcon } from '@jupyterlab/ui-components';
|
|
13
|
+
import { CommandRegistry } from '@lumino/commands';
|
|
14
|
+
import { CommandPalette, Widget } from '@lumino/widgets';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
ACCESS_TOKEN_COMMAND,
|
|
18
|
+
SIGN_IN_COMMAND,
|
|
19
|
+
SIGN_OUT_COMMAND
|
|
20
|
+
} from '../aiBackend';
|
|
21
|
+
import { DatameshConnectWidget } from '../DatameshWidget';
|
|
22
|
+
import { Signal } from '@lumino/signaling';
|
|
23
|
+
|
|
24
|
+
interface IRequest {
|
|
25
|
+
url: string;
|
|
26
|
+
headers: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let requests: IRequest[];
|
|
30
|
+
let widget: DatameshConnectWidget | null;
|
|
31
|
+
let palette: ModalCommandPalette | null;
|
|
32
|
+
const settingsChanged = new Signal<unknown, void>({});
|
|
33
|
+
|
|
34
|
+
beforeAll(() => {
|
|
35
|
+
// The chat scrolls to its last message; jsdom has no scrollIntoView, and
|
|
36
|
+
// the error would come back as React unmounting the sidebar.
|
|
37
|
+
(Element.prototype as any).scrollIntoView = (): void => undefined;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
requests = [];
|
|
42
|
+
window.datameshToken = undefined as unknown as string;
|
|
43
|
+
(globalThis as any).fetch = jest.fn(
|
|
44
|
+
async (url: string, init: { headers: Record<string, string> }) => {
|
|
45
|
+
requests.push({ url, headers: init.headers });
|
|
46
|
+
return { ok: true, json: async () => ({ code: true }) };
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
if (widget) {
|
|
53
|
+
Widget.detach(widget);
|
|
54
|
+
widget.dispose();
|
|
55
|
+
widget = null;
|
|
56
|
+
}
|
|
57
|
+
if (palette) {
|
|
58
|
+
palette.dispose();
|
|
59
|
+
palette = null;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
interface IHost {
|
|
64
|
+
commands: CommandRegistry;
|
|
65
|
+
signIn: jest.Mock;
|
|
66
|
+
accessToken: jest.Mock;
|
|
67
|
+
/** Every command the registry reported as executed, in order. */
|
|
68
|
+
executed: string[];
|
|
69
|
+
setSignedIn: (signedIn: boolean) => void;
|
|
70
|
+
setToken: (token: string | null) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A host like Oceanum Notebook: an access-token command, a sign-in command
|
|
75
|
+
* enabled where the site has sign-in, and a sign-out command visible only
|
|
76
|
+
* while signed in. `auth: false` is plain JupyterLab, which has none of them.
|
|
77
|
+
*/
|
|
78
|
+
function host({
|
|
79
|
+
auth = true,
|
|
80
|
+
signInEnabled = true,
|
|
81
|
+
signedIn = false,
|
|
82
|
+
signInRejects = false
|
|
83
|
+
} = {}): IHost {
|
|
84
|
+
const state = { signedIn, token: 'a-jwt' as string | null };
|
|
85
|
+
const commands = new CommandRegistry();
|
|
86
|
+
const executed: string[] = [];
|
|
87
|
+
commands.commandExecuted.connect((_, args) => executed.push(args.id));
|
|
88
|
+
const signIn = jest.fn(() =>
|
|
89
|
+
signInRejects
|
|
90
|
+
? Promise.reject(new Error('the sign-in window was blocked'))
|
|
91
|
+
: undefined
|
|
92
|
+
);
|
|
93
|
+
const accessToken = jest.fn(() => (state.signedIn ? state.token : null));
|
|
94
|
+
if (auth) {
|
|
95
|
+
commands.addCommand(ACCESS_TOKEN_COMMAND, {
|
|
96
|
+
execute: () => accessToken() as any
|
|
97
|
+
});
|
|
98
|
+
commands.addCommand(SIGN_IN_COMMAND, {
|
|
99
|
+
execute: signIn,
|
|
100
|
+
isEnabled: () => signInEnabled
|
|
101
|
+
});
|
|
102
|
+
commands.addCommand(SIGN_OUT_COMMAND, {
|
|
103
|
+
execute: () => undefined,
|
|
104
|
+
isVisible: () => state.signedIn
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
commands,
|
|
109
|
+
signIn,
|
|
110
|
+
accessToken,
|
|
111
|
+
executed,
|
|
112
|
+
setSignedIn: (value: boolean) => {
|
|
113
|
+
state.signedIn = value;
|
|
114
|
+
// What the host does on every sign-in and sign-out.
|
|
115
|
+
commands.notifyCommandChanged();
|
|
116
|
+
},
|
|
117
|
+
setToken: (token: string | null) => {
|
|
118
|
+
state.token = token;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function mount(
|
|
124
|
+
commands: CommandRegistry,
|
|
125
|
+
{ backend = '', pasted = '' } = {}
|
|
126
|
+
): HTMLElement {
|
|
127
|
+
widget = new DatameshConnectWidget({
|
|
128
|
+
app: {} as any,
|
|
129
|
+
name: 'Datamesh Connect',
|
|
130
|
+
icon: addIcon,
|
|
131
|
+
openDatameshUI: (): void => undefined,
|
|
132
|
+
datameshUiUrl: () => '',
|
|
133
|
+
datameshUiFrame: () => null,
|
|
134
|
+
datameshToken: () => pasted,
|
|
135
|
+
aiBackendUrl: () => backend,
|
|
136
|
+
settingsChanged,
|
|
137
|
+
commands,
|
|
138
|
+
getCurrentWidget: () => null as unknown as Widget
|
|
139
|
+
});
|
|
140
|
+
Widget.attach(widget, document.body);
|
|
141
|
+
widget.update();
|
|
142
|
+
return widget.node;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** JupyterLab's modal command palette, open with something typed in it. */
|
|
146
|
+
function openPalette(commands: CommandRegistry): ModalCommandPalette {
|
|
147
|
+
palette = new ModalCommandPalette({
|
|
148
|
+
commandPalette: new CommandPalette({ commands })
|
|
149
|
+
});
|
|
150
|
+
palette.attach();
|
|
151
|
+
palette.palette.inputNode.value = 'notebook';
|
|
152
|
+
palette.show();
|
|
153
|
+
return palette;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Waits for `check` to pass: the sidebar renders asynchronously. */
|
|
157
|
+
async function until(check: () => void, timeout = 4000): Promise<void> {
|
|
158
|
+
const end = Date.now() + timeout;
|
|
159
|
+
for (;;) {
|
|
160
|
+
try {
|
|
161
|
+
check();
|
|
162
|
+
return;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (Date.now() > end) {
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const settle = (ms = 150): Promise<void> =>
|
|
173
|
+
new Promise(resolve => setTimeout(resolve, ms));
|
|
174
|
+
|
|
175
|
+
const chat = (node: HTMLElement) => node.querySelector('.oceanum-ai-chat');
|
|
176
|
+
const notice = (node: HTMLElement) =>
|
|
177
|
+
node.querySelector('.oceanum-token-config')?.textContent ?? null;
|
|
178
|
+
|
|
179
|
+
describe('DatameshConnectWidget: Oceanum AI with the host sign-in', () => {
|
|
180
|
+
it('asks to sign in, then shows the chat once signed in, and hides it on sign-out', async () => {
|
|
181
|
+
const oceanum = host();
|
|
182
|
+
const node = mount(oceanum.commands, {
|
|
183
|
+
backend: 'https://ai.example.test/'
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await until(() =>
|
|
187
|
+
expect(notice(node)).toBe(
|
|
188
|
+
'Sign in to Oceanum.io to use Oceanum AI, or set a Datamesh token.'
|
|
189
|
+
)
|
|
190
|
+
);
|
|
191
|
+
expect(chat(node)).toBeNull();
|
|
192
|
+
expect(requests).toEqual([]);
|
|
193
|
+
// Signed out, nothing was run: not even to find out there is no token.
|
|
194
|
+
expect(oceanum.executed).toEqual([]);
|
|
195
|
+
|
|
196
|
+
(node.querySelector('.oceanum-token-config a') as HTMLElement).click();
|
|
197
|
+
expect(oceanum.signIn).toHaveBeenCalled();
|
|
198
|
+
|
|
199
|
+
oceanum.setSignedIn(true);
|
|
200
|
+
await until(() => expect(chat(node)).not.toBeNull());
|
|
201
|
+
expect(notice(node)).toBeNull();
|
|
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
|
+
);
|
|
212
|
+
// One token request for the capabilities, however many components watch.
|
|
213
|
+
expect(oceanum.accessToken).toHaveBeenCalledTimes(1);
|
|
214
|
+
|
|
215
|
+
oceanum.setSignedIn(false);
|
|
216
|
+
await until(() => expect(chat(node)).toBeNull());
|
|
217
|
+
expect(notice(node)).toContain('Sign in to Oceanum.io');
|
|
218
|
+
expect(requests).toHaveLength(1);
|
|
219
|
+
// The sign-in token never becomes the pasted one.
|
|
220
|
+
expect(window.datameshToken).toBeUndefined();
|
|
221
|
+
}, 15000);
|
|
222
|
+
|
|
223
|
+
it('runs no command while it watches, so the command palette stays open', async () => {
|
|
224
|
+
// The regression this test exists for: a one-second poll that executed the
|
|
225
|
+
// access-token command closed the palette within half a second, because
|
|
226
|
+
// ModalCommandPalette hides itself on any commandExecuted.
|
|
227
|
+
const oceanum = host();
|
|
228
|
+
const node = mount(oceanum.commands);
|
|
229
|
+
await until(() => expect(notice(node)).toContain('Sign in to Oceanum.io'));
|
|
230
|
+
|
|
231
|
+
const modal = openPalette(oceanum.commands);
|
|
232
|
+
expect(modal.isHidden).toBe(false);
|
|
233
|
+
|
|
234
|
+
await settle(3000);
|
|
235
|
+
|
|
236
|
+
expect(oceanum.executed).toEqual([]);
|
|
237
|
+
expect(oceanum.accessToken).not.toHaveBeenCalled();
|
|
238
|
+
expect(modal.isHidden).toBe(false);
|
|
239
|
+
expect(modal.palette.inputNode.value).toBe('notebook');
|
|
240
|
+
|
|
241
|
+
// Signing in is a change, not a poll: it may run the token command once.
|
|
242
|
+
oceanum.setSignedIn(true);
|
|
243
|
+
await until(() => expect(chat(node)).not.toBeNull());
|
|
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]));
|
|
247
|
+
|
|
248
|
+
await settle(2000);
|
|
249
|
+
expect(oceanum.executed).toEqual([ACCESS_TOKEN_COMMAND]);
|
|
250
|
+
}, 20000);
|
|
251
|
+
|
|
252
|
+
it('does not ask again when only the sign-in token has refreshed', async () => {
|
|
253
|
+
const oceanum = host({ signedIn: true });
|
|
254
|
+
const node = mount(oceanum.commands);
|
|
255
|
+
|
|
256
|
+
await until(() => expect(chat(node)).not.toBeNull());
|
|
257
|
+
// Awaited: the capabilities request resolves separately from the chat rendering.
|
|
258
|
+
await until(() => expect(requests).toHaveLength(1));
|
|
259
|
+
|
|
260
|
+
// The host refreshed the token and told the registry, as it does.
|
|
261
|
+
oceanum.setToken('refreshed-jwt');
|
|
262
|
+
oceanum.commands.notifyCommandChanged();
|
|
263
|
+
await settle(500);
|
|
264
|
+
|
|
265
|
+
expect(requests).toHaveLength(1);
|
|
266
|
+
expect(oceanum.accessToken).toHaveBeenCalledTimes(1);
|
|
267
|
+
}, 15000);
|
|
268
|
+
|
|
269
|
+
it('signs with a pasted token rather than the sign-in, running nothing', async () => {
|
|
270
|
+
const oceanum = host({ signedIn: true });
|
|
271
|
+
const node = mount(oceanum.commands, { pasted: 'pasted-token' });
|
|
272
|
+
|
|
273
|
+
await until(() => expect(chat(node)).not.toBeNull());
|
|
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
|
+
);
|
|
283
|
+
expect(oceanum.executed).toEqual([]);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('uses the sign-in once the pasted token is cleared, as a chat request does', async () => {
|
|
287
|
+
// window.datameshToken keeps a token after the setting is cleared, until
|
|
288
|
+
// reload. Signing the capabilities with it would disagree with the chat
|
|
289
|
+
// requests, which read the setting -- and an expired one would hide the
|
|
290
|
+
// chat from a user who is signed in.
|
|
291
|
+
window.datameshToken = 'cleared-token';
|
|
292
|
+
const oceanum = host({ signedIn: true });
|
|
293
|
+
const node = mount(oceanum.commands, { pasted: '' });
|
|
294
|
+
|
|
295
|
+
await until(() => expect(chat(node)).not.toBeNull());
|
|
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
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('keeps the chat hidden when the capabilities say there is no code', async () => {
|
|
308
|
+
(globalThis as any).fetch = jest.fn(async () => ({
|
|
309
|
+
ok: true,
|
|
310
|
+
json: async () => ({ code: false })
|
|
311
|
+
}));
|
|
312
|
+
const oceanum = host({ signedIn: true });
|
|
313
|
+
const node = mount(oceanum.commands);
|
|
314
|
+
|
|
315
|
+
await until(() => expect((globalThis as any).fetch).toHaveBeenCalled());
|
|
316
|
+
await settle();
|
|
317
|
+
expect(chat(node)).toBeNull();
|
|
318
|
+
expect(notice(node)).toBeNull();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('asks for a Datamesh token where the host has no sign-in', async () => {
|
|
322
|
+
const oceanum = host({ auth: false });
|
|
323
|
+
const node = mount(oceanum.commands);
|
|
324
|
+
|
|
325
|
+
await until(() =>
|
|
326
|
+
expect(notice(node)).toBe(
|
|
327
|
+
'Set your Datamesh token to enable Oceanum.io services Get token here'
|
|
328
|
+
)
|
|
329
|
+
);
|
|
330
|
+
expect(chat(node)).toBeNull();
|
|
331
|
+
expect(requests).toEqual([]);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('asks for a Datamesh token where sign-in is not enabled for the site', async () => {
|
|
335
|
+
const oceanum = host({ signInEnabled: false });
|
|
336
|
+
const node = mount(oceanum.commands);
|
|
337
|
+
|
|
338
|
+
await until(() =>
|
|
339
|
+
expect(notice(node)).toBe(
|
|
340
|
+
'Set your Datamesh token to enable Oceanum.io services Get token here'
|
|
341
|
+
)
|
|
342
|
+
);
|
|
343
|
+
expect(chat(node)).toBeNull();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('says so, rather than failing, when signing in cannot start', async () => {
|
|
347
|
+
// Node's process, without @types/node in the test tsconfig.
|
|
348
|
+
const node_ = (globalThis as any).process as {
|
|
349
|
+
on: (event: string, handler: (reason: unknown) => void) => void;
|
|
350
|
+
off: (event: string, handler: (reason: unknown) => void) => void;
|
|
351
|
+
};
|
|
352
|
+
const rejections: unknown[] = [];
|
|
353
|
+
const onRejection = (reason: unknown) => rejections.push(reason);
|
|
354
|
+
node_.on('unhandledRejection', onRejection);
|
|
355
|
+
const warn = jest
|
|
356
|
+
.spyOn(console, 'warn')
|
|
357
|
+
.mockImplementation(() => undefined);
|
|
358
|
+
const oceanum = host({ signInRejects: true });
|
|
359
|
+
const node = mount(oceanum.commands);
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
await until(() =>
|
|
363
|
+
expect(notice(node)).toContain('Sign in to Oceanum.io')
|
|
364
|
+
);
|
|
365
|
+
(node.querySelector('.oceanum-token-config a') as HTMLElement).click();
|
|
366
|
+
await settle();
|
|
367
|
+
|
|
368
|
+
expect(warn).toHaveBeenCalledWith(
|
|
369
|
+
'Oceanum AI: could not start signing in to Oceanum.io.'
|
|
370
|
+
);
|
|
371
|
+
expect(rejections).toEqual([]);
|
|
372
|
+
} finally {
|
|
373
|
+
node_.off('unhandledRejection', onRejection);
|
|
374
|
+
warn.mockRestore();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('stops watching once it is disposed', async () => {
|
|
379
|
+
const oceanum = host();
|
|
380
|
+
const node = mount(oceanum.commands);
|
|
381
|
+
await until(() => expect(notice(node)).toContain('Sign in to Oceanum.io'));
|
|
382
|
+
|
|
383
|
+
Widget.detach(widget!);
|
|
384
|
+
widget!.dispose();
|
|
385
|
+
widget = null;
|
|
386
|
+
|
|
387
|
+
oceanum.setSignedIn(true);
|
|
388
|
+
await settle(500);
|
|
389
|
+
|
|
390
|
+
expect(oceanum.executed).toEqual([]);
|
|
391
|
+
expect(requests).toEqual([]);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
function panel(): DatameshConnectWidget {
|
|
15
|
+
const commands = new CommandRegistry();
|
|
16
|
+
const app = {
|
|
17
|
+
shell: { currentWidget: null },
|
|
18
|
+
commands
|
|
19
|
+
} as unknown as JupyterFrontEnd;
|
|
20
|
+
return new DatameshConnectWidget({
|
|
21
|
+
app,
|
|
22
|
+
name: 'Datamesh Connect',
|
|
23
|
+
icon,
|
|
24
|
+
openDatameshUI: (): void => undefined,
|
|
25
|
+
datameshUiUrl: () => 'https://ui.datamesh.example.com',
|
|
26
|
+
datameshUiFrame: (): Window | null => null,
|
|
27
|
+
datameshToken: () => '',
|
|
28
|
+
aiBackendUrl: () => 'https://ai.example.com',
|
|
29
|
+
settingsChanged: new Signal<unknown, void>({}),
|
|
30
|
+
commands,
|
|
31
|
+
getCurrentWidget: () => new Widget(),
|
|
32
|
+
auth: null
|
|
33
|
+
} as unknown as ConstructorParameters<typeof DatameshConnectWidget>[0]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function mount(): Promise<DatameshConnectWidget> {
|
|
37
|
+
const widget = panel();
|
|
38
|
+
Widget.attach(widget, document.body);
|
|
39
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
40
|
+
return widget;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The panel used to render the Datamesh workspace and the AI chat one above the other.
|
|
45
|
+
* They are now tabs. `DatameshWidget.spec.tsx` covers the AI chat's behaviour but never
|
|
46
|
+
* touches the Datamesh surface, so nothing else here would notice if rearranging the
|
|
47
|
+
* two lost one of them.
|
|
48
|
+
*/
|
|
49
|
+
describe('the Oceanum panel', () => {
|
|
50
|
+
it('offers no Oceanum AI tab when the chat has no credential', async () => {
|
|
51
|
+
// AIChatPanel renders nothing without one, so a labelled tab would open on the
|
|
52
|
+
// same emptiness the panel showed before there were tabs, but now advertised.
|
|
53
|
+
const widget = await mount();
|
|
54
|
+
const labels = Array.from(widget.node.querySelectorAll('[role="tab"]')).map(
|
|
55
|
+
node => node.textContent
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(labels).toEqual(['Notebooks', 'Datamesh']);
|
|
59
|
+
expect(widget.node.querySelector('#oceanum-tabpanel-ai')).toBeNull();
|
|
60
|
+
|
|
61
|
+
widget.dispose();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('still renders the Datamesh workspace section', async () => {
|
|
65
|
+
const widget = await mount();
|
|
66
|
+
|
|
67
|
+
expect(
|
|
68
|
+
widget.node.querySelector('.datamesh-workspace-header')
|
|
69
|
+
).not.toBeNull();
|
|
70
|
+
expect(
|
|
71
|
+
widget.node.querySelector('.datamesh-connect-workspace')
|
|
72
|
+
).not.toBeNull();
|
|
73
|
+
expect(widget.node.textContent).toContain('Datamesh Workspace');
|
|
74
|
+
|
|
75
|
+
widget.dispose();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('opens on Notebooks, and announces a change so it can be persisted', async () => {
|
|
79
|
+
const widget = await mount();
|
|
80
|
+
const seen: string[] = [];
|
|
81
|
+
widget.tabChanged.connect((_, id) => seen.push(id));
|
|
82
|
+
|
|
83
|
+
expect(widget.selectedTab).toBe('notebooks');
|
|
84
|
+
widget.selectTab('datamesh');
|
|
85
|
+
expect(widget.selectedTab).toBe('datamesh');
|
|
86
|
+
expect(seen).toEqual(['datamesh']);
|
|
87
|
+
|
|
88
|
+
// Selecting the same tab again is not a change.
|
|
89
|
+
widget.selectTab('datamesh');
|
|
90
|
+
expect(seen).toEqual(['datamesh']);
|
|
91
|
+
|
|
92
|
+
widget.dispose();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('explains the Notebooks tab rather than failing when sign-in is unavailable', async () => {
|
|
96
|
+
// auth is null on a host with no Oceanum environment.
|
|
97
|
+
const widget = await mount();
|
|
98
|
+
expect(widget.node.textContent).toContain('sign-in is not configured');
|
|
99
|
+
widget.dispose();
|
|
100
|
+
});
|
|
101
|
+
});
|