Four entangled change-sets from one session, committed together because
their file-level overlap (build.sh, docs, embedded/, watcher.go, …) makes
post-hoc separation noisy:
* fix(archive): nested-party + folder-type cascade
transmittalIsUnderVisibleParty short-circuited on the first matched
party segment, only checking the immediately-next segment for a
folder-type marker. Paths like BM/sub/Issued/<txn> bypassed the Issued
toggle entirely. Replaced with isUnderHiddenFolderType (full-path) +
any-segment party match. Eight new Playwright cases pin the contract
in tests/archive-cascade.spec.js.
* refactor(zddc-server): scope .archive index by project
archive.Index now buckets by top-level segment
(.ByProject[<project>].ByTracking[<tracking>]). Resolve and AllEntries
take a project parameter; handler extracts it from contextPath's first
segment. /.archive/ at root returns 404 — stable refs must be
project-rooted. Within-project (tracking, rev) collisions emit a WARN
with both paths. Cross-project tracking-number duplicates no longer
collide.
* perf(zddc-server): lazy-load expensive bits of the profile page
serveProfilePage now ships a minimal shell: Email, EmailHeader,
IsSuperAdmin (root .zddc only). Visible projects + admin subtrees +
editable scaffolds populate client-side via /.profile/access. Subtree-
admin scaffolds live in <template id="tmpl-subtree-admin">; pure
non-admins receive no live admin form. ScanZddcFiles now memoized,
invalidated on .zddc events by the watcher and writer helpers.
* feat: lockstep release + redesigned releases page
sh build.sh --release [version|alpha|beta] is the canonical lockstep
cut: every tool (5 HTML + zddc-server) bumps to the same coordinated
version. zddc-server binaries now committed under website/releases/
with the same cascade chain as HTML tools (no more Codeberg release-
asset publication). zddc/release.sh deprecated (kept as a guard);
shared/publish-codeberg-release.sh removed.
Releases page redesigned as an action-first install guide: hero +
version dropdown that rewires every download link, channel chips for
always-visible alpha/beta access (state-aware labels: "tracks stable"
vs "active dev"), Path A (zddc-server with platform auto-detect from
UA), Path B (5 standalone tool HTMLs), version-pinning empowerment
narrative (drop-a-copy vs .zddc apps: cascade), channels explainer.
Channel-link verifier asserts every <tool>_{stable,beta,alpha}.html
resolves at the end of every build. Bootstrap-friendly: zddc-server
artifact checks skip until the first lockstep cut anchors the chain.
Tests: 167 Playwright + all Go packages green.
Docs: CLAUDE.md, AGENTS.md, ARCHITECTURE.md, zddc/README.md updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
125 lines
3.4 KiB
Go
125 lines
3.4 KiB
Go
package zddc
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// scanFixture lays down a small tree with a few .zddc files and reserved-
|
|
// prefix directories that should be pruned. Returns the fsRoot.
|
|
func scanFixture(t *testing.T) string {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
mk := func(rel, body string) {
|
|
path := filepath.Join(root, filepath.FromSlash(rel))
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
}
|
|
mk(".zddc", "admins:\n - alice@example.com\n")
|
|
mk("ProjectA/.zddc", "title: A\n")
|
|
mk("ProjectA/Sub/.zddc", "title: A-sub\n")
|
|
mk("ProjectB/.zddc", "title: B\n")
|
|
// Reserved-prefix subtrees must be pruned.
|
|
mk(".devshell/.zddc", "title: hidden\n")
|
|
mk("_template/.zddc", "title: scaffold\n")
|
|
return root
|
|
}
|
|
|
|
func TestScanZddcFiles_FindsAllAndPrunesReservedPrefixes(t *testing.T) {
|
|
InvalidateScanCache()
|
|
root := scanFixture(t)
|
|
|
|
dirs, err := ScanZddcFiles(root)
|
|
if err != nil {
|
|
t.Fatalf("ScanZddcFiles: %v", err)
|
|
}
|
|
|
|
want := []string{
|
|
root,
|
|
filepath.Join(root, "ProjectA"),
|
|
filepath.Join(root, "ProjectA", "Sub"),
|
|
filepath.Join(root, "ProjectB"),
|
|
}
|
|
if len(dirs) != len(want) {
|
|
t.Fatalf("got %d dirs, want %d: %v", len(dirs), len(want), dirs)
|
|
}
|
|
for i, w := range want {
|
|
if dirs[i] != w {
|
|
t.Errorf("dirs[%d] = %q, want %q", i, dirs[i], w)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cache hit returns the same slice header as the first call (proves the
|
|
// walk wasn't repeated). Cache miss after invalidation returns a freshly-
|
|
// allocated slice.
|
|
func TestScanZddcFiles_CachesAndInvalidates(t *testing.T) {
|
|
InvalidateScanCache()
|
|
root := scanFixture(t)
|
|
|
|
first, err := ScanZddcFiles(root)
|
|
if err != nil {
|
|
t.Fatalf("first call: %v", err)
|
|
}
|
|
second, err := ScanZddcFiles(root)
|
|
if err != nil {
|
|
t.Fatalf("second call: %v", err)
|
|
}
|
|
if &first[0] != &second[0] {
|
|
t.Errorf("expected cached slice reuse — got fresh allocation")
|
|
}
|
|
|
|
// Add a new .zddc file. Without invalidation the cache returns stale data.
|
|
newDir := filepath.Join(root, "ProjectC")
|
|
if err := os.MkdirAll(newDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(newDir, ".zddc"), []byte("title: C\n"), 0o644); err != nil {
|
|
t.Fatalf("write .zddc: %v", err)
|
|
}
|
|
|
|
stale, _ := ScanZddcFiles(root)
|
|
if len(stale) != len(first) {
|
|
t.Errorf("expected stale cached result before invalidation; got %d entries (first had %d)", len(stale), len(first))
|
|
}
|
|
|
|
InvalidateScanCache()
|
|
fresh, err := ScanZddcFiles(root)
|
|
if err != nil {
|
|
t.Fatalf("post-invalidate: %v", err)
|
|
}
|
|
if len(fresh) != len(first)+1 {
|
|
t.Errorf("expected fresh result with new entry; got %v", fresh)
|
|
}
|
|
wantNew := filepath.Join(root, "ProjectC")
|
|
found := false
|
|
for _, d := range fresh {
|
|
if d == wantNew {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("fresh scan missing %q; got %v", wantNew, fresh)
|
|
}
|
|
}
|
|
|
|
// Walk errors must NOT poison the cache — a transient permissions blip
|
|
// during the walk should leave the cache empty so the next call retries.
|
|
func TestScanZddcFiles_DoesNotCacheOnError(t *testing.T) {
|
|
InvalidateScanCache()
|
|
bogus := filepath.Join(t.TempDir(), "does-not-exist")
|
|
_, err := ScanZddcFiles(bogus)
|
|
if err == nil {
|
|
t.Fatalf("expected error walking nonexistent path")
|
|
}
|
|
// Cache should not have stored anything.
|
|
if _, ok := scanCache.Load(filepath.Clean(bogus)); ok {
|
|
t.Errorf("cache populated despite walk error")
|
|
}
|
|
}
|