Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/scripts/mac-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,23 @@ for _ in $(seq 1 30); do
token=$(jq -r .token "$config/runtime.json" 2>/dev/null) && break
done
test -n "$token" || fail "lich never wrote runtime.json"

# The first launch of a release opens the native window (#809). What follows
# checks the web one, so lich is switched back the way Settings does it: the
# choice, then the window. Only once the native window has attached, as it has
# for anyone switching from its Settings: before that, lich has no window of
# it to close.
for _ in $(seq 1 60); do sleep 1; grep -q 'native window attached' "$log" && break; done
grep -q 'native window attached' "$log" || fail "the first launch of a release did not open the native window"
rpc() {
curl -sf -X POST -H 'Content-Type: application/json' -d "$2" \
"http://127.0.0.1:$LICH_LISTEN_PORT/rpc/$1?token=$token" > /dev/null || fail "rpc $1 failed"
}
rpc store.SetSetting '["window.kind", "", "web"]'
rpc system.OpenWindow '[{"kind": "web"}]'
for _ in $(seq 1 15); do sleep 1; pgrep -x lich-native > /dev/null || break; done
pgrep -x lich-native > /dev/null && fail "the native window outlived the switch to the web one"
for _ in $(seq 1 30); do sleep 1; grep -q 'the bundled window' "$log" && break; done
grep -q 'the bundled window' "$log" || fail "lich did not resolve its own window"

for _ in $(seq 1 30); do
Expand Down
14 changes: 14 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,20 @@ jobs:
token=$(jq -r .token "$APPDATA/lich/runtime.json" 2>/dev/null) && break
done
test -n "$token" || { echo "::error::lich never wrote runtime.json"; cat "$APPDATA/lich/lich.log"; exit 1; }
# The first launch of a release opens the native window (#809); on
# the runner it exits before it attaches and lich falls back to the
# web one. What follows checks the web window, so lich is switched
# back the way Settings does it, and later launches open the web one,
# once the native window has attached or exited: before either, lich
# has no window of it to close.
for _ in $(seq 1 60); do sleep 1; grep -q 'native window attached\|native window exited' "$APPDATA/lich/lich.log" && break; done
grep -q 'native window attached\|native window exited' "$APPDATA/lich/lich.log" || { echo "::error::the first launch of a release did not start the native window"; cat "$APPDATA/lich/lich.log"; exit 1; }
for args in 'store.SetSetting ["window.kind", "", "web"]' 'system.OpenWindow [{"kind": "web"}]'; do
curl -sf -X POST -H 'Content-Type: application/json' -d "${args#* }" \
"http://127.0.0.1:$LICH_LISTEN_PORT/rpc/${args%% *}?token=$token" > /dev/null \
|| { echo "::error::rpc ${args%% *} failed"; cat "$APPDATA/lich/lich.log"; exit 1; }
done
for _ in $(seq 1 30); do sleep 1; grep -q 'the bundled window' "$APPDATA/lich/lich.log" && break; done
grep -q 'the bundled window' "$APPDATA/lich/lich.log" || { echo "::error::lich did not resolve its own window"; cat "$APPDATA/lich/lich.log"; exit 1; }
for _ in $(seq 1 30); do
sleep 1
Expand Down
2 changes: 2 additions & 0 deletions internal/system/native.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package system
import (
"errors"
"fmt"
"log/slog"
"sync"

"github.com/omartelo/lich/internal/restart"
Expand Down Expand Up @@ -92,6 +93,7 @@ func (s *Service) AttachNative(opts AttachNativeOptions) {
defer s.native.mu.Unlock()
s.native.executable = opts.Executable
s.native.active, s.native.attached = true, true
slog.Info("native window attached", "executable", opts.Executable)
}

// OpenWindowOptions is one OpenWindow call.
Expand Down
1 change: 0 additions & 1 deletion internal/terminal/account_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import (
// reads back is the environment the provider actually runs with — not the one
// lich spawned the wrapper with.
func TestSessionAccountReadsWhatTheWrapperExecsInto(t *testing.T) {
t.Parallel()
dir := t.TempDir()
bin := filepath.Join(dir, "claude-work.sh")
script := "#!/bin/sh\nexport CLAUDE_CONFIG_DIR=" + dir + "\nexec sleep 30\n"
Expand Down
1 change: 0 additions & 1 deletion internal/terminal/exit_watch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
// (relay.SessionExited). A close is not: it reaches the relay through the
// close watcher instead.
func TestAReapedSessionIsReportedToTheExitWatcher(t *testing.T) {
t.Parallel()
hub, _ := newProbeHub(t)
svc := New(stubBins{bin: exitBin(t, 0)}, nil, hub)
var mu sync.Mutex
Expand Down
5 changes: 0 additions & 5 deletions internal/terminal/respawn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ func respawn(t *testing.T, svc *Service, id string) *session {
// so one landing on a session respawned under the same id lies about a live
// shell.
func TestRespawnedSessionIsNotToldItExited(t *testing.T) {
t.Parallel()
hub, rec := newProbeHub(t)
svc := New(stubBins{bin: echoBin(t)}, nil, hub)
t.Cleanup(func() { _ = svc.Close("s1") })
Expand All @@ -79,7 +78,6 @@ func TestRespawnedSessionIsNotToldItExited(t *testing.T) {
// evicts only its own PTY, so after Close+Start under one id that id still
// resolves to the new PTY and input still reaches it.
func TestRespawnedSessionKeepsItsPTY(t *testing.T) {
t.Parallel()
svc := New(stubBins{bin: echoBin(t)}, nil, events.New())
t.Cleanup(func() { _ = svc.Close("s1") })

Expand Down Expand Up @@ -107,7 +105,6 @@ func TestRespawnedSessionKeepsItsPTY(t *testing.T) {
// The card reads it as the reason the session ended, and only a real spawn shows
// whether the number survives the trip.
func TestExitEventCarriesTheExitCode(t *testing.T) {
t.Parallel()
hub, rec := newProbeHub(t)
svc := New(stubBins{bin: exitBin(t, 3)}, nil, hub)
t.Cleanup(func() { _ = svc.Close("s1") })
Expand Down Expand Up @@ -136,7 +133,6 @@ func TestExitEventCarriesTheExitCode(t *testing.T) {
// the path that produces it: a child killed by a signal left no exit status, so
// the event has to say nothing rather than report the 0 of a clean exit.
func TestExitEventOmitsAnUnobservedStatus(t *testing.T) {
t.Parallel()
hub, rec := newProbeHub(t)
// echoBin execs cat, so the pid below is the process holding the PTY —
// a script that forked one would leave the slave open and never reach EOF.
Expand Down Expand Up @@ -195,7 +191,6 @@ func replayOf(t *testing.T, svc *Service, id string) string {
// under it, takes the turn with it: the count that keeps the machine awake
// must fall on the reap, not wait for someone to close the card.
func TestReapForgetsOpenTurn(t *testing.T) {
t.Parallel()
svc := New(stubBins{bin: echoBin(t)}, nil, events.New())
var open atomic.Int64
svc.turns.onOpen = func(n int) { open.Store(int64(n)) }
Expand Down
1 change: 0 additions & 1 deletion internal/terminal/spawn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,6 @@ func TestStartReportsAPtyThatCannotSpawn(t *testing.T) {
// the effective directory is what the card shows and what the cwd poller
// degrades back to.
func TestASpawnWithNoDirectoryStartsAtHome(t *testing.T) {
t.Parallel()
svc := New(stubBins{bin: stayAliveBin(t)}, nil, events.New())
t.Cleanup(func() { _ = svc.Close("s1") })

Expand Down
3 changes: 0 additions & 3 deletions internal/terminal/start_ultracode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
// argv ahead of --mcp-config, and that a resume carries it too: Claude Code
// forgets ultracode on --resume, unlike the model and effort.
func TestStartPassesUltracodeToTheProcess(t *testing.T) {
t.Parallel()
tests := []struct {
name string
store stubBins
Expand Down Expand Up @@ -53,7 +52,6 @@ func TestStartPassesUltracodeToTheProcess(t *testing.T) {
// spawns with it: the fork's row is a fresh insert, and Claude Code drops
// ultracode on --resume --fork-session as on a plain resume.
func TestStartCarriesUltracodeOntoAFork(t *testing.T) {
t.Parallel()
bin := stayAliveBin(t)
store := stubBins{
bin: bin,
Expand All @@ -80,7 +78,6 @@ func TestStartCarriesUltracodeOntoAFork(t *testing.T) {
}

func TestStartLeavesUltracodeOffByDefault(t *testing.T) {
t.Parallel()
bin := stayAliveBin(t)
svc := New(stubBins{bin: bin}, nil, events.New())
t.Cleanup(func() { _ = svc.Close("s1") })
Expand Down
1 change: 0 additions & 1 deletion internal/terminal/startgate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,6 @@ func TestHoldForStartIgnoresASessionThatWasReplaced(t *testing.T) {
// Through the real spawn: the session settles, stays held, and is released by
// the provider's report coming through the same path the hook's POST takes.
func TestStartHoldsAProviderSessionUntilItsReport(t *testing.T) {
t.Parallel()
bin := stayAliveBin(t)
svc := New(stubBins{bin: bin}, nil, events.New())
svc.SetStartReports(func(string) bool { return true })
Expand Down
15 changes: 0 additions & 15 deletions internal/terminal/terminal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,6 @@ func spawnedArgs(t *testing.T, svc *Service, id string) []string {
// TestStartPassesResumeToTheProcess proves the resume id reaches the spawned
// binary's argv — the wiring resumeArgs' unit test cannot see.
func TestStartPassesResumeToTheProcess(t *testing.T) {
t.Parallel()
bin := stayAliveBin(t)
svc := New(stubBins{bin: bin}, nil, events.New())
t.Cleanup(func() { _ = svc.Close("s1") })
Expand All @@ -593,7 +592,6 @@ func TestStartPassesResumeToTheProcess(t *testing.T) {
// parent's id — and that the fork is *named*, which is the one place a spawn
// carrying a resume id still gets --name (nameArgs).
func TestStartForksIntoItsOwnConversation(t *testing.T) {
t.Parallel()
bin := stayAliveBin(t)
svc := New(stubBins{bin: bin}, nil, events.New())
t.Cleanup(func() { _ = svc.Close("s1") })
Expand All @@ -619,7 +617,6 @@ func TestStartForksIntoItsOwnConversation(t *testing.T) {
// otherwise reopen the parent's own conversation under a second card, which
// is the one outcome a fork promises against. Nothing is spawned.
func TestStartRefusesAForkTheProviderCannotSpell(t *testing.T) {
t.Parallel()
bin := stayAliveBin(t)
svc := New(stubBins{bin: bin}, nil, events.New())
t.Cleanup(func() { _ = svc.Close("s1") })
Expand All @@ -640,7 +637,6 @@ func TestStartRefusesAForkTheProviderCannotSpell(t *testing.T) {
// it is not a fork — without one resumeArgs would start fresh and the card
// would claim a conversation it never copied.
func TestStartRefusesAForkWithNothingToBranch(t *testing.T) {
t.Parallel()
bin := stayAliveBin(t)
svc := New(stubBins{bin: bin}, nil, events.New())
t.Cleanup(func() { _ = svc.Close("s1") })
Expand All @@ -656,7 +652,6 @@ func TestStartRefusesAForkWithNothingToBranch(t *testing.T) {
// TestStartWithoutResumeSpawnsBare proves a session with no id to resume spawns
// the binary alone, with no dangling flag.
func TestStartWithoutResumeSpawnsBare(t *testing.T) {
t.Parallel()
bin := stayAliveBin(t)
svc := New(stubBins{bin: bin}, nil, events.New())
t.Cleanup(func() { _ = svc.Close("s1") })
Expand All @@ -681,7 +676,6 @@ func TestStartWithoutResumeSpawnsBare(t *testing.T) {
// goes through spawnPins, which drops the briefing before comparing — so
// nothing else here would notice it being lost between the two.
func TestStartCarriesTheBriefingIntoTheSpawn(t *testing.T) {
t.Parallel()
bin := stayAliveBin(t)
svc := New(stubBins{bin: bin}, nil, events.New())
t.Cleanup(func() { _ = svc.Close("s1") })
Expand All @@ -707,7 +701,6 @@ func TestStartCarriesTheBriefingIntoTheSpawn(t *testing.T) {
// through sh -c with the project's script ahead of the exec'd provider — the
// wiring wrapSetup's unit test cannot see.
func TestStartWithSetupWrapsTheSpawn(t *testing.T) {
t.Parallel()
bin := stayAliveBin(t)
svc := New(stubBins{bin: bin, projectPath: projectWithSetup(t, "echo setup-ran")}, nil, events.New())
t.Cleanup(func() { _ = svc.Close("s1") })
Expand All @@ -733,7 +726,6 @@ func TestStartWithSetupWrapsTheSpawn(t *testing.T) {
// TestStartWithSetupButNoScriptSpawnsBare proves the flag is inert for a
// project with no setup script configured — no sh indirection sneaks in.
func TestStartWithSetupButNoScriptSpawnsBare(t *testing.T) {
t.Parallel()
bin := stayAliveBin(t)
svc := New(stubBins{bin: bin}, nil, events.New())
t.Cleanup(func() { _ = svc.Close("s1") })
Expand Down Expand Up @@ -1015,7 +1007,6 @@ func TestSkipPermissionArgs(t *testing.T) {
// see, and the one that decides whether a user who ticked the box actually gets
// an agent that stops asking.
func TestStartPassesSkipPermissionsToTheProcess(t *testing.T) {
t.Parallel()
bin := stayAliveBin(t)
svc := New(stubBins{bin: bin, skipPerms: true}, nil, events.New())
t.Cleanup(func() { _ = svc.Close("s1") })
Expand Down Expand Up @@ -1205,7 +1196,6 @@ func TestSupportsEffort(t *testing.T) {
// TestStartPassesTheStoredEffortToTheProcess proves the effort on the row reaches
// argv, read from the store like the model so a respawn keeps it.
func TestStartPassesTheStoredEffortToTheProcess(t *testing.T) {
t.Parallel()
bin := stayAliveBin(t)
svc := New(stubBins{bin: bin, model: "opus", effort: "high"}, nil, events.New())
t.Cleanup(func() { _ = svc.Close("s1") })
Expand All @@ -1226,7 +1216,6 @@ func TestStartPassesTheStoredEffortToTheProcess(t *testing.T) {
// passed to Start precisely so it survives a respawn, and this is the wiring
// that carries it there.
func TestStartPassesTheStoredModelToTheProcess(t *testing.T) {
t.Parallel()
bin := stayAliveBin(t)
svc := New(stubBins{bin: bin, model: "opus"}, nil, events.New())
t.Cleanup(func() { _ = svc.Close("s1") })
Expand Down Expand Up @@ -1763,7 +1752,6 @@ func TestResizeRecordsTheWindowsSize(t *testing.T) {
// TestStartWithoutASizeCopiesTheWindow drives the whole path: a card the window
// measured, then a session opened with no terminal of its own.
func TestStartWithoutASizeCopiesTheWindow(t *testing.T) {
t.Parallel()
bin := stayAliveBin(t)
svc := New(stubBins{bin: bin}, nil, events.New())
t.Cleanup(func() { _ = svc.Close("s1"); _ = svc.Close("s2") })
Expand All @@ -1787,7 +1775,6 @@ func TestStartWithoutASizeCopiesTheWindow(t *testing.T) {
// the project's setup script is live but has nothing on the other end that can
// read a message, until the wrapper's marker comes through its own output.
func TestReadyWaitsForTheSetupScript(t *testing.T) {
t.Parallel()
bin := stayAliveBin(t)
svc := New(stubBins{bin: bin, projectPath: projectWithSetup(t, "echo installing")}, nil, events.New())
t.Cleanup(func() { _ = svc.Close("s1") })
Expand Down Expand Up @@ -1916,7 +1903,6 @@ func TestReadyRemembersAQuietThatAlreadyPassed(t *testing.T) {
}

func TestReadyWaitsOnlyTheSettleWithoutASetupScript(t *testing.T) {
t.Parallel()
bin := stayAliveBin(t)
svc := New(stubBins{bin: bin}, nil, events.New())
t.Cleanup(func() { _ = svc.Close("s1") })
Expand Down Expand Up @@ -1979,7 +1965,6 @@ func TestSetEnvMovesTheSpawnBase(t *testing.T) {
// TestLiveCountCountsTheSessionsWithAProcess is what the tray reads: a session
// counts while its process runs and stops counting once it is closed.
func TestLiveCountCountsTheSessionsWithAProcess(t *testing.T) {
t.Parallel()
bin := stayAliveBin(t)
svc := New(stubBins{bin: bin}, nil, events.New())
t.Cleanup(func() { _ = svc.Close("s1"); _ = svc.Close("s2") })
Expand Down
1 change: 0 additions & 1 deletion internal/terminal/transport_restart_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,6 @@ func TestListenBailsOutFastOnUnretryableError(t *testing.T) {
}

func TestListenRetriesUntilPortFreesWithoutWaitMarker(t *testing.T) {
t.Parallel()
busy, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("occupy port: %v", err)
Expand Down
4 changes: 0 additions & 4 deletions internal/terminal/usage_cost_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -797,7 +797,6 @@ func TestAPricedSessionCarriesNoMissReason(t *testing.T) {
// about to count that stretch a second time, and what it had cost the first time
// is what has to come back off.
func TestAForkRecordsWhatItsParentHadCost(t *testing.T) {
t.Parallel()
store := newCostStore("")
store.bin = stayAliveBin(t)
store.forkOffsets = map[string]float64{}
Expand All @@ -819,7 +818,6 @@ func TestAForkRecordsWhatItsParentHadCost(t *testing.T) {
// own figure and starts a forked session's at zero, so netting anything off it
// would hide what the fork itself spent.
func TestAForkOfAProviderThatPricesItselfOffsetsNothing(t *testing.T) {
t.Parallel()
store := newCostStore("")
store.bin = stayAliveBin(t)
store.forkOffsets = map[string]float64{}
Expand All @@ -842,7 +840,6 @@ func TestAForkOfAProviderThatPricesItselfOffsetsNothing(t *testing.T) {
// that only resumes must record no offset — one that did would bill the session
// backwards for its own history.
func TestAResumeIsNotAFork(t *testing.T) {
t.Parallel()
store := newCostStore("")
store.bin = stayAliveBin(t)
store.forkOffsets = map[string]float64{}
Expand All @@ -864,7 +861,6 @@ func TestAResumeIsNotAFork(t *testing.T) {
// session, and a readout that is too high is not worth losing it, so the
// failure is logged and the spawn goes ahead with no offset recorded.
func TestAForkWhoseOffsetCannotBeSavedStillStarts(t *testing.T) {
t.Parallel()
store := newCostStore("")
store.bin = stayAliveBin(t)
store.forkOffsets = map[string]float64{}
Expand Down
Loading