first commit
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parse } from "../src/index.ts";
|
||||
import { compileWireFile } from "../src/index.ts";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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("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("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("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>');
|
||||
});
|
||||
|
||||
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("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>");
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { NativeCompileError, compileNativeWireFile } from "../src/index.ts";
|
||||
|
||||
test("compiles portable wrn markup to React Native components", () => {
|
||||
const code = compileNativeWireFile(`page Home {
|
||||
state count = 0
|
||||
view {
|
||||
<main class="screen">
|
||||
<h1>Count {count}</h1>
|
||||
<button @click="count++">Add</button>
|
||||
{#if count > 0}<p>Started</p>{:else}<p>Ready</p>{/if}
|
||||
</main>
|
||||
}
|
||||
style { .screen { padding: 24px; background-color: white; } }
|
||||
}`);
|
||||
expect(code).toContain("const [count, setCount] = useState(0)");
|
||||
expect(code).toContain('<View style={[styles["screen"] ]}>');
|
||||
expect(code).toContain("<Text>Count {count}</Text>");
|
||||
expect(code).toContain("onPress={() => { setCount(value => value + 1) }}");
|
||||
expect(code).toContain('"padding": 24');
|
||||
expect(() => new Bun.Transpiler({ loader: "tsx" }).transformSync(code)).not.toThrow();
|
||||
});
|
||||
|
||||
test("rejects browser-only elements with an actionable error", () => {
|
||||
expect(() => compileNativeWireFile("page Data { view { <table></table> } }")).toThrow(
|
||||
NativeCompileError,
|
||||
);
|
||||
});
|
||||
|
||||
test("compiles loops to native JSX", () => {
|
||||
const code = compileNativeWireFile(
|
||||
"page List { view { <ul>{#each items as item, i}<li>{item.name}</li>{:empty}<li>Empty</li>{/each}</ul> } }",
|
||||
);
|
||||
expect(code).toContain("(items).map((item, i)");
|
||||
expect(code).toContain("<Text>{item.name}</Text>");
|
||||
});
|
||||
|
||||
test("selects mobile-only markup and events for native output", () => {
|
||||
const code = compileNativeWireFile(`page Platforms { view {
|
||||
<button data-native-only="mobile" @mobile-click="save()" @browser-click="copy()">Save</button>
|
||||
<p data-native-only="browser">Browser help</p>
|
||||
} }`);
|
||||
expect(code).toContain("onPress={() => { save() }}");
|
||||
expect(code).not.toContain("copy()");
|
||||
expect(code).not.toContain("Browser help");
|
||||
});
|
||||
|
||||
test("rejects declarative Capacitor actions instead of silently dropping them in Expo", () => {
|
||||
expect(() =>
|
||||
compileNativeWireFile(
|
||||
`page Share { view { <button data-native-mobile="share">Share</button> } }`,
|
||||
),
|
||||
).toThrow("currently targets browser/Capacitor pages");
|
||||
});
|
||||
|
||||
test("does not emit native visibility directives as React Native props", () => {
|
||||
const code = compileNativeWireFile(
|
||||
`page Support { view { <p data-native-requires="camera">Camera</p> } }`,
|
||||
);
|
||||
expect(code).not.toContain("data-native-requires");
|
||||
});
|
||||
Reference in New Issue
Block a user