Files
goclaw/internal/tools/document_parser_test.go
Duy /zuey/andGitHub 9ab3d6dfe5 feat(tools): local-first document extraction for read_document
Squash merge PR #111 after resolving docs/changelog conflicts. Local checks covered tools/config and both Go builds; PR CI run 26704622503 passed release-versioning, go, and web.
2026-05-31 12:55:38 +07:00

200 lines
6.9 KiB
Go

package tools
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// writeStubScript writes an executable POSIX stub and returns its path.
func writeStubScript(t *testing.T, dir, name, body string) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(body), 0o755); err != nil {
t.Fatalf("write stub %s: %v", name, err)
}
return p
}
// stubLookPath returns a lookPath that resolves only the given binary→path map.
func stubLookPath(m map[string]string) func(string) (string, error) {
return func(name string) (string, error) {
if p, ok := m[name]; ok {
return p, nil
}
return "", errors.New("not found: " + name)
}
}
func newStubParser(cfg LocalExtractConfig, lp func(string) (string, error)) *LocalExtractParser {
p := NewLocalExtractParser(cfg)
p.lookPath = lp
return p
}
func TestLocalExtractParser_SupportsRespectsEnabledAndBinary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX stub scripts not runnable on Windows")
}
dir := t.TempDir()
pdftotext := writeStubScript(t, dir, "pdftotext", "#!/bin/sh\nprintf 'x'\n")
// Disabled => never supports, even with a resolvable binary.
off := newStubParser(LocalExtractConfig{Enabled: false}, stubLookPath(map[string]string{"pdftotext": pdftotext}))
if off.Supports(mimePDF) {
t.Error("disabled parser should not support any mime")
}
// Enabled + binary present => supported; binary absent => not.
on := newStubParser(LocalExtractConfig{Enabled: true}, stubLookPath(map[string]string{"pdftotext": pdftotext}))
if !on.Supports(mimePDF) {
t.Error("expected PDF supported when pdftotext resolvable")
}
if on.Supports(mimeDOCX) {
t.Error("expected DOCX unsupported when pandoc absent")
}
if on.Supports("text/plain") {
t.Error("expected unsupported mime to be false")
}
}
func TestLocalExtractParser_ExtractSuccessPDFAndDOCX(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX stub scripts not runnable on Windows")
}
dir := t.TempDir()
pdftotext := writeStubScript(t, dir, "pdftotext", "#!/bin/sh\nprintf 'pdf text content here'\n")
pandoc := writeStubScript(t, dir, "pandoc", "#!/bin/sh\nprintf 'docx text content here'\n")
p := newStubParser(LocalExtractConfig{Enabled: true},
stubLookPath(map[string]string{"pdftotext": pdftotext, "pandoc": pandoc}))
got, err := p.Extract(context.Background(), "/tmp/doc.pdf", mimePDF)
if err != nil {
t.Fatalf("pdf extract: %v", err)
}
if got != "pdf text content here" {
t.Errorf("pdf text = %q", got)
}
got, err = p.Extract(context.Background(), "/tmp/doc.docx", mimeDOCX)
if err != nil {
t.Fatalf("docx extract: %v", err)
}
if got != "docx text content here" {
t.Errorf("docx text = %q", got)
}
}
func TestLocalExtractParser_ExtractEmptyOutput(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX stub scripts not runnable on Windows")
}
dir := t.TempDir()
empty := writeStubScript(t, dir, "pdftotext", "#!/bin/sh\nprintf ' \\n \\t '\n")
p := newStubParser(LocalExtractConfig{Enabled: true}, stubLookPath(map[string]string{"pdftotext": empty}))
_, err := p.Extract(context.Background(), "/tmp/scan.pdf", mimePDF)
if !errors.Is(err, ErrParserEmpty) {
t.Fatalf("expected ErrParserEmpty, got %v", err)
}
}
func TestLocalExtractParser_ExtractBinaryAbsent(t *testing.T) {
p := newStubParser(LocalExtractConfig{Enabled: true}, stubLookPath(map[string]string{}))
_, err := p.Extract(context.Background(), "/tmp/doc.pdf", mimePDF)
if !errors.Is(err, ErrParserUnavailable) {
t.Fatalf("expected ErrParserUnavailable, got %v", err)
}
}
func TestLocalExtractParser_ExtractDisabled(t *testing.T) {
p := newStubParser(LocalExtractConfig{Enabled: false}, stubLookPath(map[string]string{}))
_, err := p.Extract(context.Background(), "/tmp/doc.pdf", mimePDF)
if !errors.Is(err, ErrParserUnsupported) {
t.Fatalf("expected ErrParserUnsupported when disabled, got %v", err)
}
}
func TestLocalExtractParser_ExtractUnsupportedMime(t *testing.T) {
p := newStubParser(LocalExtractConfig{Enabled: true}, stubLookPath(map[string]string{}))
_, err := p.Extract(context.Background(), "/tmp/a.txt", "text/plain")
if !errors.Is(err, ErrParserUnsupported) {
t.Fatalf("expected ErrParserUnsupported, got %v", err)
}
}
func TestLocalExtractParser_ExtractNonZeroExit(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX stub scripts not runnable on Windows")
}
dir := t.TempDir()
failing := writeStubScript(t, dir, "pdftotext", "#!/bin/sh\necho boom >&2\nexit 1\n")
p := newStubParser(LocalExtractConfig{Enabled: true}, stubLookPath(map[string]string{"pdftotext": failing}))
_, err := p.Extract(context.Background(), "/tmp/doc.pdf", mimePDF)
if err == nil {
t.Fatal("expected error on non-zero exit")
}
// Must not be a sentinel that hides the real failure.
if errors.Is(err, ErrParserEmpty) {
t.Errorf("non-zero exit should not map to ErrParserEmpty: %v", err)
}
}
func TestLocalExtractParser_ExtractTruncatesAtCap(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX stub scripts not runnable on Windows")
}
dir := t.TempDir()
// Emit well over the 500KB cap so the draining writer marks overflow.
big := writeStubScript(t, dir, "pdftotext", "#!/bin/sh\nyes A | head -c 600000\n")
p := newStubParser(LocalExtractConfig{Enabled: true}, stubLookPath(map[string]string{"pdftotext": big}))
got, err := p.Extract(context.Background(), "/tmp/big.pdf", mimePDF)
if err != nil {
t.Fatalf("extract: %v", err)
}
if !strings.HasSuffix(got, docTruncationMarker) {
t.Errorf("expected truncation marker suffix; tail=%q", got[max(0, len(got)-40):])
}
if len(got) > documentMaxTextBytes+len(docTruncationMarker) {
t.Errorf("output %d exceeds cap %d + marker", len(got), documentMaxTextBytes)
}
}
// TestLocalExtractParser_TimeoutReapsProcessGroup proves a timeout kills the
// whole process group (not just the direct child): the stub forks a grandchild
// sleep, records its PID, and the test asserts that PID is gone afterwards.
func TestLocalExtractParser_TimeoutReapsProcessGroup(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("process-group kill not supported on Windows")
}
dir := t.TempDir()
pidFile := filepath.Join(dir, "child.pid")
body := fmt.Sprintf("#!/bin/sh\nsleep 30 &\necho $! > %s\nwait\n", pidFile)
slow := writeStubScript(t, dir, "pdftotext", body)
p := newStubParser(LocalExtractConfig{Enabled: true, Timeout: 1 * time.Second},
stubLookPath(map[string]string{"pdftotext": slow}))
got, err := p.Extract(context.Background(), "/tmp/slow.pdf", mimePDF)
if err == nil {
t.Fatal("expected timeout error")
}
if got != "" {
t.Errorf("expected no partial text on timeout, got %d bytes", len(got))
}
pids := waitForRecordedPIDs(t, pidFile, 1, 2*time.Second)
time.Sleep(200 * time.Millisecond) // let the OS reap the killed group
if orphans := findLivePIDs(t, pids); len(orphans) > 0 {
t.Errorf("grandchild sleep not reaped after timeout: %v", orphans)
}
}