diff --git a/src/qweb/inline_expressions.ts b/src/qweb/inline_expressions.ts index 24a533d8..2474c414 100644 --- a/src/qweb/inline_expressions.ts +++ b/src/qweb/inline_expressions.ts @@ -25,10 +25,9 @@ // Misc types, constants and helpers //------------------------------------------------------------------------------ -const RESERVED_WORDS = - "true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,this,eval,void,Math,RegExp,Array,Object,Date".split( - "," - ); +const RESERVED_WORDS = "true,false,NaN,null,undefined,debugger,console,window,in,instanceof,new,function,return,this,eval,void,Math,RegExp,Array,Object,Date".split( + "," +); const WORD_REPLACEMENT: { [key: string]: string } = Object.assign(Object.create(null), { and: "&&", diff --git a/tests/components/__snapshots__/concurrency.test.ts.snap b/tests/components/__snapshots__/concurrency.test.ts.snap index d99f7854..f2fabfde 100644 --- a/tests/components/__snapshots__/concurrency.test.ts.snap +++ b/tests/components/__snapshots__/concurrency.test.ts.snap @@ -14,6 +14,45 @@ exports[`async rendering destroying a widget before start is over 1`] = ` }" `; +exports[`calling render in destroy 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, shallowEqual } = helpers; + + let block1 = createBlock(\`
\`); + + return function template(ctx, node, key = \\"\\") { + let d1 = ctx['props'].fromA; + return block1([d1]); + } +}" +`; + +exports[`calling render in destroy 2`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, shallowEqual } = helpers; + + return function template(ctx, node, key = \\"\\") { + return component(\`C\`, {fromA: ctx['props'].fromA}, key + \`__1\`, node, ctx); + } +}" +`; + +exports[`calling render in destroy 3`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, shallowEqual } = helpers; + + return function template(ctx, node, key = \\"\\") { + return component(\`B\`, {fromA: ctx['state']}, key + \`__1\`, node, ctx); + } +}" +`; + exports[`change state and call manually render: no unnecessary rendering 1`] = ` "function anonymous(bdom, helpers ) { diff --git a/tests/components/basics.test.ts b/tests/components/basics.test.ts index cafe0584..c644a4a5 100644 --- a/tests/components/basics.test.ts +++ b/tests/components/basics.test.ts @@ -742,3 +742,291 @@ describe("basics", () => { expect(fixture.innerHTML).toBe("
asdfasdf
"); }); }); + +describe.skip("mount targets", () => { + test("can attach a component to an existing node (if same tagname)", async () => { + // class App extends Component { + // static template = xml`
app

another tag

`; + // state = useState({ customClass: "custom" }); + // } + // const div = document.createElement("div"); + // div.classList.add("arbitrary"); + // div.innerHTML = `

pre-existing

`; + // fixture.appendChild(div); + // const app = await mount(App, { target: div, position: "self" }); + // expect(fixture.innerHTML).toBe( + // `

pre-existing

app

another tag

` + // ); + // expect(div).toBe(app.el); + // app.state.customClass = "custom2"; + // await nextTick(); + // expect(fixture.innerHTML).toBe( + // `

pre-existing

app

another tag

` + // ); + // expect(div).toBe(app.el); + // app.unmount(); + // // This assert is a best guess + // // The use case it covers was not really thought through + // // and may change in the future + // expect(fixture.innerHTML).toBe(""); + }); + + test("cannot attach a component to an existing node (if not same tagname)", async () => { + // class App extends Component { + // static template = xml`app`; + // } + // const div = document.createElement("div"); + // fixture.appendChild(div); + // let error; + // try { + // await mount(App, { target: div, position: "self" }); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe("Cannot attach 'App' to target node (not same tag name)"); + }); + + test("can mount a component (with position='first-child')", async () => { + // class App extends Component { + // static template = xml`
app
`; + // } + // const span = document.createElement("span"); + // fixture.appendChild(span); + // await mount(App, { target: fixture, position: "first-child" }); + // expect(fixture.innerHTML).toBe("
app
"); + }); + + test("can mount a component (with position='last-child')", async () => { + // class App extends Component { + // static template = xml`
app
`; + // } + // const span = document.createElement("span"); + // fixture.appendChild(span); + // await mount(App, { target: fixture, position: "last-child" }); + // expect(fixture.innerHTML).toBe("
app
"); + }); + + test("default mount option is 'last-child'", async () => { + // class App extends Component { + // static template = xml`
app
`; + // } + // const span = document.createElement("span"); + // fixture.appendChild(span); + // await mount(App, { target: fixture }); + // expect(fixture.innerHTML).toBe("
app
"); + }); +}); + +describe.skip("mount special cases", () => { + test("widget can be mounted on different target", async () => { + // class MyWidget extends Component { + // static template = xml`
Hey
`; + // patched() { + // throw new Error("patched should not be called"); + // } + // } + // const div = document.createElement("div"); + // const span = document.createElement("span"); + // fixture.appendChild(div); + // fixture.appendChild(span); + // const w = new MyWidget(); + // await w.mount(div); + + // expect(fixture.innerHTML).toBe("
Hey
"); + + // await w.mount(span); + // expect(fixture.innerHTML).toBe("
Hey
"); + }); + + test("widget can be mounted on different target, another situation", async () => { + // const def = makeDeferred(); + // const steps: string[] = []; + + // class MyWidget extends Component { + // static template = xml`
Hey
`; + // async willStart() { + // return def; + // } + // patched() { + // throw new Error("patched should not be called"); + // } + // } + // const div = document.createElement("div"); + // const span = document.createElement("span"); + // fixture.appendChild(div); + // fixture.appendChild(span); + // const w = new MyWidget(); + + // w.mount(div).catch(() => steps.push("1 catch")); + + // await nextTick(); + // expect(fixture.innerHTML).toBe("
"); + + // w.mount(span).then(() => steps.push("2 resolved")); + + // // we wait two microticks because this is the number of internal promises + // // that need to be resolved/rejected, and because we want to prove here + // // that the first mount operation is cancelled immediately, and not after + // // one full tick. + // await nextMicroTick(); + // await nextMicroTick(); + // expect(steps).toEqual([]); + // await nextTick(); + // expect(fixture.innerHTML).toBe("
"); + + // def.resolve(); + // await nextTick(); + // expect(steps).toEqual(["2 resolved"]); + // expect(fixture.innerHTML).toBe("
Hey
"); + }); + + test("component can be mounted on same target, another situation", async () => { + // const def = makeDeferred(); + // const steps: string[] = []; + + // class MyWidget extends Component { + // static template = xml`
Hey
`; + // async willStart() { + // return def; + // } + // patched() { + // throw new Error("patched should not be called"); + // } + // } + // const w = new MyWidget(); + + // w.mount(fixture).then(() => steps.push("1 resolved")); + + // await nextTick(); + // expect(fixture.innerHTML).toBe(""); + + // w.mount(fixture).then(() => steps.push("2 resolved")); + + // await nextTick(); + // expect(steps).toEqual([]); + // expect(fixture.innerHTML).toBe(""); + + // def.resolve(); + // await nextTick(); + // expect(fixture.innerHTML).toBe("
Hey
"); + // expect(steps).toEqual(["1 resolved", "2 resolved"]); + }); + + test("mounting a destroyed widget", async () => { + // class MyWidget extends Component { + // static template = xml`
Hey
`; + // } + // const w = new MyWidget(); + // w.destroy(); // because, why not + + // let error; + // try { + // await w.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(scheduler.tasks.length).toBe(0); + // expect(error).toBeDefined(); + // expect(error.message).toBe("Cannot mount a destroyed component"); + // }); + + // test("destroying a sub-component cleans itself from parent's vnode", async () => { + // class C1 extends Component { + // static template = xml`
`; + // } + // class P extends Component { + // static components = { C1 }; + // static template = xml`
`; + // state = { + // a: "first", + // }; + // } + // const parent = new P(); + // await parent.mount(fixture); + // expect(fixture.textContent).toBe("first"); + // parent.unmount(); + // parent.state.a = ""; + // parent.mount(fixture); + // parent.state.a = "fixed"; + // await parent.render(); + // expect(fixture.textContent).toBe("fixed"); + }); + + test("destroying a sub-component cleans itself from parent's vnode, part 2", async () => { + // class C1 extends Component { + // static template = xml`
`; + // } + // class P extends Component { + // static components = { C1 }; + // static template = xml`
some text
`; + // state = { + // a: "first", + // }; + // } + // const parent = new P(); + // await parent.mount(fixture); + // expect(fixture.textContent).toBe("firstsome text"); + // parent.unmount(); + // parent.state.a = ""; + // parent.mount(fixture); + // parent.state.a = "fixed"; + // await parent.render(); + // expect(fixture.textContent).toBe("fixedsome text"); + }); + + test("destroying a sub-component cleans itself from parent's vnode, part 3", async () => { + // class C1 extends Component { + // static template = xml`
`; + // } + + // class C2 extends Component { + // static template = xml``; + // static components = { C1 }; + // } + + // class P extends Component { + // static components = { C2 }; + // static template = xml`
`; + // state = { + // a: "first", + // }; + // } + // const parent = new P(); + // await parent.mount(fixture); + // expect(fixture.textContent).toBe("first"); + // parent.unmount(); + // parent.state.a = ""; + // parent.mount(fixture); + // parent.state.a = "fixed"; + // await parent.render(); + // expect(fixture.textContent).toBe("fixed"); + }); + + test("destroying a sub-component cleans itself from parent's vnode, part 4", async () => { + // class C1 extends Component { + // static template = xml`
`; + // } + + // class C2 extends Component { + // static template = xml``; + // static components = { C1 }; + // } + // class P extends Component { + // static components = { C2 }; + // static template = xml`
some text
`; + // state = { + // a: "first", + // }; + // } + // const parent = new P(); + // await parent.mount(fixture); + // expect(fixture.textContent).toBe("firstsome text"); + // parent.unmount(); + // parent.state.a = ""; + // parent.mount(fixture); + // parent.state.a = "fixed"; + // await parent.render(); + // expect(fixture.textContent).toBe("fixedsome text"); + }); +}) \ No newline at end of file diff --git a/tests/components/concurrency.test.ts b/tests/components/concurrency.test.ts index c3e79ec0..383bb31c 100644 --- a/tests/components/concurrency.test.ts +++ b/tests/components/concurrency.test.ts @@ -2102,61 +2102,67 @@ test("concurrent renderings scenario 16", async () => { // expect(fixture.innerHTML).toBe("4"); // }); -// test("calling render in destroy", async () => { -// let a: any = null; -// let c: any = null; +// TODO: unskip when t-key is reimplemented properly +test.skip("calling render in destroy", async () => { + const steps: any[] = []; -// class C extends Component { -// static template = xml` -//
-// -//
`; -// } + let a: any = null; + let c: any = null; -// let flag = false; -// class B extends Component { -// static template = xml``; -// static components = { C }; + class C extends Component { + static template = xml` +
+ +
`; + } -// setup() { -// c = this; -// } + let flag = false; + class B extends Component { + static template = xml``; + static components = { C }; -// mounted() { -// if (flag) { -// this.render(); -// } else { -// flag = true; -// } -// } -// willUnmount() { -// c.render(); -// } -// } + setup() { + c = this; + onMounted(() => { + steps.push("B:mounted"); + if (flag) { + this.render(); + } else { + flag = true; + } + }); -// class A extends Component { -// static template = xml``; -// static components = { B }; -// state = "a"; -// key = 1; + onWillUnmount(() => { + steps.push("B:willUnmount"); + c.render(); + }); + } + } -// setup() { -// a = this; -// } -// } + class A extends Component { + static template = xml``; + static components = { B }; + state = "a"; + key = 1; -// const parent = new A(); -// await parent.mount(fixture); -// expect(fixture.innerHTML).toBe("
a
"); + setup() { + a = this; + } + } -// a.state = "A"; -// a.key = 2; -// await a.render(); -// // this nextTick is critical, otherwise jest may silently swallow errors -// await nextTick(); + const app = new App(A); + await app.mount(fixture); + expect(fixture.innerHTML).toBe("
a
"); -// expect(fixture.innerHTML).toBe("
A
"); -// }); + a.state = "A"; + a.key = 2; + await a.render(); + // this nextTick is critical, otherwise jest may silently swallow errors + await nextTick(); + + expect(steps).toBe(["B:mounted", "B:willUnmount", "B:mounted"]); + expect(fixture.innerHTML).toBe("
A
"); +}); test("change state and call manually render: no unnecessary rendering", async () => { const steps: string[] = []; diff --git a/tests/components/error_handling.test.ts b/tests/components/error_handling.test.ts index 327af00a..67ef11ef 100644 --- a/tests/components/error_handling.test.ts +++ b/tests/components/error_handling.test.ts @@ -1,7 +1,15 @@ import { Component, mount } from "../../src"; import { status } from "../../src/component/status"; import { xml } from "../../src/tags"; -import { makeTestFixture, snapshotEverything } from "../helpers"; +import { makeTestFixture, nextTick, snapshotEverything } from "../helpers"; +import { + onMounted, + onPatched, + onWillPatch, + onWillStart, + onWillUnmount, + useState, +} from "../../src/index"; let fixture: HTMLElement; @@ -35,8 +43,7 @@ describe("basics", () => { expect(fixture.innerHTML).toBe(""); expect(status(parent)).toBe("destroyed"); expect(error).toBeDefined(); - const regexp = - /Cannot read properties of undefined \(reading 'this'\)|Cannot read property 'this' of undefined/g; + const regexp = /Cannot read properties of undefined \(reading 'this'\)|Cannot read property 'this' of undefined/g; expect(error.message).toMatch(regexp); }); @@ -60,4 +67,545 @@ describe("basics", () => { expect(console.error).toBeCalledTimes(0); console.error = consoleError; }); + + test.skip("simple catchError", async () => { + class Boom extends Component { + static template = xml`
`; + } + + class Parent extends Component { + static template = xml` +
+ Error + + + +
`; + static components = { Boom }; + + error = false; + + // catchError(error) { + // this.error = error; + // this.render(); + // } + } + + await mount(Parent, fixture); + expect(fixture.innerHTML).toBe("
Error
"); + }); +}); + +describe.skip("errors and promises", () => { + test("a rendering error will reject the mount promise", async () => { + const consoleError = console.error; + console.error = jest.fn(() => {}); + // we do not catch error in willPatch anymore + class App extends Component { + static template = xml`
`; + } + + let error; + try { + await mount(App, fixture); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + const regexp = /Cannot read properties of undefined \(reading 'crash'\)|Cannot read property 'crash' of undefined/g; + expect(error.message).toMatch(regexp); + + expect(console.error).toBeCalledTimes(0); + console.error = consoleError; + }); + + test("an error in mounted call will reject the mount promise", async () => { + const consoleError = console.error; + console.error = jest.fn(() => {}); + + class App extends Component { + static template = xml`
abc
`; + setup() { + onMounted(() => { + throw new Error("boom"); + }); + } + } + + let error; + try { + await mount(App, fixture); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.message).toBe("boom"); + expect(fixture.innerHTML).toBe(""); + + expect(console.error).toBeCalledTimes(0); + console.error = consoleError; + }); + + test("an error in willPatch call will reject the render promise", async () => { + const consoleError = console.error; + console.error = jest.fn(() => {}); + + class App extends Component { + static template = xml`
`; + val = 3; + setup() { + onWillPatch(() => { + throw new Error("boom"); + }); + } + } + + const app = await mount(App, fixture); + app.val = 4; + let error; + try { + await app.render(); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.message).toBe("boom"); + expect(fixture.innerHTML).toBe(""); + + expect(console.error).toBeCalledTimes(0); + console.error = consoleError; + }); + + test("an error in patched call will reject the render promise", async () => { + const consoleError = console.error; + console.error = jest.fn(() => {}); + + class App extends Component { + static template = xml`
`; + val = 3; + setup() { + onPatched(() => { + throw new Error("boom"); + }); + } + } + + const app = await mount(App, fixture); + app.val = 4; + let error; + try { + await app.render(); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.message).toBe("boom"); + expect(fixture.innerHTML).toBe(""); + + expect(console.error).toBeCalledTimes(0); + console.error = consoleError; + }); + + test("a rendering error in a sub component will reject the mount promise", async () => { + const consoleError = console.error; + console.error = jest.fn(() => {}); + // we do not catch error in willPatch anymore + class Child extends Component { + static template = xml`
`; + } + class App extends Component { + static template = xml`
`; + static components = { Child }; + } + + let error; + try { + await mount(App, fixture); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + const regexp = /Cannot read properties of undefined \(reading 'crash'\)|Cannot read property 'crash' of undefined/g; + expect(error.message).toMatch(regexp); + + expect(console.error).toBeCalledTimes(0); + console.error = consoleError; + }); + + test("a rendering error will reject the render promise", async () => { + const consoleError = console.error; + console.error = jest.fn(() => {}); + // we do not catch error in willPatch anymore + class App extends Component { + static template = xml`
`; + flag = false; + } + + const app = await mount(App, fixture); + expect(fixture.innerHTML).toBe("
"); + app.flag = true; + let error; + try { + await app.render(); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + const regexp = /Cannot read properties of undefined \(reading 'crash'\)|Cannot read property 'crash' of undefined/g; + expect(error.message).toMatch(regexp); + + expect(console.error).toBeCalledTimes(0); + console.error = consoleError; + }); + + test("a rendering error will reject the render promise (with sub components)", async () => { + class Child extends Component { + static template = xml``; + } + class Parent extends Component { + static template = xml`
`; + static components = { Child }; + } + + let error; + try { + await mount(Parent, fixture); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + const regexp = /Cannot read properties of undefined \(reading 'y'\)|Cannot read property 'y' of undefined/g; + expect(error.message).toMatch(regexp); + }); + + // LPE: relevant: dead code....? + test("errors in mounted and in willUnmount", async () => { + expect.assertions(1); + class Example extends Component { + static template = xml`
`; + val: any; + setup() { + onMounted(() => { + throw new Error("Error in mounted"); + this.val = { foo: "bar" }; + }); + + onWillUnmount(() => { + console.log(this.val.foo); + }); + } + } + + try { + await mount(Example, fixture); + } catch (e) { + expect(e.message).toBe("Error in mounted"); + } + }); +}); + +describe.skip("can catch errors", () => { + test("can catch an error in a component render function", async () => { + const consoleError = console.error; + console.error = jest.fn(); + const handler = jest.fn(); + //env.qweb.on("error", null, handler); + class ErrorComponent extends Component { + static template = xml`
hey
`; + } + class ErrorBoundary extends Component { + static template = xml` +
+ Error handled + +
`; + state = useState({ error: false }); + + catchError() { + this.state.error = true; + } + } + class App extends Component { + static template = xml` +
+ +
`; + state = useState({ flag: false }); + static components = { ErrorBoundary, ErrorComponent }; + } + const app = await mount(App, fixture); + expect(fixture.innerHTML).toBe("
heyfalse
"); + app.state.flag = true; + await nextTick(); + expect(fixture.innerHTML).toBe("
Error handled
"); + + expect(console.error).toBeCalledTimes(0); + console.error = consoleError; + expect(handler).toBeCalledTimes(1); + }); + + test("can catch an error in the initial call of a component render function (parent mounted)", async () => { + const handler = jest.fn(); + //env.qweb.on("error", null, handler); + const consoleError = console.error; + console.error = jest.fn(); + class ErrorComponent extends Component { + static template = xml`
hey
`; + } + class ErrorBoundary extends Component { + static template = xml` +
+ Error handled + +
`; + state = useState({ error: false }); + + catchError() { + this.state.error = true; + } + } + class App extends Component { + static template = xml` +
+ +
`; + static components = { ErrorBoundary, ErrorComponent }; + } + await mount(App, fixture); + expect(fixture.innerHTML).toBe("
Error handled
"); + + expect(console.error).toBeCalledTimes(0); + console.error = consoleError; + expect(handler).toBeCalledTimes(1); + }); + + test("can catch an error in the initial call of a component render function (parent updated)", async () => { + const handler = jest.fn(); + //env.qweb.on("error", null, handler); + const consoleError = console.error; + console.error = jest.fn(); + class ErrorComponent extends Component { + static template = xml`
hey
`; + } + class ErrorBoundary extends Component { + static template = xml` +
+ Error handled + +
`; + state = useState({ error: false }); + + catchError() { + this.state.error = true; + } + } + class App extends Component { + static template = xml` +
+ +
`; + state = useState({ flag: false }); + static components = { ErrorBoundary, ErrorComponent }; + } + const app = await mount(App, fixture); + app.state.flag = true; + await nextTick(); + expect(fixture.innerHTML).toBe("
Error handled
"); + + expect(console.error).toBeCalledTimes(0); + console.error = consoleError; + expect(handler).toBeCalledTimes(1); + }); + + test("can catch an error in the constructor call of a component render function", async () => { + const handler = jest.fn(); + //env.qweb.on("error", null, handler); + const consoleError = console.error; + console.error = jest.fn(); + + class ErrorComponent extends Component { + static template = xml`
Some text
`; + setup() { + throw new Error("NOOOOO"); + } + } + class ErrorBoundary extends Component { + static template = xml`
+ Error handled + +
`; + state = useState({ error: false }); + + catchError() { + this.state.error = true; + } + } + class App extends Component { + static template = xml` + +
`; + static components = { ErrorBoundary, ErrorComponent }; + } + await mount(App, fixture); + expect(fixture.innerHTML).toBe("
Error handled
"); + + expect(console.error).toBeCalledTimes(0); + console.error = consoleError; + expect(handler).toBeCalledTimes(1); + }); + + test("can catch an error in the willStart call", async () => { + const consoleError = console.error; + console.error = jest.fn(); + class ErrorComponent extends Component { + static template = xml`
Some text
`; + setup() { + onWillStart(async () => { + // we wait a little bit to be in a different stack frame + await nextTick(); + throw new Error("NOOOOO"); + }); + } + } + class ErrorBoundary extends Component { + static template = xml` +
+ Error handled + +
`; + state = useState({ error: false }); + + catchError() { + this.state.error = true; + } + } + class App extends Component { + static template = xml`
`; + static components = { ErrorBoundary, ErrorComponent }; + } + await mount(App, fixture); + expect(fixture.innerHTML).toBe("
Error handled
"); + + expect(console.error).toBeCalledTimes(0); + console.error = consoleError; + }); + + test.skip("can catch an error in the mounted call", async () => { + // we do not catch error in mounted anymore + console.error = jest.fn(); + // env.qweb.addTemplates(` + // + //
+ // Error handled + // + //
+ //
Some text
+ //
+ // + //
+ //
`); + class ErrorComponent extends Component { + mounted() { + throw new Error("NOOOOO"); + } + } + class ErrorBoundary extends Component { + state = useState({ error: false }); + + catchError() { + this.state.error = true; + } + } + class App extends Component { + static components = { ErrorBoundary, ErrorComponent }; + } + await mount(App, fixture); + await nextTick(); + await nextTick(); + await nextTick(); + expect(fixture.innerHTML).toBe("
Error handled
"); + }); + + test.skip("can catch an error in the willPatch call", async () => { + // we do not catch error in willPatch anymore + const consoleError = console.error; + console.error = jest.fn(); + class ErrorComponent extends Component { + static template = xml`
`; + setup() { + onWillPatch(() => { + throw new Error("NOOOOO"); + }); + } + } + class ErrorBoundary extends Component { + static template = xml` +
+ Error handled + +
`; + state = useState({ error: false }); + + catchError() { + this.state.error = true; + } + } + class App extends Component { + static template = xml` +
+ + +
`; + state = useState({ message: "abc" }); + static components = { ErrorBoundary, ErrorComponent }; + } + const app = await mount(App, fixture); + expect(fixture.innerHTML).toBe("
abc
abc
"); + app.state.message = "def"; + await nextTick(); + await nextTick(); + await nextTick(); + expect(fixture.innerHTML).toBe("
def
Error handled
"); + expect(console.error).toHaveBeenCalledTimes(1); + console.error = consoleError; + }); + + test("catchError in catchError", async () => { + class Boom extends Component { + static template = xml`
`; + } + + class Child extends Component { + static template = xml` +
+ +
`; + static components = { Boom }; + + // catchError(error) { + // throw error; + // } + } + + class Parent extends Component { + static template = xml` +
+ Error + + + +
`; + static components = { Child }; + + error = false; + + // catchError(error) { + // this.error = error; + // this.render(); + // } + } + + await mount(Parent, fixture); + expect(fixture.innerHTML).toBe("
Error
"); + }); }); diff --git a/tests/components/event_handling.test.ts b/tests/components/event_handling.test.ts index 49153ece..9b777bdd 100644 --- a/tests/components/event_handling.test.ts +++ b/tests/components/event_handling.test.ts @@ -72,4 +72,234 @@ describe("event handling", () => { await nextTick(); expect(fixture.innerHTML).toBe(`
test
`); }); + + test.skip("t-on with prevent and/or stop modifiers", async () => { + /* expect.assertions(7); + qweb.addTemplate( + "test", + `
+ + + +
` + ); + let owner = { + onClickPrevented(e) { + expect(e.defaultPrevented).toBe(true); + expect(e.cancelBubble).toBe(false); + }, + onClickStopped(e) { + expect(e.defaultPrevented).toBe(false); + expect(e.cancelBubble).toBe(true); + }, + onClickPreventedAndStopped(e) { + expect(e.defaultPrevented).toBe(true); + expect(e.cancelBubble).toBe(true); + }, + }; + const node = renderToDOM(qweb, "test", owner, { handlers: [] }); + + const buttons = (node).getElementsByTagName("button"); + buttons[0].click(); + buttons[1].click(); + buttons[2].click();*/ + }); + + test.skip("t-on with self modifier", async () => { + /*expect.assertions(2); + qweb.addTemplate( + "test", + `
+ + +
` + ); + let steps: string[] = []; + let owner = { + onClick(e) { + steps.push("onClick"); + }, + onClickSelf(e) { + steps.push("onClickSelf"); + }, + }; + const node = renderToDOM(qweb, "test", owner, { handlers: [] }); + + const buttons = (node).getElementsByTagName("button"); + const spans = (node).getElementsByTagName("span"); + spans[0].click(); + spans[1].click(); + buttons[0].click(); + buttons[1].click(); + + expect(steps).toEqual(["onClick", "onClick", "onClickSelf"]);*/ + }); + + test.skip("t-on with self and prevent modifiers (order matters)", async () => { + /*expect.assertions(2); + qweb.addTemplate( + "test", + `
+ +
` + ); + let steps: boolean[] = []; + let owner = { + onClick() {}, + }; + const node = renderToDOM(qweb, "test", owner, { handlers: [] }); + (node).addEventListener("click", function (e) { + steps.push(e.defaultPrevented); + }); + + const button = (node).getElementsByTagName("button")[0]; + const span = (node).getElementsByTagName("span")[0]; + span.click(); + button.click(); + + expect(steps).toEqual([false, true]);*/ + }); + + test.skip("t-on with prevent and self modifiers (order matters)", async () => { + /*expect.assertions(2); + qweb.addTemplate( + "test", + `
+ +
` + ); + let steps: boolean[] = []; + let owner = { + onClick() {}, + }; + const node = renderToDOM(qweb, "test", owner, { handlers: [] }); + (node).addEventListener("click", function (e) { + steps.push(e.defaultPrevented); + }); + + const button = (node).getElementsByTagName("button")[0]; + const span = (node).getElementsByTagName("span")[0]; + span.click(); + button.click(); + + expect(steps).toEqual([true, true]);*/ + }); + + test.skip("t-on with prevent modifier in t-foreach", async () => { + /*expect.assertions(5); + qweb.addTemplate( + "test", + `
+ + + Edit + + +
` + ); + const steps: string[] = []; + const owner = { + projects: [ + { id: 1, name: "Project 1" }, + { id: 2, name: "Project 2" }, + ], + + onEdit(projectId, ev) { + expect(ev.defaultPrevented).toBe(true); + steps.push(projectId); + }, + }; + + const node = renderToDOM(qweb, "test", owner, { handlers: [] }); + expect(node.outerHTML).toBe( + `` + ); + + const links = node.querySelectorAll("a")!; + links[0].click(); + links[1].click(); + + expect(steps).toEqual([1, 2]);*/ + }); + + test.skip("t-on with empty handler (only modifiers)", () => { + /*expect.assertions(2); + qweb.addTemplate( + "test", + `
+ +
` + ); + const node = renderToDOM(qweb, "test", {}, { handlers: [] }); + + node.addEventListener("click", (e) => { + expect(e.defaultPrevented).toBe(true); + }); + + const button = (node).getElementsByTagName("button")[0]; + button.click();*/ + }); + + test.skip("t-on combined with t-esc", async () => { + /*expect.assertions(3); + qweb.addTemplate("test", `
`); + const steps: string[] = []; + const owner = { + text: "Click here", + onClick() { + steps.push("onClick"); + }, + }; + + const node = renderToDOM(qweb, "test", owner, { handlers: [] }); + expect(node.outerHTML).toBe(`
`); + + node.querySelector("button")!.click(); + + expect(steps).toEqual(["onClick"]);*/ + }); + + test.skip("t-on combined with t-raw", async () => { + /*expect.assertions(3); + qweb.addTemplate("test", `
`); + const steps: string[] = []; + const owner = { + html: "Click here", + onClick() { + steps.push("onClick"); + }, + }; + + const node = renderToDOM(qweb, "test", owner, { handlers: [] }); + expect(node.outerHTML).toBe(`
`); + + node.querySelector("button")!.click(); + + expect(steps).toEqual(["onClick"]);*/ + }); + + test.skip("t-on with .capture modifier", () => { + /*expect.assertions(2); + qweb.addTemplate( + "test", + `
+ +
` + ); + + const steps: string[] = []; + const owner = { + onCapture() { + steps.push("captured"); + }, + doSomething() { + steps.push("normal"); + }, + }; + const node = renderToDOM(qweb, "test", owner, { handlers: [] }); + + const button = (node).getElementsByTagName("button")[0]; + button.click(); + expect(steps).toEqual(["captured", "normal"]);*/ + }); }); diff --git a/tests/components/props_validation.test.ts b/tests/components/props_validation.test.ts new file mode 100644 index 00000000..849b0640 --- /dev/null +++ b/tests/components/props_validation.test.ts @@ -0,0 +1,808 @@ +// import { Component, Env } from "../../src/component/component"; +// import { makeTestFixture, makeTestEnv, nextTick } from "../helpers"; +// import { useState } from "../../src/hooks"; +// import { QWeb } from "../../src/qweb"; +// import { xml } from "../../src/tags"; + +// //------------------------------------------------------------------------------ +// // Setup and helpers +// //------------------------------------------------------------------------------ + +// let fixture: HTMLElement; +// let env: Env; +// let dev: boolean = false; + +// beforeEach(() => { +// fixture = makeTestFixture(); +// env = makeTestEnv(); +// Component.env = env; +// dev = QWeb.dev; +// QWeb.dev = true; +// }); + +// afterEach(() => { +// fixture.remove(); +// QWeb.dev = dev; +// }); + +// class Widget extends Component {} + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ +describe.skip("props validation", () => { + test("validation is only done in dev mode", async () => { + // class TestWidget extends Widget { + // static props = ["message"]; + // static template = xml`
hey
`; + // } + // class Parent extends Widget { + // static components = { TestWidget }; + // static template = xml`
`; + // } + // let error; + // QWeb.dev = true; + // try { + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe(`Missing props 'message' (component 'TestWidget')`); + // error = undefined; + // QWeb.dev = false; + // try { + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeUndefined(); + }); + + test("props: list of strings", async () => { + // class TestWidget extends Widget { + // static props = ["message"]; + // static template = xml`
hey
`; + // } + // class Parent extends Widget { + // static components = { TestWidget }; + // static template = xml`
`; + // } + // let error; + // try { + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe(`Missing props 'message' (component 'TestWidget')`); + }); + + test("validate simple types", async () => { + // const Tests = [ + // { type: Number, ok: 1, ko: "1" }, + // { type: Boolean, ok: true, ko: "1" }, + // { type: String, ok: "1", ko: 1 }, + // { type: Object, ok: {}, ko: "1" }, + // { type: Date, ok: new Date(), ko: "1" }, + // { type: Function, ok: () => {}, ko: "1" }, + // ]; + // let props; + // class Parent extends Component { + // static template = xml`
`; + // get p() { + // return props.p; + // } + // } + // for (let test of Tests) { + // let TestWidget = class extends Widget { + // static template = xml`
hey
`; + // static props = { p: test.type }; + // }; + // Parent.components = { TestWidget }; + // let error; + // props = {}; + // try { + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe(`Missing props 'p' (component '_a')`); + // error = undefined; + // props = { p: test.ok }; + // try { + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeUndefined(); + // props = { p: test.ko }; + // try { + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe("Invalid Prop 'p' in component '_a'"); + // } + }); + + test("validate simple types, alternate form", async () => { + // const Tests = [ + // { type: Number, ok: 1, ko: "1" }, + // { type: Boolean, ok: true, ko: "1" }, + // { type: String, ok: "1", ko: 1 }, + // { type: Object, ok: {}, ko: "1" }, + // { type: Date, ok: new Date(), ko: "1" }, + // { type: Function, ok: () => {}, ko: "1" }, + // ]; + // let props; + // class Parent extends Component { + // static template = xml`
`; + // get p() { + // return props.p; + // } + // } + // for (let test of Tests) { + // let TestWidget = class extends Component { + // static props = { p: { type: test.type } }; + // static template = xml`
hey
`; + // }; + // Parent.components = { TestWidget }; + // let error; + // props = {}; + // try { + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe(`Missing props 'p' (component '_a')`); + // error = undefined; + // props = { p: test.ok }; + // try { + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeUndefined(); + // props = { p: test.ko }; + // try { + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe("Invalid Prop 'p' in component '_a'"); + // } + }); + + test("can validate a prop with multiple types", async () => { + // class TestWidget extends Component { + // static template = xml`
hey
`; + // static props = { p: [String, Boolean] }; + // } + // class Parent extends Component { + // static template = xml`
`; + // static components = { TestWidget }; + // get p() { + // return props.p; + // } + // } + // let error; + // let props; + // try { + // props = { p: "string" }; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeUndefined(); + // try { + // props = { p: true }; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeUndefined(); + // try { + // props = { p: 1 }; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'"); + }); + + test("can validate an optional props", async () => { + // class TestWidget extends Component { + // static template = xml`
hey
`; + // static props = { p: { type: String, optional: true } }; + // } + // class Parent extends Component { + // static template = xml`
`; + // static components = { TestWidget }; + // get p() { + // return props.p; + // } + // } + // let error; + // let props; + // try { + // props = { p: "key" }; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeUndefined(); + // try { + // props = {}; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeUndefined(); + // try { + // props = { p: 1 }; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'"); + }); + + test("can validate an array with given primitive type", async () => { + // class TestWidget extends Component { + // static template = xml`
hey
`; + // static props = { p: { type: Array, element: String } }; + // } + // class Parent extends Component { + // static template = xml`
`; + // static components = { TestWidget }; + // get p() { + // return props.p; + // } + // } + // let error; + // let props; + // try { + // props = { p: [] }; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeUndefined(); + // try { + // props = { p: ["string"] }; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeUndefined(); + // try { + // props = { p: [1] }; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // error = undefined; + // try { + // props = { p: ["string", 1] }; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + }); + + test("can validate an array with multiple sub element types", async () => { + // class TestWidget extends Component { + // static template = xml`
hey
`; + // static props = { p: { type: Array, element: [String, Boolean] } }; + // } + // class Parent extends Component { + // static template = xml`
`; + // static components = { TestWidget }; + // get p() { + // return props.p; + // } + // } + // let error; + // let props; + // try { + // props = { p: [] }; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeUndefined(); + // try { + // props = { p: ["string"] }; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeUndefined(); + // try { + // props = { p: [false, true, "string"] }; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeUndefined(); + // try { + // props = { p: [true, 1] }; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'"); + }); + + test("can validate an object with simple shape", async () => { + // class TestWidget extends Component { + // static template = xml`
hey
`; + // static props = { + // p: { type: Object, shape: { id: Number, url: String } }, + // }; + // } + // class Parent extends Component { + // static template = xml`
`; + // static components = { TestWidget }; + // get p() { + // return props.p; + // } + // } + // let error; + // let props; + // try { + // props = { p: { id: 1, url: "url" } }; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeUndefined(); + // try { + // props = { p: { id: 1, url: "url", extra: true } }; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe("Invalid prop 'p' in component TestWidget (unknown prop 'extra')"); + // try { + // props = { p: { id: "1", url: "url" } }; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'"); + // error = undefined; + // try { + // props = { p: { id: 1 } }; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'"); + }); + + test("can validate recursively complicated prop def", async () => { + // class TestWidget extends Component { + // static template = xml`
hey
`; + // static props = { + // p: { + // type: Object, + // shape: { + // id: Number, + // url: [Boolean, { type: Array, element: Number }], + // }, + // }, + // }; + // } + // class Parent extends Component { + // static template = xml`
`; + // static components = { TestWidget }; + // get p() { + // return props.p; + // } + // } + // let error; + // let props; + // try { + // props = { p: { id: 1, url: true } }; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeUndefined(); + // try { + // props = { p: { id: 1, url: [12] } }; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeUndefined(); + // try { + // props = { p: { id: 1, url: [12, true] } }; + // const p = new Parent(); + // await p.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe("Invalid Prop 'p' in component 'TestWidget'"); + }); + + test("can validate optional attributes in nested sub props", () => { + // class TestComponent extends Component { + // static props = { + // myprop: { + // type: Array, + // element: { + // type: Object, + // shape: { + // num: { type: Number, optional: true }, + // }, + // }, + // }, + // }; + // } + // let error; + // try { + // QWeb.utils.validateProps(TestComponent, { myprop: [{}] }); + // } catch (e) { + // error = e; + // } + // expect(error).toBeUndefined(); + // try { + // QWeb.utils.validateProps(TestComponent, { myprop: [{ a: 1 }] }); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe( + // "Invalid prop 'myprop' in component TestComponent (unknown prop 'a')" + // ); + }); + + test("can validate with a custom validator", () => { + // class TestComponent extends Component { + // static props = { + // size: { + // validate: (e) => ["small", "medium", "large"].includes(e), + // }, + // }; + // } + // let error; + // try { + // QWeb.utils.validateProps(TestComponent, { size: "small" }); + // } catch (e) { + // error = e; + // } + // expect(error).toBeUndefined(); + // try { + // QWeb.utils.validateProps(TestComponent, { size: "abcdef" }); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe("Invalid Prop 'size' in component 'TestComponent'"); + }); + + test("can validate with a custom validator, and a type", () => { + // const validator = jest.fn((n) => 0 <= n && n <= 10); + // class TestComponent extends Component { + // static props = { + // n: { + // type: Number, + // validate: validator, + // }, + // }; + // } + // let error; + // try { + // QWeb.utils.validateProps(TestComponent, { n: 3 }); + // } catch (e) { + // error = e; + // } + // expect(error).toBeUndefined(); + // expect(validator).toBeCalledTimes(1); + // try { + // QWeb.utils.validateProps(TestComponent, { n: "str" }); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe("Invalid Prop 'n' in component 'TestComponent'"); + // expect(validator).toBeCalledTimes(1); + // error = null; + // try { + // QWeb.utils.validateProps(TestComponent, { n: 100 }); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe("Invalid Prop 'n' in component 'TestComponent'"); + // expect(validator).toBeCalledTimes(2); + }); + + test("props are validated in dev mode (code snapshot)", async () => { + // env.qweb.addTemplates(` + // + //
+ // + //
+ //
+ //
`); + // class Child extends Widget { + // static props = ["message"]; + // } + // class App extends Widget { + // static components = { Child }; + // } + // const app = new App(); + // await app.mount(fixture); + // expect(fixture.innerHTML).toBe("
1
"); + // // need to make sure there are 2 call to update props. one at component + // // creation, and one at update time. + // expect(env.qweb.templates.App.fn.toString()).toMatchSnapshot(); + }); + + test("props: list of strings with optional props", async () => { + // class TestWidget extends Widget { + // static props = ["message", "someProp?"]; + // } + // expect(() => { + // QWeb.utils.validateProps(TestWidget, { someProp: 1 }); + // }).toThrow(); + // expect(() => { + // QWeb.utils.validateProps(TestWidget, { message: 1 }); + // }).not.toThrow(); + }); + + test("props: can be defined with a boolean", async () => { + // class TestWidget extends Widget { + // static props = { message: true }; + // } + // expect(() => { + // QWeb.utils.validateProps(TestWidget, {}); + // }).toThrow(); + }); + + test("props with type array, and no element", async () => { + // class TestWidget extends Widget { + // static props = { myprop: { type: Array } }; + // } + // expect(() => { + // QWeb.utils.validateProps(TestWidget, { myprop: [1] }); + // }).not.toThrow(); + // expect(() => { + // QWeb.utils.validateProps(TestWidget, { myprop: 1 }); + // }).toThrow(`Invalid Prop 'myprop' in component 'TestWidget'`); + }); + + test("props with type object, and no shape", async () => { + // class TestWidget extends Widget { + // static props = { myprop: { type: Object } }; + // } + // expect(() => { + // QWeb.utils.validateProps(TestWidget, { myprop: { a: 3 } }); + // }).not.toThrow(); + // expect(() => { + // QWeb.utils.validateProps(TestWidget, { myprop: false }); + // }).toThrow(`Invalid Prop 'myprop' in component 'TestWidget'`); + }); + + test("props: extra props cause an error", async () => { + // class TestWidget extends Widget { + // static props = ["message"]; + // } + // expect(() => { + // QWeb.utils.validateProps(TestWidget, { message: 1, flag: true }); + // }).toThrow(); + }); + + test("props: extra props cause an error, part 2", async () => { + // class TestWidget extends Widget { + // static props = { message: true }; + // } + // expect(() => { + // QWeb.utils.validateProps(TestWidget, { message: 1, flag: true }); + // }).toThrow(); + }); + + test("props: optional prop do not cause an error", async () => { + // class TestWidget extends Widget { + // static props = ["message?"]; + // } + // expect(() => { + // QWeb.utils.validateProps(TestWidget, { message: 1 }); + // }).not.toThrow(); + }); + + test("optional prop do not cause an error if value is undefined", async () => { + // class TestWidget extends Widget { + // static props = { message: { type: String, optional: true } }; + // } + // expect(() => { + // QWeb.utils.validateProps(TestWidget, { message: undefined }); + // }).not.toThrow(); + // expect(() => { + // QWeb.utils.validateProps(TestWidget, { message: null }); + // }).toThrow(); + }); + + test("missing required boolean prop causes an error", async () => { + // class TestWidget extends Widget { + // static props = ["p"]; + // static template = xml`hey`; + // } + // class App extends Widget { + // static template = xml`
`; + // static components = { TestWidget }; + // } + // const w = new App(undefined, {}); + // let error; + // try { + // await w.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe("Missing props 'p' (component 'TestWidget')"); + }); + + test("props are validated whenever component is updated", async () => { + // let error; + // class TestWidget extends Component { + // static props = { p: { type: Number } }; + // static template = xml`
`; + // async __updateProps() { + // try { + // await Component.prototype.__updateProps.apply(this, arguments); + // } catch (e) { + // error = e; + // } + // } + // } + // class Parent extends Component { + // static template = xml`
`; + // static components = { TestWidget }; + // state: any = useState({ p: 1 }); + // } + // const w = new Parent(); + // await w.mount(fixture); + // expect(fixture.innerHTML).toBe("
1
"); + // w.state.p = undefined; + // await nextTick(); + // expect(error).toBeDefined(); + // expect(error.message).toBe("Missing props 'p' (component 'TestWidget')"); + }); + + test("default values are applied before validating props at update", async () => { + // class TestWidget extends Component { + // static props = { p: { type: Number } }; + // static template = xml`
`; + // static defaultProps = { p: 4 }; + // } + // class Parent extends Component { + // static template = xml`
`; + // static components = { TestWidget }; + // state: any = useState({ p: 1 }); + // } + // const w = new Parent(); + // await w.mount(fixture); + // expect(fixture.innerHTML).toBe("
1
"); + // w.state.p = undefined; + // await nextTick(); + // expect(fixture.innerHTML).toBe("
4
"); + }); + + test("mix of optional and mandatory", async () => { + // class Child extends Component { + // static props = { + // optional: { type: String, optional: true }, + // mandatory: Number, + // }; + // static template = xml`
`; + // } + // class App extends Component { + // static components = { Child }; + // static template = xml`
`; + // } + // const w = new App(undefined, {}); + // let error; + // try { + // await w.mount(fixture); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe("Missing props 'mandatory' (component 'Child')"); + }); +}); + +describe.skip("default props", () => { + test("can set default values", async () => { + // class TestWidget extends Component { + // static defaultProps = { p: 4 }; + // static template = xml`
`; + // } + // class Parent extends Component { + // static template = xml`
`; + // static components = { TestWidget }; + // } + // const w = new Parent(); + // await w.mount(fixture); + // expect(fixture.innerHTML).toBe("
4
"); + }); + + test("default values are also set whenever component is updated", async () => { + // class TestWidget extends Widget { + // static template = xml`
`; + // static defaultProps = { p: 4 }; + // } + // class Parent extends Widget { + // static template = xml`
`; + // static components = { TestWidget }; + // state: any = useState({ p: 1 }); + // } + // const w = new Parent(); + // await w.mount(fixture); + // expect(fixture.innerHTML).toBe("
1
"); + // w.state.p = undefined; + // await nextTick(); + // expect(fixture.innerHTML).toBe("
4
"); + }); + + test("can set default required boolean values", async () => { + // class TestWidget extends Widget { + // static props = ["p", "q"]; + // static defaultProps = { p: true, q: false }; + // static template = xml`heyhey`; + // } + // class App extends Widget { + // static template = xml`
`; + // static components = { TestWidget }; + // } + // const w = new App(undefined, {}); + // await w.mount(fixture); + // expect(fixture.innerHTML).toBe("
heyhey
"); + }); +}); diff --git a/tests/components/reactivity.test.ts b/tests/components/reactivity.test.ts new file mode 100644 index 00000000..8868012d --- /dev/null +++ b/tests/components/reactivity.test.ts @@ -0,0 +1,196 @@ +describe.skip("reactivity in lifecycle", () => { + test("state changes in willUnmount do not trigger rerender", async () => { + // const steps: string[] = []; + + // class Child extends Component { + // static template = xml` + // + // `; + // state = useState({ n: 2 }); + // __render(f) { + // steps.push("render"); + // return super.__render(f); + // } + // willPatch() { + // steps.push("willPatch"); + // } + // patched() { + // steps.push("patched"); + // } + + // willUnmount() { + // steps.push("willUnmount"); + // this.state.n = 3; + // } + // } + // class Parent extends Component { + // static template = xml` + //
+ // + //
+ // `; + // static components = { Child }; + // state = useState({ val: 1, flag: true }); + // } + + // const widget = await mount(Parent, { target: fixture }); + // expect(steps).toEqual(["render"]); + // expect(fixture.innerHTML).toBe("
12
"); + // widget.state.flag = false; + // await nextTick(); + // // we make sure here that no call to __render is done + // expect(steps).toEqual(["render", "willUnmount"]); + }); + + test("state changes in willUnmount will be applied on remount", async () => { + // class TestWidget extends Component { + // static template = xml` + //
+ // `; + // state = useState({ val: 1 }); + // willUnmount() { + // this.state.val = 3; + // } + // } + + // const widget = new TestWidget(); + // await widget.mount(fixture); + // expect(fixture.innerHTML).toBe("
1
"); + // widget.unmount(); + // expect(fixture.innerHTML).toBe(""); + // await nextTick(); // wait for changes to be detected before remounting + // await widget.mount(fixture); + // expect(fixture.innerHTML).toBe("
3
"); + // // we want to make sure that there are no remaining tasks left at this point. + // expect(Component.scheduler.tasks.length).toBe(0); + }); + + test("change state just before mounting component", async () => { + // const steps: number[] = []; + // class TestWidget extends Component { + // static template = xml` + //
+ // `; + // state = useState({ val: 1 }); + // __render(f) { + // steps.push(this.state.val); + // return super.__render(f); + // } + // } + // TestWidget.prototype.__render = jest.fn(TestWidget.prototype.__render); + + // const widget = new TestWidget(); + // widget.state.val = 2; + // await widget.mount(fixture); + // expect(fixture.innerHTML).toBe("
2
"); + // expect(TestWidget.prototype.__render).toHaveBeenCalledTimes(1); + + // // unmount and re-mount, as in this case, willStart won't be called, so it's + // // slightly different + // widget.unmount(); + // widget.state.val = 3; + // await widget.mount(fixture); + // expect(fixture.innerHTML).toBe("
3
"); + // expect(TestWidget.prototype.__render).toHaveBeenCalledTimes(2); + // expect(steps).toEqual([2, 3]); + }); + + test("change state while mounting component", async () => { + // const steps: number[] = []; + // class TestWidget extends Component { + // static template = xml` + //
+ // `; + // state = useState({ val: 1 }); + // __render(f) { + // steps.push(this.state.val); + // return super.__render(f); + // } + // } + // TestWidget.prototype.__render = jest.fn(TestWidget.prototype.__render); + // TestWidget.prototype.__patch = jest.fn(TestWidget.prototype.__patch); + + // const widget = new TestWidget(); + // let prom = widget.mount(fixture); + // widget.state.val = 2; + // await prom; + // expect(fixture.innerHTML).toBe("
2
"); + // expect(TestWidget.prototype.__render).toHaveBeenCalledTimes(1); + + // // unmount and re-mount, as in this case, willStart won't be called, so it's + // // slightly different + // widget.unmount(); + // prom = widget.mount(fixture); + // widget.state.val = 3; + // await prom; + // expect(fixture.innerHTML).toBe("
3
"); + // expect(TestWidget.prototype.__render).toHaveBeenCalledTimes(3); + // expect(TestWidget.prototype.__patch).toHaveBeenCalledTimes(2); + // expect(steps).toEqual([2, 2, 3]); + }); + + test("change state and render while mounted in detached dom", async () => { + // class App extends Component { + // static template = xml`
`; + // state = useState({ val: 1 }); + // } + + // const detachedDiv = document.createElement("div"); + // const app = await mount(App, { target: detachedDiv }); + + // expect(detachedDiv.innerHTML).toBe("
1
"); + // app.state.val = 2; + // await nextTick(); + // expect(detachedDiv.innerHTML).toBe("
2
"); + }); + + test("destroy and change state after mounted in detached dom", async () => { + // class App extends Component { + // static template = xml`
`; + // state = useState({ val: 1 }); + // } + + // const detachedDiv = document.createElement("div"); + // const app = await mount(App, { target: detachedDiv }); + + // expect(detachedDiv.innerHTML).toBe("
1
"); + + // app.destroy(); + // app.state.val = 2; + // await nextTick(); + // expect(detachedDiv.innerHTML).toBe(""); + }); + + test("change state while component is unmounted", async () => { + // let child; + // class Child extends Component { + // static template = xml``; + // state = useState({ + // val: "C1", + // }); + // constructor(parent, props) { + // super(parent, props); + // child = this; + // } + // } + + // class Parent extends Component { + // static components = { Child }; + // static template = xml`
`; + // state = useState({ val: "P1" }); + // } + + // const parent = new Parent(); + // await parent.mount(fixture); + // expect(fixture.innerHTML).toBe("
P1C1
"); + + // parent.unmount(); + // expect(fixture.innerHTML).toBe(""); + + // parent.state.val = "P2"; + // child.state.val = "C2"; + + // await parent.mount(fixture); + // expect(fixture.innerHTML).toBe("
P2C2
"); + }); +}) \ No newline at end of file diff --git a/tests/components/style_class.test.ts b/tests/components/style_class.test.ts index 2f75798f..e44a1262 100644 --- a/tests/components/style_class.test.ts +++ b/tests/components/style_class.test.ts @@ -341,8 +341,7 @@ describe("style and class handling", () => { error = e; } expect(error).toBeDefined(); - const regexp = - /Cannot read properties of undefined \(reading 'crash'\)|Cannot read property 'crash' of undefined/g; + const regexp = /Cannot read properties of undefined \(reading 'crash'\)|Cannot read property 'crash' of undefined/g; expect(error.message).toMatch(regexp); expect(fixture.innerHTML).toBe(""); }); diff --git a/tests/components/style_tag.test.ts b/tests/components/style_tag.test.ts new file mode 100644 index 00000000..436e331d --- /dev/null +++ b/tests/components/style_tag.test.ts @@ -0,0 +1,159 @@ +// import { Component, Env } from "../../src/component/component"; +// import { processSheet } from "../../src/component/styles"; +// import { xml, css } from "../../src/tags"; +// import { makeTestFixture, makeTestEnv } from "../helpers"; + +// //------------------------------------------------------------------------------ +// // Setup and helpers +// //------------------------------------------------------------------------------ + +// // We create before each test: +// // - fixture: a div, appended to the DOM, intended to be the target of dom +// // manipulations. Note that it is removed after each test. +// // - env: an Env, necessary to create new components + +// let fixture: HTMLElement; +// let env: Env; + +// beforeEach(() => { +// fixture = makeTestFixture(); +// env = makeTestEnv(); +// Component.env = env; +// document.head.innerHTML = ""; +// }); + +// afterEach(() => { +// fixture.remove(); +// }); + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +describe.skip("styles and component", () => { + test("can define an inline stylesheet", async () => { + // class App extends Component { + // static template = xml`
text
`; + // static style = css` + // .app { + // color: red; + // } + // `; + // } + // expect(document.head.innerHTML).toBe(""); + // const app = new App(); + // expect(document.head.innerHTML).toBe(``); + // await app.mount(fixture); + // const style = getComputedStyle(app.el!); + // expect(style.color).toBe("red"); + // expect(fixture.innerHTML).toBe('
text
'); + }); + + test("inherited components properly apply css", async () => { + // class App extends Component { + // static template = xml`
text
`; + // static style = css` + // .app { + // color: red; + // } + // `; + // } + // class SubApp extends App { + // static style = css` + // .app { + // font-weight: bold; + // } + // `; + // } + // expect(document.head.innerHTML).toBe(""); + // const app = new SubApp(); + // expect(document.head.innerHTML).toBe(``); + // await app.mount(fixture); + // const style = getComputedStyle(app.el!); + // expect(style.color).toBe("red"); + // expect(style.fontWeight).toBe("bold"); + // expect(fixture.innerHTML).toBe('
text
'); + }); + + test("get a meaningful error message if css helper is missing", async () => { + // class App extends Component { + // static template = xml`
text
`; + // static style = `.app {color: red;}`; + // } + // let error; + // try { + // new App(); + // } catch (e) { + // error = e; + // } + // expect(error).toBeDefined(); + // expect(error.message).toBe( + // "Invalid css stylesheet for component 'App'. Did you forget to use the 'css' tag helper?" + // ); + // }); + // test("inline stylesheets are processed", async () => { + // class App extends Component { + // static template = xml`
text
`; + // static style = css` + // .app { + // color: red; + // .some-class { + // font-weight: bold; + // width: 40px; + // } + // display: block; + // } + // `; + // } + // new App(); + // expect(document.head.querySelector("style")!.innerHTML).toBe(`.app { + // color: red; + // } + // .app .some-class { + // font-weight: bold; + // width: 40px; + // } + // .app { + // display: block; + // }`); + }); + + test("properly handle rules with commas", async () => { + // const sheet = processSheet(`.parent-a, .parent-b { + // .child-a, .child-b { + // color: red; + // } + // }`); + // expect(sheet) + // .toBe(`.parent-a .child-a, .parent-a .child-b, .parent-b .child-a, .parent-b .child-b { + // color: red; + // }`); + }); + + test("handle & selector", async () => { + // let sheet = processSheet(`.btn { + // &.danger { + // color: red; + // } + // }`); + // expect(sheet).toBe(`.btn.danger { + // color: red; + // }`); + // sheet = processSheet(`.some-class { + // &.btn { + // .other-class ~ & { + // color: red; + // } + // } + // }`); + // expect(sheet).toBe(`.other-class ~ .some-class.btn { + // color: red; + // }`); + }); +}); diff --git a/tests/components/t_model.test.ts b/tests/components/t_model.test.ts new file mode 100644 index 00000000..88d2dbf8 --- /dev/null +++ b/tests/components/t_model.test.ts @@ -0,0 +1,365 @@ +import { Component } from "../../src/component/component"; +import { mount, useState, xml } from "../../src/index"; +import { editInput, makeTestFixture, nextTick, snapshotEverything } from "../helpers"; + +snapshotEverything(); + +let fixture: HTMLElement; + +beforeEach(() => { + fixture = makeTestFixture(); +}); + +describe.skip("t-model directive", () => { + test("basic use, on an input", async () => { + class SomeComponent extends Component { + static template = xml` +
+ + +
`; + state = useState({ text: "" }); + } + const comp = await mount(SomeComponent, fixture); + + expect(fixture.innerHTML).toBe("
"); + + const input = fixture.querySelector("input")!; + await editInput(input, "test"); + expect(comp.state.text).toBe("test"); + expect(fixture.innerHTML).toBe("
test
"); + }); + + test("basic use, on an input with bracket expression", async () => { + class SomeComponent extends Component { + static template = xml` +
+ + +
`; + state = useState({ text: "" }); + } + + const comp = await mount(SomeComponent, fixture); + expect(fixture.innerHTML).toBe("
"); + + const input = fixture.querySelector("input")!; + await editInput(input, "test"); + expect(comp.state.text).toBe("test"); + expect(fixture.innerHTML).toBe("
test
"); + }); + + test("throws if invalid expression", async () => { + class SomeComponent extends Component { + static template = xml` +
+ +
`; + state = useState({ text: "" }); + } + let error; + try { + await mount(SomeComponent, fixture); + } catch (e) { + error = e; + } + expect(error).toBeDefined(); + expect(error.message).toBe(`Invalid t-model expression: "state" (it should be assignable)`); + }); + + test("basic use, on another key in component", async () => { + class SomeComponent extends Component { + static template = xml`
+ + +
`; + some = useState({ text: "" }); + } + const comp = await mount(SomeComponent, fixture); + + expect(fixture.innerHTML).toBe("
"); + + const input = fixture.querySelector("input")!; + await editInput(input, "test"); + expect(comp.some.text).toBe("test"); + expect(fixture.innerHTML).toBe("
test
"); + }); + + test("on an input, type=checkbox", async () => { + class SomeComponent extends Component { + static template = xml`
+ + + yes + no + +
`; + state = useState({ flag: false }); + } + + const comp = await mount(SomeComponent, fixture); + expect(fixture.innerHTML).toBe('
no
'); + + let input = fixture.querySelector("input")!; + input.click(); + await nextTick(); + expect(fixture.innerHTML).toBe('
yes
'); + expect(comp.state.flag).toBe(true); + + input.click(); + await nextTick(); + expect(comp.state.flag).toBe(false); + }); + + test("on an textarea", async () => { + class SomeComponent extends Component { + static template = xml` +
"); + + const textarea = fixture.querySelector("textarea")!; + await editInput(textarea, "test"); + expect(comp.state.text).toBe("test"); + expect(fixture.innerHTML).toBe("
test
"); + }); + + test("on an input type=radio", async () => { + class SomeComponent extends Component { + static template = xml`
+ + + Choice: +
`; + state = useState({ choice: "" }); + } + const comp = await mount(SomeComponent, fixture); + + expect(fixture.innerHTML).toBe( + '
Choice:
' + ); + + const firstInput = fixture.querySelector("input")!; + firstInput.click(); + await nextTick(); + expect(comp.state.choice).toBe("One"); + expect(fixture.innerHTML).toBe( + '
Choice: One
' + ); + + const secondInput = fixture.querySelectorAll("input")[1]; + secondInput.click(); + await nextTick(); + expect(comp.state.choice).toBe("Two"); + expect(fixture.innerHTML).toBe( + '
Choice: Two
' + ); + }); + + test("on a select", async () => { + class SomeComponent extends Component { + static template = xml`
+ + Choice: +
`; + state = useState({ color: "" }); + } + const comp = await mount(SomeComponent, fixture); + + expect(fixture.innerHTML).toBe( + '
Choice:
' + ); + + const select = fixture.querySelector("select")!; + select.value = "red"; + select.dispatchEvent(new Event("change")); + await nextTick(); + + expect(comp.state.color).toBe("red"); + expect(fixture.innerHTML).toBe( + '
Choice: red
' + ); + }); + + test("on a select, initial state", async () => { + class SomeComponent extends Component { + static template = xml` +
+ +
+ `; + state = useState({ color: "red" }); + } + await mount(SomeComponent, fixture); + const select = fixture.querySelector("select")!; + expect(select.value).toBe("red"); + }); + + test("on a sub state key", async () => { + class SomeComponent extends Component { + static template = xml` +
+ + +
+ `; + state = useState({ something: { text: "" } }); + } + const comp = await mount(SomeComponent, fixture); + + expect(fixture.innerHTML).toBe("
"); + + const input = fixture.querySelector("input")!; + await editInput(input, "test"); + expect(comp.state.something.text).toBe("test"); + expect(fixture.innerHTML).toBe("
test
"); + }); + + test(".lazy modifier", async () => { + class SomeComponent extends Component { + static template = xml` +
+ + +
+ `; + state = useState({ text: "" }); + } + const comp = await mount(SomeComponent, fixture); + + expect(fixture.innerHTML).toBe("
"); + + const input = fixture.querySelector("input")!; + input.value = "test"; + input.dispatchEvent(new Event("input")); + await nextTick(); + expect(comp.state.text).toBe(""); + expect(fixture.innerHTML).toBe("
"); + input.dispatchEvent(new Event("change")); + await nextTick(); + expect(comp.state.text).toBe("test"); + expect(fixture.innerHTML).toBe("
test
"); + }); + + test(".trim modifier", async () => { + class SomeComponent extends Component { + static template = xml` +
+ + +
+ `; + state = useState({ text: "" }); + } + const comp = await mount(SomeComponent, fixture); + + const input = fixture.querySelector("input")!; + await editInput(input, " test "); + expect(comp.state.text).toBe("test"); + expect(fixture.innerHTML).toBe("
test
"); + }); + + test(".number modifier", async () => { + class SomeComponent extends Component { + static template = xml` +
+ + +
+ `; + state = useState({ number: 0 }); + } + const comp = await mount(SomeComponent, fixture); + expect(fixture.innerHTML).toBe("
0
"); + + const input = fixture.querySelector("input")!; + await editInput(input, "13"); + expect(comp.state.number).toBe(13); + expect(fixture.innerHTML).toBe("
13
"); + + await editInput(input, "invalid"); + expect(comp.state.number).toBe("invalid"); + expect(fixture.innerHTML).toBe("
invalid
"); + }); + + test("in a t-foreach", async () => { + class SomeComponent extends Component { + static template = xml` +
+ + + +
+ `; + state = useState([ + { f: false, id: 1 }, + { f: false, id: 2 }, + { f: false, id: 3 }, + ]); + } + const comp = await mount(SomeComponent, fixture); + + expect(fixture.innerHTML).toBe( + '
' + ); + + const input = fixture.querySelectorAll("input")[1]!; + input.click(); + expect(comp.state[1].f).toBe(true); + expect(comp.state[0].f).toBe(false); + expect(comp.state[2].f).toBe(false); + }); + + test("in a t-foreach, part 2", async () => { + class SomeComponent extends Component { + static template = xml` +
+ + + +
+ `; + state = useState(["zuko", "iroh"]); + } + const comp = await mount(SomeComponent, fixture); + expect(comp.state).toEqual(["zuko", "iroh"]); + + const input = fixture.querySelectorAll("input")[1]!; + input.value = "uncle iroh"; + input.dispatchEvent(new Event("input")); + expect(comp.state).toEqual(["zuko", "uncle iroh"]); + }); + + test("two inputs in a div with a t-key", async () => { + class SomeComponent extends Component { + static template = xml` +
+ + +
+ `; + state = useState({ flag: true }); + } + const comp = await mount(SomeComponent, fixture); + expect(fixture.innerHTML).toBe('
'); + fixture.querySelector("input")!.value = "asdf"; + expect(fixture.querySelector("input")!.value).toBe("asdf"); + comp.state.flag = false; + await nextTick(); + expect(fixture.innerHTML).toBe('
'); + expect(fixture.querySelector("input")!.value).toBe(""); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index d74d4722..2f84d669 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -211,3 +211,10 @@ export function children(w: Component): Component[] { export function isDirectChildOf(child: Component, parent: Component): boolean { return children(parent).includes(child); } + +export async function editInput(input: HTMLInputElement | HTMLTextAreaElement, value: string) { + input.value = value; + input.dispatchEvent(new Event("input")); + input.dispatchEvent(new Event("change")); + return nextTick(); +} diff --git a/tests/qweb/__snapshots__/t_if.test.ts.snap b/tests/qweb/__snapshots__/t_if.test.ts.snap index 0a6ae288..c56b3ef9 100644 --- a/tests/qweb/__snapshots__/t_if.test.ts.snap +++ b/tests/qweb/__snapshots__/t_if.test.ts.snap @@ -126,6 +126,24 @@ exports[`t-if boolean value condition false else 1`] = ` }" `; +exports[`t-if boolean value condition missing 1`] = ` +"function anonymous(bdom, helpers +) { + let { text, createBlock, list, multi, html, toggler, component } = bdom; + let { withDefault, getTemplate, prepareList, withKey, zero, call, callSlot, capture, shallowEqual } = helpers; + + let block1 = createBlock(\`\`); + + return function template(ctx, node, key = \\"\\") { + let b2; + if (ctx['condition']) { + b2 = text(\`fail\`); + } + return block1([], [b2]); + } +}" +`; + exports[`t-if can use some boolean operators in expressions 1`] = ` "function anonymous(bdom, helpers ) { diff --git a/tests/qweb/attributes.test.ts b/tests/qweb/attributes.test.ts index 6652654e..a10eb543 100644 --- a/tests/qweb/attributes.test.ts +++ b/tests/qweb/attributes.test.ts @@ -379,4 +379,47 @@ describe("special cases for some specific html attributes/properties", () => { const input = fixture.querySelector("input")!; expect(input.indeterminate).toBe(true); }); + + test.skip("textarea with t-att-value", () => { + // render input with initial value +/* qweb.addTemplate("test", `