Share upstream event stream hub
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createGlobalMessageStreamHub } from '../event-stream/global-hub.js';
|
||||
import { createOpenCodeWatcherRuntime } from './watcher.js';
|
||||
|
||||
function createSseResponse({ blocks = [], signal, holdOpen = false }) {
|
||||
const encoder = new TextEncoder();
|
||||
let index = 0;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
getReader() {
|
||||
return {
|
||||
async read() {
|
||||
if (index < blocks.length) {
|
||||
return { value: encoder.encode(blocks[index++]), done: false };
|
||||
}
|
||||
|
||||
if (!holdOpen) {
|
||||
return { value: undefined, done: true };
|
||||
}
|
||||
|
||||
return new Promise((_resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
const error = new Error('Aborted');
|
||||
error.name = 'AbortError';
|
||||
reject(error);
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('createOpenCodeWatcherRuntime', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('waits for OpenCode readiness and forwards unwrapped global SSE payloads', async () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const payloads = [];
|
||||
const fetchCalls = [];
|
||||
|
||||
const watcher = createOpenCodeWatcherRuntime({
|
||||
waitForOpenCodePort: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
},
|
||||
buildOpenCodeUrl: (path) => `http://127.0.0.1:4096${path}`,
|
||||
getOpenCodeAuthHeaders: () => ({ Authorization: 'Bearer test-token' }),
|
||||
onPayload(payload) {
|
||||
payloads.push(payload);
|
||||
watcher.stop();
|
||||
},
|
||||
fetchImpl: async (url, options) => {
|
||||
fetchCalls.push({ url, headers: options.headers });
|
||||
return createSseResponse({
|
||||
signal: options.signal,
|
||||
blocks: [
|
||||
'id: evt-1\ndata: {"directory":"/tmp/project","payload":{"type":"session.updated","properties":{"sessionID":"ses_1"}}}\n\n',
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await watcher.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchCalls).toEqual([
|
||||
{
|
||||
url: 'http://127.0.0.1:4096/global/event',
|
||||
headers: {
|
||||
Accept: 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
Authorization: 'Bearer test-token',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(payloads).toEqual([
|
||||
{
|
||||
type: 'session.updated',
|
||||
properties: {
|
||||
sessionID: 'ses_1',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('resumes watcher reconnects with Last-Event-ID after a stalled upstream stream', async () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const fetchLastEventIds = [];
|
||||
const payloads = [];
|
||||
let attempt = 0;
|
||||
|
||||
const watcher = createOpenCodeWatcherRuntime({
|
||||
waitForOpenCodePort: async () => {},
|
||||
buildOpenCodeUrl: (path) => `http://127.0.0.1:4096${path}`,
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
onPayload(payload) {
|
||||
payloads.push(payload.type);
|
||||
if (payload.type === 'session.updated') {
|
||||
watcher.stop();
|
||||
}
|
||||
},
|
||||
fetchImpl: async (_url, options) => {
|
||||
fetchLastEventIds.push(options.headers['Last-Event-ID'] ?? null);
|
||||
attempt += 1;
|
||||
|
||||
if (attempt === 1) {
|
||||
return createSseResponse({
|
||||
signal: options.signal,
|
||||
holdOpen: true,
|
||||
blocks: [
|
||||
'id: evt-1\ndata: {"type":"server.connected","properties":{}}\n\n',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return createSseResponse({
|
||||
signal: options.signal,
|
||||
blocks: [
|
||||
'id: evt-2\ndata: {"type":"session.updated","properties":{}}\n\n',
|
||||
],
|
||||
});
|
||||
},
|
||||
upstreamStallTimeoutMs: 10,
|
||||
upstreamReconnectDelayMs: 0,
|
||||
});
|
||||
|
||||
await watcher.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
|
||||
expect(payloads).toEqual(['server.connected', 'session.updated']);
|
||||
expect(fetchLastEventIds.slice(0, 2)).toEqual([null, 'evt-1']);
|
||||
});
|
||||
|
||||
it('subscribes to a shared global event hub instead of opening its own upstream stream', async () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const payloads = [];
|
||||
let hubFetchCalls = 0;
|
||||
let watcherFetchCalls = 0;
|
||||
|
||||
const globalEventHub = createGlobalMessageStreamHub({
|
||||
buildOpenCodeUrl: (path) => `http://127.0.0.1:4096${path}`,
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
upstreamReconnectDelayMs: 0,
|
||||
fetchImpl: async (_url, options) => {
|
||||
hubFetchCalls += 1;
|
||||
return createSseResponse({
|
||||
signal: options.signal,
|
||||
holdOpen: true,
|
||||
blocks: [
|
||||
'id: evt-1\ndata: {"payload":{"type":"session.updated","properties":{"sessionID":"ses_1"}}}\n\n',
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const watcher = createOpenCodeWatcherRuntime({
|
||||
waitForOpenCodePort: async () => {},
|
||||
buildOpenCodeUrl: (path) => `http://127.0.0.1:4096${path}`,
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
globalEventHub,
|
||||
onPayload(payload) {
|
||||
payloads.push(payload);
|
||||
watcher.stop();
|
||||
},
|
||||
fetchImpl: async () => {
|
||||
watcherFetchCalls += 1;
|
||||
throw new Error('watcher fetch should not be called');
|
||||
},
|
||||
});
|
||||
|
||||
await watcher.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(hubFetchCalls).toBe(1);
|
||||
expect(watcherFetchCalls).toBe(0);
|
||||
expect(payloads).toEqual([
|
||||
{
|
||||
type: 'session.updated',
|
||||
properties: {
|
||||
sessionID: 'ses_1',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not stop a shared global event hub when the watcher stops', async () => {
|
||||
const events = new Set();
|
||||
const statuses = new Set();
|
||||
let startCalls = 0;
|
||||
let stopCalls = 0;
|
||||
|
||||
const globalEventHub = {
|
||||
start() {
|
||||
startCalls += 1;
|
||||
},
|
||||
stop() {
|
||||
stopCalls += 1;
|
||||
},
|
||||
subscribeEvent(subscriber) {
|
||||
events.add(subscriber);
|
||||
return () => {
|
||||
events.delete(subscriber);
|
||||
};
|
||||
},
|
||||
subscribeStatus(subscriber) {
|
||||
statuses.add(subscriber);
|
||||
return () => {
|
||||
statuses.delete(subscriber);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const watcher = createOpenCodeWatcherRuntime({
|
||||
waitForOpenCodePort: async () => {},
|
||||
buildOpenCodeUrl: (path) => `http://127.0.0.1:4096${path}`,
|
||||
getOpenCodeAuthHeaders: () => ({}),
|
||||
globalEventHub,
|
||||
onPayload() {},
|
||||
});
|
||||
|
||||
await watcher.start();
|
||||
watcher.stop();
|
||||
|
||||
expect(startCalls).toBe(1);
|
||||
expect(stopCalls).toBe(0);
|
||||
expect(events.size).toBe(0);
|
||||
expect(statuses.size).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user