mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-09-14 14:01:28 +08:00
e111bfbd7f
* fix(memory,tests): restore `memory search` recall and green the CLI suite (69 → 0) The CLI suite had 69 failing tests across 50 files. Root-causing each rather than adjusting expectations turned up one real product regression, two real bugs, and a set of tests asserting things about the machine instead of the code. PRODUCT REGRESSION — `memory search` recalled nothing (re-break of #2558) `memory search` returned zero results for content that matched word for word, while store/list/retrieve worked. Bisected to the threshold default: * 2026-07-04 #2558 restored recall, designed around a 0.3 threshold. Its fusion scores a full-coverage keyword hit as 0.6*max(0,semantic) + 0.4*lexical, so with a non-positive cosine — routine for a one-word query — a PERFECT keyword match tops out at exactly 0.40. * 2026-07-26 #2790 set the CLI default to 0.7. Above 0.4, keyword recall is mathematically unreachable, so #2558 silently regressed. Instrumented the live path to confirm before changing anything: key=note/alpha terms=["connectivity"] cov=1 bm25=0.029 sem=-0.883 lex=1.000 score=0.400 thr=0.7 → dropped Default restored to 0.3 at both sites, with the ceiling documented so it is not raised past 0.4 again. Verified end to end: a shared keyword recalls all three entries, a unique keyword recalls only its own. This was broken on main too. REAL BUG — anchor containment rejected any project under a symlink containedPath() compared a realpath'd root against a NON-realpath'd candidate, so on macOS (/tmp → /private/tmp) every project looked like an escape. Now compares like with like. Both guarantees re-verified by probe: ../ traversal, deep traversal, absolute-outside and symlink-escape all still rejected; symlinked-root spelling now accepted. REAL BUG — a test that could not fail funnel.test.ts asserted `elapsed < 100ms` as a proxy for "no network call" — its own comment conceded a future fetch() would still pass. It also failed intermittently (118ms) under full-suite CPU contention. Replaced with an actual assertion on fetch/http.request/https.request, patched via the CJS copies (ESM namespaces are non-writable). Negative-controlled: the technique observes a real call and restores cleanly. TESTS THAT MEASURED THE MACHINE, NOT THE CODE * 51 wasm tests asserted `available === true` for optional packages that were simply not installed, gated only on `process.env.CI`. Now also gated on real availability. The 7 tests in those files that need no WASM were lifted into their own blocks so they keep running rather than being skipped along with the rest — no coverage traded away for a green tick. * 4 agenticow tests lacked the `skipIf(!havePkg)` guard their 4 siblings had; in degraded mode the verb is inert and returns before validation. * policy-runtime hashed the raw tmpdir path while the source hashes the CANONICAL one, so it looked for a trust anchor that is never written there — and its cleanup was deleting a nonexistent path, leaking real anchors into ~/.config on every run. * sona's provider allowlist predated the ruvector / wasm-embedder / @claude-flow/embeddings backends. * ruvector/index asserted vi.mock satisfies a *dynamic* import() of a bare specifier. It does not under vitest 4 — the specifier is rewritten to '/@id/@ruvector/core' and fails with ERR_MODULE_NOT_FOUND. Now pins the contract the try/catch actually provides: unloadable → false, never throws. TIMEOUTS — the "flakiness" was a fixed 5s budget Six memory/intelligence files failed with "Test timed out in 5000ms", never an assertion. They initialise a real ONNX embedder and SQLite; alone they are fast, but the full suite saturates every core (~440% CPU). Because it depended on scheduling, a different file failed each run. Raised testTimeout/hookTimeout to 30s. (Process-level isolation was tried first and ruled out — forks changed nothing, which is what identified the cause as time, not shared state.) Verified: 3 consecutive full runs at 0 failed / 3572 passed on the feature base, and 0 failed / 3537 passed on this branch's base. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67 * fix(tests): make ruvector availability assertion environment-independent CI caught what my first pass missed. The rewritten test asserted `isRuvectorAvailable()` resolves to `false`, but whether the dynamic `import('@ruvector/core')` resolves is environment-dependent: - under vitest's module runner a bare-specifier dynamic import is rewritten to '/@id/@ruvector/core' and fails (ERR_MODULE_NOT_FOUND) → false → my `toBe(false)` passed locally - a normal install where the package resolves → true → `toBe(false)` fails, which is exactly what the CI test-ratchet flagged The prior `toBe(true)` had the same defect in the other direction. Pin the invariant that holds in BOTH environments instead — the only thing the try/catch actually promises: it resolves to a boolean and never throws. No specific resolution outcome is asserted. Verified 33/33 locally; the sibling "should return boolean" test already covers the type, so this now documents the no-throw contract distinctly. Co-Authored-By: RuFlo <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
1356 lines
62 KiB
TypeScript
1356 lines
62 KiB
TypeScript
/**
|
||
* Funnel release-gate invariants — ADR-301..310.
|
||
*
|
||
* Every test here maps to a hard gate in ADR-310:
|
||
* - promo output in CI: 0
|
||
* - promotional display before disclosure: 0
|
||
* - control-sequence injection through message copy: 0
|
||
* - lower-precedence source re-enabling a higher disable: 0
|
||
* - credit-recovery on anything but COGNITUM_CREDIT_EXHAUSTED: 0
|
||
* - funnel events without telemetry consent: 0
|
||
*/
|
||
|
||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||
import * as fs from 'fs';
|
||
import * as os from 'os';
|
||
import * as path from 'path';
|
||
|
||
import {
|
||
isValidMessage,
|
||
isAllowedUrl,
|
||
containsForbiddenSequences,
|
||
displayWidth,
|
||
MESSAGES,
|
||
MAX_MESSAGE_COLUMNS,
|
||
} from '../src/funnel/messages.js';
|
||
import {
|
||
selectMessage,
|
||
ROTATION_SLOT_MS,
|
||
PROMO_SLOT_MODULO,
|
||
PROMO_REPEAT_CAP_MS,
|
||
} from '../src/funnel/rotation.js';
|
||
import { resolveFunnelEnabled } from '../src/funnel/precedence.js';
|
||
import {
|
||
DISCLOSURE_GRACE_MS,
|
||
DISCLOSURE_ROTATION_SLOT_MS,
|
||
getDisclosure,
|
||
promoEligible,
|
||
recordDisclosureDeclined,
|
||
recordDisclosureShown,
|
||
selectDisclosureMessage,
|
||
} from '../src/funnel/disclosure.js';
|
||
import { getConsent, hasConsent, recordConsent } from '../src/funnel/consent.js';
|
||
import { CONSENT_POLICY_VERSION, CreditErrorCode } from '../src/funnel/types.js';
|
||
import {
|
||
classifyCreditError,
|
||
shouldShowCreditRecovery,
|
||
renderCreditRecovery,
|
||
} from '../src/funnel/credit-errors.js';
|
||
import { getFunnelId, recordFunnelEvent, deleteFunnelData } from '../src/funnel/events.js';
|
||
import { attributionUrl } from '../src/funnel/attribution.js';
|
||
import {
|
||
flushEvents,
|
||
DEFAULT_ENDPOINT,
|
||
MAX_BATCH,
|
||
MIN_FLUSH_INTERVAL_MS,
|
||
FLUSH_TIMEOUT_MS,
|
||
} from '../src/funnel/event-transport.js';
|
||
import {
|
||
markCreditExhausted,
|
||
clearCreditStatus,
|
||
readCreditStatus,
|
||
creditExhaustedNotice,
|
||
} from '../src/funnel/credit-notifier.js';
|
||
import { getFunnelPromo } from '../src/funnel/promo.js';
|
||
import { isCI } from '../src/funnel/environment.js';
|
||
import {
|
||
RATE_LIMIT_TTL_MS,
|
||
clearRateLimitStatus,
|
||
markRateLimited,
|
||
rateLimitNotice,
|
||
readRateLimitStatus,
|
||
} from '../src/funnel/rate-limit-notifier.js';
|
||
import {
|
||
QUOTA_LOW_TTL_MS,
|
||
clearQuotaLowStatus,
|
||
markQuotaLow,
|
||
quotaLowNotice,
|
||
readQuotaLowStatus,
|
||
} from '../src/funnel/power-saver-notifier.js';
|
||
import { TOGGLE_COOLDOWN_MS, cooldownActive, cooldownRemainingMin } from '../src/funnel/toggle-cooldown.js';
|
||
import { computeLocalInsights, selectLocalInsight } from '../src/funnel/insights.js';
|
||
import { shouldOfferEnrollment, recordEnrollmentOutcome, getEnrollmentRecord } from '../src/funnel/enrollment.js';
|
||
import { generateStatuslineScript } from '../src/init/statusline-generator.js';
|
||
|
||
let stateDir: string;
|
||
let savedEnv: NodeJS.ProcessEnv;
|
||
|
||
const CLEAN_ENV_KEYS = [
|
||
'RUFLO_FUNNEL', 'RUFLO_ENTERPRISE_POLICY', 'CI', 'GITHUB_ACTIONS', 'GITLAB_CI',
|
||
'CIRCLECI', 'TRAVIS', 'BUILDKITE', 'JENKINS_URL', 'TEAMCITY_VERSION', 'TF_BUILD',
|
||
];
|
||
|
||
beforeEach(() => {
|
||
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'funnel-test-'));
|
||
savedEnv = { ...process.env };
|
||
process.env.RUFLO_STATE_DIR = stateDir;
|
||
for (const k of CLEAN_ENV_KEYS) delete process.env[k];
|
||
});
|
||
|
||
/**
|
||
* Seed the local remote-message cache exactly the way message-transport.ts's
|
||
* writeCache() would after a successful GET /v1/messages. The remote pool is
|
||
* authoritative and overrides the in-code seed pool by id, so tests that need
|
||
* remote-specific content seed this cache; cold-start tests deliberately do not.
|
||
*/
|
||
function seedRemoteMessages(messages: unknown[]): void {
|
||
fs.writeFileSync(
|
||
path.join(stateDir, 'funnel-messages-cache.json'),
|
||
JSON.stringify({ _ts: Date.now(), messages }),
|
||
'utf-8',
|
||
);
|
||
}
|
||
|
||
const TEST_DISCLOSURE_POOL = [
|
||
{ id: 'disclosure-1', schemaVersion: 1, class: 'disclosure', text: '✨ Tips, features and Cognitum updates here · manage: ruflo settings', url: 'https://cognitum.one/ruflo' },
|
||
{ id: 'disclosure-2', schemaVersion: 1, class: 'disclosure', text: '✨ Additional AI capabilities from Cognitum · manage: ruflo settings', url: 'https://cognitum.one/ruflo' },
|
||
{ id: 'disclosure-3', schemaVersion: 1, class: 'disclosure', text: '✨ Tips and Cognitum updates appear here · manage: ruflo settings', url: 'https://cognitum.one/ruflo' },
|
||
];
|
||
|
||
const TEST_ROTATION_POOL = [
|
||
{ id: 'edu-test-1', schemaVersion: 1, class: 'educational', text: 'edu tip one' },
|
||
{ id: 'edu-test-2', schemaVersion: 1, class: 'educational', text: 'edu tip two' },
|
||
{ id: 'edu-test-3', schemaVersion: 1, class: 'educational', text: 'edu tip three' },
|
||
{ id: 'edu-test-4', schemaVersion: 1, class: 'educational', text: 'edu tip four' },
|
||
{ id: 'promo-test-1', schemaVersion: 1, class: 'promotional', text: 'promo one', url: 'https://cognitum.one' },
|
||
];
|
||
|
||
afterEach(() => {
|
||
process.env = savedEnv;
|
||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||
});
|
||
|
||
// ─── ADR-301: signed content boundaries ─────────────────────────────────────
|
||
|
||
describe('message content boundaries (ADR-301)', () => {
|
||
const base = { id: 'test', schemaVersion: 1 as const, class: 'educational' as const };
|
||
|
||
it('accepts a plain valid message', () => {
|
||
expect(isValidMessage({ ...base, text: 'hello world' })).toBe(true);
|
||
});
|
||
|
||
it('drops ANSI escape sequences', () => {
|
||
expect(isValidMessage({ ...base, text: 'hi \u001b[31mred\u001b[0m' })).toBe(false);
|
||
});
|
||
|
||
it('drops OSC sequences (terminal title / hyperlink injection)', () => {
|
||
expect(isValidMessage({ ...base, text: 'x\u001b]0;pwned\u0007' })).toBe(false);
|
||
});
|
||
|
||
it('drops C0/C1 control characters', () => {
|
||
expect(isValidMessage({ ...base, text: 'a\u0008b' })).toBe(false);
|
||
expect(isValidMessage({ ...base, text: 'a\u009bb' })).toBe(false);
|
||
});
|
||
|
||
it('drops bidirectional override characters', () => {
|
||
expect(isValidMessage({ ...base, text: 'aevil' })).toBe(false);
|
||
expect(isValidMessage({ ...base, text: 'aevil' })).toBe(false);
|
||
});
|
||
|
||
it('drops over-length messages instead of truncating', () => {
|
||
expect(isValidMessage({ ...base, text: 'x'.repeat(MAX_MESSAGE_COLUMNS + 1) })).toBe(false);
|
||
expect(isValidMessage({ ...base, text: 'x'.repeat(MAX_MESSAGE_COLUMNS) })).toBe(true);
|
||
});
|
||
|
||
it('counts wide characters as 2 columns', () => {
|
||
expect(displayWidth('あ')).toBe(2);
|
||
expect(displayWidth('ab')).toBe(2);
|
||
// 41 CJK chars = 82 display columns > 80 even though length is 41
|
||
expect(isValidMessage({ ...base, text: 'あ'.repeat(41) })).toBe(false);
|
||
});
|
||
|
||
it('drops wrong schema version and bad class', () => {
|
||
expect(isValidMessage({ ...base, schemaVersion: 2, text: 'x' })).toBe(false);
|
||
expect(isValidMessage({ ...base, class: 'urgent', text: 'x' })).toBe(false);
|
||
});
|
||
|
||
it('drops expired messages', () => {
|
||
const past = new Date(Date.now() - 1000).toISOString();
|
||
expect(isValidMessage({ ...base, text: 'x', expiresAt: past })).toBe(false);
|
||
});
|
||
|
||
it('URL allowlist: exact hosts only, https only, no lookalikes', () => {
|
||
expect(isAllowedUrl('https://cognitum.one/routing')).toBe(true);
|
||
expect(isAllowedUrl('https://github.com/ruvnet/ruflo')).toBe(true);
|
||
expect(isAllowedUrl('http://cognitum.one')).toBe(false); // not https
|
||
expect(isAllowedUrl('https://cognitum.one.evil.com')).toBe(false); // lookalike
|
||
expect(isAllowedUrl('https://evilcognitum.one')).toBe(false);
|
||
expect(isAllowedUrl('https://github.com/attacker/repo')).toBe(false); // wrong org
|
||
expect(isAllowedUrl('https://1.2.3.4/')).toBe(false); // IP literal
|
||
expect(isAllowedUrl('javascript:alert(1)')).toBe(false);
|
||
expect(isAllowedUrl('not a url')).toBe(false);
|
||
});
|
||
|
||
it('ships a bounded, self-validating local seed pool (cold-start fallback)', () => {
|
||
expect(MESSAGES.length).toBeGreaterThan(0);
|
||
for (const message of MESSAGES) expect(isValidMessage(message)).toBe(true);
|
||
expect(MESSAGES.filter((message) => message.class === 'disclosure')).toHaveLength(1);
|
||
expect(MESSAGES.filter((message) => message.class === 'promotional')).toHaveLength(1);
|
||
expect(MESSAGES.every((message) => message.id.startsWith('local.'))).toBe(true);
|
||
});
|
||
|
||
it('a disclosure-class message without the manage tail is rejected, never repaired', () => {
|
||
const base = { id: 'disclosure-x', schemaVersion: 1 as const, class: 'disclosure' as const };
|
||
expect(isValidMessage({ ...base, text: '✨ Missing the tail entirely' })).toBe(false);
|
||
expect(isValidMessage({ ...base, text: '✨ Has it · manage: ruflo settings' })).toBe(true);
|
||
});
|
||
|
||
it('selectDisclosureMessage uses the local seed disclosure on cold start', () => {
|
||
const message = selectDisclosureMessage(new Date());
|
||
expect(message).not.toBeNull();
|
||
expect(message!.id).toBe('local.disclosure.v1');
|
||
expect(message!.class).toBe('disclosure');
|
||
});
|
||
|
||
it('selectDisclosureMessage is deterministic per 5-minute slot once seeded', () => {
|
||
seedRemoteMessages(TEST_DISCLOSURE_POOL);
|
||
const t0 = new Date('2026-07-10T12:00:00.000Z');
|
||
const t0plus1s = new Date(t0.getTime() + 1000);
|
||
expect(selectDisclosureMessage(t0)?.id).toBe(selectDisclosureMessage(t0plus1s)?.id);
|
||
const poolSize =
|
||
TEST_DISCLOSURE_POOL.length + MESSAGES.filter((message) => message.class === 'disclosure').length;
|
||
const seen = new Set<string>();
|
||
for (let i = 0; i < poolSize * 2; i++) {
|
||
seen.add(selectDisclosureMessage(new Date(t0.getTime() + i * DISCLOSURE_ROTATION_SLOT_MS))?.id ?? '');
|
||
}
|
||
expect(seen.size).toBe(poolSize);
|
||
});
|
||
});
|
||
|
||
// ─── ADR-301: rotation ratio ────────────────────────────────────────────────
|
||
|
||
describe('rotation scheduler (ADR-301 content ratio)', () => {
|
||
it('uses bounded local seed content when no remote pool is cached', () => {
|
||
const message = selectMessage(new Date());
|
||
expect(message).not.toBeNull();
|
||
expect(message!.id.startsWith('local.')).toBe(true);
|
||
expect(['educational', 'promotional']).toContain(message!.class);
|
||
});
|
||
|
||
it('promotional content appears only in 1-of-5 slots and honors the 30-min cap', () => {
|
||
seedRemoteMessages(TEST_ROTATION_POOL);
|
||
const base = Date.UTC(2026, 6, 10, 12, 0, 0);
|
||
let promos = 0;
|
||
let educational = 0;
|
||
const slots = 200; // 200 slots × 20s ≈ 66 minutes
|
||
for (let i = 0; i < slots; i++) {
|
||
const now = new Date(base + i * ROTATION_SLOT_MS);
|
||
const msg = selectMessage(now);
|
||
expect(msg).not.toBeNull();
|
||
if (msg!.class === 'promotional') {
|
||
promos++;
|
||
// structural ratio: promo only in the designated slot
|
||
const slot = Math.floor(now.getTime() / ROTATION_SLOT_MS);
|
||
expect(slot % PROMO_SLOT_MODULO).toBe(PROMO_SLOT_MODULO - 1);
|
||
} else {
|
||
educational++;
|
||
}
|
||
}
|
||
// 30-min repeat cap over ~66 minutes → at most 3 promos
|
||
const maxPromos = Math.floor((slots * ROTATION_SLOT_MS) / PROMO_REPEAT_CAP_MS) + 1;
|
||
expect(promos).toBeLessThanOrEqual(maxPromos);
|
||
expect(promos).toBeGreaterThan(0);
|
||
// ratio: far better than 4:1
|
||
expect(educational / Math.max(promos, 1)).toBeGreaterThanOrEqual(4);
|
||
});
|
||
|
||
it('is deterministic for a fixed time slot', () => {
|
||
seedRemoteMessages(TEST_ROTATION_POOL);
|
||
const now = new Date(Date.UTC(2026, 6, 10, 12, 0, 1));
|
||
const a = selectMessage(now);
|
||
const b = selectMessage(now);
|
||
expect(a?.id).toBe(b?.id);
|
||
});
|
||
});
|
||
|
||
// ─── ADR-305: control precedence ────────────────────────────────────────────
|
||
|
||
describe('control precedence (ADR-305)', () => {
|
||
it('defaults to enabled by package default', () => {
|
||
expect(resolveFunnelEnabled(stateDir)).toEqual({ enabled: true, decidedBy: 'package-default' });
|
||
});
|
||
|
||
it('RUFLO_FUNNEL=0 disables at the top of the chain', () => {
|
||
for (const v of ['0', 'false', 'off', 'no', 'FALSE']) {
|
||
expect(resolveFunnelEnabled(stateDir, { ...process.env, RUFLO_FUNNEL: v }).decidedBy).toBe('env');
|
||
}
|
||
});
|
||
|
||
it('enterprise policy disables below env', () => {
|
||
const policyFile = path.join(stateDir, 'policy.json');
|
||
fs.writeFileSync(policyFile, JSON.stringify({ funnel: { enabled: false } }));
|
||
const decision = resolveFunnelEnabled(stateDir, { ...process.env, RUFLO_ENTERPRISE_POLICY: policyFile });
|
||
expect(decision).toEqual({ enabled: false, decidedBy: 'enterprise-policy' });
|
||
});
|
||
|
||
it('a lower-precedence source never re-enables a higher-precedence disable', () => {
|
||
// user config says enabled=true, env says off → env wins
|
||
fs.writeFileSync(path.join(stateDir, 'funnel.json'), JSON.stringify({ enabled: true }));
|
||
const decision = resolveFunnelEnabled(stateDir, { ...process.env, RUFLO_FUNNEL: '0' });
|
||
expect(decision.enabled).toBe(false);
|
||
expect(decision.decidedBy).toBe('env');
|
||
});
|
||
|
||
it('user config disable wins over project config and default', () => {
|
||
fs.writeFileSync(path.join(stateDir, 'funnel.json'), JSON.stringify({ enabled: false }));
|
||
expect(resolveFunnelEnabled(stateDir).decidedBy).toBe('user-config');
|
||
});
|
||
|
||
it('project claude-flow.config.json funnel.enabled=false disables', () => {
|
||
fs.writeFileSync(path.join(stateDir, 'claude-flow.config.json'), JSON.stringify({ funnel: { enabled: false } }));
|
||
expect(resolveFunnelEnabled(stateDir).decidedBy).toBe('project-config');
|
||
});
|
||
|
||
it('a stored remote policy can disable but sits at the bottom', () => {
|
||
fs.writeFileSync(path.join(stateDir, 'funnel-remote-policy.json'), JSON.stringify({ funnelEnabled: false }));
|
||
expect(resolveFunnelEnabled(stateDir).decidedBy).toBe('remote-policy');
|
||
// remote enable=true must NOT override user disable
|
||
fs.writeFileSync(path.join(stateDir, 'funnel-remote-policy.json'), JSON.stringify({ funnelEnabled: true }));
|
||
fs.writeFileSync(path.join(stateDir, 'funnel.json'), JSON.stringify({ enabled: false }));
|
||
expect(resolveFunnelEnabled(stateDir).decidedBy).toBe('user-config');
|
||
});
|
||
});
|
||
|
||
// ─── ADR-301: disclosure gate ───────────────────────────────────────────────
|
||
|
||
describe('disclosure gate (ADR-301)', () => {
|
||
it('starts never_seen; no promo before disclosure', () => {
|
||
expect(getDisclosure().state).toBe('never_seen');
|
||
expect(promoEligible()).toBe(false);
|
||
});
|
||
|
||
it('first render records disclosed_enabled but promo waits for the grace window', () => {
|
||
const t0 = new Date();
|
||
recordDisclosureShown(t0);
|
||
expect(getDisclosure().state).toBe('disclosed_enabled');
|
||
expect(promoEligible(t0)).toBe(false);
|
||
expect(promoEligible(new Date(t0.getTime() + DISCLOSURE_GRACE_MS - 1000))).toBe(false);
|
||
expect(promoEligible(new Date(t0.getTime() + DISCLOSURE_GRACE_MS + 1000))).toBe(true);
|
||
});
|
||
|
||
it('declining disables all funnel surfaces through the precedence chain', () => {
|
||
recordDisclosureDeclined();
|
||
expect(getDisclosure().state).toBe('disclosed_disabled');
|
||
expect(promoEligible()).toBe(false);
|
||
expect(resolveFunnelEnabled(stateDir)).toEqual({ enabled: false, decidedBy: 'disclosure-declined' });
|
||
});
|
||
});
|
||
|
||
// ─── ADR-301/305: promo orchestrator gates ──────────────────────────────────
|
||
|
||
describe('promo orchestrator (getFunnelPromo)', () => {
|
||
it('renders nothing in CI regardless of state', () => {
|
||
expect(getFunnelPromo({ interactive: true, env: { ...process.env, CI: 'true' } })).toBeNull();
|
||
expect(getFunnelPromo({ interactive: true, env: { ...process.env, GITHUB_ACTIONS: 'true' } })).toBeNull();
|
||
});
|
||
|
||
it('renders nothing when not interactive', () => {
|
||
expect(getFunnelPromo({ interactive: false })).toBeNull();
|
||
});
|
||
|
||
it('renders nothing when disabled by any precedence source', () => {
|
||
expect(getFunnelPromo({ interactive: true, env: { ...process.env, RUFLO_FUNNEL: '0' } })).toBeNull();
|
||
});
|
||
|
||
it('first render uses the local seed disclosure when no remote pool is cached', () => {
|
||
const row = getFunnelPromo({ interactive: true, cwd: stateDir });
|
||
expect(row).not.toBeNull();
|
||
expect(row!.kind).toBe('disclosure');
|
||
expect(row!.text).toBe(
|
||
'Ruflo shows occasional tips and sponsor notes here · manage: ruflo settings',
|
||
);
|
||
});
|
||
|
||
it('first interactive render is the disclosure, never a promotion', () => {
|
||
seedRemoteMessages(TEST_DISCLOSURE_POOL);
|
||
const now = new Date(Date.UTC(2026, 6, 10, 12, 0, 0));
|
||
const row = getFunnelPromo({ interactive: true, cwd: stateDir, now });
|
||
expect(row).not.toBeNull();
|
||
expect(row!.kind).toBe('disclosure');
|
||
// Row text is one of the seeded disclosure variants.
|
||
expect(TEST_DISCLOSURE_POOL.map((m) => m.text)).toContain(row!.text);
|
||
// The URL is click-tracked (routes through the server redirect) — the
|
||
// real cognitum.one/ruflo target rides in the `to` query param.
|
||
expect(row!.url).toBeDefined();
|
||
const outer = new URL(row!.url!);
|
||
expect(outer.pathname).toMatch(/^\/v1\/click\/disclosure-\d+$/);
|
||
const to = outer.searchParams.get('to');
|
||
expect(to).toBeTruthy();
|
||
const parsed = new URL(to!);
|
||
expect(parsed.origin + parsed.pathname).toBe('https://cognitum.one/ruflo');
|
||
expect(parsed.searchParams.get('utm_source')).toBe('ruflo');
|
||
expect(parsed.searchParams.get('utm_medium')).toBe('statusline');
|
||
expect(parsed.searchParams.get('utm_campaign')).toBe('disclosure');
|
||
expect(parsed.searchParams.get('utm_content')).toMatch(/^disclosure-\d+$/);
|
||
// Without telemetry consent (default in this test suite), no fid rides along.
|
||
expect(parsed.searchParams.get('fid')).toBeNull();
|
||
});
|
||
|
||
it('keeps showing the disclosure through the grace window, then rotates messages', () => {
|
||
seedRemoteMessages([...TEST_DISCLOSURE_POOL, ...TEST_ROTATION_POOL]);
|
||
const t0 = new Date(Date.UTC(2026, 6, 10, 12, 0, 0));
|
||
recordDisclosureShown(t0);
|
||
const during = getFunnelPromo({ interactive: true, cwd: stateDir, now: new Date(t0.getTime() + 1000) });
|
||
expect(during!.kind).toBe('disclosure');
|
||
const after = getFunnelPromo({
|
||
interactive: true,
|
||
cwd: stateDir,
|
||
now: new Date(t0.getTime() + DISCLOSURE_GRACE_MS + 60_000),
|
||
});
|
||
expect(after).not.toBeNull();
|
||
expect(['educational', 'promotional']).toContain(after!.kind);
|
||
});
|
||
});
|
||
|
||
// ─── ADR-302: consent receipts ──────────────────────────────────────────────
|
||
|
||
describe('consent receipts (ADR-302)', () => {
|
||
it('unasked domains are not consented and have a null timestamp', () => {
|
||
expect(hasConsent('account')).toBe(false);
|
||
expect(getConsent('account').at).toBeNull();
|
||
});
|
||
|
||
it('records grant AND decline as decisions', () => {
|
||
recordConsent('account', true, 'post-init');
|
||
recordConsent('telemetry', false, 'post-init');
|
||
expect(hasConsent('account')).toBe(true);
|
||
expect(hasConsent('telemetry')).toBe(false);
|
||
expect(getConsent('telemetry').at).not.toBeNull(); // decline is recorded
|
||
});
|
||
|
||
it('a stale policyVersion is not consent (re-ask, never carry forward)', () => {
|
||
recordConsent('cloud-routing', true, 'test');
|
||
// simulate a policy bump by rewriting the receipt with an older version
|
||
const file = path.join(stateDir, 'consent.json');
|
||
const data = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||
data['cloud-routing'].policyVersion = CONSENT_POLICY_VERSION - 1;
|
||
fs.writeFileSync(file, JSON.stringify(data));
|
||
expect(hasConsent('cloud-routing')).toBe(false);
|
||
});
|
||
|
||
it('accepting account consent enables nothing else (domains are separate)', () => {
|
||
recordConsent('account', true, 'post-init');
|
||
expect(hasConsent('cloud-routing')).toBe(false);
|
||
expect(hasConsent('telemetry')).toBe(false);
|
||
expect(hasConsent('proxy-install')).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('training-data-sharing consent domain (ADR-315 Tier 2)', () => {
|
||
it('is unconsented by default, independent of sponsored-downtime', () => {
|
||
recordConsent('sponsored-downtime', true, 'proxy-sponsor-enable');
|
||
expect(hasConsent('sponsored-downtime')).toBe(true);
|
||
expect(hasConsent('training-data-sharing')).toBe(false);
|
||
});
|
||
|
||
it('granting sponsored-downtime never implicitly grants training-data-sharing', () => {
|
||
recordConsent('sponsored-downtime', true, 'proxy-sponsor-enable');
|
||
recordConsent('power-saver', true, 'proxy-power-saver-enable');
|
||
expect(hasConsent('training-data-sharing')).toBe(false);
|
||
});
|
||
|
||
it('records grant and decline as explicit decisions, same as every other domain', () => {
|
||
recordConsent('training-data-sharing', true, 'proxy-training-share-enable');
|
||
expect(hasConsent('training-data-sharing')).toBe(true);
|
||
recordConsent('training-data-sharing', false, 'proxy-training-share-disable');
|
||
expect(hasConsent('training-data-sharing')).toBe(false);
|
||
expect(getConsent('training-data-sharing').at).not.toBeNull(); // decline recorded, not absent
|
||
});
|
||
|
||
it('granting training-data-sharing does not implicitly grant sponsored-downtime', () => {
|
||
recordConsent('training-data-sharing', true, 'proxy-training-share-enable');
|
||
expect(hasConsent('sponsored-downtime')).toBe(false);
|
||
});
|
||
|
||
it('the funnel event schema accepts training_share_enabled/disabled once telemetry is consented', () => {
|
||
recordConsent('telemetry', true, 'test');
|
||
expect(recordFunnelEvent('training_share_enabled', 'statusline', '3.25.6')).toBe(true);
|
||
expect(recordFunnelEvent('training_share_disabled', 'statusline', '3.25.6')).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('advisor-tips consent domain (ADR-316)', () => {
|
||
it('is unconsented by default, independent of every other domain', () => {
|
||
recordConsent('sponsored-downtime', true, 'proxy-sponsor-enable');
|
||
recordConsent('power-saver', true, 'proxy-power-saver-enable');
|
||
recordConsent('training-data-sharing', true, 'proxy-training-share-enable');
|
||
expect(hasConsent('advisor-tips')).toBe(false);
|
||
});
|
||
|
||
it('granting advisor-tips does not implicitly grant any other domain', () => {
|
||
recordConsent('advisor-tips', true, 'advisor-enable');
|
||
expect(hasConsent('sponsored-downtime')).toBe(false);
|
||
expect(hasConsent('power-saver')).toBe(false);
|
||
expect(hasConsent('training-data-sharing')).toBe(false);
|
||
});
|
||
|
||
it('records grant and decline as explicit decisions', () => {
|
||
recordConsent('advisor-tips', true, 'advisor-enable');
|
||
expect(hasConsent('advisor-tips')).toBe(true);
|
||
recordConsent('advisor-tips', false, 'advisor-disable');
|
||
expect(hasConsent('advisor-tips')).toBe(false);
|
||
expect(getConsent('advisor-tips').at).not.toBeNull();
|
||
});
|
||
|
||
it('the funnel event schema accepts advisor_tip_enabled/disabled once telemetry is consented', () => {
|
||
recordConsent('telemetry', true, 'test');
|
||
expect(recordFunnelEvent('advisor_tip_enabled', 'statusline', '3.25.6')).toBe(true);
|
||
expect(recordFunnelEvent('advisor_tip_disabled', 'statusline', '3.25.6')).toBe(true);
|
||
});
|
||
});
|
||
|
||
// ─── ADR-303: credit-error classifier ───────────────────────────────────────
|
||
|
||
describe('credit-error classifier (ADR-303, fail-closed)', () => {
|
||
it('only COGNITUM_CREDIT_EXHAUSTED triggers the recovery surface', () => {
|
||
const session = { creditPromptShown: false };
|
||
const fire = classifyCreditError({ providerCode: 'cognitum_credit_exhausted' });
|
||
expect(fire.code).toBe(CreditErrorCode.COGNITUM_CREDIT_EXHAUSTED);
|
||
expect(shouldShowCreditRecovery(fire, session)).toBe(true);
|
||
|
||
for (const code of ['insufficient_quota', 'rate_limit_exceeded', 'authentication_error', 'api_error']) {
|
||
const e = classifyCreditError({ providerCode: code });
|
||
expect(shouldShowCreditRecovery(e, session), `${code} must not fire`).toBe(false);
|
||
}
|
||
});
|
||
|
||
it('provider quota exhaustion maps to PROVIDER_QUOTA_EXHAUSTED, never Cognitum', () => {
|
||
const e = classifyCreditError({ providerCode: 'insufficient_quota' });
|
||
expect(e.code).toBe(CreditErrorCode.PROVIDER_QUOTA_EXHAUSTED);
|
||
});
|
||
|
||
it('unmapped codes stay unclassified with confidence 0', () => {
|
||
const e = classifyCreditError({ providerCode: 'weird_new_error' });
|
||
expect(e.code).toBeNull();
|
||
expect(e.confidence).toBe(0);
|
||
expect(shouldShowCreditRecovery(e, { creditPromptShown: false })).toBe(false);
|
||
});
|
||
|
||
it('a bare 429 with no code is ambiguous → unmapped', () => {
|
||
const e = classifyCreditError({ status: 429 });
|
||
expect(e.code).toBeNull();
|
||
expect(e.confidence).toBe(0);
|
||
});
|
||
|
||
it('never parses message text (only codes and status)', () => {
|
||
const e = classifyCreditError({
|
||
providerCode: undefined,
|
||
// message text saying "credits exhausted" is NOT a signal
|
||
} as never);
|
||
expect(e.code).toBeNull();
|
||
});
|
||
|
||
it('caps at one prompt per session', () => {
|
||
const fire = classifyCreditError({ providerCode: 'cognitum_credit_exhausted' });
|
||
expect(shouldShowCreditRecovery(fire, { creditPromptShown: true })).toBe(false);
|
||
});
|
||
|
||
it('recovery screen distinguishes signed-in vs signed-out', () => {
|
||
expect(renderCreditRecovery(false)).toContain('ruflo auth login');
|
||
expect(renderCreditRecovery(true)).toContain('ruflo proxy enable');
|
||
});
|
||
});
|
||
|
||
// ─── ADR-305/309: events, consent-gated, bucketed ───────────────────────────
|
||
|
||
describe('funnel events (ADR-305/309)', () => {
|
||
it('records nothing without telemetry consent', () => {
|
||
expect(recordFunnelEvent('disclosure_shown', 'statusline', '3.25.6')).toBe(false);
|
||
expect(fs.existsSync(path.join(stateDir, 'funnel-events.jsonl'))).toBe(false);
|
||
expect(getFunnelId()).toBeNull();
|
||
});
|
||
|
||
it('with consent: daily buckets only, closed event set, pseudonymous id', () => {
|
||
recordConsent('telemetry', true, 'test');
|
||
expect(recordFunnelEvent('signup_opened', 'init', '3.25.6')).toBe(true);
|
||
const lines = fs.readFileSync(path.join(stateDir, 'funnel-events.jsonl'), 'utf-8').trim().split('\n');
|
||
const evt = JSON.parse(lines[0]);
|
||
expect(evt.timestampBucket).toMatch(/^\d{4}-\d{2}-\d{2}$/); // daily, no time
|
||
expect(evt.pseudonymousId).toMatch(/^[0-9a-f-]{36}$/);
|
||
expect(Object.keys(evt).sort()).toEqual(
|
||
['event', 'pseudonymousId', 'release', 'schemaVersion', 'surface', 'timestampBucket'],
|
||
);
|
||
// unknown event names are rejected
|
||
expect(recordFunnelEvent('exfiltrate_prompts' as never, 'init', 'x')).toBe(false);
|
||
});
|
||
|
||
it('opt-out deletes the id and the queue', () => {
|
||
recordConsent('telemetry', true, 'test');
|
||
recordFunnelEvent('signup_opened', 'init', '3.25.6');
|
||
const id = getFunnelId();
|
||
expect(id).not.toBeNull();
|
||
deleteFunnelData();
|
||
expect(fs.existsSync(path.join(stateDir, 'funnel-events.jsonl'))).toBe(false);
|
||
expect(fs.existsSync(path.join(stateDir, 'funnel-id.json'))).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ─── ADR-302: enrollment gates ──────────────────────────────────────────────
|
||
|
||
describe('enrollment gates (ADR-302)', () => {
|
||
it('never offers in CI, with --no-signup, or when funnel is disabled', () => {
|
||
expect(shouldOfferEnrollment({ noSignup: true, cwd: stateDir })).toBe(false);
|
||
expect(shouldOfferEnrollment({ noSignup: false, cwd: stateDir, env: { ...process.env, CI: '1' } })).toBe(false);
|
||
expect(
|
||
shouldOfferEnrollment({ noSignup: false, cwd: stateDir, env: { ...process.env, RUFLO_FUNNEL: '0' } }),
|
||
).toBe(false);
|
||
});
|
||
|
||
it('is one-time: any recorded outcome suppresses future offers', () => {
|
||
recordEnrollmentOutcome(false);
|
||
expect(getEnrollmentRecord()?.outcome).toBe('skipped');
|
||
expect(shouldOfferEnrollment({ noSignup: false, cwd: stateDir })).toBe(false);
|
||
});
|
||
|
||
it('accepting records ONLY the account consent domain', () => {
|
||
recordEnrollmentOutcome(true);
|
||
expect(hasConsent('account')).toBe(true);
|
||
expect(hasConsent('telemetry')).toBe(false);
|
||
expect(hasConsent('cloud-routing')).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ─── environment gates ──────────────────────────────────────────────────────
|
||
|
||
describe('CI detection', () => {
|
||
it('recognizes the common CI environments', () => {
|
||
for (const v of ['CI', 'GITHUB_ACTIONS', 'GITLAB_CI', 'JENKINS_URL', 'TF_BUILD']) {
|
||
expect(isCI({ [v]: 'true' } as NodeJS.ProcessEnv), v).toBe(true);
|
||
}
|
||
expect(isCI({} as NodeJS.ProcessEnv)).toBe(false);
|
||
expect(isCI({ CI: 'false' } as NodeJS.ProcessEnv)).toBe(false);
|
||
});
|
||
});
|
||
|
||
// ─── generated statusline renderer (defense-in-depth) ──────────────────────
|
||
|
||
describe('generated statusline promo row', () => {
|
||
const script = generateStatuslineScript({
|
||
runtime: { maxAgents: 8 },
|
||
statusline: { enabled: true },
|
||
} as never);
|
||
|
||
it('embeds the promo renderer with CI and RUFLO_FUNNEL gates', () => {
|
||
expect(script).toContain('getPromoRow');
|
||
expect(script).toContain('process.env.CI');
|
||
expect(script).toContain('RUFLO_FUNNEL');
|
||
});
|
||
|
||
it('re-sanitizes promo text at render time (control chars stripped, capped)', () => {
|
||
expect(script).toContain('\\u0000-\\u001f');
|
||
// v3.29.0 replaced the silent `.slice(0, 100)` truncation with a
|
||
// MAX_LEN + ellipsis path that shows the row was truncated instead
|
||
// of chopping a word mid-character. Assert both the cap constant AND
|
||
// the ellipsis suffix are present.
|
||
expect(script).toContain('MAX_LEN');
|
||
expect(script).toMatch(/MAX_LEN\s*=\s*100/);
|
||
expect(script).toContain('…');
|
||
});
|
||
|
||
it('never renders promo styling from payload — colors come from a fixed kind map', () => {
|
||
// The row is styled by the renderer's own hardcoded color, chosen from the
|
||
// CLI-supplied `kind` enum (disclosure/promotional/educational). The payload
|
||
// text itself never provides ANSI — the sanitiser above strips all of it.
|
||
// The wrapping ALWAYS ends with c.reset so no color leaks into subsequent
|
||
// Claude Code UI. Guards against a future edit that lets payload styling in.
|
||
expect(script).toMatch(/promoColor \+ promoRow \+ c\.reset/);
|
||
expect(script).toMatch(/kind === 'promotional' \? c\.brightPurple/);
|
||
expect(script).toMatch(/kind === 'educational' \? c\.yellow/);
|
||
// Default branch stays a renderer-owned color, not a payload field.
|
||
expect(script).toMatch(/: c\.brightCyan/);
|
||
});
|
||
|
||
it('validates a resolved CLI bin candidate actually has a compiled dist, not just a bin/cli.js on disk', () => {
|
||
// Claude Code's own plugin marketplace mechanism installs by git clone/pull
|
||
// with no build step, so ~/.claude/plugins/marketplaces/ruflo is a
|
||
// source-only checkout by construction: bin/cli.js exists but importing
|
||
// dist/src/index.js throws MODULE_NOT_FOUND on every real command
|
||
// (confirmed live). Without checking for the compiled entrypoint too,
|
||
// resolveCliBinCandidates() picked that doomed candidate every render and
|
||
// wasted the render's time budget failing before ever reaching the npx
|
||
// fallback — starving both the promo row and its 20s rotation clock.
|
||
expect(script).toContain("path.join(path.dirname(p), '..', 'dist', 'src', 'index.js')");
|
||
});
|
||
});
|
||
|
||
// ─── ADR-301/305 attribution — network-free fallback discipline ─────────────
|
||
// The funnel row must render correctly even when the API is completely down.
|
||
// These tests pin that invariant.
|
||
|
||
describe('attributionUrl (ADR-305 measurement, no runtime network)', () => {
|
||
it('returns the base URL verbatim when it is malformed', () => {
|
||
// The URL builder must never synthesize a broken analytics endpoint —
|
||
// a malformed input passes through unchanged so downstream (OSC 8 host
|
||
// allowlist) can drop it safely.
|
||
const cases = ['not-a-url', '', 'javascript:evil()', 'ftp://cognitum.one'];
|
||
for (const bad of cases) {
|
||
expect(attributionUrl(bad, { medium: 's', campaign: 'c', content: 'x' })).toBe(bad);
|
||
}
|
||
});
|
||
|
||
it('appends UTM params and preserves any query already on the base URL', () => {
|
||
const out = attributionUrl('https://cognitum.one/ruflo?foo=1', {
|
||
medium: 'statusline', campaign: 'disclosure', content: 'test-1',
|
||
});
|
||
const parsed = new URL(out);
|
||
expect(parsed.searchParams.get('foo')).toBe('1');
|
||
expect(parsed.searchParams.get('utm_source')).toBe('ruflo');
|
||
expect(parsed.searchParams.get('utm_medium')).toBe('statusline');
|
||
expect(parsed.searchParams.get('utm_campaign')).toBe('disclosure');
|
||
expect(parsed.searchParams.get('utm_content')).toBe('test-1');
|
||
});
|
||
|
||
it('does NOT append fid when telemetry consent is absent (privacy default)', () => {
|
||
// Default test state has no consent grants. fid must not appear.
|
||
const out = attributionUrl('https://cognitum.one/ruflo', {
|
||
medium: 'statusline', campaign: 'disclosure', content: 'x',
|
||
});
|
||
expect(new URL(out).searchParams.has('fid')).toBe(false);
|
||
});
|
||
|
||
it('emits no network call — attribution is a pure link builder', async () => {
|
||
// This used to assert `elapsed < 100ms` for 1000 builds and treat that as
|
||
// proof of "no network call". Two problems: it never actually observed the
|
||
// network (its own comment conceded a future fetch() would still pass), and
|
||
// wall-clock is not a property of the code under test — under full-suite
|
||
// CPU contention the loop measured 118ms and failed, intermittently.
|
||
// Assert the real property instead, by watching the network primitives.
|
||
// Take the CJS copies: an ESM namespace object's properties are
|
||
// non-writable ("Cannot redefine property: request"), while module.exports
|
||
// on the CJS twin can be swapped and restored. Both surface the same
|
||
// underlying implementation, so patching here observes any real call.
|
||
const { createRequire } = await import('node:module');
|
||
const requireCjs = createRequire(import.meta.url);
|
||
const http = requireCjs('node:http') as { request: unknown };
|
||
const https = requireCjs('node:https') as { request: unknown };
|
||
const originalFetch = globalThis.fetch;
|
||
const fetchCalls: unknown[] = [];
|
||
const httpReq = http.request;
|
||
const httpsReq = https.request;
|
||
const httpCalls: unknown[] = [];
|
||
const httpsCalls: unknown[] = [];
|
||
globalThis.fetch = ((...a: unknown[]) => { fetchCalls.push(a); throw new Error('unexpected fetch'); }) as unknown as typeof fetch;
|
||
(http as { request: unknown }).request = ((...a: unknown[]) => { httpCalls.push(a); throw new Error('unexpected http.request'); }) as unknown;
|
||
(https as { request: unknown }).request = ((...a: unknown[]) => { httpsCalls.push(a); throw new Error('unexpected https.request'); }) as unknown;
|
||
try {
|
||
for (let i = 0; i < 1000; i++) {
|
||
attributionUrl('https://cognitum.one/ruflo', {
|
||
medium: 'statusline', campaign: 'disclosure', content: String(i),
|
||
});
|
||
}
|
||
} finally {
|
||
globalThis.fetch = originalFetch;
|
||
(http as { request: unknown }).request = httpReq;
|
||
(https as { request: unknown }).request = httpsReq;
|
||
}
|
||
expect(fetchCalls).toEqual([]);
|
||
expect(httpCalls).toEqual([]);
|
||
expect(httpsCalls).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('getFunnelPromo — API-down fallback discipline', () => {
|
||
it('generated statusline styles the label as a link and the command as bold-not-underlined', () => {
|
||
// CTA affordance: the OSC 8 label is wrapped in ANSI underline so terminals
|
||
// show it as a clickable link even when the OSC 8 hyperlink itself isn't
|
||
// supported.
|
||
//
|
||
// "manage: ruflo settings" is a shell command, not a URL -- a terminal can
|
||
// never safely execute a command from a click (that would let any
|
||
// server-served message run arbitrary commands), so it must NEVER be
|
||
// underlined or OSC-8-wrapped. Instead "ruflo settings" renders bold so it
|
||
// visually reads as "the important bit to copy/type", not a dead link.
|
||
const script = generateStatuslineScript({
|
||
statusline: { enabled: true, style: 'compact' as const },
|
||
runtime: { maxAgents: 15 },
|
||
});
|
||
// v3.29.0: label gets underline styling (still). But the whole row
|
||
// is now wrapped in ONE OSC 8 hyperlink via wrapWholeRowInHyperlink,
|
||
// not just the label — every part of the row is a click target.
|
||
// Label still carries UL_ON/UL_OFF for the underline cue.
|
||
expect(script).toMatch(/UL_ON \+ label \+ UL_OFF/);
|
||
expect(script).toContain('wrapWholeRowInHyperlink');
|
||
// "manage: " connector stays dim.
|
||
expect(script).toMatch(/DIM_ON \+ manageWord \+ DIM_OFF/);
|
||
// The command itself is bold + bright-white, never underlined.
|
||
// v3.29.0 added FG_BRIGHT_WHITE so it visually stands out from the
|
||
// row's kind-color instead of getting lost in it.
|
||
expect(script).toMatch(/BOLD_ON \+ FG_BRIGHT_WHITE \+ command \+ FG_DEFAULT \+ BOLD_OFF/);
|
||
expect(script).not.toMatch(/UL_ON \+ command/);
|
||
// The split must be on the exact manage-instruction anchor.
|
||
expect(script).toMatch(/text\.indexOf\(' · manage: '\)/);
|
||
});
|
||
|
||
it('generated statusline emits exactly 3 lines: header, ops, promo', () => {
|
||
// Claude Code truncates statusline past line 4 with the system guidance
|
||
// line. The 3-line design puts RuFlo header on line 1, then ops, then
|
||
// promo — sequence matches order of pushes in the generator source.
|
||
const script = generateStatuslineScript({
|
||
statusline: { enabled: true, style: 'compact' as const },
|
||
runtime: { maxAgents: 15 },
|
||
});
|
||
const headerIdx = script.indexOf('lines.push(header)');
|
||
const opsIdx = script.indexOf("lines.push(opsParts.join(");
|
||
const promoIdx = script.indexOf('lines.push(promoColor + promoRow');
|
||
expect(headerIdx).toBeGreaterThan(0);
|
||
expect(opsIdx).toBeGreaterThan(headerIdx);
|
||
expect(promoIdx).toBeGreaterThan(opsIdx);
|
||
});
|
||
|
||
it('generated statusline memoizes promo across renders (survives promoless CLI)', () => {
|
||
// A previously-installed older CLI cached by npx may succeed but omit
|
||
// the promo field. The memo overlay patches it back in so the row
|
||
// doesn't blink out mid-session.
|
||
const script = generateStatuslineScript({
|
||
statusline: { enabled: true, style: 'compact' as const },
|
||
runtime: { maxAgents: 15 },
|
||
});
|
||
expect(script).toMatch(/PROMO_MEMO_FILE/);
|
||
expect(script).toMatch(/function readPromoMemo/);
|
||
expect(script).toMatch(/function overlayMemoPromo/);
|
||
// Overlay must fire on every path (fresh cache, successful CLI, stale
|
||
// cache fallback, cold fallback) — grep the call count as a spot check.
|
||
const overlayCalls = (script.match(/overlayMemoPromo\(/g) || []).length;
|
||
expect(overlayCalls).toBeGreaterThanOrEqual(4);
|
||
});
|
||
|
||
it('generated statusline script implements stale-while-revalidate for promo row', () => {
|
||
// The fix for the flicker bug: the promo row must survive CLI hiccups
|
||
// and cache-expiry-mid-render. readCache() returns { fresh, data } and
|
||
// getStatuslineData falls back to stale cache when the CLI fails, so
|
||
// the last known promo persists. This test pins that design in the
|
||
// generator template so a future edit that breaks the pattern trips CI.
|
||
const script = generateStatuslineScript({
|
||
statusline: { enabled: true, style: 'compact' as const },
|
||
runtime: { maxAgents: 15 },
|
||
});
|
||
// Cache reader must expose freshness rather than gating data behind TTL.
|
||
expect(script).toMatch(/const cache = readCache\(\)/);
|
||
expect(script).toMatch(/cache\.fresh/);
|
||
// On CLI failure, must serve stale cache data (with local overlays) not
|
||
// a bare buildLocalFallback() that would drop the promo field.
|
||
expect(script).toMatch(/if \(cache\.data\)/);
|
||
expect(script).toMatch(/applyLocalOverlays\(cache\.data\)/);
|
||
});
|
||
|
||
it('the promo row has its own rotation-cadence freshness check, distinct from the general 60s cache TTL', () => {
|
||
// Bug report: "the promo area doesn't seem to be rotating every 20
|
||
// seconds." Root cause: CACHE_TTL_MS (60s, #2337's fix for excessive
|
||
// CLI re-invocation) let getStatuslineData() skip the CLI call — the
|
||
// ONLY place the rotation slot is recomputed — for up to 3 whole 20s
|
||
// rotation slots. A general cache.fresh check alone can never catch
|
||
// this; it must ALSO check a tighter, rotation-cadence-bound freshness
|
||
// signal before it's allowed to skip the CLI call.
|
||
const script = generateStatuslineScript({
|
||
statusline: { enabled: true, style: 'compact' as const },
|
||
runtime: { maxAgents: 15 },
|
||
});
|
||
expect(script).toMatch(/PROMO_ROTATION_SLOT_MS\s*=\s*20000/);
|
||
expect(script).toMatch(/promoFresh/);
|
||
// The early-return that skips the CLI call must require BOTH freshness
|
||
// signals — cache.fresh alone (the pre-fix bug) must not appear as the
|
||
// sole guard on that line.
|
||
expect(script).toMatch(/if \(cache\.fresh && cache\.promoFresh\)/);
|
||
});
|
||
|
||
it('renders the row without touching the network (no fetch import path)', async () => {
|
||
// The promo module is imported at test module load; if it pulled in a
|
||
// network library, this stringified module set would carry a fetch/https
|
||
// reference. This is a design lock — a future edit that adds network
|
||
// I/O to the render path breaks this test.
|
||
const promoSrc = await import('node:fs').then((fs) =>
|
||
fs.readFileSync(new URL('../src/funnel/promo.ts', import.meta.url), 'utf-8'),
|
||
);
|
||
expect(promoSrc).not.toMatch(/require\(\s*['"]https?['"]\s*\)/);
|
||
expect(promoSrc).not.toMatch(/from\s+['"]https?['"]/);
|
||
expect(promoSrc).not.toMatch(/fetch\s*\(/);
|
||
expect(promoSrc).not.toMatch(/XMLHttpRequest/);
|
||
});
|
||
});
|
||
|
||
// ─── ADR-308 client transport — consent-gated + failure-safe ────────────────
|
||
|
||
describe('event transport (ADR-308 POST /v1/events)', () => {
|
||
it('exposes ADR-308 defaults: https endpoint, batch cap, backoff, timeout', () => {
|
||
expect(DEFAULT_ENDPOINT.startsWith('https://')).toBe(true);
|
||
expect(MAX_BATCH).toBeGreaterThan(0);
|
||
expect(MAX_BATCH).toBeLessThanOrEqual(1000);
|
||
expect(MIN_FLUSH_INTERVAL_MS).toBeGreaterThanOrEqual(10_000);
|
||
expect(FLUSH_TIMEOUT_MS).toBeGreaterThan(0);
|
||
expect(FLUSH_TIMEOUT_MS).toBeLessThanOrEqual(10_000); // never stall the CLI
|
||
});
|
||
|
||
it('no-ops without telemetry consent — zero network activity', async () => {
|
||
// No consent granted in the base test state.
|
||
const result = await flushEvents({ endpoint: 'https://127.0.0.1:1', now: new Date() });
|
||
expect(result).toEqual({ flushed: 0, skipped: 'no-consent' });
|
||
});
|
||
|
||
it('rejects non-https endpoints inside postBatch (via consent-gated caller)', async () => {
|
||
// Grant consent so the transport reaches postBatch, then pass an
|
||
// http:// endpoint — the module must refuse rather than open a plaintext
|
||
// connection.
|
||
recordConsent('telemetry', true, 'test');
|
||
// Also stage at least one event so we don't short-circuit on empty queue.
|
||
recordFunnelEvent('disclosure_shown', 'statusline', 'test');
|
||
const result = await flushEvents({ endpoint: 'http://127.0.0.1:1', force: true, now: new Date() });
|
||
expect(result.skipped).toMatch(/transport-failed|no-consent/);
|
||
// On the failed-transport path we expect a status of 0 (never opened).
|
||
if (result.skipped === 'transport-failed') expect(result.status).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('credit-notifier (ADR-303 out-of-band signal)', () => {
|
||
it('markCreditExhausted is idempotent — stable `since`', () => {
|
||
const t0 = new Date('2026-07-10T12:00:00.000Z');
|
||
markCreditExhausted(t0);
|
||
const first = readCreditStatus();
|
||
expect(first.exhausted).toBe(true);
|
||
expect(first.since).toBe(t0.toISOString());
|
||
// Second mark must not move the `since` timestamp forward.
|
||
markCreditExhausted(new Date('2026-07-10T13:00:00.000Z'));
|
||
const second = readCreditStatus();
|
||
expect(second.since).toBe(t0.toISOString());
|
||
});
|
||
|
||
it('clearCreditStatus stamps `cleared`, drops the exhausted flag', () => {
|
||
markCreditExhausted(new Date('2026-07-10T12:00:00.000Z'));
|
||
clearCreditStatus(new Date('2026-07-10T14:00:00.000Z'));
|
||
const status = readCreditStatus();
|
||
expect(status.exhausted).toBe(false);
|
||
expect(status.cleared).toBe('2026-07-10T14:00:00.000Z');
|
||
});
|
||
|
||
it('creditExhaustedNotice renders humanized "since" copy', () => {
|
||
markCreditExhausted(new Date('2026-07-10T12:00:00.000Z'));
|
||
// 3 hours later
|
||
const notice = creditExhaustedNotice(new Date('2026-07-10T15:00:00.000Z'));
|
||
expect(notice).not.toBeNull();
|
||
expect(notice).toContain('Cognitum credits exhausted');
|
||
expect(notice).toContain('ruflo funnel signup');
|
||
expect(notice).toContain('3h ago');
|
||
});
|
||
|
||
it('returns null when credit is not exhausted (no surface)', () => {
|
||
clearCreditStatus();
|
||
expect(creditExhaustedNotice()).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ─── ADR-312/313: rate-limit notifier + sponsored downtime override ────────
|
||
|
||
describe('rate-limit notifier (ADR-312 Phase 0 — manual, self-reported)', () => {
|
||
it('starts not-limited', () => {
|
||
expect(readRateLimitStatus().limited).toBe(false);
|
||
expect(rateLimitNotice()).toBeNull();
|
||
});
|
||
|
||
it('markRateLimited is idempotent — stable `since`', () => {
|
||
const t0 = new Date('2026-07-10T12:00:00.000Z');
|
||
markRateLimited(t0);
|
||
const first = readRateLimitStatus(t0);
|
||
expect(first.limited).toBe(true);
|
||
expect(first.since).toBe(t0.toISOString());
|
||
// Marking again later must not move `since`.
|
||
markRateLimited(new Date(t0.getTime() + 60_000));
|
||
const second = readRateLimitStatus(new Date(t0.getTime() + 60_000));
|
||
expect(second.since).toBe(t0.toISOString());
|
||
});
|
||
|
||
it('clearRateLimitStatus stamps `cleared` and flips `limited` false', () => {
|
||
const t0 = new Date('2026-07-10T12:00:00.000Z');
|
||
markRateLimited(t0);
|
||
// Past the ADR-314 §D1 toggle cooldown (10 min) — a clear inside that
|
||
// window is deliberately refused (covered separately below).
|
||
const t1 = new Date(t0.getTime() + 11 * 60 * 1000);
|
||
clearRateLimitStatus(t1);
|
||
const status = readRateLimitStatus(t1);
|
||
expect(status.limited).toBe(false);
|
||
expect(status.cleared).not.toBeNull();
|
||
});
|
||
|
||
it('auto-expires the flag after the TTL (a stale manual mark self-heals)', () => {
|
||
const t0 = new Date('2026-07-10T12:00:00.000Z');
|
||
markRateLimited(t0);
|
||
const justBefore = readRateLimitStatus(new Date(t0.getTime() + RATE_LIMIT_TTL_MS - 1000));
|
||
expect(justBefore.limited).toBe(true);
|
||
const justAfter = readRateLimitStatus(new Date(t0.getTime() + RATE_LIMIT_TTL_MS + 1000));
|
||
expect(justAfter.limited).toBe(false);
|
||
});
|
||
|
||
it('rateLimitNotice humanizes age and points at the sponsor command', () => {
|
||
const t0 = new Date('2026-07-10T12:00:00.000Z');
|
||
markRateLimited(t0);
|
||
const notice = rateLimitNotice(new Date(t0.getTime() + 5 * 60 * 1000));
|
||
expect(notice).toContain('5m ago');
|
||
expect(notice).toContain('ruflo proxy sponsor-enable');
|
||
});
|
||
});
|
||
|
||
describe('toggle cooldown (ADR-314 §D1 — anti-abuse friction)', () => {
|
||
it('is inactive with no prior toggle', () => {
|
||
expect(cooldownActive(null, new Date())).toBe(false);
|
||
});
|
||
|
||
it('is active just before the cooldown window elapses', () => {
|
||
const t0 = new Date('2026-07-10T12:00:00.000Z');
|
||
const justBefore = new Date(t0.getTime() + TOGGLE_COOLDOWN_MS - 1000);
|
||
expect(cooldownActive(t0.toISOString(), justBefore)).toBe(true);
|
||
});
|
||
|
||
it('clears just after the cooldown window elapses', () => {
|
||
const t0 = new Date('2026-07-10T12:00:00.000Z');
|
||
const justAfter = new Date(t0.getTime() + TOGGLE_COOLDOWN_MS + 1000);
|
||
expect(cooldownActive(t0.toISOString(), justAfter)).toBe(false);
|
||
});
|
||
|
||
it('reports remaining minutes, floored to zero once elapsed', () => {
|
||
const t0 = new Date('2026-07-10T12:00:00.000Z');
|
||
const fiveMinIn = new Date(t0.getTime() + 5 * 60 * 1000);
|
||
expect(cooldownRemainingMin(t0.toISOString(), fiveMinIn)).toBe(5);
|
||
expect(cooldownRemainingMin(t0.toISOString(), new Date(t0.getTime() + TOGGLE_COOLDOWN_MS + 1000))).toBe(0);
|
||
});
|
||
|
||
it('rate-limit mark→clear inside the cooldown window is refused', () => {
|
||
const t0 = new Date('2026-07-10T12:30:00.000Z');
|
||
expect(markRateLimited(t0)).toBe(true);
|
||
const stillCoolingDown = new Date(t0.getTime() + 1000);
|
||
expect(clearRateLimitStatus(stillCoolingDown)).toBe(false);
|
||
// The refusal must not have silently applied — still limited.
|
||
expect(readRateLimitStatus(stillCoolingDown).limited).toBe(true);
|
||
});
|
||
|
||
it('rate-limit mark→clear after the cooldown window succeeds', () => {
|
||
const t0 = new Date('2026-07-10T12:31:00.000Z');
|
||
expect(markRateLimited(t0)).toBe(true);
|
||
const afterCooldown = new Date(t0.getTime() + TOGGLE_COOLDOWN_MS + 1000);
|
||
expect(clearRateLimitStatus(afterCooldown)).toBe(true);
|
||
expect(readRateLimitStatus(afterCooldown).limited).toBe(false);
|
||
});
|
||
|
||
it('re-marking an already-limited flag is not a state change — cooldown does not apply', () => {
|
||
const t0 = new Date('2026-07-10T12:32:00.000Z');
|
||
expect(markRateLimited(t0)).toBe(true);
|
||
// Immediately re-marking (still limited) must succeed — it's a no-op idempotent call.
|
||
expect(markRateLimited(new Date(t0.getTime() + 1000))).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('power-saver notifier (ADR-314 §A — manual, self-reported, mirrors rate-limit)', () => {
|
||
it('starts not-low', () => {
|
||
expect(readQuotaLowStatus().low).toBe(false);
|
||
expect(quotaLowNotice()).toBeNull();
|
||
});
|
||
|
||
it('markQuotaLow is idempotent — stable `since`', () => {
|
||
const t0 = new Date('2026-07-10T13:00:00.000Z');
|
||
markQuotaLow(t0);
|
||
const first = readQuotaLowStatus(t0);
|
||
expect(first.low).toBe(true);
|
||
expect(first.since).toBe(t0.toISOString());
|
||
markQuotaLow(new Date(t0.getTime() + 60_000));
|
||
const second = readQuotaLowStatus(new Date(t0.getTime() + 60_000));
|
||
expect(second.since).toBe(t0.toISOString());
|
||
});
|
||
|
||
it('clearQuotaLowStatus stamps `cleared` and flips `low` false, past the cooldown', () => {
|
||
const t0 = new Date('2026-07-10T13:01:00.000Z');
|
||
markQuotaLow(t0);
|
||
const t1 = new Date(t0.getTime() + TOGGLE_COOLDOWN_MS + 1000);
|
||
expect(clearQuotaLowStatus(t1)).toBe(true);
|
||
const status = readQuotaLowStatus(t1);
|
||
expect(status.low).toBe(false);
|
||
expect(status.cleared).not.toBeNull();
|
||
});
|
||
|
||
it('auto-expires the flag after the TTL', () => {
|
||
const t0 = new Date('2026-07-10T13:02:00.000Z');
|
||
markQuotaLow(t0);
|
||
const justBefore = readQuotaLowStatus(new Date(t0.getTime() + QUOTA_LOW_TTL_MS - 1000));
|
||
expect(justBefore.low).toBe(true);
|
||
const justAfter = readQuotaLowStatus(new Date(t0.getTime() + QUOTA_LOW_TTL_MS + 1000));
|
||
expect(justAfter.low).toBe(false);
|
||
});
|
||
|
||
it('quotaLowNotice humanizes age and points at power-saver-disable', () => {
|
||
const t0 = new Date('2026-07-10T13:03:00.000Z');
|
||
markQuotaLow(t0);
|
||
const notice = quotaLowNotice(new Date(t0.getTime() + 5 * 60 * 1000));
|
||
expect(notice).toContain('5m ago');
|
||
expect(notice).toContain('ruflo proxy power-saver-disable');
|
||
});
|
||
|
||
it('mark→clear inside the cooldown window is refused, same as rate-limit', () => {
|
||
const t0 = new Date('2026-07-10T13:04:00.000Z');
|
||
expect(markQuotaLow(t0)).toBe(true);
|
||
expect(clearQuotaLowStatus(new Date(t0.getTime() + 1000))).toBe(false);
|
||
expect(readQuotaLowStatus(new Date(t0.getTime() + 1000)).low).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('sponsored-downtime priority override (ADR-313)', () => {
|
||
it('does not override when the rate-limit flag is unset', () => {
|
||
seedRemoteMessages([...TEST_DISCLOSURE_POOL, ...TEST_ROTATION_POOL]);
|
||
const t0 = new Date();
|
||
recordDisclosureShown(t0);
|
||
const after = new Date(t0.getTime() + DISCLOSURE_GRACE_MS + 60_000);
|
||
const row = getFunnelPromo({ interactive: true, cwd: stateDir, now: after });
|
||
expect(row).not.toBeNull();
|
||
expect(row!.text).not.toContain('ruflo proxy sponsor');
|
||
});
|
||
|
||
it('shows the enable-CTA when rate-limited without sponsored consent', () => {
|
||
seedRemoteMessages([...TEST_DISCLOSURE_POOL, ...TEST_ROTATION_POOL]);
|
||
const t0 = new Date();
|
||
recordDisclosureShown(t0);
|
||
const after = new Date(t0.getTime() + DISCLOSURE_GRACE_MS + 60_000);
|
||
markRateLimited(after);
|
||
const row = getFunnelPromo({ interactive: true, cwd: stateDir, now: after });
|
||
expect(row).not.toBeNull();
|
||
expect(row!.text).toContain('Free Cognitum capacity');
|
||
expect(row!.text).toContain('manage: ruflo proxy sponsor-enable');
|
||
expect(displayWidth(row!.text)).toBeLessThanOrEqual(MAX_MESSAGE_COLUMNS);
|
||
});
|
||
|
||
it('shows the active-status line when rate-limited WITH sponsored consent granted', () => {
|
||
seedRemoteMessages([...TEST_DISCLOSURE_POOL, ...TEST_ROTATION_POOL]);
|
||
const t0 = new Date();
|
||
recordDisclosureShown(t0);
|
||
const after = new Date(t0.getTime() + DISCLOSURE_GRACE_MS + 60_000);
|
||
markRateLimited(after);
|
||
recordConsent('sponsored-downtime', true, 'test');
|
||
const row = getFunnelPromo({ interactive: true, cwd: stateDir, now: after });
|
||
expect(row).not.toBeNull();
|
||
expect(row!.text).toContain('Running on sponsored Cognitum capacity');
|
||
expect(row!.text).toContain('manage: ruflo proxy sponsor-disable');
|
||
expect(displayWidth(row!.text)).toBeLessThanOrEqual(MAX_MESSAGE_COLUMNS);
|
||
});
|
||
|
||
it('the override preempts rotation even mid-promo-slot', () => {
|
||
// Regression guard: without the override, a promotional slot would
|
||
// otherwise render a rotation message here — confirm sponsored wins.
|
||
seedRemoteMessages([...TEST_DISCLOSURE_POOL, ...TEST_ROTATION_POOL]);
|
||
const t0 = new Date();
|
||
recordDisclosureShown(t0);
|
||
const after = new Date(t0.getTime() + DISCLOSURE_GRACE_MS + 60_000);
|
||
markRateLimited(after);
|
||
const row = getFunnelPromo({ interactive: true, cwd: stateDir, now: after });
|
||
expect(row!.kind).toBe('promotional');
|
||
expect(TEST_ROTATION_POOL.map((m) => m.text)).not.toContain(row!.text);
|
||
});
|
||
|
||
it('never overrides before the disclosure invariant is satisfied', () => {
|
||
// ADR-301: no promotional content before disclosure — even a rate-limit
|
||
// flag must not bypass the first-render disclosure gate.
|
||
seedRemoteMessages(TEST_DISCLOSURE_POOL);
|
||
markRateLimited(new Date());
|
||
const row = getFunnelPromo({ interactive: true, cwd: stateDir });
|
||
expect(row).not.toBeNull();
|
||
expect(row!.kind).toBe('disclosure');
|
||
});
|
||
});
|
||
|
||
describe('local insight ticker (computeLocalInsights / selectLocalInsight)', () => {
|
||
it('returns nothing when no context signal applies', () => {
|
||
expect(computeLocalInsights({})).toEqual([]);
|
||
expect(selectLocalInsight({})).toBeNull();
|
||
});
|
||
|
||
it('surfaces scanner findings at the highest priority without calling them CVEs', () => {
|
||
const insights = computeLocalInsights({ security: { status: 'ISSUES', findings: 2, cvesFixed: 0, totalCves: 0 } });
|
||
expect(insights).toHaveLength(1);
|
||
expect(insights[0].text).toContain('2 security findings');
|
||
expect(insights[0].text).not.toContain('CVE');
|
||
});
|
||
|
||
it('singularizes "1 security finding" correctly', () => {
|
||
const insight = selectLocalInsight({ security: { status: 'ISSUES', findings: 1, cvesFixed: 0, totalCves: 0 } });
|
||
expect(insight!.text).toContain('1 security finding');
|
||
expect(insight!.text).not.toContain('1 security findings');
|
||
});
|
||
|
||
it('falls back to "scan pending" when no scan result exists', () => {
|
||
const insight = selectLocalInsight({ security: { status: 'PENDING', findings: 0, cvesFixed: 0, totalCves: 0 } });
|
||
expect(insight!.text).toContain('Security scan pending');
|
||
});
|
||
|
||
it('is silent when security is CLEAN', () => {
|
||
expect(selectLocalInsight({ security: { status: 'CLEAN', findings: 0, cvesFixed: 0, totalCves: 0 } })).toBeNull();
|
||
});
|
||
|
||
it('surfaces uncommitted changes only above the threshold', () => {
|
||
expect(selectLocalInsight({ gitUncommittedCount: 20 })).toBeNull(); // at threshold, not over
|
||
const insight = selectLocalInsight({ gitUncommittedCount: 21 });
|
||
expect(insight!.text).toContain('21 uncommitted changes');
|
||
});
|
||
|
||
it('picks the highest-priority candidate when several apply at once', () => {
|
||
const insight = selectLocalInsight({
|
||
security: { status: 'ISSUES', findings: 1, cvesFixed: 0, totalCves: 0 }, // priority 90
|
||
gitUncommittedCount: 50, // priority 50
|
||
});
|
||
expect(insight!.id).toBe('insight-security-findings');
|
||
});
|
||
|
||
it('surfaces power-saver mode only when both consented and flagged low', () => {
|
||
expect(selectLocalInsight({})).toBeNull();
|
||
recordConsent('power-saver', true, 'test');
|
||
expect(selectLocalInsight({})).toBeNull(); // consented but not flagged low
|
||
markQuotaLow(new Date());
|
||
const insight = selectLocalInsight({});
|
||
expect(insight!.text).toContain('Power saver mode active');
|
||
});
|
||
|
||
it('reads the ADR-315 flywheel-status cache when present and fresh', () => {
|
||
fs.writeFileSync(
|
||
path.join(stateDir, 'flywheel-status.json'),
|
||
JSON.stringify({ _ts: Date.now(), headline: 'test headline' }),
|
||
'utf-8',
|
||
);
|
||
const insight = selectLocalInsight({});
|
||
expect(insight!.text).toContain('test headline');
|
||
});
|
||
|
||
it('ignores an expired flywheel-status cache', () => {
|
||
fs.writeFileSync(
|
||
path.join(stateDir, 'flywheel-status.json'),
|
||
JSON.stringify({ _ts: Date.now() - 25 * 60 * 60 * 1000, headline: 'stale headline' }),
|
||
'utf-8',
|
||
);
|
||
expect(selectLocalInsight({})).toBeNull();
|
||
});
|
||
|
||
it('surfaces the ADR-316 advisor tip only when consented, and never a stale-past-TTL cache', () => {
|
||
fs.writeFileSync(
|
||
path.join(stateDir, 'advisor-tip.json'),
|
||
JSON.stringify({ _ts: Date.now(), headline: 'commit your work' }),
|
||
'utf-8',
|
||
);
|
||
expect(selectLocalInsight({})).toBeNull(); // not consented — cache is ignored regardless
|
||
recordConsent('advisor-tips', true, 'test');
|
||
const insight = selectLocalInsight({});
|
||
expect(insight!.id).toBe('insight-advisor-tip');
|
||
expect(insight!.text).toContain('commit your work');
|
||
});
|
||
|
||
it('an expired advisor-tip cache is silent even when consented', () => {
|
||
recordConsent('advisor-tips', true, 'test');
|
||
fs.writeFileSync(
|
||
path.join(stateDir, 'advisor-tip.json'),
|
||
JSON.stringify({ _ts: Date.now() - 25 * 60 * 60 * 1000, headline: 'stale tip' }),
|
||
'utf-8',
|
||
);
|
||
expect(selectLocalInsight({})).toBeNull();
|
||
});
|
||
|
||
it('security findings still outrank the advisor tip', () => {
|
||
recordConsent('advisor-tips', true, 'test');
|
||
fs.writeFileSync(
|
||
path.join(stateDir, 'advisor-tip.json'),
|
||
JSON.stringify({ _ts: Date.now(), headline: 'a tip' }),
|
||
'utf-8',
|
||
);
|
||
const insight = selectLocalInsight({ security: { status: 'ISSUES', findings: 1, cvesFixed: 0, totalCves: 0 } });
|
||
expect(insight!.id).toBe('insight-security-findings');
|
||
});
|
||
|
||
it('the advisor tip outranks the flywheel-status placeholder', () => {
|
||
recordConsent('advisor-tips', true, 'test');
|
||
fs.writeFileSync(
|
||
path.join(stateDir, 'advisor-tip.json'),
|
||
JSON.stringify({ _ts: Date.now(), headline: 'a tip' }),
|
||
'utf-8',
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(stateDir, 'flywheel-status.json'),
|
||
JSON.stringify({ _ts: Date.now(), headline: 'flywheel news' }),
|
||
'utf-8',
|
||
);
|
||
const insight = selectLocalInsight({});
|
||
expect(insight!.id).toBe('insight-advisor-tip');
|
||
});
|
||
});
|
||
|
||
describe('local insight ticker integration with getFunnelPromo (ADR §5)', () => {
|
||
it('never shows an insight when localInsights context is omitted (backward compatible)', () => {
|
||
seedRemoteMessages([...TEST_DISCLOSURE_POOL, ...TEST_ROTATION_POOL]);
|
||
const t0 = new Date();
|
||
recordDisclosureShown(t0);
|
||
// slot 2 (of 5) — the reserved insight slot — but no context passed.
|
||
const insightSlotTime = new Date(t0.getTime() + DISCLOSURE_GRACE_MS + 60_000 + 2 * ROTATION_SLOT_MS);
|
||
const row = getFunnelPromo({ interactive: true, cwd: stateDir, now: insightSlotTime });
|
||
expect(row).not.toBeNull();
|
||
expect(row!.kind).not.toBe('insight');
|
||
});
|
||
|
||
it('shows the insight on its reserved slot when context signals one', () => {
|
||
seedRemoteMessages([...TEST_DISCLOSURE_POOL, ...TEST_ROTATION_POOL]);
|
||
const t0 = new Date(0);
|
||
recordDisclosureShown(t0);
|
||
const after = new Date(t0.getTime() + DISCLOSURE_GRACE_MS + 60_000);
|
||
// Find the next slot boundary where slot % 5 === 2 (promo.ts's reserved phase).
|
||
let probe = after;
|
||
while (Math.floor(probe.getTime() / ROTATION_SLOT_MS) % 5 !== 2) {
|
||
probe = new Date(probe.getTime() + ROTATION_SLOT_MS);
|
||
}
|
||
const row = getFunnelPromo({
|
||
interactive: true,
|
||
cwd: stateDir,
|
||
now: probe,
|
||
localInsights: { security: { status: 'ISSUES', findings: 1, cvesFixed: 0, totalCves: 0 } },
|
||
});
|
||
expect(row).not.toBeNull();
|
||
expect(row!.kind).toBe('insight');
|
||
expect(row!.text).toContain('security finding');
|
||
});
|
||
|
||
it('falls through to normal rotation on the insight slot when nothing is actionable', () => {
|
||
seedRemoteMessages([...TEST_DISCLOSURE_POOL, ...TEST_ROTATION_POOL]);
|
||
const t0 = new Date(0);
|
||
recordDisclosureShown(t0);
|
||
const after = new Date(t0.getTime() + DISCLOSURE_GRACE_MS + 60_000);
|
||
let probe = after;
|
||
while (Math.floor(probe.getTime() / ROTATION_SLOT_MS) % 5 !== 2) {
|
||
probe = new Date(probe.getTime() + ROTATION_SLOT_MS);
|
||
}
|
||
const row = getFunnelPromo({
|
||
interactive: true,
|
||
cwd: stateDir,
|
||
now: probe,
|
||
localInsights: { security: { status: 'CLEAN', findings: 0, cvesFixed: 0, totalCves: 0 } },
|
||
});
|
||
expect(row).not.toBeNull();
|
||
expect(row!.kind).not.toBe('insight'); // no actionable insight -> normal rotation
|
||
});
|
||
|
||
it('the ADR-313 sponsored override still wins even on the insight slot', () => {
|
||
seedRemoteMessages([...TEST_DISCLOSURE_POOL, ...TEST_ROTATION_POOL]);
|
||
const t0 = new Date(0);
|
||
recordDisclosureShown(t0);
|
||
const after = new Date(t0.getTime() + DISCLOSURE_GRACE_MS + 60_000);
|
||
let probe = after;
|
||
while (Math.floor(probe.getTime() / ROTATION_SLOT_MS) % 5 !== 2) {
|
||
probe = new Date(probe.getTime() + ROTATION_SLOT_MS);
|
||
}
|
||
markRateLimited(probe);
|
||
const row = getFunnelPromo({
|
||
interactive: true,
|
||
cwd: stateDir,
|
||
now: probe,
|
||
localInsights: { security: { status: 'ISSUES', findings: 1, cvesFixed: 0, totalCves: 0 } },
|
||
});
|
||
expect(row!.text).toContain('Cognitum capacity');
|
||
expect(row!.kind).not.toBe('insight');
|
||
});
|
||
});
|