mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 18:52:18 +03:00
* refactor(cursor): extracts token extraction into shared lib
Moves tryIdeAuth/tryAgentAuth and supporting helpers out of the
auto-import route into src/lib/cursor/tokenExtractor.ts, and adds
an agent-cli-state.json fallback candidate path to tryAgentAuth
(alongside the existing auth.json candidate) so the extraction
logic can be reused by the upcoming renewal orchestrator.
* feat(cursor): adds cursor-agent-backed token renewal orchestrator
Builds the renewal orchestrator in src/lib/cursor/renewal.ts: a
bounded, unattended-safe --list-models nudge, a side-effect-free
status availability check, an in-flight spawn lock keyed by
command, and renewCursorConnection() which nudges cursor-agent
then independently re-scrapes the IDE and cursor-agent credential
sources to detect whichever refreshed. Extends cursorAgent.ts's
binary resolution and spawn helper with fixed-paths-only mode and
a SIGKILL follow-up for background use. Adds a generic keyed-mutex
utility (src/shared/utils/keyedMutex.ts) for serializing a
connection's renew-then-persist cycle, and forwards a busy-timeout
through driverFactory's node:sqlite fallback path.
* feat(cursor): proactively renews Cursor sessions in the sweep
Adds src/lib/tokenHealthCheckCursor.ts, sweep-side glue that calls
the renewal orchestrator and persists the result, wired into
tokenHealthCheck.ts's checkConnection() via a new Cursor-specific
branch placed ahead of the generic no-refresh-token fallthrough.
Carves out a non-terminal exception for a Cursor connection that
already landed at testStatus "expired" via the request-time 401
path, excluding permanently-dead account_deactivated connections.
Extends buildRefreshFailureUpdate() with an overrides param so
Cursor's failure path can use a distinct, non-terminal errorCode
instead of the generic refresh_failed/expired taxonomy.
* feat(cursor): adds local-only manual refresh route
Adds POST /api/providers/[id]/refresh-cursor, a dedicated
loopback-only route that calls the renewal orchestrator on demand
for a single Cursor connection, bounded by a 30s per-connection
cooldown. Classifies the new route in LOCAL_ONLY_API_PATTERNS and
closes the manage-scope-bypass gap for dynamic-segment spawn-capable
routes under /api/providers/ via a new SPAWN_CAPABLE_PATTERNS /
SPAWN_CAPABLE_PATTERN_ANCESTORS mechanism, which also retroactively
covers the pre-existing /login route. The existing shared
/api/providers/[id]/refresh route is untouched and stays
remote-reachable for every other provider.
* feat(cursor): surfaces a dismissible cursor-agent nudge
Adds GET /api/providers/cursor/agent-availability, a credential-free
LOCAL_ONLY route returning only { cursorAgentAvailable: boolean },
backed by a 5-minute cached wrapper around the renewal orchestrator's
existing availability check. Surfaces a dismissible dashboard banner
on the Cursor provider page suggesting cursor-agent installation
when it isn't detected, following the existing dismissible-banner
convention. Also fixes a pre-existing bracket character in a
routeGuard.ts comment that was silently truncating
check-openapi-security-tiers.mjs's view of LOCAL_ONLY_API_PREFIXES.
* fix(cursor): wires manual refresh button to the new route
Branches handleRefreshToken to call the dedicated Cursor refresh
route instead of the generic /refresh route, which silently 502s
for Cursor connections today since they carry no refresh token.
Every other provider's refresh behavior is unaffected. Adds the
cursorSessionUnchanged i18n key and syncs it (plus a pre-existing,
unrelated 28-key backlog) across all 42 locale files.
* fix(cursor): addresses Phase 4/4.5 review findings
Restores the legacy stdout/stderr auth-pattern fallback in
checkCursorAgentAvailability() that the plan's Task 2 Step 4
required but the implementation had dropped. Threads an optional
deps parameter through checkCursorConnectionIfNeeded() so its
error branch is reachable in tests, and switches both it and the
manual-refresh route to exhaustive switch statements over the
renewal result. Adds a short-lived host-keyed dedup cache around
tryIdeAuth() so multiple due Cursor connections sharing a host
don't each open the same state.vscdb file in one sweep tick.
Adds opportunistic eviction to the manual-refresh cooldown map,
an outer try/catch to the availability route for defense-in-depth
consistency with the plan's other routes, and corrects a stale
JSDoc claim about the /login route's auth check. Documents the
now-empirically-confirmed agent-cli-state.json schema mismatch
found while validating against a real cursor-agent install.
* docs(cursor): adds changelog fragments for the renewal plan
Adds one fragment per user-facing outcome per changelog.d/README.md's
convention for a PR that both fixes and adds. PR number placeholder
to be filled in once the PR is opened.
* fix(i18n): translates the new Cursor keys into Vietnamese
The i18n:sync-ui run in an earlier commit left __MISSING__
sentinels for the 4 new Cursor keys in every locale, but
Vietnamese has a dedicated completeness test requiring zero
internal missing markers. Provides real translations for
cursorSessionUnchanged, cursorAgentNudgeTitle,
cursorAgentNudgeBody, and cursorAgentNudgeDismiss.
* fix(cursor): addresses quality-gate Layer 1.5 findings
Restores a comment that misrepresented execFile's actual argv shape
after an earlier bracket-removal fix, this time avoiding literal
closing-bracket characters entirely so the openapi checker's naive
array parser can't be broken by either version. Bounds the sweep-
and manual-route-triggered tryIdeAuth() busy-timeout to 250ms
(down from the interactive auto-import path's 2000ms), since both
share the main event loop with all other in-flight requests and
should fail fast on a WAL-lock collision rather than block the
whole instance for up to ~4s. Has the manual refresh route bypass
the sweep's IDE-auth dedup cache so a click always sees a fresh
read, consistent with this plan's existing "manual actions never
see stale cached data" convention. Documents the previously-missing
agent-availability route in ROUTE_GUARD_TIERS.md's spawn-capable
table.
* fix(cursor): adds SIGKILL follow-up to the status-check spawn
Matches the nudge spawn's existing SIGTERM+SIGKILL pattern so an
unresponsive cursor-agent status check can't leak a lingering
process if it ignores SIGTERM.
* docs(cursor): fills in the PR number for changelog fragments
Renames the 3 changelog.d fragments to their PR-numbered filenames and replaces the (#PR) placeholder with #9173, now that the PR exists.
* fix(cursor): corrects changelog fragments to reference PR #9173
The prior commit only staged the git mv rename — a git add invocation with a stale (pre-rename) pathspec aborted before the actual (#PR) -> (#9173) content edit was staged, so the rename landed without the fix it was meant to carry. This captures the actual content change.
* docs(cursor): regenerates the agent-skills catalog for the new route
check:agent-skills-sync (CI's Merge integrity gate) requires SKILL.md files to stay in sync with the live route catalog. Adding /api/providers/cursor/agent-availability in an earlier commit needed a regen this branch never ran.
* chore(quality): rebaselines file-size caps grown by agentrouter merges
Two already-merged agentrouter commits (564c204ef, ec150a006) on release/v3.8.50 grew open-sse/executors/base.ts, open-sse/handlers/chatCore.ts, and tests/unit/chatcore-translation-paths.test.ts past their frozen caps before this PR branched — unrelated to the Cursor renewal changes here. No PR branch is left to fix the growth in-place, so the caps are bumped to the current real sizes, following the existing release-green rebaseline precedent in this file.
* fix(sse): imports getModel helpers from db/models, not localDb
A recently-merged agentrouter commit added a @/lib/localDb import in chatCore.ts, violating the no-restricted-imports rule (Hard Rule #2 — never barrel-import from localDb.ts). Points the import at the owning module, src/lib/db/models.ts, where both functions are actually defined, and prunes the now-stale suppression entry.
* fix(sse): scopes CC-relay anthropic-beta to its own requestDefaults
Two already-merged agentrouter commits widened usesClaudeCodeProtocol()'s native-Claude system-transform block (billing header + selectBetaFlags-derived anthropic-beta) to also run for generic CC-compatible relay connections, not just real claude traffic and agentrouter's own wire-image mimicry. selectBetaFlags() has no visibility into a relay's own providerSpecificData.requestDefaults, so its header replacement silently wiped out an earlier context-1m append and force-included redact-thinking regardless of the relay's own opt-in. Restores both for plain CC-compatible relays only; real claude/agentrouter traffic is unaffected.
Also bumps four stale hardcoded Codex/Claude Code CLI version-string test assertions (0.144.1->0.146.0, 2.1.219->2.1.220) that drifted when the same two commits bumped the version constants without updating their tests, and rebaselines base.ts's frozen file-size cap for this fix's own +35 lines.
* fix(sse): preserves bare CC-relay native treatment and context-1m
The previous commit's fix was too broad in one direction: excluding ALL CC-compatible relays from the native-Claude header block broke two pre-existing tests (cc-compatible-provider.test.ts, v3.6.6) that rely on that treatment for a 'vanilla' relay with no providerSpecificData.requestDefaults configured.
Refines the gate to this whole native-Claude header-replacement block: replace headers for real claude traffic, agentrouter's wire-image mimicry, OR a CC-relay with no requestDefaults at all — only a relay with EXPLICIT requestDefaults (context1m/redactThinking/summarizeThinking) gets to keep buildHeaders()'s own correctly-computed header set. A redact-thinking-beta strip (unconditional, a no-op when native treatment didn't apply) covers the one remaining gap: selectBetaFlags() force-includes it for a bare relay's opaque client, which a bare relay never explicitly opted into.
Verified against all three previously-conflicting pre-existing tests simultaneously: executor-default-base.test.ts's '1M beta' test, both cc-compatible-provider.test.ts SSE-forcing tests, and provider-request-failure-pipeline.test.ts's 'keeps request beta headers' test (the last of which was already broken by the raw agentrouter merge, confirmed via direct comparison against that exact commit).
* fix(sse): fills in remaining stale CLI version literals
The same two agentrouter commits bumped Codex/Claude Code CLI version constants (0.144.1->0.146.0, 2.1.219->2.1.220) without updating every hardcoded test assertion. This round covers the ones the previous version-string commit missed: the anthropic-cache-fingerprint billing-version constant, a cc-bridge-transforms body assertion, the UI-mirror parity test's own snapshot plus its RoutingTab.tsx source of truth, an integration test's User-Agent assertion (inconsistent with its own dynamic Version assertion two lines up), and the translate-path golden snapshot. Also updates a stale doc comment referencing the old literal by value instead of by constant name.
* fix(cursor): imports from db/ modules, not the localDb barrel
Both files violated Hard Rule #2 (never barrel-import from localDb.ts) — a genuine lint error that had gone uncaught locally. refresh-cursor/route.ts imported getCachedProviderConnectionById from @/lib/localDb instead of its owning module, @/lib/db/readCache. tokenHealthCheckCursor.ts copied the same pattern from its sibling tokenHealthCheckCopilot.ts (an existing, already-suppressed violation) for updateProviderConnection; imports it from @/lib/db/providers instead, with no circular-import fallout (verified via the existing token-health-check-cursor and refresh-cursor-route test suites).
* fix(db): removes stale raw-SQL allowlist entry for cursor route
The cursor auto-import route no longer contains raw SQL — that query
now lives in src/lib/cursor/tokenExtractor.ts, outside the
route/handler scope check-db-rules scans. The allowlist entry was
stale, tripping the stale-enforcement gate.
* fix(test): registers cursor test files in stryker tap.testFiles
Three unit test files covering mutation-tested modules
(route-guard-cursor-agent-availability, route-guard-cursor-refresh,
cursor-renewal) were missing from stryker.conf.json's tap.testFiles,
tripping the mutation-test-coverage gate's drift detection.
* chore(ci): retriggers checks (stuck GH Actions runner on shard 2/4)
* fix(sse): restores CC-relay context1m/redact-thinking test coverage
Rebasing onto release/v3.8.50's new tip (35405be60, an unrelated
agentrouter protocol-inference commit) silently flipped two assertions
this branch's own earlier fix (687fbda62) depends on, in the same test
files that commit touched for other reasons:
- executor-default-base.test.ts: calls[0] (a bare CC-relay with no
requestDefaults) expected redact-thinking-beta absent; flipped to
present. calls[1] (context1m+redactThinking requestDefaults) expected
the context-1m beta preserved; flipped to absent.
- provider-request-failure-pipeline.test.ts: expected Accept:
text/event-stream and the context-1m beta present for a relay with
explicit requestDefaults; flipped to application/json and absent.
35405be60 did not touch open-sse/executors/base.ts at all, so these
were test-only edits made without visibility into the still-unmerged
CC-relay header-preservation fix on this branch — they quietly matched
the assertions back to the pre-fix (buggy) behavior instead. Restores
the original, validated expectations; all three interdependent test
files (executor-default-base, cc-compatible-provider,
provider-request-failure-pipeline) verified passing together again.
* ci: re-trigger checks after GitHub Actions incident (2026-08-07, resolved)
* ci: re-trigger checks (previous push event was dropped)
* fix(quality): restore dropped vi.json cursor-renewal keys + rebaseline test growth
vi.json was missing 4 keys (cursorSessionUnchanged, cursorAgentNudgeTitle/Body/Dismiss) that this PR's own pre-merge branch had translated -- the original merge's 'git checkout --theirs' resolution for the 7 conflicted locale files discarded them since upstream's vi.json has no cursor-token-renewal feature. Restored from pre-merge tip a38003e30. Also rebaselines combo-routing-engine.test.ts (3457->3464) for the comment growth from the ALL_ACCOUNTS_INACTIVE fix, caught by CI's PR-mode check:file-size.
* chore(tests): drop explanatory comments on ALL_TARGETS_SKIPPED assertions
Kept the assertion value fix (ALL_ACCOUNTS_INACTIVE -> ALL_TARGETS_SKIPPED); the comments were unnecessary. Reverts the file-size baseline bump these comments caused (combo-routing-engine.test.ts back to its original 3457).
773 lines
30 KiB
TypeScript
773 lines
30 KiB
TypeScript
/**
|
|
* Cursor renewal orchestrator (src/lib/cursor/renewal.ts) and the generic
|
|
* keyed-mutex primitive (src/shared/utils/keyedMutex.ts) it builds on.
|
|
*
|
|
* None of runCursorAgentNudge()/checkCursorAgentAvailability()/
|
|
* renewCursorConnection() accept an injectable binary/dependency, and this
|
|
* tsx/ESM + Node native test-runner setup has no mock.module() support (see
|
|
* tests/unit/token-health-check-sweep.test.ts). So instead of mocking:
|
|
* - runCursorAgentNudge(binary, timeoutMs) DOES take `binary` as a direct
|
|
* parameter, so it is fully testable with a real spawned fake script.
|
|
* - checkCursorAgentAvailability()/renewCursorConnection() resolve their
|
|
* own binary via resolveCursorAgentBinary({allowPathFallback:false}),
|
|
* whose FIRST fixed candidate is `~/.local/bin/cursor-agent` (HOME-
|
|
* relative). Overriding HOME (same technique already established in
|
|
* tests/unit/cursor-token-extractor.test.ts for tryAgentAuth/tryIdeAuth)
|
|
* lets a real fake script at that path shadow any ambient real
|
|
* cursor-agent install (confirmed present on at least one dev machine,
|
|
* at /opt/homebrew/bin/cursor-agent — see cursor-agent-models.test.ts).
|
|
* - tryIdeAuth()/tryAgentAuth() (Task 1) are similarly HOME-relative and
|
|
* controlled via real fixture files, exactly as in
|
|
* tests/unit/cursor-token-extractor.test.ts.
|
|
*/
|
|
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
import {
|
|
runCursorAgentNudge,
|
|
checkCursorAgentAvailability,
|
|
getCachedCursorAgentAvailability,
|
|
renewCursorConnection,
|
|
buildCursorRenewedUpdate,
|
|
runCursorRenewalExclusive,
|
|
CURSOR_TOKEN_LIFETIME_S,
|
|
} from "@/lib/cursor/renewal";
|
|
import { createKeyedMutex } from "@/shared/utils/keyedMutex";
|
|
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
|
|
|
function deferred<T = void>(): { promise: Promise<T>; resolve: (v: T) => void } {
|
|
let resolve!: (v: T) => void;
|
|
const promise = new Promise<T>((r) => {
|
|
resolve = r;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
// A single fake "cursor-agent" binary driven entirely by env vars, so one
|
|
// script file covers every branch this suite needs (status modes, the
|
|
// --list-models nudge, and a hang-then-optionally-ignore-SIGTERM mode for
|
|
// spawn-timing tests). Invocation args are appended to FAKE_CURSOR_AGENT_LOG
|
|
// (one JSON array per line) so tests can assert exactly what was spawned,
|
|
// and how many times.
|
|
const FAKE_CURSOR_AGENT_SCRIPT = `#!/usr/bin/env node
|
|
const fs = require("fs");
|
|
const args = process.argv.slice(2);
|
|
if (process.env.FAKE_CURSOR_AGENT_LOG) {
|
|
fs.appendFileSync(process.env.FAKE_CURSOR_AGENT_LOG, JSON.stringify(args) + "\\n");
|
|
}
|
|
if (process.env.FAKE_CURSOR_AGENT_HANG === "1") {
|
|
if (process.env.FAKE_CURSOR_AGENT_IGNORE_SIGTERM === "1") {
|
|
process.on("SIGTERM", () => {});
|
|
}
|
|
const selfExitMs = process.env.FAKE_CURSOR_AGENT_SELF_EXIT_MS;
|
|
if (selfExitMs) {
|
|
setTimeout(() => process.exit(0), Number(selfExitMs));
|
|
} else {
|
|
setInterval(() => {}, 60000);
|
|
}
|
|
} else if (args[0] === "status") {
|
|
const mode = process.env.FAKE_CURSOR_AGENT_STATUS_MODE || "authenticated";
|
|
if (mode === "authenticated") {
|
|
process.stdout.write(JSON.stringify({ status: "authenticated", isAuthenticated: true }));
|
|
} else if (mode === "unauthenticated") {
|
|
process.stdout.write(JSON.stringify({ status: "unauthenticated", isAuthenticated: false }));
|
|
} else if (mode === "garbage") {
|
|
process.stdout.write("not json output at all");
|
|
} else if (mode === "legacy-unauthenticated") {
|
|
process.stderr.write("Error: Not logged in. Run 'cursor-agent login' to authenticate.");
|
|
}
|
|
}
|
|
`;
|
|
|
|
function writeFakeCursorAgentBinary(destPath: string): void {
|
|
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
fs.writeFileSync(destPath, FAKE_CURSOR_AGENT_SCRIPT, { mode: 0o755 });
|
|
fs.chmodSync(destPath, 0o755);
|
|
}
|
|
|
|
function readLoggedInvocations(logPath: string): string[][] {
|
|
if (!fs.existsSync(logPath)) return [];
|
|
return fs
|
|
.readFileSync(logPath, "utf-8")
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line));
|
|
}
|
|
|
|
function clearFakeCursorAgentEnv(): void {
|
|
delete process.env.FAKE_CURSOR_AGENT_LOG;
|
|
delete process.env.FAKE_CURSOR_AGENT_STATUS_MODE;
|
|
delete process.env.FAKE_CURSOR_AGENT_HANG;
|
|
delete process.env.FAKE_CURSOR_AGENT_IGNORE_SIGTERM;
|
|
delete process.env.FAKE_CURSOR_AGENT_SELF_EXIT_MS;
|
|
}
|
|
|
|
describe("runCursorAgentNudge", () => {
|
|
let tmpDir: string;
|
|
let binary: string;
|
|
let logPath: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cursor-nudge-"));
|
|
binary = path.join(tmpDir, "cursor-agent");
|
|
writeFakeCursorAgentBinary(binary);
|
|
logPath = path.join(tmpDir, "log.jsonl");
|
|
process.env.FAKE_CURSOR_AGENT_LOG = logPath;
|
|
});
|
|
|
|
afterEach(() => {
|
|
clearFakeCursorAgentEnv();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('invokes the binary with exactly ["--list-models"] and never "login"', async () => {
|
|
const result = await runCursorAgentNudge(binary, 2000);
|
|
assert.equal(result.code, 0);
|
|
const invocations = readLoggedInvocations(logPath);
|
|
assert.equal(invocations.length, 1);
|
|
assert.deepEqual(invocations[0], ["--list-models"]);
|
|
});
|
|
|
|
it('dedupes two concurrent calls under the "nudge" key (spawn invoked exactly once)', async () => {
|
|
const [r1, r2] = await Promise.all([
|
|
runCursorAgentNudge(binary, 2000),
|
|
runCursorAgentNudge(binary, 2000),
|
|
]);
|
|
assert.equal(r1, r2, "both callers must share the exact same in-flight promise/result");
|
|
assert.equal(readLoggedInvocations(logPath).length, 1, "underlying spawn invoked exactly once");
|
|
});
|
|
|
|
it("SIGTERMs at the timeout and SIGKILLs when the process ignores SIGTERM", async () => {
|
|
// 600ms/200ms — see tests/unit/cursor-agent-models.test.ts for why a
|
|
// freshly-spawned node process needs real wall-clock margin before its
|
|
// SIGTERM handler is guaranteed to be registered in this environment.
|
|
process.env.FAKE_CURSOR_AGENT_HANG = "1";
|
|
process.env.FAKE_CURSOR_AGENT_IGNORE_SIGTERM = "1";
|
|
const start = Date.now();
|
|
const result = await runCursorAgentNudge(binary, 600);
|
|
const elapsed = Date.now() - start;
|
|
assert.equal(result.signal, "SIGKILL");
|
|
assert.ok(
|
|
elapsed >= 600,
|
|
`expected SIGKILL only after the 600ms SIGTERM timeout, got ${elapsed}ms`
|
|
);
|
|
assert.ok(elapsed < 10_000, `expected the SIGKILL follow-up well under 10s, got ${elapsed}ms`);
|
|
});
|
|
});
|
|
|
|
describe("checkCursorAgentAvailability", () => {
|
|
const ORIGINAL_HOME = process.env.HOME;
|
|
const ORIGINAL_USERPROFILE = process.env.USERPROFILE;
|
|
let tmpHome: string;
|
|
let binaryPath: string;
|
|
let logPath: string;
|
|
|
|
beforeEach(() => {
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cursor-avail-"));
|
|
process.env.HOME = tmpHome;
|
|
process.env.USERPROFILE = tmpHome;
|
|
binaryPath = path.join(tmpHome, ".local", "bin", "cursor-agent");
|
|
writeFakeCursorAgentBinary(binaryPath);
|
|
logPath = path.join(tmpHome, "log.jsonl");
|
|
process.env.FAKE_CURSOR_AGENT_LOG = logPath;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env.HOME = ORIGINAL_HOME;
|
|
if (ORIGINAL_USERPROFILE !== undefined) process.env.USERPROFILE = ORIGINAL_USERPROFILE;
|
|
else delete process.env.USERPROFILE;
|
|
clearFakeCursorAgentEnv();
|
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
});
|
|
|
|
it("reports available:true when the resolved binary is authenticated", async () => {
|
|
process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "authenticated";
|
|
const result = await checkCursorAgentAvailability();
|
|
assert.equal(result.available, true);
|
|
assert.equal(result.binaryPath, binaryPath);
|
|
});
|
|
|
|
it("reports available:false when the resolved binary reports unauthenticated", async () => {
|
|
process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "unauthenticated";
|
|
const result = await checkCursorAgentAvailability();
|
|
assert.equal(result.available, false);
|
|
assert.equal(result.binaryPath, binaryPath);
|
|
});
|
|
|
|
it("reports available:true (legacy-authenticated) on unparseable stdout with no auth-required signal", async () => {
|
|
// Mirrors fetchCursorAgentModels()'s legacy fallback convention in
|
|
// cursorAgent.ts: an older CLI release predating `--format json` support
|
|
// on `status` still produces some non-JSON output, but absent an
|
|
// explicit "not authenticated" signal, the binary is treated as available.
|
|
process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "garbage";
|
|
const result = await checkCursorAgentAvailability();
|
|
assert.equal(result.available, true);
|
|
assert.equal(result.binaryPath, binaryPath);
|
|
});
|
|
|
|
it("reports available:false (legacy-unauthenticated) when unparseable stdout/stderr matches the auth-required pattern", async () => {
|
|
process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "legacy-unauthenticated";
|
|
const result = await checkCursorAgentAvailability();
|
|
assert.equal(result.available, false);
|
|
assert.equal(result.binaryPath, binaryPath);
|
|
});
|
|
|
|
it("never invokes --list-models from this code path", async () => {
|
|
process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "authenticated";
|
|
await checkCursorAgentAvailability();
|
|
for (const args of readLoggedInvocations(logPath)) {
|
|
assert.ok(
|
|
!args.includes("--list-models"),
|
|
`unexpected --list-models in ${JSON.stringify(args)}`
|
|
);
|
|
}
|
|
});
|
|
|
|
it('dedupes two concurrent calls under the "status" key (spawn invoked exactly once)', async () => {
|
|
process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "authenticated";
|
|
const [r1, r2] = await Promise.all([
|
|
checkCursorAgentAvailability(),
|
|
checkCursorAgentAvailability(),
|
|
]);
|
|
assert.deepEqual(r1, r2);
|
|
assert.equal(readLoggedInvocations(logPath).length, 1);
|
|
});
|
|
|
|
it('a nudge racing an availability check invokes the spawn TWICE — "nudge" and "status" never share a key (regression: discarded-renewal coalescing bug)', async () => {
|
|
process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "authenticated";
|
|
await Promise.all([runCursorAgentNudge(binaryPath, 2000), checkCursorAgentAvailability()]);
|
|
assert.equal(readLoggedInvocations(logPath).length, 2);
|
|
});
|
|
|
|
describe("when no fixed candidate resolves to a real binary", () => {
|
|
const candidates = [
|
|
"/root/.local/bin/cursor-agent",
|
|
"/usr/local/bin/cursor-agent",
|
|
"/usr/bin/cursor-agent",
|
|
"/opt/homebrew/bin/cursor-agent",
|
|
];
|
|
const ambient = candidates.find((c) => fs.existsSync(c)) ?? null;
|
|
|
|
it(
|
|
"reports available:false, binaryPath:null, and never spawns",
|
|
{
|
|
skip: ambient
|
|
? `ambient cursor-agent install detected at ${ambient} on this host — ` +
|
|
"resolveCursorAgentBinary has no DI seam for its other hardcoded absolute " +
|
|
"candidates, so this branch cannot be made hermetic here; passes in a clean " +
|
|
"CI container without cursor-agent installed"
|
|
: false,
|
|
},
|
|
async () => {
|
|
fs.rmSync(binaryPath); // remove the fake binary this describe block's beforeEach created
|
|
const result = await checkCursorAgentAvailability();
|
|
assert.equal(result.available, false);
|
|
assert.equal(result.binaryPath, null);
|
|
assert.equal(
|
|
readLoggedInvocations(logPath).length,
|
|
0,
|
|
"must not spawn when nothing is found"
|
|
);
|
|
}
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("getCachedCursorAgentAvailability (Task 5 Step 1 — 5-minute TTL wrapper for UI callers)", () => {
|
|
const ORIGINAL_HOME = process.env.HOME;
|
|
const ORIGINAL_USERPROFILE = process.env.USERPROFILE;
|
|
const CACHE_TTL_MS = 5 * 60 * 1000; // mirrors CURSOR_AGENT_AVAILABILITY_CACHE_TTL_MS in renewal.ts
|
|
let tmpHome: string;
|
|
let logPath: string;
|
|
|
|
beforeEach(() => {
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cursor-avail-cache-"));
|
|
process.env.HOME = tmpHome;
|
|
process.env.USERPROFILE = tmpHome;
|
|
writeFakeCursorAgentBinary(path.join(tmpHome, ".local", "bin", "cursor-agent"));
|
|
logPath = path.join(tmpHome, "log.jsonl");
|
|
process.env.FAKE_CURSOR_AGENT_LOG = logPath;
|
|
process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "authenticated";
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env.HOME = ORIGINAL_HOME;
|
|
if (ORIGINAL_USERPROFILE !== undefined) process.env.USERPROFILE = ORIGINAL_USERPROFILE;
|
|
else delete process.env.USERPROFILE;
|
|
clearFakeCursorAgentEnv();
|
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
});
|
|
|
|
// A single test, one continuous mocked timeline: getCachedCursorAgentAvailability()'s
|
|
// module-level cache has no exported reset hook and persists for the life of the
|
|
// process, so two separate `it()` blocks each assuming a "fresh" cache would be
|
|
// order-dependent (a later test could silently inherit an earlier test's still-valid
|
|
// cache entry, since node:test's per-test mock-timer teardown restores the REAL clock
|
|
// between tests, not the fake one — the leftover `expiresAt` would still be far in
|
|
// that real future). Keeping both assertions on one uninterrupted fake clock avoids that.
|
|
it("reuses the cached result within the TTL window, then spawns exactly once more after it expires", async (t) => {
|
|
t.mock.timers.enable({ apis: ["Date"] });
|
|
|
|
const first = await getCachedCursorAgentAvailability();
|
|
assert.equal(readLoggedInvocations(logPath).length, 1, "the first call must spawn");
|
|
|
|
t.mock.timers.tick(CACHE_TTL_MS - 1000); // still inside the window
|
|
const second = await getCachedCursorAgentAvailability();
|
|
assert.deepEqual(first, second);
|
|
assert.equal(
|
|
readLoggedInvocations(logPath).length,
|
|
1,
|
|
"still within the TTL — no second spawn"
|
|
);
|
|
|
|
t.mock.timers.tick(2000); // now past the TTL (cumulative: TTL + 1000ms)
|
|
await getCachedCursorAgentAvailability();
|
|
assert.equal(
|
|
readLoggedInvocations(logPath).length,
|
|
2,
|
|
"expiry must trigger exactly one fresh spawn"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("renewCursorConnection", () => {
|
|
const ORIGINAL_HOME = process.env.HOME;
|
|
const ORIGINAL_USERPROFILE = process.env.USERPROFILE;
|
|
let originalPlatformDescriptor: PropertyDescriptor | undefined;
|
|
let tmpHome: string;
|
|
let binaryPath: string;
|
|
let logPath: string;
|
|
|
|
beforeEach(() => {
|
|
originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
|
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
|
|
|
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cursor-renew-"));
|
|
process.env.HOME = tmpHome;
|
|
process.env.USERPROFILE = tmpHome;
|
|
|
|
binaryPath = path.join(tmpHome, ".local", "bin", "cursor-agent");
|
|
writeFakeCursorAgentBinary(binaryPath);
|
|
logPath = path.join(tmpHome, "log.jsonl");
|
|
process.env.FAKE_CURSOR_AGENT_LOG = logPath;
|
|
process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "authenticated";
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalPlatformDescriptor) {
|
|
Object.defineProperty(process, "platform", originalPlatformDescriptor);
|
|
}
|
|
process.env.HOME = ORIGINAL_HOME;
|
|
if (ORIGINAL_USERPROFILE !== undefined) process.env.USERPROFILE = ORIGINAL_USERPROFILE;
|
|
else delete process.env.USERPROFILE;
|
|
clearFakeCursorAgentEnv();
|
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
});
|
|
|
|
async function writeIdeToken(accessToken: string, machineId?: string): Promise<void> {
|
|
const { openDatabaseAsync } = await import("@/lib/db/adapters/driverFactory");
|
|
const dbPath = path.join(
|
|
tmpHome,
|
|
"Library/Application Support/Cursor/User/globalStorage/state.vscdb"
|
|
);
|
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
const seed = await openDatabaseAsync(dbPath);
|
|
seed.exec("CREATE TABLE itemTable (key TEXT PRIMARY KEY, value TEXT)");
|
|
seed
|
|
.prepare("INSERT INTO itemTable (key, value) VALUES (?, ?)")
|
|
.run("cursorAuth/accessToken", accessToken);
|
|
if (machineId) {
|
|
seed
|
|
.prepare("INSERT INTO itemTable (key, value) VALUES (?, ?)")
|
|
.run("storage.serviceMachineId", machineId);
|
|
}
|
|
seed.close();
|
|
}
|
|
|
|
function writeAgentToken(accessToken: string): void {
|
|
const authDir = path.join(tmpHome, ".config", "cursor");
|
|
fs.mkdirSync(authDir, { recursive: true });
|
|
fs.writeFileSync(path.join(authDir, "auth.json"), JSON.stringify({ accessToken }));
|
|
}
|
|
|
|
async function updateIdeToken(accessToken: string): Promise<void> {
|
|
const { openDatabaseAsync } = await import("@/lib/db/adapters/driverFactory");
|
|
const dbPath = path.join(
|
|
tmpHome,
|
|
"Library/Application Support/Cursor/User/globalStorage/state.vscdb"
|
|
);
|
|
const db = await openDatabaseAsync(dbPath);
|
|
db.prepare("INSERT OR REPLACE INTO itemTable (key, value) VALUES (?, ?)").run(
|
|
"cursorAuth/accessToken",
|
|
accessToken
|
|
);
|
|
db.close();
|
|
}
|
|
|
|
it("(a) cursor-agent unavailable + IDE re-scrape finds a new token -> renewed via cursor-ide", async () => {
|
|
process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "unauthenticated"; // cursor-agent "unavailable"
|
|
await writeIdeToken("new-ide-token", "machine-1");
|
|
|
|
const result = await renewCursorConnection({ accessToken: "old-token" });
|
|
assert.deepEqual(result, {
|
|
status: "renewed",
|
|
accessToken: "new-ide-token",
|
|
machineId: "machine-1",
|
|
source: "cursor-ide",
|
|
});
|
|
|
|
const invocations = readLoggedInvocations(logPath);
|
|
assert.ok(
|
|
!invocations.some((args) => args.includes("--list-models")),
|
|
"the nudge must never fire when cursor-agent is unavailable"
|
|
);
|
|
});
|
|
|
|
it("(b) IDE unchanged but cursor-agent's own token differs -> renewed via cursor-agent (regression: discarded-renewal)", async () => {
|
|
await writeIdeToken("old-token", "machine-1"); // IDE reports the SAME token as current
|
|
writeAgentToken("new-agent-token"); // agent's independent session differs
|
|
|
|
const result = await renewCursorConnection({ accessToken: "old-token" });
|
|
assert.deepEqual(result, {
|
|
status: "renewed",
|
|
accessToken: "new-agent-token",
|
|
source: "cursor-agent",
|
|
});
|
|
|
|
const invocations = readLoggedInvocations(logPath);
|
|
assert.ok(
|
|
invocations.some((args) => args.includes("--list-models")),
|
|
"cursor-agent was authenticated/available, so the nudge should have been attempted"
|
|
);
|
|
});
|
|
|
|
it("(c) both sources report the same token as current -> unchanged", async () => {
|
|
await writeIdeToken("old-token", "machine-1");
|
|
writeAgentToken("old-token");
|
|
|
|
const result = await renewCursorConnection({ accessToken: "old-token" });
|
|
assert.deepEqual(result, { status: "unchanged" });
|
|
});
|
|
|
|
it("both sources report not-found (no throw) -> unchanged, not error", async () => {
|
|
// Neither writeIdeToken nor writeAgentToken called: tryIdeAuth()/
|
|
// tryAgentAuth() gracefully report {found:false} (see
|
|
// tests/unit/cursor-token-extractor.test.ts), which renewCursorConnection
|
|
// treats identically to "found but unchanged": unchanged, never error.
|
|
const result = await renewCursorConnection({ accessToken: "old-token" });
|
|
assert.deepEqual(result, { status: "unchanged" });
|
|
});
|
|
|
|
it("(d) both sources fail/throw -> error with a sanitized message (via the deps injection seam)", async () => {
|
|
// renewCursorConnection()'s 3rd `deps` param is a testability seam added
|
|
// specifically because tryIdeAuth()/tryAgentAuth() never throw for real
|
|
// (they catch everything internally) — see src/lib/cursor/renewal.ts's
|
|
// doc comment on the `deps` parameter.
|
|
const rawMessage =
|
|
"Failed to read Cursor IDE database at " +
|
|
"/Users/secret-user/project/src/lib/cursor/tokenExtractor.ts:284:15 - permission denied";
|
|
const throwingTryIdeAuth = async (): Promise<never> => {
|
|
throw new Error(rawMessage);
|
|
};
|
|
const throwingTryAgentAuth = async (): Promise<never> => {
|
|
throw new Error(rawMessage);
|
|
};
|
|
|
|
const result = await renewCursorConnection(
|
|
{ accessToken: "old-token" },
|
|
{
|
|
tryIdeAuth: throwingTryIdeAuth,
|
|
tryAgentAuth: throwingTryAgentAuth,
|
|
// Keep this branch isolated from any real/fake spawn — the point of
|
|
// this test is the outer catch around tryIdeAuth/tryAgentAuth.
|
|
checkCursorAgentAvailability: async () => ({ available: false, binaryPath: null }),
|
|
}
|
|
);
|
|
|
|
assert.equal(result.status, "error");
|
|
if (result.status !== "error") return; // narrows for TS below
|
|
assert.equal(
|
|
result.error,
|
|
sanitizeErrorMessage(rawMessage),
|
|
"must be routed through sanitizeErrorMessage(), matching this repo's error-sanitization convention"
|
|
);
|
|
assert.ok(
|
|
!result.error.includes("/Users/secret-user"),
|
|
`raw absolute path must not survive sanitization, got: ${result.error}`
|
|
);
|
|
assert.ok(
|
|
!result.error.includes("tokenExtractor.ts:284:15"),
|
|
`raw source path must not survive sanitization, got: ${result.error}`
|
|
);
|
|
assert.ok(
|
|
result.error.includes("<path>"),
|
|
"the absolute path must be replaced with the <path> placeholder"
|
|
);
|
|
});
|
|
|
|
it("(e) never invokes cursor-agent with a login argument", async () => {
|
|
await writeIdeToken("new-ide-token-2");
|
|
writeAgentToken("new-agent-token-2");
|
|
await renewCursorConnection({ accessToken: "old-token" });
|
|
|
|
for (const args of readLoggedInvocations(logPath)) {
|
|
assert.ok(!args.includes("login"), `unexpected "login" argument in ${JSON.stringify(args)}`);
|
|
}
|
|
});
|
|
|
|
it("(f) the availability check's own spawn never uses --list-models, even inside the full orchestrator", async () => {
|
|
process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "unauthenticated";
|
|
await renewCursorConnection({ accessToken: "old-token" });
|
|
|
|
const statusCalls = readLoggedInvocations(logPath).filter((args) => args[0] === "status");
|
|
assert.ok(statusCalls.length >= 1, "expected at least one status check");
|
|
for (const args of statusCalls) {
|
|
assert.ok(!args.includes("--list-models"));
|
|
}
|
|
});
|
|
|
|
it("a crashing/unresponsive cursor-agent nudge degrades gracefully and still re-scrapes (does not abort the renewal)", async () => {
|
|
// The nudge (--list-models) hangs forever; runCursorAgentNudge's own
|
|
// sigkillFollowupMs eventually reaps it, but renewCursorConnection's
|
|
// try/catch around the nudge call means this must not block/fail the
|
|
// overall renewal — the IDE re-scrape below must still run and win.
|
|
process.env.FAKE_CURSOR_AGENT_HANG = "1";
|
|
process.env.FAKE_CURSOR_AGENT_IGNORE_SIGTERM = "0"; // exits on the very first SIGTERM
|
|
process.env.FAKE_CURSOR_AGENT_SELF_EXIT_MS = "50";
|
|
await writeIdeToken("new-ide-token-after-nudge-timeout", "machine-9");
|
|
|
|
const result = await renewCursorConnection({ accessToken: "old-token" });
|
|
assert.deepEqual(result, {
|
|
status: "renewed",
|
|
accessToken: "new-ide-token-after-nudge-timeout",
|
|
machineId: "machine-9",
|
|
source: "cursor-ide",
|
|
});
|
|
});
|
|
|
|
it("(g) dedupes tryIdeAuth() across near-simultaneous calls (PERF-001): a stale cached result is served within the TTL, then a fresh one after it expires", async (t) => {
|
|
// Simulates multiple Cursor connections becoming due for renewal in the
|
|
// same sweep tick: renewCursorConnection() is called back-to-back for
|
|
// the SAME host, so the second call must reuse the first's in-flight/
|
|
// recently-resolved tryIdeAuth() result rather than re-opening
|
|
// state.vscdb — this is a resource-usage guard (PERF-001), not a
|
|
// correctness fix. Uses fake timers on `Date` (same technique as
|
|
// getCachedCursorAgentAvailability's TTL test) since renewal.ts's dedup
|
|
// cache is keyed on Date.now(), not a mockable timer/interval.
|
|
t.mock.timers.enable({ apis: ["Date"] });
|
|
process.env.FAKE_CURSOR_AGENT_STATUS_MODE = "unauthenticated"; // skip the nudge; isolate the IDE-cache behavior
|
|
|
|
await writeIdeToken("token-A", "machine-a");
|
|
|
|
const first = await renewCursorConnection({ accessToken: "old-token" });
|
|
assert.deepEqual(first, {
|
|
status: "renewed",
|
|
accessToken: "token-A",
|
|
machineId: "machine-a",
|
|
source: "cursor-ide",
|
|
});
|
|
|
|
// Underlying file now has a NEW token, but a second call within the TTL
|
|
// must still observe the CACHED "token-A" — proven by comparing against
|
|
// current.accessToken: "token-A" (the first result) reads as unchanged
|
|
// only if the cache is actually being served instead of a fresh re-scrape.
|
|
await updateIdeToken("token-B");
|
|
t.mock.timers.tick(2000); // well within the 5s dedup TTL
|
|
const second = await renewCursorConnection({ accessToken: "token-A" });
|
|
assert.deepEqual(
|
|
second,
|
|
{ status: "unchanged" },
|
|
"expected the cached (stale) tryIdeAuth() result, not a fresh state.vscdb read"
|
|
);
|
|
|
|
// Past the TTL, the next call must re-open the file and observe "token-B".
|
|
t.mock.timers.tick(4000); // cumulative 6s, past the 5s TTL
|
|
const third = await renewCursorConnection({ accessToken: "token-A" });
|
|
assert.deepEqual(third, {
|
|
status: "renewed",
|
|
accessToken: "token-B",
|
|
machineId: "machine-a",
|
|
source: "cursor-ide",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("buildCursorRenewedUpdate", () => {
|
|
const NOW = "2026-07-31T12:00:00.000Z";
|
|
|
|
it("computes a fresh ~24h expiry, sets testStatus active, and clears error/retry fields", () => {
|
|
const update = buildCursorRenewedUpdate(
|
|
{
|
|
providerSpecificData: {
|
|
machineId: "old-machine",
|
|
refreshCircuit: { streak: 3 },
|
|
foo: "bar",
|
|
},
|
|
},
|
|
{ status: "renewed", accessToken: "tok", machineId: "new-machine", source: "cursor-ide" },
|
|
NOW
|
|
);
|
|
|
|
const expectedExpiresAt = new Date(
|
|
Date.parse(NOW) + CURSOR_TOKEN_LIFETIME_S * 1000
|
|
).toISOString();
|
|
assert.equal(update.accessToken, "tok");
|
|
assert.equal(update.expiresAt, expectedExpiresAt);
|
|
assert.equal(update.tokenExpiresAt, expectedExpiresAt);
|
|
assert.equal(update.testStatus, "active");
|
|
assert.equal(update.lastHealthCheckAt, NOW);
|
|
assert.equal(update.lastError, null);
|
|
assert.equal(update.lastErrorAt, null);
|
|
assert.equal(update.lastErrorType, null);
|
|
assert.equal(update.lastErrorSource, null);
|
|
assert.equal(update.errorCode, null);
|
|
assert.equal(update.expiredRetryCount, null);
|
|
assert.equal(update.expiredRetryAt, null);
|
|
|
|
const psd = update.providerSpecificData as Record<string, unknown>;
|
|
assert.equal(psd.machineId, "new-machine");
|
|
assert.equal(psd.foo, "bar");
|
|
assert.equal(psd.refreshCircuit, undefined, "refreshCircuit must be cleared");
|
|
});
|
|
|
|
it("preserves the existing machineId when the renewal result has none", () => {
|
|
const update = buildCursorRenewedUpdate(
|
|
{ providerSpecificData: { machineId: "keep-me" } },
|
|
{ status: "renewed", accessToken: "tok", source: "cursor-agent" },
|
|
NOW
|
|
);
|
|
assert.equal((update.providerSpecificData as Record<string, unknown>).machineId, "keep-me");
|
|
});
|
|
|
|
it("handles a connection with no prior providerSpecificData", () => {
|
|
const update = buildCursorRenewedUpdate(
|
|
{},
|
|
{ status: "renewed", accessToken: "tok", source: "cursor-ide" },
|
|
NOW
|
|
);
|
|
assert.deepEqual(update.providerSpecificData, {});
|
|
});
|
|
});
|
|
|
|
describe("createKeyedMutex", () => {
|
|
it("serializes calls for the same key: the second fn does not start until the first settles, and each caller gets its own result", async () => {
|
|
const mutex = createKeyedMutex<string>();
|
|
const events: string[] = [];
|
|
const gate = deferred();
|
|
|
|
const p1 = mutex.run("k", async () => {
|
|
events.push("first-start");
|
|
await gate.promise;
|
|
events.push("first-end");
|
|
return "first-result";
|
|
});
|
|
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
const p2 = mutex.run("k", async () => {
|
|
events.push("second-start");
|
|
return "second-result";
|
|
});
|
|
|
|
assert.deepEqual(events, ["first-start"], "second call must not have started yet");
|
|
gate.resolve();
|
|
|
|
const [r1, r2] = await Promise.all([p1, p2]);
|
|
assert.equal(r1, "first-result");
|
|
assert.equal(r2, "second-result");
|
|
assert.deepEqual(events, ["first-start", "first-end", "second-start"]);
|
|
});
|
|
|
|
it("runs calls for different keys concurrently (no cross-key serialization)", async () => {
|
|
const mutex = createKeyedMutex<string>();
|
|
const events: string[] = [];
|
|
const gateA = deferred();
|
|
const gateB = deferred();
|
|
|
|
const pA = mutex.run("a", async () => {
|
|
events.push("a-start");
|
|
await gateA.promise;
|
|
return "a";
|
|
});
|
|
const pB = mutex.run("b", async () => {
|
|
events.push("b-start");
|
|
await gateB.promise;
|
|
return "b";
|
|
});
|
|
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
assert.ok(events.includes("a-start"));
|
|
assert.ok(events.includes("b-start"));
|
|
|
|
gateA.resolve();
|
|
gateB.resolve();
|
|
const [ra, rb] = await Promise.all([pA, pB]);
|
|
assert.equal(ra, "a");
|
|
assert.equal(rb, "b");
|
|
});
|
|
});
|
|
|
|
describe("runCursorRenewalExclusive", () => {
|
|
it("two concurrent calls for the SAME connectionId each get their own fn's result, with correct execution ordering", async () => {
|
|
const events: string[] = [];
|
|
const gate = deferred();
|
|
const connectionId = `conn-${Date.now()}-${Math.random()}`;
|
|
|
|
const p1 = runCursorRenewalExclusive(connectionId, async () => {
|
|
events.push("sweep-start");
|
|
await gate.promise;
|
|
events.push("sweep-end");
|
|
return { kind: "sweep" };
|
|
});
|
|
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
const p2 = runCursorRenewalExclusive(connectionId, async () => {
|
|
events.push("manual-start");
|
|
return { kind: "manual" };
|
|
});
|
|
|
|
assert.deepEqual(
|
|
events,
|
|
["sweep-start"],
|
|
"the manual caller must not start until the sweep settles"
|
|
);
|
|
gate.resolve();
|
|
|
|
const [r1, r2] = await Promise.all([p1, p2]);
|
|
assert.deepEqual(r1, { kind: "sweep" });
|
|
assert.deepEqual(r2, { kind: "manual" });
|
|
assert.deepEqual(events, ["sweep-start", "sweep-end", "manual-start"]);
|
|
});
|
|
|
|
it("different connectionIds run concurrently without blocking each other", async () => {
|
|
const events: string[] = [];
|
|
const gateA = deferred();
|
|
const gateB = deferred();
|
|
const connA = `conn-a-${Date.now()}-${Math.random()}`;
|
|
const connB = `conn-b-${Date.now()}-${Math.random()}`;
|
|
|
|
const pA = runCursorRenewalExclusive(connA, async () => {
|
|
events.push("a-start");
|
|
await gateA.promise;
|
|
return "a";
|
|
});
|
|
const pB = runCursorRenewalExclusive(connB, async () => {
|
|
events.push("b-start");
|
|
await gateB.promise;
|
|
return "b";
|
|
});
|
|
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
assert.ok(events.includes("a-start"));
|
|
assert.ok(events.includes("b-start"));
|
|
|
|
gateA.resolve();
|
|
gateB.resolve();
|
|
const [ra, rb] = await Promise.all([pA, pB]);
|
|
assert.equal(ra, "a");
|
|
assert.equal(rb, "b");
|
|
});
|
|
});
|