Include full contents of all nested repositories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:25:02 +01:00
parent 14ff8fd54c
commit 2401ed446f
7271 changed files with 1310112 additions and 6 deletions

View File

@@ -0,0 +1,18 @@
import { vi } from "vitest";
export function buildDispatchInboundCaptureMock<T extends Record<string, unknown>>(
actual: T,
setCtx: (ctx: unknown) => void,
) {
const dispatchInboundMessage = vi.fn(async (params: { ctx: unknown }) => {
setCtx(params.ctx);
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
});
return {
...actual,
dispatchInboundMessage,
dispatchInboundMessageWithDispatcher: dispatchInboundMessage,
dispatchInboundMessageWithBufferedDispatcher: dispatchInboundMessage,
};
}

View File

@@ -0,0 +1,43 @@
import {
formatUtcTimestamp,
formatZonedTimestamp,
} from "../../src/infra/format-time/format-datetime.js";
export { escapeRegExp } from "../../src/utils.js";
type EnvelopeTimestampZone = string;
export function formatEnvelopeTimestamp(date: Date, zone: EnvelopeTimestampZone = "utc"): string {
const trimmedZone = zone.trim();
const normalized = trimmedZone.toLowerCase();
const weekday = (() => {
try {
if (normalized === "utc" || normalized === "gmt") {
return new Intl.DateTimeFormat("en-US", { timeZone: "UTC", weekday: "short" }).format(date);
}
if (normalized === "local" || normalized === "host") {
return new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(date);
}
return new Intl.DateTimeFormat("en-US", { timeZone: trimmedZone, weekday: "short" }).format(
date,
);
} catch {
return undefined;
}
})();
if (normalized === "utc" || normalized === "gmt") {
const ts = formatUtcTimestamp(date);
return weekday ? `${weekday} ${ts}` : ts;
}
if (normalized === "local" || normalized === "host") {
const ts = formatZonedTimestamp(date) ?? formatUtcTimestamp(date);
return weekday ? `${weekday} ${ts}` : ts;
}
const ts = formatZonedTimestamp(date, { timeZone: trimmedZone }) ?? formatUtcTimestamp(date);
return weekday ? `${weekday} ${ts}` : ts;
}
export function formatLocalEnvelopeTimestamp(date: Date): string {
return formatEnvelopeTimestamp(date, "local");
}

View File

@@ -0,0 +1,17 @@
import { vi } from "vitest";
export function useFastShortTimeouts(maxDelayMs = 2000): () => void {
const realSetTimeout = setTimeout;
const spy = vi.spyOn(global, "setTimeout").mockImplementation(((
handler: TimerHandler,
timeout?: number,
...args: unknown[]
) => {
const delay = typeof timeout === "number" ? timeout : 0;
if (delay > 0 && delay <= maxDelayMs) {
return realSetTimeout(handler, 0, ...args);
}
return realSetTimeout(handler, delay, ...args);
}) as typeof setTimeout);
return () => spy.mockRestore();
}

View File

@@ -0,0 +1,395 @@
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import { request as httpRequest } from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { GatewayClient } from "../../src/gateway/client.js";
import { connectGatewayClient } from "../../src/gateway/test-helpers.e2e.js";
import { loadOrCreateDeviceIdentity } from "../../src/infra/device-identity.js";
import { sleep } from "../../src/utils.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../src/utils/message-channel.js";
type NodeListPayload = {
nodes?: Array<{ nodeId?: string; connected?: boolean; paired?: boolean }>;
};
export type ChatEventPayload = {
runId?: string;
sessionKey?: string;
state?: string;
message?: unknown;
};
export type GatewayInstance = {
name: string;
port: number;
hookToken: string;
gatewayToken: string;
homeDir: string;
stateDir: string;
configPath: string;
child: ChildProcessWithoutNullStreams;
stdout: string[];
stderr: string[];
};
const GATEWAY_START_TIMEOUT_MS = 60_000;
const GATEWAY_STOP_TIMEOUT_MS = 1_500;
const GATEWAY_CONNECT_STATUS_TIMEOUT_MS = 2_000;
const GATEWAY_NODE_STATUS_TIMEOUT_MS = 4_000;
const GATEWAY_NODE_STATUS_POLL_MS = 20;
const getFreePort = async () => {
const srv = net.createServer();
await new Promise<void>((resolve) => srv.listen(0, "127.0.0.1", resolve));
const addr = srv.address();
if (!addr || typeof addr === "string") {
srv.close();
throw new Error("failed to bind ephemeral port");
}
await new Promise<void>((resolve) => srv.close(() => resolve()));
return addr.port;
};
async function waitForPortOpen(
proc: ChildProcessWithoutNullStreams,
chunksOut: string[],
chunksErr: string[],
port: number,
timeoutMs: number,
) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (proc.exitCode !== null) {
const stdout = chunksOut.join("");
const stderr = chunksErr.join("");
throw new Error(
`gateway exited before listening (code=${String(proc.exitCode)} signal=${String(proc.signalCode)})\n` +
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
);
}
try {
await new Promise<void>((resolve, reject) => {
const socket = net.connect({ host: "127.0.0.1", port });
socket.once("connect", () => {
socket.destroy();
resolve();
});
socket.once("error", (err) => {
socket.destroy();
reject(err);
});
});
return;
} catch {
// keep polling
}
await sleep(10);
}
const stdout = chunksOut.join("");
const stderr = chunksErr.join("");
throw new Error(
`timeout waiting for gateway to listen on port ${port}\n` +
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
);
}
export async function spawnGatewayInstance(name: string): Promise<GatewayInstance> {
const port = await getFreePort();
const hookToken = `token-${name}-${randomUUID()}`;
const gatewayToken = `gateway-${name}-${randomUUID()}`;
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), `openclaw-e2e-${name}-`));
const configDir = path.join(homeDir, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
const configPath = path.join(configDir, "openclaw.json");
const stateDir = path.join(configDir, "state");
const config = {
gateway: {
port,
auth: { mode: "token", token: gatewayToken },
controlUi: { enabled: false },
},
hooks: { enabled: true, token: hookToken, path: "/hooks" },
};
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
const stdout: string[] = [];
const stderr: string[] = [];
let child: ChildProcessWithoutNullStreams | null = null;
try {
child = spawn(
"node",
[
"dist/index.js",
"gateway",
"--port",
String(port),
"--bind",
"loopback",
"--allow-unconfigured",
],
{
cwd: process.cwd(),
env: {
...process.env,
HOME: homeDir,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_STATE_DIR: stateDir,
OPENCLAW_GATEWAY_TOKEN: "",
OPENCLAW_GATEWAY_PASSWORD: "",
OPENCLAW_SKIP_CHANNELS: "1",
OPENCLAW_SKIP_PROVIDERS: "1",
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
OPENCLAW_SKIP_CRON: "1",
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
OPENCLAW_SKIP_CANVAS_HOST: "1",
OPENCLAW_TEST_MINIMAL_GATEWAY: "1",
VITEST: "1",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (d) => stdout.push(String(d)));
child.stderr?.on("data", (d) => stderr.push(String(d)));
await waitForPortOpen(child, stdout, stderr, port, GATEWAY_START_TIMEOUT_MS);
return {
name,
port,
hookToken,
gatewayToken,
homeDir,
stateDir,
configPath,
child,
stdout,
stderr,
};
} catch (err) {
if (child && child.exitCode === null && !child.killed) {
try {
child.kill("SIGKILL");
} catch {
// ignore
}
}
await fs.rm(homeDir, { recursive: true, force: true });
throw err;
}
}
export async function stopGatewayInstance(inst: GatewayInstance) {
if (inst.child.exitCode === null && !inst.child.killed) {
try {
inst.child.kill("SIGTERM");
} catch {
// ignore
}
}
const exited = await Promise.race([
new Promise<boolean>((resolve) => {
if (inst.child.exitCode !== null) {
return resolve(true);
}
inst.child.once("exit", () => resolve(true));
}),
sleep(GATEWAY_STOP_TIMEOUT_MS).then(() => false),
]);
if (!exited && inst.child.exitCode === null && !inst.child.killed) {
try {
inst.child.kill("SIGKILL");
} catch {
// ignore
}
}
await fs.rm(inst.homeDir, { recursive: true, force: true });
}
export async function postJson(
url: string,
body: unknown,
headers?: Record<string, string>,
): Promise<{ status: number; json: unknown }> {
const payload = JSON.stringify(body);
const parsed = new URL(url);
return await new Promise<{ status: number; json: unknown }>((resolve, reject) => {
const req = httpRequest(
{
method: "POST",
hostname: parsed.hostname,
port: Number(parsed.port),
path: `${parsed.pathname}${parsed.search}`,
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
...headers,
},
},
(res) => {
let data = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
let json: unknown = null;
if (data.trim()) {
try {
json = JSON.parse(data);
} catch {
json = data;
}
}
resolve({ status: res.statusCode ?? 0, json });
});
},
);
req.on("error", reject);
req.write(payload);
req.end();
});
}
export async function connectNode(
inst: GatewayInstance,
label: string,
): Promise<{ client: GatewayClient; nodeId: string }> {
const identityPath = path.join(inst.homeDir, `${label}-device.json`);
const deviceIdentity = loadOrCreateDeviceIdentity(identityPath);
const nodeId = deviceIdentity.deviceId;
const client = await connectGatewayClient({
url: `ws://127.0.0.1:${inst.port}`,
token: inst.gatewayToken,
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName: label,
clientVersion: "1.0.0",
platform: "ios",
mode: GATEWAY_CLIENT_MODES.NODE,
role: "node",
scopes: [],
caps: ["system"],
commands: ["system.run"],
deviceIdentity,
timeoutMessage: `timeout waiting for ${label} to connect`,
});
return { client, nodeId };
}
async function connectStatusClient(
inst: GatewayInstance,
timeoutMs = GATEWAY_CONNECT_STATUS_TIMEOUT_MS,
): Promise<GatewayClient> {
let settled = false;
let timer: NodeJS.Timeout | null = null;
return await new Promise<GatewayClient>((resolve, reject) => {
const finish = (err?: Error) => {
if (settled) {
return;
}
settled = true;
if (timer) {
clearTimeout(timer);
}
if (err) {
reject(err);
return;
}
resolve(client);
};
const client = new GatewayClient({
url: `ws://127.0.0.1:${inst.port}`,
connectDelayMs: 0,
token: inst.gatewayToken,
clientName: GATEWAY_CLIENT_NAMES.CLI,
clientDisplayName: `status-${inst.name}`,
clientVersion: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.CLI,
onHelloOk: () => {
finish();
},
onConnectError: (err) => finish(err),
onClose: (code, reason) => {
finish(new Error(`gateway closed (${code}): ${reason}`));
},
});
timer = setTimeout(() => {
finish(new Error("timeout waiting for node.list"));
}, timeoutMs);
client.start();
});
}
export async function waitForNodeStatus(
inst: GatewayInstance,
nodeId: string,
timeoutMs = GATEWAY_NODE_STATUS_TIMEOUT_MS,
) {
const deadline = Date.now() + timeoutMs;
const client = await connectStatusClient(
inst,
Math.min(GATEWAY_CONNECT_STATUS_TIMEOUT_MS, timeoutMs),
);
try {
while (Date.now() < deadline) {
const list = await client.request<NodeListPayload>("node.list", {});
const match = list.nodes?.find((n) => n.nodeId === nodeId);
if (match?.connected && match?.paired) {
return;
}
await sleep(GATEWAY_NODE_STATUS_POLL_MS);
}
} finally {
client.stop();
}
throw new Error(`timeout waiting for node status for ${nodeId}`);
}
export function extractFirstTextBlock(message: unknown): string | undefined {
if (!message || typeof message !== "object") {
return undefined;
}
const content = (message as { content?: unknown }).content;
if (!Array.isArray(content) || content.length === 0) {
return undefined;
}
const first = content[0];
if (!first || typeof first !== "object") {
return undefined;
}
const text = (first as { text?: unknown }).text;
return typeof text === "string" ? text : undefined;
}
export async function waitForChatFinalEvent(params: {
events: ChatEventPayload[];
runId: string;
sessionKey: string;
timeoutMs?: number;
}): Promise<ChatEventPayload> {
const deadline = Date.now() + (params.timeoutMs ?? 15_000);
while (Date.now() < deadline) {
const match = params.events.find(
(evt) =>
evt.runId === params.runId && evt.sessionKey === params.sessionKey && evt.state === "final",
);
if (match) {
return match;
}
await sleep(20);
}
throw new Error(`timeout waiting for final chat event (runId=${params.runId})`);
}

View File

@@ -0,0 +1,20 @@
import type { MsgContext } from "../../src/auto-reply/templating.js";
import { buildDispatchInboundCaptureMock } from "./dispatch-inbound-capture.js";
export type InboundContextCapture = {
ctx: MsgContext | undefined;
};
export function createInboundContextCapture(): InboundContextCapture {
return { ctx: undefined };
}
export async function buildDispatchInboundContextCapture(
importOriginal: <T extends Record<string, unknown>>() => Promise<T>,
capture: InboundContextCapture,
) {
const actual = await importOriginal<typeof import("../../src/auto-reply/dispatch.js")>();
return buildDispatchInboundCaptureMock(actual, (ctx) => {
capture.ctx = ctx as MsgContext;
});
}

View File

@@ -0,0 +1,9 @@
import { vi } from "vitest";
import { createInboundContextCapture } from "./inbound-contract-capture.js";
import { buildDispatchInboundContextCapture } from "./inbound-contract-capture.js";
export const inboundCtxCapture = createInboundContextCapture();
vi.mock("../../src/auto-reply/dispatch.js", async (importOriginal) => {
return await buildDispatchInboundContextCapture(importOriginal, inboundCtxCapture);
});

View File

@@ -0,0 +1,19 @@
import { expect } from "vitest";
import type { MsgContext } from "../../src/auto-reply/templating.js";
import { normalizeChatType } from "../../src/channels/chat-type.js";
import { resolveConversationLabel } from "../../src/channels/conversation-label.js";
import { validateSenderIdentity } from "../../src/channels/sender-identity.js";
export function expectInboundContextContract(ctx: MsgContext) {
expect(validateSenderIdentity(ctx)).toEqual([]);
expect(ctx.Body).toBeTypeOf("string");
expect(ctx.BodyForAgent).toBeTypeOf("string");
expect(ctx.BodyForCommands).toBeTypeOf("string");
const chatType = normalizeChatType(ctx.ChatType);
if (chatType && chatType !== "direct") {
const label = ctx.ConversationLabel?.trim() || resolveConversationLabel(ctx);
expect(label).toBeTruthy();
}
}

View File

@@ -0,0 +1,65 @@
import { vi } from "vitest";
export type SearchImpl = () => Promise<unknown[]>;
export type MemoryReadParams = { relPath: string; from?: number; lines?: number };
export type MemoryReadResult = { text: string; path: string };
type MemoryBackend = "builtin" | "qmd";
let backend: MemoryBackend = "builtin";
let searchImpl: SearchImpl = async () => [];
let readFileImpl: (params: MemoryReadParams) => Promise<MemoryReadResult> = async (params) => ({
text: "",
path: params.relPath,
});
const stubManager = {
search: vi.fn(async () => await searchImpl()),
readFile: vi.fn(async (params: MemoryReadParams) => await readFileImpl(params)),
status: () => ({
backend,
files: 1,
chunks: 1,
dirty: false,
workspaceDir: "/workspace",
dbPath: "/workspace/.memory/index.sqlite",
provider: "builtin",
model: "builtin",
requestedProvider: "builtin",
sources: ["memory" as const],
sourceCounts: [{ source: "memory" as const, files: 1, chunks: 1 }],
}),
sync: vi.fn(),
probeVectorAvailability: vi.fn(async () => true),
close: vi.fn(),
};
vi.mock("../../src/memory/index.js", () => ({
getMemorySearchManager: async () => ({ manager: stubManager }),
}));
export function setMemoryBackend(next: MemoryBackend): void {
backend = next;
}
export function setMemorySearchImpl(next: SearchImpl): void {
searchImpl = next;
}
export function setMemoryReadFileImpl(
next: (params: MemoryReadParams) => Promise<MemoryReadResult>,
): void {
readFileImpl = next;
}
export function resetMemoryToolMockState(overrides?: {
backend?: MemoryBackend;
searchImpl?: SearchImpl;
readFileImpl?: (params: MemoryReadParams) => Promise<MemoryReadResult>;
}): void {
backend = overrides?.backend ?? "builtin";
searchImpl = overrides?.searchImpl ?? (async () => []);
readFileImpl =
overrides?.readFileImpl ??
(async (params: MemoryReadParams) => ({ text: "", path: params.relPath }));
vi.clearAllMocks();
}

View File

@@ -0,0 +1,27 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage } from "node:http";
export function createMockIncomingRequest(chunks: string[]): IncomingMessage {
const req = new EventEmitter() as IncomingMessage & {
destroyed?: boolean;
destroy: (error?: Error) => IncomingMessage;
};
req.destroyed = false;
req.headers = {};
req.destroy = () => {
req.destroyed = true;
return req;
};
void Promise.resolve().then(() => {
for (const chunk of chunks) {
req.emit("data", Buffer.from(chunk, "utf-8"));
if (req.destroyed) {
return;
}
}
req.emit("end");
});
return req;
}

View File

@@ -0,0 +1,9 @@
import { stripAnsi } from "../../src/terminal/ansi.js";
export function normalizeTestText(input: string): string {
return stripAnsi(input)
.replaceAll("\r\n", "\n")
.replaceAll("…", "...")
.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "?")
.replace(/[\uD800-\uDFFF]/g, "?");
}

View File

@@ -0,0 +1,16 @@
import path from "node:path";
export function isPathWithinBase(base: string, target: string): boolean {
if (process.platform === "win32") {
const normalizedBase = path.win32.normalize(path.win32.resolve(base));
const normalizedTarget = path.win32.normalize(path.win32.resolve(target));
const rel = path.win32.relative(normalizedBase.toLowerCase(), normalizedTarget.toLowerCase());
return rel === "" || (!rel.startsWith("..") && !path.win32.isAbsolute(rel));
}
const normalizedBase = path.resolve(base);
const normalizedTarget = path.resolve(target);
const rel = path.relative(normalizedBase, normalizedTarget);
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
}

View File

@@ -0,0 +1,25 @@
import { sleep } from "../../src/utils.js";
export type PollOptions = {
timeoutMs?: number;
intervalMs?: number;
};
export async function pollUntil<T>(
fn: () => Promise<T | null | undefined>,
opts: PollOptions = {},
): Promise<T | undefined> {
const timeoutMs = opts.timeoutMs ?? 2000;
const intervalMs = opts.intervalMs ?? 25;
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const value = await fn();
if (value !== null && value !== undefined) {
return value;
}
await sleep(intervalMs);
}
return undefined;
}

View File

@@ -0,0 +1,129 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
type EnvValue = string | undefined | ((home: string) => string | undefined);
type EnvSnapshot = {
home: string | undefined;
userProfile: string | undefined;
homeDrive: string | undefined;
homePath: string | undefined;
openclawHome: string | undefined;
stateDir: string | undefined;
};
function snapshotEnv(): EnvSnapshot {
return {
home: process.env.HOME,
userProfile: process.env.USERPROFILE,
homeDrive: process.env.HOMEDRIVE,
homePath: process.env.HOMEPATH,
openclawHome: process.env.OPENCLAW_HOME,
stateDir: process.env.OPENCLAW_STATE_DIR,
};
}
function restoreEnv(snapshot: EnvSnapshot) {
const restoreKey = (key: string, value: string | undefined) => {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
};
restoreKey("HOME", snapshot.home);
restoreKey("USERPROFILE", snapshot.userProfile);
restoreKey("HOMEDRIVE", snapshot.homeDrive);
restoreKey("HOMEPATH", snapshot.homePath);
restoreKey("OPENCLAW_HOME", snapshot.openclawHome);
restoreKey("OPENCLAW_STATE_DIR", snapshot.stateDir);
}
function snapshotExtraEnv(keys: string[]): Record<string, string | undefined> {
const snapshot: Record<string, string | undefined> = {};
for (const key of keys) {
snapshot[key] = process.env[key];
}
return snapshot;
}
function restoreExtraEnv(snapshot: Record<string, string | undefined>) {
for (const [key, value] of Object.entries(snapshot)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
function setTempHome(base: string) {
process.env.HOME = base;
process.env.USERPROFILE = base;
// Ensure tests using HOME isolation aren't affected by leaked OPENCLAW_HOME.
delete process.env.OPENCLAW_HOME;
process.env.OPENCLAW_STATE_DIR = path.join(base, ".openclaw");
if (process.platform !== "win32") {
return;
}
const match = base.match(/^([A-Za-z]:)(.*)$/);
if (!match) {
return;
}
process.env.HOMEDRIVE = match[1];
process.env.HOMEPATH = match[2] || "\\";
}
export async function withTempHome<T>(
fn: (home: string) => Promise<T>,
opts: { env?: Record<string, EnvValue>; prefix?: string } = {},
): Promise<T> {
const base = await fs.mkdtemp(path.join(os.tmpdir(), opts.prefix ?? "openclaw-test-home-"));
const snapshot = snapshotEnv();
const envKeys = Object.keys(opts.env ?? {});
for (const key of envKeys) {
if (key === "HOME" || key === "USERPROFILE" || key === "HOMEDRIVE" || key === "HOMEPATH") {
throw new Error(`withTempHome: use built-in home env (got ${key})`);
}
}
const envSnapshot = snapshotExtraEnv(envKeys);
setTempHome(base);
await fs.mkdir(path.join(base, ".openclaw", "agents", "main", "sessions"), { recursive: true });
if (opts.env) {
for (const [key, raw] of Object.entries(opts.env)) {
const value = typeof raw === "function" ? raw(base) : raw;
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
try {
return await fn(base);
} finally {
restoreExtraEnv(envSnapshot);
restoreEnv(snapshot);
try {
if (process.platform === "win32") {
await fs.rm(base, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 50,
});
} else {
await fs.rm(base, {
recursive: true,
force: true,
});
}
} catch {
// ignore cleanup failures in tests
}
}
}

View File

@@ -0,0 +1,17 @@
import { vi } from "vitest";
import type { WizardPrompter } from "../../src/wizard/prompts.js";
export function createWizardPrompter(overrides?: Partial<WizardPrompter>): WizardPrompter {
const select = vi.fn(async () => "quickstart") as unknown as WizardPrompter["select"];
return {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async () => {}),
select,
multiselect: vi.fn(async () => []),
text: vi.fn(async () => ""),
confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
...overrides,
};
}