jupyterlabpage.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import type { ElementHandle, Page, Response } from '@playwright/test';
  4. import * as path from 'path';
  5. import { ContentsHelper } from './contents';
  6. import {
  7. ActivityHelper,
  8. FileBrowserHelper,
  9. KernelHelper,
  10. LogConsoleHelper,
  11. MenuHelper,
  12. NotebookHelper,
  13. PerformanceHelper,
  14. SidebarHelper,
  15. StatusBarHelper,
  16. ThemeHelper
  17. } from './helpers';
  18. import * as Utils from './utils';
  19. /**
  20. * JupyterLab page interface
  21. */
  22. export interface IJupyterLabPageFixture
  23. extends Omit<Page, 'goto'>,
  24. IJupyterLabPage {}
  25. /**
  26. * JupyterLab specific helpers interface
  27. */
  28. export interface IJupyterLabPage {
  29. /**
  30. * Application URL path fragment
  31. */
  32. readonly appPath: string;
  33. /**
  34. * JupyterLab activity helpers
  35. */
  36. readonly activity: ActivityHelper;
  37. /**
  38. * JupyterLab contents helpers
  39. */
  40. readonly contents: ContentsHelper;
  41. /**
  42. * JupyterLab filebrowser helpers
  43. */
  44. readonly filebrowser: FileBrowserHelper;
  45. /**
  46. * JupyterLab kernel helpers
  47. */
  48. readonly kernel: KernelHelper;
  49. /**
  50. * JupyterLab log console helpers
  51. */
  52. readonly logconsole: LogConsoleHelper;
  53. /**
  54. * JupyterLab menu helpers
  55. */
  56. readonly menu: MenuHelper;
  57. /**
  58. * JupyterLab notebook helpers
  59. */
  60. readonly notebook: NotebookHelper;
  61. /**
  62. * Webbrowser performance helpers
  63. */
  64. readonly performance: PerformanceHelper;
  65. /**
  66. * JupyterLab status bar helpers
  67. */
  68. readonly statusbar: StatusBarHelper;
  69. /**
  70. * JupyterLab sidebar helpers
  71. */
  72. readonly sidebar: SidebarHelper;
  73. /**
  74. * JupyterLab theme helpers
  75. */
  76. readonly theme: ThemeHelper;
  77. /**
  78. * Selector for launcher tab
  79. */
  80. readonly launcherSelector: string;
  81. /**
  82. * Getter for JupyterLab base URL
  83. */
  84. getBaseUrl(): Promise<string>;
  85. /**
  86. * Getter for JupyterLab page configuration property
  87. *
  88. * @param name Option name
  89. * @returns The property value
  90. */
  91. getOption(name: string): Promise<string>;
  92. /**
  93. * Getter for JupyterLab server root folder
  94. */
  95. getServerRoot(): Promise<string | null>;
  96. /**
  97. * Getter for JupyterLab token
  98. */
  99. getToken(): Promise<string>;
  100. /**
  101. * Returns the main resource response. In case of multiple redirects, the navigation will resolve with the response of the
  102. * last redirect.
  103. *
  104. * This overrides the standard Playwright `page.goto` method by waiting for:
  105. * - the application to be started (plugins are loaded)
  106. * - the galata in page code to be injected
  107. * - the splash screen to have disappeared
  108. * - the launcher to be visible
  109. *
  110. * `page.goto` will throw an error if:
  111. * - there's an SSL error (e.g. in case of self-signed certificates).
  112. * - target URL is invalid.
  113. * - the `timeout` is exceeded during navigation.
  114. * - the remote server does not respond or is unreachable.
  115. * - the main resource failed to load.
  116. *
  117. * `page.goto` will not throw an error when any valid HTTP status code is returned by the remote server, including 404 "Not
  118. * Found" and 500 "Internal Server Error". The status code for such responses can be retrieved by calling
  119. * [response.status()](https://playwright.dev/docs/api/class-response#response-status).
  120. *
  121. * > NOTE: `page.goto` either throws an error or returns a main resource response. The only exceptions are navigation to
  122. * `about:blank` or navigation to the same URL with a different hash, which would succeed and return `null`.
  123. * > NOTE: Headless mode doesn't support navigation to a PDF document. See the
  124. * [upstream issue](https://bugs.chromium.org/p/chromium/issues/detail?id=761295).
  125. *
  126. * Shortcut for main frame's [frame.goto(url[, options])](https://playwright.dev/docs/api/class-frame#frame-goto)
  127. * @param url URL to navigate page to. The url should include scheme, e.g. `https://`. When a `baseURL` via the context options was provided and the passed URL is a path, it gets merged via the
  128. * [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor.
  129. * @param options
  130. */
  131. goto(
  132. url?: string,
  133. options?: {
  134. /**
  135. * Referer header value. If provided it will take preference over the referer header value set by
  136. * [page.setExtraHTTPHeaders(headers)](https://playwright.dev/docs/api/class-page#page-set-extra-http-headers).
  137. */
  138. referer?: string;
  139. /**
  140. * Maximum operation time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be
  141. * changed by using the
  142. * [browserContext.setDefaultNavigationTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-navigation-timeout),
  143. * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout),
  144. * [page.setDefaultNavigationTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-navigation-timeout)
  145. * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods.
  146. */
  147. timeout?: number;
  148. /**
  149. * When to consider operation succeeded, defaults to `load`. Events can be either:
  150. * - `'domcontentloaded'` - consider operation to be finished when the `DOMContentLoaded` event is fired.
  151. * - `'load'` - consider operation to be finished when the `load` event is fired.
  152. * - `'networkidle'` - consider operation to be finished when there are no network connections for at least `500` ms.
  153. */
  154. waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
  155. }
  156. ): Promise<Response | null>;
  157. /**
  158. * Whether JupyterLab is in simple mode or not
  159. */
  160. isInSimpleMode(): Promise<boolean>;
  161. /**
  162. * Reset the User Interface
  163. */
  164. resetUI(): Promise<void>;
  165. /**
  166. * Set JupyterLab simple mode
  167. *
  168. * @param simple Simple mode value
  169. * @returns Whether this operation succeeds or not
  170. */
  171. setSimpleMode(simple: boolean): Promise<boolean>;
  172. /**
  173. * Wait for a condition to be fulfilled
  174. *
  175. * @param condition Condition to fulfill
  176. * @param timeout Maximal time to wait for the condition to be true
  177. */
  178. waitForCondition(
  179. condition: () => Promise<boolean> | boolean,
  180. timeout?: number
  181. ): Promise<void>;
  182. /**
  183. * Wait for an element to emit 'transitionend' event.
  184. *
  185. * @param element Element or selector to watch
  186. */
  187. waitForTransition(element: ElementHandle<Element> | string): Promise<void>;
  188. /**
  189. * Factory for active activity tab xpath
  190. *
  191. * @returns The selector
  192. */
  193. xpBuildActiveActivityTabSelector(): string;
  194. /**
  195. * Factory for activity panel xpath by id
  196. * @param id Panel id
  197. * @returns The selector
  198. */
  199. xpBuildActivityPanelSelector(id: string): string;
  200. /**
  201. * Factory for activity tab xpath by name
  202. *
  203. * @param name Activity name
  204. * @returns The selector
  205. */
  206. xpBuildActivityTabSelector(name: string): string;
  207. /**
  208. * Factory for element containing a given class xpath
  209. *
  210. * @param className Class name
  211. * @returns The selector
  212. */
  213. xpContainsClass(className: string): string;
  214. }
  215. /**
  216. * Wrapper class around Playwright Page object.
  217. */
  218. export class JupyterLabPage implements IJupyterLabPage {
  219. /**
  220. * Page object model for JupyterLab
  221. *
  222. * @param page Playwright page object
  223. * @param baseURL Server base URL
  224. * @param waitForApplication Callback that resolved when the application page is ready
  225. * @param appPath Application URL path fragment
  226. */
  227. constructor(
  228. readonly page: Page,
  229. readonly baseURL: string,
  230. waitForApplication: (page: Page, helpers: IJupyterLabPage) => Promise<void>,
  231. readonly appPath: string = '/lab'
  232. ) {
  233. this.waitIsReady = waitForApplication;
  234. this.activity = new ActivityHelper(page);
  235. this.contents = new ContentsHelper(baseURL, page);
  236. this.filebrowser = new FileBrowserHelper(page, this.contents);
  237. this.kernel = new KernelHelper(page);
  238. this.logconsole = new LogConsoleHelper(page);
  239. this.menu = new MenuHelper(page);
  240. this.notebook = new NotebookHelper(
  241. page,
  242. this.activity,
  243. this.contents,
  244. this.filebrowser,
  245. this.menu
  246. );
  247. this.performance = new PerformanceHelper(page);
  248. this.statusbar = new StatusBarHelper(page, this.menu);
  249. this.sidebar = new SidebarHelper(page, this.menu);
  250. this.theme = new ThemeHelper(page);
  251. }
  252. /**
  253. * JupyterLab activity helpers
  254. */
  255. readonly activity: ActivityHelper;
  256. /**
  257. * JupyterLab contents helpers
  258. */
  259. readonly contents: ContentsHelper;
  260. /**
  261. * JupyterLab filebrowser helpers
  262. */
  263. readonly filebrowser: FileBrowserHelper;
  264. /**
  265. * JupyterLab kernel helpers
  266. */
  267. readonly kernel: KernelHelper;
  268. /**
  269. * JupyterLab log console helpers
  270. */
  271. readonly logconsole: LogConsoleHelper;
  272. /**
  273. * JupyterLab menu helpers
  274. */
  275. readonly menu: MenuHelper;
  276. /**
  277. * JupyterLab notebook helpers
  278. */
  279. readonly notebook: NotebookHelper;
  280. /**
  281. * Webbrowser performance helpers
  282. */
  283. readonly performance: PerformanceHelper;
  284. /**
  285. * JupyterLab status bar helpers
  286. */
  287. readonly statusbar: StatusBarHelper;
  288. /**
  289. * JupyterLab sidebar helpers
  290. */
  291. readonly sidebar: SidebarHelper;
  292. /**
  293. * JupyterLab theme helpers
  294. */
  295. readonly theme: ThemeHelper;
  296. /**
  297. * Selector for launcher tab
  298. */
  299. get launcherSelector(): string {
  300. return this.activity.launcherSelector;
  301. }
  302. /**
  303. * Getter for JupyterLab base URL
  304. */
  305. async getBaseUrl(): Promise<string> {
  306. return Utils.getBaseUrl(this.page);
  307. }
  308. /**
  309. * Getter for JupyterLab page configuration property
  310. *
  311. * @param name Option name
  312. * @returns The property value
  313. */
  314. async getOption(name: string): Promise<string> {
  315. return Utils.getOption(this.page, name);
  316. }
  317. /**
  318. * Getter for JupyterLab server root folder
  319. */
  320. async getServerRoot(): Promise<string | null> {
  321. return (await Utils.getOption(this.page, 'serverRoot')) ?? null;
  322. }
  323. /**
  324. * Getter for JupyterLab token
  325. */
  326. async getToken(): Promise<string> {
  327. return Utils.getToken(this.page);
  328. }
  329. /**
  330. * Returns the main resource response. In case of multiple redirects, the navigation will resolve with the response of the
  331. * last redirect.
  332. *
  333. * This overrides the standard Playwright `page.goto` method by waiting for:
  334. * - the application to be started (plugins are loaded)
  335. * - the galata in page code to be injected
  336. * - the splash screen to have disappeared
  337. * - the launcher to be visible
  338. *
  339. * `page.goto` will throw an error if:
  340. * - there's an SSL error (e.g. in case of self-signed certificates).
  341. * - target URL is invalid.
  342. * - the `timeout` is exceeded during navigation.
  343. * - the remote server does not respond or is unreachable.
  344. * - the main resource failed to load.
  345. *
  346. * `page.goto` will not throw an error when any valid HTTP status code is returned by the remote server, including 404 "Not
  347. * Found" and 500 "Internal Server Error". The status code for such responses can be retrieved by calling
  348. * [response.status()](https://playwright.dev/docs/api/class-response#response-status).
  349. *
  350. * > NOTE: `page.goto` either throws an error or returns a main resource response. The only exceptions are navigation to
  351. * `about:blank` or navigation to the same URL with a different hash, which would succeed and return `null`.
  352. * > NOTE: Headless mode doesn't support navigation to a PDF document. See the
  353. * [upstream issue](https://bugs.chromium.org/p/chromium/issues/detail?id=761295).
  354. *
  355. * Shortcut for main frame's [frame.goto(url[, options])](https://playwright.dev/docs/api/class-frame#frame-goto)
  356. * @param url URL to navigate page to. The url should include scheme, e.g. `https://`. When a `baseURL` via the context options was provided and the passed URL is a path, it gets merged via the
  357. * [`new URL()`](https://developer.mozilla.org/en-US/docs/Web/API/URL/URL) constructor.
  358. * @param options
  359. */
  360. async goto(
  361. url?: string,
  362. options?: {
  363. /**
  364. * Referer header value. If provided it will take preference over the referer header value set by
  365. * [page.setExtraHTTPHeaders(headers)](https://playwright.dev/docs/api/class-page#page-set-extra-http-headers).
  366. */
  367. referer?: string;
  368. /**
  369. * Maximum operation time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be
  370. * changed by using the
  371. * [browserContext.setDefaultNavigationTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-navigation-timeout),
  372. * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout),
  373. * [page.setDefaultNavigationTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-navigation-timeout)
  374. * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods.
  375. */
  376. timeout?: number;
  377. /**
  378. * When to consider operation succeeded, defaults to `load`. Events can be either:
  379. * - `'domcontentloaded'` - consider operation to be finished when the `DOMContentLoaded` event is fired.
  380. * - `'load'` - consider operation to be finished when the `load` event is fired.
  381. * - `'networkidle'` - consider operation to be finished when there are no network connections for at least `500` ms.
  382. */
  383. waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
  384. }
  385. ): Promise<Response | null> {
  386. const target = url?.startsWith('http')
  387. ? url
  388. : `${this.baseURL}${this.appPath}/${url ?? ''}`;
  389. const response = await this.page.goto(target, {
  390. ...(options ?? {}),
  391. waitUntil: options?.waitUntil ?? 'domcontentloaded'
  392. });
  393. await this.waitForAppStarted();
  394. await this.hookHelpersUp();
  395. await this.waitIsReady(this.page, this);
  396. return response;
  397. }
  398. /**
  399. * Whether JupyterLab is in simple mode or not
  400. */
  401. isInSimpleMode = async (): Promise<boolean> => {
  402. const toggle = await this.page.$(
  403. '#jp-single-document-mode button.jp-switch'
  404. );
  405. const checked = (await toggle?.getAttribute('aria-checked')) === 'true';
  406. return checked;
  407. };
  408. /**
  409. * Returns the main resource response. In case of multiple redirects, the navigation will resolve with the response of the
  410. * last redirect.
  411. *
  412. * This overrides the standard Playwright `page.reload` method by waiting for:
  413. * - the application to be started (plugins are loaded)
  414. * - the galata in page code to be injected
  415. * - the splash screen to have disappeared
  416. * - the launcher to be visible
  417. *
  418. * @param options
  419. */
  420. async reload(options?: {
  421. /**
  422. * Maximum operation time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be
  423. * changed by using the
  424. * [browserContext.setDefaultNavigationTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-navigation-timeout),
  425. * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout),
  426. * [page.setDefaultNavigationTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-navigation-timeout)
  427. * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods.
  428. */
  429. timeout?: number;
  430. /**
  431. * When to consider operation succeeded, defaults to `load`. Events can be either:
  432. * - `'domcontentloaded'` - consider operation to be finished when the `DOMContentLoaded` event is fired.
  433. * - `'load'` - consider operation to be finished when the `load` event is fired.
  434. * - `'networkidle'` - consider operation to be finished when there are no network connections for at least `500` ms.
  435. */
  436. waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
  437. }): Promise<Response | null> {
  438. const response = await this.page.reload({
  439. ...(options ?? {}),
  440. waitUntil: options?.waitUntil ?? 'domcontentloaded'
  441. });
  442. await this.waitForAppStarted();
  443. await this.hookHelpersUp();
  444. await this.waitIsReady(this.page, this);
  445. return response;
  446. }
  447. /**
  448. * Reset the User Interface
  449. */
  450. async resetUI(): Promise<void> {
  451. // close menus
  452. await this.menu.closeAll();
  453. // close all panels
  454. await this.activity.closeAll();
  455. // shutdown kernels
  456. await this.kernel.shutdownAll();
  457. // show status bar
  458. await this.statusbar.show();
  459. // make sure all sidebar tabs are on left
  460. await this.sidebar.moveAllTabsToLeft();
  461. // show Files tab on sidebar
  462. await this.sidebar.openTab('filebrowser');
  463. // go to home folder
  464. await this.filebrowser.openHomeDirectory();
  465. }
  466. /**
  467. * Set JupyterLab simple mode
  468. *
  469. * @param simple Simple mode value
  470. * @returns Whether this operation succeeds or not
  471. */
  472. async setSimpleMode(simple: boolean): Promise<boolean> {
  473. const toggle = await this.page.$(
  474. '#jp-single-document-mode button.jp-switch'
  475. );
  476. if (toggle) {
  477. const checked = (await toggle.getAttribute('aria-checked')) === 'true';
  478. if ((checked && !simple) || (!checked && simple)) {
  479. await Promise.all([
  480. Utils.waitForTransition(this.page, toggle),
  481. toggle.click()
  482. ]);
  483. }
  484. await Utils.waitForCondition(async () => {
  485. return (await this.isInSimpleMode()) === simple;
  486. });
  487. return true;
  488. }
  489. return false;
  490. }
  491. /**
  492. * Wait for a condition to be fulfilled
  493. *
  494. * @param condition Condition to fulfill
  495. * @param timeout Maximal time to wait for the condition to be true
  496. */
  497. async waitForCondition(
  498. condition: () => Promise<boolean> | boolean,
  499. timeout?: number
  500. ): Promise<void> {
  501. return Utils.waitForCondition(condition, timeout);
  502. }
  503. /**
  504. * Wait for an element to emit 'transitionend' event.
  505. *
  506. * @param element Element or selector to watch
  507. */
  508. async waitForTransition(
  509. element: ElementHandle<Element> | string
  510. ): Promise<void> {
  511. return Utils.waitForTransition(this.page, element);
  512. }
  513. /**
  514. * Factory for active activity tab xpath
  515. */
  516. xpBuildActiveActivityTabSelector(): string {
  517. return Utils.xpBuildActiveActivityTabSelector();
  518. }
  519. /**
  520. * Factory for activity panel xpath by id
  521. * @param id Panel id
  522. */
  523. xpBuildActivityPanelSelector(id: string): string {
  524. return Utils.xpBuildActivityPanelSelector(id);
  525. }
  526. /**
  527. * Factory for activity tab xpath by name
  528. * @param name Activity name
  529. */
  530. xpBuildActivityTabSelector(name: string): string {
  531. return Utils.xpBuildActivityTabSelector(name);
  532. }
  533. /**
  534. * Factory for element containing a given class xpath
  535. * @param className Class name
  536. */
  537. xpContainsClass(className: string): string {
  538. return Utils.xpContainsClass(className);
  539. }
  540. /**
  541. * Inject the galata in-page helpers
  542. */
  543. protected async hookHelpersUp(): Promise<void> {
  544. // Insert Galata in page helpers
  545. await this.page.addScriptTag({
  546. path: path.resolve(__dirname, './lib-inpage/inpage.js')
  547. });
  548. const galataipDefined = await this.page.evaluate(() => {
  549. return Promise.resolve(typeof window.galataip === 'object');
  550. });
  551. if (!galataipDefined) {
  552. throw new Error('Failed to inject galataip object into browser context');
  553. }
  554. const jlabAccessible = await this.page.evaluate(() => {
  555. return Promise.resolve(typeof window.galataip.app === 'object');
  556. });
  557. if (!jlabAccessible) {
  558. throw new Error('Failed to access JupyterLab object in browser context');
  559. }
  560. }
  561. /**
  562. * Wait for the application to be started
  563. */
  564. protected waitForAppStarted = async (): Promise<void> => {
  565. return this.waitForCondition(() =>
  566. this.page.evaluate(async () => {
  567. if (typeof window.jupyterlab === 'object') {
  568. // Wait for plugins to be loaded
  569. await window.jupyterlab.started;
  570. return true;
  571. } else if (typeof window.jupyterapp === 'object') {
  572. // Wait for plugins to be loaded
  573. await window.jupyterapp.started;
  574. return true;
  575. }
  576. return false;
  577. })
  578. );
  579. };
  580. /**
  581. * Wait for the splash screen to be hidden and the launcher to be the active tab.
  582. */
  583. protected waitIsReady: (
  584. page: Page,
  585. helpers: IJupyterLabPage
  586. ) => Promise<void>;
  587. }