290 lines
12 KiB
TypeScript
290 lines
12 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|||
|
|
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||
|
|
import { buildRuntimeFetchUrl, runtimeFetch } from './runtime-fetch';
|
||
|
|
import { clearRuntimeAuthCredentialProvider, setRuntimeBearerToken } from './runtime-auth';
|
||
|
|
import { configureRuntimeUrlResolver, getRuntimeUrlResolver, setRuntimeUrlResolver } from './runtime-url';
|
||
|
|
|
||
|
|
const originalFetch = globalThis.fetch;
|
||
|
|
|
||
|
|
describe('buildRuntimeFetchUrl', () => {
|
||
|
|
test('preserves same-origin paths by default', () => {
|
||
|
|
expect(buildRuntimeFetchUrl('/api/config/settings')).toBe('/api/config/settings');
|
||
|
|
expect(buildRuntimeFetchUrl('/auth/session')).toBe('/auth/session');
|
||
|
|
expect(buildRuntimeFetchUrl('/health')).toBe('/health');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('resolves API/auth/health through configured runtime URL resolver', () => {
|
||
|
|
const previous = getRuntimeUrlResolver();
|
||
|
|
try {
|
||
|
|
configureRuntimeUrlResolver({ apiBaseUrl: 'https://api.example' });
|
||
|
|
|
||
|
|
expect(buildRuntimeFetchUrl('/api/config/settings')).toBe('https://api.example/api/config/settings');
|
||
|
|
expect(buildRuntimeFetchUrl('/auth/session')).toBe('https://api.example/auth/session');
|
||
|
|
expect(buildRuntimeFetchUrl('/health')).toBe('https://api.example/health');
|
||
|
|
expect(buildRuntimeFetchUrl('/api/find/file', { query: 'x' })).toBe('https://api.example/api/find/file?query=x');
|
||
|
|
} finally {
|
||
|
|
setRuntimeUrlResolver(previous);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('rewrites current-origin absolute API URLs only', () => {
|
||
|
|
const previous = getRuntimeUrlResolver();
|
||
|
|
const originalWindow = globalThis.window;
|
||
|
|
try {
|
||
|
|
configureRuntimeUrlResolver({ apiBaseUrl: 'https://api.example' });
|
||
|
|
Object.defineProperty(globalThis, 'window', {
|
||
|
|
configurable: true,
|
||
|
|
value: { location: { origin: 'openchamber-ui://app', href: 'openchamber-ui://app/index.html' } },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(buildRuntimeFetchUrl('openchamber-ui://app/api/config/settings')).toBe('https://api.example/api/config/settings');
|
||
|
|
expect(buildRuntimeFetchUrl('https://external.example/api/config/settings')).toBe('https://external.example/api/config/settings');
|
||
|
|
} finally {
|
||
|
|
setRuntimeUrlResolver(previous);
|
||
|
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||
|
|
globalThis.fetch = originalFetch;
|
||
|
|
clearRuntimeAuthCredentialProvider();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('runtimeFetch transport contract', () => {
|
||
|
|
test('preserves bodies from actual SDK mutation requests on same-origin runtimes', async () => {
|
||
|
|
const previous = getRuntimeUrlResolver();
|
||
|
|
const originalWindow = globalThis.window;
|
||
|
|
const calls: Array<{ url: string; method: string; body: string; headers: Headers }> = [];
|
||
|
|
|
||
|
|
try {
|
||
|
|
configureRuntimeUrlResolver({});
|
||
|
|
Object.defineProperty(globalThis, 'window', {
|
||
|
|
configurable: true,
|
||
|
|
value: { location: { origin: 'https://app.example', href: 'https://app.example/' } },
|
||
|
|
});
|
||
|
|
|
||
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||
|
|
const request = input instanceof Request ? input : new Request(input, init);
|
||
|
|
calls.push({
|
||
|
|
url: request.url,
|
||
|
|
method: request.method,
|
||
|
|
body: await request.clone().text(),
|
||
|
|
headers: request.headers,
|
||
|
|
});
|
||
|
|
return new Response(JSON.stringify({ ok: true, id: 'ses_1', time: { created: 1 } }), {
|
||
|
|
status: 200,
|
||
|
|
headers: { 'content-type': 'application/json' },
|
||
|
|
});
|
||
|
|
}) as typeof fetch;
|
||
|
|
|
||
|
|
const client = createOpencodeClient({
|
||
|
|
baseUrl: 'https://app.example/api',
|
||
|
|
fetch: runtimeFetch,
|
||
|
|
});
|
||
|
|
|
||
|
|
await client.session.revert({ sessionID: 'ses_1', directory: '/repo', messageID: 'msg_1' });
|
||
|
|
await client.session.shell({
|
||
|
|
sessionID: 'ses_1',
|
||
|
|
directory: '/repo',
|
||
|
|
messageID: 'msg_2',
|
||
|
|
agent: 'build',
|
||
|
|
model: { providerID: 'anthropic', modelID: 'claude-sonnet' },
|
||
|
|
command: 'ls',
|
||
|
|
});
|
||
|
|
await client.session.update({ sessionID: 'ses_1', directory: '/repo', time: { archived: 123 } });
|
||
|
|
await client.permission.reply({ requestID: 'perm_1', directory: '/repo', reply: 'once' });
|
||
|
|
await client.question.reply({ requestID: 'q_1', directory: '/repo', answers: [['yes']] });
|
||
|
|
await client.auth.set({ providerID: 'anthropic', auth: { type: 'api', key: 'secret' } });
|
||
|
|
await client.provider.oauth.callback({ providerID: 'github-copilot', method: 0, code: 'oauth-code' });
|
||
|
|
|
||
|
|
expect(calls.map((call) => call.url)).toEqual([
|
||
|
|
'https://app.example/api/session/ses_1/revert?directory=%2Frepo',
|
||
|
|
'https://app.example/api/session/ses_1/shell?directory=%2Frepo',
|
||
|
|
'https://app.example/api/session/ses_1?directory=%2Frepo',
|
||
|
|
'https://app.example/api/permission/perm_1/reply?directory=%2Frepo',
|
||
|
|
'https://app.example/api/question/q_1/reply?directory=%2Frepo',
|
||
|
|
'https://app.example/api/auth/anthropic',
|
||
|
|
'https://app.example/api/provider/github-copilot/oauth/callback',
|
||
|
|
]);
|
||
|
|
expect(calls.map((call) => call.method)).toEqual(['POST', 'POST', 'PATCH', 'POST', 'POST', 'PUT', 'POST']);
|
||
|
|
expect(calls.map((call) => call.headers.get('content-type'))).toEqual([
|
||
|
|
'application/json',
|
||
|
|
'application/json',
|
||
|
|
'application/json',
|
||
|
|
'application/json',
|
||
|
|
'application/json',
|
||
|
|
'application/json',
|
||
|
|
'application/json',
|
||
|
|
]);
|
||
|
|
expect(calls.map((call) => JSON.parse(call.body))).toEqual([
|
||
|
|
{ messageID: 'msg_1' },
|
||
|
|
{
|
||
|
|
messageID: 'msg_2',
|
||
|
|
agent: 'build',
|
||
|
|
model: { providerID: 'anthropic', modelID: 'claude-sonnet' },
|
||
|
|
command: 'ls',
|
||
|
|
},
|
||
|
|
{ time: { archived: 123 } },
|
||
|
|
{ reply: 'once' },
|
||
|
|
{ answers: [['yes']] },
|
||
|
|
{ type: 'api', key: 'secret' },
|
||
|
|
{ method: 0, code: 'oauth-code' },
|
||
|
|
]);
|
||
|
|
} finally {
|
||
|
|
setRuntimeUrlResolver(previous);
|
||
|
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||
|
|
globalThis.fetch = originalFetch;
|
||
|
|
clearRuntimeAuthCredentialProvider();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('preserves SDK-style Request method, JSON body, signal, path, query, and merges auth headers', async () => {
|
||
|
|
const previous = getRuntimeUrlResolver();
|
||
|
|
const originalWindow = globalThis.window;
|
||
|
|
const controller = new AbortController();
|
||
|
|
const calls: Array<{ input: Request; body: string }> = [];
|
||
|
|
|
||
|
|
try {
|
||
|
|
configureRuntimeUrlResolver({ apiBaseUrl: 'https://runtime.example/base' });
|
||
|
|
setRuntimeBearerToken('runtime-token');
|
||
|
|
Object.defineProperty(globalThis, 'window', {
|
||
|
|
configurable: true,
|
||
|
|
value: { location: { origin: 'https://app.example', href: 'https://app.example/app' } },
|
||
|
|
});
|
||
|
|
|
||
|
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||
|
|
const request = input instanceof Request ? input : new Request(input);
|
||
|
|
calls.push({ input: request, body: await request.clone().text() });
|
||
|
|
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||
|
|
}) as typeof fetch;
|
||
|
|
|
||
|
|
const request = new Request('https://app.example/api/session/abc/prompt_async?directory=%2Frepo&workspace=main', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: {
|
||
|
|
'content-type': 'application/json',
|
||
|
|
'x-sdk-header': 'kept',
|
||
|
|
},
|
||
|
|
body: JSON.stringify({ parts: [{ type: 'text', text: 'hello' }] }),
|
||
|
|
signal: controller.signal,
|
||
|
|
});
|
||
|
|
|
||
|
|
await runtimeFetch(request, { headers: { 'x-init-header': 'merged' } });
|
||
|
|
|
||
|
|
expect(calls).toHaveLength(1);
|
||
|
|
const captured = calls[0].input;
|
||
|
|
expect(captured.url).toBe('https://runtime.example/api/session/abc/prompt_async?directory=%2Frepo&workspace=main');
|
||
|
|
expect(captured.method).toBe('POST');
|
||
|
|
expect(captured.signal).toBe(controller.signal);
|
||
|
|
expect(captured.headers.get('content-type')).toBe('application/json');
|
||
|
|
expect(captured.headers.get('x-sdk-header')).toBe('kept');
|
||
|
|
expect(captured.headers.get('x-init-header')).toBe('merged');
|
||
|
|
expect(captured.headers.get('authorization')).toBe('Bearer runtime-token');
|
||
|
|
expect(calls[0].body).toBe(JSON.stringify({ parts: [{ type: 'text', text: 'hello' }] }));
|
||
|
|
} finally {
|
||
|
|
setRuntimeUrlResolver(previous);
|
||
|
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('does not replace an existing Authorization header', async () => {
|
||
|
|
const previous = getRuntimeUrlResolver();
|
||
|
|
const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||
|
|
|
||
|
|
try {
|
||
|
|
configureRuntimeUrlResolver({ apiBaseUrl: 'https://runtime.example' });
|
||
|
|
setRuntimeBearerToken('runtime-token');
|
||
|
|
|
||
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||
|
|
calls.push({ input, init });
|
||
|
|
return new Response(null, { status: 204 });
|
||
|
|
}) as typeof fetch;
|
||
|
|
|
||
|
|
await runtimeFetch('/api/path', {
|
||
|
|
headers: { Authorization: 'Bearer sdk-token' },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(new Headers(calls[0].init?.headers).get('authorization')).toBe('Bearer sdk-token');
|
||
|
|
} finally {
|
||
|
|
setRuntimeUrlResolver(previous);
|
||
|
|
globalThis.fetch = originalFetch;
|
||
|
|
clearRuntimeAuthCredentialProvider();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('resolves URLSearchParams query and auth for runtime asset fetches', async () => {
|
||
|
|
const previous = getRuntimeUrlResolver();
|
||
|
|
const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||
|
|
|
||
|
|
try {
|
||
|
|
configureRuntimeUrlResolver({ apiBaseUrl: 'https://runtime.example' });
|
||
|
|
setRuntimeBearerToken('runtime-token');
|
||
|
|
|
||
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||
|
|
calls.push({ input, init });
|
||
|
|
return new Response(new Blob(['icon']), { status: 200 });
|
||
|
|
}) as typeof fetch;
|
||
|
|
|
||
|
|
await runtimeFetch('/api/projects/project-1/icon', {
|
||
|
|
method: 'GET',
|
||
|
|
headers: { Accept: 'image/*' },
|
||
|
|
query: new URLSearchParams({ v: '123', theme: 'dark', iconColor: '#fff' }),
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(String(calls[0].input)).toBe('https://runtime.example/api/projects/project-1/icon?v=123&theme=dark&iconColor=%23fff');
|
||
|
|
const headers = new Headers(calls[0].init?.headers);
|
||
|
|
expect(headers.get('accept')).toBe('image/*');
|
||
|
|
expect(headers.get('authorization')).toBe('Bearer runtime-token');
|
||
|
|
} finally {
|
||
|
|
setRuntimeUrlResolver(previous);
|
||
|
|
globalThis.fetch = originalFetch;
|
||
|
|
clearRuntimeAuthCredentialProvider();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('does not attach runtime auth to non-runtime absolute URLs', async () => {
|
||
|
|
const previous = getRuntimeUrlResolver();
|
||
|
|
const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||
|
|
|
||
|
|
try {
|
||
|
|
configureRuntimeUrlResolver({ apiBaseUrl: 'https://runtime.example' });
|
||
|
|
setRuntimeBearerToken('runtime-token');
|
||
|
|
|
||
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||
|
|
calls.push({ input, init });
|
||
|
|
return new Response(null, { status: 204 });
|
||
|
|
}) as typeof fetch;
|
||
|
|
|
||
|
|
await runtimeFetch('https://old-runtime.example/api/config/settings');
|
||
|
|
|
||
|
|
expect(String(calls[0].input)).toBe('https://old-runtime.example/api/config/settings');
|
||
|
|
expect(new Headers(calls[0].init?.headers).has('authorization')).toBe(false);
|
||
|
|
} finally {
|
||
|
|
setRuntimeUrlResolver(previous);
|
||
|
|
globalThis.fetch = originalFetch;
|
||
|
|
clearRuntimeAuthCredentialProvider();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test('attaches runtime auth to active runtime auth URLs', async () => {
|
||
|
|
const previous = getRuntimeUrlResolver();
|
||
|
|
const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||
|
|
|
||
|
|
try {
|
||
|
|
configureRuntimeUrlResolver({ apiBaseUrl: 'https://runtime.example' });
|
||
|
|
setRuntimeBearerToken('runtime-token');
|
||
|
|
|
||
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||
|
|
calls.push({ input, init });
|
||
|
|
return new Response(JSON.stringify({ authenticated: true }), { status: 200 });
|
||
|
|
}) as typeof fetch;
|
||
|
|
|
||
|
|
await runtimeFetch('https://runtime.example/auth/session');
|
||
|
|
|
||
|
|
expect(String(calls[0].input)).toBe('https://runtime.example/auth/session');
|
||
|
|
expect(new Headers(calls[0].init?.headers).get('authorization')).toBe('Bearer runtime-token');
|
||
|
|
} finally {
|
||
|
|
setRuntimeUrlResolver(previous);
|
||
|
|
globalThis.fetch = originalFetch;
|
||
|
|
clearRuntimeAuthCredentialProvider();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|