diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 89b7373..f74c9e6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -141,6 +141,12 @@ jobs: set -eux jlpm test + - name: Execute docs screenshot tests + working-directory: ui-tests + run: | + set -eux + jlpm test:docs-screenshots + - name: Upload Playwright Test report if: always() uses: actions/upload-artifact@v4 @@ -149,6 +155,8 @@ jobs: path: | ui-tests/test-results ui-tests/playwright-report + ui-tests/test-results-docs-screenshots + ui-tests/playwright-report-docs-screenshots integration_tests_notebook_v7: name: Integration tests (Notebook v7) diff --git a/README.md b/README.md index b5c661e..a153c78 100644 --- a/README.md +++ b/README.md @@ -19,23 +19,46 @@ pip install jupyterlab-plugin-playground ## How to use the Plugin Playground -This extension provides a new command, `Load Current File As Extension`, available in the text editor. +### Feature Highlights -It also adds a single right sidebar panel with two collapsible sections: +Plugin Playground is built to keep the full plugin prototyping workflow inside JupyterLab. In the editor toolbar, you can load the active file as an extension, export the current plugin folder as a starter extension archive, copy a shareable plugin link, and enable per-file `Auto Load on Save` for faster iteration while editing. -- **Extension Points**: token string IDs and command IDs, with a `Tokens` / `Commands` switch. Tokens support search, copy, and import actions. Commands support search, copy, and insert-at-cursor actions. -- **Extension Examples**: discovered examples from a local checkout of [`jupyterlab/extension-examples`](https://github.com/jupyterlab/extension-examples), so you can open them directly from the panel. +![Plugin Playground editor toolbar actions](docs/images/readme/editor-toolbar-actions.png) -If examples are missing: +The right sidebar includes a single Plugin Playground panel with two collapsible sections. In **Extension Points**, the `Tokens` tab helps you discover available token strings and insert import/dependency updates, the `Commands` tab lets you search command IDs, inspect argument docs, and insert execution snippets (either directly or through AI-assisted prompt mode), and the `Packages` tab surfaces package docs plus npm and repository links for known modules. -- For source checkouts: run `git submodule update --init --recursive`. -- For PyPI installs: clone `https://github.com/jupyterlab/extension-examples` into an `extension-examples/` folder in your working directory. +![Extension Points token discovery and insertion actions](docs/images/readme/extension-points-tokens.png) +![Extension Points command discovery and insertion actions](docs/images/readme/extension-points-commands.png) +![Packages reference tab in Extension Points](docs/images/readme/packages-reference.png) + +The **Extension Examples** section lists discovered examples from `extension-examples/` and lets you open source entrypoints and README files directly. This keeps reference implementations close while you prototype. + +![Extension Examples section with code and README actions](docs/images/readme/extension-examples.png) + +Command completion is also included for `app.commands.execute(...)` / `commands.execute(...)` in JavaScript and TypeScript editors, and Notebook v7 integrates `Plugin (Playground)` into New-file flows so you can create starter plugin files from the tree interface. + +To regenerate the screenshots used in this README: -When reloading a plugin with the same `id`, the playground attempts to deactivate the previously loaded plugin first. Defining `deactivate()` in examples is recommended for clean reruns. +```bash +jlpm docs:screenshots +``` + +### Quick Start -When typing inside `commands.execute(` or `app.commands.execute(` in JavaScript and TypeScript editors, the completer will also suggest available command IDs. +1. Create a file with `TypeScript File (Playground)` (Command Palette) or `Plugin (Playground)` (Notebook v7 New menu). +2. Paste plugin code into the active editor. +3. Run `Load Current File As Extension` from the editor toolbar or Command Palette. +4. Use `Auto Load on Save` for fast iteration on one file. +5. Use the sidebar to discover tokens, commands, packages, and extension examples. -As an example, open the text editor by creating a new text file and paste this small JupyterLab plugin into it. This plugin will create a simple command `My Super Cool Toggle` in the command palette that can be toggled on and off. +For extension examples availability: + +- For source checkouts: run `git submodule update --init --recursive`. +- PyPI installs: bundled examples are copied into `extension-examples/` automatically when the server extension starts. + +When reloading a plugin with the same `id`, Plugin Playground attempts to deactivate and deregister the previous plugin before loading the new one. Defining `deactivate()` is still recommended for clean reruns. + +As an example, open the text editor by creating a new text file and paste this small JupyterLab plugin into it. This plugin creates a simple command `My Super Cool Toggle` in the command palette that can be toggled on and off. ```typescript import { ICommandPalette } from '@jupyterlab/apputils'; @@ -96,13 +119,13 @@ const plugin = { export default plugin; ``` -There are a few differences in how to write plugins in the Plugin Playground compared to writing plugins in a JupyterLab extension: +There are a few differences in how to write plugins in Plugin Playground compared to writing a full JupyterLab extension: -- The playground is more understanding: you can use JavaScript-like code rather than fully typed TypeScript and it will still compile. -- You can only load a plugin with a given id more than once, but the previous version will not be unloaded. If you make changes to your plugin, save it and refresh the JupyterLab page to be able to load it afresh again. -- To load code from an external package, RequireJS is used (it is hidden behind ES6-compatible import syntax) which means that the import statements need to be slightly modified to point to appropriate version or file in the package. - - In addition to JupyterLab and Lumino packages, only AMD modules can be imported; ES6 modules and modules compiled for consumption by Webpack/Node will not work in the current version and an attempt to load such modules will result in `Uncaught SyntaxError: Unexpected token 'export'` error. -- While the playground will attempt to import relative files (with `.ts` suffix), SVG (as strings), and to load `plugin.json` schema, these are experimental features for rapid prototyping and details are subject to change; other resources like CSS styles are not yet supported (but the support is planned) +- The playground is more forgiving: you can use JavaScript-like code rather than strictly typed TypeScript and it will still compile. +- You can load a plugin with a given `id` more than once during iteration. Plugin Playground attempts to deactivate and deregister the previous version before registering the new one. Defining `deactivate()` in your plugin is still recommended for predictable cleanup between reloads. +- To load code from an external package, RequireJS is used (hidden behind ES module-compatible import syntax), so import statements may need explicit version or file paths. + - In addition to JupyterLab and Lumino packages, only AMD modules can be imported; ES modules and modules compiled for Webpack/Node are not supported directly and can fail with `Uncaught SyntaxError: Unexpected token 'export'`. +- While the playground can import relative files (`.ts`), load SVG as strings, and load `plugin.json` schema for rapid prototyping, these capabilities are still evolving; other resources such as CSS files are not currently supported. ### Migrating from version 0.3.0 @@ -141,17 +164,23 @@ Plugin Playground supports AI-assisted extension prototyping in both JupyterLite ### Command Insert Modes (Default + AI Prompt) -The `+` action in the `Commands` tab depends on mode: +In the `Commands` tab, each command row includes a split `+` action and a mode dropdown: - `Insert in selection` inserts: -```ts -app.commands.execute(''); -``` + ```ts + app.commands.execute(''); + ``` + + at the active cursor position in the current editor. + +- `Prompt AI to insert` does not insert directly. It opens JupyterLite AI chat and prefills a contextual prompt so AI can choose a better insertion location before you submit. -at the active cursor position. +![Command insert mode dropdown with AI option](docs/images/readme/command-insert-mode-dropdown.png) -- `Prompt AI to insert` does not insert directly. It opens JupyterLite AI chat and prefills a prompt with file context so AI can choose the best insertion location before you submit. +The same command row also includes the `f(n)` button to inspect command argument docs inline before insertion. + +![Command argument documentation expanded in Commands tab](docs/images/readme/command-argument-docs.png) The sidebar remembers your last-used command insert mode in: @@ -159,17 +188,25 @@ The sidebar remembers your last-used command insert mode in: ### Commands for AI Agents and Automation -Plugin Playground now exposes command APIs that mirror sidebar data and support optional `query` filtering: +Plugin Playground exposes command APIs for scripting, agents, and automation: -- `plugin-playground:list-tokens` -- `plugin-playground:list-commands` -- `plugin-playground:list-extension-examples` -- `plugin-playground:export-as-extension` (supports optional `{ path: string }`) -- `plugin-playground:share-via-link` (supports optional `{ path: string }`) +- `plugin-playground:create-new-plugin` (supports optional `{ cwd?: string, path?: string }`) +- `plugin-playground:load-as-extension` +- `plugin-playground:open-js-explorer` +- `plugin-playground:list-tokens` (supports optional `{ query?: string }`) +- `plugin-playground:list-commands` (supports optional `{ query?: string }`) +- `plugin-playground:list-extension-examples` (supports optional `{ query?: string }`) +- `plugin-playground:export-as-extension` (supports optional `{ path?: string }`) +- `plugin-playground:share-via-link` (supports optional `{ path?: string }`) Example: ```typescript +await app.commands.execute('plugin-playground:create-new-plugin', { + cwd: 'my-extension/src', + path: 'index.ts' +}); + await app.commands.execute('plugin-playground:list-tokens', { query: 'notebook' }); @@ -205,23 +242,43 @@ return a JSON object with: ## Advanced Settings -The Advanced Settings for the Plugin Playground enable you to configure plugins to load every time JupyterLab starts up. Automatically loaded plugins can be configured in two ways: - -- `urls` is a list of URLs that will be fetched and loaded as plugins automatically when JupyterLab starts up. For example, you can point to a GitHub gist or a file you host on a local server that serves text files like the above examples. -- `plugins` is a list of strings of plugin text, like the examples above, that are loaded automatically when JupyterLab starts up. Since JSON strings cannot have multiple lines, you will need to encode any newlines in your plugin text directly as `\n\` (the second backslash is to allow the string to continue on the next line). For example, here is a user setting to encode a small plugin to run at startup: - ```json5 - { - plugins: [ - "{ \n\ - id: 'MyConsoleLoggingPlugin', \n\ - autoStart: true, \n\ - activate: function(app) { \n\ - console.log('Activated!'); \n\ - } \n\ - }" - ] - } - ``` +Plugin Playground settings are available in `Settings > Settings Editor > Plugin Playground`. These settings are intended to support both quick experiments and repeatable startup workflows. + +`allowCDN` controls whether unknown packages can be executed from a CDN. The default `awaiting-decision` mode keeps things explicit, while `always-insecure` and `never` let you enforce a fixed policy. + +`requirejsCDN` defines the base URL used by RequireJS to resolve unknown package imports (for example `https://cdn.jsdelivr.net/npm/`). If you rely on external AMD packages in prototypes, this setting determines where those packages are fetched from. + +`loadOnSave` enables automatic load-as-extension behavior on save for supported editor files (JavaScript and TypeScript). This is useful when iterating quickly without repeatedly triggering the load command manually. + +`commandInsertDefaultMode` sets the default behavior for the `+` action in the Commands tab (`insert` for direct insertion or `ai` for AI-assisted prompt flow). + +![Plugin Playground settings showing command insert default mode](docs/images/readme/settings-command-insert-default-mode.png) + +For startup automation, there are two complementary settings: + +- `urls` is a list of plugin URLs that are fetched and loaded at startup. This is useful for hosting a plugin source file externally (for example, a gist or internal text endpoint) and keeping clients in sync. +- `plugins` is a list of plugin source strings loaded at startup. This is useful for embedding short startup plugins directly in settings. Because these are JSON strings, multiline code must encode line breaks as `\n\`. + +Example: + +```json5 +{ + allowCDN: 'awaiting-decision', + requirejsCDN: 'https://cdn.jsdelivr.net/npm/', + loadOnSave: false, + commandInsertDefaultMode: 'insert', + urls: ['https://gist.githubusercontent.com/.../raw/plugin.ts'], + plugins: [ + "{ \n\ + id: 'MyConsoleLoggingPlugin', \n\ + autoStart: true, \n\ + activate: function(app) { \n\ + console.log('Activated!'); \n\ + } \n\ + }" + ] +} +``` ## Contributing @@ -286,6 +343,7 @@ Run from repository root: ```bash jlpm run build:prod jlpm run test:integration +jlpm run docs:screenshots ``` setup: diff --git a/docs/images/readme/command-argument-docs.png b/docs/images/readme/command-argument-docs.png new file mode 100644 index 0000000..6e8fbb2 Binary files /dev/null and b/docs/images/readme/command-argument-docs.png differ diff --git a/docs/images/readme/command-insert-mode-dropdown.png b/docs/images/readme/command-insert-mode-dropdown.png new file mode 100644 index 0000000..d89558d Binary files /dev/null and b/docs/images/readme/command-insert-mode-dropdown.png differ diff --git a/docs/images/readme/editor-toolbar-actions.png b/docs/images/readme/editor-toolbar-actions.png new file mode 100644 index 0000000..58a1ac6 Binary files /dev/null and b/docs/images/readme/editor-toolbar-actions.png differ diff --git a/docs/images/readme/extension-examples.png b/docs/images/readme/extension-examples.png new file mode 100644 index 0000000..a585bfb Binary files /dev/null and b/docs/images/readme/extension-examples.png differ diff --git a/docs/images/readme/extension-points-commands.png b/docs/images/readme/extension-points-commands.png new file mode 100644 index 0000000..b1a9a8a Binary files /dev/null and b/docs/images/readme/extension-points-commands.png differ diff --git a/docs/images/readme/extension-points-tokens.png b/docs/images/readme/extension-points-tokens.png new file mode 100644 index 0000000..836ec87 Binary files /dev/null and b/docs/images/readme/extension-points-tokens.png differ diff --git a/docs/images/readme/packages-reference.png b/docs/images/readme/packages-reference.png new file mode 100644 index 0000000..fe15978 Binary files /dev/null and b/docs/images/readme/packages-reference.png differ diff --git a/docs/images/readme/settings-command-insert-default-mode.png b/docs/images/readme/settings-command-insert-default-mode.png new file mode 100644 index 0000000..d442e0e Binary files /dev/null and b/docs/images/readme/settings-command-insert-default-mode.png differ diff --git a/docs/index.md b/docs/index.md index 1c2998b..e765c74 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,5 +3,6 @@ Try `jupyterlab-plugin-playground` in your browser right now { + if (entry === 'html') { + return ['html', { outputFolder: DOCS_HTML_REPORT_DIR }]; + } + if (Array.isArray(entry) && entry[0] === 'html') { + return [ + 'html', + { + ...(entry[1] ?? {}), + outputFolder: DOCS_HTML_REPORT_DIR + } + ]; + } + return entry; + }) + : baseConfig.reporter === 'html' + ? [['html', { outputFolder: DOCS_HTML_REPORT_DIR }]] + : baseConfig.reporter; + +module.exports = { + ...baseConfig, + testIgnore: [], + testMatch: /readme-screenshots\.spec\.ts/, + outputDir: DOCS_OUTPUT_DIR, + reporter +}; diff --git a/ui-tests/tests/readme-screenshots.spec.ts b/ui-tests/tests/readme-screenshots.spec.ts new file mode 100644 index 0000000..63f2c00 --- /dev/null +++ b/ui-tests/tests/readme-screenshots.spec.ts @@ -0,0 +1,390 @@ +import { promises as fs } from 'fs'; +import path from 'path'; + +import { expect, test } from '@jupyterlab/galata'; +import type { IJupyterLabPageFixture } from '@jupyterlab/galata'; +import type { Locator } from '@playwright/test'; + +const CREATE_FILE_COMMAND = 'plugin-playground:create-new-plugin'; +const PLAYGROUND_SIDEBAR_ID = 'jp-plugin-playground-sidebar'; +const TOKEN_SECTION_ID = 'jp-plugin-token-sidebar'; +const EXAMPLE_SECTION_ID = 'jp-plugin-example-sidebar'; +const LOAD_ON_SAVE_CHECKBOX_LABEL = 'Auto Load on Save'; +const READABLE_DEMO_FILE = 'readme-screenshots.ts'; +const RIGHT_SIDEBAR_SCREENSHOT_WIDTH = 300; +const EXTENSION_POINTS_GALLERY_WIDTH = 290; +const EXTENSION_EXAMPLES_BOTTOM_PADDING = 24; +const EXTENSION_EXAMPLES_MIN_HEIGHT = 180; +const EDITOR_TOOLBAR_SCREENSHOT_HEIGHT = 420; +const SETTINGS_BOTTOM_PADDING = 120; +const SETTINGS_MIN_HEIGHT = 320; +const SCREENSHOT_OUTPUT_DIR = path.resolve( + __dirname, + '../../docs/images/readme' +); + +test.use({ autoGoto: false, viewport: { width: 1600, height: 1000 } }); +test.describe.configure({ mode: 'serial' }); + +async function openSidebarPanel( + page: IJupyterLabPageFixture, + sectionId?: string +): Promise { + const sidebarTab = page.sidebar.getTabLocator(PLAYGROUND_SIDEBAR_ID); + await expect(sidebarTab).toBeVisible(); + await page.sidebar.openTab(PLAYGROUND_SIDEBAR_ID); + + const sidebarSide = await page.sidebar.getTabPosition(PLAYGROUND_SIDEBAR_ID); + const panel = page.sidebar.getContentPanelLocator(sidebarSide ?? 'right'); + await expect(panel).toBeVisible(); + await expect(panel).toHaveAttribute('id', PLAYGROUND_SIDEBAR_ID); + + if (!sectionId) { + return panel; + } + + const section = panel.locator(`#${sectionId}`); + await expect(section).toBeVisible(); + return section; +} + +async function saveScreenshot( + locator: Locator, + filename: string +): Promise { + await locator.screenshot({ + path: path.join(SCREENSHOT_OUTPUT_DIR, filename) + }); +} + +async function saveSectionCroppedToAnchor( + page: IJupyterLabPageFixture, + section: Locator, + anchor: Locator, + filename: string, + options?: { + bottomPadding?: number; + minHeight?: number; + } +): Promise { + const bottomPadding = + options?.bottomPadding ?? EXTENSION_EXAMPLES_BOTTOM_PADDING; + const minHeight = options?.minHeight ?? EXTENSION_EXAMPLES_MIN_HEIGHT; + const sectionBox = await section.boundingBox(); + const anchorBox = await anchor.boundingBox(); + + if (!sectionBox || !anchorBox) { + await saveScreenshot(section, filename); + return; + } + + const sectionBottom = sectionBox.y + sectionBox.height; + const targetBottom = Math.min( + sectionBottom, + anchorBox.y + anchorBox.height + bottomPadding + ); + const clip = { + x: Math.max(0, Math.floor(sectionBox.x)), + y: Math.max(0, Math.floor(sectionBox.y)), + width: Math.max(1, Math.floor(sectionBox.width)), + height: Math.max(minHeight, Math.floor(targetBottom - sectionBox.y)) + }; + + await page.screenshot({ + path: path.join(SCREENSHOT_OUTPUT_DIR, filename), + clip + }); +} + +async function saveTopCroppedScreenshot( + page: IJupyterLabPageFixture, + section: Locator, + filename: string, + maxHeight: number +): Promise { + const sectionBox = await section.boundingBox(); + if (!sectionBox) { + await saveScreenshot(section, filename); + return; + } + + const clip = { + x: Math.max(0, Math.floor(sectionBox.x)), + y: Math.max(0, Math.floor(sectionBox.y)), + width: Math.max(1, Math.floor(sectionBox.width)), + height: Math.max(1, Math.min(Math.floor(sectionBox.height), maxHeight)) + }; + + await page.screenshot({ + path: path.join(SCREENSHOT_OUTPUT_DIR, filename), + clip + }); +} + +async function seedExtensionExamples( + page: IJupyterLabPageFixture +): Promise { + await page.evaluate(async () => { + const ensureDirectory = async (directoryPath: string): Promise => { + try { + await window.jupyterapp.serviceManager.contents.get(directoryPath, { + content: false + }); + } catch { + await window.jupyterapp.serviceManager.contents.save(directoryPath, { + type: 'directory', + format: 'json', + content: null + }); + } + }; + + await ensureDirectory('extension-examples'); + await ensureDirectory('extension-examples/launcher'); + await ensureDirectory('extension-examples/launcher/src'); + + await window.jupyterapp.serviceManager.contents.save( + 'extension-examples/launcher/src/index.ts', + { + type: 'file', + format: 'text', + content: + "const plugin = { id: 'launcher:example', autoStart: true, activate: () => undefined };\nexport default plugin;\n" + } + ); + await window.jupyterapp.serviceManager.contents.save( + 'extension-examples/launcher/package.json', + { + type: 'file', + format: 'text', + content: JSON.stringify( + { + name: '@jupyterlab-examples/launcher', + description: 'Launcher extension example for docs screenshots' + }, + null, + 2 + ) + } + ); + await window.jupyterapp.serviceManager.contents.save( + 'extension-examples/launcher/README.md', + { + type: 'file', + format: 'text', + content: '# Launcher Example\n' + } + ); + }); +} + +test('generate README screenshots', async ({ page }) => { + await fs.mkdir(SCREENSHOT_OUTPUT_DIR, { recursive: true }); + + await page.goto(); + await page.waitForCondition(() => + page.evaluate( + (id: string) => window.jupyterapp.commands.hasCommand(id), + CREATE_FILE_COMMAND + ) + ); + await seedExtensionExamples(page); + + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForCondition(() => + page.evaluate( + (id: string) => window.jupyterapp.commands.hasCommand(id), + CREATE_FILE_COMMAND + ) + ); + + await page.evaluate(async (filePath: string) => { + try { + await window.jupyterapp.serviceManager.contents.delete(filePath); + } catch { + // Ignore when file does not exist; this keeps screenshot generation idempotent. + } + }, READABLE_DEMO_FILE); + + await page.evaluate( + async ({ + commandId, + filePath + }: { + commandId: string; + filePath: string; + }) => { + await window.jupyterapp.commands.execute(commandId, { path: filePath }); + }, + { + commandId: CREATE_FILE_COMMAND, + filePath: READABLE_DEMO_FILE + } + ); + + await expect(page.getByText(/hello-world:plugin/).first()).toBeVisible({ + timeout: 10_000 + }); + + const editorPanel = page.getByRole('tabpanel', { name: READABLE_DEMO_FILE }); + await expect(editorPanel).toBeVisible(); + const toolbar = editorPanel.getByRole('toolbar', { + name: 'main area toolbar' + }); + await expect(toolbar).toBeVisible(); + const loadOnSaveCheckbox = page.getByRole('checkbox', { + name: LOAD_ON_SAVE_CHECKBOX_LABEL + }); + await expect(loadOnSaveCheckbox).toBeVisible(); + if (!(await loadOnSaveCheckbox.isChecked())) { + await loadOnSaveCheckbox.check(); + } + + const extensionPointsSection = await openSidebarPanel(page, TOKEN_SECTION_ID); + await page.sidebar.setWidth(EXTENSION_POINTS_GALLERY_WIDTH, 'right'); + + await saveTopCroppedScreenshot( + page, + editorPanel, + 'editor-toolbar-actions.png', + EDITOR_TOOLBAR_SCREENSHOT_HEIGHT + ); + + await extensionPointsSection.getByRole('tab', { name: 'Tokens' }).click(); + const tokenFilter = extensionPointsSection.getByPlaceholder( + 'Filter token strings' + ); + await tokenFilter.fill('@jupyterlab/'); + await expect( + extensionPointsSection.locator('.jp-PluginPlayground-listItem').first() + ).toBeVisible(); + await saveScreenshot(extensionPointsSection, 'extension-points-tokens.png'); + + await extensionPointsSection.getByRole('tab', { name: 'Commands' }).click(); + const commandFilter = extensionPointsSection.locator( + 'input[aria-label="Filter command ids"]' + ); + await commandFilter.fill('plugin-playground:'); + await expect( + extensionPointsSection.locator('.jp-PluginPlayground-listItem').first() + ).toBeVisible(); + await saveScreenshot(extensionPointsSection, 'extension-points-commands.png'); + + await extensionPointsSection.getByRole('tab', { name: 'Packages' }).click(); + const packageFilter = extensionPointsSection.locator( + 'input[aria-label="Filter packages"]' + ); + await packageFilter.fill('@jupyterlab/'); + await expect( + extensionPointsSection.locator('.jp-PluginPlayground-listItem').first() + ).toBeVisible({ + timeout: 20_000 + }); + await saveScreenshot(extensionPointsSection, 'packages-reference.png'); + + await page.sidebar.setWidth(RIGHT_SIDEBAR_SCREENSHOT_WIDTH, 'right'); + await extensionPointsSection.getByRole('tab', { name: 'Commands' }).click(); + await commandFilter.fill('plugin-playground:'); + await expect( + extensionPointsSection.locator('.jp-PluginPlayground-listItem').first() + ).toBeVisible(); + + const firstCommandItem = extensionPointsSection + .locator('.jp-PluginPlayground-listItem') + .first(); + await expect(firstCommandItem).toBeVisible(); + + const commandInsertModeButton = firstCommandItem.locator( + '.jp-PluginPlayground-commandInsertDropdownButton' + ); + await commandInsertModeButton.click(); + const aiInsertMenuItem = page + .locator('.lm-Menu-itemLabel:text-is("Prompt AI to insert")') + .first(); + await expect(aiInsertMenuItem).toBeVisible(); + await saveScreenshot( + extensionPointsSection, + 'command-insert-mode-dropdown.png' + ); + await aiInsertMenuItem.click(); + + const argumentDocsButton = extensionPointsSection + .locator('.jp-PluginPlayground-argumentBadgeButton:not([disabled])') + .first(); + await expect(argumentDocsButton).toBeVisible({ + timeout: 20_000 + }); + const commandItemWithArgumentDocs = argumentDocsButton.locator( + 'xpath=ancestor::li[contains(@class,"jp-PluginPlayground-listItem")]' + ); + await argumentDocsButton.click(); + await expect( + commandItemWithArgumentDocs.locator( + '.jp-PluginPlayground-commandArgumentsText' + ) + ).toBeVisible({ + timeout: 20_000 + }); + await saveScreenshot(extensionPointsSection, 'command-argument-docs.png'); + + const examplesSection = await openSidebarPanel(page, EXAMPLE_SECTION_ID); + const examplesFilter = examplesSection.locator( + 'input[aria-label="Filter extension examples"]' + ); + await examplesFilter.fill('launcher'); + const firstExampleItem = examplesSection + .locator('.jp-PluginPlayground-listItem') + .first(); + await expect(firstExampleItem).toBeVisible({ + timeout: 20_000 + }); + await saveSectionCroppedToAnchor( + page, + examplesSection, + firstExampleItem, + 'extension-examples.png' + ); + + await page.evaluate(async () => { + if (!window.jupyterapp.commands.hasCommand('settingeditor:open')) { + throw new Error('settingeditor:open command is unavailable.'); + } + await window.jupyterapp.commands.execute('settingeditor:open'); + }); + + const settingsPanel = page.getByRole('tabpanel', { name: 'Settings' }); + await expect(settingsPanel).toBeVisible({ + timeout: 20_000 + }); + const settingsSearchInput = settingsPanel.getByRole('searchbox').first(); + await settingsSearchInput.fill('Default command insertion mode'); + await expect( + settingsPanel.getByText('Default command insertion mode').first() + ).toBeVisible({ + timeout: 20_000 + }); + const defaultInsertModeSetting = settingsPanel + .getByText('Default command insertion mode') + .first(); + await saveSectionCroppedToAnchor( + page, + settingsPanel, + defaultInsertModeSetting, + 'settings-command-insert-default-mode.png', + { + bottomPadding: SETTINGS_BOTTOM_PADDING, + minHeight: SETTINGS_MIN_HEIGHT + } + ); + + await page.evaluate(async (filePath: string) => { + try { + await window.jupyterapp.serviceManager.contents.delete(filePath); + } catch (error) { + console.warn( + `Could not delete screenshot demo file "${filePath}"`, + error + ); + } + }, READABLE_DEMO_FILE); +});