From e35557f729327a422b41aa84d6f6dd2fd0cbf6b0 Mon Sep 17 00:00:00 2001 From: Sabahattin Kalkan Date: Thu, 6 Aug 2026 00:25:46 +0400 Subject: [PATCH 1/7] feat(web): implement problem comparison section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ProblemSection (apps/web/src/components/marketing/problem-section.tsx) and integrates it immediately after HeroSection on the marketing page — no other section, the Hero, header, footer, docs, or routing touched. Structure follows docs/design-system.md exactly: - H2 "Every new session starts here." + supporting copy, reusing the shared Section/Container wrapper every other homepage section uses (§5, §15 "reuse by default"). - A two-column comparison at `lg:grid-cols-2` (matches the Hero's own breakpoint), stacking to "Without Recall" then "With Recall" on mobile via DOM order — no separate mobile-only markup. - Both comparison columns share one local `ComparisonCard` helper (border, bg-card, p-6, rounded-lg, no shadow, no hover — §7: cards that aren't clickable get no hover state). - "Without Recall": a restrained transcript (speaker label in muted-foreground, message in foreground, plain stacked text) — no chat bubbles, no avatars, no gradients, not styled as a branded chat UI. - "With Recall": a checklist reusing the exact success-checkmark pattern already established in HeroTerminal (§15 consistency), ending in a plain "Context ready." status line. - Closing centered statement below the comparison, in muted-foreground to match the rest of the page's supporting-text convention. Every color is an existing semantic token (foreground, muted-foreground, border, card, success) — no new arbitrary colors, no gradients, no shadows, no animation, no icons beyond the same functional checkmark already in use elsewhere on this page. Visual verification (Playwright, localhost dev server): confirmed at 1440x900 and 390x844, in both light and dark theme — card balance, heading wrap, mobile stacking order (Without Recall first), zero horizontal overflow (scrollWidth === clientWidth at 390px), contrast, and visual consistency with the existing Hero. Validation: `pnpm --filter recall-web lint/typecheck/test/build` and root `pnpm lint/typecheck/test/build` all green (16/16 workspace tasks). --- apps/web/src/app/(marketing)/page.tsx | 3 + .../components/marketing/problem-section.tsx | 91 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 apps/web/src/components/marketing/problem-section.tsx diff --git a/apps/web/src/app/(marketing)/page.tsx b/apps/web/src/app/(marketing)/page.tsx index f479c45..d1242c2 100644 --- a/apps/web/src/app/(marketing)/page.tsx +++ b/apps/web/src/app/(marketing)/page.tsx @@ -1,6 +1,7 @@ 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 { CommandBlock } from '@/components/marketing/command-block'; import { FeatureCard } from '@/components/marketing/feature-card'; @@ -93,6 +94,8 @@ export default function MarketingPage() { <> + +

CLAUDE.md and AGENTS.md vs. Recall 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..02f33b9 --- /dev/null +++ b/apps/web/src/components/marketing/problem-section.tsx @@ -0,0 +1,91 @@ +import type { ReactNode } from 'react'; +import { Check } from 'lucide-react'; +import { Section } from '@/components/ui/section'; + +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']; + +/** 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 }: { title: string; children: ReactNode }) { + 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. +

+
+ ); +} From fd79e187a20eca46249dcaa420d2d0d6a79f3e96 Mon Sep 17 00:00:00 2001 From: Sabahattin Kalkan Date: Thu, 6 Aug 2026 00:41:40 +0400 Subject: [PATCH 2/7] feat(web): implement workflow section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds HowRecallWorksSection (apps/web/src/components/marketing/ how-recall-works-section.tsx) and integrates it immediately after ProblemSection on the marketing page. Hero, ProblemSection, header, footer, docs, routing, and copy outside this section are untouched. Structure follows docs/design-system.md exactly: - H2 "How Recall works" + supporting paragraph, using the shared Section wrapper every other homepage section uses (§5, §15 "reuse by default"). - Four equally-sized step cards (Scan Repository / Build Memory / Understand Task / Provide Context), each a plain bordered card (border, bg-card, p-6, rounded-lg, no shadow, no hover — §7: not clickable, so no hover state) containing a mono step numeral (`01`- `04`, matching the numeral convention already established in the page's existing "How it works" section), an h3 title, and a short description. - Cards are connected by a single hairline (`bg-border`) between each pair — vertical when stacked on mobile, horizontal in the row on desktop — implemented as flat flex siblings (not nested per-card wrappers) so all four cards share the exact same `flex-1` sizing rule and stay genuinely equal-width regardless of connector placement. This is a connected-steps flow, not a timeline: no dates, no large illustrations, no icons beyond the existing checkmark style. - A compact terminal block below the steps, visually identical to HeroTerminal's treatment (border, bg-code-background, font-mono, `$`-prefixed command, success-colored checkmarks) but narrower (max-w-xl) to read as a confirmation rather than a second hero. Every color is an existing semantic token (foreground, muted-foreground, border, card, code-background, success) — no new arbitrary colors, no gradients, no shadows, no animation, no illustrations. Note: a separate, pre-existing "How it works" section (3 steps: Scan/ Rank/Generate) already exists further down this same page from the original scaffold. It was left untouched per "do not modify existing copy outside this section" — the two sections now temporarily overlap in subject matter until a later pass consolidates or removes the older scaffold section. Visual verification (Playwright, live dev server): confirmed at 1440x900 and 390x844, in both light and dark theme — equal card widths, connector consistency (vertical on mobile, horizontal on desktop), terminal readability in both themes, zero horizontal overflow (scrollWidth === clientWidth at 390px), and visual consistency with the existing Hero terminal. Validation: `pnpm --filter recall-web lint/typecheck/test/build` and root `pnpm lint/typecheck/test/build` all green (16/16 workspace tasks). --- apps/web/src/app/(marketing)/page.tsx | 3 + .../marketing/how-recall-works-section.tsx | 124 ++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 apps/web/src/components/marketing/how-recall-works-section.tsx diff --git a/apps/web/src/app/(marketing)/page.tsx b/apps/web/src/app/(marketing)/page.tsx index d1242c2..af693a4 100644 --- a/apps/web/src/app/(marketing)/page.tsx +++ b/apps/web/src/app/(marketing)/page.tsx @@ -2,6 +2,7 @@ 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'; @@ -96,6 +97,8 @@ export default function MarketingPage() { + +

CLAUDE.md and AGENTS.md vs. Recall 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..95a6a67 --- /dev/null +++ b/apps/web/src/components/marketing/how-recall-works-section.tsx @@ -0,0 +1,124 @@ +import { Fragment } from 'react'; +import { Check } from 'lucide-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.', + }, +]; + +const workflowOutput = [ + 'Ranked relevant files', + 'Loaded architecture', + 'Loaded conventions', + 'Context ready', +]; + +/** + * 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. + */ +function StepConnector() { + return ( +
+ ); +} + +/** Matches HeroTerminal's visual treatment exactly (design-system.md §8: no fake chrome, no typing animation), constrained narrower to read as a compact confirmation rather than a second hero. */ +function WorkflowTerminal() { + return ( +
+

+ $ + recall context \ +

+

+ --task "Add password reset" +

+ +
    + {workflowOutput.map((line) => ( +
  • + + {line} +
  • + ))} +
+
+ ); +} + +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} + + ))} +
+ +
+ +
+
+ ); +} From bb975d314045ddd5f8d53e19d3d2d3ef9316523d Mon Sep 17 00:00:00 2001 From: Sabahattin Kalkan Date: Fri, 7 Aug 2026 01:35:38 +0400 Subject: [PATCH 3/7] refine(web): elevate Problem and Workflow composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit pass over Hero, ProblemSection, and HowRecallWorksSection against docs/design-system.md, treating the homepage as one connected narrative rather than three independent sections — no new sections, no copy changes, no new dependencies. Findings and fixes: - HowRecallWorksSection's connector line was centered against the tallest card's full height (flex `self-center`), so on desktop it landed at an approximate row-center rather than through the step numerals it's meant to connect. Pinned it to a fixed offset (24px card padding + 10px, half of `text-sm`'s exact 20px line-height — verified against Tailwind's own --text-sm--line-height token) so it runs precisely through every numeral regardless of how a description happens to wrap. This is the kind of "disciplined alignment" the design system explicitly calls for, and it was previously only approximately correct. - ProblemSection's two comparison card titles both used identical foreground weight, giving no typographic signal that "Without Recall" describes a problem and "With Recall" describes its resolution. Added a `muted` option to the shared card title (used only for "Without Recall"), applying an existing token (muted-foreground) rather than introducing any new color — per design-system.md §4 rule 3, color hierarchy (not size or decoration) is exactly how this system is meant to carry that kind of meaning. - Hero was evaluated and deliberately left unchanged: it already reads as restrained, typography-led, and correctly hierarchical: an audit finding no defect is itself a legitimate outcome, not a missed opportunity. One idea considered and explicitly rejected: scroll-triggered fade/slide reveal animations on these sections. docs/design-system.md §10 forbids this outright ("every section is visible and fully rendered immediately... not by virtue of an animation permitting it to appear"), which directly conflicts with looser "progressive reveal" language elsewhere — the design system document is the tie-breaker per its own stated authority, so no motion was added anywhere in this pass. Validation: `pnpm --filter recall-web lint/typecheck/test/build` and root `pnpm lint/typecheck/test/build` all green (16/16 workspace tasks). Visual verification via Playwright was attempted but the browser bridge was unresponsive for the full session (connects, then times out) — correctness was instead confirmed via the rendered HTML output (both new classes present and correct in the live dev-server markup) and exact CSS math against Tailwind's own token values, not a guess. Live visual confirmation is still recommended once the bridge is available. --- .../marketing/how-recall-works-section.tsx | 11 ++++++++- .../components/marketing/problem-section.tsx | 24 ++++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/marketing/how-recall-works-section.tsx b/apps/web/src/components/marketing/how-recall-works-section.tsx index 95a6a67..3d2aeb3 100644 --- a/apps/web/src/components/marketing/how-recall-works-section.tsx +++ b/apps/web/src/components/marketing/how-recall-works-section.tsx @@ -58,10 +58,19 @@ function StepCard({ step }: { step: Step }) { * 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 ( -
+
); } diff --git a/apps/web/src/components/marketing/problem-section.tsx b/apps/web/src/components/marketing/problem-section.tsx index 02f33b9..c836feb 100644 --- a/apps/web/src/components/marketing/problem-section.tsx +++ b/apps/web/src/components/marketing/problem-section.tsx @@ -1,6 +1,7 @@ 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.', @@ -21,11 +22,28 @@ const transcript = [ 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 }: { title: string; children: ReactNode }) { +function ComparisonCard({ title, children, muted }: ComparisonCardProps) { return (
-

{title}

+

+ {title} +

{children}
); @@ -57,7 +75,7 @@ export function ProblemSection() {
- +
{transcript.map((turn, index) => (
From e8a88b566644ab26b413376d948f2b927964b2a8 Mon Sep 17 00:00:00 2001 From: Sabahattin Kalkan Date: Fri, 7 Aug 2026 06:50:24 +0400 Subject: [PATCH 4/7] refine(web): reduce repeated UI patterns across the homepage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the approved items from the homepage narrative audit — removing duplication and diversifying visual language, not adding new sections, features, or copy. 1. Removed the older scaffold "How it works" (3-step: Scan/Rank/ Generate). It duplicated HowRecallWorksSection's 4-step flow with a different step count a few sections later on the same page — the single most credibility-damaging issue found in the audit (an engineer would read it as unedited). HowRecallWorksSection already covers this ground with more care; this is pure subtraction. 2. Redesigned "What Recall generates" from a 6-card bordered grid (FeatureCard, now unused and removed) into a typography-first definition list: a semantic `
` with `divide-y` hairlines between rows instead of six repeated `border + rounded-lg + p-6` boxes, generous per-row spacing (py-6/py-8), and a two-column dt/dd layout on wider viewports. Same six title/description pairs, same order, zero new copy — only the container changed. This was the single most generic-feeling section on the page (a textbook SaaS-grid pattern); it's the fourth consecutive card grid a scrolling visitor would otherwise see after Hero/Problem/Workflow. 3. Replaced HowRecallWorksSection's closing terminal block with MemoryFlow, a borderless data-flow line ("Repository → Architecture · Conventions · Decisions · Risks · Technical debt → Coding agent" plus "Context ready.") instead of a second `$ recall context` terminal. The Hero already demonstrates terminal output; repeating it two sections later added a second, weaker version of the same idea rather than a new one. Every label reused verbatim from existing copy elsewhere on the page (Hero, ProblemSection) — no new copy introduced. Uses `role="img"` + one descriptive `aria-label` (same accessible pattern as HeroTerminal), with the visual glyphs `aria-hidden`. Net effect: card-count in the Hero-to-comparison-table stretch of the page drops from 13 bordered boxes to 4 (the two ProblemSection cards and... nothing else — the step cards were the only bordered element left in HowRecallWorksSection after this change). No motion added anywhere; docs/design-system.md §10's ban on scroll-triggered reveal animations was treated as non-negotiable per its own stated authority. Validation: `pnpm --filter recall-web lint/typecheck/test/build` and root `pnpm lint/typecheck/test/build` all green (16/16 workspace tasks). Playwright's browser bridge was unresponsive for this entire session (same issue as the prior turn) — correctness was verified via the live dev server's rendered HTML instead (confirmed zero remaining occurrences of "How it works", the new `
` markup with all six features present, and MemoryFlow's exact output). A live visual pass is still recommended once the bridge reconnects. --- apps/web/src/app/(marketing)/page.tsx | 55 ++++-------------- .../src/components/marketing/feature-card.tsx | 17 ------ .../marketing/how-recall-works-section.tsx | 57 ++++++++++--------- 3 files changed, 41 insertions(+), 88 deletions(-) delete mode 100644 apps/web/src/components/marketing/feature-card.tsx diff --git a/apps/web/src/app/(marketing)/page.tsx b/apps/web/src/app/(marketing)/page.tsx index af693a4..25aaa45 100644 --- a/apps/web/src/app/(marketing)/page.tsx +++ b/apps/web/src/app/(marketing)/page.tsx @@ -4,7 +4,6 @@ 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 = [ { @@ -34,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', @@ -137,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/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 index 3d2aeb3..9ee1778 100644 --- a/apps/web/src/components/marketing/how-recall-works-section.tsx +++ b/apps/web/src/components/marketing/how-recall-works-section.tsx @@ -1,5 +1,4 @@ import { Fragment } from 'react'; -import { Check } from 'lucide-react'; import { Section } from '@/components/ui/section'; interface Step { @@ -31,12 +30,10 @@ const steps: Step[] = [ }, ]; -const workflowOutput = [ - 'Ranked relevant files', - 'Loaded architecture', - 'Loaded conventions', - 'Context ready', -]; +// 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 @@ -74,30 +71,36 @@ function StepConnector() { ); } -/** Matches HeroTerminal's visual treatment exactly (design-system.md §8: no fake chrome, no typing animation), constrained narrower to read as a compact confirmation rather than a second hero. */ -function WorkflowTerminal() { +/** + * 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 (
-

- $ - recall context \ -

-

- --task "Add password reset" +

+ Repository + → + {memoryCategories.join(' · ')} + → + Coding agent +
+

+ Context ready.

- -
    - {workflowOutput.map((line) => ( -
  • - - {line} -
  • - ))} -
); } @@ -126,7 +129,7 @@ export function HowRecallWorksSection() {
- +
); From a841faca06639afda88cdd7d0de004211fe59680 Mon Sep 17 00:00:00 2001 From: Sabahattin Kalkan Date: Fri, 7 Aug 2026 09:56:36 +0400 Subject: [PATCH 5/7] fix(web): restore visible keyboard focus ring on buttons, add selection color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buttonVariants carried focus-visible:outline-none with no replacement ring. Because Tailwind v4 compiles utilities into a layer declared after base, that class silently beat the global :focus-visible rule in globals.css, leaving the Hero's primary and secondary CTAs with no visible focus indicator on keyboard navigation — a violation of design-system.md §13. Removing the redundant override lets the existing global ring apply. Also adds the ::selection styling design-system.md §3 specifies but globals.css never implemented (accent at low opacity). --- apps/web/src/app/globals.css | 6 ++++++ apps/web/src/components/ui/button.tsx | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) 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/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: { From 03ca87817e96b0aaa67f307d82375dc3ba4ec5d8 Mon Sep 17 00:00:00 2001 From: Sabahattin Kalkan Date: Fri, 7 Aug 2026 12:11:31 +0400 Subject: [PATCH 6/7] test(core): stabilize Windows integration timeouts --- packages/core/src/__tests__/status.test.ts | 52 +++++++---- .../core/src/__tests__/symlink-safety.test.ts | 89 ++++++++++++------- .../src/__tests__/task-ranking.e2e.test.ts | 17 +++- packages/test-fixtures/src/git-helpers.ts | 19 +++- packages/test-fixtures/src/index.ts | 1 + packages/test-fixtures/src/timeouts.ts | 23 +++++ packages/test-fixtures/src/write-tree.ts | 17 ++-- 7 files changed, 158 insertions(+), 60 deletions(-) create mode 100644 packages/test-fixtures/src/timeouts.ts 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/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'); + }), + ); } From 560f32397ce81c0daff94422ae10d9c9ba9e1dba Mon Sep 17 00:00:00 2001 From: Sabahattin Kalkan Date: Fri, 7 Aug 2026 13:59:36 +0400 Subject: [PATCH 7/7] test(core): classify integration test timeouts --- packages/core/src/__tests__/context.test.ts | 64 +++++---- packages/core/src/__tests__/doctor.test.ts | 101 ++++++++++----- packages/core/src/__tests__/explain.test.ts | 9 +- packages/core/src/__tests__/init.test.ts | 137 ++++++++++++-------- packages/core/src/__tests__/update.test.ts | 118 +++++++++++------ 5 files changed, 278 insertions(+), 151 deletions(-) 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__/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, + ); });