- page_scenario.go: onclick built via JSON.stringify() embedded raw double
quotes inside a double-quoted HTML attribute, truncating the attribute so
the click handler never compiled; pass the name through an escaped
data-scenario-name attribute instead.
- build.sh: overlay staging rsyncs (OVERLAY_DIR->stage, stage->includes.chroot)
ran without --delete, so a scenario removed from the repo (a9924b0) stayed
baked into every ISO built from the persistent stage cache since — the
"second script" in the Scenario page's list.
- blackbox: rewritten around a deterministic local zip + incremental
patch-the-changed-suffix onto removable media, instead of walking/copying
~90 files through a synchronous ntfs-3g FUSE mount every cycle. journalctl
captures are now "--since last sync" (were "--since boot", growing with
uptime) and metrics.db is excluded (was copied whole every cycle).
- scenario: nvbandwidth-acs-ab now escalates GPU count (same-socket pair,
other socket's pair, one cross-socket pair, all GPUs) under each ACS state
instead of always running all 6 GPUs at once, using a new `bee
gpu-bandwidth-groups` subcommand that discovers socket layout from
`nvidia-smi topo -m` at runtime — gpu_indices is host-specific, so this
can't be baked into the scenario file.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
236 lines
7.2 KiB
Go
236 lines
7.2 KiB
Go
package app
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func writeTestFile(t *testing.T, path string, content string, mtime time.Time) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
if err := os.Chtimes(path, mtime, mtime); err != nil {
|
|
t.Fatalf("chtimes: %v", err)
|
|
}
|
|
}
|
|
|
|
func readZipEntries(t *testing.T, path string) map[string]string {
|
|
t.Helper()
|
|
r, err := zip.OpenReader(path)
|
|
if err != nil {
|
|
t.Fatalf("open zip %s: %v", path, err)
|
|
}
|
|
defer r.Close()
|
|
out := map[string]string{}
|
|
for _, f := range r.File {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
t.Fatalf("open entry %s: %v", f.Name, err)
|
|
}
|
|
data, err := io.ReadAll(rc)
|
|
rc.Close()
|
|
if err != nil {
|
|
t.Fatalf("read entry %s: %v", f.Name, err)
|
|
}
|
|
out[f.Name] = string(data)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestBuildZipArchiveRoundTrip(t *testing.T) {
|
|
root := t.TempDir()
|
|
mtime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
writeTestFile(t, filepath.Join(root, "a.txt"), "hello", mtime)
|
|
writeTestFile(t, filepath.Join(root, "sub", "b.txt"), "world", mtime)
|
|
|
|
dest := filepath.Join(t.TempDir(), "out.zip")
|
|
if err := buildZipArchive(root, dest); err != nil {
|
|
t.Fatalf("buildZipArchive: %v", err)
|
|
}
|
|
|
|
entries := readZipEntries(t, dest)
|
|
if entries["a.txt"] != "hello" || entries["sub/b.txt"] != "world" {
|
|
t.Fatalf("entries=%+v", entries)
|
|
}
|
|
}
|
|
|
|
func TestBuildZipArchiveDeterministicWhenUnchanged(t *testing.T) {
|
|
root := t.TempDir()
|
|
mtime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
writeTestFile(t, filepath.Join(root, "a.txt"), "hello", mtime)
|
|
writeTestFile(t, filepath.Join(root, "b.txt"), "world", mtime)
|
|
|
|
dir := t.TempDir()
|
|
p1 := filepath.Join(dir, "one.zip")
|
|
p2 := filepath.Join(dir, "two.zip")
|
|
if err := buildZipArchive(root, p1); err != nil {
|
|
t.Fatalf("build 1: %v", err)
|
|
}
|
|
if err := buildZipArchive(root, p2); err != nil {
|
|
t.Fatalf("build 2: %v", err)
|
|
}
|
|
b1, _ := os.ReadFile(p1)
|
|
b2, _ := os.ReadFile(p2)
|
|
if !bytes.Equal(b1, b2) {
|
|
t.Fatalf("two builds of an unchanged tree produced different bytes (len %d vs %d)", len(b1), len(b2))
|
|
}
|
|
}
|
|
|
|
func TestPatchArchiveOnTargetIncrementalAndValid(t *testing.T) {
|
|
root := t.TempDir()
|
|
mtime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
writeTestFile(t, filepath.Join(root, "static.txt"), "does not change", mtime)
|
|
writeTestFile(t, filepath.Join(root, "growing.txt"), "cycle-1 line\n", mtime)
|
|
|
|
work := t.TempDir()
|
|
cachedPath := filepath.Join(work, "cache.zip")
|
|
newPath := filepath.Join(work, "new.zip")
|
|
targetPath := filepath.Join(work, "target.zip")
|
|
|
|
// Cycle 1: cold start, no cache, no target yet.
|
|
if err := buildZipArchive(root, newPath); err != nil {
|
|
t.Fatalf("build cycle1: %v", err)
|
|
}
|
|
if err := patchArchiveOnTarget(targetPath, newPath, cachedPath); err != nil {
|
|
t.Fatalf("patch cycle1: %v", err)
|
|
}
|
|
if err := os.Rename(newPath, cachedPath); err != nil {
|
|
t.Fatalf("rename cycle1: %v", err)
|
|
}
|
|
|
|
want1, _ := os.ReadFile(cachedPath)
|
|
got1, _ := os.ReadFile(targetPath)
|
|
if !bytes.Equal(want1, got1) {
|
|
t.Fatalf("cycle1: target does not match freshly built zip")
|
|
}
|
|
entries1 := readZipEntries(t, targetPath)
|
|
if entries1["static.txt"] != "does not change" || entries1["growing.txt"] != "cycle-1 line\n" {
|
|
t.Fatalf("cycle1 entries=%+v", entries1)
|
|
}
|
|
|
|
// Cycle 2: only growing.txt changes (as it would with an appended log).
|
|
writeTestFile(t, filepath.Join(root, "growing.txt"), "cycle-1 line\ncycle-2 line\n", mtime.Add(time.Minute))
|
|
|
|
if err := buildZipArchive(root, newPath); err != nil {
|
|
t.Fatalf("build cycle2: %v", err)
|
|
}
|
|
prefixLen, err := commonPrefixLen(cachedPath, newPath)
|
|
if err != nil {
|
|
t.Fatalf("commonPrefixLen: %v", err)
|
|
}
|
|
newInfo, _ := os.Stat(newPath)
|
|
if prefixLen <= 0 {
|
|
t.Fatalf("expected a nonzero unchanged prefix (static.txt entry should be identical), got %d", prefixLen)
|
|
}
|
|
if prefixLen >= newInfo.Size() {
|
|
t.Fatalf("expected the changed growing.txt entry to make the archives diverge before EOF, prefixLen=%d size=%d", prefixLen, newInfo.Size())
|
|
}
|
|
|
|
if err := patchArchiveOnTarget(targetPath, newPath, cachedPath); err != nil {
|
|
t.Fatalf("patch cycle2: %v", err)
|
|
}
|
|
if err := os.Rename(newPath, cachedPath); err != nil {
|
|
t.Fatalf("rename cycle2: %v", err)
|
|
}
|
|
|
|
want2, _ := os.ReadFile(cachedPath)
|
|
got2, _ := os.ReadFile(targetPath)
|
|
if !bytes.Equal(want2, got2) {
|
|
t.Fatalf("cycle2: target does not match freshly built zip after incremental patch")
|
|
}
|
|
entries2 := readZipEntries(t, targetPath)
|
|
if entries2["growing.txt"] != "cycle-1 line\ncycle-2 line\n" {
|
|
t.Fatalf("cycle2 growing.txt=%q", entries2["growing.txt"])
|
|
}
|
|
if entries2["static.txt"] != "does not change" {
|
|
t.Fatalf("cycle2 static.txt=%q", entries2["static.txt"])
|
|
}
|
|
}
|
|
|
|
func TestPatchArchiveOnTargetFallsBackOnDrift(t *testing.T) {
|
|
root := t.TempDir()
|
|
mtime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
writeTestFile(t, filepath.Join(root, "a.txt"), "hello", mtime)
|
|
|
|
work := t.TempDir()
|
|
cachedPath := filepath.Join(work, "cache.zip")
|
|
newPath := filepath.Join(work, "new.zip")
|
|
targetPath := filepath.Join(work, "target.zip")
|
|
|
|
if err := buildZipArchive(root, newPath); err != nil {
|
|
t.Fatalf("build: %v", err)
|
|
}
|
|
if err := patchArchiveOnTarget(targetPath, newPath, cachedPath); err != nil {
|
|
t.Fatalf("patch: %v", err)
|
|
}
|
|
if err := os.Rename(newPath, cachedPath); err != nil {
|
|
t.Fatalf("rename: %v", err)
|
|
}
|
|
|
|
// Simulate drift: something external truncated/replaced the target file
|
|
// so its size no longer matches what our local cache expects.
|
|
if err := os.WriteFile(targetPath, []byte("garbage"), 0644); err != nil {
|
|
t.Fatalf("simulate drift: %v", err)
|
|
}
|
|
|
|
writeTestFile(t, filepath.Join(root, "a.txt"), "hello again", mtime.Add(time.Minute))
|
|
if err := buildZipArchive(root, newPath); err != nil {
|
|
t.Fatalf("build2: %v", err)
|
|
}
|
|
if err := patchArchiveOnTarget(targetPath, newPath, cachedPath); err != nil {
|
|
t.Fatalf("patch2: %v", err)
|
|
}
|
|
|
|
want, _ := os.ReadFile(newPath)
|
|
got, _ := os.ReadFile(targetPath)
|
|
if !bytes.Equal(want, got) {
|
|
t.Fatalf("expected drift to trigger a full rewrite, target still corrupt/stale")
|
|
}
|
|
entries := readZipEntries(t, targetPath)
|
|
if entries["a.txt"] != "hello again" {
|
|
t.Fatalf("entries=%+v", entries)
|
|
}
|
|
}
|
|
|
|
func TestCommonPrefixLenSortedIndependent(t *testing.T) {
|
|
// Sanity check that WalkDir's lexical ordering means entry order in the
|
|
// zip doesn't depend on directory-read order — build twice from the same
|
|
// tree and expect the entry name list to already come out sorted.
|
|
root := t.TempDir()
|
|
mtime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
names := []string{"z.txt", "a.txt", "m/one.txt", "m/two.txt"}
|
|
for _, n := range names {
|
|
writeTestFile(t, filepath.Join(root, n), n, mtime)
|
|
}
|
|
dest := filepath.Join(t.TempDir(), "out.zip")
|
|
if err := buildZipArchive(root, dest); err != nil {
|
|
t.Fatalf("build: %v", err)
|
|
}
|
|
r, err := zip.OpenReader(dest)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
defer r.Close()
|
|
var got []string
|
|
for _, f := range r.File {
|
|
got = append(got, f.Name)
|
|
}
|
|
sorted := append([]string(nil), got...)
|
|
sort.Strings(sorted)
|
|
for i := range got {
|
|
if got[i] != sorted[i] {
|
|
t.Fatalf("entries not in lexical order: %v", got)
|
|
}
|
|
}
|
|
}
|