11import { beforeEach , describe , expect , it , vi } from "vitest" ;
22
33const mocks = vi . hoisted ( ( ) => ( {
4- getCurrentPrePrCheckConfig : vi . fn ( ) ,
5- runPrePrChecksWithFixes : vi . fn ( ) ,
64 info : vi . fn ( ) ,
75 warn : vi . fn ( ) ,
86 error : vi . fn ( ) ,
97} ) ) ;
108
11- vi . mock ( "../db/client.js" , ( ) => ( { getDb : ( ) => ( { kind : "db" } ) } ) ) ;
12- vi . mock ( "../pre-pr-checks/store.js" , ( ) => ( {
13- getCurrentPrePrCheckConfig : ( ...args : any [ ] ) =>
14- mocks . getCurrentPrePrCheckConfig ( ...args ) ,
15- } ) ) ;
16- vi . mock ( "../pre-pr-checks/runner.js" , ( ) => ( {
17- runPrePrChecksWithFixes : ( ...args : any [ ] ) =>
18- mocks . runPrePrChecksWithFixes ( ...args ) ,
19- } ) ) ;
209vi . mock ( "../lib/logger.js" , ( ) => ( {
2110 logger : { info : mocks . info , warn : mocks . warn , error : mocks . error } ,
2211} ) ) ;
12+ // The regex half of the real redactor is what these assert against; its
13+ // process.env half would make the output depend on the machine running the
14+ // suite, and there is no secret in these fixtures for it to find.
2315vi . mock ( "../../env.js" , ( ) => ( {
2416 env : { DASHBOARD_ORIGIN : "https://dashboard.example.com" } ,
2517} ) ) ;
2618
2719import {
2820 PRE_PR_CHECKS_FAILURE_CAUSE_MAX_LENGTH ,
21+ prePrChecksFailureMessage ,
2922 PRE_PR_CHECKS_FAILURE_STACK_TAIL_MAX_LENGTH ,
23+ describePrePrChecksFailureStep ,
24+ prePrChecksFailureInput ,
3025 prePrChecksFailureMustPropagate ,
3126 prePrChecksFailureReport ,
32- runPrePrChecksStep ,
3327} from "./agent.js" ;
3428import { isDurationAbortError } from "./run-budget.js" ;
3529import { isRunControlError } from "./run-control-error.js" ;
@@ -43,51 +37,27 @@ function namedError(name: string, message: string): Error {
4337 return error ;
4438}
4539
46- function runStep ( ) {
47- return runPrePrChecksStep ( "sbx-test-123" , "codex" , "gpt-5" ) ;
40+ /**
41+ * What the call site does with a throw it may not propagate: flatten it in
42+ * workflow scope, then compose and log the sentence inside the step. The
43+ * checks stopped being a step of their own (they are launched detached and
44+ * polled), so this pair is the seam that carries what #316 landed.
45+ */
46+ function describe_ ( error : unknown , version : number | null = 7 ) {
47+ return describePrePrChecksFailureStep ( prePrChecksFailureInput ( error ) , version ) ;
4848}
4949
5050describe ( "pre-PR checks step failure cause" , ( ) => {
5151 beforeEach ( ( ) => {
5252 vi . clearAllMocks ( ) ;
53- mocks . getCurrentPrePrCheckConfig . mockResolvedValue ( {
54- version : 7 ,
55- config : { repositories : [ ] } ,
56- } ) ;
57- } ) ;
58-
59- it ( "returns the checks result and the loaded version when nothing throws" , async ( ) => {
60- mocks . runPrePrChecksWithFixes . mockResolvedValue ( {
61- outcome : "passed" ,
62- passed : true ,
63- fixCycles : 0 ,
64- fixCycleUsages : [ ] ,
65- budgetFailure : null ,
66- summary : "All checks passed." ,
67- } ) ;
68-
69- await expect ( runStep ( ) ) . resolves . toEqual ( {
70- outcome : "passed" ,
71- passed : true ,
72- fixCycles : 0 ,
73- fixCycleUsages : [ ] ,
74- budgetFailure : null ,
75- summary : "All checks passed." ,
76- configurationVersion : 7 ,
77- } ) ;
78- expect ( mocks . error ) . not . toHaveBeenCalled ( ) ;
7953 } ) ;
8054
8155 it ( "names the thrown cause instead of leaving Workflow's wrapper to speak alone" , async ( ) => {
8256 // Production runs wrun_01M0CBQNAX24STRMN5SGCKKGB2 and
8357 // wrun_01M0CAZKV3YMNFBCZJA8MT95GW died here reading only "exceeded max
8458 // retries", with no sanitized output captured and nothing in the runtime
8559 // logs. Whatever the step throws has to reach the operator-facing text.
86- mocks . runPrePrChecksWithFixes . mockRejectedValue (
87- new Error ( "sandbox connection reset" ) ,
88- ) ;
89-
90- await expect ( runStep ( ) ) . rejects . toThrow (
60+ await expect ( describe_ ( new Error ( "sandbox connection reset" ) ) ) . resolves . toBe (
9161 `${ MESSAGE_LEAD } sandbox connection reset` ,
9262 ) ;
9363 expect ( mocks . error ) . toHaveBeenCalledTimes ( 1 ) ;
@@ -108,66 +78,57 @@ describe("pre-PR checks step failure cause", () => {
10878 ) ;
10979 } ) ;
11080
111- it ( "prefers a system error code over a class name that says nothing" , async ( ) => {
112- mocks . runPrePrChecksWithFixes . mockRejectedValue (
113- Object . assign ( new Error ( "connect ECONNREFUSED 10.0.0.1:443" ) , {
114- code : "ECONNREFUSED" ,
115- } ) ,
116- ) ;
81+ it ( "labels the cause with the class name, the only thing left of the error" , async ( ) => {
82+ // Everything caught here was thrown inside a step, and Workflow reduces a
83+ // thrown error to name, message and stack at the VM boundary and revives it
84+ // as a plain Error. A system error code would name the cause far better
85+ // than `Error` does, but `.code` cannot reach this side, so nothing may be
86+ // built on it: a label that can never fire reads as coverage that does not
87+ // exist. Recovering it would mean parsing the message.
88+ const error = Object . assign ( namedError ( "SandboxError" , "connect ECONNREFUSED 10.0.0.1:443" ) , {
89+ code : "ECONNREFUSED" ,
90+ } ) ;
11791
118- await expect ( runStep ( ) ) . rejects . toThrow (
119- `${ MESSAGE_LEAD } ECONNREFUSED : connect ECONNREFUSED 10.0.0.1:443` ,
92+ await expect ( describe_ ( error ) ) . resolves . toBe (
93+ `${ MESSAGE_LEAD } SandboxError : connect ECONNREFUSED 10.0.0.1:443` ,
12094 ) ;
95+ // A plain Error adds nothing worth prefixing, and a non-Error throw's
96+ // `typeof` is noise on top of its own text.
97+ await expect ( describe_ ( new Error ( "plain" ) ) ) . resolves . toBe ( `${ MESSAGE_LEAD } plain` ) ;
98+ await expect ( describe_ ( "just a string" ) ) . resolves . toBe ( `${ MESSAGE_LEAD } just a string` ) ;
12199 } ) ;
122100
123101 it ( "bounds a runaway cause instead of embedding it whole" , async ( ) => {
124102 const thrownMessage = "sandbox refused the launch. " . repeat ( 200 ) ;
125- mocks . runPrePrChecksWithFixes . mockRejectedValue ( new Error ( thrownMessage ) ) ;
126103
127- const thrown = await runStep ( ) . then (
128- ( ) => null ,
129- ( err : unknown ) => err as Error ,
130- ) ;
104+ const message = await describe_ ( new Error ( thrownMessage ) ) ;
131105
132- expect ( thrown ?. message ) . toContain ( MESSAGE_LEAD ) ;
133- expect ( thrown ?. message ) . not . toContain ( thrownMessage ) ;
106+ expect ( message ) . toContain ( MESSAGE_LEAD ) ;
107+ expect ( message ) . not . toContain ( thrownMessage ) ;
134108 // Sentence plus the bound the cause is clamped to, and nothing more, so a
135109 // runaway error text cannot become the run status.
136- expect ( thrown ?. message . length ) . toBeLessThanOrEqual (
110+ expect ( message . length ) . toBeLessThanOrEqual (
137111 MESSAGE_LEAD . length + PRE_PR_CHECKS_FAILURE_CAUSE_MAX_LENGTH ,
138112 ) ;
139113 const logged = mocks . error . mock . calls [ 0 ] ?. [ 0 ] as { cause : string } ;
140114 expect ( logged . cause . length ) . toBe ( PRE_PR_CHECKS_FAILURE_CAUSE_MAX_LENGTH ) ;
141115 } ) ;
142116
143117 it . each ( runControlErrorCases ( ) ) (
144- "rethrows %s untouched so the call site still recognizes it" ,
145- async ( _label , error ) => {
146- mocks . runPrePrChecksWithFixes . mockRejectedValue ( error ) ;
147-
148- await expect ( runStep ( ) ) . rejects . toBe ( error ) ;
149- const thrown = await runStep ( ) . then (
150- ( ) => null ,
151- ( err : unknown ) => err ,
152- ) ;
153- expect ( isRunControlError ( thrown ) ) . toBe ( true ) ;
154- expect ( mocks . error ) . not . toHaveBeenCalled ( ) ;
118+ "keeps %s out of the wrap so the call site still recognizes it" ,
119+ ( _label , error ) => {
120+ expect ( prePrChecksFailureMustPropagate ( error ) ) . toBe ( true ) ;
121+ expect ( isRunControlError ( error ) ) . toBe ( true ) ;
155122 } ,
156123 ) ;
157124
158125 it . each ( [ [ "AbortError" ] , [ "TimeoutError" ] ] ) (
159- "rethrows a %s untouched so the duration budget stop survives" ,
160- async ( name ) => {
126+ "keeps a %s out of the wrap so the duration budget stop survives" ,
127+ ( name ) => {
161128 const error = namedError ( name , "The operation was aborted." ) ;
162- mocks . runPrePrChecksWithFixes . mockRejectedValue ( error ) ;
163-
164- await expect ( runStep ( ) ) . rejects . toBe ( error ) ;
165- const thrown = await runStep ( ) . then (
166- ( ) => null ,
167- ( err : unknown ) => err ,
168- ) ;
169- expect ( isDurationAbortError ( thrown ) ) . toBe ( true ) ;
170- expect ( mocks . error ) . not . toHaveBeenCalled ( ) ;
129+
130+ expect ( prePrChecksFailureMustPropagate ( error ) ) . toBe ( true ) ;
131+ expect ( isDurationAbortError ( error ) ) . toBe ( true ) ;
171132 } ,
172133 ) ;
173134
@@ -184,18 +145,18 @@ describe("pre-PR checks step failure cause", () => {
184145 expect ( prePrChecksFailureMustPropagate ( new Error ( "sandbox died" ) ) ) . toBe ( false ) ;
185146
186147 const rewrappedBudget = new Error (
187- prePrChecksFailureReport ( budgetStop , identity ) . message ,
148+ prePrChecksFailureReport ( prePrChecksFailureInput ( budgetStop ) , identity ) . message ,
188149 ) ;
189150 expect ( isRunControlError ( rewrappedBudget ) ) . toBe ( false ) ;
190151 const rewrappedAbort = new Error (
191- prePrChecksFailureReport ( abort , identity ) . message ,
152+ prePrChecksFailureReport ( prePrChecksFailureInput ( abort ) , identity ) . message ,
192153 ) ;
193154 expect ( isDurationAbortError ( rewrappedAbort ) ) . toBe ( false ) ;
194155 } ) ;
195156
196157 it ( "redacts the cause and the stack tail through the caller's redactor" , ( ) => {
197158 const report = prePrChecksFailureReport (
198- new Error ( "auth failed for token=abcd1234" ) ,
159+ prePrChecksFailureInput ( new Error ( "auth failed for token=abcd1234" ) ) ,
199160 ( value ) => value . replace ( "abcd1234" , "[REDACTED]" ) ,
200161 ) ;
201162
@@ -204,10 +165,78 @@ describe("pre-PR checks step failure cause", () => {
204165 } ) ;
205166
206167 it ( "names a non-Error throw rather than dropping it" , ( ) => {
207- const report = prePrChecksFailureReport ( "sandbox vanished" , ( v ) => v ) ;
168+ const report = prePrChecksFailureReport (
169+ prePrChecksFailureInput ( "sandbox vanished" ) ,
170+ ( v ) => v ,
171+ ) ;
208172
209173 expect ( report . message ) . toBe ( `${ MESSAGE_LEAD } sandbox vanished` ) ;
210174 expect ( report . name ) . toBe ( "string" ) ;
211175 expect ( report . stackTail ) . toBe ( "" ) ;
212176 } ) ;
177+
178+ it ( "redacts and bounds before the value can be journaled" , async ( ) => {
179+ // Workflow journals step arguments durably, so whatever the flattener
180+ // returns is written into the run's event log verbatim. Redacting inside
181+ // the step would protect only the sentence an operator reads.
182+ const secret = "glpat-AAAAAAAAAAAAAAAAAAAA" ;
183+ const error = new Error (
184+ `clone failed for https://oauth2:${ secret } @gitlab.com/acme/api.git ${ "pad " . repeat ( 200 ) } ` ,
185+ ) ;
186+
187+ const input = prePrChecksFailureInput ( error ) ;
188+
189+ expect ( input . message ) . not . toContain ( secret ) ;
190+ expect ( input . message ) . toContain ( "[REDACTED]" ) ;
191+ expect ( input . message . length ) . toBe ( PRE_PR_CHECKS_FAILURE_CAUSE_MAX_LENGTH ) ;
192+ expect ( input . stack . length ) . toBeLessThanOrEqual (
193+ PRE_PR_CHECKS_FAILURE_STACK_TAIL_MAX_LENGTH ,
194+ ) ;
195+ } ) ;
196+
197+ it ( "survives a throw with no properties at all" , async ( ) => {
198+ // `throw null` and a bare Promise.reject() both reach here. A TypeError
199+ // raised inside the reporting path would lose the cause it exists to carry
200+ // and hand the operator an unrelated error.
201+ for ( const thrown of [ null , undefined ] ) {
202+ expect ( ( ) => prePrChecksFailureInput ( thrown ) ) . not . toThrow ( ) ;
203+ await expect ( describe_ ( thrown ) ) . resolves . toContain ( MESSAGE_LEAD ) ;
204+ }
205+ } ) ;
206+
207+ it ( "degrades to a fixed sentence when the reporting step itself fails" , async ( ) => {
208+ // The step is the only place the cause is logged and its maxRetries is 0.
209+ // A failed dynamic import on a cold start, or an invocation killed
210+ // mid-step, must not substitute the reporting path's own error for the
211+ // report.
212+ mocks . error . mockImplementation ( ( ) => {
213+ throw new Error ( "logger transport is gone" ) ;
214+ } ) ;
215+
216+ const message = await prePrChecksFailureMessage (
217+ Object . assign ( new Error ( "sandbox connection reset" ) , { name : "SandboxError" } ) ,
218+ 7 ,
219+ ) ;
220+
221+ expect ( message ) . toBe (
222+ "The Pre-PR checks step failed (SandboxError), and the cause could not be recorded." ,
223+ ) ;
224+ } ) ;
225+
226+ it . each ( runControlErrorCases ( ) ) (
227+ "rethrows %s from the reporting step instead of degrading" ,
228+ async ( _label , controlError ) => {
229+ // The degraded sentence is for a reporting path that broke. A cancelled
230+ // run surfaces at every step, this one included, and swallowing it here
231+ // would report a Pre-PR checks failure for a run the operator cancelled,
232+ // and let it carry on being cancelled anyway.
233+ mocks . error . mockImplementation ( ( ) => {
234+ throw controlError ;
235+ } ) ;
236+
237+ await expect (
238+ prePrChecksFailureMessage ( new Error ( "sandbox connection reset" ) , 7 ) ,
239+ ) . rejects . toBe ( controlError ) ;
240+ } ,
241+ ) ;
213242} ) ;
0 commit comments