@jupyterlab/galata 5.0.0-alpha.2 → 5.0.0-alpha.21

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 (100) hide show
  1. package/README.md +192 -31
  2. package/lib/benchmarkReporter.d.ts +1 -0
  3. package/lib/benchmarkReporter.js +34 -39
  4. package/lib/benchmarkReporter.js.map +1 -1
  5. package/lib/benchmarkVLTpl.js +19 -5
  6. package/lib/benchmarkVLTpl.js.map +1 -1
  7. package/lib/contents.d.ts +5 -5
  8. package/lib/contents.js +32 -36
  9. package/lib/contents.js.map +1 -1
  10. package/lib/extension/global.d.ts +197 -0
  11. package/lib/extension/global.js +601 -0
  12. package/lib/extension/global.js.map +1 -0
  13. package/lib/extension/index.d.ts +6 -0
  14. package/lib/extension/index.js +27 -0
  15. package/lib/extension/index.js.map +1 -0
  16. package/lib/extension/tokens.d.ts +232 -0
  17. package/lib/extension/tokens.js +13 -0
  18. package/lib/extension/tokens.js.map +1 -0
  19. package/lib/extension.d.ts +223 -0
  20. package/lib/{global.js → extension.js} +1 -2
  21. package/lib/extension.js.map +1 -0
  22. package/lib/fixtures.d.ts +32 -10
  23. package/lib/fixtures.js +64 -17
  24. package/lib/fixtures.js.map +1 -1
  25. package/lib/galata.d.ts +140 -19
  26. package/lib/galata.js +272 -87
  27. package/lib/galata.js.map +1 -1
  28. package/lib/helpers/activity.d.ts +6 -0
  29. package/lib/helpers/activity.js +19 -5
  30. package/lib/helpers/activity.js.map +1 -1
  31. package/lib/helpers/debuggerpanel.d.ts +4 -0
  32. package/lib/helpers/debuggerpanel.js +16 -0
  33. package/lib/helpers/debuggerpanel.js.map +1 -1
  34. package/lib/helpers/filebrowser.js +8 -2
  35. package/lib/helpers/filebrowser.js.map +1 -1
  36. package/lib/helpers/index.d.ts +1 -0
  37. package/lib/helpers/index.js +6 -1
  38. package/lib/helpers/index.js.map +1 -1
  39. package/lib/helpers/kernel.js +7 -7
  40. package/lib/helpers/kernel.js.map +1 -1
  41. package/lib/helpers/menu.d.ts +7 -0
  42. package/lib/helpers/menu.js +17 -1
  43. package/lib/helpers/menu.js.map +1 -1
  44. package/lib/helpers/notebook.d.ts +6 -4
  45. package/lib/helpers/notebook.js +127 -31
  46. package/lib/helpers/notebook.js.map +1 -1
  47. package/lib/helpers/sidebar.d.ts +8 -1
  48. package/lib/helpers/sidebar.js +33 -15
  49. package/lib/helpers/sidebar.js.map +1 -1
  50. package/lib/helpers/statusbar.js +1 -1
  51. package/lib/helpers/statusbar.js.map +1 -1
  52. package/lib/helpers/style.d.ts +42 -0
  53. package/lib/helpers/style.js +50 -0
  54. package/lib/helpers/style.js.map +1 -0
  55. package/lib/helpers/theme.js +1 -1
  56. package/lib/helpers/theme.js.map +1 -1
  57. package/lib/index.d.ts +5 -2
  58. package/lib/index.js +12 -3
  59. package/lib/index.js.map +1 -1
  60. package/lib/jupyterlabpage.d.ts +29 -4
  61. package/lib/jupyterlabpage.js +38 -22
  62. package/lib/jupyterlabpage.js.map +1 -1
  63. package/lib/playwright-config.js +5 -1
  64. package/lib/playwright-config.js.map +1 -1
  65. package/lib/utils.js +5 -1
  66. package/lib/utils.js.map +1 -1
  67. package/package.json +31 -47
  68. package/src/benchmarkReporter.ts +756 -0
  69. package/src/benchmarkVLTpl.ts +91 -0
  70. package/src/contents.ts +472 -0
  71. package/src/extension.ts +281 -0
  72. package/src/fixtures.ts +387 -0
  73. package/src/galata.ts +1035 -0
  74. package/src/helpers/activity.ts +115 -0
  75. package/src/helpers/debuggerpanel.ts +159 -0
  76. package/src/helpers/filebrowser.ts +228 -0
  77. package/src/helpers/index.ts +15 -0
  78. package/src/helpers/kernel.ts +39 -0
  79. package/src/helpers/logconsole.ts +32 -0
  80. package/src/helpers/menu.ts +228 -0
  81. package/src/helpers/notebook.ts +1217 -0
  82. package/src/helpers/performance.ts +57 -0
  83. package/src/helpers/sidebar.ts +289 -0
  84. package/src/helpers/statusbar.ts +56 -0
  85. package/src/helpers/style.ts +100 -0
  86. package/src/helpers/theme.ts +50 -0
  87. package/src/index.ts +19 -0
  88. package/src/jupyterlabpage.ts +704 -0
  89. package/src/playwright-config.ts +26 -0
  90. package/src/utils.ts +264 -0
  91. package/src/vega-statistics.d.ts +15 -0
  92. package/lib/global.d.ts +0 -23
  93. package/lib/global.js.map +0 -1
  94. package/lib/inpage/tokens.d.ts +0 -135
  95. package/lib/inpage/tokens.js +0 -9
  96. package/lib/inpage/tokens.js.map +0 -1
  97. package/lib/lib-inpage/inpage.js +0 -3957
  98. package/lib/lib-inpage/inpage.js.map +0 -1
  99. package/style/index.css +0 -10
  100. package/style/index.js +0 -10
package/src/galata.ts ADDED
@@ -0,0 +1,1035 @@
1
+ /* eslint-disable camelcase */
2
+ // Copyright (c) Jupyter Development Team.
3
+ // Distributed under the terms of the Modified BSD License.
4
+
5
+ import type * as nbformat from '@jupyterlab/nbformat';
6
+ import type {
7
+ Session,
8
+ TerminalAPI,
9
+ User,
10
+ Workspace
11
+ } from '@jupyterlab/services';
12
+ import type { ISettingRegistry } from '@jupyterlab/settingregistry';
13
+ import type { JSONObject } from '@lumino/coreutils';
14
+ import { UUID } from '@lumino/coreutils';
15
+ import type { APIRequestContext, Browser, Page } from '@playwright/test';
16
+ import * as json5 from 'json5';
17
+ import { ContentsHelper } from './contents';
18
+ import { PerformanceHelper } from './helpers';
19
+ import {
20
+ IJupyterLabPage,
21
+ IJupyterLabPageFixture,
22
+ JupyterLabPage
23
+ } from './jupyterlabpage';
24
+
25
+ /**
26
+ * Galata namespace
27
+ */
28
+ export namespace galata {
29
+ /**
30
+ * Default user settings:
31
+ * - Deactivate codemirror cursor blinking to avoid noise in screenshots
32
+ */
33
+ export const DEFAULT_SETTINGS: Record<string, any> = {
34
+ '@jupyterlab/apputils-extension:notification': {
35
+ fetchNews: 'false'
36
+ },
37
+ '@jupyterlab/fileeditor-extension:plugin': {},
38
+ '@jupyterlab/notebook-extension:tracker': {},
39
+ '@jupyterlab/codemirror-extension:plugin': {
40
+ defaultConfig: {
41
+ cursorBlinkRate: 0
42
+ }
43
+ }
44
+ };
45
+
46
+ export const DEFAULT_DOCUMENTATION_STATE: Record<string, any> = {
47
+ data: {
48
+ 'layout-restorer:data': {
49
+ relativeSizes: [0, 1, 0]
50
+ }
51
+ }
52
+ };
53
+
54
+ /**
55
+ * Sidebar position
56
+ */
57
+ export type SidebarPosition = 'left' | 'right';
58
+
59
+ /**
60
+ * Default sidebar ids
61
+ */
62
+ export type DefaultSidebarTabId =
63
+ | 'filebrowser'
64
+ | 'jp-running-sessions'
65
+ | 'tab-manager'
66
+ | 'jp-property-inspector'
67
+ | 'table-of-contents'
68
+ | 'extensionmanager.main-view'
69
+ | 'jp-debugger-sidebar';
70
+
71
+ /**
72
+ * Sidebar id type
73
+ */
74
+ export type SidebarTabId = DefaultSidebarTabId | string;
75
+
76
+ /**
77
+ * Default toolbar item ids
78
+ */
79
+ export type DefaultNotebookToolbarItemId =
80
+ | 'save'
81
+ | 'insert'
82
+ | 'cut'
83
+ | 'copy'
84
+ | 'paste'
85
+ | 'run'
86
+ | 'interrupt'
87
+ | 'restart'
88
+ | 'restart-and-run'
89
+ | 'cellType'
90
+ | 'kernelName'
91
+ | 'kernelStatus';
92
+
93
+ /**
94
+ * Notebook toolbar item type
95
+ */
96
+ export type NotebookToolbarItemId = DefaultNotebookToolbarItemId | string;
97
+
98
+ /**
99
+ * Options to create a new page
100
+ */
101
+ export interface INewPageOption {
102
+ /**
103
+ * Application base URL
104
+ */
105
+ baseURL: string;
106
+ /**
107
+ * Playwright browser model
108
+ */
109
+ browser: Browser;
110
+ /**
111
+ * Callback that resolved when the application page is ready
112
+ */
113
+ waitForApplication: (page: Page, helpers: IJupyterLabPage) => Promise<void>;
114
+ /**
115
+ * Application URL path fragment
116
+ *
117
+ * Default: /lab
118
+ */
119
+ appPath?: string;
120
+ /**
121
+ * Whether to go to JupyterLab page within the fixture or not.
122
+ *
123
+ * Default: true
124
+ */
125
+ autoGoto?: boolean;
126
+ /**
127
+ * Mock Jupyter Server configuration in-memory or not.
128
+ *
129
+ * Default true
130
+ */
131
+ mockConfig?: boolean | Record<string, unknown>;
132
+ /**
133
+ * Mock JupyterLab state in-memory or not.
134
+ *
135
+ * Default galata.DEFAULT_SETTINGS
136
+ */
137
+ mockSettings?: boolean | Record<string, unknown>;
138
+ /**
139
+ * Mock JupyterLab settings in-memory or not.
140
+ *
141
+ * Default true
142
+ */
143
+ mockState?: boolean | Record<string, unknown>;
144
+ /**
145
+ * Mock JupyterLab user in-memory or not.
146
+ *
147
+ * Default true
148
+ */
149
+ mockUser?: boolean | Partial<User.IUser>;
150
+ /**
151
+ * Whether to store sessions in memory or not.
152
+ *
153
+ * Default true
154
+ */
155
+ mockSessions?: boolean;
156
+ /**
157
+ * Whether to store terminals in memory or not.
158
+ *
159
+ * Default true
160
+ */
161
+ mockTerminals?: boolean;
162
+ /**
163
+ * Create and delete a temporary path during the page existence
164
+ *
165
+ * Default ''
166
+ */
167
+ tmpPath?: string;
168
+ }
169
+
170
+ /**
171
+ * Add the Galata helpers to the page model
172
+ *
173
+ * @param page Playwright page model
174
+ * @param baseURL Application base URL
175
+ * @param waitForApplication Callback that resolved when the application page is ready
176
+ * @param appPath Application URL path fragment
177
+ * @returns Playwright page model with Galata helpers
178
+ */
179
+ export function addHelpersToPage(
180
+ page: Page,
181
+ baseURL: string,
182
+ waitForApplication: (page: Page, helpers: IJupyterLabPage) => Promise<void>,
183
+ appPath?: string
184
+ ): IJupyterLabPageFixture {
185
+ const jlabPage = new JupyterLabPage(
186
+ page,
187
+ baseURL,
188
+ waitForApplication,
189
+ appPath
190
+ );
191
+
192
+ const handler = {
193
+ get: function (obj: JupyterLabPage, prop: string) {
194
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
195
+ // @ts-ignore
196
+ return prop in obj ? obj[prop] : page[prop];
197
+ }
198
+ };
199
+
200
+ // Proxy playwright page object
201
+ return new Proxy(jlabPage, handler) as any;
202
+ }
203
+
204
+ export async function initTestPage(
205
+ appPath: string,
206
+ autoGoto: boolean,
207
+ baseURL: string,
208
+ mockConfig: boolean | Record<string, unknown>,
209
+ mockSettings: boolean | Record<string, unknown>,
210
+ mockState: boolean | Record<string, unknown>,
211
+ mockUser: boolean | Partial<User.IUser>,
212
+ page: Page,
213
+ sessions: Map<string, Session.IModel> | null,
214
+ terminals: Map<string, TerminalAPI.IModel> | null,
215
+ tmpPath: string,
216
+ waitForApplication: (page: Page, helpers: IJupyterLabPage) => Promise<void>
217
+ ): Promise<IJupyterLabPageFixture> {
218
+ // Hook the helpers
219
+ const jlabWithPage = addHelpersToPage(
220
+ page,
221
+ baseURL,
222
+ waitForApplication,
223
+ appPath
224
+ );
225
+
226
+ // Add server mocks
227
+ if (mockConfig) {
228
+ const config: Record<string, JSONObject> =
229
+ typeof mockConfig !== 'boolean' ? ({ ...mockConfig } as any) : {};
230
+ await Mock.mockConfig(page, config);
231
+ }
232
+
233
+ const settings: ISettingRegistry.IPlugin[] = [];
234
+ if (mockSettings) {
235
+ // Settings will be stored in-memory (after loading the initial version from disk)
236
+ await Mock.mockSettings(
237
+ page,
238
+ settings,
239
+ typeof mockSettings === 'boolean' ? {} : { ...mockSettings }
240
+ );
241
+ }
242
+
243
+ const workspace: Workspace.IWorkspace = {
244
+ data: {},
245
+ metadata: { id: 'default' }
246
+ };
247
+ if (mockState) {
248
+ if (typeof mockState !== 'boolean') {
249
+ workspace.data = { ...mockState } as any;
250
+ }
251
+ // State will be stored in-memory (after loading the initial version from disk)
252
+ await Mock.mockState(page, workspace);
253
+ }
254
+
255
+ let user: User.IUser = {
256
+ identity: {
257
+ username: UUID.uuid4(),
258
+ name: 'jovyan',
259
+ display_name: 'jovyan',
260
+ initials: 'JP',
261
+ color: 'var(--jp-collaborator-color1)'
262
+ },
263
+ permissions: {}
264
+ };
265
+ if (mockUser) {
266
+ if (typeof mockUser !== 'boolean') {
267
+ user = { ...mockUser } as any;
268
+ }
269
+ // The user will be stored in-memory
270
+ await Mock.mockUser(page, user);
271
+ }
272
+
273
+ // Add sessions and terminals trackers
274
+ if (sessions) {
275
+ await Mock.mockRunners(page, sessions, 'sessions');
276
+ }
277
+ if (terminals) {
278
+ await Mock.mockRunners(page, terminals, 'terminals');
279
+ }
280
+
281
+ if (autoGoto) {
282
+ // Load and initialize JupyterLab and goto test folder
283
+ await jlabWithPage.goto(`tree/${tmpPath}`);
284
+ }
285
+
286
+ return jlabWithPage;
287
+ }
288
+
289
+ /**
290
+ * Create a contents REST API helpers object
291
+ *
292
+ * @param request Playwright API request context
293
+ * @param page Playwright page model
294
+ * @returns Contents REST API helpers
295
+ */
296
+ export function newContentsHelper(
297
+ request?: APIRequestContext,
298
+ page?: Page
299
+ ): ContentsHelper {
300
+ return new ContentsHelper(request, page);
301
+ }
302
+
303
+ /**
304
+ * Create a page with Galata helpers for the given browser in a new context.
305
+ *
306
+ * @returns Playwright page model with Galata helpers
307
+ */
308
+ export async function newPage(options: INewPageOption): Promise<{
309
+ page: IJupyterLabPageFixture;
310
+ sessions: Map<string, Session.IModel> | null;
311
+ terminals: Map<string, TerminalAPI.IModel> | null;
312
+ }> {
313
+ const {
314
+ appPath,
315
+ autoGoto,
316
+ baseURL,
317
+ browser,
318
+ waitForApplication,
319
+ mockConfig,
320
+ mockSessions,
321
+ mockSettings,
322
+ mockState,
323
+ mockTerminals,
324
+ mockUser,
325
+ tmpPath
326
+ } = {
327
+ appPath: '/lab',
328
+ autoGoto: true,
329
+ mockConfig: true,
330
+ mockSessions: true,
331
+ mockSettings: galata.DEFAULT_SETTINGS,
332
+ mockState: true,
333
+ mockTerminals: true,
334
+ mockUser: true,
335
+ tmpPath: '',
336
+ ...options
337
+ };
338
+ const context = await browser.newContext();
339
+ const page = await context.newPage();
340
+
341
+ const sessions = mockSessions ? new Map<string, Session.IModel>() : null;
342
+ const terminals = mockTerminals
343
+ ? new Map<string, TerminalAPI.IModel>()
344
+ : null;
345
+
346
+ return {
347
+ page: await initTestPage(
348
+ appPath,
349
+ autoGoto,
350
+ baseURL,
351
+ mockConfig,
352
+ mockSettings,
353
+ mockState,
354
+ mockUser,
355
+ page,
356
+ sessions,
357
+ terminals,
358
+ tmpPath,
359
+ waitForApplication
360
+ ),
361
+ sessions,
362
+ terminals
363
+ };
364
+ }
365
+
366
+ /**
367
+ * Create a new performance helper
368
+ *
369
+ * @param page Playwright page model
370
+ * @returns Performance helper
371
+ */
372
+ export function newPerformanceHelper(page: Page): PerformanceHelper {
373
+ return new PerformanceHelper(page);
374
+ }
375
+
376
+ /**
377
+ * Regex to capture JupyterLab API call
378
+ */
379
+ export namespace Routes {
380
+ /**
381
+ * Config API
382
+ *
383
+ * The config section can be found in the named group `section`.
384
+ */
385
+ export const config = /.*\/api\/config\/(?<section>\w+)/;
386
+
387
+ /**
388
+ * Contents API
389
+ *
390
+ * The content path can be found in the named group `path`.
391
+ *
392
+ * The path will be prefixed by '/'.
393
+ * The path will be undefined for the root folder.
394
+ */
395
+ export const contents = /.*\/api\/contents(?<path>\/.+)?\?/;
396
+
397
+ /**
398
+ * Extensions API
399
+ */
400
+ export const extensions = /.*\/lab\/api\/extensions.*/;
401
+
402
+ /**
403
+ * Sessions API
404
+ *
405
+ * The session id can be found in the named group `id`.
406
+ *
407
+ * The id will be prefixed by '/'.
408
+ */
409
+ export const sessions = /.*\/api\/sessions(?<id>\/[@:-\w]+)?/;
410
+
411
+ /**
412
+ * Settings API
413
+ *
414
+ * The schema name can be found in the named group `id`.
415
+ *
416
+ * The id will be prefixed by '/'.
417
+ */
418
+ export const settings = /.*\/api\/settings(?<id>(\/[@:-\w]+)*)/;
419
+
420
+ /**
421
+ * Terminals API
422
+ *
423
+ * The terminal id can be found in the named group `id`.
424
+ *
425
+ * The id will be prefixed by '/'.
426
+ */
427
+ export const terminals = /.*\/api\/terminals(?<id>\/[@:-\w]+)?/;
428
+
429
+ /**
430
+ * Translations API
431
+ *
432
+ * The locale can be found in the named group `id`.
433
+ *
434
+ * The id will be prefixed by '/'.
435
+ */
436
+ export const translations = /.*\/api\/translations(?<id>\/[@:-\w]+)?/;
437
+
438
+ /**
439
+ * Workspaces API
440
+ *
441
+ * The space name can be found in the named group `id`.
442
+ *
443
+ * The id will be prefixed by '/'.
444
+ */
445
+ export const workspaces = /.*\/api\/workspaces(?<id>(\/[-\w]+)+)/;
446
+
447
+ /**
448
+ * User API
449
+ */
450
+ export const user = /.*\/api\/me.*/;
451
+ }
452
+
453
+ /**
454
+ * Notebook generation helpers
455
+ */
456
+ export namespace Notebook {
457
+ /**
458
+ * Generate a notebook with identical cells
459
+ *
460
+ * @param nCells Number of cells
461
+ * @param cellType Type of cells
462
+ * @param defaultInput Default input source
463
+ * @param defaultOutput Default outputs
464
+ * @returns The notebook
465
+ */
466
+ export function generateNotebook(
467
+ nCells: number = 0,
468
+ cellType: nbformat.CellType = 'code',
469
+ defaultInput: string[] = [],
470
+ defaultOutput: nbformat.IOutput[] = []
471
+ ): nbformat.INotebookContent {
472
+ const cells = new Array<nbformat.ICell>();
473
+ for (let i = 0; i < nCells; i++) {
474
+ const execution_count =
475
+ cellType === 'code'
476
+ ? defaultOutput.length > 0
477
+ ? i + 1
478
+ : null
479
+ : undefined;
480
+ const cell = makeCell({
481
+ cell_type: cellType,
482
+ source: [...defaultInput],
483
+ outputs: cellType === 'code' ? [...defaultOutput] : undefined,
484
+ execution_count
485
+ });
486
+ cells.push(cell);
487
+ }
488
+
489
+ return makeNotebook(cells);
490
+ }
491
+
492
+ /**
493
+ * Generate a cell object
494
+ *
495
+ * @param skeleton Cell description template
496
+ * @returns A cell
497
+ */
498
+ export function makeCell(
499
+ skeleton: Partial<nbformat.ICell>
500
+ ): nbformat.ICell {
501
+ switch (skeleton.cell_type ?? 'code') {
502
+ case 'code':
503
+ return {
504
+ cell_type: 'code',
505
+ execution_count: null,
506
+ metadata: {},
507
+ outputs: [],
508
+ source: [],
509
+ ...skeleton
510
+ };
511
+ default: {
512
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
513
+ const { execution_count, outputs, ...others } = skeleton;
514
+ return {
515
+ cell_type: 'markdown',
516
+ metadata: {},
517
+ source: [],
518
+ ...others
519
+ };
520
+ }
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Generate a notebook object from a cell list
526
+ *
527
+ * @param cells Notebook cells
528
+ * @returns Notebook
529
+ */
530
+ export function makeNotebook(
531
+ cells: Array<nbformat.ICell>
532
+ ): nbformat.INotebookContent {
533
+ return {
534
+ cells,
535
+ metadata: {
536
+ kernelspec: {
537
+ display_name: 'Python 3',
538
+ language: 'python',
539
+ name: 'python3'
540
+ },
541
+ language_info: {
542
+ codemirror_mode: {
543
+ name: 'ipython',
544
+ version: 3
545
+ },
546
+ file_extension: '.py',
547
+ mimetype: 'text/x-python',
548
+ name: 'python',
549
+ nbconvert_exporter: 'python',
550
+ pygments_lexer: 'ipython3',
551
+ version: '3.8.0'
552
+ }
553
+ },
554
+ nbformat: 4,
555
+ nbformat_minor: 4
556
+ };
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Mock methods
562
+ */
563
+ export namespace Mock {
564
+ /**
565
+ * Set last modified attributes one day ago one listing
566
+ * directory content.
567
+ *
568
+ * @param page Page model object
569
+ *
570
+ * #### Notes
571
+ * The goal is to freeze the file browser display
572
+ */
573
+ export async function freezeContentLastModified(page: Page): Promise<void> {
574
+ // Listen for closing connection (may happen when request are still being processed)
575
+ let isClosed = false;
576
+ const ctxt = page.context();
577
+ ctxt.once('close', () => {
578
+ isClosed = true;
579
+ });
580
+ ctxt.browser()?.once('disconnected', () => {
581
+ isClosed = true;
582
+ });
583
+
584
+ return page.route(Routes.contents, async (route, request) => {
585
+ switch (request.method()) {
586
+ case 'GET': {
587
+ // Proxy the GET request
588
+ const response = await ctxt.request.fetch(request);
589
+ if (!response.ok()) {
590
+ if (!page.isClosed() && !isClosed) {
591
+ return route.fulfill({
592
+ status: response.status(),
593
+ body: await response.text()
594
+ });
595
+ }
596
+ break;
597
+ }
598
+ const data = await response.json();
599
+ // Modify the last_modified values to be set one day before now.
600
+ if (
601
+ data['type'] === 'directory' &&
602
+ Array.isArray(data['content'])
603
+ ) {
604
+ const now = Date.now();
605
+ const aDayAgo = new Date(now - 24 * 3600 * 1000).toISOString();
606
+ for (const entry of data['content'] as any[]) {
607
+ // Mutate the list in-place
608
+ entry['last_modified'] = aDayAgo;
609
+ }
610
+ }
611
+
612
+ if (!page.isClosed() && !isClosed) {
613
+ return route.fulfill({
614
+ status: 200,
615
+ body: JSON.stringify(data),
616
+ contentType: 'application/json'
617
+ });
618
+ }
619
+ break;
620
+ }
621
+ default:
622
+ return route.continue();
623
+ }
624
+ });
625
+ }
626
+
627
+ /**
628
+ * Clear all wanted sessions or terminals.
629
+ *
630
+ * @param baseURL Application base URL
631
+ * @param runners Session or terminal ids to stop
632
+ * @param type Type of runner; session or terminal
633
+ * @param request API request context
634
+ * @returns Whether the runners were closed or not
635
+ */
636
+ export async function clearRunners(
637
+ request: APIRequestContext,
638
+ runners: string[],
639
+ type: 'sessions' | 'terminals'
640
+ ): Promise<boolean> {
641
+ const responses = await Promise.all(
642
+ [...new Set(runners)].map(id =>
643
+ request.fetch(`/api/${type}/${id}`, {
644
+ method: 'DELETE'
645
+ })
646
+ )
647
+ );
648
+ return responses.every(response => response.ok());
649
+ }
650
+
651
+ /**
652
+ * Mock config route.
653
+ *
654
+ * @param page Page model object
655
+ * @param config In-memory config
656
+ */
657
+ export function mockConfig(
658
+ page: Page,
659
+ config: Record<string, JSONObject>
660
+ ): Promise<void> {
661
+ return page.route(Routes.config, (route, request) => {
662
+ const section = Routes.config.exec(request.url())?.groups
663
+ ?.section as string;
664
+ switch (request.method()) {
665
+ case 'GET':
666
+ return route.fulfill({
667
+ status: 200,
668
+ body: JSON.stringify(config[section] ?? {})
669
+ });
670
+ case 'PATCH': {
671
+ const data = request.postDataJSON();
672
+ // FIXME jupyter-server does a recursive update
673
+ // We are not doing it here as @jupyterlab/services is actually not recursively
674
+ // updating the object.
675
+ config[section] = { ...(config[section] ?? {}), ...data };
676
+ return route.fulfill({
677
+ status: 200,
678
+ body: JSON.stringify(config[section])
679
+ });
680
+ }
681
+ case 'PUT': {
682
+ const data = request.postDataJSON();
683
+ config[section] = data;
684
+ return route.fulfill({ status: 204 });
685
+ }
686
+ default:
687
+ return route.continue();
688
+ }
689
+ });
690
+ }
691
+
692
+ /**
693
+ * Mock the runners API to display only those created during a test
694
+ *
695
+ * @param page Page model object
696
+ * @param runners Mapping of current test runners
697
+ * @param type Type of runner; session or terminal
698
+ */
699
+ export function mockRunners(
700
+ page: Page,
701
+ runners: Map<string, any>,
702
+ type: 'sessions' | 'terminals'
703
+ ): Promise<void> {
704
+ const routeRegex =
705
+ type === 'sessions' ? Routes.sessions : Routes.terminals;
706
+ // Listen for closing connection (may happen when request are still being processed)
707
+ let isClosed = false;
708
+ const ctxt = page.context();
709
+ ctxt.once('close', () => {
710
+ isClosed = true;
711
+ });
712
+ ctxt.browser()?.once('disconnected', () => {
713
+ isClosed = true;
714
+ });
715
+ return page.route(routeRegex, async (route, request) => {
716
+ switch (request.method()) {
717
+ case 'DELETE': {
718
+ // slice is used to remove the '/' prefix
719
+ const id = routeRegex.exec(request.url())?.groups?.id?.slice(1);
720
+
721
+ await route.continue();
722
+
723
+ if (id && runners.has(id)) {
724
+ runners.delete(id);
725
+ }
726
+
727
+ break;
728
+ }
729
+ case 'GET': {
730
+ // slice is used to remove the '/' prefix
731
+ const id = routeRegex.exec(request.url())?.groups?.id?.slice(1);
732
+
733
+ if (id) {
734
+ if (runners.has(id)) {
735
+ // Proxy the GET request
736
+ const response = await ctxt.request.fetch(request);
737
+ if (!response.ok()) {
738
+ if (!page.isClosed() && !isClosed) {
739
+ return route.fulfill({
740
+ status: response.status(),
741
+ body: await response.text()
742
+ });
743
+ }
744
+ break;
745
+ }
746
+ const data = await response.json();
747
+ // Update stored runners
748
+ runners.set(type === 'sessions' ? data.id : data.name, data);
749
+
750
+ if (!page.isClosed() && !isClosed) {
751
+ return route.fulfill({
752
+ status: 200,
753
+ body: JSON.stringify(data),
754
+ contentType: 'application/json'
755
+ });
756
+ }
757
+ break;
758
+ } else {
759
+ if (!page.isClosed() && !isClosed) {
760
+ return route.fulfill({
761
+ status: 404
762
+ });
763
+ }
764
+ break;
765
+ }
766
+ } else {
767
+ // Proxy the GET request
768
+ const response = await ctxt.request.fetch(request);
769
+ if (!response.ok()) {
770
+ if (!page.isClosed() && !isClosed) {
771
+ return route.fulfill({
772
+ status: response.status(),
773
+ body: await response.text()
774
+ });
775
+ }
776
+ break;
777
+ }
778
+ const data = (await response.json()) as any[];
779
+ const updated = new Set<string>();
780
+ data.forEach(item => {
781
+ const itemID: string =
782
+ type === 'sessions' ? item.id : item.name;
783
+ if (runners.has(itemID)) {
784
+ updated.add(itemID);
785
+ runners.set(itemID, item);
786
+ }
787
+ });
788
+
789
+ if (updated.size !== runners.size) {
790
+ for (const [runnerID] of runners) {
791
+ if (!updated.has(runnerID)) {
792
+ runners.delete(runnerID);
793
+ }
794
+ }
795
+ }
796
+
797
+ if (!page.isClosed() && !isClosed) {
798
+ return route.fulfill({
799
+ status: 200,
800
+ body: JSON.stringify([...runners.values()]),
801
+ contentType: 'application/json'
802
+ });
803
+ }
804
+ break;
805
+ }
806
+ }
807
+ case 'PATCH': {
808
+ // Proxy the PATCH request
809
+ const response = await ctxt.request.fetch(request);
810
+ if (!response.ok()) {
811
+ if (!page.isClosed() && !isClosed) {
812
+ return route.fulfill({
813
+ status: response.status(),
814
+ body: await response.text()
815
+ });
816
+ }
817
+ break;
818
+ }
819
+ const data = await response.json();
820
+ // Update stored runners
821
+ runners.set(type === 'sessions' ? data.id : data.name, data);
822
+
823
+ if (!page.isClosed() && !isClosed) {
824
+ return route.fulfill({
825
+ status: 200,
826
+ body: JSON.stringify(data),
827
+ contentType: 'application/json'
828
+ });
829
+ }
830
+ break;
831
+ }
832
+ case 'POST': {
833
+ // Proxy the POST request
834
+ const response = await ctxt.request.fetch(request);
835
+ if (!response.ok()) {
836
+ if (!page.isClosed() && !isClosed) {
837
+ return route.fulfill({
838
+ status: response.status(),
839
+ body: await response.text()
840
+ });
841
+ }
842
+ break;
843
+ }
844
+ const data = await response.json();
845
+ const id = type === 'sessions' ? data.id : data.name;
846
+ runners.set(id, data);
847
+ if (!page.isClosed() && !isClosed) {
848
+ return route.fulfill({
849
+ status: type === 'sessions' ? 201 : 200,
850
+ body: JSON.stringify(data),
851
+ contentType: 'application/json',
852
+ headers: response.headers as any
853
+ });
854
+ }
855
+ break;
856
+ }
857
+ default:
858
+ return route.continue();
859
+ }
860
+ });
861
+ }
862
+
863
+ /**
864
+ * Mock workspace route.
865
+ *
866
+ * @param page Page model object
867
+ * @param workspace In-memory workspace
868
+ */
869
+ export function mockState(
870
+ page: Page,
871
+ workspace: Workspace.IWorkspace
872
+ ): Promise<void> {
873
+ return page.route(Routes.workspaces, (route, request) => {
874
+ switch (request.method()) {
875
+ case 'GET':
876
+ return route.fulfill({
877
+ status: 200,
878
+ body: JSON.stringify(workspace)
879
+ });
880
+ case 'PUT': {
881
+ const data = request.postDataJSON();
882
+ workspace.data = { ...workspace.data, ...data.data };
883
+ workspace.metadata = { ...workspace.metadata, ...data.metadata };
884
+ return route.fulfill({ status: 204 });
885
+ }
886
+ default:
887
+ return route.continue();
888
+ }
889
+ });
890
+ }
891
+
892
+ /**
893
+ * Settings REST API endpoint
894
+ */
895
+ const settingsRegex = Routes.settings;
896
+
897
+ /**
898
+ * Mock settings route.
899
+ *
900
+ * @param page Page model object
901
+ * @param settings In-memory settings
902
+ * @param mockedSettings Test mocked settings
903
+ */
904
+ export function mockSettings(
905
+ page: Page,
906
+ settings: ISettingRegistry.IPlugin[],
907
+ mockedSettings: Record<string, any>
908
+ ): Promise<void> {
909
+ // Listen for closing connection (may happen when request are still being processed)
910
+ let isClosed = false;
911
+ const ctxt = page.context();
912
+ ctxt.once('close', () => {
913
+ isClosed = true;
914
+ });
915
+ ctxt.browser()?.once('disconnected', () => {
916
+ isClosed = true;
917
+ });
918
+
919
+ return page.route(settingsRegex, async (route, request) => {
920
+ switch (request.method()) {
921
+ case 'GET': {
922
+ // slice is used to remove the '/' prefix
923
+ const id = settingsRegex.exec(request.url())?.groups?.id.slice(1);
924
+
925
+ if (!id) {
926
+ // Get all settings
927
+ if (settings.length === 0) {
928
+ const response = await ctxt.request.fetch(request);
929
+ const loadedSettings = (await response.json())
930
+ .settings as ISettingRegistry.IPlugin[];
931
+
932
+ settings.push(
933
+ ...loadedSettings.map(plugin => {
934
+ const mocked = mockedSettings[plugin.id] ?? {};
935
+ return {
936
+ ...plugin,
937
+ raw: JSON.stringify(mocked),
938
+ settings: mocked
939
+ };
940
+ })
941
+ );
942
+ }
943
+ if (!page.isClosed() && !isClosed) {
944
+ return route.fulfill({
945
+ status: 200,
946
+ body: JSON.stringify({ settings })
947
+ });
948
+ }
949
+ break;
950
+ } else {
951
+ // Get specific settings
952
+ let pluginSettings = settings.find(setting => setting.id === id);
953
+ if (!pluginSettings) {
954
+ const response = await ctxt.request.fetch(request);
955
+ pluginSettings = await response.json();
956
+ if (pluginSettings) {
957
+ const mocked = mockedSettings[id] ?? {};
958
+ pluginSettings = {
959
+ ...pluginSettings,
960
+ raw: JSON.stringify(mocked),
961
+ settings: mocked
962
+ };
963
+ settings.push(pluginSettings);
964
+ }
965
+ }
966
+
967
+ if (!page.isClosed() && !isClosed) {
968
+ return route.fulfill({
969
+ status: 200,
970
+ body: JSON.stringify(pluginSettings)
971
+ });
972
+ }
973
+
974
+ break;
975
+ }
976
+ }
977
+
978
+ case 'PUT': {
979
+ // slice is used to remove the '/' prefix
980
+ const id = settingsRegex.exec(request.url())?.groups?.id?.slice(1);
981
+ if (!id) {
982
+ return route.abort('addressunreachable');
983
+ }
984
+ const pluginSettings = settings.find(setting => setting.id === id);
985
+ const data = request.postDataJSON();
986
+
987
+ if (pluginSettings) {
988
+ pluginSettings.raw = data.raw;
989
+ try {
990
+ pluginSettings.settings = json5.parse(pluginSettings.raw);
991
+ } catch (e) {
992
+ console.warn(
993
+ `Failed to read raw settings ${pluginSettings.raw}`
994
+ );
995
+ pluginSettings.settings = {};
996
+ }
997
+ } else {
998
+ settings.push({
999
+ id,
1000
+ ...data
1001
+ });
1002
+ }
1003
+ // Stop mocking if a new version is pushed
1004
+ delete mockedSettings[id];
1005
+ return route.fulfill({
1006
+ status: 204
1007
+ });
1008
+ }
1009
+ default:
1010
+ return route.continue();
1011
+ }
1012
+ });
1013
+ }
1014
+
1015
+ /**
1016
+ * Mock user route.
1017
+ *
1018
+ * @param page Page model object
1019
+ * @param user In-memory user
1020
+ */
1021
+ export function mockUser(page: Page, user: User.IUser): Promise<void> {
1022
+ return page.route(Routes.user, (route, request) => {
1023
+ switch (request.method()) {
1024
+ case 'GET':
1025
+ return route.fulfill({
1026
+ status: 200,
1027
+ body: JSON.stringify(user)
1028
+ });
1029
+ default:
1030
+ return route.continue();
1031
+ }
1032
+ });
1033
+ }
1034
+ }
1035
+ }