1
0

main_test.go 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849
  1. package main
  2. import (
  3. "bytes"
  4. "context"
  5. "errors"
  6. "fmt"
  7. "image"
  8. "image/jpeg"
  9. _ "image/png"
  10. "net/http"
  11. "net/http/httptest"
  12. "net/url"
  13. "os"
  14. "path/filepath"
  15. "strings"
  16. "sync/atomic"
  17. "testing"
  18. "git.monoexp.com/bajdev/rekopub/internal/epubtest"
  19. "git.monoexp.com/bajdev/rekopub/internal/pipeline"
  20. )
  21. // fakeConverter returns a fixed book and counts how often it was called, so
  22. // tests can check that nothing is fetched when arguments are rejected.
  23. type fakeConverter struct {
  24. title string
  25. err error
  26. calls int
  27. kepub bool // the KEPUB option of the last call
  28. noLocal bool // the NoLocal option of the last call
  29. }
  30. func (f *fakeConverter) convert(ctx context.Context, u *url.URL, opts pipeline.Options) (*pipeline.Book, error) {
  31. f.calls++
  32. f.kepub = opts.KEPUB
  33. f.noLocal = opts.NoLocal
  34. if f.err != nil {
  35. return nil, f.err
  36. }
  37. if opts.BeforeImages != nil {
  38. if err := opts.BeforeImages(f.title); err != nil {
  39. return nil, err
  40. }
  41. }
  42. return &pipeline.Book{Title: f.title, Content: strings.NewReader("epub:" + f.title)}, nil
  43. }
  44. type result struct {
  45. code int
  46. stdout, stderr string
  47. }
  48. func runCLI(t *testing.T, conv *fakeConverter, args ...string) result {
  49. t.Helper()
  50. var stdout, stderr bytes.Buffer
  51. code := run(context.Background(), args, &stdout, &stderr, conv.convert)
  52. return result{code, stdout.String(), stderr.String()}
  53. }
  54. const testURL = "https://example.com/article"
  55. func TestUsageErrors(t *testing.T) {
  56. dir := t.TempDir()
  57. out := filepath.Join(dir, "a.epub")
  58. tests := []struct {
  59. name string
  60. args []string
  61. want string // expected in stderr
  62. }{
  63. {"no arguments", nil, "-URL is required"},
  64. {"missing -SAVE", []string{"-URL", testURL}, "-SAVE is required"},
  65. {"missing -URL", []string{"-SAVE", out}, "-URL is required"},
  66. {"lowercase flag", []string{"-url", testURL, "-SAVE", out}, "flag provided but not defined: -url"},
  67. {"extra argument", []string{"-URL", testURL, "-SAVE", out, "extra"}, `unexpected argument "extra"`},
  68. {"ftp URL", []string{"-URL", "ftp://example.com/a", "-SAVE", out}, "must start with http:// or https://"},
  69. {"no scheme", []string{"-URL", "example.com/a", "-SAVE", out}, "must start with http:// or https://"},
  70. {"file URL", []string{"-URL", "file:///etc/passwd", "-SAVE", out}, "must start with http:// or https://"},
  71. {"local path", []string{"-URL", "/etc/passwd", "-SAVE", out}, "must start with http:// or https://"},
  72. {"Windows path", []string{"-URL", `C:\Users\me\notes.html`, "-SAVE", out}, "-URL"},
  73. {"UNC path", []string{"-URL", `\\server\share\page.html`, "-SAVE", out}, "must start with http:// or https://"},
  74. {"no host", []string{"-URL", "https://", "-SAVE", out}, "missing host name"},
  75. {"invalid URL", []string{"-URL", "https://exa mple.com/%zz", "-SAVE", out}, "not a valid URL"},
  76. {"kepub name without -KEPUB", []string{"-URL", testURL, "-SAVE", filepath.Join(dir, "a.kepub.epub")}, "needs -KEPUB"},
  77. }
  78. for _, tt := range tests {
  79. t.Run(tt.name, func(t *testing.T) {
  80. conv := &fakeConverter{title: "T"}
  81. r := runCLI(t, conv, tt.args...)
  82. if r.code != exitUsage {
  83. t.Errorf("exit code = %d, want %d", r.code, exitUsage)
  84. }
  85. if !strings.Contains(r.stderr, tt.want) {
  86. t.Errorf("stderr = %q, want it to contain %q", r.stderr, tt.want)
  87. }
  88. if !strings.Contains(r.stderr, "Usage: rekopub") {
  89. t.Errorf("stderr does not show usage: %q", r.stderr)
  90. }
  91. if conv.calls != 0 {
  92. t.Errorf("converter called %d times, want 0", conv.calls)
  93. }
  94. if r.stdout != "" {
  95. t.Errorf("stdout = %q, want empty", r.stdout)
  96. }
  97. })
  98. }
  99. }
  100. func TestVersion(t *testing.T) {
  101. conv := &fakeConverter{}
  102. r := runCLI(t, conv, "-VERSION")
  103. if r.code != exitOK || !strings.HasPrefix(r.stdout, "rekopub ") || len(strings.TrimSpace(r.stdout)) <= len("rekopub") {
  104. t.Errorf("exit code = %d, stdout = %q", r.code, r.stdout)
  105. }
  106. if conv.calls != 0 {
  107. t.Errorf("-VERSION ran a conversion")
  108. }
  109. defer func(v string) { version = v }(version)
  110. version = "v1.2.3"
  111. if r := runCLI(t, conv, "-VERSION"); r.stdout != "rekopub v1.2.3\n" {
  112. t.Errorf("stdout = %q, want the version set at build time", r.stdout)
  113. }
  114. }
  115. func TestInterrupted(t *testing.T) {
  116. dir := t.TempDir()
  117. conv := &fakeConverter{err: fmt.Errorf("%w: %w", pipeline.ErrFetch, context.Canceled)}
  118. r := runCLI(t, conv, "-URL", testURL, "-SAVE", dir)
  119. if r.code != exitInterrupted || strings.TrimSpace(r.stderr) != "error: interrupted" {
  120. t.Errorf("exit code = %d, stderr = %q; want %d and \"error: interrupted\"", r.code, r.stderr, exitInterrupted)
  121. }
  122. assertEmptyDir(t, dir)
  123. }
  124. func TestHelp(t *testing.T) {
  125. r := runCLI(t, &fakeConverter{}, "-h")
  126. if r.code != exitOK {
  127. t.Errorf("exit code = %d, want %d", r.code, exitOK)
  128. }
  129. if !strings.Contains(r.stderr, "Usage: rekopub") {
  130. t.Errorf("stderr = %q, want usage", r.stderr)
  131. }
  132. }
  133. func TestSaveToFile(t *testing.T) {
  134. path := filepath.Join(t.TempDir(), "a.epub")
  135. r := runCLI(t, &fakeConverter{title: "Title"}, "-URL", testURL, "-SAVE", path)
  136. if r.code != exitOK {
  137. t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr)
  138. }
  139. if strings.TrimSpace(r.stdout) != path {
  140. t.Errorf("stdout = %q, want %q", r.stdout, path)
  141. }
  142. assertFile(t, path, "epub:Title")
  143. }
  144. func TestSaveToFolderCreatesIt(t *testing.T) {
  145. dir := filepath.Join(t.TempDir(), "Books", "Articles")
  146. r := runCLI(t, &fakeConverter{title: "My Great Article"}, "-URL", testURL, "-SAVE", dir+"/")
  147. if r.code != exitOK {
  148. t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr)
  149. }
  150. want := filepath.Join(dir, "my-great-article.epub")
  151. if strings.TrimSpace(r.stdout) != want {
  152. t.Errorf("stdout = %q, want %q", r.stdout, want)
  153. }
  154. assertFile(t, want, "epub:My Great Article")
  155. }
  156. func TestExistingFileStopsBeforeFetching(t *testing.T) {
  157. path := filepath.Join(t.TempDir(), "a.epub")
  158. writeFile(t, path, "old")
  159. conv := &fakeConverter{title: "Title"}
  160. r := runCLI(t, conv, "-URL", testURL, "-SAVE", path)
  161. if r.code != exitExists {
  162. t.Errorf("exit code = %d, want %d", r.code, exitExists)
  163. }
  164. if !strings.Contains(r.stderr, "already exists (use -FORCE to overwrite)") {
  165. t.Errorf("stderr = %q", r.stderr)
  166. }
  167. if conv.calls != 0 {
  168. t.Errorf("converter called %d times, want 0", conv.calls)
  169. }
  170. assertFile(t, path, "old")
  171. }
  172. func TestExistingFileInFolder(t *testing.T) {
  173. dir := t.TempDir()
  174. path := filepath.Join(dir, "title.epub")
  175. writeFile(t, path, "old")
  176. r := runCLI(t, &fakeConverter{title: "Title"}, "-URL", testURL, "-SAVE", dir)
  177. if r.code != exitExists {
  178. t.Errorf("exit code = %d, want %d; stderr = %q", r.code, exitExists, r.stderr)
  179. }
  180. assertFile(t, path, "old")
  181. }
  182. func TestForceOverwrites(t *testing.T) {
  183. dir := t.TempDir()
  184. path := filepath.Join(dir, "a.epub")
  185. writeFile(t, path, "old")
  186. r := runCLI(t, &fakeConverter{title: "Title"}, "-URL", testURL, "-SAVE", path, "-FORCE")
  187. if r.code != exitOK {
  188. t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr)
  189. }
  190. assertFile(t, path, "epub:Title")
  191. // -FORCE also applies to folder targets.
  192. writeFile(t, filepath.Join(dir, "title.epub"), "old")
  193. r = runCLI(t, &fakeConverter{title: "Title"}, "-URL", testURL, "-SAVE", dir, "-FORCE")
  194. if r.code != exitOK {
  195. t.Fatalf("folder: exit code = %d, stderr = %q", r.code, r.stderr)
  196. }
  197. assertFile(t, filepath.Join(dir, "title.epub"), "epub:Title")
  198. }
  199. func TestConversionFailureWritesNothing(t *testing.T) {
  200. dir := t.TempDir()
  201. conv := &fakeConverter{err: errors.New("boom")}
  202. r := runCLI(t, conv, "-URL", testURL, "-SAVE", filepath.Join(dir, "a.epub"))
  203. if r.code != exitBuild {
  204. t.Errorf("exit code = %d, want %d", r.code, exitBuild)
  205. }
  206. if !strings.Contains(r.stderr, "error: boom") {
  207. t.Errorf("stderr = %q", r.stderr)
  208. }
  209. assertEmptyDir(t, dir)
  210. }
  211. func TestSavePathThroughFile(t *testing.T) {
  212. dir := t.TempDir()
  213. writeFile(t, filepath.Join(dir, "Books"), "not a folder")
  214. r := runCLI(t, &fakeConverter{title: "Title"}, "-URL", testURL, "-SAVE", filepath.Join(dir, "Books", "a.epub"))
  215. if r.code != exitBuild {
  216. t.Errorf("exit code = %d, want %d; stderr = %q", r.code, exitBuild, r.stderr)
  217. }
  218. }
  219. func TestKEPUBExtensionNote(t *testing.T) {
  220. dir := t.TempDir()
  221. writeFile(t, filepath.Join(dir, "a.kepub.epub"), "old")
  222. // The overwrite check uses the adjusted name, before anything is fetched.
  223. conv := &fakeConverter{title: "Title"}
  224. r := runCLI(t, conv, "-URL", testURL, "-SAVE", filepath.Join(dir, "a.epub"), "-KEPUB")
  225. if !strings.Contains(r.stderr, "note: saving as a.kepub.epub") {
  226. t.Errorf("stderr = %q, want a note about the new name", r.stderr)
  227. }
  228. if r.code != exitExists || conv.calls != 0 {
  229. t.Errorf("exit code = %d, converter calls = %d; want %d and 0", r.code, conv.calls, exitExists)
  230. }
  231. }
  232. func TestKEPUBOption(t *testing.T) {
  233. dir := t.TempDir()
  234. for _, kepub := range []bool{false, true} {
  235. conv := &fakeConverter{title: "Title"}
  236. args := []string{"-URL", testURL, "-SAVE", dir, "-FORCE"}
  237. want := filepath.Join(dir, "title.epub")
  238. if kepub {
  239. args = append(args, "-KEPUB")
  240. want = filepath.Join(dir, "title.kepub.epub")
  241. }
  242. r := runCLI(t, conv, args...)
  243. if r.code != exitOK {
  244. t.Fatalf("kepub=%v: exit code = %d, stderr = %q", kepub, r.code, r.stderr)
  245. }
  246. if conv.kepub != kepub {
  247. t.Errorf("kepub=%v: pipeline got KEPUB=%v", kepub, conv.kepub)
  248. }
  249. if strings.TrimSpace(r.stdout) != want {
  250. t.Errorf("kepub=%v: saved to %q, want %q", kepub, r.stdout, want)
  251. }
  252. }
  253. }
  254. func TestNoLocalOption(t *testing.T) {
  255. for _, noLocal := range []bool{false, true} {
  256. conv := &fakeConverter{title: "Title"}
  257. args := []string{"-URL", testURL, "-SAVE", t.TempDir()}
  258. if noLocal {
  259. args = append(args, "-NOLOCAL")
  260. }
  261. if r := runCLI(t, conv, args...); r.code != exitOK {
  262. t.Fatalf("noLocal=%v: exit code = %d, stderr = %q", noLocal, r.code, r.stderr)
  263. }
  264. if conv.noLocal != noLocal {
  265. t.Errorf("noLocal=%v: pipeline got NoLocal=%v", noLocal, conv.noLocal)
  266. }
  267. }
  268. }
  269. // TestNoLocalRefusesLocalPage runs the real pipeline: without -NOLOCAL a
  270. // page on localhost may be converted, with it the page isn't even fetched.
  271. func TestNoLocalRefusesLocalPage(t *testing.T) {
  272. blog, err := os.ReadFile("../../testdata/pages/blog.html")
  273. if err != nil {
  274. t.Fatal(err)
  275. }
  276. var requests atomic.Int32
  277. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  278. requests.Add(1)
  279. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  280. w.Write(blog)
  281. }))
  282. defer srv.Close()
  283. dir := t.TempDir()
  284. var stdout, stderr bytes.Buffer
  285. code := run(context.Background(), []string{"-URL", srv.URL + "/post", "-SAVE", dir, "-NOLOCAL"}, &stdout, &stderr, pipeline.Run)
  286. if code != exitFetch || !strings.Contains(stderr.String(), "local or private address") {
  287. t.Errorf("exit code = %d, stderr = %q; want %d and a refused local address", code, stderr.String(), exitFetch)
  288. }
  289. if n := requests.Load(); n != 0 {
  290. t.Errorf("server got %d requests, want 0", n)
  291. }
  292. assertEmptyDir(t, dir)
  293. stdout.Reset()
  294. stderr.Reset()
  295. if code := run(context.Background(), []string{"-URL", srv.URL + "/post", "-SAVE", dir}, &stdout, &stderr, pipeline.Run); code != exitOK {
  296. t.Errorf("without -NOLOCAL: exit code = %d, stderr = %q", code, stderr.String())
  297. }
  298. }
  299. // assertCover checks the book has a 1404x1872 grayscale cover, listed
  300. // first in the spine. With photo, the cover's upper half must show the
  301. // photo (mid-grays) rather than the plain cover's black band and white
  302. // space.
  303. func assertCover(t *testing.T, book *epubtest.Book, photo bool) {
  304. t.Helper()
  305. opf := book.File(t, ".opf")
  306. if !strings.Contains(opf, `href="images/cover.jpg" media-type="image/jpeg" properties="cover-image"`) {
  307. t.Fatalf("OPF has no cover image:\n%s", opf)
  308. }
  309. if strings.Index(opf, `<itemref idref="cover.xhtml">`) > strings.Index(opf, `<itemref idref="chapter.xhtml">`) {
  310. t.Errorf("cover isn't first in the spine:\n%s", opf)
  311. }
  312. img, err := jpeg.Decode(strings.NewReader(book.Files["EPUB/images/cover.jpg"]))
  313. if err != nil {
  314. t.Fatal(err)
  315. }
  316. gray, ok := img.(*image.Gray)
  317. if !ok || gray.Bounds().Dx() != 1404 || gray.Bounds().Dy() != 1872 {
  318. t.Fatalf("cover is %T %v, want 1404x1872 grayscale", img, img.Bounds())
  319. }
  320. mid := 0
  321. for y := 300; y < 900; y += 10 {
  322. for x := 0; x < 1404; x += 10 {
  323. if v := gray.GrayAt(x, y).Y; v > 40 && v < 215 {
  324. mid++
  325. }
  326. }
  327. }
  328. if hasPhoto := mid > 2000; hasPhoto != photo {
  329. t.Errorf("cover photo = %v (%d mid-gray samples), want %v", hasPhoto, mid, photo)
  330. }
  331. }
  332. func writeFile(t *testing.T, path, content string) {
  333. t.Helper()
  334. if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
  335. t.Fatal(err)
  336. }
  337. }
  338. func assertFile(t *testing.T, path, want string) {
  339. t.Helper()
  340. got, err := os.ReadFile(path)
  341. if err != nil {
  342. t.Fatal(err)
  343. }
  344. if string(got) != want {
  345. t.Errorf("%s contains %q, want %q", path, got, want)
  346. }
  347. }
  348. func assertEmptyDir(t *testing.T, dir string) {
  349. t.Helper()
  350. entries, err := os.ReadDir(dir)
  351. if err != nil {
  352. t.Fatal(err)
  353. }
  354. if len(entries) != 0 {
  355. t.Errorf("%s is not empty: %v", dir, entries)
  356. }
  357. }
  358. // TestEndToEnd runs the real pipeline against a local web server.
  359. func TestEndToEnd(t *testing.T) {
  360. blog, err := os.ReadFile("../../testdata/pages/blog.html")
  361. if err != nil {
  362. t.Fatal(err)
  363. }
  364. docs, err := os.ReadFile("../../testdata/pages/docs.html")
  365. if err != nil {
  366. t.Fatal(err)
  367. }
  368. essay, err := os.ReadFile("../../testdata/pages/photo-essay.html")
  369. if err != nil {
  370. t.Fatal(err)
  371. }
  372. lightbox, err := os.ReadFile("../../testdata/pages/lightbox.html")
  373. if err != nil {
  374. t.Fatal(err)
  375. }
  376. wiki, err := os.ReadFile("../../testdata/pages/wiki.html")
  377. if err != nil {
  378. t.Fatal(err)
  379. }
  380. images := map[string]string{ // served path → file in testdata/images
  381. "/img/press.jpg": "press.jpg",
  382. "/img/diagram.png": "diagram.png",
  383. "/img/type.webp": "gradient.webp",
  384. // lightbox.html; srcset should select the 1536w file.
  385. "/wp-content/uploads/2026/10/upgrade-dialog-1536x864.png": "diagram.png",
  386. "/wp-content/uploads/2026/10/settings-640x480.jpg": "press.jpg",
  387. "/wp-content/uploads/2026/10/hero.jpg": "press.jpg", // og:image, for the cover
  388. // wiki.html; served whatever the query string, as Wikipedia does.
  389. "/upload/a/a6/Walking_Robots_logo.png": "diagram.png",
  390. "/thumb/9/99/Painted_miniature.jpg/500px-Painted_miniature.jpg": "press.jpg",
  391. "/media/record-sheet": "gradient.webp",
  392. }
  393. var imageRequests atomic.Int32
  394. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  395. if strings.HasPrefix(r.URL.Path, "/img/") || strings.HasPrefix(r.URL.Path, "/wp-content/") ||
  396. strings.HasPrefix(r.URL.Path, "/upload/") || strings.HasPrefix(r.URL.Path, "/thumb/") || strings.HasPrefix(r.URL.Path, "/media/") {
  397. imageRequests.Add(1)
  398. name, ok := images[r.URL.Path]
  399. if !ok {
  400. http.NotFound(w, r)
  401. return
  402. }
  403. http.ServeFile(w, r, filepath.Join("../../testdata/images", name))
  404. return
  405. }
  406. switch r.URL.Path {
  407. case "/wiki/Walking_Robots":
  408. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  409. w.Write(wiki)
  410. case "/news/2026/10/kde-turns-30/":
  411. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  412. w.Write(lightbox)
  413. case "/essays/print-shop":
  414. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  415. w.Write(essay)
  416. case "/posts/eink":
  417. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  418. w.Write(blog)
  419. case "/docs/config":
  420. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  421. w.Write(docs)
  422. case "/app":
  423. w.Header().Set("Content-Type", "text/html")
  424. w.Write([]byte(`<html><body><div id="root"></div><script src="/app.js"></script></body></html>`))
  425. default:
  426. http.NotFound(w, r)
  427. }
  428. }))
  429. defer srv.Close()
  430. realRun := func(args ...string) result {
  431. var stdout, stderr bytes.Buffer
  432. code := run(context.Background(), args, &stdout, &stderr, pipeline.Run)
  433. return result{code, stdout.String(), stderr.String()}
  434. }
  435. t.Run("article", func(t *testing.T) {
  436. dir := t.TempDir()
  437. r := realRun("-URL", srv.URL+"/posts/eink", "-SAVE", dir)
  438. if r.code != exitOK {
  439. t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr)
  440. }
  441. path := filepath.Join(dir, "why-e-ink-is-easier-on-the-eyes.epub")
  442. if strings.TrimSpace(r.stdout) != path {
  443. t.Errorf("stdout = %q, want %q", r.stdout, path)
  444. }
  445. data, err := os.ReadFile(path)
  446. if err != nil {
  447. t.Fatal(err)
  448. }
  449. book := epubtest.Open(t, data)
  450. book.AssertWellFormed(t)
  451. chapter := book.File(t, "chapter.xhtml")
  452. for _, want := range []string{
  453. "<h1>Why E-Ink Is Easier on the Eyes</h1>",
  454. "Ada Lovelace · The Reading Desk · 14 March 2025",
  455. "Electronic paper does not shine light into your eyes.",
  456. `▶ Video: <a href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">How E Ink works</a>`,
  457. } {
  458. if !strings.Contains(chapter, want) {
  459. t.Errorf("chapter is missing %q", want)
  460. }
  461. }
  462. if strings.Contains(chapter, "Great article, thanks!") {
  463. t.Errorf("chapter contains page comments")
  464. }
  465. assertCover(t, book, false)
  466. epubtest.EPUBCheck(t, path)
  467. })
  468. t.Run("messy documentation page", func(t *testing.T) {
  469. dir := t.TempDir()
  470. r := realRun("-URL", srv.URL+"/docs/config", "-SAVE", dir)
  471. if r.code != exitOK {
  472. t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr)
  473. }
  474. path := filepath.Join(dir, "configuring-the-sync-daemon.epub")
  475. data, err := os.ReadFile(path)
  476. if err != nil {
  477. t.Fatal(err)
  478. }
  479. book := epubtest.Open(t, data)
  480. book.AssertWellFormed(t)
  481. chapter := book.File(t, "chapter.xhtml")
  482. for _, want := range []string{
  483. `<div lang="en" xml:lang="en">`,
  484. `<h2 id="sec-1">File location</h2>`,
  485. `<h3 id="sec-3">Durations</h3>`,
  486. "<pre><code>interval = ",
  487. "<td><code>wifi_only</code></td>",
  488. `<a href="mailto:docs@example.com">`,
  489. `<a href="` + srv.URL + `/faq#restart">`,
  490. "recipes — including", // &mdash; as a character, not an entity
  491. } {
  492. if !strings.Contains(chapter, want) {
  493. t.Errorf("chapter is missing %q", want)
  494. }
  495. }
  496. for _, unwanted := range []string{"onclick", "style=", "javascript:", "analytics", "<form", "<button", "Was this page helpful"} {
  497. if strings.Contains(chapter, unwanted) {
  498. t.Errorf("chapter contains %q", unwanted)
  499. }
  500. }
  501. assertCover(t, book, false)
  502. squash := func(s string) string { return strings.Join(strings.Fields(s), " ") }
  503. nav := squash(book.File(t, "nav.xhtml"))
  504. for _, want := range []string{
  505. `<a href="xhtml/chapter.xhtml#sec-1">File location</a>`,
  506. `<a href="xhtml/chapter.xhtml#sec-2">Options</a> <ol> <li><a href="xhtml/chapter.xhtml#sec-3">Durations</a></li> </ol>`,
  507. `<a href="xhtml/chapter.xhtml#sec-4">Example</a>`,
  508. } {
  509. if !strings.Contains(nav, want) {
  510. t.Errorf("nav is missing %q:\n%s", want, nav)
  511. }
  512. }
  513. if strings.Contains(nav, "Edge cases") {
  514. t.Errorf("nav lists an <h4>; only sections and subsections belong in it")
  515. }
  516. epubtest.EPUBCheck(t, path)
  517. })
  518. t.Run("photo essay", func(t *testing.T) {
  519. dir := t.TempDir()
  520. r := realRun("-URL", srv.URL+"/essays/print-shop", "-SAVE", dir)
  521. if r.code != exitOK {
  522. t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr)
  523. }
  524. if !strings.Contains(r.stderr, "warning: image "+srv.URL+"/img/missing.png skipped: server returned 404 Not Found") {
  525. t.Errorf("stderr = %q, want a warning about the missing image", r.stderr)
  526. }
  527. path := filepath.Join(dir, "a-walk-through-the-print-shop.epub")
  528. data, err := os.ReadFile(path)
  529. if err != nil {
  530. t.Fatal(err)
  531. }
  532. book := epubtest.Open(t, data)
  533. book.AssertWellFormed(t)
  534. var stored []string
  535. for _, name := range book.Order {
  536. if strings.HasPrefix(name, "EPUB/images/") && name != "EPUB/images/cover.jpg" {
  537. stored = append(stored, name)
  538. }
  539. }
  540. if len(stored) != 3 {
  541. t.Fatalf("book has images %v, want 3 (photo, diagram, WebP picture)", stored)
  542. }
  543. for _, name := range stored {
  544. img, format, err := image.Decode(strings.NewReader(book.Files[name]))
  545. if err != nil {
  546. t.Fatalf("%s: %v", name, err)
  547. }
  548. if format != "jpeg" && format != "png" {
  549. t.Errorf("%s is %s, want jpeg or png", name, format)
  550. }
  551. if b := img.Bounds(); b.Dx() > 1404 || b.Dy() > 1872 {
  552. t.Errorf("%s is %dx%d, larger than the screen", name, b.Dx(), b.Dy())
  553. }
  554. switch img.(type) {
  555. case *image.Gray, *image.Paletted:
  556. default:
  557. t.Errorf("%s decodes as %T, want grayscale", name, img)
  558. }
  559. }
  560. chapter := book.File(t, "chapter.xhtml")
  561. for _, want := range []string{
  562. `alt="The old printing press"/><figcaption>The 1890s press still runs every Friday.</figcaption></figure>`,
  563. `alt="How ink reaches the paper"/></figure>`,
  564. `alt="Drawers of wooden type"/></figure>`,
  565. `<span class="missing-image">[Image: small press icon]</span>`,
  566. `<p class="byline">Grace Hopper · Letterpress Weekly</p>`, // no invented date
  567. } {
  568. if !strings.Contains(chapter, want) {
  569. t.Errorf("chapter is missing %q", want)
  570. }
  571. }
  572. if strings.Contains(chapter, "tracker.example") || strings.Contains(chapter, `<img src="http`) {
  573. t.Errorf("chapter references remote images:\n%s", chapter)
  574. }
  575. assertCover(t, book, true)
  576. if opf := book.File(t, ".opf"); !strings.Contains(opf, "<dc:publisher>Letterpress Weekly</dc:publisher>") ||
  577. !strings.Contains(opf, "<dc:source>"+srv.URL+"/essays/print-shop</dc:source>") {
  578. t.Errorf("OPF is missing the publisher or source:\n%s", opf)
  579. }
  580. epubtest.EPUBCheck(t, path)
  581. })
  582. t.Run("images inside lightbox zoom links", func(t *testing.T) {
  583. // Regression test: go-trafilatura dropped every image on pages that
  584. // wrap images in zoom links, such as Ars Technica articles.
  585. dir := t.TempDir()
  586. r := realRun("-URL", srv.URL+"/news/2026/10/kde-turns-30/", "-SAVE", dir)
  587. if r.code != exitOK {
  588. t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr)
  589. }
  590. if strings.Contains(r.stderr, "warning") {
  591. t.Errorf("unexpected warnings: %q", r.stderr)
  592. }
  593. path := filepath.Join(dir, "kde-turns-30-with-plasma-6-8.epub")
  594. data, err := os.ReadFile(path)
  595. if err != nil {
  596. t.Fatal(err)
  597. }
  598. book := epubtest.Open(t, data)
  599. book.AssertWellFormed(t)
  600. var stored int
  601. for _, name := range book.Order {
  602. if strings.HasPrefix(name, "EPUB/images/") && name != "EPUB/images/cover.jpg" {
  603. stored++
  604. }
  605. }
  606. if stored != 2 {
  607. t.Errorf("book has %d images, want 2", stored)
  608. }
  609. chapter := book.File(t, "chapter.xhtml")
  610. for _, want := range []string{
  611. `<figcaption>The new remote upgrade dialog. Credit: KDE</figcaption></figure>`,
  612. `alt="System Settings in Plasma 6.8"/><figcaption>System Settings has been reorganised.</figcaption></figure>`,
  613. } {
  614. if !strings.Contains(chapter, want) {
  615. t.Errorf("chapter is missing %q", want)
  616. }
  617. }
  618. if n := strings.Count(chapter, "The new remote upgrade dialog."); n != 1 {
  619. t.Errorf("caption appears %d times, want once", n)
  620. }
  621. assertCover(t, book, true) // the page's og:image, as on Ars Technica
  622. epubtest.EPUBCheck(t, path)
  623. })
  624. t.Run("image URLs with query strings or no extension", func(t *testing.T) {
  625. // Regression test: go-trafilatura dropped images whose URL doesn't end
  626. // in a file extension, such as every image on Wikipedia.
  627. dir := t.TempDir()
  628. r := realRun("-URL", srv.URL+"/wiki/Walking_Robots", "-SAVE", dir)
  629. if r.code != exitOK {
  630. t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr)
  631. }
  632. if strings.Contains(r.stderr, "warning") {
  633. t.Errorf("unexpected warnings: %q", r.stderr)
  634. }
  635. path := filepath.Join(dir, "walking-robots-game.epub")
  636. data, err := os.ReadFile(path)
  637. if err != nil {
  638. t.Fatal(err)
  639. }
  640. book := epubtest.Open(t, data)
  641. book.AssertWellFormed(t)
  642. var stored int
  643. for _, name := range book.Order {
  644. if strings.HasPrefix(name, "EPUB/images/") && name != "EPUB/images/cover.jpg" {
  645. stored++
  646. }
  647. }
  648. if stored != 3 {
  649. t.Errorf("book has %d images, want 3 (logo, miniature, record sheet)", stored)
  650. }
  651. chapter := book.File(t, "chapter.xhtml")
  652. for _, want := range []string{
  653. `<figcaption>The game&#39;s original logo</figcaption></figure>`,
  654. `<figcaption>A painted miniature on a hex map</figcaption></figure>`,
  655. `<figcaption>A machine&#39;s record sheet</figcaption></figure>`,
  656. } {
  657. if !strings.Contains(chapter, want) {
  658. t.Errorf("chapter is missing %q", want)
  659. }
  660. }
  661. if strings.Contains(chapter, "rekopub.invalid") {
  662. t.Errorf("chapter still contains stand-in image URLs:\n%s", chapter)
  663. }
  664. epubtest.EPUBCheck(t, path)
  665. })
  666. t.Run("KEPUB for Kobo", func(t *testing.T) {
  667. dir := t.TempDir()
  668. r := realRun("-URL", srv.URL+"/essays/print-shop", "-SAVE", filepath.Join(dir, "essay.epub"), "-KEPUB")
  669. if r.code != exitOK {
  670. t.Fatalf("exit code = %d, stderr = %q", r.code, r.stderr)
  671. }
  672. if !strings.Contains(r.stderr, "note: saving as essay.kepub.epub") {
  673. t.Errorf("stderr = %q, want a note about the .kepub.epub name", r.stderr)
  674. }
  675. path := filepath.Join(dir, "essay.kepub.epub")
  676. if strings.TrimSpace(r.stdout) != path {
  677. t.Errorf("stdout = %q, want %q", r.stdout, path)
  678. }
  679. data, err := os.ReadFile(path)
  680. if err != nil {
  681. t.Fatal(err)
  682. }
  683. book := epubtest.Open(t, data)
  684. book.AssertWellFormed(t)
  685. chapter := book.File(t, "chapter.xhtml")
  686. for _, want := range []string{`class="koboSpan"`, "The print shop on Mill Street has been running since 1894.", "<figcaption>"} {
  687. if !strings.Contains(chapter, want) {
  688. t.Errorf("chapter is missing %q", want)
  689. }
  690. }
  691. assertCover(t, book, true)
  692. epubtest.EPUBCheck(t, path)
  693. })
  694. t.Run("existing file stops before images are downloaded", func(t *testing.T) {
  695. dir := t.TempDir()
  696. existing := filepath.Join(dir, "a-walk-through-the-print-shop.epub")
  697. writeFile(t, existing, "old")
  698. before := imageRequests.Load()
  699. r := realRun("-URL", srv.URL+"/essays/print-shop", "-SAVE", dir)
  700. if r.code != exitExists {
  701. t.Errorf("exit code = %d, want %d; stderr = %q", r.code, exitExists, r.stderr)
  702. }
  703. if n := imageRequests.Load() - before; n != 0 {
  704. t.Errorf("%d images downloaded, want 0", n)
  705. }
  706. assertFile(t, existing, "old")
  707. })
  708. t.Run("not found", func(t *testing.T) {
  709. dir := t.TempDir()
  710. r := realRun("-URL", srv.URL+"/missing", "-SAVE", dir)
  711. if r.code != exitFetch || !strings.Contains(r.stderr, "returned 404 Not Found") {
  712. t.Errorf("exit code = %d, stderr = %q", r.code, r.stderr)
  713. }
  714. assertEmptyDir(t, dir)
  715. })
  716. t.Run("no content", func(t *testing.T) {
  717. dir := t.TempDir()
  718. r := realRun("-URL", srv.URL+"/app", "-SAVE", dir)
  719. if r.code != exitNoContent || !strings.Contains(r.stderr, "no readable article content") {
  720. t.Errorf("exit code = %d, stderr = %q", r.code, r.stderr)
  721. }
  722. assertEmptyDir(t, dir)
  723. })
  724. }
  725. // TestLocalFilesNeverRead checks that a page can't pull files from the
  726. // reader's computer into the book, whichever way it refers to them.
  727. func TestLocalFilesNeverRead(t *testing.T) {
  728. const marker = "TOP-SECRET-MARKER-7f3a"
  729. secrets := t.TempDir()
  730. secretText := filepath.Join(secrets, "secret.txt")
  731. if err := os.WriteFile(secretText, []byte(marker), 0o644); err != nil {
  732. t.Fatal(err)
  733. }
  734. png, err := os.ReadFile("../../testdata/images/diagram.png")
  735. if err != nil {
  736. t.Fatal(err)
  737. }
  738. secretPNG := filepath.Join(secrets, "secret.png")
  739. if err := os.WriteFile(secretPNG, png, 0o644); err != nil {
  740. t.Fatal(err)
  741. }
  742. fileURL := func(path string) string {
  743. p := filepath.ToSlash(path)
  744. if !strings.HasPrefix(p, "/") {
  745. p = "/" + p // a Windows path such as C:/Users/...
  746. }
  747. return "file://" + p
  748. }
  749. blog, err := os.ReadFile("../../testdata/pages/blog.html")
  750. if err != nil {
  751. t.Fatal(err)
  752. }
  753. page := strings.Replace(string(blog), "</head>",
  754. `<meta property="og:image" content="`+fileURL(secretPNG)+`"></head>`, 1)
  755. page = strings.Replace(page, "</p>", "</p>"+
  756. `<p><img src="`+fileURL(secretPNG)+`" alt="one">`+
  757. `<img src="file://localhost`+filepath.ToSlash(secretPNG)+`" alt="two">`+
  758. `<img src="`+filepath.ToSlash(secretPNG)+`" alt="three">`+
  759. `<img src="`+secretPNG+`" alt="four">`+
  760. `<img src="`+fileURL(secretText)+`" alt="five">`+
  761. `<picture><source srcset="`+fileURL(secretPNG)+` 2x"><img src="`+fileURL(secretPNG)+`" alt="six"></picture>`+
  762. `<a href="`+fileURL(secretText)+`">notes</a></p>`, 1)
  763. var requests []string
  764. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  765. requests = append(requests, r.URL.Path)
  766. if r.URL.Path != "/posts/eink" {
  767. http.NotFound(w, r) // relative paths resolve to this server
  768. return
  769. }
  770. w.Header().Set("Content-Type", "text/html; charset=utf-8")
  771. w.Write([]byte(page))
  772. }))
  773. defer srv.Close()
  774. out := filepath.Join(t.TempDir(), "book.epub")
  775. var stdout, stderr bytes.Buffer
  776. code := run(context.Background(), []string{"-URL", srv.URL + "/posts/eink", "-SAVE", out}, &stdout, &stderr, pipeline.Run)
  777. if code != exitOK {
  778. t.Fatalf("exit %d: %s", code, stderr.String())
  779. }
  780. data, err := os.ReadFile(out)
  781. if err != nil {
  782. t.Fatal(err)
  783. }
  784. book := epubtest.Open(t, data)
  785. for name, content := range book.Files {
  786. if strings.Contains(content, marker) {
  787. t.Errorf("%s contains the secret file's content", name)
  788. }
  789. if strings.HasSuffix(name, ".png") || (strings.HasSuffix(name, ".jpg") && !strings.HasSuffix(name, "cover.jpg")) {
  790. t.Errorf("book contains image %s; the only image should be the typographic cover", name)
  791. }
  792. }
  793. if chapter := book.File(t, "chapter.xhtml"); strings.Contains(chapter, "file:") {
  794. t.Errorf("chapter still refers to a local file:\n%s", chapter)
  795. }
  796. t.Logf("server requests: %v\nwarnings:\n%s", requests, stderr.String())
  797. }