package pdftext_test // P5a task 02: Extract (the child's work), ChildMain (the `sift pdftext` entry point) and Runner // (the parent: exec, deadline, kill, parse). // // The Runner tests run the test binary itself as the child: TestMain turns into the child when // SIFT_PDFTEXT_CHILD=1 is set, exactly as `sift pdftext` would. The two other variables are test // hooks that live only here: SIFT_PDFTEXT_SLEEP delays the child (for the deadline test) and // SIFT_PDFTEXT_PIDFILE records its pid (to prove it is gone afterwards). import ( "bufio" "bytes" "context" "encoding/json" "errors" "os" "path/filepath" "strconv" "strings" "syscall" "testing" "time" "git.wntrmute.dev/kyle/sift/internal/pdftest" "git.wntrmute.dev/kyle/sift/internal/pdftext" ) func TestMain(m *testing.M) { if os.Getenv("SIFT_PDFTEXT_CHILD") == "1" { if p := os.Getenv("SIFT_PDFTEXT_PIDFILE"); p != "" { _ = os.WriteFile(p, []byte(strconv.Itoa(os.Getpid())), 0o600) } if d, err := time.ParseDuration(os.Getenv("SIFT_PDFTEXT_SLEEP")); err == nil { time.Sleep(d) } os.Exit(pdftext.ChildMain(os.Args[1:], os.Stdin, os.Stdout)) } os.Exit(m.Run()) } func three() []byte { return pdftest.Build(pdftest.Doc{ Title: "Three Pages", Pages: []string{ "Chapter one begins here.\nSecond line (with parens).", "The quick brown fox is on page two.", pdftest.Lorem(3, 5), }, Outline: []pdftest.Outline{{Title: "1 Intro", Page: 1, Level: 1}, {Title: "2 Fox", Page: 2, Level: 1}, {Title: "3 Filler", Page: 3, Level: 1}}, }) } type line struct { Meta *pdftext.Meta `json:"meta"` Page int `json:"page"` Text *string `json:"text"` Error string `json:"error"` } func lines(t *testing.T, out []byte) []line { t.Helper() var ls []line sc := bufio.NewScanner(bytes.NewReader(out)) sc.Buffer(make([]byte, 1<<20), 64<<20) for sc.Scan() { var l line if err := json.Unmarshal(sc.Bytes(), &l); err != nil { t.Fatalf("output line is not JSON: %q (%v)", sc.Text(), err) } ls = append(ls, l) } return ls } func TestExtractWritesMetaThenPages(t *testing.T) { var out bytes.Buffer if err := pdftext.Extract(three(), 2000, &out); err != nil { t.Fatal(err) } ls := lines(t, out.Bytes()) if len(ls) != 4 || ls[0].Meta == nil { t.Fatalf("got %d lines, first meta=%v; want a meta line then 3 page lines:\n%s", len(ls), ls[0].Meta, out.String()) } m := ls[0].Meta if m.Pages != 3 || m.Title != "Three Pages" { t.Errorf("meta = %+v, want 3 pages titled Three Pages", *m) } want := []pdftext.OutlineItem{{Title: "1 Intro", Page: 1, Level: 1}, {Title: "2 Fox", Page: 2, Level: 1}, {Title: "3 Filler", Page: 3, Level: 1}} if len(m.Outline) != 3 { t.Fatalf("outline = %+v, want %+v", m.Outline, want) } for i := range want { if m.Outline[i] != want[i] { t.Errorf("outline[%d] = %+v, want %+v", i, m.Outline[i], want[i]) } } for i, l := range ls[1:] { if l.Page != i+1 || l.Text == nil || l.Error != "" { t.Errorf("line %d = %+v, want page %d with text", i+1, l, i+1) } } if !strings.Contains(*ls[1].Text, "Chapter one begins here.") || !strings.Contains(*ls[2].Text, "quick brown fox") || !strings.Contains(*ls[3].Text, "p3 l5") { t.Errorf("page texts wrong: %q / %q / %q", *ls[1].Text, *ls[2].Text, *ls[3].Text) } } func TestExtractPageCap(t *testing.T) { var out bytes.Buffer if err := pdftext.Extract(three(), 2, &out); err != nil { t.Fatal(err) } ls := lines(t, out.Bytes()) if len(ls) != 3 || ls[0].Meta.Pages != 3 || ls[2].Page != 2 { t.Errorf("maxPages 2: %d lines, meta %+v; want meta (pages 3) and pages 1-2", len(ls), ls[0].Meta) } } func TestExtractNoOutline(t *testing.T) { var out bytes.Buffer if err := pdftext.Extract(pdftest.Build(pdftest.Doc{Pages: []string{"a", "b"}}), 10, &out); err != nil { t.Fatal(err) } ls := lines(t, out.Bytes()) if ls[0].Meta.Outline == nil || len(ls[0].Meta.Outline) != 0 { t.Errorf("outline = %#v, want an empty non-nil slice (JSON [])", ls[0].Meta.Outline) } if !strings.Contains(out.String(), `"outline":[]`) { t.Errorf("meta line must carry \"outline\":[]: %s", out.String()) } } func TestExtractErrors(t *testing.T) { enc, err := os.ReadFile(filepath.Join("..", "..", "tests", "testdata", "pdf", "encrypted-user.pdf")) if err != nil { t.Fatal(err) } if err := pdftext.Extract(enc, 10, &bytes.Buffer{}); !errors.Is(err, pdftext.ErrEncrypted) { t.Errorf("user-password pdf: %v, want ErrEncrypted", err) } owner, err := os.ReadFile(filepath.Join("..", "..", "tests", "testdata", "pdf", "encrypted-owner.pdf")) if err != nil { t.Fatal(err) } var out bytes.Buffer if err := pdftext.Extract(owner, 10, &out); err != nil || !strings.Contains(out.String(), "quick brown fox") { t.Errorf("owner-password-only pdf: err %v; want its text", err) } if err := pdftext.Extract([]byte("not a pdf at all"), 10, &bytes.Buffer{}); !errors.Is(err, pdftext.ErrUnreadable) { t.Errorf("garbage: %v, want ErrUnreadable", err) } // A truncated file either yields what it can or ErrUnreadable; it never panics. b := three() _ = pdftext.Extract(b[:len(b)*6/10], 10, &bytes.Buffer{}) } func TestChildMainExitCodes(t *testing.T) { enc, _ := os.ReadFile(filepath.Join("..", "..", "tests", "testdata", "pdf", "encrypted-user.pdf")) for _, tc := range []struct { name string in []byte code int }{ {"ok", three(), 0}, {"encrypted", enc, 3}, {"unreadable", []byte("nope"), 4}, } { var out bytes.Buffer if code := pdftext.ChildMain([]string{"-max-pages", "5"}, bytes.NewReader(tc.in), &out); code != tc.code { t.Errorf("%s: exit %d, want %d", tc.name, code, tc.code) } } } func runner(env ...string) *pdftext.Runner { return &pdftext.Runner{Exe: os.Args[0], Env: append([]string{"SIFT_PDFTEXT_CHILD=1"}, env...), Deadline: 20 * time.Second} } func TestRunnerRun(t *testing.T) { doc, err := runner().Run(context.Background(), three()) if err != nil { t.Fatal(err) } if doc.Pages != 3 || doc.Title != "Three Pages" || len(doc.Outline) != 3 || len(doc.Text) != 3 { t.Fatalf("doc = %+v", doc) } if !strings.Contains(doc.Text[1], "quick brown fox") { t.Errorf("page 2 = %q", doc.Text[1]) } } func TestRunnerErrors(t *testing.T) { enc, _ := os.ReadFile(filepath.Join("..", "..", "tests", "testdata", "pdf", "encrypted-user.pdf")) if _, err := runner().Run(context.Background(), enc); !errors.Is(err, pdftext.ErrEncrypted) { t.Errorf("encrypted: %v, want ErrEncrypted", err) } if _, err := runner().Run(context.Background(), []byte("nope")); !errors.Is(err, pdftext.ErrUnreadable) { t.Errorf("garbage: %v, want ErrUnreadable", err) } scanned := pdftest.Build(pdftest.Doc{Pages: []string{"", "", ""}, Scanned: []int{1, 2, 3}}) if _, err := runner().Run(context.Background(), scanned); !errors.Is(err, pdftext.ErrNoText) { t.Errorf("scanned: %v, want ErrNoText", err) } r := runner() r.MaxText = 100 // bytes of child output if _, err := r.Run(context.Background(), three()); !errors.Is(err, pdftext.ErrTooMuchText) { t.Errorf("output over MaxText: %v, want ErrTooMuchText", err) } } // The child is killed at the deadline and reaped: no process is left behind. func TestRunnerDeadlineKillsTheChild(t *testing.T) { pidfile := filepath.Join(t.TempDir(), "pid") r := runner("SIFT_PDFTEXT_SLEEP=30s", "SIFT_PDFTEXT_PIDFILE="+pidfile) r.Deadline = 300 * time.Millisecond start := time.Now() _, err := r.Run(context.Background(), three()) if !errors.Is(err, pdftext.ErrTimeout) { t.Fatalf("err %v, want ErrTimeout", err) } if d := time.Since(start); d > 3*time.Second { t.Errorf("Run returned after %v; the 300 ms deadline was not enforced", d) } b, err := os.ReadFile(pidfile) if err != nil { t.Fatalf("child never wrote its pid: %v", err) } pid, _ := strconv.Atoi(string(b)) if err := syscall.Kill(pid, 0); !errors.Is(err, syscall.ESRCH) { t.Errorf("child %d still exists after Run returned (kill -0: %v)", pid, err) } } // The caller's context cancels the child too. func TestRunnerContextCancel(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) defer cancel() _, err := runner("SIFT_PDFTEXT_SLEEP=30s").Run(ctx, three()) if err == nil { t.Fatal("cancelled run succeeded") } } // Runner passes only Env to the child: nothing of the parent's environment leaks in. func TestRunnerEnvIsExplicit(t *testing.T) { t.Setenv("SIFT_PDFTEXT_SLEEP", "30s") // in the parent only r := runner() r.Deadline = 5 * time.Second if _, err := r.Run(context.Background(), three()); err != nil { t.Errorf("parent env leaked into the child (it slept): %v", err) } }