Files
zero/internal/tui/binding_test.go
PierrunoYTandCopilot Autofix powered by AI 008bc9b3f3 fix: address bugs found in a multi-agent codebase audit (#481)
* fix: address bugs found in a multi-agent codebase audit

Five parallel review agents each swept a different area (self-update,
TUI keybindings, agent/session handling, sandbox/shell safety, and
auth/credential storage) and surfaced nine concrete defects, all fixed
here with regression tests:

- sandbox: grant_scope path comparisons were case-sensitive, letting a
  persisted deny grant be bypassed by spelling a Windows path with
  different case
- securefile/oauth: createSecretFile's Windows ERROR_ACCESS_DENIED
  handling was only applied to the oauth token store, not the shared
  credstore backend; both also masked the real lock-contention error
  behind a misleading "file does not exist" timeout
- cli: firstUsableProvider ignored OAuth logins when picking a
  fallback provider, forcing unnecessary re-onboarding for an
  already-authenticated user
- sessions: exec prompt summarization truncated on a raw byte offset,
  which could split a multi-byte UTF-8 rune and embed invalid UTF-8
  into a resumed session's prompt
- tui: configurable keybindings had no collision detection, so a
  remapped chord could silently make a hardcoded shortcut (or another
  configured binding) permanently unreachable
- tools: the Windows shell-syntax pre-flight check wasn't quote-aware
  and could block harmless commands whose quoted arguments merely
  contained operator-like text
- update: checksum verification didn't cross-check the archive
  filename it verified against the one being extracted; extractZip
  didn't reject symlink-mode entries like extractTarGz already did;
  replaceBinary's restore-on-failure rename had no retry, risking a
  permanently missing binary on a transient Windows file lock

* fix: close remaining PR 481 review findings

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* fix(tools): drop stale ; test case after rebasing onto main's MSYS check

Rebasing fix/bug-hunt-findings onto main pulled in #476, which replaced
this file's regex-based windows_shell_syntax detection with a
segment/word-anchored check and deliberately stopped treating ; as a
command separator on Windows (cmd.exe doesn't split on ; the way bash
does). detectShellCommandIssue itself came out of the rebase unchanged
from main (our quote-masking version was fully superseded by #476's
more thorough segment-based approach), but the quoted-content test
this PR added still asserted the old bash-style semantics for
`echo "foo" ; ls -la`, which now correctly returns no issue since it's
a single echo invocation with literal arguments under cmd.exe, not a
real ls invocation.

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-07-06 18:48:28 +08:00

387 lines
12 KiB
Go

package tui
import (
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/config"
)
func TestParseBindingLabel(t *testing.T) {
tests := []struct {
input string
want string // expected Label()
}{
{"option+o", "Alt+O"},
{"ctrl+o", "Ctrl+O"},
{"ctrl+e", "Ctrl+E"},
{"option+b", "Alt+B"},
{"option+O", "Alt+O"},
{"alt+o", "Alt+O"},
{"cmd+o", "Cmd+O"},
{"super+o", "Cmd+O"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
p := parseBinding(tt.input)
got := p.Label()
if got != tt.want {
t.Errorf("parseBinding(%q).Label() = %q, want %q", tt.input, got, tt.want)
}
if p.isZero() {
t.Errorf("parseBinding(%q).isZero() = true, want false", tt.input)
}
})
}
}
func TestOptionOMatchesOptionO(t *testing.T) {
p := parseBinding("option+o")
matcher := p.Matcher()
// Simulate iTerm2 with "Option as Meta" pressing Option+O
// Terminal sends ESC o → KeyPressEvent{Code: 'o', Mod: ModAlt}
msg := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt})
if !matcher(msg) {
t.Errorf("option+o matcher should match {Code:'o', Mod:ModAlt}")
}
// Should NOT match plain 'o'
msg2 := tea.KeyPressMsg(tea.Key{Code: 'o'})
if matcher(msg2) {
t.Errorf("option+o matcher should NOT match {Code:'o'} without ModAlt")
}
// Should NOT match ctrl+o
msg3 := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})
if matcher(msg3) {
t.Errorf("option+o matcher should NOT match {Code:'o', Mod:ModCtrl}")
}
}
func TestOptionOMatchesComposedCharacters(t *testing.T) {
p := parseBinding("option+o")
matcher := p.Matcher()
// On macOS without "Option as Meta", Option+O produces ø (U+00F8)
msg := tea.KeyPressMsg(tea.Key{Code: 0xF8, Text: "ø"})
if matcher(msg) {
t.Logf("NOTE: option+o matcher also matches option+ø (macOS compose behavior)")
}
}
func TestCtrlEMatchesDefault(t *testing.T) {
p := parseBinding("ctrl+e")
matcher := p.Matcher()
// Terminal sends Ctrl+E → byte 0x05 → KeyPressEvent{Code: 'e', Mod: ModCtrl}
msg := tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl})
if !matcher(msg) {
t.Errorf("ctrl+e matcher should match {Code:'e', Mod:ModCtrl}")
}
}
func TestCtrlBindingRejectsSupersetModifiers(t *testing.T) {
// Configured ctrl+e must NOT match ctrl+alt+e or ctrl+shift+e
// (Mod.Contains superset correctness for the ctrl-letter fast path).
p := parseBinding("ctrl+e")
matcher := p.Matcher()
bad := []struct {
name string
msg tea.KeyMsg
}{
{"ctrl+alt+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModAlt})},
{"ctrl+shift+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModShift})},
{"ctrl+alt+shift+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModAlt | tea.ModShift})},
{"ctrl+cmd+e", tea.KeyPressMsg(tea.Key{Code: 'e', Mod: tea.ModCtrl | tea.ModSuper})},
}
for _, tt := range bad {
t.Run(tt.name, func(t *testing.T) {
if matcher(tt.msg) {
t.Errorf("ctrl+e matcher should NOT match %s", tt.name)
}
})
}
}
func TestAltBindingRejectsSupersetModifiers(t *testing.T) {
// Configured alt+o must NOT match alt+shift+o (code path, exact mod).
p := parseBinding("alt+o")
matcher := p.Matcher()
bad := []struct {
name string
msg tea.KeyMsg
}{
{"alt+shift+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt | tea.ModShift})},
{"alt+ctrl+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt | tea.ModCtrl})},
{"alt+cmd+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt | tea.ModSuper})},
}
for _, tt := range bad {
t.Run(tt.name, func(t *testing.T) {
if matcher(tt.msg) {
t.Errorf("alt+o matcher should NOT match %s", tt.name)
}
})
}
}
func TestPlainKeyBindingRejectsModifiers(t *testing.T) {
// A configured plain-key binding (no modifiers, e.g. "o") should only
// match when Mod is exactly 0, not when a modifier is held.
p := parseBinding("o")
matcher := p.Matcher()
plain := tea.KeyPressMsg(tea.Key{Code: 'o'})
if !matcher(plain) {
t.Errorf("'o' matcher should match {Code:'o'} with no modifiers")
}
bad := []struct {
name string
msg tea.KeyMsg
}{
{"ctrl+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})},
{"alt+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt})},
{"shift+o", tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModShift})},
}
for _, tt := range bad {
t.Run(tt.name, func(t *testing.T) {
if matcher(tt.msg) {
t.Errorf("'o' matcher should NOT match %s", tt.name)
}
})
}
}
func TestParseBindingUTF8Character(t *testing.T) {
// A 2-byte UTF-8 key like é (U+00E9) should parse as a single character,
// not be rejected by a byte-length check.
p := parseBinding("é")
if p.isZero() {
t.Errorf("parseBinding('é') should not be zero — it's a valid single character")
}
if p.code != 'é' {
t.Errorf("parseBinding('é').code = %d (U+%04X), want %d (U+00E9)", p.code, p.code, 'é')
}
if p.text != "" {
t.Errorf("parseBinding('é').text = %q, want empty string", p.text)
}
// Also verify the matcher works
matcher := p.Matcher()
msg := tea.KeyPressMsg(tea.Key{Code: 'é', Text: "é"})
if !matcher(msg) {
t.Errorf("'é' matcher should match {Code:'é', Text:'é'}")
}
// Should not match with a modifier
msgAlt := tea.KeyPressMsg(tea.Key{Code: 'é', Text: "é", Mod: tea.ModAlt})
if matcher(msgAlt) {
t.Errorf("'é' matcher should NOT match with modifier")
}
}
func TestDispatchRejectsSupersetModifiers(t *testing.T) {
// Integration: model.keyMatch with a configured binding must reject
// modifier supersets.
cfg := config.KeyBindingsConfig{
ToggleDetailed: "ctrl+o",
}
bindings := resolveKeyBindings(cfg)
defaultFn := func(tea.KeyMsg) bool { return false }
m := model{keyBindings: bindings}
// Ctrl+O should match
msgCtrlO := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})
if !m.keyMatch(bindings.toggleDetailed, msgCtrlO, defaultFn) {
t.Errorf("keyMatch should match ctrl+o when configured")
}
// Ctrl+Alt+O should NOT match
msgCtrlAltO := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl | tea.ModAlt})
if m.keyMatch(bindings.toggleDetailed, msgCtrlAltO, defaultFn) {
t.Errorf("keyMatch should NOT match ctrl+alt+o when ctrl+o is configured")
}
}
func TestConfigToBindingPipeline(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "option+o",
ToggleMouse: "ctrl+e",
CycleReasoning: "ctrl+t",
TogglePlan: "ctrl+p",
ToggleSidebar: "option+b",
}
bindings := resolveKeyBindings(cfg)
tests := []struct {
name string
binding parsedBinding
wantKey string
}{
{"toggleDetailed", bindings.toggleDetailed, "Alt+O"},
{"toggleMouse", bindings.toggleMouse, "Ctrl+E"},
{"cycleReasoning", bindings.cycleReasoning, "Ctrl+T"},
{"togglePlan", bindings.togglePlan, "Ctrl+P"},
{"toggleSidebar", bindings.toggleSidebar, "Alt+B"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.binding.isZero() {
t.Errorf("%s binding is zero (unset) — config not loaded", tt.name)
}
if got := tt.binding.Label(); got != tt.wantKey {
t.Errorf("%s.Label() = %q, want %q", tt.name, got, tt.wantKey)
}
})
}
}
func TestHelpBindingSubstitution(t *testing.T) {
// Verify that the help overlay correctly shows user-configured bindings
// by checking that Label() returns non-empty for config-sourced bindings.
cfg := config.KeyBindingsConfig{
ToggleDetailed: "option+o",
ToggleSidebar: "option+b",
}
bindings := resolveKeyBindings(cfg)
if l := bindings.toggleDetailed.Label(); l != "Alt+O" {
t.Errorf("toggleDetailed.Label() = %q, want %q", l, "Alt+O")
}
if l := bindings.toggleSidebar.Label(); l != "Alt+B" {
t.Errorf("toggleSidebar.Label() = %q, want %q", l, "Alt+B")
}
}
// TestDispatchOptionO is an integration-style test: it verifies that pressing
// Option+O dispatches as toggleDetailed when configured.
func TestDispatchOptionO(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "option+o",
}
bindings := resolveKeyBindings(cfg)
// Simulate Option+O in iTerm2 with "Option as Meta" → ESC o → {Code:'o', Mod:ModAlt}
msg := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModAlt})
defaultFn := func(tea.KeyMsg) bool { return false }
// This is the dispatch check the model uses
m := model{keyBindings: bindings}
if !m.keyMatch(bindings.toggleDetailed, msg, defaultFn) {
t.Errorf("model.keyMatch should match option+o")
}
// Also verify default doesn't match
msgCtrlO := tea.KeyPressMsg(tea.Key{Code: 'o', Mod: tea.ModCtrl})
if m.keyMatch(bindings.toggleDetailed, msgCtrlO, defaultFn) {
t.Errorf("model.keyMatch should NOT match ctrl+o when option+o is configured")
}
}
// A configured binding equal to a reserved hardcoded chord must be reverted
// to default rather than silently making the hardcoded action unreachable
// (e.g. configuring toggleDetailed to ctrl+f would otherwise swallow the
// /model picker's "favorite" shortcut).
func TestSanitizeKeyBindingsDropsReservedCollision(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "ctrl+f", // collides with the hardcoded favorite-model shortcut
ToggleMouse: "ctrl+e", // unaffected, should survive untouched
}
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
if !sanitized.toggleDetailed.isZero() {
t.Errorf("toggleDetailed should be reverted to default, got %q", sanitized.toggleDetailed.Label())
}
if sanitized.toggleMouse.isZero() || sanitized.toggleMouse.Label() != "Ctrl+E" {
t.Errorf("toggleMouse should be untouched, got %q", sanitized.toggleMouse.Label())
}
if len(warnings) != 1 {
t.Fatalf("want exactly 1 warning, got %d: %v", len(warnings), warnings)
}
}
// Two configurable actions bound to the same chord must not both fire —
// sanitizeKeyBindings should keep the first and revert the rest to default.
func TestSanitizeKeyBindingsDropsMutualCollision(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "ctrl+o",
CycleReasoning: "ctrl+o", // collides with toggleDetailed above
}
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
if sanitized.toggleDetailed.isZero() || sanitized.toggleDetailed.Label() != "Ctrl+O" {
t.Errorf("toggleDetailed (first claimant) should keep ctrl+o, got %q", sanitized.toggleDetailed.Label())
}
if !sanitized.cycleReasoning.isZero() {
t.Errorf("cycleReasoning (second claimant) should be reverted to default, got %q", sanitized.cycleReasoning.Label())
}
if len(warnings) != 1 {
t.Fatalf("want exactly 1 warning, got %d: %v", len(warnings), warnings)
}
}
// Non-colliding configured bindings must pass through unchanged with no
// warnings.
func TestSanitizeKeyBindingsNoCollisions(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "option+o",
ToggleSidebar: "option+b",
}
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
if len(warnings) != 0 {
t.Fatalf("want no warnings, got %v", warnings)
}
if sanitized.toggleDetailed.Label() != "Alt+O" || sanitized.toggleSidebar.Label() != "Alt+B" {
t.Fatalf("bindings should be unchanged: toggleDetailed=%q toggleSidebar=%q",
sanitized.toggleDetailed.Label(), sanitized.toggleSidebar.Label())
}
}
// A configured binding must not shadow another action that is still on its
// built-in default chord.
func TestSanitizeKeyBindingsDropsCollisionWithOtherDefault(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "ctrl+t", // collides with cycleReasoning default
}
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
if !sanitized.toggleDetailed.isZero() {
t.Errorf("toggleDetailed should be reverted to default, got %q", sanitized.toggleDetailed.Label())
}
if !sanitized.cycleReasoning.isZero() {
t.Errorf("cycleReasoning should remain default (zero), got %q", sanitized.cycleReasoning.Label())
}
if len(warnings) != 1 {
t.Fatalf("want exactly 1 warning, got %d: %v", len(warnings), warnings)
}
}
// Bare hardcoded keys handled directly in updateModel (e.g. Tab navigation)
// are reserved too and must not be shadowed by configurable bindings.
func TestSanitizeKeyBindingsDropsBareHardcodedCollision(t *testing.T) {
cfg := config.KeyBindingsConfig{
ToggleDetailed: "tab",
}
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
if !sanitized.toggleDetailed.isZero() {
t.Errorf("toggleDetailed should be reverted to default, got %q", sanitized.toggleDetailed.Label())
}
if len(warnings) != 1 {
t.Fatalf("want exactly 1 warning, got %d: %v", len(warnings), warnings)
}
}