@@ -21,6 +21,10 @@ import {
2121 assessPatchloomCompatibility ,
2222 resolvePatchloomStatusWithInputs
2323} from "../../src/binary/patchloom" ;
24+ import { classifyAgentsFile } from "../../src/commands/initializeProject" ;
25+ import { buildStatusDetails , preferredStatusAction } from "../../src/commands/showStatus" ;
26+ import { buildReplaceQuickAction , retargetQuickAction , withApplyFlag } from "../../src/commands/quickActions" ;
27+ import { configureMcpTargets , inspectMcpTargets } from "../../src/mcp/config" ;
2428
2529const execFileAsync = promisify ( execFile ) ;
2630
@@ -255,4 +259,252 @@ describe("patchloom CLI integration", async () => {
255259 }
256260 } ) ;
257261 } ) ;
262+
263+ test ( "exit code 2 for replace --check with pending changes" , async ( ) => {
264+ await withTempDir ( async ( dir ) => {
265+ const file = path . join ( dir , "check-target.txt" ) ;
266+ await fs . writeFile ( file , "hello world\n" , "utf8" ) ;
267+
268+ try {
269+ await execFileAsync ( binaryPath , [
270+ "replace" , "hello" , "--to" , "goodbye" , file , "--check"
271+ ] , { timeout : 5000 } ) ;
272+ assert . fail ( "should have exited with non-zero code" ) ;
273+ } catch ( error ) {
274+ const execError = error as Error & { code ?: number } ;
275+ assert . equal ( execError . code , 2 , "changes-detected exit code should be 2" ) ;
276+ }
277+
278+ // File should be unchanged (--check is read-only)
279+ const content = await fs . readFile ( file , "utf8" ) ;
280+ assert . equal ( content , "hello world\n" , "file must not be modified by --check" ) ;
281+ } ) ;
282+ } ) ;
283+
284+ test ( "exit code 0 for tidy check on a clean file" , async ( ) => {
285+ await withTempDir ( async ( dir ) => {
286+ const file = path . join ( dir , "clean.txt" ) ;
287+ await fs . writeFile ( file , "already clean\n" , "utf8" ) ;
288+
289+ // File has a final newline and no trailing whitespace; tidy check should exit 0
290+ await execFileAsync ( binaryPath , [
291+ "tidy" , "check" , file , "--ensure-final-newline"
292+ ] , { timeout : 5000 } ) ;
293+ // If we get here, exit code was 0 (no issues found)
294+ } ) ;
295+ } ) ;
296+
297+ test ( "exit code 2 for tidy check on a file needing fixes" , async ( ) => {
298+ await withTempDir ( async ( dir ) => {
299+ const file = path . join ( dir , "dirty.txt" ) ;
300+ await fs . writeFile ( file , "no trailing newline" , "utf8" ) ;
301+
302+ try {
303+ await execFileAsync ( binaryPath , [
304+ "tidy" , "check" , file , "--ensure-final-newline"
305+ ] , { timeout : 5000 } ) ;
306+ assert . fail ( "should have exited with non-zero code" ) ;
307+ } catch ( error ) {
308+ const execError = error as Error & { code ?: number } ;
309+ assert . equal ( execError . code , 2 , "tidy check should return 2 for issues found" ) ;
310+ }
311+ } ) ;
312+ } ) ;
313+
314+ // --- Initialize Project round-trip ---
315+
316+ test ( "agent-rules output classified as up_to_date after write" , async ( ) => {
317+ await withTempDir ( async ( dir ) => {
318+ // Generate agent rules
319+ const { stdout : rules } = await execFileAsync ( binaryPath , [ "agent-rules" ] , {
320+ cwd : dir ,
321+ timeout : 10000
322+ } ) ;
323+
324+ // Write it exactly as generated
325+ const agentsPath = path . join ( dir , "AGENTS.md" ) ;
326+ const content = rules . endsWith ( "\n" ) ? rules : `${ rules } \n` ;
327+ await fs . writeFile ( agentsPath , content , "utf8" ) ;
328+
329+ // Extension's classifier should see it as up_to_date
330+ const existing = await fs . readFile ( agentsPath , "utf8" ) ;
331+ const state = classifyAgentsFile ( existing , content ) ;
332+ assert . equal ( state , "up_to_date" ,
333+ "freshly written agent-rules output should be classified as up_to_date" ) ;
334+ } ) ;
335+ } ) ;
336+
337+ test ( "agent-rules output classified as different after modification" , async ( ) => {
338+ await withTempDir ( async ( dir ) => {
339+ const { stdout : rules } = await execFileAsync ( binaryPath , [ "agent-rules" ] , {
340+ cwd : dir ,
341+ timeout : 10000
342+ } ) ;
343+
344+ const content = rules . endsWith ( "\n" ) ? rules : `${ rules } \n` ;
345+ const modified = content + "\n## Custom section\n\nExtra content.\n" ;
346+
347+ const state = classifyAgentsFile ( modified , content ) ;
348+ assert . equal ( state , "different" ,
349+ "modified agent-rules should be classified as different" ) ;
350+ } ) ;
351+ } ) ;
352+
353+ // --- Quick action preview flow ---
354+
355+ test ( "quick action preview flow: copy, apply to copy, compare" , async ( ) => {
356+ await withTempDir ( async ( dir ) => {
357+ // Original file
358+ const originalFile = path . join ( dir , "original.txt" ) ;
359+ const originalContent = "The quick brown fox jumps over the lazy dog.\n" ;
360+ await fs . writeFile ( originalFile , originalContent , "utf8" ) ;
361+
362+ // Build the quick action (extension builds these from user input)
363+ const action = buildReplaceQuickAction ( originalFile , "fox" , "cat" ) ;
364+
365+ // Simulate the preview flow: copy to temp, retarget, apply
366+ const previewDir = path . join ( dir , "preview" ) ;
367+ await fs . mkdir ( previewDir ) ;
368+ const previewFile = path . join ( previewDir , "original.txt" ) ;
369+ await fs . writeFile ( previewFile , originalContent , "utf8" ) ;
370+
371+ const previewAction = retargetQuickAction ( action , previewFile ) ;
372+ const applyArgs = withApplyFlag ( [ ...previewAction . args ] ) ;
373+
374+ await execFileAsync ( binaryPath , applyArgs , { cwd : previewDir , timeout : 5000 } ) ;
375+
376+ // Original is untouched
377+ const originalAfter = await fs . readFile ( originalFile , "utf8" ) ;
378+ assert . equal ( originalAfter , originalContent , "original file must not be modified during preview" ) ;
379+
380+ // Preview file has the replacement
381+ const previewContent = await fs . readFile ( previewFile , "utf8" ) ;
382+ assert . ok ( previewContent . includes ( "cat" ) , "preview should contain the replacement" ) ;
383+ assert . ok ( ! previewContent . includes ( "fox" ) , "preview should not contain the original text" ) ;
384+ } ) ;
385+ } ) ;
386+
387+ // --- Full status details with real binary ---
388+
389+ test ( "buildStatusDetails renders real binary status correctly" , async ( ) => {
390+ const status = await resolvePatchloomStatusWithInputs ( {
391+ configuredPath : binaryPath
392+ } ) ;
393+
394+ const details = buildStatusDetails ( status , {
395+ hasWorkspace : true ,
396+ workspaceName : "test-project" ,
397+ hasAgentsFile : false ,
398+ workspaceCount : 1 ,
399+ environmentLabel : "Local" ,
400+ environmentSupport : "supported"
401+ } ) ;
402+
403+ assert . match ( details , / P a t c h l o o m i s r e a d y / , "should report ready" ) ;
404+ assert . match ( details , / p a t c h l o o m \. p a t h / , "should show source as setting" ) ;
405+ assert . ok ( details . includes ( binaryPath ) , "should include the binary path" ) ;
406+ assert . match ( details , / W o r k s p a c e : t e s t - p r o j e c t / , "should include workspace name" ) ;
407+ assert . match ( details , / A G E N T S \. m d : m i s s i n g / , "should report missing AGENTS.md" ) ;
408+ } ) ;
409+
410+ test ( "preferredStatusAction suggests Initialize Project for real ready status" , async ( ) => {
411+ const status = await resolvePatchloomStatusWithInputs ( {
412+ configuredPath : binaryPath
413+ } ) ;
414+
415+ const action = preferredStatusAction ( status , {
416+ hasWorkspace : true ,
417+ workspaceName : "test" ,
418+ hasAgentsFile : false ,
419+ workspaceCount : 1 ,
420+ environmentLabel : "Local" ,
421+ environmentSupport : "supported"
422+ } ) ;
423+
424+ assert . ok ( action , "should suggest an action when AGENTS.md is missing" ) ;
425+ assert . equal ( action . command , "patchloom.initializeProject" ) ;
426+ } ) ;
427+
428+ // --- MCP config write then verify server starts ---
429+
430+ test ( "mcp-server starts and responds to JSON-RPC initialize" , async ( ) => {
431+ // Check if this binary was built with MCP support
432+ try {
433+ await execFileAsync ( binaryPath , [ "mcp-server" , "--help" ] , { timeout : 5000 } ) ;
434+ } catch {
435+ // mcp-server not available in this build; skip
436+ return ;
437+ }
438+
439+ const child = execFile ( binaryPath , [ "mcp-server" ] , { timeout : 10000 } ) ;
440+ let stdout = "" ;
441+ child . stdout ! . on ( "data" , ( data : Buffer ) => { stdout += data . toString ( ) ; } ) ;
442+
443+ // Send a JSON-RPC initialize request using the MCP wire format
444+ const initRequest = JSON . stringify ( {
445+ jsonrpc : "2.0" ,
446+ id : 1 ,
447+ method : "initialize" ,
448+ params : {
449+ protocolVersion : "2024-11-05" ,
450+ capabilities : { } ,
451+ clientInfo : { name : "test" , version : "0.0.1" }
452+ }
453+ } ) ;
454+ const header = `Content-Length: ${ Buffer . byteLength ( initRequest ) } \r\n\r\n` ;
455+ child . stdin ! . write ( header + initRequest ) ;
456+
457+ // Wait for the server to respond (it needs to start the tokio runtime)
458+ const deadline = Date . now ( ) + 5000 ;
459+ while ( stdout . length === 0 && Date . now ( ) < deadline ) {
460+ await new Promise ( ( resolve ) => setTimeout ( resolve , 100 ) ) ;
461+ }
462+
463+ child . kill ( ) ;
464+
465+ // Should have received a JSON-RPC response with Content-Length header
466+ assert . ok ( stdout . length > 0 , "mcp-server should produce output" ) ;
467+ assert . match ( stdout , / C o n t e n t - L e n g t h / i, "response should use Content-Length framing" ) ;
468+ assert . match ( stdout , / j s o n r p c / , "response body should be JSON-RPC" ) ;
469+ } ) ;
470+
471+ test ( "MCP config written for real binary is structurally valid" , async ( ) => {
472+ await withTempDir ( async ( workspace ) => {
473+ const readFile = async ( filePath : string ) => {
474+ try { return await fs . readFile ( filePath , "utf8" ) ; } catch { return undefined ; }
475+ } ;
476+ const writeFile = async ( filePath : string , content : string ) => {
477+ await fs . mkdir ( path . dirname ( filePath ) , { recursive : true } ) ;
478+ await fs . writeFile ( filePath , content , "utf8" ) ;
479+ } ;
480+
481+ // Write MCP config pointing to the real binary
482+ await configureMcpTargets ( {
483+ workspaceFolderPath : workspace ,
484+ homeDir : workspace ,
485+ includeKinds : [ "vscode-workspace" ] ,
486+ patchloomPathSetting : binaryPath ,
487+ readFile,
488+ writeFile
489+ } ) ;
490+
491+ // Read back and verify structure
492+ const configPath = path . join ( workspace , ".vscode" , "mcp.json" ) ;
493+ const config = JSON . parse ( await fs . readFile ( configPath , "utf8" ) ) as Record < string , unknown > ;
494+ const servers = config . servers as Record < string , Record < string , unknown > > ;
495+ assert . ok ( servers . patchloom , "should have a patchloom server entry" ) ;
496+ assert . equal ( servers . patchloom . command , binaryPath , "command should point to real binary" ) ;
497+ assert . deepEqual ( servers . patchloom . args , [ "mcp-server" ] , "args should be mcp-server" ) ;
498+
499+ // Verify the config is re-readable by inspectMcpTargets
500+ const targets = await inspectMcpTargets ( {
501+ workspaceFolderPath : workspace ,
502+ homeDir : workspace ,
503+ readFile
504+ } ) ;
505+ const vscodeTarget = targets . find ( ( t ) => t . kind === "vscode-workspace" ) ;
506+ assert . ok ( vscodeTarget ) ;
507+ assert . equal ( vscodeTarget . configured , true , "target should be detected as configured" ) ;
508+ } ) ;
509+ } ) ;
258510} ) ;
0 commit comments