diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..27ee1f0a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,75 @@ +name: Automated Testing + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install node + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'yarn' + + - name: Setup conda + uses: conda-incubator/setup-miniconda@v3 + with: + auto-update-conda: true + python-version: '3.12' + mamba-version: "*" + channels: conda-forge,defaults + channel-priority: strict + + - name: Install conda environment for tests + shell: bash -l {0} + run: | + set -eux + conda env create -f env_installer/jlab_server.yaml + conda activate jlab_server + which python + python --version + pip list | grep jupyterlab + + - name: Install dependencies + run: | + npm install --global yarn --prefer-offline + yarn install + + - name: Build application + run: | + yarn build + + - name: Run tests + shell: bash -l {0} + run: | + set -eux + conda activate jlab_server + export JUPYTERLAB_DESKTOP_PYTHON_PATH="$(conda info --base)/envs/jlab_server/bin/python" + xvfb-run -a yarn test + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: | + test-results/ + tests/snapshots/ + + - name: Upload screenshots + uses: actions/upload-artifact@v4 + if: always() + with: + name: screenshots + path: tests/snapshots/ \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7dccdea4..564b1ec6 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,8 @@ __pycache__ env_installer/jlab_server/ env_installer/jlab_server.tar.gz +# Test artifacts (but not screenshots) +test-env/ +test-results/ +playwright-report/ +/playwright/.cache/ diff --git a/package.json b/package.json index 3873274f..fb25a413 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "main": "./build/out/main/main.js", "scripts": { "start": "electron .", - "test": "echo \"Error: no test specified\" && exit 1", + "test": "playwright test", + "test:headed": "playwright test --headed", + "test:debug": "playwright test --debug", "clean": "rimraf build dist", "watch:tsc": "tsc -w", "watch:assets": "node ./scripts/extract.js && node ./scripts/copyassets.js watch", @@ -107,7 +109,7 @@ "base": "core22", "environment": { "SHELL": "/bin/bash", - "GTK_USE_PORTAL":"1" + "GTK_USE_PORTAL": "1" }, "hooks": "build/snap-hooks" }, @@ -182,9 +184,10 @@ "license": "BSD-3-Clause", "devDependencies": { "@jupyter-notebook/web-components": "0.9.1", + "@leeoniya/ufuzzy": "1.0.14", + "@playwright/test": "^1.55.0", "@types/ejs": "^3.1.0", "@types/js-yaml": "^4.0.3", - "@types/node": "^14.14.31", "@types/node-fetch": "~2.5.12", "@types/react": "~17.0.2", "@types/react-dom": "^17.0.1", @@ -193,7 +196,6 @@ "@types/yargs": "^17.0.18", "@typescript-eslint/eslint-plugin": "~5.28.0", "@typescript-eslint/parser": "~5.28.0", - "@leeoniya/ufuzzy": "1.0.14", "electron": "^27.0.2", "electron-builder": "^24.9.1", "electron-notarize": "^1.2.2", @@ -206,6 +208,7 @@ "meow": "^6.0.1", "mini-css-extract-plugin": "^1.3.9", "node-watch": "^0.7.4", + "playwright": "^1.55.0", "prettier": "~2.1.1", "read-package-tree": "^5.1.6", "rimraf": "~3.0.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..b323a87f --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,40 @@ +import { defineConfig } from '@playwright/test'; + +/** + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: false, // Disable parallel for Electron tests + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 1 : 0, + /* Opt out of parallel tests on CI. */ + workers: 1, // Use single worker for Electron + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + ['html'], + ['json', { outputFile: 'test-results/results.json' }] + ], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + // Increase timeouts for Electron app startup + actionTimeout: 15000, + navigationTimeout: 15000, + }, + + /* Global timeout for each test */ + timeout: 30000, + + /* Configure projects for Electron */ + projects: [ + { + name: 'electron-tests', + testMatch: '**/*.spec.ts', + }, + ], +}); \ No newline at end of file diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..88eb6a11 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,34 @@ +# Test Screenshots + +This directory contains screenshots generated by Playwright tests that replicate the screenshots shown in the documentation. + +## Structure + +The tests capture the following screenshots that correspond to the documentation: + +- `welcome-page.png` - Captures the welcome page interface (corresponds to `media/welcome-page.png`) +- `start-session.png` - Captures the start session controls (corresponds to `media/start-session.png`) +- `recent-sessions.png` - Captures the recent sessions interface (corresponds to `media/recent-sessions.png`) +- `desktop-app-frame.png` - Captures the main desktop app frame (corresponds to `media/desktop-app-frame.png`) +- `python-env-status.png` - Captures the Python environment status (corresponds to `media/python-env-status.png`) +- `start-session-connect.png` - Captures the connect to server interface (corresponds to `media/start-session-connect.png`) + +## Running Tests + +To generate the screenshots: + +1. First build the application: + ```bash + yarn build + ``` + +2. Run the tests: + ```bash + yarn test + ``` + +The tests wait for specific UI elements to be ready instead of using arbitrary timeouts, ensuring consistent screenshot capture. + +## Note + +These screenshots are committed to git to track visual changes over time. They serve as both test artifacts and visual documentation of the application's interface. \ No newline at end of file diff --git a/tests/desktop-app-frame.png b/tests/desktop-app-frame.png new file mode 100644 index 00000000..6916b705 Binary files /dev/null and b/tests/desktop-app-frame.png differ diff --git a/tests/documentation-screenshots.spec.ts b/tests/documentation-screenshots.spec.ts new file mode 100644 index 00000000..b1997409 --- /dev/null +++ b/tests/documentation-screenshots.spec.ts @@ -0,0 +1,331 @@ +import { test, expect, _electron as electron } from '@playwright/test'; +import * as path from 'path'; + +async function launchElectronApp() { + // Launch JupyterLab Desktop with proper configuration + const electronApp = await electron.launch({ + args: [ + path.join(__dirname, '../build/out/main/main.js'), + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-web-security' + ], + executablePath: undefined, + env: { + ...process.env, + NODE_ENV: 'development', + DISPLAY: process.env.DISPLAY || ':99', + // Set Python path to our conda environment so the welcome page doesn't show warnings + JUPYTERLAB_DESKTOP_PYTHON_PATH: process.env.JUPYTERLAB_DESKTOP_PYTHON_PATH || '/usr/share/miniconda/envs/jlab_server/bin/python' + } + }); + + return electronApp; +} + +async function getMainWindow(electronApp: any) { + // Wait for initial window + await electronApp.waitForEvent('window', { timeout: 30000 }); + + // Wait a bit for additional windows + await new Promise(resolve => setTimeout(resolve, 3000)); + + const allWindows = electronApp.windows(); + console.log(`Found ${allWindows.length} windows`); + + if (allWindows.length === 0) { + throw new Error('No windows found'); + } + + // Find the welcome page window by checking content + for (let i = 0; i < allWindows.length; i++) { + const window = allWindows[i]; + const title = await window.title(); + const url = window.url(); + + console.log(`Window ${i}: title="${title}", url="${url}"`); + + // Check if this window has welcome page content + const hasWelcomeContent = await window.evaluate(() => { + return document.body.innerHTML.includes('container') && + document.body.innerHTML.includes('Start') && + !document.body.innerHTML.includes('titlebar'); + }); + + console.log(`Window ${i} has welcome content: ${hasWelcomeContent}`); + + if (hasWelcomeContent) { + console.log(`Using window ${i} as main window`); + return window; + } + } + + // If no welcome content found, log all window contents for debugging + for (let i = 0; i < allWindows.length; i++) { + const window = allWindows[i]; + const content = await window.evaluate(() => { + return { + title: document.title, + bodyHTML: document.body.innerHTML.substring(0, 500), + hasContainer: document.body.innerHTML.includes('container'), + hasTitlebar: document.body.innerHTML.includes('titlebar'), + hasStart: document.body.innerHTML.includes('Start') + }; + }); + console.log(`Window ${i} content:`, content); + } + + console.log('No welcome window found, using first window'); + return allWindows[0]; +} + +async function waitForWelcomePageToLoad(page: any) { + // Wait for the page to be ready + await page.waitForLoadState('domcontentloaded'); + await page.waitForSelector('body', { timeout: 30000 }); + + // Wait a bit more for the UI to be fully ready and JavaScript to load + await page.waitForTimeout(8000); + + // Log what we have + const title = await page.title(); + console.log('Page title:', title); + + // Debug: Log all the links and their IDs/text + const linkInfo = await page.evaluate(() => { + const links = Array.from(document.querySelectorAll('a')); + return links.map(link => ({ + id: link.id, + text: link.textContent?.trim(), + href: link.href, + className: link.className, + onclick: link.onclick ? link.onclick.toString() : null, + disabled: link.classList.contains('disabled') || link.hasAttribute('disabled') + })); + }); + + console.log('All links on the page:', JSON.stringify(linkInfo, null, 2)); + + // Also log HTML structure to debug + const htmlStructure = await page.evaluate(() => { + const body = document.body; + return { + hasContainer: !!body.querySelector('.container'), + hasStartCol: !!body.querySelector('.start-col'), + hasStartRecent: !!body.querySelector('.start-recent-col'), + bodyClasses: body.className, + innerHTML: body.innerHTML.substring(0, 2000) + }; + }); + + console.log('HTML structure:', JSON.stringify(htmlStructure, null, 2)); +} + +test.describe('Documentation Screenshots', () => { + test('should capture welcome page', async () => { + const electronApp = await launchElectronApp(); + const page = await getMainWindow(electronApp); + + await waitForWelcomePageToLoad(page); + + // Take screenshot of the full welcome page + await page.screenshot({ + path: 'tests/welcome-page.png', + fullPage: true + }); + + await electronApp.close(); + }); + + test('should capture new notebook window by clicking New notebook link', async () => { + const electronApp = await launchElectronApp(); + const page = await getMainWindow(electronApp); + + await waitForWelcomePageToLoad(page); + + // Look for the "New notebook..." link and click it + try { + console.log('Looking for New notebook link...'); + + // Wait a bit more for the UI to be ready and Python environment to be detected + await page.waitForTimeout(3000); + + // Look for the new notebook link by ID + const newNotebookLink = page.locator('#new-notebook-link'); + + // Check if the link exists and is enabled + const linkExists = await newNotebookLink.count() > 0; + console.log(`New notebook link exists: ${linkExists}`); + + if (linkExists) { + const isEnabled = await newNotebookLink.evaluate(el => !el.classList.contains('disabled')); + console.log(`New notebook link enabled: ${isEnabled}`); + + if (isEnabled) { + console.log('Clicking New notebook link...'); + await newNotebookLink.click(); + + // Wait for new window to open + console.log('Waiting for new window...'); + try { + await electronApp.waitForEvent('window', { timeout: 15000 }); + console.log('New window event received'); + + // Wait a bit more for the window to be ready + await page.waitForTimeout(5000); + + // Get all windows and find the JupyterLab window + const allWindows = electronApp.windows(); + console.log(`Total windows now: ${allWindows.length}`); + + if (allWindows.length > 1) { + // Take screenshot of the new JupyterLab window + const jupyterLabWindow = allWindows[allWindows.length - 1]; + await jupyterLabWindow.screenshot({ + path: 'tests/desktop-app-frame.png', + fullPage: true + }); + console.log('Captured JupyterLab window screenshot'); + } else { + console.log('No new window found, taking screenshot of current window'); + await page.screenshot({ + path: 'tests/desktop-app-frame.png', + fullPage: true + }); + } + } catch (error) { + console.log('Error waiting for new window:', error); + await page.screenshot({ + path: 'tests/desktop-app-frame.png', + fullPage: true + }); + } + } else { + console.log('New notebook link is disabled, taking welcome page screenshot'); + await page.screenshot({ + path: 'tests/desktop-app-frame.png', + fullPage: true + }); + } + } else { + console.log('New notebook link not found, taking welcome page screenshot'); + await page.screenshot({ + path: 'tests/desktop-app-frame.png', + fullPage: true + }); + } + } catch (error) { + console.log('Error clicking new notebook link:', error); + await page.screenshot({ + path: 'tests/desktop-app-frame.png', + fullPage: true + }); + } + + await electronApp.close(); + }); + + test('should capture start session area', async () => { + const electronApp = await launchElectronApp(); + const page = await getMainWindow(electronApp); + + await waitForWelcomePageToLoad(page); + + // Take a screenshot focusing on the start session area + await page.screenshot({ + path: 'tests/start-session.png', + fullPage: true + }); + + await electronApp.close(); + }); + + test('should capture connect dialog by clicking Connect link', async () => { + const electronApp = await launchElectronApp(); + const page = await getMainWindow(electronApp); + + await waitForWelcomePageToLoad(page); + + // Look for and click the Connect link + try { + console.log('Looking for Connect link...'); + + // Find the connect link + const connectLink = page.locator('a:has-text("Connect")'); + + const linkExists = await connectLink.count() > 0; + console.log(`Connect link exists: ${linkExists}`); + + if (linkExists) { + console.log('Clicking Connect link...'); + await connectLink.click(); + + // Wait for dialog or new window + await page.waitForTimeout(3000); + + // Check if a new window opened + const allWindows = electronApp.windows(); + if (allWindows.length > 1) { + const connectWindow = allWindows[allWindows.length - 1]; + await connectWindow.screenshot({ + path: 'tests/start-session-connect.png', + fullPage: true + }); + console.log('Captured connect dialog from new window'); + } else { + // Take screenshot of current page which might have dialog + await page.screenshot({ + path: 'tests/start-session-connect.png', + fullPage: true + }); + console.log('Captured connect interface from main window'); + } + } else { + console.log('Connect link not found, taking welcome page screenshot'); + await page.screenshot({ + path: 'tests/start-session-connect.png', + fullPage: true + }); + } + } catch (error) { + console.log('Error clicking connect link:', error); + await page.screenshot({ + path: 'tests/start-session-connect.png', + fullPage: true + }); + } + + await electronApp.close(); + }); + + test('should capture python environment status', async () => { + const electronApp = await launchElectronApp(); + const page = await getMainWindow(electronApp); + + await waitForWelcomePageToLoad(page); + + // Take screenshot to show environment status + await page.screenshot({ + path: 'tests/python-env-status.png', + fullPage: true + }); + + await electronApp.close(); + }); + + test('should capture recent sessions area', async () => { + const electronApp = await launchElectronApp(); + const page = await getMainWindow(electronApp); + + await waitForWelcomePageToLoad(page); + + // Take screenshot of the recent sessions/news area + await page.screenshot({ + path: 'tests/recent-sessions.png', + fullPage: true + }); + + await electronApp.close(); + }); +}); \ No newline at end of file diff --git a/tests/python-env-status.png b/tests/python-env-status.png new file mode 100644 index 00000000..812c4fbb Binary files /dev/null and b/tests/python-env-status.png differ diff --git a/tests/recent-sessions.png b/tests/recent-sessions.png new file mode 100644 index 00000000..812c4fbb Binary files /dev/null and b/tests/recent-sessions.png differ diff --git a/tests/start-session-connect.png b/tests/start-session-connect.png new file mode 100644 index 00000000..5cf64028 Binary files /dev/null and b/tests/start-session-connect.png differ diff --git a/tests/start-session.png b/tests/start-session.png new file mode 100644 index 00000000..812c4fbb Binary files /dev/null and b/tests/start-session.png differ diff --git a/tests/welcome-page.png b/tests/welcome-page.png new file mode 100644 index 00000000..812c4fbb Binary files /dev/null and b/tests/welcome-page.png differ diff --git a/tsconfig.json b/tsconfig.json index 1ea631e6..546db32b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "noImplicitAny": true, "noEmitOnError": true, "noUnusedLocals": true, + "skipLibCheck": true, "module": "commonjs", "moduleResolution": "node", "esModuleInterop": true, diff --git a/yarn.lock b/yarn.lock index e5d11bfd..2449f7e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -299,6 +299,13 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@playwright/test@^1.55.0": + version "1.55.0" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.55.0.tgz#080fa6d9ee6d749ff523b1c18259572d0268b963" + integrity sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ== + dependencies: + playwright "1.55.0" + "@sindresorhus/is@^4.0.0": version "4.6.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f" @@ -416,11 +423,6 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.12.tgz#833756634e78c829e1254db006468dadbb0c696b" integrity sha512-Wha1UwsB3CYdqUm2PPzh/1gujGCNtWVUYF0mB00fJFoR4gTyWTDPjSm+zBF787Ahw8vSGgBja90MkgFwvB86Dg== -"@types/node@^14.14.31": - version "14.18.36" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.36.tgz#c414052cb9d43fab67d679d5f3c641be911f5835" - integrity sha512-FXKWbsJ6a1hIrRxv+FoukuHnGTgEzKYGi7kilfMae96AL9UNkPFNWJEEYWzdRI9ooIkbr4AKldyuSTLql06vLQ== - "@types/normalize-package-data@^2.4.0": version "2.4.1" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" @@ -2060,6 +2062,11 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -3330,6 +3337,20 @@ pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" +playwright-core@1.55.0: + version "1.55.0" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.55.0.tgz#ec8a9f8ef118afb3e86e0f46f1393e3bea32adf4" + integrity sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg== + +playwright@1.55.0, playwright@^1.55.0: + version "1.55.0" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.55.0.tgz#7aca7ac3ffd9e083a8ad8b2514d6f9ba401cc78b" + integrity sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA== + dependencies: + playwright-core "1.55.0" + optionalDependencies: + fsevents "2.3.2" + plist@^3.0.4: version "3.0.6" resolved "https://registry.yarnpkg.com/plist/-/plist-3.0.6.tgz#7cfb68a856a7834bca6dbfe3218eb9c7740145d3"