diff options
Diffstat (limited to 'devtools/shared/commands/script')
8 files changed, 830 insertions, 0 deletions
diff --git a/devtools/shared/commands/script/moz.build b/devtools/shared/commands/script/moz.build new file mode 100644 index 0000000000..2387c7e63b --- /dev/null +++ b/devtools/shared/commands/script/moz.build @@ -0,0 +1,10 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +DevToolsModules( + "script-command.js", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.ini"] diff --git a/devtools/shared/commands/script/script-command.js b/devtools/shared/commands/script/script-command.js new file mode 100644 index 0000000000..93917944d5 --- /dev/null +++ b/devtools/shared/commands/script/script-command.js @@ -0,0 +1,149 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + getAdHocFrontOrPrimitiveGrip, + // eslint-disable-next-line mozilla/reject-some-requires +} = require("resource://devtools/client/fronts/object.js"); + +class ScriptCommand { + constructor({ commands }) { + this._commands = commands; + } + + /** + * Execute a JavaScript expression. + * + * @param {String} expression: The code you want to evaluate. + * @param {Object} options: Options for evaluation: + * @param {Object} options.frameActor: a FrameActor ID. The actor holds a reference to + * a Debugger.Frame. This option allows you to evaluate the string in the frame + * of the given FrameActor. + * @param {String} options.url: the url to evaluate the script as. Defaults to "debugger eval code". + * @param {TargetFront} options.selectedTargetFront: When passed, the expression will be + * evaluated in the context of the target (as opposed to the default, top-level one). + * @param {String} options.selectedNodeActor: A NodeActor ID that may be used by helper + * functions that can reference the currently selected node in the Inspector, like $0. + * @param {String} options.selectedObjectActor: the actorID of a given objectActor. + * This is used by context menu entries to get a reference to an object, in order + * to perform some operation on it (copy it, store it as a global variable, …). + * @param {Number} options.innerWindowID: An optional window id to be used for the evaluation, + * instead of the regular webConsoleActor.evalWindow. + * This is used by functions that may want to evaluate in a different window (for + * example a non-remote iframe), like getting the elements of a given document. + * @param {object} options.mapped: An optional object indicating if the original expression + * entered by the users have been modified + * @param {boolean} options.mapped.await: true if the expression was a top-level await + * expression that was wrapped in an async-iife + * + * @return {Promise}: A promise that resolves with the response. + */ + async execute(expression, options = {}) { + const { + selectedObjectActor, + selectedNodeActor, + frameActor, + selectedTargetFront, + } = options; + + // Retrieve the right WebConsole front that relates either to (by order of priority): + // - the currently selected target in the context selector + // (selectedTargetFront argument), + // - the object picked in the console (when using store as global) (selectedObjectActor), + // - the currently selected Node in the inspector (selectedNodeActor), + // - the currently selected frame in the debugger (when paused) (frameActor), + // - the currently selected target in the iframe dropdown + // (selectedTargetFront from the TargetCommand) + let targetFront = this._commands.targetCommand.selectedTargetFront; + + const selectedActor = + selectedObjectActor || selectedNodeActor || frameActor; + + if (selectedTargetFront) { + targetFront = selectedTargetFront; + } else if (selectedActor) { + const selectedFront = this._commands.client.getFrontByID(selectedActor); + if (selectedFront) { + targetFront = selectedFront.targetFront; + } + } + + const consoleFront = await targetFront.getFront("console"); + + // We call `evaluateJSAsync` RDP request, which immediately returns a simple `resultID`, + // for which we later receive a related `evaluationResult` RDP event, with the same resultID. + // The evaluation result will be contained in this RDP event. + let resultID; + const response = await new Promise(resolve => { + const offEvaluationResult = consoleFront.on( + "evaluationResult", + async packet => { + // In some cases, the evaluationResult event can be received before the call to + // evaluationJSAsync completes. So make sure to wait for the corresponding promise + // before handling the evaluationResult event. + await onEvaluateJSAsync; + + if (packet.resultID === resultID) { + resolve(packet); + offEvaluationResult(); + } + } + ); + + const onEvaluateJSAsync = consoleFront + .evaluateJSAsync({ + text: expression, + eager: options.eager, + frameActor, + innerWindowID: options.innerWindowID, + mapped: options.mapped, + selectedNodeActor, + selectedObjectActor, + url: options.url, + }) + .then(packet => { + resultID = packet.resultID; + }); + }); + + // `response` is the packet sent via `evaluationResult` RDP event. + if (response.error) { + throw response; + } + + if (response.result) { + response.result = getAdHocFrontOrPrimitiveGrip( + response.result, + consoleFront + ); + } + + if (response.helperResult?.object) { + response.helperResult.object = getAdHocFrontOrPrimitiveGrip( + response.helperResult.object, + consoleFront + ); + } + + if (response.exception) { + response.exception = getAdHocFrontOrPrimitiveGrip( + response.exception, + consoleFront + ); + } + + if (response.exceptionMessage) { + response.exceptionMessage = getAdHocFrontOrPrimitiveGrip( + response.exceptionMessage, + consoleFront + ); + } + + return response; + } +} + +module.exports = ScriptCommand; diff --git a/devtools/shared/commands/script/tests/browser.ini b/devtools/shared/commands/script/tests/browser.ini new file mode 100644 index 0000000000..949c54f760 --- /dev/null +++ b/devtools/shared/commands/script/tests/browser.ini @@ -0,0 +1,11 @@ +[DEFAULT] +tags = devtools +subsuite = devtools +support-files = + !/devtools/client/shared/test/shared-head.js + head.js + +[browser_script_command_execute_basic.js] +[browser_script_command_execute_document__proto__.js] +[browser_script_command_execute_last_result.js] +[browser_script_command_execute_throw.js] diff --git a/devtools/shared/commands/script/tests/browser_script_command_execute_basic.js b/devtools/shared/commands/script/tests/browser_script_command_execute_basic.js new file mode 100644 index 0000000000..5eff8c4520 --- /dev/null +++ b/devtools/shared/commands/script/tests/browser_script_command_execute_basic.js @@ -0,0 +1,413 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing basic expression evaluation +const { + MAX_AUTOCOMPLETE_ATTEMPTS, + MAX_AUTOCOMPLETIONS, +} = require("resource://devtools/shared/webconsole/js-property-provider.js"); +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); + +add_task(async () => { + const tab = await addTab(`data:text/html;charset=utf-8, + <script> + window.foobarObject = Object.create( + null, + Object.getOwnPropertyDescriptors({ + foo: 1, + foobar: 2, + foobaz: 3, + omg: 4, + omgfoo: 5, + strfoo: "foobarz", + omgstr: "foobarz" + "abb".repeat(${DevToolsServer.LONG_STRING_LENGTH} * 2), + }) + ); + + window.largeObject1 = Object.create(null); + for (let i = 0; i < ${MAX_AUTOCOMPLETE_ATTEMPTS} + 1; i++) { + window.largeObject1["a" + i] = i; + } + + window.largeObject2 = Object.create(null); + for (let i = 0; i < ${MAX_AUTOCOMPLETIONS} * 2; i++) { + window.largeObject2["a" + i] = i; + } + + var originalExec = RegExp.prototype.exec; + + var promptIterable = { [Symbol.iterator]() { return { next: prompt } } }; + + function aliasedTest() { + const aliased = "ALIASED"; + return [0].map(() => aliased)[0]; + } + </script>`); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + await doSimpleEval(commands); + await doWindowEval(commands); + await doEvalWithException(commands); + await doEvalWithHelper(commands); + await doEvalString(commands); + await doEvalLongString(commands); + await doEvalWithBinding(commands); + await forceLexicalInit(commands); + await doSimpleEagerEval(commands); + await doEagerEvalWithSideEffect(commands); + await doEagerEvalWithSideEffectIterator(commands); + await doEagerEvalWithSideEffectMonkeyPatched(commands); + + await commands.destroy(); +}); + +async function doSimpleEval(commands) { + info("test eval '2+2'"); + const response = await commands.scriptCommand.execute("2+2"); + checkObject(response, { + input: "2+2", + result: 4, + }); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); +} + +async function doWindowEval(commands) { + info("test eval 'document'"); + const response = await commands.scriptCommand.execute("document"); + checkObject(response, { + input: "document", + result: { + type: "object", + class: "HTMLDocument", + actor: /[a-z]/, + }, + }); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); +} + +async function doEvalWithException(commands) { + info("test eval with exception"); + const response = await commands.scriptCommand.execute( + "window.doTheImpossible()" + ); + checkObject(response, { + input: "window.doTheImpossible()", + result: { + type: "undefined", + }, + exceptionMessage: /doTheImpossible/, + }); + + ok(response.exception, "js eval exception"); + ok(!response.helperResult, "no helper result"); +} + +async function doEvalWithHelper(commands) { + info("test eval with helper"); + const response = await commands.scriptCommand.execute("clear()"); + checkObject(response, { + input: "clear()", + result: { + type: "undefined", + }, + helperResult: { type: "clearOutput" }, + }); + + ok(!response.exception, "no eval exception"); +} + +async function doEvalString(commands) { + const response = await commands.scriptCommand.execute( + "window.foobarObject.strfoo" + ); + + checkObject(response, { + input: "window.foobarObject.strfoo", + result: "foobarz", + }); +} + +async function doEvalLongString(commands) { + const response = await commands.scriptCommand.execute( + "window.foobarObject.omgstr" + ); + + const str = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + function() { + return content.wrappedJSObject.foobarObject.omgstr; + } + ); + + const initial = str.substring(0, DevToolsServer.LONG_STRING_INITIAL_LENGTH); + + checkObject(response, { + input: "window.foobarObject.omgstr", + result: { + type: "longString", + initial, + length: str.length, + }, + }); +} + +async function doEvalWithBinding(commands) { + const response = await commands.scriptCommand.execute("document;"); + const documentActor = response.result.actorID; + + info("running a command with _self as document using selectedObjectActor"); + const selectedObjectSame = await commands.scriptCommand.execute( + "_self === document", + { + selectedObjectActor: documentActor, + } + ); + checkObject(selectedObjectSame, { + result: true, + }); +} + +async function forceLexicalInit(commands) { + info("test that failed let/const bindings are initialized to undefined"); + + const testData = [ + { + stmt: "let foopie = wubbalubadubdub", + vars: ["foopie"], + }, + { + stmt: "let {z, w={n}=null} = {}", + vars: ["z", "w"], + }, + { + stmt: "let [a, b, c] = null", + vars: ["a", "b", "c"], + }, + { + stmt: "const nein1 = rofl, nein2 = copter", + vars: ["nein1", "nein2"], + }, + { + stmt: "const {ha} = null", + vars: ["ha"], + }, + { + stmt: "const [haw=[lame]=null] = []", + vars: ["haw"], + }, + { + stmt: "const [rawr, wat=[lame]=null] = []", + vars: ["rawr", "haw"], + }, + { + stmt: "let {zzz: xyz=99, zwz: wb} = nexistepas()", + vars: ["xyz", "wb"], + }, + { + stmt: "let {c3pdoh=101} = null", + vars: ["c3pdoh"], + }, + { + stmt: "const {...x} = x", + vars: ["x"], + }, + { + stmt: "const {xx,yy,...rest} = null", + vars: ["xx", "yy", "rest"], + }, + ]; + + for (const data of testData) { + const response = await commands.scriptCommand.execute(data.stmt); + checkObject(response, { + input: data.stmt, + result: { type: "undefined" }, + }); + ok(response.exception, "expected exception"); + for (const varName of data.vars) { + const response2 = await commands.scriptCommand.execute(varName); + checkObject(response2, { + input: varName, + result: { type: "undefined" }, + }); + ok(!response2.exception, "unexpected exception"); + } + } +} + +async function doSimpleEagerEval(commands) { + const testData = [ + { + code: "2+2", + result: 4, + }, + { + code: "(x => x * 2)(3)", + result: 6, + }, + { + code: "[1, 2, 3].map(x => x * 2).join()", + result: "2,4,6", + }, + { + code: `"abc".match(/a./)[0]`, + result: "ab", + }, + { + code: "aliasedTest()", + result: "ALIASED", + }, + ]; + + for (const { code, result } of testData) { + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject(response, { + input: code, + result, + }); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } +} + +async function doEagerEvalWithSideEffect(commands) { + const testData = [ + // Modify environment. + "var a = 10; a;", + + // Directly call a funtion with side effect. + "prompt();", + + // Call a funtion with side effect inside a scripted function. + "(() => { prompt(); })()", + + // Call a funtion with side effect from self-hosted JS function. + "[1, 2, 3].map(prompt)", + + // Call a function with Function.prototype.call. + "Function.prototype.call.bind(Function.prototype.call)(prompt);", + + // Call a function with Function.prototype.apply. + "Function.prototype.apply.bind(Function.prototype.apply)(prompt);", + + // Indirectly call a function with Function.prototype.apply. + "Reflect.apply(prompt, null, []);", + "'aaaaaaaa'.replace(/(a)(a)(a)(a)(a)(a)(a)(a)/, prompt)", + + // Indirect call on obj[Symbol.iterator]().next. + "Array.from(promptIterable)", + ]; + + for (const code of testData) { + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject(response, { + input: code, + result: { type: "undefined" }, + }); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } +} + +async function doEagerEvalWithSideEffectIterator(commands) { + // Indirect call on %ArrayIterator%.prototype.next, + + // Create an iterable object that reuses iterator across multiple call. + let response = await commands.scriptCommand.execute(` +var arr = [1, 2, 3]; +var iterator = arr[Symbol.iterator](); +var iterable = { [Symbol.iterator]() { return iterator; } }; +"ok"; +`); + checkObject(response, { + result: "ok", + }); + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + + const testData = [ + "Array.from(iterable)", + "new Map(iterable)", + "new Set(iterable)", + ]; + + for (const code of testData) { + response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject(response, { + input: code, + result: { type: "undefined" }, + }); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } + + // Verify the iterator's internal state isn't modified. + response = await commands.scriptCommand.execute(`[...iterator].join(",")`); + checkObject(response, { + result: "1,2,3", + }); + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); +} + +async function doEagerEvalWithSideEffectMonkeyPatched(commands) { + // Patch the built-in function without eager evaluation. + let response = await commands.scriptCommand.execute( + `RegExp.prototype.exec = prompt; "patched"` + ); + checkObject(response, { + result: "patched", + }); + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + + // Test eager evaluation, where the patched built-in is called internally. + // This should be aborted. + const code = `"abc".match(/a./)[0]`; + response = await commands.scriptCommand.execute(code, { eager: true }); + checkObject(response, { + input: code, + result: { type: "undefined" }, + }); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + + // Undo the patch without eager evaluation. + response = await commands.scriptCommand.execute( + `RegExp.prototype.exec = originalExec; "unpatched"` + ); + checkObject(response, { + result: "unpatched", + }); + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + + // Test eager evaluation again, without the patch. + // This should be evaluated. + response = await commands.scriptCommand.execute(code, { eager: true }); + checkObject(response, { + input: code, + result: "ab", + }); +} diff --git a/devtools/shared/commands/script/tests/browser_script_command_execute_document__proto__.js b/devtools/shared/commands/script/tests/browser_script_command_execute_document__proto__.js new file mode 100644 index 0000000000..28f56ebac3 --- /dev/null +++ b/devtools/shared/commands/script/tests/browser_script_command_execute_document__proto__.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing evaluating document.__proto__ + +add_task(async () => { + const tab = await addTab( + `data:text/html;charset=utf-8,Test evaluating document.__proto__` + ); + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + const evaluationResponse = await commands.scriptCommand.execute( + "document.__proto__" + ); + checkObject(evaluationResponse, { + input: "document.__proto__", + result: { + type: "object", + actor: /[a-z]/, + }, + }); + + ok(!evaluationResponse.exception, "no eval exception"); + ok(!evaluationResponse.helperResult, "no helper result"); + + const response = await evaluationResponse.result.getPrototypeAndProperties(); + ok(!response.error, "no response error"); + + const props = response.ownProperties; + ok(props, "response properties available"); + + const expectedProps = Object.getOwnPropertyNames( + Object.getPrototypeOf(document) + ); + checkObject(Object.keys(props), expectedProps, "Same own properties."); + + await commands.destroy(); +}); diff --git a/devtools/shared/commands/script/tests/browser_script_command_execute_last_result.js b/devtools/shared/commands/script/tests/browser_script_command_execute_last_result.js new file mode 100644 index 0000000000..aebdaeb168 --- /dev/null +++ b/devtools/shared/commands/script/tests/browser_script_command_execute_last_result.js @@ -0,0 +1,85 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing that last evaluation result can be accessed with `$_` + +add_task(async () => { + const tab = await addTab(`data:text/html;charset=utf-8,`); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + info("$_ returns undefined if nothing has evaluated yet"); + let response = await commands.scriptCommand.execute("$_"); + basicResultCheck(response, "$_", { type: "undefined" }); + + info("$_ returns last value and performs basic arithmetic"); + response = await commands.scriptCommand.execute("2+2"); + basicResultCheck(response, "2+2", 4); + + response = await commands.scriptCommand.execute("$_"); + basicResultCheck(response, "$_", 4); + + response = await commands.scriptCommand.execute("$_ + 2"); + basicResultCheck(response, "$_ + 2", 6); + + response = await commands.scriptCommand.execute("$_ + 4"); + basicResultCheck(response, "$_ + 4", 10); + + info("$_ has correct references to objects"); + response = await commands.scriptCommand.execute("var foo = {bar:1}; foo;"); + basicResultCheck(response, "var foo = {bar:1}; foo;", { + type: "object", + class: "Object", + actor: /[a-z]/, + }); + checkObject(response.result.getGrip().preview.ownProperties, { + bar: { + value: 1, + }, + }); + + response = await commands.scriptCommand.execute("$_"); + basicResultCheck(response, "$_", { + type: "object", + class: "Object", + actor: /[a-z]/, + }); + checkObject(response.result.getGrip().preview.ownProperties, { + bar: { + value: 1, + }, + }); + + info( + "Update a property value and check that evaluating $_ returns the expected object instance" + ); + await ContentTask.spawn(gBrowser.selectedBrowser, null, () => { + content.wrappedJSObject.foo.bar = "updated_value"; + }); + + response = await commands.scriptCommand.execute("$_"); + basicResultCheck(response, "$_", { + type: "object", + class: "Object", + actor: /[a-z]/, + }); + checkObject(response.result.getGrip().preview.ownProperties, { + bar: { + value: "updated_value", + }, + }); + + await commands.destroy(); +}); + +function basicResultCheck(response, input, output) { + checkObject(response, { + input, + result: output, + }); + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); +} diff --git a/devtools/shared/commands/script/tests/browser_script_command_execute_throw.js b/devtools/shared/commands/script/tests/browser_script_command_execute_throw.js new file mode 100644 index 0000000000..8680193ecb --- /dev/null +++ b/devtools/shared/commands/script/tests/browser_script_command_execute_throw.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing evaluating thowing expressions +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); + +add_task(async () => { + const tab = await addTab(`data:text/html;charset=utf-8,Test throw`); + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + const falsyValues = [ + "-0", + "null", + "undefined", + "Infinity", + "-Infinity", + "NaN", + ]; + for (const value of falsyValues) { + const response = await commands.scriptCommand.execute(`throw ${value};`); + is( + response.exception.type, + value, + `Got the expected value for response.exception.type when throwing "${value}"` + ); + } + + const identityTestValues = [false, 0]; + for (const value of identityTestValues) { + const response = await commands.scriptCommand.execute(`throw ${value};`); + is( + response.exception, + value, + `Got the expected value for response.exception when throwing "${value}"` + ); + } + + const symbolTestValues = [ + ["Symbol.iterator", "Symbol(Symbol.iterator)"], + ["Symbol('foo')", "Symbol(foo)"], + ["Symbol()", "Symbol()"], + ]; + for (const [expr, message] of symbolTestValues) { + const response = await commands.scriptCommand.execute(`throw ${expr};`); + is( + response.exceptionMessage, + message, + `Got the expected value for response.exceptionMessage when throwing "${expr}"` + ); + } + + const longString = Array(DevToolsServer.LONG_STRING_LENGTH + 1).join("a"), + shortedString = longString.substring( + 0, + DevToolsServer.LONG_STRING_INITIAL_LENGTH + ); + const response = await commands.scriptCommand.execute( + "throw '" + longString + "';" + ); + is( + response.exception.initial, + shortedString, + "Got the expected value for exception.initial when throwing a longString" + ); + is( + response.exceptionMessage.initial, + shortedString, + "Got the expected value for exceptionMessage.initial when throwing a longString" + ); +}); diff --git a/devtools/shared/commands/script/tests/head.js b/devtools/shared/commands/script/tests/head.js new file mode 100644 index 0000000000..9b7591a9cf --- /dev/null +++ b/devtools/shared/commands/script/tests/head.js @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* import-globals-from ../../../../client/shared/test/shared-head.js */ + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +function checkObject(object, expected) { + if (object && object.getGrip) { + object = object.getGrip(); + } + + for (const name of Object.keys(expected)) { + const expectedValue = expected[name]; + const value = object[name]; + checkValue(name, value, expectedValue); + } +} + +function checkValue(name, value, expected) { + if (expected === null) { + is(value, null, `'${name}' is null`); + } else if (expected === undefined) { + is(value, expected, `'${name}' is undefined`); + } else if ( + typeof expected == "string" || + typeof expected == "number" || + typeof expected == "boolean" + ) { + is(value, expected, "property '" + name + "'"); + } else if (expected instanceof RegExp) { + ok(expected.test(value), name + ": " + expected + " matched " + value); + } else if (Array.isArray(expected)) { + info("checking array for property '" + name + "'"); + checkObject(value, expected); + } else if (typeof expected == "object") { + info("checking object for property '" + name + "'"); + checkObject(value, expected); + } +} |