From 6f0384948f486d44a59fc2c5862c7aca3508243e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 06:20:50 +0000 Subject: [PATCH] Add desktop end-to-end test; fix reload, stale redirect and first-run bugs it found e2e/desktop.spec.js (Playwright) drives the packaged Electron app with its real PyInstaller backend, per-launch API token and Tesseract: first-run account setup, window reload, upload -> OCR -> review -> metadata -> PDF/A archive (checked on disk, plus dublin-core.xml), search, and that closing the app stops the backend. CI runs it on Windows for every PR, and the release workflow runs it before publishing an installer. Bugs found by the test: - Reloading the desktop window (Ctrl+R/F5) showed a blank page: path URLs such as file:///upload do not exist on disk. Use HashRouter under file:// (web build keeps BrowserRouter); ErrorBoundary "go home" did the same. - After OCR, the Upload page's 1.8 s auto-redirect to Cleanup was never cancelled, so a user who had already moved on was pulled back to Cleanup. - First launch: "Setup & Login" opened on the Sign In tab although no account exists; it now opens on Register. Electron: LIBRADIGIT_USER_DATA_DIR and LIBRADIGIT_ARCHIVE_FOLDER overrides so tests (and admins) can isolate data. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Jze3nDgZGqUXGceiu5RCAf --- .github/workflows/ci.yml | 27 +++++++- .github/workflows/release.yml | 10 +++ .gitignore | 4 ++ README.md | 11 +++ e2e/desktop.spec.js | 114 +++++++++++++++++++++++++++++++ e2e/fixtures/sample-scan.png | Bin 0 -> 36742 bytes electron/main.js | 8 ++- eslint.config.js | 7 +- package-lock.json | 64 +++++++++++++++++ package.json | 4 +- playwright.config.js | 16 +++++ src/App.jsx | 7 +- src/components/ErrorBoundary.jsx | 8 ++- src/components/LoginScreen.jsx | 4 +- src/pages/LandingPage.jsx | 1 + src/pages/UploadOCR.jsx | 19 ++++-- 16 files changed, 289 insertions(+), 15 deletions(-) create mode 100644 e2e/desktop.spec.js create mode 100644 e2e/fixtures/sample-scan.png create mode 100644 playwright.config.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1043101..5f3f8b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,7 +59,7 @@ jobs: - run: npm run build package-windows: - name: Windows backend package + name: Windows desktop app (package + end-to-end) runs-on: windows-latest needs: backend steps: @@ -79,3 +79,28 @@ jobs: - name: Smoke test packaged backend shell: pwsh run: ./scripts/smoke-test-backend.ps1 + + # Package the full desktop app (unpacked, unsigned) and drive it like a + # user: first-run setup, upload, OCR, review, metadata, PDF/A archive, + # search, and backend shutdown on close (e2e/desktop.spec.js). + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - name: Package desktop app + env: + ELECTRON_BUILD: 'true' + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + run: | + npm run build + npx electron-builder --win --dir --publish never + - name: Desktop end-to-end test + run: npx playwright test + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: e2e-results + path: | + test-results/ + playwright-report/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f3608a4..c64d11d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,16 @@ jobs: cache: npm - run: npm ci + # Never publish an installer whose app fails the end-to-end flow. + - name: Package and end-to-end test the desktop app + env: + ELECTRON_BUILD: 'true' + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + run: | + npm run build + npx electron-builder --win --dir --publish never + npx playwright test + # Signing is used when the WIN_CSC_LINK (base64 .pfx) and WIN_CSC_KEY_PASSWORD # secrets exist; otherwise an unsigned installer is built. - name: Configure code signing diff --git a/.gitignore b/.gitignore index 5a245cb..5aca64f 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,7 @@ Desktop.ini # Desktop packaging outputs release/ vendor/ + +# Playwright end-to-end output +test-results/ +playwright-report/ diff --git a/README.md b/README.md index 9a4ccce..ee3b803 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,17 @@ pip install -r requirements-dev.txt python -m pytest tests -q # OCR workflow tests are skipped if Tesseract is absent ``` +**Desktop end-to-end test** (`e2e/desktop.spec.js`): drives the packaged Electron app through +first-run setup, upload, OCR, review, metadata, PDF/A archive, search and shutdown. CI runs it +on Windows for every pull request and before every release. + +```bash +cd backend && pyinstaller --noconfirm server.spec && cd .. # backend executable +ELECTRON_BUILD=true npm run build +npx electron-builder --dir --publish never # unpacked app in release/ +npx playwright test # Linux without a display: xvfb-run -a npx playwright test +``` + ### Backend Configuration The backend reads optional environment variables (see `backend/app/config.py`): diff --git a/e2e/desktop.spec.js b/e2e/desktop.spec.js new file mode 100644 index 0000000..103e32f --- /dev/null +++ b/e2e/desktop.spec.js @@ -0,0 +1,114 @@ +// End-to-end test of the packaged desktop app: the real Electron shell, the +// PyInstaller backend it spawns (with its per-launch API token) and Tesseract. +// +// Build first (see docs/RELEASING.md), then: +// npx playwright test (Linux CI: xvfb-run -a npx playwright test) +// LIBRADIGIT_APP overrides the path of the packaged executable. +import { _electron as electron, expect, test } from '@playwright/test' +import fs from 'node:fs' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' + +const ROOT = path.resolve(import.meta.dirname, '..') +const DEFAULT_APP = { + win32: path.join(ROOT, 'release', 'win-unpacked', 'LibraDigit AI.exe'), + linux: path.join(ROOT, 'release', 'linux-unpacked', 'libradigit-ai'), +}[process.platform] +const APP = process.env.LIBRADIGIT_APP || DEFAULT_APP +const SCAN = path.join(ROOT, 'e2e', 'fixtures', 'sample-scan.png') +const PASSWORD = 'e2e-archivist-pass' + +const findFiles = (dir, ext) => fs.readdirSync(dir, { recursive: true }) + .filter(name => name.toLowerCase().endsWith(ext)) + .map(name => path.join(dir, name)) + +const portOpen = (port) => new Promise(resolve => { + const socket = net.connect(port, '127.0.0.1') + socket.once('connect', () => { socket.destroy(); resolve(true) }) + socket.once('error', () => resolve(false)) +}) + +test.describe.configure({ mode: 'serial' }) + +let app +let win +let dataDir +let archiveDir + +test.beforeAll(async () => { + expect(fs.existsSync(APP), `packaged app not found at ${APP}`).toBe(true) + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'libradigit-e2e-')) + dataDir = path.join(tmp, 'userdata') + archiveDir = path.join(tmp, 'Archive') + app = await electron.launch({ + executablePath: APP, + args: process.platform === 'linux' ? ['--no-sandbox'] : [], + env: { ...process.env, LIBRADIGIT_USER_DATA_DIR: dataDir, LIBRADIGIT_ARCHIVE_FOLDER: archiveDir }, + timeout: 60_000, + }) + win = await app.firstWindow() +}) + +test.afterAll(async () => { + if (app) await app.close().catch(() => {}) +}) + +test('first launch: account setup opens on Register and signs in', async () => { + // The splash stays until the bundled backend answers /api/health. + const setup = win.getByRole('button', { name: 'Setup & Login' }) + await expect(setup).toBeVisible({ timeout: 90_000 }) + await setup.click() + + await win.getByPlaceholder('Create Master Password (min. 6 chars)').fill(PASSWORD) + await win.getByPlaceholder('Confirm Master Password').fill(PASSWORD) + await win.getByRole('button', { name: /Create Account & Initialize/ }).click() + + await expect(win.getByText('Digitize your first document')).toBeVisible({ timeout: 30_000 }) +}) + +test('the app survives a window reload', async () => { + await win.reload() + await expect(win.getByText('Digitize your first document')).toBeVisible({ timeout: 30_000 }) +}) + +test('scan -> OCR -> review -> metadata -> PDF/A archive', async () => { + test.setTimeout(240_000) + await win.getByRole('button', { name: /New Ingest & OCR|Ingest Single Document/ }).first().click() + + await win.setInputFiles('input[type=file]', SCAN) + await win.getByText('Ingest Document to Sovereign Repository').click() + await win.getByText('Execute Neural OCR Recognition').click({ timeout: 30_000 }) + await win.getByText('Continue to Cleanup Studio').click({ timeout: 120_000 }) + + // The OCR text reaches the editor. + await expect(win.locator('.ql-editor')).toContainText('Annual Report', { timeout: 30_000 }) + await win.getByRole('button', { name: /Continue to Metadata/ }).click() + + await win.locator('input[name=title]').fill('Library Board Annual Report') + await win.locator('input[name=author]').fill('Jane Doe') + await win.locator('input[name=year]').fill('1954') + await win.locator('input[name=subject]').fill('History') + await win.getByRole('button', { name: /Continue to Archive/ }).click() + + await win.getByRole('button', { name: /Generate Archive File/ }).click() + await expect(win.getByText('Archive Generated Successfully!')).toBeVisible({ timeout: 60_000 }) + await expect(win.getByText('PDF/A-2b')).toBeVisible() + + const pdfs = findFiles(archiveDir, '.pdf') + expect(pdfs).toHaveLength(1) + expect(fs.readFileSync(pdfs[0]).subarray(0, 5).toString()).toBe('%PDF-') + expect(findFiles(archiveDir, 'dublin-core.xml')).toHaveLength(1) +}) + +test('archived document is searchable', async () => { + await win.getByRole('button', { name: 'Archive Search' }).click() + await win.locator('input.search-input').fill('Library') + await expect(win.getByText('Library Board Annual Report').first()).toBeVisible({ timeout: 15_000 }) +}) + +test('closing the app stops the backend', async () => { + await app.close() + app = null + await expect.poll(() => portOpen(5001), { timeout: 20_000 }).toBe(false) +}) diff --git a/e2e/fixtures/sample-scan.png b/e2e/fixtures/sample-scan.png new file mode 100644 index 0000000000000000000000000000000000000000..b80f52c14e861613052a56ba98b5f5766ee6a20a GIT binary patch literal 36742 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 literal 0 HcmV?d00001 diff --git a/electron/main.js b/electron/main.js index e728978..9a3283b 100644 --- a/electron/main.js +++ b/electron/main.js @@ -16,6 +16,11 @@ const BACKEND_START_TIMEOUT_MS = 60000; const isBackendUrl = (url) => /^http:\/\/(localhost|127\.0\.0\.1):5001\//.test(url); const isExternalWebUrl = (url) => /^https?:\/\//i.test(url) && !isBackendUrl(url); +// Test/automation hook: isolate all app data in a given folder. +if (process.env.LIBRADIGIT_USER_DATA_DIR) { + app.setPath('userData', process.env.LIBRADIGIT_USER_DATA_DIR); +} + let mainWindow = null; let pythonProcess = null; let quitting = false; @@ -53,7 +58,8 @@ const backendEnv = (backendDir) => { LIBRADIGIT_PORT: String(BACKEND_PORT), LIBRADIGIT_DATABASE: path.join(dataDir, 'libradigit.db'), LIBRADIGIT_UPLOAD_FOLDER: path.join(dataDir, 'uploads'), - LIBRADIGIT_ARCHIVE_FOLDER: path.join(app.getPath('documents'), 'LibraDigit Archive'), + LIBRADIGIT_ARCHIVE_FOLDER: process.env.LIBRADIGIT_ARCHIVE_FOLDER + || path.join(app.getPath('documents'), 'LibraDigit Archive'), // Releases before 1.3 stored data beside the backend executable. LIBRADIGIT_LEGACY_DATA_DIR: backendDir, }; diff --git a/eslint.config.js b/eslint.config.js index 7db1e82..46ce74e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,7 @@ import reactHooks from 'eslint-plugin-react-hooks' import unusedImports from 'eslint-plugin-unused-imports' export default [ - { ignores: ['dist/**', 'node_modules/**', 'backend/**', 'release/**'] }, + { ignores: ['dist/**', 'node_modules/**', 'backend/**', 'release/**', 'test-results/**', 'playwright-report/**'] }, { files: ['src/**/*.{js,jsx}'], languageOptions: { @@ -36,6 +36,11 @@ export default [ languageOptions: { ecmaVersion: 2022, sourceType: 'commonjs', globals: { ...globals.node } }, rules: { ...js.configs.recommended.rules }, }, + { + files: ['e2e/**/*.js'], + languageOptions: { ecmaVersion: 2024, sourceType: 'module', globals: { ...globals.node } }, + rules: { ...js.configs.recommended.rules }, + }, { files: ['*.config.js'], languageOptions: { ecmaVersion: 2022, sourceType: 'module', globals: { ...globals.node } }, diff --git a/package-lock.json b/package-lock.json index e9ca2b7..5befe50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.5", + "@playwright/test": "^1.56.1", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.6.0", "axios": "^1.6.2", @@ -2000,6 +2001,22 @@ "node": ">=14.18.0" } }, + "node_modules/@playwright/test": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -7984,6 +8001,53 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/playwright": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "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", diff --git a/package.json b/package.json index dc242c7..6b58b00 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "pack": "cross-env ELECTRON_BUILD=true npm run build && electron-builder --win --dir", "lint": "eslint .", "test:backend": "cd backend && python -m pytest tests -q", - "build:backend": "cd backend && pyinstaller --noconfirm server.spec" + "build:backend": "cd backend && pyinstaller --noconfirm server.spec", + "test:e2e": "playwright test" }, "keywords": [ "ocr", @@ -36,6 +37,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.5", + "@playwright/test": "^1.56.1", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.6.0", "axios": "^1.6.2", diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..2816de8 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from '@playwright/test' + +// Desktop end-to-end tests (e2e/). They drive the packaged Electron app, so +// build it first; see the header of e2e/desktop.spec.js. +export default defineConfig({ + testDir: 'e2e', + timeout: 120_000, + workers: 1, + retries: 0, + reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]], + use: { + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + outputDir: 'test-results', +}) diff --git a/src/App.jsx b/src/App.jsx index 4c55b5f..99c65f9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, lazy, Suspense } from 'react' -import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom' +import { BrowserRouter, HashRouter, Routes, Route, Navigate } from 'react-router-dom' import Sidebar from './components/Sidebar' import Header from './components/Header' import WelcomeScreen from './components/WelcomeScreen' @@ -8,6 +8,11 @@ import { ProjectProvider } from './context/ProjectContext' import { ToastProvider } from './context/ToastContext' import { ThemeProvider } from './context/ThemeContext' +// The desktop app loads index.html from disk (file://), where path-style URLs +// such as file:///upload do not exist: reloading the window showed a blank +// page. Hash URLs (index.html#/upload) work there; the web build keeps clean URLs. +const Router = window.location.protocol === 'file:' ? HashRouter : BrowserRouter + // Each page is its own chunk, so heavy libraries (charts, PDF rendering, the // rich-text editor, bcrypt) only download when the page that needs them opens. const LandingPage = lazy(() => import('./pages/LandingPage')) diff --git a/src/components/ErrorBoundary.jsx b/src/components/ErrorBoundary.jsx index cc146ae..aa2a07a 100644 --- a/src/components/ErrorBoundary.jsx +++ b/src/components/ErrorBoundary.jsx @@ -21,7 +21,13 @@ class ErrorBoundary extends React.Component { handleGoHome = () => { this.setState({ hasError: false, error: null }) - window.location.href = '/' + // file:// (desktop app) has no '/' page; go to the app's hash root instead. + if (window.location.protocol === 'file:') { + window.location.hash = '#/' + window.location.reload() + } else { + window.location.href = '/' + } } render() { diff --git a/src/components/LoginScreen.jsx b/src/components/LoginScreen.jsx index 929a426..a0cfefb 100644 --- a/src/components/LoginScreen.jsx +++ b/src/components/LoginScreen.jsx @@ -19,9 +19,9 @@ import { } from 'lucide-react' import bcrypt from 'bcryptjs' -const LoginScreen = ({ onLogin, isModal = false, onClose = null }) => { +const LoginScreen = ({ onLogin, isModal = false, onClose = null, initialMode = 'login' }) => { // Mode: 'login' | 'register' | 'reset' - const [authMode, setAuthMode] = useState('login') + const [authMode, setAuthMode] = useState(initialMode) // Form fields const [username, setUsername] = useState('') diff --git a/src/pages/LandingPage.jsx b/src/pages/LandingPage.jsx index ef40614..e767865 100644 --- a/src/pages/LandingPage.jsx +++ b/src/pages/LandingPage.jsx @@ -469,6 +469,7 @@ const LandingPage = ({ onLogin, isLoggedIn }) => { {showLoginModal && ( setShowLoginModal(false)} onLogin={() => { if (onLogin) onLogin() diff --git a/src/pages/UploadOCR.jsx b/src/pages/UploadOCR.jsx index f65e5a2..e171358 100644 --- a/src/pages/UploadOCR.jsx +++ b/src/pages/UploadOCR.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { useProject } from '../context/ProjectContext' import { useToast } from '../context/ToastContext' @@ -128,6 +128,15 @@ const UploadOCR = () => { const jobOptions = { onStart: setJob, onProgress: setJob } + // Auto-advance to Cleanup shortly after OCR finishes, but never after the + // user has already moved on (the stale timer used to yank them back). + const redirectTimer = useRef(null) + useEffect(() => () => clearTimeout(redirectTimer.current), []) + const goToCleanupSoon = (id) => { + clearTimeout(redirectTimer.current) + redirectTimer.current = setTimeout(() => navigate(`/cleanup/${id}`), 1800) + } + // Opened from the dashboard (/upload?project=): continue that document, // and if OCR is already running for it, show its live progress. const resumeProjectId = searchParams.get('project') @@ -200,9 +209,7 @@ const UploadOCR = () => { finishJob() addToast('Neural OCR extraction complete!', 'success') - setTimeout(() => { - navigate(`/cleanup/${projectId || currentProject.id}`) - }, 1800) + goToCleanupSoon(projectId || currentProject.id) } catch (err) { finishJob() @@ -235,9 +242,7 @@ const UploadOCR = () => { finishJob() addToast('Handwritten document converted to a searchable PDF!', 'success') - setTimeout(() => { - navigate(`/cleanup/${projectId || currentProject.id}`) - }, 1800) + goToCleanupSoon(projectId || currentProject.id) } catch (err) { setConvertingPDF(false)