1369 lines
35 KiB
TypeScript
1369 lines
35 KiB
TypeScript
import { test, expect } from "bun:test";
|
|
import { writeFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { generate, parse } from "../src/index.ts";
|
|
import { compileWireFile } from "../src/index.ts";
|
|
import { mountHtml } from "@wrnexus/test";
|
|
|
|
let seq = 0;
|
|
/** Compile a `.wrn` source and import the resulting module. */
|
|
async function compileAndImport(src: string): Promise<Record<string, unknown>> {
|
|
const dir = join(tmpdir(), "wire-compiler-test");
|
|
mkdirSync(dir, { recursive: true });
|
|
const file = join(dir, `m${seq++}.ts`);
|
|
writeFileSync(file, compileWireFile(src));
|
|
return import(pathToFileURL(file).href);
|
|
}
|
|
|
|
type CompiledBehavior = {
|
|
functions: string;
|
|
lifecycle: {
|
|
mount?: string;
|
|
update?: string;
|
|
unmount?: string;
|
|
};
|
|
watches: Array<{
|
|
state: string;
|
|
body: string;
|
|
}>;
|
|
};
|
|
|
|
function extractCompiledBehavior(output: string): CompiledBehavior {
|
|
const marker = 'data-wrn-behavior="';
|
|
|
|
const start = output.indexOf(marker);
|
|
|
|
expect(start).toBeGreaterThanOrEqual(0);
|
|
|
|
const valueStart = start + marker.length;
|
|
|
|
const valueEnd = output.indexOf('"', valueStart);
|
|
|
|
expect(valueEnd).toBeGreaterThan(valueStart);
|
|
|
|
const encoded = output.slice(valueStart, valueEnd);
|
|
|
|
expect(encoded).toMatch(/^[A-Za-z0-9+/]+=*$/);
|
|
|
|
return JSON.parse(Buffer.from(encoded, "base64").toString("utf8")) as CompiledBehavior;
|
|
}
|
|
|
|
test("parses a page with a layout member", () => {
|
|
const ast = parse(`page Home {\n layout = "public"\n view { <h1>Hi</h1> }\n}`);
|
|
expect(ast.kind).toBe("page");
|
|
expect(ast.name).toBe("Home");
|
|
expect(ast.layout).toBe("public");
|
|
});
|
|
|
|
test("top-level imports are preserved and available to SSR view expressions", async () => {
|
|
const source = `import { posix } from "node:path";
|
|
|
|
page Home {
|
|
view { <PublicHeader signInHref="{posix.join('/sso', '/sign-in')}" /> }
|
|
}`;
|
|
const ast = parse(source);
|
|
expect(ast.imports).toEqual(['import { posix } from "node:path";']);
|
|
|
|
const mod = await compileAndImport(source);
|
|
const render = mod.default as (ctx: unknown) => string;
|
|
expect(String(await render({}))).toContain('signInHref="/sso/sign-in"');
|
|
});
|
|
|
|
test("parses a component with props and state", () => {
|
|
const ast = parse(
|
|
`component C {\n props {\n n = 0\n }\n state count = n\n view { <b>{count}</b> }\n}`,
|
|
);
|
|
expect(ast.kind).toBe("component");
|
|
expect(ast.props.map((p) => p.name)).toEqual(["n"]);
|
|
expect(ast.states.map((s) => s.name)).toEqual(["count"]);
|
|
});
|
|
|
|
test("component event declarations compile to public root metadata", async () => {
|
|
const source = `component EventButton {
|
|
props {
|
|
label = "Save"
|
|
@event complete = function
|
|
}
|
|
view { <button>{label}</button> }
|
|
}`;
|
|
const ast = parse(source);
|
|
const output = generate(ast);
|
|
const mod = await compileAndImport(source);
|
|
const render = mod.render as (props: Record<string, unknown>) => string;
|
|
|
|
expect(ast.events).toEqual([{ name: "complete" }]);
|
|
expect(output).toContain('data-wrn-events="complete"');
|
|
expect(render({})).toContain('data-wrn-events="complete"');
|
|
});
|
|
|
|
test("explicit nested component mounts serialize structured expression props", () => {
|
|
const output = compileWireFile(`component Shell {
|
|
state brand = { "label": "WRNexus" }
|
|
state items = [{ "label": "Home", "href": "/" }]
|
|
view {
|
|
<div data-component="Navbar" brand={brand} items={items}></div>
|
|
}
|
|
}`);
|
|
|
|
expect(output).toContain('data-component="Navbar"');
|
|
expect(output).toContain('brand="${__wireProp(brand)}"');
|
|
expect(output).toContain('items="${__wireProp(items)}"');
|
|
});
|
|
|
|
test("typed props, required props, state, custom types, and function parameters compile", () => {
|
|
const source = `component TypedPicker {
|
|
types {
|
|
interface DateOption { value: string; label: string }
|
|
type ChangeHandler = (value: string) => void
|
|
}
|
|
props {
|
|
value: string = ""
|
|
options: DateOption[] = []
|
|
required: boolean = false
|
|
onChange: ChangeHandler
|
|
}
|
|
state open: boolean = false
|
|
functions {
|
|
function choose(option: DateOption, index: number): void {
|
|
open = false
|
|
onChange(option.value)
|
|
}
|
|
}
|
|
view { <button @click="choose(options[0], 0)">{value}</button> }
|
|
}`;
|
|
const ast = parse(source);
|
|
expect(ast.props.map(({ name, valueType, required }) => ({ name, valueType, required }))).toEqual(
|
|
[
|
|
{ name: "value", valueType: "string", required: false },
|
|
{ name: "options", valueType: "DateOption[]", required: false },
|
|
{ name: "required", valueType: "boolean", required: false },
|
|
{ name: "onChange", valueType: "ChangeHandler", required: true },
|
|
],
|
|
);
|
|
expect(ast.states[0]).toMatchObject({ name: "open", valueType: "boolean", expr: "false" });
|
|
const output = generate(ast);
|
|
expect(output).toContain("interface DateOption");
|
|
expect(output).toContain("export interface TypedPickerProps");
|
|
expect(output).toContain('"onChange": ChangeHandler;');
|
|
expect(output).toContain("let open: boolean = (false)");
|
|
expect(output).toContain("function choose(option: DateOption, index: number): void");
|
|
const behavior = extractCompiledBehavior(output);
|
|
expect(behavior.functions).toContain("function choose(option, index)");
|
|
expect(behavior.functions).not.toContain("option: DateOption");
|
|
});
|
|
|
|
test("typed declarations reject obvious initializer mismatches", () => {
|
|
expect(() =>
|
|
parse(`component Invalid {
|
|
props {
|
|
required: boolean = "yes"
|
|
}
|
|
view { <div></div> }
|
|
}`),
|
|
).toThrow("Prop 'required' is declared as boolean, but its initializer is string");
|
|
expect(() => parse(`page Invalid { state count: number = false\n view { <p></p> } }`)).toThrow(
|
|
"State 'count' is declared as number, but its initializer is boolean",
|
|
);
|
|
});
|
|
|
|
test("typed props enforce required values and runtime-compatible input", async () => {
|
|
const component = await compileAndImport(`component TypedInput {
|
|
props {
|
|
label: string
|
|
count: number = 0
|
|
enabled: boolean = false
|
|
}
|
|
view { <span>{label}:{count}:{enabled}</span> }
|
|
}`);
|
|
const render = component.render as (props?: Record<string, unknown>) => string;
|
|
expect(() => render()).toThrow("TypedInput requires prop 'label' (string)");
|
|
expect(render({ label: "Total", count: "4", enabled: "true" })).toContain(
|
|
"<span>Total:4:true</span>",
|
|
);
|
|
expect(() => render({ label: "Total", count: "many" })).toThrow("Expected a finite number prop");
|
|
expect(() => render({ label: "Total", enabled: "sometimes" })).toThrow("Expected a boolean prop");
|
|
});
|
|
|
|
test("HTML view: void elements, boolean attrs, comments, lone <", () => {
|
|
const ast = parse(
|
|
`component T {\n view {\n <input type="text" disabled>\n <br/>\n <!-- comment -->\n <p>a < b</p>\n }\n}`,
|
|
);
|
|
const html = compileWireFile(
|
|
`component T {\n view {\n <input type="text" disabled>\n <br/>\n <!-- comment -->\n <p>a < b</p>\n }\n}`,
|
|
);
|
|
expect(html).toContain("<input");
|
|
expect(html).toContain(" disabled");
|
|
expect(html).toContain("<br>");
|
|
expect(html).not.toContain("comment");
|
|
expect(html).toContain("a < b");
|
|
void ast;
|
|
});
|
|
|
|
test("dynamic HTML boolean attributes are omitted when false", async () => {
|
|
const mod = await compileAndImport(`component BooleanAttributes {
|
|
props {
|
|
disabled = false
|
|
checked = false
|
|
required = false
|
|
loading = false
|
|
}
|
|
view {
|
|
<button disabled="{disabled || loading}">Save</button>
|
|
<input checked="{checked}" required="{required}" />
|
|
}
|
|
}`);
|
|
const render = mod.render as (props?: Record<string, unknown>) => string;
|
|
|
|
const enabled = render();
|
|
expect(enabled).not.toContain(" disabled");
|
|
expect(enabled).not.toContain(" checked");
|
|
expect(enabled).not.toContain(" required");
|
|
|
|
const disabled = render({ disabled: "true", checked: "true", required: "true" });
|
|
expect(disabled).toContain("<button disabled>");
|
|
expect(disabled).toContain("<input checked required>");
|
|
});
|
|
|
|
test("components safely forward undeclared HTML attributes to their root", async () => {
|
|
const mod = await compileAndImport(`component ForwardingButton {
|
|
props {
|
|
label = "Button"
|
|
variant = "default"
|
|
disabled = false
|
|
}
|
|
view {
|
|
<button disabled="{disabled}">{label}</button>
|
|
}
|
|
}`);
|
|
const render = mod.render as (props?: Record<string, unknown>) => string;
|
|
const html = render({
|
|
label: "Save",
|
|
variant: "primary",
|
|
formaction: "/submit-form",
|
|
formenctype: "application/x-www-form-urlencoded",
|
|
formmethod: "post",
|
|
popovertarget: "myPopover",
|
|
"aria-describedby": "save-help",
|
|
"data-testid": "save",
|
|
onclick: "alert(1)",
|
|
style: "display:none",
|
|
"data-wrn-bind-0": "unsafe",
|
|
});
|
|
|
|
expect(html).toContain('formaction="/submit-form"');
|
|
expect(html).toContain('formenctype="application/x-www-form-urlencoded"');
|
|
expect(html).toContain('formmethod="post"');
|
|
expect(html).toContain('popovertarget="myPopover"');
|
|
expect(html).toContain('aria-describedby="save-help"');
|
|
expect(html).toContain('data-testid="save"');
|
|
expect(html).not.toContain("variant=");
|
|
expect(html).not.toContain("onclick=");
|
|
expect(html).not.toContain("style=");
|
|
expect(html).not.toContain("data-wrn-bind");
|
|
});
|
|
|
|
test("an explicit attrs spread overrides automatic root forwarding", async () => {
|
|
const mod = await compileAndImport(`component WrappedControl {
|
|
props {
|
|
label = "Control"
|
|
class = ""
|
|
}
|
|
view {
|
|
<span class="{class}">
|
|
<button {...attrs}>{label}</button>
|
|
</span>
|
|
}
|
|
}`);
|
|
const render = mod.render as (props?: Record<string, unknown>) => string;
|
|
const html = render({ label: "Save", class: "wrapper", formaction: "/save" });
|
|
|
|
expect(html).toContain('<span class="wrapper">');
|
|
expect(html).not.toContain('<span formaction="/save"');
|
|
expect(html).toContain('<button formaction="/save">Save</button>');
|
|
});
|
|
|
|
test("stateless component bakes props into server HTML (zero JS)", async () => {
|
|
const mod = await compileAndImport(
|
|
`component Button {\n props {\n label = "Button"\n variant = "default"\n class = ""\n }\n view { <button class="wire-btn wire-btn--{variant} {class}">{label}</button> }\n}`,
|
|
);
|
|
const render = mod.render as (p: Record<string, string>) => string;
|
|
const out = render({ label: "Save <b>", variant: "primary", class: "mt-2" });
|
|
expect(out).toContain('class="wire-btn wire-btn--primary mt-2"');
|
|
expect(out).toContain("Save <b>"); // html-escaped
|
|
expect(out).not.toContain("data-scope"); // no reactivity → no scope
|
|
});
|
|
|
|
test("stateful component: state text baked into a reactive data-text span, prop text baked", async () => {
|
|
const mod = await compileAndImport(
|
|
`component Counter {\n props {\n start = 0\n label = "Count"\n }\n state count = start\n view { <button @click="count++">{label}: {count}</button> }\n}`,
|
|
);
|
|
const render = mod.render as (p: Record<string, string>) => string;
|
|
const out = render({ start: "10", label: "Score" });
|
|
expect(out).toContain('data-scope="start: 10, label: "Score", count: 10"');
|
|
expect(out).toContain('data-on-click="count++"');
|
|
// label baked as static text; count baked as its initial value AND kept live.
|
|
expect(out).toContain('Score: <span data-text="count">10</span>');
|
|
|
|
expect(out).toContain('data-scope="');
|
|
|
|
expect(out).toContain("start: 10");
|
|
|
|
expect(out).toContain("label: "Score"");
|
|
|
|
expect(out).toContain("count: 10");
|
|
});
|
|
|
|
test("prop type coercion follows the default value's type", async () => {
|
|
const mod = await compileAndImport(
|
|
`component X {\n props {\n n = 0\n s = "x"\n b = false\n }\n view { <i>{n}{s}{b}</i> }\n}`,
|
|
);
|
|
// needsScope=false → seeds nothing; verify via a stateful variant instead:
|
|
const mod2 = await compileAndImport(
|
|
`component Y {\n props {\n n = 0\n }\n state v = n\n view { <i @click="v++">{v}</i> }\n}`,
|
|
);
|
|
const out = (mod2.render as (p: Record<string, string>) => string)({ n: "42" });
|
|
expect(out).toContain("v: 42"); // "42" coerced to number 42 (not '42')
|
|
void mod;
|
|
});
|
|
|
|
test("platform events compile through the native runtime bridge", () => {
|
|
const out = compileWireFile(`page Platform {
|
|
state count = 0
|
|
view {
|
|
<button @browser-click="count++" @mobile-click="count = count + 2">Run</button>
|
|
}
|
|
}`);
|
|
expect(out).toContain('data-on-wrnexus-browser-click="count++"');
|
|
expect(out).toContain('data-on-wrnexus-mobile-click="count = count + 2"');
|
|
});
|
|
|
|
test("{t:key} compiles to a data-t marker (both pages and components)", () => {
|
|
const page = compileWireFile(`page P {\n view { <h1>{t:home.title}</h1> }\n}`);
|
|
expect(page).toContain('<span data-t="home.title"></span>');
|
|
const comp = compileWireFile(`component C {\n view { <h1>{t:x}</h1> }\n}`);
|
|
expect(comp).toContain('<span data-t="x"></span>');
|
|
});
|
|
|
|
test("{t:} does NOT wrap a page in a data-scope (regression)", () => {
|
|
// Only state / events force a reactive scope, not i18n markers.
|
|
const page = compileWireFile(`page P {\n view { <h1>{t:a}</h1> <p>{t:b}</p> }\n}`);
|
|
expect(page).not.toContain("data-scope");
|
|
});
|
|
|
|
test("page state text bakes its initial value into a reactive data-text span", () => {
|
|
const page = compileWireFile(
|
|
`page Reactive {\n state count = 3\n view { <p>Count is {count}, doubled {count * 2}</p> }\n}`,
|
|
);
|
|
expect(page).toContain('<span data-text="count">3</span>'); // no-JS sees "3"
|
|
expect(page).toContain('<span data-text="count * 2">6</span>'); // expression evaluated
|
|
expect(page).toContain("data-scope"); // state still forces a reactive scope
|
|
});
|
|
|
|
test("page state attributes bake their initial value and retain a reactive binding", () => {
|
|
const page = compileWireFile(`page Password {
|
|
state show = false
|
|
view {
|
|
<input type="{show ? 'text' : 'password'}" aria-label="{show ? 'Hide' : 'Show'}">
|
|
<button @click="show = !show">Toggle</button>
|
|
}
|
|
}`);
|
|
expect(page).toContain('type="password"');
|
|
expect(page).toContain('aria-label="Show"');
|
|
expect(page.match(/data-wrn-bind-/g)).toHaveLength(2);
|
|
expect(page).toContain("show ? 'text' : 'password'");
|
|
});
|
|
|
|
test("request-dependent page state attributes resolve during server rendering", async () => {
|
|
const mod = await compileAndImport(`page Playground {
|
|
state label = ctx.url.searchParams.get("label") ?? "Default"
|
|
state loading = ctx.url.searchParams.get("loading") ?? "false"
|
|
view {
|
|
<div data-component="Button" label="{label}" loading="{loading}"></div>
|
|
}
|
|
}`);
|
|
const render = mod.default as (ctx: { url: URL }) => Promise<string>;
|
|
const html = await render({
|
|
url: new URL("https://example.test/playground?label=Visible&loading=false"),
|
|
});
|
|
|
|
expect(html).toContain('label="Visible"');
|
|
expect(html).toContain('loading="false"');
|
|
expect(html).not.toContain('label=""');
|
|
});
|
|
|
|
test("data-for: loop-variable mustaches stay literal (not baked server-side)", () => {
|
|
const comp = compileWireFile(
|
|
`component TodoList {\n state todos = []\n view { <ul><li data-for="t in todos">{t.text}</li></ul> }\n}`,
|
|
);
|
|
// The <li> template keeps `{t.text}` for the client list renderer, and the
|
|
// loop variable is never baked (which would be a server-side ReferenceError).
|
|
expect(comp).toContain('data-for="t in todos"');
|
|
expect(comp).toContain("{t.text}");
|
|
expect(comp).not.toContain("__wireHtml(t.text)");
|
|
});
|
|
|
|
test("named slots pass through to the component output", () => {
|
|
const out = compileWireFile(
|
|
`component Card {\n view { <div><slot name="header"></slot><slot></slot></div> }\n}`,
|
|
);
|
|
expect(out).toContain('<slot name="header">');
|
|
expect(out).toContain("<slot></slot>");
|
|
});
|
|
|
|
test("capitalized self-closing tags compile to component mounts", () => {
|
|
const source = `
|
|
page ComponentPage {
|
|
view {
|
|
<UserCard
|
|
name="Ajay"
|
|
role="Admin"
|
|
/>
|
|
}
|
|
}
|
|
`;
|
|
|
|
const output = generate(parse(source));
|
|
|
|
expect(output).toContain('data-component="UserCard"');
|
|
|
|
expect(output).toContain('name="Ajay"');
|
|
|
|
expect(output).toContain('role="Admin"');
|
|
|
|
expect(output).not.toContain("<UserCard");
|
|
});
|
|
|
|
test("capitalized component tags preserve slot children", () => {
|
|
const source = `
|
|
page SlotPage {
|
|
view {
|
|
<Card>
|
|
<h2>Hello</h2>
|
|
<p>Component content</p>
|
|
</Card>
|
|
}
|
|
}
|
|
`;
|
|
|
|
const output = generate(parse(source));
|
|
|
|
expect(output).toContain('data-component="Card"');
|
|
|
|
expect(output).toContain("<h2>Hello</h2>");
|
|
|
|
expect(output).toContain("<p>Component content</p>");
|
|
});
|
|
|
|
test("component tags work inside reusable components", () => {
|
|
const source = `
|
|
component ParentCard {
|
|
props {
|
|
title = "Hello"
|
|
}
|
|
|
|
view {
|
|
<ChildCard title="{title}" />
|
|
}
|
|
}
|
|
`;
|
|
|
|
const output = generate(parse(source));
|
|
|
|
expect(output).toContain('data-component="ChildCard"');
|
|
|
|
expect(output).toContain('title="${__wireProp(title)}"');
|
|
|
|
expect(output).toContain("function __wireProp");
|
|
});
|
|
|
|
test("native array and object props serialize through component mounts", async () => {
|
|
const mod = await compileAndImport(`
|
|
component NativeProps {
|
|
state items = [{"label":"Home","href":"/"}]
|
|
view {
|
|
<Navbar items={items} options={{"dense":true}} />
|
|
<Sidebar items={[{"label":"Cases","href":"/cases"}]} />
|
|
}
|
|
}
|
|
`);
|
|
const render = mod.render as (props?: Record<string, unknown>) => string;
|
|
const html = render();
|
|
|
|
expect(html).toContain(
|
|
'items="[{"label":"Home","href":"/"}]"',
|
|
);
|
|
expect(html).toContain('options="{"dense":true}"');
|
|
expect(html).toContain(
|
|
'items="[{"label":"Cases","href":"/cases"}]"',
|
|
);
|
|
});
|
|
|
|
test("parses a layout block", () => {
|
|
const ast = parse(`
|
|
layout AppLayout {
|
|
view {
|
|
<main>{content}</main>
|
|
}
|
|
}
|
|
`);
|
|
|
|
expect(ast.kind).toBe("layout");
|
|
expect(ast.name).toBe("AppLayout");
|
|
});
|
|
|
|
test("layouts compile as reusable render modules", () => {
|
|
const output = generate(
|
|
parse(`
|
|
layout AppLayout {
|
|
view {
|
|
<main>{content}</main>
|
|
}
|
|
}
|
|
`),
|
|
);
|
|
|
|
expect(output).toContain('export const __wrnexusLayout = "AppLayout"');
|
|
|
|
expect(output).toContain("${__wireRaw(content)}");
|
|
|
|
expect(output).toContain("export function render");
|
|
});
|
|
test("parses component lifecycle hooks", () => {
|
|
const ast = parse(`
|
|
component BackToTop {
|
|
state visible = false
|
|
|
|
lifecycle {
|
|
mount {
|
|
visible = true
|
|
}
|
|
|
|
update {
|
|
console.log(visible)
|
|
}
|
|
|
|
unmount {
|
|
visible = false
|
|
}
|
|
}
|
|
|
|
view {
|
|
<button>Top</button>
|
|
}
|
|
}
|
|
`);
|
|
|
|
expect(ast.lifecycle.mount).toContain("visible = true");
|
|
|
|
expect(ast.lifecycle.update).toContain("console.log(visible)");
|
|
|
|
expect(ast.lifecycle.unmount).toContain("visible = false");
|
|
});
|
|
test("parses state watchers", () => {
|
|
const ast = parse(`
|
|
component Menu {
|
|
state open = false
|
|
|
|
watch open {
|
|
console.log("Open changed", open)
|
|
}
|
|
|
|
view {
|
|
<button>{open}</button>
|
|
}
|
|
}
|
|
`);
|
|
|
|
expect(ast.watches).toEqual([
|
|
{
|
|
state: "open",
|
|
body: expect.stringContaining('console.log("Open changed", open)'),
|
|
},
|
|
]);
|
|
});
|
|
test("rejects watching an undeclared state", () => {
|
|
expect(() =>
|
|
parse(`
|
|
component Broken {
|
|
watch missing {
|
|
console.log(missing)
|
|
}
|
|
|
|
view {
|
|
<div>Broken</div>
|
|
}
|
|
}
|
|
`),
|
|
).toThrow("Cannot watch undeclared state 'missing'");
|
|
});
|
|
test("rejects unknown lifecycle hooks", () => {
|
|
expect(() =>
|
|
parse(`
|
|
component Broken {
|
|
lifecycle {
|
|
created {
|
|
console.log("invalid")
|
|
}
|
|
}
|
|
|
|
view {
|
|
<div>Broken</div>
|
|
}
|
|
}
|
|
`),
|
|
).toThrow("Unknown lifecycle hook 'created'");
|
|
});
|
|
test("component functions compile into browser behavior metadata", () => {
|
|
const output = generate(
|
|
parse(`
|
|
component Counter {
|
|
state count = 0
|
|
|
|
functions {
|
|
function increment() {
|
|
count += 1
|
|
}
|
|
}
|
|
|
|
view {
|
|
<button @click="increment()">
|
|
{count}
|
|
</button>
|
|
}
|
|
}
|
|
`),
|
|
);
|
|
|
|
expect(output).toContain("data-wrn-behavior");
|
|
|
|
expect(output).toContain("function increment()");
|
|
|
|
expect(output).toContain("export const __wrnexusBehavior");
|
|
});
|
|
test("component lifecycle hooks compile into behavior metadata", () => {
|
|
const output = generate(
|
|
parse(`
|
|
component LifecycleTest {
|
|
state ready = false
|
|
|
|
lifecycle {
|
|
mount {
|
|
ready = true
|
|
}
|
|
|
|
update {
|
|
console.log(ready)
|
|
}
|
|
|
|
unmount {
|
|
ready = false
|
|
}
|
|
}
|
|
|
|
view {
|
|
<div>{ready}</div>
|
|
}
|
|
}
|
|
`),
|
|
);
|
|
|
|
const behavior = extractCompiledBehavior(output);
|
|
|
|
expect(behavior.lifecycle.mount).toContain("ready = true");
|
|
|
|
expect(behavior.lifecycle.update).toContain("console.log(ready)");
|
|
|
|
expect(behavior.lifecycle.unmount).toContain("ready = false");
|
|
});
|
|
test("component watchers compile into behavior metadata", () => {
|
|
const output = generate(
|
|
parse(`
|
|
component WatchTest {
|
|
state visible = false
|
|
|
|
watch visible {
|
|
console.log("changed", visible)
|
|
}
|
|
|
|
view {
|
|
<div>{visible}</div>
|
|
}
|
|
}
|
|
`),
|
|
);
|
|
|
|
const behavior = extractCompiledBehavior(output);
|
|
|
|
expect(behavior.watches).toHaveLength(1);
|
|
|
|
expect(behavior.watches[0]?.state).toBe("visible");
|
|
|
|
expect(behavior.watches[0]?.body).toContain('console.log("changed", visible)');
|
|
});
|
|
test("behavior-only components still receive a reactive scope", () => {
|
|
const output = generate(
|
|
parse(`
|
|
component BehaviorOnly {
|
|
functions {
|
|
function sayHello() {
|
|
console.log("hello")
|
|
}
|
|
}
|
|
|
|
lifecycle {
|
|
mount {
|
|
sayHello()
|
|
}
|
|
}
|
|
|
|
view {
|
|
<div>Hello</div>
|
|
}
|
|
}
|
|
`),
|
|
);
|
|
|
|
expect(output).toContain("data-scope");
|
|
|
|
expect(output).toContain("data-wrn-behavior");
|
|
});
|
|
test("component behavior metadata safely encodes multiline code", () => {
|
|
const output = generate(
|
|
parse(`
|
|
component BackToTop {
|
|
state visible = false
|
|
|
|
functions {
|
|
function updateVisibility() {
|
|
visible = window.scrollY > 500
|
|
}
|
|
|
|
function scrollToTop() {
|
|
window.scrollTo({
|
|
top: 0,
|
|
behavior: "smooth"
|
|
})
|
|
}
|
|
}
|
|
|
|
lifecycle {
|
|
mount {
|
|
updateVisibility()
|
|
}
|
|
}
|
|
|
|
watch visible {
|
|
console.log(
|
|
value,
|
|
previous
|
|
)
|
|
}
|
|
|
|
view {
|
|
<button @click="scrollToTop()">
|
|
Top
|
|
</button>
|
|
}
|
|
}
|
|
`),
|
|
);
|
|
|
|
expect(output).toContain("data-wrn-behavior=");
|
|
|
|
expect(output).not.toContain('data-wrn-behavior="{');
|
|
|
|
expect(output).not.toContain('data-wrn-behavior="%7B');
|
|
});
|
|
|
|
test("component watchers compile into behavior metadata", () => {
|
|
const output = generate(
|
|
parse(`
|
|
component WatchTest {
|
|
state visible = false
|
|
|
|
watch visible {
|
|
console.log("changed", visible)
|
|
}
|
|
|
|
view {
|
|
<div>{visible}</div>
|
|
}
|
|
}
|
|
`),
|
|
);
|
|
|
|
const match = /data-wrn-behavior="([^"]+)"/.exec(output);
|
|
|
|
expect(match).not.toBeNull();
|
|
|
|
const behavior = extractBehavior(output);
|
|
|
|
expect(behavior.watches).toEqual([
|
|
{
|
|
state: "visible",
|
|
body: expect.stringContaining('console.log("changed", visible)'),
|
|
},
|
|
]);
|
|
});
|
|
test("component lifecycle hooks compile into behavior metadata", () => {
|
|
const output = generate(
|
|
parse(`
|
|
component LifecycleTest {
|
|
state ready = false
|
|
|
|
lifecycle {
|
|
mount {
|
|
ready = true
|
|
}
|
|
|
|
update {
|
|
console.log(ready)
|
|
}
|
|
|
|
unmount {
|
|
ready = false
|
|
}
|
|
}
|
|
|
|
view {
|
|
<div>{ready}</div>
|
|
}
|
|
}
|
|
`),
|
|
);
|
|
|
|
const match = /data-wrn-behavior="([^"]+)"/.exec(output);
|
|
|
|
expect(match).not.toBeNull();
|
|
|
|
const behavior = extractBehavior(output);
|
|
|
|
expect(behavior.lifecycle.mount).toContain("ready = true");
|
|
|
|
expect(behavior.lifecycle.update).toContain("console.log(ready)");
|
|
|
|
expect(behavior.lifecycle.unmount).toContain("ready = false");
|
|
});
|
|
function extractBehavior(output: string): {
|
|
functions: string;
|
|
lifecycle: {
|
|
mount?: string;
|
|
update?: string;
|
|
unmount?: string;
|
|
};
|
|
watches: Array<{
|
|
state: string;
|
|
body: string;
|
|
}>;
|
|
} {
|
|
const match = /data-wrn-behavior="([^"]+)"/.exec(output);
|
|
|
|
expect(match).not.toBeNull();
|
|
|
|
return JSON.parse(Buffer.from(match![1], "base64").toString("utf8"));
|
|
}
|
|
test("component interpolated class attributes remain reactive", () => {
|
|
const output = generate(
|
|
parse(`
|
|
component CookieToggle {
|
|
state personalizationCookies = false
|
|
|
|
view {
|
|
<span
|
|
class="base {personalizationCookies ? 'enabled' : 'disabled'}"
|
|
>
|
|
Cookie
|
|
</span>
|
|
}
|
|
}
|
|
`),
|
|
);
|
|
|
|
const match = output.match(/data-wrn-bind-class="([^"]+)"/);
|
|
|
|
expect(match).not.toBeNull();
|
|
|
|
const encodedBinding = match?.[1] ?? "";
|
|
|
|
const decodedBinding = encodedBinding
|
|
.replaceAll(""", '"')
|
|
.replaceAll("'", "'")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">");
|
|
|
|
const binding = JSON.parse(decodedBinding) as [string, string];
|
|
|
|
expect(binding[0]).toBe("class");
|
|
|
|
expect(binding[1]).toBe("base {personalizationCookies ? 'enabled' : 'disabled'}");
|
|
});
|
|
test("parses class directives containing Tailwind arbitrary values", () => {
|
|
const ast = parse(`
|
|
component PageHeader {
|
|
state centered = false
|
|
|
|
view {
|
|
<div
|
|
class="py-12"
|
|
class:lg:grid-cols-[minmax(0,1fr)_auto]="!centered"
|
|
class:dark:bg-white/10="centered"
|
|
class:w-[calc(100%-2rem)]="centered"
|
|
>
|
|
Header
|
|
</div>
|
|
}
|
|
}
|
|
`);
|
|
|
|
expect(ast).toBeDefined();
|
|
});
|
|
test("compiles Tailwind arbitrary values in class directives", () => {
|
|
const output = generate(
|
|
parse(`
|
|
component PageHeader {
|
|
state centered = false
|
|
|
|
view {
|
|
<div
|
|
class="py-12"
|
|
class:lg:grid-cols-[minmax(0,1fr)_auto]="!centered"
|
|
class:dark:bg-white/10="centered"
|
|
>
|
|
Header
|
|
</div>
|
|
}
|
|
}
|
|
`),
|
|
);
|
|
|
|
expect(output).toContain("lg:grid-cols-[minmax(0,1fr)_auto]");
|
|
|
|
expect(output).toContain("dark:bg-white/10");
|
|
});
|
|
test("class directives accept single-quoted brace expressions", () => {
|
|
const output = generate(
|
|
parse(`
|
|
component MarketingSectionHeader {
|
|
props {
|
|
align = "start"
|
|
}
|
|
|
|
view {
|
|
<header
|
|
class="max-w-3xl"
|
|
class:mx-auto='{align === "center"}'
|
|
class:text-center='{align === "center"}'
|
|
>
|
|
Header
|
|
</header>
|
|
}
|
|
}
|
|
`),
|
|
);
|
|
|
|
expect(output).toContain('${(align === "center") ? " mx-auto" : ""}');
|
|
expect(output).toContain('${(align === "center") ? " text-center" : ""}');
|
|
expect(output).not.toContain('({align === "center"})');
|
|
expect(output).toContain(""align === \\\\"center\\\\""");
|
|
});
|
|
|
|
test("components support each blocks", () => {
|
|
const output = generate(
|
|
parse(`
|
|
component PageHeader {
|
|
props {
|
|
highlights = []
|
|
}
|
|
|
|
view {
|
|
<div>
|
|
{#each highlights as highlight}
|
|
<span>{highlight.title}</span>
|
|
{/each}
|
|
</div>
|
|
}
|
|
}
|
|
`),
|
|
);
|
|
|
|
expect(output).toContain("highlights");
|
|
|
|
expect(output).toContain("highlight.title");
|
|
|
|
expect(output).not.toContain("supported in pages, not components");
|
|
});
|
|
test("components support if blocks", () => {
|
|
const output = generate(
|
|
parse(`
|
|
component Banner {
|
|
props {
|
|
visible = true
|
|
}
|
|
|
|
view {
|
|
{#if visible}
|
|
<div>Visible</div>
|
|
{:else}
|
|
<div>Hidden</div>
|
|
{/if}
|
|
}
|
|
}
|
|
`),
|
|
);
|
|
|
|
expect(output).toContain("visible");
|
|
expect(output).toContain("Visible");
|
|
expect(output).toContain("Hidden");
|
|
});
|
|
test("component array props support each blocks", () => {
|
|
const output = generate(
|
|
parse(`
|
|
component HighlightList {
|
|
props {
|
|
highlights = []
|
|
}
|
|
|
|
view {
|
|
{#each highlights as highlight}
|
|
<p>{highlight.title}</p>
|
|
{/each}
|
|
}
|
|
}
|
|
`),
|
|
);
|
|
|
|
expect(output).toContain("Array.isArray(def)");
|
|
|
|
expect(output).toContain("JSON.parse(v)");
|
|
|
|
expect(output).toContain("highlights");
|
|
});
|
|
test("page component props evaluate whole expressions", () => {
|
|
const output = generate(
|
|
parse(`
|
|
page Home {
|
|
view {
|
|
<HighlightList
|
|
enabled="{true}"
|
|
highlights="{[
|
|
{ title: 'First' },
|
|
{ title: 'Second' }
|
|
]}"
|
|
/>
|
|
}
|
|
}
|
|
`),
|
|
);
|
|
|
|
expect(output).toContain("__wrnexusPropAttr(true)");
|
|
|
|
expect(output).toContain("__wrnexusPropAttr([");
|
|
});
|
|
test("component functions are available during server rendering", () => {
|
|
const output = generate(
|
|
parse(`
|
|
component FAQAccordion {
|
|
props {
|
|
allowMultiple = false
|
|
items = []
|
|
}
|
|
|
|
state openIndexes = []
|
|
|
|
functions {
|
|
function isOpen(index) {
|
|
return openIndexes.includes(index)
|
|
}
|
|
|
|
function toggleItem(index) {
|
|
if (isOpen(index)) {
|
|
openIndexes = openIndexes.filter(
|
|
(itemIndex) =>
|
|
itemIndex !== index
|
|
)
|
|
} else if (allowMultiple) {
|
|
openIndexes = [
|
|
...openIndexes,
|
|
index
|
|
]
|
|
} else {
|
|
openIndexes = [index]
|
|
}
|
|
}
|
|
}
|
|
|
|
view {
|
|
{#each items as item, index}
|
|
<button
|
|
class:hidden="!isOpen(index)"
|
|
@click="toggleItem(index)"
|
|
>
|
|
{item.title}
|
|
</button>
|
|
{/each}
|
|
}
|
|
}
|
|
`),
|
|
);
|
|
|
|
expect(output).toContain("function isOpen(index)");
|
|
|
|
expect(output).toContain("function toggleItem(index)");
|
|
|
|
expect(output).toContain("let openIndexes =");
|
|
|
|
expect(output).toContain("isOpen(index)");
|
|
});
|
|
test("data-for conditional classes do not evaluate loop variables during SSR", () => {
|
|
const output = generate(
|
|
parse(`
|
|
component Accordion {
|
|
props {
|
|
items = []
|
|
}
|
|
|
|
state openIndexes = []
|
|
|
|
functions {
|
|
function isOpen(index) {
|
|
return openIndexes.includes(index)
|
|
}
|
|
}
|
|
|
|
view {
|
|
<article
|
|
data-for="item, index in items"
|
|
class:open="isOpen(index)"
|
|
>
|
|
{item.title}
|
|
</article>
|
|
}
|
|
}
|
|
`),
|
|
);
|
|
|
|
expect(output).toContain('data-for="item, index in items"');
|
|
|
|
expect(output).toContain("data-wrn-class-0");
|
|
|
|
expect(output).not.toContain('${(isOpen(index)) ? " open" : ""}');
|
|
});
|
|
|
|
test("data-for parser supports optional stable key expressions", async () => {
|
|
const { parseForExpr } = await import("../src/codegen.ts");
|
|
|
|
expect(parseForExpr("item, index in items key item.id")).toEqual({
|
|
item: "item",
|
|
index: "index",
|
|
list: "items",
|
|
key: "item.id",
|
|
});
|
|
|
|
expect(parseForExpr("item in items")).toEqual({
|
|
item: "item",
|
|
index: undefined,
|
|
list: "items",
|
|
key: undefined,
|
|
});
|
|
});
|
|
test("server-rendered each locals work in reactive handlers", async () => {
|
|
const firstLocals = Buffer.from(
|
|
JSON.stringify({
|
|
item: {
|
|
question: "First",
|
|
},
|
|
index: 0,
|
|
}),
|
|
"utf8",
|
|
).toString("base64");
|
|
|
|
const secondLocals = Buffer.from(
|
|
JSON.stringify({
|
|
item: {
|
|
question: "Second",
|
|
},
|
|
index: 1,
|
|
}),
|
|
"utf8",
|
|
).toString("base64");
|
|
|
|
const dom = mountHtml(`
|
|
<div data-scope="openIndex: -1">
|
|
<article
|
|
data-wrn-loop-locals="${firstLocals}"
|
|
>
|
|
<button
|
|
type="button"
|
|
data-on-click="openIndex = index"
|
|
>
|
|
First
|
|
</button>
|
|
|
|
<span
|
|
data-wrn-class-0='["open","openIndex === index"]'
|
|
></span>
|
|
</article>
|
|
|
|
<article
|
|
data-wrn-loop-locals="${secondLocals}"
|
|
>
|
|
<button
|
|
type="button"
|
|
data-on-click="openIndex = index"
|
|
>
|
|
Second
|
|
</button>
|
|
|
|
<span
|
|
data-wrn-class-0='["open","openIndex === index"]'
|
|
></span>
|
|
</article>
|
|
</div>
|
|
`);
|
|
|
|
const buttons = dom.querySelectorAll("button") as HTMLButtonElement[];
|
|
|
|
buttons[1]!.click();
|
|
|
|
await Promise.resolve();
|
|
|
|
const articles = dom.querySelectorAll("article");
|
|
|
|
expect(articles[0]!.querySelector("span")?.classList.contains("open")).toBe(false);
|
|
|
|
expect(articles[1]!.querySelector("span")?.classList.contains("open")).toBe(true);
|
|
}, 20_000);
|
|
|
|
test("WRN 0.3 metadata, computed values, loaders, and actions compile additively", () => {
|
|
const source = `page Dashboard {
|
|
runtime = "universal"
|
|
hydrate = "idle"
|
|
state count = 2
|
|
computed { doubled = count * 2 }
|
|
effect { console.log(doubled) }
|
|
security { auth = "required" }
|
|
load server { return { count: 2 } }
|
|
load client { return { refreshed: true } }
|
|
action save(input) { return input }
|
|
view { <button @click="count++">{doubled}</button> }
|
|
}`;
|
|
const output = compileWireFile(source);
|
|
|
|
expect(output).toContain('export const __wrnexusRuntime = "universal"');
|
|
expect(output).toContain('export const __wrnexusHydrate = "idle"');
|
|
expect(output).toContain("export async function __wrnexusLoad");
|
|
expect(output).toContain("export async function __wrnexusClientLoad");
|
|
expect(output).toContain("export async function save(input)");
|
|
expect(output).toContain("export const __wrnexusActions");
|
|
expect(output).toContain('data-wrn-hydrate="idle"');
|
|
});
|
|
test("page state is available inside server-rendered if blocks", () => {
|
|
const source = `
|
|
page Pricing {
|
|
state billingCycle = "monthly"
|
|
|
|
view {
|
|
{#if billingCycle === "annual"}
|
|
<p>Annual</p>
|
|
{:else}
|
|
<p>Monthly</p>
|
|
{/if}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const ast = parse(source);
|
|
const code = generate(ast);
|
|
|
|
expect(code).toContain("const { billingCycle } = __state;");
|
|
expect(code).toContain('billingCycle === "annual"');
|
|
});
|
|
test("page state SSR condition renders without ReferenceError", async () => {
|
|
const root = mkdtempSync(join(tmpdir(), "wrnexus-page-state-"));
|
|
const generatedFile = join(root, "pricing.generated.ts");
|
|
|
|
try {
|
|
const source = `
|
|
page Pricing {
|
|
state billingCycle = "monthly"
|
|
|
|
view {
|
|
{#if billingCycle === "annual"}
|
|
<p>Annual</p>
|
|
{:else}
|
|
<p>Monthly</p>
|
|
{/if}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const code = generate(parse(source));
|
|
|
|
expect(code).toContain("const { billingCycle } = __state;");
|
|
expect(code).toContain('billingCycle === "annual"');
|
|
|
|
writeFileSync(generatedFile, code, "utf8");
|
|
|
|
const moduleUrl = pathToFileURL(generatedFile).href + `?test=${Date.now()}-${Math.random()}`;
|
|
|
|
const generatedModule = await import(moduleUrl);
|
|
|
|
const html = await generatedModule.default({});
|
|
|
|
expect(html).toContain("<p>Monthly</p>");
|
|
expect(html).not.toContain("<p>Annual</p>");
|
|
} finally {
|
|
rmSync(root, {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
}
|
|
});
|
|
test("page state is declared for server-rendered control blocks", () => {
|
|
const source = `
|
|
page Pricing {
|
|
state billingCycle = "monthly"
|
|
|
|
view {
|
|
{#if billingCycle === "annual"}
|
|
<p>Annual</p>
|
|
{:else}
|
|
<p>Monthly</p>
|
|
{/if}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const code = generate(parse(source));
|
|
|
|
expect(code).toContain("const { billingCycle } = __state;");
|
|
|
|
expect(code).toContain('billingCycle === "annual"');
|
|
});
|
|
|
|
test("style blocks compile with stable page/component/layout metadata", () => {
|
|
const page = compileWireFile(`page StyledPage {
|
|
style { .shared { color: red; } }
|
|
view { <main class="shared">Page</main> }
|
|
}`);
|
|
const component = compileWireFile(`component StyledCard {
|
|
style { .shared { color: blue; } }
|
|
view { <article class="shared">Card</article> }
|
|
}`);
|
|
const layout = compileWireFile(`layout StyledLayout {
|
|
style { .shared { color: green; } }
|
|
view { <div class="shared"><slot></slot></div> }
|
|
}`);
|
|
|
|
expect(page).toContain('data-wrnexus-style-kind="page"');
|
|
expect(component).toContain('data-wrnexus-style-kind="component"');
|
|
expect(layout).toContain('data-wrnexus-style-kind="layout"');
|
|
expect(page).toContain("export const __wrnexusStyles");
|
|
expect(component).toContain("export const __wrnexusStyles");
|
|
expect(layout).toContain("export const __wrnexusStyles");
|
|
expect(page).toMatch(/data-wrnexus-style-id="wrn-page-[a-z0-9]+"/);
|
|
});
|