|
@@ -3,8 +3,13 @@
|
|
|
// Distributed under the terms of the Modified BSD License.
|
|
|
|
|
|
import * as nbformat from '@jupyterlab/nbformat';
|
|
|
+import { Session, TerminalAPI, Workspace } from '@jupyterlab/services';
|
|
|
+import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
|
|
import { Browser, Page } from '@playwright/test';
|
|
|
+import * as json5 from 'json5';
|
|
|
+import fetch from 'node-fetch';
|
|
|
import { ContentsHelper } from './contents';
|
|
|
+import { PerformanceHelper } from './helpers';
|
|
|
import {
|
|
|
IJupyterLabPage,
|
|
|
IJupyterLabPageFixture,
|
|
@@ -108,6 +113,65 @@ export namespace galata {
|
|
|
return new Proxy(jlabPage, handler) as any;
|
|
|
}
|
|
|
|
|
|
+ export async function initTestPage(
|
|
|
+ appPath: string,
|
|
|
+ autoGoto: boolean,
|
|
|
+ baseURL: string,
|
|
|
+ mockSettings: boolean | Record<string, unknown>,
|
|
|
+ mockState: boolean | Record<string, unknown>,
|
|
|
+ page: Page,
|
|
|
+ sessions: Map<string, Session.IModel> | null,
|
|
|
+ terminals: Map<string, TerminalAPI.IModel> | null,
|
|
|
+ tmpPath: string,
|
|
|
+ waitForApplication: (page: Page, helpers: IJupyterLabPage) => Promise<void>
|
|
|
+ ): Promise<IJupyterLabPageFixture> {
|
|
|
+ // Hook the helpers
|
|
|
+ const jlabWithPage = addHelpersToPage(
|
|
|
+ page,
|
|
|
+ baseURL,
|
|
|
+ waitForApplication,
|
|
|
+ appPath
|
|
|
+ );
|
|
|
+
|
|
|
+ // Add server mocks
|
|
|
+ const settings: ISettingRegistry.IPlugin[] = [];
|
|
|
+ if (mockSettings) {
|
|
|
+ // Settings will be stored in-memory (after loading the initial version from disk)
|
|
|
+ await Mock.mockSettings(
|
|
|
+ page,
|
|
|
+ settings,
|
|
|
+ typeof mockSettings === 'boolean' ? {} : { ...mockSettings }
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ const workspace: Workspace.IWorkspace = {
|
|
|
+ data: {},
|
|
|
+ metadata: { id: 'default' }
|
|
|
+ };
|
|
|
+ if (mockState) {
|
|
|
+ if (typeof mockState !== 'boolean') {
|
|
|
+ workspace.data = { ...mockState } as any;
|
|
|
+ }
|
|
|
+ // State will be stored in-memory (after loading the initial version from disk)
|
|
|
+ await Mock.mockState(page, workspace);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Add sessions and terminals trackers
|
|
|
+ if (sessions) {
|
|
|
+ await Mock.mockRunners(page, sessions, 'sessions');
|
|
|
+ }
|
|
|
+ if (terminals) {
|
|
|
+ await Mock.mockRunners(page, terminals, 'terminals');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (autoGoto) {
|
|
|
+ // Load and initialize JupyterLab and goto test folder
|
|
|
+ await jlabWithPage.goto(`tree/${tmpPath}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ return jlabWithPage;
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Create a contents REST API helpers object
|
|
|
*
|
|
@@ -132,15 +196,42 @@ export namespace galata {
|
|
|
* @returns Playwright page model with Galata helpers
|
|
|
*/
|
|
|
export async function newPage(
|
|
|
- browser: Browser,
|
|
|
+ appPath: string,
|
|
|
+ autoGoto: boolean,
|
|
|
baseURL: string,
|
|
|
- waitForApplication: (page: Page, helpers: IJupyterLabPage) => Promise<void>,
|
|
|
- appPath: string = '/lab'
|
|
|
+ browser: Browser,
|
|
|
+ mockSettings: boolean | Record<string, unknown>,
|
|
|
+ mockState: boolean | Record<string, unknown>,
|
|
|
+ sessions: Map<string, Session.IModel> | null,
|
|
|
+ terminals: Map<string, TerminalAPI.IModel> | null,
|
|
|
+ tmpPath: string,
|
|
|
+ waitForApplication: (page: Page, helpers: IJupyterLabPage) => Promise<void>
|
|
|
): Promise<IJupyterLabPageFixture> {
|
|
|
const context = await browser.newContext();
|
|
|
const page = await context.newPage();
|
|
|
|
|
|
- return addHelpersToPage(page, baseURL, waitForApplication, appPath);
|
|
|
+ return initTestPage(
|
|
|
+ appPath,
|
|
|
+ autoGoto,
|
|
|
+ baseURL,
|
|
|
+ mockSettings,
|
|
|
+ mockState,
|
|
|
+ page,
|
|
|
+ sessions,
|
|
|
+ terminals,
|
|
|
+ tmpPath,
|
|
|
+ waitForApplication
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Create a new performance helper
|
|
|
+ *
|
|
|
+ * @param page Playwright page model
|
|
|
+ * @returns Performance helper
|
|
|
+ */
|
|
|
+ export function newPerformanceHelper(page: Page): PerformanceHelper {
|
|
|
+ return new PerformanceHelper(page);
|
|
|
}
|
|
|
|
|
|
/**
|
|
@@ -299,4 +390,371 @@ export namespace galata {
|
|
|
};
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Mock methods
|
|
|
+ */
|
|
|
+ export namespace Mock {
|
|
|
+ /**
|
|
|
+ * Clear all wanted sessions or terminals.
|
|
|
+ *
|
|
|
+ * @param baseURL Application base URL
|
|
|
+ * @param runners Session or terminal ids to stop
|
|
|
+ * @param type Type of runner; session or terminal
|
|
|
+ * @returns Whether the runners were closed or not
|
|
|
+ */
|
|
|
+ export async function clearRunners(
|
|
|
+ baseURL: string,
|
|
|
+ runners: string[],
|
|
|
+ type: 'sessions' | 'terminals'
|
|
|
+ ): Promise<boolean> {
|
|
|
+ const responses = await Promise.all(
|
|
|
+ [...new Set(runners)].map(id =>
|
|
|
+ fetch(`${baseURL}/api/${type}/${id}`, { method: 'DELETE' })
|
|
|
+ )
|
|
|
+ );
|
|
|
+ return responses.every(response => response.ok);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Mock the runners API to display only those created during a test
|
|
|
+ *
|
|
|
+ * @param page Page model object
|
|
|
+ * @param runners Mapping of current test runners
|
|
|
+ * @param type Type of runner; session or terminal
|
|
|
+ */
|
|
|
+ export function mockRunners(
|
|
|
+ page: Page,
|
|
|
+ runners: Map<string, any>,
|
|
|
+ type: 'sessions' | 'terminals'
|
|
|
+ ): Promise<void> {
|
|
|
+ const routeRegex =
|
|
|
+ type === 'sessions' ? Routes.sessions : Routes.terminals;
|
|
|
+ // Listen for closing connection (may happen when request are still being processed)
|
|
|
+ let isClosed = false;
|
|
|
+ const ctxt = page.context();
|
|
|
+ ctxt.on('close', () => {
|
|
|
+ isClosed = true;
|
|
|
+ });
|
|
|
+ ctxt.browser()?.on('disconnected', () => {
|
|
|
+ isClosed = true;
|
|
|
+ });
|
|
|
+ return page.route(routeRegex, async (route, request) => {
|
|
|
+ switch (request.method()) {
|
|
|
+ case 'DELETE': {
|
|
|
+ // slice is used to remove the '/' prefix
|
|
|
+ const id = routeRegex.exec(request.url())?.groups?.id?.slice(1);
|
|
|
+
|
|
|
+ await route.continue();
|
|
|
+
|
|
|
+ if (id && runners.has(id)) {
|
|
|
+ runners.delete(id);
|
|
|
+ }
|
|
|
+
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case 'GET': {
|
|
|
+ // slice is used to remove the '/' prefix
|
|
|
+ const id = routeRegex.exec(request.url())?.groups?.id?.slice(1);
|
|
|
+
|
|
|
+ if (id) {
|
|
|
+ if (runners.has(id)) {
|
|
|
+ // Proxy the GET request
|
|
|
+ const response = await fetch(request.url(), {
|
|
|
+ headers: await request.allHeaders(),
|
|
|
+ method: request.method()
|
|
|
+ });
|
|
|
+ if (!response.ok) {
|
|
|
+ if (!page.isClosed() && !isClosed) {
|
|
|
+ return route.fulfill({
|
|
|
+ status: response.status,
|
|
|
+ body: await response.text()
|
|
|
+ });
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ const data = await response.json();
|
|
|
+ // Update stored runners
|
|
|
+ runners.set(type === 'sessions' ? data.id : data.name, data);
|
|
|
+
|
|
|
+ if (!page.isClosed() && !isClosed) {
|
|
|
+ return route.fulfill({
|
|
|
+ status: 200,
|
|
|
+ body: JSON.stringify(data),
|
|
|
+ contentType: 'application/json'
|
|
|
+ });
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ } else {
|
|
|
+ if (!page.isClosed() && !isClosed) {
|
|
|
+ return route.fulfill({
|
|
|
+ status: 404
|
|
|
+ });
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // Proxy the GET request
|
|
|
+ const response = await fetch(request.url(), {
|
|
|
+ headers: await request.allHeaders(),
|
|
|
+ method: request.method()
|
|
|
+ });
|
|
|
+ if (!response.ok) {
|
|
|
+ if (!page.isClosed() && !isClosed) {
|
|
|
+ return route.fulfill({
|
|
|
+ status: response.status,
|
|
|
+ body: await response.text()
|
|
|
+ });
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ const data = (await response.json()) as any[];
|
|
|
+ const updated = new Set<string>();
|
|
|
+ data.forEach(item => {
|
|
|
+ const itemID: string =
|
|
|
+ type === 'sessions' ? item.id : item.name;
|
|
|
+ if (runners.has(itemID)) {
|
|
|
+ updated.add(itemID);
|
|
|
+ runners.set(itemID, item);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ if (updated.size !== runners.size) {
|
|
|
+ for (const [runnerID] of runners) {
|
|
|
+ if (!updated.has(runnerID)) {
|
|
|
+ runners.delete(runnerID);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!page.isClosed() && !isClosed) {
|
|
|
+ return route.fulfill({
|
|
|
+ status: 200,
|
|
|
+ body: JSON.stringify([...runners.values()]),
|
|
|
+ contentType: 'application/json'
|
|
|
+ });
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ case 'PATCH': {
|
|
|
+ // Proxy the PATCH request
|
|
|
+ const response = await fetch(request.url(), {
|
|
|
+ body: request.postDataBuffer()!,
|
|
|
+ headers: await request.allHeaders(),
|
|
|
+ method: request.method()
|
|
|
+ });
|
|
|
+ if (!response.ok) {
|
|
|
+ if (!page.isClosed() && !isClosed) {
|
|
|
+ return route.fulfill({
|
|
|
+ status: response.status,
|
|
|
+ body: await response.text()
|
|
|
+ });
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ const data = await response.json();
|
|
|
+ // Update stored runners
|
|
|
+ runners.set(type === 'sessions' ? data.id : data.name, data);
|
|
|
+
|
|
|
+ if (!page.isClosed() && !isClosed) {
|
|
|
+ return route.fulfill({
|
|
|
+ status: 200,
|
|
|
+ body: JSON.stringify(data),
|
|
|
+ contentType: 'application/json'
|
|
|
+ });
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ case 'POST': {
|
|
|
+ // Proxy the POST request
|
|
|
+ const response = await fetch(request.url(), {
|
|
|
+ body: request.postDataBuffer()!,
|
|
|
+ headers: await request.allHeaders(),
|
|
|
+ method: request.method()
|
|
|
+ });
|
|
|
+ if (!response.ok) {
|
|
|
+ if (!page.isClosed() && !isClosed) {
|
|
|
+ return route.fulfill({
|
|
|
+ status: response.status,
|
|
|
+ body: await response.text()
|
|
|
+ });
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ const data = await response.json();
|
|
|
+ const id = type === 'sessions' ? data.id : data.name;
|
|
|
+ runners.set(id, data);
|
|
|
+ if (!page.isClosed() && !isClosed) {
|
|
|
+ return route.fulfill({
|
|
|
+ status: type === 'sessions' ? 201 : 200,
|
|
|
+ body: JSON.stringify(data),
|
|
|
+ contentType: 'application/json',
|
|
|
+ headers: response.headers as any
|
|
|
+ });
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ default:
|
|
|
+ return route.continue();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Mock workspace route.
|
|
|
+ *
|
|
|
+ * @param page Page model object
|
|
|
+ * @param workspace In-memory workspace
|
|
|
+ */
|
|
|
+ export function mockState(
|
|
|
+ page: Page,
|
|
|
+ workspace: Workspace.IWorkspace
|
|
|
+ ): Promise<void> {
|
|
|
+ return page.route(Routes.workspaces, (route, request) => {
|
|
|
+ switch (request.method()) {
|
|
|
+ case 'GET':
|
|
|
+ return route.fulfill({
|
|
|
+ status: 200,
|
|
|
+ body: JSON.stringify(workspace)
|
|
|
+ });
|
|
|
+ case 'PUT': {
|
|
|
+ const data = request.postDataJSON();
|
|
|
+ workspace.data = { ...workspace.data, ...data.data };
|
|
|
+ workspace.metadata = { ...workspace.metadata, ...data.metadata };
|
|
|
+ return route.fulfill({ status: 204 });
|
|
|
+ }
|
|
|
+ default:
|
|
|
+ return route.continue();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Settings REST API endpoint
|
|
|
+ */
|
|
|
+ const settingsRegex = Routes.settings;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Mock settings route.
|
|
|
+ *
|
|
|
+ * @param page Page model object
|
|
|
+ * @param settings In-memory settings
|
|
|
+ * @param mockedSettings Test mocked settings
|
|
|
+ */
|
|
|
+ export function mockSettings(
|
|
|
+ page: Page,
|
|
|
+ settings: ISettingRegistry.IPlugin[],
|
|
|
+ mockedSettings: Record<string, any>
|
|
|
+ ): Promise<void> {
|
|
|
+ // Listen for closing connection (may happen when request are still being processed)
|
|
|
+ let isClosed = false;
|
|
|
+ const ctxt = page.context();
|
|
|
+ ctxt.on('close', () => {
|
|
|
+ isClosed = true;
|
|
|
+ });
|
|
|
+ ctxt.browser()?.on('disconnected', () => {
|
|
|
+ isClosed = true;
|
|
|
+ });
|
|
|
+
|
|
|
+ return page.route(settingsRegex, async (route, request) => {
|
|
|
+ switch (request.method()) {
|
|
|
+ case 'GET': {
|
|
|
+ // slice is used to remove the '/' prefix
|
|
|
+ const id = settingsRegex.exec(request.url())?.groups?.id.slice(1);
|
|
|
+
|
|
|
+ if (!id) {
|
|
|
+ // Get all settings
|
|
|
+ if (settings.length === 0) {
|
|
|
+ const response = await fetch(request.url(), {
|
|
|
+ headers: await request.allHeaders()
|
|
|
+ });
|
|
|
+ const loadedSettings = (await response.json())
|
|
|
+ .settings as ISettingRegistry.IPlugin[];
|
|
|
+
|
|
|
+ settings.push(
|
|
|
+ ...loadedSettings.map(plugin => {
|
|
|
+ const mocked = mockedSettings[plugin.id] ?? {};
|
|
|
+ return {
|
|
|
+ ...plugin,
|
|
|
+ raw: JSON.stringify(mocked),
|
|
|
+ settings: mocked
|
|
|
+ };
|
|
|
+ })
|
|
|
+ );
|
|
|
+ }
|
|
|
+ if (!page.isClosed() && !isClosed) {
|
|
|
+ return route.fulfill({
|
|
|
+ status: 200,
|
|
|
+ body: JSON.stringify({ settings })
|
|
|
+ });
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ } else {
|
|
|
+ // Get specific settings
|
|
|
+ let pluginSettings = settings.find(setting => setting.id === id);
|
|
|
+ if (!pluginSettings) {
|
|
|
+ const response = await fetch(request.url(), {
|
|
|
+ headers: await request.allHeaders()
|
|
|
+ });
|
|
|
+ pluginSettings = await response.json();
|
|
|
+ if (pluginSettings) {
|
|
|
+ const mocked = mockedSettings[id] ?? {};
|
|
|
+ pluginSettings = {
|
|
|
+ ...pluginSettings,
|
|
|
+ raw: JSON.stringify(mocked),
|
|
|
+ settings: mocked
|
|
|
+ };
|
|
|
+ settings.push(pluginSettings);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!page.isClosed() && !isClosed) {
|
|
|
+ return route.fulfill({
|
|
|
+ status: 200,
|
|
|
+ body: JSON.stringify(pluginSettings)
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ case 'PUT': {
|
|
|
+ // slice is used to remove the '/' prefix
|
|
|
+ const id = settingsRegex.exec(request.url())?.groups?.id?.slice(1);
|
|
|
+ if (!id) {
|
|
|
+ return route.abort('addressunreachable');
|
|
|
+ }
|
|
|
+ const pluginSettings = settings.find(setting => setting.id === id);
|
|
|
+ const data = request.postDataJSON();
|
|
|
+
|
|
|
+ if (pluginSettings) {
|
|
|
+ pluginSettings.raw = data.raw;
|
|
|
+ try {
|
|
|
+ pluginSettings.settings = json5.parse(pluginSettings.raw);
|
|
|
+ } catch (e) {
|
|
|
+ console.warn(
|
|
|
+ `Failed to read raw settings ${pluginSettings.raw}`
|
|
|
+ );
|
|
|
+ pluginSettings.settings = {};
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ settings.push({
|
|
|
+ id,
|
|
|
+ ...data
|
|
|
+ });
|
|
|
+ }
|
|
|
+ // Stop mocking if a new version is pushed
|
|
|
+ delete mockedSettings[id];
|
|
|
+ return route.fulfill({
|
|
|
+ status: 204
|
|
|
+ });
|
|
|
+ }
|
|
|
+ default:
|
|
|
+ return route.continue();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|