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..1f36a8c
--- /dev/null
+++ b/src/components/AboutPage.test.tsx
@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { MemoryRouter } from 'react-router-dom'
+import { About } from '../pages/About'
+
+function renderAbout() {
+ return render()
+}
+
+describe('AboutPage', () => {
+ it('renders the about heading', () => {
+ renderAbout()
+ expect(screen.getByRole('heading', { name: 'About mashx' })).toBeInTheDocument()
+ })
+
+ it('renders the privacy note', () => {
+ renderAbout()
+ expect(
+ screen.getByText(/no data leaves your machine/i),
+ ).toBeInTheDocument()
+ })
+
+ it('renders the available databases section', () => {
+ renderAbout()
+ expect(
+ screen.getByRole('heading', { name: 'Available Databases' }),
+ ).toBeInTheDocument()
+ })
+
+ it('renders the how it works section', () => {
+ renderAbout()
+ expect(
+ screen.getByRole('heading', { name: 'How it works' }),
+ ).toBeInTheDocument()
+ })
+
+ it('renders author information', () => {
+ renderAbout()
+ 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..343afd4
--- /dev/null
+++ b/src/components/LogConsole.test.tsx
@@ -0,0 +1,22 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { LogConsole } from '@genomicx/ui'
+
+describe('LogConsole', () => {
+ it('renders empty state when logs array is empty', () => {
+ render()
+ expect(screen.getByText(/no logs yet/i)).toBeInTheDocument()
+ })
+
+ it('renders log lines', () => {
+ const logs = ['[MashX] Starting...', '[MashX] Done.']
+ render()
+ expect(screen.getByText(/Starting\.\.\./)).toBeInTheDocument()
+ expect(screen.getByText(/Done\./)).toBeInTheDocument()
+ })
+
+ it('renders a copy log button', () => {
+ render()
+ expect(screen.getByText(/copy/i)).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'],
+ },
+})