open-design/apps/daemon/tests/sse-response.test.ts
nettee 3fb849d047
Fix chat runs surviving web disconnects (#146)
* 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>
2026-04-30 20:16:46 +08:00

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');
}
}