detached e695415

refactor(app): require closable script input for shutdown

commit
e69541584a159b6695081d790472fc930572e558
author
brice · · 2026-09-23 16:18 UTC
parent
f192a7a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +6 -9
M internal/app/app.go +4 -7
@@ -30,8 +30,9 @@ type Options struct {
3030 Timeout time.Duration
3131 NoTerminal bool
3232 ScreenshotDir string
33- Script io.Reader
34- testScenes chan<- ports.Scene
33+ // Run closes Script on shutdown; the reader goroutine exits after Close.
34+ Script io.ReadCloser
35+ testScenes chan<- ports.Scene
3536 }
3637
3738 func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
@@ -101,11 +102,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
101102 _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input"))
102103 }()
103104 if opts.Script != nil {
104- // A reader without Close may remain blocked in Scan after shutdown.
105- // It must not be joined with the application workers.
106- if closer, ok := opts.Script.(io.Closer); ok {
107- go func() { <-ctx.Done(); _ = closer.Close() }()
108- }
105+ go func() { <-ctx.Done(); _ = opts.Script.Close() }()
109106 go func() {
110107 defer close(script)
111108 scanner := bufio.NewScanner(opts.Script)
@@ -30,8 +30,9 @@ type Options struct {
30 Timeout time.Duration30 Timeout time.Duration
31 NoTerminal bool31 NoTerminal bool
32 ScreenshotDir string32 ScreenshotDir string
33- Script io.Reader33+ // Run closes Script on shutdown; the reader goroutine exits after Close.
34- testScenes chan<- ports.Scene34+ Script io.ReadCloser
35+ testScenes chan<- ports.Scene
35 }36 }
36 37
37 func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }38 func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
@@ -101,11 +102,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
101 _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input"))102 _ = headlessinput.Run(ctx, km, script, input, logging.For(ctx, "input"))
102 }()103 }()
103 if opts.Script != nil {104 if opts.Script != nil {
104- // A reader without Close may remain blocked in Scan after shutdown.105+ go func() { <-ctx.Done(); _ = opts.Script.Close() }()
105- // It must not be joined with the application workers.
106- if closer, ok := opts.Script.(io.Closer); ok {
107- go func() { <-ctx.Done(); _ = closer.Close() }()
108- }
109 go func() {106 go func() {
110 defer close(script)107 defer close(script)
111 scanner := bufio.NewScanner(opts.Script)108 scanner := bufio.NewScanner(opts.Script)
M internal/app/app_test.go +2 -2
@@ -167,7 +167,7 @@ func TestHeadlessTyping(t *testing.T) {
167167 cfg := config.Defaults()
168168 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
169169 dir := t.TempDir()
170- err := Run(context.Background(), Options{Backend: "headless", Config: cfg, ScreenshotDir: dir, Script: strings.NewReader("sleep 1.5s\ntype echo nefertty-ok\nkey Return\nsleep 1s\n"), Timeout: 6 * time.Second})
170+ err := Run(context.Background(), Options{Backend: "headless", Config: cfg, ScreenshotDir: dir, Script: io.NopCloser(strings.NewReader("sleep 1.5s\ntype echo nefertty-ok\nkey Return\nsleep 1s\n")), Timeout: 6 * time.Second})
171171 if err != nil {
172172 if strings.Contains(err.Error(), "Vulkan") || strings.Contains(err.Error(), "vulkan") {
173173 t.Skipf("Vulkan unavailable: %v", err)
@@ -219,7 +219,7 @@ func TestHeadlessPointerClickFocus(t *testing.T) {
219219 cfg := config.Defaults()
220220 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
221221 scenes := make(chan ports.Scene, 128)
222- err := Run(context.Background(), Options{Backend: "headless", Config: cfg, Script: strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n"), Timeout: 5 * time.Second, testScenes: scenes})
222+ err := Run(context.Background(), Options{Backend: "headless", Config: cfg, Script: io.NopCloser(strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n")), Timeout: 5 * time.Second, testScenes: scenes})
223223 if err != nil {
224224 if strings.Contains(strings.ToLower(err.Error()), "vulkan") {
225225 t.Skipf("Vulkan unavailable: %v", err)
@@ -167,7 +167,7 @@ func TestHeadlessTyping(t *testing.T) {
167 cfg := config.Defaults()167 cfg := config.Defaults()
168 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}168 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
169 dir := t.TempDir()169 dir := t.TempDir()
170- err := Run(context.Background(), Options{Backend: "headless", Config: cfg, ScreenshotDir: dir, Script: strings.NewReader("sleep 1.5s\ntype echo nefertty-ok\nkey Return\nsleep 1s\n"), Timeout: 6 * time.Second})170+ err := Run(context.Background(), Options{Backend: "headless", Config: cfg, ScreenshotDir: dir, Script: io.NopCloser(strings.NewReader("sleep 1.5s\ntype echo nefertty-ok\nkey Return\nsleep 1s\n")), Timeout: 6 * time.Second})
171 if err != nil {171 if err != nil {
172 if strings.Contains(err.Error(), "Vulkan") || strings.Contains(err.Error(), "vulkan") {172 if strings.Contains(err.Error(), "Vulkan") || strings.Contains(err.Error(), "vulkan") {
173 t.Skipf("Vulkan unavailable: %v", err)173 t.Skipf("Vulkan unavailable: %v", err)
@@ -219,7 +219,7 @@ func TestHeadlessPointerClickFocus(t *testing.T) {
219 cfg := config.Defaults()219 cfg := config.Defaults()
220 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}220 cfg.Terminal.Command = []string{"foot", "-c", "/dev/null", "sh"}
221 scenes := make(chan ports.Scene, 128)221 scenes := make(chan ports.Scene, 128)
222- err := Run(context.Background(), Options{Backend: "headless", Config: cfg, Script: strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n"), Timeout: 5 * time.Second, testScenes: scenes})222+ err := Run(context.Background(), Options{Backend: "headless", Config: cfg, Script: io.NopCloser(strings.NewReader("sleep 1s\nkey Super+Return\nsleep 1s\nmove 600 300\nclick\nsleep 500ms\n")), Timeout: 5 * time.Second, testScenes: scenes})
223 if err != nil {223 if err != nil {
224 if strings.Contains(strings.ToLower(err.Error()), "vulkan") {224 if strings.Contains(strings.ToLower(err.Error()), "vulkan") {
225 t.Skipf("Vulkan unavailable: %v", err)225 t.Skipf("Vulkan unavailable: %v", err)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 26

Tags 10

↑↓ moveenter openesc close