Skip to content

Commit 963c61f

Browse files
NathanFlurryclaude
andcommitted
feat: US-024 - Pi interactive tests (PTY mode)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bc4eaff commit 963c61f

1 file changed

Lines changed: 122 additions & 6 deletions

File tree

packages/secure-exec/tests/cli-tools/pi-interactive.test.ts

Lines changed: 122 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ import { tmpdir } from 'node:os';
2121
import path from 'node:path';
2222
import { fileURLToPath } from 'node:url';
2323
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
24-
import { createKernel } from '../../../kernel/src/index.ts';
25-
import type { Kernel } from '../../../kernel/src/index.ts';
26-
import type { VirtualFileSystem } from '../../../kernel/src/vfs.ts';
27-
import { TerminalHarness } from '../../../kernel/test/terminal-harness.ts';
28-
import { InMemoryFileSystem } from '../../../os/browser/src/index.ts';
29-
import { createNodeRuntime } from '../../../runtime/node/src/index.ts';
24+
import { createKernel } from '../../../secure-exec-core/src/kernel/index.ts';
25+
import type { Kernel } from '../../../secure-exec-core/src/kernel/index.ts';
26+
import type { VirtualFileSystem } from '../../../secure-exec-core/src/kernel/index.ts';
27+
import { TerminalHarness } from '../../../secure-exec-core/test/kernel/terminal-harness.ts';
28+
import { InMemoryFileSystem } from '../../../secure-exec-browser/src/os-filesystem.ts';
29+
import { createNodeRuntime } from '../../../secure-exec-nodejs/src/kernel-runtime.ts';
3030
import {
3131
createMockLlmServer,
3232
type MockLlmServerHandle,
@@ -444,6 +444,93 @@ describe.skipIf(piSkip)('Pi interactive PTY E2E (sandbox)', () => {
444444
60_000,
445445
);
446446

447+
it(
448+
'differential rendering — multiple interactions render without artifacts',
449+
async ({ skip }) => {
450+
if (sandboxSkip) skip();
451+
452+
const firstCanary = 'DIFF_RENDER_FIRST_42';
453+
const secondCanary = 'DIFF_RENDER_SECOND_77';
454+
mockServer.reset([
455+
{ type: 'text', text: firstCanary },
456+
{ type: 'text', text: secondCanary },
457+
]);
458+
harness = createPiHarness();
459+
460+
await harness.waitFor('claude-sonnet', 1, 30_000);
461+
462+
// First interaction
463+
await harness.type('first prompt\r');
464+
await harness.waitFor(firstCanary, 1, 30_000);
465+
466+
const screenAfterFirst = harness.screenshotTrimmed();
467+
expect(screenAfterFirst).toContain(firstCanary);
468+
469+
// Second interaction — Pi re-renders, new response should appear
470+
await harness.type('second prompt\r');
471+
await harness.waitFor(secondCanary, 1, 30_000);
472+
473+
const screenAfterSecond = harness.screenshotTrimmed();
474+
expect(screenAfterSecond).toContain(secondCanary);
475+
// No garbled escape sequences should appear as visible text
476+
expect(screenAfterSecond).not.toMatch(/\x1b\[[\d;]*[A-Za-z]/);
477+
},
478+
90_000,
479+
);
480+
481+
it(
482+
'synchronized output — CSI ?2026h/l sequences do not leak to screen',
483+
async ({ skip }) => {
484+
if (sandboxSkip) skip();
485+
486+
const canary = 'SYNC_OUTPUT_CANARY';
487+
mockServer.reset([{ type: 'text', text: canary }]);
488+
harness = createPiHarness();
489+
490+
await harness.waitFor('claude-sonnet', 1, 30_000);
491+
492+
await harness.type('say something\r');
493+
await harness.waitFor(canary, 1, 30_000);
494+
495+
const screen = harness.screenshotTrimmed();
496+
// Synchronized update sequences (CSI ?2026h / CSI ?2026l) should be
497+
// consumed by xterm, not rendered as visible text on screen
498+
expect(screen).not.toContain('?2026h');
499+
expect(screen).not.toContain('?2026l');
500+
expect(screen).toContain(canary);
501+
},
502+
60_000,
503+
);
504+
505+
it(
506+
'PTY resize — Pi re-renders for new dimensions',
507+
async ({ skip }) => {
508+
if (sandboxSkip) skip();
509+
510+
mockServer.reset([{ type: 'text', text: 'resize test' }]);
511+
harness = createPiHarness();
512+
513+
await harness.waitFor('claude-sonnet', 1, 30_000);
514+
515+
const screenBefore = harness.screenshotTrimmed();
516+
517+
// Resize PTY to wider terminal and resize xterm to match
518+
harness.shell.resize(120, 40);
519+
harness.term.resize(120, 40);
520+
521+
// Wait for Pi to process SIGWINCH and re-render
522+
await new Promise((r) => setTimeout(r, 1_000));
523+
524+
const screenAfter = harness.screenshotTrimmed();
525+
// Pi should still show its UI elements after resize
526+
expect(screenAfter).toContain('claude-sonnet');
527+
// Screen should differ from before (re-rendered at new width)
528+
// or at minimum still be a valid TUI (not blank/garbled)
529+
expect(screenAfter.length).toBeGreaterThan(0);
530+
},
531+
45_000,
532+
);
533+
447534
it(
448535
'exit cleanly — ^D on empty editor, Pi exits and PTY closes',
449536
async ({ skip }) => {
@@ -472,4 +559,33 @@ describe.skipIf(piSkip)('Pi interactive PTY E2E (sandbox)', () => {
472559
},
473560
45_000,
474561
);
562+
563+
it(
564+
'/exit command — Pi exits cleanly via /exit',
565+
async ({ skip }) => {
566+
if (sandboxSkip) skip();
567+
568+
mockServer.reset([]);
569+
harness = createPiHarness();
570+
571+
await harness.waitFor('claude-sonnet', 1, 30_000);
572+
573+
// Type /exit and submit
574+
await harness.type('/exit\r');
575+
576+
// Wait for process to exit
577+
const exitCode = await Promise.race([
578+
harness.shell.wait(),
579+
new Promise<number>((_, reject) =>
580+
setTimeout(
581+
() => reject(new Error('Pi did not exit within 10s after /exit')),
582+
10_000,
583+
),
584+
),
585+
]);
586+
587+
expect(exitCode).toBe(0);
588+
},
589+
45_000,
590+
);
475591
});

0 commit comments

Comments
 (0)