Skip to content
Open
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
104 changes: 103 additions & 1 deletion src/__tests__/messageFormatter.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { describe, it, expect } from 'vitest';
import { parseSSEEvent, extractTextFromPart, accumulateText, formatOutput, stripAnsi, buildContextHeader } from '../utils/messageFormatter.js';
import {
parseSSEEvent,
extractTextFromPart,
accumulateText,
formatOutput,
stripAnsi,
buildContextHeader,
splitIntoChunks,
splitForDiscordTemplate,
DISCORD_MAX_LENGTH,
} from '../utils/messageFormatter.js';

describe('messageFormatter', () => {
describe('stripAnsi', () => {
Expand Down Expand Up @@ -107,5 +117,97 @@ describe('messageFormatter', () => {
const buffer = 'Line1\nLine2\nLine3';
expect(formatOutput(buffer)).toBe('Line1\nLine2\nLine3');
});

it('should respect custom maxLength (body slice only)', () => {
const long = 'a'.repeat(500);
const truncated = formatOutput(long, 100);
// formatOutput applies maxLength to the body slice; the truncation
// notice adds ~19 chars of overhead on top.
expect(truncated.length).toBeLessThanOrEqual(100 + 19);
expect(truncated.endsWith('a'.repeat(100))).toBe(true);
expect(truncated.startsWith('...(truncated)...')).toBe(true);
});
});

describe('splitIntoChunks', () => {
it('returns a single chunk when text fits', () => {
expect(splitIntoChunks('hello', 100)).toEqual(['hello']);
});

it('splits long text on double-newline boundaries when possible', () => {
const part = 'a'.repeat(80);
const text = `${part}\n\n${part}\n\n${part}\n\n${part}`;
const chunks = splitIntoChunks(text, 100);
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(100);
});

it('falls back to single newline when no double newline in window', () => {
const lines = Array.from({ length: 50 }, (_, i) => `line${i}`).join('\n');
const chunks = splitIntoChunks(lines, 60);
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(60);
});

it('hard splits when no newline fits in the window', () => {
const text = 'x'.repeat(500);
const chunks = splitIntoChunks(text, 100);
expect(chunks.length).toBe(5);
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(100);
});

it('strips leading newlines between chunks', () => {
const text = Array.from({ length: 20 }, () => 'p').join('\n\n');
const chunks = splitIntoChunks(text, 30);
for (const c of chunks) expect(c.startsWith('\n')).toBe(false);
});
});

describe('splitForDiscordTemplate', () => {
it('keeps everything in prefixBody when body fits', () => {
const r = splitForDiscordTemplate({
header: '🌿 `main` · 🤖 `default`',
prompt: 'hi',
body: 'short body',
});
expect(r.overflowChunks).toEqual([]);
expect(r.prefixBody).toContain('📌 **Prompt**: hi');
expect(r.prefixBody).toContain('short body');
expect(r.prefixBody.length).toBeLessThanOrEqual(DISCORD_MAX_LENGTH);
});

it('truncates and overflows when body is too large', () => {
const body = 'a'.repeat(5000);
const r = splitForDiscordTemplate({
header: '🌿 `main` · 🤖 `default`',
prompt: 'p',
body,
});
expect(r.prefixBody.length).toBeLessThanOrEqual(DISCORD_MAX_LENGTH);
expect(r.prefixBody.endsWith('...')).toBe(true);
expect(r.overflowChunks.length).toBeGreaterThan(0);
for (const c of r.overflowChunks) expect(c.length).toBeLessThanOrEqual(DISCORD_MAX_LENGTH);
});

it('respects a custom maxLength', () => {
const r = splitForDiscordTemplate({
header: 'h',
prompt: 'p',
body: 'a'.repeat(1000),
maxLength: 500,
});
expect(r.prefixBody.length).toBeLessThanOrEqual(500);
expect(r.overflowChunks.length).toBeGreaterThan(0);
});

it('handles a body smaller than the minimum budget without overflowing', () => {
const r = splitForDiscordTemplate({
header: 'h',
prompt: 'p',
body: '',
});
expect(r.overflowChunks).toEqual([]);
expect(r.prefixBody).toContain('📌 **Prompt**: p');
});
});
});
60 changes: 53 additions & 7 deletions src/__tests__/serveManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,9 +320,14 @@ describe("serveManager", () => {
await vi.runAllTimersAsync();

await expect(promise).resolves.toBeUndefined();
expect(fetch).toHaveBeenCalledWith("http://127.0.0.1:14097/session", {
headers: {},
});
expect(fetch).toHaveBeenCalledWith(
"http://127.0.0.1:14097/session",
expect.objectContaining({ headers: {} }),
);
expect(fetch).toHaveBeenCalledWith(
"http://127.0.0.1:14097/session",
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});

it("should retry if fetch fails or returns not ok", async () => {
Expand All @@ -341,13 +346,53 @@ describe("serveManager", () => {
expect(fetch).toHaveBeenCalledTimes(3);
});

it("should retry on AbortSignal timeout without aborting the wait loop", async () => {
vi.mocked(fetch)
.mockRejectedValueOnce(
new DOMException("The operation was aborted due to timeout", "TimeoutError"),
)
.mockRejectedValueOnce(
new DOMException("The operation was aborted due to timeout", "TimeoutError"),
)
.mockResolvedValueOnce({ ok: true } as Response);

const promise = serveManager.waitForReady(14097);

await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(1000);

await expect(promise).resolves.toBeUndefined();
expect(fetch).toHaveBeenCalledTimes(3);
});

it("should tolerate a slow cold-start longer than the legacy 30s default", async () => {
// Server takes ~45s of simulated polling before becoming ready.
// With the legacy 30s default this would fail; with the new 60s default
// it should succeed.
let calls = 0;
vi.mocked(fetch).mockImplementation(async () => {
calls++;
if (calls < 45) return { ok: false } as Response;
return { ok: true } as Response;
});

const promise = serveManager.waitForReady(14097);
for (let i = 0; i < 46; i++) {
await vi.advanceTimersByTimeAsync(1000);
}

await expect(promise).resolves.toBeUndefined();
expect(calls).toBe(45);
});

it("should throw error on timeout", async () => {
vi.mocked(fetch).mockRejectedValue(new Error("Connection refused"));

const promise = serveManager.waitForReady(14097, 1000);

const wrappedPromise = expect(promise).rejects.toThrow(
"Service at port 14097 failed to become ready within 1000ms. Check if 'opencode serve' is working correctly.",
"Service at port 14097 failed to become ready within 1000ms",
);

await vi.advanceTimersByTimeAsync(1500);
Expand Down Expand Up @@ -418,9 +463,10 @@ describe("serveManager", () => {
await expect(promise).resolves.toBeUndefined();

const expected = `Basic ${Buffer.from("opencode:s3cret").toString("base64")}`;
expect(fetch).toHaveBeenCalledWith("http://127.0.0.1:14097/session", {
headers: { Authorization: expected },
});
expect(fetch).toHaveBeenCalledWith(
"http://127.0.0.1:14097/session",
expect.objectContaining({ headers: { Authorization: expected } }),
);
});

it("fails fast with a clear error when readiness probe returns 401", async () => {
Expand Down
Loading