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 0000000..b80f52c Binary files /dev/null and b/e2e/fixtures/sample-scan.png differ 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)