11import { describe , expect , it } from "vitest" ;
22import type { OpenClawConfig } from "../config/types.openclaw.js" ;
33import { runDoctorHealthRepairs } from "./doctor-repair-flow.js" ;
4+ import { defineSplitHealthCheck , normalizeHealthCheck } from "./health-check-adapter.js" ;
5+ import type { RunnableHealthCheck } from "./health-check-runner-types.js" ;
46import type { HealthCheck , HealthRepairContext } from "./health-checks.js" ;
57
68function ctx ( cfg : OpenClawConfig ) : HealthRepairContext {
@@ -16,10 +18,56 @@ function ctx(cfg: OpenClawConfig): HealthRepairContext {
1618}
1719
1820describe ( "runDoctorHealthRepairs" , ( ) => {
21+ it ( "repairs single-run checks and validates through lint mode" , async ( ) => {
22+ const runModes : string [ ] = [ ] ;
23+ const scopes : unknown [ ] = [ ] ;
24+ const runnable : RunnableHealthCheck = {
25+ id : "test/run-repairable" ,
26+ kind : "core" ,
27+ description : "run repairable" ,
28+ async run ( ctx , scope ) {
29+ runModes . push ( ctx . mode ) ;
30+ if ( scope !== undefined ) {
31+ scopes . push ( scope ) ;
32+ }
33+ const findings =
34+ ctx . cfg . gateway ?. mode === "local"
35+ ? [ ]
36+ : [
37+ {
38+ checkId : "test/run-repairable" ,
39+ severity : "warning" as const ,
40+ message : "gateway mode missing" ,
41+ path : "gateway.mode" ,
42+ } ,
43+ ] ;
44+ if ( ! ctx . repair || findings . length === 0 ) {
45+ return { findings } ;
46+ }
47+ return {
48+ findings,
49+ config : { ...ctx . cfg , gateway : { ...ctx . cfg . gateway , mode : "local" } } ,
50+ changes : [ "Set gateway.mode to local." ] ,
51+ } ;
52+ } ,
53+ } ;
54+ const checks : HealthCheck [ ] = [ normalizeHealthCheck ( runnable ) ] ;
55+
56+ const result = await runDoctorHealthRepairs ( ctx ( { } ) , { checks } ) ;
57+
58+ expect ( result . config . gateway ?. mode ) . toBe ( "local" ) ;
59+ expect ( result . changes ) . toEqual ( [ "Set gateway.mode to local." ] ) ;
60+ expect ( result . checksRepaired ) . toBe ( 1 ) ;
61+ expect ( result . checksValidated ) . toBe ( 1 ) ;
62+ expect ( result . remainingFindings ) . toEqual ( [ ] ) ;
63+ expect ( runModes ) . toEqual ( [ "fix" , "lint" ] ) ;
64+ expect ( scopes ) . toMatchObject ( [ { paths : [ "gateway.mode" ] } ] ) ;
65+ } ) ;
66+
1967 it ( "repairs modern checks and threads updated config" , async ( ) => {
2068 const scopes : unknown [ ] = [ ] ;
2169 const checks : HealthCheck [ ] = [
22- {
70+ defineSplitHealthCheck ( {
2371 id : "test/repairable" ,
2472 kind : "core" ,
2573 description : "repairable" ,
@@ -44,7 +92,7 @@ describe("runDoctorHealthRepairs", () => {
4492 changes : [ "Set gateway.mode to local." ] ,
4593 } ;
4694 } ,
47- } ,
95+ } ) ,
4896 ] ;
4997
5098 const result = await runDoctorHealthRepairs ( ctx ( { } ) , { checks } ) ;
@@ -57,9 +105,35 @@ describe("runDoctorHealthRepairs", () => {
57105 expect ( scopes ) . toMatchObject ( [ { paths : [ "gateway.mode" ] } ] ) ;
58106 } ) ;
59107
108+ it ( "keeps repairable out of split repair result types" , ( ) => {
109+ const check = defineSplitHealthCheck ( {
110+ id : "test/repair-result-status-boundary" ,
111+ kind : "core" ,
112+ description : "repair result status boundary" ,
113+ async detect ( ) {
114+ return [
115+ {
116+ checkId : "test/repair-result-status-boundary" ,
117+ severity : "warning" ,
118+ message : "needs repair" ,
119+ } ,
120+ ] ;
121+ } ,
122+ // @ts -expect-error repairable is a run-result preview status, not a split repair result.
123+ async repair ( ) {
124+ return {
125+ status : "repairable" ,
126+ changes : [ ] ,
127+ } ;
128+ } ,
129+ } ) ;
130+
131+ expect ( check . id ) . toBe ( "test/repair-result-status-boundary" ) ;
132+ } ) ;
133+
60134 it ( "leaves non-repairable checks for legacy doctor behavior" , async ( ) => {
61135 const checks : HealthCheck [ ] = [
62- {
136+ defineSplitHealthCheck ( {
63137 id : "test/legacy-only" ,
64138 kind : "core" ,
65139 description : "legacy only" ,
@@ -72,7 +146,7 @@ describe("runDoctorHealthRepairs", () => {
72146 } ,
73147 ] ;
74148 } ,
75- } ,
149+ } ) ,
76150 ] ;
77151
78152 const result = await runDoctorHealthRepairs ( ctx ( { } ) , { checks } ) ;
@@ -85,9 +159,44 @@ describe("runDoctorHealthRepairs", () => {
85159 expect ( result . checksValidated ) . toBe ( 0 ) ;
86160 } ) ;
87161
88- it ( "reports repair validation findings that remain after repair" , async ( ) => {
162+ it ( "keeps split check findings when repair throws " , async ( ) => {
89163 const checks : HealthCheck [ ] = [
164+ defineSplitHealthCheck ( {
165+ id : "test/repair-throws" ,
166+ kind : "core" ,
167+ description : "repair throws" ,
168+ async detect ( ) {
169+ return [
170+ {
171+ checkId : "test/repair-throws" ,
172+ severity : "warning" ,
173+ message : "needs repair" ,
174+ path : "gateway.mode" ,
175+ } ,
176+ ] ;
177+ } ,
178+ async repair ( ) {
179+ throw new Error ( "repair exploded" ) ;
180+ } ,
181+ } ) ,
182+ ] ;
183+
184+ const result = await runDoctorHealthRepairs ( ctx ( { } ) , { checks } ) ;
185+
186+ expect ( result . findings ) . toMatchObject ( [
90187 {
188+ checkId : "test/repair-throws" ,
189+ path : "gateway.mode" ,
190+ } ,
191+ ] ) ;
192+ expect ( result . warnings ) . toEqual ( [ "test/repair-throws repair failed: repair exploded" ] ) ;
193+ expect ( result . checksRepaired ) . toBe ( 0 ) ;
194+ expect ( result . checksValidated ) . toBe ( 0 ) ;
195+ } ) ;
196+
197+ it ( "reports repair validation findings that remain after repair" , async ( ) => {
198+ const checks : HealthCheck [ ] = [
199+ defineSplitHealthCheck ( {
91200 id : "test/not-fixed" ,
92201 kind : "core" ,
93202 description : "not fixed" ,
@@ -106,7 +215,7 @@ describe("runDoctorHealthRepairs", () => {
106215 changes : [ "Tried repair." ] ,
107216 } ;
108217 } ,
109- } ,
218+ } ) ,
110219 ] ;
111220
112221 const result = await runDoctorHealthRepairs ( ctx ( { } ) , { checks } ) ;
@@ -122,10 +231,49 @@ describe("runDoctorHealthRepairs", () => {
122231 expect ( result . warnings ) . toEqual ( [ "test/not-fixed repair left 1 finding(s)" ] ) ;
123232 } ) ;
124233
234+ it ( "validates successful repairs by default" , async ( ) => {
235+ let detectCalls = 0 ;
236+ const checks : HealthCheck [ ] = [
237+ defineSplitHealthCheck ( {
238+ id : "test/no-default-validation" ,
239+ kind : "core" ,
240+ description : "no default validation" ,
241+ async detect ( ) {
242+ detectCalls ++ ;
243+ return [
244+ {
245+ checkId : "test/no-default-validation" ,
246+ severity : "warning" ,
247+ message : "needs repair" ,
248+ } ,
249+ ] ;
250+ } ,
251+ async repair ( ) {
252+ return {
253+ changes : [ "Ran repair." ] ,
254+ } ;
255+ } ,
256+ } ) ,
257+ ] ;
258+
259+ const result = await runDoctorHealthRepairs ( ctx ( { } ) , { checks } ) ;
260+
261+ expect ( detectCalls ) . toBe ( 2 ) ;
262+ expect ( result . checksRepaired ) . toBe ( 1 ) ;
263+ expect ( result . checksValidated ) . toBe ( 1 ) ;
264+ expect ( result . remainingFindings ) . toEqual ( [
265+ {
266+ checkId : "test/no-default-validation" ,
267+ severity : "warning" ,
268+ message : "needs repair" ,
269+ } ,
270+ ] ) ;
271+ } ) ;
272+
125273 it ( "does not validate skipped or failed repair results" , async ( ) => {
126274 let validationCalls = 0 ;
127275 const checks : HealthCheck [ ] = [
128- {
276+ defineSplitHealthCheck ( {
129277 id : "test/skipped" ,
130278 kind : "core" ,
131279 description : "skipped" ,
@@ -146,7 +294,7 @@ describe("runDoctorHealthRepairs", () => {
146294 changes : [ ] ,
147295 } ;
148296 } ,
149- } ,
297+ } ) ,
150298 ] ;
151299
152300 const result = await runDoctorHealthRepairs ( ctx ( { } ) , { checks } ) ;
@@ -162,7 +310,7 @@ describe("runDoctorHealthRepairs", () => {
162310 const repairContexts : HealthRepairContext [ ] = [ ] ;
163311 let detectCalls = 0 ;
164312 const checks : HealthCheck [ ] = [
165- {
313+ defineSplitHealthCheck ( {
166314 id : "test/dry-run" ,
167315 kind : "core" ,
168316 description : "dry run" ,
@@ -202,7 +350,7 @@ describe("runDoctorHealthRepairs", () => {
202350 ] ,
203351 } ;
204352 } ,
205- } ,
353+ } ) ,
206354 ] ;
207355
208356 const result = await runDoctorHealthRepairs ( ctx ( { } ) , {
@@ -220,4 +368,58 @@ describe("runDoctorHealthRepairs", () => {
220368 expect ( detectCalls ) . toBe ( 1 ) ;
221369 expect ( repairContexts [ 0 ] ) . toMatchObject ( { dryRun : true , diff : true } ) ;
222370 } ) ;
371+
372+ it ( "passes diff false and true through the repair API" , async ( ) => {
373+ const repairContexts : HealthRepairContext [ ] = [ ] ;
374+ const checks : HealthCheck [ ] = [
375+ defineSplitHealthCheck ( {
376+ id : "test/diff-preview" ,
377+ kind : "core" ,
378+ description : "diff preview" ,
379+ async detect ( ) {
380+ return [
381+ {
382+ checkId : "test/diff-preview" ,
383+ severity : "warning" ,
384+ message : "config needs repair" ,
385+ path : "gateway.mode" ,
386+ } ,
387+ ] ;
388+ } ,
389+ async repair ( ctx ) {
390+ repairContexts . push ( ctx ) ;
391+ return {
392+ changes : [ "Would set gateway.mode to local." ] ,
393+ diffs :
394+ ctx . diff === true
395+ ? [
396+ {
397+ kind : "config" ,
398+ path : "gateway.mode" ,
399+ before : undefined ,
400+ after : "local" ,
401+ } ,
402+ ]
403+ : [ ] ,
404+ } ;
405+ } ,
406+ } ) ,
407+ ] ;
408+
409+ const withoutDiff = await runDoctorHealthRepairs ( ctx ( { } ) , {
410+ checks,
411+ dryRun : true ,
412+ diff : false ,
413+ } ) ;
414+ const withDiff = await runDoctorHealthRepairs ( ctx ( { } ) , {
415+ checks,
416+ dryRun : true ,
417+ diff : true ,
418+ } ) ;
419+
420+ expect ( repairContexts [ 0 ] ) . toMatchObject ( { dryRun : true , diff : false } ) ;
421+ expect ( withoutDiff . diffs ) . toEqual ( [ ] ) ;
422+ expect ( repairContexts [ 1 ] ) . toMatchObject ( { dryRun : true , diff : true } ) ;
423+ expect ( withDiff . diffs ) . toMatchObject ( [ { kind : "config" , path : "gateway.mode" } ] ) ;
424+ } ) ;
223425} ) ;
0 commit comments