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 65fb2fa..24d1c04 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,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 ` 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. + +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): diff --git a/cmd/httprunner/main.go b/cmd/httprunner/main.go index 6bccd12..2667cc1 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,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 diff --git a/docs/specs/javascript-runtimes.md b/docs/specs/javascript-runtimes.md new file mode 100644 index 0000000..90b4efd --- /dev/null +++ b/docs/specs/javascript-runtimes.md @@ -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. diff --git a/examples/external-node-runtime/README.md b/examples/external-node-runtime/README.md new file mode 100644 index 0000000..6ec3153 --- /dev/null +++ b/examples/external-node-runtime/README.md @@ -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.()` 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/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..4a7fc6a 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -43,6 +43,8 @@ type Runner struct { teardownUserRequests []chttp.Request teardownIterationRequests []chttp.Request normalRequests []chttp.Request + useNodeRuntime bool + nodeRequirePaths []string } // NewRunner creates a new Runner with file streaming for memory efficiency @@ -99,6 +101,16 @@ 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 +} + +// 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 { @@ -161,6 +173,17 @@ 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) + if len(r.nodeRequirePaths) > 0 { + globalTemplateEngine.SetNodeRequirePaths(r.nodeRequirePaths) + } + } + 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 +219,17 @@ 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) + if len(r.nodeRequirePaths) > 0 { + templateEngine.SetNodeRequirePaths(r.nodeRequirePaths) + } + } + 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..923705e --- /dev/null +++ b/template/node_runtime.go @@ -0,0 +1,295 @@ +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"` + RequirePaths []string `json:"requirePaths,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..3ef6dbf --- /dev/null +++ b/template/node_worker.js @@ -0,0 +1,369 @@ +const vm = require('vm'); +const readline = require('readline'); +const path = require('path'); +const Module = require('module'); + +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 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 || {})); +} + +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; + + applyRequirePaths(message.requirePaths); + + 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..fd07cab 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,30 @@ 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 + // Additional search paths for resolving Node.js modules + nodeRequirePaths []string } +// 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 +203,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 +399,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 +480,164 @@ 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() + 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 != "" { + if err := json.Unmarshal([]byte(responseBody), &responseData); err != nil { + responseData = map[string]interface{}{ + "body": responseBody, + } + } + } else { + responseData = map[string]interface{}{} + } + + reqPayload := nodeExecuteRequest{ + Type: "execute", + Script: script, + ResponseBody: responseData, + Context: map[string]interface{}{ + "userId": virtualUserID, + "iterationId": iterationID, + }, + Globals: te.globalStore.GetAll(), + } + + if len(reqFuncs) > 0 { + reqPayload.RequestFunctions = make([]string, 0, len(reqFuncs)) + for name := range reqFuncs { + reqPayload.RequestFunctions = append(reqPayload.RequestFunctions, name) + } + } + + if len(requirePaths) > 0 { + reqPayload.RequirePaths = append(reqPayload.RequirePaths, requirePaths...) + } + + handler := func(name string, args []interface{}) (*nodeRequestResponse, error) { + req, ok := reqFuncs[name] + if !ok { + return &nodeRequestResponse{ + Success: false, + Error: fmt.Sprintf("unknown request function: %s", name), + }, nil + } + + if requestExecutor == nil { + return &nodeRequestResponse{ + Success: false, + Error: "request executor not available", + }, nil + } + + resp, execErr := requestExecutor(req) + 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 := runtime.Execute(reqPayload, handler) + te.vmMu.Lock() + defer te.vmMu.Unlock() + + if err != nil { + if te.nodeRuntime != 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 +648,29 @@ 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 +} + +// 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() + 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}