UX redesign: installer + provider rename + /learn-codebase + welcome card + SessionStart hint (#2255)
* feat(ux): claude-mem UX improvements with installer enhancements
Squashed PR #2156 commits for clean rebase onto main:
- feat(installer): add provider selection, model prompt, worker auto-start
- refactor: rename *Agent provider classes to *Provider
- feat: add /learn-codebase skill and viewer welcome card
- feat(worker): inject welcome hint when project has zero observations
- fix(pr-2156): address greptile review comments
- fix(pr-2156): address coderabbit review comments
- fix(pr-2156): persist CLAUDE_MEM_PROVIDER for non-claude in non-TTY mode
- fix(pr-2156): file-backed settings reads in installer + env-first SKILL doc
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* build: rebuild plugin artifacts after rebase onto v12.4.7
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(skills): strip claude-mem internals from learn-codebase
The learn-codebase skill, install next-step copy, WelcomeCard, and
welcome-hint previously walked the primary agent through worker endpoints
and synthetic observation payloads. The PostToolUse hook already captures
every Read/Edit the agent makes — the agent should have no awareness that
the memory layer exists. Collapse the skill to one instruction ("read every
source file in full") and rephrase touchpoints to describe only what the
user observes (Claude reading files), not what happens behind the scenes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sync): preflight version mismatch + settings-aware port resolution
Two related fixes for build-and-sync's worker restart step:
1. Read CLAUDE_MEM_WORKER_PORT from ~/.claude-mem/settings.json the same
way the worker does, instead of computing the default port from the
uid alone. Previously, users with a custom port saw a misleading
"Worker not running" message because the restart POST hit the wrong
port and got ECONNREFUSED.
2. Add a preflight check that aborts the sync when the running worker's
reported version does not match the version we are about to build.
Claude Code's plugin loader pins the worker to a specific cache
version per session, so syncing into a newer cache directory has no
effect until the user runs `claude plugin update thedotmack/claude-mem`
to bump the pin. The preflight surfaces this explicitly with the exact
command to run; --force bypasses it for intentional cases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(learn-codebase): note sed for partial reads of large files
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: strip comments codebase-wide
Removed prose comments from all tracked source. Preserved directives
(@ts-ignore, eslint-disable, biome-ignore, prettier-ignore, triple-slash
references, webpack magic, shebangs). Deleted two tests that asserted
on comment text rather than runtime behavior.
Net: 401 files, -14,587 / +389 lines, -10.4% bytes.
Verified: typecheck passes, build passes, test count unchanged from
baseline (22 pre-existing fails, all unrelated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(installer): move runtime setup into npx, eliminate hook dead air
Smart-install ran 3 times during a fresh install — the worst run was silent,
fired by Claude Code's Setup hook after `claude plugin install`, producing
~30s of dead air that looked like the plugin was hung.
This change makes `npx claude-mem install` the single place heavy work
happens, with a visible spinner. Hooks become runtime-only.
- New `src/npx-cli/install/setup-runtime.ts` module: ensureBun, ensureUv,
installPluginDependencies, read/writeInstallMarker, isInstallCurrent.
Marker schema preserved exactly ({version, bun, uv, installedAt}) so
ContextBuilder and BranchManager readers keep working.
- `npx claude-mem install`: ungated copy/register/enable for every IDE,
inserts a "Setting up runtime" task with honest "first install can take
~30s" spinner. The claude-code shell-out to `claude plugin install` is
removed — npx already populated everything Claude reads.
- New `npx claude-mem repair` command for post-`claude plugin update`
recovery, force-reinstalls runtime.
- Setup hook now runs `plugin/scripts/version-check.js` (29ms wall) instead
of smart-install. Mismatch prints "run: npx claude-mem repair" on stderr.
Always exits 0 (non-blocking, per CLAUDE.md exit-code strategy).
- SessionStart loses the smart-install entry; 2 hooks remain (worker start,
context fetch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(installer): delete smart-install sources, retarget tests
- Delete scripts/smart-install.js + plugin/scripts/smart-install.js (both
are source files kept in sync manually; both must go).
- Delete tests/smart-install.test.ts (covered surface is gone).
- tests/plugin-scripts-line-endings: drop smart-install.js entry.
- tests/infrastructure/plugin-distribution: retarget two assertions at
version-check.js (the new Setup hook script).
- New tests/setup-runtime.test.ts: 9 tests covering marker read/write,
isInstallCurrent semantics. Marker schema invariant verified.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(installer): describe npx-driven setup + version-check Setup hook
Sweep public docs and architecture notes to reflect the new flow:
npx installer does Bun/uv setup with a visible spinner; Setup hook runs
sub-100ms version-check.js; users hit `npx claude-mem repair` after a
`claude plugin update`.
- docs/architecture-overview.md: hook lifecycle table + npx flow paragraph
- docs/public/configuration.mdx: tree + hook config example
- docs/public/development.mdx: build output line
- docs/public/hooks-architecture.mdx: full rewrite of pre-hook section,
timing table, performance table
- docs/public/architecture/{overview,hooks,worker-service}.mdx: tree
comments, JSON config example, Bun requirement section
docs/reports/* untouched (historical incident reports).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(install): mergeSettings writes via USER_SETTINGS_PATH
Greptile P1 (#2156): `settingsFilePath()` only resolved
`process.env.CLAUDE_MEM_DATA_DIR`, while `getSetting()` reads via
`USER_SETTINGS_PATH` which `resolveDataDir()` populates from BOTH the env
var AND a `CLAUDE_MEM_DATA_DIR` entry persisted in
`~/.claude-mem/settings.json`. Result: a user with the data dir saved in
settings.json but not exported in their shell would have provider/model
settings silently written to `~/.claude-mem/settings.json` while
`getSetting()` read from `/custom/path/settings.json` — read/write split.
Drop `settingsFilePath()` and the now-unused `homedir` import; reuse the
already-imported `USER_SETTINGS_PATH` constant.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): parse --provider, --model, --no-auto-start install flags
Greptile P1 (#2156): InstallOptions has fields `provider`, `model`,
`noAutoStart`, but the install case in the npx-cli switch only parsed
`--ide`. The other three flags were silently dropped — `npx claude-mem
install --provider gemini` was a no-op.
Extract a `parseInstallOptions(argv)` helper, share it between the bare
`npx claude-mem` and `npx claude-mem install` paths, and validate
`--provider` against the allowed set. Update help text accordingly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(install): pipe runtime-setup output, always show IDE multiselect
Two issues caught in a docker test of the installer:
1. The bun.sh installer, uv installer, and `bun install` were using
stdio: 'inherit', dumping their stdout/stderr through clack's spinner
region — visible as raw "downloading uv 0.11.8…" / "Checked 58
installs across 38 packages…" text streaming under the spinner. Switch
to stdio: 'pipe' and surface captured stderr only on failure (via a
shared describeExecError() helper that includes stdout when stderr is
empty). Spinner stays clean on the happy path.
2. promptForIDESelection() silently picked claude-code when no IDEs were
detected, never showing the user the multiselect. On a fresh machine
with no IDEs present yet (e.g. our docker test container), the user
never got to choose. Now: always show the full IDE list when
interactive; mark detected ones with [detected] hints and pre-select
them; show a warn line if zero are detected explaining they should pick
what they plan to use. Non-TTY callers still get the silent
claude-code default at the call site (unchanged).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(install): skip marketplace work for claude-code-only, offer to install Claude Code
Two related UX fixes from a docker test:
**Delay between "Saved Claude model=…" and "Plugin files copied OK"**
After dropping the needsManualInstall gate, every install was unconditionally
running `copyPluginToMarketplace` (which copied the entire root node_modules
tree — thousands of files, dozens of seconds) and `runNpmInstallInMarketplace`
(npm install --production) even when only claude-code was selected. Neither
is needed for claude-code: that path uses the plugin cache dir + the
installed_plugins.json + enabledPlugins flag, all of which we already write.
- Drop `node_modules` from `copyPluginToMarketplace`'s allowed-entries list;
the dependency-install task populates it on the destination side anyway.
- Re-introduce `needsMarketplace = selectedIDEs.some(id => id !== 'claude-code')`
scoped *only* to `copyPluginToMarketplace`, `runNpmInstallInMarketplace`,
and the pre-install `shutdownWorkerAndWait` (also pointless for claude-code-
only flows since we're not overwriting the worker's running cache dir
source). All other tasks (cache copy, register, enable, runtime setup) stay
unconditional.
**Claude Code missing → silent install of an IDE that isn't there**
When the user picked claude-code on a machine without it (e.g. a fresh
container), the install completed but `claude` was unavailable and the only
hint was a generic warn line. Replace with an explicit pre-flight prompt:
Claude Code is not installed. Claude-mem works best in Claude Code, but
also works with the IDEs below.
? Install Claude Code now?
◆ Yes — install Claude Code (recommended)
◯ No — pick another IDE below
◯ Cancel installation
If the user picks "Yes", run `curl -fsSL https://claude.ai/install.sh | bash`
(or the PowerShell equivalent on Windows), then re-detect IDEs and proceed
with claude-code pre-selected. If the install fails or the user picks "No",
the multiselect still appears with claude-code visible (just unmarked
[detected]), so they can opt in or pick another IDE.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(install): detect Claude Code via `claude` CLI, not ~/.claude dir
The directory `~/.claude` can exist (e.g. mounted in Docker, or created
by tooling) without Claude Code actually being installed. Detect the
`claude` command in PATH instead so the installer correctly offers to
install Claude Code when missing.
* docs(learn-codebase): add reviewer note explaining the cost tradeoff
The skill intentionally reads every file in full to build a cognitive
cache that pays off across the rest of the project. Add a brief note
so reviewers (human or bot) understand the tradeoff before flagging
the unbounded read as a cost issue.
* fix: address Greptile P1 feedback on welcome hint and learn-codebase
- SearchRoutes: skip welcome hint when caller passes ?full=true so
explicit full-context requests aren't intercepted by the hint.
- learn-codebase: replace `sed` instruction with the Read tool's
offset/limit parameters, since Bash is gated in Claude Code by
default.
* feat(install): ASCII-animated logo splash on interactive install
Plays a ~1s bloom animation of the claude-mem sunburst logomark when
the installer starts in an interactive terminal — geometrically rendered
via 12 ray curves around a center disc, in the brand orange. The
wordmark and tagline type on alongside the final frame.
Auto-skipped on non-TTY, in CI, when NO_COLOR or CLAUDE_MEM_NO_BANNER
is set, or when the terminal is too narrow.
Inspired by ghostty +boo.
* feat(banner): replace rotation frames with angular-sector bloom generator
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(banner): replace rotation frames with angular-sector bloom generator
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(banner): three-act choreography renderer with radial gradient and diff redraw
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(banner): update preview script to support small/medium/hero tier selection
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(docker): add COLORTERM=truecolor to test-installer sandbox
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(install): auto-apply PATH for Claude Code with spinner UX
The Claude Code install.sh prints a Setup notes block telling users to
manually edit "your shell config file" to add ~/.local/bin to PATH —
which left fresh installs unable to launch claude from the command line.
After a successful install, detect ~/.local/bin/claude on disk and, if
the dir is missing from PATH, append the right export line to .zshrc /
.bash_profile / .bashrc / fish config (idempotent, marked with a
comment). Also updates process.env.PATH for the current install run.
Wraps the curl|bash install in a clack spinner (interactive only) so the
~4 minute native-build download doesn't look frozen — output is captured
silently and dumped on failure for debuggability. Non-interactive mode
keeps inherited stdio for CI logs.
Verified end-to-end in the test-installer docker sandbox: spinner
animates, .bashrc gets the export, fresh login shell resolves claude.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(banner): video-frame ASCII renderer with three-act choreography
Generator switched from a single Jimp-rendered logo to pre-extracted
video frames concatenated with \x01 separators and gzip-deflated, ported
from ghostty's boo wire format. Renderer rewritten around three acts
(ignite → stagger bloom → text reveal + breathe) with adaptive sizing,
radial gradient, and diff-based redraw.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(onboarding): unify install / SessionStart / viewer around one first-success moment
Three surfaces now point at the same north-star moment — open the viewer, do
anything in Claude Code, watch an observation appear within seconds — with the
same verbatim timing and privacy lines, and a single canonical "how it works"
explainer instead of three diverging copies.
- Canonical explainer at src/services/worker/onboarding-explainer.md served via
GET /api/onboarding/explainer; mirrored into plugin/skills/how-it-works/SKILL.md
- SessionStart welcome hint rewritten as third-person status (no imperatives
Claude tries to execute), pinned with a default-value regression test
- Post-install Next Steps reframed as "two paths": passive default + optional
/learn-codebase front-load; drops /mem-search and /knowledge-agent from this
surface; adds verbatim timing + privacy lines and /how-it-works link
- /api/stats response gains firstObservationAt for the viewer stat row
- Viewer WelcomeCard branches on observationCount === 0: empty state shows live
worker-connection dot + "waiting for activity"; has-data state shows
observations · projects · since [date] and two example prompts. v2 dismiss key
- jimp added to package.json to fix pre-existing banner-frame build break
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(banner): play unconditionally; only honor CLAUDE_MEM_NO_BANNER
The 128-col / TTY / CI / NO_COLOR gates silently swallowed the banner in
narrower terminals, CI logs, and any non-TTY pipe — including Docker runs
where -it should preserve the experience but column width was the wrong
gate. Remove the implicit gates; keep the explicit opt-out only.
If a frame wraps in a narrow terminal, that's better than the banner
not playing at all.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* revert(banner): restore 15:33 gating logic per user request
Reverts eb6fc157. Restores isBannerEnabled to the state at commit
8e448015 (2026-04-30 15:33): TTY check, !CI, !NO_COLOR, !CLAUDE_MEM_NO_BANNER,
and cols >= BANNER.width.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(install): wrap remaining slow steps with spinners
Each IDE installer (Cursor, Gemini CLI, OpenCode, Windsurf, OpenClaw,
Codex CLI, MCP integrations) now runs inside a clack task spinner with
per-step progress messages instead of silent dynamic-import + cpSync.
Pre-overwrite worker shutdown (up to 10s) and the post-install health
probe (up to 3s) also get spinners.
Internal console.log/error/warn from each IDE installer is buffered
during the spinner; if the install fails, captured output is replayed
afterward via log.warn so users can see what broke.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(review): observation count + IDE pre-selection regressions
WelcomeCard's "no observations yet" empty state was triggered when a
project filter narrowed the feed to zero rows, even with thousands of
observations elsewhere. Source the count from global stats.database
to match firstObservationAt's scope.
Restore initialValues: [] in the IDE multiselect — pre-selecting every
detected IDE was the exact regression #2106 was filed for.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(install): trichotomy worker state + cache fallback for script path
ensureWorkerStarted now returns 'ready' | 'warming' | 'dead' instead of
boolean. The spawned-but-still-warming case (common in Docker cold
starts and slow first-time inits) was being misreported as 'did not
start', which contradicted the next-steps panel saying 'still starting
up'. Install task message and Next Steps headline now agree on the
actual state.
Also fixes the actual root cause of 'Worker did not start' on
claude-code-only installs: the worker script path was hardcoded to the
marketplace dir, which is left empty when no non-claude-code IDE is
selected. Now falls back to pluginCacheDirectory(version) when the
marketplace copy isn't present.
Verified end-to-end in docker/claude-mem with --ide claude-code,
--ide cursor, and a fresh container — install task and headline
agree on 'Worker ready at http://localhost:<port>' in all cases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: align CLAUDE.md and public docs with current code
Sweep across CLAUDE.md and 10 high-traffic docs/public/ MDX files to
remove point-in-time references and align with the actual current
shape of the codebase. Highlights:
- Hardcoded port 37777 → per-user formula (37700 + uid % 100) on the
front-door pages (introduction, installation, configuration,
architecture/overview, architecture/worker-service, troubleshooting,
hooks-architecture, platform-integration).
- Default model 'sonnet' → 'claude-haiku-4-5-20251001' (matches
SettingsDefaultsManager).
- Node 18 → 20 (matches package.json engines).
- Lifecycle hook count corrected (5 events).
- Removed the nonexistent 'Smart Install' component and pre-built
directory tree referencing files that no longer exist
(context-hook.ts, save-hook.ts, cleanup-hook.ts, etc.); replaced
with the real worker dispatcher shape.
- Removed CLAUDE.md '#2101' issue tag (kept the design rationale).
- Replaced obsolete hooks.json example with a description of the real
bun-runner.js / worker-service.cjs hook event shape.
Lower-traffic doc pages still hardcode 37777 — left for a separate
global pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(scripts): land strip-comments around real parsers (postcss, remark, parse5)
Each language gets a real parser to locate comments, then we splice ranges
out of the original source. The library never serializes — that's how
remark-stringify produced 243 reformat-noise diffs in the first attempt
versus the 21 real strip targets here.
JS/TS/JSX -> ts.createSourceFile + getLeadingCommentRanges
CSS/SCSS -> postcss.parse + walkComments + node.source offsets
MD/MDX -> remark-parse (+ remark-mdx) + AST html / mdx-expression nodes
HTML -> parse5 with sourceCodeLocationInfo
shell/py -> kept hand-rolled hash stripper (no library worth the dep)
Preserves: shebangs, @ts-* directives, eslint-disable, biome-ignore,
prettier-ignore, triple-slash refs, webpack magic, /*! license keep,
@strip-comments-keep file marker. JS/TS handler runs a parse-roundtrip
check and refuses to write if syntax errors increased (catches the
worker-utils.ts breakage class from the 2026-04-29 attempt).
npm scripts:
strip-comments (apply)
strip-comments:check (CI-style, exits non-zero if changes needed)
strip-comments:dry-run (list, no writes)
Verified --check on this repo: 21 changes, -4.0% bytes, no parse-error
regressions, no reformat-suspect false positives.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor: strip comments codebase-wide via parser-backed tool
21 files changed, -17,550 bytes (-4.0%) of narrative comments removed
across .ts / .tsx / .js / .mjs and the .gitignore. JS/TS comments stripped
via ts.createSourceFile + getLeadingCommentRanges — same canonical lexer,
same behavior as the 2026-04-29 strip, no reformat noise.
Preexisting baseline (unchanged):
typecheck: 16 errors at HEAD, 16 errors after strip (line numbers shift,
no new error classes — verified via diff of sorted error lists)
build: fails at HEAD with CrushHooksInstaller.js unresolved import
(preexisting, unrelated to this strip)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(install): drop Crush integration references after extract
The Crush integration was extracted to its own branch on May 1, but the
import at install.ts:280 (and the case block + ide-detection entry +
McpIntegrations config + npx-cli help text) still referenced the now-
removed CrushHooksInstaller.js, breaking the build.
Removes:
- case 'crush' block in install.ts
- crush entry in ide-detection.ts
- CRUSH_CONFIG and registration in McpIntegrations.ts
- 'crush' from the IDE Identifiers help line in index.ts
Rebuilds worker-service.cjs to match.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(banner): mark generated banner-frames.ts with @strip-comments-keep
Without this, every build/strip cycle ping-pongs five lines of doc
comments in and out of the auto-generated output. The keep-marker tells
strip-comments.ts to skip the file entirely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(build): drop banner-frame regen from build script
generate-banner-frames.mjs requires PNG frames in /tmp/cmem-banner-frames
that only exist after the maintainer runs ffmpeg locally on the source
video. CI has neither the video nor the frames, so the build broke on
Windows. The output (src/npx-cli/banner-frames.ts) is committed, so the
regen is a one-shot dev step — not a build step. Run the script directly
when the video changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(worker): unstick the spinner — kill claim-self-lock, wake on fail, auto-broadcast
Three surgical changes that cure the stuck-spinner bug at the source.
Phase 1.1 (L9): claimNextMessage no longer self-excludes its own worker_pid.
A single UPDATE-RETURNING grabs the oldest pending row by id. Removes the
LiveWorkerPidsProvider plumbing that was never injected — Supervisor enforces
single-worker via PID file, so the multi-worker SQL was defending against a
configuration the project does not support.
Phase 1.2 (L19): SessionManager.markMessageFailed wraps PendingMessageStore.markFailed
and emits 'message' on the per-session EventEmitter. The iterator's waitForMessage
now wakes immediately on re-pend instead of parking for 3 minutes. ResponseProcessor
and SessionRoutes routed through the new wrapper.
Phase 1.3 (L24): PendingMessageStore takes an optional onMutate callback fired
from every mutator (enqueue, claimNextMessage, confirmProcessed, markFailed,
transitionMessagesTo, clearFailedOlderThan). SessionManager wires it; WorkerService
passes broadcastProcessingStatus. Ten manual broadcast calls deleted across
SessionCleanupHelper, SessionEventBroadcaster, SessionRoutes, DataRoutes, and
worker-service. Caller discipline becomes structurally impossible to forget.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(worker): delete dead code — legacy routes, processPendingQueues, decorative guards
Pure deletions. Phase 2 of kill-the-asshole-gates.
- Legacy /sessions/:sessionDbId/* routes (handleSessionInit, handleObservations,
handleSummarize, handleSessionStatus, handleSessionDelete, handleSessionComplete)
bypassed all five ingest gates and were a parallel write path. Folded the
initializeSession + broadcastNewPrompt + syncUserPrompt + ensureGeneratorRunning
+ broadcastSessionStarted work into the canonical /api/sessions/init handler so
the hook makes one round trip instead of two.
- processPendingQueues (~104 lines, zero callers) — replaced in Phase 6 by a
one-statement startup sweep.
- spawnInProgress Map and crashRecoveryScheduled Set — decorative dedupe over
generatorPromise and stillExists checks that already provide the real safety.
- STALE_GENERATOR_THRESHOLD_MS — pre-empted live generators and raced with the
finally block; the 3min idle timeout already kills zombies.
- MAX_SESSION_WALL_CLOCK_MS — ran a SELECT on every observation to enforce 24h.
Runaway-spend protection lives in the API key, not in claude-mem.
- Missing-id 400 in shared.ts ingestObservation — Zod already enforces min(1)
on contentSessionId and toolName at the route schema.
- SessionCompletionHandler import + completionHandler field on SessionRoutes
(orphaned after handler deletions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(worker): SQL-backed getTotalQueueDepth — single source of truth
Was: iterate this.sessions.values() and sum getPendingCount per session.
Now: SELECT COUNT(*) FROM pending_messages WHERE status IN ('pending','processing').
The in-memory sessions Map drifted from the DB rows whenever a generator exited
without confirm/fail, leading to false-positive isProcessing in the UI. Phase 1.3's
auto-broadcast fires on every mutation, but it broadcast a stale Map count.
Reading from the DB makes the UI's spinner state match what the queue actually holds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(worker): typed abortReason replaces wasAborted boolean
Was: a boolean wasAborted that lumped every abort together. The finally block
branched on !wasAborted, so any abort skipped restart — including idle aborts
with pending work, which is exactly the case where we DO want to restart.
Now: ActiveSession.abortReason is a typed enum 'idle' | 'shutdown' | 'overflow'
| 'restart-guard'. The finally block consumes the reason and only skips restart
for 'shutdown' and 'restart-guard'. Idle and overflow aborts fall through, so
if pending work exists they trigger restart correctly.
Dropped 'stale' and 'wall-clock' from the union — Phase 2 deleted those paths.
Natural-completion abort (post-success) intentionally has no reason; it's not
gating restart logic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(worker): unify the two generator-exit finally blocks
Was: worker-service.ts:startSessionProcessor and SessionRoutes:ensureGeneratorRunning
each had their own ~70-line finally block with divergent restart-guard handling.
The worker-service path called terminateSession on RestartGuard trip and orphaned
pending rows (the L16 bug); the SessionRoutes path drained them. Two places to
update when rules changed.
Now: handleGeneratorExit in src/services/worker/session/GeneratorExitHandler.ts
owns the contract:
1. Always kill the SDK subprocess if alive.
2. Always drain processingMessageIds via sessionManager.markMessageFailed
(which wakes the iterator — Phase 1.2).
3. shutdown / restart-guard reasons: drain pending rows via
transitionMessagesTo('failed'), finalize, remove from Map. Fixes L16.
4. pendingCount=0: finalize normally and remove from Map.
5. pendingCount>0: backoff respawn via per-session respawnTimer (no global Set;
Phase 2.4 deleted that). RestartGuard trip drains to 'abandoned'.
Both finally blocks are now ~10-line wrappers that translate local state into the
canonical abortReason and delegate. Restored completionHandler injection into
SessionRoutes (was dropped in Phase 2 cleanup; needed by the unified helper for
finalizeSession).
Behavior change: SessionRoutes' previous "keep idle session in memory" was
deliberately replaced by the plan's "remove from Map on natural completion" —
next observation reinitializes via getMessageIterator → initializeSession.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(worker): startup orphan sweep — reset 'processing' rows at boot
When the worker dies (crash, kill, restart), any pending_messages rows it left
in 'processing' state are by definition orphans (the only worker is dead).
Single SQL UPDATE at boot resets them to 'pending' so the iterator can claim
them again. Replaces the deleted processPendingQueues function (Phase 2.2).
Runs in initializeBackground after dbManager.initialize() and before the
initializationComplete middleware releases blocked HTTP requests, so no
in-flight request can race the sweep. NOT on a periodic timer — after boot,
every 'processing' row has a live consumer and a periodic sweep would race.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(worker): simplify enqueue catch, replace memorySessionId throw with re-pend
7.1: queueObservation's catch was logging two ERROR-level messages and rethrowing.
The rethrow is correct (FK violations / disk full / schema drift should crash
loudly), but the verbose ERROR logging pretended the error was recoverable.
Reduced to one INFO line + rethrow.
7.2: ResponseProcessor's memorySessionId guard was throwing if the SDK hadn't
included session_id on the first user-yield, terminal-failing the entire batch.
Now warns and re-pends in-flight messages via sessionManager.markMessageFailed
(which wakes the iterator — Phase 1.2). The next iteration tries again with
memorySessionId hopefully captured.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sync): mirror builds to installed-version cache for hot reload
When package.json bumps past Claude Code's installed pin, sync-marketplace
wrote new code to cache/<buildVersion>/ but the worker loaded from
cache/<installedVersion>/, so worker:restart reloaded the same old code.
Replace the exit-on-mismatch preflight with a mirror step: when versions
differ, also rsync plugin/ into cache/<installedVersion>/ so worker:restart
hot-reloads new code without a Claude Code session restart. The
build-version cache still gets written for the eventual
`claude plugin update`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: delete dead barrel files and orphan utilities
- src/sdk/index.ts (re-exports parser+prompts; nothing imported the barrel)
- src/services/Context.ts (re-exports ./context/index.js; no importers)
- src/services/integrations/index.ts (no importers)
- src/services/worker/Search.ts (3-line barrel of ./search/index.js)
- src/services/infrastructure/index.ts: drop CleanupV12_4_3 re-export
- src/utils/error-messages.ts (getWorkerRestartInstructions never imported)
- src/types/transcript.ts (170 LoC of types, zero importers)
- src/npx-cli/_preview.ts (banner dev preview, no script wires it)
Build + tests still pass; observations still flowing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(parser): drop unused detectLanguage
Only the user-grammar-aware variant detectLanguageWithUserGrammars()
is actually called.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(types): drop unused SdkSessionRecord + ObservationWithContext
Both interfaces in src/types/database.ts had zero importers anywhere
in src or tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(npx-cli): drop unused getDetectedIDEs + claudeMemDataDirectory
getDetectedIDEs has no callers — install.ts uses detectInstalledIDEs
directly. claudeMemDataDirectory has no callers either.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(ProcessManager): drop dead orphan-reaper + signal-handler helpers
Each had zero callers in src/ or tests/:
- cleanupOrphanedProcesses + enumerateOrphanedProcesses
- ORPHAN_PROCESS_PATTERNS + ORPHAN_MAX_AGE_MINUTES
- forceKillProcess
- waitForProcessesExit
- createSignalHandler
- resetWorkerRuntimePathCache
The orphan reaper was retired in PATHFINDER Plan 02 ("OS process groups
replace hand-rolled reapers", commit 94d592f2) — these were the leftover
pieces. shutdown.ts uses the supervisor's own kill-pgid path instead.
parseElapsedTime kept (covered by tests/infrastructure/process-manager.test.ts).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(scripts): delete 11 unreferenced DX/forensic scripts
None of these are referenced by package.json npm scripts or docs/.
All last touched on Apr 29 only as part of the comment-stripping
pass — the feature code itself is older and orphaned:
analyze-transformations-smart.js
debug-transcript-structure.ts
dump-transcript-readable.ts
endless-mode-token-calculator.js
extract-prompts-to-yaml.cjs
extract-rich-context-examples.ts
find-silent-failures.sh
fix-all-timestamps.ts
format-transcript-context.ts
test-transcript-parser.ts
transcript-to-markdown.ts
These are standalone tools — runtime behavior unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(scripts): delete unused extraction/ and types/ subdirs
- scripts/extraction/{extract-all-xml.py, filter-actual-xml.py, README.md}
point at ~/Scripts/claude-mem/ — the user's pre-relocation path that no
longer exists. Zero references in package.json, src/, or tests/.
- scripts/types/export.ts duplicates ObservationRecord etc. and has no
importers (CodexCliInstaller imports transcripts/types, not this).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(BranchManager): drop dead getInstalledPluginPath
OpenCodeInstaller has its own (used) getInstalledPluginPath; the
BranchManager copy never had any external callers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(ChromaSyncState): unexport DocKind (used internally only)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(gemini): drop stale earliestPendingTimestamp / processingMessageIds
Both fields were removed from ActiveSession in earlier queue-engine
cleanup. Tests had been silently keeping them because the mock sessions
use 'as any' to bypass strict typing, so the dead fields rode along
without complaint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: drop 3 unused module-level constants
- src/npx-cli/banner.ts: CURSOR_HOME, CLEAR_DOWN (banner uses
CLEAR_SCREEN which combines clear-down + cursor-home into a single
CSI sequence; the standalone constants were leftovers).
- src/services/worker/BranchManager.ts: DEFAULT_SHELL_TIMEOUT_MS
(BranchManager only uses GIT_COMMAND_TIMEOUT_MS / NPM_INSTALL_TIMEOUT_MS).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(opencode-plugin): drop dead workerPost helper
Only the fire-and-forget variant (workerPostFireAndForget) is actually
called. workerPost was the await-result version with no remaining caller.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: drop 8 truly-unused interface fields
Verified each by grepping for `.field`, `"field"`, `'field'`, and
`field:` patterns across src/ + tests/ + plugin/scripts. Where the
only remaining usage was the assignment site, removed the assignments too.
- GitHubStarsData: watchers_count, forks_count (only stargazers_count read)
- TableColumnInfo: dflt_value (PRAGMA returns it but no caller reads it)
- IndexInfo: seq (PRAGMA returns it but no caller reads it)
- ObservationRecord: source_files (legacy field, no readers)
- HookResult.hookSpecificOutput: permissionDecisionReason
- WatchTarget: rescanIntervalMs (set in config, never read)
- ShutdownResult: confirmedStopped (write-only — assigned but no
reader; updated all 3 return sites to drop it)
- ModePrompts: language_instruction (multilingual support never wired)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(npx-cli): reuse InstallOptions type instead of inline duplicate
parseInstallOptions had its return type written out inline as an
anonymous duplicate of InstallOptions. Use the canonical type
(import type — zero bundle cost).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(integrations): drop unused Platform type alias
The detectPlatform() function that returned this type was deleted earlier
in the branch (along with getScriptExtension that consumed it). The type
itself outlived its consumer; only string literals "Platform:" survive in
console.log diagnostics, which don't reference the alias.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(worker): broadcast processing_status when summarize is queued
broadcastSummarizeQueued was an empty no-op even though
handleSummarizeByClaudeId calls it after enqueueing. The PendingMessageStore
onMutate callback already fires broadcastProcessingStatus on enqueue, but
calling it explicitly from broadcastSummarizeQueued ensures the spinner
ticks on the moment a summary is requested even if the onMutate chain has
any timing race.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(worker): keep spinner on while summary generates
ClaudeProvider's SDK can pull multiple synthetic prompts (e.g.
observation + summarize) before producing responses. Each pull pushed
an ID to session.processingMessageIds. When the SDK's first
observation response came back, ResponseProcessor.confirmProcessed
deleted ALL pending message rows — including the still-in-flight
summary — so getTotalQueueDepth dropped to 0 and the spinner turned
off, even though the summary took another ~22s to actually generate.
Tag each in-flight message with its type ({id, type}) so the response
processor can pop only the FIFO message of the matching type
(observation vs summarize). The summary row stays in 'processing'
until its own response arrives, keeping the spinner lit through the
entire summary window.
Also updates Gemini/OpenRouter providers and GeneratorExitHandler for
the new shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(worker): clear summary from queue on any SDK response
Switch ResponseProcessor from type-aware FIFO matching to strict FIFO
popping (each SDK response → 1 in-flight message consumed). This way
the summary always clears when the SDK responds, even when the
response is unparseable or the summary doesn't actually generate
content — preventing stuck spinner / queue-depth-stuck-at-1.
Spinner behavior is preserved: messages enqueued after the summary
keep the queue depth elevated, and only when the SDK has responded
to every prompt does the queue drain to zero.
Also: when the consumed message is a 'summarize' and parsing fails,
treat it as best-effort and confirmProcessed (no retry) — summaries
that can't be parsed shouldn't keep retrying.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(viewer): redesign welcome card and remove source filters
The first-start welcome card now explains the three feed card types
(observation/summary/prompt) with color-coded badges, points users at
the gear icon for settings and the project dropdown for filtering, and
plugs /mem-search for recall — replacing the old two-line "ask:" prompts.
Source filter tabs (Claude/Codex/etc.) are removed from the header.
Filtering by AI provider was nonsense from a user POV; the project
dropdown is the only header filter now. Source tracking is also
stripped from useSSE, usePagination, App state, and CSS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(viewer): keep welcome card in feed column, swap rows for 3 squares
Two visible problems in the previous design: the card stretched
edge-to-edge while feed cards sit in a centered 650px column, and
the body was a stack of long horizontal rows that scanned line-by-line.
Both fixed: Feed now accepts a pinnedTop slot so the welcome card
renders inside the same .feed-content column as observation cards.
Body is now a 3-column grid of square feature blocks — Live feed,
Tune it, Recall it — each with a custom inline SVG illustration
(stacked cards with color-coded stripes, gear+sliders, magnifier
over cards). Old text-row sections (welcome-card-types,
welcome-card-tips, welcome-card-section, welcome-card-tip-icon)
are removed. Squares stack to one column under 600px.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(viewer): convert welcome card to glassy modal with stylized logo
Card now opens as a centered modal with a frosted/glass backdrop
(blur + saturate) so it doubles as a proper help dialog when reopened
from the header's question-mark button. Removed the observation count,
project count, and "since" date — those don't make sense for a
first-launch surface and felt out of place in a help context.
Header art swapped from the small webp logomark to the new
high-resolution sun/sunburst PNG (claude-mem-logo-stylized.png),
shipped as a checked-in asset in src/ui and plugin/ui.
Bigger throughout: 28px h2, 16px tagline, 88px illustrations,
26px feature padding, 1:1 aspect-ratio squares. Backdrop click and
Esc both close. Mobile collapses the grid to one column and drops
the aspect-ratio constraint.
Reverted the unused pinnedTop slot on Feed.tsx since the welcome
card is now a true overlay rather than an in-feed pinned card.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(viewer): make welcome modal actually glassy
Previous version had a 55%-opacity black backdrop that almost fully
blocked the underlying UI — the "glass" was just a dark plate.
Now the backdrop is fully transparent (no darkening at all), the
panel itself drops to 55% bg-card opacity with its existing
backdrop-filter blur(28px) saturate(170%), and the feature squares
drop to 35% bg-tertiary so they layer as glass-on-glass over the
already-blurred panel. The header and feed below now read clearly
through the modal's frosted blur.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(viewer): bulletproof square features via padding-bottom + clamp() fluid type
Squares were rendering taller than wide because aspect-ratio is treated
as a minimum — content can push the box past 1:1. Switched to the
classic padding-bottom: 100% trick: percentage padding resolves against
the parent's width, so the box is ALWAYS W × W regardless of content.
Inner content sits in an absolutely-positioned flex column that can't
push the shell taller.
Whole modal is now desktop-first and fluid via clamp() — no media-query
stair-steps for type, padding, gaps, border-radius, illustration size,
or modal width. Single mobile breakpoint at <600px collapses the grid
to one column and reverts the padding-bottom trick so each feature can
grow to natural content height.
Tightened the three feature descriptions so they fit comfortably inside
the square at the desktop size.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* style(viewer): 15% black overlay + heavier modal shadow for elevation
Backdrop goes from transparent to rgba(0,0,0,0.15) — just enough
darkening to push the modal visually forward without burying the
underlying UI. Modal shadow stacked: 40px/120px ambient + 16px/48px
contact, both deeper, plus the existing inset 1px highlight.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(build): clear pending_messages queue on build-and-sync
Rewrites scripts/clear-failed-queue.ts to talk directly to SQLite via
bun:sqlite — the previous HTTP endpoints (/api/pending-queue/*) were
removed during the queue engine rewrite, so the script was orphaned.
Wires `npm run queue:clear` into `build-and-sync` so each rebuild
starts with a clean queue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(worker): collapse parser to binary valid/invalid + clearPendingForSession model
- Parser: { valid: true, observations, summary } | { valid: false } — drops kind/skipped enum dispatch
- ResponseProcessor: two branches only (parseable → store + clearPendingForSession; else → no-op)
- Drop processingMessageIds + per-message claim/confirm/markFailed lifecycle across 3 providers
- PendingMessageStore: 226 → 140 lines; remove markFailed/transitionMessagesTo/confirmProcessed/clearFailedOlderThan/getAllPending/peekPendingTypes... wait keep peekPendingTypes
- Schema migration v31+v32: drop retry_count, failed_at_epoch, completed_at_epoch, worker_pid columns
- SessionQueueProcessor: delete two 1s recovery sleeps (let iterator end on error)
- Server.ts/SettingsRoutes.ts: replace four magic-number setTimeout exit-flush patterns with flushResponseThen helper
- GeneratorExitHandler: 183 → 117 lines (drain in-flight loop gone)
Net: -181 lines. No more silent data loss via maxRetries=3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(pr-2255): address review comments batch 1
- install.ts: needsMarketplace true when claude-code selected (P1, was no-op)
- install.ts: throw on invalid --model so CLI exits non-zero
- install.ts: skip worker health checks + adapt next-step copy when --no-auto-start
- install.ts: repair regenerates plugin cache when missing
- index.ts: readFlag rejects missing/flag-shaped values
- index.ts: route flag-first invocations (e.g. `--provider claude`) to install
- banner.ts: fail-open if frame payload decode throws
- SearchRoutes.ts: 5s TTL cache for settings reads on hot hook path (P2)
- detect-error-handling-antipatterns.ts: trailing-brace strip whitespace-tolerant
- investigate-timestamps.ts: compute Dec 2025 epochs at runtime (was Dec 2024)
- regenerate-claude-md.ts: include workingDir in fallback walker so root is covered
- sync-marketplace.cjs: parseWorkerPort validates 1..65535 before http.request
- sync-to-marketplace.sh: resolve SOURCE_DIR from script location, not cwd
- Dockerfile.test-installer: bash --login sources .bashrc via .bash_profile
- docs/configuration.mdx: drop nonexistent .worker.port file refs, use settings.json
- docs/architecture-overview.md: dynamic port + queue model after parser collapse
- docs/architecture/worker-service.mdx: dynamic port example + drop port-file claim
- docs/platform-integration.mdx: WORKER_BASE_URL pattern, drop hardcoded 37777
- install/public/install.sh: Node 20 floor (was 18) to match docs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(pr-2255): reset claimed messages to pending on early-return paths
ResponseProcessor returns early in two cases:
- parser invalid (unparseable response)
- memorySessionId not yet captured
Both paths previously left the just-claimed message in `status='processing'`,
which counts toward `getPendingCount`. The generator-exit handler then sees
`pendingCount > 0` and respawns the generator, looping until the restart
guard trips and `clearPendingForSession` deletes the message — silent data
loss.
Calling `resetProcessingToPending` on these paths lets the next generator
pass re-claim the message and try again, instead of burning the restart
budget on no-op respawns.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(pr-2255): swebench fallback row + troubleshooting port path
- evals/swebench/run-batch.py: append fallback prediction row when
orchestrator future raises, preserving "never drop an instance" guarantee
- docs/troubleshooting.mdx: drop nonexistent .worker.port / worker.port file
references; use settings.json + /api/health for port discovery
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(pr-2255): memoize per-project observation count for welcome-hint hot path
handleContextInject runs on every PostToolUse hook (after every Read/Edit).
The welcome-hint block ran a COUNT(*) on observations for every call once
CLAUDE_MEM_WELCOME_HINT_ENABLED was true. Observation counts are
monotonically increasing — once a project has any observations it always
will — so cache the positive result in a Set and skip the COUNT(*) on
subsequent requests.
Combined with the 5s settings TTL added earlier, the steady-state cost on
the hook hot path drops to a Set lookup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(pr-2255): use clearProcessingForSession on AI-success path
clearPendingForSession deletes ALL rows for the session. On the success
path of processAgentResponse, that's wrong: messages that arrived as
'pending' during the (1-5s) AI response latency get deleted along with
the 'processing' row we just consumed. In a hook burst (three quick
PostToolUse hooks), B and C land while A is in flight; A's success then
nukes B and C — silent data loss.
Add a status-scoped clearProcessingForSession to PendingMessageStore +
SessionManager, and use it in ResponseProcessor's success path. The
unconditional clearPendingForSession remains correct in
GeneratorExitHandler for hard-stop / restart-guard-trip paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Revert "fix(pr-2255): use clearProcessingForSession on AI-success path"
This reverts commit a08995299c30cbad36bddc3e5bddda7af8604b35.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,46 +2,24 @@ import { describe, it, expect } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* Regression tests for bun-runner.js to prevent the re-introduction of
|
||||
* platform-specific issues that are difficult to catch in CI.
|
||||
*
|
||||
* These tests inspect the source code for known-bad patterns rather than
|
||||
* executing the script, because bun-runner.js is a top-level side-effecting
|
||||
* Node.js script (not an importable module) and the Windows-specific code
|
||||
* paths cannot be exercised on non-Windows CI runners.
|
||||
*/
|
||||
|
||||
const BUN_RUNNER_PATH = join(import.meta.dir, '..', 'plugin', 'scripts', 'bun-runner.js');
|
||||
const source = readFileSync(BUN_RUNNER_PATH, 'utf-8');
|
||||
|
||||
describe('bun-runner.js findBun: DEP0190 regression guard (#1503)', () => {
|
||||
it('does not use separate args array with shell:true (DEP0190 trigger pattern)', () => {
|
||||
// Node 22+ emits DEP0190 when spawnSync is called with a separate args array
|
||||
// AND shell:true, because the args are only concatenated (not escaped).
|
||||
// The vulnerable pattern looks like: spawnSync(cmd, ['bun'], { shell: true/IS_WINDOWS })
|
||||
// This test verifies the fix in findBun() has not been reverted.
|
||||
const vulnerablePattern = /spawnSync\s*\(\s*(?:IS_WINDOWS\s*\?\s*['"]where['"]\s*:[^)]+|['"]where['"]),\s*\[[^\]]+\],\s*\{[^}]*shell\s*:\s*(?:true|IS_WINDOWS)/;
|
||||
expect(vulnerablePattern.test(source)).toBe(false);
|
||||
});
|
||||
|
||||
it('uses a single string command for Windows where-bun lookup', () => {
|
||||
// The safe pattern: pass a single combined string 'where bun' with shell:true
|
||||
// so no separate args array is involved. This is the fix for DEP0190.
|
||||
expect(source).toContain("spawnSync('where bun'");
|
||||
});
|
||||
|
||||
it('uses no shell option for Unix which-bun lookup', () => {
|
||||
// On Unix, spawnSync('which', ['bun']) without shell:true is safe and avoids
|
||||
// the deprecation warning entirely.
|
||||
// Check that the unix path does NOT pass shell:true alongside the args array.
|
||||
// We look for the pattern: spawnSync('which', ['bun'], { ... }) — shell should be absent.
|
||||
const unixCallMatch = source.match(/spawnSync\('which',\s*\['bun'\],\s*\{([^}]+)\}/)
|
||||
if (unixCallMatch) {
|
||||
expect(unixCallMatch[1]).not.toContain('shell');
|
||||
}
|
||||
// If the pattern is not found as expected, that means the code changed shape —
|
||||
// either way we shouldn't have shell:true on the unix path
|
||||
expect(source).toContain("spawnSync('which', ['bun']");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
/**
|
||||
* Tests for SDKAgent resume parameter logic
|
||||
*
|
||||
* The resume parameter should ONLY be passed when:
|
||||
* 1. memorySessionId exists (was captured from a previous SDK response)
|
||||
* 2. lastPromptNumber > 1 (this is a continuation within the same SDK session)
|
||||
*
|
||||
* On worker restart or crash recovery, memorySessionId may exist from a previous
|
||||
* SDK session but we must NOT resume because the SDK context was lost.
|
||||
*/
|
||||
describe('SDKAgent Resume Parameter Logic', () => {
|
||||
/**
|
||||
* Helper function that mirrors the logic in SDKAgent.startSession()
|
||||
* This is the exact condition used at SDKAgent.ts line 99
|
||||
*/
|
||||
describe('ClaudeProvider Resume Parameter Logic', () => {
|
||||
function shouldPassResumeParameter(session: {
|
||||
memorySessionId: string | null;
|
||||
lastPromptNumber: number;
|
||||
@@ -25,7 +11,6 @@ describe('SDKAgent Resume Parameter Logic', () => {
|
||||
|
||||
describe('INIT prompt scenarios (lastPromptNumber === 1)', () => {
|
||||
it('should NOT pass resume parameter when lastPromptNumber === 1 even if memorySessionId exists', () => {
|
||||
// Scenario: Worker restart with stale memorySessionId from previous session
|
||||
const session = {
|
||||
memorySessionId: 'stale-session-id-from-previous-run',
|
||||
lastPromptNumber: 1, // INIT prompt
|
||||
@@ -34,12 +19,11 @@ describe('SDKAgent Resume Parameter Logic', () => {
|
||||
const hasRealMemorySessionId = !!session.memorySessionId;
|
||||
const shouldResume = shouldPassResumeParameter(session);
|
||||
|
||||
expect(hasRealMemorySessionId).toBe(true); // memorySessionId exists
|
||||
expect(shouldResume).toBe(false); // but should NOT resume because it's INIT
|
||||
expect(hasRealMemorySessionId).toBe(true);
|
||||
expect(shouldResume).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT pass resume parameter when memorySessionId is null and lastPromptNumber === 1', () => {
|
||||
// Scenario: Fresh session, first prompt ever
|
||||
const session = {
|
||||
memorySessionId: null,
|
||||
lastPromptNumber: 1,
|
||||
@@ -55,7 +39,6 @@ describe('SDKAgent Resume Parameter Logic', () => {
|
||||
|
||||
describe('CONTINUATION prompt scenarios (lastPromptNumber > 1)', () => {
|
||||
it('should pass resume parameter when lastPromptNumber > 1 AND memorySessionId exists', () => {
|
||||
// Scenario: Normal continuation within same SDK session
|
||||
const session = {
|
||||
memorySessionId: 'valid-session-id',
|
||||
lastPromptNumber: 2, // CONTINUATION prompt
|
||||
@@ -69,7 +52,6 @@ describe('SDKAgent Resume Parameter Logic', () => {
|
||||
});
|
||||
|
||||
it('should pass resume parameter for higher prompt numbers', () => {
|
||||
// Scenario: Later in a multi-turn conversation
|
||||
const session = {
|
||||
memorySessionId: 'valid-session-id',
|
||||
lastPromptNumber: 5, // 5th prompt in session
|
||||
@@ -80,8 +62,6 @@ describe('SDKAgent Resume Parameter Logic', () => {
|
||||
});
|
||||
|
||||
it('should NOT pass resume parameter when memorySessionId is null even for lastPromptNumber > 1', () => {
|
||||
// Scenario: Bug case - somehow got to prompt 2 without capturing memorySessionId
|
||||
// This shouldn't happen in practice but we should handle it safely
|
||||
const session = {
|
||||
memorySessionId: null,
|
||||
lastPromptNumber: 2,
|
||||
@@ -97,7 +77,6 @@ describe('SDKAgent Resume Parameter Logic', () => {
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty string memorySessionId as falsy', () => {
|
||||
// Empty string should be treated as "no session ID"
|
||||
const session = {
|
||||
memorySessionId: '' as unknown as null,
|
||||
lastPromptNumber: 2,
|
||||
@@ -126,13 +105,6 @@ describe('SDKAgent Resume Parameter Logic', () => {
|
||||
|
||||
describe('Bug reproduction: stale session resume crash', () => {
|
||||
it('should NOT resume when worker restarts with stale memorySessionId', () => {
|
||||
// This is the exact bug scenario from the logs:
|
||||
// [17:30:21.773] Starting SDK query {
|
||||
// hasRealMemorySessionId=true,
|
||||
// resume_parameter=5439891b-...,
|
||||
// lastPromptNumber=1 ← NEW SDK session!
|
||||
// }
|
||||
// [17:30:24.450] Generator failed {error=Claude Code process exited with code 1}
|
||||
|
||||
const session = {
|
||||
memorySessionId: '5439891b-7d4b-4ee3-8662-c000f66bc199', // Stale from previous session
|
||||
@@ -141,12 +113,10 @@ describe('SDKAgent Resume Parameter Logic', () => {
|
||||
|
||||
const shouldResume = shouldPassResumeParameter(session);
|
||||
|
||||
// The fix: should NOT try to resume, should start fresh
|
||||
expect(shouldResume).toBe(false);
|
||||
});
|
||||
|
||||
it('should resume correctly for normal continuation (not after restart)', () => {
|
||||
// Normal case: same SDK session, continuing conversation
|
||||
const session = {
|
||||
memorySessionId: '5439891b-7d4b-4ee3-8662-c000f66bc199',
|
||||
lastPromptNumber: 2, // Second prompt in SAME session
|
||||
@@ -154,7 +124,6 @@ describe('SDKAgent Resume Parameter Logic', () => {
|
||||
|
||||
const shouldResume = shouldPassResumeParameter(session);
|
||||
|
||||
// Should resume - same session, valid memorySessionId
|
||||
expect(shouldResume).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,3 @@
|
||||
/**
|
||||
* Tests for Claude Code adapter subagent field extraction.
|
||||
*
|
||||
* Validates that normalizeInput picks up the `agent_id` / `agent_type`
|
||||
* fields from Claude Code hook stdin and that the type guard rejects
|
||||
* non-string values. These fields are the discriminator for subagent
|
||||
* context; they are undefined in main-session payloads.
|
||||
*
|
||||
* Sources:
|
||||
* - Adapter: src/cli/adapters/claude-code.ts
|
||||
* - Types: src/cli/types.ts
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { claudeCodeAdapter } from '../../../src/cli/adapters/claude-code.js';
|
||||
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
/**
|
||||
* Tests for subagent-context short-circuit in summarizeHandler.
|
||||
*
|
||||
* Validates that when the Stop hook fires inside a Claude Code subagent
|
||||
* (identified by `agentId` or `agentType` on NormalizedHookInput), the
|
||||
* summarize handler exits before calling the worker — subagents must not
|
||||
* own the session summary.
|
||||
*
|
||||
* Sources:
|
||||
* - Handler: src/cli/handlers/summarize.ts
|
||||
* - Mock pattern: tests/hooks/context-reinjection-guard.test.ts
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
// Mock modules that touch the filesystem / network at import time.
|
||||
// MUST be declared before the handler is imported.
|
||||
mock.module('../../../src/shared/SettingsDefaultsManager.js', () => ({
|
||||
SettingsDefaultsManager: {
|
||||
get: (key: string) => {
|
||||
@@ -27,9 +13,6 @@ mock.module('../../../src/shared/SettingsDefaultsManager.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// workerHttpRequest is the only worker entry point we must NOT call in
|
||||
// subagent context. It throws so we can assert "never called" by proving
|
||||
// the handler returns success anyway.
|
||||
const workerCallLog: Array<{ path: string; options: any }> = [];
|
||||
mock.module('../../../src/shared/worker-utils.js', () => ({
|
||||
ensureWorkerRunning: () => Promise.resolve(true),
|
||||
@@ -42,7 +25,6 @@ mock.module('../../../src/shared/worker-utils.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Suppress logger during tests
|
||||
import { logger } from '../../../src/utils/logger.js';
|
||||
|
||||
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
@@ -78,17 +60,10 @@ describe('summarizeHandler — subagent short-circuit', () => {
|
||||
expect(result.continue).toBe(true);
|
||||
expect(result.suppressOutput).toBe(true);
|
||||
expect(result.exitCode).toBe(0);
|
||||
// Guard fires BEFORE any worker HTTP request. If workerHttpRequest were
|
||||
// called, our mock would have thrown — reaching this expect proves it.
|
||||
expect(workerCallLog.length).toBe(0);
|
||||
});
|
||||
|
||||
it('does NOT skip when only agentType is set (--agent main session still owns its summary)', async () => {
|
||||
// agent_type without agent_id is how Claude Code signals a main session started
|
||||
// with --agent. These are main sessions, not Task-spawned subagents, so the
|
||||
// summary path must proceed. Here the transcript path is missing so the handler
|
||||
// falls through to the existing no-transcriptPath return — the key assertion is
|
||||
// that the subagent guard did NOT short-circuit (handler reached the normal path).
|
||||
const { summarizeHandler } = await import('../../../src/cli/handlers/summarize.js');
|
||||
|
||||
const result = await summarizeHandler.execute({
|
||||
@@ -123,9 +98,6 @@ describe('summarizeHandler — subagent short-circuit', () => {
|
||||
});
|
||||
|
||||
it('falls through to existing no-transcriptPath guard in main-session context', async () => {
|
||||
// Neither agentId nor agentType → NOT a subagent. Handler should
|
||||
// proceed past the subagent guard and hit the existing
|
||||
// "no transcriptPath" early return. Worker must still not be called.
|
||||
const { summarizeHandler } = await import('../../../src/cli/handlers/summarize.js');
|
||||
|
||||
const result = await summarizeHandler.execute({
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
/**
|
||||
* Tests for privacy-tag stripping in summarizeHandler.
|
||||
*
|
||||
* Validates that the Stop hook strips memory tags (<private>, <claude-mem-context>,
|
||||
* <system-instruction>, <system_instruction>, <persisted-output>) from the assistant's
|
||||
* last message before POSTing to /api/sessions/summarize. This is the fix for the bug
|
||||
* where private content was leaking into the summarize queue and downstream summary LLM.
|
||||
*
|
||||
* Sources:
|
||||
* - Handler: src/cli/handlers/summarize.ts
|
||||
* - Stripping utility: src/utils/tag-stripping.ts
|
||||
* - Mock pattern: tests/cli/handlers/summarize-subagent-skip.test.ts
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
@@ -30,14 +17,11 @@ mock.module('../../../src/shared/hook-settings.js', () => ({
|
||||
loadFromFileOnce: () => ({ CLAUDE_MEM_EXCLUDED_PROJECTS: '' }),
|
||||
}));
|
||||
|
||||
// Per-test control over what the transcript parser "extracts".
|
||||
let mockExtractedMessage: string = '';
|
||||
mock.module('../../../src/shared/transcript-parser.js', () => ({
|
||||
extractLastMessage: () => mockExtractedMessage,
|
||||
}));
|
||||
|
||||
// Capture every executeWithWorkerFallback call. Resolve successfully so the
|
||||
// handler completes its normal path — the assertions inspect what got POSTed.
|
||||
const workerCallLog: Array<{ path: string; method: string; body: any }> = [];
|
||||
mock.module('../../../src/shared/worker-utils.js', () => ({
|
||||
ensureWorkerRunning: () => Promise.resolve(true),
|
||||
@@ -84,8 +68,6 @@ const baseInput = {
|
||||
function postedBody(): any {
|
||||
expect(workerCallLog).toHaveLength(1);
|
||||
const { body } = workerCallLog[0];
|
||||
// executeWithWorkerFallback receives the body as a plain object; the legacy
|
||||
// workerHttpRequest path receives a JSON string. Support both for forward-compat.
|
||||
return typeof body === 'string' ? JSON.parse(body) : body;
|
||||
}
|
||||
|
||||
@@ -119,8 +101,6 @@ describe('summarizeHandler — privacy tag stripping', () => {
|
||||
});
|
||||
|
||||
it('skips the worker POST when the entire turn is wrapped in a privacy tag', async () => {
|
||||
// After stripping, the message is empty — handler should hit the
|
||||
// "no assistant message" guard and return without POSTing.
|
||||
mockExtractedMessage = '<private>everything is private</private>';
|
||||
|
||||
const { summarizeHandler } = await import('../../../src/cli/handlers/summarize.js');
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
// Tests for readJsonFromStdin's onEnd contract (#2089).
|
||||
//
|
||||
// The previous implementation silently dropped malformed JSON when stdin
|
||||
// closed, returning undefined just like the empty-input case. The fix mirrors
|
||||
// the safety-timeout path: non-empty + unparseable = reject.
|
||||
|
||||
import { describe, it, expect, afterEach } from 'bun:test';
|
||||
import { Readable } from 'stream';
|
||||
@@ -13,10 +8,7 @@ const realStdin = process.stdin;
|
||||
const realStdinDescriptor = Object.getOwnPropertyDescriptor(process, 'stdin');
|
||||
|
||||
function installFakeStdin(payload: string): void {
|
||||
// Build a Readable that emits the payload, then ends — matches the
|
||||
// shape of a process.stdin pipe closing after a single write.
|
||||
const fake = Readable.from([payload], { objectMode: false }) as unknown as NodeJS.ReadStream;
|
||||
// The reader checks isTTY (must be falsy) and `.readable` access.
|
||||
Object.defineProperty(fake, 'isTTY', { value: false, configurable: true });
|
||||
Object.defineProperty(process, 'stdin', {
|
||||
configurable: true,
|
||||
|
||||
@@ -2,12 +2,6 @@ import { describe, it, expect } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
// Source-only assertions that document the three Windows-specific
|
||||
// regressions from #2192. We stay source-level (no fs.watch / no SDK spawn)
|
||||
// because the failure modes are all in code paths that only execute on
|
||||
// Windows; the goal is to lock the fix in so a future refactor can't
|
||||
// silently revert it.
|
||||
|
||||
const watcherSource = readFileSync(
|
||||
join(__dirname, '..', 'src', 'services', 'transcripts', 'watcher.ts'),
|
||||
'utf-8',
|
||||
@@ -29,23 +23,14 @@ describe('Codex transcript ingestion on Windows (#2192)', () => {
|
||||
});
|
||||
|
||||
it('pokes an existing tailer on root-watcher events instead of returning early', () => {
|
||||
// The recursive root watcher must call poke() on existing tailers, not
|
||||
// skip them — Windows fs.watch on the file itself misses appends.
|
||||
expect(watcherSource).toMatch(/existingTailer\.poke\(\)/);
|
||||
});
|
||||
|
||||
it('normalizes the resolved path to forward slashes before tailer-map lookup', () => {
|
||||
// Without this, the lookup key (native path.resolve) won't match the
|
||||
// stored key (forward-slash from glob), and every append looks like a
|
||||
// new file.
|
||||
expect(watcherSource).toMatch(/resolvePath\(watchRoot, name\)\.replace\(\/\\\\\/g, '\/'\)/);
|
||||
});
|
||||
|
||||
it('requeues in-flight processing rows when the generator aborts (queue self-deadlock fix)', () => {
|
||||
// After abort, processingMessageIds entries must go through markFailed so
|
||||
// the retry ladder can either requeue them as 'pending' or terminate
|
||||
// them — leaving them in 'processing' under the live worker's PID is
|
||||
// the deadlock #2192 reports.
|
||||
expect(sessionRoutesSource).toMatch(/Generator aborted/);
|
||||
expect(sessionRoutesSource).toMatch(/processingMessageIds\.slice\(\)/);
|
||||
expect(sessionRoutesSource).toMatch(/inflightStore\.markFailed\(messageId\)/);
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const configSource = readFileSync(
|
||||
join(__dirname, '..', 'src', 'services', 'transcripts', 'config.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
const installerSource = readFileSync(
|
||||
join(__dirname, '..', 'src', 'services', 'integrations', 'CodexCliInstaller.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
describe('Codex workspace-local context', () => {
|
||||
it('does not hardcode ~/.codex/AGENTS.md in the sample transcript watch config', () => {
|
||||
expect(configSource).not.toContain("path: '~/.codex/AGENTS.md'");
|
||||
});
|
||||
|
||||
it('documents workspace-local AGENTS.md injection for Codex', () => {
|
||||
expect(installerSource).toContain('workspace-local AGENTS.md');
|
||||
expect(installerSource).toContain('Context files: <workspace>/AGENTS.md');
|
||||
});
|
||||
|
||||
it('cleans legacy global Codex context during install', () => {
|
||||
expect(installerSource).toContain('cleanupLegacyCodexAgentsMdContext();');
|
||||
expect(installerSource).toContain('Removed legacy global context');
|
||||
});
|
||||
});
|
||||
@@ -8,13 +8,6 @@ import {
|
||||
CONTEXT_TAG_CLOSE,
|
||||
} from '../src/utils/context-injection';
|
||||
|
||||
/**
|
||||
* Tests for the shared context injection utility.
|
||||
*
|
||||
* injectContextIntoMarkdownFile is used by MCP integrations and OpenCode
|
||||
* installer to inject or update a <claude-mem-context> section in markdown files.
|
||||
*/
|
||||
|
||||
describe('Context Injection', () => {
|
||||
let tempDir: string;
|
||||
|
||||
@@ -171,7 +164,6 @@ describe('Context Injection', () => {
|
||||
injectContextIntoMarkdownFile(filePath, 'data');
|
||||
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
// Should have double newline before the tag
|
||||
expect(content).toContain(`# Header\n\n${CONTEXT_TAG_OPEN}`);
|
||||
});
|
||||
|
||||
@@ -182,7 +174,6 @@ describe('Context Injection', () => {
|
||||
injectContextIntoMarkdownFile(filePath, 'data');
|
||||
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
// Should not have excessive whitespace before the tag
|
||||
expect(content).toContain(`# Header\n\n${CONTEXT_TAG_OPEN}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, it, expect, mock, beforeEach } from 'bun:test';
|
||||
|
||||
// Mock the ModeManager before importing the formatter
|
||||
mock.module('../../../src/services/domain/ModeManager.js', () => ({
|
||||
ModeManager: {
|
||||
getInstance: () => ({
|
||||
@@ -46,7 +45,6 @@ import {
|
||||
|
||||
import type { Observation, TokenEconomics, ContextConfig, PriorMessages } from '../../../src/services/context/types.js';
|
||||
|
||||
// Helper to create a minimal observation
|
||||
function createTestObservation(overrides: Partial<Observation> = {}): Observation {
|
||||
return {
|
||||
id: 1,
|
||||
@@ -66,7 +64,6 @@ function createTestObservation(overrides: Partial<Observation> = {}): Observatio
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create token economics
|
||||
function createTestEconomics(overrides: Partial<TokenEconomics> = {}): TokenEconomics {
|
||||
return {
|
||||
totalObservations: 10,
|
||||
@@ -78,7 +75,6 @@ function createTestEconomics(overrides: Partial<TokenEconomics> = {}): TokenEcon
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create context config
|
||||
function createTestConfig(overrides: Partial<ContextConfig> = {}): ContextConfig {
|
||||
return {
|
||||
totalObservationCount: 50,
|
||||
@@ -281,7 +277,6 @@ describe('AgentFormatter', () => {
|
||||
const obs = createTestObservation();
|
||||
const config = createTestConfig();
|
||||
|
||||
// Empty string timeDisplay means "same as previous"
|
||||
const result = renderAgentTableRow(obs, '', config);
|
||||
|
||||
expect(result).toContain('"');
|
||||
@@ -316,7 +311,6 @@ describe('AgentFormatter', () => {
|
||||
|
||||
const result = renderAgentFullObservation(obs, '10:00 AM', null, config);
|
||||
|
||||
// Should not have an extra content block
|
||||
expect(result.length).toBeLessThan(5);
|
||||
});
|
||||
|
||||
@@ -327,7 +321,6 @@ describe('AgentFormatter', () => {
|
||||
const result = renderAgentFullObservation(obs, '10:00 AM', null, config);
|
||||
const joined = result.join('\n');
|
||||
|
||||
// Compact format: "~{readTokens}t" and "W {discoveryTokens}"
|
||||
expect(joined).toContain('~');
|
||||
expect(joined).toContain('t');
|
||||
expect(joined).toContain('W 250');
|
||||
@@ -381,7 +374,6 @@ describe('AgentFormatter', () => {
|
||||
it('should return empty array when value is empty string', () => {
|
||||
const result = renderAgentSummaryField('Learned', '');
|
||||
|
||||
// Empty string is falsy, so should return empty array
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -443,7 +435,6 @@ describe('AgentFormatter', () => {
|
||||
const result = renderAgentFooter(15500, 100);
|
||||
const joined = result.join('\n');
|
||||
|
||||
// 15500 / 1000 = 15.5 -> rounds to 16
|
||||
expect(joined).toContain('16k');
|
||||
});
|
||||
});
|
||||
@@ -459,7 +450,6 @@ describe('AgentFormatter', () => {
|
||||
it('should be valid markdown', () => {
|
||||
const result = renderAgentEmptyState('test');
|
||||
|
||||
// Should start with h1
|
||||
expect(result.startsWith('#')).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,14 +2,6 @@ import { describe, it, expect } from 'bun:test';
|
||||
import { buildTimeline } from '../../src/services/context/index.js';
|
||||
import type { Observation, SummaryTimelineItem } from '../../src/services/context/types.js';
|
||||
|
||||
/**
|
||||
* Timeline building tests - validates real sorting and merging logic
|
||||
*
|
||||
* Removed: queryObservations, querySummaries tests (mock database - not testing real behavior)
|
||||
* Kept: buildTimeline tests (tests actual sorting algorithm)
|
||||
*/
|
||||
|
||||
// Helper to create a minimal observation
|
||||
function createTestObservation(overrides: Partial<Observation> = {}): Observation {
|
||||
return {
|
||||
id: 1,
|
||||
@@ -29,7 +21,6 @@ function createTestObservation(overrides: Partial<Observation> = {}): Observatio
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create a summary timeline item
|
||||
function createTestSummaryTimelineItem(overrides: Partial<SummaryTimelineItem> = {}): SummaryTimelineItem {
|
||||
return {
|
||||
id: 1,
|
||||
@@ -73,7 +64,6 @@ describe('buildTimeline', () => {
|
||||
|
||||
const timeline = buildTimeline(observations, summaries);
|
||||
|
||||
// Should be sorted: obs2 (1000), summary (2000), obs1 (3000)
|
||||
expect(timeline).toHaveLength(3);
|
||||
expect(timeline[0].type).toBe('observation');
|
||||
expect((timeline[0].data as Observation).id).toBe(2);
|
||||
@@ -139,7 +129,6 @@ describe('buildTimeline', () => {
|
||||
|
||||
const timeline = buildTimeline(observations, summaries);
|
||||
|
||||
// Summary should come first because its displayEpoch is earlier
|
||||
expect(timeline[0].type).toBe('summary');
|
||||
expect(timeline[1].type).toBe('observation');
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import type { Observation } from '../../src/services/context/types.js';
|
||||
import { CHARS_PER_TOKEN_ESTIMATE } from '../../src/services/context/types.js';
|
||||
|
||||
// Helper to create a minimal observation for testing
|
||||
function createTestObservation(overrides: Partial<Observation> = {}): Observation {
|
||||
return {
|
||||
id: 1,
|
||||
@@ -38,45 +37,34 @@ describe('TokenCalculator', () => {
|
||||
it('should return 0 for an observation with no content', () => {
|
||||
const obs = createTestObservation();
|
||||
const tokens = calculateObservationTokens(obs);
|
||||
// Even empty observations have facts as "[]" when stringified
|
||||
// null facts becomes '[]' = 2 chars / 4 = 0.5 -> ceil = 1
|
||||
expect(tokens).toBe(1);
|
||||
});
|
||||
|
||||
it('should estimate tokens based on title length', () => {
|
||||
const title = 'A'.repeat(40); // 40 chars = 10 tokens
|
||||
const title = 'A'.repeat(40);
|
||||
const obs = createTestObservation({ title });
|
||||
const tokens = calculateObservationTokens(obs);
|
||||
// title (40) + facts stringified (null -> '[]' = 2) = 42 / 4 = 10.5 -> 11
|
||||
expect(tokens).toBe(11);
|
||||
});
|
||||
|
||||
it('should estimate tokens based on subtitle length', () => {
|
||||
const subtitle = 'B'.repeat(20); // 20 chars = 5 tokens
|
||||
const subtitle = 'B'.repeat(20);
|
||||
const obs = createTestObservation({ subtitle });
|
||||
const tokens = calculateObservationTokens(obs);
|
||||
// subtitle (20) + facts (2) = 22 / 4 = 5.5 -> 6
|
||||
expect(tokens).toBe(6);
|
||||
});
|
||||
|
||||
it('should estimate tokens based on narrative length', () => {
|
||||
const narrative = 'C'.repeat(80); // 80 chars = 20 tokens
|
||||
const narrative = 'C'.repeat(80);
|
||||
const obs = createTestObservation({ narrative });
|
||||
const tokens = calculateObservationTokens(obs);
|
||||
// narrative (80) + facts (2) = 82 / 4 = 20.5 -> 21
|
||||
expect(tokens).toBe(21);
|
||||
});
|
||||
|
||||
it('should estimate tokens based on facts JSON length', () => {
|
||||
// When facts is a string, JSON.stringify adds quotes around it
|
||||
// '["fact"]' as string becomes '"[\\"fact\\"]"' when stringified
|
||||
// But in practice, obs.facts is a string that gets stringified
|
||||
const facts = '["fact one", "fact two", "fact three"]'; // 38 chars
|
||||
const facts = '["fact one", "fact two", "fact three"]';
|
||||
const obs = createTestObservation({ facts });
|
||||
const tokens = calculateObservationTokens(obs);
|
||||
// JSON.stringify of string adds quotes: 38 + 2 = 40, plus escaping
|
||||
// Actually becomes: '"[\"fact one\", \"fact two\", \"fact three\"]"' = 46 chars
|
||||
// 46 / 4 = 11.5 -> 12
|
||||
expect(tokens).toBe(12);
|
||||
});
|
||||
|
||||
@@ -88,23 +76,19 @@ describe('TokenCalculator', () => {
|
||||
facts: '["test"]', // 8 chars, but JSON.stringify adds quotes = 10 chars
|
||||
});
|
||||
const tokens = calculateObservationTokens(obs);
|
||||
// 20 + 20 + 40 + 10 (stringified) = 90 / 4 = 22.5 -> 23
|
||||
expect(tokens).toBe(23);
|
||||
});
|
||||
|
||||
it('should handle large observations correctly', () => {
|
||||
const largeNarrative = 'X'.repeat(4000); // 4000 chars = 1000 tokens
|
||||
const largeNarrative = 'X'.repeat(4000);
|
||||
const obs = createTestObservation({ narrative: largeNarrative });
|
||||
const tokens = calculateObservationTokens(obs);
|
||||
// 4000 + 2 (null facts) = 4002 / 4 = 1000.5 -> 1001
|
||||
expect(tokens).toBe(1001);
|
||||
});
|
||||
|
||||
it('should round up fractional tokens using ceil', () => {
|
||||
// 9 chars / 4 = 2.25 -> should be 3
|
||||
const obs = createTestObservation({ title: 'ABCDEFGHI' }); // 9 chars
|
||||
const obs = createTestObservation({ title: 'ABCDEFGHI' });
|
||||
const tokens = calculateObservationTokens(obs);
|
||||
// 9 + 2 = 11 / 4 = 2.75 -> 3
|
||||
expect(tokens).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -177,7 +161,6 @@ describe('TokenCalculator', () => {
|
||||
});
|
||||
|
||||
it('should calculate savings percent correctly', () => {
|
||||
// If discovery = 1000 and read = 100, savings = 900, percent = 90%
|
||||
const observations = [
|
||||
createTestObservation({
|
||||
title: 'A'.repeat(396), // 396 + 2 = 398 / 4 = 99.5 -> 100 read tokens
|
||||
@@ -203,7 +186,6 @@ describe('TokenCalculator', () => {
|
||||
});
|
||||
|
||||
it('should handle negative savings correctly', () => {
|
||||
// When read tokens > discovery tokens, savings is negative
|
||||
const observations = [
|
||||
createTestObservation({
|
||||
narrative: 'X'.repeat(400), // ~101 read tokens
|
||||
@@ -216,8 +198,6 @@ describe('TokenCalculator', () => {
|
||||
});
|
||||
|
||||
it('should round savings percent to nearest integer', () => {
|
||||
// Create a scenario where savings percent is fractional
|
||||
// discovery = 100, read = 33, savings = 67, percent = 67%
|
||||
const observations = [
|
||||
createTestObservation({
|
||||
title: 'A'.repeat(130), // 130 + 2 = 132 / 4 = 33 read tokens
|
||||
|
||||
@@ -4,28 +4,17 @@ import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { writeContextFile, readContextFile } from '../src/utils/cursor-utils';
|
||||
|
||||
/**
|
||||
* Tests for Cursor Context Update functionality
|
||||
*
|
||||
* These tests validate that context files are correctly written to
|
||||
* .cursor/rules/claude-mem-context.mdc for registered projects.
|
||||
*
|
||||
* The context file uses Cursor's MDC format with frontmatter.
|
||||
*/
|
||||
|
||||
describe('Cursor Context Update', () => {
|
||||
let tempDir: string;
|
||||
let workspacePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
tempDir = join(tmpdir(), `cursor-context-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
workspacePath = join(tempDir, 'my-project');
|
||||
mkdirSync(workspacePath, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
@@ -135,10 +124,8 @@ Paragraph 2`;
|
||||
const content = readContextFile(workspacePath)!;
|
||||
const lines = content.split('\n');
|
||||
|
||||
// First line should be ---
|
||||
expect(lines[0]).toBe('---');
|
||||
|
||||
// Should have closing --- for frontmatter
|
||||
const secondDashIndex = lines.indexOf('---', 1);
|
||||
expect(secondDashIndex).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -152,7 +139,6 @@ Paragraph 2`;
|
||||
|
||||
const frontmatter = lines.slice(1, frontmatterEnd).join('\n');
|
||||
|
||||
// Should contain valid YAML key-value pairs
|
||||
expect(frontmatter).toMatch(/alwaysApply:\s*true/);
|
||||
expect(frontmatter).toMatch(/description:\s*"/);
|
||||
});
|
||||
@@ -162,12 +148,9 @@ Paragraph 2`;
|
||||
|
||||
const content = readContextFile(workspacePath)!;
|
||||
|
||||
// Should have markdown header
|
||||
expect(content).toMatch(/^# Memory Context/m);
|
||||
|
||||
// Should have horizontal rule (---)
|
||||
// Note: The footer uses --- which is also a horizontal rule in markdown
|
||||
const bodyPart = content.split('---')[2]; // After frontmatter
|
||||
const bodyPart = content.split('---')[2];
|
||||
expect(bodyPart).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -196,7 +179,6 @@ Paragraph 2`;
|
||||
});
|
||||
|
||||
it('handles very long context', () => {
|
||||
// 100KB of context
|
||||
const longContext = 'x'.repeat(100 * 1024);
|
||||
|
||||
writeContextFile(workspacePath, longContext);
|
||||
@@ -206,13 +188,11 @@ Paragraph 2`;
|
||||
});
|
||||
|
||||
it('works when .cursor directory already exists', () => {
|
||||
// Pre-create .cursor with other content
|
||||
mkdirSync(join(workspacePath, '.cursor', 'other'), { recursive: true });
|
||||
writeFileSync(join(workspacePath, '.cursor', 'other', 'file.txt'), 'existing');
|
||||
|
||||
writeContextFile(workspacePath, 'new context');
|
||||
|
||||
// Should not destroy existing content
|
||||
expect(existsSync(join(workspacePath, '.cursor', 'other', 'file.txt'))).toBe(true);
|
||||
expect(readContextFile(workspacePath)).toContain('new context');
|
||||
});
|
||||
|
||||
@@ -7,20 +7,6 @@ import {
|
||||
urlEncode
|
||||
} from '../src/utils/cursor-utils';
|
||||
|
||||
/**
|
||||
* Tests for Cursor Hooks JSON/Utility Functions
|
||||
*
|
||||
* These tests validate the logic used in common.sh bash utilities.
|
||||
* The TypeScript implementations in cursor-utils.ts mirror the bash logic,
|
||||
* allowing us to verify correct behavior and catch edge cases.
|
||||
*
|
||||
* The bash scripts use these functions:
|
||||
* - json_get: Extract fields from JSON, including array access
|
||||
* - get_project_name: Extract project name from workspace path
|
||||
* - is_empty: Check if a string is empty/null
|
||||
* - url_encode: URL-encode a string
|
||||
*/
|
||||
|
||||
describe('Cursor Hooks JSON Utilities', () => {
|
||||
describe('parseArrayField', () => {
|
||||
it('parses simple array access', () => {
|
||||
@@ -97,7 +83,6 @@ describe('Cursor Hooks JSON Utilities', () => {
|
||||
});
|
||||
|
||||
it('returns empty string value (not fallback)', () => {
|
||||
// Empty string is a valid value, should not trigger fallback
|
||||
expect(jsonGet(testJson, 'empty_string', 'fallback')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -158,7 +143,6 @@ describe('Cursor Hooks JSON Utilities', () => {
|
||||
});
|
||||
|
||||
it('returns true for literal "null" string', () => {
|
||||
// This is important - jq returns "null" as string when value is null
|
||||
expect(isEmpty('null')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -171,7 +155,6 @@ describe('Cursor Hooks JSON Utilities', () => {
|
||||
});
|
||||
|
||||
it('returns false for whitespace-only string', () => {
|
||||
// Whitespace is not empty
|
||||
expect(isEmpty(' ')).toBe(false);
|
||||
});
|
||||
|
||||
@@ -217,7 +200,6 @@ describe('Cursor Hooks JSON Utilities', () => {
|
||||
});
|
||||
|
||||
describe('integration: hook payload parsing', () => {
|
||||
// Simulates parsing a real Cursor hook payload
|
||||
|
||||
it('extracts all fields from typical beforeSubmitPrompt payload', () => {
|
||||
const payload = {
|
||||
|
||||
@@ -8,29 +8,18 @@ import {
|
||||
type CursorMcpConfig
|
||||
} from '../src/utils/cursor-utils';
|
||||
|
||||
/**
|
||||
* Tests for Cursor MCP Configuration
|
||||
*
|
||||
* These tests validate the MCP server configuration that gets written
|
||||
* to .cursor/mcp.json (project-level) or ~/.cursor/mcp.json (user-level).
|
||||
*
|
||||
* The config must match Cursor's expected format for MCP servers.
|
||||
*/
|
||||
|
||||
describe('Cursor MCP Configuration', () => {
|
||||
let tempDir: string;
|
||||
let mcpJsonPath: string;
|
||||
const mcpServerPath = '/path/to/mcp-server.cjs';
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
tempDir = join(tmpdir(), `cursor-mcp-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
mcpJsonPath = join(tempDir, '.cursor', 'mcp.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
@@ -63,7 +52,6 @@ describe('Cursor MCP Configuration', () => {
|
||||
});
|
||||
|
||||
it('preserves existing MCP servers when adding claude-mem', () => {
|
||||
// Pre-create config with another server
|
||||
mkdirSync(join(tempDir, '.cursor'), { recursive: true });
|
||||
const existingConfig = {
|
||||
mcpServers: {
|
||||
@@ -79,17 +67,14 @@ describe('Cursor MCP Configuration', () => {
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
|
||||
// Both servers should exist
|
||||
expect(config.mcpServers['other-server']).toBeDefined();
|
||||
expect(config.mcpServers['other-server'].command).toBe('python');
|
||||
expect(config.mcpServers['claude-mem']).toBeDefined();
|
||||
});
|
||||
|
||||
it('updates existing claude-mem server path', () => {
|
||||
// First config
|
||||
configureCursorMcp(mcpJsonPath, '/old/path.cjs');
|
||||
|
||||
// Update with new path
|
||||
const newPath = '/new/path.cjs';
|
||||
configureCursorMcp(mcpJsonPath, newPath);
|
||||
|
||||
@@ -99,11 +84,9 @@ describe('Cursor MCP Configuration', () => {
|
||||
});
|
||||
|
||||
it('recovers from corrupt mcp.json', () => {
|
||||
// Create corrupt file
|
||||
mkdirSync(join(tempDir, '.cursor'), { recursive: true });
|
||||
writeFileSync(mcpJsonPath, 'not valid json {{{{');
|
||||
|
||||
// Should not throw, should overwrite
|
||||
configureCursorMcp(mcpJsonPath, mcpServerPath);
|
||||
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
@@ -111,7 +94,6 @@ describe('Cursor MCP Configuration', () => {
|
||||
});
|
||||
|
||||
it('handles mcp.json with missing mcpServers key', () => {
|
||||
// Create file with empty object
|
||||
mkdirSync(join(tempDir, '.cursor'), { recursive: true });
|
||||
writeFileSync(mcpJsonPath, '{}');
|
||||
|
||||
@@ -128,7 +110,6 @@ describe('Cursor MCP Configuration', () => {
|
||||
|
||||
const content = readFileSync(mcpJsonPath, 'utf-8');
|
||||
|
||||
// Should not throw
|
||||
expect(() => JSON.parse(content)).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -137,7 +118,6 @@ describe('Cursor MCP Configuration', () => {
|
||||
|
||||
const content = readFileSync(mcpJsonPath, 'utf-8');
|
||||
|
||||
// Should contain newlines and indentation
|
||||
expect(content).toContain('\n');
|
||||
expect(content).toContain(' "mcpServers"');
|
||||
});
|
||||
@@ -147,11 +127,9 @@ describe('Cursor MCP Configuration', () => {
|
||||
|
||||
const config = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
|
||||
// Top-level must have mcpServers
|
||||
expect(config).toHaveProperty('mcpServers');
|
||||
expect(typeof config.mcpServers).toBe('object');
|
||||
|
||||
// Each server must have command (string) and optionally args (array)
|
||||
for (const [name, server] of Object.entries(config.mcpServers)) {
|
||||
expect(typeof name).toBe('string');
|
||||
expect((server as { command: string }).command).toBeDefined();
|
||||
@@ -176,7 +154,6 @@ describe('Cursor MCP Configuration', () => {
|
||||
});
|
||||
|
||||
it('preserves other servers when removing claude-mem', () => {
|
||||
// Setup: both servers
|
||||
mkdirSync(join(tempDir, '.cursor'), { recursive: true });
|
||||
const config = {
|
||||
mcpServers: {
|
||||
@@ -194,7 +171,6 @@ describe('Cursor MCP Configuration', () => {
|
||||
});
|
||||
|
||||
it('does nothing if mcp.json does not exist', () => {
|
||||
// Should not throw
|
||||
expect(() => removeMcpConfig(mcpJsonPath)).not.toThrow();
|
||||
expect(existsSync(mcpJsonPath)).toBe(false);
|
||||
});
|
||||
@@ -239,7 +215,6 @@ describe('Cursor MCP Configuration', () => {
|
||||
const config: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(config.mcpServers['claude-mem'].args).toEqual([specialPath]);
|
||||
|
||||
// Verify it survives JSON round-trip
|
||||
const reread: CursorMcpConfig = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
|
||||
expect(reread.mcpServers['claude-mem'].args![0]).toBe(specialPath);
|
||||
});
|
||||
|
||||
@@ -9,28 +9,17 @@ import {
|
||||
unregisterCursorProject
|
||||
} from '../src/utils/cursor-utils';
|
||||
|
||||
/**
|
||||
* Tests for Cursor Project Registry functionality
|
||||
*
|
||||
* These tests validate the file-based registry that tracks which projects
|
||||
* have Cursor hooks installed for automatic context updates.
|
||||
*
|
||||
* The registry is stored at ~/.claude-mem/cursor-projects.json
|
||||
*/
|
||||
|
||||
describe('Cursor Project Registry', () => {
|
||||
let tempDir: string;
|
||||
let registryFile: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
tempDir = join(tmpdir(), `cursor-registry-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
registryFile = join(tempDir, 'cursor-projects.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
@@ -80,7 +69,6 @@ describe('Cursor Project Registry', () => {
|
||||
expect(registry['test-project']).toBeDefined();
|
||||
expect(registry['test-project'].workspacePath).toBe('/workspace/test');
|
||||
|
||||
// Verify installedAt is a valid ISO timestamp within the test window
|
||||
const installedAt = new Date(registry['test-project'].installedAt).getTime();
|
||||
expect(installedAt).toBeGreaterThanOrEqual(before);
|
||||
expect(installedAt).toBeLessThanOrEqual(after);
|
||||
@@ -130,7 +118,6 @@ describe('Cursor Project Registry', () => {
|
||||
it('does nothing when unregistering non-existent project', () => {
|
||||
registerCursorProject(registryFile, 'existing', '/path');
|
||||
|
||||
// Should not throw
|
||||
unregisterCursorProject(registryFile, 'non-existent');
|
||||
|
||||
const registry = readCursorRegistry(registryFile);
|
||||
@@ -138,10 +125,8 @@ describe('Cursor Project Registry', () => {
|
||||
});
|
||||
|
||||
it('handles unregister when registry file does not exist', () => {
|
||||
// Should not throw even when file doesn't exist
|
||||
unregisterCursorProject(registryFile, 'any-project');
|
||||
|
||||
// File should not be created by unregister
|
||||
expect(existsSync(registryFile)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -151,7 +136,6 @@ describe('Cursor Project Registry', () => {
|
||||
registerCursorProject(registryFile, 'test', '/path');
|
||||
|
||||
const content = readFileSync(registryFile, 'utf-8');
|
||||
// Should be indented (pretty-printed)
|
||||
expect(content).toContain('\n');
|
||||
expect(content).toContain(' ');
|
||||
});
|
||||
@@ -160,7 +144,6 @@ describe('Cursor Project Registry', () => {
|
||||
registerCursorProject(registryFile, 'project-1', '/path/1');
|
||||
registerCursorProject(registryFile, 'project-2', '/path/2');
|
||||
|
||||
// Read raw and parse with JSON.parse (not our helper)
|
||||
const content = readFileSync(registryFile, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
/**
|
||||
* Tests for FK constraint fix (Issue #846)
|
||||
*
|
||||
* Problem: When worker restarts, observations fail because:
|
||||
* 1. Session created with memory_session_id = NULL
|
||||
* 2. SDK generates new memory_session_id
|
||||
* 3. storeObservation() tries to INSERT with new ID
|
||||
* 4. FK constraint fails - parent row doesn't have this ID yet
|
||||
*
|
||||
* Fix: ensureMemorySessionIdRegistered() updates parent table before child INSERT
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { SessionStore } from '../src/services/sqlite/SessionStore.js';
|
||||
@@ -18,14 +7,12 @@ describe('FK Constraint Fix (Issue #846)', () => {
|
||||
let testDbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Use unique temp database for each test (randomUUID prevents collision in parallel runs)
|
||||
testDbPath = `/tmp/test-fk-fix-${crypto.randomUUID()}.db`;
|
||||
store = new SessionStore(testDbPath);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
// Clean up test database
|
||||
try {
|
||||
require('fs').unlinkSync(testDbPath);
|
||||
} catch (e) {
|
||||
@@ -34,24 +21,18 @@ describe('FK Constraint Fix (Issue #846)', () => {
|
||||
});
|
||||
|
||||
it('should auto-register memory_session_id before observation INSERT', () => {
|
||||
// Create session with NULL memory_session_id (simulates initial creation)
|
||||
const sessionDbId = store.createSDKSession('test-content-id', 'test-project', 'test prompt');
|
||||
|
||||
// Verify memory_session_id starts as NULL
|
||||
const beforeSession = store.getSessionById(sessionDbId);
|
||||
expect(beforeSession?.memory_session_id).toBeNull();
|
||||
|
||||
// Simulate SDK providing new memory_session_id
|
||||
const newMemorySessionId = 'new-uuid-from-sdk-' + Date.now();
|
||||
|
||||
// Call ensureMemorySessionIdRegistered (the fix)
|
||||
store.ensureMemorySessionIdRegistered(sessionDbId, newMemorySessionId);
|
||||
|
||||
// Verify parent table was updated
|
||||
const afterSession = store.getSessionById(sessionDbId);
|
||||
expect(afterSession?.memory_session_id).toBe(newMemorySessionId);
|
||||
|
||||
// Now storeObservation should succeed (FK target exists)
|
||||
const result = store.storeObservation(
|
||||
newMemorySessionId,
|
||||
'test-project',
|
||||
@@ -73,17 +54,13 @@ describe('FK Constraint Fix (Issue #846)', () => {
|
||||
});
|
||||
|
||||
it('should not update if memory_session_id already matches', () => {
|
||||
// Create session
|
||||
const sessionDbId = store.createSDKSession('test-content-id-2', 'test-project', 'test prompt');
|
||||
const memorySessionId = 'fixed-memory-id-' + Date.now();
|
||||
|
||||
// Register it once
|
||||
store.ensureMemorySessionIdRegistered(sessionDbId, memorySessionId);
|
||||
|
||||
// Call again with same ID - should be a no-op
|
||||
store.ensureMemorySessionIdRegistered(sessionDbId, memorySessionId);
|
||||
|
||||
// Verify still has the same ID
|
||||
const session = store.getSessionById(sessionDbId);
|
||||
expect(session?.memory_session_id).toBe(memorySessionId);
|
||||
});
|
||||
@@ -97,28 +74,21 @@ describe('FK Constraint Fix (Issue #846)', () => {
|
||||
});
|
||||
|
||||
it('should handle observation storage after worker restart scenario', () => {
|
||||
// Simulate: Session exists from previous worker instance
|
||||
const sessionDbId = store.createSDKSession('restart-test-id', 'test-project', 'test prompt');
|
||||
|
||||
// Simulate: Previous worker had set a memory_session_id
|
||||
const oldMemorySessionId = 'old-stale-id';
|
||||
store.updateMemorySessionId(sessionDbId, oldMemorySessionId);
|
||||
|
||||
// Verify old ID is set
|
||||
const before = store.getSessionById(sessionDbId);
|
||||
expect(before?.memory_session_id).toBe(oldMemorySessionId);
|
||||
|
||||
// Simulate: New worker gets new memory_session_id from SDK
|
||||
const newMemorySessionId = 'new-fresh-id-from-sdk';
|
||||
|
||||
// The fix: ensure new ID is registered before storage
|
||||
store.ensureMemorySessionIdRegistered(sessionDbId, newMemorySessionId);
|
||||
|
||||
// Verify update happened
|
||||
const after = store.getSessionById(sessionDbId);
|
||||
expect(after?.memory_session_id).toBe(newMemorySessionId);
|
||||
|
||||
// Storage should now succeed
|
||||
const result = store.storeObservation(
|
||||
newMemorySessionId,
|
||||
'test-project',
|
||||
|
||||
@@ -1,35 +1,14 @@
|
||||
/**
|
||||
* Tests for Gemini CLI 0.37.0 compatibility fixes (Issue #1664)
|
||||
*
|
||||
* Validates:
|
||||
* 1. BeforeAgent is mapped to session-init (not user-message)
|
||||
* 2. Transcript parser handles Gemini JSON document format (type: "gemini")
|
||||
* 3. Summarize handler includes platformSource in the request body
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { writeFileSync, mkdirSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. BeforeAgent event mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GeminiCliHooksInstaller - event mapping', () => {
|
||||
it('should map BeforeAgent to session-init, not user-message', async () => {
|
||||
// Import the module to access the constant indirectly by inspecting
|
||||
// the generated command string through the installer's internal mapping.
|
||||
// The constant GEMINI_EVENT_TO_INTERNAL_EVENT is module-private, but we
|
||||
// can verify the effect by checking that the installer installs the
|
||||
// correct internal event name.
|
||||
//
|
||||
// Strategy: read the source file and assert the mapping directly.
|
||||
const { readFileSync } = await import('fs');
|
||||
const src = readFileSync('src/services/integrations/GeminiCliHooksInstaller.ts', 'utf-8');
|
||||
|
||||
// BeforeAgent must map to 'session-init'
|
||||
expect(src).toContain("'BeforeAgent': 'session-init'");
|
||||
// BeforeAgent must NOT map to 'user-message'
|
||||
expect(src).not.toContain("'BeforeAgent': 'user-message'");
|
||||
});
|
||||
|
||||
@@ -46,21 +25,15 @@ describe('GeminiCliHooksInstaller - event mapping', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Transcript parser — Gemini JSON document format
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('extractLastMessage - Gemini CLI 0.37.0 transcript format', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
// Helper: write a temp transcript file and return its path
|
||||
const writeTranscript = (name: string, content: string): string => {
|
||||
const filePath = join(tmpDir, name);
|
||||
writeFileSync(filePath, content, 'utf-8');
|
||||
return filePath;
|
||||
};
|
||||
|
||||
// Set up / tear down a fresh temp directory per suite
|
||||
const setup = () => {
|
||||
tmpDir = join(tmpdir(), `gemini-transcript-test-${Date.now()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
@@ -214,10 +187,6 @@ describe('extractLastMessage - Gemini CLI 0.37.0 transcript format', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Summarize handler includes platformSource
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Summarize handler - platformSource in request body', () => {
|
||||
it('should include platformSource import in summarize.ts', async () => {
|
||||
const { readFileSync } = await import('fs');
|
||||
@@ -229,9 +198,7 @@ describe('Summarize handler - platformSource in request body', () => {
|
||||
it('should pass platformSource in the summarize request body', async () => {
|
||||
const { readFileSync } = await import('fs');
|
||||
const src = readFileSync('src/cli/handlers/summarize.ts', 'utf-8');
|
||||
// The body must include platformSource
|
||||
expect(src).toContain('platformSource');
|
||||
// It must appear in the JSON.stringify call for the summarize endpoint
|
||||
expect(src).toContain('/api/sessions/summarize');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,17 +2,14 @@ import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:te
|
||||
import { writeFileSync, mkdirSync, rmSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { GeminiAgent } from '../src/services/worker/GeminiAgent';
|
||||
import { GeminiProvider } from '../src/services/worker/GeminiProvider';
|
||||
import { DatabaseManager } from '../src/services/worker/DatabaseManager';
|
||||
import { SessionManager } from '../src/services/worker/SessionManager';
|
||||
import { ModeManager } from '../src/services/domain/ModeManager';
|
||||
import { SettingsDefaultsManager } from '../src/shared/SettingsDefaultsManager';
|
||||
|
||||
// Track rate limiting setting (controls Gemini RPM throttling)
|
||||
// Set to 'false' to disable rate limiting for faster tests
|
||||
let rateLimitingEnabled = 'false';
|
||||
|
||||
// Mock mode config
|
||||
const mockMode = {
|
||||
name: 'code',
|
||||
prompts: {
|
||||
@@ -24,19 +21,16 @@ const mockMode = {
|
||||
observation_concepts: []
|
||||
};
|
||||
|
||||
// Use spyOn for all dependencies to avoid affecting other test files
|
||||
// spyOn restores automatically, unlike mock.module which persists
|
||||
let loadFromFileSpy: ReturnType<typeof spyOn>;
|
||||
let getSpy: ReturnType<typeof spyOn>;
|
||||
let modeManagerSpy: ReturnType<typeof spyOn>;
|
||||
|
||||
describe('GeminiAgent', () => {
|
||||
let agent: GeminiAgent;
|
||||
describe('GeminiProvider', () => {
|
||||
let agent: GeminiProvider;
|
||||
let originalFetch: typeof global.fetch;
|
||||
|
||||
// Mocks
|
||||
let mockStoreObservation: any;
|
||||
let mockStoreObservations: any; // Plural - atomic transaction method used by ResponseProcessor
|
||||
let mockStoreObservations: any;
|
||||
let mockStoreSummary: any;
|
||||
let mockMarkSessionCompleted: any;
|
||||
let mockSyncObservation: any;
|
||||
@@ -48,16 +42,13 @@ describe('GeminiAgent', () => {
|
||||
let mockSessionManager: SessionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset rate limiting to disabled by default (speeds up tests)
|
||||
rateLimitingEnabled = 'false';
|
||||
|
||||
// Mock ModeManager using spyOn (restores properly)
|
||||
modeManagerSpy = spyOn(ModeManager, 'getInstance').mockImplementation(() => ({
|
||||
getActiveMode: () => mockMode,
|
||||
loadMode: () => {},
|
||||
} as any));
|
||||
|
||||
// Mock SettingsDefaultsManager methods using spyOn (restores properly)
|
||||
loadFromFileSpy = spyOn(SettingsDefaultsManager, 'loadFromFile').mockImplementation(() => ({
|
||||
...SettingsDefaultsManager.getAllDefaults(),
|
||||
CLAUDE_MEM_GEMINI_API_KEY: 'test-api-key',
|
||||
@@ -74,7 +65,6 @@ describe('GeminiAgent', () => {
|
||||
return SettingsDefaultsManager.getAllDefaults()[key as keyof ReturnType<typeof SettingsDefaultsManager.getAllDefaults>] ?? '';
|
||||
});
|
||||
|
||||
// Initialize mocks
|
||||
mockStoreObservation = mock(() => ({ id: 1, createdAtEpoch: Date.now() }));
|
||||
mockStoreSummary = mock(() => ({ id: 1, createdAtEpoch: Date.now() }));
|
||||
mockMarkSessionCompleted = mock(() => {});
|
||||
@@ -84,7 +74,6 @@ describe('GeminiAgent', () => {
|
||||
mockCleanupProcessed = mock(() => 0);
|
||||
mockResetStuckMessages = mock(() => 0);
|
||||
|
||||
// Mock for storeObservations (plural) - the atomic transaction method called by ResponseProcessor
|
||||
mockStoreObservations = mock(() => ({
|
||||
observationIds: [1],
|
||||
summaryId: 1,
|
||||
@@ -97,7 +86,7 @@ describe('GeminiAgent', () => {
|
||||
storeSummary: mockStoreSummary,
|
||||
markSessionCompleted: mockMarkSessionCompleted,
|
||||
getSessionById: mock(() => ({ memory_session_id: 'mem-session-123' })), // Required by ResponseProcessor.ts for FK fix
|
||||
ensureMemorySessionIdRegistered: mock(() => {}) // Required by ResponseProcessor.ts for FK constraint fix (Issue #846)
|
||||
ensureMemorySessionIdRegistered: mock(() => {})
|
||||
};
|
||||
|
||||
const mockChromaSync = {
|
||||
@@ -122,13 +111,12 @@ describe('GeminiAgent', () => {
|
||||
getPendingMessageStore: () => mockPendingMessageStore
|
||||
} as unknown as SessionManager;
|
||||
|
||||
agent = new GeminiAgent(mockDbManager, mockSessionManager);
|
||||
agent = new GeminiProvider(mockDbManager, mockSessionManager);
|
||||
originalFetch = global.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
// Restore spied methods
|
||||
if (modeManagerSpy) modeManagerSpy.mockRestore();
|
||||
if (loadFromFileSpy) loadFromFileSpy.mockRestore();
|
||||
if (getSpy) getSpy.mockRestore();
|
||||
@@ -149,10 +137,8 @@ describe('GeminiAgent', () => {
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
||||
@@ -186,10 +172,8 @@ describe('GeminiAgent', () => {
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
||||
@@ -219,10 +203,8 @@ describe('GeminiAgent', () => {
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
const observationXml = `
|
||||
@@ -245,16 +227,12 @@ describe('GeminiAgent', () => {
|
||||
|
||||
await agent.startSession(session);
|
||||
|
||||
// ResponseProcessor uses storeObservations (plural) for atomic transactions
|
||||
expect(mockStoreObservations).toHaveBeenCalled();
|
||||
expect(mockSyncObservation).toHaveBeenCalled();
|
||||
expect(session.cumulativeInputTokens).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should throw on rate limit (429) error — no Claude fallback (#2087)', async () => {
|
||||
// The Claude-SDK fallback path was removed in #2087: it was never wired in
|
||||
// production (`fallbackAgent` was always null) so 429s already threw.
|
||||
// This test pins the new explicit behavior.
|
||||
const session = {
|
||||
sessionDbId: 1,
|
||||
contentSessionId: 'test-session',
|
||||
@@ -268,10 +246,8 @@ describe('GeminiAgent', () => {
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response('Resource has been exhausted (e.g. check quota).', { status: 429 })));
|
||||
@@ -293,10 +269,8 @@ describe('GeminiAgent', () => {
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response('Invalid argument', { status: 400 })));
|
||||
@@ -305,8 +279,6 @@ describe('GeminiAgent', () => {
|
||||
});
|
||||
|
||||
it('should respect rate limits when rate limiting enabled', async () => {
|
||||
// Enable rate limiting - this means requests will be throttled
|
||||
// Note: CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED !== 'false' means enabled
|
||||
rateLimitingEnabled = 'true';
|
||||
|
||||
const originalSetTimeout = global.setTimeout;
|
||||
@@ -327,10 +299,8 @@ describe('GeminiAgent', () => {
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
||||
@@ -348,7 +318,6 @@ describe('GeminiAgent', () => {
|
||||
|
||||
describe('conversation history truncation', () => {
|
||||
it('should truncate history when message count exceeds limit', async () => {
|
||||
// Build a history with 25 small messages (limit is 20)
|
||||
const history: any[] = [];
|
||||
for (let i = 0; i < 25; i++) {
|
||||
history.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `message ${i}` });
|
||||
@@ -367,10 +336,8 @@ describe('GeminiAgent', () => {
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: []
|
||||
} as any;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
||||
@@ -379,13 +346,11 @@ describe('GeminiAgent', () => {
|
||||
|
||||
await agent.startSession(session);
|
||||
|
||||
// The request body should have truncated contents (init adds 1 more, so 26 total → truncated to 20)
|
||||
const body = JSON.parse((global.fetch as any).mock.calls[0][1].body);
|
||||
expect(body.contents.length).toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
it('should always keep at least the newest message even if it exceeds token limit', async () => {
|
||||
// Override settings to have a very low token limit
|
||||
loadFromFileSpy.mockImplementation(() => ({
|
||||
...SettingsDefaultsManager.getAllDefaults(),
|
||||
CLAUDE_MEM_GEMINI_API_KEY: 'test-api-key',
|
||||
@@ -396,8 +361,7 @@ describe('GeminiAgent', () => {
|
||||
CLAUDE_MEM_DATA_DIR: '/tmp/claude-mem-test',
|
||||
}));
|
||||
|
||||
// Create a single large message that exceeds the token limit
|
||||
const largeContent = 'x'.repeat(8000); // ~2000 tokens, well above 1000 limit
|
||||
const largeContent = 'x'.repeat(8000);
|
||||
|
||||
const session = {
|
||||
sessionDbId: 1,
|
||||
@@ -412,10 +376,8 @@ describe('GeminiAgent', () => {
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: []
|
||||
} as any;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
||||
@@ -424,7 +386,6 @@ describe('GeminiAgent', () => {
|
||||
|
||||
await agent.startSession(session);
|
||||
|
||||
// Should still send at least 1 message (the newest), not empty contents
|
||||
const body = JSON.parse((global.fetch as any).mock.calls[0][1].body);
|
||||
expect(body.contents.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
@@ -432,7 +393,6 @@ describe('GeminiAgent', () => {
|
||||
|
||||
describe('gemini-3-flash-preview model support', () => {
|
||||
it('should accept gemini-3-flash-preview as a valid model', async () => {
|
||||
// The GeminiModel type includes gemini-3-flash-preview - compile-time check
|
||||
const validModels = [
|
||||
'gemini-2.5-flash-lite',
|
||||
'gemini-2.5-flash',
|
||||
@@ -442,15 +402,11 @@ describe('GeminiAgent', () => {
|
||||
'gemini-3-flash-preview'
|
||||
];
|
||||
|
||||
// Verify all models are strings (type guard)
|
||||
expect(validModels.every(m => typeof m === 'string')).toBe(true);
|
||||
expect(validModels).toContain('gemini-3-flash-preview');
|
||||
});
|
||||
|
||||
it('should have rate limit defined for gemini-3-flash-preview', async () => {
|
||||
// GEMINI_RPM_LIMITS['gemini-3-flash-preview'] = 5
|
||||
// This is enforced at compile time, but we can test the rate limiting behavior
|
||||
// by checking that the rate limit is applied when using gemini-3-flash-preview
|
||||
const session = {
|
||||
sessionDbId: 1,
|
||||
contentSessionId: 'test-session',
|
||||
@@ -464,10 +420,8 @@ describe('GeminiAgent', () => {
|
||||
pendingMessages: [],
|
||||
abortController: new AbortController(),
|
||||
generatorPromise: null,
|
||||
earliestPendingTimestamp: null,
|
||||
currentProvider: null,
|
||||
startTime: Date.now(),
|
||||
processingMessageIds: [] // CLAIM-CONFIRM pattern: track message IDs being processed
|
||||
} as any;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
|
||||
@@ -475,8 +429,6 @@ describe('GeminiAgent', () => {
|
||||
usageMetadata: { totalTokenCount: 10 }
|
||||
}))));
|
||||
|
||||
// This validates that gemini-3-flash-preview is a valid model at runtime
|
||||
// The agent's validation array includes gemini-3-flash-preview
|
||||
await agent.startSession(session);
|
||||
expect(global.fetch).toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,12 +1,3 @@
|
||||
/**
|
||||
* Tests for hook-command error classifier
|
||||
*
|
||||
* Validates that isWorkerUnavailableError correctly distinguishes between:
|
||||
* - Transport failures (ECONNREFUSED, etc.) → true (graceful degradation)
|
||||
* - Server errors (5xx) → true (graceful degradation)
|
||||
* - Client errors (4xx) → false (handler bug, blocking)
|
||||
* - Programming errors (TypeError, etc.) → false (code bug, blocking)
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { isWorkerUnavailableError } from '../src/cli/hook-command.js';
|
||||
|
||||
@@ -129,8 +120,6 @@ describe('isWorkerUnavailableError', () => {
|
||||
describe('programming errors → false (blocking)', () => {
|
||||
it('should NOT classify TypeError as worker unavailable', () => {
|
||||
const error = new TypeError('Cannot read properties of undefined');
|
||||
// Note: TypeError with "fetch failed" IS classified as unavailable (transport layer)
|
||||
// But generic TypeErrors are NOT
|
||||
expect(isWorkerUnavailableError(new TypeError('Cannot read properties of undefined'))).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
/**
|
||||
* Tests for hook timeout and exit code constants
|
||||
*
|
||||
* Mock Justification (~12% mock code):
|
||||
* - process.platform: Only mocked to test cross-platform timeout multiplier
|
||||
* logic - ensures Windows users get appropriate longer timeouts
|
||||
*
|
||||
* Value: Prevents regressions in timeout values that could cause
|
||||
* hook failures on slow systems or Windows
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { HOOK_TIMEOUTS, HOOK_EXIT_CODES, getTimeout } from '../src/shared/hook-constants.js';
|
||||
|
||||
@@ -15,7 +5,6 @@ describe('hook-constants', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original platform after each test
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
writable: true,
|
||||
@@ -101,7 +90,6 @@ describe('hook-constants', () => {
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// 333 * 1.5 = 499.5, should round to 500
|
||||
expect(getTimeout(333)).toBe(500);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
/**
|
||||
* Tests for Hook Lifecycle Fixes (TRIAGE-04)
|
||||
*
|
||||
* Validates:
|
||||
* - Stop hook returns suppressOutput: true (prevents infinite loop #987)
|
||||
* - All handlers return suppressOutput: true (prevents conversation pollution #598, #784)
|
||||
* - Unknown event types handled gracefully (fixes #984)
|
||||
* - stderr suppressed in hook context (fixes #1181)
|
||||
* - Claude Code adapter defaults suppressOutput to true
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||
|
||||
// --- Event Handler Tests ---
|
||||
|
||||
describe('Hook Lifecycle - Event Handlers', () => {
|
||||
describe('getEventHandler', () => {
|
||||
it('should return handler for all recognized event types', async () => {
|
||||
@@ -45,13 +33,10 @@ describe('Hook Lifecycle - Event Handlers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Codex CLI Compatibility Tests (#744) ---
|
||||
|
||||
describe('Codex CLI Compatibility (#744)', () => {
|
||||
describe('getPlatformAdapter', () => {
|
||||
it('should return rawAdapter for unknown platforms like codex', async () => {
|
||||
const { getPlatformAdapter, rawAdapter } = await import('../src/cli/adapters/index.js');
|
||||
// Should not throw for unknown platforms — falls back to rawAdapter
|
||||
const adapter = getPlatformAdapter('codex');
|
||||
expect(adapter).toBe(rawAdapter);
|
||||
});
|
||||
@@ -98,7 +83,6 @@ describe('Codex CLI Compatibility (#744)', () => {
|
||||
|
||||
describe('session-init handler undefined prompt', () => {
|
||||
it('should not throw when prompt is undefined', () => {
|
||||
// Verify the short-circuit logic works for undefined
|
||||
const rawPrompt: string | undefined = undefined;
|
||||
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;
|
||||
expect(prompt).toBe('[media prompt]');
|
||||
@@ -124,8 +108,6 @@ describe('Codex CLI Compatibility (#744)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Cursor IDE Compatibility Tests (#838, #1049) ---
|
||||
|
||||
describe('Cursor IDE Compatibility (#838, #1049)', () => {
|
||||
describe('cursorAdapter session ID fallbacks', () => {
|
||||
it('should use conversation_id when present', async () => {
|
||||
@@ -244,16 +226,12 @@ describe('Cursor IDE Compatibility (#838, #1049)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Platform Adapter Tests ---
|
||||
|
||||
describe('Hook Lifecycle - Claude Code Adapter', () => {
|
||||
const fmt = async (input: any) => {
|
||||
const { claudeCodeAdapter } = await import('../src/cli/adapters/claude-code.js');
|
||||
return claudeCodeAdapter.formatOutput(input);
|
||||
};
|
||||
|
||||
// --- Happy paths ---
|
||||
|
||||
it('should return empty object for empty result', async () => {
|
||||
expect(await fmt({})).toEqual({});
|
||||
});
|
||||
@@ -279,8 +257,6 @@ describe('Hook Lifecycle - Claude Code Adapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- Edge cases / unhappy paths (addresses PR #1291 review) ---
|
||||
|
||||
it('should return empty object for malformed input (undefined/null)', async () => {
|
||||
expect(await fmt(undefined)).toEqual({});
|
||||
expect(await fmt(null)).toEqual({});
|
||||
@@ -318,8 +294,6 @@ describe('Hook Lifecycle - Claude Code Adapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- stderr Suppression Tests ---
|
||||
|
||||
describe('Hook Lifecycle - stderr Suppression (#1181)', () => {
|
||||
let originalStderrWrite: typeof process.stderr.write;
|
||||
let stderrOutput: string[];
|
||||
@@ -327,7 +301,6 @@ describe('Hook Lifecycle - stderr Suppression (#1181)', () => {
|
||||
beforeEach(() => {
|
||||
originalStderrWrite = process.stderr.write.bind(process.stderr);
|
||||
stderrOutput = [];
|
||||
// Capture stderr writes
|
||||
process.stderr.write = ((chunk: any) => {
|
||||
stderrOutput.push(String(chunk));
|
||||
return true;
|
||||
@@ -339,26 +312,18 @@ describe('Hook Lifecycle - stderr Suppression (#1181)', () => {
|
||||
});
|
||||
|
||||
it('should not use console.error in handlers/index.ts for unknown events', async () => {
|
||||
// Re-import to get fresh module
|
||||
const { getEventHandler } = await import('../src/cli/handlers/index.js');
|
||||
|
||||
// Clear any stderr from import
|
||||
stderrOutput.length = 0;
|
||||
|
||||
// Call with unknown event — should use logger (writes to file), not console.error (writes to stderr)
|
||||
const handler = getEventHandler('unknown-event-type');
|
||||
await handler.execute({ sessionId: 'test', cwd: '/tmp' });
|
||||
|
||||
// No stderr output should have leaked from the handler dispatcher itself
|
||||
// (logger may write to stderr as fallback if log file unavailable, but that's
|
||||
// the logger's responsibility, not the dispatcher's)
|
||||
const dispatcherStderr = stderrOutput.filter(s => s.includes('[claude-mem] Unknown event'));
|
||||
expect(dispatcherStderr).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Hook Response Constants ---
|
||||
|
||||
describe('Hook Lifecycle - Standard Response', () => {
|
||||
it('should define standard hook response with suppressOutput: true', async () => {
|
||||
const { STANDARD_HOOK_RESPONSE } = await import('../src/hooks/hook-response.js');
|
||||
@@ -368,30 +333,19 @@ describe('Hook Lifecycle - Standard Response', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- hookCommand stderr suppression ---
|
||||
|
||||
describe('hookCommand - stderr suppression', () => {
|
||||
it('should not use console.error for worker unavailable errors', async () => {
|
||||
// The hookCommand function should use logger.warn instead of console.error
|
||||
// for worker unavailable errors, so stderr stays clean (#1181)
|
||||
const { hookCommand } = await import('../src/cli/hook-command.js');
|
||||
|
||||
// Verify the import includes logger
|
||||
const hookCommandSource = await Bun.file(
|
||||
new URL('../src/cli/hook-command.ts', import.meta.url).pathname
|
||||
).text();
|
||||
|
||||
// Should import logger
|
||||
expect(hookCommandSource).toContain("import { logger }");
|
||||
// Should use logger.warn for worker unavailable
|
||||
expect(hookCommandSource).toContain("logger.warn('HOOK'");
|
||||
// Should use logger.error for hook errors
|
||||
expect(hookCommandSource).toContain("logger.error('HOOK'");
|
||||
// Should suppress stderr
|
||||
expect(hookCommandSource).toContain("process.stderr.write = (() => true)");
|
||||
// Should restore stderr in finally block
|
||||
expect(hookCommandSource).toContain("process.stderr.write = originalStderrWrite");
|
||||
// Should NOT have console.error for error reporting
|
||||
expect(hookCommandSource).not.toContain("console.error(`[claude-mem]");
|
||||
expect(hookCommandSource).not.toContain("console.error(`Hook error:");
|
||||
});
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
// Tests for file-context cache validation and the #2094 deadlock fix.
|
||||
//
|
||||
// The hook used to truncate Reads to limit:1 and inject "you have enough info"
|
||||
// guidance — that combination broke Edit-after-Read because Claude Code's
|
||||
// read-state tracker saw a "read" but content was missing. Behavior now:
|
||||
// inject the timeline as supplementary context only; never set updatedInput.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
|
||||
import { mkdtempSync, writeFileSync, utimesSync, rmSync } from 'fs';
|
||||
import { tmpdir, homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
// Mock modules that cause import chain issues — MUST be before handler imports
|
||||
mock.module('../../src/shared/SettingsDefaultsManager.js', () => ({
|
||||
SettingsDefaultsManager: {
|
||||
get: (key: string) => {
|
||||
@@ -44,11 +37,10 @@ mock.module('../../src/utils/project-filter.js', () => ({
|
||||
isProjectExcluded: () => false,
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
import { fileContextHandler } from '../../src/cli/handlers/file-context.js';
|
||||
import { logger } from '../../src/utils/logger.js';
|
||||
|
||||
const PADDING = 'x'.repeat(2_000); // ensures file > FILE_READ_GATE_MIN_BYTES (1500)
|
||||
const PADDING = 'x'.repeat(2_000);
|
||||
|
||||
let tmpDir: string;
|
||||
let testFile: string;
|
||||
@@ -97,7 +89,6 @@ afterEach(() => {
|
||||
|
||||
describe('fileContextHandler — #2094 (no Read mutation)', () => {
|
||||
it('injects timeline context but never sets updatedInput on an unconstrained Read', async () => {
|
||||
// File mtime is "now" (just written). Make observations newer to avoid mtime bypass.
|
||||
const future = Date.now() + 60_000;
|
||||
fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
makeObservationsResponse([{ id: 1, created_at_epoch: future }])
|
||||
@@ -112,7 +103,6 @@ describe('fileContextHandler — #2094 (no Read mutation)', () => {
|
||||
|
||||
expect(result.hookSpecificOutput).toBeDefined();
|
||||
expect(result.hookSpecificOutput!.additionalContext).toContain('prior observations');
|
||||
// The whole point of #2094: do not rewrite the Read call.
|
||||
expect((result.hookSpecificOutput as any).updatedInput).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -134,7 +124,6 @@ describe('fileContextHandler — #2094 (no Read mutation)', () => {
|
||||
});
|
||||
|
||||
it('skips entirely when file mtime is newer than newest observation (#1719 still honored)', async () => {
|
||||
// Backdate observations 1 hour into the past so the just-written file is newer.
|
||||
const stale = Date.now() - 3_600_000;
|
||||
fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
makeObservationsResponse([
|
||||
@@ -150,13 +139,11 @@ describe('fileContextHandler — #2094 (no Read mutation)', () => {
|
||||
toolInput: { file_path: testFile },
|
||||
});
|
||||
|
||||
// Pass-through: no hookSpecificOutput
|
||||
expect(result.continue).toBe(true);
|
||||
expect(result.hookSpecificOutput).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still injects context when file mtime is older than newest observation', async () => {
|
||||
// Backdate the file by 1 hour, observations stamped "now"
|
||||
const past = (Date.now() - 3_600_000) / 1000;
|
||||
utimesSync(testFile, past, past);
|
||||
|
||||
@@ -192,7 +179,6 @@ describe('fileContextHandler — #2094 (no Read mutation)', () => {
|
||||
|
||||
const ctx = result.hookSpecificOutput!.additionalContext as string;
|
||||
expect(ctx).not.toContain('Only line 1 was read');
|
||||
// The new copy explicitly states the Read result is the full requested section.
|
||||
expect(ctx).toContain('full requested section');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
/**
|
||||
* Happy-path tests for runOneTimeV12_4_3Cleanup.
|
||||
*
|
||||
* Uses a real on-disk SQLite under a tmpdir so VACUUM INTO, statSync,
|
||||
* statfsSync, and marker-file writes all exercise their real code paths.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync, readFileSync, readdirSync } from 'fs';
|
||||
@@ -58,12 +52,10 @@ function seedDatabase(dbPath: string, opts: { observerSessions: number; stuckCou
|
||||
insertObservation.run(`obs-memory-${i}`, OBSERVER_SESSIONS_PROJECT, `obs ${i}`, now, epoch);
|
||||
}
|
||||
|
||||
// Real session that should survive
|
||||
const keepResult = insertSession.run('keep-content', 'keep-memory', 'real-project', now, epoch);
|
||||
const keepSessionDbId = Number(keepResult.lastInsertRowid);
|
||||
insertPrompt.run('keep-content', 'survives', now, epoch);
|
||||
|
||||
// Stuck pending_messages tied to the surviving session (so FK passes).
|
||||
const insertPending = db.prepare(
|
||||
`INSERT INTO pending_messages (session_db_id, content_session_id, message_type, status, created_at_epoch)
|
||||
VALUES (?, 'keep-content', 'observation', 'failed', ?)`
|
||||
@@ -105,7 +97,6 @@ describe('runOneTimeV12_4_3Cleanup', () => {
|
||||
const dbPath = path.join(tmpDataDir, 'claude-mem.db');
|
||||
seedDatabase(dbPath, { observerSessions: 3, stuckCount: 12 });
|
||||
|
||||
// chroma artifacts that should be wiped
|
||||
mkdirSync(path.join(tmpDataDir, 'chroma'), { recursive: true });
|
||||
writeFileSync(path.join(tmpDataDir, 'chroma', 'collection.bin'), 'opaque');
|
||||
writeFileSync(path.join(tmpDataDir, 'chroma-sync-state.json'), '{}');
|
||||
@@ -117,20 +108,17 @@ describe('runOneTimeV12_4_3Cleanup', () => {
|
||||
const payload = JSON.parse(readFileSync(markerPath, 'utf8'));
|
||||
|
||||
expect(payload.counts.observerSessions).toBe(3);
|
||||
expect(payload.counts.observerCascadeRows).toBe(6); // 3 user_prompts + 3 observations
|
||||
expect(payload.counts.observerCascadeRows).toBe(6);
|
||||
expect(payload.counts.stuckPendingMessages).toBe(12);
|
||||
expect(payload.chromaWiped).toBe(true);
|
||||
expect(payload.chromaWipeError).toBeUndefined();
|
||||
expect(payload.backupPath).toBeTruthy();
|
||||
|
||||
// Backup file is real and non-empty
|
||||
expect(existsSync(payload.backupPath)).toBe(true);
|
||||
|
||||
// Chroma artifacts gone
|
||||
expect(existsSync(path.join(tmpDataDir, 'chroma'))).toBe(false);
|
||||
expect(existsSync(path.join(tmpDataDir, 'chroma-sync-state.json'))).toBe(false);
|
||||
|
||||
// Real session still present; observer rows gone
|
||||
const verify = new Database(dbPath, { readonly: true });
|
||||
const observerCount = (verify.prepare('SELECT COUNT(*) AS n FROM sdk_sessions WHERE project = ?').get(OBSERVER_SESSIONS_PROJECT) as { n: number }).n;
|
||||
const realCount = (verify.prepare(`SELECT COUNT(*) AS n FROM sdk_sessions WHERE project = 'real-project'`).get() as { n: number }).n;
|
||||
@@ -140,7 +128,7 @@ describe('runOneTimeV12_4_3Cleanup', () => {
|
||||
|
||||
expect(observerCount).toBe(0);
|
||||
expect(realCount).toBe(1);
|
||||
expect(survivingPrompts).toBe(1); // only the keep-content prompt
|
||||
expect(survivingPrompts).toBe(1);
|
||||
expect(survivingPending).toBe(0);
|
||||
});
|
||||
|
||||
@@ -191,6 +179,6 @@ describe('runOneTimeV12_4_3Cleanup', () => {
|
||||
const verify = new Database(dbPath, { readonly: true });
|
||||
const observerCount = (verify.prepare('SELECT COUNT(*) AS n FROM sdk_sessions WHERE project = ?').get(OBSERVER_SESSIONS_PROJECT) as { n: number }).n;
|
||||
verify.close();
|
||||
expect(observerCount).toBe(1); // untouched
|
||||
expect(observerCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,17 +19,14 @@ const DATA_DIR = path.join(homedir(), '.claude-mem');
|
||||
const PID_FILE = path.join(DATA_DIR, 'worker.pid');
|
||||
|
||||
describe('GracefulShutdown', () => {
|
||||
// Store original PID file content if it exists
|
||||
let originalPidContent: string | null = null;
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
// Backup existing PID file if present
|
||||
if (existsSync(PID_FILE)) {
|
||||
originalPidContent = readFileSync(PID_FILE, 'utf-8');
|
||||
}
|
||||
|
||||
// Ensure we're testing on non-Windows to avoid child process enumeration
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
writable: true,
|
||||
@@ -38,7 +35,6 @@ describe('GracefulShutdown', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original PID file or remove test one
|
||||
if (originalPidContent !== null) {
|
||||
const { writeFileSync } = require('fs');
|
||||
writeFileSync(PID_FILE, originalPidContent);
|
||||
@@ -47,7 +43,6 @@ describe('GracefulShutdown', () => {
|
||||
removePidFile();
|
||||
}
|
||||
|
||||
// Restore platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
writable: true,
|
||||
@@ -93,7 +88,6 @@ describe('GracefulShutdown', () => {
|
||||
})
|
||||
};
|
||||
|
||||
// Create a PID file so we can verify it's removed
|
||||
writePidFile({ pid: 12345, port: 37777, startedAt: new Date().toISOString() });
|
||||
expect(existsSync(PID_FILE)).toBe(true);
|
||||
|
||||
@@ -107,7 +101,6 @@ describe('GracefulShutdown', () => {
|
||||
|
||||
await performGracefulShutdown(config);
|
||||
|
||||
// Verify order: PID removal happens first (synchronous), then server, then session, then MCP, then Chroma, then DB
|
||||
expect(callOrder).toContain('closeAllConnections');
|
||||
expect(callOrder).toContain('serverClose');
|
||||
expect(callOrder).toContain('sessionManager.shutdownAll');
|
||||
@@ -115,16 +108,12 @@ describe('GracefulShutdown', () => {
|
||||
expect(callOrder).toContain('chromaMcpManager.stop');
|
||||
expect(callOrder).toContain('dbManager.close');
|
||||
|
||||
// Verify server closes before session manager
|
||||
expect(callOrder.indexOf('serverClose')).toBeLessThan(callOrder.indexOf('sessionManager.shutdownAll'));
|
||||
|
||||
// Verify session manager shuts down before MCP client
|
||||
expect(callOrder.indexOf('sessionManager.shutdownAll')).toBeLessThan(callOrder.indexOf('mcpClient.close'));
|
||||
|
||||
// Verify MCP closes before database
|
||||
expect(callOrder.indexOf('mcpClient.close')).toBeLessThan(callOrder.indexOf('dbManager.close'));
|
||||
|
||||
// Verify Chroma stops before DB closes
|
||||
expect(callOrder.indexOf('chromaMcpManager.stop')).toBeLessThan(callOrder.indexOf('dbManager.close'));
|
||||
});
|
||||
|
||||
@@ -133,7 +122,6 @@ describe('GracefulShutdown', () => {
|
||||
shutdownAll: mock(async () => {})
|
||||
};
|
||||
|
||||
// Create PID file
|
||||
writePidFile({ pid: 99999, port: 37777, startedAt: new Date().toISOString() });
|
||||
expect(existsSync(PID_FILE)).toBe(true);
|
||||
|
||||
@@ -144,7 +132,6 @@ describe('GracefulShutdown', () => {
|
||||
|
||||
await performGracefulShutdown(config);
|
||||
|
||||
// PID file should be removed
|
||||
expect(existsSync(PID_FILE)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -159,10 +146,8 @@ describe('GracefulShutdown', () => {
|
||||
// mcpClient and dbManager are undefined
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
await expect(performGracefulShutdown(config)).resolves.toBeUndefined();
|
||||
|
||||
// Session manager should still be called
|
||||
expect(mockSessionManager.shutdownAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -176,7 +161,6 @@ describe('GracefulShutdown', () => {
|
||||
sessionManager: mockSessionManager
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
await expect(performGracefulShutdown(config)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -236,7 +220,6 @@ describe('GracefulShutdown', () => {
|
||||
});
|
||||
|
||||
it('should handle shutdown when PID file does not exist', async () => {
|
||||
// Ensure PID file doesn't exist
|
||||
removePidFile();
|
||||
expect(existsSync(PID_FILE)).toBe(false);
|
||||
|
||||
@@ -249,7 +232,6 @@ describe('GracefulShutdown', () => {
|
||||
sessionManager: mockSessionManager
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
await expect(performGracefulShutdown(config)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,15 +16,11 @@ describe('HealthMonitor', () => {
|
||||
});
|
||||
|
||||
describe('isPortInUse', () => {
|
||||
// Note: Since we are on Linux (as per session_context), isPortInUse uses 'net'
|
||||
// instead of 'fetch'. We need to mock 'net.createServer().listen()'
|
||||
|
||||
it('should return true for occupied port (EADDRINUSE)', async () => {
|
||||
// Create a specific mock for this test
|
||||
const createServerMock = mock(() => ({
|
||||
once: mock((event: string, cb: Function) => {
|
||||
if (event === 'error') {
|
||||
// Trigger EADDRINUSE immediately
|
||||
setTimeout(() => cb({ code: 'EADDRINUSE' }), 0);
|
||||
}
|
||||
}),
|
||||
@@ -46,7 +42,6 @@ describe('HealthMonitor', () => {
|
||||
const createServerMock = mock(() => ({
|
||||
once: mock((event: string, cb: Function) => {
|
||||
if (event === 'listening') {
|
||||
// Trigger listening success
|
||||
setTimeout(() => cb(), 0);
|
||||
}
|
||||
}),
|
||||
@@ -69,7 +64,6 @@ describe('HealthMonitor', () => {
|
||||
const createServerMock = mock(() => ({
|
||||
once: mock((event: string, cb: Function) => {
|
||||
if (event === 'error') {
|
||||
// Trigger other error (e.g., EACCES)
|
||||
setTimeout(() => cb({ code: 'EACCES' }), 0);
|
||||
}
|
||||
}),
|
||||
@@ -99,7 +93,6 @@ describe('HealthMonitor', () => {
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(result).toBe(true);
|
||||
// Should return quickly (within first poll cycle)
|
||||
expect(elapsed).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
@@ -111,7 +104,6 @@ describe('HealthMonitor', () => {
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(result).toBe(false);
|
||||
// Should take close to timeout duration
|
||||
expect(elapsed).toBeGreaterThanOrEqual(1400);
|
||||
expect(elapsed).toBeLessThan(2500);
|
||||
});
|
||||
@@ -120,7 +112,6 @@ describe('HealthMonitor', () => {
|
||||
let callCount = 0;
|
||||
global.fetch = mock(() => {
|
||||
callCount++;
|
||||
// Fail first 2 calls, succeed on third
|
||||
if (callCount < 3) {
|
||||
return Promise.reject(new Error('ECONNREFUSED'));
|
||||
}
|
||||
@@ -147,9 +138,6 @@ describe('HealthMonitor', () => {
|
||||
|
||||
await waitForHealth(37777, 1000);
|
||||
|
||||
// waitForHealth uses /api/health (liveness), not /api/readiness
|
||||
// This is because hooks have 15-second timeout but full initialization can take 5+ minutes
|
||||
// See: https://github.com/thedotmack/claude-mem/issues/811
|
||||
const calls = fetchMock.mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
expect(calls[0][0]).toBe('http://127.0.0.1:37777/api/health');
|
||||
@@ -162,7 +150,6 @@ describe('HealthMonitor', () => {
|
||||
text: () => Promise.resolve('')
|
||||
} as unknown as Response));
|
||||
|
||||
// Just verify it doesn't throw and returns quickly
|
||||
const result = await waitForHealth(37777);
|
||||
|
||||
expect(result).toBe(true);
|
||||
@@ -173,15 +160,12 @@ describe('HealthMonitor', () => {
|
||||
it('should return a valid semver string', () => {
|
||||
const version = getInstalledPluginVersion();
|
||||
|
||||
// Should be a string matching semver pattern or 'unknown'
|
||||
if (version !== 'unknown') {
|
||||
expect(version).toMatch(/^\d+\.\d+\.\d+/);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not throw on ENOENT (graceful degradation)', () => {
|
||||
// The function handles ENOENT internally — should not throw
|
||||
// If package.json exists, it returns the version; if not, 'unknown'
|
||||
expect(() => getInstalledPluginVersion()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -205,7 +189,6 @@ describe('HealthMonitor', () => {
|
||||
|
||||
const result = await checkVersionMatch(37777);
|
||||
|
||||
// Unless the plugin version is also '0.0.0-definitely-wrong', this should be a mismatch
|
||||
const pluginVersion = getInstalledPluginVersion();
|
||||
if (pluginVersion !== 'unknown' && pluginVersion !== '0.0.0-definitely-wrong') {
|
||||
expect(result.matches).toBe(false);
|
||||
@@ -214,7 +197,7 @@ describe('HealthMonitor', () => {
|
||||
|
||||
it('should detect version match', async () => {
|
||||
const pluginVersion = getInstalledPluginVersion();
|
||||
if (pluginVersion === 'unknown') return; // Skip if can't read plugin version
|
||||
if (pluginVersion === 'unknown') return;
|
||||
|
||||
global.fetch = mock(() => Promise.resolve({
|
||||
ok: true,
|
||||
@@ -274,7 +257,6 @@ describe('HealthMonitor', () => {
|
||||
const spy = spyOn(net, 'createServer').mockImplementation(() => ({
|
||||
once: mock((event: string, cb: Function) => {
|
||||
callCount++;
|
||||
// Port occupied for first 2 checks, then free
|
||||
if (callCount < 3) {
|
||||
if (event === 'error') setTimeout(() => cb({ code: 'EADDRINUSE' }), 0);
|
||||
} else {
|
||||
|
||||
@@ -4,15 +4,6 @@ import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { isPluginDisabledInClaudeSettings } from '../../src/shared/plugin-state.js';
|
||||
|
||||
/**
|
||||
* Tests for isPluginDisabledInClaudeSettings() (#781).
|
||||
*
|
||||
* The function reads CLAUDE_CONFIG_DIR/settings.json and checks if
|
||||
* enabledPlugins["claude-mem@thedotmack"] === false.
|
||||
*
|
||||
* We test by setting CLAUDE_CONFIG_DIR to a temp directory with mock settings.
|
||||
*/
|
||||
|
||||
let tempDir: string;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
|
||||
|
||||
@@ -6,13 +6,6 @@ import { fileURLToPath } from 'url';
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, '../..');
|
||||
|
||||
/**
|
||||
* Regression tests for plugin distribution completeness.
|
||||
* Ensures all required files (skills, hooks, manifests) are present
|
||||
* and correctly structured for end-user installs.
|
||||
*
|
||||
* Prevents issue #1187 (missing skills/ directory after install).
|
||||
*/
|
||||
describe('Plugin Distribution - Skills', () => {
|
||||
const skillPath = path.join(projectRoot, 'plugin/skills/mem-search/SKILL.md');
|
||||
|
||||
@@ -23,10 +16,8 @@ describe('Plugin Distribution - Skills', () => {
|
||||
it('should have valid YAML frontmatter with name and description', () => {
|
||||
const content = readFileSync(skillPath, 'utf-8');
|
||||
|
||||
// Must start with YAML frontmatter
|
||||
expect(content.startsWith('---\n')).toBe(true);
|
||||
|
||||
// Extract frontmatter
|
||||
const frontmatterEnd = content.indexOf('\n---\n', 4);
|
||||
expect(frontmatterEnd).toBeGreaterThan(0);
|
||||
|
||||
@@ -37,7 +28,6 @@ describe('Plugin Distribution - Skills', () => {
|
||||
|
||||
it('should reference the 3-layer search workflow', () => {
|
||||
const content = readFileSync(skillPath, 'utf-8');
|
||||
// The skill must document the search → timeline → get_observations workflow
|
||||
expect(content).toContain('search');
|
||||
expect(content).toContain('timeline');
|
||||
expect(content).toContain('get_observations');
|
||||
@@ -109,7 +99,6 @@ describe('Plugin Distribution - hooks.json Integrity', () => {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type === 'command') {
|
||||
expect(hook.command).toContain(cachePath);
|
||||
// Cache lookup must appear before the final marketplaces fallback
|
||||
expect(hook.command.indexOf(cachePath)).toBeLessThan(hook.command.indexOf(marketplacesPath));
|
||||
}
|
||||
}
|
||||
@@ -132,7 +121,6 @@ describe('Plugin Distribution - Build Script Verification', () => {
|
||||
const buildScriptPath = path.join(projectRoot, 'scripts/build-hooks.js');
|
||||
const content = readFileSync(buildScriptPath, 'utf-8');
|
||||
|
||||
// Build script must check for critical distribution files
|
||||
expect(content).toContain('plugin/skills/mem-search/SKILL.md');
|
||||
expect(content).toContain('plugin/hooks/hooks.json');
|
||||
expect(content).toContain('plugin/.claude-plugin/plugin.json');
|
||||
@@ -141,35 +129,30 @@ describe('Plugin Distribution - Build Script Verification', () => {
|
||||
|
||||
describe('Plugin Distribution - Setup Hook (#1547)', () => {
|
||||
it('should not reference removed setup.sh in Setup hook', () => {
|
||||
// setup.sh was removed; the Setup hook must not reference it or the
|
||||
// plugin silently fails to install on Linux (hooks disabled on setup failure).
|
||||
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
|
||||
const content = readFileSync(hooksPath, 'utf-8');
|
||||
expect(content).not.toContain('setup.sh');
|
||||
});
|
||||
|
||||
it('should call smart-install.js in the Setup hook', () => {
|
||||
it('should call version-check.js in the Setup hook', () => {
|
||||
const hooksPath = path.join(projectRoot, 'plugin/hooks/hooks.json');
|
||||
const parsed = JSON.parse(readFileSync(hooksPath, 'utf-8'));
|
||||
const setupHooks: any[] = parsed.hooks['Setup'] ?? [];
|
||||
|
||||
// Collect all command hooks from all matchers
|
||||
const commandHooks = setupHooks.flatMap((matcher: any) =>
|
||||
(matcher.hooks ?? []).filter((h: any) => h.type === 'command')
|
||||
);
|
||||
|
||||
// There must be at least one command hook — otherwise the test vacuously passes
|
||||
expect(commandHooks.length).toBeGreaterThan(0);
|
||||
|
||||
// At least one command hook must reference smart-install.js
|
||||
const smartInstallHooks = commandHooks.filter((h: any) =>
|
||||
h.command?.includes('smart-install.js')
|
||||
const versionCheckHooks = commandHooks.filter((h: any) =>
|
||||
h.command?.includes('version-check.js')
|
||||
);
|
||||
expect(smartInstallHooks.length).toBeGreaterThan(0);
|
||||
expect(versionCheckHooks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('smart-install.js referenced by Setup hook should exist on disk', () => {
|
||||
const smartInstallPath = path.join(projectRoot, 'plugin/scripts/smart-install.js');
|
||||
expect(existsSync(smartInstallPath)).toBe(true);
|
||||
it('version-check.js referenced by Setup hook should exist on disk', () => {
|
||||
const versionCheckPath = path.join(projectRoot, 'plugin/scripts/version-check.js');
|
||||
expect(existsSync(versionCheckPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,18 +25,15 @@ const DATA_DIR = path.join(homedir(), '.claude-mem');
|
||||
const PID_FILE = path.join(DATA_DIR, 'worker.pid');
|
||||
|
||||
describe('ProcessManager', () => {
|
||||
// Store original PID file content if it exists
|
||||
let originalPidContent: string | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
// Backup existing PID file if present
|
||||
if (existsSync(PID_FILE)) {
|
||||
originalPidContent = readFileSync(PID_FILE, 'utf-8');
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original PID file or remove test one
|
||||
if (originalPidContent !== null) {
|
||||
writeFileSync(PID_FILE, originalPidContent);
|
||||
originalPidContent = null;
|
||||
@@ -101,7 +98,6 @@ describe('ProcessManager', () => {
|
||||
});
|
||||
|
||||
it('should return null for missing file', () => {
|
||||
// Ensure file doesn't exist
|
||||
removePidFile();
|
||||
|
||||
const result = readPidFile();
|
||||
@@ -134,11 +130,9 @@ describe('ProcessManager', () => {
|
||||
});
|
||||
|
||||
it('should not throw for missing file', () => {
|
||||
// Ensure file doesn't exist
|
||||
removePidFile();
|
||||
expect(existsSync(PID_FILE)).toBe(false);
|
||||
|
||||
// Should not throw
|
||||
expect(() => removePidFile()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -157,9 +151,9 @@ describe('ProcessManager', () => {
|
||||
});
|
||||
|
||||
it('should parse DD-HH:MM:SS format', () => {
|
||||
expect(parseElapsedTime('1-00:00:00')).toBe(1440); // 1 day
|
||||
expect(parseElapsedTime('2-12:30:00')).toBe(3630); // 2 days + 12.5 hours
|
||||
expect(parseElapsedTime('0-01:00:00')).toBe(60); // 1 hour
|
||||
expect(parseElapsedTime('1-00:00:00')).toBe(1440);
|
||||
expect(parseElapsedTime('2-12:30:00')).toBe(3630);
|
||||
expect(parseElapsedTime('0-01:00:00')).toBe(60);
|
||||
});
|
||||
|
||||
it('should return -1 for empty or invalid input', () => {
|
||||
@@ -223,7 +217,6 @@ describe('ProcessManager', () => {
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// 2.0x of 333 = 666 (rounds to 666)
|
||||
const result = getPlatformTimeout(333);
|
||||
|
||||
expect(result).toBe(666);
|
||||
@@ -344,7 +337,6 @@ describe('ProcessManager', () => {
|
||||
});
|
||||
|
||||
it('should return false for a non-existent PID', () => {
|
||||
// Use a very high PID that's extremely unlikely to exist
|
||||
expect(isProcessAlive(2147483647)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -390,8 +382,6 @@ describe('ProcessManager', () => {
|
||||
});
|
||||
|
||||
it('returns null on win32 (liveness-only fallback path)', () => {
|
||||
// Simulate Windows to exercise the documented fallback. Real CI doesn't
|
||||
// run on win32, so without this mock the branch is uncovered.
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
||||
try {
|
||||
@@ -421,8 +411,6 @@ describe('ProcessManager', () => {
|
||||
});
|
||||
|
||||
it('omits startToken when the target PID has no readable token (dead PID)', () => {
|
||||
// pid is dead, so captureProcessStartToken() returns null and writePidFile
|
||||
// should not persist a startToken field.
|
||||
writePidFile({ pid: 2147483647, port: 37777, startedAt: new Date().toISOString() });
|
||||
const persisted = readPidFile();
|
||||
expect(persisted).not.toBeNull();
|
||||
@@ -467,8 +455,6 @@ describe('ProcessManager', () => {
|
||||
});
|
||||
|
||||
it.if(supported)('returns false when the stored token does not match (PID reused)', () => {
|
||||
// Simulates the container-restart bug: PID is alive (we pass our own),
|
||||
// but the stored token was written by a prior incarnation.
|
||||
expect(verifyPidFileOwnership({
|
||||
pid: process.pid,
|
||||
port: 37777,
|
||||
@@ -480,7 +466,6 @@ describe('ProcessManager', () => {
|
||||
|
||||
describe('cleanStalePidFile', () => {
|
||||
it('should remove PID file when process is dead', () => {
|
||||
// Write a PID file with a non-existent PID
|
||||
const staleInfo: PidInfo = {
|
||||
pid: 2147483647,
|
||||
port: 37777,
|
||||
@@ -495,7 +480,6 @@ describe('ProcessManager', () => {
|
||||
});
|
||||
|
||||
it('should keep PID file when process is alive', () => {
|
||||
// Write a PID file with the current process PID (definitely alive)
|
||||
const liveInfo: PidInfo = {
|
||||
pid: process.pid,
|
||||
port: 37777,
|
||||
@@ -505,7 +489,6 @@ describe('ProcessManager', () => {
|
||||
|
||||
cleanStalePidFile();
|
||||
|
||||
// PID file should still exist since process.pid is alive
|
||||
expect(existsSync(PID_FILE)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -513,7 +496,6 @@ describe('ProcessManager', () => {
|
||||
removePidFile();
|
||||
expect(existsSync(PID_FILE)).toBe(false);
|
||||
|
||||
// Should not throw
|
||||
expect(() => cleanStalePidFile()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -522,7 +504,6 @@ describe('ProcessManager', () => {
|
||||
it('should return true for a recently written PID file', () => {
|
||||
writePidFile({ pid: process.pid, port: 37777, startedAt: new Date().toISOString() });
|
||||
|
||||
// File was just written, should be very recent
|
||||
expect(isPidFileRecent(15000)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -535,9 +516,6 @@ describe('ProcessManager', () => {
|
||||
it('should return false for a very short threshold on a real file', () => {
|
||||
writePidFile({ pid: process.pid, port: 37777, startedAt: new Date().toISOString() });
|
||||
|
||||
// With a 0ms threshold, even a just-written file should be "too old"
|
||||
// (mtime is at least 1ms in the past by the time we check)
|
||||
// Use a negative threshold to guarantee false
|
||||
expect(isPidFileRecent(-1)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -546,13 +524,11 @@ describe('ProcessManager', () => {
|
||||
it('should update mtime of existing PID file', async () => {
|
||||
writePidFile({ pid: process.pid, port: 37777, startedAt: new Date().toISOString() });
|
||||
|
||||
// Wait a bit to ensure measurable mtime difference
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
|
||||
const statsBefore = statSync(PID_FILE);
|
||||
const mtimeBefore = statsBefore.mtimeMs;
|
||||
|
||||
// Wait again to ensure mtime advances
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
|
||||
touchPidFile();
|
||||
@@ -572,71 +548,42 @@ describe('ProcessManager', () => {
|
||||
|
||||
describe('spawnDaemon', () => {
|
||||
it('should use setsid on Linux when available', () => {
|
||||
// setsid should exist at /usr/bin/setsid on Linux
|
||||
if (process.platform === 'win32') return; // Skip on Windows
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const setsidAvailable = existsSync('/usr/bin/setsid');
|
||||
if (!setsidAvailable) return; // Skip if setsid not installed
|
||||
if (!setsidAvailable) return;
|
||||
|
||||
// Spawn a daemon with a non-existent script (it will fail to start, but we can verify the spawn attempt)
|
||||
// Use a harmless script path — the child will exit immediately
|
||||
const pid = spawnDaemon('/dev/null', 39999);
|
||||
|
||||
// setsid spawn should return a PID (the setsid process itself)
|
||||
expect(pid).toBeDefined();
|
||||
expect(typeof pid).toBe('number');
|
||||
|
||||
// Clean up: kill the spawned process if it's still alive
|
||||
if (pid !== undefined && pid > 0) {
|
||||
try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ }
|
||||
}
|
||||
});
|
||||
|
||||
it('should return undefined when spawn fails on Windows path', () => {
|
||||
// On non-Windows, this tests the Unix path which should succeed
|
||||
// The function should not throw, only return undefined on failure
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
// Spawning with a totally invalid script should still return a PID
|
||||
// (setsid/spawn succeeds even if the child will exit immediately)
|
||||
const result = spawnDaemon('/nonexistent/script.cjs', 39998);
|
||||
// spawn itself should succeed (returns PID), even if child exits
|
||||
expect(result).toBeDefined();
|
||||
|
||||
// Clean up
|
||||
if (result !== undefined && result > 0) {
|
||||
try { process.kill(result, 'SIGKILL'); } catch { /* already exited */ }
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Documents the spawnDaemon return contract for the Windows `0` PID
|
||||
* success sentinel. PowerShell `Start-Process` does not return the spawned
|
||||
* PID, so the Windows branch returns 0 as a "spawn dispatched" sentinel.
|
||||
* Callers MUST use `pid === undefined` to detect failure — never falsy
|
||||
* checks like `if (!pid)`, which would silently treat success as failure
|
||||
* because 0 is falsy in JavaScript.
|
||||
*
|
||||
* This contract test exists so any future contributor introducing
|
||||
* `if (!pid)` against a spawnDaemon return value (or its wrapper) sees a
|
||||
* failing assertion that documents why the falsy check is incorrect.
|
||||
* See PR #1645 review feedback for context.
|
||||
*/
|
||||
it('Windows 0 PID success sentinel must NOT be detected via falsy check', () => {
|
||||
const windowsSuccessSentinel: number | undefined = 0;
|
||||
const failureSentinel: number | undefined = undefined;
|
||||
|
||||
// Correct contract: undefined === failure, anything else === success.
|
||||
expect(windowsSuccessSentinel === undefined).toBe(false);
|
||||
expect(failureSentinel === undefined).toBe(true);
|
||||
|
||||
// Demonstrates the bug a future regression would introduce:
|
||||
// `if (!pid)` is true for BOTH the Windows success sentinel AND the
|
||||
// genuine failure sentinel — silently treating success as failure.
|
||||
expect(!windowsSuccessSentinel).toBe(true); // ← this is the trap
|
||||
expect(!windowsSuccessSentinel).toBe(true);
|
||||
expect(!failureSentinel).toBe(true);
|
||||
|
||||
// Therefore, callers must use strict undefined comparison.
|
||||
const isFailure = (pid: number | undefined) => pid === undefined;
|
||||
expect(isFailure(windowsSuccessSentinel)).toBe(false);
|
||||
expect(isFailure(failureSentinel)).toBe(true);
|
||||
@@ -645,26 +592,21 @@ describe('ProcessManager', () => {
|
||||
|
||||
describe('SIGHUP handling', () => {
|
||||
it('should have SIGHUP listeners registered (integration check)', () => {
|
||||
// Verify that SIGHUP listener registration is possible on Unix
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
// Register a test handler, verify it works, then remove it
|
||||
let received = false;
|
||||
const testHandler = () => { received = true; };
|
||||
|
||||
process.on('SIGHUP', testHandler);
|
||||
expect(process.listenerCount('SIGHUP')).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Clean up the test handler
|
||||
process.removeListener('SIGHUP', testHandler);
|
||||
});
|
||||
|
||||
it('should ignore SIGHUP when --daemon is in process.argv', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
// Simulate the daemon SIGHUP handler logic
|
||||
const isDaemon = process.argv.includes('--daemon');
|
||||
// In test context, --daemon is not in argv, so this tests the branch logic
|
||||
expect(isDaemon).toBe(false);
|
||||
|
||||
// Verify the non-daemon path: SIGHUP should trigger shutdown (covered by registerSignalHandlers)
|
||||
@@ -685,37 +627,30 @@ describe('ProcessManager', () => {
|
||||
});
|
||||
|
||||
it('should wipe chroma directory and write marker file', () => {
|
||||
// Create a fake chroma directory with data
|
||||
const chromaDir = path.join(testDataDir, 'chroma');
|
||||
mkdirSync(chromaDir, { recursive: true });
|
||||
writeFileSync(path.join(chromaDir, 'test-data.bin'), 'fake chroma data');
|
||||
|
||||
runOneTimeChromaMigration(testDataDir);
|
||||
|
||||
// Chroma dir should be gone
|
||||
expect(existsSync(chromaDir)).toBe(false);
|
||||
// Marker file should exist
|
||||
expect(existsSync(path.join(testDataDir, '.chroma-cleaned-v10.3'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip when marker file already exists (idempotent)', () => {
|
||||
// Write marker file first
|
||||
writeFileSync(path.join(testDataDir, '.chroma-cleaned-v10.3'), 'already done');
|
||||
|
||||
// Create a chroma directory that should NOT be wiped
|
||||
const chromaDir = path.join(testDataDir, 'chroma');
|
||||
mkdirSync(chromaDir, { recursive: true });
|
||||
writeFileSync(path.join(chromaDir, 'important.bin'), 'should survive');
|
||||
|
||||
runOneTimeChromaMigration(testDataDir);
|
||||
|
||||
// Chroma dir should still exist (migration was skipped)
|
||||
expect(existsSync(chromaDir)).toBe(true);
|
||||
expect(existsSync(path.join(chromaDir, 'important.bin'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle missing chroma directory gracefully', () => {
|
||||
// No chroma dir exists — should just write marker without error
|
||||
expect(() => runOneTimeChromaMigration(testDataDir)).not.toThrow();
|
||||
expect(existsSync(path.join(testDataDir, '.chroma-cleaned-v10.3'))).toBe(true);
|
||||
});
|
||||
|
||||
@@ -6,15 +6,6 @@ import { fileURLToPath } from 'url';
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, '../..');
|
||||
|
||||
/**
|
||||
* Test suite to ensure version consistency across all package.json files
|
||||
* and built artifacts.
|
||||
*
|
||||
* This prevents the infinite restart loop issue where:
|
||||
* - Plugin reads version from plugin/package.json
|
||||
* - Worker returns built-in version from bundled code
|
||||
* - Mismatch triggers restart on every hook call
|
||||
*/
|
||||
describe('Version Consistency', () => {
|
||||
let rootVersion: string;
|
||||
|
||||
@@ -61,19 +52,13 @@ describe('Version Consistency', () => {
|
||||
it('should have version injected into built worker-service.cjs', () => {
|
||||
const workerServicePath = path.join(projectRoot, 'plugin/scripts/worker-service.cjs');
|
||||
|
||||
// Skip if file doesn't exist (e.g., before first build)
|
||||
if (!existsSync(workerServicePath)) {
|
||||
console.log('⚠️ worker-service.cjs not found - run npm run build first');
|
||||
return;
|
||||
}
|
||||
|
||||
const workerServiceContent = readFileSync(workerServicePath, 'utf-8');
|
||||
|
||||
// The build script injects version via esbuild define:
|
||||
// define: { '__DEFAULT_PACKAGE_VERSION__': `"${version}"` }
|
||||
// This becomes: const BUILT_IN_VERSION = "9.0.0" (or minified: Bre="9.0.0")
|
||||
|
||||
// Check for the version string in the minified code
|
||||
|
||||
const versionPattern = new RegExp(`"${rootVersion.replace(/\./g, '\\.')}"`, 'g');
|
||||
const matches = workerServiceContent.match(versionPattern);
|
||||
|
||||
@@ -84,20 +69,16 @@ describe('Version Consistency', () => {
|
||||
it('should have built mcp-server.cjs', () => {
|
||||
const mcpServerPath = path.join(projectRoot, 'plugin/scripts/mcp-server.cjs');
|
||||
|
||||
// Skip if file doesn't exist (e.g., before first build)
|
||||
if (!existsSync(mcpServerPath)) {
|
||||
console.log('⚠️ mcp-server.cjs not found - run npm run build first');
|
||||
return;
|
||||
}
|
||||
|
||||
// mcp-server.cjs doesn't use __DEFAULT_PACKAGE_VERSION__ - it's a search server
|
||||
// that doesn't need to expose version info. Just verify it exists and is built.
|
||||
const mcpServerContent = readFileSync(mcpServerPath, 'utf-8');
|
||||
expect(mcpServerContent.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should validate version format is semver compliant', () => {
|
||||
// Ensure version follows semantic versioning: MAJOR.MINOR.PATCH
|
||||
expect(rootVersion).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
|
||||
const [major, minor, patch] = rootVersion.split('.').map(Number);
|
||||
@@ -107,9 +88,6 @@ describe('Version Consistency', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Additional test to ensure build script properly reads and injects version
|
||||
*/
|
||||
describe('Build Script Version Handling', () => {
|
||||
it('should read version from package.json in build-hooks.js', () => {
|
||||
const buildScriptPath = path.join(projectRoot, 'scripts/build-hooks.js');
|
||||
@@ -117,14 +95,11 @@ describe('Build Script Version Handling', () => {
|
||||
|
||||
const buildScriptContent = readFileSync(buildScriptPath, 'utf-8');
|
||||
|
||||
// Verify build script reads from package.json
|
||||
expect(buildScriptContent).toContain("readFileSync('package.json'");
|
||||
expect(buildScriptContent).toContain('packageJson.version');
|
||||
|
||||
// Verify it generates plugin/package.json with the version
|
||||
expect(buildScriptContent).toContain('version: version');
|
||||
|
||||
// Verify it injects version into esbuild define
|
||||
expect(buildScriptContent).toContain('__DEFAULT_PACKAGE_VERSION__');
|
||||
expect(buildScriptContent).toContain('`"${version}"`');
|
||||
});
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
/**
|
||||
* Tests for PowerShell output parsing logic used in Windows process enumeration.
|
||||
*
|
||||
* This tests the parsing behavior directly since mocking promisified exec
|
||||
* is unreliable across module boundaries. The parsing logic matches exactly
|
||||
* what's in ProcessManager.getChildProcesses().
|
||||
*/
|
||||
|
||||
// Extract the parsing logic from ProcessManager for direct testing
|
||||
// This matches the implementation in src/services/infrastructure/ProcessManager.ts lines 95-100
|
||||
function parsePowerShellOutput(stdout: string): number[] {
|
||||
return stdout
|
||||
.split('\n')
|
||||
@@ -19,7 +9,6 @@ function parsePowerShellOutput(stdout: string): number[] {
|
||||
.filter(pid => pid > 0);
|
||||
}
|
||||
|
||||
// Validate parent PID - matches ProcessManager.getChildProcesses() lines 85-88
|
||||
function isValidParentPid(parentPid: number): boolean {
|
||||
return Number.isInteger(parentPid) && parentPid > 0;
|
||||
}
|
||||
@@ -107,7 +96,6 @@ describe('PowerShell output parsing (Windows)', () => {
|
||||
});
|
||||
|
||||
it('should handle very large PIDs', () => {
|
||||
// Windows PIDs can be large but are still 32-bit integers
|
||||
const stdout = '2147483647\r\n';
|
||||
|
||||
const result = parsePowerShellOutput(stdout);
|
||||
@@ -120,7 +108,6 @@ describe('PowerShell output parsing (Windows)', () => {
|
||||
|
||||
1234
|
||||
|
||||
|
||||
5678
|
||||
|
||||
`;
|
||||
@@ -190,7 +177,6 @@ describe('getChildProcesses platform behavior', () => {
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// Import fresh to get updated platform value
|
||||
const { getChildProcesses } = await import('../../src/services/infrastructure/ProcessManager.js');
|
||||
|
||||
const result = await getChildProcesses(1000);
|
||||
@@ -213,7 +199,6 @@ describe('getChildProcesses platform behavior', () => {
|
||||
});
|
||||
|
||||
it('should return empty array for invalid parent PID regardless of platform', async () => {
|
||||
// Even on Windows, invalid parent PIDs should be rejected before exec
|
||||
const { getChildProcesses } = await import('../../src/services/infrastructure/ProcessManager.js');
|
||||
|
||||
expect(await getChildProcesses(0)).toEqual([]);
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
/**
|
||||
* Tests for worker JSON status output structure
|
||||
*
|
||||
* Tests the buildStatusOutput pure function extracted from worker-service.ts
|
||||
* to ensure JSON output matches the hook framework contract.
|
||||
*
|
||||
* Also tests CLI output capture for the 'start' command to verify
|
||||
* actual JSON output matches expected structure.
|
||||
*
|
||||
* No mocks needed - tests a pure function directly and captures real CLI output.
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
@@ -17,10 +6,6 @@ import { buildStatusOutput, StatusOutput } from '../../src/services/worker-servi
|
||||
|
||||
const WORKER_SCRIPT = path.join(__dirname, '../../plugin/scripts/worker-service.cjs');
|
||||
|
||||
/**
|
||||
* Run worker CLI command and return stdout + exit code
|
||||
* Uses spawnSync for synchronous output capture
|
||||
*/
|
||||
function runWorkerStart(): { stdout: string; exitCode: number } {
|
||||
const result = spawnSync('bun', [WORKER_SCRIPT, 'start'], {
|
||||
encoding: 'utf-8',
|
||||
@@ -122,7 +107,6 @@ describe('worker-json-status', () => {
|
||||
const readyOutput = JSON.stringify(buildStatusOutput('ready'));
|
||||
const errorOutput = JSON.stringify(buildStatusOutput('error', 'error msg'));
|
||||
|
||||
// Verify exact structure (order may vary, but content must match)
|
||||
const parsedReady = JSON.parse(readyOutput);
|
||||
expect(parsedReady).toEqual({
|
||||
continue: true,
|
||||
@@ -142,8 +126,6 @@ describe('worker-json-status', () => {
|
||||
|
||||
describe('type safety', () => {
|
||||
it('should only accept valid status values', () => {
|
||||
// TypeScript ensures these are the only valid values at compile time
|
||||
// This runtime test validates the behavior
|
||||
const readyResult: StatusOutput = buildStatusOutput('ready');
|
||||
const errorResult: StatusOutput = buildStatusOutput('error');
|
||||
|
||||
@@ -154,7 +136,6 @@ describe('worker-json-status', () => {
|
||||
it('should have correct type structure', () => {
|
||||
const result = buildStatusOutput('ready');
|
||||
|
||||
// Verify literal types
|
||||
expect(result.continue).toBe(true as const);
|
||||
expect(result.suppressOutput).toBe(true as const);
|
||||
});
|
||||
@@ -162,7 +143,6 @@ describe('worker-json-status', () => {
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty string message', () => {
|
||||
// Empty string is falsy, so message should NOT be included
|
||||
const result = buildStatusOutput('error', '');
|
||||
expect('message' in result).toBe(false);
|
||||
});
|
||||
@@ -172,7 +152,6 @@ describe('worker-json-status', () => {
|
||||
const result = buildStatusOutput('error', specialMessage);
|
||||
expect(result.message).toBe(specialMessage);
|
||||
|
||||
// Verify it serializes correctly
|
||||
const parsed = JSON.parse(JSON.stringify(result));
|
||||
expect(parsed.message).toBe(specialMessage);
|
||||
});
|
||||
@@ -188,7 +167,6 @@ describe('worker-json-status', () => {
|
||||
describe('start command JSON output', () => {
|
||||
describe('when worker already healthy', () => {
|
||||
it('should output valid JSON with status: ready', () => {
|
||||
// Skip if worker script doesn't exist (not built)
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
return;
|
||||
@@ -196,15 +174,12 @@ describe('worker-json-status', () => {
|
||||
|
||||
const { stdout, exitCode } = runWorkerStart();
|
||||
|
||||
// The start command always exits with 0 (Windows Terminal compatibility)
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
// Should output valid JSON
|
||||
expect(() => JSON.parse(stdout)).not.toThrow();
|
||||
|
||||
const parsed = JSON.parse(stdout);
|
||||
|
||||
// Verify required fields per hook framework contract
|
||||
expect(parsed.continue).toBe(true);
|
||||
expect(parsed.suppressOutput).toBe(true);
|
||||
expect(['ready', 'error']).toContain(parsed.status);
|
||||
@@ -219,22 +194,16 @@ describe('worker-json-status', () => {
|
||||
const { stdout } = runWorkerStart();
|
||||
const parsed = JSON.parse(stdout);
|
||||
|
||||
// When worker is already healthy, status should be 'ready'
|
||||
// (or 'error' if something unexpected happens)
|
||||
if (parsed.status === 'ready') {
|
||||
// Ready status should not include message unless explicitly set
|
||||
expect(parsed.continue).toBe(true);
|
||||
expect(parsed.suppressOutput).toBe(true);
|
||||
} else if (parsed.status === 'error') {
|
||||
// Error status may include a message explaining the failure
|
||||
expect(typeof parsed.message).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('error scenarios', () => {
|
||||
// These tests require complex setup (mocking ports, killing processes)
|
||||
// Skipped for now - the pure function tests above cover the JSON structure
|
||||
it.skip('should output JSON with status: error when port in use but not responding', () => {
|
||||
// Would require: start a non-worker server on the port, then call start
|
||||
});
|
||||
@@ -249,39 +218,7 @@ describe('worker-json-status', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Claude Code hook framework compatibility tests
|
||||
*
|
||||
* These tests verify that the worker 'start' command output conforms to
|
||||
* Claude Code's hook output contract. Key requirements:
|
||||
*
|
||||
* 1. Exit code 0 - Required for Windows Terminal compatibility (prevents
|
||||
* tab accumulation from spawned processes)
|
||||
*
|
||||
* 2. JSON on stdout - Claude Code parses stdout as JSON. Logs must go to
|
||||
* stderr to avoid breaking JSON parsing.
|
||||
*
|
||||
* 3. `continue: true` - CRITICAL: This field tells Claude Code to continue
|
||||
* processing. If missing or false, Claude Code stops after the hook.
|
||||
* Per docs: "If continue is false, Claude stops processing after the
|
||||
* hooks run."
|
||||
*
|
||||
* 4. `suppressOutput: true` - Hides output from transcript mode (Ctrl-R).
|
||||
* Optional but recommended for non-user-facing status.
|
||||
*
|
||||
* Reference: private/context/claude-code/hooks.md
|
||||
*/
|
||||
describe('Claude Code hook framework compatibility', () => {
|
||||
/**
|
||||
* Windows Terminal compatibility requirement
|
||||
*
|
||||
* When hooks run in Windows Terminal, each spawned process can open a
|
||||
* new tab. Exit code 0 tells the terminal the process completed
|
||||
* successfully and prevents tab accumulation.
|
||||
*
|
||||
* Even for error states (worker failed to start), we exit 0 and
|
||||
* communicate the error via JSON { status: 'error', message: '...' }
|
||||
*/
|
||||
it('should always exit with code 0', () => {
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
@@ -290,20 +227,9 @@ describe('worker-json-status', () => {
|
||||
|
||||
const { exitCode } = runWorkerStart();
|
||||
|
||||
// Per Windows Terminal compatibility requirement, exit code is always 0
|
||||
// Error states are communicated via JSON status field, not exit codes
|
||||
expect(exitCode).toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* JSON must go to stdout, not stderr
|
||||
*
|
||||
* Claude Code parses stdout as JSON for hook output. Any non-JSON on
|
||||
* stdout breaks parsing. Logs, warnings, and debug info must go to
|
||||
* stderr.
|
||||
*
|
||||
* Structure: { status, continue, suppressOutput, message? }
|
||||
*/
|
||||
it('should output JSON on stdout (not stderr)', () => {
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
@@ -318,20 +244,15 @@ describe('worker-json-status', () => {
|
||||
const stdout = result.stdout?.trim() || '';
|
||||
const stderr = result.stderr?.trim() || '';
|
||||
|
||||
// stdout should contain valid JSON
|
||||
expect(() => JSON.parse(stdout)).not.toThrow();
|
||||
|
||||
// stderr should NOT contain the JSON output (it may have logs)
|
||||
// The JSON structure should only appear in stdout
|
||||
const parsed = JSON.parse(stdout);
|
||||
expect(parsed).toHaveProperty('status');
|
||||
expect(parsed).toHaveProperty('continue');
|
||||
|
||||
// Verify stderr doesn't accidentally contain the JSON output
|
||||
if (stderr) {
|
||||
try {
|
||||
const stderrParsed = JSON.parse(stderr);
|
||||
// If stderr parses as JSON with our structure, that's wrong
|
||||
expect(stderrParsed).not.toHaveProperty('suppressOutput');
|
||||
} catch {
|
||||
// stderr is not JSON, which is expected (logs, etc.)
|
||||
@@ -339,13 +260,6 @@ describe('worker-json-status', () => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* JSON must be parseable as valid JSON
|
||||
*
|
||||
* This seems obvious but is critical - any extraneous output (console.log
|
||||
* statements, warnings, etc.) will break JSON parsing and cause Claude
|
||||
* Code to fail processing the hook output.
|
||||
*/
|
||||
it('should be parseable as valid JSON', () => {
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
@@ -354,32 +268,16 @@ describe('worker-json-status', () => {
|
||||
|
||||
const { stdout } = runWorkerStart();
|
||||
|
||||
// Should not throw on parse
|
||||
let parsed: unknown;
|
||||
expect(() => {
|
||||
parsed = JSON.parse(stdout);
|
||||
}).not.toThrow();
|
||||
|
||||
// Should be an object, not a string, array, etc.
|
||||
expect(typeof parsed).toBe('object');
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(Array.isArray(parsed)).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* `continue: true` is CRITICAL
|
||||
*
|
||||
* From Claude Code docs: "If continue is false, Claude stops processing
|
||||
* after the hooks run."
|
||||
*
|
||||
* For SessionStart hooks (which start the worker), we MUST return
|
||||
* continue: true so Claude Code continues to process the user's prompt.
|
||||
* If we returned continue: false, Claude would stop immediately after
|
||||
* starting the worker and never respond to the user.
|
||||
*
|
||||
* This is why continue: true is a required literal in our StatusOutput
|
||||
* type - it can never be false.
|
||||
*/
|
||||
it('should always include continue: true (required for Claude Code to proceed)', () => {
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
@@ -389,24 +287,11 @@ describe('worker-json-status', () => {
|
||||
const { stdout } = runWorkerStart();
|
||||
const parsed = JSON.parse(stdout);
|
||||
|
||||
// continue: true is CRITICAL - without it, Claude Code stops processing
|
||||
// This is not optional; it must always be true for our hooks
|
||||
expect(parsed.continue).toBe(true);
|
||||
|
||||
// Also verify it's the literal `true`, not a truthy value
|
||||
expect(parsed.continue).toStrictEqual(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* suppressOutput hides from transcript mode
|
||||
*
|
||||
* When suppressOutput: true, the hook output doesn't appear in transcript
|
||||
* mode (Ctrl-R). This is useful for status messages that aren't relevant
|
||||
* to the user's conversation history.
|
||||
*
|
||||
* For the worker start command, we suppress output since "worker started"
|
||||
* is infrastructure noise, not conversation content.
|
||||
*/
|
||||
it('should include suppressOutput: true to hide from transcript mode', () => {
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
@@ -416,20 +301,9 @@ describe('worker-json-status', () => {
|
||||
const { stdout } = runWorkerStart();
|
||||
const parsed = JSON.parse(stdout);
|
||||
|
||||
// suppressOutput prevents infrastructure noise from polluting transcript
|
||||
expect(parsed.suppressOutput).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* status field communicates outcome
|
||||
*
|
||||
* The status field tells Claude Code (and debugging tools) whether the
|
||||
* hook succeeded. Valid values: 'ready' | 'error'
|
||||
*
|
||||
* Unlike exit codes (which are always 0), status can indicate failure.
|
||||
* This allows Claude Code to potentially take remedial action or log
|
||||
* the issue.
|
||||
*/
|
||||
it('should include a valid status field', () => {
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping CLI test - worker script not built');
|
||||
|
||||
@@ -2,19 +2,6 @@ import { describe, it, expect } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* Tests for the non-TTY detection in the install command.
|
||||
*
|
||||
* The install command (src/npx-cli/commands/install.ts) has non-interactive
|
||||
* fallbacks so it works in CI/CD, Docker, and piped environments where
|
||||
* process.stdin.isTTY is undefined.
|
||||
*
|
||||
* Since isInteractive, runTasks, and log are not exported, we verify
|
||||
* their presence and correctness via source inspection. This is a valid
|
||||
* approach for testing private module-level constructs that can't be
|
||||
* imported directly.
|
||||
*/
|
||||
|
||||
const installSourcePath = join(
|
||||
__dirname,
|
||||
'..',
|
||||
@@ -32,7 +19,6 @@ describe('Install Non-TTY Support', () => {
|
||||
});
|
||||
|
||||
it('uses strict equality (===) not truthy check for isTTY', () => {
|
||||
// Ensures undefined isTTY is treated as false, not just falsy
|
||||
const match = installSource.match(/const isInteractive = process\.stdin\.isTTY === true/);
|
||||
expect(match).not.toBeNull();
|
||||
});
|
||||
@@ -48,7 +34,6 @@ describe('Install Non-TTY Support', () => {
|
||||
});
|
||||
|
||||
it('has non-interactive fallback using console.log', () => {
|
||||
// In non-TTY mode, tasks iterate and log output directly
|
||||
expect(installSource).toContain('console.log(` ${msg}`)');
|
||||
});
|
||||
|
||||
@@ -60,7 +45,6 @@ describe('Install Non-TTY Support', () => {
|
||||
describe('log wrapper', () => {
|
||||
it('defines log.info that falls back to console.log', () => {
|
||||
expect(installSource).toContain('info: (msg: string) =>');
|
||||
// Should have console.log fallback
|
||||
expect(installSource).toMatch(/info:.*console\.log/);
|
||||
});
|
||||
|
||||
@@ -82,7 +66,6 @@ describe('Install Non-TTY Support', () => {
|
||||
|
||||
describe('non-interactive install path', () => {
|
||||
it('defaults to claude-code when not interactive and no IDE specified', () => {
|
||||
// The non-interactive path should have a fallback
|
||||
expect(installSource).toContain("selectedIDEs = ['claude-code']");
|
||||
});
|
||||
|
||||
@@ -109,4 +92,43 @@ describe('Install Non-TTY Support', () => {
|
||||
expect(installSource).toContain('ide?: string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('post-install Next Steps copy', () => {
|
||||
it('frames the choice as two paths', () => {
|
||||
expect(installSource).toContain('Two paths from here:');
|
||||
});
|
||||
|
||||
it('sets timing honesty about second-session memory injection', () => {
|
||||
expect(installSource).toContain('Memory injection starts on your second session in a project.');
|
||||
});
|
||||
|
||||
it('addresses privacy: everything stays local', () => {
|
||||
expect(installSource).toContain('Everything stays in ');
|
||||
expect(installSource).toContain("pc.cyan('~/.claude-mem')");
|
||||
});
|
||||
|
||||
it('keeps /learn-codebase as the optional front-load path', () => {
|
||||
expect(installSource).toContain('/learn-codebase');
|
||||
});
|
||||
|
||||
it('demotes the uninstall caveat into a dim footer', () => {
|
||||
expect(installSource).toContain('close all Claude Code sessions before uninstalling');
|
||||
});
|
||||
|
||||
it('does not advertise /mem-search in the post-install Next Steps', () => {
|
||||
const nextStepsRegion = installSource.slice(
|
||||
installSource.indexOf('const nextSteps = '),
|
||||
installSource.indexOf("p.note(nextSteps.join"),
|
||||
);
|
||||
expect(nextStepsRegion).not.toContain('/mem-search');
|
||||
});
|
||||
|
||||
it('does not advertise /knowledge-agent in the post-install Next Steps', () => {
|
||||
const nextStepsRegion = installSource.slice(
|
||||
installSource.indexOf('const nextSteps = '),
|
||||
installSource.indexOf("p.note(nextSteps.join"),
|
||||
);
|
||||
expect(nextStepsRegion).not.toContain('/knowledge-agent');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
/**
|
||||
* Chroma Vector Sync Integration Tests
|
||||
*
|
||||
* Tests ChromaSync vector embedding and semantic search.
|
||||
* Skips tests if uvx/chroma not installed (CI-safe).
|
||||
*
|
||||
* Sources:
|
||||
* - ChromaSync implementation from src/services/sync/ChromaSync.ts
|
||||
* - MCP patterns from the Chroma MCP server
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll, spyOn } from 'bun:test';
|
||||
import { logger } from '../../src/utils/logger.js';
|
||||
@@ -15,13 +5,11 @@ import path from 'path';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
// Check if uvx/chroma is available
|
||||
let chromaAvailable = false;
|
||||
let skipReason = '';
|
||||
|
||||
async function checkChromaAvailability(): Promise<{ available: boolean; reason: string }> {
|
||||
try {
|
||||
// Check if uvx is available
|
||||
const uvxCheck = Bun.spawn(['uvx', '--version'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
@@ -38,7 +26,6 @@ async function checkChromaAvailability(): Promise<{ available: boolean; reason:
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress logger output during tests
|
||||
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
describe('ChromaSync Vector Sync Integration', () => {
|
||||
@@ -50,14 +37,12 @@ describe('ChromaSync Vector Sync Integration', () => {
|
||||
chromaAvailable = check.available;
|
||||
skipReason = check.reason;
|
||||
|
||||
// Create temp directory for vector db
|
||||
if (chromaAvailable) {
|
||||
fs.mkdirSync(testVectorDbDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup temp directory
|
||||
try {
|
||||
if (fs.existsSync(testVectorDbDir)) {
|
||||
fs.rmSync(testVectorDbDir, { recursive: true, force: true });
|
||||
@@ -83,7 +68,6 @@ describe('ChromaSync Vector Sync Integration', () => {
|
||||
describe('ChromaSync availability check', () => {
|
||||
it('should detect uvx availability status', async () => {
|
||||
const check = await checkChromaAvailability();
|
||||
// This test always passes - it just logs the status
|
||||
expect(typeof check.available).toBe('boolean');
|
||||
if (!check.available) {
|
||||
console.log(`Chroma tests will be skipped: ${check.reason}`);
|
||||
@@ -115,9 +99,6 @@ describe('ChromaSync Vector Sync Integration', () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
|
||||
// Test the document formatting logic by examining the class
|
||||
// The formatObservationDocs method is private, but we can verify
|
||||
// the sync method signature exists
|
||||
expect(typeof sync.syncObservation).toBe('function');
|
||||
expect(typeof sync.syncSummary).toBe('function');
|
||||
expect(typeof sync.syncUserPrompt).toBe('function');
|
||||
@@ -147,7 +128,6 @@ describe('ChromaSync Vector Sync Integration', () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
|
||||
// The syncObservation method should accept these parameters
|
||||
const observationId = 1;
|
||||
const memorySessionId = 'session-123';
|
||||
const project = 'test-project';
|
||||
@@ -164,8 +144,6 @@ describe('ChromaSync Vector Sync Integration', () => {
|
||||
const promptNumber = 1;
|
||||
const createdAtEpoch = Date.now();
|
||||
|
||||
// Verify method signature accepts these parameters
|
||||
// We don't actually call it to avoid needing a running Chroma server
|
||||
expect(sync.syncObservation.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
@@ -175,7 +153,6 @@ describe('ChromaSync Vector Sync Integration', () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
|
||||
// The syncSummary method should accept these parameters
|
||||
const summaryId = 1;
|
||||
const memorySessionId = 'session-123';
|
||||
const project = 'test-project';
|
||||
@@ -190,7 +167,6 @@ describe('ChromaSync Vector Sync Integration', () => {
|
||||
const promptNumber = 1;
|
||||
const createdAtEpoch = Date.now();
|
||||
|
||||
// Verify method exists
|
||||
expect(typeof sync.syncSummary).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -200,7 +176,6 @@ describe('ChromaSync Vector Sync Integration', () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
|
||||
// The syncUserPrompt method should accept these parameters
|
||||
const promptId = 1;
|
||||
const memorySessionId = 'session-123';
|
||||
const project = 'test-project';
|
||||
@@ -208,7 +183,6 @@ describe('ChromaSync Vector Sync Integration', () => {
|
||||
const promptNumber = 1;
|
||||
const createdAtEpoch = Date.now();
|
||||
|
||||
// Verify method exists
|
||||
expect(typeof sync.syncUserPrompt).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -218,7 +192,6 @@ describe('ChromaSync Vector Sync Integration', () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
|
||||
// Verify method signature
|
||||
expect(typeof sync.queryChroma).toBe('function');
|
||||
|
||||
// The method should return a promise
|
||||
@@ -230,19 +203,15 @@ describe('ChromaSync Vector Sync Integration', () => {
|
||||
it('should use project-based collection name', async () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
|
||||
// Collection name format is cm__{project}
|
||||
const projectName = 'my-project';
|
||||
const sync = new ChromaSync(projectName);
|
||||
|
||||
// The collection name is private, but we can verify the class
|
||||
// was constructed successfully with the project name
|
||||
expect(sync).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle special characters in project names', async () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
|
||||
// Projects with special characters should work
|
||||
const projectName = 'my-project_v2.0';
|
||||
const sync = new ChromaSync(projectName);
|
||||
expect(sync).toBeDefined();
|
||||
@@ -259,8 +228,6 @@ describe('ChromaSync Vector Sync Integration', () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
|
||||
// Calling syncObservation without a running server should throw
|
||||
// but not crash the process
|
||||
const observation = {
|
||||
type: 'discovery' as const,
|
||||
title: 'Test',
|
||||
@@ -272,7 +239,6 @@ describe('ChromaSync Vector Sync Integration', () => {
|
||||
files_modified: []
|
||||
};
|
||||
|
||||
// This should either throw or fail gracefully
|
||||
try {
|
||||
await sync.syncObservation(
|
||||
1,
|
||||
@@ -284,11 +250,9 @@ describe('ChromaSync Vector Sync Integration', () => {
|
||||
);
|
||||
// If it didn't throw, the connection might have succeeded
|
||||
} catch (error) {
|
||||
// Expected - server not running
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
|
||||
// Clean up
|
||||
await sync.close();
|
||||
});
|
||||
});
|
||||
@@ -298,7 +262,6 @@ describe('ChromaSync Vector Sync Integration', () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
|
||||
// Close without ever connecting should not throw
|
||||
await expect(sync.close()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -306,59 +269,36 @@ describe('ChromaSync Vector Sync Integration', () => {
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
|
||||
// Multiple close calls should be safe
|
||||
await expect(sync.close()).resolves.toBeUndefined();
|
||||
await expect(sync.close()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Process leak prevention (Issue #761)', () => {
|
||||
/**
|
||||
* Regression test for GitHub Issue #761:
|
||||
* "Feature Request: Option to disable Chroma (RAM usage / zombie processes)"
|
||||
*
|
||||
* Root cause: When connection errors occur (MCP error -32000, Connection closed),
|
||||
* the code was resetting `connected` and `client` but NOT closing the transport,
|
||||
* leaving the chroma-mcp subprocess alive. Each reconnection attempt spawned
|
||||
* a NEW process while old ones accumulated as zombies.
|
||||
*
|
||||
* Fix: Transport lifecycle is now managed by ChromaMcpManager (singleton),
|
||||
* which handles connect/disconnect/cleanup. ChromaSync delegates to it.
|
||||
*/
|
||||
it('should have transport cleanup in ChromaMcpManager error handlers', async () => {
|
||||
// ChromaSync now delegates connection management to ChromaMcpManager.
|
||||
// Verify that ChromaMcpManager source includes transport cleanup.
|
||||
const sourceFile = await Bun.file(
|
||||
new URL('../../src/services/sync/ChromaMcpManager.ts', import.meta.url)
|
||||
).text();
|
||||
|
||||
// Verify that error handlers include transport cleanup
|
||||
expect(sourceFile).toContain('this.transport.close()');
|
||||
|
||||
// Verify transport is set to null after close
|
||||
expect(sourceFile).toContain('this.transport = null');
|
||||
|
||||
// Verify connected is set to false after close
|
||||
expect(sourceFile).toContain('this.connected = false');
|
||||
});
|
||||
|
||||
it('should reset state after close regardless of connection status', async () => {
|
||||
// ChromaSync.close() is now a lightweight method that logs and returns.
|
||||
// Connection state is managed by ChromaMcpManager singleton.
|
||||
const { ChromaSync } = await import('../../src/services/sync/ChromaSync.js');
|
||||
const sync = new ChromaSync(testProject);
|
||||
|
||||
// close() should complete without error regardless of state
|
||||
await expect(sync.close()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clean up transport in ChromaMcpManager close() method', async () => {
|
||||
// Read the ChromaMcpManager source to verify transport.close() is in the close path
|
||||
const sourceFile = await Bun.file(
|
||||
new URL('../../src/services/sync/ChromaMcpManager.ts', import.meta.url)
|
||||
).text();
|
||||
|
||||
// Verify the close/disconnect method properly cleans up transport
|
||||
expect(sourceFile).toContain('await this.transport.close()');
|
||||
expect(sourceFile).toContain('this.transport = null');
|
||||
expect(sourceFile).toContain('this.connected = false');
|
||||
|
||||
@@ -1,30 +1,16 @@
|
||||
/**
|
||||
* Hook Execution End-to-End Integration Tests
|
||||
*
|
||||
* Tests the full session lifecycle: SessionStart -> PostToolUse -> SessionEnd
|
||||
* Uses real worker on test port with in-memory SQLite database.
|
||||
*
|
||||
* Sources:
|
||||
* - Hook implementations from src/hooks/*.ts
|
||||
* - Session routes from src/services/worker/http/routes/SessionRoutes.ts
|
||||
* - Server patterns from tests/server/server.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
|
||||
import { logger } from '../../src/utils/logger.js';
|
||||
|
||||
// Mock middleware to avoid complex dependencies
|
||||
mock.module('../../src/services/worker/http/middleware.js', () => ({
|
||||
createMiddleware: () => [],
|
||||
requireLocalhost: (_req: any, _res: any, next: any) => next(),
|
||||
summarizeRequestBody: () => 'test body',
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
import { Server } from '../../src/services/server/Server.js';
|
||||
import type { ServerOptions } from '../../src/services/server/Server.js';
|
||||
|
||||
// Suppress logger output during tests
|
||||
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
describe('Hook Execution E2E', () => {
|
||||
@@ -139,11 +125,9 @@ describe('Hook Execution E2E', () => {
|
||||
expect(httpServer).not.toBeNull();
|
||||
expect(httpServer!.listening).toBe(true);
|
||||
|
||||
// Verify health endpoint works
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Close server
|
||||
try {
|
||||
await server.close();
|
||||
} catch (e: any) {
|
||||
@@ -172,15 +156,12 @@ describe('Hook Execution E2E', () => {
|
||||
server = new Server(dynamicOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Check when not initialized
|
||||
let response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
let body = await response.json();
|
||||
expect(body.initialized).toBe(false);
|
||||
|
||||
// Change state
|
||||
isInitialized = true;
|
||||
|
||||
// Check when initialized
|
||||
response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
body = await response.json();
|
||||
expect(body.initialized).toBe(true);
|
||||
@@ -205,33 +186,26 @@ describe('Hook Execution E2E', () => {
|
||||
server.finalizeRoutes();
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Even though this endpoint doesn't exist, verify JSON handling
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/test-json`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ test: 'data' })
|
||||
});
|
||||
|
||||
// Should get 404 (not found), not 400 (bad request due to JSON parsing)
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('privacy tag handling simulation', () => {
|
||||
it('should demonstrate privacy skip flow for entirely private prompt', async () => {
|
||||
// This test simulates what the session init endpoint does
|
||||
// with private prompts, without needing the full route handler
|
||||
server = new Server(mockOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Import tag stripping utility
|
||||
const { stripMemoryTagsFromPrompt } = await import('../../src/utils/tag-stripping.js');
|
||||
|
||||
// Simulate the flow
|
||||
const privatePrompt = '<private>secret command</private>';
|
||||
const cleanedPrompt = stripMemoryTagsFromPrompt(privatePrompt);
|
||||
|
||||
// Verify privacy check would skip this prompt
|
||||
const shouldSkip = !cleanedPrompt || cleanedPrompt.trim() === '';
|
||||
expect(shouldSkip).toBe(true);
|
||||
});
|
||||
@@ -245,7 +219,6 @@ describe('Hook Execution E2E', () => {
|
||||
const mixedPrompt = '<private>my password is secret123</private> Help me write a function';
|
||||
const cleanedPrompt = stripMemoryTagsFromPrompt(mixedPrompt);
|
||||
|
||||
// Should not skip - has public content
|
||||
const shouldSkip = !cleanedPrompt || cleanedPrompt.trim() === '';
|
||||
expect(shouldSkip).toBe(false);
|
||||
expect(cleanedPrompt.trim()).toBe('Help me write a function');
|
||||
|
||||
@@ -1,30 +1,16 @@
|
||||
/**
|
||||
* Worker API Endpoints Integration Tests
|
||||
*
|
||||
* Tests all REST API endpoints with real HTTP and database.
|
||||
* Uses real Server instance with in-memory database.
|
||||
*
|
||||
* Sources:
|
||||
* - Server patterns from tests/server/server.test.ts
|
||||
* - Session routes from src/services/worker/http/routes/SessionRoutes.ts
|
||||
* - Search routes from src/services/worker/http/routes/SearchRoutes.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
|
||||
import { logger } from '../../src/utils/logger.js';
|
||||
|
||||
// Mock middleware to avoid complex dependencies
|
||||
mock.module('../../src/services/worker/http/middleware.js', () => ({
|
||||
createMiddleware: () => [],
|
||||
requireLocalhost: (_req: any, _res: any, next: any) => next(),
|
||||
summarizeRequestBody: () => 'test body',
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
import { Server } from '../../src/services/server/Server.js';
|
||||
import type { ServerOptions } from '../../src/services/server/Server.js';
|
||||
|
||||
// Suppress logger output during tests
|
||||
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
describe('Worker API Endpoints Integration', () => {
|
||||
@@ -104,7 +90,7 @@ describe('Worker API Endpoints Integration', () => {
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
const body = await response.json();
|
||||
|
||||
expect(body.status).toBe('ok'); // Health always returns ok
|
||||
expect(body.status).toBe('ok');
|
||||
expect(body.initialized).toBe(false);
|
||||
expect(body.mcpReady).toBe(false);
|
||||
});
|
||||
@@ -205,7 +191,6 @@ describe('Worker API Endpoints Integration', () => {
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/health`, {
|
||||
method: 'OPTIONS'
|
||||
});
|
||||
// OPTIONS should either return 200 or 204 (CORS preflight)
|
||||
expect([200, 204]).toContain(response.status);
|
||||
});
|
||||
});
|
||||
@@ -223,7 +208,6 @@ describe('Worker API Endpoints Integration', () => {
|
||||
body: JSON.stringify({ key: 'value' })
|
||||
});
|
||||
|
||||
// Should get 404 (route not found), not a content-type error
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
@@ -253,14 +237,11 @@ describe('Worker API Endpoints Integration', () => {
|
||||
server = new Server(dynamicOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Check uninitialized
|
||||
let response = await fetch(`http://127.0.0.1:${testPort}/api/readiness`);
|
||||
expect(response.status).toBe(503);
|
||||
|
||||
// Initialize
|
||||
initialized = true;
|
||||
|
||||
// Check initialized
|
||||
response = await fetch(`http://127.0.0.1:${testPort}/api/readiness`);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
@@ -279,15 +260,12 @@ describe('Worker API Endpoints Integration', () => {
|
||||
server = new Server(dynamicOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Check MCP not ready
|
||||
let response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
let body = await response.json();
|
||||
expect(body.mcpReady).toBe(false);
|
||||
|
||||
// Set MCP ready
|
||||
mcpReady = true;
|
||||
|
||||
// Check MCP ready
|
||||
response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
body = await response.json();
|
||||
expect(body.mcpReady).toBe(true);
|
||||
@@ -308,18 +286,15 @@ describe('Worker API Endpoints Integration', () => {
|
||||
server = new Server(mockOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Verify it's running
|
||||
const response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Close
|
||||
try {
|
||||
await server.close();
|
||||
} catch (e: any) {
|
||||
if (e.code !== 'ERR_SERVER_NOT_RUNNING') throw e;
|
||||
}
|
||||
|
||||
// Verify closed
|
||||
const httpServer = server.getHttpServer();
|
||||
if (httpServer) {
|
||||
expect(httpServer.listening).toBe(false);
|
||||
@@ -332,10 +307,8 @@ describe('Worker API Endpoints Integration', () => {
|
||||
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Second server should fail on same port
|
||||
await expect(server2.listen(testPort, '127.0.0.1')).rejects.toThrow();
|
||||
|
||||
// Clean up second server if it has a reference
|
||||
const httpServer2 = server2.getHttpServer();
|
||||
if (httpServer2) {
|
||||
expect(httpServer2.listening).toBe(false);
|
||||
@@ -346,23 +319,19 @@ describe('Worker API Endpoints Integration', () => {
|
||||
server = new Server(mockOptions);
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Close first server
|
||||
try {
|
||||
await server.close();
|
||||
} catch (e: any) {
|
||||
if (e.code !== 'ERR_SERVER_NOT_RUNNING') throw e;
|
||||
}
|
||||
|
||||
// Wait for port to be released
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Start second server on same port
|
||||
const server2 = new Server(mockOptions);
|
||||
await server2.listen(testPort, '127.0.0.1');
|
||||
|
||||
expect(server2.getHttpServer()!.listening).toBe(true);
|
||||
|
||||
// Clean up
|
||||
try {
|
||||
await server2.close();
|
||||
} catch {
|
||||
|
||||
@@ -4,13 +4,6 @@ import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { readJsonSafe } from '../src/utils/json-utils';
|
||||
|
||||
/**
|
||||
* Tests for the shared JSON file utilities.
|
||||
*
|
||||
* readJsonSafe is used across the CLI and services to safely read JSON
|
||||
* files with fallback to defaults when files are missing or corrupt.
|
||||
*/
|
||||
|
||||
describe('JSON Utils', () => {
|
||||
let tempDir: string;
|
||||
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
/**
|
||||
* Log Level Audit Test
|
||||
*
|
||||
* This test scans all TypeScript files in src/ to find logger calls,
|
||||
* extracts the message text, and groups them by log level for review.
|
||||
*
|
||||
* Purpose: Help identify misclassified log messages that should be at a different level.
|
||||
*
|
||||
* Log Level Guidelines:
|
||||
* - ERROR/failure: Critical failures that require investigation (data loss, service down)
|
||||
* - WARN: Non-critical issues with fallback behavior (degraded, but functional)
|
||||
* - INFO: Normal operational events (session started, request processed)
|
||||
* - DEBUG: Detailed diagnostic information (variable values, flow tracing)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { readdir, readFile } from 'fs/promises';
|
||||
@@ -30,9 +16,6 @@ interface LoggerCall {
|
||||
fullMatch: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find all TypeScript files in a directory
|
||||
*/
|
||||
async function findTypeScriptFiles(dir: string): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
@@ -50,16 +33,11 @@ async function findTypeScriptFiles(dir: string): Promise<string[]> {
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract logger calls from file content
|
||||
* Handles multiline calls and captures error parameter (4th arg)
|
||||
*/
|
||||
function extractLoggerCalls(content: string, filePath: string): LoggerCall[] {
|
||||
const calls: LoggerCall[] = [];
|
||||
const lines = content.split('\n');
|
||||
const seenCalls = new Set<string>();
|
||||
|
||||
// Build line number index for position-to-line lookup
|
||||
const lineStarts: number[] = [0];
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
if (content[i] === '\n') {
|
||||
@@ -74,9 +52,6 @@ function extractLoggerCalls(content: string, filePath: string): LoggerCall[] {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Pattern that matches logger calls across multiple lines
|
||||
// Captures: method, component, message, and everything up to closing paren
|
||||
// Uses [\s\S] instead of . to match newlines
|
||||
const loggerPattern = /logger\.(error|warn|info|debug|failure|success|timing|dataIn|dataOut|happyPathError)\s*\(\s*['"]([^'"]+)['"][\s\S]*?\)/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
@@ -86,11 +61,9 @@ function extractLoggerCalls(content: string, filePath: string): LoggerCall[] {
|
||||
const component = match[2];
|
||||
const lineNum = getLineNumber(match.index);
|
||||
|
||||
// Extract message (2nd string arg) - could be single, double, or template
|
||||
const messageMatch = fullMatch.match(/['"][^'"]+['"]\s*,\s*(['"`])([\s\S]*?)\1/);
|
||||
const message = messageMatch ? messageMatch[2] : '(message not captured)';
|
||||
|
||||
// Extract error parameter (4th arg) - look for "error as Error" or similar patterns
|
||||
let errorParam: string | null = null;
|
||||
const errorMatch = fullMatch.match(/,\s*(error|err|e)\s+as\s+Error\s*\)/i) ||
|
||||
fullMatch.match(/,\s*(error|err|e)\s*\)/i) ||
|
||||
@@ -109,7 +82,7 @@ function extractLoggerCalls(content: string, filePath: string): LoggerCall[] {
|
||||
component,
|
||||
message,
|
||||
errorParam,
|
||||
fullMatch: fullMatch.replace(/\s+/g, ' ').trim() // Normalize whitespace for display
|
||||
fullMatch: fullMatch.replace(/\s+/g, ' ').trim()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -117,9 +90,6 @@ function extractLoggerCalls(content: string, filePath: string): LoggerCall[] {
|
||||
return calls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize log level names to standard categories
|
||||
*/
|
||||
function normalizeLevel(method: string): string {
|
||||
switch (method) {
|
||||
case 'error':
|
||||
@@ -141,9 +111,6 @@ function normalizeLevel(method: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate formatted audit report
|
||||
*/
|
||||
function generateReport(calls: LoggerCall[]): string {
|
||||
const byLevel: Record<string, LoggerCall[]> = {
|
||||
'ERROR': [],
|
||||
@@ -162,7 +129,6 @@ function generateReport(calls: LoggerCall[]): string {
|
||||
lines.push('\n=== LOG LEVEL AUDIT REPORT ===\n');
|
||||
lines.push(`Total logger calls found: ${calls.length}\n`);
|
||||
|
||||
// ERROR level
|
||||
lines.push('');
|
||||
lines.push('ERROR (should be critical failures only):');
|
||||
lines.push('─'.repeat(60));
|
||||
@@ -181,7 +147,6 @@ function generateReport(calls: LoggerCall[]): string {
|
||||
}
|
||||
lines.push(` Count: ${byLevel['ERROR'].length}`);
|
||||
|
||||
// WARN level
|
||||
lines.push('');
|
||||
lines.push('WARN (should be non-critical, has fallback):');
|
||||
lines.push('─'.repeat(60));
|
||||
@@ -200,7 +165,6 @@ function generateReport(calls: LoggerCall[]): string {
|
||||
}
|
||||
lines.push(` Count: ${byLevel['WARN'].length}`);
|
||||
|
||||
// INFO level
|
||||
lines.push('');
|
||||
lines.push('INFO (informational):');
|
||||
lines.push('─'.repeat(60));
|
||||
@@ -219,7 +183,6 @@ function generateReport(calls: LoggerCall[]): string {
|
||||
}
|
||||
lines.push(` Count: ${byLevel['INFO'].length}`);
|
||||
|
||||
// DEBUG level
|
||||
lines.push('');
|
||||
lines.push('DEBUG (detailed diagnostics):');
|
||||
lines.push('─'.repeat(60));
|
||||
@@ -238,7 +201,6 @@ function generateReport(calls: LoggerCall[]): string {
|
||||
}
|
||||
lines.push(` Count: ${byLevel['DEBUG'].length}`);
|
||||
|
||||
// Summary
|
||||
lines.push('');
|
||||
lines.push('=== SUMMARY ===');
|
||||
lines.push(` ERROR: ${byLevel['ERROR'].length}`);
|
||||
@@ -251,9 +213,6 @@ function generateReport(calls: LoggerCall[]): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format message for display - NO TRUNCATION
|
||||
*/
|
||||
function formatMessage(message: string): string {
|
||||
return message;
|
||||
}
|
||||
@@ -278,7 +237,6 @@ describe('Log Level Audit', () => {
|
||||
const report = generateReport(allCalls);
|
||||
console.log(report);
|
||||
|
||||
// This test always passes - it's for generating a review report
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
@@ -302,8 +260,6 @@ describe('Log Level Audit', () => {
|
||||
console.log(` INFO: ${byLevel['INFO']} (${((byLevel['INFO'] / allCalls.length) * 100).toFixed(1)}%)`);
|
||||
console.log(` DEBUG: ${byLevel['DEBUG']} (${((byLevel['DEBUG'] / allCalls.length) * 100).toFixed(1)}%)`);
|
||||
|
||||
// Log distribution health check - not a hard failure, just informational
|
||||
// A healthy codebase typically has: DEBUG > INFO > WARN > ERROR
|
||||
expect(allCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,21 +3,9 @@ import { readdir } from "fs/promises";
|
||||
import { join, relative } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
/**
|
||||
* Logger Usage Standards - Enforces coding standards for logging
|
||||
*
|
||||
* This test enforces logging standards by:
|
||||
* 1. Detecting console.log/console.error usage in background services (invisible logs)
|
||||
* 2. Ensuring high-priority service files import the logger
|
||||
* 3. Reporting coverage statistics for observability
|
||||
*
|
||||
* Note: This is a legitimate coding standard enforcement test, not a coverage metric.
|
||||
*/
|
||||
|
||||
const PROJECT_ROOT = join(import.meta.dir, "..");
|
||||
const SRC_DIR = join(PROJECT_ROOT, "src");
|
||||
|
||||
// Files/directories that don't require logging
|
||||
const EXCLUDED_PATTERNS = [
|
||||
/types\//, // Type definition files
|
||||
/constants\//, // Pure constants
|
||||
@@ -40,8 +28,6 @@ const EXCLUDED_PATTERNS = [
|
||||
/services\/transcripts\/cli\.ts$/, // CLI transcript subcommands use console.log for user-visible interactive output
|
||||
];
|
||||
|
||||
// Files that should always use logger (core business logic)
|
||||
// Excludes UI files, type files, and pure utilities
|
||||
const HIGH_PRIORITY_PATTERNS = [
|
||||
/^services\/worker\/(?!.*types\.ts$)/, // Worker services (not type files)
|
||||
/^services\/sqlite\/(?!types\.ts$|index\.ts$)/, // SQLite services
|
||||
@@ -52,7 +38,6 @@ const HIGH_PRIORITY_PATTERNS = [
|
||||
/^servers\/(?!.*types?\.ts$)/, // Server files (not type files)
|
||||
];
|
||||
|
||||
// Additional check: exclude UI files from high priority
|
||||
const isUIFile = (path: string) => /^ui\//.test(path);
|
||||
|
||||
interface FileAnalysis {
|
||||
@@ -65,9 +50,6 @@ interface FileAnalysis {
|
||||
isHighPriority: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find all TypeScript files in a directory
|
||||
*/
|
||||
async function findTypeScriptFiles(dir: string): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
@@ -85,21 +67,14 @@ async function findTypeScriptFiles(dir: string): Promise<string[]> {
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file should be excluded from logger requirements
|
||||
*/
|
||||
function shouldExclude(filePath: string): boolean {
|
||||
const relativePath = relative(SRC_DIR, filePath);
|
||||
return EXCLUDED_PATTERNS.some(pattern => pattern.test(relativePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is high priority for logging
|
||||
*/
|
||||
function isHighPriority(filePath: string): boolean {
|
||||
const relativePath = relative(SRC_DIR, filePath);
|
||||
|
||||
// UI files are never high priority
|
||||
if (isUIFile(relativePath)) {
|
||||
return false;
|
||||
}
|
||||
@@ -107,18 +82,13 @@ function isHighPriority(filePath: string): boolean {
|
||||
return HIGH_PRIORITY_PATTERNS.some(pattern => pattern.test(relativePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a single TypeScript file for logger usage
|
||||
*/
|
||||
function analyzeFile(filePath: string): FileAnalysis {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
const relativePath = relative(PROJECT_ROOT, filePath);
|
||||
|
||||
// Check for logger import (handles both .ts and .js extensions in import paths)
|
||||
const hasLoggerImport = /import\s+.*logger.*from\s+['"].*logger(\.(js|ts))?['"]/.test(content);
|
||||
|
||||
// Find console.log/console.error usage with line numbers
|
||||
const consoleLogLines: number[] = [];
|
||||
lines.forEach((line, index) => {
|
||||
if (/console\.(log|error|warn|info|debug)/.test(line)) {
|
||||
@@ -126,7 +96,6 @@ function analyzeFile(filePath: string): FileAnalysis {
|
||||
}
|
||||
});
|
||||
|
||||
// Count logger method calls
|
||||
const loggerCallMatches = content.match(/logger\.(debug|info|warn|error|success|failure|timing|dataIn|dataOut|happyPathError)\(/g);
|
||||
const loggerCallCount = loggerCallMatches ? loggerCallMatches.length : 0;
|
||||
|
||||
@@ -155,8 +124,6 @@ describe("Logger Usage Standards", () => {
|
||||
});
|
||||
|
||||
it("should NOT use console.log/console.error (these logs are invisible in background services)", () => {
|
||||
// Only hook files can use console.log for their final output response
|
||||
// Everything else (services, workers, sqlite, etc.) runs in background - console.log is USELESS there
|
||||
const filesWithConsole = relevantFiles.filter(f => {
|
||||
const isHookFile = /^src\/hooks\//.test(f.relativePath);
|
||||
return f.usesConsoleLog && !isHookFile;
|
||||
@@ -214,7 +181,6 @@ describe("Logger Usage Standards", () => {
|
||||
});
|
||||
}
|
||||
|
||||
// This is an informational test - we expect some files won't need logging
|
||||
expect(withLogger.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,26 +3,9 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
/**
|
||||
* Tests for the MCP integration factory utilities.
|
||||
*
|
||||
* Because McpIntegrations.ts uses `findMcpServerPath()` which checks specific
|
||||
* filesystem paths, and the factory functions are not individually exported,
|
||||
* we test the underlying helpers indirectly by exercising writeMcpJsonConfig
|
||||
* and buildMcpServerEntry behavior through the readJsonSafe + JSON file writing
|
||||
* patterns they use.
|
||||
*
|
||||
* We also verify the key behavioral contract: MCP entries use process.execPath.
|
||||
*/
|
||||
|
||||
import { readJsonSafe } from '../src/utils/json-utils';
|
||||
import { injectContextIntoMarkdownFile, CONTEXT_TAG_OPEN, CONTEXT_TAG_CLOSE } from '../src/utils/context-injection';
|
||||
|
||||
/**
|
||||
* Reimplements the core logic of buildMcpServerEntry and writeMcpJsonConfig
|
||||
* from McpIntegrations.ts for testability, since those functions are not exported.
|
||||
* The tests verify the contract these functions must uphold.
|
||||
*/
|
||||
function buildMcpServerEntry(mcpServerPath: string): { command: string; args: string[] } {
|
||||
return {
|
||||
command: process.execPath,
|
||||
@@ -200,7 +183,6 @@ describe('MCP Integrations', () => {
|
||||
/Corrupt JSON file, refusing to overwrite/
|
||||
);
|
||||
|
||||
// Original file should be untouched
|
||||
expect(readFileSync(configPath, 'utf-8')).toBe('not valid json {{{{');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const runtimeSourcePath = join(
|
||||
__dirname,
|
||||
'..',
|
||||
'src',
|
||||
'npx-cli',
|
||||
'commands',
|
||||
'runtime.ts',
|
||||
);
|
||||
const runtimeSource = readFileSync(runtimeSourcePath, 'utf-8');
|
||||
|
||||
describe('NPX search query param', () => {
|
||||
it('documents the search endpoint with query param', () => {
|
||||
expect(runtimeSource).toContain('GET /api/search?query=<query>');
|
||||
});
|
||||
|
||||
it('uses query param instead of q param for worker search requests', () => {
|
||||
expect(runtimeSource).toContain('/api/search?query=${encodeURIComponent(query)}');
|
||||
expect(runtimeSource).not.toContain('/api/search?q=${encodeURIComponent(query)}');
|
||||
});
|
||||
});
|
||||
@@ -2,19 +2,6 @@ import { describe, it, expect } from 'bun:test';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* Regression tests for issue #1342.
|
||||
*
|
||||
* Bundled plugin scripts use a shebang line (#!/usr/bin/env node or #!/usr/bin/env bun).
|
||||
* If those files are committed with Windows CRLF line endings, the shebang becomes
|
||||
* "#!/usr/bin/env node\r" which fails with:
|
||||
* env: node\r: No such file or directory
|
||||
* on macOS and Linux, breaking the MCP server and all hook scripts.
|
||||
*
|
||||
* These tests guard against CRLF line endings being re-introduced into the
|
||||
* committed plugin scripts (e.g. by a Windows contributor without .gitattributes).
|
||||
*/
|
||||
|
||||
const SCRIPTS_DIR = join(import.meta.dir, '..', 'plugin', 'scripts');
|
||||
|
||||
const SHEBANG_SCRIPTS = [
|
||||
@@ -22,7 +9,6 @@ const SHEBANG_SCRIPTS = [
|
||||
'worker-service.cjs',
|
||||
'context-generator.cjs',
|
||||
'bun-runner.js',
|
||||
'smart-install.js',
|
||||
'worker-cli.js',
|
||||
];
|
||||
|
||||
@@ -34,7 +20,6 @@ describe('plugin/scripts line endings (#1342)', () => {
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
const content = readFileSync(filePath, 'binary');
|
||||
const firstLine = content.split('\n')[0];
|
||||
// CRLF would leave a trailing \r on the shebang line
|
||||
expect(firstLine.endsWith('\r')).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
/**
|
||||
* Tests for parseAgentXml summary path (PATHFINDER plan 03 phase 1).
|
||||
*
|
||||
* Validates that the discriminated-union parser:
|
||||
* - rejects responses with no recognised root element (`{ valid: false }`),
|
||||
* - rejects empty / no-sub-tag <summary> blocks (former #1360 false-positive),
|
||||
* - returns a populated summary when at least one sub-tag is present,
|
||||
* - treats <skip_summary reason="…"/> as a first-class summary case,
|
||||
* - DOES NOT coerce <observation> blocks into summary fields (former
|
||||
* #1633 fallback path is deleted; the caller must mark the message failed
|
||||
* and let the retry ladder do its job — principle 1 + principle 2).
|
||||
*/
|
||||
import { describe, it, expect, mock } from 'bun:test';
|
||||
|
||||
mock.module('../../src/services/domain/ModeManager.js', () => ({
|
||||
@@ -31,9 +19,7 @@ describe('parseAgentXml — summaries', () => {
|
||||
});
|
||||
|
||||
it('returns invalid when <summary> has no sub-tags (false positive — was #1360)', () => {
|
||||
// observation response that accidentally contains <summary>some text</summary>
|
||||
const result = parseAgentXml('<observation>done <summary>some content here</summary></observation>');
|
||||
// The first root is <observation>, which has no parseable content; result must be invalid.
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
@@ -93,9 +79,6 @@ describe('parseAgentXml — summaries', () => {
|
||||
const text = `<observation><title>obs title</title></observation>
|
||||
<summary><request>summary request</request></summary>`;
|
||||
const result = parseAgentXml(text);
|
||||
// First root by position is observation → that wins. Caller must pick the
|
||||
// right turn (summary vs observation) by sending only summary prompts on
|
||||
// summary turns. This is the contract; it is not coercion.
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.kind).toBe('observation');
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, it, expect, mock } from 'bun:test';
|
||||
|
||||
// Mock ModeManager before importing parser (it's used at module load time)
|
||||
mock.module('../../src/services/domain/ModeManager.js', () => ({
|
||||
ModeManager: {
|
||||
getInstance: () => ({
|
||||
@@ -73,8 +72,6 @@ describe('parseAgentXml — observations', () => {
|
||||
expect(result[0].concepts).toEqual(['dependency-injection']);
|
||||
});
|
||||
|
||||
// Regression test for issue #1625:
|
||||
// Ghost observations (all content fields null/empty) must be filtered out.
|
||||
it('filters out ghost observations where all content fields are null (#1625)', () => {
|
||||
const xml = `<observation>
|
||||
<type>bugfix</type>
|
||||
@@ -113,8 +110,6 @@ describe('parseAgentXml — observations', () => {
|
||||
expect(result[0].title).toBe('Real observation');
|
||||
});
|
||||
|
||||
// Subtitle alone is explicitly excluded from the content guard (see parser comment).
|
||||
// An observation with only a subtitle is too thin to be useful and must be filtered.
|
||||
it('filters out observation with only a subtitle (excluded from survival criteria) (#1625)', () => {
|
||||
const xml = `<observation>
|
||||
<type>discovery</type>
|
||||
@@ -133,7 +128,6 @@ describe('parseAgentXml — observations', () => {
|
||||
const result = expectObservation(xml);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
// First type in mocked mode is 'bugfix'
|
||||
expect(result[0].type).toBe('bugfix');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
/**
|
||||
* Tests for Express error handling middleware
|
||||
*
|
||||
* Mock Justification (~11% mock code):
|
||||
* - Logger spies: Suppress console output during tests (standard practice)
|
||||
* - Express req/res mocks: Required because Express middleware expects these
|
||||
* objects - testing the actual formatting and status code logic
|
||||
*
|
||||
* What's NOT mocked: AppError class, createErrorResponse function (tested directly)
|
||||
*/
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { logger } from '../../src/utils/logger.js';
|
||||
@@ -19,8 +9,6 @@ import {
|
||||
notFoundHandler,
|
||||
} from '../../src/services/server/ErrorHandler.js';
|
||||
|
||||
// Spy on logger methods to suppress output during tests
|
||||
// Using spyOn instead of mock.module to avoid polluting global module cache
|
||||
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
describe('ErrorHandler', () => {
|
||||
@@ -126,7 +114,6 @@ describe('ErrorHandler', () => {
|
||||
it('should handle empty string code as falsy and exclude it', () => {
|
||||
const response = createErrorResponse('Error', 'Test', '');
|
||||
|
||||
// Empty string is falsy, so code should not be set
|
||||
expect(response.code).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test';
|
||||
import { logger } from '../../src/utils/logger.js';
|
||||
|
||||
// Mock middleware to avoid complex dependencies
|
||||
mock.module('../../src/services/worker/http/middleware.js', () => ({
|
||||
createMiddleware: () => [],
|
||||
requireLocalhost: (_req: any, _res: any, next: any) => next(),
|
||||
summarizeRequestBody: () => 'test body',
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
import { Server } from '../../src/services/server/Server.js';
|
||||
import type { RouteHandler, ServerOptions } from '../../src/services/server/Server.js';
|
||||
|
||||
// Spy on logger methods to suppress output during tests
|
||||
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
describe('Server', () => {
|
||||
@@ -43,7 +40,6 @@ describe('Server', () => {
|
||||
|
||||
afterEach(async () => {
|
||||
loggerSpies.forEach(spy => spy.mockRestore());
|
||||
// Clean up server if created and still has an active http server
|
||||
if (server && server.getHttpServer()) {
|
||||
try {
|
||||
await server.close();
|
||||
@@ -67,10 +63,8 @@ describe('Server', () => {
|
||||
it('should expose app as readonly property', () => {
|
||||
server = new Server(mockOptions);
|
||||
|
||||
// App should be accessible
|
||||
expect(server.app).toBeDefined();
|
||||
|
||||
// App should be an Express application
|
||||
expect(typeof server.app.listen).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -79,12 +73,10 @@ describe('Server', () => {
|
||||
it('should start server on specified port', async () => {
|
||||
server = new Server(mockOptions);
|
||||
|
||||
// Use a random high port to avoid conflicts
|
||||
const testPort = 40000 + Math.floor(Math.random() * 10000);
|
||||
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Server should now be listening
|
||||
const httpServer = server.getHttpServer();
|
||||
expect(httpServer).not.toBeNull();
|
||||
expect(httpServer!.listening).toBe(true);
|
||||
@@ -96,13 +88,10 @@ describe('Server', () => {
|
||||
|
||||
const testPort = 40000 + Math.floor(Math.random() * 10000);
|
||||
|
||||
// Start first server
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Second server should fail on same port
|
||||
await expect(server2.listen(testPort, '127.0.0.1')).rejects.toThrow();
|
||||
|
||||
// The server object was created but not successfully listening
|
||||
const httpServer = server2.getHttpServer();
|
||||
if (httpServer) {
|
||||
expect(httpServer.listening).toBe(false);
|
||||
@@ -117,23 +106,18 @@ describe('Server', () => {
|
||||
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Server should exist and be listening
|
||||
const httpServerBefore = server.getHttpServer();
|
||||
expect(httpServerBefore).not.toBeNull();
|
||||
expect(httpServerBefore!.listening).toBe(true);
|
||||
|
||||
// Close the server - may throw ERR_SERVER_NOT_RUNNING on some platforms
|
||||
// because closeAllConnections() might immediately close the server
|
||||
try {
|
||||
await server.close();
|
||||
} catch (e: any) {
|
||||
// ERR_SERVER_NOT_RUNNING is acceptable - closeAllConnections() already closed it
|
||||
if (e.code !== 'ERR_SERVER_NOT_RUNNING') {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// The server should no longer be listening (even if ref is not null due to early throw)
|
||||
const httpServerAfter = server.getHttpServer();
|
||||
if (httpServerAfter) {
|
||||
expect(httpServerAfter.listening).toBe(false);
|
||||
@@ -143,7 +127,6 @@ describe('Server', () => {
|
||||
it('should handle close when server not started', async () => {
|
||||
server = new Server(mockOptions);
|
||||
|
||||
// Should not throw when closing unstarted server
|
||||
await expect(server.close()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -153,26 +136,21 @@ describe('Server', () => {
|
||||
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Close the server
|
||||
try {
|
||||
await server.close();
|
||||
} catch (e: any) {
|
||||
// ERR_SERVER_NOT_RUNNING is acceptable
|
||||
if (e.code !== 'ERR_SERVER_NOT_RUNNING') {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay to ensure port is released
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Should be able to listen again on same port with a new server
|
||||
const server2 = new Server(mockOptions);
|
||||
await server2.listen(testPort, '127.0.0.1');
|
||||
|
||||
expect(server2.getHttpServer()!.listening).toBe(true);
|
||||
|
||||
// Clean up server2
|
||||
try {
|
||||
await server2.close();
|
||||
} catch {
|
||||
@@ -284,15 +262,12 @@ describe('Server', () => {
|
||||
|
||||
await server.listen(testPort, '127.0.0.1');
|
||||
|
||||
// Check when not initialized
|
||||
let response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
let body = await response.json();
|
||||
expect(body.initialized).toBe(false);
|
||||
|
||||
// Change state
|
||||
isInitialized = true;
|
||||
|
||||
// Check when initialized
|
||||
response = await fetch(`http://127.0.0.1:${testPort}/api/health`);
|
||||
body = await response.json();
|
||||
expect(body.initialized).toBe(true);
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
/**
|
||||
* Tests for MCP tool inputSchema declarations (fix for #1384 / #1413)
|
||||
*
|
||||
* Validates that search and timeline tools declare their parameters explicitly
|
||||
* so MCP clients (Claude Code) can expose them to the LLM.
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
// Static schema validation — reads source as text, no server startup needed
|
||||
const mcpServerPath = new URL('../../src/servers/mcp-server.ts', import.meta.url).pathname;
|
||||
|
||||
describe('MCP tool inputSchema declarations', () => {
|
||||
let tools: any[];
|
||||
|
||||
// Load tools by reading the source and extracting the exported structure
|
||||
// We test the schema shape directly from the source constants
|
||||
it('search tool declares query parameter', async () => {
|
||||
const src = await Bun.file(mcpServerPath).text();
|
||||
|
||||
// Verify search properties are declared (not empty)
|
||||
expect(src).toContain("name: 'search'");
|
||||
// Check query is declared in properties after the search tool definition
|
||||
const searchSection = src.slice(src.indexOf("name: 'search'"), src.indexOf("name: 'timeline'"));
|
||||
expect(searchSection).toContain("query:");
|
||||
expect(searchSection).toContain("limit:");
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
/**
|
||||
* Tests for readLastLines() — tail-read function for /api/logs endpoint (#1203)
|
||||
*
|
||||
* Verifies that log files are read from the end without loading the entire
|
||||
* file into memory, preventing OOM on large log files.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { writeFileSync, mkdirSync, rmSync, existsSync } from 'fs';
|
||||
@@ -73,7 +67,6 @@ describe('readLastLines (#1203 OOM fix)', () => {
|
||||
});
|
||||
|
||||
it('should work with lines larger than initial chunk size', () => {
|
||||
// Create a file where lines are long enough to exceed the 64KB initial chunk
|
||||
const longLine = 'X'.repeat(10000);
|
||||
const lines = Array.from({ length: 20 }, (_, i) => `${i}:${longLine}`);
|
||||
writeFileSync(testFile, lines.join('\n') + '\n', 'utf-8');
|
||||
@@ -91,7 +84,6 @@ describe('readLastLines (#1203 OOM fix)', () => {
|
||||
writeFileSync(testFile, lines.join('\n') + '\n', 'utf-8');
|
||||
|
||||
const result = readLastLines(testFile, 100);
|
||||
// When file fits in one chunk, totalEstimate should be exact
|
||||
expect(result.totalEstimate).toBe(5);
|
||||
});
|
||||
|
||||
@@ -105,22 +97,17 @@ describe('readLastLines (#1203 OOM fix)', () => {
|
||||
writeFileSync(testFile, '\n\n\n', 'utf-8');
|
||||
const result = readLastLines(testFile, 2);
|
||||
const resultLines = result.lines.split('\n');
|
||||
// The last two "lines" before trailing newline are empty strings
|
||||
expect(resultLines.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should not load entire large file for small tail request', () => {
|
||||
// This test verifies the core fix: a file with many lines should
|
||||
// not be fully loaded when only a few lines are requested.
|
||||
// We create a file larger than the initial 64KB chunk.
|
||||
const line = 'A'.repeat(100) + '\n'; // ~101 bytes per line
|
||||
const lineCount = 1000; // ~101KB total
|
||||
const line = 'A'.repeat(100) + '\n';
|
||||
const lineCount = 1000;
|
||||
writeFileSync(testFile, line.repeat(lineCount), 'utf-8');
|
||||
|
||||
const result = readLastLines(testFile, 5);
|
||||
const resultLines = result.lines.split('\n');
|
||||
expect(resultLines.length).toBe(5);
|
||||
// Each returned line should be our repeated 'A' pattern
|
||||
for (const l of resultLines) {
|
||||
expect(l).toBe('A'.repeat(100));
|
||||
}
|
||||
|
||||
@@ -3,10 +3,6 @@ import { EventEmitter } from 'events';
|
||||
import { SessionQueueProcessor, CreateIteratorOptions } from '../../../src/services/queue/SessionQueueProcessor.js';
|
||||
import type { PendingMessageStore, PersistentPendingMessage } from '../../../src/services/sqlite/PendingMessageStore.js';
|
||||
|
||||
/**
|
||||
* Mock PendingMessageStore that returns null (empty queue) by default.
|
||||
* Individual tests can override claimNextMessage behavior.
|
||||
*/
|
||||
function createMockStore(): PendingMessageStore {
|
||||
return {
|
||||
claimNextMessage: mock(() => null),
|
||||
@@ -22,9 +18,6 @@ function createMockStore(): PendingMessageStore {
|
||||
} as unknown as PendingMessageStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock PersistentPendingMessage for testing
|
||||
*/
|
||||
function createMockMessage(overrides: Partial<PersistentPendingMessage> = {}): PersistentPendingMessage {
|
||||
return {
|
||||
id: 1,
|
||||
@@ -60,20 +53,15 @@ describe('SessionQueueProcessor', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Ensure abort controller is triggered to clean up any pending iterators
|
||||
abortController.abort();
|
||||
// Remove all listeners to prevent memory leaks
|
||||
events.removeAllListeners();
|
||||
});
|
||||
|
||||
describe('createIterator', () => {
|
||||
describe('idle timeout behavior', () => {
|
||||
it('should exit after idle timeout when no messages arrive', async () => {
|
||||
// Use a very short timeout for testing (50ms)
|
||||
const SHORT_TIMEOUT_MS = 50;
|
||||
|
||||
// Mock the private waitForMessage to use short timeout
|
||||
// We'll test with real timing but short durations
|
||||
const onIdleTimeout = mock(() => {});
|
||||
|
||||
const options: CreateIteratorOptions = {
|
||||
@@ -84,33 +72,21 @@ describe('SessionQueueProcessor', () => {
|
||||
|
||||
const iterator = processor.createIterator(options);
|
||||
|
||||
// Store returns null (empty queue), so iterator waits for message event
|
||||
// With no messages arriving, it should eventually timeout
|
||||
|
||||
const startTime = Date.now();
|
||||
const results: any[] = [];
|
||||
|
||||
// We need to trigger the timeout scenario
|
||||
// The iterator uses IDLE_TIMEOUT_MS (3 minutes) which is too long for tests
|
||||
// Instead, we'll test the abort path and verify callback behavior
|
||||
|
||||
// Abort after a short delay to simulate timeout-like behavior
|
||||
setTimeout(() => abortController.abort(), 100);
|
||||
|
||||
for await (const message of iterator) {
|
||||
results.push(message);
|
||||
}
|
||||
|
||||
// Iterator should exit cleanly when aborted
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should invoke onIdleTimeout callback when idle timeout occurs', async () => {
|
||||
// This test verifies the callback mechanism works
|
||||
// We can't easily test the full 3-minute timeout, so we verify the wiring
|
||||
|
||||
const onIdleTimeout = mock(() => {
|
||||
// Callback should trigger abort in real usage
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
@@ -120,11 +96,8 @@ describe('SessionQueueProcessor', () => {
|
||||
onIdleTimeout
|
||||
};
|
||||
|
||||
// To test this properly, we'd need to mock the internal waitForMessage
|
||||
// For now, verify that abort signal exits cleanly
|
||||
const iterator = processor.createIterator(options);
|
||||
|
||||
// Simulate external abort (which is what onIdleTimeout should do)
|
||||
setTimeout(() => abortController.abort(), 50);
|
||||
|
||||
const results: any[] = [];
|
||||
@@ -139,7 +112,6 @@ describe('SessionQueueProcessor', () => {
|
||||
const onIdleTimeout = mock(() => abortController.abort());
|
||||
let callCount = 0;
|
||||
|
||||
// Return a message on first call, then null
|
||||
(store.claimNextMessage as any) = mock(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
@@ -157,21 +129,15 @@ describe('SessionQueueProcessor', () => {
|
||||
const iterator = processor.createIterator(options);
|
||||
const results: any[] = [];
|
||||
|
||||
// First message should be yielded
|
||||
// Then queue is empty, wait for more
|
||||
// Abort after receiving first message
|
||||
setTimeout(() => abortController.abort(), 100);
|
||||
|
||||
for await (const message of iterator) {
|
||||
results.push(message);
|
||||
}
|
||||
|
||||
// Should have received exactly one message
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]._persistentId).toBe(1);
|
||||
|
||||
// Store's claimNextMessage should have been called at least twice
|
||||
// (once returning message, once returning null)
|
||||
expect(callCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -188,7 +154,6 @@ describe('SessionQueueProcessor', () => {
|
||||
|
||||
const iterator = processor.createIterator(options);
|
||||
|
||||
// Abort immediately
|
||||
abortController.abort();
|
||||
|
||||
const results: any[] = [];
|
||||
@@ -196,16 +161,13 @@ describe('SessionQueueProcessor', () => {
|
||||
results.push(message);
|
||||
}
|
||||
|
||||
// Should exit with no messages
|
||||
expect(results).toHaveLength(0);
|
||||
// onIdleTimeout should NOT be called when abort signal is used
|
||||
expect(onIdleTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should take precedence over timeout when both could fire', async () => {
|
||||
const onIdleTimeout = mock(() => {});
|
||||
|
||||
// Return null to trigger wait
|
||||
(store.claimNextMessage as any) = mock(() => null);
|
||||
|
||||
const options: CreateIteratorOptions = {
|
||||
@@ -216,7 +178,6 @@ describe('SessionQueueProcessor', () => {
|
||||
|
||||
const iterator = processor.createIterator(options);
|
||||
|
||||
// Abort very quickly - before any timeout could fire
|
||||
setTimeout(() => abortController.abort(), 10);
|
||||
|
||||
const results: any[] = [];
|
||||
@@ -224,9 +185,7 @@ describe('SessionQueueProcessor', () => {
|
||||
results.push(message);
|
||||
}
|
||||
|
||||
// Should have exited cleanly
|
||||
expect(results).toHaveLength(0);
|
||||
// onIdleTimeout should NOT have been called
|
||||
expect(onIdleTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -239,19 +198,13 @@ describe('SessionQueueProcessor', () => {
|
||||
createMockMessage({ id: 2 })
|
||||
];
|
||||
|
||||
// First call: return null (queue empty)
|
||||
// After message event: return message
|
||||
// Then return null again
|
||||
(store.claimNextMessage as any) = mock(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
// First check - queue empty, will wait
|
||||
return null;
|
||||
} else if (callCount === 2) {
|
||||
// After wake-up - return message
|
||||
return mockMessages[0];
|
||||
} else if (callCount === 3) {
|
||||
// Second check after message processed - empty again
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
@@ -265,17 +218,14 @@ describe('SessionQueueProcessor', () => {
|
||||
const iterator = processor.createIterator(options);
|
||||
const results: any[] = [];
|
||||
|
||||
// Emit message event after a short delay to wake up the iterator
|
||||
setTimeout(() => events.emit('message'), 50);
|
||||
|
||||
// Abort after collecting results
|
||||
setTimeout(() => abortController.abort(), 150);
|
||||
|
||||
for await (const message of iterator) {
|
||||
results.push(message);
|
||||
}
|
||||
|
||||
// Should have received exactly one message
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
if (results.length > 0) {
|
||||
expect(results[0]._persistentId).toBe(1);
|
||||
@@ -292,26 +242,20 @@ describe('SessionQueueProcessor', () => {
|
||||
|
||||
const iterator = processor.createIterator(options);
|
||||
|
||||
// Get initial listener count
|
||||
const initialListenerCount = events.listenerCount('message');
|
||||
|
||||
// Abort to trigger cleanup
|
||||
abortController.abort();
|
||||
|
||||
// Consume the iterator
|
||||
const results: any[] = [];
|
||||
for await (const message of iterator) {
|
||||
results.push(message);
|
||||
}
|
||||
|
||||
// After iterator completes, listener count should be same or less
|
||||
// (the cleanup happens inside waitForMessage which may not be called)
|
||||
const finalListenerCount = events.listenerCount('message');
|
||||
expect(finalListenerCount).toBeLessThanOrEqual(initialListenerCount + 1);
|
||||
});
|
||||
|
||||
it('should clean up event listeners when message received', async () => {
|
||||
// Return a message immediately
|
||||
(store.claimNextMessage as any) = mock(() => createMockMessage({ id: 1 }));
|
||||
|
||||
const options: CreateIteratorOptions = {
|
||||
@@ -321,20 +265,16 @@ describe('SessionQueueProcessor', () => {
|
||||
|
||||
const iterator = processor.createIterator(options);
|
||||
|
||||
// Get first message
|
||||
const firstResult = await iterator.next();
|
||||
expect(firstResult.done).toBe(false);
|
||||
expect(firstResult.value._persistentId).toBe(1);
|
||||
|
||||
// Now abort and complete iteration
|
||||
abortController.abort();
|
||||
|
||||
// Drain remaining
|
||||
for await (const _ of iterator) {
|
||||
// Should not get here since we aborted
|
||||
}
|
||||
|
||||
// Verify no leftover listeners (accounting for potential timing)
|
||||
const finalListenerCount = events.listenerCount('message');
|
||||
expect(finalListenerCount).toBeLessThanOrEqual(1);
|
||||
});
|
||||
@@ -363,15 +303,13 @@ describe('SessionQueueProcessor', () => {
|
||||
const iterator = processor.createIterator(options);
|
||||
const results: any[] = [];
|
||||
|
||||
// Abort after giving time for retry
|
||||
setTimeout(() => abortController.abort(), 1500);
|
||||
|
||||
for await (const message of iterator) {
|
||||
results.push(message);
|
||||
break; // Exit after first message
|
||||
break;
|
||||
}
|
||||
|
||||
// Should have recovered and received message after error
|
||||
expect(results).toHaveLength(1);
|
||||
expect(callCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
@@ -388,7 +326,6 @@ describe('SessionQueueProcessor', () => {
|
||||
|
||||
const iterator = processor.createIterator(options);
|
||||
|
||||
// Abort during the backoff period
|
||||
setTimeout(() => abortController.abort(), 100);
|
||||
|
||||
const results: any[] = [];
|
||||
@@ -396,7 +333,6 @@ describe('SessionQueueProcessor', () => {
|
||||
results.push(message);
|
||||
}
|
||||
|
||||
// Should exit cleanly with no messages
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -423,7 +359,6 @@ describe('SessionQueueProcessor', () => {
|
||||
const iterator = processor.createIterator(options);
|
||||
const result = await iterator.next();
|
||||
|
||||
// Abort to clean up
|
||||
abortController.abort();
|
||||
|
||||
expect(result.done).toBe(false);
|
||||
|
||||
@@ -33,12 +33,8 @@ describe('PendingMessageStore - Self-Healing claimNextMessage', () => {
|
||||
return store.enqueue(sessionDbId, CONTENT_SESSION_ID, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to simulate a stuck processing message by directly updating the DB
|
||||
* to set started_processing_at_epoch to a time in the past (>60s ago)
|
||||
*/
|
||||
function makeMessageStaleProcessing(messageId: number): void {
|
||||
const staleTimestamp = Date.now() - 120_000; // 2 minutes ago (well past 60s threshold)
|
||||
const staleTimestamp = Date.now() - 120_000;
|
||||
db.run(
|
||||
`UPDATE pending_messages SET status = 'processing', started_processing_at_epoch = ? WHERE id = ?`,
|
||||
[staleTimestamp, messageId]
|
||||
@@ -46,64 +42,51 @@ describe('PendingMessageStore - Self-Healing claimNextMessage', () => {
|
||||
}
|
||||
|
||||
test('stuck processing messages are recovered on next claim', () => {
|
||||
// Enqueue a message and make it stuck in processing
|
||||
const msgId = enqueueMessage();
|
||||
makeMessageStaleProcessing(msgId);
|
||||
|
||||
// Verify it's stuck (status = processing)
|
||||
const beforeClaim = db.query('SELECT status FROM pending_messages WHERE id = ?').get(msgId) as { status: string };
|
||||
expect(beforeClaim.status).toBe('processing');
|
||||
|
||||
// claimNextMessage should self-heal: reset the stuck message, then claim it
|
||||
const claimed = store.claimNextMessage(sessionDbId);
|
||||
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.id).toBe(msgId);
|
||||
// It should now be in 'processing' status again (freshly claimed)
|
||||
const afterClaim = db.query('SELECT status FROM pending_messages WHERE id = ?').get(msgId) as { status: string };
|
||||
expect(afterClaim.status).toBe('processing');
|
||||
});
|
||||
|
||||
test('actively processing messages are NOT recovered', () => {
|
||||
// Enqueue two messages
|
||||
const activeId = enqueueMessage();
|
||||
const pendingId = enqueueMessage();
|
||||
|
||||
// Make the first one actively processing (recent timestamp, NOT stale)
|
||||
const recentTimestamp = Date.now() - 5_000; // 5 seconds ago (well within 60s threshold)
|
||||
const recentTimestamp = Date.now() - 5_000;
|
||||
db.run(
|
||||
`UPDATE pending_messages SET status = 'processing', started_processing_at_epoch = ? WHERE id = ?`,
|
||||
[recentTimestamp, activeId]
|
||||
);
|
||||
|
||||
// claimNextMessage should NOT reset the active one — should claim the pending one instead
|
||||
const claimed = store.claimNextMessage(sessionDbId);
|
||||
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.id).toBe(pendingId);
|
||||
|
||||
// The active message should still be processing
|
||||
const activeMsg = db.query('SELECT status FROM pending_messages WHERE id = ?').get(activeId) as { status: string };
|
||||
expect(activeMsg.status).toBe('processing');
|
||||
});
|
||||
|
||||
test('recovery and claim is atomic within single call', () => {
|
||||
// Enqueue three messages
|
||||
const stuckId = enqueueMessage();
|
||||
const pendingId1 = enqueueMessage();
|
||||
const pendingId2 = enqueueMessage();
|
||||
|
||||
// Make the first one stuck
|
||||
makeMessageStaleProcessing(stuckId);
|
||||
|
||||
// Single claimNextMessage should reset stuck AND claim oldest pending (which is the reset stuck one)
|
||||
const claimed = store.claimNextMessage(sessionDbId);
|
||||
|
||||
expect(claimed).not.toBeNull();
|
||||
// The stuck message was reset to pending, and being oldest, it gets claimed
|
||||
expect(claimed!.id).toBe(stuckId);
|
||||
|
||||
// The other two should still be pending
|
||||
const msg1 = db.query('SELECT status FROM pending_messages WHERE id = ?').get(pendingId1) as { status: string };
|
||||
const msg2 = db.query('SELECT status FROM pending_messages WHERE id = ?').get(pendingId2) as { status: string };
|
||||
expect(msg1.status).toBe('pending');
|
||||
@@ -116,14 +99,11 @@ describe('PendingMessageStore - Self-Healing claimNextMessage', () => {
|
||||
});
|
||||
|
||||
test('self-healing only affects the specified session', () => {
|
||||
// Create a second session
|
||||
const session2Id = createSDKSession(db, 'other-session', 'test-project', 'Test');
|
||||
|
||||
// Enqueue and make stuck in session 1
|
||||
const stuckInSession1 = enqueueMessage();
|
||||
makeMessageStaleProcessing(stuckInSession1);
|
||||
|
||||
// Enqueue in session 2
|
||||
const msg: PendingMessage = {
|
||||
type: 'observation',
|
||||
tool_name: 'TestTool',
|
||||
@@ -134,12 +114,10 @@ describe('PendingMessageStore - Self-Healing claimNextMessage', () => {
|
||||
const session2MsgId = store.enqueue(session2Id, 'other-session', msg);
|
||||
makeMessageStaleProcessing(session2MsgId);
|
||||
|
||||
// Claim for session 2 — should only heal session 2's stuck message
|
||||
const claimed = store.claimNextMessage(session2Id);
|
||||
expect(claimed).not.toBeNull();
|
||||
expect(claimed!.id).toBe(session2MsgId);
|
||||
|
||||
// Session 1's stuck message should still be stuck (not healed by session 2's claim)
|
||||
const session1Msg = db.query('SELECT status FROM pending_messages WHERE id = ?').get(stuckInSession1) as { status: string };
|
||||
expect(session1Msg.status).toBe('processing');
|
||||
});
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
/**
|
||||
* Regression test for #2153: ChromaSearchStrategy passes orderBy='relevance'
|
||||
* to SessionStore.getObservationsByIds expecting Chroma's vector ranking
|
||||
* (caller-provided ID order) to be preserved. The old code coerced
|
||||
* 'relevance' to undefined, which then defaulted to 'date_desc' inside
|
||||
* SessionStore, destroying the semantic ranking.
|
||||
*
|
||||
* Mock Justification: NONE - real SQLite ':memory:' covers SQL + ordering.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { SessionStore } from '../../../src/services/sqlite/SessionStore.js';
|
||||
|
||||
@@ -25,9 +16,6 @@ describe('SessionStore.*ByIds — orderBy: "relevance" preserves caller ID order
|
||||
const sdkId = store.createSDKSession('content-relevance', 'p', 'prompt');
|
||||
store.updateMemorySessionId(sdkId, 'session-relevance');
|
||||
|
||||
// Insert 5 observations with strictly increasing created_at_epoch so that
|
||||
// a date_desc default would reverse the natural insertion order. The test
|
||||
// proves that caller-provided ID order, not date order, is honored.
|
||||
const baseTs = 1_700_000_000_000;
|
||||
const inserted: number[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
@@ -52,8 +40,6 @@ describe('SessionStore.*ByIds — orderBy: "relevance" preserves caller ID order
|
||||
inserted.push(result.observationIds[0]);
|
||||
}
|
||||
|
||||
// Reverse the IDs — semantic ranking from Chroma would not match
|
||||
// chronological order.
|
||||
const callerOrder = [...inserted].reverse();
|
||||
const results = store.getObservationsByIds(callerOrder, { orderBy: 'relevance' });
|
||||
|
||||
@@ -87,8 +73,7 @@ describe('SessionStore.*ByIds — orderBy: "relevance" preserves caller ID order
|
||||
inserted.push(result.observationIds[0]);
|
||||
}
|
||||
|
||||
const callerOrder = [...inserted].reverse(); // [oldId... newer... oldest]
|
||||
// Default order is date_desc -> newest first regardless of input order.
|
||||
const callerOrder = [...inserted].reverse();
|
||||
const results = store.getObservationsByIds(callerOrder);
|
||||
expect(results.map(r => r.id)).toEqual([...inserted].reverse());
|
||||
});
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
/**
|
||||
* Tests for MigrationRunner idempotency and schema initialization (#979)
|
||||
*
|
||||
* Mock Justification: NONE (0% mock code)
|
||||
* - Uses real SQLite with ':memory:' — tests actual migration SQL
|
||||
* - Validates idempotency by running migrations multiple times
|
||||
* - Covers the version-conflict scenario from issue #979
|
||||
*
|
||||
* Value: Prevents regression where old DatabaseManager migrations mask core table creation
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { MigrationRunner } from '../../../src/services/sqlite/migrations/runner.js';
|
||||
@@ -121,21 +111,20 @@ describe('MigrationRunner', () => {
|
||||
runner.runAllMigrations();
|
||||
|
||||
const versions = getSchemaVersions(db);
|
||||
// Core set of expected versions
|
||||
expect(versions).toContain(4); // initializeSchema
|
||||
expect(versions).toContain(5); // worker_port
|
||||
expect(versions).toContain(6); // prompt tracking
|
||||
expect(versions).toContain(7); // remove unique constraint
|
||||
expect(versions).toContain(8); // hierarchical fields
|
||||
expect(versions).toContain(9); // text nullable
|
||||
expect(versions).toContain(10); // user_prompts
|
||||
expect(versions).toContain(11); // discovery_tokens
|
||||
expect(versions).toContain(16); // pending_messages
|
||||
expect(versions).toContain(17); // rename columns
|
||||
expect(versions).toContain(20); // failed_at_epoch
|
||||
expect(versions).toContain(21); // ON UPDATE CASCADE
|
||||
expect(versions).toContain(22); // content_hash
|
||||
expect(versions).toContain(30); // observations.metadata
|
||||
expect(versions).toContain(4);
|
||||
expect(versions).toContain(5);
|
||||
expect(versions).toContain(6);
|
||||
expect(versions).toContain(7);
|
||||
expect(versions).toContain(8);
|
||||
expect(versions).toContain(9);
|
||||
expect(versions).toContain(10);
|
||||
expect(versions).toContain(11);
|
||||
expect(versions).toContain(16);
|
||||
expect(versions).toContain(17);
|
||||
expect(versions).toContain(20);
|
||||
expect(versions).toContain(21);
|
||||
expect(versions).toContain(22);
|
||||
expect(versions).toContain(30);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,10 +132,8 @@ describe('MigrationRunner', () => {
|
||||
it('should succeed when run twice on the same database', () => {
|
||||
const runner = new MigrationRunner(db);
|
||||
|
||||
// First run
|
||||
runner.runAllMigrations();
|
||||
|
||||
// Second run — must not throw
|
||||
expect(() => runner.runAllMigrations()).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -206,8 +193,6 @@ describe('MigrationRunner', () => {
|
||||
|
||||
describe('issue #979 — old DatabaseManager version conflict', () => {
|
||||
it('should create core tables even when old migration versions 1-7 are in schema_versions', () => {
|
||||
// Simulate the old DatabaseManager having applied its migrations 1-7
|
||||
// (which are completely different operations with the same version numbers)
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS schema_versions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -221,7 +206,6 @@ describe('MigrationRunner', () => {
|
||||
db.prepare('INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)').run(v, now);
|
||||
}
|
||||
|
||||
// Now run MigrationRunner — core tables MUST still be created
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
@@ -234,9 +218,6 @@ describe('MigrationRunner', () => {
|
||||
});
|
||||
|
||||
it('should handle version 5 conflict (old=drop tables, new=add column) correctly', () => {
|
||||
// Old migration 5 drops streaming_sessions/observation_queue
|
||||
// New migration 5 adds worker_port column to sdk_sessions
|
||||
// With old version 5 already recorded, MigrationRunner must still add the column
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS schema_versions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -249,7 +230,6 @@ describe('MigrationRunner', () => {
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
// sdk_sessions should exist and have worker_port (added by later migrations even if v5 is skipped)
|
||||
const columns = getColumns(db, 'sdk_sessions');
|
||||
const columnNames = columns.map(c => c.name);
|
||||
expect(columnNames).toContain('content_session_id');
|
||||
@@ -261,7 +241,6 @@ describe('MigrationRunner', () => {
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
// Simulate a leftover temp table from a crash
|
||||
db.run(`
|
||||
CREATE TABLE session_summaries_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -269,10 +248,8 @@ describe('MigrationRunner', () => {
|
||||
)
|
||||
`);
|
||||
|
||||
// Remove version 7 so migration tries to re-run
|
||||
db.prepare('DELETE FROM schema_versions WHERE version = 7').run();
|
||||
|
||||
// Re-run should handle the leftover table gracefully
|
||||
expect(() => runner.runAllMigrations()).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -280,7 +257,6 @@ describe('MigrationRunner', () => {
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
// Simulate a leftover temp table from a crash
|
||||
db.run(`
|
||||
CREATE TABLE observations_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -288,10 +264,8 @@ describe('MigrationRunner', () => {
|
||||
)
|
||||
`);
|
||||
|
||||
// Remove version 9 so migration tries to re-run
|
||||
db.prepare('DELETE FROM schema_versions WHERE version = 9').run();
|
||||
|
||||
// Re-run should handle the leftover table gracefully
|
||||
expect(() => runner.runAllMigrations()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -327,7 +301,6 @@ describe('MigrationRunner', () => {
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
// Insert test data
|
||||
const now = new Date().toISOString();
|
||||
const epoch = Date.now();
|
||||
|
||||
@@ -346,7 +319,6 @@ describe('MigrationRunner', () => {
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run('test-memory-1', 'test-project', 'test request', now, epoch);
|
||||
|
||||
// Run migrations again — data should survive
|
||||
runner.runAllMigrations();
|
||||
|
||||
const sessions = db.prepare('SELECT COUNT(*) as count FROM sdk_sessions').get() as { count: number };
|
||||
|
||||
@@ -1,21 +1,3 @@
|
||||
/**
|
||||
* Tests for storeObservation subagent labeling (agent_type, agent_id).
|
||||
*
|
||||
* Validates:
|
||||
* 1. Rows carry agent_type / agent_id when set on ObservationInput.
|
||||
* 2. Omitted subagent fields store as NULL (main-session rows).
|
||||
* 3. Dedup is intentionally UNAFFECTED by agent_type — the content hash
|
||||
* covers (memory_session_id, title, narrative) only, so two observations
|
||||
* with the same semantic identity but different originating subagents
|
||||
* dedup to the same row. This preserves stable observation identity
|
||||
* across main-session and subagent contexts and is the documented
|
||||
* intended behavior per Phase 4 anti-pattern guard in the plan.
|
||||
*
|
||||
* Sources:
|
||||
* - Store: src/services/sqlite/observations/store.ts
|
||||
* - Types: src/services/sqlite/observations/types.ts
|
||||
* - Test pattern: tests/sqlite/observations.test.ts
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { ClaudeMemDatabase } from '../../../../src/services/sqlite/Database.js';
|
||||
import { storeObservation } from '../../../../src/services/sqlite/Observations.js';
|
||||
@@ -82,7 +64,6 @@ describe('storeObservation — subagent labeling', () => {
|
||||
it('stores NULL for agent_type and agent_id when fields are omitted (main-session row)', () => {
|
||||
const memorySessionId = createSessionWithMemoryId('content-main-1', 'mem-main-1');
|
||||
const input = createObservationInput();
|
||||
// input has no agent_type / agent_id
|
||||
|
||||
const result = storeObservation(db, memorySessionId, 'test-project', input);
|
||||
|
||||
@@ -113,11 +94,6 @@ describe('storeObservation — subagent labeling', () => {
|
||||
});
|
||||
|
||||
it('dedup is NOT affected by agent fields — second insert with different agent_type returns existing id', () => {
|
||||
// INTENDED BEHAVIOR (per plan Phase 4 anti-pattern guard):
|
||||
// The content hash covers (memory_session_id, title, narrative) only.
|
||||
// Two observations with identical title + narrative but different
|
||||
// agent_type must dedup to the same row so observation identity is
|
||||
// stable across main-session and subagent contexts.
|
||||
const memorySessionId = createSessionWithMemoryId('content-dedup-1', 'mem-dedup-1');
|
||||
|
||||
const first = storeObservation(
|
||||
@@ -144,7 +120,6 @@ describe('storeObservation — subagent labeling', () => {
|
||||
})
|
||||
);
|
||||
|
||||
// Second insert is deduped → same id, no new row, original agent fields preserved.
|
||||
expect(second.id).toBe(first.id);
|
||||
|
||||
const rowCount = db
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
/**
|
||||
* Tests for parseFileList (fix for #1359)
|
||||
*
|
||||
* Validates safe JSON array parsing for files_read/files_modified DB columns
|
||||
* that may contain legacy bare path strings instead of JSON arrays.
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { parseFileList } from '../../../src/services/sqlite/observations/files.js';
|
||||
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
/**
|
||||
* Tests for malformed schema repair in Database.ts
|
||||
*
|
||||
* Mock Justification: NONE (0% mock code)
|
||||
* - Uses real SQLite with temp file — tests actual schema repair logic
|
||||
* - Uses Python sqlite3 to simulate cross-version schema corruption
|
||||
* (bun:sqlite doesn't allow writable_schema modifications)
|
||||
* - Covers the cross-machine sync scenario from issue #1307
|
||||
*
|
||||
* Value: Prevents the silent 503 failure loop when a DB is synced between
|
||||
* machines running different claude-mem versions
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { ClaudeMemDatabase } from '../../../src/services/sqlite/Database.js';
|
||||
@@ -39,11 +27,6 @@ function hasPython(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use Python's sqlite3 to corrupt a DB by removing the content_hash column
|
||||
* from the observations table definition while leaving the index intact.
|
||||
* This simulates what happens when a DB from a newer version is synced.
|
||||
*/
|
||||
function corruptDbViaPython(dbPath: string): void {
|
||||
const script = join(tmpdir(), `corrupt-${Date.now()}.py`);
|
||||
writeFileSync(script, `
|
||||
@@ -74,7 +57,6 @@ describe('Schema repair on malformed database', () => {
|
||||
|
||||
const dbPath = tempDbPath();
|
||||
try {
|
||||
// Step 1: Create a valid database with all migrations
|
||||
const db = new Database(dbPath, { create: true, readwrite: true });
|
||||
db.run('PRAGMA journal_mode = WAL');
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
@@ -82,19 +64,15 @@ describe('Schema repair on malformed database', () => {
|
||||
const runner = new MigrationRunner(db);
|
||||
runner.runAllMigrations();
|
||||
|
||||
// Verify content_hash column and index exist
|
||||
const hasContentHash = db.prepare('PRAGMA table_info(observations)').all()
|
||||
.some((col: any) => col.name === 'content_hash');
|
||||
expect(hasContentHash).toBe(true);
|
||||
|
||||
// Checkpoint WAL so all data is in the main file
|
||||
db.run('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
db.close();
|
||||
|
||||
// Step 2: Corrupt the DB
|
||||
corruptDbViaPython(dbPath);
|
||||
|
||||
// Step 3: Verify the DB is actually corrupted
|
||||
const corruptDb = new Database(dbPath, { readwrite: true });
|
||||
let threw = false;
|
||||
try {
|
||||
@@ -107,22 +85,18 @@ describe('Schema repair on malformed database', () => {
|
||||
corruptDb.close();
|
||||
expect(threw).toBe(true);
|
||||
|
||||
// Step 4: Open via ClaudeMemDatabase — it should auto-repair
|
||||
const repaired = new ClaudeMemDatabase(dbPath);
|
||||
|
||||
// Verify the DB is functional
|
||||
const tables = repaired.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
const tableNames = tables.map(t => t.name);
|
||||
expect(tableNames).toContain('observations');
|
||||
expect(tableNames).toContain('sdk_sessions');
|
||||
|
||||
// Verify the index was recreated by the migration runner
|
||||
const indexes = repaired.db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_observations_content_hash'")
|
||||
.all() as { name: string }[];
|
||||
expect(indexes.length).toBe(1);
|
||||
|
||||
// Verify the content_hash column was re-added by the migration
|
||||
const columns = repaired.db.prepare('PRAGMA table_info(observations)').all() as { name: string }[];
|
||||
expect(columns.some(c => c.name === 'content_hash')).toBe(true);
|
||||
|
||||
@@ -154,9 +128,6 @@ describe('Schema repair on malformed database', () => {
|
||||
const dbPath = tempDbPath();
|
||||
const scriptPath = join(tmpdir(), `corrupt-nosv-${Date.now()}.py`);
|
||||
try {
|
||||
// Build a minimal DB with only a malformed observations table and orphaned index
|
||||
// — no schema_versions table. This simulates a partially-initialized DB that was
|
||||
// synced before migrations ever ran.
|
||||
writeFileSync(scriptPath, `
|
||||
import sqlite3, sys
|
||||
c = sqlite3.connect(sys.argv[1])
|
||||
@@ -175,7 +146,6 @@ c.close()
|
||||
`);
|
||||
execFileSync('python3', [scriptPath, dbPath], { timeout: 10000 });
|
||||
|
||||
// Verify it's corrupted
|
||||
const corruptDb = new Database(dbPath, { readwrite: true });
|
||||
let threw = false;
|
||||
try {
|
||||
@@ -187,7 +157,6 @@ c.close()
|
||||
corruptDb.close();
|
||||
expect(threw).toBe(true);
|
||||
|
||||
// ClaudeMemDatabase must repair and fully initialize despite missing schema_versions
|
||||
const repaired = new ClaudeMemDatabase(dbPath);
|
||||
const tables = repaired.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
@@ -210,7 +179,6 @@ c.close()
|
||||
|
||||
const dbPath = tempDbPath();
|
||||
try {
|
||||
// Step 1: Create a fully migrated DB and insert a session + observation
|
||||
const db = new Database(dbPath, { create: true, readwrite: true });
|
||||
db.run('PRAGMA journal_mode = WAL');
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
@@ -233,13 +201,10 @@ c.close()
|
||||
db.run('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
db.close();
|
||||
|
||||
// Step 2: Corrupt the DB
|
||||
corruptDbViaPython(dbPath);
|
||||
|
||||
// Step 3: Repair via ClaudeMemDatabase
|
||||
const repaired = new ClaudeMemDatabase(dbPath);
|
||||
|
||||
// Data must survive the repair + re-migration
|
||||
const sessions = repaired.db.prepare('SELECT COUNT(*) as count FROM sdk_sessions').get() as { count: number };
|
||||
const observations = repaired.db.prepare('SELECT COUNT(*) as count FROM observations').get() as { count: number };
|
||||
expect(sessions.count).toBe(1);
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { isDirectChild, normalizePath } from '../../../src/shared/path-utils.js';
|
||||
|
||||
/**
|
||||
* Tests for path matching logic, specifically the isDirectChild() algorithm
|
||||
* Covers fix for issue #794: Path format mismatch causes folder CLAUDE.md files to show "No recent activity"
|
||||
*
|
||||
* These tests validate the shared path-utils module which is used by:
|
||||
* - SessionSearch.ts (runtime folder CLAUDE.md generation)
|
||||
* - regenerate-claude-md.ts (CLI regeneration tool)
|
||||
*/
|
||||
|
||||
describe('isDirectChild path matching', () => {
|
||||
describe('same path format', () => {
|
||||
test('returns true for direct child with relative paths', () => {
|
||||
@@ -35,7 +26,6 @@ describe('isDirectChild path matching', () => {
|
||||
|
||||
describe('mixed path formats (absolute folder, relative file) - fixes #794', () => {
|
||||
test('returns true when absolute folder ends with relative file directory', () => {
|
||||
// This is the exact bug case from #794
|
||||
expect(isDirectChild('app/api/router.py', '/Users/dev/project/app/api')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -89,12 +79,10 @@ describe('isDirectChild path matching', () => {
|
||||
});
|
||||
|
||||
test('prevents false positive from partial segment match', () => {
|
||||
// "api" folder should not match "api-v2" folder
|
||||
expect(isDirectChild('app/api-v2/router.py', '/Users/dev/project/app/api')).toBe(false);
|
||||
});
|
||||
|
||||
test('handles similar folder names correctly', () => {
|
||||
// "components" should not match "components-old"
|
||||
expect(isDirectChild('src/components-old/Button.tsx', '/project/src/components')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
/**
|
||||
* Tests for SessionStore.markSessionCompleted (fix for #1532)
|
||||
*
|
||||
* Mock Justification: NONE (0% mock code)
|
||||
* - Uses real SQLite with ':memory:' - tests actual SQL and schema
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { SessionStore } from '../../../src/services/sqlite/SessionStore.js';
|
||||
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
import { describe, it, expect, beforeEach, mock, spyOn } from 'bun:test';
|
||||
|
||||
/**
|
||||
* Tests for Issue #1099: Stale AbortController queue stall prevention
|
||||
*
|
||||
* Validates that:
|
||||
* 1. ActiveSession tracks lastGeneratorActivity timestamp
|
||||
* 2. deleteSession uses a 30s timeout to prevent indefinite stalls
|
||||
* 3. Stale generators (>30s no activity) are detected and aborted
|
||||
* 4. processAgentResponse updates lastGeneratorActivity
|
||||
*/
|
||||
|
||||
describe('Stale AbortController Guard (#1099)', () => {
|
||||
describe('ActiveSession.lastGeneratorActivity', () => {
|
||||
it('should be defined in ActiveSession type', () => {
|
||||
// Verify the type includes lastGeneratorActivity
|
||||
const session = {
|
||||
sessionDbId: 1,
|
||||
contentSessionId: 'test',
|
||||
@@ -49,13 +38,13 @@ describe('Stale AbortController Guard (#1099)', () => {
|
||||
const STALE_THRESHOLD_MS = 30_000;
|
||||
|
||||
it('should detect generator as stale when no activity for >30s', () => {
|
||||
const lastActivity = Date.now() - 31_000; // 31 seconds ago
|
||||
const lastActivity = Date.now() - 31_000;
|
||||
const timeSinceActivity = Date.now() - lastActivity;
|
||||
expect(timeSinceActivity).toBeGreaterThan(STALE_THRESHOLD_MS);
|
||||
});
|
||||
|
||||
it('should NOT detect generator as stale when activity within 30s', () => {
|
||||
const lastActivity = Date.now() - 5_000; // 5 seconds ago
|
||||
const lastActivity = Date.now() - 5_000;
|
||||
const timeSinceActivity = Date.now() - lastActivity;
|
||||
expect(timeSinceActivity).toBeLessThan(STALE_THRESHOLD_MS);
|
||||
});
|
||||
@@ -67,13 +56,11 @@ describe('Stale AbortController Guard (#1099)', () => {
|
||||
generatorPromise: Promise.resolve() as Promise<void> | null,
|
||||
};
|
||||
|
||||
// Simulate stale recovery: abort, reset, restart
|
||||
session.abortController.abort();
|
||||
session.generatorPromise = null;
|
||||
session.abortController = new AbortController();
|
||||
session.lastGeneratorActivity = Date.now();
|
||||
|
||||
// After reset, should no longer be stale
|
||||
const timeSinceActivity = Date.now() - session.lastGeneratorActivity;
|
||||
expect(timeSinceActivity).toBeLessThan(STALE_THRESHOLD_MS);
|
||||
expect(session.abortController.signal.aborted).toBe(false);
|
||||
@@ -83,19 +70,17 @@ describe('Stale AbortController Guard (#1099)', () => {
|
||||
describe('AbortSignal.timeout for deleteSession', () => {
|
||||
it('should resolve timeout signal after specified ms', async () => {
|
||||
const start = Date.now();
|
||||
const timeoutMs = 50; // Use short timeout for test
|
||||
const timeoutMs = 50;
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
AbortSignal.timeout(timeoutMs).addEventListener('abort', () => resolve(), { once: true });
|
||||
});
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
// Allow some margin for timing
|
||||
expect(elapsed).toBeGreaterThanOrEqual(timeoutMs - 10);
|
||||
});
|
||||
|
||||
it('should race generator promise against timeout', async () => {
|
||||
// Simulate a hung generator (never resolves)
|
||||
const hungGenerator = new Promise<void>(() => {});
|
||||
const timeoutMs = 50;
|
||||
|
||||
@@ -110,7 +95,6 @@ describe('Stale AbortController Guard (#1099)', () => {
|
||||
});
|
||||
|
||||
it('should prefer generator completion over timeout when fast', async () => {
|
||||
// Simulate a generator that resolves quickly
|
||||
const fastGenerator = Promise.resolve('generator');
|
||||
const timeoutMs = 5000;
|
||||
|
||||
|
||||
@@ -3,38 +3,20 @@ import os from 'os';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* Regression test for issue #1297.
|
||||
*
|
||||
* When the worker spawns chroma-mcp via StdioClientTransport, if the CWD is
|
||||
* the project directory and that directory contains a .env.local file with
|
||||
* non-chroma env vars, pydantic-settings crashes with "Extra inputs are not
|
||||
* permitted". The fix is to set `cwd: os.homedir()` so pydantic never reads
|
||||
* the project's env files.
|
||||
*/
|
||||
|
||||
const CHROMA_MCP_MANAGER_PATH = join(
|
||||
import.meta.dir, '..', '..', '..', 'src', 'services', 'sync', 'ChromaMcpManager.ts'
|
||||
);
|
||||
|
||||
describe('ChromaMcpManager: cwd isolation from project .env files (#1297)', () => {
|
||||
it('StdioClientTransport is constructed with cwd set to homedir', () => {
|
||||
// Source-level assertion: verify the fix is present in the source.
|
||||
// ChromaMcpManager uses StdioClientTransport (from @modelcontextprotocol/sdk),
|
||||
// which we cannot easily import in a unit test without spawning a real process.
|
||||
// A source inspection is the appropriate guardrail here.
|
||||
const source = readFileSync(CHROMA_MCP_MANAGER_PATH, 'utf-8');
|
||||
|
||||
// The StdioClientTransport constructor call must include `cwd: os.homedir()`
|
||||
// (or equivalent) so that pydantic-settings in chroma-mcp does not read
|
||||
// .env.local from the project directory.
|
||||
expect(source).toContain('cwd: os.homedir()');
|
||||
});
|
||||
|
||||
it('the cwd property appears inside the StdioClientTransport constructor call', () => {
|
||||
const source = readFileSync(CHROMA_MCP_MANAGER_PATH, 'utf-8');
|
||||
|
||||
// Locate the StdioClientTransport constructor block and verify cwd is in it.
|
||||
const transportBlockMatch = source.match(
|
||||
/new StdioClientTransport\(\s*\{([\s\S]*?)\}\s*\)/
|
||||
);
|
||||
@@ -47,7 +29,6 @@ describe('ChromaMcpManager: cwd isolation from project .env files (#1297)', () =
|
||||
|
||||
it('os module is imported (required for os.homedir())', () => {
|
||||
const source = readFileSync(CHROMA_MCP_MANAGER_PATH, 'utf-8');
|
||||
// os is already imported in the original file — confirm it's still there
|
||||
expect(source).toMatch(/import os from ['"]os['"]/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
/**
|
||||
* Regression tests for ChromaMcpManager SSL flag handling (PR #1286)
|
||||
*
|
||||
* Validates that buildCommandArgs() always emits the correct `--ssl` flag
|
||||
* based on CLAUDE_MEM_CHROMA_SSL, and omits it entirely in local mode.
|
||||
*
|
||||
* Strategy: mock StdioClientTransport to capture the spawned args without
|
||||
* actually launching a subprocess, then inspect the captured args array.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, mock } from 'bun:test';
|
||||
|
||||
// ── Mutable settings closure (updated per test) ────────────────────────
|
||||
let currentSettings: Record<string, string> = {};
|
||||
|
||||
// ── Mock modules BEFORE importing the module under test ────────────────
|
||||
// Capture the args passed to StdioClientTransport constructor
|
||||
let capturedTransportOpts: { command: string; args: string[] } | null = null;
|
||||
|
||||
mock.module('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
||||
StdioClientTransport: class FakeTransport {
|
||||
// Required: ChromaMcpManager assigns transport.onclose after connect()
|
||||
onclose: (() => void) | null = null;
|
||||
constructor(opts: { command: string; args: string[] }) {
|
||||
capturedTransportOpts = { command: opts.command, args: opts.args };
|
||||
@@ -60,10 +47,8 @@ mock.module('../../../src/utils/logger.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Now import the module under test ───────────────────────────────────
|
||||
import { ChromaMcpManager } from '../../../src/services/sync/ChromaMcpManager.js';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
async function assertSslFlag(sslSetting: string | undefined, expectedValue: string) {
|
||||
currentSettings = { CLAUDE_MEM_CHROMA_MODE: 'remote' };
|
||||
if (sslSetting !== undefined) currentSettings.CLAUDE_MEM_CHROMA_SSL = sslSetting;
|
||||
@@ -78,7 +63,6 @@ async function assertSslFlag(sslSetting: string | undefined, expectedValue: stri
|
||||
|
||||
let mgr: ChromaMcpManager;
|
||||
|
||||
// ── Test suite ─────────────────────────────────────────────────────────
|
||||
describe('ChromaMcpManager SSL flag regression (#1286)', () => {
|
||||
beforeEach(async () => {
|
||||
await ChromaMcpManager.reset();
|
||||
|
||||
@@ -2,18 +2,6 @@ import { describe, it, expect } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* Source-inspection tests for Issue #1447: Worker startup race condition
|
||||
*
|
||||
* When the MCP server and SessionStart hook both spawn a daemon concurrently,
|
||||
* one daemon loses the port bind race (EADDRINUSE / Bun's "port in use" error).
|
||||
* The loser should detect this, verify the winner is healthy, and exit cleanly
|
||||
* instead of logging an ERROR that clutters the user's session start output.
|
||||
*
|
||||
* These are source-inspection tests because the race is non-deterministic and
|
||||
* requires a real concurrent multi-process scenario to reproduce reliably.
|
||||
*/
|
||||
|
||||
const WORKER_SERVICE_PATH = join(import.meta.dir, '../../src/services/worker-service.ts');
|
||||
const source = readFileSync(WORKER_SERVICE_PATH, 'utf-8');
|
||||
|
||||
@@ -27,18 +15,14 @@ describe('Worker daemon port-race guard (#1447)', () => {
|
||||
});
|
||||
|
||||
it('calls waitForHealth before exiting on a port conflict', () => {
|
||||
// The guard must verify the winner is actually healthy before exiting,
|
||||
// otherwise a non-worker process on the port would suppress a real error.
|
||||
expect(source).toContain('isPortConflict && await waitForHealth(port,');
|
||||
});
|
||||
|
||||
it('uses async catch handler to allow awaiting waitForHealth', () => {
|
||||
// The .catch() must be async so it can await the health check.
|
||||
expect(source).toContain('worker.start().catch(async (error) =>');
|
||||
});
|
||||
|
||||
it('logs info (not error) when cleanly exiting after port race', () => {
|
||||
// Must not call logger.failure() / logger.error() on the clean exit path.
|
||||
expect(source).toContain("logger.info('SYSTEM', 'Duplicate daemon exiting");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
/**
|
||||
* Tests for worker-spawner.ts validation guards.
|
||||
*
|
||||
* These tests cover the entry-point defensive guards in `ensureWorkerStarted`
|
||||
* (empty workerScriptPath, non-existent workerScriptPath). The deeper spawn
|
||||
* lifecycle (PID file cleanup, health checks, daemon spawn, readiness wait)
|
||||
* is not unit-tested here because it requires injectable I/O and a broader
|
||||
* refactor — see PR #1645 review feedback discussion.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { ensureWorkerStarted } from '../../src/services/worker-spawner.js';
|
||||
|
||||
describe('ensureWorkerStarted validation guards', () => {
|
||||
// The port arguments here are arbitrary — both tests short-circuit on the
|
||||
// workerScriptPath validation guards before any network/health-check I/O,
|
||||
// so the port is never actually bound or contacted. Picked from an unlikely
|
||||
// range to prevent confusion if a future test ever does run real health
|
||||
// checks against these instances.
|
||||
|
||||
it('returns false when workerScriptPath is empty string', async () => {
|
||||
const result = await ensureWorkerStarted(39001, '');
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { SessionStore } from '../src/services/sqlite/SessionStore.js';
|
||||
|
||||
/**
|
||||
* Session ID Usage Validation - Smoke Tests for Critical Invariants
|
||||
*
|
||||
* These tests validate the most critical behaviors of the dual session ID system:
|
||||
* - contentSessionId: User's Claude Code conversation session (immutable)
|
||||
* - memorySessionId: SDK agent's session ID for resume (captured from SDK response)
|
||||
*
|
||||
* CRITICAL INVARIANTS:
|
||||
* 1. Cross-contamination prevention: Observations from different sessions never mix
|
||||
* 2. Resume safety: Resume only allowed when memorySessionId is actually captured (non-NULL)
|
||||
* 3. 1:1 mapping: Each contentSessionId maps to exactly one memorySessionId
|
||||
*/
|
||||
describe('Session ID Critical Invariants', () => {
|
||||
let store: SessionStore;
|
||||
|
||||
@@ -26,7 +14,6 @@ describe('Session ID Critical Invariants', () => {
|
||||
|
||||
describe('Cross-Contamination Prevention', () => {
|
||||
it('should never mix observations from different content sessions', () => {
|
||||
// Create two independent sessions
|
||||
const content1 = 'user-session-A';
|
||||
const content2 = 'user-session-B';
|
||||
const memory1 = 'memory-session-A';
|
||||
@@ -37,7 +24,6 @@ describe('Session ID Critical Invariants', () => {
|
||||
store.updateMemorySessionId(id1, memory1);
|
||||
store.updateMemorySessionId(id2, memory2);
|
||||
|
||||
// Store observations in each session
|
||||
store.storeObservation(memory1, 'project-a', {
|
||||
type: 'discovery',
|
||||
title: 'Observation A',
|
||||
@@ -60,7 +46,6 @@ describe('Session ID Critical Invariants', () => {
|
||||
files_modified: []
|
||||
}, 1);
|
||||
|
||||
// CRITICAL: Each session's observations must be isolated
|
||||
const obsA = store.getObservationsForSession(memory1);
|
||||
const obsB = store.getObservationsForSession(memory2);
|
||||
|
||||
@@ -69,7 +54,6 @@ describe('Session ID Critical Invariants', () => {
|
||||
expect(obsA[0].title).toBe('Observation A');
|
||||
expect(obsB[0].title).toBe('Observation B');
|
||||
|
||||
// Verify no cross-contamination: A's query doesn't return B's data
|
||||
expect(obsA.some(o => o.title === 'Observation B')).toBe(false);
|
||||
expect(obsB.some(o => o.title === 'Observation A')).toBe(false);
|
||||
});
|
||||
@@ -82,14 +66,11 @@ describe('Session ID Critical Invariants', () => {
|
||||
|
||||
const session = store.getSessionById(sessionDbId);
|
||||
|
||||
// CRITICAL: Before SDK returns real session ID, memory_session_id must be NULL
|
||||
expect(session?.memory_session_id).toBeNull();
|
||||
|
||||
// hasRealMemorySessionId check: only resume when non-NULL
|
||||
const hasRealMemorySessionId = session?.memory_session_id !== null;
|
||||
expect(hasRealMemorySessionId).toBe(false);
|
||||
|
||||
// Resume options should be empty (no resume parameter)
|
||||
const resumeOptions = hasRealMemorySessionId ? { resume: session?.memory_session_id } : {};
|
||||
expect(resumeOptions).toEqual({});
|
||||
});
|
||||
@@ -100,14 +81,11 @@ describe('Session ID Critical Invariants', () => {
|
||||
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Prompt');
|
||||
|
||||
// Before capture
|
||||
let session = store.getSessionById(sessionDbId);
|
||||
expect(session?.memory_session_id).toBeNull();
|
||||
|
||||
// Capture memory session ID (simulates SDK response)
|
||||
store.updateMemorySessionId(sessionDbId, capturedMemoryId);
|
||||
|
||||
// After capture
|
||||
session = store.getSessionById(sessionDbId);
|
||||
const hasRealMemorySessionId = session?.memory_session_id !== null;
|
||||
|
||||
@@ -117,40 +95,30 @@ describe('Session ID Critical Invariants', () => {
|
||||
});
|
||||
|
||||
it('should preserve memorySessionId across createSDKSession calls (pure get-or-create)', () => {
|
||||
// createSDKSession is a pure get-or-create: it never modifies memory_session_id.
|
||||
// Multi-terminal isolation is handled by ON UPDATE CASCADE at the schema level,
|
||||
// and ensureMemorySessionIdRegistered updates the ID when a new generator captures one.
|
||||
const contentSessionId = 'multi-prompt-session';
|
||||
const firstMemoryId = 'first-generator-memory-id';
|
||||
|
||||
// First generator creates session and captures memory ID
|
||||
let sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Prompt 1');
|
||||
store.updateMemorySessionId(sessionDbId, firstMemoryId);
|
||||
let session = store.getSessionById(sessionDbId);
|
||||
expect(session?.memory_session_id).toBe(firstMemoryId);
|
||||
|
||||
// Second createSDKSession call preserves memory_session_id (no reset)
|
||||
sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Prompt 2');
|
||||
session = store.getSessionById(sessionDbId);
|
||||
expect(session?.memory_session_id).toBe(firstMemoryId); // Preserved, not reset
|
||||
expect(session?.memory_session_id).toBe(firstMemoryId);
|
||||
|
||||
// ensureMemorySessionIdRegistered can update to a new ID (ON UPDATE CASCADE handles FK)
|
||||
store.ensureMemorySessionIdRegistered(sessionDbId, 'second-generator-memory-id');
|
||||
session = store.getSessionById(sessionDbId);
|
||||
expect(session?.memory_session_id).toBe('second-generator-memory-id');
|
||||
});
|
||||
|
||||
it('should NOT reset memorySessionId when it is still NULL (first prompt scenario)', () => {
|
||||
// When memory_session_id is NULL, createSDKSession should NOT reset it
|
||||
// This is the normal first-prompt scenario where SDKAgent hasn't captured the ID yet
|
||||
const contentSessionId = 'new-session';
|
||||
|
||||
// First createSDKSession - creates row with NULL memory_session_id
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, 'test-project', 'Prompt 1');
|
||||
let session = store.getSessionById(sessionDbId);
|
||||
expect(session?.memory_session_id).toBeNull();
|
||||
|
||||
// Second createSDKSession (before SDK has returned) - should still be NULL, no reset needed
|
||||
store.createSDKSession(contentSessionId, 'test-project', 'Prompt 2');
|
||||
session = store.getSessionById(sessionDbId);
|
||||
expect(session?.memory_session_id).toBeNull();
|
||||
@@ -166,15 +134,12 @@ describe('Session ID Critical Invariants', () => {
|
||||
const id1 = store.createSDKSession(content1, 'project', 'Prompt 1');
|
||||
const id2 = store.createSDKSession(content2, 'project', 'Prompt 2');
|
||||
|
||||
// First session captures memory ID - should succeed
|
||||
store.updateMemorySessionId(id1, sharedMemoryId);
|
||||
|
||||
// Second session tries to use SAME memory ID - should FAIL
|
||||
expect(() => {
|
||||
store.updateMemorySessionId(id2, sharedMemoryId);
|
||||
}).toThrow(); // UNIQUE constraint violation
|
||||
}).toThrow();
|
||||
|
||||
// First session still has the ID
|
||||
const session1 = store.getSessionById(id1);
|
||||
expect(session1?.memory_session_id).toBe(sharedMemoryId);
|
||||
});
|
||||
@@ -193,7 +158,7 @@ describe('Session ID Critical Invariants', () => {
|
||||
files_read: [],
|
||||
files_modified: []
|
||||
}, 1);
|
||||
}).toThrow(); // FK constraint violation
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
/**
|
||||
* Tests for SessionStore in-memory database operations
|
||||
*
|
||||
* Mock Justification: NONE (0% mock code)
|
||||
* - Uses real SQLite with ':memory:' - tests actual SQL and schema
|
||||
* - All CRUD operations are tested against real database behavior
|
||||
* - Timestamp handling and FK relationships are validated
|
||||
*
|
||||
* Value: Validates core persistence layer without filesystem dependencies
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { SessionStore } from '../src/services/sqlite/SessionStore.js';
|
||||
|
||||
@@ -26,18 +16,14 @@ describe('SessionStore', () => {
|
||||
const claudeId = 'claude-session-1';
|
||||
store.createSDKSession(claudeId, 'test-project', 'initial prompt');
|
||||
|
||||
// Should be 0 initially
|
||||
expect(store.getPromptNumberFromUserPrompts(claudeId)).toBe(0);
|
||||
|
||||
// Save prompt 1
|
||||
store.saveUserPrompt(claudeId, 1, 'First prompt');
|
||||
expect(store.getPromptNumberFromUserPrompts(claudeId)).toBe(1);
|
||||
|
||||
// Save prompt 2
|
||||
store.saveUserPrompt(claudeId, 2, 'Second prompt');
|
||||
expect(store.getPromptNumberFromUserPrompts(claudeId)).toBe(2);
|
||||
|
||||
// Save prompt for another session
|
||||
store.createSDKSession('claude-session-2', 'test-project', 'initial prompt');
|
||||
store.saveUserPrompt('claude-session-2', 1, 'Other prompt');
|
||||
expect(store.getPromptNumberFromUserPrompts(claudeId)).toBe(2);
|
||||
@@ -48,8 +34,6 @@ describe('SessionStore', () => {
|
||||
const memoryId = 'memory-sess-obs';
|
||||
const sdkId = store.createSDKSession(claudeId, 'test-project', 'initial prompt');
|
||||
|
||||
// Set the memory_session_id before storing observations
|
||||
// createSDKSession now initializes memory_session_id = NULL
|
||||
store.updateMemorySessionId(sdkId, memoryId);
|
||||
|
||||
const obs = {
|
||||
@@ -63,7 +47,7 @@ describe('SessionStore', () => {
|
||||
files_modified: []
|
||||
};
|
||||
|
||||
const pastTimestamp = 1600000000000; // Some time in the past
|
||||
const pastTimestamp = 1600000000000;
|
||||
|
||||
const result = store.storeObservation(
|
||||
memoryId, // Use memorySessionId for FK reference
|
||||
@@ -80,7 +64,6 @@ describe('SessionStore', () => {
|
||||
expect(stored).not.toBeNull();
|
||||
expect(stored?.created_at_epoch).toBe(pastTimestamp);
|
||||
|
||||
// Verify ISO string matches
|
||||
expect(new Date(stored!.created_at).getTime()).toBe(pastTimestamp);
|
||||
});
|
||||
|
||||
@@ -89,7 +72,6 @@ describe('SessionStore', () => {
|
||||
const memoryId = 'memory-sess-sum';
|
||||
const sdkId = store.createSDKSession(claudeId, 'test-project', 'initial prompt');
|
||||
|
||||
// Set the memory_session_id before storing summaries
|
||||
store.updateMemorySessionId(sdkId, memoryId);
|
||||
|
||||
const summary = {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import {
|
||||
readInstallMarker,
|
||||
writeInstallMarker,
|
||||
isInstallCurrent,
|
||||
} from '../src/npx-cli/install/setup-runtime';
|
||||
|
||||
function probeBunVersion(): string | null {
|
||||
try {
|
||||
const result = spawnSync('bun', ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
return result.status === 0 ? result.stdout.trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe('setup-runtime install marker', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(
|
||||
tmpdir(),
|
||||
`setup-runtime-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('readInstallMarker', () => {
|
||||
it('returns null when marker file is missing', () => {
|
||||
expect(readInstallMarker(tempDir)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when marker file is invalid JSON', () => {
|
||||
writeFileSync(join(tempDir, '.install-version'), 'not valid json');
|
||||
expect(readInstallMarker(tempDir)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns parsed marker when file is valid', () => {
|
||||
writeInstallMarker(tempDir, '1.2.3', '1.0.0', '0.5.0');
|
||||
const marker = readInstallMarker(tempDir);
|
||||
expect(marker).not.toBeNull();
|
||||
expect(marker?.version).toBe('1.2.3');
|
||||
expect(marker?.bun).toBe('1.0.0');
|
||||
expect(marker?.uv).toBe('0.5.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeInstallMarker', () => {
|
||||
it('writes a JSON file with the canonical schema { version, bun, uv, installedAt }', () => {
|
||||
writeInstallMarker(tempDir, '12.4.7', '1.2.0', '0.4.18');
|
||||
|
||||
const path = join(tempDir, '.install-version');
|
||||
expect(existsSync(path)).toBe(true);
|
||||
|
||||
const parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
||||
expect(parsed.version).toBe('12.4.7');
|
||||
expect(parsed.bun).toBe('1.2.0');
|
||||
expect(parsed.uv).toBe('0.4.18');
|
||||
expect(typeof parsed.installedAt).toBe('string');
|
||||
expect(() => new Date(parsed.installedAt).toISOString()).not.toThrow();
|
||||
});
|
||||
|
||||
it('only writes the four documented fields', () => {
|
||||
writeInstallMarker(tempDir, '1.0.0', '1.0.0', '0.1.0');
|
||||
const parsed = JSON.parse(readFileSync(join(tempDir, '.install-version'), 'utf-8'));
|
||||
expect(Object.keys(parsed).sort()).toEqual(['bun', 'installedAt', 'uv', 'version'].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInstallCurrent', () => {
|
||||
it('returns false when node_modules is missing', () => {
|
||||
writeInstallMarker(tempDir, '1.0.0', '1.0.0', '0.1.0');
|
||||
expect(isInstallCurrent(tempDir, '1.0.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when marker is missing (but node_modules exists)', () => {
|
||||
mkdirSync(join(tempDir, 'node_modules'));
|
||||
expect(isInstallCurrent(tempDir, '1.0.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when marker version does not match expected', () => {
|
||||
mkdirSync(join(tempDir, 'node_modules'));
|
||||
const bunVersion = probeBunVersion() ?? '1.0.0';
|
||||
writeInstallMarker(tempDir, '1.0.0', bunVersion, '0.1.0');
|
||||
expect(isInstallCurrent(tempDir, '2.0.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when marker matches version and bun version matches', () => {
|
||||
const bunVersion = probeBunVersion();
|
||||
if (!bunVersion) {
|
||||
return;
|
||||
}
|
||||
mkdirSync(join(tempDir, 'node_modules'));
|
||||
writeInstallMarker(tempDir, '1.0.0', bunVersion, '0.1.0');
|
||||
expect(isInstallCurrent(tempDir, '1.0.0')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,3 @@
|
||||
/**
|
||||
* SettingsDefaultsManager Tests
|
||||
*
|
||||
* Tests for the settings file auto-creation feature in loadFromFile().
|
||||
* Uses temp directories for file system isolation.
|
||||
*
|
||||
* Test cases:
|
||||
* 1. File doesn't exist - should create file with defaults and return defaults
|
||||
* 2. File exists with valid content - should return parsed content
|
||||
* 3. File exists but is empty/corrupt - should return defaults
|
||||
* 4. Directory doesn't exist - should create directory and file
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
|
||||
@@ -22,14 +10,12 @@ describe('SettingsDefaultsManager', () => {
|
||||
let settingsPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test
|
||||
tempDir = join(tmpdir(), `settings-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
settingsPath = join(tempDir, 'settings.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up temp directory
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
@@ -113,7 +99,6 @@ describe('SettingsDefaultsManager', () => {
|
||||
});
|
||||
|
||||
it('should merge file settings with defaults for missing keys', () => {
|
||||
// Only set one value, defaults should fill the rest
|
||||
const partialSettings = {
|
||||
CLAUDE_MEM_MODEL: 'partial-model',
|
||||
};
|
||||
@@ -123,7 +108,6 @@ describe('SettingsDefaultsManager', () => {
|
||||
const defaults = SettingsDefaultsManager.getAllDefaults();
|
||||
|
||||
expect(result.CLAUDE_MEM_MODEL).toBe('partial-model');
|
||||
// Other values should come from defaults
|
||||
expect(result.CLAUDE_MEM_WORKER_PORT).toBe(defaults.CLAUDE_MEM_WORKER_PORT);
|
||||
expect(result.CLAUDE_MEM_WORKER_HOST).toBe(defaults.CLAUDE_MEM_WORKER_HOST);
|
||||
expect(result.CLAUDE_MEM_LOG_LEVEL).toBe(defaults.CLAUDE_MEM_LOG_LEVEL);
|
||||
@@ -232,7 +216,6 @@ describe('SettingsDefaultsManager', () => {
|
||||
|
||||
SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
// File should now be flat schema
|
||||
const content = readFileSync(settingsPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.env).toBeUndefined();
|
||||
@@ -268,12 +251,8 @@ describe('SettingsDefaultsManager', () => {
|
||||
const settings = { CLAUDE_MEM_MODEL: 'bom-model' };
|
||||
writeFileSync(settingsPath, bom + JSON.stringify(settings));
|
||||
|
||||
// JSON.parse handles BOM, but let's verify behavior
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
// If it fails to parse due to BOM, it should return defaults
|
||||
// If it succeeds, it should return the parsed value
|
||||
// Either way, should not throw
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -285,23 +264,20 @@ describe('SettingsDefaultsManager', () => {
|
||||
const defaults2 = SettingsDefaultsManager.getAllDefaults();
|
||||
|
||||
expect(defaults1).toEqual(defaults2);
|
||||
expect(defaults1).not.toBe(defaults2); // Different object references
|
||||
expect(defaults1).not.toBe(defaults2);
|
||||
});
|
||||
|
||||
it('should include all expected keys', () => {
|
||||
const defaults = SettingsDefaultsManager.getAllDefaults();
|
||||
|
||||
// Core settings
|
||||
expect(defaults.CLAUDE_MEM_MODEL).toBeDefined();
|
||||
expect(defaults.CLAUDE_MEM_WORKER_PORT).toBeDefined();
|
||||
expect(defaults.CLAUDE_MEM_WORKER_HOST).toBeDefined();
|
||||
|
||||
// Provider settings
|
||||
expect(defaults.CLAUDE_MEM_PROVIDER).toBeDefined();
|
||||
expect(defaults.CLAUDE_MEM_GEMINI_API_KEY).toBeDefined();
|
||||
expect(defaults.CLAUDE_MEM_OPENROUTER_API_KEY).toBeDefined();
|
||||
|
||||
// System settings
|
||||
expect(defaults.CLAUDE_MEM_DATA_DIR).toBeDefined();
|
||||
expect(defaults.CLAUDE_MEM_LOG_LEVEL).toBeDefined();
|
||||
});
|
||||
@@ -310,7 +286,6 @@ describe('SettingsDefaultsManager', () => {
|
||||
describe('get', () => {
|
||||
it('should return default value for key', () => {
|
||||
expect(SettingsDefaultsManager.get('CLAUDE_MEM_MODEL')).toBe('claude-sonnet-4-6');
|
||||
// Per-UID port: 37700 + (uid % 100). See SettingsDefaultsManager.ts.
|
||||
const expectedPort = String(37700 + ((process.getuid?.() ?? 77) % 100));
|
||||
expect(SettingsDefaultsManager.get('CLAUDE_MEM_WORKER_PORT')).toBe(expectedPort);
|
||||
});
|
||||
@@ -338,14 +313,12 @@ describe('SettingsDefaultsManager', () => {
|
||||
const originalEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original env values
|
||||
originalEnv.CLAUDE_MEM_WORKER_PORT = process.env.CLAUDE_MEM_WORKER_PORT;
|
||||
originalEnv.CLAUDE_MEM_MODEL = process.env.CLAUDE_MEM_MODEL;
|
||||
originalEnv.CLAUDE_MEM_LOG_LEVEL = process.env.CLAUDE_MEM_LOG_LEVEL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env values
|
||||
if (originalEnv.CLAUDE_MEM_WORKER_PORT === undefined) {
|
||||
delete process.env.CLAUDE_MEM_WORKER_PORT;
|
||||
} else {
|
||||
@@ -364,7 +337,6 @@ describe('SettingsDefaultsManager', () => {
|
||||
});
|
||||
|
||||
it('should prioritize env var over file setting', () => {
|
||||
// File has port 12345, env var has 54321
|
||||
const fileSettings = {
|
||||
CLAUDE_MEM_WORKER_PORT: '12345',
|
||||
};
|
||||
@@ -377,7 +349,6 @@ describe('SettingsDefaultsManager', () => {
|
||||
});
|
||||
|
||||
it('should prioritize env var over default', () => {
|
||||
// No file, env var set
|
||||
process.env.CLAUDE_MEM_WORKER_PORT = '99999';
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
@@ -416,36 +387,29 @@ describe('SettingsDefaultsManager', () => {
|
||||
|
||||
process.env.CLAUDE_MEM_WORKER_PORT = '54321';
|
||||
process.env.CLAUDE_MEM_MODEL = 'env-model';
|
||||
// LOG_LEVEL not set in env, should use file value
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(result.CLAUDE_MEM_WORKER_PORT).toBe('54321');
|
||||
expect(result.CLAUDE_MEM_MODEL).toBe('env-model');
|
||||
expect(result.CLAUDE_MEM_LOG_LEVEL).toBe('DEBUG'); // From file
|
||||
expect(result.CLAUDE_MEM_LOG_LEVEL).toBe('DEBUG');
|
||||
});
|
||||
|
||||
it('should document priority: env > file > defaults', () => {
|
||||
// This test documents the expected priority order
|
||||
const defaults = SettingsDefaultsManager.getAllDefaults();
|
||||
|
||||
// Set file to something different from default
|
||||
const fileSettings = {
|
||||
CLAUDE_MEM_WORKER_PORT: '22222', // Different from default 37777
|
||||
};
|
||||
writeFileSync(settingsPath, JSON.stringify(fileSettings));
|
||||
|
||||
// Set env to something different from both
|
||||
process.env.CLAUDE_MEM_WORKER_PORT = '33333';
|
||||
|
||||
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
// Priority check:
|
||||
// Default is per-UID (37700 + uid%100), file is 22222, env is 33333
|
||||
// Result should be env (33333) because env > file > default
|
||||
const expectedDefault = String(37700 + ((process.getuid?.() ?? 77) % 100));
|
||||
expect(defaults.CLAUDE_MEM_WORKER_PORT).toBe(expectedDefault); // Confirm default
|
||||
expect(result.CLAUDE_MEM_WORKER_PORT).toBe('33333'); // Env wins
|
||||
expect(defaults.CLAUDE_MEM_WORKER_PORT).toBe(expectedDefault);
|
||||
expect(result.CLAUDE_MEM_WORKER_PORT).toBe('33333');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, it, expect, mock, afterEach } from 'bun:test';
|
||||
|
||||
// Mock logger BEFORE imports (required pattern)
|
||||
mock.module('../../src/utils/logger.js', () => ({
|
||||
logger: {
|
||||
info: () => {},
|
||||
@@ -11,7 +10,6 @@ mock.module('../../src/utils/logger.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
import { extractFirstFile, groupByDate } from '../../src/shared/timeline-formatting.js';
|
||||
|
||||
afterEach(() => {
|
||||
@@ -120,7 +118,6 @@ describe('groupByDate', () => {
|
||||
|
||||
const dates = Array.from(result.keys());
|
||||
expect(dates).toHaveLength(3);
|
||||
// Dates should be in chronological order (oldest first)
|
||||
expect(dates[0]).toContain('Jan 4');
|
||||
expect(dates[1]).toContain('Jan 5');
|
||||
expect(dates[2]).toContain('Jan 6');
|
||||
@@ -167,7 +164,6 @@ describe('groupByDate', () => {
|
||||
});
|
||||
|
||||
it('should handle numeric timestamps as date input', () => {
|
||||
// Use clearly different dates (24+ hours apart to avoid timezone issues)
|
||||
const items = [
|
||||
{ id: 1, date: '2025-01-04T00:00:00Z' },
|
||||
{ id: 2, date: '2025-01-06T00:00:00Z' }, // 2 days later
|
||||
@@ -192,7 +188,6 @@ describe('groupByDate', () => {
|
||||
const result = groupByDate(items, (item) => item.date);
|
||||
|
||||
const dayItems = Array.from(result.values())[0];
|
||||
// Items should maintain their insertion order
|
||||
expect(dayItems.map(i => i.id)).toEqual([3, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdirSync, writeFileSync, existsSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { SettingsDefaultsManager } from '../../src/shared/SettingsDefaultsManager.js';
|
||||
|
||||
describe('CLAUDE_MEM_WELCOME_HINT_ENABLED default', () => {
|
||||
let tempDir: string;
|
||||
let settingsPath: string;
|
||||
let originalEnvValue: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = join(tmpdir(), `welcome-hint-default-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
settingsPath = join(tempDir, 'settings.json');
|
||||
originalEnvValue = process.env.CLAUDE_MEM_WELCOME_HINT_ENABLED;
|
||||
delete process.env.CLAUDE_MEM_WELCOME_HINT_ENABLED;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnvValue === undefined) {
|
||||
delete process.env.CLAUDE_MEM_WELCOME_HINT_ENABLED;
|
||||
} else {
|
||||
process.env.CLAUDE_MEM_WELCOME_HINT_ENABLED = originalEnvValue;
|
||||
}
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it('is set to "true" in getAllDefaults()', () => {
|
||||
const defaults = SettingsDefaultsManager.getAllDefaults();
|
||||
expect(defaults.CLAUDE_MEM_WELCOME_HINT_ENABLED).toBe('true');
|
||||
});
|
||||
|
||||
it('resolves to "true" when settings file is missing (auto-created with defaults)', () => {
|
||||
expect(existsSync(settingsPath)).toBe(false);
|
||||
|
||||
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(settings.CLAUDE_MEM_WELCOME_HINT_ENABLED).toBe('true');
|
||||
expect(existsSync(settingsPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves to "true" when settings file is empty JSON object', () => {
|
||||
writeFileSync(settingsPath, '{}', 'utf-8');
|
||||
|
||||
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(settings.CLAUDE_MEM_WELCOME_HINT_ENABLED).toBe('true');
|
||||
});
|
||||
|
||||
it('preserves an explicit "false" value through loadFromFile', () => {
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({ CLAUDE_MEM_WELCOME_HINT_ENABLED: 'false' }, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
expect(settings.CLAUDE_MEM_WELCOME_HINT_ENABLED).toBe('false');
|
||||
});
|
||||
});
|
||||
@@ -1,356 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { checkBinaryPlatformCompatibility } from '../plugin/scripts/smart-install.js';
|
||||
|
||||
/**
|
||||
* Smart Install Script Tests
|
||||
*
|
||||
* Tests the resolveRoot() and verifyCriticalModules() logic used by
|
||||
* plugin/scripts/smart-install.js to find the correct install directory
|
||||
* for cache-based and marketplace installs.
|
||||
*
|
||||
* These are unit tests that exercise the resolution logic in isolation
|
||||
* using temp directories, without running actual bun/npm install.
|
||||
*/
|
||||
|
||||
const TEST_DIR = join(tmpdir(), `claude-mem-smart-install-test-${process.pid}`);
|
||||
|
||||
function createDir(relativePath: string): string {
|
||||
const fullPath = join(TEST_DIR, relativePath);
|
||||
mkdirSync(fullPath, { recursive: true });
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
function createPackageJson(dir: string, version = '10.0.0', deps: Record<string, string> = {}): void {
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify({
|
||||
name: 'claude-mem-plugin',
|
||||
version,
|
||||
dependencies: deps
|
||||
}));
|
||||
}
|
||||
|
||||
describe('smart-install resolveRoot logic', () => {
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should prefer CLAUDE_PLUGIN_ROOT when it contains package.json', () => {
|
||||
const cacheDir = createDir('cache/thedotmack/claude-mem/10.0.0');
|
||||
createPackageJson(cacheDir);
|
||||
|
||||
// Simulate what resolveRoot does
|
||||
const root = cacheDir;
|
||||
expect(existsSync(join(root, 'package.json'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect cache-based install paths', () => {
|
||||
// Cache installs have paths like ~/.claude/plugins/cache/thedotmack/claude-mem/<version>/
|
||||
const cacheDir = createDir('plugins/cache/thedotmack/claude-mem/10.3.0');
|
||||
createPackageJson(cacheDir);
|
||||
|
||||
// Marketplace dir does NOT exist (fresh cache install, no marketplace)
|
||||
const pluginRoot = cacheDir;
|
||||
expect(existsSync(join(pluginRoot, 'package.json'))).toBe(true);
|
||||
// The cache dir is valid — resolveRoot should use it, not try to navigate to marketplace
|
||||
});
|
||||
|
||||
it('should fall back to script-relative path when CLAUDE_PLUGIN_ROOT is unset', () => {
|
||||
// Simulate: scripts/smart-install.js lives in <root>/scripts/
|
||||
const pluginRoot = createDir('marketplace-plugin');
|
||||
createPackageJson(pluginRoot);
|
||||
const scriptsDir = createDir('marketplace-plugin/scripts');
|
||||
|
||||
// dirname(scripts/) = marketplace-plugin/ which has package.json
|
||||
const candidate = join(scriptsDir, '..');
|
||||
expect(existsSync(join(candidate, 'package.json'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle missing package.json in CLAUDE_PLUGIN_ROOT gracefully', () => {
|
||||
// CLAUDE_PLUGIN_ROOT points to a dir without package.json
|
||||
const badDir = createDir('empty-cache-dir');
|
||||
expect(existsSync(join(badDir, 'package.json'))).toBe(false);
|
||||
// resolveRoot should fall through to next candidate
|
||||
});
|
||||
});
|
||||
|
||||
describe('smart-install verifyCriticalModules logic', () => {
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should pass when all dependencies exist in node_modules', () => {
|
||||
const root = createDir('plugin-root');
|
||||
createPackageJson(root, '10.0.0', {
|
||||
'@chroma-core/default-embed': '^0.1.9'
|
||||
});
|
||||
|
||||
// Create the module directory
|
||||
mkdirSync(join(root, 'node_modules', '@chroma-core', 'default-embed'), { recursive: true });
|
||||
|
||||
// Simulate verifyCriticalModules
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
|
||||
const dependencies = Object.keys(pkg.dependencies || {});
|
||||
const missing: string[] = [];
|
||||
for (const dep of dependencies) {
|
||||
const modulePath = join(root, 'node_modules', ...dep.split('/'));
|
||||
if (!existsSync(modulePath)) {
|
||||
missing.push(dep);
|
||||
}
|
||||
}
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect missing dependencies in node_modules', () => {
|
||||
const root = createDir('plugin-root-missing');
|
||||
createPackageJson(root, '10.0.0', {
|
||||
'@chroma-core/default-embed': '^0.1.9'
|
||||
});
|
||||
|
||||
// Do NOT create node_modules — simulate a failed install
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
|
||||
const dependencies = Object.keys(pkg.dependencies || {});
|
||||
const missing: string[] = [];
|
||||
for (const dep of dependencies) {
|
||||
const modulePath = join(root, 'node_modules', ...dep.split('/'));
|
||||
if (!existsSync(modulePath)) {
|
||||
missing.push(dep);
|
||||
}
|
||||
}
|
||||
|
||||
expect(missing).toEqual(['@chroma-core/default-embed']);
|
||||
});
|
||||
|
||||
it('should handle packages with no dependencies gracefully', () => {
|
||||
const root = createDir('plugin-root-no-deps');
|
||||
createPackageJson(root, '10.0.0', {});
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
|
||||
const dependencies = Object.keys(pkg.dependencies || {});
|
||||
|
||||
expect(dependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect partially installed scoped packages', () => {
|
||||
const root = createDir('plugin-root-partial');
|
||||
createPackageJson(root, '10.0.0', {
|
||||
'@chroma-core/default-embed': '^0.1.9',
|
||||
'@chroma-core/other-pkg': '^1.0.0'
|
||||
});
|
||||
|
||||
// Only install one of the two packages
|
||||
mkdirSync(join(root, 'node_modules', '@chroma-core', 'default-embed'), { recursive: true });
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
|
||||
const dependencies = Object.keys(pkg.dependencies || {});
|
||||
const missing: string[] = [];
|
||||
for (const dep of dependencies) {
|
||||
const modulePath = join(root, 'node_modules', ...dep.split('/'));
|
||||
if (!existsSync(modulePath)) {
|
||||
missing.push(dep);
|
||||
}
|
||||
}
|
||||
|
||||
expect(missing).toEqual(['@chroma-core/other-pkg']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('smart-install stdout JSON output (#1253)', () => {
|
||||
const SCRIPT_PATH = join(__dirname, '..', 'plugin', 'scripts', 'smart-install.js');
|
||||
|
||||
it('should not have any execSync with stdio: inherit (prevents stdout leak)', () => {
|
||||
const content = readFileSync(SCRIPT_PATH, 'utf-8');
|
||||
// stdio: 'inherit' would leak non-JSON output to stdout, breaking Claude Code hooks
|
||||
expect(content).not.toContain("stdio: 'inherit'");
|
||||
expect(content).not.toContain('stdio: "inherit"');
|
||||
});
|
||||
|
||||
it('should output valid JSON to stdout on success path', () => {
|
||||
const content = readFileSync(SCRIPT_PATH, 'utf-8');
|
||||
// The script must print JSON to stdout for the Claude Code hook contract
|
||||
expect(content).toContain('console.log(JSON.stringify(');
|
||||
expect(content).toContain('continue');
|
||||
expect(content).toContain('suppressOutput');
|
||||
});
|
||||
|
||||
it('should output valid JSON to stdout even in error catch block', () => {
|
||||
const content = readFileSync(SCRIPT_PATH, 'utf-8');
|
||||
// Find the catch block and verify it also outputs JSON
|
||||
const catchIndex = content.lastIndexOf('catch (e)');
|
||||
expect(catchIndex).toBeGreaterThan(0);
|
||||
const catchBlock = content.slice(catchIndex, catchIndex + 300);
|
||||
expect(catchBlock).toContain('console.log(JSON.stringify(');
|
||||
});
|
||||
|
||||
it('should use piped stdout for all execSync calls', () => {
|
||||
const content = readFileSync(SCRIPT_PATH, 'utf-8');
|
||||
// All execSync calls should pipe stdout to prevent leaking to the hook output.
|
||||
// Match execSync calls that have a stdio option — they should all use array form.
|
||||
// All execSync calls should either use 'ignore', array form, or the installStdio variable
|
||||
// — never bare 'inherit' which leaks non-JSON output to stdout
|
||||
expect(content).not.toContain("stdio: 'inherit'");
|
||||
expect(content).not.toContain('stdio: "inherit"');
|
||||
// Verify the installStdio variable is defined with the correct pipe config
|
||||
expect(content).toContain("const installStdio = ['pipe', 'pipe', 'inherit']");
|
||||
});
|
||||
|
||||
it('should produce valid JSON when run with plugin disabled', () => {
|
||||
// Run the actual script with the plugin forcefully disabled via settings
|
||||
// This exercises the early exit path
|
||||
const settingsDir = join(tmpdir(), `claude-mem-test-settings-${process.pid}`);
|
||||
const settingsFile = join(settingsDir, 'settings.json');
|
||||
mkdirSync(settingsDir, { recursive: true });
|
||||
writeFileSync(settingsFile, JSON.stringify({
|
||||
enabledPlugins: { 'claude-mem@thedotmack': false }
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = spawnSync('node', [SCRIPT_PATH], {
|
||||
encoding: 'utf-8',
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_CONFIG_DIR: settingsDir,
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// When plugin is disabled, script exits with 0 and produces no stdout
|
||||
// (the early exit at line 31-33 calls process.exit(0) before any output)
|
||||
expect(result.status).toBe(0);
|
||||
// stdout should be empty or valid JSON (not plain text install messages)
|
||||
const stdout = (result.stdout || '').trim();
|
||||
if (stdout.length > 0) {
|
||||
expect(() => JSON.parse(stdout)).not.toThrow();
|
||||
}
|
||||
} finally {
|
||||
rmSync(settingsDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Tests for checkBinaryPlatformCompatibility() (#1547).
|
||||
*
|
||||
* The bundled plugin/scripts/claude-mem binary is macOS arm64 only.
|
||||
* On Linux/Windows it cannot execute and hooks fail silently.
|
||||
* These tests call the production function directly, mocking process.platform
|
||||
* and passing controlled binary paths to verify Mach-O detection behaviour.
|
||||
*/
|
||||
describe('smart-install binary platform compatibility (#1547)', () => {
|
||||
let testDir: string;
|
||||
let originalPlatform: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = join(tmpdir(), `claude-mem-binary-compat-test-${process.pid}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
// Restore process.platform
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform);
|
||||
}
|
||||
});
|
||||
|
||||
function setPlatform(value: string) {
|
||||
Object.defineProperty(process, 'platform', { value, configurable: true });
|
||||
}
|
||||
|
||||
it('should detect native arm64/x86_64 Mach-O binary and warn on Linux', () => {
|
||||
// Real macOS arm64 binary header: bytes CF FA ED FE (MH_MAGIC_64)
|
||||
const binaryPath = join(testDir, 'claude-mem');
|
||||
writeFileSync(binaryPath, Buffer.from([0xCF, 0xFA, 0xED, 0xFE, 0x0C, 0x00, 0x00, 0x01]));
|
||||
|
||||
const stderrLines: string[] = [];
|
||||
const originalError = console.error;
|
||||
console.error = (...args: any[]) => stderrLines.push(args.join(' '));
|
||||
|
||||
setPlatform('linux');
|
||||
try {
|
||||
checkBinaryPlatformCompatibility(binaryPath);
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
|
||||
expect(stderrLines.some(l => l.includes('macOS-only'))).toBe(true);
|
||||
expect(stderrLines.some(l => l.includes('linux'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect byte-swapped Mach-O binary and warn on Linux', () => {
|
||||
// Byte-swapped 64-bit Mach-O: bytes FE ED FA CF (MH_CIGAM_64)
|
||||
const binaryPath = join(testDir, 'claude-mem-swapped');
|
||||
writeFileSync(binaryPath, Buffer.from([0xFE, 0xED, 0xFA, 0xCF, 0x01, 0x00, 0x00, 0x0C]));
|
||||
|
||||
const stderrLines: string[] = [];
|
||||
const originalError = console.error;
|
||||
console.error = (...args: any[]) => stderrLines.push(args.join(' '));
|
||||
|
||||
setPlatform('linux');
|
||||
try {
|
||||
checkBinaryPlatformCompatibility(binaryPath);
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
|
||||
expect(stderrLines.some(l => l.includes('macOS-only'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT warn for an ELF binary (Linux native) on Linux', () => {
|
||||
// ELF magic: 0x7F 'E' 'L' 'F'
|
||||
const binaryPath = join(testDir, 'claude-mem-elf');
|
||||
writeFileSync(binaryPath, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00]));
|
||||
|
||||
const stderrLines: string[] = [];
|
||||
const originalError = console.error;
|
||||
console.error = (...args: any[]) => stderrLines.push(args.join(' '));
|
||||
|
||||
setPlatform('linux');
|
||||
try {
|
||||
checkBinaryPlatformCompatibility(binaryPath);
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
|
||||
expect(stderrLines.some(l => l.includes('macOS-only'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should not throw when binary path does not exist', () => {
|
||||
const binaryPath = join(testDir, 'nonexistent-claude-mem');
|
||||
expect(existsSync(binaryPath)).toBe(false);
|
||||
|
||||
setPlatform('linux');
|
||||
expect(() => checkBinaryPlatformCompatibility(binaryPath)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should skip the check entirely when platform is darwin', () => {
|
||||
// Write a Mach-O binary — on macOS the check returns early, so no warning
|
||||
const binaryPath = join(testDir, 'claude-mem');
|
||||
writeFileSync(binaryPath, Buffer.from([0xCF, 0xFA, 0xED, 0xFE, 0x0C, 0x00, 0x00, 0x01]));
|
||||
|
||||
const stderrLines: string[] = [];
|
||||
const originalError = console.error;
|
||||
console.error = (...args: any[]) => stderrLines.push(args.join(' '));
|
||||
|
||||
setPlatform('darwin');
|
||||
try {
|
||||
checkBinaryPlatformCompatibility(binaryPath);
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
|
||||
expect(stderrLines.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,3 @@
|
||||
/**
|
||||
* Data integrity tests for TRIAGE-03
|
||||
* Tests: content-hash deduplication, project name collision, empty project guard, stuck isProcessing
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { ClaudeMemDatabase } from '../../src/services/sqlite/Database.js';
|
||||
@@ -69,7 +65,6 @@ describe('TRIAGE-03: Data Integrity', () => {
|
||||
});
|
||||
|
||||
it('computeObservationContentHash avoids collision from field boundary ambiguity', () => {
|
||||
// These tuples would collide without a delimiter between fields
|
||||
const hash1 = computeObservationContentHash('session-abc', 'debug log', '');
|
||||
const hash2 = computeObservationContentHash('session-ab', 'cdebug log', '');
|
||||
const hash3 = computeObservationContentHash('session-', 'abcdebug log', '');
|
||||
@@ -86,20 +81,15 @@ describe('TRIAGE-03: Data Integrity', () => {
|
||||
const result1 = storeObservation(db, memId, 'test-project', obs, 1, 0, now);
|
||||
const result2 = storeObservation(db, memId, 'test-project', obs, 1, 0, now + 1000);
|
||||
|
||||
// Second call should return the same id as the first (deduped)
|
||||
expect(result2.id).toBe(result1.id);
|
||||
});
|
||||
|
||||
it('storeObservation deduplicates identical content regardless of time gap (UNIQUE constraint)', () => {
|
||||
// PATHFINDER-2026-04-22 Plan 01 Phase 4: the legacy time-window dedup
|
||||
// was replaced by UNIQUE(memory_session_id, content_hash) +
|
||||
// ON CONFLICT DO NOTHING. Identical content always dedupes.
|
||||
const memId = createSessionWithMemoryId(db, 'content-dedup-2', 'mem-dedup-2');
|
||||
const obs = createObservationInput({ title: 'Same Title', narrative: 'Same Narrative' });
|
||||
|
||||
const now = Date.now();
|
||||
const result1 = storeObservation(db, memId, 'test-project', obs, 1, 0, now);
|
||||
// Far outside any legacy window — UNIQUE constraint still dedupes.
|
||||
const result2 = storeObservation(db, memId, 'test-project', obs, 1, 0, now + 31_000);
|
||||
|
||||
expect(result2.id).toBe(result1.id);
|
||||
@@ -136,12 +126,10 @@ describe('TRIAGE-03: Data Integrity', () => {
|
||||
|
||||
const result = storeObservations(db, memId, 'test-project', [obs, obs, obs], null);
|
||||
|
||||
// First is inserted, second and third are deduped to the first
|
||||
expect(result.observationIds.length).toBe(3);
|
||||
expect(result.observationIds[1]).toBe(result.observationIds[0]);
|
||||
expect(result.observationIds[2]).toBe(result.observationIds[0]);
|
||||
|
||||
// Only 1 row in the database
|
||||
const count = db.prepare('SELECT COUNT(*) as count FROM observations').get() as { count: number };
|
||||
expect(count.count).toBe(1);
|
||||
});
|
||||
@@ -155,16 +143,12 @@ describe('TRIAGE-03: Data Integrity', () => {
|
||||
const result = storeObservation(db, memId, '', obs);
|
||||
const row = db.prepare('SELECT project FROM observations WHERE id = ?').get(result.id) as { project: string };
|
||||
|
||||
// Should not be empty — will be derived from cwd
|
||||
expect(row.project).toBeTruthy();
|
||||
expect(row.project.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasAnyPendingWork', () => {
|
||||
// PATHFINDER-2026-04-22 Plan 01: time-based stale-reset on
|
||||
// started_processing_at_epoch was replaced by worker-PID liveness.
|
||||
// The legacy "5-minute reset" tests were removed with the column.
|
||||
|
||||
it('hasAnyPendingWork returns false when no pending or processing messages exist', () => {
|
||||
const pendingStore = new PendingMessageStore(db);
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
/**
|
||||
* Observations module tests
|
||||
* Tests modular observation functions with in-memory database
|
||||
*
|
||||
* Sources:
|
||||
* - API patterns from src/services/sqlite/observations/store.ts
|
||||
* - API patterns from src/services/sqlite/observations/get.ts
|
||||
* - API patterns from src/services/sqlite/observations/recent.ts
|
||||
* - Type definitions from src/services/sqlite/observations/types.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { ClaudeMemDatabase } from '../../src/services/sqlite/Database.js';
|
||||
@@ -15,6 +5,7 @@ import {
|
||||
storeObservation,
|
||||
getObservationById,
|
||||
getRecentObservations,
|
||||
getFirstObservationCreatedAt,
|
||||
} from '../../src/services/sqlite/Observations.js';
|
||||
import {
|
||||
createSDKSession,
|
||||
@@ -34,7 +25,6 @@ describe('Observations Module', () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
// Helper to create a valid observation input
|
||||
function createObservationInput(overrides: Partial<ObservationInput> = {}): ObservationInput {
|
||||
return {
|
||||
type: 'discovery',
|
||||
@@ -49,7 +39,6 @@ describe('Observations Module', () => {
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create a session and return memory_session_id for FK constraints
|
||||
function createSessionWithMemoryId(contentSessionId: string, memorySessionId: string, project: string = 'test-project'): string {
|
||||
const sessionId = createSDKSession(db, contentSessionId, project, 'initial prompt');
|
||||
updateMemorySessionId(db, sessionId, memorySessionId);
|
||||
@@ -98,7 +87,7 @@ describe('Observations Module', () => {
|
||||
const memorySessionId = createSessionWithMemoryId('content-789', 'mem-session-789');
|
||||
const project = 'test-project';
|
||||
const observation = createObservationInput();
|
||||
const pastTimestamp = 1600000000000; // Sep 13, 2020
|
||||
const pastTimestamp = 1600000000000;
|
||||
|
||||
const result = storeObservation(
|
||||
db,
|
||||
@@ -114,7 +103,6 @@ describe('Observations Module', () => {
|
||||
|
||||
const stored = getObservationById(db, result.id);
|
||||
expect(stored?.created_at_epoch).toBe(pastTimestamp);
|
||||
// Verify ISO string matches epoch
|
||||
expect(new Date(stored!.created_at).getTime()).toBe(pastTimestamp);
|
||||
});
|
||||
|
||||
@@ -172,7 +160,6 @@ describe('Observations Module', () => {
|
||||
it('should return observations ordered by date DESC', () => {
|
||||
const project = 'test-project';
|
||||
|
||||
// Create sessions and store observations with different timestamps (oldest first)
|
||||
const mem1 = createSessionWithMemoryId('content-1', 'session1', project);
|
||||
const mem2 = createSessionWithMemoryId('content-2', 'session2', project);
|
||||
const mem3 = createSessionWithMemoryId('content-3', 'session3', project);
|
||||
@@ -184,7 +171,6 @@ describe('Observations Module', () => {
|
||||
const recent = getRecentObservations(db, project, 10);
|
||||
|
||||
expect(recent.length).toBe(3);
|
||||
// Most recent first (DESC order)
|
||||
expect(recent[0].prompt_number).toBe(3);
|
||||
expect(recent[1].prompt_number).toBe(2);
|
||||
expect(recent[2].prompt_number).toBe(1);
|
||||
@@ -228,4 +214,33 @@ describe('Observations Module', () => {
|
||||
expect(recent).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFirstObservationCreatedAt', () => {
|
||||
it('should return null when there are no observations', () => {
|
||||
const result = getFirstObservationCreatedAt(db);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return the earliest observation created_at as ISO string', () => {
|
||||
const project = 'test-project';
|
||||
|
||||
const memEarly = createSessionWithMemoryId('content-early', 'session-early', project);
|
||||
const memMid = createSessionWithMemoryId('content-mid', 'session-mid', project);
|
||||
const memLate = createSessionWithMemoryId('content-late', 'session-late', project);
|
||||
|
||||
const earliestEpoch = 1000000000000;
|
||||
const midEpoch = 2000000000000;
|
||||
const latestEpoch = 3000000000000;
|
||||
|
||||
storeObservation(db, memMid, project, createObservationInput(), 2, 0, midEpoch);
|
||||
storeObservation(db, memLate, project, createObservationInput(), 3, 0, latestEpoch);
|
||||
storeObservation(db, memEarly, project, createObservationInput(), 1, 0, earliestEpoch);
|
||||
|
||||
const result = getFirstObservationCreatedAt(db);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(new Date(result!).getTime()).toBe(earliestEpoch);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
/**
|
||||
* Prompts module tests
|
||||
* Tests modular prompt functions with in-memory database
|
||||
*
|
||||
* Sources:
|
||||
* - API patterns from src/services/sqlite/prompts/store.ts
|
||||
* - API patterns from src/services/sqlite/prompts/get.ts
|
||||
* - Test pattern from tests/session_store.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { ClaudeMemDatabase } from '../../src/services/sqlite/Database.js';
|
||||
@@ -28,7 +19,6 @@ describe('Prompts Module', () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
// Helper to create a session (for FK constraint on user_prompts.content_session_id)
|
||||
function createSession(contentSessionId: string, project: string = 'test-project'): string {
|
||||
createSDKSession(db, contentSessionId, project, 'initial prompt');
|
||||
return contentSessionId;
|
||||
@@ -95,20 +85,15 @@ describe('Prompts Module', () => {
|
||||
const sessionA = createSession('isolation-session-a');
|
||||
const sessionB = createSession('isolation-session-b');
|
||||
|
||||
// Add prompts to session A
|
||||
saveUserPrompt(db, sessionA, 1, 'A1');
|
||||
saveUserPrompt(db, sessionA, 2, 'A2');
|
||||
|
||||
// Add prompts to session B
|
||||
saveUserPrompt(db, sessionB, 1, 'B1');
|
||||
|
||||
// Session A should have 2 prompts
|
||||
expect(getPromptNumberFromUserPrompts(db, sessionA)).toBe(2);
|
||||
|
||||
// Session B should have 1 prompt
|
||||
expect(getPromptNumberFromUserPrompts(db, sessionB)).toBe(1);
|
||||
|
||||
// Adding to session B shouldn't affect session A
|
||||
saveUserPrompt(db, sessionB, 2, 'B2');
|
||||
saveUserPrompt(db, sessionB, 3, 'B3');
|
||||
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
/**
|
||||
* Session module tests
|
||||
* Tests modular session functions with in-memory database
|
||||
*
|
||||
* Sources:
|
||||
* - API patterns from src/services/sqlite/sessions/create.ts
|
||||
* - API patterns from src/services/sqlite/sessions/get.ts
|
||||
* - Test pattern from tests/session_store.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { ClaudeMemDatabase } from '../../src/services/sqlite/Database.js';
|
||||
@@ -73,7 +64,6 @@ describe('Sessions Module', () => {
|
||||
expect(session?.content_session_id).toBe(contentSessionId);
|
||||
expect(session?.project).toBe(project);
|
||||
expect(session?.user_prompt).toBe(userPrompt);
|
||||
// memory_session_id should be null initially (set via updateMemorySessionId)
|
||||
expect(session?.memory_session_id).toBeNull();
|
||||
});
|
||||
|
||||
@@ -104,7 +94,6 @@ describe('Sessions Module', () => {
|
||||
let session = getSessionById(db, sessionId);
|
||||
expect(session?.custom_title).toBeNull();
|
||||
|
||||
// Second call with custom_title should backfill
|
||||
createSDKSession(db, 'session-title-3', 'project', 'prompt', 'Backfilled Title');
|
||||
session = getSessionById(db, sessionId);
|
||||
expect(session?.custom_title).toBe('Backfilled Title');
|
||||
@@ -115,7 +104,6 @@ describe('Sessions Module', () => {
|
||||
let session = getSessionById(db, sessionId);
|
||||
expect(session?.custom_title).toBe('Original');
|
||||
|
||||
// Second call should NOT overwrite
|
||||
createSDKSession(db, 'session-title-4', 'project', 'prompt', 'Attempted Override');
|
||||
session = getSessionById(db, sessionId);
|
||||
expect(session?.custom_title).toBe('Original');
|
||||
@@ -125,7 +113,6 @@ describe('Sessions Module', () => {
|
||||
const sessionId = createSDKSession(db, 'session-title-5', 'project', 'prompt', '');
|
||||
const session = getSessionById(db, sessionId);
|
||||
|
||||
// Empty string becomes null via the || null conversion
|
||||
expect(session?.custom_title).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -171,14 +158,11 @@ describe('Sessions Module', () => {
|
||||
|
||||
const sessionId = createSDKSession(db, contentSessionId, project, userPrompt);
|
||||
|
||||
// Verify memory_session_id is null initially
|
||||
let session = getSessionById(db, sessionId);
|
||||
expect(session?.memory_session_id).toBeNull();
|
||||
|
||||
// Update memory session ID
|
||||
updateMemorySessionId(db, sessionId, memorySessionId);
|
||||
|
||||
// Verify update
|
||||
session = getSessionById(db, sessionId);
|
||||
expect(session?.memory_session_id).toBe(memorySessionId);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
/**
|
||||
* Summaries module tests
|
||||
* Tests modular summary functions with in-memory database
|
||||
*
|
||||
* Sources:
|
||||
* - API patterns from src/services/sqlite/summaries/store.ts
|
||||
* - API patterns from src/services/sqlite/summaries/get.ts
|
||||
* - Type definitions from src/services/sqlite/summaries/types.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { ClaudeMemDatabase } from '../../src/services/sqlite/Database.js';
|
||||
@@ -32,7 +23,6 @@ describe('Summaries Module', () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
// Helper to create a valid summary input
|
||||
function createSummaryInput(overrides: Partial<SummaryInput> = {}): SummaryInput {
|
||||
return {
|
||||
request: 'User requested feature X',
|
||||
@@ -45,7 +35,6 @@ describe('Summaries Module', () => {
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create a session and return memory_session_id for FK constraints
|
||||
function createSessionWithMemoryId(contentSessionId: string, memorySessionId: string, project: string = 'test-project'): string {
|
||||
const sessionId = createSDKSession(db, contentSessionId, project, 'initial prompt');
|
||||
updateMemorySessionId(db, sessionId, memorySessionId);
|
||||
@@ -95,7 +84,7 @@ describe('Summaries Module', () => {
|
||||
const memorySessionId = createSessionWithMemoryId('content-sum-789', 'mem-session-sum-789');
|
||||
const project = 'test-project';
|
||||
const summary = createSummaryInput();
|
||||
const pastTimestamp = 1650000000000; // Apr 15, 2022
|
||||
const pastTimestamp = 1650000000000;
|
||||
|
||||
const result = storeSummary(
|
||||
db,
|
||||
@@ -162,7 +151,6 @@ describe('Summaries Module', () => {
|
||||
it('should return most recent summary when multiple exist', () => {
|
||||
const memorySessionId = createSessionWithMemoryId('content-multi', 'multi-summary-session');
|
||||
|
||||
// Store older summary
|
||||
storeSummary(
|
||||
db,
|
||||
memorySessionId,
|
||||
@@ -173,7 +161,6 @@ describe('Summaries Module', () => {
|
||||
1000000000000
|
||||
);
|
||||
|
||||
// Store newer summary
|
||||
storeSummary(
|
||||
db,
|
||||
memorySessionId,
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
/**
|
||||
* Transactions module tests
|
||||
* Tests atomic transaction functions with in-memory database
|
||||
*
|
||||
* Sources:
|
||||
* - API patterns from src/services/sqlite/transactions.ts
|
||||
* - Type definitions from src/services/sqlite/transactions.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { ClaudeMemDatabase } from '../../src/services/sqlite/Database.js';
|
||||
@@ -34,7 +26,6 @@ describe('Transactions Module', () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
// Helper to create a valid observation input
|
||||
function createObservationInput(overrides: Partial<ObservationInput> = {}): ObservationInput {
|
||||
return {
|
||||
type: 'discovery',
|
||||
@@ -49,7 +40,6 @@ describe('Transactions Module', () => {
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create a valid summary input
|
||||
function createSummaryInput(overrides: Partial<SummaryInput> = {}): SummaryInput {
|
||||
return {
|
||||
request: 'User requested feature X',
|
||||
@@ -62,7 +52,6 @@ describe('Transactions Module', () => {
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create a session and return memory_session_id for FK constraints
|
||||
function createSessionWithMemoryId(contentSessionId: string, memorySessionId: string, project: string = 'test-project'): { memorySessionId: string; sessionDbId: number } {
|
||||
const sessionDbId = createSDKSession(db, contentSessionId, project, 'initial prompt');
|
||||
updateMemorySessionId(db, sessionDbId, memorySessionId);
|
||||
@@ -109,7 +98,6 @@ describe('Transactions Module', () => {
|
||||
|
||||
expect(result.createdAtEpoch).toBe(fixedTimestamp);
|
||||
|
||||
// Verify each observation has the same timestamp
|
||||
for (const id of result.observationIds) {
|
||||
const obs = getObservationById(db, id);
|
||||
expect(obs?.created_at_epoch).toBe(fixedTimestamp);
|
||||
@@ -128,7 +116,6 @@ describe('Transactions Module', () => {
|
||||
expect(result.summaryId).not.toBeNull();
|
||||
expect(typeof result.summaryId).toBe('number');
|
||||
|
||||
// Verify summary was stored
|
||||
const storedSummary = getSummaryForSession(db, memorySessionId);
|
||||
expect(storedSummary).not.toBeNull();
|
||||
expect(storedSummary?.request).toBe('Test request');
|
||||
@@ -201,8 +188,6 @@ describe('Transactions Module', () => {
|
||||
});
|
||||
|
||||
describe('storeObservationsAndMarkComplete', () => {
|
||||
// Note: This function also marks a pending message as processed.
|
||||
// For testing, we need a pending_messages row to exist first.
|
||||
|
||||
it('should store observations, summary, and mark message complete', () => {
|
||||
const { memorySessionId, sessionDbId } = createSessionWithMemoryId('content-complete', 'complete-session');
|
||||
@@ -210,7 +195,6 @@ describe('Transactions Module', () => {
|
||||
const observations = [createObservationInput({ title: 'Complete Obs' })];
|
||||
const summary = createSummaryInput({ request: 'Complete request' });
|
||||
|
||||
// First, insert a pending message to mark as complete
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO pending_messages
|
||||
(session_db_id, content_session_id, message_type, created_at_epoch, status)
|
||||
@@ -231,7 +215,6 @@ describe('Transactions Module', () => {
|
||||
expect(result.observationIds).toHaveLength(1);
|
||||
expect(result.summaryId).not.toBeNull();
|
||||
|
||||
// Verify message was marked as processed
|
||||
const msgStmt = db.prepare('SELECT status FROM pending_messages WHERE id = ?');
|
||||
const msg = msgStmt.get(messageId) as { status: string } | undefined;
|
||||
expect(msg?.status).toBe('processed');
|
||||
@@ -247,7 +230,6 @@ describe('Transactions Module', () => {
|
||||
const summary = createSummaryInput();
|
||||
const fixedTimestamp = 1700000000000;
|
||||
|
||||
// Create pending message
|
||||
db.prepare(`
|
||||
INSERT INTO pending_messages
|
||||
(session_db_id, content_session_id, message_type, created_at_epoch, status)
|
||||
@@ -269,13 +251,11 @@ describe('Transactions Module', () => {
|
||||
|
||||
expect(result.createdAtEpoch).toBe(fixedTimestamp);
|
||||
|
||||
// All observations should have same timestamp
|
||||
for (const id of result.observationIds) {
|
||||
const obs = getObservationById(db, id);
|
||||
expect(obs?.created_at_epoch).toBe(fixedTimestamp);
|
||||
}
|
||||
|
||||
// Summary should have same timestamp
|
||||
const storedSummary = getSummaryForSession(db, memorySessionId);
|
||||
expect(storedSummary?.created_at_epoch).toBe(fixedTimestamp);
|
||||
});
|
||||
@@ -285,7 +265,6 @@ describe('Transactions Module', () => {
|
||||
const project = 'test-project';
|
||||
const observations = [createObservationInput({ title: 'Only Obs' })];
|
||||
|
||||
// Create pending message
|
||||
db.prepare(`
|
||||
INSERT INTO pending_messages
|
||||
(session_db_id, content_session_id, message_type, created_at_epoch, status)
|
||||
|
||||
@@ -70,13 +70,10 @@ describe('sanitizeEnv', () => {
|
||||
|
||||
const result = sanitizeEnv(original);
|
||||
|
||||
// Result should be a different object
|
||||
expect(result).not.toBe(original);
|
||||
|
||||
// Original should be unchanged
|
||||
expect(original).toEqual(originalCopy);
|
||||
|
||||
// Result should not contain stripped vars
|
||||
expect(result.CLAUDECODE_FOO).toBeUndefined();
|
||||
expect(result.PATH).toBe('/usr/bin');
|
||||
});
|
||||
@@ -170,15 +167,12 @@ describe('sanitizeEnv', () => {
|
||||
PATH: '/usr/bin'
|
||||
});
|
||||
|
||||
// Preserved: explicitly allowed CLAUDE_CODE_* vars
|
||||
expect(result.CLAUDE_CODE_OAUTH_TOKEN).toBe('my-oauth-token');
|
||||
expect(result.CLAUDE_CODE_GIT_BASH_PATH).toBe('/usr/bin/bash');
|
||||
|
||||
// Stripped: all other CLAUDE_CODE_* vars
|
||||
expect(result.CLAUDE_CODE_RANDOM_OTHER).toBeUndefined();
|
||||
expect(result.CLAUDE_CODE_INTERNAL_FLAG).toBeUndefined();
|
||||
|
||||
// Preserved: normal env vars
|
||||
expect(result.PATH).toBe('/usr/bin');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { startHealthChecker, stopHealthChecker } from '../../src/supervisor/heal
|
||||
|
||||
describe('health-checker', () => {
|
||||
afterEach(() => {
|
||||
// Always stop the checker to avoid leaking intervals between tests
|
||||
stopHealthChecker();
|
||||
});
|
||||
|
||||
@@ -21,7 +20,6 @@ describe('health-checker', () => {
|
||||
});
|
||||
|
||||
it('multiple startHealthChecker calls do not create multiple intervals', () => {
|
||||
// Track setInterval calls
|
||||
const originalSetInterval = globalThis.setInterval;
|
||||
let setIntervalCallCount = 0;
|
||||
|
||||
@@ -31,7 +29,6 @@ describe('health-checker', () => {
|
||||
}) as typeof setInterval;
|
||||
|
||||
try {
|
||||
// Stop any existing checker first to ensure clean state
|
||||
stopHealthChecker();
|
||||
setIntervalCallCount = 0;
|
||||
|
||||
@@ -39,7 +36,6 @@ describe('health-checker', () => {
|
||||
startHealthChecker();
|
||||
startHealthChecker();
|
||||
|
||||
// Only one interval should have been created due to the guard
|
||||
expect(setIntervalCallCount).toBe(1);
|
||||
} finally {
|
||||
globalThis.setInterval = originalSetInterval;
|
||||
|
||||
@@ -69,13 +69,6 @@ describe('validateWorkerPidFile', () => {
|
||||
expect(status).toBe('alive');
|
||||
});
|
||||
|
||||
// Regression: container restart (docker stop / docker start) reused low PIDs
|
||||
// across boots. The PID file on a bind-mounted volume pointed at PID 11;
|
||||
// the new worker also came up as PID 11. kill(0) returned "alive" and the
|
||||
// worker refused to boot, thinking its own prior incarnation was still up.
|
||||
// With the start-token identity check, a stored token that doesn't match
|
||||
// the current PID's token should resolve as "stale" and the PID file should
|
||||
// be cleared so the new worker can proceed.
|
||||
const tokenSupported = process.platform === 'linux' || process.platform === 'darwin';
|
||||
it.if(tokenSupported)('returns "stale" when startToken does not match the live PID (PID reused)', () => {
|
||||
const tempDir = makeTempDir();
|
||||
@@ -98,7 +91,6 @@ describe('Supervisor assertCanSpawn behavior', () => {
|
||||
const { getSupervisor } = require('../../src/supervisor/index.js');
|
||||
const supervisor = getSupervisor();
|
||||
|
||||
// When not shutting down, assertCanSpawn should not throw
|
||||
expect(() => supervisor.assertCanSpawn('test')).not.toThrow();
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ describe('supervisor ProcessRegistry', () => {
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
const registryPath = path.join(tempDir, 'supervisor.json');
|
||||
|
||||
// Create a registry, register an entry, and let it persist
|
||||
const registry1 = createProcessRegistry(registryPath);
|
||||
registry1.register('worker:1', {
|
||||
pid: process.pid,
|
||||
@@ -53,12 +52,10 @@ describe('supervisor ProcessRegistry', () => {
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
|
||||
// Verify file exists on disk
|
||||
expect(existsSync(registryPath)).toBe(true);
|
||||
const diskData = JSON.parse(readFileSync(registryPath, 'utf-8'));
|
||||
expect(diskData.processes['worker:1']).toBeDefined();
|
||||
|
||||
// Create a second registry from the same path — it should load the persisted entry
|
||||
const registry2 = createProcessRegistry(registryPath);
|
||||
registry2.initialize();
|
||||
const records = registry2.getAll();
|
||||
@@ -108,7 +105,6 @@ describe('supervisor ProcessRegistry', () => {
|
||||
const registry = createProcessRegistry(registryPath);
|
||||
registry.initialize();
|
||||
|
||||
// Should recover with an empty registry
|
||||
expect(registry.getAll()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -254,7 +250,6 @@ describe('supervisor ProcessRegistry', () => {
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
|
||||
// Querying with number should find string "42"
|
||||
expect(registry.getBySession(42)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -341,7 +336,6 @@ describe('supervisor ProcessRegistry', () => {
|
||||
registry.clear();
|
||||
expect(registry.getAll()).toHaveLength(0);
|
||||
|
||||
// Verify persisted to disk
|
||||
const diskData = JSON.parse(readFileSync(registryPath, 'utf-8'));
|
||||
expect(Object.keys(diskData.processes)).toHaveLength(0);
|
||||
});
|
||||
@@ -362,7 +356,6 @@ describe('supervisor ProcessRegistry', () => {
|
||||
startedAt: '2026-03-15T00:00:00.000Z'
|
||||
});
|
||||
|
||||
// registry2 should be independent
|
||||
expect(registry1.getAll()).toHaveLength(1);
|
||||
expect(registry2.getAll()).toHaveLength(0);
|
||||
});
|
||||
@@ -387,7 +380,6 @@ describe('supervisor ProcessRegistry', () => {
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
|
||||
// Register a process for a different session (should survive)
|
||||
registry.register('sdk:100:50003', {
|
||||
pid: process.pid,
|
||||
type: 'sdk',
|
||||
|
||||
@@ -125,7 +125,6 @@ describe('supervisor shutdown cascade', () => {
|
||||
const registryPath = path.join(tempDir, 'supervisor.json');
|
||||
const registry = createProcessRegistry(registryPath);
|
||||
|
||||
// Register processes with PIDs that are definitely dead
|
||||
registry.register('dead:1', {
|
||||
pid: 2147483640,
|
||||
type: 'sdk',
|
||||
@@ -137,14 +136,12 @@ describe('supervisor shutdown cascade', () => {
|
||||
startedAt: '2026-03-15T00:00:01.000Z'
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
await runShutdownCascade({
|
||||
registry,
|
||||
currentPid: process.pid,
|
||||
pidFilePath: path.join(tempDir, 'worker.pid')
|
||||
});
|
||||
|
||||
// All entries should be unregistered
|
||||
const persisted = JSON.parse(readFileSync(registryPath, 'utf-8'));
|
||||
expect(Object.keys(persisted.processes)).toHaveLength(0);
|
||||
});
|
||||
@@ -179,7 +176,6 @@ describe('supervisor shutdown cascade', () => {
|
||||
pidFilePath: path.join(tempDir, 'worker.pid')
|
||||
});
|
||||
|
||||
// All records (including the current process one) should be removed
|
||||
expect(registry.getAll()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs';
|
||||
import path, { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
// Mock logger BEFORE imports (required pattern)
|
||||
mock.module('../../src/utils/logger.js', () => ({
|
||||
logger: {
|
||||
info: () => {},
|
||||
@@ -14,7 +13,6 @@ mock.module('../../src/utils/logger.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock worker-utils to delegate workerHttpRequest to global.fetch
|
||||
mock.module('../../src/shared/worker-utils.js', () => ({
|
||||
getWorkerPort: () => 37777,
|
||||
getWorkerHost: () => '127.0.0.1',
|
||||
@@ -32,7 +30,6 @@ mock.module('../../src/shared/worker-utils.js', () => ({
|
||||
buildWorkerUrl: (apiPath: string) => `http://127.0.0.1:37777${apiPath}`,
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
import {
|
||||
replaceTaggedContent,
|
||||
formatTimelineForClaudeMd,
|
||||
@@ -147,9 +144,7 @@ describe('formatTimelineForClaudeMd', () => {
|
||||
|
||||
expect(result).toContain('#123');
|
||||
expect(result).toContain('#124');
|
||||
// First occurrence should show time
|
||||
expect(result).toContain('4:30 PM');
|
||||
// Second occurrence should show ditto mark
|
||||
expect(result).toContain('"');
|
||||
});
|
||||
|
||||
@@ -170,10 +165,8 @@ describe('writeClaudeMdToFolder', () => {
|
||||
const folderPath = join(tempDir, 'non-existent-folder');
|
||||
const content = '# Recent Activity\n\nTest content';
|
||||
|
||||
// Should not throw, should silently skip
|
||||
writeClaudeMdToFolder(folderPath, content);
|
||||
|
||||
// Folder and CLAUDE.md should NOT be created
|
||||
expect(existsSync(folderPath)).toBe(false);
|
||||
const claudeMdPath = join(folderPath, 'CLAUDE.md');
|
||||
expect(existsSync(claudeMdPath)).toBe(false);
|
||||
@@ -217,10 +210,8 @@ describe('writeClaudeMdToFolder', () => {
|
||||
const folderPath = join(tempDir, 'deep', 'nested', 'folder');
|
||||
const content = 'Nested content';
|
||||
|
||||
// Should not throw, should silently skip
|
||||
writeClaudeMdToFolder(folderPath, content);
|
||||
|
||||
// Nested directories should NOT be created
|
||||
const claudeMdPath = join(folderPath, 'CLAUDE.md');
|
||||
expect(existsSync(claudeMdPath)).toBe(false);
|
||||
expect(existsSync(join(tempDir, 'deep'))).toBe(false);
|
||||
@@ -295,7 +286,7 @@ describe('updateFolderClaudeMdFiles', () => {
|
||||
|
||||
it('should fetch timeline and write CLAUDE.md', async () => {
|
||||
const folderPath = join(tempDir, 'api-test');
|
||||
mkdirSync(folderPath, { recursive: true }); // Folder must exist - we no longer create directories
|
||||
mkdirSync(folderPath, { recursive: true });
|
||||
const filePath = join(folderPath, 'test.ts');
|
||||
|
||||
const apiResponse = {
|
||||
@@ -339,7 +330,6 @@ describe('updateFolderClaudeMdFiles', () => {
|
||||
|
||||
await updateFolderClaudeMdFiles([file1, file2], 'test-project', 37777);
|
||||
|
||||
// Should only fetch once for the shared folder
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -352,10 +342,8 @@ describe('updateFolderClaudeMdFiles', () => {
|
||||
status: 404
|
||||
} as Response));
|
||||
|
||||
// Should not throw
|
||||
await expect(updateFolderClaudeMdFiles([filePath], 'test-project', 37777)).resolves.toBeUndefined();
|
||||
|
||||
// CLAUDE.md should not be created
|
||||
const claudeMdPath = join(folderPath, 'CLAUDE.md');
|
||||
expect(existsSync(claudeMdPath)).toBe(false);
|
||||
});
|
||||
@@ -366,10 +354,8 @@ describe('updateFolderClaudeMdFiles', () => {
|
||||
|
||||
global.fetch = mock(() => Promise.reject(new Error('Network error')));
|
||||
|
||||
// Should not throw
|
||||
await expect(updateFolderClaudeMdFiles([filePath], 'test-project', 37777)).resolves.toBeUndefined();
|
||||
|
||||
// CLAUDE.md should not be created
|
||||
const claudeMdPath = join(folderPath, 'CLAUDE.md');
|
||||
expect(existsSync(claudeMdPath)).toBe(false);
|
||||
});
|
||||
@@ -391,10 +377,9 @@ describe('updateFolderClaudeMdFiles', () => {
|
||||
['src/utils/file.ts'], // relative path
|
||||
'test-project',
|
||||
37777,
|
||||
'/home/user/my-project' // projectRoot
|
||||
'/home/user/my-project'
|
||||
);
|
||||
|
||||
// Should call API with absolute path /home/user/my-project/src/utils
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const callUrl = (fetchMock.mock.calls[0] as unknown[])[0] as string;
|
||||
expect(callUrl).toContain(encodeURIComponent('/home/user/my-project/src/utils'));
|
||||
@@ -420,10 +405,9 @@ describe('updateFolderClaudeMdFiles', () => {
|
||||
[filePath], // absolute path within tempDir
|
||||
'test-project',
|
||||
37777,
|
||||
tempDir // projectRoot matches the absolute path's root
|
||||
tempDir
|
||||
);
|
||||
|
||||
// Should call API with the original absolute path's folder
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const callUrl = (fetchMock.mock.calls[0] as unknown[])[0] as string;
|
||||
expect(callUrl).toContain(encodeURIComponent(folderPath));
|
||||
@@ -452,7 +436,6 @@ describe('updateFolderClaudeMdFiles', () => {
|
||||
// No projectRoot - backward compatibility
|
||||
);
|
||||
|
||||
// Should still make API call with the folder path
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const callUrl = (fetchMock.mock.calls[0] as unknown[])[0] as string;
|
||||
expect(callUrl).toContain(encodeURIComponent(folderPath));
|
||||
@@ -471,26 +454,22 @@ describe('updateFolderClaudeMdFiles', () => {
|
||||
} as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
// projectRoot WITH trailing slash
|
||||
await updateFolderClaudeMdFiles(
|
||||
['src/utils/file.ts'],
|
||||
'test-project',
|
||||
37777,
|
||||
'/home/user/my-project/' // trailing slash
|
||||
'/home/user/my-project/'
|
||||
);
|
||||
|
||||
// Should call API with normalized path (no double slashes)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const callUrl = (fetchMock.mock.calls[0] as unknown[])[0] as string;
|
||||
// path.join normalizes the path, so /home/user/my-project/ + src/utils becomes /home/user/my-project/src/utils
|
||||
expect(callUrl).toContain(encodeURIComponent('/home/user/my-project/src/utils'));
|
||||
// Should NOT contain double slashes (except in http://)
|
||||
expect(callUrl.replace('http://', '')).not.toContain('//');
|
||||
});
|
||||
|
||||
it('should write CLAUDE.md to resolved projectRoot path', async () => {
|
||||
const subfolderPath = join(tempDir, 'project-root-write-test', 'src', 'utils');
|
||||
mkdirSync(subfolderPath, { recursive: true }); // Folder must exist - we no longer create directories
|
||||
mkdirSync(subfolderPath, { recursive: true });
|
||||
|
||||
const apiResponse = {
|
||||
content: [{
|
||||
@@ -503,7 +482,6 @@ describe('updateFolderClaudeMdFiles', () => {
|
||||
json: () => Promise.resolve(apiResponse)
|
||||
} as Response));
|
||||
|
||||
// Use tempDir as projectRoot with relative path src/utils/file.ts
|
||||
await updateFolderClaudeMdFiles(
|
||||
['src/utils/file.ts'],
|
||||
'test-project',
|
||||
@@ -511,7 +489,6 @@ describe('updateFolderClaudeMdFiles', () => {
|
||||
join(tempDir, 'project-root-write-test')
|
||||
);
|
||||
|
||||
// Verify CLAUDE.md was written at the resolved absolute path
|
||||
const claudeMdPath = join(subfolderPath, 'CLAUDE.md');
|
||||
expect(existsSync(claudeMdPath)).toBe(true);
|
||||
|
||||
@@ -533,7 +510,6 @@ describe('updateFolderClaudeMdFiles', () => {
|
||||
} as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
// Multiple files in same folder (relative paths)
|
||||
await updateFolderClaudeMdFiles(
|
||||
['src/utils/file1.ts', 'src/utils/file2.ts', 'src/utils/file3.ts'],
|
||||
'test-project',
|
||||
@@ -541,7 +517,6 @@ describe('updateFolderClaudeMdFiles', () => {
|
||||
'/home/user/project'
|
||||
);
|
||||
|
||||
// Should only fetch once for the shared folder
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const callUrl = (fetchMock.mock.calls[0] as unknown[])[0] as string;
|
||||
expect(callUrl).toContain(encodeURIComponent('/home/user/project/src/utils'));
|
||||
@@ -558,7 +533,6 @@ describe('updateFolderClaudeMdFiles', () => {
|
||||
'/home/user/project'
|
||||
);
|
||||
|
||||
// Should skip empty strings and only process valid path
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const callUrl = (fetchMock.mock.calls[0] as unknown[])[0] as string;
|
||||
expect(callUrl).toContain(encodeURIComponent('/home/user/project/src'));
|
||||
@@ -660,7 +634,6 @@ describe('path validation in updateFolderClaudeMdFiles', () => {
|
||||
} as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
// Create an absolute path within the temp directory
|
||||
const absolutePathInProject = path.join(tempDir, 'src', 'utils', 'file.ts');
|
||||
|
||||
await updateFolderClaudeMdFiles(
|
||||
@@ -719,16 +692,13 @@ describe('issue #814 - reject consecutive duplicate path segments', () => {
|
||||
const fetchMock = mock(() => Promise.resolve({ ok: true } as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
// Simulate cwd=/project/frontend/ receiving relative path frontend/src/file.ts
|
||||
// resolves to /project/frontend/frontend/src/file.ts
|
||||
await updateFolderClaudeMdFiles(
|
||||
['frontend/src/file.ts'],
|
||||
'test-project',
|
||||
37777,
|
||||
path.join(tempDir, 'frontend') // cwd is already inside frontend/
|
||||
path.join(tempDir, 'frontend')
|
||||
);
|
||||
|
||||
// Should NOT make API call because resolved path has frontend/frontend/
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -740,10 +710,9 @@ describe('issue #814 - reject consecutive duplicate path segments', () => {
|
||||
['src/components/file.ts'],
|
||||
'test-project',
|
||||
37777,
|
||||
path.join(tempDir, 'src') // cwd is already inside src/
|
||||
path.join(tempDir, 'src')
|
||||
);
|
||||
|
||||
// resolved path = tempDir/src/src/components/file.ts → has src/src/
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -757,7 +726,6 @@ describe('issue #814 - reject consecutive duplicate path segments', () => {
|
||||
} as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
// Non-consecutive: src/components/src/utils → allowed
|
||||
await updateFolderClaudeMdFiles(
|
||||
['src/components/src/utils/file.ts'],
|
||||
'test-project',
|
||||
@@ -765,7 +733,6 @@ describe('issue #814 - reject consecutive duplicate path segments', () => {
|
||||
tempDir
|
||||
);
|
||||
|
||||
// Should process because segments are non-consecutive
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -775,7 +742,6 @@ describe('issue #859 - skip folders with active CLAUDE.md', () => {
|
||||
const fetchMock = mock(() => Promise.resolve({ ok: true } as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
// Simulate reading CLAUDE.md - should skip that folder
|
||||
await updateFolderClaudeMdFiles(
|
||||
['/project/src/utils/CLAUDE.md'],
|
||||
'test-project',
|
||||
@@ -783,7 +749,6 @@ describe('issue #859 - skip folders with active CLAUDE.md', () => {
|
||||
'/project'
|
||||
);
|
||||
|
||||
// Should NOT make API call since the CLAUDE.md file was read
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -791,7 +756,6 @@ describe('issue #859 - skip folders with active CLAUDE.md', () => {
|
||||
const fetchMock = mock(() => Promise.resolve({ ok: true } as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
// Simulate modifying CLAUDE.md - should skip that folder
|
||||
await updateFolderClaudeMdFiles(
|
||||
['/project/src/CLAUDE.md'],
|
||||
'test-project',
|
||||
@@ -799,7 +763,6 @@ describe('issue #859 - skip folders with active CLAUDE.md', () => {
|
||||
'/project'
|
||||
);
|
||||
|
||||
// Should NOT make API call since the CLAUDE.md file was modified
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -813,18 +776,16 @@ describe('issue #859 - skip folders with active CLAUDE.md', () => {
|
||||
} as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
// Mix of CLAUDE.md read and other files
|
||||
await updateFolderClaudeMdFiles(
|
||||
[
|
||||
'/project/src/utils/CLAUDE.md', // Should skip /project/src/utils
|
||||
'/project/src/services/api.ts' // Should process /project/src/services
|
||||
'/project/src/services/api.ts'
|
||||
],
|
||||
'test-project',
|
||||
37777,
|
||||
'/project'
|
||||
);
|
||||
|
||||
// Should make ONE API call for /project/src/services, NOT for /project/src/utils
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const callUrl = (fetchMock.mock.calls[0] as unknown[])[0] as string;
|
||||
expect(callUrl).toContain(encodeURIComponent('/project/src/services'));
|
||||
@@ -835,7 +796,6 @@ describe('issue #859 - skip folders with active CLAUDE.md', () => {
|
||||
const fetchMock = mock(() => Promise.resolve({ ok: true } as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
// Relative path to CLAUDE.md
|
||||
await updateFolderClaudeMdFiles(
|
||||
['src/components/CLAUDE.md'],
|
||||
'test-project',
|
||||
@@ -843,7 +803,6 @@ describe('issue #859 - skip folders with active CLAUDE.md', () => {
|
||||
'/project'
|
||||
);
|
||||
|
||||
// Should NOT make API call since CLAUDE.md was accessed
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -857,7 +816,6 @@ describe('issue #859 - skip folders with active CLAUDE.md', () => {
|
||||
} as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
// Two CLAUDE.md files in different folders, plus a regular file
|
||||
await updateFolderClaudeMdFiles(
|
||||
[
|
||||
'/project/src/a/CLAUDE.md',
|
||||
@@ -869,7 +827,6 @@ describe('issue #859 - skip folders with active CLAUDE.md', () => {
|
||||
'/project'
|
||||
);
|
||||
|
||||
// Should only process folder c, not a or b
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const callUrl = (fetchMock.mock.calls[0] as unknown[])[0] as string;
|
||||
expect(callUrl).toContain(encodeURIComponent('/project/src/c'));
|
||||
@@ -879,12 +836,10 @@ describe('issue #859 - skip folders with active CLAUDE.md', () => {
|
||||
const fetchMock = mock(() => Promise.resolve({ ok: true } as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
// Create a temp dir with .git to simulate project root
|
||||
const projectRoot = join(tempDir, 'git-project');
|
||||
const gitDir = join(projectRoot, '.git');
|
||||
mkdirSync(gitDir, { recursive: true });
|
||||
|
||||
// File at project root
|
||||
await updateFolderClaudeMdFiles(
|
||||
[join(projectRoot, 'file.ts')],
|
||||
'test-project',
|
||||
@@ -892,7 +847,6 @@ describe('issue #859 - skip folders with active CLAUDE.md', () => {
|
||||
projectRoot
|
||||
);
|
||||
|
||||
// Should NOT make API call because it's the project root
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -992,7 +946,6 @@ describe('issue #912 - skip unsafe directories for CLAUDE.md generation', () =>
|
||||
const fetchMock = mock(() => Promise.resolve({ ok: true } as Response));
|
||||
global.fetch = fetchMock;
|
||||
|
||||
// node_modules nested deep inside project
|
||||
await updateFolderClaudeMdFiles(
|
||||
['packages/frontend/node_modules/react/index.js'],
|
||||
'test-project',
|
||||
@@ -1099,7 +1052,7 @@ describe('CLAUDE.local.md support', () => {
|
||||
[
|
||||
'/project/src/a/CLAUDE.md', // Skip folder a (regular)
|
||||
'/project/src/b/CLAUDE.local.md', // Skip folder b (local)
|
||||
'/project/src/c/file.ts' // Process folder c
|
||||
'/project/src/c/file.ts'
|
||||
],
|
||||
'test-project',
|
||||
37777,
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
/**
|
||||
* Direct implementation of formatTool for testing
|
||||
* This avoids Bun's mock.module() pollution from parallel tests
|
||||
* The logic is identical to Logger.formatTool in src/utils/logger.ts
|
||||
*/
|
||||
function formatTool(toolName: string, toolInput?: any): string {
|
||||
if (!toolInput) return toolName;
|
||||
|
||||
@@ -13,37 +8,30 @@ function formatTool(toolName: string, toolInput?: any): string {
|
||||
try {
|
||||
input = JSON.parse(toolInput);
|
||||
} catch {
|
||||
// Input is a raw string (e.g., Bash command), use as-is
|
||||
input = toolInput;
|
||||
}
|
||||
}
|
||||
|
||||
// Bash: show full command
|
||||
if (toolName === 'Bash' && input.command) {
|
||||
return `${toolName}(${input.command})`;
|
||||
}
|
||||
|
||||
// File operations: show full path
|
||||
if (input.file_path) {
|
||||
return `${toolName}(${input.file_path})`;
|
||||
}
|
||||
|
||||
// NotebookEdit: show full notebook path
|
||||
if (input.notebook_path) {
|
||||
return `${toolName}(${input.notebook_path})`;
|
||||
}
|
||||
|
||||
// Glob: show full pattern
|
||||
if (toolName === 'Glob' && input.pattern) {
|
||||
return `${toolName}(${input.pattern})`;
|
||||
}
|
||||
|
||||
// Grep: show full pattern
|
||||
if (toolName === 'Grep' && input.pattern) {
|
||||
return `${toolName}(${input.pattern})`;
|
||||
}
|
||||
|
||||
// WebFetch/WebSearch: show full URL or query
|
||||
if (input.url) {
|
||||
return `${toolName}(${input.url})`;
|
||||
}
|
||||
@@ -52,7 +40,6 @@ function formatTool(toolName: string, toolInput?: any): string {
|
||||
return `${toolName}(${input.query})`;
|
||||
}
|
||||
|
||||
// Task: show subagent_type or full description
|
||||
if (toolName === 'Task') {
|
||||
if (input.subagent_type) {
|
||||
return `${toolName}(${input.subagent_type})`;
|
||||
@@ -62,17 +49,14 @@ function formatTool(toolName: string, toolInput?: any): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Skill: show skill name
|
||||
if (toolName === 'Skill' && input.skill) {
|
||||
return `${toolName}(${input.skill})`;
|
||||
}
|
||||
|
||||
// LSP: show operation type
|
||||
if (toolName === 'LSP' && input.operation) {
|
||||
return `${toolName}(${input.operation})`;
|
||||
}
|
||||
|
||||
// Default: just show tool name
|
||||
return toolName;
|
||||
}
|
||||
|
||||
@@ -101,9 +85,7 @@ describe('logger.formatTool()', () => {
|
||||
|
||||
describe('Raw non-JSON string input (Issue #545 bug fix)', () => {
|
||||
it('should handle raw command string without crashing', () => {
|
||||
// This was the bug: raw strings caused JSON.parse to throw
|
||||
const result = formatTool('Bash', 'raw command string');
|
||||
// Since it's not JSON, it should just return the tool name
|
||||
expect(result).toBe('Bash');
|
||||
});
|
||||
|
||||
@@ -119,7 +101,6 @@ describe('logger.formatTool()', () => {
|
||||
|
||||
it('should handle empty string input', () => {
|
||||
const result = formatTool('Bash', '');
|
||||
// Empty string is falsy, so returns just the tool name early
|
||||
expect(result).toBe('Bash');
|
||||
});
|
||||
|
||||
@@ -193,13 +174,11 @@ describe('logger.formatTool()', () => {
|
||||
});
|
||||
|
||||
it('should return just tool name when toolInput is 0', () => {
|
||||
// 0 is falsy
|
||||
const result = formatTool('Task', 0);
|
||||
expect(result).toBe('Task');
|
||||
});
|
||||
|
||||
it('should return just tool name when toolInput is false', () => {
|
||||
// false is falsy
|
||||
const result = formatTool('Task', false);
|
||||
expect(result).toBe('Task');
|
||||
});
|
||||
@@ -337,19 +316,16 @@ describe('logger.formatTool()', () => {
|
||||
});
|
||||
|
||||
it('should extract url from unknown tools if present', () => {
|
||||
// url is a generic extractor
|
||||
const result = formatTool('CustomFetch', { url: 'https://api.custom.com' });
|
||||
expect(result).toBe('CustomFetch(https://api.custom.com)');
|
||||
});
|
||||
|
||||
it('should extract query from unknown tools if present', () => {
|
||||
// query is a generic extractor
|
||||
const result = formatTool('CustomSearch', { query: 'find something' });
|
||||
expect(result).toBe('CustomSearch(find something)');
|
||||
});
|
||||
|
||||
it('should extract file_path from unknown tools if present', () => {
|
||||
// file_path is a generic extractor
|
||||
const result = formatTool('CustomFileTool', { file_path: '/some/path.txt' });
|
||||
expect(result).toBe('CustomFileTool(/some/path.txt)');
|
||||
});
|
||||
@@ -390,21 +366,17 @@ describe('logger.formatTool()', () => {
|
||||
});
|
||||
|
||||
it('should handle number values in fields correctly', () => {
|
||||
// If command is a number, it gets stringified
|
||||
const result = formatTool('Bash', { command: 123 });
|
||||
expect(result).toBe('Bash(123)');
|
||||
});
|
||||
|
||||
it('should handle JSON array as input', () => {
|
||||
// Arrays don't have command/file_path/etc fields
|
||||
const result = formatTool('Unknown', ['item1', 'item2']);
|
||||
expect(result).toBe('Unknown');
|
||||
});
|
||||
|
||||
it('should handle JSON string that parses to a primitive', () => {
|
||||
// JSON.parse("123") = 123 (number)
|
||||
const result = formatTool('Task', '"a plain string"');
|
||||
// After parsing, input becomes "a plain string" which has no recognized fields
|
||||
expect(result).toBe('Task');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
/**
|
||||
* Project Filter Tests
|
||||
*
|
||||
* Tests glob-based path matching for project exclusion.
|
||||
* Source: src/utils/project-filter.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { isProjectExcluded } from '../../src/utils/project-filter.js';
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
/**
|
||||
* Regression test for mock.module() worker pollution (#1299)
|
||||
*
|
||||
* context-reinjection-guard.test.ts used to call mock.module('../../src/utils/project-name.js', ...)
|
||||
* at the top level, which permanently stubbed getProjectName to return 'test-project'
|
||||
* for every subsequent import in the same Bun worker process.
|
||||
*
|
||||
* Without bunfig.toml [test] smol=true, this test would fail when Bun scheduled
|
||||
* it in the same worker as context-reinjection-guard.test.ts, because the module
|
||||
* was mocked before these tests ran and getProjectName() returned 'test-project'
|
||||
* instead of the real extracted basename.
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { getProjectName } from '../../src/utils/project-name.js';
|
||||
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
/**
|
||||
* Project Name Tests
|
||||
*
|
||||
* Tests tilde expansion and project name extraction.
|
||||
* Source: src/utils/project-name.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
|
||||
import { homedir } from 'os';
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
/**
|
||||
* Regression test for misplaced smart-explore language docs (#1651)
|
||||
*
|
||||
* The smart-explore language support section was missing from smart-explore/SKILL.md
|
||||
* and had previously been in mem-search/SKILL.md (where it doesn't belong).
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
@@ -48,7 +42,6 @@ describe('skill docs placement (#1651)', () => {
|
||||
expect(existsSync(path)).toBe(true);
|
||||
const content = readFileSync(path, 'utf-8');
|
||||
|
||||
// Language support docs belong in smart-explore, not mem-search
|
||||
expect(content).not.toContain('tree-sitter');
|
||||
expect(content).not.toContain('Bundled Languages');
|
||||
});
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
/**
|
||||
* Tag Stripping Utility Tests
|
||||
*
|
||||
* Tests the tag privacy system for <private>, <claude-mem-context>, and <system_instruction> tags.
|
||||
* These tags enable users and the system to exclude content from memory storage.
|
||||
*
|
||||
* Sources:
|
||||
* - Implementation from src/utils/tag-stripping.ts
|
||||
* - Privacy patterns from src/services/worker/http/routes/SessionRoutes.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
|
||||
import { stripMemoryTagsFromPrompt, stripMemoryTagsFromJson, isInternalProtocolPayload } from '../../src/utils/tag-stripping.js';
|
||||
import { logger } from '../../src/utils/logger.js';
|
||||
|
||||
// Suppress logger output during tests
|
||||
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
describe('Tag Stripping Utilities', () => {
|
||||
@@ -77,7 +66,6 @@ describe('Tag Stripping Utilities', () => {
|
||||
}
|
||||
input += ' end';
|
||||
const result = stripMemoryTagsFromPrompt(input);
|
||||
// Tags are stripped but spaces between them remain
|
||||
expect(result).not.toContain('<private>');
|
||||
expect(result).not.toContain('<claude-mem-context>');
|
||||
expect(result).toContain('start');
|
||||
@@ -164,7 +152,6 @@ finish`;
|
||||
|
||||
describe('ReDoS protection', () => {
|
||||
it('should handle content with many tags without hanging (< 1 second)', async () => {
|
||||
// Generate content with many tags
|
||||
let content = '';
|
||||
for (let i = 0; i < 150; i++) {
|
||||
content += `<private>secret${i}</private> text${i} `;
|
||||
@@ -174,16 +161,12 @@ finish`;
|
||||
const result = stripMemoryTagsFromPrompt(content);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// Should complete quickly despite many tags
|
||||
expect(duration).toBeLessThan(1000);
|
||||
// Should not contain any private content
|
||||
expect(result).not.toContain('<private>');
|
||||
// Should warn about exceeding tag limit
|
||||
expect(loggerSpies[2]).toHaveBeenCalled(); // warn spy
|
||||
expect(loggerSpies[2]).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should process within reasonable time with nested-looking patterns', () => {
|
||||
// Content that looks like it could cause backtracking
|
||||
const content = '<private>' + 'x'.repeat(10000) + '</private> keep this';
|
||||
|
||||
const startTime = Date.now();
|
||||
@@ -392,11 +375,9 @@ after`;
|
||||
|
||||
describe('privacy enforcement integration', () => {
|
||||
it('should allow empty result to trigger privacy skip', () => {
|
||||
// Simulates what SessionRoutes does with private-only prompts
|
||||
const prompt = '<private>entirely private prompt</private>';
|
||||
const cleanedPrompt = stripMemoryTagsFromPrompt(prompt);
|
||||
|
||||
// Empty/whitespace prompts should trigger skip
|
||||
const shouldSkip = !cleanedPrompt || cleanedPrompt.trim() === '';
|
||||
expect(shouldSkip).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, beforeEach } from 'bun:test';
|
||||
|
||||
class MemoryStorage {
|
||||
private data: Map<string, string> = new Map();
|
||||
getItem(key: string): string | null {
|
||||
return this.data.has(key) ? this.data.get(key)! : null;
|
||||
}
|
||||
setItem(key: string, value: string): void {
|
||||
this.data.set(key, value);
|
||||
}
|
||||
removeItem(key: string): void {
|
||||
this.data.delete(key);
|
||||
}
|
||||
clear(): void {
|
||||
this.data.clear();
|
||||
}
|
||||
get length(): number {
|
||||
return this.data.size;
|
||||
}
|
||||
key(index: number): string | null {
|
||||
return Array.from(this.data.keys())[index] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
const memStore = new MemoryStorage();
|
||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage = memStore;
|
||||
|
||||
const STORAGE_KEY = 'claude-mem-welcome-dismissed-v2';
|
||||
const LEGACY_KEY = 'claude-mem-welcome-dismissed-v1';
|
||||
|
||||
import {
|
||||
getStoredWelcomeDismissed,
|
||||
setStoredWelcomeDismissed,
|
||||
} from '../../src/ui/viewer/components/WelcomeCard';
|
||||
|
||||
describe('WelcomeCard storage helpers (v2 key)', () => {
|
||||
beforeEach(() => {
|
||||
memStore.clear();
|
||||
});
|
||||
|
||||
it('returns false when nothing has been stored', () => {
|
||||
expect(getStoredWelcomeDismissed()).toBe(false);
|
||||
});
|
||||
|
||||
it('persists dismissal under the v2 key', () => {
|
||||
setStoredWelcomeDismissed(true);
|
||||
expect(memStore.getItem(STORAGE_KEY)).toBe('true');
|
||||
expect(getStoredWelcomeDismissed()).toBe(true);
|
||||
});
|
||||
|
||||
it('clears the v2 key when dismissed=false', () => {
|
||||
setStoredWelcomeDismissed(true);
|
||||
setStoredWelcomeDismissed(false);
|
||||
expect(memStore.getItem(STORAGE_KEY)).toBeNull();
|
||||
expect(getStoredWelcomeDismissed()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not consult the v1 legacy key', () => {
|
||||
memStore.setItem(LEGACY_KEY, 'true');
|
||||
expect(getStoredWelcomeDismissed()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,18 +4,6 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, rmSync
|
||||
import { homedir } from 'os';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Worker Self-Spawn Integration Tests
|
||||
*
|
||||
* Tests actual integration points:
|
||||
* - Health check utilities (real network behavior)
|
||||
* - PID file management (real filesystem)
|
||||
* - Status command output format
|
||||
* - Windows-specific behavior detection
|
||||
*
|
||||
* Removed: JSON.parse tests, CLI command parsing (tests language built-ins)
|
||||
*/
|
||||
|
||||
const TEST_PORT = 37877;
|
||||
const TEST_DATA_DIR = path.join(homedir(), '.claude-mem-test');
|
||||
const TEST_PID_FILE = path.join(TEST_DATA_DIR, 'worker.pid');
|
||||
@@ -27,9 +15,6 @@ interface PidInfo {
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check if port is in use by attempting a health check
|
||||
*/
|
||||
async function isPortInUse(port: number): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
||||
@@ -41,9 +26,6 @@ async function isPortInUse(port: number): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to wait for port to be healthy
|
||||
*/
|
||||
async function waitForHealth(port: number, timeoutMs: number = 30000): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
@@ -53,9 +35,6 @@ async function waitForHealth(port: number, timeoutMs: number = 30000): Promise<b
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run worker CLI command and return stdout
|
||||
*/
|
||||
function runWorkerCommand(command: string, env: Record<string, string> = {}): string {
|
||||
const result = execSync(`bun "${WORKER_SCRIPT}" ${command}`, {
|
||||
env: { ...process.env, ...env },
|
||||
@@ -81,7 +60,6 @@ describe('Worker Self-Spawn CLI', () => {
|
||||
describe('status command', () => {
|
||||
it('should report worker status in expected format', async () => {
|
||||
const output = runWorkerCommand('status');
|
||||
// Should contain either "running" or "not running"
|
||||
expect(output.includes('running')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -112,7 +90,6 @@ describe('Worker Self-Spawn CLI', () => {
|
||||
expect(readInfo.port).toBe(TEST_PORT);
|
||||
expect(readInfo.startedAt).toBe(testPidInfo.startedAt);
|
||||
|
||||
// Cleanup
|
||||
unlinkSync(TEST_PID_FILE);
|
||||
expect(existsSync(TEST_PID_FILE)).toBe(false);
|
||||
});
|
||||
@@ -131,7 +108,6 @@ describe('Worker Self-Spawn CLI', () => {
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(result).toBe(false);
|
||||
// Should not wait longer than the timeout (2s) + small buffer
|
||||
expect(elapsed).toBeLessThan(3000);
|
||||
});
|
||||
});
|
||||
@@ -141,7 +117,6 @@ describe('Worker Health Endpoints', () => {
|
||||
let workerProcess: ChildProcess | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Skip if worker script doesn't exist (not built)
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping worker health tests - worker script not built');
|
||||
return;
|
||||
@@ -157,7 +132,6 @@ describe('Worker Health Endpoints', () => {
|
||||
|
||||
describe('health endpoint contract', () => {
|
||||
it('should expect /api/health to return status ok with expected fields', async () => {
|
||||
// Contract validation: verify expected response structure
|
||||
const mockResponse = {
|
||||
status: 'ok',
|
||||
build: 'TEST-008-wrapper-ipc',
|
||||
@@ -212,9 +186,8 @@ describe('Windows-specific behavior', () => {
|
||||
expect(isWindows).toBe(true);
|
||||
expect(isManaged).toBe(true);
|
||||
|
||||
// In non-managed mode (without process.send), IPC messages won't work
|
||||
const hasProcessSend = typeof process.send === 'function';
|
||||
const isWindowsManaged = isWindows && isManaged && hasProcessSend;
|
||||
expect(isWindowsManaged).toBe(false); // No process.send in test context
|
||||
expect(isWindowsManaged).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,36 +1,5 @@
|
||||
/**
|
||||
* Regression coverage for SearchManager.timeline() anchor dispatch.
|
||||
*
|
||||
* Bug history: HTTP query params arrive as strings, so the
|
||||
* `typeof anchor === 'number'` dispatch missed the observation-ID branch
|
||||
* and silently fell through to ISO-timestamp parsing — returning a
|
||||
* wrong-epoch window with the correct anchor still echoed in the header.
|
||||
*
|
||||
* The fix coerces stringified numerics in `SearchManager.timeline()` via
|
||||
* `anchorAsNumber`. These tests guard that fix by exercising:
|
||||
* (a) numeric anchor as JS number
|
||||
* (b) numeric anchor as string (THE bug case)
|
||||
* (c) session-ID string anchor "S<n>"
|
||||
* (d) ISO-timestamp anchor
|
||||
* (e) garbage anchor (must return isError: true)
|
||||
*
|
||||
* Pattern source: tests/session_store.test.ts uses real SessionStore
|
||||
* against ':memory:' SQLite. We follow the same approach (no SessionStore
|
||||
* mocks) and additionally instantiate real SessionSearch over the same DB
|
||||
* handle, plus real FormattingService and TimelineService. ChromaSync is
|
||||
* passed as null (the timeline anchor branch does not require Chroma).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||
|
||||
// ModeManager is a global singleton that requires `loadMode()` to be
|
||||
// called before use. The formatter path inside `SearchManager.timeline()`
|
||||
// calls `ModeManager.getInstance().getTypeIcon(...)`, which throws if no
|
||||
// mode is loaded. Existing worker tests (e.g. tests/worker/search/
|
||||
// result-formatter.test.ts) follow the same pattern: stub ModeManager
|
||||
// so the unrelated config singleton does not blow up the unit under
|
||||
// test. We deliberately do NOT mock SessionStore — that's the data
|
||||
// layer the bug travelled through, and faking it would defeat the
|
||||
// regression coverage.
|
||||
mock.module('../../src/services/domain/ModeManager.js', () => ({
|
||||
ModeManager: {
|
||||
getInstance: () => ({
|
||||
@@ -69,10 +38,8 @@ function seedObservations(store: SessionStore, count: number): SeededObservation
|
||||
const sdkId = store.createSDKSession(CONTENT_SESSION_ID, PROJECT, 'initial prompt');
|
||||
store.updateMemorySessionId(sdkId, MEMORY_SESSION_ID);
|
||||
|
||||
// Anchor the synthetic timeline well in the past so it cannot collide with
|
||||
// any "recent rows" the buggy code path would otherwise return.
|
||||
const baseEpoch = Date.UTC(2024, 0, 1, 0, 0, 0); // 2024-01-01T00:00:00Z
|
||||
const stepMs = 60_000; // 1 minute apart, deterministic ordering
|
||||
const baseEpoch = Date.UTC(2024, 0, 1, 0, 0, 0);
|
||||
const stepMs = 60_000;
|
||||
|
||||
const seeded: SeededObservation[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
@@ -99,13 +66,6 @@ function seedObservations(store: SessionStore, count: number): SeededObservation
|
||||
return seeded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the observation IDs out of the timeline's formatted markdown.
|
||||
* Each observation row renders as `| #<id> | <time> | ...` (see
|
||||
* SearchManager.timeline() formatter, ~line 744). We only want
|
||||
* observation IDs (rows starting with `| #` followed by a digit) — we
|
||||
* deliberately skip session rows (`| #S...`) and prompt headers.
|
||||
*/
|
||||
function extractObservationIds(formattedText: string): number[] {
|
||||
const ids: number[] = [];
|
||||
const rowRegex = /^\|\s*#(\d+)\s*\|/gm;
|
||||
@@ -133,8 +93,6 @@ describe('SearchManager.timeline() anchor dispatch', () => {
|
||||
let seeded: SeededObservation[];
|
||||
|
||||
beforeEach(() => {
|
||||
// Real SQLite, shared connection between store + search (same wiring
|
||||
// DatabaseManager uses in production at src/services/worker/DatabaseManager.ts:34-35).
|
||||
db = new Database(':memory:');
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
store = new SessionStore(db);
|
||||
@@ -155,8 +113,7 @@ describe('SearchManager.timeline() anchor dispatch', () => {
|
||||
});
|
||||
|
||||
it('(a) numeric anchor passed as JS number returns the 7-id window around the anchor', async () => {
|
||||
// depth_before=3 + anchor + depth_after=3 = 7 IDs
|
||||
const middle = seeded[24]; // 25th observation (index 24)
|
||||
const middle = seeded[24];
|
||||
const expectedIds = seeded.slice(21, 28).map((o) => o.id);
|
||||
|
||||
const response = await manager.timeline({
|
||||
@@ -168,17 +125,11 @@ describe('SearchManager.timeline() anchor dispatch', () => {
|
||||
expect(response.isError).not.toBe(true);
|
||||
const text: string = response.content[0].text;
|
||||
const returnedIds = extractObservationIds(text);
|
||||
// Exact sequence equality — chronological order matters, not just membership.
|
||||
expect(returnedIds).toEqual(expectedIds);
|
||||
// Header must echo the anchor ID and the anchor row must be marked.
|
||||
expectAnchorRendered(text, middle.id);
|
||||
});
|
||||
|
||||
it('(b) numeric anchor passed as STRING returns the 7-id window around the anchor (THE bug case)', async () => {
|
||||
// This is the exact regression that motivated Phase 2's anchorAsNumber
|
||||
// coercion. Without that fix, the response collapsed to the most
|
||||
// recent rows because `new Date("<digits>")` produced a wrong-epoch
|
||||
// window, while the header still echoed the requested anchor.
|
||||
const middle = seeded[24];
|
||||
const expectedIds = seeded.slice(21, 28).map((o) => o.id);
|
||||
|
||||
@@ -209,18 +160,10 @@ describe('SearchManager.timeline() anchor dispatch', () => {
|
||||
const text: string = response.content[0].text;
|
||||
const returnedIds = extractObservationIds(text);
|
||||
expect(returnedIds).toEqual(expectedIds);
|
||||
// Whitespace must be trimmed in the rendered header — the trimmed numeric ID, not the padded string.
|
||||
expectAnchorRendered(text, middle.id);
|
||||
});
|
||||
|
||||
it('(c) session-ID anchor "S<n>" routes to the timestamp branch and returns a non-error response', async () => {
|
||||
// Look up the SDK session row id directly. The timeline session
|
||||
// anchor branch (SearchManager.timeline ~line 576) parses the integer
|
||||
// after the "S" and calls getSessionSummariesByIds, so we need a row
|
||||
// in session_summaries for this id. Build one off the existing
|
||||
// memory session.
|
||||
// Anchor the synthetic summary on the same epoch as the middle
|
||||
// observation so the timestamp branch lands inside the seeded range.
|
||||
const middle = seeded[24];
|
||||
const summaryResult = store.storeSummary(
|
||||
MEMORY_SESSION_ID,
|
||||
@@ -246,18 +189,12 @@ describe('SearchManager.timeline() anchor dispatch', () => {
|
||||
});
|
||||
|
||||
expect(response.isError).not.toBe(true);
|
||||
// We do not assert the exact ID set here — getTimelineAroundTimestamp
|
||||
// returns whatever lives near the session's epoch. The invariant the
|
||||
// bug was about (numeric coercion not stealing string anchors) is
|
||||
// captured by the fact that this call does NOT 404 and does NOT hit
|
||||
// the invalid-anchor branch.
|
||||
const text: string = response.content[0].text;
|
||||
expect(typeof text).toBe('string');
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('(d) ISO-timestamp anchor routes to the timestamp branch and returns a non-error response', async () => {
|
||||
// Pick an ISO timestamp in the middle of our seeded range.
|
||||
const middle = seeded[24];
|
||||
const isoAnchor = new Date(middle.epoch).toISOString();
|
||||
|
||||
@@ -269,8 +206,6 @@ describe('SearchManager.timeline() anchor dispatch', () => {
|
||||
|
||||
expect(response.isError).not.toBe(true);
|
||||
const text: string = response.content[0].text;
|
||||
// ISO branch uses a timestamp window — the seeded observation closest
|
||||
// to the requested epoch must appear somewhere in the result.
|
||||
const returnedIds = extractObservationIds(text);
|
||||
expect(returnedIds).toContain(middle.id);
|
||||
});
|
||||
@@ -284,9 +219,6 @@ describe('SearchManager.timeline() anchor dispatch', () => {
|
||||
|
||||
expect(response.isError).toBe(true);
|
||||
const text: string = response.content[0].text;
|
||||
// Garbage strings must hit the ISO-timestamp branch and surface its
|
||||
// concrete "Invalid timestamp" error — not the numeric-observation
|
||||
// branch (which would mean `anchorAsNumber` silently coerced "123abc").
|
||||
expect(text).toBe('Invalid timestamp: 123abc');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
/**
|
||||
* Tests for fallback error classification logic
|
||||
*
|
||||
* Mock Justification: NONE (0% mock code)
|
||||
* - Tests pure functions directly with no external dependencies
|
||||
* - shouldFallbackToClaude: Pattern matching on error messages
|
||||
* - isAbortError: Simple type checking
|
||||
*
|
||||
* High-value tests: Ensure correct provider fallback behavior for transient errors
|
||||
*/
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
// Import directly from specific files to avoid worker-service import chain
|
||||
import { shouldFallbackToClaude, isAbortError } from '../../../src/services/worker/agents/FallbackErrorHandler.js';
|
||||
import { FALLBACK_ERROR_PATTERNS } from '../../../src/services/worker/agents/types.js';
|
||||
|
||||
@@ -112,8 +101,8 @@ describe('FallbackErrorHandler', () => {
|
||||
});
|
||||
|
||||
it('should handle non-error objects by stringifying', () => {
|
||||
expect(shouldFallbackToClaude({ code: 429 })).toBe(false); // toString won't include 429
|
||||
expect(shouldFallbackToClaude(429)).toBe(true); // number 429 stringifies to "429"
|
||||
expect(shouldFallbackToClaude({ code: 429 })).toBe(false);
|
||||
expect(shouldFallbackToClaude(429)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test';
|
||||
import { logger } from '../../../src/utils/logger.js';
|
||||
|
||||
// Mock modules that cause import chain issues - MUST be before imports
|
||||
// Use full paths from test file location
|
||||
mock.module('../../../src/services/worker-service.js', () => ({
|
||||
updateCursorContextForProject: () => Promise.resolve(),
|
||||
}));
|
||||
@@ -11,7 +9,6 @@ mock.module('../../../src/shared/worker-utils.js', () => ({
|
||||
getWorkerPort: () => 37777,
|
||||
}));
|
||||
|
||||
// Mock the ModeManager
|
||||
mock.module('../../../src/services/domain/ModeManager.js', () => ({
|
||||
ModeManager: {
|
||||
getInstance: () => ({
|
||||
@@ -29,7 +26,6 @@ mock.module('../../../src/services/domain/ModeManager.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocks
|
||||
import { processAgentResponse } from '../../../src/services/worker/agents/ResponseProcessor.js';
|
||||
import { SUMMARY_MODE_MARKER } from '../../../src/sdk/prompts.js';
|
||||
import type { WorkerRef, StorageResult } from '../../../src/services/worker/agents/types.js';
|
||||
@@ -37,11 +33,9 @@ import type { ActiveSession } from '../../../src/services/worker-types.js';
|
||||
import type { DatabaseManager } from '../../../src/services/worker/DatabaseManager.js';
|
||||
import type { SessionManager } from '../../../src/services/worker/SessionManager.js';
|
||||
|
||||
// Spy on logger methods to suppress output during tests
|
||||
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
describe('ResponseProcessor', () => {
|
||||
// Mocks
|
||||
let mockStoreObservations: ReturnType<typeof mock>;
|
||||
let mockChromaSyncObservation: ReturnType<typeof mock>;
|
||||
let mockChromaSyncSummary: ReturnType<typeof mock>;
|
||||
@@ -52,7 +46,6 @@ describe('ResponseProcessor', () => {
|
||||
let mockWorker: WorkerRef;
|
||||
|
||||
beforeEach(() => {
|
||||
// Spy on logger to suppress output
|
||||
loggerSpies = [
|
||||
spyOn(logger, 'info').mockImplementation(() => {}),
|
||||
spyOn(logger, 'debug').mockImplementation(() => {}),
|
||||
@@ -60,7 +53,6 @@ describe('ResponseProcessor', () => {
|
||||
spyOn(logger, 'error').mockImplementation(() => {}),
|
||||
];
|
||||
|
||||
// Create fresh mocks for each test
|
||||
mockStoreObservations = mock(() => ({
|
||||
observationIds: [1, 2],
|
||||
summaryId: 1,
|
||||
@@ -110,7 +102,6 @@ describe('ResponseProcessor', () => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
// Helper to create mock session
|
||||
function createMockSession(
|
||||
overrides: Partial<ActiveSession> = {}
|
||||
): ActiveSession {
|
||||
@@ -248,8 +239,6 @@ describe('ResponseProcessor', () => {
|
||||
describe('parsing summary from XML response', () => {
|
||||
it('should parse summary from response', async () => {
|
||||
const session = createMockSession();
|
||||
// PATHFINDER plan 03 phase 1: parseAgentXml returns one kind per call.
|
||||
// Summary-only response exercises the summary path.
|
||||
const responseText = `
|
||||
<summary>
|
||||
<request>Build login form</request>
|
||||
@@ -292,7 +281,6 @@ describe('ResponseProcessor', () => {
|
||||
</observation>
|
||||
`;
|
||||
|
||||
// Mock to return result without summary
|
||||
mockStoreObservations = mock(() => ({
|
||||
observationIds: [1],
|
||||
summaryId: null,
|
||||
@@ -352,10 +340,8 @@ describe('ResponseProcessor', () => {
|
||||
'TestAgent'
|
||||
);
|
||||
|
||||
// Verify storeObservations was called exactly once (atomic)
|
||||
expect(mockStoreObservations).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify all parameters passed correctly
|
||||
const [
|
||||
memorySessionId,
|
||||
project,
|
||||
@@ -369,10 +355,6 @@ describe('ResponseProcessor', () => {
|
||||
expect(memorySessionId).toBe('memory-session-456');
|
||||
expect(project).toBe('test-project');
|
||||
expect(observations).toHaveLength(1);
|
||||
// PATHFINDER plan 03 phase 1: parseAgentXml returns ONE kind per call.
|
||||
// The first recognised root wins (here: <observation>), so the summary
|
||||
// in the same response is NOT extracted — the caller is expected to
|
||||
// issue observation turns and summary turns separately.
|
||||
expect(summary).toBeNull();
|
||||
expect(promptNumber).toBe(5);
|
||||
expect(tokens).toBe(100);
|
||||
@@ -396,7 +378,6 @@ describe('ResponseProcessor', () => {
|
||||
</observation>
|
||||
`;
|
||||
|
||||
// Mock returning single observation ID
|
||||
mockStoreObservations = mock(() => ({
|
||||
observationIds: [42],
|
||||
summaryId: null,
|
||||
@@ -419,10 +400,8 @@ describe('ResponseProcessor', () => {
|
||||
'TestAgent'
|
||||
);
|
||||
|
||||
// Should broadcast observation
|
||||
expect(mockBroadcast).toHaveBeenCalled();
|
||||
|
||||
// Find the observation broadcast call
|
||||
const observationCall = mockBroadcast.mock.calls.find(
|
||||
(call: any[]) => call[0].type === 'new_observation'
|
||||
);
|
||||
@@ -433,8 +412,6 @@ describe('ResponseProcessor', () => {
|
||||
});
|
||||
|
||||
it('should broadcast summary via SSE', async () => {
|
||||
// PATHFINDER plan 03 phase 1: parseAgentXml returns one kind per call,
|
||||
// so summary broadcasts require a summary-only response.
|
||||
mockStoreObservations = mock(() => ({
|
||||
observationIds: [],
|
||||
summaryId: 99,
|
||||
@@ -468,7 +445,6 @@ describe('ResponseProcessor', () => {
|
||||
'TestAgent'
|
||||
);
|
||||
|
||||
// Find the summary broadcast call
|
||||
const summaryCall = mockBroadcast.mock.calls.find(
|
||||
(call: any[]) => call[0].type === 'new_summary'
|
||||
);
|
||||
@@ -693,8 +669,6 @@ describe('ResponseProcessor', () => {
|
||||
});
|
||||
|
||||
it('should set lastSummaryStored=false when storage returns summaryId=null (silent loss path, #1633)', async () => {
|
||||
// Simulate the silent failure: agent returns no parseable <summary> tags,
|
||||
// storeObservations skips summary and returns summaryId=null.
|
||||
mockStoreObservations.mockImplementation(() => ({
|
||||
observationIds: [],
|
||||
summaryId: null,
|
||||
@@ -702,7 +676,6 @@ describe('ResponseProcessor', () => {
|
||||
} as StorageResult));
|
||||
|
||||
const session = createMockSession();
|
||||
// Response with no <summary> block — LLM failed to produce structured output
|
||||
const responseText = '<skip_summary/>';
|
||||
|
||||
await processAgentResponse(responseText, session, mockDbManager, mockSessionManager, mockWorker, 0, null, 'TestAgent');
|
||||
@@ -711,19 +684,10 @@ describe('ResponseProcessor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// PATHFINDER plan 03 phase 3: circuit breaker (consecutiveSummaryFailures) deleted.
|
||||
// Former tests covered: counter stability on observation turns, increment on
|
||||
// missing summary, neutrality on <skip_summary/>, reset on successful summary.
|
||||
// Replacement coverage: `tests/sdk/parse-summary.test.ts` asserts that the
|
||||
// parser returns `{ valid: false, reason }` for malformed summaries; the
|
||||
// failure path goes through PendingMessageStore.markFailed's retry ladder,
|
||||
// which is unit-tested separately in tests/services/sqlite/.
|
||||
describe.skip('circuit breaker: consecutiveSummaryFailures counter (#1633 — deleted)', () => {
|
||||
const SUMMARY_PROMPT = `--- ${SUMMARY_MODE_MARKER} ---\nDo the summary now.`;
|
||||
|
||||
it('does NOT increment the counter on normal observation responses (P1 regression guard)', async () => {
|
||||
// Session where the last user message is an OBSERVATION request, not a summary request.
|
||||
// The counter must stay at 0 even though the response has <observation> tags and no summary.
|
||||
mockStoreObservations.mockImplementation(() => ({
|
||||
observationIds: [1],
|
||||
summaryId: null,
|
||||
@@ -745,7 +709,6 @@ describe('ResponseProcessor', () => {
|
||||
</observation>
|
||||
`;
|
||||
|
||||
// Drive multiple observation responses — counter must never increment.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await processAgentResponse(obsResponse, session, mockDbManager, mockSessionManager, mockWorker, 0, null, 'TestAgent');
|
||||
}
|
||||
@@ -763,7 +726,6 @@ describe('ResponseProcessor', () => {
|
||||
const session = createMockSession({
|
||||
conversationHistory: [{ role: 'user', content: SUMMARY_PROMPT }],
|
||||
});
|
||||
// LLM returned nothing structured — no summary stored
|
||||
const badResponse = 'I cannot comply with that request.';
|
||||
|
||||
await processAgentResponse(badResponse, session, mockDbManager, mockSessionManager, mockWorker, 0, null, 'TestAgent');
|
||||
@@ -786,7 +748,6 @@ describe('ResponseProcessor', () => {
|
||||
|
||||
await processAgentResponse(skipResponse, session, mockDbManager, mockSessionManager, mockWorker, 0, null, 'TestAgent');
|
||||
|
||||
// Skip is neutral — counter stays where it was, no spurious increment
|
||||
expect(session.consecutiveSummaryFailures).toBe(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
/**
|
||||
* Tests for session cleanup helper functionality
|
||||
*
|
||||
* Mock Justification (~19% mock code):
|
||||
* - Session fixtures: Required to create valid ActiveSession objects with
|
||||
* all required fields - tests the actual cleanup logic
|
||||
* - Worker mocks: Verify broadcast notification calls - the actual
|
||||
* cleanupProcessedMessages logic is tested against real session mutation
|
||||
*
|
||||
* What's NOT mocked: Session state mutation, null/undefined handling
|
||||
*/
|
||||
import { describe, it, expect, mock } from 'bun:test';
|
||||
|
||||
// Import directly from specific files to avoid worker-service import chain
|
||||
import { cleanupProcessedMessages } from '../../../src/services/worker/agents/SessionCleanupHelper.js';
|
||||
import type { WorkerRef } from '../../../src/services/worker/agents/types.js';
|
||||
import type { ActiveSession } from '../../../src/services/worker-types.js';
|
||||
|
||||
describe('SessionCleanupHelper', () => {
|
||||
// Helper to create a minimal mock session
|
||||
function createMockSession(
|
||||
overrides: Partial<ActiveSession> = {}
|
||||
): ActiveSession {
|
||||
@@ -42,7 +29,6 @@ describe('SessionCleanupHelper', () => {
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create mock worker
|
||||
function createMockWorker() {
|
||||
const broadcastProcessingStatusMock = mock(() => {});
|
||||
const worker: WorkerRef = {
|
||||
@@ -93,12 +79,10 @@ describe('SessionCleanupHelper', () => {
|
||||
earliestPendingTimestamp: 1700000000000,
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
expect(() => {
|
||||
cleanupProcessedMessages(session, undefined);
|
||||
}).not.toThrow();
|
||||
|
||||
// Should still reset timestamp
|
||||
expect(session.earliestPendingTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
@@ -113,12 +97,10 @@ describe('SessionCleanupHelper', () => {
|
||||
// No broadcastProcessingStatus
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
expect(() => {
|
||||
cleanupProcessedMessages(session, worker);
|
||||
}).not.toThrow();
|
||||
|
||||
// Should still reset timestamp
|
||||
expect(session.earliestPendingTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
@@ -128,12 +110,10 @@ describe('SessionCleanupHelper', () => {
|
||||
});
|
||||
const worker: WorkerRef = {};
|
||||
|
||||
// Should not throw
|
||||
expect(() => {
|
||||
cleanupProcessedMessages(session, worker);
|
||||
}).not.toThrow();
|
||||
|
||||
// Should still reset timestamp
|
||||
expect(session.earliestPendingTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
@@ -145,12 +125,10 @@ describe('SessionCleanupHelper', () => {
|
||||
broadcastProcessingStatus: undefined,
|
||||
};
|
||||
|
||||
// Should not throw
|
||||
expect(() => {
|
||||
cleanupProcessedMessages(session, worker);
|
||||
}).not.toThrow();
|
||||
|
||||
// Should still reset timestamp
|
||||
expect(session.earliestPendingTimestamp).toBeNull();
|
||||
});
|
||||
|
||||
@@ -166,7 +144,6 @@ describe('SessionCleanupHelper', () => {
|
||||
|
||||
cleanupProcessedMessages(session, worker);
|
||||
|
||||
// Only earliestPendingTimestamp should change
|
||||
expect(session.earliestPendingTimestamp).toBeNull();
|
||||
expect(session.lastPromptNumber).toBe(10);
|
||||
expect(session.cumulativeInputTokens).toBe(500);
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
/**
|
||||
* CorpusRoutes Type Coercion Tests
|
||||
*
|
||||
* Tests that MCP/HTTP clients sending string-encoded corpus filters are coerced
|
||||
* before CorpusBuilder assumes array and number fields.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, mock, beforeEach } from 'bun:test';
|
||||
import type { Request, Response } from 'express';
|
||||
@@ -50,12 +44,6 @@ async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan 06 Phase 3 — body validation lives in `validateBody` middleware now.
|
||||
* Build a single chain function that runs the validateBody middleware
|
||||
* followed by the handler, mirroring how Express dispatches them in
|
||||
* production.
|
||||
*/
|
||||
function captureChain(mockApp: any, targetPath: string): (req: Request, res: Response) => void {
|
||||
let middleware: ((req: Request, res: Response, next: () => void) => void) | undefined;
|
||||
let handler: (req: Request, res: Response) => void;
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
/**
|
||||
* DataRoutes Type Coercion Tests
|
||||
*
|
||||
* Tests that MCP clients sending string-encoded arrays for `ids` and
|
||||
* `memorySessionIds` are properly coerced before validation.
|
||||
*
|
||||
* Mock Justification:
|
||||
* - Express req/res mocks: Required because route handlers expect Express objects
|
||||
* - DatabaseManager/SessionStore: Avoids database setup; we test coercion logic, not queries
|
||||
* - Logger spies: Suppress console output during tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test';
|
||||
import type { Request, Response } from 'express';
|
||||
import { logger } from '../../../../src/utils/logger.js';
|
||||
|
||||
// Mock dependencies before importing DataRoutes
|
||||
mock.module('../../../../src/shared/paths.js', () => ({
|
||||
getPackageRoot: () => '/tmp/test',
|
||||
}));
|
||||
@@ -26,7 +14,6 @@ import { DataRoutes } from '../../../../src/services/worker/http/routes/DataRout
|
||||
|
||||
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
// Helper to create mock req/res
|
||||
function createMockReqRes(body: any): { req: Partial<Request>; res: Partial<Response>; jsonSpy: ReturnType<typeof mock>; statusSpy: ReturnType<typeof mock> } {
|
||||
const jsonSpy = mock(() => {});
|
||||
const statusSpy = mock(() => ({ json: jsonSpy }));
|
||||
@@ -38,12 +25,6 @@ function createMockReqRes(body: any): { req: Partial<Request>; res: Partial<Resp
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan 06 Phase 3 — body validation lives in `validateBody` middleware now.
|
||||
* Build a single chain function that runs the validateBody middleware
|
||||
* followed by the handler, mirroring how Express dispatches them in
|
||||
* production.
|
||||
*/
|
||||
function captureChain(mockApp: any, targetPath: string): (req: Request, res: Response) => void {
|
||||
let middleware: (req: Request, res: Response, next: () => void) => void;
|
||||
let handler: (req: Request, res: Response) => void;
|
||||
@@ -109,7 +90,6 @@ describe('DataRoutes Type Coercion', () => {
|
||||
});
|
||||
|
||||
describe('handleGetObservationsByIds — ids coercion', () => {
|
||||
// Access the handler via setupRoutes
|
||||
let handler: (req: Request, res: Response) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -150,7 +130,6 @@ describe('DataRoutes Type Coercion', () => {
|
||||
const { req, res, statusSpy } = createMockReqRes({ ids: 'foo,bar' });
|
||||
handler(req as Request, res as Response);
|
||||
|
||||
// NaN values should fail the Number.isInteger check
|
||||
expect(statusSpy).toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
/**
|
||||
* MemoryRoutes Tests — POST /api/memory/save (#2116)
|
||||
*
|
||||
* Asserts:
|
||||
* - `metadata` is persisted verbatim (no silent drop)
|
||||
* - top-level `project` wins; `metadata.project` used as fallback
|
||||
* - unknown top-level fields are rejected (400) — no silent drop
|
||||
* - chromaSync is invoked when present, skipped when absent
|
||||
*/
|
||||
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test';
|
||||
import type { Request, Response } from 'express';
|
||||
@@ -86,7 +77,6 @@ describe('MemoryRoutes — POST /api/memory/save (#2116)', () => {
|
||||
storeObservation: mockStoreObservation,
|
||||
getOrCreateManualSession: mockGetOrCreateManualSession,
|
||||
}),
|
||||
// Return null so we skip the chroma path in tests
|
||||
getChromaSync: () => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
|
||||
import { describe, it, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test';
|
||||
import type { Request, Response } from 'express';
|
||||
import { logger } from '../../../../src/utils/logger.js';
|
||||
|
||||
const generateContextStub = mock(async () => 'CONTEXT_FROM_GENERATOR');
|
||||
mock.module('../../../../src/services/context-generator.js', () => ({
|
||||
generateContext: generateContextStub,
|
||||
}));
|
||||
|
||||
import { SearchRoutes } from '../../../../src/services/worker/http/routes/SearchRoutes.js';
|
||||
|
||||
let loggerSpies: ReturnType<typeof spyOn>[] = [];
|
||||
|
||||
interface MockRes {
|
||||
setHeader: ReturnType<typeof mock>;
|
||||
send: ReturnType<typeof mock>;
|
||||
status: ReturnType<typeof mock>;
|
||||
json: ReturnType<typeof mock>;
|
||||
headersSent: boolean;
|
||||
}
|
||||
|
||||
function createMockRes(): MockRes {
|
||||
const res: MockRes = {
|
||||
setHeader: mock(() => {}),
|
||||
send: mock(() => {}),
|
||||
status: mock(() => res as any),
|
||||
json: mock(() => {}),
|
||||
headersSent: false,
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
function captureContextInjectHandler(routes: SearchRoutes): (req: Request, res: Response) => void {
|
||||
let captured: ((req: Request, res: Response) => void) | undefined;
|
||||
const mockApp: any = {
|
||||
get: mock((path: string, handler: (req: Request, res: Response) => void) => {
|
||||
if (path === '/api/context/inject') {
|
||||
captured = handler;
|
||||
}
|
||||
}),
|
||||
post: mock(() => {}),
|
||||
delete: mock(() => {}),
|
||||
use: mock(() => {}),
|
||||
};
|
||||
routes.setupRoutes(mockApp);
|
||||
if (!captured) throw new Error('Failed to capture /api/context/inject handler');
|
||||
return captured;
|
||||
}
|
||||
|
||||
describe('SearchRoutes Welcome Hint', () => {
|
||||
let countQueryStub: ReturnType<typeof mock>;
|
||||
let prepareStub: ReturnType<typeof mock>;
|
||||
let mockSessionStore: any;
|
||||
let mockSearchManager: any;
|
||||
|
||||
beforeEach(() => {
|
||||
loggerSpies = [
|
||||
spyOn(logger, 'info').mockImplementation(() => {}),
|
||||
spyOn(logger, 'debug').mockImplementation(() => {}),
|
||||
spyOn(logger, 'warn').mockImplementation(() => {}),
|
||||
spyOn(logger, 'error').mockImplementation(() => {}),
|
||||
spyOn(logger, 'failure').mockImplementation(() => {}),
|
||||
];
|
||||
|
||||
countQueryStub = mock(() => ({ count: 0 }));
|
||||
prepareStub = mock(() => ({ get: countQueryStub }));
|
||||
mockSessionStore = { db: { prepare: prepareStub } };
|
||||
mockSearchManager = {
|
||||
getSessionStore: () => mockSessionStore,
|
||||
};
|
||||
|
||||
generateContextStub.mockClear();
|
||||
delete process.env.CLAUDE_MEM_WELCOME_HINT_ENABLED;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
loggerSpies.forEach(spy => spy.mockRestore());
|
||||
delete process.env.CLAUDE_MEM_WELCOME_HINT_ENABLED;
|
||||
});
|
||||
|
||||
it('returns the welcome hint when project has zero observations', async () => {
|
||||
const routes = new SearchRoutes(mockSearchManager);
|
||||
const handler = captureContextInjectHandler(routes);
|
||||
|
||||
const res = createMockRes();
|
||||
const req = { query: { projects: '/path/to/empty-project' } } as unknown as Request;
|
||||
|
||||
handler(req, res as unknown as Response);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
expect(res.send).toHaveBeenCalledTimes(1);
|
||||
const body = (res.send as any).mock.calls[0][0] as string;
|
||||
expect(body).toContain('# claude-mem status');
|
||||
expect(body).toContain('/learn-codebase');
|
||||
expect(body).toContain('http://localhost:');
|
||||
expect(body).toContain('Memory injection starts on your second session in a project.');
|
||||
expect(body).toContain('disappears once the first observation lands');
|
||||
expect(body).not.toContain('Welcome');
|
||||
expect(generateContextStub).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the welcome hint when at least one observation exists', async () => {
|
||||
countQueryStub = mock(() => ({ count: 7 }));
|
||||
prepareStub = mock(() => ({ get: countQueryStub }));
|
||||
mockSessionStore = { db: { prepare: prepareStub } };
|
||||
mockSearchManager = { getSessionStore: () => mockSessionStore };
|
||||
|
||||
const routes = new SearchRoutes(mockSearchManager);
|
||||
const handler = captureContextInjectHandler(routes);
|
||||
|
||||
const res = createMockRes();
|
||||
const req = { query: { projects: '/path/to/active-project' } } as unknown as Request;
|
||||
|
||||
handler(req, res as unknown as Response);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
expect(generateContextStub).toHaveBeenCalledTimes(1);
|
||||
expect(res.send).toHaveBeenCalledWith('CONTEXT_FROM_GENERATOR');
|
||||
});
|
||||
|
||||
it('skips the welcome hint when CLAUDE_MEM_WELCOME_HINT_ENABLED=false', async () => {
|
||||
process.env.CLAUDE_MEM_WELCOME_HINT_ENABLED = 'false';
|
||||
|
||||
const routes = new SearchRoutes(mockSearchManager);
|
||||
const handler = captureContextInjectHandler(routes);
|
||||
|
||||
const res = createMockRes();
|
||||
const req = { query: { projects: '/path/to/empty-project' } } as unknown as Request;
|
||||
|
||||
handler(req, res as unknown as Response);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
expect(generateContextStub).toHaveBeenCalledTimes(1);
|
||||
expect(res.send).toHaveBeenCalledWith('CONTEXT_FROM_GENERATOR');
|
||||
});
|
||||
|
||||
it('queries both projects in a worktree (multi-project) request', async () => {
|
||||
const routes = new SearchRoutes(mockSearchManager);
|
||||
const handler = captureContextInjectHandler(routes);
|
||||
|
||||
const res = createMockRes();
|
||||
const req = { query: { projects: '/path/parent, /path/worktree' } } as unknown as Request;
|
||||
|
||||
handler(req, res as unknown as Response);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
expect(res.send).toHaveBeenCalledTimes(1);
|
||||
expect(countQueryStub).toHaveBeenCalledWith(
|
||||
'/path/parent',
|
||||
'/path/worktree',
|
||||
'/path/parent',
|
||||
'/path/worktree',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,16 @@
|
||||
/**
|
||||
* CORS Restriction Tests
|
||||
*
|
||||
* Verifies that CORS is properly restricted to localhost origins only,
|
||||
* and that preflight responses include the correct methods and headers (#1029).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import http from 'http';
|
||||
|
||||
// Test the CORS origin validation logic directly
|
||||
function isAllowedOrigin(origin: string | undefined): boolean {
|
||||
if (!origin) return true; // No origin = hooks, curl, CLI
|
||||
if (!origin) return true;
|
||||
if (origin.startsWith('http://localhost:')) return true;
|
||||
if (origin.startsWith('http://127.0.0.1:')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the same CORS config used in production middleware.ts.
|
||||
* Duplicated here to avoid module-mock interference from other test files.
|
||||
*/
|
||||
function buildProductionCorsMiddleware() {
|
||||
return cors({
|
||||
origin: (origin, callback) => {
|
||||
@@ -65,7 +54,6 @@ describe('CORS Restriction', () => {
|
||||
});
|
||||
|
||||
it('blocks HTTPS localhost (not typically used for local dev)', () => {
|
||||
// HTTPS localhost is unusual and could indicate a proxy attack
|
||||
expect(isAllowedOrigin('https://localhost:37777')).toBe(false);
|
||||
});
|
||||
|
||||
@@ -79,7 +67,6 @@ describe('CORS Restriction', () => {
|
||||
});
|
||||
|
||||
it('blocks null origin', () => {
|
||||
// null origin can come from sandboxed iframes
|
||||
expect(isAllowedOrigin('null')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -94,7 +81,6 @@ describe('CORS Restriction', () => {
|
||||
app.use(express.json());
|
||||
app.use(buildProductionCorsMiddleware());
|
||||
|
||||
// Add a test endpoint that supports all methods
|
||||
app.all('/api/settings', (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
@@ -194,7 +180,6 @@ describe('CORS Restriction', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// cors middleware rejects disallowed origins — browser enforces the block
|
||||
const origin = response.headers.get('access-control-allow-origin');
|
||||
expect(origin).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, mock } from 'bun:test';
|
||||
|
||||
// Mock the ModeManager before imports
|
||||
mock.module('../../../src/services/domain/ModeManager.js', () => ({
|
||||
ModeManager: {
|
||||
getInstance: () => ({
|
||||
@@ -44,7 +43,6 @@ mock.module('../../../src/services/domain/ModeManager.js', () => ({
|
||||
import { ResultFormatter } from '../../../src/services/worker/search/ResultFormatter.js';
|
||||
import type { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult, SearchResults } from '../../../src/services/worker/search/types.js';
|
||||
|
||||
// Mock data
|
||||
const mockObservation: ObservationSearchResult = {
|
||||
id: 1,
|
||||
memory_session_id: 'session-123',
|
||||
@@ -111,7 +109,7 @@ describe('ResultFormatter', () => {
|
||||
expect(formatted).toContain('test query');
|
||||
expect(formatted).toContain('1 result');
|
||||
expect(formatted).toContain('1 obs');
|
||||
expect(formatted).toContain('#1'); // ID
|
||||
expect(formatted).toContain('#1');
|
||||
expect(formatted).toContain('Test Decision Title');
|
||||
});
|
||||
|
||||
@@ -125,7 +123,7 @@ describe('ResultFormatter', () => {
|
||||
const formatted = formatter.formatSearchResults(results, 'session query');
|
||||
|
||||
expect(formatted).toContain('1 session');
|
||||
expect(formatted).toContain('#S1'); // Session ID format
|
||||
expect(formatted).toContain('#S1');
|
||||
expect(formatted).toContain('Implement feature X');
|
||||
});
|
||||
|
||||
@@ -139,7 +137,7 @@ describe('ResultFormatter', () => {
|
||||
const formatted = formatter.formatSearchResults(results, 'prompt query');
|
||||
|
||||
expect(formatted).toContain('1 prompt');
|
||||
expect(formatted).toContain('#P1'); // Prompt ID format
|
||||
expect(formatted).toContain('#P1');
|
||||
expect(formatted).toContain('Can you help me implement');
|
||||
});
|
||||
|
||||
@@ -305,16 +303,13 @@ describe('ResultFormatter', () => {
|
||||
|
||||
expect(result.row).toContain('#1');
|
||||
expect(result.row).toContain('Test Decision Title');
|
||||
expect(result.row).toContain('~'); // Token estimate
|
||||
expect(result.row).toContain('~');
|
||||
});
|
||||
|
||||
it('should use quote mark for repeated time', () => {
|
||||
// First get the actual time format for this observation
|
||||
const firstResult = formatter.formatObservationSearchRow(mockObservation, '');
|
||||
// Now pass that same time as lastTime
|
||||
const result = formatter.formatObservationSearchRow(mockObservation, firstResult.time);
|
||||
|
||||
// When time matches lastTime, the row should show quote mark
|
||||
expect(result.row).toContain('"');
|
||||
expect(result.time).toBe(firstResult.time);
|
||||
});
|
||||
@@ -368,7 +363,6 @@ describe('ResultFormatter', () => {
|
||||
const row = formatter.formatObservationIndex(mockObservation, 0);
|
||||
|
||||
expect(row).toContain('#1');
|
||||
// Should have more columns than search row
|
||||
expect(row.split('|').length).toBeGreaterThan(5);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, it, expect, mock, beforeEach } from 'bun:test';
|
||||
|
||||
// Mock the ModeManager before imports
|
||||
mock.module('../../../src/services/domain/ModeManager.js', () => ({
|
||||
ModeManager: {
|
||||
getInstance: () => ({
|
||||
@@ -44,7 +43,6 @@ mock.module('../../../src/services/domain/ModeManager.js', () => ({
|
||||
import { SearchOrchestrator } from '../../../src/services/worker/search/SearchOrchestrator.js';
|
||||
import type { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../../../src/services/worker/search/types.js';
|
||||
|
||||
// Mock data
|
||||
const mockObservation: ObservationSearchResult = {
|
||||
id: 1,
|
||||
memory_session_id: 'session-123',
|
||||
@@ -153,9 +151,6 @@ describe('SearchOrchestrator', () => {
|
||||
it('should throw ChromaUnavailableError (HTTP 503) when Chroma fails', async () => {
|
||||
mockChromaSync.queryChroma = mock(() => Promise.reject(new Error('Chroma unavailable')));
|
||||
|
||||
// Fail-fast: Chroma errors propagate as ChromaUnavailableError
|
||||
// (HTTP 503 via the AppError status code) rather than silently
|
||||
// falling back to SQLite.
|
||||
await expect(
|
||||
orchestrator.search({ query: 'test query' })
|
||||
).rejects.toMatchObject({
|
||||
@@ -171,7 +166,6 @@ describe('SearchOrchestrator', () => {
|
||||
limit: 10
|
||||
});
|
||||
|
||||
// Should be parsed into array internally
|
||||
const callArgs = mockSessionSearch.searchObservations.mock.calls[0];
|
||||
expect(callArgs[1].concepts).toEqual(['concept1', 'concept2', 'concept3']);
|
||||
});
|
||||
@@ -204,7 +198,6 @@ describe('SearchOrchestrator', () => {
|
||||
type: 'observations'
|
||||
});
|
||||
|
||||
// Should search only observations
|
||||
expect(mockSessionSearch.searchObservations).toHaveBeenCalled();
|
||||
expect(mockSessionSearch.searchSessions).not.toHaveBeenCalled();
|
||||
expect(mockSessionSearch.searchUserPrompts).not.toHaveBeenCalled();
|
||||
@@ -217,7 +210,6 @@ describe('SearchOrchestrator', () => {
|
||||
limit: 10
|
||||
});
|
||||
|
||||
// Hybrid strategy should be used
|
||||
expect(mockSessionSearch.findByConcept).toHaveBeenCalled();
|
||||
expect(mockChromaSync.queryChroma).toHaveBeenCalled();
|
||||
});
|
||||
@@ -322,7 +314,6 @@ describe('SearchOrchestrator', () => {
|
||||
query: 'semantic query'
|
||||
});
|
||||
|
||||
// No Chroma available, can't do semantic search
|
||||
expect(result.results.observations).toHaveLength(0);
|
||||
expect(result.usedChroma).toBe(false);
|
||||
});
|
||||
@@ -396,8 +387,6 @@ describe('SearchOrchestrator', () => {
|
||||
});
|
||||
|
||||
const callArgs = mockSessionSearch.searchObservations.mock.calls[0];
|
||||
// Empty strings are falsy, so the normalization doesn't process them
|
||||
// They stay as empty strings (the underlying search functions handle this)
|
||||
expect(callArgs[1].concepts).toEqual('');
|
||||
expect(callArgs[1].files).toEqual('');
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user