mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Five items, two of which he could only see by running it, plus six smaller ones. Taking the two blockers first, because both were wrong in ways the existing tests could not catch. **Adopting the PTY's rows put the CLI's input line off-screen.** A phone that took a desktop's 43 rows into a viewport with room for 18 painted an `.xterm-screen` far taller than its container; xterm's own viewport then had nothing to scroll, so the bottom of the frame sat below the container with no gesture able to reach it. Output visible, typing invisible, for as long as the desktop kept the claim hot. `reconcilePtyGeometry` adopts COLUMNS ONLY now: width is the axis Ink's wrap and `eraseLines` arithmetic depend on, and keeping the local row count keeps the composer at the bottom of a viewport that scrolls. Measured at his geometry — a 360x300 container against a 198x43 pane now keeps 13 rows, takes 198 columns, paints 202px into a 210px container, and the input line is inside the box. **`capture-geometry-retry.browser.test.ts` failed, and CI could not see it** because the file is in `BROWSER_TEST_GLOBS`. Its premise WAS the clamp — `getTerminalDimensions()` floored while `fitAddon.fit()` did not — which this work removes at the source, so it can never hold again at any viewport. The case survives on its own terms: a pane already drawing at the requested size must not be replayed. Its premise is now the #464 invariant itself, that the floored report and the terminal agree, which is a stronger guard because the clamp coming back fails it here rather than silently restoring the replay loop. The helper docblock that repeated the old premise is corrected too. **A session with no pane reported 120x40 and the client adopted it.** `resize()` writes `_ptyCols`/`_ptyRows` only when `ptyProcess` is set and nothing seeds them from the spawn geometry, so a dead-pane session still held the constructor defaults — clicking that tab resized the browser terminal to 120x40 and, on anything narrower, claimed another device owned the pane when none existed. `Session.ptyGeometry` returns null without a pane, the HTTP route answers `{}` and the socket sends no frame at all. The raw `ptyCols`/`ptyRows` getters are deleted rather than left available to be misused again. **The 40-column floor clipped the pane with nothing able to reach it.** The affordance keyed on a PTY mismatch, and the floor produces no mismatch — xterm and the PTY agree throughout, the terminal is simply wider than the box. It keys on what does not FIT now, MEASURED (`.xterm-screen` against the container, on the next frame, because the screen takes its width with the render) rather than derived from cell arithmetic. Measured at 360px: font 24 applies 40 columns and paints 560px, and all 200px of the overhang is reachable. `.pty-oversized` is renamed `.term-overflows-x`, because after this the old name describes only one of the two causes. **"Scroll sideways" did not work on touch for the sessions it targets.** `touch-action: pan-x` is cancelled before it starts by the `preventDefault()` `touchstart` calls on every 'content' tap. The terminal's own touchmove handler pans the container now, with the axis locked once per gesture so a diagonal cannot pan and scroll at once, and the CSS grants no `touch-action` at all — handing the browser a pan AS WELL would move the pane twice for one finger on the taps where that preventDefault does not run. Measured under real touch dispatch: a 140px swipe reaches `scrollLeft` 140 where it reached 0 before, the buffer does not move with it, and a vertical swipe still scrolls the scrollback. Three defects in the above, found while checking it rather than by being told: - `canPanHorizontally` first tested `scrollWidth > clientWidth` alone, which is true of a container that is not a scroller — a sideways swipe would have locked the axis, done nothing, AND suppressed the vertical scroll it should have been. Gated on the class as well. - The notice advised scrolling sideways whenever the PTY was wider, including when it still fitted and nothing scrolled. It is gated on measured overflow, and on a comparison against the width this container WOULD request rather than the one it currently holds — once adopted those are equal, so the second question answers itself false while the condition is still true. - `_syncTerminalOverflowAffordance` could throw out of `document.getElementById` before reaching its try block. It runs off every geometry change, so a cosmetic affordance could have taken the resize down with it. The smaller items: - `docs/architecture-invariants.md` no longer explains the equality guard as a clamp signature; it records what the clamp used to do and why it cannot any more. Edited by hand — that file is outside the Prettier glob, and letting Prettier near it rewrote eleven unrelated emphasis markers. - `throttledResize`'s HTTP fallback reads the reply. It is the path where a declined resize is least likely to be noticed, because no socket means no `{"t":"zc"}` frame either. - The changeset covers the whole release: the geometry work, the queued replay clear, the renderer watchdog, the body-covering fetch deadline, the WebSocket output-gap reconcile, the build-generated service-worker precache and per-build cache key, and the crash-trail hygiene. - `@xterm/headless` is declared in the root devDependencies instead of being reached through workspace hoisting. - The output-gap marker is cleared after any response arrives, not only when the capture was non-empty: a server that answers with an empty capture HAS reconciled us, and leaving the marker set refetched on every reconnect. - `e587d845`'s message claimed a test asserted the failed-load copy against the built asset. It did not — that assertion lived in a probe deleted with the other scratch scripts, so the claim was false when it was written. There is a real test now, and it reads the source rather than `dist/`, because `dist/` is not committed and a test that skips when it is absent would pass for the wrong reason in CI. `Session.ptyGeometry` gets behavioural coverage against the real class in `session-resize-arbitration.test.ts` rather than a source guard, including the contrast — a pane that does exist still reports, and still follows a resize — so "always null" would fail it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
558 lines
24 KiB
TypeScript
558 lines
24 KiB
TypeScript
/**
|
|
* @fileoverview A capture drawn for a bigger pane makes the client replay once.
|
|
*
|
|
* A visible-frame capture repaints each row at an absolute position, counting
|
|
* up to the PANE's height and out to the PANE's width. A terminal shorter than
|
|
* that clamps every address past its own height onto its last line, so the
|
|
* overflow rows overwrite one another and the rows underneath are lost. A
|
|
* narrower terminal wraps every painted row, and the wrap on the last one
|
|
* scrolls the whole frame up by one. The client cannot see either from the
|
|
* escape sequence, so the terminal response reports the geometry the capture
|
|
* was taken at (`captureCols`/`captureRows`) and `selectSession` replays once
|
|
* at the size that stuck.
|
|
*
|
|
* The comparison runs on a `mux-visible` response ONLY. The other two sources
|
|
* position no rows absolutely, so a size mismatch damages neither and a replay
|
|
* repairs neither, and the last case here pins that the expensive one is left
|
|
* alone.
|
|
*
|
|
* These drive the REAL client in chromium and stub only the terminal endpoint,
|
|
* because the mismatch itself needs two viewports to stage against live tmux.
|
|
* Without the fix the first assertion below sees one fetch instead of two.
|
|
*
|
|
* Port: 3252 (capture geometry retry)
|
|
*
|
|
* Run: npx vitest run --config config/vitest.browser.config.ts test/capture-geometry-retry.browser.test.ts
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
|
|
import { WebServer } from '../src/web/server.js';
|
|
|
|
const PORT = 3252;
|
|
const BASE_URL = `http://localhost:${PORT}`;
|
|
|
|
let server: WebServer;
|
|
let browser: Browser;
|
|
|
|
beforeAll(async () => {
|
|
server = new WebServer(PORT, false, true); // testMode
|
|
await server.start();
|
|
browser = await chromium.launch({ headless: true });
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await browser?.close();
|
|
await server?.stop();
|
|
}, 30_000);
|
|
|
|
/** A visible-frame capture: one absolutely-addressed paint per row. */
|
|
function paneSnapshot(rows: number): string {
|
|
const parts: string[] = [];
|
|
for (let row = 1; row <= rows; row++) parts.push(`\x1b[${row};1Hprobe-row-${row}`);
|
|
parts.push(`\x1b[${rows};6H`);
|
|
return parts.join('');
|
|
}
|
|
|
|
/**
|
|
* Serve every terminal fetch from a stub reporting `captureRows`, counting the
|
|
* fetches. The real route needs live tmux to produce a mismatched frame.
|
|
*
|
|
* `source` is DERIVED from the request the way the real route derives it: a
|
|
* `full=1` request whose capture came back is `mux-full-history`, and every
|
|
* other one is `mux-visible`. The route cannot answer `full=1` with
|
|
* `mux-visible`, so a stub that did would stage a combination production never
|
|
* produces, and a test resting on it would prove nothing about production. A
|
|
* test that needs some other source passes it explicitly and says why.
|
|
*/
|
|
async function stubTerminal(
|
|
page: Page,
|
|
captureRows: number,
|
|
counter: { n: number; urls: string[] },
|
|
options: { source?: string; captureCols?: number } = {}
|
|
) {
|
|
const captureCols = options.captureCols ?? 200;
|
|
await page.route('**/api/sessions/*/terminal*', async (route) => {
|
|
const url = route.request().url();
|
|
counter.n += 1;
|
|
counter.urls.push(url);
|
|
const source = options.source ?? (url.includes('full=1') ? 'mux-full-history' : 'mux-visible');
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
success: true,
|
|
data: {
|
|
terminalBuffer: paneSnapshot(captureRows),
|
|
status: 'idle',
|
|
fullSize: 1024,
|
|
retainedBytes: 1024,
|
|
truncated: false,
|
|
truncationReason: null,
|
|
source,
|
|
captureCols,
|
|
captureRows,
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* As `stubTerminal`, but reading its geometry from a holder the test can change
|
|
* between selects. That is what lets one case watch a pane stop fitting and
|
|
* start fitting again, which a stub fixed at construction cannot show.
|
|
*/
|
|
async function stubTerminalDynamic(
|
|
page: Page,
|
|
counter: { n: number; urls: string[] },
|
|
state: { captureRows: number; captureCols: number }
|
|
) {
|
|
await page.route('**/api/sessions/*/terminal*', async (route) => {
|
|
const url = route.request().url();
|
|
counter.n += 1;
|
|
counter.urls.push(url);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
success: true,
|
|
data: {
|
|
terminalBuffer: paneSnapshot(state.captureRows),
|
|
status: 'idle',
|
|
fullSize: 1024,
|
|
retainedBytes: 1024,
|
|
truncated: false,
|
|
truncationReason: null,
|
|
source: url.includes('full=1') ? 'mux-full-history' : 'mux-visible',
|
|
captureCols: state.captureCols,
|
|
captureRows: state.captureRows,
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Answer every fetch with the geometry the client itself is asking for, read
|
|
* live from the page. That is the clamp signature: `getTerminalDimensions()`
|
|
* floors at 40x10, and since #464 `syncTerminalGeometry()` applies that floor
|
|
* to xterm too — so at a small enough viewport the pane, the report and the
|
|
* terminal all agree on the floored size, which is the case the equality guard
|
|
* is left covering.
|
|
*/
|
|
async function stubTerminalAtRequestedSize(page: Page, counter: { n: number; urls: string[] }) {
|
|
await page.route('**/api/sessions/*/terminal*', async (route) => {
|
|
counter.n += 1;
|
|
counter.urls.push(route.request().url());
|
|
const dims = await page.evaluate(
|
|
() =>
|
|
(
|
|
window as unknown as { app: { getTerminalDimensions?: () => { cols: number; rows: number } | null } }
|
|
).app.getTerminalDimensions?.() ?? null
|
|
);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
success: true,
|
|
data: {
|
|
terminalBuffer: paneSnapshot(dims?.rows ?? 10),
|
|
status: 'idle',
|
|
fullSize: 1024,
|
|
retainedBytes: 1024,
|
|
truncated: false,
|
|
truncationReason: null,
|
|
source: 'mux-visible',
|
|
captureCols: dims?.cols,
|
|
captureRows: dims?.rows,
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
}
|
|
|
|
/** The widest terminal this suite's 1280px viewport can produce, with margin. */
|
|
const WIDER_THAN_ANY_TERMINAL_COLS = 500;
|
|
|
|
async function openSession(page: Page): Promise<string> {
|
|
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
|
|
await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { timeout: 10_000 });
|
|
// xterm is loaded from /vendor, so the terminal appears a beat after the app.
|
|
// Without it `app.terminal.rows` reads 0 and every height comparison below
|
|
// would pass vacuously.
|
|
await page.waitForFunction(() => (window as unknown as { app?: { terminal?: unknown } }).app?.terminal, null, {
|
|
timeout: 30_000,
|
|
});
|
|
return page.evaluate(async () => {
|
|
const res = await fetch('/api/sessions', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workingDir: '/tmp', name: 'capture-geometry-test' }),
|
|
});
|
|
const body = await res.json();
|
|
return body.data?.session?.id ?? body.data?.id ?? body.id;
|
|
});
|
|
}
|
|
|
|
/** The terminal is sized by the first select, so this only reads after one. */
|
|
async function terminalRows(page: Page): Promise<number> {
|
|
return page.evaluate(() => (window as unknown as { app: { terminal?: { rows: number } } }).app.terminal?.rows ?? 0);
|
|
}
|
|
|
|
/** As above, for the width half of the comparison. */
|
|
async function terminalCols(page: Page): Promise<number> {
|
|
return page.evaluate(() => (window as unknown as { app: { terminal?: { cols: number } } }).app.terminal?.cols ?? 0);
|
|
}
|
|
|
|
async function select(page: Page, sessionId: string, options: object = {}): Promise<void> {
|
|
await page.evaluate(
|
|
async ({ sid, opts }) => {
|
|
const app = (window as unknown as { app: { selectSession: (id: string, o?: object) => Promise<void> } }).app;
|
|
await app.selectSession(sid, opts);
|
|
},
|
|
{ sid: sessionId, opts: options }
|
|
);
|
|
await page.waitForTimeout(1500);
|
|
}
|
|
|
|
/**
|
|
* Spend the per-page full-history allowance and forget what it cost. Every
|
|
* geometry comparison below runs on a `mux-visible` response, and the route
|
|
* only produces one for a request sent WITHOUT `full=1`, so reaching that shape
|
|
* means not being the first select of the page — which is what a tab switch is.
|
|
*/
|
|
async function consumeFullHistory(
|
|
page: Page,
|
|
sessionId: string,
|
|
counter: { n: number; urls: string[] }
|
|
): Promise<void> {
|
|
await select(page, sessionId);
|
|
counter.n = 0;
|
|
counter.urls.length = 0;
|
|
}
|
|
|
|
async function closeSession(page: Page, sessionId: string): Promise<void> {
|
|
await page.evaluate(
|
|
(sid: string) => fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).then(() => undefined),
|
|
sessionId
|
|
);
|
|
}
|
|
|
|
describe('a capture bigger than the terminal', () => {
|
|
let context: BrowserContext;
|
|
let page: Page;
|
|
|
|
afterAll(async () => {
|
|
await context?.close();
|
|
});
|
|
|
|
it('replays once when the captured pane is taller, and stops at one retry', async () => {
|
|
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
page = await context.newPage();
|
|
const sessionId = await openSession(page);
|
|
expect(sessionId).toBeTruthy();
|
|
|
|
// 200 rows is taller than any terminal this viewport can produce, so the
|
|
// trigger is the captured height alone and not a size that moved.
|
|
const fetches = { n: 0, urls: [] as string[] };
|
|
await stubTerminal(page, 200, fetches);
|
|
// A tab switch is where a visible-frame response arrives, so that is what
|
|
// this measures. The first select of the page takes the full-history path
|
|
// and is covered by its own case below.
|
|
await consumeFullHistory(page, sessionId, fetches);
|
|
await select(page, sessionId, { forceReload: true });
|
|
|
|
// The terminal is sized by that select, so the premise is checkable now.
|
|
expect(await terminalRows(page)).toBeLessThan(200);
|
|
// One original load plus exactly one retry. `resizeRetry` caps it there:
|
|
// the retry's own response reports the same mismatch, so an uncapped
|
|
// implementation would loop.
|
|
expect(fetches.n).toBe(2);
|
|
|
|
await closeSession(page, sessionId);
|
|
await context.close();
|
|
}, 60_000);
|
|
|
|
it('retries at the same scope the first pass used, not a wider one', async () => {
|
|
// The retry re-arms the full-history flag only when the pass that ran had
|
|
// consumed it. A tab switch takes the bounded tail, so its retry must take
|
|
// the tail too; clearing the flag unconditionally would upgrade it into a
|
|
// fresh multi-megabyte scrollback capture the user never asked for.
|
|
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
page = await context.newPage();
|
|
const sessionId = await openSession(page);
|
|
|
|
const fetches = { n: 0, urls: [] as string[] };
|
|
await stubTerminal(page, 200, fetches);
|
|
|
|
// First select: a fresh session, so this one pulls full history. It does
|
|
// NOT retry, because the geometry comparison runs on a visible-frame
|
|
// response and a `full=1` request cannot produce one.
|
|
await select(page, sessionId);
|
|
expect(fetches.n).toBe(1);
|
|
expect(fetches.urls.filter((u) => u.includes('full=1'))).toHaveLength(1);
|
|
|
|
// Re-select the SAME session. `selectSession` early-returns on an already
|
|
// active session unless forceReload is set, and forceReload is the shape a
|
|
// tab switch back to this session takes: `_fullHistoryLoaded` still holds
|
|
// it, so neither this pass nor its retry asks for full history again.
|
|
await select(page, sessionId, { forceReload: true });
|
|
const tabSwitchUrls = fetches.urls.slice(1);
|
|
expect(tabSwitchUrls.length).toBe(2);
|
|
expect(tabSwitchUrls.filter((u) => u.includes('full=1'))).toHaveLength(0);
|
|
|
|
await closeSession(page, sessionId);
|
|
await context.close();
|
|
}, 60_000);
|
|
|
|
it('does not replay when the captured pane fits the terminal', async () => {
|
|
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
page = await context.newPage();
|
|
const sessionId = await openSession(page);
|
|
|
|
// Five rows is shorter than any terminal this viewport can produce, so the
|
|
// frame fits, nothing is clamped, and nothing needs repeating. A retry here
|
|
// would double the work of every tab switch.
|
|
const fetches = { n: 0, urls: [] as string[] };
|
|
await stubTerminal(page, 5, fetches, { captureCols: 40 });
|
|
await consumeFullHistory(page, sessionId, fetches);
|
|
await select(page, sessionId, { forceReload: true });
|
|
|
|
expect(await terminalRows(page)).toBeGreaterThan(5);
|
|
expect(fetches.n).toBe(1);
|
|
|
|
await closeSession(page, sessionId);
|
|
await context.close();
|
|
}, 60_000);
|
|
|
|
it('replays once when the captured pane is wider', async () => {
|
|
// A pane wider than the terminal damages the same frame a second way.
|
|
// `formatPaneSnapshot` paints every row out to the PANE's width, so a
|
|
// narrower browser wraps each painted row, and the wrap on the last row
|
|
// scrolls the whole frame up by one. The height here fits deliberately, so
|
|
// the width is the only thing that can trigger the replay.
|
|
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
page = await context.newPage();
|
|
const sessionId = await openSession(page);
|
|
|
|
const fetches = { n: 0, urls: [] as string[] };
|
|
await stubTerminal(page, 5, fetches, { captureCols: WIDER_THAN_ANY_TERMINAL_COLS });
|
|
await consumeFullHistory(page, sessionId, fetches);
|
|
await select(page, sessionId, { forceReload: true });
|
|
|
|
expect(await terminalRows(page)).toBeGreaterThan(5);
|
|
expect(await terminalCols(page)).toBeLessThan(WIDER_THAN_ANY_TERMINAL_COLS);
|
|
expect(fetches.n).toBe(2);
|
|
|
|
await closeSession(page, sessionId);
|
|
await context.close();
|
|
}, 60_000);
|
|
|
|
it('does not replay a full-history response, whatever geometry it reports', async () => {
|
|
// A `full=1` body is linear scrollback closed by a RELATIVE cursor move,
|
|
// which is relative precisely so the browser's row count need not match the
|
|
// pane's. A mismatch there is not damage and a replay cannot repair it, so
|
|
// the geometry comparison must not fire on it. This is the path that makes
|
|
// the gate worth having: `_fullHistoryLoaded` is empty on the first select
|
|
// of every non-shell session per page, so an ungated comparison would pull
|
|
// the entire tmux scrollback a second time on every page load and every
|
|
// first tab switch, for a session whose pane a desktop tab is holding too
|
|
// tall to ever fit.
|
|
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
page = await context.newPage();
|
|
const sessionId = await openSession(page);
|
|
|
|
const fetches = { n: 0, urls: [] as string[] };
|
|
await stubTerminal(page, 200, fetches, { captureCols: WIDER_THAN_ANY_TERMINAL_COLS });
|
|
await select(page, sessionId);
|
|
|
|
// Both dimensions are mismatched, so height alone is not what spares it.
|
|
expect(await terminalRows(page)).toBeLessThan(200);
|
|
expect(await terminalCols(page)).toBeLessThan(WIDER_THAN_ANY_TERMINAL_COLS);
|
|
expect(fetches.n).toBe(1);
|
|
expect(fetches.urls.filter((u) => u.includes('full=1'))).toHaveLength(1);
|
|
|
|
await closeSession(page, sessionId);
|
|
await context.close();
|
|
}, 60_000);
|
|
|
|
it('replays once per session, not once per tab switch, when it cannot converge', async () => {
|
|
// `resizeRetry` caps the recursion inside ONE select and says nothing about
|
|
// the next one, so a pane this browser cannot size reported the same
|
|
// mismatch on every select and bought the same failed repair every time:
|
|
// two fetches per tab switch for the life of the page. That is the case the
|
|
// description calls "every time rather than occasionally", a phone whose
|
|
// resize is declined while a desktop claim is live, and it is not the only
|
|
// one — any pane Codeman cannot size lands there, a second tmux client
|
|
// attached to it included. Each wasted pass costs another `capture-pane`,
|
|
// which is `execSync` on the server's event loop, plus a reset and rewrite,
|
|
// a discarded snapshot, and a dropped and reopened WebSocket.
|
|
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
page = await context.newPage();
|
|
const sessionId = await openSession(page);
|
|
|
|
const fetches = { n: 0, urls: [] as string[] };
|
|
const pane = { captureRows: 200, captureCols: 200 };
|
|
await stubTerminalDynamic(page, fetches, pane);
|
|
await consumeFullHistory(page, sessionId, fetches);
|
|
|
|
// First tab switch: one load, one replay, and the replay does not fit
|
|
// either, which is the proof that this pane ignores the size it is given.
|
|
await select(page, sessionId, { forceReload: true });
|
|
expect(fetches.n).toBe(2);
|
|
|
|
// Every switch after it pays once. Unlatched this reads 4 then 6.
|
|
await select(page, sessionId, { forceReload: true });
|
|
expect(fetches.n).toBe(3);
|
|
await select(page, sessionId, { forceReload: true });
|
|
expect(fetches.n).toBe(4);
|
|
|
|
// The memo has to lift when the pane becomes sizeable again, or closing the
|
|
// desktop tab that was holding it would leave this session permanently
|
|
// unrepaired. A frame that fits clears it...
|
|
pane.captureRows = 5;
|
|
pane.captureCols = 40;
|
|
await select(page, sessionId, { forceReload: true });
|
|
expect(fetches.n).toBe(5);
|
|
|
|
// ...so the next genuine mismatch is diagnosed again.
|
|
pane.captureRows = 200;
|
|
pane.captureCols = 200;
|
|
await select(page, sessionId, { forceReload: true });
|
|
expect(fetches.n).toBe(7);
|
|
|
|
await closeSession(page, sessionId);
|
|
await context.close();
|
|
}, 60_000);
|
|
|
|
it('hands over text typed but not yet submitted before it replays', async () => {
|
|
// On a touch device the characters the user has typed live ONLY in the
|
|
// local-echo overlay until Enter; they have never reached the PTY. The
|
|
// replay re-enters `selectSession` with `forceReload` on the session that
|
|
// is still active, and that branch used to null `activeSessionId` before
|
|
// `_cleanupPreviousSession` ran, so the flush there saw no session and the
|
|
// unconditional `clear()` afterwards took the characters with it. Nothing
|
|
// the user did triggered that: the replay fires on its own the moment a
|
|
// tab switch finishes, which is exactly when someone typing into a
|
|
// still-loading terminal has text in the overlay.
|
|
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
page = await context.newPage();
|
|
const sessionId = await openSession(page);
|
|
|
|
const fetches = { n: 0, urls: [] as string[] };
|
|
await stubTerminal(page, 200, fetches);
|
|
await consumeFullHistory(page, sessionId, fetches);
|
|
|
|
// Headless chromium reports `isTouchDevice()` false even with `hasTouch`,
|
|
// so the overlay would stay off and the whole case would pass vacuously.
|
|
// The setting is what `_updateLocalEchoState()` reads, so it survives the
|
|
// recompute that every select runs; the flag is forced too, for the window
|
|
// before the next recompute. Record what crosses into the delivery layer,
|
|
// which is the seam the text failed to cross.
|
|
await page.evaluate(() => {
|
|
const w = window as unknown as {
|
|
app: {
|
|
_localEchoEnabled: boolean;
|
|
_sendInputAsync: (id: string, text: string, opts?: unknown) => void;
|
|
terminal?: { focus: () => void };
|
|
loadAppSettingsFromStorage: () => Record<string, unknown>;
|
|
};
|
|
__sentInputs: { id: string; text: string }[];
|
|
};
|
|
const settings = w.app.loadAppSettingsFromStorage();
|
|
settings.localEchoEnabled = true;
|
|
localStorage.setItem('codeman-app-settings', JSON.stringify(settings));
|
|
w.app._localEchoEnabled = true;
|
|
w.__sentInputs = [];
|
|
const original = w.app._sendInputAsync.bind(w.app);
|
|
w.app._sendInputAsync = (id: string, text: string, opts?: unknown) => {
|
|
w.__sentInputs.push({ id, text });
|
|
return original(id, text, opts);
|
|
};
|
|
w.app.terminal?.focus();
|
|
});
|
|
|
|
await page.keyboard.type('hello-unsent');
|
|
// The premise: the characters really are sitting in the overlay, unsent.
|
|
// Without this the case would pass on a build where typing goes straight
|
|
// to the PTY and there is nothing to lose.
|
|
const pendingBefore = await page.evaluate(
|
|
() =>
|
|
(window as unknown as { app: { _localEchoOverlay?: { pendingText: string } } }).app._localEchoOverlay
|
|
?.pendingText ?? ''
|
|
);
|
|
expect(pendingBefore).toBe('hello-unsent');
|
|
|
|
// The captured pane is taller than the terminal, so this select replays.
|
|
await select(page, sessionId, { forceReload: true });
|
|
expect(fetches.n).toBe(2);
|
|
|
|
const sent = await page.evaluate(
|
|
() => (window as unknown as { __sentInputs: { id: string; text: string }[] }).__sentInputs
|
|
);
|
|
expect(sent.map((s) => s.text)).toContain('hello-unsent');
|
|
expect(sent.find((s) => s.text === 'hello-unsent')?.id).toBe(sessionId);
|
|
|
|
await closeSession(page, sessionId);
|
|
await context.close();
|
|
}, 60_000);
|
|
|
|
it('does not replay a pane already at the size the client asked for', async () => {
|
|
// ⚠️ The premise of this case CHANGED with issue #464, and the old one can
|
|
// never hold again. It used to be the clamp: `getTerminalDimensions()`
|
|
// floors at 40x10 while `fitAddon.fit()` did not, so a viewport this small
|
|
// left the terminal shorter than the size the client itself requested, the
|
|
// pane drew at the floored size, and the captured height exceeded the
|
|
// terminal's forever — a replay that re-requested the same floored size and
|
|
// captured the same frame, on every tab switch, for the life of the page.
|
|
//
|
|
// `syncTerminalGeometry()` now applies the floor to xterm as well, so the
|
|
// browser terminal IS the size it reports and that divergence is gone at
|
|
// the source. The case survives on its own terms — a pane already drawing
|
|
// at the requested size must not be replayed, because the retry would
|
|
// capture the identical frame — and its premise is now the #464 invariant
|
|
// itself, asserted below: the floored report and the terminal agree. That
|
|
// is a stronger guard than the old one, since the clamp coming back would
|
|
// fail it here rather than silently restoring the replay loop.
|
|
context = await browser.newContext({ viewport: { width: 320, height: 200 } });
|
|
page = await context.newPage();
|
|
const sessionId = await openSession(page);
|
|
|
|
const fetches = { n: 0, urls: [] as string[] };
|
|
await stubTerminalAtRequestedSize(page, fetches);
|
|
await consumeFullHistory(page, sessionId, fetches);
|
|
await select(page, sessionId, { forceReload: true });
|
|
|
|
// The premise: the floor really does bind at this viewport — otherwise the
|
|
// case would pass on any viewport, proving nothing — AND the terminal holds
|
|
// exactly what it reports, which is what stops the old replay loop.
|
|
const requested = await page.evaluate(
|
|
() =>
|
|
(
|
|
window as unknown as { app: { getTerminalDimensions?: () => { cols: number; rows: number } | null } }
|
|
).app.getTerminalDimensions?.() ?? null
|
|
);
|
|
expect(requested).not.toBeNull();
|
|
const proposed = await page.evaluate(
|
|
() =>
|
|
(
|
|
window as unknown as { app: { fitAddon?: { proposeDimensions?: () => { cols: number; rows: number } } } }
|
|
).app.fitAddon?.proposeDimensions?.() ?? null
|
|
);
|
|
expect(proposed, 'the terminal could not be measured').not.toBeNull();
|
|
expect(
|
|
proposed!.rows < requested!.rows || proposed!.cols < requested!.cols,
|
|
`the floor must bind at this viewport, or the case proves nothing (proposed ${proposed!.cols}x${proposed!.rows}, reported ${requested!.cols}x${requested!.rows})`
|
|
).toBe(true);
|
|
// The #464 invariant: what the client reports is what the terminal holds.
|
|
expect(requested!.rows).toBe(await terminalRows(page));
|
|
expect(requested!.cols).toBe(await terminalCols(page));
|
|
|
|
expect(fetches.n).toBe(1);
|
|
|
|
await closeSession(page, sessionId);
|
|
await context.close();
|
|
}, 60_000);
|
|
});
|