mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* fix chat runs surviving web disconnects * fix chat run create abort propagation Generated-By: looper 0.0.0-dev (runner=fixer, agent=openai/gpt-5.5) * fix daemon keepalive reconnect budget Generated-By: looper 0.0.0-dev (runner=fixer, agent=gpt-5.5) * fix daemon stream disconnect cancellation Generated-By: looper 0.0.0-dev (runner=fixer, agent=openai/gpt-5.5) * fix daemon stream abort cancellation race Generated-By: looper 0.0.0-dev (runner=fixer, agent=openai/gpt-5.5) * fix daemon run cancellation semantics * fix load * doc * 2 * add run refresh recovery * fix active run refresh status * fix reattach abort handling * fix * fix chat initial scroll * fix daemon start failures Generated-By: looper 0.2.7 (runner=fixer, agent=openai/gpt-5.5) * fix background run recovery Generated-By: looper 0.2.7 (runner=fixer, agent=openai/gpt-5.5) * fix stop run status Generated-By: looper 0.2.7 (runner=fixer, agent=openai/gpt-5.5) * fix background run recovery Generated-By: looper 0.2.7 (runner=fixer, agent=openai/gpt-5.5) * extract daemon run service * move prompt composition to daemon * fix prompt module resolution * fix project id generation * add project run status * add designs kanban view with awaiting_input status - add grid/kanban view toggle on Designs tab; persist choice in localStorage - introduce awaiting_input project display status (daemon-derived from unanswered <question-form>) so projects asking the user aren't shown as Completed; ordered between Running and Completed with amber accent - hide transient queued state from users: coerce queued/starting to running in daemon /api/projects projection and drop the queued kanban column - a11y polish on Designs cards: Space activation, aria-labels on delete, focus-visible outlines, reveal delete on focus-within and touch, prefers-reduced-motion handling - kanban layout uses flex sizing instead of viewport math; scoped icon- only pill button rule fixes view-toggle icon alignment --------- Co-authored-by: mrcfps <mrc@powerformer.com>
125 lines
3.3 KiB
TypeScript
125 lines
3.3 KiB
TypeScript
// @ts-nocheck
|
|
import { EventEmitter } from 'node:events';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { createCompatApiErrorResponse, createSseResponse } from '../src/server.js';
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('createSseResponse', () => {
|
|
it('sets SSE headers and sends JSON app events', () => {
|
|
const res = new FakeResponse();
|
|
const sse = createSseResponse(res, { keepAliveIntervalMs: 0 });
|
|
|
|
expect(res.headers).toEqual({
|
|
'Cache-Control': 'no-cache, no-transform',
|
|
Connection: 'keep-alive',
|
|
'Content-Type': 'text/event-stream',
|
|
'X-Accel-Buffering': 'no',
|
|
});
|
|
expect(res.flushed).toBe(true);
|
|
|
|
expect(sse.send('start', { ok: true })).toBe(true);
|
|
expect(res.writes.join('')).toBe('event: start\ndata: {"ok":true}\n\n');
|
|
});
|
|
|
|
it('can attach SSE event ids for resumable streams', () => {
|
|
const res = new FakeResponse();
|
|
const sse = createSseResponse(res, { keepAliveIntervalMs: 0 });
|
|
|
|
expect(sse.send('stdout', { chunk: 'hello' }, 12)).toBe(true);
|
|
|
|
expect(res.writes.join('')).toBe('id: 12\nevent: stdout\ndata: {"chunk":"hello"}\n\n');
|
|
});
|
|
|
|
it('emits heartbeat comments before real events', () => {
|
|
const res = new FakeResponse();
|
|
const sse = createSseResponse(res, { keepAliveIntervalMs: 0 });
|
|
|
|
expect(sse.writeKeepAlive()).toBe(true);
|
|
expect(sse.send('end', {})).toBe(true);
|
|
expect(res.writes.join('')).toBe(': keepalive\n\nevent: end\ndata: {}\n\n');
|
|
});
|
|
|
|
it('clears interval heartbeat on close', () => {
|
|
vi.useFakeTimers();
|
|
const res = new FakeResponse();
|
|
createSseResponse(res, { keepAliveIntervalMs: 10 });
|
|
|
|
vi.advanceTimersByTime(10);
|
|
expect(res.writes).toEqual([': keepalive\n\n']);
|
|
|
|
res.emit('close');
|
|
vi.advanceTimersByTime(30);
|
|
expect(res.writes).toEqual([': keepalive\n\n']);
|
|
});
|
|
|
|
it('skips writes after the response ends', () => {
|
|
const res = new FakeResponse();
|
|
const sse = createSseResponse(res, { keepAliveIntervalMs: 0 });
|
|
|
|
sse.end();
|
|
|
|
expect(res.ended).toBe(true);
|
|
expect(sse.writeKeepAlive()).toBe(false);
|
|
expect(sse.send('end', {})).toBe(false);
|
|
expect(res.writes).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('createCompatApiErrorResponse', () => {
|
|
it('wraps legacy string errors in the shared ApiError response shape', () => {
|
|
expect(createCompatApiErrorResponse('BAD_REQUEST', 'message required')).toEqual({
|
|
error: {
|
|
code: 'BAD_REQUEST',
|
|
message: 'message required',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('preserves shared ApiError metadata fields', () => {
|
|
expect(
|
|
createCompatApiErrorResponse('AGENT_UNAVAILABLE', 'missing agent', {
|
|
retryable: true,
|
|
details: { legacyCode: 'ENOENT' },
|
|
}),
|
|
).toEqual({
|
|
error: {
|
|
code: 'AGENT_UNAVAILABLE',
|
|
message: 'missing agent',
|
|
retryable: true,
|
|
details: { legacyCode: 'ENOENT' },
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
class FakeResponse extends EventEmitter {
|
|
headers = {};
|
|
writes = [];
|
|
destroyed = false;
|
|
writableEnded = false;
|
|
flushed = false;
|
|
ended = false;
|
|
|
|
setHeader(name, value) {
|
|
this.headers[name] = value;
|
|
}
|
|
|
|
flushHeaders() {
|
|
this.flushed = true;
|
|
}
|
|
|
|
write(chunk) {
|
|
this.writes.push(chunk);
|
|
return true;
|
|
}
|
|
|
|
end() {
|
|
this.ended = true;
|
|
this.writableEnded = true;
|
|
this.emit('finish');
|
|
}
|
|
}
|