diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9c4286e3..a7a86a4e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,7 +4,7 @@ permissions: contents: read on: pull_request: - branches: [master] + branches: [master, dev] jobs: Prettier-check: diff --git a/package.json b/package.json index 12bcc721..29293764 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "dev:server": "pnpm --filter upup-react-file-uploader exec node server/index.js", "test": "turbo run test", "test:coverage": "pnpm --filter upup-react-file-uploader exec jest --coverage --coverageDirectory ../../coverage", - "test:e2e": "pnpm --filter upup-react-file-uploader exec playwright test", + "test:e2e": "pnpm --filter upup-react-file-uploader run test:e2e", + "test:e2e:ui": "pnpm --filter upup-react-file-uploader run test:e2e:ui", "typecheck": "turbo run typecheck", "playwright:install": "pnpm --filter upup-react-file-uploader run playwright:install", "release": "pnpm --filter upup-react-file-uploader run release" diff --git a/packages/upup/e2e/fixtures/baseTest.ts b/packages/upup/e2e/fixtures/baseTest.ts new file mode 100644 index 00000000..e20ac1bd --- /dev/null +++ b/packages/upup/e2e/fixtures/baseTest.ts @@ -0,0 +1,21 @@ +import { test as baseTest, expect } from '@playwright/test' +import { UploaderPage } from '../pages/UploaderPage' +import { MockUploadApi, setupMockUploadApi } from './mockUploadApi' + +type UpupPageObjects = { + uploaderPage: UploaderPage + mockUploadApi: MockUploadApi +} + +export const test = baseTest.extend({ + uploaderPage: async ({ page }, use) => { + const uploaderPage = new UploaderPage(page) + await use(uploaderPage) + }, + mockUploadApi: async ({ page }, use) => { + const mockApi = await setupMockUploadApi(page) + await use(mockApi) + }, +}) + +export { expect } diff --git a/packages/upup/e2e/fixtures/mockUploadApi.ts b/packages/upup/e2e/fixtures/mockUploadApi.ts new file mode 100644 index 00000000..8a216324 --- /dev/null +++ b/packages/upup/e2e/fixtures/mockUploadApi.ts @@ -0,0 +1,35 @@ +import { Page } from '@playwright/test' + +export interface MockUploadApi { + getUploadCount: () => number + waitForUploads: (count: number) => Promise +} + +export async function setupMockUploadApi(page: Page): Promise { + let uploadCount = 0 + + await page.route('**/api/upload**', async route => { + uploadCount++ + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + key: `mock-upload-key-${uploadCount}`, + url: `https://mock-storage.com/file-${uploadCount}.png`, + }), + }) + }) + + return { + getUploadCount: () => uploadCount, + waitForUploads: async (count: number) => { + const promises = Array.from({ length: count }, () => + page.waitForResponse( + r => r.url().includes('/api/upload') && r.status() === 200, + ), + ) + await Promise.all(promises) + }, + } +} diff --git a/packages/upup/e2e/folder-upload.spec.ts b/packages/upup/e2e/folder-upload.spec.ts index a7fc8c1f..de1d66c1 100644 --- a/packages/upup/e2e/folder-upload.spec.ts +++ b/packages/upup/e2e/folder-upload.spec.ts @@ -1,27 +1,25 @@ import { expect, test } from '@playwright/test' - -// This test targets the Storybook story for UpupUploader -// It simulates selecting a folder by providing files with relative paths -// to the hidden input[type=file] (webkitdirectory behavior in Chromium) +import { setupMockUploadApi } from './fixtures/mockUploadApi' test('folder upload selects and displays structured files', async ({ page, }) => { - // Navigate to the Storybook iframe story (baseURL provided by config) - // Storybook generates ids from titles by lowercasing and stripping punctuation. - // Title: 'UpUpUploader' => id: 'upupuploader' + // Setup mock before navigation + const mockUploadApi = await setupMockUploadApi(page) + + // Navigate to Storybook await page.goto('/iframe.html?id=upupuploader--uploader-with-button') - // Wait for the UI to be ready using a visible anchor + // Wait for UI await expect(page.getByRole('button', { name: /browse/i })).toBeVisible({ timeout: 20000, }) - // The story renders the component centered; find the hidden file input + // Find file input const fileInput = page.getByTestId('upup-file-input') await expect(fileInput).toHaveCount(1, { timeout: 15000 }) - // Create a virtual folder with nested files + // Create virtual folder files const files = [ { name: 'docs/readme.txt', @@ -32,7 +30,6 @@ test('folder upload selects and displays structured files', async ({ name: 'images/photo.png', mimeType: 'image/png', buffer: Buffer.from( - // Tiny 1x1 transparent PNG 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8Xw8AAn0B4y1bq3kAAAAASUVORK5CYII=', 'base64', ), @@ -55,17 +52,16 @@ test('folder upload selects and displays structured files', async ({ })), ) - // After selection, file list should render items - // Use the file names as text content anchors + // Verify files displayed await expect(page.getByText('readme.txt')).toBeVisible() await expect(page.getByText('photo.png')).toBeVisible() await expect(page.getByText('photo2.png')).toBeVisible() - // Validate count text on the Upload button + // Check upload button const uploadBtn = page.getByRole('button', { name: /Upload 3 files/i }) await expect(uploadBtn).toBeVisible() - // Optional: ensure ordering by relative path (images/ before images/sub/ before docs/) + // Verify ordering const allNames = await page .locator('[data-testid="upup-file-item"], .upup-preview-scroll *') .filter({ hasText: /readme\.txt|photo2?\.png/i }) @@ -74,18 +70,11 @@ test('folder upload selects and displays structured files', async ({ /photo\.png[\s\S]*photo2\.png[\s\S]*readme\.txt/i, ) - // Trigger upload and verify backend was called for each file + // Click upload and wait for all responses + const uploadPromise = mockUploadApi.waitForUploads(files.length) await uploadBtn.click() - const expectedCount = 3 - let seen = 0 - await Promise.all( - Array.from({ length: expectedCount }).map(() => - page.waitForResponse(r => { - const ok = r.url().includes('/api/upload') && r.status() === 200 - if (ok) seen++ - return ok - }), - ), - ) - expect(seen).toBe(expectedCount) + await uploadPromise + + // Verify all files were uploaded + expect(mockUploadApi.getUploadCount()).toBe(files.length) }) diff --git a/packages/upup/e2e/locators/UploaderLocators.ts b/packages/upup/e2e/locators/UploaderLocators.ts new file mode 100644 index 00000000..b960e75e --- /dev/null +++ b/packages/upup/e2e/locators/UploaderLocators.ts @@ -0,0 +1,13 @@ +export const UploaderLocators = { + fileInput: 'input[type="file"]', + myDeviceBtn: 'My Device', + addMoreBtn: 'Add More', + removeAllFilesBtn: 'Remove all files', + removeFileBtn: 'Remove file', + uploaderRegion: 'Add your documents here, you', + + messages: { + dropInstructions: + 'Add your documents here, you can upload up to 10 files max', + }, +} as const diff --git a/packages/upup/e2e/pages/UploaderPage.ts b/packages/upup/e2e/pages/UploaderPage.ts new file mode 100644 index 00000000..cbb438a3 --- /dev/null +++ b/packages/upup/e2e/pages/UploaderPage.ts @@ -0,0 +1,54 @@ +import { Locator, Page } from '@playwright/test' +import { UploaderLocators as locators } from '../locators/UploaderLocators' + +export class UploaderPage { + readonly page: Page + readonly fileInput: Locator + readonly myDeviceBtn: Locator + readonly addMoreBtn: Locator + readonly removeAllFilesBtn: Locator + readonly uploaderRegion: Locator + + constructor(page: Page) { + this.page = page + this.fileInput = page.locator(locators.fileInput) + this.myDeviceBtn = page.getByRole('button', { + name: locators.myDeviceBtn, + }) + this.addMoreBtn = page.getByRole('button', { + name: locators.addMoreBtn, + }) + this.removeAllFilesBtn = page.getByRole('button', { + name: locators.removeAllFilesBtn, + }) + this.uploaderRegion = page.getByLabel(locators.uploaderRegion) + } + + async goTo(): Promise { + await this.page.goto( + 'http://localhost:53050/iframe.html?id=upupuploader--uploader-with-button&viewMode=story', + ) + await this.page.waitForLoadState('domcontentloaded') + await this.uploaderRegion.waitFor({ state: 'visible' }) + } + + async uploadFiles(files: string[]): Promise { + await this.fileInput.setInputFiles(files) + } + + async clickUpload(fileCount: number): Promise { + const uploadBtn = this.page.getByRole('button', { + name: `Upload ${fileCount} file${fileCount > 1 ? 's' : ''}`, + }) + await uploadBtn.click() + } + + async addMoreFiles(files: string[]): Promise { + await this.addMoreBtn.click() + await this.fileInput.setInputFiles(files) + } + + async removeAllFiles(): Promise { + await this.removeAllFilesBtn.click() + } +} diff --git a/packages/upup/e2e/specs/upload.spec.ts b/packages/upup/e2e/specs/upload.spec.ts new file mode 100644 index 00000000..473cfb0a --- /dev/null +++ b/packages/upup/e2e/specs/upload.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from '../fixtures/baseTest' +import { UploaderLocators as locators } from '../locators/UploaderLocators' + +test.describe('Upup Uploader Component - Full Validation', () => { + test.beforeEach(async ({ uploaderPage }) => { + await uploaderPage.goTo() + }) + + test('should display initial upload instructions', async ({ + uploaderPage, + }) => { + await expect(uploaderPage.uploaderRegion).toContainText( + locators.messages.dropInstructions, + ) + }) + + test('should handle selection of 10 files', async ({ uploaderPage }) => { + const files = Array.from( + { length: 10 }, + (_, i) => `test-files/image${i + 1}.jpg`, + ) + + await uploaderPage.uploadFiles(files) + + await expect(uploaderPage.uploaderRegion).toContainText( + '10 files selected', + ) + }) + + test('should allow removing all files to reset state', async ({ + uploaderPage, + }) => { + await uploaderPage.uploadFiles([ + 'test-files/image1.jpg', + 'test-files/image2.jpg', + ]) + await uploaderPage.removeAllFiles() + + await expect(uploaderPage.uploaderRegion).toContainText( + locators.messages.dropInstructions, + ) + }) + + test('upload button should hide after clicking', async ({ + uploaderPage, + page, + mockUploadApi, + }) => { + await uploaderPage.uploadFiles(['test-files/image1.jpg']) + const uploadBtn = page.getByRole('button', { name: 'Upload 1 file' }) + + await uploadBtn.click() + + await expect(uploadBtn).not.toBeVisible({ timeout: 5000 }) + }) +}) diff --git a/packages/upup/package.json b/packages/upup/package.json index b9c772f1..b6f61961 100644 --- a/packages/upup/package.json +++ b/packages/upup/package.json @@ -65,8 +65,8 @@ "test:watch": "pnpm exec jest --watch", "test:coverage": "pnpm exec jest --coverage", "test:ci": "pnpm exec jest --coverage --ci --maxWorkers=2", - "test:e2e": "pnpm exec playwright test", - "test:e2e:ui": "pnpm exec playwright test --ui", + "test:e2e": "dotenv -e ../../.env -e ../../local-dev/.env.ports -- pnpm exec playwright test", + "test:e2e:ui": "dotenv -e ../../.env -e ../../local-dev/.env.ports -- pnpm exec playwright test --ui", "playwright:install": "pnpm exec playwright install --with-deps", "lint": "pnpm exec eslint src/", "lint:fix": "pnpm exec eslint src/ --fix", diff --git a/packages/upup/playwright.config.ts b/packages/upup/playwright.config.ts index 859c7966..00bf5776 100644 --- a/packages/upup/playwright.config.ts +++ b/packages/upup/playwright.config.ts @@ -1,7 +1,7 @@ import { defineConfig, devices } from '@playwright/test' -// Use a dedicated Storybook port for E2E to avoid conflicts with local dev -const STORYBOOK_PORT = 6007 +// Read Storybook port from environment (loaded from local-dev/.env.ports) +const STORYBOOK_PORT = process.env.STORYBOOK_PORT || '53050' const storyUrl = `http://localhost:${STORYBOOK_PORT}` export default defineConfig({ @@ -20,8 +20,8 @@ export default defineConfig({ timeout: 60_000, // Start Storybook before running tests; reuse if already running webServer: { - // Launch Storybook directly with the desired port in CI-friendly mode - command: `pnpm exec storybook dev -p ${STORYBOOK_PORT} --ci --no-open`, + // Launch Storybook using the package.json script + command: `pnpm run storybook`, url: `http://localhost:${STORYBOOK_PORT}`, timeout: 120_000, reuseExistingServer: true, diff --git a/packages/upup/src/frontend/components/FileList.tsx b/packages/upup/src/frontend/components/FileList.tsx index 49eff877..6d461eda 100644 --- a/packages/upup/src/frontend/components/FileList.tsx +++ b/packages/upup/src/frontend/components/FileList.tsx @@ -88,7 +88,13 @@ export default memo(function FileList() { classNames.fileListFooter, )} > - + {/* FIX: Hide upload button when status is SUCCESSFUL or FAILED */} +