@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.
Files changed (62) 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 +24 -2
  5. package/lib/DatameshWidget.js +171 -45
  6. package/lib/StoredNotebooks.d.ts +16 -0
  7. package/lib/StoredNotebooks.js +84 -0
  8. package/lib/Tabs.d.ts +26 -0
  9. package/lib/Tabs.js +45 -0
  10. package/lib/aiBackend.d.ts +66 -0
  11. package/lib/aiBackend.js +101 -0
  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 +253 -0
  20. package/lib/auth/tokens.d.ts +95 -0
  21. package/lib/auth/tokens.js +9 -0
  22. package/lib/chatRouter.d.ts +17 -1
  23. package/lib/chatRouter.js +34 -9
  24. package/lib/constants.d.ts +1 -1
  25. package/lib/constants.js +1 -1
  26. package/lib/datameshUiUrl.d.ts +2 -2
  27. package/lib/datameshUiUrl.js +8 -16
  28. package/lib/httpUrl.d.ts +9 -0
  29. package/lib/httpUrl.js +20 -0
  30. package/lib/index.d.ts +8 -1
  31. package/lib/index.js +67 -15
  32. package/lib/specStore.d.ts +37 -0
  33. package/lib/specStore.js +57 -0
  34. package/package.json +9 -1
  35. package/schema/datamesh-connect.json +3 -6
  36. package/src/DatameshUI.tsx +1 -10
  37. package/src/DatameshWidget.tsx +294 -59
  38. package/src/StoredNotebooks.tsx +166 -0
  39. package/src/Tabs.tsx +102 -0
  40. package/src/__tests__/DatameshWidget.spec.tsx +393 -0
  41. package/src/__tests__/OceanumPanel.spec.tsx +101 -0
  42. package/src/__tests__/Tabs.spec.tsx +168 -0
  43. package/src/__tests__/aiBackend.spec.ts +271 -0
  44. package/src/__tests__/chatRouter.spec.ts +253 -17
  45. package/src/__tests__/datameshUiUrl.spec.ts +4 -3
  46. package/src/__tests__/pluginArity.spec.ts +24 -0
  47. package/src/__tests__/serverAuth.spec.ts +277 -0
  48. package/src/__tests__/settingsSchema.spec.ts +73 -0
  49. package/src/__tests__/specStore.spec.ts +126 -0
  50. package/src/aiBackend.ts +150 -0
  51. package/src/auth/accountWidget.ts +113 -0
  52. package/src/auth/claims.ts +30 -0
  53. package/src/auth/plugin.ts +155 -0
  54. package/src/auth/serverAuth.ts +341 -0
  55. package/src/auth/tokens.ts +104 -0
  56. package/src/chatRouter.ts +41 -9
  57. package/src/constants.ts +1 -1
  58. package/src/datameshUiUrl.ts +8 -15
  59. package/src/httpUrl.ts +23 -0
  60. package/src/index.ts +99 -33
  61. package/src/specStore.ts +82 -0
  62. package/style/index.css +158 -0
@@ -0,0 +1,168 @@
1
+ import { ReactWidget } from '@jupyterlab/ui-components';
2
+ import { Widget } from '@lumino/widgets';
3
+ import { readFileSync } from 'fs';
4
+ import { join } from 'path';
5
+ import React from 'react';
6
+
7
+ import { ITab, Tabs } from '../Tabs';
8
+
9
+ /** A widget whose sole job is to render the tab strip so the DOM can be inspected. */
10
+ class Harness extends ReactWidget {
11
+ selected = 'one';
12
+ readonly selections: string[] = [];
13
+
14
+ constructor(private readonly _tabs: ITab[]) {
15
+ super();
16
+ }
17
+
18
+ render(): React.ReactElement {
19
+ return (
20
+ <Tabs
21
+ tabs={this._tabs}
22
+ selected={this.selected}
23
+ onSelect={id => {
24
+ this.selections.push(id);
25
+ this.selected = id;
26
+ this.update();
27
+ }}
28
+ />
29
+ );
30
+ }
31
+ }
32
+
33
+ const TABS: ITab[] = [
34
+ { id: 'one', label: 'One', render: () => <p>first pane</p> },
35
+ { id: 'two', label: 'Two', render: () => <p>second pane</p> },
36
+ { id: 'three', label: 'Three', render: () => <p>third pane</p> }
37
+ ];
38
+
39
+ async function mount(): Promise<Harness> {
40
+ const widget = new Harness(TABS);
41
+ Widget.attach(widget, document.body);
42
+ // ReactWidget renders on an animation frame, so wait for the first pane rather than
43
+ // sleeping a fixed 50ms and hoping. That wait was enough on a warm run and not on a cold
44
+ // one: the first run after a fresh install, with an empty ts-jest cache, rendered nothing
45
+ // in time and every test here failed at once with zero panes -- which reads like a broken
46
+ // component rather than a slow one.
47
+ const deadline = Date.now() + 4000;
48
+ for (;;) {
49
+ if (widget.node.querySelector('[role="tabpanel"]')) {
50
+ return widget;
51
+ }
52
+ if (Date.now() > deadline) {
53
+ throw new Error('Tabs rendered no panes within 4s');
54
+ }
55
+ await new Promise(resolve => setTimeout(resolve, 25));
56
+ }
57
+ }
58
+
59
+ function strip(widget: Harness): HTMLElement[] {
60
+ return Array.from(widget.node.querySelectorAll<HTMLElement>('[role="tab"]'));
61
+ }
62
+
63
+ function panels(widget: Harness): HTMLElement[] {
64
+ return Array.from(
65
+ widget.node.querySelectorAll<HTMLElement>('[role="tabpanel"]')
66
+ );
67
+ }
68
+
69
+ describe('Tabs', () => {
70
+ it('marks only the selected tab, and puts only it in the tab order', async () => {
71
+ const widget = await mount();
72
+ const tabs = strip(widget);
73
+
74
+ expect(tabs.map(t => t.textContent)).toEqual(['One', 'Two', 'Three']);
75
+ expect(tabs.map(t => t.getAttribute('aria-selected'))).toEqual([
76
+ 'true',
77
+ 'false',
78
+ 'false'
79
+ ]);
80
+ // A single tab stop for the strip: Tab moves past it, arrows move within it.
81
+ expect(tabs.map(t => t.tabIndex)).toEqual([0, -1, -1]);
82
+
83
+ widget.dispose();
84
+ });
85
+
86
+ it('keeps every pane mounted and hides the inactive ones', async () => {
87
+ // Unmounting would discard the AI conversation whenever the user switches away.
88
+ const widget = await mount();
89
+ const rendered = panels(widget);
90
+
91
+ expect(rendered).toHaveLength(3);
92
+ expect(rendered.map(p => p.hidden)).toEqual([false, true, true]);
93
+ expect(widget.node.textContent).toContain('second pane');
94
+
95
+ widget.dispose();
96
+ });
97
+
98
+ it('the stylesheet actually hides them', async () => {
99
+ // `hidden` is only a hint: an author `display` on .oceanum-tabpanel overrides the
100
+ // user agent's `[hidden] { display: none }`, and asserting the IDL property above
101
+ // cannot see that. This renders the real stylesheet and asks for the computed value.
102
+ const css = readFileSync(
103
+ join(__dirname, '..', '..', 'style', 'index.css'),
104
+ 'utf8'
105
+ );
106
+ const style = document.createElement('style');
107
+ style.textContent = css;
108
+ document.head.appendChild(style);
109
+
110
+ const widget = await mount();
111
+ const [active, inactive] = panels(widget);
112
+ expect(getComputedStyle(active).display).not.toBe('none');
113
+ expect(getComputedStyle(inactive).display).toBe('none');
114
+
115
+ widget.dispose();
116
+ style.remove();
117
+ });
118
+
119
+ it('moves with the arrow keys, wrapping at both ends', async () => {
120
+ const widget = await mount();
121
+ // ReactWidget re-renders on an animation frame, so each key has to land on the
122
+ // render that the previous one produced.
123
+ const settle = (): Promise<void> =>
124
+ new Promise(resolve => setTimeout(resolve, 50));
125
+
126
+ strip(widget)[0].dispatchEvent(
127
+ new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })
128
+ );
129
+ expect(widget.selections.pop()).toBe('two');
130
+ await settle();
131
+
132
+ strip(widget)[1].dispatchEvent(
133
+ new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })
134
+ );
135
+ expect(widget.selections.pop()).toBe('one');
136
+ await settle();
137
+
138
+ // Wrapping: left from the first tab reaches the last.
139
+ strip(widget)[0].dispatchEvent(
140
+ new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })
141
+ );
142
+ expect(widget.selections.pop()).toBe('three');
143
+
144
+ widget.dispose();
145
+ });
146
+
147
+ it('jumps to the ends with Home and End', async () => {
148
+ const widget = await mount();
149
+
150
+ strip(widget)[0].dispatchEvent(
151
+ new KeyboardEvent('keydown', { key: 'End', bubbles: true })
152
+ );
153
+ expect(widget.selections.pop()).toBe('three');
154
+
155
+ widget.dispose();
156
+ });
157
+
158
+ it('falls back to the first tab when the selection names no tab', async () => {
159
+ const widget = new Harness(TABS);
160
+ widget.selected = 'gone';
161
+ Widget.attach(widget, document.body);
162
+ await new Promise(resolve => setTimeout(resolve, 50));
163
+
164
+ expect(strip(widget)[0].getAttribute('aria-selected')).toBe('true');
165
+
166
+ widget.dispose();
167
+ });
168
+ });
@@ -0,0 +1,271 @@
1
+ import {
2
+ ACCESS_TOKEN_COMMAND,
3
+ SIGN_IN_COMMAND,
4
+ SIGN_OUT_COMMAND,
5
+ aiBackendUrl,
6
+ aiCredentialSource,
7
+ canSignIn,
8
+ isSignedIn,
9
+ resolveAiCredential,
10
+ signInToken,
11
+ type AuthCommands
12
+ } from '../aiBackend';
13
+ import { OCEANUM_AI_BACKEND_URL } from '../constants';
14
+ import { validHttpUrl } from '../httpUrl';
15
+
16
+ describe('aiBackendUrl', () => {
17
+ it('is production when the deployment names no address', () => {
18
+ // What index.ts passes when the sign-in environment has no `ai` URL, or there is no
19
+ // sign-in at all.
20
+ expect(aiBackendUrl('')).toBe(OCEANUM_AI_BACKEND_URL);
21
+ expect(aiBackendUrl(undefined)).toBe('https://ai.oceanum.io');
22
+ });
23
+
24
+ it.each([
25
+ ['as it is', 'https://ai.oceanum.tech', 'https://ai.oceanum.tech'],
26
+ [
27
+ 'without a trailing slash',
28
+ 'https://ai.oceanum.tech/',
29
+ 'https://ai.oceanum.tech'
30
+ ],
31
+ ['trimmed', ' https://ai.oceanum.tech/ ', 'https://ai.oceanum.tech'],
32
+ [
33
+ 'over plain http, with a port',
34
+ 'http://localhost:8000/',
35
+ 'http://localhost:8000'
36
+ ],
37
+ [
38
+ 'with a path prefix',
39
+ 'https://example.test/ai//',
40
+ 'https://example.test/ai'
41
+ ],
42
+ [
43
+ 'without a query or fragment',
44
+ 'https://ai.oceanum.tech/?x=1#y',
45
+ 'https://ai.oceanum.tech'
46
+ ]
47
+ ])('takes an http(s) address %s', (_, value, expected) => {
48
+ expect(aiBackendUrl(value)).toBe(expected);
49
+ });
50
+
51
+ it.each([
52
+ ['empty', ''],
53
+ ['blank', ' '],
54
+ ['missing', undefined],
55
+ ['null', null],
56
+ ['not a URL', 'not a url'],
57
+ ['a relative path', '/api'],
58
+ ['a javascript: URL', 'javascript:alert(1)'],
59
+ ['a file: URL', 'file:///etc/passwd']
60
+ ])('falls back to the default for %s', (_, value) => {
61
+ expect(aiBackendUrl(value)).toBe('https://ai.oceanum.io');
62
+ });
63
+ });
64
+
65
+ describe('validHttpUrl', () => {
66
+ it('falls back to the fallback it is given', () => {
67
+ expect(validHttpUrl('ftp://x', 'https://fallback.test').href).toBe(
68
+ 'https://fallback.test/'
69
+ );
70
+ });
71
+ });
72
+
73
+ describe('resolveAiCredential', () => {
74
+ const never = jest.fn(async (): Promise<unknown> => 'unused');
75
+
76
+ beforeEach(() => never.mockClear());
77
+
78
+ it('uses a pasted token, trimmed, without asking for the sign-in', async () => {
79
+ expect(await resolveAiCredential(' a-token \n', never)).toEqual({
80
+ source: 'datamesh-token',
81
+ headers: { 'X-Datamesh-Token': 'a-token' }
82
+ });
83
+ expect(never).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it.each([
87
+ ['no', undefined],
88
+ ['a null', null],
89
+ ['an empty', ''],
90
+ ['a blank', ' \t ']
91
+ ])(
92
+ 'uses the sign-in as a bearer token with %s pasted token',
93
+ async (_, pasted) => {
94
+ expect(await resolveAiCredential(pasted, async () => ' a-jwt ')).toEqual({
95
+ source: 'sign-in',
96
+ headers: { Authorization: 'Bearer a-jwt' }
97
+ });
98
+ }
99
+ );
100
+
101
+ it.each([
102
+ ['signed out', async (): Promise<unknown> => null],
103
+ ['an empty token', async (): Promise<unknown> => ''],
104
+ ['a blank token', async (): Promise<unknown> => ' '],
105
+ ['a non-string', async (): Promise<unknown> => ({ token: 'a-jwt' })],
106
+ ['a number', async (): Promise<unknown> => 42],
107
+ [
108
+ 'a rejection',
109
+ (): Promise<unknown> => Promise.reject(new Error('sign-in failed'))
110
+ ],
111
+ [
112
+ 'a throw',
113
+ (): Promise<unknown> => {
114
+ throw new Error('sign-in failed');
115
+ }
116
+ ]
117
+ ])('has no credential without a pasted token and %s', async (_, get) => {
118
+ expect(await resolveAiCredential('', get)).toBeNull();
119
+ });
120
+ });
121
+
122
+ /**
123
+ * A host registry: `commands` are registered, `enabled`/`visible` say what its
124
+ * queries answer. Queries must never execute anything.
125
+ */
126
+ const host = (
127
+ commands: Record<string, () => unknown>,
128
+ { enabled = [] as string[], visible = [] as string[] } = {}
129
+ ) => ({
130
+ hasCommand: jest.fn((id: string) => id in commands),
131
+ execute: jest.fn(async (id: string) => commands[id]()),
132
+ isEnabled: jest.fn((id: string) => enabled.includes(id)),
133
+ isVisible: jest.fn((id: string) => visible.includes(id))
134
+ });
135
+
136
+ const asCommands = (commands: ReturnType<typeof host>): AuthCommands =>
137
+ commands as unknown as AuthCommands;
138
+
139
+ describe('canSignIn and isSignedIn', () => {
140
+ const all = {
141
+ [ACCESS_TOKEN_COMMAND]: () => 'a-jwt',
142
+ [SIGN_IN_COMMAND]: (): void => undefined,
143
+ [SIGN_OUT_COMMAND]: (): void => undefined
144
+ };
145
+
146
+ it('reads the queries, and runs nothing', () => {
147
+ const commands = host(all, {
148
+ enabled: [SIGN_IN_COMMAND],
149
+ visible: [SIGN_OUT_COMMAND]
150
+ });
151
+
152
+ expect(canSignIn(asCommands(commands))).toBe(true);
153
+ expect(isSignedIn(asCommands(commands))).toBe(true);
154
+ expect(commands.execute).not.toHaveBeenCalled();
155
+ });
156
+
157
+ it('is signed out while the sign-out command is hidden', () => {
158
+ const commands = host(all, { enabled: [SIGN_IN_COMMAND] });
159
+
160
+ expect(canSignIn(asCommands(commands))).toBe(true);
161
+ expect(isSignedIn(asCommands(commands))).toBe(false);
162
+ });
163
+
164
+ it('cannot sign in where the site has sign-in disabled', () => {
165
+ const commands = host(all, { visible: [SIGN_OUT_COMMAND] });
166
+
167
+ expect(canSignIn(asCommands(commands))).toBe(false);
168
+ });
169
+
170
+ it('is false where the host has no such commands', () => {
171
+ const commands = host({});
172
+
173
+ expect(canSignIn(asCommands(commands))).toBe(false);
174
+ expect(isSignedIn(asCommands(commands))).toBe(false);
175
+ expect(commands.execute).not.toHaveBeenCalled();
176
+ });
177
+
178
+ it('is false when a query throws', () => {
179
+ const commands = {
180
+ hasCommand: () => true,
181
+ execute: jest.fn(),
182
+ isEnabled: () => {
183
+ throw new Error('no');
184
+ },
185
+ isVisible: () => {
186
+ throw new Error('no');
187
+ }
188
+ } as unknown as AuthCommands;
189
+
190
+ expect(canSignIn(commands)).toBe(false);
191
+ expect(isSignedIn(commands)).toBe(false);
192
+ });
193
+ });
194
+
195
+ describe('aiCredentialSource', () => {
196
+ const signedIn = {
197
+ enabled: [SIGN_IN_COMMAND],
198
+ visible: [SIGN_OUT_COMMAND]
199
+ };
200
+ const all = {
201
+ [ACCESS_TOKEN_COMMAND]: () => 'a-jwt',
202
+ [SIGN_IN_COMMAND]: (): void => undefined,
203
+ [SIGN_OUT_COMMAND]: (): void => undefined
204
+ };
205
+
206
+ it('is the pasted token when there is one', () => {
207
+ const commands = host(all, signedIn);
208
+
209
+ expect(aiCredentialSource(' a-token ', asCommands(commands))).toBe(
210
+ 'datamesh-token'
211
+ );
212
+ expect(commands.execute).not.toHaveBeenCalled();
213
+ });
214
+
215
+ it('is the sign-in while signed in, and nothing once signed out', () => {
216
+ const commands = host(all, signedIn);
217
+ expect(aiCredentialSource('', asCommands(commands))).toBe('sign-in');
218
+ expect(commands.execute).not.toHaveBeenCalled();
219
+
220
+ const out = host(all, { enabled: [SIGN_IN_COMMAND] });
221
+ expect(aiCredentialSource('', asCommands(out))).toBeNull();
222
+ });
223
+
224
+ it('is nothing where the host has no access-token command', () => {
225
+ const commands = host(
226
+ { [SIGN_OUT_COMMAND]: (): void => undefined },
227
+ { visible: [SIGN_OUT_COMMAND] }
228
+ );
229
+
230
+ expect(aiCredentialSource('', asCommands(commands))).toBeNull();
231
+ });
232
+
233
+ it.each([
234
+ ['blank', ' '],
235
+ ['missing', undefined],
236
+ ['not a string', 42]
237
+ ])('treats a %s pasted token as none', (_, value) => {
238
+ const commands = host(all, signedIn);
239
+
240
+ expect(aiCredentialSource(value, asCommands(commands))).toBe('sign-in');
241
+ });
242
+ });
243
+
244
+ describe('signInToken', () => {
245
+ it('is null, without running anything, when the host has no access-token command', async () => {
246
+ const commands = host({});
247
+
248
+ expect(await signInToken(asCommands(commands))()).toBeNull();
249
+ expect(commands.execute).not.toHaveBeenCalled();
250
+ });
251
+
252
+ it("is what the host's access-token command answers", async () => {
253
+ let token: string | null = 'a-jwt';
254
+ const commands = host({ [ACCESS_TOKEN_COMMAND]: () => token });
255
+ const get = signInToken(asCommands(commands));
256
+
257
+ expect(await get()).toBe('a-jwt');
258
+ token = null;
259
+ expect(await get()).toBeNull();
260
+ expect(commands.execute).toHaveBeenCalledWith(ACCESS_TOKEN_COMMAND);
261
+ });
262
+
263
+ it('checks for the command on every call, so a host registering it late is found', async () => {
264
+ const commands: Record<string, () => unknown> = {};
265
+ const get = signInToken(asCommands(host(commands)));
266
+
267
+ expect(await get()).toBeNull();
268
+ commands[ACCESS_TOKEN_COMMAND] = () => 'a-jwt';
269
+ expect(await get()).toBe('a-jwt');
270
+ });
271
+ });