The brace scanner knew about strings and comments but had no case for regex
literals. A quote inside one opened a phantom string that swallowed every brace
until the next quote; a lone `{` or `}` inside one miscounted block depth. Both
failed the component with "Unbalanced braces" pointing at the block's first line.
`/-/g` parsed fine, which is why this went unnoticed -- it needs a quote or a
brace inside the pattern to bite.
Regex-vs-division is decided by scanning back to the last significant
character, erring towards division: mistaking division for a regex would
swallow code to the next `/` and lose any braces between. A regex cannot span a
newline, so an unterminated one on the line is treated as "not a regex", which
is what keeps a bare URL in view text intact.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
289 lines
9.0 KiB
TypeScript
289 lines
9.0 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { diagnose, formatDiagnostic, parse } from "../src/index.ts";
|
|
|
|
test("parses explicit rendering modes and the never hydration alias", () => {
|
|
const ast = parse(`page Marketing {
|
|
render = "static"
|
|
hydrate = "never"
|
|
view { <h1>Fast</h1> }
|
|
}`);
|
|
expect(ast.renderMode).toBe("static");
|
|
expect(ast.hydrate).toBe("none");
|
|
expect(() => parse('page Invalid { render = "sometimes" view { <p>No</p> } }')).toThrow(
|
|
"Unknown render mode",
|
|
);
|
|
});
|
|
|
|
test("parses a declarative cache policy", () => {
|
|
const ast = parse(`page Dashboard {
|
|
cache {
|
|
strategy = "stale-while-revalidate"
|
|
ttl = "5m"
|
|
tags = ["users", "dashboard"]
|
|
vary = ["tenant", "language"]
|
|
}
|
|
view { <h1>Dashboard</h1> }
|
|
}`);
|
|
expect(ast.cache).toEqual({
|
|
strategy: "stale-while-revalidate",
|
|
ttl: "5m",
|
|
tags: '["users", "dashboard"]',
|
|
vary: '["tenant", "language"]',
|
|
});
|
|
});
|
|
|
|
test("parses native array and object literals in state and component props", () => {
|
|
const ast = parse(`
|
|
component Navigation {
|
|
state items = [{"label":"Home","href":"/"}]
|
|
state options = {"dense":true,"nested":{"label":"A {brace}"}}
|
|
view {
|
|
<Navbar items={items} />
|
|
<Sidebar items={[{"label":"Cases","href":"/cases"}]} options={{"dense":true}} />
|
|
}
|
|
}
|
|
`);
|
|
|
|
expect(ast.states[0]?.expr).toBe('[{"label":"Home","href":"/"}]');
|
|
expect(ast.states[1]?.expr).toBe('{"dense":true,"nested":{"label":"A {brace}"}}');
|
|
|
|
const elements = ast.view.filter((node) => node.type === "element");
|
|
const navbar = elements[0];
|
|
const sidebar = elements[1];
|
|
expect(navbar?.type).toBe("element");
|
|
expect(sidebar?.type).toBe("element");
|
|
if (navbar?.type !== "element" || sidebar?.type !== "element") return;
|
|
|
|
expect(navbar.attrs.find((attr) => attr.name === "items")?.value).toBe("{items}");
|
|
expect(sidebar.attrs.find((attr) => attr.name === "items")?.value).toBe(
|
|
'{[{"label":"Cases","href":"/cases"}]}',
|
|
);
|
|
expect(sidebar.attrs.find((attr) => attr.name === "options")?.value).toBe('{{"dense":true}}');
|
|
});
|
|
|
|
test("parses formatted multiline arrays and objects in state", () => {
|
|
const ast = parse(`component Navigation {
|
|
state items = [
|
|
{
|
|
"label": "Home",
|
|
"href": "/"
|
|
},
|
|
{
|
|
"label": "Services",
|
|
"children": [
|
|
{ "label": "Reports", "href": "/reports" }
|
|
]
|
|
}
|
|
]
|
|
state options = {
|
|
"dense": true,
|
|
"columns": 4
|
|
}
|
|
view { <nav></nav> }
|
|
}`);
|
|
|
|
expect(ast.states[0]?.expr).toContain('"Services"');
|
|
expect(ast.states[0]?.expr).toContain('"Reports"');
|
|
expect(ast.states[1]?.expr).toContain('"columns": 4');
|
|
});
|
|
|
|
test("parses WRN 0.3 execution, data, security, and reactivity blocks", () => {
|
|
const ast = parse(`page Dashboard {
|
|
runtime = "universal"
|
|
hydrate = "visible"
|
|
state count = 1
|
|
computed {
|
|
doubled = count * 2
|
|
}
|
|
effect {
|
|
console.log(doubled)
|
|
}
|
|
security {
|
|
auth = "required"
|
|
csrf = "true"
|
|
}
|
|
load server {
|
|
return { count: 1 }
|
|
}
|
|
load client {
|
|
return { refreshed: true }
|
|
}
|
|
action save(input) {
|
|
return input
|
|
}
|
|
view { <button @click="count++">{doubled}</button> }
|
|
}`);
|
|
|
|
expect(ast.runtime).toBe("universal");
|
|
expect(ast.hydrate).toBe("visible");
|
|
expect(ast.computed).toEqual([{ name: "doubled", expr: "count * 2" }]);
|
|
expect(ast.effects).toHaveLength(1);
|
|
expect(ast.security).toEqual({ auth: "required", csrf: "true" });
|
|
expect(ast.loads.map((load) => load.mode)).toEqual(["server", "client"]);
|
|
expect(ast.actions).toEqual([expect.objectContaining({ name: "save", args: ["input"] })]);
|
|
});
|
|
|
|
test("diagnoses server-only interactive roots and accessibility issues", () => {
|
|
const diagnostics = diagnose(
|
|
`component AvatarButton {
|
|
runtime = "server"
|
|
state open = false
|
|
view {
|
|
<button @click="open = true"><img src="/avatar.png"></button>
|
|
}
|
|
}`,
|
|
{ file: "AvatarButton.wrn", accessibility: true },
|
|
);
|
|
|
|
expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain(
|
|
"WRN-RUNTIME-SERVER-INTERACTIVE",
|
|
);
|
|
expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain("WRN-A11Y-001");
|
|
});
|
|
|
|
test("formats parser diagnostics with stable codes and source locations", () => {
|
|
const source = `page Broken { runtime = "quantum"\n view { <main></main> } }`;
|
|
const [diagnostic] = diagnose(source, { file: "Broken.wrn" });
|
|
|
|
expect(diagnostic?.code).toBe("WRN-RUNTIME-TARGET");
|
|
expect(formatDiagnostic(source, diagnostic!)).toContain("Broken.wrn");
|
|
});
|
|
|
|
test("parses keyed each blocks without changing legacy loop syntax", () => {
|
|
const keyed = parse(`component Rows {
|
|
props { rows = [] }
|
|
view {
|
|
{#each rows as row, index key row.id}
|
|
<p>{index}: {row.name}</p>
|
|
{/each}
|
|
}
|
|
}`);
|
|
|
|
const loop = keyed.view.find((node) => node.type === "each");
|
|
expect(loop).toEqual(
|
|
expect.objectContaining({
|
|
type: "each",
|
|
list: "rows",
|
|
item: "row",
|
|
index: "index",
|
|
key: "row.id",
|
|
}),
|
|
);
|
|
|
|
const legacy = parse(`component Rows {
|
|
props { rows = [] }
|
|
view { {#each rows as row}<p>{row.name}</p>{/each} }
|
|
}`);
|
|
expect(legacy.view.find((node) => node.type === "each")).toEqual(
|
|
expect.objectContaining({ type: "each", key: undefined }),
|
|
);
|
|
});
|
|
|
|
test("parses public component event declarations inside props", () => {
|
|
const ast = parse(`component SearchBox {
|
|
props {
|
|
value = ""
|
|
@event search = function
|
|
@event clear = function
|
|
}
|
|
view { <input value="{value}" /> }
|
|
}`);
|
|
|
|
expect(ast.props.map((prop) => prop.name)).toEqual(["value"]);
|
|
expect(ast.events).toEqual([{ name: "search" }, { name: "clear" }]);
|
|
});
|
|
|
|
test("parses edge and worker execution targets", () => {
|
|
for (const runtime of ["edge", "worker", "service-worker"] as const) {
|
|
expect(parse(`page Runtime { runtime = "${runtime}" view { <p>Hi</p> } }`).runtime).toBe(
|
|
runtime,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("maps literal unions to their runtime primitive types", async () => {
|
|
const { runtimeTypeOf } = await import("../src/types.ts");
|
|
expect(runtimeTypeOf('"_blank" | "_self"')).toBe("string");
|
|
expect(runtimeTypeOf("1 | 2 | 3")).toBe("number");
|
|
expect(runtimeTypeOf("true | false")).toBe("boolean");
|
|
});
|
|
|
|
// An apostrophe in prose is not a string. The brace scanner used to treat one
|
|
// as an opening quote and swallow every brace until the next apostrophe, so a
|
|
// comment like "the Input's slot" broke the whole component with a baffling
|
|
// "Unbalanced braces" error pointing at the block's first line.
|
|
test("comments may contain apostrophes without unbalancing a block", () => {
|
|
const source = `component Demo {
|
|
style {
|
|
/* The panel's own color -- do not inherit it. */
|
|
.demo {
|
|
color: red;
|
|
}
|
|
// A trailing note about the card's border.
|
|
.demo-b {
|
|
color: blue;
|
|
}
|
|
}
|
|
view {
|
|
<p>Docs at https://example.com/a//b are not comments.</p>
|
|
}
|
|
}
|
|
`;
|
|
const ast = parse(source);
|
|
expect(ast.name).toBe("Demo");
|
|
expect(ast.styles.join(" ")).toContain(".demo-b");
|
|
// The URL in view text must survive: `//` is only a comment at line start.
|
|
expect(JSON.stringify(ast.view)).toContain("https://example.com/a//b");
|
|
});
|
|
|
|
// A regex literal is not a string and not a pair of braces. The brace scanner
|
|
// knew about quotes and comments but had no case for regexes, so a quote inside
|
|
// one opened a phantom string that swallowed every brace until the next quote,
|
|
// and a lone `{` or `}` inside one miscounted depth. Both failed the whole
|
|
// component -- with a green build in the reported case, because the damage
|
|
// landed in generated output rather than at parse time.
|
|
test("regex literals do not unbalance a block", () => {
|
|
const mk = (body: string) =>
|
|
`page P {
|
|
load server {
|
|
${body}
|
|
return { x };
|
|
}
|
|
|
|
view { <div>{x}</div> }
|
|
}
|
|
`;
|
|
// A quote inside a regex used to open a string that ran to the next quote.
|
|
expect(parse(mk(` const x = /it's/.test("its");`)).name).toBe("P");
|
|
expect(parse(mk(` const x = /"/.test("q");`)).name).toBe("P");
|
|
// A brace inside a regex used to be counted as block depth.
|
|
expect(parse(mk(` const x = /\{/.test("{");`)).name).toBe("P");
|
|
expect(parse(mk(` const x = /}/.test("}");`)).name).toBe("P");
|
|
// A brace quantifier is balanced, but must not be counted either.
|
|
expect(parse(mk(` const x = /^a{2,3}$/.test("aa");`)).name).toBe("P");
|
|
// A `/` inside a character class does not close the regex.
|
|
expect(parse(mk(` const x = /[/'"{]/.test("/");`)).name).toBe("P");
|
|
// The case that already worked must keep working.
|
|
expect(parse(mk(` const x = "a-b".replace(/-/g, " ");`)).name).toBe("P");
|
|
});
|
|
|
|
// Division must not be mistaken for a regex, or the scanner would swallow code
|
|
// from the `/` to the next one and lose any braces in between.
|
|
test("division is not treated as a regex literal", () => {
|
|
const source = `page P {
|
|
load server {
|
|
const half = 10 / 2;
|
|
const ratio = (a + b) / 2;
|
|
const each = items[0] / total;
|
|
if (half > 1) {
|
|
return { half };
|
|
}
|
|
return { half: 0 };
|
|
}
|
|
|
|
view { <div>{half}</div> }
|
|
}
|
|
`;
|
|
expect(parse(source).name).toBe("P");
|
|
});
|