diff options
Diffstat (limited to 'preact/debug/test/browser')
-rw-r--r-- | preact/debug/test/browser/component-stack-2.test.js | 54 | ||||
-rw-r--r-- | preact/debug/test/browser/component-stack.test.js | 86 | ||||
-rw-r--r-- | preact/debug/test/browser/debug-compat.test.js | 81 | ||||
-rw-r--r-- | preact/debug/test/browser/debug-hooks.test.js | 111 | ||||
-rw-r--r-- | preact/debug/test/browser/debug-suspense.test.js | 190 | ||||
-rw-r--r-- | preact/debug/test/browser/debug.options.test.js | 135 | ||||
-rw-r--r-- | preact/debug/test/browser/debug.test.js | 624 | ||||
-rw-r--r-- | preact/debug/test/browser/fakeDevTools.js | 1 | ||||
-rw-r--r-- | preact/debug/test/browser/serializeVNode.test.js | 66 |
9 files changed, 1348 insertions, 0 deletions
diff --git a/preact/debug/test/browser/component-stack-2.test.js b/preact/debug/test/browser/component-stack-2.test.js new file mode 100644 index 0000000..a9a75af --- /dev/null +++ b/preact/debug/test/browser/component-stack-2.test.js @@ -0,0 +1,54 @@ +import { createElement, render, Component } from 'preact'; +import 'preact/debug'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; + +/** @jsx createElement */ + +// This test is not part of component-stack.test.js to avoid it being +// transpiled with '@babel/plugin-transform-react-jsx-source' enabled. + +describe('component stack', () => { + /** @type {HTMLDivElement} */ + let scratch; + + let errors = []; + let warnings = []; + + beforeEach(() => { + scratch = setupScratch(); + + errors = []; + warnings = []; + sinon.stub(console, 'error').callsFake(e => errors.push(e)); + sinon.stub(console, 'warn').callsFake(w => warnings.push(w)); + }); + + afterEach(() => { + console.error.restore(); + console.warn.restore(); + teardown(scratch); + }); + + it('should print a warning when "@babel/plugin-transform-react-jsx-source" is not installed', () => { + function Foo() { + return <Thrower />; + } + + class Thrower extends Component { + constructor(props) { + super(props); + this.setState({ foo: 1 }); + } + + render() { + return <div>foo</div>; + } + } + + render(<Foo />, scratch); + + expect( + warnings[0].indexOf('@babel/plugin-transform-react-jsx-source') > -1 + ).to.equal(true); + }); +}); diff --git a/preact/debug/test/browser/component-stack.test.js b/preact/debug/test/browser/component-stack.test.js new file mode 100644 index 0000000..87cd085 --- /dev/null +++ b/preact/debug/test/browser/component-stack.test.js @@ -0,0 +1,86 @@ +import { createElement, render, Component } from 'preact'; +import 'preact/debug'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; + +/** @jsx createElement */ + +describe('component stack', () => { + /** @type {HTMLDivElement} */ + let scratch; + + let errors = []; + let warnings = []; + + const getStack = arr => arr[0].split('\n\n')[1]; + + beforeEach(() => { + scratch = setupScratch(); + + errors = []; + warnings = []; + sinon.stub(console, 'error').callsFake(e => errors.push(e)); + sinon.stub(console, 'warn').callsFake(w => warnings.push(w)); + }); + + afterEach(() => { + console.error.restore(); + console.warn.restore(); + teardown(scratch); + }); + + it('should print component stack', () => { + function Foo() { + return <Thrower />; + } + + class Thrower extends Component { + constructor(props) { + super(props); + this.setState({ foo: 1 }); + } + + render() { + return <div>foo</div>; + } + } + + render(<Foo />, scratch); + + let lines = getStack(warnings).split('\n'); + expect(lines[0].indexOf('Thrower') > -1).to.equal(true); + expect(lines[1].indexOf('Foo') > -1).to.equal(true); + }); + + it('should only print owners', () => { + function Foo(props) { + return <div>{props.children}</div>; + } + + function Bar() { + return ( + <Foo> + <Thrower /> + </Foo> + ); + } + + class Thrower extends Component { + render() { + return ( + <table> + <td> + <tr>foo</tr> + </td> + </table> + ); + } + } + + render(<Bar />, scratch); + + let lines = getStack(errors).split('\n'); + expect(lines[0].indexOf('td') > -1).to.equal(true); + expect(lines[1].indexOf('Thrower') > -1).to.equal(true); + expect(lines[2].indexOf('Bar') > -1).to.equal(true); + }); +}); diff --git a/preact/debug/test/browser/debug-compat.test.js b/preact/debug/test/browser/debug-compat.test.js new file mode 100644 index 0000000..aea131f --- /dev/null +++ b/preact/debug/test/browser/debug-compat.test.js @@ -0,0 +1,81 @@ +import { createElement, render, createRef } from 'preact'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import './fakeDevTools'; +import 'preact/debug'; +import * as PropTypes from 'prop-types'; + +// eslint-disable-next-line no-duplicate-imports +import { resetPropWarnings } from 'preact/debug'; +import { forwardRef, createPortal } from 'preact/compat'; + +const h = createElement; +/** @jsx createElement */ + +describe('debug compat', () => { + let scratch; + let root; + let errors = []; + let warnings = []; + + beforeEach(() => { + errors = []; + warnings = []; + scratch = setupScratch(); + sinon.stub(console, 'error').callsFake(e => errors.push(e)); + sinon.stub(console, 'warn').callsFake(w => warnings.push(w)); + + root = document.createElement('div'); + document.body.appendChild(root); + }); + + afterEach(() => { + /** @type {*} */ + (console.error).restore(); + console.warn.restore(); + teardown(scratch); + + document.body.removeChild(root); + }); + + describe('portals', () => { + it('should not throw an invalid render argument for a portal.', () => { + function Foo(props) { + return <div>{createPortal(props.children, root)}</div>; + } + expect(() => render(<Foo>foobar</Foo>, scratch)).not.to.throw(); + }); + }); + + describe('PropTypes', () => { + beforeEach(() => { + resetPropWarnings(); + }); + + it('should not fail if ref is passed to comp wrapped in forwardRef', () => { + // This test ensures compat with airbnb/prop-types-exact, mui exact prop types util, etc. + + const Foo = forwardRef(function Foo(props, ref) { + return <h1 ref={ref}>{props.text}</h1>; + }); + + Foo.propTypes = { + text: PropTypes.string.isRequired, + ref(props) { + if ('ref' in props) { + throw new Error( + 'ref should not be passed to prop-types valiation!' + ); + } + } + }; + + const ref = createRef(); + + render(<Foo ref={ref} text="123" />, scratch); + + expect(console.error).not.been.called; + + expect(ref.current).to.not.be.undefined; + }); + }); +}); diff --git a/preact/debug/test/browser/debug-hooks.test.js b/preact/debug/test/browser/debug-hooks.test.js new file mode 100644 index 0000000..84bc028 --- /dev/null +++ b/preact/debug/test/browser/debug-hooks.test.js @@ -0,0 +1,111 @@ +import { createElement, render, Component } from 'preact'; +import { useState, useEffect } from 'preact/hooks'; +import 'preact/debug'; +import { act } from 'preact/test-utils'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; + +/** @jsx createElement */ + +describe('debug with hooks', () => { + let scratch; + let errors = []; + let warnings = []; + + beforeEach(() => { + errors = []; + warnings = []; + scratch = setupScratch(); + sinon.stub(console, 'error').callsFake(e => errors.push(e)); + sinon.stub(console, 'warn').callsFake(w => warnings.push(w)); + }); + + afterEach(() => { + console.error.restore(); + console.warn.restore(); + teardown(scratch); + }); + + it('should throw an error when using a hook outside a render', () => { + class Foo extends Component { + componentWillMount() { + useState(); + } + + render() { + return this.props.children; + } + } + + class App extends Component { + render() { + return <p>test</p>; + } + } + const fn = () => + act(() => + render( + <Foo> + <App /> + </Foo>, + scratch + ) + ); + expect(fn).to.throw(/Hook can only be invoked from render/); + }); + + it('should throw an error when invoked outside of a component', () => { + function foo() { + useEffect(() => {}); // Pretend to use a hook + return <p>test</p>; + } + + const fn = () => + act(() => { + render(foo(), scratch); + }); + expect(fn).to.throw(/Hook can only be invoked from render/); + }); + + it('should throw an error when invoked outside of a component before render', () => { + function Foo(props) { + useEffect(() => {}); // Pretend to use a hook + return props.children; + } + + const fn = () => + act(() => { + useState(); + render(<Foo>Hello!</Foo>, scratch); + }); + expect(fn).to.throw(/Hook can only be invoked from render/); + }); + + it('should throw an error when invoked outside of a component after render', () => { + function Foo(props) { + useEffect(() => {}); // Pretend to use a hook + return props.children; + } + + const fn = () => + act(() => { + render(<Foo>Hello!</Foo>, scratch); + useState(); + }); + expect(fn).to.throw(/Hook can only be invoked from render/); + }); + + it('should throw an error when invoked inside an effect callback', () => { + function Foo(props) { + useEffect(() => { + useState(); + }); + return props.children; + } + + const fn = () => + act(() => { + render(<Foo>Hello!</Foo>, scratch); + }); + expect(fn).to.throw(/Hook can only be invoked from render/); + }); +}); diff --git a/preact/debug/test/browser/debug-suspense.test.js b/preact/debug/test/browser/debug-suspense.test.js new file mode 100644 index 0000000..a436154 --- /dev/null +++ b/preact/debug/test/browser/debug-suspense.test.js @@ -0,0 +1,190 @@ +import { createElement, render, lazy, Suspense } from 'preact/compat'; +import 'preact/debug'; +import { setupRerender } from 'preact/test-utils'; +import { + setupScratch, + teardown, + serializeHtml +} from '../../../test/_util/helpers'; + +/** @jsx createElement */ + +describe('debug with suspense', () => { + /** @type {HTMLDivElement} */ + let scratch; + let rerender; + let errors = []; + let warnings = []; + + beforeEach(() => { + errors = []; + warnings = []; + scratch = setupScratch(); + rerender = setupRerender(); + sinon.stub(console, 'error').callsFake(e => errors.push(e)); + sinon.stub(console, 'warn').callsFake(w => warnings.push(w)); + }); + + afterEach(() => { + console.error.restore(); + console.warn.restore(); + teardown(scratch); + }); + + it('should throw on missing <Suspense>', () => { + function Foo() { + throw Promise.resolve(); + } + + expect(() => render(<Foo />, scratch)).to.throw; + }); + + it('should throw an error when using lazy and missing Suspense', () => { + const Foo = () => <div>Foo</div>; + const LazyComp = lazy( + () => new Promise(resolve => resolve({ default: Foo })) + ); + const fn = () => { + render(<LazyComp />, scratch); + }; + + expect(fn).to.throw(/Missing Suspense/gi); + }); + + describe('PropTypes', () => { + it('should validate propTypes inside lazy()', () => { + function Baz(props) { + return <h1>{props.unhappy}</h1>; + } + + Baz.propTypes = { + unhappy: function alwaysThrows(obj, key) { + if (obj[key] === 'signal') throw Error('got prop inside lazy()'); + } + }; + + const loader = Promise.resolve({ default: Baz }); + const LazyBaz = lazy(() => loader); + + const suspense = ( + <Suspense fallback={<div>fallback...</div>}> + <LazyBaz unhappy="signal" /> + </Suspense> + ); + render(suspense, scratch); + rerender(); // render fallback + + expect(console.error).to.not.be.called; + expect(serializeHtml(scratch)).to.equal('<div>fallback...</div>'); + + return loader.then(() => { + rerender(); + expect(errors.length).to.equal(1); + expect(errors[0].includes('got prop')).to.equal(true); + expect(serializeHtml(scratch)).to.equal('<h1>signal</h1>'); + }); + }); + + describe('warn for PropTypes on lazy()', () => { + it('should log the function name', () => { + const loader = Promise.resolve({ + default: function MyLazyLoaded() { + return <div>Hi there</div>; + } + }); + const FakeLazy = lazy(() => loader); + FakeLazy.propTypes = {}; + const suspense = ( + <Suspense fallback={<div>fallback...</div>}> + <FakeLazy /> + </Suspense> + ); + render(suspense, scratch); + rerender(); // Render fallback + + expect(serializeHtml(scratch)).to.equal('<div>fallback...</div>'); + + return loader.then(() => { + rerender(); + expect(console.warn).to.be.calledTwice; + expect(warnings[1].includes('MyLazyLoaded')).to.equal(true); + expect(serializeHtml(scratch)).to.equal('<div>Hi there</div>'); + }); + }); + + it('should log the displayName', () => { + function MyLazyLoadedComponent() { + return <div>Hi there</div>; + } + MyLazyLoadedComponent.displayName = 'HelloLazy'; + const loader = Promise.resolve({ default: MyLazyLoadedComponent }); + const FakeLazy = lazy(() => loader); + FakeLazy.propTypes = {}; + const suspense = ( + <Suspense fallback={<div>fallback...</div>}> + <FakeLazy /> + </Suspense> + ); + render(suspense, scratch); + rerender(); // Render fallback + + expect(serializeHtml(scratch)).to.equal('<div>fallback...</div>'); + + return loader.then(() => { + rerender(); + expect(console.warn).to.be.calledTwice; + expect(warnings[1].includes('HelloLazy')).to.equal(true); + expect(serializeHtml(scratch)).to.equal('<div>Hi there</div>'); + }); + }); + + it("should not log a component if lazy loader's Promise rejects", () => { + const loader = Promise.reject(new Error('Hey there')); + const FakeLazy = lazy(() => loader); + FakeLazy.propTypes = {}; + render( + <Suspense fallback={<div>fallback...</div>}> + <FakeLazy /> + </Suspense>, + scratch + ); + rerender(); // Render fallback + + expect(serializeHtml(scratch)).to.equal('<div>fallback...</div>'); + + return loader.catch(() => { + try { + rerender(); + } catch (e) { + // Ignore the loader's bubbling error + } + + // Called once on initial render, and again when promise rejects + expect(console.warn).to.be.calledTwice; + }); + }); + + it("should not log a component if lazy's loader throws", () => { + const FakeLazy = lazy(() => { + throw new Error('Hello'); + }); + FakeLazy.propTypes = {}; + let error; + try { + render( + <Suspense fallback={<div>fallback...</div>}> + <FakeLazy /> + </Suspense>, + scratch + ); + } catch (e) { + error = e; + } + + expect(console.warn).to.be.calledOnce; + expect(error).not.to.be.undefined; + expect(error.message).to.eql('Hello'); + }); + }); + }); +}); diff --git a/preact/debug/test/browser/debug.options.test.js b/preact/debug/test/browser/debug.options.test.js new file mode 100644 index 0000000..21d2c3f --- /dev/null +++ b/preact/debug/test/browser/debug.options.test.js @@ -0,0 +1,135 @@ +import { + vnodeSpy, + rootSpy, + beforeDiffSpy, + hookSpy, + afterDiffSpy, + catchErrorSpy +} from '../../../test/_util/optionSpies'; + +import { createElement, render, Component } from 'preact'; +import { useState } from 'preact/hooks'; +import { setupRerender } from 'preact/test-utils'; +import 'preact/debug'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; + +/** @jsx createElement */ + +describe('debug options', () => { + /** @type {HTMLDivElement} */ + let scratch; + + /** @type {() => void} */ + let rerender; + + /** @type {(count: number) => void} */ + let setCount; + + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + + vnodeSpy.resetHistory(); + rootSpy.resetHistory(); + beforeDiffSpy.resetHistory(); + hookSpy.resetHistory(); + afterDiffSpy.resetHistory(); + catchErrorSpy.resetHistory(); + }); + + afterEach(() => { + teardown(scratch); + }); + + class ClassApp extends Component { + constructor() { + super(); + this.state = { count: 0 }; + setCount = count => this.setState({ count }); + } + + render() { + return <div>{this.state.count}</div>; + } + } + + it('should call old options on mount', () => { + render(<ClassApp />, scratch); + + expect(vnodeSpy).to.have.been.called; + expect(rootSpy).to.have.been.called; + expect(beforeDiffSpy).to.have.been.called; + expect(afterDiffSpy).to.have.been.called; + }); + + it('should call old options on update', () => { + render(<ClassApp />, scratch); + + setCount(1); + rerender(); + + expect(vnodeSpy).to.have.been.called; + expect(rootSpy).to.have.been.called; + expect(beforeDiffSpy).to.have.been.called; + expect(afterDiffSpy).to.have.been.called; + }); + + it('should call old options on unmount', () => { + render(<ClassApp />, scratch); + render(null, scratch); + + expect(vnodeSpy).to.have.been.called; + expect(rootSpy).to.have.been.called; + expect(beforeDiffSpy).to.have.been.called; + expect(afterDiffSpy).to.have.been.called; + }); + + it('should call old hook options for hook components', () => { + function HookApp() { + const [count, realSetCount] = useState(0); + setCount = realSetCount; + return <div>{count}</div>; + } + + render(<HookApp />, scratch); + + expect(hookSpy).to.have.been.called; + }); + + it('should call old options on error', () => { + const e = new Error('test'); + class ErrorApp extends Component { + constructor() { + super(); + this.state = { error: true }; + } + componentDidCatch() { + this.setState({ error: false }); + } + render() { + return <Throw error={this.state.error} />; + } + } + + function Throw({ error }) { + if (error) { + throw e; + } else { + return <div>no error</div>; + } + } + + const clock = sinon.useFakeTimers(); + + render(<ErrorApp />, scratch); + rerender(); + + expect(catchErrorSpy).to.have.been.called; + + // we expect to throw after setTimeout to trigger a window.onerror + // this is to ensure react compat (i.e. with next.js' dev overlay) + expect(() => clock.tick(0)).to.throw(e); + + clock.restore(); + }); +}); diff --git a/preact/debug/test/browser/debug.test.js b/preact/debug/test/browser/debug.test.js new file mode 100644 index 0000000..65b3ab4 --- /dev/null +++ b/preact/debug/test/browser/debug.test.js @@ -0,0 +1,624 @@ +import { createElement, render, createRef, Component, Fragment } from 'preact'; +import { + setupScratch, + teardown, + serializeHtml +} from '../../../test/_util/helpers'; +import './fakeDevTools'; +import 'preact/debug'; +import * as PropTypes from 'prop-types'; + +// eslint-disable-next-line no-duplicate-imports +import { resetPropWarnings } from 'preact/debug'; + +const h = createElement; +/** @jsx createElement */ + +describe('debug', () => { + let scratch; + let errors = []; + let warnings = []; + + beforeEach(() => { + errors = []; + warnings = []; + scratch = setupScratch(); + sinon.stub(console, 'error').callsFake(e => errors.push(e)); + sinon.stub(console, 'warn').callsFake(w => warnings.push(w)); + }); + + afterEach(() => { + /** @type {*} */ + (console.error).restore(); + console.warn.restore(); + teardown(scratch); + }); + + it('should initialize devtools', () => { + expect(window.__PREACT_DEVTOOLS__.attachPreact).to.have.been.called; + }); + + it('should print an error on rendering on undefined parent', () => { + let fn = () => render(<div />, undefined); + expect(fn).to.throw(/render/); + }); + + it('should print an error on rendering on invalid parent', () => { + let fn = () => render(<div />, 6); + expect(fn).to.throw(/valid HTML node/); + expect(fn).to.throw(/<div/); + }); + + it('should print an error with (function) component name when available', () => { + const App = () => <div />; + let fn = () => render(<App />, 6); + expect(fn).to.throw(/<App/); + expect(fn).to.throw(/6/); + + fn = () => render(<App />, {}); + expect(fn).to.throw(/<App/); + expect(fn).to.throw(/[object Object]/); + + fn = () => render(<Fragment />, 'badroot'); + expect(fn).to.throw(/<Fragment/); + expect(fn).to.throw(/badroot/); + }); + + it('should print an error with (class) component name when available', () => { + class App extends Component { + render() { + return <div />; + } + } + let fn = () => render(<App />, 6); + expect(fn).to.throw(/<App/); + }); + + it('should print an error on undefined component', () => { + let fn = () => render(h(undefined), scratch); + expect(fn).to.throw(/createElement/); + }); + + it('should print an error on invalid object component', () => { + let fn = () => render(h({}), scratch); + expect(fn).to.throw(/createElement/); + }); + + it('should print an error when component is an array', () => { + let fn = () => render(h([<div />]), scratch); + expect(fn).to.throw(/createElement/); + }); + + it('should print an error on double jsx conversion', () => { + let Foo = <div />; + let fn = () => render(h(<Foo />), scratch); + expect(fn).to.throw(/JSX twice/); + }); + + it('should add __source to the vnode in debug mode.', () => { + const vnode = h('div', { + __source: { + fileName: 'div.jsx', + lineNumber: 3 + } + }); + expect(vnode.__source).to.deep.equal({ + fileName: 'div.jsx', + lineNumber: 3 + }); + expect(vnode.props.__source).to.be.undefined; + }); + + it('should add __self to the vnode in debug mode.', () => { + const vnode = h('div', { + __self: {} + }); + expect(vnode.__self).to.deep.equal({}); + expect(vnode.props.__self).to.be.undefined; + }); + + it('should warn when accessing certain attributes', () => { + const vnode = h('div', null); + + // Push into an array to avoid empty statements being dead code eliminated + const res = []; + res.push(vnode); + res.push(vnode.attributes); + expect(console.warn).to.be.calledOnce; + expect(console.warn.args[0]).to.match(/use vnode.props/); + res.push(vnode.nodeName); + expect(console.warn).to.be.calledTwice; + expect(console.warn.args[1]).to.match(/use vnode.type/); + res.push(vnode.children); + expect(console.warn).to.be.calledThrice; + expect(console.warn.args[2]).to.match(/use vnode.props.children/); + + // Should only warn once + res.push(vnode.attributes); + expect(console.warn).to.be.calledThrice; + res.push(vnode.nodeName); + expect(console.warn).to.be.calledThrice; + res.push(vnode.children); + expect(console.warn).to.be.calledThrice; + + vnode.attributes = {}; + expect(console.warn.args[3]).to.match(/use vnode.props/); + vnode.nodeName = ''; + expect(console.warn.args[4]).to.match(/use vnode.type/); + vnode.children = []; + expect(console.warn.args[5]).to.match(/use vnode.props.children/); + + // Should only warn once + vnode.attributes = {}; + expect(console.warn.args.length).to.equal(6); + vnode.nodeName = ''; + expect(console.warn.args.length).to.equal(6); + vnode.children = []; + expect(console.warn.args.length).to.equal(6); + + // Mark res as used, otherwise it will be dead code eliminated + expect(res.length).to.equal(7); + }); + + it('should warn when calling setState inside the constructor', () => { + class Foo extends Component { + constructor(props) { + super(props); + this.setState({ foo: true }); + } + render() { + return <div>foo</div>; + } + } + + render(<Foo />, scratch); + expect(console.warn).to.be.calledOnce; + expect(console.warn.args[0]).to.match(/no-op/); + }); + + it('should NOT warn when calling setState inside the cWM', () => { + class Foo extends Component { + componentWillMount() { + this.setState({ foo: true }); + } + render() { + return <div>foo</div>; + } + } + + render(<Foo />, scratch); + expect(console.warn).to.not.be.called; + }); + + it('should warn when calling setState on an unmounted Component', () => { + let setState; + + class Foo extends Component { + constructor(props) { + super(props); + setState = () => this.setState({ foo: true }); + } + render() { + return <div>foo</div>; + } + } + + render(<Foo />, scratch); + expect(console.warn).to.not.be.called; + + render(null, scratch); + + setState(); + expect(console.warn).to.be.calledOnce; + expect(console.warn.args[0]).to.match(/no-op/); + }); + + it('should warn when calling forceUpdate inside the constructor', () => { + class Foo extends Component { + constructor(props) { + super(props); + this.forceUpdate(); + } + render() { + return <div>foo</div>; + } + } + + render(<Foo />, scratch); + expect(console.warn).to.be.calledOnce; + expect(console.warn.args[0]).to.match(/no-op/); + }); + + it('should warn when calling forceUpdate on an unmounted Component', () => { + let forceUpdate; + + class Foo extends Component { + constructor(props) { + super(props); + forceUpdate = () => this.forceUpdate(); + } + render() { + return <div>foo</div>; + } + } + + render(<Foo />, scratch); + forceUpdate(); + expect(console.warn).to.not.be.called; + + render(null, scratch); + + forceUpdate(); + expect(console.warn).to.be.calledOnce; + expect(console.warn.args[0]).to.match(/no-op/); + }); + + it('should print an error when child is a plain object', () => { + let fn = () => render(<div>{{}}</div>, scratch); + expect(fn).to.throw(/not valid/); + }); + + it('should print an error on invalid refs', () => { + let fn = () => render(<div ref="a" />, scratch); + expect(fn).to.throw(/createRef/); + }); + + it('should not print for null as a handler', () => { + let fn = () => render(<div onclick={null} />, scratch); + expect(fn).not.to.throw(); + }); + + it('should not print for undefined as a handler', () => { + let fn = () => render(<div onclick={undefined} />, scratch); + expect(fn).not.to.throw(); + }); + + it('should not print for attributes starting with on for Components', () => { + const Comp = () => <p>online</p>; + let fn = () => render(<Comp online={false} />, scratch); + expect(fn).not.to.throw(); + }); + + it('should print an error on invalid handler', () => { + let fn = () => render(<div onclick="a" />, scratch); + expect(fn).to.throw(/"onclick" property should be a function/); + }); + + it('should NOT print an error on valid refs', () => { + let noop = () => {}; + render(<div ref={noop} />, scratch); + + let ref = createRef(); + render(<div ref={ref} />, scratch); + expect(console.error).to.not.be.called; + }); + + describe('duplicate keys', () => { + const List = props => <ul>{props.children}</ul>; + const ListItem = props => <li>{props.children}</li>; + + it('should print an error on duplicate keys with DOM nodes', () => { + render( + <div> + <span key="a" /> + <span key="a" /> + </div>, + scratch + ); + expect(console.error).to.be.calledOnce; + }); + + it('should allow distinct object keys', () => { + const A = { is: 'A' }; + const B = { is: 'B' }; + render( + <div> + <span key={A} /> + <span key={B} /> + </div>, + scratch + ); + expect(console.error).not.to.be.called; + }); + + it('should print an error for duplicate object keys', () => { + const A = { is: 'A' }; + render( + <div> + <span key={A} /> + <span key={A} /> + </div>, + scratch + ); + expect(console.error).to.be.calledOnce; + }); + + it('should print an error on duplicate keys with Components', () => { + function App() { + return ( + <List> + <ListItem key="a">a</ListItem> + <ListItem key="b">b</ListItem> + <ListItem key="b">d</ListItem> + <ListItem key="d">d</ListItem> + </List> + ); + } + + render(<App />, scratch); + expect(console.error).to.be.calledOnce; + }); + + it('should print an error on duplicate keys with Fragments', () => { + function App() { + return ( + <Fragment> + <List key="list"> + <ListItem key="a">a</ListItem> + <ListItem key="b">b</ListItem> + <Fragment key="b"> + {/* Should be okay to duplicate keys since these are inside a Fragment */} + <ListItem key="a">c</ListItem> + <ListItem key="b">d</ListItem> + <ListItem key="c">e</ListItem> + </Fragment> + <ListItem key="f">f</ListItem> + </List> + <div key="list">sibling</div> + </Fragment> + ); + } + + render(<App />, scratch); + expect(console.error).to.be.calledTwice; + }); + }); + + describe('table markup', () => { + it('missing <tbody>/<thead>/<tfoot>/<table>', () => { + const Table = () => ( + <tr> + <td>hi</td> + </tr> + ); + render(<Table />, scratch); + expect(console.error).to.be.calledOnce; + }); + + it('missing <table> with <thead>', () => { + const Table = () => ( + <thead> + <tr> + <td>hi</td> + </tr> + </thead> + ); + render(<Table />, scratch); + expect(console.error).to.be.calledOnce; + }); + + it('missing <table> with <tbody>', () => { + const Table = () => ( + <tbody> + <tr> + <td>hi</td> + </tr> + </tbody> + ); + render(<Table />, scratch); + expect(console.error).to.be.calledOnce; + }); + + it('missing <table> with <tfoot>', () => { + const Table = () => ( + <tfoot> + <tr> + <td>hi</td> + </tr> + </tfoot> + ); + render(<Table />, scratch); + expect(console.error).to.be.calledOnce; + }); + + it('missing <tr>', () => { + const Table = () => ( + <table> + <tbody> + <td>Hi</td> + </tbody> + </table> + ); + render(<Table />, scratch); + expect(console.error).to.be.calledOnce; + }); + + it('missing <tr> with td component', () => { + const Cell = ({ children }) => <td>{children}</td>; + const Table = () => ( + <table> + <tbody> + <Cell>Hi</Cell> + </tbody> + </table> + ); + render(<Table />, scratch); + expect(console.error).to.be.calledOnce; + }); + + it('missing <tr> with th component', () => { + const Cell = ({ children }) => <th>{children}</th>; + const Table = () => ( + <table> + <tbody> + <Cell>Hi</Cell> + </tbody> + </table> + ); + render(<Table />, scratch); + expect(console.error).to.be.calledOnce; + }); + + it('Should accept <td> instead of <th> in <thead>', () => { + const Table = () => ( + <table> + <thead> + <tr> + <td>Hi</td> + </tr> + </thead> + </table> + ); + render(<Table />, scratch); + expect(console.error).to.not.be.called; + }); + + it('Accepts well formed table with TD components', () => { + const Cell = ({ children }) => <td>{children}</td>; + const Table = () => ( + <table> + <thead> + <tr> + <th>Head</th> + </tr> + </thead> + <tbody> + <tr> + <td>Body</td> + </tr> + </tbody> + <tfoot> + <tr> + <Cell>Body</Cell> + </tr> + </tfoot> + </table> + ); + render(<Table />, scratch); + expect(console.error).to.not.be.called; + }); + + it('Accepts well formed table', () => { + const Table = () => ( + <table> + <thead> + <tr> + <th>Head</th> + </tr> + </thead> + <tbody> + <tr> + <td>Body</td> + </tr> + </tbody> + <tfoot> + <tr> + <td>Body</td> + </tr> + </tfoot> + </table> + ); + render(<Table />, scratch); + expect(console.error).to.not.be.called; + }); + + it('Accepts minimal well formed table', () => { + const Table = () => ( + <table> + <tr> + <th>Head</th> + </tr> + <tr> + <td>Body</td> + </tr> + </table> + ); + render(<Table />, scratch); + expect(console.error).to.not.be.called; + }); + }); + + describe('PropTypes', () => { + beforeEach(() => { + resetPropWarnings(); + }); + + it("should fail if props don't match prop-types", () => { + function Foo(props) { + return <h1>{props.text}</h1>; + } + + Foo.propTypes = { + text: PropTypes.string.isRequired + }; + + render(<Foo text={123} />, scratch); + + expect(console.error).to.be.calledOnce; + + // The message here may change when the "prop-types" library is updated, + // but we check it exactly to make sure all parameters were supplied + // correctly. + expect(console.error).to.have.been.calledOnceWith( + sinon.match( + /^Failed prop type: Invalid prop `text` of type `number` supplied to `Foo`, expected `string`\.\n {2}in Foo \(at (.*)[/\\]debug[/\\]test[/\\]browser[/\\]debug\.test\.js:[0-9]+\)$/m + ) + ); + }); + + it('should only log a given prop type error once', () => { + function Foo(props) { + return <h1>{props.text}</h1>; + } + + Foo.propTypes = { + text: PropTypes.string.isRequired, + count: PropTypes.number + }; + + // Trigger the same error twice. The error should only be logged + // once. + render(<Foo text={123} />, scratch); + render(<Foo text={123} />, scratch); + + expect(console.error).to.be.calledOnce; + + // Trigger a different error. This should result in a new log + // message. + console.error.resetHistory(); + render(<Foo text="ok" count="123" />, scratch); + expect(console.error).to.be.calledOnce; + }); + + it('should render with error logged when validator gets signal and throws exception', () => { + function Baz(props) { + return <h1>{props.unhappy}</h1>; + } + + Baz.propTypes = { + unhappy: function alwaysThrows(obj, key) { + if (obj[key] === 'signal') throw Error('got prop'); + } + }; + + render(<Baz unhappy={'signal'} />, scratch); + + expect(console.error).to.be.calledOnce; + expect(errors[0].includes('got prop')).to.equal(true); + expect(serializeHtml(scratch)).to.equal('<h1>signal</h1>'); + }); + + it('should not print to console when types are correct', () => { + function Bar(props) { + return <h1>{props.text}</h1>; + } + + Bar.propTypes = { + text: PropTypes.string.isRequired + }; + + render(<Bar text="foo" />, scratch); + expect(console.error).to.not.be.called; + }); + }); +}); diff --git a/preact/debug/test/browser/fakeDevTools.js b/preact/debug/test/browser/fakeDevTools.js new file mode 100644 index 0000000..e611df3 --- /dev/null +++ b/preact/debug/test/browser/fakeDevTools.js @@ -0,0 +1 @@ +window.__PREACT_DEVTOOLS__ = { attachPreact: sinon.spy() }; diff --git a/preact/debug/test/browser/serializeVNode.test.js b/preact/debug/test/browser/serializeVNode.test.js new file mode 100644 index 0000000..f8c8515 --- /dev/null +++ b/preact/debug/test/browser/serializeVNode.test.js @@ -0,0 +1,66 @@ +import { createElement, Component } from 'preact'; +import { serializeVNode } from '../../src/debug'; + +/** @jsx createElement */ + +describe('serializeVNode', () => { + it("should prefer a function component's displayName", () => { + function Foo() { + return <div />; + } + Foo.displayName = 'Bar'; + + expect(serializeVNode(<Foo />)).to.equal('<Bar />'); + }); + + it("should prefer a class component's displayName", () => { + class Bar extends Component { + render() { + return <div />; + } + } + Bar.displayName = 'Foo'; + + expect(serializeVNode(<Bar />)).to.equal('<Foo />'); + }); + + it('should serialize vnodes without children', () => { + expect(serializeVNode(<br />)).to.equal('<br />'); + }); + + it('should serialize vnodes with children', () => { + expect(serializeVNode(<div>Hello World</div>)).to.equal('<div>..</div>'); + }); + + it('should serialize components', () => { + function Foo() { + return <div />; + } + expect(serializeVNode(<Foo />)).to.equal('<Foo />'); + }); + + it('should serialize props', () => { + expect(serializeVNode(<div class="foo" />)).to.equal('<div class="foo" />'); + + // Ensure that we have a predictable function name. Our test runner + // creates an all inclusive bundle per file and the identifier + // "noop" may have already been used. + // eslint-disable-next-line func-style + let noop = function noopFn() {}; + expect(serializeVNode(<div onClick={noop} />)).to.equal( + '<div onClick="function noopFn() {}" />' + ); + + function Foo(props) { + return props.foo; + } + + expect(serializeVNode(<Foo foo={[1, 2, 3]} />)).to.equal( + '<Foo foo="1,2,3" />' + ); + + expect(serializeVNode(<div prop={Object.create(null)} />)).to.equal( + '<div prop="[object Object]" />' + ); + }); +}); |