package convert import ( "context" "errors" "strings" "sync" "testing" "time" ) // fakeRunner records the args it was invoked with and replays canned // responses. Lets us assert command lines + binary refs + scratch // dirs without needing actual pandoc. type fakeRunner struct { mu sync.Mutex calls [][]string binaries []string stdin [][]byte scratchDir []string resp []byte err error } func (f *fakeRunner) Run(_ context.Context, binary string, stdin []byte, scratchDir string, cmd []string) ([]byte, error) { f.mu.Lock() defer f.mu.Unlock() f.calls = append(f.calls, append([]string(nil), cmd...)) f.binaries = append(f.binaries, binary) f.stdin = append(f.stdin, append([]byte(nil), stdin...)) f.scratchDir = append(f.scratchDir, scratchDir) return f.resp, f.err } func (f *fakeRunner) lastCall() (string, []string) { f.mu.Lock() defer f.mu.Unlock() if len(f.calls) == 0 { return "", nil } return f.binaries[len(f.binaries)-1], f.calls[len(f.calls)-1] } func TestConvert_Directions(t *testing.T) { cases := []struct { from, to string wantArgs []string // substrings that must appear in the pandoc command wantErr bool }{ {"docx", "md", []string{"--from=docx", "--to=gfm", "--wrap=none"}, false}, {"html", "md", []string{"--from=html", "--to=gfm", "--wrap=none"}, false}, {"docx", "html", []string{"--from=docx", "--to=html5", "--embed-resources"}, false}, {"html", "docx", []string{"--from=html", "--to=docx"}, false}, {"md", "docx", []string{"--from=markdown+yaml_metadata_block", "--to=docx"}, false}, {"md", "html", []string{"--from=markdown+yaml_metadata_block", "--to=html5"}, false}, {"docx", "pdf", nil, true}, // pdf is markdown-only {"docx", "docx", nil, true}, // same-format is unsupported {"html", "html", nil, true}, } for _, c := range cases { t.Run(c.from+"_to_"+c.to, func(t *testing.T) { f := &fakeRunner{resp: []byte("OUT")} InstallRunner(f) t.Cleanup(func() { InstallRunner(nil) }) SetBinaries("pandoc", "chromium-browser") _, err := Convert(context.Background(), c.from, c.to, []byte("x"), Metadata{}, TemplateSet{}) if c.wantErr { if err == nil { t.Fatalf("Convert(%s→%s): expected error, got nil", c.from, c.to) } return } if err != nil { t.Fatalf("Convert(%s→%s): %v", c.from, c.to, err) } binary, call := f.lastCall() if binary != "pandoc" { t.Errorf("expected pandoc, got %q", binary) } for _, want := range c.wantArgs { if !contains(call, want) { t.Errorf("Convert(%s→%s) missing %q in %v", c.from, c.to, want, call) } } // To-markdown directions inline images via the lua filter. if c.to == "md" { if !hasPrefArg(call, "--lua-filter=") || !hasSuffArg(call, "inline-media.lua") { t.Errorf("Convert(%s→md) missing inline-media.lua filter: %v", c.from, call) } } }) } } // hasPrefArg / hasSuffArg report whether any arg has the given prefix/suffix. func hasPrefArg(args []string, prefix string) bool { for _, a := range args { if strings.HasPrefix(a, prefix) { return true } } return false } func hasSuffArg(args []string, suffix string) bool { for _, a := range args { if strings.HasSuffix(a, suffix) { return true } } return false } func TestToDocx_UsesPandocBinary(t *testing.T) { f := &fakeRunner{resp: []byte("FAKE-DOCX")} InstallRunner(f) t.Cleanup(func() { InstallRunner(nil) }) SetBinaries("pandoc", "chromium-browser") out, err := ToDocx(context.Background(), []byte("# Hello\n"), Metadata{ Title: "Hello", Client: "Acme", }) if err != nil { t.Fatalf("ToDocx: %v", err) } if string(out) != "FAKE-DOCX" { t.Errorf("unexpected output: %q", out) } binary, call := f.lastCall() if binary != "pandoc" { t.Errorf("expected pandoc binary, got %q", binary) } if !contains(call, "--to=docx") { t.Errorf("missing --to=docx: %v", call) } if !contains(call, "title=Hello") { t.Errorf("missing title metadata: %v", call) } if !contains(call, "client=Acme") { t.Errorf("missing client metadata: %v", call) } // Last arg must be "-" so pandoc reads from stdin. if call[len(call)-1] != "-" { t.Errorf("expected stdin marker as last arg, got %q", call[len(call)-1]) } // ToDocx is stdin → stdout — no scratch dir needed. if f.scratchDir[len(f.scratchDir)-1] != "" { t.Errorf("ToDocx should not need a scratch dir, got %q", f.scratchDir[len(f.scratchDir)-1]) } } func TestToHTML_UsesTemplateFromScratchDir(t *testing.T) { f := &fakeRunner{resp: []byte("fake")} InstallRunner(f) t.Cleanup(func() { InstallRunner(nil) }) SetBinaries("pandoc", "chromium-browser") _, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{Title: "Hi"}, TemplateSet{}) if err != nil { t.Fatalf("ToHTML: %v", err) } binary, call := f.lastCall() if binary != "pandoc" { t.Errorf("expected pandoc binary, got %q", binary) } // Template flag must reference an absolute path under the scratch // dir (no /tpl indirection anymore — the wrapper bind-mounts the // scratch dir at its own path, so absolute host paths just work). scratch := f.scratchDir[len(f.scratchDir)-1] if scratch == "" { t.Fatalf("ToHTML must pass a scratch dir to the runner") } wantTpl := "--template=" + scratch + "/report.html" if !contains(call, wantTpl) { t.Errorf("template flag missing/wrong; want %q in %v", wantTpl, call) } if !contains(call, "--toc") { t.Errorf("TOC flag missing (default NoTOC=false): %v", call) } } func TestToHTML_NoTOCSuppressesTOC(t *testing.T) { f := &fakeRunner{resp: []byte("")} InstallRunner(f) t.Cleanup(func() { InstallRunner(nil) }) _, _ = ToHTML(context.Background(), []byte("# Hi\n"), Metadata{NoTOC: true}, TemplateSet{}) _, call := f.lastCall() if contains(call, "--toc") { t.Errorf("TOC should be suppressed when NoTOC=true: %v", call) } if !contains(call, "no-toc=true") { t.Errorf("no-toc metadata variable missing: %v", call) } } // recordingRunner records every call and returns canned responses in // sequence. Lets ToPDF tests assert the two-stage pipeline (pandoc // then chromium). type recordingRunner struct { mu sync.Mutex calls []recordedCall resp [][]byte err []error cursor int } type recordedCall struct { binary string cmd []string scratch string } func (r *recordingRunner) Run(_ context.Context, binary string, _ []byte, scratch string, cmd []string) ([]byte, error) { r.mu.Lock() defer r.mu.Unlock() r.calls = append(r.calls, recordedCall{ binary: binary, cmd: append([]string(nil), cmd...), scratch: scratch, }) if r.cursor >= len(r.resp) { return nil, nil } out := r.resp[r.cursor] var e error if r.cursor < len(r.err) { e = r.err[r.cursor] } r.cursor++ return out, e } func TestScratchDir_UsedByToHTML(t *testing.T) { f := &fakeRunner{resp: []byte("")} InstallRunner(f) t.Cleanup(func() { InstallRunner(nil); SetScratchDir("") }) scratchRoot := t.TempDir() SetScratchDir(scratchRoot) _, err := ToHTML(context.Background(), []byte("# Hi\n"), Metadata{}, TemplateSet{}) if err != nil { t.Fatalf("ToHTML: %v", err) } if len(f.scratchDir) == 0 { t.Fatalf("expected a scratch dir to be passed to the runner") } got := f.scratchDir[0] if !strings.HasPrefix(got, scratchRoot+"/") { t.Errorf("scratch dir not under configured root: %q (root=%q)", got, scratchRoot) } } func TestToPDF_TwoStagePipeline(t *testing.T) { // Stage 1: pandoc emits HTML. Stage 2: chromium reads HTML from // the scratch dir and writes out.pdf there. The fake runner can't // actually write the PDF, so we expect ToPDF to fail at the // read-back step — but we can still assert the two-stage call // shape and the right binary per stage. r := &recordingRunner{ resp: [][]byte{ []byte("fake"), // stage 1 stdout nil, // stage 2 stdout (chromium writes PDF to scratch) }, } InstallRunner(r) t.Cleanup(func() { InstallRunner(nil) }) SetBinaries("pandoc", "chromium-browser") _, err := ToPDF(context.Background(), []byte("# Hi\n"), Metadata{}, TemplateSet{}) // PDF read-back will fail (fake runner didn't write the file) — // that's expected for this test which only inspects the call shape. if err == nil { t.Fatalf("expected error from PDF read-back; got nil") } if len(r.calls) != 2 { t.Fatalf("expected 2 calls (pandoc + chromium); got %d", len(r.calls)) } if r.calls[0].binary != "pandoc" { t.Errorf("stage 1 binary: got %q want pandoc", r.calls[0].binary) } if r.calls[1].binary != "chromium-browser" { t.Errorf("stage 2 binary: got %q want chromium-browser", r.calls[1].binary) } // Stage 2 must include --print-to-pdf pointing at an absolute // path under the scratch dir. stage2 := r.calls[1] if stage2.scratch == "" { t.Fatalf("chromium call must have a scratch dir") } wantPDF := "--print-to-pdf=" + stage2.scratch + "/out.pdf" if !contains(stage2.cmd, wantPDF) { t.Errorf("chromium call missing --print-to-pdf=%s/out.pdf: %v", stage2.scratch, stage2.cmd) } if !contains(stage2.cmd, "--no-sandbox") { t.Errorf("chromium call missing --no-sandbox: %v", stage2.cmd) } // Stage 2 chromium reads file:///in.html. wantHTML := "file://" + stage2.scratch + "/in.html" if !contains(stage2.cmd, wantHTML) { t.Errorf("chromium call missing file:// URL: %v", stage2.cmd) } } func TestErrUnavailable_WhenNoRunner(t *testing.T) { InstallRunner(nil) _, err := ToDocx(context.Background(), []byte("x"), Metadata{}) if !errors.Is(err, ErrUnavailable) { t.Errorf("expected ErrUnavailable, got %v", err) } } func TestMetadataArgs_OmitsEmptyAndOrdersStably(t *testing.T) { args := metadataArgs(Metadata{ Title: "T", Project: "P", GenerationTime: time.Date(2026, 5, 13, 14, 30, 22, 0, time.UTC), }) want := []string{ "-V", "title=T", "-V", "project=P", } for i, w := range want { if i >= len(args) || args[i] != w { t.Fatalf("args[%d]: got %v want prefix %v", i, args, want) } } joined := strings.Join(args, "|") if !strings.Contains(joined, "generation_time=") || !strings.Contains(joined, "2026") { t.Errorf("generation_time missing or malformed: %v", args) } if strings.Contains(joined, "client=") { t.Errorf("empty client should not be passed: %v", args) } } func TestSingleflight_Collapses(t *testing.T) { var g singleflightGroup const N = 50 var wg sync.WaitGroup var hits int32 var mu sync.Mutex wg.Add(N) for i := 0; i < N; i++ { go func() { defer wg.Done() _, _ = g.Do("k", func() (any, error) { mu.Lock() hits++ mu.Unlock() time.Sleep(20 * time.Millisecond) return "v", nil }) }() } wg.Wait() if hits != 1 { t.Errorf("singleflight collapse: got %d invocations, want 1", hits) } } // contains reports whether haystack has needle as any of its elements. func contains(haystack []string, needle string) bool { for _, s := range haystack { if s == needle { return true } } return false }