Files
WRNexusJS/packages/compiler/test/v060-targets.test.ts
T

450 lines
15 KiB
TypeScript

import { expect, test } from "bun:test";
import { gzipSync } from "bun";
import { generateTargets, parse } from "../src/index.ts";
const ast = parse(`component ConfirmDialog {
props { title: string open: boolean = false }
state { loading: boolean = false }
server state { internalId: string = "secret" }
outputs { confirm(payload: string) close() }
functions {
client async function confirm(value: string): Promise<void> {
const saved = await server.confirm(value)
output.confirm(saved)
}
server async function confirm(value: string): Promise<string> { return value }
shared function normalize(value: string): string { return value.trim() }
}
view { <button>{title}</button> }
}`);
test("emits separate browser and server function targets", () => {
const targets = generateTargets(ast);
expect(targets.browser).toContain('"confirm": async function');
expect(targets.browser).toContain('"normalize": function');
expect(targets.server).toContain("async function confirm");
expect(targets.rpc).toEqual([
expect.objectContaining({ component: "ConfirmDialog", function: "confirm" }),
]);
expect(targets.declarations).toContain("interface ConfirmDialogOutputs");
expect(targets.declarations).toContain("interface ConfirmDialogServerCalls");
});
test("component contracts retain declared union options", () => {
const targets = generateTargets(
parse(`component SizeBox {
props { size: "small" | "medium" | "large" = "medium" }
view { <div></div> }
}`),
);
expect(targets.contract.props[0]?.options).toEqual(["small", "medium", "large"]);
});
test("generates a standalone browser module for imported stores", () => {
const targets = generateTargets(
parse(`page store SearchStore {
state { query: string = "" }
client state { focused: boolean = false }
server state { secret: string = "hidden" }
computed { empty: boolean = query.length === 0 }
functions { client function setQuery(value: string): void { query = value } }
persist { storage = "session" include = ["query"] version = 1 }
}`),
);
expect(targets.browser).toContain("__wrnexusStoreRegistry");
expect(targets.browser).toContain('name: "SearchStore"');
expect(targets.browser).toContain('"query"');
expect(targets.browser).not.toContain('"secret": ("hidden")');
expect(targets.declarations).toContain("interface SearchStoreInstance");
});
test("browser codegen avoids reserved prop bindings and parameter collisions", () => {
const targets = generateTargets(
parse(`component ReservedBindings {
props { class: string = "" output: string = "" }
state { value: string = "" }
outputs { change(payload: { value: string }) }
functions {
client function update(output: string): void {
value = output
}
client function notify(): void {
output.change({ value: value })
}
}
view { <button>{class}</button> }
}`),
);
expect(targets.browser).not.toContain("const { class }");
expect(targets.browser).not.toContain("const output = context.output;\n const output");
expect(() => new Function(targets.browser.replace(/^export\s+/gm, ""))).not.toThrow();
});
test("RPC manifests expose only server functions referenced through server.name", () => {
const targets = generateTargets(
parse(`component SecureActions {
functions {
client async function saveClient(): Promise<void> { await server.save("ok") }
server async function save(value: string): Promise<string> { return value }
server function internalSecret(): string { return "secret" }
}
view { <button @click='saveClient()'>Save</button> }
}`),
);
expect(targets.rpc.map((entry) => entry.function)).toEqual(["save"]);
expect(targets.server).toContain("internalSecret");
});
test("generated browser stores bind typed RPC, persistence validation, and HMR", () => {
const targets = generateTargets(
parse(`global store UserStore {
state { user: string | null = null count: number = 0 }
functions {
client async function refresh(): Promise<void> { user = await server.loadCurrentUser() }
server async function loadCurrentUser(): Promise<string | null> { return "Ajay" }
server function internalOnly(): string { return "secret" }
}
persist {
storage = "local"
include = ["count"]
version = 2
migrations { function migrate(value, fromVersion, toVersion) { return value } }
validate { function validate(value) { return value && typeof value === "object" ? value : null } }
}
}`),
);
expect(targets.browser).toContain("const server = context.server");
expect(targets.browser).toContain("currentDefinition.persist.migrate");
expect(targets.browser).toContain("currentDefinition.persist.validate");
expect(targets.browser).toContain("__wrnexusApplyStoreHotUpdate");
expect(targets.rpc.map((entry) => entry.function)).toEqual(["loadCurrentUser"]);
});
test("browser codegen binds peer client functions through the scoped function table", () => {
const targets = generateTargets(
parse(`component PeerCalls {
state { value: number = 0 }
functions {
client function increment(): void { value += 1 }
client function run(): void { increment() }
}
view { <button @click='run()'>{value}</button> }
}`),
);
expect(targets.browser).toContain('implementations["increment"]');
const executable = new Function(
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn { bindClientScope };`,
)() as { bindClientScope: (context: Record<string, unknown>) => Record<string, () => void> };
const state = { value: 0 };
const functions = executable.bindClientScope({
state,
props: {},
output: {},
server: {},
refs: {},
});
functions.run?.();
expect(state.value).toBe(1);
});
test("deferred timer state writes commit after the client function returns", async () => {
const targets = generateTargets(
parse(`component DeferredWrite {
state { value: number = 0 }
functions {
client function later(): void { setTimeout(() => { value = 2 }, 0) }
}
view { <button @click='later()'>{value}</button> }
}`),
);
const executable = new Function(
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn { bindClientScope };`,
)() as { bindClientScope: (context: Record<string, unknown>) => Record<string, () => void> };
let rendered = "0";
const state = new Proxy(
{ value: 0 },
{
set(target, property, value) {
Reflect.set(target, property, value);
rendered = String(value);
return true;
},
},
);
const functions = executable.bindClientScope({
state,
props: {},
output: {},
server: {},
refs: {},
});
functions.later?.();
expect(state.value).toBe(0);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(state.value).toBe(2);
expect(rendered).toBe("2");
});
test("the deferred commit helper does not shadow an authored commit function", () => {
const targets = generateTargets(
parse(`component AuthoredCommit {
state { value: number = 0 }
functions {
client function commit(): void { value = 1 }
client function save(): void { commit() }
}
view { <button @click='save()'>{value}</button> }
}`),
);
expect(() => new Function(targets.browser.replace(/^export\s+/gm, ""))).not.toThrow();
expect(targets.browser).not.toContain("const commit = __wrnexusCommit");
});
test("generated client modules stay below decoded-size and duplication ratchets", () => {
const states = Array.from({ length: 12 }, (_, index) => `s${index}: number = ${index}`).join(" ");
const functions = Array.from(
{ length: 20 },
(_, index) =>
`client function f${index}(): void { s${index % 12} += 1 ${Array.from(
{ length: 20 },
(_unused, peer) => (peer === index ? "" : `if (false) f${peer}()`),
).join(" ")} }`,
).join("\n");
const browser = generateTargets(
parse(`component ClientSizeStress {
state { ${states} }
functions { ${functions} }
view { <button></button> }
}`),
).browser;
const decoded = new TextEncoder().encode(browser).length;
const compressed = gzipSync(new TextEncoder().encode(browser)).length;
expect(decoded).toBeLessThanOrEqual(13_000);
expect(decoded / compressed).toBeLessThanOrEqual(10);
});
test("async completion commits sibling state atomically before reactive readers run", async () => {
const targets = generateTargets(
parse(`component AtomicCompletion {
state { busy: boolean = true; submitted: boolean = false }
functions { shared function visible(){return !submitted} client async function finish(){await Promise.resolve();busy=false;submitted=true} }
view { <p>{visible()}</p> }
}`),
);
const executable = new Function(
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn {bindClientScope};`,
)();
let batching = false,
reads = 0;
const values = { busy: true, submitted: false };
const state = new Proxy(values, {
set(target, key, value) {
const previous = Reflect.get(target, key);
Reflect.set(target, key, value);
if (!batching && previous !== value) {
reads++;
functions.visible();
}
return true;
},
});
const functions = executable.bindClientScope({
state,
props: {},
output: {},
server: {},
refs: {},
batch(callback: () => void) {
batching = true;
try {
callback();
} finally {
batching = false;
}
reads++;
},
});
await functions.finish();
expect(values.busy).toBe(false);
expect(values.submitted).toBe(true);
expect(reads).toBeGreaterThan(0);
});
test("display helpers subscribe only to their transitive inputs and never write unchanged state", () => {
const targets = generateTargets(
parse(`component FocusedHelpers {
state { events: string[] = []; title: string = ""; selected: string = "" }
props { timezone: string = "UTC"; unrelated: string = "" }
functions {
shared function zone(){return timezone}
shared function matches(item){return item===selected}
shared function visible(){return events.filter(matches).map(item=>item+zone())}
client function changeTitle(value){title=value}
}
view { <p>{visible()}</p> }
}`),
);
const executable = new Function(
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn {bindClientScope};`,
)();
const reads: string[] = [],
writes: string[] = [];
const values = { events: ["chosen"], title: "draft", selected: "chosen" };
const functions = executable.bindClientScope({
state: new Proxy(values, {
get(target, key) {
reads.push(String(key));
return Reflect.get(target, key);
},
set(target, key, value) {
writes.push(String(key));
return Reflect.set(target, key, value);
},
}),
props: { timezone: "UTC", unrelated: "" },
output: {},
server: {},
refs: {},
});
reads.length = 0;
writes.length = 0;
expect(functions.visible()).toEqual(["chosenUTC"]);
expect([...new Set(reads)].sort()).toEqual(["events", "selected"]);
expect(writes).toEqual([]);
functions.changeTitle("new draft");
expect(writes).toEqual(["title"]);
expect(values.title).toBe("new draft");
reads.length = 0;
writes.length = 0;
expect(functions.visible()).toEqual(["chosenUTC"]);
expect(reads).not.toContain("title");
expect(writes).toEqual([]);
});
test("helper defaults, template expressions and returned callbacks retain state dependencies", () => {
const targets = generateTargets(
parse(`component IndirectReads {
state { selected: string = "A"; title: string = "draft"; irrelevant: number = 0 }
functions { shared function value(input=selected){return input} shared function label(){return () => \`\${title}:\${value()}\`} }
view { <p>{label()()}</p> }
}`),
);
const executable = new Function(
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn {bindClientScope};`,
)();
const reads: string[] = [];
const values = { selected: "A", title: "draft", irrelevant: 0 };
const functions = executable.bindClientScope({
state: new Proxy(values, {
get(target, key) {
reads.push(String(key));
return Reflect.get(target, key);
},
}),
props: {},
output: {},
server: {},
refs: {},
});
reads.length = 0;
expect(functions.label()()).toBe("draft:A");
expect([...new Set(reads)].sort()).toEqual(["selected", "title"]);
values.title = "updated";
expect(functions.label()()).toBe("updated:A");
});
test("a focused helper cannot overwrite unrelated state changed outside the bound closure", async () => {
const targets = generateTargets(
parse(`component PreserveExternalState {
state { count: number = 0; title: string = "draft" }
functions { shared function countLabel(){return String(count)} client async function increment(){await Promise.resolve();count+=1} }
view { <p>{countLabel()}</p> }
}`),
);
const executable = new Function(
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn {bindClientScope};`,
)();
const state = { count: 0, title: "draft" };
const functions = executable.bindClientScope({
state,
props: {},
output: {},
server: {},
refs: {},
});
state.title = "external update";
expect(functions.countLabel()).toBe("0");
expect(state.title).toBe("external update");
await functions.increment();
expect(state.count).toBe(1);
expect(state.title).toBe("external update");
});
test("permission strings and method names do not create false helper dependencies", () => {
const targets = generateTargets(
parse(`component PermissionHelpers {
state { title: string = "draft" }
props { roles: string[] = [] }
functions { shared function canCreate(){return roles.includes('calendar.create')} client function create(){return title} }
view { <p>{canCreate()}</p> }
}`),
);
const executable = new Function(
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn {bindClientScope};`,
)();
const reads: string[] = [];
const functions = executable.bindClientScope({
state: new Proxy(
{ title: "draft" },
{
get(target, key) {
reads.push(String(key));
return Reflect.get(target, key);
},
},
),
props: { roles: ["calendar.create"] },
output: {},
server: {},
refs: {},
});
reads.length = 0;
expect(functions.canCreate()).toBe(true);
expect(reads).toEqual([]);
});
test("async functions publish loading and navigation state before yielding", async () => {
const targets = generateTargets(
parse(`component ImmediateAsyncState {
state { busy: boolean = false; selected: string = "old" }
functions { shared function label(){return selected} client async function load(value){busy=true;selected=value;await server.wait();busy=false} }
view { <p>{label()}</p> }
}`),
);
const executable = new Function(
`${targets.browser.replace(/^export\s+/gm, "")}\nreturn {bindClientScope};`,
)();
let release: any;
const gate = new Promise<void>((resolve) => (release = resolve));
const state = { busy: false, selected: "old" };
const functions = executable.bindClientScope({
state,
props: {},
output: {},
server: { wait: () => gate },
refs: {},
});
const pending = functions.load("new");
expect(state.busy).toBe(true);
expect(state.selected).toBe("new");
expect(functions.label()).toBe("new");
expect(state.busy).toBe(true);
release();
await pending;
expect(state.busy).toBe(false);
expect(state.selected).toBe("new");
});