Skip to main content
Glama
cli-client.test.ts20.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'); }); }); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/0xBigBoss/tilt-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server