Skip to content

fix(tui): replace flaky setTimeout(0) tick() with robust flush utility in TUI tests #1118

Description

@JeremyDev87

Problem

TUI integration tests intermittently fail in CI because tick() only waits one macrotask (setTimeout(resolve, 0)), but Ink (React) rendering sometimes needs 2-3 macrotask cycles to complete the event → state update → reconcile → render pipeline.

Example failure (PR #1117 CI, eventbus-ui.integration.spec.tsx:313):

expect(lastFrame()).toContain('RUNNING')
// Actual: full TUI frame rendered but still showing 'idle' instead of 'RUNNING'

Same test passes in PRs #1114 and #1115 — classic flaky timing issue.

Root Cause

// Current: single macrotask — not enough for Ink rendering pipeline
const tick = () => new Promise(resolve => setTimeout(resolve, 0));

This pattern is duplicated in 5 files with 74 await tick() call sites:

  • eventbus-ui.integration.spec.tsx (44 calls)
  • dashboard-app.spec.tsx (14 calls)
  • transport-tui.integration.spec.tsx (7 calls)
  • __perf__/memory-stability.spec.tsx (5 calls)
  • __perf__/rendering-performance.spec.tsx (4 calls)

Solution

1. Create shared test utility: apps/mcp-server/src/tui/testing/tui-test-utils.ts

/**
 * Flush multiple macrotask cycles so Ink completes its render pipeline.
 * Replaces the fragile single-setTimeout tick() used across TUI tests.
 */
export async function flushInk(cycles = 3): Promise<void> {
  for (let i = 0; i < cycles; i++) {
    await new Promise(resolve => setTimeout(resolve, 0));
  }
}

/**
 * Retry an assertion until it passes or times out.
 * Use for frame content assertions that depend on Ink render timing.
 */
export async function waitForFrame(
  getFrame: () => string | undefined,
  assertion: (frame: string) => void,
  timeoutMs = 500,
): Promise<void> {
  const start = Date.now();
  let lastError: Error | undefined;
  while (Date.now() - start < timeoutMs) {
    try {
      assertion(getFrame() ?? '');
      return;
    } catch (err) {
      lastError = err as Error;
      await new Promise(resolve => setTimeout(resolve, 10));
    }
  }
  throw lastError ?? new Error('waitForFrame timeout');
}

2. Replace tick() in all 5 files

// BEFORE (each file):
const tick = () => new Promise(resolve => setTimeout(resolve, 0));

// AFTER (each file):
import { flushInk } from './testing/tui-test-utils';
// or: import { flushInk } from '../testing/tui-test-utils';

Then replace await tick()await flushInk() across all 74 call sites.

3. (Optional) Migrate critical assertions to waitForFrame

For assertions most prone to flakiness (long tests with many sequential events), use waitForFrame:

// BEFORE:
await tick();
expect(lastFrame()).toContain('RUNNING');

// AFTER:
await waitForFrame(lastFrame, frame => expect(frame).toContain('RUNNING'));

Files to Modify

File Changes
src/tui/testing/tui-test-utils.ts CREATEflushInk() + waitForFrame()
src/tui/testing/tui-test-utils.spec.ts CREATE — unit tests for utilities
src/tui/eventbus-ui.integration.spec.tsx Replace tickflushInk (44 sites)
src/tui/dashboard-app.spec.tsx Replace tickflushInk (14 sites)
src/tui/transport-tui.integration.spec.tsx Replace tickflushInk (7 sites)
src/tui/__perf__/rendering-performance.spec.tsx Replace tickflushInk (4 sites)
src/tui/__perf__/memory-stability.spec.tsx Replace tickflushInk (5 sites)

Verification

# Run all TUI tests multiple times to confirm no flakiness
for i in {1..5}; do
  yarn workspace codingbuddy test -- --testPathPattern="src/tui/(eventbus|dashboard|transport|__perf__)" && echo "Run $i: PASS" || echo "Run $i: FAIL"
done

Acceptance Criteria

  • Shared flushInk() utility created with tests
  • No inline tick() definitions remain in TUI test files
  • All 74 call sites migrated to flushInk()
  • TUI tests pass 5 consecutive runs locally
  • CI passes

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions