Skip to content

Commit c33d271

Browse files
dmartinochoaclaude
andcommitted
test: extract 3 modules from extension.ts + 38 new tests (194 → 232)
Three thin extractions out of the activate() closure so the behaviour they encapsulate can be exercised in unit tests rather than waiting for the @vscode/test-electron integration run: - lspStart.ts: bounded-time client.start(). Internally restructured from Promise.race([start, timeout]) to a single deferred with a settled flag — Promise.race plus vitest's fake timers triggered "rejection handled asynchronously" warnings because Promise.race attaches its handler one microtask after the timer fires the rejection. The deferred avoids the two-promise dance. - middleware.ts: pure transformDiagnostics(uri, diagnostics, config) composing the disabledProviders blanket-drop and the severityThreshold filter. extension.ts now plumbs the config in via deps; the composition (which the user sees) is testable in isolation. - scanOnSave.ts: createScanOnSaveHandler(deps) returns a save handler that closes over a busy flag. Cheap-gates-first ordering (isEnabled → isPipelineFile → busy) is pinned; each handler instance has its own busy flag so two windows don't share a lock. CodeLens provider class gets four new tests pinning the lens range (line 0 col 0), the click target (pipelineCheck.findings.focus), the "reads live counts each call" contract (so a future cache-once-at- construction refactor trips), and the per-document scope (lens reflects only this document's diagnostics, not the workspace total). Tests breakdown: - lspStart: 12 tests (timeout race, stop-on-timeout, settled-flag, copy) - middleware: 13 tests (disabled stage, severity stage, composition) - scanOnSave: 9 tests (gates, busy guard, per-instance lock) - codeLens: +4 tests on the existing file Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f9b5a8f commit c33d271

8 files changed

Lines changed: 953 additions & 84 deletions

File tree

src/codeLens.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,4 +176,106 @@ describe("FindingsCodeLensProvider — pipelineCheck.codeLens.enabled toggle", (
176176
const p = new FindingsCodeLensProvider(ctx);
177177
expect(p.provideCodeLenses(document)).toEqual([]);
178178
});
179+
180+
it("anchors the lens at line 0 column 0 (top of file)", () => {
181+
// The lens sits *above* line 1 so it doesn't push the first
182+
// line of YAML out of view. A future refactor that moves it to
183+
// the first finding's line would change UX without anyone
184+
// noticing; lock it down.
185+
const p = new FindingsCodeLensProvider(ctx);
186+
const lenses = p.provideCodeLenses(document) as unknown[];
187+
expect(lenses).toHaveLength(1);
188+
const lens = lenses[0] as { range: { start: { line: number; character: number } } };
189+
expect(lens.range.start.line).toBe(0);
190+
expect(lens.range.start.character).toBe(0);
191+
});
192+
193+
it("targets the pipelineCheck.findings.focus command so click reveals the panel", () => {
194+
// Other plausible click targets (vscode.open, the file at the
195+
// finding location, the rule docs URL) all do different things.
196+
// The lens is meant as a *drill-in* — surface the panel grouped
197+
// by severity so the user can scan the per-file count in context.
198+
const p = new FindingsCodeLensProvider(ctx);
199+
const lenses = p.provideCodeLenses(document) as unknown[];
200+
const lens = lenses[0] as {
201+
command?: { command: string; title: string };
202+
};
203+
expect(lens.command?.command).toBe("pipelineCheck.findings.focus");
204+
});
205+
206+
it("renders the title from the live count, not the constructor snapshot", () => {
207+
// Without rebuilding the provider, the lens text must reflect
208+
// whatever the diagnostic store says right now. (The provider
209+
// calls summariseCounts inside provideCodeLenses each time, but
210+
// the test exists to catch a future "cache once at construction"
211+
// refactor.)
212+
const p = new FindingsCodeLensProvider(ctx);
213+
const lenses1 = p.provideCodeLenses(document) as unknown[];
214+
const title1 = (lenses1[0] as { command?: { title: string } }).command
215+
?.title;
216+
expect(title1).toContain("1 critical");
217+
218+
// Now swap the stub diagnostics underneath the provider and
219+
// request fresh lenses.
220+
(globalThis as { __stubDiagnostics?: unknown }).__stubDiagnostics = [
221+
[
222+
{ toString: () => "file:///a.yml" },
223+
[
224+
{
225+
source: "pipeline-check",
226+
message: "",
227+
range: {
228+
start: { line: 0, character: 0 },
229+
end: { line: 0, character: 0 },
230+
},
231+
severity: 0,
232+
data: { severity: "HIGH" },
233+
},
234+
{
235+
source: "pipeline-check",
236+
message: "",
237+
range: {
238+
start: { line: 0, character: 0 },
239+
end: { line: 0, character: 0 },
240+
},
241+
severity: 0,
242+
data: { severity: "HIGH" },
243+
},
244+
],
245+
],
246+
];
247+
const lenses2 = p.provideCodeLenses(document) as unknown[];
248+
const title2 = (lenses2[0] as { command?: { title: string } }).command
249+
?.title;
250+
expect(title2).toContain("2 high");
251+
expect(title2).not.toContain("critical");
252+
});
253+
254+
it("only considers the document's OWN diagnostics, not the workspace total", () => {
255+
// The lens is per-file; the Findings panel is the workspace
256+
// aggregate. Confusing the two would show "10 critical" on a
257+
// file with zero findings just because the workspace has them.
258+
(globalThis as { __stubDiagnostics?: unknown }).__stubDiagnostics = [
259+
[
260+
{ toString: () => "file:///OTHER.yml" },
261+
[
262+
{
263+
source: "pipeline-check",
264+
message: "",
265+
range: {
266+
start: { line: 0, character: 0 },
267+
end: { line: 0, character: 0 },
268+
},
269+
severity: 0,
270+
data: { severity: "CRITICAL" },
271+
},
272+
],
273+
],
274+
];
275+
const p = new FindingsCodeLensProvider(ctx);
276+
// The stub's getDiagnostics(uri) returns diagnostics keyed by
277+
// `uri.toString()`; our document is "file:///a.yml", which is
278+
// not in the stubbed store. Should yield no lenses.
279+
expect(p.provideCodeLenses(document)).toEqual([]);
280+
});
179281
});

src/extension.ts

Lines changed: 27 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,15 @@ import {
2626
installInTerminal,
2727
} from "./install";
2828
import * as clientLog from "./log";
29+
import { startWithTimeout } from "./lspStart";
2930
import { setLspReady } from "./lspState";
31+
import { transformDiagnostics } from "./middleware";
3032
import { goToFinding } from "./navigate";
3133
import {
3234
providerForPath,
33-
type ProviderId,
3435
TRIGGER_DOCUMENT_SELECTOR,
3536
} from "./providers";
36-
import { filterByThreshold } from "./severityFilter";
37+
import { createScanOnSaveHandler } from "./scanOnSave";
3738
import { registerStatusBar } from "./statusBar";
3839
import { scanWorkspace } from "./workspaceScan";
3940
import { showWhatsNewIfUpgraded } from "./whatsNew";
@@ -155,30 +156,21 @@ function buildClient(): LanguageClient {
155156
},
156157
outputChannelName: OUTPUT_CHANNEL,
157158
middleware: {
158-
// Drop diagnostics below the user-configured severity threshold
159-
// before they reach VS Code's Problems panel. The config is
160-
// re-read on each publish so a settings change takes effect on
161-
// the next scan without needing a server restart. Diagnostics
162-
// whose `data.severity` is missing (older server, or a
163-
// not-from-pipeline-check publish that somehow flowed through)
164-
// pass through unconditionally so the filter never hides
165-
// legitimate signal when the metadata is absent.
159+
// Two-stage filter (composition lives in middleware.ts): drop
160+
// every diagnostic for a URI whose provider the user has
161+
// silenced via `disabledProviders`, otherwise drop those below
162+
// the configured `severityThreshold`. Re-reads the config on
163+
// each publish so a settings change takes effect on the next
164+
// scan without a server restart.
166165
handleDiagnostics: (uri, diagnostics, next) => {
167166
const config = vscode.workspace.getConfiguration("pipelineCheck");
168-
// Per-provider toggle: if this URI maps to a provider the
169-
// user has disabled, drop every diagnostic for it. We still
170-
// accept the publish (so a future "unset disable" causes a
171-
// fresh publish to reach us), we just blank the list.
172-
const disabled = new Set(
173-
config.get<string[]>("disabledProviders", []) as ProviderId[],
167+
next(
168+
uri,
169+
transformDiagnostics(uri, diagnostics, {
170+
disabledProviders: config.get<string[]>("disabledProviders", []),
171+
severityThreshold: config.get<string>("severityThreshold", "low"),
172+
}),
174173
);
175-
const provider = providerForPath(uri.fsPath);
176-
if (provider && disabled.has(provider)) {
177-
next(uri, []);
178-
return;
179-
}
180-
const threshold = config.get<string>("severityThreshold", "low");
181-
next(uri, filterByThreshold(diagnostics, threshold));
182174
},
183175
},
184176
};
@@ -304,41 +296,6 @@ async function stopClient(): Promise<void> {
304296
}
305297
}
306298

307-
/**
308-
* Race the LSP startup handshake against a hard ceiling. On timeout
309-
* we call `client.stop()` to kill the stranded subprocess (best
310-
* effort — the server may already be hung past saving) and throw a
311-
* recognisable error the caller's catch surfaces in the failure
312-
* toast.
313-
*/
314-
async function startWithTimeout(
315-
c: LanguageClient,
316-
timeoutMs: number,
317-
): Promise<void> {
318-
let timer: NodeJS.Timeout | undefined;
319-
const timeout = new Promise<never>((_, reject) => {
320-
timer = setTimeout(() => {
321-
// Fire-and-forget the cleanup stop(); we don't await it because
322-
// a hung interpreter probably won't respond, and the timeout
323-
// toast wants to be on screen now, not in 30 seconds when stop
324-
// gives up.
325-
void c.stop().catch(() => undefined);
326-
reject(
327-
new Error(
328-
`Language server did not finish startup within ${Math.round(timeoutMs / 1000)}s. Check the server log; common causes are an empty pipelineCheck.serverArgs, an interpreter that drops into the REPL, or a corrupted pipeline_check install.`,
329-
),
330-
);
331-
}, timeoutMs);
332-
});
333-
try {
334-
await Promise.race([c.start(), timeout]);
335-
} finally {
336-
if (timer) {
337-
clearTimeout(timer);
338-
}
339-
}
340-
}
341-
342299
export async function activate(
343300
context: vscode.ExtensionContext,
344301
): Promise<void> {
@@ -518,32 +475,18 @@ export async function activate(
518475
// `didSave`, so this is purely about picking up cross-file effects in
519476
// *other* CI files (a Jenkinsfile that includes the just-edited
520477
// library, a GHA workflow that calls the just-edited composite
521-
// action). A simple in-flight guard prevents save-storms (autosave,
522-
// Save All) from queueing redundant scans.
523-
let scanOnSaveBusy = false;
524-
context.subscriptions.push(
525-
vscode.workspace.onDidSaveTextDocument(async (doc) => {
526-
const config = vscode.workspace.getConfiguration("pipelineCheck");
527-
if (!config.get<boolean>("scanOnSave", false)) {
528-
return;
529-
}
530-
// Only react to saves of CI-relevant files. providerForPath
531-
// returns `undefined` for anything that doesn't match our
532-
// glob patterns, so package.json / random YAML never triggers.
533-
if (!providerForPath(doc.uri.fsPath)) {
534-
return;
535-
}
536-
if (scanOnSaveBusy) {
537-
return;
538-
}
539-
scanOnSaveBusy = true;
540-
try {
541-
await scanWorkspace({ quiet: true });
542-
} finally {
543-
scanOnSaveBusy = false;
544-
}
545-
}),
546-
);
478+
// action). Busy-guard semantics + the gate logic live in
479+
// src/scanOnSave.ts so they're unit-testable without a real save
480+
// event source; this wiring just plumbs VS Code's dependencies in.
481+
const onSave = createScanOnSaveHandler({
482+
isEnabled: () =>
483+
vscode.workspace
484+
.getConfiguration("pipelineCheck")
485+
.get<boolean>("scanOnSave", false),
486+
isPipelineFile: (fsPath) => providerForPath(fsPath) !== undefined,
487+
scan: () => scanWorkspace({ quiet: true }),
488+
});
489+
context.subscriptions.push(vscode.workspace.onDidSaveTextDocument(onSave));
547490

548491
// Fire-and-forget the one-time "what's new" toast for users who
549492
// just upgraded. Detached so a not-yet-dismissed notification never

0 commit comments

Comments
 (0)