@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,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 the default the settings schema offers', () => {
19
- expect(schema.properties.aiBackendUrl.default).toBe(OCEANUM_AI_BACKEND_URL);
20
- expect(aiBackendUrl(schema.properties.aiBackendUrl.default)).toBe(
21
- 'https://ai.oceanum.io'
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
- settingsWith({
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
- settingsWith({
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
- settingsWith({
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 the default the settings schema offers', () => {
11
- expect(schema.properties.datameshUiUrl.default).toBe(DATAMESH_UI_SERVICE);
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
+ });