fix(cli): terminal 출력 테스트를 안정화한다

This commit is contained in:
toki 2026-07-08 11:45:26 +09:00
parent baa1c0a890
commit 28f68a6880
6 changed files with 145 additions and 27 deletions

View file

@ -1068,7 +1068,7 @@ echo '{"id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{}}}'
Command: scriptPath,
Args: []string{},
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
proc, err := startCodexAppServerProc(ctx, profile, workspace)
@ -1078,7 +1078,7 @@ echo '{"id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{}}}'
defer proc.close()
markerPath := filepath.Join(workspace, "cwd.txt")
cwdBytes := readFileEventually(t, markerPath, 2*time.Second)
cwdBytes := readFileEventually(t, markerPath, 5*time.Second)
cwd := strings.TrimSpace(string(cwdBytes))
resolvedWorkspace, err := os.Readlink(workspace)

View file

@ -28,7 +28,7 @@ func TestCLIStartPersistentReturnsErrorWhenProcessExitsDuringStartup(t *testing.
Args: []string{"-c", "exit 2"},
Persistent: true,
Terminal: true,
StartupIdleTimeoutMS: 50,
StartupIdleTimeoutMS: 200,
},
},
}
@ -58,7 +58,7 @@ func TestCLIStartCleansUpStartedProfilesWhenLaterProfileFails(t *testing.T) {
Args: []string{"-c", "exit 2"},
Persistent: true,
Terminal: true,
StartupIdleTimeoutMS: 50,
StartupIdleTimeoutMS: 200,
},
},
}
@ -106,7 +106,7 @@ func TestCLIStartDeterministicOrder(t *testing.T) {
Args: []string{"-c", "exit 2"},
Persistent: true,
Terminal: true,
StartupIdleTimeoutMS: 50,
StartupIdleTimeoutMS: 200,
},
},
}

View file

@ -183,17 +183,19 @@ func (e *persistentExecutor) Execute(ctx context.Context, spec runtime.Execution
Timestamp: time.Now(),
})
}
if idleTimer == nil {
idleTimer = time.NewTimer(idleTimeout)
idleC = idleTimer.C
} else {
if !idleTimer.Stop() {
select {
case <-idleTimer.C:
default:
if waitForFilteredMessage || outputFilter.HasOutput() || !profile.Terminal {
if idleTimer == nil {
idleTimer = time.NewTimer(idleTimeout)
idleC = idleTimer.C
} else {
if !idleTimer.Stop() {
select {
case <-idleTimer.C:
default:
}
}
idleTimer.Reset(idleTimeout)
}
idleTimer.Reset(idleTimeout)
}
case <-idleC:
if delta := outputFilter.Flush(); delta != "" {

View file

@ -251,7 +251,7 @@ func TestCLIExecutePersistentTerminalAcceptsClaudeBypassWarning(t *testing.T) {
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 100,
StartupIdleTimeoutMS: 50,
StartupIdleTimeoutMS: 250,
},
},
}
@ -263,7 +263,7 @@ func TestCLIExecutePersistentTerminalAcceptsClaudeBypassWarning(t *testing.T) {
}
defer func() { _ = c.Stop(ctx) }()
execCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
execCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
sink := &testutil.FakeSink{}
@ -297,7 +297,7 @@ func TestCLIExecutePersistentTerminalAcceptsClaudeWorkspaceTrustAndBypassWarning
Persistent: true,
Terminal: true,
ResponseIdleTimeoutMS: 100,
StartupIdleTimeoutMS: 50,
StartupIdleTimeoutMS: 250,
},
},
}
@ -309,7 +309,7 @@ func TestCLIExecutePersistentTerminalAcceptsClaudeWorkspaceTrustAndBypassWarning
}
defer func() { _ = c.Stop(ctx) }()
execCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
execCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
sink := &testutil.FakeSink{}
@ -355,7 +355,7 @@ func TestCLIExecutePersistentClaudeTUICancelsSessionLimitUpgradePrompt(t *testin
}
defer func() { _ = c.Stop(ctx) }()
execCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
execCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
sink := &testutil.FakeSink{}
@ -409,7 +409,7 @@ func TestCLIExecutePersistentClaudeTUIReplaysPromptAfterStartupReadyRace(t *test
}
defer func() { _ = c.Stop(ctx) }()
execCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
execCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
sink := &testutil.FakeSink{}

View file

@ -17,21 +17,26 @@ type persistentOutputFilter interface {
CompletionMessage() string
}
type passthroughOutputFilter struct{}
type passthroughOutputFilter struct {
emitted bool
}
func (passthroughOutputFilter) Filter(chunk string) string {
func (f *passthroughOutputFilter) Filter(chunk string) string {
if chunk != "" {
f.emitted = true
}
return chunk
}
func (passthroughOutputFilter) Flush() string {
func (f *passthroughOutputFilter) Flush() string {
return ""
}
func (passthroughOutputFilter) HasOutput() bool {
return false
func (f *passthroughOutputFilter) HasOutput() bool {
return f.emitted
}
func (passthroughOutputFilter) CompletionMessage() string {
func (f *passthroughOutputFilter) CompletionMessage() string {
return ""
}
@ -39,7 +44,10 @@ func newPersistentOutputFilter(target string, profile config.CLIProfileConf, pro
if profile.Terminal && isClaudeTerminalProfile(target, profile) {
return newClaudeTUIOutputFilter(prompt, baselineAssistant)
}
return passthroughOutputFilter{}
if profile.Terminal {
return newTerminalPassthroughOutputFilter(prompt)
}
return &passthroughOutputFilter{}
}
func isClaudeTerminalProfile(target string, profile config.CLIProfileConf) bool {
@ -57,6 +65,80 @@ type claudeTUIOutputFilter struct {
seenAssistant bool
}
type terminalPassthroughOutputFilter struct {
prompt string
promptBuf strings.Builder
promptDone bool
suppressLineEnd bool
emitted bool
}
func newTerminalPassthroughOutputFilter(prompt string) *terminalPassthroughOutputFilter {
return &terminalPassthroughOutputFilter{prompt: strings.TrimRight(prompt, "\r\n")}
}
func (f *terminalPassthroughOutputFilter) Filter(chunk string) string {
if chunk == "" {
return ""
}
if !f.promptDone && f.prompt != "" {
f.promptBuf.WriteString(chunk)
raw := f.promptBuf.String()
if strings.HasPrefix(f.prompt, raw) {
if raw == f.prompt {
f.promptDone = true
f.suppressLineEnd = true
f.promptBuf.Reset()
}
return ""
}
if strings.HasPrefix(raw, f.prompt) {
rest := raw[len(f.prompt):]
if rest == "" {
f.promptDone = true
f.suppressLineEnd = true
f.promptBuf.Reset()
return ""
}
if strings.HasPrefix(rest, "\r") || strings.HasPrefix(rest, "\n") {
f.promptDone = true
f.promptBuf.Reset()
return f.emit(strings.TrimLeft(rest, "\r\n"))
}
}
f.promptDone = true
f.promptBuf.Reset()
return f.emit(raw)
}
if f.suppressLineEnd {
f.suppressLineEnd = false
chunk = strings.TrimLeft(chunk, "\r\n")
if chunk == "" {
return ""
}
}
return f.emit(chunk)
}
func (f *terminalPassthroughOutputFilter) Flush() string {
return ""
}
func (f *terminalPassthroughOutputFilter) HasOutput() bool {
return f.emitted
}
func (f *terminalPassthroughOutputFilter) CompletionMessage() string {
return ""
}
func (f *terminalPassthroughOutputFilter) emit(chunk string) string {
if chunk != "" {
f.emitted = true
}
return chunk
}
type claudeMessageSnapshot struct {
text string
closed bool

View file

@ -84,6 +84,40 @@ func TestPassthroughOutputFilterKeepsNonClaudeTerminalOutput(t *testing.T) {
}
}
func TestTerminalPassthroughOutputFilterSuppressesPromptEcho(t *testing.T) {
filter := newPersistentOutputFilter("raw-terminal", testProfile("sh", true), "hello", "")
if got := filter.Filter("he"); got != "" {
t.Fatalf("first prompt echo Filter() = %q, want empty", got)
}
if filter.HasOutput() {
t.Fatalf("HasOutput() = true while only prompt echo was seen")
}
if got := filter.Filter("llo\r\n"); got != "" {
t.Fatalf("completed prompt echo Filter() = %q, want empty", got)
}
if filter.HasOutput() {
t.Fatalf("HasOutput() = true after only prompt echo was seen")
}
got := filter.Filter("reply:hello\r\n")
if got != "reply:hello\r\n" {
t.Fatalf("reply Filter() = %q, want reply", got)
}
if !filter.HasOutput() {
t.Fatalf("HasOutput() = false after reply")
}
}
func TestTerminalPassthroughOutputFilterSuppressesPromptEchoBeforeReplyInSameChunk(t *testing.T) {
filter := newPersistentOutputFilter("raw-terminal", testProfile("sh", true), "hello", "")
got := filter.Filter("hello\r\nreply:hello\r\n")
if got != "reply:hello\r\n" {
t.Fatalf("Filter() = %q, want reply without echoed prompt", got)
}
}
func TestClaudeTUIOutputFilterSuppressesStartupNoiseBeforeAssistantMarker(t *testing.T) {
filter := newClaudeTUIOutputFilter("hello")