Restructure/frontend editor (#6404)

## Move editor under `frontend/editor/`

Pure restructure: `frontend/` becomes the workspace, `frontend/editor/`
holds
  the PDF editor. 1775 file renames + 40 wiring edits. No logic changes.

  ### Why

`frontend/` is currently the editor — its `src/`, `public/`,
`src-tauri/`,
  config files all sit at the root. Promoting `frontend/` to a
workspace and putting the editor in a sibling folder leaves room for
future
apps to drop in alongside it, sharing one `package.json` /
`node_modules` /
  lint config / Storybook.

  ### What moves

  frontend/
  ├── editor/                ← NEW: everything editor-specific
  │   ├── src/               ← was frontend/src/
  │   ├── public/            ← was frontend/public/
  │   ├── src-tauri/         ← was frontend/src-tauri/
│ ├── index.html, vite.config.ts, vitest.config.ts, playwright.config.ts
  │   ├── tsconfig*.json, tailwind.config.js, postcss.config.js
  │   ├── scripts/
  │   ├── .env, .env.desktop, .env.saas
  │   └── DeveloperGuide.md
├── package.json, package-lock.json, node_modules/ ← workspace install
  ├── eslint.config.mjs, .prettierrc, .prettierignore ← shared tooling
  ├── .gitignore
  └── README.md

  ### Wiring edits (40 files)

  - `.taskfiles/frontend.yml`, `desktop.yml`, `e2e.yml`
  - `build.gradle`, `app/core/build.gradle`
- `eslint.config.mjs`, `frontend/package.json`, `.gitignore`,
`.prettierignore`
  - `docker/frontend/Dockerfile`
  - 8 `.github/workflows/*.yml`, plus `.github/dependabot.yml`,
    `.github/config/.files.yaml`, `.github/labeler-config-srvaroa.yml`
  - `scripts/translations/**`
- Docs: `AGENTS.md`, `CLAUDE.md`, `ADDING_TOOLS.md`,
`DeveloperGuide.md`,
`WINDOWS_SIGNING.md`, `devGuide/HowToAddNewLanguage.md`,
`frontend/README.md`,
    `frontend/editor/DeveloperGuide.md`

Plus 3 renamed + edited: `editor/vite.config.ts` (env path +
node_modules
  walk-up), `editor/scripts/setup-env.mts` (renamed from `.ts` for
`import.meta.url`), `editor/scripts/build-provisioner.mjs` (resolve
src-tauri
  relative to script).

  ### Verification

  | Check | Result |
  |---|---|
  | `task frontend:typecheck:all` (6 variants) | exit 0 |
  | `task frontend:lint` (eslint + dpdm) | exit 0 |
  | `task frontend:format:check` | exit 0 |
  | `task frontend:test` | 657 tests pass, 50 files |
| `task frontend:build:{core,proprietary,saas,desktop,prototypes}` | all
green |
| `task desktop:build` | full Tauri pipeline →
`Stirling-PDF_2.11.0_x64_en-US.msi` |
  | `playwright test --list --project=stubbed` | 172 tests discovered |

`task desktop:build` exercises the heaviest path — Rust + WiX + MSI
bundle
against the moved `editor/src-tauri/`. If anything in the restructure
was
  wrong it wouldn't have built.

  ### Test plan

  - [ ] `frontend-validation.yml` green
  - [ ] `e2e-stubbed.yml` green
  - [ ] `tauri-build.yml` green on at least one platform
  - [ ] `check_toml.yml` runs on a translation-touching PR

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Reece Browne
2026-05-22 13:40:34 +01:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 48027ee9d6
commit 0a50e765b7
1820 changed files with 300 additions and 226 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,713 @@
/**
* Integration tests for Convert Tool Smart Detection with real file scenarios
* Tests the complete flow from file upload through auto-detection to API calls
*/
import React from "react";
import {
describe,
test,
expect,
vi,
beforeEach,
afterEach,
Mock,
} from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useConvertOperation } from "@app/hooks/tools/convert/useConvertOperation";
import { useConvertParameters } from "@app/hooks/tools/convert/useConvertParameters";
import { FileContextProvider } from "@app/contexts/FileContext";
import { NavigationProvider } from "@app/contexts/NavigationContext";
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import { I18nextProvider } from "react-i18next";
import i18n from "@app/i18n/config";
import { detectFileExtension } from "@app/utils/fileUtils";
import { FIT_OPTIONS } from "@app/constants/convertConstants";
import {
createTestStirlingFile,
createTestFilesWithId,
} from "@app/tests/utils/testFileHelpers";
import { MantineProvider } from "@mantine/core";
// Mock axios (for static methods like CancelToken, isCancel)
vi.mock("axios", () => ({
default: {
CancelToken: {
source: vi.fn(() => ({
token: "mock-cancel-token",
cancel: vi.fn(),
})),
},
isCancel: vi.fn(() => false),
},
}));
// Mock our apiClient service
vi.mock("../../services/apiClient", () => ({
default: {
post: vi.fn(),
get: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
interceptors: {
response: {
use: vi.fn(),
},
},
},
}));
// Import the mocked apiClient
import apiClient from "@app/services/apiClient";
const mockedApiClient = vi.mocked(apiClient);
// Mock only essential services that are actually called by the tests
vi.mock("../../services/fileStorage", () => ({
fileStorage: {
init: vi.fn().mockResolvedValue(undefined),
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
return Promise.resolve({
id: `mock-id-${file.name}`,
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified,
thumbnail: thumbnail,
});
}),
getAllFileMetadata: vi.fn().mockResolvedValue([]),
cleanup: vi.fn().mockResolvedValue(undefined),
},
}));
vi.mock("../../services/thumbnailGenerationService", () => ({
thumbnailGenerationService: {
generateThumbnail: vi
.fn()
.mockResolvedValue("data:image/png;base64,fake-thumbnail"),
cleanup: vi.fn(),
destroy: vi.fn(),
},
}));
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<MantineProvider>
<I18nextProvider i18n={i18n}>
<PreferencesProvider>
<ToolRegistryProvider>
<NavigationProvider>
<FileContextProvider>{children}</FileContextProvider>
</NavigationProvider>
</ToolRegistryProvider>
</PreferencesProvider>
</I18nextProvider>
</MantineProvider>
);
describe("Convert Tool - Smart Detection Integration Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock successful API response
(mockedApiClient.post as Mock).mockResolvedValue({
data: new Blob(["fake converted content"], { type: "application/pdf" }),
});
});
afterEach(() => {
// Clean up any blob URLs created during tests
vi.restoreAllMocks();
});
describe("Single File Auto-Detection Flow", () => {
test("should auto-detect PDF from DOCX and convert to PDF", async () => {
const { result: paramsResult } = renderHook(
() => useConvertParameters(),
{
wrapper: TestWrapper,
},
);
const { result: operationResult } = renderHook(
() => useConvertOperation(),
{
wrapper: TestWrapper,
},
);
// Create mock DOCX file
const docxFile = createTestStirlingFile(
"document.docx",
"docx content",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
);
// Test auto-detection
act(() => {
paramsResult.current.analyzeFileTypes([docxFile]);
});
await waitFor(() => {
expect(paramsResult.current.parameters.fromExtension).toBe("docx");
expect(paramsResult.current.parameters.toExtension).toBe("pdf");
expect(paramsResult.current.parameters.isSmartDetection).toBe(false);
});
// Test conversion operation
await act(async () => {
await operationResult.current.executeOperation(
paramsResult.current.parameters,
[docxFile],
);
});
expect(mockedApiClient.post).toHaveBeenCalledWith(
"/api/v1/convert/file/pdf",
expect.any(FormData),
{
responseType: "blob",
},
);
});
test("should handle unknown file type with file-to-pdf fallback", async () => {
const { result: paramsResult } = renderHook(
() => useConvertParameters(),
{
wrapper: TestWrapper,
},
);
const { result: operationResult } = renderHook(
() => useConvertOperation(),
{
wrapper: TestWrapper,
},
);
// Create mock unknown file
const unknownFile = createTestStirlingFile(
"document.xyz",
"unknown content",
"application/octet-stream",
);
// Test auto-detection
act(() => {
paramsResult.current.analyzeFileTypes([unknownFile]);
});
await waitFor(() => {
expect(paramsResult.current.parameters.fromExtension).toBe("file-xyz");
expect(paramsResult.current.parameters.toExtension).toBe("pdf"); // Fallback
expect(paramsResult.current.parameters.isSmartDetection).toBe(false);
});
// Test conversion operation
await act(async () => {
await operationResult.current.executeOperation(
paramsResult.current.parameters,
[unknownFile],
);
});
expect(mockedApiClient.post).toHaveBeenCalledWith(
"/api/v1/convert/file/pdf",
expect.any(FormData),
{
responseType: "blob",
},
);
});
});
describe("Multi-File Smart Detection Flow", () => {
test("should detect all images and use img-to-pdf endpoint", async () => {
const { result: paramsResult } = renderHook(
() => useConvertParameters(),
{
wrapper: TestWrapper,
},
);
const { result: operationResult } = renderHook(
() => useConvertOperation(),
{
wrapper: TestWrapper,
},
);
// Create mock image files
const imageFiles = createTestFilesWithId([
{ name: "photo1.jpg", content: "jpg content", type: "image/jpeg" },
{ name: "photo2.png", content: "png content", type: "image/png" },
{ name: "photo3.gif", content: "gif content", type: "image/gif" },
]);
// Test smart detection for all images
act(() => {
paramsResult.current.analyzeFileTypes(imageFiles);
});
await waitFor(() => {
expect(paramsResult.current.parameters.fromExtension).toBe("image");
expect(paramsResult.current.parameters.toExtension).toBe("pdf");
expect(paramsResult.current.parameters.isSmartDetection).toBe(true);
expect(paramsResult.current.parameters.smartDetectionType).toBe(
"images",
);
});
// Test conversion operation
await act(async () => {
await operationResult.current.executeOperation(
paramsResult.current.parameters,
imageFiles,
);
});
expect(mockedApiClient.post).toHaveBeenCalledWith(
"/api/v1/convert/img/pdf",
expect.any(FormData),
{
responseType: "blob",
},
);
// Should send all files in single request
const formData = (mockedApiClient.post as Mock).mock
.calls[0][1] as FormData;
const files = formData.getAll("fileInput");
expect(files).toHaveLength(3);
});
test("should detect mixed file types and use file-to-pdf endpoint", async () => {
const { result: paramsResult } = renderHook(
() => useConvertParameters(),
{
wrapper: TestWrapper,
},
);
const { result: operationResult } = renderHook(
() => useConvertOperation(),
{
wrapper: TestWrapper,
},
);
// Create mixed file types
const mixedFiles = createTestFilesWithId([
{
name: "document.pdf",
content: "pdf content",
type: "application/pdf",
},
{
name: "spreadsheet.xlsx",
content: "docx content",
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
},
{
name: "presentation.pptx",
content: "pptx content",
type: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
},
]);
// Test smart detection for mixed types
act(() => {
paramsResult.current.analyzeFileTypes(mixedFiles);
});
await waitFor(() => {
expect(paramsResult.current.parameters.fromExtension).toBe("any");
expect(paramsResult.current.parameters.toExtension).toBe("pdf");
expect(paramsResult.current.parameters.isSmartDetection).toBe(true);
expect(paramsResult.current.parameters.smartDetectionType).toBe(
"mixed",
);
});
// Test conversion operation
await act(async () => {
await operationResult.current.executeOperation(
paramsResult.current.parameters,
mixedFiles,
);
});
expect(mockedApiClient.post).toHaveBeenCalledWith(
"/api/v1/convert/file/pdf",
expect.any(FormData),
{
responseType: "blob",
},
);
});
test("should detect all web files and use html-to-pdf endpoint", async () => {
const { result: paramsResult } = renderHook(
() => useConvertParameters(),
{
wrapper: TestWrapper,
},
);
const { result: operationResult } = renderHook(
() => useConvertOperation(),
{
wrapper: TestWrapper,
},
);
// Create mock web files
const webFiles = createTestFilesWithId([
{
name: "page1.html",
content: "<html>content</html>",
type: "text/html",
},
{ name: "site.zip", content: "zip content", type: "application/zip" },
]);
// Test smart detection for web files
act(() => {
paramsResult.current.analyzeFileTypes(webFiles);
});
await waitFor(() => {
expect(paramsResult.current.parameters.fromExtension).toBe("html");
expect(paramsResult.current.parameters.toExtension).toBe("pdf");
expect(paramsResult.current.parameters.isSmartDetection).toBe(true);
expect(paramsResult.current.parameters.smartDetectionType).toBe("web");
});
// Test conversion operation
await act(async () => {
await operationResult.current.executeOperation(
paramsResult.current.parameters,
webFiles,
);
});
expect(mockedApiClient.post).toHaveBeenCalledWith(
"/api/v1/convert/html/pdf",
expect.any(FormData),
{
responseType: "blob",
},
);
// Should process files separately for web files
expect(mockedApiClient.post).toHaveBeenCalledTimes(2);
});
});
describe("Web and Email Conversion Options Integration", () => {
test("should send correct HTML parameters for web-to-pdf conversion", async () => {
const { result: paramsResult } = renderHook(
() => useConvertParameters(),
{
wrapper: TestWrapper,
},
);
const { result: operationResult } = renderHook(
() => useConvertOperation(),
{
wrapper: TestWrapper,
},
);
const htmlFile = createTestStirlingFile(
"page.html",
"<html>content</html>",
"text/html",
);
// Set up HTML conversion parameters
act(() => {
paramsResult.current.analyzeFileTypes([htmlFile]);
paramsResult.current.updateParameter("htmlOptions", {
zoomLevel: 1.5,
});
});
await act(async () => {
await operationResult.current.executeOperation(
paramsResult.current.parameters,
[htmlFile],
);
});
const formData = (mockedApiClient.post as Mock).mock
.calls[0][1] as FormData;
expect(formData.get("zoom")).toBe("1.5");
});
test("should send correct email parameters for eml-to-pdf conversion", async () => {
const { result: paramsResult } = renderHook(
() => useConvertParameters(),
{
wrapper: TestWrapper,
},
);
const { result: operationResult } = renderHook(
() => useConvertOperation(),
{
wrapper: TestWrapper,
},
);
const emlFile = createTestStirlingFile(
"email.eml",
"email content",
"message/rfc822",
);
// Set up email conversion parameters
act(() => {
paramsResult.current.updateParameter("fromExtension", "eml");
paramsResult.current.updateParameter("toExtension", "pdf");
paramsResult.current.updateParameter("emailOptions", {
includeAttachments: false,
maxAttachmentSizeMB: 20,
downloadHtml: true,
includeAllRecipients: true,
});
});
await act(async () => {
await operationResult.current.executeOperation(
paramsResult.current.parameters,
[emlFile],
);
});
const formData = (mockedApiClient.post as Mock).mock
.calls[0][1] as FormData;
expect(formData.get("includeAttachments")).toBe("false");
expect(formData.get("maxAttachmentSizeMB")).toBe("20");
expect(formData.get("downloadHtml")).toBe("true");
expect(formData.get("includeAllRecipients")).toBe("true");
});
test("should send correct PDF/A parameters for pdf-to-pdfa conversion", async () => {
const { result: paramsResult } = renderHook(
() => useConvertParameters(),
{
wrapper: TestWrapper,
},
);
const { result: operationResult } = renderHook(
() => useConvertOperation(),
{
wrapper: TestWrapper,
},
);
const pdfFile = createTestStirlingFile(
"document.pdf",
"pdf content",
"application/pdf",
);
// Set up PDF/A conversion parameters
act(() => {
paramsResult.current.updateParameter("fromExtension", "pdf");
paramsResult.current.updateParameter("toExtension", "pdfa");
paramsResult.current.updateParameter("pdfaOptions", {
outputFormat: "pdfa",
strict: false,
});
});
await act(async () => {
await operationResult.current.executeOperation(
paramsResult.current.parameters,
[pdfFile],
);
});
const formData = (mockedApiClient.post as Mock).mock
.calls[0][1] as FormData;
expect(formData.get("outputFormat")).toBe("pdfa");
expect(formData.get("strict")).toBe("false");
expect(mockedApiClient.post).toHaveBeenCalledWith(
"/api/v1/convert/pdf/pdfa",
expect.any(FormData),
{
responseType: "blob",
},
);
});
});
describe("Image Conversion Options Integration", () => {
test("should send correct parameters for image-to-pdf conversion", async () => {
const { result: paramsResult } = renderHook(
() => useConvertParameters(),
{
wrapper: TestWrapper,
},
);
const { result: operationResult } = renderHook(
() => useConvertOperation(),
{
wrapper: TestWrapper,
},
);
const imageFiles = createTestFilesWithId([
{ name: "photo1.jpg", content: "jpg1", type: "image/jpeg" },
{ name: "photo2.jpg", content: "jpg2", type: "image/jpeg" },
]);
// Set up image conversion parameters
act(() => {
paramsResult.current.analyzeFileTypes(imageFiles);
paramsResult.current.updateParameter("imageOptions", {
colorType: "grayscale",
dpi: 150,
singleOrMultiple: "single",
fitOption: FIT_OPTIONS.FIT_PAGE,
autoRotate: false,
combineImages: true,
});
});
await act(async () => {
await operationResult.current.executeOperation(
paramsResult.current.parameters,
imageFiles,
);
});
const formData = (mockedApiClient.post as Mock).mock
.calls[0][1] as FormData;
expect(formData.get("fitOption")).toBe(FIT_OPTIONS.FIT_PAGE);
expect(formData.get("colorType")).toBe("grayscale");
expect(formData.get("autoRotate")).toBe("false");
});
test("should process images separately when combineImages is false", async () => {
const { result: paramsResult } = renderHook(
() => useConvertParameters(),
{
wrapper: TestWrapper,
},
);
const { result: operationResult } = renderHook(
() => useConvertOperation(),
{
wrapper: TestWrapper,
},
);
const imageFiles = createTestFilesWithId([
{ name: "photo1.jpg", content: "jpg1", type: "image/jpeg" },
{ name: "photo2.jpg", content: "jpg2", type: "image/jpeg" },
]);
// Set up for separate processing
act(() => {
paramsResult.current.analyzeFileTypes(imageFiles);
paramsResult.current.updateParameter("imageOptions", {
...paramsResult.current.parameters.imageOptions,
combineImages: false,
});
});
await act(async () => {
await operationResult.current.executeOperation(
paramsResult.current.parameters,
imageFiles,
);
});
// Should make separate API calls for each file
expect(mockedApiClient.post).toHaveBeenCalledTimes(2);
});
});
describe("Error Scenarios in Smart Detection", () => {
test("should handle partial failures in multi-file processing", async () => {
const { result: paramsResult } = renderHook(
() => useConvertParameters(),
{
wrapper: TestWrapper,
},
);
const { result: operationResult } = renderHook(
() => useConvertOperation(),
{
wrapper: TestWrapper,
},
);
// Mock one success, one failure
(mockedApiClient.post as Mock)
.mockResolvedValueOnce({
data: new Blob(["converted1"], { type: "application/pdf" }),
})
.mockRejectedValueOnce(new Error("File 2 failed"));
const mixedFiles = createTestFilesWithId([
{ name: "doc1.txt", content: "file1", type: "text/plain" },
{
name: "doc2.xyz",
content: "file2",
type: "application/octet-stream",
},
]);
// Set up for separate processing (mixed smart detection)
act(() => {
paramsResult.current.analyzeFileTypes(mixedFiles);
});
await act(async () => {
await operationResult.current.executeOperation(
paramsResult.current.parameters,
mixedFiles,
);
});
await waitFor(() => {
// Should have processed at least one file successfully
expect(operationResult.current.files.length).toBeGreaterThan(0);
expect(mockedApiClient.post).toHaveBeenCalledTimes(2);
});
});
});
describe("Real File Extension Detection", () => {
test("should correctly detect various file extensions", async () => {
renderHook(() => useConvertParameters(), {
wrapper: TestWrapper,
});
const testCases = [
{ filename: "document.PDF", expected: "pdf" },
{ filename: "image.JPEG", expected: "jpg" }, // JPEG should normalize to jpg
{ filename: "photo.jpeg", expected: "jpg" }, // jpeg should normalize to jpg
{ filename: "archive.tar.gz", expected: "gz" },
{ filename: "file.", expected: "" },
{ filename: ".hidden", expected: "hidden" },
{ filename: "noextension", expected: "" },
];
testCases.forEach(({ filename, expected }) => {
const detected = detectFileExtension(filename);
expect(detected).toBe(expected);
});
});
});
});
@@ -0,0 +1,264 @@
# Convert Tool Test Suite
This directory contains comprehensive tests for the Convert Tool functionality.
## Test Files Overview
### 1. ConvertTool.test.tsx
**Purpose**: Unit/Component testing for the Convert Tool UI components
- Tests dropdown behavior and navigation
- Tests format availability based on endpoint status
- Tests UI state management and form validation
- Mocks backend dependencies for isolated testing
**Key Test Areas**:
- FROM dropdown enables/disables formats based on endpoint availability
- TO dropdown shows correct conversions for selected source format
- Format-specific options appear/disappear correctly
- Parameter validation and state management
### 2. ConvertIntegration.test.ts
**Purpose**: Integration testing for Convert Tool business logic
- Tests parameter validation and conversion matrix logic
- Tests endpoint resolution and availability checking
- Tests file extension detection
- Provides framework for testing actual conversions (requires backend)
**Key Test Areas**:
- Endpoint availability checking matches real backend status
- Conversion parameters are correctly validated
- File extension detection works properly
- Conversion matrix returns correct available formats
### 3. ConvertE2E.spec.ts
**Purpose**: End-to-End testing using Playwright with Dynamic Endpoint Discovery
- **Automatically discovers available conversion endpoints** from the backend
- Tests complete user workflows from file upload to download
- Tests actual file conversions with real backend
- **Skips tests for unavailable endpoints** automatically
- Tests error handling and edge cases
- Tests UI/UX flow and user interactions
**Key Test Areas**:
- **Dynamic endpoint discovery** using `/api/v1/config/endpoints-enabled` API
- Complete conversion workflows for **all available endpoints**
- **Unavailable endpoint testing** - verifies disabled conversions are properly blocked
- File upload, conversion, and download process
- Error handling for corrupted files and network issues
- Performance testing with large files
- UI responsiveness and progress indicators
**Supported Conversions** (tested if available):
- PDF ↔ Images (PNG, JPG, GIF, BMP, TIFF, WebP)
- PDF ↔ Office (DOCX, PPTX)
- PDF ↔ Text (TXT, HTML, XML, CSV, Markdown)
- Office → PDF (DOCX, PPTX, XLSX, etc.)
- Email (EML) → PDF
- HTML → PDF, URL → PDF
- Markdown → PDF
## Running the Tests
**Important**: All commands should be run from the `frontend/` directory:
```bash
cd frontend
```
### Setup (First Time Only)
```bash
# Install dependencies (includes test frameworks)
npm install
# Install Playwright browsers for E2E tests
npx playwright install
```
### Unit Tests (ConvertTool.test.tsx)
```bash
# Run all unit tests
npm test
# Run specific test file
npm test ConvertTool.test.tsx
# Run with coverage
npm run test:coverage
# Run in watch mode (re-runs on file changes)
npm run test:watch
# Run specific test pattern
npm test -- --grep "dropdown"
```
### Integration Tests (ConvertIntegration.test.ts)
```bash
# Run integration tests
npm test ConvertIntegration.test.ts
# Run with verbose output
npm test ConvertIntegration.test.ts -- --reporter=verbose
```
### E2E Tests (ConvertE2E.spec.ts)
```bash
# Prerequisites: Backend must be running on localhost:8080
# Start backend first, then:
# Run all E2E tests (automatically discovers available endpoints)
npm run test:e2e
# Run specific E2E test file
npx playwright test ConvertE2E.spec.ts
# Run with UI mode for debugging
npx playwright test --ui
# Run specific test by endpoint name (dynamic)
npx playwright test -g "pdf-to-img:"
# Run only available conversion tests
npx playwright test -g "Dynamic Conversion Tests"
# Run only unavailable conversion tests
npx playwright test -g "Unavailable Conversions"
# Run in headed mode (see browser)
npx playwright test --headed
# Generate HTML report
npx playwright test ConvertE2E.spec.ts --reporter=html
```
**Test Discovery Process:**
1. Tests automatically query `/api/v1/config/endpoints-enabled` to discover available conversions
2. Tests are generated dynamically for each available endpoint
3. Tests for unavailable endpoints verify they're properly disabled in the UI
4. Console output shows which endpoints were discovered
## Test Requirements
### For Unit Tests
- No special requirements
- All dependencies are mocked
- Can run in any environment
### For Integration Tests
- May require backend API for full functionality
- Uses mock data for endpoint availability
- Tests business logic in isolation
### For E2E Tests
- **Requires running backend server** (localhost:8080)
- **Requires test fixture files** (see ../test-fixtures/README.md)
- Requires frontend dev server (localhost:5173)
- Tests real conversion functionality
## Test Data
The tests use realistic endpoint availability data based on your current server configuration:
**Available Endpoints** (should pass):
- `file-to-pdf`: true (DOCX, XLSX, PPTX → PDF)
- `img-to-pdf`: true (PNG, JPG, etc. → PDF)
- `markdown-to-pdf`: true (MD → PDF)
- `pdf-to-csv`: true (PDF → CSV)
- `pdf-to-img`: true (PDF → PNG, JPG, etc.)
- `pdf-to-text`: true (PDF → TXT)
**Disabled Endpoints** (should be blocked):
- `eml-to-pdf`: false
- `html-to-pdf`: false
- `pdf-to-html`: false
- `pdf-to-markdown`: false
- `pdf-to-pdfa`: false
- `pdf-to-presentation`: false
- `pdf-to-word`: false
- `pdf-to-xml`: false
## Test Scenarios
### Success Scenarios (Available Endpoints)
1. **PDF → Image**: PDF to PNG/JPG with various DPI and color settings
2. **PDF → Data**: PDF to CSV (table extraction), PDF to TXT (text extraction)
3. **Office → PDF**: DOCX/XLSX/PPTX to PDF conversion
4. **Image → PDF**: PNG/JPG to PDF with image options
5. **Markdown → PDF**: MD to PDF with formatting preservation
### Blocked Scenarios (Disabled Endpoints)
1. **EML conversions**: Should be disabled in FROM dropdown
2. **PDF → Office**: PDF to Word/PowerPoint should be disabled
3. **PDF → Web**: PDF to HTML/XML should be disabled
4. **PDF → PDF/A**: Should be disabled
### Error Scenarios
1. **Corrupted files**: Should show helpful error messages
2. **Network failures**: Should handle backend unavailability
3. **Large files**: Should handle memory constraints gracefully
4. **Invalid parameters**: Should validate before submission
## Adding New Tests
When adding new conversion formats:
1. **Update ConvertTool.test.tsx**:
- Add the new format to test data
- Test dropdown behavior for the new format
- Test format-specific options if any
2. **Update ConvertIntegration.test.ts**:
- Add endpoint availability test cases
- Add conversion matrix test cases
- Add parameter validation tests
3. **Update ConvertE2E.spec.ts**:
- Add end-to-end workflow tests
- Add test fixture files
- Test actual conversion functionality
4. **Update test fixtures**:
- Add sample files for the new format
- Update ../test-fixtures/README.md
## Debugging Failed Tests
### Unit Test Failures
- Check mock data matches real endpoint status
- Verify component props and state management
- Check for React hook dependency issues
### Integration Test Failures
- Verify conversion matrix includes new formats
- Check endpoint name mappings
- Ensure parameter validation logic is correct
### E2E Test Failures
- Ensure backend server is running
- Check test fixture files exist and are valid
- Verify element selectors match current UI
- Check for timing issues (increase timeouts if needed)
## Test Maintenance
### Regular Updates Needed
1. **Endpoint Status**: Update mock data when backend endpoints change
2. **UI Selectors**: Update test selectors when UI changes
3. **Test Fixtures**: Replace old test files with new ones periodically
4. **Performance Benchmarks**: Update expected performance metrics
### CI/CD Integration
- Unit tests: Run on every commit
- Integration tests: Run on pull requests
- E2E tests: Run on staging deployment
- Performance tests: Run weekly or on major releases
## Performance Expectations
These tests focus on frontend functionality, not backend performance:
- **File upload/UI**: < 1 second for small test files
- **Dropdown interactions**: < 200ms
- **Form validation**: < 100ms
- **Conversion UI flow**: < 5 seconds for small test files
Tests will fail if UI interactions are slow, indicating frontend performance issues.
@@ -0,0 +1,164 @@
import { test, expect } from "@playwright/test";
import { ensureCookieConsent } from "@app/tests/helpers/login";
import { bypassOnboarding } from "@app/tests/helpers/api-stubs";
import { openSettings } from "@app/tests/helpers/ui-helpers";
/**
* License-gated feature surface validation. Drives the actual UI rather
* than poking endpoints — every assertion is on what the user sees in
* the admin settings + tool surfaces. Requires a backend booted with a
* real `PREMIUM_KEY` (premium.enabled=true), `-PbuildWithFrontend=true`
* so the SPA is served from :8080, and the live-suite admin user
* (admin/adminadmin) provisioned.
*/
const ADMIN = "admin";
const PASSWORD = "adminadmin";
async function uiLogin(page: import("@playwright/test").Page) {
await bypassOnboarding(page);
await ensureCookieConsent(page);
await page.goto("/login", { waitUntil: "domcontentloaded" });
await page.locator("#email").fill(ADMIN);
await page.locator("#password").fill(PASSWORD);
await page.locator('button[type="submit"]').click();
await page.waitForURL("/", { timeout: 15_000 });
await expect(
page.locator('[data-testid="config-button"]').first(),
).toBeVisible({ timeout: 15_000 });
}
test.describe("Enterprise license — admin settings UI", () => {
test("Account settings shows the admin username", async ({ page }) => {
test.setTimeout(60_000);
await uiLogin(page);
await openSettings(page);
await page
.getByText(/account settings/i)
.first()
.click();
await expect(page.getByText(/admin/i).first()).toBeVisible({
timeout: 10_000,
});
});
test("License / premium section reports a valid key (no invalid/expired warnings)", async ({
page,
}) => {
test.setTimeout(60_000);
await uiLogin(page);
await openSettings(page);
// Open the license/premium section if present in the side nav
const licenseNav = page.getByText(/license|premium|subscription/i).first();
if (await licenseNav.isVisible({ timeout: 5_000 }).catch(() => false)) {
await licenseNav.click();
await page.waitForTimeout(500);
}
// No "invalid"/"expired"/"key required" warnings should render
// anywhere in the dialog.
await expect(
page.getByText(/invalid license|expired|trial.*expired|key required/i),
).toHaveCount(0);
});
test("Audit log section is reachable from settings", async ({ page }) => {
test.setTimeout(60_000);
await uiLogin(page);
await openSettings(page);
const auditNav = page.getByText(/^audit/i).first();
if (!(await auditNav.isVisible({ timeout: 5_000 }).catch(() => false))) {
test.skip(true, "Audit section not available on this build");
return;
}
await auditNav.click();
await page.waitForTimeout(500);
// Audit dashboard renders some data surface in the DOM (table, list,
// chart). Some builds tab the dashboard behind a sub-section so we
// assert attachment rather than visibility.
const surface = page
.locator('[data-testid*="audit" i], table, [class*="AuditDashboard" i]')
.first();
await expect(surface).toBeAttached({ timeout: 10_000 });
});
test("Teams section renders and exposes a create-team affordance", async ({
page,
}) => {
test.setTimeout(60_000);
await uiLogin(page);
await openSettings(page);
const teamsNav = page.getByText(/^teams/i).first();
if (!(await teamsNav.isVisible({ timeout: 5_000 }).catch(() => false))) {
test.skip(true, "Teams section not available on this build");
return;
}
await teamsNav.click();
await page.waitForTimeout(500);
await expect(
page
.getByRole("button", { name: /create team|new team|add team/i })
.first(),
).toBeVisible({ timeout: 10_000 });
});
test("Users / Workspace member list renders", async ({ page }) => {
test.setTimeout(60_000);
await uiLogin(page);
await openSettings(page);
const usersNav = page.getByText(/^users|^members/i).first();
if (!(await usersNav.isVisible({ timeout: 5_000 }).catch(() => false))) {
test.skip(true, "Users / members section not available on this build");
return;
}
await usersNav.click();
await page.waitForTimeout(500);
// The current admin user should appear in the list
await expect(page.getByText(/^admin$/i).first()).toBeVisible({
timeout: 10_000,
});
});
test("Analytics / usage statistics dashboard is reachable", async ({
page,
}) => {
test.setTimeout(60_000);
await uiLogin(page);
await openSettings(page);
const usageNav = page.getByText(/usage|analytics|statistics/i).first();
if (!(await usageNav.isVisible({ timeout: 5_000 }).catch(() => false))) {
test.skip(true, "Usage/analytics section not on this build");
return;
}
await usageNav.click();
await page.waitForTimeout(500);
// The dashboard renders some surface — table, chart, canvas, or a
// "no data" empty state. Either is acceptable for "section is
// reachable on a premium-enabled build".
const surface = page.locator(
'[data-testid*="usage" i], canvas, table, [class*="chart" i]',
);
const empty = page.getByText(/no data|no events|nothing here/i).first();
const surfaceCount = await surface.count();
const hasEmpty = await empty
.isVisible({ timeout: 1_000 })
.catch(() => false);
if (surfaceCount === 0 && !hasEmpty) {
test.info().annotations.push({
type: "feature-surface",
description:
"Analytics dashboard rendered no data surface and no empty state",
});
}
});
});
@@ -0,0 +1,69 @@
import { test, expect } from "@playwright/test";
import { ensureCookieConsent } from "@app/tests/helpers/login";
import { bypassOnboarding } from "@app/tests/helpers/api-stubs";
/**
* OAuth login round-trip via Keycloak.
*
* Requires the docker-compose-keycloak-oauth stack to be running:
* - Keycloak on http://localhost:9080 with realm `stirling-oauth`
* - Stirling-PDF on http://localhost:8080 with PREMIUM_KEY set
*
* Validates:
* 1. SSO redirect → IdP form → callback → dashboard rendering.
* 2. The authenticated user identity surfaces in the settings panel
* (matches the Keycloak account, not just "someone logged in").
*
* Real tool round-trips after login are covered by
* live/e2e-pdf-operations.spec.ts; we don't duplicate that here because
* the post-OAuth-callback navigation has timing quirks that produce flake
* but aren't actually testing the SSO contract.
*
* Test user: [email protected] / oauthpassword (per
* testing/compose/keycloak-realm-oauth.json).
*/
test.describe("Enterprise OAuth (Keycloak) — full SSO flow", () => {
test.beforeEach(async ({ page }) => {
await bypassOnboarding(page);
await ensureCookieConsent(page);
});
test("SSO redirect, identity in settings, real merge tool run", async ({
page,
}) => {
test.setTimeout(120_000);
// ── 1. SSO redirect chain ────────────────────────────────
await page.goto("/login", { waitUntil: "domcontentloaded" });
const keycloakBtn = page
.locator('a[href*="oauth2/authorization/keycloak"]')
.or(page.getByRole("button", { name: /keycloak|continue with/i }))
.first();
await expect(keycloakBtn).toBeVisible({ timeout: 10_000 });
await keycloakBtn.click();
await page.waitForURL(/\/realms\/stirling-oauth\/protocol\/openid-connect/);
await page.locator("#username").fill("[email protected]");
await page.locator("#password").fill("oauthpassword");
await page.locator('input[type="submit"], button[type="submit"]').click();
// Back on Stirling-PDF, authenticated dashboard renders
await page.waitForURL((url) => !url.pathname.includes("/login"), {
timeout: 30_000,
});
await expect(
page.locator('[data-testid="config-button"]').first(),
).toBeVisible({ timeout: 15_000 });
// ── 2. Identity surfaced in settings → Account ────────────
await page.locator('[data-testid="config-button"]').first().click();
await page
.getByText(/account settings/i)
.first()
.click();
await expect(page.getByText(/oauthuser/i).first()).toBeVisible({
timeout: 10_000,
});
});
});
@@ -0,0 +1,63 @@
import { test, expect } from "@playwright/test";
import { ensureCookieConsent } from "@app/tests/helpers/login";
import { bypassOnboarding } from "@app/tests/helpers/api-stubs";
/**
* SAML login round-trip via Keycloak.
*
* Requires the docker-compose-keycloak-saml stack:
* - Keycloak on http://localhost:9080 with realm `stirling-saml`
* - Stirling-PDF on http://localhost:8080 with PREMIUM_KEY set and
* security.saml2.enabled=true
*
* Validates:
* 1. SAML redirect chain → IdP form → SP callback → dashboard.
* 2. Identity surfaces in settings panel.
* 3. A real tool run completes after SAML login.
*/
test.describe("Enterprise SAML (Keycloak) — full SSO flow", () => {
test.beforeEach(async ({ page }) => {
await bypassOnboarding(page);
await ensureCookieConsent(page);
});
test("SAML redirect, identity in settings, real split tool run", async ({
page,
}) => {
test.setTimeout(120_000);
// ── 1. SAML redirect chain ────────────────────────────────
await page.goto("/login", { waitUntil: "domcontentloaded" });
const samlBtn = page
.locator('a[href*="saml"], a[href*="saml2"]')
.or(page.getByRole("button", { name: /saml|authentik|keycloak/i }))
.first();
await expect(samlBtn).toBeVisible({ timeout: 10_000 });
await samlBtn.click();
await page.waitForURL(/\/realms\/stirling-saml\//, {
timeout: 30_000,
});
await page.locator("#username").fill("samluser");
await page.locator("#password").fill("samlpassword");
await page.locator('input[type="submit"], button[type="submit"]').click();
await page.waitForURL((url) => !url.pathname.includes("/login"), {
timeout: 30_000,
});
await expect(
page.locator('[data-testid="config-button"]').first(),
).toBeVisible({ timeout: 15_000 });
// ── 2. Identity in settings → Account ────────────────────
await page.locator('[data-testid="config-button"]').first().click();
await page
.getByText(/account settings/i)
.first()
.click();
await expect(page.getByText(/samluser/i).first()).toBeVisible({
timeout: 10_000,
});
});
});
@@ -0,0 +1,315 @@
import type { Page, Route } from "@playwright/test";
/**
* Shared Playwright API stubs for Stirling-PDF E2E tests.
*
* Import `mockAppApis(page)` from this module in any spec that doesn't need
* a real backend. The helper installs `page.route()` handlers for the
* endpoints the React app hits during bootstrap so the UI renders without
* `ECONNREFUSED` proxy errors from the Vite dev server.
*
* Specs that mock tool-specific endpoints (e.g. `/api/v1/convert/pdf/img`)
* should call `mockAppApis` first, then register their own narrower routes
* before navigation. Playwright uses last-registered-wins for overlapping
* patterns.
*/
/**
* URL-path slugs for backend endpoints under `/api/v1/`. Used only to seed
* the stub responses for `endpoints-availability` and `endpoints-enabled`
* so the React app sees a populated map at startup.
*
* NOTE: this is *not* the frontend tool-registry IDs and several entries
* here have already drifted from the real registry endpoints (e.g. `merge`
* vs `merge-pdfs`, `compress` vs `compress-pdf`, `ocr` vs `ocr-pdf`).
* Tests still pass because the frontend's `useEndpointConfig` defaults
* absent keys to `enabled: true`, so a wrong key is functionally the same
* as a missing key — every endpoint reports enabled either way.
*
* TODO: derive this from `getAllApplicationEndpoints(registry, …)` instead
* of hand-maintaining it. That requires extracting endpoint metadata out of
* `useTranslatedToolRegistry` (currently a React hook with deep i18n + tool
* component imports — can't be called from Node-side Playwright setup) into
* a pure-data module both the hook and this helper can import.
*/
const ALL_BACKEND_ENDPOINTS = [
"pdf-to-img",
"img-to-pdf",
"pdf-to-word",
"file-to-pdf",
"pdf-to-text",
"pdf-to-html",
"pdf-to-xml",
"pdf-to-csv",
"pdf-to-xlsx",
"pdf-to-pdfa",
"pdf-to-pdfx",
"pdf-to-presentation",
"pdf-to-markdown",
"pdf-to-cbz",
"pdf-to-cbr",
"pdf-to-epub",
"html-to-pdf",
"svg-to-pdf",
"markdown-to-pdf",
"eml-to-pdf",
"cbz-to-pdf",
"cbr-to-pdf",
"add-password",
"remove-password",
"change-permissions",
"watermark",
"sanitize",
"split",
"merge",
"convert",
"ocr",
"add-image",
"rotate",
"annotate",
"scanner-image-split",
"edit-table-of-contents",
"scanner-effect",
"auto-rename",
"page-layout",
"scale-pages",
"adjust-contrast",
"crop",
"pdf-to-single-page",
"repair",
"compare",
"add-page-numbers",
"redact",
"flatten",
"remove-cert-sign",
"unlock-pdf-forms",
"compress",
"sign",
"cert-sign",
"add-text",
"remove-pages",
"remove-blanks",
"remove-annotations",
"remove-image",
"extract-pages",
"reorganize-pages",
"extract-images",
"add-stamp",
"add-attachments",
"change-metadata",
"overlay-pdfs",
"get-pdf-info",
"validate-signature",
"timestamp-pdf",
"replace-color",
"show-j-s",
"booklet-imposition",
"pdf-text-editor",
"form-fill",
"multi-tool",
"read",
"automate",
];
const DEFAULT_ENDPOINTS_AVAILABILITY = Object.fromEntries(
ALL_BACKEND_ENDPOINTS.map((k) => [k, { enabled: true }]),
);
export interface MockAppApiOptions {
/** Override `enableLogin`. Default `false` — app loads in anonymous mode. */
enableLogin?: boolean;
/** Override `isAdmin` in app-config. Default `false`. */
isAdmin?: boolean;
/** Override the logged-in user returned by `/auth/me`. */
user?: {
id?: number;
username?: string;
email?: string;
roles?: string[];
};
/** Languages advertised by `/config/app-config`. */
languages?: string[];
/** Default locale. */
defaultLocale?: string;
/** Merge overrides into the endpoint availability map. */
endpointsAvailability?: Record<string, { enabled: boolean }>;
/** Backend probe status. Set to `"DOWN"` to exercise offline-mode UI. */
backendStatus?: "UP" | "DOWN";
}
/**
* Register stub routes for the endpoints the app calls during bootstrap.
* Call this inside `test.beforeEach` before any `page.goto(...)`.
*/
export async function mockAppApis(
page: Page,
opts: MockAppApiOptions = {},
): Promise<void> {
const {
enableLogin = false,
isAdmin = false,
user = {
id: 1,
username: "testuser",
email: "[email protected]",
roles: ["ROLE_USER"],
},
languages = ["en-GB"],
defaultLocale = "en-GB",
endpointsAvailability = {},
backendStatus = "UP",
} = opts;
// Backend liveness probe — determines whether the UI shows the app or an offline screen
await page.route("**/api/v1/info/status", (route: Route) =>
route.fulfill({ json: { status: backendStatus } }),
);
// App config — drives the login flow, language list, and feature flags the UI reads at startup
await page.route("**/api/v1/config/app-config", (route: Route) =>
route.fulfill({
json: {
enableLogin,
isAdmin,
languages,
defaultLocale,
},
}),
);
await page.route("**/api/v1/config/public-config", (route: Route) =>
route.fulfill({ json: { enableLogin, languages, defaultLocale } }),
);
// Current user — anonymous by default, configurable for authenticated flows
await page.route("**/api/v1/auth/me", (route: Route) =>
route.fulfill({ json: user }),
);
// Tool availability — every tool enabled unless overridden
await page.route("**/api/v1/config/endpoints-availability", (route: Route) =>
route.fulfill({
json: { ...DEFAULT_ENDPOINTS_AVAILABILITY, ...endpointsAvailability },
}),
);
await page.route("**/api/v1/config/endpoints-enabled", (route: Route) =>
route.fulfill({
json: { ...DEFAULT_ENDPOINTS_AVAILABILITY, ...endpointsAvailability },
}),
);
// Per-endpoint check hit by tool pages before enabling the run button
await page.route("**/api/v1/config/endpoint-enabled*", (route: Route) =>
route.fulfill({ json: true }),
);
await page.route("**/api/v1/config/group-enabled*", (route: Route) =>
route.fulfill({ json: true }),
);
// Footer / branding — non-critical but proxied, so stub to avoid noise
await page.route("**/api/v1/ui-data/footer-info", (route: Route) =>
route.fulfill({ json: {} }),
);
// Proprietary bucket (login UI, audit, teams, …) — catch-all so the Vite
// proxy doesn't log ECONNREFUSED for every call we haven't individually
// stubbed. Specs can override with a narrower route registered afterwards.
await page.route("**/api/v1/proprietary/ui-data/login", (route: Route) =>
route.fulfill({
json: { enabled: enableLogin, loginMethod: "form" },
}),
);
await page.route("**/api/v1/proprietary/ui-data/account", (route: Route) =>
route.fulfill({ json: user }),
);
await page.route("**/api/v1/proprietary/**", (route: Route) =>
route.fulfill({ json: {} }),
);
// License info — stubbed so admin settings doesn't log proxy errors
await page.route("**/api/v1/admin/license-info", (route: Route) =>
route.fulfill({ json: { licenseType: "FREE", valid: true, maxUsers: 5 } }),
);
// Settings sections touched by the settings page
await page.route("**/api/v1/admin/settings", (route: Route) =>
route.fulfill({ json: {} }),
);
await page.route("**/api/v1/admin/settings/section/**", (route: Route) =>
route.fulfill({ json: {} }),
);
// Info sub-resources
await page.route("**/api/v1/info/wau", (route: Route) =>
route.fulfill({ json: { count: 0 } }),
);
}
/**
* Prevent the onboarding modal from appearing by seeding localStorage
* before the React app boots.
*/
export async function skipOnboarding(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem("onboarding::completed", "true");
localStorage.setItem("onboarding::tours-tooltip-shown", "true");
});
}
/**
* Stronger variant of {@link skipOnboarding}: also sets the session
* `onboarding::bypass-all` flag honoured by `useBypassOnboarding`. This
* suppresses the analytics opt-in modal, MFA setup prompt, and any other
* onboarding step the orchestrator may try to render. Use this in specs
* where SSO callbacks land on a page that would otherwise show overlays
* intercepting clicks.
*/
export async function bypassOnboarding(page: Page): Promise<void> {
await page.addInitScript(() => {
try {
sessionStorage.setItem("onboarding::bypass-all", "true");
localStorage.setItem("onboarding::completed", "true");
localStorage.setItem("onboarding::tours-tooltip-shown", "true");
} catch {
/* sessionStorage may be unavailable in some contexts — ignore */
}
});
}
/**
* Seed the cookie-consent cookie so the banner (#cc-main) never renders.
* The banner overlays the viewport and intercepts clicks on firefox/webkit.
*/
export async function seedCookieConsent(page: Page): Promise<void> {
await page.context().addCookies([
{
name: "cc_cookie",
value: JSON.stringify({
categories: ["necessary"],
revision: 0,
data: null,
rfc_cookie: false,
consentTimestamp: new Date().toISOString(),
consentId: "playwright-test",
}),
domain: "localhost",
path: "/",
},
]);
}
/**
* Close the tour tooltip if it's visible. The tooltip can intercept clicks
* on firefox/webkit even when invisible on chromium.
*/
export async function dismissTourTooltip(page: Page): Promise<void> {
const closeBtn = page.getByRole("button", { name: /close tooltip/i }).first();
if (await closeBtn.isVisible({ timeout: 500 }).catch(() => false)) {
await closeBtn.click();
}
}
@@ -0,0 +1,131 @@
import { Page } from "@playwright/test";
/**
* Ensure the cookie consent banner doesn't appear by setting the consent cookie.
* Call this before navigating or after clearing cookies.
*/
export async function ensureCookieConsent(page: Page): Promise<void> {
await page.context().addCookies([
{
name: "cc_cookie",
value: JSON.stringify({
categories: ["necessary"],
revision: 0,
data: null,
rfc_cookie: false,
}),
domain: "localhost",
path: "/",
},
]);
}
/**
* Mark onboarding as completed in localStorage to prevent the onboarding
* modal from appearing. This is more reliable than trying to click through
* the onboarding slides, which can cause unintended tool selections.
*
* Uses addInitScript so the localStorage is set before the React app reads it.
*/
export async function skipOnboarding(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem("onboarding::completed", "true");
localStorage.setItem("onboarding::tours-tooltip-shown", "true");
});
}
/**
* Shared login helper for Stirling-PDF E2E tests.
* Logs in with the given credentials and waits for the dashboard to load.
*
* Default credentials are `admin / adminadmin` — set by the live-setup
* bootstrap spec, which performs the real first-login password change from
* the backend's default `admin / stirling` (min 8 chars per
* FirstLoginSlide validation).
*/
export const DEFAULT_TEST_USERNAME = "admin";
export const DEFAULT_TEST_PASSWORD = "adminadmin";
export async function login(
page: Page,
username = DEFAULT_TEST_USERNAME,
password = DEFAULT_TEST_PASSWORD,
): Promise<void> {
await ensureCookieConsent(page);
// Skip onboarding before navigating so the modal never appears
await skipOnboarding(page);
await page.goto("/login", { waitUntil: "domcontentloaded" });
// Wait for the login form to render (React SPA may take a moment)
await page.locator("#email").waitFor({ state: "visible", timeout: 15000 });
// Fill in credentials (use input IDs — labels are localized and may not match)
await page.locator("#email").fill(username);
await page.locator("#password").fill(password);
// Click Sign In (the submit button inside the auth form)
await page.locator('button[type="submit"]').click();
// Wait for redirect to home
await page.waitForURL("/", { timeout: 15000 });
}
/**
* Dismiss all startup dialogs (welcome + cookie consent + any others).
* Uses Escape key to close overlays without triggering side effects.
*/
export async function dismissWelcomeDialog(page: Page): Promise<void> {
// Give dialogs time to render
await page.waitForTimeout(1000);
// Try up to 5 times to dismiss all overlays via Escape
for (let i = 0; i < 5; i++) {
const hasOverlay = await page
.locator(".mantine-Modal-overlay, .mantine-Overlay-root")
.first()
.isVisible()
.catch(() => false);
if (!hasOverlay) break;
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
}
}
/**
* Dismiss the cookie consent banner if it appears.
* The banner is rendered inside #cc-main by the CookieConsent library.
*/
export async function dismissCookieConsent(page: Page): Promise<void> {
try {
// Target buttons specifically inside the cookie consent container
const ccMain = page.locator("#cc-main");
const dismissBtn = ccMain
.locator(
'button:has-text("Tidak, terima kasih"), button:has-text("No Thanks"), button:has-text("Oke"), button:has-text("OK")',
)
.first();
if (await dismissBtn.isVisible({ timeout: 2000 })) {
await dismissBtn.click({ force: true });
await page.waitForTimeout(500);
}
} catch {
// No cookie consent banner present
}
}
/**
* Login and dismiss any welcome dialogs.
*/
export async function loginAndSetup(
page: Page,
username = DEFAULT_TEST_USERNAME,
password = DEFAULT_TEST_PASSWORD,
): Promise<void> {
await login(page, username, password);
// Cookie consent may appear on top, dismiss it first
await dismissCookieConsent(page);
await dismissWelcomeDialog(page);
// In case cookie appeared after welcome was dismissed
await dismissCookieConsent(page);
}
@@ -0,0 +1,84 @@
import { test as base, expect } from "@playwright/test";
import {
bypassOnboarding,
mockAppApis,
seedCookieConsent,
skipOnboarding,
type MockAppApiOptions,
} from "@app/tests/helpers/api-stubs";
/**
* Custom Playwright fixture for backend-free specs.
*
* Every test gets a `page` that:
* 1. Has the cookie-consent cookie seeded (banner never renders)
* 2. Has onboarding flags set in localStorage (modal never renders)
* 3. Has all bootstrap API endpoints stubbed via `mockAppApis()`
* 4. Has already navigated to `/` by default (set `autoGoto: false` to skip)
*
* Usage:
* import { test, expect } from "@app/tests/helpers/stub-test-base";
*
* test("something", async ({ page }) => {
* // page is already at `/` with stubs installed
* await page.getByRole("button", { name: "Merge" }).click();
* });
*
* To start somewhere other than `/`, navigate inside the test — Playwright
* replaces the prior navigation, so the auto-goto is effectively free.
*
* To skip the auto-goto entirely (e.g. inspecting cold mount):
* test.use({ autoGoto: false });
*
* To override the stub options (enable login, change user, …):
* test.use({ stubOptions: { enableLogin: true } });
*
* To seed a JWT so the user is treated as logged-in (required when
* `enableLogin: true`, otherwise Landing redirects to /login):
* test.use({ stubOptions: { enableLogin: true }, seedJwt: true });
*
* To register a narrower stub for a tool endpoint, just call `page.route(...)`
* after the fixture runs — Playwright uses last-registered-wins.
*/
type StubFixtures = {
stubOptions: MockAppApiOptions;
autoGoto: false | string;
seedJwt: boolean;
};
// Minimal JWT-shaped value — the proprietary auth client only checks for
// the token's *presence* in localStorage before treating the user as
// logged-in; the stubbed `/auth/me` route supplies the actual user.
const STUB_JWT = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzdHViLXVzZXIifQ.signature";
export const test = base.extend<StubFixtures>({
stubOptions: [{}, { option: true }],
autoGoto: ["/", { option: true }],
seedJwt: [false, { option: true }],
page: async ({ page, stubOptions, autoGoto, seedJwt }, use) => {
await seedCookieConsent(page);
if (seedJwt) {
// Logged-in users hit the orchestrator path that surfaces the
// analytics opt-in / MFA prompts — use the stronger bypass-all flag
// so those overlays don't block clicks.
await bypassOnboarding(page);
await page.addInitScript((token) => {
localStorage.setItem("stirling_jwt", token);
}, STUB_JWT);
} else {
await skipOnboarding(page);
}
await mockAppApis(page, stubOptions);
if (autoGoto !== false) {
// waitUntil: 'domcontentloaded' avoids hanging on third-party CDN
// resources (iconify, posthog, stripe) the stub doesn't mock — the
// default 'load' event waits for ALL subresources, which can time out
// on slow runners and is rarely what tests actually need.
await page.goto(autoGoto, { waitUntil: "domcontentloaded" });
}
await use(page);
},
});
export { expect };
@@ -0,0 +1,35 @@
import { test as base, expect } from "@playwright/test";
/**
* Custom test fixture that auto-dismisses the cookie consent banner
* before every test. The banner (#cc-main) overlays the page and
* intercepts pointer events, causing click timeouts across all tests.
*
* Usage: import { test, expect } from '@app/tests/helpers/test-base';
*/
export const test = base.extend({
page: async ({ page }, use) => {
// Set the cookie consent cookie before any navigation so the banner
// never appears. The cookieconsent library (orestbida/cookieconsent)
// reads this cookie on init and skips the banner if consent exists.
await page.context().addCookies([
{
name: "cc_cookie",
value: JSON.stringify({
categories: ["necessary"],
revision: 0,
data: null,
rfc_cookie: false,
consentTimestamp: new Date().toISOString(),
consentId: "playwright-test",
}),
domain: "localhost",
path: "/",
},
]);
await use(page);
},
});
export { expect };
@@ -0,0 +1,129 @@
import { expect, type Page, type Locator } from "@playwright/test";
/**
* Shared UI helpers for Playwright specs.
*
* Centralises the patterns repeated across the suite (file upload, settings
* dialog, run-button + review-panel wait, viewer-mode escape, modal-overlay
* waits) so each spec stays focused on its assertion rather than the
* machinery.
*/
const MANTINE_MODAL_OVERLAY = ".mantine-Modal-overlay";
/**
* Wait for a Mantine Modal overlay to appear or disappear. Most file pickers,
* settings dialogs, encrypted-PDF unlock prompts and so on render through
* this overlay; specs use it as a synchronisation point.
*/
export async function waitForModalOpen(
page: Page,
timeout = 5_000,
): Promise<void> {
await page.waitForSelector(MANTINE_MODAL_OVERLAY, {
state: "visible",
timeout,
});
}
export async function waitForModalClose(
page: Page,
timeout = 10_000,
): Promise<void> {
await page.waitForSelector(MANTINE_MODAL_OVERLAY, {
state: "hidden",
timeout,
});
}
/**
* Upload one or more files through the workbench's "Files" modal. The modal
* auto-closes once a file is selected; we wait for the overlay to vanish so
* the caller can interact with the page immediately afterwards.
*
* Pass `awaitClose: false` when the spec is testing a flow that keeps the
* modal open after upload (e.g. encrypted-PDF unlock — the unlock modal
* appears on top before the files modal closes).
*/
export async function uploadFiles(
page: Page,
filePaths: string | string[],
opts: { awaitClose?: boolean } = {},
): Promise<void> {
const { awaitClose = true } = opts;
await page.getByTestId("files-button").click();
await waitForModalOpen(page);
await page
.locator('[data-testid="file-input"]')
.setInputFiles(filePaths as string | string[]);
if (awaitClose) {
await waitForModalClose(page);
}
}
/**
* Some tools (Merge in particular) park the workbench in `viewer` mode after
* upload, which keeps the run button disabled. The UI exposes a "Go to file
* editor" affordance to switch out of viewer mode; this helper clicks it
* when present and is a no-op otherwise.
*/
export async function switchToEditorIfViewerMode(page: Page): Promise<void> {
const goToEditor = page.getByRole("button", {
name: /go to file editor/i,
});
if (await goToEditor.isVisible({ timeout: 1_000 }).catch(() => false)) {
await goToEditor.click();
}
}
/**
* Click the tool's run button and wait for the review panel to render with
* the produced output. Throws if the run button never enables or the review
* panel never appears, both of which are real regressions.
*/
export async function runToolAndWaitForReview(
page: Page,
opts: { runTimeout?: number; reviewTimeout?: number } = {},
): Promise<void> {
const { runTimeout = 15_000, reviewTimeout = 60_000 } = opts;
const runBtn = page.locator('[data-tour="run-button"]');
await expect(runBtn).toBeEnabled({ timeout: runTimeout });
await runBtn.click();
await expect(
page.locator('[data-testid="review-panel-container"]'),
).toBeVisible({ timeout: reviewTimeout });
}
/**
* Open the global Settings dialog. Returns the dialog locator so callers can
* scope further queries to it.
*/
export async function openSettings(page: Page): Promise<Locator> {
await page.locator('[data-testid="config-button"]').first().click();
const dialog = page.locator(".mantine-Modal-content").first();
await expect(dialog).toBeVisible({ timeout: 5_000 });
return dialog;
}
/**
* Close the Settings dialog via its built-in Close button and assert the
* dialog is fully dismissed before returning.
*/
export async function closeSettings(page: Page): Promise<void> {
const closeBtn = page.locator('[aria-label="Close"]').first();
await closeBtn.click();
await expect(page.locator(".mantine-Modal-content").first()).not.toBeVisible({
timeout: 5_000,
});
}
/**
* Dismiss the onboarding tour tooltip (`Watch walkthroughs here…`) when it's
* blocking pointer events on firefox/webkit. No-op when absent.
*/
export async function dismissTourTooltip(page: Page): Promise<void> {
const closeBtn = page.getByRole("button", { name: /close tooltip/i }).first();
if (await closeBtn.isVisible({ timeout: 500 }).catch(() => false)) {
await closeBtn.click();
}
}
@@ -0,0 +1,104 @@
import { test, expect } from "@app/tests/helpers/test-base";
import {
ensureCookieConsent,
skipOnboarding,
DEFAULT_TEST_USERNAME,
DEFAULT_TEST_PASSWORD,
} from "@app/tests/helpers/login";
/**
* Live-suite bootstrap. Two responsibilities:
*
* 1. **Pristine CI path** — log in as the backend's default
* `admin / stirling` user (created with firstLogin=true), exercise
* the FirstLoginSlide UI, change to `admin / adminadmin`, verify
* the post-change toast. Real coverage of the forced-first-login
* flow.
*
* 2. **Already-seeded path** — for local dev where `admin / adminadmin`
* already exists (e.g. the developer's settings.yml provisions it
* directly), the bootstrap is a no-op pass. We detect this by
* attempting the API login first; if it returns 200 we skip the
* UI flow.
*
* Configured as a Playwright `setup` project; the `live` project
* depends on it so it runs once before every other live spec.
*/
const DEFAULT_BACKEND_PASSWORD = "stirling";
async function adminAdminadminAlreadyExists(
request: import("@playwright/test").APIRequestContext,
): Promise<boolean> {
const res = await request
.post("/api/v1/auth/login", {
data: {
username: DEFAULT_TEST_USERNAME,
password: DEFAULT_TEST_PASSWORD,
},
})
.catch(() => null);
return res?.ok() ?? false;
}
test.describe("Live-suite bootstrap", () => {
test("first-login: change default admin/stirling to admin/adminadmin", async ({
page,
request,
}) => {
test.setTimeout(60_000);
// Already-seeded local case — skip the UI flow.
if (await adminAdminadminAlreadyExists(request)) {
test.info().annotations.push({
type: "bootstrap",
description:
"admin/adminadmin already provisioned (local-dev path); skipping forced-first-login UI flow",
});
return;
}
await ensureCookieConsent(page);
await skipOnboarding(page);
await page.goto("/login", { waitUntil: "domcontentloaded" });
await page.locator("#email").waitFor({ state: "visible", timeout: 15_000 });
await page.locator("#email").fill(DEFAULT_TEST_USERNAME);
await page.locator("#password").fill(DEFAULT_BACKEND_PASSWORD);
await page.locator('button[type="submit"]').click();
await expect(
page.getByText(/must change your password|set your password/i).first(),
).toBeVisible({ timeout: 15_000 });
// Mantine's PasswordInput label association doesn't match getByLabel
// cleanly across builds — use the placeholder which is stable.
await page
.getByPlaceholder(/enter new password.*characters/i)
.fill(DEFAULT_TEST_PASSWORD);
await page
.getByPlaceholder(/re-enter new password/i)
.fill(DEFAULT_TEST_PASSWORD);
const submitBtn = page.getByRole("button", { name: /change password/i });
await expect(submitBtn).toBeEnabled();
await submitBtn.click();
await expect(
page.getByText(/password changed successfully/i).first(),
).toBeVisible({ timeout: 10_000 });
// After success the user is signed out; expect redirect back to /login
await page.waitForURL(/\/login(\?.*)?$/, { timeout: 15_000 });
// Confirm the new credentials work via the API
const verify = await request.post("/api/v1/auth/login", {
data: {
username: DEFAULT_TEST_USERNAME,
password: DEFAULT_TEST_PASSWORD,
},
});
expect(verify.ok()).toBeTruthy();
});
});
@@ -0,0 +1,173 @@
import { test, expect } from "@app/tests/helpers/test-base";
import { login, dismissWelcomeDialog } from "@app/tests/helpers/login";
test.describe("1. Authentication and Login", () => {
test.describe("1.1 Login Page - Happy Path", () => {
test("should login successfully with valid credentials", async ({
page,
}) => {
// Step 1: Verify the browser redirects to /login
await page.goto("/");
await expect(page).toHaveURL(/\/login/);
// Step 2: Confirm the login page displays the Stirling PDF logo
await expect(
page
.locator(
'img[alt*="Stirling"], img[src*="stirling"], img[src*="logo"]',
)
.first(),
).toBeVisible();
// Step 3: Confirm the heading for "Sign In" / "Login" is visible
await expect(
page.getByRole("heading", { name: /sign in|login|masuk/i }),
).toBeVisible();
// Step 4: Confirm a "Username" text input field is present and empty
const usernameInput = page.locator("#email");
await expect(usernameInput).toBeVisible();
await expect(usernameInput).toHaveValue("");
// Step 5: Confirm a "Password" text input field is present and empty
const passwordInput = page.locator("#password");
await expect(passwordInput).toBeVisible();
await expect(passwordInput).toHaveValue("");
// Step 6: Confirm the "Sign In" button is present and disabled when both fields are empty
const signInButton = page.locator('button[type="submit"]');
await expect(signInButton).toBeVisible();
await expect(signInButton).toBeDisabled();
// Step 7: Enter a valid username into the username field
await usernameInput.fill("admin");
// Step 8: Enter a valid password into the password field
await passwordInput.fill("adminadmin");
// Step 9: Confirm the "Sign In" button becomes enabled
await expect(signInButton).toBeEnabled();
// Step 10: Click the "Sign In" button
await signInButton.click();
// Step 11: Verify the user is redirected to the home page at /
await page.waitForURL("/", { timeout: 15000 });
await expect(page).toHaveURL("/");
// Step 12: Verify the home dashboard loads with tool sidebar and file upload area visible
await expect(
page
.locator('.h-screen, .mobile-layout, [data-testid="dashboard"]')
.first(),
).toBeVisible({ timeout: 10000 });
});
});
test.describe("1.3 Login Page - Empty Fields Validation", () => {
test("should keep Sign In button disabled when fields are empty", async ({
page,
}) => {
// Starting state: User is logged out; browser on /login
await page.goto("/login");
await page.waitForLoadState("domcontentloaded");
const usernameInput = page.locator("#email");
const passwordInput = page.locator("#password");
const signInButton = page.locator('button[type="submit"]');
// Step 1-2: Leave both fields empty, verify button is disabled
await expect(signInButton).toBeDisabled();
// Step 3-4: Enter only a username value; leave password empty; verify button remains disabled
await usernameInput.fill("admin");
await expect(signInButton).toBeDisabled();
// Step 5-6: Clear username; enter only a password value; verify button remains disabled
await usernameInput.clear();
await passwordInput.fill("adminadmin");
await expect(signInButton).toBeDisabled();
});
});
test.describe("1.5 Login Page - Session Expiry and Redirect", () => {
test("should redirect back to intended page after re-login", async ({
page,
}) => {
// Starting state: User is logged in and on a tool page
await login(page);
await dismissWelcomeDialog(page);
await page.goto("/merge");
await page.waitForLoadState("domcontentloaded");
// Step 1-2: Invalidate session by clearing cookies and localStorage JWT,
// then re-add the cookie consent cookie so the banner doesn't block after redirect
await page.context().clearCookies();
await page.evaluate(() => {
localStorage.removeItem("stirling_jwt");
localStorage.removeItem("stirling_refresh_token");
});
await page.context().addCookies([
{
name: "cc_cookie",
value: JSON.stringify({
categories: ["necessary"],
revision: 0,
data: null,
rfc_cookie: false,
}),
domain: "localhost",
path: "/",
},
]);
// Full page reload forces the SPA to re-check auth with the backend
await page.reload({ waitUntil: "domcontentloaded" });
// Step 3: Verify the user is redirected to the login page
await expect(page).toHaveURL(/\/login/, { timeout: 15000 });
// Step 5: Log in with valid credentials
await page.locator("#email").fill("admin");
await page.locator("#password").fill("adminadmin");
await page.locator('button[type="submit"]').click();
// Step 6: Verify the user is redirected back to /merge or home.
// Any non-/login URL is acceptable — the app may route to the original
// page (/merge) or to the dashboard (/), both are valid post-login states.
await page.waitForURL((url) => !url.pathname.includes("/login"), {
timeout: 15000,
});
});
});
test.describe("1.6 Login Page - Carousel/Slideshow", () => {
test("should navigate between carousel slides", async ({ page }) => {
// Carousel is hidden on small viewports (< 940px wide), ensure desktop size
await page.setViewportSize({ width: 1920, height: 1080 });
// Starting state: User is logged out; browser on /login
await page.goto("/login");
await page.waitForLoadState("domcontentloaded");
// Step 1: Verify slide indicator dots are present (carousel uses aria-label "Go to slide N")
const slideButtons = page.getByRole("button", { name: /Go to slide/i });
const count = await slideButtons.count();
test.skip(count === 0, "No carousel slides configured on this instance");
// Step 2: Click through slides
if (count >= 2) {
await slideButtons.nth(1).click();
await page.waitForTimeout(500);
}
if (count >= 3) {
await slideButtons.nth(2).click();
await page.waitForTimeout(500);
}
// Step 3: Click back to slide 1
await slideButtons.nth(0).click();
await page.waitForTimeout(500);
});
});
});
@@ -0,0 +1,55 @@
import { test, expect } from "@app/tests/helpers/test-base";
import { loginAndSetup } from "@app/tests/helpers/login";
/**
* Automate is the "super tool" that lets a user chain multiple tools
* against a single set of files. Today the only coverage is "automate
* page loads"; this verifies the builder UI is reachable and a basic
* chain can be assembled. We don't run the chain (that depends on the
* backend's automation runner being fully wired in CI) — just that the
* builder accepts at least two tools.
*/
test.describe("Automate — multi-tool chain builder", () => {
test.beforeEach(async ({ page }) => {
await loginAndSetup(page);
});
test("builder opens and supports adding tools to the chain", async ({
page,
}) => {
test.setTimeout(60_000);
await page.goto("/automate");
await page.waitForLoadState("domcontentloaded");
// Either there's a "create" button on a list view, or the builder
// opens directly. Click create if present.
const createBtn = page
.getByRole("button", { name: /create|new automation|new workflow/i })
.first();
if (await createBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
await createBtn.click();
}
// The builder exposes some way to add a tool to the chain — search
// for "add tool" / "add step" / a tool picker.
const addStep = page
.getByRole("button", { name: /add tool|add step|\+/ })
.first();
if (!(await addStep.isVisible({ timeout: 5_000 }).catch(() => false))) {
test.skip(true, "Automate builder not in expected shape on this build");
return;
}
await addStep.click();
await page.waitForTimeout(500);
// After adding a step there should be at least one named tool in the
// chain (the selector inside the builder).
const toolNode = page.locator(
'[data-testid^="automation-step"], [class*="ToolChain"] [class*="step" i]',
);
await expect
.poll(async () => toolNode.count(), { timeout: 5_000 })
.toBeGreaterThan(0);
});
});
@@ -0,0 +1,204 @@
import { test, expect } from "@app/tests/helpers/test-base";
import { loginAndSetup } from "@app/tests/helpers/login";
import {
uploadFiles,
switchToEditorIfViewerMode,
runToolAndWaitForReview,
} from "@app/tests/helpers/ui-helpers";
import * as path from "path";
import * as fs from "fs";
/**
* E2E tests for real PDF operations.
* These tests upload actual PDF files, process them through the backend,
* and verify the results are produced. Requires a running Spring Boot backend
* — registered under the `live` Playwright project.
*/
// Resolve test fixture paths - works from both frontend/ and repo root
function fixture(filename: string): string {
const candidates = [
path.resolve(
process.cwd(),
"src",
"core",
"tests",
"test-fixtures",
filename,
),
path.resolve(
process.cwd(),
"frontend",
"src",
"core",
"tests",
"test-fixtures",
filename,
),
];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
throw new Error(
`Test fixture not found: ${filename} (tried: ${candidates.join(", ")})`,
);
}
// Additional test PDFs from the testing/ directory (lives at the repo root).
// Candidates cover the three plausible cwds Playwright might be invoked from:
// - repo root → testing/
// - frontend/ → ../testing/
// - frontend/editor/ → ../../testing/ (post editor-restructure)
function testingFile(filename: string): string {
const candidates = [
path.resolve(process.cwd(), "..", "..", "testing", filename),
path.resolve(process.cwd(), "..", "testing", filename),
path.resolve(process.cwd(), "testing", filename),
];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
throw new Error(
`Testing file not found: ${filename} (tried: ${candidates.join(", ")})`,
);
}
async function executeAndWaitForResults(page: import("@playwright/test").Page) {
await switchToEditorIfViewerMode(page);
await runToolAndWaitForReview(page);
}
test.describe("E2E PDF Operations", () => {
test.describe.configure({ timeout: 120000 });
test.beforeEach(async ({ page }) => {
await loginAndSetup(page);
});
test.describe("Merge Tool - End to End", () => {
test("should merge two PDF files and produce a result", async ({
page,
}) => {
await page.goto("/merge");
await page.waitForLoadState("domcontentloaded");
// Upload two PDF files (merge requires minimum 2)
const file1 = testingFile("test_pdf_1.pdf");
const file2 = testingFile("test_pdf_2.pdf");
await uploadFiles(page, [file1, file2]);
// Click merge button and wait for results
await executeAndWaitForResults(page);
// Verify results panel shows with processed file
const reviewPanel = page.locator(
'[data-testid="review-panel-container"]',
);
await expect(reviewPanel).toBeVisible();
});
test("should merge three PDF files", async ({ page }) => {
await page.goto("/merge");
await page.waitForLoadState("domcontentloaded");
const file1 = testingFile("test_pdf_1.pdf");
const file2 = testingFile("test_pdf_2.pdf");
const file3 = testingFile("test_pdf_3.pdf");
await uploadFiles(page, [file1, file2, file3]);
await executeAndWaitForResults(page);
await expect(
page.locator('[data-testid="review-panel-container"]'),
).toBeVisible();
});
});
test.describe("Split Tool - End to End", () => {
test("should split a PDF by page numbers", async ({ page }) => {
await page.goto("/split");
await page.waitForLoadState("domcontentloaded");
// Upload a PDF file
await uploadFiles(page, [fixture("sample.pdf")]);
// Select "Page Numbers" split method from the CardSelector
await page
.getByText(/Page Numbers/i)
.first()
.click();
// Wait for the settings step to expand and find the page numbers input
const pagesInput = page.getByPlaceholder(/Custom Page Selection/i);
await pagesInput.waitFor({ state: "visible", timeout: 10000 });
await pagesInput.fill("1");
// Execute split
await executeAndWaitForResults(page);
// Verify results
await expect(
page.locator('[data-testid="review-panel-container"]'),
).toBeVisible();
});
});
test.describe("Compress Tool - End to End", () => {
test("should compress a PDF file", async ({ page }) => {
await page.goto("/compress");
await page.waitForLoadState("domcontentloaded");
// Upload a PDF file
await uploadFiles(page, [fixture("sample.pdf")]);
// Default settings (quality level 5) should be fine
await executeAndWaitForResults(page);
// Verify results
await expect(
page.locator('[data-testid="review-panel-container"]'),
).toBeVisible();
});
});
test.describe("Add Password Tool - End to End", () => {
test("should add a password to a PDF file", async ({ page }) => {
await page.goto("/add-password");
await page.waitForLoadState("domcontentloaded");
// Upload a PDF file
await uploadFiles(page, [fixture("sample.pdf")]);
// Fill in the password field - wait for the password step to be visible
const passwordInput = page.getByPlaceholder(/password/i).first();
await passwordInput.waitFor({ state: "visible", timeout: 10000 });
await passwordInput.fill("testpassword123");
// Execute add password
await executeAndWaitForResults(page);
// Verify results
await expect(
page.locator('[data-testid="review-panel-container"]'),
).toBeVisible();
});
});
test.describe("Convert Tool - End to End", () => {
test("should convert an image to PDF", async ({ page }) => {
await page.goto("/convert");
await page.waitForLoadState("domcontentloaded");
// Upload an image file (PNG -> PDF conversion)
await uploadFiles(page, [fixture("sample.png")]);
// The convert tool auto-detects input format
// Execute conversion
await executeAndWaitForResults(page);
// Verify results
await expect(
page.locator('[data-testid="review-panel-container"]'),
).toBeVisible();
});
});
});
@@ -0,0 +1,167 @@
import { test, expect } from "@app/tests/helpers/test-base";
import { loginAndSetup } from "@app/tests/helpers/login";
import * as path from "path";
import * as fs from "fs";
import * as os from "os";
test.describe("20. Edge Cases and Security", () => {
// 20.1 Concurrent Sessions removed — cross-tab cookie invalidation timing
// is racy across browsers and produced flake in CI even with retries=2.
test.describe("20.2 XSS Prevention in Search", () => {
test("should prevent XSS via search input", async ({ page }) => {
await loginAndSetup(page);
// Step 1: Enter XSS payload in the search box
const searchBox = page.getByPlaceholder(/search|cari/i).first();
await searchBox.fill('"><img src=x onerror=alert(1)>');
// Step 2: Verify no script execution or image error handler fires
await page.waitForTimeout(1000);
// Step 3: Verify the input is rendered as plain text
await expect(searchBox).toHaveValue('"><img src=x onerror=alert(1)>');
});
});
test.describe("20.3 Large File Name Handling", () => {
test("should handle files with very long filenames", async ({ page }) => {
await loginAndSetup(page);
await page.goto("/merge");
await page.waitForLoadState("domcontentloaded");
// Step 1: Create a PDF with a long filename (keep under OS path limits)
const longName = "a".repeat(100) + ".pdf";
const tmpDir = os.tmpdir();
const tmpFile = path.join(tmpDir, longName);
// Create a minimal PDF file
const pdfContent =
"%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>endobj\nxref\n0 4\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n0000000115 00000 n \ntrailer<</Size 4/Root 1 0 R>>\nstartxref\n190\n%%EOF";
try {
fs.writeFileSync(tmpFile, pdfContent);
// Step 2: Upload the file
const fileInput = page.locator('input[type="file"]').first();
await fileInput.setInputFiles(tmpFile);
// Step 3: Verify the application handles the file without crashing
await page.waitForTimeout(2000);
const bodyContent = await page.locator("body").textContent();
expect(bodyContent).toBeTruthy();
} finally {
// Clean up
try {
fs.unlinkSync(tmpFile);
} catch {
/* ignore */
}
}
});
});
test.describe("20.4 Non-PDF File Upload to PDF-Only Tool", () => {
test("should reject invalid file types", async ({ page }) => {
await loginAndSetup(page);
await page.goto("/merge");
await page.waitForLoadState("domcontentloaded");
// Step 1: Create a .txt file
const tmpDir = os.tmpdir();
const txtFile = path.join(tmpDir, "test-invalid.txt");
fs.writeFileSync(txtFile, "This is a text file, not a PDF.");
try {
// Step 2: Upload the non-PDF file
const fileInput = page.locator('input[type="file"]').first();
await fileInput.setInputFiles(txtFile);
// Verify the application handles it (either rejects or shows error)
await page.waitForTimeout(2000);
} finally {
try {
fs.unlinkSync(txtFile);
} catch {
/* ignore */
}
}
});
});
test.describe("20.5 Empty File Upload", () => {
test("should handle empty files gracefully", async ({ page }) => {
await loginAndSetup(page);
await page.goto("/merge");
await page.waitForLoadState("domcontentloaded");
// Step 1: Create a 0-byte file named empty.pdf
const tmpDir = os.tmpdir();
const emptyFile = path.join(tmpDir, "empty.pdf");
fs.writeFileSync(emptyFile, "");
try {
// Step 2: Attempt to upload it
const fileInput = page.locator('input[type="file"]').first();
await fileInput.setInputFiles(emptyFile);
// Step 3: Verify the application handles the empty file gracefully
await page.waitForTimeout(2000);
const bodyContent = await page.locator("body").textContent();
expect(bodyContent).toBeTruthy();
} finally {
try {
fs.unlinkSync(emptyFile);
} catch {
/* ignore */
}
}
});
});
test.describe("20.6 API Documentation Link", () => {
test("should navigate to Swagger API documentation", async ({
page,
context,
}) => {
await loginAndSetup(page);
// The API tool is a link that opens swagger-ui in a new tab.
// Listen for popup before triggering navigation.
const popupPromise = context
.waitForEvent("page", { timeout: 10000 })
.catch(() => null);
// Navigate to the dev-api-docs route
await page.goto("/dev-api-docs");
await page.waitForLoadState("domcontentloaded");
await page.waitForTimeout(2000);
// Check approach 1: A new tab was opened with swagger-ui URL
const popup = await popupPromise;
if (popup) {
expect(popup.url()).toContain("swagger-ui");
await popup.close();
return;
}
// Check approach 2: The page itself contains a link to swagger-ui
const swaggerLink = page.locator('a[href*="swagger-ui"]').first();
if (await swaggerLink.isVisible({ timeout: 3000 }).catch(() => false)) {
const href = await swaggerLink.getAttribute("href");
expect(href).toContain("swagger-ui");
return;
}
// Check approach 3: The page redirected to swagger-ui
if (page.url().includes("swagger-ui")) {
expect(page.url()).toContain("swagger-ui");
return;
}
// If none of the above, verify the page at least rendered without error
const bodyText = await page.locator("body").textContent();
expect(bodyText).toBeTruthy();
});
});
});
@@ -0,0 +1,62 @@
import { test } from "@app/tests/helpers/test-base";
import { loginAndSetup } from "@app/tests/helpers/login";
import {
switchToEditorIfViewerMode,
runToolAndWaitForReview,
waitForModalOpen,
waitForModalClose,
} from "@app/tests/helpers/ui-helpers";
import path from "path";
const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
const ENCRYPTED_PDF = path.join(FIXTURES_DIR, "encrypted.pdf");
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
/**
* The encrypted-PDF unlock prompt is well-tested in isolation
* (stubbed/EncryptedPdfUnlockE2E.spec.ts), but no test verifies that
* after unlocking, the file actually flows into a tool run. This spec
* uploads an encrypted PDF + a regular PDF, unlocks the encrypted one,
* and runs merge against the real backend.
*/
test.describe("Encrypted PDF: unlock then merge", () => {
test.beforeEach(async ({ page }) => {
await loginAndSetup(page);
});
test("unlocked encrypted PDF participates in a real merge", async ({
page,
}) => {
test.setTimeout(120_000);
await page.goto("/merge");
await page.waitForLoadState("domcontentloaded");
await page.getByTestId("files-button").click();
await waitForModalOpen(page);
await page
.locator('[data-testid="file-input"]')
.setInputFiles([ENCRYPTED_PDF, SAMPLE_PDF]);
// Encrypted unlock modal appears (the files modal may still be closing)
const passwordInput = page.getByPlaceholder(/password/i).first();
if (
!(await passwordInput.isVisible({ timeout: 10_000 }).catch(() => false))
) {
test.skip(
true,
"Encrypted unlock modal not surfaced — fixture may not match this build",
);
return;
}
await passwordInput.fill("test");
await page
.getByRole("button", { name: /unlock/i })
.first()
.click();
await waitForModalClose(page, 15_000);
await switchToEditorIfViewerMode(page);
await runToolAndWaitForReview(page);
});
});
@@ -0,0 +1,114 @@
import { test, expect } from "@app/tests/helpers/test-base";
import { loginAndSetup } from "@app/tests/helpers/login";
test.describe("21. Password Change Flow", () => {
test.describe("21.1 Change Password", () => {
test("should change password end-to-end", async ({ page }) => {
await loginAndSetup(page);
// Open settings dialog
await page
.getByRole("button", { name: /settings/i })
.first()
.click();
const settingsDialog = page.locator(".mantine-Modal-content").first();
await expect(settingsDialog).toBeVisible({ timeout: 5000 });
// Navigate to Account Settings section
const accountNav = page.getByText(/Account Settings/i).first();
await expect(accountNav).toBeVisible({ timeout: 5000 });
await accountNav.click();
await page.waitForTimeout(500);
// Step 1: Click "Update password" button in account section
const updatePwBtn = page
.getByRole("button", { name: /Update password/i })
.first();
await expect(updatePwBtn).toBeVisible({ timeout: 5000 });
await updatePwBtn.click();
// Step 2: Wait for the password change modal to appear
const passwordModal = page.getByRole("dialog", { name: /Security/i });
await expect(passwordModal).toBeVisible({ timeout: 5000 });
// Step 3: Enter the current password
await passwordModal.getByPlaceholder(/current password/i).fill("admin");
// Step 4: Enter a new password
await passwordModal
.getByPlaceholder(/Enter a new password/i)
.fill("admin");
// Step 5: Confirm the new password
await passwordModal.getByPlaceholder(/Re-enter/i).fill("admin");
// Step 6: Submit the form - click "Update password" button inside the modal
const modalSubmitBtn = passwordModal.getByRole("button", {
name: /Update password/i,
});
await expect(modalSubmitBtn).toBeVisible({ timeout: 3000 });
await modalSubmitBtn.click();
// Step 7: Verify a success message is shown (or no error)
await page.waitForTimeout(2000);
});
});
test.describe("21.2 Change Password - Mismatch", () => {
test("should reject mismatched passwords with error", async ({ page }) => {
await loginAndSetup(page);
// Open settings dialog
await page
.getByRole("button", { name: /settings/i })
.first()
.click();
const settingsDialog = page.locator(".mantine-Modal-content").first();
await expect(settingsDialog).toBeVisible({ timeout: 5000 });
// Navigate to Account Settings section
const accountNav = page.getByText(/Account Settings/i).first();
await expect(accountNav).toBeVisible({ timeout: 5000 });
await accountNav.click();
await page.waitForTimeout(500);
// Click "Update password" to open password change modal
const updatePwBtn = page
.getByRole("button", { name: /Update password/i })
.first();
await expect(updatePwBtn).toBeVisible({ timeout: 5000 });
await updatePwBtn.click();
// Wait for the password change modal to appear
const passwordModal = page.getByRole("dialog", { name: /Security/i });
await expect(passwordModal).toBeVisible({ timeout: 5000 });
// Step 1: Enter the current password correctly
await passwordModal.getByPlaceholder(/current password/i).fill("admin");
// Step 2: Enter a new password
await passwordModal
.getByPlaceholder(/Enter a new password/i)
.fill("newpassword123");
// Step 3: Enter a different value in confirm password
await passwordModal
.getByPlaceholder(/Re-enter/i)
.fill("differentpassword456");
// Step 4: Submit the form inside the modal
const modalSubmitBtn = passwordModal.getByRole("button", {
name: /Update password/i,
});
await expect(modalSubmitBtn).toBeVisible({ timeout: 3000 });
await modalSubmitBtn.click();
// Step 5: Verify an error message about password mismatch is shown
await expect(
page
.locator("text=/mismatch|do not match|tidak cocok|tidak sama/i")
.first(),
).toBeVisible({ timeout: 5000 });
});
});
});
@@ -0,0 +1,51 @@
import { test, expect } from "@app/tests/helpers/test-base";
import { loginAndSetup } from "@app/tests/helpers/login";
test.describe("22. Username Change Flow", () => {
test.describe("22.1 Change Username", () => {
test("should update username successfully", async ({ page }) => {
await loginAndSetup(page);
// Open settings dialog
await page
.getByRole("button", { name: /settings/i })
.first()
.click();
const settingsDialog = page.locator(".mantine-Modal-content").first();
await expect(settingsDialog).toBeVisible({ timeout: 5000 });
// Navigate to Account Settings section
const accountNav = page.getByText(/Account Settings/i).first();
await expect(accountNav).toBeVisible({ timeout: 5000 });
await accountNav.click();
await page.waitForTimeout(500);
// Step 1: Click "Change Username"
const changeUsernameBtn = page
.getByRole("button", { name: /Change Username/i })
.first();
await expect(changeUsernameBtn).toBeVisible({ timeout: 5000 });
await changeUsernameBtn.click();
// Step 2: Wait for the Change Username modal to appear
const usernameModal = page.getByRole("dialog", {
name: /Change Username/i,
});
await expect(usernameModal).toBeVisible({ timeout: 5000 });
// Step 3: Enter a new valid username
await usernameModal.getByLabel(/New Username/i).fill("admin");
// Step 4: Enter current password (required for username change)
await usernameModal.getByLabel(/Current Password/i).fill("admin");
// Step 5: Submit the form via "Save" button inside the modal
const saveBtn = usernameModal.getByRole("button", { name: /Save/i });
await expect(saveBtn).toBeVisible({ timeout: 3000 });
await saveBtn.click();
// Step 6: Verify success or completion
await page.waitForTimeout(2000);
});
});
});
@@ -0,0 +1,231 @@
import fs from "fs";
import path from "path";
import ts from "typescript";
import { describe, expect, test } from "vitest";
import { parse } from "smol-toml";
const REPO_ROOT = path.join(__dirname, "../../../..");
const SRC_ROOT = path.join(__dirname, "../..");
const EN_GB_FILE = path.join(
__dirname,
"../../../public/locales/en-GB/translation.toml",
);
const IGNORED_DIRS = new Set(["tests", "__mocks__"]);
const IGNORED_FILE_PATTERNS = [
/\.d\.ts$/,
/\.test\./,
/\.spec\./,
/\.stories\./,
];
const IGNORED_KEYS = new Set<string>([
// If the script has found a false-positive that shouldn't be in the translations, include it here
]);
const LIKELY_TRANSLATION_USAGE_RE = /(?:^|[^\w$])t\s*\(|\.t\s*\(|\bi18nKey\b/;
type FoundKey = {
key: string;
fallback: string;
file: string;
line: number;
column: number;
};
const flattenKeys = (
node: unknown,
prefix = "",
acc = new Set<string>(),
): Set<string> => {
if (!node || typeof node !== "object" || Array.isArray(node)) {
if (prefix) {
acc.add(prefix);
}
return acc;
}
for (const [childKey, value] of Object.entries(
node as Record<string, unknown>,
)) {
const next = prefix ? `${prefix}.${childKey}` : childKey;
flattenKeys(value, next, acc);
}
return acc;
};
const listSourceFiles = (): string[] => {
const files = ts.sys.readDirectory(
SRC_ROOT,
[".ts", ".tsx", ".js", ".jsx"],
undefined,
["**/*"],
);
return files
.filter(
(file) =>
!file.split(path.sep).some((segment) => IGNORED_DIRS.has(segment)),
)
.filter((file) => !IGNORED_FILE_PATTERNS.some((re) => re.test(file)));
};
const getScriptKind = (file: string): ts.ScriptKind => {
if (file.endsWith(".tsx")) {
return ts.ScriptKind.TSX;
}
if (file.endsWith(".ts")) {
return ts.ScriptKind.TS;
}
if (file.endsWith(".jsx")) {
return ts.ScriptKind.JSX;
}
return ts.ScriptKind.JS;
};
/**
* Find all of the static first keys for translation functions that we can.
* Ignores dynamic strings because we can't know what the actual translation key will be.
*/
const extractKeys = (file: string): FoundKey[] => {
const code = fs.readFileSync(file, "utf8");
if (!LIKELY_TRANSLATION_USAGE_RE.test(code)) {
return [];
}
const sourceFile = ts.createSourceFile(
file,
code,
ts.ScriptTarget.Latest,
true,
getScriptKind(file),
);
const found: FoundKey[] = [];
const record = (node: ts.Node, key: string, fallback: string = "") => {
const { line, character } = sourceFile.getLineAndCharacterOfPosition(
node.getStart(),
);
found.push({ key, fallback, file, line: line + 1, column: character + 1 });
};
const visit = (node: ts.Node) => {
if (ts.isCallExpression(node)) {
const callee = node.expression;
const arg0 = node.arguments.at(0);
const arg1 = node.arguments.at(1);
const isT =
(ts.isIdentifier(callee) && callee.text === "t") ||
(ts.isPropertyAccessExpression(callee) && callee.name.text === "t");
if (
isT &&
arg0 &&
(ts.isStringLiteral(arg0) || ts.isNoSubstitutionTemplateLiteral(arg0))
) {
let arg1Text: string = "";
if (
arg1 &&
(ts.isStringLiteral(arg1) || ts.isNoSubstitutionTemplateLiteral(arg1))
) {
arg1Text = arg1.text;
}
record(arg0, arg0.text, arg1Text);
}
}
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
for (const attr of node.attributes.properties) {
if (
!ts.isJsxAttribute(attr) ||
attr.name.getText(sourceFile) !== "i18nKey" ||
!attr.initializer
) {
continue;
}
const init = attr.initializer;
if (ts.isStringLiteral(init)) {
record(init, init.text);
continue;
}
if (
ts.isJsxExpression(init) &&
init.expression &&
ts.isStringLiteral(init.expression)
) {
record(init.expression, init.expression.text);
}
}
}
ts.forEachChild(node, visit);
};
ts.forEachChild(sourceFile, visit);
return found;
};
describe("Missing translation coverage", () => {
test(
"fails if any en-GB translation key used in source is missing",
{ timeout: 10000 },
() => {
expect(fs.existsSync(EN_GB_FILE)).toBe(true);
const localeContent = fs.readFileSync(EN_GB_FILE, "utf8");
const enGb = parse(localeContent);
const availableKeys = flattenKeys(enGb);
const usedKeys = listSourceFiles()
.flatMap(extractKeys)
.filter(({ key }) => !IGNORED_KEYS.has(key));
expect(usedKeys.length).toBeGreaterThan(100); // Sanity check
const missingKeys = usedKeys.filter(({ key }) => !availableKeys.has(key));
const annotations = missingKeys.map(
({ key, fallback, file, line, column }) => {
const workspaceRelativeRaw = path.relative(REPO_ROOT, file);
const workspaceRelativeFile = workspaceRelativeRaw.replace(
/\\/g,
"/",
);
return {
key,
fallback,
file: workspaceRelativeFile,
line,
column,
};
},
);
// Output errors in GitHub Annotations format so they appear tagged in the code in CI
for (const { key, fallback, file, line, column } of annotations) {
process.stderr.write(
`::error file=${file},line=${line},col=${column}::Missing en-GB translation for ${key} (${fallback})\n`,
);
}
const neatened = annotations.map(
({ key, fallback, file, line, column }) => {
return {
key,
fallback,
location: `${file}:${line}:${column}`,
};
},
);
expect(neatened).toEqual([]);
},
);
});
@@ -0,0 +1,30 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
import path from "path";
const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
/**
* Add Page Numbers walks the user through a multi-step config: position
* (corner / centre), font/size, page range. Asserting the run button
* starts disabled and enables only after a file is uploaded catches
* the most common regression — a config-validation effect that fails
* to mark the form valid.
*/
test.describe("Add Page Numbers tool — config validation", () => {
test("run button stays disabled until a PDF is uploaded", async ({
page,
}) => {
await page.goto("/add-page-numbers");
await page.waitForLoadState("domcontentloaded");
const runBtn = page.locator('[data-tour="run-button"]');
await expect(runBtn).toBeVisible({ timeout: 5_000 });
await expect(runBtn).toBeDisabled();
await uploadFiles(page, SAMPLE_PDF);
// After upload the run button should enable (default position selected)
await expect(runBtn).toBeEnabled({ timeout: 5_000 });
});
});
@@ -0,0 +1,29 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
test.describe("9. Add Password Tool", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/add-password");
await page.waitForLoadState("domcontentloaded");
});
test.describe("9.1 Add Password - Multi-Step Form", () => {
test("should display all configuration steps", async ({ page }) => {
// Step 1: Verify a 3-step workflow: Files, Passwords & Encryption, Change Permissions
await expect(page.getByText("Files").first()).toBeVisible();
await expect(
page.getByText(/Passwords?\s*&?\s*Encryption/i).first(),
).toBeVisible();
await expect(page.getByText(/Change Permissions/i).first()).toBeVisible();
// Step 2: Verify the Encrypt button is disabled
const encryptButton = page
.getByRole("button", { name: /encrypt/i })
.first();
await expect(encryptButton).toBeVisible();
await expect(encryptButton).toBeDisabled();
// Step 3: Verify the file upload area is present
await expect(page.getByText(/upload/i).first()).toBeVisible();
});
});
});
@@ -0,0 +1,24 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
import path from "path";
const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
/**
* AddStamp loads, accepts a PDF upload, and remains interactive.
* Deeper coverage of the quick/custom positioning modes is left to the
* tool-specific vitest tests; the surface area exposed here is whatever
* happens to render given the current build's feature flags.
*/
test.describe("AddStamp tool — page health", () => {
test("page loads, accepts upload, body remains non-empty", async ({
page,
}) => {
await page.goto("/add-stamp");
await page.waitForLoadState("domcontentloaded");
await uploadFiles(page, SAMPLE_PDF);
await expect(page).toHaveURL(/\/add-stamp/);
await expect(page.locator("body").first()).not.toBeEmpty();
});
});
@@ -0,0 +1,256 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
/**
* Smoke test: verify every tool page loads without crashing,
* including sub-modes for tools that have them (Split methods,
* Redact modes, Watermark types, CertSign modes, etc.).
*
* This is a Chromium-only smoke test — fast and reliable.
*/
// ─── URL helper ──────────────────────────────────────────────────────────────
/** Convert camelCase toolId to URL path, matching getToolUrlPath() in toolsTaxonomy.ts */
function toUrlPath(toolId: string): string {
return `/${toolId.replace(/([A-Z])/g, "-$1").toLowerCase()}`;
}
// ─── Tool IDs ────────────────────────────────────────────────────────────────
// Mirror of CORE_REGULAR_TOOL_IDS + CORE_SUPER_TOOL_IDS from toolId.ts
// Link tools (devApi, devFolderScanning, etc.) are excluded — they redirect externally.
const TOOL_IDS = [
// Regular tools
"certSign",
"sign",
"addText",
"addPassword",
"removePassword",
"removePages",
"removeBlanks",
"removeAnnotations",
"removeImage",
"changePermissions",
"watermark",
"sanitize",
"split",
"merge",
"convert",
"ocr",
"addImage",
"rotate",
"annotate",
"scannerImageSplit",
"editTableOfContents",
"scannerEffect",
"autoRename",
"pageLayout",
"scalePages",
"adjustContrast",
"crop",
"pdfToSinglePage",
"repair",
"compare",
"addPageNumbers",
"redact",
"flatten",
"removeCertSign",
"unlockPDFForms",
"compress",
"extractPages",
"reorganizePages",
"extractImages",
"addStamp",
"addAttachments",
"changeMetadata",
"overlayPdfs",
"getPdfInfo",
"validateSignature",
"timestampPdf",
"replaceColor",
"showJS",
"bookletImposition",
"pdfTextEditor",
"formFill",
// Super tools
"multiTool",
"read",
"automate",
] as const;
// ─── Sub-mode definitions for tools with multiple methods/modes ──────────────
// CertSign tool: two modes selected via ButtonSelector
const CERT_SIGN_MODES = ["auto", "manual"];
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Navigate to a tool page and verify it loaded (didn't crash / redirect to login) */
async function verifyToolPageLoads(
page: import("@playwright/test").Page,
urlPath: string,
) {
// waitUntil: 'domcontentloaded' avoids hanging on third-party CDN resources
// (iconify, posthog, stripe) the stub doesn't mock — the default 'load'
// event waits for ALL subresources, which can time out on slow runners.
await page.goto(urlPath, { waitUntil: "domcontentloaded" });
// Page should not show an unhandled error / white screen
await expect(page.locator("body").first()).not.toBeEmpty();
// Should not have been kicked back to login
const url = page.url();
expect(url.includes(urlPath) || url.endsWith("/")).toBeTruthy();
}
// ─── Tests: every tool page loads ────────────────────────────────────────────
test.describe("Smoke: All tool pages load", () => {
for (const toolId of TOOL_IDS) {
const urlPath = toUrlPath(toolId);
test(`tool page loads: ${toolId} (${urlPath})`, async ({ page }) => {
await verifyToolPageLoads(page, urlPath);
});
}
});
// ─── Tests: tools with sub-modes ─────────────────────────────────────────────
test.describe("Smoke: Tool sub-modes load", () => {
// ── Split: click each available method card ─────────────────────────
// Disabled endpoints remove cards entirely (shifting indices), so we
// simply click every visible card rather than matching by index.
test("split: all available method cards load", async ({ page }) => {
test.setTimeout(120_000); // 8 cards × ~10s each
await page.goto("/split");
await page.waitForLoadState("domcontentloaded");
const cards = page.locator(".mantine-Card-root");
// Wait for at least one card to appear (may be zero if all endpoints off)
const firstVisible = await cards
.first()
.isVisible({ timeout: 10_000 })
.catch(() => false);
if (!firstVisible) return; // all split endpoints disabled — nothing to test
const count = await cards.count();
for (let i = 0; i < count; i++) {
// Re-navigate for each card — clicking a card collapses the selector
if (i > 0) {
await page.goto("/split");
await page.waitForLoadState("domcontentloaded");
}
const card = cards.nth(i);
await card.click();
// After clicking, the settings step should appear and page shouldn't crash
await page.waitForTimeout(300);
await expect(page).toHaveURL(/\/split/);
await expect(page.locator("body").first()).not.toBeEmpty();
}
});
// ── Watermark: click each available type card ───────────────────────
test("watermark: all available type cards load", async ({ page }) => {
await page.goto("/watermark");
await page.waitForLoadState("domcontentloaded");
const cards = page.locator(".mantine-Card-root");
const firstVisible = await cards
.first()
.isVisible({ timeout: 10_000 })
.catch(() => false);
if (!firstVisible) return;
const count = await cards.count();
for (let i = 0; i < count; i++) {
if (i > 0) {
await page.goto("/watermark");
await page.waitForLoadState("domcontentloaded");
}
await cards.nth(i).click();
await page.waitForTimeout(300);
await expect(page).toHaveURL(/\/watermark/);
await expect(page.locator("body").first()).not.toBeEmpty();
}
});
// ── CertSign: click Auto / Manual mode buttons ─────────────────────────
test.describe("CertSign modes", () => {
for (const mode of CERT_SIGN_MODES) {
test(`certSign sub-mode: ${mode}`, async ({ page }) => {
await page.goto("/cert-sign");
await page.waitForLoadState("domcontentloaded");
// ButtonSelector renders Mantine <Button disabled> — check both visible AND enabled
const label = mode === "auto" ? /auto/i : /manual/i;
const btn = page.getByRole("button", { name: label }).first();
const visible = await btn
.isVisible({ timeout: 5_000 })
.catch(() => false);
if (!visible) return; // tool not rendered (endpoint entirely off)
const enabled = await btn.isEnabled().catch(() => false);
if (!enabled) return; // button rendered but disabled
await btn.click();
await page.waitForTimeout(300);
await expect(page).toHaveURL(/\/cert-sign/);
await expect(page.locator("body").first()).not.toBeEmpty();
});
}
});
// ── Redact: click Automatic / Manual mode buttons ──────────────────────
test.describe("Redact modes", () => {
for (const mode of ["automatic", "manual"]) {
test(`redact sub-mode: ${mode}`, async ({ page }) => {
await page.goto("/redact");
await page.waitForLoadState("domcontentloaded");
const label = mode === "automatic" ? /automatic/i : /manual/i;
const btn = page.getByRole("button", { name: label }).first();
const visible = await btn
.isVisible({ timeout: 5_000 })
.catch(() => false);
if (!visible) return;
const enabled = await btn.isEnabled().catch(() => false);
if (!enabled) return; // e.g. "Automatic" disabled when no files selected
await btn.click();
await page.waitForTimeout(300);
await expect(page).toHaveURL(/\/redact/);
await expect(page.locator("body").first()).not.toBeEmpty();
});
}
});
// ── AddStamp: click Quick Position / Custom Position mode ──────────────
test.describe("AddStamp positioning modes", () => {
for (const mode of ["quick", "custom"]) {
test(`addStamp sub-mode: ${mode} position`, async ({ page }) => {
await page.goto("/add-stamp");
await page.waitForLoadState("domcontentloaded");
const label = mode === "quick" ? /quick/i : /custom/i;
const btn = page.getByRole("button", { name: label }).first();
const visible = await btn
.isVisible({ timeout: 5_000 })
.catch(() => false);
if (!visible) return;
const enabled = await btn.isEnabled().catch(() => false);
if (!enabled) return;
await btn.click();
await page.waitForTimeout(300);
await expect(page).toHaveURL(/\/add-stamp/);
await expect(page.locator("body").first()).not.toBeEmpty();
});
}
});
});
@@ -0,0 +1,130 @@
import { test, expect, type Page } from "@playwright/test";
import {
bypassOnboarding,
mockAppApis,
seedCookieConsent,
} from "@app/tests/helpers/api-stubs";
import { openSettings } from "@app/tests/helpers/ui-helpers";
/**
* Stubbed audit-log UI coverage. Drives the audit dashboard against
* mocked event-list / stats responses so the table-rendering, empty-state
* and event-type-filter flows can be asserted on every PR — no premium
* key required.
*/
async function setUpAdminWithAudit(
page: Page,
audit: {
events?: Array<Record<string, unknown>>;
stats?: Record<string, unknown>;
eventTypes?: string[];
},
) {
await seedCookieConsent(page);
await bypassOnboarding(page);
await page.addInitScript(() => {
localStorage.setItem(
"stirling_jwt",
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.signature",
);
});
await mockAppApis(page, {
enableLogin: true,
user: {
id: 1,
username: "admin",
email: "admin",
roles: ["ROLE_ADMIN"],
},
});
await page.route("**/api/v1/proprietary/ui-data/account", (route) =>
route.fulfill({
json: { username: "admin", email: "admin", isAdmin: true },
}),
);
await page.route("**/api/v1/proprietary/ui-data/audit-events**", (route) =>
route.fulfill({
json: {
content: audit.events ?? [],
totalElements: audit.events?.length ?? 0,
totalPages: 1,
number: 0,
size: 50,
},
}),
);
await page.route("**/api/v1/proprietary/ui-data/audit-stats", (route) =>
route.fulfill({
json: audit.stats ?? {
totalEvents: audit.events?.length ?? 0,
eventsByType: {},
eventsLast24h: 0,
},
}),
);
await page.route("**/api/v1/proprietary/ui-data/audit-event-types", (route) =>
route.fulfill({
json: audit.eventTypes ?? [
"USER_LOGIN",
"USER_LOGOUT",
"FILE_UPLOAD",
"TOOL_RUN",
],
}),
);
await page.goto("/");
}
test.describe("Audit log UI", () => {
test("renders empty-state when audit-events returns no rows", async ({
page,
}) => {
await setUpAdminWithAudit(page, { events: [] });
await openSettings(page);
const auditNav = page.getByText(/^audit/i).first();
if (!(await auditNav.isVisible({ timeout: 3_000 }).catch(() => false))) {
test.skip(true, "Audit section not in this build");
return;
}
await auditNav.click();
// Empty state — either "no events" copy or an empty table renders
const empty = page.getByText(/no .*events|no audit|empty/i).first();
const surface = page.locator("table, [data-testid*='audit' i]").first();
await expect(empty.or(surface)).toBeVisible({ timeout: 10_000 });
});
test("renders rows when audit-events returns mocked data", async ({
page,
}) => {
await setUpAdminWithAudit(page, {
events: [
{
id: 1,
eventType: "USER_LOGIN",
username: "admin",
timestamp: "2026-01-01T12:00:00Z",
metadata: {},
},
{
id: 2,
eventType: "TOOL_RUN",
username: "admin",
timestamp: "2026-01-01T12:05:00Z",
metadata: { tool: "merge" },
},
],
});
await openSettings(page);
const auditNav = page.getByText(/^audit/i).first();
if (!(await auditNav.isVisible({ timeout: 3_000 }).catch(() => false))) {
test.skip(true, "Audit section not in this build");
return;
}
await auditNav.click();
// The username from our mocked rows surfaces
await expect(page.getByText(/USER_LOGIN|admin/i).first()).toBeVisible({
timeout: 10_000,
});
});
});
@@ -0,0 +1,382 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
/**
* Stubbed coverage for the Automate tool's import / export flows.
*
* Imports go through a single "Import" entry on the Create New kebab that
* opens a modal. The modal accepts either a dropped JSON file or pasted
* text, auto-detects the format (Automate vs Folder Scanning), and only
* enables the Import button once the input parses cleanly.
*
* Exports come in two flavours:
* - Per-automation kebab: "Export" (native Automate) + "Export for
* Folder Scanning" (backend pipeline shape).
* - Builder buttons: same two options, side by side.
*/
async function clearAutomationStorage(page: Page): Promise<void> {
await page.addInitScript(() => {
try {
indexedDB.deleteDatabase("StirlingPDF_Automations");
} catch {
// First-page-load case where IDB hasn't been opened yet — safe to ignore.
}
});
}
async function gotoAutomate(page: Page): Promise<void> {
await clearAutomationStorage(page);
await page.goto("/automate");
await page.waitForLoadState("domcontentloaded");
}
async function ensureOnSelection(page: Page): Promise<void> {
const savedHeader = page.getByText("Saved").first();
if (!(await savedHeader.isVisible({ timeout: 2_000 }).catch(() => false))) {
await page
.getByText(/Automation Selection/i)
.first()
.click();
await expect(savedHeader).toBeVisible({ timeout: 5_000 });
}
}
/**
* Hover the entry button matching `entryTitle` to reveal its kebab, then
* click the kebab. Returns once the menu is open.
*/
async function openEntryMenu(
page: Page,
entryTitle: RegExp | string,
): Promise<void> {
const entryButton = page
.getByRole("button", { name: entryTitle as RegExp })
.first();
await entryButton.hover();
const titleSource =
typeof entryTitle === "string" ? entryTitle : entryTitle.source;
const escaped = titleSource.replace(/^\^|\$$/g, "");
const kebab = page
.getByRole("button", {
name: new RegExp(`Open menu for .*${escaped}`, "i"),
})
.first();
await kebab.click();
}
function makeAutomateJson(name: string): string {
return JSON.stringify({
name,
description: "Imported via Playwright",
icon: "CompressIcon",
operations: [
{ operation: "merge", parameters: { generateToc: true } },
{ operation: "compress", parameters: { compressionLevel: 3 } },
],
});
}
function makeFolderScanJson(name: string): string {
return JSON.stringify({
name,
pipeline: [
{
operation: "/api/v1/general/merge-pdfs",
parameters: {
generateToc: true,
fileInput: "automated",
},
},
{
operation: "/api/v1/misc/compress-pdf",
parameters: {
compressionLevel: 3,
fileInput: "automated",
},
},
],
_examples: {
outputDir: "{outputFolder}/{folderName}",
outputFileName: "{filename}-{pipelineName}-{date}-{time}",
},
outputDir: "{outputFolder}",
outputFileName: "{filename}",
});
}
/**
* Open the import modal from the "Create New Automation" kebab.
*/
async function openImportModal(page: Page): Promise<void> {
await openEntryMenu(page, /Create New Automation/i);
await page.getByRole("menuitem", { name: /^Import$/ }).click();
await expect(
page.getByRole("dialog", { name: /Import automation/i }),
).toBeVisible({ timeout: 5_000 });
}
test.describe("12. Automation Page — Import / Export", () => {
test.beforeEach(async ({ page }) => {
await gotoAutomate(page);
await ensureOnSelection(page);
});
test.describe("12.1 Create New kebab — single Import option", () => {
test("kebab on Create New entry exposes a single Import option", async ({
page,
}) => {
await openEntryMenu(page, /Create New Automation/i);
await expect(
page.getByRole("menuitem", { name: /^Import$/ }),
).toBeVisible({ timeout: 5_000 });
// Saved-only options should not appear on Create New.
await expect(
page.getByRole("menuitem", { name: /^Edit$/ }),
).not.toBeVisible();
await expect(
page.getByRole("menuitem", { name: /^Delete$/ }),
).not.toBeVisible();
await expect(
page.getByRole("menuitem", { name: /^Export$/ }),
).not.toBeVisible();
});
});
test.describe("12.2 Import modal — paste flow", () => {
test("pasting valid Automate JSON enables Import and saves the entry", async ({
page,
}) => {
await openImportModal(page);
// Import button starts disabled.
const importBtn = page.getByRole("button", { name: /^Import$/ }).last();
await expect(importBtn).toBeDisabled();
const textarea = page.getByLabel(/Or paste JSON/i);
await textarea.fill(makeAutomateJson("Pasted Automate"));
// Detected-format badge should appear.
await expect(page.getByText(/Automate JSON/).first()).toBeVisible({
timeout: 5_000,
});
await expect(importBtn).toBeEnabled();
await importBtn.click();
// Modal closes and the new entry appears in Saved.
await expect(
page.getByRole("dialog", { name: /Import automation/i }),
).not.toBeVisible({ timeout: 5_000 });
await expect(
page.getByRole("button", { name: /Pasted Automate/i }).first(),
).toBeVisible({ timeout: 10_000 });
// Icon must round-trip into IndexedDB. If it doesn't, the saved-entry
// render falls back to SettingsIcon and looks like a silent picker bug.
const persistedIcon = await page.evaluate(
() =>
new Promise<string | undefined>((resolve) => {
const req = indexedDB.open("StirlingPDF_Automations", 1);
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction("automations", "readonly");
const store = tx.objectStore("automations");
const all = store.getAll();
all.onsuccess = () => {
const match = (
all.result as Array<{
name: string;
icon?: string;
}>
).find((a) => a.name === "Pasted Automate");
resolve(match?.icon);
};
all.onerror = () => resolve(undefined);
};
req.onerror = () => resolve(undefined);
}),
);
expect(persistedIcon).toBe("CompressIcon");
});
test("pasting valid Folder Scanning JSON shows the right format and imports", async ({
page,
}) => {
await openImportModal(page);
const textarea = page.getByLabel(/Or paste JSON/i);
await textarea.fill(makeFolderScanJson("Pasted Folder Scan"));
await expect(page.getByText(/Folder Scanning JSON/).first()).toBeVisible({
timeout: 5_000,
});
await page
.getByRole("button", { name: /^Import$/ })
.last()
.click();
await expect(
page.getByRole("button", { name: /Pasted Folder Scan/i }).first(),
).toBeVisible({ timeout: 10_000 });
});
test("pasting invalid JSON shows an error and keeps Import disabled", async ({
page,
}) => {
await openImportModal(page);
const textarea = page.getByLabel(/Or paste JSON/i);
await textarea.fill("{ this is not valid json");
await expect(page.getByText(/Could not parse/i).first()).toBeVisible({
timeout: 5_000,
});
const importBtn = page.getByRole("button", { name: /^Import$/ }).last();
await expect(importBtn).toBeDisabled();
});
});
test.describe("12.3 Import modal — file drop flow", () => {
test("dropping an Automate JSON file fills the textarea and imports", async ({
page,
}) => {
await openImportModal(page);
// The Mantine Dropzone exposes a hidden file input we can target via
// `setInputFiles`. There's only one file input inside the modal.
const fileInput = page
.getByRole("dialog", { name: /Import automation/i })
.locator('input[type="file"]');
await fileInput.setInputFiles({
name: "dropped.automate.json",
mimeType: "application/json",
buffer: Buffer.from(makeAutomateJson("Dropped Automate")),
});
// The textarea should reflect the dropped content.
await expect(page.getByLabel(/Or paste JSON/i)).toHaveValue(
/Dropped Automate/,
{ timeout: 5_000 },
);
await page
.getByRole("button", { name: /^Import$/ })
.last()
.click();
await expect(
page.getByRole("button", { name: /Dropped Automate/i }).first(),
).toBeVisible({ timeout: 10_000 });
});
test("dropping a Folder Scanning JSON file imports it", async ({
page,
}) => {
await openImportModal(page);
const fileInput = page
.getByRole("dialog", { name: /Import automation/i })
.locator('input[type="file"]');
await fileInput.setInputFiles({
name: "dropped.folder-scan.json",
mimeType: "application/json",
buffer: Buffer.from(makeFolderScanJson("Dropped Folder Scan")),
});
await expect(page.getByText(/Folder Scanning JSON/).first()).toBeVisible({
timeout: 5_000,
});
await page
.getByRole("button", { name: /^Import$/ })
.last()
.click();
await expect(
page.getByRole("button", { name: /Dropped Folder Scan/i }).first(),
).toBeVisible({ timeout: 10_000 });
});
});
test.describe("12.4 Export — per-automation kebab menu", () => {
test("saved entry kebab exposes Export and Export for Folder Scanning", async ({
page,
}) => {
// Seed a saved automation by importing one first.
await openImportModal(page);
await page
.getByLabel(/Or paste JSON/i)
.fill(makeAutomateJson("Export Menu Seed"));
await page
.getByRole("button", { name: /^Import$/ })
.last()
.click();
const seededEntry = page
.getByRole("button", { name: /Export Menu Seed/i })
.first();
await expect(seededEntry).toBeVisible({ timeout: 10_000 });
await openEntryMenu(page, /Export Menu Seed/i);
await expect(
page.getByRole("menuitem", { name: /^Export$/ }),
).toBeVisible({ timeout: 5_000 });
await expect(
page.getByRole("menuitem", { name: /Export for Folder Scanning/i }),
).toBeVisible({ timeout: 5_000 });
await expect(
page.getByRole("menuitem", { name: /^Edit$/ }),
).toBeVisible();
await expect(
page.getByRole("menuitem", { name: /^Delete$/ }),
).toBeVisible();
});
test("clicking 'Export' triggers a .automate.json download", async ({
page,
}) => {
await openImportModal(page);
await page
.getByLabel(/Or paste JSON/i)
.fill(makeAutomateJson("Download Test"));
await page
.getByRole("button", { name: /^Import$/ })
.last()
.click();
await expect(
page.getByRole("button", { name: /Download Test/i }).first(),
).toBeVisible({ timeout: 10_000 });
await openEntryMenu(page, /Download Test/i);
const downloadPromise = page.waitForEvent("download");
await page.getByRole("menuitem", { name: /^Export$/ }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.automate\.json$/);
});
test("clicking 'Export for Folder Scanning' triggers a .folder-scan.json download", async ({
page,
}) => {
await openImportModal(page);
await page
.getByLabel(/Or paste JSON/i)
.fill(makeAutomateJson("Folder Download Test"));
await page
.getByRole("button", { name: /^Import$/ })
.last()
.click();
await expect(
page.getByRole("button", { name: /Folder Download Test/i }).first(),
).toBeVisible({ timeout: 10_000 });
await openEntryMenu(page, /Folder Download Test/i);
const downloadPromise = page.waitForEvent("download");
await page
.getByRole("menuitem", { name: /Export for Folder Scanning/i })
.click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.folder-scan\.json$/);
});
});
});
@@ -0,0 +1,199 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import type { Page } from "@playwright/test";
/**
* Stubbed coverage for the `/automate` super-tool. The Automate UI is a small
* three-step state machine: Selection ↔ Creation ↔ Run. These specs exercise
* the surface area that doesn't require a live backend run — the catalogue,
* the builder save flow, and the per-automation menu.
*
* Live runs (actually executing a pipeline against a real backend) live in
* `src/core/tests/live/automate-chain.spec.ts`.
*/
/**
* IndexedDB persists across tests sharing a worker. Each test wipes the
* automation store before navigating so the "Saved" list starts empty.
*/
async function clearAutomationStorage(page: Page): Promise<void> {
await page.addInitScript(() => {
try {
indexedDB.deleteDatabase("StirlingPDF_Automations");
} catch {
// First-page-load case where IDB hasn't been opened yet — safe to ignore.
}
});
}
async function gotoAutomate(page: Page): Promise<void> {
await clearAutomationStorage(page);
await page.goto("/automate");
await page.waitForLoadState("domcontentloaded");
}
/**
* Land on the Selection step. The auto-goto fixture sometimes parks us on the
* builder if a previous test left state behind; click the section header to
* snap back to a known state.
*/
async function ensureOnSelection(page: Page): Promise<void> {
const savedHeader = page.getByText("Saved").first();
if (!(await savedHeader.isVisible({ timeout: 2_000 }).catch(() => false))) {
await page
.getByText(/Automation Selection/i)
.first()
.click();
await expect(savedHeader).toBeVisible({ timeout: 5_000 });
}
}
/**
* Hover the entry whose title matches `entryTitle` and click its kebab.
* Returns once the kebab menu is open and the dropdown is visible.
*/
async function openEntryMenu(page: Page, entryTitle: RegExp): Promise<void> {
const entry = page.getByRole("button", { name: entryTitle }).first();
await entry.hover();
const kebab = page
.getByRole("button", { name: /^Open menu for / })
.filter({ hasText: "" }) // ActionIcon has no text content
.first();
// Match by accessible-name fragment — the title is interpolated.
const titleHint = (entryTitle.source || "")
.replace(/^\^|\$$/g, "")
.replace(/\\/g, "");
const scopedKebab = page
.getByRole("button", {
name: new RegExp(`Open menu for .*${titleHint}`, "i"),
})
.first();
if (await scopedKebab.isVisible({ timeout: 1_000 }).catch(() => false)) {
await scopedKebab.click();
} else {
await kebab.click();
}
}
test.describe("11. Automation Page", () => {
test.beforeEach(async ({ page }) => {
await gotoAutomate(page);
});
test.describe("11.1 Automation - Suggested Workflows", () => {
test("should display saved and suggested workflows", async ({ page }) => {
// Step 1: Verify the Automation Selection header is present
await expect(page).toHaveURL(/\/automate/);
await expect(
page.getByText(/Automation Selection/i).first(),
).toBeVisible();
await ensureOnSelection(page);
await expect(page.getByText("Saved").first()).toBeVisible();
await expect(
page.getByText(/Create New Automation/i).first(),
).toBeVisible();
await expect(page.getByText("Suggested").first()).toBeVisible();
const suggestedWorkflows = [
/Secure PDF Ingestion/i,
/Pre-publish Sanitization/i,
/Email Preparation/i,
/Security Workflow/i,
/Process Images/i,
];
for (const workflow of suggestedWorkflows) {
await expect(page.getByText(workflow).first()).toBeVisible({
timeout: 5_000,
});
}
});
});
test.describe("11.2 Automation - Create New Automation", () => {
test("should open automation builder when clicking create button", async ({
page,
}) => {
await ensureOnSelection(page);
await page
.getByText(/Create New Automation/i)
.first()
.click();
await expect(page.getByText(/Create Automation/i).first()).toBeVisible({
timeout: 5_000,
});
await expect(page.getByText(/Automation Name/i).first()).toBeVisible({
timeout: 5_000,
});
await expect(page.getByText(/Add Tool/i).first()).toBeVisible();
await expect(
page.getByRole("button", { name: /Save Automation/i }).first(),
).toBeVisible();
});
test("save button stays disabled until a name is filled", async ({
page,
}) => {
await ensureOnSelection(page);
await page
.getByText(/Create New Automation/i)
.first()
.click();
const saveBtn = page
.getByRole("button", { name: /Save Automation/i })
.first();
await expect(saveBtn).toBeVisible({ timeout: 5_000 });
await expect(saveBtn).toBeDisabled();
// Naming alone shouldn't enable save — a tool must be added too.
await page.getByLabel(/Automation Name/i).fill("E2E Builder Smoke");
await expect(saveBtn).toBeDisabled();
});
test("both export buttons render in the builder and are disabled until valid", async ({
page,
}) => {
await ensureOnSelection(page);
await page
.getByText(/Create New Automation/i)
.first()
.click();
const exportAutomate = page
.getByRole("button", { name: /^Export$/ })
.first();
const exportFolderScan = page
.getByRole("button", { name: /Export for Folder Scanning/i })
.first();
await expect(exportAutomate).toBeVisible({ timeout: 5_000 });
await expect(exportFolderScan).toBeVisible({ timeout: 5_000 });
// Same disabled-when-invalid contract as Save — no name + no tool.
await expect(exportAutomate).toBeDisabled();
await expect(exportFolderScan).toBeDisabled();
});
});
test.describe("11.3 Automation - Suggested copy-to-saved", () => {
test("copies a suggested automation into the Saved list", async ({
page,
}) => {
await ensureOnSelection(page);
await openEntryMenu(page, /Secure PDF Ingestion/i);
await page.getByRole("menuitem", { name: /Copy to Saved/i }).click();
// The suggested entry stays in the list and a duplicate appears under
// Saved, so the count of matching texts goes from 1 to 2.
await expect
.poll(async () => page.getByText(/Secure PDF Ingestion/i).count(), {
timeout: 5_000,
})
.toBeGreaterThanOrEqual(2);
});
});
});
@@ -0,0 +1,43 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
import path from "path";
const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
/**
* CertSign is the most complex tool — a 5-step wizard. Stubbed coverage
* focuses on:
* - The page renders cleanly with a PDF uploaded.
* - The cert-file input is reachable.
* - At least one of the Auto/Manual mode buttons exists.
* Deeper step-by-step interaction is brittle to render across builds and
* is best left to vitest unit tests of the underlying step components.
*/
test.describe("CertSign tool — wizard surface", () => {
test("renders, accepts PDF upload, exposes cert input and a mode button", async ({
page,
}) => {
await page.route("**/api/v1/security/cert-sign", (route) =>
route.fulfill({
status: 200,
contentType: "application/pdf",
headers: { "Content-Disposition": 'attachment; filename="signed.pdf"' },
body: Buffer.from("%PDF-1.4 stub\n"),
}),
);
await page.goto("/cert-sign");
await page.waitForLoadState("domcontentloaded");
await uploadFiles(page, SAMPLE_PDF);
await expect(page).toHaveURL(/\/cert-sign/);
await expect(page.locator("body").first()).not.toBeEmpty();
// At least one mode button (Auto or Manual) should be in the DOM
const modeBtn = page
.getByRole("button", { name: /^auto$|^manual$/i })
.first();
await expect(modeBtn).toBeAttached({ timeout: 10_000 });
});
});
@@ -0,0 +1,313 @@
import { test, expect, type Page } from "@playwright/test";
import path from "path";
// ---------------------------------------------------------------------------
// Test fixtures — pre-generated keystores in test-fixtures/certs/
// ---------------------------------------------------------------------------
const CERTS_DIR = path.join(__dirname, "../test-fixtures/certs");
const VALID_P12 = path.join(CERTS_DIR, "valid-test.p12");
const EXPIRED_P12 = path.join(CERTS_DIR, "expired-test.p12");
const NOT_YET_VALID_P12 = path.join(CERTS_DIR, "not-yet-valid-test.p12");
const VALID_JKS = path.join(CERTS_DIR, "valid-test.jks");
// ---------------------------------------------------------------------------
// Stable mock data returned by the mocked backend
// ---------------------------------------------------------------------------
const MOCK_SESSION = {
sessionId: "test-session-id",
documentName: "test-document.pdf",
status: "IN_PROGRESS",
ownerUsername: "test-owner",
message: null,
dueDate: null,
participants: [],
};
const MOCK_PARTICIPANT = {
id: 1,
email: "[email protected]",
name: "Test User",
status: "PENDING",
shareToken: "test-token",
expiresAt: null,
hasCompleted: false,
isExpired: false,
};
// ---------------------------------------------------------------------------
// Helper: mock the participant session and document endpoints
// (called in beforeEach so each test starts from a clean active session)
// ---------------------------------------------------------------------------
async function mockParticipantApis(page: Page) {
// Mock auth so AppProviders/Landing don't redirect to /login
await page.route("**/api/v1/auth/me", (route) =>
route.fulfill({
json: {
id: 1,
username: "testuser",
email: "[email protected]",
roles: ["ROLE_USER"],
},
}),
);
await page.route("**/api/v1/workflow/participant/session**", (route) =>
route.fulfill({ json: MOCK_SESSION }),
);
await page.route("**/api/v1/workflow/participant/details**", (route) =>
route.fulfill({ json: MOCK_PARTICIPANT }),
);
// Minimal stub so the download-document call doesn't throw
await page.route("**/api/v1/workflow/participant/document**", (route) =>
route.fulfill({
status: 200,
contentType: "application/pdf",
body: Buffer.alloc(128),
}),
);
}
// ---------------------------------------------------------------------------
// Helper: set the Mantine <Select> value by opening its dropdown and clicking
// the matching option — Mantine renders a custom combobox, not a native select.
// ---------------------------------------------------------------------------
async function selectCertType(page: Page, label: string) {
await page.getByTestId("cert-type-select").click();
await page.getByRole("option", { name: label }).click();
}
// ---------------------------------------------------------------------------
// Helper: upload a file into the Mantine <FileInput> (hidden native input)
// ---------------------------------------------------------------------------
async function uploadCertFile(page: Page, filePath: string) {
// Mantine FileInput uses a visually hidden <input type="file">.
// We click the visible button to expose it, then set files via the hidden input.
const certFileInput = page.getByTestId("cert-file-input");
await certFileInput.click();
// After click, the file chooser or the hidden input becomes interactive.
// Use the first file input on the page (Mantine places it near the button).
const fileInput = page.locator('input[type="file"]').first();
await fileInput.setInputFiles(filePath);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test.describe("Certificate Validation — ParticipantView", () => {
test.beforeEach(async ({ page }) => {
await mockParticipantApis(page);
});
// 1. Happy path — valid P12
test('valid P12 cert shows green "Certificate valid until" feedback', async ({
page,
}) => {
await page.route(
"**/api/v1/workflow/participant/validate-certificate",
(route) =>
route.fulfill({
json: {
valid: true,
subjectName: "Test Signer",
notAfter: "2027-01-01T00:00:00Z",
notBefore: "2025-01-01T00:00:00Z",
selfSigned: true,
error: null,
},
}),
);
await page.goto("/workflow/sign/test-token");
await page.waitForSelector('[data-testid="submit-signature-button"]');
await uploadCertFile(page, VALID_P12);
await page.getByTestId("cert-password-input").fill("testpass");
// Wait for debounce (600 ms) + network round-trip
const feedback = page.getByTestId("cert-validation-feedback");
await expect(feedback).toContainText("Certificate valid until", {
timeout: 5000,
});
await expect(feedback).toContainText("Test Signer");
});
// 2. Wrong password — red error
test("wrong password shows red error message", async ({ page }) => {
await page.route(
"**/api/v1/workflow/participant/validate-certificate",
(route) =>
route.fulfill({
json: {
valid: false,
subjectName: null,
notAfter: null,
notBefore: null,
selfSigned: false,
error: "Invalid certificate password or corrupt keystore file",
},
}),
);
await page.goto("/workflow/sign/test-token");
await page.waitForSelector('[data-testid="submit-signature-button"]');
await uploadCertFile(page, VALID_P12);
await page.getByTestId("cert-password-input").fill("wrongpass");
const feedback = page.getByTestId("cert-validation-feedback");
await expect(feedback).toContainText("Invalid certificate password", {
timeout: 5000,
});
});
// 3. Expired certificate
test('expired cert shows "Certificate has expired" error', async ({
page,
}) => {
await page.route(
"**/api/v1/workflow/participant/validate-certificate",
(route) =>
route.fulfill({
json: {
valid: false,
subjectName: null,
notAfter: null,
notBefore: null,
selfSigned: false,
error: "Certificate has expired (expired: 2023-01-02 00:00:00 UTC)",
},
}),
);
await page.goto("/workflow/sign/test-token");
await page.waitForSelector('[data-testid="submit-signature-button"]');
await uploadCertFile(page, EXPIRED_P12);
await page.getByTestId("cert-password-input").fill("testpass");
const feedback = page.getByTestId("cert-validation-feedback");
await expect(feedback).toContainText("Certificate has expired", {
timeout: 5000,
});
});
// 4. Not-yet-valid certificate
test('not-yet-valid cert shows "not yet valid" error', async ({ page }) => {
await page.route(
"**/api/v1/workflow/participant/validate-certificate",
(route) =>
route.fulfill({
json: {
valid: false,
subjectName: null,
notAfter: null,
notBefore: null,
selfSigned: false,
error:
"Certificate is not yet valid (valid from: 2027-01-01 00:00:00 UTC)",
},
}),
);
await page.goto("/workflow/sign/test-token");
await page.waitForSelector('[data-testid="submit-signature-button"]');
await uploadCertFile(page, NOT_YET_VALID_P12);
await page.getByTestId("cert-password-input").fill("testpass");
const feedback = page.getByTestId("cert-validation-feedback");
await expect(feedback).toContainText("not yet valid", { timeout: 5000 });
});
// 5. Submit button disabled while validating
test("submit button is disabled while validation is in flight", async ({
page,
}) => {
// Slow response so we can assert the disabled state mid-flight
await page.route(
"**/api/v1/workflow/participant/validate-certificate",
async (route) => {
await new Promise((r) => setTimeout(r, 1500));
await route.fulfill({
json: {
valid: true,
subjectName: "Test Signer",
notAfter: "2027-01-01T00:00:00Z",
notBefore: "2025-01-01T00:00:00Z",
selfSigned: true,
error: null,
},
});
},
);
await page.goto("/workflow/sign/test-token");
await page.waitForSelector('[data-testid="submit-signature-button"]');
await uploadCertFile(page, VALID_P12);
await page.getByTestId("cert-password-input").fill("testpass");
// Shortly after typing, validation is in flight — button must be disabled
const submitBtn = page.getByTestId("submit-signature-button");
await expect(submitBtn).toBeDisabled({ timeout: 3000 });
// After validation completes the button should be re-enabled
await expect(submitBtn).toBeEnabled({ timeout: 5000 });
});
// 6. SERVER type — no validation call made, button stays enabled
test("SERVER cert type skips validation and keeps submit enabled", async ({
page,
}) => {
let validateCalled = false;
await page.route(
"**/api/v1/workflow/participant/validate-certificate",
(route) => {
validateCalled = true;
return route.fulfill({ json: { valid: true } });
},
);
await page.goto("/workflow/sign/test-token");
await page.waitForSelector('[data-testid="submit-signature-button"]');
await selectCertType(page, "Server Certificate (if available)");
// Wait longer than debounce to confirm no call is made
await page.waitForTimeout(1000);
expect(validateCalled).toBe(false);
await expect(page.getByTestId("submit-signature-button")).toBeEnabled();
});
// 7. Bonus — valid JKS keystore
test("valid JKS keystore shows green feedback", async ({ page }) => {
await page.route(
"**/api/v1/workflow/participant/validate-certificate",
(route) =>
route.fulfill({
json: {
valid: true,
subjectName: "JKS Signer",
notAfter: "2027-01-01T00:00:00Z",
notBefore: "2025-01-01T00:00:00Z",
selfSigned: true,
error: null,
},
}),
);
await page.goto("/workflow/sign/test-token");
await page.waitForSelector('[data-testid="submit-signature-button"]');
await selectCertType(page, "JKS Keystore");
await uploadCertFile(page, VALID_JKS);
await page.getByTestId("cert-password-input").fill("jkspass");
const feedback = page.getByTestId("cert-validation-feedback");
await expect(feedback).toContainText("Certificate valid until", {
timeout: 5000,
});
await expect(feedback).toContainText("JKS Signer");
});
});
@@ -0,0 +1,233 @@
/**
* End-to-End Tests for Compare Tool
*
* Regression coverage for the Compare slot auto-fill flow.
*
* Background: when a user has one file in the workbench and picks a second
* file from the "My Files" picker (triggering handleRecentFileSelect), the
* existing selection used to be replaced rather than unioned, so the Original
* slot jumped to the new file and the Edited slot stayed empty. See fix in
* FilesModalContext.handleRecentFileSelect that unions the newly picked IDs
* with the current selection.
*
* The end-to-end invariant this test guards: after two distinct PDFs have been
* added to the workbench through the file modal's add buttons, both Compare
* slots are populated and the Compare action button is enabled.
*
* All backend API calls are mocked via page.route() — no real backend required.
* The Vite dev server must be running (handled by playwright.config.ts webServer).
*/
import { test, expect, type Page } from "@playwright/test";
import path from "path";
import { mockAppApis } from "@app/tests/helpers/api-stubs";
const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
const PDF_A = path.join(FIXTURES_DIR, "compare_sample_a.pdf");
const PDF_B = path.join(FIXTURES_DIR, "compare_sample_b.pdf");
async function navigateToCompare(page: Page) {
await page.locator('[data-tour="tool-button-compare"]').first().click();
await page.waitForSelector('[data-testid="compare-slot-base"]', {
timeout: 5000,
});
}
async function uploadIntoSlot(
page: Page,
role: "base" | "comparison",
filePath: string,
expectedFilename: string,
) {
// Set files directly on the hidden input (always in DOM, no popover needed)
await page
.getByTestId(`compare-slot-${role}-add-input`)
.setInputFiles(filePath);
// The slot becoming filled is the user-visible outcome we actually care
// about. Don't gate on the modal-overlay close animation — Mantine 9's
// Modal can leave the overlay element mounted briefly while transitioning,
// which races with successive uploads in this test and produces flaky
// 10s timeouts. The slot fill assertion below covers the same intent and
// implies the upload completed.
const slot = page.locator(`[data-testid="compare-slot-${role}"]`);
await expect(slot).toHaveAttribute("data-slot-state", "filled", {
timeout: 15000,
});
await expect(slot).toHaveAttribute("data-slot-filename", expectedFilename);
// Wait for the modal overlay to be fully gone before the next interaction
// so click targets in subsequent uploads aren't intercepted.
await page
.locator(".mantine-Modal-overlay")
.waitFor({ state: "detached", timeout: 5000 })
.catch(() => {
/* if it's detached or re-detached during teardown, that's fine */
});
}
test.describe("Compare tool slot selection", () => {
test.beforeEach(async ({ page }) => {
await mockAppApis(page);
await page.goto("/?bypassOnboarding=true");
await page.waitForSelector('[data-tour="tool-button-compare"]', {
timeout: 10000,
});
});
test("uploading a PDF via the Original add button fills the Original slot", async ({
page,
}) => {
await navigateToCompare(page);
const baseSlot = page.locator('[data-testid="compare-slot-base"]');
const comparisonSlot = page.locator(
'[data-testid="compare-slot-comparison"]',
);
await expect(baseSlot).toHaveAttribute("data-slot-state", "empty");
await expect(comparisonSlot).toHaveAttribute("data-slot-state", "empty");
await uploadIntoSlot(page, "base", PDF_A, "compare_sample_a.pdf");
await expect(comparisonSlot).toHaveAttribute("data-slot-state", "empty");
});
test("uploading into both slots fills them and enables the Compare button", async ({
page,
}) => {
await navigateToCompare(page);
// Original slot first
await uploadIntoSlot(page, "base", PDF_A, "compare_sample_a.pdf");
// Edited slot second. This upload goes through handleFileUpload, which
// adds via the internal selectFiles: true path (union with existing
// selection). If the union breaks — as it did in FilesModalContext before
// the fix to handleRecentFileSelect — the Original slot would be clobbered
// and the Edited slot would remain empty.
await uploadIntoSlot(page, "comparison", PDF_B, "compare_sample_b.pdf");
// Both slots must remain populated with their respective files.
const baseSlot = page.locator('[data-testid="compare-slot-base"]');
const comparisonSlot = page.locator(
'[data-testid="compare-slot-comparison"]',
);
await expect(baseSlot).toHaveAttribute("data-slot-state", "filled");
await expect(baseSlot).toHaveAttribute(
"data-slot-filename",
"compare_sample_a.pdf",
);
await expect(comparisonSlot).toHaveAttribute("data-slot-state", "filled");
await expect(comparisonSlot).toHaveAttribute(
"data-slot-filename",
"compare_sample_b.pdf",
);
// Compare button should be enabled now that both slots are set.
const compareButton = page.getByRole("button", { name: "Compare" });
await expect(compareButton).toBeEnabled();
});
test("picking a saved file from the FileSelectorPicker popover fills the comparison slot", async ({
page,
}) => {
// Guards the FileSelectorPicker "Saved files" tab flow. Steps:
// 1. Upload PDF_A into the base slot → stored in IndexedDB and slot fills.
// 2. Clear the base slot (X button) so both slots are empty again.
// 3. Upload PDF_B into the base slot.
// 4. Open the comparison slot's FileSelectorPicker and pick PDF_A from
// the Saved files tab.
// Result: base = PDF_B, comparison = PDF_A.
await navigateToCompare(page);
// Step 1 — upload PDF_A and confirm base slot fills (also persists to IndexedDB).
await uploadIntoSlot(page, "base", PDF_A, "compare_sample_a.pdf");
// Step 2 — clear the base slot via the X button so both slots reset.
await page
.locator('[data-testid="compare-slot-base"]')
.getByRole("button", { name: "Remove file" })
.click();
await expect(
page.locator('[data-testid="compare-slot-base"]'),
).toHaveAttribute("data-slot-state", "empty");
// Step 3 — upload PDF_B into base. IndexedDB now has both A and B.
await uploadIntoSlot(page, "base", PDF_B, "compare_sample_b.pdf");
await expect(
page.locator('[data-testid="compare-slot-comparison"]'),
).toHaveAttribute("data-slot-state", "empty");
// Step 4 — open the comparison slot's FileSelectorPicker and pick PDF_A
// from the Saved files tab.
await page.locator('[data-testid="compare-slot-comparison-add"]').click();
// Wait for the popover dropdown to open (withinPortal → attaches to body).
await page.waitForSelector('[aria-pressed="true"]', {
state: "visible",
timeout: 5000,
});
// The list should contain PDF_A (PDF_B is excluded as it's in the base slot).
const pdfARow = page.locator("text=compare_sample_a.pdf").first();
await pdfARow.click();
// Comparison slot must fill with PDF_A; base slot stays PDF_B.
const baseSlot = page.locator('[data-testid="compare-slot-base"]');
const comparisonSlot = page.locator(
'[data-testid="compare-slot-comparison"]',
);
await expect(comparisonSlot).toHaveAttribute("data-slot-state", "filled", {
timeout: 10000,
});
await expect(comparisonSlot).toHaveAttribute(
"data-slot-filename",
"compare_sample_a.pdf",
);
await expect(baseSlot).toHaveAttribute("data-slot-state", "filled");
await expect(baseSlot).toHaveAttribute(
"data-slot-filename",
"compare_sample_b.pdf",
);
const compareButton = page.getByRole("button", { name: "Compare" });
await expect(compareButton).toBeEnabled();
});
test("Clear selected empties both slots", async ({ page }) => {
await navigateToCompare(page);
await uploadIntoSlot(page, "base", PDF_A, "compare_sample_a.pdf");
await uploadIntoSlot(page, "comparison", PDF_B, "compare_sample_b.pdf");
// Sanity: both slots filled before the clear.
await expect(
page.locator('[data-testid="compare-slot-base"]'),
).toHaveAttribute("data-slot-state", "filled");
await expect(
page.locator('[data-testid="compare-slot-comparison"]'),
).toHaveAttribute("data-slot-state", "filled");
// Open the Clear confirmation modal and confirm.
await page.getByRole("button", { name: "Clear selected" }).click();
// Wait for the confirmation modal to appear before clicking confirm.
await page.waitForSelector(".mantine-Modal-overlay", {
state: "visible",
timeout: 5000,
});
await page
.locator('[role="dialog"]')
.getByRole("button", { name: "Clear Selected" })
.click();
// Both slots must be empty after clearing.
await page.waitForSelector(
'[data-testid="compare-slot-base"][data-slot-state="empty"]',
{ timeout: 10000 },
);
await expect(
page.locator('[data-testid="compare-slot-comparison"]'),
).toHaveAttribute("data-slot-state", "empty");
});
});
@@ -0,0 +1,28 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
test.describe("8. Compress Tool", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/compress");
await page.waitForLoadState("domcontentloaded");
});
test.describe("8.1 Compress - Page Structure", () => {
test("should load correctly with disabled action button", async ({
page,
}) => {
// Step 1: Verify the page shows the Files and Settings steps
await expect(page.getByText("Files").first()).toBeVisible();
await expect(page.getByText("Settings").first()).toBeVisible();
// Step 2: Verify the "Compress" button is present and disabled
const compressButton = page
.getByRole("button", { name: /compress/i })
.first();
await expect(compressButton).toBeVisible();
await expect(compressButton).toBeDisabled();
// Step 3: Verify the file upload area is displayed
await expect(page.getByText(/upload/i).first()).toBeVisible();
});
});
});
@@ -0,0 +1,162 @@
/**
* End-to-End Tests for Convert Tool
*
* All backend API calls are mocked via page.route() — no real backend required.
* The Vite dev server must be running (handled by playwright.config.ts webServer).
*/
import { test, expect, type Page } from "@playwright/test";
import path from "path";
import { mockAppApis } from "@app/tests/helpers/api-stubs";
const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
// ---------------------------------------------------------------------------
// Helper: dismiss the tour tooltip that can intercept clicks on firefox/webkit
// ---------------------------------------------------------------------------
async function dismissTourTooltip(page: Page) {
const closeBtn = page.getByRole("button", { name: /close tooltip/i }).first();
if (await closeBtn.isVisible({ timeout: 500 }).catch(() => false)) {
await closeBtn.click();
}
}
// ---------------------------------------------------------------------------
// Helper: upload a file through the Files modal
// Uses the HiddenFileInput (data-testid="file-input") which has the correct
// onChange handler. Waits for the modal to auto-close after upload.
// ---------------------------------------------------------------------------
async function uploadFile(page: Page, filePath: string) {
await page.getByTestId("files-button").click();
await page.waitForSelector(".mantine-Modal-overlay", {
state: "visible",
timeout: 5000,
});
await page.locator('[data-testid="file-input"]').setInputFiles(filePath);
// Modal auto-closes after file is selected
await page.waitForSelector(".mantine-Modal-overlay", {
state: "hidden",
timeout: 10000,
});
}
// ---------------------------------------------------------------------------
// Helper: navigate to the Convert tool panel
// Tools use data-tour="tool-button-{key}" anchors in the ToolPanel.
// After clicking, the URL changes to /convert and the settings appear.
// ---------------------------------------------------------------------------
async function navigateToConvert(page: Page) {
await page.locator('[data-tour="tool-button-convert"]').click();
await page.waitForSelector('[data-testid="convert-from-dropdown"]', {
timeout: 5000,
});
}
// ---------------------------------------------------------------------------
// Helper: select the TO format in the convert dropdown
// The FROM format is auto-detected from the uploaded file (e.g. PDF → "Document (PDF)").
// Opening the TO dropdown renders format-option-{value} buttons in a portal.
// ---------------------------------------------------------------------------
async function selectToFormat(page: Page, toValue: string) {
await page.getByTestId("convert-to-dropdown").click();
await page.getByTestId(`format-option-${toValue}`).click();
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test.describe("Convert Tool", () => {
test.beforeEach(async ({ page }) => {
await mockAppApis(page);
await page.goto("/?bypassOnboarding=true");
await page.waitForSelector('[data-testid="files-button"]', {
timeout: 10000,
});
});
test("convert button is disabled before a TO format is selected", async ({
page,
}) => {
await uploadFile(page, SAMPLE_PDF);
await navigateToConvert(page);
// FROM is auto-detected as PDF; TO not selected → button visible but disabled
const convertBtn = page.getByTestId("convert-button");
await expect(convertBtn).toBeVisible({ timeout: 3000 });
await expect(convertBtn).toBeDisabled();
});
test("successful PDF to PNG conversion shows download option", async ({
page,
}) => {
// Minimal valid PNG header (8 bytes signature + padding)
const fakePng = Buffer.from([
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
...Array(504).fill(0),
]);
await page.route("**/api/v1/convert/pdf/img", (route) =>
route.fulfill({
status: 200,
contentType: "image/png",
headers: { "Content-Disposition": 'attachment; filename="sample.png"' },
body: fakePng,
}),
);
await uploadFile(page, SAMPLE_PDF);
await navigateToConvert(page);
await selectToFormat(page, "png");
await dismissTourTooltip(page);
await page.getByTestId("convert-button").click();
await expect(page.getByTestId("download-result-button")).toBeVisible({
timeout: 10000,
});
});
test("conversion API error shows error notification", async ({ page }) => {
await page.route("**/api/v1/convert/pdf/img", (route) =>
route.fulfill({
status: 500,
contentType: "text/plain",
body: "Internal server error: conversion failed",
}),
);
await uploadFile(page, SAMPLE_PDF);
await navigateToConvert(page);
await selectToFormat(page, "png");
await dismissTourTooltip(page);
await page.getByTestId("convert-button").click();
// Mantine Notification renders as role="alert"
await expect(page.getByRole("alert").first()).toBeVisible({
timeout: 5000,
});
});
test("convert button becomes enabled after selecting a valid TO format", async ({
page,
}) => {
await uploadFile(page, SAMPLE_PDF);
await navigateToConvert(page);
// Before selecting TO format — button visible but disabled
const convertBtn = page.getByTestId("convert-button");
await expect(convertBtn).toBeVisible({ timeout: 3000 });
await expect(convertBtn).toBeDisabled();
// After selecting PNG as TO format — button enabled
await selectToFormat(page, "png");
await expect(convertBtn).toBeEnabled({ timeout: 3000 });
});
});
@@ -0,0 +1,103 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
test.describe("18. Cookie Preferences", () => {
test.describe("18.1 Cookie Banner", () => {
test("should open and configure cookie preferences from footer", async ({
page,
}) => {
// Step 1: Locate the "Cookie Preferences" button in the footer
const cookieButton = page
.locator(
'#cookieBanner, button:has-text("Preferensi Cookie"), button:has-text("Cookie Preferences")',
)
.first();
await page.waitForTimeout(1000);
const isVisible = await cookieButton.isVisible().catch(() => false);
if (!isVisible) {
test.skip(
true,
"Cookie Preferences button not visible — analytics may be disabled",
);
return;
}
await expect(cookieButton).toBeVisible();
// Step 2: Click the Cookie Preferences button
await cookieButton.click({ force: true });
// Step 3: Verify the cookie consent dialog opens
// The CookieConsent library renders inside #cc-main
const ccMain = page.locator("#cc-main");
const consentDialog = ccMain.getByRole("dialog").first();
await expect(consentDialog).toBeVisible({ timeout: 5000 });
// Step 4: Verify options are available
// The initial consent view shows: "Oke", "Tidak, terima kasih", "Kelola preferensi"
const okeBtn = ccMain
.locator('button:has-text("Oke"), button:has-text("OK")')
.first();
const noThanksBtn = ccMain
.locator(
'button:has-text("Tidak, terima kasih"), button:has-text("No Thanks")',
)
.first();
const manageBtn = ccMain
.locator(
'button:has-text("Kelola preferensi"), button:has-text("Manage preferences")',
)
.first();
const hasOke = await okeBtn.isVisible().catch(() => false);
const hasNoThanks = await noThanksBtn.isVisible().catch(() => false);
const hasManage = await manageBtn.isVisible().catch(() => false);
expect(hasOke || hasNoThanks || hasManage).toBe(true);
// Step 5: Click "Kelola preferensi" to open the detailed preferences panel
if (hasManage) {
await manageBtn.click();
await page.waitForTimeout(500);
// Verify the preferences panel shows cookie categories
const necessaryCategory = ccMain
.locator(
"text=/Cookie yang Sangat Diperlukan|Strictly Necessary|necessary/i",
)
.first();
const analyticsCategory = ccMain
.locator("text=/Analitik|Analytics/i")
.first();
await expect(
necessaryCategory.or(analyticsCategory).first(),
).toBeVisible({ timeout: 3000 });
// Click "Terima semua" (Accept all) or "Simpan preferensi" (Save preferences) to close
const acceptAllBtn = ccMain
.locator(
'button:has-text("Terima semua"), button:has-text("Accept all")',
)
.first();
const savePrefBtn = ccMain
.locator(
'button:has-text("Simpan preferensi"), button:has-text("Save preferences")',
)
.first();
if (await acceptAllBtn.isVisible().catch(() => false)) {
await acceptAllBtn.click();
} else if (await savePrefBtn.isVisible().catch(() => false)) {
await savePrefBtn.click();
}
} else if (hasOke) {
await okeBtn.click();
} else if (hasNoThanks) {
await noThanksBtn.click();
}
// Step 6: Verify the dialog is dismissed
await expect(consentDialog).toBeHidden({ timeout: 5000 });
});
});
});
@@ -0,0 +1,191 @@
/**
* End-to-End Tests for Encrypted PDF Password Prompting
*
* Tests the EncryptedPdfUnlockModal flow when uploading password-protected PDFs.
* All backend API calls are mocked via page.route() — no real backend required.
*
* Coverage trimmed to 5 high-value cases:
* 1. Modal renders with the expected title/inputs/buttons.
* 2. Successful unlock removes the modal and shows the success toast.
* 3. Wrong password keeps the modal open with an inline error.
* 4. Pressing Enter in the password field triggers unlock.
* 5. Multiple encrypted files surface the "Use for all" affordance and
* unlocking via that path resolves the modal.
*
* Removed previously: input-disabled-when-empty, input-enabled-after-fill,
* skip-button-closes, normal-PDF-doesn't-prompt, single-file-hides-use-for-all,
* unlock-all-wrong-password — all transitively covered or low-value.
*/
import { test, expect, type Page } from "@playwright/test";
import path from "path";
import fs from "fs";
import { mockAppApis } from "@app/tests/helpers/api-stubs";
const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
const ENCRYPTED_PDF = path.join(FIXTURES_DIR, "encrypted.pdf");
const FAKE_UNLOCKED_PDF = Buffer.from(
"%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" +
"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" +
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\n" +
"xref\n0 4\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n" +
"0000000115 00000 n \ntrailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n190\n%%EOF",
);
function mockRemovePasswordSuccess(page: Page) {
return page.route("**/api/v1/security/remove-password", (route) =>
route.fulfill({
status: 200,
contentType: "application/pdf",
headers: {
"Content-Disposition": 'attachment; filename="encrypted.pdf"',
},
body: FAKE_UNLOCKED_PDF,
}),
);
}
function mockRemovePasswordWrongPassword(page: Page) {
return page.route("**/api/v1/security/remove-password", (route) =>
route.fulfill({
status: 400,
contentType: "application/problem+json",
body: JSON.stringify({
type: "/errors/pdf-password",
title: "PDF password incorrect",
status: 400,
detail:
"The PDF is passworded and requires the correct password to open.",
}),
}),
);
}
async function uploadEncryptedFile(page: Page, filePath: string) {
await page.getByTestId("files-button").click();
await page.waitForSelector(".mantine-Modal-overlay", {
state: "visible",
timeout: 5000,
});
await page.locator('[data-testid="file-input"]').setInputFiles(filePath);
}
const MODAL_TITLE = "Remove password to continue";
const PASSWORD_PLACEHOLDER = "Enter the PDF password";
const UNLOCK_BUTTON_TEXT = "Unlock & Continue";
test.describe.configure({ mode: "serial" });
test.describe("Encrypted PDF Unlock Modal", () => {
test.beforeEach(async ({ page }) => {
await mockAppApis(page);
await page.goto("/?bypassOnboarding=true");
await page.waitForSelector('[data-testid="files-button"]', {
timeout: 10000,
});
const tooltip = page.locator('button:has-text("Close tooltip")');
if (await tooltip.isVisible({ timeout: 1000 }).catch(() => false)) {
await tooltip.click();
}
});
test("modal renders with title, password input, and action buttons", async ({
page,
}) => {
await uploadEncryptedFile(page, ENCRYPTED_PDF);
await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 });
await expect(page.getByPlaceholder(PASSWORD_PLACEHOLDER)).toBeVisible();
await expect(
page.getByRole("button", { name: UNLOCK_BUTTON_TEXT }),
).toBeVisible();
});
test("successful unlock removes the modal and shows success alert", async ({
page,
}) => {
await mockRemovePasswordSuccess(page);
await uploadEncryptedFile(page, ENCRYPTED_PDF);
await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 });
await page.getByPlaceholder(PASSWORD_PLACEHOLDER).fill("testpass123");
await page.getByRole("button", { name: UNLOCK_BUTTON_TEXT }).click();
await expect(page.getByText(MODAL_TITLE)).toBeHidden({ timeout: 10000 });
await expect(
page.getByText("Password removed", { exact: true }),
).toBeVisible({ timeout: 5000 });
});
test("incorrect password keeps the modal open with an inline error", async ({
page,
}) => {
await mockRemovePasswordWrongPassword(page);
await uploadEncryptedFile(page, ENCRYPTED_PDF);
await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 });
await page.getByPlaceholder(PASSWORD_PLACEHOLDER).fill("wrongpassword");
await page.getByRole("button", { name: UNLOCK_BUTTON_TEXT }).click();
await expect(page.getByText("Incorrect password")).toBeVisible({
timeout: 5000,
});
await expect(page.getByText(MODAL_TITLE)).toBeVisible();
});
test("pressing Enter in the password field triggers unlock", async ({
page,
}) => {
await mockRemovePasswordSuccess(page);
await uploadEncryptedFile(page, ENCRYPTED_PDF);
await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 10000 });
const passwordInput = page.getByPlaceholder(PASSWORD_PLACEHOLDER);
await passwordInput.fill("testpass123");
await passwordInput.press("Enter");
await expect(page.getByText(MODAL_TITLE)).toBeHidden({ timeout: 10000 });
});
test("multi-file unlock-all closes the modal after one password entry", async ({
page,
}) => {
await mockRemovePasswordSuccess(page);
await page.getByTestId("files-button").click();
await page.waitForSelector(".mantine-Modal-overlay", {
state: "visible",
timeout: 5000,
});
await page.locator('[data-testid="file-input"]').setInputFiles([
{
name: "encrypted-a.pdf",
mimeType: "application/pdf",
buffer: fs.readFileSync(ENCRYPTED_PDF),
},
{
name: "encrypted-b.pdf",
mimeType: "application/pdf",
buffer: fs.readFileSync(ENCRYPTED_PDF),
},
]);
await expect(page.getByText(MODAL_TITLE)).toBeVisible({ timeout: 15000 });
// The "Use for all" affordance only appears once BOTH files have been
// detected as encrypted. PDF.js encryption probing runs per-file and
// can lag the modal opening (which fires as soon as the first file
// surfaces a password prompt). A 10s timeout was occasionally too tight
// on heavily-loaded CI runners — bump to 20s.
const unlockAllBtn = page.getByRole("button", { name: /Use for all/ });
await expect(unlockAllBtn).toBeVisible({ timeout: 20000 });
await page.getByPlaceholder(PASSWORD_PLACEHOLDER).fill("testpass123");
await unlockAllBtn.click();
await expect(page.getByText(MODAL_TITLE)).toBeHidden({ timeout: 15000 });
});
});
@@ -0,0 +1,36 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
import path from "path";
const FIXTURES_DIR = path.join(__dirname, "../test-fixtures");
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
/**
* Files uploaded on one tool page should remain in the workbench when the
* user navigates to a different tool. This is FileContext behaviour and
* easy to break with a stale-effect or unmount-clear bug.
*/
test.describe("File state persists across tool navigation", () => {
test("file uploaded on /merge survives navigation to /split", async ({
page,
}) => {
await uploadFiles(page, SAMPLE_PDF);
// Sanity: the file picker now lists the upload
await page.getByTestId("files-button").click();
await expect(page.getByText(/sample\.pdf/i).first()).toBeVisible({
timeout: 5_000,
});
await page.keyboard.press("Escape");
// Navigate to /split
await page.goto("/split");
await page.waitForLoadState("domcontentloaded");
// Re-open the files modal — sample.pdf must still be there
await page.getByTestId("files-button").click();
await expect(page.getByText(/sample\.pdf/i).first()).toBeVisible({
timeout: 5_000,
});
});
});
@@ -0,0 +1,119 @@
import { test, expect, type Page } from "@playwright/test";
import { mockAppApis, seedCookieConsent } from "@app/tests/helpers/api-stubs";
/**
* The InitialOnboardingModal opens to the FirstLoginSlide when the
* proprietary `/account` endpoint reports `changeCredsFlag: true`.
*
* IMPORTANT: this spec uses raw @playwright/test rather than the shared
* stub-test-base fixture because the fixture sets onboarding::completed
* in localStorage, which suppresses the very modal we're testing.
*/
async function setUpFirstLoginPage(page: Page) {
await seedCookieConsent(page);
// Seed a JWT in localStorage so hasAuthToken() returns true and the
// orchestrator's checkFirstLogin effect actually fires.
await page.addInitScript(() => {
localStorage.setItem(
"stirling_jwt",
// Minimal JWT-shape value — the proprietary client only checks presence
// before deciding to call /account, then trusts the API mocks.
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.signature",
);
});
// Note: deliberately NOT calling skipOnboarding here.
await mockAppApis(page, {
enableLogin: true,
user: {
id: 1,
username: "admin",
email: "admin",
roles: ["ROLE_ADMIN"],
},
});
await page.route("**/api/v1/proprietary/ui-data/account", (route) =>
route.fulfill({
json: {
username: "admin",
email: "admin",
changeCredsFlag: true,
isAdmin: true,
isSso: false,
isMfaEnabled: false,
mfaRequired: false,
settings: { mfaRequired: false },
},
}),
);
await page.route("**/api/v1/proprietary/ui-data/login", (route) =>
route.fulfill({
json: {
enabled: true,
loginMethod: "all",
showDefaultCredentials: true,
},
}),
);
}
test.describe("First-login forced password change modal", () => {
test("modal renders with FirstLoginSlide content", async ({ page }) => {
await setUpFirstLoginPage(page);
await page.goto("/");
await expect(
page.getByText(/must change your password|set your password/i).first(),
).toBeVisible({ timeout: 15_000 });
const submit = page.getByRole("button", { name: /change password/i });
await expect(submit).toBeDisabled();
});
test("Escape does not close the modal (non-dismissible)", async ({
page,
}) => {
await setUpFirstLoginPage(page);
await page.goto("/");
await expect(
page.getByText(/must change your password|set your password/i).first(),
).toBeVisible({ timeout: 15_000 });
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
await expect(
page.getByText(/must change your password|set your password/i).first(),
).toBeVisible();
});
test("submitting valid passwords calls change-password endpoint", async ({
page,
}) => {
let captured = false;
await setUpFirstLoginPage(page);
await page.route(
"**/api/v1/user/change-password-on-login",
async (route) => {
captured = true;
await route.fulfill({ status: 200, body: "" });
},
);
await page.goto("/");
await expect(
page.getByText(/must change your password|set your password/i).first(),
).toBeVisible({ timeout: 15_000 });
await page
.getByPlaceholder(/enter new password.*characters/i)
.fill("adminadmin");
await page.getByPlaceholder(/re-enter new password/i).fill("adminadmin");
const submit = page.getByRole("button", { name: /change password/i });
await expect(submit).toBeEnabled();
await submit.click();
await expect.poll(() => captured, { timeout: 5_000 }).toBe(true);
});
});
@@ -0,0 +1,64 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
test.describe("13. Language / Localization", () => {
test.use({
stubOptions: {
languages: [
"en-GB",
"en-US",
"id-ID",
"de-DE",
"es-ES",
"fr-FR",
"pt-BR",
],
},
});
test.describe("13.1 Language Switcher", () => {
test("should switch UI text when language is changed", async ({ page }) => {
// Step 1: Locate the Language selector.
// The language selector is accessed via Settings > General.
let languageButton = page
.locator('[data-testid="language-selector-button"]')
.first();
if (
!(await languageButton.isVisible({ timeout: 1000 }).catch(() => false))
) {
// Open Settings to access the language selector in the General section
await page.locator('[data-testid="config-button"]').first().click();
await page
.locator(".mantine-Modal-content")
.first()
.waitFor({ state: "visible", timeout: 5000 });
languageButton = page
.locator('[data-testid="language-selector-button"]')
.first();
}
// Step 2: Click the language button
await expect(languageButton).toBeVisible({ timeout: 5000 });
await languageButton.click();
// Step 3: Verify a language selection menu opens
const languageMenu = page.locator(".mantine-Menu-dropdown").first();
await expect(languageMenu).toBeVisible({ timeout: 5000 });
// Step 4: Select English
const englishOption = languageMenu.getByText(/english/i).first();
if (await englishOption.isVisible({ timeout: 3000 }).catch(() => false)) {
await englishOption.click();
// Step 5: Wait for page reload (language change triggers window.location.reload())
await page.waitForLoadState("domcontentloaded");
// Step 6: Verify the UI text is in English
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible({
timeout: 10000,
});
}
});
});
});
@@ -0,0 +1,107 @@
import { test, expect, type Page } from "@playwright/test";
import {
bypassOnboarding,
mockAppApis,
seedCookieConsent,
} from "@app/tests/helpers/api-stubs";
import { openSettings } from "@app/tests/helpers/ui-helpers";
/**
* Stubbed license-state matrix. The admin Plan/Premium settings section
* renders different banners depending on what `/admin/license-info`
* returns (no-key / valid normal / valid enterprise / disabled). This
* spec drives each state via mocked responses so frontend regressions
* surface even on PRs without a real license key.
*/
async function setUpAdminPage(
page: Page,
licenseInfo: Record<string, unknown>,
) {
await seedCookieConsent(page);
await bypassOnboarding(page);
await page.addInitScript(() => {
localStorage.setItem(
"stirling_jwt",
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.signature",
);
});
await mockAppApis(page, {
enableLogin: true,
user: {
id: 1,
username: "admin",
email: "admin",
roles: ["ROLE_ADMIN"],
},
});
// Admin role surfaces the Plan/License section in settings
await page.route("**/api/v1/proprietary/ui-data/account", (route) =>
route.fulfill({
json: {
username: "admin",
email: "admin",
changeCredsFlag: false,
isAdmin: true,
},
}),
);
await page.route("**/api/v1/admin/license-info", (route) =>
route.fulfill({ json: licenseInfo }),
);
await page.goto("/");
}
test.describe("Admin license panel — state matrix", () => {
test("ENTERPRISE license renders without invalid/expired warnings", async ({
page,
}) => {
await setUpAdminPage(page, {
licenseType: "ENTERPRISE",
enabled: true,
maxUsers: 1000,
hasKey: true,
licenseKey: "MOCK-LICENSE-KEY",
});
await openSettings(page);
await expect(
page.getByText(/invalid license|expired|trial.*expired|key required/i),
).toHaveCount(0);
});
test("no-key state opens the settings dialog cleanly (license panel reachable)", async ({
page,
}) => {
await setUpAdminPage(page, {
licenseType: "NORMAL",
enabled: true,
maxUsers: 1,
hasKey: false,
});
const dialog = await openSettings(page);
// The dialog renders without an error / blank state. A key-required
// banner is one acceptable indicator but builds vary; the meaningful
// assertion is that we got into settings without a crash and there
// is no INVALID/EXPIRED warning surface.
await expect(dialog).toBeVisible();
await expect(
page.getByText(/invalid license|expired|trial.*expired/i),
).toHaveCount(0);
});
test("disabled premium (premium.enabled=false) hides license panel content", async ({
page,
}) => {
await setUpAdminPage(page, {
licenseType: "NORMAL",
enabled: false,
maxUsers: 1,
hasKey: false,
});
await openSettings(page);
// No invalid/expired warnings should leak through when premium is off
await expect(
page.getByText(/invalid license|expired|trial.*expired/i),
).toHaveCount(0);
});
});
@@ -0,0 +1,158 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import { mockAppApis, seedCookieConsent } from "@app/tests/helpers/api-stubs";
test.describe("2. Main Dashboard / Home Page", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
test.describe("2.1 Dashboard Layout and Tool Categories", () => {
test("should display all navigation elements and tool categories", async ({
page,
}) => {
await expect(
page.locator('[data-testid="files-button"]').first(),
).toBeVisible({ timeout: 10000 });
await expect(
page.locator('[data-testid="config-button"]').first(),
).toBeVisible();
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
await expect(
page.getByRole("button", { name: /fullscreen|sidebar/i }).first(),
).toBeVisible();
const categories = [
/Recommended/,
/Signing/,
/Document Security/,
/Verification/,
/Document Review/,
/Page Formatting/,
/Extraction/,
/Removal/,
/Automation/,
/General/,
/Advanced Formatting/,
/Developer Tools/,
];
for (const category of categories) {
await expect(page.getByText(category).first()).toBeVisible({
timeout: 10000,
});
}
});
});
test.describe("2.2 Dashboard - Recommended Tools", () => {
test("should display recommended tools and navigate to merge", async ({
page,
}) => {
const recommendedTools = [
/PDF Text Editor/i,
/Merge/i,
/Compare/i,
/Compress/i,
/Convert/i,
/Redact/i,
];
for (const tool of recommendedTools) {
await expect(page.getByText(tool).first()).toBeVisible({
timeout: 10000,
});
}
await page
.getByText(/^Merge$/i)
.first()
.click();
await expect(page).toHaveURL(/\/merge/, { timeout: 10000 });
await page.goto("/");
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
});
});
test.describe("2.3 Dashboard - File Upload Area", () => {
test("should display file upload area with buttons", async ({ page }) => {
const uploadButton = page
.getByRole("button", { name: /upload|add files/i })
.first();
await expect(uploadButton).toBeVisible({ timeout: 10000 });
});
});
test.describe("2.4 Dashboard - Footer Links", () => {
test("should display footer links with correct URLs", async ({ page }) => {
await expect(page.getByText("Survey").first()).toBeVisible({
timeout: 10000,
});
await expect(page.getByText("Privacy Policy").first()).toBeVisible({
timeout: 10000,
});
await expect(page.getByText(/Terms/i).first()).toBeVisible({
timeout: 10000,
});
await expect(page.getByText("Discord").first()).toBeVisible({
timeout: 10000,
});
await expect(page.getByText("GitHub").first()).toBeVisible({
timeout: 10000,
});
await expect(page.getByText("Accessibility").first()).toBeVisible({
timeout: 10000,
});
const githubLink = page
.locator('a[href*="github.com/Stirling-Tools/Stirling-PDF"]')
.first();
await expect(githubLink).toBeVisible();
const discordLink = page
.locator('a[href*="discord.gg/Cn8pWhQRxZ"]')
.first();
await expect(discordLink).toBeVisible();
});
});
test.describe("2.5 Dashboard - Welcome Dialog for fresh users", () => {
test("should show welcome dialog when onboarding flags are unset", async ({
browser,
}) => {
// Fresh context — no localStorage flags, so the onboarding modal should appear.
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
});
const page = await context.newPage();
await seedCookieConsent(page);
await mockAppApis(page);
await page.goto("/");
const welcomeDialog = page.getByText(/welcome/i).first();
await expect(welcomeDialog).toBeVisible({ timeout: 10000 });
for (let i = 0; i < 5; i++) {
const hasOverlay = await page
.locator(".mantine-Modal-overlay, .mantine-Overlay-root")
.first()
.isVisible()
.catch(() => false);
if (!hasOverlay) break;
await page.keyboard.press("Escape");
await page.waitForTimeout(500);
}
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible({
timeout: 10000,
});
await context.close();
});
});
});
@@ -0,0 +1,56 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
test.describe("5. Merge Tool", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/merge");
await page.waitForLoadState("domcontentloaded");
});
test.describe("5.1 Merge - Page Structure", () => {
test("should display the correct multi-step workflow", async ({ page }) => {
// Step 1: Verify the page title/tool shows "Merge"
await expect(
page.locator("text=/merge|menggabungkan/i").first(),
).toBeVisible();
// Step 2: Verify a 3-step workflow is displayed (Files, Sort Files, Settings)
await expect(page.locator("text=/^Files$|^File$/i").first()).toBeVisible({
timeout: 10000,
});
await expect(
page.locator("text=/Sort Files|Sort/i").first(),
).toBeVisible();
await expect(
page.locator("text=/Settings|Pengaturan/i").first(),
).toBeVisible();
// Step 3: Verify the "Merge" button is present and disabled
const mergeButton = page
.getByRole("button", { name: /merge|gabungkan/i })
.first();
await expect(mergeButton).toBeVisible();
await expect(mergeButton).toBeDisabled();
// Step 4: Verify the file upload drop zone is visible
// (input[type="file"] is excluded — it is hidden in the FileSidebar)
await expect(
page.locator('[class*="upload"], [class*="dropzone"]').first(),
).toBeVisible();
});
});
test.describe("5.2 Merge - Submit Without Files", () => {
test("should not allow merge without uploading files", async ({ page }) => {
// Step 1: Verify the "Merge" button is disabled
const mergeButton = page
.getByRole("button", { name: /merge|gabungkan/i })
.first();
await expect(mergeButton).toBeDisabled();
// Step 2-3: Attempt to interact without uploading files
// The workflow should not proceed without files
await mergeButton.click({ force: true });
await expect(page).toHaveURL(/\/merge/);
});
});
});
@@ -0,0 +1,53 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
/**
* Routing & navigation behaviour for the SPA. Merges what was previously
* `direct-url-navigation.spec.ts` and `browser-navigation.spec.ts` —
* they overlapped ~30% (both verifying that tool routes resolve cleanly).
*/
test.describe("Navigation", () => {
test("direct URLs load the matching tool page", async ({ page }) => {
// We exercise a representative subset; `all-tool-pages-load.spec.ts`
// does the exhaustive sweep across every registered tool.
const targets = ["/merge", "/split", "/compress"];
for (const url of targets) {
await page.goto(url, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(new RegExp(url));
await expect(page.locator("body").first()).not.toBeEmpty();
}
});
test("unknown routes do not crash or white-screen", async ({ page }) => {
await page.goto("/nonexistent-page-12345");
await page.waitForLoadState("domcontentloaded");
const body = await page.locator("body").textContent();
expect(body).toBeTruthy();
});
test("browser back / forward correctly traverses tool history", async ({
page,
}) => {
// / → /merge → / → /split, then back/back/forward
await page.locator('a[href="/merge"]').first().click();
await expect(page).toHaveURL(/\/merge/);
// In the redesigned UI, "Back to tools" button replaces the old "Tools" link
await page
.getByRole("button", { name: /Back to tools/i })
.first()
.click();
await expect(page).toHaveURL("/");
await page.locator('a[href="/split"]').first().click();
await expect(page).toHaveURL(/\/split/);
await page.goBack();
await expect(page).toHaveURL("/");
await page.goBack();
await expect(page).toHaveURL(/\/merge/);
await page.goForward();
await expect(page).toHaveURL("/");
});
});
@@ -0,0 +1,79 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
/**
* When app-config advertises OAuth providers the login screen renders
* a "Continue with X" button per provider. This is the entry-point
* customers depend on — a config drift that drops the button means
* SSO becomes silently unreachable from the UI.
*/
test.describe("OAuth/SSO login buttons", () => {
test.use({
stubOptions: {
enableLogin: true,
user: {
id: 1,
username: "admin",
email: "admin",
roles: ["ROLE_ADMIN"],
},
},
autoGoto: false,
});
test("renders Continue-with-X buttons when oauth2 providers configured", async ({
page,
}) => {
// Override the bootstrap config-app-config mock with provider data
await page.route("**/api/v1/config/app-config", (route) =>
route.fulfill({
json: {
enableLogin: true,
languages: ["en-GB"],
defaultLocale: "en-GB",
oauth2: {
enabled: true,
providers: [
{ id: "keycloak", name: "Keycloak" },
{ id: "google", name: "Google" },
],
},
saml2: { enabled: true, provider: "Authentik" },
},
}),
);
// The proprietary login page reads ui-data/login for branding + provider list
// The frontend expects `providerList` as a {path: displayName} map.
await page.route("**/api/v1/proprietary/ui-data/login", (route) =>
route.fulfill({
json: {
enableLogin: true,
loginMethod: "all",
providerList: {
"/oauth2/authorization/keycloak": "Keycloak",
"/oauth2/authorization/google": "Google",
"/saml2/authenticate/stirling": "Authentik",
},
ssoAutoLogin: false,
firstTimeSetup: false,
showDefaultCredentials: false,
},
}),
);
await page.goto("/login");
await page.waitForLoadState("domcontentloaded");
// Email/password form is still there
await expect(page.locator("#email")).toBeVisible({ timeout: 10_000 });
// Provider buttons render — match flexibly because the exact
// markup depends on the proprietary layer we're stubbing into.
const oauthHints = page.locator(
'a[href*="oauth2"], a[href*="saml"], button:has-text("Keycloak"), button:has-text("Google"), button:has-text("Authentik")',
);
await expect
.poll(async () => oauthHints.count(), { timeout: 5_000 })
.toBeGreaterThan(0);
});
});
@@ -0,0 +1,52 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import path from "path";
const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
/**
* The reader/viewer exposes an in-PDF text search via CustomSearchLayer.
* No prior coverage; even a smoke assertion that the search input renders
* and accepts a query catches the most damaging regression (search bar
* disappears when reader refactors).
*/
test.describe("Reader — in-document text search", () => {
test("search input is reachable from the reader and accepts a query", async ({
page,
}) => {
await page.goto("/read");
await page.waitForLoadState("domcontentloaded");
// Upload a PDF first so the reader has content
await page.getByTestId("files-button").click();
await page.waitForSelector(".mantine-Modal-overlay", {
state: "visible",
timeout: 5_000,
});
await page.locator('[data-testid="file-input"]').setInputFiles(SAMPLE_PDF);
await page.waitForSelector(".mantine-Modal-overlay", {
state: "hidden",
timeout: 10_000,
});
// The WorkbenchBar exposes a "Search PDF" button (aria-label="Search PDF")
// that opens a Popover with the in-document search input.
// We target this button specifically to avoid matching the FileSidebar's
// "Search" button (a <div role="button">) which appears earlier in the DOM.
const searchBtn = page
.getByRole("button", { name: /^Search PDF$/i })
.first();
if (!(await searchBtn.isVisible({ timeout: 5_000 }).catch(() => false))) {
test.skip(true, "Search PDF button not visible on this build");
return;
}
await searchBtn.click();
// The SearchInterface renders inside a Popover with placeholder "Enter search term..."
const input = page.getByPlaceholder(/enter search term/i).first();
await expect(input).toBeVisible({ timeout: 5_000 });
await input.fill("page");
await expect(input).toHaveValue("page");
});
});
@@ -0,0 +1,93 @@
import { test, expect, type Page } from "@playwright/test";
import {
bypassOnboarding,
mockAppApis,
seedCookieConsent,
} from "@app/tests/helpers/api-stubs";
/**
* Stubbed premium-feature gating. Several tools and admin surfaces only
* render when the backend reports `enabled: true` for the tool's
* endpoint. This spec asserts:
* - tools whose endpoint is `enabled: false` still expose a tile but
* surface a disabled / locked indicator,
* - tools whose endpoint is `enabled: true` route normally.
*/
async function setUpEndpointAvailability(
page: Page,
overrides: Record<string, { enabled: boolean }>,
) {
await seedCookieConsent(page);
await bypassOnboarding(page);
await mockAppApis(page, { endpointsAvailability: overrides });
await page.goto("/");
}
test.describe("Premium / endpoint gating", () => {
test("disabled tool endpoint still appears in the picker (tile present)", async ({
page,
}) => {
await setUpEndpointAvailability(page, {
compress: { enabled: false },
});
// Even disabled endpoints render a tile in the picker — the tool
// becomes a no-op or shows a disabled affordance once clicked.
const compressTile = page.locator('[data-tour="tool-button-compress"]');
await expect(compressTile.first()).toBeVisible({ timeout: 10_000 });
});
test("enabled tool endpoint routes to its tool page", async ({ page }) => {
await setUpEndpointAvailability(page, {
compress: { enabled: true },
});
await page.locator('[data-tour="tool-button-compress"]').first().click();
await expect(page).toHaveURL(/\/compress/);
});
test("non-admin user does not see admin-only settings sections", async ({
page,
}) => {
await seedCookieConsent(page);
await bypassOnboarding(page);
// Seed JWT so the orchestrator's auth-gated effect treats the user as
// logged-in — without this the orchestrator returns early and the
// dashboard chrome never renders.
await page.addInitScript(() => {
localStorage.setItem(
"stirling_jwt",
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.signature",
);
});
await mockAppApis(page, {
enableLogin: true,
user: {
id: 2,
username: "user",
email: "[email protected]",
roles: ["ROLE_USER"],
},
});
await page.route("**/api/v1/proprietary/ui-data/account", (route) =>
route.fulfill({
json: { username: "user", email: "[email protected]", isAdmin: false },
}),
);
await page.goto("/");
const configBtn = page.locator('[data-testid="config-button"]').first();
if (!(await configBtn.isVisible({ timeout: 5_000 }).catch(() => false))) {
test.skip(true, "Config button not rendered for non-admin on this build");
return;
}
await configBtn.click();
const dialog = page.locator(".mantine-Modal-content").first();
await expect(dialog).toBeVisible({ timeout: 5_000 });
// Admin-only sections must not render for ROLE_USER
for (const section of [/^audit/i, /^teams/i, /^license/i]) {
await expect(dialog.getByText(section)).toHaveCount(0);
}
});
});
@@ -0,0 +1,123 @@
/**
* Live saas-backend smoke. Hits the actual Spring Boot saas backend at a configured port and
* verifies the fixes landed in this branch.
*
* Skipped automatically if the backend isn't reachable — CI doesn't boot one. To run locally:
* STIRLING_FLAVOR=saas ./gradlew :stirling-pdf:bootRun --args="--server.port=18083 --spring.profiles.include=dev"
* STIRLING_SAAS_URL=http://localhost:18083 npx playwright test --project=stubbed saas-backend-smoke
*/
import { test, expect, request } from "@playwright/test";
const SAAS_URL = process.env.STIRLING_SAAS_URL ?? "http://localhost:18083";
test.describe("SaaS backend smoke", () => {
test.beforeAll(async () => {
const ctx = await request.newContext();
try {
const r = await ctx.get(`${SAAS_URL}/actuator/health`, { timeout: 2000 });
if (r.status() !== 200) {
test.skip(true, `saas backend at ${SAAS_URL} returned ${r.status()}`);
}
} catch (err) {
test.skip(true, `saas backend at ${SAAS_URL} unreachable: ${err}`);
}
});
test("health endpoint returns UP", async () => {
const ctx = await request.newContext();
const r = await ctx.get(`${SAAS_URL}/actuator/health`);
expect(r.status()).toBe(200);
const body = await r.json();
expect(body.status).toBe("UP");
});
test("public endpoints return 200 / protected return 401", async () => {
const ctx = await request.newContext();
expect((await ctx.get(`${SAAS_URL}/api/v1/info/status`)).status()).toBe(
200,
);
expect(
(await ctx.get(`${SAAS_URL}/api/v1/config/app-config`)).status(),
).toBe(200);
expect((await ctx.get(`${SAAS_URL}/api/v1/credits`)).status()).toBe(401);
});
test("user-role webhook endpoints reject unauthenticated (regression #3)", async () => {
const ctx = await request.newContext();
for (const path of [
"/api/v1/user-role/upgrade?supabaseId=foo",
"/api/v1/user-role/downgrade?supabaseId=foo",
"/api/v1/user-role/enable-metered-billing?supabaseId=foo",
"/api/v1/user-role/disable-metered-billing?supabaseId=foo",
]) {
const r = await ctx.post(`${SAAS_URL}${path}`);
expect(r.status(), `${path}: expected 401, got ${r.status()}`).toBe(401);
expect(
r.status(),
`${path}: must not 500 — would indicate hasRole prefix bug returned`,
).not.toBe(500);
}
});
test("CORS preflight rejects unknown origin (regression #11)", async () => {
const ctx = await request.newContext();
const r = await ctx.fetch(`${SAAS_URL}/api/v1/credits`, {
method: "OPTIONS",
headers: {
Origin: "https://attacker.example.com",
"Access-Control-Request-Method": "GET",
},
});
expect(r.status()).toBe(403);
expect(r.headers()["access-control-allow-origin"]).toBeUndefined();
});
test("CORS preflight rejects tenant wildcard origin (regression #11)", async () => {
const ctx = await request.newContext();
const r = await ctx.fetch(`${SAAS_URL}/api/v1/credits`, {
method: "OPTIONS",
headers: {
Origin: "https://abandoned-tenant.ssl.stirlingpdf.cloud",
"Access-Control-Request-Method": "GET",
},
});
expect(r.status()).toBe(403);
expect(r.headers()["access-control-allow-origin"]).toBeUndefined();
});
test("CORS preflight accepts allowed origin", async () => {
const ctx = await request.newContext();
const r = await ctx.fetch(`${SAAS_URL}/api/v1/credits`, {
method: "OPTIONS",
headers: {
Origin: "https://app.stirling.com",
"Access-Control-Request-Method": "GET",
},
});
expect(r.status()).toBe(200);
expect(r.headers()["access-control-allow-origin"]).toBe(
"https://app.stirling.com",
);
expect(r.headers()["access-control-allow-credentials"]).toBe("true");
});
test("backend HTML loads in a real browser (no boot-time console errors)", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(`pageerror: ${e.message}`));
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(`console: ${msg.text()}`);
});
const resp = await page.goto(`${SAAS_URL}/`, { waitUntil: "load" });
expect(resp?.status()).toBe(200);
await page.waitForTimeout(750);
const real = errors.filter(
(e) => !/Failed to load resource.*401|\/api\/v1\/credits/i.test(e),
);
expect(real, real.join("\n")).toEqual([]);
});
});
@@ -0,0 +1,67 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import * as path from "path";
import * as fs from "fs";
/**
* Seed test for Stirling-PDF E2E tests.
* This file is copied into generated tests by the Playwright Test Agents.
* It provides the baseline environment: navigates to the app and verifies it loaded.
*/
// ─── Test Fixture Paths ─────────────────────────────────────────────────────
function resolveFixturePath(filename: string): string {
const candidates = [
path.join(
process.cwd(),
"frontend",
"src",
"core",
"tests",
"test-fixtures",
filename,
),
path.join(process.cwd(), "src", "core", "tests", "test-fixtures", filename),
path.join(__dirname, "..", "core", "tests", "test-fixtures", filename),
];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
return candidates[0];
}
export const TEST_FILES = {
pdf: resolveFixturePath("sample.pdf"),
docx: resolveFixturePath("sample.docx"),
xlsx: resolveFixturePath("sample.xlsx"),
pptx: resolveFixturePath("sample.pptx"),
png: resolveFixturePath("sample.png"),
jpg: resolveFixturePath("sample.jpg"),
html: resolveFixturePath("sample.html"),
txt: resolveFixturePath("sample.txt"),
csv: resolveFixturePath("sample.csv"),
xml: resolveFixturePath("sample.xml"),
md: resolveFixturePath("sample.md"),
svg: resolveFixturePath("sample.svg"),
corrupted: resolveFixturePath("corrupted.pdf"),
} as const;
test.describe("Stirling-PDF seed", () => {
test("seed - app loads", async ({ page }) => {
// Navigate to the Stirling-PDF frontend
await page.goto("/");
// The app may redirect to /login if authentication is enabled.
// Wait for the app to be ready: either the dashboard layout or the login page.
await expect(
page
.locator(
'.h-screen, .mobile-layout, [data-testid="dashboard"], img[alt*="Stirling"]',
)
.first(),
).toBeVisible({ timeout: 15000 });
// Verify the title contains Stirling PDF
await expect(page).toHaveTitle(/Stirling/i);
});
});
@@ -0,0 +1,150 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import { openSettings, closeSettings } from "@app/tests/helpers/ui-helpers";
/**
* Consolidated settings-dialog coverage. Was previously three files
* (`settings.spec.ts`, `settings-configuration.spec.ts`,
* `settings-toggle-behavior.spec.ts`) generated from a numbered test
* plan; merged here to cut bloat. Logout flow lives in
* `live/authentication-login.spec.ts` since it requires real session
* invalidation.
*/
test.describe("Settings dialog", () => {
test("opens with sidebar nav and lists General + Keyboard Shortcuts sections", async ({
page,
}) => {
await openSettings(page);
for (const label of [/^General$/i, /^Keyboard Shortcuts$/i]) {
await expect(page.getByText(label).first()).toBeVisible({
timeout: 5_000,
});
}
// General section is selected by default and exposes version info
await expect(page.getByText(/version/i).first()).toBeVisible({
timeout: 5_000,
});
});
test("Account section shows the user and management buttons", async ({
page,
}) => {
await openSettings(page);
const accountNav = page.getByText(/^Account( Settings)?$/i).first();
if (!(await accountNav.isVisible({ timeout: 3_000 }).catch(() => false))) {
test.skip(true, "Account section not visible on this build");
return;
}
await accountNav.click();
await expect(page.getByText(/admin/).first()).toBeVisible({
timeout: 5_000,
});
await expect(page.getByText(/update password/i).first()).toBeVisible();
await expect(page.getByText(/change username/i).first()).toBeVisible();
await expect(page.getByText(/log out/i).first()).toBeVisible();
await expect(
page.getByText(/two-factor authentication/i).first(),
).toBeVisible();
});
test("Close button dismisses dialog and restores main UI", async ({
page,
}) => {
await openSettings(page);
await closeSettings(page);
// Verify the main UI is restored — the FileSidebar is always visible
await expect(
page.locator('[data-testid="files-button"]').first(),
).toBeVisible();
});
test("toggle state persists across dialog open/close", async ({ page }) => {
const dialog = await openSettings(page);
const toggle = dialog
.locator('input[type="checkbox"][role="switch"], input[role="switch"]')
.first();
if (!(await toggle.isVisible({ timeout: 3_000 }).catch(() => false))) {
test.skip(true, "No toggle in General section on this build");
return;
}
// Click the visible label wrapper rather than the hidden input directly —
// force-clicking the input doesn't register a state change in Firefox.
const toggleLabel = dialog
.locator('label:has(input[role="switch"]), .mantine-Switch-body')
.first();
const before = await toggle.isChecked();
await toggleLabel.click();
const after = await toggle.isChecked();
expect(after).not.toBe(before);
await closeSettings(page);
await openSettings(page);
const persisted = await toggle.isChecked();
expect(persisted).toBe(after);
// Restore
if (persisted !== before) {
await toggleLabel.click();
}
});
test("segmented controls (e.g. tool-picker mode) persist across reopen", async ({
page,
}) => {
const dialog = await openSettings(page);
const segmented = dialog.locator(".mantine-SegmentedControl-root").first();
if (!(await segmented.isVisible({ timeout: 3_000 }).catch(() => false))) {
test.skip(true, "No segmented control on this build");
return;
}
const labels = segmented.locator("label");
const count = await labels.count();
if (count < 2) {
test.skip(true, "Segmented control has too few options to assert switch");
return;
}
await labels.nth(1).click();
await page.waitForTimeout(300);
await closeSettings(page);
await openSettings(page);
// Restore
const restored = page
.locator(".mantine-Modal-content .mantine-SegmentedControl-root label")
.first();
await restored.click();
});
test("config sub-sections (System / Features / Endpoints / API Keys) are reachable when present", async ({
page,
}) => {
const dialog = await openSettings(page);
const sections = [
/^System Settings$/i,
/^Features$/i,
/^Endpoints$/i,
/^API Keys$/i,
];
let visited = 0;
for (const label of sections) {
const nav = page.getByText(label).first();
if (await nav.isVisible({ timeout: 1_500 }).catch(() => false)) {
await nav.click();
await page.waitForTimeout(200);
const body = await dialog.textContent();
expect(body, `body rendered after clicking ${label}`).toBeTruthy();
visited++;
}
}
test.info().annotations.push({
type: "config-sections",
description: `Visited ${visited}/${sections.length} sections on this build`,
});
});
});
@@ -0,0 +1,62 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
test.describe("6. Split Tool", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/split");
await page.waitForLoadState("domcontentloaded");
});
test.describe("6.1 Split - Method Selection", () => {
test("should display all split methods", async ({ page }) => {
// Step 1: Verify the page shows a multi-step workflow (Files and Choose Method)
await expect(page.locator("text=/^Files$|^File$/i").first()).toBeVisible({
timeout: 10000,
});
await expect(
page.locator("text=/Choose Method|Method/i").first(),
).toBeVisible();
// Step 2: Verify the following split methods are listed as cards
// Each card shows "prefix name" e.g. "Split at Page Numbers"
const splitMethods = [
/Page Numbers/i,
/Chapters/i,
/Sections/i,
/File Size/i,
/Page Count/i,
/Document Count/i,
/Page Divider/i,
/Printable Chunks/i,
];
for (const method of splitMethods) {
await expect(page.getByText(method).first()).toBeVisible({
timeout: 5000,
});
}
// Step 3: Verify the "Split" button is disabled
const splitButton = page
.getByRole("button", { name: /split|pisahkan/i })
.first();
await expect(splitButton).toBeVisible();
await expect(splitButton).toBeDisabled();
});
});
test.describe("6.2 Split - Submit Without File", () => {
test("should not allow split without a file", async ({ page }) => {
// Step 1: Select a split method by clicking a method card
const firstMethod = page.getByText(/Page Numbers/i).first();
if (await firstMethod.isVisible({ timeout: 3000 })) {
await firstMethod.click();
}
// Step 2: Verify the "Split" button remains disabled without a file uploaded
const splitButton = page
.getByRole("button", { name: /split|pisahkan/i })
.first();
await expect(splitButton).toBeDisabled();
});
});
});
@@ -0,0 +1,84 @@
import { test, expect, type Page } from "@playwright/test";
import {
bypassOnboarding,
mockAppApis,
seedCookieConsent,
} from "@app/tests/helpers/api-stubs";
import { openSettings } from "@app/tests/helpers/ui-helpers";
/**
* Stubbed teams-management UI coverage. The proprietary Teams section
* lists teams from `/proprietary/ui-data/teams` and exposes a
* create-team affordance. We mock both empty and populated lists to
* catch frontend regressions on every PR.
*/
async function setUpAdminWithTeams(
page: Page,
teams: Array<Record<string, unknown>>,
) {
await seedCookieConsent(page);
await bypassOnboarding(page);
await page.addInitScript(() => {
localStorage.setItem(
"stirling_jwt",
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiJ9.signature",
);
});
await mockAppApis(page, {
enableLogin: true,
user: {
id: 1,
username: "admin",
email: "admin",
roles: ["ROLE_ADMIN"],
},
});
await page.route("**/api/v1/proprietary/ui-data/account", (route) =>
route.fulfill({
json: { username: "admin", email: "admin", isAdmin: true },
}),
);
await page.route("**/api/v1/proprietary/ui-data/teams", (route) =>
route.fulfill({ json: teams }),
);
await page.goto("/");
}
test.describe("Teams management UI", () => {
test("empty teams list still exposes create-team affordance", async ({
page,
}) => {
await setUpAdminWithTeams(page, []);
await openSettings(page);
const teamsNav = page.getByText(/^teams/i).first();
if (!(await teamsNav.isVisible({ timeout: 3_000 }).catch(() => false))) {
test.skip(true, "Teams section not in this build");
return;
}
await teamsNav.click();
await expect(
page
.getByRole("button", { name: /create team|new team|add team/i })
.first(),
).toBeVisible({ timeout: 10_000 });
});
test("populated teams list renders team rows", async ({ page }) => {
await setUpAdminWithTeams(page, [
{ id: 1, name: "Engineering", memberCount: 4 },
{ id: 2, name: "Marketing", memberCount: 2 },
]);
await openSettings(page);
const teamsNav = page.getByText(/^teams/i).first();
if (!(await teamsNav.isVisible({ timeout: 3_000 }).catch(() => false))) {
test.skip(true, "Teams section not in this build");
return;
}
await teamsNav.click();
await expect(page.getByText(/Engineering/i).first()).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText(/Marketing/i).first()).toBeVisible();
});
});
@@ -0,0 +1,56 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
test.describe("4. PDF Tool Pages - Common Patterns", () => {
test.describe("4.1 Tool Page - File Upload Required Before Processing", () => {
test("should require file upload before processing on merge tool", async ({
page,
}) => {
// Step 1: Navigate to the merge tool page
await page.goto("/merge");
await page.waitForLoadState("domcontentloaded");
// Step 2: Verify the primary action button is disabled
const actionButton = page
.getByRole("button", { name: /merge|gabungkan/i })
.first();
await expect(actionButton).toBeVisible({ timeout: 10000 });
await expect(actionButton).toBeDisabled();
// Step 3: Verify the file upload area is displayed
// (input[type="file"] is excluded — it is hidden in the FileSidebar)
await expect(
page.locator('[class*="upload"], [class*="dropzone"]').first(),
).toBeVisible();
// Step 4: Verify that clicking the disabled action button does nothing
await actionButton.click({ force: true });
await expect(page).toHaveURL(/\/merge/);
});
});
test.describe("4.2 Tool Page - Navigation Back to Home", () => {
test("should navigate between tool pages and home via breadcrumbs and history", async ({
page,
}) => {
// Step 1: Navigate to /compress
await page.goto("/compress");
await page.waitForLoadState("domcontentloaded");
// Step 2: Click the "Back to tools" button in ToolPanel to go back to /.
// In the redesigned UI this replaces the old "Tools" sidebar link.
const homeLink = page
.getByRole("button", { name: /Back to tools/i })
.first();
await homeLink.click();
// Step 3: Verify navigation back to the home dashboard
await expect(page).toHaveURL("/");
// Step 4: Use browser back button
await page.goBack();
// Step 5: Verify return to the tool page
await expect(page).toHaveURL(/\/compress/);
});
});
});
@@ -0,0 +1,78 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
test.describe("3. Tool Search", () => {
test.describe("3.1 Search - Happy Path", () => {
test("should filter tools in real time based on search input", async ({
page,
}) => {
// Step 1: Click on the search box
const searchBox = page.getByPlaceholder(/search|cari/i).first();
await searchBox.click();
// Step 2: Type "merge"
await searchBox.fill("merge");
// Step 3: Verify search results filter to show relevant tools
await expect(
page.locator("text=/merge|menggabungkan/i").first(),
).toBeVisible({ timeout: 5000 });
// Step 5: Clear the search field
await searchBox.clear();
// Step 6: Verify all tools reappear (check for multiple categories)
await expect(
page.locator("text=/recommended|direkomendasikan/i").first(),
).toBeVisible({ timeout: 5000 });
});
});
test.describe("3.2 Search - No Results", () => {
test("should handle queries with no matching tools gracefully", async ({
page,
}) => {
// Step 1: Click on the search box
const searchBox = page.getByPlaceholder(/search|cari/i).first();
await searchBox.click();
// Step 2: Type xyznonexistent123
await searchBox.fill("xyznonexistent123");
// Step 3: Verify the search field accepted the input
await expect(searchBox).toHaveValue("xyznonexistent123");
// The app uses fuzzy search with a fallback that shows all tools when nothing
// matches, so we verify the search state is active (no "recommended" section)
// and the page remains functional without errors.
await expect(
page.locator("text=/recommended|direkomendasikan/i"),
).toHaveCount(0, { timeout: 5000 });
// Step 4: Clear the search field
await searchBox.clear();
// Step 5: Verify all tools reappear (recommended section comes back)
await expect(
page.locator("text=/recommended|direkomendasikan/i").first(),
).toBeVisible({ timeout: 5000 });
});
});
test.describe("3.3 Search - Special Characters", () => {
test("should sanitize search input against XSS", async ({ page }) => {
// Step 1: Type XSS payload into the search box
const searchBox = page.getByPlaceholder(/search|cari/i).first();
await searchBox.fill("<script>alert(1)</script>");
// Step 2: Verify no script execution occurs (no alert dialog)
// If an alert appeared, Playwright would throw an unhandled dialog error
await page.waitForTimeout(1000);
// Step 3: Verify the search treats the input as plain text
await expect(searchBox).toHaveValue("<script>alert(1)</script>");
// Step 4: Clear the search field
await searchBox.clear();
});
});
});
@@ -0,0 +1,227 @@
import path from "path";
import { test, expect } from "@app/tests/helpers/stub-test-base";
import { uploadFiles, openSettings } from "@app/tests/helpers/ui-helpers";
/**
* Tour selector integrity tests.
*
* Each test asserts that a `data-tour="…"` element referenced by one of the
* three guided tours (user, admin, whats-new) is actually present in the DOM
* at the point in the UI where the tour step would fire. If an element is
* renamed or removed the test fails immediately, surfacing the breakage before
* it silently breaks the tour for real users.
*
* Selectors under test come from:
* - userStepsConfig.ts
* - adminStepsConfig.ts
* - whatsNewStepsConfig.ts
*/
const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
// ---------------------------------------------------------------------------
// 15.1 Static layout — always visible on the main page
// ---------------------------------------------------------------------------
test.describe("15.1 Tour selectors — static layout", () => {
test("tool-panel is present", async ({ page }) => {
await expect(page.locator('[data-tour="tool-panel"]').first()).toBeVisible({
timeout: 10_000,
});
});
test("quick-access-bar (FileSidebar) is present", async ({ page }) => {
await expect(
page.locator('[data-tour="quick-access-bar"]').first(),
).toBeVisible({ timeout: 10_000 });
});
test("files-button is present", async ({ page }) => {
await expect(
page.locator('[data-tour="files-button"]').first(),
).toBeVisible({ timeout: 10_000 });
});
test("config-button is present", async ({ page }) => {
await expect(
page.locator('[data-tour="config-button"]').first(),
).toBeVisible({ timeout: 10_000 });
});
test("tool-button-crop is present in tool panel", async ({ page }) => {
await expect(
page.locator('[data-tour="tool-button-crop"]').first(),
).toBeVisible({ timeout: 10_000 });
});
// help-button: not yet implemented in the redesigned FileSidebar layout.
// Re-enable once a tours/help entry point is added to the new UI.
test.skip("help-button is present — TODO: add to new sidebar layout", async ({
page,
}) => {
await expect(page.locator('[data-tour="help-button"]').first()).toBeVisible(
{ timeout: 10_000 },
);
});
});
// ---------------------------------------------------------------------------
// 15.2 Tour selectors — files modal
// ---------------------------------------------------------------------------
test.describe("15.2 Tour selectors — files modal", () => {
test("file-sources is present when files modal is open", async ({ page }) => {
await page.getByTestId("files-button").click();
await expect(
page.locator('[data-tour="file-sources"]').first(),
).toBeVisible({ timeout: 10_000 });
await page.keyboard.press("Escape");
});
});
// ---------------------------------------------------------------------------
// 15.3 Tour selectors — workbench elements (require a loaded file)
// ---------------------------------------------------------------------------
test.describe("15.3 Tour selectors — workbench with file", () => {
test.beforeEach(async ({ page }) => {
await uploadFiles(page, SAMPLE_PDF);
});
test("workbench is present", async ({ page }) => {
await expect(page.locator('[data-tour="workbench"]').first()).toBeVisible({
timeout: 10_000,
});
});
test("view-switcher is present in viewer mode", async ({ page }) => {
await expect(
page.locator('[data-tour="view-switcher"]').first(),
).toBeVisible({ timeout: 10_000 });
});
});
// ---------------------------------------------------------------------------
// 15.4 Tour selectors — active files view (file-card-checkbox, file-card-pin)
// Two files → app auto-navigates to fileEditor (active files) mode.
// ---------------------------------------------------------------------------
test.describe("15.4 Tour selectors — active files view", () => {
test.beforeEach(async ({ page }) => {
// Two files → getStartupNavigationAction returns workbench:"fileEditor"
await uploadFiles(page, [SAMPLE_PDF, SAMPLE_PDF]);
});
test("file-card-checkbox is present in active files view", async ({
page,
}) => {
await expect(
page.locator('[data-tour="file-card-checkbox"]').first(),
).toBeVisible({ timeout: 15_000 });
});
test("file-card-pin is in DOM when file cards are rendered", async ({
page,
}) => {
// The pin button lives inside HoverActionMenu (CSS-hover driven, always
// attached to DOM). Hover over the first file card to ensure the element
// is rendered, then assert it is attached.
const fileCard = page.locator('[data-tour="file-card-checkbox"]').first();
await expect(fileCard).toBeVisible({ timeout: 15_000 });
await fileCard.hover();
await expect(
page.locator('[data-tour="file-card-pin"]').first(),
).toBeAttached({ timeout: 5_000 });
});
});
// ---------------------------------------------------------------------------
// 15.5 Tour selectors — crop tool (crop-settings, run-button)
// ---------------------------------------------------------------------------
test.describe("15.5 Tour selectors — crop tool", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/crop", { waitUntil: "domcontentloaded" });
await uploadFiles(page, SAMPLE_PDF);
});
test("crop-settings is present", async ({ page }) => {
await expect(
page.locator('[data-tour="crop-settings"]').first(),
).toBeVisible({ timeout: 10_000 });
});
test("run-button is present", async ({ page }) => {
await expect(page.locator('[data-tour="run-button"]').first()).toBeVisible({
timeout: 10_000,
});
});
});
// ---------------------------------------------------------------------------
// 15.6 Tour selectors — config modal (non-admin)
// ---------------------------------------------------------------------------
test.describe("15.6 Tour selectors — config modal", () => {
test.beforeEach(async ({ page }) => {
await openSettings(page);
});
test("modal-nav is present", async ({ page }) => {
await expect(page.locator(".modal-nav").first()).toBeVisible({
timeout: 10_000,
});
});
test("settings-content-area is present", async ({ page }) => {
await expect(
page.locator('[data-tour="settings-content-area"]').first(),
).toBeVisible({ timeout: 10_000 });
});
});
// ---------------------------------------------------------------------------
// 15.7 Tour selectors — admin config modal nav items
// Requires isAdmin:true in app-config so the proprietary admin sections render.
// These nav items are EE-only; the test is skipped in core-only builds where
// the sections are not registered.
// ---------------------------------------------------------------------------
test.describe("15.7 Tour selectors — admin modal nav items", () => {
// enableLogin:true makes Landing require a session — seed a JWT so the
// dashboard renders instead of redirecting to /login.
test.use({
stubOptions: { enableLogin: true, isAdmin: true },
seedJwt: true,
});
const adminNavSections = [
"people",
"teams",
"adminGeneral",
"adminFeatures",
"adminEndpoints",
"adminDatabase",
"adminConnections",
"adminAudit",
"adminUsage",
"help",
] as const;
for (const section of adminNavSections) {
test(`admin-${section}-nav is present`, async ({ page }) => {
await openSettings(page);
const navItem = page
.locator(`[data-tour="admin-${section}-nav"]`)
.first();
// Use waitFor attached (not isVisible) because admin nav items live in a
// scrollable sidebar and may be below the visible fold even when rendered.
const isPresent = await navItem
.waitFor({ state: "attached", timeout: 5_000 })
.then(() => true)
.catch(() => false);
if (!isPresent) {
test.skip(
true,
`admin-${section}-nav not rendered — section may be EE-only or not yet ported to this build`,
);
return;
}
await navItem.scrollIntoViewIfNeeded();
await expect(navItem).toBeVisible();
});
}
});
@@ -0,0 +1,43 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
import path from "path";
const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
/**
* The NavigationGuard context warns the user when they have unsaved work
* (uploaded files or modified config) and try to navigate away. The guard
* surface is a Mantine modal asking to confirm or cancel the navigation.
*
* Today the guard logic exists but is silently bypassed by tests that go
* through the workbench. This spec asserts the modal appears and that
* cancelling keeps the user on the current tool.
*/
test.describe("Unsaved changes navigation guard", () => {
test("uploading then navigating away surfaces the guard prompt", async ({
page,
}) => {
await page.goto("/merge");
await page.waitForLoadState("domcontentloaded");
await uploadFiles(page, SAMPLE_PDF);
// Triggering a tool-level navigation while files are loaded should
// either prompt or clear-and-navigate cleanly. A regression that
// discards files silently is the failure we want to catch.
const splitNav = page.getByRole("link", { name: /^Split$/i }).first();
if (await splitNav.isVisible({ timeout: 1_000 }).catch(() => false)) {
await splitNav.click();
} else {
await page.goto("/split");
}
// After arriving at /split the file picker should still list the
// previously uploaded sample (NavigationGuard either kept us on
// /merge or moved us with state intact). A "no files" empty state
// here would indicate the guard silently dropped the workbench.
await page.getByTestId("files-button").click();
await expect(page.getByText(/sample\.pdf/i).first()).toBeVisible({
timeout: 5_000,
});
});
});
@@ -0,0 +1,43 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
import { uploadFiles } from "@app/tests/helpers/ui-helpers";
import path from "path";
const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
/**
* Watermark has three modes — text / image / file overlay — selected via
* card chooser, each driving a different settings step. The chooser only
* renders after a file has been uploaded; today the page-loads smoke test
* covers the bare URL only, so this spec extends to the post-upload flow.
*/
test.describe("Watermark tool — mode selection after upload", () => {
test.beforeEach(async ({ page }) => {
await page.route("**/api/v1/security/add-watermark", (route) =>
route.fulfill({
status: 200,
contentType: "application/pdf",
headers: {
"Content-Disposition": 'attachment; filename="watermarked.pdf"',
},
body: Buffer.from("%PDF-1.4 stub\n"),
}),
);
await page.goto("/watermark");
await page.waitForLoadState("domcontentloaded");
await uploadFiles(page, SAMPLE_PDF);
});
test("post-upload UI renders mode cards or settings (whatever the chooser is)", async ({
page,
}) => {
// The chooser may render as Mantine cards or buttons depending on the build.
// Either flavour is fine; what we want to catch is "post-upload watermark
// page is empty / errored".
const choices = page.locator(
'.mantine-Card-root, button:has-text("Text"), button:has-text("Image"), button:has-text("File")',
);
await expect
.poll(async () => choices.count(), { timeout: 10_000 })
.toBeGreaterThan(0);
});
});
@@ -0,0 +1,49 @@
import { test, expect } from "@app/tests/helpers/stub-test-base";
test.describe("26. Workspace Features", () => {
test.beforeEach(async ({ page }) => {
// Open settings dialog
await page
.getByRole("button", { name: /settings/i })
.first()
.click();
const settingsDialog = page.locator(".mantine-Modal-content").first();
await expect(settingsDialog).toBeVisible({ timeout: 5000 });
});
test.describe("26.1 Team Members", () => {
test("should display workspace members section", async ({ page }) => {
// Step 1: Click "People" in the settings nav
const peopleNav = page.getByText(/^People$/i).first();
if (await peopleNav.isVisible({ timeout: 3000 }).catch(() => false)) {
await peopleNav.click();
// Step 2: Verify the members/team management section loads
await page.waitForTimeout(500);
// Step 3: Verify the admin user is listed
await expect(page.locator("text=/admin/").first()).toBeVisible({
timeout: 5000,
});
}
});
});
test.describe("26.2 Teams", () => {
test("should display teams management section", async ({ page }) => {
// Step 1: Click "Teams" in the settings nav
const teamsNav = page.getByText(/^Teams$/i).first();
if (await teamsNav.isVisible({ timeout: 3000 }).catch(() => false)) {
await teamsNav.click();
// Step 2: Verify the teams management section loads
await page.waitForTimeout(500);
const bodyContent = await page
.locator(".mantine-Modal-content")
.first()
.textContent();
expect(bodyContent).toBeTruthy();
}
});
});
});
@@ -0,0 +1,132 @@
# Test Fixtures for Convert Tool Testing
This directory contains sample files for testing the convert tool functionality.
## Required Test Files
To run the full test suite, please add the following test files to this directory:
### 1. sample.pdf
- A small PDF document (1-2 pages)
- Should contain text and ideally a simple table for CSV conversion testing
- Should be under 1MB for fast testing
### 2. sample.docx
- A Microsoft Word document with basic formatting
- Should contain headers, paragraphs, and possibly a table
- Should be under 500KB
### 3. sample.png
- A small PNG image (e.g., 500x500 pixels)
- Should be a real image, not just a test pattern
- Should be under 100KB
### 3b. sample.jpg
- A small JPG image (same image as PNG, different format)
- Should be under 100KB
- Can be created by converting sample.png to JPG
### 4. sample.md
- A Markdown file with various formatting elements:
```markdown
# Test Document
This is a **test** markdown file.
## Features
- Lists
- **Bold text**
- *Italic text*
- [Links](https://example.com)
### Code Block
```javascript
console.log('Hello, world!');
```
| Column 1 | Column 2 |
|----------|----------|
| Data 1 | Data 2 |
```
### 5. sample.eml (Optional)
- An email file with headers and body
- Can be exported from any email client
- Should contain some attachments for testing
### 6. sample.html (Optional)
- A simple HTML file with various elements
- Should include text, headings, and basic styling
## File Creation Tips
### Creating a test PDF:
1. Create a document in LibreOffice Writer or Google Docs
2. Add some text, headers, and a simple table
3. Export/Save as PDF
### Creating a test DOCX:
1. Create a document in Microsoft Word or LibreOffice Writer
2. Add formatted content (headers, bold, italic, lists)
3. Save as DOCX format
### Creating a test PNG:
1. Use any image editor or screenshot tool
2. Create a simple image with text or shapes
3. Save as PNG format
### Creating a test EML:
1. In your email client, save an email as .eml format
2. Or create manually with proper headers:
```
From: [email protected]
To: [email protected]
Subject: Test Email
Date: Mon, 1 Jan 2024 12:00:00 +0000
This is a test email for conversion testing.
```
## Test File Structure
```
frontend/src/tests/test-fixtures/
├── README.md (this file)
├── sample.pdf
├── sample.docx
├── sample.png
├── sample.jpg
├── sample.md
├── sample.eml (optional)
└── sample.html (optional)
```
## Usage in Tests
These files are referenced in the test files:
- `ConvertE2E.spec.ts` - Uses all files for E2E testing
- `ConvertIntegration.test.ts` - Uses files for integration testing
- Manual testing scenarios
## Security Note
These are test files only and should not contain any sensitive information. They will be committed to the repository and used in automated testing.
## File Size Guidelines
- Keep test files small for fast CI/CD pipelines and frontend testing
- PDF files: < 1MB (preferably 100-500KB)
- Image files: < 100KB
- Text files: < 50KB
- Focus on frontend functionality, not backend performance
## Maintenance
When updating the convert tool with new formats:
1. Add corresponding test files to this directory
2. Update the test files list above
3. Update the test cases to include the new formats
@@ -0,0 +1,74 @@
%PDF-1.3
%“Œ‹ž ReportLab Generated PDF document http://www.reportlab.com
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
>>
endobj
6 0 obj
<<
/Author (anonymous) /CreationDate (D:20250819094504+01'00') /Creator (ReportLab PDF Library - www.reportlab.com) /Keywords () /ModDate (D:20250819094504+01'00') /Producer (ReportLab PDF Library - www.reportlab.com)
/Subject (unspecified) /Title (untitled) /Trapped /False
>>
endobj
7 0 obj
<<
/Count 1 /Kids [ 4 0 R ] /Type /Pages
>>
endobj
8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 147
>>
stream
GarW00abco&4HDcidm(mI,3'DZY:^WQ,7!K+Bf&Mo_p+bJu"KZ.3A(M3%pEBpBe"=Bb3[h-Xt2ROZoe^Q)8NH>;#5qqB`Oee86NZp3Iif`:9`Y'Dq([aoCS4Veh*jH9C%+DV`*GHUK^ngmo`i~>endstream
endobj
xref
0 9
0000000000 65535 f
0000000073 00000 n
0000000114 00000 n
0000000221 00000 n
0000000333 00000 n
0000000526 00000 n
0000000594 00000 n
0000000890 00000 n
0000000949 00000 n
trailer
<<
/ID
[<46f6a3460762da2956d1d3fc19ab996f><46f6a3460762da2956d1d3fc19ab996f>]
% ReportLab generated PDF document -- digest (http://www.reportlab.com)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1186
%%EOF
@@ -0,0 +1 @@
This is not a valid PDF file
@@ -0,0 +1,6 @@
Name,Age,City,Country
John Doe,30,New York,USA
Jane Smith,25,London,UK
Bob Johnson,35,Toronto,Canada
Alice Brown,28,Sydney,Australia
Charlie Wilson,42,Berlin,Germany
1 Name Age City Country
2 John Doe 30 New York USA
3 Jane Smith 25 London UK
4 Bob Johnson 35 Toronto Canada
5 Alice Brown 28 Sydney Australia
6 Charlie Wilson 42 Berlin Germany
@@ -0,0 +1,10 @@
# Test DOC File
This is a test DOC file for conversion testing.
Content:
- Test bullet point 1
- Test bullet point 2
- Test bullet point 3
This file should be sufficient for testing office document conversions.
@@ -0,0 +1,105 @@
Return-Path: <[email protected]>
Delivered-To: [email protected]
Received: from mail.example.com (mail.example.com [192.168.1.1])
by mx.example.com (Postfix) with ESMTP id 1234567890
for <[email protected]>; Mon, 1 Jan 2024 12:00:00 +0000 (UTC)
Message-ID: <[email protected]>
Date: Mon, 1 Jan 2024 12:00:00 +0000
From: Test Sender <[email protected]>
User-Agent: Mozilla/5.0 (compatible; Test Email Client)
MIME-Version: 1.0
To: Test Recipient <[email protected]>
Subject: Test Email for Convert Tool
Content-Type: multipart/alternative;
boundary="------------boundary123456789"
This is a multi-part message in MIME format.
--------------boundary123456789
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 7bit
Test Email for Convert Tool
===========================
This is a test email for testing the EML to PDF conversion functionality.
Email Details:
- From: [email protected]
- To: [email protected]
- Subject: Test Email for Convert Tool
- Date: January 1, 2024
Content Features:
- Plain text content
- HTML content (in alternative part)
- Headers and metadata
- MIME structure
This email should convert to a PDF that includes:
1. Email headers (From, To, Subject, Date)
2. Email body content
3. Proper formatting
Important Notes:
- This is a test email only
- Generated for Stirling PDF testing
- Contains no sensitive information
- Should preserve email formatting in PDF
Best regards,
Test Email System
--------------boundary123456789
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 7bit
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Test Email</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<h1 style="color: #2c3e50;">Test Email for Convert Tool</h1>
<p>This is a <strong>test email</strong> for testing the EML to PDF conversion functionality.</p>
<h2 style="color: #34495e;">Email Details:</h2>
<ul>
<li><strong>From:</strong> [email protected]</li>
<li><strong>To:</strong> [email protected]</li>
<li><strong>Subject:</strong> Test Email for Convert Tool</li>
<li><strong>Date:</strong> January 1, 2024</li>
</ul>
<h2 style="color: #34495e;">Content Features:</h2>
<ul>
<li>Plain text content</li>
<li><em>HTML content</em> (this part)</li>
<li>Headers and metadata</li>
<li>MIME structure</li>
</ul>
<div style="background-color: #f8f9fa; padding: 15px; border-left: 4px solid #007bff; margin: 20px 0;">
<p><strong>This email should convert to a PDF that includes:</strong></p>
<ol>
<li>Email headers (From, To, Subject, Date)</li>
<li>Email body content</li>
<li>Proper formatting</li>
</ol>
</div>
<h3 style="color: #6c757d;">Important Notes:</h3>
<ul>
<li>This is a test email only</li>
<li>Generated for Stirling PDF testing</li>
<li>Contains no sensitive information</li>
<li>Should preserve email formatting in PDF</li>
</ul>
<p>Best regards,<br>
<strong>Test Email System</strong></p>
</body>
</html>
--------------boundary123456789--
@@ -0,0 +1,140 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Test HTML Document</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
margin: 40px;
color: #333;
}
h1 {
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
}
h2 {
color: #34495e;
margin-top: 30px;
}
table {
border-collapse: collapse;
width: 100%;
margin: 20px 0;
}
th,
td {
border: 1px solid #ddd;
padding: 12px;
text-align: left;
}
th {
background-color: #f2f2f2;
font-weight: bold;
}
.highlight {
background-color: #fff3cd;
padding: 10px;
border-left: 4px solid #ffc107;
margin: 20px 0;
}
code {
background-color: #f8f9fa;
padding: 2px 4px;
border-radius: 4px;
font-family: "Courier New", monospace;
}
</style>
</head>
<body>
<h1>Test HTML Document for Convert Tool</h1>
<p>
This is a <strong>test HTML file</strong> for testing the HTML to PDF
conversion functionality. It contains various HTML elements to ensure
proper conversion.
</p>
<h2>Text Formatting</h2>
<p>
This paragraph contains <strong>bold text</strong>, <em>italic text</em>,
and <code>inline code</code>.
</p>
<div class="highlight">
<p>
<strong>Important:</strong> This is a highlighted section that should be
preserved in the PDF output.
</p>
</div>
<h2>Lists</h2>
<h3>Unordered List</h3>
<ul>
<li>First item</li>
<li>Second item with <a href="https://example.com">a link</a></li>
<li>Third item</li>
</ul>
<h3>Ordered List</h3>
<ol>
<li>Primary point</li>
<li>Secondary point</li>
<li>Tertiary point</li>
</ol>
<h2>Table</h2>
<table>
<thead>
<tr>
<th>Column 1</th>
<th>Column 2</th>
<th>Column 3</th>
</tr>
</thead>
<tbody>
<tr>
<td>Data A</td>
<td>Data B</td>
<td>Data C</td>
</tr>
<tr>
<td>Test 1</td>
<td>Test 2</td>
<td>Test 3</td>
</tr>
<tr>
<td>Sample X</td>
<td>Sample Y</td>
<td>Sample Z</td>
</tr>
</tbody>
</table>
<h2>Code Block</h2>
<pre><code>function testFunction() {
console.log("This is a test function");
return "Hello from HTML to PDF conversion";
}</code></pre>
<h2>Final Notes</h2>
<p>
This HTML document should convert to a well-formatted PDF that preserves:
</p>
<ul>
<li>Text formatting (bold, italic)</li>
<li>Headings and hierarchy</li>
<li>Tables with proper borders</li>
<li>Lists (ordered and unordered)</li>
<li>Code formatting</li>
<li>Basic CSS styling</li>
</ul>
<p>
<small>Generated for Stirling PDF Convert Tool testing purposes.</small>
</p>
</body>
</html>
@@ -0,0 +1,140 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Test HTML Document</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
margin: 40px;
color: #333;
}
h1 {
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
}
h2 {
color: #34495e;
margin-top: 30px;
}
table {
border-collapse: collapse;
width: 100%;
margin: 20px 0;
}
th,
td {
border: 1px solid #ddd;
padding: 12px;
text-align: left;
}
th {
background-color: #f2f2f2;
font-weight: bold;
}
.highlight {
background-color: #fff3cd;
padding: 10px;
border-left: 4px solid #ffc107;
margin: 20px 0;
}
code {
background-color: #f8f9fa;
padding: 2px 4px;
border-radius: 4px;
font-family: "Courier New", monospace;
}
</style>
</head>
<body>
<h1>Test HTML Document for Convert Tool</h1>
<p>
This is a <strong>test HTML file</strong> for testing the HTML to PDF
conversion functionality. It contains various HTML elements to ensure
proper conversion.
</p>
<h2>Text Formatting</h2>
<p>
This paragraph contains <strong>bold text</strong>, <em>italic text</em>,
and <code>inline code</code>.
</p>
<div class="highlight">
<p>
<strong>Important:</strong> This is a highlighted section that should be
preserved in the PDF output.
</p>
</div>
<h2>Lists</h2>
<h3>Unordered List</h3>
<ul>
<li>First item</li>
<li>Second item with <a href="https://example.com">a link</a></li>
<li>Third item</li>
</ul>
<h3>Ordered List</h3>
<ol>
<li>Primary point</li>
<li>Secondary point</li>
<li>Tertiary point</li>
</ol>
<h2>Table</h2>
<table>
<thead>
<tr>
<th>Column 1</th>
<th>Column 2</th>
<th>Column 3</th>
</tr>
</thead>
<tbody>
<tr>
<td>Data A</td>
<td>Data B</td>
<td>Data C</td>
</tr>
<tr>
<td>Test 1</td>
<td>Test 2</td>
<td>Test 3</td>
</tr>
<tr>
<td>Sample X</td>
<td>Sample Y</td>
<td>Sample Z</td>
</tr>
</tbody>
</table>
<h2>Code Block</h2>
<pre><code>function testFunction() {
console.log("This is a test function");
return "Hello from HTML to PDF conversion";
}</code></pre>
<h2>Final Notes</h2>
<p>
This HTML document should convert to a well-formatted PDF that preserves:
</p>
<ul>
<li>Text formatting (bold, italic)</li>
<li>Headings and hierarchy</li>
<li>Tables with proper borders</li>
<li>Lists (ordered and unordered)</li>
<li>Code formatting</li>
<li>Basic CSS styling</li>
</ul>
<p>
<small>Generated for Stirling PDF Convert Tool testing purposes.</small>
</p>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,49 @@
# Test Document for Convert Tool
This is a **test** markdown file for testing the markdown to PDF conversion functionality.
## Features Being Tested
- **Bold text**
- *Italic text*
- [Links](https://example.com)
- Lists and formatting
### Code Block
```javascript
console.log('Hello, world!');
function testFunction() {
return "This is a test";
}
```
### Table
| Column 1 | Column 2 | Column 3 |
|----------|----------|----------|
| Data 1 | Data 2 | Data 3 |
| Test A | Test B | Test C |
## Lists
### Unordered List
- Item 1
- Item 2
- Nested item
- Another nested item
- Item 3
### Ordered List
1. First item
2. Second item
3. Third item
## Blockquote
> This is a blockquote for testing purposes.
> It should be properly formatted in the PDF output.
## Conclusion
This markdown file contains various elements to test the conversion functionality. The PDF output should preserve formatting, tables, code blocks, and other markdown elements.
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,12 @@
# Test PPTX Presentation
## Slide 1: Title
This is a test PowerPoint presentation for conversion testing.
## Slide 2: Content
- Test bullet point 1
- Test bullet point 2
- Test bullet point 3
## Slide 3: Conclusion
This file should be sufficient for testing presentation conversions.
@@ -0,0 +1,32 @@
<svg width="400" height="300" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="400" height="300" fill="#f8f9fa" stroke="#dee2e6" stroke-width="2"/>
<!-- Title -->
<text x="200" y="40" text-anchor="middle" font-family="Arial, sans-serif" font-size="24" font-weight="bold" fill="#2c3e50">
Test Image for Convert Tool
</text>
<!-- Shapes for visual content -->
<circle cx="100" cy="120" r="30" fill="#3498db" stroke="#2980b9" stroke-width="2"/>
<rect x="180" y="90" width="60" height="60" fill="#e74c3c" stroke="#c0392b" stroke-width="2"/>
<polygon points="320,90 350,150 290,150" fill="#f39c12" stroke="#e67e22" stroke-width="2"/>
<!-- Labels -->
<text x="100" y="170" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#7f8c8d">Circle</text>
<text x="210" y="170" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#7f8c8d">Square</text>
<text x="320" y="170" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#7f8c8d">Triangle</text>
<!-- Description -->
<text x="200" y="210" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" fill="#34495e">
This image tests conversion functionality
</text>
<text x="200" y="230" text-anchor="middle" font-family="Arial, sans-serif" font-size="12" fill="#95a5a6">
PNG/JPG ↔ PDF conversions
</text>
<!-- Footer -->
<text x="200" y="270" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" fill="#bdc3c7">
Generated for Stirling PDF testing - 400x300px
</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,8 @@
This is a test text file for conversion testing.
It contains multiple lines of text to test various conversion scenarios.
Special characters: àáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿ
Numbers: 1234567890
Symbols: !@#$%^&*()_+-=[]{}|;':\",./<>?
This file should be sufficient for testing text-based conversions.
@@ -0,0 +1,6 @@
Name,Age,City,Country,Department,Salary
John Doe,30,New York,USA,Engineering,75000
Jane Smith,25,London,UK,Marketing,65000
Bob Johnson,35,Toronto,Canada,Sales,70000
Alice Brown,28,Sydney,Australia,Design,68000
Charlie Wilson,42,Berlin,Germany,Operations,72000
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<document>
<title>Test Document</title>
<content>
<section id="1">
<heading>Introduction</heading>
<paragraph>This is a test XML document for conversion testing.</paragraph>
</section>
<section id="2">
<heading>Data</heading>
<data>
<item name="test1" value="value1"/>
<item name="test2" value="value2"/>
<item name="test3" value="value3"/>
</data>
</section>
</content>
</document>
@@ -0,0 +1,59 @@
import { describe, test, expect } from "vitest";
import fs from "fs";
import path from "path";
import { parse } from "smol-toml";
const LOCALES_DIR = path.join(__dirname, "../../../public/locales");
// Get all locale directories for parameterized tests
const getLocaleDirectories = () => {
if (!fs.existsSync(LOCALES_DIR)) {
return [];
}
return fs
.readdirSync(LOCALES_DIR, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name);
};
const localeDirectories = getLocaleDirectories();
describe("Translation TOML Validation", () => {
test("should find the locales directory", () => {
expect(fs.existsSync(LOCALES_DIR)).toBe(true);
});
test("should have at least one locale directory", () => {
expect(localeDirectories.length).toBeGreaterThan(0);
});
test.each(localeDirectories)(
"should have valid TOML in %s/translation.toml",
(localeDir) => {
const translationFile = path.join(
LOCALES_DIR,
localeDir,
"translation.toml",
);
// Check if file exists
expect(fs.existsSync(translationFile)).toBe(true);
// Read file content
const content = fs.readFileSync(translationFile, "utf8");
expect(content.trim()).not.toBe("");
// Parse TOML - this will throw if invalid TOML
let tomlData;
expect(() => {
tomlData = parse(content);
}).not.toThrow();
// Ensure it's an object at root level
expect(typeof tomlData).toBe("object");
expect(tomlData).not.toBeNull();
expect(Array.isArray(tomlData)).toBe(false);
},
);
});
@@ -0,0 +1,63 @@
import { describe, test, expect } from "vitest";
import fs from "fs";
import path from "path";
import { parse } from "smol-toml";
const LOCALES_DIR = path.join(__dirname, "../../../public/locales");
const getLocaleDirectories = () => {
if (!fs.existsSync(LOCALES_DIR)) {
return [];
}
return fs
.readdirSync(LOCALES_DIR, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name);
};
const findDottedKeys = (node: unknown, segments: string[] = []): string[] => {
if (!node || typeof node !== "object") {
return [];
}
const issues: string[] = [];
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
if (key.includes(".")) {
issues.push([...segments, key].join("."));
}
issues.push(...findDottedKeys(value, [...segments, key]));
}
return issues;
};
const localeDirectories = getLocaleDirectories();
describe("Translation key structure", () => {
test("should locate locales directory", () => {
expect(fs.existsSync(LOCALES_DIR)).toBe(true);
});
test("should have at least one locale directory", () => {
expect(localeDirectories.length).toBeGreaterThan(0);
});
test.each(localeDirectories)(
"should not contain dotted keys in %s/translation.toml",
(localeDir) => {
const translationFile = path.join(
LOCALES_DIR,
localeDir,
"translation.toml",
);
expect(fs.existsSync(translationFile)).toBe(true);
const data = parse(fs.readFileSync(translationFile, "utf8"));
const dottedKeys = findDottedKeys(data);
expect(
dottedKeys,
`Dotted keys found in ${localeDir}: ${dottedKeys.join(", ")}`,
).toHaveLength(0);
},
);
});
@@ -0,0 +1,29 @@
/**
* Test utilities for creating StirlingFile objects in tests
*/
import { StirlingFile, createStirlingFile } from "@app/types/fileContext";
/**
* Create a StirlingFile object for testing purposes
*/
export function createTestStirlingFile(
name: string,
content: string = "test content",
type: string = "application/pdf",
): StirlingFile {
const file = new File([content], name, { type });
return createStirlingFile(file);
}
/**
* Create multiple StirlingFile objects for testing
*/
export function createTestFilesWithId(
files: Array<{ name: string; content?: string; type?: string }>,
): StirlingFile[] {
return files.map(
({ name, content = "test content", type = "application/pdf" }) =>
createTestStirlingFile(name, content, type),
);
}