@jupyterlab/testing 4.0.0-alpha.18 → 4.0.0-alpha.20
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/package.json +7 -6
- package/src/babel-config.ts +19 -0
- package/src/common.ts +248 -0
- package/src/index.ts +24 -0
- package/src/jest-config.ts +56 -0
- package/src/jest-file-mock.ts +6 -0
- package/src/jest-raw-loader.ts +14 -0
- package/src/jest-shim.ts +137 -0
- package/src/start_jupyter_server.ts +357 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jupyterlab/testing",
|
|
3
|
-
"version": "4.0.0-alpha.
|
|
3
|
+
"version": "4.0.0-alpha.20",
|
|
4
4
|
"description": "JupyterLab basic testing utilities.",
|
|
5
5
|
"homepage": "https://github.com/jupyterlab/jupyterlab",
|
|
6
6
|
"bugs": {
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"lib": "lib/"
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
|
-
"lib/**/*.{d.ts,js,js.map,json}"
|
|
21
|
+
"lib/**/*.{d.ts,js,js.map,json}",
|
|
22
|
+
"src/**/*.{ts,tsx}"
|
|
22
23
|
],
|
|
23
24
|
"scripts": {
|
|
24
25
|
"build": "tsc -b",
|
|
@@ -34,9 +35,9 @@
|
|
|
34
35
|
"dependencies": {
|
|
35
36
|
"@babel/core": "^7.10.2",
|
|
36
37
|
"@babel/preset-env": "^7.10.2",
|
|
37
|
-
"@jupyterlab/coreutils": "^6.0.0-alpha.
|
|
38
|
-
"@lumino/coreutils": "^2.0.0-
|
|
39
|
-
"@lumino/signaling": "^2.0.0-
|
|
38
|
+
"@jupyterlab/coreutils": "^6.0.0-alpha.20",
|
|
39
|
+
"@lumino/coreutils": "^2.0.0-rc.0",
|
|
40
|
+
"@lumino/signaling": "^2.0.0-rc.0",
|
|
40
41
|
"child_process": "~1.0.2",
|
|
41
42
|
"deepmerge": "^4.2.2",
|
|
42
43
|
"fs-extra": "^10.1.0",
|
|
@@ -53,7 +54,7 @@
|
|
|
53
54
|
"@types/node": "^18.11.18",
|
|
54
55
|
"@types/node-fetch": "^2.6.2",
|
|
55
56
|
"rimraf": "~3.0.0",
|
|
56
|
-
"typescript": "~
|
|
57
|
+
"typescript": "~5.0.0-beta"
|
|
57
58
|
},
|
|
58
59
|
"publishConfig": {
|
|
59
60
|
"access": "public"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const babelConfig = {
|
|
7
|
+
presets: [
|
|
8
|
+
[
|
|
9
|
+
'@babel/preset-env',
|
|
10
|
+
{
|
|
11
|
+
targets: {
|
|
12
|
+
node: 'current'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
]
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default babelConfig;
|
package/src/common.ts
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { simulate } from 'simulate-event';
|
|
7
|
+
|
|
8
|
+
import { PromiseDelegate } from '@lumino/coreutils';
|
|
9
|
+
|
|
10
|
+
import { ISignal, Signal } from '@lumino/signaling';
|
|
11
|
+
|
|
12
|
+
import { sleep } from '@jupyterlab/coreutils/lib/testutils';
|
|
13
|
+
|
|
14
|
+
export { sleep } from '@jupyterlab/coreutils/lib/testutils';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Test a single emission from a signal.
|
|
18
|
+
*
|
|
19
|
+
* @param signal - The signal we are listening to.
|
|
20
|
+
* @param find - An optional function to determine which emission to test,
|
|
21
|
+
* defaulting to the first emission.
|
|
22
|
+
* @param test - An optional function which contains the tests for the emission, and should throw an error if the tests fail.
|
|
23
|
+
* @param value - An optional value that the promise resolves to if the test is
|
|
24
|
+
* successful.
|
|
25
|
+
*
|
|
26
|
+
* @returns a promise that rejects if the function throws an error (e.g., if an
|
|
27
|
+
* expect test doesn't pass), and resolves otherwise.
|
|
28
|
+
*
|
|
29
|
+
* #### Notes
|
|
30
|
+
* The first emission for which the find function returns true will be tested in
|
|
31
|
+
* the test function. If the find function is not given, the first signal
|
|
32
|
+
* emission will be tested.
|
|
33
|
+
*
|
|
34
|
+
* You can test to see if any signal comes which matches a criteria by just
|
|
35
|
+
* giving a find function. You can test the very first signal by just giving a
|
|
36
|
+
* test function. And you can test the first signal matching the find criteria
|
|
37
|
+
* by giving both.
|
|
38
|
+
*
|
|
39
|
+
* The reason this function is asynchronous is so that the thing causing the
|
|
40
|
+
* signal emission (such as a websocket message) can be asynchronous.
|
|
41
|
+
*/
|
|
42
|
+
export async function testEmission<T, U, V>(
|
|
43
|
+
signal: ISignal<T, U>,
|
|
44
|
+
options: {
|
|
45
|
+
find?: (a: T, b: U) => boolean;
|
|
46
|
+
test?: (a: T, b: U) => void;
|
|
47
|
+
value?: V;
|
|
48
|
+
} = {}
|
|
49
|
+
): Promise<V | undefined> {
|
|
50
|
+
const done = new PromiseDelegate<V | undefined>();
|
|
51
|
+
const object = {};
|
|
52
|
+
signal.connect((sender: T, args: U) => {
|
|
53
|
+
if (options.find?.(sender, args) ?? true) {
|
|
54
|
+
try {
|
|
55
|
+
Signal.disconnectReceiver(object);
|
|
56
|
+
if (options.test) {
|
|
57
|
+
options.test(sender, args);
|
|
58
|
+
}
|
|
59
|
+
} catch (e) {
|
|
60
|
+
done.reject(e);
|
|
61
|
+
}
|
|
62
|
+
done.resolve(options.value ?? undefined);
|
|
63
|
+
}
|
|
64
|
+
}, object);
|
|
65
|
+
return done.promise;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Expect a failure on a promise with the given message.
|
|
70
|
+
*/
|
|
71
|
+
export async function expectFailure(
|
|
72
|
+
promise: Promise<any>,
|
|
73
|
+
message?: string
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
let called = false;
|
|
76
|
+
try {
|
|
77
|
+
await promise;
|
|
78
|
+
called = true;
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (message && err.message.indexOf(message) === -1) {
|
|
81
|
+
throw Error(`Error "${message}" not in: "${err.message}"`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (called) {
|
|
85
|
+
throw Error(`Failure was not triggered, message was: ${message}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Do something in the future ensuring total ordering with respect to promises.
|
|
91
|
+
*/
|
|
92
|
+
export async function doLater(cb: () => void): Promise<void> {
|
|
93
|
+
await Promise.resolve(void 0);
|
|
94
|
+
cb();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Convert a signal into an array of promises.
|
|
99
|
+
*
|
|
100
|
+
* @param signal - The signal we are listening to.
|
|
101
|
+
* @param numberValues - The number of values to store.
|
|
102
|
+
*
|
|
103
|
+
* @returns a Promise that resolves with an array of `(sender, args)` pairs.
|
|
104
|
+
*/
|
|
105
|
+
export function signalToPromises<T, U>(
|
|
106
|
+
signal: ISignal<T, U>,
|
|
107
|
+
numberValues: number
|
|
108
|
+
): Promise<[T, U]>[] {
|
|
109
|
+
const values: Promise<[T, U]>[] = new Array(numberValues);
|
|
110
|
+
const resolvers: Array<(value: [T, U]) => void> = new Array(numberValues);
|
|
111
|
+
|
|
112
|
+
for (let i = 0; i < numberValues; i++) {
|
|
113
|
+
values[i] = new Promise<[T, U]>(resolve => {
|
|
114
|
+
resolvers[i] = resolve;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let current = 0;
|
|
119
|
+
function slot(sender: T, args: U) {
|
|
120
|
+
resolvers[current++]([sender, args]);
|
|
121
|
+
if (current === numberValues) {
|
|
122
|
+
cleanup();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
signal.connect(slot);
|
|
126
|
+
|
|
127
|
+
function cleanup() {
|
|
128
|
+
signal.disconnect(slot);
|
|
129
|
+
}
|
|
130
|
+
return values;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Convert a signal into a promise for the first emitted value.
|
|
135
|
+
*
|
|
136
|
+
* @param signal - The signal we are listening to.
|
|
137
|
+
*
|
|
138
|
+
* @returns a Promise that resolves with a `(sender, args)` pair.
|
|
139
|
+
*/
|
|
140
|
+
export function signalToPromise<T, U>(signal: ISignal<T, U>): Promise<[T, U]> {
|
|
141
|
+
return signalToPromises(signal, 1)[0];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Test to see if a promise is fulfilled.
|
|
146
|
+
*
|
|
147
|
+
* @param delay - optional delay in milliseconds before checking
|
|
148
|
+
* @returns true if the promise is fulfilled (either resolved or rejected), and
|
|
149
|
+
* false if the promise is still pending.
|
|
150
|
+
*/
|
|
151
|
+
export async function isFulfilled<T>(
|
|
152
|
+
p: PromiseLike<T>,
|
|
153
|
+
delay = 0
|
|
154
|
+
): Promise<boolean> {
|
|
155
|
+
const x = Object.create(null);
|
|
156
|
+
let race: any;
|
|
157
|
+
if (delay > 0) {
|
|
158
|
+
race = sleep(delay, x);
|
|
159
|
+
} else {
|
|
160
|
+
race = x;
|
|
161
|
+
}
|
|
162
|
+
const result = await Promise.race([p, race]).catch(() => false);
|
|
163
|
+
return result !== x;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Convert a requestAnimationFrame into a Promise.
|
|
168
|
+
*/
|
|
169
|
+
export function framePromise(): Promise<void> {
|
|
170
|
+
const done = new PromiseDelegate<void>();
|
|
171
|
+
requestAnimationFrame(() => {
|
|
172
|
+
done.resolve(void 0);
|
|
173
|
+
});
|
|
174
|
+
return done.promise;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Wait for a dialog to be attached to an element.
|
|
179
|
+
*/
|
|
180
|
+
export async function waitForDialog(
|
|
181
|
+
host: HTMLElement = document.body,
|
|
182
|
+
timeout: number = 250
|
|
183
|
+
): Promise<void> {
|
|
184
|
+
const interval = 25;
|
|
185
|
+
const limit = Math.floor(timeout / interval);
|
|
186
|
+
for (let counter = 0; counter < limit; counter++) {
|
|
187
|
+
if (host.getElementsByClassName('jp-Dialog')[0]) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
await sleep(interval);
|
|
191
|
+
}
|
|
192
|
+
throw new Error('Dialog not found');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Accept a dialog after it is attached by accepting the default button.
|
|
197
|
+
*/
|
|
198
|
+
export async function acceptDialog(
|
|
199
|
+
host: HTMLElement = document.body,
|
|
200
|
+
timeout: number = 250
|
|
201
|
+
): Promise<void> {
|
|
202
|
+
await waitForDialog(host, timeout);
|
|
203
|
+
|
|
204
|
+
const node = host.getElementsByClassName('jp-Dialog')[0];
|
|
205
|
+
|
|
206
|
+
if (node) {
|
|
207
|
+
simulate(node as HTMLElement, 'keydown', { keyCode: 13 });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Click on the warning button in a dialog after it is attached
|
|
213
|
+
*/
|
|
214
|
+
export async function dangerDialog(
|
|
215
|
+
host: HTMLElement = document.body,
|
|
216
|
+
timeout: number = 250
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
await waitForDialog(host, timeout);
|
|
219
|
+
|
|
220
|
+
const node = host.getElementsByClassName('jp-mod-warn')[0];
|
|
221
|
+
|
|
222
|
+
if (node) {
|
|
223
|
+
simulate(node as HTMLElement, 'click', { button: 1 });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Dismiss a dialog after it is attached.
|
|
229
|
+
*
|
|
230
|
+
* #### Notes
|
|
231
|
+
* This promise will always resolve successfully.
|
|
232
|
+
*/
|
|
233
|
+
export async function dismissDialog(
|
|
234
|
+
host: HTMLElement = document.body,
|
|
235
|
+
timeout: number = 250
|
|
236
|
+
): Promise<void> {
|
|
237
|
+
try {
|
|
238
|
+
await waitForDialog(host, timeout);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
return; // Ignore calls to dismiss the dialog if there is no dialog.
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const node = host.getElementsByClassName('jp-Dialog')[0];
|
|
244
|
+
|
|
245
|
+
if (node) {
|
|
246
|
+
simulate(node as HTMLElement, 'keydown', { keyCode: 27 });
|
|
247
|
+
}
|
|
248
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/* -----------------------------------------------------------------------------
|
|
2
|
+
| Copyright (c) Jupyter Development Team.
|
|
3
|
+
| Distributed under the terms of the Modified BSD License.
|
|
4
|
+
|----------------------------------------------------------------------------*/
|
|
5
|
+
/**
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
* @module testing
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
testEmission,
|
|
12
|
+
expectFailure,
|
|
13
|
+
signalToPromises,
|
|
14
|
+
signalToPromise,
|
|
15
|
+
isFulfilled,
|
|
16
|
+
framePromise,
|
|
17
|
+
sleep,
|
|
18
|
+
waitForDialog,
|
|
19
|
+
acceptDialog,
|
|
20
|
+
dangerDialog,
|
|
21
|
+
dismissDialog
|
|
22
|
+
} from './common';
|
|
23
|
+
|
|
24
|
+
export { JupyterServer } from './start_jupyter_server';
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import path from 'path';
|
|
7
|
+
|
|
8
|
+
const esModules = [
|
|
9
|
+
'@codemirror',
|
|
10
|
+
'@jupyter/ydoc',
|
|
11
|
+
'lib0',
|
|
12
|
+
'nanoid',
|
|
13
|
+
'vscode-ws-jsonrpc',
|
|
14
|
+
'y-protocols',
|
|
15
|
+
'y-websocket',
|
|
16
|
+
'yjs'
|
|
17
|
+
].join('|');
|
|
18
|
+
|
|
19
|
+
module.exports = function (baseDir: string) {
|
|
20
|
+
return {
|
|
21
|
+
testEnvironment: 'jsdom',
|
|
22
|
+
moduleNameMapper: {
|
|
23
|
+
'\\.(css|less|sass|scss)$': 'identity-obj-proxy',
|
|
24
|
+
'\\.(gif|ttf|eot)$': '@jupyterlab/testing/lib/jest-file-mock.js'
|
|
25
|
+
},
|
|
26
|
+
transform: {
|
|
27
|
+
'\\.svg$': '@jupyterlab/testing/lib/jest-raw-loader.js',
|
|
28
|
+
// Extracted from https://github.com/kulshekhar/ts-jest/blob/v29.0.3/presets/index.js
|
|
29
|
+
'^.+\\.tsx?$': [
|
|
30
|
+
'ts-jest/legacy',
|
|
31
|
+
{
|
|
32
|
+
tsconfig: `./tsconfig.test.json`
|
|
33
|
+
}
|
|
34
|
+
],
|
|
35
|
+
'^.+\\.jsx?$': 'babel-jest'
|
|
36
|
+
},
|
|
37
|
+
testTimeout: 10000,
|
|
38
|
+
setupFiles: ['@jupyterlab/testing/lib/jest-shim.js'],
|
|
39
|
+
testPathIgnorePatterns: ['/lib/', '/node_modules/'],
|
|
40
|
+
moduleFileExtensions: [
|
|
41
|
+
'ts',
|
|
42
|
+
'tsx',
|
|
43
|
+
'js',
|
|
44
|
+
'jsx',
|
|
45
|
+
'json',
|
|
46
|
+
'node',
|
|
47
|
+
'mjs',
|
|
48
|
+
'cjs'
|
|
49
|
+
],
|
|
50
|
+
transformIgnorePatterns: [`/node_modules/(?!${esModules}).+`],
|
|
51
|
+
reporters: ['default', 'jest-junit', 'github-actions'],
|
|
52
|
+
coverageReporters: ['json', 'lcov', 'text', 'html'],
|
|
53
|
+
coverageDirectory: path.join(baseDir, 'coverage'),
|
|
54
|
+
testRegex: '/test/.*.spec.ts[x]?$'
|
|
55
|
+
};
|
|
56
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// jest-raw-loader compatibility with Jest version 28.
|
|
7
|
+
// See: https://github.com/keplersj/jest-raw-loader/pull/239
|
|
8
|
+
module.exports = {
|
|
9
|
+
process: (content: string): { code: string } => {
|
|
10
|
+
return {
|
|
11
|
+
code: 'module.exports = ' + JSON.stringify(content)
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
};
|
package/src/jest-shim.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Shims originally adapted from https://github.com/nteract/nteract/blob/47f8b038ff129543e42c39395129efc433eb4e90/scripts/test-shim.js
|
|
7
|
+
|
|
8
|
+
/* global globalThis */
|
|
9
|
+
|
|
10
|
+
globalThis.DragEvent = class DragEvent {} as any;
|
|
11
|
+
|
|
12
|
+
if (
|
|
13
|
+
typeof globalThis.TextDecoder === 'undefined' ||
|
|
14
|
+
typeof globalThis.TextEncoder === 'undefined'
|
|
15
|
+
) {
|
|
16
|
+
const util = require('util');
|
|
17
|
+
globalThis.TextDecoder = util.TextDecoder;
|
|
18
|
+
globalThis.TextEncoder = util.TextEncoder;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const fetchMod = ((window as any).fetch = require('node-fetch'));
|
|
22
|
+
(window as any).Request = fetchMod.Request;
|
|
23
|
+
(window as any).Headers = fetchMod.Headers;
|
|
24
|
+
(window as any).Response = fetchMod.Response;
|
|
25
|
+
|
|
26
|
+
globalThis.Image = (window as any).Image;
|
|
27
|
+
globalThis.Range = function Range() {
|
|
28
|
+
/* no-op */
|
|
29
|
+
} as any;
|
|
30
|
+
|
|
31
|
+
// HACK: Polyfill that allows CodeMirror to render in a JSDOM env.
|
|
32
|
+
const createContextualFragment = (html: string) => {
|
|
33
|
+
const div = document.createElement('div');
|
|
34
|
+
div.innerHTML = html;
|
|
35
|
+
return div.children[0]; // so hokey it's not even funny
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
globalThis.Range.prototype.createContextualFragment = (html: string) =>
|
|
39
|
+
createContextualFragment(html) as any;
|
|
40
|
+
|
|
41
|
+
(window as any).document.createRange = function createRange() {
|
|
42
|
+
return {
|
|
43
|
+
setEnd: () => {
|
|
44
|
+
/* no-op */
|
|
45
|
+
},
|
|
46
|
+
setStart: () => {
|
|
47
|
+
/* no-op */
|
|
48
|
+
},
|
|
49
|
+
getBoundingClientRect: () => ({ right: 0 }),
|
|
50
|
+
getClientRects: (): DOMRect[] => [],
|
|
51
|
+
createContextualFragment
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
// end CodeMirror HACK
|
|
55
|
+
|
|
56
|
+
window.focus = () => {
|
|
57
|
+
/* JSDom throws "Not Implemented" */
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
(window as any).document.elementFromPoint = (left: number, top: number) =>
|
|
61
|
+
document.body;
|
|
62
|
+
|
|
63
|
+
if (!window.hasOwnProperty('getSelection')) {
|
|
64
|
+
// Minimal getSelection() that supports a fake selection
|
|
65
|
+
(window as any).getSelection = function getSelection() {
|
|
66
|
+
return {
|
|
67
|
+
_selection: '',
|
|
68
|
+
selectAllChildren: () => {
|
|
69
|
+
this._selection = 'foo';
|
|
70
|
+
},
|
|
71
|
+
toString: () => {
|
|
72
|
+
const val = this._selection;
|
|
73
|
+
this._selection = '';
|
|
74
|
+
return val;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Used by xterm.js
|
|
81
|
+
(window as any).matchMedia = function (media: string): MediaQueryList {
|
|
82
|
+
return {
|
|
83
|
+
matches: false,
|
|
84
|
+
media,
|
|
85
|
+
onchange: () => {
|
|
86
|
+
/* empty */
|
|
87
|
+
},
|
|
88
|
+
addEventListener: () => {
|
|
89
|
+
/* empty */
|
|
90
|
+
},
|
|
91
|
+
removeEventListener: () => {
|
|
92
|
+
/* empty */
|
|
93
|
+
},
|
|
94
|
+
dispatchEvent: () => {
|
|
95
|
+
return true;
|
|
96
|
+
},
|
|
97
|
+
addListener: () => {
|
|
98
|
+
/* empty */
|
|
99
|
+
},
|
|
100
|
+
removeListener: () => {
|
|
101
|
+
/* empty */
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
process.on('unhandledRejection', (error, promise) => {
|
|
107
|
+
console.error('Unhandled promise rejection somewhere in tests');
|
|
108
|
+
if (error) {
|
|
109
|
+
console.error(error);
|
|
110
|
+
const stack = (error as any).stack;
|
|
111
|
+
if (stack) {
|
|
112
|
+
console.error(stack);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
promise.catch(err => console.error('promise rejected', err));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if ((window as any).requestIdleCallback === undefined) {
|
|
119
|
+
// On Safari, requestIdleCallback is not available, so we use replacement functions for `idleCallbacks`
|
|
120
|
+
// See: https://developer.mozilla.org/en-US/docs/Web/API/Background_Tasks_API#falling_back_to_settimeout
|
|
121
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
122
|
+
(window as any).requestIdleCallback = function (handler: Function) {
|
|
123
|
+
let startTime = Date.now();
|
|
124
|
+
return setTimeout(function () {
|
|
125
|
+
handler({
|
|
126
|
+
didTimeout: false,
|
|
127
|
+
timeRemaining: function () {
|
|
128
|
+
return Math.max(0, 50.0 - (Date.now() - startTime));
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}, 1);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
(window as any).cancelIdleCallback = function (id: number) {
|
|
135
|
+
clearTimeout(id);
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Jupyter Development Team.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/* eslint-disable camelcase */
|
|
7
|
+
// Copyright (c) Jupyter Development Team.
|
|
8
|
+
|
|
9
|
+
import { ChildProcess, spawn } from 'child_process';
|
|
10
|
+
import merge from 'deepmerge';
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
|
|
14
|
+
import { PageConfig, URLExt } from '@jupyterlab/coreutils';
|
|
15
|
+
import { JSONObject, PromiseDelegate, UUID } from '@lumino/coreutils';
|
|
16
|
+
import { sleep } from './common';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A Jupyter Server that runs as a child process.
|
|
20
|
+
*
|
|
21
|
+
* ### Notes
|
|
22
|
+
* There can only be one running server at a time, since
|
|
23
|
+
* PageConfig is global. Any classes that use `ServerConnection.ISettings`
|
|
24
|
+
* such as `ServiceManager` should be instantiated after the server
|
|
25
|
+
* has fully started so they pick up the right `PageConfig`.
|
|
26
|
+
*
|
|
27
|
+
* #### Example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* const server = new JupyterServer();
|
|
30
|
+
*
|
|
31
|
+
* beforeAll(async () => {
|
|
32
|
+
* await server.start();
|
|
33
|
+
* }, 30000);
|
|
34
|
+
*
|
|
35
|
+
* afterAll(async () => {
|
|
36
|
+
* await server.shutdown();
|
|
37
|
+
* });
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
*/
|
|
41
|
+
export class JupyterServer {
|
|
42
|
+
/**
|
|
43
|
+
* Start the server.
|
|
44
|
+
*
|
|
45
|
+
* @returns A promise that resolves with the url of the server
|
|
46
|
+
*
|
|
47
|
+
* @throws Error if another server is still running.
|
|
48
|
+
*/
|
|
49
|
+
async start(options: Partial<JupyterServer.IOptions> = {}): Promise<string> {
|
|
50
|
+
if (Private.child !== null) {
|
|
51
|
+
throw Error('Previous server was not disposed');
|
|
52
|
+
}
|
|
53
|
+
const startDelegate = new PromiseDelegate<string>();
|
|
54
|
+
|
|
55
|
+
const env = {
|
|
56
|
+
JUPYTER_CONFIG_DIR: Private.handleConfig(options),
|
|
57
|
+
JUPYTER_DATA_DIR: Private.handleData(options),
|
|
58
|
+
JUPYTER_RUNTIME_DIR: Private.mktempDir('jupyter_runtime'),
|
|
59
|
+
IPYTHONDIR: Private.mktempDir('ipython'),
|
|
60
|
+
PATH: process.env.PATH
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Create the child process for the server.
|
|
64
|
+
const child = (Private.child = spawn('jupyter-lab', { env }));
|
|
65
|
+
|
|
66
|
+
let started = false;
|
|
67
|
+
|
|
68
|
+
// Handle server output.
|
|
69
|
+
const handleOutput = (output: string) => {
|
|
70
|
+
console.debug(output);
|
|
71
|
+
|
|
72
|
+
if (started) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const baseUrl = Private.handleStartup(output);
|
|
76
|
+
if (baseUrl) {
|
|
77
|
+
console.debug('Jupyter Server started');
|
|
78
|
+
started = true;
|
|
79
|
+
void Private.connect(baseUrl, startDelegate);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
child.stdout.on('data', data => {
|
|
84
|
+
handleOutput(String(data));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
child.stderr.on('data', data => {
|
|
88
|
+
handleOutput(String(data));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const url = await startDelegate.promise;
|
|
92
|
+
return url;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Shut down the server, waiting for it to exit gracefully.
|
|
97
|
+
*/
|
|
98
|
+
async shutdown(): Promise<void> {
|
|
99
|
+
if (!Private.child) {
|
|
100
|
+
return Promise.resolve(void 0);
|
|
101
|
+
}
|
|
102
|
+
const stopDelegate = new PromiseDelegate<void>();
|
|
103
|
+
const child = Private.child;
|
|
104
|
+
child.on('exit', code => {
|
|
105
|
+
Private.child = null;
|
|
106
|
+
if (code !== null && code !== 0) {
|
|
107
|
+
stopDelegate.reject('child process exited with code ' + String(code));
|
|
108
|
+
} else {
|
|
109
|
+
stopDelegate.resolve(void 0);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
child.kill();
|
|
114
|
+
window.setTimeout(() => {
|
|
115
|
+
if (Private.child) {
|
|
116
|
+
Private.child.kill(9);
|
|
117
|
+
}
|
|
118
|
+
}, 3000);
|
|
119
|
+
|
|
120
|
+
return stopDelegate.promise;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A namespace for JupyterServer static values.
|
|
126
|
+
*/
|
|
127
|
+
export namespace JupyterServer {
|
|
128
|
+
/**
|
|
129
|
+
* Options used to create a new JupyterServer instance.
|
|
130
|
+
*/
|
|
131
|
+
export interface IOptions {
|
|
132
|
+
/**
|
|
133
|
+
* Additional Page Config values.
|
|
134
|
+
*/
|
|
135
|
+
pageConfig: { [name: string]: string };
|
|
136
|
+
/**
|
|
137
|
+
* Additional traitlet config data.
|
|
138
|
+
*/
|
|
139
|
+
configData: JSONObject;
|
|
140
|
+
/**
|
|
141
|
+
* Map of additional kernelspec names to kernel.json dictionaries
|
|
142
|
+
*/
|
|
143
|
+
additionalKernelSpecs: JSONObject;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A namespace for module private data.
|
|
149
|
+
*/
|
|
150
|
+
namespace Private {
|
|
151
|
+
export let child: ChildProcess | null = null;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Make a temporary directory.
|
|
155
|
+
*
|
|
156
|
+
* @param suffix the last portion of the dir naem.
|
|
157
|
+
*/
|
|
158
|
+
export function mktempDir(suffix: string): string {
|
|
159
|
+
const pathPrefix = '/tmp/jupyterServer';
|
|
160
|
+
if (!fs.existsSync(pathPrefix)) {
|
|
161
|
+
fs.mkdirSync(pathPrefix);
|
|
162
|
+
}
|
|
163
|
+
return fs.mkdtempSync(`${pathPrefix}/${suffix}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Install a spec in the data directory.
|
|
168
|
+
*/
|
|
169
|
+
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
|
170
|
+
export function installSpec(dataDir: string, name: string, spec: any): void {
|
|
171
|
+
const specDir = path.join(dataDir, 'kernels', name);
|
|
172
|
+
fs.mkdirSync(specDir, { recursive: true });
|
|
173
|
+
fs.writeFileSync(path.join(specDir, 'kernel.json'), JSON.stringify(spec));
|
|
174
|
+
PageConfig.setOption(`__kernelSpec_${name}`, JSON.stringify(spec));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Create and populate a notebook directory.
|
|
179
|
+
*/
|
|
180
|
+
function createNotebookDir(): string {
|
|
181
|
+
const nbDir = mktempDir('notebook');
|
|
182
|
+
fs.mkdirSync(path.join(nbDir, 'src'));
|
|
183
|
+
fs.writeFileSync(path.join(nbDir, 'src', 'temp.txt'), 'hello');
|
|
184
|
+
|
|
185
|
+
const roFilepath = path.join(nbDir, 'src', 'readonly-temp.txt');
|
|
186
|
+
fs.writeFileSync(roFilepath, 'hello from a ready only file', {
|
|
187
|
+
mode: 0o444
|
|
188
|
+
});
|
|
189
|
+
return nbDir;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Create a temporary directory for schemas.
|
|
194
|
+
*/
|
|
195
|
+
function createAppDir(): string {
|
|
196
|
+
const appDir = mktempDir('app');
|
|
197
|
+
|
|
198
|
+
// Add a fake static/index.html for `ensure_app_check()`
|
|
199
|
+
fs.mkdirSync(path.join(appDir, 'static'));
|
|
200
|
+
fs.writeFileSync(path.join(appDir, 'static', 'index.html'), 'foo');
|
|
201
|
+
|
|
202
|
+
// Add the apputils schema.
|
|
203
|
+
const schemaDir = path.join(appDir, 'schemas');
|
|
204
|
+
fs.mkdirSync(schemaDir, { recursive: true });
|
|
205
|
+
const extensionDir = path.join(
|
|
206
|
+
schemaDir,
|
|
207
|
+
'@jupyterlab',
|
|
208
|
+
'apputils-extension'
|
|
209
|
+
);
|
|
210
|
+
fs.mkdirSync(extensionDir, { recursive: true });
|
|
211
|
+
|
|
212
|
+
// Get schema content.
|
|
213
|
+
const schema = {
|
|
214
|
+
title: 'Theme',
|
|
215
|
+
description: 'Theme manager settings.',
|
|
216
|
+
properties: {
|
|
217
|
+
theme: {
|
|
218
|
+
type: 'string',
|
|
219
|
+
title: 'Selected Theme',
|
|
220
|
+
default: 'JupyterLab Light'
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
type: 'object'
|
|
224
|
+
};
|
|
225
|
+
fs.writeFileSync(
|
|
226
|
+
path.join(extensionDir, 'themes.json'),
|
|
227
|
+
JSON.stringify(schema)
|
|
228
|
+
);
|
|
229
|
+
return appDir;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Handle configuration.
|
|
234
|
+
*/
|
|
235
|
+
export function handleConfig(
|
|
236
|
+
options: Partial<JupyterServer.IOptions>
|
|
237
|
+
): string {
|
|
238
|
+
// Set up configuration.
|
|
239
|
+
const token = UUID.uuid4();
|
|
240
|
+
PageConfig.setOption('token', token);
|
|
241
|
+
PageConfig.setOption('terminalsAvailable', 'true');
|
|
242
|
+
|
|
243
|
+
if (options.pageConfig) {
|
|
244
|
+
Object.keys(options.pageConfig).forEach(key => {
|
|
245
|
+
PageConfig.setOption(key, options.pageConfig![key]);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const configDir = mktempDir('config');
|
|
250
|
+
const configPath = path.join(configDir, 'jupyter_server_config.json');
|
|
251
|
+
const root_dir = createNotebookDir();
|
|
252
|
+
|
|
253
|
+
const app_dir = createAppDir();
|
|
254
|
+
const user_settings_dir = mktempDir('settings');
|
|
255
|
+
const workspaces_dir = mktempDir('workspaces');
|
|
256
|
+
|
|
257
|
+
const configData = merge(
|
|
258
|
+
{
|
|
259
|
+
LabApp: {
|
|
260
|
+
user_settings_dir,
|
|
261
|
+
workspaces_dir,
|
|
262
|
+
app_dir,
|
|
263
|
+
open_browser: false,
|
|
264
|
+
log_level: 'DEBUG'
|
|
265
|
+
},
|
|
266
|
+
ServerApp: {
|
|
267
|
+
token,
|
|
268
|
+
root_dir,
|
|
269
|
+
log_level: 'DEBUG'
|
|
270
|
+
},
|
|
271
|
+
MultiKernelManager: {
|
|
272
|
+
default_kernel_name: 'echo'
|
|
273
|
+
},
|
|
274
|
+
KernelManager: {
|
|
275
|
+
shutdown_wait_time: 1.0
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
options.configData || {}
|
|
279
|
+
);
|
|
280
|
+
PageConfig.setOption('__configData', JSON.stringify(configData));
|
|
281
|
+
fs.writeFileSync(configPath, JSON.stringify(configData));
|
|
282
|
+
return configDir;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Handle data.
|
|
287
|
+
*/
|
|
288
|
+
export function handleData(options: Partial<JupyterServer.IOptions>): string {
|
|
289
|
+
const dataDir = mktempDir('data');
|
|
290
|
+
|
|
291
|
+
// Install custom specs.
|
|
292
|
+
installSpec(dataDir, 'echo', {
|
|
293
|
+
argv: [
|
|
294
|
+
'python',
|
|
295
|
+
'-m',
|
|
296
|
+
'jupyterlab.tests.echo_kernel',
|
|
297
|
+
'-f',
|
|
298
|
+
'{connection_file}'
|
|
299
|
+
],
|
|
300
|
+
display_name: 'Echo Kernel',
|
|
301
|
+
language: 'echo'
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
installSpec(dataDir, 'ipython', {
|
|
305
|
+
argv: ['python', '-m', 'ipykernel_launcher', '-f', '{connection_file}'],
|
|
306
|
+
display_name: 'Python 3',
|
|
307
|
+
language: 'python'
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
if (options.additionalKernelSpecs) {
|
|
311
|
+
Object.keys(options.additionalKernelSpecs).forEach(key => {
|
|
312
|
+
installSpec(dataDir, key, options.additionalKernelSpecs![key]);
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
return dataDir;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Handle process startup.
|
|
320
|
+
*
|
|
321
|
+
* @param output the process output
|
|
322
|
+
*
|
|
323
|
+
* @returns The baseUrl of the server or `null`.
|
|
324
|
+
*/
|
|
325
|
+
export function handleStartup(output: string): string | null {
|
|
326
|
+
let baseUrl: string | null = null;
|
|
327
|
+
output.split('\n').forEach(line => {
|
|
328
|
+
const baseUrlMatch = line.match(/(http:\/\/localhost:\d+\/[^?]*)/);
|
|
329
|
+
if (baseUrlMatch) {
|
|
330
|
+
baseUrl = baseUrlMatch[1].replace('/lab', '');
|
|
331
|
+
PageConfig.setOption('baseUrl', baseUrl);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
return baseUrl;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Connect to the Jupyter server.
|
|
339
|
+
*/
|
|
340
|
+
export async function connect(
|
|
341
|
+
baseUrl: string,
|
|
342
|
+
startDelegate: PromiseDelegate<string>
|
|
343
|
+
): Promise<void> {
|
|
344
|
+
// eslint-disable-next-line
|
|
345
|
+
while (true) {
|
|
346
|
+
try {
|
|
347
|
+
await fetch(URLExt.join(baseUrl, 'api'));
|
|
348
|
+
startDelegate.resolve(baseUrl);
|
|
349
|
+
return;
|
|
350
|
+
} catch (e) {
|
|
351
|
+
// spin until we can connect to the server.
|
|
352
|
+
console.warn(e);
|
|
353
|
+
await sleep(1000);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|