Files
supabase/e2e/studio/features/storage.spec.ts
T
09f36316a7 test(studio): cover storage explorer deep links end to end (#50478)
| | PR | Base | Branch |
| --- | --- | --- | --- |
| 1 | #50476 | `master` | pre-existing correctness fixes |
| 2 | #50413 | `fix/storage-explorer-listing-and-scroll` |
`?path`/`?preview` deep-linking |
| 3 | **this PR** | `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 `?path` / `?preview` deep-linking added in #50413 has no end-to-end
coverage. It is exercised by unit and component tests, but nothing
verifies the real round trip through a browser and a live Storage
backend.

## What is the new behavior?

One spec that drills into a nested folder and asserts `?path`, opens a
file and asserts `?preview`, reloads to confirm the deep link restores
the same location rather than dropping back to the bucket root, then
goes Back to confirm it walks up one level.

Assertions go through `toHaveURL((url) => url.searchParams.get(...) ===
...)` rather than a regex over the whole URL, so dots and other regex
metacharacters in file names are compared literally.

## Additional context

Kept on its own branch for one reason: **this spec has never been
executed.** It was written without a local Supabase stack available to
run it against, so its first real run is here in CI
(`studio-e2e-test.yml`, `framework: [next, tanstack]` × 2 shards).
Isolating it means iterating on it cannot hold up the feature work in
#50413 below.

Expect this one to need a pass or two.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DB6KEERERPWtLL4SY2RX4Q

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Tests**
* Improved end-to-end coverage for storage workflows, including bucket
management, file and folder operations, uploads, downloads, search, and
deep links.
* Added reliable cleanup for test-created storage data, including when
navigation, uploads, or assertions fail.
* Continued verification of storage navigation and file and folder
management behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com>
2026-09-18 10:28:45 +02:00

645 lines
22 KiB
TypeScript

import path from 'path'
import { expect } from '@playwright/test'
import { env } from '../env.config.js'
import { expectClipboardValue } from '../utils/clipboard.js'
import {
createBucket,
createFolder,
deleteBucket,
deleteItem,
downloadFile,
getStorageRowNameInput,
navigateToBucket,
navigateToStorageFiles,
renameItem,
uploadFile,
} from '../utils/storage-helpers.js'
import {
createBucket as createBucketViaApi,
deleteBucket as deleteBucketViaApi,
} from '../utils/storage/index.js'
import { test, withSetupCleanup } from '../utils/test.js'
import { waitForApiResponse } from '../utils/wait-for-response.js'
const bucketNamePrefix = 'pw_bucket'
test.describe('Storage', () => {
test.beforeEach(async ({ page, ref }) => {
await navigateToStorageFiles(page, ref)
})
test('can navigate to storage page', async ({ page, ref }) => {
await expect(
page.getByRole('button', { name: 'New bucket' }),
'New bucket button should be visible'
).toBeVisible()
// Verify we're on the storage files page
await expect(page).toHaveURL(new RegExp(`/project/${ref}/storage/files`))
})
test('can create a private bucket', async ({ page, ref }) => {
const bucketName = `${bucketNamePrefix}_private`
await using _ = await withSetupCleanup(
async () => {
// Nothing
},
async () => {
await deleteBucketViaApi(bucketName)
}
)
await createBucket(page, ref, bucketName, false)
// Verify it's marked as private (no "Public" badge should be visible)
const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
await expect(bucketRow, 'Bucket row should be visible').toBeVisible()
await expect(
bucketRow.getByText('Public'),
'Private bucket should not have Public badge'
).not.toBeVisible()
})
test('can create a public bucket', async ({ page, ref }) => {
const bucketName = `${bucketNamePrefix}_public`
await using _ = await withSetupCleanup(
async () => {
// Nothing
},
async () => {
await deleteBucketViaApi(bucketName)
}
)
await createBucket(page, ref, bucketName, true)
// Verify it's marked as public - wait for the badge to appear
const bucketRow = page.getByRole('row').filter({ hasText: bucketName })
await expect(bucketRow, 'Bucket row should be visible').toBeVisible()
// The Public badge should be visible within the bucket row
await expect(
bucketRow.getByText('Public', { exact: true }),
'Bucket should be marked as Public'
).toBeVisible()
})
test('can edit bucket settings', async ({ page, ref }) => {
const bucketName = `${bucketNamePrefix}_edit`
// Create a fresh private bucket via API
await using _ = await withSetupCleanup(
async () => {
await createBucketViaApi(bucketName, false)
},
async () => {
await deleteBucketViaApi(bucketName)
}
)
await navigateToStorageFiles(page, ref)
// Navigate to the bucket
await navigateToBucket(page, ref, bucketName)
// Open edit bucket dropdown
await page.getByRole('button', { name: 'Edit bucket' }).click()
await page.getByRole('menuitem', { name: 'Bucket settings' }).click()
// Toggle public setting
const publicToggle = page.getByRole('switch', { name: 'Public bucket' })
await expect(publicToggle, 'Public toggle should be visible').toBeVisible()
await publicToggle.click()
// Save changes
const apiPromise = waitForApiResponse(page, 'storage', ref, `buckets/${bucketName}`, {
method: 'PATCH',
})
await page.getByRole('button', { name: 'Save' }).click()
await apiPromise
// Verify the bucket is now public
await expect(
page.getByText('Public').first(),
'Bucket should now be marked as Public'
).toBeVisible()
})
test('can delete a bucket', async ({ page, ref }) => {
const bucketName = `${bucketNamePrefix}_delbkt`
// Create a bucket via API
await using _ = await withSetupCleanup(
async () => {
await createBucketViaApi(bucketName, false)
},
async () => {
await deleteBucketViaApi(bucketName)
}
)
await navigateToStorageFiles(page, ref)
// Delete it via UI
await deleteBucket(page, ref, bucketName)
// Verify it's gone
await expect(
page.getByRole('row').filter({ hasText: bucketName }),
'Bucket should not be visible after deletion'
).not.toBeVisible()
})
test('can search for buckets', async ({ page, ref }) => {
const bucketName1 = `${bucketNamePrefix}_search_1`
const bucketName2 = `${bucketNamePrefix}_search_2`
// Create two buckets via API
await using _ = await withSetupCleanup(
async () => {
await createBucketViaApi(bucketName1, false)
await createBucketViaApi(bucketName2, false)
},
async () => {
await deleteBucketViaApi(bucketName1)
await deleteBucketViaApi(bucketName2)
}
)
await navigateToStorageFiles(page, ref)
// Search for first bucket
const searchInput = page.getByPlaceholder('Search for a bucket')
await searchInput.fill('search_1')
// Verify only first bucket is visible
await expect(
page.getByRole('row').filter({ hasText: bucketName1 }),
'First bucket should be visible in search results'
).toBeVisible()
await expect(
page.getByRole('row').filter({ hasText: bucketName2 }),
'Second bucket should not be visible in search results'
).not.toBeVisible()
// Clear search
await searchInput.clear()
// Verify both buckets are visible
await expect(
page.getByRole('row').filter({ hasText: bucketName1 }),
'First bucket should be visible after clearing search'
).toBeVisible()
await expect(
page.getByRole('row').filter({ hasText: bucketName2 }),
'Second bucket should be visible after clearing search'
).toBeVisible()
})
test('can upload a file', async ({ page, ref }) => {
const bucketName = `${bucketNamePrefix}_upload`
const fileName = 'test-file.txt'
// Create a bucket via API and navigate to it
await using _ = await withSetupCleanup(
async () => {
await createBucketViaApi(bucketName, false)
},
async () => {
await deleteBucketViaApi(bucketName)
}
)
await navigateToStorageFiles(page, ref)
await navigateToBucket(page, ref, bucketName)
// Upload a file
const filePath = path.join(import.meta.dirname, 'files', fileName)
await uploadFile(page, filePath, fileName)
})
test('can create a folder', async ({ page, ref }) => {
const bucketName = `${bucketNamePrefix}_newfolder`
const folderName = 'test_folder'
// Create a bucket via API and navigate to it
await using _ = await withSetupCleanup(
async () => {
await createBucketViaApi(bucketName, false)
},
async () => {
await deleteBucketViaApi(bucketName)
}
)
await navigateToStorageFiles(page, ref)
await navigateToBucket(page, ref, bucketName)
// Create a folder
await createFolder(page, folderName)
})
test('deep links to a nested folder and file via the URL', async ({ page, ref }) => {
const bucketName = `${bucketNamePrefix}_deeplink`
const folderName = 'deeplink_folder'
const fileName = 'test-file.txt'
await using _ = await withSetupCleanup(
async () => {
await createBucketViaApi(bucketName, false)
},
async () => {
await deleteBucketViaApi(bucketName)
}
)
await navigateToStorageFiles(page, ref)
await navigateToBucket(page, ref, bucketName)
// Creating a folder drills into it, so the upload lands inside the folder
await createFolder(page, folderName)
await expect(page.getByText('Drop your files here')).toBeVisible()
const filePath = path.join(import.meta.dirname, 'files', fileName)
await uploadFile(page, filePath, fileName)
// Reload at the bucket root so opening the folder is a real navigation rather than
// the store drilling in on its own — only the former pushes a history entry
await navigateToStorageFiles(page, ref)
await navigateToBucket(page, ref, bucketName)
await page.getByTitle(folderName).click()
// Compare the parsed params rather than regex-matching the URL, so dots and other
// regex metacharacters in file names are treated literally
await expect(page).toHaveURL((url) => url.searchParams.get('path') === folderName)
// Opening the file records it in the URL alongside the folder
await page.getByTitle(fileName).click()
await expect(page).toHaveURL((url) => url.searchParams.get('path') === folderName)
await expect(page).toHaveURL((url) => url.searchParams.get('preview') === fileName)
// A reload restores the same location rather than dropping back to bucket root
const deepLink = page.url()
await page.reload()
await expect(
page.getByTitle(fileName),
'File should still be visible after reloading the deep link'
).toBeVisible()
expect(page.url()).toBe(deepLink)
// Back walks up out of the folder
await page.goBack()
await expect(page).toHaveURL((url) => url.searchParams.get('path') === null)
await expect(
page.getByTitle(folderName),
'Should be back at bucket root showing the folder'
).toBeVisible()
await deleteBucketViaApi(bucketName)
})
test('can rename a file', async ({ page, ref }) => {
const bucketName = `${bucketNamePrefix}_rename_file`
const fileName = 'test-file.txt'
const newFileName = 'renamed-file.txt'
// Create a bucket via API, navigate to it, and upload a file
await using _ = await withSetupCleanup(
async () => {
await createBucketViaApi(bucketName, false)
},
async () => {
await deleteBucketViaApi(bucketName)
}
)
await navigateToStorageFiles(page, ref)
await navigateToBucket(page, ref, bucketName)
const filePath = path.join(import.meta.dirname, 'files', fileName)
await uploadFile(page, filePath, fileName)
// Rename the file
await renameItem(page, fileName, newFileName)
})
test('can rename a folder', async ({ page, ref }) => {
const bucketName = `${bucketNamePrefix}_mvdir`
const folderName = 'old_folder'
const newFolderName = 'new_folder'
// Create a bucket via API, navigate to it, and create a folder
await using _ = await withSetupCleanup(
async () => {
await createBucketViaApi(bucketName, false)
},
async () => {
await deleteBucketViaApi(bucketName)
}
)
await navigateToStorageFiles(page, ref)
await navigateToBucket(page, ref, bucketName)
await createFolder(page, folderName)
// Rename the folder
await renameItem(page, folderName, newFolderName)
})
test('can copy a file url regardless of the opened folders', async ({ page, ref }) => {
const bucketName = `${bucketNamePrefix}_urls`
const folderName = 'test_folder'
const rootFileName = 'test-file.txt'
const rootFilePath = path.join(import.meta.dirname, 'files', rootFileName)
const folderFileName = 'test-file-2.txt'
const folderFilePath = path.join(import.meta.dirname, 'files', folderFileName)
// Create a bucket via API and navigate to it
await using _ = await withSetupCleanup(
async () => {
await createBucketViaApi(bucketName, true)
},
async () => {
await deleteBucketViaApi(bucketName)
}
)
await navigateToStorageFiles(page, ref)
await navigateToBucket(page, ref, bucketName)
await uploadFile(page, rootFilePath, rootFileName)
// Create a folder
await createFolder(page, folderName)
// Wait for the folder file input
await expect(page.getByText('Drop your files here')).toBeVisible()
await uploadFile(page, folderFilePath, folderFileName)
// Right-click on the folder file to open context menu
const folderFile = page.getByTitle(folderFileName)
await folderFile.click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Get URL' }).click()
await expectClipboardValue({
page,
value: `storage/v1/object/public/${bucketName}/${folderName}/${folderFileName}`,
})
await expect(page.getByRole('menuitem', { name: 'Get URL' })).not.toBeVisible()
// Right-click on the root file to open context menu while the folder is still open
const rootFile = page.getByTitle(rootFileName)
await rootFile.click({ button: 'right' })
await page.getByRole('menuitem', { name: 'Get URL' }).click()
await expectClipboardValue({
page,
value: `storage/v1/object/public/${bucketName}/${rootFileName}`,
})
await expect(page.getByRole('menuitem', { name: 'Get URL' })).not.toBeVisible()
// Click the actions button on the folder file to open dropdown menu
await page.getByRole('button', { name: `${folderFileName} actions` }).click()
await page.getByRole('menuitem', { name: 'Get URL' }).click()
await expectClipboardValue({
page,
value: `storage/v1/object/public/${bucketName}/${folderName}/${folderFileName}`,
})
await expect(page.getByRole('menuitem', { name: 'Get URL' })).not.toBeVisible()
// Click the actions button on the root file to open dropdown menu while the folder is still open
await page.getByRole('button', { name: `${rootFileName} actions` }).click()
await page.getByRole('menuitem', { name: 'Get URL' }).click()
await expectClipboardValue({
page,
value: `storage/v1/object/public/${bucketName}/${rootFileName}`,
})
await expect(page.getByRole('menuitem', { name: 'Get URL' })).not.toBeVisible()
// Click the folder file to open its preview pane
await folderFile.click()
await page.getByRole('button', { name: 'Get URL' }).click()
await expectClipboardValue({
page,
value: `storage/v1/object/public/${bucketName}/${folderName}/${folderFileName}`,
})
// Click the root file to open its preview pane while folder is still open
await rootFile.click()
await page.getByRole('button', { name: 'Get URL' }).click()
await expectClipboardValue({
page,
value: `storage/v1/object/public/${bucketName}/${rootFileName}`,
})
})
test('resets folder name when renaming with empty string', async ({ page, ref }) => {
const bucketName = `${bucketNamePrefix}_reset_enter`
const folderName = 'folder_to_rename'
// Create a bucket via API, navigate to it, and create a folder
await using _ = await withSetupCleanup(
async () => {
await createBucketViaApi(bucketName, false)
},
async () => {
await deleteBucketViaApi(bucketName)
}
)
await navigateToStorageFiles(page, ref)
await navigateToBucket(page, ref, bucketName)
await createFolder(page, folderName)
// Right-click on the folder to open context menu
const folder = page.getByTitle(folderName)
await expect(folder, `Folder ${folderName} should be visible`).toBeVisible()
await folder.click({ button: 'right' })
// Click rename option from context menu
await page.getByRole('menuitem', { name: 'Rename' }).click()
// Clear the input and press Enter with empty name
const nameInput = getStorageRowNameInput(page)
await expect(nameInput, 'Rename input should be visible').toBeVisible()
await nameInput.clear()
await nameInput.press('Enter')
// Verify the input disappears (edit mode exits)
await expect(nameInput, 'Input should disappear after pressing Enter').not.toBeVisible()
// Verify the folder name is reset to original
await expect(
page.getByTitle(folderName),
'Folder should retain its original name'
).toBeVisible()
})
test('resets folder name when clicking outside with empty string', async ({ page, ref }) => {
const bucketName = `${bucketNamePrefix}_reset_blur`
const folderName = 'folder_to_blur'
// Create a bucket via API, navigate to it, and create a folder
await using _ = await withSetupCleanup(
async () => {
await createBucketViaApi(bucketName, false)
},
async () => {
await deleteBucketViaApi(bucketName)
}
)
await navigateToStorageFiles(page, ref)
await navigateToBucket(page, ref, bucketName)
await createFolder(page, folderName)
// Right-click on the folder to open context menu
const folder = page.getByTitle(folderName)
await expect(folder, `Folder ${folderName} should be visible`).toBeVisible()
await folder.click({ button: 'right' })
// Click rename option from context menu
await page.getByRole('menuitem', { name: 'Rename' }).click()
// Clear the input and click outside to blur
const nameInput = getStorageRowNameInput(page)
await expect(nameInput, 'Rename input should be visible').toBeVisible()
await nameInput.clear()
// Click outside the input to trigger blur
await page.getByRole('button', { name: 'Edit bucket' }).click()
// Verify the folder name is reset to original
await expect(
page.getByTitle(folderName),
'Folder should retain its original name after blur'
).toBeVisible()
})
test('resets storage view when switching buckets', async ({ page, ref }) => {
const bucketName = `${bucketNamePrefix}_navigation`
const bucketName2 = `${bucketNamePrefix}2_navigation`
const folderName = 'folder_navigation'
const fileName = 'test-file.txt'
// Create 2 bucket via API, navigate to the first
await using _ = await withSetupCleanup(
async () => {
await createBucketViaApi(bucketName, false)
await createBucketViaApi(bucketName2, false)
},
async () => {
await deleteBucketViaApi(bucketName)
await deleteBucketViaApi(bucketName2)
}
)
await navigateToStorageFiles(page, ref)
await navigateToBucket(page, ref, bucketName)
// create a folder and add a file
await createFolder(page, folderName)
// Open the folder
await page.getByTitle(folderName).click()
const filePath = path.join(import.meta.dirname, 'files', fileName)
await uploadFile(page, filePath, fileName)
// Navigate to bucket list
await page.getByRole('link', { name: 'Files' }).nth(1).click()
// Navigate to the 2nd bucket
await navigateToBucket(page, ref, bucketName2)
await expect(page.getByTitle(fileName)).not.toBeVisible()
})
test('can delete a file', async ({ page, ref }) => {
const bucketName = `${bucketNamePrefix}_delete_file`
const fileName = 'test-file.txt'
// Create a bucket via API, navigate to it, and upload a file
await using _ = await withSetupCleanup(
async () => {
await createBucketViaApi(bucketName, false)
},
async () => {
await deleteBucketViaApi(bucketName)
}
)
await navigateToStorageFiles(page, ref)
await navigateToBucket(page, ref, bucketName)
const filePath = path.join(import.meta.dirname, 'files', fileName)
await uploadFile(page, filePath, fileName)
// Delete the file
await deleteItem(page, fileName)
})
test('can delete a folder', async ({ page, ref }) => {
const bucketName = `${bucketNamePrefix}_delete_folder`
const folderName = 'test_folder'
// Create a bucket via API, navigate to it, and create a folder
await using _ = await withSetupCleanup(
async () => {
await createBucketViaApi(bucketName, false)
},
async () => {
await deleteBucketViaApi(bucketName)
}
)
await navigateToStorageFiles(page, ref)
await navigateToBucket(page, ref, bucketName)
await createFolder(page, folderName)
// Delete the folder
await deleteItem(page, folderName)
})
test('can download a file', async ({ page, ref }) => {
const bucketName = `${bucketNamePrefix}_download`
const fileName = 'test-file.txt'
// Create a bucket via API, navigate to it, and upload a file
await using _ = await withSetupCleanup(
async () => {
await createBucketViaApi(bucketName, false)
},
async () => {
await deleteBucketViaApi(bucketName)
}
)
await navigateToStorageFiles(page, ref)
await navigateToBucket(page, ref, bucketName)
const filePath = path.join(import.meta.dirname, 'files', fileName)
await uploadFile(page, filePath, fileName)
// Download the file
await downloadFile(page, fileName)
})
})
test.describe('Storage Settings - Self Hosted', () => {
test.skip(env.IS_PLATFORM, 'Storage settings are only disabled on self-hosted')
test('settings tab should not be visible in navigation', async ({ page, ref }) => {
// Navigate to storage files page
await page.goto(`/project/${ref}/storage/files`)
// Wait for the page to load
await expect(
page.getByRole('button', { name: 'New bucket' }),
'New bucket button should be visible'
).toBeVisible()
// Verify Buckets and Policies tabs are visible but Settings is not
// Use href patterns to avoid matching other "Settings" links in the sidebar
await expect(
page.getByRole('link', { name: 'Buckets' }).filter({ hasText: /^Buckets$/ }),
'Buckets tab should be visible'
).toBeVisible()
await expect(
page.getByRole('link', { name: 'Policies' }).filter({ hasText: /^Policies$/ }),
'Policies tab should be visible'
).toBeVisible()
await expect(
page.locator(`a[href="/project/${ref}/storage/files/settings"]`),
'Settings tab should NOT be visible for self-hosted'
).not.toBeVisible()
})
test('direct navigation to settings page should show error', async ({ page, ref }) => {
// Navigate directly to the settings page
await page.goto(`/project/${ref}/storage/files/settings`)
// Should show an error message indicating settings are not available
await expect(
page.getByText('Storage settings are not available for self-hosted projects'),
'Error message should be visible'
).toBeVisible()
})
})