Update .gitignore and .dockerignore, enhance component classes, add E2E test setup, and configure Playwright

This commit is contained in:
Dominik Schröter 2025-01-27 16:41:18 +01:00
parent 969bacc137
commit 772034fa8a
17 changed files with 1871 additions and 15 deletions

View file

@ -1,2 +1,6 @@
.env
Dockerfile
web-app/e2e
web-app/node_modules
web-app/playwright_report
web-app/test-results

70
.github/workflows/e2e-tests.yml vendored Normal file
View file

@ -0,0 +1,70 @@
name: E2E Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
name: E2E Tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./web-app
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: './web-app/package-lock.json'
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- name: Build app
run: npm run build
- name: Create test env file
run: |
echo "PAPERLESS_GPT_URL=http://localhost:8080" > .env.test
echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}" >> .env.test
- name: Start test containers
run: npm run docker:test:up
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Run Playwright tests
run: npm run test:e2e
env:
CI: true
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: web-app/playwright-report/
retention-days: 30
- name: Upload test screenshots
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: web-app/test-results/
retention-days: 30
- name: Stop test containers
if: always()
run: npm run docker:test:down

2
.gitignore vendored
View file

@ -4,3 +4,5 @@ prompts/
tests/tmp
tmp/
db/
web-app/playwright-report/
web-app/test-results/.last-run.json

8
web-app/.env.test Normal file
View file

@ -0,0 +1,8 @@
# Environment variables for E2E tests
PAPERLESS_NGX_URL=http://localhost:8000
PAPERLESS_GPT_URL=http://localhost:8080
PAPERLESS_ADMIN_USER=admin
PAPERLESS_ADMIN_PASSWORD=admin
# Add your OpenAI API key here (required for document processing)
# OPENAI_API_KEY=sk-uhB.... # Replace with your actual OpenAI API key from https://platform.openai.com/api-keys

View file

@ -0,0 +1,57 @@
version: '3.8'
services:
paperless-ngx:
image: ghcr.io/paperless-ngx/paperless-ngx:latest
environment:
PAPERLESS_URL: http://localhost:8001
PAPERLESS_SECRET_KEY: change-me
PAPERLESS_ADMIN_USER: admin
PAPERLESS_ADMIN_PASSWORD: admin
PAPERLESS_TIME_ZONE: Europe/Berlin
PAPERLESS_OCR_LANGUAGE: eng
PAPERLESS_REDIS: redis://redis:6379
ports:
- "8001:8000"
volumes:
- paperless-data:/usr/src/paperless/data
- paperless-media:/usr/src/paperless/media
- paperless-export:/usr/src/paperless/export
depends_on:
- redis
- postgres
redis:
image: redis:7
restart: unless-stopped
postgres:
image: postgres:15
restart: unless-stopped
environment:
POSTGRES_DB: paperless
POSTGRES_USER: paperless
POSTGRES_PASSWORD: paperless
volumes:
- pgdata:/var/lib/postgresql/data
paperless-gpt:
build:
context: ..
dockerfile: Dockerfile
image: icereed/paperless-gpt:e2e
environment:
PAPERLESS_URL: http://paperless-ngx:8000
PAPERLESS_ADMIN_USER: admin
PAPERLESS_ADMIN_PASSWORD: admin
OPENAI_API_KEY: ${OPENAI_API_KEY}
ports:
- "8080:8080"
depends_on:
- paperless-ngx
volumes:
paperless-data:
paperless-media:
paperless-export:
pgdata:

View file

@ -0,0 +1,129 @@
import { expect, Page, test } from '@playwright/test';
import path, { dirname } from 'path';
import { fileURLToPath } from 'url';
import { addTagToDocument, PORTS, setupTestEnvironment, TestEnvironment, uploadDocument } from './test-environment';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
let testEnv: TestEnvironment;
let page: Page;
test.beforeAll(async () => {
testEnv = await setupTestEnvironment();
});
test.afterAll(async () => {
await testEnv.cleanup();
});
test.beforeEach(async () => {
page = await testEnv.browser.newPage();
});
test.afterEach(async () => {
await page.close();
});
test('should process document and show changes in history', async () => {
const paperlessNgxPort = testEnv.paperlessNgx.getMappedPort(PORTS.paperlessNgx);
const paperlessGptPort = testEnv.paperlessGpt.getMappedPort(PORTS.paperlessGpt);
const credentials = { username: 'admin', password: 'admin' };
// 1. Upload document and add initial tag via API
const baseUrl = `http://localhost:${paperlessNgxPort}`;
const documentPath = path.join(__dirname, 'fixtures', 'test-document.txt');
// Get the paperless-gpt tag ID
const response = await fetch(`${baseUrl}/api/tags/?name=paperless-gpt`, {
headers: {
'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`),
},
});
if (!response.ok) {
throw new Error('Failed to fetch paperless-gpt tag');
}
const tags = await response.json();
if (!tags.results || tags.results.length === 0) {
throw new Error('paperless-gpt tag not found');
}
const tagId = tags.results[0].id;
// Upload document and get ID
const { id: documentId } = await uploadDocument(
baseUrl,
documentPath,
'Original Title',
credentials
);
console.log(`Document ID: ${documentId}`);
// Add tag to document
await addTagToDocument(
baseUrl,
documentId,
tagId,
credentials
);
// 2. Navigate to Paperless-GPT UI and process the document
await page.goto(`http://localhost:${paperlessGptPort}`);
// Wait for document to appear in the list
await page.waitForSelector('.document-card', { timeout: 1000 * 60 });
// Click the process button
await page.click('button:has-text("Generate Suggestions")');
// Wait for processing to complete
await page.waitForSelector('.suggestions-review', { timeout: 30000 });
// Apply the suggestions
await page.click('button:has-text("Apply")');
// Wait for success message
await page.waitForSelector('.success-modal', { timeout: 10000 });
// Click "OK" on success modal
await page.click('button:has-text("OK")');
// 3. Check history page for the modifications
await page.click('a:has-text("History")');
// Wait for history page to load
await page.waitForSelector('.modification-history', { timeout: 5000 });
// Verify at least one modification entry exists
const modifications = await page.locator('.undo-card').count();
expect(modifications).toBeGreaterThan(0);
// Verify modification details
const firstModification = await page.locator('.undo-card').first();
// Check if title was modified
const titleChange = await firstModification.locator('text=Original Title').isVisible();
expect(titleChange).toBeTruthy();
// Test pagination if there are multiple modifications
const paginationVisible = await page.locator('.pagination-controls').isVisible();
if (paginationVisible) {
// Click next page if available
const nextButton = page.locator('button:has-text("Next")');
if (await nextButton.isEnabled()) {
await nextButton.click();
// Wait for new items to load
await page.waitForSelector('.undo-card');
}
}
// 4. Test undo functionality
const undoButton = await firstModification.locator('button:has-text("Undo")');
if (await undoButton.isEnabled()) {
await undoButton.click();
// Wait for undo to complete. Text should change to "Undone"
await page.waitForSelector('text=Undone');
}
});

View file

@ -0,0 +1,20 @@
Invoice #12345
From: Test Company Ltd.
To: Sample Client Inc.
Date: January 27, 2025
Item Description:
1. Software Development Services - $5,000
2. Cloud Infrastructure Setup - $2,500
3. Technical Consulting - $1,500
Total: $9,000
Payment Terms: Net 30
Due Date: February 26, 2025
Please make payment to:
Bank: Test Bank
Account: 1234567890

View file

@ -0,0 +1,24 @@
import { chromium } from '@playwright/test';
import * as nodeFetch from 'node-fetch';
// Polyfill fetch for Node.js environment
if (!globalThis.fetch) {
(globalThis as any).fetch = nodeFetch.default;
(globalThis as any).Headers = nodeFetch.Headers;
(globalThis as any).Request = nodeFetch.Request;
(globalThis as any).Response = nodeFetch.Response;
(globalThis as any).FormData = nodeFetch.FormData;
}
async function globalSetup() {
// Install Playwright browser if needed
const browser = await chromium.launch();
await browser.close();
// Load environment variables
if (!process.env.OPENAI_API_KEY) {
console.warn('Warning: OPENAI_API_KEY environment variable is not set');
}
}
export default globalSetup;

View file

@ -0,0 +1,279 @@
import { Browser, chromium } from '@playwright/test';
import * as fs from 'fs';
import { GenericContainer, Network, StartedTestContainer, Wait } from 'testcontainers';
export interface TestEnvironment {
paperlessNgx: StartedTestContainer;
paperlessGpt: StartedTestContainer;
browser: Browser;
cleanup: () => Promise<void>;
}
export const PORTS = {
paperlessNgx: 8000,
paperlessGpt: 8080,
};
export async function setupTestEnvironment(): Promise<TestEnvironment> {
console.log('Setting up test environment...');
const paperlessPort = PORTS.paperlessNgx;
const gptPort = PORTS.paperlessGpt;
// Create a network for the containers
const network = await new Network().start();
console.log('Starting Redis container...');
const redis = await new GenericContainer('redis:7')
.withNetwork(network)
.withNetworkAliases('redis')
.start();
console.log('Starting Postgres container...');
const postgres = await new GenericContainer('postgres:15')
.withNetwork(network)
.withNetworkAliases('postgres')
.withEnvironment({
POSTGRES_DB: 'paperless',
POSTGRES_USER: 'paperless',
POSTGRES_PASSWORD: 'paperless'
})
.start();
console.log('Starting Paperless-ngx container...');
const paperlessNgx = await new GenericContainer('ghcr.io/paperless-ngx/paperless-ngx:latest')
.withNetwork(network)
.withNetworkAliases('paperless-ngx')
.withEnvironment({
PAPERLESS_URL: `http://localhost:${paperlessPort}`,
PAPERLESS_SECRET_KEY: 'change-me',
PAPERLESS_ADMIN_USER: 'admin',
PAPERLESS_ADMIN_PASSWORD: 'admin',
PAPERLESS_TIME_ZONE: 'Europe/Berlin',
PAPERLESS_OCR_LANGUAGE: 'eng',
PAPERLESS_REDIS: 'redis://redis:6379',
PAPERLESS_DBHOST: 'postgres',
PAPERLESS_DBNAME: 'paperless',
PAPERLESS_DBUSER: 'paperless',
PAPERLESS_DBPASS: 'paperless'
})
.withExposedPorts(paperlessPort)
.withWaitStrategy(Wait.forHttp('/api/', paperlessPort))
.start();
const mappedPort = paperlessNgx.getMappedPort(paperlessPort);
console.log(`Paperless-ngx container started, mapped port: ${mappedPort}`);
// Create required tag before starting paperless-gpt
const baseUrl = `http://localhost:${mappedPort}`;
const credentials = { username: 'admin', password: 'admin' };
try {
console.log('Creating paperless-gpt tag...');
await createTag(baseUrl, 'paperless-gpt', credentials);
} catch (error) {
console.error('Failed to create paperless-gpt tag:', error);
await paperlessNgx.stop();
throw error;
}
console.log('Starting Paperless-gpt container...');
const paperlessGpt = await new GenericContainer('icereed/paperless-gpt:e2e')
.withNetwork(network)
.withEnvironment({
PAPERLESS_BASE_URL: `http://paperless-ngx:${paperlessPort}`,
PAPERLESS_API_TOKEN: await getApiToken(baseUrl, credentials),
LLM_PROVIDER: "openai",
LLM_MODEL: "gpt-4",
LLM_LANGUAGE: "english",
OPENAI_API_KEY: process.env.OPENAI_API_KEY || '',
})
.withExposedPorts(gptPort)
.withWaitStrategy(Wait.forHttp('/', gptPort))
.start();
console.log('Paperless-gpt container started');
console.log('Launching browser...');
const browser = await chromium.launch();
console.log('Browser launched');
const cleanup = async () => {
console.log('Cleaning up test environment...');
await browser.close();
await paperlessGpt.stop();
await paperlessNgx.stop();
await redis.stop();
await postgres.stop();
await network.stop();
console.log('Test environment cleanup completed');
};
console.log('Test environment setup completed');
return {
paperlessNgx,
paperlessGpt,
browser,
cleanup,
};
}
export async function waitForElement(page: any, selector: string, timeout = 5000): Promise<void> {
await page.waitForSelector(selector, { timeout });
}
export interface PaperlessDocument {
id: number;
title: string;
content: string;
tags: number[];
}
// Helper to upload a document via Paperless-ngx API
export async function uploadDocument(
baseUrl: string,
filePath: string,
title: string,
credentials: { username: string; password: string }
): Promise<PaperlessDocument> {
console.log(`Uploading document: ${title} from ${filePath}`);
const formData = new FormData();
const fileData = await fs.promises.readFile(filePath);
formData.append('document', new Blob([fileData]));
formData.append('title', title);
// Initial upload to get task ID
const uploadResponse = await fetch(`${baseUrl}/api/documents/post_document/`, {
method: 'POST',
body: formData,
headers: {
'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`),
},
});
if (!uploadResponse.ok) {
console.error(`Upload failed with status ${uploadResponse.status}: ${uploadResponse.statusText}`);
throw new Error(`Failed to upload document: ${uploadResponse.statusText}`);
}
const task_id = await uploadResponse.json();
// Poll the tasks endpoint until document is processed
while (true) {
console.log(`Checking task status for ID: ${task_id}`);
const taskResponse = await fetch(`${baseUrl}/api/tasks/?task_id=${task_id}`, {
headers: {
'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`),
},
});
if (!taskResponse.ok) {
throw new Error(`Failed to check task status: ${taskResponse.statusText}`);
}
const taskResultArr = await taskResponse.json();
console.log(`Task status: ${JSON.stringify(taskResultArr)}`);
if (taskResultArr.length === 0) {
continue;
}
const taskResult = taskResultArr[0];
// Check if task is completed
if (taskResult.status === 'SUCCESS' && taskResult.id) {
console.log(`Document processed successfully with ID: ${taskResult.id}`);
// Fetch the complete document details
const documentResponse = await fetch(`${baseUrl}/api/documents/${taskResult.id}/`, {
headers: {
'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`),
},
});
if (!documentResponse.ok) {
throw new Error(`Failed to fetch document details: ${documentResponse.statusText}`);
}
return await documentResponse.json();
}
// Check for failure
if (taskResult.status === 'FAILED') {
throw new Error(`Document processing failed: ${taskResult.result}`);
}
// Wait before polling again
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
// Helper to create a tag via Paperless-ngx API
export async function createTag(
baseUrl: string,
name: string,
credentials: { username: string; password: string }
): Promise<number> {
console.log(`Creating tag: ${name}`);
const response = await fetch(`${baseUrl}/api/tags/`, {
method: 'POST',
body: JSON.stringify({ name }),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`),
},
});
if (!response.ok) {
console.error(`Tag creation failed with status ${response.status}: ${response.statusText}`);
throw new Error(`Failed to create tag: ${response.statusText}`);
}
const tag = await response.json();
console.log(`Tag created successfully with ID: ${tag.id}`);
return tag.id;
}
// Helper to get an API token
export async function getApiToken(
baseUrl: string,
credentials: { username: string; password: string }
): Promise<string> {
console.log('Fetching API token');
const response = await fetch(`${baseUrl}/api/token/`, {
method: 'POST',
body: new URLSearchParams({
username: credentials.username,
password: credentials.password,
}),
});
if (!response.ok) {
console.error(`API token fetch failed with status ${response.status}: ${response.statusText}`);
throw new Error(`Failed to fetch API token: ${response.statusText}`);
}
const token = await response.json();
console.log(`API token fetched successfully: ${token.token}`);
return token.token;
}
// Helper to add a tag to a document
export async function addTagToDocument(
baseUrl: string,
documentId: number,
tagId: number,
credentials: { username: string; password: string }
): Promise<void> {
console.log(`Adding tag ${tagId} to document ${documentId}`);
const response = await fetch(`${baseUrl}/api/documents/${documentId}/`, {
method: 'PATCH',
body: JSON.stringify({
tags: [tagId],
}),
headers: {
'Content-Type': 'application/json',
'Authorization': 'Basic ' + btoa(`${credentials.username}:${credentials.password}`),
},
});
if (!response.ok) {
console.error(`Tag addition failed with status ${response.status}: ${response.statusText}`);
throw new Error(`Failed to add tag to document: ${response.statusText}`);
}
console.log('Tag added successfully');
}

1229
web-app/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,11 @@
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "echo \"TODO\""
"pretest:e2e": "docker compose -f docker-compose.test.yml build",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"docker:test:up": "docker compose -f docker-compose.test.yml up -d",
"docker:test:down": "docker compose -f docker-compose.test.yml down -v"
},
"dependencies": {
"@headlessui/react": "^2.1.8",
@ -29,17 +33,21 @@
},
"devDependencies": {
"@eslint/js": "^9.9.0",
"@types/node": "^22.10.1",
"@playwright/test": "^1.50.0",
"@types/node": "^22.10.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.20",
"dotenv": "^16.4.7",
"eslint": "^9.9.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"node-fetch": "^3.3.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.12",
"testcontainers": "^10.17.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^5.4.1"

View file

@ -0,0 +1,36 @@
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
// Load test environment variables
dotenv.config({ path: '.env.test' });
export default defineConfig({
testDir: './e2e',
timeout: 120000, // Increased timeout for container startup
expect: {
timeout: 10000,
},
globalSetup: './e2e/setup/global-setup.ts',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: 'html',
use: {
baseURL: process.env.PAPERLESS_GPT_URL || 'http://localhost:8080',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1280, height: 720 },
},
},
],
outputDir: 'test-results',
preserveOutput: 'failures-only',
});

View file

@ -111,7 +111,7 @@ const History: React.FC = () => {
}
return (
<div className="container mx-auto px-4 py-8">
<div className="modification-history container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold text-gray-800 dark:text-gray-200">
Modification History
</h1>

View file

@ -6,7 +6,7 @@ interface DocumentCardProps {
}
const DocumentCard: React.FC<DocumentCardProps> = ({ document }) => (
<div className="bg-white dark:bg-gray-800 shadow-lg shadow-blue-500/50 rounded-md p-4 relative group overflow-hidden">
<div className="document-card bg-white dark:bg-gray-800 shadow-lg shadow-blue-500/50 rounded-md p-4 relative group overflow-hidden">
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200">{document.title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2 truncate">
{document.content.length > 100

View file

@ -11,7 +11,7 @@ const SuccessModal: React.FC<SuccessModalProps> = ({ isOpen, onClose }) => (
<Transition show={isOpen} as={Fragment}>
<Dialog
as="div"
className="fixed z-10 inset-0 overflow-y-auto"
className="success-modal fixed z-10 inset-0 overflow-y-auto"
open={isOpen}
onClose={onClose}
>

View file

@ -25,7 +25,7 @@ const SuggestionsReview: React.FC<SuggestionsReviewProps> = ({
onUpdate,
updating,
}) => (
<section>
<section className="suggestions-review">
<h2 className="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-6">
Review and Edit Suggested Titles
</h2>

View file

@ -1,6 +1,6 @@
// UndoCard.tsx
import React from 'react';
import { Tooltip } from 'react-tooltip'
import { Tooltip } from 'react-tooltip';
interface ModificationProps {
ID: number;
@ -72,7 +72,7 @@ const UndoCard: React.FC<ModificationProps> = ({
};
return (
<div className="relative bg-white dark:bg-gray-800 p-4 rounded-md shadow-md">
<div className="undo-card relative bg-white dark:bg-gray-800 p-4 rounded-md shadow-md">
<div className="grid grid-cols-6">
<div className="col-span-5"> {/* Left content */}
<div className="grid grid-cols-3 gap-4 mb-4">