From b573d33e7c830c6a2b40c9e06fb4b0e347a13e3e Mon Sep 17 00:00:00 2001 From: Alex Freska Date: Tue, 18 Jun 2024 09:07:05 -0400 Subject: [PATCH] fix: renterd file rename, add e2e tests --- .changeset/gentle-countries-cry.md | 5 + .changeset/hip-turkeys-remember.md | 5 + .changeset/short-bears-grab.md | 5 + apps/renterd-e2e/src/fixtures/beforeTest.ts | 16 +++ apps/renterd-e2e/src/fixtures/buckets.ts | 39 +++++- .../src/fixtures/configResetAllSettings.ts | 2 +- apps/renterd-e2e/src/fixtures/files.ts | 115 ++++++++++++++++++ apps/renterd-e2e/src/fixtures/navigate.ts | 4 +- apps/renterd-e2e/src/specs/buckets.spec.ts | 9 +- apps/renterd-e2e/src/specs/config.spec.ts | 31 +---- apps/renterd-e2e/src/specs/files.spec.ts | 100 +++++++++++++++ apps/renterd-e2e/src/specs/sample.txt | 1 + .../components/Files/DirectoryContextMenu.tsx | 6 +- .../Files/FileContextMenu/index.tsx | 2 +- .../EmptyState/StateNoneYet.tsx | 31 ++++- .../FilesDirectory/FilesActionsMenu.tsx | 8 +- .../FilesDirectory/FilesExplorer.tsx | 12 +- apps/renterd/contexts/dialog.tsx | 2 - apps/renterd/dialogs/FileRenameDialog.tsx | 59 ++++----- .../dialogs/FilesBucketDeleteDialog.tsx | 27 ++-- .../src/app/AppAuthedLayout/Sidenav.tsx | 5 +- .../design-system/src/components/Dropzone.tsx | 8 +- .../src/components/Table/TableRow.tsx | 1 + .../src/components/Table/index.tsx | 8 +- .../src/form/configurationFields.ts | 9 +- package-lock.json | 16 +-- package.json | 2 +- 27 files changed, 419 insertions(+), 109 deletions(-) create mode 100644 .changeset/gentle-countries-cry.md create mode 100644 .changeset/hip-turkeys-remember.md create mode 100644 .changeset/short-bears-grab.md create mode 100644 apps/renterd-e2e/src/fixtures/beforeTest.ts create mode 100644 apps/renterd-e2e/src/fixtures/files.ts create mode 100644 apps/renterd-e2e/src/specs/files.spec.ts create mode 100644 apps/renterd-e2e/src/specs/sample.txt diff --git a/.changeset/gentle-countries-cry.md b/.changeset/gentle-countries-cry.md new file mode 100644 index 000000000..c9cb765b0 --- /dev/null +++ b/.changeset/gentle-countries-cry.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': minor +--- + +Updated react-hook-form. diff --git a/.changeset/hip-turkeys-remember.md b/.changeset/hip-turkeys-remember.md new file mode 100644 index 000000000..47fabc2ad --- /dev/null +++ b/.changeset/hip-turkeys-remember.md @@ -0,0 +1,5 @@ +--- +'renterd': patch +--- + +Fixed an issue where renaming a file would throw an error. diff --git a/.changeset/short-bears-grab.md b/.changeset/short-bears-grab.md new file mode 100644 index 000000000..8f2fcc203 --- /dev/null +++ b/.changeset/short-bears-grab.md @@ -0,0 +1,5 @@ +--- +'renterd': patch +--- + +Fixed an issue where the empty directory state was showing an empty bucket message. diff --git a/apps/renterd-e2e/src/fixtures/beforeTest.ts b/apps/renterd-e2e/src/fixtures/beforeTest.ts new file mode 100644 index 000000000..ecfb7b5fd --- /dev/null +++ b/apps/renterd-e2e/src/fixtures/beforeTest.ts @@ -0,0 +1,16 @@ +import { mockApiSiaCentralExchangeRates } from '@siafoundation/sia-central-mock' +import { login } from './login' +import { navigateToConfig } from './navigate' +import { configResetAllSettings } from './configResetAllSettings' +import { setViewMode } from './configViewMode' +import { Page } from 'playwright' + +export async function beforeTest(page: Page) { + await mockApiSiaCentralExchangeRates({ page }) + await login({ page }) + + // Reset state. + await navigateToConfig({ page }) + await configResetAllSettings({ page }) + await setViewMode({ page, state: 'basic' }) +} diff --git a/apps/renterd-e2e/src/fixtures/buckets.ts b/apps/renterd-e2e/src/fixtures/buckets.ts index cb57944e7..664a52053 100644 --- a/apps/renterd-e2e/src/fixtures/buckets.ts +++ b/apps/renterd-e2e/src/fixtures/buckets.ts @@ -2,6 +2,7 @@ import { Page, expect } from '@playwright/test' import { navigateToBuckets } from './navigate' import { fillTextInputByName } from './textInput' import { clearToasts } from './clearToasts' +import { deleteDirectory, deleteFile } from './files' export async function createBucket(page: Page, name: string) { await navigateToBuckets({ page }) @@ -25,10 +26,36 @@ export async function deleteBucket(page: Page, name: string) { export async function deleteBucketIfExists(page: Page, name: string) { const doesBucketExist = await page - .getByRole('table') + .getByTestId('bucketsTable') .getByText(name) .isVisible() if (doesBucketExist) { + await openBucket(page, name) + // The list changes to filesTable and is still loading=false for a split second + // before the files start fetching - this is why we need to wait for 1000ms. + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(1000) + await expect( + page.locator('[data-testid=filesTable][data-loading=false]') + ).toBeVisible() + const tableRows = await page + .getByTestId('filesTable') + .getByTestId(new RegExp(`${name}.*`)) + .all() + // First delete all top-level objects in the bucket, because a bucket + // can't be deleted if there are objects in it. + for (const row of tableRows) { + const id = await row.getAttribute('data-testid') + if (id === '..') { + continue + } + if (id?.endsWith('/')) { + await deleteDirectory(page, id) + } else { + await deleteFile(page, id) + } + } + await navigateToBuckets({ page }) await deleteBucket(page, name) } } @@ -37,10 +64,16 @@ export async function openBucketContextMenu(page: Page, name: string) { await page.getByRole('row', { name }).getByRole('button').first().click() } +export async function openBucket(page: Page, name: string) { + await page.getByRole('row').getByText(name).click() + await expect(page.getByTestId('navbar').getByText(name)).toBeVisible() + await expect(page.getByLabel('Upload files')).toBeVisible() +} + export async function bucketInList(page: Page, name: string) { - await expect(page.getByRole('table').getByText(name)).toBeVisible() + await expect(page.getByTestId('bucketsTable').getByText(name)).toBeVisible() } export async function bucketNotInList(page: Page, name: string) { - await expect(page.getByRole('table').getByText(name)).toBeHidden() + await expect(page.getByTestId('bucketsTable').getByText(name)).toBeHidden() } diff --git a/apps/renterd-e2e/src/fixtures/configResetAllSettings.ts b/apps/renterd-e2e/src/fixtures/configResetAllSettings.ts index fcb04aa6e..c0797ac42 100644 --- a/apps/renterd-e2e/src/fixtures/configResetAllSettings.ts +++ b/apps/renterd-e2e/src/fixtures/configResetAllSettings.ts @@ -13,7 +13,7 @@ export async function configResetAllSettings({ page }: { page: Page }) { await fillTextInputByName(page, 'storageTB', '1') await fillTextInputByName(page, 'uploadTBMonth', '1') await fillTextInputByName(page, 'downloadTBMonth', '1') - await fillTextInputByName(page, 'allowanceMonth', '1') + await fillTextInputByName(page, 'allowanceMonth', '21000') await fillTextInputByName(page, 'periodWeeks', '6') await fillTextInputByName(page, 'renewWindowWeeks', '2') await fillTextInputByName(page, 'amountHosts', '12') diff --git a/apps/renterd-e2e/src/fixtures/files.ts b/apps/renterd-e2e/src/fixtures/files.ts new file mode 100644 index 000000000..a707e8b05 --- /dev/null +++ b/apps/renterd-e2e/src/fixtures/files.ts @@ -0,0 +1,115 @@ +import { Page, expect } from '@playwright/test' +import { readFileSync } from 'fs' +import { fillTextInputByName } from './textInput' + +export async function deleteFile(page: Page, path: string) { + await openFileContextMenu(page, path) + await page.getByRole('menuitem', { name: 'Delete file' }).click() + await expect(page.getByRole('dialog').getByText('Delete file')).toBeVisible() + await page.locator('form button[type=submit]').click() + await expect(page.getByRole('dialog')).toBeHidden() + await fileNotInList(page, path) +} + +export async function deleteFileIfExists(page: Page, path: string) { + const exists = await page.getByRole('table').getByTestId(path).isVisible() + if (exists) { + await deleteFile(page, path) + } +} + +export async function deleteDirectory(page: Page, path: string) { + await openDirectoryContextMenu(page, path) + const deleteDirectoryItem = page.getByRole('menuitem', { + name: 'Delete directory', + }) + await expect(deleteDirectoryItem).toBeVisible() + await deleteDirectoryItem.click() + await expect( + page.getByRole('dialog').getByText('Delete directory') + ).toBeVisible() + await page.locator('form button[type=submit]').click() + await expect(page.getByRole('dialog')).toBeHidden() + await fileNotInList(page, path) +} + +export async function deleteDirectoryIfExists(page: Page, path: string) { + const exists = await page.getByRole('table').getByTestId(path).isVisible() + if (exists) { + await deleteDirectory(page, path) + } +} + +export async function openDirectoryContextMenu(page: Page, path: string) { + const selector = page.getByTestId(path).getByLabel('Directory context menu') + // Click doesn't work until animation is finished. + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(100) + await expect(selector).toBeVisible() + await selector.click() +} + +export async function openFileContextMenu(page: Page, path: string) { + const selector = page.getByTestId(path).getByLabel('File context menu') + await expect(selector).toBeVisible() + await selector.click() +} + +export async function openDirectory(page: Page, path: string) { + await page.getByRole('table').getByTestId(path).click() + for (const dir of path.split('/').slice(0, -1)) { + await expect(page.getByTestId('navbar').getByText(dir)).toBeVisible() + } +} + +export async function crateDirectory(page: Page, name: string) { + await expect(page.getByLabel('Create directory')).toBeVisible() + await page.getByLabel('Create directory').click() + await fillTextInputByName(page, 'name', name) + await page.locator('input[name=name]').press('Enter') + await expect(page.getByRole('dialog')).toBeHidden() +} + +export async function createDirectoryIfNotExists(page: Page, name: string) { + const exists = await page.getByRole('table').getByTestId(name).isVisible() + if (!exists) { + await crateDirectory(page, name) + } +} + +export async function fileInList(page: Page, path: string) { + await expect(page.getByRole('table').getByTestId(path)).toBeVisible() +} + +export async function fileNotInList(page: Page, path: string) { + await expect(page.getByRole('table').getByTestId(path)).toBeHidden() +} + +export async function dragAndDropFile( + page: Page, + selector: string, + filePath: string, + fileName: string, + fileType = '' +) { + const buffer = readFileSync(filePath).toString('base64') + + const dataTransfer = await page.evaluateHandle( + async ({ bufferData, localFileName, localFileType }) => { + const dt = new DataTransfer() + + const blobData = await fetch(bufferData).then((res) => res.blob()) + + const file = new File([blobData], localFileName, { type: localFileType }) + dt.items.add(file) + return dt + }, + { + bufferData: `data:application/octet-stream;base64,${buffer}`, + localFileName: fileName, + localFileType: fileType, + } + ) + + await page.dispatchEvent(selector, 'drop', { dataTransfer }) +} diff --git a/apps/renterd-e2e/src/fixtures/navigate.ts b/apps/renterd-e2e/src/fixtures/navigate.ts index 6493dcf19..6e75b5ba4 100644 --- a/apps/renterd-e2e/src/fixtures/navigate.ts +++ b/apps/renterd-e2e/src/fixtures/navigate.ts @@ -1,12 +1,12 @@ import { Page, expect } from '@playwright/test' export async function navigateToBuckets({ page }: { page: Page }) { - await page.getByLabel('Files').click() + await page.getByTestId('sidenav').getByLabel('Files').click() await expect(page.getByTestId('navbar').getByText('Buckets')).toBeVisible() } export async function navigateToConfig({ page }: { page: Page }) { - await page.getByLabel('Configuration').click() + await page.getByTestId('sidenav').getByLabel('Configuration').click() await expect( page.getByTestId('navbar').getByText('Configuration') ).toBeVisible() diff --git a/apps/renterd-e2e/src/specs/buckets.spec.ts b/apps/renterd-e2e/src/specs/buckets.spec.ts index afb5019f5..c44d1714b 100644 --- a/apps/renterd-e2e/src/specs/buckets.spec.ts +++ b/apps/renterd-e2e/src/specs/buckets.spec.ts @@ -1,6 +1,5 @@ import { test, expect } from '@playwright/test' import { navigateToBuckets } from '../fixtures/navigate' -import { login } from '../fixtures/login' import { bucketInList, createBucket, @@ -8,9 +7,13 @@ import { deleteBucketIfExists, openBucketContextMenu, } from '../fixtures/buckets' +import { beforeTest } from '../fixtures/beforeTest' + +test.beforeEach(async ({ page }) => { + await beforeTest(page) +}) test('can change a buckets policy', async ({ page }) => { - await login({ page }) await navigateToBuckets({ page }) await openBucketContextMenu(page, 'default') await page.getByRole('menuitem', { name: 'Change policy' }).click() @@ -22,7 +25,7 @@ test('can change a buckets policy', async ({ page }) => { }) test('can create and delete a bucket', async ({ page }) => { - await login({ page }) + await navigateToBuckets({ page }) await deleteBucketIfExists(page, 'my-new-bucket') await createBucket(page, 'my-new-bucket') await deleteBucket(page, 'my-new-bucket') diff --git a/apps/renterd-e2e/src/specs/config.spec.ts b/apps/renterd-e2e/src/specs/config.spec.ts index 211e85e50..2107dd02b 100644 --- a/apps/renterd-e2e/src/specs/config.spec.ts +++ b/apps/renterd-e2e/src/specs/config.spec.ts @@ -1,26 +1,23 @@ import { test, expect } from '@playwright/test' -import { login } from '../fixtures/login' import { expectSwitchByLabel, setSwitchByLabel } from '../fixtures/switchValue' import { setViewMode } from '../fixtures/configViewMode' import { navigateToConfig } from '../fixtures/navigate' -import { mockApiSiaCentralExchangeRates } from '@siafoundation/sia-central-mock' import { expectTextInputByName, expectTextInputByNameAttribute, fillTextInputByName, } from '../fixtures/textInput' -import { configResetAllSettings } from '../fixtures/configResetAllSettings' import { clearToasts } from '../fixtures/clearToasts' import { clickIfEnabledAndWait, clickIf } from '../fixtures/click' +import { beforeTest } from '../fixtures/beforeTest' -test('basic field change and save behaviour', async ({ page }) => { - // Set up. - await mockApiSiaCentralExchangeRates({ page }) - await login({ page }) +test.beforeEach(async ({ page }) => { + await beforeTest(page) +}) +test('basic field change and save behaviour', async ({ page }) => { // Reset state. await navigateToConfig({ page }) - await configResetAllSettings({ page }) await setViewMode({ page, state: 'basic' }) await setSwitchByLabel(page, 'autoAllowance', true) @@ -50,10 +47,6 @@ test('basic field change and save behaviour', async ({ page }) => { test('estimate based off storage, pricing, and redundancy', async ({ page, }) => { - // Set up. - await mockApiSiaCentralExchangeRates({ page }) - await login({ page }) - // Reset state. await navigateToConfig({ page }) await setSwitchByLabel(page, 'autoAllowance', true) @@ -84,10 +77,6 @@ test('estimate based off storage, pricing, and redundancy', async ({ }) test('configure with auto allowance', async ({ page }) => { - // Set up. - await mockApiSiaCentralExchangeRates({ page }) - await login({ page }) - // Reset state. await navigateToConfig({ page }) await setSwitchByLabel(page, 'autoAllowance', true) @@ -108,13 +97,8 @@ test('configure with auto allowance', async ({ page }) => { }) test('configure allowance manually', async ({ page }) => { - // Set up. - await mockApiSiaCentralExchangeRates({ page }) - await login({ page }) - // Reset state. await navigateToConfig({ page }) - await configResetAllSettings({ page }) await setSwitchByLabel(page, 'autoAllowance', false) await setViewMode({ page, state: 'basic' }) await fillTextInputByName(page, 'allowanceMonth', '777') @@ -135,13 +119,8 @@ test('configure allowance manually', async ({ page }) => { }) test('system offers recommendations', async ({ page }) => { - // Set up. - await mockApiSiaCentralExchangeRates({ page }) - await login({ page }) - // Reset state. await navigateToConfig({ page }) - await configResetAllSettings({ page }) await setViewMode({ page, state: 'basic' }) await setSwitchByLabel(page, 'autoAllowance', true) diff --git a/apps/renterd-e2e/src/specs/files.spec.ts b/apps/renterd-e2e/src/specs/files.spec.ts new file mode 100644 index 000000000..206c15040 --- /dev/null +++ b/apps/renterd-e2e/src/specs/files.spec.ts @@ -0,0 +1,100 @@ +import { test, expect } from '@playwright/test' +import { navigateToBuckets } from '../fixtures/navigate' +import { + createBucket, + deleteBucketIfExists, + openBucket, +} from '../fixtures/buckets' +import path from 'path' +import { + createDirectoryIfNotExists, + deleteDirectoryIfExists, + deleteFileIfExists, + dragAndDropFile, + fileInList, + fileNotInList, + openDirectory, + openFileContextMenu, +} from '../fixtures/files' +import { fillTextInputByName } from '../fixtures/textInput' +import { clearToasts } from '../fixtures/clearToasts' +import { beforeTest } from '../fixtures/beforeTest' + +test.beforeEach(async ({ page }) => { + await beforeTest(page) +}) + +test('can create directory, upload file, rename file, navigate, delete a file, delete a directory', async ({ + page, +}) => { + test.setTimeout(80_000) + const bucketName = 'files-test' + const dirName = `test-dir-${Date.now()}` + const originalFileName = 'sample.txt' + const newFileName = 'renamed.txt' + const dirPath = `${bucketName}/${dirName}/` + const originalFilePath = `${bucketName}/${dirName}/${originalFileName}` + const newFilePath = `${bucketName}/${dirName}/${newFileName}` + + await navigateToBuckets({ page }) + await deleteBucketIfExists(page, bucketName) + await createBucket(page, bucketName) + await openBucket(page, bucketName) + await expect( + page.getByText('bucket does not contain any files') + ).toBeVisible() + + // create directory + await createDirectoryIfNotExists(page, dirName) + await fileInList(page, dirPath) + await openDirectory(page, dirPath) + await expect( + page.getByText('The current directory does not contain any files yet') + ).toBeVisible() + await clearToasts({ page }) + + // upload + await dragAndDropFile( + page, + `[data-testid=filesDropzone]`, + path.join(__dirname, originalFileName), + originalFileName + ) + await fileInList(page, originalFilePath) + await expect(page.getByText('100%')).toBeVisible() + + // rename + await openFileContextMenu(page, originalFilePath) + await page.getByRole('menuitem', { name: 'Rename file' }).click() + await fillTextInputByName(page, 'name', 'renamed.txt') + await page.locator('input[name=name]').press('Enter') + await expect(page.getByRole('dialog')).toBeHidden() + await fileInList(page, newFilePath) + + // delete + await deleteFileIfExists(page, newFilePath) + await fileNotInList(page, newFilePath) + await clearToasts({ page }) + + // upload again + await dragAndDropFile( + page, + `[data-testid=filesDropzone]`, + path.join(__dirname, originalFileName), + originalFileName + ) + await fileInList(page, originalFilePath) + await expect(page.getByText('100%')).toBeVisible() + + // navigate back to root + await page.getByRole('cell', { name: '..' }).click() + await fileInList(page, dirPath) + + // delete directory + await deleteDirectoryIfExists(page, dirPath) + await fileNotInList(page, dirPath) + + // delete bucket + await navigateToBuckets({ page }) + await deleteBucketIfExists(page, bucketName) +}) diff --git a/apps/renterd-e2e/src/specs/sample.txt b/apps/renterd-e2e/src/specs/sample.txt new file mode 100644 index 000000000..95d09f2b1 --- /dev/null +++ b/apps/renterd-e2e/src/specs/sample.txt @@ -0,0 +1 @@ +hello world \ No newline at end of file diff --git a/apps/renterd/components/Files/DirectoryContextMenu.tsx b/apps/renterd/components/Files/DirectoryContextMenu.tsx index 3d86d18e2..8106875e7 100644 --- a/apps/renterd/components/Files/DirectoryContextMenu.tsx +++ b/apps/renterd/components/Files/DirectoryContextMenu.tsx @@ -21,7 +21,11 @@ export function DirectoryContextMenu({ path, size }: Props) { return ( + } diff --git a/apps/renterd/components/Files/FileContextMenu/index.tsx b/apps/renterd/components/Files/FileContextMenu/index.tsx index b1bb6b0fa..c4e91244c 100644 --- a/apps/renterd/components/Files/FileContextMenu/index.tsx +++ b/apps/renterd/components/Files/FileContextMenu/index.tsx @@ -36,7 +36,7 @@ export function FileContextMenu({ path }: Props) { return ( + } diff --git a/apps/renterd/components/FilesDirectory/EmptyState/StateNoneYet.tsx b/apps/renterd/components/FilesDirectory/EmptyState/StateNoneYet.tsx index db903c43e..62c6a887f 100644 --- a/apps/renterd/components/FilesDirectory/EmptyState/StateNoneYet.tsx +++ b/apps/renterd/components/FilesDirectory/EmptyState/StateNoneYet.tsx @@ -1,10 +1,37 @@ -import { Code, LinkButton, Text } from '@siafoundation/design-system' +import { Button, Code, LinkButton, Text } from '@siafoundation/design-system' import { CloudUpload32 } from '@siafoundation/react-icons' import { routes } from '../../../config/routes' import { useFilesManager } from '../../../contexts/filesManager' export function StateNoneYet() { - const { activeBucketName: activeBucket } = useFilesManager() + const { + activeBucketName: activeBucket, + activeDirectory, + setActiveDirectory, + } = useFilesManager() + if (activeDirectory.length > 1) { + return ( +
+ + + +
+ + The current directory does not contain any files yet, drag and drop + files or click here to start uploading. + + +
+
+ ) + } return (
diff --git a/apps/renterd/components/FilesDirectory/FilesActionsMenu.tsx b/apps/renterd/components/FilesDirectory/FilesActionsMenu.tsx index ac509f008..1ae07bca1 100644 --- a/apps/renterd/components/FilesDirectory/FilesActionsMenu.tsx +++ b/apps/renterd/components/FilesDirectory/FilesActionsMenu.tsx @@ -40,11 +40,17 @@ export function FilesActionsMenu() { -