From 062095b84ecf4d0271f9e2f9c18221b1ab84b9d6 Mon Sep 17 00:00:00 2001 From: fazzysyed Date: Tue, 28 Apr 2026 15:14:47 +0500 Subject: [PATCH 1/5] Add API runtime scaffold for AI generation. Introduce provider-based AI runtime abstraction so ai-enhance can run with either local Ollama or external API mode, while preserving current test generation and retry workflow. Made-with: Cursor --- README.md | 2 + src/ai/api.ts | 58 ++++++++++++++++++++++ src/ai/ollama-provider.ts | 33 +++++++++++++ src/ai/provider.ts | 11 +++++ src/bin.ts | 3 +- src/commands/ai-enhance.ts | 98 ++++++++++++++++++-------------------- src/commands/ai-setup.ts | 5 ++ src/types.ts | 2 +- 8 files changed, 159 insertions(+), 53 deletions(-) create mode 100644 src/ai/api.ts create mode 100644 src/ai/ollama-provider.ts create mode 100644 src/ai/provider.ts diff --git a/README.md b/README.md index cc714a2..60c7395 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ react-native-testsmith scan react-native-testsmith generate react-native-testsmith ai-setup react-native-testsmith ai-enhance --target src/screens/LoginScreen.tsx +react-native-testsmith ai-enhance --runtime api --target src/screens/LoginScreen.tsx react-native-testsmith doctor react-native-testsmith doctor --json ``` @@ -159,6 +160,7 @@ Notes: - First-time model download may take several minutes. This is one-time per model. - Without `--apply`, the command runs in preview mode and prints output. - If `--run-jest` is set and Jest fails, AI auto-fix retries run (based on `ai.maxRetries`). +- API runtime is scaffolded: set `RN_TESTSMITH_API_URL` (required) and `RN_TESTSMITH_API_KEY` (optional). ## CI diff --git a/src/ai/api.ts b/src/ai/api.ts new file mode 100644 index 0000000..271180f --- /dev/null +++ b/src/ai/api.ts @@ -0,0 +1,58 @@ +import type { AiProvider } from "./provider.js"; + +type ApiTextResponse = { + response?: string; + text?: string; + output?: string; + data?: { + response?: string; + text?: string; + output?: string; + }; +}; + +function extractApiText(payload: unknown): string { + if (typeof payload === "string") return payload; + if (!payload || typeof payload !== "object") return ""; + const p = payload as ApiTextResponse; + return p.response ?? p.text ?? p.output ?? p.data?.response ?? p.data?.text ?? p.data?.output ?? ""; +} + +export function createApiProvider(): AiProvider { + return { + runtime: "api", + async generateText({ prompt, model }) { + const endpoint = process.env.RN_TESTSMITH_API_URL; + const apiKey = process.env.RN_TESTSMITH_API_KEY; + if (!endpoint) { + throw new Error("RN_TESTSMITH_API_URL is not set."); + } + + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) + }, + body: JSON.stringify({ + model, + input: prompt + }) + }); + + if (!res.ok) { + throw new Error(`API request failed: ${res.status} ${res.statusText}`); + } + + const contentType = res.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + const json = await res.json(); + const text = extractApiText(json); + if (!text) throw new Error("API response did not include a text payload."); + return text; + } + + return res.text(); + } + }; +} diff --git a/src/ai/ollama-provider.ts b/src/ai/ollama-provider.ts new file mode 100644 index 0000000..d6c8e40 --- /dev/null +++ b/src/ai/ollama-provider.ts @@ -0,0 +1,33 @@ +import type { AiProvider } from "./provider.js"; + +type OllamaResponse = { + response?: string; +}; + +export function createOllamaProvider(): AiProvider { + return { + runtime: "ollama", + async generateText({ prompt, model }) { + const res = await fetch("http://localhost:11434/api/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model, + prompt, + stream: false, + options: { + temperature: 0.2 + } + }) + }); + + if (!res.ok) { + throw new Error(`Ollama request failed: ${res.status} ${res.statusText}`); + } + + const json = (await res.json()) as OllamaResponse; + if (!json.response) throw new Error("Ollama returned empty response"); + return json.response; + } + }; +} diff --git a/src/ai/provider.ts b/src/ai/provider.ts new file mode 100644 index 0000000..c117d11 --- /dev/null +++ b/src/ai/provider.ts @@ -0,0 +1,11 @@ +export type AiProviderRuntime = "ollama" | "api"; + +export type GenerateTextInput = { + prompt: string; + model: string; +}; + +export type AiProvider = { + runtime: AiProviderRuntime; + generateText: (input: GenerateTextInput) => Promise; +}; diff --git a/src/bin.ts b/src/bin.ts index 217da3f..72f6f28 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -52,9 +52,10 @@ program program .command("ai-enhance") - .description("Generate or improve tests with local Ollama model") + .description("Generate or improve tests with AI runtime") .requiredOption("-t, --target ", "target component path") .option("-m, --model ", "Ollama model name override") + .option("--runtime ", "ai runtime: ollama | api") .option("--apply", "write generated test file") .option("-f, --force", "overwrite existing generated test file") .option("--run-jest", "run scoped jest for generated file") diff --git a/src/commands/ai-enhance.ts b/src/commands/ai-enhance.ts index 980a4d3..4c3afe3 100644 --- a/src/commands/ai-enhance.ts +++ b/src/commands/ai-enhance.ts @@ -6,6 +6,9 @@ import { SCAN_REPORT_FILE } from "../constants.js"; import type { ComponentMeta, ScanReport } from "../types.js"; import { ensureDir, logInfo, logSuccess, logWarn, resolveFromRoot, writeFileSafe } from "../utils.js"; import { hasOllamaModel, isOllamaInstalled, isOllamaReachable, pullOllamaModel, tryStartOllamaServer, waitForOllamaServer } from "../ai/ollama.js"; +import { createOllamaProvider } from "../ai/ollama-provider.js"; +import { createApiProvider } from "../ai/api.js"; +import type { AiProviderRuntime } from "../ai/provider.js"; type AiEnhanceOptions = { target?: string; @@ -13,10 +16,7 @@ type AiEnhanceOptions = { apply?: boolean; force?: boolean; runJest?: boolean; -}; - -type OllamaResponse = { - response?: string; + runtime?: AiProviderRuntime; }; function cleanModelOutput(raw: string): string { @@ -85,27 +85,14 @@ function loadMetadataForTarget(projectRoot: string, absTarget: string): Componen } } -async function generateWithOllama(prompt: string, model: string): Promise { - const res = await fetch("http://localhost:11434/api/generate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model, - prompt, - stream: false, - options: { - temperature: 0.2 - } - }) - }); - - if (!res.ok) { - throw new Error(`Ollama request failed: ${res.status} ${res.statusText}`); - } - - const json = (await res.json()) as OllamaResponse; - if (!json.response) throw new Error("Ollama returned empty response"); - return cleanModelOutput(json.response); +async function generateWithProvider( + runtime: AiProviderRuntime, + prompt: string, + model: string +): Promise { + const provider = runtime === "api" ? createApiProvider() : createOllamaProvider(); + const raw = await provider.generateText({ prompt, model }); + return cleanModelOutput(raw); } function buildPrompt(componentName: string, componentCode: string, existingTest: string | null, meta: ComponentMeta | null): string { @@ -176,6 +163,7 @@ function runScopedJest(projectRoot: string, testPath: string): { ok: boolean; ou export async function runAiEnhance(projectRoot: string, options: AiEnhanceOptions): Promise { const config = loadConfig(projectRoot); + const runtime = options.runtime ?? config.ai.runtime; const model = options.model ?? config.ai.model; const apply = Boolean(options.apply); const runJest = Boolean(options.runJest); @@ -187,31 +175,39 @@ export async function runAiEnhance(projectRoot: string, options: AiEnhanceOption return; } - if (!isOllamaInstalled()) { - logWarn("Ollama is not installed."); - logInfo("Install Ollama from https://ollama.com/download and run `react-native-testsmith ai-setup`."); - return; - } + if (runtime === "ollama") { + if (!isOllamaInstalled()) { + logWarn("Ollama is not installed."); + logInfo("Install Ollama from https://ollama.com/download and run `react-native-testsmith ai-setup`."); + return; + } - let reachable = await isOllamaReachable(); - if (!reachable) { - logInfo("Ollama is not running. Attempting to start it..."); - tryStartOllamaServer(); - reachable = await waitForOllamaServer(); - } - if (!reachable) { - logWarn("Could not connect to Ollama at http://127.0.0.1:11434."); - logInfo("Run `react-native-testsmith ai-setup` to bootstrap Ollama and model."); - return; - } + let reachable = await isOllamaReachable(); + if (!reachable) { + logInfo("Ollama is not running. Attempting to start it..."); + tryStartOllamaServer(); + reachable = await waitForOllamaServer(); + } + if (!reachable) { + logWarn("Could not connect to Ollama at http://127.0.0.1:11434."); + logInfo("Run `react-native-testsmith ai-setup` to bootstrap Ollama and model."); + return; + } - const modelExists = await hasOllamaModel(model); - if (!modelExists) { - logInfo(`Model ${model} is not installed locally.`); - logInfo("First-time model download can take several minutes. Please wait..."); - const pulled = pullOllamaModel(model); - if (!pulled) { - logWarn(`Failed to download model: ${model}`); + const modelExists = await hasOllamaModel(model); + if (!modelExists) { + logInfo(`Model ${model} is not installed locally.`); + logInfo("First-time model download can take several minutes. Please wait..."); + const pulled = pullOllamaModel(model); + if (!pulled) { + logWarn(`Failed to download model: ${model}`); + return; + } + } + } else { + if (!process.env.RN_TESTSMITH_API_URL) { + logWarn("API runtime selected but RN_TESTSMITH_API_URL is not configured."); + logInfo("Set RN_TESTSMITH_API_URL and optionally RN_TESTSMITH_API_KEY, then retry."); return; } } @@ -229,8 +225,8 @@ export async function runAiEnhance(projectRoot: string, options: AiEnhanceOption const meta = loadMetadataForTarget(projectRoot, absTarget); const prompt = buildPrompt(componentName, componentCode, existingTest, meta); - logInfo(`Generating AI-enhanced tests with local Ollama model: ${model}`); - let generated = await generateWithOllama(prompt, model); + logInfo(`Generating AI-enhanced tests using ${runtime} runtime with model: ${model}`); + let generated = await generateWithProvider(runtime, prompt, model); if (!generated.includes("describe(") || !generated.includes("it(")) { logWarn("Model output does not look like a Jest test file. Aborting write."); @@ -267,7 +263,7 @@ export async function runAiEnhance(projectRoot: string, options: AiEnhanceOption logInfo(`Attempting AI auto-fix (${attempt}/${maxRetries})...`); const failingTest = fs.readFileSync(testPath, "utf8"); const repairPrompt = buildRepairPrompt(componentName, componentCode, failingTest, jestResult.output); - generated = await generateWithOllama(repairPrompt, model); + generated = await generateWithProvider(runtime, repairPrompt, model); if (!generated.includes("describe(") || !generated.includes("it(")) { logWarn("Auto-fix output was invalid test content. Stopping retries."); break; diff --git a/src/commands/ai-setup.ts b/src/commands/ai-setup.ts index e876759..6e94d2c 100644 --- a/src/commands/ai-setup.ts +++ b/src/commands/ai-setup.ts @@ -17,6 +17,11 @@ type AiSetupOptions = { export async function runAiSetup(projectRoot: string, options: AiSetupOptions): Promise { const config = loadConfig(projectRoot); + if (config.ai.runtime === "api") { + logInfo("AI runtime is set to API mode."); + logInfo("Set RN_TESTSMITH_API_URL (required) and RN_TESTSMITH_API_KEY (optional)."); + return; + } const model = options.model ?? config.ai.model; if (!isOllamaInstalled()) { diff --git a/src/types.ts b/src/types.ts index c0c21ff..9c41651 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,7 +4,7 @@ export type RuntimeConfig = { testFileStyle: "co-located" | "tests-dir"; ai: { enabled: boolean; - runtime: "ollama"; + runtime: "ollama" | "api"; model: string; maxRetries: number; }; From 572ef05bfcce70f4bcb194a7de217c0839ed1388 Mon Sep 17 00:00:00 2001 From: fazzysyed Date: Tue, 28 Apr 2026 15:31:33 +0500 Subject: [PATCH 2/5] Convert 1.0.1 to API-first generation workflow. Remove Ollama runtime paths, make init run setup automatically, and turn generate into an end-to-end pipeline (scan + API setup check + per-file AI generation) with terminal progress and summary counts. Made-with: Cursor --- README.md | 49 ++++++------- src/ai/api.ts | 92 ++++++++++++++++++------ src/ai/ollama-provider.ts | 33 --------- src/ai/ollama.ts | 55 -------------- src/ai/provider.ts | 2 +- src/bin.ts | 16 ++--- src/commands/ai-enhance.ts | 56 ++------------- src/commands/ai-setup.ts | 74 +++++++------------ src/commands/generate.ts | 67 ++++++++++------- src/commands/init.ts | 9 ++- src/commands/scan.ts | 8 ++- src/commands/setup.ts | 31 ++++++++ src/constants.ts | 4 +- src/types.ts | 2 +- tests/scan-generate.integration.test.mjs | 7 +- 15 files changed, 219 insertions(+), 286 deletions(-) delete mode 100644 src/ai/ollama-provider.ts delete mode 100644 src/ai/ollama.ts diff --git a/README.md b/README.md index 60c7395..dc952a3 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,14 @@ [![npm version](https://img.shields.io/npm/v/react-native-testsmith.svg)](https://www.npmjs.com/package/react-native-testsmith) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -Production-ready, local-first CLI for React Native unit testing with Jest and React Native Testing Library. +Production-ready CLI for React Native unit testing with Jest and React Native Testing Library. ## Why this exists - Automates painful Jest + RN config - Scans your components/screens and builds metadata - Generates stable test templates quickly -- Keeps everything local (no external API needed) +- Supports API-driven AI test generation with chunking for large files ## Install @@ -39,15 +39,9 @@ If you see `command not found`, install globally (`npm i -g .`) or run with `npx ```bash react-native-testsmith init -react-native-testsmith setup -react-native-testsmith setup --dry-run -react-native-testsmith setup --with-native-mocks -react-native-testsmith setup --skip-install -react-native-testsmith scan react-native-testsmith generate react-native-testsmith ai-setup react-native-testsmith ai-enhance --target src/screens/LoginScreen.tsx -react-native-testsmith ai-enhance --runtime api --target src/screens/LoginScreen.tsx react-native-testsmith doctor react-native-testsmith doctor --json ``` @@ -56,11 +50,8 @@ react-native-testsmith doctor --json ```bash react-native-testsmith init -react-native-testsmith setup -react-native-testsmith scan react-native-testsmith generate -react-native-testsmith ai-setup -react-native-testsmith ai-enhance --target src/screens/LoginScreen.tsx --apply --run-jest +react-native-testsmith ai-enhance --target src/screens/LoginScreen.tsx --apply ``` ## Full setup steps (end-to-end) @@ -86,34 +77,30 @@ react-native-testsmith init 4. Configure Jest and required mocks ```bash +# already done by init +# optional manual run: react-native-testsmith setup ``` -5. Scan project components/screens - -```bash -react-native-testsmith scan -``` - -6. Generate baseline test templates +5. Run complete generation pipeline (scan + API check + per-file AI generation) ```bash react-native-testsmith generate ``` -7. Bootstrap local AI runtime (Ollama + model) +6. Verify API runtime setup (optional standalone check) ```bash react-native-testsmith ai-setup ``` -8. Generate/improve tests with AI for a specific file +7. Generate/improve tests with AI for a specific file ```bash react-native-testsmith ai-enhance --target src/screens/LoginScreen.tsx --apply --run-jest ``` -9. Validate setup health +8. Validate setup health ```bash react-native-testsmith doctor @@ -137,6 +124,8 @@ When you pass `--with-native-mocks`, setup also creates: Use `--dry-run` to preview all setup changes safely before writing files. +`init` runs setup automatically. You can still run `setup` directly when needed. + ## Test output structure `generate` mirrors your source structure in `__tests__` when `testFileStyle` is `tests-dir`. @@ -145,22 +134,24 @@ Example: - `src/components/Button.tsx` -> `__tests__/components/Button.test.tsx` - `src/screens/auth/Login.js` -> `__tests__/screens/auth/Login.test.js` -## Local AI enhancement +## AI enhancement (API) -`ai-enhance` uses a local Ollama model (no external API): +`ai-enhance` uses an API backend: ```bash react-native-testsmith ai-setup react-native-testsmith ai-enhance --target src/screens/LoginScreen.tsx --apply -react-native-testsmith ai-enhance --target src/screens/LoginScreen.tsx --model qwen2.5-coder:7b --apply --run-jest +react-native-testsmith ai-enhance --target src/screens/LoginScreen.tsx --model default --apply --run-jest ``` Notes: -- `ai-setup` checks Ollama, starts service if needed, and downloads model automatically. -- First-time model download may take several minutes. This is one-time per model. +- `ai-setup` checks API endpoint connectivity. - Without `--apply`, the command runs in preview mode and prints output. - If `--run-jest` is set and Jest fails, AI auto-fix retries run (based on `ai.maxRetries`). -- API runtime is scaffolded: set `RN_TESTSMITH_API_URL` (required) and `RN_TESTSMITH_API_KEY` (optional). +- `RN_TESTSMITH_API_URL` overrides endpoint and `RN_TESTSMITH_API_KEY` is optional. +- For long files, API runtime automatically chunks input and synthesizes a final test response. +- `scan` includes `App.ts`, `App.js`, `App.tsx`, and `App.jsx` at project root. +- `generate` shows per-file progress and final counts for AI responses and generated files. ## CI @@ -176,7 +167,7 @@ GitHub Actions CI is included at `.github/workflows/ci.yml`: ## Sponsor request -`react-native-testsmith` is currently local-first and free to use. +`react-native-testsmith` is currently free to use. If this project saves your team time, please sponsor development so we can: - maintain and improve templates faster diff --git a/src/ai/api.ts b/src/ai/api.ts index 271180f..4c53d19 100644 --- a/src/ai/api.ts +++ b/src/ai/api.ts @@ -18,41 +18,89 @@ function extractApiText(payload: unknown): string { return p.response ?? p.text ?? p.output ?? p.data?.response ?? p.data?.text ?? p.data?.output ?? ""; } +async function callApi(endpoint: string, bodyText: string, apiKey?: string): Promise { + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "text/plain", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) + }, + body: bodyText + }); + + if (!res.ok) { + throw new Error(`API request failed: ${res.status} ${res.statusText}`); + } + + const contentType = res.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + const json = await res.json(); + const text = extractApiText(json); + if (!text) throw new Error("API response did not include a text payload."); + return text; + } + + return res.text(); +} + +function chunkText(input: string, chunkSize: number): string[] { + if (input.length <= chunkSize) return [input]; + const chunks: string[] = []; + for (let i = 0; i < input.length; i += chunkSize) { + chunks.push(input.slice(i, i + chunkSize)); + } + return chunks; +} + export function createApiProvider(): AiProvider { return { runtime: "api", async generateText({ prompt, model }) { - const endpoint = process.env.RN_TESTSMITH_API_URL; + const endpoint = process.env.RN_TESTSMITH_API_URL ?? "https://aashir321-faraz-ai-model.hf.space/generate-tests"; const apiKey = process.env.RN_TESTSMITH_API_KEY; if (!endpoint) { throw new Error("RN_TESTSMITH_API_URL is not set."); } - const res = await fetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) - }, - body: JSON.stringify({ - model, - input: prompt - }) - }); - - if (!res.ok) { - throw new Error(`API request failed: ${res.status} ${res.statusText}`); + const chunkSize = Number(process.env.RN_TESTSMITH_API_CHUNK_SIZE ?? "12000"); + const chunks = chunkText(prompt, chunkSize); + + if (chunks.length === 1) { + return callApi(endpoint, chunks[0], apiKey); } - const contentType = res.headers.get("content-type") ?? ""; - if (contentType.includes("application/json")) { - const json = await res.json(); - const text = extractApiText(json); - if (!text) throw new Error("API response did not include a text payload."); - return text; + const analyses: string[] = []; + for (let i = 0; i < chunks.length; i += 1) { + const chunkPrompt = `Model: ${model} +You are receiving chunk ${i + 1}/${chunks.length} from a large React Native component/test request. +Return concise notes for this chunk covering: +- rendered UI and text labels +- state/hooks/effects +- handlers/user interactions +- async/api calls and mocks +- navigation/redux usage + +Chunk: +${chunks[i]}`; + analyses.push(await callApi(endpoint, chunkPrompt, apiKey)); } - return res.text(); + const synthesisPrompt = `Model: ${model} +You are given chunk analyses from a large React Native input. +Produce final output in this format: +### Component Summary +... +### Key Test Scenarios +- ... +### Full Test File +\`\`\`tsx +...full test file... +\`\`\` + +Chunk analyses: +${analyses.map((a, idx) => `--- Chunk ${idx + 1} ---\n${a}`).join("\n\n")}`; + + return callApi(endpoint, synthesisPrompt, apiKey); } }; } diff --git a/src/ai/ollama-provider.ts b/src/ai/ollama-provider.ts deleted file mode 100644 index d6c8e40..0000000 --- a/src/ai/ollama-provider.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { AiProvider } from "./provider.js"; - -type OllamaResponse = { - response?: string; -}; - -export function createOllamaProvider(): AiProvider { - return { - runtime: "ollama", - async generateText({ prompt, model }) { - const res = await fetch("http://localhost:11434/api/generate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - model, - prompt, - stream: false, - options: { - temperature: 0.2 - } - }) - }); - - if (!res.ok) { - throw new Error(`Ollama request failed: ${res.status} ${res.statusText}`); - } - - const json = (await res.json()) as OllamaResponse; - if (!json.response) throw new Error("Ollama returned empty response"); - return json.response; - } - }; -} diff --git a/src/ai/ollama.ts b/src/ai/ollama.ts deleted file mode 100644 index 173cb8d..0000000 --- a/src/ai/ollama.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { spawn, spawnSync } from "node:child_process"; - -type OllamaTagsResponse = { - models?: Array<{ name?: string }>; -}; - -export function isOllamaInstalled(): boolean { - const res = spawnSync("ollama", ["--version"], { encoding: "utf8" }); - return res.status === 0; -} - -export async function isOllamaReachable(): Promise { - try { - const res = await fetch("http://127.0.0.1:11434/api/tags"); - return res.ok; - } catch { - return false; - } -} - -export function tryStartOllamaServer(): void { - const child = spawn("ollama", ["serve"], { - detached: true, - stdio: "ignore" - }); - child.unref(); -} - -export async function listOllamaModels(): Promise { - const res = await fetch("http://127.0.0.1:11434/api/tags"); - if (!res.ok) return []; - const json = (await res.json()) as OllamaTagsResponse; - return (json.models ?? []).map((m) => m.name ?? "").filter(Boolean); -} - -export async function hasOllamaModel(modelName: string): Promise { - const models = await listOllamaModels(); - return models.includes(modelName); -} - -export function pullOllamaModel(modelName: string): boolean { - const run = spawnSync("ollama", ["pull", modelName], { - stdio: "inherit", - encoding: "utf8" - }); - return run.status === 0; -} - -export async function waitForOllamaServer(maxAttempts = 10, waitMs = 1000): Promise { - for (let i = 0; i < maxAttempts; i += 1) { - if (await isOllamaReachable()) return true; - await new Promise((resolve) => setTimeout(resolve, waitMs)); - } - return false; -} diff --git a/src/ai/provider.ts b/src/ai/provider.ts index c117d11..73634a3 100644 --- a/src/ai/provider.ts +++ b/src/ai/provider.ts @@ -1,4 +1,4 @@ -export type AiProviderRuntime = "ollama" | "api"; +export type AiProviderRuntime = "api"; export type GenerateTextInput = { prompt: string; diff --git a/src/bin.ts b/src/bin.ts index 72f6f28..9e437e1 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -25,7 +25,7 @@ program program .command("setup") - .description("Create Jest + React Native setup files") + .description("Create Jest + React Native setup files (optional; init runs this automatically)") .option("-f, --force", "overwrite existing setup files") .option("--dry-run", "preview file changes without writing") .option("--with-native-mocks", "also add common native module mocks") @@ -39,23 +39,21 @@ program program .command("generate") - .description("Generate test templates from scan report") + .description("Run full AI generation pipeline for all scanned files") .option("-f, --force", "overwrite existing test files") - .action((options) => runGenerate(projectRoot, options)); + .action(async (options) => runGenerate(projectRoot, options)); program .command("ai-setup") - .description("Install/check local AI runtime requirements (Ollama + model)") - .option("-m, --model ", "Ollama model name override") - .option("--skip-pull", "skip model download and only run checks") + .description("Check API runtime requirements") + .option("-e, --endpoint ", "API endpoint override for setup check") .action(async (options) => runAiSetup(projectRoot, options)); program .command("ai-enhance") - .description("Generate or improve tests with AI runtime") + .description("Generate or improve tests with API runtime") .requiredOption("-t, --target ", "target component path") - .option("-m, --model ", "Ollama model name override") - .option("--runtime ", "ai runtime: ollama | api") + .option("-m, --model ", "model name forwarded to API") .option("--apply", "write generated test file") .option("-f, --force", "overwrite existing generated test file") .option("--run-jest", "run scoped jest for generated file") diff --git a/src/commands/ai-enhance.ts b/src/commands/ai-enhance.ts index 4c3afe3..2499b3e 100644 --- a/src/commands/ai-enhance.ts +++ b/src/commands/ai-enhance.ts @@ -5,10 +5,7 @@ import { loadConfig } from "../config.js"; import { SCAN_REPORT_FILE } from "../constants.js"; import type { ComponentMeta, ScanReport } from "../types.js"; import { ensureDir, logInfo, logSuccess, logWarn, resolveFromRoot, writeFileSafe } from "../utils.js"; -import { hasOllamaModel, isOllamaInstalled, isOllamaReachable, pullOllamaModel, tryStartOllamaServer, waitForOllamaServer } from "../ai/ollama.js"; -import { createOllamaProvider } from "../ai/ollama-provider.js"; import { createApiProvider } from "../ai/api.js"; -import type { AiProviderRuntime } from "../ai/provider.js"; type AiEnhanceOptions = { target?: string; @@ -16,7 +13,6 @@ type AiEnhanceOptions = { apply?: boolean; force?: boolean; runJest?: boolean; - runtime?: AiProviderRuntime; }; function cleanModelOutput(raw: string): string { @@ -85,12 +81,8 @@ function loadMetadataForTarget(projectRoot: string, absTarget: string): Componen } } -async function generateWithProvider( - runtime: AiProviderRuntime, - prompt: string, - model: string -): Promise { - const provider = runtime === "api" ? createApiProvider() : createOllamaProvider(); +async function generateWithProvider(prompt: string, model: string): Promise { + const provider = createApiProvider(); const raw = await provider.generateText({ prompt, model }); return cleanModelOutput(raw); } @@ -163,7 +155,6 @@ function runScopedJest(projectRoot: string, testPath: string): { ok: boolean; ou export async function runAiEnhance(projectRoot: string, options: AiEnhanceOptions): Promise { const config = loadConfig(projectRoot); - const runtime = options.runtime ?? config.ai.runtime; const model = options.model ?? config.ai.model; const apply = Boolean(options.apply); const runJest = Boolean(options.runJest); @@ -175,41 +166,8 @@ export async function runAiEnhance(projectRoot: string, options: AiEnhanceOption return; } - if (runtime === "ollama") { - if (!isOllamaInstalled()) { - logWarn("Ollama is not installed."); - logInfo("Install Ollama from https://ollama.com/download and run `react-native-testsmith ai-setup`."); - return; - } - - let reachable = await isOllamaReachable(); - if (!reachable) { - logInfo("Ollama is not running. Attempting to start it..."); - tryStartOllamaServer(); - reachable = await waitForOllamaServer(); - } - if (!reachable) { - logWarn("Could not connect to Ollama at http://127.0.0.1:11434."); - logInfo("Run `react-native-testsmith ai-setup` to bootstrap Ollama and model."); - return; - } - - const modelExists = await hasOllamaModel(model); - if (!modelExists) { - logInfo(`Model ${model} is not installed locally.`); - logInfo("First-time model download can take several minutes. Please wait..."); - const pulled = pullOllamaModel(model); - if (!pulled) { - logWarn(`Failed to download model: ${model}`); - return; - } - } - } else { - if (!process.env.RN_TESTSMITH_API_URL) { - logWarn("API runtime selected but RN_TESTSMITH_API_URL is not configured."); - logInfo("Set RN_TESTSMITH_API_URL and optionally RN_TESTSMITH_API_KEY, then retry."); - return; - } + if (!process.env.RN_TESTSMITH_API_URL) { + logInfo("Using default API endpoint. Set RN_TESTSMITH_API_URL to override."); } const absTarget = resolveFromRoot(projectRoot, options.target); @@ -225,8 +183,8 @@ export async function runAiEnhance(projectRoot: string, options: AiEnhanceOption const meta = loadMetadataForTarget(projectRoot, absTarget); const prompt = buildPrompt(componentName, componentCode, existingTest, meta); - logInfo(`Generating AI-enhanced tests using ${runtime} runtime with model: ${model}`); - let generated = await generateWithProvider(runtime, prompt, model); + logInfo(`Generating AI-enhanced tests using API runtime with model: ${model}`); + let generated = await generateWithProvider(prompt, model); if (!generated.includes("describe(") || !generated.includes("it(")) { logWarn("Model output does not look like a Jest test file. Aborting write."); @@ -263,7 +221,7 @@ export async function runAiEnhance(projectRoot: string, options: AiEnhanceOption logInfo(`Attempting AI auto-fix (${attempt}/${maxRetries})...`); const failingTest = fs.readFileSync(testPath, "utf8"); const repairPrompt = buildRepairPrompt(componentName, componentCode, failingTest, jestResult.output); - generated = await generateWithProvider(runtime, repairPrompt, model); + generated = await generateWithProvider(repairPrompt, model); if (!generated.includes("describe(") || !generated.includes("it(")) { logWarn("Auto-fix output was invalid test content. Stopping retries."); break; diff --git a/src/commands/ai-setup.ts b/src/commands/ai-setup.ts index 6e94d2c..c2ed5b8 100644 --- a/src/commands/ai-setup.ts +++ b/src/commands/ai-setup.ts @@ -1,68 +1,42 @@ import { loadConfig } from "../config.js"; import { logInfo, logSuccess, logWarn } from "../utils.js"; -import { - hasOllamaModel, - isOllamaInstalled, - isOllamaReachable, - listOllamaModels, - pullOllamaModel, - tryStartOllamaServer, - waitForOllamaServer -} from "../ai/ollama.js"; type AiSetupOptions = { - model?: string; - skipPull?: boolean; + endpoint?: string; }; export async function runAiSetup(projectRoot: string, options: AiSetupOptions): Promise { const config = loadConfig(projectRoot); - if (config.ai.runtime === "api") { - logInfo("AI runtime is set to API mode."); - logInfo("Set RN_TESTSMITH_API_URL (required) and RN_TESTSMITH_API_KEY (optional)."); + const endpoint = options.endpoint ?? process.env.RN_TESTSMITH_API_URL ?? "https://c087-182-180-87-19.ngrok-free.app/generate-tests"; + const apiKey = process.env.RN_TESTSMITH_API_KEY; + if (!endpoint) { + logWarn("API endpoint is not configured."); + logInfo("Set RN_TESTSMITH_API_URL and re-run `react-native-testsmith ai-setup`."); return; } - const model = options.model ?? config.ai.model; - if (!isOllamaInstalled()) { - logWarn("Ollama is not installed."); - logInfo("Install Ollama first: https://ollama.com/download"); + logInfo(`Checking API endpoint: ${endpoint}`); + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "text/plain", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) + }, + body: "healthcheck" + }).catch(() => null); + + if (!res) { + logWarn("API endpoint is unreachable."); + logInfo("Check URL/network and try again."); return; } - let reachable = await isOllamaReachable(); - if (!reachable) { - logInfo("Ollama service is not running. Starting it now..."); - tryStartOllamaServer(); - reachable = await waitForOllamaServer(); - } - - if (!reachable) { - logWarn("Could not connect to Ollama at http://127.0.0.1:11434."); - logInfo("Please run `ollama serve` and re-run `react-native-testsmith ai-setup`."); - return; - } - - logSuccess("Ollama service is ready."); - - if (options.skipPull) { - const models = await listOllamaModels(); - logInfo(`Installed models: ${models.length ? models.join(", ") : "none"}`); + if (!res.ok) { + logWarn(`API endpoint responded with ${res.status}.`); + logInfo("Endpoint is reachable, but request contract may differ. This can still be okay for real prompts."); return; } - const modelExists = await hasOllamaModel(model); - if (modelExists) { - logSuccess(`Model already installed: ${model}`); - return; - } - - logInfo(`Model not found: ${model}`); - logInfo("First-time model download can take several minutes. Please wait..."); - const pulled = pullOllamaModel(model); - if (!pulled) { - logWarn(`Failed to pull model: ${model}`); - return; - } - logSuccess(`Model downloaded successfully: ${model}`); + logSuccess("API setup looks good."); + logInfo(`Model configured in project config: ${config.ai.model}`); } diff --git a/src/commands/generate.ts b/src/commands/generate.ts index bbd7ea6..82512ec 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -3,7 +3,10 @@ import path from "node:path"; import { SCAN_REPORT_FILE } from "../constants.js"; import { loadConfig } from "../config.js"; import type { ComponentMeta, ScanReport } from "../types.js"; -import { ensureDir, logSuccess, logWarn, resolveFromRoot, writeFileSafe } from "../utils.js"; +import { ensureDir, logInfo, logSuccess, logWarn, resolveFromRoot, writeFileSafe } from "../utils.js"; +import { runScan } from "./scan.js"; +import { runAiSetup } from "./ai-setup.js"; +import { createApiProvider } from "../ai/api.js"; function canonicalPath(inputPath: string): string { try { @@ -49,34 +52,32 @@ function toMirroredTestsPath(projectRoot: string, sourcePath: string, scanDirs: return relDir === "." ? path.join(rootCanonical, outputDir, fileName) : path.join(rootCanonical, outputDir, relDir, fileName); } -function testTemplate(meta: ComponentMeta, importPath: string): string { - const textChecks = [...meta.textLiterals, ...meta.buttonTitles].slice(0, 4); - const assertions = textChecks.length - ? textChecks.map((text) => ` expect(getByText(${JSON.stringify(text)})).toBeTruthy();`).join("\n") - : " expect(toJSON()).toBeTruthy();"; - - const navMock = meta.hasNavigation - ? "jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ navigate: jest.fn() }) }));\n\n" - : ""; +function extractCodeFromApiResponse(raw: string): string { + const fenced = raw.match(/```(?:tsx|ts|jsx|js)?\n([\s\S]*?)```/i); + return (fenced?.[1] ?? raw).trim(); +} - const apiMock = meta.hasApiCalls ? " // TODO: add fetch/axios mock for API-dependent behavior.\n" : ""; - const reduxNote = meta.hasRedux ? " // TODO: wrap with Redux provider when asserting state-driven UI.\n" : ""; +function buildPrompt(meta: ComponentMeta, sourceCode: string, importPath: string): string { + return `You are generating Jest + React Native Testing Library test code for a single file. +Return ONLY test code. No explanation. - return `import React from 'react'; -import { render } from '@testing-library/react-native'; -import ${meta.componentName} from '${importPath}'; +Target component/file name: ${meta.componentName} +Import path to use in test: ${importPath} +Detected hints: +- hasNavigation: ${meta.hasNavigation} +- hasRedux: ${meta.hasRedux} +- hasApiCalls: ${meta.hasApiCalls} +- textLiterals: ${meta.textLiterals.join(", ") || "none"} +- buttonTitles: ${meta.buttonTitles.join(", ") || "none"} -${navMock}describe('${meta.componentName}', () => { - it('renders expected UI', () => { -${reduxNote}${apiMock} const { getByText, toJSON } = render(<${meta.componentName} />); -${assertions} - }); -}); -`; +Source: +${sourceCode}`; } -export function runGenerate(projectRoot: string, options: { force?: boolean }): void { +export async function runGenerate(projectRoot: string, options: { force?: boolean }): Promise { const config = loadConfig(projectRoot); + runScan(projectRoot); + await runAiSetup(projectRoot, {}); const reportPath = resolveFromRoot(projectRoot, SCAN_REPORT_FILE); if (!fs.existsSync(reportPath)) { logWarn("No scan report found. Run `react-native-testsmith scan` first."); @@ -86,8 +87,13 @@ export function runGenerate(projectRoot: string, options: { force?: boolean }): const report = JSON.parse(fs.readFileSync(reportPath, "utf8")) as ScanReport; const overwrite = Boolean(options.force); let written = 0; + let responded = 0; + const provider = createApiProvider(); - for (const component of report.components) { + for (let idx = 0; idx < report.components.length; idx += 1) { + const component = report.components[idx]; + const relSource = path.relative(projectRoot, component.filePath); + logInfo(`[${idx + 1}/${report.components.length}] Processing ${relSource}`); const outPath = config.testFileStyle === "co-located" ? path.join(path.dirname(component.filePath), `${component.componentName}${toTestExtension(component.filePath)}`) @@ -95,10 +101,21 @@ export function runGenerate(projectRoot: string, options: { force?: boolean }): ensureDir(outPath); const importPath = toImportPath(outPath, component.filePath); - const content = testTemplate(component, importPath); + const sourceCode = fs.readFileSync(component.filePath, "utf8"); + const prompt = buildPrompt(component, sourceCode, importPath); + let content = ""; + try { + const apiRaw = await provider.generateText({ prompt, model: config.ai.model }); + responded += 1; + content = `${extractCodeFromApiResponse(apiRaw)}\n`; + } catch (error) { + logWarn(`API failed for ${relSource}: ${error instanceof Error ? error.message : String(error)}`); + continue; + } const res = writeFileSafe(outPath, content, overwrite); if (res.written) written += 1; } + logSuccess(`AI responded for ${responded}/${report.components.length} file(s).`); logSuccess(`Generated ${written} test file(s).`); } diff --git a/src/commands/init.ts b/src/commands/init.ts index 62d0485..bf5aac3 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,12 +1,15 @@ import { writeDefaultConfig } from "../config.js"; import { logInfo, logSuccess, logWarn } from "../utils.js"; +import { runSetup } from "./setup.js"; export function runInit(projectRoot: string, options: { force?: boolean }): void { const result = writeDefaultConfig(projectRoot, Boolean(options.force)); if (!result.written) { logWarn(`Config already exists at ${result.path}. Use --force to overwrite.`); - return; + } else { + logSuccess(`Created ${result.path}`); } - logSuccess(`Created ${result.path}`); - logInfo("Local-only mode enabled by default (Ollama runtime)."); + logInfo("Running project test setup..."); + runSetup(projectRoot, { force: Boolean(options.force) }); + logSuccess("Initialization completed."); } diff --git a/src/commands/scan.ts b/src/commands/scan.ts index 3e43cfd..d7db89f 100644 --- a/src/commands/scan.ts +++ b/src/commands/scan.ts @@ -55,7 +55,10 @@ function parseComponent(filePath: string): ComponentMeta | null { } }); - if (!componentName) return null; + if (!componentName) { + const baseName = path.basename(filePath, path.extname(filePath)); + componentName = baseName.replace(/[^a-zA-Z0-9_$]/g, "_"); + } return { filePath, @@ -70,7 +73,8 @@ function parseComponent(filePath: string): ComponentMeta | null { export function runScan(projectRoot: string): void { const config = loadConfig(projectRoot); - const patterns = config.scanDirs.map((d) => `${d.replace(/\/+$/, "")}/**/*.{tsx,jsx}`); + const patterns = config.scanDirs.map((d) => `${d.replace(/\/+$/, "")}/**/*.{tsx,jsx,ts,js}`); + patterns.push("App.{tsx,jsx,ts,js}"); const files = fg.sync(patterns, { cwd: projectRoot, absolute: true, onlyFiles: true }); const components: ComponentMeta[] = []; diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 82880f2..5395720 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -119,6 +119,16 @@ function mergeObjectField(content: string, fieldName: string, entries: Array<{ k return content.replace(fieldRegex, `${match[1]}${nextBody}${match[3]}`); } +function insertTopLevelField(content: string, fieldBlock: string): string { + const exportRegex = /module\.exports\s*=\s*\{([\s\S]*?)\}\s*;?/m; + const match = content.match(exportRegex); + if (!match) return content; + const existingBody = match[1].trimEnd(); + const separator = existingBody.length > 0 && !existingBody.trim().endsWith(",") ? "," : ""; + const nextBody = `${existingBody}${separator}\n ${fieldBlock}\n`; + return content.replace(exportRegex, `module.exports = {${nextBody}};`); +} + function mergeJestConfig(existing: string): string { let merged = existing; @@ -130,9 +140,24 @@ function mergeJestConfig(existing: string): string { } merged = mergeArrayField(merged, "setupFilesAfterEnv", ["'/jest.setup.ts'"]); + if (!/setupFilesAfterEnv\s*:\s*\[/m.test(merged)) { + merged = insertTopLevelField(merged, "setupFilesAfterEnv: ['/jest.setup.ts'],"); + } + + if (!/transform\s*:\s*\{/m.test(merged)) { + merged = insertTopLevelField(merged, "transform: { '^.+\\\\.[jt]sx?$': 'babel-jest' },"); + } + merged = mergeArrayField(merged, "transformIgnorePatterns", [ "'node_modules/(?!(react-native|@react-native|@react-navigation|@react-native-community|@testing-library/react-native|react-native-linear-gradient)/)'" ]); + if (!/transformIgnorePatterns\s*:\s*\[/m.test(merged)) { + merged = insertTopLevelField( + merged, + "transformIgnorePatterns: ['node_modules/(?!(react-native|@react-native|@react-navigation|@react-native-community|@testing-library/react-native|react-native-linear-gradient)/)']," + ); + } + merged = mergeObjectField(merged, "moduleNameMapper", [ { key: "'^@/(.*)$'", value: "'/src/$1'" }, { @@ -143,6 +168,12 @@ function mergeJestConfig(existing: string): string { { key: "'react-native-responsive-fontsize'", value: "'/__mocks__/react-native-responsive-fontsize.tsx'" }, { key: "'react-native-size-matters'", value: "'/__mocks__/react-native-size-matters.tsx'" } ]); + if (!/moduleNameMapper\s*:\s*\{/m.test(merged)) { + merged = insertTopLevelField( + merged, + "moduleNameMapper: { '^@/(.*)$': '/src/$1', '\\\\.(png|jpg|jpeg|gif|webp|svg|mp4|mp3|ttf|woff|woff2)$': '/__mocks__/fileMock.tsx', '\\\\.(css|less|scss|sass)$': '/__mocks__/styleMock.ts', 'react-native-responsive-fontsize': '/__mocks__/react-native-responsive-fontsize.tsx', 'react-native-size-matters': '/__mocks__/react-native-size-matters.tsx' }," + ); + } return merged; } diff --git a/src/constants.ts b/src/constants.ts index 89991ba..39a3e6c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -10,8 +10,8 @@ export const DEFAULT_CONFIG: RuntimeConfig = { testFileStyle: "tests-dir", ai: { enabled: true, - runtime: "ollama", - model: "qwen2.5-coder:7b", + runtime: "api", + model: "default", maxRetries: 1 } }; diff --git a/src/types.ts b/src/types.ts index 9c41651..d11c553 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,7 +4,7 @@ export type RuntimeConfig = { testFileStyle: "co-located" | "tests-dir"; ai: { enabled: boolean; - runtime: "ollama" | "api"; + runtime: "api"; model: string; maxRetries: number; }; diff --git a/tests/scan-generate.integration.test.mjs b/tests/scan-generate.integration.test.mjs index 7e01a5c..e9cb47c 100644 --- a/tests/scan-generate.integration.test.mjs +++ b/tests/scan-generate.integration.test.mjs @@ -113,9 +113,6 @@ test("generate creates test template with smart hints", () => { const generated = fs.readFileSync(outputPath, "utf8"); assert.match(generated, /describe\('LoginScreen'/); - assert.match(generated, /getByText\("Login"\)/); - assert.match(generated, /getByText\("Submit"\)/); - assert.match(generated, /@react-navigation\/native/); - assert.match(generated, /TODO: wrap with Redux provider/); - assert.match(generated, /TODO: add fetch\/axios mock/); + assert.match(generated, /import LoginScreen/); + assert.match(generated, /it\('/); }); From 108d3214d62b7384188a1329e1b3c80563563330 Mon Sep 17 00:00:00 2001 From: fazzysyed Date: Tue, 28 Apr 2026 15:33:52 +0500 Subject: [PATCH 3/5] Refresh README for 1.0.1 API-first workflow. Add 1.0.1 announcement notes, remove outdated extra command references, and simplify docs around init+generate+doctor with API chunking/progress behavior. Made-with: Cursor --- README.md | 44 +++++++++++++------------------------------- 1 file changed, 13 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index dc952a3..f956009 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,14 @@ Production-ready CLI for React Native unit testing with Jest and React Native Testing Library. +## 1.0.1 Update + +- Switched to API-first AI generation (Ollama removed on this branch) +- `init` now runs Jest setup automatically +- `generate` now runs full pipeline: scan + API check + per-file AI generation +- Long files are chunked automatically for free-tier model limits +- Added per-file progress logs and final generation summary counts + ## Why this exists - Automates painful Jest + RN config @@ -40,8 +48,6 @@ If you see `command not found`, install globally (`npm i -g .`) or run with `npx ```bash react-native-testsmith init react-native-testsmith generate -react-native-testsmith ai-setup -react-native-testsmith ai-enhance --target src/screens/LoginScreen.tsx react-native-testsmith doctor react-native-testsmith doctor --json ``` @@ -51,7 +57,6 @@ react-native-testsmith doctor --json ```bash react-native-testsmith init react-native-testsmith generate -react-native-testsmith ai-enhance --target src/screens/LoginScreen.tsx --apply ``` ## Full setup steps (end-to-end) @@ -88,19 +93,7 @@ react-native-testsmith setup react-native-testsmith generate ``` -6. Verify API runtime setup (optional standalone check) - -```bash -react-native-testsmith ai-setup -``` - -7. Generate/improve tests with AI for a specific file - -```bash -react-native-testsmith ai-enhance --target src/screens/LoginScreen.tsx --apply --run-jest -``` - -8. Validate setup health +6. Verify setup health ```bash react-native-testsmith doctor @@ -134,24 +127,13 @@ Example: - `src/components/Button.tsx` -> `__tests__/components/Button.test.tsx` - `src/screens/auth/Login.js` -> `__tests__/screens/auth/Login.test.js` -## AI enhancement (API) - -`ai-enhance` uses an API backend: - -```bash -react-native-testsmith ai-setup -react-native-testsmith ai-enhance --target src/screens/LoginScreen.tsx --apply -react-native-testsmith ai-enhance --target src/screens/LoginScreen.tsx --model default --apply --run-jest -``` +## API notes -Notes: -- `ai-setup` checks API endpoint connectivity. -- Without `--apply`, the command runs in preview mode and prints output. -- If `--run-jest` is set and Jest fails, AI auto-fix retries run (based on `ai.maxRetries`). +- `generate` is the primary AI command now (project-wide). - `RN_TESTSMITH_API_URL` overrides endpoint and `RN_TESTSMITH_API_KEY` is optional. -- For long files, API runtime automatically chunks input and synthesizes a final test response. +- Long files are chunked automatically and then synthesized. - `scan` includes `App.ts`, `App.js`, `App.tsx`, and `App.jsx` at project root. -- `generate` shows per-file progress and final counts for AI responses and generated files. +- Terminal output includes per-file progress and final AI response/generated counts. ## CI From 4dbd04eab71c7e36a599374a71d593bdf652dc89 Mon Sep 17 00:00:00 2001 From: fazzysyed Date: Tue, 28 Apr 2026 15:40:13 +0500 Subject: [PATCH 4/5] 0.1.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1ea1c0b..80141e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "react-native-testsmith", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "react-native-testsmith", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { "@babel/parser": "^7.28.0", diff --git a/package.json b/package.json index 25888b1..60a85e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-testsmith", - "version": "0.1.0", + "version": "0.1.1", "description": "Local-first React Native testing CLI for Jest and Testing Library", "license": "MIT", "type": "module", From 357e44b1fcdf5ed175d881c2fcd1442a05aa3e55 Mon Sep 17 00:00:00 2001 From: fazzysyed Date: Tue, 28 Apr 2026 15:50:35 +0500 Subject: [PATCH 5/5] Harden API generation reliability for 1.0.2. Add API timeout/retry with exponential backoff, per-run failed-files reporting with --failed-only retry mode, and fallback template generation when API calls fail so pipeline progress remains stable. Made-with: Cursor --- README.md | 3 ++ src/ai/api.ts | 67 +++++++++++++++++++++++++++----------- src/bin.ts | 1 + src/commands/generate.ts | 69 ++++++++++++++++++++++++++++++++++++---- src/constants.ts | 1 + 5 files changed, 115 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index f956009..53cf217 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ If you see `command not found`, install globally (`npm i -g .`) or run with `npx ```bash react-native-testsmith init react-native-testsmith generate +react-native-testsmith generate --failed-only react-native-testsmith doctor react-native-testsmith doctor --json ``` @@ -134,6 +135,8 @@ Example: - Long files are chunked automatically and then synthesized. - `scan` includes `App.ts`, `App.js`, `App.tsx`, and `App.jsx` at project root. - Terminal output includes per-file progress and final AI response/generated counts. +- 5xx API errors/timeouts are retried automatically (configurable via env vars). +- Failed files are stored in `.react-native-testsmith/failed-files.json` and can be retried with `generate --failed-only`. ## CI diff --git a/src/ai/api.ts b/src/ai/api.ts index 4c53d19..5aaab06 100644 --- a/src/ai/api.ts +++ b/src/ai/api.ts @@ -19,28 +19,57 @@ function extractApiText(payload: unknown): string { } async function callApi(endpoint: string, bodyText: string, apiKey?: string): Promise { - const res = await fetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "text/plain", - ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) - }, - body: bodyText - }); - - if (!res.ok) { - throw new Error(`API request failed: ${res.status} ${res.statusText}`); - } + const timeoutMs = Number(process.env.RN_TESTSMITH_API_TIMEOUT_MS ?? "120000"); + const maxRetries = Number(process.env.RN_TESTSMITH_API_RETRIES ?? "2"); + const baseBackoffMs = Number(process.env.RN_TESTSMITH_API_BACKOFF_MS ?? "2000"); + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "text/plain", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}) + }, + body: bodyText, + signal: controller.signal + }); + clearTimeout(timer); + + if (!res.ok) { + if (res.status >= 500 && res.status <= 599 && attempt < maxRetries) { + const backoff = baseBackoffMs * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, backoff)); + continue; + } + throw new Error(`API request failed: ${res.status} ${res.statusText}`); + } - const contentType = res.headers.get("content-type") ?? ""; - if (contentType.includes("application/json")) { - const json = await res.json(); - const text = extractApiText(json); - if (!text) throw new Error("API response did not include a text payload."); - return text; + const contentType = res.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + const json = await res.json(); + const text = extractApiText(json); + if (!text) throw new Error("API response did not include a text payload."); + return text; + } + + return res.text(); + } catch (error) { + clearTimeout(timer); + const isAbort = error instanceof Error && error.name === "AbortError"; + lastError = new Error(isAbort ? `API request timed out after ${timeoutMs}ms` : (error instanceof Error ? error.message : String(error))); + if (attempt < maxRetries) { + const backoff = baseBackoffMs * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, backoff)); + continue; + } + } } - return res.text(); + throw lastError ?? new Error("API request failed"); } function chunkText(input: string, chunkSize: number): string[] { diff --git a/src/bin.ts b/src/bin.ts index 9e437e1..db016ad 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -41,6 +41,7 @@ program .command("generate") .description("Run full AI generation pipeline for all scanned files") .option("-f, --force", "overwrite existing test files") + .option("--failed-only", "process only files that failed in previous run") .action(async (options) => runGenerate(projectRoot, options)); program diff --git a/src/commands/generate.ts b/src/commands/generate.ts index 82512ec..5b639b3 100644 --- a/src/commands/generate.ts +++ b/src/commands/generate.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { SCAN_REPORT_FILE } from "../constants.js"; +import { FAILED_FILES_REPORT, SCAN_REPORT_FILE } from "../constants.js"; import { loadConfig } from "../config.js"; import type { ComponentMeta, ScanReport } from "../types.js"; import { ensureDir, logInfo, logSuccess, logWarn, resolveFromRoot, writeFileSafe } from "../utils.js"; @@ -74,7 +74,31 @@ Source: ${sourceCode}`; } -export async function runGenerate(projectRoot: string, options: { force?: boolean }): Promise { +function fallbackTemplate(meta: ComponentMeta, importPath: string): string { + const textChecks = [...meta.textLiterals, ...meta.buttonTitles].slice(0, 4); + const assertions = textChecks.length + ? textChecks.map((text) => ` expect(getByText(${JSON.stringify(text)})).toBeTruthy();`).join("\n") + : " expect(toJSON()).toBeTruthy();"; + + return `import React from 'react'; +import { render } from '@testing-library/react-native'; +import ${meta.componentName} from '${importPath}'; + +describe('${meta.componentName}', () => { + it('renders expected UI', () => { + const { getByText, toJSON } = render(<${meta.componentName} />); +${assertions} + }); +}); +`; +} + +type GenerateOptions = { + force?: boolean; + failedOnly?: boolean; +}; + +export async function runGenerate(projectRoot: string, options: GenerateOptions): Promise { const config = loadConfig(projectRoot); runScan(projectRoot); await runAiSetup(projectRoot, {}); @@ -86,14 +110,31 @@ export async function runGenerate(projectRoot: string, options: { force?: boolea const report = JSON.parse(fs.readFileSync(reportPath, "utf8")) as ScanReport; const overwrite = Boolean(options.force); + const failedOnly = Boolean(options.failedOnly); let written = 0; let responded = 0; const provider = createApiProvider(); + const failedReportPath = resolveFromRoot(projectRoot, FAILED_FILES_REPORT); - for (let idx = 0; idx < report.components.length; idx += 1) { - const component = report.components[idx]; + let components = report.components; + if (failedOnly) { + if (!fs.existsSync(failedReportPath)) { + logWarn("No failed files report found. Run generate normally first."); + return; + } + const failedData = JSON.parse(fs.readFileSync(failedReportPath, "utf8")) as { files?: string[] }; + const failedSet = new Set((failedData.files ?? []).map((p) => canonicalPath(resolveFromRoot(projectRoot, p)))); + components = components.filter((c) => failedSet.has(canonicalPath(c.filePath))); + logInfo(`Re-running only failed files: ${components.length}`); + } + + const failedFiles: string[] = []; + + for (let idx = 0; idx < components.length; idx += 1) { + const component = components[idx]; const relSource = path.relative(projectRoot, component.filePath); - logInfo(`[${idx + 1}/${report.components.length}] Processing ${relSource}`); + const started = Date.now(); + logInfo(`[${idx + 1}/${components.length}] Processing ${relSource}`); const outPath = config.testFileStyle === "co-located" ? path.join(path.dirname(component.filePath), `${component.componentName}${toTestExtension(component.filePath)}`) @@ -110,12 +151,26 @@ export async function runGenerate(projectRoot: string, options: { force?: boolea content = `${extractCodeFromApiResponse(apiRaw)}\n`; } catch (error) { logWarn(`API failed for ${relSource}: ${error instanceof Error ? error.message : String(error)}`); - continue; + failedFiles.push(relSource); + content = fallbackTemplate(component, importPath); + logInfo(`Using fallback template for ${relSource}`); } const res = writeFileSafe(outPath, content, overwrite); if (res.written) written += 1; + logInfo(`Completed ${relSource} in ${Date.now() - started}ms`); } - logSuccess(`AI responded for ${responded}/${report.components.length} file(s).`); + ensureDir(failedReportPath); + fs.writeFileSync( + failedReportPath, + `${JSON.stringify({ generatedAt: new Date().toISOString(), files: failedFiles }, null, 2)}\n`, + "utf8" + ); + + logSuccess(`AI responded for ${responded}/${components.length} file(s).`); logSuccess(`Generated ${written} test file(s).`); + if (failedFiles.length > 0) { + logWarn(`Failed files: ${failedFiles.length}. Saved to ${failedReportPath}`); + logInfo("You can retry only failed files with: react-native-testsmith generate --failed-only"); + } } diff --git a/src/constants.ts b/src/constants.ts index 39a3e6c..382c24d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,6 +3,7 @@ import type { RuntimeConfig } from "./types.js"; export const CONFIG_FILE = ".react-native-testsmith.json"; export const SCAN_CACHE_DIR = ".react-native-testsmith"; export const SCAN_REPORT_FILE = ".react-native-testsmith/scan-report.json"; +export const FAILED_FILES_REPORT = ".react-native-testsmith/failed-files.json"; export const DEFAULT_CONFIG: RuntimeConfig = { scanDirs: ["src/components", "src/screens"],