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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ requests.http
build
/test_*_output*/*
node_modules
/examples/**/node_modules/
/examples/**/results/
/httprunner
/harparser
/results
/testapi/results/
.idea/copilot*
.idea/copilot*
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ github.com/deicon/httprunner/
- `-offset n`: Max random startup delay between virtual users in milliseconds (default: 0)
- `-f filename`: .http file containing HTTP requests (required)
- `-e filename`: .env file containing environment variables (optional)
- `--experimental-node-runtime`: Execute JavaScript via a managed Node.js worker (requires Node on PATH); enables native npm packages and async helpers.

#### Reporting and Output Parameters
- `-report format`: Report format: console, html, csv, json (default: html)
Expand Down Expand Up @@ -147,6 +148,27 @@ JavaScript code can be embedded using `> {%` and `%}` blocks in two locations:
- `sleep(millis)`: Sleep execution for millis
- `assert(function() -> bool, message, status_code )`: assert using function returning bool and fail request if false

#### Request helpers and `await`

Any request annotated with `# @name <Name>` can be invoked from JavaScript through `client.<name>()`.
Even though the default Goja runtime resolves these helpers synchronously, **always** `await` the
result (e.g. `const res = await client.create_user()`). The experimental Node.js runtime returns a
Promise for each helper; if it is not awaited the worker emits a warning and the HTTP call may finish
later than expected.

#### Experimental Node.js runtime

By default scripts run inside the embedded Goja engine. Passing `--experimental-node-runtime` to the
CLI starts a dedicated `node` worker per virtual user, enabling modern JavaScript features and loading
native npm packages. The worker communicates with `httprunner` over stdio, mirrors global state, and
supports the same helper surface (`client.global`, checks, assertions, named requests). Node.js must be
available on `PATH`; if it is missing or crashes, the CLI falls back to reporting an execution error.

When the flag is enabled, `httprunner` automatically adds any `node_modules` directories found next
to the `.http` file (and up to two parent directories) to Node's resolution paths. For custom
layouts, you can still extend `NODE_PATH` before launching the runner. See
`examples/external-node-runtime` for a complete walkthrough.

### Example requests.http

#### Basic requests (backward compatible):
Expand Down
54 changes: 54 additions & 0 deletions cmd/httprunner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func main() {
verbose := flag.Bool("v", false, "Verbose mode: print request result JSON for each request")
rawFile := flag.String("raw", "", "Path to raw results .jsonl file to generate report without executing")
csvShortcut := flag.Bool("csv", false, "Shorthand to output CSV from -raw to stdout (forces -report=csv, -detail=summary)")
experimentalNodeRuntime := flag.Bool("experimental-node-runtime", false, "Use experimental Node.js runtime for JavaScript execution")

flag.Parse()

Expand Down Expand Up @@ -221,6 +222,59 @@ func main() {

// Set verbose flag
r.SetVerbose(*verbose)
// Toggle experimental Node runtime if requested
r.EnableNodeRuntime(*experimentalNodeRuntime)
if *experimentalNodeRuntime {
modulePaths := make([]string, 0)
seen := make(map[string]struct{})
addPath := func(candidate string, requireDirCheck bool) {
if candidate == "" {
return
}
absPath, err := filepath.Abs(candidate)
if err != nil {
absPath = filepath.Clean(candidate)
} else {
absPath = filepath.Clean(absPath)
}
if _, ok := seen[absPath]; ok {
return
}
if requireDirCheck {
info, statErr := os.Stat(absPath)
if statErr != nil || !info.IsDir() {
return
}
}
seen[absPath] = struct{}{}
modulePaths = append(modulePaths, absPath)
}

if !offlineMode && *requestFile != "" {
dir := filepath.Dir(*requestFile)
for depth := 0; depth < 3 && dir != ""; depth++ {
nodeModulesPath := filepath.Join(dir, "node_modules")
addPath(nodeModulesPath, true)
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
}

if envPaths := os.Getenv("NODE_PATH"); envPaths != "" {
for _, segment := range strings.Split(envPaths, string(os.PathListSeparator)) {
trimmed := strings.TrimSpace(segment)
if trimmed == "" {
continue
}
addPath(trimmed, true)
}
}

r.SetNodeRequirePaths(modulePaths)
}
// Apply startup offset between VU spawns, if provided
r.StartupOffset = *offset

Expand Down
118 changes: 118 additions & 0 deletions docs/specs/javascript-runtimes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# JavaScript Execution for `.http` Scenarios

The goal of this document is to outline how `httprunner` can execute arbitrary JavaScript inside
`.http` files while keeping the experience predictable, fast and extensible.

## Goals
- Allow script authors to execute synchronous and asynchronous JavaScript snippets.
- Expose the same helper surface (environment, request context, templating helpers) regardless of
the underlying runtime.
- Support modern JavaScript syntax (ES2020+) and ecosystem features such as `npm` packages.
- Keep execution sandboxed to avoid accidental access to host resources beyond what `.http` files
explicitly allow.

## Functional Requirements
- Per-request scripts can read/write scoped state (`{{.vars}}`, dynamic headers, payload templates).
- Long-lived scripts (e.g. `beforeAll`, `afterAll`, custom helpers) may keep shared state between
requests within a scenario.
- Access to selected built-ins (`console`, `fetch`, `crypto`, timers) that match browser/node
behaviour where practical.
- Ability to import third-party libraries declared by the scenario author (see *External Packages*).

## Non-Functional Requirements
- Cold start: < 100 ms for typical scripts to avoid noticeable request latency.
- Memory footprint: bounded per scenario; engine must reclaim state between scenarios.
- Deterministic execution regardless of host OS (Linux/macOS) to keep CI reproducible.
- Observability hooks: structured logging, duration metrics, error stack traces that map back to
the original `.http` file.

## Option 1 — External Node.js Runtime

### Overview
Run each scenario inside a managed Node.js worker process. `httprunner` communicates via stdio or a
local IPC socket using JSON messages (request events, context updates, script results).

### Data Interchange
- `httprunner` sends request context (`vars`, `env`, request metadata).
- Node worker executes user code, mutates state, emits results and logs.
- Shared state stored in the Node process and mirrored back to Go when required. Use explicit
`sync` messages to keep Go authoritative on scenario state.

### Pros
- Full `npm` compatibility with zero transpilation; users may `require`/`import` any package.
- Access to Node’s async primitives, timers, `fetch`/`http`, crypto without custom adapters.
- Easier to keep parity with JavaScript innovation (ES modules, top-level await, etc.).

### Cons
- Process management complexity (startup latency, health checks, worker pool lifecycle).
- Deployment requires Node.js runtime alongside `httprunner`.
- IPC overhead for every script invocation; large payloads need streaming or shared memory.
- Harder to sandbox without additional tooling (e.g. `--experimental-policy`, `vm` contexts).

### Open Questions
- How many concurrent scenarios share a worker? Fixed pool vs. per-scenario process?
- Do we embed a Node distribution or require it to be pre-installed?
- How do we version-lock `npm` dependencies to avoid supply-chain drift?

## Option 2 — Embedded JavaScript Engines

### Candidates
- **Goja**: Pure Go, good ES6 coverage, fast cold start, no native module support.
- **Otto**: Mature but lacks modern syntax/features; likely insufficient.
- **Duktape / QuickJS** via cgo bindings: modern JS support, lightweight, requires CGO.
- **V8 (via `v8go`)**: Best compatibility/performance; heavier build, CGO dependency.

### Integration Model
- Instantiate an engine per scenario; expose Go functions (`console.log`, HTTP helpers) via host
bindings.
- Package management achieved via pre-bundled scripts (e.g. `esbuild` compiling dependencies into a
single bundle) since `npm` cannot run inside the embedded engine directly.
- Provide a thin module loader that resolves relative imports from scenario-defined directories or
an in-memory virtual FS.

### Pros
- Single binary distribution with no external runtime requirements.
- Lower IPC overhead and easier state sharing with Go structures.
- Better sandboxing (the engine cannot reach filesystem/network unless exposed).

### Cons
- Need build pipeline for bundling third-party packages; user experience more complex.
- Async support varies; may need polyfills or event loop emulation.
- CGO-based engines complicate cross-compilation and increase binary size.

### Open Questions
- Which engine offers the best balance between modern syntax support and operational simplicity?
- How do we surface high-quality stack traces (source maps) after bundling/minification?
- Can we cache compiled scripts between runs to reduce warm-up cost?

## External Packages
- **Node option**: allow authors to provide a `package.json` adjacent to the scenario. `httprunner`
runs `npm install` (or `pnpm`) during preparation, caches `node_modules`, and passes the path to
the worker.
- **Embedded option**: use `package.json` + `lockfile` to drive a bundler step. The build artefact
is shipped with the scenario and loaded into the engine at runtime.
- In both cases, define clear limits for install time, disk usage, and network access (mirror
registry or offline cache).

## Recommendation & Next Steps
- Prototype both approaches:
1. Minimal Node worker communicating over stdio; run `.http` sample with third-party library.
2. Goja-based prototype using bundled dependencies via `esbuild`.
- Measure cold start, steady-state latency, and resource consumption for representative scripts.
- Decide on default runtime and keep the alternative behind a feature flag until parity is reached.
- Document the developer workflow (install dependencies, configure modules, debug scripts).

## Prototype Status
- Experimental support for the Node.js stdio worker is available behind the CLI flag
`--experimental-node-runtime`. When enabled, each template engine instance launches a dedicated
`node` worker that exchanges JSON messages over stdin/stdout to execute scripts, forward console
logs, and synchronize global state.
- The prototype covers `client.global`, `client.check`, `client.assert`, request function calls (via
synchronous Go execution behind the scenes—scripts should `await client.some_request()`), and basic logging/timer
helpers. If a script forgets to `await` a request helper, the runtime emits a warning before the
step finishes. Metrics access remains a stub until the telemetry surface is replicated in Node.
- When enabled, the runner automatically includes `node_modules` directories adjacent to the scenario
(and up to two parent directories) in the worker's module resolution. Additional paths can still be
provided via `NODE_PATH` for custom layouts.
- The Goja runtime remains the default; the Node-based path is best-effort and intended for early
feedback on ergonomics and performance.
38 changes: 38 additions & 0 deletions examples/external-node-runtime/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# External Node Runtime Example

This folder demonstrates how to execute `.http` scenarios with the experimental Node.js runtime and
consume dependencies installed from `npm`.

## Setup

From the repository root:

```bash
npm install --prefix examples/external-node-runtime
```

This installs the packages listed in `package.json` under `examples/external-node-runtime/node_modules`.

## Running the Scenario

Ensure you have already built `httprunner` (or use `go run ./cmd/httprunner`). Execute the example
with the Node runtime enabled; the CLI will automatically pick up the local `node_modules`
directory. For custom layouts you can still extend `NODE_PATH` manually:

```bash
./httprunner \
--experimental-node-runtime \
-f examples/external-node-runtime/test-external.http \
-report console \
-detail summary
```

Key points:

- `httprunner` automatically adds `examples/external-node-runtime/node_modules` to the worker's
module resolution paths. Set `NODE_PATH` (or add more directories) if your dependencies live
elsewhere.
- The `.http` file showcases requiring the `dayjs` library inside pre/post script blocks and
uses `console.log` output for visibility.
- Remember to `await` any `client.<named_request>()` helpers in Node-backed scripts; the runtime emits
a warning when a promise is not awaited.
22 changes: 22 additions & 0 deletions examples/external-node-runtime/package-lock.json

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

10 changes: 10 additions & 0 deletions examples/external-node-runtime/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "httprunner-external-runtime-demo",
"version": "0.1.0",
"private": true,
"description": "Sample npm workspace for demonstrating httprunner's experimental Node.js runtime integration.",
"license": "MIT",
"dependencies": {
"dayjs": "^1.11.13"
}
}
37 changes: 37 additions & 0 deletions examples/external-node-runtime/test-external.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
###
# @name BeforeUserSetup
# @BeforeUser
> {%
const dayjs = require('dayjs');

console.log('[node-runtime] Using dayjs version', dayjs.version);
client.global.set('suiteStart', dayjs().toISOString());
%}

###
# @name Generate Identifier
GET https://httpbin.org/get
Accept: application/json

> {%
const dayjs = require('dayjs');

const now = dayjs();
const startedAt = client.global.get('suiteStart');
const elapsedSeconds = now.diff(dayjs(startedAt), 'second', true);

client.check('Suite start captured', () => Boolean(startedAt), 'Missing suiteStart global value');
client.check('Elapsed time less than 5 seconds', () => elapsedSeconds < 5, `Elapsed ${elapsedSeconds.toFixed(2)}s > 5s`);

console.log(`[node-runtime] request started at ${startedAt}, elapsed ${elapsedSeconds.toFixed(2)}s`);
%}

###
# @TeardownUser
> {%
const dayjs = require('dayjs');
const suiteStart = client.global.get('suiteStart');
const duration = dayjs().diff(dayjs(suiteStart), 'millisecond');

console.log(`[node-runtime] Total suite duration: ${duration}ms`);
%}
Loading
Loading