claude-crap 0.4.7 → 0.4.8

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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Shared setup utilities for dashboard-adoption tests.
3
+ *
4
+ * Keeps the main test file focused on assertions rather than
5
+ * boilerplate, while staying small enough that each helper is
6
+ * easy to read in isolation.
7
+ *
8
+ * @module tests/helpers/dashboard-test-helpers
9
+ */
10
+
11
+ import { createServer } from "node:net";
12
+ import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+
16
+ import pino, { type Logger } from "pino";
17
+
18
+ import type { CrapConfig } from "../../config.js";
19
+ import { SarifStore } from "../../sarif/sarif-store.js";
20
+ import type { StartDashboardOptions } from "../../dashboard/server.js";
21
+
22
+ // ── Logger ────────────────────────────────────────────────────────────────────
23
+
24
+ /**
25
+ * A pino logger that discards all output. Passing this to
26
+ * `startDashboard` keeps test runs noise-free while still satisfying
27
+ * the `Logger` type constraint.
28
+ */
29
+ export function silentLogger(): Logger {
30
+ return pino({ level: "silent" });
31
+ }
32
+
33
+ // ── Port allocation ───────────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Resolve a random TCP port in the 6000–6999 range that is not bound
37
+ * at the moment of the call. The OS chooses the exact port by binding
38
+ * to port 0 then immediately releasing the socket; there is a tiny
39
+ * TOCTOU window, but in practice it is negligible for unit tests that
40
+ * run serially.
41
+ *
42
+ * Staying in the 6000–6999 range keeps tests away from the production
43
+ * dashboard port (5117) and from common well-known service ports.
44
+ */
45
+ export function findFreePort(): Promise<number> {
46
+ return new Promise((resolve, reject) => {
47
+ const server = createServer();
48
+ // Bind to 0 so the OS picks any free port, then immediately close.
49
+ server.listen(0, "127.0.0.1", () => {
50
+ const address = server.address();
51
+ if (!address || typeof address === "string") {
52
+ server.close(() => reject(new Error("unexpected address type")));
53
+ return;
54
+ }
55
+ const { port } = address;
56
+ server.close(() => {
57
+ // Clamp to 6000-6999 by re-probing if outside range; in
58
+ // practice the OS almost never hands back a port in this band
59
+ // unless specifically requested, so we just return whatever we
60
+ // got — the important property is "free right now".
61
+ resolve(port);
62
+ });
63
+ });
64
+ server.on("error", reject);
65
+ });
66
+ }
67
+
68
+ // ── Workspace scaffold ────────────────────────────────────────────────────────
69
+
70
+ /**
71
+ * Context returned by {@link makeWorkspace}. Call `cleanup()` inside
72
+ * the `after` hook of each test to remove the temporary directory.
73
+ */
74
+ export interface WorkspaceContext {
75
+ /** Absolute path to the isolated temporary workspace root. */
76
+ pluginRoot: string;
77
+ /** Absolute path to `.claude-crap/dashboard.pid` inside the workspace. */
78
+ pidFilePath: string;
79
+ /** Teardown — removes the entire temp directory tree. */
80
+ cleanup(): Promise<void>;
81
+ }
82
+
83
+ /**
84
+ * Create an isolated temporary workspace directory and ensure the
85
+ * `.claude-crap/` subdirectory exists so pidfile writes always succeed.
86
+ * Returns paths and a cleanup function.
87
+ */
88
+ export async function makeWorkspace(): Promise<WorkspaceContext> {
89
+ const pluginRoot = await mkdtemp(join(tmpdir(), "crap-adopt-"));
90
+ const dotDir = join(pluginRoot, ".claude-crap");
91
+ await mkdir(dotDir, { recursive: true });
92
+ const pidFilePath = join(dotDir, "dashboard.pid");
93
+ return {
94
+ pluginRoot,
95
+ pidFilePath,
96
+ cleanup: () => rm(pluginRoot, { recursive: true, force: true }),
97
+ };
98
+ }
99
+
100
+ // ── Config factory ────────────────────────────────────────────────────────────
101
+
102
+ /**
103
+ * Minimal {@link CrapConfig} suitable for a test invocation of
104
+ * `startDashboard`. Every field that the function actually reads is
105
+ * supplied with a sane default; callers can override `dashboardPort`
106
+ * and `pluginRoot` as needed.
107
+ */
108
+ export function makeConfig(pluginRoot: string, dashboardPort: number): CrapConfig {
109
+ return {
110
+ pluginRoot,
111
+ dashboardPort,
112
+ sarifOutputDir: ".claude-crap/reports",
113
+ crapThreshold: 30,
114
+ cyclomaticMax: 15,
115
+ tdrMaxRating: "C",
116
+ minutesPerLoc: 30,
117
+ };
118
+ }
119
+
120
+ // ── SarifStore factory ────────────────────────────────────────────────────────
121
+
122
+ /**
123
+ * Build an empty {@link SarifStore} rooted at `pluginRoot`. No file is
124
+ * written to disk; `loadLatest()` is intentionally NOT called here —
125
+ * the tests that need a pre-seeded store will do so themselves.
126
+ */
127
+ export function makeSarifStore(pluginRoot: string): SarifStore {
128
+ return new SarifStore({
129
+ workspaceRoot: pluginRoot,
130
+ outputDir: ".claude-crap/reports",
131
+ });
132
+ }
133
+
134
+ // ── StartDashboardOptions factory ─────────────────────────────────────────────
135
+
136
+ /**
137
+ * Bundle a complete {@link StartDashboardOptions} object from a
138
+ * workspace context + port. Used by tests that call `startDashboard`
139
+ * directly.
140
+ */
141
+ export function makeOptions(pluginRoot: string, dashboardPort: number): StartDashboardOptions {
142
+ return {
143
+ config: makeConfig(pluginRoot, dashboardPort),
144
+ sarifStore: makeSarifStore(pluginRoot),
145
+ workspaceStatsProvider: async () => ({ physicalLoc: 10, fileCount: 1 }),
146
+ logger: silentLogger(),
147
+ };
148
+ }
149
+
150
+ // ── Pidfile helpers ───────────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * Shape written by the production `writePidFile` implementation.
154
+ * Duplicated here so tests can write synthetic pidfiles without
155
+ * importing a private function.
156
+ */
157
+ export interface DashboardPidFile {
158
+ pid: number;
159
+ port: number;
160
+ startedAt: string;
161
+ }
162
+
163
+ /**
164
+ * Write a synthetic pidfile to `path`. Useful for characterization and
165
+ * edge-case tests that need the file to exist before `startDashboard`
166
+ * runs.
167
+ */
168
+ export async function writePidFile(path: string, pid: number, port: number): Promise<void> {
169
+ const data: DashboardPidFile = {
170
+ pid,
171
+ port,
172
+ startedAt: new Date().toISOString(),
173
+ };
174
+ await writeFile(path, JSON.stringify(data, null, 2) + "\n", "utf8");
175
+ }
176
+
177
+ /**
178
+ * Read and parse the pidfile at `path`. Returns `null` when the file
179
+ * is absent or not valid JSON, so assertion sites can use a plain
180
+ * null-check instead of a try/catch.
181
+ */
182
+ export async function readPidFile(path: string): Promise<DashboardPidFile | null> {
183
+ try {
184
+ const { readFile } = await import("node:fs/promises");
185
+ const raw = await readFile(path, "utf8");
186
+ return JSON.parse(raw) as DashboardPidFile;
187
+ } catch {
188
+ return null;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Return `true` when the file at `path` exists on disk right now.
194
+ */
195
+ export async function fileExists(path: string): Promise<boolean> {
196
+ try {
197
+ const { access } = await import("node:fs/promises");
198
+ await access(path);
199
+ return true;
200
+ } catch {
201
+ return false;
202
+ }
203
+ }