diff --git a/doc/api/debugger.md b/doc/api/debugger.md index ce25c927d8ef81..5a56667a18df8c 100644 --- a/doc/api/debugger.md +++ b/doc/api/debugger.md @@ -90,6 +90,157 @@ steps to the next line. Type `help` to see what other commands are available. Pressing `enter` without typing a command will repeat the previous debugger command. +## Probe mode + + + +> Stability: 1 - Experimental + +`node inspect` supports a non-interactive probe mode for inspecting runtime values +in an application via the flag `--probe`. Probe mode launches the application, +sets one or more source breakpoints, evaluates one expression whenever a +matching breakpoint is hit, and prints one final report when the session ends +(either on normal completion or timeout). This allows developers to perform +printf-style debugging without having to modify the application code and +clean up afterwards, and it supports structured output for tool use. + +```console +$ node inspect [--json] [--preview] [--timeout=] [--port=] \ + --probe app.js:10 --expr 'x' \ + [--probe app.js:20 --expr 'y' ...] \ + [--] [ ...] [args...] +``` + +* `--probe :[:]`: Source location to probe. Line and column number + are 1-based. +* `--timeout=`: A global wall-clock deadline for the entire probe session. + The default is `30000`. This can be used to probe a long-running application + that can be terminated externally. +* `--json`: If used, prints a structured JSON report instead of the default text report. +* `--preview`: If used, non-primitive values will include CDP property previews for + object-like JSON probe values. +* `--port=`: Selects the local inspector port used for the `--inspect-brk` + launch path. Probe mode defaults to `0`, which requests a random port. +* `--` is optional unless the child needs its own Node.js flags. + +Additional rules about the `--probe` and `--expr` arguments: + +* `--probe :[:]` and `--expr ` are strict pairs. Each + `--probe` must be followed immediately by exactly one `--expr`. +* `--timeout`, `--json`, `--preview`, and `--port` are global probe options + for the whole probe session. They may appear before or between probe pairs, + but not between a `--probe` and its matching `--expr`. + +If a single probe needs to evaluate more than one value, +evaluate a structured value in `--expr`, for example `--expr "{ foo, bar }"` +or `--expr "[foo, bar]"`, and use `--preview` to include property previews for +any object-like values in the output. + +Probe mode only prints the final probe report to stdout, and otherwise silences +stdout/stderr from the child process. If the child exits with an error after the +probe session starts, the final report records a terminal `error` event with the +exit code and captured child stderr. Invalid arguments and fatal launch or +connect failures may still print diagnostics to stderr without a final probe +result. + +Consider this script: + +```js +// cli.js +let maxRSS = 0; +for (let i = 0; i < 2; i++) { + const { rss } = process.memoryUsage(); + maxRSS = Math.max(maxRSS, rss); +} +``` + +If `--json` is not used, the output is printed in a human-readable text format: + +```console +$ node inspect --probe cli.js:5 --expr 'rss' cli.js +Hit 1 at cli.js:5 + rss = 54935552 +Hit 2 at cli.js:5 + rss = 55083008 +Completed +``` + +Primitive results are printed directly, while objects and arrays use Chrome +DevTools Protocol preview data when available. Other non-primitive values +fall back to the Chrome DevTools Protocol `description` string. +Expression failures are recorded as `[error] ...` lines and do not fail +the overall session. If richer text formatting is needed, wrap the expression +in `JSON.stringify(...)` or `util.inspect(...)`. + +When `--json` is used, the output shape looks like this: + +```console +$ node inspect --json --probe cli.js:5 --expr 'rss' cli.js +{"v":1,"probes":[{"expr":"rss","target":["cli.js",5]}],"results":[{"probe":0,"event":"hit","hit":1,"result":{"type":"number","value":55443456,"description":"55443456"}},{"probe":0,"event":"hit","hit":2,"result":{"type":"number","value":55574528,"description":"55574528"}},{"event":"completed"}]} +``` + +```json +{ + "v": 1, // Probe JSON schema version. + "probes": [ + { + "expr": "rss", // The expression paired with --probe. + "target": ["cli.js", 5] // [file, line] or [file, line, col]. + } + ], + "results": [ + { + "probe": 0, // Index into probes[]. + "event": "hit", // Hit events are recorded in observation order. + "hit": 1, // 1-based hit count for this probe. + "result": { + "type": "number", + "value": 55443456, + "description": "55443456" + } + // If the expression throws, "error" is present instead of "result". + }, + { + "probe": 0, + "event": "hit", + "hit": 2, + "result": { + "type": "number", + "value": 55574528, + "description": "55574528" + } + }, + { + "event": "completed" + // The final entry is always a terminal event, for example: + // 1. { "event": "completed" } + // 2. { "event": "miss", "pending": [0, 1] } + // 3. { + // "event": "timeout", + // "pending": [0], + // "error": { + // "code": "probe_timeout", + // "message": "Timed out after 30000ms waiting for probes: app.js:10" + // } + // } + // 4. { + // "event": "error", + // "pending": [0], + // "error": { + // "code": "probe_target_exit", + // "exitCode": 1, + // "stderr": "[Error: boom]", + // "message": "Target exited with code 1 before probes: app.js:10" + // } + // } + } + ] +} +``` + ## Watchers It is possible to watch expression and variable values while debugging. On diff --git a/lib/internal/debugger/inspect.js b/lib/internal/debugger/inspect.js index 6a763b7770c0ed..1012fd6eacd30e 100644 --- a/lib/internal/debugger/inspect.js +++ b/lib/internal/debugger/inspect.js @@ -1,28 +1,41 @@ 'use strict'; const { + ArrayIsArray, ArrayPrototypeForEach, ArrayPrototypeJoin, ArrayPrototypeMap, ArrayPrototypePop, + ArrayPrototypePush, ArrayPrototypePushApply, ArrayPrototypeShift, ArrayPrototypeSlice, FunctionPrototypeBind, + JSONStringify, Number, + NumberIsNaN, + NumberParseInt, + ObjectEntries, Promise, PromisePrototypeThen, PromiseResolve, Proxy, RegExpPrototypeExec, RegExpPrototypeSymbolSplit, + SafeMap, + SafeSet, StringPrototypeEndsWith, + StringPrototypeIncludes, + StringPrototypeLastIndexOf, + StringPrototypeSlice, StringPrototypeSplit, + StringPrototypeStartsWith, } = primordials; const { spawn } = require('child_process'); const { EventEmitter } = require('events'); const net = require('net'); +const { clearTimeout, setTimeout } = require('timers'); const util = require('util'); const { setInterval: pSetInterval, @@ -31,6 +44,7 @@ const { const { AbortController, } = require('internal/abort_controller'); +const { SideEffectFreeRegExpPrototypeSymbolReplace } = require('internal/util'); const InspectClient = require('internal/debugger/inspect_client'); const createRepl = require('internal/debugger/inspect_repl'); @@ -46,6 +60,23 @@ const { }, } = internalBinding('errors'); +const kProbeDefaultTimeout = 30000; +const kProbeVersion = 1; +const kProbeDisconnectSentinel = 'Waiting for the debugger to disconnect...'; +const kDigitsRegex = /^\d+$/; +const kInspectPortRegex = /^--inspect-port=(\d+)$/; +const kProbeArgOptions = { + __proto__: null, + expr: { type: 'string' }, + json: { type: 'boolean' }, + // Port and timeout use type 'string' because parseArgs has no + // numeric type; the values are parsed to integers in parseProbeArgv(). + port: { type: 'string' }, + preview: { type: 'boolean' }, + probe: { type: 'string' }, + timeout: { type: 'string' }, +}; + async function portIsFree(host, port, timeout = 3000) { if (port === 0) return; // Binding to a random port. @@ -77,34 +108,797 @@ async function portIsFree(host, port, timeout = 3000) { } const debugRegex = /Debugger listening on ws:\/\/\[?(.+?)\]?:(\d+)\//; -async function runScript(script, scriptArgs, inspectHost, inspectPort, - childPrint) { - await portIsFree(inspectHost, inspectPort); - const args = [`--inspect-brk=${inspectPort}`, script]; - ArrayPrototypePushApply(args, scriptArgs); + +function getInspectUsage(invokedAs) { + return `Usage: ${invokedAs} script.js\n` + + ` ${invokedAs} :\n` + + ` ${invokedAs} --port= Use 0 for random port assignment\n` + + ` ${invokedAs} -p \n` + + ` ${invokedAs} [--json] [--timeout=] [--port=] ` + + `--probe :[:] --expr ` + + `[--probe :[:] --expr ...] ` + + `[--] [ ...] [args...]\n`; +} + +function writeUsageAndExit(invokedAs, message, exitCode = kInvalidCommandLineArgument) { + if (message) { + process.stderr.write(`${message}\n`); + } + process.stderr.write(getInspectUsage(invokedAs)); + process.exit(exitCode); +} + +function ensureTrailingNewline(text) { + return StringPrototypeEndsWith(text, '\n') ? text : `${text}\n`; +} + +function parseUnsignedInteger(value, name, allowZero = false) { + if (typeof value !== 'string' || RegExpPrototypeExec(kDigitsRegex, value) === null) { + throw new ERR_DEBUGGER_STARTUP_ERROR(`Invalid ${name}: ${value}`); + } + const parsed = NumberParseInt(value, 10); + if (NumberIsNaN(parsed) || (!allowZero && parsed < 1)) { + throw new ERR_DEBUGGER_STARTUP_ERROR(`Invalid ${name}: ${value}`); + } + return parsed; +} + +// Parse from right to left to allow Windows paths with drive letters. +// Accepts file:line or file:line:column formats. +function parseProbeLocation(text) { + const lastColon = StringPrototypeLastIndexOf(text, ':'); + if (lastColon === -1) { + throw new ERR_DEBUGGER_STARTUP_ERROR(`Invalid probe location: ${text}`); + } + + const right = StringPrototypeSlice(text, lastColon + 1); + if (RegExpPrototypeExec(kDigitsRegex, right) === null) { + throw new ERR_DEBUGGER_STARTUP_ERROR(`Invalid probe location: ${text}`); + } + + let file = StringPrototypeSlice(text, 0, lastColon); + let line = parseUnsignedInteger(right, 'probe location'); + let column; + + const secondColon = StringPrototypeLastIndexOf(file, ':'); + if (secondColon !== -1) { + const maybeLine = StringPrototypeSlice(file, secondColon + 1); + if (RegExpPrototypeExec(kDigitsRegex, maybeLine) !== null) { + column = line; + line = parseUnsignedInteger(maybeLine, 'probe location'); + file = StringPrototypeSlice(file, 0, secondColon); + } + } + + if (file.length === 0) { + throw new ERR_DEBUGGER_STARTUP_ERROR(`Invalid probe location: ${text}`); + } + + const target = column === undefined ? [file, line] : [file, line, column]; + + return { + file, + lineNumber: line - 1, + columnNumber: column === undefined ? undefined : column - 1, + target, + }; +} + +function formatProbeTuple(tuple) { + return ArrayPrototypeJoin(ArrayPrototypeMap(tuple, (part) => `${part}`), ':'); +} + +function formatPendingProbeLocations(probes, pending) { + const seen = new SafeSet(); + const names = []; + for (const probeIndex of pending) { + const location = formatProbeTuple(probes[probeIndex].target); + if (!seen.has(location)) { + seen.add(location); + ArrayPrototypePush(names, location); + } + } + return ArrayPrototypeJoin(names, ', '); +} + +function formatTargetExitMessage(probes, pending, exitCode, signal) { + const status = signal === null ? + `Target exited with code ${exitCode}` : + `Target exited with signal ${signal}`; + if (pending.length === 0) { + return `${status} before target completion`; + } + return `${status} before probes: ${formatPendingProbeLocations(probes, pending)}`; +} + +// Trim the "Waiting for the debugger to disconnect..." message from stderr for reporting child errors. +function trimProbeChildStderr(stderr) { + const lines = RegExpPrototypeSymbolSplit(/\r\n|\r|\n/g, stderr); + const kept = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line === '' && i === lines.length - 1) { continue; } + if (line === kProbeDisconnectSentinel) { continue; } + ArrayPrototypePush(kept, line); + } + return ArrayPrototypeJoin(kept, '\n'); +} + +function formatPreviewPropertyValue(property) { + if (property.type === 'string') { + return JSONStringify(property.value ?? ''); + } + return property.value ?? property.type; +} + +function trimRemoteObject(result) { + if (result === undefined || result === null || typeof result !== 'object') { + return result; + } + + if (ArrayIsArray(result)) { + return ArrayPrototypeMap(result, trimRemoteObject); + } + + const trimmed = { __proto__: null }; + for (const { 0: key, 1: value } of ObjectEntries(result)) { + if (key === 'objectId' || key === 'className') { + continue; + } + trimmed[key] = trimRemoteObject(value); + } + return trimmed; +} + +function stripProbePreviews(value) { + if (value === undefined || value === null || typeof value !== 'object') { + return value; + } + + if (ArrayIsArray(value)) { + return ArrayPrototypeMap(value, stripProbePreviews); + } + + const stripped = { __proto__: null }; + for (const { 0: key, 1: entry } of ObjectEntries(value)) { + if (key === 'preview') { + continue; + } + stripped[key] = stripProbePreviews(entry); + } + return stripped; +} + +// Format CDP RemoteObject values into more readable formats. +function formatRemoteObject(result) { + if (result === undefined) { return 'undefined'; } + + switch (result.type) { + case 'undefined': + return 'undefined'; + case 'string': + return JSONStringify(result.value); + case 'number': + if (result.unserializableValue !== undefined) { + return result.unserializableValue; + } + return `${result.value}`; + case 'boolean': + return `${result.value}`; + case 'symbol': + return result.description || 'Symbol()'; + case 'bigint': + return result.unserializableValue ?? result.description ?? '0n'; + case 'function': + return result.description || 'function()'; + case 'object': + if (result.subtype === 'null') { + return 'null'; + } + if (result.subtype === 'error') { + return result.description || 'Error'; + } + if (result.preview !== undefined) { + const properties = ArrayPrototypeJoin(ArrayPrototypeMap( + result.preview.properties, + result.preview.subtype === 'array' ? + (property) => formatPreviewPropertyValue(property) : + (property) => `${property.name}: ${formatPreviewPropertyValue(property)}`, + ), ', '); + const suffix = result.preview.overflow ? ', ...' : ''; + if (result.preview.subtype === 'array') { + return `[${properties}${suffix}]`; + } + return `{${properties}${suffix}}`; + } + return result.description || result.className || 'Object'; + default: + return `${result.value ?? result.description ?? ''}`; + } +} + +// Built human-readable text output for probe reports. +function buildProbeTextReport(report) { + const lines = []; + + for (const result of report.results) { + if (result.event === 'hit') { + const probe = report.probes[result.probe]; + const location = formatProbeTuple(probe.target); + ArrayPrototypePush(lines, `Hit ${result.hit} at ${location}`); + if (result.error !== undefined) { + ArrayPrototypePush(lines, + ` [error] ${probe.expr} = ` + + `${formatRemoteObject(result.error)}`); + } else { + ArrayPrototypePush(lines, + ` ${probe.expr} = ` + + `${formatRemoteObject(result.result)}`); + } + continue; + } + + if (result.event === 'completed') { + ArrayPrototypePush(lines, 'Completed'); + continue; + } + + if (result.event === 'miss') { + ArrayPrototypePush(lines, + `Missed probes: ` + + `${formatPendingProbeLocations(report.probes, result.pending)}`); + continue; + } + + if (result.event === 'timeout') { + ArrayPrototypePush(lines, result.error.message); + continue; + } + + if (result.event === 'error') { + ArrayPrototypePush(lines, result.error.message); + if (result.error.stderr !== undefined) { + ArrayPrototypePush(lines, ' [stderr]'); + const stderrLines = RegExpPrototypeSymbolSplit( + /\r\n|\r|\n/g, + result.error.stderr, + ); + for (let i = 0; i < stderrLines.length; i++) { + if (stderrLines[i] === '' && i === stderrLines.length - 1) { + continue; + } + ArrayPrototypePush(lines, ` ${stderrLines[i]}`); + } + } + } + } + + return ensureTrailingNewline(ArrayPrototypeJoin(lines, '\n')); +} + +function hasTopLevelProbeOption(args) { + const { tokens } = util.parseArgs({ + args, + allowPositionals: true, + options: kProbeArgOptions, + strict: false, + tokens: true, + }); + + for (const token of tokens) { + if (token.kind === 'option' && token.name === 'probe') { + return true; + } + + if (token.kind === 'option-terminator' || token.kind === 'positional') { + return false; + } + } + + return false; +} + +function parseProbeArgv(args) { + let port = 0; + let preview = false; + let timeout = kProbeDefaultTimeout; + let json = false; + let sawSeparator = false; + let childStartIndex = args.length; + let pendingLocation; + let expectedExprIndex = -1; + const probes = []; + + const { tokens } = util.parseArgs({ + args, + allowPositionals: true, + options: kProbeArgOptions, + strict: false, + tokens: true, + }); + + for (const token of tokens) { + if (token.kind === 'option-terminator') { + sawSeparator = true; + childStartIndex = token.index + 1; + break; + } + + if (pendingLocation !== undefined) { + if (token.kind === 'option' && + token.name === 'expr' && + token.index === expectedExprIndex && + token.value !== undefined) { + ArrayPrototypePush(probes, { + expr: token.value, + location: pendingLocation, + }); + pendingLocation = undefined; + continue; + } + + throw new ERR_DEBUGGER_STARTUP_ERROR( + 'Each --probe must be followed immediately by --expr '); + } + + if (token.kind === 'positional') { + childStartIndex = token.index; + break; + } + + switch (token.name) { + case 'json': + json = true; + break; + case 'timeout': + if (token.value === undefined) { + throw new ERR_DEBUGGER_STARTUP_ERROR(`Missing value for ${token.rawName}`); + } + timeout = parseUnsignedInteger(token.value, 'timeout', true); + break; + case 'port': + if (token.value === undefined) { + throw new ERR_DEBUGGER_STARTUP_ERROR(`Missing value for ${token.rawName}`); + } + port = parseUnsignedInteger(token.value, 'inspector port', true); + break; + case 'preview': + preview = true; + break; + case 'probe': + pendingLocation = parseProbeLocation(token.value); + expectedExprIndex = token.index + (token.inlineValue ? 1 : 2); + break; + case 'expr': + throw new ERR_DEBUGGER_STARTUP_ERROR('Unexpected --expr before --probe'); + default: + if (probes.length > 0) { + throw new ERR_DEBUGGER_STARTUP_ERROR( + 'Use -- before child Node.js flags in probe mode'); + } + throw new ERR_DEBUGGER_STARTUP_ERROR(`Unknown probe option: ${token.rawName}`); + } + } + + if (pendingLocation !== undefined) { + throw new ERR_DEBUGGER_STARTUP_ERROR( + 'Each --probe must be followed immediately by --expr '); + } + + if (probes.length === 0) { + throw new ERR_DEBUGGER_STARTUP_ERROR( + 'Probe mode requires at least one --probe --expr group'); + } + + const childArgv = ArrayPrototypeSlice(args, childStartIndex); + if (childArgv.length === 0) { + throw new ERR_DEBUGGER_STARTUP_ERROR('Probe mode requires a child script'); + } + + if (!sawSeparator && StringPrototypeStartsWith(childArgv[0], '-')) { + throw new ERR_DEBUGGER_STARTUP_ERROR('Use -- before child Node.js flags in probe mode'); + } + + let skipPortPreflight = port === 0; + for (const arg of childArgv) { + const inspectPortMatch = RegExpPrototypeExec(kInspectPortRegex, arg); + if (inspectPortMatch === null) { + continue; + } + if (inspectPortMatch[1] === '0') { + skipPortPreflight = true; + continue; + } + throw new ERR_DEBUGGER_STARTUP_ERROR( + 'Only child --inspect-port=0 is supported in probe mode'); + } + + return { + host: '127.0.0.1', + port, + preview, + timeout, + json, + probes, + childArgv, + skipPortPreflight, + }; +} + +async function launchChildProcess(childArgs, inspectHost, inspectPort, + childOutput, options = { __proto__: null }) { + if (!options.skipPortPreflight) { + await portIsFree(inspectHost, inspectPort); + } + + const args = [`--inspect-brk=${inspectPort}`]; + ArrayPrototypePushApply(args, childArgs); + const child = spawn(process.execPath, args); child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); - child.stdout.on('data', (chunk) => childPrint(chunk, 'stdout')); - child.stderr.on('data', (chunk) => childPrint(chunk, 'stderr')); - - let output = ''; - return new Promise((resolve) => { - function waitForListenHint(text) { - output += text; - const debug = RegExpPrototypeExec(debugRegex, output); + child.stdout.on('data', (chunk) => childOutput(chunk, 'stdout')); + child.stderr.on('data', (chunk) => childOutput(chunk, 'stderr')); + + let stderrOutput = ''; + return new Promise((resolve, reject) => { + function rejectLaunch(message) { + reject(new ERR_DEBUGGER_STARTUP_ERROR(message, { childStderr: stderrOutput })); + } + + function onExit(code, signal) { + const suffix = signal !== null ? ` (${signal})` : ` (code ${code})`; + rejectLaunch(`Target exited before the inspector was ready${suffix}`); + } + + function onError(error) { + rejectLaunch(error.message); + } + + function onStderr(text) { + stderrOutput += text; + const debug = RegExpPrototypeExec(debugRegex, stderrOutput); if (debug) { - const host = debug[1]; - const port = Number(debug[2]); - child.stderr.removeListener('data', waitForListenHint); - resolve([child, port, host]); + child.stderr.removeListener('data', onStderr); + child.removeListener('exit', onExit); + child.removeListener('error', onError); + resolve([child, Number(debug[2]), debug[1]]); } } - child.stderr.on('data', waitForListenHint); + child.once('exit', onExit); + child.once('error', onError); + child.stderr.on('data', onStderr); }); } +class ProbeInspectorSession { + constructor(options) { + this.options = options; + this.client = new InspectClient(); + this.child = null; + this.cleanupStarted = false; + this.childStderr = ''; + this.disconnectRequested = false; + this.finished = false; + this.started = false; + this.stderrBuffer = ''; + this.breakpointDefinitions = new SafeMap(); + this.results = []; + this.timeout = null; + this.resolveCompletion = null; + this.completionPromise = new Promise((resolve) => { + this.resolveCompletion = resolve; + }); + this.probes = ArrayPrototypeMap(options.probes, (probe) => ({ + expr: probe.expr, + target: probe.location.target, + location: probe.location, + hits: 0, + })); + + this.onChildOutput = FunctionPrototypeBind(this.onChildOutput, this); + this.onChildExit = FunctionPrototypeBind(this.onChildExit, this); + this.onClientClose = FunctionPrototypeBind(this.onClientClose, this); + this.onPaused = FunctionPrototypeBind(this.onPaused, this); + } + + finish(state) { + if (this.finished) { return; } + this.finished = true; + if (this.timeout !== null) { + clearTimeout(this.timeout); + this.timeout = null; + } + this.resolveCompletion(state); + } + + onChildOutput(text, which) { + if (which !== 'stderr') { return; } + + if (this.started) { + this.childStderr += text; + } + + const combined = this.stderrBuffer + text; + if (this.started && + StringPrototypeIncludes(combined, kProbeDisconnectSentinel)) { + this.disconnectRequested = true; + this.client.reset(); + } + + if (combined.length > kProbeDisconnectSentinel.length) { + this.stderrBuffer = StringPrototypeSlice(combined, + combined.length - + kProbeDisconnectSentinel.length); + } else { + this.stderrBuffer = combined; + } + } + + onChildExit(code, signal) { + if (this.started) { + if (code !== 0 || signal !== null) { + this.finish({ + __proto__: null, + event: 'error', + exitCode: code, + signal, + stderr: trimProbeChildStderr(this.childStderr), + }); + } else { + this.finish('complete'); + } + } + } + + onClientClose() { + if (!this.started || this.child === null) { return; } + + // TODO(joyeecheung): Surface mid-probe inspector disconnects as terminal probe errors + // instead of deferring to timeout or miss classification. + if (this.disconnectRequested) { return; } + + if (this.child.exitCode !== null || this.child.signalCode !== null) { + this.onChildExit(this.child.exitCode, this.child.signalCode); + } + } + + onPaused(params) { + // TODO(joyeecheung): Preserve evaluation and resume failures as terminal probe errors + // instead of collapsing them into a synthetic completion. + this.handlePaused(params).catch((error) => { + if (!this.finished) { + if (error?.code === 'ERR_DEBUGGER_ERROR') { + if (this.child !== null && + (this.child.exitCode !== null || this.child.signalCode !== null)) { + this.onChildExit(this.child.exitCode, this.child.signalCode); + } + return; + } + this.finish('complete'); + } + }); + } + + async handlePaused(params) { + if (this.finished) { return; } + + const hitBreakpoints = params.hitBreakpoints; + if (hitBreakpoints === undefined || hitBreakpoints.length === 0) { + await this.resume(); + return; + } + + const callFrameId = params.callFrames?.[0]?.callFrameId; + if (callFrameId === undefined) { + await this.resume(); + return; + } + + for (const breakpointId of hitBreakpoints) { + const definition = this.breakpointDefinitions.get(breakpointId); + if (definition === undefined) { continue; } + for (const probeIndex of definition.probeIndices) { + await this.evaluateProbe(callFrameId, probeIndex); + } + } + + await this.resume(); + } + + async evaluateProbe(callFrameId, probeIndex) { + const probe = this.probes[probeIndex]; + const evaluation = await this.client.callMethod('Debugger.evaluateOnCallFrame', { + callFrameId, + expression: probe.expr, + generatePreview: true, + }); + + probe.hits++; + const result = { probe: probeIndex, event: 'hit', hit: probe.hits }; + + if (evaluation.exceptionDetails !== undefined) { + result.error = evaluation.result === undefined ? { + type: 'object', + subtype: 'error', + description: 'Probe expression failed', + } : trimRemoteObject(evaluation.result); + } else { + result.result = trimRemoteObject(evaluation.result); + } + + ArrayPrototypePush(this.results, result); + } + + async resume() { + if (this.finished) { return; } + await this.client.callMethod('Debugger.resume'); + } + + startTimeout() { + this.timeout = setTimeout(() => { this.finish('timeout'); }, this.options.timeout); + this.timeout.unref(); + } + + attachListeners() { + this.child.on('exit', this.onChildExit); + this.client.on('close', this.onClientClose); + this.client.on('Debugger.paused', this.onPaused); + } + + async bindBreakpoints() { + const uniqueLocations = new SafeMap(); + + for (let probeIndex = 0; probeIndex < this.probes.length; probeIndex++) { + const probe = this.probes[probeIndex]; + const key = `${probe.location.file}\n${probe.location.lineNumber}\n` + + `${probe.location.columnNumber === undefined ? '' : probe.location.columnNumber}`; + let entry = uniqueLocations.get(key); + if (entry === undefined) { + entry = { location: probe.location, probeIndices: [] }; + uniqueLocations.set(key, entry); + } + ArrayPrototypePush(entry.probeIndices, probeIndex); + } + + for (const { location, probeIndices } of uniqueLocations.values()) { + // TODO(joyeecheung): Normalize relative probe paths and avoid suffix matches that can + // bind unrelated loaded scripts with the same basename. + const escapedPath = SideEffectFreeRegExpPrototypeSymbolReplace( + /([/\\.?*()^${}|[\]])/g, + location.file, + '\\$1', + ); + const params = { + urlRegex: `^(.*[\\/\\\\])?${escapedPath}$`, + lineNumber: location.lineNumber, + }; + if (location.columnNumber !== undefined) { + params.columnNumber = location.columnNumber; + } + + const result = await this.client.callMethod('Debugger.setBreakpointByUrl', params); + this.breakpointDefinitions.set(result.breakpointId, { probeIndices }); + } + } + + getPendingProbeIndices() { + const pending = []; + for (let probeIndex = 0; probeIndex < this.probes.length; probeIndex++) { + if (this.probes[probeIndex].hits === 0) { + ArrayPrototypePush(pending, probeIndex); + } + } + return pending; + } + + buildReport(state) { + const pending = this.getPendingProbeIndices(); + const report = { + v: kProbeVersion, + probes: ArrayPrototypeMap(this.probes, (probe) => { + return { expr: probe.expr, target: probe.target }; + }), + results: ArrayPrototypeSlice(this.results), + }; + + if (state === 'timeout') { + ArrayPrototypePush(report.results, { + event: 'timeout', + pending, + error: { + code: 'probe_timeout', + message: pending.length === 0 ? + `Timed out after ${this.options.timeout}ms waiting for target completion` : + `Timed out after ${this.options.timeout}ms waiting for probes: ` + + `${formatPendingProbeLocations(this.probes, pending)}`, + }, + }); + return { code: kGenericUserError, report }; + } + + if (state?.event === 'error') { + const error = { + __proto__: null, + code: 'probe_target_exit', + message: formatTargetExitMessage(this.probes, pending, state.exitCode, state.signal), + }; + if (state.exitCode !== null) { + error.exitCode = state.exitCode; + } + if (state.signal !== null) { + error.signal = state.signal; + } + error.stderr = state.stderr; + ArrayPrototypePush(report.results, { event: 'error', pending, error }); + return { code: kNoFailure, report }; + } + + if (pending.length === 0) { + ArrayPrototypePush(report.results, { event: 'completed' }); + } else { + ArrayPrototypePush(report.results, { event: 'miss', pending }); + } + + return { code: kNoFailure, report }; + } + + async cleanup() { + if (this.cleanupStarted) { return; } + this.cleanupStarted = true; + + if (this.timeout !== null) { + clearTimeout(this.timeout); + this.timeout = null; + } + + this.client.reset(); + + if (this.child === null) { return; } + + if (this.child.exitCode === null && this.child.signalCode === null) { + this.child.kill(); + } + } + + async run() { + try { + const { childArgv, host, port, skipPortPreflight } = this.options; + const { 0: child, 1: actualPort, 2: actualHost } = + await launchChildProcess(childArgv, + host, + port, + this.onChildOutput, + { skipPortPreflight }); + this.child = child; + this.attachListeners(); + + await this.client.connect(actualPort, actualHost); + await this.client.callMethod('Runtime.enable'); + await this.client.callMethod('Debugger.enable'); + await this.bindBreakpoints(); + this.started = true; + this.startTimeout(); + + await this.client.callMethod('Runtime.runIfWaitingForDebugger'); + + const state = await this.completionPromise; + return this.buildReport(state); + } finally { + await this.cleanup(); + } + } +} + +async function runScript(script, scriptArgs, inspectHost, inspectPort, + childPrint) { + return launchChildProcess([script, ...scriptArgs], + inspectHost, + inspectPort, + childPrint); +} + function createAgentProxy(domain, client) { const agent = new EventEmitter(); agent.then = (then, _catch) => { @@ -330,14 +1124,35 @@ function parseArgv(args) { function startInspect(argv = ArrayPrototypeSlice(process.argv, 2), stdin = process.stdin, stdout = process.stdout) { + const invokedAs = `${process.argv0} ${process.argv[1]}`; + if (argv.length < 1) { - const invokedAs = `${process.argv0} ${process.argv[1]}`; + writeUsageAndExit(invokedAs); + } + + if (hasTopLevelProbeOption(argv)) { + let probeOptions; + try { + probeOptions = parseProbeArgv(argv); + } catch (error) { + writeUsageAndExit(invokedAs, error.message, kGenericUserError); + } - process.stderr.write(`Usage: ${invokedAs} script.js\n` + - ` ${invokedAs} :\n` + - ` ${invokedAs} --port= Use 0 for random port assignment\n` + - ` ${invokedAs} -p \n`); - process.exit(kInvalidCommandLineArgument); + (async () => { + const session = new ProbeInspectorSession(probeOptions); + const { code, report } = await session.run(); + stdout.write(probeOptions.json ? + `${JSONStringify(probeOptions.preview ? report : stripProbePreviews(report))}\n` : + buildProbeTextReport(report)); + process.exit(code); + })().catch((error) => { + if (error.childStderr) { + process.stderr.write(error.childStderr); + } + process.stderr.write(ensureTrailingNewline(error.message)); + process.exit(kGenericUserError); + }); + return; } const options = parseArgv(argv); diff --git a/lib/internal/debugger/inspect_client.js b/lib/internal/debugger/inspect_client.js index 839be084d7e58b..1e5794ecc3a87e 100644 --- a/lib/internal/debugger/inspect_client.js +++ b/lib/internal/debugger/inspect_client.js @@ -1,12 +1,14 @@ 'use strict'; const { + ArrayPrototypeForEach, ArrayPrototypePush, ErrorCaptureStackTrace, FunctionPrototypeBind, JSONParse, JSONStringify, ObjectKeys, + ObjectValues, Promise, } = primordials; @@ -224,6 +226,15 @@ class Client extends EventEmitter { } reset() { + const pending = this._pending; + if (pending) { + ArrayPrototypeForEach(ObjectValues(pending), (handler) => { + handler({ + code: 'ERR_DEBUGGER_ERROR', + message: 'Debugger session ended', + }); + }); + } if (this._http) { this._http.destroy(); } diff --git a/lib/internal/errors.js b/lib/internal/errors.js index 206e2a24716022..6d3a8f5e102474 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1192,7 +1192,12 @@ E('ERR_CRYPTO_SCRYPT_NOT_SUPPORTED', 'Scrypt algorithm not supported', Error); // Switch to TypeError. The current implementation does not seem right. E('ERR_CRYPTO_SIGN_KEY_REQUIRED', 'No key provided to sign', Error); E('ERR_DEBUGGER_ERROR', '%s', Error); -E('ERR_DEBUGGER_STARTUP_ERROR', '%s', Error); +E('ERR_DEBUGGER_STARTUP_ERROR', function(message, details = undefined) { + if (details !== undefined) { + ObjectAssign(this, details); + } + return message; +}, Error); E('ERR_DIR_CLOSED', 'Directory handle was closed', Error); E('ERR_DIR_CONCURRENT_OPERATION', 'Cannot do synchronous work on directory handle with concurrent ' + diff --git a/test/common/debugger-probe.js b/test/common/debugger-probe.js new file mode 100644 index 00000000000000..4ad693e0363c26 --- /dev/null +++ b/test/common/debugger-probe.js @@ -0,0 +1,21 @@ +'use strict'; + +const fixtures = require('./fixtures'); +const path = require('path'); + +function debuggerFixturePath(name) { + return path.relative(process.cwd(), fixtures.path('debugger', name)); +} + +function escapeRegex(string) { + return string.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); +} + +module.exports = { + escapeRegex, + missScript: debuggerFixturePath('probe-miss.js'), + probeScript: debuggerFixturePath('probe.js'), + throwScript: debuggerFixturePath('probe-throw.js'), + probeTypesScript: debuggerFixturePath('probe-types.js'), + timeoutScript: debuggerFixturePath('probe-timeout.js'), +}; diff --git a/test/fixtures/debugger/probe-miss.js b/test/fixtures/debugger/probe-miss.js new file mode 100644 index 00000000000000..1b647114a6e9ed --- /dev/null +++ b/test/fixtures/debugger/probe-miss.js @@ -0,0 +1,5 @@ +'use strict'; + +function neverCalled() { + console.log('unreachable'); +} diff --git a/test/fixtures/debugger/probe-timeout.js b/test/fixtures/debugger/probe-timeout.js new file mode 100644 index 00000000000000..8894305e3d905b --- /dev/null +++ b/test/fixtures/debugger/probe-timeout.js @@ -0,0 +1,7 @@ +'use strict'; + +function neverCalled() { + console.log('never'); +} + +setInterval(() => {}, 1000); diff --git a/test/fixtures/debugger/probe-types.js b/test/fixtures/debugger/probe-types.js new file mode 100644 index 00000000000000..9ca45b69684b37 --- /dev/null +++ b/test/fixtures/debugger/probe-types.js @@ -0,0 +1,17 @@ +'use strict'; + +const stringValue = 'hello'; +const booleanValue = true; +const undefinedValue = undefined; +const nullValue = null; +const nanValue = NaN; +const bigintValue = 1n; +const symbolValue = Symbol('tag'); +const functionValue = () => 1; +const objectValue = { alpha: 1, beta: 'two' }; +const arrayValue = [1, 'two', 3]; +const errorValue = new Error('boom'); +errorValue.stack = 'Error: boom'; + +function consume() {} +consume(); diff --git a/test/fixtures/debugger/probe.js b/test/fixtures/debugger/probe.js new file mode 100644 index 00000000000000..a847c7af62f0d1 --- /dev/null +++ b/test/fixtures/debugger/probe.js @@ -0,0 +1,12 @@ +'use strict'; + +console.log('probe stdout'); +console.error('probe stderr'); + +let total = 0; +for (let index = 0; index < 2; index++) { + total += index + 40; +} + +const finalValue = total; +console.log(finalValue); diff --git a/test/parallel/test-debugger-probe-activation.js b/test/parallel/test-debugger-probe-activation.js new file mode 100644 index 00000000000000..ba19106c815df5 --- /dev/null +++ b/test/parallel/test-debugger-probe-activation.js @@ -0,0 +1,35 @@ +// This tests that probe mode only activates when --probe is present. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const fixtures = require('../common/fixtures'); +const startCLI = require('../common/debugger'); + +const assert = require('assert'); + +const script = fixtures.path('debugger', 'three-lines.js'); +const cli = startCLI([ + script, + '--json', + '--preview', + '--timeout=1', + '--expr', + 'value', +]); + +(async () => { + try { + await cli.waitForInitialBreak(); + await cli.waitForPrompt(); + await cli.command('exec JSON.stringify(process.argv.slice(2))'); + assert.match( + cli.output, + /\["--json","--preview","--timeout=1","--expr","value"\]/, + ); + } finally { + const code = await cli.quit(); + assert.strictEqual(code, 0); + } +})().then(common.mustCall()); diff --git a/test/parallel/test-debugger-probe-child-inspect-port-zero.js b/test/parallel/test-debugger-probe-child-inspect-port-zero.js new file mode 100644 index 00000000000000..a6f03ad9f41cbc --- /dev/null +++ b/test/parallel/test-debugger-probe-child-inspect-port-zero.js @@ -0,0 +1,38 @@ +// This tests child --inspect-port=0 pass-through in probe mode. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const { probeScript } = require('../common/debugger-probe'); + +spawnSyncAndAssert(process.execPath, [ + 'inspect', + '--json', + '--probe', `${probeScript}:12`, + '--expr', 'finalValue', + '--', + '--inspect-port=0', + probeScript, +], { + stdout(output) { + assert.deepStrictEqual(JSON.parse(output), { + v: 1, + probes: [{ + expr: 'finalValue', + target: [probeScript, 12], + }], + results: [{ + probe: 0, + event: 'hit', + hit: 1, + result: { type: 'number', value: 81, description: '81' }, + }, { + event: 'completed', + }], + }); + }, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-global-option-order.js b/test/parallel/test-debugger-probe-global-option-order.js new file mode 100644 index 00000000000000..9e247c3213178a --- /dev/null +++ b/test/parallel/test-debugger-probe-global-option-order.js @@ -0,0 +1,36 @@ +// This tests that global probe options can appear after the first --probe. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const { probeScript } = require('../common/debugger-probe'); + +spawnSyncAndAssert(process.execPath, [ + 'inspect', + '--probe', `${probeScript}:12`, + '--expr', 'finalValue', + '--json', + probeScript, +], { + stdout(output) { + assert.deepStrictEqual(JSON.parse(output), { + v: 1, + probes: [{ + expr: 'finalValue', + target: [probeScript, 12], + }], + results: [{ + probe: 0, + event: 'hit', + hit: 1, + result: { type: 'number', value: 81, description: '81' }, + }, { + event: 'completed', + }], + }); + }, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-json-preview.js b/test/parallel/test-debugger-probe-json-preview.js new file mode 100644 index 00000000000000..62b9edfe489c4a --- /dev/null +++ b/test/parallel/test-debugger-probe-json-preview.js @@ -0,0 +1,98 @@ +// This tests debugger probe JSON preview opt-in for object-like values. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const { probeTypesScript } = require('../common/debugger-probe'); + +const location = `${probeTypesScript}:17`; + +spawnSyncAndAssert(process.execPath, [ + 'inspect', + '--json', + '--preview', + '--probe', location, + '--expr', 'objectValue', + '--probe', location, + '--expr', 'arrayValue', + '--probe', location, + '--expr', 'errorValue', + probeTypesScript, +], { + stdout(output) { + assert.deepStrictEqual(JSON.parse(output), { + v: 1, + probes: [ + { expr: 'objectValue', target: [probeTypesScript, 17] }, + { expr: 'arrayValue', target: [probeTypesScript, 17] }, + { expr: 'errorValue', target: [probeTypesScript, 17] }, + ], + results: [ + { + probe: 0, + event: 'hit', + hit: 1, + result: { + type: 'object', + description: 'Object', + preview: { + type: 'object', + description: 'Object', + overflow: false, + properties: [ + { name: 'alpha', type: 'number', value: '1' }, + { name: 'beta', type: 'string', value: 'two' }, + ], + }, + }, + }, + { + probe: 1, + event: 'hit', + hit: 1, + result: { + type: 'object', + subtype: 'array', + description: 'Array(3)', + preview: { + type: 'object', + subtype: 'array', + description: 'Array(3)', + overflow: false, + properties: [ + { name: '0', type: 'number', value: '1' }, + { name: '1', type: 'string', value: 'two' }, + { name: '2', type: 'number', value: '3' }, + ], + }, + }, + }, + { + probe: 2, + event: 'hit', + hit: 1, + result: { + type: 'object', + subtype: 'error', + description: 'Error: boom', + preview: { + type: 'object', + subtype: 'error', + description: 'Error: boom', + overflow: false, + properties: [ + { name: 'stack', type: 'string', value: 'Error: boom' }, + { name: 'message', type: 'string', value: 'boom' }, + ], + }, + }, + }, + { event: 'completed' }, + ], + }); + }, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-json-special-values.js b/test/parallel/test-debugger-probe-json-special-values.js new file mode 100644 index 00000000000000..ea18a33f1e9ac7 --- /dev/null +++ b/test/parallel/test-debugger-probe-json-special-values.js @@ -0,0 +1,142 @@ +// This tests debugger probe JSON output for stable special-cased values. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const { probeTypesScript } = require('../common/debugger-probe'); + +const location = `${probeTypesScript}:17`; + +spawnSyncAndAssert(process.execPath, [ + 'inspect', + '--json', + '--probe', location, + '--expr', 'stringValue', + '--probe', location, + '--expr', 'booleanValue', + '--probe', location, + '--expr', 'undefinedValue', + '--probe', location, + '--expr', 'nullValue', + '--probe', location, + '--expr', 'nanValue', + '--probe', location, + '--expr', 'bigintValue', + '--probe', location, + '--expr', 'symbolValue', + '--probe', location, + '--expr', 'functionValue', + '--probe', location, + '--expr', 'objectValue', + '--probe', location, + '--expr', 'arrayValue', + '--probe', location, + '--expr', 'errorValue', + probeTypesScript, +], { + stdout(output) { + assert.deepStrictEqual(JSON.parse(output), { + v: 1, + probes: [ + { expr: 'stringValue', target: [probeTypesScript, 17] }, + { expr: 'booleanValue', target: [probeTypesScript, 17] }, + { expr: 'undefinedValue', target: [probeTypesScript, 17] }, + { expr: 'nullValue', target: [probeTypesScript, 17] }, + { expr: 'nanValue', target: [probeTypesScript, 17] }, + { expr: 'bigintValue', target: [probeTypesScript, 17] }, + { expr: 'symbolValue', target: [probeTypesScript, 17] }, + { expr: 'functionValue', target: [probeTypesScript, 17] }, + { expr: 'objectValue', target: [probeTypesScript, 17] }, + { expr: 'arrayValue', target: [probeTypesScript, 17] }, + { expr: 'errorValue', target: [probeTypesScript, 17] }, + ], + results: [ + { + probe: 0, + event: 'hit', + hit: 1, + result: { type: 'string', value: 'hello' }, + }, + { + probe: 1, + event: 'hit', + hit: 1, + result: { type: 'boolean', value: true }, + }, + { + probe: 2, + event: 'hit', + hit: 1, + result: { type: 'undefined' }, + }, + { + probe: 3, + event: 'hit', + hit: 1, + result: { type: 'object', subtype: 'null', value: null }, + }, + { + probe: 4, + event: 'hit', + hit: 1, + result: { type: 'number', unserializableValue: 'NaN', description: 'NaN' }, + }, + { + probe: 5, + event: 'hit', + hit: 1, + result: { type: 'bigint', unserializableValue: '1n', description: '1n' }, + }, + { + probe: 6, + event: 'hit', + hit: 1, + result: { type: 'symbol', description: 'Symbol(tag)' }, + }, + { + probe: 7, + event: 'hit', + hit: 1, + result: { + type: 'function', + description: '() => 1', + }, + }, + { + probe: 8, + event: 'hit', + hit: 1, + result: { + type: 'object', + description: 'Object', + }, + }, + { + probe: 9, + event: 'hit', + hit: 1, + result: { + type: 'object', + subtype: 'array', + description: 'Array(3)', + }, + }, + { + probe: 10, + event: 'hit', + hit: 1, + result: { + type: 'object', + subtype: 'error', + description: 'Error: boom', + }, + }, + { event: 'completed' }, + ], + }); + }, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-json.js b/test/parallel/test-debugger-probe-json.js new file mode 100644 index 00000000000000..e95b7ffbd72587 --- /dev/null +++ b/test/parallel/test-debugger-probe-json.js @@ -0,0 +1,66 @@ +// This tests debugger probe JSON output for duplicate and multi-probe hits. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const { probeScript } = require('../common/debugger-probe'); + +spawnSyncAndAssert(process.execPath, [ + 'inspect', + '--json', + '--probe', `${probeScript}:8`, + '--expr', 'index', + '--probe', `${probeScript}:8`, + '--expr', 'total', + '--probe', `${probeScript}:12`, + '--expr', 'finalValue', + probeScript, +], { + stdout(output) { + assert.deepStrictEqual(JSON.parse(output), { + v: 1, + probes: [ + { expr: 'index', target: [probeScript, 8] }, + { expr: 'total', target: [probeScript, 8] }, + { expr: 'finalValue', target: [probeScript, 12] }, + ], + results: [ + { + probe: 0, + event: 'hit', + hit: 1, + result: { type: 'number', value: 0, description: '0' }, + }, + { + probe: 1, + event: 'hit', + hit: 1, + result: { type: 'number', value: 0, description: '0' }, + }, + { + probe: 0, + event: 'hit', + hit: 2, + result: { type: 'number', value: 1, description: '1' }, + }, + { + probe: 1, + event: 'hit', + hit: 2, + result: { type: 'number', value: 40, description: '40' }, + }, + { + probe: 2, + event: 'hit', + hit: 1, + result: { type: 'number', value: 81, description: '81' }, + }, + { event: 'completed' }, + ], + }); + }, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-launch.js b/test/parallel/test-debugger-probe-launch.js new file mode 100644 index 00000000000000..09511fabf2caaf --- /dev/null +++ b/test/parallel/test-debugger-probe-launch.js @@ -0,0 +1,26 @@ +// This tests that probe launch failures fail fast instead of timing out. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSyncAndExit } = require('../common/child_process'); +const { probeScript } = require('../common/debugger-probe'); + +spawnSyncAndExit(process.execPath, [ + 'inspect', + '--probe', `${probeScript}:12`, + '--expr', 'finalValue', + '--', + '--not-a-real-node-flag', + probeScript, +], { + signal: null, + status: 1, + stderr(output) { + assert.match(output, /bad option: --not-a-real-node-flag/); + assert.match(output, /Target exited before the inspector was ready \(code 9\)/); + }, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-miss.js b/test/parallel/test-debugger-probe-miss.js new file mode 100644 index 00000000000000..7dc52e9a07b0c0 --- /dev/null +++ b/test/parallel/test-debugger-probe-miss.js @@ -0,0 +1,29 @@ +// This tests that probe sessions report unresolved breakpoints as misses. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const { missScript } = require('../common/debugger-probe'); + +spawnSyncAndAssert(process.execPath, [ + 'inspect', + '--json', + '--probe', `${missScript}:99`, + '--expr', '42', + missScript, +], { + stdout(output) { + assert.deepStrictEqual(JSON.parse(output), { + v: 1, + probes: [{ expr: '42', target: [missScript, 99] }], + results: [{ + event: 'miss', + pending: [0], + }], + }); + }, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-missing-expr.js b/test/parallel/test-debugger-probe-missing-expr.js new file mode 100644 index 00000000000000..e6d4f13ac6f26d --- /dev/null +++ b/test/parallel/test-debugger-probe-missing-expr.js @@ -0,0 +1,19 @@ +// This tests that probe mode rejects a --probe without a matching --expr. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const { spawnSyncAndExit } = require('../common/child_process'); +const { probeScript } = require('../common/debugger-probe'); + +spawnSyncAndExit(process.execPath, [ + 'inspect', + '--probe', `${probeScript}:12`, + probeScript, +], { + signal: null, + status: 1, + stderr: /Each --probe must be followed immediately by --expr/, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-requires-separator.js b/test/parallel/test-debugger-probe-requires-separator.js new file mode 100644 index 00000000000000..8aae48052c1a29 --- /dev/null +++ b/test/parallel/test-debugger-probe-requires-separator.js @@ -0,0 +1,21 @@ +// This tests that child Node.js flags require -- in probe mode. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const { spawnSyncAndExit } = require('../common/child_process'); +const { probeScript } = require('../common/debugger-probe'); + +spawnSyncAndExit(process.execPath, [ + 'inspect', + '--probe', `${probeScript}:12`, + '--expr', 'finalValue', + '--inspect-port=0', + probeScript, +], { + signal: null, + status: 1, + stderr: /Use -- before child Node\.js flags in probe mode/, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-text-special-values.js b/test/parallel/test-debugger-probe-text-special-values.js new file mode 100644 index 00000000000000..17ff62ef4a086d --- /dev/null +++ b/test/parallel/test-debugger-probe-text-special-values.js @@ -0,0 +1,67 @@ +// This tests debugger probe text output for stable special-cased values. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const { probeTypesScript } = require('../common/debugger-probe'); + +const location = `${probeTypesScript}:17`; + +spawnSyncAndAssert(process.execPath, [ + 'inspect', + '--probe', location, + '--expr', 'stringValue', + '--probe', location, + '--expr', 'booleanValue', + '--probe', location, + '--expr', 'undefinedValue', + '--probe', location, + '--expr', 'nullValue', + '--probe', location, + '--expr', 'nanValue', + '--probe', location, + '--expr', 'bigintValue', + '--probe', location, + '--expr', 'symbolValue', + '--probe', location, + '--expr', 'functionValue', + '--probe', location, + '--expr', 'objectValue', + '--probe', location, + '--expr', 'arrayValue', + '--probe', location, + '--expr', 'errorValue', + probeTypesScript, +], { + stdout(output) { + assert.strictEqual(output, [ + `Hit 1 at ${location}`, + ' stringValue = "hello"', + `Hit 1 at ${location}`, + ' booleanValue = true', + `Hit 1 at ${location}`, + ' undefinedValue = undefined', + `Hit 1 at ${location}`, + ' nullValue = null', + `Hit 1 at ${location}`, + ' nanValue = NaN', + `Hit 1 at ${location}`, + ' bigintValue = 1n', + `Hit 1 at ${location}`, + ' symbolValue = Symbol(tag)', + `Hit 1 at ${location}`, + ' functionValue = () => 1', + `Hit 1 at ${location}`, + ' objectValue = {alpha: 1, beta: "two"}', + `Hit 1 at ${location}`, + ' arrayValue = [1, "two", 3]', + `Hit 1 at ${location}`, + ' errorValue = Error: boom', + 'Completed', + ].join('\n')); + }, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-text.js b/test/parallel/test-debugger-probe-text.js new file mode 100644 index 00000000000000..75be98611d378b --- /dev/null +++ b/test/parallel/test-debugger-probe-text.js @@ -0,0 +1,24 @@ +// This tests debugger probe text output for a single hit. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSyncAndAssert } = require('../common/child_process'); +const { probeScript } = require('../common/debugger-probe'); + +spawnSyncAndAssert(process.execPath, [ + 'inspect', + '--probe', `${probeScript}:12`, + '--expr', 'finalValue', + probeScript, +], { + stdout(output) { + assert.strictEqual(output, + `Hit 1 at ${probeScript}:12\n` + + ' finalValue = 81\n' + + 'Completed'); + }, + trim: true, +}); diff --git a/test/parallel/test-debugger-probe-timeout.js b/test/parallel/test-debugger-probe-timeout.js new file mode 100644 index 00000000000000..d4728407bc5924 --- /dev/null +++ b/test/parallel/test-debugger-probe-timeout.js @@ -0,0 +1,36 @@ +// This tests probe session timeout behavior and teardown. +'use strict'; + +const common = require('../common'); +common.skipIfInspectorDisabled(); + +const assert = require('assert'); +const { spawnSyncAndExit } = require('../common/child_process'); +const { timeoutScript } = require('../common/debugger-probe'); + +spawnSyncAndExit(process.execPath, [ + 'inspect', + '--json', + '--timeout=200', + '--probe', `${timeoutScript}:99`, + '--expr', '1', + timeoutScript, +], { + signal: null, + status: 1, + stdout(output) { + assert.deepStrictEqual(JSON.parse(output), { + v: 1, + probes: [{ expr: '1', target: [timeoutScript, 99] }], + results: [{ + event: 'timeout', + pending: [0], + error: { + code: 'probe_timeout', + message: `Timed out after 200ms waiting for probes: ${timeoutScript}:99`, + }, + }], + }); + }, + trim: true, +});