Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app/tasks/components/TaskLogger/TaskLogger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ const TaskLogger = (): JSX.Element => {

return (
<div
data-test="task-logger"
className={`absolute bottom-5 right-5 z-40 flex w-96 flex-col shadow-subtle-hard transition-height duration-350 ${
isMinimized ? 'h-11' : 'h-72'
} overflow-hidden rounded-xl border border-gray-10 bg-surface dark:bg-gray-1 ${!isOpen ? 'hidden' : ''}`}
Expand Down
74 changes: 74 additions & 0 deletions test/e2e/tests/helper/authRouteMocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { expect, Page, Request, Route } from '@playwright/test';
import { LoginPage } from '../pages/loginPage';
import { staticData } from './staticData';
import { getLoggedUser, getUserCredentials } from './getUser';

const BASE_API_URL = process.env.REACT_APP_DRIVE_NEW_API_URL;

export const INVALID_EMAIL = 'invalid@internxt.com';

const loggedUser = getLoggedUser();

const mockLoginCall = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
hasKeys: true,
sKey: '53616c7465645f5f2aa5386bc0b15f6f69a733acdd46a6551dc004f6c1cb6352390535de3ec17e9b96da7de984e5d27e79ad04a88a2cc8c6315f03dc0b0d174c',
tfa: false,
hasKyberKeys: true,
hasEccKeys: true,
}),
});
};

const mockAccessCall = async (route: Route, request: Request) => {
const { email } = request.postDataJSON();

if (email === INVALID_EMAIL) {
return route.fulfill({
status: 400,
body: JSON.stringify({ message: 'Wrong login credentials' }),
});
}

await route.fulfill({
status: 200,
body: JSON.stringify({
user: loggedUser.user,
token: loggedUser.token,
newToken: loggedUser.newToken,
userTeam: loggedUser.userTeam,
}),
});
};

const mockRefreshUserCall = async (route: Route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ user: loggedUser.user, newToken: loggedUser.newToken }),
});
};

/**
* Mocks the auth endpoints so specs can log in with the mocked user from user.json.
*/
export const mockAuthRoutes = async (page: Page) => {
await page.route(`${BASE_API_URL}/auth/login`, mockLoginCall);
await page.route(`${BASE_API_URL}/auth/login/access`, mockAccessCall);
await page.route(`${BASE_API_URL}/users/refresh`, mockRefreshUserCall);
};

export const logInThroughUI = async (page: Page) => {
const credentials = getUserCredentials();
const loginPage = new LoginPage(page);

await page.goto('/');
await expect(page).toHaveURL(/\/login$/);
await loginPage.typeEmail(credentials.email);
await loginPage.typePassword(credentials.password);
const driveTitle = await loginPage.clickLogIn();
expect(driveTitle).toEqual(staticData.driveTitle);
};
152 changes: 152 additions & 0 deletions test/e2e/tests/helper/driveRouteMocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { Page, Route } from '@playwright/test';
import { getLoggedUser } from './getUser';
import { mockAuthRoutes } from './authRouteMocks';

const BASE_API_URL = process.env.REACT_APP_DRIVE_NEW_API_URL;
const OLD_API_URL = process.env.REACT_APP_API_URL;
const PAYMENTS_API_URL = process.env.REACT_APP_PAYMENTS_API_URL;
const BRIDGE_URL = process.env.REACT_APP_STORJ_BRIDGE;

const loggedUser = getLoggedUser();

export type TrashRequest = { items: { uuid: string; type: string }[] };
export type ExistingFile = ReturnType<typeof buildExistingFile>;

/**
* Every request the specs may want to assert on, recorded by the mocked routes.
* Bridge uploads are only observable in Chromium: Playwright cannot route their CORS
* preflight in Firefox, so assert on the task panel when a spec must run in both.
*/
export type RecordedRequests = {
trash: TrashRequest[];
bridge: string[];
};

export const buildExistingFile = (id: number, plainName: string, type: string) => ({
id,
uuid: `existing-file-uuid-${id}`,
fileId: `existing-bridge-file-${id}`,
name: plainName,
plainName,
plain_name: plainName,
type,
size: 1024,
bucket: loggedUser.user.bucket,
folderUuid: loggedUser.user.rootFolderId,
createdAt: '2026-08-01T10:00:00.000Z',
updatedAt: '2026-08-01T10:00:00.000Z',
status: 'EXISTS',
thumbnails: [],
});

/**
* The Drive the mocked API serves. Trashing removes items, so follow-up listings and
* duplicate checks see the new state.
*/
class InMemoryDrive {
private files: ExistingFile[];

constructor(files: ExistingFile[]) {
this.files = [...files];
}

filesIn(folderUuid: string) {
return this.files.filter((file) => file.folderUuid === folderUuid);
}

trash(uuids: string[]) {
const trashed = new Set(uuids);
this.files = this.files.filter((file) => !trashed.has(file.uuid));
}
}

const fulfillJson = (route: Route, body: unknown) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });

const getFolderUuidFromUrl = (url: string) => /\/folders\/content\/([^/?]+)/.exec(url)?.[1] ?? '';

/**
* Answers the calls the app makes while bootstrapping the Drive view with minimal valid
* payloads, and any other API call with an empty 200. The app logs the user out on any
* 401, and the mocked session token is not valid against a real backend.
*/
const mockAppBootstrapCalls = async (page: Page) => {
for (const baseUrl of [BASE_API_URL, OLD_API_URL, PAYMENTS_API_URL]) {
await page.route(`${baseUrl}/**`, (route) => fulfillJson(route, {}));
}

const bootstrapResponses: Record<string, unknown> = {
'workspaces/': { availableWorkspaces: [], pendingWorkspaces: [] },
'sharings/invites**': { invites: [] },
'sharings/roles': [],
'files/limits': { versioning: { enabled: false, maxVersions: 0 }, maxUploadFileSize: 21474836480 },
'users/limit': { maxSpaceBytes: 10737418240 },
'users/usage': { drive: 3072, backups: 0, total: 3072 },
'users/me/upload-status': { hasUploadedFiles: true },
'users/avatar/refresh': { avatar: null },
'referral/enabled': { enabled: false },
};

for (const [path, body] of Object.entries(bootstrapResponses)) {
await page.route(`${BASE_API_URL}/${path}`, (route) => fulfillJson(route, body));
}
};

/**
* Folder listings and the file duplicate check, scoped to the folder in the URL.
*/
const mockFolderContentRoutes = (page: Page, drive: InMemoryDrive) =>
page.route(`${BASE_API_URL}/folders/content/**`, (route, request) => {
const url = request.url();
const folderUuid = getFolderUuidFromUrl(url);
const isExistenceCheck = request.method() === 'POST';

if (isExistenceCheck && url.endsWith('/files/existence')) {
const { files } = request.postDataJSON() as { files: { plainName: string; type: string }[] };
const existentFiles = drive
.filesIn(folderUuid)
.filter((existing) =>
files.some((file) => file.plainName === existing.plainName && file.type === existing.type),
);
return fulfillJson(route, { existentFiles });
}

if (isExistenceCheck) return fulfillJson(route, { existentFolders: [] });
if (url.includes('/files/')) return fulfillJson(route, { files: drive.filesIn(folderUuid) });
if (url.includes('/folders/')) return fulfillJson(route, { folders: [] });
return fulfillJson(route, {});
});

const mockTrashRoute = (page: Page, drive: InMemoryDrive, requests: RecordedRequests) =>
page.route(`${BASE_API_URL}/storage/trash/add`, (route, request) => {
const trashRequest = request.postDataJSON() as TrashRequest;
drive.trash(trashRequest.items.map((item) => item.uuid));
requests.trash.push(trashRequest);
return fulfillJson(route, {});
});

const mockBridgeRoute = (page: Page, requests: RecordedRequests) =>
page.route(`${BRIDGE_URL}/**buckets/**`, (route, request) => {
requests.bridge.push(request.url());
return route.abort();
});

/**
* Mocks everything a logged-in Drive view needs on top of an in-memory Drive made of the
* given files, blocks bucket uploads to the bridge, and returns the recorder of every
* request a spec may assert on.
* Routes are registered from the most generic to the most specific because Playwright
* matches the last registered route first.
*/
export const mockDriveRoutes = async (page: Page, files: ExistingFile[]): Promise<RecordedRequests> => {
const drive = new InMemoryDrive(files);
const requests: RecordedRequests = { trash: [], bridge: [] };

await mockAppBootstrapCalls(page);
await mockAuthRoutes(page);
await mockFolderContentRoutes(page, drive);
await mockTrashRoute(page, drive, requests);
await mockBridgeRoute(page, requests);

return requests;
};
33 changes: 33 additions & 0 deletions test/e2e/tests/helper/mockedDrive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { expect, Page } from '@playwright/test';
import { logInThroughUI } from './authRouteMocks';
import { ExistingFile, mockDriveRoutes } from './driveRouteMocks';
import { DrivePage } from '../pages/drivePage';
import { NameCollisionDialogPage } from '../pages/nameCollisionDialogPage';

const MIME_TYPES_BY_EXTENSION: Record<string, string> = {
txt: 'text/plain',
pdf: 'application/pdf',
};

export const buildUploadFile = (name: string) => ({
name,
mimeType: MIME_TYPES_BY_EXTENSION[name.split('.').pop() ?? ''] ?? 'application/octet-stream',
buffer: Buffer.from(`content of ${name}`),
});

/**
* Mocks the API around the given Drive, logs in through the UI and waits until the first
* file is listed. Returns the page objects and the recorder of the requests the app made.
*/
export const openMockedDrive = async (page: Page, files: ExistingFile[]) => {
const requests = await mockDriveRoutes(page, files);
await logInThroughUI(page);

const drivePage = new DrivePage(page);
const [firstFile] = files;
if (firstFile) {
await expect(drivePage.fileRow(`${firstFile.plainName}.${firstFile.type}`)).toBeVisible({ timeout: 10000 });
}

return { drivePage, collisionDialog: new NameCollisionDialogPage(page), requests };
};
7 changes: 7 additions & 0 deletions test/e2e/tests/helper/staticData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,11 @@ export const staticData = {
needHelpLinkText: 'Need help?',
driveTitle: 'Drive',
itemMovedToTrash: 'moved to trash',

//NAME COLLISION DIALOG
collisionDialogTitle: 'Item already exists',
collisionReplaceOption: 'Replace current item',
collisionKeepBothOption: 'Keep both',
collisionSkipOption: 'Skip this item',
collisionApplyToAll: 'Apply this action to all duplicates',
};
12 changes: 12 additions & 0 deletions test/e2e/tests/pages/drivePage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export class DrivePage {
private uploadDownloadWidget: Locator;
private uploadWidgetBorder: Locator;
private movingToTrashAndMovedSign: Locator;
private fileInput: Locator;

constructor(page: Page) {
this.page = page;
Expand Down Expand Up @@ -67,6 +68,7 @@ export class DrivePage {
'[class$="rounded-xl border border-gray-10 bg-surface dark:bg-gray-1 "]',
);
this.uploadWidgetBorder = this.page.locator('[class$="border-b border-gray-10 bg-gray-5 px-3 py-2.5"]');
this.fileInput = this.page.locator('[data-test="input-file"]');
}
async checkFolder(folderName: string) {
const folderLocator = this.allFolderNamesInDrive.filter({ hasText: folderName });
Expand Down Expand Up @@ -171,4 +173,14 @@ export class DrivePage {
const checkBox = item.locator('[class$="text-white border-gray-30 hover:border-gray-40"]');
await checkBox.click();
}
async uploadFiles(files: { name: string; mimeType: string; buffer: Buffer }[]) {
await this.fileInput.setInputFiles(files);
}
fileRow(fileName: string) {
return this.page.locator(`[title="${fileName}"]`);
}

taskItem(itemName: string) {
return this.page.locator(`[data-test="task-logger"] [title="${itemName}"]`);
}
}
59 changes: 59 additions & 0 deletions test/e2e/tests/pages/nameCollisionDialogPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { expect, Locator, Page } from '@playwright/test';
import { staticData } from '../helper/staticData';

export class NameCollisionDialogPage {
private page: Page;
private dialog: Locator;
private title: Locator;
private options: Locator;
private applyToAllLabel: Locator;
private applyToAllCheckbox: Locator;
private submitButton: Locator;

constructor(page: Page) {
this.page = page;
this.dialog = this.page.getByRole('dialog');
this.title = this.dialog.getByText(staticData.collisionDialogTitle);
this.options = this.dialog.getByRole('radio');
this.applyToAllLabel = this.dialog.getByText(staticData.collisionApplyToAll);
this.applyToAllCheckbox = this.dialog.locator('#apply-to-all');
this.submitButton = this.dialog.getByRole('button', { name: /^(Upload|Move)$/ });
}

async expectOpenFor(itemName: string) {
await expect(this.title).toBeVisible({ timeout: 10000 });
await expect(this.dialog.getByText(`${itemName} already exists in this location`)).toBeVisible();
}

async expectClosed() {
await expect(this.title).toBeHidden({ timeout: 10000 });
}

async expectOptions(optionNames: string[]) {
await expect(this.options).toHaveText(optionNames);
}

async expectApplyToAllVisible(isVisible: boolean) {
await expect(this.applyToAllLabel).toBeVisible({ visible: isVisible });
}

async selectOption(optionName: string) {
await this.dialog.getByRole('radio', { name: optionName }).click();
}

async checkApplyToAllByClickingLabel() {
await this.applyToAllLabel.click();
await expect(this.applyToAllCheckbox).toBeChecked();
}

async submit() {
await expect(this.submitButton).toBeVisible();
await this.submitButton.click();
}

async resolve(itemName: string, optionName: string) {
await this.expectOpenFor(itemName);
await this.selectOption(optionName);
await this.submit();
}
}
Loading
Loading