diff --git a/apps/web/src/app/(marketing)/page.tsx b/apps/web/src/app/(marketing)/page.tsx index f479c45..25aaa45 100644 --- a/apps/web/src/app/(marketing)/page.tsx +++ b/apps/web/src/app/(marketing)/page.tsx @@ -1,8 +1,9 @@ import { siteConfig } from '@/config/site'; import { Section } from '@/components/ui/section'; import { HeroSection } from '@/components/marketing/hero-section'; +import { ProblemSection } from '@/components/marketing/problem-section'; +import { HowRecallWorksSection } from '@/components/marketing/how-recall-works-section'; import { CommandBlock } from '@/components/marketing/command-block'; -import { FeatureCard } from '@/components/marketing/feature-card'; const problemRows = [ { @@ -32,24 +33,6 @@ const problemRows = [ }, ]; -const steps = [ - { - title: 'Scan', - description: - 'Recall walks the repository deterministically and analyzes structure, workspaces, entry points, and conventions — no network access, no AI provider required.', - }, - { - title: 'Rank', - description: - 'For a given task, files are ranked with explainable, local signals: filename and symbol matches, import-graph proximity, and workspace locality.', - }, - { - title: 'Generate', - description: - 'Recall writes evidence-backed Markdown and JSON context into `.recall/`, ready to paste into or pipe to a coding agent.', - }, -]; - const features = [ { title: 'Deterministic, local-first', @@ -93,6 +76,10 @@ export default function MarketingPage() { <> + + + +

CLAUDE.md and AGENTS.md vs. Recall @@ -131,37 +118,23 @@ export default function MarketingPage() {

-
-

- How it works -

-
- {steps.map((step, index) => ( -
- - {String(index + 1).padStart(2, '0')} - -

{step.title}

-

- {step.description} -

-
- ))} -
-
-

What Recall generates

-
+
{features.map((feature) => ( - +
+
{feature.title}
+
+ {feature.description} +
+
))} -
+
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index dfd8c5d..5a169b6 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -80,6 +80,12 @@ outline-offset: 2px; } + /* Accent at low opacity, not a browser default (design-system.md §3). */ + ::selection { + background-color: hsl(var(--accent) / 0.25); + color: hsl(var(--foreground)); + } + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/apps/web/src/components/marketing/feature-card.tsx b/apps/web/src/components/marketing/feature-card.tsx deleted file mode 100644 index fd91627..0000000 --- a/apps/web/src/components/marketing/feature-card.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import type { ReactNode } from 'react'; - -export interface FeatureCardProps { - title: string; - description: string; - icon?: ReactNode; -} - -export function FeatureCard({ title, description, icon }: FeatureCardProps) { - return ( -
- {icon ?
{icon}
: null} -

{title}

-

{description}

-
- ); -} diff --git a/apps/web/src/components/marketing/how-recall-works-section.tsx b/apps/web/src/components/marketing/how-recall-works-section.tsx new file mode 100644 index 0000000..9ee1778 --- /dev/null +++ b/apps/web/src/components/marketing/how-recall-works-section.tsx @@ -0,0 +1,136 @@ +import { Fragment } from 'react'; +import { Section } from '@/components/ui/section'; + +interface Step { + number: string; + title: string; + description: string; +} + +const steps: Step[] = [ + { + number: '01', + title: 'Scan Repository', + description: 'Analyze project structure, frameworks, dependencies and conventions.', + }, + { + number: '02', + title: 'Build Memory', + description: 'Generate architecture, decisions, conventions, risks and technical debt.', + }, + { + number: '03', + title: 'Understand Task', + description: 'Rank the most relevant files for the current request.', + }, + { + number: '04', + title: 'Provide Context', + description: 'Produce deterministic context that any coding agent can immediately use.', + }, +]; + +// Reused verbatim from HeroTerminal and ProblemSection's "With Recall" card +// — not new content, just referenced again at the point where it's actually +// produced. +const memoryCategories = ['Architecture', 'Conventions', 'Decisions', 'Risks', 'Technical debt']; + +/** + * One equally-sized step in the flow. Real headings (h3) carry the + * accessible structure — connectors between cards are purely decorative + * (design-system.md §7 card rules: border, bg-card, no shadow, no hover, + * since these aren't clickable). + */ +function StepCard({ step }: { step: Step }) { + return ( +
+ {step.number} +

{step.title}

+

{step.description}

+
+ ); +} + +/** + * A single hairline connecting adjacent steps — vertical when stacked + * (mobile), horizontal in the row (desktop). Decorative only: the real + * sequence is already conveyed by the step numbers and heading order. + * + * On desktop the line is pinned to a fixed offset (card padding + half the + * step numeral's line-height: 24px + 10px) rather than centered against + * whichever card happens to be tallest — so it runs precisely through the + * numerals themselves regardless of how much a description wraps, instead + * of landing at an approximate row-center. + */ +function StepConnector() { + return ( +
+ ); +} + +/** + * A borderless data-flow line, not another terminal: the step cards above + * already show the four *stages* of the process, so this deliberately + * avoids repeating that same "boxes + connectors" grammar, and avoids + * repeating the Hero's own `$ recall context` terminal a second time in the + * same visit. Instead it states the *transformation* as plain, precise + * typography — repository in, structured memory in the middle, coding + * agent out — closer to a type signature or a pipeline notation than a UI + * pattern. Every label is reused verbatim from elsewhere on the page; no + * new copy. + */ +function MemoryFlow() { + return ( +
+
+ Repository + → + {memoryCategories.join(' · ')} + → + Coding agent +
+

+ Context ready. +

+
+ ); +} + +export function HowRecallWorksSection() { + return ( +
+

+ How Recall works +

+

+ Recall analyzes your repository once, builds a structured project memory, and gives every + future AI session immediate access to the parts of the codebase that matter. +

+ +
+ {steps.map((step, index) => ( + + + {index < steps.length - 1 ? : null} + + ))} +
+ +
+ +
+
+ ); +} diff --git a/apps/web/src/components/marketing/problem-section.tsx b/apps/web/src/components/marketing/problem-section.tsx new file mode 100644 index 0000000..c836feb --- /dev/null +++ b/apps/web/src/components/marketing/problem-section.tsx @@ -0,0 +1,109 @@ +import type { ReactNode } from 'react'; +import { Check } from 'lucide-react'; +import { Section } from '@/components/ui/section'; +import { cn } from '@/lib/utils'; + +const disappearingContext = [ + 'Architecture decisions.', + 'Naming conventions.', + 'Important dependencies.', + 'Project history.', + 'Previous trade-offs.', +]; + +const transcript = [ + { speaker: 'Developer', message: 'How does authentication work?' }, + { speaker: 'Agent', message: 'I do not know this repository yet.' }, + { + speaker: 'Agent', + message: 'Can you explain the architecture, conventions, and relevant files?', + }, +]; + +const availableMemory = ['Architecture', 'Conventions', 'Decisions', 'Risks', 'Technical debt']; + +interface ComparisonCardProps { + title: string; + children: ReactNode; + /** + * "Without Recall" renders its title in muted-foreground rather than + * foreground — a deliberate, tokens-only hierarchy choice (§4 rule 3: + * "Color, not size, is the secondary hierarchy signal") that reads the + * left column as the gap being described and the right column as the + * resolution, without introducing any new color or icon. + */ + muted?: boolean; +} + +/** Shared card shell for the two comparison columns — same border/padding/radius as every other card on the site (design-system.md §7), never clickable, so no hover state. */ +function ComparisonCard({ title, children, muted }: ComparisonCardProps) { + return ( +
+

+ {title} +

+ {children} +
+ ); +} + +export function ProblemSection() { + return ( +
+
+

+ Every new session starts here. +

+ +

+ Most coding agents only know what you paste into the current conversation. +

+ +
    + {disappearingContext.map((item) => ( +
  • {item}
  • + ))} +
+ +

They disappear.

+

So every new session starts from zero.

+
+ +
+ +
+ {transcript.map((turn, index) => ( +
+

{turn.speaker}:

+

{turn.message}

+
+ ))} +
+
+ + +
    + {availableMemory.map((item) => ( +
  • + + {item} +
  • + ))} +
+

Context ready.

+
+
+ +

+ Recall does not replace the coding agent. +
+ It gives the agent the repository context it did not have. +

+
+ ); +} diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index b9326e5..db7c3b6 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -1,7 +1,7 @@ import { cva } from 'class-variance-authority'; export const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50', + 'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50', { variants: { variant: { diff --git a/packages/core/src/__tests__/context.test.ts b/packages/core/src/__tests__/context.test.ts index 7781d97..4be45b1 100644 --- a/packages/core/src/__tests__/context.test.ts +++ b/packages/core/src/__tests__/context.test.ts @@ -6,6 +6,7 @@ import { commitAll, createTempDir, initGitRepo, + integrationTestTimeout, removeTempDir, } from '@recall-ai/test-fixtures'; import { runInit } from '../use-cases/init.js'; @@ -30,29 +31,46 @@ describe('runContext', () => { await expect(runContext({ path: dir })).rejects.toThrow(InvalidStateError); }); - it('writes context.md by default', async () => { - await runInit({ path: dir, toolVersion: '0.1.0' }); - const result = await runContext({ path: dir }); - expect(result.outputPath).toBe(join(dir, '.recall', 'context.md')); - await expect(access(result.outputPath as string)).resolves.toBeUndefined(); - }); + // All three tests below call `runInit` (~8 real `git` spawns via + // scan-runner's GitAdapter.collectMetadata + listTrackedFiles), on top of + // this describe block's beforeEach (initGitRepo + commitAll). + // `runContext` itself does no further Git work — it only reads the + // snapshot `runInit` just wrote. + it( + 'writes context.md by default', + async () => { + await runInit({ path: dir, toolVersion: '0.1.0' }); + const result = await runContext({ path: dir }); + expect(result.outputPath).toBe(join(dir, '.recall', 'context.md')); + await expect(access(result.outputPath as string)).resolves.toBeUndefined(); + }, + integrationTestTimeout, + ); - it('does not write a file when stdout mode is requested', async () => { - await runInit({ path: dir, toolVersion: '0.1.0' }); - const result = await runContext({ path: dir, stdout: true }); - expect(result.outputPath).toBeNull(); - expect(result.content.length).toBeGreaterThan(0); - }); + it( + 'does not write a file when stdout mode is requested', + async () => { + await runInit({ path: dir, toolVersion: '0.1.0' }); + const result = await runContext({ path: dir, stdout: true }); + expect(result.outputPath).toBeNull(); + expect(result.content.length).toBeGreaterThan(0); + }, + integrationTestTimeout, + ); - it('produces task-focused output that differs from the default', async () => { - await runInit({ path: dir, toolVersion: '0.1.0' }); - const withoutTask = await runContext({ path: dir, stdout: true }); - const withTask = await runContext({ - path: dir, - stdout: true, - task: 'Implement password reset', - }); - expect(withTask.content).toContain('password reset'); - expect(withTask.content).not.toBe(withoutTask.content); - }); + it( + 'produces task-focused output that differs from the default', + async () => { + await runInit({ path: dir, toolVersion: '0.1.0' }); + const withoutTask = await runContext({ path: dir, stdout: true }); + const withTask = await runContext({ + path: dir, + stdout: true, + task: 'Implement password reset', + }); + expect(withTask.content).toContain('password reset'); + expect(withTask.content).not.toBe(withoutTask.content); + }, + integrationTestTimeout, + ); }); diff --git a/packages/core/src/__tests__/doctor.test.ts b/packages/core/src/__tests__/doctor.test.ts index 2441563..78f7166 100644 --- a/packages/core/src/__tests__/doctor.test.ts +++ b/packages/core/src/__tests__/doctor.test.ts @@ -7,6 +7,7 @@ import { createDirLink, createTempDir, initGitRepo, + integrationTestTimeout, removeTempDir, } from '@recall-ai/test-fixtures'; import { runInit } from '../use-cases/init.js'; @@ -34,20 +35,38 @@ describe('runDoctor', () => { ); }); - it('passes all checks immediately after a clean init', async () => { - await runInit({ path: dir, toolVersion: '0.1.0' }); - const result = await runDoctor(dir); - expect(result.overallStatus).not.toBe('fail'); - expect(result.checks.some((c) => c.id === 'manifest-valid' && c.status === 'pass')).toBe(true); - expect(result.checks.some((c) => c.id === 'snapshot-valid' && c.status === 'pass')).toBe(true); - }); - - it('reports memory freshness as PASS (not WARN) immediately after a clean init', async () => { - await runInit({ path: dir, toolVersion: '0.1.0' }); - const result = await runDoctor(dir); - const freshness = result.checks.find((c) => c.id === 'memory-freshness'); - expect(freshness?.status).toBe('pass'); - }); + // Both tests below run `runInit` (init → scan → GitAdapter.collectMetadata + // + listTrackedFiles, ~8 `git` spawns) on top of this describe block's + // beforeEach (initGitRepo + commitAll), then `runDoctor` reaches its final + // `manifest && snapshot` branch and issues one more `git.currentCommit` + // spawn — the same call-path measured at 6482ms on Windows CI (exceeding + // the 5000ms default) for the first of these two. + it( + 'passes all checks immediately after a clean init', + async () => { + await runInit({ path: dir, toolVersion: '0.1.0' }); + const result = await runDoctor(dir); + expect(result.overallStatus).not.toBe('fail'); + expect(result.checks.some((c) => c.id === 'manifest-valid' && c.status === 'pass')).toBe( + true, + ); + expect(result.checks.some((c) => c.id === 'snapshot-valid' && c.status === 'pass')).toBe( + true, + ); + }, + integrationTestTimeout, + ); + + it( + 'reports memory freshness as PASS (not WARN) immediately after a clean init', + async () => { + await runInit({ path: dir, toolVersion: '0.1.0' }); + const result = await runDoctor(dir); + const freshness = result.checks.find((c) => c.id === 'memory-freshness'); + expect(freshness?.status).toBe('pass'); + }, + integrationTestTimeout, + ); it('reports the runtime-version check as PASS on Node 22+ and states the Node 22+ requirement', async () => { const result = await runDoctor(dir); @@ -57,28 +76,42 @@ describe('runDoctor', () => { expect(runtimeCheck?.detail).not.toContain('18+'); }); - it('detects a corrupted manifest as a parse failure', async () => { - await runInit({ path: dir, toolVersion: '0.1.0' }); - await writeFile(join(dir, '.recall', 'manifest.json'), '{ not valid json', 'utf8'); + // Both tests below run a full `runInit` (~8 `git` spawns) before + // corrupting a file and re-running `runDoctor` — the same integration-heavy + // call path as the two tests above, just with an added file corruption + // step that doesn't change the dominant cost. + it( + 'detects a corrupted manifest as a parse failure', + async () => { + await runInit({ path: dir, toolVersion: '0.1.0' }); + await writeFile(join(dir, '.recall', 'manifest.json'), '{ not valid json', 'utf8'); - const result = await runDoctor(dir); - expect(result.overallStatus).toBe('fail'); - expect(result.checks.some((c) => c.id === 'manifest-valid' && c.status === 'fail')).toBe(true); - }); - - it('detects malformed markers in a memory file', async () => { - await runInit({ path: dir, toolVersion: '0.1.0' }); - await writeFile( - join(dir, '.recall', 'risks.md'), - '\nno end marker', - 'utf8', - ); + const result = await runDoctor(dir); + expect(result.overallStatus).toBe('fail'); + expect(result.checks.some((c) => c.id === 'manifest-valid' && c.status === 'fail')).toBe( + true, + ); + }, + integrationTestTimeout, + ); + + it( + 'detects malformed markers in a memory file', + async () => { + await runInit({ path: dir, toolVersion: '0.1.0' }); + await writeFile( + join(dir, '.recall', 'risks.md'), + '\nno end marker', + 'utf8', + ); - const result = await runDoctor(dir); - expect(result.checks.some((c) => c.id === 'markers-risks.md' && c.status === 'fail')).toBe( - true, - ); - }); + const result = await runDoctor(dir); + expect(result.checks.some((c) => c.id === 'markers-risks.md' && c.status === 'fail')).toBe( + true, + ); + }, + integrationTestTimeout, + ); it('fails cleanly and stops early when .recall is a symlink', async () => { const outsideDir = await createTempDir('recall-outside-'); diff --git a/packages/core/src/__tests__/explain.test.ts b/packages/core/src/__tests__/explain.test.ts index 9227ef7..299aab4 100644 --- a/packages/core/src/__tests__/explain.test.ts +++ b/packages/core/src/__tests__/explain.test.ts @@ -4,6 +4,7 @@ import { commitAll, createTempDir, initGitRepo, + integrationHookTimeout, removeTempDir, } from '@recall-ai/test-fixtures'; import { runInit } from '../use-cases/init.js'; @@ -13,13 +14,19 @@ import { InvalidUsageError } from '../errors.js'; describe('runExplain', () => { let dir: string; + // initGitRepo + commitAll + a full runInit (~11 real `git` spawns) before + // every test — the same order of Git-subprocess cost measured elsewhere in + // this suite to approach/exceed Vitest's default hookTimeout on Windows + // CI. `runExplain` itself does no Git/FS-heavy work (it only reads the + // snapshot this hook already produced), so only the hook needs the + // widened timeout — the `it()` bodies below keep the default testTimeout. beforeEach(async () => { dir = await createTempDir(); await buildNestJsFixture(dir); await initGitRepo(dir); await commitAll(dir, 'chore: initial commit'); await runInit({ path: dir, toolVersion: '0.1.0' }); - }); + }, integrationHookTimeout); afterEach(async () => { await removeTempDir(dir); diff --git a/packages/core/src/__tests__/init.test.ts b/packages/core/src/__tests__/init.test.ts index 3cd29ff..c07ee97 100644 --- a/packages/core/src/__tests__/init.test.ts +++ b/packages/core/src/__tests__/init.test.ts @@ -6,6 +6,7 @@ import { commitAll, createTempDir, initGitRepo, + integrationTestTimeout, removeTempDir, } from '@recall-ai/test-fixtures'; import { runInit } from '../use-cases/init.js'; @@ -24,64 +25,96 @@ describe('runInit', () => { await removeTempDir(dir); }); - it('creates the full .recall directory structure', async () => { - const result = await runInit({ path: dir, toolVersion: '0.1.0' }); - expect(result.wasAlreadyInitialized).toBe(false); + // Every test in this file calls `runInit` at least once — each call is + // init → scan → GitAdapter.collectMetadata + listTrackedFiles, ~8 real + // `git` spawns — on top of this describe block's beforeEach (initGitRepo + + // commitAll). The two tests that call `runInit` twice pay that cost twice. + it( + 'creates the full .recall directory structure', + async () => { + const result = await runInit({ path: dir, toolVersion: '0.1.0' }); + expect(result.wasAlreadyInitialized).toBe(false); - await expect(access(join(dir, '.recall', 'manifest.json'))).resolves.toBeUndefined(); - await expect(access(join(dir, '.recall', 'snapshots', 'latest.json'))).resolves.toBeUndefined(); - for (const file of [ - 'architecture.md', - 'conventions.md', - 'decisions.md', - 'features.md', - 'glossary.md', - 'risks.md', - 'technical-debt.md', - ]) { - await expect(access(join(dir, '.recall', file))).resolves.toBeUndefined(); - } - }); + await expect(access(join(dir, '.recall', 'manifest.json'))).resolves.toBeUndefined(); + await expect( + access(join(dir, '.recall', 'snapshots', 'latest.json')), + ).resolves.toBeUndefined(); + for (const file of [ + 'architecture.md', + 'conventions.md', + 'decisions.md', + 'features.md', + 'glossary.md', + 'risks.md', + 'technical-debt.md', + ]) { + await expect(access(join(dir, '.recall', file))).resolves.toBeUndefined(); + } + }, + integrationTestTimeout, + ); - it('adds .recall/cache/ to .gitignore', async () => { - await runInit({ path: dir, toolVersion: '0.1.0' }); - const gitignore = await readFile(join(dir, '.gitignore'), 'utf8'); - expect(gitignore).toContain('.recall/cache/'); - }); + it( + 'adds .recall/cache/ to .gitignore', + async () => { + await runInit({ path: dir, toolVersion: '0.1.0' }); + const gitignore = await readFile(join(dir, '.gitignore'), 'utf8'); + expect(gitignore).toContain('.recall/cache/'); + }, + integrationTestTimeout, + ); - it('does not modify source files', async () => { - const before = await readFile(join(dir, 'src/index.js'), 'utf8'); - await runInit({ path: dir, toolVersion: '0.1.0' }); - const after = await readFile(join(dir, 'src/index.js'), 'utf8'); - expect(after).toBe(before); - }); + it( + 'does not modify source files', + async () => { + const before = await readFile(join(dir, 'src/index.js'), 'utf8'); + await runInit({ path: dir, toolVersion: '0.1.0' }); + const after = await readFile(join(dir, 'src/index.js'), 'utf8'); + expect(after).toBe(before); + }, + integrationTestTimeout, + ); - it('does not write anything in dry-run mode', async () => { - await runInit({ path: dir, toolVersion: '0.1.0', dryRun: true }); - await expect(access(join(dir, '.recall'))).rejects.toThrow(); - }); + it( + 'does not write anything in dry-run mode', + async () => { + await runInit({ path: dir, toolVersion: '0.1.0', dryRun: true }); + await expect(access(join(dir, '.recall'))).rejects.toThrow(); + }, + integrationTestTimeout, + ); - it('is idempotent when run twice in a row', async () => { - const first = await runInit({ path: dir, toolVersion: '0.1.0' }); - const second = await runInit({ path: dir, toolVersion: '0.1.0' }); + // Calls `runInit` twice — roughly double the git-spawn cost of the tests + // above. + it( + 'is idempotent when run twice in a row', + async () => { + const first = await runInit({ path: dir, toolVersion: '0.1.0' }); + const second = await runInit({ path: dir, toolVersion: '0.1.0' }); - expect(first.wasAlreadyInitialized).toBe(false); - expect(second.wasAlreadyInitialized).toBe(true); - expect(second.memoryFileUpdates.every((u) => !u.changed)).toBe(true); - }); + expect(first.wasAlreadyInitialized).toBe(false); + expect(second.wasAlreadyInitialized).toBe(true); + expect(second.memoryFileUpdates.every((u) => !u.changed)).toBe(true); + }, + integrationTestTimeout, + ); - it('preserves manually edited memory file content across a second init', async () => { - await runInit({ path: dir, toolVersion: '0.1.0' }); - const architecturePath = join(dir, '.recall', 'architecture.md'); - const original = await readFile(architecturePath, 'utf8'); - await writeFile( - architecturePath, - original.replace('## Notes', '## Notes\n\nHand-written context.'), - 'utf8', - ); + it( + 'preserves manually edited memory file content across a second init', + async () => { + await runInit({ path: dir, toolVersion: '0.1.0' }); + const architecturePath = join(dir, '.recall', 'architecture.md'); + const original = await readFile(architecturePath, 'utf8'); + await writeFile( + architecturePath, + original.replace('## Notes', '## Notes\n\nHand-written context.'), + 'utf8', + ); - await runInit({ path: dir, toolVersion: '0.1.0' }); - const finalContent = await readFile(architecturePath, 'utf8'); - expect(finalContent).toContain('Hand-written context.'); - }); + await runInit({ path: dir, toolVersion: '0.1.0' }); + const finalContent = await readFile(architecturePath, 'utf8'); + expect(finalContent).toContain('Hand-written context.'); + }, + integrationTestTimeout, + ); }); diff --git a/packages/core/src/__tests__/status.test.ts b/packages/core/src/__tests__/status.test.ts index c037bf2..5e7f4e6 100644 --- a/packages/core/src/__tests__/status.test.ts +++ b/packages/core/src/__tests__/status.test.ts @@ -6,6 +6,7 @@ import { commitAll, createTempDir, initGitRepo, + integrationTestTimeout, removeTempDir, } from '@recall-ai/test-fixtures'; import { runInit } from '../use-cases/init.js'; @@ -32,25 +33,42 @@ describe('runStatus', () => { expect(status.missingMemoryFiles.length).toBeGreaterThan(0); }); - it('reports ok immediately after a clean init', async () => { - await runInit({ path: dir, toolVersion: '0.1.0' }); - const status = await runStatus(dir); - expect(status.initialized).toBe(true); - expect(status.overallStatus).toBe('ok'); - expect(status.missingMemoryFiles).toEqual([]); - expect(status.malformedFiles).toEqual([]); - expect(status.detectedProjectType).not.toBeNull(); - }); + // Both tests below run `runInit` (init → scan → GitAdapter.collectMetadata + // + listTrackedFiles) followed by `runStatus` (currentCommit/currentBranch + // /changedFiles), on top of this describe block's beforeEach + // (initGitRepo + commitAll) — a chain of real `git` subprocess spawns that + // has measured over Vitest's default 5000ms testTimeout on Windows CI, + // where each spawn carries process-creation/AV-scan overhead far higher + // than on Linux/macOS or a local dev machine. The "stale" variant below + // does strictly more of the same work (one extra commitAll), so it shares + // the same risk even though it wasn't the one CI happened to flag. + it( + 'reports ok immediately after a clean init', + async () => { + await runInit({ path: dir, toolVersion: '0.1.0' }); + const status = await runStatus(dir); + expect(status.initialized).toBe(true); + expect(status.overallStatus).toBe('ok'); + expect(status.missingMemoryFiles).toEqual([]); + expect(status.malformedFiles).toEqual([]); + expect(status.detectedProjectType).not.toBeNull(); + }, + integrationTestTimeout, + ); - it('reports stale after new commits change the repository', async () => { - await runInit({ path: dir, toolVersion: '0.1.0' }); - await writeFile(join(dir, 'src/extra.js'), 'module.exports = {};\n', 'utf8'); - await commitAll(dir, 'feat: extra file'); + it( + 'reports stale after new commits change the repository', + async () => { + await runInit({ path: dir, toolVersion: '0.1.0' }); + await writeFile(join(dir, 'src/extra.js'), 'module.exports = {};\n', 'utf8'); + await commitAll(dir, 'feat: extra file'); - const status = await runStatus(dir); - expect(status.overallStatus).toBe('stale'); - expect(status.stale).toBe(true); - }); + const status = await runStatus(dir); + expect(status.overallStatus).toBe('stale'); + expect(status.stale).toBe(true); + }, + integrationTestTimeout, + ); }); describe('runStatus (non-Git repository)', () => { diff --git a/packages/core/src/__tests__/symlink-safety.test.ts b/packages/core/src/__tests__/symlink-safety.test.ts index 8f35fe3..3b7eba2 100644 --- a/packages/core/src/__tests__/symlink-safety.test.ts +++ b/packages/core/src/__tests__/symlink-safety.test.ts @@ -7,6 +7,7 @@ import { createDirLink, createTempDir, initGitRepo, + integrationTestTimeout, removeTempDir, } from '@recall-ai/test-fixtures'; import { runInit } from '../use-cases/init.js'; @@ -37,47 +38,69 @@ describe('symlink attack resistance (integration)', () => { await removeTempDir(outsideDir); }); - it('refuses to init through a pre-planted symlinked .recall directory', async () => { - await createDirLink(outsideDir, join(repoDir, '.recall')); + // Every test in this block shares the beforeEach's real Git init + commit + // and then drives at least one full `runInit`/`runUpdate` call (init/update + // → scan → GitAdapter.collectMetadata + listTrackedFiles) — a chain of real + // `git` subprocess spawns that has measured over Vitest's default 5000ms + // testTimeout on Windows CI, where each spawn carries process-creation/ + // AV-scan overhead far higher than on Linux/macOS or a local dev machine. + it( + 'refuses to init through a pre-planted symlinked .recall directory', + async () => { + await createDirLink(outsideDir, join(repoDir, '.recall')); - await expect(runInit({ path: repoDir, toolVersion: '0.1.0' })).rejects.toThrow( - InvalidStateError, - ); + await expect(runInit({ path: repoDir, toolVersion: '0.1.0' })).rejects.toThrow( + InvalidStateError, + ); - const outsideEntries = await readdir(outsideDir); - expect(outsideEntries).toEqual([]); - }); + const outsideEntries = await readdir(outsideDir); + expect(outsideEntries).toEqual([]); + }, + integrationTestTimeout, + ); - it('refuses to update through a .recall directory replaced by a symlink after init', async () => { - await runInit({ path: repoDir, toolVersion: '0.1.0' }); + it( + 'refuses to update through a .recall directory replaced by a symlink after init', + async () => { + await runInit({ path: repoDir, toolVersion: '0.1.0' }); - // Simulate an attacker (or a broken tool) swapping the real .recall - // directory for a symlink between commands. - const { rm } = await import('node:fs/promises'); - await rm(join(repoDir, '.recall'), { recursive: true, force: true }); - await createDirLink(outsideDir, join(repoDir, '.recall')); + // Simulate an attacker (or a broken tool) swapping the real .recall + // directory for a symlink between commands. + const { rm } = await import('node:fs/promises'); + await rm(join(repoDir, '.recall'), { recursive: true, force: true }); + await createDirLink(outsideDir, join(repoDir, '.recall')); - await expect(runUpdate({ path: repoDir, toolVersion: '0.1.0' })).rejects.toThrow( - InvalidStateError, - ); + await expect(runUpdate({ path: repoDir, toolVersion: '0.1.0' })).rejects.toThrow( + InvalidStateError, + ); - const outsideEntries = await readdir(outsideDir); - expect(outsideEntries).toEqual([]); - }); + const outsideEntries = await readdir(outsideDir); + expect(outsideEntries).toEqual([]); + }, + integrationTestTimeout, + ); - it('refuses to init through a symlink planted one level below an existing .recall', async () => { - await mkdir(join(repoDir, '.recall'), { recursive: true }); - await createDirLink(outsideDir, join(repoDir, '.recall', 'snapshots')); + it( + 'refuses to init through a symlink planted one level below an existing .recall', + async () => { + await mkdir(join(repoDir, '.recall'), { recursive: true }); + await createDirLink(outsideDir, join(repoDir, '.recall', 'snapshots')); - await expect(runInit({ path: repoDir, toolVersion: '0.1.0' })).rejects.toThrow(); + await expect(runInit({ path: repoDir, toolVersion: '0.1.0' })).rejects.toThrow(); - const outsideEntries = await readdir(outsideDir); - expect(outsideEntries).toEqual([]); - }); + const outsideEntries = await readdir(outsideDir); + expect(outsideEntries).toEqual([]); + }, + integrationTestTimeout, + ); - it('succeeds normally once the symlink is removed (control case)', async () => { - const result = await runInit({ path: repoDir, toolVersion: '0.1.0' }); - expect(result.wasAlreadyInitialized).toBe(false); - expect(result.memoryFileUpdates.every((u) => u.changed)).toBe(true); - }); + it( + 'succeeds normally once the symlink is removed (control case)', + async () => { + const result = await runInit({ path: repoDir, toolVersion: '0.1.0' }); + expect(result.wasAlreadyInitialized).toBe(false); + expect(result.memoryFileUpdates.every((u) => u.changed)).toBe(true); + }, + integrationTestTimeout, + ); }); diff --git a/packages/core/src/__tests__/task-ranking.e2e.test.ts b/packages/core/src/__tests__/task-ranking.e2e.test.ts index fb2b606..6860022 100644 --- a/packages/core/src/__tests__/task-ranking.e2e.test.ts +++ b/packages/core/src/__tests__/task-ranking.e2e.test.ts @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { buildTaskRankingFixture, createTempDir, removeTempDir } from '@recall-ai/test-fixtures'; +import { + buildTaskRankingFixture, + createTempDir, + integrationHookTimeout, + removeTempDir, +} from '@recall-ai/test-fixtures'; import { runInit } from '../use-cases/init.js'; import { runContext } from '../use-cases/context.js'; @@ -14,11 +19,19 @@ import { runContext } from '../use-cases/context.js'; describe('task-focused ranking (regression fixture, end-to-end)', () => { let dir: string; + // Writes ~35 fixture files, then `runInit` scans them and probes Git via + // several `git` subprocess spawns (repo detection + metadata, all of which + // no-op/fail cleanly since this temp dir isn't a Git repo, but each spawn + // still pays full process-creation cost). This hook has measured over + // Vitest's default 10000ms hookTimeout on Windows CI, where process-spawn + // and AV-scan overhead run far higher than on Linux/macOS or a local dev + // machine. The `it()` bodies below only read the already-computed snapshot + // in memory, so they don't need the same allowance. beforeEach(async () => { dir = await createTempDir(); await buildTaskRankingFixture(dir); await runInit({ path: dir, toolVersion: '0.1.0' }); - }); + }, integrationHookTimeout); afterEach(async () => { await removeTempDir(dir); diff --git a/packages/core/src/__tests__/update.test.ts b/packages/core/src/__tests__/update.test.ts index 87ea411..826d959 100644 --- a/packages/core/src/__tests__/update.test.ts +++ b/packages/core/src/__tests__/update.test.ts @@ -6,73 +6,109 @@ import { commitAll, createTempDir, initGitRepo, + integrationHookTimeout, + integrationTestTimeout, removeTempDir, } from '@recall-ai/test-fixtures'; import { runInit } from '../use-cases/init.js'; import { runUpdate } from '../use-cases/update.js'; import { InvalidStateError } from '../errors.js'; +// `runUpdate` throws `InvalidStateError` immediately after `readManifest`/ +// `readSnapshot` come back empty — before it ever calls `runScan` — so this +// case needs only a bare, uninitialized temp dir. It intentionally lives +// outside `describe('runUpdate', ...)` below: that block's beforeEach does +// initGitRepo + commitAll + a full runInit (~11 real `git` spawns) to set up +// a fixture this test doesn't touch, which is exactly why it measured +// 4897ms on Windows CI — dangerously close to the 5000ms default for a test +// whose own assertion does zero Git/FS work. Removing the unnecessary +// dependency on that fixture (rather than just widening its timeout) is the +// actual fix. +describe('runUpdate (uninitialized target)', () => { + it('throws InvalidStateError when Recall has not been initialized', async () => { + const other = await createTempDir(); + try { + await expect(runUpdate({ path: other, toolVersion: '0.1.0' })).rejects.toThrow( + InvalidStateError, + ); + } finally { + await removeTempDir(other); + } + }); +}); + describe('runUpdate', () => { let dir: string; + // initGitRepo + commitAll + a full runInit (~11 real `git` spawns) before + // every test in this block — measured as part of the same call path that + // hit 6482ms in doctor.test.ts's clean-init case, and every test below + // adds further real git/runUpdate work on top of it. beforeEach(async () => { dir = await createTempDir(); await buildSimpleNodeFixture(dir); await initGitRepo(dir); await commitAll(dir, 'chore: initial commit'); await runInit({ path: dir, toolVersion: '0.1.0' }); - }); + }, integrationHookTimeout); afterEach(async () => { await removeTempDir(dir); }); - it('throws InvalidStateError when Recall has not been initialized', async () => { - const other = await createTempDir(); - try { - await expect(runUpdate({ path: other, toolVersion: '0.1.0' })).rejects.toThrow( - InvalidStateError, - ); - } finally { - await removeTempDir(other); - } - }); - - it('reports no changes when the repository is untouched', async () => { - const result = await runUpdate({ path: dir, toolVersion: '0.1.0' }); - expect(result.changeReport.hasChanges).toBe(false); - expect(result.staleness.stale).toBe(false); - }); + it( + 'reports no changes when the repository is untouched', + async () => { + const result = await runUpdate({ path: dir, toolVersion: '0.1.0' }); + expect(result.changeReport.hasChanges).toBe(false); + expect(result.staleness.stale).toBe(false); + }, + integrationTestTimeout, + ); - it('detects new files as changes and reports staleness', async () => { - await writeFile(join(dir, 'src/new-feature.js'), 'module.exports = {};\n', 'utf8'); - await commitAll(dir, 'feat: add new feature'); + it( + 'detects new files as changes and reports staleness', + async () => { + await writeFile(join(dir, 'src/new-feature.js'), 'module.exports = {};\n', 'utf8'); + await commitAll(dir, 'feat: add new feature'); - const result = await runUpdate({ path: dir, toolVersion: '0.1.0', check: true }); - expect(result.changeReport.filesAdded).toContain('src/new-feature.js'); - expect(result.staleness.stale).toBe(true); - expect(result.applied).toBe(false); - }); + const result = await runUpdate({ path: dir, toolVersion: '0.1.0', check: true }); + expect(result.changeReport.filesAdded).toContain('src/new-feature.js'); + expect(result.staleness.stale).toBe(true); + expect(result.applied).toBe(false); + }, + integrationTestTimeout, + ); - it('applies updates and persists the new snapshot when not in check/dry-run mode', async () => { - await writeFile(join(dir, 'src/new-feature.js'), 'module.exports = {};\n', 'utf8'); - await commitAll(dir, 'feat: add new feature'); + // Calls `runUpdate` twice — roughly double the git-spawn cost of the test + // above. + it( + 'applies updates and persists the new snapshot when not in check/dry-run mode', + async () => { + await writeFile(join(dir, 'src/new-feature.js'), 'module.exports = {};\n', 'utf8'); + await commitAll(dir, 'feat: add new feature'); - const result = await runUpdate({ path: dir, toolVersion: '0.1.0' }); - expect(result.applied).toBe(true); + const result = await runUpdate({ path: dir, toolVersion: '0.1.0' }); + expect(result.applied).toBe(true); - const followUp = await runUpdate({ path: dir, toolVersion: '0.1.0' }); - expect(followUp.changeReport.hasChanges).toBe(false); - }); + const followUp = await runUpdate({ path: dir, toolVersion: '0.1.0' }); + expect(followUp.changeReport.hasChanges).toBe(false); + }, + integrationTestTimeout, + ); - it('does not write anything in dry-run mode', async () => { - await writeFile(join(dir, 'src/new-feature.js'), 'module.exports = {};\n', 'utf8'); - await commitAll(dir, 'feat: add new feature'); + it( + 'does not write anything in dry-run mode', + async () => { + await writeFile(join(dir, 'src/new-feature.js'), 'module.exports = {};\n', 'utf8'); + await commitAll(dir, 'feat: add new feature'); - const result = await runUpdate({ path: dir, toolVersion: '0.1.0', dryRun: true }); - expect(result.applied).toBe(false); + const result = await runUpdate({ path: dir, toolVersion: '0.1.0', dryRun: true }); + expect(result.applied).toBe(false); - const followUp = await runUpdate({ path: dir, toolVersion: '0.1.0', check: true }); - expect(followUp.staleness.stale).toBe(true); - }); + const followUp = await runUpdate({ path: dir, toolVersion: '0.1.0', check: true }); + expect(followUp.staleness.stale).toBe(true); + }, + integrationTestTimeout, + ); }); diff --git a/packages/test-fixtures/src/git-helpers.ts b/packages/test-fixtures/src/git-helpers.ts index 84886e4..c05b697 100644 --- a/packages/test-fixtures/src/git-helpers.ts +++ b/packages/test-fixtures/src/git-helpers.ts @@ -1,14 +1,27 @@ import { execa } from 'execa'; +/** + * Author/committer identity supplied via environment variables rather than + * two extra `git config` invocations per repo. Git reads these directly, so + * a repo never needs local config written for tests to produce a valid + * commit — this halves the process-spawn count of `initGitRepo` (1 spawn + * instead of 3), which matters on Windows CI where each `git.exe` spawn + * carries real process-creation/AV-scan overhead. + */ +const TEST_GIT_IDENTITY_ENV = { + GIT_AUTHOR_NAME: 'Recall Test', + GIT_AUTHOR_EMAIL: 'recall-test@example.com', + GIT_COMMITTER_NAME: 'Recall Test', + GIT_COMMITTER_EMAIL: 'recall-test@example.com', +}; + export async function initGitRepo(dir: string, defaultBranch = 'main'): Promise { await execa('git', ['init', '-q', '-b', defaultBranch, dir]); - await execa('git', ['-C', dir, 'config', 'user.email', 'recall-test@example.com']); - await execa('git', ['-C', dir, 'config', 'user.name', 'Recall Test']); } export async function commitAll(dir: string, message: string): Promise { await execa('git', ['-C', dir, 'add', '-A']); - await execa('git', ['-C', dir, 'commit', '-q', '-m', message]); + await execa('git', ['-C', dir, 'commit', '-q', '-m', message], { env: TEST_GIT_IDENTITY_ENV }); } export async function currentCommit(dir: string): Promise { diff --git a/packages/test-fixtures/src/index.ts b/packages/test-fixtures/src/index.ts index f59f1cb..a778e90 100644 --- a/packages/test-fixtures/src/index.ts +++ b/packages/test-fixtures/src/index.ts @@ -2,6 +2,7 @@ export * from './write-tree.js'; export * from './temp-dir.js'; export * from './git-helpers.js'; export * from './symlink-support.js'; +export * from './timeouts.js'; export * from './fixtures/simple-node.js'; export * from './fixtures/nestjs.js'; export * from './fixtures/nextjs.js'; diff --git a/packages/test-fixtures/src/timeouts.ts b/packages/test-fixtures/src/timeouts.ts new file mode 100644 index 0000000..e43169f --- /dev/null +++ b/packages/test-fixtures/src/timeouts.ts @@ -0,0 +1,23 @@ +/** + * Timeouts for integration tests/hooks that do real filesystem + Git + * subprocess work (repo init, commits, multi-file scans). On Windows CI + * runners, each `git.exe` invocation carries process-creation and + * antivirus-scan overhead far higher than on Linux/macOS or a local dev + * machine, and these tests issue many of them — so the same test that + * comfortably finishes in under a second locally can exceed Vitest's + * defaults on Windows CI. + * + * These are deliberately NOT global timeout overrides: apply the relevant + * one only to the specific `it(...)`/`beforeEach(...)` that has demonstrated + * Windows timing pressure, as its trailing timeout argument. The non-Windows + * value in each constant matches Vitest's own default for that hook type + * exactly, so applying it elsewhere is a deliberate widening on Windows only + * — never a change in behavior on Linux/macOS, and never a reduction below + * what an unannotated test already gets. + */ + +/** For `it(name, fn, integrationTestTimeout)`. Vitest's `testTimeout` default is 5000ms. */ +export const integrationTestTimeout = process.platform === 'win32' ? 15_000 : 5_000; + +/** For `beforeEach(fn, integrationHookTimeout)` (or `afterEach`/`beforeAll`/`afterAll`). Vitest's `hookTimeout` default is 10000ms. */ +export const integrationHookTimeout = process.platform === 'win32' ? 15_000 : 10_000; diff --git a/packages/test-fixtures/src/write-tree.ts b/packages/test-fixtures/src/write-tree.ts index e54aca6..fa8c0bf 100644 --- a/packages/test-fixtures/src/write-tree.ts +++ b/packages/test-fixtures/src/write-tree.ts @@ -9,9 +9,16 @@ import { dirname, join } from 'node:path'; export type FileTree = Record; export async function writeTree(root: string, tree: FileTree): Promise { - for (const [relativePath, contents] of Object.entries(tree)) { - const fullPath = join(root, relativePath); - await mkdir(dirname(fullPath), { recursive: true }); - await writeFile(fullPath, contents, 'utf8'); - } + // Each entry is an independent file write; `mkdir(..., { recursive: true })` + // is idempotent under concurrent calls, so there's no ordering requirement + // between entries. Running them in parallel (rather than one at a time) + // cuts wall-clock I/O time on filesystems with higher per-call latency, + // such as Windows CI runners. + await Promise.all( + Object.entries(tree).map(async ([relativePath, contents]) => { + const fullPath = join(root, relativePath); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, contents, 'utf8'); + }), + ); }