2026-05-08 17:58:14 +00:00
import { Effect } from "effect"
import { looksJson } from "./assertions"
import type {
ActiveScenario ,
2026-05-08 18:05:46 +00:00
AuthPolicy ,
2026-05-08 17:58:14 +00:00
BuilderState ,
CallResult ,
Comparison ,
Method ,
ProjectOptions ,
2026-05-09 00:50:01 +00:00
RequestSpec ,
2026-05-08 17:58:14 +00:00
ScenarioContext ,
SeededContext ,
TodoScenario ,
} from "./types"
class ScenarioBuilder < S = undefined > {
private readonly state : BuilderState < S >
2026-05-09 00:50:01 +00:00
constructor ( method : Method , path : string , name : string , auth : AuthPolicy ) {
2026-05-08 17:58:14 +00:00
this . state = {
method ,
path ,
name ,
project : { git : true } ,
2026-05-08 18:05:46 +00:00
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The unseeded builder state is intentionally undefined until `.seeded(...)` narrows it.
2026-05-08 17:58:14 +00:00
seed : ( ) = > Effect . succeed ( undefined as S ) ,
request : ( ctx ) = > ( { path , headers : ctx.headers ( ) } ) ,
2026-05-09 00:50:01 +00:00
authProbe : undefined ,
2026-05-08 17:58:14 +00:00
capture : "full" ,
mutates : false ,
reset : true ,
2026-05-09 00:50:01 +00:00
auth ,
2026-05-08 17:58:14 +00:00
}
}
global ( ) {
return this . clone ( { project : undefined , request : ( ) = > ( { path : this.state.path } ) } )
}
inProject ( project : ProjectOptions = { git : true } ) {
return this . clone ( { project } )
}
withLlm() {
return this . clone ( { project : { . . . ( this . state . project ? ? { git : true } ) , llm : true } } )
}
at ( request : BuilderState < S > [ "request" ] ) {
return this . clone ( { request } )
}
2026-05-09 00:50:01 +00:00
probe ( authProbe : RequestSpec ) {
return this . clone ( { authProbe } )
}
2026-05-08 17:58:14 +00:00
mutating() {
return this . clone ( { mutates : true } )
}
preserveDatabase() {
return this . clone ( { reset : false } )
}
stream() {
return this . clone ( { capture : "stream" } )
}
2026-05-08 18:05:46 +00:00
protected ( ) {
return this . auth ( "protected" )
}
public ( ) {
return this . auth ( "public" )
}
publicBypass() {
return this . auth ( "public-bypass" )
}
ticketBypass() {
return this . auth ( "ticket-bypass" )
}
private auth ( auth : AuthPolicy ) {
return this . clone ( { auth } )
}
2026-05-08 17:58:14 +00:00
/** Assert a non-JSON or shape-only response. */
ok ( status = 200 , compare : Comparison = "status" ) {
return this . done ( compare , ( _ctx , result ) = >
Effect . sync ( ( ) = > {
if ( result . status !== status ) throw new Error ( ` expected ${ status } , got ${ result . status } : ${ result . text } ` )
} ) ,
)
}
status (
status = 200 ,
inspect ? : ( ctx : SeededContext < S > , result : CallResult ) = > Effect . Effect < void > ,
compare : Comparison = "status" ,
) {
return this . done ( compare , ( ctx , result ) = >
Effect . gen ( function * ( ) {
if ( result . status !== status ) throw new Error ( ` expected ${ status } , got ${ result . status } : ${ result . text } ` )
if ( inspect ) yield * inspect ( ctx , result )
} ) ,
)
}
/** Assert JSON status/content-type plus an optional synchronous body check. */
json ( status = 200 , inspect ? : ( body : unknown , ctx : SeededContext < S > ) = > void , compare : Comparison = "json" ) {
return this . jsonEffect ( status , inspect ? ( body , ctx ) = > Effect . sync ( ( ) = > inspect ( body , ctx ) ) : undefined , compare )
}
/** Assert JSON status/content-type plus optional Effect assertions, e.g. DB side effects. */
jsonEffect (
status = 200 ,
inspect ? : ( body : unknown , ctx : SeededContext < S > ) = > Effect . Effect < void > ,
compare : Comparison = "json" ,
) {
return this . done ( compare , ( ctx , result ) = >
Effect . gen ( function * ( ) {
if ( result . status !== status ) throw new Error ( ` expected ${ status } , got ${ result . status } : ${ result . text } ` )
if ( ! looksJson ( result ) )
throw new Error ( ` expected JSON response, got ${ result . contentType || "no content-type" } ` )
if ( inspect ) yield * inspect ( result . body , ctx )
} ) ,
)
}
private clone ( next : Partial < BuilderState < S > > ) {
2026-05-09 00:50:01 +00:00
const builder = new ScenarioBuilder < S > ( this . state . method , this . state . path , this . state . name , this . state . auth )
2026-05-08 17:58:14 +00:00
Object . assign ( builder . state , this . state , next )
return builder
}
/ * *
* Seed typed state before the HTTP request . The returned value becomes ` ctx.state `
* for ` .at(...) ` and assertions , giving stateful route tests type - safe setup .
* /
seeded < Next > ( seed : ( ctx : ScenarioContext ) = > Effect . Effect < Next > ) {
2026-05-09 00:50:01 +00:00
const builder = new ScenarioBuilder < Next > ( this . state . method , this . state . path , this . state . name , this . state . auth )
2026-05-08 17:58:14 +00:00
Object . assign ( builder . state , this . state , { seed } )
return builder
}
private done (
compare : Comparison ,
expect : ( ctx : SeededContext < S > , result : CallResult ) = > Effect . Effect < void > ,
) : ActiveScenario {
const state = this . state
return {
kind : "active" ,
method : state.method ,
path : state.path ,
name : state.name ,
project : state.project ,
seed : state.seed ,
2026-05-09 00:50:01 +00:00
authProbe : state.authProbe ,
2026-05-08 18:05:46 +00:00
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired request/state type inside the builder.
2026-05-08 17:58:14 +00:00
request : ( ctx , seeded ) = > state . request ( { . . . ctx , state : seeded as S } ) ,
2026-05-08 18:05:46 +00:00
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired assertion/state type inside the builder.
2026-05-08 17:58:14 +00:00
expect : ( ctx , seeded , result ) = > expect ( { . . . ctx , state : seeded as S } , result ) ,
compare ,
capture : state.capture ,
mutates : state.mutates ,
reset : state.reset ,
2026-05-08 18:05:46 +00:00
auth : state.auth ,
2026-05-08 17:58:14 +00:00
}
}
}
2026-05-09 00:50:01 +00:00
const routes = ( auth : AuthPolicy ) = > ( {
get : ( path : string , name : string ) = > new ScenarioBuilder ( "GET" , path , name , auth ) ,
post : ( path : string , name : string ) = > new ScenarioBuilder ( "POST" , path , name , auth ) ,
put : ( path : string , name : string ) = > new ScenarioBuilder ( "PUT" , path , name , auth ) ,
patch : ( path : string , name : string ) = > new ScenarioBuilder ( "PATCH" , path , name , auth ) ,
delete : ( path : string , name : string ) = > new ScenarioBuilder ( "DELETE" , path , name , auth ) ,
} )
2026-05-08 17:58:14 +00:00
export const http = {
2026-05-09 00:50:01 +00:00
protected : routes ( "protected" ) ,
public : routes ( "public" ) ,
publicBypass : routes ( "public-bypass" ) ,
ticketBypass : routes ( "ticket-bypass" ) ,
2026-05-08 17:58:14 +00:00
}
export const pending = ( method : Method , path : string , name : string , reason : string ) : TodoScenario = > ( {
kind : "todo" ,
method ,
path ,
name ,
reason ,
} )
export function route ( template : string , params : Record < string , string > ) {
return Object . entries ( params ) . reduce (
( next , [ key , value ] ) = > next . replaceAll ( ` { ${ key } } ` , value ) . replaceAll ( ` : ${ key } ` , value ) ,
template ,
)
}
export function controlledPtyInput ( title : string | undefined ) {
return {
command : "/bin/sh" ,
args : [ "-c" , "sleep 30" ] ,
. . . ( title ? { title } : { } ) ,
}
}