diff --git a/ui-tests/test/editor.spec.ts b/ui-tests/test/editor.spec.ts index f17271f60e3..bf3d216d278 100644 --- a/ui-tests/test/editor.spec.ts +++ b/ui-tests/test/editor.spec.ts @@ -76,6 +76,40 @@ test.describe('Editor', () => { await expect(page.locator('.jp-Notebook')).toHaveCount(0); }); + test('Should display a partial view with white space at the bottom', async ({ + page, + tmpPath, + }) => { + const file = `${tmpPath}/${FILE}`; + await page.goto(`edit/${file}`); + + await expect(page.locator('.cm-editor')).toBeVisible(); + // wait for the file content to be rendered in the editor + await expect(page.locator('.cm-content')).toContainText('name: notebook'); + + // the edit page should display a partial view, with white space below + // the main panel + const spacer = page.locator('#spacer-widget-bottom'); + await expect(spacer).toBeVisible(); + const spacerBox = await spacer.boundingBox(); + expect(spacerBox?.height).toBeGreaterThanOrEqual(16); + }); + + test('Should not render the micro toolbar for files', async ({ + page, + tmpPath, + }) => { + const file = `${tmpPath}/${FILE}`; + await page.goto(`edit/${file}`); + + await expect(page.locator('.cm-editor')).toBeVisible(); + + // the micro toolbar is added to the DOM but should be hidden via CSS + const microToolbar = page.locator('.jp-MainAreaWidget > .jp-Toolbar-micro'); + await expect(microToolbar).toHaveCount(1); + await expect(microToolbar).toBeHidden(); + }); + test('Renaming the file via the menu entry', async ({ page, tmpPath }) => { const file = `${tmpPath}/${FILE}`; await page.goto(`edit/${file}`); diff --git a/ui-tests/test/filebrowser.spec.ts b/ui-tests/test/filebrowser.spec.ts index 41cb88ea69e..fdfb7b5b2c8 100644 --- a/ui-tests/test/filebrowser.spec.ts +++ b/ui-tests/test/filebrowser.spec.ts @@ -103,6 +103,43 @@ test.describe('File Browser', () => { await notebook.close(); }); + test('Show the Upload button in the toolbar by default', async ({ page }) => { + const toolbar = page.locator('.jp-FileBrowser-toolbar'); + + await expect(toolbar.getByText('Upload')).toBeVisible(); + }); + + test('Filter the file browser listing with the file filter', async ({ + page, + }) => { + await page.filebrowser.refresh(); + + const toggleButton = page.locator( + 'jp-button[data-command="filebrowser:toggle-file-filter"]' + ); + const filterInput = page.locator('.jp-FileBrowser-filterBox input'); + const listing = page.locator('.jp-DirListing-item'); + + // the file filter input is hidden by default + await expect(filterInput).toBeHidden(); + await expect(listing).toHaveCount(3); + + // clicking the toggle button shows the filter input + await toggleButton.click(); + await expect(filterInput).toBeVisible(); + + // typing a query narrows down the listing + await filterInput.fill('folder1'); + await expect(listing).toHaveCount(1); + await expect(listing).toHaveText(/folder1/); + + // clicking the toggle button again hides the filter input and restores + // the full listing + await toggleButton.click(); + await expect(filterInput).toBeHidden(); + await expect(listing).toHaveCount(3); + }); + test('Toggle the Date Created column from the header context menu', async ({ page, }) => { @@ -138,3 +175,36 @@ test.describe('File Browser settings', () => { await expect(header.locator('.jp-id-created')).toBeVisible(); }); }); + +test.describe('File Browser toolbar settings', () => { + test.use({ + mockSettings: { + ...galata.DEFAULT_SETTINGS, + '@jupyter-notebook/tree-extension:widget': { + toolbar: [ + { + name: 'uploader', + disabled: true, + }, + ], + }, + }, + }); + + test('Should hide the Upload button when disabled in the settings', async ({ + page, + }) => { + const toolbar = page.locator('.jp-FileBrowser-toolbar'); + + // other toolbar items should still be visible + await expect(toolbar.getByText('New', { exact: true })).toBeVisible(); + await expect( + toolbar.locator('[data-jp-item-name="refresh"]') + ).toBeVisible(); + + // the Upload button should not be added to the toolbar + await expect(toolbar.locator('[data-jp-item-name="uploader"]')).toHaveCount( + 0 + ); + }); +}); diff --git a/ui-tests/test/help.spec.ts b/ui-tests/test/help.spec.ts new file mode 100644 index 00000000000..40b6bdf8ff3 --- /dev/null +++ b/ui-tests/test/help.spec.ts @@ -0,0 +1,73 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { expect } from '@jupyterlab/galata'; + +import { test } from './fixtures'; + +test.describe('Help menu', () => { + test('Should open the About dialog', async ({ page }) => { + await page.menu.clickMenuItem('Help>About Jupyter Notebook'); + + const dialog = page.locator('.jp-Dialog.jp-AboutNotebook'); + await expect(dialog).toBeVisible(); + + // The version reported in the dialog should be a valid version number + await expect(dialog.locator('.jp-AboutNotebook-version')).toHaveText( + /^Version: \d+\.\d+\.\d+/ + ); + + // The external links should open in a new tab + const githubLink = dialog.getByRole('link', { + name: 'JUPYTER NOTEBOOK ON GITHUB', + }); + await expect(githubLink).toHaveAttribute( + 'href', + 'https://github.com/jupyter/notebook' + ); + await expect(githubLink).toHaveAttribute('target', '_blank'); + + const contributorsLink = dialog.getByRole('link', { + name: 'CONTRIBUTOR LIST', + }); + await expect(contributorsLink).toHaveAttribute( + 'href', + 'https://github.com/jupyter/notebook/pulse' + ); + await expect(contributorsLink).toHaveAttribute('target', '_blank'); + + await expect( + dialog.locator('.jp-AboutNotebook-about-copyright') + ).toBeVisible(); + + // Dismiss the dialog + await dialog.getByRole('button', { name: 'Dismiss' }).click(); + await expect(dialog).toHaveCount(0); + }); + + test('Should open the Documentation in a new browser tab', async ({ + page, + }) => { + const documentationUrl = 'https://jupyter-notebook.readthedocs.io/'; + + // Stub the external website so the test does not depend on network access + await page.context().route(`${documentationUrl}**`, (route) => + route.fulfill({ + contentType: 'text/html', + body: 'Documentation', + }) + ); + + const [popup] = await Promise.all([ + page.waitForEvent('popup'), + page.menu.clickMenuItem('Help>Documentation'), + ]); + + // Only check the URL of the new tab, without waiting for the page to load + await popup.waitForURL(`${documentationUrl}en/stable/`, { + waitUntil: 'commit', + }); + expect(popup.url()).toEqual(`${documentationUrl}en/stable/`); + await popup.close(); + }); +}); diff --git a/ui-tests/test/notebook.spec.ts b/ui-tests/test/notebook.spec.ts index 803f1a06f93..4ce410f6ac4 100644 --- a/ui-tests/test/notebook.spec.ts +++ b/ui-tests/test/notebook.spec.ts @@ -156,6 +156,94 @@ test.describe('Notebook', () => { expect(await panel.screenshot()).toMatchSnapshot(imageName); }); + test('Edit Notebook Metadata should open the right panel with Advanced Tools expanded', async ({ + page, + tmpPath, + }) => { + const notebook = 'simple.ipynb'; + await page.contents.uploadFile( + path.resolve(__dirname, `./notebooks/${notebook}`), + `${tmpPath}/${notebook}` + ); + await page.goto(`notebooks/${tmpPath}/${notebook}`); + + await waitForKernelReady(page); + + await page.menu.clickMenuItem('Edit>Edit Notebook Metadata'); + + const panel = page.locator('#jp-right-stack'); + await expect(panel).toBeVisible(); + + const notebookTools = page.locator('#notebook-tools.jp-NotebookTools'); + await expect(notebookTools).toBeVisible(); + + // The Advanced Tools section should be expanded + const advancedTools = notebookTools.locator('.jp-Collapse', { + hasText: 'Advanced Tools', + }); + await expect(advancedTools.locator('.jp-Collapse-header')).not.toHaveClass( + /jp-Collapse-header-collapsed/ + ); + await expect(advancedTools.locator('.jp-Collapse-contents')).toBeVisible(); + }); + + test('Tab title should reflect the current document', async ({ + page, + tmpPath, + }) => { + const notebook = 'simple.ipynb'; + await page.contents.uploadFile( + path.resolve(__dirname, `./notebooks/${notebook}`), + `${tmpPath}/${notebook}` + ); + await page.goto(`notebooks/${tmpPath}/${notebook}`); + + // The tab title should be the notebook name with the ".ipynb" suffix stripped + await expect(page).toHaveTitle('simple'); + + // The tree page should have the default title + await page.goto(`tree/${tmpPath}`); + await expect(page).toHaveTitle('Home'); + }); + + test('Favicon should switch to busy while a cell is running', async ({ + page, + tmpPath, + }) => { + const notebook = 'empty.ipynb'; + await page.contents.uploadFile( + path.resolve(__dirname, `./notebooks/${notebook}`), + `${tmpPath}/${notebook}` + ); + await page.goto(`notebooks/${tmpPath}/${notebook}`); + + await waitForKernelReady(page); + + const favicon = page.locator('link[rel*="icon"]'); + + await page.click('.jp-Cell-inputArea'); + + // Enter code in the first cell + await page + .locator( + '.jp-Cell-inputArea >> .cm-editor >> .cm-content[contenteditable="true"]' + ) + .type('import time; time.sleep(3)'); + + // Run the cell + await runAndAdvance(page); + + // The favicon should switch to the busy icon while the kernel is busy + await expect(favicon).toHaveAttribute('href', /favicon-busy-1\.ico/, { + timeout: 15000, + }); + + // And back to the idle notebook icon when the execution is done + await expect(favicon).toHaveAttribute('href', /favicon-notebook\.ico/, { + timeout: 30000, + }); + }); + test('Clicking on "Close and Shut Down Notebook" should close the browser tab', async ({ page, tmpPath, diff --git a/ui-tests/test/terminal.spec.ts b/ui-tests/test/terminal.spec.ts new file mode 100644 index 00000000000..ecadd54d2b4 --- /dev/null +++ b/ui-tests/test/terminal.spec.ts @@ -0,0 +1,133 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { expect, IJupyterLabPageFixture } from '@jupyterlab/galata'; + +import { Page } from '@playwright/test'; + +import { test } from './fixtures'; + +/** + * Create a new terminal from the New dropdown of the file browser toolbar. + * + * The terminal session is created via the page so it is tracked by Galata + * and automatically disposed at the end of the test. + */ +const openTerminalFromNewDropdown = async ( + page: IJupyterLabPageFixture +): Promise<{ terminal: Page; name: string }> => { + const terminalPromise = page.waitForEvent('popup'); + await page.click('.jp-DropdownMenu >> text="New"'); + await page.click('.lm-Menu [data-command="terminal:create-new"]'); + const terminal = await terminalPromise; + await terminal.waitForLoadState(); + + // the terminal should open in a new tab on the /terminals/ page + await terminal.waitForURL(/\/terminals\/\w+/); + const name = new URL(terminal.url()).pathname.split('/').pop() ?? ''; + return { terminal, name }; +}; + +test.describe('Terminal', () => { + test('Create a terminal from the New dropdown', async ({ page }) => { + const { terminal } = await openTerminalFromNewDropdown(page); + + await expect(terminal.locator('.jp-Terminal')).toBeVisible(); + await expect(terminal.locator('.jp-Terminal .xterm-screen')).toBeVisible(); + + await terminal.close(); + }); + + test('Micro toolbars should not be visible on the terminal page', async ({ + page, + }) => { + const { terminal } = await openTerminalFromNewDropdown(page); + + await expect(terminal.locator('.jp-Terminal')).toBeVisible(); + + // the micro toolbar is added to the DOM but should be hidden via CSS + const microToolbar = terminal.locator( + '.jp-MainAreaWidget > .jp-Toolbar-micro' + ); + await expect(microToolbar).toHaveCount(1); + await expect(microToolbar).toBeHidden(); + + // there should not be any cell toolbar on the terminal page + await expect(terminal.locator('.jp-cell-toolbar')).toHaveCount(0); + + await terminal.close(); + }); + + test('Show and shut down a running terminal from the Running tab', async ({ + page, + }) => { + const { terminal, name } = await openTerminalFromNewDropdown(page); + + await expect(terminal.locator('.jp-Terminal')).toBeVisible(); + await terminal.close(); + + // open the Running tab on the tree page + await page.locator('.jp-TreePanel >> text="Running"').click(); + await expect( + page.locator('#main-panel #jp-running-sessions-tree') + ).toBeVisible(); + + const item = page.locator( + '#jp-running-sessions-tree .jp-RunningSessions-item', + { + hasText: `terminals/${name}`, + } + ); + await expect(item).toBeVisible(); + + // shut the terminal down from the running sessions list + await item.hover(); + await item.locator('.jp-RunningSessions-itemShutdown').click(); + + await expect(item).toHaveCount(0); + }); + + test('Execute a command in the terminal', async ({ page, request }) => { + const { terminal, name } = await openTerminalFromNewDropdown(page); + + await expect(terminal.locator('.jp-Terminal .xterm-screen')).toBeVisible(); + await terminal.locator('.jp-Terminal').click(); + + // the terminal output is not exposed in the DOM (xterm renders to a + // canvas), so exit the shell and check the terminal session gets + // terminated as a result. The shell may not be ready to process input + // right away, so retry typing the command until the session is gone. + await expect + .poll( + async () => { + await terminal.keyboard.type('exit'); + await terminal.keyboard.press('Enter'); + const response = await request.get('/api/terminals'); + const models = (await response.json()) as { name: string }[]; + return models.map((model) => model.name); + }, + { timeout: 30000 } + ) + .not.toContain(name); + + await terminal.close(); + }); + + test('Open a terminal directly from its URL', async ({ page, request }) => { + const { terminal, name } = await openTerminalFromNewDropdown(page); + + await expect(terminal.locator('.jp-Terminal')).toBeVisible(); + await terminal.close(); + + await page.goto(`terminals/${name}`); + + await expect(page.locator('.jp-Terminal')).toBeVisible(); + await expect(page.locator('.jp-Terminal .xterm-screen')).toBeVisible(); + + // the page should have connected to the existing terminal session + // instead of creating a new one + const response = await request.get('/api/terminals'); + const models = (await response.json()) as { name: string }[]; + expect(models.map((model) => model.name)).toEqual([name]); + }); +}); diff --git a/ui-tests/test/topbar.spec.ts b/ui-tests/test/topbar.spec.ts new file mode 100644 index 00000000000..5ff00deec44 --- /dev/null +++ b/ui-tests/test/topbar.spec.ts @@ -0,0 +1,115 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import path from 'path'; + +import { expect } from '@jupyterlab/galata'; + +import { test } from './fixtures'; + +import { waitForKernelReady } from './utils'; + +test.use({ autoGoto: false }); + +test.describe('Top bar', () => { + test('Kernel logo should be displayed with the kernel name', async ({ + page, + tmpPath, + }) => { + const notebook = 'empty.ipynb'; + await page.contents.uploadFile( + path.resolve(__dirname, `./notebooks/${notebook}`), + `${tmpPath}/${notebook}` + ); + await page.goto(`notebooks/${tmpPath}/${notebook}`); + + await waitForKernelReady(page); + + const logo = page.locator('.jp-NotebookKernelLogo img'); + await expect(logo).toBeVisible(); + await expect(logo).toHaveAttribute('src', /kernelspecs/); + await expect(logo).toHaveAttribute('title', 'Python 3 (ipykernel)'); + }); + + test('Checkpoint indicator should be updated after saving the notebook', async ({ + page, + tmpPath, + }) => { + const notebook = 'empty.ipynb'; + await page.contents.uploadFile( + path.resolve(__dirname, `./notebooks/${notebook}`), + `${tmpPath}/${notebook}` + ); + await page.goto(`notebooks/${tmpPath}/${notebook}`); + + await waitForKernelReady(page); + + // a freshly uploaded notebook does not have a checkpoint yet, + // so the indicator should be empty + const checkpoint = page.locator('.jp-NotebookCheckpoint'); + await expect(checkpoint).toHaveText(''); + + // make an edit to the notebook + await page.click('.jp-Cell-inputArea'); + await page + .locator( + '.jp-Cell-inputArea >> .cm-editor >> .cm-content[contenteditable="true"]' + ) + .type('1 + 1'); + + // save the notebook, which also creates a checkpoint + await page.keyboard.press('Escape'); + await page.keyboard.press('ControlOrMeta+S'); + + // the indicator is refreshed shortly after the save + await expect(checkpoint).toContainText('Last Checkpoint:', { + timeout: 10000, + }); + }); + + test('A notebook with code cells should not be trusted by default', async ({ + page, + tmpPath, + }) => { + // empty.ipynb contains a code cell + const notebook = 'empty.ipynb'; + await page.contents.uploadFile( + path.resolve(__dirname, `./notebooks/${notebook}`), + `${tmpPath}/${notebook}` + ); + await page.goto(`notebooks/${tmpPath}/${notebook}`); + + await waitForKernelReady(page); + + const trustedButton = page.locator('button.jp-NotebookTrustedStatus'); + await expect(trustedButton).toHaveText('Not Trusted'); + + // clicking on the button should open a confirmation dialog to trust the notebook + await trustedButton.click(); + const dialog = page.locator('.jp-Dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText('Trust this notebook?'); + + // accepting the dialog should trust the notebook + await dialog.locator('.jp-Dialog-button.jp-mod-accept').click(); + await expect(trustedButton).toHaveText('Trusted'); + }); + + test('A notebook without code cells should be trusted', async ({ + page, + tmpPath, + }) => { + // simple.ipynb only contains a markdown cell + const notebook = 'simple.ipynb'; + await page.contents.uploadFile( + path.resolve(__dirname, `./notebooks/${notebook}`), + `${tmpPath}/${notebook}` + ); + await page.goto(`notebooks/${tmpPath}/${notebook}`); + + await waitForKernelReady(page); + + const trustedButton = page.locator('button.jp-NotebookTrustedStatus'); + await expect(trustedButton).toHaveText('Trusted'); + }); +}); diff --git a/ui-tests/test/tree.spec.ts b/ui-tests/test/tree.spec.ts index 74eba32df96..d9b65d04df3 100644 --- a/ui-tests/test/tree.spec.ts +++ b/ui-tests/test/tree.spec.ts @@ -64,3 +64,58 @@ test('Should activate file browser tab', async ({ page, tmpPath }) => { await page.menu.clickMenuItem('View>File Browser'); await expect(page.locator('#main-panel #filebrowser')).toBeVisible(); }); + +test.describe('Toolbar New dropdown', () => { + test('New > New Folder should create a new folder', async ({ + page, + tmpPath, + }) => { + await page.click('.jp-DropdownMenu >> text="New"'); + await page.click( + '.lm-Menu [data-command="filebrowser:create-new-directory"]' + ); + + // the new folder is created in inline-rename mode, commit the default name + await page.waitForSelector('.jp-DirListing-editor'); + await page.keyboard.press('Enter'); + + await expect( + page.locator('.jp-DirListing-item >> text="Untitled Folder"') + ).toBeVisible(); + expect( + await page.contents.directoryExists(`${tmpPath}/Untitled Folder`) + ).toBe(true); + }); + + test('New > New File should create a new file', async ({ page, tmpPath }) => { + await page.click('.jp-DropdownMenu >> text="New"'); + await page.click('.lm-Menu [data-command="filebrowser:create-new-file"]'); + + // the new file is created in inline-rename mode, commit the default name + await page.waitForSelector('.jp-DirListing-editor'); + await page.keyboard.press('Enter'); + + await expect( + page.locator('.jp-DirListing-item >> text="untitled.txt"') + ).toBeVisible(); + expect(await page.contents.fileExists(`${tmpPath}/untitled.txt`)).toBe( + true + ); + }); + + test('New > Console should open a new console', async ({ page }) => { + await page.click('.jp-DropdownMenu >> text="New"'); + await page.click('.lm-Menu [data-command="console:create"]'); + + // choose the default kernel in the kernel selection dialog + const [consolePage] = await Promise.all([ + page.waitForEvent('popup'), + page.click('.jp-Dialog >> text="Select"'), + ]); + await consolePage.waitForLoadState(); + + expect(new URL(consolePage.url()).pathname).toContain('/consoles/'); + await consolePage.waitForSelector('.jp-CodeConsole'); + await consolePage.close(); + }); +});