mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-31 20:03:59 +08:00
The auth mode probes every protected route with valid credentials to prove the route accepts them. Two routes intentionally block after a valid request: tui.control.next waits for queued TUI input, and instance.reload can outlive the one second probe. The probe won by timeout, marked the scenario passed, and left the request alive inside the cached web handler. Final disposeApps() then waited for those requests forever, so any run that rebuilt the app (cold turbo cache) never exited and CI cancelled the job at 15 minutes. Add a scenario flag that keeps the missing-credentials check (the route must still return 401) but skips the valid-credential probe for routes whose valid requests intentionally block, and mark tui.control.next and instance.reload with it.
219 lines
6.6 KiB
TypeScript
219 lines
6.6 KiB
TypeScript
import { Effect } from "effect"
|
|
import { looksJson } from "./assertions"
|
|
import type {
|
|
ActiveScenario,
|
|
AuthPolicy,
|
|
BuilderState,
|
|
CallResult,
|
|
Comparison,
|
|
Method,
|
|
ProjectOptions,
|
|
RequestSpec,
|
|
ScenarioContext,
|
|
SeededContext,
|
|
TodoScenario,
|
|
} from "./types"
|
|
|
|
class ScenarioBuilder<S = undefined> {
|
|
private readonly state: BuilderState<S>
|
|
|
|
constructor(method: Method, path: string, name: string, auth: AuthPolicy) {
|
|
this.state = {
|
|
method,
|
|
path,
|
|
name,
|
|
project: { git: true },
|
|
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The unseeded builder state is intentionally undefined until `.seeded(...)` narrows it.
|
|
seed: () => Effect.succeed(undefined as S),
|
|
request: (ctx) => ({ path, headers: ctx.headers() }),
|
|
authProbe: undefined,
|
|
validAuthProbe: true, // kilocode_change
|
|
capture: "full",
|
|
mutates: false,
|
|
reset: true,
|
|
auth,
|
|
}
|
|
}
|
|
|
|
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 })
|
|
}
|
|
|
|
probe(authProbe: RequestSpec) {
|
|
return this.clone({ authProbe })
|
|
}
|
|
|
|
// kilocode_change start - blocking routes only prove they reject missing credentials so no valid request leaks into app disposal
|
|
skipValidAuthProbe() {
|
|
return this.clone({ validAuthProbe: false })
|
|
}
|
|
// kilocode_change end
|
|
|
|
mutating() {
|
|
return this.clone({ mutates: true })
|
|
}
|
|
|
|
preserveDatabase() {
|
|
return this.clone({ reset: false })
|
|
}
|
|
|
|
stream() {
|
|
return this.clone({ capture: "stream" })
|
|
}
|
|
|
|
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 })
|
|
}
|
|
|
|
/** 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>>) {
|
|
const builder = new ScenarioBuilder<S>(this.state.method, this.state.path, this.state.name, this.state.auth)
|
|
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>) {
|
|
const builder = new ScenarioBuilder<Next>(this.state.method, this.state.path, this.state.name, this.state.auth)
|
|
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,
|
|
authProbe: state.authProbe,
|
|
validAuthProbe: state.validAuthProbe, // kilocode_change
|
|
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired request/state type inside the builder.
|
|
request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }),
|
|
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired assertion/state type inside the builder.
|
|
expect: (ctx, seeded, result) => expect({ ...ctx, state: seeded as S }, result),
|
|
compare,
|
|
capture: state.capture,
|
|
mutates: state.mutates,
|
|
reset: state.reset,
|
|
auth: state.auth,
|
|
}
|
|
}
|
|
}
|
|
|
|
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),
|
|
})
|
|
|
|
export const http = {
|
|
protected: routes("protected"),
|
|
public: routes("public"),
|
|
publicBypass: routes("public-bypass"),
|
|
ticketBypass: routes("ticket-bypass"),
|
|
}
|
|
|
|
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 } : {}),
|
|
}
|
|
}
|