From c8f63e3f91a4df4f6f2f85e787c9de6a7428308e Mon Sep 17 00:00:00 2001 From: Zane <75694352+zanesq@users.noreply.github.com> Date: Tue, 29 Apr 2025 07:02:43 -0700 Subject: [PATCH] Added v2 playwright e2e tests and workflow action (#2379) --- .github/workflows/ui-v2.yml | 78 +++++++ ui-v2/.gitignore | 5 + ui-v2/.stylelintrc.json | 14 +- ui-v2/README.md | 25 ++- ui-v2/electron.html | 11 +- ui-v2/electron/main.ts | 39 +++- ui-v2/eslint.config.cjs | 8 +- ui-v2/forge.config.ts | 2 +- ui-v2/index.html | 9 +- ui-v2/package-lock.json | 222 +++++++++++++++++++ ui-v2/package.json | 16 +- ui-v2/playwright.config.ts | 35 +++ ui-v2/src/test/e2e/electron/electron.spec.ts | 108 +++++++++ ui-v2/src/test/e2e/web/web.spec.ts | 139 ++++++++++++ ui-v2/vitest.config.ts | 1 + 15 files changed, 673 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/ui-v2.yml create mode 100644 ui-v2/playwright.config.ts create mode 100644 ui-v2/src/test/e2e/electron/electron.spec.ts create mode 100644 ui-v2/src/test/e2e/web/web.spec.ts diff --git a/.github/workflows/ui-v2.yml b/.github/workflows/ui-v2.yml new file mode 100644 index 00000000..804e9987 --- /dev/null +++ b/.github/workflows/ui-v2.yml @@ -0,0 +1,78 @@ +name: UI v2 CI + +on: + push: + paths: + - 'ui-v2/**' + branches: + - main + pull_request: + paths: + - 'ui-v2/**' + branches: + - main + workflow_dispatch: + +jobs: + ui-v2-checks: + name: Lint and Test UI v2 + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 'lts/*' + cache: 'npm' + cache-dependency-path: ui-v2/package-lock.json + + - name: Install Dependencies + working-directory: ui-v2 + run: npm ci + + - name: Run All Checks + working-directory: ui-v2 + run: npm run check-all + + - name: Run Unit Tests + working-directory: ui-v2 + run: npm test + + - name: Install Playwright Browsers + working-directory: ui-v2 + run: npx playwright install --with-deps chromium + +# - name: Configure Electron Sandbox +# working-directory: ui-v2 +# run: | +# sudo chown root:root node_modules/electron/dist/chrome-sandbox +# sudo chmod 4755 node_modules/electron/dist/chrome-sandbox + + - name: Run E2E Tests + working-directory: ui-v2 + env: + HEADLESS: true + NODE_OPTIONS: "--loader ts-node/esm --max-old-space-size=4096" + run: | + # Increase system limits + sudo sysctl -w vm.max_map_count=262144 + sudo sysctl -w fs.file-max=65535 + ulimit -n 65535 + + # Run tests with xvfb + xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test:e2e:web:headless + + # todo: fix electron tests not running in GH workflow + # xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" -- npm run test:e2e:electron:headless + + - name: Upload Test Results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results + path: | + ui-v2/playwright-report/ + ui-v2/test-results/ + retention-days: 30 \ No newline at end of file diff --git a/ui-v2/.gitignore b/ui-v2/.gitignore index 9b64bede..08a816a3 100644 --- a/ui-v2/.gitignore +++ b/ui-v2/.gitignore @@ -25,3 +25,8 @@ out/ # Generated JavaScript files in source electron/**/*.js !electron/**/*.config.js + +# Playwright +test-results/ +playwright-report/ +playwright/.cache/ diff --git a/ui-v2/.stylelintrc.json b/ui-v2/.stylelintrc.json index 6f67e52b..8ab0fc41 100644 --- a/ui-v2/.stylelintrc.json +++ b/ui-v2/.stylelintrc.json @@ -1,20 +1,12 @@ { - "extends": [ - "stylelint-config-standard" - ], + "extends": ["stylelint-config-standard"], "rules": { "at-rule-no-unknown": [ true, { - "ignoreAtRules": [ - "tailwind", - "apply", - "variants", - "responsive", - "screen" - ] + "ignoreAtRules": ["tailwind", "apply", "variants", "responsive", "screen"] } ], "no-descending-specificity": null } -} \ No newline at end of file +} diff --git a/ui-v2/README.md b/ui-v2/README.md index e6211836..d632789c 100644 --- a/ui-v2/README.md +++ b/ui-v2/README.md @@ -1,4 +1,5 @@ # codename goose ui v2 + Your on-machine AI agent, automating tasks seamlessly. ## Development @@ -50,6 +51,18 @@ npm run test:ui # Generate test coverage npm run test:coverage +# End-to-End Testing +npm run test:e2e # Run all e2e tests headlessly +npm run test:e2e:ui # Run e2e tests with UI mode for both web and electron + +npm run test:e2e:web # Run web e2e tests with browser visible +npm run test:e2e:web:headless # Run web e2e tests headlessly +npm run test:e2e:web:ui # Run web e2e tests with Playwright UI mode + +npm run test:e2e:electron # Run electron e2e tests with window visible +npm run test:e2e:electron:headless # Run electron e2e tests headlessly +npm run test:e2e:electron:ui # Run electron e2e tests with Playwright UI mode + # Type checking npm run typecheck # Check all TypeScript files npm run tsc:web # Check web TypeScript files @@ -85,6 +98,11 @@ npm run check-all │ │ ├── IPlatformService.ts │ │ └── index.ts │ ├── test/ # Test setup and configurations +│ │ ├── e2e/ # End-to-end test files +│ │ │ ├── electron/ # Electron-specific e2e tests +│ │ │ │ └── electron.spec.ts +│ │ │ └── web/ # Web-specific e2e tests +│ │ │ └── web.spec.ts │ │ ├── setup.ts │ │ └── types.d.ts │ ├── App.tsx @@ -92,6 +110,7 @@ npm run check-all │ └── web.tsx # Web entry ├── electron.html # Electron HTML template ├── index.html # Web HTML template +├── playwright.config.ts # Playwright e2e test configuration ├── vite.config.ts # Vite config for web ├── vite.main.config.ts # Vite config for electron main ├── vite.preload.config.ts # Vite config for preload script @@ -118,6 +137,7 @@ export interface IPlatformService { ``` This is implemented through two concrete classes: + - `WebPlatformService`: Implements functionality for web browsers using Web APIs - `ElectronPlatformService`: Implements functionality for Electron using IPC @@ -130,6 +150,7 @@ The application uses a dependency injection pattern for platform services: 3. **Unified Access**: Components access platform features through a single `platformService` instance Example usage in components: + ```typescript import { platformService } from '@platform'; @@ -142,6 +163,7 @@ await platformService.copyToClipboard(text); For Electron-specific functionality, the architecture includes: 1. **Preload Script**: Safely exposes Electron APIs to the renderer process + ```typescript // Type definitions for Electron APIs declare global { @@ -154,6 +176,7 @@ declare global { ``` 2. **IPC Communication**: Typed handlers for main process communication + ```typescript // Electron implementation export class ElectronPlatformService implements IPlatformService { @@ -168,7 +191,7 @@ export class ElectronPlatformService implements IPlatformService { The project uses a sophisticated build system with multiple configurations: 1. **Web Build**: Vite-based build for web deployment -2. **Electron Build**: +2. **Electron Build**: - Main Process: Separate Vite config for Electron main process - Renderer Process: Specialized config for Electron renderer - Preload Scripts: Dedicated build configuration for preload scripts diff --git a/ui-v2/electron.html b/ui-v2/electron.html index e35d93d0..22a26532 100644 --- a/ui-v2/electron.html +++ b/ui-v2/electron.html @@ -1,14 +1,17 @@ - + - - + + Goose v2
- \ No newline at end of file + diff --git a/ui-v2/electron/main.ts b/ui-v2/electron/main.ts index 0db1acdf..b329fd92 100644 --- a/ui-v2/electron/main.ts +++ b/ui-v2/electron/main.ts @@ -9,27 +9,46 @@ import type { } from 'electron'; const isDevelopment = !app.isPackaged; +const isHeadless = process.env.HEADLESS === 'true'; // Enable sandbox before app is ready app.enableSandbox(); +if (isHeadless) { + app.disableHardwareAcceleration(); +} + async function createWindow() { // Handle different preload paths for dev and prod const preloadPath = isDevelopment ? path.join(app.getAppPath(), '.vite/build/preload/preload.js') : path.join(__dirname, 'preload.js'); - // Create the browser window. + // Create the browser window with headless options when needed const mainWindow = new BrowserWindow({ width: 1200, height: 800, - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - webSecurity: true, - preload: preloadPath, - sandbox: true, - }, + ...(isHeadless + ? { + show: false, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + webSecurity: true, + preload: preloadPath, + sandbox: true, + offscreen: true, + }, + } + : { + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + webSecurity: true, + preload: preloadPath, + sandbox: true, + }, + }), }); // Set up CSP @@ -108,7 +127,9 @@ async function createWindow() { // Load the app if (isDevelopment) { mainWindow.loadURL('http://localhost:3001/'); - mainWindow.webContents.openDevTools(); + if (!isHeadless) { + mainWindow.webContents.openDevTools(); + } } else { // In production, load from the asar archive mainWindow.loadFile(path.join(__dirname, 'renderer/index.html')); diff --git a/ui-v2/eslint.config.cjs b/ui-v2/eslint.config.cjs index 096d0057..4bb86216 100644 --- a/ui-v2/eslint.config.cjs +++ b/ui-v2/eslint.config.cjs @@ -8,13 +8,7 @@ const reactHooks = require('eslint-plugin-react-hooks'); module.exports = [ { - ignores: [ - '**/node_modules/**', - '**/dist/**', - '**/out/**', - '**/coverage/**', - '**/.vite/**', - ], + ignores: ['**/node_modules/**', '**/dist/**', '**/out/**', '**/coverage/**', '**/.vite/**'], }, // Configuration for Node.js files { diff --git a/ui-v2/forge.config.ts b/ui-v2/forge.config.ts index 28d52738..2351f9a0 100644 --- a/ui-v2/forge.config.ts +++ b/ui-v2/forge.config.ts @@ -13,6 +13,7 @@ const config: ForgeConfig = { }, { name: '@electron-forge/maker-zip', + config: {}, platforms: ['darwin'], }, { @@ -40,7 +41,6 @@ const config: ForgeConfig = { { name: 'main_window', config: 'vite.renderer.config.ts', - entry: ['electron.html'], }, ], }), diff --git a/ui-v2/index.html b/ui-v2/index.html index 77bf206f..e263a556 100644 --- a/ui-v2/index.html +++ b/ui-v2/index.html @@ -1,10 +1,13 @@ - + - - + + Goose v2 diff --git a/ui-v2/package-lock.json b/ui-v2/package-lock.json index fc904c15..17679708 100644 --- a/ui-v2/package-lock.json +++ b/ui-v2/package-lock.json @@ -19,6 +19,7 @@ "@electron-forge/maker-zip": "^7.8.0", "@electron-forge/plugin-vite": "^7.8.0", "@electron-forge/shared-types": "^7.8.0", + "@playwright/test": "^1.42.1", "@tailwindcss/postcss": "^4.1.4", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -48,6 +49,7 @@ "stylelint": "^16.19.1", "stylelint-config-standard": "^38.0.0", "tailwindcss": "^4.1.4", + "ts-node": "^10.9.2", "typescript": "^5.8.3", "vite": "^6.3.3", "vitest": "^3.1.2" @@ -423,6 +425,30 @@ "node": ">=18" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", @@ -2223,6 +2249,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -2926,6 +2968,34 @@ "node": ">= 10" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -3564,6 +3634,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -3660,6 +3743,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4805,6 +4895,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", @@ -5163,6 +5260,16 @@ "license": "MIT", "optional": true }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -9756,6 +9863,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/make-fetch-happen": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", @@ -11046,6 +11160,53 @@ "node": ">=0.10.0" } }, + "node_modules/playwright": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -13656,6 +13817,50 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -13965,6 +14170,13 @@ "node": ">= 0.4.0" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/validate-npm-package-license": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", @@ -14721,6 +14933,16 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/ui-v2/package.json b/ui-v2/package.json index c32a24b1..dd14b5c3 100644 --- a/ui-v2/package.json +++ b/ui-v2/package.json @@ -16,14 +16,22 @@ "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui --project=web --project=electron --workers=1", + "test:e2e:web": "playwright test --project=web --headed", + "test:e2e:web:headless": "playwright test --project=web", + "test:e2e:web:ui": "playwright test --ui --project=web", + "test:e2e:electron": "playwright test --project=electron --headed", + "test:e2e:electron:headless": "HEADLESS=true playwright test --project=electron", + "test:e2e:electron:ui": "playwright test --ui --project=electron", "tsc": "tsc --noEmit", "tsc:web": "tsc --project tsconfig.json --noEmit", "tsc:electron": "tsc --project tsconfig.electron.json --noEmit", "typecheck": "npm run tsc:web && npm run tsc:electron", "lint": "eslint . && npm run lint:style", "lint:fix": "eslint . --fix && npm run lint:style:fix", - "lint:style": "stylelint \"src/**/*.{css}\"", - "lint:style:fix": "stylelint \"src/**/*.{css}\" --fix", + "lint:style": "stylelint src/**/*.css", + "lint:style:fix": "stylelint src/**/*.css --fix", "prettier": "prettier --check \"src/**/*.{ts,tsx,js,jsx,css}\" \"electron/**/*.{ts,tsx,js,jsx,css}\"", "prettier:fix": "prettier --write \"src/**/*.{ts,tsx,js,jsx,css}\" \"electron/**/*.{ts,tsx,js,jsx,css}\"", "format": "npm run prettier:fix && npm run lint:style:fix", @@ -42,6 +50,7 @@ "@electron-forge/maker-zip": "^7.8.0", "@electron-forge/plugin-vite": "^7.8.0", "@electron-forge/shared-types": "^7.8.0", + "@playwright/test": "^1.42.1", "@tailwindcss/postcss": "^4.1.4", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -71,6 +80,7 @@ "stylelint": "^16.19.1", "stylelint-config-standard": "^38.0.0", "tailwindcss": "^4.1.4", + "ts-node": "^10.9.2", "typescript": "^5.8.3", "vite": "^6.3.3", "vitest": "^3.1.2" @@ -79,7 +89,7 @@ "src/**/*.{ts,tsx}": [ "eslint --fix --max-warnings 0 --no-warn-ignored", "prettier --write", - "tsc --noemit" + "bash -c 'tsc --pretty --noEmit --project tsconfig.json'" ], "src/**/*.{css,json}": [ "prettier --write", diff --git a/ui-v2/playwright.config.ts b/ui-v2/playwright.config.ts new file mode 100644 index 00000000..46965f9f --- /dev/null +++ b/ui-v2/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './src/test/e2e', + workers: 1, + use: { + trace: 'on-first-retry', + // Use headless mode in CI, non-headless locally unless specified + headless: process.env.CI === 'true' || process.env.HEADLESS === 'true', + // Add longer timeouts for CI + navigationTimeout: 30000, + actionTimeout: 15000, + }, + projects: [ + { + name: 'web', + testMatch: ['**/web/*.spec.ts'], + use: { + ...devices['Desktop Chrome'], + }, + }, + { + name: 'electron', + testMatch: ['**/electron/*.spec.ts'], + use: { + ...devices['Desktop Chrome'], + }, + }, + ], + timeout: 60000, // Increase overall timeout + expect: { + timeout: 15000, // Increase expect timeout + }, + reporter: [['html'], ['list']], +}); diff --git a/ui-v2/src/test/e2e/electron/electron.spec.ts b/ui-v2/src/test/e2e/electron/electron.spec.ts new file mode 100644 index 00000000..03e83d2e --- /dev/null +++ b/ui-v2/src/test/e2e/electron/electron.spec.ts @@ -0,0 +1,108 @@ +import { spawn } from 'child_process'; +import { Buffer } from 'node:buffer'; + +import { test, expect } from '@playwright/test'; + +let electronProcess: ReturnType | undefined; + +// Helper function to safely kill a process and its children +async function killProcess(pid: number): Promise { + try { + // Try to kill the process group first + try { + process.kill(-pid, 'SIGTERM'); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.log('Failed to kill process group:', message); + } + + // Wait a bit and then try to kill the process directly + await new Promise((resolve) => setTimeout(resolve, 1000)); + + try { + process.kill(pid, 'SIGTERM'); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.log('Failed to kill process:', message); + } + + // Final cleanup with SIGKILL if needed + await new Promise((resolve) => setTimeout(resolve, 1000)); + try { + process.kill(pid, 'SIGKILL'); + } catch (error: unknown) { + // Process is probably already dead + const message = error instanceof Error ? error.message : String(error); + console.log('Process already terminated:', message); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.log('Error during process cleanup:', message); + } +} + +test.describe('electron app', () => { + test.beforeAll(async () => { + console.log('Starting Electron app...'); + console.log('Environment:', process.env.NODE_ENV); + console.log('HEADLESS:', process.env.HEADLESS); + + // Start electron with minimal memory settings + electronProcess = spawn('npm', ['run', 'start:electron'], { + stdio: 'pipe', + shell: true, + env: { + ...process.env, + ELECTRON_IS_DEV: '1', + NODE_ENV: 'development', + HEADLESS: process.env.HEADLESS || 'false', + ELECTRON_START_URL: 'http://localhost:3001', + // Add memory limits for Electron + ELECTRON_EXTRA_LAUNCH_ARGS: '--js-flags="--max-old-space-size=512" --disable-gpu', + }, + // Set detached to false and create a new process group + detached: false, + }); + + // Store the PID for cleanup + const pid = electronProcess.pid; + console.log('Started Electron app with PID:', pid); + + // Capture stdout and stderr for debugging + electronProcess.stdout?.on('data', (data: Buffer) => { + console.log(`Electron stdout: ${data.toString()}`); + }); + + electronProcess.stderr?.on('data', (data: Buffer) => { + console.log(`Electron stderr: ${data.toString()}`); + }); + + // Wait for the app to be ready + await new Promise((resolve) => setTimeout(resolve, 2000)); + }); + + test.afterAll(async () => { + console.log('Stopping Electron app...'); + + if (electronProcess?.pid) { + console.log('Killing Electron process:', electronProcess.pid); + await killProcess(electronProcess.pid); + } + + // Give processes time to fully terminate + await new Promise((resolve) => setTimeout(resolve, 2000)); + }); + + test('shows correct runtime', async ({ page }) => { + console.log('Navigating to http://localhost:3001'); + const response = await page.goto('http://localhost:3001'); + console.log('Navigation status:', response?.status()); + + // Wait for and check the text with more detailed logging + console.log('Looking for runtime text...'); + const runtimeText = page.locator('text=Running in: Electron'); + + // Wait for the text to be visible + await expect(runtimeText).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/ui-v2/src/test/e2e/web/web.spec.ts b/ui-v2/src/test/e2e/web/web.spec.ts new file mode 100644 index 00000000..1e47521a --- /dev/null +++ b/ui-v2/src/test/e2e/web/web.spec.ts @@ -0,0 +1,139 @@ +import { spawn } from 'child_process'; +import http from 'http'; +import { Buffer } from 'node:buffer'; + +import { test, expect } from '@playwright/test'; + +let webProcess: ReturnType | undefined; + +// Helper function to check if server is ready +async function waitForServer(url: string, timeout: number): Promise { + const start = Date.now(); + + while (Date.now() - start < timeout) { + try { + await new Promise((resolve, reject) => { + const req = http.get(url, (res) => { + if (res.statusCode === 200) { + resolve(); + } else { + reject(new Error(`Status code: ${res.statusCode}`)); + } + res.resume(); // Consume response data to free up memory + }); + + req.on('error', reject); + req.setTimeout(1000, () => reject(new Error('Request timeout'))); + }); + + console.log('Server is ready'); + return true; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.log('Waiting for server...', message); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + + console.log('Server failed to respond in time'); + return false; +} + +// Helper function to safely kill a process and its children +async function killProcess(pid: number): Promise { + try { + // Try to kill the process group first + try { + process.kill(-pid, 'SIGTERM'); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.log('Failed to kill process group:', message); + } + + // Wait a bit and then try to kill the process directly + await new Promise((resolve) => setTimeout(resolve, 1000)); + + try { + process.kill(pid, 'SIGTERM'); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.log('Failed to kill process:', message); + } + + // Final cleanup with SIGKILL if needed + await new Promise((resolve) => setTimeout(resolve, 1000)); + try { + process.kill(pid, 'SIGKILL'); + } catch (error: unknown) { + // Process is probably already dead + const message = error instanceof Error ? error.message : String(error); + console.log('Process already terminated:', message); + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.log('Error during process cleanup:', message); + } +} + +test.describe('web app', () => { + test.beforeAll(async () => { + console.log('Starting web app...'); + + // Start the vite dev server + webProcess = spawn('npm', ['run', 'start:web'], { + stdio: 'pipe', + shell: true, + env: { + ...process.env, + NODE_ENV: 'development', + }, + // Set detached to false and create a new process group + detached: false, + }); + + // Store the PID for cleanup + const pid = webProcess.pid; + console.log('Started Vite server with PID:', pid); + + // Capture stdout and stderr for debugging + webProcess.stdout?.on('data', (data: Buffer) => { + console.log(`Vite stdout: ${data.toString()}`); + }); + + webProcess.stderr?.on('data', (data: Buffer) => { + console.log(`Vite stderr: ${data.toString()}`); + }); + + // Wait for server to be ready + console.log('Waiting for server to be ready...'); + const serverReady = await waitForServer('http://localhost:3000', 30000); + if (!serverReady) { + throw new Error('Server failed to start'); + } + }); + + test.afterAll(async () => { + console.log('Cleaning up processes...'); + + if (webProcess?.pid) { + console.log('Killing Vite server process:', webProcess.pid); + await killProcess(webProcess.pid); + } + + // Give processes time to fully terminate + await new Promise((resolve) => setTimeout(resolve, 2000)); + }); + + test('shows correct runtime', async ({ page }) => { + console.log('Navigating to http://localhost:3000'); + const response = await page.goto('http://localhost:3000'); + console.log('Navigation status:', response?.status()); + + // Wait for and check the text with more detailed logging + console.log('Looking for runtime text...'); + const runtimeText = page.locator('text=Running in: Web Browser'); + + // Wait for the text to be visible + await expect(runtimeText).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/ui-v2/vitest.config.ts b/ui-v2/vitest.config.ts index 10bb4e9e..21a8d56c 100644 --- a/ui-v2/vitest.config.ts +++ b/ui-v2/vitest.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], css: true, + exclude: ['**/test/e2e/**', '**/node_modules/**'], coverage: { provider: 'v8', reporter: ['text', 'html'],