#![cfg(unix)]
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
const COLUMNS: u16 = 140;
const ROWS: u16 = 36;
const BUILD_TIME: Duration = Duration::from_secs(300);
struct Screen {
parser: Arc<Mutex<vt100::Parser>>,
input: Box<dyn Write + Send>,
child: Box<dyn portable_pty::Child + Send + Sync>,
}
impl Screen {
fn start(dir: &Path, path: &Path, home: &Path, file: &str) -> Self {
let pty = native_pty_system()
.openpty(PtySize {
rows: ROWS,
cols: COLUMNS,
pixel_width: 0,
pixel_height: 0,
})
.unwrap();
let mut command = CommandBuilder::new(env!("CARGO_BIN_EXE_cellix"));
command.cwd(dir);
command.arg(file);
command.env("PATH", path);
command.env("HOME", home);
command.env("XDG_CONFIG_HOME", home.join(".config"));
command.env("CELLIX_TIER", "cells");
let child = pty.slave.spawn_command(command).unwrap();
let mut reader = pty.master.try_clone_reader().unwrap();
let input = pty.master.take_writer().unwrap();
let parser = Arc::new(Mutex::new(vt100::Parser::new(ROWS, COLUMNS, 0)));
let sink = parser.clone();
std::thread::spawn(move || {
let mut buf = [0; 8192];
while let Ok(n) = reader.read(&mut buf) {
if n == 0 {
break;
}
sink.lock().unwrap().process(&buf[..n]);
}
});
Self {
parser,
input,
child,
}
}
fn contents(&self) -> String {
self.parser.lock().unwrap().screen().contents()
}
fn wait_for(&self, text: &str, time: Duration) {
let deadline = Instant::now() + time;
while Instant::now() < deadline {
let shown = self.contents();
if shown.contains(text) {
return;
}
for failed in ["The build failed", "needs a LaTeX engine"] {
assert!(!shown.contains(failed), "{failed}:\n{shown}");
}
std::thread::sleep(Duration::from_millis(100));
}
panic!("{text:?} never appeared:\n{}", self.contents());
}
fn send(&mut self, bytes: &[u8]) {
self.input.write_all(bytes).unwrap();
self.input.flush().unwrap();
}
fn command(&mut self, name: &str) {
self.send(b"\x1bOP");
std::thread::sleep(Duration::from_millis(400));
self.send(name.as_bytes());
std::thread::sleep(Duration::from_millis(400));
self.send(b"\r");
}
}
impl Drop for Screen {
fn drop(&mut self) {
let _ = self.child.kill();
}
}
fn path_without(hidden: &[&str], folder: &Path) -> PathBuf {
let bin = folder.join("bin");
std::fs::create_dir_all(&bin).unwrap();
let path = std::env::var("PATH").unwrap_or_default();
for dir in path.split(':').filter(|d| !d.is_empty()) {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries.flatten() {
let name = entry.file_name();
let link = bin.join(&name);
if hidden.iter().any(|h| name == *h) || link.exists() || link.is_symlink() {
continue;
}
let _ = std::os::unix::fs::symlink(entry.path(), link);
}
}
for needed in ["pdflatex", "bibtex", "perl", "latexmk"] {
if hidden.contains(&needed) {
continue;
}
assert!(
bin.join(needed).exists(),
"{needed} must be installed to run these tests"
);
}
for gone in hidden {
assert!(!bin.join(gone).exists());
}
bin
}
fn paper(dir: &Path) {
std::fs::create_dir_all(dir.join("sections")).unwrap();
std::fs::write(
dir.join("main.tex"),
"\\documentclass{article}\n\\begin{document}\n\\section{Introduction}\\label{sec:intro}\nAs \\cite{knuth84} shows, the method is in Section~\\ref{sec:method}.\n\\input{sections/method}\n\\bibliographystyle{plain}\n\\bibliography{refs}\n\\end{document}\n",
)
.unwrap();
std::fs::write(
dir.join("sections/method.tex"),
"\\section{Method}\\label{sec:method}\nIt builds on Section~\\ref{sec:intro} and \\cite{lamport94}.\n",
)
.unwrap();
std::fs::write(
dir.join("refs.bib"),
"@book{knuth84,\n author = {Donald E. Knuth},\n title = {The {\\TeX}book},\n publisher = {Addison-Wesley},\n year = {1984}\n}\n\n@book{lamport94,\n author = {Leslie Lamport},\n title = {{\\LaTeX}: A Document Preparation System},\n publisher = {Addison-Wesley},\n year = {1994}\n}\n",
)
.unwrap();
}
fn builds_a_paper(hidden: &[&str], latexmk: bool) {
let scratch = tempfile::tempdir().unwrap();
let bin = path_without(hidden, scratch.path());
let home = scratch.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let dir = scratch.path().join("paper");
paper(&dir);
let mut screen = Screen::start(&dir, &bin, &home, "main.tex");
screen.wait_for("Introduction", Duration::from_secs(15));
screen.command("build pdf");
screen.wait_for("Built in", BUILD_TIME);
assert!(dir.join("main.pdf").exists(), "no PDF");
assert!(dir.join("main.synctex.gz").exists(), "no SyncTeX");
let log = std::fs::read_to_string(dir.join("main.log")).unwrap();
for unresolved in [
"LaTeX Warning: Citation",
"LaTeX Warning: Reference",
"There were undefined references",
"Rerun to get",
"??",
] {
assert!(!log.contains(unresolved), "{unresolved}:\n{log}");
}
let bbl = std::fs::read_to_string(dir.join("main.bbl")).expect("BibTeX ran");
assert!(
bbl.contains("knuth84") && bbl.contains("lamport94"),
"{bbl}"
);
let aux = std::fs::read_to_string(dir.join("main.aux")).unwrap();
assert!(aux.contains("\\bibcite{knuth84}"), "{aux}");
assert_eq!(
dir.join("main.fdb_latexmk").exists(),
latexmk,
"which engine ran"
);
}
fn exports_markdown(hidden: &[&str]) {
let scratch = tempfile::tempdir().unwrap();
let bin = path_without(hidden, scratch.path());
let home = scratch.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let dir = scratch.path().join("notes");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("note.md"),
"# Attention\n\nThe weights sum to one, as $\\sum_i w_i = 1$ says.\n\n- One point\n- Another, with a [link](https://example.com)\n\n$$\nE = mc^2\n$$\n",
)
.unwrap();
let mut screen = Screen::start(&dir, &bin, &home, "note.md");
screen.wait_for("Attention", Duration::from_secs(15));
screen.command("export to pdf");
screen.wait_for("Built in", BUILD_TIME);
drop(screen);
assert!(
dir.join("note.pdf").exists(),
"the PDF lands beside the note"
);
let pdf = std::fs::read(dir.join("note.pdf")).unwrap();
assert!(pdf.starts_with(b"%PDF"), "a real PDF");
let left: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|name| name != "note.md" && name != "note.pdf" && name != ".cellix")
.collect();
assert!(left.is_empty(), "the build left {left:?}");
}
#[test]
#[ignore = "needs TeX Live: harness/x tex"]
fn latexmk_builds_a_paper_without_tectonic() {
builds_a_paper(&["tectonic"], true);
}
#[test]
#[ignore = "needs TeX Live: harness/x tex"]
fn pdflatex_builds_a_paper_with_bibtex_passes() {
builds_a_paper(&["tectonic", "latexmk"], false);
}
#[test]
#[ignore = "needs TeX Live: harness/x tex"]
fn latexmk_exports_markdown_to_pdf() {
exports_markdown(&["tectonic"]);
}
#[test]
#[ignore = "needs TeX Live: harness/x tex"]
fn pdflatex_exports_markdown_to_pdf() {
exports_markdown(&["tectonic", "latexmk"]);
}
fn start_with(
hidden: &[&str],
files: &[(&str, &str)],
open: &str,
command: &str,
) -> (tempfile::TempDir, PathBuf, Screen) {
let scratch = tempfile::tempdir().unwrap();
let bin = path_without(hidden, scratch.path());
let home = scratch.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let dir = scratch.path().join("paper");
for (name, text) in files {
let path = dir.join(name);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
let mut screen = Screen::start(&dir, &bin, &home, open);
screen.wait_for(open, Duration::from_secs(15));
screen.command(command);
(scratch, dir, screen)
}
const REFS: &str = "@book{knuth84,\n author = {Donald E. Knuth},\n title = {The {\\TeX}book},\n publisher = {Addison-Wesley},\n year = {1984}\n}\n";
#[test]
#[ignore = "needs TeX Live and Biber: harness/x tex"]
fn pdflatex_runs_biber_for_a_biblatex_paper() {
let main = "\\documentclass{article}\n\\usepackage[backend=biber]{biblatex}\n\\addbibresource{refs.bib}\n\\begin{document}\nAs \\cite{knuth84} shows.\n\\printbibliography\n\\end{document}\n";
let files = [("main.tex", main), ("refs.bib", REFS)];
let (_scratch, dir, screen) =
start_with(&["tectonic", "latexmk"], &files, "main.tex", "build pdf");
screen.wait_for("Built in", BUILD_TIME);
assert!(
!screen.contents().contains("failed"),
"{}",
screen.contents()
);
let bbl = std::fs::read_to_string(dir.join("main.bbl")).expect("Biber ran");
assert!(bbl.contains("knuth84"), "{bbl}");
let log = std::fs::read_to_string(dir.join("main.log")).unwrap();
for unresolved in [
"LaTeX Warning: Citation",
"There were undefined",
"Please rerun",
"Rerun to get",
] {
assert!(!log.contains(unresolved), "{unresolved}:\n{log}");
}
}
#[test]
#[ignore = "needs TeX Live: harness/x tex"]
fn pdflatex_settles_labels_in_included_chapters() {
let main = "\\documentclass{article}\n\\begin{document}\n\\include{ch1}\n\\include{ch2}\n\\end{document}\n";
let files = [
("main.tex", main),
(
"ch1.tex",
"\\section{One}\\label{sec:one}\nSee Section~\\ref{sec:two}.\n",
),
(
"ch2.tex",
"\\section{Two}\\label{sec:two}\nSee Section~\\ref{sec:one}.\n",
),
];
let (_scratch, dir, screen) =
start_with(&["tectonic", "latexmk"], &files, "main.tex", "build pdf");
screen.wait_for("Built in", BUILD_TIME);
let log = std::fs::read_to_string(dir.join("main.log")).unwrap();
for unresolved in [
"LaTeX Warning: Reference",
"There were undefined",
"Rerun to get",
] {
assert!(!log.contains(unresolved), "{unresolved}:\n{log}");
}
assert!(
screen.contents().contains("Built in") && !screen.contents().contains("warning"),
"{}",
screen.contents()
);
}
#[test]
#[ignore = "needs TeX Live: harness/x tex"]
fn pdflatex_says_when_bibtex_finds_no_bib() {
let main = "\\documentclass{article}\n\\begin{document}\nAs \\cite{knuth84} shows.\n\\bibliographystyle{plain}\n\\bibliography{missing}\n\\end{document}\n";
let (_scratch, _dir, screen) = start_with(
&["tectonic", "latexmk"],
&[("main.tex", main)],
"main.tex",
"build pdf",
);
screen.wait_for("BibTeX failed", BUILD_TIME);
assert!(
screen.contents().contains("missing.bib"),
"{}",
screen.contents()
);
}
#[test]
#[ignore = "needs TeX Live: harness/x tex"]
fn latexmk_counts_a_warning_in_a_section_file_once() {
let main = "\\documentclass{article}\n\\begin{document}\n\\section{Intro}\\label{sec:intro}\nText.\n\\input{sections/method}\n\\end{document}\n";
let method = "\\section{Method}\nIt builds on Section~\\ref{sec:intro}.\n\nAnd on Section~\\ref{sec:gone}.\n";
let files = [("main.tex", main), ("sections/method.tex", method)];
let (_scratch, _dir, mut screen) = start_with(&["tectonic"], &files, "main.tex", "build pdf");
screen.wait_for("Built in", BUILD_TIME);
screen.wait_for("with a warning", Duration::from_secs(5));
screen.send(b"\x1b[19~");
screen.wait_for("No problems in this file", Duration::from_secs(10));
}
#[test]
#[ignore = "needs TeX Live: harness/x tex"]
fn a_failed_pdflatex_export_keeps_the_last_good_pdf() {
let files = [
("note.md", "# Note\n\nBroken: $\\nosuchmacro{x}$.\n"),
("note.pdf", "the good one"),
];
let (_scratch, dir, screen) =
start_with(&["tectonic", "latexmk"], &files, "note.md", "export to pdf");
screen.wait_for("The PDF wasn't made", BUILD_TIME);
assert_eq!(
std::fs::read_to_string(dir.join("note.pdf")).unwrap(),
"the good one"
);
let left: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|name| name != "note.md" && name != "note.pdf" && name != ".cellix")
.collect();
assert!(left.is_empty(), "the build left {left:?}");
}