Files
dockhand-upstream/tests/docker-stream-core.test.ts
T
Ivan KaraandJarek Krochmalski 0ca3b86f82 Add docker attach support (#1377)
* Add attach mode

* Better UI

* Remove unnecessary message

* extract + test the docker stream demux; reset terminal mode per session

- Pull the Docker hijacked-stream decoder (HTTP header strip, chunked
  decode, 8-byte multiplexed frame demux + raw/TTY fallback) into a pure
  src/lib/server/docker-stream-core.ts with unit tests covering raw
  passthrough, stdout/stderr demux, frames split across socket events,
  a split header, chunked reassembly, and the raw-fallback path.
- vite.config.ts now imports the core instead of carrying its own copy.
  server.js runs against ./build and can't import the TS core at runtime,
  so it keeps the inline copy with a note to keep the two in sync.
- containers page: default a fresh terminal session to exec so a prior
  container's Attach choice (shared terminalMode) doesn't carry over.

---------

Co-authored-by: Jarek Krochmalski <jkrochmalski@gmail.com>
2026-09-13 14:08:19 +02:00

99 lines
4.2 KiB
TypeScript

// @ts-expect-error -- bun:test is a runtime built-in with no types installed
import { describe, expect, test } from 'bun:test';
import {
createDockerStreamState,
decodeChunkedDockerBody,
processDockerStreamChunk
} from '../src/lib/server/docker-stream-core';
const HTTP_HEAD = 'HTTP/1.1 200 OK\r\nContent-Type: application/vnd.docker.raw-stream\r\n\r\n';
const HTTP_HEAD_CHUNKED = 'HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n';
/** Build one 8-byte-framed multiplexed frame: type 1=stdout, 2=stderr. */
function frame(type: number, payload: string): Buffer {
const body = Buffer.from(payload, 'utf-8');
const header = Buffer.alloc(8);
header.writeUInt8(type, 0);
header.writeUInt32BE(body.length, 4);
return Buffer.concat([header, body]);
}
describe('processDockerStreamChunk - raw (TTY) stream', () => {
test('passes raw body through once HTTP headers are stripped', () => {
const st = createDockerStreamState(false);
const out = processDockerStreamChunk(Buffer.from(HTTP_HEAD + 'hello world'), st);
expect(out.join('')).toBe('hello world');
});
test('buffers until the full HTTP header terminator arrives', () => {
const st = createDockerStreamState(false);
// header split across two socket events - nothing emitted until \r\n\r\n seen
expect(processDockerStreamChunk(Buffer.from('HTTP/1.1 200 OK\r\n'), st)).toEqual([]);
const out = processDockerStreamChunk(Buffer.from('\r\nlate'), st);
expect(out.join('')).toBe('late');
});
});
describe('processDockerStreamChunk - multiplexed (non-TTY attach) stream', () => {
test('demuxes stdout and stderr frames', () => {
const st = createDockerStreamState(true);
const data = Buffer.concat([Buffer.from(HTTP_HEAD), frame(1, 'out '), frame(2, 'err')]);
expect(processDockerStreamChunk(data, st).join('')).toBe('out err');
});
test('reassembles a frame split across two socket events', () => {
const st = createDockerStreamState(true);
const f = frame(1, 'hello');
// deliver the header + first 2 payload bytes, then the rest
const first = Buffer.concat([Buffer.from(HTTP_HEAD), f.slice(0, 8 + 2)]);
const second = f.slice(8 + 2);
expect(processDockerStreamChunk(first, st)).toEqual([]); // incomplete frame -> nothing yet
expect(processDockerStreamChunk(second, st).join('')).toBe('hello');
});
test('reassembles a frame whose 8-byte header itself is split', () => {
const st = createDockerStreamState(true);
const f = frame(2, 'x');
const first = Buffer.concat([Buffer.from(HTTP_HEAD), f.slice(0, 4)]); // half the header
const second = f.slice(4);
expect(processDockerStreamChunk(first, st)).toEqual([]);
expect(processDockerStreamChunk(second, st).join('')).toBe('x');
});
test('falls back to raw when the framing is not valid multiplexed (proxy stripped it)', () => {
const st = createDockerStreamState(true);
// a byte-0 stream type of 5 is invalid -> raw fallback, and stays raw after
const bogus = Buffer.concat([Buffer.from(HTTP_HEAD), Buffer.from([5, 0, 0, 0, 0, 0, 0, 1]), Buffer.from('raw')]);
const out = processDockerStreamChunk(bogus, st);
expect(out.join('')).toContain('raw');
expect(st.multiplexed).toBe(false);
// subsequent bytes now pass through raw
expect(processDockerStreamChunk(Buffer.from('more'), st).join('')).toBe('more');
});
});
describe('decodeChunkedDockerBody', () => {
test('decodes a chunked body and drops the framing', () => {
const st = createDockerStreamState(false);
// header says chunked; body: "5\r\nhello\r\n"
const out = processDockerStreamChunk(Buffer.from(HTTP_HEAD_CHUNKED + '5\r\nhello\r\n'), st);
expect(out.join('')).toBe('hello');
});
test('reassembles a chunk whose data is split across two events', () => {
const st = createDockerStreamState(false);
processDockerStreamChunk(Buffer.from(HTTP_HEAD_CHUNKED + '5\r\nhel'), st);
const out = processDockerStreamChunk(Buffer.from('lo\r\n'), st);
expect(out.join('')).toBe('hello');
});
test('the 0-size terminating chunk ends the body', () => {
const st = createDockerStreamState(false);
st.headersStripped = true;
st.isChunked = true;
const out = decodeChunkedDockerBody(Buffer.from('3\r\nabc\r\n0\r\n\r\n'), st);
expect(Buffer.concat(out).toString()).toBe('abc');
expect(st.chunkEnded).toBe(true);
});
});