mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
A capture load now replays its queued tail, and that replay runs through `batchTerminalWrite`, which samples `_wasAtBottomBeforeWrite` before it queues. It runs inside `chunkedTerminalWrite`, before that promise resolves, with the terminal freshly reset and rewritten — so the sample is always true. The caller then restored the reader's position and the next `flushPendingWrites` scrolled straight back to the bottom off the latched flag, undoing it. The only thing in the way was `_hasRecentUserScrollUp()`, a 1500ms window a server-triggered refresh is usually past. `_syncStickyScrollBaseline()` re-takes the flag from wherever the viewport now sits, and the two paths that restore a position call it right after doing so: `_onSessionNeedsRefresh` and `_maybeRefetchFullHistory`. Those are the paths #259 and #205 exist for, and they are also where a non-empty queue is most likely, since a needsRefresh fires when output is flooding. Re-taking rather than suppressing the sampling: suppressing leaves whatever stale value the flag held from before the load, which on the full-history re-pull has no reason to be false. `selectSession` and `_onSessionClearTerminal` deliberately end at the bottom, so the sampled true is already the truth there and they do not call it. `_bufferLoadFinishOpts` gains the coverage the CI gate can see: both mux sources flush, `history` does not, and a payload naming no source does not. Its only coverage was the browser suite, which CI does not run. The JSDoc and the changeset now record the one duplicate window this cutoff cannot close. The server appends output to the byte buffer in the same tick it emits, but broadcasts on a batch timer — 8ms over WebSocket, 16 to 50ms over SSE — so a batch pending when `capture-pane` ran leaves the server after the reply and is replayed although the capture holds it. It is one batch interval wide against a recovery window spanning the whole chunked write, and closing it means flushing that batch server side before the capture. The second browser test asserts its session was created, so a failed create fails it instead of passing with zero hits. docs/architecture-invariants.md no longer claims the replay leaves the queued-event discard window alone. That clause now describes what decides how a load ends, the baseline rule, the batch window, and the three covering tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
181 lines
6.9 KiB
TypeScript
181 lines
6.9 KiB
TypeScript
/**
|
|
* @fileoverview Output arriving after a pane capture survives the buffer load.
|
|
*
|
|
* `batchTerminalWrite` queues live terminal events while a buffer load runs,
|
|
* and `_finishBufferLoad` discards that queue by default. That is right when
|
|
* the loaded buffer is the server's accumulated byte history, which is current
|
|
* up to the response. A tmux pane capture is current only up to CAPTURE time,
|
|
* so anything arriving between the capture and the end of the chunked write is
|
|
* queued and then dropped, with nothing scheduling a re-fetch.
|
|
*
|
|
* The queue now stamps each entry with its arrival time, and a capture load
|
|
* replays the tail that arrived after the response headers. These drive the
|
|
* real client in chromium: the event is injected from inside the response's
|
|
* own `json()` call, which is the one place guaranteed to land after the
|
|
* headers and before the chunked write.
|
|
*
|
|
* Port: 3256 (capture load window)
|
|
*
|
|
* Run: npx vitest run --config config/vitest.browser.config.ts test/capture-load-window.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 = 3256;
|
|
const BASE_URL = `http://localhost:${PORT}`;
|
|
const MARKER = 'ARRIVED-AFTER-THE-CAPTURE';
|
|
|
|
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);
|
|
|
|
/**
|
|
* Select the session with the terminal fetch stubbed, injecting one live event
|
|
* from inside `json()`. Returns how many terminal rows carry the marker, so a
|
|
* flush that replays too much fails as loudly as one that replays nothing.
|
|
*/
|
|
async function runLoad(page: Page, sessionId: string, source: string): Promise<number> {
|
|
return page.evaluate(
|
|
async ({ sid, src, marker }) => {
|
|
const app = (
|
|
window as unknown as {
|
|
app: {
|
|
selectSession: (id: string, o?: object) => Promise<void>;
|
|
_onSessionTerminal: (e: { id: string; data: string }) => void;
|
|
terminal: {
|
|
buffer: {
|
|
active: {
|
|
length: number;
|
|
getLine: (i: number) => { translateToString: (t: boolean) => string } | undefined;
|
|
};
|
|
};
|
|
};
|
|
};
|
|
}
|
|
).app;
|
|
|
|
const realFetch = window.fetch.bind(window);
|
|
window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = String(typeof input === 'string' ? input : ((input as Request).url ?? input));
|
|
if (!url.includes('/terminal')) return realFetch(input as RequestInfo, init);
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
// `selectSession` timestamps the headers the moment this promise
|
|
// resolves, then calls json(). Injecting here puts the event after
|
|
// that timestamp and inside the load window, which is exactly the
|
|
// gap a pane capture cannot cover.
|
|
json: async () => {
|
|
app._onSessionTerminal({ id: sid, data: `\r\n${marker}\r\n` });
|
|
return {
|
|
success: true,
|
|
data: {
|
|
terminalBuffer: '\x1b[1;1Hcaptured frame line one\r\n',
|
|
status: 'idle',
|
|
fullSize: 512,
|
|
retainedBytes: 512,
|
|
truncated: false,
|
|
truncationReason: null,
|
|
source: src,
|
|
captureCols: 80,
|
|
captureRows: 24,
|
|
},
|
|
};
|
|
},
|
|
}) as unknown as Promise<Response>;
|
|
}) as typeof window.fetch;
|
|
|
|
try {
|
|
await app.selectSession(sid);
|
|
await new Promise((r) => setTimeout(r, 1200));
|
|
const buf = app.terminal.buffer.active;
|
|
let hits = 0;
|
|
for (let i = 0; i < buf.length; i++) {
|
|
if (buf.getLine(i)?.translateToString(true).includes(marker)) hits += 1;
|
|
}
|
|
return hits;
|
|
} finally {
|
|
window.fetch = realFetch;
|
|
}
|
|
},
|
|
{ sid: sessionId, src: source, marker: MARKER }
|
|
);
|
|
}
|
|
|
|
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 loads from /vendor, so the terminal appears a beat after the app.
|
|
// Without it every buffer assertion below would throw rather than compare.
|
|
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-load-window-test' }),
|
|
});
|
|
const body = await res.json();
|
|
return body.data?.session?.id ?? body.data?.id ?? body.id;
|
|
});
|
|
}
|
|
|
|
describe('output emitted during a capture load', () => {
|
|
let context: BrowserContext;
|
|
let page: Page;
|
|
|
|
afterAll(async () => {
|
|
await context?.close();
|
|
});
|
|
|
|
it('reaches the terminal exactly once when the buffer came from a pane capture', async () => {
|
|
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
page = await context.newPage();
|
|
const sessionId = await openSession(page);
|
|
expect(sessionId).toBeTruthy();
|
|
|
|
// Exactly once. The cutoff exists so the flush cannot also replay events the
|
|
// payload already carried, which would double the output rather than heal it.
|
|
expect(await runLoad(page, sessionId, 'mux-visible')).toBe(1);
|
|
|
|
await page.evaluate(
|
|
(sid: string) => fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).then(() => undefined),
|
|
sessionId
|
|
);
|
|
await context.close();
|
|
}, 60_000);
|
|
|
|
it('stays dropped when the buffer came from the accumulated byte history', async () => {
|
|
// The byte history already contains everything up to the response, so
|
|
// replaying the queue on top of it would duplicate the output — most
|
|
// visibly Ink's cursor-up redraws. The discard has to survive this fix.
|
|
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
|
page = await context.newPage();
|
|
const sessionId = await openSession(page);
|
|
// Without this, a failed create passes the zero-hit assertion below
|
|
// vacuously — nothing was loaded, so nothing was replayed.
|
|
expect(sessionId).toBeTruthy();
|
|
|
|
expect(await runLoad(page, sessionId, 'history')).toBe(0);
|
|
|
|
await page.evaluate(
|
|
(sid: string) => fetch(`/api/sessions/${sid}`, { method: 'DELETE' }).then(() => undefined),
|
|
sessionId
|
|
);
|
|
await context.close();
|
|
}, 60_000);
|
|
});
|