diff --git a/e2e/studio/env.config.ts b/e2e/studio/env.config.ts index 9999ac4b0c7..b22d469a5eb 100644 --- a/e2e/studio/env.config.ts +++ b/e2e/studio/env.config.ts @@ -13,9 +13,12 @@ const toBoolean = (value?: string) => { return normalized === 'true' } +// Default service role key for local development +const DEFAULT_SERVICE_ROLE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU" + export const env = { STUDIO_URL: process.env.STUDIO_URL || 'http://localhost:8082', - API_URL: process.env.API_URL || 'https://localhost:8080', + API_URL: process.env.API_URL || 'http://127.0.0.1:54321', IS_PLATFORM: toBoolean(process.env.IS_PLATFORM || 'false'), EMAIL: process.env.EMAIL, @@ -40,6 +43,9 @@ export const env = { IS_APP_RUNNING_ON_LOCALHOST: process.env.STUDIO_URL?.includes('localhost') || process.env.STUDIO_URL?.includes('127.0.0.1'), + + + SERVICE_ROLE_KEY: process.env.SERVICE_ROLE_KEY || DEFAULT_SERVICE_ROLE_KEY, } export const STORAGE_STATE_PATH = path.join(import.meta.dirname, './playwright/.auth/user.json') diff --git a/e2e/studio/features/storage.spec.ts b/e2e/studio/features/storage.spec.ts index c4c24895028..b2d371b9de9 100644 --- a/e2e/studio/features/storage.spec.ts +++ b/e2e/studio/features/storage.spec.ts @@ -6,21 +6,24 @@ import { waitForApiResponse } from '../utils/wait-for-response.js' import { createBucket, createFolder, - deleteAllBuckets, deleteBucket, deleteItem, downloadFile, navigateToBucket, + navigateToStorageFiles, renameItem, uploadFile, } from '../utils/storage-helpers.js' -import { dismissToastsIfAny } from '../utils/dismiss-toast.js' +import { + createBucket as createBucketViaApi, + deleteBucket as deleteBucketViaApi, +} from '../utils/storage/index.js' const bucketNamePrefix = 'pw_bucket' -test.describe.serial('Storage', () => { +test.describe('Storage', () => { test.beforeEach(async ({ page, ref }) => { - await deleteAllBuckets(page, ref) + await navigateToStorageFiles(page, ref) }) test('can navigate to storage page', async ({ page, ref }) => { @@ -36,6 +39,7 @@ test.describe.serial('Storage', () => { test('can create a private bucket', async ({ page, ref }) => { const bucketName = `${bucketNamePrefix}_private` + await deleteBucketViaApi(bucketName) await createBucket(page, ref, bucketName, false) // Verify it's marked as private (no "Public" badge should be visible) @@ -50,6 +54,7 @@ test.describe.serial('Storage', () => { test('can create a public bucket', async ({ page, ref }) => { const bucketName = `${bucketNamePrefix}_public` + await deleteBucketViaApi(bucketName) await createBucket(page, ref, bucketName, true) // Verify it's marked as public - wait for the badge to appear @@ -66,8 +71,10 @@ test.describe.serial('Storage', () => { test('can edit bucket settings', async ({ page, ref }) => { const bucketName = `${bucketNamePrefix}_edit` - // Create a private bucket - await createBucket(page, ref, bucketName, false) + // Create a fresh private bucket via API + await deleteBucketViaApi(bucketName) + await createBucketViaApi(bucketName, false) + await navigateToStorageFiles(page, ref) // Navigate to the bucket await navigateToBucket(page, ref, bucketName) @@ -96,12 +103,14 @@ test.describe.serial('Storage', () => { }) test('can delete a bucket', async ({ page, ref }) => { - const bucketName = `${bucketNamePrefix}_delete` + const bucketName = `${bucketNamePrefix}_delbkt` - // Create a bucket - await createBucket(page, ref, bucketName, false) + // Create a bucket via API + await deleteBucketViaApi(bucketName) + await createBucketViaApi(bucketName, false) + await navigateToStorageFiles(page, ref) - // Delete it + // Delete it via UI await deleteBucket(page, ref, bucketName) // Verify it's gone @@ -115,10 +124,12 @@ test.describe.serial('Storage', () => { const bucketName1 = `${bucketNamePrefix}_search_1` const bucketName2 = `${bucketNamePrefix}_search_2` - // Create two buckets - await createBucket(page, ref, bucketName1, false) - await dismissToastsIfAny(page) - await createBucket(page, ref, bucketName2, false) + // Create two buckets via API + await deleteBucketViaApi(bucketName1) + await deleteBucketViaApi(bucketName2) + await createBucketViaApi(bucketName1, false) + await createBucketViaApi(bucketName2, false) + await navigateToStorageFiles(page, ref) // Search for first bucket const searchInput = page.getByPlaceholder('Search for a bucket') @@ -152,24 +163,25 @@ test.describe.serial('Storage', () => { const bucketName = `${bucketNamePrefix}_upload` const fileName = 'test-file.txt' - // Create a bucket and navigate to it - await createBucket(page, ref, bucketName, false) + // Create a bucket via API and navigate to it + await deleteBucketViaApi(bucketName) + await createBucketViaApi(bucketName, false) + 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) - - // Clean up - await deleteBucket(page, ref, bucketName) }) test('can create a folder', async ({ page, ref }) => { - const bucketName = `${bucketNamePrefix}_folder` + const bucketName = `${bucketNamePrefix}_newfolder` const folderName = 'test_folder' - // Create a bucket and navigate to it - await createBucket(page, ref, bucketName, false) + // Create a bucket via API and navigate to it + await deleteBucketViaApi(bucketName) + await createBucketViaApi(bucketName, false) + await navigateToStorageFiles(page, ref) await navigateToBucket(page, ref, bucketName) // Create a folder @@ -181,8 +193,10 @@ test.describe.serial('Storage', () => { const fileName = 'test-file.txt' const newFileName = 'renamed-file.txt' - // Create a bucket, navigate to it, and upload a file - await createBucket(page, ref, bucketName, false) + // Create a bucket via API, navigate to it, and upload a file + await deleteBucketViaApi(bucketName) + await createBucketViaApi(bucketName, false) + await navigateToStorageFiles(page, ref) await navigateToBucket(page, ref, bucketName) const filePath = path.join(import.meta.dirname, 'files', fileName) @@ -190,18 +204,17 @@ test.describe.serial('Storage', () => { // Rename the file await renameItem(page, fileName, newFileName) - - // Clean up - await deleteBucket(page, ref, bucketName) }) test('can rename a folder', async ({ page, ref }) => { - const bucketName = `${bucketNamePrefix}_rename_folder` + const bucketName = `${bucketNamePrefix}_mvdir` const folderName = 'old_folder' const newFolderName = 'new_folder' - // Create a bucket, navigate to it, and create a folder - await createBucket(page, ref, bucketName, false) + // Create a bucket via API, navigate to it, and create a folder + await deleteBucketViaApi(bucketName) + await createBucketViaApi(bucketName, false) + await navigateToStorageFiles(page, ref) await navigateToBucket(page, ref, bucketName) await createFolder(page, folderName) @@ -210,11 +223,13 @@ test.describe.serial('Storage', () => { }) test('resets folder name when renaming with empty string', async ({ page, ref }) => { - const bucketName = `${bucketNamePrefix}_rename_folder_empty` + const bucketName = `${bucketNamePrefix}_reset_enter` const folderName = 'folder_to_rename' - // Create a bucket, navigate to it, and create a folder - await createBucket(page, ref, bucketName, false) + // Create a bucket via API, navigate to it, and create a folder + await deleteBucketViaApi(bucketName) + await createBucketViaApi(bucketName, false) + await navigateToStorageFiles(page, ref) await navigateToBucket(page, ref, bucketName) await createFolder(page, folderName) @@ -243,11 +258,13 @@ test.describe.serial('Storage', () => { }) test('resets folder name when clicking outside with empty string', async ({ page, ref }) => { - const bucketName = `${bucketNamePrefix}_rename_folder_blur` + const bucketName = `${bucketNamePrefix}_reset_blur` const folderName = 'folder_to_blur' - // Create a bucket, navigate to it, and create a folder - await createBucket(page, ref, bucketName, false) + // Create a bucket via API, navigate to it, and create a folder + await deleteBucketViaApi(bucketName) + await createBucketViaApi(bucketName, false) + await navigateToStorageFiles(page, ref) await navigateToBucket(page, ref, bucketName) await createFolder(page, folderName) @@ -278,8 +295,10 @@ test.describe.serial('Storage', () => { const bucketName = `${bucketNamePrefix}_delete_file` const fileName = 'test-file.txt' - // Create a bucket, navigate to it, and upload a file - await createBucket(page, ref, bucketName, false) + // Create a bucket via API, navigate to it, and upload a file + await deleteBucketViaApi(bucketName) + await createBucketViaApi(bucketName, false) + await navigateToStorageFiles(page, ref) await navigateToBucket(page, ref, bucketName) const filePath = path.join(import.meta.dirname, 'files', fileName) @@ -293,8 +312,10 @@ test.describe.serial('Storage', () => { const bucketName = `${bucketNamePrefix}_delete_folder` const folderName = 'test_folder' - // Create a bucket, navigate to it, and create a folder - await createBucket(page, ref, bucketName, false) + // Create a bucket via API, navigate to it, and create a folder + await deleteBucketViaApi(bucketName) + await createBucketViaApi(bucketName, false) + await navigateToStorageFiles(page, ref) await navigateToBucket(page, ref, bucketName) await createFolder(page, folderName) @@ -306,8 +327,10 @@ test.describe.serial('Storage', () => { const bucketName = `${bucketNamePrefix}_download` const fileName = 'test-file.txt' - // Create a bucket, navigate to it, and upload a file - await createBucket(page, ref, bucketName, false) + // Create a bucket via API, navigate to it, and upload a file + await deleteBucketViaApi(bucketName) + await createBucketViaApi(bucketName, false) + await navigateToStorageFiles(page, ref) await navigateToBucket(page, ref, bucketName) const filePath = path.join(import.meta.dirname, 'files', fileName) diff --git a/e2e/studio/utils/db/client.ts b/e2e/studio/utils/db/client.ts index f8f1c0689fb..fb8c584bc62 100644 --- a/e2e/studio/utils/db/client.ts +++ b/e2e/studio/utils/db/client.ts @@ -1,7 +1,4 @@ -// Default Supabase CLI constants (hardcoded for local development) -const SERVICE_ROLE_KEY = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU' -const API_URL = 'http://127.0.0.1:54321' +import { env } from "../../env.config.js" /** * Execute a SQL query against the local Supabase database via pg-meta. @@ -15,11 +12,11 @@ const API_URL = 'http://127.0.0.1:54321' * @throws Error if the query fails */ export async function query(sql: string, params?: Array): Promise> { - const response = await fetch(`${API_URL}/pg/query`, { + const response = await fetch(`${env.API_URL}/pg/query`, { method: 'POST', headers: { 'Content-Type': 'application/json', - apikey: SERVICE_ROLE_KEY, + apikey: env.SERVICE_ROLE_KEY, }, body: JSON.stringify({ query: sql, parameters: params }), }) diff --git a/e2e/studio/utils/storage/client.ts b/e2e/studio/utils/storage/client.ts new file mode 100644 index 00000000000..208d6edfd0f --- /dev/null +++ b/e2e/studio/utils/storage/client.ts @@ -0,0 +1,39 @@ +import { env } from "../../env.config.js"; + +/** + * Make an HTTP request to the local Supabase Storage API. + * + * @param path - The path to append to the storage base URL (e.g., '/bucket') + * @param options - Optional method and body + * @returns Parsed JSON response + * @throws Error if the request fails + */ +export async function storageRequest( + path: string, + options?: { method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; body?: Record } +): Promise { + const storageUrl = `${env.API_URL}/storage/v1` + + const headers: Record = { + apikey: env.SERVICE_ROLE_KEY, + Authorization: `Bearer ${env.SERVICE_ROLE_KEY}`, + } + + if (options?.body) { + headers['Content-Type'] = 'application/json' + } + + const response = await fetch(`${storageUrl}${path}`, { + method: options?.method ?? 'GET', + headers, + body: options?.body ? JSON.stringify(options.body) : undefined, + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`Storage request failed (${response.status}): ${text}`) + } + + const text = await response.text() + return text ? JSON.parse(text) : ({} as T) +} diff --git a/e2e/studio/utils/storage/index.ts b/e2e/studio/utils/storage/index.ts new file mode 100644 index 00000000000..cffa68bfd69 --- /dev/null +++ b/e2e/studio/utils/storage/index.ts @@ -0,0 +1,2 @@ +export { storageRequest } from './client.js' +export { createBucket, deleteBucket, deleteAllBuckets, listBuckets } from './queries.js' diff --git a/e2e/studio/utils/storage/queries.ts b/e2e/studio/utils/storage/queries.ts new file mode 100644 index 00000000000..e518b8a3381 --- /dev/null +++ b/e2e/studio/utils/storage/queries.ts @@ -0,0 +1,54 @@ +import { storageRequest } from './client.js' + +interface Bucket { + id: string + name: string + public: boolean +} + +/** + * List all storage buckets. + */ +export async function listBuckets(): Promise { + return storageRequest('/bucket') +} + +/** + * Create a storage bucket. Idempotent — skips creation if the bucket already exists. + * + * @param name - Bucket name / id + * @param isPublic - Whether the bucket should be public (default: false) + */ +export async function createBucket(name: string, isPublic: boolean = false): Promise { + const buckets = await listBuckets() + if (buckets.some((b) => b.id === name)) return + + await storageRequest('/bucket', { + method: 'POST', + body: { id: name, name, public: isPublic }, + }) +} + +/** + * Delete a storage bucket. Idempotent — empties the bucket first, then deletes it. + * No-ops if the bucket does not exist. + * + * @param name - Bucket name / id + */ +export async function deleteBucket(name: string): Promise { + const buckets = await listBuckets() + if (!buckets.some((b) => b.id === name)) return + + await storageRequest(`/bucket/${name}/empty`, { method: 'POST' }) + await storageRequest(`/bucket/${name}`, { method: 'DELETE' }) +} + +/** + * Delete every storage bucket. + */ +export async function deleteAllBuckets(): Promise { + const buckets = await listBuckets() + for (const bucket of buckets) { + await deleteBucket(bucket.id) + } +}