@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.
- package/README.md +102 -11
- package/lib/DatameshUI.d.ts +1 -6
- package/lib/DatameshUI.js +1 -9
- package/lib/DatameshWidget.d.ts +18 -2
- package/lib/DatameshWidget.js +80 -12
- package/lib/StoredNotebooks.d.ts +20 -0
- package/lib/StoredNotebooks.js +112 -0
- package/lib/Tabs.d.ts +26 -0
- package/lib/Tabs.js +45 -0
- package/lib/aiBackend.d.ts +1 -1
- package/lib/aiBackend.js +1 -1
- 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 +254 -0
- package/lib/auth/startSignIn.d.ts +11 -0
- package/lib/auth/startSignIn.js +16 -0
- package/lib/auth/tokens.d.ts +102 -0
- package/lib/auth/tokens.js +9 -0
- package/lib/chatRouter.d.ts +13 -1
- package/lib/chatRouter.js +9 -2
- package/lib/constants.d.ts +1 -1
- package/lib/constants.js +1 -1
- package/lib/datameshUiUrl.d.ts +2 -2
- package/lib/datameshUiUrl.js +6 -6
- package/lib/index.d.ts +8 -1
- package/lib/index.js +58 -17
- package/lib/share/client.d.ts +84 -0
- package/lib/share/client.js +205 -0
- package/lib/share/events.d.ts +7 -0
- package/lib/share/events.js +9 -0
- package/lib/share/notebook.d.ts +184 -0
- package/lib/share/notebook.js +277 -0
- package/lib/share/oceanumOnly.d.ts +49 -0
- package/lib/share/oceanumOnly.js +90 -0
- package/lib/share/plugin.d.ts +21 -0
- package/lib/share/plugin.js +883 -0
- package/lib/share/widgets.d.ts +25 -0
- package/lib/share/widgets.js +154 -0
- package/package.json +13 -1
- package/schema/datamesh-connect.json +3 -12
- package/src/DatameshUI.tsx +1 -10
- package/src/DatameshWidget.tsx +143 -19
- package/src/StoredNotebooks.tsx +206 -0
- package/src/Tabs.tsx +102 -0
- package/src/__tests__/DatameshWidget.spec.tsx +33 -20
- package/src/__tests__/OceanumPanel.spec.tsx +157 -0
- package/src/__tests__/StoredNotebooks.spec.tsx +157 -0
- package/src/__tests__/Tabs.spec.tsx +168 -0
- package/src/__tests__/aiBackend.spec.ts +5 -6
- package/src/__tests__/chatRouter.spec.ts +9 -15
- package/src/__tests__/datameshUiUrl.spec.ts +4 -3
- package/src/__tests__/oceanumOnly.spec.ts +193 -0
- 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__/shareClient.spec.ts +314 -0
- package/src/__tests__/shareNotebook.spec.ts +451 -0
- package/src/__tests__/sharePlugin.spec.ts +426 -0
- package/src/aiBackend.ts +1 -1
- 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 +342 -0
- package/src/auth/startSignIn.ts +23 -0
- package/src/auth/tokens.ts +111 -0
- package/src/chatRouter.ts +8 -4
- package/src/constants.ts +1 -1
- package/src/datameshUiUrl.ts +6 -6
- package/src/index.ts +87 -36
- package/src/share/client.ts +288 -0
- package/src/share/events.ts +10 -0
- package/src/share/notebook.ts +416 -0
- package/src/share/oceanumOnly.ts +126 -0
- package/src/share/plugin.ts +1096 -0
- package/src/share/widgets.ts +217 -0
- package/style/index.css +266 -13
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { ReactWidget } from '@jupyterlab/ui-components';
|
|
2
|
+
import { Signal } from '@lumino/signaling';
|
|
3
|
+
import { Widget } from '@lumino/widgets';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import { IOceanumAuth, IOceanumUser } from '../auth/tokens';
|
|
7
|
+
import { notebooksChanged } from '../share/events';
|
|
8
|
+
import { StoredNotebooks } from '../StoredNotebooks';
|
|
9
|
+
|
|
10
|
+
/** A signed-out auth that records whether sign-in was asked for. */
|
|
11
|
+
function signedOut(): IOceanumAuth & { signIns: number } {
|
|
12
|
+
const auth = {
|
|
13
|
+
signIns: 0,
|
|
14
|
+
environment: null as IOceanumAuth['environment'],
|
|
15
|
+
urls: { specs: 'https://specs.example.com' },
|
|
16
|
+
ready: Promise.resolve(),
|
|
17
|
+
user: null as IOceanumUser | null,
|
|
18
|
+
userChanged: new Signal<IOceanumAuth, IOceanumUser | null>(
|
|
19
|
+
{} as IOceanumAuth
|
|
20
|
+
),
|
|
21
|
+
tokenChanged: new Signal<IOceanumAuth, string | null>({} as IOceanumAuth),
|
|
22
|
+
signIn: async (): Promise<void> => {
|
|
23
|
+
auth.signIns += 1;
|
|
24
|
+
},
|
|
25
|
+
signOut: async (): Promise<void> => undefined,
|
|
26
|
+
getAccessToken: async (): Promise<string | null> => null
|
|
27
|
+
};
|
|
28
|
+
return auth as unknown as IOceanumAuth & { signIns: number };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class Harness extends ReactWidget {
|
|
32
|
+
constructor(
|
|
33
|
+
private readonly _auth: IOceanumAuth,
|
|
34
|
+
private readonly _onSignIn?: () => void
|
|
35
|
+
) {
|
|
36
|
+
super();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
render(): React.ReactElement {
|
|
40
|
+
return <StoredNotebooks auth={this._auth} onSignIn={this._onSignIn} />;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function mount(
|
|
45
|
+
auth: IOceanumAuth,
|
|
46
|
+
onSignIn?: () => void
|
|
47
|
+
): Promise<Harness> {
|
|
48
|
+
const widget = new Harness(auth, onSignIn);
|
|
49
|
+
Widget.attach(widget, document.body);
|
|
50
|
+
const deadline = Date.now() + 4000;
|
|
51
|
+
for (;;) {
|
|
52
|
+
if (widget.node.querySelector('.oceanum-text-empty')) {
|
|
53
|
+
return widget;
|
|
54
|
+
}
|
|
55
|
+
if (Date.now() > deadline) {
|
|
56
|
+
throw new Error('StoredNotebooks rendered nothing within 4s');
|
|
57
|
+
}
|
|
58
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('the Notebooks tab while signed in', () => {
|
|
63
|
+
const ID = '6f1c1a52-4b8e-4c0f-9a57-1d2e3f4a5b6c';
|
|
64
|
+
|
|
65
|
+
it('lists again when a record is created, renamed or deleted', async () => {
|
|
66
|
+
const auth = signedOut();
|
|
67
|
+
(auth as { user: IOceanumUser | null }).user = {
|
|
68
|
+
sub: 'auth0|1',
|
|
69
|
+
email: 'me@example.com',
|
|
70
|
+
name: null,
|
|
71
|
+
activeOrg: null,
|
|
72
|
+
colorScheme: null
|
|
73
|
+
};
|
|
74
|
+
auth.getAccessToken = async () => 'tok';
|
|
75
|
+
const fetches: string[] = [];
|
|
76
|
+
const originalFetch = globalThis.fetch;
|
|
77
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
78
|
+
fetches.push(String(input));
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
status: 200,
|
|
82
|
+
json: async () => [
|
|
83
|
+
{
|
|
84
|
+
id: ID,
|
|
85
|
+
name: 'Waves',
|
|
86
|
+
description: null as string | null,
|
|
87
|
+
modified: '2026-09-12T10:00:00',
|
|
88
|
+
creator: 'me@example.com'
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
} as unknown as Response;
|
|
92
|
+
}) as typeof fetch;
|
|
93
|
+
try {
|
|
94
|
+
const widget = new Harness(auth);
|
|
95
|
+
Widget.attach(widget, document.body);
|
|
96
|
+
const until = async (test: () => boolean): Promise<void> => {
|
|
97
|
+
const deadline = Date.now() + 4000;
|
|
98
|
+
while (!test()) {
|
|
99
|
+
if (Date.now() > deadline) {
|
|
100
|
+
throw new Error('timed out');
|
|
101
|
+
}
|
|
102
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
await until(() => !!widget.node.querySelector('.oceanum-notebooks-item'));
|
|
106
|
+
expect(fetches).toHaveLength(1);
|
|
107
|
+
const row = widget.node.querySelector('.oceanum-notebooks-item')!;
|
|
108
|
+
expect(row.getAttribute('data-spec-id')).toBe(ID);
|
|
109
|
+
|
|
110
|
+
notebooksChanged.emit();
|
|
111
|
+
|
|
112
|
+
await until(() => fetches.length === 2);
|
|
113
|
+
widget.dispose();
|
|
114
|
+
} finally {
|
|
115
|
+
globalThis.fetch = originalFetch;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('the Notebooks tab while signed out', () => {
|
|
121
|
+
it('links "Sign in" to starting a sign-in', async () => {
|
|
122
|
+
const auth = signedOut();
|
|
123
|
+
const widget = await mount(auth);
|
|
124
|
+
|
|
125
|
+
expect(widget.node.textContent).toContain(
|
|
126
|
+
'Sign in to Oceanum.io to see your notebooks.'
|
|
127
|
+
);
|
|
128
|
+
const link = widget.node.querySelector<HTMLAnchorElement>(
|
|
129
|
+
'.oceanum-text-empty a'
|
|
130
|
+
);
|
|
131
|
+
expect(link?.textContent).toBe('Sign in');
|
|
132
|
+
|
|
133
|
+
link!.click();
|
|
134
|
+
expect(auth.signIns).toBe(1);
|
|
135
|
+
|
|
136
|
+
widget.dispose();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("prefers the host's own way of signing in when given one", async () => {
|
|
140
|
+
// On a JupyterLab server that is the device sign-in command, which shows the
|
|
141
|
+
// code; the auth's signIn() alone would start the flow with nothing to look at.
|
|
142
|
+
const auth = signedOut();
|
|
143
|
+
let hostSignIns = 0;
|
|
144
|
+
const widget = await mount(auth, () => {
|
|
145
|
+
hostSignIns += 1;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
widget.node
|
|
149
|
+
.querySelector<HTMLAnchorElement>('.oceanum-text-empty a')!
|
|
150
|
+
.click();
|
|
151
|
+
|
|
152
|
+
expect(hostSignIns).toBe(1);
|
|
153
|
+
expect(auth.signIns).toBe(0);
|
|
154
|
+
|
|
155
|
+
widget.dispose();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import schema from '../../schema/datamesh-connect.json';
|
|
2
1
|
import {
|
|
3
2
|
ACCESS_TOKEN_COMMAND,
|
|
4
3
|
SIGN_IN_COMMAND,
|
|
@@ -15,11 +14,11 @@ import { OCEANUM_AI_BACKEND_URL } from '../constants';
|
|
|
15
14
|
import { validHttpUrl } from '../httpUrl';
|
|
16
15
|
|
|
17
16
|
describe('aiBackendUrl', () => {
|
|
18
|
-
it('is
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
);
|
|
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');
|
|
23
22
|
});
|
|
24
23
|
|
|
25
24
|
it.each([
|
|
@@ -417,12 +417,10 @@ describe('ChatRouter: where a request goes, and what it is signed with', () => {
|
|
|
417
417
|
|
|
418
418
|
it('sends to the configured address, without a double slash', async () => {
|
|
419
419
|
const router = new ChatRouter(
|
|
420
|
-
|
|
421
|
-
datameshToken: 'a-token',
|
|
422
|
-
aiBackendUrl: ' https://ai.oceanum.tech/ '
|
|
423
|
-
}),
|
|
420
|
+
settings,
|
|
424
421
|
noSignIn,
|
|
425
|
-
noNotebook
|
|
422
|
+
noNotebook,
|
|
423
|
+
() => ' https://ai.oceanum.tech/ '
|
|
426
424
|
);
|
|
427
425
|
|
|
428
426
|
await router.route('hello');
|
|
@@ -434,12 +432,10 @@ describe('ChatRouter: where a request goes, and what it is signed with', () => {
|
|
|
434
432
|
|
|
435
433
|
it('sends to the default for an invalid address', async () => {
|
|
436
434
|
const router = new ChatRouter(
|
|
437
|
-
|
|
438
|
-
datameshToken: 'a-token',
|
|
439
|
-
aiBackendUrl: 'javascript:alert(1)'
|
|
440
|
-
}),
|
|
435
|
+
settings,
|
|
441
436
|
noSignIn,
|
|
442
|
-
noNotebook
|
|
437
|
+
noNotebook,
|
|
438
|
+
() => 'javascript:alert(1)'
|
|
443
439
|
);
|
|
444
440
|
|
|
445
441
|
await router.route('hello');
|
|
@@ -542,12 +538,10 @@ describe('ChatRouter: where a request goes, and what it is signed with', () => {
|
|
|
542
538
|
throw new TypeError('Failed to fetch');
|
|
543
539
|
});
|
|
544
540
|
const router = new ChatRouter(
|
|
545
|
-
|
|
546
|
-
datameshToken: 'a-token',
|
|
547
|
-
aiBackendUrl: 'http://localhost:8000/'
|
|
548
|
-
}),
|
|
541
|
+
settings,
|
|
549
542
|
noSignIn,
|
|
550
|
-
noNotebook
|
|
543
|
+
noNotebook,
|
|
544
|
+
() => 'http://localhost:8000/'
|
|
551
545
|
);
|
|
552
546
|
|
|
553
547
|
await expect(router.route('hello')).rejects.toThrow(
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import schema from '../../schema/datamesh-connect.json';
|
|
2
1
|
import { DATAMESH_UI_SERVICE } from '../constants';
|
|
3
2
|
import {
|
|
4
3
|
datameshUiSrc,
|
|
@@ -7,8 +6,10 @@ import {
|
|
|
7
6
|
} from '../datameshUiUrl';
|
|
8
7
|
|
|
9
8
|
describe('validDatameshUiUrl', () => {
|
|
10
|
-
it('is
|
|
11
|
-
expect(
|
|
9
|
+
it('is production when the deployment names no address', () => {
|
|
10
|
+
expect(validDatameshUiUrl(undefined).href).toBe(
|
|
11
|
+
new URL(DATAMESH_UI_SERVICE).href
|
|
12
|
+
);
|
|
12
13
|
});
|
|
13
14
|
|
|
14
15
|
it('takes an http(s) address as it is', () => {
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import type { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
|
|
2
|
+
import { CommandRegistry } from '@lumino/commands';
|
|
3
|
+
import { Signal } from '@lumino/signaling';
|
|
4
|
+
import { Menu, Panel, Widget } from '@lumino/widgets';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
FILE_BROWSER_ID,
|
|
8
|
+
hideFileBrowser,
|
|
9
|
+
LOCAL_FILE_COMMANDS,
|
|
10
|
+
pruneBeforeEachOpen,
|
|
11
|
+
pruneFileMenu,
|
|
12
|
+
routeSaves
|
|
13
|
+
} from '../share/oceanumOnly';
|
|
14
|
+
|
|
15
|
+
describe('hideFileBrowser', () => {
|
|
16
|
+
it('closes the file browser without disposing it, and leaves the rest', () => {
|
|
17
|
+
const left = new Panel();
|
|
18
|
+
const browser = new Widget();
|
|
19
|
+
browser.id = FILE_BROWSER_ID;
|
|
20
|
+
const running = new Widget();
|
|
21
|
+
running.id = 'jp-running-sessions';
|
|
22
|
+
left.addWidget(browser);
|
|
23
|
+
left.addWidget(running);
|
|
24
|
+
|
|
25
|
+
expect(hideFileBrowser({ widgets: () => left.widgets })).toBe(true);
|
|
26
|
+
|
|
27
|
+
expect(left.widgets).toEqual([running]);
|
|
28
|
+
// Other extensions still hold it through IDefaultFileBrowser.
|
|
29
|
+
expect(browser.isDisposed).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('reports when there is none to hide', () => {
|
|
33
|
+
const left = new Panel();
|
|
34
|
+
expect(hideFileBrowser({ widgets: () => left.widgets })).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function fileMenu(commands: string[]): {
|
|
39
|
+
menu: Menu;
|
|
40
|
+
registry: CommandRegistry;
|
|
41
|
+
} {
|
|
42
|
+
const registry = new CommandRegistry();
|
|
43
|
+
const menu = new Menu({ commands: registry });
|
|
44
|
+
for (const command of commands) {
|
|
45
|
+
if (command === '-') {
|
|
46
|
+
menu.addItem({ type: 'separator' });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
registry.addCommand(command, { execute: () => undefined, label: command });
|
|
50
|
+
menu.addItem({ command });
|
|
51
|
+
}
|
|
52
|
+
return { menu, registry };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const menuCommands = (menu: Menu): string[] =>
|
|
56
|
+
menu.items.map(item => (item.type === 'separator' ? '-' : item.command));
|
|
57
|
+
|
|
58
|
+
describe('pruneFileMenu', () => {
|
|
59
|
+
it('drops the local-file entries and keeps Save, Rename and everything else', () => {
|
|
60
|
+
const { menu } = fileMenu([
|
|
61
|
+
'filebrowser:open-path',
|
|
62
|
+
'filebrowser:open-url',
|
|
63
|
+
'-',
|
|
64
|
+
'docmanager:save',
|
|
65
|
+
'docmanager:save-as',
|
|
66
|
+
'docmanager:save-all',
|
|
67
|
+
'-',
|
|
68
|
+
'docmanager:reload',
|
|
69
|
+
'docmanager:restore-checkpoint',
|
|
70
|
+
'docmanager:rename',
|
|
71
|
+
'docmanager:duplicate',
|
|
72
|
+
'-',
|
|
73
|
+
'docmanager:download',
|
|
74
|
+
'oceanum-share:save'
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
expect(pruneFileMenu(menu)).toBe(7);
|
|
78
|
+
|
|
79
|
+
expect(menuCommands(menu)).toEqual([
|
|
80
|
+
'-',
|
|
81
|
+
'docmanager:save',
|
|
82
|
+
'docmanager:save-all',
|
|
83
|
+
'-',
|
|
84
|
+
'docmanager:rename',
|
|
85
|
+
'-',
|
|
86
|
+
'oceanum-share:save'
|
|
87
|
+
]);
|
|
88
|
+
// Idempotent: nothing left to remove.
|
|
89
|
+
expect(pruneFileMenu(menu)).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('drops the Open Recent submenu, which the document manager builds by hand', () => {
|
|
93
|
+
const { menu, registry } = fileMenu(['docmanager:save']);
|
|
94
|
+
registry.addCommand('docmanager:clear-recents', {
|
|
95
|
+
execute: () => undefined,
|
|
96
|
+
label: 'Clear Recent'
|
|
97
|
+
});
|
|
98
|
+
const recents = new Menu({ commands: registry });
|
|
99
|
+
recents.title.label = 'Open Recent';
|
|
100
|
+
recents.addItem({ command: 'docmanager:clear-recents' });
|
|
101
|
+
menu.addItem({ type: 'submenu', submenu: recents });
|
|
102
|
+
const other = new Menu({ commands: registry });
|
|
103
|
+
other.title.label = 'New';
|
|
104
|
+
menu.addItem({ type: 'submenu', submenu: other });
|
|
105
|
+
|
|
106
|
+
expect(pruneFileMenu(menu)).toBe(1);
|
|
107
|
+
|
|
108
|
+
expect(menu.items.map(item => item.type)).toEqual(['command', 'submenu']);
|
|
109
|
+
expect(menu.items[1].submenu?.title.label).toBe('New');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('names every entry it prunes, so the list is reviewable', () => {
|
|
113
|
+
expect(LOCAL_FILE_COMMANDS).toEqual([
|
|
114
|
+
'filebrowser:open-path',
|
|
115
|
+
'filebrowser:open-url',
|
|
116
|
+
'docmanager:save-as',
|
|
117
|
+
'docmanager:reload',
|
|
118
|
+
'docmanager:restore-checkpoint',
|
|
119
|
+
'docmanager:duplicate',
|
|
120
|
+
'docmanager:download'
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('pruneBeforeEachOpen', () => {
|
|
126
|
+
it('prunes entries that arrive after the first pass, each time the menu opens', () => {
|
|
127
|
+
const { menu, registry } = fileMenu(['docmanager:save']);
|
|
128
|
+
pruneBeforeEachOpen(menu);
|
|
129
|
+
// A plugin's settings schema loads late and adds its entry.
|
|
130
|
+
registry.addCommand('docmanager:save-as', {
|
|
131
|
+
execute: () => undefined,
|
|
132
|
+
label: 'Save As'
|
|
133
|
+
});
|
|
134
|
+
menu.addItem({ command: 'docmanager:save-as' });
|
|
135
|
+
expect(menuCommands(menu)).toEqual([
|
|
136
|
+
'docmanager:save',
|
|
137
|
+
'docmanager:save-as'
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
menu.open(0, 0);
|
|
141
|
+
try {
|
|
142
|
+
expect(menuCommands(menu)).toEqual(['docmanager:save']);
|
|
143
|
+
} finally {
|
|
144
|
+
menu.close();
|
|
145
|
+
menu.dispose();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/** Just enough of a notebook tracker and its panels to route saves. */
|
|
151
|
+
function fakeTracker(): {
|
|
152
|
+
tracker: INotebookTracker;
|
|
153
|
+
add: () => { panel: NotebookPanel; saveState: Signal<unknown, string> };
|
|
154
|
+
} {
|
|
155
|
+
const panels: NotebookPanel[] = [];
|
|
156
|
+
const widgetAdded = new Signal<INotebookTracker, NotebookPanel>(
|
|
157
|
+
{} as INotebookTracker
|
|
158
|
+
);
|
|
159
|
+
const tracker = {
|
|
160
|
+
forEach: (fn: (panel: NotebookPanel) => void) => panels.forEach(fn),
|
|
161
|
+
widgetAdded
|
|
162
|
+
} as unknown as INotebookTracker;
|
|
163
|
+
const add = () => {
|
|
164
|
+
const saveState = new Signal<unknown, string>({});
|
|
165
|
+
const panel = { context: { saveState } } as unknown as NotebookPanel;
|
|
166
|
+
panels.push(panel);
|
|
167
|
+
widgetAdded.emit(panel);
|
|
168
|
+
return { panel, saveState };
|
|
169
|
+
};
|
|
170
|
+
return { tracker, add };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
describe('routeSaves', () => {
|
|
174
|
+
it('pushes after every completed save, of notebooks open now and later', () => {
|
|
175
|
+
const { tracker, add } = fakeTracker();
|
|
176
|
+
const first = add();
|
|
177
|
+
const pushed: NotebookPanel[] = [];
|
|
178
|
+
routeSaves(tracker, async panel => {
|
|
179
|
+
pushed.push(panel);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
first.saveState.emit('started');
|
|
183
|
+
expect(pushed).toEqual([]);
|
|
184
|
+
first.saveState.emit('completed');
|
|
185
|
+
expect(pushed).toEqual([first.panel]);
|
|
186
|
+
|
|
187
|
+
const second = add();
|
|
188
|
+
second.saveState.emit('completed');
|
|
189
|
+
second.saveState.emit('failed');
|
|
190
|
+
first.saveState.emit('completed');
|
|
191
|
+
expect(pushed).toEqual([first.panel, second.panel, first.panel]);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import plugins from '../index';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JupyterLab passes `requires` then `optional` to `activate`, positionally, after the
|
|
5
|
+
* application. Omitting a parameter for a dependency you do not use does not skip it —
|
|
6
|
+
* it shifts every later argument along by one, silently handing the wrong object to the
|
|
7
|
+
* wrong parameter. TypeScript cannot see this: `activate` is typed loosely enough that
|
|
8
|
+
* the mismatch compiles, and unit tests that never activate the plugin never notice.
|
|
9
|
+
*
|
|
10
|
+
* This bit exactly once: adding an optional IOceanumAuth to a plugin whose signature
|
|
11
|
+
* had quietly omitted its IStateDB meant `auth` received the state database, and the
|
|
12
|
+
* panel crashed on `auth.userChanged.connect` at application start.
|
|
13
|
+
*/
|
|
14
|
+
describe('plugin activate signatures', () => {
|
|
15
|
+
it.each(plugins.map(plugin => [plugin.id, plugin] as const))(
|
|
16
|
+
'%s takes one parameter per declared dependency',
|
|
17
|
+
(_id, plugin) => {
|
|
18
|
+
const declared =
|
|
19
|
+
(plugin.requires?.length ?? 0) + (plugin.optional?.length ?? 0);
|
|
20
|
+
// +1 for the JupyterFrontEnd itself.
|
|
21
|
+
expect(plugin.activate.length).toBe(declared + 1);
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
});
|