From 50ad21372c3c165192a8673007ede7098a01e988 Mon Sep 17 00:00:00 2001 From: mmkal Date: Tue, 14 Mar 2017 08:52:11 -0400 Subject: [PATCH] Make test context optionally type aware for TypeScript (#1298) --- docs/recipes/typescript.md | 31 ++++++++++++++- readme.md | 2 + types/base.d.ts | 37 ++++++++++-------- types/make.js | 79 +++++++++++++++++++++++--------------- 4 files changed, 101 insertions(+), 48 deletions(-) diff --git a/docs/recipes/typescript.md b/docs/recipes/typescript.md index 8cd4edd..4dbce8a 100644 --- a/docs/recipes/typescript.md +++ b/docs/recipes/typescript.md @@ -6,7 +6,7 @@ AVA comes bundled with a TypeScript definition file. This allows developers to l ## Setup -First install [TypeScript](https://github.com/Microsoft/TypeScript). +First install [TypeScript](https://github.com/Microsoft/TypeScript) (if you already have it installed, make sure you use version 2.1 or greater). ``` $ npm install --save-dev typescript @@ -50,6 +50,35 @@ test(async (t) => { }); ``` +## Working with [`context`](https://github.com/avajs/ava#test-context) + +By default, the type of `t.context` will be [`any`](https://www.typescriptlang.org/docs/handbook/basic-types.html#any). AVA exposes an interface `RegisterContextual` which you can use to apply your own type to `t.context`. This can help you catch errors at compile-time: + +```ts +import * as ava from 'ava'; + +function contextualize(getContext: () => T): ava.RegisterContextual { + ava.test.beforeEach(t => { + Object.assign(t.context, getContext()); + }); + + return ava.test; +} + +const test = contextualize(() => ({ foo: 'bar' })); + +test.beforeEach(t => { + t.context.foo = 123; // error: Type '123' is not assignable to type 'string' +}); + +test.after.always.failing.cb.serial('very long chains are properly typed', t => { + t.context.fooo = 'a value'; // error: Property 'fooo' does not exist on type '{ foo: string }' +}); + +test('an actual test', t => { + t.deepEqual(t.context.foo.map(c => c), ['b', 'a', 'r']); // error: Property 'map' does not exist on type 'string' +}); +``` ## Execute the tests diff --git a/readme.md b/readme.md index 712adfb..cb0607e 100644 --- a/readme.md +++ b/readme.md @@ -578,6 +578,8 @@ Keep in mind that the `beforeEach` and `afterEach` hooks run just before and aft Remember that AVA runs each test file in its own process. You may not have to clean up global state in a `after`-hook since that's only called right before the process exits. +#### Test context + The `beforeEach` & `afterEach` hooks can share context with the test: ```js diff --git a/types/base.d.ts b/types/base.d.ts index 448e7aa..c4faed0 100644 --- a/types/base.d.ts +++ b/types/base.d.ts @@ -1,5 +1,3 @@ -export default test; - export type ErrorValidator = (new (...args: any[]) => any) | RegExp @@ -9,11 +7,16 @@ export type ErrorValidator export interface Observable { subscribe(observer: (value: {}) => void): void; } - export type Test = (t: TestContext) => PromiseLike | Iterator | Observable | void; -export type ContextualTest = (t: ContextualTestContext) => PromiseLike | Iterator | Observable | void; +export type GenericTest = (t: GenericTestContext) => PromiseLike | Iterator | Observable | void; export type CallbackTest = (t: CallbackTestContext) => void; -export type ContextualCallbackTest = (t: ContextualCallbackTestContext) => void; +export type GenericCallbackTest = (t: GenericCallbackTestContext) => void; + +export interface Context { context: T } +export type AnyContext = Context; + +export type ContextualTest = GenericTest; +export type ContextualCallbackTest = GenericCallbackTest; export interface AssertContext { /** @@ -99,12 +102,9 @@ export interface CallbackTestContext extends TestContext { */ end(): void; } -export interface ContextualTestContext extends TestContext { - context: any; -} -export interface ContextualCallbackTestContext extends CallbackTestContext { - context: any; -} + +export type GenericTestContext = TestContext & T; +export type GenericCallbackTestContext = CallbackTestContext & T; export interface Macro { (t: T, ...args: any[]): void; @@ -112,7 +112,14 @@ export interface Macro { } export type Macros = Macro | Macro[]; -export function test(name: string, run: ContextualTest): void; -export function test(run: ContextualTest): void; -export function test(name: string, run: Macros, ...args: any[]): void; -export function test(run: Macros, ...args: any[]): void; +interface RegisterBase { + (name: string, run: GenericTest): void; + (run: GenericTest): void; + (name: string, run: Macros>, ...args: any[]): void; + (run: Macros>, ...args: any[]): void; +} + +export default test; +export const test: RegisterContextual; +export interface RegisterContextual extends Register> { +} diff --git a/types/make.js b/types/make.js index 603fc76..9f2f675 100644 --- a/types/make.js +++ b/types/make.js @@ -24,7 +24,10 @@ const base = fs.readFileSync(path.join(__dirname, 'base.d.ts'), 'utf8'); // All suported function names const allParts = Object.keys(runner._chainableMethods).filter(name => name !== 'test'); +// The output consists of the base declarations, the actual 'test' function declarations, +// and the namespaced chainable methods. const output = base + generatePrefixed([]); + fs.writeFileSync(path.join(__dirname, 'generated.d.ts'), output); // Generates type definitions, for the specified prefix @@ -43,10 +46,21 @@ function generatePrefixed(prefix) { // If `parts` is not sorted, we alias it to the sorted chain if (!isArraySorted(parts)) { - const chain = parts.sort().join('.'); - if (exists(parts)) { - output += `\texport const ${part}: typeof test.${chain};\n`; + parts.sort(); + + let chain; + if (hasChildren(parts)) { + chain = parts.join('_') + ''; + } else { + // this is a single function, not a namespace, so there's no type associated + // and we need to dereference it as a property type + const last = parts.pop(); + const joined = parts.join('_'); + chain = `${joined}['${last}']`; + } + + output += `\t${part}: Register_${chain};\n`; } continue; @@ -56,14 +70,19 @@ function generatePrefixed(prefix) { // `always` is a valid prefix, for instance of `always.after`, // but not a valid function name. if (verify(parts, false)) { - if (parts.indexOf('todo') !== -1) { // eslint-disable-line no-negated-condition - output += '\t' + writeFunction(part, 'name: string', 'void'); + if (arrayHas(parts)('todo')) { + // 'todo' functions don't have a function argument, just a string + output += `\t${part}: (name: string) => void;\n`; } else { - const type = testType(parts); - output += '\t' + writeFunction(part, `name: string, implementation: ${type}`); - output += '\t' + writeFunction(part, `implementation: ${type}`); - output += '\t' + writeFunction(part, `name: string, implementation: Macros<${type}Context>, ...args: any[]`); - output += '\t' + writeFunction(part, `implementation: Macros<${type}Context>, ...args: any[]`); + output += `\t${part}: RegisterBase`; + + if (hasChildren(parts)) { + // this chain can be continued, make the property an intersection type with the chain continuation + const joined = parts.join('_'); + output += ` & Register_${joined}`; + } + + output += ';\n'; } } @@ -74,13 +93,14 @@ function generatePrefixed(prefix) { return children; } - const namespace = ['test'].concat(prefix).join('.'); + const typeBody = `{\n${output}}\n${children}`; - return `export namespace ${namespace} {\n${output}}\n${children}`; -} - -function writeFunction(name, args) { - return `export function ${name}(${args}): void;\n`; + if (prefix.length === 0) { + // no prefix, so this is the type for the default export + return `export interface Register extends RegisterBase ${typeBody}`; + } + const namespace = ['Register'].concat(prefix).join('_'); + return `interface ${namespace} ${typeBody}`; } // Checks whether a chain is a valid function name (when `asPrefix === false`) @@ -126,6 +146,17 @@ function verify(parts, asPrefix) { return true; } +// Returns true if a chain can have any child properties +function hasChildren(parts) { + // concatenate the chain with each other part, and see if any concatenations are valid functions + const validChildren = allParts + .filter(newPart => parts.indexOf(newPart) === -1) + .map(newPart => parts.concat([newPart])) + .filter(longer => verify(longer, false)); + + return validChildren.length > 0; +} + // Checks whether a chain is a valid function name or a valid prefix with some member function exists(parts) { if (verify(parts, false)) { @@ -147,19 +178,3 @@ function exists(parts) { return false; } - -// Returns the type name of for the test implementation -function testType(parts) { - const has = arrayHas(parts); - let type = 'Test'; - - if (has('cb')) { - type = `Callback${type}`; - } - - if (!has('before') && !has('after')) { - type = `Contextual${type}`; - } - - return type; -}