Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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/
10 changes: 10 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,7 @@ Desktop.ini
# Desktop packaging outputs
release/
vendor/

# Playwright end-to-end output
test-results/
playwright-report/
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):
Expand Down
114 changes: 114 additions & 0 deletions e2e/desktop.spec.js
Original file line number Diff line number Diff line change
@@ -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)
})
Binary file added e2e/fixtures/sample-scan.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 7 additions & 1 deletion electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
};
Expand Down
7 changes: 6 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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 } },
Expand Down
64 changes: 64 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions playwright.config.js
Original file line number Diff line number Diff line change
@@ -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',
})
7 changes: 6 additions & 1 deletion src/App.jsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'))
Expand Down
8 changes: 7 additions & 1 deletion src/components/ErrorBoundary.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading
Loading