@jupyterlab/testing 4.0.0-alpha.19 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyterlab/testing",
3
- "version": "4.0.0-alpha.19",
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.19",
38
- "@lumino/coreutils": "^2.0.0-beta.0",
39
- "@lumino/signaling": "^2.0.0-beta.1",
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": "~4.7.3"
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,6 @@
1
+ /*
2
+ * Copyright (c) Jupyter Development Team.
3
+ * Distributed under the terms of the Modified BSD License.
4
+ */
5
+
6
+ module.exports = 'test-file-stub';
@@ -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
+ };
@@ -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
+ }