diff --git a/build/azure-pipelines/linux/product-build-linux-test.yml b/build/azure-pipelines/linux/product-build-linux-test.yml index 43673619b16..91cc411dd44 100644 --- a/build/azure-pipelines/linux/product-build-linux-test.yml +++ b/build/azure-pipelines/linux/product-build-linux-test.yml @@ -150,7 +150,7 @@ steps: - script: yarn --cwd test/smoke compile displayName: Compile smoke tests - - script: yarn gulp compile-extension:markdown-language-features compile-extension-media compile-extension:vscode-test-resolver + - script: yarn gulp compile-extension:markdown-language-features compile-extension:ipynb compile-extension-media compile-extension:vscode-test-resolver displayName: Build extensions for smoke tests - script: yarn gulp node diff --git a/test/automation/src/application.ts b/test/automation/src/application.ts index e06eaaf5be4..2a0084e1c1d 100644 --- a/test/automation/src/application.ts +++ b/test/automation/src/application.ts @@ -6,6 +6,7 @@ import { Workbench } from './workbench'; import { Code, launch, LaunchOptions } from './code'; import { Logger, measureAndLog } from './logger'; +import { Profiler } from './profiler'; export const enum Quality { Dev, @@ -63,6 +64,10 @@ export class Application { return this._userDataPath; } + private _profiler: Profiler | undefined; + + get profiler(): Profiler { return this._profiler!; } + async start(): Promise { await this._start(); await this.code.waitForElement('.explorer-folders-view'); @@ -110,6 +115,7 @@ export class Application { }); this._workbench = new Workbench(this._code); + this._profiler = new Profiler(this.code); return code; } diff --git a/test/automation/src/notebook.ts b/test/automation/src/notebook.ts index e2760109a54..dff250027db 100644 --- a/test/automation/src/notebook.ts +++ b/test/automation/src/notebook.ts @@ -23,7 +23,6 @@ export class Notebook { await this.code.waitForElement(activeRowSelector); await this.focusFirstCell(); - await this.waitForActiveCellEditorContents('code()'); } async focusNextCell() { diff --git a/test/automation/src/playwrightDriver.ts b/test/automation/src/playwrightDriver.ts index 0aeb50e2194..681ca4ef540 100644 --- a/test/automation/src/playwrightDriver.ts +++ b/test/automation/src/playwrightDriver.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as playwright from '@playwright/test'; +import type { Protocol } from 'playwright-core/types/protocol'; import { dirname, join } from 'path'; import { promises } from 'fs'; import { IWindowDriver } from './driver'; @@ -83,6 +84,82 @@ export class PlaywrightDriver { await this.whenLoaded; } + private _cdpSession: playwright.CDPSession | undefined; + + async startCDP() { + if (this._cdpSession) { + return; + } + + this._cdpSession = await this.page.context().newCDPSession(this.page); + } + + async collectGarbage() { + if (!this._cdpSession) { + throw new Error('CDP not started'); + } + + await this._cdpSession.send('HeapProfiler.collectGarbage'); + } + + async evaluate(options: Protocol.Runtime.evaluateParameters): Promise { + if (!this._cdpSession) { + throw new Error('CDP not started'); + } + + return await this._cdpSession.send('Runtime.evaluate', options); + } + + async releaseObjectGroup(parameters: Protocol.Runtime.releaseObjectGroupParameters): Promise { + if (!this._cdpSession) { + throw new Error('CDP not started'); + } + + await this._cdpSession.send('Runtime.releaseObjectGroup', parameters); + } + + async queryObjects(parameters: Protocol.Runtime.queryObjectsParameters): Promise { + if (!this._cdpSession) { + throw new Error('CDP not started'); + } + + return await this._cdpSession.send('Runtime.queryObjects', parameters); + } + + async callFunctionOn(parameters: Protocol.Runtime.callFunctionOnParameters): Promise { + if (!this._cdpSession) { + throw new Error('CDP not started'); + } + + return await this._cdpSession.send('Runtime.callFunctionOn', parameters); + } + + async takeHeapSnapshot(): Promise { + if (!this._cdpSession) { + throw new Error('CDP not started'); + } + + let snapshot = ''; + const listener = (c: { chunk: string }) => { + snapshot += c.chunk; + }; + + this._cdpSession.addListener('HeapProfiler.addHeapSnapshotChunk', listener); + + await this._cdpSession.send('HeapProfiler.takeHeapSnapshot'); + + this._cdpSession.removeListener('HeapProfiler.addHeapSnapshotChunk', listener); + return snapshot; + } + + async getProperties(parameters: Protocol.Runtime.getPropertiesParameters): Promise { + if (!this._cdpSession) { + throw new Error('CDP not started'); + } + + return await this._cdpSession.send('Runtime.getProperties', parameters); + } + private async takeScreenshot(name: string): Promise { try { const persistPath = join(this.options.logsPath, `playwright-screenshot-${PlaywrightDriver.screenShotCounter++}-${name.replace(/\s+/g, '-')}.png`); diff --git a/test/automation/src/profiler.ts b/test/automation/src/profiler.ts new file mode 100644 index 00000000000..9b3a0107d5e --- /dev/null +++ b/test/automation/src/profiler.ts @@ -0,0 +1,208 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const { decode_bytes } = require('@vscode/v8-heap-parser'); +import { Code } from './code'; +import { PlaywrightDriver } from './playwrightDriver'; + +export class Profiler { + constructor(private readonly code: Code) { + } + + async checkObjectLeaks(classNames: string | string[], fn: () => Promise): Promise { + await this.code.driver.startCDP(); + + const classNamesArray = Array.isArray(classNames) ? classNames : [classNames]; + const countsBefore = await getInstances(this.code.driver, classNamesArray); + + await fn(); + + const countAfter = await getInstances(this.code.driver, classNamesArray); + const leaks: string[] = []; + for (const className of classNamesArray) { + const count = countAfter[className] ?? 0; + const countBefore = countsBefore[className] ?? 0; + if (count !== countBefore) { + leaks.push(`Leaked ${count - countBefore} ${className}`); + } + } + + if (leaks.length > 0) { + throw new Error(leaks.join('\n')); + } + } + + async checkHeapLeaks(classNames: string | string[], fn: () => Promise): Promise { + await this.code.driver.startCDP(); + await fn(); + + const heapSnapshotAfter = await this.code.driver.takeHeapSnapshot(); + const buff = Buffer.from(heapSnapshotAfter); + const graph = await decode_bytes(buff); + const counts: number[] = Array.from(graph.get_class_counts(classNames)); + const leaks: string[] = []; + for (let i = 0; i < classNames.length; i++) { + if (counts[i] > 0) { + leaks.push(`Leaked ${counts[i]} ${classNames[i]}`); + } + } + + if (leaks.length > 0) { + throw new Error(leaks.join('\n')); + } + } +} + +function generateUuid() { + // use `randomValues` if possible + function getRandomValues(bucket: Uint8Array): Uint8Array { + for (let i = 0; i < bucket.length; i++) { + bucket[i] = Math.floor(Math.random() * 256); + } + return bucket; + } + + // prep-work + const _data = new Uint8Array(16); + const _hex: string[] = []; + for (let i = 0; i < 256; i++) { + _hex.push(i.toString(16).padStart(2, '0')); + } + + // get data + getRandomValues(_data); + + // set version bits + _data[6] = (_data[6] & 0x0f) | 0x40; + _data[8] = (_data[8] & 0x3f) | 0x80; + + // print as string + let i = 0; + let result = ''; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += '-'; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += '-'; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += '-'; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += '-'; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + result += _hex[_data[i++]]; + return result; +} + + + +/*--------------------------------------------------------------------------------------------- + * The MIT License (MIT) + * Copyright (c) 2023-present, Simon Siefke + * + * This code is derived from https://github.com/SimonSiefke/vscode-memory-leak-finder + *--------------------------------------------------------------------------------------------*/ + +const getInstances = async (driver: PlaywrightDriver, classNames: string[]): Promise<{ [key: string]: number }> => { + await driver.collectGarbage(); + const objectGroup = `og:${generateUuid()}`; + const prototypeDescriptor = await driver.evaluate({ + expression: 'Object.prototype', + returnByValue: false, + objectGroup, + }); + const objects = await driver.queryObjects({ + prototypeObjectId: prototypeDescriptor.result.objectId!, + objectGroup, + }); + const fnResult1 = await driver.callFunctionOn({ + functionDeclaration: `function(){ + const objects = this + const classNames = ${JSON.stringify(classNames)} + + const nativeConstructors = [ + Object, + Array, + Function, + Set, + Map, + WeakMap, + WeakSet, + RegExp, + Node, + HTMLScriptElement, + DOMRectReadOnly, + DOMRect, + HTMLHtmlElement, + Node, + DOMTokenList, + HTMLUListElement, + HTMLStyleElement, + HTMLDivElement, + HTMLCollection, + FocusEvent, + Promise, + HTMLLinkElement, + HTMLLIElement, + HTMLAnchorElement, + HTMLSpanElement, + ArrayBuffer, + Uint16Array, + HTMLLabelElement, + TrustedTypePolicy, + Uint8Array, + Uint32Array, + HTMLHeadingElement, + MediaQueryList, + HTMLDocument, + TextDecoder, + TextEncoder, + HTMLInputElement, + HTMLCanvasElement, + HTMLIFrameElement, + Int32Array, + CSSStyleDeclaration + ] + + const isNativeConstructor = object => { + return nativeConstructors.includes(object.constructor) || + object.constructor.name === 'AsyncFunction' || + object.constructor.name === 'GeneratorFunction' || + object.constructor.name === 'AsyncGeneratorFunction' + } + + const isInstance = (object) => { + return object && !isNativeConstructor(object) + } + + const instances = objects.filter(isInstance) + + const counts = Object.create(null) + for(const instance of instances){ + const name=instance.constructor.name + if(classNames.includes(name)){ + counts[name]||= 0 + counts[name]++ + } + } + return counts +}`, + objectId: objects.objects.objectId, + returnByValue: true, + objectGroup, + }); + + const returnObject = fnResult1.result.value; + await driver.releaseObjectGroup({ objectGroup: objectGroup }); + return returnObject; +}; diff --git a/test/smoke/src/areas/notebook/notebook.test.ts b/test/smoke/src/areas/notebook/notebook.test.ts index a5251a5f75e..da4d527fe4f 100644 --- a/test/smoke/src/areas/notebook/notebook.test.ts +++ b/test/smoke/src/areas/notebook/notebook.test.ts @@ -8,7 +8,7 @@ import { Application, Logger } from '../../../../automation'; import { installAllHandlers } from '../../utils'; export function setup(logger: Logger) { - describe.skip('Notebooks', () => { // https://github.com/microsoft/vscode/issues/140575 + describe('Notebooks', () => { // https://github.com/microsoft/vscode/issues/140575 // Shared before/after handling installAllHandlers(logger); @@ -25,7 +25,25 @@ export function setup(logger: Logger) { cp.execSync('git reset --hard HEAD --quiet', { cwd: app.workspacePathOrFolder }); }); - it('inserts/edits code cell', async function () { + it.skip('check heap leaks', async function () { + const app = this.app as Application; + await app.profiler.checkHeapLeaks(['NotebookTextModel', 'NotebookCellTextModel', 'NotebookEventDispatcher'], async () => { + await app.workbench.notebook.openNotebook(); + await app.workbench.quickaccess.runCommand('workbench.action.files.save'); + await app.workbench.quickaccess.runCommand('workbench.action.closeActiveEditor'); + }); + }); + + it('check object leaks', async function () { + const app = this.app as Application; + await app.profiler.checkObjectLeaks(['NotebookTextModel', 'NotebookCellTextModel', 'NotebookEventDispatcher'], async () => { + await app.workbench.notebook.openNotebook(); + await app.workbench.quickaccess.runCommand('workbench.action.files.save'); + await app.workbench.quickaccess.runCommand('workbench.action.closeActiveEditor'); + }); + }); + + it.skip('inserts/edits code cell', async function () { const app = this.app as Application; await app.workbench.notebook.openNotebook(); await app.workbench.notebook.focusNextCell(); @@ -34,7 +52,7 @@ export function setup(logger: Logger) { await app.workbench.notebook.stopEditingCell(); }); - it('inserts/edits markdown cell', async function () { + it.skip('inserts/edits markdown cell', async function () { const app = this.app as Application; await app.workbench.notebook.openNotebook(); await app.workbench.notebook.focusNextCell();