diff --git a/lib/internal/bootstrap_node.js b/lib/internal/bootstrap_node.js index aa428f785d6e8e..cf517cdcf2b5ef 100644 --- a/lib/internal/bootstrap_node.js +++ b/lib/internal/bootstrap_node.js @@ -282,6 +282,7 @@ return console; } }); + setupInspectorCommandLineAPI(); } function installInspectorConsole(globalConsole) { @@ -310,6 +311,22 @@ return wrappedConsole; } + function setupInspectorCommandLineAPI() { + const { addCommandLineAPI } = process.binding('inspector'); + if (!addCommandLineAPI) return; + + const Module = NativeModule.require('module'); + const { makeRequireFunction } = NativeModule.require('internal/module'); + const path = NativeModule.require('path'); + const cwd = tryGetCwd(path); + + const consoleAPIModule = new Module(''); + consoleAPIModule.paths = + Module._nodeModulePaths(cwd).concat(Module.globalPaths); + + addCommandLineAPI('require', makeRequireFunction(consoleAPIModule)); + } + function setupProcessFatal() { const async_wrap = process.binding('async_wrap'); // Arrays containing hook flags and ids for async_hook calls. diff --git a/src/env.h b/src/env.h index f466ecd2ef50fd..abc765c6dc3331 100644 --- a/src/env.h +++ b/src/env.h @@ -292,6 +292,7 @@ namespace node { V(context, v8::Context) \ V(domain_array, v8::Array) \ V(domains_stack_array, v8::Array) \ + V(inspector_console_api_object, v8::Object) \ V(jsstream_constructor_template, v8::FunctionTemplate) \ V(module_load_list_array, v8::Array) \ V(pbkdf2_constructor_template, v8::ObjectTemplate) \ diff --git a/src/inspector_agent.cc b/src/inspector_agent.cc index 4d51c3d140f941..bedf74f3b02f61 100644 --- a/src/inspector_agent.cc +++ b/src/inspector_agent.cc @@ -22,6 +22,7 @@ namespace node { namespace inspector { namespace { +using v8::Array; using v8::Context; using v8::External; using v8::Function; @@ -554,6 +555,20 @@ class NodeInspectorClient : public V8InspectorClient { return env_->context(); } + void installAdditionalCommandLineAPI(Local context, + Local target) override { + Local console_api = env_->inspector_console_api_object(); + + Local properties = + console_api->GetOwnPropertyNames(context).ToLocalChecked(); + for (uint32_t i = 0; i < properties->Length(); ++i) { + Local key = properties->Get(context, i).ToLocalChecked(); + target->Set(context, + key, + console_api->Get(context, key).ToLocalChecked()).FromJust(); + } + } + void FatalException(Local error, Local message) { Local context = env_->context(); @@ -682,6 +697,20 @@ bool Agent::StartIoThread(bool wait_for_connect) { return true; } +static void AddCommandLineAPI( + const FunctionCallbackInfo& info) { + auto env = Environment::GetCurrent(info); + Local context = env->context(); + + if (info.Length() != 2 || !info[0]->IsString()) { + return env->ThrowTypeError("inspector.addCommandLineAPI takes " + "exactly 2 arguments: a string and a value."); + } + + Local console_api = env->inspector_console_api_object(); + console_api->Set(context, info[0], info[1]).FromJust(); +} + void Agent::Stop() { if (io_ != nullptr) { io_->Stop(); @@ -784,8 +813,16 @@ void Url(const FunctionCallbackInfo& args) { void Agent::InitInspector(Local target, Local unused, Local context, void* priv) { Environment* env = Environment::GetCurrent(context); + { + auto obj = Object::New(env->isolate()); + auto null = Null(env->isolate()); + CHECK(obj->SetPrototype(context, null).FromJust()); + env->set_inspector_console_api_object(obj); + } + Agent* agent = env->inspector_agent(); env->SetMethod(target, "consoleCall", InspectorConsoleCall); + env->SetMethod(target, "addCommandLineAPI", AddCommandLineAPI); if (agent->debug_options_.wait_for_connect()) env->SetMethod(target, "callAndPauseOnStart", CallAndPauseOnStart); env->SetMethod(target, "connect", ConnectJSBindingsSession); diff --git a/test/inspector/test-inspector.js b/test/inspector/test-inspector.js index 4c8f8f242d7600..f933232553587e 100644 --- a/test/inspector/test-inspector.js +++ b/test/inspector/test-inspector.js @@ -34,6 +34,11 @@ function checkBadPath(err, response) { assert(/WebSockets request was expected/.test(err.response)); } +function checkException(message) { + assert.strictEqual(message['exceptionDetails'], undefined, + 'An exception occurred during execution'); +} + function expectMainScriptSource(result) { const expected = helper.mainScriptSource(); const source = result['scriptSource']; @@ -209,6 +214,142 @@ function testI18NCharacters(session) { ]); } +function testCommandLineAPI(session) { + const testModulePath = require.resolve('../fixtures/empty.js'); + const testModuleStr = JSON.stringify(testModulePath); + const printAModulePath = require.resolve('../fixtures/printA.js'); + const printAModuleStr = JSON.stringify(printAModulePath); + const printBModulePath = require.resolve('../fixtures/printB.js'); + const printBModuleStr = JSON.stringify(printBModulePath); + session.sendInspectorCommands([ + [ // we can use `require` outside of a callframe with require in scope + { + 'method': 'Runtime.evaluate', 'params': { + 'expression': 'typeof require("fs").readFile === "function"', + 'includeCommandLineAPI': true + } + }, (message) => { + checkException(message); + assert.strictEqual(message['result']['value'], true); + } + ], + [ // the global require has the same properties as a normal `require` + { + 'method': 'Runtime.evaluate', 'params': { + 'expression': [ + 'typeof require.resolve === "function"', + 'typeof require.extensions === "object"', + 'typeof require.cache === "object"' + ].join(' && '), + 'includeCommandLineAPI': true + } + }, (message) => { + checkException(message); + assert.strictEqual(message['result']['value'], true); + } + ], + [ // `require` twice returns the same value + { + 'method': 'Runtime.evaluate', 'params': { + // 1. We require the same module twice + // 2. We mutate the exports so we can compare it later on + 'expression': ` + Object.assign( + require(${testModuleStr}), + { old: 'yes' } + ) === require(${testModuleStr})`, + 'includeCommandLineAPI': true + } + }, (message) => { + checkException(message); + assert.strictEqual(message['result']['value'], true); + } + ], + [ // after require the module appears in require.cache + { + 'method': 'Runtime.evaluate', 'params': { + 'expression': `JSON.stringify( + require.cache[${testModuleStr}].exports + )`, + 'includeCommandLineAPI': true + } + }, (message) => { + checkException(message); + assert.deepStrictEqual(JSON.parse(message['result']['value']), + { old: 'yes' }); + } + ], + [ // remove module from require.cache + { + 'method': 'Runtime.evaluate', 'params': { + 'expression': `delete require.cache[${testModuleStr}]`, + 'includeCommandLineAPI': true + } + }, (message) => { + checkException(message); + assert.strictEqual(message['result']['value'], true); + } + ], + [ // require again, should get fresh (empty) exports + { + 'method': 'Runtime.evaluate', 'params': { + 'expression': `JSON.stringify(require(${testModuleStr}))`, + 'includeCommandLineAPI': true + } + }, (message) => { + checkException(message); + assert.deepStrictEqual(JSON.parse(message['result']['value']), {}); + } + ], + [ // require 2nd module, exports an empty object + { + 'method': 'Runtime.evaluate', 'params': { + 'expression': `JSON.stringify(require(${printAModuleStr}))`, + 'includeCommandLineAPI': true + } + }, (message) => { + checkException(message); + assert.deepStrictEqual(JSON.parse(message['result']['value']), {}); + } + ], + [ // both modules end up with the same module.parent + { + 'method': 'Runtime.evaluate', 'params': { + 'expression': `JSON.stringify({ + parentsEqual: + require.cache[${testModuleStr}].parent === + require.cache[${printAModuleStr}].parent, + parentId: require.cache[${testModuleStr}].parent.id, + })`, + 'includeCommandLineAPI': true + } + }, (message) => { + checkException(message); + assert.deepStrictEqual(JSON.parse(message['result']['value']), { + parentsEqual: true, + parentId: '' + }); + } + ], + [ // the `require` in the module shadows the command line API's `require` + { + 'method': 'Debugger.evaluateOnCallFrame', 'params': { + 'callFrameId': '{"ordinal":0,"injectedScriptId":1}', + 'expression': `( + require(${printBModuleStr}), + require.cache[${printBModuleStr}].parent.id + )`, + 'includeCommandLineAPI': true + } + }, (message) => { + checkException(message); + assert.notStrictEqual(message['result']['value'], + ''); + } + ], + ]); +} + function testWaitsForFrontendDisconnect(session, harness) { console.log('[test]', 'Verify node waits for the frontend to disconnect'); session.sendInspectorCommands({ 'method': 'Debugger.resume' }) @@ -231,6 +372,7 @@ function runTests(harness) { testSetBreakpointAndResume, testInspectScope, testI18NCharacters, + testCommandLineAPI, testWaitsForFrontendDisconnect ]).expectShutDown(55); }