Skip to content

Close the gaps mutation testing found - #20

Merged
muellerei merged 15 commits into
mainfrom
test/api-endpoint-binding
Sep 17, 2026
Merged

muellerei merged 15 commits into
mainfrom
test/api-endpoint-binding

Conversation

@muellerei

@muellerei muellerei commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Closes step 1 of the roadmap. No production behaviour changes except one
removal, noted below and in the CHANGELOG.

What the befund got wrong

The befund this branch started from listed six unprotected assertions. Its
mutation pattern was def NAME\(([^)]*)\):, which matches no signature
carrying a return annotation — 75 of the 170 functions in the package. The
substitution replaced nothing and count=1 reported no failure, so the "green
suite" was the unmutated suite.

That produced wrong findings in both directions: two protected assertions
reported as gaps, and every real gap behind an annotated signature missed.

What a working sweep found

Function bodies replaced by AST line span, every mutation verified to have
changed the file before the suite runs. 132 functions mutated, 0 silently
skipped, 24 survivors against the 3 that were known.

Area Gap
api.py endpoint names unasserted — delete_page could call renamePage
helpers.py journal page names, the journal flag, regex escaping
render.py the markdown a page renders to
group.py --no-cache could be made inert
commands/meta.py both of doctor's probes
commands/query.py the text output of smart-query

Two survivors were not gaps and are recorded as such: cli.py:main is the
checkout entry point (now covered by a subprocess test) and
datalog.py:__init__ is a constructor, where return None is correct.

How the tests are written

Derived, not restated. A table mapping wrapper to endpoint would be copied out
of api.py and checked against api.py, pinning down whatever is written
there. The wrapper's own name is the independent source: snake_case to
camelCase gives the endpoint leaf, without exception across all 18. The same
holds for the journal pair — is_journal_date is fed the output of
format_journal_date across a full year rather than restating four formats.

Every test was verified against the mutation it targets: remove the assertion,
the test falls. The full sweep now reports 0 survivors.

The one behaviour change

_MUTATING_METHODS listed setBlockProperty and replaceText; neither has a
wrapper and neither was ever sent. The entries are the smaller half — the find
is that _CACHEABLE_METHODS has been held to its call sites since the read
cache shipped and the mutating list had no counterpart, which is why they
survived. Both lists are now bound to their wrappers in both directions.
CHANGELOG entry included. No release.

Size

842 tests to 999. Two parametrised lists were measured and cut back where cases
fell together with their neighbours (−16). Each remaining file was checked by
removing it and confirming its mutation goes uncaught without it.

The wrappers in LogseqAPI are one-liners around call(), so what they assert
is which endpoint goes out, not what comes back. Nothing held them to it:
pointing delete_page at logseq.Editor.renamePage left all 842 tests green,
and a destructive command reaching a different endpoint is the worst form
this defect takes.

The set-vs-reality test in test_api_cache.py cannot catch it. It asserts a
method name appears somewhere in api.py; after swapping two endpoints
between wrappers both names are still there.

A table mapping wrapper to endpoint would be no better. Copied out of api.py
and checked against api.py, it pins down whatever is written there, mistake
included. The wrapper's own name is the independent source: snake_case to
camelCase gives the endpoint leaf, without exception across all 18.

Also asserted: every endpoint sits in exactly one of the two method sets,
and every mutating wrapper clears the cache when call() runs it.
The class around it walks the command registry precisely so a write command
cannot be missed by a list nobody updated. The list of mutating wrappers it
compares against stayed hand-written, and had already drifted: it carried
replace_text, for which no wrapper exists.

Harmless in that direction. A missing entry is not — it would excuse a write
command from needing --dry-run, silently, which is the defect the registry
walk was written to prevent one layer up.

Now read off _MUTATING_METHODS and the api.py AST, so the two cannot disagree.
format_journal_date turns a date into the title Logseq stores the entry
under, and nine call sites in the journal, edit and analysis commands write
through it. A wrong name does not look wrong -- it addresses a different
page, so add-journal-entry creates one instead of appending to the entry
already there.

Nothing tested it. Returning "th" from get_day_suffix for every day left all
842 tests green, although it turns "mar 3rd, 2025" into "mar 3th, 2025"
under the format Logseq ships with by default.

Covered: the ordinal rule including the 11-13 exception that breaks the
last-digit shortcut, the default format, the tokens a configured
:journal/page-title-format can use, and that every day of a year yields one
stable, unique name.
is_journal_date is what makes create-page pass journal?: true to Logseq.
Wrong in one direction an ordinary page lands in the journal timeline;
wrong in the other the journal for that day exists twice, once as a plain
page. Returning True for every name left all 842 tests green.

The recogniser and the formatter are two halves of one claim, so the central
test feeds format_journal_date output back in rather than restating the four
formats by hand -- a name the tool writes but does not recognise is exactly
what creates the duplicate. Checked across a full year, where ordinal
suffixes and zero padding vary.

Also pinned: the patterns are anchored, so a date inside a longer name does
not make the page a journal.
find_backlinks is the fallback get-backlinks drops to when the native
getPageLinkedReferences call fails. It builds a regex out of the page name,
so a title carrying regex syntax -- C++, What is this?, Report (2025) --
compiled into a pattern that no longer matched the link it was built for.
The command then reported no backlinks and exited 0.

Output showing less than exists, with nothing to indicate it, is a defect
this project has had before. Worse on this path: it only runs when something
else has already broken.

escape_regex is one line and dropping it left all 842 tests green. What is
asserted here is not the call but the guarantee -- the name is matched
literally, a metacharacter does not widen the match to a different page, and
the fallback still finds the linking page when driven through the CLI.
"Projekt-Plan" was taken from a real graph, by way of the working notes this
test was written from. It carried no personal data, but test fixtures should
not borrow page names from anyone's graph at all -- the next borrowed name
may not be harmless, and nothing would flag it.

Replaced with an invented English name. No assertion changes.
The group callback turns the flag into api.cache_enabled = False. Replacing
that line with pass left all 842 tests green: the one test on the subject sets
the attribute itself, so it never runs the wiring the flag depends on.

What the user loses is specific. --no-cache is what you reach for after
changing something in Logseq, so a flag that does nothing answers with the
state from before the change and gives no sign it did.

Driven through the CLI and counted at the network boundary, since the claim is
about requests going out rather than about an attribute being set.
blocks_to_markdown is the default output of get-page and get-journal-range,
and nothing tested it. Two independent mutations survived the whole suite:
dropping the properties-block branch, and dropping indentation entirely.

Worth spelling out, because is_properties_block itself is covered -- by tests
that exercise get-backlinks --with-context, a different caller. Coverage of a
function says nothing about coverage of the branch that calls it.

The properties branch is why the renderer is not a one-liner: Logseq stores a
page's key:: value header without a bullet, and rendering it as a list item
produces a page that no longer round-trips into the graph.

blocks_with_ids promises in its docstring that its indentation matches. That
is asserted by rendering one tree through both renderers rather than by
writing the expected tab runs out twice.
test_doctor.py patches both probes out. That is right for what it tests --
how doctor reports a given answer -- but it means the probes themselves never
ran: returning None from either left all 842 tests green.

They decide the distinction their docstring calls the one that costs the most
time by hand: "Logseq is not running" against "Logseq runs, but its HTTP API
is off". Answered wrong, doctor sends the user to start an application that is
already open, or into the settings of one that is closed.

The port probe runs against a real socket bound on loopback and closed again
in the fixture. The process probe runs against a stubbed pgrep, because a real
one answers differently depending on whether the machine running the suite has
Logseq open.

Pinned along the way: unknown (None) stays distinguishable from not-running
(False), and the lowercase pattern is still tried, which is the only one that
matches on Linux.
_print_results is what a user sees without --json, and returning None from it
left all 842 tests green. Its four branches exist because Datalog does not
answer in one shape: rows wrapping a block, bare rows, plain maps, or scalars,
each with a different place to look for a name.

A branch reading the wrong field does not fail. It prints a blank line where a
page name belongs, numbered as though a result were there.

The 20-item cap is pinned too. It is what keeps a query matching a thousand
blocks from filling a terminal, and the count line above the list still
reports the full number, so a silent change to it misleads twice.
Two assertions left after the sweep, both real once traced.

pyproject installs the console script as logseq_cli.cli:cli, so main() is not
on the installed path at all. It carries `python logseq_cli/cli.py`, the way
the tool runs from a checkout without installing. Emptying it left the whole
suite green while that invocation fell silent.

DatalogQueryError carries api_message and query as attributes because output.py
reads e.query to build the JSON error payload, as its docstring states. The two
sibling errors follow the same shape with value, and nothing reads them yet, so
dropping the assignment changed nothing a test could see.

Kept rather than deleted: naming the offending value is why the fields exist,
and the three classes are meant to answer alike. Pinning that intent means the
next JSON error path still finds the field there.
Both parametrised lists carried cases that fell together with their
neighbours. Measured rather than judged: breaking one suffix branch failed
seven date cases where one per suffix class is enough, and of the eight
metacharacter names three did not exercise the escaping at all -- "50%
capacity" has no metacharacter, and "a|b" and "Notes." still match unescaped,
which is a separate claim already asserted on its own.

Down 16 tests. Every mutation these files were written against is still
caught, and the full sweep still reports no survivors.
test_api_cache.py has held _CACHEABLE_METHODS to its call sites since the read
cache shipped; the mutating list had no such check. The endpoint binding added
earlier only looked one way -- every wrapper reaches a listed method -- so an
entry with no wrapper at all stayed invisible.

Verified by adding a method name that nothing sends: the suite passed before
this test and fails after it.
setBlockProperty and replaceText have no wrapper and no call site. All 19
call() invocations pass a literal method name, so nothing could reach them;
they date from the initial import and described a tool that does not exist.

Behaviour is unchanged for every command -- a method in neither list is read
from the network each time and leaves the cache alone, and these two were in
no code path. The guard added in the previous commit is what keeps the lists
from collecting entries like this again.
@muellerei muellerei changed the title Cover four assertions that no test held Close the gaps mutation testing found Sep 17, 2026
@muellerei
muellerei merged commit 27a37c6 into main Sep 17, 2026
5 checks passed
@muellerei
muellerei deleted the test/api-endpoint-binding branch September 17, 2026 00:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant