(frontend) add custom columns E2E tests

Test column visibility toggling, sort interactions,
and preference persistence across navigation.
Add shared test utilities for column assertions.
This commit is contained in:
Nathan Panchout
2026-03-31 11:13:24 +02:00
parent 0686605f05
commit a1a5688fd0
9 changed files with 407 additions and 14 deletions
+1
View File
@@ -13,6 +13,7 @@ and this project adheres to
- ✨(frontend) add PDF viewer with thumbnail sidebar, zoom and page navigation
- ✨(frontend) integrate PDF viewer into file preview modal
- 📝(doc) add local network setup documentation
- ✨(global) add custom columns feature with configurable grid columns
### Changed
@@ -1,2 +1,2 @@
module.exports = "test-file-stub";
module.exports = { src: "test-file-stub", height: 1, width: 1 };
@@ -0,0 +1,3 @@
module.exports = function prettyBytes(number) {
return String(number);
};
+2 -1
View File
@@ -11,6 +11,7 @@ const config: Config = {
// Handle static assets FIRST (before path aliases)
"\\.(css|less|scss|sass|svg|png|jpg|jpeg|gif)$":
"<rootDir>/__mocks__/fileMock.js",
"^pretty-bytes$": "<rootDir>/__mocks__/pretty-bytes.js",
// Then handle path aliases
...pathsToModuleNameMapper(tsconfig.compilerOptions.paths || {}, {
prefix: "<rootDir>/",
@@ -21,7 +22,7 @@ const config: Config = {
"ts-jest",
{
tsconfig: {
jsx: "react",
jsx: "react-jsx",
moduleResolution: "node",
},
},
+1 -1
View File
@@ -1,5 +1,5 @@
declare module "*.svg" {
const content: string;
const content: { src: string; height: number; width: number };
export default content;
}
@@ -0,0 +1,243 @@
import test, { expect } from "@playwright/test";
import path from "path";
import { clearDb, login } from "./utils-common";
import { clickToMyFiles, clickToRecent } from "./utils-navigate";
import {
createFolderInCurrentFolder,
createFileFromTemplate,
importFile,
} from "./utils-item";
import {
changeColumnType,
clickColumnSortButton,
clickNameSortButton,
expectColumnHeaderLabel,
expectRowNamesInOrder,
getCellText,
getColumnHeader,
} from "./utils/custom-columns-utils";
const PDF_FILE_PATH = path.join(__dirname, "/assets/pv_cm.pdf");
const DOCX_FILE_PATH = path.join(__dirname, "/assets/empty_doc.docx");
test.describe("Custom columns", () => {
test.beforeEach(async ({ page }) => {
await clearDb();
await login(page, "drive@example.com");
await page.goto("/");
await clickToMyFiles(page);
});
// ── Group 1: Default columns ──────────────────────────────────
test("Default columns are Last modified and Created by", async ({
page,
}) => {
await createFolderInCurrentFolder(page, "TestFolder");
await expectColumnHeaderLabel(page, 1, "Last modified");
await expectColumnHeaderLabel(page, 2, "Created by");
// The Last modified cell should show a relative time
const cellText = await getCellText(page, "TestFolder", 1);
expect(cellText).toMatch(/seconds? ago|minute/);
});
// ── Group 2: Change column types ──────────────────────────────
test("Change column 1 type via dropdown", async ({ page }) => {
await importFile(page, PDF_FILE_PATH);
// Wait for the uploaded file to appear (no optimistic update, needs API round-trip)
const fileRow = page.getByRole("row").filter({ hasText: "pv_cm" }).first();
await expect(fileRow).toBeVisible({ timeout: 15000 });
await changeColumnType(page, 1, "File size");
await expectColumnHeaderLabel(page, 1, "File size");
// File should show a size value (not "-")
const cellText = await getCellText(page, "pv_cm", 1);
expect(cellText).not.toBe("-");
expect(cellText).toBeTruthy();
});
test("Change column 2 type via dropdown", async ({ page }) => {
await createFolderInCurrentFolder(page, "MyFolder");
await changeColumnType(page, 2, "File type");
await expectColumnHeaderLabel(page, 2, "File type");
const cellText = await getCellText(page, "MyFolder", 2);
expect(cellText).toBe("Folder");
});
test("Change both columns", async ({ page }) => {
await createFolderInCurrentFolder(page, "MyFolder");
await changeColumnType(page, 1, "Created");
await changeColumnType(page, 2, "File size");
await expectColumnHeaderLabel(page, 1, "Created");
await expectColumnHeaderLabel(page, 2, "File size");
// Folder has no file size
const sizeText = await getCellText(page, "MyFolder", 2);
expect(sizeText).toBe("-");
// Created should show a relative time
const createdText = await getCellText(page, "MyFolder", 1);
expect(createdText).toMatch(/seconds? ago|minute/);
});
// ── Group 3: Persistence ──────────────────────────────────────
test("Column preferences persist after page reload", async ({ page }) => {
await createFolderInCurrentFolder(page, "MyFolder");
await changeColumnType(page, 1, "File type");
await expectColumnHeaderLabel(page, 1, "File type");
await page.reload();
await clickToMyFiles(page);
await expectColumnHeaderLabel(page, 1, "File type");
});
test("Column preferences are shared across views", async ({ page }) => {
// Create a file (not a folder) because Recents uses files_only mode
await createFileFromTemplate(page, "TestDoc");
await changeColumnType(page, 1, "File size");
await expectColumnHeaderLabel(page, 1, "File size");
await clickToRecent(page);
// Wait for the file to appear in Recents
const fileRow = page
.getByRole("row")
.filter({ hasText: "TestDoc" })
.first();
await expect(fileRow).toBeVisible({ timeout: 10000 });
await expectColumnHeaderLabel(page, 1, "File size");
});
// ── Group 4: Sorting ──────────────────────────────────────────
test("Sort by Name column cycles through asc/desc/default", async ({
page,
}) => {
await createFolderInCurrentFolder(page, "AAA");
await createFolderInCurrentFolder(page, "CCC");
await createFolderInCurrentFolder(page, "BBB");
// Default order: folders first, sorted by title = AAA, BBB, CCC
await expectRowNamesInOrder(page, ["AAA", "BBB", "CCC"]);
// Click sort → ascending
await clickNameSortButton(page);
await expectRowNamesInOrder(page, ["AAA", "BBB", "CCC"]);
// Click sort → descending
await clickNameSortButton(page);
await expectRowNamesInOrder(page, ["CCC", "BBB", "AAA"]);
// Click sort → reset to default
await clickNameSortButton(page);
await expectRowNamesInOrder(page, ["AAA", "BBB", "CCC"]);
});
test("Sort by a customizable column (Last modified)", async ({ page }) => {
await createFolderInCurrentFolder(page, "First");
// Delay to ensure distinct updated_at timestamps
await page.waitForTimeout(2000);
await createFolderInCurrentFolder(page, "Second");
// Click sort on col1 (Last modified) → ascending (oldest first)
await clickColumnSortButton(page, 1);
await expectRowNamesInOrder(page, ["First", "Second"]);
// Click again → descending (newest first)
await clickColumnSortButton(page, 1);
await expectRowNamesInOrder(page, ["Second", "First"]);
});
test("Sort by File size after changing column type", async ({ page }) => {
// Import two files of different sizes to avoid folders_first interference
await importFile(page, PDF_FILE_PATH);
await page.getByRole("row").filter({ hasText: "pv_cm" }).first().waitFor({
state: "visible",
timeout: 15000,
});
await importFile(page, DOCX_FILE_PATH);
await page
.getByRole("row")
.filter({ hasText: "empty_doc" })
.first()
.waitFor({ state: "visible", timeout: 15000 });
await changeColumnType(page, 1, "File size");
await expectColumnHeaderLabel(page, 1, "File size");
// Click sort on col1 → ascending (smallest first)
await clickColumnSortButton(page, 1);
await expectRowNamesInOrder(page, ["empty_doc", "pv_cm"]);
// Click again → descending (largest first)
await clickColumnSortButton(page, 1);
await expectRowNamesInOrder(page, ["pv_cm", "empty_doc"]);
});
// ── Group 5: Cell content ─────────────────────────────────────
test("Cells display correct content for each column type", async ({
page,
}) => {
await createFolderInCurrentFolder(page, "TestFolder");
// Switch col1 to File size → folders show "-"
await changeColumnType(page, 1, "File size");
const sizeText = await getCellText(page, "TestFolder", 1);
expect(sizeText).toBe("-");
// Switch col2 to File type → folders show "Folder"
await changeColumnType(page, 2, "File type");
const typeText = await getCellText(page, "TestFolder", 2);
expect(typeText).toBe("Folder");
// Switch col1 to Created → shows relative time
await changeColumnType(page, 1, "Created");
const createdText = await getCellText(page, "TestFolder", 1);
expect(createdText).toMatch(/seconds? ago|minute/);
});
// ── Group 6: Default marker in dropdown ───────────────────────
test("Default column option is marked in dropdown", async ({ page }) => {
await createFolderInCurrentFolder(page, "TestFolder");
// Open col1 dropdown — default for col1 is "Last modified"
const header1 = getColumnHeader(page, 1);
const dropdownButton1 = header1
.locator(".explorer__grid__header button")
.first();
await dropdownButton1.click();
await expect(
page.getByRole("menuitem", { name: "Last modified (default)" }),
).toBeVisible();
// Close dropdown
await page.keyboard.press("Escape");
// Open col2 dropdown — default for col2 is "Created by"
const header2 = getColumnHeader(page, 2);
const dropdownButton2 = header2
.locator(".explorer__grid__header button")
.first();
await dropdownButton2.click();
await expect(
page.getByRole("menuitem", { name: "Created by (default)" }),
).toBeVisible();
});
});
@@ -2,6 +2,7 @@ import test, { expect } from "@playwright/test";
import { clearDb, login } from "./utils-common";
import path from "path";
import { clickToMyFiles } from "./utils-navigate";
import { getRowItem } from "./utils-embedded-grid";
import { uploadFile } from "./utils/upload-utils";
test("Display HEIC not supported message when opening a HEIC file", async ({
@@ -21,14 +22,10 @@ test("Display HEIC not supported message when opening a HEIC file", async ({
// Wait for the file to be uploaded and visible in the list
await expect(page.getByText("Drop your files here")).not.toBeVisible();
await expect(page.getByRole("cell", { name: "test-image.heic" })).toBeVisible(
{
timeout: 10000,
},
);
// Click on the HEIC file to open the preview
await page.getByRole("cell", { name: "test-image.heic" }).dblclick();
const row = await getRowItem(page, "test-image");
await row.dblclick();
// Check that the file preview is visible
const filePreview = page.getByTestId("file-preview");
@@ -0,0 +1,149 @@
import { expect, Locator, Page } from "@playwright/test";
/**
* Get the <th> element for a customizable column slot.
*/
export const getColumnHeader = (page: Page, slot: 1 | 2): Locator => {
return page.locator(`th.explorer__grid__th--info-col-${slot}`);
};
/**
* Change the type of a customizable column via its dropdown menu.
*/
export const changeColumnType = async (
page: Page,
slot: 1 | 2,
columnLabel: string,
) => {
const header = getColumnHeader(page, slot);
// Click the dropdown button inside the header (first button = dropdown trigger)
const dropdownButton = header
.locator(".explorer__grid__header button")
.first();
await dropdownButton.click();
// Select the desired column type — use exact to avoid "Created" matching "Created by"
const menuItem = page.getByRole("menuitem", {
name: columnLabel,
exact: true,
});
await expect(menuItem).toBeVisible();
await menuItem.click();
};
/**
* Assert that a column header displays the expected label.
*/
export const expectColumnHeaderLabel = async (
page: Page,
slot: 1 | 2,
expectedLabel: string,
) => {
const header = getColumnHeader(page, slot);
const button = header.locator(".explorer__grid__header button").first();
await expect(button).toContainText(expectedLabel);
};
/**
* Click a sort button via native JS click (bypasses tooltip overlay).
* Waits for the aria-label to change, confirming the click was processed.
*/
const clickSortAndWaitForStateChange = async (
sortButton: Locator,
nextLabel: string,
) => {
// Use evaluate to trigger a native click — tooltips can intercept Playwright clicks
await sortButton.evaluate((el) => (el as HTMLButtonElement).click());
// Wait for the button aria-label to change (state updated)
await expect(sortButton).toHaveAttribute("aria-label", nextLabel, {
timeout: 10000,
});
};
const SORT_TRANSITIONS: Record<string, string> = {
"Sort ascending": "Sort descending",
"Sort descending": "Reset sorting",
"Reset sorting": "Sort ascending",
};
/**
* Click the sort button for the "Name" column header.
*/
export const clickNameSortButton = async (page: Page) => {
const nameHeader = page.locator("th").nth(1); // second th = Name column
const sortButton = nameHeader.locator(
".explorer__grid__header button[aria-label]",
);
const ariaLabel = await sortButton.getAttribute("aria-label");
const nextLabel = SORT_TRANSITIONS[ariaLabel ?? ""];
if (nextLabel) {
await clickSortAndWaitForStateChange(sortButton, nextLabel);
} else {
await sortButton.evaluate((el) => (el as HTMLButtonElement).click());
}
};
/**
* Click the sort button for a customizable column (slot 1 or 2).
*/
export const clickColumnSortButton = async (page: Page, slot: 1 | 2) => {
const header = getColumnHeader(page, slot);
// Second button in the header = sort button (first = dropdown trigger)
const sortButton = header.locator(".explorer__grid__header button").nth(1);
const ariaLabel = await sortButton.getAttribute("aria-label");
const nextLabel = SORT_TRANSITIONS[ariaLabel ?? ""];
if (nextLabel) {
await clickSortAndWaitForStateChange(sortButton, nextLabel);
} else {
await sortButton.evaluate((el) => (el as HTMLButtonElement).click());
}
};
/**
* Get all visible row names in order from the grid.
*/
export const getRowNamesInOrder = async (page: Page): Promise<string[]> => {
const nameElements = page.locator(
"tbody tr.selectable .explorer__grid__item__name",
);
const count = await nameElements.count();
if (count === 0) return [];
const names: string[] = [];
for (let i = 0; i < count; i++) {
const text = (await nameElements.nth(i).textContent()) ?? "";
names.push(text.trim());
}
return names;
};
/**
* Wait for row names to match the expected order (retries automatically).
*/
export const expectRowNamesInOrder = async (
page: Page,
expectedNames: string[],
) => {
await expect
.poll(async () => getRowNamesInOrder(page), { timeout: 10000 })
.toEqual(expectedNames);
};
/**
* Get the text content of a cell in a specific row at a given column slot.
* Slot 1 = first info column (3rd td), slot 2 = second info column (4th td).
*/
export const getCellText = async (
page: Page,
rowName: string,
slot: 1 | 2,
): Promise<string> => {
const row = page
.getByRole("row", { name: rowName })
.filter({ hasText: rowName })
.first();
await expect(row).toBeVisible();
// td indices: 0=mobile, 1=title, 2=info-col-1, 3=info-col-2, 4=actions
const cellIndex = slot === 1 ? 2 : 3;
const cell = row.locator("td").nth(cellIndex);
return ((await cell.textContent()) ?? "").trim();
};
@@ -2,6 +2,7 @@ import path from "path";
import test, { expect } from "@playwright/test";
import { clearDb, login } from "./utils-common";
import { clickToMyFiles } from "./utils-navigate";
import { getRowItem } from "./utils-embedded-grid";
import { uploadFile } from "./utils/upload-utils";
import { grantClipboardPermissions } from "./utils/various-utils";
@@ -19,12 +20,10 @@ test("Copy and paste works in wopi editor", async ({ page, context, browserName
// Wait for the file to be uploaded and visible in the list
await expect(page.getByText("Drop your files here")).not.toBeVisible();
await expect(page.getByRole("cell", { name: "empty_doc.docx" })).toBeVisible({
timeout: 10000,
});
// Click on the HEIC file to open the preview
await page.getByRole("cell", { name: "empty_doc.docx" }).dblclick();
// Click on the file to open the preview
const row = await getRowItem(page, "empty_doc");
await row.dblclick();
// Check that the file preview is visible
const filePreview = page.getByTestId("file-preview");