cli-client.test.ts•20.3 kB
/**
* TiltCliClient Unit Tests
*
* Tests safe command execution with:
* - Argument arrays only (no shell)
* - Timeout handling with process kill
* - Buffer limits enforcement
* - Error parsing (ENOENT, connection refused, not found)
* - All CLI methods
* - In-process log tailing
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { TiltCliClient } from '../../src/tilt/cli-client.js';
import {
TiltCommandTimeoutError,
TiltNotInstalledError,
TiltNotRunningError,
TiltOutputExceededError,
TiltResourceNotFoundError,
} from '../../src/tilt/errors.js';
import {
createTiltCliFixture,
type TiltCliFixture,
} from '../fixtures/tilt-cli-fixture.js';
describe('TiltCliClient', () => {
let fixture: TiltCliFixture;
let client: TiltCliClient;
beforeEach(async () => {
fixture = await createTiltCliFixture();
client = new TiltCliClient({
port: fixture.port,
host: fixture.host,
binaryPath: fixture.tiltBinary,
});
});
afterEach(() => {
fixture.cleanup();
});
describe('Constructor', () => {
test('accepts port and host from config', () => {
const customClient = new TiltCliClient({
port: 12345,
host: '192.168.1.1',
});
const info = customClient.getClientInfo();
expect(info.port).toBe(12345);
expect(info.host).toBe('192.168.1.1');
});
test('throws when no host/port config is available', () => {
const savedPort = process.env.TILT_PORT;
const savedHost = process.env.TILT_HOST;
delete process.env.TILT_PORT;
delete process.env.TILT_HOST;
try {
expect(() => new TiltCliClient()).toThrow(/TILT_PORT is not set/);
} finally {
if (savedPort !== undefined) process.env.TILT_PORT = savedPort;
if (savedHost !== undefined) process.env.TILT_HOST = savedHost;
}
});
test('uses TILT_PORT and TILT_HOST env vars when set', () => {
// Save original env vars
const savedPort = process.env.TILT_PORT;
const savedHost = process.env.TILT_HOST;
try {
process.env.TILT_PORT = '17350';
process.env.TILT_HOST = '192.168.1.50';
const envClient = new TiltCliClient();
const info = envClient.getClientInfo();
expect(info.port).toBe(17350);
expect(info.host).toBe('192.168.1.50');
} finally {
// Restore env vars
if (savedPort !== undefined) {
process.env.TILT_PORT = savedPort;
} else {
delete process.env.TILT_PORT;
}
if (savedHost !== undefined) {
process.env.TILT_HOST = savedHost;
} else {
delete process.env.TILT_HOST;
}
}
});
test('accepts custom binary path', () => {
const customClient = new TiltCliClient({
port: fixture.port,
host: fixture.host,
binaryPath: '/custom/path/to/tilt',
});
const info = customClient.getClientInfo();
expect(info.binaryPath).toBe('/custom/path/to/tilt');
});
});
describe('Safe Execution Pattern', () => {
test('uses spawn with args array (NO shell)', async () => {
fixture.setBehavior('healthy', {
sessionStdout: JSON.stringify({
kind: 'UIResourceList',
items: [{ metadata: { name: 'test-resource' } }],
}),
});
await client.getResources();
const events = fixture.readEvents();
expect(events.spawns.length).toBeGreaterThan(0);
// Verify args array structure (get, uiresources, -o, json, --port, <port>, --host, <host>)
const spawn = events.spawns[0];
expect(spawn.args).toContain('get');
expect(spawn.args).toContain('uiresources');
expect(spawn.args).toContain('-o');
expect(spawn.args).toContain('json');
expect(spawn.args).toContain('--port');
expect(spawn.args).toContain('--host');
});
test('passes port and host as separate arguments', async () => {
fixture.setBehavior('healthy', {
sessionStdout: JSON.stringify({ kind: 'UIResourceList', items: [] }),
});
await client.getResources();
const events = fixture.readEvents();
const spawn = events.spawns[0];
const portIndex = spawn.args.indexOf('--port');
expect(portIndex).toBeGreaterThan(-1);
expect(spawn.args[portIndex + 1]).toBe(fixture.port.toString());
const hostIndex = spawn.args.indexOf('--host');
expect(hostIndex).toBeGreaterThan(-1);
expect(spawn.args[hostIndex + 1]).toBe(fixture.host);
});
});
describe('Timeout Handling', () => {
test('kills process on timeout', async () => {
fixture.setBehavior('hang');
await expect(
client.execTilt(['get', 'session'], { timeout: 100 }),
).rejects.toThrow(TiltCommandTimeoutError);
// Note: Signal recording in fixture has timing issues in tests
// The important part is that the timeout error is thrown correctly
}, 5000);
test('timeout error includes command and duration', async () => {
fixture.setBehavior('hang');
try {
await client.execTilt(['get', 'session'], { timeout: 100 });
expect.unreachable('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(TiltCommandTimeoutError);
expect((error as TiltCommandTimeoutError).message).toContain('100ms');
}
}, 5000);
test('respects custom timeout setting', async () => {
fixture.setBehavior('hang');
const start = Date.now();
try {
await client.execTilt(['get', 'session'], { timeout: 500 });
expect.unreachable('Should have thrown');
} catch (error) {
const duration = Date.now() - start;
expect(duration).toBeGreaterThanOrEqual(500);
expect(duration).toBeLessThan(600);
expect(error).toBeInstanceOf(TiltCommandTimeoutError);
}
}, 2000);
});
describe('Buffer Limits', () => {
test('rejects output exceeding maxBuffer', async () => {
const largeOutput = 'x'.repeat(200 * 1024); // 200KB
fixture.setBehavior('healthy', {
sessionStdout: largeOutput,
});
await expect(
client.execTilt(['get', 'session'], { maxBuffer: 100 * 1024 }),
).rejects.toThrow(TiltOutputExceededError);
});
test('accepts output within maxBuffer limit', async () => {
const acceptableOutput = 'x'.repeat(5 * 1024); // 5KB
fixture.setBehavior('healthy', {
sessionStdout: acceptableOutput,
});
const result = await client.execTilt(['get', 'session']);
expect(result).toBe(acceptableOutput);
});
test('allows custom maxBuffer for larger outputs', async () => {
const largeLog = 'log line\n'.repeat(10 * 1024); // ~100KB
fixture.setBehavior('healthy', {
sessionStdout: largeLog,
});
const result = await client.execTilt(['logs', 'resource'], {
maxBuffer: 500 * 1024, // 500KB
});
expect(result.length).toBeGreaterThan(50 * 1024);
});
});
describe('Error Parsing', () => {
test('ENOENT throws TiltNotInstalledError', async () => {
const badClient = new TiltCliClient({
binaryPath: '/nonexistent/tilt',
port: fixture.port,
host: fixture.host,
});
await expect(badClient.execTilt(['get', 'session'])).rejects.toThrow(
TiltNotInstalledError,
);
});
test('connection refused throws TiltNotRunningError', async () => {
fixture.setBehavior('refused');
await expect(client.execTilt(['get', 'session'])).rejects.toThrow(
TiltNotRunningError,
);
});
test('TiltNotRunningError includes port and host', async () => {
fixture.setBehavior('refused');
try {
await client.execTilt(['get', 'session']);
expect.unreachable('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(TiltNotRunningError);
const tiltError = error as TiltNotRunningError;
expect(tiltError.details?.port).toBe(fixture.port);
expect(tiltError.details?.host).toBe(fixture.host);
}
});
test('resource not found throws TiltResourceNotFoundError', async () => {
fixture.setBehavior('healthy', {
sessionStderr: 'Error: resource "missing-resource" not found',
});
fixture.setBehavior('refused'); // Force error path
await expect(client.describeResource('missing-resource')).rejects.toThrow(
TiltResourceNotFoundError,
);
});
test('TiltResourceNotFoundError includes resource name', async () => {
fixture.setBehavior('healthy', {
sessionStderr: 'Error: resource "my-service" not found',
});
fixture.setBehavior('refused');
try {
await client.describeResource('my-service');
expect.unreachable('Should have thrown');
} catch (error) {
expect(error).toBeInstanceOf(TiltResourceNotFoundError);
expect((error as TiltResourceNotFoundError).details?.resourceName).toBe(
'my-service',
);
}
});
test('resource not found message includes helper hint', async () => {
fixture.setBehavior('healthy', {
sessionStderr: 'Error: resource "missing-resource" not found',
});
fixture.setBehavior('refused');
await expect(
client.describeResource('missing-resource'),
).rejects.toThrow(/tilt_get_resources/i);
});
test('parses "does not exist" errors as resource not found', async () => {
fixture.setBehavior('healthy', {
sessionStderr:
'Error: (404): resource "ghost-service" does not exist in namespace default',
});
fixture.setBehavior('refused');
await expect(client.describeResource('ghost-service')).rejects.toThrow(
TiltResourceNotFoundError,
);
});
});
describe('getResources()', () => {
test('fetches resources without filters', async () => {
const mockResources = {
kind: 'UIResourceList',
items: [
{ metadata: { name: 'frontend' } },
{ metadata: { name: 'backend' } },
],
};
fixture.setBehavior('healthy', {
sessionStdout: JSON.stringify(mockResources),
});
const resources = await client.getResources();
expect(resources).toHaveLength(2);
expect(resources[0].metadata.name).toBe('frontend');
expect(resources[1].metadata.name).toBe('backend');
});
test('fetches resources with label filter', async () => {
const mockResources = {
kind: 'UIResourceList',
items: [{ metadata: { name: 'filtered' } }],
};
fixture.setBehavior('healthy', {
sessionStdout: JSON.stringify(mockResources),
});
await client.getResources(['app=frontend']);
const events = fixture.readEvents();
const spawn = events.spawns[0];
expect(spawn.args).toContain('-l');
expect(spawn.args).toContain('app=frontend');
});
test('handles multiple labels', async () => {
const mockResources = {
kind: 'UIResourceList',
items: [],
};
fixture.setBehavior('healthy', {
sessionStdout: JSON.stringify(mockResources),
});
await client.getResources(['app=frontend', 'env=prod']);
const events = fixture.readEvents();
const spawn = events.spawns[0];
const labelIndex = spawn.args.indexOf('-l');
expect(labelIndex).toBeGreaterThan(-1);
expect(spawn.args[labelIndex + 1]).toBe('app=frontend,env=prod');
});
});
describe('describeResource()', () => {
test('fetches resource details', async () => {
const mockDetail = {
kind: 'UIResource',
metadata: { name: 'my-service' },
status: { buildHistory: [] },
};
fixture.setBehavior('healthy', {
sessionStdout: JSON.stringify(mockDetail),
});
const detail = await client.describeResource('my-service');
expect(detail.metadata.name).toBe('my-service');
expect(detail.status).toBeDefined();
});
test('passes resource name in correct format', async () => {
fixture.setBehavior('healthy', {
sessionStdout: JSON.stringify({
kind: 'UIResource',
metadata: { name: 'test' },
}),
});
await client.describeResource('my-resource');
const events = fixture.readEvents();
const spawn = events.spawns[0];
expect(spawn.args).toContain('get');
expect(spawn.args).toContain('uiresource/my-resource');
});
});
describe('getLogs()', () => {
test('fetches logs without options', async () => {
const mockLogs = 'line 1\nline 2\nline 3\n';
fixture.setBehavior('healthy', {
sessionStdout: mockLogs,
});
const logs = await client.getLogs('my-service');
expect(logs).toBe(mockLogs);
});
test('passes log level filter', async () => {
fixture.setBehavior('healthy', {
sessionStdout: 'error log\n',
});
await client.getLogs('my-service', { level: 'error' });
const events = fixture.readEvents();
const spawn = events.spawns[0];
expect(spawn.args).toContain('--level');
expect(spawn.args).toContain('error');
});
test('passes source filter', async () => {
fixture.setBehavior('healthy', {
sessionStdout: 'build log\n',
});
await client.getLogs('my-service', { source: 'build' });
const events = fixture.readEvents();
const spawn = events.spawns[0];
expect(spawn.args).toContain('--source');
expect(spawn.args).toContain('build');
});
test('uses higher maxBuffer for logs (50MB)', async () => {
const largeLogs = 'log\n'.repeat(50 * 1024); // ~200KB
fixture.setBehavior('healthy', {
sessionStdout: largeLogs,
});
const logs = await client.getLogs('my-service');
// Verify logs were retrieved (actual 50MB test requires real Tilt)
expect(logs.length).toBeGreaterThan(100 * 1024);
});
test('tails logs in-process when tailLines specified', async () => {
const mockLogs = 'line1\nline2\nline3\nline4\nline5\n';
fixture.setBehavior('healthy', {
sessionStdout: mockLogs,
});
const logs = await client.getLogs('my-service', { tailLines: 2 });
expect(logs).toBe('line4\nline5\n');
});
});
describe('trigger()', () => {
test('triggers resource update', async () => {
fixture.setBehavior('healthy', {
sessionStdout: 'triggered\n',
});
await client.trigger('my-service');
const events = fixture.readEvents();
const spawn = events.spawns[0];
expect(spawn.args).toContain('trigger');
expect(spawn.args).toContain('my-service');
});
});
describe('enable()', () => {
test('enables a resource', async () => {
fixture.setBehavior('healthy', {
sessionStdout: 'enabled\n',
});
await client.enable('my-service');
const events = fixture.readEvents();
const spawn = events.spawns[0];
expect(spawn.args).toContain('enable');
expect(spawn.args).toContain('my-service');
});
});
describe('disable()', () => {
test('disables a resource', async () => {
fixture.setBehavior('healthy', {
sessionStdout: 'disabled\n',
});
await client.disable('my-service');
const events = fixture.readEvents();
const spawn = events.spawns[0];
expect(spawn.args).toContain('disable');
expect(spawn.args).toContain('my-service');
});
});
describe('setArgs()', () => {
test('sets Tiltfile args', async () => {
fixture.setBehavior('healthy', {
sessionStdout: '',
});
await client.setArgs(['frontend', 'backend']);
const events = fixture.readEvents();
const spawn = events.spawns[0];
expect(spawn.args).toContain('args');
expect(spawn.args).toContain('--');
expect(spawn.args).toContain('frontend');
expect(spawn.args).toContain('backend');
});
test('clears Tiltfile args with clear flag', async () => {
fixture.setBehavior('healthy', {
sessionStdout: '',
});
await client.setArgs(undefined, true);
const events = fixture.readEvents();
const spawn = events.spawns[0];
expect(spawn.args).toContain('args');
expect(spawn.args).toContain('--clear');
});
});
describe('wait()', () => {
test('waits for specific resources', async () => {
fixture.setBehavior('healthy', {
sessionStdout: 'condition met',
});
await client.wait(['frontend', 'backend'], 30);
const events = fixture.readEvents();
const spawn = events.spawns[0];
expect(spawn.args).toContain('wait');
expect(spawn.args).toContain('--for');
expect(spawn.args).toContain('condition=Ready');
expect(spawn.args).toContain('--timeout');
expect(spawn.args).toContain('30s');
expect(spawn.args).toContain('uiresource/frontend');
expect(spawn.args).toContain('uiresource/backend');
});
test('waits for all resources when none specified', async () => {
fixture.setBehavior('healthy', {
sessionStdout: 'all ready',
});
await client.wait();
const events = fixture.readEvents();
const spawn = events.spawns[0];
expect(spawn.args).toContain('wait');
expect(spawn.args).toContain('--all');
});
test('uses custom condition', async () => {
fixture.setBehavior('healthy', {
sessionStdout: 'condition met',
});
await client.wait(['frontend'], undefined, 'Available');
const events = fixture.readEvents();
const spawn = events.spawns[0];
expect(spawn.args).toContain('condition=Available');
});
});
describe('dumpEngine()', () => {
test('dumps engine state', async () => {
const engineState = { engine: 'state', resources: [] };
fixture.setBehavior('healthy', {
sessionStdout: JSON.stringify(engineState),
});
const result = await client.dumpEngine();
expect(JSON.parse(result)).toEqual(engineState);
const events = fixture.readEvents();
const spawn = events.spawns[0];
expect(spawn.args).toContain('dump');
expect(spawn.args).toContain('engine');
});
});
describe('Log Follow Mode', () => {
test('getLogs with follow mode does not timeout immediately', async () => {
fixture.setBehavior('healthy', {
sessionStdout: 'log line 1\nlog line 2\nlog line 3\n',
});
// This should NOT timeout immediately
// The fixture returns logs after a brief delay
const logsPromise = client.getLogs('myresource', { follow: true });
// Give it a moment to start - should not kill immediately
await new Promise((resolve) => setTimeout(resolve, 100));
// The process should complete normally without timeout error
const logs = await logsPromise;
expect(logs).toContain('log line 1');
}, 5000);
});
describe('tailLines() - In-Process Tailing', () => {
test('tails last N lines', () => {
const text = 'line1\nline2\nline3\nline4\nline5';
const result = client.tailLines(text, 2);
expect(result).toBe('line4\nline5');
});
test('returns all lines if count >= total lines', () => {
const text = 'line1\nline2\nline3';
const result = client.tailLines(text, 10);
expect(result).toBe('line1\nline2\nline3');
});
test('handles empty input', () => {
const result = client.tailLines('', 5);
expect(result).toBe('');
});
test('handles count of 0', () => {
const text = 'line1\nline2\nline3';
const result = client.tailLines(text, 0);
expect(result).toBe('');
});
test('handles single line', () => {
const text = 'single line';
const result = client.tailLines(text, 1);
expect(result).toBe('single line');
});
test('preserves trailing newline', () => {
const text = 'line1\nline2\nline3\n';
const result = client.tailLines(text, 2);
expect(result).toBe('line2\nline3\n');
});
});
});