mirror of
https://github.com/icereed/paperless-gpt.git
synced 2025-03-12 12:58:02 -05:00
Implement pagination for modification history and E2E tests (#162)
This commit is contained in:
parent
4b9ba45eb3
commit
16eebc93d8
19 changed files with 1981 additions and 51 deletions
|
@ -1,3 +1,7 @@
|
|||
.env
|
||||
Dockerfile
|
||||
web-app/e2e
|
||||
web-app/node_modules
|
||||
web-app/playwright_report
|
||||
web-app/test-results
|
||||
.github
|
65
.github/workflows/docker-build-and-push.yml
vendored
65
.github/workflows/docker-build-and-push.yml
vendored
|
@ -1,5 +1,9 @@
|
|||
name: Build and Push Docker Images
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
|
@ -16,29 +20,22 @@ jobs:
|
|||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 1.22
|
||||
|
||||
- name: Install mupdf
|
||||
run: sudo apt-get install -y mupdf
|
||||
|
||||
- name: Set library path
|
||||
run: echo "/usr/lib" | sudo tee -a /etc/ld.so.conf.d/mupdf.conf && sudo ldconfig
|
||||
|
||||
- name: Install dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Run Go tests
|
||||
run: go test ./...
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
|
@ -46,11 +43,9 @@ jobs:
|
|||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm install
|
||||
working-directory: web-app
|
||||
|
||||
- name: Run frontend tests
|
||||
run: npm test
|
||||
working-directory: web-app
|
||||
|
@ -60,13 +55,13 @@ jobs:
|
|||
needs: test
|
||||
outputs:
|
||||
digest: ${{ steps.build_amd64.outputs.digest }}
|
||||
image_tag: ${{ steps.set_image_tag.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Log in to Docker Hub
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
|
@ -74,7 +69,9 @@ jobs:
|
|||
- name: Set Docker tags
|
||||
id: set_tags
|
||||
run: |
|
||||
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "pull_request" ]]; then
|
||||
echo "TAGS=icereed/paperless-gpt:pr-${GITHUB_SHA}-amd64" >> $GITHUB_ENV
|
||||
elif [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "TAGS=icereed/paperless-gpt:${VERSION}-amd64" >> $GITHUB_ENV
|
||||
else
|
||||
|
@ -86,7 +83,7 @@ jobs:
|
|||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ env.TAGS }}
|
||||
|
@ -94,6 +91,9 @@ jobs:
|
|||
VERSION=${{ github.ref_type == 'tag' && github.ref_name || github.sha }}
|
||||
COMMIT=${{ github.sha }}
|
||||
BUILD_DATE=${{ github.event.repository.pushed_at }}
|
||||
- name: Set image tag output
|
||||
id: set_image_tag
|
||||
run: echo "image_tag=${TAGS}" >> $GITHUB_OUTPUT
|
||||
- name: Export digest for amd64
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
|
@ -204,3 +204,44 @@ jobs:
|
|||
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
|
||||
docker buildx imagetools inspect ${DOCKERHUB_REPO}:latest
|
||||
fi
|
||||
|
||||
e2e-tests:
|
||||
name: E2E Tests
|
||||
needs: build-amd64
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./web-app
|
||||
steps:
|
||||
- name: Checkout code
|
||||
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: Run Playwright tests
|
||||
run: npm run test:e2e
|
||||
env:
|
||||
CI: true
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
PAPERLESS_GPT_IMAGE: icereed/paperless-gpt:pr-${{ github.sha }}-amd64
|
||||
- name: Upload Playwright Report
|
||||
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
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -4,3 +4,6 @@ prompts/
|
|||
tests/tmp
|
||||
tmp/
|
||||
db/
|
||||
web-app/playwright-report/
|
||||
web-app/test-results/.last-run.json
|
||||
web-app/test-results
|
||||
|
|
|
@ -248,13 +248,34 @@ func (app *App) getDocumentHandler() gin.HandlerFunc {
|
|||
// Section for local-db actions
|
||||
|
||||
func (app *App) getModificationHistoryHandler(c *gin.Context) {
|
||||
modifications, err := GetAllModifications(app.Database)
|
||||
// Parse pagination parameters
|
||||
page := 1
|
||||
pageSize := 20
|
||||
|
||||
if p, err := strconv.Atoi(c.DefaultQuery("page", "1")); err == nil && p > 0 {
|
||||
page = p
|
||||
}
|
||||
if ps, err := strconv.Atoi(c.DefaultQuery("pageSize", "20")); err == nil && ps > 0 && ps <= 100 {
|
||||
pageSize = ps
|
||||
}
|
||||
|
||||
// Get paginated modifications and total count
|
||||
modifications, total, err := GetPaginatedModifications(app.Database, page, pageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve modification history"})
|
||||
log.Errorf("Failed to retrieve modification history: %v", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, modifications)
|
||||
|
||||
totalPages := (int(total) + pageSize - 1) / pageSize
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": modifications,
|
||||
"totalItems": total,
|
||||
"totalPages": totalPages,
|
||||
"currentPage": page,
|
||||
"pageSize": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
func (app *App) undoModificationHandler(c *gin.Context) {
|
||||
|
|
26
local_db.go
26
local_db.go
|
@ -63,13 +63,35 @@ func GetModification(db *gorm.DB, id uint) (*ModificationHistory, error) {
|
|||
return &record, result.Error
|
||||
}
|
||||
|
||||
// GetAllModifications retrieves all modification records from the database
|
||||
// GetAllModifications retrieves all modification records from the database (deprecated - use GetPaginatedModifications instead)
|
||||
func GetAllModifications(db *gorm.DB) ([]ModificationHistory, error) {
|
||||
var records []ModificationHistory
|
||||
result := db.Order("date_changed DESC").Find(&records) // GORM's Find method retrieves all records
|
||||
result := db.Order("date_changed DESC").Find(&records)
|
||||
return records, result.Error
|
||||
}
|
||||
|
||||
// GetPaginatedModifications retrieves a page of modification records with total count
|
||||
func GetPaginatedModifications(db *gorm.DB, page int, pageSize int) ([]ModificationHistory, int64, error) {
|
||||
var records []ModificationHistory
|
||||
var total int64
|
||||
|
||||
// Get total count
|
||||
if err := db.Model(&ModificationHistory{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Calculate offset
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
// Get paginated records
|
||||
result := db.Order("date_changed DESC").
|
||||
Offset(offset).
|
||||
Limit(pageSize).
|
||||
Find(&records)
|
||||
|
||||
return records, total, result.Error
|
||||
}
|
||||
|
||||
// UndoModification marks a modification record as undone and sets the undo date
|
||||
func SetModificationUndone(db *gorm.DB, record *ModificationHistory) error {
|
||||
record.Undone = true
|
||||
|
|
8
web-app/.env.test
Normal file
8
web-app/.env.test
Normal 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
|
57
web-app/docker-compose.test.yml
Normal file
57
web-app/docker-compose.test.yml
Normal 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:
|
135
web-app/e2e/document-processing.spec.ts
Normal file
135
web-app/e2e/document-processing.spec.ts
Normal file
|
@ -0,0 +1,135 @@
|
|||
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: testPage }) => {
|
||||
page = testPage;
|
||||
await page.goto(`http://localhost:${testEnv.paperlessGpt.getMappedPort(PORTS.paperlessGpt)}`);
|
||||
await page.screenshot({ path: 'test-results/initial-state.png' });
|
||||
});
|
||||
|
||||
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 });
|
||||
await page.screenshot({ path: 'test-results/document-loaded.png' });
|
||||
|
||||
// Click the process button
|
||||
await page.click('button:has-text("Generate Suggestions")');
|
||||
|
||||
// Wait for processing to complete
|
||||
await page.waitForSelector('.suggestions-review', { timeout: 30000 });
|
||||
await page.screenshot({ path: 'test-results/suggestions-loaded.png' });
|
||||
|
||||
// Apply the suggestions
|
||||
await page.click('button:has-text("Apply")');
|
||||
|
||||
// Wait for success message
|
||||
await page.waitForSelector('.success-modal', { timeout: 10000 });
|
||||
await page.screenshot({ path: 'test-results/suggestions-applied.png' });
|
||||
|
||||
// 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 });
|
||||
await page.screenshot({ path: 'test-results/history-page.png' });
|
||||
|
||||
// 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');
|
||||
}
|
||||
});
|
20
web-app/e2e/fixtures/test-document.txt
Normal file
20
web-app/e2e/fixtures/test-document.txt
Normal 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
|
24
web-app/e2e/setup/global-setup.ts
Normal file
24
web-app/e2e/setup/global-setup.ts
Normal 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;
|
281
web-app/e2e/test-environment.ts
Normal file
281
web-app/e2e/test-environment.ts
Normal file
|
@ -0,0 +1,281 @@
|
|||
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 paperlessGptImage = process.env.PAPERLESS_GPT_IMAGE || 'icereed/paperless-gpt:e2e';
|
||||
console.log(`Using image: ${paperlessGptImage}`);
|
||||
const paperlessGpt = await new GenericContainer(paperlessGptImage)
|
||||
.withNetwork(network)
|
||||
.withEnvironment({
|
||||
PAPERLESS_BASE_URL: `http://paperless-ngx:${paperlessPort}`,
|
||||
PAPERLESS_API_TOKEN: await getApiToken(baseUrl, credentials),
|
||||
LLM_PROVIDER: "openai",
|
||||
LLM_MODEL: "gpt-4o-mini",
|
||||
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
1229
web-app/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -8,7 +8,11 @@
|
|||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "echo \"TODO\""
|
||||
"test": "echo \"TODO\"",
|
||||
"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"
|
||||
|
|
36
web-app/playwright.config.ts
Normal file
36
web-app/playwright.config.ts
Normal 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: {
|
||||
screenshot: 'on',
|
||||
baseURL: process.env.PAPERLESS_GPT_URL || 'http://localhost:8080',
|
||||
trace: 'retain-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
},
|
||||
],
|
||||
outputDir: 'test-results',
|
||||
preserveOutput: 'failures-only',
|
||||
});
|
|
@ -12,11 +12,23 @@ interface ModificationHistory {
|
|||
UndoneDate: string | null;
|
||||
}
|
||||
|
||||
interface PaginatedResponse {
|
||||
items: ModificationHistory[];
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
const History: React.FC = () => {
|
||||
const [modifications, setModifications] = useState<ModificationHistory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [paperlessUrl, setPaperlessUrl] = useState<string>('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const pageSize = 20;
|
||||
|
||||
// Get Paperless URL
|
||||
useEffect(() => {
|
||||
|
@ -36,19 +48,22 @@ const History: React.FC = () => {
|
|||
fetchUrl();
|
||||
}, []);
|
||||
|
||||
// Get all modifications
|
||||
// Get modifications with pagination
|
||||
useEffect(() => {
|
||||
fetchModifications();
|
||||
}, []);
|
||||
fetchModifications(currentPage);
|
||||
}, [currentPage]);
|
||||
|
||||
const fetchModifications = async () => {
|
||||
const fetchModifications = async (page: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/modifications');
|
||||
const response = await fetch(`/api/modifications?page=${page}&pageSize=${pageSize}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch modifications');
|
||||
}
|
||||
const data = await response.json();
|
||||
setModifications(data);
|
||||
const data: PaginatedResponse = await response.json();
|
||||
setModifications(data.items);
|
||||
setTotalPages(data.totalPages);
|
||||
setTotalItems(data.totalItems);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error occurred');
|
||||
} finally {
|
||||
|
@ -96,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>
|
||||
|
@ -108,16 +123,52 @@ const History: React.FC = () => {
|
|||
No modifications found
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-1">
|
||||
{modifications.map((modification) => (
|
||||
<UndoCard
|
||||
key={modification.ID}
|
||||
{...modification}
|
||||
onUndo={handleUndo}
|
||||
paperlessUrl={paperlessUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-1 mb-6">
|
||||
{modifications.map((modification) => (
|
||||
<UndoCard
|
||||
key={modification.ID}
|
||||
{...modification}
|
||||
onUndo={handleUndo}
|
||||
paperlessUrl={paperlessUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>
|
||||
Showing {((currentPage - 1) * pageSize) + 1} to {Math.min(currentPage * pageSize, totalItems)} of {totalItems} results
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(page => Math.max(1, page - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className={`px-3 py-1 rounded-md ${
|
||||
currentPage === 1
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed dark:bg-gray-800'
|
||||
: 'bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(page => Math.min(totalPages, page + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`px-3 py-1 rounded-md ${
|
||||
currentPage === totalPages
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed dark:bg-gray-800'
|
||||
: 'bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
Loading…
Reference in a new issue