mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
Add server-side folders and files page UI (#6383)
This commit is contained in:
@@ -37,28 +37,30 @@ export async function waitForModalClose(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Upload one or more files through the FileSidebar's "Open from computer"
|
||||
* action. The button is always rendered (collapsed or expanded sidebar) and
|
||||
* triggers the hidden `data-testid="file-input"` native picker directly -
|
||||
* there is no modal to wait for under the post-refactor design.
|
||||
*
|
||||
* 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).
|
||||
* `setInputFiles` doesn't await the input's async onChange (which writes to
|
||||
* IndexedDB via `addFiles`), so without a sync point a caller that follows
|
||||
* with `page.goto()` can race the IDB flush. Wait for the workbench to
|
||||
* pick up the upload (the FileSidebar renders the added file in its scroll
|
||||
* list once `addFiles` resolves and IDB has been written).
|
||||
*/
|
||||
export async function uploadFiles(
|
||||
page: Page,
|
||||
filePaths: string | string[],
|
||||
opts: { awaitClose?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const { awaitClose = true } = opts;
|
||||
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
|
||||
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);
|
||||
}
|
||||
await page.locator('[data-testid="file-input"]').setInputFiles(paths);
|
||||
// Sync point: wait until at least one file lands in the sidebar's file
|
||||
// list. The list only renders once `addFiles` has resolved (which awaits
|
||||
// the IDB write). Use first() so multi-file uploads pass too.
|
||||
await expect(page.locator(".file-sidebar-file-item").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,6 @@ import { loginAndSetup } from "@app/tests/helpers/login";
|
||||
import {
|
||||
switchToEditorIfViewerMode,
|
||||
runToolAndWaitForReview,
|
||||
waitForModalOpen,
|
||||
waitForModalClose,
|
||||
} from "@app/tests/helpers/ui-helpers";
|
||||
import path from "path";
|
||||
@@ -33,19 +32,18 @@ test.describe("Encrypted PDF: unlock then 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)
|
||||
// Encrypted unlock modal appears once decryption is attempted.
|
||||
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",
|
||||
"Encrypted unlock modal not surfaced - fixture may not match this build",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* End-to-End Tests for Convert Tool
|
||||
*
|
||||
* All backend API calls are mocked via page.route() — no real backend required.
|
||||
* 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).
|
||||
*/
|
||||
|
||||
@@ -23,22 +23,14 @@ async function dismissTourTooltip(page: Page) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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.
|
||||
// Helper: upload a file via the FileSidebar's "Open from computer" action.
|
||||
// The button now triggers the native OS picker directly - no modal - and
|
||||
// the hidden `data-testid="file-input"` accepts `setInputFiles` in either
|
||||
// sidebar state.
|
||||
// ---------------------------------------------------------------------------
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -150,12 +142,12 @@ test.describe("Convert Tool", () => {
|
||||
await uploadFile(page, SAMPLE_PDF);
|
||||
await navigateToConvert(page);
|
||||
|
||||
// Before selecting TO format — button visible but disabled
|
||||
// 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
|
||||
// After selecting PNG as TO format - button enabled
|
||||
await selectToFormat(page, "png");
|
||||
await expect(convertBtn).toBeEnabled({ timeout: 3000 });
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 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.
|
||||
* 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.
|
||||
@@ -14,7 +14,7 @@
|
||||
*
|
||||
* 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.
|
||||
* unlock-all-wrong-password - all transitively covered or low-value.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
@@ -64,10 +64,7 @@ function mockRemovePasswordWrongPassword(page: Page) {
|
||||
|
||||
async function uploadEncryptedFile(page: Page, filePath: string) {
|
||||
await page.getByTestId("files-button").click();
|
||||
await page.waitForSelector(".mantine-Modal-overlay", {
|
||||
state: "visible",
|
||||
timeout: 5000,
|
||||
});
|
||||
// No modal flow - `files-button` triggers the native picker directly.
|
||||
await page.locator('[data-testid="file-input"]').setInputFiles(filePath);
|
||||
}
|
||||
|
||||
@@ -157,10 +154,7 @@ test.describe("Encrypted PDF Unlock Modal", () => {
|
||||
await mockRemovePasswordSuccess(page);
|
||||
|
||||
await page.getByTestId("files-button").click();
|
||||
await page.waitForSelector(".mantine-Modal-overlay", {
|
||||
state: "visible",
|
||||
timeout: 5000,
|
||||
});
|
||||
// No modal flow - `files-button` triggers the native picker directly.
|
||||
await page.locator('[data-testid="file-input"]').setInputFiles([
|
||||
{
|
||||
name: "encrypted-a.pdf",
|
||||
@@ -179,7 +173,7 @@ test.describe("Encrypted PDF Unlock Modal", () => {
|
||||
// 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.
|
||||
// on heavily-loaded CI runners - bump to 20s.
|
||||
const unlockAllBtn = page.getByRole("button", { name: /Use for all/ });
|
||||
await expect(unlockAllBtn).toBeVisible({ timeout: 20000 });
|
||||
|
||||
|
||||
@@ -16,19 +16,18 @@ test.describe("File state persists across tool navigation", () => {
|
||||
}) => {
|
||||
await uploadFiles(page, SAMPLE_PDF);
|
||||
|
||||
// Sanity: the file picker now lists the upload
|
||||
await page.getByTestId("files-button").click();
|
||||
// Sanity: My Files page lists the upload
|
||||
await page.getByTestId("my-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();
|
||||
// Re-open My Files - sample.pdf must still be there (persisted across tools)
|
||||
await page.getByTestId("my-files-button").click();
|
||||
await expect(page.getByText(/sample\.pdf/i).first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import type { Page, Route } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
/** Screenshot review of /files surfaces; dumps PNGs to screenshots/files-page. */
|
||||
|
||||
interface SeedFile {
|
||||
id: string;
|
||||
name: string;
|
||||
remoteStorageId: number | null;
|
||||
folderId?: string | null;
|
||||
}
|
||||
|
||||
async function seedFiles(page: Page, files: SeedFile[]): Promise<void> {
|
||||
await page.addInitScript((records) => {
|
||||
const open = window.indexedDB.open("stirling-pdf-files", 4);
|
||||
open.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
// Create both `files` and `folders` stores on this DB.
|
||||
if (!db.objectStoreNames.contains("files")) {
|
||||
const store = db.createObjectStore("files", { keyPath: "id" });
|
||||
store.createIndex("name", "name", { unique: false });
|
||||
store.createIndex("folderId", "folderId", { unique: false });
|
||||
store.createIndex("originalFileId", "originalFileId", {
|
||||
unique: false,
|
||||
});
|
||||
}
|
||||
if (!db.objectStoreNames.contains("folders")) {
|
||||
const fStore = db.createObjectStore("folders", { keyPath: "id" });
|
||||
fStore.createIndex("parentFolderId", "parentFolderId", {
|
||||
unique: false,
|
||||
});
|
||||
fStore.createIndex("name", "name", { unique: false });
|
||||
}
|
||||
};
|
||||
open.onsuccess = () => {
|
||||
const db = open.result;
|
||||
const tx = db.transaction("files", "readwrite");
|
||||
const store = tx.objectStore("files");
|
||||
const now = Date.now();
|
||||
for (const f of records) {
|
||||
store.put({
|
||||
id: f.id,
|
||||
fileId: f.id,
|
||||
quickKey: f.id,
|
||||
name: f.name,
|
||||
type: "application/pdf",
|
||||
size: 1024,
|
||||
lastModified: now,
|
||||
createdAt: now,
|
||||
data: new ArrayBuffer(8),
|
||||
thumbnail: null,
|
||||
isLeaf: true,
|
||||
versionNumber: 1,
|
||||
originalFileId: f.id,
|
||||
parentFileId: null,
|
||||
toolHistory: [],
|
||||
folderId: f.folderId ?? null,
|
||||
remoteStorageId: f.remoteStorageId,
|
||||
remoteStorageUpdatedAt: f.remoteStorageId ? now : null,
|
||||
remoteOwnerUsername: f.remoteStorageId ? "testuser" : null,
|
||||
remoteOwnedByCurrentUser: f.remoteStorageId ? true : null,
|
||||
remoteAccessRole: f.remoteStorageId ? "owner" : null,
|
||||
remoteSharedViaLink: false,
|
||||
remoteHasShareLinks: false,
|
||||
remoteShareToken: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
}, files);
|
||||
}
|
||||
|
||||
async function stubStorageApis(
|
||||
page: Page,
|
||||
opts: { storageEnabled?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const { storageEnabled = true } = opts;
|
||||
const configPayload = {
|
||||
appVersion: "test",
|
||||
storageEnabled,
|
||||
storageSharingEnabled: false,
|
||||
storageShareLinksEnabled: false,
|
||||
};
|
||||
await page.route("**/api/v1/config/app-config", (route: Route) =>
|
||||
route.fulfill({ json: configPayload }),
|
||||
);
|
||||
await page.route("**/api/v1/config", (route: Route) =>
|
||||
route.fulfill({ json: configPayload }),
|
||||
);
|
||||
await page.route("**/api/v1/storage/folders", (route: Route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
await page.route("**/api/v1/storage/**", (route: Route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
}
|
||||
|
||||
const SCREENSHOTS_DIR = path.resolve(
|
||||
process.cwd(),
|
||||
"screenshots",
|
||||
"files-page",
|
||||
);
|
||||
|
||||
function shotPath(name: string): string {
|
||||
return path.join(SCREENSHOTS_DIR, `${name}.png`);
|
||||
}
|
||||
|
||||
async function settle(page: Page, ms = 350): Promise<void> {
|
||||
// Let Mantine portal transitions settle.
|
||||
await page.waitForTimeout(ms);
|
||||
}
|
||||
|
||||
test.describe("Files page screenshots", () => {
|
||||
test.use({ autoGoto: false, viewport: { width: 1600, height: 900 } });
|
||||
|
||||
test("01_empty_state_ctas", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-empty")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("01_empty_state_ctas") });
|
||||
});
|
||||
|
||||
test("02_empty_state_storage_off", async ({ page }) => {
|
||||
await stubStorageApis(page, { storageEnabled: false });
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-empty")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("02_empty_state_storage_off") });
|
||||
});
|
||||
|
||||
test("03_subtoolbar_with_files", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
{ id: "bravo", name: "bravo.pdf", remoteStorageId: null },
|
||||
{ id: "cloud-c", name: "cloud-c.pdf", remoteStorageId: 1001 },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("03_subtoolbar_with_files") });
|
||||
});
|
||||
|
||||
test("04_kebab_save_to_server_local", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "alpha.pdf" });
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: /^Save to server$/i }),
|
||||
).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("04_kebab_save_to_server_local") });
|
||||
});
|
||||
|
||||
test("05_kebab_no_save_to_server_cloud", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "cloud-c", name: "cloud-c.pdf", remoteStorageId: 1001 },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "cloud-c.pdf" });
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: shotPath("05_kebab_no_save_to_server_cloud"),
|
||||
});
|
||||
});
|
||||
|
||||
test("06_details_panel_save_to_server", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "alpha.pdf" })
|
||||
.click();
|
||||
await expect(page.locator(".files-page-details")).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: shotPath("06_details_panel_save_to_server"),
|
||||
});
|
||||
});
|
||||
|
||||
test("07_move_dialog_collapsed", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "alpha.pdf" });
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await page.getByRole("menuitem", { name: /Move to/i }).click();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: /Move to folder/i }),
|
||||
).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("07_move_dialog_collapsed") });
|
||||
});
|
||||
|
||||
test("08_move_dialog_create_folder_expanded", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "alpha.pdf" });
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await page.getByRole("menuitem", { name: /Move to/i }).click();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: /Move to folder/i }),
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: /Create new folder/i }).click();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: /New folder name/i }),
|
||||
).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: shotPath("08_move_dialog_create_folder_expanded"),
|
||||
});
|
||||
});
|
||||
|
||||
test("09_subtoolbar_narrow_viewport", async ({ page, browserName }) => {
|
||||
test.skip(browserName !== "chromium", "viewport-resize spec");
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.setViewportSize({ width: 900, height: 700 });
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("09_subtoolbar_narrow_viewport") });
|
||||
});
|
||||
|
||||
test("08b_move_dialog_after_create_folder", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.route(
|
||||
"**/api/v1/storage/folders",
|
||||
async (route: Route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
// FolderId must be a UUID; timestamps must be ISO strings.
|
||||
await route.fulfill({
|
||||
json: {
|
||||
id: "11111111-2222-4333-8444-555555555555",
|
||||
name: "Reports",
|
||||
parentFolderId: null,
|
||||
color: null,
|
||||
icon: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({ json: [] });
|
||||
},
|
||||
{ times: 5 },
|
||||
);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "alpha.pdf" });
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await page.getByRole("menuitem", { name: /Move to/i }).click();
|
||||
await page.getByRole("button", { name: /Create new folder/i }).click();
|
||||
await page
|
||||
.getByRole("textbox", { name: /New folder name/i })
|
||||
.fill("Reports");
|
||||
await page.getByRole("button", { name: /^Create$/i }).click();
|
||||
// Inline row collapses back; create succeeded.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /Create new folder/i }),
|
||||
).toBeVisible({ timeout: 3_000 });
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: shotPath("08b_move_dialog_after_create_folder"),
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Dark mode pass ─────────────────────────────────────────────────────
|
||||
async function enableDarkMode(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("mantine-color-scheme", "dark");
|
||||
localStorage.setItem("mantine-color-scheme-value", "dark");
|
||||
});
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
}
|
||||
|
||||
test("11_dark_empty_state_ctas", async ({ page }) => {
|
||||
await enableDarkMode(page);
|
||||
await stubStorageApis(page);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-empty")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("11_dark_empty_state_ctas") });
|
||||
});
|
||||
|
||||
test("12_dark_subtoolbar_with_files", async ({ page }) => {
|
||||
await enableDarkMode(page);
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
{ id: "bravo", name: "bravo.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("12_dark_subtoolbar_with_files") });
|
||||
});
|
||||
|
||||
test("13_dark_move_dialog_create_folder", async ({ page }) => {
|
||||
await enableDarkMode(page);
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "alpha.pdf" });
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await page.getByRole("menuitem", { name: /Move to/i }).click();
|
||||
await page.getByRole("button", { name: /Create new folder/i }).click();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: /New folder name/i }),
|
||||
).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: shotPath("13_dark_move_dialog_create_folder"),
|
||||
});
|
||||
});
|
||||
|
||||
// ─── RTL pass ────────────────────────────────────────────────────────────
|
||||
async function enableRtl(page: Page): Promise<void> {
|
||||
// Seed language + dir before first paint.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("i18nextLng", "ar-AR");
|
||||
localStorage.setItem("stirling-language", "ar-AR");
|
||||
localStorage.setItem("stirling-language-source", "user");
|
||||
document.documentElement.setAttribute("dir", "rtl");
|
||||
document.documentElement.setAttribute("lang", "ar-AR");
|
||||
});
|
||||
}
|
||||
|
||||
test("15_rtl_empty_state_ctas", async ({ page }) => {
|
||||
await enableRtl(page);
|
||||
await stubStorageApis(page);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-empty")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("15_rtl_empty_state_ctas") });
|
||||
});
|
||||
|
||||
test("16_rtl_subtoolbar_with_files", async ({ page }) => {
|
||||
await enableRtl(page);
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
{ id: "bravo", name: "bravo.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("16_rtl_subtoolbar_with_files") });
|
||||
});
|
||||
|
||||
test("17_rtl_move_dialog_create_folder", async ({ page }) => {
|
||||
await enableRtl(page);
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "alpha.pdf" });
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await page.getByRole("menuitem", { name: /Move to/i }).click();
|
||||
await page.getByRole("button", { name: /Create new folder/i }).click();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: /New folder name/i }),
|
||||
).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: shotPath("17_rtl_move_dialog_create_folder"),
|
||||
});
|
||||
});
|
||||
|
||||
test("18_rtl_details_panel_save_to_server", async ({ page }) => {
|
||||
await enableRtl(page);
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "alpha.pdf" })
|
||||
.click();
|
||||
await expect(page.locator(".files-page-details")).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: shotPath("18_rtl_details_panel_save_to_server"),
|
||||
});
|
||||
});
|
||||
|
||||
test("14_dark_details_panel_save_to_server", async ({ page }) => {
|
||||
await enableDarkMode(page);
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "alpha.pdf" })
|
||||
.click();
|
||||
await expect(page.locator(".files-page-details")).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: shotPath("14_dark_details_panel_save_to_server"),
|
||||
});
|
||||
});
|
||||
|
||||
test("19_delete_folder_dialog", async ({ page }) => {
|
||||
const FOLDER_ID = "22222222-2222-4333-8444-555555555555";
|
||||
await stubStorageApis(page);
|
||||
// Seed a file inside the Reports folder so the checkbox appears.
|
||||
await seedFiles(page, [
|
||||
{
|
||||
id: "alpha",
|
||||
name: "alpha.pdf",
|
||||
remoteStorageId: 9001,
|
||||
folderId: FOLDER_ID,
|
||||
},
|
||||
{
|
||||
id: "bravo",
|
||||
name: "bravo.pdf",
|
||||
remoteStorageId: 9002,
|
||||
folderId: FOLDER_ID,
|
||||
},
|
||||
]);
|
||||
await page.route("**/api/v1/storage/folders", async (route: Route) => {
|
||||
await route.fulfill({
|
||||
json: [
|
||||
{
|
||||
id: FOLDER_ID,
|
||||
name: "Reports",
|
||||
parentFolderId: null,
|
||||
color: null,
|
||||
icon: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
// Wait for the Reports folder card or list row.
|
||||
await expect(page.getByText("Reports").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
// Open the kebab on the folder card.
|
||||
const folderCard = page
|
||||
.locator(".files-page-card.is-folder")
|
||||
.filter({ hasText: "Reports" })
|
||||
.first();
|
||||
await folderCard.getByRole("button", { name: /Folder actions/i }).click();
|
||||
await page.getByRole("menuitem", { name: /Delete folder/i }).click();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: /Delete folder\?/i }),
|
||||
).toBeVisible({ timeout: 3_000 });
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("19_delete_folder_dialog") });
|
||||
});
|
||||
|
||||
test("10_subtoolbar_phone_hidden", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({ path: shotPath("10_subtoolbar_phone_hidden") });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,819 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import type { Page, Route } from "@playwright/test";
|
||||
|
||||
/** Stubbed coverage for the /files page UI invariants. */
|
||||
|
||||
interface SeedFile {
|
||||
id: string;
|
||||
name: string;
|
||||
remoteStorageId: number | null;
|
||||
versionNumber?: number;
|
||||
toolHistory?: Array<{ toolId: string; timestamp: number }>;
|
||||
}
|
||||
|
||||
/** Seed IDB + register the cloud entries with the server stub. */
|
||||
async function seedFiles(page: Page, files: SeedFile[]): Promise<void> {
|
||||
// Build the server-side view from the cloud entries so reconcileServerFiles
|
||||
// sees them as still-existing on the server (otherwise they get detached).
|
||||
const serverFiles = files
|
||||
.filter((f) => f.remoteStorageId != null)
|
||||
.map((f) => ({
|
||||
id: f.remoteStorageId,
|
||||
fileName: f.name,
|
||||
contentType: "application/pdf",
|
||||
sizeBytes: 1024,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
owner: "testuser",
|
||||
ownedByCurrentUser: true,
|
||||
accessRole: "owner",
|
||||
shareLinks: [],
|
||||
filePurpose: "generic",
|
||||
folderId: null,
|
||||
}));
|
||||
await page.route("**/api/v1/storage/files", (route: Route) =>
|
||||
route.fulfill({ json: serverFiles }),
|
||||
);
|
||||
await page.addInitScript((records) => {
|
||||
const open = window.indexedDB.open("stirling-pdf-files", 4);
|
||||
open.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
// Create both `files` and `folders` stores on this DB.
|
||||
if (!db.objectStoreNames.contains("files")) {
|
||||
const store = db.createObjectStore("files", { keyPath: "id" });
|
||||
store.createIndex("name", "name", { unique: false });
|
||||
store.createIndex("folderId", "folderId", { unique: false });
|
||||
store.createIndex("originalFileId", "originalFileId", {
|
||||
unique: false,
|
||||
});
|
||||
}
|
||||
if (!db.objectStoreNames.contains("folders")) {
|
||||
const fStore = db.createObjectStore("folders", { keyPath: "id" });
|
||||
fStore.createIndex("parentFolderId", "parentFolderId", {
|
||||
unique: false,
|
||||
});
|
||||
fStore.createIndex("name", "name", { unique: false });
|
||||
}
|
||||
};
|
||||
open.onsuccess = () => {
|
||||
const db = open.result;
|
||||
const tx = db.transaction("files", "readwrite");
|
||||
const store = tx.objectStore("files");
|
||||
const now = Date.now();
|
||||
for (const f of records) {
|
||||
store.put({
|
||||
id: f.id,
|
||||
fileId: f.id,
|
||||
quickKey: f.id,
|
||||
name: f.name,
|
||||
type: "application/pdf",
|
||||
size: 1024,
|
||||
lastModified: now,
|
||||
createdAt: now,
|
||||
// Placeholder; opening would need real bytes.
|
||||
data: new ArrayBuffer(8),
|
||||
thumbnail: null,
|
||||
isLeaf: true,
|
||||
versionNumber: f.versionNumber ?? 1,
|
||||
originalFileId: f.id,
|
||||
parentFileId: null,
|
||||
toolHistory: f.toolHistory ?? [],
|
||||
folderId: null,
|
||||
remoteStorageId: f.remoteStorageId,
|
||||
remoteStorageUpdatedAt: f.remoteStorageId ? now : null,
|
||||
remoteOwnerUsername: f.remoteStorageId ? "testuser" : null,
|
||||
remoteOwnedByCurrentUser: f.remoteStorageId ? true : null,
|
||||
remoteAccessRole: f.remoteStorageId ? "owner" : null,
|
||||
remoteSharedViaLink: false,
|
||||
remoteHasShareLinks: false,
|
||||
remoteShareToken: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
}, files);
|
||||
}
|
||||
|
||||
/** Stub the storage + config endpoints hit on mount. */
|
||||
async function stubStorageApis(
|
||||
page: Page,
|
||||
opts: { storageEnabled?: boolean; sharingEnabled?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const { storageEnabled = true, sharingEnabled = false } = opts;
|
||||
// No enableLogin; setting it would trigger the auth redirect.
|
||||
const configPayload = {
|
||||
appVersion: "test",
|
||||
storageEnabled,
|
||||
storageSharingEnabled: sharingEnabled,
|
||||
storageShareLinksEnabled: sharingEnabled,
|
||||
};
|
||||
await page.route("**/api/v1/config/app-config", (route: Route) =>
|
||||
route.fulfill({ json: configPayload }),
|
||||
);
|
||||
await page.route("**/api/v1/config", (route: Route) =>
|
||||
route.fulfill({ json: configPayload }),
|
||||
);
|
||||
await page.route("**/api/v1/storage/folders", (route: Route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
await page.route("**/api/v1/storage/**", (route: Route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Navigate to /files and wait for at least one seeded card. */
|
||||
async function gotoFilesPage(page: Page): Promise<void> {
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-card").first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Files page", () => {
|
||||
test.describe("Selection model", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
{ id: "bravo", name: "bravo.pdf", remoteStorageId: null },
|
||||
{ id: "charlie", name: "charlie.pdf", remoteStorageId: null },
|
||||
{ id: "delta", name: "delta.pdf", remoteStorageId: null },
|
||||
]);
|
||||
});
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("plain click selects one file (single-select replaces)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
const cards = page.locator(".files-page-card:not(.is-folder)");
|
||||
await cards.nth(0).click();
|
||||
await expect(cards.locator(".is-selected")).toHaveCount(0);
|
||||
await expect(page.locator(".files-page-card.is-selected")).toHaveCount(1);
|
||||
|
||||
// Plain-clicking a different file replaces the selection.
|
||||
await cards.nth(1).click();
|
||||
await expect(page.locator(".files-page-card.is-selected")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("ctrl+click toggles into multi-select mode (sticky)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
const cards = page.locator(".files-page-card:not(.is-folder)");
|
||||
await cards.nth(0).click();
|
||||
await cards.nth(1).click({ modifiers: ["Control"] });
|
||||
await expect(page.locator(".files-page-card.is-selected")).toHaveCount(2);
|
||||
|
||||
// In multi-select (2+), plain-click ADDS instead of replacing.
|
||||
await cards.nth(2).click();
|
||||
await expect(page.locator(".files-page-card.is-selected")).toHaveCount(3);
|
||||
|
||||
// Plain-click an already-selected file in multi-mode removes it.
|
||||
await cards.nth(0).click();
|
||||
await expect(page.locator(".files-page-card.is-selected")).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("checkboxes hidden in single-select, visible in multi-select", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
const cards = page.locator(".files-page-card:not(.is-folder)");
|
||||
// 0 selected: no checkboxes anywhere on file cards.
|
||||
await expect(page.locator(".files-page-card-selector")).toHaveCount(0);
|
||||
|
||||
// 1 selected: still no checkbox (highlight border is the indicator).
|
||||
await cards.nth(0).click();
|
||||
await expect(page.locator(".files-page-card-selector")).toHaveCount(0);
|
||||
|
||||
// 2+ selected: checkboxes appear on every file card.
|
||||
await cards.nth(1).click({ modifiers: ["Control"] });
|
||||
await expect(
|
||||
page.locator(".files-page-card-selector").first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Select all tooltip explains Ctrl/Shift shortcuts", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
// Tooltip is the discovery point for Ctrl/Shift multi-select.
|
||||
const selectAll = page.getByRole("button", { name: /^Select all$/i });
|
||||
await selectAll.hover();
|
||||
await expect(
|
||||
page.getByText(/hold Ctrl.*Cmd.*Shift to select a range/i),
|
||||
).toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Bulk action button visibility", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "local-a", name: "local-a.pdf", remoteStorageId: null },
|
||||
{ id: "local-b", name: "local-b.pdf", remoteStorageId: null },
|
||||
{ id: "cloud-a", name: "cloud-a.pdf", remoteStorageId: 1001 },
|
||||
]);
|
||||
});
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("Save to server hidden when nothing selected", async ({ page }) => {
|
||||
await gotoFilesPage(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^Save to server$/i }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Save to server visible when local file selected", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
// Click the local-a card.
|
||||
await page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "local-a.pdf" })
|
||||
.click();
|
||||
// Two entry points share the name; use .first() for strict mode.
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^Save to server$/i }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^Save to server$/i }),
|
||||
).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("Save to server hidden when ONLY cloud files selected", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
// Cloud-only selection - nothing to save (already on server).
|
||||
await page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "cloud-a.pdf" })
|
||||
.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^Save to server$/i }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Per-file kebab has Save to server item for local file", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
// Open the kebab without first selecting.
|
||||
const localCard = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "local-a.pdf" });
|
||||
await localCard.getByRole("button", { name: /File actions/i }).click();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: /^Save to server$/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Per-file kebab hides Save to server for cloud file", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
// Cloud file kebab omits Save to server.
|
||||
const cloudCard = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "cloud-a.pdf" });
|
||||
await cloudCard.getByRole("button", { name: /File actions/i }).click();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: /^Save to server$/i }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Upload behaviour", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "seed", name: "seed.pdf", remoteStorageId: null },
|
||||
]);
|
||||
});
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("upload on /files page doesn't navigate the user away", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
// Write to the hidden file input directly.
|
||||
const tinyPdf = Buffer.from("%PDF-1.4\n%%EOF", "utf8");
|
||||
const input = page.locator('input[data-testid="file-input"]').first();
|
||||
if ((await input.count()) === 0) {
|
||||
test.skip(
|
||||
true,
|
||||
"No file-input testid on this build - upload entry-point selector drifted",
|
||||
);
|
||||
}
|
||||
await input.setInputFiles({
|
||||
name: "upload-test.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: tinyPdf,
|
||||
});
|
||||
// Upload must leave the user on /files.
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page).toHaveURL(/\/files/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Already-active file handling", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "active-test", name: "active-test.pdf", remoteStorageId: null },
|
||||
]);
|
||||
});
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("Add to workspace on already-active file navigates without crash", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "active-test.pdf" });
|
||||
await card.click();
|
||||
// First Add to workspace; routes to viewer.
|
||||
await page
|
||||
.getByRole("button", { name: /Add to workspace/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect(page).not.toHaveURL(/\/files/, { timeout: 3_000 });
|
||||
|
||||
// Re-add the now-active file; activation branches on requested stubs.
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
const card2 = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "active-test.pdf" });
|
||||
await expect(card2).toBeVisible({ timeout: 5_000 });
|
||||
await card2.click();
|
||||
await page
|
||||
.getByRole("button", { name: /Add to workspace/i })
|
||||
.first()
|
||||
.click();
|
||||
// Should still navigate away, NOT throw and leave us stuck on /files.
|
||||
await expect(page).not.toHaveURL(/\/files/, { timeout: 3_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Drag-and-drop wiring", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "drag-test", name: "drag-test.pdf", remoteStorageId: null },
|
||||
]);
|
||||
});
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("card thumbnail <img> is not natively draggable", async ({ page }) => {
|
||||
// draggable={false} keeps the card's onDragStart as drag authority.
|
||||
await gotoFilesPage(page);
|
||||
const thumbImg = page.locator(".files-page-card-thumb img").first();
|
||||
if ((await thumbImg.count()) === 0) {
|
||||
test.skip(
|
||||
true,
|
||||
"Seeded files have no thumbnailUrl so the <img> branch isn't rendered - drag-hijack regression can't surface",
|
||||
);
|
||||
}
|
||||
await expect(thumbImg).toHaveAttribute("draggable", "false");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Mobile details drawer", () => {
|
||||
test.use({
|
||||
autoGoto: false,
|
||||
viewport: { width: 500, height: 900 },
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "phone-a", name: "phone-a.pdf", remoteStorageId: null },
|
||||
{ id: "phone-b", name: "phone-b.pdf", remoteStorageId: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test("drawer does NOT auto-open on file selection", async ({ page }) => {
|
||||
// Drawer is button-triggered only.
|
||||
await gotoFilesPage(page);
|
||||
await page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "phone-a.pdf" })
|
||||
.click();
|
||||
// No drawer overlay should be present.
|
||||
await expect(page.locator(".mantine-Drawer-content")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Show details button opens drawer with file info", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
await page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "phone-a.pdf" })
|
||||
.click();
|
||||
await page.getByRole("button", { name: /Show details/i }).click();
|
||||
// Drawer opens, file name shown inside it.
|
||||
await expect(page.locator(".mantine-Drawer-content")).toBeVisible({
|
||||
timeout: 3_000,
|
||||
});
|
||||
await expect(
|
||||
page.locator(".mantine-Drawer-content").getByText("phone-a.pdf"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("multi-select still works while drawer is closed", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoFilesPage(page);
|
||||
const cards = page.locator(".files-page-card:not(.is-folder)");
|
||||
await cards.nth(0).click();
|
||||
// Drawer stays closed so the second click reaches the card.
|
||||
await cards.nth(1).click({ modifiers: ["Control"] });
|
||||
await expect(page.locator(".files-page-card.is-selected")).toHaveCount(2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Empty-state CTAs", () => {
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("renders Upload + Create folder CTAs when grid is empty", async ({
|
||||
page,
|
||||
}) => {
|
||||
await stubStorageApis(page);
|
||||
// No seedFiles - grid is empty so EmptyState renders.
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
// Wait for the empty state itself rather than card visibility -
|
||||
// gotoFilesPage's card-visibility wait would time out here.
|
||||
await expect(page.locator(".files-page-empty")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
// Both CTAs centered in the grid area where the eye lands.
|
||||
await expect(
|
||||
page
|
||||
.locator(".files-page-empty-actions")
|
||||
.getByRole("button", { name: /Upload files/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator(".files-page-empty-actions")
|
||||
.getByRole("button", { name: /Create folder/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Create folder CTA disabled when storage isn't reachable", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Storage disabled - the New folder action is gated and the CTA
|
||||
// should mirror that gating with a disabled state.
|
||||
await stubStorageApis(page, { storageEnabled: false });
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".files-page-empty")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const createCta = page
|
||||
.locator(".files-page-empty-actions")
|
||||
.getByRole("button", { name: /Create folder/i });
|
||||
await expect(createCta).toBeVisible();
|
||||
await expect(createCta).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Move dialog inline create-folder", () => {
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("Move dialog shows Create new folder affordance", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "to-move", name: "to-move.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await gotoFilesPage(page);
|
||||
// Open the move dialog via the per-file kebab.
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "to-move.pdf" });
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await page.getByRole("menuitem", { name: /Move to/i }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /Create new folder/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Side-rail integration with /files", () => {
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("Rail Search focuses the central search field, no navigation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await gotoFilesPage(page);
|
||||
// Click the search row in the rail.
|
||||
await page.locator(".file-sidebar-search-row").click();
|
||||
// The central search input should be focused.
|
||||
const focused = await page.evaluate(
|
||||
() => document.activeElement?.getAttribute("aria-label") ?? "",
|
||||
);
|
||||
expect(focused).toMatch(/Search/i);
|
||||
// And we must still be on /files (i.e. didn't navigate home).
|
||||
await expect(page).toHaveURL(/\/files/);
|
||||
});
|
||||
|
||||
test("Rail New folder button visible on /files", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await gotoFilesPage(page);
|
||||
// The extra action is the only thing with this testid.
|
||||
await expect(
|
||||
page.locator('[data-testid="files-rail-new-folder"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Server file sync", () => {
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("Server-only file downloads bytes when opened", async ({ page }) => {
|
||||
await stubStorageApis(page);
|
||||
const REMOTE_ID = 9001;
|
||||
await page.route("**/api/v1/storage/files", (route: Route) =>
|
||||
route.fulfill({
|
||||
json: [
|
||||
{
|
||||
id: REMOTE_ID,
|
||||
fileName: "cross-browser.pdf",
|
||||
contentType: "application/pdf",
|
||||
sizeBytes: 4096,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
owner: "testuser",
|
||||
ownedByCurrentUser: true,
|
||||
accessRole: "owner",
|
||||
shareLinks: [],
|
||||
filePurpose: "generic",
|
||||
folderId: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
let downloadHit = false;
|
||||
await page.route(
|
||||
`**/api/v1/storage/files/${REMOTE_ID}/download`,
|
||||
(route: Route) => {
|
||||
downloadHit = true;
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/pdf",
|
||||
"content-disposition": 'attachment; filename="cross-browser.pdf"',
|
||||
},
|
||||
body: Buffer.from("%PDF-1.4\n%%EOF", "utf8"),
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "cross-browser.pdf" });
|
||||
await expect(card).toBeVisible({ timeout: 5_000 });
|
||||
// Open via Add to workspace (kebab > Add to workspace).
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await page.getByRole("menuitem", { name: /Add to workspace/i }).click();
|
||||
// The materializer should have hit the download endpoint and
|
||||
// routed the user to the viewer (/).
|
||||
await expect(page).toHaveURL(/^https?:\/\/[^/]+\/?(\?|$)/, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
expect(downloadHit).toBe(true);
|
||||
});
|
||||
|
||||
test("Shared-link file appears in /files and materializes on open", async ({
|
||||
page,
|
||||
}) => {
|
||||
await stubStorageApis(page, { sharingEnabled: true });
|
||||
const SHARE_TOKEN = "tok-abc-123";
|
||||
// Owner-side listing has no entry for the shared file.
|
||||
await page.route("**/api/v1/storage/files", (route: Route) =>
|
||||
route.fulfill({ json: [] }),
|
||||
);
|
||||
await page.route(
|
||||
"**/api/v1/storage/share-links/accessed",
|
||||
(route: Route) =>
|
||||
route.fulfill({
|
||||
json: [
|
||||
{
|
||||
shareToken: SHARE_TOKEN,
|
||||
fileId: 4242,
|
||||
fileName: "shared-report.pdf",
|
||||
owner: "alice",
|
||||
ownedByCurrentUser: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAccessedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
let shareDownloadHit = false;
|
||||
await page.route(
|
||||
`**/api/v1/storage/share-links/${SHARE_TOKEN}`,
|
||||
(route: Route) => {
|
||||
shareDownloadHit = true;
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/pdf",
|
||||
"content-disposition": 'attachment; filename="shared-report.pdf"',
|
||||
},
|
||||
body: Buffer.from("%PDF-1.4\n%%EOF", "utf8"),
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
const card = page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "shared-report.pdf" });
|
||||
await expect(card).toBeVisible({ timeout: 5_000 });
|
||||
// Open the card and confirm the share-link download endpoint fires.
|
||||
await card.getByRole("button", { name: /File actions/i }).click();
|
||||
await page.getByRole("menuitem", { name: /Add to workspace/i }).click();
|
||||
await expect(page).toHaveURL(/^https?:\/\/[^/]+\/?(\?|$)/, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
expect(shareDownloadHit).toBe(true);
|
||||
});
|
||||
|
||||
test("Server-only files appear in /files on a fresh browser", async ({
|
||||
page,
|
||||
}) => {
|
||||
await stubStorageApis(page);
|
||||
// No local IDB seed. Override the GET /api/v1/storage/files route
|
||||
// to return a file that the server knows about. The /files grid
|
||||
// should pull this in via the new sync path.
|
||||
await page.route("**/api/v1/storage/files", (route: Route) =>
|
||||
route.fulfill({
|
||||
json: [
|
||||
{
|
||||
id: 9001,
|
||||
fileName: "cross-browser.pdf",
|
||||
contentType: "application/pdf",
|
||||
sizeBytes: 4096,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
owner: "testuser",
|
||||
ownedByCurrentUser: true,
|
||||
accessRole: "owner",
|
||||
shareLinks: [],
|
||||
filePurpose: "generic",
|
||||
folderId: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
// The file lands as a synthesised server stub.
|
||||
await expect(
|
||||
page
|
||||
.locator(".files-page-card:not(.is-folder)")
|
||||
.filter({ hasText: "cross-browser.pdf" }),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("Shared-by-me tab lists only files I own with share links", async ({
|
||||
page,
|
||||
}) => {
|
||||
await stubStorageApis(page, { sharingEnabled: true });
|
||||
// Three server files: one shared via link (owned by me), one shared
|
||||
// with users (owned by me), one plain mine, and one owned by someone else.
|
||||
await page.route("**/api/v1/storage/files", (route: Route) =>
|
||||
route.fulfill({
|
||||
json: [
|
||||
{
|
||||
id: 1,
|
||||
fileName: "link-shared.pdf",
|
||||
contentType: "application/pdf",
|
||||
sizeBytes: 100,
|
||||
createdAt: new Date().toISOString(),
|
||||
owner: "admin",
|
||||
ownedByCurrentUser: true,
|
||||
accessRole: "owner",
|
||||
shareLinks: [{ token: "tok1" }],
|
||||
sharedUsers: [],
|
||||
filePurpose: "generic",
|
||||
folderId: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
fileName: "user-shared.pdf",
|
||||
contentType: "application/pdf",
|
||||
sizeBytes: 100,
|
||||
createdAt: new Date().toISOString(),
|
||||
owner: "admin",
|
||||
ownedByCurrentUser: true,
|
||||
accessRole: "owner",
|
||||
shareLinks: [],
|
||||
sharedUsers: [{ username: "bob" }],
|
||||
filePurpose: "generic",
|
||||
folderId: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
fileName: "plain-mine.pdf",
|
||||
contentType: "application/pdf",
|
||||
sizeBytes: 100,
|
||||
createdAt: new Date().toISOString(),
|
||||
owner: "admin",
|
||||
ownedByCurrentUser: true,
|
||||
accessRole: "owner",
|
||||
shareLinks: [],
|
||||
sharedUsers: [],
|
||||
filePurpose: "generic",
|
||||
folderId: null,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
fileName: "from-someone-else.pdf",
|
||||
contentType: "application/pdf",
|
||||
sizeBytes: 100,
|
||||
createdAt: new Date().toISOString(),
|
||||
owner: "alice",
|
||||
ownedByCurrentUser: false,
|
||||
accessRole: "viewer",
|
||||
shareLinks: [],
|
||||
sharedUsers: [],
|
||||
filePurpose: "generic",
|
||||
folderId: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await page.goto("/files", { waitUntil: "domcontentloaded" });
|
||||
// Wait for the 4 cards to land via server sync.
|
||||
await expect(
|
||||
page.locator(".files-page-card:not(.is-folder)"),
|
||||
).toHaveCount(4, { timeout: 5_000 });
|
||||
|
||||
// "Shared by me" -> only link-shared.pdf
|
||||
await page.locator("#filesPage-tab-sharedByMe").click();
|
||||
const sharedByMeCards = page.locator(".files-page-card:not(.is-folder)");
|
||||
await expect(sharedByMeCards).toHaveCount(1, { timeout: 3_000 });
|
||||
await expect(sharedByMeCards.first()).toContainText("link-shared.pdf");
|
||||
|
||||
// "I'm sharing" -> only user-shared.pdf
|
||||
await page.locator("#filesPage-tab-imSharing").click();
|
||||
const imSharingCards = page.locator(".files-page-card:not(.is-folder)");
|
||||
await expect(imSharingCards).toHaveCount(1, { timeout: 3_000 });
|
||||
await expect(imSharingCards.first()).toContainText("user-shared.pdf");
|
||||
|
||||
// "Shared with me" -> only from-someone-else.pdf
|
||||
await page.locator("#filesPage-tab-shared").click();
|
||||
const sharedWithMeCards = page.locator(
|
||||
".files-page-card:not(.is-folder)",
|
||||
);
|
||||
await expect(sharedWithMeCards).toHaveCount(1, { timeout: 3_000 });
|
||||
await expect(sharedWithMeCards.first()).toContainText(
|
||||
"from-someone-else.pdf",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Folder tree panel resize", () => {
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("Resize handle is present and keyboard-adjustable", async ({
|
||||
page,
|
||||
}) => {
|
||||
await stubStorageApis(page);
|
||||
await seedFiles(page, [
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await gotoFilesPage(page);
|
||||
const handle = page.locator(".folder-tree-panel-resizer").first();
|
||||
await expect(handle).toBeVisible();
|
||||
const before = await page.evaluate(() => {
|
||||
const el = document.querySelector(
|
||||
".folder-tree-panel[data-active='true']",
|
||||
) as HTMLElement | null;
|
||||
return el?.getBoundingClientRect().width ?? 0;
|
||||
});
|
||||
await handle.focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await page.keyboard.press("ArrowRight");
|
||||
const after = await page.evaluate(() => {
|
||||
const el = document.querySelector(
|
||||
".folder-tree-panel[data-active='true']",
|
||||
) as HTMLElement | null;
|
||||
return el?.getBoundingClientRect().width ?? 0;
|
||||
});
|
||||
// Four 8px steps = +32px.
|
||||
expect(after).toBeGreaterThanOrEqual(before + 24);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,24 +9,17 @@ const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
|
||||
* and accepts a query catches the most damaging regression (search bar
|
||||
* disappears when reader refactors).
|
||||
*/
|
||||
test.describe("Reader — in-document text search", () => {
|
||||
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
|
||||
// Upload a PDF first so the reader has content. `files-button` now
|
||||
// triggers the native picker directly - no modal flow involved.
|
||||
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.
|
||||
|
||||
@@ -121,6 +121,43 @@ test.describe("Settings dialog", () => {
|
||||
await restored.click();
|
||||
});
|
||||
|
||||
test("close returns to origin URL even after switching tabs (no history pile-up)", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Land on / first so the originating URL is unambiguous.
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.locator('[data-testid="config-button"]').first(),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
const originPath = new URL(page.url()).pathname;
|
||||
const dialog = await openSettings(page);
|
||||
|
||||
// Click the same visible nav 3 times. In the buggy code each click
|
||||
// pushed a fresh history entry, so close-by-back popped only the
|
||||
// most recent tab change. The fix uses PUSH for the first nav (when
|
||||
// not yet in /settings/*) and REPLACE for subsequent navs, so the
|
||||
// origin URL stays at history depth 1 regardless of how many tabs
|
||||
// the user clicks through.
|
||||
const generalNav = dialog
|
||||
.locator('[data-tour="admin-general-nav"]')
|
||||
.first();
|
||||
await expect(generalNav).toBeVisible({ timeout: 5_000 });
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await generalNav.click();
|
||||
}
|
||||
// Wait for the URL to settle on /settings/general before closing so
|
||||
// we're not racing the in-modal nav under parallel-worker load.
|
||||
await page.waitForURL(/\/settings\/general/, { timeout: 5_000 });
|
||||
|
||||
await closeSettings(page);
|
||||
// Wait for the URL pathname to settle back to origin. Match only
|
||||
// pathname so trailing ?query or #hash don't trip the assertion.
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).pathname, { timeout: 5_000 })
|
||||
.toBe(originPath);
|
||||
});
|
||||
|
||||
test("config sub-sections (System / Features / Endpoints / API Keys) are reachable when present", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -20,9 +20,9 @@ import { uploadFiles, openSettings } from "@app/tests/helpers/ui-helpers";
|
||||
const SAMPLE_PDF = path.join(__dirname, "../test-fixtures/sample.pdf");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.1 Static layout — always visible on the main page
|
||||
// 15.1 Static layout - always visible on the main page
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("15.1 Tour selectors — static layout", () => {
|
||||
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,
|
||||
@@ -55,7 +55,7 @@ test.describe("15.1 Tour selectors — static layout", () => {
|
||||
|
||||
// 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 ({
|
||||
test.skip("help-button is present - TODO: add to new sidebar layout", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.locator('[data-tour="help-button"]').first()).toBeVisible(
|
||||
@@ -65,10 +65,18 @@ test.describe("15.1 Tour selectors — static layout", () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.2 Tour selectors — files modal
|
||||
// 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 }) => {
|
||||
// SKIPPED post-refactor: the FilesModal is no longer reachable from the
|
||||
// FileSidebar's `files-button` (which now triggers the native OS picker
|
||||
// directly). The modal is still rendered for AddFileCard /
|
||||
// PageEditorFileDropdown / LandingActions paths, but none of those have a
|
||||
// stable testid yet to drive from a spec. Re-enable once a `data-testid`
|
||||
// is added to one of the modal-opening entry points.
|
||||
test.describe("15.2 Tour selectors - files modal", () => {
|
||||
test.skip("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(),
|
||||
@@ -78,9 +86,9 @@ test.describe("15.2 Tour selectors — files modal", () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.3 Tour selectors — workbench elements (require a loaded file)
|
||||
// 15.3 Tour selectors - workbench elements (require a loaded file)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("15.3 Tour selectors — workbench with file", () => {
|
||||
test.describe("15.3 Tour selectors - workbench with file", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await uploadFiles(page, SAMPLE_PDF);
|
||||
});
|
||||
@@ -99,10 +107,10 @@ test.describe("15.3 Tour selectors — workbench with file", () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.4 Tour selectors — active files view (file-card-checkbox, file-card-pin)
|
||||
// 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.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]);
|
||||
@@ -132,9 +140,9 @@ test.describe("15.4 Tour selectors — active files view", () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.5 Tour selectors — crop tool (crop-settings, run-button)
|
||||
// 15.5 Tour selectors - crop tool (crop-settings, run-button)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("15.5 Tour selectors — crop tool", () => {
|
||||
test.describe("15.5 Tour selectors - crop tool", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/crop", { waitUntil: "domcontentloaded" });
|
||||
await uploadFiles(page, SAMPLE_PDF);
|
||||
@@ -154,9 +162,9 @@ test.describe("15.5 Tour selectors — crop tool", () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.6 Tour selectors — config modal (non-admin)
|
||||
// 15.6 Tour selectors - config modal (non-admin)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("15.6 Tour selectors — config modal", () => {
|
||||
test.describe("15.6 Tour selectors - config modal", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openSettings(page);
|
||||
});
|
||||
@@ -175,13 +183,13 @@ test.describe("15.6 Tour selectors — config modal", () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15.7 Tour selectors — admin config modal nav items
|
||||
// 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
|
||||
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 },
|
||||
@@ -216,7 +224,7 @@ test.describe("15.7 Tour selectors — admin modal nav items", () => {
|
||||
if (!isPresent) {
|
||||
test.skip(
|
||||
true,
|
||||
`admin-${section}-nav not rendered — section may be EE-only or not yet ported to this build`,
|
||||
`admin-${section}-nav not rendered - section may be EE-only or not yet ported to this build`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -31,11 +31,11 @@ test.describe("Unsaved changes navigation guard", () => {
|
||||
await page.goto("/split");
|
||||
}
|
||||
|
||||
// After arriving at /split the file picker should still list the
|
||||
// After arriving at /split the My Files page 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 page.getByTestId("my-files-button").click();
|
||||
await expect(page.getByText(/sample\.pdf/i).first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user