Compare commits

...
Author SHA1 Message Date
Max Paulus 🥪 f979e8809f refactor vcr to not use nock 2026-03-17 13:35:08 -07:00
Max Paulus 🥪 dd33824611 add tui UI tests
using microsoft/tui-test library, can run many headless versions of
cline and execute ui tests (requires Node <= 20)

improve brittle sleep calls

add nock http recording

bit of test cleanup

add more live tests (with mock responses)

add first test fixture

add CLINE_SECRETS_DIR env var for test recordings

add headless test fixture and update headless tests

add another interactive test

add more CLI e2e tests (now using mocked api responses)

- these use recorded responses
2026-03-17 13:35:08 -07:00
Max Paulus 🥪 81f6cfc876 add nock library to Cline CLI 2026-03-17 13:35:08 -07:00
44 changed files with 5691 additions and 290 deletions
+3 -1
View File
@@ -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
View File
@@ -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()
}
+661
View File
@@ -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)
}
}
+81 -29
View File
@@ -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
View File
@@ -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
View File
@@ -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
+13 -1
View File
@@ -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"),
+85 -28
View File
@@ -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
View File
@@ -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)
})
})
-18
View File
@@ -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"),
})
})
+67 -73
View File
@@ -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)
})
})
+56 -7
View File
@@ -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)
})
})
+1 -1
View File
@@ -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
+8 -7
View File
@@ -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,
})
+132 -2
View File
@@ -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
View File
@@ -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,
})
+4
View File
@@ -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
+27 -9
View File
@@ -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)
}
+20 -4
View File
@@ -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")
}
+93 -15
View File
@@ -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)
})
}
+56 -22
View File
@@ -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)
})
})
+147 -29
View File
@@ -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 {}
}
})
})
+1 -1
View File
@@ -6,5 +6,5 @@ export default defineConfig({
trace: true,
traceFolder: "./tui-traces",
testMatch: "./**/*.test.ts",
timeout: 30_000,
timeout: 60_000,
})
-9
View File
@@ -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"
+5 -4
View File
@@ -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 }) => {