diff --git a/apps/frontend/src/components/deployments/AnalyticsCard.test.tsx b/apps/frontend/src/components/deployments/AnalyticsCard.test.tsx
new file mode 100644
index 0000000..455bf50
--- /dev/null
+++ b/apps/frontend/src/components/deployments/AnalyticsCard.test.tsx
@@ -0,0 +1,50 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { AnalyticsCard } from './AnalyticsCard';
+
+const icon = ;
+
+describe('AnalyticsCard', () => {
+ it('renders label and value', () => {
+ render();
+ expect(screen.getByText('Total Deployments')).toBeDefined();
+ expect(screen.getByText('248')).toBeDefined();
+ });
+
+ it('renders subValue when provided', () => {
+ render();
+ expect(screen.getByText('5 today')).toBeDefined();
+ });
+
+ it('does not render subValue when omitted', () => {
+ render();
+ expect(screen.queryByText('5 today')).toBeNull();
+ });
+
+ it('renders positive trend with + sign', () => {
+ render();
+ expect(screen.getByText(/\+2\.1/)).toBeDefined();
+ });
+
+ it('renders negative trend without + sign', () => {
+ render();
+ expect(screen.getByText(/-8/)).toBeDefined();
+ expect(screen.queryByText(/\+/)).toBeNull();
+ });
+
+ it('does not render trend badge when trend is undefined', () => {
+ const { container } = render();
+ expect(container.querySelector('.bg-green-50')).toBeNull();
+ expect(container.querySelector('.bg-red-50')).toBeNull();
+ });
+
+ it('has accessible article with aria-label', () => {
+ render();
+ expect(screen.getByRole('article', { name: 'Success Rate' })).toBeDefined();
+ });
+
+ it('sets the id attribute', () => {
+ const { container } = render();
+ expect(container.querySelector('#analytics-total')).not.toBeNull();
+ });
+});
diff --git a/apps/frontend/src/components/deployments/AnalyticsGrid.test.tsx b/apps/frontend/src/components/deployments/AnalyticsGrid.test.tsx
new file mode 100644
index 0000000..607581b
--- /dev/null
+++ b/apps/frontend/src/components/deployments/AnalyticsGrid.test.tsx
@@ -0,0 +1,62 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { AnalyticsGrid } from './AnalyticsGrid';
+import type { DeploymentAnalytics } from '@/types/deployment';
+
+const analytics: DeploymentAnalytics = {
+ totalDeployments: 248,
+ successRate: 94.2,
+ avgDurationSeconds: 118,
+ activeDeployments: 2,
+ failedLast24h: 1,
+ deploymentsToday: 8,
+ successRateTrend: 2.1,
+ avgDurationTrend: -8,
+};
+
+describe('AnalyticsGrid', () => {
+ it('renders all six analytics cards', () => {
+ render();
+ expect(screen.getByText('Total Deployments')).toBeDefined();
+ expect(screen.getByText('Success Rate')).toBeDefined();
+ expect(screen.getByText('Avg Build Time')).toBeDefined();
+ expect(screen.getByText('Active Deployments')).toBeDefined();
+ expect(screen.getByText('Failed (24 h)')).toBeDefined();
+ expect(screen.getByText('Deployments Today')).toBeDefined();
+ });
+
+ it('displays total deployments value', () => {
+ render();
+ expect(screen.getByText('248')).toBeDefined();
+ });
+
+ it('displays success rate formatted to 1 decimal', () => {
+ render();
+ expect(screen.getByText('94.2%')).toBeDefined();
+ });
+
+ it('displays avg duration in minutes and seconds', () => {
+ render();
+ expect(screen.getByText('1m 58s')).toBeDefined();
+ });
+
+ it('displays avg duration in seconds only when < 60s', () => {
+ render();
+ expect(screen.getByText('45s')).toBeDefined();
+ });
+
+ it('has accessible section label', () => {
+ render();
+ expect(screen.getByRole('region', { name: 'Deployment analytics' })).toBeDefined();
+ });
+
+ it('shows "No failures" sub-value when failedLast24h is 0', () => {
+ render();
+ expect(screen.getByText('No failures β great!')).toBeDefined();
+ });
+
+ it('shows "Needs attention" sub-value when failedLast24h > 0', () => {
+ render();
+ expect(screen.getByText('Needs attention')).toBeDefined();
+ });
+});
diff --git a/apps/frontend/src/components/deployments/DeploymentRow.test.tsx b/apps/frontend/src/components/deployments/DeploymentRow.test.tsx
new file mode 100644
index 0000000..3f201be
--- /dev/null
+++ b/apps/frontend/src/components/deployments/DeploymentRow.test.tsx
@@ -0,0 +1,106 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { DeploymentRow } from './DeploymentRow';
+import type { Deployment } from '@/types/deployment';
+
+const base: Deployment = {
+ id: 'dep-001',
+ name: 'stellar-dex',
+ status: 'success',
+ environment: 'production',
+ trigger: 'push',
+ commit: {
+ sha: 'a3f9c12',
+ message: 'feat: add liquidity pool',
+ author: 'jana.m',
+ branch: 'main',
+ },
+ region: { id: 'us-east-1', label: 'US East', flag: 'πΊπΈ' },
+ createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
+ durationSeconds: 127,
+ url: 'https://stellar-dex.craft.app',
+ logsUrl: '#',
+};
+
+describe('DeploymentRow', () => {
+ it('renders deployment name', () => {
+ render(
);
+ expect(screen.getByText('stellar-dex')).toBeDefined();
+ });
+
+ it('renders commit sha and message', () => {
+ render();
+ expect(screen.getByText('a3f9c12')).toBeDefined();
+ expect(screen.getByText('feat: add liquidity pool')).toBeDefined();
+ });
+
+ it('renders environment badge', () => {
+ render();
+ expect(screen.getByText('Production')).toBeDefined();
+ });
+
+ it('renders status badge', () => {
+ render();
+ expect(screen.getByText('Success')).toBeDefined();
+ });
+
+ it('renders duration when provided', () => {
+ render();
+ expect(screen.getByText('2m 7s')).toBeDefined();
+ });
+
+ it('does not render duration when omitted', () => {
+ const { queryByText } = render();
+ expect(queryByText(/\dm/)).toBeNull();
+ });
+
+ it('renders visit link when url is present', () => {
+ render();
+ const link = screen.getByRole('link', { name: /Visit stellar-dex/i });
+ expect(link.getAttribute('href')).toBe('https://stellar-dex.craft.app');
+ });
+
+ it('does not render visit link when url is absent', () => {
+ render();
+ expect(screen.queryByRole('link', { name: /Visit/i })).toBeNull();
+ });
+
+ it('calls onViewLogs when logs button clicked', () => {
+ const onViewLogs = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /View logs for stellar-dex/i }));
+ expect(onViewLogs).toHaveBeenCalledWith('dep-001');
+ });
+
+ it('does not render logs button when logsUrl is absent', () => {
+ render();
+ expect(screen.queryByRole('button', { name: /View logs/i })).toBeNull();
+ });
+
+ it('calls onRedeploy when redeploy button clicked for success status', () => {
+ const onRedeploy = vi.fn();
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /Redeploy stellar-dex/i }));
+ expect(onRedeploy).toHaveBeenCalledWith('dep-001');
+ });
+
+ it('renders redeploy button for failed status', () => {
+ render();
+ expect(screen.getByRole('button', { name: /Redeploy/i })).toBeDefined();
+ });
+
+ it('does not render redeploy button for running status', () => {
+ render();
+ expect(screen.queryByRole('button', { name: /Redeploy/i })).toBeNull();
+ });
+
+ it('sets the row id attribute', () => {
+ const { container } = render();
+ expect(container.querySelector('#deployment-row-dep-001')).not.toBeNull();
+ });
+
+ it('renders region flag', () => {
+ render();
+ expect(screen.getByText('πΊπΈ')).toBeDefined();
+ });
+});