From 6c145c31f5b1f465bb4f86c2a052958cb7922b9f Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 26 Jan 2015 16:59:11 -0800 Subject: [PATCH] Add Basic TypeScript Class Test As part of the new class effort it is now possible to define React Components using any type of generic JavaScript class syntax. This includes TypeScript classes. This test ensures that we don't regress that support, and also serves as an example for using React in TypeScript. TypeScript provides a good demo of where we think property initializers are going. We don't have any official *type* support for TypeScript yet. This test trails the ReactES6Class-test file. Some manual tweaking is required when converting tests. --- jest/jest.d.ts | 71 +++ jest/preprocessor.js | 17 + package.json | 4 +- .../__tests__/ReactClassEquivalence-test.js | 7 +- .../__tests__/ReactTypeScriptClass-test.ts | 491 ++++++++++++++++++ src/modern/class/__tests__/react.d.ts | 32 ++ 6 files changed, 620 insertions(+), 2 deletions(-) create mode 100644 jest/jest.d.ts create mode 100644 src/modern/class/__tests__/ReactTypeScriptClass-test.ts create mode 100644 src/modern/class/__tests__/react.d.ts diff --git a/jest/jest.d.ts b/jest/jest.d.ts new file mode 100644 index 0000000000..31fcc07dfc --- /dev/null +++ b/jest/jest.d.ts @@ -0,0 +1,71 @@ +declare var jasmine: any; + +declare function afterEach(fn: any): any; +declare function beforeEach(fn: any): any; +declare function describe(name: string, fn: any): void; +declare var it: { + (name: string, fn: any): void; + only: (name: string, fn: any) => void; +} +declare function expect(val: any): Expect; +declare var jest: Jest; +declare function pit(name: string, fn: any): void; +declare function spyOn(obj: any, key: string): any; +declare function xdescribe(name: string, fn: any): void; +declare function xit(name: string, fn: any): void; + +interface Expect { + not: Expect + toThrow(message?: string): void + toBe(value: any): void + toEqual(value: any): void + toBeFalsy(): void + toBeTruthy(): void + toBeNull(): void + toBeUndefined(): void + toBeDefined(): void + toMatch(regexp: RegExp): void + toContain(string: string): void + toBeCloseTo(number: number, delta: number): void + toBeGreaterThan(number: number): void + toBeLessThan(number: number): void + toBeCalled(): void + toBeCalledWith(...arguments): void + lastCalledWith(...arguments): void +} + +interface Jest { + autoMockOff(): void + autoMockOn(): void + clearAllTimers(): void + dontMock(moduleName: string): void + genMockFromModule(moduleObj: Object): Object + genMockFunction(): MockFunction + genMockFn(): MockFunction + mock(moduleName: string): void + runAllTicks(): void + runAllTimers(): void + runOnlyPendingTimers(): void + setMock(moduleName: string, moduleExports: Object): void +} + +interface MockFunction { + (...arguments): any + mock: { + calls: Array> + instances: Array + } + mockClear(): void + mockImplementation(fn: Function): MockFunction + mockImpl(fn: Function): MockFunction + mockReturnThis(): MockFunction + mockReturnValue(value: any): MockFunction + mockReturnValueOnce(value: any): MockFunction +} + +// Allow importing jasmine-check +declare module 'jasmine-check' { + export function install(global?: any): void; +} +declare var check: any; +declare var gen: any; diff --git a/jest/preprocessor.js b/jest/preprocessor.js index 37fdd6ac5f..d52482c816 100644 --- a/jest/preprocessor.js +++ b/jest/preprocessor.js @@ -3,12 +3,29 @@ var ReactTools = require('../main.js'); var coffee = require('coffee-script'); +var ts = require('ts-compiler'); module.exports = { process: function(src, path) { if (path.match(/\.coffee$/)) { return coffee.compile(src, {'bare': true}); } + if (path.match(/\.ts$/) && !path.match(/\.d\.ts$/)) { + ts.compile([path], { + skipWrite: true, + module: 'commonjs' + }, function(err, results) { + if (err) { + throw err; + } + results.forEach(function(file) { + // This is gross, but jest doesn't provide an asynchronous way to + // process a module, and ts currently runs syncronously. + src = file.text; + }); + }); + return src; + } return ReactTools.transform(src, {harmony: true}); } }; diff --git a/package.json b/package.json index 1d2bb1b806..917c1dc51b 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "populist": "~0.1.6", "recast": "^0.9.11", "sauce-tunnel": "~1.1.0", + "ts-compiler": "^2.0.0", "tmp": "~0.0.18", "uglify-js": "~2.4.0", "uglifyify": "^2.4.0", @@ -82,7 +83,8 @@ "setupEnvScriptFile": "jest/environment.js", "testFileExtensions": [ "js", - "coffee" + "coffee", + "ts" ], "modulePathIgnorePatterns": [ "/build/", diff --git a/src/modern/class/__tests__/ReactClassEquivalence-test.js b/src/modern/class/__tests__/ReactClassEquivalence-test.js index ff48f5373a..cd342ad487 100644 --- a/src/modern/class/__tests__/ReactClassEquivalence-test.js +++ b/src/modern/class/__tests__/ReactClassEquivalence-test.js @@ -19,9 +19,14 @@ describe('ReactClassEquivalence', function() { var es6 = () => require('./ReactES6Class-test.js'); var coffee = () => require('./ReactCoffeeScriptClass-test.coffee'); + var ts = () => require('./ReactTypeScriptClass-test.ts'); - it('tests the same thing for es6 classes and coffee script', function() { + it('tests the same thing for es6 classes and CoffeeScript', function() { expect(coffee).toEqualSpecsIn(es6); }); + it('tests the same thing for es6 classes and TypeScript', function() { + expect(ts).toEqualSpecsIn(es6); + }); + }); diff --git a/src/modern/class/__tests__/ReactTypeScriptClass-test.ts b/src/modern/class/__tests__/ReactTypeScriptClass-test.ts new file mode 100644 index 0000000000..1cbe9e6427 --- /dev/null +++ b/src/modern/class/__tests__/ReactTypeScriptClass-test.ts @@ -0,0 +1,491 @@ +/*! + * Copyright 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +/// +/// + +import React = require('React'); + +// Before Each + +var container; +var attachedListener = null; +var renderedName = null; + +class Inner extends React.Component { + getName() { + return this.props.name; + } + render() { + attachedListener = this.props.onClick; + renderedName = this.props.name; + return React.createElement('div', { className: this.props.name }); + } +}; + +function test(element, expectedTag, expectedClassName) { + var instance = React.render(element, container); + expect(container.firstChild).not.toBeNull(); + expect(container.firstChild.tagName).toBe(expectedTag); + expect(container.firstChild.className).toBe(expectedClassName); + return instance; +} + +// Classes need to be declared at the top level scope, so we declare all the +// classes that will be used by the tests below, instead of inlining them. +// TODO: Consider redesigning this using modules so that we can use non-unique +// names of classes and bundle them with the test code. + +// it preserves the name of the class for use in error messages +// it throws if no render function is defined +class Empty extends React.Component { } + +// it renders a simple stateless component with prop +class SimpleStateless { + props : any; + render() { + return React.createElement(Inner, {name: this.props.bar}); + } +} + +// it renders based on state using initial values in this.props +class InitialState extends React.Component { + state = { + bar: this.props.initialValue + }; + render() { + return React.createElement('span', {className: this.state.bar}); + } +} + +// it renders based on state using props in the constructor +class StateBasedOnProps extends React.Component { + constructor(props) { + super(props); + this.state = { bar: props.initialValue }; + } + changeState() { + this.setState({ bar: 'bar' }); + } + render() { + if (this.state.bar === 'foo') { + return React.createElement('div', {className: 'foo'}); + } + return React.createElement('span', {className: this.state.bar}); + } +} + +// it renders based on context in the constructor +class StateBasedOnContext extends React.Component { + static contextTypes = { + tag: React.PropTypes.string, + className: React.PropTypes.string + } + state = { + tag: this.context.tag, + className: this.context.className + } + render() { + var Tag = this.state.tag; + return React.createElement(Tag, {className: this.state.className}); + } +} + +class ProvideChildContextTypes extends React.Component { + static childContextTypes = { + tag: React.PropTypes.string, + className: React.PropTypes.string + } + getChildContext() { + return { tag: 'span', className: 'foo' }; + } + render() { + return React.createElement(StateBasedOnContext); + } +} + +// it renders only once when setting state in componentWillMount +var renderCount = 0; +class RenderOnce extends React.Component { + state = { + bar: this.props.initialValue + } + componentWillMount() { + this.setState({ bar: 'bar' }); + } + render() { + renderCount++; + return React.createElement('span', {className: this.state.bar}); + } +} + +// it should throw with non-object in the initial state property +class ArrayState { + state = ['an array'] + render() { + return React.createElement('span'); + } +} +class StringState { + state = 'a string' + render() { + return React.createElement('span'); + } +} +class NumberState { + state = 1234 + render() { + return React.createElement('span'); + } +} + +// it should render with null in the initial state property +class NullState extends React.Component { + state = null + render() { + return React.createElement('span'); + } +} + +// it setState through an event handler +class BoundEventHandler extends React.Component { + state = { + bar: this.props.initialValue + } + handleClick = () => { + this.setState({ bar: 'bar' }); + } + render() { + return ( + React.createElement(Inner, { + name: this.state.bar, + onClick: this.handleClick + }) + ); + } +} + +// it should not implicitly bind event handlers +class UnboundEventHandler extends React.Component { + state = { + bar: this.props.initialValue + } + handleClick() { + this.setState({ bar: 'bar' }); + } + render() { + return React.createElement( + Inner, { name: this.state.bar, onClick: this.handleClick } + ); + } +} + +// it renders using forceUpdate even when there is no state +class ForceUpdateWithNoState extends React.Component { + mutativeValue : string = this.props.initialValue + handleClick() { + this.mutativeValue = 'bar'; + this.forceUpdate(); + } + render() { + return ( + React.createElement(Inner, { + name: this.mutativeValue, + onClick: this.handleClick.bind(this)} + ) + ); + } +} + +// it will call all the normal life cycle methods +var lifeCycles = []; +class NormalLifeCycles { + props : any + state = {} + componentWillMount() { + lifeCycles.push('will-mount'); + } + componentDidMount() { + lifeCycles.push('did-mount'); + } + componentWillReceiveProps(nextProps) { + lifeCycles.push('receive-props', nextProps); + } + shouldComponentUpdate(nextProps, nextState) { + lifeCycles.push('should-update', nextProps, nextState); + return true; + } + componentWillUpdate(nextProps, nextState) { + lifeCycles.push('will-update', nextProps, nextState); + } + componentDidUpdate(prevProps, prevState) { + lifeCycles.push('did-update', prevProps, prevState); + } + componentWillUnmount() { + lifeCycles.push('will-unmount'); + } + render() { + return React.createElement('span', {className: this.props.value}); + } +} + +// warns when classic properties are defined on the instance, +// but does not invoke them. +var getInitialStateWasCalled = false; +class ClassicProperties extends React.Component { + contextTypes = {}; + propTypes = {}; + getInitialState() { + getInitialStateWasCalled = true; + return {}; + } + render() { + return React.createElement('span', {className: 'foo'}); + } +} + +// it should warn when mispelling shouldComponentUpdate +class NamedComponent { + componentShouldUpdate() { + return false; + } + render() { + return React.createElement('span', {className: 'foo'}); + } +} + +// it supports this.context passed via getChildContext +class ReadContext extends React.Component { + static contextTypes = { bar: React.PropTypes.string }; + render() { + return React.createElement('div', { className: this.context.bar }); + } +} +class ProvideContext { + static childContextTypes = { bar: React.PropTypes.string }; + getChildContext() { + return { bar: 'bar-through-context' }; + } + render() { + return React.createElement(ReadContext); + } +} + +// it supports classic refs +class ClassicRefs { + render() { + return React.createElement(Inner, {name: 'foo', ref: 'inner'}); + } +} + +// Describe the actual test cases. + +describe('ReactTypeScriptClass', function() { + + beforeEach(function() { + container = document.createElement('div'); + attachedListener = null; + renderedName = null; + }); + + it('preserves the name of the class for use in error messages', function() { + expect(Empty.name).toBe('Empty'); + }); + + it('throws if no render function is defined', function() { + expect(() => React.render(React.createElement(Empty), container)).toThrow(); + }); + + it('renders a simple stateless component with prop', function() { + test(React.createElement(SimpleStateless, {bar: 'foo'}), 'DIV', 'foo'); + test(React.createElement(SimpleStateless, {bar: 'bar'}), 'DIV', 'bar'); + }); + + it('renders based on state using initial values in this.props', function() { + test( + React.createElement(InitialState, {initialValue: 'foo'}), + 'SPAN', + 'foo' + ); + }); + + it('renders based on state using props in the constructor', function() { + var instance = test( + React.createElement(StateBasedOnProps, {initialValue: 'foo'}), + 'DIV', + 'foo' + ); + instance.changeState(); + test(React.createElement(StateBasedOnProps), 'SPAN', 'bar'); + }); + + it('renders based on context in the constructor', function() { + test(React.createElement(ProvideChildContextTypes), 'SPAN', 'foo'); + }); + + it('renders only once when setting state in componentWillMount', function() { + renderCount = 0; + test(React.createElement(RenderOnce, {initialValue: 'foo'}), 'SPAN', 'bar'); + expect(renderCount).toBe(1); + }); + + it('should throw with non-object in the initial state property', function() { + expect(() => test(React.createElement(ArrayState), 'span', '')) + .toThrow( + 'Invariant Violation: ArrayState.state: ' + + 'must be set to an object or null' + ); + expect(() => test(React.createElement(StringState), 'span', '')) + .toThrow( + 'Invariant Violation: StringState.state: ' + + 'must be set to an object or null' + ); + expect(() => test(React.createElement(NumberState), 'span', '')) + .toThrow( + 'Invariant Violation: NumberState.state: ' + + 'must be set to an object or null' + ); + }); + + it('should render with null in the initial state property', function() { + test(React.createElement(NullState), 'SPAN', ''); + }); + + it('setState through an event handler', function() { + test( + React.createElement(BoundEventHandler, {initialValue: 'foo'}), + 'DIV', + 'foo' + ); + attachedListener(); + expect(renderedName).toBe('bar'); + }); + + it('should not implicitly bind event handlers', function() { + test( + React.createElement(UnboundEventHandler, {initialValue: 'foo'}), + 'DIV', + 'foo' + ); + expect(attachedListener).toThrow(); + }); + + it('renders using forceUpdate even when there is no state', function() { + test( + React.createElement(ForceUpdateWithNoState, {initialValue: 'foo'}), + 'DIV', + 'foo' + ); + attachedListener(); + expect(renderedName).toBe('bar'); + }); + + it('will call all the normal life cycle methods', function() { + lifeCycles = []; + test(React.createElement(NormalLifeCycles, {value: 'foo'}), 'SPAN', 'foo'); + expect(lifeCycles).toEqual([ + 'will-mount', + 'did-mount' + ]); + lifeCycles = []; // reset + test(React.createElement(NormalLifeCycles, {value: 'bar'}), 'SPAN', 'bar'); + expect(lifeCycles).toEqual([ + 'receive-props', { value: 'bar' }, + 'should-update', { value: 'bar' }, {}, + 'will-update', { value: 'bar' }, {}, + 'did-update', { value: 'foo' }, {} + ]); + lifeCycles = []; // reset + React.unmountComponentAtNode(container); + expect(lifeCycles).toEqual([ + 'will-unmount' + ]); + }); + + it('warns when classic properties are defined on the instance, ' + + 'but does not invoke them.', function() { + var warn = jest.genMockFn(); + console.warn = warn; + getInitialStateWasCalled = false; + test(React.createElement(ClassicProperties), 'SPAN', 'foo'); + expect(getInitialStateWasCalled).toBe(false); + expect(warn.mock.calls.length).toBe(3); + expect(warn.mock.calls[0][0]).toContain( + 'getInitialState was defined on ClassicProperties, ' + + 'a plain JavaScript class.' + ); + expect(warn.mock.calls[1][0]).toContain( + 'propTypes was defined as an instance property on ClassicProperties.' + ); + expect(warn.mock.calls[2][0]).toContain( + 'contextTypes was defined as an instance property on ClassicProperties.' + ); + }); + + it('should warn when mispelling shouldComponentUpdate', function() { + var warn = jest.genMockFn(); + console.warn = warn; + + test(React.createElement(NamedComponent), 'SPAN', 'foo'); + + expect(warn.mock.calls.length).toBe(1); + expect(warn.mock.calls[0][0]).toBe( + 'Warning: ' + + 'NamedComponent has a method called componentShouldUpdate(). Did you ' + + 'mean shouldComponentUpdate()? The name is phrased as a question ' + + 'because the function is expected to return a value.' + ); + }); + + it('should throw AND warn when trying to access classic APIs', function() { + var warn = jest.genMockFn(); + console.warn = warn; + var instance = test( + React.createElement(Inner, {name: 'foo'}), + 'DIV','foo' + ); + expect(() => instance.getDOMNode()).toThrow(); + expect(() => instance.replaceState({})).toThrow(); + expect(() => instance.isMounted()).toThrow(); + expect(() => instance.setProps({ name: 'bar' })).toThrow(); + expect(warn.mock.calls.length).toBe(4); + expect(warn.mock.calls[0][0]).toContain( + 'getDOMNode(...) is deprecated in plain JavaScript React classes' + ); + expect(warn.mock.calls[1][0]).toContain( + 'replaceState(...) is deprecated in plain JavaScript React classes' + ); + expect(warn.mock.calls[2][0]).toContain( + 'isMounted(...) is deprecated in plain JavaScript React classes' + ); + expect(warn.mock.calls[3][0]).toContain( + 'setProps(...) is deprecated in plain JavaScript React classes' + ); + }); + + it('supports this.context passed via getChildContext', function() { + test(React.createElement(ProvideContext), 'DIV', 'bar-through-context'); + }); + + it('supports classic refs', function() { + var instance = test(React.createElement(ClassicRefs), 'DIV', 'foo'); + expect(instance.refs.inner.getName()).toBe('foo'); + }); + + it('supports drilling through to the DOM using findDOMNode', function() { + var instance = test( + React.createElement(Inner, {name: 'foo'}), + 'DIV', + 'foo' + ); + var node = React.findDOMNode(instance); + expect(node).toBe(container.firstChild); + }); + +}); diff --git a/src/modern/class/__tests__/react.d.ts b/src/modern/class/__tests__/react.d.ts new file mode 100644 index 0000000000..ff317606ae --- /dev/null +++ b/src/modern/class/__tests__/react.d.ts @@ -0,0 +1,32 @@ +/*! + * Copyright 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +/** + * TypeScript Definition File for React. + * + * Full type definitions are not yet officially supported. These are mostly + * just helpers for the unit test. + */ + +declare module 'React' { + export class Component { + props: any; + state: any; + context: any; + static name: string; + constructor(props?, context?); + setState(partial : any, callback ?: any) : void; + forceUpdate(callback ?: any) : void; + } + export var PropTypes : any; + export function createElement(tag : any, props ?: any, ...children : any[]) : any + export function render(element : any, container : any) : any + export function unmountComponentAtNode(container : any) : void + export function findDOMNode(instance : any) : any +}