diff --git a/appveyor.yml b/appveyor.yml index eb24616..8ea7954 100755 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,7 +1,20 @@ init: - git config --global core.autocrlf true build_script: - - build.cmd verify + - npm install -g npm@^3.0.0 + - npm --prefix templates/package-builder install + - npm --prefix templates/package-builder run build +# - build.cmd verify clone_depth: 1 -test: off +test_script: + - dotnet restore ./src + - npm install -g selenium-standalone + - selenium-standalone install + # The nosys flag is needed for selenium to work on Appveyor + - ps: Start-Process selenium-standalone 'start','--','-Djna.nosys=true' + - npm --prefix test install + - npm --prefix test test +on_finish : + # After running tests, upload results to Appveyor + - ps: (new-object net.webclient).UploadFile("https://ci.appveyor.com/api/testresults/junit/$($env:APPVEYOR_JOB_ID)", (Resolve-Path .\test\tmp\junit\*.xml)) deploy: off diff --git a/test/.gitignore b/test/.gitignore new file mode 100644 index 0000000..0c81bf9 --- /dev/null +++ b/test/.gitignore @@ -0,0 +1,3 @@ +/node_modules/ +/tmp/ +/yarn.lock diff --git a/test/package.json b/test/package.json new file mode 100644 index 0000000..3470f6c --- /dev/null +++ b/test/package.json @@ -0,0 +1,33 @@ +{ + "name": "test", + "version": "1.0.0", + "description": "Integration tests for the templates in JavaScriptServices. This is not really an NPM package and will not be published.", + "main": "index.js", + "scripts": { + "test": "tsc && wdio" + }, + "author": "Microsoft", + "license": "Apache-2.0", + "dependencies": { + "@types/chai": "^3.4.34", + "@types/mkdirp": "^0.3.29", + "@types/mocha": "^2.2.33", + "@types/node": "^6.0.52", + "@types/rimraf": "^0.0.28", + "@types/webdriverio": "^4.0.32", + "chai": "^3.5.0", + "cross-spawn": "^5.0.1", + "mkdirp": "^0.5.1", + "rimraf": "^2.5.4", + "selenium-standalone": "^5.9.0", + "tree-kill": "^1.1.0", + "typescript": "^2.1.4", + "webdriverio": "^4.5.0", + "yo": "^1.8.5" + }, + "devDependencies": { + "wdio-junit-reporter": "^0.2.0", + "wdio-mocha-framework": "^0.5.7", + "wdio-selenium-standalone-service": "0.0.7" + } +} diff --git a/test/templates/angular.spec.ts b/test/templates/angular.spec.ts new file mode 100644 index 0000000..a79b1e8 --- /dev/null +++ b/test/templates/angular.spec.ts @@ -0,0 +1,96 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { expect } from 'chai'; +import { generateProjectSync } from './util/yeoman'; +import { AspNetProcess, AspNetCoreEnviroment, defaultUrl, publishProjectSync } from './util/aspnet'; +import { getValue, getCssPropertyValue } from './util/webdriverio'; + +// First, generate a new project using the locally-built generator-aspnetcore-spa +// Do this outside the Mocha fixture, otherwise Mocha will time out +const appDir = path.resolve(__dirname, '../generated/angular'); +const publishedAppDir = path.resolve(appDir, './bin/Release/published'); +if (!process.env.SKIP_PROJECT_GENERATION) { + generateProjectSync(appDir, { framework: 'angular-2', name: 'Test App', tests: false }); + publishProjectSync(appDir, publishedAppDir); +} + +function testBasicNavigation() { + describe('Basic navigation', () => { + beforeEach(() => browser.url(defaultUrl)); + + it('should initially display the home page', () => { + expect(browser.getText('h1')).to.eq('Hello, world!'); + expect(browser.getText('li a[href="https://angular.io/"]')).to.eq('Angular 2'); + }); + + it('should be able to show the counter page', () => { + browser.click('a[href="/counter"]'); + expect(browser.getText('h1')).to.eq('Counter'); + + // Test clicking the 'increment' button + expect(browser.getText('counter strong')).to.eq('0'); + browser.click('counter button'); + expect(browser.getText('counter strong')).to.eq('1'); + }); + + it('should be able to show the fetchdata page', () => { + browser.click('a[href="/fetch-data"]'); + expect(browser.getText('h1')).to.eq('Weather forecast'); + + browser.waitForExist('fetchdata table'); + expect(getValue(browser.elements('fetchdata table tbody tr')).length).to.eq(5); + }); + }); +} + +function testHotModuleReplacement() { + describe('Hot module replacement', () => { + beforeEach(() => browser.url(defaultUrl)); + + it('should update when HTML is changed', () => { + expect(browser.getText('h1')).to.eq('Hello, world!'); + + const filePath = path.resolve(appDir, './ClientApp/app/components/home/home.component.html'); + const origFileContents = fs.readFileSync(filePath, 'utf8'); + + try { + const newFileContents = origFileContents.replace('

Hello, world!

', '

HMR is working

'); + fs.writeFileSync(filePath, newFileContents, { encoding: 'utf8' }); + + browser.waitUntil(() => browser.getText('h1').toString() === 'HMR is working'); + } finally { + // Restore old contents so that other tests don't have to account for this + fs.writeFileSync(filePath, origFileContents, { encoding: 'utf8' }); + } + }); + + it('should update when CSS is changed', () => { + expect(getCssPropertyValue(browser, 'li.link-active a', 'color')).to.eq('rgba(255,255,255,1)'); + + const filePath = path.resolve(appDir, './ClientApp/app/components/navmenu/navmenu.component.css'); + const origFileContents = fs.readFileSync(filePath, 'utf8'); + + try { + const newFileContents = origFileContents.replace('color: white;', 'color: purple;'); + fs.writeFileSync(filePath, newFileContents, { encoding: 'utf8' }); + + browser.waitUntil(() => getCssPropertyValue(browser, 'li.link-active a', 'color') === 'rgba(128,0,128,1)'); + } finally { + // Restore old contents so that other tests don't have to account for this + fs.writeFileSync(filePath, origFileContents, { encoding: 'utf8' }); + } + }); + }); +} + +// Now launch dotnet and use selenium to perform tests +describe('Angular template: dev mode', () => { + AspNetProcess.RunInMochaContext(appDir, AspNetCoreEnviroment.development); + testBasicNavigation(); + testHotModuleReplacement(); +}); + +describe('Angular template: production mode', () => { + AspNetProcess.RunInMochaContext(publishedAppDir, AspNetCoreEnviroment.production, 'angular.dll'); + testBasicNavigation(); +}); diff --git a/test/templates/util/aspnet.ts b/test/templates/util/aspnet.ts new file mode 100644 index 0000000..5286d81 --- /dev/null +++ b/test/templates/util/aspnet.ts @@ -0,0 +1,102 @@ +import * as childProcess from 'child_process'; +import * as path from 'path'; +import * as readline from 'readline'; +const treeKill = require('tree-kill'); +const crossSpawn: typeof childProcess.spawn = require('cross-spawn'); + +export const defaultUrl = 'http://localhost:5000'; + +export enum AspNetCoreEnviroment { + development, + production +} + +export class AspNetProcess { + public static RunInMochaContext(cwd: string, mode: AspNetCoreEnviroment, dllToRun?: string) { + // Set up mocha before/after callbacks so that a 'dotnet run' process exists + // for the same duration as the context this is called inside + let aspNetProcess: AspNetProcess; + before(() => { + aspNetProcess = new AspNetProcess(cwd, mode, dllToRun); + return aspNetProcess.waitUntilListening(); + }); + after(() => aspNetProcess.dispose()); + } + + private _process: childProcess.ChildProcess; + private _processHasExited: boolean; + private _stdoutReader: readline.ReadLine; + + constructor(cwd: string, mode: AspNetCoreEnviroment, dllToRun?: string) { + try { + // Prepare env for child process. Note that it doesn't inherit parent's env vars automatically, + // hence cloning process.env. + const childProcessEnv = Object.assign({}, process.env); + childProcessEnv.ASPNETCORE_ENVIRONMENT = mode === AspNetCoreEnviroment.development ? 'Development' : 'Production'; + + const verbOrAssembly = dllToRun || 'run'; + console.log(`Running 'dotnet ${ verbOrAssembly }' in ${ cwd }`); + this._process = crossSpawn('dotnet', [verbOrAssembly], { cwd: cwd, stdio: 'pipe', env: childProcessEnv }); + this._stdoutReader = readline.createInterface(this._process.stdout, null); + + // Echo stdout to the test process's own stdout + this._stdoutReader.on('line', line => { + console.log(`[dotnet] ${ line.toString() }`); + }); + + // Also echo stderr + this._process.stderr.on('data', chunk => { + console.log(`[dotnet ERROR] ${ chunk.toString() }`); + }); + + // Ensure the process isn't orphaned even if Node crashes before we're disposed + process.on('exit', () => this._killProcessSync()); + + // Also track whether it exited on its own already + this._process.on('exit', () => { + this._processHasExited = true; + }); + } catch(ex) { + console.log('ERROR: ' + ex.toString()); + throw ex; + } + } + + public waitUntilListening(): Promise { + return new Promise((resolve, reject) => { + this._stdoutReader.on('line', (line: string) => { + if (line.startsWith('Now listening on:')) { + resolve(); + } + }); + }); + } + + public dispose(): Promise { + return new Promise((resolve, reject) => { + this._killProcessSync(err => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } + + private _killProcessSync(callback?: (err: any) => void) { + if (!this._processHasExited) { + // It's important to kill the whole tree, because 'dotnet run' launches a separate 'dotnet exec' + // child process that would otherwise be left running + treeKill(this._process.pid, 'SIGINT', callback); + } + } +} + +export function publishProjectSync(sourceDir: string, outputDir: string) { + childProcess.execSync(`dotnet publish -c Release -o ${ outputDir }`, { + cwd: sourceDir, + stdio: 'inherit', + encoding: 'utf8' + }); +} diff --git a/test/templates/util/webdriverio.ts b/test/templates/util/webdriverio.ts new file mode 100644 index 0000000..e2846ac --- /dev/null +++ b/test/templates/util/webdriverio.ts @@ -0,0 +1,12 @@ +// Workaround for missing '.value' property on WebdriverIO.Client> that should be of type T +// Can't notify TypeScript that the property exists directly, because the interface merging feature doesn't +// appear to support pattern matching in such a way that WebdriverIO.Client is extended only when T +// itself extends RawResult for some U. +export function getValue(client: WebdriverIO.Client>): T { + return (client as any).value; +} + +// The official type declarations for getCssProperty are completely wrong. This function matches runtime behaviour. +export function getCssPropertyValue(client: WebdriverIO.Client, selector: string, cssProperty: string): string { + return (client.getCssProperty(selector, cssProperty) as any).value; +} diff --git a/test/templates/util/yeoman.ts b/test/templates/util/yeoman.ts new file mode 100644 index 0000000..12177fc --- /dev/null +++ b/test/templates/util/yeoman.ts @@ -0,0 +1,52 @@ +import * as childProcess from 'child_process'; +import * as path from 'path'; +import * as rimraf from 'rimraf'; +import * as mkdirp from 'mkdirp'; + +const generatorDirRelative = '../templates/package-builder/dist/generator-aspnetcore-spa'; +const yoPackageDirAbsolute = path.resolve('./node_modules/yo'); + +export interface GeneratorOptions { + framework: string; + name: string; + tests?: boolean; +} + +export function generateProjectSync(targetDir: string, generatorOptions: GeneratorOptions) { + const generatorDirAbsolute = path.resolve(generatorDirRelative); + console.log(`Running NPM install to prepare Yeoman generator at ${ generatorDirAbsolute }`); + childProcess.execSync(`npm install`, { stdio: 'inherit', cwd: generatorDirAbsolute }); + + console.log(`Ensuring empty output directory at ${ targetDir }`); + rimraf.sync(targetDir); + mkdirp.sync(targetDir); + + const yoExecutableAbsolute = findYeomanCliScript(); + console.log(`Will invoke Yeoman at ${ yoExecutableAbsolute } to generate application in ${ targetDir } with options:`); + console.log(JSON.stringify(generatorOptions, null, 2)); + const command = `node "${ yoExecutableAbsolute }" "${ path.resolve(generatorDirAbsolute, './app/index.js') }"`; + const args = makeYeomanCommandLineArgs(generatorOptions); + childProcess.execSync(`${ command } ${ args }`, { + stdio: 'inherit', + cwd: targetDir + }); +} + +function findYeomanCliScript() { + // On Windows, you can't invoke ./node_modules/.bin/yo from the shell for some reason. + // So instead, we'll locate the CLI entrypoint that yeoman would expose if it was installed globally. + const yeomanPackageJsonPath = path.join(yoPackageDirAbsolute, './package.json'); + const yeomanPackageJson = require(yeomanPackageJsonPath); + const yeomanCliScriptRelative = yeomanPackageJson.bin.yo; + if (!yeomanCliScriptRelative) { + throw new Error(`Could not find Yeoman CLI script. Looked for a bin/yo entry in ${ yeomanPackageJsonPath }`); + } + + return path.join(yoPackageDirAbsolute, yeomanCliScriptRelative); +} + +function makeYeomanCommandLineArgs(generatorOptions: GeneratorOptions) { + return Object.getOwnPropertyNames(generatorOptions) + .map(key => `--${ key }="${ generatorOptions[key] }"`) + .join(' '); +} diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..168845f --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "moduleResolution": "node", + "target": "es5", + "rootDir": ".", + "outDir": "tmp", + "sourceMap": false, + "lib": ["es6", "dom"] + }, + "exclude": [ + "node_modules", + "**/node_modules", + "tmp" + ] +} diff --git a/test/wdio.conf.js b/test/wdio.conf.js new file mode 100644 index 0000000..a84696d --- /dev/null +++ b/test/wdio.conf.js @@ -0,0 +1,204 @@ +exports.config = { + + // + // ================== + // Specify Test Files + // ================== + // Define which test specs should run. The pattern is relative to the directory + // from which `wdio` was called. Notice that, if you are calling `wdio` from an + // NPM script (see https://docs.npmjs.com/cli/run-script) then the current working + // directory is where your package.json resides, so `wdio` will be called from there. + // + specs: [ + './tmp/templates/**/*.spec.js' + ], + // Patterns to exclude. + exclude: [ + // 'path/to/excluded/files' + ], + // + // ============ + // Capabilities + // ============ + // Define your capabilities here. WebdriverIO can run multiple capabilities at the same + // time. Depending on the number of capabilities, WebdriverIO launches several test + // sessions. Within your capabilities you can overwrite the spec and exclude options in + // order to group specific specs to a specific capability. + // + // First, you can define how many instances should be started at the same time. Let's + // say you have 3 different capabilities (Chrome, Firefox, and Safari) and you have + // set maxInstances to 1; wdio will spawn 3 processes. Therefore, if you have 10 spec + // files and you set maxInstances to 10, all spec files will get tested at the same time + // and 30 processes will get spawned. The property handles how many capabilities + // from the same test should run tests. + // + maxInstances: 10, + // + // If you have trouble getting all important capabilities together, check out the + // Sauce Labs platform configurator - a great tool to configure your capabilities: + // https://docs.saucelabs.com/reference/platforms-configurator + // + capabilities: [{ + // maxInstances can get overwritten per capability. So if you have an in-house Selenium + // grid with only 5 firefox instances available you can make sure that not more than + // 5 instances get started at a time. + maxInstances: 5, + // + browserName: 'chrome' + }], + // + // =================== + // Test Configurations + // =================== + // Define all options that are relevant for the WebdriverIO instance here + // + // By default WebdriverIO commands are executed in a synchronous way using + // the wdio-sync package. If you still want to run your tests in an async way + // e.g. using promises you can set the sync option to false. + sync: true, + // + // Level of logging verbosity: silent | verbose | command | data | result | error + logLevel: 'silent', + // + // Enables colors for log output. + coloredLogs: true, + // + // If you only want to run your tests until a specific amount of tests have failed use + // bail (default is 0 - don't bail, run all tests). + bail: 0, + // + // Saves a screenshot to a given path if a command fails. + screenshotPath: './tmp/errorShots/', + // + // Set a base URL in order to shorten url command calls. If your url parameter starts + // with "/", then the base url gets prepended. + baseUrl: 'http://localhost:5000', + // + // Default timeout for all waitFor* commands. + waitforTimeout: 10000, + // + // Default timeout in milliseconds for request + // if Selenium Grid doesn't send response + connectionRetryTimeout: 90000, + // + // Default request retries count + connectionRetryCount: 3, + // + // Initialize the browser instance with a WebdriverIO plugin. The object should have the + // plugin name as key and the desired plugin options as properties. Make sure you have + // the plugin installed before running any tests. The following plugins are currently + // available: + // WebdriverCSS: https://github.com/webdriverio/webdrivercss + // WebdriverRTC: https://github.com/webdriverio/webdriverrtc + // Browserevent: https://github.com/webdriverio/browserevent + // plugins: { + // webdrivercss: { + // screenshotRoot: 'my-shots', + // failedComparisonsRoot: 'diffs', + // misMatchTolerance: 0.05, + // screenWidth: [320,480,640,1024] + // }, + // webdriverrtc: {}, + // browserevent: {} + // }, + // + // Test runner services + // Services take over a specific job you don't want to take care of. They enhance + // your test setup with almost no effort. Unlike plugins, they don't add new + // commands. Instead, they hook themselves up into the test process. + // services: ['selenium-standalone'], + // + // Framework you want to run your specs with. + // The following are supported: Mocha, Jasmine, and Cucumber + // see also: http://webdriver.io/guide/testrunner/frameworks.html + // + // Make sure you have the wdio adapter package for the specific framework installed + // before running any tests. + framework: 'mocha', + // + // Test reporter for stdout. + // The only one supported by default is 'dot' + // see also: http://webdriver.io/guide/testrunner/reporters.html + reporters: ['junit'], + reporterOptions: { + outputDir: './tmp/junit' + }, + + // + // Options to be passed to Mocha. + // See the full list at http://mochajs.org/ + mochaOpts: { + ui: 'bdd', + timeout: 30000 + }, + // + // ===== + // Hooks + // ===== + // WebdriverIO provides several hooks you can use to interfere with the test process in order to enhance + // it and to build services around it. You can either apply a single function or an array of + // methods to it. If one of them returns with a promise, WebdriverIO will wait until that promise got + // resolved to continue. + // + // Gets executed once before all workers get launched. + // onPrepare: function (config, capabilities) { + // }, + // + // Gets executed just before initialising the webdriver session and test framework. It allows you + // to manipulate configurations depending on the capability or spec. + // beforeSession: function (config, capabilities, specs) { + // }, + // + // Gets executed before test execution begins. At this point you can access all global + // variables, such as `browser`. It is the perfect place to define custom commands. + // before: function (capabilities, specs) { + // }, + // + // Hook that gets executed before the suite starts + // beforeSuite: function (suite) { + // }, + // + // Hook that gets executed _before_ a hook within the suite starts (e.g. runs before calling + // beforeEach in Mocha) + // beforeHook: function () { + // }, + // + // Hook that gets executed _after_ a hook within the suite starts (e.g. runs after calling + // afterEach in Mocha) + // afterHook: function () { + // }, + // + // Function to be executed before a test (in Mocha/Jasmine) or a step (in Cucumber) starts. + // beforeTest: function (test) { + // }, + // + // Runs before a WebdriverIO command gets executed. + // beforeCommand: function (commandName, args) { + // }, + // + // Runs after a WebdriverIO command gets executed + // afterCommand: function (commandName, args, result, error) { + // }, + // + // Function to be executed after a test (in Mocha/Jasmine) or a step (in Cucumber) starts. + // afterTest: function (test) { + // }, + // + // Hook that gets executed after the suite has ended + // afterSuite: function (suite) { + // }, + // + // Gets executed after all tests are done. You still have access to all global variables from + // the test. + // after: function (result, capabilities, specs) { + // }, + // + // Gets executed right after terminating the webdriver session. + // afterSession: function (config, capabilities, specs) { + // }, + // + // Gets executed after all workers got shut down and the process is about to exit. It is not + // possible to defer the end of the process using a promise. + // onComplete: function(exitCode) { + // } +}