浏览代码

Backport PR #11128: Run comparative benchmark (#11441)

Frédéric Collonval 3 年之前
父节点
当前提交
af2d159f1e

+ 1 - 0
.gitignore

@@ -43,6 +43,7 @@ tests/**/.cache-loader
 galata/benchmark-results
 galata/playwright-report
 galata/test-results
+galata/*benchmark-expected.json
 
 package_json.tar.gz
 

+ 11 - 3
galata/README.md

@@ -386,7 +386,7 @@ test.describe.serial('Table of Contents', () => {
 
 ## Benchmark
 
-Benchmark of JupyterLab is done automatically using Playwright. The actions measured are:
+Benchmark of JupyterLab is done using Playwright. The actions measured are:
 
 - Opening a file
 - Switching from the file to a simple text file
@@ -395,6 +395,13 @@ Benchmark of JupyterLab is done automatically using Playwright. The actions meas
 
 Two files are tested: a notebook with many code cells and another with many markdown cells.
 
+The test is run on the CI by comparing the result in the commit at which a PR branch started and the PR branch head on
+the same CI job to ensure using the same hardware.  
+The benchmark job is triggered on:
+
+- Approved PR review
+- PR review that contains the sentence `please run benchmark`
+
 The tests are located in the subfolder [test/benchmark](./test/benchmark). And they can be
 executed with the following command:
 
@@ -464,13 +471,14 @@ By default, Galata will generate a text report in the form of `markdown` table a
       { outputFile: 'lab-benchmark.json',
         vegaLiteConfigFactory: (
           allData: Array<IReportRecord>, // All test records
-          comparison: 'snapshot' | 'project'// Logic of test comparisons:'snapshot' or 'project'.
+          comparison?: 'snapshot' | 'project'// Logic of test comparisons:'snapshot' or 'project' - default 'snapshot'.
         ) => {
           // Return a Vega-Lite graph configuration object
           return {};
         }
         textReportFactory: (
-          allData: Array<IReportRecord> // All test records
+          allData: Array<IReportRecord>, // All test records
+          comparison?: 'snapshot' | 'project'// Logic of test comparisons:'snapshot' or 'project' - default 'snapshot'.
         ) => {
           // Return a promise of with the tuple [report content, file extension]
           return Promise.resolve(['My report content', 'md']);

+ 2 - 1
galata/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@jupyterlab/galata",
-  "version": "4.0.3",
+  "version": "4.1.0",
   "description": "JupyterLab UI Testing Framework",
   "homepage": "https://github.com/jupyterlab/jupyterlab",
   "bugs": {
@@ -54,6 +54,7 @@
     "@lumino/algorithm": "^1.3.3",
     "@lumino/coreutils": "^1.5.3",
     "@playwright/test": "^1.16.2",
+    "@stdlib/stats": "^0.0.13",
     "canvas": "^2.6.1",
     "fs-extra": "^9.0.1",
     "http-server": "^13.0.0",

+ 5 - 1
galata/playwright-benchmark.config.js

@@ -18,7 +18,11 @@ module.exports = {
       { outputFile: 'lab-benchmark.json' }
     ]
   ],
-  use: { ...baseConfig.use, video: 'off' },
+  use: {
+    ...baseConfig.use,
+    video: 'off',
+    baseURL: process.env.TARGET_URL ?? 'http://127.0.0.1:8888'
+  },
   preserveOutput: 'failures-only',
   workers: 1
 };

+ 229 - 19
galata/src/benchmarkReporter.ts

@@ -8,6 +8,7 @@ import {
   TestCase,
   TestResult
 } from '@playwright/test/reporter';
+import { dists, meanpw, variancepn } from '@stdlib/stats/base';
 import * as canvas from 'canvas';
 import fs from 'fs';
 import path from 'path';
@@ -118,6 +119,141 @@ export namespace benchmark {
       body: Buffer.from(JSON.stringify(data))
     };
   }
+  /**
+   * Change between two distributions
+   */
+  export interface IDistributionChange {
+    /**
+     * Mean value
+     */
+    mean: number;
+    /**
+     * Spread around the mean value
+     */
+    confidenceInterval: number;
+  }
+
+  /**
+   * Statistical description of a distribution
+   */
+  interface IDistribution {
+    /**
+     * Mean
+     */
+    mean: number;
+    /**
+     * Variance
+     */
+    variance: number;
+  }
+
+  /**
+   * Quantifies the performance changes between two measures systems. Assumes we gathered
+   * n independent measurement from each, and calculated their means and variance.
+   *
+   * Based on the work by Tomas Kalibera and Richard Jones. See their paper
+   * "Quantifying Performance Changes with Effect Size Confidence Intervals", section 6.2,
+   * formula "Quantifying Performance Change".
+   *
+   * However, it simplifies it to only assume one level of benchmarks, not multiple levels.
+   * If you do have multiple levels, simply use the mean of the lower levels as your data,
+   * like they do in the paper.
+   *
+   * @param oldDistribution The old distribution description
+   * @param newDistribution The new distribution description
+   * @param n The number of samples from each system (must be equal)
+   * @param confidenceInterval The confidence interval for the results.
+   *  The default is a 95% confidence interval (95% of the time the true mean will be
+   *  between the resulting mean +- the resulting CI)
+   */
+  function performanceChange(
+    oldDistribution: IDistribution,
+    newDistribution: IDistribution,
+    n: number,
+    confidenceInterval: number = 0.95
+  ): IDistributionChange {
+    const { mean: yO, variance: sO } = oldDistribution;
+    const { mean: yN, variance: sN } = newDistribution;
+    const dof = n - 1;
+    const t = dists.t.quantile(1 - (1 - confidenceInterval) / 2, dof);
+    const oldFactor = sq(yO) - (sq(t) * sO) / n;
+    const newFactor = sq(yN) - (sq(t) * sN) / n;
+    const meanNum = yO * yN;
+    const ciNum = Math.sqrt(sq(yO * yN) - newFactor * oldFactor);
+    return {
+      mean: meanNum / oldFactor,
+      confidenceInterval: ciNum / oldFactor
+    };
+  }
+
+  /**
+   * Compute the performance change based on a number of old and new measurements.
+   *
+   * Based on the work by Tomas Kalibera and Richard Jones. See their paper
+   * "Quantifying Performance Changes with Effect Size Confidence Intervals", section 6.2,
+   * formula "Quantifying Performance Change".
+   *
+   * However, it simplifies it to only assume one level of benchmarks, not multiple levels.
+   * If you do have multiple levels, simply use the mean of the lower levels as your data,
+   * like they do in the paper.
+   *
+   * Note: The measurements must have the same length. As fallback, you could use the minimum
+   * size of the two measurement sets.
+   *
+   * @param oldMeasures The old measurements
+   * @param newMeasures The new measurements
+   * @param confidenceInterval The confidence interval for the results.
+   * @param minLength Fall back to the minimum length of the two arrays
+   */
+  export function distributionChange(
+    oldMeasures: number[],
+    newMeasures: number[],
+    confidenceInterval: number = 0.95,
+    minLength = false
+  ): IDistributionChange {
+    const n = oldMeasures.length;
+    if (!minLength && n !== newMeasures.length) {
+      throw new Error('Data have different length');
+    }
+    return performanceChange(
+      { mean: mean(...oldMeasures), variance: variance(...oldMeasures) },
+      { mean: mean(...newMeasures), variance: variance(...newMeasures) },
+      minLength ? Math.min(n, newMeasures.length) : n,
+      confidenceInterval
+    );
+  }
+
+  /**
+   * Format a performance changes like `between 20.1% slower and 30.3% faster`.
+   *
+   * @param distribution The distribution change
+   * @returns The formatted distribution change
+   */
+  export function formatChange(distribution: IDistributionChange): string {
+    const { mean, confidenceInterval } = distribution;
+    return `between ${formatPercent(
+      mean + confidenceInterval
+    )} and ${formatPercent(mean - confidenceInterval)}`;
+  }
+
+  function formatPercent(percent: number): string {
+    if (percent < 1) {
+      return `${((1 - percent) * 100).toFixed(1)}% faster`;
+    }
+    return `${((percent - 1) * 100).toFixed(1)}% slower`;
+  }
+
+  function sq(x: number): number {
+    return Math.pow(x, 2);
+  }
+
+  function mean(...x: number[]): number {
+    return meanpw(x.length, x, 1);
+  }
+
+  function variance(...x: number[]): number {
+    return variancepn(x.length, 1, x, 1);
+  }
 }
 
 /**
@@ -189,10 +325,11 @@ class BenchmarkReporter implements Reporter {
       comparison?: 'snapshot' | 'project';
       vegaLiteConfigFactory?: (
         allData: Array<IReportRecord>,
-        comparison: 'snapshot' | 'project'
+        comparison?: 'snapshot' | 'project'
       ) => JSONObject;
       textReportFactory?: (
-        allData: Array<IReportRecord>
+        allData: Array<IReportRecord>,
+        comparison?: 'snapshot' | 'project'
       ) => Promise<[string, string]>;
     } = {}
   ) {
@@ -361,15 +498,18 @@ class BenchmarkReporter implements Reporter {
    * by supplying another builder to constructor's option or override this
    * method on a sub-class.
    *
-   * @param {Array<IReportRecord>} allData: all test records.
-   * @return {Promise<[string, string]>} A list of two strings, the first one
+   * @param allData all test records.
+   * @param comparison logic of test comparisons:
+   * 'snapshot' or 'project'; default 'snapshot'.
+   * @return A list of two strings, the first one
    * is the content of report, the second one is the extension of report file.
    */
   protected async defaultTextReportFactory(
-    allData: Array<IReportRecord>
+    allData: Array<IReportRecord>,
+    comparison: 'snapshot' | 'project' = 'snapshot'
   ): Promise<[string, string]> {
     // Compute statistics
-    // - Groupby (test, browser, reference, file)
+    // - Groupby (test, browser, reference | project, file)
 
     const groups = new Map<
       string,
@@ -392,11 +532,13 @@ class BenchmarkReporter implements Reporter {
 
       const browserGroup = testGroup.get(d.browser)!;
 
-      if (!browserGroup.has(d.reference)) {
-        browserGroup.set(d.reference, new Map<string, number[]>());
+      const lastLevel = comparison === 'snapshot' ? d.reference : d.project;
+
+      if (!browserGroup.has(lastLevel)) {
+        browserGroup.set(lastLevel, new Map<string, number[]>());
       }
 
-      const fileGroup = browserGroup.get(d.reference)!;
+      const fileGroup = browserGroup.get(lastLevel)!;
 
       if (!fileGroup.has(d.file)) {
         fileGroup.set(d.file, new Array<number>());
@@ -405,17 +547,30 @@ class BenchmarkReporter implements Reporter {
       fileGroup.get(d.file)?.push(d.time);
     });
 
+    // If the reference | project lists has two items, the intervals will be compared.
+    const compare =
+      (groups.values().next().value.values().next().value as Map<
+        string,
+        Map<string, number[]>
+      >).size === 2;
+
     // - Create report
     const reportContent = new Array<string>(
       '## Benchmark report',
       '',
       'The execution time (in milliseconds) are grouped by test file, test type and browser.',
-      'For each case, the following values are computed: _min_ <- [_1st quartile_ - _median_ - _3rd quartile_] -> _max_.',
-      '',
-      '<details><summary>Results table</summary>',
-      ''
+      'For each case, the following values are computed: _min_ <- [_1st quartile_ - _median_ - _3rd quartile_] -> _max_.'
     );
 
+    if (compare) {
+      reportContent.push(
+        '',
+        'The mean relative comparison is computed with 95% confidence.'
+      );
+    }
+
+    reportContent.push('', '<details><summary>Results table</summary>', '');
+
     let header = '| Test file |';
     let nFiles = 0;
     for (const [
@@ -429,14 +584,31 @@ class BenchmarkReporter implements Reporter {
     reportContent.push(new Array(nFiles + 2).fill('|').join(' --- '));
     const filler = new Array(nFiles).fill('|').join(' ');
 
+    let changeReference = benchmark.DEFAULT_EXPECTED_REFERENCE;
+
     for (const [test, testGroup] of groups) {
       reportContent.push(`| **${test}** | ` + filler);
       for (const [browser, browserGroup] of testGroup) {
         reportContent.push(`| \`${browser}\` | ` + filler);
+        const actual = new Map<string, number[]>();
+        const expected = new Map<string, number[]>();
         for (const [reference, fileGroup] of browserGroup) {
           let line = `| ${reference} |`;
-          for (const [_, dataGroup] of fileGroup) {
+          for (const [filename, dataGroup] of fileGroup) {
             const [q1, median, q3] = vs.quartiles(dataGroup);
+
+            if (compare) {
+              if (
+                reference === benchmark.DEFAULT_REFERENCE ||
+                !actual.has(filename)
+              ) {
+                actual.set(filename, dataGroup);
+              } else {
+                changeReference = reference;
+                expected.set(filename, dataGroup);
+              }
+            }
+
             line += ` ${Math.min(
               ...dataGroup
             ).toFixed()} <- [${q1.toFixed()} - ${median.toFixed()} - ${q3.toFixed()}] -> ${Math.max(
@@ -446,8 +618,45 @@ class BenchmarkReporter implements Reporter {
 
           reportContent.push(line);
         }
+
+        if (compare) {
+          let line = `| Mean relative change |`;
+          for (const [filename, oldDistribution] of expected) {
+            const newDistribution = actual.get(filename)!;
+            try {
+              const delta = benchmark.distributionChange(
+                oldDistribution,
+                newDistribution,
+                0.95,
+                true
+              );
+
+              let unmatchWarning = '';
+              if (oldDistribution.length != newDistribution.length) {
+                unmatchWarning = `[:warning:](# "Reference size ${oldDistribution.length} != Actual size ${newDistribution.length}") `;
+              }
+
+              line += ` ${unmatchWarning}${((delta.mean - 1) * 100).toFixed(
+                1
+              )}% ± ${(delta.confidenceInterval * 100).toFixed(1)}% |`;
+            } catch (error) {
+              console.error(
+                `Reference has length ${oldDistribution.length} and new has ${newDistribution.length}.`
+              );
+              line += ` ${error} |`;
+            }
+          }
+
+          reportContent.push(line);
+        }
       }
     }
+    if (compare) {
+      reportContent.push(
+        '',
+        `Changes are computed with _${changeReference}_ as reference.`
+      );
+    }
     reportContent.push('', '</details>', '');
     const reportExtension = 'md';
     const reportContentString = reportContent.join('\n');
@@ -459,14 +668,15 @@ class BenchmarkReporter implements Reporter {
    * be used by to generate VegaLite configuration. Users can customize
    * the builder by supplying another builder to constructor's option or
    * override this method on a sub-class.
-   * @param {Array<IReportRecord>} allData: all test records.
-   * @param {('snapshot' | 'project')} comparison: logic of test comparisons:
-   * 'snapshot' or 'project'.
-   * @return {*}  {Record<string, any>} :  VegaLite configuration
+   *
+   * @param allData all test records.
+   * @param comparison logic of test comparisons:
+   * 'snapshot' or 'project'; default 'snapshot'.
+   * @return VegaLite configuration
    */
   protected defaultVegaLiteConfigFactory(
     allData: Array<IReportRecord>,
-    comparison: 'snapshot' | 'project'
+    comparison: 'snapshot' | 'project' = 'snapshot'
   ): Record<string, any> {
     const config = generateVegaLiteSpec(
       [...new Set(allData.map(d => d.test))],

+ 0 - 2
galata/src/benchmarkVLTpl.ts

@@ -29,13 +29,11 @@ function configPerFile(
         transform: [{ filter: `datum.test === '${t}'` }],
         facet: {
           column: { field: 'browser', type: 'nominal' }
-          // row: { field: 'test', type: 'nominal' }
         },
         spec: {
           mark: { type: 'boxplot', extent: 'min-max' },
           encoding: {
             y: { field: comparison, type: 'nominal' },
-            // color: { field: 'file', type: 'nominal', legend: null },
             x: {
               field: 'time',
               title: 'Time (ms)',

+ 23 - 413
galata/src/fixtures.ts

@@ -2,8 +2,7 @@
 // Copyright (c) Jupyter Development Team.
 // Distributed under the terms of the Modified BSD License.
 
-import { Session, TerminalAPI, Workspace } from '@jupyterlab/services';
-import { ISettingRegistry } from '@jupyterlab/settingregistry';
+import { Session, TerminalAPI } from '@jupyterlab/services';
 import {
   test as base,
   Page,
@@ -13,8 +12,6 @@ import {
   PlaywrightWorkerOptions,
   TestType
 } from '@playwright/test';
-import * as json5 from 'json5';
-import fetch from 'node-fetch';
 import { ContentsHelper } from './contents';
 import { galata } from './galata';
 import { IJupyterLabPage, IJupyterLabPageFixture } from './jupyterlabpage';
@@ -210,7 +207,11 @@ export const test: TestType<
     await use(sessions);
 
     if (sessions.size > 0) {
-      await Private.clearRunners(baseURL!, [...sessions.keys()], 'sessions');
+      await galata.Mock.clearRunners(
+        baseURL!,
+        [...sessions.keys()],
+        'sessions'
+      );
     }
   },
   /**
@@ -228,7 +229,11 @@ export const test: TestType<
     await use(terminals);
 
     if (terminals.size > 0) {
-      await Private.clearRunners(baseURL!, [...terminals.keys()], 'terminals');
+      await galata.Mock.clearRunners(
+        baseURL!,
+        [...terminals.keys()],
+        'terminals'
+      );
     }
   },
   /**
@@ -319,414 +324,19 @@ export const test: TestType<
     },
     use
   ) => {
-    // Hook the helpers
-    const jlabWithPage = galata.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 Private.mockSettings(
+    await use(
+      await galata.initTestPage(
+        appPath,
+        autoGoto,
+        baseURL!,
+        mockSettings,
+        mockState,
         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 Private.mockState(page, workspace);
-    }
-
-    // Add sessions and terminals trackers
-    if (sessions) {
-      await Private.mockRunners(page, sessions, 'sessions');
-    }
-    if (terminals) {
-      await Private.mockRunners(page, terminals, 'terminals');
-    }
-
-    if (autoGoto) {
-      // Load and initialize JupyterLab and goto test folder
-      await jlabWithPage.goto(`tree/${tmpPath}`);
-    }
-
-    await use(jlabWithPage);
-  }
-});
-
-/**
- * Private methods
- */
-namespace Private {
-  /**
-   * 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' })
+        sessions,
+        terminals,
+        tmpPath,
+        waitForApplication
       )
     );
-    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' ? galata.Routes.sessions : galata.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: request.headers(),
-                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: request.headers(),
-              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: request.headers(),
-            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: request.headers(),
-            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(galata.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 = galata.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: request.headers()
-              });
-              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: request.headers()
-              });
-              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();
-      }
-    });
-  }
-}
+});

+ 462 - 4
galata/src/galata.ts

@@ -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();
+        }
+      });
+    }
+  }
 }

+ 51 - 54
galata/test/benchmark/notebook.spec.ts

@@ -1,25 +1,11 @@
 // Copyright (c) Jupyter Development Team.
 // Distributed under the terms of the Modified BSD License.
 
-import { expect } from '@playwright/test';
-import { benchmark, galata, test } from '@jupyterlab/galata';
+import { expect, test } from '@playwright/test';
+import { benchmark, galata } from '@jupyterlab/galata';
 import path from 'path';
 
-test.use({
-  // Remove codemirror cursor
-  mockSettings: {
-    '@jupyterlab/fileeditor-extension:plugin': {
-      editorConfig: { cursorBlinkRate: -1 }
-    },
-    '@jupyterlab/notebook-extension:tracker': {
-      codeCellConfig: { cursorBlinkRate: -1 },
-      markdownCellConfig: { cursorBlinkRate: -1 },
-      rawCellConfig: { cursorBlinkRate: -1 }
-    }
-  },
-  tmpPath: 'test-performance-open'
-});
-
+const tmpPath = 'test-performance-open';
 const codeNotebook = 'large_code_notebook.ipynb';
 const mdNotebook = 'large_md_notebook.ipynb';
 const textFile = 'lorem_ipsum.txt';
@@ -35,15 +21,14 @@ const parameters = [].concat(
 
 test.describe('Benchmark', () => {
   // Generate the files for the benchmark
-  test.beforeAll(async ({ baseURL, tmpPath }) => {
-    const contents = galata.newContentsHelper(baseURL);
-
+  test.beforeAll(async ({ baseURL }) => {
+    const content = galata.newContentsHelper(baseURL);
     const codeContent = galata.Notebook.generateNotebook(300, 'code', [
       'for x in range(OUTPUT_LENGTH):\n',
       '    print(f"{PREFIX} {x}")'
     ]);
 
-    await contents.uploadContent(
+    await content.uploadContent(
       JSON.stringify(codeContent),
       'text',
       `${tmpPath}/${codeNotebook}`
@@ -78,7 +63,7 @@ test.describe('Benchmark', () => {
       '### This heading should be highlighted too'
     ]);
 
-    await contents.uploadContent(
+    await content.uploadContent(
       JSON.stringify(mdContent),
       'text',
       `${tmpPath}/${mdNotebook}`
@@ -86,13 +71,13 @@ test.describe('Benchmark', () => {
 
     const loremIpsum =
       'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin molestie suscipit libero non volutpat. Suspendisse et tincidunt metus. Proin laoreet magna rutrum egestas tristique. Proin vel neque sit amet lectus egestas pellentesque nec quis nisl. Quisque faucibus condimentum leo, quis euismod eros ultrices in. Vivamus maximus malesuada tempor. Aliquam maximus maximus elit, ac imperdiet tellus posuere nec. Sed at rutrum velit. Etiam et lectus convallis, sagittis nibh sit amet, gravida turpis. Nulla nec velit id est tristique iaculis.\n\nDonec vel finibus mauris, eu tristique justo. Pellentesque turpis lorem, lobortis eu tincidunt non, cursus sit amet ex. Vivamus eget ligula a leo vulputate egestas a eu felis. Donec sollicitudin maximus neque quis condimentum. Cras vestibulum nulla libero, sed semper velit faucibus ac. Phasellus et consequat risus. Sed suscipit ligula est. Etiam ultricies ac lacus sit amet cursus. Nam non leo vehicula, iaculis eros eu, consequat sapien. Ut quis odio quis augue pharetra porttitor sit amet eget nisl. Vestibulum magna eros, rutrum ac nisi non, lobortis varius ipsum. Proin luctus euismod arcu eget sollicitudin. Praesent nec erat gravida, tincidunt diam eget, tempor tortor.';
-    await contents.uploadContent(loremIpsum, 'text', `${tmpPath}/${textFile}`);
+    await content.uploadContent(loremIpsum, 'text', `${tmpPath}/${textFile}`);
   });
 
   // Remove benchmark files
-  test.afterAll(async ({ baseURL, tmpPath }) => {
-    const contents = galata.newContentsHelper(baseURL);
-    await contents.deleteDirectory(tmpPath);
+  test.afterAll(async ({ baseURL }) => {
+    const content = galata.newContentsHelper(baseURL);
+    await content.deleteDirectory(tmpPath);
   });
 
   // Loop on benchmark files nSamples times
@@ -103,9 +88,9 @@ test.describe('Benchmark', () => {
   //  - Close the file
   for (const [file, sample] of parameters) {
     test(`measure ${file} - ${sample + 1}`, async ({
+      baseURL,
       browserName,
-      page,
-      tmpPath
+      page
     }, testInfo) => {
       const attachmentCommon = {
         nSamples: benchmark.nSamples,
@@ -113,29 +98,28 @@ test.describe('Benchmark', () => {
         file: path.basename(file, '.ipynb'),
         project: testInfo.project.name
       };
+      const perf = new galata.newPerformanceHelper(page);
+
+      await page.goto(baseURL + '?reset');
+
+      await page.click('#filebrowser >> .jp-BreadCrumbs-home');
+      await page.dblclick(`#filebrowser >> text=${tmpPath}`);
 
-      const openTime = await page.performance.measure(async () => {
+      const openTime = await perf.measure(async () => {
         // Open the notebook and wait for the spinner
         await Promise.all([
           page.waitForSelector('[role="main"] >> .jp-SpinnerContent'),
-          page.notebook.openByPath(`${tmpPath}/${file}`)
+          page.dblclick(`#filebrowser >> text=${file}`)
         ]);
 
         // Wait for spinner to be hidden
         await page.waitForSelector('[role="main"] >> .jp-SpinnerContent', {
           state: 'hidden'
         });
-
-        // if (file === mdNotebook) {
-        //   // Wait for Latex rendering => consider as acceptable to require additional time
-        //   await page.waitForSelector('[role="main"] >> text=𝜌');
-        // }
-        // // Wait for kernel readiness => consider this is acceptable to take additional time
-        // await page.waitForSelector(`#jp-main-statusbar >> text=Idle`);
       });
 
       // Check the notebook is correctly opened
-      let panel = await page.activity.getPanel();
+      let panel = await page.$('[role="main"] >> .jp-NotebookPanel');
       // Get only the document node to avoid noise from kernel and debugger in the toolbar
       let document = await panel.$('.jp-Notebook');
       expect(await document.screenshot()).toMatchSnapshot(
@@ -151,20 +135,24 @@ test.describe('Benchmark', () => {
       );
 
       // Shutdown the kernel to be sure it does not get in our way (especially for the close action)
-      await page.kernel.shutdownAll();
+      await page.click('li[role="menuitem"]:has-text("Kernel")');
+      await page.click('ul[role="menu"] >> text=Shut Down All Kernels…');
+      await page.click(':nth-match(button:has-text("Shut Down All"), 3)');
 
       // Open text file
-      await page.filebrowser.revealFileInBrowser(`${tmpPath}/${textFile}`);
-
-      const fromTime = await page.performance.measure(async () => {
-        await page.filebrowser.open(textFile);
-        await page.waitForCondition(
-          async () => await page.activity.isTabActive(path.basename(textFile))
+      const fromTime = await perf.measure(async () => {
+        await page.dblclick(`#filebrowser >> text=${textFile}`);
+        await page.waitForSelector(
+          `div[role="main"] >> .lm-DockPanel-tabBar >> text=${path.basename(
+            textFile
+          )}`
         );
       });
 
-      let editorPanel = await page.activity.getPanel();
-      expect(await editorPanel.screenshot()).toMatchSnapshot('loremIpsum.png');
+      let editorPanel = page.locator(
+        'div[role="tabpanel"]:has-text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin mole")'
+      );
+      await expect(editorPanel).toBeVisible();
 
       testInfo.attachments.push(
         benchmark.addAttachment({
@@ -175,12 +163,14 @@ test.describe('Benchmark', () => {
       );
 
       // Switch back
-      const toTime = await page.performance.measure(async () => {
-        await page.notebook.openByPath(`${tmpPath}/${file}`);
+      const toTime = await perf.measure(async () => {
+        await page.click(
+          `div[role="main"] >> .lm-DockPanel-tabBar >> text=${file}`
+        );
       });
 
       // Check the notebook is correctly opened
-      panel = await page.activity.getPanel();
+      panel = await page.$('[role="main"] >> .jp-NotebookPanel');
       // Get only the document node to avoid noise from kernel and debugger in the toolbar
       document = await panel.$('.jp-Notebook');
       expect(await document.screenshot()).toMatchSnapshot(
@@ -196,13 +186,20 @@ test.describe('Benchmark', () => {
       );
 
       // Close notebook
-      const closeTime = await page.performance.measure(async () => {
+      await page.click('li[role="menuitem"]:has-text("File")');
+      const closeTime = await perf.measure(async () => {
+        await page.click('ul[role="menu"] >> text=Close Tab');
         // Revert changes so we don't measure saving
-        await page.notebook.close(true);
+        const dimissButton = page.locator('button:has-text("Discard")');
+        if (await dimissButton.isVisible({ timeout: 50 })) {
+          await dimissButton.click();
+        }
       });
 
-      editorPanel = await page.activity.getPanel();
-      expect(await editorPanel.screenshot()).toMatchSnapshot('loremIpsum.png');
+      editorPanel = page.locator(
+        'div[role="tabpanel"]:has-text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin mole")'
+      );
+      await expect(editorPanel).toBeVisible();
 
       testInfo.attachments.push(
         benchmark.addAttachment({

二进制
galata/test/benchmark/notebook.spec.ts-snapshots/loremIpsum-benchmark-linux.png


+ 1 - 1
scripts/ci_install.sh

@@ -13,7 +13,7 @@ if [ $OSTYPE == "Linux" ]; then
 fi
 
 # create jupyter base dir (needed for config retrieval)
-mkdir ~/.jupyter
+mkdir -p ~/.jupyter
 
 # Set up git config
 git config --global user.name foo

+ 330 - 1
yarn.lock

@@ -2761,6 +2761,335 @@
   dependencies:
     "@sinonjs/commons" "^1.7.0"
 
+"@stdlib/array@^0.0.x":
+  version "0.0.12"
+  resolved "https://registry.yarnpkg.com/@stdlib/array/-/array-0.0.12.tgz#12f40ab95bb36d424cdad991f29fc3cb491ee29e"
+  integrity sha512-nDksiuvRC1dSTHrf5yOGQmlRwAzSKV8MdFQwFSvLbZGGhi5Y4hExqea5HloLgNVouVs8lnAFi2oubSM4Mc7YAg==
+  dependencies:
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/blas" "^0.0.x"
+    "@stdlib/complex" "^0.0.x"
+    "@stdlib/constants" "^0.0.x"
+    "@stdlib/math" "^0.0.x"
+    "@stdlib/symbol" "^0.0.x"
+    "@stdlib/types" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+
+"@stdlib/assert@^0.0.x":
+  version "0.0.12"
+  resolved "https://registry.yarnpkg.com/@stdlib/assert/-/assert-0.0.12.tgz#1648c9016e5041291f55a6464abcc4069c5103ce"
+  integrity sha512-38FxFf+ZoQZbdc+m09UsWtaCmzd/2e7im0JOaaFYE7icmRfm+4KiE9BRvBT4tIn7ioLB2f9PsBicKjIsf+tY1w==
+  dependencies:
+    "@stdlib/array" "^0.0.x"
+    "@stdlib/cli" "^0.0.x"
+    "@stdlib/complex" "^0.0.x"
+    "@stdlib/constants" "^0.0.x"
+    "@stdlib/fs" "^0.0.x"
+    "@stdlib/math" "^0.0.x"
+    "@stdlib/ndarray" "^0.0.x"
+    "@stdlib/number" "^0.0.x"
+    "@stdlib/os" "^0.0.x"
+    "@stdlib/process" "^0.0.x"
+    "@stdlib/regexp" "^0.0.x"
+    "@stdlib/streams" "^0.0.x"
+    "@stdlib/string" "^0.0.x"
+    "@stdlib/symbol" "^0.0.x"
+    "@stdlib/types" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+
+"@stdlib/bigint@^0.0.x":
+  version "0.0.11"
+  resolved "https://registry.yarnpkg.com/@stdlib/bigint/-/bigint-0.0.11.tgz#c416a1d727001c55f4897e6424124199d638f2fd"
+  integrity sha512-uz0aYDLABAYyqxaCSHYbUt0yPkXYUCR7TrVvHN+UUD3i8FZ02ZKcLO+faKisDyxKEoSFTNtn3Ro8Ir5ebOlVXQ==
+  dependencies:
+    "@stdlib/utils" "^0.0.x"
+
+"@stdlib/blas@^0.0.x":
+  version "0.0.12"
+  resolved "https://registry.yarnpkg.com/@stdlib/blas/-/blas-0.0.12.tgz#7e93e42b4621fc6903bf63264f045047333536c2"
+  integrity sha512-nWY749bWceuoWQ7gz977blCwR7lyQ/rsIXVO4b600h+NFpeA2i/ea7MYC680utIbeu2cnDWHdglBPoK535VAzA==
+  dependencies:
+    "@stdlib/array" "^0.0.x"
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/math" "^0.0.x"
+    "@stdlib/number" "^0.0.x"
+    "@stdlib/types" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+
+"@stdlib/buffer@^0.0.x":
+  version "0.0.11"
+  resolved "https://registry.yarnpkg.com/@stdlib/buffer/-/buffer-0.0.11.tgz#6137b00845e6c905181cc7ebfae9f7e47c01b0ce"
+  integrity sha512-Jeie5eDDa1tVuRcuU+cBXI/oOXSmMxUUccZpqXzgYe0IO8QSNtNxv9mUTzJk/m5wH+lmLoDvNxzPpOH9TODjJg==
+  dependencies:
+    "@stdlib/array" "^0.0.x"
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/process" "^0.0.x"
+    "@stdlib/types" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+
+"@stdlib/cli@^0.0.x":
+  version "0.0.10"
+  resolved "https://registry.yarnpkg.com/@stdlib/cli/-/cli-0.0.10.tgz#28e2fbe6865d7f5cd15b7dc5846c99bd3b91674f"
+  integrity sha512-OITGaxG46kwK799+NuOd/+ccosJ9koVuQBC610DDJv0ZJf8mD7sbjGXrmue9C4EOh8MP7Vm/6HN14BojX8oTCg==
+  dependencies:
+    "@stdlib/utils" "^0.0.x"
+    minimist "^1.2.0"
+
+"@stdlib/complex@^0.0.x":
+  version "0.0.12"
+  resolved "https://registry.yarnpkg.com/@stdlib/complex/-/complex-0.0.12.tgz#3afbc190cd0a9b37fc7c6e508c3aa9fda9106944"
+  integrity sha512-UbZBdaUxT2G+lsTIrVlRZwx2IRY6GXnVILggeejsIVxHSuK+oTyapfetcAv0FJFLP+Rrr+ZzrN4b9G3hBw6NHA==
+  dependencies:
+    "@stdlib/array" "^0.0.x"
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/types" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+
+"@stdlib/constants@^0.0.x":
+  version "0.0.11"
+  resolved "https://registry.yarnpkg.com/@stdlib/constants/-/constants-0.0.11.tgz#78cd56d6c2982b30264843c3d75bde7125e90cd2"
+  integrity sha512-cWKy0L9hXHUQTvFzdPkTvZnn/5Pjv7H4UwY0WC1rLt+A5CxFDJKjvnIi9ypSzJS3CAiGl1ZaHCdadoqXhNdkUg==
+  dependencies:
+    "@stdlib/array" "^0.0.x"
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/number" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+
+"@stdlib/fs@^0.0.x":
+  version "0.0.12"
+  resolved "https://registry.yarnpkg.com/@stdlib/fs/-/fs-0.0.12.tgz#662365fd5846a51f075724b4f2888ae88441b70d"
+  integrity sha512-zcDLbt39EEM3M3wJW6luChS53B8T+TMJkjs2526UpKJ71O0/0adR57cI7PfCpkMd33d05uM7GM+leEj4eks4Cw==
+  dependencies:
+    "@stdlib/array" "^0.0.x"
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/cli" "^0.0.x"
+    "@stdlib/math" "^0.0.x"
+    "@stdlib/process" "^0.0.x"
+    "@stdlib/string" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+    debug "^2.6.9"
+
+"@stdlib/math@^0.0.x":
+  version "0.0.11"
+  resolved "https://registry.yarnpkg.com/@stdlib/math/-/math-0.0.11.tgz#eb6638bc03a20fbd6727dd5b977ee0170bda4649"
+  integrity sha512-qI78sR1QqGjHj8k/aAqkZ51Su2fyBvaR/jMKQqcB/ML8bpYpf+QGlGvTty5Qdru/wpqds4kVFOVbWGcNFIV2+Q==
+  dependencies:
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/constants" "^0.0.x"
+    "@stdlib/ndarray" "^0.0.x"
+    "@stdlib/number" "^0.0.x"
+    "@stdlib/strided" "^0.0.x"
+    "@stdlib/symbol" "^0.0.x"
+    "@stdlib/types" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+    debug "^2.6.9"
+
+"@stdlib/ndarray@^0.0.x":
+  version "0.0.13"
+  resolved "https://registry.yarnpkg.com/@stdlib/ndarray/-/ndarray-0.0.13.tgz#2e8fc645e10f56a645a0ab81598808c0e8f43b82"
+  integrity sha512-Z+U9KJP4U2HWrLtuAXSPvhNetAdqaNLMcliR6S/fz+VPlFDeymRK7omRFMgVQ+1zcAvIgKZGJxpLC3vjiPUYEw==
+  dependencies:
+    "@stdlib/array" "^0.0.x"
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/bigint" "^0.0.x"
+    "@stdlib/buffer" "^0.0.x"
+    "@stdlib/complex" "^0.0.x"
+    "@stdlib/constants" "^0.0.x"
+    "@stdlib/math" "^0.0.x"
+    "@stdlib/number" "^0.0.x"
+    "@stdlib/string" "^0.0.x"
+    "@stdlib/types" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+
+"@stdlib/nlp@^0.0.x":
+  version "0.0.11"
+  resolved "https://registry.yarnpkg.com/@stdlib/nlp/-/nlp-0.0.11.tgz#532ec0f7267b8d639e4c20c6de864e8de8a09054"
+  integrity sha512-D9avYWANm0Db2W7RpzdSdi5GxRYALGAqUrNnRnnKIO6sMEfr/DvONoAbWruda4QyvSC+0MJNwcEn7+PHhRwYhw==
+  dependencies:
+    "@stdlib/array" "^0.0.x"
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/math" "^0.0.x"
+    "@stdlib/random" "^0.0.x"
+    "@stdlib/string" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+
+"@stdlib/number@^0.0.x":
+  version "0.0.10"
+  resolved "https://registry.yarnpkg.com/@stdlib/number/-/number-0.0.10.tgz#4030ad8fc3fac19a9afb415c443cee6deea0e65c"
+  integrity sha512-RyfoP9MlnX4kccvg8qv7vYQPbLdzfS1Mnp/prGOoWhvMG3pyBwFAan34kwFb5IS/zHC3W5EmrgXCV2QWyLg/Kg==
+  dependencies:
+    "@stdlib/array" "^0.0.x"
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/constants" "^0.0.x"
+    "@stdlib/math" "^0.0.x"
+    "@stdlib/os" "^0.0.x"
+    "@stdlib/string" "^0.0.x"
+    "@stdlib/types" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+
+"@stdlib/os@^0.0.x":
+  version "0.0.12"
+  resolved "https://registry.yarnpkg.com/@stdlib/os/-/os-0.0.12.tgz#08bbf013c62a7153099fa9cbac086ca1349a4677"
+  integrity sha512-O7lklZ/9XEzoCmYvzjPh7jrFWkbpOSHGI71ve3dkSvBy5tyiSL3TtivfKsIC+9ZxuEJZ3d3lIjc9e+yz4HVbqQ==
+  dependencies:
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/cli" "^0.0.x"
+    "@stdlib/fs" "^0.0.x"
+    "@stdlib/process" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+
+"@stdlib/process@^0.0.x":
+  version "0.0.12"
+  resolved "https://registry.yarnpkg.com/@stdlib/process/-/process-0.0.12.tgz#123325079d89a32f4212f72fb694f8fe3614cf18"
+  integrity sha512-P0X0TMvkissBE1Wr877Avi2/AxmP7X5Toa6GatHbpJdDg6jQmN4SgPd+NZNp98YtZUyk478c8XSIzMr1krQ20g==
+  dependencies:
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/buffer" "^0.0.x"
+    "@stdlib/cli" "^0.0.x"
+    "@stdlib/fs" "^0.0.x"
+    "@stdlib/streams" "^0.0.x"
+    "@stdlib/string" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+
+"@stdlib/random@^0.0.x":
+  version "0.0.12"
+  resolved "https://registry.yarnpkg.com/@stdlib/random/-/random-0.0.12.tgz#e819c3abd602ed5559ba800dba751e49c633ff85"
+  integrity sha512-c5yND4Ahnm9Jx0I+jsKhn4Yrz10D53ALSrIe3PG1qIz3kNFcIPnmvCuNGd+3V4ch4Mbrez55Y8z/ZC5RJh4vJQ==
+  dependencies:
+    "@stdlib/array" "^0.0.x"
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/blas" "^0.0.x"
+    "@stdlib/buffer" "^0.0.x"
+    "@stdlib/cli" "^0.0.x"
+    "@stdlib/constants" "^0.0.x"
+    "@stdlib/fs" "^0.0.x"
+    "@stdlib/math" "^0.0.x"
+    "@stdlib/process" "^0.0.x"
+    "@stdlib/stats" "^0.0.x"
+    "@stdlib/streams" "^0.0.x"
+    "@stdlib/symbol" "^0.0.x"
+    "@stdlib/types" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+    debug "^2.6.9"
+    readable-stream "^2.1.4"
+
+"@stdlib/regexp@^0.0.x":
+  version "0.0.13"
+  resolved "https://registry.yarnpkg.com/@stdlib/regexp/-/regexp-0.0.13.tgz#80b98361dc7a441b47bc3fa964bb0c826759e971"
+  integrity sha512-3JT5ZIoq/1nXY+dY+QtkU8/m7oWDeekyItEEXMx9c/AOf0ph8fmvTUGMDNfUq0RetcznFe3b66kFz6Zt4XHviA==
+  dependencies:
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+
+"@stdlib/stats@^0.0.13", "@stdlib/stats@^0.0.x":
+  version "0.0.13"
+  resolved "https://registry.yarnpkg.com/@stdlib/stats/-/stats-0.0.13.tgz#87c973f385379d794707c7b5196a173dba8b07e1"
+  integrity sha512-hm+t32dKbx/L7+7WlQ1o4NDEzV0J4QSnwFBCsIMIAO8+VPxTZ4FxyNERl4oKlS3hZZe4AVKjoOVhBDtgEWrS4g==
+  dependencies:
+    "@stdlib/array" "^0.0.x"
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/blas" "^0.0.x"
+    "@stdlib/constants" "^0.0.x"
+    "@stdlib/math" "^0.0.x"
+    "@stdlib/ndarray" "^0.0.x"
+    "@stdlib/random" "^0.0.x"
+    "@stdlib/string" "^0.0.x"
+    "@stdlib/symbol" "^0.0.x"
+    "@stdlib/types" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+
+"@stdlib/streams@^0.0.x":
+  version "0.0.12"
+  resolved "https://registry.yarnpkg.com/@stdlib/streams/-/streams-0.0.12.tgz#07f5ceae5852590afad8e1cb7ce94174becc8739"
+  integrity sha512-YLUlXwjJNknHp92IkJUdvn5jEQjDckpawKhDLLCoxyh3h5V+w/8+61SH7TMTfKx5lBxKJ8vvtchZh90mIJOAjQ==
+  dependencies:
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/buffer" "^0.0.x"
+    "@stdlib/cli" "^0.0.x"
+    "@stdlib/fs" "^0.0.x"
+    "@stdlib/types" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+    debug "^2.6.9"
+    readable-stream "^2.1.4"
+
+"@stdlib/strided@^0.0.x":
+  version "0.0.12"
+  resolved "https://registry.yarnpkg.com/@stdlib/strided/-/strided-0.0.12.tgz#86ac48e660cb7f64a45cf07e80cbbfe58be21ae1"
+  integrity sha512-1NINP+Y7IJht34iri/bYLY7TVxrip51f6Z3qWxGHUCH33kvk5H5QqV+RsmFEGbbyoGtdeHrT2O+xA+7R2e3SNg==
+  dependencies:
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/math" "^0.0.x"
+    "@stdlib/ndarray" "^0.0.x"
+    "@stdlib/types" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+
+"@stdlib/string@^0.0.x":
+  version "0.0.13"
+  resolved "https://registry.yarnpkg.com/@stdlib/string/-/string-0.0.13.tgz#37457ca49e8d1dff0e523c68f5673c655c79eb2d"
+  integrity sha512-nGMHi7Qk9LBW0+Y+e3pSePQEBqyWH7+7DjFR1APcbsYccJE0p4aCaQdhPhx9Tp7j3uRGBmqPFek8wpcvIuC+CQ==
+  dependencies:
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/cli" "^0.0.x"
+    "@stdlib/constants" "^0.0.x"
+    "@stdlib/fs" "^0.0.x"
+    "@stdlib/math" "^0.0.x"
+    "@stdlib/nlp" "^0.0.x"
+    "@stdlib/process" "^0.0.x"
+    "@stdlib/regexp" "^0.0.x"
+    "@stdlib/streams" "^0.0.x"
+    "@stdlib/types" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+
+"@stdlib/symbol@^0.0.x":
+  version "0.0.12"
+  resolved "https://registry.yarnpkg.com/@stdlib/symbol/-/symbol-0.0.12.tgz#b9f396b0bf269c2985bb7fe99810a8e26d7288c3"
+  integrity sha512-2IDhpzWVGeLHgsvIsX12RXvf78r7xBkc4QLoRUv3k7Cp61BisR1Ym1p0Tq9PbxT8fknlvLToh9n5RpmESi2d4w==
+  dependencies:
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+
+"@stdlib/time@^0.0.x":
+  version "0.0.14"
+  resolved "https://registry.yarnpkg.com/@stdlib/time/-/time-0.0.14.tgz#ea6daa438b1d3b019b99f5091117ee4bcef55d60"
+  integrity sha512-1gMFCQTabMVIgww+k4g8HHHIhyy1tIlvwT8mC0BHW7Q7TzDAgobwL0bvor+lwvCb5LlDAvNQEpaRgVT99QWGeQ==
+  dependencies:
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/cli" "^0.0.x"
+    "@stdlib/constants" "^0.0.x"
+    "@stdlib/fs" "^0.0.x"
+    "@stdlib/math" "^0.0.x"
+    "@stdlib/string" "^0.0.x"
+    "@stdlib/utils" "^0.0.x"
+
+"@stdlib/types@^0.0.x":
+  version "0.0.13"
+  resolved "https://registry.yarnpkg.com/@stdlib/types/-/types-0.0.13.tgz#4cf4666286294a48c589a37c2b0b48c9076128f9"
+  integrity sha512-8aPkDtaJM/XZENqhoj7BYuwENLGyxz1xfLIcf2zct7kLZMi0rODzks3n65LEMIR9Rh3rFDXlwc35XvzEkTpmZQ==
+
+"@stdlib/utils@^0.0.x":
+  version "0.0.12"
+  resolved "https://registry.yarnpkg.com/@stdlib/utils/-/utils-0.0.12.tgz#670de5a7b253f04f11a4cba38f790e82393bcb46"
+  integrity sha512-+JhFpl6l7RSq/xGnbWRQ5dAL90h9ONj8MViqlb7teBZFtePZLMwoRA1wssypFcJ8SFMRWQn7lPmpYVUkGwRSOg==
+  dependencies:
+    "@stdlib/array" "^0.0.x"
+    "@stdlib/assert" "^0.0.x"
+    "@stdlib/blas" "^0.0.x"
+    "@stdlib/buffer" "^0.0.x"
+    "@stdlib/cli" "^0.0.x"
+    "@stdlib/constants" "^0.0.x"
+    "@stdlib/fs" "^0.0.x"
+    "@stdlib/math" "^0.0.x"
+    "@stdlib/os" "^0.0.x"
+    "@stdlib/process" "^0.0.x"
+    "@stdlib/random" "^0.0.x"
+    "@stdlib/regexp" "^0.0.x"
+    "@stdlib/streams" "^0.0.x"
+    "@stdlib/string" "^0.0.x"
+    "@stdlib/symbol" "^0.0.x"
+    "@stdlib/time" "^0.0.x"
+    "@stdlib/types" "^0.0.x"
+    debug "^2.6.9"
+
 "@storybook/addon-actions@6.0.20":
   version "6.0.20"
   resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-6.0.20.tgz#4fcf83ec7f961a90a0836633cdf18ecf37d9ed51"
@@ -14317,7 +14646,7 @@ read@1, read@~1.0.1:
   dependencies:
     mute-stream "~0.0.4"
 
-"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6:
+"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6:
   version "2.3.7"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
   integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==