com um clique
test-runner
Guidelines and patterns for the obsidian-headless test suite
Instalar com Codex ou Claude Copie este prompt, cole no Codex, Claude ou outro assistente e deixe que ele revise a página da skill e instale para você.
Menu
Guidelines and patterns for the obsidian-headless test suite
Instalar com Codex ou Claude Copie este prompt, cole no Codex, Claude ou outro assistente e deixe que ele revise a página da skill e instale para você.
Baseado na classificação ocupacional SOC
| name | test-runner |
| description | Guidelines and patterns for the obsidian-headless test suite |
Run the full suite locally (from the src/ directory):
cd src
go test -race -count=1 -timeout=10m -shuffle=on ./...
Run a single package:
go test -race -count=1 -timeout=5m -shuffle=on ./internal/sync/...
Key flags explained:
| Flag | Purpose |
|---|---|
-race | Enables the Go race detector. Catches data races between goroutines. ~2-3x slower. |
-count=1 | Disables test caching. Essential for catching timing-dependent failures. |
-timeout=10m | Per-package timeout. Increase for slow packages (./internal/sync/...). |
-shuffle=on | Randomizes test execution order within a package. Catches hidden inter-test dependencies. |
-run=TestName | Filters to a specific test or sub-test. |
-v | Verbose output (useful for debugging a single test). |
Before committing, run the full quality gate:
cd src
go fmt ./...
go vet ./...
go fix ./...
go build ./...
go test -race -count=1 -timeout=10m -shuffle=on ./...
golangci-lint run
Every test function must call t.Parallel() unless there is a documented exception. This keeps the suite fast as it grows. The CI runs with -shuffle=on which only works correctly when tests are independent.
func TestMyFeature(t *testing.T) {
t.Parallel()
// ...
}
Sub-tests should also parallelize when independent:
func TestAggregator(t *testing.T) {
t.Run("push rename", func(t *testing.T) {
t.Parallel()
// ...
})
}
Skipping t.Parallel() is acceptable only in these situations:
t.Setenv() is used — Go panics at runtime if t.Parallel() is combined with t.Setenv(). If a package needs env vars, use TestMain + os.Setenv for package-global vars (e.g. secret prefix), and avoid t.Setenv in individual tests.
Global mutable state is mutated — If a test modifies a package-level variable (e.g. reducing quiescenceDelay for speed), it must run serially to avoid data races.
func TestAggregator_PushRename_Overwrites(t *testing.T) {
// No t.Parallel() — mutates package-level quiescenceDelay
oldDelay := quiescenceDelay
quiescenceDelay = 500 * time.Millisecond
t.Cleanup(func() { quiescenceDelay = oldDelay })
// ...
}
Shared external resources — Tests that depend on a single external process or fixed port must obtain a unique port per test or run serially.
mockSyncServer — Full WebSocket Protocol Mockengine_test.go defines mockSyncServer, a stateful WebSocket server that implements the Obsidian sync protocol (init, push, pull, ping, delete). Use it for any test that exercises Engine.executePlan, RunContinuous, or WebSocket session logic.
mock := newMockSyncServer(t)
mock.addRecord("remote.md", 1, []byte("remote content"))
server := httptest.NewServer(http.HandlerFunc(mock.serveHTTP))
defer server.Close()
wsURL := "ws" + server.URL[4:]
// Pass wsURL to Engine.Config.Host
The mock tracks:
recordsByUID / recordsByPath — server-side file statecontentByUID — raw file contentpushMsgs — all push operations received from the clientinitMsgs — all init messages (useful for verifying worker handshakes)pingCount — heartbeat pings receivedcloseAfterPing — force-close the connection after first ping (for reconnection tests)startContinuousTest — Integration Helper for Continuous Synccontinuous_test.go provides startContinuousTest, a helper that spins up a mock server, creates a temp vault, initializes an Engine, and starts RunContinuous in a background goroutine. Use it for end-to-end continuous sync scenarios.
func TestContinuousWatcherSync(t *testing.T) {
t.Parallel()
env := startContinuousTest(t)
mustWriteFile(t, filepath.Join(env.vault, "new.md"), []byte("new content"))
waitFor(t, 5*time.Second, "new.md uploaded to mock server", func() bool {
env.mock.mu.Lock()
defer env.mock.mu.Unlock()
for _, content := range env.mock.contentByUID {
if string(content) == "new content" {
return true
}
}
return false
})
env.cancel()
<-env.errCh
}
Options can customize the environment before start:
env := startContinuousTest(t, func(env *continuousTestEnv) {
env.mock.addRecord("remote.md", 1, []byte("remote content"))
env.engine.Config.DownloadConcurrency = 2
}, withTimeout(10*time.Second))
waitFor — Polling Assertioncontinuous_test.go defines waitFor, which polls a condition every 100ms until it returns true or the timeout expires.
func waitFor(t *testing.T, timeout time.Duration, desc string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for {
if cond() {
return
}
if time.Now().After(deadline) {
t.Fatalf("timed out after %v waiting for: %s", timeout, desc)
}
time.Sleep(100 * time.Millisecond)
}
}
Use this instead of time.Sleep for any asynchronous condition (file writes, WebSocket messages, state transitions).
waitForEvent — Channel-Based Pollingwatcher_test.go defines waitForEvent for reading from the watcher event channel until a predicate matches.
func waitForEvent(t *testing.T, ch <-chan ScanEvent, timeout time.Duration, desc string, pred func(ScanEvent) bool) ScanEvent
Example:
_ = waitForEvent(t, w.Out, 10*time.Second, "initial Create event", func(ev ScanEvent) bool {
return ev.Type == EventCreate && ev.Path == oldPath
})
Use table-driven tests for pure functions with multiple input/output pairs. Always run sub-tests in parallel.
func TestIsJSONConfigPath(t *testing.T) {
t.Parallel()
tests := []struct {
path string
configDir string
want bool
}{
{".obsidian/app.json", ".obsidian", true},
{"notes/config.json", ".obsidian", false},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
t.Parallel()
if got := isJSONConfigPath(tt.path, tt.configDir); got != tt.want {
t.Errorf("isJSONConfigPath(%q, %q) = %v, want %v", tt.path, tt.configDir, got, tt.want)
}
})
}
}
t.TempDir() for Filesystem IsolationAlways use t.TempDir() instead of manual os.MkdirTemp / defer os.RemoveAll. It auto-cleans and is race-safe.
func TestScanLocal(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
mustWriteFile(t, filepath.Join(tmp, "a.md"), []byte("hello"))
// ...
}
t.SetenvTests that exercise config or storage must isolate the user's real home directory to avoid polluting ~/.config.
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config"))
Do not call t.Parallel() in tests that use t.Setenv(). Instead, the package should set _OBSIDIAN_HEADLESS_TEST_SECRET_PREFIX in TestMain:
func TestMain(m *testing.M) {
os.Setenv("_OBSIDIAN_HEADLESS_TEST_SECRET_PREFIX", "test:")
code := m.Run()
os.Exit(code)
}
httptest.NewServerFor REST API tests, use httptest.NewServer with inline handlers. Always handle OPTIONS preflight requests.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"token": "tok123"})
}))
defer server.Close()
client := New(server.URL, 5*time.Second, zerolog.Nop())
EngineEngine has three test* fields that override heartbeat timing defaults when non-zero:
type Engine struct {
// ...
testHeartbeatInterval time.Duration
testHeartbeatSendThreshold time.Duration
testHeartbeatTimeout time.Duration
}
Set them in startContinuousTest options to avoid waiting for production intervals (60s+):
env.engine.testHeartbeatInterval = 500 * time.Millisecond
env.engine.testHeartbeatSendThreshold = 250 * time.Millisecond
env.engine.testHeartbeatTimeout = 3 * time.Second
For tests that depend on timers, temporarily override package-level constants and restore them with t.Cleanup:
func TestAggregator_PushRename_Overwrites(t *testing.T) {
oldDelay := quiescenceDelay
quiescenceDelay = 500 * time.Millisecond
t.Cleanup(func() { quiescenceDelay = oldDelay })
// ...
}
Do not parallelize tests that mutate package-level variables.
withTimeoutcontinuous_test.go provides withTimeout to extend the default context timeout for slow scenarios (e.g. reconnection with backoff):
env := startContinuousTest(t, withTimeout(10*time.Second))
waitFor TimeoutsWhen writing new tests, keep waitFor timeouts as tight as possible. A 5-second timeout is the default; if the operation reliably completes in <1s, use 2*time.Second. This makes failures faster and reduces CI wall time.
| Package | Coverage | Notes |
|---|---|---|
internal/sync | High | Engine, plan builder, renames, merge logic, lock files, continuous sync |
internal/sync/watch | High | Scanner, aggregator, fsnotify watcher, rename detection |
internal/api | High | REST client, auth, vault ops, retry logic, error handling |
internal/storage | Medium | SQLite state store, typed columns, incremental save, migration from v1 |
internal/config | Medium | Config manager round-trips, secret storage isolation |
internal/util | Medium | File utilities |
internal/circuitbreaker | Medium | Circuit breaker config |
internal/publish | Medium | Publish flag detection, glob matching, probe reading, scanLocal |
internal/1passwordstub | Medium | WASM core and imported stub tests |
| Package | Why Untested |
|---|---|
internal/encryption | No tests. Key derivation and AES-GCM are thin wrappers over golang.org/x/crypto; integration tests exercise them indirectly. Unit tests would require large fixture data. |
internal/model | Pure types / structs. No logic to test. |
internal/logging | Thin zerolog wrapper. No business logic. |
internal/buildinfo | Static variables populated at build time. |
internal/cli | Cobra command handlers are thin wrappers over api / sync / publish packages. E2E tests cover the critical paths. |
| Real WebSocket edge cases | The mock server covers the happy path and basic reconnection. Complex scenarios (server-initiated disconnect, chunked upload failures, mid-stream encryption key rotation) are not exercised. |
| File conflict resolution UI | threeWayMerge and jsonMerge are tested, but the user-facing "choose local / remote" flow is CLI-only and covered only by E2E. |
The .github/workflows/go-ci.yml runs the full test suite as a single monolithic job:
- run: go test -race -count=1 -timeout=10m -shuffle=on ./...
working-directory: src
Implications for developers:
go test -timeout=10m), and ensure the overall suite finishes within any CI job-level limits (e.g. workflow timeout-minutes)../... — no CI config changes needed.-race)Always run with -race locally and in CI. It catches:
mockSyncServer.muEngine.remoteKnown safe pattern: mockSyncServer uses sync.Mutex around all shared state. Copy data out of the lock before asserting:
env.mock.mu.Lock()
pings := env.mock.pingCount
env.mock.mu.Unlock()
-shuffle=on)Randomizes test order within a package. Catches tests that depend on side effects from earlier tests (e.g. leaking temp files, global state). If a test fails only under shuffle, it is not properly isolated.
-count=1)Disables Go's test cache. Without this, go test may skip running tests if the binary and source have not changed. This hides timing-dependent failures.
time.Sleep for synchronization. Use waitFor or waitForEvent instead.time.Sleep, keep it minimal (e.g. 200ms for goroutine startup) and document why.t.Cleanup to restore global state (timers, env vars) so shuffle does not propagate state.t.TempDir() for all filesystem state to prevent cross-test pollution.If CI flakes on a specific test:
go test -race -count=1 -shuffle=on -run=TestName ./package.