From 3fdaac676cea6643a62af5cad4bf6b514e9268ba Mon Sep 17 00:00:00 2001 From: Nabil-Fareed Alikhan Date: Thu, 26 Feb 2026 09:12:56 +0000 Subject: [PATCH 1/2] Add tests, CI workflow, and Vercel config - Add 35 tests across 9 test files covering all components and core modules - Add vitest.config.ts with jsdom environment and test setup - Add GitHub Actions CI workflow (type check, lint, test, build) - Add vercel.json for Vercel deployment - Add vite-env.d.ts for CSS module type declarations - Add .gitignore for node_modules and dist Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 32 ++++++++ .gitignore | 2 + src/components/AboutPage.test.tsx | 38 ++++++++++ src/components/FileUpload.test.tsx | 30 ++++++++ src/components/LogConsole.test.tsx | 28 +++++++ src/components/OptionsPanel.test.tsx | 40 ++++++++++ src/components/ResultsTable.test.tsx | 108 +++++++++++++++++++++++++++ src/mashx/databases.test.ts | 36 +++++++++ src/mashx/export.test.ts | 86 +++++++++++++++++++++ src/mashx/meta.test.ts | 90 ++++++++++++++++++++++ src/mashx/types.test.ts | 12 +++ src/test-setup.ts | 10 +++ src/vite-env.d.ts | 1 + vercel.json | 5 ++ vitest.config.ts | 10 +++ 15 files changed, 528 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 src/components/AboutPage.test.tsx create mode 100644 src/components/FileUpload.test.tsx create mode 100644 src/components/LogConsole.test.tsx create mode 100644 src/components/OptionsPanel.test.tsx create mode 100644 src/components/ResultsTable.test.tsx create mode 100644 src/mashx/databases.test.ts create mode 100644 src/mashx/export.test.ts create mode 100644 src/mashx/meta.test.ts create mode 100644 src/mashx/types.test.ts create mode 100644 src/test-setup.ts create mode 100644 src/vite-env.d.ts create mode 100644 vercel.json create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2f5c986 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - run: npm ci + + - name: Type check + run: npx tsc --noEmit + + - name: Lint + run: npm run lint + + - name: Test + run: npm test + + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/src/components/AboutPage.test.tsx b/src/components/AboutPage.test.tsx new file mode 100644 index 0000000..678797a --- /dev/null +++ b/src/components/AboutPage.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { AboutPage } from './AboutPage' + +describe('AboutPage', () => { + it('renders the about heading', () => { + render() + expect(screen.getByRole('heading', { name: 'About mashx' })).toBeInTheDocument() + }) + + it('renders the privacy note', () => { + render() + expect( + screen.getByText(/no data leaves your machine/i), + ).toBeInTheDocument() + }) + + it('renders the available databases section', () => { + render() + expect( + screen.getByRole('heading', { name: 'Available Databases' }), + ).toBeInTheDocument() + }) + + it('renders the how it works section', () => { + render() + expect( + screen.getByRole('heading', { name: 'How it works' }), + ).toBeInTheDocument() + }) + + it('renders author information', () => { + render() + expect( + screen.getByRole('heading', { name: 'Nabil-Fareed Alikhan' }), + ).toBeInTheDocument() + }) +}) diff --git a/src/components/FileUpload.test.tsx b/src/components/FileUpload.test.tsx new file mode 100644 index 0000000..cc91881 --- /dev/null +++ b/src/components/FileUpload.test.tsx @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { FileUpload } from './FileUpload' + +describe('FileUpload', () => { + it('renders the upload prompt when no files selected', () => { + render( {}} disabled={false} />) + expect( + screen.getByText('Drop query genomes or click to browse'), + ).toBeInTheDocument() + expect(screen.getByText('.fasta, .fa, .fna, .fsa, .gz')).toBeInTheDocument() + }) + + it('renders file count and names when files are selected', () => { + const files = [ + new File([''], 'sample1.fasta'), + new File([''], 'sample2.fa'), + ] + render( {}} disabled={false} />) + expect(screen.getByText('2 file(s) selected')).toBeInTheDocument() + expect(screen.getByText('sample1.fasta')).toBeInTheDocument() + expect(screen.getByText('sample2.fa')).toBeInTheDocument() + }) + + it('has accessible file input', () => { + render( {}} disabled={false} />) + const inputs = screen.getAllByLabelText('Upload query genome files') + expect(inputs.length).toBeGreaterThan(0) + }) +}) diff --git a/src/components/LogConsole.test.tsx b/src/components/LogConsole.test.tsx new file mode 100644 index 0000000..70b1363 --- /dev/null +++ b/src/components/LogConsole.test.tsx @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { LogConsole } from './LogConsole' + +describe('LogConsole', () => { + it('renders nothing when lines array is empty', () => { + const { container } = render() + expect(container.innerHTML).toBe('') + }) + + it('renders log lines', () => { + const lines = ['[MashX] Starting...', '[MashX] Done.'] + render() + + expect(screen.getByText(/Starting\.\.\./)).toBeInTheDocument() + expect(screen.getByText(/Done\./)).toBeInTheDocument() + }) + + it('shows the entry count', () => { + render() + expect(screen.getByText('3 entries')).toBeInTheDocument() + }) + + it('renders a copy log button', () => { + render() + expect(screen.getByText('Copy Log')).toBeInTheDocument() + }) +}) diff --git a/src/components/OptionsPanel.test.tsx b/src/components/OptionsPanel.test.tsx new file mode 100644 index 0000000..1ac4e12 --- /dev/null +++ b/src/components/OptionsPanel.test.tsx @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { OptionsPanel } from './OptionsPanel' +import { DEFAULT_OPTIONS } from '../mashx/types' + +describe('OptionsPanel', () => { + it('renders all option fields', () => { + render( + {}} disabled={false} />, + ) + expect(screen.getByText('Top N hits')).toBeInTheDocument() + expect(screen.getByText('Sketch size (s)')).toBeInTheDocument() + expect(screen.getByText('k-mer size (k)')).toBeInTheDocument() + }) + + it('displays current option values', () => { + render( + {}} disabled={false} />, + ) + const inputs = screen.getAllByRole('spinbutton') + expect(inputs[0]).toHaveValue(20) + expect(inputs[1]).toHaveValue(1000) + expect(inputs[2]).toHaveValue(21) + }) + + it('calls onChange when topN is updated', () => { + const onChange = vi.fn() + render( + , + ) + + const topNInput = screen.getAllByRole('spinbutton')[0] + fireEvent.change(topNInput, { target: { value: '50' } }) + + expect(onChange).toHaveBeenCalledWith({ + ...DEFAULT_OPTIONS, + topN: 50, + }) + }) +}) diff --git a/src/components/ResultsTable.test.tsx b/src/components/ResultsTable.test.tsx new file mode 100644 index 0000000..f5f1aa3 --- /dev/null +++ b/src/components/ResultsTable.test.tsx @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent, within } from '@testing-library/react' +import { ResultsTable } from './ResultsTable' +import type { MashxResult } from '../mashx/types' + +vi.mock('../mashx/export', () => ({ + exportCsv: vi.fn(), +})) + +const mockResult: MashxResult = { + hits: [ + { + reference: 'GCF_000123.1', + query: 'sample.fasta', + distance: 0.001, + pValue: 1e-20, + sharedHashes: '990/1000', + organism: 'Escherichia coli', + taxId: '562', + }, + { + reference: 'GCF_000456.2', + query: 'sample.fasta', + distance: 0.1, + pValue: 3e-10, + sharedHashes: '800/1000', + organism: 'Staphylococcus aureus', + taxId: '1280', + }, + { + reference: 'GCF_000789.3', + query: 'sample.fasta', + distance: 0.2, + pValue: 5e-5, + sharedHashes: '500/1000', + organism: 'Salmonella enterica', + taxId: '28901', + }, + ], + queryFiles: ['sample.fasta'], + databaseName: 'Test DB', + ranAt: '2025-01-01T00:00:00.000Z', + topN: 20, +} + +describe('ResultsTable', () => { + it('renders the results heading', () => { + render() + expect(screen.getByRole('heading', { name: 'Results' })).toBeInTheDocument() + }) + + it('renders all hit rows', () => { + render() + const table = screen.getByRole('table') + const rows = within(table).getAllByRole('row') + // header + 3 data rows + expect(rows.length).toBe(4) + }) + + it('shows organism column when hits have organism data', () => { + render() + expect(screen.getByText('Escherichia coli')).toBeInTheDocument() + expect(screen.getByText('Staphylococcus aureus')).toBeInTheDocument() + }) + + it('renders TaxID links to NCBI', () => { + render() + const link = screen.getByText('562').closest('a') + expect(link).toHaveAttribute( + 'href', + 'https://www.ncbi.nlm.nih.gov/Taxonomy/Browser/wwwtax.cgi?id=562', + ) + }) + + it('renders Export CSV button', () => { + render() + expect(screen.getByRole('button', { name: 'Export CSV' })).toBeInTheDocument() + }) + + it('filters results by search text', () => { + render() + const filterInput = screen.getByPlaceholderText(/filter by name/i) + fireEvent.change(filterInput, { target: { value: 'Escherichia' } }) + + const table = screen.getByRole('table') + const rows = within(table).getAllByRole('row') + // header + 1 filtered row + expect(rows.length).toBe(2) + }) + + it('shows hit count', () => { + render() + expect(screen.getByText(/3 of 3 hits/)).toBeInTheDocument() + }) + + it('applies distance color classes', () => { + render() + // 0.001 should be dist-close + const closeBadge = screen.getByText('0.0010') + expect(closeBadge.className).toContain('dist-close') + // 0.1 should be dist-medium + const medBadge = screen.getByText('0.1000') + expect(medBadge.className).toContain('dist-medium') + // 0.2 should be dist-far + const farBadge = screen.getByText('0.2000') + expect(farBadge.className).toContain('dist-far') + }) +}) diff --git a/src/mashx/databases.test.ts b/src/mashx/databases.test.ts new file mode 100644 index 0000000..43e33a9 --- /dev/null +++ b/src/mashx/databases.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { + DATABASES, + CACHE_VERSION, + DEFAULT_TOP_N, + MASH_SKETCH_SIZE, + MASH_KMER_SIZE, +} from './databases' + +describe('databases', () => { + it('exports expected constants', () => { + expect(CACHE_VERSION).toBe('mashx-v1') + expect(DEFAULT_TOP_N).toBe(20) + expect(MASH_SKETCH_SIZE).toBe(1000) + expect(MASH_KMER_SIZE).toBe(21) + }) + + it('DATABASES is a non-empty array', () => { + expect(Array.isArray(DATABASES)).toBe(true) + expect(DATABASES.length).toBeGreaterThan(0) + }) + + it.each(DATABASES)('database "$name" has required fields', (db) => { + expect(db.id).toBeTruthy() + expect(db.name).toBeTruthy() + expect(db.description).toBeTruthy() + expect(db.url).toMatch(/^https?:\/\//) + expect(db.sizeBytes).toBeGreaterThan(0) + expect(db.version).toBeTruthy() + }) + + it('database IDs are unique', () => { + const ids = DATABASES.map((d) => d.id) + expect(new Set(ids).size).toBe(ids.length) + }) +}) diff --git a/src/mashx/export.test.ts b/src/mashx/export.test.ts new file mode 100644 index 0000000..aeea4fa --- /dev/null +++ b/src/mashx/export.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { MashxResult } from './types' + +vi.mock('file-saver', () => ({ + saveAs: vi.fn(), +})) + +import { exportCsv } from './export' +import { saveAs } from 'file-saver' + +const mockedSaveAs = saveAs as unknown as ReturnType + +beforeEach(() => { + mockedSaveAs.mockClear() +}) + +describe('exportCsv', () => { + it('generates CSV with correct headers and rows', () => { + const result: MashxResult = { + hits: [ + { + reference: 'GCF_000123.1', + query: 'sample.fasta', + distance: 0.001234, + pValue: 1.5e-20, + sharedHashes: '990/1000', + organism: 'E. coli', + taxId: '562', + }, + { + reference: 'GCF_000456.2', + query: 'sample.fasta', + distance: 0.05, + pValue: 3.2e-10, + sharedHashes: '800/1000', + }, + ], + queryFiles: ['sample.fasta'], + databaseName: 'Test DB', + ranAt: '2025-01-01T00:00:00.000Z', + topN: 20, + } + + exportCsv(result) + + expect(mockedSaveAs).toHaveBeenCalledOnce() + const [blob, filename] = mockedSaveAs.mock.calls[0] + + expect(blob).toBeInstanceOf(Blob) + expect(blob.type).toBe('text/csv;charset=utf-8;') + expect(filename).toMatch(/^mashx-results-\d{4}-\d{2}-\d{2}\.csv$/) + }) + + it('escapes double quotes in CSV cells', () => { + // Spy on Blob constructor to capture the CSV string + let capturedCsv = '' + const OrigBlob = globalThis.Blob + globalThis.Blob = class extends OrigBlob { + constructor(parts: BlobPart[], options?: BlobPropertyBag) { + super(parts, options) + capturedCsv = String(parts[0]) + } + } as typeof Blob + + const result: MashxResult = { + hits: [ + { + reference: 'ref with "quotes"', + query: 'q.fasta', + distance: 0, + pValue: 0, + sharedHashes: '1/1', + }, + ], + queryFiles: ['q.fasta'], + databaseName: 'Test', + ranAt: '2025-01-01T00:00:00.000Z', + topN: 1, + } + + exportCsv(result) + globalThis.Blob = OrigBlob + + expect(capturedCsv).toContain('""quotes""') + }) +}) diff --git a/src/mashx/meta.test.ts b/src/mashx/meta.test.ts new file mode 100644 index 0000000..aff5f12 --- /dev/null +++ b/src/mashx/meta.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { fetchMeta } from './meta' + +beforeEach(() => { + vi.restoreAllMocks() +}) + +describe('fetchMeta', () => { + it('parses a TSV with assembly, taxid, and name columns', async () => { + const tsv = [ + 'Assembly\tTaxID\tScientificName', + 'GCF_000123.1\t562\tEscherichia coli', + 'GCF_000456.2\t1280\tStaphylococcus aureus', + ].join('\n') + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(tsv, { status: 200 }), + ) + + const map = await fetchMeta('https://example.com/meta.tsv') + + // Full ID lookup + expect(map.get('GCF_000123.1')).toEqual({ + taxName: 'Escherichia coli', + taxId: '562', + organism: 'Escherichia coli', + }) + + // Prefix lookup + expect(map.get('GCF_000123')).toEqual({ + taxName: 'Escherichia coli', + taxId: '562', + organism: 'Escherichia coli', + }) + + // Second entry + expect(map.get('GCF_000456.2')?.organism).toBe('Staphylococcus aureus') + }) + + it('handles alternative column names (accession, organism)', async () => { + const tsv = [ + 'accession\ttax_id\torganism', + 'NC_001.1\t99\tSome organism', + ].join('\n') + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(tsv, { status: 200 }), + ) + + const map = await fetchMeta('https://example.com/alt.tsv') + expect(map.get('NC_001.1')?.taxId).toBe('99') + expect(map.get('NC_001.1')?.organism).toBe('Some organism') + }) + + it('returns empty map on fetch failure', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('', { status: 404 }), + ) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const map = await fetchMeta('https://example.com/missing.tsv') + expect(map.size).toBe(0) + }) + + it('skips comment and empty lines', async () => { + const tsv = [ + 'Assembly\tTaxID\tName', + '# This is a comment', + '', + 'GCF_001.1\t100\tFoo', + ].join('\n') + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(tsv, { status: 200 }), + ) + + const map = await fetchMeta('https://example.com/meta.tsv') + expect(map.size).toBe(2) // GCF_001.1 + prefix GCF_001 + expect(map.get('GCF_001.1')?.organism).toBe('Foo') + }) + + it('returns empty map for single-line TSV (header only)', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('Assembly\tTaxID\tName', { status: 200 }), + ) + + const map = await fetchMeta('https://example.com/empty.tsv') + expect(map.size).toBe(0) + }) +}) diff --git a/src/mashx/types.test.ts b/src/mashx/types.test.ts new file mode 100644 index 0000000..6544454 --- /dev/null +++ b/src/mashx/types.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest' +import { DEFAULT_OPTIONS } from './types' + +describe('DEFAULT_OPTIONS', () => { + it('has correct default values', () => { + expect(DEFAULT_OPTIONS).toEqual({ + topN: 20, + sketchSize: 1000, + kmerSize: 21, + }) + }) +}) diff --git a/src/test-setup.ts b/src/test-setup.ts new file mode 100644 index 0000000..a36174e --- /dev/null +++ b/src/test-setup.ts @@ -0,0 +1,10 @@ +import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach } from 'vitest' + +afterEach(() => { + cleanup() +}) + +// jsdom does not implement scrollIntoView +Element.prototype.scrollIntoView = () => {} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..0654b16 --- /dev/null +++ b/vercel.json @@ -0,0 +1,5 @@ +{ + "buildCommand": "npm run build", + "outputDirectory": "dist", + "framework": "vite" +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..726f05b --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + setupFiles: ['./src/test-setup.ts'], + }, +}) From 0328a885f167091435dda0c7a994bf73a1342f41 Mon Sep 17 00:00:00 2001 From: Nabil-Fareed Alikhan Date: Fri, 3 Apr 2026 00:12:06 +0000 Subject: [PATCH 2/2] fix: update AboutPage and LogConsole tests for @genomicx/ui migration AboutPage.test: import from ../pages/About + wrap in MemoryRouter LogConsole.test: import from @genomicx/ui, use logs prop not lines prop Co-Authored-By: Claude Sonnet 4.6 --- src/components/AboutPage.test.tsx | 17 +++++++++++------ src/components/LogConsole.test.tsx | 22 ++++++++-------------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/components/AboutPage.test.tsx b/src/components/AboutPage.test.tsx index 678797a..1f36a8c 100644 --- a/src/components/AboutPage.test.tsx +++ b/src/components/AboutPage.test.tsx @@ -1,36 +1,41 @@ import { describe, it, expect } from 'vitest' import { render, screen } from '@testing-library/react' -import { AboutPage } from './AboutPage' +import { MemoryRouter } from 'react-router-dom' +import { About } from '../pages/About' + +function renderAbout() { + return render() +} describe('AboutPage', () => { it('renders the about heading', () => { - render() + renderAbout() expect(screen.getByRole('heading', { name: 'About mashx' })).toBeInTheDocument() }) it('renders the privacy note', () => { - render() + renderAbout() expect( screen.getByText(/no data leaves your machine/i), ).toBeInTheDocument() }) it('renders the available databases section', () => { - render() + renderAbout() expect( screen.getByRole('heading', { name: 'Available Databases' }), ).toBeInTheDocument() }) it('renders the how it works section', () => { - render() + renderAbout() expect( screen.getByRole('heading', { name: 'How it works' }), ).toBeInTheDocument() }) it('renders author information', () => { - render() + renderAbout() expect( screen.getByRole('heading', { name: 'Nabil-Fareed Alikhan' }), ).toBeInTheDocument() diff --git a/src/components/LogConsole.test.tsx b/src/components/LogConsole.test.tsx index 70b1363..343afd4 100644 --- a/src/components/LogConsole.test.tsx +++ b/src/components/LogConsole.test.tsx @@ -1,28 +1,22 @@ import { describe, it, expect } from 'vitest' import { render, screen } from '@testing-library/react' -import { LogConsole } from './LogConsole' +import { LogConsole } from '@genomicx/ui' describe('LogConsole', () => { - it('renders nothing when lines array is empty', () => { - const { container } = render() - expect(container.innerHTML).toBe('') + it('renders empty state when logs array is empty', () => { + render() + expect(screen.getByText(/no logs yet/i)).toBeInTheDocument() }) it('renders log lines', () => { - const lines = ['[MashX] Starting...', '[MashX] Done.'] - render() - + const logs = ['[MashX] Starting...', '[MashX] Done.'] + render() expect(screen.getByText(/Starting\.\.\./)).toBeInTheDocument() expect(screen.getByText(/Done\./)).toBeInTheDocument() }) - it('shows the entry count', () => { - render() - expect(screen.getByText('3 entries')).toBeInTheDocument() - }) - it('renders a copy log button', () => { - render() - expect(screen.getByText('Copy Log')).toBeInTheDocument() + render() + expect(screen.getByText(/copy/i)).toBeInTheDocument() }) })