testutil

package
v0.4.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 29, 2026 License: Apache-2.0 Imports: 8 Imported by: 0

Documentation

Overview

Package testutil provides shared test scaffolding helpers used by *_test.go files across the dot-agents tree. The package exists to eliminate cross-package fixture duplication that SonarCloud flags (Cluster D in plans/sonarqube-pr10/findings.md): per-package copies of setupTempProject / writeFixtureRC / manifest writers / git-repo init that all do the same thing under different names.

Helper conventions:

  • All helpers accept *testing.T as the first argument and call t.Helper() so test stack traces stay accurate.
  • Helpers t.Fatal on prerequisite errors (no returned errors). Callers always want immediate test failure on fixture setup.
  • Helpers do NOT call t.TempDir() themselves. Callers control the lifecycle root and pass paths in.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func AssertExtraFieldsPreserved

func AssertExtraFieldsPreserved(t *testing.T, rc *config.AgentsRC)

AssertExtraFieldsPreserved checks that rc retains customPolicy + myteam extra fields and a multi-source Sources list after a promote operation.

func InitGitRepo

func InitGitRepo(t *testing.T, repoPath string, files map[string]string)

InitGitRepo runs `git init` in repoPath, sets test author/committer identity (env + config), writes the supplied path→content map, then `git add .` + `git commit -m "init"`. File paths in the map are relative to repoPath and use forward slashes (converted via filepath.FromSlash). Map iteration is sorted by path so file write-order is deterministic across runs.

Replaces commands/workflow/testutil_test.go::initWorkflowTestRepo, commands/scaffold_hooks_test.go::initShellHookTestRepo, and the inline git block in workflow_integration_test.go::TestWorkflow_EmptyStateGraceful.

func MakeDirUnreadable

func MakeDirUnreadable(t *testing.T, dir string)

MakeDirUnreadable makes the directory at dir unenumerable for the duration of the test using the OS-native denial mechanism. Callers can rely on:

  • os.ReadDir(dir) returns an error.
  • filepath.WalkDir's visit on dir's children returns an error.

The exact error varies by OS: POSIX denies with EACCES at the readdir system call; Windows denies with ERROR_ACCESS_DENIED via a deny-ACE on the directory's DACL. Tests that need to assert a specific errno should compare against errors.Is(err, fs.ErrPermission) — both platforms map to that.

Why this exists. The sibling helper MakeFileUnreadable handles per-file denial; many tests also need to assert the "cannot list a directory" path (e.g. iteration over a stale plans/ tree, scanning a hooks dir, walking a resource snapshot). The POSIX-only `os.Chmod(dir, 0)` trick does not work on Windows: NTFS does not honour POSIX mode bits, so the readdir succeeds and the error path the test wanted to exercise is silently skipped. Sprinkling `if runtime.GOOS == "windows" { t.Skip(...) }` around every such test silently lowers Windows coverage.

Per-platform mechanism. On POSIX (the //go:build unix file) the helper chmods the directory to 0 and restores 0o755 in a t.Cleanup so the surrounding t.TempDir can complete its own teardown. On Windows (the //go:build windows file) the helper installs a deny-ACE on the directory's DACL for the current process's user SID via SetNamedSecurityInfo; FILE_LIST_DIRECTORY is denied, so any FindFirstFile/ReadDir against the dir fails with ERROR_ACCESS_DENIED. The original DACL is restored at t.Cleanup time so t.TempDir teardown can remove the directory.

Failure modes the helper handles itself. Running as root on POSIX bypasses mode bits, so the helper t.Skips with a clear reason instead of producing a false negative. On Windows, running as a process with SeBackupPrivilege / SeRestorePrivilege (typical for elevated/admin contexts) bypasses DACL enforcement; the helper probes by attempting os.ReadDir after installing the deny-ACE and t.Skips if the denial was not actually applied. Tmpfs and other synthetic filesystems on Linux can also short-circuit chmod enforcement under some kernel configurations; the same post-install probe covers that case. Callers should not duplicate these checks — concentrating the platform policy here is the point of the abstraction.

func MakeDirWriteDenied

func MakeDirWriteDenied(t *testing.T, dir string)

MakeDirWriteDenied makes the directory at dir reject child-mutation operations (create, write, delete) for the duration of the test using the OS-native denial mechanism, while preserving read+execute. Callers can rely on:

  • os.Create / os.WriteFile against a child path returns an error.
  • os.Remove / os.RemoveAll against an existing child returns an error.
  • os.ReadDir(dir) still SUCCEEDS — the helper is the dual of MakeDirUnreadable: read-side stays open so the test can verify the unchanged contents after the denial.

The exact error varies by OS: POSIX denies with EACCES at the unlink/open syscall via chmod 0o500 (the write bit is cleared, read+execute remain); Windows denies with ERROR_ACCESS_DENIED via a deny-ACE on the directory's DACL that masks FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_DELETE_CHILD. Tests that need to assert a specific errno should compare against errors.Is(err, fs.ErrPermission) — both platforms map to that.

Why this exists alongside MakeDirUnreadable. The sibling helper denies list/traverse (FILE_LIST_DIRECTORY | FILE_TRAVERSE on Windows; chmod 0 on POSIX) and is the right tool when the test asserts a ReadDir / opendir failure. A second family of tests asserts that os.Remove / os.RemoveAll on a child fails because the *parent* refuses the unlink — that path needs write-side denial only. Reusing MakeDirUnreadable would also block stat / readdir on the parent, which can mask the test's actual assertion (the fault would surface at the wrong syscall). This helper isolates the write/delete denial so the assertion stays on the operation under test.

Per-platform mechanism. On POSIX (the //go:build unix file) the helper chmods the directory to 0o500 (read+execute, no write) and restores 0o755 in a t.Cleanup so the surrounding t.TempDir can complete its own teardown. On Windows (the //go:build windows file) the helper installs a deny-ACE on the directory's DACL for the current process's user SID via SetNamedSecurityInfo; FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_DELETE_CHILD are denied, so any CreateFile-with-write or DeleteFile against a child fails with ERROR_ACCESS_DENIED. The original DACL is restored at t.Cleanup time so t.TempDir teardown can remove the directory.

Failure modes the helper handles itself. Running as root on POSIX bypasses mode bits, so the helper t.Skips with a clear reason instead of producing a false negative. On Windows, running as a process with SeBackupPrivilege / SeRestorePrivilege (typical for elevated/admin contexts) bypasses DACL enforcement; the helper probes by attempting os.Create on a transient child after installing the deny-ACE and t.Skips if the denial was not actually applied. Tmpfs and other synthetic filesystems on Linux can also short-circuit chmod enforcement under some kernel configurations; the same post-install probe covers that case. Callers should not duplicate these checks — concentrating the platform policy here is the point of the abstraction.

func MakeFileReadOnly

func MakeFileReadOnly(t *testing.T, path string)

MakeFileReadOnly makes the file at path reject writes for the duration of the test. After this call, os.WriteFile(path, ...) and os.OpenFile(path, O_WRONLY|O_APPEND, ...) both return an error until the surrounding test's Cleanup runs.

Why this is cross-platform (no build-tag split). os.Chmod is one of the few POSIX permission calls Go translates verbatim on Windows: the runtime inspects the user-write bit (0o200) and toggles FILE_ATTRIBUTE_READONLY on the file accordingly (see Go's syscall_windows.go). Mode 0o444 has the user-write bit clear, so on both POSIX and Windows the file is rejected for write. This is the inverse of MakeFileUnreadable — POSIX/Windows agree on the write-denial semantics of chmod, while they disagree on read-denial, which is what forces MakeFileUnreadable into a platform-split.

Failure modes the helper handles itself. On POSIX, running as root bypasses DAC mode bits for writes (the kernel's uid-0 short-circuit), so the helper would silently produce a false negative: the test would see the write succeed and conclude the read-only enforcement is broken. We probe by attempting a write after the chmod; if it succeeds we restore the mode and t.Skip with a clear reason. Windows has no equivalent bypass — the readonly attribute is enforced for every caller.

Cleanup restores 0o644 so the enclosing t.TempDir teardown can remove the file. Without the restore, t.TempDir's recursive remove still works on POSIX (the parent dir's write bit is what governs removal), but on Windows the readonly attribute blocks os.Remove — leaving the t.TempDir teardown to log a noisy (non-fatal) cleanup error per file. Restoring 0o644 keeps both platforms quiet.

func MakeFileUnreadable

func MakeFileUnreadable(t *testing.T, path string)

MakeFileUnreadable makes the file at path unreadable for the duration of the test using the OS-native denial mechanism. Callers can rely on:

  • os.ReadFile(path) returns an error.
  • bufio.Scanner(f).Scan() (the iteration path BackfillIterations uses) returns an error once it tries to read bytes.

The exact failure point varies by OS: POSIX denies at open; Windows denies at read. Tests that need a guaranteed open-failure should test their downstream contract (read failure) rather than os.Open directly.

Why this exists. The natural-looking POSIX trick — os.WriteFile(path, data, 0o000) and read it back — does not work on Windows: NTFS does not honour POSIX mode bits, so the read succeeds and the error path the test wanted to exercise is silently skipped. Sprinkling `if runtime.GOOS == "windows" { t.Skip(...) }` around every such test silently lowers coverage on the Windows runner and lets real Windows-only regressions slip in.

Per-platform mechanism. On POSIX (the //go:build unix file) the helper chmods the file to 0 and restores 0o644 in a t.Cleanup so the surrounding t.TempDir can complete its own teardown. On Windows (the //go:build windows file) the helper takes an exclusive byte-range lock spanning the entire file via LockFileEx; reads from any handle to the locked range fail with ERROR_LOCK_VIOLATION until the lock is released at t.Cleanup time. The Windows path intentionally does NOT rely on FILE_SHARE_NONE because CI runners have antivirus processes that open new files with maximum sharing, which defeats share-mode denial; byte-range locks are not subject to that interference.

Failure modes the helper handles itself. Running as root on POSIX bypasses mode bits, so the helper t.Skips with a clear reason instead of producing a false negative. Callers should not duplicate that check — concentrating the platform policy here is the point of the abstraction.

func NewTempAgentsHome

func NewTempAgentsHome(t *testing.T) (tmp, agentsHome string)

NewTempAgentsHome creates a t.TempDir() root, sets HOME and AGENTS_HOME envs (with .agents/ subdir under HOME), and returns (tmp, agentsHome).

Lighter than NewTempProject when a test needs full env-var control but no project tree or .agentsrc.json. Absorbs the recurring 5-line preamble:

tmp := t.TempDir()
t.Setenv("HOME", tmp)
agentsHome := filepath.Join(tmp, ".agents")
if err := os.MkdirAll(agentsHome, 0755); err != nil { t.Fatal(err) }
t.Setenv("AGENTS_HOME", agentsHome)

This helper intentionally calls t.TempDir() internally (the env-var setup wraps the temp dir; the helper exists to bundle them). Use NewTempProject when you also need a project directory + canonical agents tree; use this when you don't.

func NewTempProject

func NewTempProject(t *testing.T, projectName string) (agentsHome, projectPath string)

NewTempProject creates a self-contained agentsHome + repo pair under t.TempDir(), points AGENTS_HOME at agentsHome, and writes a minimal .agentsrc.json (Version=1, Project=projectName, one local source). Returns (agentsHome, projectPath).

Replaces commands/agents/agents_test.go::setupAgentsEnv and commands/skills/promote_test.go::setupSkillsEnv.

func SymlinkOrSkip

func SymlinkOrSkip(t *testing.T)

SymlinkOrSkip gates a test on the *capability* to create a symbolic link in the current process, NOT on the operating system. It probes by attempting an os.Symlink inside t.TempDir(); if the probe succeeds the helper returns and the caller may use real symlinks for the remainder of the test. If the probe fails the helper calls t.Skip with a Windows-aware reason and the test stops.

Why capability, not OS

Existing tests across the tree gate symlink coverage with

if runtime.GOOS == "windows" { t.Skip(...) }

That gate is too coarse. Windows 10+ with Developer Mode enabled — and the default GitHub Actions windows-latest runner, which grants SeCreateSymbolicLinkPrivilege to the build user — *can* create symlinks. Skipping unconditionally on Windows surrenders coverage that is in fact available, and a windows-only path bug in the production link layer would never be observed by CI. The right question is "can THIS process call os.Symlink right now?" — answered by trying it.

On POSIX the probe always succeeds (modulo a write-only TempDir, which is exotic enough we let the t.Skip path handle it). On Windows the probe fails with ERROR_PRIVILEGE_NOT_HELD when the process lacks the privilege, and the test is skipped with a message naming Developer Mode as the remedy. On a properly-configured Windows host the probe succeeds and the test runs end-to-end, picking up the coverage that the runtime.GOOS gate threw away.

Probe shape

The probe symlinks a non-existent target ("probe-target") to a freshly-created link ("probe-link") under t.TempDir(). The target does not need to exist for os.Symlink to succeed — the call only needs the process privilege. Both paths are inside t.TempDir(), so cleanup is automatic and no fixture is left behind whether the probe succeeds or fails. The helper does NOT pre-clean the probe link; t.TempDir's teardown removes the whole tree.

Usage

func TestSomethingThatNeedsSymlinks(t *testing.T) {
    testutil.SymlinkOrSkip(t)
    // ... call os.Symlink freely from here
}

Migration targets (10 sites, deferred to migrate-sites task)

Per the catalogue in .agents/workflow/plans/cross-platform-test-skips-audit/findings.md, the [shortcut-symlink] class covers these sites:

  • internal/links/symlink_remove_error_test.go (2 tests)
  • internal/links/managed_link_branches_test.go (3 tests)
  • internal/links/managed_link_branches2_test.go (5 tests, one mixed with hardlink — verify hardlink coverage isn't lost when migrating)

Replace each `if runtime.GOOS == "windows" { t.Skip(...) }` preamble with a single call to SymlinkOrSkip(t). Do NOT migrate sites in the [genuine-posix] class (Windows-file-managed-link semantics differ from POSIX symlinks at a higher level than the syscall — that's an abstraction problem, not a privilege problem).

func WriteAgentManifest

func WriteAgentManifest(t *testing.T, projectPath, agentName string)

WriteAgentManifest creates projectPath/.agents/agents/<agentName>/ and writes AGENT.md with a frontmatter (`name`, `description: test agent`) and a body. Bucket-aware wrapper around the generic writeManifest core.

Replaces commands/agents/agents_test.go::writeAgentMD.

func WriteAgentsRC

func WriteAgentsRC(t *testing.T, projectPath string, rc *config.AgentsRC)

WriteAgentsRC saves rc into projectPath/.agentsrc.json. When rc is nil, a minimal default (Version=1, Sources=[{Type:"local"}]) is used.

Replaces the repeated `rc := &config.AgentsRC{...}; rc.Save(projectPath)` 6-line block scattered across agents/skills setups, and the inline .agentsrc.json string literal in workflow_integration_test.go and scaffold_hooks_test.go.

func WriteCanonicalAgent

func WriteCanonicalAgent(t *testing.T, agentsHome, projectName, agentName string) string

WriteCanonicalAgent creates the canonical agentsHome/agents/<projectName>/ <agentName>/ directory with an AGENT.md fixture. Returns the directory.

Replaces commands/agents/agents_test.go::writeCanonicalAgent.

func WriteCanonicalSkill

func WriteCanonicalSkill(t *testing.T, agentsHome, projectName, skillName string) string

WriteCanonicalSkill creates the canonical agentsHome/skills/<projectName>/ <skillName>/ directory with a SKILL.md fixture. Returns the directory.

Symmetric counterpart to WriteCanonicalAgent.

func WritePreservationManifest

func WritePreservationManifest(t *testing.T) (agentsHome, projectPath string)

WritePreservationManifest creates a temp project whose .agentsrc.json contains extra fields (customPolicy, myteam) and a multi-source Sources list — the fixture used by TestPromote*_PreservesManifestUnknownFields. Returns (agentsHome, projectPath) with AGENTS_HOME set.

func WriteScopeFile

func WriteScopeFile(t *testing.T, agentsHome, bucket, scope, baseName string, content []byte)

WriteScopeFile creates agentsHome/<bucket>/<scope>/ and writes <baseName> with the given content. Used by mcp/settings/rules tests where the fixture is just "drop a file under the scope tree" with no manifest semantics.

Replaces inline mkdir+writeFile blocks in commands/{mcp_settings,rules}_test.go and internal/platform/{mcp_settings,rules}_test.go.

func WriteScopeFilePath

func WriteScopeFilePath(t *testing.T, agentsHome, bucket, scope, relPath string, content []byte)

WriteScopeFilePath extends WriteScopeFile to accept a nested relative path under agentsHome/bucket/scope/, e.g. ".github/hooks/pre-tool.json". The relative path components are MkdirAll'd as needed. Equivalent to WriteScopeFile when relPath has no separator.

Unlocks the seed loop in TestRestoreFromResourcesCounted_RestoresDirectoryBuckets and similar nested-path call sites surfaced in t5's merge-back.

func WriteSkillManifest

func WriteSkillManifest(t *testing.T, projectPath, skillName string)

WriteSkillManifest creates projectPath/.agents/skills/<skillName>/ and writes SKILL.md with a frontmatter and body.

Replaces commands/skills/promote_test.go::writeSkillMD.

Types

This section is empty.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL