Skip to content

Running the tests

Every command is run through a real XNEdit and the resulting buffer compared, byte for byte, against a file saying what it should have been. A macro rewrites the buffer with no confirmation step, so the only convincing test is one that runs it on a file and checks what came back.

uv run pytest

The two halves

The suite splits in two, and only one half needs an editor.

Half What it covers Needs XNEdit
Conventions Header comments, filenames, the replace_in_string() trap, formatting No
Macros What each command actually does to a file Yes

Without XNEdit installed, the second half skips and the run still goes green. That result says the macros are tidy, not that they work.

uv run pytest -m "not xnedit"   # just the conventions, deliberately

python/nedtransients/ is covered by the first half. Its tests read saved responses out of tests/fixtures/transients/ and never touch the network.

The tests that use the network

tests/test_sources_live.py checks the live TNS and Swift sites. It skips unless you ask for it:

NEDKIT_NETWORK=1 uv run pytest -m network

These are corpus tests. The objects in tests/fixtures/transients/ came off a real load, their values are known, and they are still published, so re-fetching them and getting something different means an upstream format moved. The strongest one rebuilds GRB.2026.03.31.mod from today's Swift table and compares it byte for byte against the real file.

.github/workflows/sources.yml runs them nightly. They are kept out of the per-push build because they depend on two sites nobody here controls, and a red build for someone else's outage is one people learn to ignore.

"Cannot reach" is not "has changed." A 403, a 429 or a timeout skips; only a site that answers and says something different fails.

That distinction matters more than it sounds, because TNS refuses GitHub's runners. From a runner, with the same User-Agent that gets 200 from a laptop:

200  https://www.swift.ac.uk/xrt_positions/...
403  https://www.wis-tns.org/search?format=csv&num_page=1

The block is by origin, not by client. So the nightly job genuinely watches Swift, and reports TNS as unreachable rather than pretending. The TNS half is only really checked when someone runs it from an ordinary network.

The environment variable is the guard, not the marker. Deselecting a marker through addopts in pyproject.toml looks like it works and does not: a -m on the command line replaces the one in addopts rather than combining with it, so CI's pytest -m "not xnedit" quietly opted the tests back in and the build went red. An explicit skip on an environment variable is the thing no invocation can override.

Building an XNEdit to test against

There are no prebuilt macOS binaries, so this is a one-time build from source. XQuartz is the X server it runs on, and openmotif is the widget toolkit.

brew install --cask xquartz
brew install openmotif

git clone https://github.com/unixwork/xnedit.git
cd xnedit
git checkout v1.6.3
make macos

The binary lands at source/xnedit. Point the suite at it and tell it to insist:

export NEDKIT_XNEDIT=/path/to/xnedit/source/xnedit
export NEDKIT_REQUIRE_XNEDIT=1
uv run pytest

NEDKIT_REQUIRE_XNEDIT=1 turns those skips into errors, so the run can no longer pass by testing nothing. Set it whenever a green result has to mean something. If xnedit is already on your $PATH, NEDKIT_XNEDIT is optional.

The tests are not headless

Each test opens a real XNEdit window for a second or two, so a full run flickers windows on and off the screen and takes focus for a couple of minutes. macOS has no hidden display to run them on. Nearly all of that time is the editor starting up once per test, so the wait grows with the suite rather than staying where this page last measured it.

You do not need to start XQuartz first. $DISPLAY points at a socket that starts the server on demand the moment the first test connects.

What CI runs

The two halves of the suite are also the two workflows.

Workflow Job Runs What it covers
ci.yml lint Every push and pull request ruff, actionlint over the workflows, and uv lock --check
ci.yml test Every push and pull request The half that needs no editor: the macro conventions, the generated pages, and a real 3.9 parse of anything the NED team is expected to run
macros.yml XNEdit on Linux Monday morning, 15:17 UTC The whole suite against XNEdit v1.6.3, built on the runner and cached until either the version or the image changes
macros.yml Classic NEdit 5.7 Monday morning, 15:17 UTC The same suite against Ubuntu's packaged NEdit, which is how far the macros carry outside the editor they were written for
macros.yml The documented macOS build Monday morning, 15:17 UTC That the build recipe above still works on a current macOS. It stops at the binary and runs no tests

ci.yml takes about twenty seconds. It deselects the macro tests rather than letting them skip, so the count at the bottom of the log is the truth about what ran.

Both editor jobs run under Xvfb with NEDKIT_REQUIRE_XNEDIT=1, so a build that produced nothing comes back red instead of green. Start the workflow by hand from the Actions tab, or:

gh workflow run macros.yml

Linux rather than macOS, for the one thing macOS cannot do. The macros do not care which X server they are running on, and Xvfb gives Linux one that needs no screen. The macOS job installs XQuartz too, because its headers are part of the build and not only the X server the editor later runs on. That job is watching Homebrew and the macOS toolchain rather than the macros.

The NEdit job is a gate like any other. Everything that diverges on 5.7 is marked and skips there, either by an xnedit-only file beside the fixture or by a skip in the test itself, so a red run means a real failure.

Weekly, because what it catches is rarely a bad commit. It is drift underneath the macros: Ubuntu's Motif changing, a Homebrew formula moving, a runner image turning over beneath a binary that used to build.

Adding a test for a new command

A command with no tests fails the suite, by design. Each case is a directory holding the file before and the file after:

tests/fixtures/<command-name>/<case-name>/
    input.txt       what is in the buffer to begin with
    expected.txt    what the command should leave behind
    setup.nm        optional, runs first, e.g. select(0, 12)
    xnedit-only     optional, skips the case on classic NEdit

<command-name> is the .nm filename without its extension, so macros/commands/pipe-at-columns.nm reads its cases from tests/fixtures/pipe-at-columns/.

setup.nm runs in the same interpreter as the command, so it is also where a case puts the cursor with set_cursor_pos() and where it answers a prompt the command is about to raise:

$ned_string_dialog_answer = "10, 23"
$ned_string_dialog_button = 2

Those two globals are what the harness's stand-in string_dialog() hands back. They default to an empty answer and button 1, so a command that asks a question and gets no fixture answer has to do nothing. That default is what stops the blanket tests hanging on it.

Most cases need only the first two files. xnedit-only is for the handful that turn on something XNEdit added and NEdit 5.7 does not have. There are two such things, and the arithmetic one is the more common of the two:

  • A column is a character on XNEdit and a byte on NEdit 5.7, in $column and in the regex engine alike, so any case putting a pipe or a pad on a line with an accented name in it lands somewhere else there.
  • Encoding: a buffer locking on a byte it cannot convert, or a BOM that lives outside the buffer.

Put the reason in the file and it appears in the skip message. Reach for the marker only when a case genuinely turns on the fork, because every expected failure left unmarked is one more reason to stop reading the NEdit job.

The two files are compared without being decoded, so trailing spaces, tabs and a missing final newline all count. That is deliberate. Write them with a script rather than an editor if the whitespace matters, since most editors will quietly tidy it for you.

Worth a case each time: the ordinary input, an input the command should leave completely alone, and an empty file. The middle one catches the most damaging class of bug these macros have, where a pattern that matches nothing returns an empty string and the command writes that over your file.

When a test fails

A failure names the command and the case, and prints both buffers with spaces as · and tabs as →, because otherwise the interesting failures are invisible.

Two failures mean something other than a wrong answer:

What it says What happened
XNEdit did not exit The macro raised an error. XNEdit puts errors in a dialog and waits for a click that never comes, so the harness times out and kills it. Look for a syntax error first
The macro exited without reaching its last line The macro died part way through, so the file it was working on is half-rewritten

A command that needs to tell the person running it something prints it to the terminal between === nedkit === and === end ===, and MacroRun.reports is the list of those blocks. A modal dialog is not an option: on some macOS/XQuartz builds one crashes the X server outright, and even where it does not it waits forever for a click no test can give it.

One dialog is left, because it asks rather than tells: Pipe at Columns needs to know which columns. The harness defines its own string_dialog() that prints instead, so a test can choose what the command hears back. Any other subroutine that stops and waits needs the same treatment before a test can get past it, and a return value some test can control before that treatment is worth anything.