- CRITICAL: openShadowVaultFor armed the 15s guard-clear timer in a
`finally`, so a FAILED spawn locked retry for 15s behind a stale
"still opening" toast (the exact "can't reconnect" symptom). Now
asymmetric: success arms the 15s debounce; failure clears the guard
synchronously → instant retry.
- IMPORTANT: a failed shadow-window auto-connect showed connectProfile's
classified, cause-specific Notice AND runAutoConnect's generic one in
the SAME window — the second stacked on and obscured the first. Drop
the generic Notice; keep the log line the e2e oracle asserts on.
- Correct the shadowSpawnInFlight JSDoc to describe the (now) asymmetric
semantics instead of "held after each spawn".
- Add tests/openShadowVaultFor.test.ts: locks the failed-spawn
instant-clear, the success 15s hold, and the in-flight re-entrancy
rejection (the C2 guard the suite never exercised).
- Consume the shared e2e/helpers/sshd.ts (drops the triplicated copy).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
N1 (Important — my prior justification was factually wrong): I'd
claimed the reuse→mismatch→redeploy branch was "e2e-covered", but
the connect e2e always fresh-deploys and never exercises a
pre-existing daemon. The branch + the `?? ''` guard were genuinely
unpinned. Added 4 ConnectionManager.startRpcSession unit tests
(vi.mock DaemonProbe/RpcConnection/ServerDeployer, REAL
resolveRemotePath): reuse-on-match (no redeploy), mismatch →
reused.close() + deploy({remoteVaultRoot: absolute}), absent
vaultRoot (`?? ''`) → redeploy + no throw, no-daemon → fresh deploy.
N2 (Medium — type was lying): `ServerInfo.vaultRoot: string` while
the runtime can be `undefined` for an older/3rd-party daemon — a
future reader would delete the `?? ''` guard trusting the type and
regress. Now `vaultRoot?: string` with a doc-comment forbidding
dropping the `?`. Zero ripple: the sole runtime reader
(ConnectionManager:106) already `?? ''`-guards; tsc stays green.
Advisory: inline note that `path.posix.normalize` PRESERVES trailing
slashes so the manual strip loop is load-bearing (don't delete);
trimmed the over-explaining absVaultRoot block comment to a
cross-ref; reuse-success log now prints absVaultRoot (was the
relative effectivePath) — consistent with the deploy/mismatch logs.
Deliberately NOT done: a user Notice on the mismatch-triggered
reconnect. ConnectionManager is the transport layer and doesn't
import obsidian's Notice; surfacing it correctly is a caller-side
(main.ts) concern. Pre-existing, not worsened by this PR (now fires
only on a real mismatch, not spuriously) — tracked as a separate
caller-side UX follow-up rather than a layering violation here.
tsc clean, eslint clean, vitest 70 files / 1065 passed / 1 skipped.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Multi-agent review caught a real regression in the daemon-reuse fix:
- C1 (CRITICAL): remotePath "~" → effectivePath "." →
resolveRemotePath("." , home) = "/home/u/." but the daemon reports
filepath.Abs(".") = "/home/u" via server.info → sameRemotePath
(trailing-slash-only) = false → kill+redeploy on EVERY connect.
Same for any "..", "//" in remotePath. The client (string-join) and
daemon (filepath.Abs) lived in different path spaces.
- I1: reused.info.vaultRoot is typed string but an older/3rd-party
daemon can omit it on the wire → undefined → sameRemotePath's
.trim() threw TypeError → uncaught silent connect failure.
- I2: deploy() got the raw relative effectivePath as --vault-root →
implicit daemon-cwd dependency.
Consolidated fix:
- ConnectionManager: compute the absolute vault root ONCE
(resolveRemotePath(effectivePath, home)) and use it BOTH for the
reuse compare AND as deploy()'s remoteVaultRoot — same path space
on both sides, no cwd dependency (fixes C1 + I2 together).
- sameRemotePath: POSIX-normalise both sides (path.posix.normalize →
collapses ./../ //, then strip trailing slash); null-safe
(string|undefined); empty/blank never matches a real root, not
even empty-vs-empty (always forces the safe redeploy). Fixes C1
remainder + I1.
- haveRoot guarded `?? ''` at the call site; reused.close() catch
now logger.warn (mirrors every other close in the file) instead of
a silent swallow.
- JSDoc reworded: a spurious redeploy is SAFE but not cheap (full
pkill + upload + restart) — normalisation exists to avoid firing
it on legitimately-equal paths.
Tests: pathUtils.test.ts +6 (the "." / ~ loop case, ".."/"//"
collapse, empty-vs-empty=false, undefined-safe no-throw). The
startRpcSession reuse/mismatch glue is intentionally covered by the
now-exhaustive sameRemotePath unit suite + the connect e2e
(#351/#353) rather than a bespoke ssh/deployer mock harness —
disproportionate for thin control flow whose decision core is pure
and fully tested.
tsc clean, eslint clean, vitest 70 files / 1061 passed / 1 skipped.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Field repro: changing a profile's remotePath (or deleting the old
remote vault) had NO effect — `tryReuseExistingDaemon` reused the
already-running daemon purely on socket+token presence, never
checking that its `ServerInfo.vaultRoot` still matches the profile's
requested root. The daemon kept serving its original (now wrong /
deleted) vault-root, so `fs.walk("")` returned `no such file:`, the
shadow vault populated 0 files, and every op (incl. renaming a new
note) failed `no such file`. The only workaround was to SSH in and
`pkill` the daemon by hand — exactly what should be automatic.
Fix: in `ConnectionManager.startRpcSession`, after a reuse candidate
is found, resolve the profile's `effectivePath` to absolute
(`resolveRemotePath(effectivePath, home)`) and compare it with the
reused daemon's `reused.info.vaultRoot` via the new
`sameRemotePath` (trailing-slash/whitespace tolerant, otherwise
strict — a spurious redeploy is cheap, a false match reattaches to
the wrong root). On mismatch: close the connection and fall through
to `deployer.deploy()`, whose `killExisting:true` pkills the stale
daemon and redeploys at the correct root. No manual pkill ever
needed; a remotePath change now always takes effect.
`sameRemotePath` added to util/pathUtils with 6 unit cases incl. the
exact field bug (`/home/souta/work/VaultDev` vs `/home/souta/work`
→ false → redeploy). tsc/eslint clean, vitest 70 files / 1057
passed.
Chained on fix/bootstrap-first-run-appjson so one local build has
both fixes (empty-app.json deadlock + stale-daemon root) for
end-to-end verification.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CRITICAL: seedObsidianFirstRunState used `.trim() === ''`, which
passes Obsidian's ACTUAL first-run content (the literal `{}`) through
as "configured" — the deadlock the fix was supposed to close stayed
open. Replace with a parse-based predicate: seed when absent, blank,
unparseable, or an empty object/array; a real config (>=1 key/elem)
is still never clobbered.
- IMPORTANT: the bare `catch { appNeedsSeed = true }` swallowed EACCES
/ EISDIR as "absent" and would overwrite a file it merely failed to
read. Now only ENOENT means "absent"; other errors rethrow.
- IMPORTANT: core-plugins.json used `!existsSync` only — a zero-byte
or `[]` file still deadlocked (asymmetric with app.json). Both files
now go through the same needsFirstRunSeed predicate.
- Tests: add the `{}` app.json case (fails on the old code — the real
regression guard the zero-byte-only test never caught), the empty
`[]` core-plugins.json case, and a non-ENOENT (EISDIR) no-clobber
case. The reviewed "RED now GREEN" stale comment does not exist in
the file (nothing to remove).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
THE actual "can't connect to a new vault" bug, root-caused from a
live dev-vault capture:
- SSH + remotePath are correct (`SFTP channel open` in the source
log), but the freshly-bootstrapped shadow vault had an EMPTY
app.json and ZERO plugin console.log — i.e. remote-ssh never even
loaded in the shadow window.
- Obsidian treats an empty/absent app.json as "never configured" and
opens the vault in first-run / Restricted mode, so community
plugins (incl. remote-ssh) don't load → onLayoutReady →
runShadowStartup → runAutoConnect never run → and the
pullSharedObsidianConfig that *would* populate the real app.json
also never runs. Deadlock: the very first connect to any new
profile silently does nothing; only a manually-trusted (reused)
shadow vault works.
Fix: ShadowVaultBootstrap.seedObsidianFirstRunState — on bootstrap,
write a minimal non-empty app.json (`{promptDelete:false}`) and a
default core-plugins.json when absent/empty. Idempotent and
non-destructive (same contract as seedCommunityPlugins): a real
app.json later written by pullSharedObsidianConfig or by Obsidian is
never clobbered. This is exactly what e2e/helpers/vault-scaffold.ts
has always pre-written — the production bootstrap was the only path
missing it (which is also why the connect e2e never reproduced this;
tracked as a follow-up to bootstrap-via-the-real-path in the e2e).
Tests: 4 cases in ShadowVaultBootstrap.test.ts — first bootstrap
writes non-empty app.json + core-plugins.json; an empty app.json
(the field state) is re-seeded; a real app.json / custom
core-plugins.json is preserved across re-bootstrap.
Validation: tsc clean, eslint clean, ShadowVaultBootstrap.test.ts
34 passed. Beta bump 1.0.49 → 1.0.50-beta.0 (version-check; root
manifest.json + versions.json stay pinned per the beta rule).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A shared remote root (the reporter's ~/work) is 747k entries — mostly
.git / node_modules / build dirs. Even with pagination the populate is
unusable: Obsidian eagerly indexes the whole vault. Fix: prune noise
subtrees at the daemon so they are never walked, transferred, or
indexed.
- SshProfile.walkIgnoreDirs?: string[] + DEFAULT_WALK_IGNORE_DIRS
(.git, node_modules, target, dist, .venv, …). DEFAULT_PROFILE seeds
it so new profiles are pre-filled; older profiles fall back to the
default list at walk time (an explicit [] = "ignore nothing").
- ProfileForm: multi-line "Ignore directories" textarea (one name per
line) in the profile editor, placeholdered/pre-filled with defaults.
- proto WalkParams.Ignore (Go) + TS mirror (ignore?: string[]).
- daemon fs.walk: prune any directory whose exact basename is in the
ignore set via fs.SkipDir — BEFORE counting/emitting, so pruned
subtrees never shift pagination offsets. Go tests: subtree fully
pruned (dir itself not emitted) + paged-with-ignore stitches the
same set as a single ignored walk (no offset drift).
- BulkWalker forwards ignoreDirs → fs.walk `ignore`; main.ts populate
passes activeProfile.walkIgnoreDirs ?? DEFAULT.
Full plugin (1050) + Go suites green; typecheck + lint clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Symptom: a profile whose remotePath is a large dir (e.g. ~/work with
50k+ files) left the vault EMPTY — "remote files that exist can't be
opened". Root cause: fs.walk capped at 50000 entries and returned
truncated=true; BulkWalker then DISCARDED the partial result and fell
back to an unbounded per-folder BFS that never completes over SSH on
a huge tree, so populateVaultFromRemote never returned.
Fix — stateless offset pagination:
- daemon fs.walk: new WalkParams.Offset. The walk order is
deterministic (filepath.WalkDir), so the client fetches page N+1
with offset = entries already received; the daemon skips that many
emittable entries then fills the next page. No daemon-side cursor
to leak; re-walking skipped entries is local-FS on the remote, not
network. Go test stitches pages == single full walk (no dupes/gaps).
- proto: WalkParams.offset added to Go + the TS mirror (kept in sync).
- BulkWalker.walk: a truncated page is no longer a fallback trigger —
it drains every page via increasing offset and returns the full
rpc-walk tree. Defensive guards: stop on an empty truncated page and
at MAX_PAGES so a broken daemon can't spin forever (surfaced as
truncated=true so populate can warn). Per-folder list stays only for
no-RPC / capability-absent / RPC-threw.
- populateVaultFromRemote: log pages; Notice on 0 entries or an
incomplete (page-guard) load instead of silently showing an empty
vault.
Tests updated for the new contract (pagination drains + empty-page
guard) + Go pagination stitch test. Full unit + Go suites green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
#342: a settings change in the shadow vault was written to the local
`.obsidian/app.json` but never reached the remote, so the next
session's pull found nothing and the change "evaporated". Only the
remote→local half (pullSharedObsidianConfig) existed.
- ShadowVaultBootstrap.pushSharedObsidianConfig: symmetric to pull —
reads each local shared file, JSON-validates (a half-written file
must not clobber a healthy remote copy), writes verbatim to remote;
{pushed,skipped,errored} so a failed push surfaces instead of
silently losing settings.
- SharedConfigWatcher: watches the shadow vault's local config dir and
debounce-pushes divergent shared files. Catches BOTH adapter and
direct-fs writes (the shadow `.obsidian/` is local disk). Echo-
suppressed via markSynced so the pull's own writes / identical
re-saves don't bounce back; a failed push is retried (not masked
as synced).
- main.ts: after the connect pull, seed markSynced with the pulled
bytes, start the watcher (flush = pushSharedObsidianConfig via the
patched adapter, Notice on errored); stop it on disconnect and on
reconnect-failed.
Tests: pushSharedObsidianConfig (verbatim / absent-skip / invalid-
JSON-not-pushed / write-fail-errored) + SharedConfigWatcher (debounce,
echo-suppress, diverge-pushes, burst-coalesce, non-shared-ignored,
null-filename, stop-cancels, failed-push-retried). Full suite 1059
passed/1 skipped; typecheck + lint clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A writer rename reflects into vault.fileMap correctly (renameOne
preserves the TFile identity and fires vault.trigger('rename')), but
Obsidian's own post-adapter Vault.rename reconcile (reconcileFile)
crashes on this Obsidian build — the documented "iu/nu …startsWith"
throw — which aborts FileManager.renameFile's editor-follow and
orphans the open tab. Symptom: the file is renamed but the tab closes
and must be reopened.
RenameLeafFollower owns the editor-follow: our renameOne runs inside
our own vault.trigger('rename'), before Obsidian's later reconcile
crash in the same call stack, so it records the file was open, then
after the stack unwinds re-opens it iff Obsidian dropped the tab.
Idempotent on healthy builds (native follow holds → no-op); gated by
adapterMgr.isPatched() so an unconnected local vault is untouched.
tests/RenameLeafFollower.test.ts locks all four contract paths
(dropped→reopen, survived→no-op, not-open→no spurious tab,
not-active→fully inert).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
#341 was only papered-over on RPC: the adapter fired no writer-side
trigger; the daemon fs.watch echo *eventually* reached the model via
FsChangeListener, but with a real race (editor could save to a dead
path in the window). SFTP was fixed in #346; this makes self-reflect
transport-independent so RPC is genuinely fixed too.
- LocalOpRegistry (new): short-TTL path set the writer records into
synchronously after each applied op (rename records old+new). The
daemon echo can only arrive strictly later, so no record/echo race.
- SftpDataAdapter: `applied(echoPaths, run)` helper folds the
registry.record + the (exception-safe) reflect into one call at
every applied-op site (write/writeBinary/appendBinary/mkdir/remove/
rmdir/rename), reusing the !reconnecting guard from #346/C1.
- FsChangeListener: subscribe() takes an optional localOpRegistry
(stored like lastPathMapper so reconnect-resume reuses the same
instance); handleNotification drops a self-originated echo before
invalidate+applyChange. One check on action.vaultPath covers every
echo shape (incl. watchers that split rename into delete+create)
because both old and new were recorded. Other clients' changes
never pass through record, so multi-client propagation is intact.
- AdapterManager: the reflector is now wired on BOTH transports plus
a per-patch LocalOpRegistry; RPC additionally subscribes the
listener with that registry. Removed applyWriterReflectPolicy /
afterSwapClient (and the main.ts swapClient call): the old
RPC=null-reflector strategy that I5/N3 patched is obsolete — the
reflector is transport-invariant and the adapter object outlives
swapClient, so wiring once in patch() holds for the patched
lifetime; double-fire is prevented by the registry, not by nulling.
Tests: self-reflect-rpc wires the reflector (now 4/4 green, docblock
rewritten); LocalOpRegistry.test.ts (TTL/no-consume/prune/boundary);
FsChangeListener echo-dedup unit (self drop / other-client apply /
no-registry legacy); removed the now-obsolete AdapterManager
afterSwapClient describe.
Validation: tsc --noEmit clean, eslint clean, vitest 70 files /
1047 passed / 1 skipped.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR #346 landed the SFTP writer-reflect fix, so this property suite
now runs far enough to expose a pre-existing harness bug: fast-check
reuses one process across numRuns + shrink reruns, but the generator
resets its liveness model to SEED_PATHS every invocation. Once run K
renamed/removed a seed path on the (shared) remote, run K+1 emitted a
valid-against-fresh-model op against a path that no longer existed —
spuriously failing I1/I2 (counterexample: a lone `rename seed-0 →
fresh-1`). The other suites (self-reflect SFTP 5/5, restart-roundtrip
4/4, invariants.e2e 3/3) pass on real docker sshd, confirming the
production #341/#342 fixes are sound — only this harness was leaky.
Each fast-check run now gets a fresh HarnessVault + FakeFileExplorer
+ reflector and re-seeds SEED_PATHS on the remote, so the generator
model, the remote, and the asserted vault all agree at op 0. Re-seed
is 3 small writes/run — well within the 60s property budget.
self-reflect-rpc remains red by design (RPC echo de-dup is the
documented follow-up, not addressed here).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Second review pass confirmed the prior C1/I1-I5 fixes are sound
(transport ordering verified against ConnectionManager, build anchor
proven to hard-error on drift, C1 test confirmed not a false guard).
This resolves the residual confidence>=80 findings.
- N1: pullSharedObsidianConfig unlinks the tmp file when renameSync
fails (cross-device / perms) instead of leaking an orphan .tmp,
then rethrows into the skip path.
- N5: the result now reports `errored` (remote HAD the file but it
couldn't be pulled) distinctly from `skipped` (superset incl.
benign absence). runAutoConnect surfaces a Notice when errored is
non-empty so a transient SSH hiccup no longer silently leaves
settings stale — a fresh vault (all-absent) stays quiet.
- N2: reflectWrite (new-path insert) and reflectMkdir now log when
insertOne returns null, so the documented "not silently dropped"
contract holds for the insert path too, not just modify/rename.
- N6: the reflect() swallow log includes the error class name so a
systematic VaultModelBuilder bug (TypeError) is triageable apart
from a transient listener fault.
- N3: AdapterManager SFTP-policy test now asserts the wired reflector
is an actual VaultModelBuilder instance (a no-op stub would have
passed the old not-toBeNull check).
- N4: added reconnecting-no-reflect coverage for rmdir + rename
(C1 was only covered for remove) and a throwing-reflector test
for rename (the #341-core op; previously only write was covered).
ShadowVaultBootstrap tests assert the new errored vs skipped split.
Out of scope (tracked as follow-up): copy() has no writer reflect —
a pre-existing gap deliberately deferred, documented by its test.
Validation: tsc --noEmit clean, eslint clean, vitest 1039 passed /
1 skipped.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Follow-up to the review on PR #346. Resolves every confidence>=80
finding from the 6-agent review pass.
Critical
- C1: remove/rmdir/rename now reflect ONLY when the op actually hit
the remote (guarded by !reconnecting), not when merely queued
during a reconnect. A failed QueueReplayer drain no longer leaves
the writer's vault model permanently diverged from remote.
Important
- I1: appendBinary now reflects (it writes via writeBuffer directly,
so it was the one mutating op missing self-reflect); all reflect
calls go through a central try/catch helper so a throwing reflector
/ vault listener can't surface as a spurious write failure.
- I2: pullSharedObsidianConfig validates JSON before writing and
writes atomically (tmp + rename) so a truncated remote file can't
clobber a healthy local copy or tear on crash.
- I3: build-time `satisfies`-style anchor pins VaultModelBuilder to
WriterReflector (tests aren't typechecked; this is the only
compile-time guard against reflect* signature drift).
- I4: unit tests added — VaultModelBuilder.reflect* (incl. folder
guard + idempotency), pullSharedObsidianConfig (missing / throw /
corrupt / mkdir / atomic), SftpDataAdapter reflect wiring (incl.
the C1 reconnecting-no-reflect guard), AdapterManager SFTP-vs-RPC
policy (double-fire regression guard).
- I5: AdapterManager.afterSwapClient re-applies the reflect policy
after a reconnect transport swap (RPC->null, SFTP->builder); wired
into the main.ts swapClient hook.
Advisory
- A1/A2: reflect* logs when the underlying model mutation no-ops
(folder-at-path collision, renameOne/modifyOne returning false) so
the #341-class divergence is diagnosable instead of silent.
- A3: corrected stale/misleading comments (assertConfigRoundTrip
file JSDoc, SftpDataAdapter reflector-field rationale, the
rot-prone AdapterManager "still red by design" note, VaultModel-
Builder dependency-edge phrasing).
- A4: extracted makeWriterReflector() into harnessVault.ts; the 3
e2e suites no longer duplicate the builder-construction cast boilerplate.
- A5: SHARED_OBSIDIAN_CONFIG_FILES is now an `as const` literal tuple.
Validation: tsc --noEmit clean, eslint clean, vitest 1037 passed /
1 skipped (+22 new unit tests).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements the production fixes the Layer 1-3 sync-test framework
(PR #343) asserts. The framework's e2e cases were authored as plain
`it()` that fail on the SSH-integration job until the fix lands; this
PR turns them green and wires them so the diff demonstrates the
framework caught the regression.
#341 — writer self-reflect:
- new `adapter/WriterReflector` interface; `VaultModelBuilder` gains
`reflectWrite/Rename/Remove/Mkdir` (structurally satisfies it, no
adapter→vault import edge).
- `SftpDataAdapter.setWriterReflector(...)` fires the matching
reflect after each successful write/writeBinary/rename/remove/
rmdir/mkdir, so the writer's own vault.fileMap + vault.trigger bus
follow a title-bar rename instead of staying bound to a stale TFile.
- `AdapterManager` wires it for the SFTP transport only; RPC keeps
the FsChangeListener echo path (no double-fire). null reflector =
legacy no-op, so non-shadow callers are unaffected.
#342 — shared-config round-trip:
- `ShadowVaultBootstrap.pullSharedObsidianConfig(...)` (static) copies
the shared allowlist (app/appearance/core-plugins/hotkeys.json)
verbatim from remote into the local shadow config dir.
- called from the connect flow (`runAutoConnect`) before populate so
the next Obsidian restart reads fresh settings, and from the Layer-2
helper so the test exercises the real path.
Test wiring: self-reflect / invariants / invariants.property e2e
suites now wire the reflector in beforeAll; assertConfigRoundTrip
calls the new pull step.
Validation: tsc --noEmit clean, eslint clean, vitest unit 1015
passed. SSH-integration e2e cases require docker sshd (covered by the
integration job).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the expectFailingWithShape wrapper with plain assertions of
the post-fix behaviour. The semantic changes from bug-reproducer
(green today, red after fix) to classic TDD (red today, green after
fix):
- The CI integration job report directly answers "are #341 / #342
fixed yet?" — red means no.
- The fix PR diff has no wrapper to remove; once the production
code does the right thing the assertions just pass.
- Test names describe the intended contract, not today's broken
state.
Failure discrimination still works at the message level rather than
the pass/fail signal:
- Layer 1 timeouts read "FakeFileExplorer: no <event> for ..."
— #341 unaddressed.
- Layer 2 throws "local shadow vault missing <basename>"
— #342 unaddressed.
- Other shapes (TypeError, ECONNRESET) → infra regression,
investigate separately.
SSH integration is a non-required check on `next` (the three
required checks are Lint & type-check, Test (ubuntu-latest), and
version-check), so this PR can merge while these tests are red.
After the #341 and #342 fix PRs land the integration job turns
green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
it.fails(name, fn) accepts ANY thrown error as proof that the
production bug is reproduced. That is a hole: a TypeError in a
helper, an ECONNRESET on a flaky network, or any other infrastructure
failure silently passes as yes the bug is still here. We hit this
exact trap earlier in this PR: a typoed fast-check API call threw
inside the property generator, and the surrounding it.fails block
stayed green because vitest only saw something threw.
This commit closes the hole. New helper expectFailingWithShape wraps
the production call and asserts BOTH that it threw AND that the
throw message matches a specific regex. Two distinct failure modes
get clear diagnostics:
1. fn resolved without throwing: production bug fixed; remove the
wrapper and assert success directly.
2. fn threw with the wrong shape: test infrastructure broke (helper
bug, env issue, network glitch); diagnose that before touching
production code.
Every regression-target test now uses the wrapper:
- self-reflect.e2e.test.ts (5 cases, SFTP)
- self-reflect-rpc.e2e.test.ts (4 cases, RPC)
- restart-roundtrip.e2e.test.ts (4 cases, bootstrap pull)
- invariants.e2e.test.ts (2 scenarios, inverted assertions)
- invariants.property.e2e.test.ts (1 property)
The fix PR diff is now slightly larger (must remove the wrapper, not
just the .fails marker), but the trade is worth it: the framework
is genuinely load-bearing instead of accidentally green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previous fix called `g(fc.integer({...}))` — fc.gen() v4 instead
takes a builder factory + spread args: `g(fc.integer, { min, max })`.
For closed-over Arbitrary values (opKindArb, contentArb), wrap them
in `() => arb` so they satisfy the (builder) signature.
Verified locally: `node -e ... fc.sample(arb, 3)` yields three valid
{v, s} records with no TypeError.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
`fc.gen()` returns an Arbitrary<g-function>, not a callable. Use
`.map((g) => ...)` to consume the generator. Inside the closure,
arbitraries must be constructed first then passed to `g(...)`:
before: g(fc.integer, { min, max })
after: g(fc.integer({ min, max }))
Same for `fc.shuffledSubarray`. Caught by CI on the SSH integration
job: `TypeError: gen(...) is not a function`.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extends PR #343 in three independent directions:
A. Property-based Layer 3 (fast-check)
- `helpers/propertyTesting.ts` — `opSequenceArbitrary` produces
random VALID adapter op sequences (rename targets only live
paths, remove only live paths) by threading an in-memory
Set<string> model through fc.gen(). When an invariant fires,
fast-check shrinks the failing sequence to a minimal reproducer.
- `invariants.property.e2e.test.ts` — one it.fails property
(numRuns=5, maxOps=3, timeout=60s) asserting I1+I2 hold for
every short random sequence. Plus a generator self-test that
samples 50 sequences and verifies the model never proposes an
invalid op.
B. RPC transport variant of Layer 1
- `self-reflect-rpc.e2e.test.ts` — same 4 cases as the SFTP
self-reflect suite, run against a daemon + RPC client. Proves
the bug is not SFTP-specific; today both transports fail
because no writer-side wiring exists.
E. Public design doc
- `docs/en/architecture/bidirectional-sync-gap.md` — adapts the
internal `.claude/PRPs/reviews/issues-341-342-design-review.md`
for the Quartz docs site. Linked from issue comments so
external readers can see the full analysis + framework map.
Version bump: 1.0.49-beta.0 → 1.0.49-beta.1.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Final layer of the sync-test framework. Layers 1 and 2 each test a
single contract violation directly; Layer 3 raises the abstraction:
declare invariants the system should always satisfy and have a
scenario runner that drives sequences of adapter ops, checking every
invariant after every step.
Catalog (initial):
I1. WRITER_VAULT_FILEMAP_MIRRORS_ADAPTER
`vault.fileMap` reflects every adapter mutation locally.
Today fails: SftpDataAdapter doesn't update writer-side
vault model on local-originated ops. Captures #341.
I2. ADAPTER_OP_FIRES_MATCHING_TRIGGER
Every adapter mutation produces a matching `vault.trigger`
on the writer (event-bus angle of #341).
I3. SHARED_CONFIG_ROUND_TRIPS
Shared .obsidian/* config files survive plugin restart.
Tracked here for completeness; exercised by Layer 2 cases.
Layer 3 ships:
- `helpers/invariants.ts` — `Invariant` / `InvariantContext` /
`InvariantResult` types, three invariant constants, the
`runScenario(...)` driver, `formatReport(...)` for failure
diagnostics.
- `invariants.e2e.test.ts` — two `it.fails` scenarios:
* basic-crud: write/modify/rename/remove
* rename-chain: three sequential renames
Plus one always-green sanity test that the catalog stays
well-formed (guard against drift).
Property-based wrapper (random op sequences + shrinking with
fast-check) is deferred to a follow-up RFC. The `Invariant`
interface is stable across both runners, so the upgrade is
purely additive.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the second layer of the sync-test framework: bootstrap-side
round-trip for shared Obsidian config files. Each case seeds
`<vaultRoot>/.obsidian/<basename>` on the remote, runs
`ShadowVaultBootstrap` against a fresh local tmp dir, and asserts the
local shadow vault carries the same content. Today all 4 cases fail
because the bootstrap has no remote-pull step.
Layer 2 ships:
- `helpers/assertConfigRoundTrip.ts` — seed remote, run bootstrap,
compare local↔remote with order-insensitive deep-equal on the JSON
lattice. Centralised error formatting so each case stays declarative.
- `restart-roundtrip.e2e.test.ts` — 4 `it.fails(...)` cases for the
shared-config allowlist:
* app.json ← issue #342 reproducer
* appearance.json — theme + UI font/size
* core-plugins.json — enabled built-ins
* hotkeys.json — keybinding overrides
The `.fails` markers come off in the #342 fix PR, which adds the
remote-pull step to `ShadowVaultBootstrap`. The diff of that PR
will then demonstrate the framework caught the regression.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the writer-side counterpart to the existing M9 sync-reflect E2E
matrix. Refactors `HarnessVault` + `asArrayBuffer` out of
`sync.e2e.test.ts` so the new self-reflect suite (and the upcoming
restart + invariant suites) can share them without duplicating the
event-bus semantics.
Layer 1 ships:
- `helpers/harnessVault.ts` — `HarnessVault`/`HarnessTFile`/
`HarnessTFolder`/`asArrayBuffer` extracted from `sync.e2e.test.ts`.
No behaviour change.
- `helpers/assertSelfReflect.ts` — writer-side variant of
`assertSyncReflect`. Same span-and-budget contract, but the
FakeFileExplorer is attached to the *writer's* vault rather than
a reader peer.
- `self-reflect.e2e.test.ts` — 5 `it.fails(...)` cases documenting
the contract `SftpDataAdapter` should satisfy:
* adapter.write fires vault.trigger('create')
* adapter.write (overwrite) fires vault.trigger('modify')
* adapter.rename fires vault.trigger('rename') ← issue #341
* adapter.remove fires vault.trigger('delete')
* post-rename writer.vault.fileMap reflects the new path
The `.fails` markers come off in the #341 fix PR; that PR's diff
then proves the framework caught the regression.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR #294 migrated 5 root-level docs into docs/en/. This sweeps the
stale path references that lived outside the moved files:
- README.md, CONTRIBUTING.md, SECURITY.md: link/cite paths updated
from docs/architecture-*.md, docs/plugin-compatibility.md,
docs/testing-strategy.md to their new docs/en/ locations.
- README.md: "pre-1.0" status block replaced with the current
"1.0 released, community-store listing pending" framing — aligns
with the same edit landed in docs/en/index.md.
- plugin/src code-comment references (AdapterManager, SftpDataAdapter,
main.ts, tests/compat/CompatVault.ts) updated to the new doc paths.
- docs/en/index.md, docs/en/architecture/{index,shadow-vault}.md,
docs/en/contributing/documentation.md: small text-level catch-up
edits (path references, status note) that were missed in #294.
* fix(tests): repair 6 CI failures introduced by recent PR merges
ObsidianRegistry.test.ts + ShadowVaultBootstrap.test.ts:
Wrap `import * as fs from "fs"` in `vi.mock("fs", () => ({ ...actual }))`
so vitest creates a configurable namespace. The raw ESM `fs` namespace
is non-configurable in Node + Vitest 4, which made the existing
`vi.spyOn(fs, "writeFileSync"|"renameSync"|"symlinkSync")` calls throw
`Cannot redefine property` at test setup time. The mock re-exports
`actual` so all non-spied fs calls keep their real behaviour.
ShadowVaultBootstrap.test.ts (collectPendingPluginSuggestions):
Two tests asserted `data.pendingPluginSuggestions` equal `[]` when
source `community-plugins.json` is invalid/non-array. The implementation
(ShadowVaultBootstrap.ts:121-126) deliberately omits the key entirely
when `pending.length === 0` -- the absent key is the "no suggestions"
signal that ShadowStartupCoordinator relies on for its no-op fast path.
Aligned the test with the implementation contract.
ConflictResolver.test.ts:58:
`swapClient` test asserted `writeBinary` was called with a third
`undefined` arg. Other tests in the file (e.g. line 91) and the
source (ConflictResolver.ts:88,92,129) confirm the call passes only
two args. Removed the spurious trailing `undefined`.
* chore: bump version to 1.0.36
* ci: bump Node heap to 6 GiB to stop intermittent test-job OOMs
Vitest 4's default `forks` pool plus jsdom on every test file pushed
peak RSS just past Node's default ~4 GiB old-space ceiling once the
suite grew to ~70 files / ~1000 tests. The CI logs showed `FATAL ERROR:
Reached heap limit Allocation failed - JavaScript heap out of memory`
during ubuntu-latest, ubuntu-node22, macos-latest, and windows-latest
test jobs on the last several main-branch runs (it predates this PR;
the earlier 6 unit-test failures were just the first symptom).
Apply the same env to release.yml's test gate so a release tag push
does not flake on the same wall.
GitHub-hosted runners advertise 7+ GiB, so 6 GiB is safe headroom
without crowding the kernel/page-cache budget.
* fix(tests): SftpClient unit tests caused worker OOM via infinite BFS
Root cause of the CI test-job crashes:
tests/SftpClient.unit.test.ts > rmdir() > recursive: removes all
files and subdirs before root
mocked sftp.readdir with a path-agnostic `mockImplementation` that
returned `[file.md, sub (dir)]` for every call. SftpClient.rmdir's
recursive branch calls listRecursive, whose BFS then re-discovered
`sub` inside `/vault/sub`, then `/vault/sub/sub`, then
`/vault/sub/sub/sub`, ... -- growing the visited Set, the queue, and
the result array without bound until the worker hit
`FATAL ERROR: Reached heap limit`. Replaced with a path-aware mock so
only `/vault` has children; the BFS terminates after one level.
Also fixed `listRecursive() returns files from nested subdirectories
via BFS`: the assertion expected `['deep.md', ...]` but
`SftpClient.listRecursive` deliberately returns the full path under
root as relativePath (`sub/deep.md`), not the basename -- every
downstream consumer needs the unambiguous form.
Plus pass `--max-old-space-size=6144` to vitest workers via
`test.execArgv` so future legitimately-large suites have headroom
without needing per-job NODE_OPTIONS overrides. (In Vitest 4 the
top-level `poolOptions` was removed; both `pool` and `execArgv` are
now top-level keys inside `test:`.)
* test(AdapterManager,ShadowStartupCoordinator): coverage for lifecycle and startup paths
AdapterManager.test.ts (17 tests):
- PATCHED_METHODS constant: shape, read-side, write-side, bridge, basePath
- isPatched() false on fresh instance
- dataAdapter getter null on fresh instance
- replayOfflineQueue(): early return (both labels)
- showPendingEditsModal(): early return with no queue
- restore(): idempotent, calls unsubscribe(), clears transferTracker
ShadowStartupCoordinator.test.ts (5 tests):
- prepareForAutoConnect(): resolves when suggestions absent/empty
- does not call saveSettings when there is nothing to install
- idempotent on repeated calls
Also adds requestUrl stub to __mocks__/obsidian.ts so the coordinator
import succeeds without a real Obsidian runtime.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(shadow): expand ShadowStartupCoordinator coverage to F2 design intent
Add 15 tests covering all pendingPluginSuggestions decision branches:
- no suggestions (undefined/empty array): no modal, no side effects
- decision later: snapshot preserved, no save, no install
- decision skip: snapshot cleared, saveSettings called, no install
- decision install: installMissing called with selected ids, snapshot cleared,
settings saved; edge cases: empty selection, all-failed installs
Fixes the previous test file which only covered no-suggestion paths and
left the core F2 plugin-suggestion flow (arch §9) entirely untested.
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(RpcConnection): cover auth ok=false and server.info error branches
Adds two tests that exercise previously uncovered branches in
establishRpcConnection(): one where auth succeeds but the daemon
returns result.ok=false (→ AuthInvalid), and one where server.info
replies with an error envelope (→ connection cleaned up before rethrow).
Also extends ServerScript/startFakeDaemon to support both scenarios.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(ShadowVaultBootstrap): cover parse-failure and symlink-fallback branches
Adds 7 new tests for previously uncovered code paths:
seedCommunityPlugins():
- Rewrites shadow community-plugins.json when existing file is invalid JSON
- Rewrites when existing file is valid JSON but not an array
collectPendingPluginSuggestions():
- Returns [] when source list is not an array (valid JSON object)
- Returns [] when source list is invalid JSON
- Returns suggestion with sourceData=null when plugin data.json is invalid
readBaseDataJson():
- Starts from {} when shadow data.json is corrupt (no crash)
installPlugin():
- Falls back to copyFileSync when symlinkSync throws EPERM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds two tests that exercise previously uncovered branches in
establishRpcConnection(): one where auth succeeds but the daemon
returns result.ok=false (→ AuthInvalid), and one where server.info
replies with an error envelope (→ connection cleaned up before rethrow).
Also extends ServerScript/startFakeDaemon to support both scenarios.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- tests/errorMessage.test.ts (new): covers both branches of errorMessage()
(Error instance → .message, non-Error → String()) and both branches of
asError() (Error passthrough, non-Error wrapping), including subclasses,
null, undefined, and numeric inputs.
- tests/RpcError.test.ts (new): covers RpcError constructor (name/code/
message/data set correctly, optional data omitted), instanceof Error,
and is() true/false branches with close numeric codes.
Also bundles isPreconditionFailed() from src/proto/rpcError.ts: all
branches tested — PreconditionFailed match, wrong code, missing code
property, null, string/number primitives, and duck-typed plain object.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- ObsidianRegistry: add two tests for writeAtomic error propagation via
vi.spyOn on fs.writeFileSync and fs.renameSync, covering the previously
untested throw paths in the atomic-write helper.
- ServerDeployer: assert that the deadline-exceeded error includes the
`(last error: …)` suffix when readRemoteFile always rejects, covering
the lastErr branch in waitForToken.
- SshConfigReader: add two Port edge-case tests — non-numeric Port value
(parseInt NaN → 22 fallback) and Port set in a Host * defaults block —
covering two branches in parseBlocks and mergeDefaults that had no test.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds 4 tests for previously uncovered code paths:
getResourcePath():
- Returns data: fallback when resourceBridge is null (always the case in
existing tests, but never explicitly asserted)
- Returns bridge URL when resourceBridge.isRunning() is true
- Returns data: fallback when resourceBridge.isRunning() is false
readBuffer():
- Still returns file content and caches with mtime=0 when the
opportunistic stat-after-read call throws (lines 733-738)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a test for the case where classifyError receives an error with a
`.code` property that mapSyscallCode does not recognise (e.g. ENOENT).
The inner `if (cat)` guard at line 131 was always true in existing tests
because every tested syscall code (ENOTFOUND, ECONNREFUSED, …) is in the
switch. With ENOENT, mapSyscallCode returns null, the false branch is taken,
and execution falls through to the message-pattern / "unknown" fallback.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes#109. Brings the Phase C E2E suite from 8/9 cases to 9/9 by
adding the disconnect/queue/reconnect case and deepening two
existing cases per the issue spec.
## What changed in plugin/tests/integration/sync.e2e.test.ts
- "large file (1 MB)" → "large file (10 MB)". The issue specs 10 MB
to push the wire-transfer envelope and verify the daemon doesn't
time out on long writes. Budget multiplier bumped 5x → 20x for
proportional headroom on slow CI links (~50 MB/s effective sftp
throughput → ~200 ms transfer + reader's fs.changed → stat round-trip).
- 3-way conflict deepened. The pre-existing test only asserted the
daemon rejects with PreconditionFailed (-32020). The new variant
wires a real ConflictResolver + AncestorTracker on a third "Bob"
adapter and verifies the full pipeline:
Alice creates "version-1" (becomes Bob's ancestor)
Bob reads → ancestor remembered
Alice overwrites with "alice-edit" (becomes "theirs" on Bob)
Bob writes "bob-edit" → PreconditionFailed
→ ConflictResolver.resolve → onTextConflict({ancestor,mine,theirs})
→ stub returns keep-mine
Remote ends up holding "bob-edit"
This is the production wiring used by ThreeWayMergeModal — the old
test only proved the daemon rejects, not that the resolver chain
runs end-to-end.
- New: "disconnect → queue → reconnect" case. Pre-populates an
OfflineQueue with mkdir + 3 writes and runs QueueReplayer against
the connected writerAdapter. Asserts:
- report.errors === []
- report.drained === ops.length
- queue is empty after drain
- all 4 paths land on the reader's FakeFileExplorer
Validates F3 (offline queue) + F6 (replay) + F19 (in-order
delivery) from the plan's functional matrix. The "forcibly close
the SSH socket" half of the spec is AdapterManager orchestration
already covered by unit tests; this integration assertion is the
one the unit suite can't make (queued ops surfacing as fs.changed
events on the reader's vault).
## Imports added
- node:os + node:path (for the queue's tmp directory)
- ConflictResolver, ThreeWayPanes, AncestorTracker
- OfflineQueue, QueuedOp, QueueReplayer
## Verified locally
- tsc --noEmit clean
- npm run lint clean
- vitest run (unit suite) 780 pass / 1 Windows-skipped (integration
tests don't run under `vitest run`; they're routed through
vitest.integration.config.ts and require Docker test sshd)
## CI
The new integration tests will run under the `SSH integration` job
in .github/workflows/integration.yml, which spins up the Docker
test sshd. Local Windows can't exercise them; CI is the verification
path.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#149. Adds a single-pane remote terminal in the right sidebar,
backed by xterm.js and an ssh2 PTY channel multiplexed onto the
already-authenticated SSH connection that SftpClient owns.
## Files
- `src/ssh/SftpClient.ts` — `openShell({rows, cols, term?, cmd?})`
method (mirrors existing `openUnixStream` / `exec` shape). When `cmd`
is supplied, runs it under the PTY (e.g. `/usr/bin/zsh -l`); otherwise
the remote launches the user's default login shell.
- `src/ssh/RemoteShell.ts` (NEW) — thin lifecycle wrapper around the
ssh2 ClientChannel. `open() / write() / resize() / close() / isOpen()`.
Stdout + stderr both surface as a single `onData(chunk)`. Two
synchronous-flag guards keep `open()` race-free:
`opening` blocks concurrent open() calls before they reach the
await; the post-await `if (this.closed) ch.end()` check prevents a
channel leak when close() runs while open() is pending.
- `src/ui/RemoteTerminalView.ts` (NEW) — `extends ItemView`, renders
xterm.js + FitAddon, debounced 100 ms ResizeObserver → setWindow,
graceful "Not connected" / "Failed to open shell" disconnected
states. Reads font/scrollback/shell from plugin settings each `onOpen`.
The `onClose` callback logs `reason`/`cause` unconditionally so the
channel-close audit trail isn't lost when the View has torn down.
The `shell.open()` catch block disposes + nulls shell/term/fit so
late channel events can't reach a half-constructed view.
- `src/main.ts` — `registerView(VIEW_TYPE_REMOTE_TERMINAL, ...)` plus
`addCommand("Open remote terminal")` (checkCallback gates on
client.isAlive). `disconnect()` calls
`app.workspace.detachLeavesOfType(VIEW_TYPE_REMOTE_TERMINAL)` so the
shell channel close fires while ssh2.Client is still around.
`openRemoteTerminal()` uses `setActiveLeaf` rather than `revealLeaf`
to keep the minAppVersion 1.4.0 contract (revealLeaf needs 1.7.2).
`openingTerminal` re-entrant flag prevents two leaves being created
by rapid command-palette activations.
- `src/types.ts` + `src/constants.ts` — three new optional settings:
`terminalShell?: string`, `terminalFontSize?: number` (default 12),
`terminalScrollback?: number` (default 1000).
- `src/settings/SettingsTab.ts` — Terminal section with the three
inputs (font validated 6-32 px, scrollback 100-100_000 lines).
- `styles.css` — terminal pane host/disconnected styling + inlined
xterm.js v5 default stylesheet (~3 KB, MIT license preserved) so
users don't need a separate CSS asset.
- `package.json` — `@xterm/xterm@^5.5` + `@xterm/addon-fit@^0.10`
added as production dependencies.
- `.github/workflows/release.yml` + `.github/workflows/ci.yml` —
bundle cap raised 600 -> 800 KB to absorb the bundled xterm
(770 KB actual). esbuild splitting needs ESM which Obsidian doesn't
support, so single-bundle is the only option. Comment in workflows
explains the rationale.
- `tests/RemoteShell.test.ts` (NEW) — 20 unit tests against a fake
ssh2 ClientChannel: open/double-open guards, concurrent-open guard,
close-while-pending end()s the channel + bypasses listener-attach,
stdout+stderr routing, write/resize/close, lifecycle invariants
(no double-onClose, suppressed-after-close events, etc.).
- `vitest.config.ts` — `RemoteTerminalView.ts` added to coverage
exclude (xterm.js + ResizeObserver under jsdom is impractical to
unit-test; covered by manual smoke against a real Obsidian window,
consistent with the other src/ui/*Modal/Bar exclusions).
## Verified
- `tsc --noEmit` clean
- `npm run lint` clean (sentence-case + active-window-timers
+ no-floating-promises + minAppVersion gates all satisfied)
- `vitest run` 780 pass / 1 Windows-skipped (was 760)
- `node esbuild.config.mjs production` -> 770 KB main.js (under 800 KB cap)
## Out of scope (per #149 v1)
- Multiple terminal tabs
- Persisted scrollback across re-opens
- Split panes
- Search-in-scrollback
- back-pressure on huge pastes (ssh2 handles it; we trust write() return)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OnboardingModal:
- Generate-key button now has UI-level coverage (M1) — happy path,
EEXIST refusal, and non-absolute-path guard. SshKeyGen is vi.mock'd
so the test never touches ~/.ssh.
- Browse-button test switched from raw querySelector to clickButton
(M2) — uses the helper this PR introduced, fails loudly if the
button is missing instead of relying on `.toBeTruthy() + !.click()`.
Mock:
- makeButton: drop the dead `typeof document !== 'undefined'` branch
(L1). The mock requires jsdom — no need to defend against the
node env that no longer exists.
- clickButton JSDoc documents the single-microtask drain limit and
points at vi.waitFor for setTimeout/fire-and-forget cases (L4).
Setup:
- Drop the no-op `afterAll` from vitest.setup.ts (L2) — the polyfills
are set at module init and never torn down anyway.
- Switch clearNotices import to 'obsidian' (L3) so the alias is
exercised end-to-end and the setup file isn't coupled to the
physical mock path.
Coverage: OnboardingModal.ts 50.9→66.66% lines (+15.7), 36.84→47.36%
branches (+10.5). Global lines 69.62→70.4%.
Tests: 760 → 763 (+3 Generate-key cases).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mock fixes (false-positive vectors):
- addClass/removeClass widened to (...classes: string[])
- toggleClass widened to (string | string[], boolean)
- setText widened to (string | DocumentFragment) with proper branch
- createEl/createDiv/createSpan accept the optional callback Obsidian
patterns use; mock used to drop it silently
- simulate{Input,Change,Click} now throw if no callback was registered
— green-on-broken-wiring is the bug to avoid
- Native click event wired on buttonEl so query-and-click works
- New helpers: findButton, clickButton, getSettingFor, getSettingsIn,
AnyComponent discriminator (kind: 'text' | 'toggle' | 'button' | 'dropdown')
- Drop _obsidianMockPatched leak from global HTMLElement; use Symbol.for
Setup:
- vitest.setup.ts auto-clears the Notice buffer before each test so
individual files don't have to remember to do it
SettingsTab tests:
- fakePlugin typed via SettingsTabPlugin (Pick-style slice) so a new
required method on RemoteSshPlugin would surface as a compile error
- vi.mock isolates the real telemetry singleton from the toggle path
- Real interaction tests added: Connect/Disconnect clicks,
clientId text input drives sanitizeClientId, reconnect-attempts
in-range/out-of-range, telemetry toggle change, daemon Restart
success + failure paths
- displayTab(plugin) helper de-duplicates render boilerplate
- SettingsTab.ts coverage 33→53% lines, 27→60% branches
OnboardingModal tests:
- internals(modal) helper replaces six near-identical inline casts
- vi.waitFor replaces the fragile `await Promise.resolve()` x2 flush
- Browse-button empty-host guard test added
vitest.config: trim historical 78/70/72 numbers from threshold comment
Net: 18 → 26 tests; coverage 68.78/63.23/64.43 → 69.62/64.25/65.29.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Last open Phase F item from #126. Stand up an obsidian runtime mock
so UI/settings code can be unit-tested without a real Obsidian
process, then drop the src/ui/** + src/settings/** coverage excludes.
New:
- plugin/tests/__mocks__/obsidian.ts — runtime shim for Modal,
Setting (chainable, with addText/addToggle/addButton/addDropdown
components that record onChange/onClick callbacks for test-side
driving), Notice (recorded into a buffer), PluginSettingTab,
App / Vault / FileSystemAdapter / Plugin. Patches HTMLElement
prototype with Obsidian's createEl / createDiv / createSpan /
empty / addClass / removeClass / setText / toggleClass helpers.
Each Setting builds a real DOM row inside its container so
tests that walk `containerEl.textContent` see the rendered names.
- plugin/tests/ui/OnboardingModal.test.ts — 9 tests:
- initial render (heading + 3 numbered sections)
- profile defaults (UUID id, "My remote" name)
- validation gates: host / username / remotePath / privateKeyPath
(4 separate Notice assertions)
- Save without testing fires onFinish with profile + dismiss
- Skip / dismiss fires onFinish with no profile + dismiss
- alreadyDismissed guard prevents double-dismiss after a save
- plugin/tests/settings/SettingsTab.test.ts — 9 tests:
- base render (SSH profiles / This device / Advanced sections)
- Daemon section omitted when status: none, rendered for running
+ down (with version/capabilities/error text)
- one row per saved profile with the right host:port format
- "Connect" button on non-active profiles
- clientId saveSettings round-trip
- Telemetry section renders the toggle + privacy copy
- reconnect-attempts setting label
Wiring:
- vitest.config.ts: resolve.alias maps `obsidian` → the new mock,
environment switched node → jsdom (jsdom polyfills HTMLElement +
document for the obsidian-mock to patch onto), excludes drop
src/ui/** + src/settings/** from coverage so the new suites
count toward thresholds.
- jsdom@^25 added as a devDependency.
Counts: 54 test files / 752 pass / 1 Windows-skipped (was 52/734 —
+18 new tests).
Closes the F UI-test-coverage checkbox in #126. The 4 Phase F
deliverables (#127, #128, #130, plus F17/F22) plus this UI
foundation make Phase F complete; Phase G (BRAT beta + telemetry
bug bash) is next on the v1.0 critical path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drives the metadataCache.getFileCache(file).listItems query shape that
both Tasks (raw checkbox scan) and DataviewJS (arbitrary JS over the
same cache) collapse to. Issue (#124 F14) calls for a 10-file fixture
vault with mixed task states.
Hot APIs exercised:
- vault.getMarkdownFiles() — enumerate the indexable corpus
- metadataCache.getFileCache(file).listItems — raw task records
- metadataCache.getFileCache(file).frontmatter — for the
DataviewJS "filter by tag" branch
New:
- plugin/tests/compat/fixtures/tasks/{01..10}-*.md — 10 fixtures
spread across all-open, mixed, mostly-done, completed, large
(10-task), single-task, empty, no-tasks, frontmatter-tagged,
archived shapes. Total 18 open + 16 done across 8 files-with-tasks.
- plugin/tests/compat/tasks.test.ts — 12 tests:
- vault enumeration + per-file aggregate row count (10 each)
- vault-wide totals (open=18, done=16, total=34, files-with=8)
- per-file open/done counts match the fixture distribution
- 100%-complete vs all-open file detection
- DataviewJS-shape: filter by frontmatter tag, completion ratio,
top-3 by open count
docs/plugin-compatibility.md: added a new Tasks row in the main
compat matrix marked "✅ verified-by-harness (#124 F14)" with a
pointer to the test file. (Tasks was previously only listed in the
basePath survey table — no main-matrix entry until now.)
Counts: 52 test files / 734 pass / 1 Windows-skipped.
This completes Phase E F11-F14. Only the nightly compat workflow
(.github/workflows/compat.yml) remains as a follow-up in #124.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Medium:
- M1: CompatVault.createBinary now defensively copies the input
ArrayBuffer. Previously `new Uint8Array(data)` shared the caller's
buffer, so a post-call mutation would silently change the store —
the mirror problem readBinary already solved on the way out. New
unit test exercises the raw shared-buffer case (asArrayBuffer's
.slice() in the existing tests would have masked the bug).
Low:
- L2: pseudoRandomBytes seed-guard comment clarified. The `| 0` is
int32 coercion to keep state shift-stable, NOT a fallback filter.
xorshift breaks at state=0 only, so we replace explicitly.
L1 (YAML arrays as strings) noted but not actionable — already
documented limitation, no test depends on the misparsed value.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Simulate Templater's tp.file.create_new(template, filename) +
subsequent vault.modify() flow against the harness. Verifies the
public Vault APIs round-trip the way Templater expects without
embedding Templater itself.
Hot APIs exercised:
- vault.read(template) — load template body
- vault.create(filename, content) — drop the rendered file
- vault.modify(file, content) — user edits the result
- vault.on('create' | 'modify') — Templater listens for both
- metadataCache.getFileCache(...) — frontmatter shows up post-create
New:
- plugin/tests/compat/fixtures/templater/Templates/{daily-note,
project, meeting}.md — 3 templates covering date placeholder,
frontmatter + tp.file.title, and a no-date negative-control.
- plugin/tests/compat/templater.test.ts — 6 tests:
- daily template renders <% tp.date.now %> against a frozen clock
- project template renders frontmatter + tp.file.title heading;
metadataCache reflects the new frontmatter immediately
- meeting template renders without any date placeholder
- duplicate target path rejection
- missing template rejection
- vault.modify round-trips body + cache refresh
The <% expr %> evaluator is a deliberately-tiny substitute for
Templater's real one — just enough to cover tp.date.now(fmt) and
tp.file.title, the placeholders the bundled templates use.
docs/plugin-compatibility.md: Templater row flipped from
"🟡 expected (smoke pending after #170)" to
"✅ verified-by-harness (#124 F12)".
Counts: 50 test files / 713 pass / 1 Windows-skipped.
F13 Excalidraw, F14 Tasks, and the nightly compat workflow remain
as follow-up PRs in #124.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add the first plugin scenario on top of the E-α harness. Drives
metadataCache.getFileCache() for every file in vault.getMarkdownFiles()
— the same shape Dataview's dv.pages('"folder"') query takes.
Reorg:
- Existing 4 harness fixtures moved to fixtures/harness/. Each
scenario gets its own subdirectory so getMarkdownFiles assertions
in one suite can't pick up another suite's fixtures. The harness
test now points at fixtures/harness/.
New:
- plugin/tests/compat/fixtures/dataview-pages/{task-one..five}.md —
4 tasks with frontmatter (status × priority × assignee × tag) plus
one with no frontmatter (negative-control).
- plugin/tests/compat/dataview.test.ts — 8 tests covering:
- getMarkdownFiles returns all 5 (including no-frontmatter)
- frontmatter scalars round-trip per file
- status / tag / assignee aggregations match expectations
- sum-priority aggregation
- dvPages folder-scope helper filters by prefix
docs/plugin-compatibility.md: Dataview row flipped from
"🟡 expected (check on first build)" to "✅ verified-by-harness (#124 F11)"
with a pointer to the test file.
Counts: 49 test files / 707 pass / 1 Windows-skipped.
F12 Templater, F13 Excalidraw, F14 Tasks, and the nightly compat
workflow remain as follow-up PRs in #124.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Medium fixes:
- M1: loadFixtures is now async + awaits each vault.create. Drops
the void-cast that swallowed rejections (e.g. duplicate path) and
decouples the harness from CompatVault.create's current synchronous
internals. harness.test.ts beforeEach already awaits.
- M2: CompatMetadataCache.getFileCache JSDoc clarifies it's a pure
read; the computation is in rebuildFor.
Low fixes:
- L1: Comment on CompatTFile.vault! / CompatTFolder.vault! explaining
the assertion is "don't surface as a typing error" not "promise this
is initialised" — readers shouldn't treat the dangling field as a
bug. Plugins that touch file.vault directly are out of harness scope.
- L2: New asFile(TAbstractFile | null) helper in harness.test.ts.
Replaced 12 `as never` casts with a single named cast that throws
on null + documents intent. Only the comment reference remains.
- L3: parseFrontmatter normalises CRLF → LF so a Windows-authored
fixture parses identically. New unit test asserts a CRLF body
round-trips frontmatter + headings the same as LF.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a duck-typed Vault + MetadataCache (Approach B from the issue
matrix) so subsequent plugin scenarios can run without a real
Obsidian process. Fast, deterministic, vitest-native; no Electron
binary download.
New files:
- plugin/tests/compat/CompatVault.ts — CompatVault, CompatTFile,
CompatTFolder, CompatMetadataCache. Hot duck-type surface
identified from the docs/plugin-compatibility.md top-20 survey:
vault.getMarkdownFiles / getAbstractFileByPath / read /
cachedRead / readBinary / create / createBinary / modify /
delete + on/offref/trigger events (create | modify | delete |
rename), and metadataCache.getFileCache returning CachedMetadata
with frontmatter (YAML scalars), headings (1-6 + text), and
listItems (checkbox tasks open vs done).
Adds-as-needed when a future plugin scenario hits an unmocked
method — no full obsidian.d.ts mock.
- plugin/tests/compat/fixtures.ts — loadFixtures(vault, dir) walks
for .md files; fixturesDir() resolves to the bundled fixtures dir.
- plugin/tests/compat/fixtures/{note-with-frontmatter,
note-with-headings, note-with-tasks, plain-note}.md — 4
hand-crafted markdown fixtures covering each parser shape plus
the negative-control case.
- plugin/tests/compat/harness.test.ts — 11 smoke tests covering the
full surface: getMarkdownFiles enumeration, cachedRead/read parity,
text + binary CRUD with events, frontmatter scalar parsing,
heading extraction, task open/done discrimination, plain-note
empty cache, modify-rebuild, delete-invalidate.
Documented the surface contract + "adding a new scenario" recipe in
docs/plugin-compatibility.md so F11-F14 PRs can plug in directly.
This is the E-α subtask of #124. F11 (Dataview), F12 (Templater),
F13 (Excalidraw), F14 (Tasks), and the nightly compat workflow
land as follow-up PRs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>