From ac6b5ad071a86349179659b471405f9b3be5dae6 Mon Sep 17 00:00:00 2001 From: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> Date: Tue, 1 Jul 2025 19:16:30 +0530 Subject: [PATCH 01/16] Check out sharing service --- .github/workflows/build.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d8041b95..aa75f3e7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -211,6 +211,13 @@ jobs: - name: Base Setup uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 + - name: Checkout CKHub sharing service + uses: actions/checkout@v4 + with: + repository: jupytereverywhere/sharing-service + path: ckhub-sharing-service + token: ${{ secrets.GH_PAT }} + persist-credentials: false - name: Download lite app (test mode) uses: actions/download-artifact@v4 with: From 3c7e2d7eb27ca6cc435577709836c184b1ea1017 Mon Sep 17 00:00:00 2001 From: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> Date: Tue, 1 Jul 2025 19:16:46 +0530 Subject: [PATCH 02/16] Don't persist credentials for this repository too --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aa75f3e7..0d6067d4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -207,6 +207,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + persist-credentials: false - name: Base Setup uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 From c10945b11747b97b1f23092444622b24bdcd9259 Mon Sep 17 00:00:00 2001 From: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> Date: Tue, 1 Jul 2025 19:18:22 +0530 Subject: [PATCH 03/16] Set up Docker Compose --- .github/workflows/build.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0d6067d4..8e754661 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -213,6 +213,11 @@ jobs: - name: Base Setup uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 + - name: Set up Docker Compose + uses: docker/setup-compose-action@v1 + with: + cache-binary: true + - name: Checkout CKHub sharing service uses: actions/checkout@v4 with: From 4ec0f1546585f9d740dc8ec2a9cee3a26785c657 Mon Sep 17 00:00:00 2001 From: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> Date: Tue, 1 Jul 2025 19:18:13 +0530 Subject: [PATCH 04/16] Start CKHub sharing service --- .github/workflows/build.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8e754661..20186093 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -225,6 +225,11 @@ jobs: path: ckhub-sharing-service token: ${{ secrets.GH_PAT }} persist-credentials: false + + - name: Start CKHub sharing service + working-directory: ckhub-sharing-service + run: docker compose up --build --detach + - name: Download lite app (test mode) uses: actions/download-artifact@v4 with: From 9b17b099a92230cf512d83ac3537f631e9af2261 Mon Sep 17 00:00:00 2001 From: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> Date: Tue, 1 Jul 2025 19:23:47 +0530 Subject: [PATCH 05/16] Add explicit contents read permissions --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 20186093..1bc46ada 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -200,6 +200,8 @@ jobs: name: Integration tests needs: lite runs-on: ubuntu-latest + permissions: + contents: read env: PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/pw-browsers From 73b836d02c0e06f8316f010592119a17315f16d8 Mon Sep 17 00:00:00 2001 From: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> Date: Tue, 15 Jul 2025 06:00:49 +0530 Subject: [PATCH 06/16] Remove token --- .github/workflows/build.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1bc46ada..feec008a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -225,7 +225,6 @@ jobs: with: repository: jupytereverywhere/sharing-service path: ckhub-sharing-service - token: ${{ secrets.GH_PAT }} persist-credentials: false - name: Start CKHub sharing service From fd159cc58def4cfab93fe65879dc7a5a420fee69 Mon Sep 17 00:00:00 2001 From: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> Date: Tue, 15 Jul 2025 06:34:46 +0530 Subject: [PATCH 07/16] Don't mock the sharing service token --- ui-tests/tests/jupytereverywhere.spec.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/ui-tests/tests/jupytereverywhere.spec.ts b/ui-tests/tests/jupytereverywhere.spec.ts index b2e11178..b0784648 100644 --- a/ui-tests/tests/jupytereverywhere.spec.ts +++ b/ui-tests/tests/jupytereverywhere.spec.ts @@ -51,13 +51,6 @@ const TEST_NOTEBOOK = { nbformat_minor: 5 }; -async function mockTokenRoute(page: Page) { - await page.route('**/api/v1/auth/issue', async route => { - const json = { token: 'test-token' }; - await route.fulfill({ json }); - }); -} - async function mockGetSharedNotebook(page: Page, notebookId: string) { await page.route('**/api/v1/notebooks/*', async route => { const json = { @@ -117,7 +110,6 @@ test.describe('General', () => { }); test('Should load a view-only notebook', async ({ page }) => { - await mockTokenRoute(page); const notebookId = 'e3b0c442-98fc-1fc2-9c9f-8b6d6ed08a1d'; await page.route('**/api/v1/notebooks/*', async route => { @@ -148,7 +140,6 @@ test.describe('General', () => { test.describe('Sharing', () => { test('Should open share dialog in interactive notebook', async ({ page }) => { - await mockTokenRoute(page); await mockShareNotebookResponse(page, 'e3b0c442-98fc-1fc2-9c9f-8b6d6ed08a1d'); const shareButton = page.locator('.jp-ToolbarButton').getByTitle('Share this notebook'); await shareButton.click(); @@ -157,8 +148,6 @@ test.describe('Sharing', () => { }); test('Should open share dialog in view-only mode', async ({ page }) => { - await mockTokenRoute(page); - // Load view-only (shared) notebook const notebookId = 'e3b0c442-98fc-1fc2-9c9f-8b6d6ed08a1d'; await mockGetSharedNotebook(page, notebookId); @@ -186,7 +175,6 @@ test.describe('Download', () => { }); test('Should download a notebook as IPyNB and PDF', async ({ page, context }) => { - await mockTokenRoute(page); await mockShareNotebookResponse(page, 'test-download-regular-notebook'); const ipynbDownload = page.waitForEvent('download'); @@ -201,8 +189,6 @@ test.describe('Download', () => { }); test('Should download view-only notebook as IPyNB and PDF', async ({ page }) => { - await mockTokenRoute(page); - const notebookId = 'e3b0c442-98fc-1fc2-9c9f-8b6d6ed08a1d'; await mockGetSharedNotebook(page, notebookId); await mockShareNotebookResponse(page, 'test-download-viewonly-notebook'); @@ -259,8 +245,6 @@ test.describe('Files', () => { }); test('Should remove View Only banner when the Create Copy button is clicked', async ({ page }) => { - await mockTokenRoute(page); - const notebookId = 'e3b0c442-98fc-1fc2-9c9f-8b6d6ed08a1d'; await mockGetSharedNotebook(page, notebookId); From 113df1e9043ccefe9b874c78ba83b57f07a1bf51 Mon Sep 17 00:00:00 2001 From: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> Date: Tue, 15 Jul 2025 19:38:38 +0530 Subject: [PATCH 08/16] Temporarily expose the `SharingService` --- src/index.ts | 2 ++ src/pages/notebook.tsx | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index d1f46b53..4e913196 100644 --- a/src/index.ts +++ b/src/index.ts @@ -145,6 +145,8 @@ const plugin: JupyterFrontEndPlugin = { PageConfig.getOption('sharing_service_api_url') || 'http://localhost:8080/api/v1'; const sharingService = new SharingService(apiUrl); + // Temporary + (window as any).sharingService = sharingService; /** * Hook into notebook saves using the saveState signal to handle CKHub sharing diff --git a/src/pages/notebook.tsx b/src/pages/notebook.tsx index f78b2bf0..15c4d05a 100644 --- a/src/pages/notebook.tsx +++ b/src/pages/notebook.tsx @@ -39,7 +39,10 @@ export const notebookPlugin: JupyterFrontEndPlugin = { const apiUrl = PageConfig.getOption('sharing_service_api_url') || 'http://localhost:8080/api/v1'; - const sharingService = new SharingService(apiUrl); + + // Temporary + const sharingService = (window as any).sharingService ?? new SharingService(apiUrl); + (window as any).sharingService = sharingService; console.log(`API URL: ${apiUrl}`); console.log('Retrieving notebook from API...'); From 77a90e2a924a42854b7138b47e8b03f8eeb5fe06 Mon Sep 17 00:00:00 2001 From: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> Date: Tue, 15 Jul 2025 19:47:18 +0530 Subject: [PATCH 09/16] Try to clear token before each test --- src/sharing-service.ts | 7 +++++++ ui-tests/tests/jupytereverywhere.spec.ts | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/src/sharing-service.ts b/src/sharing-service.ts index df5a2f0c..a8f6e117 100644 --- a/src/sharing-service.ts +++ b/src/sharing-service.ts @@ -396,4 +396,11 @@ export class SharingService { return headers; } + + /** + * Clears the cached token so a fresh one is fetched on next request. + */ + resetToken(): void { + this._token = undefined; + } } diff --git a/ui-tests/tests/jupytereverywhere.spec.ts b/ui-tests/tests/jupytereverywhere.spec.ts index b0784648..efcceeb3 100644 --- a/ui-tests/tests/jupytereverywhere.spec.ts +++ b/ui-tests/tests/jupytereverywhere.spec.ts @@ -76,6 +76,11 @@ async function mockShareNotebookResponse(page: Page, notebookId: string) { test.beforeEach(async ({ page }) => { await page.goto('lab/index.html'); await page.waitForSelector('.jp-LabShell'); + + // Clear token before each test + await page.evaluate(() => { + window.sharingService?.resetToken(); + }); }); test.describe('General', () => { From 2ffa27519fdf8d6052d8395db1c06a72f75ea45e Mon Sep 17 00:00:00 2001 From: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:33:57 +0530 Subject: [PATCH 10/16] Remove mocks, try turning off kernel selection --- ui-tests/tests/jupytereverywhere.spec.ts | 146 +++++++++-------------- 1 file changed, 59 insertions(+), 87 deletions(-) diff --git a/ui-tests/tests/jupytereverywhere.spec.ts b/ui-tests/tests/jupytereverywhere.spec.ts index efcceeb3..8328077c 100644 --- a/ui-tests/tests/jupytereverywhere.spec.ts +++ b/ui-tests/tests/jupytereverywhere.spec.ts @@ -3,12 +3,20 @@ import path from 'path'; import type { JupyterLab } from '@jupyterlab/application'; import type { JSONObject } from '@lumino/coreutils'; +import { SharingService } from '../../src/sharing-service'; + declare global { interface Window { jupyterapp: JupyterLab; } } +declare global { + interface Window { + sharingService?: SharingService; + } +} + async function runCommand(page: Page, command: string, args: JSONObject = {}) { await page.evaluate( async ({ command, args }) => { @@ -18,63 +26,30 @@ async function runCommand(page: Page, command: string, args: JSONObject = {}) { ); } -const TEST_NOTEBOOK = { - cells: [ - { - cell_type: 'code', - execution_count: null, - id: '55eb9a2d-401d-4abd-b0eb-373ded5b408d', - outputs: [], - metadata: {}, - source: [`# This is a test notebook`] - } - ], - metadata: { - kernelspec: { - display_name: 'Python 3 (ipykernel)', - language: 'python', - name: 'python3' - }, - language_info: { - codemirror_mode: { - name: 'ipython', - version: 3 - }, - file_extension: '.py', - mimetype: 'text/x-python', - name: 'python', - nbconvert_exporter: 'python', - pygments_lexer: 'ipython3' - } - }, - nbformat: 4, - nbformat_minor: 5 -}; - -async function mockGetSharedNotebook(page: Page, notebookId: string) { - await page.route('**/api/v1/notebooks/*', async route => { - const json = { - id: notebookId, - domain_id: 'domain', - readable_id: null, - content: TEST_NOTEBOOK - }; - await route.fulfill({ json }); +async function dismissKernelSelectDialog(page: Page) { + const kernelDialogHeader = page.locator('.jp-Dialog .jp-Dialog-header', { + hasText: 'Select Kernel' }); + + if ((await kernelDialogHeader.count()) === 0) { + return; + } + + const selectButtonLabel = kernelDialogHeader.locator( + 'xpath=../../..//div[@aria-label="Select Kernel"]' + ); + + if (await selectButtonLabel.count()) { + await selectButtonLabel.first().click(); + } } -async function mockShareNotebookResponse(page: Page, notebookId: string) { - await page.route('**/api/v1/notebooks', async route => { - const json = { - message: 'Shared!', - notebook: { id: notebookId, readable_id: null } - }; - await route.fulfill({ json }); - }); +async function getSharedNotebookID(page: Page) { + return new URL(page.url()).searchParams.get('notebook'); } test.beforeEach(async ({ page }) => { - await page.goto('lab/index.html'); + await page.goto('lab/index.html?kernel=python'); await page.waitForSelector('.jp-LabShell'); // Clear token before each test @@ -95,11 +70,13 @@ test.describe('General', () => { }); test('Dialog windows should shade the notebook area only', async ({ page }) => { + await dismissKernelSelectDialog(page); const firstCell = page.locator('.jp-Cell'); await firstCell .getByRole('textbox') .fill('The shaded area should cover the notebook content, but not the toolbar.'); const promise = runCommand(page, 'notebook:restart-kernel'); + await dismissKernelSelectDialog(page); const dialog = page.locator('.jp-Dialog'); expect( @@ -115,19 +92,13 @@ test.describe('General', () => { }); test('Should load a view-only notebook', async ({ page }) => { - const notebookId = 'e3b0c442-98fc-1fc2-9c9f-8b6d6ed08a1d'; - - await page.route('**/api/v1/notebooks/*', async route => { - const json = { - id: notebookId, - domain_id: 'domain', - readable_id: null, - content: TEST_NOTEBOOK - }; - await route.fulfill({ json }); - }); + await runCommand(page, 'jupytereverywhere:share-notebook'); - await page.goto(`lab/index.html?notebook=${notebookId}`); + const notebookId = await getSharedNotebookID(page); + expect(notebookId).not.toBeNull(); + + await page.goto(`lab/index.html?notebook=${notebookId}&kernel=python`); + dismissKernelSelectDialog(page); expect( await page.locator('.jp-NotebookPanel').screenshot({ @@ -139,33 +110,31 @@ test.describe('General', () => { test('Should open files page', async ({ page }) => { await page.locator('.jp-SideBar').getByTitle('Files').click(); + await dismissKernelSelectDialog(page); expect(await page.locator('#je-files').screenshot()).toMatchSnapshot('files.png'); }); }); test.describe('Sharing', () => { test('Should open share dialog in interactive notebook', async ({ page }) => { - await mockShareNotebookResponse(page, 'e3b0c442-98fc-1fc2-9c9f-8b6d6ed08a1d'); - const shareButton = page.locator('.jp-ToolbarButton').getByTitle('Share this notebook'); - await shareButton.click(); + await runCommand(page, 'jupytereverywhere:share-notebook'); const dialog = page.locator('.jp-Dialog-content'); expect(await dialog.screenshot()).toMatchSnapshot('share-dialog.png'); }); test('Should open share dialog in view-only mode', async ({ page }) => { // Load view-only (shared) notebook - const notebookId = 'e3b0c442-98fc-1fc2-9c9f-8b6d6ed08a1d'; - await mockGetSharedNotebook(page, notebookId); - await page.goto(`lab/index.html?notebook=${notebookId}`); + await runCommand(page, 'jupytereverywhere:share-notebook'); + const notebookId = await getSharedNotebookID(page); + expect(notebookId).not.toBeNull(); - // Re-Share it as a new notebook - const newNotebookId = '104931f8-fd96-489e-8520-c1793cbba6ce'; - await mockShareNotebookResponse(page, newNotebookId); + // Re-share it as a new notebook + await page.goto(`lab/index.html?notebook=${notebookId}&kernel=python`); + dismissKernelSelectDialog(page); - const shareButton = page.locator('.jp-ToolbarButton').getByTitle('Share this notebook'); const dialog = page.locator('.jp-Dialog-content'); await expect(dialog).toHaveCount(0); - await shareButton.click(); + await runCommand(page, 'jupytereverywhere:share-notebook'); await expect(dialog).toHaveCount(1); }); }); @@ -180,7 +149,10 @@ test.describe('Download', () => { }); test('Should download a notebook as IPyNB and PDF', async ({ page, context }) => { - await mockShareNotebookResponse(page, 'test-download-regular-notebook'); + dismissKernelSelectDialog(page); + await runCommand(page, 'jupytereverywhere:share-notebook'); + await getSharedNotebookID(page); + dismissKernelSelectDialog(page); const ipynbDownload = page.waitForEvent('download'); await runCommand(page, 'jupytereverywhere:download-notebook'); @@ -194,24 +166,24 @@ test.describe('Download', () => { }); test('Should download view-only notebook as IPyNB and PDF', async ({ page }) => { - const notebookId = 'e3b0c442-98fc-1fc2-9c9f-8b6d6ed08a1d'; - await mockGetSharedNotebook(page, notebookId); - await mockShareNotebookResponse(page, 'test-download-viewonly-notebook'); + await runCommand(page, 'jupytereverywhere:share-notebook'); + const notebookId = await getSharedNotebookID(page); + expect(notebookId).not.toBeNull(); - await page.goto(`lab/index.html?notebook=${notebookId}`); + await page.goto(`lab/index.html?notebook=${notebookId}&kernel=python`); + dismissKernelSelectDialog(page); // Wait until view-only notebook loads, and assert it is a view-only notebook. await page.locator('.jp-NotebookPanel').waitFor(); await expect(page.locator('.je-ViewOnlyHeader')).toBeVisible(); const ipynbDownload = page.waitForEvent('download'); - await runCommand(page, 'jupytereverywhere:download-pdf'); + await runCommand(page, 'jupytereverywhere:download-notebook'); const ipynbPath = await (await ipynbDownload).path(); expect(ipynbPath).not.toBeNull(); const pdfDownload = page.waitForEvent('download'); await runCommand(page, 'jupytereverywhere:download-pdf'); - const pdfPath = await (await pdfDownload).path(); expect(pdfPath).not.toBeNull(); }); @@ -219,7 +191,7 @@ test.describe('Download', () => { test.describe('Files', () => { test('Should upload two files and display their thumbnails', async ({ page }) => { - await page.goto('lab/index.html'); + await page.goto('lab/index.html?kernel=python'); await page.waitForSelector('.jp-LabShell'); await page.locator('.jp-SideBar').getByTitle('Files').click(); @@ -250,15 +222,15 @@ test.describe('Files', () => { }); test('Should remove View Only banner when the Create Copy button is clicked', async ({ page }) => { - const notebookId = 'e3b0c442-98fc-1fc2-9c9f-8b6d6ed08a1d'; - await mockGetSharedNotebook(page, notebookId); + await runCommand(page, 'jupytereverywhere:share-notebook'); + const notebookId = await getSharedNotebookID(page); + expect(notebookId).not.toBeNull(); // Open view-only notebook - await page.goto(`lab/index.html?notebook=${notebookId}`); + await page.goto(`lab/index.html?notebook=${notebookId}&kernel=python`); await expect(page.locator('.je-ViewOnlyHeader')).toBeVisible(); - const createCopyButton = page.locator('.jp-ToolbarButtonComponent.je-CreateCopyButton'); - await createCopyButton.click(); + await runCommand(page, 'jupytereverywhere:create-copy-notebook'); await expect(page.locator('.je-ViewOnlyHeader')).toBeHidden({ timeout: 10000 }); From 4d8edb9810353ab33fe31c299262e8d6a5dc5df9 Mon Sep 17 00:00:00 2001 From: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:35:10 +0530 Subject: [PATCH 11/16] Dismiss kernel dialog for File uploads test --- ui-tests/tests/jupytereverywhere.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ui-tests/tests/jupytereverywhere.spec.ts b/ui-tests/tests/jupytereverywhere.spec.ts index 8328077c..4fafac20 100644 --- a/ui-tests/tests/jupytereverywhere.spec.ts +++ b/ui-tests/tests/jupytereverywhere.spec.ts @@ -195,6 +195,7 @@ test.describe('Files', () => { await page.waitForSelector('.jp-LabShell'); await page.locator('.jp-SideBar').getByTitle('Files').click(); + await dismissKernelSelectDialog(page); await page.locator('.je-FileTile').first().click(); // the first tile will always be the "add new" one From bce631a1bad6b6a92ebeeb4197fc1a6159c3c2e8 Mon Sep 17 00:00:00 2001 From: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:42:33 +0530 Subject: [PATCH 12/16] Drop all modifications to tests and restore mocks --- src/index.ts | 2 - src/pages/notebook.tsx | 5 +- src/sharing-service.ts | 7 - ui-tests/tests/jupytereverywhere.spec.ts | 166 ++++++++++++++--------- 4 files changed, 103 insertions(+), 77 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0bcf6916..ba657730 100644 --- a/src/index.ts +++ b/src/index.ts @@ -145,8 +145,6 @@ const plugin: JupyterFrontEndPlugin = { PageConfig.getOption('sharing_service_api_url') || 'http://localhost:8080/api/v1'; const sharingService = new SharingService(apiUrl); - // Temporary - (window as any).sharingService = sharingService; /** * Hook into notebook saves using the saveState signal to handle CKHub sharing diff --git a/src/pages/notebook.tsx b/src/pages/notebook.tsx index 15c4d05a..f78b2bf0 100644 --- a/src/pages/notebook.tsx +++ b/src/pages/notebook.tsx @@ -39,10 +39,7 @@ export const notebookPlugin: JupyterFrontEndPlugin = { const apiUrl = PageConfig.getOption('sharing_service_api_url') || 'http://localhost:8080/api/v1'; - - // Temporary - const sharingService = (window as any).sharingService ?? new SharingService(apiUrl); - (window as any).sharingService = sharingService; + const sharingService = new SharingService(apiUrl); console.log(`API URL: ${apiUrl}`); console.log('Retrieving notebook from API...'); diff --git a/src/sharing-service.ts b/src/sharing-service.ts index a8f6e117..df5a2f0c 100644 --- a/src/sharing-service.ts +++ b/src/sharing-service.ts @@ -396,11 +396,4 @@ export class SharingService { return headers; } - - /** - * Clears the cached token so a fresh one is fetched on next request. - */ - resetToken(): void { - this._token = undefined; - } } diff --git a/ui-tests/tests/jupytereverywhere.spec.ts b/ui-tests/tests/jupytereverywhere.spec.ts index 7d3e15ae..d6f85d29 100644 --- a/ui-tests/tests/jupytereverywhere.spec.ts +++ b/ui-tests/tests/jupytereverywhere.spec.ts @@ -3,20 +3,12 @@ import path from 'path'; import type { JupyterLab } from '@jupyterlab/application'; import type { JSONObject } from '@lumino/coreutils'; -import { SharingService } from '../../src/sharing-service'; - declare global { interface Window { jupyterapp: JupyterLab; } } -declare global { - interface Window { - sharingService?: SharingService; - } -} - async function runCommand(page: Page, command: string, args: JSONObject = {}) { await page.evaluate( async ({ command, args }) => { @@ -26,36 +18,71 @@ async function runCommand(page: Page, command: string, args: JSONObject = {}) { ); } -async function dismissKernelSelectDialog(page: Page) { - const kernelDialogHeader = page.locator('.jp-Dialog .jp-Dialog-header', { - hasText: 'Select Kernel' +const TEST_NOTEBOOK = { + cells: [ + { + cell_type: 'code', + execution_count: null, + id: '55eb9a2d-401d-4abd-b0eb-373ded5b408d', + outputs: [], + metadata: {}, + source: [`# This is a test notebook`] + } + ], + metadata: { + kernelspec: { + display_name: 'Python 3 (ipykernel)', + language: 'python', + name: 'python3' + }, + language_info: { + codemirror_mode: { + name: 'ipython', + version: 3 + }, + file_extension: '.py', + mimetype: 'text/x-python', + name: 'python', + nbconvert_exporter: 'python', + pygments_lexer: 'ipython3' + } + }, + nbformat: 4, + nbformat_minor: 5 +}; + +async function mockTokenRoute(page: Page) { + await page.route('**/api/v1/auth/issue', async route => { + const json = { token: 'test-token' }; + await route.fulfill({ json }); }); +} - if ((await kernelDialogHeader.count()) === 0) { - return; - } - - const selectButtonLabel = kernelDialogHeader.locator( - 'xpath=../../..//div[@aria-label="Select Kernel"]' - ); - - if (await selectButtonLabel.count()) { - await selectButtonLabel.first().click(); - } +async function mockGetSharedNotebook(page: Page, notebookId: string) { + await page.route('**/api/v1/notebooks/*', async route => { + const json = { + id: notebookId, + domain_id: 'domain', + readable_id: null, + content: TEST_NOTEBOOK + }; + await route.fulfill({ json }); + }); } -async function getSharedNotebookID(page: Page) { - return new URL(page.url()).searchParams.get('notebook'); +async function mockShareNotebookResponse(page: Page, notebookId: string) { + await page.route('**/api/v1/notebooks', async route => { + const json = { + message: 'Shared!', + notebook: { id: notebookId, readable_id: null } + }; + await route.fulfill({ json }); + }); } test.beforeEach(async ({ page }) => { - await page.goto('lab/index.html?kernel=python'); + await page.goto('lab/index.html'); await page.waitForSelector('.jp-LabShell'); - - // Clear token before each test - await page.evaluate(() => { - window.sharingService?.resetToken(); - }); }); test.describe('General', () => { @@ -70,13 +97,11 @@ test.describe('General', () => { }); test('Dialog windows should shade the notebook area only', async ({ page }) => { - await dismissKernelSelectDialog(page); const firstCell = page.locator('.jp-Cell'); await firstCell .getByRole('textbox') .fill('The shaded area should cover the notebook content, but not the toolbar.'); const promise = runCommand(page, 'notebook:restart-kernel'); - await dismissKernelSelectDialog(page); const dialog = page.locator('.jp-Dialog'); expect( @@ -92,13 +117,20 @@ test.describe('General', () => { }); test('Should load a view-only notebook', async ({ page }) => { - await runCommand(page, 'jupytereverywhere:share-notebook'); - - const notebookId = await getSharedNotebookID(page); - expect(notebookId).not.toBeNull(); + await mockTokenRoute(page); + const notebookId = 'e3b0c442-98fc-1fc2-9c9f-8b6d6ed08a1d'; + + await page.route('**/api/v1/notebooks/*', async route => { + const json = { + id: notebookId, + domain_id: 'domain', + readable_id: null, + content: TEST_NOTEBOOK + }; + await route.fulfill({ json }); + }); - await page.goto(`lab/index.html?notebook=${notebookId}&kernel=python`); - dismissKernelSelectDialog(page); + await page.goto(`lab/index.html?notebook=${notebookId}`); expect( await page.locator('.jp-NotebookPanel').screenshot({ @@ -110,31 +142,36 @@ test.describe('General', () => { test('Should open files page', async ({ page }) => { await page.locator('.jp-SideBar').getByTitle('Files').click(); - await dismissKernelSelectDialog(page); expect(await page.locator('#je-files').screenshot()).toMatchSnapshot('files.png'); }); }); test.describe('Sharing', () => { test('Should open share dialog in interactive notebook', async ({ page }) => { - await runCommand(page, 'jupytereverywhere:share-notebook'); + await mockTokenRoute(page); + await mockShareNotebookResponse(page, 'e3b0c442-98fc-1fc2-9c9f-8b6d6ed08a1d'); + const shareButton = page.locator('.jp-ToolbarButton').getByTitle('Share this notebook'); + await shareButton.click(); const dialog = page.locator('.jp-Dialog-content'); expect(await dialog.screenshot()).toMatchSnapshot('share-dialog.png'); }); test('Should open share dialog in view-only mode', async ({ page }) => { + await mockTokenRoute(page); + // Load view-only (shared) notebook - await runCommand(page, 'jupytereverywhere:share-notebook'); - const notebookId = await getSharedNotebookID(page); - expect(notebookId).not.toBeNull(); + const notebookId = 'e3b0c442-98fc-1fc2-9c9f-8b6d6ed08a1d'; + await mockGetSharedNotebook(page, notebookId); + await page.goto(`lab/index.html?notebook=${notebookId}`); - // Re-share it as a new notebook - await page.goto(`lab/index.html?notebook=${notebookId}&kernel=python`); - dismissKernelSelectDialog(page); + // Re-Share it as a new notebook + const newNotebookId = '104931f8-fd96-489e-8520-c1793cbba6ce'; + await mockShareNotebookResponse(page, newNotebookId); + const shareButton = page.locator('.jp-ToolbarButton').getByTitle('Share this notebook'); const dialog = page.locator('.jp-Dialog-content'); await expect(dialog).toHaveCount(0); - await runCommand(page, 'jupytereverywhere:share-notebook'); + await shareButton.click(); await expect(dialog).toHaveCount(1); }); @@ -158,10 +195,8 @@ test.describe('Download', () => { }); test('Should download a notebook as IPyNB and PDF', async ({ page, context }) => { - dismissKernelSelectDialog(page); - await runCommand(page, 'jupytereverywhere:share-notebook'); - await getSharedNotebookID(page); - dismissKernelSelectDialog(page); + await mockTokenRoute(page); + await mockShareNotebookResponse(page, 'test-download-regular-notebook'); const ipynbDownload = page.waitForEvent('download'); await runCommand(page, 'jupytereverywhere:download-notebook'); @@ -175,24 +210,26 @@ test.describe('Download', () => { }); test('Should download view-only notebook as IPyNB and PDF', async ({ page }) => { - await runCommand(page, 'jupytereverywhere:share-notebook'); - const notebookId = await getSharedNotebookID(page); - expect(notebookId).not.toBeNull(); + await mockTokenRoute(page); - await page.goto(`lab/index.html?notebook=${notebookId}&kernel=python`); - dismissKernelSelectDialog(page); + const notebookId = 'e3b0c442-98fc-1fc2-9c9f-8b6d6ed08a1d'; + await mockGetSharedNotebook(page, notebookId); + await mockShareNotebookResponse(page, 'test-download-viewonly-notebook'); + + await page.goto(`lab/index.html?notebook=${notebookId}`); // Wait until view-only notebook loads, and assert it is a view-only notebook. await page.locator('.jp-NotebookPanel').waitFor(); await expect(page.locator('.je-ViewOnlyHeader')).toBeVisible(); const ipynbDownload = page.waitForEvent('download'); - await runCommand(page, 'jupytereverywhere:download-notebook'); + await runCommand(page, 'jupytereverywhere:download-pdf'); const ipynbPath = await (await ipynbDownload).path(); expect(ipynbPath).not.toBeNull(); const pdfDownload = page.waitForEvent('download'); await runCommand(page, 'jupytereverywhere:download-pdf'); + const pdfPath = await (await pdfDownload).path(); expect(pdfPath).not.toBeNull(); }); @@ -200,11 +237,10 @@ test.describe('Download', () => { test.describe('Files', () => { test('Should upload two files and display their thumbnails', async ({ page }) => { - await page.goto('lab/index.html?kernel=python'); + await page.goto('lab/index.html'); await page.waitForSelector('.jp-LabShell'); await page.locator('.jp-SideBar').getByTitle('Files').click(); - await dismissKernelSelectDialog(page); await page.locator('.je-FileTile').first().click(); // the first tile will always be the "add new" one @@ -232,15 +268,17 @@ test.describe('Files', () => { }); test('Should remove View Only banner when the Create Copy button is clicked', async ({ page }) => { - await runCommand(page, 'jupytereverywhere:share-notebook'); - const notebookId = await getSharedNotebookID(page); - expect(notebookId).not.toBeNull(); + await mockTokenRoute(page); + + const notebookId = 'e3b0c442-98fc-1fc2-9c9f-8b6d6ed08a1d'; + await mockGetSharedNotebook(page, notebookId); // Open view-only notebook - await page.goto(`lab/index.html?notebook=${notebookId}&kernel=python`); + await page.goto(`lab/index.html?notebook=${notebookId}`); await expect(page.locator('.je-ViewOnlyHeader')).toBeVisible(); - await runCommand(page, 'jupytereverywhere:create-copy-notebook'); + const createCopyButton = page.locator('.jp-ToolbarButtonComponent.je-CreateCopyButton'); + await createCopyButton.click(); await expect(page.locator('.je-ViewOnlyHeader')).toBeHidden({ timeout: 10000 }); From ef753f8214aeb7ef9b01f271b6e982baa3e8c9fd Mon Sep 17 00:00:00 2001 From: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> Date: Wed, 16 Jul 2025 00:38:59 +0530 Subject: [PATCH 13/16] Add functional test for the sharing service --- ui-tests/tests/sharing-service.spec.ts | 113 +++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 ui-tests/tests/sharing-service.spec.ts diff --git a/ui-tests/tests/sharing-service.spec.ts b/ui-tests/tests/sharing-service.spec.ts new file mode 100644 index 00000000..16bbb6b1 --- /dev/null +++ b/ui-tests/tests/sharing-service.spec.ts @@ -0,0 +1,113 @@ +import { test, expect, Page } from '@playwright/test'; +import type { JupyterLab } from '@jupyterlab/application'; +import type { JSONObject } from '@lumino/coreutils'; + +declare global { + interface Window { + jupyterapp: JupyterLab; + } +} + +async function runCommand(page: Page, command: string, args: JSONObject = {}) { + await page.evaluate( + async ({ command, args }) => { + await window.jupyterapp.commands.execute(command, args); + }, + { command, args } + ); +} + +const TEST_NOTEBOOK = { + cells: [ + { + cell_type: 'code', + execution_count: null, + id: 'test-cell-1', + outputs: [], + metadata: {}, + source: ['print("Hello from CKHub shared notebook!")'] + }, + { + cell_type: 'markdown', + id: 'test-cell-2', + metadata: {}, + source: ['# Test Markdown Cell\n\nThis is a test notebook for sharing.'] + } + ], + metadata: { + kernelspec: { + display_name: 'Python 3 (ipykernel)', + language: 'python', + name: 'python3' + }, + language_info: { + name: 'python', + version: '3.8.0' + } + }, + nbformat: 4, + nbformat_minor: 5 +}; + +async function createTestNotebook(page: Page): Promise { + await page.evaluate(notebookContent => { + const { serviceManager } = window.jupyterapp; + return serviceManager.contents.save('test-notebook.ipynb', { + type: 'notebook', + format: 'json', + content: notebookContent + }); + }, TEST_NOTEBOOK); +} + +async function openTestNotebook(page: Page): Promise { + await runCommand(page, 'docmanager:open', { path: 'test-notebook.ipynb' }); +} + +async function extractShareUrlFromDialog(page: Page): Promise { + const shareUrlElement = await page.waitForSelector('.je-share-link', { timeout: 10000 }); + const shareUrl = await shareUrlElement.textContent(); + + if (!shareUrl) { + throw new Error('Share URL not found in dialog'); + } + + return shareUrl.trim(); +} + +async function getCellContent(page: Page, cellIndex: number = 0): Promise { + return await page.evaluate(index => { + const cells = document.querySelectorAll('.jp-Cell'); + const cell = cells[index]; + if (!cell) return ''; + + const content = cell.querySelector('.cm-content'); + return content?.textContent || ''; + }, cellIndex); +} + +test.beforeEach(async ({ page }) => { + await page.goto('lab/index.html'); + await page.waitForSelector('.jp-LabShell'); +}); + +test.describe('A functional test for the sharing service', () => { + test('Perform round-trip with sharing service', async ({ page, context }) => { + await createTestNotebook(page); + await openTestNotebook(page); + await runCommand(page, 'jupytereverywhere:share-notebook'); + + const shareUrl = await extractShareUrlFromDialog(page); + + const sharedPage = await context.newPage(); + await sharedPage.goto(shareUrl); + await sharedPage.waitForSelector('.jp-LabShell'); + + await runCommand(sharedPage, 'jupytereverywhere:create-copy-notebook'); + + // Wait for view-only header to disappear + await expect(sharedPage.locator('.je-ViewOnlyHeader')).toBeHidden({ timeout: 10000 }); + + await sharedPage.close(); + }); +}); From 54fbc2fc01ec8c89eca9bc119a9d29fae55714ec Mon Sep 17 00:00:00 2001 From: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> Date: Wed, 16 Jul 2025 00:40:02 +0530 Subject: [PATCH 14/16] Start sharing service after installation is done --- .github/workflows/build.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ec785b5b..50ad9aec 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -239,10 +239,6 @@ jobs: path: ckhub-sharing-service persist-credentials: false - - name: Start CKHub sharing service - working-directory: ckhub-sharing-service - run: docker compose up --build --detach - - name: Download lite app (test mode) uses: actions/download-artifact@v4 with: @@ -272,6 +268,10 @@ jobs: run: jlpm playwright install chromium working-directory: ui-tests + - name: Start CKHub sharing service + working-directory: ckhub-sharing-service + run: docker compose up --build --detach + - name: Execute integration tests working-directory: ui-tests run: | From ffd71cef5f8383818b40fc675d633b273bdc3caf Mon Sep 17 00:00:00 2001 From: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> Date: Wed, 16 Jul 2025 01:02:37 +0530 Subject: [PATCH 15/16] Start service + Playwright tests in same terminal --- .github/workflows/build.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 50ad9aec..bd69bb5d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -268,13 +268,15 @@ jobs: run: jlpm playwright install chromium working-directory: ui-tests - - name: Start CKHub sharing service - working-directory: ckhub-sharing-service - run: docker compose up --build --detach - - - name: Execute integration tests + - name: Start CKHub sharing service and execute integration tests working-directory: ui-tests run: | + echo "::group::Starting CKHub sharing service" + cd ckhub-sharing-service + docker compose up --build --detach + cd .. + echo "::endgroup::" + jlpm playwright test --browser chromium - name: Upload Playwright Test report From 80687cdae2365cc269380cbe90ec4fd76a26207a Mon Sep 17 00:00:00 2001 From: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> Date: Wed, 16 Jul 2025 01:23:38 +0530 Subject: [PATCH 16/16] Fix path to sharing service --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bd69bb5d..40fe764d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -269,7 +269,6 @@ jobs: working-directory: ui-tests - name: Start CKHub sharing service and execute integration tests - working-directory: ui-tests run: | echo "::group::Starting CKHub sharing service" cd ckhub-sharing-service @@ -277,6 +276,7 @@ jobs: cd .. echo "::endgroup::" + cd ui-tests jlpm playwright test --browser chromium - name: Upload Playwright Test report