diff --git a/README.md b/README.md index c4ac8733..17c93f90 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,12 @@ rli mcp start # Start the MCP server rli mcp install # Install Runloop MCP server configurat... ``` +### Axon Commands + +```bash +rli axon list # List active axons +``` + ### Scenario Commands (alias: `scn`) ```bash diff --git a/package.json b/package.json index c4bcb9dd..4d34ddb4 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "dependencies": { "@js-temporal/polyfill": "^0.5.1", "@modelcontextprotocol/sdk": "^1.26.0", - "@runloop/api-client": "1.10.3", + "@runloop/api-client": "1.16.0", "@types/express": "^5.0.6", "adm-zip": "^0.5.16", "chalk": "^5.6.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39ffcf1f..22ca7b14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^1.26.0 version: 1.26.0(zod@4.3.6) '@runloop/api-client': - specifier: 1.10.3 - version: 1.10.3 + specifier: 1.16.0 + version: 1.16.0 '@types/express': specifier: ^5.0.6 version: 5.0.6 @@ -695,8 +695,8 @@ packages: '@cfworker/json-schema': optional: true - '@runloop/api-client@1.10.3': - resolution: {integrity: sha512-LtTgHZP72MOxVQPGbh0rT4VTTc5gAYRHAH6Wksr2aahPSVXMsa9wY+KqRGuNlTdJYf1QGsJXbAFcHA34sfOFrg==} + '@runloop/api-client@1.16.0': + resolution: {integrity: sha512-4oprwxIeqEKooxaTqGXbcySypg7Obg2hyh62bxRqAWWxc8lUsBKZLK8ZcvdSyEk/g1nDeeGPdteTkDNNtP/3+g==} '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -3845,7 +3845,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@runloop/api-client@1.10.3': + '@runloop/api-client@1.16.0': dependencies: '@types/node': 18.19.130 '@types/node-fetch': 2.6.13 diff --git a/src/commands/axon/list.ts b/src/commands/axon/list.ts new file mode 100644 index 00000000..d27702fe --- /dev/null +++ b/src/commands/axon/list.ts @@ -0,0 +1,106 @@ +/** + * List active axons (beta) + */ + +import chalk from "chalk"; +import { formatTimeAgo } from "../../components/ResourceListView.js"; +import { listActiveAxons, type Axon } from "../../services/axonService.js"; +import { output, outputError, parseLimit } from "../../utils/output.js"; + +interface ListOptions { + limit?: string; + startingAfter?: string; + output?: string; +} + +const PAGE_SIZE = 100; + +function printTable(axons: Axon[]): void { + if (axons.length === 0) { + console.log(chalk.dim("No active axons found")); + return; + } + + const COL_ID = 34; + const COL_NAME = 28; + const COL_CREATED = 12; + + const header = + "ID".padEnd(COL_ID) + + " " + + "NAME".padEnd(COL_NAME) + + " " + + "CREATED".padEnd(COL_CREATED); + console.log(chalk.bold(header)); + console.log(chalk.dim("─".repeat(header.length))); + + for (const axon of axons) { + const id = + axon.id.length > COL_ID ? axon.id.slice(0, COL_ID - 1) + "…" : axon.id; + const nameRaw = axon.name ?? ""; + const name = + nameRaw.length > COL_NAME + ? nameRaw.slice(0, COL_NAME - 1) + "…" + : nameRaw; + const created = formatTimeAgo(axon.created_at_ms); + console.log( + `${id.padEnd(COL_ID)} ${name.padEnd(COL_NAME)} ${created.padEnd(COL_CREATED)}`, + ); + } + + console.log(); + console.log( + chalk.dim(`${axons.length} axon${axons.length !== 1 ? "s" : ""}`), + ); +} + +export async function listAxonsCommand(options: ListOptions): Promise { + try { + const maxResults = parseLimit(options.limit); + const format = options.output || "text"; + + let axons: Axon[]; + + if (options.startingAfter) { + const pageLimit = maxResults === Infinity ? PAGE_SIZE : maxResults; + const { axons: page, hasMore } = await listActiveAxons({ + limit: pageLimit, + startingAfter: options.startingAfter, + }); + axons = page; + if (format === "text" && hasMore && axons.length > 0) { + console.log( + chalk.dim( + "More results may be available; use --starting-after with the last ID to continue.", + ), + ); + console.log(); + } + } else { + const all: Axon[] = []; + let cursor: string | undefined; + while (all.length < maxResults) { + const remaining = maxResults - all.length; + const pageLimit = Math.min(PAGE_SIZE, remaining); + const { axons: page, hasMore } = await listActiveAxons({ + limit: pageLimit, + startingAfter: cursor, + }); + all.push(...page); + if (!hasMore || page.length === 0) { + break; + } + cursor = page[page.length - 1].id; + } + axons = all; + } + + if (format !== "text") { + output(axons, { format, defaultFormat: "json" }); + } else { + printTable(axons); + } + } catch (error) { + outputError("Failed to list active axons", error); + } +} diff --git a/src/commands/benchmark-job/logs.ts b/src/commands/benchmark-job/logs.ts index aa6f62a5..87622a91 100644 --- a/src/commands/benchmark-job/logs.ts +++ b/src/commands/benchmark-job/logs.ts @@ -100,7 +100,10 @@ function buildScenarioOutcomeMap( const map = new Map(); for (const outcome of job.benchmark_outcomes || []) { for (const scenario of outcome.scenario_outcomes || []) { - map.set(scenario.scenario_run_id, scenario); + const runId = scenario.scenario_run_id; + if (runId) { + map.set(runId, scenario); + } } } return map; diff --git a/src/components/DevboxDetailPage.tsx b/src/components/DevboxDetailPage.tsx index b37c8796..dd75415b 100644 --- a/src/components/DevboxDetailPage.tsx +++ b/src/components/DevboxDetailPage.tsx @@ -12,7 +12,7 @@ import { type DetailSection, type ResourceOperation, } from "./ResourceDetailPage.js"; -import { getDevboxUrl } from "../utils/url.js"; +import { getDevboxUrl, getDevboxTunnelUrlPattern } from "../utils/url.js"; import { colors } from "../utils/theme.js"; import { formatTimeAgo } from "../utils/time.js"; import { getMcpConfig } from "../services/mcpConfigService.js"; @@ -339,7 +339,7 @@ export const DevboxDetailPage = ({ devbox, onBack }: DevboxDetailPageProps) => { if (devbox.tunnel && devbox.tunnel.tunnel_key) { const tunnelKey = devbox.tunnel.tunnel_key; const authMode = devbox.tunnel.auth_mode; - const tunnelUrl = `https://{port}-${tunnelKey}.tunnel.runloop.ai`; + const tunnelUrl = getDevboxTunnelUrlPattern(tunnelKey); detailFields.push({ label: "Tunnel", @@ -651,7 +651,7 @@ export const DevboxDetailPage = ({ devbox, onBack }: DevboxDetailPageProps) => { , ); - const tunnelUrl = `https://{port}-${devbox.tunnel.tunnel_key}.tunnel.runloop.ai`; + const tunnelUrl = getDevboxTunnelUrlPattern(devbox.tunnel.tunnel_key); lines.push( {" "} diff --git a/src/services/axonService.ts b/src/services/axonService.ts new file mode 100644 index 00000000..ef23ae50 --- /dev/null +++ b/src/services/axonService.ts @@ -0,0 +1,47 @@ +/** + * Axon service — active axons listing (beta API) + */ +import { getClient } from "../utils/client.js"; +import type { AxonView } from "@runloop/api-client/resources/axons/axons"; +import type { AxonsCursorIDPage } from "@runloop/api-client/pagination"; + +export type Axon = AxonView; + +export interface ListActiveAxonsOptions { + limit?: number; + startingAfter?: string; +} + +export interface ListActiveAxonsResult { + axons: Axon[]; + hasMore: boolean; +} + +/** + * List active axons with optional cursor pagination (`limit`, `starting_after`). + */ +export async function listActiveAxons( + options: ListActiveAxonsOptions, +): Promise { + const client = getClient(); + + const query: { + limit?: number; + starting_after?: string; + } = {}; + if (options.limit !== undefined) { + query.limit = options.limit; + } + if (options.startingAfter) { + query.starting_after = options.startingAfter; + } + + const page = (await client.axons.list( + Object.keys(query).length > 0 ? query : undefined, + )) as AxonsCursorIDPage; + + const axons = page.axons || []; + const hasMore = page.has_more || false; + + return { axons, hasMore }; +} diff --git a/src/services/devboxService.ts b/src/services/devboxService.ts index 8c93b5da..3684e705 100644 --- a/src/services/devboxService.ts +++ b/src/services/devboxService.ts @@ -3,6 +3,7 @@ * Returns plain data objects with no SDK reference retention */ import { getClient } from "../utils/client.js"; +import { getTunnelBaseHost } from "../utils/url.js"; import type { Devbox } from "../store/devboxStore.js"; import type { DevboxesCursorIDPage } from "@runloop/api-client/pagination"; import type { @@ -254,17 +255,18 @@ export async function createSSHKey(id: string): Promise<{ } /** - * Create tunnel to devbox + * Enable V2 HTTP tunnel on devbox and return the public URL for the given port. */ export async function createTunnel( id: string, port: number, ): Promise<{ url: string }> { const client = getClient(); - const tunnel = await client.devboxes.createTunnel(id, { port }); + const tunnel = await client.devboxes.enableTunnel(id); + const url = `https://${port}-${tunnel.tunnel_key}.${getTunnelBaseHost()}`; return { - url: String((tunnel as any).url || "").substring(0, 500), + url: url.substring(0, 500), }; } diff --git a/src/utils/commands.ts b/src/utils/commands.ts index 0a25c86a..c1d4a1b4 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -1012,6 +1012,26 @@ export function createProgram(): Command { await installMcpConfig(); }); + // Axon commands (beta) + const axon = program.command("axon").description("Manage axons (beta)"); + + axon + .command("list") + .description("List active axons") + .option("--limit ", "Max axons to return (0 = unlimited)", "0") + .option( + "--starting-after ", + "Starting point for cursor pagination (axon ID)", + ) + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: text)", + ) + .action(async (options) => { + const { listAxonsCommand } = await import("../commands/axon/list.js"); + await listAxonsCommand(options); + }); + // Scenario commands const scenario = program .command("scenario") diff --git a/src/utils/url.ts b/src/utils/url.ts index 4c50f672..d856f190 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -42,3 +42,18 @@ export function getSettingsUrl(): string { const baseUrl = getBaseUrl(); return `${baseUrl}/settings`; } + +/** + * Hostname for V2 devbox tunnel URLs (matches RUNLOOP_ENV / API host). + */ +export function getTunnelBaseHost(): string { + const env = process.env.RUNLOOP_ENV?.toLowerCase(); + return env === "dev" ? "tunnel.runloop.pro" : "tunnel.runloop.ai"; +} + +/** + * Tunnel URL pattern with a literal `{port}` placeholder for display. + */ +export function getDevboxTunnelUrlPattern(tunnelKey: string): string { + return `https://{port}-${tunnelKey}.${getTunnelBaseHost()}`; +} diff --git a/tests/__tests__/components/ResourcePicker.test.tsx b/tests/__tests__/components/ResourcePicker.test.tsx index 344858f5..1b46b660 100644 --- a/tests/__tests__/components/ResourcePicker.test.tsx +++ b/tests/__tests__/components/ResourcePicker.test.tsx @@ -2,11 +2,15 @@ * Tests for ResourcePicker component * Focuses on single-select vs multi-select modes and checkbox display */ -import React from 'react'; -import { jest } from '@jest/globals'; -import { render } from 'ink-testing-library'; -import { ResourcePicker, ResourcePickerConfig, Column } from '../../../src/components/ResourcePicker.js'; -import { Text } from 'ink'; +import React from "react"; +import { jest } from "@jest/globals"; +import { render } from "ink-testing-library"; +import { + ResourcePicker, + ResourcePickerConfig, + Column, +} from "../../../src/components/ResourcePicker.js"; +import { Text } from "ink"; interface TestItem { id: string; @@ -15,11 +19,33 @@ interface TestItem { } const testItems: TestItem[] = [ - { id: 'item_1', name: 'First Item', status: 'active' }, - { id: 'item_2', name: 'Second Item', status: 'inactive' }, - { id: 'item_3', name: 'Third Item', status: 'pending' }, + { id: "item_1", name: "First Item", status: "active" }, + { id: "item_2", name: "Second Item", status: "inactive" }, + { id: "item_3", name: "Third Item", status: "pending" }, ]; +/** Wait until ink-testing-library output matches (async fetch + React updates can exceed fixed delays on CI). */ +async function waitForFrame( + lastFrame: () => string | undefined, + predicate: (frame: string) => boolean, + options: { timeoutMs?: number; intervalMs?: number } = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? 10_000; + const intervalMs = options.intervalMs ?? 10; + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const frame = lastFrame() || ""; + if (predicate(frame)) { + return frame; + } + await new Promise((r) => setTimeout(r, intervalMs)); + } + const last = lastFrame() || ""; + throw new Error( + `Timeout waiting for frame after ${timeoutMs}ms. Last (truncated): ${JSON.stringify(last.slice(0, 300))}`, + ); +} + // Mock fetch function that returns test items const createMockFetchPage = (items: TestItem[] = testItems) => { return jest.fn().mockResolvedValue({ @@ -31,34 +57,34 @@ const createMockFetchPage = (items: TestItem[] = testItems) => { // Base config for single-select mode const createSingleSelectConfig = ( - fetchPage = createMockFetchPage() + fetchPage = createMockFetchPage(), ): ResourcePickerConfig => ({ - title: 'Select Item', + title: "Select Item", fetchPage, getItemId: (item) => item.id, getItemLabel: (item) => item.name, getItemStatus: (item) => item.status, - mode: 'single', - emptyMessage: 'No items found', - searchPlaceholder: 'Search items...', + mode: "single", + emptyMessage: "No items found", + searchPlaceholder: "Search items...", }); // Base config for multi-select mode const createMultiSelectConfig = ( - fetchPage = createMockFetchPage() + fetchPage = createMockFetchPage(), ): ResourcePickerConfig => ({ - title: 'Select Items', + title: "Select Items", fetchPage, getItemId: (item) => item.id, getItemLabel: (item) => item.name, getItemStatus: (item) => item.status, - mode: 'multi', + mode: "multi", minSelection: 1, - emptyMessage: 'No items found', - searchPlaceholder: 'Search items...', + emptyMessage: "No items found", + searchPlaceholder: "Search items...", }); -describe('ResourcePicker', () => { +describe("ResourcePicker", () => { const mockOnSelect = jest.fn(); const mockOnCancel = jest.fn(); @@ -66,102 +92,94 @@ describe('ResourcePicker', () => { jest.clearAllMocks(); }); - describe('basic rendering', () => { - it('renders without crashing in single-select mode', async () => { + describe("basic rendering", () => { + it("renders without crashing in single-select mode", async () => { const { lastFrame } = render( + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - expect(lastFrame()).toBeTruthy(); + const frame = await waitForFrame(lastFrame, (f) => + f.includes("First Item"), + ); + expect(frame).toBeTruthy(); }); - it('renders without crashing in multi-select mode', async () => { + it("renders without crashing in multi-select mode", async () => { const { lastFrame } = render( + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - expect(lastFrame()).toBeTruthy(); + const frame = await waitForFrame(lastFrame, (f) => + f.includes("First Item"), + ); + expect(frame).toBeTruthy(); }); - it('shows loading state initially', () => { + it("shows loading state initially", () => { const { lastFrame } = render( + />, ); - const frame = lastFrame() || ''; - expect(frame).toContain('Loading'); + const frame = lastFrame() || ""; + expect(frame).toContain("Loading"); }); - it('displays items after loading', async () => { + it("displays items after loading", async () => { const { lastFrame } = render( + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - const frame = lastFrame() || ''; - expect(frame).toContain('First Item'); - expect(frame).toContain('Second Item'); - expect(frame).toContain('Third Item'); + const frame = await waitForFrame(lastFrame, (f) => + f.includes("First Item"), + ); + expect(frame).toContain("Second Item"); + expect(frame).toContain("Third Item"); }); - it('displays item status', async () => { + it("displays item status", async () => { const { lastFrame } = render( + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - const frame = lastFrame() || ''; - expect(frame).toContain('active'); - expect(frame).toContain('inactive'); - expect(frame).toContain('pending'); + const frame = await waitForFrame(lastFrame, (f) => f.includes("active")); + expect(frame).toContain("inactive"); + expect(frame).toContain("pending"); }); - it('shows selection pointer', async () => { + it("shows selection pointer", async () => { const { lastFrame } = render( + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - expect(lastFrame()).toContain('❯'); + const frame = await waitForFrame(lastFrame, (f) => f.includes("❯")); + expect(frame).toContain("First Item"); }); - it('shows empty state when no items', async () => { + it("shows empty state when no items", async () => { const emptyFetch = createMockFetchPage([]); const config = createSingleSelectConfig(emptyFetch); @@ -170,113 +188,110 @@ describe('ResourcePicker', () => { config={config} onSelect={mockOnSelect} onCancel={mockOnCancel} - /> + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - expect(lastFrame()).toContain('No items found'); + await waitForFrame(lastFrame, (f) => f.includes("No items found")); }); }); - describe('single-select mode', () => { - it('does not show checkboxes in single-select mode', async () => { + describe("single-select mode", () => { + it("does not show checkboxes in single-select mode", async () => { const { lastFrame } = render( + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - const frame = lastFrame() || ''; + const frame = await waitForFrame( + lastFrame, + (f) => !f.includes("Loading select item"), + ); // Should not contain checkbox characters - expect(frame).not.toContain('☑'); - expect(frame).not.toContain('☐'); + expect(frame).not.toContain("☑"); + expect(frame).not.toContain("☐"); }); - it('does not show Toggle in navigation tips', async () => { + it("does not show Toggle in navigation tips", async () => { const { lastFrame } = render( + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - const frame = lastFrame() || ''; - expect(frame).not.toContain('Toggle'); - expect(frame).toContain('Select'); // Single mode uses "Select" + const frame = await waitForFrame( + lastFrame, + (f) => !f.includes("Loading select item"), + ); + expect(frame).not.toContain("Toggle"); + expect(frame).toContain("Select"); // Single mode uses "Select" }); - it('does not show selected count in title', async () => { + it("does not show selected count in title", async () => { const { lastFrame } = render( + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - const frame = lastFrame() || ''; - expect(frame).not.toContain('selected'); + const frame = await waitForFrame( + lastFrame, + (f) => !f.includes("Loading select item"), + ); + expect(frame).not.toContain("selected"); }); }); - describe('multi-select mode', () => { - it('shows unchecked checkboxes for unselected items', async () => { + describe("multi-select mode", () => { + it("shows unchecked checkboxes for unselected items", async () => { const { lastFrame } = render( + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - const frame = lastFrame() || ''; + const frame = await waitForFrame( + lastFrame, + (f) => !f.includes("Loading select item"), + ); // Should contain unchecked checkbox character - expect(frame).toContain('☐'); + expect(frame).toContain("☐"); }); - it('shows checked checkboxes for initially selected items', async () => { + it("shows checked checkboxes for initially selected items", async () => { const { lastFrame } = render( + initialSelected={["item_1", "item_2"]} + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - const frame = lastFrame() || ''; + const frame = await waitForFrame( + lastFrame, + (f) => !f.includes("Loading select item"), + ); // Should contain checked checkbox character for pre-selected items - expect(frame).toContain('☑'); + expect(frame).toContain("☑"); }); - it('shows selected count in title when using Table view', async () => { + it("shows selected count in title when using Table view", async () => { // Add columns to force Table view (which shows selected count in title) const configWithColumns: ResourcePickerConfig = { ...createMultiSelectConfig(), columns: [ { - key: 'name', - label: 'Name', + key: "name", + label: "Name", width: 20, render: (row) => {row.name}, }, @@ -288,24 +303,24 @@ describe('ResourcePicker', () => { config={configWithColumns} onSelect={mockOnSelect} onCancel={mockOnCancel} - initialSelected={['item_1']} - /> + initialSelected={["item_1"]} + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - const frame = lastFrame() || ''; - expect(frame).toContain('1 selected'); + const frame = await waitForFrame( + lastFrame, + (f) => !f.includes("Loading select item"), + ); + expect(frame).toContain("1 selected"); }); - it('shows correct count for multiple selections in Table view', async () => { + it("shows correct count for multiple selections in Table view", async () => { const configWithColumns: ResourcePickerConfig = { ...createMultiSelectConfig(), columns: [ { - key: 'name', - label: 'Name', + key: "name", + label: "Name", width: 20, render: (row) => {row.name}, }, @@ -317,24 +332,24 @@ describe('ResourcePicker', () => { config={configWithColumns} onSelect={mockOnSelect} onCancel={mockOnCancel} - initialSelected={['item_1', 'item_2', 'item_3']} - /> + initialSelected={["item_1", "item_2", "item_3"]} + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - const frame = lastFrame() || ''; - expect(frame).toContain('3 selected'); + const frame = await waitForFrame( + lastFrame, + (f) => !f.includes("Loading select item"), + ); + expect(frame).toContain("3 selected"); }); - it('shows 0 selected in Table view when nothing is selected', async () => { + it("shows 0 selected in Table view when nothing is selected", async () => { const configWithColumns: ResourcePickerConfig = { ...createMultiSelectConfig(), columns: [ { - key: 'name', - label: 'Name', + key: "name", + label: "Name", width: 20, render: (row) => {row.name}, }, @@ -347,145 +362,145 @@ describe('ResourcePicker', () => { onSelect={mockOnSelect} onCancel={mockOnCancel} initialSelected={[]} - /> + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - const frame = lastFrame() || ''; - expect(frame).toContain('0 selected'); + const frame = await waitForFrame( + lastFrame, + (f) => !f.includes("Loading select item"), + ); + expect(frame).toContain("0 selected"); }); - it('shows Toggle in navigation tips', async () => { + it("shows Toggle in navigation tips", async () => { const { lastFrame } = render( + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - const frame = lastFrame() || ''; - expect(frame).toContain('Toggle'); + const frame = await waitForFrame( + lastFrame, + (f) => !f.includes("Loading select item"), + ); + expect(frame).toContain("Toggle"); }); - it('shows Confirm in navigation tips when items are selected', async () => { + it("shows Confirm in navigation tips when items are selected", async () => { const { lastFrame } = render( + initialSelected={["item_1"]} // Select at least minSelection items + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - const frame = lastFrame() || ''; - expect(frame).toContain('Confirm'); // Multi mode uses "Confirm" when canConfirm is true + const frame = await waitForFrame( + lastFrame, + (f) => !f.includes("Loading select item"), + ); + expect(frame).toContain("Confirm"); // Multi mode uses "Confirm" when canConfirm is true }); - it('shows Space key hint for toggling', async () => { + it("shows Space key hint for toggling", async () => { const { lastFrame } = render( + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - const frame = lastFrame() || ''; - expect(frame).toContain('Space'); + const frame = await waitForFrame( + lastFrame, + (f) => !f.includes("Loading select item"), + ); + expect(frame).toContain("Space"); }); }); - describe('navigation tips', () => { - it('shows search hint', async () => { + describe("navigation tips", () => { + it("shows search hint", async () => { const { lastFrame } = render( + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - const frame = lastFrame() || ''; - expect(frame).toContain('/'); - expect(frame).toContain('Search'); + const frame = await waitForFrame( + lastFrame, + (f) => !f.includes("Loading select item"), + ); + expect(frame).toContain("/"); + expect(frame).toContain("Search"); }); - it('shows cancel hint', async () => { + it("shows cancel hint", async () => { const { lastFrame } = render( + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - const frame = lastFrame() || ''; - expect(frame).toContain('Esc'); - expect(frame).toContain('Cancel'); + const frame = await waitForFrame( + lastFrame, + (f) => !f.includes("Loading select item"), + ); + expect(frame).toContain("Esc"); + expect(frame).toContain("Cancel"); }); }); - describe('statistics bar', () => { - it('shows total count', async () => { + describe("statistics bar", () => { + it("shows total count", async () => { const { lastFrame } = render( + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - const frame = lastFrame() || ''; - expect(frame).toContain('3'); - expect(frame).toContain('total'); + const frame = await waitForFrame( + lastFrame, + (f) => !f.includes("Loading select item"), + ); + expect(frame).toContain("3"); + expect(frame).toContain("total"); }); - it('shows showing range', async () => { + it("shows showing range", async () => { const { lastFrame } = render( + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - const frame = lastFrame() || ''; - expect(frame).toContain('Showing'); + const frame = await waitForFrame( + lastFrame, + (f) => !f.includes("Loading select item"), + ); + expect(frame).toContain("Showing"); }); }); - describe('breadcrumb', () => { - it('displays breadcrumb when provided', async () => { + describe("breadcrumb", () => { + it("displays breadcrumb when provided", async () => { const configWithBreadcrumb: ResourcePickerConfig = { ...createSingleSelectConfig(), breadcrumbItems: [ - { label: 'Home' }, - { label: 'Items' }, - { label: 'Select', active: true }, + { label: "Home" }, + { label: "Items" }, + { label: "Select", active: true }, ], }; @@ -494,21 +509,21 @@ describe('ResourcePicker', () => { config={configWithBreadcrumb} onSelect={mockOnSelect} onCancel={mockOnCancel} - /> + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - const frame = lastFrame() || ''; - expect(frame).toContain('Home'); - expect(frame).toContain('Items'); - expect(frame).toContain('Select'); + const frame = await waitForFrame( + lastFrame, + (f) => !f.includes("Loading select item"), + ); + expect(frame).toContain("Home"); + expect(frame).toContain("Items"); + expect(frame).toContain("Select"); }); }); - describe('config options', () => { - it('respects minSelection config', async () => { + describe("config options", () => { + it("respects minSelection config", async () => { const config: ResourcePickerConfig = { ...createMultiSelectConfig(), minSelection: 2, @@ -519,21 +534,21 @@ describe('ResourcePicker', () => { config={config} onSelect={mockOnSelect} onCancel={mockOnCancel} - initialSelected={['item_1']} // Only 1 selected, but min is 2 - /> + initialSelected={["item_1"]} // Only 1 selected, but min is 2 + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - // Component should render (confirm may be disabled but that's behavioral) - expect(lastFrame()).toBeTruthy(); + const frame = await waitForFrame( + lastFrame, + (f) => !f.includes("Loading select items"), + ); + expect(frame).toBeTruthy(); }); - it('respects custom emptyMessage', async () => { + it("respects custom emptyMessage", async () => { const config: ResourcePickerConfig = { ...createSingleSelectConfig(createMockFetchPage([])), - emptyMessage: 'Custom empty message', + emptyMessage: "Custom empty message", }; const { lastFrame } = render( @@ -541,13 +556,10 @@ describe('ResourcePicker', () => { config={config} onSelect={mockOnSelect} onCancel={mockOnCancel} - /> + />, ); - // Wait for async fetch - await new Promise((r) => setTimeout(r, 50)); - - expect(lastFrame()).toContain('Custom empty message'); + await waitForFrame(lastFrame, (f) => f.includes("Custom empty message")); }); }); }); diff --git a/tests/setup-components.ts b/tests/setup-components.ts index e9a64839..9e40d14f 100644 --- a/tests/setup-components.ts +++ b/tests/setup-components.ts @@ -289,13 +289,16 @@ jest.mock("../src/store/navigationStore", () => ({ })), })); -// Mock hooks +// Stable dimensions: jest restoreMocks clears jest.fn() implementations, which +// would make PAGE_SIZE undefined in ResourcePicker and leave it stuck loading. +const mockViewportDimensions = { + viewportHeight: 20, + terminalHeight: 24, + terminalWidth: 80, +}; + jest.mock("../src/hooks/useViewportHeight.ts", () => ({ - useViewportHeight: jest.fn(() => ({ - viewportHeight: 20, - terminalHeight: 24, - terminalWidth: 80, - })), + useViewportHeight: () => mockViewportDimensions, })); jest.mock("../src/hooks/useExitOnCtrlC.ts", () => ({ diff --git a/tests/setup-router.ts b/tests/setup-router.ts index da06f194..7b5bb9da 100644 --- a/tests/setup-router.ts +++ b/tests/setup-router.ts @@ -207,54 +207,76 @@ jest.mock("../src/store/devboxStore.ts", () => ({ })); jest.mock("../src/store/blueprintStore.ts", () => ({ - useBlueprintStore: Object.assign(jest.fn(() => ({})), { - getState: () => ({ clearAll: jest.fn() }), - }), + useBlueprintStore: Object.assign( + jest.fn(() => ({})), + { + getState: () => ({ clearAll: jest.fn() }), + }, + ), })); jest.mock("../src/store/snapshotStore.ts", () => ({ - useSnapshotStore: Object.assign(jest.fn(() => ({})), { - getState: () => ({ clearAll: jest.fn() }), - }), + useSnapshotStore: Object.assign( + jest.fn(() => ({})), + { + getState: () => ({ clearAll: jest.fn() }), + }, + ), })); jest.mock("../src/store/networkPolicyStore.ts", () => ({ - useNetworkPolicyStore: Object.assign(jest.fn(() => ({})), { - getState: () => ({ clearAll: jest.fn() }), - }), + useNetworkPolicyStore: Object.assign( + jest.fn(() => ({})), + { + getState: () => ({ clearAll: jest.fn() }), + }, + ), })); jest.mock("../src/store/gatewayConfigStore.ts", () => ({ - useGatewayConfigStore: Object.assign(jest.fn(() => ({})), { - getState: () => ({ clearAll: jest.fn() }), - }), + useGatewayConfigStore: Object.assign( + jest.fn(() => ({})), + { + getState: () => ({ clearAll: jest.fn() }), + }, + ), })); jest.mock("../src/store/objectStore.ts", () => ({ - useObjectStore: Object.assign(jest.fn(() => ({})), { - getState: () => ({ clearAll: jest.fn() }), - }), + useObjectStore: Object.assign( + jest.fn(() => ({})), + { + getState: () => ({ clearAll: jest.fn() }), + }, + ), })); jest.mock("../src/store/benchmarkStore.ts", () => ({ - useBenchmarkStore: Object.assign(jest.fn(() => ({})), { - getState: () => ({ clearAll: jest.fn() }), - }), + useBenchmarkStore: Object.assign( + jest.fn(() => ({})), + { + getState: () => ({ clearAll: jest.fn() }), + }, + ), })); jest.mock("../src/store/benchmarkJobStore.ts", () => ({ - useBenchmarkJobStore: Object.assign(jest.fn(() => ({})), { - getState: () => ({ clearAll: jest.fn() }), - }), + useBenchmarkJobStore: Object.assign( + jest.fn(() => ({})), + { + getState: () => ({ clearAll: jest.fn() }), + }, + ), })); -// Mock hooks +const mockViewportDimensionsRouter = { + viewportHeight: 20, + terminalHeight: 24, + terminalWidth: 80, +}; + jest.mock("../src/hooks/useViewportHeight.ts", () => ({ - useViewportHeight: jest.fn(() => ({ - viewportHeight: 20, - terminalHeight: 24, - terminalWidth: 80, - })), + useViewportHeight: () => mockViewportDimensionsRouter, })); jest.mock("../src/hooks/useExitOnCtrlC.ts", () => ({ diff --git a/tests/setup.ts b/tests/setup.ts index 4c18fd91..a025467b 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -192,13 +192,14 @@ jest.mock("../src/store/navigationStore", () => ({ })), })); -// Mock hooks +const mockViewportDimensionsMain = { + viewportHeight: 20, + terminalHeight: 24, + terminalWidth: 80, +}; + jest.mock("../src/hooks/useViewportHeight.ts", () => ({ - useViewportHeight: jest.fn(() => ({ - viewportHeight: 20, - terminalHeight: 24, - terminalWidth: 80, - })), + useViewportHeight: () => mockViewportDimensionsMain, })); jest.mock("../src/hooks/useExitOnCtrlC.ts", () => ({