mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
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:
co-authored by
Claude Opus 4.7
parent
48027ee9d6
commit
0a50e765b7
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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
Binary file not shown.
@@ -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
|
||||
|
@@ -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.
|
||||
Binary file not shown.
@@ -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.
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),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user