mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 14:41:35 +00:00
fix(tui): preserve Unicode during fragmented terminal input [AI-assisted] (#114728)
* fix(tests): run Codex prewarm in exactly one shard * fix(tui): preserve Unicode in fragmented terminal input * fix(tui): keep Unicode stress coverage within lint limits
This commit is contained in:
committed by
GitHub
parent
7394262cd1
commit
c656e73065
@@ -7,6 +7,13 @@ export type FixtureLogEntry = {
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
export const COMPACT_TERMINAL_SIZES = [
|
||||
[64, 18],
|
||||
[68, 18],
|
||||
[72, 20],
|
||||
[80, 20],
|
||||
] as const;
|
||||
|
||||
export async function readFixtureLog(logPath: string): Promise<FixtureLogEntry[]> {
|
||||
try {
|
||||
const text = await readFile(logPath, "utf8");
|
||||
@@ -53,6 +60,32 @@ export function objectFieldEquals(entry: FixtureLogEntry, field: string, value:
|
||||
return Object.hasOwn(payload, field) && payload[field] === value;
|
||||
}
|
||||
|
||||
/** Proves fixture-local fragmentation preserves a Unicode prompt through the real TUI loop. */
|
||||
export async function exerciseFragmentedUnicodePrompt(
|
||||
startFixture: (opts: { env?: NodeJS.ProcessEnv }) => Promise<{
|
||||
run: PtyRun;
|
||||
waitForLogEntry: (predicate: (entry: FixtureLogEntry) => boolean) => Promise<FixtureLogEntry>;
|
||||
cleanup: () => Promise<void>;
|
||||
}>,
|
||||
startupTimeoutMs: number,
|
||||
) {
|
||||
const fixture = await startFixture({
|
||||
env: { OPENCLAW_TUI_PTY_TYPE_CHUNK_SIZE: "1", OPENCLAW_TUI_PTY_TYPE_DELAY_MS: "1" },
|
||||
});
|
||||
const message = "hello 👋 from pty";
|
||||
|
||||
try {
|
||||
await fixture.run.waitForOutput("local ready", startupTimeoutMs);
|
||||
await fixture.run.write(`${message}\r`);
|
||||
await fixture.run.waitForOutput(`PTY_RESPONSE: ${message}`);
|
||||
await fixture.waitForLogEntry(
|
||||
(entry) => entry.method === "sendChat" && objectFieldEquals(entry, "message", message),
|
||||
);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/** Approves a workspace skill using exact fragments that survive narrow-terminal wrapping. */
|
||||
export async function approveWorkspaceSkill(
|
||||
fixture: {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
approveWorkspaceSkill,
|
||||
buildOpaqueSessionIsolationFixture,
|
||||
COMPACT_TERMINAL_SIZES,
|
||||
exerciseFragmentedUnicodePrompt,
|
||||
objectFieldEquals,
|
||||
readFixtureLog,
|
||||
waitForFixtureLogEntry,
|
||||
@@ -655,7 +657,7 @@ describe.sequential("TUI PTY harness", () => {
|
||||
});
|
||||
|
||||
it(
|
||||
"drives the real TUI terminal loop through typed input",
|
||||
"drives the real TUI terminal loop through typed and fragmented Unicode input",
|
||||
async () => {
|
||||
await fixture.run.write("hello from pty\r");
|
||||
await fixture.run.waitForOutput("PTY_RESPONSE: hello from pty");
|
||||
@@ -663,8 +665,9 @@ describe.sequential("TUI PTY harness", () => {
|
||||
(entry) =>
|
||||
entry.method === "sendChat" && objectFieldEquals(entry, "message", "hello from pty"),
|
||||
);
|
||||
await exerciseFragmentedUnicodePrompt(startTuiFixture, STARTUP_TIMEOUT_MS);
|
||||
},
|
||||
TEST_TIMEOUT_MS,
|
||||
STARTUP_TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
@@ -707,12 +710,9 @@ describe.sequential("TUI PTY harness", () => {
|
||||
TEST_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ cols: 64, rows: 18 },
|
||||
{ cols: 72, rows: 20 },
|
||||
])(
|
||||
"presents and resolves workspace skill approval in a $cols×$rows terminal",
|
||||
async ({ cols, rows }) => {
|
||||
it.each(COMPACT_TERMINAL_SIZES)(
|
||||
"presents and resolves workspace skill approval in a %i×%i terminal",
|
||||
async (cols, rows) => {
|
||||
const compactFixture = await startTuiFixture({
|
||||
env: {
|
||||
OPENCLAW_TUI_PTY_COLS: String(cols),
|
||||
|
||||
@@ -71,6 +71,33 @@ describe("TUI PTY test support", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ chunkSize: "1", chunks: ["A", "👋", "B"] },
|
||||
{ chunkSize: "2", chunks: ["A👋", "B"] },
|
||||
])(
|
||||
"preserves Unicode characters with fixture-specific $chunkSize-character input chunks",
|
||||
async ({ chunkSize, chunks }) => {
|
||||
const write = vi.fn();
|
||||
const pty = createMockPty({ write });
|
||||
nodePtyMocks.spawn.mockReturnValue(pty);
|
||||
|
||||
const run = startPty("node", [], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
OPENCLAW_TUI_PTY_TYPE_CHUNK_SIZE: chunkSize,
|
||||
OPENCLAW_TUI_PTY_TYPE_DELAY_MS: "1",
|
||||
},
|
||||
exitTimeoutMs: 1_000,
|
||||
outputTimeoutMs: 1_000,
|
||||
});
|
||||
|
||||
await run.write("A👋B");
|
||||
|
||||
expect(write).toHaveBeenCalledTimes(chunks.length);
|
||||
expect(write.mock.calls.map(([chunk]) => chunk)).toEqual(chunks);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "split ANSI control sequences",
|
||||
|
||||
@@ -69,18 +69,20 @@ function readPtyDimensionEnv(name: string, fallback: number, env: NodeJS.Process
|
||||
async function writePtyInput(
|
||||
pty: IPty,
|
||||
data: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
opts: { delay?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const delayMs = readPositiveIntegerEnv("OPENCLAW_TUI_PTY_TYPE_DELAY_MS");
|
||||
const delayMs = readPositiveIntegerEnv("OPENCLAW_TUI_PTY_TYPE_DELAY_MS", env);
|
||||
if (!delayMs || opts.delay === false) {
|
||||
pty.write(data);
|
||||
return;
|
||||
}
|
||||
const chunkSize = readPositiveIntegerEnv("OPENCLAW_TUI_PTY_TYPE_CHUNK_SIZE") ?? 1;
|
||||
// Chunked writes reproduce paste/type races without making every PTY test slow by default.
|
||||
for (let idx = 0; idx < data.length; idx += chunkSize) {
|
||||
pty.write(data.slice(idx, idx + chunkSize));
|
||||
if (idx + chunkSize < data.length) {
|
||||
const chunkSize = readPositiveIntegerEnv("OPENCLAW_TUI_PTY_TYPE_CHUNK_SIZE", env) ?? 1;
|
||||
// Chunk by Unicode characters so stress typing never sends half of a surrogate pair.
|
||||
const characters = Array.from(data);
|
||||
for (let idx = 0; idx < characters.length; idx += chunkSize) {
|
||||
pty.write(characters.slice(idx, idx + chunkSize).join(""));
|
||||
if (idx + chunkSize < characters.length) {
|
||||
await sleep(delayMs);
|
||||
}
|
||||
}
|
||||
@@ -149,7 +151,7 @@ export function startPty(
|
||||
const run: PtyRun = {
|
||||
output: () => output,
|
||||
visibleOutput: () => visibleOutput,
|
||||
write: async (data, writeOpts) => await writePtyInput(pty, data, writeOpts),
|
||||
write: async (data, writeOpts) => await writePtyInput(pty, data, ptyEnv, writeOpts),
|
||||
waitForOutput: async (needle, timeoutMs = opts.outputTimeoutMs) =>
|
||||
await waitFor({
|
||||
timeoutMs,
|
||||
|
||||
Reference in New Issue
Block a user