Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions packages/core/src/spec-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,80 @@ describe("validateSpec", () => {
expect(watchIssue!.elementKey).toBe("root");
});

it("detects action_in_props (legacy pattern)", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Button",
props: { label: "Submit", action: "submitForm" },
children: [],
},
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "action_in_props")).toBe(true);
});

it("detects actionParams_in_props (legacy pattern)", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Button",
props: {
label: "Submit",
action: "submitForm",
actionParams: { formId: "main" },
},
children: [],
},
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "actionParams_in_props")).toBe(
true,
);
});

it("detects children_in_props", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Stack",
props: { children: ["child1", "child2"] },
children: [],
},
child1: { type: "Text", props: {}, children: [] },
child2: { type: "Text", props: {}, children: [] },
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "children_in_props")).toBe(
true,
);
});

it("detects state_in_props", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Stack",
props: { state: { count: 0 } },
children: [],
},
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "state_in_props")).toBe(true);
});

it("detects orphaned elements when checkOrphans is true", () => {
const spec: Spec = {
root: "root",
Expand Down Expand Up @@ -247,4 +321,71 @@ describe("autoFixSpec", () => {
const { fixes } = autoFixSpec(spec);
expect(fixes).toHaveLength(0);
});

it("moves children from props to element level", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Stack",
props: { children: ["child1", "child2"] },
children: [],
},
child1: { type: "Text", props: {}, children: [] },
child2: { type: "Text", props: {}, children: [] },
},
};
const { spec: fixed, fixes } = autoFixSpec(spec);
expect(
(fixed.elements.root.props as Record<string, unknown>).children,
).toBeUndefined();
expect(fixed.elements.root.children).toEqual(["child1", "child2"]);
expect(fixes.some((f) => f.includes('"children"'))).toBe(true);
});

it("converts legacy action/actionParams to on.press", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Button",
props: {
label: "Submit",
action: "submitForm",
actionParams: { formId: "main" },
},
children: [],
},
},
};
const { spec: fixed, fixes } = autoFixSpec(spec);
const props = fixed.elements.root.props as Record<string, unknown>;
expect(props.action).toBeUndefined();
expect(props.actionParams).toBeUndefined();
expect(fixed.elements.root.on).toEqual({
press: {
action: "submitForm",
params: { formId: "main" },
},
});
expect(fixes.some((f) => f.includes("legacy"))).toBe(true);
});

it("converts legacy action without params to on.press", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Button",
props: { label: "OK", action: "confirm" },
children: [],
},
},
};
const { spec: fixed, fixes } = autoFixSpec(spec);
expect(fixed.elements.root.on).toEqual({
press: { action: "confirm" },
});
expect(fixes.length).toBeGreaterThan(0);
});
});
93 changes: 92 additions & 1 deletion packages/core/src/spec-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ export interface SpecIssue {
| "empty_spec"
| "on_in_props"
| "repeat_in_props"
| "watch_in_props";
| "watch_in_props"
| "action_in_props"
| "actionParams_in_props"
| "children_in_props"
| "state_in_props";
}

/**
Expand Down Expand Up @@ -162,6 +166,46 @@ export function validateSpec(
code: "watch_in_props",
});
}

// 3f. `action` inside props (legacy pattern, should use `on` field)
if (props && "action" in props && props.action !== undefined) {
issues.push({
severity: "error",
message: `Element "${key}" has "action" inside "props". Use the "on" field instead: { "on": { "press": { "action": "...", "params": {...} } } }`,
elementKey: key,
code: "action_in_props",
});
}

// 3g. `actionParams` inside props (legacy pattern, should use `on` field)
if (props && "actionParams" in props && props.actionParams !== undefined) {
issues.push({
severity: "error",
message: `Element "${key}" has "actionParams" inside "props". Use the "on" field instead: { "on": { "press": { "action": "...", "params": {...} } } }`,
elementKey: key,
code: "actionParams_in_props",
});
}

// 3h. `children` inside props (should be a top-level field)
if (props && "children" in props && props.children !== undefined) {
issues.push({
severity: "error",
message: `Element "${key}" has "children" inside "props". It should be a top-level field on the element (sibling of type/props).`,
elementKey: key,
code: "children_in_props",
});
}

// 3i. `state` inside props (should be at spec level or use $state/$bindState)
if (props && "state" in props && props.state !== undefined) {
issues.push({
severity: "error",
message: `Element "${key}" has "state" inside "props". State should be defined at spec.state level, not inside element props. Use { "$state": "/path" } or { "$bindState": "/path" } for state references.`,
elementKey: key,
code: "state_in_props",
});
}
}

// 4. Orphaned elements (optional)
Expand Down Expand Up @@ -274,6 +318,53 @@ export function autoFixSpec(spec: Spec): {
fixes.push(`Moved "watch" from props to element level on "${key}".`);
}

// Fix children inside props → move to element level
currentProps = fixed.props as Record<string, unknown> | undefined;
if (
currentProps &&
"children" in currentProps &&
currentProps.children !== undefined
) {
const { children, ...restProps } = currentProps;
// Only move if it's an array of strings (element keys)
if (
Array.isArray(children) &&
children.every((c) => typeof c === "string")
) {
fixed = {
...fixed,
props: restProps,
children: children as string[],
};
fixes.push(`Moved "children" from props to element level on "${key}".`);
}
}

// Fix legacy action/actionParams → convert to on.press
currentProps = fixed.props as Record<string, unknown> | undefined;
if (
currentProps &&
"action" in currentProps &&
currentProps.action !== undefined
) {
const { action, actionParams, ...restProps } = currentProps;
const actionBinding = {
action: action as string,
...(actionParams ? { params: actionParams as Record<string, unknown> } : {}),
};
fixed = {
...fixed,
props: restProps,
on: {
...(fixed.on ?? {}),
press: actionBinding,
},
};
fixes.push(
`Converted legacy "action"/"actionParams" to "on.press" on "${key}".`,
);
}

fixedElements[key] = fixed;
}

Expand Down