feature fixes

This commit is contained in:
pj committed 2026-08-28 16:08:39 +05:30
1 parent 1b31e2f189
commit 586ee946d0
38 files changed
+1938 -260

No files matched your search

+10 -11
View File
@@ -6,6 +6,9 @@
// touched is `git status` being empty, plus a byte-level snapshot of every path under the root
// including .git itself.
//
// The repository is built by the suite, under /private/tmp, on the first test that asks for it, and
// there is nothing to set up by hand. `tests/support/notes_repo.rs` is where it comes from.
//
// Run single threaded. The tests share one folder and several of them mutate it.
//
// cargo test --test no_write_on_open -- --test-threads=1 --nocapture
@@ -25,28 +28,24 @@ use margin_docs_lib::fs::{
};
use margin_docs_lib::watch::spawn_watcher;
const REPO: &str = "/private/tmp/margin-notouch/notes-repo";
#[path = "support/notes_repo.rs"]
mod notes_repo;
// ---------------------------------------------------------------- fixture
/// The fixture repository, built on the first call and shared by every test after it.
fn repo() -> PathBuf {
let path = PathBuf::from(REPO);
let path = notes_repo::path().to_path_buf();
assert!(
path.join(".git").is_dir(),
"the fixture repo is missing: {REPO}"
"the fixture repo is missing: {}",
path.display()
);
path
}
fn git(args: &[&str]) -> String {
let out = Command::new("git")
.args(args)
.current_dir(REPO)
.output()
.expect("git runs");
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
text.push_str(&String::from_utf8_lossy(&out.stderr));
text
notes_repo::git(args)
}
/// Back to the committed state, then one warm `git status` so the index's stat cache is already
+339
View File
@@ -0,0 +1,339 @@
// The folder the `no_write_on_open` suite runs against, built from nothing every time the test
// binary starts.
//
// It has to be a real git repository and not a `TempDir` full of loose files, because `git status`
// is the oracle the whole suite leans on: an editor that promises to write nothing the user did not
// edit is believable exactly when a checkout of that folder comes back with no lines. A snapshot of
// inodes and timestamps says a byte moved; `git status` says which document it belonged to and
// whether the user would have seen it in their own diff.
//
// It used to be a repository somebody built by hand at a fixed path under /tmp, which meant the
// suite ran on one machine until macOS reaped the folder and then ran nowhere. Everything below
// exists so that the repository is an output of the test run rather than a precondition of it.
//
// The documents are copied out of `src/markdown/corpus/real`, the same real world markdown the
// bridge tests parse, rather than invented here. Reusing them keeps one corpus in the repository
// instead of two, and gives the fixture documents that are the length and shape of the ones people
// actually keep in a notes folder. Nothing in the suite asserts on their bytes, only that they read
// back as UTF-8 and that the tree has the right number of rows, so the corpus is free to grow.
//
// The vendored `node_modules` is generated rather than copied. Several tests need a folder large
// enough that walking it is measurably slower than skipping it, and thirteen thousand tiny files
// that exist to be ignored are not files worth committing.
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::LazyLock;
use std::time::{SystemTime, UNIX_EPOCH};
/// Every fixture this suite builds is named `margin-notouch-<pid>-<nanos>`, so a later run can
/// recognise the ones earlier runs left behind and work out which of them are finished with.
const PREFIX: &str = "margin-notouch-";
/// 500 packages of 26 entries each is 13,000 paths under `node_modules`. Two tests put a floor
/// under this: one wants more than 5,000 entries there, and one wants a walk of the whole root with
/// the skip turned off to return more than 10,000 rows. Building it costs about a second.
const PACKAGES: usize = 500;
const MODULES_PER_PACKAGE: usize = 21;
/// Where each document in the fixture comes from in `src/markdown/corpus/real`. The paths on the
/// left are named by the tests and cannot move without moving the tests too. There are twelve
/// editable documents here, three more than the suite's floor of ten, and the two sidecars are the
/// files the tests describe as the user's own: a save must not touch either.
const DOCUMENTS: [(&str, &str); 14] = [
("README.md", "margin-readme.md"),
("notes.txt", "margin-claude.md"),
("docs/index.md", "calendar-readme.md"),
("docs/architecture.md", "editor-architecture.md"),
("docs/conventions.md", "editor-conventions.md"),
("docs/design.md", "editor-design.md"),
("docs/guides/setup.md", "editor-setup.md"),
("docs/guides/release.md", "editor-release.md"),
("docs/guides/mobile.md", "calendar-mobile.md"),
("docs/internals/website.md", "margin-website-readme.md"),
("docs/internals/indexing.md", "calendar-architecture.md"),
("docs/internals/watcher.md", "calendar-design.md"),
// Not documents. A `.bak` and a `.tmp` the user owns, committed so that a save deleting one is
// a line of `git status` and not merely an absence somebody has to notice.
("docs/design.md.bak", "editor-design.md"),
("docs/conventions.md.tmp", "editor-conventions.md"),
];
/// Two files that are not text, so the tree has rows the editor cannot open.
const ASSETS: [(&str, &str); 2] = [
("assets/logo.png", "128x128.png"),
("assets/[email protected]", "[email protected]"),
];
/// Built once per test binary, on whichever test calls `pristine()` first, and then shared. The
/// suite runs single threaded, but `LazyLock` is what makes that a property of the fixture rather
/// than a rule someone has to remember.
static REPO: LazyLock<PathBuf> = LazyLock::new(build);
/// The root of the fixture repository. Always canonical, so it starts `/private/tmp/` on macOS and
/// the same folder is also reachable through the `/tmp` symlink, which one test needs.
pub fn path() -> &'static Path {
REPO.as_path()
}
/// Runs git inside the fixture and hands back everything it said, output and errors together. The
/// suite reads these as prose, so a failing command shows up in the assertion that used it rather
/// than as an empty string that quietly looks clean.
pub fn git(args: &[&str]) -> String {
let out = run(path(), args);
let mut text = String::from_utf8_lossy(&out.stdout).into_owned();
text.push_str(&String::from_utf8_lossy(&out.stderr));
text
}
// ---------------------------------------------------------------- running git
/// Git, with the machine it happens to be running on held at arm's length.
///
/// The fixture is an oracle, so nothing outside it may change what it says. A developer with a
/// `core.excludesFile` full of `*.tmp`, a commit template, a signing key, a `gc.auto` that fires
/// mid run, or a stray `GIT_DIR` in the environment would each turn a green suite red or, worse, a
/// red one green. The config files are pointed at /dev/null and the inherited git variables are
/// dropped, so the only configuration in play is the handful of keys written into the repository
/// itself by `configure` below.
fn run(dir: &Path, args: &[&str]) -> Output {
Command::new("git")
.current_dir(dir)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_OPTIONAL_LOCKS", "1")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_COMMON_DIR")
.env_remove("GIT_OBJECT_DIRECTORY")
.env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES")
.env_remove("GIT_CEILING_DIRECTORIES")
.env_remove("GIT_ATTR_NOSYSTEM")
.args(args)
.output()
.unwrap_or_else(|e| {
panic!(
"cannot run `git {}`: {e}\n\
This suite needs the git command line tool on PATH. Its whole method is to ask a \
real repository whether anything moved, so there is no useful way to run it \
without git and it fails here rather than passing on a folder nobody checked.",
args.join(" ")
)
})
}
fn must(dir: &Path, args: &[&str]) {
let out = run(dir, args);
assert!(
out.status.success(),
"building the fixture: `git {}` failed with {}\n{}{}",
args.join(" "),
out.status,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
// ---------------------------------------------------------------- building
fn build() -> PathBuf {
let tmp = fs::canonicalize("/tmp").unwrap_or_else(|_| std::env::temp_dir());
sweep(&tmp);
// Per process and per instant, so two runs of the suite at once get two repositories and
// neither has to wait for the other. `notes-repo` is a folder inside it rather than the
// temporary folder itself, so the repository has a parent the tests never touch.
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let root = tmp
.join(format!("{PREFIX}{}-{stamp}", std::process::id()))
.join("notes-repo");
fs::create_dir_all(&root)
.unwrap_or_else(|e| panic!("cannot build the fixture at {}: {e}", root.display()));
populate(&root);
commit(&root);
root
}
fn populate(root: &Path) {
let corpus = corpus_dir();
for (rel, source) in DOCUMENTS {
let from = corpus.join(source);
let bytes = fs::read(&from).unwrap_or_else(|e| {
panic!(
"the fixture is built out of the markdown corpus and {} is missing: {e}",
from.display()
)
});
put(&root.join(rel), &bytes);
}
let icons = project_root().join("src-tauri/icons");
for (rel, source) in ASSETS {
let from = icons.join(source);
let bytes = fs::read(&from)
.unwrap_or_else(|e| panic!("the fixture wants {} for an asset: {e}", from.display()));
put(&root.join(rel), &bytes);
}
// The only thing the folder ignores. Deliberately not `*.tmp` or `*.bak`: the tests plant files
// by those names on purpose and need git to report them.
put(
&root.join(".gitignore"),
b"node_modules/\n.DS_Store\n" as &[u8],
);
vendor(root);
}
/// A vendored `node_modules`, big enough that walking it and skipping it are visibly different
/// jobs. The contents are filler; only the count and the shape matter.
fn vendor(root: &Path) {
let node_modules = root.join("node_modules");
for package in 0..PACKAGES {
let dir = node_modules.join(format!("pkg-{package:03}"));
let lib = dir.join("lib");
fs::create_dir_all(&lib)
.unwrap_or_else(|e| panic!("cannot build {}: {e}", lib.display()));
put(
&dir.join("package.json"),
format!("{{\n \"name\": \"pkg-{package:03}\",\n \"version\": \"1.0.{package}\"\n}}\n")
.as_bytes(),
);
put(
&dir.join("index.js"),
b"module.exports = require(\"./lib/mod-00.js\");\n" as &[u8],
);
put(
&dir.join("README.md"),
format!("# pkg-{package:03}\n\nVendored. Not a document, and not the user's writing.\n")
.as_bytes(),
);
for module in 0..MODULES_PER_PACKAGE {
put(
&lib.join(format!("mod-{module:02}.js")),
format!("exports.value = {package} * 100 + {module};\n").as_bytes(),
);
}
}
}
fn commit(root: &Path) {
must(root, &["init", "-q", "-b", "main"]);
configure(root);
must(root, &["add", "-A"]);
must(
root,
&["commit", "-q", "-m", "the folder as the user left it"],
);
let out = run(root, &["status", "--porcelain"]);
let status = String::from_utf8_lossy(&out.stdout);
assert!(
status.is_empty(),
"the fixture did not commit clean, so `git status` cannot be trusted as the oracle:\n{status}"
);
}
/// Written into the repository rather than passed as `-c` flags on every call, so that the settings
/// travel with the fixture and a command run by hand inside it behaves the way the suite's commands
/// do.
fn configure(root: &Path) {
for (key, value) in [
("user.name", "Margin Fixture"),
("user.email", "[email protected]"),
// Signing would ask for a passphrase, and a passphrase in CI is a hang rather than a
// failure.
("commit.gpgsign", "false"),
("tag.gpgsign", "false"),
// A background repack landing between two snapshots would move bytes under .git and read
// as the editor having written something.
("gc.auto", "0"),
("maintenance.auto", "false"),
// The suite asserts on exactly which paths git reports, so the answer must not depend on
// what the person running it happens to ignore everywhere.
("core.excludesFile", "/dev/null"),
("core.autocrlf", "false"),
// Both write to .git while only reading the working tree, which is the one thing every
// snapshot in this suite is watching for.
("core.fsmonitor", "false"),
("core.untrackedCache", "false"),
("core.splitIndex", "false"),
("status.showUntrackedFiles", "normal"),
] {
must(root, &["config", key, value]);
}
}
// ---------------------------------------------------------------- odds and ends
fn put(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.unwrap_or_else(|e| panic!("cannot build {}: {e}", parent.display()));
}
fs::write(path, bytes).unwrap_or_else(|e| panic!("cannot write {}: {e}", path.display()));
}
fn project_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("src-tauri has a parent")
.to_path_buf()
}
fn corpus_dir() -> PathBuf {
project_root().join("src/markdown/corpus/real")
}
/// The fixture outlives the run that built it, on purpose.
///
/// A `TempDir` parked in a `static` is a destructor that never runs, and a suite that quietly
/// relies on that is worse than one that says so. Nothing here tries to delete the repository when
/// the tests finish: the last thing a failing run should do is destroy the evidence, and running
/// `git status` and `git diff` inside the folder is the first thing anyone will want. Thirteen
/// thousand files that each take a block is about fifty megabytes, which is small enough to leave
/// lying about once and much too big to leave lying about once per `cargo test`.
///
/// So the clearing up happens at the start of the next run instead, and it goes by whether the
/// process that built a fixture is still running rather than by how old the folder looks. The pid
/// is in the name for exactly this. Asking that question can only be wrong in the safe direction:
/// a pid that has been recycled reads as alive and the folder is kept, and a folder is only ever
/// removed once nothing is left that could be using it.
fn sweep(tmp: &Path) {
let Ok(entries) = fs::read_dir(tmp) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
let Some(pid) = name.strip_prefix(PREFIX).and_then(|rest| rest.split('-').next()) else {
continue;
};
if !alive(pid) {
fs::remove_dir_all(entry.path()).ok();
}
}
}
/// Signal zero asks whether a process exists without sending it anything. A `kill` that cannot be
/// run at all counts as alive, which leaves the folder where it is rather than deleting a
/// repository on a guess.
fn alive(pid: &str) -> bool {
if pid.is_empty() || !pid.bytes().all(|b| b.is_ascii_digit()) {
return true;
}
Command::new("/bin/kill")
.args(["-0", pid])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(true)
}