diff --git a/artifacthub-pkg.yml b/artifacthub-pkg.yml
index 5c571f3..730cabc 100644
--- a/artifacthub-pkg.yml
+++ b/artifacthub-pkg.yml
@@ -11,6 +11,7 @@ description: >-
`polaris-dashboard` service in the `polaris` namespace.
license: Apache-2.0
homeURL: "https://github.com/privilegedescalation/headlamp-polaris-plugin"
+appVersion: "5.0"
category: security
keywords:
- polaris
@@ -24,6 +25,43 @@ links:
url: "https://github.com/privilegedescalation/headlamp-polaris-plugin"
- name: Polaris
url: "https://polaris.docs.fairwinds.com/"
+install: |
+ ## Installation
+
+ ### Prerequisites
+
+ 1. [Headlamp](https://headlamp.dev) v0.26.0 or later
+ 2. [Fairwinds Polaris](https://polaris.docs.fairwinds.com/) installed and the dashboard running in your cluster
+
+ ### Install via Headlamp Plugin Catalog
+
+ 1. Open Headlamp and navigate to **Settings → Plugin Catalog**
+ 2. Search for **"Polaris"**
+ 3. Click **Install** and restart Headlamp when prompted
+
+ The plugin is sourced directly from [ArtifactHub](https://artifacthub.io/packages/headlamp/headlamp/headlamp-polaris).
+
+ ## Usage
+
+ After installation, the Polaris plugin adds:
+ - A **cluster score badge** in the Headlamp app bar
+ - A **Polaris** section in the sidebar with the full dashboard and namespace drill-downs
+ - An **inline audit panel** on Deployment, StatefulSet, DaemonSet, Job, and CronJob detail pages
+
+ For more information, see the [README](https://github.com/privilegedescalation/headlamp-polaris-plugin/blob/main/README.md).
+changes:
+ - kind: added
+ description: ExemptionManager — apply Polaris annotation exemptions directly from the resource detail page
+ - kind: added
+ description: Inline audit section on workload detail pages with per-check pass/fail breakdown
+ - kind: added
+ description: Namespace drill-down view with per-resource score list and filterable check table
+ - kind: added
+ description: App bar score badge showing overall cluster Polaris score
+ - kind: added
+ description: PolarisSettings page for configuring dashboard refresh interval
+ - kind: changed
+ description: Stable public API — routes, sidebar entries, settings schema, and app bar action are frozen
maintainers:
- name: privilegedescalation
email: "chris@farhood.org"
@@ -31,4 +69,4 @@ annotations:
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/download/v0.7.2/headlamp-polaris-0.7.2.tar.gz"
headlamp/plugin/version-compat: ">=0.26"
headlamp/plugin/archive-checksum: sha256:ce75449a05d3d3dd3c546db36a2257fae3e4601e466108182e64310a1a4f6d71
- headlamp/plugin/distro-compat: in-cluster
+ headlamp/plugin/distro-compat: "in-cluster,web,desktop"
diff --git a/package-lock.json b/package-lock.json
index 887daa4..1523bc0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
"@testing-library/user-event": "^14.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
+ "@vitest/coverage-v8": "^3.2.4",
"jsdom": "^24.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -37,6 +38,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@ampproject/remapping": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
+ "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@apidevtools/json-schema-ref-parser": {
"version": "11.7.2",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.7.2.tgz",
@@ -431,6 +446,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@bundled-es-modules/cookie": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz",
@@ -4846,6 +4871,40 @@
"vitest": "3.2.4"
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
+ "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ampproject/remapping": "^2.3.0",
+ "@bcoe/v8-coverage": "^1.0.2",
+ "ast-v8-to-istanbul": "^0.3.3",
+ "debug": "^4.4.1",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-lib-source-maps": "^5.0.6",
+ "istanbul-reports": "^3.1.7",
+ "magic-string": "^0.30.17",
+ "magicast": "^0.3.5",
+ "std-env": "^3.9.0",
+ "test-exclude": "^7.0.1",
+ "tinyrainbow": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "3.2.4",
+ "vitest": "3.2.4"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
@@ -5651,6 +5710,35 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "0.3.12",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz",
+ "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/astral-regex": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
diff --git a/package.json b/package.json
index ad2c18e..6e657fb 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,7 @@
"@testing-library/user-event": "^14.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
+ "@vitest/coverage-v8": "^3.2.4",
"jsdom": "^24.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
diff --git a/src/components/ExemptionManager.test.tsx b/src/components/ExemptionManager.test.tsx
new file mode 100644
index 0000000..c164549
--- /dev/null
+++ b/src/components/ExemptionManager.test.tsx
@@ -0,0 +1,432 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import React from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { makeResult } from '../test-utils';
+
+const { mockApiRequest } = vi.hoisted(() => ({ mockApiRequest: vi.fn() }));
+
+vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
+ ApiProxy: { request: mockApiRequest },
+}));
+
+vi.mock('@mui/material/styles', () => ({
+ useTheme: () => ({
+ palette: {
+ primary: { main: '#1976d2', contrastText: '#fff' },
+ action: { disabledBackground: '#e0e0e0', disabled: '#9e9e9e' },
+ divider: '#e0e0e0',
+ },
+ }),
+}));
+
+vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
+ SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => (
+
+ {children}
+
+ ),
+ StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
+
+ {children}
+
+ ),
+ Dialog: ({
+ open,
+ children,
+ title,
+ }: {
+ open: boolean;
+ onClose?: () => void;
+ title?: string;
+ children?: React.ReactNode;
+ }) =>
+ open ? (
+
+ {children}
+
+ ) : null,
+}));
+
+import ExemptionManager from './ExemptionManager';
+
+const defaultProps = {
+ workloadResult: makeResult(),
+ namespace: 'default',
+ kind: 'Deployment',
+ name: 'my-deploy',
+};
+
+const resultWithPodFailures = makeResult({
+ PodResult: {
+ Name: 'pod',
+ Results: {
+ hostIPCSet: {
+ ID: 'hostIPCSet',
+ Message: 'Host IPC is set',
+ Details: [],
+ Success: false,
+ Severity: 'danger',
+ Category: 'Security',
+ },
+ hostPIDSet: {
+ ID: 'hostPIDSet',
+ Message: 'Host PID is set',
+ Details: [],
+ Success: false,
+ Severity: 'danger',
+ Category: 'Security',
+ },
+ },
+ ContainerResults: [],
+ },
+});
+
+const resultWithContainerFailures = makeResult({
+ PodResult: {
+ Name: 'pod',
+ Results: {},
+ ContainerResults: [
+ {
+ Name: 'container-1',
+ Results: {
+ cpuRequestsMissing: {
+ ID: 'cpuRequestsMissing',
+ Message: 'CPU requests missing',
+ Details: [],
+ Success: false,
+ Severity: 'warning',
+ Category: 'Efficiency',
+ },
+ },
+ },
+ ],
+ },
+});
+
+const resultWithIgnoredFailures = makeResult({
+ PodResult: {
+ Name: 'pod',
+ Results: {
+ hostIPCSet: {
+ ID: 'hostIPCSet',
+ Message: '',
+ Details: [],
+ Success: false,
+ Severity: 'ignore',
+ Category: 'Security',
+ },
+ },
+ ContainerResults: [],
+ },
+});
+
+describe('ExemptionManager', () => {
+ describe('rendering failing checks', () => {
+ it('shows disabled Add Exemption button when no failing checks', () => {
+ render();
+ const btn = screen.getByRole('button', { name: /add exemption/i });
+ expect(btn).toBeDisabled();
+ });
+
+ it('shows enabled Add Exemption button when there are failing checks', () => {
+ render();
+ const btn = screen.getByRole('button', { name: /add exemption/i });
+ expect(btn).not.toBeDisabled();
+ });
+
+ it('does not include ignored-severity checks as failing', () => {
+ render();
+ const btn = screen.getByRole('button', { name: /add exemption/i });
+ expect(btn).toBeDisabled();
+ });
+
+ it('collects failing checks from pod-level results', () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
+ expect(screen.getByText('Host IPC')).toBeInTheDocument();
+ expect(screen.getByText('Host PID')).toBeInTheDocument();
+ });
+
+ it('collects failing checks from container-level results', () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
+ expect(screen.getByText('CPU Requests')).toBeInTheDocument();
+ });
+
+ it('deduplicates checks that appear in multiple containers', () => {
+ const resultWithDuplicate = makeResult({
+ PodResult: {
+ Name: 'pod',
+ Results: {},
+ ContainerResults: [
+ {
+ Name: 'container-1',
+ Results: {
+ cpuRequestsMissing: {
+ ID: 'cpuRequestsMissing',
+ Message: '',
+ Details: [],
+ Success: false,
+ Severity: 'warning',
+ Category: 'Efficiency',
+ },
+ },
+ },
+ {
+ Name: 'container-2',
+ Results: {
+ cpuRequestsMissing: {
+ ID: 'cpuRequestsMissing',
+ Message: '',
+ Details: [],
+ Success: false,
+ Severity: 'warning',
+ Category: 'Efficiency',
+ },
+ },
+ },
+ ],
+ },
+ });
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
+ const items = screen.getAllByText('CPU Requests');
+ expect(items).toHaveLength(1);
+ });
+ });
+
+ describe('dialog interactions', () => {
+ it('opens dialog when Add Exemption button is clicked', () => {
+ render();
+ expect(screen.queryByTestId('dialog')).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
+ expect(screen.getByTestId('dialog')).toBeInTheDocument();
+ });
+
+ it('closes dialog when Cancel button is clicked', () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
+ expect(screen.getByTestId('dialog')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
+ expect(screen.queryByTestId('dialog')).not.toBeInTheDocument();
+ });
+
+ it('toggles individual check selection', () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
+
+ // Find the checkbox next to "Host IPC"
+ const checkboxes = screen.getAllByRole('checkbox');
+ // First checkbox is "Exempt from all checks", rest are individual checks
+ const hostIPCCheckbox = checkboxes[1];
+ expect(hostIPCCheckbox).not.toBeChecked();
+ fireEvent.click(hostIPCCheckbox);
+ expect(hostIPCCheckbox).toBeChecked();
+ fireEvent.click(hostIPCCheckbox);
+ expect(hostIPCCheckbox).not.toBeChecked();
+ });
+
+ it('hides individual checks list when exempt-all is toggled', () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
+ expect(screen.getByText('Host IPC')).toBeInTheDocument();
+
+ const exemptAllCheckbox = screen.getByRole('checkbox', { name: /exempt from all checks/i });
+ fireEvent.click(exemptAllCheckbox);
+ expect(screen.queryByText('Host IPC')).not.toBeInTheDocument();
+ });
+
+ it('Apply button is disabled when no checks selected and exemptAll is false', () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
+ expect(screen.getByRole('button', { name: /apply/i })).toBeDisabled();
+ });
+
+ it('Apply button is enabled when exemptAll is checked', () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
+ const exemptAllCheckbox = screen.getByRole('checkbox', { name: /exempt from all checks/i });
+ fireEvent.click(exemptAllCheckbox);
+ expect(screen.getByRole('button', { name: /apply/i })).not.toBeDisabled();
+ });
+
+ it('Apply button is enabled when at least one individual check is selected', () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
+ const checkboxes = screen.getAllByRole('checkbox');
+ fireEvent.click(checkboxes[1]); // select first individual check
+ expect(screen.getByRole('button', { name: /apply/i })).not.toBeDisabled();
+ });
+ });
+
+ describe('ApiProxy.request calls', () => {
+ it('patches with exempt-all annotation when exemptAll is selected', async () => {
+ mockApiRequest.mockResolvedValue({});
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
+ fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
+ fireEvent.click(screen.getByRole('button', { name: /apply/i }));
+
+ await waitFor(() => {
+ expect(mockApiRequest).toHaveBeenCalledWith(
+ '/apis/apps/v1/namespaces/default/deployments/my-deploy',
+ expect.objectContaining({
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/strategic-merge-patch+json' },
+ body: JSON.stringify({
+ metadata: {
+ annotations: { 'polaris.fairwinds.com/exempt': 'true' },
+ },
+ }),
+ })
+ );
+ });
+ });
+
+ it('patches with per-check annotations when individual checks selected', async () => {
+ mockApiRequest.mockResolvedValue({});
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
+ // Select first check (hostIPCSet)
+ fireEvent.click(screen.getAllByRole('checkbox')[1]);
+ fireEvent.click(screen.getByRole('button', { name: /apply/i }));
+
+ await waitFor(() => {
+ expect(mockApiRequest).toHaveBeenCalledWith(
+ '/apis/apps/v1/namespaces/default/deployments/my-deploy',
+ expect.objectContaining({
+ method: 'PATCH',
+ body: JSON.stringify({
+ metadata: {
+ annotations: { 'polaris.fairwinds.com/hostIPCSet-exempt': 'true' },
+ },
+ }),
+ })
+ );
+ });
+ });
+
+ it('uses core API path for Pod kind (no api group)', async () => {
+ mockApiRequest.mockResolvedValue({});
+ render(
+
+ );
+ fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
+ fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
+ fireEvent.click(screen.getByRole('button', { name: /apply/i }));
+
+ await waitFor(() => {
+ expect(mockApiRequest).toHaveBeenCalledWith(
+ '/api/v1/namespaces/default/pods/my-deploy',
+ expect.anything()
+ );
+ });
+ });
+
+ it('uses batch API group for Job kind', async () => {
+ mockApiRequest.mockResolvedValue({});
+ render(
+
+ );
+ fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
+ fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
+ fireEvent.click(screen.getByRole('button', { name: /apply/i }));
+
+ await waitFor(() => {
+ expect(mockApiRequest).toHaveBeenCalledWith(
+ '/apis/batch/v1/namespaces/default/jobs/my-deploy',
+ expect.anything()
+ );
+ });
+ });
+
+ it('uses batch API group for CronJob kind', async () => {
+ mockApiRequest.mockResolvedValue({});
+ render(
+
+ );
+ fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
+ fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
+ fireEvent.click(screen.getByRole('button', { name: /apply/i }));
+
+ await waitFor(() => {
+ expect(mockApiRequest).toHaveBeenCalledWith(
+ '/apis/batch/v1/namespaces/default/cronjobs/my-deploy',
+ expect.anything()
+ );
+ });
+ });
+
+ it('uses apps API group for StatefulSet kind', async () => {
+ mockApiRequest.mockResolvedValue({});
+ render(
+
+ );
+ fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
+ fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
+ fireEvent.click(screen.getByRole('button', { name: /apply/i }));
+
+ await waitFor(() => {
+ expect(mockApiRequest).toHaveBeenCalledWith(
+ '/apis/apps/v1/namespaces/default/statefulsets/my-deploy',
+ expect.anything()
+ );
+ });
+ });
+ });
+
+ describe('feedback states', () => {
+ it('shows success feedback and closes dialog after successful apply', async () => {
+ mockApiRequest.mockResolvedValue({});
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
+ fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
+ fireEvent.click(screen.getByRole('button', { name: /apply/i }));
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('dialog')).not.toBeInTheDocument();
+ const label = screen.getByTestId('status-label');
+ expect(label).toHaveAttribute('data-status', 'success');
+ expect(label).toHaveTextContent('Exemptions applied successfully');
+ });
+ });
+
+ it('shows error feedback and keeps dialog closed after failed apply', async () => {
+ mockApiRequest.mockRejectedValue(new Error('403 Forbidden'));
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
+ fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
+ fireEvent.click(screen.getByRole('button', { name: /apply/i }));
+
+ await waitFor(() => {
+ const label = screen.getByTestId('status-label');
+ expect(label).toHaveAttribute('data-status', 'error');
+ expect(label).toHaveTextContent(/failed to apply exemptions/i);
+ });
+ });
+
+ it('shows "Applying..." text on Apply button while in-flight', async () => {
+ let resolveRequest!: () => void;
+ mockApiRequest.mockReturnValue(
+ new Promise(res => {
+ resolveRequest = res;
+ })
+ );
+
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /add exemption/i }));
+ fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i }));
+ fireEvent.click(screen.getByRole('button', { name: /apply/i }));
+
+ expect(screen.getByRole('button', { name: /applying/i })).toBeInTheDocument();
+ resolveRequest();
+ await waitFor(() => {
+ expect(screen.queryByTestId('dialog')).not.toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/vitest.config.mts b/vitest.config.mts
index dd403a9..a316ace 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -9,5 +9,16 @@ export default defineConfig({
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
exclude: ['e2e/**', 'node_modules/**'],
+ coverage: {
+ provider: 'v8',
+ include: ['src/**/*.{ts,tsx}'],
+ exclude: ['src/**/*.test.{ts,tsx}', 'src/test-utils.tsx', 'src/index.tsx'],
+ thresholds: {
+ lines: 80,
+ functions: 80,
+ branches: 80,
+ statements: 80,
+ },
+ },
},
});