claude-crap 0.4.6 → 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.
- package/CHANGELOG.md +51 -0
- package/dist/dashboard/file-detail.d.ts +6 -0
- package/dist/dashboard/file-detail.d.ts.map +1 -1
- package/dist/dashboard/file-detail.js +1 -0
- package/dist/dashboard/file-detail.js.map +1 -1
- package/dist/dashboard/server.d.ts +6 -0
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +99 -31
- package/dist/dashboard/server.js.map +1 -1
- package/dist/shared/exclusions.d.ts.map +1 -1
- package/dist/shared/exclusions.js +10 -0
- package/dist/shared/exclusions.js.map +1 -1
- package/dist/tests/helpers/dashboard-test-helpers.d.ts +94 -0
- package/dist/tests/helpers/dashboard-test-helpers.d.ts.map +1 -0
- package/dist/tests/helpers/dashboard-test-helpers.js +159 -0
- package/dist/tests/helpers/dashboard-test-helpers.js.map +1 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/bundle/dashboard/public/index.html +216 -7
- package/plugin/bundle/mcp-server.mjs +88 -13
- package/plugin/bundle/mcp-server.mjs.map +2 -2
- package/plugin/hooks/lib/quality-gate.mjs +3 -0
- package/src/dashboard/file-detail.ts +7 -0
- package/src/dashboard/public/index.html +216 -7
- package/src/dashboard/server.ts +119 -42
- package/src/shared/exclusions.ts +11 -0
- package/src/tests/dashboard-adoption.test.ts +553 -0
- package/src/tests/exclusions.test.ts +34 -0
- package/src/tests/file-detail-api.test.ts +38 -0
- package/src/tests/helpers/dashboard-test-helpers.ts +203 -0
- package/src/tests/workspace-walker.test.ts +30 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test suite for the adopt-don't-steal dashboard lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* GOLDEN RULE STRUCTURE
|
|
5
|
+
* ─────────────────────
|
|
6
|
+
* Tests 1–6 are *characterization* tests: they describe behavior that
|
|
7
|
+
* already exists in the current port-steal implementation and MUST
|
|
8
|
+
* continue to pass after the adopt-don't-steal rewrite lands.
|
|
9
|
+
*
|
|
10
|
+
* Tests 7–12 are *edge-case* tests: they describe the new adoption
|
|
11
|
+
* contract that the forthcoming implementation must satisfy. They are
|
|
12
|
+
* expected to FAIL against the current port-steal code and PASS once
|
|
13
|
+
* the rewrite is in place.
|
|
14
|
+
*
|
|
15
|
+
* Every test is hermetic:
|
|
16
|
+
* - Its own mkdtemp workspace under os.tmpdir().
|
|
17
|
+
* - A random port in the 6000–6999 range (avoids the real 5117).
|
|
18
|
+
* - All handles are closed and the workspace removed in after().
|
|
19
|
+
*
|
|
20
|
+
* @module tests/dashboard-adoption.test
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, it, before, after } from "node:test";
|
|
24
|
+
import assert from "node:assert/strict";
|
|
25
|
+
|
|
26
|
+
import { startDashboard, type DashboardHandle } from "../dashboard/server.js";
|
|
27
|
+
import {
|
|
28
|
+
makeWorkspace,
|
|
29
|
+
makeOptions,
|
|
30
|
+
writePidFile,
|
|
31
|
+
readPidFile,
|
|
32
|
+
fileExists,
|
|
33
|
+
findFreePort,
|
|
34
|
+
type WorkspaceContext,
|
|
35
|
+
} from "./helpers/dashboard-test-helpers.js";
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Helper: fetch /api/health and return the HTTP status code.
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
async function healthStatus(url: string): Promise<number> {
|
|
41
|
+
const res = await fetch(`${url}/api/health`);
|
|
42
|
+
return res.status;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Helper: assert that a handle has an `adopted` property.
|
|
47
|
+
// The property does not exist yet on the current DashboardHandle interface;
|
|
48
|
+
// we access it through an augmented type so TypeScript does not complain,
|
|
49
|
+
// but the assertion will produce a clear failure message until it is wired.
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
interface AugmentedHandle extends DashboardHandle {
|
|
52
|
+
readonly adopted?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ===========================================================================
|
|
56
|
+
// Characterization tests (must pass on current AND forthcoming code)
|
|
57
|
+
// ===========================================================================
|
|
58
|
+
|
|
59
|
+
describe("dashboard-adoption — characterization", () => {
|
|
60
|
+
// -------------------------------------------------------------------------
|
|
61
|
+
// Test 1: startDashboard resolves with a handle whose url matches the port
|
|
62
|
+
// -------------------------------------------------------------------------
|
|
63
|
+
describe("1. handle url matches configured port", () => {
|
|
64
|
+
let ws: WorkspaceContext;
|
|
65
|
+
let port: number;
|
|
66
|
+
let handle: DashboardHandle | null = null;
|
|
67
|
+
|
|
68
|
+
before(async () => {
|
|
69
|
+
ws = await makeWorkspace();
|
|
70
|
+
port = await findFreePort();
|
|
71
|
+
handle = await startDashboard(makeOptions(ws.pluginRoot, port));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
after(async () => {
|
|
75
|
+
await handle?.close();
|
|
76
|
+
await ws.cleanup();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("url is http://127.0.0.1:<port>", () => {
|
|
80
|
+
assert.ok(handle, "startDashboard must resolve a handle");
|
|
81
|
+
assert.equal(handle.url, `http://127.0.0.1:${port}`);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// -------------------------------------------------------------------------
|
|
86
|
+
// Test 2: GET /api/health returns 200 {status:"ok"}
|
|
87
|
+
// -------------------------------------------------------------------------
|
|
88
|
+
describe("2. GET /api/health returns 200 with status ok", () => {
|
|
89
|
+
let ws: WorkspaceContext;
|
|
90
|
+
let handle: DashboardHandle | null = null;
|
|
91
|
+
|
|
92
|
+
before(async () => {
|
|
93
|
+
ws = await makeWorkspace();
|
|
94
|
+
const port = await findFreePort();
|
|
95
|
+
handle = await startDashboard(makeOptions(ws.pluginRoot, port));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
after(async () => {
|
|
99
|
+
await handle?.close();
|
|
100
|
+
await ws.cleanup();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("responds 200", async () => {
|
|
104
|
+
assert.ok(handle);
|
|
105
|
+
const res = await fetch(`${handle.url}/api/health`);
|
|
106
|
+
assert.equal(res.status, 200);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("body contains status ok", async () => {
|
|
110
|
+
assert.ok(handle);
|
|
111
|
+
const res = await fetch(`${handle.url}/api/health`);
|
|
112
|
+
const body = await res.json() as Record<string, unknown>;
|
|
113
|
+
assert.equal(body["status"], "ok");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// -------------------------------------------------------------------------
|
|
118
|
+
// Test 3: GET /api/score returns 200 JSON with an `overall` key
|
|
119
|
+
// -------------------------------------------------------------------------
|
|
120
|
+
describe("3. GET /api/score returns overall key", () => {
|
|
121
|
+
let ws: WorkspaceContext;
|
|
122
|
+
let handle: DashboardHandle | null = null;
|
|
123
|
+
|
|
124
|
+
before(async () => {
|
|
125
|
+
ws = await makeWorkspace();
|
|
126
|
+
const port = await findFreePort();
|
|
127
|
+
handle = await startDashboard(makeOptions(ws.pluginRoot, port));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
after(async () => {
|
|
131
|
+
await handle?.close();
|
|
132
|
+
await ws.cleanup();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("responds 200", async () => {
|
|
136
|
+
assert.ok(handle);
|
|
137
|
+
const res = await fetch(`${handle.url}/api/score`);
|
|
138
|
+
assert.equal(res.status, 200);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("JSON body has overall key", async () => {
|
|
142
|
+
assert.ok(handle);
|
|
143
|
+
const res = await fetch(`${handle.url}/api/score`);
|
|
144
|
+
const body = await res.json() as Record<string, unknown>;
|
|
145
|
+
assert.ok(
|
|
146
|
+
Object.prototype.hasOwnProperty.call(body, "overall"),
|
|
147
|
+
"body must contain an 'overall' key",
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// -------------------------------------------------------------------------
|
|
153
|
+
// Test 4: handle.close() releases the port so a second startDashboard
|
|
154
|
+
// on the same port succeeds.
|
|
155
|
+
// -------------------------------------------------------------------------
|
|
156
|
+
describe("4. close() releases the port", () => {
|
|
157
|
+
let ws: WorkspaceContext;
|
|
158
|
+
let port: number;
|
|
159
|
+
let handle2: DashboardHandle | null = null;
|
|
160
|
+
|
|
161
|
+
before(async () => {
|
|
162
|
+
ws = await makeWorkspace();
|
|
163
|
+
port = await findFreePort();
|
|
164
|
+
// First server: start and immediately close.
|
|
165
|
+
const first = await startDashboard(makeOptions(ws.pluginRoot, port));
|
|
166
|
+
await first.close();
|
|
167
|
+
// Second server: must bind successfully after the first closed.
|
|
168
|
+
handle2 = await startDashboard(makeOptions(ws.pluginRoot, port));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
after(async () => {
|
|
172
|
+
await handle2?.close();
|
|
173
|
+
await ws.cleanup();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("second server binds and responds 200", async () => {
|
|
177
|
+
assert.ok(handle2, "second startDashboard must succeed after first close");
|
|
178
|
+
const status = await healthStatus(handle2.url);
|
|
179
|
+
assert.equal(status, 200);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// -------------------------------------------------------------------------
|
|
184
|
+
// Test 5: stale pidfile (dead PID) is removed and a fresh pidfile is written
|
|
185
|
+
// -------------------------------------------------------------------------
|
|
186
|
+
describe("5. stale pidfile with dead PID is replaced", () => {
|
|
187
|
+
let ws: WorkspaceContext;
|
|
188
|
+
let handle: DashboardHandle | null = null;
|
|
189
|
+
|
|
190
|
+
before(async () => {
|
|
191
|
+
ws = await makeWorkspace();
|
|
192
|
+
const port = await findFreePort();
|
|
193
|
+
// PID 999999 is virtually never alive on a developer machine.
|
|
194
|
+
await writePidFile(ws.pidFilePath, 999999, port);
|
|
195
|
+
handle = await startDashboard(makeOptions(ws.pluginRoot, port));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
after(async () => {
|
|
199
|
+
await handle?.close();
|
|
200
|
+
await ws.cleanup();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("server starts and responds 200", async () => {
|
|
204
|
+
assert.ok(handle);
|
|
205
|
+
assert.equal(await healthStatus(handle.url), 200);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("pidfile now contains process.pid", async () => {
|
|
209
|
+
const pf = await readPidFile(ws.pidFilePath);
|
|
210
|
+
assert.ok(pf, "pidfile must exist after boot");
|
|
211
|
+
assert.equal(pf.pid, process.pid);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// -------------------------------------------------------------------------
|
|
216
|
+
// Test 6: corrupt (non-JSON) pidfile is deleted, server boots normally
|
|
217
|
+
// -------------------------------------------------------------------------
|
|
218
|
+
describe("6. corrupt pidfile is deleted, server boots normally", () => {
|
|
219
|
+
let ws: WorkspaceContext;
|
|
220
|
+
let handle: DashboardHandle | null = null;
|
|
221
|
+
|
|
222
|
+
before(async () => {
|
|
223
|
+
ws = await makeWorkspace();
|
|
224
|
+
const port = await findFreePort();
|
|
225
|
+
// Write deliberate garbage.
|
|
226
|
+
const { writeFile } = await import("node:fs/promises");
|
|
227
|
+
await writeFile(ws.pidFilePath, "not-json{{{", "utf8");
|
|
228
|
+
handle = await startDashboard(makeOptions(ws.pluginRoot, port));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
after(async () => {
|
|
232
|
+
await handle?.close();
|
|
233
|
+
await ws.cleanup();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("server starts successfully", async () => {
|
|
237
|
+
assert.ok(handle);
|
|
238
|
+
assert.equal(await healthStatus(handle.url), 200);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("pidfile contains valid JSON with process.pid after boot", async () => {
|
|
242
|
+
const pf = await readPidFile(ws.pidFilePath);
|
|
243
|
+
assert.ok(pf, "a fresh pidfile must be written after boot");
|
|
244
|
+
assert.equal(pf.pid, process.pid);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ===========================================================================
|
|
250
|
+
// Edge-case tests (will fail on current port-steal code; must pass on adopt)
|
|
251
|
+
// ===========================================================================
|
|
252
|
+
|
|
253
|
+
describe("dashboard-adoption — edge cases (adopt-don't-steal)", () => {
|
|
254
|
+
// -------------------------------------------------------------------------
|
|
255
|
+
// Test 7: Adoption happy path
|
|
256
|
+
//
|
|
257
|
+
// A is the first owner.
|
|
258
|
+
// B calls startDashboard with the same config.
|
|
259
|
+
// B must adopt A (not kill it), return adopted:true, and share A's url.
|
|
260
|
+
// -------------------------------------------------------------------------
|
|
261
|
+
describe("7. adoption happy path — B adopts A without killing it", () => {
|
|
262
|
+
let ws: WorkspaceContext;
|
|
263
|
+
let handleA: AugmentedHandle | null = null;
|
|
264
|
+
let handleB: AugmentedHandle | null = null;
|
|
265
|
+
let port: number;
|
|
266
|
+
|
|
267
|
+
before(async () => {
|
|
268
|
+
ws = await makeWorkspace();
|
|
269
|
+
port = await findFreePort();
|
|
270
|
+
handleA = await startDashboard(makeOptions(ws.pluginRoot, port)) as AugmentedHandle;
|
|
271
|
+
handleB = await startDashboard(makeOptions(ws.pluginRoot, port)) as AugmentedHandle;
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
after(async () => {
|
|
275
|
+
// Close B first (no-op), then A (real shutdown).
|
|
276
|
+
await handleB?.close();
|
|
277
|
+
await handleA?.close();
|
|
278
|
+
await ws.cleanup();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("B reports adopted === true", () => {
|
|
282
|
+
assert.ok(handleB, "handleB must exist");
|
|
283
|
+
assert.equal(
|
|
284
|
+
(handleB as AugmentedHandle).adopted,
|
|
285
|
+
true,
|
|
286
|
+
"handle B must be an adopted handle",
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("B url equals A url", () => {
|
|
291
|
+
assert.ok(handleA && handleB);
|
|
292
|
+
assert.equal(handleB.url, handleA.url);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("A is still alive after B is created — was NOT SIGTERMd", async () => {
|
|
296
|
+
assert.ok(handleA);
|
|
297
|
+
assert.equal(
|
|
298
|
+
await healthStatus(handleA.url),
|
|
299
|
+
200,
|
|
300
|
+
"A must still respond after B adopted it",
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("pidfile PID still equals A owner's pid (process.pid)", async () => {
|
|
305
|
+
const pf = await readPidFile(ws.pidFilePath);
|
|
306
|
+
assert.ok(pf, "pidfile must exist");
|
|
307
|
+
assert.equal(pf.pid, process.pid);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// -------------------------------------------------------------------------
|
|
312
|
+
// Test 8: Adopted close is a no-op
|
|
313
|
+
//
|
|
314
|
+
// After handleB.close(), A's server is still alive and the pidfile
|
|
315
|
+
// still exists.
|
|
316
|
+
// -------------------------------------------------------------------------
|
|
317
|
+
describe("8. adopted close() is a no-op", () => {
|
|
318
|
+
let ws: WorkspaceContext;
|
|
319
|
+
let handleA: DashboardHandle | null = null;
|
|
320
|
+
let handleB: AugmentedHandle | null = null;
|
|
321
|
+
|
|
322
|
+
before(async () => {
|
|
323
|
+
ws = await makeWorkspace();
|
|
324
|
+
const port = await findFreePort();
|
|
325
|
+
handleA = await startDashboard(makeOptions(ws.pluginRoot, port));
|
|
326
|
+
handleB = await startDashboard(makeOptions(ws.pluginRoot, port)) as AugmentedHandle;
|
|
327
|
+
// Close the adopted handle.
|
|
328
|
+
await handleB.close();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
after(async () => {
|
|
332
|
+
await handleA?.close();
|
|
333
|
+
await ws.cleanup();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("A is still reachable after B.close()", async () => {
|
|
337
|
+
assert.ok(handleA);
|
|
338
|
+
assert.equal(
|
|
339
|
+
await healthStatus(handleA.url),
|
|
340
|
+
200,
|
|
341
|
+
"A must still serve requests after adopter B closed",
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("pidfile still exists on disk after B.close()", async () => {
|
|
346
|
+
assert.ok(
|
|
347
|
+
await fileExists(ws.pidFilePath),
|
|
348
|
+
"pidfile must survive an adopted close()",
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// -------------------------------------------------------------------------
|
|
354
|
+
// Test 9: Zombie detection
|
|
355
|
+
//
|
|
356
|
+
// Pidfile says pid=process.pid (alive) but the port is free (no server).
|
|
357
|
+
// startDashboard must detect the zombie, remove the pidfile, bind itself,
|
|
358
|
+
// and return adopted:false.
|
|
359
|
+
// -------------------------------------------------------------------------
|
|
360
|
+
describe("9. zombie detection — alive PID but dead port", () => {
|
|
361
|
+
let ws: WorkspaceContext;
|
|
362
|
+
let handle: AugmentedHandle | null = null;
|
|
363
|
+
|
|
364
|
+
before(async () => {
|
|
365
|
+
ws = await makeWorkspace();
|
|
366
|
+
// Allocate two ports: one for the fake zombie entry, one for the
|
|
367
|
+
// real server that must boot.
|
|
368
|
+
const zombiePort = await findFreePort();
|
|
369
|
+
const realPort = await findFreePort();
|
|
370
|
+
// Write a pidfile that points to ourselves (alive) on zombiePort
|
|
371
|
+
// (which nobody is actually listening on).
|
|
372
|
+
await writePidFile(ws.pidFilePath, process.pid, zombiePort);
|
|
373
|
+
// Now start on realPort — the implementation should use the
|
|
374
|
+
// configured dashboardPort, see the stale pidfile, health-probe
|
|
375
|
+
// zombiePort, find it dead, remove the file, and bind realPort.
|
|
376
|
+
// We call startDashboard with a config whose dashboardPort is
|
|
377
|
+
// realPort so the final server lands there.
|
|
378
|
+
const opts = makeOptions(ws.pluginRoot, realPort);
|
|
379
|
+
handle = await startDashboard(opts) as AugmentedHandle;
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
after(async () => {
|
|
383
|
+
await handle?.close();
|
|
384
|
+
await ws.cleanup();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("server starts and responds 200", async () => {
|
|
388
|
+
assert.ok(handle);
|
|
389
|
+
assert.equal(await healthStatus(handle.url), 200);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("handle adopted === false — we own the port", () => {
|
|
393
|
+
assert.ok(handle);
|
|
394
|
+
assert.equal(
|
|
395
|
+
(handle as AugmentedHandle).adopted,
|
|
396
|
+
false,
|
|
397
|
+
"zombie detected: must become owner, not adopter",
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("new pidfile contains process.pid", async () => {
|
|
402
|
+
const pf = await readPidFile(ws.pidFilePath);
|
|
403
|
+
assert.ok(pf, "a fresh pidfile must exist after zombie cleanup");
|
|
404
|
+
assert.equal(pf.pid, process.pid);
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// -------------------------------------------------------------------------
|
|
409
|
+
// Test 10: Concurrent boot race
|
|
410
|
+
//
|
|
411
|
+
// Two startDashboard calls on the same port race simultaneously.
|
|
412
|
+
// Exactly one must be the owner (adopted:false) and one must adopt
|
|
413
|
+
// (adopted:true). Both must share the same url. Fastify's EADDRINUSE
|
|
414
|
+
// from the losing bind must be caught and converted to an adoption.
|
|
415
|
+
// -------------------------------------------------------------------------
|
|
416
|
+
describe("10. concurrent boot race — EADDRINUSE triggers adoption", () => {
|
|
417
|
+
let ws: WorkspaceContext;
|
|
418
|
+
let handles: AugmentedHandle[] = [];
|
|
419
|
+
let port: number;
|
|
420
|
+
|
|
421
|
+
before(async () => {
|
|
422
|
+
ws = await makeWorkspace();
|
|
423
|
+
port = await findFreePort();
|
|
424
|
+
const opts = makeOptions(ws.pluginRoot, port);
|
|
425
|
+
// Fire both concurrently — do not await sequentially.
|
|
426
|
+
const results = await Promise.all([
|
|
427
|
+
startDashboard(opts) as Promise<AugmentedHandle>,
|
|
428
|
+
startDashboard(opts) as Promise<AugmentedHandle>,
|
|
429
|
+
]);
|
|
430
|
+
handles = results;
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
after(async () => {
|
|
434
|
+
for (const h of handles) await h.close();
|
|
435
|
+
await ws.cleanup();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("exactly one handle is adopted === false (owner)", () => {
|
|
439
|
+
const owners = handles.filter((h) => h.adopted === false);
|
|
440
|
+
assert.equal(owners.length, 1, "exactly one owner expected");
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("exactly one handle is adopted === true (adopter)", () => {
|
|
444
|
+
const adopters = handles.filter((h) => h.adopted === true);
|
|
445
|
+
assert.equal(adopters.length, 1, "exactly one adopter expected");
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("both handles share the same url", () => {
|
|
449
|
+
assert.equal(handles[0]!.url, handles[1]!.url);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("both handles report url http://127.0.0.1:<port>", () => {
|
|
453
|
+
for (const h of handles) {
|
|
454
|
+
assert.equal(h.url, `http://127.0.0.1:${port}`);
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("the owner handle pid matches the pidfile", async () => {
|
|
459
|
+
const pf = await readPidFile(ws.pidFilePath);
|
|
460
|
+
assert.ok(pf, "pidfile must exist after race");
|
|
461
|
+
// Both handles are in the same process, so pidfile.pid === process.pid.
|
|
462
|
+
assert.equal(pf.pid, process.pid);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// -------------------------------------------------------------------------
|
|
467
|
+
// Test 11: Owner close() removes the pidfile
|
|
468
|
+
//
|
|
469
|
+
// After the owner closes (with no adopters alive), the pidfile must
|
|
470
|
+
// be absent from disk.
|
|
471
|
+
// -------------------------------------------------------------------------
|
|
472
|
+
describe("11. owner close() removes pidfile", () => {
|
|
473
|
+
let ws: WorkspaceContext;
|
|
474
|
+
let pidFilePathSnapshot: string;
|
|
475
|
+
|
|
476
|
+
before(async () => {
|
|
477
|
+
ws = await makeWorkspace();
|
|
478
|
+
const port = await findFreePort();
|
|
479
|
+
const handle = await startDashboard(makeOptions(ws.pluginRoot, port));
|
|
480
|
+
pidFilePathSnapshot = ws.pidFilePath;
|
|
481
|
+
// Close the owner with no adopters around.
|
|
482
|
+
await handle.close();
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
after(async () => {
|
|
486
|
+
await ws.cleanup();
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("pidfile does not exist after owner close()", async () => {
|
|
490
|
+
assert.equal(
|
|
491
|
+
await fileExists(pidFilePathSnapshot),
|
|
492
|
+
false,
|
|
493
|
+
"pidfile must be removed when the owner closes",
|
|
494
|
+
);
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// -------------------------------------------------------------------------
|
|
499
|
+
// Test 12: Adoption when a real dashboard is already alive on port P
|
|
500
|
+
//
|
|
501
|
+
// Start A on port P. Call startDashboard again on the same port.
|
|
502
|
+
// Verify adopted:true, no SIGTERM sent to A, A still alive.
|
|
503
|
+
// -------------------------------------------------------------------------
|
|
504
|
+
describe("12. second call on same port returns adopted handle, no steal", () => {
|
|
505
|
+
let ws: WorkspaceContext;
|
|
506
|
+
let handleA: DashboardHandle | null = null;
|
|
507
|
+
let handleB: AugmentedHandle | null = null;
|
|
508
|
+
let portP: number;
|
|
509
|
+
|
|
510
|
+
before(async () => {
|
|
511
|
+
ws = await makeWorkspace();
|
|
512
|
+
portP = await findFreePort();
|
|
513
|
+
handleA = await startDashboard(makeOptions(ws.pluginRoot, portP));
|
|
514
|
+
// Second caller, same workspace, same port.
|
|
515
|
+
handleB = await startDashboard(makeOptions(ws.pluginRoot, portP)) as AugmentedHandle;
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
after(async () => {
|
|
519
|
+
await handleB?.close();
|
|
520
|
+
await handleA?.close();
|
|
521
|
+
await ws.cleanup();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("B is adopted (adopted === true)", () => {
|
|
525
|
+
assert.ok(handleB);
|
|
526
|
+
assert.equal(
|
|
527
|
+
(handleB as AugmentedHandle).adopted,
|
|
528
|
+
true,
|
|
529
|
+
"second caller must adopt, not steal",
|
|
530
|
+
);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("B url equals http://127.0.0.1:<portP>", () => {
|
|
534
|
+
assert.ok(handleB);
|
|
535
|
+
assert.equal(handleB.url, `http://127.0.0.1:${portP}`);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("A is still alive — B did NOT send SIGTERM", async () => {
|
|
539
|
+
assert.ok(handleA);
|
|
540
|
+
assert.equal(
|
|
541
|
+
await healthStatus(handleA.url),
|
|
542
|
+
200,
|
|
543
|
+
"A must still be reachable, adoption must never SIGTERM the owner",
|
|
544
|
+
);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it("pidfile PID still points to the original owner (process.pid)", async () => {
|
|
548
|
+
const pf = await readPidFile(ws.pidFilePath);
|
|
549
|
+
assert.ok(pf);
|
|
550
|
+
assert.equal(pf.pid, process.pid);
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
});
|
|
@@ -50,6 +50,25 @@ describe("DEFAULT_SKIP_DIRS", () => {
|
|
|
50
50
|
assert.ok(DEFAULT_SKIP_DIRS.has(dir), `missing skip dir: ${dir}`);
|
|
51
51
|
}
|
|
52
52
|
});
|
|
53
|
+
|
|
54
|
+
it("includes coverage report directories emitted by test tooling", () => {
|
|
55
|
+
// ReportGenerator (.NET), Istanbul (JS) and dotCover emit generated
|
|
56
|
+
// HTML/JS bundles into these folders. Previously only the bare
|
|
57
|
+
// `coverage` name was skipped, which leaked files like
|
|
58
|
+
// `GanttLite.Server/coverage-report/main.js` into complexity scans
|
|
59
|
+
// and flooded the dashboard with false-positive high-CC findings.
|
|
60
|
+
for (const dir of [
|
|
61
|
+
"coverage-report", // ReportGenerator default
|
|
62
|
+
"CoverageReport", // ReportGenerator PascalCase
|
|
63
|
+
"coveragereport", // ReportGenerator lowercase fallback
|
|
64
|
+
"TestResults", // dotnet test default
|
|
65
|
+
"cobertura", // Cobertura XML output
|
|
66
|
+
"lcov-report", // Istanbul HTML reporter
|
|
67
|
+
"htmlcov", // coverage.py HTML output
|
|
68
|
+
]) {
|
|
69
|
+
assert.ok(DEFAULT_SKIP_DIRS.has(dir), `missing coverage skip dir: ${dir}`);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
53
72
|
});
|
|
54
73
|
|
|
55
74
|
describe("DEFAULT_SKIP_PATTERNS", () => {
|
|
@@ -93,6 +112,21 @@ describe("createExclusionFilter", () => {
|
|
|
93
112
|
assert.equal(filter.shouldSkipDir("generated"), true);
|
|
94
113
|
assert.equal(filter.shouldSkipDir("src"), false);
|
|
95
114
|
});
|
|
115
|
+
|
|
116
|
+
it("skips coverage report directories from every major test runner", () => {
|
|
117
|
+
// Regression: the ReportGenerator bundle
|
|
118
|
+
// (`coverage-report/main.js`, `class.js`, etc.) used to surface
|
|
119
|
+
// in the dashboard's complexity hotspots as minified code with
|
|
120
|
+
// CC ≥ 80. These directories must never be walked.
|
|
121
|
+
const filter = createExclusionFilter();
|
|
122
|
+
assert.equal(filter.shouldSkipDir("coverage-report"), true);
|
|
123
|
+
assert.equal(filter.shouldSkipDir("CoverageReport"), true);
|
|
124
|
+
assert.equal(filter.shouldSkipDir("coveragereport"), true);
|
|
125
|
+
assert.equal(filter.shouldSkipDir("TestResults"), true);
|
|
126
|
+
assert.equal(filter.shouldSkipDir("cobertura"), true);
|
|
127
|
+
assert.equal(filter.shouldSkipDir("lcov-report"), true);
|
|
128
|
+
assert.equal(filter.shouldSkipDir("htmlcov"), true);
|
|
129
|
+
});
|
|
96
130
|
});
|
|
97
131
|
|
|
98
132
|
describe("shouldSkipFile", () => {
|
|
@@ -190,6 +190,44 @@ describe("buildFileDetail", () => {
|
|
|
190
190
|
}
|
|
191
191
|
});
|
|
192
192
|
|
|
193
|
+
it("returns the resolved absolute path so the UI can build editor deep-links", async () => {
|
|
194
|
+
// The file-detail view exposes "Open in editor" buttons that emit
|
|
195
|
+
// `vscode://file/{absolutePath}:{line}` (and JetBrains equivalents).
|
|
196
|
+
// Constructing that URL client-side requires the absolute path —
|
|
197
|
+
// the workspace-relative `filePath` alone is not enough. This
|
|
198
|
+
// characterization test pins that contract so the UI can rely on it.
|
|
199
|
+
const dir = makeTmpDir();
|
|
200
|
+
try {
|
|
201
|
+
writeFileSync(join(dir, "hello.ts"), SAMPLE_TS);
|
|
202
|
+
const store = new SarifStore({
|
|
203
|
+
workspaceRoot: dir,
|
|
204
|
+
outputDir: join(dir, ".claude-crap/reports"),
|
|
205
|
+
});
|
|
206
|
+
const result = await buildFileDetail({
|
|
207
|
+
relativePath: "hello.ts",
|
|
208
|
+
workspaceRoot: dir,
|
|
209
|
+
astEngine: engine,
|
|
210
|
+
sarifStore: store,
|
|
211
|
+
cyclomaticMax: 15,
|
|
212
|
+
});
|
|
213
|
+
assert.equal(
|
|
214
|
+
result.absolutePath,
|
|
215
|
+
join(dir, "hello.ts"),
|
|
216
|
+
`expected absolutePath to join workspaceRoot with filePath, got ${result.absolutePath}`,
|
|
217
|
+
);
|
|
218
|
+
assert.ok(
|
|
219
|
+
result.absolutePath.endsWith("hello.ts"),
|
|
220
|
+
"absolutePath should end with the relative path",
|
|
221
|
+
);
|
|
222
|
+
assert.ok(
|
|
223
|
+
result.absolutePath.startsWith(dir),
|
|
224
|
+
"absolutePath must live under the workspace root (path-traversal defense)",
|
|
225
|
+
);
|
|
226
|
+
} finally {
|
|
227
|
+
rmSync(dir, { recursive: true, force: true });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
193
231
|
it("returns empty functions for unsupported languages", async () => {
|
|
194
232
|
const dir = makeTmpDir();
|
|
195
233
|
try {
|