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
102 changes: 80 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,60 @@ Everything you need for API testing. Nothing you don't.
- **OpenAPI Import** (`openapi-generate`) — Generate test files from any OpenAPI spec. Point it at a file, and Napper creates `.nap` files with requests, headers, bodies, and assertions. Optionally enhance with AI via GitHub Copilot (`vscode-openapi-ai`).
- **Plain Text, Git Friendly** (`nap-file`) — Every request is a `.nap` file. Every environment is a `.napenv` file (`env-file`). Version control everything. No binary blobs, no lock-in.

## Quick Start
## Installation

### VS Code Extension

### Install the VS Code Extension
Install from the marketplace in one command:

```sh
code --install-extension nimblesite.napper
```

### Or grab the CLI binary
Or search **"Napper"** in the VS Code Extensions panel (`Ctrl+Shift+X` / `Cmd+Shift+X`) and click Install.

To install a specific `.vsix` manually: open the Extensions panel → `...` menu → **Install from VSIX...**.

> **Requirements:** VS Code 1.95.0 or later. The extension shells out to the CLI, so install the CLI binary too.

### CLI Binary

The CLI is a self-contained binary with **no runtime dependencies**.

| Platform | Download |
|----------|----------|
| macOS (Apple Silicon) | [`napper-osx-arm64`](https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-osx-arm64) |
| macOS (Intel) | [`napper-osx-x64`](https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-osx-x64) |
| Linux (x64) | [`napper-linux-x64`](https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-linux-x64) |
| Windows (x64) | [`napper-win-x64.exe`](https://github.com/MelbourneDeveloper/napper/releases/latest/download/napper-win-x64.exe) |

**macOS / Linux:**
```sh
chmod +x napper-osx-arm64
mv napper-osx-arm64 /usr/local/bin/napper
napper --version
```

**Install script (macOS / Linux):**
```sh
curl -fsSL https://raw.githubusercontent.com/MelbourneDeveloper/napper/main/scripts/install.sh | bash
```

Download from the [latest release](https://github.com/MelbourneDeveloper/napper/releases).
**Install script (Windows PowerShell):**
```powershell
irm https://raw.githubusercontent.com/MelbourneDeveloper/napper/main/scripts/install.ps1 | iex
```

**Build from source** (requires .NET SDK + `make`):
```sh
git clone https://github.com/MelbourneDeveloper/napper.git && cd napper && make install-binaries
```

> **Note:** F# (`.fsx`) and C# (`.csx`) script hooks require the [.NET 10 SDK](https://dotnet.microsoft.com/download). Plain `.nap` and `.naplist` files need nothing extra.

See the [full installation guide](https://napperapi.dev/docs/installation/) for VSIX manual install, troubleshooting, and macOS Gatekeeper notes.

## Quick Start

## How do you use Napper?

Expand Down Expand Up @@ -188,44 +231,58 @@ Variable priority (highest wins):

## OpenAPI Import

Generate `.nap` test files automatically from any OpenAPI specification. Available from the CLI and the VS Code extension.
Generate `.nap` test files automatically from any OpenAPI or Swagger spec. Napper creates one file per operation, a `.naplist` playlist, and a `.napenv` environment file — giving you a working test suite in seconds.

**Supported formats:** OpenAPI 3.0.x, OpenAPI 3.1.x, Swagger 2.0 (JSON input).

### From the CLI

```sh
# Generate from a local spec file
napper generate openapi ./petstore.json --output-dir ./tests

# Output as JSON (for programmatic use)
napper generate openapi ./spec.yaml --output-dir ./tests --output json
# Output a JSON summary for scripting
napper generate openapi ./spec.json --output-dir ./tests --output json
```

### From VS Code

The extension provides two commands (accessible via the Command Palette):
Open the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) and choose:

- **Napper: Import OpenAPI from URL** — Enter a URL to an OpenAPI spec (e.g. `https://petstore3.swagger.io/api/v3/openapi.json`). The extension downloads the spec, generates `.nap` files, and creates a `.naplist` playlist.
- **Napper: Import OpenAPI from File** — Select a local OpenAPI spec file (JSON or YAML) and an output folder.
- **Napper: Import OpenAPI from URL** — paste a URL (e.g. `https://petstore3.swagger.io/api/v3/openapi.json`). Napper downloads the spec and generates files.
- **Napper: Import OpenAPI from File** — browse to a local `.json` spec file.

Both commands prompt you to choose between basic generation or AI-enhanced generation (requires GitHub Copilot). AI enhancement adds smarter assertions, realistic test data, and reorders the playlist for logical test flow.
Both commands prompt for an output folder and offer basic or AI-enhanced generation.

### What gets generated

| File | Purpose |
|------|---------|
| `01_get-users.nap`, `02_post-users.nap`, ... | One `.nap` file per API endpoint with request, headers, body, and assertions |
| `api-name.naplist` | Playlist referencing all generated files in order |
| `.napenv` | Environment file with the API base URL |
Endpoints are grouped into subdirectories by API tag:

```
tests/
├── pets/
│ ├── get-pets.nap
│ ├── post-pets.nap
│ └── get-pets-petId.nap
├── store/
│ └── get-store-inventory.nap
├── petstore.naplist
└── .napenv
```

Each `.nap` file includes the method, URL (with path params as `{{variables}}`), auth headers, request body (from schema), and status code assertions. The `.napenv` file contains the base URL from the spec's `servers` field and variable placeholders for auth tokens.

### AI Enhancement (optional)

### AI Enhancement (Optional)
With GitHub Copilot available, choose AI-enhanced generation to get:

When GitHub Copilot is available, you can opt for AI-enhanced generation which:
- Semantic assertions beyond status codes (e.g. `body.email contains @`)
- Realistic test data in request bodies instead of placeholder values
- Logical playlist ordering (auth first, then CRUD in dependency order)

- Adds semantic assertions beyond basic status checks (e.g. `body.email contains @`)
- Generates realistic test data for request bodies
- Reorders the playlist for logical flow (auth first, then CRUD operations)
Falls back to basic generation automatically if Copilot is unavailable.

If Copilot is not available, a warning is shown and basic generation proceeds normally.
See the [full OpenAPI import guide](https://napperapi.dev/docs/openapi-import/) for authentication handling, `$ref` resolution, customisation tips, and troubleshooting.

## CLI Reference

Expand All @@ -241,6 +298,7 @@ Options:
--var <key=value> Variable override (repeatable) (cli-var)
--output <format> Output: pretty, junit, json, ndjson (cli-output)
--output-dir <dir> Output directory for generate command (cli-output-dir)
--version Print the installed CLI version
--verbose Enable debug-level logging (cli-verbose)
```

Expand Down
10 changes: 6 additions & 4 deletions src/Napper.Core.Tests/OpenApiE2eTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ let ``Petstore POST endpoints have request body`` () =
allNaps
|> Array.filter (fun f ->
let content = File.ReadAllText(f)
content.Contains("POST {{baseUrl}}"))
content.Contains("method = POST"))

Assert.True(postFiles.Length >= 1, "Must have at least one POST endpoint")

Expand Down Expand Up @@ -397,7 +397,7 @@ let ``Beeceptor POST endpoints have request body and headers`` () =
allNaps
|> Array.filter (fun f ->
let content = File.ReadAllText(f)
content.Contains "POST {{baseUrl}}")
content.Contains "method = POST")
// auth/register, auth/login, cart/items, checkout, addresses POST = 5
Assert.True(postFiles.Length >= 5, $"Must have at least 5 POST endpoints, got {postFiles.Length}")

Expand Down Expand Up @@ -483,7 +483,9 @@ let ``Beeceptor checkout endpoint asserts 201 status`` () =
allNaps
|> Array.filter (fun f ->
let content = File.ReadAllText(f)
content.Contains("POST {{baseUrl}}/checkout"))

content.Contains("method = POST")
&& content.Contains("url = {{baseUrl}}/checkout"))

Assert.True(checkoutFiles.Length >= 1, "Must have checkout endpoint")
let content = File.ReadAllText(checkoutFiles[0])
Expand Down Expand Up @@ -525,7 +527,7 @@ let ``Petstore POST endpoints include actual JSON body content`` () =
allNaps
|> Array.filter (fun f ->
let content = File.ReadAllText(f)
content.Contains("POST {{baseUrl}}") && content.Contains("[request.body]"))
content.Contains("method = POST") && content.Contains("[request.body]"))

Assert.True(postFilesWithBody.Length >= 1, "Must have POST endpoints with body")

Expand Down
43 changes: 41 additions & 2 deletions src/Napper.Core.Tests/OpenApiGeneratorTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ module OpenApiGeneratorTests
// openapi-meta-flag, nap-meta, nap-request, nap-headers, nap-body, nap-vars, nap-assert

open Xunit
open Napper.Core
open Napper.Core.OpenApiGenerator

// --- Helpers ---
Expand Down Expand Up @@ -153,7 +154,8 @@ let ``OAS3 nap file contains meta section with name`` () =
let ``OAS3 nap file contains request section`` () =
let content = (unwrap minimalOas3 |> firstFile).Content
Assert.Contains("[request]", content)
Assert.Contains("GET {{baseUrl}}/users", content)
Assert.Contains("method = GET", content)
Assert.Contains("url = {{baseUrl}}/users", content)

[<Fact>]
let ``OAS3 nap file contains assert section`` () =
Expand Down Expand Up @@ -200,7 +202,8 @@ let ``Swagger2 generates nap file`` () =
let gen = unwrap minimalSwagger2
Assert.Equal(1, gen.NapFiles.Length)
let content = (firstFile gen).Content
Assert.Contains("GET {{baseUrl}}/items", content)
Assert.Contains("method = GET", content)
Assert.Contains("url = {{baseUrl}}/items", content)

// --- Multiple endpoints --- Spec: openapi-nap-gen, openapi-params, openapi-assert-gen

Expand Down Expand Up @@ -716,6 +719,42 @@ let ``Environment file has baseUrl key-value pair`` () =

// --- Base URL fallback --- Spec: openapi-baseurl

// --- Generated files must be parseable --- Spec: openapi-nap-gen, nap-file

[<Fact>]
let ``Generated nap files are parseable by the nap parser`` () =
let gen = unwrap minimalOas3

for f in gen.NapFiles do
match Napper.Core.Parser.parseNapFile f.Content with
| Ok parsed ->
Assert.Equal(GET, parsed.Request.Method)
Assert.Contains("{{baseUrl}}/users", parsed.Request.Url)
| Error e -> failwith $"Generated file '{f.FileName}' failed to parse: {e}"

[<Fact>]
let ``Generated POST nap file is parseable with correct method and body`` () =
let gen = unwrap multiMethodSpec
let postFile = gen.NapFiles |> List.find (fun f -> f.Content.Contains("Create pet"))

match Napper.Core.Parser.parseNapFile postFile.Content with
| Ok parsed ->
Assert.Equal(POST, parsed.Request.Method)
Assert.Contains("{{baseUrl}}/pets", parsed.Request.Url)
Assert.True(parsed.Request.Body.IsSome, "POST must have a request body")
| Error e -> failwith $"Generated POST file failed to parse: {e}"

[<Fact>]
let ``Generated nap file with path params is parseable`` () =
let gen = unwrap multiMethodSpec
let petFile = gen.NapFiles |> List.find (fun f -> f.Content.Contains("getPetById"))

match Napper.Core.Parser.parseNapFile petFile.Content with
| Ok parsed ->
Assert.Contains("{{petId}}", parsed.Request.Url)
Assert.True(parsed.Vars.ContainsKey("petId"), "Must have petId var")
| Error e -> failwith $"Generated file with path params failed to parse: {e}"

[<Fact>]
let ``Falls back to default URL when no servers or host`` () =
let spec =
Expand Down
5 changes: 4 additions & 1 deletion src/Napper.Core/OpenApiGenerator.fs
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,10 @@ let private buildRequest (ep: EndpointInfo) : string list =
let url =
sprintf "%s%s%s" BaseUrlVar (convertPathParams ep.UrlPath) (buildQuery ep.QueryParams)

[ SectionRequest; sprintf "%s %s" (ep.Method.ToUpperInvariant()) url; "" ]
[ SectionRequest
sprintf "%s = %s" KeyMethod (ep.Method.ToUpperInvariant())
sprintf "%s = %s" KeyUrl url
"" ]

let private buildHeaders (ep: EndpointInfo) : string list =
let hasBody = methodHasBody ep.Method
Expand Down
6 changes: 6 additions & 0 deletions src/Napper.Core/OpenApiTypes.fs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ let KeyName = "name"
[<Literal>]
let KeyDescription = "description"

[<Literal>]
let KeyMethod = "method"

[<Literal>]
let KeyUrl = "url"

[<Literal>]
let KeyGenerated = "generated"

Expand Down
1 change: 1 addition & 0 deletions src/Napper.VsCode/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const CSX_EXTENSION = '.csx';
export const NAP_GLOB = '**/*.nap';
export const NAPLIST_GLOB = '**/*.naplist';
export const NAPENV_GLOB = '**/.napenv*';
export const DIRECTORY_GLOB = '**/';

// View IDs
export const VIEW_EXPLORER = 'napperExplorer';
Expand Down
36 changes: 36 additions & 0 deletions src/Napper.VsCode/src/test/e2e/explorer.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as fs from 'fs';
import {
activateExtension,
closeAllEditors,
deleteFixtureDir,
deleteFixtureFile,
getFixturePath,
openDocument,
Expand Down Expand Up @@ -157,6 +158,41 @@ suite('Explorer Tree View', () => {
);
});

test('folder deletion triggers tree refresh via watcher', async function () {
this.timeout(20000);
const testDir = 'watcher-folder-test',
testFile = `${testDir}/probe.nap`,
napContent = '[request]\nmethod = "GET"\nurl = "https://httpbin.org/get"\n';

// Create a folder with a .nap file so it appears in the tree
writeFixtureFile(testFile, napContent);
await sleep(3000);

const provider = getExplorerProvider(),
nodesAfterCreate = provider.getChildren(),
folderNode = findNodeByLabel(nodesAfterCreate, testDir);
assert.ok(folderNode, `Folder "${testDir}" must appear in explorer after creation`);

// Listen for onDidChangeTreeData — this is what VS Code uses to know
// it should call getChildren() again and repaint the tree
let refreshFired = false;
const disposable = provider.onDidChangeTreeData(() => {
refreshFired = true;
});

// Delete the entire folder from disk (not individual files)
deleteFixtureDir(testDir);

// Wait for the watcher to fire a refresh
await sleep(5000);
disposable.dispose();

assert.ok(
refreshFired,
'onDidChangeTreeData must fire after a folder is deleted — the tree must react to folder removal',
);
});

test('nested playlist in file tree also expands with children', function () {
this.timeout(10000);
const provider = getExplorerProvider(),
Expand Down
7 changes: 7 additions & 0 deletions src/Napper.VsCode/src/test/helpers/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ export const deleteFixtureFile = (relativePath: string): void => {
}
};

export const deleteFixtureDir = (relativePath: string): void => {
const fullPath = getFixturePath(relativePath);
if (fs.existsSync(fullPath)) {
fs.rmSync(fullPath, { recursive: true });
}
};

export const waitForCondition = async (
condition: () => boolean | Promise<boolean>,
timeout = 10000,
Expand Down
Loading
Loading