Add server-side folders and files page UI (#6383)

This commit is contained in:
Anthony Stirling
2026-05-27 12:52:46 +01:00
committed by GitHub
parent 4564ed5bec
commit d42b779644
90 changed files with 14720 additions and 663 deletions
@@ -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,
});