@rxtx4816/cockpit-plugin-base-react 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/eslint.config.base.js +85 -0
  2. package/package.json +116 -0
  3. package/scripts/test-vm.sh +608 -0
  4. package/src/bootstrap.tsx +7 -0
  5. package/src/cockpit.d.ts +63 -0
  6. package/src/components/ConfirmDialog.test.tsx +61 -0
  7. package/src/components/ConfirmDialog.tsx +65 -0
  8. package/src/components/ErrorBoundary.test.tsx +50 -0
  9. package/src/components/ErrorBoundary.tsx +30 -0
  10. package/src/components/HelpPopover.tsx +30 -0
  11. package/src/components/LogViewer.tsx +108 -0
  12. package/src/components/StatusBadge.test.tsx +32 -0
  13. package/src/components/StatusBadge.tsx +18 -0
  14. package/src/components/ToastProvider.css +20 -0
  15. package/src/components/ToastProvider.test.tsx +61 -0
  16. package/src/components/ToastProvider.tsx +76 -0
  17. package/src/components/index.ts +8 -0
  18. package/src/css.d.ts +4 -0
  19. package/src/dark-theme.ts +30 -0
  20. package/src/hooks/useAsyncAction.test.ts +59 -0
  21. package/src/hooks/useAsyncAction.ts +31 -0
  22. package/src/hooks/useAsyncStream.test.ts +122 -0
  23. package/src/hooks/useAsyncStream.ts +94 -0
  24. package/src/hooks/useAutoRefresh.test.ts +106 -0
  25. package/src/hooks/useAutoRefresh.ts +23 -0
  26. package/src/hooks/useConfirmAction.test.ts +68 -0
  27. package/src/hooks/useConfirmAction.ts +35 -0
  28. package/src/hooks/usePollingFetch.ts +41 -0
  29. package/src/i18n.ts +51 -0
  30. package/src/index.ts +11 -0
  31. package/src/systemd/ServiceControl.tsx +172 -0
  32. package/src/systemd/api.test.ts +83 -0
  33. package/src/systemd/api.ts +36 -0
  34. package/src/systemd/index.ts +5 -0
  35. package/src/systemd/types.ts +1 -0
  36. package/src/systemd/useServiceStatus.test.ts +96 -0
  37. package/src/systemd/useServiceStatus.ts +29 -0
  38. package/src/testing/helpers.ts +30 -0
  39. package/src/testing/setup.ts +57 -0
  40. package/tsconfig.base.json +17 -0
  41. package/vitest.config.base.ts +47 -0
@@ -0,0 +1,172 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import { Button, Flex, FlexItem, Spinner } from "@patternfly/react-core";
3
+ import { ConfirmDialog } from "../components/ConfirmDialog";
4
+ import { useToast } from "../components/ToastProvider";
5
+ import { startService, stopService, restartService, reloadService } from "./api";
6
+ import type { ServiceStatus } from "./types";
7
+
8
+ type PendingAction = "start" | "stop" | "restart" | "reload";
9
+
10
+ export interface ServiceControlLabels {
11
+ start?: string;
12
+ stop?: string;
13
+ restart?: string;
14
+ reload?: string;
15
+ cancel?: string;
16
+ confirmAction?: string;
17
+ confirmStartTitle?: string;
18
+ confirmStartBody?: string;
19
+ confirmStopTitle?: string;
20
+ confirmStopBody?: string;
21
+ confirmRestartTitle?: string;
22
+ confirmRestartBody?: string;
23
+ confirmReloadTitle?: string;
24
+ confirmReloadBody?: string;
25
+ }
26
+
27
+ const DEFAULTS: Required<ServiceControlLabels> = {
28
+ start: "Start",
29
+ stop: "Stop",
30
+ restart: "Restart",
31
+ reload: "Reload",
32
+ cancel: "Cancel",
33
+ confirmAction: "Confirm",
34
+ confirmStartTitle: "Start service?",
35
+ confirmStartBody: "The service will be started.",
36
+ confirmStopTitle: "Stop service?",
37
+ confirmStopBody: "The service will be stopped.",
38
+ confirmRestartTitle: "Restart service?",
39
+ confirmRestartBody: "The service will be restarted.",
40
+ confirmReloadTitle: "Reload service?",
41
+ confirmReloadBody: "The service configuration will be reloaded.",
42
+ };
43
+
44
+ interface Props {
45
+ unit: string;
46
+ status: ServiceStatus;
47
+ loading?: boolean;
48
+ onRefresh?: () => void;
49
+ statusBadge?: ReactNode;
50
+ labels?: ServiceControlLabels;
51
+ }
52
+
53
+ export function ServiceControl({ unit, status, loading = false, onRefresh, statusBadge, labels }: Props) {
54
+ const toast = useToast();
55
+ const l = { ...DEFAULTS, ...labels };
56
+ const [busy, setBusy] = useState(false);
57
+ const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
58
+ const [actionError, setActionError] = useState<string | null>(null);
59
+
60
+ const ACTION_FN: Record<PendingAction, () => Promise<void>> = {
61
+ start: () => startService(unit),
62
+ stop: () => stopService(unit),
63
+ restart: () => restartService(unit),
64
+ reload: () => reloadService(unit),
65
+ };
66
+
67
+ async function runAction() {
68
+ if (!pendingAction) return;
69
+ setBusy(true);
70
+ setActionError(null);
71
+ try {
72
+ await ACTION_FN[pendingAction]();
73
+ setPendingAction(null);
74
+ onRefresh?.();
75
+ } catch (e) {
76
+ const msg = e instanceof Error ? e.message : String(e);
77
+ setActionError(msg);
78
+ toast.error(`${pendingAction} failed`, msg);
79
+ } finally {
80
+ setBusy(false);
81
+ }
82
+ }
83
+
84
+ function openAction(action: PendingAction) {
85
+ setActionError(null);
86
+ setPendingAction(action);
87
+ }
88
+
89
+ const isRunning = status === "active";
90
+ const notInstalled = status === "not-installed";
91
+
92
+ const confirmTitle: Record<PendingAction, string> = {
93
+ start: l.confirmStartTitle,
94
+ stop: l.confirmStopTitle,
95
+ restart: l.confirmRestartTitle,
96
+ reload: l.confirmReloadTitle,
97
+ };
98
+
99
+ const confirmBody: Record<PendingAction, string> = {
100
+ start: l.confirmStartBody,
101
+ stop: l.confirmStopBody,
102
+ restart: l.confirmRestartBody,
103
+ reload: l.confirmReloadBody,
104
+ };
105
+
106
+ const isDanger = pendingAction === "stop" || pendingAction === "restart";
107
+
108
+ return (
109
+ <>
110
+ <Flex alignItems={{ default: "alignItemsCenter" }} gap={{ default: "gapSm" }}>
111
+ {(loading || statusBadge) && (
112
+ <FlexItem>
113
+ {loading ? <Spinner size="sm" /> : statusBadge}
114
+ </FlexItem>
115
+ )}
116
+ <FlexItem>
117
+ <Button
118
+ variant="primary"
119
+ size="sm"
120
+ isDisabled={busy || notInstalled || isRunning}
121
+ onClick={() => openAction("start")}
122
+ >
123
+ {l.start}
124
+ </Button>
125
+ </FlexItem>
126
+ <FlexItem>
127
+ <Button
128
+ variant="secondary"
129
+ size="sm"
130
+ isDisabled={busy || notInstalled || !isRunning}
131
+ onClick={() => openAction("stop")}
132
+ >
133
+ {l.stop}
134
+ </Button>
135
+ </FlexItem>
136
+ <FlexItem>
137
+ <Button
138
+ variant="secondary"
139
+ size="sm"
140
+ isDisabled={busy || notInstalled || !isRunning}
141
+ onClick={() => openAction("restart")}
142
+ >
143
+ {l.restart}
144
+ </Button>
145
+ </FlexItem>
146
+ <FlexItem>
147
+ <Button
148
+ variant="plain"
149
+ size="sm"
150
+ isDisabled={busy || notInstalled || !isRunning}
151
+ onClick={() => openAction("reload")}
152
+ >
153
+ {l.reload}
154
+ </Button>
155
+ </FlexItem>
156
+ </Flex>
157
+
158
+ <ConfirmDialog
159
+ isOpen={pendingAction !== null}
160
+ title={pendingAction ? confirmTitle[pendingAction] : ""}
161
+ body={pendingAction ? confirmBody[pendingAction] : undefined}
162
+ confirmLabel={l.confirmAction}
163
+ cancelLabel={l.cancel}
164
+ variant={isDanger ? "danger" : "primary"}
165
+ loading={busy}
166
+ error={actionError}
167
+ onConfirm={() => void runAction()}
168
+ onClose={() => { if (!busy) { setPendingAction(null); setActionError(null); } }}
169
+ />
170
+ </>
171
+ );
172
+ }
@@ -0,0 +1,83 @@
1
+ import { vi, describe, it, expect, beforeEach } from "vitest";
2
+ import { mockProcess } from "../testing/helpers";
3
+ import {
4
+ getServiceStatus,
5
+ startService,
6
+ stopService,
7
+ restartService,
8
+ reloadService,
9
+ } from "./api";
10
+
11
+ const mockSpawn = vi.fn();
12
+ vi.stubGlobal("cockpit", { spawn: mockSpawn });
13
+
14
+ beforeEach(() => { mockSpawn.mockReset(); });
15
+
16
+ describe("getServiceStatus", () => {
17
+ it("returns 'not-installed' when which fails", async () => {
18
+ mockSpawn.mockRejectedValueOnce(new Error("not found"));
19
+ expect(await getServiceStatus("caddy")).toBe("not-installed");
20
+ });
21
+
22
+ it("returns 'active' when systemctl reports active", async () => {
23
+ mockSpawn.mockResolvedValueOnce(""); // which succeeds
24
+ mockSpawn.mockResolvedValueOnce("active\n");
25
+ expect(await getServiceStatus("caddy")).toBe("active");
26
+ });
27
+
28
+ it("returns 'inactive' when systemctl reports inactive", async () => {
29
+ mockSpawn.mockResolvedValueOnce("");
30
+ mockSpawn.mockResolvedValueOnce("inactive\n");
31
+ expect(await getServiceStatus("caddy")).toBe("inactive");
32
+ });
33
+
34
+ it("returns 'failed' when systemctl reports failed", async () => {
35
+ mockSpawn.mockResolvedValueOnce("");
36
+ mockSpawn.mockResolvedValueOnce("failed\n");
37
+ expect(await getServiceStatus("caddy")).toBe("failed");
38
+ });
39
+
40
+ it("returns 'unknown' for unexpected systemctl output", async () => {
41
+ mockSpawn.mockResolvedValueOnce("");
42
+ mockSpawn.mockResolvedValueOnce("activating\n");
43
+ expect(await getServiceStatus("caddy")).toBe("unknown");
44
+ });
45
+
46
+ it("returns 'unknown' when systemctl itself throws", async () => {
47
+ mockSpawn.mockResolvedValueOnce("");
48
+ mockSpawn.mockRejectedValueOnce(new Error("timeout"));
49
+ expect(await getServiceStatus("nginx")).toBe("unknown");
50
+ });
51
+
52
+ it("passes the unit name to which", async () => {
53
+ mockSpawn.mockRejectedValueOnce(new Error("not found"));
54
+ await getServiceStatus("nginx");
55
+ expect(mockSpawn).toHaveBeenCalledWith(["which", "nginx"]);
56
+ });
57
+ });
58
+
59
+ describe("service control functions", () => {
60
+ beforeEach(() => {
61
+ mockSpawn.mockResolvedValue(mockProcess(""));
62
+ });
63
+
64
+ it("startService calls systemctl start with the unit", async () => {
65
+ await startService("caddy");
66
+ expect(mockSpawn).toHaveBeenCalledWith(["systemctl", "start", "caddy"], { superuser: "try" });
67
+ });
68
+
69
+ it("stopService calls systemctl stop with the unit", async () => {
70
+ await stopService("nginx");
71
+ expect(mockSpawn).toHaveBeenCalledWith(["systemctl", "stop", "nginx"], { superuser: "try" });
72
+ });
73
+
74
+ it("restartService calls systemctl restart with the unit", async () => {
75
+ await restartService("caddy");
76
+ expect(mockSpawn).toHaveBeenCalledWith(["systemctl", "restart", "caddy"], { superuser: "try" });
77
+ });
78
+
79
+ it("reloadService calls systemctl reload with the unit", async () => {
80
+ await reloadService("caddy");
81
+ expect(mockSpawn).toHaveBeenCalledWith(["systemctl", "reload", "caddy"], { superuser: "try" });
82
+ });
83
+ });
@@ -0,0 +1,36 @@
1
+ import type { ServiceStatus } from "./types";
2
+
3
+ export async function getServiceStatus(unit: string): Promise<ServiceStatus> {
4
+ try {
5
+ await cockpit.spawn(["which", unit]);
6
+ } catch {
7
+ return "not-installed";
8
+ }
9
+
10
+ try {
11
+ const status = await cockpit.spawn(["systemctl", "is-active", unit]);
12
+ const trimmed = status.trim();
13
+ if (trimmed === "active") return "active";
14
+ if (trimmed === "inactive") return "inactive";
15
+ if (trimmed === "failed") return "failed";
16
+ return "unknown";
17
+ } catch {
18
+ return "unknown";
19
+ }
20
+ }
21
+
22
+ export async function startService(unit: string): Promise<void> {
23
+ await cockpit.spawn(["systemctl", "start", unit], { superuser: "try" });
24
+ }
25
+
26
+ export async function stopService(unit: string): Promise<void> {
27
+ await cockpit.spawn(["systemctl", "stop", unit], { superuser: "try" });
28
+ }
29
+
30
+ export async function restartService(unit: string): Promise<void> {
31
+ await cockpit.spawn(["systemctl", "restart", unit], { superuser: "try" });
32
+ }
33
+
34
+ export async function reloadService(unit: string): Promise<void> {
35
+ await cockpit.spawn(["systemctl", "reload", unit], { superuser: "try" });
36
+ }
@@ -0,0 +1,5 @@
1
+ export type { ServiceStatus } from "./types";
2
+ export { getServiceStatus, startService, stopService, restartService, reloadService } from "./api";
3
+ export { useServiceStatus } from "./useServiceStatus";
4
+ export { ServiceControl } from "./ServiceControl";
5
+ export type { ServiceControlLabels } from "./ServiceControl";
@@ -0,0 +1 @@
1
+ export type ServiceStatus = "active" | "inactive" | "failed" | "unknown" | "not-installed";
@@ -0,0 +1,96 @@
1
+ import { renderHook, act, waitFor } from "@testing-library/react";
2
+ import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
3
+ import { useServiceStatus } from "./useServiceStatus";
4
+
5
+ const mockSpawn = vi.fn();
6
+ vi.stubGlobal("cockpit", { spawn: mockSpawn });
7
+
8
+ function stubStatus(status: string) {
9
+ mockSpawn
10
+ .mockResolvedValueOnce("") // which <unit> succeeds
11
+ .mockResolvedValueOnce(`${status}\n`); // systemctl is-active <unit>
12
+ }
13
+
14
+ const flushAsync = async () => {
15
+ await Promise.resolve();
16
+ await Promise.resolve();
17
+ await Promise.resolve();
18
+ };
19
+
20
+ beforeEach(() => { mockSpawn.mockReset(); });
21
+
22
+ describe("useServiceStatus", () => {
23
+ it("starts with loading=true and status='unknown'", () => {
24
+ stubStatus("active");
25
+ const { result } = renderHook(() => useServiceStatus("caddy"));
26
+ expect(result.current.loading).toBe(true);
27
+ expect(result.current.status).toBe("unknown");
28
+ });
29
+
30
+ it("fetches status on mount and clears loading", async () => {
31
+ stubStatus("active");
32
+ const { result } = renderHook(() => useServiceStatus("caddy"));
33
+
34
+ await waitFor(() => expect(result.current.loading).toBe(false));
35
+ expect(result.current.status).toBe("active");
36
+ expect(result.current.error).toBeNull();
37
+ });
38
+
39
+ it("resolves 'inactive' status", async () => {
40
+ stubStatus("inactive");
41
+ const { result } = renderHook(() => useServiceStatus("caddy"));
42
+ await waitFor(() => expect(result.current.status).toBe("inactive"));
43
+ });
44
+
45
+ it("resolves 'not-installed' when which fails", async () => {
46
+ mockSpawn.mockRejectedValueOnce(new Error("not found"));
47
+ const { result } = renderHook(() => useServiceStatus("caddy"));
48
+ await waitFor(() => expect(result.current.status).toBe("not-installed"));
49
+ });
50
+
51
+ it("passes the unit name through spawn calls", async () => {
52
+ stubStatus("active");
53
+ renderHook(() => useServiceStatus("nginx"));
54
+ await waitFor(() => expect(mockSpawn).toHaveBeenCalledWith(["which", "nginx"]));
55
+ });
56
+
57
+ it("polls at the given interval", async () => {
58
+ vi.useFakeTimers();
59
+ try {
60
+ stubStatus("inactive");
61
+ renderHook(() => useServiceStatus("caddy", 2000));
62
+
63
+ await act(flushAsync);
64
+ expect(mockSpawn).toHaveBeenCalledTimes(2); // which + systemctl (1 poll)
65
+
66
+ stubStatus("active");
67
+ await act(async () => {
68
+ vi.advanceTimersByTime(2000);
69
+ await flushAsync();
70
+ });
71
+ expect(mockSpawn).toHaveBeenCalledTimes(4); // 2 more spawn calls for 2nd poll
72
+ } finally {
73
+ vi.useRealTimers();
74
+ }
75
+ });
76
+
77
+ it("updates status across poll cycles", async () => {
78
+ vi.useFakeTimers();
79
+ try {
80
+ stubStatus("inactive");
81
+ const { result } = renderHook(() => useServiceStatus("caddy", 1000));
82
+
83
+ await act(flushAsync);
84
+ expect(result.current.status).toBe("inactive");
85
+
86
+ stubStatus("active");
87
+ await act(async () => {
88
+ vi.advanceTimersByTime(1000);
89
+ await flushAsync();
90
+ });
91
+ expect(result.current.status).toBe("active");
92
+ } finally {
93
+ vi.useRealTimers();
94
+ }
95
+ });
96
+ });
@@ -0,0 +1,29 @@
1
+ import { useState, useCallback, useEffect } from "react";
2
+ import { useAutoRefresh } from "../hooks/useAutoRefresh";
3
+ import { getServiceStatus } from "./api";
4
+ import type { ServiceStatus } from "./types";
5
+
6
+ const DEFAULT_INTERVAL = 5000;
7
+
8
+ export function useServiceStatus(unit: string, intervalMs = DEFAULT_INTERVAL) {
9
+ const [status, setStatus] = useState<ServiceStatus>("unknown");
10
+ const [loading, setLoading] = useState(true);
11
+ const [error, setError] = useState<string | null>(null);
12
+
13
+ const refresh = useCallback(async () => {
14
+ try {
15
+ const s = await getServiceStatus(unit);
16
+ setStatus(s);
17
+ setError(null);
18
+ } catch (e) {
19
+ setError(e instanceof Error ? e.message : String(e));
20
+ } finally {
21
+ setLoading(false);
22
+ }
23
+ }, [unit]);
24
+
25
+ useEffect(() => { void refresh(); }, [refresh]);
26
+ useAutoRefresh(refresh, intervalMs);
27
+
28
+ return { status, loading, error, refresh };
29
+ }
@@ -0,0 +1,30 @@
1
+ import { vi } from "vitest";
2
+
3
+ export function mockProcess(data: string | string[], error?: string): CockpitProcess {
4
+ const chunks = Array.isArray(data) ? data : [data];
5
+ let streamCb: ((data: string) => void) | null = null;
6
+ const p = new Promise<string>((resolve, reject) => {
7
+ queueMicrotask(() => {
8
+ for (const chunk of chunks) {
9
+ if (streamCb && chunk) streamCb(chunk);
10
+ }
11
+ if (error) reject(new Error(error));
12
+ else resolve(chunks.join(""));
13
+ });
14
+ });
15
+ return Object.assign(p, {
16
+ stream: (cb: (data: string) => void) => { streamCb = cb; return p as CockpitProcess; },
17
+ close: vi.fn(),
18
+ input: vi.fn(),
19
+ wait: () => p,
20
+ }) as CockpitProcess;
21
+ }
22
+
23
+ export function mockHttpClient(responses: Record<string, string> = {}): CockpitHttpClient {
24
+ return {
25
+ get: vi.fn((path: string) => Promise.resolve(responses[path] ?? "{}")),
26
+ post: vi.fn(() => Promise.resolve("")),
27
+ request: vi.fn(() => Promise.resolve({ status: 200, headers: {}, data: "" })),
28
+ close: vi.fn(),
29
+ };
30
+ }
@@ -0,0 +1,57 @@
1
+ // Base Vitest setup for Cockpit plugins.
2
+ // Installs DOM mocks that must be in place before i18n initializes.
3
+ // Each plugin's own setup.ts (listed second in vitest setupFiles) adds:
4
+ // - await import("../i18n")
5
+ // - vi.stubGlobal("cockpit", { spawn: ..., http: ... })
6
+
7
+ import "@testing-library/jest-dom/vitest";
8
+ import { vi } from "vitest";
9
+
10
+ class ResizeObserverMock {
11
+ observe() {}
12
+ unobserve() {}
13
+ disconnect() {}
14
+ }
15
+ vi.stubGlobal("ResizeObserver", ResizeObserverMock);
16
+
17
+ // localStorage mock must be installed before i18n's cockpitDetector runs.
18
+ const localStorageMock = (() => {
19
+ let store: Record<string, string> = {};
20
+ return {
21
+ getItem: (key: string) => store[key] ?? null,
22
+ setItem: (key: string, value: string) => { store[key] = String(value); },
23
+ removeItem: (key: string) => { delete store[key]; },
24
+ clear: () => { store = {}; },
25
+ get length() { return Object.keys(store).length; },
26
+ key: (index: number) => Object.keys(store)[index] ?? null,
27
+ };
28
+ })();
29
+ vi.stubGlobal("localStorage", localStorageMock);
30
+ Object.defineProperty(window, "localStorage", { value: localStorageMock });
31
+
32
+ vi.stubGlobal("requestAnimationFrame", (callback: (timestamp: number) => void) => {
33
+ callback(0);
34
+ return 0;
35
+ });
36
+ vi.stubGlobal("cancelAnimationFrame", () => {});
37
+
38
+ const consoleError = console.error.bind(console);
39
+ vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
40
+ const message = args.map(String).join(" ");
41
+ if (message.includes("not wrapped in act(...)") || message.includes("Not implemented: navigation to another Document")) {
42
+ return;
43
+ }
44
+ consoleError(...args);
45
+ });
46
+
47
+ const consoleWarn = console.warn.bind(console);
48
+ vi.spyOn(console, "warn").mockImplementation((...args: unknown[]) => {
49
+ const message = args.map(String).join(" ");
50
+ if (message.includes("not wrapped in act(...)") || message.includes("Not implemented: navigation to another Document")) {
51
+ return;
52
+ }
53
+ consoleWarn(...args);
54
+ });
55
+
56
+ // jsdom doesn't implement HTMLCanvasElement.getContext; stub it to silence the warning
57
+ window.HTMLCanvasElement.prototype.getContext = () => null;
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "esModuleInterop": true,
12
+ "allowSyntheticDefaultImports": true,
13
+ "forceConsistentCasingInFileNames": true,
14
+ "resolveJsonModule": true,
15
+ "isolatedModules": true
16
+ }
17
+ }
@@ -0,0 +1,47 @@
1
+ import { defineConfig } from "vitest/config";
2
+ import type { TestUserConfig } from "vitest/config";
3
+
4
+ type TestConfig = TestUserConfig;
5
+
6
+ export function createVitestConfig(overrides: TestConfig = {}) {
7
+ const { coverage: coverageOverrides, setupFiles: extraSetupFiles, ...rest } = overrides;
8
+
9
+ return defineConfig({
10
+ server: {
11
+ // Allow Vite's dev server to serve files from symlinked file: packages
12
+ // that live outside the consuming project's root directory.
13
+ fs: { allow: [".."] },
14
+ },
15
+ resolve: {
16
+ // Deduplicate packages that must be singletons when cockpit-plugin-base-react
17
+ // is installed as a file: link (symlink) — without this, the linked
18
+ // package resolves these from its own node_modules and React / i18next
19
+ // end up with two separate instances, breaking hooks and translations.
20
+ dedupe: ["react", "react-dom", "i18next", "react-i18next", "@patternfly/react-core", "@patternfly/react-icons"],
21
+ },
22
+ test: {
23
+ globals: true,
24
+ environment: "jsdom",
25
+ setupFiles: [
26
+ "@rxtx4816/cockpit-plugin-base-react/testing",
27
+ ...(Array.isArray(extraSetupFiles) ? extraSetupFiles : extraSetupFiles ? [extraSetupFiles] : []),
28
+ ],
29
+ include: ["src/**/*.test.{ts,tsx}"],
30
+ pool: "threads",
31
+ maxWorkers: 4,
32
+ coverage: {
33
+ provider: "v8",
34
+ reporter: ["text", "html"],
35
+ include: ["src/**/*.{ts,tsx}"],
36
+ thresholds: {
37
+ lines: 80,
38
+ branches: 80,
39
+ functions: 80,
40
+ statements: 80,
41
+ },
42
+ ...coverageOverrides,
43
+ },
44
+ ...rest,
45
+ },
46
+ });
47
+ }