import { makeTestFixture, nextAppError, nextTick, snapshotEverything } from "../helpers"; import { Component, onError, xml, mount, OwlError, useState } from "../../src"; import { App, DEV_MSG } from "../../src/runtime/app"; import { validateProps } from "../../src/runtime/template_helpers"; import { Schema } from "../../src/runtime/validation"; let fixture: HTMLElement; snapshotEverything(); const info = console.info; let originalconsoleWarn = console.warn; let mockConsoleWarn: any; beforeAll(() => { console.info = (message: any) => { if (message === DEV_MSG()) { return; } info(message); }; }); afterAll(() => { console.info = info; }); beforeEach(() => { fixture = makeTestFixture(); mockConsoleWarn = jest.fn(() => {}); console.warn = mockConsoleWarn; }); afterEach(() => { console.warn = originalconsoleWarn; }); //------------------------------------------------------------------------------ // Props validation //------------------------------------------------------------------------------ describe("props validation", () => { test("validation is only done in dev mode", async () => { class SubComp extends Component { static props = ["message"]; static template = xml`
hey
`; } class Parent extends Component { static components = { SubComp }; static template = xml`
`; } const app = new App(Parent, { test: true }); let error: OwlError | undefined; const appError = nextAppError(app); const mountProm = app.mount(fixture).catch((e: Error) => (error = e)); await expect(appError).resolves.toThrow( "Invalid props for component 'SubComp': 'message' is missing" ); await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe("Invalid props for component 'SubComp': 'message' is missing"); error = undefined; try { await mount(Parent, fixture, { dev: false }); } catch (e) { error = e as Error; } expect(error!).toBeUndefined(); }); test("props: list of strings", async () => { class SubComp extends Component { static props = ["message"]; static template = xml`
hey
`; } class Parent extends Component { static components = { SubComp }; static template = xml`
`; } const app = new App(Parent, { test: true }); let error: OwlError | undefined; const appError = nextAppError(app); const mountProm = app.mount(fixture).catch((e: Error) => (error = e)); await expect(appError).resolves.toThrow( "Invalid props for component 'SubComp': 'message' is missing" ); await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe("Invalid props for component 'SubComp': 'message' is missing"); }); test("validate props for root component", async () => { class Root extends Component { static props = ["message"]; static template = xml`
`; } let error: Error; try { await mount(Root, fixture, { dev: true }); } catch (e) { error = e as Error; } expect(error!).toBeDefined(); expect(error!.message).toBe("Invalid props for component 'Root': 'message' is missing"); }); 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: { p?: any }; class Parent extends Component { static template = xml`
`; get p() { return props.p; } } for (let test of Tests) { const SubComp = class extends Component { static template = xml`
hey
`; static props = { p: test.type }; }; (Parent as any).components = { SubComp }; props = {}; let app = new App(Parent, { test: true }); let error: OwlError | undefined; let appError = nextAppError(app); let mountProm = app.mount(fixture).catch((e: Error) => (error = e)); await expect(appError).resolves.toThrow("Invalid props for component '_a'"); await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe( `Invalid props for component '_a': 'p' is undefined (should be a ${test.type.name.toLowerCase()})` ); error = undefined; props = { p: test.ok }; try { await mount(Parent, fixture, { dev: true }); } catch (e) { error = e as Error; } expect(error!).toBeUndefined(); props = { p: test.ko }; app = new App(Parent, { test: true }); appError = nextAppError(app); mountProm = app.mount(fixture).catch((e: Error) => (error = e)); await expect(appError).resolves.toThrow("Invalid props for component '_a'"); await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe( `Invalid props for component '_a': 'p' is not a ${test.type.name.toLowerCase()}` ); } }); 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: { p?: any }; class Parent extends Component { static template = xml`
`; get p() { return props.p; } } for (let test of Tests) { const SubComp = class extends Component { static props = { p: { type: test.type } }; static template = xml`
hey
`; }; (Parent as any).components = { SubComp }; props = {}; let app = new App(Parent, { test: true }); let error: OwlError | undefined; let appError = nextAppError(app); let mountProm = app.mount(fixture).catch((e: Error) => (error = e)); await expect(appError).resolves.toThrow("Invalid props for component '_a'"); await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe( `Invalid props for component '_a': 'p' is undefined (should be a ${test.type.name.toLowerCase()})` ); error = undefined; props = { p: test.ok }; try { await mount(Parent, fixture, { dev: true }); } catch (e) { error = e as Error; } expect(error!).toBeUndefined(); props = { p: test.ko }; app = new App(Parent, { test: true }); appError = nextAppError(app); mountProm = app.mount(fixture).catch((e: Error) => (error = e)); await expect(appError).resolves.toThrow("Invalid props for component '_a'"); await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe( `Invalid props for component '_a': 'p' is not a ${test.type.name.toLowerCase()}` ); } }); test("can validate a prop with multiple types", async () => { class SubComp extends Component { static template = xml`
hey
`; static props = { p: [String, Boolean] }; } class Parent extends Component { static template = xml`
`; static components = { SubComp }; get p() { return props.p; } } let error: Error; let props: { p?: any }; props = { p: "string" }; try { await mount(Parent, fixture, { dev: true }); } catch (e) { error = e as Error; } expect(error!).toBeUndefined(); props = { p: true }; try { await mount(Parent, fixture, { dev: true }); } catch (e) { error = e as Error; } expect(error!).toBeUndefined(); props = { p: 1 }; const app = new App(Parent, { test: true }); const appError = nextAppError(app); const mountProm = app.mount(fixture).catch((e: Error) => (error = e)); await expect(appError).resolves.toThrow("Invalid props for component 'SubComp'"); await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe( "Invalid props for component 'SubComp': 'p' is not a string or boolean" ); }); test("can validate an optional props", async () => { class SubComp extends Component { static template = xml`
hey
`; static props = { p: { type: String, optional: true } }; } class Parent extends Component { static template = xml`
`; static components = { SubComp }; get p() { return props.p; } } let error: Error; let props: { p?: any }; props = { p: "key" }; try { await mount(Parent, fixture, { dev: true }); } catch (e) { error = e as Error; } expect(error!).toBeUndefined(); props = {}; try { await mount(Parent, fixture, { dev: true }); } catch (e) { error = e as Error; } expect(error!).toBeUndefined(); props = { p: 1 }; const app = new App(Parent, { test: true }); const appError = nextAppError(app); const mountProm = app.mount(fixture).catch((e: Error) => (error = e)); await expect(appError).resolves.toThrow("Invalid props for component 'SubComp'"); await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe("Invalid props for component 'SubComp': 'p' is not a string"); }); test("can validate an array with given primitive type", async () => { class SubComp extends Component { static template = xml`
hey
`; static props = { p: { type: Array, element: String } }; } class Parent extends Component { static template = xml`
`; static components = { SubComp }; get p() { return props.p; } } let error: Error | undefined; let props: { p?: any }; try { props = { p: [] }; await mount(Parent, fixture, { dev: true }); } catch (e) { error = e as Error; } expect(error!).toBeUndefined(); try { props = { p: ["string"] }; await mount(Parent, fixture, { dev: true }); } catch (e) { error = e as Error; } expect(error!).toBeUndefined(); props = { p: [1] }; let app = new App(Parent, { test: true }); let appError = nextAppError(app); let mountProm = app.mount(fixture).catch((e: Error) => (error = e)); await expect(appError).resolves.toThrow("Invalid props for component 'SubComp'"); await mountProm; expect(error!).toBeDefined(); error = undefined; app = new App(Parent, { test: true }); appError = nextAppError(app); mountProm = app.mount(fixture).catch((e: Error) => (error = e)); await expect(appError).resolves.toThrow("Invalid props for component 'SubComp'"); await mountProm; expect(error!).toBeDefined(); }); test("can validate an array with multiple sub element types", async () => { class SubComp extends Component { static template = xml`
hey
`; static props = { p: { type: Array, element: [String, Boolean] } }; } class Parent extends Component { static template = xml`
`; static components = { SubComp }; get p() { return props.p; } } let error: Error; let props: { p?: any }; try { props = { p: [] }; await mount(Parent, fixture, { dev: true }); } catch (e) { error = e as Error; } expect(error!).toBeUndefined(); try { props = { p: ["string"] }; await mount(Parent, fixture, { dev: true }); } catch (e) { error = e as Error; } expect(error!).toBeUndefined(); try { props = { p: [false, true, "string"] }; await mount(Parent, fixture, { dev: true }); } catch (e) { error = e as Error; } expect(error!).toBeUndefined(); props = { p: [true, 1] }; const app = new App(Parent, { test: true }); const appError = nextAppError(app); const mountProm = app.mount(fixture).catch((e: Error) => (error = e)); await expect(appError).resolves.toThrow("Invalid props for component 'SubComp'"); await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe( "Invalid props for component 'SubComp': 'p[1]' is not a string or boolean" ); }); test("can validate an object with simple shape", async () => { class SubComp 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 = { SubComp }; get p() { return props.p; } } let error: Error | undefined; let props: { p?: any }; try { props = { p: { id: 1, url: "url" } }; await mount(Parent, fixture, { dev: true }); } catch (e) { error = e as Error; } expect(error!).toBeUndefined(); props = { p: { id: 1, url: "url", extra: true } }; let app = new App(Parent, { test: true }); let appError = nextAppError(app); let mountProm = app.mount(fixture).catch((e: Error) => (error = e)); await expect(appError).resolves.toThrow("Invalid props for component 'SubComp'"); await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe( "Invalid props for component 'SubComp': 'p' doesn't have the correct shape (unknown key 'extra')" ); props = { p: { id: "1", url: "url" } }; app = new App(Parent, { test: true }); appError = nextAppError(app); mountProm = app.mount(fixture).catch((e: Error) => (error = e)); await expect(appError).resolves.toThrow("Invalid props for component 'SubComp'"); await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe( "Invalid props for component 'SubComp': 'p' doesn't have the correct shape ('id' is not a number)" ); error = undefined; props = { p: { id: 1 } }; app = new App(Parent, { test: true }); appError = nextAppError(app); mountProm = app.mount(fixture).catch((e: Error) => (error = e)); await expect(appError).resolves.toThrow("Invalid props for component 'SubComp'"); await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe( "Invalid props for component 'SubComp': 'p' doesn't have the correct shape ('url' is missing (should be a string))" ); }); test("can validate recursively complicated prop def", async () => { class SubComp 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 = { SubComp }; get p() { return props.p; } } let error: Error; let props: { p?: any }; try { props = { p: { id: 1, url: true } }; await mount(Parent, fixture, { dev: true }); } catch (e) { error = e as Error; } expect(error!).toBeUndefined(); try { props = { p: { id: 1, url: [12] } }; await mount(Parent, fixture, { dev: true }); } catch (e) { error = e as Error; } expect(error!).toBeUndefined(); props = { p: { id: 1, url: [12, true] } }; const app = new App(Parent, { test: true }); const appError = nextAppError(app); const mountProm = app.mount(fixture).catch((e: Error) => (error = e)); await expect(appError).resolves.toThrow("Invalid props for component 'SubComp'"); await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe( "Invalid props for component 'SubComp': 'p' doesn't have the correct shape ('url' is not a boolean or list of numbers)" ); }); 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: Error; try { validateProps(TestComponent as any, { myprop: [{}] }); } catch (e) { error = e as Error; } expect(error!).toBeUndefined(); try { validateProps(TestComponent as any, { myprop: [{ a: 1 }] }); } catch (e) { error = e as Error; } expect(error!).toBeDefined(); expect(error!.message).toBe( "Invalid props for component 'TestComponent': 'myprop[0]' doesn't have the correct shape (unknown key 'a')" ); }); test("can validate with a custom validator", () => { class TestComponent extends Component { static props = { size: { validate: (e: string) => ["small", "medium", "large"].includes(e), }, }; } let error: Error; try { validateProps(TestComponent as any, { size: "small" }); } catch (e) { error = e as Error; } expect(error!).toBeUndefined(); try { validateProps(TestComponent as any, { size: "abcdef" }); } catch (e) { error = e as Error; } expect(error!).toBeDefined(); expect(error!.message).toBe("Invalid props for component 'TestComponent': 'size' is not valid"); }); 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: Error | undefined; try { validateProps(TestComponent as any, { n: 3 }); } catch (e) { error = e as Error; } expect(error!).toBeUndefined(); expect(validator).toBeCalledTimes(1); try { validateProps(TestComponent as any, { n: "str" }); } catch (e) { error = e as Error; } expect(error!).toBeDefined(); expect(error!.message).toBe("Invalid props for component 'TestComponent': 'n' is not a number"); expect(validator).toBeCalledTimes(1); error = undefined; try { validateProps(TestComponent as any, { n: 100 }); } catch (e) { error = e as Error; } expect(error!).toBeDefined(); expect(error!.message).toBe("Invalid props for component 'TestComponent': 'n' is not valid"); expect(validator).toBeCalledTimes(2); }); test("props are validated in dev mode (code snapshot)", async () => { class Child extends Component { static props = ["message"]; static template = xml`
`; } class Parent extends Component { static components = { Child }; static template = xml`
`; } await mount(Parent, fixture, { dev: true }); expect(fixture.innerHTML).toBe("
1
"); }); test("props: list of strings with optional props", async () => { class SubComp extends Component { static props = ["message", "someProp?"]; } expect(() => { validateProps(SubComp as any, { someProp: 1 }); }).toThrow(); expect(() => { validateProps(SubComp as any, { message: 1 }); }).not.toThrow(); }); test("props: can be defined with a boolean", async () => { class SubComp extends Component { static props = { message: true }; } expect(() => { validateProps(SubComp as any, {}); }).toThrow(); }); test("props with type array, and no element", async () => { class SubComp extends Component { static props = { myprop: { type: Array } }; } expect(() => { validateProps(SubComp as any, { myprop: [1] }); }).not.toThrow(); expect(() => { validateProps(SubComp as any, { myprop: 1 }); }).toThrow("Invalid props for component 'SubComp': 'myprop' is not a array"); }); test("props with type object, and no shape", async () => { class SubComp extends Component { static props = { myprop: { type: Object } }; } expect(() => { validateProps(SubComp as any, { myprop: { a: 3 } }); }).not.toThrow(); expect(() => { validateProps(SubComp as any, { myprop: false }); }).toThrow("Invalid props for component 'SubComp': 'myprop' is not a object"); }); test("props: extra props cause an error", async () => { class SubComp extends Component { static props = ["message"]; } expect(() => { validateProps(SubComp as any, { message: 1, flag: true }); }).toThrow(); }); test("props: extra props cause an error, part 2", async () => { class SubComp extends Component { static props = { message: true }; } expect(() => { validateProps(SubComp as any, { message: 1, flag: true }); }).toThrow(); }); test("props: optional prop do not cause an error", async () => { class SubComp extends Component { static props = ["message?"]; } expect(() => { validateProps(SubComp as any, { message: 1 }); }).not.toThrow(); }); test("optional prop do not cause an error if value is undefined", async () => { class SubComp extends Component { static props = { message: { type: String, optional: true } }; } expect(() => { validateProps(SubComp as any, { message: undefined }); }).not.toThrow(); expect(() => { validateProps(SubComp as any, { message: null }); }).toThrow(); }); test("missing required boolean prop causes an error", async () => { class SubComp extends Component { static props = ["p"]; static template = xml`hey`; } class Parent extends Component { static template = xml`
`; static components = { SubComp }; } let error: Error; const app = new App(Parent, { test: true }); const appError = nextAppError(app); const mountProm = app.mount(fixture).catch((e: Error) => (error = e)); await expect(appError).resolves.toThrow("Invalid props for component 'SubComp'"); await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe("Invalid props for component 'SubComp': 'p' is missing"); }); test("props validation does not cause additional subscription", async () => { let obj = { value: 1, otherValue: 2, }; class Child extends Component { static props = { obj: { type: Object, shape: { value: Number, otherValue: Number } }, }; static template = xml``; } class Parent extends Component { static template = xml``; static components = { Child }; obj = useState(obj); } const app = new App(Parent, { test: true }); await app.mount(fixture); expect(fixture.innerHTML).toBe("12"); expect(app.root!.subscriptions).toEqual([{ keys: ["otherValue"], target: obj }]); }); test("props are validated whenever component is updated", async () => { let error: Error; class SubComp extends Component { static props = { p: { type: Number } }; static template = xml`
`; } class Parent extends Component { static template = xml`
`; static components = { SubComp }; state: any = { p: 1 }; setup() { onError((e) => (error = e)); } } const parent = await mount(Parent, fixture, { dev: true }); expect(fixture.innerHTML).toBe("
1
"); parent.state.p = undefined; parent.render(); await nextTick(); expect(error!).toBeDefined(); expect(error!.message).toBe( "Invalid props for component 'SubComp': 'p' is undefined (should be a number)" ); }); test("default values are applied before validating props at update", async () => { // need to do something about errors catched in render class SubComp extends Component { static props = { p: { type: Number, optional: true } }; static template = xml`
`; static defaultProps = { p: 4 }; } class Parent extends Component { static template = xml`
`; static components = { SubComp }; state: any = { p: 1 }; } const parent = await mount(Parent, fixture, { dev: true }); expect(fixture.innerHTML).toBe("
1
"); parent.state.p = undefined; parent.render(); 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 Parent extends Component { static components = { Child }; static template = xml`
`; } let error: Error; const app = new App(Parent, { test: true }); const appError = nextAppError(app); const mountProm = app.mount(fixture).catch((e: Error) => (error = e)); await expect(appError).resolves.toThrow("Invalid props for component 'Child'"); await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe( "Invalid props for component 'Child': 'mandatory' is missing (should be a number)" ); }); test("can specify that additional props are allowed (array)", async () => { class Child extends Component { static props = ["message", "*"]; static template = xml`
hey
`; } class Parent extends Component { static template = xml``; static components = { Child }; } await expect(mount(Parent, fixture, { dev: true })).resolves.toEqual(expect.anything()); }); test("can specify that additional props are allowed (object)", async () => { class Child extends Component { static props: Schema = { message: { type: String }, "*": true, }; static template = xml`
hey
`; } class Parent extends Component { static template = xml``; static components = { Child }; } // we just check that it doesn't throw await expect(mount(Parent, fixture, { dev: true })).resolves.toEqual(expect.anything()); }); test("can validate through slots", async () => { class Child extends Component { static props = ["message"]; static template = xml`
hey
`; } class Wrapper extends Component { static template = xml``; } class Parent extends Component { static components = { Child, Wrapper }; static template = xml``; } const app = new App(Parent, { test: true }); let error: OwlError | undefined; const appError = nextAppError(app); const mountProm = app.mount(fixture).catch((e: Error) => (error = e)); await expect(appError).resolves.toThrow( "Invalid props for component 'Child': 'message' is missing" ); await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe("Invalid props for component 'Child': 'message' is missing"); }); }); //------------------------------------------------------------------------------ // Default props //------------------------------------------------------------------------------ describe("default props", () => { test("can set default values", async () => { class SubComp extends Component { static defaultProps = { p: 4 }; static template = xml`
`; } class Parent extends Component { static template = xml`
`; static components = { SubComp }; } await mount(Parent, fixture, { dev: true }); expect(fixture.innerHTML).toBe("
4
"); }); test("default values are also set whenever component is updated", async () => { class SubComp extends Component { static template = xml`
`; static defaultProps = { p: 4 }; } class Parent extends Component { static template = xml`
`; static components = { SubComp }; state: any = { p: 1 }; } const parent = await mount(Parent, fixture, { dev: true }); expect(fixture.innerHTML).toBe("
1
"); parent.state.p = undefined; parent.render(); await nextTick(); expect(fixture.innerHTML).toBe("
4
"); }); test("can set default boolean values", async () => { class SubComp extends Component { static props = ["p?", "q?"]; static defaultProps = { p: true, q: false }; static template = xml`heyhey`; } class Parent extends Component { static template = xml`
`; static components = { SubComp }; } await mount(Parent, fixture, { dev: true }); expect(fixture.innerHTML).toBe("
heyhey
"); }); test("a default prop cannot be defined on a mandatory prop", async () => { class Child extends Component { static props = { mandatory: Number, }; static defaultProps = { mandatory: 3 }; static template = xml`
`; } class Parent extends Component { static components = { Child }; static template = xml``; } let error: Error; const app = new App(Parent, { test: true }); const appError = nextAppError(app); const mountProm = app.mount(fixture).catch((e: Error) => (error = e)); await expect(appError).resolves.toThrow("default value cannot be defined for a mandatory prop"); await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe( "A default value cannot be defined for a mandatory prop (name: 'mandatory', component: Child)" ); }); });