diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5e0f21f..e6979e36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,3 +56,46 @@ jobs: - name: Run unit tests working-directory: web-src run: npm run test:unit-ci + + e2e-tests: + name: E2E (Playwright) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + cache: pip + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "22" + cache: npm + cache-dependency-path: web-src/package-lock.json + + - name: Install frontend dependencies + working-directory: web-src + run: npm ci + + - name: Install Playwright browser + working-directory: web-src + run: npx playwright install --with-deps chromium + + - name: Run e2e tests + working-directory: web-src + # Builds the frontend, then starts the backend with the isolated + # e2e config (tests/e2e/server.sh creates its own venv) and runs + # the Playwright suite against it. + run: npm run test:e2e + + - name: Upload Playwright report on failure + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: web-src/playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index e3a6d6d6..5d7a6eea 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,9 @@ web-src/geckodriver.log venv/ /venv2/ e2e_venv/ + +# e2e (Playwright) +.e2e_venv/ +web-src/tests/e2e/.run/ +web-src/playwright-report/ +web-src/test-results/ diff --git a/web-src/package-lock.json b/web-src/package-lock.json index 89cc29ac..2aaa6d9b 100644 --- a/web-src/package-lock.json +++ b/web-src/package-lock.json @@ -25,6 +25,7 @@ "vuex": "^4.1.0" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@testing-library/jest-dom": "^6.4.0", "@vitejs/plugin-vue": "^6.0.7", "@vue/test-utils": "^2.4.6", @@ -829,6 +830,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -3242,6 +3259,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "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/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -4489,6 +4553,15 @@ "dev": true, "optional": true }, + "@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "requires": { + "playwright": "1.60.0" + } + }, "@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -5975,6 +6048,31 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true }, + "playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.60.0" + }, + "dependencies": { + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + } + } + }, + "playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true + }, "pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", diff --git a/web-src/package.json b/web-src/package.json index e878bcaf..1934e11d 100644 --- a/web-src/package.json +++ b/web-src/package.json @@ -21,6 +21,7 @@ "vuex": "^4.1.0" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@testing-library/jest-dom": "^6.4.0", "@vitejs/plugin-vue": "^6.0.7", "@vue/test-utils": "^2.4.6", @@ -38,7 +39,9 @@ "build": "vite build", "preview": "vite preview", "test:unit": "vitest", - "test:unit-ci": "vitest run" + "test:unit-ci": "vitest run", + "test:e2e": "npm run build && playwright test", + "test:e2e-ui": "playwright test --ui" }, "browserslist": [ "> 1%", diff --git a/web-src/playwright.config.js b/web-src/playwright.config.js new file mode 100644 index 00000000..a3b246be --- /dev/null +++ b/web-src/playwright.config.js @@ -0,0 +1,32 @@ +import {defineConfig, devices} from '@playwright/test' + +// E2E suite against the real Python backend serving the production build +// (web/). The backend is started automatically with an isolated config +// (tests/e2e/fixtures/conf — never the developer's real conf/), on port 5099. +// +// Run with: npm run test:e2e (builds the frontend first) +export default defineConfig({ + testDir: './tests/e2e', + testMatch: '**/*.spec.js', + fullyParallel: false, + workers: 1, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? [['github'], ['html', {open: 'never'}]] : 'list', + + use: { + baseURL: 'http://localhost:5099', + trace: 'on-first-retry', + screenshot: 'only-on-failure' + }, + + projects: [ + {name: 'chromium', use: {...devices['Desktop Chrome']}} + ], + + webServer: { + command: 'bash tests/e2e/server.sh', + url: 'http://localhost:5099/index.html', + reuseExistingServer: !process.env.CI, + timeout: 120_000 + } +}) diff --git a/web-src/tests/e2e/admin.spec.js b/web-src/tests/e2e/admin.spec.js new file mode 100644 index 00000000..4c589a28 --- /dev/null +++ b/web-src/tests/e2e/admin.spec.js @@ -0,0 +1,23 @@ +import {expect, test} from '@playwright/test' + +// Admin app boot: second Vue application (own router + store). Covers the +// admin mount path verified manually after the Vue 3 migration. + +test.describe('Admin app', () => { + + test('loads with Logs/Scripts tabs and defaults to logs', async ({page}) => { + await page.goto('/admin.html') + + await expect(page.locator('.admin-page')).toBeVisible() + await expect(page).toHaveURL(/#\/logs$/) + await expect(page.locator('.tab', {hasText: 'Logs'})).toBeVisible() + await expect(page.locator('.tab', {hasText: 'Scripts'})).toBeVisible() + }) + + test('scripts tab lists configured scripts with an Add button', async ({page}) => { + await page.goto('/admin.html#/scripts') + + await expect(page.locator('.add-script-btn')).toBeVisible() + await expect(page.locator('.collection-item', {hasText: 'E2E Echo'})).toBeVisible() + }) +}) diff --git a/web-src/tests/e2e/execution.spec.js b/web-src/tests/e2e/execution.spec.js new file mode 100644 index 00000000..80cd7a35 --- /dev/null +++ b/web-src/tests/e2e/execution.spec.js @@ -0,0 +1,70 @@ +import {expect, test} from '@playwright/test' + +// Full execution path in a real browser: XSRF token mode (the _xsrf cookie +// must be readable by JS and echoed as X-XSRFToken — regression of the +// HttpOnly bug), POST /executions/start, websocket streaming, and the +// log panel rendering (regression of the logChunks deep-watch bug). + +async function openEchoScript(page) { + await page.goto('/index.html#/' + encodeURIComponent('E2E Echo')) + await expect(page.locator('.script-parameters-panel .parameter')).toHaveCount(2) +} + +function paramInput(page, label) { + return page.locator('.script-parameters-panel .parameter') + .filter({has: page.locator('label', {hasText: label})}) + .locator('input') +} + +test.describe('Script execution', () => { + + // NOTE: order matters. Finished executions stay attached server-side + // (/executions/active) for the rest of the suite, so a later page load on + // the same script re-binds the previous execution and its log panel. The + // validation test must therefore run BEFORE any successful execution. + + test('execute without required parameter is blocked', async ({page}) => { + await openEchoScript(page) + + // Message is required and empty: clicking Execute must show the + // validation panel and must NOT start an execution. + await page.locator('.button-execute').click() + + const validationPanel = page.locator('.validation-panel') + await expect(validationPanel).toBeVisible() + await expect(validationPanel).toContainText('Message') + + await expect(page.locator('.log-panel .log-content')).toBeHidden() + }) + + test('executes a script and streams its output to the log panel', async ({page}) => { + await openEchoScript(page) + + await paramInput(page, 'Message').fill('hello-e2e') + + await page.locator('.button-execute').click() + + const log = page.locator('.log-panel .log-content') + await expect(log).toContainText('e2e: started', {timeout: 15_000}) + await expect(log).toContainText('--mode alpha --message hello-e2e') + await expect(log).toContainText('e2e: done', {timeout: 15_000}) + }) + + test('changes a combobox value before executing', async ({page}) => { + // Exercises the materialize FormSelect dropdown in a real browser — + // behaviour parity baseline for the upcoming Vuetify migration. + await openEchoScript(page) + + const modeParam = page.locator('.script-parameters-panel .parameter') + .filter({has: page.locator('label', {hasText: 'Mode'})}) + await modeParam.locator('input.select-dropdown').click() + + await page.locator('.dropdown-content li', {hasText: 'beta'}).click() + + await paramInput(page, 'Message').fill('combo-test') + await page.locator('.button-execute').click() + + const log = page.locator('.log-panel .log-content') + await expect(log).toContainText('--mode beta --message combo-test', {timeout: 15_000}) + }) +}) diff --git a/web-src/tests/e2e/fixtures/conf/conf.json b/web-src/tests/e2e/fixtures/conf/conf.json new file mode 100644 index 00000000..b2445edf --- /dev/null +++ b/web-src/tests/e2e/fixtures/conf/conf.json @@ -0,0 +1,6 @@ +{ + "port": 5099, + "security": { + "cookie_secure": false + } +} diff --git a/web-src/tests/e2e/fixtures/conf/logging.json b/web-src/tests/e2e/fixtures/conf/logging.json new file mode 100644 index 00000000..aa51c601 --- /dev/null +++ b/web-src/tests/e2e/fixtures/conf/logging.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "disable_existing_loggers": false, + "formatters": { + "simple": { + "format": "%(asctime)s [%(name)s.%(levelname)s] %(message)s" + } + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "level": "DEBUG", + "formatter": "simple", + "stream": "ext://sys.stdout" + }, + "file": { + "class": "logging.FileHandler", + "level": "INFO", + "formatter": "simple" + } + }, + "loggers": { + }, + "root": { + "level": "DEBUG", + "handlers": [ + "console", + "file" + ] + } +} \ No newline at end of file diff --git a/web-src/tests/e2e/fixtures/conf/runners/e2e_echo.json b/web-src/tests/e2e/fixtures/conf/runners/e2e_echo.json new file mode 100644 index 00000000..c4e373c4 --- /dev/null +++ b/web-src/tests/e2e/fixtures/conf/runners/e2e_echo.json @@ -0,0 +1,21 @@ +{ + "name": "E2E Echo", + "group": "E2E Group", + "description": "Fixture script for the Playwright e2e suite", + "script_path": "python3 e2e_echo.py", + "working_directory": "./web-src/tests/e2e/fixtures/scripts", + "parameters": [ + { + "name": "Mode", + "param": "--mode", + "type": "list", + "values": ["alpha", "beta", "gamma"], + "default": "alpha" + }, + { + "name": "Message", + "param": "--message", + "required": true + } + ] +} diff --git a/web-src/tests/e2e/fixtures/conf/theme/theme.css b/web-src/tests/e2e/fixtures/conf/theme/theme.css new file mode 100644 index 00000000..0595fd08 --- /dev/null +++ b/web-src/tests/e2e/fixtures/conf/theme/theme.css @@ -0,0 +1 @@ +/* empty theme for e2e fixtures */ diff --git a/web-src/tests/e2e/fixtures/scripts/e2e_echo.py b/web-src/tests/e2e/fixtures/scripts/e2e_echo.py new file mode 100644 index 00000000..8a13869d --- /dev/null +++ b/web-src/tests/e2e/fixtures/scripts/e2e_echo.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""E2E fixture script: echoes its arguments and prints a few output lines. + +Used by the Playwright suite to verify the full execution path +(XSRF POST -> process spawn -> websocket streaming -> log panel rendering). +""" +import sys +import time + +print('e2e: started') +print('e2e: args = ' + ' '.join(sys.argv[1:])) +sys.stdout.flush() + +time.sleep(0.2) +print('e2e: working...') +sys.stdout.flush() + +time.sleep(0.2) +print('e2e: done') diff --git a/web-src/tests/e2e/main-app.spec.js b/web-src/tests/e2e/main-app.spec.js new file mode 100644 index 00000000..3df87942 --- /dev/null +++ b/web-src/tests/e2e/main-app.spec.js @@ -0,0 +1,46 @@ +import {expect, test} from '@playwright/test' + +// Critical path: main page boot, script selection through a group, and +// parameter rendering. Each assertion maps to a real regression found during +// the Vue 3 migration (router.currentRoute.value, group expansion, parameter +// panel rendering). + +test.describe('Main app', () => { + + test('loads and shows the scripts sidebar', async ({page}) => { + await page.goto('/index.html') + + await expect(page).toHaveTitle('Script server') + await expect(page.locator('.scripts-list')).toBeVisible() + await expect(page.locator('.script-group', {hasText: 'E2E Group'})).toBeVisible() + }) + + test('expands a group and selects a script -> parameters render', async ({page}) => { + await page.goto('/index.html') + + await page.locator('.script-group', {hasText: 'E2E Group'}).click() + + const scriptLink = page.locator('.script-list-item', {hasText: 'E2E Echo'}) + await expect(scriptLink).toBeVisible() + await scriptLink.click() + + // parameter panel renders both fixture parameters + const params = page.locator('.script-parameters-panel .parameter') + await expect(params).toHaveCount(2) + await expect(page.locator('.script-parameters-panel')).toContainText('Mode') + await expect(page.locator('.script-parameters-panel')).toContainText('Message') + + // execute button present + await expect(page.locator('.button-execute')).toBeVisible() + }) + + test('deep link to a script renders its parameters (router regression)', async ({page}) => { + // Direct hash navigation: this is the exact scenario broken by the + // Vue Router 4 `currentRoute.value` regression. + await page.goto('/index.html#/' + encodeURIComponent('E2E Echo')) + + const params = page.locator('.script-parameters-panel .parameter') + await expect(params).toHaveCount(2) + await expect(page.locator('.script-parameters-panel')).toContainText('Message') + }) +}) diff --git a/web-src/tests/e2e/server.sh b/web-src/tests/e2e/server.sh new file mode 100755 index 00000000..8bd63dec --- /dev/null +++ b/web-src/tests/e2e/server.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Starts the script-server backend with a fully isolated configuration for the +# Playwright e2e suite. It NEVER reads or writes the developer's real conf/. +# +# - config dir : web-src/tests/e2e/fixtures/conf (port 5099, cookie_secure=false, +# XSRF token mode kept ON so the real browser flow is exercised) +# - logs/temp : web-src/tests/e2e/.run/ (gitignored) +# - python deps: a dedicated .e2e_venv at the project root, created on demand +set -euo pipefail + +cd "$(dirname "$0")/../../.." # project root + +PY=.e2e_venv/bin/python +if [ ! -x "$PY" ]; then + echo "[e2e] creating .e2e_venv and installing backend deps..." + python3 -m venv .e2e_venv + .e2e_venv/bin/pip install -q -r requirements.txt +fi + +mkdir -p web-src/tests/e2e/.run/logs web-src/tests/e2e/.run/temp + +exec "$PY" launcher.py \ + -d web-src/tests/e2e/fixtures/conf \ + -l web-src/tests/e2e/.run/logs \ + -t web-src/tests/e2e/.run/temp diff --git a/web-src/vite.config.js b/web-src/vite.config.js index 32602af9..ba53e96d 100644 --- a/web-src/vite.config.js +++ b/web-src/vite.config.js @@ -14,8 +14,27 @@ const materializeComponentPlugin = { } } +// Vite plugin: materialize-css/js/anime.min.js embeds a Closure-compiler ES6 +// runtime that resolves its global via `$jscomp.getGlobal(this)`. Vite 5 +// (rollup CJS wrapping) passed a truthy `exports` object there, which was +// harmless. Vite 8 (Rolldown) rewrites top-level `this` to `undefined`, so +// `$jscomp.global` ended up undefined and the app crashed at boot with +// "Cannot use 'in' operator to search for 'Array' in undefined". +// Point the runtime at `window` explicitly. +const materializeAnimeGlobalPlugin = { + name: 'materialize-anime-global-fix', + transform(code, id) { + if (id.includes('materialize-css/js/anime.min.js')) { + return { + code: code.replace('$jscomp.getGlobal(this)', '$jscomp.getGlobal(window)'), + map: null + } + } + } +} + export default defineConfig({ - plugins: [vue(), materializeComponentPlugin], + plugins: [vue(), materializeComponentPlugin, materializeAnimeGlobalPlugin], // Relative public path (assets referenced as ./...), so the build can be // served from any sub-path.