detached b35bb20

test(headless): assert screenshot hard links and cover skip and rename failure paths

commit
b35bb200cb13a83c429b471ae9fe7602f1fa1091
author
brice · · 2026-10-22 04:53 UTC
parent
b970441
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +81 -13
M internal/adapters/headless/output_test.go +81 -13
@@ -14,6 +14,7 @@ import (
1414 "strconv"
1515 "strings"
1616 "sync"
17+ "syscall"
1718 "testing"
1819 "time"
1920
@@ -299,6 +300,17 @@ func TestLinkPNGSecure(t *testing.T) {
299300 if !bytes.Equal(a, b) {
300301 t.Fatal("latest.png differs from the frame")
301302 }
303+ fi, err := os.Stat(frame)
304+ if err != nil {
305+ t.Fatal(err)
306+ }
307+ li, err := os.Stat(latest)
308+ if err != nil {
309+ t.Fatal(err)
310+ }
311+ if !os.SameFile(fi, li) {
312+ t.Fatal("latest.png is a copy, not a hard link to the frame")
313+ }
302314 got, err := png.Decode(bytes.NewReader(b))
303315 if err != nil {
304316 t.Fatal(err)
@@ -319,40 +331,73 @@ func TestLinkPNGSecure(t *testing.T) {
319331 }
320332 }
321333
322-// A frame that crossed a security change is not published as latest.png, and
323-// a source that cannot be linked is encoded instead.
334+// A frame that crossed a security change is not published as latest.png, a
335+// failed rename leaves no temporary file, and a source that cannot be linked
336+// is encoded instead.
324337 func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) {
325338 img := image.NewRGBA(image.Rect(0, 0, 1, 1))
326339 good := ports.SecurityState{Generation: 1}
327- t.Run("protected", func(t *testing.T) {
340+ // frameIn writes the frame to link and returns its directory and path.
341+ frameIn := func(t *testing.T) (string, string) {
342+ t.Helper()
328343 dir := t.TempDir()
329344 frame := filepath.Join(dir, "frame-000001.png")
330345 if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil {
331346 t.Fatal(err)
332347 }
348+ return dir, frame
349+ }
350+ // onlyFrame fails unless the frame is all the directory holds: no
351+ // latest.png and no latest.png.tmp.
352+ onlyFrame := func(t *testing.T, dir string) {
353+ t.Helper()
354+ if entries, _ := os.ReadDir(dir); len(entries) != 1 {
355+ t.Fatalf("latest.png or a temp file delivered: %v", entries)
356+ }
357+ }
358+ t.Run("protected state", func(t *testing.T) {
359+ dir, frame := frameIn(t)
360+ // A protected state is refused without reading the live snapshot.
361+ gate := portsmocks.NewMockSessionSecurity(t)
362+ if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, ports.SecurityState{Generation: 1, Protected: true}); err != nil || written {
363+ t.Fatalf("link = %v, %v", written, err)
364+ }
365+ onlyFrame(t, dir)
366+ })
367+ t.Run("protected snapshot", func(t *testing.T) {
368+ dir, frame := frameIn(t)
333369 gate := portsmocks.NewMockSessionSecurity(t)
334370 gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
335371 if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written {
336372 t.Fatalf("link = %v, %v", written, err)
337373 }
338- if entries, _ := os.ReadDir(dir); len(entries) != 1 {
339- t.Fatalf("latest.png or a temp file delivered: %v", entries)
340- }
374+ onlyFrame(t, dir)
341375 })
342376 t.Run("changed after the link", func(t *testing.T) {
343- dir := t.TempDir()
344- frame := filepath.Join(dir, "frame-000001.png")
345- if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil {
346- t.Fatal(err)
347- }
377+ dir, frame := frameIn(t)
348378 gate := portsmocks.NewMockSessionSecurity(t)
349379 gate.EXPECT().Snapshot().Return(good).Once()
350380 gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 2}).Once()
351381 if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written {
352382 t.Fatalf("link = %v, %v", written, err)
353383 }
354- if entries, _ := os.ReadDir(dir); len(entries) != 1 {
355- t.Fatalf("latest.png or a temp file delivered: %v", entries)
384+ onlyFrame(t, dir)
385+ })
386+ t.Run("rename failure", func(t *testing.T) {
387+ dir, frame := frameIn(t)
388+ // A non-empty directory cannot be replaced by a file.
389+ latest := filepath.Join(dir, "latest.png")
390+ if err := os.MkdirAll(filepath.Join(latest, "keep"), 0o700); err != nil {
391+ t.Fatal(err)
392+ }
393+ if written, err := linkPNGSecure(frame, latest, img, nil, ports.SecurityState{}); err == nil || written {
394+ t.Fatalf("link = %v, %v; want an error", written, err)
395+ }
396+ if _, err := os.Stat(latest + ".tmp"); !os.IsNotExist(err) {
397+ t.Fatalf("temporary link left behind: %v", err)
398+ }
399+ if _, err := os.Stat(filepath.Join(latest, "keep")); err != nil {
400+ t.Fatalf("existing directory touched: %v", err)
356401 }
357402 })
358403 t.Run("no source file", func(t *testing.T) {
@@ -361,6 +406,13 @@ func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) {
361406 if written, err := linkPNGSecure(filepath.Join(dir, "missing.png"), latest, img, nil, ports.SecurityState{}); err != nil || !written {
362407 t.Fatalf("link = %v, %v", written, err)
363408 }
409+ fi, err := os.Stat(latest)
410+ if err != nil {
411+ t.Fatal(err)
412+ }
413+ if !fi.Mode().IsRegular() {
414+ t.Fatalf("latest.png is %v", fi.Mode())
415+ }
364416 f, err := os.Open(latest)
365417 if err != nil {
366418 t.Fatal(err)
@@ -369,9 +421,25 @@ func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) {
369421 if _, err := png.Decode(f); err != nil {
370422 t.Fatal(err)
371423 }
424+ // Encoded on its own: nothing else in the directory shares it.
425+ if entries, _ := os.ReadDir(dir); len(entries) != 1 {
426+ t.Fatalf("unexpected files: %v", entries)
427+ }
428+ if n, ok := linkCount(fi); ok && n != 1 {
429+ t.Fatalf("latest.png has %d links, want 1", n)
430+ }
372431 })
373432 }
374433
434+// linkCount returns the hard link count of fi where the platform reports it.
435+func linkCount(fi os.FileInfo) (uint64, bool) {
436+ st, ok := fi.Sys().(*syscall.Stat_t)
437+ if !ok {
438+ return 0, false
439+ }
440+ return uint64(st.Nlink), true
441+}
442+
375443 func TestWritePNGAtomic(t *testing.T) {
376444 dir := t.TempDir()
377445 img := image.NewRGBA(image.Rect(0, 0, 1, 1))
@@ -14,6 +14,7 @@ import (
14 "strconv"14 "strconv"
15 "strings"15 "strings"
16 "sync"16 "sync"
17+ "syscall"
17 "testing"18 "testing"
18 "time"19 "time"
19 20
@@ -299,6 +300,17 @@ func TestLinkPNGSecure(t *testing.T) {
299 if !bytes.Equal(a, b) {300 if !bytes.Equal(a, b) {
300 t.Fatal("latest.png differs from the frame")301 t.Fatal("latest.png differs from the frame")
301 }302 }
303+ fi, err := os.Stat(frame)
304+ if err != nil {
305+ t.Fatal(err)
306+ }
307+ li, err := os.Stat(latest)
308+ if err != nil {
309+ t.Fatal(err)
310+ }
311+ if !os.SameFile(fi, li) {
312+ t.Fatal("latest.png is a copy, not a hard link to the frame")
313+ }
302 got, err := png.Decode(bytes.NewReader(b))314 got, err := png.Decode(bytes.NewReader(b))
303 if err != nil {315 if err != nil {
304 t.Fatal(err)316 t.Fatal(err)
@@ -319,40 +331,73 @@ func TestLinkPNGSecure(t *testing.T) {
319 }331 }
320 }332 }
321 333
322-// A frame that crossed a security change is not published as latest.png, and334+// A frame that crossed a security change is not published as latest.png, a
323-// a source that cannot be linked is encoded instead.335+// failed rename leaves no temporary file, and a source that cannot be linked
336+// is encoded instead.
324 func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) {337 func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) {
325 img := image.NewRGBA(image.Rect(0, 0, 1, 1))338 img := image.NewRGBA(image.Rect(0, 0, 1, 1))
326 good := ports.SecurityState{Generation: 1}339 good := ports.SecurityState{Generation: 1}
327- t.Run("protected", func(t *testing.T) {340+ // frameIn writes the frame to link and returns its directory and path.
341+ frameIn := func(t *testing.T) (string, string) {
342+ t.Helper()
328 dir := t.TempDir()343 dir := t.TempDir()
329 frame := filepath.Join(dir, "frame-000001.png")344 frame := filepath.Join(dir, "frame-000001.png")
330 if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil {345 if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil {
331 t.Fatal(err)346 t.Fatal(err)
332 }347 }
348+ return dir, frame
349+ }
350+ // onlyFrame fails unless the frame is all the directory holds: no
351+ // latest.png and no latest.png.tmp.
352+ onlyFrame := func(t *testing.T, dir string) {
353+ t.Helper()
354+ if entries, _ := os.ReadDir(dir); len(entries) != 1 {
355+ t.Fatalf("latest.png or a temp file delivered: %v", entries)
356+ }
357+ }
358+ t.Run("protected state", func(t *testing.T) {
359+ dir, frame := frameIn(t)
360+ // A protected state is refused without reading the live snapshot.
361+ gate := portsmocks.NewMockSessionSecurity(t)
362+ if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, ports.SecurityState{Generation: 1, Protected: true}); err != nil || written {
363+ t.Fatalf("link = %v, %v", written, err)
364+ }
365+ onlyFrame(t, dir)
366+ })
367+ t.Run("protected snapshot", func(t *testing.T) {
368+ dir, frame := frameIn(t)
333 gate := portsmocks.NewMockSessionSecurity(t)369 gate := portsmocks.NewMockSessionSecurity(t)
334 gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})370 gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
335 if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written {371 if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written {
336 t.Fatalf("link = %v, %v", written, err)372 t.Fatalf("link = %v, %v", written, err)
337 }373 }
338- if entries, _ := os.ReadDir(dir); len(entries) != 1 {374+ onlyFrame(t, dir)
339- t.Fatalf("latest.png or a temp file delivered: %v", entries)
340- }
341 })375 })
342 t.Run("changed after the link", func(t *testing.T) {376 t.Run("changed after the link", func(t *testing.T) {
343- dir := t.TempDir()377+ dir, frame := frameIn(t)
344- frame := filepath.Join(dir, "frame-000001.png")
345- if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil {
346- t.Fatal(err)
347- }
348 gate := portsmocks.NewMockSessionSecurity(t)378 gate := portsmocks.NewMockSessionSecurity(t)
349 gate.EXPECT().Snapshot().Return(good).Once()379 gate.EXPECT().Snapshot().Return(good).Once()
350 gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 2}).Once()380 gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 2}).Once()
351 if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written {381 if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written {
352 t.Fatalf("link = %v, %v", written, err)382 t.Fatalf("link = %v, %v", written, err)
353 }383 }
354- if entries, _ := os.ReadDir(dir); len(entries) != 1 {384+ onlyFrame(t, dir)
355- t.Fatalf("latest.png or a temp file delivered: %v", entries)385+ })
386+ t.Run("rename failure", func(t *testing.T) {
387+ dir, frame := frameIn(t)
388+ // A non-empty directory cannot be replaced by a file.
389+ latest := filepath.Join(dir, "latest.png")
390+ if err := os.MkdirAll(filepath.Join(latest, "keep"), 0o700); err != nil {
391+ t.Fatal(err)
392+ }
393+ if written, err := linkPNGSecure(frame, latest, img, nil, ports.SecurityState{}); err == nil || written {
394+ t.Fatalf("link = %v, %v; want an error", written, err)
395+ }
396+ if _, err := os.Stat(latest + ".tmp"); !os.IsNotExist(err) {
397+ t.Fatalf("temporary link left behind: %v", err)
398+ }
399+ if _, err := os.Stat(filepath.Join(latest, "keep")); err != nil {
400+ t.Fatalf("existing directory touched: %v", err)
356 }401 }
357 })402 })
358 t.Run("no source file", func(t *testing.T) {403 t.Run("no source file", func(t *testing.T) {
@@ -361,6 +406,13 @@ func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) {
361 if written, err := linkPNGSecure(filepath.Join(dir, "missing.png"), latest, img, nil, ports.SecurityState{}); err != nil || !written {406 if written, err := linkPNGSecure(filepath.Join(dir, "missing.png"), latest, img, nil, ports.SecurityState{}); err != nil || !written {
362 t.Fatalf("link = %v, %v", written, err)407 t.Fatalf("link = %v, %v", written, err)
363 }408 }
409+ fi, err := os.Stat(latest)
410+ if err != nil {
411+ t.Fatal(err)
412+ }
413+ if !fi.Mode().IsRegular() {
414+ t.Fatalf("latest.png is %v", fi.Mode())
415+ }
364 f, err := os.Open(latest)416 f, err := os.Open(latest)
365 if err != nil {417 if err != nil {
366 t.Fatal(err)418 t.Fatal(err)
@@ -369,9 +421,25 @@ func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) {
369 if _, err := png.Decode(f); err != nil {421 if _, err := png.Decode(f); err != nil {
370 t.Fatal(err)422 t.Fatal(err)
371 }423 }
424+ // Encoded on its own: nothing else in the directory shares it.
425+ if entries, _ := os.ReadDir(dir); len(entries) != 1 {
426+ t.Fatalf("unexpected files: %v", entries)
427+ }
428+ if n, ok := linkCount(fi); ok && n != 1 {
429+ t.Fatalf("latest.png has %d links, want 1", n)
430+ }
372 })431 })
373 }432 }
374 433
434+// linkCount returns the hard link count of fi where the platform reports it.
435+func linkCount(fi os.FileInfo) (uint64, bool) {
436+ st, ok := fi.Sys().(*syscall.Stat_t)
437+ if !ok {
438+ return 0, false
439+ }
440+ return uint64(st.Nlink), true
441+}
442+
375 func TestWritePNGAtomic(t *testing.T) {443 func TestWritePNGAtomic(t *testing.T) {
376 dir := t.TempDir()444 dir := t.TempDir()
377 img := image.NewRGBA(image.Rect(0, 0, 1, 1))445 img := image.NewRGBA(image.Rect(0, 0, 1, 1))

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 24

Tags 10

↑↓ moveenter openesc close