Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions packages/eas-cli/src/chat/__tests__/chatClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import nock from 'nock';

import { makeUserMessage, streamChatResponseAsync } from '../chatClient';

jest.mock('../../log');
jest.mock('../../ora', () => ({
ora: () => {
const spinner = {
isSpinning: true,
text: '',
start: () => spinner,
stop: () => {
spinner.isSpinning = false;
return spinner;
},
};
return spinner;
},
}));

const WEBSITE_ORIGIN = 'https://expo.dev';

function sseBody(frames: object[]): string {
return [...frames.map(frame => `data: ${JSON.stringify(frame)}`), 'data: [DONE]', ''].join(
'\n\n'
);
}

describe(streamChatResponseAsync, () => {
let writeSpy: jest.SpyInstance;

beforeEach(() => {
nock.cleanAll();
writeSpy = jest.spyOn(process.stdout, 'write').mockReturnValue(true);
});

afterEach(() => {
writeSpy.mockRestore();
});

it('streams text deltas to stdout and returns the full text', async () => {
nock(WEBSITE_ORIGIN)
.post('/api/chat')
.reply(
200,
sseBody([
{ type: 'start' },
{ type: 'tool-input-available', toolCallId: 't1', toolName: 'get_latest_builds' },
{ type: 'text-start', id: '0' },
{ type: 'text-delta', id: '0', delta: 'Your latest ' },
{ type: 'text-delta', id: '0', delta: 'build passed.' },
{ type: 'finish' },
]),
{ 'content-type': 'text/event-stream' }
);

const result = await streamChatResponseAsync({
messages: [makeUserMessage('how are my builds?')],
accountName: 'my-account',
sessionSecret: '{"id":"session-id","version":"1"}',
stream: true,
});

const written = writeSpy.mock.calls.map(call => call[0]).join('');
expect(written).toContain('Your latest ');
expect(written).toContain('build passed.');
expect(result.text).toBe('Your latest build passed.');
});

it('captures tool calls with inputs and outputs without writing to stdout when not streaming', async () => {
nock(WEBSITE_ORIGIN)
.post('/api/chat')
.reply(
200,
sseBody([
{
type: 'tool-input-available',
toolCallId: 't1',
toolName: 'get_latest_builds',
input: { limit: 1 },
},
{ type: 'tool-output-available', toolCallId: 't1', output: { builds: ['b1'] } },
{ type: 'text-delta', delta: 'Done.' },
{ type: 'finish' },
])
);

const result = await streamChatResponseAsync({
messages: [makeUserMessage('builds?')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: false,
});

expect(writeSpy).not.toHaveBeenCalled();
expect(result.text).toBe('Done.');
expect(result.toolCalls).toEqual([
{
toolName: 'get_latest_builds',
input: { limit: 1 },
output: { builds: ['b1'] },
errorText: undefined,
},
]);
});

it('sends the session secret as a cookie and the account as a header', async () => {
let capturedHeaders: Record<string, string | string[]> = {};
let capturedBody: any;
nock(WEBSITE_ORIGIN)
.post('/api/chat')
.reply(function (_uri, body) {
capturedHeaders = this.req.headers;
capturedBody = body;
return [200, sseBody([{ type: 'text-delta', delta: 'hi' }])];
});

await streamChatResponseAsync({
messages: [makeUserMessage('hello')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: false,
});

const cookie = [capturedHeaders.cookie].flat().join('; ');
expect(cookie).toContain('io.expo.auth.sessionSecret=');
expect(cookie).toContain(encodeURIComponent('{"id":"abc","version":"1"}'));
expect([capturedHeaders['x-account-name']].flat()[0]).toBe('my-account');
// A steering system message is prepended, followed by the caller's messages.
expect(capturedBody.messages[0].role).toBe('system');
expect(capturedBody.messages[0].parts[0].text).toContain('https://expo.dev');
expect(capturedBody.messages[1]).toEqual(
expect.objectContaining({ role: 'user', parts: [{ type: 'text', text: 'hello' }] })
);
});

it('maps a 429 response to a usage-limit error', async () => {
nock(WEBSITE_ORIGIN).post('/api/chat').reply(429, 'Chat usage limit reached');

await expect(
streamChatResponseAsync({
messages: [makeUserMessage('hello')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: false,
})
).rejects.toThrow(/usage limit/);
});

it('maps a 403 response to a not-enabled error', async () => {
nock(WEBSITE_ORIGIN).post('/api/chat').reply(403, 'Forbidden');

await expect(
streamChatResponseAsync({
messages: [makeUserMessage('hello')],
accountName: 'my-account',
sessionSecret: '{"id":"abc","version":"1"}',
stream: false,
})
).rejects.toThrow(/not enabled/);
});
});
88 changes: 88 additions & 0 deletions packages/eas-cli/src/chat/__tests__/renderMarkdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import chalk from 'chalk';

import {
createMarkdownRenderState,
renderInlineMarkdown,
renderMarkdownLine,
wrapToWidth,
} from '../renderMarkdown';

describe(renderInlineMarkdown, () => {
it('renders bold with chalk.bold', () => {
expect(renderInlineMarkdown('a **bold** word')).toBe(`a ${chalk.bold('bold')} word`);
expect(renderInlineMarkdown('a __bold__ word')).toBe(`a ${chalk.bold('bold')} word`);
});

it('renders inline code with chalk.cyan', () => {
expect(renderInlineMarkdown('run `eas build` now')).toBe(`run ${chalk.cyan('eas build')} now`);
});

it('renders italic with chalk.italic', () => {
expect(renderInlineMarkdown('an *emphasized* word')).toBe(
`an ${chalk.italic('emphasized')} word`
);
});

it('does not treat underscores inside a word as italic', () => {
expect(renderInlineMarkdown('my_project_name')).toBe('my_project_name');
});

it('renders links as underlined label with dimmed url', () => {
expect(renderInlineMarkdown('see [build](https://expo.dev/b/1)')).toBe(
`see ${chalk.underline('build')} ${chalk.dim('(https://expo.dev/b/1)')}`
);
});
});

describe(renderMarkdownLine, () => {
it('renders headings as bold and strips the marker', () => {
const state = createMarkdownRenderState();
expect(renderMarkdownLine('## Builds', state)).toBe(chalk.bold('Builds'));
});

it('renders bullet lines with a bullet glyph', () => {
const state = createMarkdownRenderState();
expect(renderMarkdownLine('- first', state)).toBe(`${chalk.dim('•')} first`);
});

it('drops code fences and dims code block contents', () => {
const state = createMarkdownRenderState();
expect(renderMarkdownLine('```ts', state)).toBeNull();
expect(state.inCodeBlock).toBe(true);
expect(renderMarkdownLine('const x = 1;', state)).toBe(chalk.gray('const x = 1;'));
expect(renderMarkdownLine('```', state)).toBeNull();
expect(state.inCodeBlock).toBe(false);
});

it('applies inline styling to plain lines', () => {
const state = createMarkdownRenderState();
expect(renderMarkdownLine('the **latest** build', state)).toBe(
`the ${chalk.bold('latest')} build`
);
});
});

describe(wrapToWidth, () => {
it('word-wraps to the given width', () => {
const segments = wrapToWidth('one two three four five', 12);
expect(segments.length).toBeGreaterThan(1);
for (const segment of segments) {
expect(segment.length).toBeLessThanOrEqual(12);
}
expect(segments.join(' ').replace(/\s+/g, ' ').trim()).toBe('one two three four five');
});

it('hard-breaks long unbreakable tokens like URLs', () => {
const url = 'https://expo.dev/accounts/acme/projects/mobile/builds/cb7c9bdd-56e6-42b8';
const segments = wrapToWidth(url, 20);
expect(segments.length).toBeGreaterThan(1);
for (const segment of segments) {
expect(segment.length).toBeLessThanOrEqual(20);
}
expect(segments.join('')).toBe(url);
});

it('returns the text unchanged when the width is too small to wrap into', () => {
expect(wrapToWidth('anything at all here', 4)).toEqual(['anything at all here']);
});
});
51 changes: 51 additions & 0 deletions packages/eas-cli/src/chat/__tests__/replInput.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { PassThrough } from 'node:stream';

import { createChatReplInput } from '../replInput';

describe(createChatReplInput, () => {
it('resolves the typed line', async () => {
const input = new PassThrough();
const output = new PassThrough();
const repl = createChatReplInput({ input, output, terminal: false });

const pending = repl.askAsync('> ');
input.write('how are my builds?\n');

expect(await pending).toBe('how are my builds?');
repl.close();
});

it('reads input even when the stream was left paused (e.g. by a spinner)', async () => {
const input = new PassThrough();
const output = new PassThrough();
const repl = createChatReplInput({ input, output, terminal: false });
input.pause();

const pending = repl.askAsync('> ');
input.write('hello\n');

expect(await pending).toBe('hello');
repl.close();
});

it('resolves null when the input stream ends', async () => {
const input = new PassThrough();
const output = new PassThrough();
const repl = createChatReplInput({ input, output, terminal: false });

const pending = repl.askAsync('> ');
input.end();

expect(await pending).toBeNull();
repl.close();
});

it('resolves null immediately once closed', async () => {
const input = new PassThrough();
const output = new PassThrough();
const repl = createChatReplInput({ input, output, terminal: false });
repl.close();

expect(await repl.askAsync('> ')).toBeNull();
});
});
Loading
Loading