Files
Codeman/test/input-size-limit.test.ts
T
Codeman maintainer ead3d34411 refactor(terminal): seams for a second terminal pane (input socket map, targeted links, copy, paste)
No behavior change. Prepares the split pane's second terminal (and later
grid tiles) to share what today only the primary terminal has:

- _inputSocketFor/_registerInputSocket/_unregisterInputSocket: the
  exactly-once input queue, its ACK handling and the redelivery sweep now
  deliver over any registered socket bound to a session, not only
  this._ws. ACKs are routed by the receiving socket's session; silence is
  judged per socket; a stale handle cannot unregister its replacement.
- registerFilePathLinkProvider, cleanedTerminalSelection,
  copyTerminalSelection and _handleImagePaste take an optional target
  terminal and session (defaults: the primary pane).
- _focusedPane() is the one place to ask which pane the keyboard is in
  (primary only, for now); _forEachTile() replaces the _splitPane special
  cases in the font, family, weight, skin and resize paths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 08:55:05 +02:00

109 lines
4.7 KiB
TypeScript

/**
* @fileoverview Oversized input must never poison the durable input queue (#484).
*
* The failure: a paste over MAX_INPUT_LENGTH was queued for reliable delivery,
* refused by both transports (the WebSocket silently, the POST with a 400), and
* never dropped by the client, which treated a 400 like a transient failure. It
* was re-sent every 2 s forever, blocked every later input for that session
* behind it, and came back from localStorage on every reload.
*
* The fix, pinned here: the client splits a large paste into in-limit frames
* (one limit, shared with the server), drops a frame the server refused for
* good, and prunes oversized frames persisted by an older build.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
import { MAX_INPUT_LENGTH } from '../src/config/terminal-limits.js';
import { SessionInputWithLimitSchema } from '../src/web/schemas.js';
const pub = (f: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', f), 'utf8');
const appSource = pub('app.js');
type InputLimit = { FRAME_MAX_CHARS: number; PASTE_MAX_CHARS: number; split: (d: string, max?: number) => string[] };
function loadLimit(): InputLimit {
const context = vm.createContext({ window: {}, globalThis: {} });
vm.runInContext(pub('constants.js'), context, { filename: 'constants.js' });
return (context.window as { CodemanInputLimit: InputLimit }).CodemanInputLimit;
}
describe('one input limit on both sides', () => {
it('the frontend frame limit equals the server MAX_INPUT_LENGTH', () => {
expect(loadLimit().FRAME_MAX_CHARS).toBe(MAX_INPUT_LENGTH);
});
it('the composer budget is derived from the same number', () => {
expect(pub('keyboard-accessory.js')).toMatch(
new RegExp(`COMPOSER_INPUT_FRAME_LIMIT = (64 \\* 1024|${MAX_INPUT_LENGTH});`)
);
expect(MAX_INPUT_LENGTH).toBe(64 * 1024);
});
it('the POST schema caps input at MAX_INPUT_LENGTH, not a second number', () => {
expect(SessionInputWithLimitSchema.safeParse({ input: 'x'.repeat(MAX_INPUT_LENGTH) }).success).toBe(true);
expect(SessionInputWithLimitSchema.safeParse({ input: 'x'.repeat(MAX_INPUT_LENGTH + 1) }).success).toBe(false);
});
});
describe('splitInputFrames', () => {
const { split } = loadLimit();
it('returns a short input as one frame and nothing for empty input', () => {
expect(split('abc')).toEqual(['abc']);
expect(split('')).toEqual([]);
});
it('splits a large paste into in-limit frames that rejoin byte-identically', () => {
const paste = '\x1b[200~' + 'log line\n'.repeat(15000) + '\x1b[201~';
const frames = split(paste);
expect(frames.length).toBeGreaterThan(1);
for (const f of frames) expect(f.length).toBeLessThanOrEqual(MAX_INPUT_LENGTH);
expect(frames.join('')).toBe(paste);
});
it('never cuts a surrogate pair in half', () => {
const paste = 'a' + '😀'.repeat(10); // pairs start at odd offsets
const frames = split(paste, 4);
expect(frames.join('')).toBe(paste);
for (const f of frames) {
const last = f.charCodeAt(f.length - 1);
expect(last >= 0xd800 && last <= 0xdbff).toBe(false);
const first = f.charCodeAt(0);
expect(first >= 0xdc00 && first <= 0xdfff).toBe(false);
}
});
});
describe('the client never queues or keeps an undeliverable frame', () => {
const sendAsync = appSource.slice(
appSource.indexOf('_sendInputAsync(sessionId, input, opts) {'),
appSource.indexOf('_sendInputEphemeral(sessionId, input) {')
);
it('splits an oversized paste, and refuses an oversized mux write or giant paste with a toast', () => {
expect(sendAsync).toContain('limit.split(input)');
expect(sendAsync).toMatch(/useMux \|\| input\.length > limit\.PASTE_MAX_CHARS/);
expect(sendAsync).toContain('showToast');
});
it('drops a POST the server refused as invalid instead of retrying it forever', () => {
const drain = appSource.slice(appSource.indexOf('_drainSession(sessionId) {'));
const body = drain.slice(0, drain.indexOf('_dropRejectedInput(sessionId, rec) {'));
expect(body).toMatch(
/resp\.status === 400 \|\| resp\.status === 413\)\) \{\s*[\s\S]{0,400}this\._dropRejectedInput\(sessionId, rec\);/
);
});
it('drops a frame the WebSocket refused with an error ACK', () => {
const handler = appSource.slice(appSource.indexOf('_onWsInputAck(seq, msg, sessionId = this._wsSessionId) {'));
expect(handler.slice(0, 600)).toMatch(/if \(msg && msg\.err\)/);
});
it('prunes oversized frames persisted by an older build on load', () => {
const load = appSource.slice(appSource.indexOf('_loadReliableState() {'));
expect(load.slice(0, 3000)).toMatch(/r\.data\.length <= frameMax/);
});
});