Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 26 additions & 48 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,22 @@
[![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.

## 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
- 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

Expand Down Expand Up @@ -39,14 +47,8 @@ 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 generate --failed-only
react-native-testsmith doctor
react-native-testsmith doctor --json
```
Expand All @@ -55,11 +57,7 @@ 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
```

## Full setup steps (end-to-end)
Expand All @@ -85,34 +83,18 @@ 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)

```bash
react-native-testsmith ai-setup
```

8. 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
6. Verify setup health

```bash
react-native-testsmith doctor
Expand All @@ -136,6 +118,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`.
Expand All @@ -144,21 +128,15 @@ 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-enhance` uses a local Ollama model (no external API):

```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
```
## API notes

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.
- 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.
- 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

Expand All @@ -174,7 +152,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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
135 changes: 135 additions & 0 deletions src/ai/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
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 ?? "";
}

async function callApi(endpoint: string, bodyText: string, apiKey?: string): Promise<string> {
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;
}

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;
}
}
}

throw lastError ?? new Error("API request failed");
}

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 ?? "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 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 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));
}

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);
}
};
}
55 changes: 0 additions & 55 deletions src/ai/ollama.ts

This file was deleted.

11 changes: 11 additions & 0 deletions src/ai/provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export type AiProviderRuntime = "api";

export type GenerateTextInput = {
prompt: string;
model: string;
};

export type AiProvider = {
runtime: AiProviderRuntime;
generateText: (input: GenerateTextInput) => Promise<string>;
};
Loading
Loading