Add comprehensive component tests (#279)

pull/276/head
Alex Kern 6 months ago committed by GitHub
parent ffd64d8d99
commit 6e5c989ff3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -13,7 +13,10 @@ jobs:
node-version: 20
cache: 'pnpm'
- run: pnpm install
- run: pnpm exec playwright install --with-deps
- run: pnpm lint:check
- run: pnpm format:check
- run: pnpm type:check
- run: pnpm test
- run: pnpm build
- run: pnpm test:e2e

@ -23,7 +23,10 @@
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
"type:check": "tsc --noEmit",
"ci": "pnpm lint:check && pnpm format:check && pnpm type:check && pnpm build && pnpm docker:build"
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"ci": "pnpm lint:check && pnpm format:check && pnpm type:check && pnpm test && pnpm build && pnpm test:e2e && pnpm docker:build"
},
"repository": {
"type": "git",
@ -33,6 +36,7 @@
"url": "https://github.com/kern/filepizza/issues"
},
"dependencies": {
"@tailwindcss/postcss": "^4.1.11",
"@tanstack/react-query": "^5.55.2",
"autoprefixer": "^10.4.20",
"debug": "^4.3.6",
@ -51,26 +55,35 @@
"react-qr-code": "^2.0.15",
"streamsaver": "^2.0.6",
"tailwindcss": "^4.1.11",
"@tailwindcss/postcss": "^4.1.11",
"web-streams-polyfill": "^4.0.0",
"webrtcsupport": "^2.2.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@eslint/js": "^9.30.0",
"@playwright/test": "^1.53.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/debug": "^4.1.12",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@typescript-eslint/eslint-plugin": "^8.18.2",
"@typescript-eslint/parser": "^8.18.2",
"@vitejs/plugin-react": "^4.6.0",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.17.0",
"eslint-config-next": "^15.1.3",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-react": "^7.37.3",
"husky": "^9.0.0",
"jsdom": "^26.1.0",
"lint-staged": "^15.0.0",
"playwright": "^1.53.2",
"prettier": "^3.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.18.2"
"typescript-eslint": "^8.18.2",
"vitest": "^3.2.4"
},
"husky": {
"hooks": {

@ -0,0 +1,11 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
webServer: {
command: 'node .next/standalone/server.js',
port: 3000,
timeout: 120 * 1000,
reuseExistingServer: true,
},
})

File diff suppressed because it is too large Load Diff

@ -0,0 +1,9 @@
/// <reference types="@playwright/test" />
import { test, expect } from '@playwright/test'
test('home page loads', async ({ page }) => {
await page.goto('http://localhost:3000/')
await expect(
page.getByText('Peer-to-peer file transfers in your browser.'),
).toBeVisible()
})

@ -0,0 +1,14 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import CancelButton from '../../src/components/CancelButton'
describe('CancelButton', () => {
it('calls onClick when clicked', () => {
const onClick = vi.fn()
const { getByText } = render(<CancelButton onClick={onClick} />)
fireEvent.click(getByText('Cancel'))
expect(onClick).toHaveBeenCalled()
})
})

@ -0,0 +1,25 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { ConnectionListItem } from '../../src/components/ConnectionListItem'
import { UploaderConnectionStatus } from '../../src/types'
const baseConn = {
status: UploaderConnectionStatus.Uploading,
dataConnection: {} as any,
completedFiles: 1,
totalFiles: 2,
currentFileProgress: 0.5,
browserName: 'Chrome',
browserVersion: '120',
}
describe('ConnectionListItem', () => {
it('shows status and progress', () => {
const { getByText } = render(<ConnectionListItem conn={baseConn} />)
expect(getByText((c, e) => e?.textContent === 'Chrome v120')).toBeInTheDocument()
expect(getByText('UPLOADING')).toBeInTheDocument()
expect(getByText('Completed: 1 / 2 files')).toBeInTheDocument()
})
})

@ -0,0 +1,22 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { act } from 'react'
import { describe, it, expect, vi } from 'vitest'
import { CopyableInput } from '../../src/components/CopyableInput'
Object.assign(navigator, {
clipboard: {
writeText: vi.fn().mockResolvedValue(undefined),
},
})
describe('CopyableInput', () => {
it('copies text when button clicked', async () => {
const { getByText } = render(<CopyableInput label="URL" value="hello" />)
await act(async () => {
fireEvent.click(getByText('Copy'))
})
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('hello')
})
})

@ -0,0 +1,14 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import DownloadButton from '../../src/components/DownloadButton'
describe('DownloadButton', () => {
it('calls onClick', () => {
const fn = vi.fn()
const { getByText } = render(<DownloadButton onClick={fn} />)
fireEvent.click(getByText('Download'))
expect(fn).toHaveBeenCalled()
})
})

@ -0,0 +1,60 @@
/// <reference types="@testing-library/jest-dom" />
import { vi } from 'vitest'
vi.mock('next-view-transitions', () => ({ Link: (p: any) => <a {...p}>{p.children}</a> }))
import React from 'react'
import { render, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import {
ConnectingToUploader,
DownloadComplete,
DownloadInProgress,
ReadyToDownload,
PasswordEntry,
} from '../../src/components/Downloader'
const files = [{ fileName: 'a.txt', size: 1, type: 'text/plain' }]
describe('Downloader subcomponents', () => {
it('ConnectingToUploader shows troubleshooting', async () => {
const { getByText } = render(
<ConnectingToUploader showTroubleshootingAfter={0} />,
)
await waitFor(() => {
expect(getByText('Having trouble connecting?')).toBeInTheDocument()
})
})
it('DownloadComplete lists files', () => {
const { getByText } = render(
<DownloadComplete filesInfo={files} bytesDownloaded={1} totalSize={1} />,
)
expect(getByText('You downloaded 1 file.')).toBeInTheDocument()
})
it('DownloadInProgress shows stop button', () => {
const { getByText } = render(
<DownloadInProgress filesInfo={files} bytesDownloaded={0} totalSize={1} onStop={() => {}} />,
)
expect(getByText('Stop Download')).toBeInTheDocument()
})
it('ReadyToDownload shows start button', () => {
const { getByText } = render(
<ReadyToDownload filesInfo={files} onStart={() => {}} />,
)
expect(getByText('Download')).toBeInTheDocument()
})
it('PasswordEntry submits value', () => {
let submitted = ''
const { getByPlaceholderText, getByText } = render(
<PasswordEntry errorMessage={null} onSubmit={(v) => (submitted = v)} />,
)
fireEvent.change(
getByPlaceholderText('Enter a secret password for this slice of FilePizza...'),
{ target: { value: 'secret' } },
)
fireEvent.submit(getByText('Unlock'))
expect(submitted).toBe('secret')
})
})

@ -0,0 +1,19 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import DropZone from '../../src/components/DropZone'
function createFile(name: string) {
return new File(['hello'], name, { type: 'text/plain' })
}
describe('DropZone', () => {
it('calls onDrop when file selected', () => {
const fn = vi.fn()
const { container } = render(<DropZone onDrop={fn} />)
const input = container.querySelector('input') as HTMLInputElement
fireEvent.change(input, { target: { files: [createFile('a.txt')] } })
expect(fn).toHaveBeenCalled()
})
})

@ -0,0 +1,12 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { ErrorMessage } from '../../src/components/ErrorMessage'
describe('ErrorMessage', () => {
it('renders message', () => {
const { getByText } = render(<ErrorMessage message="oops" />)
expect(getByText('oops')).toBeInTheDocument()
})
})

@ -0,0 +1,18 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import Footer from '../../src/components/Footer'
Object.defineProperty(window, 'location', {
value: { href: '' },
writable: true,
})
describe('Footer', () => {
it('redirects to donate link', () => {
const { getByText } = render(<Footer />)
fireEvent.click(getByText('Donate'))
expect(window.location.href).toContain('coinbase')
})
})

@ -0,0 +1,16 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import InputLabel from '../../src/components/InputLabel'
describe('InputLabel', () => {
it('shows tooltip on hover', () => {
const { getByRole, getByText } = render(
<InputLabel tooltip="tip">Label</InputLabel>,
)
const button = getByRole('button')
fireEvent.mouseOver(button)
expect(getByText('tip')).toBeInTheDocument()
})
})

@ -0,0 +1,12 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import Loading from '../../src/components/Loading'
describe('Loading', () => {
it('renders text', () => {
const { getByText } = render(<Loading text="wait" />)
expect(getByText('wait')).toBeInTheDocument()
})
})

@ -0,0 +1,16 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { ModeToggle } from '../../src/components/ModeToggle'
const setTheme = vi.fn()
vi.mock('next-themes', () => ({ useTheme: () => ({ setTheme, resolvedTheme: 'light' }) }))
describe('ModeToggle', () => {
it('toggles theme', () => {
const { getByRole } = render(<ModeToggle />)
fireEvent.click(getByRole('button'))
expect(setTheme).toHaveBeenCalledWith('dark')
})
})

@ -0,0 +1,18 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import PasswordField from '../../src/components/PasswordField'
describe('PasswordField', () => {
it('calls onChange', () => {
let val = ''
const { getByPlaceholderText } = render(
<PasswordField value="" onChange={(v) => (val = v)} />,
)
fireEvent.change(getByPlaceholderText('Enter a secret password for this slice of FilePizza...'), {
target: { value: 'a' },
})
expect(val).toBe('a')
})
})

@ -0,0 +1,12 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import ProgressBar from '../../src/components/ProgressBar'
describe('ProgressBar', () => {
it('shows percentage', () => {
const { getAllByText } = render(<ProgressBar value={50} max={100} />)
expect(getAllByText('50%').length).toBeGreaterThan(0)
})
})

@ -0,0 +1,16 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import FilePizzaQueryClientProvider from '../../src/components/QueryClientProvider'
describe('QueryClientProvider', () => {
it('renders children', () => {
const { getByText } = render(
<FilePizzaQueryClientProvider>
<span>child</span>
</FilePizzaQueryClientProvider>,
)
expect(getByText('child')).toBeInTheDocument()
})
})

@ -0,0 +1,23 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import FilePizzaQueryClientProvider from '../../src/components/QueryClientProvider'
vi.mock('../../src/components/WebRTCProvider', () => ({
useWebRTCPeer: () => ({ peer: { connect: vi.fn(() => ({ on: vi.fn(), close: vi.fn() })) } }),
}))
import ReportTermsViolationButton from '../../src/components/ReportTermsViolationButton'
describe('ReportTermsViolationButton', () => {
it('opens modal on click', () => {
const { getByText } = render(
<FilePizzaQueryClientProvider>
<ReportTermsViolationButton uploaderPeerID="peer" slug="slug" />
</FilePizzaQueryClientProvider>,
)
fireEvent.click(getByText('Report suspicious pizza delivery'))
expect(getByText('Found a suspicious delivery?')).toBeInTheDocument()
})
})

@ -0,0 +1,14 @@
/// <reference types="@testing-library/jest-dom" />
vi.mock("next-view-transitions", () => ({ Link: (p: any) => <a {...p}>{p.children}</a> }))
import { vi } from "vitest"
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import ReturnHome from '../../src/components/ReturnHome'
describe('ReturnHome', () => {
it('links to home', () => {
const { getByText } = render(<ReturnHome />)
expect(getByText(/Serve up/).getAttribute('href')).toBe('/')
})
})

@ -0,0 +1,19 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render } from '@testing-library/react'
import { act } from 'react'
import { describe, it, expect } from 'vitest'
import Spinner from '../../src/components/Spinner'
import { setRotating } from '../../src/hooks/useRotatingSpinner'
describe('Spinner', () => {
it('reflects rotating state', () => {
// @ts-ignore
act(() => { setRotating(true) })
// @ts-ignore
const { getByLabelText } = render(<Spinner />)
expect(getByLabelText('Rotating pizza')).toBeInTheDocument()
// @ts-ignore
act(() => { setRotating(false) })
})
})

@ -0,0 +1,14 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import StartButton from '../../src/components/StartButton'
describe('StartButton', () => {
it('calls handler', () => {
const fn = vi.fn()
const { getByText } = render(<StartButton onClick={fn} />)
fireEvent.click(getByText('Start'))
expect(fn).toHaveBeenCalled()
})
})

@ -0,0 +1,19 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import StopButton from '../../src/components/StopButton'
describe('StopButton', () => {
it('labels correctly when downloading', () => {
const { getByText } = render(<StopButton onClick={() => {}} isDownloading />)
expect(getByText('Stop Download')).toBeInTheDocument()
})
it('calls handler', () => {
const fn = vi.fn()
const { getByText } = render(<StopButton onClick={fn} />)
fireEvent.click(getByText('Stop Upload'))
expect(fn).toHaveBeenCalled()
})
})

@ -0,0 +1,13 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import TermsAcceptance from '../../src/components/TermsAcceptance'
describe('TermsAcceptance', () => {
it('opens modal', () => {
const { getByText } = render(<TermsAcceptance />)
fireEvent.click(getByText('our terms'))
expect(getByText('FilePizza Terms')).toBeInTheDocument()
})
})

@ -0,0 +1,17 @@
/// <reference types="@testing-library/jest-dom" />
Object.defineProperty(window, "matchMedia", { value: () => ({ matches: false, addListener: () => {}, removeListener: () => {} }) })
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { ThemeProvider } from '../../src/components/ThemeProvider'
describe('ThemeProvider', () => {
it('renders children', () => {
const { getByText } = render(
<ThemeProvider>
<span>child</span>
</ThemeProvider>,
)
expect(getByText('child')).toBeInTheDocument()
})
})

@ -0,0 +1,12 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import TitleText from '../../src/components/TitleText'
describe('TitleText', () => {
it('renders children', () => {
const { getByText } = render(<TitleText>hello</TitleText>)
expect(getByText('hello')).toBeInTheDocument()
})
})

@ -0,0 +1,12 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import TypeBadge from '../../src/components/TypeBadge'
describe('TypeBadge', () => {
it('renders type', () => {
const { getByText } = render(<TypeBadge type="image/png" />)
expect(getByText('image/png')).toBeInTheDocument()
})
})

@ -0,0 +1,14 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import UnlockButton from '../../src/components/UnlockButton'
describe('UnlockButton', () => {
it('calls onClick', () => {
const fn = vi.fn()
const { getByText } = render(<UnlockButton onClick={fn} />)
fireEvent.click(getByText('Unlock'))
expect(fn).toHaveBeenCalled()
})
})

@ -0,0 +1,15 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import UploadFileList from '../../src/components/UploadFileList'
describe('UploadFileList', () => {
it('calls onRemove', () => {
const fn = vi.fn()
const files = [{ fileName: 'a.txt', type: 'text/plain' }]
const { getByText } = render(<UploadFileList files={files} onRemove={fn} />)
fireEvent.click(getByText('✕'))
expect(fn).toHaveBeenCalledWith(0)
})
})

@ -0,0 +1,35 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
var mockUseUploaderChannel: any
vi.mock('../../src/components/WebRTCProvider', () => ({
useWebRTCPeer: () => ({ peer: { id: '1' }, stop: vi.fn() }),
}))
vi.mock('../../src/hooks/useUploaderChannel', () => ({
useUploaderChannel: (...args: any[]) => mockUseUploaderChannel(...args),
}))
vi.mock('../../src/hooks/useUploaderConnections', () => ({ useUploaderConnections: () => [] }))
vi.mock('react-qr-code', () => ({ default: () => <div>QR</div> }))
vi.mock('../../src/components/CopyableInput', () => ({ CopyableInput: () => <div>Input</div> }))
vi.mock('../../src/components/ConnectionListItem', () => ({ ConnectionListItem: () => <div>Item</div> }))
vi.mock('../../src/components/StopButton', () => ({ default: () => <button>Stop</button> }))
import Uploader from '../../src/components/Uploader'
describe('Uploader', () => {
it('shows loading when channel loading', () => {
mockUseUploaderChannel = vi.fn().mockReturnValueOnce({
isLoading: true,
error: null,
longSlug: undefined,
shortSlug: undefined,
longURL: undefined,
shortURL: undefined,
})
const { getByText } = render(<Uploader files={[]} password="" onStop={() => {}} />)
expect(getByText('Creating channel...')).toBeInTheDocument()
})
})

@ -0,0 +1,24 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render, waitFor } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response(JSON.stringify({ iceServers: [] })))) )
vi.mock('peerjs', () => ({
default: class { id = 'peer1'; on(event: string, cb: (id: string) => void) { if (event === 'open') cb('peer1') } off() {} },
}))
import WebRTCProvider from '../../src/components/WebRTCProvider'
const Child = () => <div>child</div>
describe('WebRTCProvider', () => {
it('renders children after init', async () => {
const { getByText } = render(
<WebRTCProvider>
<Child />
</WebRTCProvider>,
)
await waitFor(() => expect(getByText('child')).toBeInTheDocument())
})
})

@ -0,0 +1,12 @@
/// <reference types="@testing-library/jest-dom" />
import React from 'react'
import { render } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import Wordmark from '../../src/components/Wordmark'
describe('Wordmark', () => {
it('renders svg', () => {
const { getByLabelText } = render(<Wordmark />)
expect(getByLabelText('FilePizza logo')).toBeInTheDocument()
})
})

@ -0,0 +1,21 @@
import { describe, it, expect, vi } from 'vitest'
import {
setRotating,
addRotationListener,
removeRotationListener,
getRotating,
} from '../../src/hooks/useRotatingSpinner'
describe('useRotatingSpinner state helpers', () => {
it('notifies listeners on state change', () => {
const listener = vi.fn()
addRotationListener(listener)
setRotating(true)
expect(listener).toHaveBeenCalledWith(true)
expect(getRotating()).toBe(true)
setRotating(false)
expect(listener).toHaveBeenCalledWith(false)
expect(getRotating()).toBe(false)
removeRotationListener(listener)
})
})

@ -24,7 +24,11 @@
"name": "next"
}
],
"strictNullChecks": true
"strictNullChecks": true,
"types": [
"vitest/globals",
"@testing-library/jest-dom"
]
},
"include": [
"tailwind.config.js",
@ -32,6 +36,8 @@
"src/**/*.js",
"src/**/*.ts",
"src/**/*.tsx",
"tests/**/*.ts",
"tests/**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [

@ -0,0 +1,16 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './vitest.setup.ts',
exclude: ['tests/e2e/**', '**/node_modules/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
},
},
})

@ -0,0 +1 @@
import '@testing-library/jest-dom'
Loading…
Cancel
Save