Skip to content

test: remove agent traps from the test and e2e tooling - #524

Merged
chhoumann merged 16 commits into
masterfrom
dx-agent-traps
Oct 24, 2026
Merged

chhoumann merged 16 commits into
masterfrom
dx-agent-traps

Conversation

@chhoumann

@chhoumann chhoumann commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Summary

Agent instructions in AGENTS.md worked around traps in the test tooling. This change removes the traps in the tooling itself, so the rules can go.

  • npm run test:unit runs typecheck before Vitest. A test with a type error no longer passes locally and fails in CI.
  • npm run test:e2e takes a per-worktree run lock, builds, stops the isolated instance, provisions a fresh vault, runs the suite, and stops the instance. Leftovers from manual verification or an interrupted run no longer fail later tests. A second run in the same worktree fails at once instead of replacing the first run's bundle or vault.
  • Each e2e test gets its own sandbox folder inside its file's sandbox, so two tests in one file can create the same folder.
  • New npm run plant proves that a test catches a defect. It applies one or more exact edits, type-checks, requires the named test to fail, restores the files, and requires the command to pass again. A tooling test runs the real script.
  • AGENTS.md keeps two rules that tooling cannot enforce: plant each new test's defects and list them in the PR, and never run an Obsidian executable by hand. It also trims stale prose.

Out of scope: tests that fake app by hand still break when the plugin calls a new host API. Moving them to the shared fake in tests/mocks/obsidian.ts is a larger follow-up.

Verification

  • Before the change, a test with new TFile("a.md") passed npm run test:unit. After it, test:unit fails on TS2554.
  • A Reading-view leaf left by hand in the instance failed J-TR-06 (mode: "preview") before the change. After it, J-TR-06 passes with the leftover in place.
  • Two e2e tests that create the same sandbox folder failed with "Folder already exists" before the change and pass after it. npm run plant caught two wrong sandbox fixes with them.
  • Two concurrent test:e2e runs: the second exits 1 with the busy message and does not build, and the first passes.
  • tests/tooling/plant.test.ts catches each defect that review found in plant, planted back with npm run plant itself: colored output, a sibling test's failure, no signal handler, no baseline, a file named two ways, and deleted backups.
  • npm run lint, format:check, typecheck, build, test (2,793), test:tooling (47), and the full npm run test:e2e (246 of 246, Obsidian 1.14.4, Linux headless) pass.

Release impact

None. The changes touch only dev tooling, tests, and docs.


Note

Low Risk
Changes are limited to npm scripts, E2E harness/runner, tooling tests, and contributor docs; the shipped plugin bundle is untouched.

Overview
This PR hardens dev test tooling so local runs match CI expectations and E2E no longer depends on manual cleanup rules in AGENTS.md.

Unit tests: npm run test:unit now runs typecheck before Vitest, so type errors fail the unit suite instead of passing locally and failing only in CI. npm run test inlines Vitest without going through the test:unit script.

E2E runner: npm run test:e2e builds inside the runner, takes a per-worktree run lock (a second concurrent run fails fast), stops the isolated instance, deletes the vault, and starts fresh, then always stops the instance and releases the lock. Manual leftovers or interrupted runs no longer poison later suites.

E2E harness: Each test gets its own sandbox folder under the file sandbox via createSandboxApi; tests expose sandbox only inside a test body. A new isolation test proves two tests in one file can create the same folder name.

npm run plant: New scripts/plant.mjs applies exact one-match edits, type-checks, requires the named test to fail on the plant and the command to pass after restore, with backup/restore and signal handling. tests/tooling/plant.test.ts exercises the real script.

Docs: AGENTS.md and CONTRIBUTING.md document plant workflow, the new E2E lifecycle, and trim obsolete workarounds (e.g. macOS pre-start steps, manual vault reset).

Reviewed by Cursor Bugbot for commit f5aa585. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add plant defect-verification CLI and per-test E2E sandboxes

  • Adds scripts/plant.mjs, a CLI (npm run plant) that plants a deliberate edit into a file, runs a test command, and requires the exact named test to fail on the plant and pass on the restored baseline. It restores unchanged files, keeps backups when a file changed during the run, and handles SIGINT/SIGTERM/SIGHUP.
  • Rewrites the E2E runner in obsidian-e2e.mjs to build, stop any existing instance, delete the vault, start a fresh instance, and always clean up. Adds a run lock that rejects concurrent E2E runs in the same worktree.
  • Gives each E2E test its own SandboxApi under the file sandbox via harness.ts; sandbox access outside a test is rejected. Adds an isolation test in harness-isolation.test.ts.
  • Updates test:unit to run typecheck before Vitest and test:e2e to delegate build and lifecycle to the runner. Updates AGENTS.md and CONTRIBUTING.md to document the new workflows.
  • Behavioral Change: npm run test:e2e now deletes and recreates the isolated vault every run and fails when another run holds the lock; test:unit fails earlier on type errors.

Macroscope summarized f5aa585.

npm run test:unit ran Vitest only, which strips types, so a test that
passes there could still fail typecheck in CI. It now runs the 1 s native
typecheck first. npm run test keeps svelte-check, which already covers
test files, and calls Vitest directly.
test:e2e reused a warm instance and its vault, so fixtures, layout, and
runtime errors from manual verification or an interrupted run became the
suite's baseline and failed later tests. It now stops this worktree's
instance, removes the vault, provisions a fresh one, and stops the
instance afterwards. A fresh vault adds no measurable time.
Tests in one file shared the file's sandbox, so two tests creating the
same folder collided with "Folder already exists". The harness now
creates a sandbox per test inside the file's sandbox, which teardown
still removes. harness-isolation covers it with two tests that create
the same folder.
Planting a defect by hand went wrong in the same few ways: an edit that
matched more than once, a plant that failed typecheck so no test ran, a
filter that matched no test read as a catch, and a git checkout restore
that dropped uncommitted work. The script replaces exactly one match,
type-checks, passes only when the named test is in the run's FAIL
summary, restores from a backup copy, and rebuilds main.js after an e2e
run.
Keeps two rules that tooling cannot enforce: a new test must fail on
planted defects, proved with npm run plant and listed in the PR body,
and an Obsidian executable is never run by hand because it opens the
host's real profile. Trims history from the svelte-check, run --reload,
Settings popout, and workflow concurrency notes.
The fresh-vault reset stopped the instance and deleted the vault even
while another run used them. test:e2e now takes a run lock first and
fails at once when another run holds it.
getContext().sandbox now throws outside a test instead of returning
undefined or the previous test's folder. The isolation test also checks
that both tests' folders share the file's sandbox as parent.
- Match the exact test name, so a sibling test's failure is no catch.
- Run Vitest with NO_COLOR, so CI colors cannot hide the FAIL line.
- Require the command to pass before the plant.
- Restore on exit and on SIGINT, SIGTERM, and SIGHUP, from memory,
  keeping the file mode.
- Accept several edits across files.
- Fail when the post-e2e rebuild fails.

tests/tooling/plant.test.ts runs the real script and catches each of
these defects when planted back.
A second run rebuilt root main.js before it reached the lock, so the
first run's later files loaded the second run's bundle. runTests now
takes the lock, then builds, and a busy lock names the other run.
A failing pre-test check left the sandbox set for later hooks. Each
sandbox test now checks its own parent, so it runs alone.
- Key files by resolved path, so two spellings of one file plant and
  restore it once.
- Restore only a file that still holds the plant, otherwise keep the
  backups and fail.
- Run the baseline after the restore. A build in the command then
  replaces anything built from the plant.
- Launch npm through npm_execpath, so no shell splits arguments.
- Print the FAIL lines that caught the plant.
The exit handler's second restore deleted the backups that a refused
restore had just pointed at. A plant that was not caught skipped the
baseline, so an e2e run left main.js built from the plant.
An interrupt during the build returned a null exit code, which exited 0,
and a signal to the wrapper alone still reset the instance. Only a busy
lock now reads as another run.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: f5aa585
Status: ✅  Deploy successful!
Preview URL: https://00b823b1.podnotes.pages.dev
Branch Preview URL: https://dx-agent-traps.podnotes.pages.dev

View logs

Comment thread scripts/plant.mjs Outdated
process.on("exit", restore);
for (const signal of /** @type {NodeJS.Signals[]} */ (["SIGINT", "SIGTERM", "SIGHUP"])) {
process.on(signal, () => {
child?.kill(signal);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium scripts/plant.mjs:54

On interruption, plant restores the files and exits while an npm run test:e2e process tree can still be running, so its tests may continue against restored files and retain the run lock. The handler only signals the immediate child and does not wait for it to shut down; terminate the command’s process tree and await shutdown before restoring.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @scripts/plant.mjs around line 54:

On interruption, `plant` restores the files and exits while an `npm run test:e2e` process tree can still be running, so its tests may continue against restored files and retain the run lock. The handler only signals the immediate child and does not wait for it to shut down; terminate the command’s process tree and await shutdown before restoring.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9ad2ab6. On a signal, plant restores the files and then waits for its command to close, so test:e2e releases its run lock. A second signal exits at once. Repro: SIGTERM to plant during an e2e plant, then an immediate test:e2e. Before the fix it was refused with "Another npm run test:e2e is running". After it, the run passed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lock race is addressed by waiting before exit, but the original ordering concern remains: the handler restores files before waiting for the command to close, so the e2e process can still run against restored sources. It also still signals only npm rather than explicitly terminating the command tree.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 476b5be. Configure here.

Comment thread scripts/obsidian-e2e.mjs Outdated
@chhoumann

Copy link
Copy Markdown
Owner Author

PASS+NOTES - independent verification of 476b5be against master 2b67ade (Linux headless, Node 22.23.4, Obsidian 1.14.4 and 1.13.7)

Claims

  • test:unit: a probe test with new TFile("a.md") passes npm run test:unit on master (1 passed) and fails on the PR with TS2554.
  • Fresh vault: with a Reading-view Leftover.md leaf saved in the instance, master's J-TR-06 reuses the instance and fails on mode: "preview". The PR stops the instance, reprovisions the vault, and passes.
  • Run lock: a second test:e2e started while the first was mid-build exited 1 in 133 ms with "Another npm run test:e2e is running" and did not build. The first passed 10/10.
  • Sandboxes: harness-isolation.test.ts passes 10/10, including both new same-folder tests.
  • plant (formatSeconds): a caught plant with an empty replace exits 0 and prints the FAIL line. A passing test name and an unknown name both exit 1 "Not caught". A search with 0 or 7 matches, a TS2322 plant, and bad arity are all refused. Ctrl-C during typecheck and SIGTERM to plant during Vitest both restore the file and leave no stray Vitest. git status was clean after every case, and no backup dirs were left behind.
  • e2e plant: src/URIHandler.ts time / 2 is caught by "opens timestamp URI links at requested progress..." (exit 0). Root main.js sha256 4cedf526... matches a clean npm run build.
  • test:tooling: 47/47, including the 7 plant tests.
  • Also passing: lint, format:check, test:unit (2793), the full test:e2e (246/246, 962 s), and CI.
  • Docs: the AGENTS.md and CONTRIBUTING changes match observed behavior. With a 1.14.4 instance running, PODNOTES_OBSIDIAN_APP=/opt/Obsidian/obsidian npm run test:e2e stopped it and passed on 1.13.7 with no manual stop.

Notes (non-blocking)

  • This confirms the Macroscope comment at plant.mjs:54. If you SIGTERM or Ctrl-C plant during an -- npm run test:e2e plant, plant exits at once and the orphaned runner dies before lock.release(). The next test:e2e is then refused for up to 15 s, and root main.js stays the planted bundle until npm run build. Plant does print that hint. Ctrl-C on a plain test:e2e releases the lock correctly.
  • The Bugbot lock leak at obsidian-e2e.mjs:151 recovers by itself. After a SIGKILL, an immediate rerun was refused (dead pid, stale=false, "Wait for it to finish"), and a rerun 16 s later passed.
  • A SIGTERM sent only to the npm wrapper never reaches plant, because npm signals sh -c and sh does not forward it. Plant then runs to the end (~60 s) before it restores. This is npm behavior, not this PR's.
  • The PR body says test:e2e ran 247/247. At this head it is 246.

plant exited at once on a signal, so a test:e2e child died on a closed
pipe before it released its run lock, and the next run was refused. It
now waits for the command to close, and a second signal exits at once.
test:e2e also releases its lock when stopping the instance throws.
@chhoumann

Copy link
Copy Markdown
Owner Author

PASS+NOTES - independent verification of 9ad2ab6 (Linux headless, Node 22.23.4, Obsidian 1.14.4)

  • SIGTERM to plant (node) right after Vitest printed RUN, command -- npm run test:e2e -- tests/e2e/harness-isolation.test.ts -t "first test": source restored, plant waited 2.1 s for the runner's "Stopped instance", exited 143, no stray Vitest or runner, lock dir empty. The immediate next run passed (1 passed, 9 skipped).
  • Same with 476b5be's plant.mjs: exit 143 in 0.3 s, the runner died before releasing, and the next run was refused in 0.13 s ("locked by ... pid= stale=false").
  • Second signal: SIGTERM then SIGTERM exited 143 17 ms after the second, SIGTERM then SIGINT exited 130 within 0.3 s, source restored both times. As expected, the abandoned runner then holds its lock and the next run is refused until it goes stale.
  • Ctrl-C (SIGINT to plant's whole process group, full harness-isolation file): the runner interrupted Vitest and released the lock in 0.13 s, plant exited 130 right after, next run passed.
  • obsidian-e2e.mjs: the try/finally releases the lock when stop throws. Real check: hiding obsidian-e2e.config.mjs after the test passed makes the final stop throw "Cannot find Obsidian E2E runner config". At 9ad2ab6 the lock is released and the next run passes. With 476b5be's runner the lock stays and the next run is refused at once.
  • lint, typecheck, format:check and test:tooling (47/47) pass. CI is green at 9ad2ab6. The branch is BEHIND master.

Notes (non-blocking)

  • A lone SIGTERM to plant does not stop the command. npm forwards it to sh -c, dash dies without forwarding (npm exits 16 ms later), and the runner is orphaned. Plant waits only because the runner still holds plant's stdout pipe. With the whole harness-isolation file, plant waited 19.6 s while all 10 tests ran to the end, so on the full suite it waits for the whole run. Spawning the command detached and signalling its process group, as obsidian-e2e.mjs does for Vitest, would reach the runner. Ctrl-C at a terminal is fine because the runner gets SIGINT directly.
  • No test pins the new behavior: with plant.mjs reverted to 476b5be, all 7 plant tooling tests still pass. The mid-run test signals a plain node -e child that dies at once, so it cannot tell waiting from exiting.

@chhoumann
chhoumann merged commit f3b727d into master Oct 24, 2026
18 checks passed
@chhoumann
chhoumann deleted the dx-agent-traps branch October 24, 2026 22:27
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