@@ -21,12 +21,12 @@ import { tmpdir } from 'node:os';
2121import path from 'node:path' ;
2222import { fileURLToPath } from 'node:url' ;
2323import { 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' ;
3030import {
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 - Z a - 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