@oceanum/oceanumlab 4.7.1 → 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/lib/DatameshWidget.js +14 -5
- package/lib/StoredNotebooks.d.ts +9 -5
- package/lib/StoredNotebooks.js +49 -21
- package/lib/auth/serverAuth.js +1 -0
- package/lib/auth/startSignIn.d.ts +11 -0
- package/lib/auth/startSignIn.js +16 -0
- package/lib/auth/tokens.d.ts +7 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +8 -6
- 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 +5 -1
- package/src/DatameshWidget.tsx +21 -2
- package/src/StoredNotebooks.tsx +60 -20
- package/src/__tests__/OceanumPanel.spec.tsx +60 -4
- package/src/__tests__/StoredNotebooks.spec.tsx +157 -0
- package/src/__tests__/oceanumOnly.spec.ts +193 -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/auth/serverAuth.ts +1 -0
- package/src/auth/startSignIn.ts +23 -0
- package/src/auth/tokens.ts +7 -0
- package/src/index.ts +8 -6
- 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 +111 -16
- package/lib/specStore.d.ts +0 -37
- package/lib/specStore.js +0 -57
- package/src/__tests__/specStore.spec.ts +0 -126
- package/src/specStore.ts +0 -82
|
@@ -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,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,314 @@
|
|
|
1
|
+
import type { INotebookContent } from '@jupyterlab/nbformat';
|
|
2
|
+
|
|
3
|
+
import { SpecStoreClient, SpecStoreError } from '../share/client';
|
|
4
|
+
import type { ISpecBody } from '../share/notebook';
|
|
5
|
+
|
|
6
|
+
const ID = '6f1c1a52-4b8e-4c0f-9a57-1d2e3f4a5b6c';
|
|
7
|
+
const SERVER_ID = '0b7d9f2e-1111-4a2b-8c3d-9e8f7a6b5c4d';
|
|
8
|
+
|
|
9
|
+
const notebook: INotebookContent = {
|
|
10
|
+
nbformat: 4,
|
|
11
|
+
nbformat_minor: 5,
|
|
12
|
+
metadata: {},
|
|
13
|
+
cells: []
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function record(id: string = ID) {
|
|
17
|
+
return {
|
|
18
|
+
id,
|
|
19
|
+
name: 'Waves',
|
|
20
|
+
description: null as string | null,
|
|
21
|
+
modified: '2026-09-12T10:00:00',
|
|
22
|
+
creator: 'a@example.com',
|
|
23
|
+
spec: notebook
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function json(data: unknown, status = 200): Response {
|
|
28
|
+
return {
|
|
29
|
+
ok: status >= 200 && status < 300,
|
|
30
|
+
status,
|
|
31
|
+
json: async () => data
|
|
32
|
+
} as unknown as Response;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function noContent(): Response {
|
|
36
|
+
return {
|
|
37
|
+
ok: true,
|
|
38
|
+
status: 204,
|
|
39
|
+
json: async () => {
|
|
40
|
+
throw new Error('no body');
|
|
41
|
+
}
|
|
42
|
+
} as unknown as Response;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function setup(
|
|
46
|
+
response: () => Response | Promise<Response>,
|
|
47
|
+
token: string | null = 'tok'
|
|
48
|
+
) {
|
|
49
|
+
const fetch = jest.fn(
|
|
50
|
+
async (_input: RequestInfo | URL, _init?: RequestInit) => response()
|
|
51
|
+
);
|
|
52
|
+
const client = new SpecStoreClient({
|
|
53
|
+
specsUrl: 'https://specs.example.com/',
|
|
54
|
+
getAccessToken: async () => token,
|
|
55
|
+
fetch: fetch as unknown as typeof globalThis.fetch
|
|
56
|
+
});
|
|
57
|
+
const call = (i = 0) => {
|
|
58
|
+
const [url, init] = fetch.mock.calls[i];
|
|
59
|
+
return {
|
|
60
|
+
url: String(url),
|
|
61
|
+
method: init?.method,
|
|
62
|
+
headers: (init?.headers ?? {}) as Record<string, string>,
|
|
63
|
+
body: init?.body === undefined ? undefined : JSON.parse(String(init.body))
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
return { client, fetch, call };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const body: ISpecBody = { name: 'Waves', description: null, spec: notebook };
|
|
70
|
+
|
|
71
|
+
describe('SpecStoreClient', () => {
|
|
72
|
+
it('lists with the bearer token', async () => {
|
|
73
|
+
const { client, call } = setup(() => json([record()]));
|
|
74
|
+
const items = await client.list();
|
|
75
|
+
expect(items).toHaveLength(1);
|
|
76
|
+
expect(call()).toEqual({
|
|
77
|
+
url: 'https://specs.example.com/specs/notebook',
|
|
78
|
+
method: 'GET',
|
|
79
|
+
headers: { Authorization: 'Bearer tok' },
|
|
80
|
+
body: undefined
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('requires sign-in to list, without calling the server', async () => {
|
|
85
|
+
const { client, fetch } = setup(() => json([]), null);
|
|
86
|
+
await expect(client.list()).rejects.toMatchObject({ kind: 'signed-out' });
|
|
87
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('gets a record anonymously when signed out', async () => {
|
|
91
|
+
const { client, call } = setup(() => json(record()), null);
|
|
92
|
+
const result = await client.get(ID);
|
|
93
|
+
expect(result.id).toBe(ID);
|
|
94
|
+
expect(call().url).toBe(`https://specs.example.com/specs/notebook/${ID}`);
|
|
95
|
+
expect(call().headers).toEqual({});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('refuses ids that are not UUIDs before building a URL', async () => {
|
|
99
|
+
const { client, fetch } = setup(() => json(record()));
|
|
100
|
+
await expect(client.get('../eidos')).rejects.toMatchObject({
|
|
101
|
+
kind: 'invalid'
|
|
102
|
+
});
|
|
103
|
+
await expect(client.update('x/permissions', body)).rejects.toBeInstanceOf(
|
|
104
|
+
SpecStoreError
|
|
105
|
+
);
|
|
106
|
+
await expect(client.remove('x')).rejects.toMatchObject({
|
|
107
|
+
kind: 'invalid'
|
|
108
|
+
});
|
|
109
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('creates with a JSON body, never sends an id, and uses the server id', async () => {
|
|
113
|
+
const { client, call } = setup(() => json(record(SERVER_ID)));
|
|
114
|
+
const withId = { ...body, id: ID } as ISpecBody;
|
|
115
|
+
const result = await client.create(withId);
|
|
116
|
+
expect(result.id).toBe(SERVER_ID);
|
|
117
|
+
expect(call()).toEqual({
|
|
118
|
+
url: 'https://specs.example.com/specs/notebook',
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: {
|
|
121
|
+
Authorization: 'Bearer tok',
|
|
122
|
+
'Content-Type': 'application/json'
|
|
123
|
+
},
|
|
124
|
+
body: { name: 'Waves', description: null, spec: notebook }
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('updates with PUT to the record URL', async () => {
|
|
129
|
+
const { client, call } = setup(() => json(record()));
|
|
130
|
+
await client.update(ID, body);
|
|
131
|
+
expect(call().method).toBe('PUT');
|
|
132
|
+
expect(call().url).toBe(`https://specs.example.com/specs/notebook/${ID}`);
|
|
133
|
+
expect(call().body).toEqual(body);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('patches only the fields it is given', async () => {
|
|
137
|
+
const { client, call } = setup(() => json(record()));
|
|
138
|
+
await client.patch(ID, { name: 'Renamed' });
|
|
139
|
+
expect(call()).toEqual({
|
|
140
|
+
url: `https://specs.example.com/specs/notebook/${ID}`,
|
|
141
|
+
method: 'PATCH',
|
|
142
|
+
headers: {
|
|
143
|
+
Authorization: 'Bearer tok',
|
|
144
|
+
'Content-Type': 'application/json'
|
|
145
|
+
},
|
|
146
|
+
body: { name: 'Renamed' }
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('refuses a patch that carries nothing, without calling the server', async () => {
|
|
151
|
+
// The store answers an empty patch with 400, and there is nothing to send.
|
|
152
|
+
const { client, fetch } = setup(() => json(record()));
|
|
153
|
+
await expect(client.patch(ID, {})).rejects.toMatchObject({
|
|
154
|
+
kind: 'invalid'
|
|
155
|
+
});
|
|
156
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('renames with a single patch, leaving the notebook alone', async () => {
|
|
160
|
+
const { client, call, fetch } = setup(() => json(record()));
|
|
161
|
+
await client.rename(ID, 'Renamed');
|
|
162
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
163
|
+
expect(call().method).toBe('PATCH');
|
|
164
|
+
expect(call().body).toEqual({ name: 'Renamed' });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('renames by reading and putting back where the store has no PATCH', async () => {
|
|
168
|
+
// Production served no PATCH when this was written; a store without it answers 405.
|
|
169
|
+
const responses: Record<string, () => Response> = {
|
|
170
|
+
PATCH: () => json('Method Not Allowed', 405),
|
|
171
|
+
GET: () => json({ ...record(), description: 'Wave stats' }),
|
|
172
|
+
PUT: () => json(record())
|
|
173
|
+
};
|
|
174
|
+
const fetch = jest.fn(
|
|
175
|
+
async (_input: RequestInfo | URL, init?: RequestInit) =>
|
|
176
|
+
responses[init?.method ?? 'GET']()
|
|
177
|
+
);
|
|
178
|
+
const client = new SpecStoreClient({
|
|
179
|
+
specsUrl: 'https://specs.example.com',
|
|
180
|
+
getAccessToken: async () => 'tok',
|
|
181
|
+
fetch: fetch as unknown as typeof globalThis.fetch
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await client.rename(ID, 'Renamed');
|
|
185
|
+
|
|
186
|
+
expect(fetch.mock.calls.map(([, init]) => init?.method)).toEqual([
|
|
187
|
+
'PATCH',
|
|
188
|
+
'GET',
|
|
189
|
+
'PUT'
|
|
190
|
+
]);
|
|
191
|
+
const put = JSON.parse(String(fetch.mock.calls[2][1]?.body));
|
|
192
|
+
// The name changes; everything else goes back as it was read.
|
|
193
|
+
expect(put).toEqual({
|
|
194
|
+
name: 'Renamed',
|
|
195
|
+
description: 'Wave stats',
|
|
196
|
+
spec: notebook
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('does not fall back for a failure that is not a missing method', async () => {
|
|
201
|
+
const { client, fetch } = setup(() => json('Forbidden', 403));
|
|
202
|
+
await expect(client.rename(ID, 'Renamed')).rejects.toMatchObject({
|
|
203
|
+
kind: 'forbidden'
|
|
204
|
+
});
|
|
205
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('deletes with DELETE to the record URL, and needs sign-in', async () => {
|
|
209
|
+
const { client, call } = setup(() => noContent());
|
|
210
|
+
await client.remove(ID);
|
|
211
|
+
expect(call().method).toBe('DELETE');
|
|
212
|
+
expect(call().url).toBe(`https://specs.example.com/specs/notebook/${ID}`);
|
|
213
|
+
expect(call().body).toBeUndefined();
|
|
214
|
+
|
|
215
|
+
const signedOut = setup(() => noContent(), null);
|
|
216
|
+
await expect(signedOut.client.remove(ID)).rejects.toMatchObject({
|
|
217
|
+
kind: 'signed-out'
|
|
218
|
+
});
|
|
219
|
+
expect(signedOut.fetch).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('posts permission grants', async () => {
|
|
223
|
+
const { client, call } = setup(() => json([]));
|
|
224
|
+
await client.addPermission(ID, {
|
|
225
|
+
type: 'user',
|
|
226
|
+
entity: 'b@example.com',
|
|
227
|
+
permission: 'write'
|
|
228
|
+
});
|
|
229
|
+
await client.addPermission(ID, {
|
|
230
|
+
type: 'public',
|
|
231
|
+
entity: '',
|
|
232
|
+
permission: 'read'
|
|
233
|
+
});
|
|
234
|
+
expect(call(0).url).toBe(
|
|
235
|
+
`https://specs.example.com/specs/notebook/${ID}/permissions`
|
|
236
|
+
);
|
|
237
|
+
expect(call(0).method).toBe('POST');
|
|
238
|
+
expect(call(0).body).toEqual({
|
|
239
|
+
type: 'user',
|
|
240
|
+
entity: 'b@example.com',
|
|
241
|
+
permission: 'write'
|
|
242
|
+
});
|
|
243
|
+
expect(call(1).body).toEqual({
|
|
244
|
+
type: 'public',
|
|
245
|
+
entity: '',
|
|
246
|
+
permission: 'read'
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('deletes a permission by repeating the exact grant', async () => {
|
|
251
|
+
const { client, call } = setup(() => json([]));
|
|
252
|
+
await client.removePermission(ID, {
|
|
253
|
+
type: 'public',
|
|
254
|
+
entity: '',
|
|
255
|
+
permission: 'read'
|
|
256
|
+
});
|
|
257
|
+
expect(call(0).url).toBe(
|
|
258
|
+
`https://specs.example.com/specs/notebook/${ID}/permissions`
|
|
259
|
+
);
|
|
260
|
+
expect(call(0).method).toBe('DELETE');
|
|
261
|
+
expect(call(0).body).toEqual({
|
|
262
|
+
type: 'public',
|
|
263
|
+
entity: '',
|
|
264
|
+
permission: 'read'
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it.each([
|
|
269
|
+
[400, 'expired', 'Your Oceanum session has expired, sign in again.'],
|
|
270
|
+
[403, 'forbidden', /another organisation/],
|
|
271
|
+
[404, 'not-found', /no longer exists/],
|
|
272
|
+
[405, 'unsupported', /does not offer that operation/],
|
|
273
|
+
[413, 'too-large', /too large/],
|
|
274
|
+
[500, 'server', 'Oceanum.io returned an error (HTTP 500).']
|
|
275
|
+
])('maps HTTP %i to %s', async (status, kind, message) => {
|
|
276
|
+
const { client } = setup(() => json('Forbidden', status as number));
|
|
277
|
+
const error = await client.get(ID).catch(e => e);
|
|
278
|
+
expect(error).toBeInstanceOf(SpecStoreError);
|
|
279
|
+
expect(error.kind).toBe(kind);
|
|
280
|
+
expect(error.status).toBe(status);
|
|
281
|
+
expect(error.message).toMatch(message as string | RegExp);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('maps a network failure', async () => {
|
|
285
|
+
const { client } = setup(() =>
|
|
286
|
+
Promise.reject(new TypeError('Failed to fetch'))
|
|
287
|
+
);
|
|
288
|
+
await expect(client.create(body)).rejects.toMatchObject({
|
|
289
|
+
kind: 'network',
|
|
290
|
+
message: expect.stringMatching(/Could not reach Oceanum.io/)
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('rejects malformed responses', async () => {
|
|
295
|
+
const { client } = setup(() => json({ id: 'nope', name: 1 }));
|
|
296
|
+
await expect(client.get(ID)).rejects.toMatchObject({ kind: 'invalid' });
|
|
297
|
+
const list = setup(() => json({ detail: 'x' }));
|
|
298
|
+
await expect(list.client.list()).rejects.toMatchObject({
|
|
299
|
+
kind: 'invalid'
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('asks for a fresh token on every request', async () => {
|
|
304
|
+
const getAccessToken = jest.fn(async () => 'tok');
|
|
305
|
+
const client = new SpecStoreClient({
|
|
306
|
+
specsUrl: 'https://specs.example.com',
|
|
307
|
+
getAccessToken,
|
|
308
|
+
fetch: async () => json(record())
|
|
309
|
+
});
|
|
310
|
+
await client.get(ID);
|
|
311
|
+
await client.get(ID);
|
|
312
|
+
expect(getAccessToken).toHaveBeenCalledTimes(2);
|
|
313
|
+
});
|
|
314
|
+
});
|