diff --git a/.travis.yml b/.travis.yml index c8a53b365546..d0d4006db80a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -90,6 +90,7 @@ script: bash <(curl -s https://codecov.io/bash); fi - if [ $FUNCTIONAL_TEST == "true" ]; then + python -m pip install --upgrade -r functionalTestRequirements.txt npm run cover:enable; npm run test:functional; fi diff --git a/.vscode/launch.json b/.vscode/launch.json index f1fac794313e..e8a65f83a096 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -140,7 +140,23 @@ "sourceMaps": true, "args": [ "timeout=300000", - "grep=History" + "grep=" + ], + "outFiles": [ + "${workspaceFolder}/out/**/*.js" + ], + "preLaunchTask": "Compile" + }, + { + "name": "Functional Tests (without VS Code, *.functional.test.ts)", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/out/test/functionalTests.js", + "stopOnEntry": false, + "sourceMaps": true, + "args": [ + "timeout=300000", + "grep=" ], "outFiles": [ "${workspaceFolder}/out/**/*.js" diff --git a/functionalTestRequirements.txt b/functionalTestRequirements.txt new file mode 100644 index 000000000000..83b9f71a6416 --- /dev/null +++ b/functionalTestRequirements.txt @@ -0,0 +1,2 @@ +# List of requirements for functional tests +jupyter diff --git a/src/client/datascience/history.ts b/src/client/datascience/history.ts index a7b6a67d5b91..41c1fc890be4 100644 --- a/src/client/datascience/history.ts +++ b/src/client/datascience/history.ts @@ -200,7 +200,7 @@ export class History implements IWebPanelMessageListener, IHistory { @captureTelemetry(Telemetry.RestartKernel) private restartKernel() { if (this.jupyterServer) { - this.jupyterServer.restartKernel(); + this.jupyterServer.restartKernel().ignoreErrors(); } } diff --git a/src/client/datascience/jupyterServer.ts b/src/client/datascience/jupyterServer.ts index 681da81be05b..1baf8fa11fdb 100644 --- a/src/client/datascience/jupyterServer.ts +++ b/src/client/datascience/jupyterServer.ts @@ -181,7 +181,7 @@ export class JupyterServer implements INotebookServer { }); } - return Promise.reject(localize.DataScience.sessionDisposed); + return Promise.reject(new Error(localize.DataScience.sessionDisposed())); } public get onStatusChanged() : vscode.Event { @@ -196,10 +196,12 @@ export class JupyterServer implements INotebookServer { } } - public restartKernel = () => { + public restartKernel = () : Promise => { if (this.session && this.session.kernel) { - this.session.kernel.restart().ignoreErrors(); + return this.session.kernel.restart(); } + + return Promise.reject(new Error(localize.DataScience.sessionDisposed())); } public translateToNotebook = async (cells: ICell[]) : Promise => { diff --git a/src/client/datascience/types.ts b/src/client/datascience/types.ts index 9407e10a6c48..e80959bcf81c 100644 --- a/src/client/datascience/types.ts +++ b/src/client/datascience/types.ts @@ -31,7 +31,7 @@ export interface INotebookServer extends IDisposable { getCurrentState() : Promise; executeObservable(code: string, file: string, line: number) : Observable; execute(code: string, file: string, line: number) : Promise; - restartKernel(); + restartKernel() : Promise; translateToNotebook(cells: ICell[]) : Promise; launchNotebook(file: string) : Promise; } diff --git a/src/client/ioc/serviceManager.ts b/src/client/ioc/serviceManager.ts index bcd4331dfd13..7392a95d1668 100644 --- a/src/client/ioc/serviceManager.ts +++ b/src/client/ioc/serviceManager.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. - import { Container, injectable, interfaces } from 'inversify'; -import { Abstract, IServiceManager, Newable } from './types'; + +import { Abstract, ClassType, IServiceManager, Newable } from './types'; type identifier = string | symbol | Newable | Abstract; @@ -43,4 +43,13 @@ export class ServiceManager implements IServiceManager { public getAll(serviceIdentifier: identifier, name?: string | number | symbol | undefined): T[] { return name ? this.container.getAllNamed(serviceIdentifier, name) : this.container.getAll(serviceIdentifier); } + + public rebind(serviceIdentifier: interfaces.ServiceIdentifier, constructor: ClassType, name?: string | number | symbol): void { + if (name) { + this.container.rebind(serviceIdentifier).to(constructor).whenTargetNamed(name); + } else { + this.container.rebind(serviceIdentifier).to(constructor); + } + } + } diff --git a/src/client/ioc/types.ts b/src/client/ioc/types.ts index 75fe3b312832..d2a433cbb101 100644 --- a/src/client/ioc/types.ts +++ b/src/client/ioc/types.ts @@ -26,6 +26,7 @@ export interface IServiceManager { addFactory(factoryIdentifier: interfaces.ServiceIdentifier>, factoryMethod: interfaces.FactoryCreator): void; get(serviceIdentifier: interfaces.ServiceIdentifier, name?: string | number | symbol): T; getAll(serviceIdentifier: interfaces.ServiceIdentifier, name?: string | number | symbol): T[]; + rebind(serviceIdentifier: interfaces.ServiceIdentifier, constructor: ClassType, name?: string | number | symbol): void; } export const IServiceContainer = Symbol('IServiceContainer'); diff --git a/src/test/datascience/DefaultSalesReport.csv b/src/test/datascience/DefaultSalesReport.csv new file mode 100644 index 000000000000..02a53318cf00 --- /dev/null +++ b/src/test/datascience/DefaultSalesReport.csv @@ -0,0 +1,278 @@ +Product,Customer,Qtr 1,Qtr 2,Qtr 3,Qtr 4 +Alice Mutton,ANTON, $- , $702.00 , $- , $- +Alice Mutton,BERGS, $312.00 , $- , $- , $- +Alice Mutton,BOLID, $- , $- , $- ," $1,170.00 " +Alice Mutton,BOTTM," $1,170.00 ", $- , $- , $- +Alice Mutton,ERNSH," $1,123.20 ", $- , $- ," $2,607.15 " +Alice Mutton,GODOS, $- , $280.80 , $- , $- +Alice Mutton,HUNGC, $62.40 , $- , $- , $- +Alice Mutton,PICCO, $- ," $1,560.00 ", $936.00 , $- +Alice Mutton,RATTC, $- , $592.80 , $- , $- +Alice Mutton,REGGC, $- , $- , $- , $741.00 +Alice Mutton,SAVEA, $- , $- ," $3,900.00 ", $789.75 +Alice Mutton,SEVES, $- , $877.50 , $- , $- +Alice Mutton,WHITC, $- , $- , $- , $780.00 +Aniseed Syrup,ALFKI, $- , $- , $- , $60.00 +Aniseed Syrup,BOTTM, $- , $- , $- , $200.00 +Aniseed Syrup,ERNSH, $- , $- , $- , $180.00 +Aniseed Syrup,LINOD, $544.00 , $- , $- , $- +Aniseed Syrup,QUICK, $- , $600.00 , $- , $- +Aniseed Syrup,VAFFE, $- , $- , $140.00 , $- +Boston Crab Meat,ANTON, $- , $165.60 , $- , $- +Boston Crab Meat,BERGS, $- , $920.00 , $- , $- +Boston Crab Meat,BONAP, $- , $248.40 , $524.40 , $- +Boston Crab Meat,BOTTM, $551.25 , $- , $- , $- +Boston Crab Meat,BSBEV, $147.00 , $- , $- , $- +Boston Crab Meat,FRANS, $- , $- , $- , $18.40 +Boston Crab Meat,HILAA, $- , $92.00 ," $1,104.00 ", $- +Boston Crab Meat,LAZYK, $147.00 , $- , $- , $- +Boston Crab Meat,LEHMS, $- , $515.20 , $- , $- +Boston Crab Meat,MAGAA, $- , $- , $- , $55.20 +Boston Crab Meat,OTTIK, $- , $- , $368.00 , $- +Boston Crab Meat,PERIC, $308.70 , $- , $- , $- +Boston Crab Meat,QUEEN, $26.46 , $- , $419.52 , $110.40 +Boston Crab Meat,QUICK, $- , $- ," $1,223.60 ", $- +Boston Crab Meat,RANCH, $294.00 , $- , $- , $- +Boston Crab Meat,SAVEA, $- , $- , $772.80 , $736.00 +Boston Crab Meat,TRAIH, $- , $36.80 , $- , $- +Boston Crab Meat,VAFFE, $294.00 , $- , $- , $736.00 +Camembert Pierrot,ANATR, $- , $- , $340.00 , $- +Camembert Pierrot,AROUT, $- , $- , $- , $510.00 +Camembert Pierrot,BERGS, $- , $- , $680.00 , $- +Camembert Pierrot,BOTTM, $- , $- , $- ," $1,700.00 " +Camembert Pierrot,CHOPS, $- , $323.00 , $- , $- +Camembert Pierrot,FAMIA, $- , $346.80 , $- , $- +Camembert Pierrot,FRANK, $- , $- , $612.00 , $- +Camembert Pierrot,FURIB, $544.00 , $- , $- , $- +Camembert Pierrot,GOURL, $- , $- , $- , $340.00 +Camembert Pierrot,LEHMS, $- , $892.50 , $- , $- +Camembert Pierrot,MEREP, $- , $- ," $2,261.00 ", $- +Camembert Pierrot,OTTIK, $- , $- ," $1,020.00 ", $- +Camembert Pierrot,QUEEN, $- , $- , $- , $510.00 +Camembert Pierrot,QUICK, $- ," $2,427.60 "," $1,776.50 ", $- +Camembert Pierrot,RICAR," $1,088.00 ", $- , $- , $- +Camembert Pierrot,RICSU," $1,550.40 ", $- , $- , $- +Camembert Pierrot,SAVEA, $- , $- ," $2,380.00 ", $- +Camembert Pierrot,WARTH, $- , $693.60 , $- , $- +Camembert Pierrot,WOLZA, $- , $- , $510.00 , $- +Chef Anton's Cajun Seasoning,BERGS, $- , $- , $237.60 , $- +Chef Anton's Cajun Seasoning,BONAP, $- , $935.00 , $- , $- +Chef Anton's Cajun Seasoning,EASTC, $- , $- , $- , $550.00 +Chef Anton's Cajun Seasoning,FOLKO, $- ," $1,045.00 ", $- , $- +Chef Anton's Cajun Seasoning,FURIB, $225.28 , $- , $- , $- +Chef Anton's Cajun Seasoning,MAGAA, $- , $- , $198.00 , $- +Chef Anton's Cajun Seasoning,QUEEN, $- , $- , $- , $132.00 +Chef Anton's Cajun Seasoning,QUICK, $- , $990.00 , $- , $- +Chef Anton's Cajun Seasoning,TRADH, $- , $- , $352.00 , $- +Chef Anton's Cajun Seasoning,WARTH, $- , $- , $550.00 , $- +Chef Anton's Gumbo Mix,MAGAA, $- , $- , $288.22 , $- +Chef Anton's Gumbo Mix,THEBI, $- , $- , $- , $85.40 +Filo Mix,AROUT, $- , $210.00 , $- , $56.00 +Filo Mix,BERGS, $- , $- , $- , $175.00 +Filo Mix,BLONP, $112.00 , $- , $- , $- +Filo Mix,DUMON, $- , $- , $63.00 , $- +Filo Mix,FAMIA, $- , $- , $- , $28.00 +Filo Mix,LAUGB, $- , $- , $35.00 , $- +Filo Mix,NORTS, $- , $42.00 , $- , $- +Filo Mix,OLDWO, $- , $- , $168.00 , $- +Filo Mix,REGGC, $- , $- , $23.80 , $- +Filo Mix,RICAR, $- , $490.00 , $- , $- +Filo Mix,RICSU, $- , $- , $- , $420.00 +Filo Mix,TOMSP, $75.60 , $- , $- , $- +Filo Mix,VAFFE, $- , $- , $- , $99.75 +Filo Mix,VINET, $- , $- , $- , $126.00 +Gorgonzola Telino,AROUT, $- , $- , $- , $625.00 +Gorgonzola Telino,BLONP, $- , $593.75 , $- , $- +Gorgonzola Telino,BONAP, $- , $- , $- , $35.62 +Gorgonzola Telino,CACTU, $- , $- , $- , $12.50 +Gorgonzola Telino,ERNSH, $- , $- , $- , $890.00 +Gorgonzola Telino,FOLKO, $- , $- , $- , $18.75 +Gorgonzola Telino,GOURL, $140.00 , $- , $- , $- +Gorgonzola Telino,HANAR, $- , $- , $- , $125.00 +Gorgonzola Telino,HILAA, $- , $- , $- , $250.00 +Gorgonzola Telino,HUNGO, $- , $600.00 , $- , $- +Gorgonzola Telino,LEHMS, $- , $250.00 , $- , $- +Gorgonzola Telino,OLDWO, $- , $- , $187.50 , $- +Gorgonzola Telino,PICCO, $- , $- , $- , $100.00 +Gorgonzola Telino,QUEEN, $- , $- , $237.50 , $- +Gorgonzola Telino,QUICK, $- , $584.37 , $- , $- +Gorgonzola Telino,RATTC, $- , $421.25 , $- , $- +Gorgonzola Telino,RICSU, $- , $375.00 , $- , $- +Gorgonzola Telino,SAVEA, $- , $- , $- , $625.00 +Gorgonzola Telino,SUPRD, $297.50 , $- , $- , $- +Gorgonzola Telino,TOMSP, $27.00 , $- , $- , $- +Gorgonzola Telino,TORTU, $- , $250.00 , $- , $- +Gorgonzola Telino,TRADH, $- , $190.00 , $- , $- +Gorgonzola Telino,WANDK, $- , $- , $90.00 , $- +Gorgonzola Telino,WARTH, $- , $375.00 , $- , $- +Grandma's Boysenberry Spread,GOURL, $- , $- , $- , $750.00 +Grandma's Boysenberry Spread,MEREP, $- , $- ," $1,750.00 ", $- +Ipoh Coffee,ANTON, $- , $586.50 , $- , $- +Ipoh Coffee,BERGS, $- ," $2,760.00 ", $- , $- +Ipoh Coffee,FURIB, $110.40 , $- , $- , $- +Ipoh Coffee,KOENE, $552.00 , $- , $- , $- +Ipoh Coffee,MAISD, $- , $- , $- ," $1,035.00 " +Ipoh Coffee,OLDWO, $- , $- , $- ," $1,104.00 " +Ipoh Coffee,PICCO, $- ," $1,150.00 ", $- , $- +Ipoh Coffee,QUICK, $- , $- , $- ," $1,840.00 " +Ipoh Coffee,SUPRD, $736.00 , $- , $- , $- +Ipoh Coffee,WELLI, $- , $- , $920.00 , $- +Ipoh Coffee,WILMK, $- , $- , $276.00 , $- +Jack's New England Clam Chowder,AROUT, $- , $- , $- , $135.10 +Jack's New England Clam Chowder,BERGS, $231.00 , $- , $- , $96.50 +Jack's New England Clam Chowder,BLONP, $- , $110.01 , $- , $- +Jack's New England Clam Chowder,BOTTM, $154.00 , $- , $- , $- +Jack's New England Clam Chowder,CACTU, $- , $96.50 , $- , $- +Jack's New England Clam Chowder,FAMIA, $- , $- , $- , $115.80 +Jack's New England Clam Chowder,FRANK, $- , $- , $- , $183.35 +Jack's New England Clam Chowder,GOURL, $- , $- , $38.60 , $- +Jack's New England Clam Chowder,HUNGO, $- , $694.80 , $- , $- +Jack's New England Clam Chowder,LAUGB, $- , $154.00 , $- , $- +Jack's New England Clam Chowder,OTTIK, $- , $82.51 , $- , $- +Jack's New England Clam Chowder,PICCO, $- , $- , $- , $337.75 +Jack's New England Clam Chowder,REGGC, $- , $- , $154.40 , $- +Jack's New England Clam Chowder,SAVEA, $- , $- ," $1,389.60 ", $405.30 +Jack's New England Clam Chowder,SEVES, $- , $52.11 , $- , $- +Jack's New England Clam Chowder,TOMSP, $- , $135.10 , $- , $- +Jack's New England Clam Chowder,VAFFE, $- , $- , $- , $275.02 +Jack's New England Clam Chowder,VINET, $- , $- , $- , $115.80 +Laughing Lumberjack Lager,FRANK, $- , $- , $350.00 , $- +Laughing Lumberjack Lager,LONEP, $- , $98.00 , $- , $- +Laughing Lumberjack Lager,PERIC, $- , $420.00 , $- , $- +Laughing Lumberjack Lager,THECR, $- , $- , $- , $42.00 +Longlife Tofu,FRANS, $- , $- , $- , $50.00 +Longlife Tofu,HILAA, $128.00 , $- , $- , $- +Longlife Tofu,MEREP, $240.00 , $- , $- , $- +Longlife Tofu,QUICK, $120.00 , $- , $- , $- +Longlife Tofu,VICTE, $- , $- , $- , $112.50 +Longlife Tofu,WARTH, $- , $- , $- , $350.00 +Louisiana Fiery Hot Pepper Sauce,BONAP, $- , $- , $- , $199.97 +Louisiana Fiery Hot Pepper Sauce,ERNSH, $- , $820.95 , $- ," $1,299.84 " +Louisiana Fiery Hot Pepper Sauce,FRANR, $- , $- , $252.60 , $- +Louisiana Fiery Hot Pepper Sauce,FURIB, $- , $- , $268.39 , $- +Louisiana Fiery Hot Pepper Sauce,HANAR, $- , $682.02 , $- , $- +Louisiana Fiery Hot Pepper Sauce,HUNGO, $- , $421.00 , $- , $842.00 +Louisiana Fiery Hot Pepper Sauce,LAMAI, $- , $226.80 , $- , $- +Louisiana Fiery Hot Pepper Sauce,LINOD, $- , $- , $442.05 , $- +Louisiana Fiery Hot Pepper Sauce,OTTIK, $- , $599.92 , $- , $- +Louisiana Fiery Hot Pepper Sauce,PICCO, $- , $- , $202.08 , $- +Louisiana Fiery Hot Pepper Sauce,QUICK, $423.36 , $- , $- ," $1,515.60 " +Louisiana Fiery Hot Pepper Sauce,RATTC, $336.00 , $- , $- , $- +Louisiana Fiery Hot Pepper Sauce,RICAR, $588.00 , $- , $- , $- +Louisiana Fiery Hot Pepper Sauce,RICSU, $- , $- , $210.50 , $- +Louisiana Fiery Hot Pepper Sauce,VICTE, $- , $- , $- , $42.10 +Louisiana Hot Spiced Okra,ANTON, $- , $- , $68.00 , $- +Louisiana Hot Spiced Okra,EASTC, $- , $408.00 , $- , $- +Louisiana Hot Spiced Okra,ERNSH, $816.00 , $- , $- , $- +Louisiana Hot Spiced Okra,FOLKO, $- , $- , $- , $850.00 +Louisiana Hot Spiced Okra,LAMAI, $- , $122.40 , $- , $- +Louisiana Hot Spiced Okra,SUPRD, $693.60 , $- , $- , $- +Mozzarella di Giovanni,BOTTM, $- , $- , $- ," $1,218.00 " +Mozzarella di Giovanni,BSBEV, $- , $34.80 , $- , $- +Mozzarella di Giovanni,CONSH, $278.00 , $- , $- , $- +Mozzarella di Giovanni,FOLKO, $- , $835.20 , $- , $- +Mozzarella di Giovanni,GREAL, $- , $313.20 , $- , $- +Mozzarella di Giovanni,ISLAT, $- , $- , $- , $348.00 +Mozzarella di Giovanni,LEHMS, $- , $695.00 , $- , $- +Mozzarella di Giovanni,LINOD, $- , $- ," $2,088.00 ", $- +Mozzarella di Giovanni,MAGAA, $- , $- , $- , $887.40 +Mozzarella di Giovanni,MAISD, $- , $- , $522.00 , $- +Mozzarella di Giovanni,MORGK, $- ," $1,044.00 ", $- , $- +Mozzarella di Giovanni,QUICK, $- , $- , $- , $243.60 +Mozzarella di Giovanni,RICSU, $- , $730.80 , $- , $- +Mozzarella di Giovanni,SAVEA, $- , $- , $417.60 , $- +Mozzarella di Giovanni,SIMOB, $- , $835.20 , $- , $- +Mozzarella di Giovanni,VICTE," $1,112.00 ", $- , $- , $- +Northwoods Cranberry Sauce,BONAP, $- , $340.00 , $- , $- +Northwoods Cranberry Sauce,GOURL, $- , $- , $- ," $1,600.00 " +Northwoods Cranberry Sauce,LEHMS, $- , $960.00 , $- , $- +Northwoods Cranberry Sauce,QUEEN, $- , $- , $- , $960.00 +Northwoods Cranberry Sauce,WILMK, $- , $- , $- , $400.00 +Ravioli Angelo,ANTON, $- , $87.75 , $- , $- +Ravioli Angelo,AROUT, $- , $- , $- , $780.00 +Ravioli Angelo,BLAUS, $- , $78.00 , $- , $- +Ravioli Angelo,BONAP, $- , $- , $- , $204.75 +Ravioli Angelo,BSBEV, $- , $117.00 , $- , $- +Ravioli Angelo,PICCO, $- , $- , $390.00 , $- +Ravioli Angelo,TOMSP, $187.20 , $- , $- , $- +Ravioli Angelo,WARTH, $312.00 , $- , $- , $- +Sasquatch Ale,ANTON, $- , $560.00 , $- , $- +Sasquatch Ale,SAVEA, $- , $- , $- , $554.40 +Sasquatch Ale,THEBI, $- , $- , $- , $140.00 +Sasquatch Ale,TOMSP, $179.20 , $105.00 , $- , $- +Sasquatch Ale,VAFFE, $- , $- , $- , $196.00 +Sasquatch Ale,WHITC, $372.40 , $- , $- , $- +Sir Rodney's Marmalade,ERNSH, $- ," $3,159.00 ", $- , $- +Sir Rodney's Marmalade,HUNGC, $- , $- ," $1,701.00 ", $- +Sir Rodney's Marmalade,LEHMS, $- , $- ," $1,360.80 ", $- +Sir Rodney's Marmalade,SEVES, $- ," $1,093.50 ", $- , $- +Sir Rodney's Scones,BLAUS, $- , $- , $80.00 , $- +Sir Rodney's Scones,BSBEV, $112.00 , $150.00 , $- , $- +Sir Rodney's Scones,CHOPS, $- , $- , $- , $380.00 +Sir Rodney's Scones,DUMON, $- , $- , $60.00 , $- +Sir Rodney's Scones,ERNSH, $400.00 , $- , $- , $- +Sir Rodney's Scones,FOLIG, $- , $- , $- , $400.00 +Sir Rodney's Scones,FRANK, $- , $- , $225.00 , $304.00 +Sir Rodney's Scones,GODOS, $- , $54.00 , $- , $- +Sir Rodney's Scones,GREAL, $- , $- , $108.00 , $- +Sir Rodney's Scones,KOENE, $272.00 , $- , $- , $- +Sir Rodney's Scones,LILAS, $240.00 , $- , $- , $- +Sir Rodney's Scones,LINOD, $- , $- , $- , $300.00 +Sir Rodney's Scones,MEREP, $- , $- , $420.00 , $- +Sir Rodney's Scones,OCEAN, $96.00 , $- , $- , $- +Sir Rodney's Scones,PRINI, $126.00 , $- , $- , $- +Sir Rodney's Scones,QUEEN, $216.00 , $- , $- , $- +Sir Rodney's Scones,QUICK, $- , $- , $600.00 , $- +Sir Rodney's Scones,RANCH, $- , $- , $- , $50.00 +Sir Rodney's Scones,SIMOB, $- , $- , $240.00 , $- +Sir Rodney's Scones,WANDK, $- , $320.00 , $- , $- +Sir Rodney's Scones,WHITC, $- , $120.00 , $- , $- +Steeleye Stout,BERGS, $115.20 , $- , $- , $- +Steeleye Stout,BSBEV, $- , $360.00 , $- , $- +Steeleye Stout,CACTU, $- , $54.00 , $- , $- +Steeleye Stout,EASTC, $504.00 , $- , $- , $- +Steeleye Stout,ERNSH, $- , $- , $405.00 , $- +Steeleye Stout,FOLIG, $- , $- , $- , $270.00 +Steeleye Stout,FRANK, $- , $- , $486.00 , $- +Steeleye Stout,FURIB, $- , $306.00 , $- , $- +Steeleye Stout,GREAL, $- , $- , $72.00 , $- +Steeleye Stout,LINOD, $- , $- , $- , $121.50 +Steeleye Stout,MEREP, $691.20 , $- , $- , $- +Steeleye Stout,QUEDE, $- , $- , $360.00 , $378.00 +Steeleye Stout,VICTE, $- , $540.00 , $- , $- +Steeleye Stout,WARTH, $- , $108.00 , $- , $- +Steeleye Stout,WHITC, $- , $- , $- , $504.00 +Teatime Chocolate Biscuits,FAMIA, $124.83 , $- , $- , $- +Teatime Chocolate Biscuits,FRANK, $- , $- , $124.20 , $- +Teatime Chocolate Biscuits,FRANS, $- , $- , $- , $46.00 +Teatime Chocolate Biscuits,GODOS, $- , $92.00 , $- , $- +Teatime Chocolate Biscuits,GREAL, $- , $- , $248.40 , $- +Teatime Chocolate Biscuits,ISLAT, $- , $- , $46.00 , $- +Teatime Chocolate Biscuits,LINOD, $- , $- , $- , $48.30 +Teatime Chocolate Biscuits,QUEDE, $24.82 , $- , $276.00 , $- +Teatime Chocolate Biscuits,QUEEN, $36.50 , $- , $- , $- +Teatime Chocolate Biscuits,QUICK, $- , $- , $- , $437.00 +Teatime Chocolate Biscuits,RICAR, $292.00 , $- , $- , $- +Teatime Chocolate Biscuits,SAVEA, $- , $257.60 , $- , $110.40 +Teatime Chocolate Biscuits,SUPRD, $153.30 , $- , $- , $- +Teatime Chocolate Biscuits,TOMSP, $166.44 , $- , $- , $- +Teatime Chocolate Biscuits,TORTU, $- , $- , $64.40 , $- +Teatime Chocolate Biscuits,WANDK, $- , $- , $82.80 , $- +Teatime Chocolate Biscuits,WARTH, $146.00 , $- , $- , $- +Teatime Chocolate Biscuits,WELLI, $- , $- , $- , $209.76 +Uncle Bob's Organic Dried Pears,BONAP, $- ," $1,275.00 ", $- , $- +Uncle Bob's Organic Dried Pears,BSBEV, $720.00 , $- , $- , $- +Uncle Bob's Organic Dried Pears,FOLIG, $- , $- ," $1,050.00 ", $- +Uncle Bob's Organic Dried Pears,GOURL, $- , $- , $- , $76.50 +Uncle Bob's Organic Dried Pears,OTTIK, $- , $- , $- ," $1,050.00 " +Uncle Bob's Organic Dried Pears,QUICK, $- , $- , $- ," $2,700.00 " +Uncle Bob's Organic Dried Pears,SAVEA, $- , $- ," $1,350.00 ", $- +Uncle Bob's Organic Dried Pears,VAFFE, $- , $- , $300.00 , $- +Uncle Bob's Organic Dried Pears,VICTE, $364.80 , $300.00 , $- , $- +Veggie-spread,ALFKI, $- , $- , $- , $878.00 +Veggie-spread,ERNSH," $2,281.50 ", $- , $- , $- +Veggie-spread,FOLIG, $- , $- , $- ," $1,317.00 " +Veggie-spread,HUNGO, $921.37 , $- , $- , $- +Veggie-spread,MORGK, $- , $263.40 , $- , $- +Veggie-spread,PICCO, $- , $- , $- , $395.10 +Veggie-spread,WHITC, $- , $- , $842.88 , $- diff --git a/src/test/datascience/executionServiceMock.ts b/src/test/datascience/executionServiceMock.ts index 2bfe29aea205..1d4ab293e0f2 100644 --- a/src/test/datascience/executionServiceMock.ts +++ b/src/test/datascience/executionServiceMock.ts @@ -1,13 +1,19 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. - 'use strict'; - import { ErrorUtils } from '../../client/common/errors/errorUtils'; import { ModuleNotInstalledError } from '../../client/common/errors/moduleNotInstalledError'; import { BufferDecoder } from '../../client/common/process/decoder'; import { ProcessService } from '../../client/common/process/proc'; -import { ExecutionResult, InterpreterInfomation, IPythonExecutionService, ObservableExecutionResult, SpawnOptions } from '../../client/common/process/types'; +import { + ExecutionResult, + InterpreterInfomation, + IPythonExecutionService, + ObservableExecutionResult, + PythonVersionInfo, + SpawnOptions +} from '../../client/common/process/types'; +import { Architecture } from '../../client/common/utils/platform'; export class MockPythonExecutionService implements IPythonExecutionService { @@ -18,7 +24,15 @@ export class MockPythonExecutionService implements IPythonExecutionService { this.procService = new ProcessService(new BufferDecoder()); } public getInterpreterInformation(): Promise { - throw new Error('Method not implemented.'); + return Promise.resolve( + { + path: '', + version: '3.6', + sysVersion: '1.0', + sysPrefix: '1.0', + architecture: Architecture.x64, + version_info: [ 3, 6, 0, 'beta'] as PythonVersionInfo + }); } public getExecutablePath(): Promise { diff --git a/src/test/datascience/foo.py b/src/test/datascience/foo.py new file mode 100644 index 000000000000..17da214da465 --- /dev/null +++ b/src/test/datascience/foo.py @@ -0,0 +1 @@ +# Dummy file just to find a file for use in jupyter execution diff --git a/src/test/datascience/notebook.functional.test.ts b/src/test/datascience/notebook.functional.test.ts index 6de9812a168e..54c63288e96f 100644 --- a/src/test/datascience/notebook.functional.test.ts +++ b/src/test/datascience/notebook.functional.test.ts @@ -3,11 +3,20 @@ 'use strict'; import { nbformat } from '@jupyterlab/coreutils'; import * as assert from 'assert'; +import * as fs from 'fs-extra'; +import * as path from 'path'; import { Disposable } from 'vscode'; -import { IJupyterAvailability, INotebookServer } from '../../client/datascience/types'; +import { EXTENSION_ROOT_DIR } from '../../client/common/constants'; +import { IFileSystem } from '../../client/common/platform/types'; +import { JupyterAvailability } from '../../client/datascience/jupyterAvailability'; +import { IConnectionInfo, JupyterProcess } from '../../client/datascience/jupyterProcess'; +import { IJupyterAvailability, INotebookImporter, INotebookProcess, INotebookServer } from '../../client/datascience/types'; +import { Cell, ICellViewModel } from '../../datascience-ui/history-react/cell'; +import { generateTestState } from '../../datascience-ui/history-react/mainPanelState'; import { DataScienceIocContainer } from './dataScienceIocContainer'; +// tslint:disable:no-any no-multiline-string max-func-body-length suite('Jupyter notebook tests', () => { const disposables: Disposable[] = []; let availability: IJupyterAvailability; @@ -31,20 +40,92 @@ suite('Jupyter notebook tests', () => { ioc.dispose(); }); - test('Creation', async () => { - if (await availability.isNotebookSupported()) { - const server = await jupyterServer.start(); - if (!server) { - assert.fail('Server not created'); + function escapePath(p: string) { + return p.replace(/\\/g, '\\\\'); + } + + function srcDirectory() { + return path.join(EXTENSION_ROOT_DIR, 'src', 'test', 'datascience'); + } + + async function assertThrows(func : () => Promise, message: string) { + try { + await func(); + assert.fail(message); + // tslint:disable-next-line:no-empty + } catch { + } + } + + async function verifySimple(code: string, expectedValue: any) : Promise { + const cells = await jupyterServer.execute(code, path.join(srcDirectory(), 'foo.py'), 2); + assert.equal(cells.length, 1, `Wrong number of cells returned`); + assert.equal(cells[0].data.cell_type, 'code', `Wrong type of cell returned`); + const cell = cells[0].data as nbformat.ICodeCell; + assert.equal(cell.outputs.length, 1, `Cell length not correct`); + const data = cell.outputs[0].data; + const error = cell.outputs[0].evalue; + if (error) { + assert.fail(`Unexpected error: ${error}`); + } + assert.ok(data, `No data object on the cell`); + if (data) { // For linter + assert.ok(data.hasOwnProperty('text/plain'), `Cell mime type not correct`); + assert.ok(data['text/plain'], `Cell mime type not correct`); + assert.equal(data['text/plain'], expectedValue, 'Cell value does not match'); + } + } + + async function verifyError(code: string, errorString: string) : Promise { + const cells = await jupyterServer.execute(code, path.join(srcDirectory(), 'foo.py'), 2); + assert.equal(cells.length, 1, `Wrong number of cells returned`); + assert.equal(cells[0].data.cell_type, 'code', `Wrong type of cell returned`); + const cell = cells[0].data as nbformat.ICodeCell; + assert.equal(cell.outputs.length, 1, `Cell length not correct`); + const error = cell.outputs[0].evalue; + if (error) { + assert.ok(error, 'Error not found when expected'); + assert.equal(error, errorString, 'Unexpected error found'); + } + } + + async function verifyCell(index: number, code: string, mimeType: string, cellType: string, verifyValue : (data: any) => void) : Promise { + // Verify results of an execute + const cells = await jupyterServer.execute(code, path.join(srcDirectory(), 'foo.py'), 2); + assert.equal(cells.length, 1, `${index}: Wrong number of cells returned`); + if (cellType === 'code') { + assert.equal(cells[0].data.cell_type, cellType, `${index}: Wrong type of cell returned`); + const cell = cells[0].data as nbformat.ICodeCell; + assert.equal(cell.outputs.length, 1, `${index}: Cell length not correct`); + const error = cell.outputs[0].evalue; + if (error) { + assert.fail(`${index}: Unexpected error: ${error}`); } - } else { - // tslint:disable-next-line:no-console - console.log('Creation test skipped, no Jupyter installed'); + const data = cell.outputs[0].data; + assert.ok(data, `${index}: No data object on the cell`); + if (data) { // For linter + assert.ok(data.hasOwnProperty(mimeType), `${index}: Cell mime type not correct`); + assert.ok(data[mimeType], `${index}: Cell mime type not correct`); + verifyValue(data[mimeType]); + } + } else if (cellType === 'markdown') { + assert.equal(cells[0].data.cell_type, cellType, `${index}: Wrong type of cell returned`); + const cell = cells[0].data as nbformat.IMarkdownCell; + const outputSource = Cell.concatMultilineString(cell.source); + verifyValue(outputSource); + } else if (cellType === 'error') { + const cell = cells[0].data as nbformat.ICodeCell; + assert.equal(cell.outputs.length, 1, `${index}: Cell length not correct`); + const error = cell.outputs[0].evalue; + assert.ok(error, 'Error not found when expected'); + verifyValue(error); } - }).timeout(60000); + } - test('Execution', async () => { - if (await availability.isNotebookSupported()) { + function testMimeTypes(types : {code: string; mimeType: string; cellType: string; verifyValue(data: any): void}[]) { + runTest('MimeTypes', async () => { + // Test all mime types together so we don't have to startup and shutdown between + // each const server = await jupyterServer.start(); if (!server) { assert.fail('Server not created'); @@ -53,23 +134,175 @@ suite('Jupyter notebook tests', () => { jupyterServer.onStatusChanged((bool: boolean) => { statusCount += 1; }); - const cells = await jupyterServer.execute('a = 1\r\na', 'foo.py', 2); - assert.equal(cells.length, 1, 'Wrong number of cells returned'); - assert.equal(cells[0].data.cell_type, 'code', 'Wrong type of cell returned'); - const cell = cells[0].data as nbformat.ICodeCell; - assert.equal(cell.outputs.length, 1, 'Cell length not correct'); - const data = cell.outputs[0].data; - assert.ok(data, 'No data object on the cell'); - if (data) { // For linter - assert.ok(data.hasOwnProperty('text/plain'), 'Cell mime type not correct'); - assert.ok(data['text/plain'], 'Cell mime type not correct'); - assert.equal(data['text/plain'], '1', 'Cell not correct'); - assert.ok(statusCount >= 2, 'Status wasnt updated'); + for (let i = 0; i < types.length; i += 1) { + const prevCount = statusCount; + await verifyCell(i, types[i].code, types[i].mimeType, types[i].cellType, types[i].verifyValue); + if (types[i].cellType !== 'markdown') { + assert.ok(statusCount > prevCount, 'Status didnt update'); + } } - } else { - // tslint:disable-next-line:no-console - console.log('Execution test skipped, no Jupyter installed'); + }); + } + + function runTest(name: string, func: () => Promise) { + test(name, async () => { + if (await availability.isNotebookSupported()) { + return func(); + } else { + // tslint:disable-next-line:no-console + console.log(`Skipping test ${name}, no jupyter installed.`); + } + }); + } + + runTest('Creation', async () => { + const server = await jupyterServer.start(); + if (!server) { + assert.fail('Server not created'); } - }).timeout(60000); + }); + + runTest('Failure', async () => { + jupyterServer.shutdown().ignoreErrors(); + // Make a dummy class that will fail during launch + class FailedProcess extends JupyterProcess { + public waitForConnectionInformation() : Promise { + return Promise.reject('Failing'); + } + } + ioc.serviceManager.rebind(INotebookProcess, FailedProcess); + jupyterServer = ioc.serviceManager.get(INotebookServer); + return assertThrows(async () => { + await jupyterServer.start(); + }, 'Server start is not throwing'); + }); + + test('Not installed', async () => { + jupyterServer.shutdown().ignoreErrors(); + // Make a dummy class that will fail during launch + class FailedAvailability extends JupyterAvailability { + public isNotebookSupported = () : Promise => { + return Promise.resolve(false); + } + } + ioc.serviceManager.rebind(IJupyterAvailability, FailedAvailability); + jupyterServer = ioc.serviceManager.get(INotebookServer); + return assertThrows(async () => { + await jupyterServer.start(); + }, 'Server start is not throwing'); + }); + + runTest('Export/Import', async () => { + const server = await jupyterServer.start(); + if (!server) { + assert.fail('Server not created'); + } + + // Get a bunch of test cells (use our test cells from the react controls) + const testState = generateTestState(id => { return; }); + const cells = testState.cellVMs.map((cellVM: ICellViewModel, index: number) => { return cellVM.cell; }); + + // Translate this into a notebook + const notebook = await jupyterServer.translateToNotebook(cells); + + // Save to a temp file + const fileSystem = ioc.serviceManager.get(IFileSystem); + const importer = ioc.serviceManager.get(INotebookImporter); + const temp = await fileSystem.createTemporaryFile('.ipynb'); + try { + await fs.writeFile(temp.filePath, JSON.stringify(notebook), 'utf8'); + // Try importing this. This should verify export works and that importing is possible + await importer.importFromFile(temp.filePath); + } finally { + importer.dispose(); + temp.dispose(); + } + }); + + runTest('Restart kernel', async () => { + const server = await jupyterServer.start(); + if (!server) { + assert.fail('Server not created'); + } + + // Setup some state and verify output is correct + await verifySimple('a=1\r\na', 1); + await verifySimple('a+=1\r\na', 2); + await verifySimple('a+=4\r\na', 6); + + await jupyterServer.restartKernel(); + + await verifyError('a', `name 'a' is not defined`); + }); + + testMimeTypes( + [ + { + code: + `a=1 +a`, + mimeType: 'text/plain', + cellType: 'code', + verifyValue: (d) => assert.equal(d, 1, 'Plain text invalid') + }, + { + code: + `df = pd.read_csv("${escapePath(path.join(srcDirectory(), 'DefaultSalesReport.csv'))}") +df.head()`, + mimeType: 'text/html', + cellType: 'code', + verifyValue: (d) => assert.ok(d.toString().includes(''), 'Table not found') + }, + { + code: + `df = pd.read("${escapePath(path.join(srcDirectory(), 'DefaultSalesReport.csv'))}") +df.head()`, + mimeType: 'text/html', + cellType: 'error', + verifyValue: (d) => assert.equal(d, `module 'pandas' has no attribute 'read'`, 'Unexpected error result') + }, + { + code: + `#%% [markdown]# +# #HEADER`, + mimeType: 'text/plain', + cellType: 'markdown', + verifyValue: (d) => assert.equal(d, '#HEADER', 'Markdown incorrect') + }, + { + // Test relative directories too. + code: + `df = pd.read_csv("./DefaultSalesReport.csv") +df.head()`, + mimeType: 'text/html', + cellType: 'code', + verifyValue: (d) => assert.ok(d.toString().includes(''), 'Table not found') + }, + { + // Plotly + code: + `import matplotlib.pyplot as plt +import matplotlib as mpl +import numpy as np +import pandas as pd +x = np.linspace(0, 20, 100) +plt.plot(x, np.sin(x)) +plt.show()`, + mimeType: 'image/png', + cellType: 'code', + verifyValue: (d) => { return; } + } + ] + ); + // Tests that should be running: + // - Creation + // - Failure + // - Not installed + // - Different mime types + // - Export/import + // - Auto import + // - changing directories + // - Restart + // - Error types }); diff --git a/src/test/functionalTests.ts b/src/test/functionalTests.ts index 67e7a53da30f..9ce1699532e2 100644 --- a/src/test/functionalTests.ts +++ b/src/test/functionalTests.ts @@ -17,7 +17,7 @@ process.env.VSC_PYTHON_UNIT_TEST = '1'; // This is checked to make tests run fas // this allows us to run hygiene as a git pre-commit hook or via debugger. if (require.main === module) { // When running from debugger, allow custom args. - const args = extractParams(); + const args = extractParams(60000); runTests({ filePattern: '**/**.functional.test.js', grep: args.grep, timeout: args.timeout }); } diff --git a/src/test/nonUiTests.ts b/src/test/nonUiTests.ts index e732377b2097..491110e43ee2 100644 --- a/src/test/nonUiTests.ts +++ b/src/test/nonUiTests.ts @@ -113,12 +113,12 @@ function reportErrors(error?: Error, failures?: number) { process.exit(1); } } -export function extractParams() : { grep: string; timeout: number } { +export function extractParams(defaultTimeout?: number) : { grep: string; timeout: number } { // When running from debugger, allow custom args. const args = process.argv0.length > 2 ? process.argv.slice(2) : []; const timeoutArgIndex = args.findIndex(arg => arg.startsWith('timeout=')); const grepArgIndex = args.findIndex(arg => arg.startsWith('grep=')); - const timeout: number | undefined = timeoutArgIndex >= 0 ? parseInt(args[timeoutArgIndex].split('=')[1].trim(), 10) : undefined; + const timeout: number | undefined = timeoutArgIndex >= 0 ? parseInt(args[timeoutArgIndex].split('=')[1].trim(), 10) : defaultTimeout; let grep: string | undefined = grepArgIndex >= 0 ? args[grepArgIndex].split('=')[1].trim() : undefined; grep = grep && grep.length > 0 ? grep : undefined; return { grep: grep, timeout: timeout }; diff --git a/src/test/testRunner.ts b/src/test/testRunner.ts index 783873a6a1c1..55c2bb60146e 100644 --- a/src/test/testRunner.ts +++ b/src/test/testRunner.ts @@ -1,236 +1,236 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -// tslint:disable:no-require-imports no-var-requires import-name no-function-expression no-any prefer-template no-console no-var-self -// Most of the source is in node_modules/vscode/lib/testrunner.js - -'use strict'; -import * as fs from 'fs-extra'; -import * as glob from 'glob'; -import * as istanbul from 'istanbul'; -import * as Mocha from 'mocha'; -import * as path from 'path'; -import { MochaSetupOptions } from 'vscode/lib/testrunner'; -const remapIstanbul = require('remap-istanbul'); -import { setUpDomEnvironment } from './datascience/reactHelpers'; - -interface ITestRunnerOptions { - enabled?: boolean; - relativeCoverageDir: string; - relativeSourcePath: string; - ignorePatterns: string[]; - includePid?: boolean; - reports?: string[]; - verbose?: boolean; -} - -// http://gotwarlost.github.io/istanbul/public/apidocs/files/lib_instrumenter.js.html#l478. -type CoverState = { - path: string; - s: {}; - b: {}; - f: {}; - fnMap: {}; - statementMap: {}; - branchMap: {}; -}; - -type Instrumenter = istanbul.Instrumenter & { coverState: CoverState }; -type TestCallback = (error?: Error, failures?: number) => void; - -// Linux: prevent a weird NPE when mocha on Linux requires the window size from the TTY. -// Since we are not running in a tty environment, we just implement the method statically. -const tty = require('tty'); -if (!tty.getWindowSize) { - tty.getWindowSize = function (): number[] { return [80, 75]; }; -} - -let mocha = new Mocha({ - ui: 'tdd', - useColors: true -}); - -export type SetupOptions = MochaSetupOptions & { - testFilesSuffix?: string; - reporter?: string; - reporterOptions?: { - mochaFile?: string; - properties?: string; - }; -}; - -let testFilesGlob = 'test'; -let coverageOptions: { coverageConfig: string } | undefined; - -export function configure(setupOptions: SetupOptions, coverageOpts?: { coverageConfig: string }): void { - if (setupOptions.testFilesSuffix) { - testFilesGlob = setupOptions.testFilesSuffix; - } - mocha = new Mocha(setupOptions); - coverageOptions = coverageOpts; -} - -export function run(testsRoot: string, callback: TestCallback): void { - // Enable source map support. - require('source-map-support').install(); - - // nteract/transforms-full expects to run in the browser so we have to fake - // parts of the browser here. - setUpDomEnvironment(); - - // Check whether code coverage is enabled. - const options = getCoverageOptions(testsRoot); - if (options && options.enabled) { - // Setup coverage pre-test, including post-test hook to report. - // tslint:disable-next-line:no-use-before-declare - const coverageRunner = new CoverageRunner(options, testsRoot, callback); - coverageRunner.setupCoverage(); - } - - // Run the tests. - glob(`**/**.${testFilesGlob}.js`, { ignore: ['**/**.unit.test.js', '**/**.functional.test.js'], cwd: testsRoot }, (error, files) => { - if (error) { - return callback(error); - } - try { - files.forEach(file => mocha.addFile(path.join(testsRoot, file))); - mocha.run((failures) => callback(undefined, failures)); - } catch (error) { - return callback(error); - } - }); -} - -function getCoverageOptions(testsRoot: string): ITestRunnerOptions | undefined { - if (!coverageOptions) { - return undefined; - } - const coverConfigPath = path.join(testsRoot, coverageOptions.coverageConfig); - return fs.existsSync(coverConfigPath) ? JSON.parse(fs.readFileSync(coverConfigPath, 'utf8')) : undefined; -} - -class CoverageRunner { - private coverageVar: string = `$$cov_${new Date().getTime()}$$`; - private sourceFiles: string[] = []; - private instrumenter!: Instrumenter; - - private get coverage(): { [key: string]: CoverState } { - if (global[this.coverageVar] === undefined || Object.keys(global[this.coverageVar]).length === 0) { - console.error('No coverage information was collected, exit without writing coverage information'); - return {}; - } else { - return global[this.coverageVar]; - } - } - private set coverage(value: { [key: string]: CoverState }) { - global[this.coverageVar] = value; - } - - constructor(private options: ITestRunnerOptions, private testsRoot: string, endRunCallback: TestCallback) { - if (!options.relativeSourcePath) { - endRunCallback(new Error('Error - relativeSourcePath must be defined for code coverage to work')); - } - } - /** - * Information on hooking up code coverage can be found here: - * http://tannguyen.org/2017/04/gulp-mocha-and-istanbul/ - * http://gotwarlost.github.io/istanbul/public/apidocs/classes/HookOptions.html - * @memberof CoverageRunner - */ - public setupCoverage(): void { - const reportingDir = path.join(this.testsRoot, this.options.relativeCoverageDir); - fs.emptyDirSync(reportingDir); - - // Set up Code Coverage, hooking require so that instrumented code is returned. - this.instrumenter = new istanbul.Instrumenter({ coverageVariable: this.coverageVar }) as Instrumenter; - const sourceRoot = path.join(this.testsRoot, this.options.relativeSourcePath); - - // Glob source files - const srcFiles = glob.sync('**/**.js', { - ignore: this.options.ignorePatterns, - cwd: sourceRoot - }); - - // Create a match function - taken from the run-with-cover.js in istanbul. - const decache = require('decache'); - const fileMap = new Set(); - srcFiles - .map(file => path.join(sourceRoot, file)) - .forEach(fullPath => { - fileMap.add(fullPath); - - // On Windows, extension is loaded pre-test hooks and this mean we lose - // our chance to hook the Require call. In order to instrument the code - // we have to decache the JS file so on next load it gets instrumented. - // This doesn't impact tests, but is a concern if we had some integration - // tests that relied on VSCode accessing our module since there could be - // some shared global state that we lose. - decache(fullPath); - }); - - const matchFn = (file: string) => fileMap.has(file); - this.sourceFiles = Array.from(fileMap.keys()); - - // http://gotwarlost.github.io/istanbul/public/apidocs/classes/Hook.html#method_hookRequire. - // Hook up to the Require function so that when this is called, if any of our source files - // are required, the instrumented version is pulled in instead. These instrumented versions - // write to a global coverage variable with hit counts whenever they are accessed. - const transformer = this.instrumenter.instrumentSync.bind(this.instrumenter); - const hookOpts = { verbose: false, extensions: ['.js'] }; - (istanbul.hook).hookRequire(matchFn, transformer, hookOpts); - - // Initialize the global variable to store instrumentation details. - // http://gotwarlost.github.io/istanbul/public/apidocs/classes/Instrumenter.html. - this.coverage = {}; - - // Hook the process exit event to handle reporting, - // Only report coverage if the process is exiting successfully. - process.on('exit', () => this.reportCoverage()); - } - - /** - * Writes a coverage report. Note that as this is called in the process exit callback, all calls must be synchronous. - * @returns {void} - * @memberOf CoverageRunner - */ - public reportCoverage(): void { - (istanbul.hook).unhookRequire(); - const coverage = this.coverage; - - // Files that are not touched by code ran by the test runner is manually instrumented, to - // illustrate the missing coverage. - this.sourceFiles - .filter(file => !coverage[file]) - .forEach(file => { - this.instrumenter.instrumentSync(fs.readFileSync(file, 'utf-8'), file); - - // When instrumenting the code, istanbul will give each FunctionDeclaration a value of 1 in coverState.s, - // presumably to compensate for function hoisting. We need to reset this, as the function was not hoisted, - // as it was never loaded. - Object.keys(this.instrumenter.coverState.s).forEach(key => this.instrumenter.coverState.s[key] = 0); - - coverage[file] = this.instrumenter.coverState; - }); - - const reportingDir = path.join(this.testsRoot, this.options.relativeCoverageDir); - const coverageFile = path.join(reportingDir, 'coverage.json'); - - fs.mkdirsSync(reportingDir); - fs.writeFileSync(coverageFile, JSON.stringify(coverage), 'utf8'); - - const remappedCollector: istanbul.Collector = remapIstanbul.remap(coverage, { - warn: warning => { - // We expect some warnings as any JS file without a typescript mapping will cause this. - // By default, we'll skip printing these to the console as it clutters it up. - if (this.options.verbose) { - console.warn(warning); - } - } - }); - - const reporter = new istanbul.Reporter(undefined, reportingDir); - const reportTypes = Array.isArray(this.options.reports) ? this.options.reports! : ['lcov']; - reporter.addAll(reportTypes); - reporter.write(remappedCollector, true, () => console.log(`reports written to ${reportingDir}`)); - } -} +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// tslint:disable:no-require-imports no-var-requires import-name no-function-expression no-any prefer-template no-console no-var-self +// Most of the source is in node_modules/vscode/lib/testrunner.js + +'use strict'; +import * as fs from 'fs-extra'; +import * as glob from 'glob'; +import * as istanbul from 'istanbul'; +import * as Mocha from 'mocha'; +import * as path from 'path'; +import { MochaSetupOptions } from 'vscode/lib/testrunner'; +const remapIstanbul = require('remap-istanbul'); +import { setUpDomEnvironment } from './datascience/reactHelpers'; + +interface ITestRunnerOptions { + enabled?: boolean; + relativeCoverageDir: string; + relativeSourcePath: string; + ignorePatterns: string[]; + includePid?: boolean; + reports?: string[]; + verbose?: boolean; +} + +// http://gotwarlost.github.io/istanbul/public/apidocs/files/lib_instrumenter.js.html#l478. +type CoverState = { + path: string; + s: {}; + b: {}; + f: {}; + fnMap: {}; + statementMap: {}; + branchMap: {}; +}; + +type Instrumenter = istanbul.Instrumenter & { coverState: CoverState }; +type TestCallback = (error?: Error, failures?: number) => void; + +// Linux: prevent a weird NPE when mocha on Linux requires the window size from the TTY. +// Since we are not running in a tty environment, we just implement the method statically. +const tty = require('tty'); +if (!tty.getWindowSize) { + tty.getWindowSize = function (): number[] { return [80, 75]; }; +} + +let mocha = new Mocha({ + ui: 'tdd', + useColors: true +}); + +export type SetupOptions = MochaSetupOptions & { + testFilesSuffix?: string; + reporter?: string; + reporterOptions?: { + mochaFile?: string; + properties?: string; + }; +}; + +let testFilesGlob = 'test'; +let coverageOptions: { coverageConfig: string } | undefined; + +export function configure(setupOptions: SetupOptions, coverageOpts?: { coverageConfig: string }): void { + if (setupOptions.testFilesSuffix) { + testFilesGlob = setupOptions.testFilesSuffix; + } + mocha = new Mocha(setupOptions); + coverageOptions = coverageOpts; +} + +export function run(testsRoot: string, callback: TestCallback): void { + // Enable source map support. + require('source-map-support').install(); + + // nteract/transforms-full expects to run in the browser so we have to fake + // parts of the browser here. + setUpDomEnvironment(); + + // Check whether code coverage is enabled. + const options = getCoverageOptions(testsRoot); + if (options && options.enabled) { + // Setup coverage pre-test, including post-test hook to report. + // tslint:disable-next-line:no-use-before-declare + const coverageRunner = new CoverageRunner(options, testsRoot, callback); + coverageRunner.setupCoverage(); + } + + // Run the tests. + glob(`**/**.${testFilesGlob}.js`, { ignore: ['**/**.unit.test.js', '**/**.functional.test.js'], cwd: testsRoot }, (error, files) => { + if (error) { + return callback(error); + } + try { + files.forEach(file => mocha.addFile(path.join(testsRoot, file))); + mocha.run((failures) => callback(undefined, failures)); + } catch (error) { + return callback(error); + } + }); +} + +function getCoverageOptions(testsRoot: string): ITestRunnerOptions | undefined { + if (!coverageOptions) { + return undefined; + } + const coverConfigPath = path.join(testsRoot, coverageOptions.coverageConfig); + return fs.existsSync(coverConfigPath) ? JSON.parse(fs.readFileSync(coverConfigPath, 'utf8')) : undefined; +} + +class CoverageRunner { + private coverageVar: string = `$$cov_${new Date().getTime()}$$`; + private sourceFiles: string[] = []; + private instrumenter!: Instrumenter; + + private get coverage(): { [key: string]: CoverState } { + if (global[this.coverageVar] === undefined || Object.keys(global[this.coverageVar]).length === 0) { + console.error('No coverage information was collected, exit without writing coverage information'); + return {}; + } else { + return global[this.coverageVar]; + } + } + private set coverage(value: { [key: string]: CoverState }) { + global[this.coverageVar] = value; + } + + constructor(private options: ITestRunnerOptions, private testsRoot: string, endRunCallback: TestCallback) { + if (!options.relativeSourcePath) { + endRunCallback(new Error('Error - relativeSourcePath must be defined for code coverage to work')); + } + } + /** + * Information on hooking up code coverage can be found here: + * http://tannguyen.org/2017/04/gulp-mocha-and-istanbul/ + * http://gotwarlost.github.io/istanbul/public/apidocs/classes/HookOptions.html + * @memberof CoverageRunner + */ + public setupCoverage(): void { + const reportingDir = path.join(this.testsRoot, this.options.relativeCoverageDir); + fs.emptyDirSync(reportingDir); + + // Set up Code Coverage, hooking require so that instrumented code is returned. + this.instrumenter = new istanbul.Instrumenter({ coverageVariable: this.coverageVar }) as Instrumenter; + const sourceRoot = path.join(this.testsRoot, this.options.relativeSourcePath); + + // Glob source files + const srcFiles = glob.sync('**/**.js', { + ignore: this.options.ignorePatterns, + cwd: sourceRoot + }); + + // Create a match function - taken from the run-with-cover.js in istanbul. + const decache = require('decache'); + const fileMap = new Set(); + srcFiles + .map(file => path.join(sourceRoot, file)) + .forEach(fullPath => { + fileMap.add(fullPath); + + // On Windows, extension is loaded pre-test hooks and this mean we lose + // our chance to hook the Require call. In order to instrument the code + // we have to decache the JS file so on next load it gets instrumented. + // This doesn't impact tests, but is a concern if we had some integration + // tests that relied on VSCode accessing our module since there could be + // some shared global state that we lose. + decache(fullPath); + }); + + const matchFn = (file: string) => fileMap.has(file); + this.sourceFiles = Array.from(fileMap.keys()); + + // http://gotwarlost.github.io/istanbul/public/apidocs/classes/Hook.html#method_hookRequire. + // Hook up to the Require function so that when this is called, if any of our source files + // are required, the instrumented version is pulled in instead. These instrumented versions + // write to a global coverage variable with hit counts whenever they are accessed. + const transformer = this.instrumenter.instrumentSync.bind(this.instrumenter); + const hookOpts = { verbose: false, extensions: ['.js'] }; + (istanbul.hook).hookRequire(matchFn, transformer, hookOpts); + + // Initialize the global variable to store instrumentation details. + // http://gotwarlost.github.io/istanbul/public/apidocs/classes/Instrumenter.html. + this.coverage = {}; + + // Hook the process exit event to handle reporting, + // Only report coverage if the process is exiting successfully. + process.on('exit', () => this.reportCoverage()); + } + + /** + * Writes a coverage report. Note that as this is called in the process exit callback, all calls must be synchronous. + * @returns {void} + * @memberOf CoverageRunner + */ + public reportCoverage(): void { + (istanbul.hook).unhookRequire(); + const coverage = this.coverage; + + // Files that are not touched by code ran by the test runner is manually instrumented, to + // illustrate the missing coverage. + this.sourceFiles + .filter(file => !coverage[file]) + .forEach(file => { + this.instrumenter.instrumentSync(fs.readFileSync(file, 'utf-8'), file); + + // When instrumenting the code, istanbul will give each FunctionDeclaration a value of 1 in coverState.s, + // presumably to compensate for function hoisting. We need to reset this, as the function was not hoisted, + // as it was never loaded. + Object.keys(this.instrumenter.coverState.s).forEach(key => this.instrumenter.coverState.s[key] = 0); + + coverage[file] = this.instrumenter.coverState; + }); + + const reportingDir = path.join(this.testsRoot, this.options.relativeCoverageDir); + const coverageFile = path.join(reportingDir, 'coverage.json'); + + fs.mkdirsSync(reportingDir); + fs.writeFileSync(coverageFile, JSON.stringify(coverage), 'utf8'); + + const remappedCollector: istanbul.Collector = remapIstanbul.remap(coverage, { + warn: warning => { + // We expect some warnings as any JS file without a typescript mapping will cause this. + // By default, we'll skip printing these to the console as it clutters it up. + if (this.options.verbose) { + console.warn(warning); + } + } + }); + + const reporter = new istanbul.Reporter(undefined, reportingDir); + const reportTypes = Array.isArray(this.options.reports) ? this.options.reports! : ['lcov']; + reporter.addAll(reportTypes); + reporter.write(remappedCollector, true, () => console.log(`reports written to ${reportingDir}`)); + } +} diff --git a/src/test/unittests.ts b/src/test/unittests.ts index a1895714b551..c4aab3d6d0a2 100644 --- a/src/test/unittests.ts +++ b/src/test/unittests.ts @@ -1,22 +1,22 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -'use strict'; - -// tslint:disable:no-any no-require-imports no-var-requires - -if ((Reflect as any).metadata === undefined) { - require('reflect-metadata'); -} - -import { extractParams, runTests } from './nonUiTests'; - -process.env.VSC_PYTHON_CI_TEST = '1'; -process.env.VSC_PYTHON_UNIT_TEST = '1'; - -// this allows us to run hygiene as a git pre-commit hook or via debugger. -if (require.main === module) { - // When running from debugger, allow custom args. - const args = extractParams(); - - runTests({ filePattern: '**/**.unit.test.js', grep: args.grep, timeout: args.timeout }); -} +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +'use strict'; + +// tslint:disable:no-any no-require-imports no-var-requires + +if ((Reflect as any).metadata === undefined) { + require('reflect-metadata'); +} + +import { extractParams, runTests } from './nonUiTests'; + +process.env.VSC_PYTHON_CI_TEST = '1'; +process.env.VSC_PYTHON_UNIT_TEST = '1'; + +// this allows us to run hygiene as a git pre-commit hook or via debugger. +if (require.main === module) { + // When running from debugger, allow custom args. + const args = extractParams(); + + runTests({ filePattern: '**/**.unit.test.js', grep: args.grep, timeout: args.timeout }); +}