From a136ba50faf501594a558ba78bb0ed0d13f84177 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 8 Apr 2020 10:55:04 -0700 Subject: [PATCH] feat: support clipboard context for value copies Fixes #423 --- demos/node/main.js | 39 +------- src/adapter/debugAdapter.ts | 1 + src/adapter/objectPreview/contexts.ts | 2 + src/adapter/templates/index.ts | 11 ++- .../templates/serializeForClipboard.ts | 98 +++++++++++++++++++ src/adapter/templates/toStringForClipboard.ts | 23 ----- src/adapter/threads.ts | 35 ++++--- .../evaluate-copy-via-evaluate-context.txt | 35 +++++++ .../evaluate/evaluate-copy-via-function.txt | 24 +++++ src/test/evaluate/evaluate-copy.txt | 12 --- src/test/evaluate/evaluate.ts | 32 ++++-- 11 files changed, 219 insertions(+), 93 deletions(-) create mode 100644 src/adapter/templates/serializeForClipboard.ts delete mode 100644 src/adapter/templates/toStringForClipboard.ts create mode 100644 src/test/evaluate/evaluate-copy-via-evaluate-context.txt create mode 100644 src/test/evaluate/evaluate-copy-via-function.txt delete mode 100644 src/test/evaluate/evaluate-copy.txt diff --git a/demos/node/main.js b/demos/node/main.js index e52875da0..6da6e1921 100644 --- a/demos/node/main.js +++ b/demos/node/main.js @@ -1,38 +1,3 @@ -console.log('hi');console.log('hi2'); +const env = process.env; -const mm = require('./micromatch'); - -mm(['foo', 'bar', 'baz', 'qux'], ['f*', 'b*']); - -async function bar() { - return 42; -} - -async function foo() { - const result1 = await bar(); - const result2 = await bar(); - console.log(result1 + result2); -} - -function throwIt() { - setTimeout(() => { - throw new Error('Oh my!'); - }, 0); -} - -let counter = 0; -setInterval(() => { - setTimeout(() => { - //console.log("a\nb\nc\nd" + (++counter)); - }, 0); -}, 2000); - -console.log('Hello world!'); - -var path = './.vscode/launch.json:4:2'; -console.log(path); -var obj = {foo: path}; -console.log(obj); -var arr = [path, obj]; -console.log(arr); -foo(); +console.log(env); diff --git a/src/adapter/debugAdapter.ts b/src/adapter/debugAdapter.ts index 356a8c07e..016e9ac3e 100644 --- a/src/adapter/debugAdapter.ts +++ b/src/adapter/debugAdapter.ts @@ -152,6 +152,7 @@ export class DebugAdapter implements IDisposable { supportsTerminateRequest: false, completionTriggerCharacters: ['.', '[', '"', "'"], supportsBreakpointLocationsRequest: true, + supportsClipboardContext: true, //supportsDataBreakpoints: false, //supportsReadMemoryRequest: false, //supportsDisassembleRequest: false, diff --git a/src/adapter/objectPreview/contexts.ts b/src/adapter/objectPreview/contexts.ts index 6b71dbb76..605845c68 100644 --- a/src/adapter/objectPreview/contexts.ts +++ b/src/adapter/objectPreview/contexts.ts @@ -27,6 +27,7 @@ export const enum PreviewContextType { Hover = 'hover', PropertyValue = 'propertyValue', Copy = 'copy', + Clipboard = 'clipboard', } const repl: IPreviewContext = { budget: 1000, quoted: true }; @@ -47,6 +48,7 @@ export const getContextForType = (type: PreviewContextType | string | undefined) case PreviewContextType.PropertyValue: return hover; case PreviewContextType.Copy: + case PreviewContextType.Clipboard: return copy; default: // the type is received straight from the DAP, so it's possible we might diff --git a/src/adapter/templates/index.ts b/src/adapter/templates/index.ts index 4112dc89f..40299a226 100644 --- a/src/adapter/templates/index.ts +++ b/src/adapter/templates/index.ts @@ -43,7 +43,12 @@ export function templateFunction(fn: string): (...args: export function templateFunction( fn: string | ((...args: Args) => void), ): (...args: string[]) => string { - const stringified = '' + fn; + return templateFunctionStr('' + fn); +} + +export function templateFunctionStr( + stringified: string, +): (...args: Args) => string { const sourceFile = ts.createSourceFile('test.js', stringified, ts.ScriptTarget.ESNext, true); // 1. Find the function. @@ -57,7 +62,7 @@ export function templateFunction( }); if (!decl || !('body' in decl) || !decl.body) { - throw new Error(`Could not find function declaration for ${fn}`); + throw new Error(`Could not find function declaration for:\n\n${stringified}`); } // 2. Get parameter names. @@ -123,7 +128,7 @@ type RemoteObjectWithType = ByValue extends true * that takes the CDP and arguments with which to invoke the function. The * arguments should be simple objects. */ -export function remoteFunction(fn: (...args: Args) => R) { +export function remoteFunction(fn: string | ((...args: Args) => R)) { const stringified = '' + fn; // Some ugly typing here, but it gets us type safety. Mainly we want to: diff --git a/src/adapter/templates/serializeForClipboard.ts b/src/adapter/templates/serializeForClipboard.ts new file mode 100644 index 000000000..fd0b8905b --- /dev/null +++ b/src/adapter/templates/serializeForClipboard.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { templateFunctionStr, remoteFunction } from '.'; + +export type CycleReplacer = ( + key: string, + value: unknown, + stack: unknown[], + keys: string[], +) => string; + +/** + * Safe-stringifier. Modified from json-stringify-safe. + * + * @license + * + * The ISC License + * + * Copyright (c) Isaac Z. Schlueter and Contributors + * + * Permission to use, copy, modify, and/or distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR + * IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + */ +export function safeSerializer( + replacer?: (key: string, value: unknown) => unknown, + cycleReplacer: CycleReplacer = (_key, value, stack, keys) => + stack[0] === value + ? '[Circular ~]' + : '[Circular ~.' + keys.slice(0, stack.indexOf(value)).join('.') + ']', +) { + const stack: unknown[] = []; + const keys: string[] = []; + + return function (this: unknown, key: string, value: unknown) { + if (stack.length > 0) { + const thisPos = stack.indexOf(this); + if (thisPos === -1) { + stack.push(this); + keys.push(key); + } else { + stack.splice(thisPos + 1); + keys.splice(thisPos, Infinity, key); + } + + if (stack.indexOf(value) !== -1) { + value = cycleReplacer.call(this, key, value, stack, keys); + } + } else { + stack.push(value); + } + + // Modification: avoid JSON.stringify throwing on bigints. + if (typeof value === 'bigint') { + value = value <= Number.MAX_SAFE_INTEGER ? Number(value) : String(value); + } + + return replacer == null ? value : replacer.call(this, key, value); + }; +} + +/** + * Safe-stringifies the value as JSON, replacing + */ +export const serializeForClipboardTmpl = templateFunctionStr<[string, string]>(` + function (valueToStringify, spaces) { + try { + if (typeof valueToStringify === 'function') { + return '' + valueToStringify; + } + + if (typeof Node !== 'undefined' && valueToStringify instanceof Node) { + return valueToStringify.outerHTML; + } + + return JSON.stringify(valueToStringify, (${safeSerializer})(), spaces); + } catch (e) { + return '' + valueToStringify; + } + } +`); + +export const serializeForClipboard = remoteFunction<[number], string>(` + function(spaces) { + const result = ${serializeForClipboardTmpl('this', 'spaces')}; + return result; + } +`); diff --git a/src/adapter/templates/toStringForClipboard.ts b/src/adapter/templates/toStringForClipboard.ts deleted file mode 100644 index 0972a621d..000000000 --- a/src/adapter/templates/toStringForClipboard.ts +++ /dev/null @@ -1,23 +0,0 @@ -/*--------------------------------------------------------- - * Copyright (C) Microsoft Corporation. All rights reserved. - *--------------------------------------------------------*/ - -import { remoteFunction } from '.'; - -/** - * Stringifies the current object for the clipboard. - */ -export const toStringForClipboard = remoteFunction(function ( - this: unknown, - subtype: string | undefined, -) { - if (subtype === 'node') - // a DOM node, but we don't have those typings here. - return (this as { outerHTML: string }).outerHTML; - if (subtype && typeof this === 'undefined') return subtype + ''; - try { - return JSON.stringify(this, null, ' '); - } catch (e) { - return '' + this; - } -}); diff --git a/src/adapter/threads.ts b/src/adapter/threads.ts index 2d123d69c..69732e4b3 100644 --- a/src/adapter/threads.ts +++ b/src/adapter/threads.ts @@ -19,12 +19,15 @@ import { SmartStepper } from './smartStepping'; import { IPreferredUiLocation, Source, SourceContainer, IUiLocation, base1To0 } from './sources'; import { StackFrame, StackTrace } from './stackTrace'; import { VariableStore, IVariableStoreDelegate } from './variables'; -import { toStringForClipboard } from './templates/toStringForClipboard'; import { previewThis } from './templates/previewThis'; import { UserDefinedBreakpoint } from './breakpoints/userDefinedBreakpoint'; import { ILogger } from '../common/logging'; import { AnyObject } from './objectPreview/betterTypes'; import { IEvaluator } from './evaluator'; +import { + serializeForClipboardTmpl, + serializeForClipboard, +} from './templates/serializeForClipboard'; const localize = nls.loadMessageBundle(); @@ -345,14 +348,24 @@ export class Thread implements IVariableStoreDelegate { } } } - // TODO: consider checking expression for side effects on hover. - const params: Cdp.Runtime.EvaluateParams = { - expression: args.expression, - includeCommandLineAPI: true, - objectGroup: 'console', - generatePreview: true, - timeout: args.context === 'hover' ? 500 : undefined, - }; + + // For clipboard evaluations, return a safe JSON-stringified string. + const params: Cdp.Runtime.EvaluateParams = + args.context === 'clipboard' + ? { + expression: serializeForClipboardTmpl(args.expression, '2'), + includeCommandLineAPI: true, + returnByValue: true, + objectGroup: 'console', + } + : { + expression: args.expression, + includeCommandLineAPI: true, + objectGroup: 'console', + generatePreview: true, + timeout: args.context === 'hover' ? 500 : undefined, + }; + if (args.context === 'repl') { params.expression = sourceUtils.wrapObjectLiteral(params.expression); if (params.expression.indexOf('await') !== -1) { @@ -1233,10 +1246,10 @@ export class Thread implements IVariableStoreDelegate { } try { - const result = await toStringForClipboard({ + const result = await serializeForClipboard({ cdp: this.cdp(), objectId: object.objectId, - args: [object.subtype], + args: [2], silent: true, returnByValue: true, }); diff --git a/src/test/evaluate/evaluate-copy-via-evaluate-context.txt b/src/test/evaluate/evaluate-copy-via-evaluate-context.txt new file mode 100644 index 000000000..39e88576d --- /dev/null +++ b/src/test/evaluate/evaluate-copy-via-evaluate-context.txt @@ -0,0 +1,35 @@ +{ + result : 123 + type : string + variablesReference : +} +{ + result : null + type : string + variablesReference : +} +{ + result : { "foo": "bar", "baz": { "a": [ 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "b": 123 } } + type : string + variablesReference : +} +{ + result : function hello() { return "world" } + type : string + variablesReference : +} +{ + result : { "foo": true, "recurse": "[Circular ~]" } + type : string + variablesReference : +} +{ + result : "1267650600228229401496703205376" + type : string + variablesReference : +} +{ + result :
hi
+ type : string + variablesReference : +} diff --git a/src/test/evaluate/evaluate-copy-via-function.txt b/src/test/evaluate/evaluate-copy-via-function.txt new file mode 100644 index 000000000..a47daf17a --- /dev/null +++ b/src/test/evaluate/evaluate-copy-via-function.txt @@ -0,0 +1,24 @@ +{ + text : hello +} +{ + text : 123n +} +{ + text : NaN +} +{ + text : { "foo": "bar", "baz": { "a": [ 1, 2, 3, 4, 5, 6, 7, 8, 9 ], "b": 123 } } +} +{ + text : function hello() { return "world" } +} +{ + text : { "foo": true, "recurse": "[Circular ~]" } +} +{ + text : 1267650600228229401496703205376n +} +{ + text :
hi
+} diff --git a/src/test/evaluate/evaluate-copy.txt b/src/test/evaluate/evaluate-copy.txt deleted file mode 100644 index 049cd36ef..000000000 --- a/src/test/evaluate/evaluate-copy.txt +++ /dev/null @@ -1,12 +0,0 @@ -{ - text : hello -} -{ - text : 123n -} -{ - text : NaN -} -{ - text : { "foo": "bar" } -} diff --git a/src/test/evaluate/evaluate.ts b/src/test/evaluate/evaluate.ts index 4d2d24353..d1c168fc7 100644 --- a/src/test/evaluate/evaluate.ts +++ b/src/test/evaluate/evaluate.ts @@ -148,16 +148,34 @@ describe('evaluate', () => { p.assertLog(); }); - itIntegrates('copy', async ({ r }) => { + const copyExpressions = [ + '123n', + 'NaN', + '{foo: "bar", baz: { a: [1, 2, 3, 4, 5, 6, 7, 8, 9], b: 123n }}', + 'function hello() { return "world" }', + '(() => { const n = { foo: true }; n.recurse = n; return n })()', + '1n << 100n', + '(() => { const node = document.createElement("div"); node.innerText = "hi"; return node })()', + ]; + + itIntegrates('copy via function', async ({ r }) => { const p = await r.launchAndLoad('blank'); p.dap.evaluate({ expression: 'var x = "hello"; copy(x)' }); p.log(await p.dap.once('copyRequested')); - p.dap.evaluate({ expression: 'copy(123n)' }); - p.log(await p.dap.once('copyRequested')); - p.dap.evaluate({ expression: 'copy(NaN)' }); - p.log(await p.dap.once('copyRequested')); - p.dap.evaluate({ expression: 'copy({foo: "bar"})' }); - p.log(await p.dap.once('copyRequested')); + for (const expression of copyExpressions) { + p.dap.evaluate({ expression: `copy(${expression})` }); + p.log(await p.dap.once('copyRequested')); + } + + p.assertLog(); + }); + + itIntegrates('copy via evaluate context', async ({ r }) => { + const p = await r.launchAndLoad('blank'); + for (const expression of copyExpressions) { + p.log(await p.dap.evaluate({ expression, context: 'clipboard' })); + } + p.assertLog(); });