mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
| | PR | Base | Branch | | --- | --- | --- | --- | | 1 | #50476 | `master` | pre-existing correctness fixes | | 2 | **this PR** | `fix/storage-explorer-listing-and-scroll` | `?path`/`?preview` deep-linking | | 3 | #50478 | `feat/storage-nav-improvement` | end-to-end deep-link test | | 4 | #50480 | `test/storage-deep-link-e2e` | copy path / copy link row actions | ## What is the current behavior? The file explorer doesn't keep track of folder navigation. Files and folders paths aren't shareable ## What is the new behavior? With this PR: - nav state is stored via params - "path" to store folder path (if nested folder paths) - "preview" to store the selected filename - back/forward nav history - file url opens correct folder/file [https://github.com/user-attachments/assets/](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)[0cfb7fcc-2c6e](https://github.com/user-attachments/assets/0cfb7fcc-2c6e-4f5a-950d-060c8eb2027b)[528d5c1d-a1b9](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)[-](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)[4f5a-950d](https://github.com/user-attachments/assets/0cfb7fcc-2c6e-4f5a-950d-060c8eb2027b)[4061-9b67](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)[-](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0)[060c8eb2027b](https://github.com/user-attachments/assets/0cfb7fcc-2c6e-4f5a-950d-060c8eb2027b)[a41dd98716e0](https://github.com/user-attachments/assets/528d5c1d-a1b9-4061-9b67-a41dd98716e0) ## Steps to review - Open bucket in Storage File Explorer - navigate between files and folders and notice url params change - reload page, it should reopen where you left off - hitting back/forward on the browser history should follow file/folder navigation history --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com>
328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
import { expect, Page } from '@playwright/test'
|
|
|
|
import { dismissToastsIfAny } from './dismiss-toast.js'
|
|
import { toUrl } from './to-url.js'
|
|
import { waitForApiResponse } from './wait-for-response.js'
|
|
|
|
/** Inline rename/create input in the storage file explorer (not the search box). */
|
|
export const getStorageRowNameInput = (page: Page) => page.locator('.storage-row-input')
|
|
|
|
/**
|
|
* Navigates to a the storage home view
|
|
* @param page - Playwright page instance
|
|
* @param ref - Project reference
|
|
*/
|
|
export const navigateToStorageFiles = async (page: Page, ref: string) => {
|
|
await page.goto(toUrl(`/project/${ref}/storage/files`))
|
|
await page.waitForLoadState('networkidle')
|
|
}
|
|
|
|
/**
|
|
* Creates a new storage bucket
|
|
* @param page - Playwright page instance
|
|
* @param ref - Project reference
|
|
* @param bucketName - Name of the bucket to create
|
|
* @param isPublic - Whether the bucket should be public (default: false)
|
|
*/
|
|
export const createBucket = async (
|
|
page: Page,
|
|
ref: string,
|
|
bucketName: string,
|
|
isPublic: boolean = false
|
|
) => {
|
|
await navigateToStorageFiles(page, ref)
|
|
// Check if bucket already exists
|
|
const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
|
|
if ((await bucketRow.count()) > 0) return
|
|
|
|
// Dismiss any toasts that might block the button
|
|
await dismissToastsIfAny(page)
|
|
|
|
// Click "New bucket" button
|
|
const newBucketBtn = page.getByRole('button', { name: 'New bucket' })
|
|
await expect(newBucketBtn, 'New bucket button should be visible').toBeVisible()
|
|
await newBucketBtn.click()
|
|
|
|
// Fill in bucket name
|
|
const nameInput = page.getByRole('textbox', { name: 'Bucket name' })
|
|
await expect(nameInput, 'Bucket name input should be visible').toBeVisible()
|
|
await nameInput.fill(bucketName)
|
|
|
|
// Toggle public setting if needed
|
|
if (isPublic) {
|
|
const publicToggle = page.getByRole('switch', { name: 'Public bucket' })
|
|
await publicToggle.click()
|
|
}
|
|
|
|
// Wait for bucket creation API call and click create button
|
|
const apiPromise = waitForApiResponse(page, 'storage', ref, 'bucket', { method: 'POST' })
|
|
await page.getByRole('button', { name: 'Create' }).click()
|
|
await apiPromise
|
|
|
|
// Verify bucket was created - bucket appears in table row
|
|
await expect(
|
|
page.getByRole('row').filter({ hasText: bucketName }),
|
|
`Bucket ${bucketName} should be visible after creation`
|
|
).toBeVisible()
|
|
}
|
|
|
|
/**
|
|
* Deletes a storage bucket
|
|
* @param page - Playwright page instance
|
|
* @param ref - Project reference
|
|
* @param bucketName - Name of the bucket to delete
|
|
*/
|
|
export const deleteBucket = async (page: Page, ref: string, bucketName: string) => {
|
|
// Check if bucket exists
|
|
const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
|
|
if ((await bucketRow.count()) === 0) return
|
|
|
|
// Navigate to the bucket first
|
|
await bucketRow.click()
|
|
await page.waitForURL(new RegExp(`/storage/files/buckets/${bucketName}`))
|
|
|
|
// Click "Edit bucket" dropdown
|
|
await page.getByRole('button', { name: 'Edit bucket' }).click()
|
|
|
|
// Click "Delete bucket" option from dropdown
|
|
await page.getByRole('menuitem', { name: 'Delete bucket' }).click()
|
|
|
|
// Type bucket name in the confirmation textbox (placeholder: "Type bucket name")
|
|
const confirmInput = page.getByPlaceholder('Type bucket name')
|
|
await expect(confirmInput, 'Confirmation input should be visible').toBeVisible({
|
|
timeout: 15_000,
|
|
})
|
|
await confirmInput.fill(bucketName)
|
|
|
|
// Wait for API call and click Delete bucket button
|
|
const apiPromise = waitForApiResponse(page, 'storage', ref, `buckets/${bucketName}`, {
|
|
method: 'DELETE',
|
|
})
|
|
await page.getByRole('button', { name: 'Delete bucket' }).click()
|
|
await apiPromise
|
|
|
|
// Verify bucket is no longer in the list. The post-delete redirect to
|
|
// /storage/files can race other history updates, so asserting the row
|
|
// is gone is a more stable signal that the delete actually succeeded.
|
|
await expect(
|
|
page.getByRole('row').filter({ hasText: bucketName }),
|
|
`Bucket ${bucketName} should not be visible after deletion`
|
|
).not.toBeVisible({ timeout: 10000 })
|
|
}
|
|
|
|
/**
|
|
* Navigates to a specific bucket
|
|
* @param page - Playwright page instance
|
|
* @param ref - Project reference
|
|
* @param bucketName - Name of the bucket to navigate to
|
|
*/
|
|
export const navigateToBucket = async (page: Page, ref: string, bucketName: string) => {
|
|
// Identify the bucket row to click
|
|
const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
|
|
await expect(bucketRow, `Bucket row for ${bucketName} should be visible`).toBeVisible()
|
|
|
|
// Wait for the objects list API request to complete
|
|
const objectsListPromise = page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes(`/platform/storage/${ref}/buckets/${bucketName}/objects/list`) &&
|
|
response.request().method() === 'POST' &&
|
|
(response.status() === 200 || response.status() === 201)
|
|
)
|
|
|
|
await bucketRow.click()
|
|
|
|
// Wait for the API response
|
|
await objectsListPromise
|
|
|
|
// Verify we're in the bucket by checking the breadcrumb or "Edit bucket" button
|
|
await expect(
|
|
page.getByRole('button', { name: 'Edit bucket' }),
|
|
`Should be in bucket ${bucketName}`
|
|
).toBeVisible()
|
|
}
|
|
|
|
/**
|
|
* Creates a new folder in the current bucket
|
|
* @param page - Playwright page instance
|
|
* @param folderName - Name of the folder to create
|
|
*/
|
|
export const createFolder = async (page: Page, folderName: string) => {
|
|
// Click "Create folder" button
|
|
const createFolderBtn = page.getByRole('button', { name: 'Create folder' })
|
|
await expect(createFolderBtn, 'Create folder button should be visible').toBeVisible()
|
|
await createFolderBtn.click()
|
|
|
|
// Inline folder name input appears (search box is a separate textbox)
|
|
const nameInput = getStorageRowNameInput(page)
|
|
await expect(nameInput, 'Folder name input should be visible').toBeVisible()
|
|
await nameInput.fill(folderName)
|
|
await nameInput.press('Enter')
|
|
|
|
// Wait for folder to appear in the list
|
|
await expect(
|
|
page.getByTitle(folderName),
|
|
`Folder ${folderName} should be visible after creation`
|
|
).toBeVisible()
|
|
}
|
|
|
|
/**
|
|
* Uploads a file to the current folder
|
|
* @param page - Playwright page instance
|
|
* @param filePath - Absolute path to the file to upload
|
|
* @param fileName - Expected file name (for verification)
|
|
*/
|
|
export const uploadFile = async (page: Page, filePath: string, fileName: string) => {
|
|
// Find the hidden file input and upload the file
|
|
const fileInput = page.locator('input[type="file"]')
|
|
await fileInput.setInputFiles(filePath)
|
|
|
|
// Wait out the upload progress toast. Scoped to the toast itself — a page-wide
|
|
// `getByRole('status')` also matches any live region the explorer renders.
|
|
await expect(page.locator('[data-sonner-toast]')).not.toBeVisible()
|
|
// Verify file appears in the explorer by title
|
|
await expect(
|
|
page.getByTitle(fileName),
|
|
`File ${fileName} should be visible in explorer after upload`
|
|
).toBeVisible()
|
|
// Verify its action button is visible too as it means the upload is indeed complete
|
|
await expect(page.getByRole('button', { name: `${fileName} actions` })).toBeVisible()
|
|
}
|
|
|
|
/**
|
|
* Deletes a file or folder from the current location
|
|
* @param page - Playwright page instance
|
|
* @param itemName - Name of the file or folder to delete
|
|
*/
|
|
export const deleteItem = async (page: Page, itemName: string) => {
|
|
// Right-click on the item to open context menu
|
|
const item = page.getByTitle(itemName)
|
|
await expect(item, `Item ${itemName} should be visible`).toBeVisible()
|
|
await item.click({ button: 'right' })
|
|
|
|
// Click delete option from context menu
|
|
await page.getByRole('menuitem', { name: 'Delete' }).click()
|
|
|
|
// Confirm deletion in the modal
|
|
await page.getByRole('button', { name: 'Submit' }).click()
|
|
|
|
// Wait for deletion to complete
|
|
await page.waitForTimeout(1000)
|
|
await expect(
|
|
page.getByTitle(itemName),
|
|
`Item ${itemName} should not be visible after deletion`
|
|
).not.toBeVisible()
|
|
}
|
|
|
|
/**
|
|
* Renames a file or folder
|
|
* @param page - Playwright page instance
|
|
* @param oldName - Current name of the item
|
|
* @param newName - New name for the item
|
|
*/
|
|
export const renameItem = async (page: Page, oldName: string, newName: string) => {
|
|
// Right-click on the item to open context menu
|
|
const item = page.getByTitle(oldName)
|
|
await expect(item, `Item ${oldName} should be visible`).toBeVisible()
|
|
await item.click({ button: 'right' })
|
|
|
|
// Click rename option from context menu
|
|
await page.getByRole('menuitem', { name: 'Rename' }).click()
|
|
|
|
const nameInput = getStorageRowNameInput(page)
|
|
await expect(nameInput, 'Rename input should be visible').toBeVisible()
|
|
await nameInput.fill(newName)
|
|
await nameInput.press('Enter')
|
|
|
|
// Wait for rename to complete
|
|
await page.waitForTimeout(1000)
|
|
|
|
// Verify item was renamed
|
|
await expect(page.getByTitle(newName), `Item should be renamed to ${newName}`).toBeVisible({
|
|
timeout: 30_000,
|
|
})
|
|
await expect(
|
|
page.getByTitle(oldName),
|
|
`Old name ${oldName} should not be visible after rename`
|
|
).not.toBeVisible()
|
|
}
|
|
|
|
/**
|
|
* Downloads a file and returns the download object
|
|
* @param page - Playwright page instance
|
|
* @param fileName - Name of the file to download
|
|
*/
|
|
export const downloadFile = async (page: Page, fileName: string) => {
|
|
// Right-click on the file to open context menu
|
|
const file = page.getByTitle(fileName)
|
|
await expect(file, `File ${fileName} should be visible`).toBeVisible()
|
|
await file.click({ button: 'right' })
|
|
|
|
// Click download option from context menu
|
|
const downloadPromise = page.waitForEvent('download')
|
|
await page.getByRole('menuitem', { name: 'Download' }).click()
|
|
|
|
// Wait for and return the download
|
|
const download = await downloadPromise
|
|
expect(download.suggestedFilename()).toContain(fileName)
|
|
}
|
|
|
|
export const deleteAllBuckets = async (page: Page, ref: string) => {
|
|
await navigateToStorageFiles(page, ref)
|
|
|
|
// Find all bucket rows and collect their IDs
|
|
const bucketRows = await page.locator('[data-bucket-id]').all()
|
|
const bucketIds = await Promise.all(bucketRows.map((row) => row.getAttribute('data-bucket-id')))
|
|
|
|
// Delete each bucket
|
|
for (const bucketId of bucketIds) {
|
|
if (bucketId) {
|
|
await deleteBucket(page, ref, bucketId)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Opens the move dialog for a file from its row actions menu.
|
|
*
|
|
* @param page - Playwright page instance
|
|
* @param fileName - Name of the file to move
|
|
* @returns The move dialog locator, to scope assertions to the picker
|
|
*/
|
|
export const openMoveDialog = async (page: Page, fileName: string) => {
|
|
// Opened from the row's context menu rather than its actions button: the actions button sits at
|
|
// the row's right edge, where a top-right toast can cover it, while a right-click targets the
|
|
// row's center. Both menus are built from the same options.
|
|
const row = page.getByTitle(fileName)
|
|
await expect(row, `Row for ${fileName} should be visible`).toBeVisible()
|
|
await row.click({ button: 'right' })
|
|
await page.getByRole('menuitem', { name: 'Move' }).click()
|
|
|
|
const dialog = page.getByRole('dialog')
|
|
await expect(dialog, 'Move dialog should be visible').toBeVisible()
|
|
await expect(dialog.getByText(`Move ${fileName}`), 'Dialog should name the file').toBeVisible()
|
|
|
|
return dialog
|
|
}
|
|
|
|
/**
|
|
* Confirms the move dialog, waiting for the move request so the assertion that follows runs
|
|
* against a settled explorer.
|
|
*
|
|
* @param page - Playwright page instance
|
|
* @param ref - Project reference
|
|
* @param destinationName - Folder name shown on the confirm button (the bucket name at the root)
|
|
*/
|
|
export const confirmMove = async (page: Page, ref: string, destinationName: string) => {
|
|
// The confirm button stays focusable when disabled, so it reports aria-disabled rather than
|
|
// the native disabled property
|
|
const moveButton = page.getByRole('button', { name: `Move to ${destinationName}` })
|
|
await expect(moveButton, `Move to ${destinationName} should be enabled`).not.toHaveAttribute(
|
|
'aria-disabled',
|
|
'true'
|
|
)
|
|
|
|
const movePromise = waitForApiResponse(page, 'storage', ref, 'objects/move', { method: 'POST' })
|
|
await moveButton.click()
|
|
await movePromise
|
|
}
|