diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..a8df5061d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,128 @@ +# Repository Guidelines + +ByteRover CLI (`brv`) - Interactive REPL with React/Ink TUI + +## Commands + +```bash +npm run build # Compile to dist/ +npm run dev # Kill daemon + build + run dev mode +npm test # All tests +npx mocha --forbid-only "test/path/to/file.test.ts" # Single test +npm run lint # ESLint +npm run typecheck # TypeScript type checking +./bin/dev.js [command] # Dev mode (ts-node) +./bin/run.js [command] # Prod mode +``` + +**Test dirs**: `test/commands/`, `test/unit/`, `test/integration/`, `test/hooks/`, `test/learning/`, `test/helpers/`, `test/shared/` +**Note**: Run tests from project root, not within test directories + +## Development Standards + +**TypeScript**: +- Avoid `as Type` assertions - use type guards or proper typing instead +- Avoid `any` type - use `unknown` with type narrowing or proper generics +- Functions with >3 parameters must use object parameters +- Prefer `type` for data-only shapes (DTOs, payloads, configs); prefer `interface` for behavioral contracts with method signatures (services, repositories, strategies) +- Default to `undefined` over `null`; reserve `null` only for external boundaries (storage, HTTP APIs) that force it — normalize to `undefined` before the value flows into internal modules +- Avoid `!` non-null assertions — narrow with type guards or throw explicitly. Lazy-initialized singletons (e.g. `this.services!.foo` after a guaranteed init step) are the only acceptable exception +- Use `??` for nullish defaults (not `||`, which also triggers on `0`/`''`/`false`) and `?.` for safe property access +- Prefer optional properties (`foo?: T`) over `foo: T | undefined` when a key may legitimately be absent + +**Testing (Strict TDD — MANDATORY)**: +- You MUST follow Test-Driven Development. This is non-negotiable. + - **Step 1 — Write failing tests FIRST**: Before writing ANY implementation code, write or update tests that describe the expected behavior. Do NOT write implementation and tests together or in reverse order. + - **Step 2 — Run tests to confirm they fail**: Execute the relevant test file to verify the new tests fail for the right reason (missing implementation, not a syntax error). + - **Step 3 — Write the minimal implementation**: Write only enough code to make the failing tests pass. Do not add untested behavior. + - **Step 4 — Run tests to confirm they pass**: Execute tests again to verify all tests pass. + - **Step 5 — Refactor if needed**: Clean up while keeping tests green. + - If you catch yourself writing implementation code without a failing test, STOP and write the test first. +- 80% coverage minimum, critical paths must be covered. +- Suppress console logging in tests to keep output clean. +- Unit tests must run fast and run completely in memory. Proper stubbing and mocking must be implemented. + +**Feature Development (Outside-In Approach — applies to ALL work: planning, reviewing, coding, and auditing)**: +- This is a foundational principle, not just a coding guideline. Apply it when writing code, reviewing plans, designing milestones, evaluating project structure, or auditing existing work. If a plan, project, or milestone ordering violates Outside-In, flag it. +- Start from the consumer (oclif command, REPL command, or TUI component) — understand what it needs +- Define the minimal interface — only what the consumer actually requires +- Implement the service — fulfill the interface contract +- Extract entities only if needed — when shared structure emerges across multiple consumers +- Avoid designing in isolation — always have a concrete consumer driving requirements +- When reviewing or planning: if entities, types, or store interfaces are designed before any consumer exists to validate them, that is Inside-Out and must be flagged + +## Architecture + +### Source Layout (`src/`) + +- `agent/` — LLM agent: `core/` (interfaces/domain), `infra/` (23 modules, including llm, memory, map, swarm, tools, document-parser), `resources/` (prompts YAML, tool `.txt` descriptions) +- `server/` — Daemon infrastructure: `config/`, `core/` (domain/interfaces), `infra/` (30 modules, including vc, git, hub, mcp, cogit, project, provider-oauth, space, dream), `templates/`, `utils/` +- `shared/` — Cross-module: constants, types, transport events, utils +- `tui/` — React/Ink TUI: app (router/pages), components, features (23 modules, including vc, worktree, source, hub, curate), hooks, lib, providers, stores +- `oclif/` — Commands grouped by topic (`vc/`, `hub/`, `worktree/`, `source/`, `space/`, `review/`, `connectors/`, `curate/`, `model/`, `providers/`, `swarm/`, `query-log/`) + top-level `.ts` commands; hooks, lib (daemon-client, task-client, json-response) + +**Import boundary** (ESLint-enforced): `tui/` must not import from `server/`, `agent/`, or `oclif/`. Use transport events or `shared/`. + +### REPL + TUI + +- `brv` (no args) starts REPL (`src/tui/repl-startup.tsx`) +- Esc cancels streaming responses and long-running commands +- Slash commands in `src/tui/features/commands/definitions/` (order in `index.ts` = suggestion order) + +### Daemon + +- Global daemon (`server/infra/daemon/`) hosts Socket.IO transport; clients connect via `@campfirein/brv-transport-client` +- Agent pool manages forked child processes per project; task routing in `server/infra/process/` +- MCP server in `server/infra/mcp/` exposes tools via Model Context Protocol; `tools/` subdir has dedicated implementations (`brv-query-tool`, `brv-curate-tool`) + +### VC, Worktrees & Knowledge Sources + +- `brv vc` — isomorphic-git version control (add, branch, checkout, clone, commit, config, fetch, init, log, merge, pull, push, remote, reset, status); git plumbing in `server/infra/git/` (`isomorphic-git-service.ts`), VC config store in `server/infra/vc/` +- `brv worktree` (add/list/remove) — git-style worktree pointer model: `.brv/` is either a real project directory OR a pointer file to a parent project; parent stores registry in `.brv/worktrees//link.json` +- `brv source` (add/list/remove) — link another project's context tree as a read-only knowledge source with write isolation +- `brv search ` — pure BM25 retrieval over the context tree (minisearch, no LLM, no token cost); structured results with paths/scores. Pairs with `brv query` (LLM-synthesized answer). Engine: `server/infra/executor/search-executor.ts` +- `brv locations` — lists all registered projects with context-tree status (text or `--format json`); reads from `LocationsEvents` over the daemon transport +- `brv query-log view [id]` / `brv query-log summary` — inspect query history and recall metrics (coverage, cache hit rate, top topics); store: `server/infra/storage/file-query-log-store.ts`, summary use-case in `server/infra/usecase/` +- `brv dream [--force] [--undo] [--detach]` — background context-tree consolidation (synthesize/consolidate/prune); engine: `server/infra/dream/` +- Canonical project resolver: `resolveProject()` in `server/infra/project/` — priority `flag > direct > linked > walked-up > null`. `projectRoot` and `worktreeRoot` are threaded through transport schemas, task routing, and all executors +- All commands are daemon-routed: `oclif/` and `tui/` never import from `server/` +- Oclif: `src/oclif/commands/{vc,worktree,source}/`; TUI: `src/tui/features/{vc,worktree,source}/`; slash commands (`vc-*`, `worktree`, `source`) in `src/tui/features/commands/definitions/` + +### Agent (`src/agent/`) + +- Tools: definitions in `resources/tools/*.txt`, implementations in `infra/tools/implementations/`, registry in `infra/tools/tool-registry.ts` +- Tool categories: file ops (read/write/edit/glob/grep/list-dir), bash (exec/output), knowledge (create/expand/search), memory (read/write/edit/delete/list), swarm (query/store), todos (read/write), curate, code exec, batch, detect domains, kill process, search history +- LLM: 18 providers in `infra/llm/providers/`; 6 compression strategies in `infra/llm/context/compression/` +- System prompts: contributor pattern (XML sections) in `infra/system-prompt/` +- Map/memory: `infra/map/` (agentic map, context-tree store, LLM map memory, worker pool); `infra/memory/` (memory-manager, deduplicator) +- Storage: file-based blob (`infra/blob/`) and key storage (`infra/storage/`) — no SQLite + +### Swarm (`src/agent/infra/swarm/`, `src/oclif/commands/swarm/`) + +- Multi-provider memory/knowledge federation: routes queries and writes across pluggable adapters (byterover, gbrain, local-markdown, memory-wiki, obsidian) +- `brv swarm query` — RRF-fused search across providers; flags: `--explain`, `--format`, `-n` +- `brv swarm curate` — auto-routes content to best provider; flags: `--provider`, `--format` +- `brv swarm onboard` — interactive wizard (`@inquirer/prompts`) to scaffold swarm config; uses snake_case YAML keys (`eslint-disable camelcase`) +- `brv swarm status` — pre-flight health check for configured providers +- Agent tools: `swarm_query.txt`, `swarm_store.txt` in `resources/tools/` +- Config: `swarm/config/` (loader + schema), `swarm/validation/` (config validator) +- CLI-only (oclif) — no TUI feature dir; swarm queries flow through existing `tui/features/query/` + +## Testing Gotchas + +- **HTTP (nock)**: Must verify `.matchHeader('authorization', ...)` + `.matchHeader('x-byterover-session-id', ...)` +- **ES Modules**: Cannot stub ES exports with sinon; test utils with real filesystem (`tmpdir()`) + +## Conventions + +- ES modules with `.js` import extensions required +- `I` prefix for interfaces; `toJson()`/`fromJson()` (capital J) for serialization +- Snake_case APIs: `/* eslint-disable camelcase */` + +## Environment + +- `BRV_ENV` — `development` | `production` (dev-only commands require `development`, set by `bin/dev.js` and `bin/run.js`) + +## Stack + +oclif v4, TypeScript (ES2022, Node16 modules, strict), React/Ink (TUI), Zustand, axios, socket.io, isomorphic-git, Mocha + Chai + Sinon + Nock diff --git a/test/e2e/helpers/brv-e2e-helper.test.ts b/test/e2e/helpers/brv-e2e-helper.test.ts new file mode 100644 index 000000000..144bc73e6 --- /dev/null +++ b/test/e2e/helpers/brv-e2e-helper.test.ts @@ -0,0 +1,158 @@ +import {expect} from 'chai' +import {existsSync, readFileSync} from 'node:fs' +import {join} from 'node:path' + +import type {E2eConfig} from './env-guard.js' + +import {BrvE2eHelper} from './brv-e2e-helper.js' +import {getE2eConfig, requireE2eEnv} from './env-guard.js' + +const dummyConfig: E2eConfig = { + apiBaseUrl: 'http://localhost:0', + apiKey: 'test-key', + cogitApiBaseUrl: 'http://localhost:0', + gitRemoteBaseUrl: 'http://localhost:0', + llmApiBaseUrl: 'http://localhost:0', + webAppUrl: 'http://localhost:0', +} + +describe('BrvE2EHelper', () => { + describe('mechanics', () => { + let helper: BrvE2eHelper + + beforeEach(() => { + helper = new BrvE2eHelper(dummyConfig) + }) + + afterEach(async () => { + await helper.cleanup() + }) + + it('should instantiate with E2eConfig', () => { + expect(helper).to.be.instanceOf(BrvE2eHelper) + }) + + it('should throw when accessing cwd before setup()', () => { + expect(() => helper.cwd).to.throw('setup() must be called') + }) + + it('should create a temp directory with .brv/config.json on setup()', async () => { + await helper.setup() + + expect(helper.cwd).to.be.a('string').that.is.not.empty + expect(existsSync(helper.cwd)).to.be.true + + const configPath = join(helper.cwd, '.brv', 'config.json') + expect(existsSync(configPath)).to.be.true + + const config = JSON.parse(readFileSync(configPath, 'utf8')) + expect(config).to.deep.equal({version: '0.0.1'}) + }) + + it('should remove the temp directory on cleanup()', async () => { + await helper.setup() + const dir = helper.cwd + + await helper.cleanup() + + expect(existsSync(dir)).to.be.false + expect(() => helper.cwd).to.throw('setup() must be called') + }) + + it('should run all registered teardown functions during cleanup() in reverse order', async () => { + await helper.setup() + + const order: number[] = [] + helper.onTeardown(async () => { order.push(1) }) + helper.onTeardown(async () => { order.push(2) }) + helper.onTeardown(async () => { order.push(3) }) + + await helper.cleanup() + + expect(order).to.deep.equal([3, 2, 1]) + }) + + it('should be safe to call cleanup() multiple times', async () => { + await helper.setup() + await helper.cleanup() + await helper.cleanup() // should not throw + }) + + it('should still cleanup temp dir if a teardown throws', async () => { + await helper.setup() + const dir = helper.cwd + + const ran: number[] = [] + helper.onTeardown(async () => { ran.push(1) }) + helper.onTeardown(async () => { throw new Error('teardown failed') }) + helper.onTeardown(async () => { ran.push(3) }) + + // cleanup should not throw despite the failing teardown + await helper.cleanup() + + expect(existsSync(dir)).to.be.false + expect(ran).to.deep.equal([3, 1]) // reverse order, skipping the one that threw + }) + + it('should run a CLI command and return the result', async () => { + await helper.setup() + + const result = await helper.run('--help') + + expect(result.exitCode).to.equal(0) + expect(result.stdout).to.include('USAGE') + expect(result.stderr).to.be.a('string') + }) + + it('should throw when runJson() receives non-JSON output', async () => { + await helper.setup() + + try { + await helper.runJson('--help') + expect.fail('should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(Error) + expect((error as Error).message).to.include('No valid JSON') + } + }) + }) + + describe('auth (requires E2E env)', () => { + before(requireE2eEnv) + + let helper: BrvE2eHelper + + beforeEach(async () => { + const config = getE2eConfig() + helper = new BrvE2eHelper(config) + await helper.setup() + }) + + afterEach(async () => { + await helper.cleanup() + }) + + it('should login with the configured API key', async () => { + const result = await helper.login() + + // login() returns void on success, throws on failure + expect(result).to.be.undefined + }) + + it('should logout after login', async () => { + await helper.login() + const result = await helper.logout() + + expect(result).to.be.undefined + }) + + it('should parse JSON response via runJson()', async () => { + const result = await helper.runJson<{userEmail?: string}>('login', ['--api-key', getE2eConfig().apiKey]) + + expect(result).to.have.property('command', 'login') + expect(result).to.have.property('success').that.is.a('boolean') + expect(result).to.have.property('data').that.is.an('object') + expect(result).to.have.property('timestamp').that.is.a('string') + }) + }) +}) diff --git a/test/e2e/helpers/brv-e2e-helper.ts b/test/e2e/helpers/brv-e2e-helper.ts new file mode 100644 index 000000000..8bde4bd82 --- /dev/null +++ b/test/e2e/helpers/brv-e2e-helper.ts @@ -0,0 +1,123 @@ +import {mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import type {CLIResult} from './cli-runner.js' +import type {E2eConfig} from './env-guard.js' + +import {BRV_CONFIG_VERSION, BRV_DIR, PROJECT_CONFIG_FILE} from '../../../src/server/constants.js' +import {runBrv} from './cli-runner.js' + +export type JsonResult = { + command: string + data: T + success: boolean + timestamp: string +} + +export type RunOptions = { + env?: Record + timeout?: number +} + +export class BrvE2eHelper { + private _cwd: string | undefined + private readonly config: E2eConfig + private teardowns: Array<() => Promise> = [] + + constructor(config: E2eConfig) { + this.config = config + } + + get cwd(): string { + if (!this._cwd) { + throw new Error('setup() must be called before accessing cwd') + } + + return this._cwd + } + + async cleanup(): Promise { + if (!this._cwd) return + + const dir = this._cwd + + // Run teardowns in reverse order (LIFO), continue even if one throws + for (let i = this.teardowns.length - 1; i >= 0; i--) { + try { + // eslint-disable-next-line no-await-in-loop + await this.teardowns[i]() + } catch { + // Swallow — cleanup must always complete + } + } + + this.teardowns = [] + this._cwd = undefined + rmSync(dir, {force: true, recursive: true}) + } + + async login(): Promise { + const result = await this.runJson('login', ['--api-key', this.config.apiKey]) + if (!result.success) { + throw new Error(`Login failed: ${JSON.stringify(result.data)}`) + } + + // Auto-register logout as teardown + this.onTeardown(async () => { + try { + await this.runJson('logout') + } catch { + // Best-effort logout during cleanup + } + }) + } + + async logout(): Promise { + const result = await this.runJson('logout') + if (!result.success) { + throw new Error(`Logout failed: ${JSON.stringify(result.data)}`) + } + } + + onTeardown(fn: () => Promise): void { + this.teardowns.push(fn) + } + + async run(command: string, args?: string[], opts?: RunOptions): Promise { + return runBrv({ + args: [command, ...(args ?? [])], + config: this.config, + cwd: this.cwd, + ...opts, + }) + } + + async runJson(command: string, args?: string[], opts?: RunOptions): Promise> { + const result = await this.run(command, [...(args ?? []), '--format', 'json'], opts) + const lines = result.stdout.trim().split('\n') + + // Find the last valid JSON line (CLI may print non-JSON before it) + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim() + if (!line) continue + try { + return JSON.parse(line) as JsonResult + } catch { + // Not JSON, try next line + } + } + + throw new Error(`No valid JSON found in CLI output.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`) + } + + async setup(): Promise { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'brv-e2e-'))) + const brvDir = join(dir, BRV_DIR) + + mkdirSync(brvDir, {recursive: true}) + writeFileSync(join(brvDir, PROJECT_CONFIG_FILE), JSON.stringify({version: BRV_CONFIG_VERSION})) + + this._cwd = dir + } +} diff --git a/test/e2e/helpers/cli-runner.test.ts b/test/e2e/helpers/cli-runner.test.ts new file mode 100644 index 000000000..786817f52 --- /dev/null +++ b/test/e2e/helpers/cli-runner.test.ts @@ -0,0 +1,45 @@ +import {expect} from 'chai' + +import type {E2eConfig} from './env-guard.js' + +import {runBrv} from './cli-runner.js' + +const dummyConfig: E2eConfig = { + apiBaseUrl: 'http://localhost:0', + apiKey: 'test-key', + cogitApiBaseUrl: 'http://localhost:0', + gitRemoteBaseUrl: 'http://localhost:0', + llmApiBaseUrl: 'http://localhost:0', + webAppUrl: 'http://localhost:0', +} + +describe('runBrv', () => { + it('should capture stdout from a successful command', async () => { + const result = await runBrv({args: ['--help'], config: dummyConfig}) + + expect(result.exitCode).to.equal(0) + expect(result.stdout).to.be.a('string').and.to.include('USAGE') + expect(result.stderr).to.be.a('string') + }) + + it('should return non-zero exit code for invalid commands without throwing', async () => { + const result = await runBrv({args: ['nonexistent-command-xyz'], config: dummyConfig}) + + expect(result.exitCode).to.not.equal(0) + expect(result.stderr).to.be.a('string').that.is.not.empty + }) + + it('should pass command arguments correctly', async () => { + const result = await runBrv({args: ['login', '--help'], config: dummyConfig}) + + expect(result.exitCode).to.equal(0) + expect(result.stdout).to.include('api-key') + }) + + it('should accept a custom timeout option', async () => { + const result = await runBrv({args: ['--help'], config: dummyConfig, timeout: 30_000}) + + expect(result.exitCode).to.equal(0) + expect(result.stdout).to.include('USAGE') + }) +}) diff --git a/test/e2e/helpers/cli-runner.ts b/test/e2e/helpers/cli-runner.ts new file mode 100644 index 000000000..f5146419b --- /dev/null +++ b/test/e2e/helpers/cli-runner.ts @@ -0,0 +1,61 @@ +import {execFile} from 'node:child_process' +import {dirname, resolve} from 'node:path' +import {fileURLToPath} from 'node:url' + +import type {E2eConfig} from './env-guard.js' + +export type CLIResult = { + exitCode: number + stderr: string + stdout: string +} + +export type RunBrvOptions = { + args: string[] + config: E2eConfig + cwd?: string + env?: Record + timeout?: number +} + +const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') +const BIN_DEV_PATH = resolve(PROJECT_ROOT, 'bin', 'dev.js') +// Resolve tsx from project root so it works even when cwd is a temp dir +const TSX_IMPORT_PATH = resolve(PROJECT_ROOT, 'node_modules', 'tsx', 'dist', 'esm', 'index.mjs') + +export function runBrv(opts: RunBrvOptions): Promise { + const {args, config, cwd, env, timeout = 60_000} = opts + + const childEnv: Record = { + ...process.env as Record, + BRV_API_BASE_URL: config.apiBaseUrl, + BRV_COGIT_API_BASE_URL: config.cogitApiBaseUrl, + BRV_E2E_API_KEY: config.apiKey, + BRV_ENV: 'development', + BRV_GIT_REMOTE_BASE_URL: config.gitRemoteBaseUrl, + BRV_LLM_API_BASE_URL: config.llmApiBaseUrl, + BRV_WEB_APP_URL: config.webAppUrl, + ...env, + } + + // Use node explicitly with tsx import path instead of the shebang, + // so tsx resolves correctly regardless of the child process cwd + const nodeArgs = ['--import', TSX_IMPORT_PATH, '--no-warnings', BIN_DEV_PATH, ...args] + + return new Promise((resolve) => { + execFile(process.execPath, nodeArgs, {cwd, env: childEnv, maxBuffer: 10 * 1024 * 1024, timeout}, (error, stdout, stderr) => { + if (error) { + // execFile rejects on non-zero exit — extract result instead of throwing + const exitCode = typeof error.code === 'number' ? error.code : 1 + resolve({ + exitCode, + stderr: stderr || error.message, + stdout: stdout || '', + }) + return + } + + resolve({exitCode: 0, stderr: stderr || '', stdout: stdout || ''}) + }) + }) +} diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index 5ecfc4086..a1c2a01c5 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -1,2 +1,7 @@ +export type {JsonResult, RunOptions} from './brv-e2e-helper.js' +export {BrvE2eHelper} from './brv-e2e-helper.js' +export type {CLIResult, RunBrvOptions} from './cli-runner.js' +export {runBrv} from './cli-runner.js' export type {E2eConfig} from './env-guard.js' export {getE2eConfig, requireE2eEnv} from './env-guard.js' +export {retry, waitUntil} from './retry.js' diff --git a/test/e2e/helpers/retry.test.ts b/test/e2e/helpers/retry.test.ts new file mode 100644 index 000000000..e2dd21dd4 --- /dev/null +++ b/test/e2e/helpers/retry.test.ts @@ -0,0 +1,124 @@ +import {expect} from 'chai' + +import {retry, waitUntil} from './retry.js' + +describe('retry utilities', () => { + describe('retry', () => { + it('should return the result when fn succeeds on first attempt', async () => { + const result = await retry(() => Promise.resolve('ok')) + expect(result).to.equal('ok') + }) + + it('should retry on failure then return result when fn succeeds within limit', async () => { + let calls = 0 + const fn = () => { + calls++ + if (calls < 3) throw new Error(`fail #${calls}`) + return Promise.resolve('recovered') + } + + const result = await retry(fn, {delay: 10, retries: 3}) + expect(result).to.equal('recovered') + expect(calls).to.equal(3) + }) + + it('should throw the last error after all retries are exhausted', async () => { + let calls = 0 + const fn = () => { + calls++ + return Promise.reject(new Error(`fail #${calls}`)) + } + + try { + await retry(fn, {delay: 10, retries: 2}) + expect.fail('should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(Error) + expect((error as Error).message).to.equal('fail #3') + expect(calls).to.equal(3) // 1 initial + 2 retries + } + }) + + it('should respect custom retries count', async () => { + let calls = 0 + const fn = () => { + calls++ + return Promise.reject(new Error('always fails')) + } + + try { + await retry(fn, {delay: 10, retries: 1}) + expect.fail('should have thrown') + } catch { + expect(calls).to.equal(2) // 1 initial + 1 retry + } + }) + + it('should respect custom delay between retries', async () => { + let calls = 0 + const fn = () => { + calls++ + if (calls < 3) return Promise.reject(new Error('fail')) + return Promise.resolve('done') + } + + const start = Date.now() + await retry(fn, {delay: 50, retries: 3}) + const elapsed = Date.now() - start + + // 2 retries * 50ms delay = at least 100ms + expect(elapsed).to.be.at.least(80) // small margin for timer imprecision + }) + + it('should not retry when retries is 0', async () => { + let calls = 0 + const fn = () => { + calls++ + return Promise.reject(new Error('immediate fail')) + } + + try { + await retry(fn, {delay: 10, retries: 0}) + expect.fail('should have thrown') + } catch (error) { + expect(calls).to.equal(1) + expect((error as Error).message).to.equal('immediate fail') + } + }) + }) + + describe('waitUntil', () => { + it('should resolve when predicate returns true', async () => { + let calls = 0 + + await waitUntil( + () => { + calls++ + return Promise.resolve(calls >= 3) + }, + {interval: 10, timeout: 1000}, + ) + expect(calls).to.be.at.least(3) + }) + + it('should throw when timeout is exceeded', async () => { + try { + await waitUntil(() => Promise.resolve(false), {interval: 20, timeout: 100}) + expect.fail('should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(Error) + expect((error as Error).message).to.include('timed out') + } + }) + + it('should propagate errors thrown by the predicate', async () => { + try { + await waitUntil(() => Promise.reject(new Error('predicate exploded')), {interval: 10, timeout: 1000}) + expect.fail('should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(Error) + expect((error as Error).message).to.equal('predicate exploded') + } + }) + }) +}) diff --git a/test/e2e/helpers/retry.ts b/test/e2e/helpers/retry.ts new file mode 100644 index 000000000..db08eb5a7 --- /dev/null +++ b/test/e2e/helpers/retry.ts @@ -0,0 +1,43 @@ +const sleep = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms) + }) + +export async function retry(fn: () => Promise, opts?: {delay?: number; retries?: number}): Promise { + const retries = opts?.retries ?? 3 + const delay = opts?.delay ?? 250 + let lastError: unknown + + for (let attempt = 0; attempt <= retries; attempt++) { + try { + // eslint-disable-next-line no-await-in-loop + return await fn() + } catch (error) { + lastError = error + if (attempt < retries) { + // eslint-disable-next-line no-await-in-loop + await sleep(delay) + } + } + } + + throw lastError +} + +export async function waitUntil( + fn: () => Promise, + opts?: {interval?: number; timeout?: number}, +): Promise { + const timeout = opts?.timeout ?? 10_000 + const interval = opts?.interval ?? 250 + const start = Date.now() + + while (Date.now() - start < timeout) { + // eslint-disable-next-line no-await-in-loop + if (await fn()) return + // eslint-disable-next-line no-await-in-loop + await sleep(interval) + } + + throw new Error(`waitUntil timed out after ${timeout}ms`) +}