mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
Merge remote-tracking branch 'origin/master' into pr/cod-455-xlsx-preview
# Conflicts: # src/web/public/constants.js
This commit is contained in:
@@ -713,6 +713,12 @@ describe('registry writes are serialized and never clobber a file the reader wou
|
||||
}
|
||||
expect(installEnv({ CODEMAN_PASSWORD: 'x', HOME: '/h' })).toEqual({ HOME: '/h' });
|
||||
});
|
||||
|
||||
it('redirects npm installs to the persistent HOME inside the Compose container', () => {
|
||||
expect(
|
||||
installEnv({ CODEMAN_IN_CONTAINER: '1', HOME: '/home/codeman', NPM_CONFIG_PREFIX: '/opt/codeman-cli' })
|
||||
).toEqual({ HOME: '/home/codeman', NPM_CONFIG_PREFIX: '/home/codeman/.local' });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -681,6 +681,18 @@ describe('file-routes', () => {
|
||||
expect(body.data.url).toContain('file-raw');
|
||||
});
|
||||
|
||||
it('classifies avif as an image so the viewer renders it instead of dumping bytes', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 1024 } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=photo.avif`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.type).toBe('image');
|
||||
});
|
||||
|
||||
it('returns audio metadata for audio files', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 2048 } as never);
|
||||
|
||||
@@ -841,6 +853,19 @@ describe('file-routes', () => {
|
||||
expect(res.headers['content-type']).toBe('image/png');
|
||||
});
|
||||
|
||||
it('serves avif with its image type, since <img> refuses an octet-stream', async () => {
|
||||
const content = Buffer.from('fake avif data');
|
||||
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
||||
mockedStat.mockResolvedValue({ size: content.length } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=photo.avif`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/avif');
|
||||
});
|
||||
|
||||
it('serves workspace SVG as an untrusted attachment instead of inline image/svg+xml', async () => {
|
||||
const content = Buffer.from('<svg><script>alert("xss")</script></svg>');
|
||||
mockedCreateReadStream.mockReturnValue(Readable.from([content]) as never);
|
||||
|
||||
@@ -14,11 +14,12 @@
|
||||
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Session } from '../../src/session.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { isPlainPromptInput } from '../../src/web/route-helpers.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
|
||||
@@ -155,3 +156,106 @@ describe('POST /api/sessions/:id/input rollback wiring', () => {
|
||||
expect(session.shouldApplyInput('c2', 5)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A plain prompt goes through the mux even when the caller did not say `useMux`.
|
||||
*
|
||||
* Measured on Claude Code 2.1.283: a direct write of `<text>\r` arrives as one burst,
|
||||
* a burst of about a hundred characters is taken as a paste, and its `\r` lands as a
|
||||
* newline in the composer, so a script's prompt sat there unsent while the route
|
||||
* answered 200. The mux path types the text, presses Enter on its own, and arms the
|
||||
* submit verifier.
|
||||
*/
|
||||
describe('POST /api/sessions/:id/input plain-prompt routing', () => {
|
||||
let harness: { app: FastifyInstance; ctx: MockRouteContext };
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createEnvelopeHarness();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
const post = (body: Record<string, unknown>) =>
|
||||
harness.app.inject({ method: 'POST', url: '/api/sessions/test-session-1/input', payload: body });
|
||||
const spies = () => {
|
||||
const session = harness.ctx.sessions.get('test-session-1')!;
|
||||
return { session, viaMux: vi.spyOn(session, 'writeViaMux'), direct: vi.spyOn(session, 'write') };
|
||||
};
|
||||
const LONG_PROMPT =
|
||||
'Reply with only the word ok and nothing else, this sentence is padding to reach about one hundred chars.\r';
|
||||
|
||||
it('sends a prompt with no useMux through the mux, not as one burst', async () => {
|
||||
const { viaMux, direct } = spies();
|
||||
|
||||
const res = await post({ input: LONG_PROMPT });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(viaMux).toHaveBeenCalledWith(LONG_PROMPT, { fromUser: true });
|
||||
expect(direct).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers only once the mux write is done, so the next frame cannot overtake it', async () => {
|
||||
// The browser's POST fallback sends frames one at a time and waits for each 2xx;
|
||||
// a fire-and-forget write here would let its next keystroke land before the Enter.
|
||||
const { viaMux } = spies();
|
||||
let finished = false;
|
||||
viaMux.mockImplementation(async () => {
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
finished = true;
|
||||
return true;
|
||||
});
|
||||
|
||||
await post({ input: 'ok\r', clientId: 'browser-1', seq: 1 });
|
||||
|
||||
expect(finished).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the direct write when the mux write fails', async () => {
|
||||
const { viaMux, direct } = spies();
|
||||
viaMux.mockResolvedValue(false);
|
||||
|
||||
await post({ input: 'hello\r' });
|
||||
|
||||
expect(direct).toHaveBeenCalledWith('hello\r', { fromUser: true });
|
||||
});
|
||||
|
||||
it('keeps the raw write for an explicit useMux: false', async () => {
|
||||
const { viaMux, direct } = spies();
|
||||
|
||||
await post({ input: LONG_PROMPT, useMux: false });
|
||||
|
||||
expect(direct).toHaveBeenCalledWith(LONG_PROMPT, { fromUser: true });
|
||||
expect(viaMux).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a bare Enter', '\r'],
|
||||
['text with no Enter', 'hello'],
|
||||
['an arrow key', '\x1b[A'],
|
||||
['a bracketed paste frame', '\x1b[200~line one\nline two\x1b[201~'],
|
||||
['a line feed inside', 'line one\nline two\r'],
|
||||
['two Enters', 'hello\r\r'],
|
||||
['a tab', 'a\tb\r'],
|
||||
])('leaves %s on the direct write', async (_label, input) => {
|
||||
const { viaMux, direct } = spies();
|
||||
|
||||
await post({ input });
|
||||
|
||||
expect(direct).toHaveBeenCalledWith(input, { fromUser: true });
|
||||
expect(viaMux).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPlainPromptInput', () => {
|
||||
it('accepts printable text ending in exactly one carriage return', () => {
|
||||
expect(isPlainPromptInput('run the tests\r')).toBe(true);
|
||||
expect(isPlainPromptInput('ünïcødé and emoji 🚀\r')).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses anything carrying another control character', () => {
|
||||
for (const input of ['\r', 'x', 'x\n', 'x\r\n', 'x\r\r', '\x1b[Ax\r', 'a\tb\r', 'x\x7f\r', 'x\u009b\r', '\rx']) {
|
||||
expect(isPlainPromptInput(input), JSON.stringify(input)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -920,6 +920,53 @@ describe('session-routes', () => {
|
||||
expect(res.headers['server-timing']).toMatch(/^capture;dur=\d+\.\d, prepare;dur=\d+\.\d, total;dur=\d+\.\d$/);
|
||||
});
|
||||
|
||||
it('full reload with a tail (?full=1&tail=) cuts the full capture to its newest bytes, cursor restore intact', async () => {
|
||||
// A Shell scroll-to-top asks for exactly this (`_maybeRefetchFullHistory`):
|
||||
// tmux's whole scrollback, bounded to the tab-switch tail size. The client
|
||||
// relies on all three answers below, so a refactor that dropped the tail on
|
||||
// a full capture (an unbounded pull from an ordinary scroll) or cut off the
|
||||
// closing cursor move (a caret parked below the prompt) must fail here.
|
||||
const tail = 1024 * 1024;
|
||||
const oldestMarker = 'BOUNDED_OLDEST_LINE_00001';
|
||||
const newestMarker = 'BOUNDED_NEWEST_LINE_40000';
|
||||
const rows: string[] = [oldestMarker];
|
||||
for (let i = 2; i < 40_000; i++) rows.push(`shell history line ${String(i).padStart(5, '0')} lorem ipsum`);
|
||||
rows.push(newestMarker);
|
||||
// What formatCursorRestore appends: up from the last row, then the column.
|
||||
const cursorRestore = '\x1b[3A\r\x1b[2C';
|
||||
const fullHistoryCapture = `${rows.join('\r\n')}${cursorRestore}`;
|
||||
expect(fullHistoryCapture.length).toBeGreaterThan(tail);
|
||||
|
||||
harness.ctx._session.mode = 'shell';
|
||||
harness.ctx._session.terminalBuffer = '';
|
||||
const captureSpy = vi.fn((_name: string, opts?: { fullHistory?: boolean }) =>
|
||||
opts?.fullHistory ? fullHistoryCapture : 'only the visible frame'
|
||||
);
|
||||
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/terminal?full=1&tail=${tail}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
// Still the scrollback, not the visible frame a plain `?tail=` gets.
|
||||
expect(captureSpy).toHaveBeenCalledWith(
|
||||
harness.ctx._session.muxName,
|
||||
expect.objectContaining({ fullHistory: true })
|
||||
);
|
||||
expect(body.data.source).toBe('mux-full-history');
|
||||
// Recoverable, not 'capped': Load full history can still bring the rest back.
|
||||
expect(body.data.truncated).toBe(true);
|
||||
expect(body.data.truncationReason).toBe('tail');
|
||||
expect(body.data.fullSize).toBe(fullHistoryCapture.length);
|
||||
expect(body.data.terminalBuffer.length).toBeLessThanOrEqual(tail);
|
||||
expect(body.data.terminalBuffer).toContain(newestMarker);
|
||||
expect(body.data.terminalBuffer).not.toContain(oldestMarker);
|
||||
expect(body.data.terminalBuffer.endsWith(`${newestMarker}${cursorRestore}`)).toBe(true);
|
||||
});
|
||||
|
||||
it('full reload (?full=1) returns the tmux capture ALONE — byte history is not duplicated', async () => {
|
||||
// The full-history capture is the rendered form of everything already in
|
||||
// the byte buffer; prepending the byte history would replay the whole
|
||||
|
||||
Reference in New Issue
Block a user