mirror of
https://github.com/cline/cline.git
synced 2026-09-13 18:10:14 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f979e8809f | ||
|
|
dd33824611 | ||
|
|
81f6cfc876 |
+3
-1
@@ -53,6 +53,8 @@ test-results
|
||||
evals/smoke-tests/results/
|
||||
|
||||
.tui-test
|
||||
secrets.json
|
||||
tui-traces
|
||||
tests/**/cache
|
||||
tests/**/state/
|
||||
tests/**/workspaces/
|
||||
tests/**/configs/**/tasks
|
||||
|
||||
+5
-1
@@ -45,6 +45,7 @@ import { applyProviderConfig } from "./utils/provider-config"
|
||||
import { getValidCliProviders, isValidCliProvider } from "./utils/providers"
|
||||
import { findMostRecentTaskForWorkspace } from "./utils/task-history"
|
||||
import { autoUpdateOnStartup, checkForUpdates } from "./utils/update"
|
||||
import { initVcr } from "./utils/vcr"
|
||||
import { initializeCliContext } from "./vscode-context"
|
||||
import { CLI_LOG_FILE, shutdownEvent, window } from "./vscode-shim"
|
||||
|
||||
@@ -1148,7 +1149,10 @@ program
|
||||
}
|
||||
})
|
||||
|
||||
// Parse and run
|
||||
// Initialize VCR (HTTP record/playback) before parsing commands.
|
||||
// This must happen before any HTTP requests are made so VCR can intercept them.
|
||||
// Does nothing if CLINE_VCR env var is not set.
|
||||
if (process.env.VITEST !== "true") {
|
||||
await initVcr(process.env.CLINE_VCR)
|
||||
program.parse()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
/**
|
||||
* VCR (Video Cassette Recorder) for HTTP requests.
|
||||
*
|
||||
* Patches `globalThis.fetch` to record and replay HTTP interactions,
|
||||
* enabling deterministic testing without making real API calls.
|
||||
*
|
||||
* Unlike nock (which patches Node's `http` module), this works by wrapping
|
||||
* `globalThis.fetch` directly — catching all HTTP traffic in this codebase
|
||||
* including calls made through the OpenAI, Anthropic, Gemini, and Vercel AI
|
||||
* SDKs (all of which delegate to the global fetch).
|
||||
*
|
||||
* Environment variables:
|
||||
* CLINE_VCR - "record" to record HTTP requests, "playback" to replay them
|
||||
* CLINE_VCR_CASSETTE - Path to the cassette file (default: ./vcr-cassette.json)
|
||||
* CLINE_VCR_FILTER - Substring to filter recorded/replayed request paths.
|
||||
* When set to a non-empty string, only requests whose path
|
||||
* contains this substring are recorded/replayed; all other
|
||||
* requests pass through to the real network.
|
||||
* When empty or unset, ALL requests are intercepted (no filter).
|
||||
* CLINE_VCR_SSE_DELAY - Milliseconds between SSE chunks during playback (default: 100).
|
||||
* Set to 0 for instant delivery.
|
||||
*
|
||||
* Usage:
|
||||
* # Record only inference requests
|
||||
* CLINE_VCR=record CLINE_VCR_CASSETTE=./fixtures/my-test.json clite task "hello"
|
||||
*
|
||||
* # Replay — auth/S3/etc. requests go through normally, only inference is mocked
|
||||
* CLINE_VCR=playback CLINE_VCR_CASSETTE=./fixtures/my-test.json clite task "hello"
|
||||
*
|
||||
* # Record everything (no filter)
|
||||
* CLINE_VCR=record CLINE_VCR_FILTER="" CLINE_VCR_CASSETTE=./fixtures/all.json clite task "hello"
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { dirname, resolve } from "node:path"
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
type VcrMode = "record" | "playback"
|
||||
|
||||
/** A single recorded HTTP interaction (nock-compatible shape). */
|
||||
export interface VcrRecording {
|
||||
scope: string
|
||||
method: string
|
||||
path: string
|
||||
body?: string
|
||||
status: number
|
||||
response: unknown
|
||||
responseIsBinary: boolean
|
||||
/** Content-Type header from the original response (captured at record time). */
|
||||
contentType?: string
|
||||
}
|
||||
|
||||
interface VcrConfig {
|
||||
mode: VcrMode
|
||||
cassettePath: string
|
||||
/**
|
||||
* Only record/replay requests whose path includes this substring.
|
||||
* Empty string ("") means no filtering — ALL requests are intercepted.
|
||||
* A non-empty string enables selective mode where only matching requests
|
||||
* are intercepted and non-matching requests pass through to the real network.
|
||||
*/
|
||||
filter: string
|
||||
}
|
||||
|
||||
// ── Sensitive data sanitization ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sanitization is key-based: any JSON key whose name matches a rule gets
|
||||
* its value redacted. This is more robust than regex-matching values,
|
||||
* because it works regardless of the value format.
|
||||
*
|
||||
* Three categories of keys are redacted:
|
||||
*
|
||||
* 1. **Exact key names** (case-insensitive) — secrets, tokens, credentials.
|
||||
* 2. **Key name patterns** (substring/suffix) — catches ID fields, PII, etc.
|
||||
* 3. **Value-level regex patterns** — for values embedded in plain strings
|
||||
* (e.g. filesystem paths, AWS key IDs in URLs).
|
||||
*
|
||||
* To add new sanitization rules, just add entries to the sets/arrays below.
|
||||
*/
|
||||
|
||||
/** Keys whose values are always fully redacted (case-insensitive exact match). */
|
||||
const REDACT_KEYS_EXACT = new Set([
|
||||
// Secrets & tokens
|
||||
"accesskeyid",
|
||||
"secretaccesskey",
|
||||
"idtoken",
|
||||
"refreshtoken",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"apikey",
|
||||
"api_key",
|
||||
"authorization",
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
// PII
|
||||
"email",
|
||||
"displayname",
|
||||
"display_name",
|
||||
])
|
||||
|
||||
/**
|
||||
* Keys whose values are redacted if the key name ends with or contains
|
||||
* one of these substrings (case-insensitive). Catches fields like
|
||||
* "userId", "organizationId", "memberId", "sessionId", etc.
|
||||
*/
|
||||
const REDACT_KEY_SUFFIXES = [
|
||||
"id", // matches *Id, *_id — covers most entity identifiers
|
||||
"balance",
|
||||
"cost",
|
||||
"secret",
|
||||
]
|
||||
|
||||
/** Check whether a key name should have its value redacted. */
|
||||
function shouldRedactKey(key: string): boolean {
|
||||
const lower = key.toLowerCase()
|
||||
if (REDACT_KEYS_EXACT.has(lower)) {
|
||||
return true
|
||||
}
|
||||
for (const suffix of REDACT_KEY_SUFFIXES) {
|
||||
// Match "userId", "user_id", "id" but not "video" or "valid"
|
||||
if (lower === suffix) {
|
||||
return true
|
||||
}
|
||||
// camelCase: ends with "Id", "Balance", etc.
|
||||
if (lower.endsWith(suffix) && lower.length > suffix.length) {
|
||||
const charBefore = lower[lower.length - suffix.length - 1]
|
||||
// Must be preceded by a word boundary character (_, -, or uppercase transition)
|
||||
if (charBefore === "_" || charBefore === "-") {
|
||||
return true
|
||||
}
|
||||
// camelCase: the suffix starts with lowercase but original key has uppercase
|
||||
const originalChar = key[key.length - suffix.length]
|
||||
if (originalChar && originalChar === originalChar.toUpperCase() && originalChar !== originalChar.toLowerCase()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// snake_case: ends with "_id", "_balance", etc.
|
||||
if (lower.endsWith(`_${suffix}`)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Regex patterns applied to plain string values (not key-based). */
|
||||
const SENSITIVE_VALUE_PATTERNS: { pattern: RegExp; replacement: string }[] = [
|
||||
// AWS access key IDs
|
||||
{ pattern: /AKIA[A-Z0-9]{16}/g, replacement: "AKIA_REDACTED" },
|
||||
// Filesystem paths with usernames
|
||||
{ pattern: /\/Users\/[A-Za-z0-9._-]+/g, replacement: "/Users/REDACTED_USER" },
|
||||
{ pattern: /\/home\/[A-Za-z0-9._-]+/g, replacement: "/home/REDACTED_USER" },
|
||||
]
|
||||
|
||||
/** Apply value-level regex sanitization to a plain string. */
|
||||
function sanitizeStringValue(input: string): string {
|
||||
let result = input
|
||||
for (const { pattern, replacement } of SENSITIVE_VALUE_PATTERNS) {
|
||||
result = result.replace(pattern, replacement)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Path-level patterns for normalizing request paths in recordings.
|
||||
* These replace dynamic path segments with stable test values so that
|
||||
* playback matching works across different environments/users.
|
||||
*
|
||||
* Patterns are applied in order — more specific patterns should come first.
|
||||
*/
|
||||
const PATH_NORMALIZATION_PATTERNS: { pattern: RegExp; replacement: string }[] = [
|
||||
// S3-style task artifact paths: /tasks/<userId>/<taskId>/api_conversation_history.json
|
||||
{
|
||||
pattern: /tasks\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\/api_conversation_history/g,
|
||||
replacement: "tasks/usr-test/taskid/api_conversation_history",
|
||||
},
|
||||
// Prefixed entity IDs in path segments (org-XXX, usr-XXX, mbr-XXX, ses-XXX, etc.)
|
||||
// Matches common Cline ID formats: prefix + ULID/UUID-like suffix
|
||||
{
|
||||
pattern: /\/(org|usr|mbr|ses|gen|req|msg|tsk|sch|exe|srv|cli|wkr|evt|sub|tkn)-[A-Za-z0-9]{10,}(?=[/?#]|$)/g,
|
||||
replacement: "/$1-REDACTED",
|
||||
},
|
||||
]
|
||||
|
||||
/** Normalize a request path for stable matching. */
|
||||
function normalizePath(input: string): string {
|
||||
let result = input
|
||||
for (const { pattern, replacement } of PATH_NORMALIZATION_PATTERNS) {
|
||||
result = result.replace(pattern, replacement)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-sanitize a value, redacting sensitive keys and patterns.
|
||||
* Handles objects, arrays, plain strings, and JSON-encoded strings.
|
||||
*/
|
||||
function sanitizeValue(obj: unknown): unknown {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (typeof obj === "string") {
|
||||
// Try to parse as JSON and sanitize recursively
|
||||
try {
|
||||
const parsed = JSON.parse(obj)
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
return JSON.stringify(sanitizeValue(parsed))
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — apply string-level patterns
|
||||
}
|
||||
return sanitizeStringValue(obj)
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(sanitizeValue)
|
||||
}
|
||||
|
||||
if (typeof obj === "object") {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||
if (shouldRedactKey(key) && (typeof value === "string" || typeof value === "number")) {
|
||||
result[key] = "REDACTED"
|
||||
} else {
|
||||
result[key] = sanitizeValue(value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
/** Sanitize a single recorded interaction, stripping sensitive data. */
|
||||
function sanitizeRecording(rec: VcrRecording): VcrRecording {
|
||||
const cleaned = { ...rec }
|
||||
|
||||
// Remove request body (may contain prompts, API keys, etc.)
|
||||
if (cleaned.body) {
|
||||
delete cleaned.body
|
||||
}
|
||||
|
||||
// Normalize the request path for stable matching
|
||||
if (typeof cleaned.path === "string") {
|
||||
cleaned.path = normalizePath(cleaned.path)
|
||||
}
|
||||
|
||||
// Deep-sanitize response body
|
||||
if (cleaned.response !== undefined) {
|
||||
cleaned.response = sanitizeValue(cleaned.response)
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// ── URL helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function parseScope(url: string): { scope: string; path: string } {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
const scope = `${parsed.protocol}//${parsed.host}`
|
||||
const path = parsed.pathname + parsed.search
|
||||
return { scope, path }
|
||||
} catch {
|
||||
return { scope: "", path: url }
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRequestUrl(input: string | URL | Request): string {
|
||||
if (typeof input === "string") {
|
||||
return input
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input.toString()
|
||||
}
|
||||
if (input && typeof (input as Request).url === "string") {
|
||||
return (input as Request).url
|
||||
}
|
||||
return String(input)
|
||||
}
|
||||
|
||||
function resolveRequestMethod(input: string | URL | Request, init?: RequestInit): string {
|
||||
if (init?.method) {
|
||||
return init.method.toUpperCase()
|
||||
}
|
||||
if (input && typeof (input as Request).method === "string") {
|
||||
return (input as Request).method.toUpperCase()
|
||||
}
|
||||
return "GET"
|
||||
}
|
||||
|
||||
// ── Config resolution ───────────────────────────────────────────────────
|
||||
|
||||
function getVcrConfig(vcrMode: string | undefined): VcrConfig | null {
|
||||
if (!vcrMode) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!process.env.CLINE_VCR_CASSETTE) {
|
||||
process.stderr.write("[VCR] No CLINE_VCR_CASSETTE: requests will not be recorded or played back.\n")
|
||||
return null
|
||||
}
|
||||
|
||||
if (vcrMode !== "record" && vcrMode !== "playback") {
|
||||
process.stderr.write(`[VCR] Invalid CLINE_VCR value: "${vcrMode}". Expected "record" or "playback".\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const cassettePath = resolve(process.env.CLINE_VCR_CASSETTE)
|
||||
const filter = process.env.CLINE_VCR_FILTER ?? ""
|
||||
|
||||
return { mode: vcrMode, cassettePath, filter }
|
||||
}
|
||||
|
||||
// ── Record mode ─────────────────────────────────────────────────────────
|
||||
|
||||
/** An in-progress stream capture that can be finalized synchronously. */
|
||||
interface InFlightCapture {
|
||||
scope: string
|
||||
method: string
|
||||
path: string
|
||||
body: string
|
||||
status: number
|
||||
contentType: string | undefined
|
||||
chunks: Uint8Array[]
|
||||
finalized: boolean
|
||||
}
|
||||
|
||||
function startRecordingRequests(cassettePath: string, filter: string): void {
|
||||
const recordings: VcrRecording[] = []
|
||||
/** Streams still being consumed — finalized on flush or on process exit. */
|
||||
const inFlight: InFlightCapture[] = []
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
/** Convert accumulated chunks into a recording entry. */
|
||||
function finalizeCapture(capture: InFlightCapture): void {
|
||||
if (capture.finalized) {
|
||||
return
|
||||
}
|
||||
capture.finalized = true
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
const bodyText = capture.chunks.map((c) => decoder.decode(c, { stream: true })).join("") + decoder.decode()
|
||||
|
||||
let responseBody: unknown
|
||||
try {
|
||||
responseBody = JSON.parse(bodyText)
|
||||
} catch {
|
||||
responseBody = bodyText
|
||||
}
|
||||
|
||||
recordings.push({
|
||||
scope: capture.scope,
|
||||
method: capture.method,
|
||||
path: capture.path,
|
||||
body: capture.body,
|
||||
status: capture.status,
|
||||
response: responseBody,
|
||||
responseIsBinary: false,
|
||||
contentType: capture.contentType,
|
||||
})
|
||||
}
|
||||
|
||||
globalThis.fetch = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
const url = resolveRequestUrl(input)
|
||||
const method = resolveRequestMethod(input, init)
|
||||
const { scope, path } = parseScope(url)
|
||||
|
||||
// Capture request body
|
||||
let requestBody: string | undefined
|
||||
if (init?.body) {
|
||||
requestBody =
|
||||
typeof init.body === "string"
|
||||
? init.body
|
||||
: init.body instanceof ArrayBuffer
|
||||
? new TextDecoder().decode(init.body)
|
||||
: undefined
|
||||
} else if (input instanceof Request) {
|
||||
try {
|
||||
requestBody = await input.clone().text()
|
||||
} catch {
|
||||
// Ignore if body can't be read
|
||||
}
|
||||
}
|
||||
|
||||
// Call real fetch
|
||||
const response = await originalFetch(input, init)
|
||||
|
||||
// Check filter
|
||||
if (filter && !path.includes(filter)) {
|
||||
return response
|
||||
}
|
||||
|
||||
// Capture content-type from the real response
|
||||
const contentType = response.headers.get("content-type") ?? undefined
|
||||
|
||||
// No body — record immediately
|
||||
if (!response.body) {
|
||||
recordings.push({
|
||||
scope,
|
||||
method,
|
||||
path,
|
||||
body: requestBody ?? "",
|
||||
status: response.status,
|
||||
response: "",
|
||||
responseIsBinary: false,
|
||||
contentType,
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
// Wrap the response body with a TransformStream that captures
|
||||
// chunks as the caller consumes them. The capture is tracked in
|
||||
// `inFlight` so the exit handler can finalize it even if the
|
||||
// stream hasn't completed (e.g. process.exit() during SSE).
|
||||
const capture: InFlightCapture = {
|
||||
scope,
|
||||
method,
|
||||
path,
|
||||
body: requestBody ?? "",
|
||||
status: response.status,
|
||||
contentType,
|
||||
chunks: [],
|
||||
finalized: false,
|
||||
}
|
||||
inFlight.push(capture)
|
||||
|
||||
const originalBody = response.body
|
||||
const transform = new TransformStream<Uint8Array, Uint8Array>({
|
||||
transform(chunk, controller) {
|
||||
capture.chunks.push(chunk)
|
||||
controller.enqueue(chunk)
|
||||
},
|
||||
flush() {
|
||||
finalizeCapture(capture)
|
||||
},
|
||||
})
|
||||
|
||||
const wrappedBody = originalBody.pipeThrough(transform)
|
||||
|
||||
return new Response(wrappedBody, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
})
|
||||
}
|
||||
|
||||
const filterDesc = filter ? `matching path "*${filter}*"` : "all paths"
|
||||
process.stderr.write(`[VCR] Recording HTTP requests (${filterDesc}). Cassette will be saved to: ${cassettePath}\n`)
|
||||
|
||||
// Save recordings — finalizes any in-flight stream captures first
|
||||
let saved = false
|
||||
const saveRecordings = () => {
|
||||
if (saved) {
|
||||
return
|
||||
}
|
||||
saved = true
|
||||
|
||||
// Restore original fetch
|
||||
globalThis.fetch = originalFetch
|
||||
|
||||
// Finalize any in-flight stream captures with whatever data
|
||||
// has been received so far (critical for SSE streams that may
|
||||
// still be open when process.exit() is called).
|
||||
for (const capture of inFlight) {
|
||||
finalizeCapture(capture)
|
||||
}
|
||||
|
||||
if (recordings.length === 0) {
|
||||
process.stderr.write(`[VCR] No HTTP requests${filter ? ` matching "${filter}"` : ""} were recorded.\n`)
|
||||
return
|
||||
}
|
||||
|
||||
const dir = dirname(cassettePath)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
|
||||
const sanitized = recordings.map(sanitizeRecording)
|
||||
writeFileSync(cassettePath, JSON.stringify(sanitized, null, 2))
|
||||
process.stderr.write(`[VCR] Saved ${sanitized.length} recorded HTTP interaction(s) to ${cassettePath}\n`)
|
||||
}
|
||||
|
||||
process.on("exit", saveRecordings)
|
||||
process.on("SIGTERM", () => {
|
||||
saveRecordings()
|
||||
process.exit(0)
|
||||
})
|
||||
process.on("SIGINT", () => {
|
||||
saveRecordings()
|
||||
process.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Playback mode ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Split an SSE response body into individual event chunks.
|
||||
* Each chunk is a complete "data: ...\n\n" segment.
|
||||
*/
|
||||
function splitSseChunks(body: string): string[] {
|
||||
// Split on double-newline boundaries that separate SSE events
|
||||
const chunks: string[] = []
|
||||
const parts = body.split(/\n\n/)
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim()
|
||||
if (trimmed) {
|
||||
chunks.push(`${trimmed}\n\n`)
|
||||
}
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ReadableStream that delivers SSE chunks with a delay between each.
|
||||
*/
|
||||
function createDelayedSseStream(chunks: string[], delayMs: number): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder()
|
||||
let index = 0
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
if (index >= chunks.length) {
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
if (index > 0 && delayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
controller.enqueue(encoder.encode(chunks[index]!))
|
||||
index += 1
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function startPlayingBackRequests(cassettePath: string, filter: string): void {
|
||||
if (!existsSync(cassettePath)) {
|
||||
process.stderr.write(`[VCR] Cassette file not found: ${cassettePath}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const recordings: VcrRecording[] = JSON.parse(readFileSync(cassettePath, "utf-8"))
|
||||
|
||||
const sseDelayMs = Number.parseInt(process.env.CLINE_VCR_SSE_DELAY ?? "100", 10)
|
||||
|
||||
// Track which recordings have been consumed (each can be used once)
|
||||
const consumed = new Array<boolean>(recordings.length).fill(false)
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
globalThis.fetch = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
const url = resolveRequestUrl(input)
|
||||
const method = resolveRequestMethod(input, init)
|
||||
const { path } = parseScope(url)
|
||||
const normalizedPath = normalizePath(path)
|
||||
|
||||
// Check filter: if filter is set and path doesn't match, passthrough
|
||||
if (filter && !path.includes(filter)) {
|
||||
return originalFetch(input, init)
|
||||
}
|
||||
|
||||
// Find a matching unconsumed recording
|
||||
const matchIndex = recordings.findIndex((rec, index) => {
|
||||
if (consumed[index]) {
|
||||
return false
|
||||
}
|
||||
// Match on method + normalized path. Scope is checked loosely
|
||||
// (hostname may differ between record and playback environments).
|
||||
const recNormalizedPath = normalizePath(rec.path)
|
||||
return rec.method.toUpperCase() === method && recNormalizedPath === normalizedPath
|
||||
})
|
||||
|
||||
if (matchIndex >= 0) {
|
||||
consumed[matchIndex] = true
|
||||
const rec = recordings[matchIndex]!
|
||||
|
||||
// Build response body
|
||||
const body = typeof rec.response === "string" ? rec.response : JSON.stringify(rec.response)
|
||||
|
||||
// Use recorded content-type if available, otherwise infer from response shape
|
||||
const headers = new Headers()
|
||||
if (rec.contentType) {
|
||||
headers.set("content-type", rec.contentType)
|
||||
} else {
|
||||
// Fallback heuristic for cassettes recorded before contentType was captured
|
||||
const isSSE = typeof rec.response === "string" && rec.response.trimStart().startsWith("data:")
|
||||
if (isSSE) {
|
||||
headers.set("content-type", "text/event-stream")
|
||||
} else if (typeof rec.response === "object") {
|
||||
headers.set("content-type", "application/json")
|
||||
}
|
||||
}
|
||||
|
||||
const isSSEResponse = headers.get("content-type")?.includes("text/event-stream") ?? false
|
||||
|
||||
// SSE responses need streaming-friendly headers
|
||||
if (isSSEResponse) {
|
||||
headers.set("cache-control", "no-cache")
|
||||
headers.set("connection", "keep-alive")
|
||||
}
|
||||
|
||||
// For SSE responses, stream chunks with a delay to simulate
|
||||
// real-time delivery (controlled by CLINE_VCR_SSE_DELAY).
|
||||
if (isSSEResponse && typeof rec.response === "string") {
|
||||
const chunks = splitSseChunks(rec.response)
|
||||
if (chunks.length > 1) {
|
||||
const stream = createDelayedSseStream(chunks, sseDelayMs)
|
||||
return new Response(stream, {
|
||||
status: rec.status,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(body, {
|
||||
status: rec.status,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
// No match found
|
||||
if (!filter) {
|
||||
// Full isolation mode — no filter means nothing should leak
|
||||
throw new Error(
|
||||
`[VCR] No matching recording for ${method} ${url} (path: ${normalizedPath}). ` +
|
||||
`${recordings.length} recording(s) loaded from ${cassettePath}.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Filtered mode — passthrough non-matching requests
|
||||
return originalFetch(input, init)
|
||||
}
|
||||
|
||||
const filterDesc = filter
|
||||
? `(only paths matching "*${filter}*", all other requests go through normally)`
|
||||
: "(all requests intercepted)"
|
||||
process.stderr.write(
|
||||
`[VCR] Playing back ${recordings.length} recorded HTTP interaction(s) from ${cassettePath} ${filterDesc}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initialize VCR mode based on environment variables.
|
||||
* Must be called early in startup, before HTTP requests are made.
|
||||
*
|
||||
* Does nothing if `CLINE_VCR` is not set.
|
||||
*/
|
||||
export function initVcr(vcrMode: string | undefined): void {
|
||||
const config = getVcrConfig(vcrMode)
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.mode === "record") {
|
||||
startRecordingRequests(config.cassettePath, config.filter)
|
||||
} else {
|
||||
startPlayingBackRequests(config.cassettePath, config.filter)
|
||||
}
|
||||
}
|
||||
Generated
+81
-29
@@ -112,7 +112,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.14",
|
||||
"@bufbuild/buf": "^1.54.0",
|
||||
"@microsoft/tui-test": "0.0.1",
|
||||
"@microsoft/tui-test": "0.0.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/chai": "^5.0.1",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
@@ -3718,9 +3718,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/tui-test": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/tui-test/-/tui-test-0.0.1.tgz",
|
||||
"integrity": "sha512-rbZuhFkWi6jz0M/56ESpw7IPt84Bl6C1i3fnNboeqz4WBQCxehZFFfqgu4Tfnr4PuWLfi1hMYwbJYCMwTxXMZQ==",
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/tui-test/-/tui-test-0.0.2.tgz",
|
||||
"integrity": "sha512-4HcPhG9BJgvYeEBSKfOJAEP1jMSWmZ5+I3Nm+okyYlZ8w237n9ZmaM4ZC7xTSrjOBME/xKy8p14ontlbmB+BMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5626,7 +5626,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.57.1",
|
||||
@@ -5639,7 +5640,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.57.1",
|
||||
@@ -5652,7 +5654,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.57.1",
|
||||
@@ -5665,7 +5668,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.57.1",
|
||||
@@ -5678,7 +5682,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.57.1",
|
||||
@@ -5691,7 +5696,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.57.1",
|
||||
@@ -5704,7 +5710,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.57.1",
|
||||
@@ -5717,7 +5724,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.57.1",
|
||||
@@ -5730,7 +5738,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.57.1",
|
||||
@@ -5743,7 +5752,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.57.1",
|
||||
@@ -5756,7 +5766,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.57.1",
|
||||
@@ -5769,7 +5780,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.57.1",
|
||||
@@ -5782,7 +5794,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.57.1",
|
||||
@@ -5795,7 +5808,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.57.1",
|
||||
@@ -5808,7 +5822,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.57.1",
|
||||
@@ -5821,7 +5836,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.57.1",
|
||||
@@ -5834,7 +5850,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
@@ -5847,7 +5864,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.57.1",
|
||||
@@ -5860,7 +5878,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.57.1",
|
||||
@@ -5873,7 +5892,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.57.1",
|
||||
@@ -5886,7 +5906,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.57.1",
|
||||
@@ -5899,7 +5920,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.57.1",
|
||||
@@ -5912,7 +5934,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
@@ -5925,7 +5948,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.57.1",
|
||||
@@ -5938,7 +5962,8 @@
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
],
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@sap-ai-sdk/ai-api": {
|
||||
"version": "2.7.0",
|
||||
@@ -22549,6 +22574,7 @@
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22565,6 +22591,7 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22581,6 +22608,7 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22597,6 +22625,7 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22613,6 +22642,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22629,6 +22659,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22645,6 +22676,7 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22661,6 +22693,7 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22677,6 +22710,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22693,6 +22727,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22709,6 +22744,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22725,6 +22761,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22741,6 +22778,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22757,6 +22795,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22773,6 +22812,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22789,6 +22829,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22805,6 +22846,7 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22821,6 +22863,7 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22837,6 +22880,7 @@
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22853,6 +22897,7 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22869,6 +22914,7 @@
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22885,6 +22931,7 @@
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22901,6 +22948,7 @@
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22917,6 +22965,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22933,6 +22982,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -22949,6 +22999,7 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -23004,6 +23055,7 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
|
||||
+1
-1
@@ -461,7 +461,7 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.3.14",
|
||||
"@bufbuild/buf": "^1.54.0",
|
||||
"@microsoft/tui-test": "0.0.1",
|
||||
"@microsoft/tui-test": "0.0.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/chai": "^5.0.1",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
|
||||
+9
-5
@@ -112,18 +112,23 @@ let mockFetch: typeof globalThis.fetch | undefined
|
||||
export const fetch: typeof globalThis.fetch = (() => {
|
||||
// Note: Don't use Logger here; it may not be initialized.
|
||||
|
||||
let baseFetch: typeof globalThis.fetch = globalThis.fetch
|
||||
let baseFetch: typeof globalThis.fetch | null = null
|
||||
// Note: See esbuild.mjs, process.env.IS_STANDALONE is statically rewritten
|
||||
// to "true" or "false" (as strings) in the JetBrains/CLI build.
|
||||
// We must use explicit string comparison because "false" is truthy in JS.
|
||||
if (process.env.IS_STANDALONE === "true") {
|
||||
if (process.env.IS_STANDALONE === "true" && !process.env.CLINE_VCR) {
|
||||
// Configure undici with ProxyAgent
|
||||
// Skip when CLINE_VCR is set so VCR can intercept via globalThis.fetch
|
||||
const agent = new EnvHttpProxyAgent({})
|
||||
setGlobalDispatcher(agent)
|
||||
baseFetch = undiciFetch as any as typeof globalThis.fetch
|
||||
}
|
||||
|
||||
return (input: string | URL | Request, init?: RequestInit): Promise<Response> => (mockFetch || baseFetch)(input, init)
|
||||
// When CLINE_VCR is set, baseFetch is null and we reference globalThis.fetch
|
||||
// lazily at call time. This is required because VCR patches globalThis.fetch
|
||||
// AFTER module initialization, so an eagerly-captured reference would bypass VCR.
|
||||
return (input: string | URL | Request, init?: RequestInit): Promise<Response> =>
|
||||
(mockFetch || baseFetch || globalThis.fetch)(input, init)
|
||||
})()
|
||||
|
||||
/**
|
||||
@@ -145,9 +150,8 @@ export function mockFetchForTesting<T>(theFetch: typeof globalThis.fetch, callba
|
||||
return result.finally(() => {
|
||||
mockFetch = originalMockFetch
|
||||
}) as typeof result
|
||||
} else {
|
||||
return result
|
||||
}
|
||||
return result
|
||||
} finally {
|
||||
if (willResetSync) {
|
||||
mockFetch = originalMockFetch
|
||||
|
||||
@@ -59,6 +59,16 @@ export interface StorageContextOptions {
|
||||
* once the JetBrains client side is cleaned up.
|
||||
*/
|
||||
workspaceStorageDir?: string
|
||||
|
||||
/**
|
||||
* Override the path to the secrets file. Defaults to CLINE_SECRETS_FILE
|
||||
* env var or `<dataDir>/secrets.json`.
|
||||
*
|
||||
* Used by e2e tests during VCR recording to read real API keys from
|
||||
* ~/.cline/data/secrets.json while keeping all other config from a
|
||||
* mock test directory.
|
||||
*/
|
||||
secretsFile?: string
|
||||
}
|
||||
|
||||
const SETTINGS_SUBFOLDER = "data"
|
||||
@@ -113,10 +123,12 @@ export function createStorageContext(opts: StorageContextOptions = {}): StorageC
|
||||
|
||||
const globalState = new ClineFileStorage(path.join(dataDir, "globalState.json"), "GlobalState")
|
||||
|
||||
const secretsFile = opts.secretsFile || process.env.CLINE_SECRETS_FILE || path.join(dataDir, "secrets.json")
|
||||
|
||||
return {
|
||||
globalState,
|
||||
globalStateBackingStore: globalState,
|
||||
secrets: new ClineFileStorage<string>(path.join(dataDir, "secrets.json"), "Secrets", {
|
||||
secrets: new ClineFileStorage<string>(secretsFile, "Secrets", {
|
||||
fileMode: 0o600, // Owner read/write only — protects API keys
|
||||
}),
|
||||
workspaceState: new ClineFileStorage(path.join(workspaceDir, "workspaceState.json"), "WorkspaceState"),
|
||||
|
||||
@@ -10,14 +10,11 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { test } from "@microsoft/tui-test"
|
||||
import { CLINE_BIN, TERMINAL_WIDE } from "../helpers/constants.js"
|
||||
import { CLINE_BIN, EXIT_CODE_SUCCESS, TERMINAL_WIDE } from "../helpers/constants.js"
|
||||
import { clineEnv } from "../helpers/env.js"
|
||||
import { waitForAuthScreen } from "../helpers/page-objects/auth.js"
|
||||
import { expectVisible } from "../helpers/terminal.js"
|
||||
import { expectExitCode, expectVisible } from "../helpers/terminal.js"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline auth (interactive screen — no flags)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline auth (interactive screen)", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["auth"] },
|
||||
@@ -33,14 +30,10 @@ test.describe("cline auth (interactive screen)", () => {
|
||||
await waitForAuthScreen(terminal)
|
||||
terminal.keyDown()
|
||||
terminal.keyUp()
|
||||
// Still on the auth screen after navigation
|
||||
await expectVisible(terminal, "Sign in with Cline")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline auth --help
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline auth --help", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["auth", "--help"] },
|
||||
@@ -49,11 +42,7 @@ test.describe("cline auth --help", () => {
|
||||
})
|
||||
|
||||
test("shows auth help page", async ({ terminal }) => {
|
||||
await expectVisible(terminal, "Usage:")
|
||||
await expectVisible(terminal, "--provider")
|
||||
await expectVisible(terminal, "--apikey")
|
||||
await expectVisible(terminal, "--modelid")
|
||||
await expectVisible(terminal, "--baseurl")
|
||||
await expectVisible(terminal, ["Usage:", "--provider", "--apikey", "--modelid", "--baseurl"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -132,10 +121,6 @@ test.describe("cline auth --verbose only (partial flags)", () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline auth --cwd
|
||||
// User sees interactive auth screen; after authing, footer shows workspace dir
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline auth --cwd", () => {
|
||||
test.use({
|
||||
program: {
|
||||
@@ -151,11 +136,6 @@ test.describe("cline auth --cwd", () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline auth --config <dir>
|
||||
// User sees interactive auth screen; after authing, custom config dir exists
|
||||
// with globalState.json and secrets.json; default ~/.cline does NOT exist
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline auth --config", () => {
|
||||
test.use({
|
||||
program: {
|
||||
@@ -171,10 +151,87 @@ test.describe("cline auth --config", () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline auth -p <invalid-provider> -k <key> -m <model>
|
||||
// → should show "invalid provider" and exit 1
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline auth -p -k -m (golden path)", () => {
|
||||
test.use({
|
||||
program: {
|
||||
file: CLINE_BIN,
|
||||
args: ["auth", "--provider", "openai", "--apikey", "sk-test-key-12345", "--modelid", "gpt-4o"],
|
||||
},
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("unauthenticated"),
|
||||
})
|
||||
|
||||
test("exits successfully with valid provider, key, and model", async ({ terminal }) => {
|
||||
// Golden path: should not show interactive auth screen, should exit cleanly
|
||||
await expectExitCode(terminal, EXIT_CODE_SUCCESS)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("cline auth with invalid key (still exits 0)", () => {
|
||||
test.use({
|
||||
program: {
|
||||
file: CLINE_BIN,
|
||||
args: ["auth", "--provider", "openai", "--apikey", "invalid-key", "--modelid", "gpt-4o"],
|
||||
},
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("unauthenticated"),
|
||||
})
|
||||
|
||||
test("accepts invalid key without error at auth time", async ({ terminal }) => {
|
||||
await expectExitCode(terminal, EXIT_CODE_SUCCESS)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("cline auth -p -k -m -b (golden path with baseUrl)", () => {
|
||||
test.use({
|
||||
program: {
|
||||
file: CLINE_BIN,
|
||||
args: [
|
||||
"auth",
|
||||
"--provider",
|
||||
"openai",
|
||||
"--apikey",
|
||||
"sk-test-key-12345",
|
||||
"--modelid",
|
||||
"gpt-4o",
|
||||
"--baseurl",
|
||||
"https://api.example.com/v1",
|
||||
],
|
||||
},
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("unauthenticated"),
|
||||
})
|
||||
|
||||
test("exits successfully with baseUrl for OpenAI provider", async ({ terminal }) => {
|
||||
await expectExitCode(terminal, EXIT_CODE_SUCCESS)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("cline auth --baseurl with non-OpenAI-compatible provider", () => {
|
||||
test.use({
|
||||
program: {
|
||||
file: CLINE_BIN,
|
||||
args: [
|
||||
"auth",
|
||||
"--provider",
|
||||
"anthropic",
|
||||
"--apikey",
|
||||
"sk-ant-test",
|
||||
"--modelid",
|
||||
"claude-sonnet-4-20250514",
|
||||
"--baseurl",
|
||||
"https://api.example.com",
|
||||
],
|
||||
},
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("unauthenticated"),
|
||||
})
|
||||
|
||||
test("shows error for baseUrl with non-OpenAI provider", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /only supported for openai|not supported|openai.compatible/i)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("cline auth with invalid provider", () => {
|
||||
test.use({
|
||||
program: {
|
||||
@@ -186,6 +243,6 @@ test.describe("cline auth with invalid provider", () => {
|
||||
})
|
||||
|
||||
test("shows invalid provider error", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /invalid provider/i, { timeout: 5000 })
|
||||
await expectVisible(terminal, /invalid provider/i)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,9 +11,6 @@ import { CLINE_BIN, TERMINAL_WIDE } from "../helpers/constants.js"
|
||||
import { clineEnv } from "../helpers/env.js"
|
||||
import { expectVisible } from "../helpers/terminal.js"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline config --help
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline config --help", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["config", "--help"] },
|
||||
@@ -22,21 +19,21 @@ test.describe("cline config --help", () => {
|
||||
})
|
||||
|
||||
test("shows config help page", async ({ terminal }) => {
|
||||
await expectVisible(terminal, "Usage:")
|
||||
await expectVisible(terminal, "--config")
|
||||
await expectVisible(terminal, ["Usage:", "--config"])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline config --config <dir>
|
||||
// Shows interactive config view for the specified directory
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline config (default config)", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["config"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
|
||||
test("shows interactive config view for default config", async ({ terminal }) => {
|
||||
// Config view should display provider/model settings from the default config
|
||||
await expectVisible(terminal, /config|settings|provider|model/i)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("cline config --config (claude-sonnet-4.6)", () => {
|
||||
@@ -48,4 +45,9 @@ test.describe("cline config --config (claude-sonnet-4.6)", () => {
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
})
|
||||
|
||||
test("shows interactive config view for claude-sonnet-4.6 config", async ({ terminal }) => {
|
||||
// Different config dir should show different configuration
|
||||
await expectVisible(terminal, /config|settings|provider|model/i)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline dev — CLI tests
|
||||
//
|
||||
// Covers:
|
||||
// - `cline dev log`
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { test } from "@microsoft/tui-test"
|
||||
import { CLINE_BIN, TERMINAL_WIDE } from "../helpers/constants.js"
|
||||
import { clineEnv } from "../helpers/env.js"
|
||||
|
||||
test.describe("cline dev log", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["dev", "log"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
})
|
||||
@@ -13,12 +13,9 @@
|
||||
import { test } from "@microsoft/tui-test"
|
||||
import { CLINE_BIN, TERMINAL_WIDE } from "../helpers/constants.js"
|
||||
import { clineEnv } from "../helpers/env.js"
|
||||
import { expectVisible } from "../utils.js"
|
||||
import { waitForChatReady } from "../helpers/page-objects/chat.js"
|
||||
import { expectVisible } from "../helpers/terminal.js"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline --act
|
||||
// Starts cline in Act mode regardless of globalState
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline --act", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["--act"] },
|
||||
@@ -27,14 +24,10 @@ test.describe("cline --act", () => {
|
||||
})
|
||||
|
||||
test("starts in Act mode", async ({ terminal }) => {
|
||||
await expectVisible(terminal, "Act")
|
||||
await expectVisible(terminal, "○ Plan ● Act")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline --plan
|
||||
// Starts cline in Plan mode regardless of globalState
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline --plan", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["--plan"] },
|
||||
@@ -43,30 +36,10 @@ test.describe("cline --plan", () => {
|
||||
})
|
||||
|
||||
test("starts in Plan mode", async ({ terminal }) => {
|
||||
await expectVisible(terminal, "Plan")
|
||||
await expectVisible(terminal, "● Plan ○ Act")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline --timeout <n> ⚠️
|
||||
// Current behavior: starts interactive mode and ignores timeout value
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline --timeout (interactive mode, flag ignored) ⚠️", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["--timeout", "30"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
|
||||
test("starts interactive mode (timeout value currently ignored)", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /what can i do|plan|act/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline --model <model-id> ⚠️
|
||||
// Current behavior: starts interactive mode and ignores model value
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline --model (interactive mode, flag ignored) ⚠️", () => {
|
||||
test.use({
|
||||
program: {
|
||||
@@ -78,30 +51,11 @@ test.describe("cline --model (interactive mode, flag ignored) ⚠️", () => {
|
||||
})
|
||||
|
||||
test("starts interactive mode (model value currently ignored)", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /what can i do|plan|act/i)
|
||||
await waitForChatReady(terminal)
|
||||
// TODO expect model id in the UI here
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline --verbose ⚠️
|
||||
// Current behavior: starts interactive mode and ignores verbose value
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline --verbose (interactive mode, flag ignored) ⚠️", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["--verbose"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
|
||||
test("starts interactive mode (verbose value currently ignored)", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /what can i do|plan|act/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline -c / cline --cwd <dir> ⚠️
|
||||
// Starts cline in interactive mode with the cwd present in the client footer
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline --cwd <dir>", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["--cwd", "/tmp"] },
|
||||
@@ -110,7 +64,8 @@ test.describe("cline --cwd <dir>", () => {
|
||||
})
|
||||
|
||||
test("starts interactive mode with --cwd flag", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /what can i do|plan|act/i)
|
||||
await waitForChatReady(terminal)
|
||||
// TODO expect working directory showing in the UI
|
||||
})
|
||||
})
|
||||
|
||||
@@ -122,14 +77,11 @@ test.describe("cline -c <dir> (short alias)", () => {
|
||||
})
|
||||
|
||||
test("starts interactive mode with -c flag", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /what can i do|plan|act/i)
|
||||
await waitForChatReady(terminal)
|
||||
// TODO expect working directory showing in the UI
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline --config <dir>
|
||||
// Starts cline in interactive mode using settings from the custom config dir
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline --config (claude-sonnet-4.6)", () => {
|
||||
test.use({
|
||||
program: {
|
||||
@@ -141,15 +93,10 @@ test.describe("cline --config (claude-sonnet-4.6)", () => {
|
||||
})
|
||||
|
||||
test("starts interactive mode with custom config directory", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /what can i do|plan|act/i)
|
||||
await expectVisible(terminal, "anthropic/claude-sonnet-4.6")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline --thinking ⚠️
|
||||
// Starts cline in interactive mode with thinking turned on regardless of globalState
|
||||
// (if thinking not supported, this flag is a no-op)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline --thinking ⚠️", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["--thinking"] },
|
||||
@@ -158,15 +105,10 @@ test.describe("cline --thinking ⚠️", () => {
|
||||
})
|
||||
|
||||
test("starts interactive mode with --thinking flag", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /what can i do|plan|act/i)
|
||||
await waitForChatReady(terminal)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline --reasoning-effort <level> ⚠️
|
||||
// Starts cline in interactive mode with reasoning turned on regardless of globalState
|
||||
// (if reasoning not supported, this flag is a no-op)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline --reasoning-effort ⚠️", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["--reasoning-effort", "high"] },
|
||||
@@ -175,7 +117,7 @@ test.describe("cline --reasoning-effort ⚠️", () => {
|
||||
})
|
||||
|
||||
test("starts interactive mode with --reasoning-effort flag", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /what can i do|plan|act/i)
|
||||
await waitForChatReady(terminal)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -193,7 +135,7 @@ test.describe("cline --max-consecutive-mistakes", () => {
|
||||
})
|
||||
|
||||
test("starts interactive mode with --max-consecutive-mistakes flag", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /what can i do|plan|act/i)
|
||||
await waitForChatReady(terminal)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -208,6 +150,58 @@ test.describe("cline --double-check-completion", () => {
|
||||
})
|
||||
|
||||
test("starts interactive mode with --double-check-completion flag", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /what can i do|plan|act/i)
|
||||
await waitForChatReady(terminal)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline --json "prompt"
|
||||
// Starts cline in headless yolo mode with all output conforming to JSON
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline --json (headless yolo mode)", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["--json", "tell me a joke"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("unauthenticated"),
|
||||
})
|
||||
|
||||
test("starts in headless yolo mode with JSON output", async ({ terminal }) => {
|
||||
// --json implies headless yolo; unauthenticated should produce a JSON-like error
|
||||
await expectVisible(terminal, /not authenticated/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline -T / cline --taskId <taskId>
|
||||
// Starts cline in interactive mode pre-populated with a prior task conversation
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline --taskId (resume existing task)", () => {
|
||||
test.use({
|
||||
program: {
|
||||
file: CLINE_BIN,
|
||||
args: ["--taskId", "1773351188846"],
|
||||
},
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
|
||||
test("starts interactive mode pre-populated with prior task conversation", async ({ terminal }) => {
|
||||
// Should show the chat view with the task's conversation loaded
|
||||
await expectVisible(terminal, /wezterm|task/i)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("cline -T (short alias for --taskId)", () => {
|
||||
test.use({
|
||||
program: {
|
||||
file: CLINE_BIN,
|
||||
args: ["-T", "1773351188846"],
|
||||
},
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
|
||||
test("starts interactive mode pre-populated with prior task via -T", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /wezterm|task/i)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,9 +13,6 @@ import { CLINE_BIN, TERMINAL_WIDE } from "../helpers/constants.js"
|
||||
import { clineEnv } from "../helpers/env.js"
|
||||
import { expectVisible } from "../helpers/terminal.js"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline history --help
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline history --help", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["history", "--help"] },
|
||||
@@ -24,9 +21,61 @@ test.describe("cline history --help", () => {
|
||||
})
|
||||
|
||||
test("shows history help page with all flags", async ({ terminal }) => {
|
||||
await expectVisible(terminal, "Usage:")
|
||||
await expectVisible(terminal, "--limit")
|
||||
await expectVisible(terminal, "--page")
|
||||
await expectVisible(terminal, "--config")
|
||||
await expectVisible(terminal, ["Usage:", "--limit", "--page", "--config"])
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("cline history --limit", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["history", "--limit", "1"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
|
||||
test("shows history limited to specified number of results", async ({ terminal }) => {
|
||||
// The default config has 2 tasks in taskHistory.json; with limit=1
|
||||
// we should see pagination or only 1 task entry per page
|
||||
await expectVisible(terminal, /history|task/i)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("cline history --page", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["history", "--page", "1"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
|
||||
test("shows history for the specified page", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /history|task/i)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("cline history --config (default)", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["history"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
|
||||
test("shows history for default config", async ({ terminal }) => {
|
||||
// Default config has tasks with "wezterm" in them
|
||||
await expectVisible(terminal, /history|task|wezterm/i)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("cline history --config (claude-sonnet-4.6)", () => {
|
||||
test.use({
|
||||
program: {
|
||||
file: CLINE_BIN,
|
||||
args: ["history", "--config", "configs/claude-sonnet-4.6"],
|
||||
},
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
})
|
||||
|
||||
test("shows different history for different config directory", async ({ terminal }) => {
|
||||
// The claude-sonnet-4.6 config has its own separate task history
|
||||
await expectVisible(terminal, /history|task/i)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,4 +19,4 @@ Tests point to a config via `clineEnv("<name>")` in `tests/e2e/cli/helpers/env.t
|
||||
## Secrets
|
||||
|
||||
API keys and secrets should never be committed.
|
||||
If you create authenticated fixtures locally, keep `data/secrets.json` untracked.
|
||||
If you create authenticated fixtures locally, keep `data/secrets.json` untracked or fill it with mock data
|
||||
|
||||
@@ -38,5 +38,7 @@
|
||||
"planModeClineModelId": "anthropic/claude-sonnet-4.6",
|
||||
"openAiHeaders": {},
|
||||
"sapAiCoreUseOrchestrationMode": true,
|
||||
"ocaMode": "internal"
|
||||
"ocaMode": "internal",
|
||||
"globalClineRulesToggles": {},
|
||||
"globalWorkflowToggles": {}
|
||||
}
|
||||
|
||||
@@ -58,5 +58,6 @@
|
||||
"primaryRootIndex": 0,
|
||||
"globalWorkflowToggles": {},
|
||||
"globalClineRulesToggles": {},
|
||||
"isNewUser": false
|
||||
"isNewUser": false,
|
||||
"subagentsEnabled": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"cline:clineAccountId": "{\"idToken\":\"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ0ZXN0SWQiLCJleHRlcm5hbF9pZCI6InRlc3RJZCIsImVtYWlsIjoidGVzdEBjbGluZS5ib3QiLCJuYW1lIjoiVGVzdCBVc2VyIiwiZXhwIjoxODk5NTc4OTI0LCJpYXQiOjE3NzMzNDQzODQsInNpZCI6InRlc3Qtc2Vzc2lvbi1pZCJ9.\",\"refreshToken\":\"testRefreshToken\",\"userInfo\":{\"id\":\"testId\",\"email\":\"test@cline.bot\",\"displayName\":\"Test User\",\"termsAcceptedAt\":\"2025-12-01T22:41:32.619173Z\",\"clineBenchConsent\":true,\"organizations\":[],\"createdAt\":\"2025-12-01T22:34:35.355099Z\",\"updatedAt\":\"2026-03-12T19:39:43.267925Z\"},\"expiresAt\":1899578924,\"provider\":\"cline\",\"startedAt\":1773344384814}"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,212 @@
|
||||
[
|
||||
{
|
||||
"scope": "https://api.cline.bot",
|
||||
"method": "GET",
|
||||
"path": "/api/v1/organizations/org-REDACTED/remote-config",
|
||||
"body": "",
|
||||
"status": 200,
|
||||
"response": {
|
||||
"data": {
|
||||
"value": "{\"version\":\"v1\",\"telemetryEnabled\":true,\"yoloModeAllowed\":true,\"mcpMarketplaceEnabled\":true,\"providerSettings\":{},\"openTelemetryEnabled\":false,\"openTelemetryOtlpInsecure\":false,\"enterpriseTelemetry\":{\"promptUploading\":{\"enabled\":true,\"type\":\"s3_access_keys\",\"s3AccessSettings\":{\"bucket\":\"cline-tasks\",\"accessKeyId\":\"REDACTED\",\"secretAccessKey\":\"REDACTED\",\"region\":\"us-west-2\"}}}}",
|
||||
"enabled": true
|
||||
},
|
||||
"success": true
|
||||
},
|
||||
"responseIsBinary": false,
|
||||
"contentType": "application/json"
|
||||
},
|
||||
{
|
||||
"scope": "https://data.cline.bot",
|
||||
"method": "POST",
|
||||
"path": "/flags/?v=2&config=true",
|
||||
"status": 200,
|
||||
"response": {
|
||||
"errorsWhileComputingFlags": false,
|
||||
"flags": {
|
||||
"cline-recommended-models-upstream": {
|
||||
"key": "cline-recommended-models-upstream",
|
||||
"enabled": true,
|
||||
"variant": null,
|
||||
"reason": {
|
||||
"code": "condition_match",
|
||||
"condition_index": 3,
|
||||
"description": "Matched condition set 4"
|
||||
},
|
||||
"metadata": {
|
||||
"id": "REDACTED",
|
||||
"version": 2,
|
||||
"description": null,
|
||||
"payload": null
|
||||
}
|
||||
},
|
||||
"worktree-exp": {
|
||||
"key": "worktree-exp",
|
||||
"enabled": false,
|
||||
"variant": null,
|
||||
"reason": {
|
||||
"code": "no_condition_match",
|
||||
"condition_index": 0,
|
||||
"description": "No matching condition set"
|
||||
},
|
||||
"metadata": {
|
||||
"id": "REDACTED",
|
||||
"version": 1,
|
||||
"description": null,
|
||||
"payload": null
|
||||
}
|
||||
},
|
||||
"extension_remote_banners_ttl": {
|
||||
"key": "extension_remote_banners_ttl",
|
||||
"enabled": true,
|
||||
"variant": null,
|
||||
"reason": {
|
||||
"code": "condition_match",
|
||||
"condition_index": 0,
|
||||
"description": "Matched condition set 1"
|
||||
},
|
||||
"metadata": {
|
||||
"id": "REDACTED",
|
||||
"version": 1,
|
||||
"description": null,
|
||||
"payload": "86400000"
|
||||
}
|
||||
},
|
||||
"remote-welcome-banners": {
|
||||
"key": "remote-welcome-banners",
|
||||
"enabled": true,
|
||||
"variant": null,
|
||||
"reason": {
|
||||
"code": "condition_match",
|
||||
"condition_index": 0,
|
||||
"description": "Matched condition set 1"
|
||||
},
|
||||
"metadata": {
|
||||
"id": "REDACTED",
|
||||
"version": 6,
|
||||
"description": null,
|
||||
"payload": null
|
||||
}
|
||||
},
|
||||
"webtools": {
|
||||
"key": "webtools",
|
||||
"enabled": true,
|
||||
"variant": null,
|
||||
"reason": {
|
||||
"code": "condition_match",
|
||||
"condition_index": 1,
|
||||
"description": "Matched condition set 2"
|
||||
},
|
||||
"metadata": {
|
||||
"id": "REDACTED",
|
||||
"version": 3,
|
||||
"description": null,
|
||||
"payload": null
|
||||
}
|
||||
},
|
||||
"onboarding_models": {
|
||||
"key": "onboarding_models",
|
||||
"enabled": true,
|
||||
"variant": null,
|
||||
"reason": {
|
||||
"code": "condition_match",
|
||||
"condition_index": 0,
|
||||
"description": "Matched condition set 1"
|
||||
},
|
||||
"metadata": {
|
||||
"id": "REDACTED",
|
||||
"version": 4,
|
||||
"description": null,
|
||||
"payload": "{\"models\":{\"x-ai/grok-code-fast-1\":{\"info\":{\"inputPrice\":0,\"outputPrice\":0},\"hidden\":false},\"openai/gpt-5-codex\":{\"id\":\"REDACTED\",\"name\":\"OpenAI: GPT-5.1 Codex\",\"hidden\":false}}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"requestId": "REDACTED",
|
||||
"evaluatedAt": 1773683244397,
|
||||
"supportedCompression": [
|
||||
"gzip",
|
||||
"gzip-js"
|
||||
],
|
||||
"analytics": {
|
||||
"endpoint": "/i/v0/e/"
|
||||
},
|
||||
"autocaptureExceptions": true,
|
||||
"captureDeadClicks": false,
|
||||
"capturePerformance": {
|
||||
"network_timing": true,
|
||||
"web_vitals": true,
|
||||
"web_vitals_allowed_metrics": null
|
||||
},
|
||||
"hasFeatureFlags": true,
|
||||
"defaultIdentifiedOnly": true,
|
||||
"heatmaps": true,
|
||||
"productTours": false,
|
||||
"logs": {
|
||||
"captureConsoleLogs": false
|
||||
},
|
||||
"autocapture_opt_out": false,
|
||||
"siteApps": [],
|
||||
"surveys": false,
|
||||
"elementsChainAsString": true,
|
||||
"token": "REDACTED",
|
||||
"sessionRecording": {
|
||||
"consoleLogRecordingEnabled": true,
|
||||
"endpoint": "/s/",
|
||||
"eventTriggers": [],
|
||||
"linkedFlag": null,
|
||||
"masking": null,
|
||||
"minimumDurationMilliseconds": null,
|
||||
"networkPayloadCapture": null,
|
||||
"recorderVersion": "v2",
|
||||
"sampleRate": null,
|
||||
"scriptConfig": {
|
||||
"script": "posthog-recorder"
|
||||
},
|
||||
"triggerMatchType": null,
|
||||
"urlBlocklist": [],
|
||||
"urlTriggers": []
|
||||
},
|
||||
"conversations": false,
|
||||
"errorTracking": {
|
||||
"autocaptureExceptions": true,
|
||||
"errorTrackingAutocaptureTriggers": null,
|
||||
"suppressionRules": []
|
||||
}
|
||||
},
|
||||
"responseIsBinary": false,
|
||||
"contentType": "application/json"
|
||||
},
|
||||
{
|
||||
"scope": "https://api.cline.bot",
|
||||
"method": "GET",
|
||||
"path": "/api/v1/organizations/org-REDACTED/remote-config",
|
||||
"body": "",
|
||||
"status": 200,
|
||||
"response": {
|
||||
"data": {
|
||||
"value": "{\"version\":\"v1\",\"telemetryEnabled\":true,\"yoloModeAllowed\":true,\"mcpMarketplaceEnabled\":true,\"providerSettings\":{},\"openTelemetryEnabled\":false,\"openTelemetryOtlpInsecure\":false,\"enterpriseTelemetry\":{\"promptUploading\":{\"enabled\":true,\"type\":\"s3_access_keys\",\"s3AccessSettings\":{\"bucket\":\"cline-tasks\",\"accessKeyId\":\"REDACTED\",\"secretAccessKey\":\"REDACTED\",\"region\":\"us-west-2\"}}}}",
|
||||
"enabled": true
|
||||
},
|
||||
"success": true
|
||||
},
|
||||
"responseIsBinary": false,
|
||||
"contentType": "application/json"
|
||||
},
|
||||
{
|
||||
"scope": "https://s3.us-west-2.amazonaws.com",
|
||||
"method": "PUT",
|
||||
"path": "/cline-tasks/tasks/usr-test/taskid/api_conversation_history.json",
|
||||
"status": 200,
|
||||
"response": "",
|
||||
"responseIsBinary": false
|
||||
},
|
||||
{
|
||||
"scope": "https://data.cline.bot",
|
||||
"method": "POST",
|
||||
"path": "/batch/",
|
||||
"body": "",
|
||||
"status": 200,
|
||||
"response": "",
|
||||
"responseIsBinary": false,
|
||||
"contentType": "application/json"
|
||||
}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,212 @@
|
||||
[
|
||||
{
|
||||
"scope": "https://api.cline.bot",
|
||||
"method": "GET",
|
||||
"path": "/api/v1/organizations/org-REDACTED/remote-config",
|
||||
"body": "",
|
||||
"status": 200,
|
||||
"response": {
|
||||
"data": {
|
||||
"value": "{\"version\":\"v1\",\"telemetryEnabled\":true,\"yoloModeAllowed\":true,\"mcpMarketplaceEnabled\":true,\"providerSettings\":{},\"openTelemetryEnabled\":false,\"openTelemetryOtlpInsecure\":false,\"enterpriseTelemetry\":{\"promptUploading\":{\"enabled\":true,\"type\":\"s3_access_keys\",\"s3AccessSettings\":{\"bucket\":\"cline-tasks\",\"accessKeyId\":\"REDACTED\",\"secretAccessKey\":\"REDACTED\",\"region\":\"us-west-2\"}}}}",
|
||||
"enabled": true
|
||||
},
|
||||
"success": true
|
||||
},
|
||||
"responseIsBinary": false,
|
||||
"contentType": "application/json"
|
||||
},
|
||||
{
|
||||
"scope": "https://data.cline.bot",
|
||||
"method": "POST",
|
||||
"path": "/flags/?v=2&config=true",
|
||||
"status": 200,
|
||||
"response": {
|
||||
"errorsWhileComputingFlags": false,
|
||||
"flags": {
|
||||
"onboarding_models": {
|
||||
"key": "onboarding_models",
|
||||
"enabled": true,
|
||||
"variant": null,
|
||||
"reason": {
|
||||
"code": "condition_match",
|
||||
"condition_index": 0,
|
||||
"description": "Matched condition set 1"
|
||||
},
|
||||
"metadata": {
|
||||
"id": "REDACTED",
|
||||
"version": 4,
|
||||
"description": null,
|
||||
"payload": "{\"models\":{\"x-ai/grok-code-fast-1\":{\"info\":{\"inputPrice\":0,\"outputPrice\":0},\"hidden\":false},\"openai/gpt-5-codex\":{\"id\":\"REDACTED\",\"name\":\"OpenAI: GPT-5.1 Codex\",\"hidden\":false}}}"
|
||||
}
|
||||
},
|
||||
"cline-recommended-models-upstream": {
|
||||
"key": "cline-recommended-models-upstream",
|
||||
"enabled": true,
|
||||
"variant": null,
|
||||
"reason": {
|
||||
"code": "condition_match",
|
||||
"condition_index": 3,
|
||||
"description": "Matched condition set 4"
|
||||
},
|
||||
"metadata": {
|
||||
"id": "REDACTED",
|
||||
"version": 2,
|
||||
"description": null,
|
||||
"payload": null
|
||||
}
|
||||
},
|
||||
"webtools": {
|
||||
"key": "webtools",
|
||||
"enabled": true,
|
||||
"variant": null,
|
||||
"reason": {
|
||||
"code": "condition_match",
|
||||
"condition_index": 1,
|
||||
"description": "Matched condition set 2"
|
||||
},
|
||||
"metadata": {
|
||||
"id": "REDACTED",
|
||||
"version": 3,
|
||||
"description": null,
|
||||
"payload": null
|
||||
}
|
||||
},
|
||||
"remote-welcome-banners": {
|
||||
"key": "remote-welcome-banners",
|
||||
"enabled": true,
|
||||
"variant": null,
|
||||
"reason": {
|
||||
"code": "condition_match",
|
||||
"condition_index": 0,
|
||||
"description": "Matched condition set 1"
|
||||
},
|
||||
"metadata": {
|
||||
"id": "REDACTED",
|
||||
"version": 6,
|
||||
"description": null,
|
||||
"payload": null
|
||||
}
|
||||
},
|
||||
"extension_remote_banners_ttl": {
|
||||
"key": "extension_remote_banners_ttl",
|
||||
"enabled": true,
|
||||
"variant": null,
|
||||
"reason": {
|
||||
"code": "condition_match",
|
||||
"condition_index": 0,
|
||||
"description": "Matched condition set 1"
|
||||
},
|
||||
"metadata": {
|
||||
"id": "REDACTED",
|
||||
"version": 1,
|
||||
"description": null,
|
||||
"payload": "86400000"
|
||||
}
|
||||
},
|
||||
"worktree-exp": {
|
||||
"key": "worktree-exp",
|
||||
"enabled": false,
|
||||
"variant": null,
|
||||
"reason": {
|
||||
"code": "no_condition_match",
|
||||
"condition_index": 0,
|
||||
"description": "No matching condition set"
|
||||
},
|
||||
"metadata": {
|
||||
"id": "REDACTED",
|
||||
"version": 1,
|
||||
"description": null,
|
||||
"payload": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"requestId": "REDACTED",
|
||||
"evaluatedAt": 1773683241316,
|
||||
"elementsChainAsString": true,
|
||||
"sessionRecording": {
|
||||
"consoleLogRecordingEnabled": true,
|
||||
"endpoint": "/s/",
|
||||
"eventTriggers": [],
|
||||
"linkedFlag": null,
|
||||
"masking": null,
|
||||
"minimumDurationMilliseconds": null,
|
||||
"networkPayloadCapture": null,
|
||||
"recorderVersion": "v2",
|
||||
"sampleRate": null,
|
||||
"scriptConfig": {
|
||||
"script": "posthog-recorder"
|
||||
},
|
||||
"triggerMatchType": null,
|
||||
"urlBlocklist": [],
|
||||
"urlTriggers": []
|
||||
},
|
||||
"hasFeatureFlags": true,
|
||||
"defaultIdentifiedOnly": true,
|
||||
"supportedCompression": [
|
||||
"gzip",
|
||||
"gzip-js"
|
||||
],
|
||||
"autocaptureExceptions": true,
|
||||
"siteApps": [],
|
||||
"logs": {
|
||||
"captureConsoleLogs": false
|
||||
},
|
||||
"analytics": {
|
||||
"endpoint": "/i/v0/e/"
|
||||
},
|
||||
"heatmaps": true,
|
||||
"capturePerformance": {
|
||||
"network_timing": true,
|
||||
"web_vitals": true,
|
||||
"web_vitals_allowed_metrics": null
|
||||
},
|
||||
"surveys": false,
|
||||
"errorTracking": {
|
||||
"autocaptureExceptions": true,
|
||||
"errorTrackingAutocaptureTriggers": null,
|
||||
"suppressionRules": []
|
||||
},
|
||||
"productTours": false,
|
||||
"token": "REDACTED",
|
||||
"captureDeadClicks": false,
|
||||
"conversations": false,
|
||||
"autocapture_opt_out": false
|
||||
},
|
||||
"responseIsBinary": false,
|
||||
"contentType": "application/json"
|
||||
},
|
||||
{
|
||||
"scope": "https://api.cline.bot",
|
||||
"method": "GET",
|
||||
"path": "/api/v1/organizations/org-REDACTED/remote-config",
|
||||
"body": "",
|
||||
"status": 200,
|
||||
"response": {
|
||||
"data": {
|
||||
"value": "{\"version\":\"v1\",\"telemetryEnabled\":true,\"yoloModeAllowed\":true,\"mcpMarketplaceEnabled\":true,\"providerSettings\":{},\"openTelemetryEnabled\":false,\"openTelemetryOtlpInsecure\":false,\"enterpriseTelemetry\":{\"promptUploading\":{\"enabled\":true,\"type\":\"s3_access_keys\",\"s3AccessSettings\":{\"bucket\":\"cline-tasks\",\"accessKeyId\":\"REDACTED\",\"secretAccessKey\":\"REDACTED\",\"region\":\"us-west-2\"}}}}",
|
||||
"enabled": true
|
||||
},
|
||||
"success": true
|
||||
},
|
||||
"responseIsBinary": false,
|
||||
"contentType": "application/json"
|
||||
},
|
||||
{
|
||||
"scope": "https://s3.us-west-2.amazonaws.com",
|
||||
"method": "PUT",
|
||||
"path": "/cline-tasks/tasks/usr-test/taskid/api_conversation_history.json",
|
||||
"status": 200,
|
||||
"response": "",
|
||||
"responseIsBinary": false
|
||||
},
|
||||
{
|
||||
"scope": "https://data.cline.bot",
|
||||
"method": "POST",
|
||||
"path": "/batch/",
|
||||
"body": "",
|
||||
"status": 200,
|
||||
"response": "",
|
||||
"responseIsBinary": false,
|
||||
"contentType": "application/json"
|
||||
}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,7 @@
|
||||
import { test } from "@microsoft/tui-test"
|
||||
import { CLINE_BIN } from "./helpers/constants.js"
|
||||
import { expectVisible, testEnv } from "./utils.js"
|
||||
import { clineEnv } from "./helpers/env.js"
|
||||
import { expectVisible } from "./helpers/terminal.js"
|
||||
|
||||
const HELP_TERMINAL = { columns: 120, rows: 50 }
|
||||
|
||||
@@ -10,7 +11,7 @@ const HELP_TERMINAL = { columns: 120, rows: 50 }
|
||||
test.describe("root flag descriptions", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["--help"] },
|
||||
env: testEnv("default"),
|
||||
env: clineEnv("default"),
|
||||
...HELP_TERMINAL,
|
||||
})
|
||||
|
||||
@@ -45,7 +46,7 @@ test.describe("root flag descriptions", () => {
|
||||
test.describe("task flag descriptions", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["task", "--help"] },
|
||||
env: testEnv("default"),
|
||||
env: clineEnv("default"),
|
||||
...HELP_TERMINAL,
|
||||
})
|
||||
|
||||
@@ -75,7 +76,7 @@ test.describe("task flag descriptions", () => {
|
||||
test.describe("history flag descriptions", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["history", "--help"] },
|
||||
env: testEnv("default"),
|
||||
env: clineEnv("default"),
|
||||
...HELP_TERMINAL,
|
||||
})
|
||||
|
||||
@@ -92,7 +93,7 @@ test.describe("history flag descriptions", () => {
|
||||
test.describe("auth flag descriptions", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["auth", "--help"] },
|
||||
env: testEnv("default"),
|
||||
env: clineEnv("default"),
|
||||
...HELP_TERMINAL,
|
||||
})
|
||||
|
||||
@@ -115,7 +116,7 @@ test.describe("auth flag descriptions", () => {
|
||||
test.describe("config flag descriptions", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["config", "--help"] },
|
||||
env: testEnv("default"),
|
||||
env: clineEnv("default"),
|
||||
...HELP_TERMINAL,
|
||||
})
|
||||
|
||||
@@ -130,7 +131,7 @@ test.describe("config flag descriptions", () => {
|
||||
test.describe("update flag descriptions", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["update", "--help"] },
|
||||
env: testEnv("default"),
|
||||
env: clineEnv("default"),
|
||||
...HELP_TERMINAL,
|
||||
})
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { test } from "@microsoft/tui-test"
|
||||
import { CLINE_BIN, TERMINAL_WIDE } from "../helpers/constants.js"
|
||||
import { CLINE_BIN, EXIT_CODE_FAIL, EXIT_CODE_SUCCESS, TERMINAL_WIDE } from "../helpers/constants.js"
|
||||
import { clineEnv } from "../helpers/env.js"
|
||||
import { expectVisible } from "../helpers/terminal.js"
|
||||
import { expectExitCode, expectVisible } from "../helpers/terminal.js"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline -y "tell me a joke"
|
||||
@@ -27,6 +27,7 @@ test.describe("cline -y (headless yolo mode) — unauthenticated", () => {
|
||||
|
||||
test("prints Not authenticated and exits 1", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /not authenticated/i)
|
||||
await expectExitCode(terminal, EXIT_CODE_FAIL)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,6 +47,7 @@ test.describe("piped stdin | cline -y — unauthenticated", () => {
|
||||
|
||||
test("prints Not Authenticated for piped stdin", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /not authenticated/i)
|
||||
await expectExitCode(terminal, EXIT_CODE_FAIL)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,6 +67,7 @@ test.describe("cline -y --verbose — unauthenticated", () => {
|
||||
|
||||
test("shows verbose output or not-authenticated", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /not authenticated|verbose|task/i)
|
||||
await expectExitCode(terminal, EXIT_CODE_FAIL)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,5 +85,132 @@ test.describe("cline --json — unauthenticated", () => {
|
||||
test("outputs JSON error for unauthenticated", async ({ terminal }) => {
|
||||
// cline --json when unauthenticated outputs a plain "Not authenticated" message
|
||||
await expectVisible(terminal, /not authenticated/i)
|
||||
await expectExitCode(terminal, EXIT_CODE_FAIL)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("cline -y (headless yolo mode) — authenticated @live", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["-y", "tell me a joke"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default", { CLINE_VCR_CASSETTE: "./fixtures/headless-yolo-basic.json" }),
|
||||
})
|
||||
|
||||
test("prints Task started then LLM output", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /task started/i)
|
||||
await expectExitCode(terminal, EXIT_CODE_SUCCESS)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// echo "max paulus" | cline -y "..." — authenticated
|
||||
// Piped stdin test with valid credentials
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("piped stdin | cline -y — authenticated", () => {
|
||||
test.use({
|
||||
program: {
|
||||
file: "sh",
|
||||
args: ["-c", `echo "butterfly horse country" | ${CLINE_BIN} -y "print only the second word I gave you"`],
|
||||
},
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default", { CLINE_VCR_CASSETTE: "./fixtures/headless-piped-stdin.json" }),
|
||||
})
|
||||
|
||||
test("prints Task started and output for piped stdin", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /task started/i)
|
||||
await expectExitCode(terminal, EXIT_CODE_SUCCESS)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline -y --verbose "tell me a joke" 2>&1 — authenticated
|
||||
// Golden path: prints task started, prompt, api request, reasoning, task_completion
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline -y --verbose — authenticated @live", () => {
|
||||
test.use({
|
||||
program: {
|
||||
file: "sh",
|
||||
args: ["-c", `${CLINE_BIN} -y --verbose "tell me a joke" 2>&1`],
|
||||
},
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default", { CLINE_VCR_CASSETTE: "./fixtures/headless-verbose.json" }),
|
||||
})
|
||||
|
||||
test("shows verbose output with task started, prompt, and api request lines", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /task started/i)
|
||||
await expectExitCode(terminal, EXIT_CODE_SUCCESS)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline --json "tell me a joke" — authenticated
|
||||
// All output must conform to JSON (one JSON object per line)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline --json — authenticated @live", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["--json", "tell me a joke"] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default", { CLINE_VCR_CASSETTE: "./fixtures/headless-json.json" }),
|
||||
})
|
||||
|
||||
test("outputs JSON-formatted messages", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /\{.*"type"/i)
|
||||
await expectExitCode(terminal, EXIT_CODE_SUCCESS)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline -t 2 -y "tell me a joke"
|
||||
// Timeout: should print "Error: Timeout" and exit 1
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline -t (timeout) — headless yolo", () => {
|
||||
test.use({
|
||||
program: {
|
||||
file: CLINE_BIN,
|
||||
args: ["-t", "2", "-y", "tell me a long detailed joke"],
|
||||
},
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default", { CLINE_VCR_CASSETTE: "./fixtures/headless-timeout.json" }),
|
||||
})
|
||||
|
||||
test("prints timeout error when timeout exceeded", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /timeout/i, { timeout: 15_000 })
|
||||
await expectExitCode(terminal, EXIT_CODE_FAIL)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("cline --json -t (timeout) — JSON mode", () => {
|
||||
test.use({
|
||||
program: {
|
||||
file: CLINE_BIN,
|
||||
args: ["--json", "-t", "2", "tell me a long detailed joke"],
|
||||
},
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default", { CLINE_VCR_CASSETTE: "./fixtures/headless-json-timeout.json" }),
|
||||
})
|
||||
|
||||
test("outputs JSON timeout error", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /timeout/i, { timeout: 15_000 })
|
||||
await expectExitCode(terminal, EXIT_CODE_FAIL)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline -y -m <model-id> "what model are you"
|
||||
// Model flag in headless mode — should use specified model but not persist
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline -y -m (model flag in headless) @live", () => {
|
||||
test.use({
|
||||
program: {
|
||||
file: CLINE_BIN,
|
||||
args: ["-y", "-m", "anthropic/claude-sonnet-4", "what model are you"],
|
||||
},
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default", { CLINE_VCR_CASSETTE: "./fixtures/headless-model-flag.json" }),
|
||||
})
|
||||
|
||||
test("prints a message and exits 0 with --model flag", async ({ terminal }) => {
|
||||
await expectVisible(terminal, /task started/i)
|
||||
await expectExitCode(terminal, EXIT_CODE_SUCCESS)
|
||||
})
|
||||
})
|
||||
|
||||
+13
-12
@@ -1,6 +1,7 @@
|
||||
import { test } from "@microsoft/tui-test"
|
||||
import { CLINE_BIN } from "./helpers/constants.js"
|
||||
import { expectVisible, testEnv } from "./utils.js"
|
||||
import { clineEnv } from "./helpers/env.js"
|
||||
import { expectVisible } from "./helpers/terminal.js"
|
||||
|
||||
const HELP_TERMINAL = { columns: 120, rows: 50 }
|
||||
|
||||
@@ -10,7 +11,7 @@ const HELP_TERMINAL = { columns: 120, rows: 50 }
|
||||
test.describe("cline --help", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["--help"] },
|
||||
env: testEnv("claude-sonnet-4.6"),
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
...HELP_TERMINAL,
|
||||
})
|
||||
|
||||
@@ -54,7 +55,7 @@ test.describe("cline --help", () => {
|
||||
test.describe("cline -h", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["-h"] },
|
||||
env: testEnv("claude-sonnet-4.6"),
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
...HELP_TERMINAL,
|
||||
})
|
||||
|
||||
@@ -69,7 +70,7 @@ test.describe("cline -h", () => {
|
||||
test.describe("cline task --help", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["task", "--help"] },
|
||||
env: testEnv("claude-sonnet-4.6"),
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
...HELP_TERMINAL,
|
||||
})
|
||||
|
||||
@@ -101,7 +102,7 @@ test.describe("cline task --help", () => {
|
||||
test.describe("cline t --help (task alias)", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["t", "--help"] },
|
||||
env: testEnv("claude-sonnet-4.6"),
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
...HELP_TERMINAL,
|
||||
})
|
||||
|
||||
@@ -116,7 +117,7 @@ test.describe("cline t --help (task alias)", () => {
|
||||
test.describe("cline history --help", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["history", "--help"] },
|
||||
env: testEnv("claude-sonnet-4.6"),
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
...HELP_TERMINAL,
|
||||
})
|
||||
|
||||
@@ -131,7 +132,7 @@ test.describe("cline history --help", () => {
|
||||
test.describe("cline h --help (history alias)", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["h", "--help"] },
|
||||
env: testEnv("claude-sonnet-4.6"),
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
...HELP_TERMINAL,
|
||||
})
|
||||
|
||||
@@ -146,7 +147,7 @@ test.describe("cline h --help (history alias)", () => {
|
||||
test.describe("cline config --help", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["config", "--help"] },
|
||||
env: testEnv("claude-sonnet-4.6"),
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
...HELP_TERMINAL,
|
||||
})
|
||||
|
||||
@@ -161,7 +162,7 @@ test.describe("cline config --help", () => {
|
||||
test.describe("cline auth --help", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["auth", "--help"] },
|
||||
env: testEnv("claude-sonnet-4.6"),
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
...HELP_TERMINAL,
|
||||
})
|
||||
|
||||
@@ -185,7 +186,7 @@ test.describe("cline auth --help", () => {
|
||||
test.describe("cline version --help", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["version", "--help"] },
|
||||
env: testEnv("claude-sonnet-4.6"),
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
...HELP_TERMINAL,
|
||||
})
|
||||
|
||||
@@ -200,7 +201,7 @@ test.describe("cline version --help", () => {
|
||||
test.describe("cline update --help", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["update", "--help"] },
|
||||
env: testEnv("claude-sonnet-4.6"),
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
...HELP_TERMINAL,
|
||||
})
|
||||
|
||||
@@ -215,7 +216,7 @@ test.describe("cline update --help", () => {
|
||||
test.describe("cline dev --help", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["dev", "--help"] },
|
||||
env: testEnv("claude-sonnet-4.6"),
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
...HELP_TERMINAL,
|
||||
})
|
||||
|
||||
|
||||
@@ -22,3 +22,7 @@ export const CLINE_BIN = resolveClineBin()
|
||||
// Standard terminal dimensions used across test suites
|
||||
export const TERMINAL_WIDE = { columns: 120, rows: 50 } as const
|
||||
export const TERMINAL_NARROW = { columns: 80, rows: 30 } as const
|
||||
|
||||
export const EXIT_CODE_SUCCESS = 0
|
||||
export const EXIT_CODE_FAIL = 1
|
||||
export const EXIT_CODE_TIMEOUT = 124
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
// test.use({ env: clineEnv("/absolute/path/to/config") });
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
|
||||
const TEST_SUITE_ROOT = new URL("../", import.meta.url).pathname
|
||||
export const TEST_SUITE_ROOT = new URL("../", import.meta.url).pathname
|
||||
|
||||
/**
|
||||
* Build the process environment for a cline test.
|
||||
@@ -20,14 +21,38 @@ const TEST_SUITE_ROOT = new URL("../", import.meta.url).pathname
|
||||
export function clineEnv(configDir: string, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
|
||||
const clinePath = path.isAbsolute(configDir) ? configDir : path.join(TEST_SUITE_ROOT, "configs", configDir)
|
||||
|
||||
// Determine effective VCR mode: extra overrides > parent env > default "playback"
|
||||
const effectiveVcrMode = extra.CLINE_VCR ?? process.env.CLINE_VCR ?? "playback"
|
||||
|
||||
// During recording, authenticated configs read real API keys from
|
||||
// ~/.cline/data/secrets.json while keeping all other settings (model,
|
||||
// provider, global state) from the mock config directory.
|
||||
const isRecording = effectiveVcrMode === "record"
|
||||
const isAuthenticated = configDir !== "unauthenticated"
|
||||
const realSecretsFile = isRecording && isAuthenticated ? path.join(os.homedir(), ".cline", "data", "secrets.json") : undefined
|
||||
|
||||
// Remove CI env var so Ink's `is-in-ci` check doesn't disable interactive
|
||||
// rendering. When CI=true (set by GitHub Actions / act), Ink treats the
|
||||
// environment as non-interactive and skips rendering to stdout — even
|
||||
// inside a real PTY — which causes tui-test traces to be empty.
|
||||
const { CI: _ci, ...cleanEnv } = process.env
|
||||
//
|
||||
// Remove VITEST so the spawned CLI binary doesn't skip initVcr().
|
||||
// cli/src/index.ts guards `initVcr` behind `process.env.VITEST !== "true"`,
|
||||
// so if the parent vitest process's VITEST=true leaks into the child, VCR
|
||||
// recording/playback is silently skipped.
|
||||
const { CI: _ci, VITEST: _vitest, ...cleanEnv } = process.env
|
||||
|
||||
// Only enable VCR when a cassette path is provided (via extra or parent env),
|
||||
// otherwise tests without cassettes would trigger a spurious
|
||||
// "[VCR] No CLINE_VCR_CASSETTE" warning on every run.
|
||||
const hasCassette = !!(extra.CLINE_VCR_CASSETTE ?? process.env.CLINE_VCR_CASSETTE)
|
||||
const vcrDefaults = hasCassette ? { CLINE_VCR: "playback", CLINE_VCR_FILTER: "" } : {}
|
||||
|
||||
// the order of these env vars matter; later ones override earlier ones
|
||||
return {
|
||||
...vcrDefaults,
|
||||
...cleanEnv,
|
||||
...(realSecretsFile ? { CLINE_SECRETS_FILE: realSecretsFile } : {}),
|
||||
CLINE_TELEMETRY_DISABLED: "1",
|
||||
CLINE_DIR: clinePath,
|
||||
NO_UPDATE_NOTIFIER: "1",
|
||||
@@ -35,10 +60,3 @@ export function clineEnv(configDir: string, extra: NodeJS.ProcessEnv = {}): Node
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `clineEnv` instead. Kept for backward compatibility.
|
||||
*/
|
||||
export function testEnv(configDir: string): NodeJS.ProcessEnv {
|
||||
return clineEnv(configDir)
|
||||
}
|
||||
|
||||
@@ -33,28 +33,44 @@ export async function waitForTaskCompleted(terminal: Terminal, timeout = 60_000)
|
||||
await expectVisible(terminal, "Task completed", { timeout })
|
||||
}
|
||||
|
||||
export async function waitForActing(terminal: Terminal, timeout = 60_000): Promise<void> {
|
||||
await expectVisible(terminal, "Acting...", { timeout })
|
||||
}
|
||||
|
||||
/** Wait for the "Start New Task (1)" and "Exit (2)" buttons */
|
||||
export async function waitForTaskButtons(terminal: Terminal, timeout = 60_000): Promise<void> {
|
||||
await expectVisible(terminal, ["Start New Task", "Exit"], { timeout })
|
||||
}
|
||||
|
||||
export async function waitForApproveReject(terminal: Terminal): Promise<void> {
|
||||
await expectVisible(terminal, ["Approve (1)", "Reject (2)"])
|
||||
}
|
||||
|
||||
/** Press "1" to start a new task after task completion */
|
||||
export async function startNewTask(terminal: Terminal): Promise<void> {
|
||||
export function startNewTask(terminal: Terminal) {
|
||||
terminal.write("1")
|
||||
}
|
||||
|
||||
export function approveTool(terminal: Terminal) {
|
||||
terminal.write("1")
|
||||
}
|
||||
|
||||
export function rejectTool(terminal: Terminal) {
|
||||
terminal.write("2")
|
||||
}
|
||||
|
||||
/** Press "2" to exit after task completion */
|
||||
export async function exitAfterTask(terminal: Terminal): Promise<void> {
|
||||
export function exitAfterTask(terminal: Terminal) {
|
||||
terminal.write("2")
|
||||
}
|
||||
|
||||
/** Wait for a permission prompt and approve it (press "1" / Save) */
|
||||
export async function approvePermission(terminal: Terminal): Promise<void> {
|
||||
export function approvePermission(terminal: Terminal) {
|
||||
terminal.write("1")
|
||||
}
|
||||
|
||||
/** Wait for a permission prompt and reject it (press "2" / Reject) */
|
||||
export async function rejectPermission(terminal: Terminal): Promise<void> {
|
||||
export function rejectPermission(terminal: Terminal) {
|
||||
terminal.write("2")
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { expect } from "@microsoft/tui-test"
|
||||
import type { Terminal } from "@microsoft/tui-test/lib/terminal/term"
|
||||
import { EXIT_CODE_TIMEOUT } from "./constants.js"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core wait / assertion helpers
|
||||
@@ -13,6 +14,33 @@ import type { Terminal } from "@microsoft/tui-test/lib/terminal/term"
|
||||
|
||||
const maxTimeoutMs = 10_000
|
||||
|
||||
/**
|
||||
* Internal helper – asserts visibility (or not) for one or more patterns.
|
||||
*/
|
||||
async function expectTextVisibility(
|
||||
terminal: Terminal,
|
||||
text: string | RegExp | (string | RegExp)[],
|
||||
visible: boolean,
|
||||
options: { timeout?: number } = { timeout: maxTimeoutMs },
|
||||
): Promise<void> {
|
||||
const items = Array.isArray(text) ? text : [text]
|
||||
const timeoutOpt = options.timeout !== undefined ? { timeout: options.timeout } : undefined
|
||||
await Promise.all(
|
||||
items.map((t) => {
|
||||
// tui-test uses String.prototype.matchAll internally, which requires
|
||||
// the global flag on RegExp arguments. Ensure it is set.
|
||||
if (t instanceof RegExp && !t.flags.includes("g")) {
|
||||
t = new RegExp(t.source, `${t.flags}g`)
|
||||
}
|
||||
const locator = terminal.getByText(t, {
|
||||
full: true,
|
||||
strict: false,
|
||||
})
|
||||
return visible ? expect(locator).toBeVisible(timeoutOpt) : expect(locator).not.toBeVisible(timeoutOpt)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for one or more text strings/regexes to appear on screen.
|
||||
*
|
||||
@@ -25,21 +53,27 @@ export async function expectVisible(
|
||||
text: string | RegExp | (string | RegExp)[],
|
||||
options: { timeout?: number } = { timeout: maxTimeoutMs },
|
||||
): Promise<void> {
|
||||
const items = Array.isArray(text) ? text : [text]
|
||||
await Promise.all(
|
||||
items.map((t) => {
|
||||
// tui-test uses String.prototype.matchAll internally, which requires
|
||||
// the global flag on RegExp arguments. Ensure it is set.
|
||||
if (t instanceof RegExp && !t.flags.includes("g")) {
|
||||
t = new RegExp(t.source, `${t.flags}g`)
|
||||
}
|
||||
const locator = terminal.getByText(t, {
|
||||
full: true,
|
||||
strict: false,
|
||||
})
|
||||
return expect(locator).toBeVisible(options.timeout !== undefined ? { timeout: options.timeout } : undefined)
|
||||
}),
|
||||
)
|
||||
return expectTextVisibility(terminal, text, true, options)
|
||||
}
|
||||
|
||||
export async function expectExitCode(terminal: Terminal, exitCode: number): Promise<void> {
|
||||
const xCode = await waitForTerminalExit(terminal)
|
||||
expect(xCode).toBe(exitCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that one or more text strings/regexes are **not** visible on screen.
|
||||
*
|
||||
* @example
|
||||
* await expectNotVisible(terminal, "Loading…");
|
||||
* await expectNotVisible(terminal, ["/secret", /error/i], { timeout: 5000 });
|
||||
*/
|
||||
export async function expectNotVisible(
|
||||
terminal: Terminal,
|
||||
text: string | RegExp | (string | RegExp)[],
|
||||
options: { timeout?: number } = { timeout: maxTimeoutMs },
|
||||
): Promise<void> {
|
||||
return expectTextVisibility(terminal, text, false, options)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,3 +85,47 @@ export async function typeAndSubmit(terminal: Terminal, text: string, delay = 50
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
terminal.submit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully shut down the CLI process by sending Ctrl+C (SIGINT) and
|
||||
* waiting for the process to actually exit.
|
||||
*
|
||||
* This is necessary for VCR recording tests because tui-test normally
|
||||
* terminates processes with SIGKILL (signal 9), which cannot be caught
|
||||
* and prevents `process.on('exit')` handlers from flushing recorded
|
||||
* HTTP interactions to disk. Sending SIGINT first triggers the CLI's
|
||||
* graceful shutdown path which flushes VCR cassettes before exiting.
|
||||
*
|
||||
* @param timeout Maximum ms to wait for exit before giving up (default 5000)
|
||||
*/
|
||||
export async function gracefulShutdown(terminal: Terminal): Promise<number> {
|
||||
terminal.keyCtrlC()
|
||||
return await waitForTerminalExit(terminal)
|
||||
}
|
||||
|
||||
export async function waitForTerminalExit(terminal: Terminal, timeout = 31000): Promise<number> {
|
||||
return await new Promise<number>((resolve) => {
|
||||
let resolved = false
|
||||
const done = (code: number) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
clearTimeout(timer)
|
||||
clearInterval(poller)
|
||||
resolve(code)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => done(EXIT_CODE_TIMEOUT), timeout) // exit code 124 is timeout
|
||||
|
||||
// Register listener for future exit events
|
||||
terminal.onExit((exitResult) => done(exitResult.exitCode))
|
||||
|
||||
// Poll terminal.exitResult as a fallback for the race condition where
|
||||
// the process exits before onExit listener is registered (the event
|
||||
// fires once and is not replayed for late subscribers).
|
||||
const poller = setInterval(() => {
|
||||
if (terminal.exitResult) {
|
||||
done(terminal.exitResult.exitCode)
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import { test } from "@microsoft/tui-test"
|
||||
import { CLINE_BIN } from "./helpers/constants.js"
|
||||
import { assertApiTab, assertAutoApproveTab, assertFeaturesTab, assertOtherTab } from "./helpers/page-objects/settings.js"
|
||||
import { expectVisible, testEnv, typeAndSubmit } from "./utils.js"
|
||||
import { clineEnv } from "./helpers/env.js"
|
||||
import {
|
||||
approveTool,
|
||||
waitForActing,
|
||||
waitForApproveReject,
|
||||
waitForChatReady,
|
||||
waitForTaskButtons,
|
||||
} from "./helpers/page-objects/chat.js"
|
||||
import {
|
||||
assertAccountTab,
|
||||
assertApiTab,
|
||||
assertAutoApproveTab,
|
||||
assertFeaturesTab,
|
||||
assertOtherTab,
|
||||
} from "./helpers/page-objects/settings.js"
|
||||
import { expectVisible, gracefulShutdown, typeAndSubmit } from "./helpers/terminal.js"
|
||||
|
||||
test.describe("cline interactive basics", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
rows: 50,
|
||||
columns: 120,
|
||||
env: testEnv("default"),
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
|
||||
test("shows logo, prompt, mode toggles, and hints", async ({ terminal }) => {
|
||||
@@ -25,40 +39,60 @@ test.describe("cline interactive basics", () => {
|
||||
|
||||
test("opens /settings and navigates tabs with left/right arrows", async ({ terminal }) => {
|
||||
await expectVisible(terminal, "What can I do for you?")
|
||||
|
||||
await typeAndSubmit(terminal, "/settings")
|
||||
|
||||
await expectVisible(terminal, "Settings (Esc to close)")
|
||||
|
||||
// API tab (default)
|
||||
await assertApiTab(terminal)
|
||||
await expectVisible(terminal, "Use separate models for Plan and Act")
|
||||
|
||||
// Auto-approve tab
|
||||
terminal.keyRight()
|
||||
await assertAutoApproveTab(terminal)
|
||||
|
||||
// Features tab
|
||||
terminal.keyRight()
|
||||
await assertFeaturesTab(terminal)
|
||||
await expectVisible(terminal, ["Strict plan mode", "Native tool call", "Parallel tool calling"])
|
||||
|
||||
// Account tab
|
||||
terminal.keyRight()
|
||||
await expectVisible(terminal, ["Sign in to access Cline features", "Sign in with Cline"])
|
||||
|
||||
// Other tab
|
||||
await assertAccountTab(terminal)
|
||||
terminal.keyRight()
|
||||
await assertOtherTab(terminal)
|
||||
|
||||
// Left once from Other should move back to Account
|
||||
terminal.keyLeft()
|
||||
await expectVisible(terminal, "Sign in to access Cline features")
|
||||
|
||||
// Two rights from Account should wrap back to API
|
||||
await assertAccountTab(terminal)
|
||||
terminal.keyRight()
|
||||
await assertOtherTab(terminal)
|
||||
terminal.keyRight()
|
||||
await assertApiTab(terminal)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("cline interactive prompt submission", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
rows: 50,
|
||||
columns: 120,
|
||||
env: clineEnv("default", { CLINE_VCR_CASSETTE: "./fixtures/hello-and-goodbye.json" }),
|
||||
})
|
||||
|
||||
test("submits 'just say hello' and LLM responds with 'hello'", async ({ terminal }) => {
|
||||
await waitForChatReady(terminal)
|
||||
await typeAndSubmit(terminal, "just say hello")
|
||||
await waitForTaskButtons(terminal)
|
||||
await typeAndSubmit(terminal, "now say goodbye")
|
||||
await waitForActing(terminal)
|
||||
await waitForTaskButtons(terminal)
|
||||
await gracefulShutdown(terminal)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("cline interactive prompt read file", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
rows: 50,
|
||||
columns: 120,
|
||||
env: clineEnv("default", { CLINE_VCR_CASSETTE: "./fixtures/read-file.json" }),
|
||||
})
|
||||
|
||||
test("submits 'read a file outside my workspace' and LLM asks permission", async ({ terminal }) => {
|
||||
await waitForChatReady(terminal)
|
||||
await typeAndSubmit(terminal, "read my ~/.wezterm.lua file")
|
||||
await waitForApproveReject(terminal)
|
||||
approveTool(terminal)
|
||||
await waitForTaskButtons(terminal)
|
||||
await gracefulShutdown(terminal)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,17 +17,24 @@
|
||||
// - Auto-approve all (Shift+Tab)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { test } from "@microsoft/tui-test"
|
||||
import { expect, test } from "@microsoft/tui-test"
|
||||
import { unlinkSync, writeFileSync } from "fs"
|
||||
import path from "path"
|
||||
import { CLINE_BIN, TERMINAL_WIDE } from "../helpers/constants.js"
|
||||
import { clineEnv } from "../helpers/env.js"
|
||||
import {
|
||||
approveTool,
|
||||
exitAfterTask,
|
||||
openHistory,
|
||||
openModels,
|
||||
openSettings,
|
||||
openSkills,
|
||||
startNewTask,
|
||||
toggleAutoApproveAll,
|
||||
togglePlanAct,
|
||||
waitForApproveReject,
|
||||
waitForChatReady,
|
||||
waitForTaskButtons,
|
||||
} from "../helpers/page-objects/chat.js"
|
||||
import {
|
||||
assertApiTab,
|
||||
@@ -36,11 +43,8 @@ import {
|
||||
assertOtherTab,
|
||||
goToSettingsTab,
|
||||
} from "../helpers/page-objects/settings.js"
|
||||
import { expectVisible } from "../helpers/terminal.js"
|
||||
import { expectNotVisible, expectVisible, gracefulShutdown, typeAndSubmit, waitForTerminalExit } from "../helpers/terminal.js"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline (unauthenticated) → shows interactive auth view
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline (unauthenticated) — shows auth view", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
@@ -55,9 +59,6 @@ test.describe("cline (unauthenticated) — shows auth view", () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline (authenticated) → shows main chat view
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("cline (authenticated) — shows chat view", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
@@ -70,9 +71,6 @@ test.describe("cline (authenticated) — shows chat view", () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /settings — tab navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("/settings — tab navigation", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
@@ -110,9 +108,6 @@ test.describe("/settings — tab navigation", () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /models — browse models
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("/models — model browser", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
@@ -133,9 +128,6 @@ test.describe("/models — model browser", () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /history — task history
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("/history — task history", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
@@ -156,9 +148,6 @@ test.describe("/history — task history", () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /skills — skills view
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("/skills — skills view", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
@@ -179,9 +168,6 @@ test.describe("/skills — skills view", () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan/Act mode toggle
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("Plan/Act mode toggle", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
@@ -191,17 +177,12 @@ test.describe("Plan/Act mode toggle", () => {
|
||||
|
||||
test("pressing Tab toggles between Plan and Act mode", async ({ terminal }) => {
|
||||
await waitForChatReady(terminal)
|
||||
// Default should show Act
|
||||
await expectVisible(terminal, "○ Plan ● Act")
|
||||
await togglePlanAct(terminal)
|
||||
// After toggle, the other mode should be active
|
||||
await expectVisible(terminal, "● Plan ○ Act")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-approve all (Shift+Tab)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe("Auto-approve all — Shift+Tab toggle", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
@@ -213,7 +194,144 @@ test.describe("Auto-approve all — Shift+Tab toggle", () => {
|
||||
await waitForChatReady(terminal)
|
||||
await expectVisible(terminal, "Auto-approve all disabled")
|
||||
await toggleAutoApproveAll(terminal)
|
||||
// TODO: verify config store is updated
|
||||
await expectVisible(terminal, "Auto-approve all enabled")
|
||||
await toggleAutoApproveAll(terminal)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Task completed — Start New Task button @live", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default", { CLINE_VCR_CASSETTE: "./fixtures/task-complete-new-task.json" }),
|
||||
})
|
||||
|
||||
test("pressing 1 (Start New Task) clears screen and shows fresh prompt", async ({ terminal }) => {
|
||||
await waitForChatReady(terminal)
|
||||
await typeAndSubmit(terminal, "just say hello")
|
||||
await waitForTaskButtons(terminal)
|
||||
startNewTask(terminal)
|
||||
await expectNotVisible(terminal, "just say hello")
|
||||
await gracefulShutdown(terminal)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Task completed — Exit button @live", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default", { CLINE_VCR_CASSETTE: "./fixtures/task-complete-exit.json" }),
|
||||
})
|
||||
|
||||
test("pressing 2 (Exit) exits the app with code 0", async ({ terminal }) => {
|
||||
await waitForChatReady(terminal)
|
||||
await typeAndSubmit(terminal, "just say hello")
|
||||
await waitForTaskButtons(terminal)
|
||||
exitAfterTask(terminal)
|
||||
const exitCode = await waitForTerminalExit(terminal)
|
||||
expect(exitCode).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("read file outside cwd requires permission @live", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default", { CLINE_VCR_CASSETTE: "./fixtures/read-file-outside-cwd.json" }),
|
||||
})
|
||||
|
||||
test("reading file outside cwd requires permission when readFilesExternally is off", async ({ terminal }) => {
|
||||
await waitForChatReady(terminal)
|
||||
await typeAndSubmit(terminal, "read my ~/.wezterm.lua file")
|
||||
await waitForApproveReject(terminal)
|
||||
approveTool(terminal)
|
||||
await waitForTaskButtons(terminal)
|
||||
await gracefulShutdown(terminal)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Auto-approve — safe command doesn't require permission @live", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default", { CLINE_VCR_CASSETTE: "./fixtures/safe-command-no-permission.json" }),
|
||||
})
|
||||
|
||||
test("executing 'date' bash command requires no permission", async ({ terminal }) => {
|
||||
await waitForChatReady(terminal)
|
||||
await typeAndSubmit(terminal, "run date bash command and show me the output")
|
||||
await waitForTaskButtons(terminal)
|
||||
await gracefulShutdown(terminal)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Subagents @live", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default", { CLINE_VCR_CASSETTE: "./fixtures/subagents.json" }),
|
||||
})
|
||||
|
||||
test("shows subagent UI when using subagents", async ({ terminal }) => {
|
||||
await waitForChatReady(terminal)
|
||||
await typeAndSubmit(terminal, "tell 3 jokes using subagents")
|
||||
await expectVisible(terminal, /subagent|running subagent/i, { timeout: 30_000 })
|
||||
await waitForTaskButtons(terminal)
|
||||
await gracefulShutdown(terminal)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Web tools — web fetch", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default", { CLINE_VCR_CASSETTE: "./fixtures/web-fetch.json" }),
|
||||
})
|
||||
|
||||
test("uses the web fetch tool", async ({ terminal }) => {
|
||||
await waitForChatReady(terminal)
|
||||
await toggleAutoApproveAll(terminal)
|
||||
await typeAndSubmit(terminal, "summarize this web page in one sentence: https://cline.bot/")
|
||||
await waitForTaskButtons(terminal)
|
||||
await gracefulShutdown(terminal)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("/settings — Account Tab organization editing", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default"),
|
||||
})
|
||||
|
||||
test("can navigate to Account tab and see account info", async ({ terminal }) => {
|
||||
await openSettings(terminal)
|
||||
await goToSettingsTab(terminal, "Account")
|
||||
await expectVisible(terminal, /sign in|account|organization/i)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("Auto-approve all — file edit requires no permission", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: [] },
|
||||
...TERMINAL_WIDE,
|
||||
env: clineEnv("default", { CLINE_VCR_CASSETTE: "./fixtures/file-edit-auto-approve.json" }),
|
||||
})
|
||||
|
||||
test("with auto-approve-all enabled, file edit does not prompt for permission", async ({ terminal }) => {
|
||||
const file = path.resolve("/tmp", "testFile.txt")
|
||||
writeFileSync(file, "This is a test file with content")
|
||||
try {
|
||||
await waitForChatReady(terminal)
|
||||
await toggleAutoApproveAll(terminal)
|
||||
await expectVisible(terminal, "Auto-approve all enabled")
|
||||
await typeAndSubmit(terminal, `append sup to ${file}`)
|
||||
await waitForTaskButtons(terminal)
|
||||
await gracefulShutdown(terminal)
|
||||
} finally {
|
||||
try {
|
||||
unlinkSync(file)
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,5 +6,5 @@ export default defineConfig({
|
||||
trace: true,
|
||||
traceFolder: "./tui-traces",
|
||||
testMatch: "./**/*.test.ts",
|
||||
timeout: 30_000,
|
||||
timeout: 60_000,
|
||||
})
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backward-compatible re-exports.
|
||||
//
|
||||
// Existing test files import from "./utils.js" — this file keeps those
|
||||
// imports working while the canonical implementations live in helpers/.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { clineEnv, testEnv } from "./helpers/env.js"
|
||||
export { expectVisible, typeAndSubmit } from "./helpers/terminal.js"
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from "@microsoft/tui-test"
|
||||
import { CLINE_BIN } from "./helpers/constants.js"
|
||||
import { expectVisible, testEnv } from "./utils.js"
|
||||
import { clineEnv } from "./helpers/env.js"
|
||||
import { expectVisible } from "./helpers/terminal.js"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// cline --version (root flag)
|
||||
@@ -8,7 +9,7 @@ import { expectVisible, testEnv } from "./utils.js"
|
||||
test.describe("cline --version", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["--version"] },
|
||||
env: testEnv("claude-sonnet-4.6"),
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
})
|
||||
|
||||
test("prints the version string", async ({ terminal }) => {
|
||||
@@ -22,7 +23,7 @@ test.describe("cline --version", () => {
|
||||
test.describe("cline -V", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["-V"] },
|
||||
env: testEnv("claude-sonnet-4.6"),
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
})
|
||||
|
||||
test("prints the version string with short flag", async ({ terminal }) => {
|
||||
@@ -36,7 +37,7 @@ test.describe("cline -V", () => {
|
||||
test.describe("cline version subcommand", () => {
|
||||
test.use({
|
||||
program: { file: CLINE_BIN, args: ["version"] },
|
||||
env: testEnv("claude-sonnet-4.6"),
|
||||
env: clineEnv("claude-sonnet-4.6"),
|
||||
})
|
||||
|
||||
test("prints 'Cline CLI version:' message", async ({ terminal }) => {
|
||||
|
||||
Reference in New Issue
Block a user