From 787a542acb9f64bf3fe0dbde09ac61a88221afff Mon Sep 17 00:00:00 2001 From: Dieter Eickstaedt Date: Sun, 26 Oct 2025 17:20:48 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat(core):=20f=C3=BCge=20Node.js=20Runtime?= =?UTF-8?q?=20Option=20hinzu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # cmd/httprunner/main.go --- README.md | 20 + cmd/httprunner/main.go | 3 + docs/specs/javascript-runtimes.md | 115 +++++ examples/external-node-runtime/README.md | 37 ++ .../external-node-runtime/package-lock.json | 22 + examples/external-node-runtime/package.json | 10 + ...raw-results-20251026-171636-979e2962.jsonl | 0 ...raw-results-20251026-171840-81375198.jsonl | 0 ...raw-results-20251026-171848-2e106100.jsonl | 1 + ...raw-results-20251026-171928-077f1a4a.jsonl | 0 ...raw-results-20251026-171945-c2a94370.jsonl | 1 + .../external-node-runtime/results/report.html | 423 ++++++++++++++++++ .../external-node-runtime/test-external.http | 37 ++ runner/runner.go | 22 + template/node_runtime.go | 294 ++++++++++++ template/node_worker.js | 344 ++++++++++++++ template/template.go | 196 ++++++++ ...raw-results-20251026-170443-c1a5712a.jsonl | 2 + ...raw-results-20251026-170453-7a8eb57b.jsonl | 2 + 19 files changed, 1529 insertions(+) create mode 100644 docs/specs/javascript-runtimes.md create mode 100644 examples/external-node-runtime/README.md create mode 100644 examples/external-node-runtime/package-lock.json create mode 100644 examples/external-node-runtime/package.json create mode 100644 examples/external-node-runtime/results/raw-results-20251026-171636-979e2962.jsonl create mode 100644 examples/external-node-runtime/results/raw-results-20251026-171840-81375198.jsonl create mode 100644 examples/external-node-runtime/results/raw-results-20251026-171848-2e106100.jsonl create mode 100644 examples/external-node-runtime/results/raw-results-20251026-171928-077f1a4a.jsonl create mode 100644 examples/external-node-runtime/results/raw-results-20251026-171945-c2a94370.jsonl create mode 100644 examples/external-node-runtime/results/report.html create mode 100644 examples/external-node-runtime/test-external.http create mode 100644 template/node_runtime.go create mode 100644 template/node_worker.js create mode 100644 tests/results/raw-results-20251026-170443-c1a5712a.jsonl create mode 100644 tests/results/raw-results-20251026-170453-7a8eb57b.jsonl diff --git a/README.md b/README.md index 65fb2fa..9d5de4c 100644 --- a/README.md +++ b/README.md @@ -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) @@ -147,6 +148,25 @@ 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 ` can be invoked from JavaScript through `client.()`. +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. + +To make additional packages available, install them next to your scenario and extend `NODE_PATH` +before launching `httprunner`. See `examples/external-node-runtime` for a complete walkthrough. + ### Example requests.http #### Basic requests (backward compatible): diff --git a/cmd/httprunner/main.go b/cmd/httprunner/main.go index 6bccd12..6cde73c 100644 --- a/cmd/httprunner/main.go +++ b/cmd/httprunner/main.go @@ -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() @@ -221,6 +222,8 @@ func main() { // Set verbose flag r.SetVerbose(*verbose) + // Toggle experimental Node runtime if requested + r.EnableNodeRuntime(*experimentalNodeRuntime) // Apply startup offset between VU spawns, if provided r.StartupOffset = *offset diff --git a/docs/specs/javascript-runtimes.md b/docs/specs/javascript-runtimes.md new file mode 100644 index 0000000..0e27ecd --- /dev/null +++ b/docs/specs/javascript-runtimes.md @@ -0,0 +1,115 @@ +# 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. +- The Goja runtime remains the default; the Node-based path is best-effort and intended for early + feedback on ergonomics and performance. diff --git a/examples/external-node-runtime/README.md b/examples/external-node-runtime/README.md new file mode 100644 index 0000000..6c387c4 --- /dev/null +++ b/examples/external-node-runtime/README.md @@ -0,0 +1,37 @@ +# 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 and point `NODE_PATH` to the folder that contains the installed +dependencies: + +```bash +NODE_PATH="$(pwd)/examples/external-node-runtime/node_modules" \ +./httprunner \ + --experimental-node-runtime \ + -f examples/external-node-runtime/test-external.http \ + -report console \ + -detail summary +``` + +Key points: + +- `NODE_PATH` extends Node's module resolution so the worker can `require('dayjs')`. +- 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.()` helpers in Node-backed scripts; the runtime emits + a warning when a promise is not awaited. diff --git a/examples/external-node-runtime/package-lock.json b/examples/external-node-runtime/package-lock.json new file mode 100644 index 0000000..e14faa8 --- /dev/null +++ b/examples/external-node-runtime/package-lock.json @@ -0,0 +1,22 @@ +{ + "name": "httprunner-external-runtime-demo", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "httprunner-external-runtime-demo", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "dayjs": "^1.11.13" + } + }, + "node_modules/dayjs": { + "version": "1.11.18", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", + "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", + "license": "MIT" + } + } +} diff --git a/examples/external-node-runtime/package.json b/examples/external-node-runtime/package.json new file mode 100644 index 0000000..555e6cd --- /dev/null +++ b/examples/external-node-runtime/package.json @@ -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" + } +} diff --git a/examples/external-node-runtime/results/raw-results-20251026-171636-979e2962.jsonl b/examples/external-node-runtime/results/raw-results-20251026-171636-979e2962.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/examples/external-node-runtime/results/raw-results-20251026-171840-81375198.jsonl b/examples/external-node-runtime/results/raw-results-20251026-171840-81375198.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/examples/external-node-runtime/results/raw-results-20251026-171848-2e106100.jsonl b/examples/external-node-runtime/results/raw-results-20251026-171848-2e106100.jsonl new file mode 100644 index 0000000..1ed947f --- /dev/null +++ b/examples/external-node-runtime/results/raw-results-20251026-171848-2e106100.jsonl @@ -0,0 +1 @@ +{"Name":"Generate Identifier","Verb":"GET","URL":"https://httpbin.org/get","StatusCode":200,"ResponseTime":1976900292,"Success":true,"Error":"","Timestamp":"2025-10-26T17:18:48.11182+01:00","VirtualUserID":0,"IterationID":0,"Checks":[{"Name":"Suite start captured","Success":true,"FailureMessage":"","Timestamp":"2025-10-26T17:18:50.193056+01:00"},{"Name":"Elapsed time less than 5 seconds","Success":true,"FailureMessage":"","Timestamp":"2025-10-26T17:18:50.193056+01:00"}]} diff --git a/examples/external-node-runtime/results/raw-results-20251026-171928-077f1a4a.jsonl b/examples/external-node-runtime/results/raw-results-20251026-171928-077f1a4a.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/examples/external-node-runtime/results/raw-results-20251026-171945-c2a94370.jsonl b/examples/external-node-runtime/results/raw-results-20251026-171945-c2a94370.jsonl new file mode 100644 index 0000000..bbcd7f5 --- /dev/null +++ b/examples/external-node-runtime/results/raw-results-20251026-171945-c2a94370.jsonl @@ -0,0 +1 @@ +{"Name":"Generate Identifier","Verb":"GET","URL":"https://httpbin.org/get","StatusCode":200,"ResponseTime":1151332833,"Success":true,"Error":"","Timestamp":"2025-10-26T17:19:45.285995+01:00","VirtualUserID":0,"IterationID":0,"Checks":[{"Name":"Suite start captured","Success":true,"FailureMessage":"","Timestamp":"2025-10-26T17:19:46.49473+01:00"},{"Name":"Elapsed time less than 5 seconds","Success":true,"FailureMessage":"","Timestamp":"2025-10-26T17:19:46.49473+01:00"}]} diff --git a/examples/external-node-runtime/results/report.html b/examples/external-node-runtime/results/report.html new file mode 100644 index 0000000..27d995c --- /dev/null +++ b/examples/external-node-runtime/results/report.html @@ -0,0 +1,423 @@ + + + + HTTP Request Report - Hierarchical + + + + +
+

🚀 HTTP Request Report - Hierarchical View

+

Generated on 2025-10-26 17:19:45

+
+ +
+ + +
+ +
+

📊 Overall Summary

+
+
+
1
+
Total VirtualUserReports
+
+
+
+
+
+
1
+
Successful VirtualUserReports
+
+
+
+
+
+
1
+
Total Requests
+
+
+
1
+
Successful Requests
+
+
+
0
+
Failed Requests
+
+
+
1.151332833s
+
Avg Response Time
+
+
+ +
+
+ Success Rate: + + 100.0% + +
+
Total Duration: 69.671ms
+
Min Response: 1.151332833s
+
Max Response: 1.151332833s
+
+
+ + +
+

⏱️ Top 5 Longest Requests

+ + + + + + + + + + + + + +
#MethodNameURLStatusTimeIteration
1GETGenerate Identifierhttps://httpbin.org/get200 ✓1.151332833sVU 0, Iter 0
+
+ + + +
+

✅ Check Results

+
+
+
2
+
Total Checks
+
+
+
2
+
Successful Checks
+
+
+
0
+
Failed Checks
+
+
+
100.0%
+
Check Success Rate
+
+
+ + +
+ +
+ Elapsed time less than 5 seconds
+ 1 runs (1 successful, 0 failed) + +
+ +
+ Suite start captured
+ 1 runs (1 successful, 0 failed) + +
+ +
+
+ + + + +
+ +
+ VirtualUser 0 + - 1 requests + - 100.0% success rate + - Avg: 1.151332833s +
+ +
+
+
+
1
+
Iterations
+
+
+
1
+
Successful
+
+
+
0
+
Failed
+
+
+
0s
+
Duration
+
+
+ + + + +
+ +
+ Iteration 0 + - 1 requests + - 100.0% success + - 1.151332833s avg + - 0s duration +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
#NameMethodURLStatusResponse TimeChecksErrorTimestamp
1Generate IdentifierGEThttps://httpbin.org/get + + 200 ✓ + + 1.151332833s + + + + + ✓ Suite start captured + + +
+ + ✓ Elapsed time less than 5 seconds + + + +
-17:19:45.285
+
+ + + +
+ + +
+ +
+ + +
+ + 🎯 Tip: Click on section headers to expand/collapse details. + Use "Expand All" / "Collapse All" buttons for bulk operations. + +
+ + diff --git a/examples/external-node-runtime/test-external.http b/examples/external-node-runtime/test-external.http new file mode 100644 index 0000000..2825a8f --- /dev/null +++ b/examples/external-node-runtime/test-external.http @@ -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`); +%} diff --git a/runner/runner.go b/runner/runner.go index c37e2ef..40acadc 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -43,6 +43,7 @@ type Runner struct { teardownUserRequests []chttp.Request teardownIterationRequests []chttp.Request normalRequests []chttp.Request + useNodeRuntime bool } // NewRunner creates a new Runner with file streaming for memory efficiency @@ -99,6 +100,11 @@ func (r *Runner) SetVerbose(verbose bool) { r.Verbose = verbose } +// EnableNodeRuntime toggles the experimental Node.js scripting runtime +func (r *Runner) EnableNodeRuntime(enabled bool) { + r.useNodeRuntime = enabled +} + // Run executes requests with file streaming to reduce memory usage func (r *Runner) Run() (*types.Report, error) { if r.StreamingCollector == nil { @@ -161,6 +167,14 @@ func (r *Runner) executeWithStreaming() error { // Create a shared template engine for @BeforeUser scripts globalTemplateEngine, _ := template.NewTemplateEngineWithEnvFile(r.envFile) globalTemplateEngine.SetMetricsCollector(r.MetricsCollector) + if r.useNodeRuntime { + globalTemplateEngine.SetRuntimeMode(template.RuntimeModeNode) + } + defer func() { + if err := globalTemplateEngine.Close(); err != nil && r.Verbose { + fmt.Printf("warning: failed to close global template engine: %v\n", err) + } + }() // Register all named requests as callable functions for _, req := range r.Requests { @@ -196,6 +210,14 @@ func (r *Runner) executeWithStreaming() error { // Create template engine for this worker, inheriting global state templateEngine, _ := template.NewTemplateEngineWithEnvFile(r.envFile) templateEngine.SetMetricsCollector(r.MetricsCollector) + if r.useNodeRuntime { + templateEngine.SetRuntimeMode(template.RuntimeModeNode) + } + defer func() { + if err := templateEngine.Close(); err != nil && r.Verbose { + fmt.Printf("[Worker %d] warning: failed to close template engine: %v\n", workerID, err) + } + }() // Copy global state to worker template ein inngine globalStore := globalTemplateEngine.GetGlobalStore() diff --git a/template/node_runtime.go b/template/node_runtime.go new file mode 100644 index 0000000..8cb5a7f --- /dev/null +++ b/template/node_runtime.go @@ -0,0 +1,294 @@ +package template + +import ( + "bufio" + "bytes" + _ "embed" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sync" + "time" +) + +//go:embed node_worker.js +var nodeWorkerSource string + +const ( + nodeResponseTypeResult = "result" + nodeResponseTypeAssertion = "assertion" + nodeResponseTypeError = "error" + nodeMessageInvokeRequest = "invoke_request" + nodeMessageRequestResult = "request_result" + nodeMessageShutdownAck = "shutdown_ack" +) + +type nodeRuntime struct { + cmd *exec.Cmd + stdin io.WriteCloser + stdout *bufio.Reader + tempDir string + mu sync.Mutex + closed bool +} + +type nodeExecuteRequest struct { + Type string `json:"type"` + Script string `json:"script"` + ResponseBody interface{} `json:"responseBody"` + Context map[string]interface{} `json:"context"` + Globals map[string]interface{} `json:"globals"` + RequestFunctions []string `json:"requestFunctions,omitempty"` +} + +type nodeExecuteResponse struct { + Type string `json:"type"` + Globals map[string]interface{} `json:"globals"` + Checks []nodeCheck `json:"checks"` + Logs []nodeLogEntry `json:"logs"` + Error *nodeError `json:"error,omitempty"` + Assertion *nodeAssertion `json:"assertion,omitempty"` +} + +type nodeInvokeRequest struct { + Type string `json:"type"` + ID string `json:"id"` + Name string `json:"name"` + Args []interface{} `json:"args"` +} + +type nodeRequestResultMessage struct { + Type string `json:"type"` + ID string `json:"id"` + Success bool `json:"success"` + Response *nodeRequestResponse `json:"response,omitempty"` + Error string `json:"error,omitempty"` +} + +type nodeRequestResponse struct { + StatusCode int `json:"status_code"` + Headers map[string]string `json:"headers"` + Body interface{} `json:"body"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +type nodeRequestHandler func(name string, args []interface{}) (*nodeRequestResponse, error) + +type nodeCheck struct { + Name string `json:"name"` + Success bool `json:"success"` + FailureMessage string `json:"failureMessage"` +} + +type nodeLogEntry struct { + Level string `json:"level"` + Message string `json:"message"` +} + +type nodeError struct { + Message string `json:"message"` + Stack string `json:"stack"` +} + +type nodeAssertion struct { + Message string `json:"message"` + StatusCode int `json:"statusCode"` +} + +func newNodeRuntime() (*nodeRuntime, error) { + tempDir, err := os.MkdirTemp("", "httprunner-node-*") + if err != nil { + return nil, fmt.Errorf("failed to create temp dir for node runtime: %w", err) + } + + scriptPath := filepath.Join(tempDir, "worker.js") + if writeErr := os.WriteFile(scriptPath, []byte(nodeWorkerSource), 0600); writeErr != nil { + _ = os.RemoveAll(tempDir) + return nil, fmt.Errorf("failed to write node worker script: %w", writeErr) + } + + cmd := exec.Command("node", scriptPath) + stdin, err := cmd.StdinPipe() + if err != nil { + _ = os.RemoveAll(tempDir) + return nil, fmt.Errorf("failed to get stdin pipe for node runtime: %w", err) + } + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + _ = stdin.Close() + _ = os.RemoveAll(tempDir) + return nil, fmt.Errorf("failed to get stdout pipe for node runtime: %w", err) + } + + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + _ = stdin.Close() + _ = stdoutPipe.Close() + _ = os.RemoveAll(tempDir) + return nil, fmt.Errorf("failed to start node runtime: %w", err) + } + + return &nodeRuntime{ + cmd: cmd, + stdin: stdin, + stdout: bufio.NewReader(stdoutPipe), + tempDir: tempDir, + }, nil +} + +func (nr *nodeRuntime) Execute(req nodeExecuteRequest, handler nodeRequestHandler) (*nodeExecuteResponse, error) { + nr.mu.Lock() + defer nr.mu.Unlock() + + if nr.closed { + return nil, errors.New("node runtime already closed") + } + + if nr.cmd.ProcessState != nil && nr.cmd.ProcessState.Exited() { + return nil, errors.New("node runtime process has exited") + } + + if err := nr.sendMessage(req); err != nil { + return nil, err + } + + for { + line, readErr := nr.stdout.ReadBytes('\n') + if readErr != nil { + return nil, fmt.Errorf("failed to read from node runtime: %w", readErr) + } + + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + + var envelope struct { + Type string `json:"type"` + } + + if err := json.Unmarshal(line, &envelope); err != nil { + return nil, fmt.Errorf("failed to decode node runtime response: %w", err) + } + + switch envelope.Type { + case nodeMessageInvokeRequest: + var invocation nodeInvokeRequest + if err := json.Unmarshal(line, &invocation); err != nil { + return nil, fmt.Errorf("failed to decode invoke_request message: %w", err) + } + + resultMessage := nodeRequestResultMessage{ + Type: nodeMessageRequestResult, + ID: invocation.ID, + } + + if handler == nil { + resultMessage.Success = false + resultMessage.Error = "request handler not configured" + resultMessage.Response = &nodeRequestResponse{ + Success: false, + Error: resultMessage.Error, + } + } else { + response, err := handler(invocation.Name, invocation.Args) + if err != nil { + resultMessage.Success = false + resultMessage.Error = err.Error() + resultMessage.Response = &nodeRequestResponse{ + Success: false, + Error: resultMessage.Error, + } + } else if response == nil { + resultMessage.Success = false + resultMessage.Error = "request handler returned no response" + resultMessage.Response = &nodeRequestResponse{ + Success: false, + Error: resultMessage.Error, + } + } else { + resultMessage.Success = response.Success + resultMessage.Response = response + if response.Error != "" { + resultMessage.Error = response.Error + } + } + } + + if err := nr.sendMessage(resultMessage); err != nil { + return nil, fmt.Errorf("failed to send request_result to node runtime: %w", err) + } + case nodeMessageRequestResult: + // Request results should only be sent from Go to the Node worker. + // Receiving them back indicates a protocol mismatch. + return nil, errors.New("unexpected request_result received from node runtime") + case nodeResponseTypeResult, nodeResponseTypeAssertion, nodeResponseTypeError: + var resp nodeExecuteResponse + if err := json.Unmarshal(line, &resp); err != nil { + return nil, fmt.Errorf("failed to decode node runtime response: %w", err) + } + return &resp, nil + case nodeMessageShutdownAck: + // Ignore shutdown acknowledgements while executing. + continue + default: + return nil, fmt.Errorf("unexpected node runtime message type: %s", envelope.Type) + } + } +} + +func (nr *nodeRuntime) Close() error { + nr.mu.Lock() + if nr.closed { + nr.mu.Unlock() + return nil + } + nr.closed = true + nr.mu.Unlock() + + defer func() { + _ = os.RemoveAll(nr.tempDir) + }() + + shutdownPayload, _ := json.Marshal(map[string]string{"type": "shutdown"}) + shutdownPayload = append(shutdownPayload, '\n') + + _, _ = nr.stdin.Write(shutdownPayload) + _ = nr.stdin.Close() + + done := make(chan error, 1) + go func() { + done <- nr.cmd.Wait() + }() + + select { + case err := <-done: + return err + case <-time.After(2 * time.Second): + if nr.cmd.Process != nil { + _ = nr.cmd.Process.Kill() + } + return errors.New("node runtime did not shut down gracefully") + } +} + +func (nr *nodeRuntime) sendMessage(message interface{}) error { + payload, err := json.Marshal(message) + if err != nil { + return fmt.Errorf("failed to marshal node runtime message: %w", err) + } + payload = append(payload, '\n') + + if _, err := nr.stdin.Write(payload); err != nil { + return fmt.Errorf("failed to write to node runtime: %w", err) + } + return nil +} diff --git a/template/node_worker.js b/template/node_worker.js new file mode 100644 index 0000000..405a231 --- /dev/null +++ b/template/node_worker.js @@ -0,0 +1,344 @@ +const vm = require('vm'); +const readline = require('readline'); + +let globalStore = {}; +let currentLogs = null; +let currentWarningChecks = null; +const pendingRequests = new Map(); +let nextRequestId = 1; + +const rl = readline.createInterface({ + input: process.stdin, + crlfDelay: Infinity, +}); + +function clone(obj) { + return JSON.parse(JSON.stringify(obj || {})); +} + +function toSerializable(value) { + if (typeof value === 'undefined') { + return null; + } + if (value === null) { + return null; + } + if (typeof value === 'object') { + try { + return JSON.parse(JSON.stringify(value)); + } catch (err) { + return `[unserializable:${err.message}]`; + } + } + if (typeof value === 'function') { + return `[function ${value.name || 'anonymous'}]`; + } + return value; +} + +function createConsole(logs) { + const capture = (level) => (...args) => { + const message = args.map((arg) => { + if (typeof arg === 'string') { + return arg; + } + try { + return JSON.stringify(arg); + } catch (err) { + return Object.prototype.toString.call(arg); + } + }).join(' '); + logs.push({ level, message }); + }; + + return { + log: capture('log'), + info: capture('info'), + warn: capture('warn'), + error: capture('error'), + }; +} + +function sendMessage(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +function invokeRequest(name, args) { + const id = `req-${nextRequestId++}`; + let handled = false; + let warned = false; + let resolveCheck = () => {}; + + if (Array.isArray(currentWarningChecks)) { + const checkPromise = new Promise((resolve) => { + resolveCheck = resolve; + }); + currentWarningChecks.push(checkPromise); + } + + const markHandled = () => { + if (!handled) { + handled = true; + } + clearTimeout(warnTimer); + resolveCheck(); + }; + + const warn = () => { + if (handled || warned) { + return; + } + warned = true; + const message = `client.${name} returned a Promise; add 'await client.${name}()' to ensure the request completes before continuing.`; + if (currentLogs) { + currentLogs.push({ level: 'warn', message }); + } else { + console.warn(message); + } + resolveCheck(); + }; + + const promise = new Promise((resolve) => { + pendingRequests.set(id, (payload) => { + resolve(payload); + }); + sendMessage({ + type: 'invoke_request', + id, + name, + args, + }); + }); + + const warnTimer = setTimeout(warn, 0); + + const wrap = (methodName) => { + const original = promise[methodName].bind(promise); + promise[methodName] = (...methodArgs) => { + markHandled(); + return original(...methodArgs); + }; + }; + + wrap('then'); + wrap('catch'); + wrap('finally'); + + return promise; +} + +function handleRequestResult(message) { + const entry = pendingRequests.get(message.id); + if (!entry) { + return; + } + pendingRequests.delete(message.id); + + let payload = message.response || {}; + if (typeof payload !== 'object' || payload === null) { + payload = { success: false }; + } + + if (typeof payload.success !== 'boolean') { + payload.success = Boolean(message.success); + } else if (!message.success) { + payload.success = false; + } + + if (message.error && !payload.error) { + payload.error = message.error; + } + + entry(payload); +} + +function sleep(ms) { + if (typeof ms !== 'number' || ms < 0) { + throw new Error(`sleep expects a positive number, received ${ms}`); + } + const sab = new SharedArrayBuffer(4); + const int32 = new Int32Array(sab); + Atomics.wait(int32, 0, 0, Math.floor(ms)); +} + +async function executeScript(message) { + globalStore = clone(message.globals); + const checks = []; + const logs = []; + const warningChecks = []; + currentLogs = logs; + currentWarningChecks = warningChecks; + + const client = { + global: { + set: (key, value) => { + globalStore[key] = toSerializable(value); + }, + get: (key) => globalStore[key], + }, + check: (name, handler, failureMessage) => { + let success = false; + try { + success = Boolean(handler()); + } catch (err) { + success = false; + } + checks.push({ + name: typeof name === 'string' ? name : '', + success, + failureMessage: success ? '' : (failureMessage || ''), + }); + }, + assert: (handler, failureMessage, statusCode) => { + let success = false; + try { + success = Boolean(handler()); + } catch (err) { + success = false; + } + if (!success) { + const error = new Error(failureMessage || 'Assertion failed'); + error.httprunnerAssertion = { + message: failureMessage || 'Assertion failed', + statusCode: typeof statusCode === 'number' ? statusCode : 500, + }; + throw error; + } + }, + metrics: { + get: () => null, + getAll: () => ({}), + getCurrent: () => null, + }, + }; + + if (Array.isArray(message.requestFunctions)) { + for (const fnName of message.requestFunctions) { + if (typeof fnName === 'string' && !(fnName in client)) { + client[fnName] = (...args) => invokeRequest(fnName, args); + } + } + } + + const sandbox = { + console: createConsole(logs), + client, + context: message.context || {}, + response: { + body: message.responseBody, + }, + sleep, + setTimeout, + setInterval, + clearTimeout, + clearInterval, + require, + module, + exports, + __dirname, + __filename, + Buffer, + process, + }; + sandbox.global = sandbox; + sandbox.globalThis = sandbox; + + const script = `(async () => {\n${message.script}\n})()`; + const scriptOptions = { + filename: message.filename || 'scenario.js', + lineOffset: 0, + displayErrors: true, + }; + + const context = vm.createContext(sandbox, { + name: 'httprunner', + }); + + let response; + try { + const result = vm.runInContext(script, context, scriptOptions); + await result; + response = { + type: 'result', + globals: globalStore, + checks, + logs, + }; + } catch (err) { + if (err && err.httprunnerAssertion) { + const assertion = err.httprunnerAssertion; + response = { + type: 'assertion', + globals: globalStore, + checks, + logs, + assertion, + }; + } else { + response = { + type: 'error', + globals: globalStore, + checks, + logs, + error: { + message: err ? err.message : 'Unknown error', + stack: err && err.stack, + }, + }; + } + } + + if (warningChecks.length > 0) { + try { + await Promise.allSettled(warningChecks); + } catch (_) { + // ignore: warnings already surfaced to logs + } + } + + currentWarningChecks = null; + currentLogs = null; + + return response; +} + +rl.on('line', async (line) => { + if (!line.trim()) { + return; + } + let message; + try { + message = JSON.parse(line); + } catch (err) { + sendMessage({ + type: 'error', + error: { message: `Invalid JSON payload: ${err.message}` }, + }); + return; + } + + if (message.type === 'request_result') { + handleRequestResult(message); + return; + } + + if (message.type === 'shutdown') { + sendMessage({ type: 'shutdown_ack' }); + process.exit(0); + return; + } + + if (message.type === 'execute') { + const response = await executeScript(message); + sendMessage(response); + } else { + sendMessage({ + type: 'error', + error: { message: `Unknown message type: ${message.type}` }, + }); + } +}); + +rl.on('close', () => { + process.exit(0); +}); diff --git a/template/template.go b/template/template.go index a3481b9..3822ccc 100644 --- a/template/template.go +++ b/template/template.go @@ -127,6 +127,22 @@ func (gs *GlobalStore) GetAll() map[string]interface{} { return result } +// ReplaceAll replaces the contents of the store with the provided values +func (gs *GlobalStore) ReplaceAll(values map[string]interface{}) { + gs.mu.Lock() + defer gs.mu.Unlock() + + if values == nil { + gs.data = make(map[string]interface{}) + return + } + + gs.data = make(map[string]interface{}, len(values)) + for k, v := range values { + gs.data[k] = v + } +} + // Response represents a simplified HTTP response for JavaScript access type Response struct { StatusCode int `json:"status_code"` @@ -148,14 +164,28 @@ type Engine struct { // Persistent VM for maintaining JavaScript state between executions vm *goja.Runtime vmMu sync.Mutex + // Runtime mode selection and optional Node process + runtimeMode RuntimeMode + nodeRuntime *nodeRuntime } +// RuntimeMode represents the active JavaScript runtime implementation +type RuntimeMode string + +const ( + // RuntimeModeGoja executes JavaScript using the embedded Goja engine + RuntimeModeGoja RuntimeMode = "goja" + // RuntimeModeNode executes JavaScript via an external Node.js worker + RuntimeModeNode RuntimeMode = "node" +) + // NewTemplateEngine creates a new template engine func NewTemplateEngine() *Engine { return &Engine{ globalStore: NewGlobalStore(), checks: make([]types.CheckResult, 0), requestFunctions: make(map[string]chttp.Request), + runtimeMode: RuntimeModeGoja, } } @@ -171,6 +201,7 @@ func NewTemplateEngineWithEnvFile(envFile string) (*Engine, error) { globalStore: store, checks: make([]types.CheckResult, 0), requestFunctions: make(map[string]chttp.Request), + runtimeMode: RuntimeModeGoja, }, nil } @@ -366,6 +397,13 @@ func (te *Engine) initializeVM(virtualUserID, iterationID int) *goja.Runtime { // ExecuteScript executes JavaScript code with access to global store and response func (te *Engine) ExecuteScript(script string, responseBody string, virtualUserID, iterationID int) error { + if te.runtimeMode == RuntimeModeNode { + return te.executeScriptNode(script, responseBody, virtualUserID, iterationID) + } + return te.executeScriptGoja(script, responseBody, virtualUserID, iterationID) +} + +func (te *Engine) executeScriptGoja(script string, responseBody string, virtualUserID, iterationID int) error { te.vmMu.Lock() defer te.vmMu.Unlock() @@ -440,6 +478,146 @@ func (te *Engine) ExecuteScript(script string, responseBody string, virtualUserI return nil } +func (te *Engine) executeScriptNode(script string, responseBody string, virtualUserID, iterationID int) error { + te.vmMu.Lock() + defer te.vmMu.Unlock() + + if te.nodeRuntime == nil { + runtime, err := newNodeRuntime() + if err != nil { + return fmt.Errorf("failed to start Node.js runtime: %w", err) + } + te.nodeRuntime = runtime + } + + // Prepare response data similar to Goja runtime + var responseData interface{} + if responseBody != "" { + if err := json.Unmarshal([]byte(responseBody), &responseData); err != nil { + responseData = map[string]interface{}{ + "body": responseBody, + } + } + } else { + responseData = map[string]interface{}{} + } + + request := nodeExecuteRequest{ + Type: "execute", + Script: script, + ResponseBody: responseData, + Context: map[string]interface{}{ + "userId": virtualUserID, + "iterationId": iterationID, + }, + Globals: te.globalStore.GetAll(), + } + + if len(te.requestFunctions) > 0 { + request.RequestFunctions = make([]string, 0, len(te.requestFunctions)) + for name := range te.requestFunctions { + request.RequestFunctions = append(request.RequestFunctions, name) + } + } + + handler := func(name string, args []interface{}) (*nodeRequestResponse, error) { + request, ok := te.requestFunctions[name] + if !ok { + return &nodeRequestResponse{ + Success: false, + Error: fmt.Sprintf("unknown request function: %s", name), + }, nil + } + + if te.requestExecutor == nil { + return &nodeRequestResponse{ + Success: false, + Error: "request executor not available", + }, nil + } + + resp, execErr := te.requestExecutor(request) + result := &nodeRequestResponse{ + Success: execErr == nil, + } + + if resp != nil { + result.StatusCode = resp.StatusCode + result.Headers = resp.Headers + result.Body = resp.Body + } + + if execErr != nil { + result.Error = execErr.Error() + } + + if resp == nil && execErr == nil { + result.Success = false + result.Error = "request executor returned no response" + } + + return result, nil + } + + response, err := te.nodeRuntime.Execute(request, handler) + if err != nil { + _ = te.nodeRuntime.Close() + te.nodeRuntime = nil + return fmt.Errorf("node runtime execution failed: %w", err) + } + + if response.Globals != nil { + te.globalStore.ReplaceAll(response.Globals) + } + + if len(response.Checks) > 0 { + now := time.Now() + te.checksMu.Lock() + for _, check := range response.Checks { + te.checks = append(te.checks, types.CheckResult{ + Name: check.Name, + Success: check.Success, + FailureMessage: check.FailureMessage, + Timestamp: now, + }) + } + te.checksMu.Unlock() + } + + for _, entry := range response.Logs { + if entry.Message != "" { + fmt.Println(entry.Message) + } + } + + switch response.Type { + case nodeResponseTypeResult: + return nil + case nodeResponseTypeAssertion: + if response.Assertion == nil { + return fmt.Errorf("node runtime assertion missing details") + } + statusCode := response.Assertion.StatusCode + if statusCode == 0 { + statusCode = 500 + } + return &AssertionError{ + Message: response.Assertion.Message, + StatusCode: statusCode, + } + case nodeResponseTypeError: + if response.Error != nil { + if response.Error.Stack != "" { + return fmt.Errorf("node runtime error: %s\n%s", response.Error.Message, response.Error.Stack) + } + return fmt.Errorf("node runtime error: %s", response.Error.Message) + } + return fmt.Errorf("node runtime reported an error without details") + default: + return fmt.Errorf("unexpected node runtime response type: %s", response.Type) + } +} + // GetGlobalStore returns the global store for external access func (te *Engine) GetGlobalStore() *GlobalStore { return te.globalStore @@ -450,6 +628,24 @@ func (te *Engine) SetMetricsCollector(collector *metrics.MetricsCollector) { te.metricsCollector = collector } +// SetRuntimeMode switches the engine to the provided JavaScript runtime +func (te *Engine) SetRuntimeMode(mode RuntimeMode) { + te.runtimeMode = mode +} + +// Close releases resources associated with the engine +func (te *Engine) Close() error { + te.vmMu.Lock() + defer te.vmMu.Unlock() + + if te.nodeRuntime != nil { + err := te.nodeRuntime.Close() + te.nodeRuntime = nil + return err + } + return nil +} + // GetChecks returns the current checks and clears the internal list func (te *Engine) GetChecks() []types.CheckResult { te.checksMu.Lock() diff --git a/tests/results/raw-results-20251026-170443-c1a5712a.jsonl b/tests/results/raw-results-20251026-170443-c1a5712a.jsonl new file mode 100644 index 0000000..6d3f987 --- /dev/null +++ b/tests/results/raw-results-20251026-170443-c1a5712a.jsonl @@ -0,0 +1,2 @@ +{"Name":"Test Assert Pre-request Success","Verb":"POST","URL":"https://httpbin.org/post","StatusCode":200,"ResponseTime":1793491167,"Success":true,"Error":"","Timestamp":"2025-10-26T17:04:43.320758+01:00","VirtualUserID":0,"IterationID":0,"Checks":[]} +{"Name":"Test Assert Pre-request Failure","Verb":"POST","URL":"https://httpbin.org/post","StatusCode":401,"ResponseTime":0,"Success":false,"Error":"Authentication token is required","Timestamp":"2025-10-26T17:04:45.379135+01:00","VirtualUserID":0,"IterationID":0,"Checks":null} diff --git a/tests/results/raw-results-20251026-170453-7a8eb57b.jsonl b/tests/results/raw-results-20251026-170453-7a8eb57b.jsonl new file mode 100644 index 0000000..6703d6d --- /dev/null +++ b/tests/results/raw-results-20251026-170453-7a8eb57b.jsonl @@ -0,0 +1,2 @@ +{"Name":"Test Assert Pre-request Success","Verb":"POST","URL":"https://httpbin.org/post","StatusCode":200,"ResponseTime":606328334,"Success":true,"Error":"","Timestamp":"2025-10-26T17:04:53.400154+01:00","VirtualUserID":0,"IterationID":0,"Checks":[]} +{"Name":"Test Assert Pre-request Failure","Verb":"POST","URL":"https://httpbin.org/post","StatusCode":401,"ResponseTime":0,"Success":false,"Error":"Authentication token is required at github.com/deicon/httprunner/template.(*Engine).initializeVM.func4 (native)","Timestamp":"2025-10-26T17:04:54.016214+01:00","VirtualUserID":0,"IterationID":0,"Checks":null} From 13577d04c1aeb15f65971b6658f90d0f2ad46f57 Mon Sep 17 00:00:00 2001 From: Dieter Eickstaedt Date: Sun, 26 Oct 2025 18:51:19 +0100 Subject: [PATCH 2/3] feat(node): add automatic node_modules resolution --- .gitignore | 4 +- README.md | 6 +- cmd/httprunner/main.go | 51 +++ docs/specs/javascript-runtimes.md | 3 + examples/external-node-runtime/README.md | 9 +- ...raw-results-20251026-171636-979e2962.jsonl | 0 ...raw-results-20251026-171840-81375198.jsonl | 0 ...raw-results-20251026-171848-2e106100.jsonl | 1 - ...raw-results-20251026-171928-077f1a4a.jsonl | 0 ...raw-results-20251026-171945-c2a94370.jsonl | 1 - .../external-node-runtime/results/report.html | 423 ------------------ runner/runner.go | 12 + template/node_runtime.go | 1 + template/node_worker.js | 25 ++ template/template.go | 11 + 15 files changed, 115 insertions(+), 432 deletions(-) delete mode 100644 examples/external-node-runtime/results/raw-results-20251026-171636-979e2962.jsonl delete mode 100644 examples/external-node-runtime/results/raw-results-20251026-171840-81375198.jsonl delete mode 100644 examples/external-node-runtime/results/raw-results-20251026-171848-2e106100.jsonl delete mode 100644 examples/external-node-runtime/results/raw-results-20251026-171928-077f1a4a.jsonl delete mode 100644 examples/external-node-runtime/results/raw-results-20251026-171945-c2a94370.jsonl delete mode 100644 examples/external-node-runtime/results/report.html diff --git a/.gitignore b/.gitignore index e3e1a6b..415ec5a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,10 @@ requests.http build /test_*_output*/* node_modules +/examples/**/node_modules/ +/examples/**/results/ /httprunner /harparser /results /testapi/results/ -.idea/copilot* \ No newline at end of file +.idea/copilot* diff --git a/README.md b/README.md index 9d5de4c..24d1c04 100644 --- a/README.md +++ b/README.md @@ -164,8 +164,10 @@ native npm packages. The worker communicates with `httprunner` over stdio, mirro 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. -To make additional packages available, install them next to your scenario and extend `NODE_PATH` -before launching `httprunner`. See `examples/external-node-runtime` for a complete walkthrough. +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 diff --git a/cmd/httprunner/main.go b/cmd/httprunner/main.go index 6cde73c..2667cc1 100644 --- a/cmd/httprunner/main.go +++ b/cmd/httprunner/main.go @@ -224,6 +224,57 @@ func main() { 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 diff --git a/docs/specs/javascript-runtimes.md b/docs/specs/javascript-runtimes.md index 0e27ecd..90b4efd 100644 --- a/docs/specs/javascript-runtimes.md +++ b/docs/specs/javascript-runtimes.md @@ -111,5 +111,8 @@ local IPC socket using JSON messages (request events, context updates, script re 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. diff --git a/examples/external-node-runtime/README.md b/examples/external-node-runtime/README.md index 6c387c4..6ec3153 100644 --- a/examples/external-node-runtime/README.md +++ b/examples/external-node-runtime/README.md @@ -16,11 +16,10 @@ This installs the packages listed in `package.json` under `examples/external-nod ## Running the Scenario Ensure you have already built `httprunner` (or use `go run ./cmd/httprunner`). Execute the example -with the Node runtime enabled and point `NODE_PATH` to the folder that contains the installed -dependencies: +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 -NODE_PATH="$(pwd)/examples/external-node-runtime/node_modules" \ ./httprunner \ --experimental-node-runtime \ -f examples/external-node-runtime/test-external.http \ @@ -30,7 +29,9 @@ NODE_PATH="$(pwd)/examples/external-node-runtime/node_modules" \ Key points: -- `NODE_PATH` extends Node's module resolution so the worker can `require('dayjs')`. +- `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.()` helpers in Node-backed scripts; the runtime emits diff --git a/examples/external-node-runtime/results/raw-results-20251026-171636-979e2962.jsonl b/examples/external-node-runtime/results/raw-results-20251026-171636-979e2962.jsonl deleted file mode 100644 index e69de29..0000000 diff --git a/examples/external-node-runtime/results/raw-results-20251026-171840-81375198.jsonl b/examples/external-node-runtime/results/raw-results-20251026-171840-81375198.jsonl deleted file mode 100644 index e69de29..0000000 diff --git a/examples/external-node-runtime/results/raw-results-20251026-171848-2e106100.jsonl b/examples/external-node-runtime/results/raw-results-20251026-171848-2e106100.jsonl deleted file mode 100644 index 1ed947f..0000000 --- a/examples/external-node-runtime/results/raw-results-20251026-171848-2e106100.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"Name":"Generate Identifier","Verb":"GET","URL":"https://httpbin.org/get","StatusCode":200,"ResponseTime":1976900292,"Success":true,"Error":"","Timestamp":"2025-10-26T17:18:48.11182+01:00","VirtualUserID":0,"IterationID":0,"Checks":[{"Name":"Suite start captured","Success":true,"FailureMessage":"","Timestamp":"2025-10-26T17:18:50.193056+01:00"},{"Name":"Elapsed time less than 5 seconds","Success":true,"FailureMessage":"","Timestamp":"2025-10-26T17:18:50.193056+01:00"}]} diff --git a/examples/external-node-runtime/results/raw-results-20251026-171928-077f1a4a.jsonl b/examples/external-node-runtime/results/raw-results-20251026-171928-077f1a4a.jsonl deleted file mode 100644 index e69de29..0000000 diff --git a/examples/external-node-runtime/results/raw-results-20251026-171945-c2a94370.jsonl b/examples/external-node-runtime/results/raw-results-20251026-171945-c2a94370.jsonl deleted file mode 100644 index bbcd7f5..0000000 --- a/examples/external-node-runtime/results/raw-results-20251026-171945-c2a94370.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"Name":"Generate Identifier","Verb":"GET","URL":"https://httpbin.org/get","StatusCode":200,"ResponseTime":1151332833,"Success":true,"Error":"","Timestamp":"2025-10-26T17:19:45.285995+01:00","VirtualUserID":0,"IterationID":0,"Checks":[{"Name":"Suite start captured","Success":true,"FailureMessage":"","Timestamp":"2025-10-26T17:19:46.49473+01:00"},{"Name":"Elapsed time less than 5 seconds","Success":true,"FailureMessage":"","Timestamp":"2025-10-26T17:19:46.49473+01:00"}]} diff --git a/examples/external-node-runtime/results/report.html b/examples/external-node-runtime/results/report.html deleted file mode 100644 index 27d995c..0000000 --- a/examples/external-node-runtime/results/report.html +++ /dev/null @@ -1,423 +0,0 @@ - - - - HTTP Request Report - Hierarchical - - - - -
-

🚀 HTTP Request Report - Hierarchical View

-

Generated on 2025-10-26 17:19:45

-
- -
- - -
- -
-

📊 Overall Summary

-
-
-
1
-
Total VirtualUserReports
-
-
-
-
-
-
1
-
Successful VirtualUserReports
-
-
-
-
-
-
1
-
Total Requests
-
-
-
1
-
Successful Requests
-
-
-
0
-
Failed Requests
-
-
-
1.151332833s
-
Avg Response Time
-
-
- -
-
- Success Rate: - - 100.0% - -
-
Total Duration: 69.671ms
-
Min Response: 1.151332833s
-
Max Response: 1.151332833s
-
-
- - -
-

⏱️ Top 5 Longest Requests

- - - - - - - - - - - - - -
#MethodNameURLStatusTimeIteration
1GETGenerate Identifierhttps://httpbin.org/get200 ✓1.151332833sVU 0, Iter 0
-
- - - -
-

✅ Check Results

-
-
-
2
-
Total Checks
-
-
-
2
-
Successful Checks
-
-
-
0
-
Failed Checks
-
-
-
100.0%
-
Check Success Rate
-
-
- - -
- -
- Elapsed time less than 5 seconds
- 1 runs (1 successful, 0 failed) - -
- -
- Suite start captured
- 1 runs (1 successful, 0 failed) - -
- -
-
- - - - -
- -
- VirtualUser 0 - - 1 requests - - 100.0% success rate - - Avg: 1.151332833s -
- -
-
-
-
1
-
Iterations
-
-
-
1
-
Successful
-
-
-
0
-
Failed
-
-
-
0s
-
Duration
-
-
- - - - -
- -
- Iteration 0 - - 1 requests - - 100.0% success - - 1.151332833s avg - - 0s duration -
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - -
#NameMethodURLStatusResponse TimeChecksErrorTimestamp
1Generate IdentifierGEThttps://httpbin.org/get - - 200 ✓ - - 1.151332833s - - - - - ✓ Suite start captured - - -
- - ✓ Elapsed time less than 5 seconds - - - -
-17:19:45.285
-
- - - -
- - -
- -
- - -
- - 🎯 Tip: Click on section headers to expand/collapse details. - Use "Expand All" / "Collapse All" buttons for bulk operations. - -
- - diff --git a/runner/runner.go b/runner/runner.go index 40acadc..4a7fc6a 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -44,6 +44,7 @@ type Runner struct { teardownIterationRequests []chttp.Request normalRequests []chttp.Request useNodeRuntime bool + nodeRequirePaths []string } // NewRunner creates a new Runner with file streaming for memory efficiency @@ -105,6 +106,11 @@ func (r *Runner) EnableNodeRuntime(enabled bool) { r.useNodeRuntime = enabled } +// SetNodeRequirePaths configures additional module resolution paths for the Node runtime +func (r *Runner) SetNodeRequirePaths(paths []string) { + r.nodeRequirePaths = paths +} + // Run executes requests with file streaming to reduce memory usage func (r *Runner) Run() (*types.Report, error) { if r.StreamingCollector == nil { @@ -169,6 +175,9 @@ func (r *Runner) executeWithStreaming() error { globalTemplateEngine.SetMetricsCollector(r.MetricsCollector) if r.useNodeRuntime { globalTemplateEngine.SetRuntimeMode(template.RuntimeModeNode) + if len(r.nodeRequirePaths) > 0 { + globalTemplateEngine.SetNodeRequirePaths(r.nodeRequirePaths) + } } defer func() { if err := globalTemplateEngine.Close(); err != nil && r.Verbose { @@ -212,6 +221,9 @@ func (r *Runner) executeWithStreaming() error { templateEngine.SetMetricsCollector(r.MetricsCollector) if r.useNodeRuntime { templateEngine.SetRuntimeMode(template.RuntimeModeNode) + if len(r.nodeRequirePaths) > 0 { + templateEngine.SetNodeRequirePaths(r.nodeRequirePaths) + } } defer func() { if err := templateEngine.Close(); err != nil && r.Verbose { diff --git a/template/node_runtime.go b/template/node_runtime.go index 8cb5a7f..923705e 100644 --- a/template/node_runtime.go +++ b/template/node_runtime.go @@ -43,6 +43,7 @@ type nodeExecuteRequest struct { Context map[string]interface{} `json:"context"` Globals map[string]interface{} `json:"globals"` RequestFunctions []string `json:"requestFunctions,omitempty"` + RequirePaths []string `json:"requirePaths,omitempty"` } type nodeExecuteResponse struct { diff --git a/template/node_worker.js b/template/node_worker.js index 405a231..3ef6dbf 100644 --- a/template/node_worker.js +++ b/template/node_worker.js @@ -1,5 +1,7 @@ const vm = require('vm'); const readline = require('readline'); +const path = require('path'); +const Module = require('module'); let globalStore = {}; let currentLogs = null; @@ -12,6 +14,27 @@ const rl = readline.createInterface({ crlfDelay: Infinity, }); +function applyRequirePaths(paths) { + if (!Array.isArray(paths)) { + return; + } + for (const candidate of paths) { + if (typeof candidate !== 'string' || candidate.trim() === '') { + continue; + } + const resolved = path.resolve(candidate.trim()); + if (!module.paths.includes(resolved)) { + module.paths.unshift(resolved); + } + if (require.main && Array.isArray(require.main.paths) && !require.main.paths.includes(resolved)) { + require.main.paths.unshift(resolved); + } + if (!Module.globalPaths.includes(resolved)) { + Module.globalPaths.push(resolved); + } + } +} + function clone(obj) { return JSON.parse(JSON.stringify(obj || {})); } @@ -169,6 +192,8 @@ async function executeScript(message) { currentLogs = logs; currentWarningChecks = warningChecks; + applyRequirePaths(message.requirePaths); + const client = { global: { set: (key, value) => { diff --git a/template/template.go b/template/template.go index 3822ccc..36bbc00 100644 --- a/template/template.go +++ b/template/template.go @@ -167,6 +167,8 @@ type Engine struct { // Runtime mode selection and optional Node process runtimeMode RuntimeMode nodeRuntime *nodeRuntime + // Additional search paths for resolving Node.js modules + nodeRequirePaths []string } // RuntimeMode represents the active JavaScript runtime implementation @@ -520,6 +522,10 @@ func (te *Engine) executeScriptNode(script string, responseBody string, virtualU } } + if len(te.nodeRequirePaths) > 0 { + request.RequirePaths = append(request.RequirePaths, te.nodeRequirePaths...) + } + handler := func(name string, args []interface{}) (*nodeRequestResponse, error) { request, ok := te.requestFunctions[name] if !ok { @@ -633,6 +639,11 @@ func (te *Engine) SetRuntimeMode(mode RuntimeMode) { te.runtimeMode = mode } +// SetNodeRequirePaths configures additional directories for resolving Node.js modules +func (te *Engine) SetNodeRequirePaths(paths []string) { + te.nodeRequirePaths = paths +} + // Close releases resources associated with the engine func (te *Engine) Close() error { te.vmMu.Lock() From 84671226a900b1847d2fd7dd9c32614f36b3ab32 Mon Sep 17 00:00:00 2001 From: Dieter Eickstaedt Date: Sun, 26 Oct 2025 19:30:15 +0100 Subject: [PATCH 3/3] fix(template): fix race condition in node script execution --- template/template.go | 48 ++++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/template/template.go b/template/template.go index 36bbc00..fd07cab 100644 --- a/template/template.go +++ b/template/template.go @@ -482,16 +482,25 @@ func (te *Engine) executeScriptGoja(script string, responseBody string, virtualU func (te *Engine) executeScriptNode(script string, responseBody string, virtualUserID, iterationID int) error { te.vmMu.Lock() - defer te.vmMu.Unlock() - - if te.nodeRuntime == nil { - runtime, err := newNodeRuntime() + runtime := te.nodeRuntime + if runtime == nil { + var err error + runtime, err = newNodeRuntime() if err != nil { + te.vmMu.Unlock() return fmt.Errorf("failed to start Node.js runtime: %w", err) } te.nodeRuntime = runtime } + reqFuncs := make(map[string]chttp.Request, len(te.requestFunctions)) + for name, req := range te.requestFunctions { + reqFuncs[name] = req + } + requestExecutor := te.requestExecutor + requirePaths := append([]string(nil), te.nodeRequirePaths...) + te.vmMu.Unlock() + // Prepare response data similar to Goja runtime var responseData interface{} if responseBody != "" { @@ -504,7 +513,7 @@ func (te *Engine) executeScriptNode(script string, responseBody string, virtualU responseData = map[string]interface{}{} } - request := nodeExecuteRequest{ + reqPayload := nodeExecuteRequest{ Type: "execute", Script: script, ResponseBody: responseData, @@ -515,19 +524,19 @@ func (te *Engine) executeScriptNode(script string, responseBody string, virtualU Globals: te.globalStore.GetAll(), } - if len(te.requestFunctions) > 0 { - request.RequestFunctions = make([]string, 0, len(te.requestFunctions)) - for name := range te.requestFunctions { - request.RequestFunctions = append(request.RequestFunctions, name) + if len(reqFuncs) > 0 { + reqPayload.RequestFunctions = make([]string, 0, len(reqFuncs)) + for name := range reqFuncs { + reqPayload.RequestFunctions = append(reqPayload.RequestFunctions, name) } } - if len(te.nodeRequirePaths) > 0 { - request.RequirePaths = append(request.RequirePaths, te.nodeRequirePaths...) + if len(requirePaths) > 0 { + reqPayload.RequirePaths = append(reqPayload.RequirePaths, requirePaths...) } handler := func(name string, args []interface{}) (*nodeRequestResponse, error) { - request, ok := te.requestFunctions[name] + req, ok := reqFuncs[name] if !ok { return &nodeRequestResponse{ Success: false, @@ -535,14 +544,14 @@ func (te *Engine) executeScriptNode(script string, responseBody string, virtualU }, nil } - if te.requestExecutor == nil { + if requestExecutor == nil { return &nodeRequestResponse{ Success: false, Error: "request executor not available", }, nil } - resp, execErr := te.requestExecutor(request) + resp, execErr := requestExecutor(req) result := &nodeRequestResponse{ Success: execErr == nil, } @@ -565,10 +574,15 @@ func (te *Engine) executeScriptNode(script string, responseBody string, virtualU return result, nil } - response, err := te.nodeRuntime.Execute(request, handler) + response, err := runtime.Execute(reqPayload, handler) + te.vmMu.Lock() + defer te.vmMu.Unlock() + if err != nil { - _ = te.nodeRuntime.Close() - te.nodeRuntime = nil + if te.nodeRuntime != nil { + _ = te.nodeRuntime.Close() + te.nodeRuntime = nil + } return fmt.Errorf("node runtime execution failed: %w", err) }