Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/output-accumulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export class OutputAccumulator {
private decoder = new StringDecoder('utf8');
private ansi: AnsiCarry = { state: 'text' };
byteLength = 0;
/** Increments on every newline so a later pager marker is a new line. */
/** Increments on every newline or CR repaint so a later pager marker is a new line. */
lineEpoch = 0;

constructor(private readonly maxPromptLength = 256) {}
Expand Down Expand Up @@ -124,6 +124,7 @@ export class OutputAccumulator {
this.parts.push('\r'.repeat(this.pendingCr));
this.col = 0;
this.pendingCr = 0;
this.lineEpoch += 1;
}

run += ch;
Expand Down
10 changes: 7 additions & 3 deletions src/pager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { SshClientError } from './errors.js';

const MORE_RE = /---\s*MORE\s*---\s*$/i;
// DrayOS appends a key hint: `--- MORE --- ['q': Quit, ...] ---`.
const MARKER = String.raw`---\s*MORE\s*---(?:[ \t]*\[[^\]\n]*\](?:[ \t]*---)?)?`;
const MORE_RE = new RegExp(`${MARKER}\\s*$`, 'i');
const MARKER_RE = new RegExp(MARKER, 'giu');

export interface PagerState {
pages: number;
Expand Down Expand Up @@ -31,7 +34,8 @@ export function handlePagerTail(
state: PagerState,
maxPages: number,
): boolean {
if (!markerOnTail(line) || state.answeredTail === line) {
// A tail that extends the answered one is the same marker still arriving.
if (!markerOnTail(line) || (state.answeredTail !== null && line.startsWith(state.answeredTail))) {
return false;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

Expand Down Expand Up @@ -64,7 +68,7 @@ export function removeHandledMarkers(text: string, handled: number): string {

let left = handled;

return text.replace(/---\s*MORE\s*---/giu, (marker) => {
return text.replace(MARKER_RE, (marker) => {
if (left <= 0) {
return marker;
}
Expand Down
26 changes: 26 additions & 0 deletions tests/pager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,30 @@ describe('handlePagerTail', () => {
expect(markerOnTail('--- MORE ---\nb')).toBe(false);
expect(markerOnTail('tail --- MORE ---')).toBe(true);
});

it('recognizes the DrayOS marker with its key hint as the tail', () => {
const hint = "['q': Quit, 'Enter': New Lines, 'Space Bar': Next Page]";

expect(markerOnTail(`--- MORE --- ${hint} --- `)).toBe(true);
expect(markerOnTail(`--- MORE --- ${hint} `)).toBe(true);
expect(markerOnTail(`--- MORE --- ${hint} ---\nb`)).toBe(false);
expect(markerOnTail('--- MORE --- trailing output')).toBe(false);
});

it('answers a marker once while its key hint is still arriving', () => {
const writes: string[] = [];
const state = createPagerState();
const write = { write: (data: string) => writes.push(data) };

expect(handlePagerTail('--- MORE ---', write, state, 5)).toBe(true);
expect(handlePagerTail("--- MORE --- ['q': Quit, 'Enter': New Lines, 'Space Bar': Next Page] ---", write, state, 5)).toBe(false);
expect(writes).toEqual([' ']);
expect(state.handled).toBe(1);
});

it('removes the DrayOS key hint together with a handled marker', () => {
const marker = "--- MORE --- ['q': Quit, 'Enter': New Lines, 'Space Bar': Next Page] ---";

expect(removeHandledMarkers(`a\n${marker}\nb`, 1)).toBe('a\n\nb');
});
});
65 changes: 65 additions & 0 deletions tests/shell-io-extras.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,71 @@ describe('ShellIo extras', () => {
io.detach();
});

it('pages through the DrayOS marker and returns every page without the key hint', async () => {
const channel = new FakeSsh2Channel({ prompt });
const marker = "--- MORE --- ['q': Quit, 'Enter': New Lines, 'Space Bar': Next Page] --- ";
const first = `first ${randomUUID()}`;
const second = `second ${randomUUID()}`;
const original = channel.write.bind(channel);
channel.write = (data) => {
if (String(data) === ' ') {
channel.writes.push(data);
channel.emitText(`\r\n${second}\r\n${prompt}`);
return true;
}

return original(data);
};
const io = new ShellIo(channel, { promptIdentity: prompt, settleMs: 0, idleBufferMaxBytes: 64_000 });
const command = `wan status ${randomUUID()}`;
const run = io.runCommand(command, {
commandTimeoutMs: 500,
maxPages: 5,
maxOutputBytes: 1_000_000,
settleMs: 0,
}, { idleTimeoutMs: 200 });
channel.emitText(`${command}\r\n${first}\r\n${marker}`);

const output = await run;
expect(channel.writes.filter((data) => String(data) === ' ')).toHaveLength(1);
expect(output).toContain(first);
expect(output).toContain(second);
expect(output).not.toContain('MORE');
expect(output).not.toContain('Space Bar');
io.detach();
});

it('answers a pager marker repainted after a carriage return', async () => {
const channel = new FakeSsh2Channel({ prompt });
const marker = "--- MORE --- ['q': Quit, 'Enter': New Lines, 'Space Bar': Next Page] --- ";
const last = `last ${randomUUID()}`;
const original = channel.write.bind(channel);
let spaces = 0;
channel.write = (data) => {
if (String(data) === ' ') {
channel.writes.push(data);
spaces += 1;
channel.emitText(spaces === 1 ? `\r${marker}` : `\r\n${last}\r\n${prompt}`);
return true;
}

return original(data);
};
const io = new ShellIo(channel, { promptIdentity: prompt, settleMs: 0, idleBufferMaxBytes: 64_000 });
const command = `wan status ${randomUUID()}`;
const run = io.runCommand(command, {
commandTimeoutMs: 500,
maxPages: 5,
maxOutputBytes: 1_000_000,
settleMs: 0,
}, { idleTimeoutMs: 200 });
channel.emitText(`${command}\r\nfirst\r\n--- MORE ---`);

await expect(run).resolves.toContain(last);
expect(spaces).toBe(2);
io.detach();
});

it('rejects when the channel closes mid-command with a string reason', async () => {
const channel = new FakeSsh2Channel({ prompt });
const io = new ShellIo(channel, { promptIdentity: prompt, settleMs: 0, idleBufferMaxBytes: 64_000 });
Expand Down
Loading