summaryrefslogtreecommitdiff
path: root/preact/debug/test/browser
diff options
context:
space:
mode:
Diffstat (limited to 'preact/debug/test/browser')
-rw-r--r--preact/debug/test/browser/component-stack-2.test.js54
-rw-r--r--preact/debug/test/browser/component-stack.test.js86
-rw-r--r--preact/debug/test/browser/debug-compat.test.js81
-rw-r--r--preact/debug/test/browser/debug-hooks.test.js111
-rw-r--r--preact/debug/test/browser/debug-suspense.test.js190
-rw-r--r--preact/debug/test/browser/debug.options.test.js135
-rw-r--r--preact/debug/test/browser/debug.test.js624
-rw-r--r--preact/debug/test/browser/fakeDevTools.js1
-rw-r--r--preact/debug/test/browser/serializeVNode.test.js66
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]" />'
+ );
+ });
+});