From 30b577138dda685f65a8529be1866afa6e321845 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Tue, 4 Oct 2016 11:50:26 +0200 Subject: Squashed 'thirdparty/preact/' content from commit b2d90cc git-subtree-dir: thirdparty/preact git-subtree-split: b2d90cc116f1d1998f7a7c98dc6986bf4c1841f4 --- test/browser/components.js | 713 +++++++++++++++++++++++++++++++++++++++++++ test/browser/context.js | 170 +++++++++++ test/browser/keys.js | 85 ++++++ test/browser/lifecycle.js | 493 ++++++++++++++++++++++++++++++ test/browser/linked-state.js | 98 ++++++ test/browser/performance.js | 245 +++++++++++++++ test/browser/refs.js | 287 +++++++++++++++++ test/browser/render.js | 439 ++++++++++++++++++++++++++ test/browser/spec.js | 124 ++++++++ test/browser/svg.js | 112 +++++++ test/karma.conf.js | 126 ++++++++ test/node/index.js | 1 + test/shared/exports.js | 21 ++ test/shared/h.js | 201 ++++++++++++ 14 files changed, 3115 insertions(+) create mode 100644 test/browser/components.js create mode 100644 test/browser/context.js create mode 100644 test/browser/keys.js create mode 100644 test/browser/lifecycle.js create mode 100644 test/browser/linked-state.js create mode 100644 test/browser/performance.js create mode 100644 test/browser/refs.js create mode 100644 test/browser/render.js create mode 100644 test/browser/spec.js create mode 100644 test/browser/svg.js create mode 100644 test/karma.conf.js create mode 100644 test/node/index.js create mode 100644 test/shared/exports.js create mode 100644 test/shared/h.js (limited to 'test') diff --git a/test/browser/components.js b/test/browser/components.js new file mode 100644 index 000000000..b4649a719 --- /dev/null +++ b/test/browser/components.js @@ -0,0 +1,713 @@ +import { h, render, rerender, Component } from '../../src/preact'; +/** @jsx h */ + +let spyAll = obj => Object.keys(obj).forEach( key => sinon.spy(obj,key) ); + +function getAttributes(node) { + let attrs = {}; + if (node.attributes) { + for (let i=node.attributes.length; i--; ) { + attrs[node.attributes[i].name] = node.attributes[i].value; + } + } + return attrs; +} + +// hacky normalization of attribute order across browsers. +function sortAttributes(html) { + return html.replace(/<([a-z0-9-]+)((?:\s[a-z0-9:_.-]+=".*?")+)((?:\s*\/)?>)/gi, (s, pre, attrs, after) => { + let list = attrs.match(/\s[a-z0-9:_.-]+=".*?"/gi).sort( (a, b) => a>b ? 1 : -1 ); + if (~after.indexOf('/')) after = '>'; + return '<' + pre + list.join('') + after; + }); +} + +const Empty = () => null; + +describe('Components', () => { + let scratch; + + before( () => { + scratch = document.createElement('div'); + (document.body || document.documentElement).appendChild(scratch); + }); + + beforeEach( () => { + let c = scratch.firstElementChild; + if (c) render(, scratch, c); + scratch.innerHTML = ''; + }); + + after( () => { + scratch.parentNode.removeChild(scratch); + scratch = null; + }); + + it('should render components', () => { + class C1 extends Component { + render() { + return
C1
; + } + } + sinon.spy(C1.prototype, 'render'); + render(, scratch); + + expect(C1.prototype.render) + .to.have.been.calledOnce + .and.to.have.been.calledWithMatch({}, {}) + .and.to.have.returned(sinon.match({ nodeName:'div' })); + + expect(scratch.innerHTML).to.equal('
C1
'); + }); + + + it('should render functional components', () => { + const PROPS = { foo:'bar', onBaz:()=>{} }; + + const C3 = sinon.spy( props =>
); + + render(, scratch); + + expect(C3) + .to.have.been.calledOnce + .and.to.have.been.calledWith(PROPS) + .and.to.have.returned(sinon.match({ + nodeName: 'div', + attributes: PROPS + })); + + expect(scratch.innerHTML).to.equal('
'); + }); + + + it('should render components with props', () => { + const PROPS = { foo:'bar', onBaz:()=>{} }; + let constructorProps; + + class C2 extends Component { + constructor(props) { + super(props); + constructorProps = props; + } + render(props) { + return
; + } + } + sinon.spy(C2.prototype, 'render'); + + render(, scratch); + + expect(constructorProps).to.deep.equal(PROPS); + + expect(C2.prototype.render) + .to.have.been.calledOnce + .and.to.have.been.calledWithMatch(PROPS, {}) + .and.to.have.returned(sinon.match({ + nodeName: 'div', + attributes: PROPS + })); + + expect(scratch.innerHTML).to.equal('
'); + }); + + + // Test for Issue #73 + it('should remove orphaned elements replaced by Components', () => { + class Comp extends Component { + render() { + return span in a component; + } + } + + let root; + function test(content) { + root = render(content, scratch, root); + } + + test(); + test(
just a div
); + test(); + + expect(scratch.innerHTML).to.equal('span in a component'); + }); + + + // Test for Issue #176 + it('should remove children when root changes to text node', () => { + let comp; + + class Comp extends Component { + render(_, { alt }) { + return alt ? 'asdf' :
test
; + } + } + + render(comp=c} />, scratch); + + comp.setState({ alt:true }); + comp.forceUpdate(); + expect(scratch.innerHTML, 'switching to textnode').to.equal('asdf'); + + comp.setState({ alt:false }); + comp.forceUpdate(); + expect(scratch.innerHTML, 'switching to element').to.equal('
test
'); + + comp.setState({ alt:true }); + comp.forceUpdate(); + expect(scratch.innerHTML, 'switching to textnode 2').to.equal('asdf'); + }); + + + describe('props.children', () => { + it('should support passing children as a prop', () => { + const Foo = props =>
; + + render(bar, + '123', + 456 + ]} />, scratch); + + expect(scratch.innerHTML).to.equal('
bar123456
'); + }); + + it('should be ignored when explicit children exist', () => { + const Foo = props =>
a
; + + render(, scratch); + + expect(scratch.innerHTML).to.equal('
a
'); + }); + }); + + + describe('High-Order Components', () => { + it('should render nested functional components', () => { + const PROPS = { foo:'bar', onBaz:()=>{} }; + + const Outer = sinon.spy( + props => + ); + + const Inner = sinon.spy( + props =>
inner
+ ); + + render(, scratch); + + expect(Outer) + .to.have.been.calledOnce + .and.to.have.been.calledWith(PROPS) + .and.to.have.returned(sinon.match({ + nodeName: Inner, + attributes: PROPS + })); + + expect(Inner) + .to.have.been.calledOnce + .and.to.have.been.calledWith(PROPS) + .and.to.have.returned(sinon.match({ + nodeName: 'div', + attributes: PROPS, + children: ['inner'] + })); + + expect(scratch.innerHTML).to.equal('
inner
'); + }); + + it('should re-render nested functional components', () => { + let doRender = null; + class Outer extends Component { + componentDidMount() { + let i = 1; + doRender = () => this.setState({ i: ++i }); + } + componentWillUnmount() {} + render(props, { i }) { + return ; + } + } + sinon.spy(Outer.prototype, 'render'); + sinon.spy(Outer.prototype, 'componentWillUnmount'); + + let j = 0; + const Inner = sinon.spy( + props =>
inner
+ ); + + render(, scratch); + + // update & flush + doRender(); + rerender(); + + expect(Outer.prototype.componentWillUnmount) + .not.to.have.been.called; + + expect(Inner).to.have.been.calledTwice; + + expect(Inner.secondCall) + .to.have.been.calledWith({ foo:'bar', i:2 }) + .and.to.have.returned(sinon.match({ + attributes: { + j: 2, + i: 2, + foo: 'bar' + } + })); + + expect(getAttributes(scratch.firstElementChild)).to.eql({ + j: '2', + i: '2', + foo: 'bar' + }); + + // update & flush + doRender(); + rerender(); + + expect(Inner).to.have.been.calledThrice; + + expect(Inner.thirdCall) + .to.have.been.calledWith({ foo:'bar', i:3 }) + .and.to.have.returned(sinon.match({ + attributes: { + j: 3, + i: 3, + foo: 'bar' + } + })); + + expect(getAttributes(scratch.firstElementChild)).to.eql({ + j: '3', + i: '3', + foo: 'bar' + }); + }); + + it('should re-render nested components', () => { + let doRender = null, + alt = false; + + class Outer extends Component { + componentDidMount() { + let i = 1; + doRender = () => this.setState({ i: ++i }); + } + componentWillUnmount() {} + render(props, { i }) { + if (alt) return
; + return ; + } + } + sinon.spy(Outer.prototype, 'render'); + sinon.spy(Outer.prototype, 'componentDidMount'); + sinon.spy(Outer.prototype, 'componentWillUnmount'); + + let j = 0; + class Inner extends Component { + constructor(...args) { + super(); + this._constructor(...args); + } + _constructor() {} + componentWillMount() {} + componentDidMount() {} + componentWillUnmount() {} + componentDidUnmount() {} + render(props) { + return
inner
; + } + } + sinon.spy(Inner.prototype, '_constructor'); + sinon.spy(Inner.prototype, 'render'); + sinon.spy(Inner.prototype, 'componentWillMount'); + sinon.spy(Inner.prototype, 'componentDidMount'); + sinon.spy(Inner.prototype, 'componentDidUnmount'); + sinon.spy(Inner.prototype, 'componentWillUnmount'); + + render(, scratch); + + expect(Outer.prototype.componentDidMount).to.have.been.calledOnce; + + // update & flush + doRender(); + rerender(); + + expect(Outer.prototype.componentWillUnmount).not.to.have.been.called; + + expect(Inner.prototype._constructor).to.have.been.calledOnce; + expect(Inner.prototype.componentWillUnmount).not.to.have.been.called; + expect(Inner.prototype.componentDidUnmount).not.to.have.been.called; + expect(Inner.prototype.componentWillMount).to.have.been.calledOnce; + expect(Inner.prototype.componentDidMount).to.have.been.calledOnce; + expect(Inner.prototype.render).to.have.been.calledTwice; + + expect(Inner.prototype.render.secondCall) + .to.have.been.calledWith({ foo:'bar', i:2 }) + .and.to.have.returned(sinon.match({ + attributes: { + j: 2, + i: 2, + foo: 'bar' + } + })); + + expect(getAttributes(scratch.firstElementChild)).to.eql({ + j: '2', + i: '2', + foo: 'bar' + }); + + expect(sortAttributes(scratch.innerHTML)).to.equal(sortAttributes('
inner
')); + + // update & flush + doRender(); + rerender(); + + expect(Inner.prototype.componentWillUnmount).not.to.have.been.called; + expect(Inner.prototype.componentDidUnmount).not.to.have.been.called; + expect(Inner.prototype.componentWillMount).to.have.been.calledOnce; + expect(Inner.prototype.componentDidMount).to.have.been.calledOnce; + expect(Inner.prototype.render).to.have.been.calledThrice; + + expect(Inner.prototype.render.thirdCall) + .to.have.been.calledWith({ foo:'bar', i:3 }) + .and.to.have.returned(sinon.match({ + attributes: { + j: 3, + i: 3, + foo: 'bar' + } + })); + + expect(getAttributes(scratch.firstElementChild)).to.eql({ + j: '3', + i: '3', + foo: 'bar' + }); + + + // update & flush + alt = true; + doRender(); + rerender(); + + expect(Inner.prototype.componentWillUnmount).to.have.been.calledOnce; + expect(Inner.prototype.componentDidUnmount).to.have.been.calledOnce; + + expect(scratch.innerHTML).to.equal('
'); + + // update & flush + alt = false; + doRender(); + rerender(); + + expect(sortAttributes(scratch.innerHTML)).to.equal(sortAttributes('
inner
')); + }); + + it('should resolve intermediary functional component', () => { + let ctx = {}; + class Root extends Component { + getChildContext() { + return { ctx }; + } + render() { + return ; + } + } + const Func = sinon.spy( () => ); + class Inner extends Component { + componentWillMount() {} + componentDidMount() {} + componentWillUnmount() {} + componentDidUnmount() {} + render() { + return
inner
; + } + } + + spyAll(Inner.prototype); + + let root = render(, scratch); + + expect(Inner.prototype.componentWillMount).to.have.been.calledOnce; + expect(Inner.prototype.componentDidMount).to.have.been.calledOnce; + expect(Inner.prototype.componentWillMount).to.have.been.calledBefore(Inner.prototype.componentDidMount); + + root = render(, scratch, root); + + expect(Inner.prototype.componentWillUnmount).to.have.been.calledOnce; + expect(Inner.prototype.componentDidUnmount).to.have.been.calledOnce; + expect(Inner.prototype.componentWillUnmount).to.have.been.calledBefore(Inner.prototype.componentDidUnmount); + }); + + it('should unmount children of high-order components without unmounting parent', () => { + let outer, inner2, counter=0; + + class Outer extends Component { + constructor(props, context) { + super(props, context); + outer = this; + this.state = { + child: this.props.child + }; + } + componentWillUnmount(){} + componentDidUnmount(){} + componentWillMount(){} + componentDidMount(){} + render(_, { child:C }) { + return ; + } + } + spyAll(Outer.prototype); + + class Inner extends Component { + componentWillUnmount(){} + componentDidUnmount(){} + componentWillMount(){} + componentDidMount(){} + render() { + return h('element'+(++counter)); + } + } + spyAll(Inner.prototype); + + class Inner2 extends Component { + constructor(props, context) { + super(props, context); + inner2 = this; + } + componentWillUnmount(){} + componentDidUnmount(){} + componentWillMount(){} + componentDidMount(){} + render() { + return h('element'+(++counter)); + } + } + spyAll(Inner2.prototype); + + render(, scratch); + + // outer should only have been mounted once + expect(Outer.prototype.componentWillMount, 'outer initial').to.have.been.calledOnce; + expect(Outer.prototype.componentDidMount, 'outer initial').to.have.been.calledOnce; + expect(Outer.prototype.componentWillUnmount, 'outer initial').not.to.have.been.called; + expect(Outer.prototype.componentDidUnmount, 'outer initial').not.to.have.been.called; + + // inner should only have been mounted once + expect(Inner.prototype.componentWillMount, 'inner initial').to.have.been.calledOnce; + expect(Inner.prototype.componentDidMount, 'inner initial').to.have.been.calledOnce; + expect(Inner.prototype.componentWillUnmount, 'inner initial').not.to.have.been.called; + expect(Inner.prototype.componentDidUnmount, 'inner initial').not.to.have.been.called; + + outer.setState({ child:Inner2 }); + outer.forceUpdate(); + + expect(Inner2.prototype.render).to.have.been.calledOnce; + + // outer should still only have been mounted once + expect(Outer.prototype.componentWillMount, 'outer swap').to.have.been.calledOnce; + expect(Outer.prototype.componentDidMount, 'outer swap').to.have.been.calledOnce; + expect(Outer.prototype.componentWillUnmount, 'outer swap').not.to.have.been.called; + expect(Outer.prototype.componentDidUnmount, 'outer swap').not.to.have.been.called; + + // inner should only have been mounted once + expect(Inner2.prototype.componentWillMount, 'inner2 swap').to.have.been.calledOnce; + expect(Inner2.prototype.componentDidMount, 'inner2 swap').to.have.been.calledOnce; + expect(Inner2.prototype.componentWillUnmount, 'inner2 swap').not.to.have.been.called; + expect(Inner2.prototype.componentDidUnmount, 'inner2 swap').not.to.have.been.called; + + inner2.forceUpdate(); + + expect(Inner2.prototype.render, 'inner2 update').to.have.been.calledTwice; + expect(Inner2.prototype.componentWillMount, 'inner2 update').to.have.been.calledOnce; + expect(Inner2.prototype.componentDidMount, 'inner2 update').to.have.been.calledOnce; + expect(Inner2.prototype.componentWillUnmount, 'inner2 update').not.to.have.been.called; + expect(Inner2.prototype.componentDidUnmount, 'inner2 update').not.to.have.been.called; + }); + + it('should remount when swapping between HOC child types', () => { + class Outer extends Component { + render({ child: Child }) { + return ; + } + } + + class Inner extends Component { + componentWillMount() {} + componentWillUnmount() {} + render() { + return
foo
; + } + } + spyAll(Inner.prototype); + + const InnerFunc = () => ( +
bar
+ ); + + let root = render(, scratch, root); + + expect(Inner.prototype.componentWillMount, 'initial mount').to.have.been.calledOnce; + expect(Inner.prototype.componentWillUnmount, 'initial mount').not.to.have.been.called; + + Inner.prototype.componentWillMount.reset(); + root = render(, scratch, root); + + expect(Inner.prototype.componentWillMount, 'unmount').not.to.have.been.called; + expect(Inner.prototype.componentWillUnmount, 'unmount').to.have.been.calledOnce; + + Inner.prototype.componentWillUnmount.reset(); + root = render(, scratch, root); + + expect(Inner.prototype.componentWillMount, 'remount').to.have.been.calledOnce; + expect(Inner.prototype.componentWillUnmount, 'remount').not.to.have.been.called; + }); + }); + + describe('Component Nesting', () => { + let useIntermediary = false; + + let createComponent = (Intermediary) => { + class C extends Component { + componentWillMount() {} + componentDidUnmount() {} + render({ children }) { + if (!useIntermediary) return children[0]; + let I = useIntermediary===true ? Intermediary : useIntermediary; + return {children}; + } + } + spyAll(C.prototype); + return C; + }; + + let createFunction = () => sinon.spy( ({ children }) => children[0] ); + + let root; + let rndr = n => root = render(n, scratch, root); + + let F1 = createFunction(); + let F2 = createFunction(); + let F3 = createFunction(); + + let C1 = createComponent(F1); + let C2 = createComponent(F2); + let C3 = createComponent(F3); + + let reset = () => [C1, C2, C3].reduce( + (acc, c) => acc.concat( Object.keys(c.prototype).map(key => c.prototype[key]) ), + [F1, F2, F3] + ).forEach( c => c.reset && c.reset() ); + + + it('should handle lifecycle for no intermediary in component tree', () => { + reset(); + rndr(Some Text); + + expect(C1.prototype.componentWillMount, 'initial mount').to.have.been.calledOnce; + expect(C2.prototype.componentWillMount, 'initial mount').to.have.been.calledOnce; + expect(C3.prototype.componentWillMount, 'initial mount').to.have.been.calledOnce; + + reset(); + rndr(Some Text); + + expect(C1.prototype.componentWillMount, 'unmount innermost, C1').not.to.have.been.called; + expect(C2.prototype.componentWillMount, 'unmount innermost, C2').not.to.have.been.called; + expect(C3.prototype.componentDidUnmount, 'unmount innermost, C3').to.have.been.calledOnce; + + reset(); + rndr(Some Text); + + expect(C1.prototype.componentWillMount, 'swap innermost').not.to.have.been.called; + expect(C2.prototype.componentDidUnmount, 'swap innermost').to.have.been.calledOnce; + expect(C3.prototype.componentWillMount, 'swap innermost').to.have.been.calledOnce; + + reset(); + rndr(Some Text); + + expect(C1.prototype.componentDidUnmount, 'inject between, C1').not.to.have.been.called; + expect(C1.prototype.componentWillMount, 'inject between, C1').not.to.have.been.called; + expect(C2.prototype.componentWillMount, 'inject between, C2').to.have.been.calledOnce; + expect(C3.prototype.componentDidUnmount, 'inject between, C3').to.have.been.calledOnce; + expect(C3.prototype.componentWillMount, 'inject between, C3').to.have.been.calledOnce; + }); + + + it('should handle lifecycle for nested intermediary functional components', () => { + useIntermediary = true; + + rndr(
); + reset(); + rndr(Some Text); + + expect(C1.prototype.componentWillMount, 'initial mount w/ intermediary fn, C1').to.have.been.calledOnce; + expect(C2.prototype.componentWillMount, 'initial mount w/ intermediary fn, C2').to.have.been.calledOnce; + expect(C3.prototype.componentWillMount, 'initial mount w/ intermediary fn, C3').to.have.been.calledOnce; + + reset(); + rndr(Some Text); + + expect(C1.prototype.componentWillMount, 'unmount innermost w/ intermediary fn, C1').not.to.have.been.called; + expect(C2.prototype.componentWillMount, 'unmount innermost w/ intermediary fn, C2').not.to.have.been.called; + expect(C3.prototype.componentDidUnmount, 'unmount innermost w/ intermediary fn, C3').to.have.been.calledOnce; + + reset(); + rndr(Some Text); + + expect(C1.prototype.componentWillMount, 'swap innermost w/ intermediary fn').not.to.have.been.called; + expect(C2.prototype.componentDidUnmount, 'swap innermost w/ intermediary fn').to.have.been.calledOnce; + expect(C3.prototype.componentWillMount, 'swap innermost w/ intermediary fn').to.have.been.calledOnce; + + reset(); + rndr(Some Text); + + expect(C1.prototype.componentDidUnmount, 'inject between, C1 w/ intermediary fn').not.to.have.been.called; + expect(C1.prototype.componentWillMount, 'inject between, C1 w/ intermediary fn').not.to.have.been.called; + expect(C2.prototype.componentWillMount, 'inject between, C2 w/ intermediary fn').to.have.been.calledOnce; + expect(C3.prototype.componentDidUnmount, 'inject between, C3 w/ intermediary fn').to.have.been.calledOnce; + expect(C3.prototype.componentWillMount, 'inject between, C3 w/ intermediary fn').to.have.been.calledOnce; + }); + + + it('should handle lifecycle for nested intermediary elements', () => { + useIntermediary = 'div'; + + rndr(
); + reset(); + rndr(Some Text); + + expect(C1.prototype.componentWillMount, 'initial mount w/ intermediary div, C1').to.have.been.calledOnce; + expect(C2.prototype.componentWillMount, 'initial mount w/ intermediary div, C2').to.have.been.calledOnce; + expect(C3.prototype.componentWillMount, 'initial mount w/ intermediary div, C3').to.have.been.calledOnce; + + reset(); + rndr(Some Text); + + expect(C1.prototype.componentWillMount, 'unmount innermost w/ intermediary div, C1').not.to.have.been.called; + expect(C2.prototype.componentDidUnmount, 'unmount innermost w/ intermediary div, C2 ummount').not.to.have.been.called; + // @TODO this was just incorrect? + // expect(C2.prototype.componentWillMount, 'unmount innermost w/ intermediary div, C2').not.to.have.been.called; + expect(C3.prototype.componentDidUnmount, 'unmount innermost w/ intermediary div, C3').to.have.been.calledOnce; + + reset(); + rndr(Some Text); + + expect(C1.prototype.componentWillMount, 'swap innermost w/ intermediary div').not.to.have.been.called; + expect(C2.prototype.componentDidUnmount, 'swap innermost w/ intermediary div').to.have.been.calledOnce; + expect(C3.prototype.componentWillMount, 'swap innermost w/ intermediary div').to.have.been.calledOnce; + + reset(); + rndr(Some Text); + + expect(C1.prototype.componentDidUnmount, 'inject between, C1 w/ intermediary div').not.to.have.been.called; + expect(C1.prototype.componentWillMount, 'inject between, C1 w/ intermediary div').not.to.have.been.called; + expect(C2.prototype.componentWillMount, 'inject between, C2 w/ intermediary div').to.have.been.calledOnce; + expect(C3.prototype.componentDidUnmount, 'inject between, C3 w/ intermediary div').to.have.been.calledOnce; + expect(C3.prototype.componentWillMount, 'inject between, C3 w/ intermediary div').to.have.been.calledOnce; + }); + }); +}); diff --git a/test/browser/context.js b/test/browser/context.js new file mode 100644 index 000000000..e62a948a4 --- /dev/null +++ b/test/browser/context.js @@ -0,0 +1,170 @@ +import { h, render, Component } from '../../src/preact'; +/** @jsx h */ + +describe('context', () => { + let scratch; + + before( () => { + scratch = document.createElement('div'); + (document.body || document.documentElement).appendChild(scratch); + }); + + beforeEach( () => { + scratch.innerHTML = ''; + }); + + after( () => { + scratch.parentNode.removeChild(scratch); + scratch = null; + }); + + it('should pass context to grandchildren', () => { + const CONTEXT = { a:'a' }; + const PROPS = { b:'b' }; + // let inner; + + class Outer extends Component { + getChildContext() { + return CONTEXT; + } + render(props) { + return
; + } + } + sinon.spy(Outer.prototype, 'getChildContext'); + + class Inner extends Component { + // constructor() { + // super(); + // inner = this; + // } + shouldComponentUpdate() { return true; } + componentWillReceiveProps() {} + componentWillUpdate() {} + componentDidUpdate() {} + render(props, state, context) { + return
{ context && context.a }
; + } + } + sinon.spy(Inner.prototype, 'shouldComponentUpdate'); + sinon.spy(Inner.prototype, 'componentWillReceiveProps'); + sinon.spy(Inner.prototype, 'componentWillUpdate'); + sinon.spy(Inner.prototype, 'componentDidUpdate'); + sinon.spy(Inner.prototype, 'render'); + + render(, scratch, scratch.lastChild); + + expect(Outer.prototype.getChildContext).to.have.been.calledOnce; + + // initial render does not invoke anything but render(): + expect(Inner.prototype.render).to.have.been.calledWith({}, {}, CONTEXT); + + CONTEXT.foo = 'bar'; + render(, scratch, scratch.lastChild); + + expect(Outer.prototype.getChildContext).to.have.been.calledTwice; + + expect(Inner.prototype.shouldComponentUpdate).to.have.been.calledOnce.and.calledWith(PROPS, {}, CONTEXT); + expect(Inner.prototype.componentWillReceiveProps).to.have.been.calledWith(PROPS, CONTEXT); + expect(Inner.prototype.componentWillUpdate).to.have.been.calledWith(PROPS, {}); + expect(Inner.prototype.componentDidUpdate).to.have.been.calledWith({}, {}); + expect(Inner.prototype.render).to.have.been.calledWith(PROPS, {}, CONTEXT); + + + /* Future: + * Newly created context objects are *not* currently cloned. + * This test checks that they *are* cloned. + */ + // Inner.prototype.render.reset(); + // CONTEXT.foo = 'baz'; + // inner.forceUpdate(); + // expect(Inner.prototype.render).to.have.been.calledWith(PROPS, {}, { a:'a', foo:'bar' }); + }); + + it('should pass context to direct children', () => { + const CONTEXT = { a:'a' }; + const PROPS = { b:'b' }; + + class Outer extends Component { + getChildContext() { + return CONTEXT; + } + render(props) { + return ; + } + } + sinon.spy(Outer.prototype, 'getChildContext'); + + class Inner extends Component { + shouldComponentUpdate() { return true; } + componentWillReceiveProps() {} + componentWillUpdate() {} + componentDidUpdate() {} + render(props, state, context) { + return
{ context && context.a }
; + } + } + sinon.spy(Inner.prototype, 'shouldComponentUpdate'); + sinon.spy(Inner.prototype, 'componentWillReceiveProps'); + sinon.spy(Inner.prototype, 'componentWillUpdate'); + sinon.spy(Inner.prototype, 'componentDidUpdate'); + sinon.spy(Inner.prototype, 'render'); + + render(, scratch, scratch.lastChild); + + expect(Outer.prototype.getChildContext).to.have.been.calledOnce; + + // initial render does not invoke anything but render(): + expect(Inner.prototype.render).to.have.been.calledWith({}, {}, CONTEXT); + + CONTEXT.foo = 'bar'; + render(, scratch, scratch.lastChild); + + expect(Outer.prototype.getChildContext).to.have.been.calledTwice; + + expect(Inner.prototype.shouldComponentUpdate).to.have.been.calledOnce.and.calledWith(PROPS, {}, CONTEXT); + expect(Inner.prototype.componentWillReceiveProps).to.have.been.calledWith(PROPS, CONTEXT); + expect(Inner.prototype.componentWillUpdate).to.have.been.calledWith(PROPS, {}); + expect(Inner.prototype.componentDidUpdate).to.have.been.calledWith({}, {}); + expect(Inner.prototype.render).to.have.been.calledWith(PROPS, {}, CONTEXT); + + // make sure render() could make use of context.a + expect(Inner.prototype.render).to.have.returned(sinon.match({ children:['a'] })); + }); + + it('should preserve existing context properties when creating child contexts', () => { + let outerContext = { outer:true }, + innerContext = { inner:true }; + class Outer extends Component { + getChildContext() { + return { outerContext }; + } + render() { + return
; + } + } + + class Inner extends Component { + getChildContext() { + return { innerContext }; + } + render() { + return ; + } + } + + class InnerMost extends Component { + render() { + return test; + } + } + + sinon.spy(Inner.prototype, 'render'); + sinon.spy(InnerMost.prototype, 'render'); + + render(, scratch); + + expect(Inner.prototype.render).to.have.been.calledWith({}, {}, { outerContext }); + expect(InnerMost.prototype.render).to.have.been.calledWith({}, {}, { outerContext, innerContext }); + }); +}); diff --git a/test/browser/keys.js b/test/browser/keys.js new file mode 100644 index 000000000..e0a6b9ae8 --- /dev/null +++ b/test/browser/keys.js @@ -0,0 +1,85 @@ +import { h, Component, render } from '../../src/preact'; +/** @jsx h */ + +describe('keys', () => { + let scratch; + + before( () => { + scratch = document.createElement('div'); + (document.body || document.documentElement).appendChild(scratch); + }); + + beforeEach( () => { + scratch.innerHTML = ''; + }); + + after( () => { + scratch.parentNode.removeChild(scratch); + scratch = null; + }); + + // See developit/preact-compat#21 + it('should remove orphaned keyed nodes', () => { + let root = render(( +
+
1
+
  • a
  • +
    + ), scratch); + + root = render(( +
    +
    2
    +
  • b
  • +
    + ), scratch, root); + + expect(scratch.innerHTML).to.equal('
    2
  • b
  • '); + }); + + it('should set VNode#key property', () => { + expect(
    ).to.have.property('key').that.is.empty; + expect(
    ).to.have.property('key').that.is.empty; + expect(
    ).to.have.property('key', '1'); + }); + + it('should remove keyed nodes (#232)', () => { + class App extends Component { + componentDidMount() { + setTimeout(() => this.setState({opened: true,loading: true}), 10); + setTimeout(() => this.setState({opened: true,loading: false}), 20); + } + + render({ opened, loading }) { + return ( + +
    This div needs to be here for this to break
    + { opened && !loading &&
    {[]}
    } +
    + ); + } + } + + class BusyIndicator extends Component { + render({ children, busy }) { + return
    + { children && children.length ? children :
    } +
    +
    indicator
    +
    indicator
    +
    indicator
    +
    +
    ; + } + } + + let root; + + root = render(, scratch, root); + root = render(, scratch, root); + root = render(, scratch, root); + + let html = String(root.innerHTML).replace(/ class=""/g, ''); + expect(html).to.equal('
    This div needs to be here for this to break
    indicator
    indicator
    indicator
    '); + }); +}); diff --git a/test/browser/lifecycle.js b/test/browser/lifecycle.js new file mode 100644 index 000000000..d6204ca8f --- /dev/null +++ b/test/browser/lifecycle.js @@ -0,0 +1,493 @@ +import { h, render, rerender, Component } from '../../src/preact'; +/** @jsx h */ + +let spyAll = obj => Object.keys(obj).forEach( key => sinon.spy(obj,key) ); + +describe('Lifecycle methods', () => { + let scratch; + + before( () => { + scratch = document.createElement('div'); + (document.body || document.documentElement).appendChild(scratch); + }); + + beforeEach( () => { + scratch.innerHTML = ''; + }); + + after( () => { + scratch.parentNode.removeChild(scratch); + scratch = null; + }); + + + describe('#componentWillUpdate', () => { + it('should NOT be called on initial render', () => { + class ReceivePropsComponent extends Component { + componentWillUpdate() {} + render() { + return
    ; + } + } + sinon.spy(ReceivePropsComponent.prototype, 'componentWillUpdate'); + render(, scratch); + expect(ReceivePropsComponent.prototype.componentWillUpdate).not.to.have.been.called; + }); + + it('should be called when rerender with new props from parent', () => { + let doRender; + class Outer extends Component { + constructor(p, c) { + super(p, c); + this.state = { i: 0 }; + } + componentDidMount() { + doRender = () => this.setState({ i: this.state.i + 1 }); + } + render(props, { i }) { + return ; + } + } + class Inner extends Component { + componentWillUpdate(nextProps, nextState) { + expect(nextProps).to.be.deep.equal({i: 1}); + expect(nextState).to.be.deep.equal({}); + } + render() { + return
    ; + } + } + sinon.spy(Inner.prototype, 'componentWillUpdate'); + sinon.spy(Outer.prototype, 'componentDidMount'); + + // Initial render + render(, scratch); + expect(Inner.prototype.componentWillUpdate).not.to.have.been.called; + + // Rerender inner with new props + doRender(); + rerender(); + expect(Inner.prototype.componentWillUpdate).to.have.been.called; + }); + + it('should be called on new state', () => { + let doRender; + class ReceivePropsComponent extends Component { + componentWillUpdate() {} + componentDidMount() { + doRender = () => this.setState({ i: this.state.i + 1 }); + } + render() { + return
    ; + } + } + sinon.spy(ReceivePropsComponent.prototype, 'componentWillUpdate'); + render(, scratch); + expect(ReceivePropsComponent.prototype.componentWillUpdate).not.to.have.been.called; + + doRender(); + rerender(); + expect(ReceivePropsComponent.prototype.componentWillUpdate).to.have.been.called; + }); + }); + + describe('#componentWillReceiveProps', () => { + it('should NOT be called on initial render', () => { + class ReceivePropsComponent extends Component { + componentWillReceiveProps() {} + render() { + return
    ; + } + } + sinon.spy(ReceivePropsComponent.prototype, 'componentWillReceiveProps'); + render(, scratch); + expect(ReceivePropsComponent.prototype.componentWillReceiveProps).not.to.have.been.called; + }); + + it('should be called when rerender with new props from parent', () => { + let doRender; + class Outer extends Component { + constructor(p, c) { + super(p, c); + this.state = { i: 0 }; + } + componentDidMount() { + doRender = () => this.setState({ i: this.state.i + 1 }); + } + render(props, { i }) { + return ; + } + } + class Inner extends Component { + componentWillMount() { + expect(this.props.i).to.be.equal(0); + } + componentWillReceiveProps(nextProps) { + expect(nextProps.i).to.be.equal(1); + } + render() { + return
    ; + } + } + sinon.spy(Inner.prototype, 'componentWillReceiveProps'); + sinon.spy(Outer.prototype, 'componentDidMount'); + + // Initial render + render(, scratch); + expect(Inner.prototype.componentWillReceiveProps).not.to.have.been.called; + + // Rerender inner with new props + doRender(); + rerender(); + expect(Inner.prototype.componentWillReceiveProps).to.have.been.called; + }); + + it('should be called in right execution order', () => { + let doRender; + class Outer extends Component { + constructor(p, c) { + super(p, c); + this.state = { i: 0 }; + } + componentDidMount() { + doRender = () => this.setState({ i: this.state.i + 1 }); + } + render(props, { i }) { + return ; + } + } + class Inner extends Component { + componentDidUpdate() { + expect(Inner.prototype.componentWillReceiveProps).to.have.been.called; + expect(Inner.prototype.componentWillUpdate).to.have.been.called; + } + componentWillReceiveProps() { + expect(Inner.prototype.componentWillUpdate).not.to.have.been.called; + expect(Inner.prototype.componentDidUpdate).not.to.have.been.called; + } + componentWillUpdate() { + expect(Inner.prototype.componentWillReceiveProps).to.have.been.called; + expect(Inner.prototype.componentDidUpdate).not.to.have.been.called; + } + render() { + return
    ; + } + } + sinon.spy(Inner.prototype, 'componentWillReceiveProps'); + sinon.spy(Inner.prototype, 'componentDidUpdate'); + sinon.spy(Inner.prototype, 'componentWillUpdate'); + sinon.spy(Outer.prototype, 'componentDidMount'); + + render(, scratch); + doRender(); + rerender(); + + expect(Inner.prototype.componentWillReceiveProps).to.have.been.calledBefore(Inner.prototype.componentWillUpdate); + expect(Inner.prototype.componentWillUpdate).to.have.been.calledBefore(Inner.prototype.componentDidUpdate); + }); + }); + + + let _it = it; + describe('#constructor and component(Did|Will)(Mount|Unmount)', () => { + /* global DISABLE_FLAKEY */ + let it = DISABLE_FLAKEY ? xit : _it; + + let setState; + class Outer extends Component { + constructor(p, c) { + super(p, c); + this.state = { show:true }; + setState = s => this.setState(s); + } + render(props, { show }) { + return ( +
    + { show && ( + + ) } +
    + ); + } + } + + class LifecycleTestComponent extends Component { + constructor(p, c) { super(p, c); this._constructor(); } + _constructor() {} + componentWillMount() {} + componentDidMount() {} + componentWillUnmount() {} + componentDidUnmount() {} + render() { return
    ; } + } + + class Inner extends LifecycleTestComponent { + render() { + return ( +
    + +
    + ); + } + } + + class InnerMost extends LifecycleTestComponent { + render() { return
    ; } + } + + let spies = ['_constructor', 'componentWillMount', 'componentDidMount', 'componentWillUnmount', 'componentDidUnmount']; + + let verifyLifycycleMethods = (TestComponent) => { + let proto = TestComponent.prototype; + spies.forEach( s => sinon.spy(proto, s) ); + let reset = () => spies.forEach( s => proto[s].reset() ); + + it('should be invoked for components on initial render', () => { + reset(); + render(, scratch); + expect(proto._constructor).to.have.been.called; + expect(proto.componentDidMount).to.have.been.called; + expect(proto.componentWillMount).to.have.been.calledBefore(proto.componentDidMount); + expect(proto.componentDidMount).to.have.been.called; + }); + + it('should be invoked for components on unmount', () => { + reset(); + setState({ show:false }); + rerender(); + + expect(proto.componentDidUnmount).to.have.been.called; + expect(proto.componentWillUnmount).to.have.been.calledBefore(proto.componentDidUnmount); + expect(proto.componentDidUnmount).to.have.been.called; + }); + + it('should be invoked for components on re-render', () => { + reset(); + setState({ show:true }); + rerender(); + + expect(proto._constructor).to.have.been.called; + expect(proto.componentDidMount).to.have.been.called; + expect(proto.componentWillMount).to.have.been.calledBefore(proto.componentDidMount); + expect(proto.componentDidMount).to.have.been.called; + }); + }; + + describe('inner components', () => { + verifyLifycycleMethods(Inner); + }); + + describe('innermost components', () => { + verifyLifycycleMethods(InnerMost); + }); + + describe('when shouldComponentUpdate() returns false', () => { + let setState; + + class Outer extends Component { + constructor() { + super(); + this.state = { show:true }; + setState = s => this.setState(s); + } + render(props, { show }) { + return ( +
    + { show && ( +
    + +
    + ) } +
    + ); + } + } + + class Inner extends Component { + shouldComponentUpdate(){ return false; } + componentWillMount() {} + componentDidMount() {} + componentWillUnmount() {} + componentDidUnmount() {} + render() { + return
    ; + } + } + + let proto = Inner.prototype; + let spies = ['componentWillMount', 'componentDidMount', 'componentWillUnmount', 'componentDidUnmount']; + spies.forEach( s => sinon.spy(proto, s) ); + + let reset = () => spies.forEach( s => proto[s].reset() ); + + beforeEach( () => reset() ); + + it('should be invoke normally on initial mount', () => { + render(, scratch); + expect(proto.componentWillMount).to.have.been.called; + expect(proto.componentWillMount).to.have.been.calledBefore(proto.componentDidMount); + expect(proto.componentDidMount).to.have.been.called; + }); + + it('should be invoked normally on unmount', () => { + setState({ show:false }); + rerender(); + + expect(proto.componentWillUnmount).to.have.been.called; + expect(proto.componentWillUnmount).to.have.been.calledBefore(proto.componentDidUnmount); + expect(proto.componentDidUnmount).to.have.been.called; + }); + + it('should still invoke mount for shouldComponentUpdate():false', () => { + setState({ show:true }); + rerender(); + + expect(proto.componentWillMount).to.have.been.called; + expect(proto.componentWillMount).to.have.been.calledBefore(proto.componentDidMount); + expect(proto.componentDidMount).to.have.been.called; + }); + + it('should still invoke unmount for shouldComponentUpdate():false', () => { + setState({ show:false }); + rerender(); + + expect(proto.componentWillUnmount).to.have.been.called; + expect(proto.componentWillUnmount).to.have.been.calledBefore(proto.componentDidUnmount); + expect(proto.componentDidUnmount).to.have.been.called; + }); + }); + }); + + describe('Lifecycle DOM Timing', () => { + it('should be invoked when dom does (DidMount, WillUnmount) or does not (WillMount, DidUnmount) exist', () => { + let setState; + class Outer extends Component { + constructor() { + super(); + this.state = { show:true }; + setState = s => { + this.setState(s); + this.forceUpdate(); + }; + } + componentWillMount() { + expect(document.getElementById('OuterDiv'), 'Outer componentWillMount').to.not.exist; + } + componentDidMount() { + expect(document.getElementById('OuterDiv'), 'Outer componentDidMount').to.exist; + } + componentWillUnmount() { + expect(document.getElementById('OuterDiv'), 'Outer componentWillUnmount').to.exist; + } + componentDidUnmount() { + expect(document.getElementById('OuterDiv'), 'Outer componentDidUnmount').to.not.exist; + } + render(props, { show }) { + return ( +
    + { show && ( +
    + +
    + ) } +
    + ); + } + } + + class Inner extends Component { + componentWillMount() { + expect(document.getElementById('InnerDiv'), 'Inner componentWillMount').to.not.exist; + } + componentDidMount() { + expect(document.getElementById('InnerDiv'), 'Inner componentDidMount').to.exist; + } + componentWillUnmount() { + // @TODO Component mounted into elements (non-components) + // are currently unmounted after those elements, so their + // DOM is unmounted prior to the method being called. + //expect(document.getElementById('InnerDiv'), 'Inner componentWillUnmount').to.exist; + } + componentDidUnmount() { + expect(document.getElementById('InnerDiv'), 'Inner componentDidUnmount').to.not.exist; + } + + render() { + return
    ; + } + } + + let proto = Inner.prototype; + let spies = ['componentWillMount', 'componentDidMount', 'componentWillUnmount', 'componentDidUnmount']; + spies.forEach( s => sinon.spy(proto, s) ); + + let reset = () => spies.forEach( s => proto[s].reset() ); + + render(, scratch); + expect(proto.componentWillMount).to.have.been.called; + expect(proto.componentWillMount).to.have.been.calledBefore(proto.componentDidMount); + expect(proto.componentDidMount).to.have.been.called; + + reset(); + setState({ show:false }); + + expect(proto.componentWillUnmount).to.have.been.called; + expect(proto.componentWillUnmount).to.have.been.calledBefore(proto.componentDidUnmount); + expect(proto.componentDidUnmount).to.have.been.called; + + reset(); + setState({ show:true }); + + expect(proto.componentWillMount).to.have.been.called; + expect(proto.componentWillMount).to.have.been.calledBefore(proto.componentDidMount); + expect(proto.componentDidMount).to.have.been.called; + }); + + it('should remove this.base for HOC', () => { + let createComponent = (name, fn) => { + class C extends Component { + componentWillUnmount() { + expect(this.base, `${name}.componentWillUnmount`).to.exist; + } + componentDidUnmount() { + expect(this.base, `${name}.componentDidUnmount`).not.to.exist; + } + render(props) { return fn(props); } + } + spyAll(C.prototype); + return C; + }; + + class Wrapper extends Component { + render({ children }) { + return
    {children}
    ; + } + } + + let One = createComponent('One', () => one ); + let Two = createComponent('Two', () => two ); + let Three = createComponent('Three', () => three ); + + let components = [One, Two, Three]; + + let Selector = createComponent('Selector', ({ page }) => { + let Child = components[page]; + return ; + }); + + class App extends Component { + render(_, { page }) { + return ; + } + } + + let app; + render( app=c } />, scratch); + + for (let i=0; i<20; i++) { + app.setState({ page: i%components.length }); + app.forceUpdate(); + } + }); + }); +}); diff --git a/test/browser/linked-state.js b/test/browser/linked-state.js new file mode 100644 index 000000000..1ca84cdc6 --- /dev/null +++ b/test/browser/linked-state.js @@ -0,0 +1,98 @@ +import { Component } from '../../src/preact'; +import { createLinkedState } from '../../src/linked-state'; + +describe('linked-state', () => { + class TestComponent extends Component { } + let testComponent, linkFunction; + + before( () => { + testComponent = new TestComponent(); + sinon.spy(TestComponent.prototype, 'setState'); + }); + + describe('createLinkedState without eventPath argument', () => { + + before( () => { + linkFunction = createLinkedState(testComponent,'testStateKey'); + expect(linkFunction).to.be.a('function'); + }); + + beforeEach( () => { + TestComponent.prototype['setState'].reset(); + }); + + it('should use value attribute on text input when no eventPath is supplied', () => { + let element = document.createElement('input'); + element.type= 'text'; + element.value = 'newValue'; + + linkFunction({ currentTarget: element }); + + expect(TestComponent.prototype.setState).to.have.been.calledOnce; + expect(TestComponent.prototype.setState).to.have.been.calledWith({'testStateKey': 'newValue'}); + + linkFunction.call(element); + + expect(TestComponent.prototype.setState).to.have.been.calledTwice; + expect(TestComponent.prototype.setState.secondCall).to.have.been.calledWith({'testStateKey': 'newValue'}); + }); + + it('should use checked attribute on checkbox input when no eventPath is supplied', () => { + let checkboxElement = document.createElement('input'); + checkboxElement.type= 'checkbox'; + checkboxElement.checked = true; + + linkFunction({ currentTarget: checkboxElement }); + + expect(TestComponent.prototype.setState).to.have.been.calledOnce; + expect(TestComponent.prototype.setState).to.have.been.calledWith({'testStateKey': true}); + }); + + it('should use checked attribute on radio input when no eventPath is supplied', () => { + let radioElement = document.createElement('input'); + radioElement.type= 'radio'; + radioElement.checked = true; + + linkFunction({ currentTarget: radioElement }); + + expect(TestComponent.prototype.setState).to.have.been.calledOnce; + expect(TestComponent.prototype.setState).to.have.been.calledWith({'testStateKey': true}); + }); + + + it('should set dot notated state key appropriately', () => { + linkFunction = createLinkedState(testComponent,'nested.state.key'); + let element = document.createElement('input'); + element.type= 'text'; + element.value = 'newValue'; + + linkFunction({ currentTarget: element }); + + expect(TestComponent.prototype.setState).to.have.been.calledOnce; + expect(TestComponent.prototype.setState).to.have.been.calledWith({nested: {state: {key: 'newValue'}}}); + }); + + }); + + describe('createLinkedState with eventPath argument', () => { + + before( () => { + linkFunction = createLinkedState(testComponent,'testStateKey', 'nested.path'); + expect(linkFunction).to.be.a('function'); + }); + + beforeEach( () => { + TestComponent.prototype['setState'].reset(); + }); + + it('should give precedence to nested.path on event over nested.path on component', () => { + let event = {nested: {path: 'nestedPathValueFromEvent'}}; + let component = {_component: {nested: {path: 'nestedPathValueFromComponent'}}}; + + linkFunction.call(component, event); + + expect(TestComponent.prototype.setState).to.have.been.calledOnce; + expect(TestComponent.prototype.setState).to.have.been.calledWith({'testStateKey': 'nestedPathValueFromEvent'}); + }); + }); +}); diff --git a/test/browser/performance.js b/test/browser/performance.js new file mode 100644 index 000000000..e1f7d7956 --- /dev/null +++ b/test/browser/performance.js @@ -0,0 +1,245 @@ +/*global coverage, ENABLE_PERFORMANCE, NODE_ENV*/ +/*eslint no-console:0*/ +/** @jsx h */ + +let { h, Component, render } = require(NODE_ENV==='production' ? '../../dist/preact.min.js' : '../../src/preact'); + +const MULTIPLIER = ENABLE_PERFORMANCE ? (coverage ? 5 : 1) : 999999; + + +let now = typeof performance!=='undefined' && performance.now ? () => performance.now() : () => +new Date(); + +function loop(iter, time) { + let start = now(), + count = 0; + while ( now()-start < time ) { + count++; + iter(); + } + return count; +} + + +function benchmark(iter, callback) { + let a = 0; + function noop() { + try { a++; } finally { a += Math.random(); } + } + + // warm + for (let i=3; i--; ) noop(), iter(); + + let count = 5, + time = 200, + passes = 0, + noops = loop(noop, time), + iterations = 0; + + function next() { + iterations += loop(iter, time); + setTimeout(++passes===count ? done : next, 10); + } + + function done() { + let ticks = Math.round(noops / iterations * count), + hz = iterations / count / time * 1000, + message = `${hz|0}/s (${ticks} ticks)`; + callback({ iterations, noops, count, time, ticks, hz, message }); + } + + next(); +} + + +describe('performance', function() { + let scratch; + + this.timeout(10000); + + before( () => { + if (coverage) { + console.warn('WARNING: Code coverage is enabled, which dramatically reduces performance. Do not pay attention to these numbers.'); + } + scratch = document.createElement('div'); + (document.body || document.documentElement).appendChild(scratch); + }); + + beforeEach( () => { + scratch.innerHTML = ''; + }); + + after( () => { + scratch.parentNode.removeChild(scratch); + scratch = null; + }); + + it('should rerender without changes fast', done => { + let jsx = ( +
    +
    +

    a {'b'} c {0} d

    + +
    +
    +
    {}}> + + +
    + + +
    + + + + + + +
    +
    +
    + ); + + let root; + benchmark( () => { + root = render(jsx, scratch, root); + }, ({ ticks, message }) => { + console.log(`PERF: empty diff: ${message}`); + expect(ticks).to.be.below(350 * MULTIPLIER); + done(); + }); + }); + + it('should rerender repeated trees fast', done => { + class Header extends Component { + render() { + return ( +
    +

    a {'b'} c {0} d

    + +
    + ); + } + } + class Form extends Component { + render() { + return ( +
    {}}> + + +
    + + +
    + + + ); + } + } + class ButtonBar extends Component { + render() { + return ( + + + + + + + ); + } + } + class Button extends Component { + render(props) { + return + + + + + + +
    + ); + }, ({ ticks, message }) => { + console.log(`PERF: large VTree: ${message}`); + expect(ticks).to.be.below(2000 * MULTIPLIER); + done(); + }); + }); +}); diff --git a/test/browser/refs.js b/test/browser/refs.js new file mode 100644 index 000000000..89678b76e --- /dev/null +++ b/test/browser/refs.js @@ -0,0 +1,287 @@ +import { h, render, Component } from '../../src/preact'; +/** @jsx h */ + +// gives call count and argument errors names (otherwise sinon just uses "spy"): +let spy = (name, ...args) => { + let spy = sinon.spy(...args); + spy.displayName = `spy('${name}')`; + return spy; +}; + +describe('refs', () => { + let scratch; + + before( () => { + scratch = document.createElement('div'); + (document.body || document.documentElement).appendChild(scratch); + }); + + beforeEach( () => { + scratch.innerHTML = ''; + }); + + after( () => { + scratch.parentNode.removeChild(scratch); + scratch = null; + }); + + it('should invoke refs in render()', () => { + let ref = spy('ref'); + render(
    , scratch); + expect(ref).to.have.been.calledOnce.and.calledWith(scratch.firstChild); + }); + + it('should invoke refs in Component.render()', () => { + let outer = spy('outer'), + inner = spy('inner'); + class Foo extends Component { + render() { + return ( +
    + +
    + ); + } + } + render(, scratch); + + expect(outer).to.have.been.calledWith(scratch.firstChild); + expect(inner).to.have.been.calledWith(scratch.firstChild.firstChild); + }); + + it('should pass components to ref functions', () => { + let ref = spy('ref'), + instance; + class Foo extends Component { + constructor() { + super(); + instance = this; + } + render() { + return
    ; + } + } + render(, scratch); + + expect(ref).to.have.been.calledOnce.and.calledWith(instance); + }); + + it('should pass rendered DOM from functional components to ref functions', () => { + let ref = spy('ref'); + + const Foo = () =>
    ; + + let root = render(, scratch); + expect(ref).to.have.been.calledOnce.and.calledWith(scratch.firstChild); + + ref.reset(); + render(, scratch, root); + expect(ref).to.have.been.calledOnce.and.calledWith(scratch.firstChild); + + ref.reset(); + render(, scratch, root); + expect(ref).to.have.been.calledOnce.and.calledWith(null); + }); + + it('should pass children to ref functions', () => { + let outer = spy('outer'), + inner = spy('inner'), + rerender, inst; + class Outer extends Component { + constructor() { + super(); + rerender = () => this.forceUpdate(); + } + render() { + return ( +
    + +
    + ); + } + } + class Inner extends Component { + constructor() { + super(); + inst = this; + } + render() { + return ; + } + } + + let root = render(, scratch); + + expect(outer).to.have.been.calledOnce.and.calledWith(inst); + expect(inner).to.have.been.calledOnce.and.calledWith(inst.base); + + outer.reset(); + inner.reset(); + + rerender(); + + expect(outer).to.have.been.calledOnce.and.calledWith(inst); + expect(inner).to.have.been.calledOnce.and.calledWith(inst.base); + + outer.reset(); + inner.reset(); + + render(
    , scratch, root); + + expect(outer).to.have.been.calledOnce.and.calledWith(null); + expect(inner).to.have.been.calledOnce.and.calledWith(null); + }); + + it('should pass high-order children to ref functions', () => { + let outer = spy('outer'), + inner = spy('inner'), + innermost = spy('innermost'), + outerInst, + innerInst; + class Outer extends Component { + constructor() { + super(); + outerInst = this; + } + render() { + return ; + } + } + class Inner extends Component { + constructor() { + super(); + innerInst = this; + } + render() { + return ; + } + } + + let root = render(, scratch); + + expect(outer, 'outer initial').to.have.been.calledOnce.and.calledWith(outerInst); + expect(inner, 'inner initial').to.have.been.calledOnce.and.calledWith(innerInst); + expect(innermost, 'innerMost initial').to.have.been.calledOnce.and.calledWith(innerInst.base); + + outer.reset(); + inner.reset(); + innermost.reset(); + root = render(, scratch, root); + + expect(outer, 'outer update').to.have.been.calledOnce.and.calledWith(outerInst); + expect(inner, 'inner update').to.have.been.calledOnce.and.calledWith(innerInst); + expect(innermost, 'innerMost update').to.have.been.calledOnce.and.calledWith(innerInst.base); + + outer.reset(); + inner.reset(); + innermost.reset(); + root = render(
    , scratch, root); + + expect(outer, 'outer unmount').to.have.been.calledOnce.and.calledWith(null); + expect(inner, 'inner unmount').to.have.been.calledOnce.and.calledWith(null); + expect(innermost, 'innerMost unmount').to.have.been.calledOnce.and.calledWith(null); + }); + + it('should not pass ref into component as a prop', () => { + let foo = spy('foo'), + bar = spy('bar'); + + class Foo extends Component { + render(){ return
    ; } + } + const Bar = spy('Bar', () =>
    ); + + sinon.spy(Foo.prototype, 'render'); + + render(( +
    + + +
    + ), scratch); + + expect(Foo.prototype.render).to.have.been.calledWithExactly({ a:'a' }, { }, { }); + expect(Bar).to.have.been.calledWithExactly({ b:'b', ref:bar }, { }); + }); + + // Test for #232 + it('should only null refs after unmount', () => { + let root, outer, inner; + + class TestUnmount extends Component { + componentWillUnmount() { + expect(this).to.have.property('outer', outer); + expect(this).to.have.property('inner', inner); + } + + componentDidUnmount() { + expect(this).to.have.property('outer', null); + expect(this).to.have.property('inner', null); + } + + render() { + return ( +
    this.outer=c }> +
    this.inner=c } /> +
    + ); + } + } + + sinon.spy(TestUnmount.prototype, 'componentWillUnmount'); + sinon.spy(TestUnmount.prototype, 'componentDidUnmount'); + + root = render(
    , scratch, root); + outer = scratch.querySelector('#outer'); + inner = scratch.querySelector('#inner'); + + expect(TestUnmount.prototype.componentWillUnmount).not.to.have.been.called; + expect(TestUnmount.prototype.componentDidUnmount).not.to.have.been.called; + + root = render(
    , scratch, root); + + expect(TestUnmount.prototype.componentWillUnmount).to.have.been.calledOnce; + expect(TestUnmount.prototype.componentDidUnmount).to.have.been.calledOnce; + }); + + it('should null and re-invoke refs when swapping component root element type', () => { + let inst; + + class App extends Component { + render() { + return
    ; + } + } + + class Child extends Component { + constructor(props, context) { + super(props, context); + this.state = { show:false }; + inst = this; + } + handleMount(){} + render(_, { show }) { + if (!show) return
    ; + return some test content; + } + } + sinon.spy(Child.prototype, 'handleMount'); + + render(, scratch); + expect(inst.handleMount).to.have.been.calledOnce.and.calledWith(scratch.querySelector('#div')); + inst.handleMount.reset(); + + inst.setState({ show:true }); + inst.forceUpdate(); + expect(inst.handleMount).to.have.been.calledTwice; + expect(inst.handleMount.firstCall).to.have.been.calledWith(null); + expect(inst.handleMount.secondCall).to.have.been.calledWith(scratch.querySelector('#span')); + inst.handleMount.reset(); + + inst.setState({ show:false }); + inst.forceUpdate(); + expect(inst.handleMount).to.have.been.calledTwice; + expect(inst.handleMount.firstCall).to.have.been.calledWith(null); + expect(inst.handleMount.secondCall).to.have.been.calledWith(scratch.querySelector('#div')); + }); +}); diff --git a/test/browser/render.js b/test/browser/render.js new file mode 100644 index 000000000..5d18fb282 --- /dev/null +++ b/test/browser/render.js @@ -0,0 +1,439 @@ +/* global DISABLE_FLAKEY */ + +import { h, render } from '../../src/preact'; +/** @jsx h */ + +function getAttributes(node) { + let attrs = {}; + for (let i=node.attributes.length; i--; ) { + attrs[node.attributes[i].name] = node.attributes[i].value; + } + return attrs; +} + +// hacky normalization of attribute order across browsers. +function sortAttributes(html) { + return html.replace(/<([a-z0-9-]+)((?:\s[a-z0-9:_.-]+=".*?")+)((?:\s*\/)?>)/gi, (s, pre, attrs, after) => { + let list = attrs.match(/\s[a-z0-9:_.-]+=".*?"/gi).sort( (a, b) => a>b ? 1 : -1 ); + if (~after.indexOf('/')) after = '>'; + return '<' + pre + list.join('') + after; + }); +} + +describe('render()', () => { + let scratch; + + before( () => { + scratch = document.createElement('div'); + (document.body || document.documentElement).appendChild(scratch); + }); + + beforeEach( () => { + scratch.innerHTML = ''; + }); + + after( () => { + scratch.parentNode.removeChild(scratch); + scratch = null; + }); + + it('should create empty nodes (<* />)', () => { + render(
    , scratch); + expect(scratch.childNodes) + .to.have.length(1) + .and.to.have.deep.property('0.nodeName', 'DIV'); + + scratch.innerHTML = ''; + + render(, scratch); + expect(scratch.childNodes) + .to.have.length(1) + .and.to.have.deep.property('0.nodeName', 'SPAN'); + + scratch.innerHTML = ''; + + render(, scratch); + render(, scratch); + expect(scratch.childNodes).to.have.length(2); + expect(scratch.childNodes[0]).to.have.property('nodeName', 'FOO'); + expect(scratch.childNodes[1]).to.have.property('nodeName', 'X-BAR'); + }); + + it('should nest empty nodes', () => { + render(( +
    + + + +
    + ), scratch); + + expect(scratch.childNodes) + .to.have.length(1) + .and.to.have.deep.property('0.nodeName', 'DIV'); + + let c = scratch.childNodes[0].childNodes; + expect(c).to.have.length(3); + expect(c).to.have.deep.property('0.nodeName', 'SPAN'); + expect(c).to.have.deep.property('1.nodeName', 'FOO'); + expect(c).to.have.deep.property('2.nodeName', 'X-BAR'); + }); + + it('should not render falsey values', () => { + render(( +
    + {null},{undefined},{false},{0},{NaN} +
    + ), scratch); + + expect(scratch.firstChild).to.have.property('innerHTML', ',,,0,NaN'); + }); + + it('should clear falsey attributes', () => { + let root = render(( +
    + ), scratch); + + root = render(( +
    + ), scratch, root); + + expect(getAttributes(scratch.firstChild), 'from previous truthy values').to.eql({ + a0: '0', + anan: 'NaN' + }); + + scratch.innerHTML = ''; + + root = render(( +
    + ), scratch); + + expect(getAttributes(scratch.firstChild), 'initial render').to.eql({ + a0: '0', + anan: 'NaN' + }); + }); + + it('should clear falsey input values', () => { + let root = render(( +
    + + + + +
    + ), scratch); + + expect(root.children[0]).to.have.property('value', '0'); + expect(root.children[1]).to.have.property('value', 'false'); + expect(root.children[2]).to.have.property('value', ''); + expect(root.children[3]).to.have.property('value', ''); + }); + + it('should clear falsey DOM properties', () => { + let root; + function test(val) { + root = render(( +
    + + + + ), scratch, root); + } + + test('2'); + test(false); + expect(scratch).to.have.property('innerHTML', '
    ', 'for false'); + + test('3'); + test(null); + expect(scratch).to.have.property('innerHTML', '
    ', 'for null'); + + test('4'); + test(undefined); + expect(scratch).to.have.property('innerHTML', '
    ', 'for undefined'); + }); + + it('should apply string attributes', () => { + render(
    , scratch); + + let div = scratch.childNodes[0]; + expect(div).to.have.deep.property('attributes.length', 2); + + expect(div).to.have.deep.property('attributes[0].name', 'foo'); + expect(div).to.have.deep.property('attributes[0].value', 'bar'); + + expect(div).to.have.deep.property('attributes[1].name', 'data-foo'); + expect(div).to.have.deep.property('attributes[1].value', 'databar'); + }); + + it('should apply class as String', () => { + render(
    , scratch); + expect(scratch.childNodes[0]).to.have.property('className', 'foo'); + }); + + it('should alias className to class', () => { + render(
    , scratch); + expect(scratch.childNodes[0]).to.have.property('className', 'bar'); + }); + + it('should apply style as String', () => { + render(
    , scratch); + expect(scratch.childNodes[0]).to.have.deep.property('style.cssText') + .that.matches(/top\s*:\s*5px\s*/) + .and.matches(/position\s*:\s*relative\s*/); + }); + + it('should only register on* functions as handlers', () => { + let click = () => {}, + onclick = () => {}; + + let proto = document.createElement('div').constructor.prototype; + + sinon.spy(proto, 'addEventListener'); + + render(
    , scratch); + + expect(scratch.childNodes[0]).to.have.deep.property('attributes.length', 0); + + expect(proto.addEventListener).to.have.been.calledOnce + .and.to.have.been.calledWithExactly('click', sinon.match.func, false); + + proto.addEventListener.restore(); + }); + + it('should add and remove event handlers', () => { + let click = sinon.spy(), + mousedown = sinon.spy(); + + let proto = document.createElement('div').constructor.prototype; + sinon.spy(proto, 'addEventListener'); + sinon.spy(proto, 'removeEventListener'); + + function fireEvent(on, type) { + let e = document.createEvent('Event'); + e.initEvent(type, true, true); + on.dispatchEvent(e); + } + + render(
    click(1) } onMouseDown={ mousedown } />, scratch); + + expect(proto.addEventListener).to.have.been.calledTwice + .and.to.have.been.calledWith('click') + .and.calledWith('mousedown'); + + fireEvent(scratch.childNodes[0], 'click'); + expect(click).to.have.been.calledOnce + .and.calledWith(1); + + proto.addEventListener.reset(); + click.reset(); + + render(
    click(2) } />, scratch, scratch.firstChild); + + expect(proto.addEventListener).not.to.have.been.called; + + expect(proto.removeEventListener) + .to.have.been.calledOnce + .and.calledWith('mousedown'); + + fireEvent(scratch.childNodes[0], 'click'); + expect(click).to.have.been.calledOnce + .and.to.have.been.calledWith(2); + + fireEvent(scratch.childNodes[0], 'mousedown'); + expect(mousedown).not.to.have.been.called; + + proto.removeEventListener.reset(); + click.reset(); + mousedown.reset(); + + render(
    , scratch, scratch.firstChild); + + expect(proto.removeEventListener) + .to.have.been.calledOnce + .and.calledWith('click'); + + fireEvent(scratch.childNodes[0], 'click'); + expect(click).not.to.have.been.called; + + proto.addEventListener.restore(); + proto.removeEventListener.restore(); + }); + + it('should use capturing for events that do not bubble', () => { + let click = sinon.spy(), + focus = sinon.spy(); + + let root = render(( +
    +
    + ), scratch); + + root.firstElementChild.click(); + root.firstElementChild.focus(); + + expect(click, 'click').to.have.been.calledOnce; + + if (DISABLE_FLAKEY!==true) { + // Focus delegation requires a 50b hack I'm not sure we want to incur + expect(focus, 'focus').to.have.been.calledOnce; + + // IE doesn't set it + expect(click).to.have.been.calledWithMatch({ eventPhase: 0 }); // capturing + expect(focus).to.have.been.calledWithMatch({ eventPhase: 0 }); // capturing + } + }); + + it('should serialize style objects', () => { + let root = render(( +
    + test +
    + ), scratch); + + let { style } = scratch.childNodes[0]; + expect(style).to.have.property('color').that.equals('rgb(255, 255, 255)'); + expect(style).to.have.property('background').that.contains('rgb(255, 100, 0)'); + expect(style).to.have.property('backgroundPosition').that.equals('10px 10px'); + expect(style).to.have.property('backgroundSize', 'cover'); + expect(style).to.have.property('padding', '5px'); + expect(style).to.have.property('top', '100px'); + expect(style).to.have.property('left', '100%'); + + root = render(( +
    test
    + ), scratch, root); + + expect(root).to.have.deep.property('style.cssText').that.equals('color: rgb(0, 255, 255);'); + + root = render(( +
    test
    + ), scratch, root); + + expect(root).to.have.deep.property('style.cssText').that.equals('display: inline;'); + + root = render(( +
    test
    + ), scratch, root); + + expect(root).to.have.deep.property('style.cssText').that.equals('background-color: rgb(0, 255, 255);'); + }); + + it('should serialize class/className', () => { + render(
    , scratch); + + let { className } = scratch.childNodes[0]; + expect(className).to.be.a.string; + expect(className.split(' ')) + .to.include.members(['yes1', 'yes2', 'yes3', 'yes4', 'yes5']) + .and.not.include.members(['no1', 'no2', 'no3', 'no4', 'no5']); + }); + + it('should support dangerouslySetInnerHTML', () => { + let html = 'foo & bar'; + let root = render(
    , scratch); + + expect(scratch.firstChild).to.have.property('innerHTML', html); + expect(scratch.innerHTML).to.equal('
    '+html+'
    '); + + root = render(
    ab
    , scratch, root); + + expect(scratch).to.have.property('innerHTML', `
    ab
    `); + + root = render(
    , scratch, root); + + expect(scratch.innerHTML).to.equal('
    '+html+'
    '); + }); + + it('should reconcile mutated DOM attributes', () => { + let check = p => render(, scratch, scratch.lastChild), + value = () => scratch.lastChild.checked, + setValue = p => scratch.lastChild.checked = p; + check(true); + expect(value()).to.equal(true); + check(false); + expect(value()).to.equal(false); + check(true); + expect(value()).to.equal(true); + setValue(true); + check(false); + expect(value()).to.equal(false); + setValue(false); + check(true); + expect(value()).to.equal(true); + }); + + it('should ignore props.children if children are manually specified', () => { + expect( +
    c
    + ).to.eql( +
    c
    + ); + }); + + it('should reorder child pairs', () => { + let root = render(( +
    + a + b +
    + ), scratch, root); + + let a = scratch.firstChild.firstChild; + let b = scratch.firstChild.lastChild; + + expect(a).to.have.property('nodeName', 'A'); + expect(b).to.have.property('nodeName', 'B'); + + root = render(( +
    + b + a +
    + ), scratch, root); + + expect(scratch.firstChild.firstChild).to.have.property('nodeName', 'B'); + expect(scratch.firstChild.lastChild).to.have.property('nodeName', 'A'); + expect(scratch.firstChild.firstChild).to.equal(b); + expect(scratch.firstChild.lastChild).to.equal(a); + }); + + // Discussion: https://github.com/developit/preact/issues/287 + ('HTMLDataListElement' in window ? it : xit)('should allow to pass through as an attribute', () => { + render(( +
    + + + + + + +
    + ), scratch); + + let html = scratch.firstElementChild.firstElementChild.outerHTML; + expect(sortAttributes(html)).to.equal(sortAttributes('')); + }); +}); diff --git a/test/browser/spec.js b/test/browser/spec.js new file mode 100644 index 000000000..eb48151f0 --- /dev/null +++ b/test/browser/spec.js @@ -0,0 +1,124 @@ +import { h, render, rerender, Component } from '../../src/preact'; +/** @jsx h */ + +describe('Component spec', () => { + let scratch; + + before( () => { + scratch = document.createElement('div'); + (document.body || document.documentElement).appendChild(scratch); + }); + + beforeEach( () => { + scratch.innerHTML = ''; + }); + + after( () => { + scratch.parentNode.removeChild(scratch); + scratch = null; + }); + + describe('defaultProps', () => { + it('should apply default props on initial render', () => { + class WithDefaultProps extends Component { + constructor(props, context) { + super(props, context); + expect(props).to.be.deep.equal({ + fieldA: 1, fieldB: 2, + fieldC: 1, fieldD: 2 + }); + } + render() { + return
    ; + } + } + WithDefaultProps.defaultProps = { fieldC: 1, fieldD: 1 }; + render(, scratch); + }); + + it('should apply default props on rerender', () => { + let doRender; + class Outer extends Component { + constructor() { + super(); + this.state = { i:1 }; + } + componentDidMount() { + doRender = () => this.setState({ i: 2 }); + } + render(props, { i }) { + return ; + } + } + class WithDefaultProps extends Component { + constructor(props, context) { + super(props, context); + this.ctor(props, context); + } + ctor(){} + componentWillReceiveProps() {} + render() { + return
    ; + } + } + WithDefaultProps.defaultProps = { fieldC: 1, fieldD: 1 }; + + let proto = WithDefaultProps.prototype; + sinon.spy(proto, 'ctor'); + sinon.spy(proto, 'componentWillReceiveProps'); + sinon.spy(proto, 'render'); + + render(, scratch); + doRender(); + + const PROPS1 = { + fieldA: 1, fieldB: 1, + fieldC: 1, fieldD: 1 + }; + + const PROPS2 = { + fieldA: 1, fieldB: 2, + fieldC: 1, fieldD: 2 + }; + + expect(proto.ctor).to.have.been.calledWith(PROPS1); + expect(proto.render).to.have.been.calledWith(PROPS1); + + rerender(); + + // expect(proto.ctor).to.have.been.calledWith(PROPS2); + expect(proto.componentWillReceiveProps).to.have.been.calledWith(PROPS2); + expect(proto.render).to.have.been.calledWith(PROPS2); + }); + + // @TODO: migrate this to preact-compat + xit('should cache default props', () => { + class WithDefaultProps extends Component { + constructor(props, context) { + super(props, context); + expect(props).to.be.deep.equal({ + fieldA: 1, fieldB: 2, + fieldC: 1, fieldD: 2, + fieldX: 10 + }); + } + getDefaultProps() { + return { fieldA: 1, fieldB: 1 }; + } + render() { + return
    ; + } + } + WithDefaultProps.defaultProps = { fieldC: 1, fieldD: 1 }; + sinon.spy(WithDefaultProps.prototype, 'getDefaultProps'); + render(( +
    + + + +
    + ), scratch); + expect(WithDefaultProps.prototype.getDefaultProps).to.be.calledOnce; + }); + }); +}); diff --git a/test/browser/svg.js b/test/browser/svg.js new file mode 100644 index 000000000..684f4dd96 --- /dev/null +++ b/test/browser/svg.js @@ -0,0 +1,112 @@ +import { h, render } from '../../src/preact'; +/** @jsx h */ + + +// hacky normalization of attribute order across browsers. +function sortAttributes(html) { + return html.replace(/<([a-z0-9-]+)((?:\s[a-z0-9:_.-]+=".*?")+)((?:\s*\/)?>)/gi, (s, pre, attrs, after) => { + let list = attrs.match(/\s[a-z0-9:_.-]+=".*?"/gi).sort( (a, b) => a>b ? 1 : -1 ); + if (~after.indexOf('/')) after = '>'; + return '<' + pre + list.join('') + after; + }); +} + + +describe('svg', () => { + let scratch; + + before( () => { + scratch = document.createElement('div'); + (document.body || document.documentElement).appendChild(scratch); + }); + + beforeEach( () => { + scratch.innerHTML = ''; + }); + + after( () => { + scratch.parentNode.removeChild(scratch); + scratch = null; + }); + + it('should render SVG to string', () => { + render(( + + + + ), scratch); + + let html = sortAttributes(String(scratch.innerHTML).replace(' xmlns="http://www.w3.org/2000/svg"', '')); + expect(html).to.equal(sortAttributes(` + + + + `.replace(/[\n\t]+/g,''))); + }); + + it('should render SVG to DOM', () => { + const Demo = () => ( + + + + ); + render(, scratch); + + let html = sortAttributes(String(scratch.innerHTML).replace(' xmlns="http://www.w3.org/2000/svg"', '')); + expect(html).to.equal(sortAttributes('')); + }); + + it('should use attributes for className', () => { + const Demo = ({ c }) => ( + + + + ); + let root = render(, scratch, root); + sinon.spy(root, 'removeAttribute'); + root = render(, scratch, root); + expect(root.removeAttribute).to.have.been.calledOnce.and.calledWith('class'); + root.removeAttribute.restore(); + + root = render(
    , scratch, root); + root = render(, scratch, root); + sinon.spy(root, 'setAttribute'); + root = render(, scratch, root); + expect(root.setAttribute).to.have.been.calledOnce.and.calledWith('class', 'foo_2'); + root.setAttribute.restore(); + root = render(, scratch, root); + root = render(, scratch, root); + }); + + it('should still support class attribute', () => { + render(( + + ), scratch); + + expect(scratch.innerHTML).to.contain(` class="foo bar"`); + }); + + it('should serialize class', () => { + render(( + + ), scratch); + + expect(scratch.innerHTML).to.contain(` class="foo other"`); + }); + + it('should switch back to HTML for ', () => { + render(( + + + + test + + + + ), scratch); + + expect(scratch.getElementsByTagName('a')) + .to.have.property('0') + .that.is.a('HTMLAnchorElement'); + }); +}); diff --git a/test/karma.conf.js b/test/karma.conf.js new file mode 100644 index 000000000..6ed5397fb --- /dev/null +++ b/test/karma.conf.js @@ -0,0 +1,126 @@ +/*eslint no-var:0, object-shorthand:0 */ + +var coverage = String(process.env.COVERAGE)!=='false', + sauceLabs = String(process.env.SAUCELABS).match(/^(1|true)$/gi) && !String(process.env.TRAVIS_PULL_REQUEST).match(/^(1|true)$/gi), + performance = !coverage && !sauceLabs && String(process.env.PERFORMANCE)!=='false', + webpack = require('webpack'); + +var sauceLabsLaunchers = { + sl_chrome: { + base: 'SauceLabs', + browserName: 'chrome' + }, + sl_firefox: { + base: 'SauceLabs', + browserName: 'firefox' + }, + sl_ios_safari: { + base: 'SauceLabs', + browserName: 'iphone', + platform: 'OS X 10.9', + version: '7.1' + }, + sl_ie_11: { + base: 'SauceLabs', + browserName: 'internet explorer', + version: '11' + }, + sl_ie_10: { + base: 'SauceLabs', + browserName: 'internet explorer', + version: '10' + }, + sl_ie_9: { + base: 'SauceLabs', + browserName: 'internet explorer', + version: '9' + } +}; + +module.exports = function(config) { + config.set({ + browsers: sauceLabs ? Object.keys(sauceLabsLaunchers) : ['PhantomJS'], + + frameworks: ['source-map-support', 'mocha', 'chai-sinon'], + + reporters: ['mocha'].concat( + coverage ? 'coverage' : [], + sauceLabs ? 'saucelabs' : [] + ), + + coverageReporter: { + reporters: [ + { + type: 'text-summary' + }, + { + type: 'html', + dir: __dirname+'/../coverage' + } + ] + }, + + mochaReporter: { + showDiff: true + }, + + browserLogOptions: { terminal: true }, + browserConsoleLogOptions: { terminal: true }, + + browserNoActivityTimeout: 5 * 60 * 1000, + + // sauceLabs: { + // tunnelIdentifier: process.env.TRAVIS_JOB_NUMBER || ('local'+require('./package.json').version), + // startConnect: false + // }, + + customLaunchers: sauceLabsLaunchers, + + files: [ + { pattern: '{browser,shared}/**.js', watched: false } + ], + + preprocessors: { + '**/*': ['webpack', 'sourcemap'] + }, + + webpack: { + devtool: 'inline-source-map', + module: { + /* Transpile source and test files */ + preLoaders: [ + { + test: /\.jsx?$/, + exclude: /node_modules/, + loader: 'babel', + query: { + loose: 'all', + blacklist: ['es6.tailCall'] + } + } + ], + /* Only Instrument our source files for coverage */ + loaders: [].concat( coverage ? { + test: /\.jsx?$/, + loader: 'isparta', + include: /src/ + } : []) + }, + resolve: { + modulesDirectories: [__dirname, 'node_modules'] + }, + plugins: [ + new webpack.DefinePlugin({ + coverage: coverage, + NODE_ENV: JSON.stringify(process.env.NODE_ENV || ''), + ENABLE_PERFORMANCE: performance, + DISABLE_FLAKEY: !!String(process.env.FLAKEY).match(/^(0|false)$/gi) + }) + ] + }, + + webpackMiddleware: { + noInfo: true + } + }); +}; diff --git a/test/node/index.js b/test/node/index.js new file mode 100644 index 000000000..81fb567fb --- /dev/null +++ b/test/node/index.js @@ -0,0 +1 @@ +// this is just a placeholder diff --git a/test/shared/exports.js b/test/shared/exports.js new file mode 100644 index 000000000..7ef3c659b --- /dev/null +++ b/test/shared/exports.js @@ -0,0 +1,21 @@ +import preact, { h, Component, render, rerender, options } from '../../src/preact'; +import { expect } from 'chai'; + +describe('preact', () => { + it('should be available as a default export', () => { + expect(preact).to.be.an('object'); + expect(preact).to.have.property('h', h); + expect(preact).to.have.property('Component', Component); + expect(preact).to.have.property('render', render); + expect(preact).to.have.property('rerender', rerender); + expect(preact).to.have.property('options', options); + }); + + it('should be available as named exports', () => { + expect(h).to.be.a('function'); + expect(Component).to.be.a('function'); + expect(render).to.be.a('function'); + expect(rerender).to.be.a('function'); + expect(options).to.exist.and.be.an('object'); + }); +}); diff --git a/test/shared/h.js b/test/shared/h.js new file mode 100644 index 000000000..b0cf7f0e8 --- /dev/null +++ b/test/shared/h.js @@ -0,0 +1,201 @@ +import { h } from '../../src/preact'; +import { VNode } from '../../src/vnode'; +import { expect } from 'chai'; + +/*eslint-env browser, mocha */ + +/** @jsx h */ + +let flatten = obj => JSON.parse(JSON.stringify(obj)); + +describe('h(jsx)', () => { + it('should return a VNode', () => { + let r; + expect( () => r = h('foo') ).not.to.throw(); + expect(r).to.be.an('object'); + expect(r).to.be.an.instanceof(VNode); + expect(r).to.have.property('nodeName', 'foo'); + expect(r).to.have.property('attributes', undefined); + expect(r).to.have.property('children', undefined); + }); + + it('should perserve raw attributes', () => { + let attrs = { foo:'bar', baz:10, func:()=>{} }, + r = h('foo', attrs); + expect(r).to.be.an('object') + .with.property('attributes') + .that.deep.equals(attrs); + }); + + it('should support element children', () => { + let r = h( + 'foo', + null, + h('bar'), + h('baz') + ); + + expect(r).to.be.an('object') + .with.property('children') + .that.deep.equals([ + new VNode('bar'), + new VNode('baz') + ]); + }); + + it('should support multiple element children, given as arg list', () => { + let r = h( + 'foo', + null, + h('bar'), + h('baz', null, h('test')) + ); + + r = flatten(r); + + expect(r).to.be.an('object') + .with.property('children') + .that.deep.equals([ + { nodeName:'bar' }, + { nodeName:'baz', children:[ + { nodeName:'test' } + ]} + ]); + }); + + it('should handle multiple element children, given as an array', () => { + let r = h( + 'foo', + null, + [ + h('bar'), + h('baz', null, h('test')) + ] + ); + + r = flatten(r); + + expect(r).to.be.an('object') + .with.property('children') + .that.deep.equals([ + { nodeName:'bar' }, + { nodeName:'baz', children:[ + { nodeName:'test' } + ]} + ]); + }); + + it('should handle multiple children, flattening one layer as needed', () => { + let r = h( + 'foo', + null, + h('bar'), + [ + h('baz', null, h('test')) + ] + ); + + r = flatten(r); + + expect(r).to.be.an('object') + .with.property('children') + .that.deep.equals([ + { nodeName:'bar' }, + { nodeName:'baz', children:[ + { nodeName:'test' } + ]} + ]); + }); + + it('should support nested children', () => { + const m = x => h(x); + expect( + h('foo', null, m('a'), [m('b'), m('c')], m('d')) + ).to.have.property('children').that.eql(['a', 'b', 'c', 'd'].map(m)); + + expect( + h('foo', null, [m('a'), [m('b'), m('c')], m('d')]) + ).to.have.property('children').that.eql(['a', 'b', 'c', 'd'].map(m)); + + expect( + h('foo', { children: [m('a'), [m('b'), m('c')], m('d')] }) + ).to.have.property('children').that.eql(['a', 'b', 'c', 'd'].map(m)); + + expect( + h('foo', { children: [[m('a'), [m('b'), m('c')], m('d')]] }) + ).to.have.property('children').that.eql(['a', 'b', 'c', 'd'].map(m)); + + expect( + h('foo', { children: m('a') }) + ).to.have.property('children').that.eql([m('a')]); + + expect( + h('foo', { children: 'a' }) + ).to.have.property('children').that.eql(['a']); + }); + + it('should support text children', () => { + let r = h( + 'foo', + null, + 'textstuff' + ); + + expect(r).to.be.an('object') + .with.property('children') + .with.length(1) + .with.property('0') + .that.equals('textstuff'); + }); + + it('should merge adjacent text children', () => { + let r = h( + 'foo', + null, + 'one', + 'two', + h('bar'), + 'three', + h('baz'), + h('baz'), + 'four', + null, + 'five', + 'six' + ); + + r = flatten(r); + + expect(r).to.be.an('object') + .with.property('children') + .that.deep.equals([ + 'onetwo', + { nodeName:'bar' }, + 'three', + { nodeName:'baz' }, + { nodeName:'baz' }, + 'fourfivesix' + ]); + }); + + it('should merge nested adjacent text children', () => { + let r = h( + 'foo', + null, + 'one', + ['two', null, 'three'], + null, + ['four', null, 'five', null], + 'six', + null + ); + + r = flatten(r); + + expect(r).to.be.an('object') + .with.property('children') + .that.deep.equals([ + 'onetwothreefourfivesix' + ]); + }); +}); -- cgit v1.2.3