phase 16: vitest + MSW frontend tests, CI integration
This commit is contained in:
@@ -30,6 +30,20 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: python -m pytest tests/ -x --tb=short -q || true
|
run: python -m pytest tests/ -x --tb=short -q || true
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install frontend deps
|
||||||
|
run: npm ci
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Frontend tests
|
||||||
|
run: npm test
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
build-services:
|
build-services:
|
||||||
needs: lint-and-test
|
needs: lint-and-test
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
|||||||
Generated
+1620
-1
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -7,7 +7,9 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest --run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
@@ -22,6 +24,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -30,8 +35,11 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
|
"msw": "^2.13.2",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.58.0",
|
"typescript-eslint": "^8.58.0",
|
||||||
"vite": "^8.0.4"
|
"vite": "^8.0.4",
|
||||||
|
"vitest": "^4.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { http, HttpResponse } from 'msw';
|
||||||
|
|
||||||
|
// Seed data for deterministic tests
|
||||||
|
export const mockCompanies = [
|
||||||
|
{ id: '1', ticker: 'AAPL', legal_name: 'Apple Inc.', exchange: 'NASDAQ', sector: 'Technology', industry: 'Consumer Electronics', market_cap_bucket: 'mega', active: true, active_source_count: 3 },
|
||||||
|
{ id: '2', ticker: 'MSFT', legal_name: 'Microsoft Corporation', exchange: 'NASDAQ', sector: 'Technology', industry: 'Software', market_cap_bucket: 'mega', active: true, active_source_count: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockDocuments = [
|
||||||
|
{ id: 'd1', document_type: 'news', source_type: 'news_api', publisher: 'Reuters', url: null, title: 'Apple Q4 Earnings Beat', published_at: '2026-04-10T12:00:00Z', retrieved_at: '2026-04-10T12:05:00Z', language: 'en', content_hash: 'abc123', parse_quality_score: 0.92, parse_confidence: 'high', status: 'extracted', created_at: '2026-04-10T12:05:00Z' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockTrends = [
|
||||||
|
{ id: 't1', entity_type: 'company', entity_id: 'AAPL', window: '7d', trend_direction: 'bullish', trend_strength: 0.75, confidence: 0.82, top_supporting_evidence: ['Strong earnings'], top_opposing_evidence: [], dominant_catalysts: ['earnings'], material_risks: [], contradiction_score: 0.1, market_context: null, generated_at: '2026-04-10T18:00:00Z' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockRecommendations = [
|
||||||
|
{ id: 'r1', ticker: 'AAPL', action: 'buy', mode: 'paper', confidence: 0.78, time_horizon: '7d', thesis: 'Strong earnings momentum', invalidation_conditions: null, portfolio_pct: 0.05, max_loss_pct: 0.02, model_version: 'v1', risk_classification: 'moderate', generated_at: '2026-04-10T19:00:00Z' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockOrders = [
|
||||||
|
{ id: 'o1', recommendation_id: 'r1', broker_account_id: null, ticker: 'AAPL', side: 'buy', order_type: 'market', quantity: 10, limit_price: null, stop_price: null, status: 'filled', broker_order_id: null, submitted_at: '2026-04-10T19:01:00Z', fill_price: 185.50, fill_quantity: 10, created_at: '2026-04-10T19:01:00Z' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockPositions = [
|
||||||
|
{ id: 'p1', broker_account_id: null, ticker: 'AAPL', quantity: 10, avg_entry_price: 185.50, current_price: 188.20, unrealized_pnl: 27.00, realized_pnl: 0, updated_at: '2026-04-11T12:00:00Z' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const handlers = [
|
||||||
|
// Query API (proxied at /api/)
|
||||||
|
http.get('/api/companies', () => HttpResponse.json(mockCompanies)),
|
||||||
|
http.get('/api/companies/:id', ({ params }) => {
|
||||||
|
const c = mockCompanies.find((c) => c.id === params.id);
|
||||||
|
return c ? HttpResponse.json({ ...c, aliases: [], active_source_count: c.active_source_count }) : new HttpResponse(null, { status: 404 });
|
||||||
|
}),
|
||||||
|
http.get('/api/companies/:id/sources', () => HttpResponse.json([])),
|
||||||
|
http.get('/api/documents', () => HttpResponse.json(mockDocuments)),
|
||||||
|
http.get('/api/documents/:id', () => HttpResponse.json({ ...mockDocuments[0], canonical_url: null, raw_storage_ref: null, normalized_storage_ref: null, company_mentions: [], intelligence: null })),
|
||||||
|
http.get('/api/trends', () => HttpResponse.json(mockTrends)),
|
||||||
|
http.get('/api/trends/:id', () => HttpResponse.json(mockTrends[0])),
|
||||||
|
http.get('/api/trends/:id/evidence', () => HttpResponse.json({ trend: mockTrends[0], evidence: [] })),
|
||||||
|
http.get('/api/recommendations', () => HttpResponse.json(mockRecommendations)),
|
||||||
|
http.get('/api/recommendations/:id', () => HttpResponse.json({ ...mockRecommendations[0], company_id: '1', evidence: [], risk_evaluation: null })),
|
||||||
|
http.get('/api/orders', () => HttpResponse.json(mockOrders)),
|
||||||
|
http.get('/api/orders/:id', () => HttpResponse.json({ ...mockOrders[0], idempotency_key: null, decision_trace: null, events: [], audit_trail: [] })),
|
||||||
|
http.get('/api/positions', () => HttpResponse.json(mockPositions)),
|
||||||
|
http.get('/api/admin/trading/config', () => HttpResponse.json({ trading_mode: 'paper', config: {} })),
|
||||||
|
http.get('/api/admin/trading/approvals', () => HttpResponse.json([])),
|
||||||
|
http.get('/api/admin/trading/lockouts', () => HttpResponse.json([])),
|
||||||
|
http.get('/api/ops/pipeline/health', () => HttpResponse.json({ hours: 24, document_stages: [{ status: 'extracted', doc_count: 5 }], parsing: {}, extraction: {}, aggregation: {} })),
|
||||||
|
http.get('/api/ops/ingestion/summary', () => HttpResponse.json({ total_runs: 10, completed: 8, failed: 2, total_items_fetched: 50, total_items_new: 12, by_source_type: [] })),
|
||||||
|
http.get('/api/ops/ingestion/throughput', () => HttpResponse.json([])),
|
||||||
|
http.get('/api/ops/model/performance', () => HttpResponse.json({ total_extractions: 20, success_rate: 0.9, avg_duration_ms: 1500, retry_rate: 0.05, avg_confidence: 0.8 })),
|
||||||
|
http.get('/api/ops/model/failures', () => HttpResponse.json([])),
|
||||||
|
http.get('/api/ops/sources/coverage-gaps', () => HttpResponse.json({ missing_source_types: [], stale_sources: [] })),
|
||||||
|
http.get('/api/admin/companies/coverage', () => HttpResponse.json([])),
|
||||||
|
|
||||||
|
// Symbol Registry (proxied at /registry/)
|
||||||
|
http.get('/registry/companies', () => HttpResponse.json(mockCompanies)),
|
||||||
|
http.post('/registry/companies', async ({ request }) => {
|
||||||
|
const body = await request.json() as Record<string, string>;
|
||||||
|
return HttpResponse.json({ id: '99', ticker: body.ticker, legal_name: body.legal_name, exchange: body.exchange ?? null, sector: body.sector ?? null, industry: null, market_cap_bucket: null, active: true }, { status: 201 });
|
||||||
|
}),
|
||||||
|
http.get('/registry/watchlists', () => HttpResponse.json([])),
|
||||||
|
http.post('/registry/watchlists', async ({ request }) => {
|
||||||
|
const body = await request.json() as Record<string, string>;
|
||||||
|
return HttpResponse.json({ id: 'w1', name: body.name, description: body.description ?? null, active: true }, { status: 201 });
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Health
|
||||||
|
http.get('/api/health', () => HttpResponse.json({ status: 'ok' })),
|
||||||
|
];
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { setupServer } from 'msw/node';
|
||||||
|
import { handlers } from './handlers';
|
||||||
|
|
||||||
|
export const server = setupServer(...handlers);
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { renderRoute } from './render';
|
||||||
|
|
||||||
|
describe('Home page', () => {
|
||||||
|
it('renders the title and key metrics', async () => {
|
||||||
|
renderRoute('/');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Stonks Oracle')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Companies page', () => {
|
||||||
|
it('renders company list with tickers', async () => {
|
||||||
|
renderRoute('/companies');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('AAPL')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('MSFT')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters companies by search', async () => {
|
||||||
|
renderRoute('/companies');
|
||||||
|
await waitFor(() => expect(screen.getByText('AAPL')).toBeInTheDocument());
|
||||||
|
|
||||||
|
const filter = screen.getByLabelText('Filter table');
|
||||||
|
await userEvent.type(filter, 'micro');
|
||||||
|
|
||||||
|
expect(screen.getByText('MSFT')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('AAPL')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows add company form on button click', async () => {
|
||||||
|
renderRoute('/companies');
|
||||||
|
await waitFor(() => expect(screen.getByText('Add Company')).toBeInTheDocument());
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText('Add Company'));
|
||||||
|
expect(screen.getByLabelText('Ticker')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('Legal Name')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits new company form', async () => {
|
||||||
|
renderRoute('/companies');
|
||||||
|
await waitFor(() => expect(screen.getByText('Add Company')).toBeInTheDocument());
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText('Add Company'));
|
||||||
|
await userEvent.type(screen.getByLabelText('Ticker'), 'TSLA');
|
||||||
|
await userEvent.type(screen.getByLabelText('Legal Name'), 'Tesla Inc.');
|
||||||
|
await userEvent.click(screen.getByText('Create'));
|
||||||
|
|
||||||
|
// Form should close on success
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByLabelText('Ticker')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Documents page', () => {
|
||||||
|
it('renders document list', async () => {
|
||||||
|
renderRoute('/documents');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Apple Q4 Earnings Beat')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Trends page', () => {
|
||||||
|
it('renders trend cards with direction', async () => {
|
||||||
|
renderRoute('/trends');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('AAPL')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Recommendations page', () => {
|
||||||
|
it('renders recommendation list', async () => {
|
||||||
|
renderRoute('/recommendations');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('AAPL')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Orders page', () => {
|
||||||
|
it('renders order list', async () => {
|
||||||
|
renderRoute('/orders');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('AAPL')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Positions page', () => {
|
||||||
|
it('renders positions with PnL', async () => {
|
||||||
|
renderRoute('/positions');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('AAPL')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('$185.50')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Trading page', () => {
|
||||||
|
it('renders trading mode buttons', async () => {
|
||||||
|
renderRoute('/trading');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('paper')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('live')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('disabled')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Ops pages', () => {
|
||||||
|
it('pipeline health renders', async () => {
|
||||||
|
renderRoute('/ops/pipeline');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Pipeline Health')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ingestion monitor renders', async () => {
|
||||||
|
renderRoute('/ops/ingestion');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Ingestion Monitor')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('model performance renders', async () => {
|
||||||
|
renderRoute('/ops/model');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Model Performance')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('source coverage renders', async () => {
|
||||||
|
renderRoute('/ops/coverage');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Source Coverage')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Watchlists page', () => {
|
||||||
|
it('renders watchlists page with new button', async () => {
|
||||||
|
renderRoute('/watchlists');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('New Watchlist')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { type ReactNode } from 'react';
|
||||||
|
import { render, type RenderOptions } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { RouterProvider, createMemoryHistory } from '@tanstack/react-router';
|
||||||
|
import { router } from '../routes';
|
||||||
|
|
||||||
|
function createTestQueryClient() {
|
||||||
|
return new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false, gcTime: 0 },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderWithProviders(
|
||||||
|
ui: ReactNode,
|
||||||
|
options?: RenderOptions,
|
||||||
|
) {
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
|
function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return render(ui, { wrapper: Wrapper, ...options });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderRoute(path: string) {
|
||||||
|
const queryClient = createTestQueryClient();
|
||||||
|
const history = createMemoryHistory({ initialEntries: [path] });
|
||||||
|
const testRouter = Object.assign(router, {});
|
||||||
|
testRouter.update({ history });
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={testRouter} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
import { cleanup } from '@testing-library/react';
|
||||||
|
import { afterEach, afterAll, beforeAll } from 'vitest';
|
||||||
|
import { server } from './mocks/server';
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
server.resetHandlers();
|
||||||
|
});
|
||||||
|
afterAll(() => server.close());
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
css: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user