template-jj/cmd/planctl/context_test.go
sid e0db651781 test(planctl): parseLastUsage 10 MB buffer regression tests (pre-PR fix)
Pre-PR codex review flagged missing coverage for the 10 MB scanner buffer
called out in design §3.5. Adds:
- TestParseLastUsage_largeLine: 5 MB JSONL line with inert padding still
  extracts the usage object correctly (no silent truncation).
- TestParseLastUsage_oversizedLineErrors: 11 MB line surfaces a scanner
  error cleanly (no panic, nil usage).

Follow-up to parent 1.0, dev/plans/26174-planctl-context-tokens/tasks.md.
2026-04-23 18:59:52 -06:00

478 lines
15 KiB
Go

package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// spec:26174-planctl-context-tokens/R4.4+D§3.8
func TestFormatTokenCount(t *testing.T) {
cases := []struct {
in int64
want string
}{
{0, "< 1 k"},
{500, "< 1 k"},
{999, "< 1 k"},
{1000, "1 k"},
{1499, "1 k"},
{1500, "2 k"},
{143000, "143 k"},
{1000000, "1000 k"},
}
for _, c := range cases {
if got := formatTokenCount(c.in); got != c.want {
t.Errorf("formatTokenCount(%d) = %q, want %q", c.in, got, c.want)
}
}
}
// spec:26174-planctl-context-tokens/R3.1+R3.2+R3.5+D§3.7
func TestClassify_bands(t *testing.T) {
cases := []struct {
name string
ctx *TokenCtx
wantSev string
wantMsg string
}{
{"nil ctx", nil, "", ""},
{"pct 0", &TokenCtx{Used: 0, Limit: 200000}, "", ""},
{"pct 69", &TokenCtx{Used: 138000, Limit: 200000}, "", ""},
{"pct 70 info lower", &TokenCtx{Used: 140000, Limit: 200000}, "info", "plan to wrap up this session soon."},
{"pct 84 info upper", &TokenCtx{Used: 168000, Limit: 200000}, "info", "plan to wrap up this session soon."},
{"pct 85 warn lower", &TokenCtx{Used: 170000, Limit: 200000}, "warn", "commit current work and start a new session after this task."},
{"pct 94 warn upper", &TokenCtx{Used: 188000, Limit: 200000}, "warn", "commit current work and start a new session after this task."},
{"pct 95 error lower", &TokenCtx{Used: 190000, Limit: 200000}, "error", "stop new work; commit and close out immediately."},
{"pct 100 error", &TokenCtx{Used: 200000, Limit: 200000}, "error", "stop new work; commit and close out immediately."},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
sev, msg := c.ctx.Classify()
if sev != c.wantSev || msg != c.wantMsg {
t.Errorf("Classify() = (%q, %q), want (%q, %q)", sev, msg, c.wantSev, c.wantMsg)
}
})
}
}
// spec:26174-planctl-context-tokens/R3.3+D§3.7
func TestClassify_unknownLimit(t *testing.T) {
ctx := &TokenCtx{Used: 143000, Limit: 0}
sev, msg := ctx.Classify()
if sev != "" || msg != "" {
t.Errorf("Classify() with Limit=0 = (%q, %q), want (\"\", \"\")", sev, msg)
}
}
// spec:26174-planctl-context-tokens/R2.4+D§3.6
func TestReadLimit_set(t *testing.T) {
t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "200000")
if got := readLimit(); got != 200000 {
t.Errorf("readLimit() = %d, want 200000", got)
}
}
// spec:26174-planctl-context-tokens/R2.4+D§3.6
func TestReadLimit_unset(t *testing.T) {
t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "")
if got := readLimit(); got != 0 {
t.Errorf("readLimit() unset = %d, want 0", got)
}
}
// spec:26174-planctl-context-tokens/R2.4+D§3.6
func TestReadLimit_invalid(t *testing.T) {
t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "not-a-number")
if got := readLimit(); got != 0 {
t.Errorf("readLimit() invalid = %d, want 0", got)
}
}
// spec:26174-planctl-context-tokens/R1.5+D§3.4
func TestTranscriptPath_slash(t *testing.T) {
got := transcriptPath("abc", "/Users/x", "/Users/x/repos/foo")
want := "/Users/x/.claude/projects/-Users-x-repos-foo/abc.jsonl"
if got != want {
t.Errorf("transcriptPath = %q, want %q", got, want)
}
}
// spec:26174-planctl-context-tokens/R1.5+D§3.4
func TestTranscriptPath_nested(t *testing.T) {
got := transcriptPath("uuid", "/home/u", "/a/b/c/d")
want := "/home/u/.claude/projects/-a-b-c-d/uuid.jsonl"
if got != want {
t.Errorf("transcriptPath nested = %q, want %q", got, want)
}
}
// spec:26174-planctl-context-tokens/R1.6+D§3.4
func TestTranscriptPath_fallbackCwd(t *testing.T) {
got := transcriptPath("uuid", "/h", "")
// fallback uses os.Getwd; just assert shape contains home and the uuid.jsonl suffix
if !strings.HasPrefix(got, "/h/.claude/projects/") {
t.Errorf("fallback path prefix wrong: %q", got)
}
if !strings.HasSuffix(got, "/uuid.jsonl") {
t.Errorf("fallback path suffix wrong: %q", got)
}
}
// spec:26174-planctl-context-tokens/R6.2+D§3.9
func TestParsePIDFile_valid(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "1234")
if err := os.WriteFile(p, []byte("817fec78-6ff1-4fb2-a17c-5708ea5df968\n1700000000\n"), 0o600); err != nil {
t.Fatal(err)
}
uuid, stamp, err := parsePIDFile(p)
if err != nil {
t.Fatalf("parsePIDFile: %v", err)
}
if uuid != "817fec78-6ff1-4fb2-a17c-5708ea5df968" {
t.Errorf("uuid = %q", uuid)
}
if stamp != 1700000000 {
t.Errorf("stamp = %d, want 1700000000", stamp)
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.9
func TestParsePIDFile_missingLine2(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "1234")
if err := os.WriteFile(p, []byte("817fec78-6ff1-4fb2-a17c-5708ea5df968\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := parsePIDFile(p); err == nil {
t.Fatal("expected error for missing line 2")
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.9
func TestParsePIDFile_malformedInt(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "1234")
if err := os.WriteFile(p, []byte("817fec78-6ff1-4fb2-a17c-5708ea5df968\nnot-a-number\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := parsePIDFile(p); err == nil {
t.Fatal("expected error for malformed int")
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.9+D§4.1
func TestParsePIDFile_trailingContent(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "1234")
if err := os.WriteFile(p, []byte("uuid-abc\n1700000000\nunexpected\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := parsePIDFile(p); err == nil {
t.Fatal("expected error for trailing content")
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.9+D§4.1
// Blank third line also disallowed — design §4.1 specifies exactly two newline-terminated lines.
func TestParsePIDFile_blankThirdLine(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "1234")
if err := os.WriteFile(p, []byte("uuid-abc\n1700000000\n\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := parsePIDFile(p); err == nil {
t.Fatal("expected error for blank third line")
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.9+D§4.1
// Missing final newline indicates a partial/truncated write.
func TestParsePIDFile_missingFinalNewline(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "1234")
if err := os.WriteFile(p, []byte("uuid-abc\n1700000000"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := parsePIDFile(p); err == nil {
t.Fatal("expected error for missing final newline")
}
}
// spec:26174-planctl-context-tokens/R2.1+R2.2+D§3.5
func TestParseLastUsage_valid(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "t.jsonl")
content := `{"type":"user","message":{"content":"hi"}}
{"type":"assistant","message":{"usage":{"input_tokens":10,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}}}
{"type":"assistant","message":{"usage":{"input_tokens":20,"cache_creation_input_tokens":200,"cache_read_input_tokens":2000}}}
`
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
got, err := parseLastUsage(p)
if err != nil {
t.Fatalf("parseLastUsage: %v", err)
}
// last line wins: 20 + 200 + 2000 = 2220
if got != 2220 {
t.Errorf("got %d, want 2220", got)
}
}
// spec:26174-planctl-context-tokens/R2.3+D§3.5
func TestParseLastUsage_malformedMid(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "t.jsonl")
content := `{"type":"assistant","message":{"usage":{"input_tokens":1}}}
{not valid json at all
{"type":"assistant","message":{"usage":{"input_tokens":5,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
`
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
got, err := parseLastUsage(p)
if err != nil {
t.Fatalf("parseLastUsage: %v", err)
}
if got != 5 {
t.Errorf("got %d, want 5 (last good line after malformed skip)", got)
}
}
// spec:26174-planctl-context-tokens/R2.3+D§3.5
func TestParseLastUsage_noAssistant(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "t.jsonl")
content := `{"type":"user","message":{"content":"hi"}}
{"type":"system","message":{"content":"boot"}}
`
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
if _, err := parseLastUsage(p); err == nil {
t.Fatal("expected errNoUsageLine")
}
}
// spec:26174-planctl-context-tokens/R2.3+D§3.5
func TestParseLastUsage_emptyFile(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "t.jsonl")
if err := os.WriteFile(p, []byte(""), 0o600); err != nil {
t.Fatal(err)
}
if _, err := parseLastUsage(p); err == nil {
t.Fatal("expected errNoUsageLine for empty file")
}
}
// spec:26174-planctl-context-tokens/R2.1+R2.6+D§3.5+D§7.1
// parseLastUsage must handle oversized-but-valid JSONL lines — design §3.5
// sizes the scanner buffer at 10 MB to accommodate large messages. Here we
// stuff the assistant line with a 5 MB inert string field (ignored by the
// transcriptUsage struct) to confirm the scanner doesn't truncate.
func TestParseLastUsage_largeLine(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "big.jsonl")
f, err := os.Create(p)
if err != nil {
t.Fatal(err)
}
pad := strings.Repeat("x", 5*1024*1024)
if _, err := f.WriteString(`{"type":"user","message":{"content":"hi"}}` + "\n"); err != nil {
t.Fatal(err)
}
// Single 5 MB assistant line with a harmless "noise" field plus the
// usage object we do care about.
line := `{"type":"assistant","noise":"` + pad + `","message":{"usage":{"input_tokens":11,"cache_creation_input_tokens":22,"cache_read_input_tokens":33}}}` + "\n"
if _, err := f.WriteString(line); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
got, err := parseLastUsage(p)
if err != nil {
t.Fatalf("parseLastUsage on 5 MB line: %v", err)
}
if got != 66 {
t.Errorf("got %d, want 66 (11+22+33)", got)
}
}
// spec:26174-planctl-context-tokens/R2.3+R2.6+D§3.5
// A line exceeding the scanner's 10 MB cap must error out cleanly (no panic)
// — the design specifically sizes the buffer at 10 MB; lines larger than
// that should surface a scanner error via parseLastUsage's sc.Err() check.
func TestParseLastUsage_oversizedLineErrors(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "huge.jsonl")
f, err := os.Create(p)
if err != nil {
t.Fatal(err)
}
pad := strings.Repeat("x", 11*1024*1024) // 11 MB > 10 MB buffer
line := `{"type":"assistant","noise":"` + pad + `","message":{"usage":{"input_tokens":1}}}` + "\n"
if _, err := f.WriteString(line); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
if _, err := parseLastUsage(p); err == nil {
t.Fatal("expected error on oversized line; got none")
}
}
// spec:26174-planctl-context-tokens/R2.1+D§3.5
// Zero-usage lines still qualify per PRD R2.1 (presence, not non-zero).
func TestParseLastUsage_zeroUsageStillQualifies(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "t.jsonl")
content := `{"type":"assistant","message":{"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}}
`
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
got, err := parseLastUsage(p)
if err != nil {
t.Fatalf("parseLastUsage: %v", err)
}
if got != 0 {
t.Errorf("got %d, want 0", got)
}
}
// spec:26174-planctl-context-tokens/R1.1+D§3.3
func TestResolveSessionUUID_envWins(t *testing.T) {
t.Setenv("CLAUDE_SESSION_ID", "env-uuid-12345")
got := resolveSessionUUID()
if got != "env-uuid-12345" {
t.Errorf("resolveSessionUUID = %q, want env-uuid-12345", got)
}
}
// spec:26174-planctl-context-tokens/R1.2+R6.1+R6.3+D§3.3
func TestResolveViaPIDWalk_pidWalkFinds(t *testing.T) {
dir := t.TempDir()
// Ancestor chain: 100 -> 200 (winner) -> 300 -> 1
parents := map[int]int{100: 200, 200: 300, 300: 1}
parentOf := func(pid int) int { return parents[pid] }
alive := func(pid int) bool { return pid == 200 }
stampOf := func(pid int) (int64, error) {
if pid == 200 {
return 1_700_000_000, nil
}
return 0, nil
}
p := filepath.Join(dir, "200")
if err := os.WriteFile(p, []byte("the-uuid\n1700000000\n"), 0o600); err != nil {
t.Fatal(err)
}
got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf)
if got != "the-uuid" {
t.Errorf("got %q, want the-uuid", got)
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.3
func TestResolveViaPIDWalk_staleSkipped(t *testing.T) {
dir := t.TempDir()
parents := map[int]int{100: 200, 200: 1}
parentOf := func(pid int) int { return parents[pid] }
// dead: returns false → skip
alive := func(_ int) bool { return false }
stampOf := func(_ int) (int64, error) { return 1_700_000_000, nil }
p := filepath.Join(dir, "200")
if err := os.WriteFile(p, []byte("the-uuid\n1700000000\n"), 0o600); err != nil {
t.Fatal(err)
}
got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf)
if got != "" {
t.Errorf("stale PID should not resolve, got %q", got)
}
}
// spec:26174-planctl-context-tokens/R6.3+D§3.3
func TestResolveViaPIDWalk_startTimeMismatch(t *testing.T) {
dir := t.TempDir()
parents := map[int]int{100: 200, 200: 1}
parentOf := func(pid int) int { return parents[pid] }
alive := func(_ int) bool { return true }
// file says 1700000000, actual says 9999999999 → mismatch → skip
stampOf := func(_ int) (int64, error) { return 9_999_999_999, nil }
p := filepath.Join(dir, "200")
if err := os.WriteFile(p, []byte("the-uuid\n1700000000\n"), 0o600); err != nil {
t.Fatal(err)
}
got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf)
if got != "" {
t.Errorf("start-time mismatch should not resolve, got %q", got)
}
}
// spec:26174-planctl-context-tokens/R1.4+D§3.3
func TestResolveViaPIDWalk_exhausted(t *testing.T) {
dir := t.TempDir()
// Deep chain with no files anywhere.
parentOf := func(pid int) int {
if pid <= 1 {
return 0
}
return pid - 1
}
alive := func(_ int) bool { return true }
stampOf := func(_ int) (int64, error) { return 0, nil }
got := resolveViaPIDWalk(100, dir, parentOf, alive, stampOf)
if got != "" {
t.Errorf("exhausted walk should return empty, got %q", got)
}
}
// spec:26174-planctl-context-tokens/R1.1+R1.5+R2.1+R2.2+R2.4+D§3.2
func TestReadTokenCtx_happyPath(t *testing.T) {
home := t.TempDir()
projectDir := "/path/to/proj"
slug := "-path-to-proj"
uuid := "abc-123"
dir := filepath.Join(home, ".claude", "projects", slug)
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
content := `{"type":"assistant","message":{"usage":{"input_tokens":10,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000}}}
`
if err := os.WriteFile(filepath.Join(dir, uuid+".jsonl"), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("HOME", home)
t.Setenv("CLAUDE_PROJECT_DIR", projectDir)
t.Setenv("CLAUDE_SESSION_ID", uuid)
t.Setenv("CLAUDE_CODE_MAX_CONTEXT_TOKENS", "2000")
ctx := ReadTokenCtx()
if ctx == nil {
t.Fatal("ReadTokenCtx returned nil, want non-nil")
}
if ctx.Used != 1110 {
t.Errorf("Used = %d, want 1110", ctx.Used)
}
if ctx.Limit != 2000 {
t.Errorf("Limit = %d, want 2000", ctx.Limit)
}
}
// spec:26174-planctl-context-tokens/R1.4+D§3.2
func TestReadTokenCtx_noSession(t *testing.T) {
t.Setenv("CLAUDE_SESSION_ID", "")
// Re-route PID walk to an empty dir so ancestor probing finds nothing.
empty := t.TempDir()
origDir := pidSessionDir
pidSessionDir = empty
t.Cleanup(func() { pidSessionDir = origDir })
if ctx := ReadTokenCtx(); ctx != nil {
t.Errorf("expected nil for no session, got %+v", ctx)
}
}