Add Appveyor builds and webdriver.io tests (tests cover Angular2Spa template only at present)

This commit is contained in:
SteveSandersonMS
2016-12-15 16:38:30 +00:00
parent 6decb30681
commit 67c2cfd84e
9 changed files with 532 additions and 2 deletions

View File

@@ -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('<h1>Hello, world!</h1>', '<h1>HMR is working</h1>');
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();
});

View File

@@ -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<any> {
return new Promise((resolve, reject) => {
this._stdoutReader.on('line', (line: string) => {
if (line.startsWith('Now listening on:')) {
resolve();
}
});
});
}
public dispose(): Promise<any> {
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'
});
}

View File

@@ -0,0 +1,12 @@
// Workaround for missing '.value' property on WebdriverIO.Client<RawResult<T>> 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<T> is extended only when T
// itself extends RawResult<U> for some U.
export function getValue<T>(client: WebdriverIO.Client<WebdriverIO.RawResult<T>>): T {
return (client as any).value;
}
// The official type declarations for getCssProperty are completely wrong. This function matches runtime behaviour.
export function getCssPropertyValue<T>(client: WebdriverIO.Client<T>, selector: string, cssProperty: string): string {
return (client.getCssProperty(selector, cssProperty) as any).value;
}

View File

@@ -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(' ');
}