fix(custom-widgets): refine v2 authoring
This commit is contained in:
124 files changed
+2089
-913
No files matched your search
@@ -1,15 +1,9 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
CUSTOM_WIDGET_SKILL_MD,
|
||||
CUSTOM_WIDGET_SKILL_SHA256,
|
||||
getCustomWidgetComponent,
|
||||
getCustomWidgetSkill,
|
||||
} from "../core/authoring-resources";
|
||||
import { CUSTOM_WIDGET_SKILL_MD, getCustomWidgetComponent, getCustomWidgetSkill } from "../core/authoring-resources";
|
||||
|
||||
const repositoryRoot = resolve(import.meta.dirname, "../../../..");
|
||||
|
||||
@@ -20,11 +14,15 @@ describe("custom widget Agent Skill", () => {
|
||||
expect(getCustomWidgetSkill().skillMd).toBe(source);
|
||||
});
|
||||
|
||||
test("publishes the exact archive hash", async () => {
|
||||
const archive = await readFile(
|
||||
resolve(repositoryRoot, "apps/docs/static/downloads/homarr-custom-widget-2.0.0.zip"),
|
||||
test("publishes repository-based skills.sh installation metadata", () => {
|
||||
const skill = getCustomWidgetSkill();
|
||||
expect(skill.skillsShUrl).toBe("https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget");
|
||||
expect(skill.sourceUrl).toContain("github.com/homarr-labs/homarr/tree/v2/.agents/skills/homarr-custom-widget");
|
||||
expect(skill.installCommand).toBe(
|
||||
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget",
|
||||
);
|
||||
expect(createHash("sha256").update(archive).digest("hex")).toBe(CUSTOM_WIDGET_SKILL_SHA256);
|
||||
expect(skill).not.toHaveProperty("sha256");
|
||||
expect(skill).not.toHaveProperty("downloadUrl");
|
||||
});
|
||||
|
||||
test("publishes safe icon names with the Tabler icon component", () => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { customJsxComponentByName, customJsxComponentRegistry } from "../core/component-registry";
|
||||
import {
|
||||
customJsxComponentByName,
|
||||
customJsxComponentRegistry,
|
||||
getCustomJsxBindingType,
|
||||
} from "../core/component-registry";
|
||||
|
||||
const mantineCore941ComponentExports = [
|
||||
"RemoveScroll",
|
||||
@@ -231,4 +235,25 @@ describe("customJsxComponentRegistry", () => {
|
||||
])("classifies the Mantine 9.4.1 export %s", (name) => {
|
||||
expect(customJsxComponentByName.has(name), `${name} is not classified`).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["TextInput", "string"],
|
||||
["NumberInput", "number"],
|
||||
["Switch", "boolean"],
|
||||
["Select", "string"],
|
||||
["MultiSelect", "string[]"],
|
||||
["DateInput", "string"],
|
||||
["Calendar", "string"],
|
||||
["Tabs", "string"],
|
||||
["Popover", "boolean"],
|
||||
["RangeSlider", "number[]"],
|
||||
] as const)("infers %s bindings as %s", (component, type) => {
|
||||
expect(getCustomJsxBindingType(component)).toBe(type);
|
||||
});
|
||||
|
||||
test("infers range and multiple date controls from their authored props", () => {
|
||||
expect(getCustomJsxBindingType("Calendar", { type: "range" })).toBe("string[]");
|
||||
expect(getCustomJsxBindingType("Chip.Group", { multiple: true })).toBe("string[]");
|
||||
expect(getCustomJsxBindingType("Text", {})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
```json
|
||||
{
|
||||
"$schema": "homarr-custom-widget-v2",
|
||||
"name": "Pokédex",
|
||||
"description": "Browse Pokémon from PokeAPI, view stats/types/abilities, and keep a list of favorites. Every section can be shown or hidden.",
|
||||
"iconUrl": "https://raw.githubusercontent.com/PokeAPI/media/master/logo/pokeapi_256.png",
|
||||
"sources": [
|
||||
{
|
||||
"id": "default",
|
||||
"name": "PokeAPI",
|
||||
"baseUrl": "https://pokeapi.co/api/v2",
|
||||
"networkScope": "public",
|
||||
"auth": { "type": "none" }
|
||||
}
|
||||
],
|
||||
"requests": [
|
||||
{
|
||||
"id": "list",
|
||||
"sourceId": "default",
|
||||
"kind": "query",
|
||||
"method": "GET",
|
||||
"pathTemplate": "/pokemon",
|
||||
"parameters": { "pokemonLimit": { "type": "number" } },
|
||||
"queryTemplate": { "limit": { "$param": "pokemonLimit" }, "offset": 0 },
|
||||
"auth": "inherit",
|
||||
"minimumBoardPermission": "view",
|
||||
"trigger": "load",
|
||||
"invalidates": []
|
||||
},
|
||||
{
|
||||
"id": "detail",
|
||||
"sourceId": "default",
|
||||
"kind": "query",
|
||||
"method": "GET",
|
||||
"pathTemplate": "/pokemon/{name}",
|
||||
"parameters": { "name": { "type": "string" } },
|
||||
"auth": "inherit",
|
||||
"minimumBoardPermission": "view",
|
||||
"trigger": "manual",
|
||||
"invalidates": []
|
||||
}
|
||||
],
|
||||
"optionsSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pokemonLimit": {
|
||||
"type": "number",
|
||||
"title": "Number of Pokémon to load",
|
||||
"minimum": 1,
|
||||
"maximum": 1000,
|
||||
"x-homarr": { "control": "number" }
|
||||
},
|
||||
"showSprite": { "type": "boolean", "title": "Show sprite image", "x-homarr": { "control": "switch" } },
|
||||
"showTypes": { "type": "boolean", "title": "Show type badges", "x-homarr": { "control": "switch" } },
|
||||
"showStats": { "type": "boolean", "title": "Show base stats", "x-homarr": { "control": "switch" } },
|
||||
"showAbilities": { "type": "boolean", "title": "Show abilities", "x-homarr": { "control": "switch" } },
|
||||
"showPhysical": { "type": "boolean", "title": "Show height and weight", "x-homarr": { "control": "switch" } },
|
||||
"showFavoritesBar": {
|
||||
"type": "boolean",
|
||||
"title": "Show favorites-only toggle",
|
||||
"x-homarr": { "control": "switch" }
|
||||
},
|
||||
"accentColor": { "type": "string", "title": "Accent color", "x-homarr": { "control": "color" } }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"defaultOptions": {
|
||||
"pokemonLimit": 151,
|
||||
"showSprite": true,
|
||||
"showTypes": true,
|
||||
"showStats": true,
|
||||
"showAbilities": true,
|
||||
"showPhysical": true,
|
||||
"showFavoritesBar": true,
|
||||
"accentColor": "#e3350d"
|
||||
},
|
||||
"stateSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"selectedPokemon": { "type": "string" },
|
||||
"favorites": { "type": "array", "items": { "type": "string" } },
|
||||
"favoritesOnly": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"defaultState": { "selectedPokemon": "", "favorites": [], "favoritesOnly": false },
|
||||
"template": "**HOMARR\_TEMPLATE**"
|
||||
}
|
||||
```
|
||||
@@ -1,31 +1,89 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { CUSTOM_WIDGET_STARTER } from "../core/examples";
|
||||
import { parseCustomWidgetClipboard, parseCustomWidgetClipboardDetailed } from "../core/import";
|
||||
import {
|
||||
parseCustomWidgetAiResponse,
|
||||
parseCustomWidgetClipboard,
|
||||
parseCustomWidgetClipboardDetailed,
|
||||
} from "../core/import";
|
||||
|
||||
describe("parseCustomWidgetClipboard", () => {
|
||||
describe("Custom Widget imports", () => {
|
||||
it("accepts the canonical raw JSON export", () => {
|
||||
expect(parseCustomWidgetClipboard(JSON.stringify(CUSTOM_WIDGET_STARTER))?.$schema).toBe("homarr-custom-widget-v2");
|
||||
});
|
||||
|
||||
it("combines widget.json and widget.jsx blocks", () => {
|
||||
it("combines one widget.json block and one widget.jsx block", () => {
|
||||
const manifest = { ...CUSTOM_WIDGET_STARTER, template: "__HOMARR_TEMPLATE__" };
|
||||
const result = parseCustomWidgetClipboard(
|
||||
const result = parseCustomWidgetAiResponse(
|
||||
`\`\`\`json\n${JSON.stringify(manifest)}\n\`\`\`\n\`\`\`jsx\n<Text>{options.title}</Text>\n\`\`\``,
|
||||
);
|
||||
expect(result?.template).toBe("<Text>{options.title}</Text>");
|
||||
expect(result).toMatchObject({ success: true, widget: { template: "<Text>{options.title}</Text>" } });
|
||||
});
|
||||
|
||||
it("rejects removed display types and old exports", () => {
|
||||
it("normalizes common AI parameter objects but keeps strict imports strict", () => {
|
||||
const request = {
|
||||
id: "detail",
|
||||
sourceId: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/pokemon/{name}",
|
||||
parameters: { name: { type: "string" } },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
trigger: "manual",
|
||||
};
|
||||
const manifest = { ...CUSTOM_WIDGET_STARTER, requests: [request], template: "__HOMARR_TEMPLATE__" };
|
||||
expect(parseCustomWidgetClipboardDetailed(JSON.stringify(manifest)).success).toBe(false);
|
||||
const result = parseCustomWidgetAiResponse(
|
||||
`\`\`\`json\n${JSON.stringify(manifest)}\n\`\`\`\n\`\`\`jsx\n<Text>Pokemon</Text>\n\`\`\``,
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
widget: { requests: [{ parameters: { name: "string" } }] },
|
||||
warnings: [{ code: "NORMALIZED_PARAMETER_TYPE" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns multiple actionable issues for the supplied malformed Pokemon response", async () => {
|
||||
const response = await readFile(resolve(import.meta.dirname, "fixtures/pokedex-ai-response.md"), "utf8");
|
||||
const result = parseCustomWidgetAiResponse(response);
|
||||
expect(result).toMatchObject({ success: false });
|
||||
if (result.success) throw new Error("Expected malformed response to fail");
|
||||
expect(result.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: "INVALID_JSON" }),
|
||||
expect.objectContaining({ code: "INVALID_JSON_ESCAPE" }),
|
||||
expect.objectContaining({ code: "AI_JSX_BLOCK_REQUIRED" }),
|
||||
expect.objectContaining({ code: "REMOVED_LOCAL_STATE" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects removed fields, legacy displays, and missing JSX fences explicitly", () => {
|
||||
expect(
|
||||
parseCustomWidgetClipboard(JSON.stringify({ displayType: "raw", displayConfig: { type: "raw" } })),
|
||||
).toBeNull();
|
||||
const result = parseCustomWidgetAiResponse(
|
||||
`\`\`\`json\n${JSON.stringify({ ...CUSTOM_WIDGET_STARTER, stateSchema: {} })}\n\`\`\``,
|
||||
);
|
||||
expect(result).toMatchObject({ success: false });
|
||||
if (result.success) throw new Error("Expected removed state to fail");
|
||||
expect(result.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: "REMOVED_LOCAL_STATE" }),
|
||||
expect.objectContaining({ code: "AI_JSX_BLOCK_REQUIRED" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns an actionable validation issue for invalid AI output", () => {
|
||||
expect(parseCustomWidgetClipboardDetailed(JSON.stringify({ $schema: "homarr-custom-widget-v2" }))).toEqual({
|
||||
success: false,
|
||||
error: expect.stringMatching(/name|sources/u),
|
||||
});
|
||||
it("returns all useful schema issues instead of a nullable failure", () => {
|
||||
const result = parseCustomWidgetClipboardDetailed(JSON.stringify({ $schema: "homarr-custom-widget-v2" }));
|
||||
expect(result).toMatchObject({ success: false });
|
||||
if (result.success) throw new Error("Expected invalid widget to fail");
|
||||
expect(result.issues.length).toBeGreaterThan(1);
|
||||
expect(result.issues.some((issue) => issue.path?.includes("name"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -37,8 +37,6 @@ const input: CreatePreviewSessionInput = {
|
||||
template: "<Text>Preview</Text>",
|
||||
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
defaultOptions: {},
|
||||
stateSchema: {},
|
||||
defaultState: {},
|
||||
};
|
||||
|
||||
describe("preview session service", () => {
|
||||
|
||||
@@ -4,11 +4,12 @@ import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { MantineProvider, Text } from "@mantine/core";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ActionButton, CustomJsxRenderer, CustomWidgetRuntimeProvider, SubFetch, ToggleSwitch } from "../runtime";
|
||||
import { createCustomJsxComponents } from "../jsx";
|
||||
import type {
|
||||
CustomJsxRequestCapability,
|
||||
CustomWidgetPublishedQueryState,
|
||||
@@ -105,35 +106,33 @@ async function settle() {
|
||||
}
|
||||
|
||||
describe("Custom Widget runtime ports", () => {
|
||||
it("does not republish local state when equivalent default objects are rendered", async () => {
|
||||
const onStateChange = vi.fn();
|
||||
it("exposes temporary bindings through inputs without browser storage", async () => {
|
||||
const setItem = vi.spyOn(Storage.prototype, "setItem");
|
||||
const rendererMessages = {
|
||||
noTemplate: "No template",
|
||||
interactive: "Interactive",
|
||||
networkCapabilities: "Network capabilities",
|
||||
templateWarnings: (count: number) => `${count} warnings`,
|
||||
};
|
||||
const widget = () => (
|
||||
const components = createCustomJsxComponents({
|
||||
TablerIcon: (() => null) as never,
|
||||
copyLabels: { copy: "Copy", copied: "Copied" },
|
||||
});
|
||||
const widget = (
|
||||
<CustomJsxRenderer
|
||||
template="<Text>{state.search}</Text>"
|
||||
template={'<Stack><TextInput bind="search" defaultValue="containers"/><Text>{inputs.search}</Text></Stack>'}
|
||||
data={{}}
|
||||
stateSchema={{ search: "string" }}
|
||||
defaultState={{ search: "containers" }}
|
||||
onStateChange={onStateChange}
|
||||
requestCapabilities={[]}
|
||||
components={{ Text: Text as never }}
|
||||
components={components}
|
||||
createBindings={() => ({})}
|
||||
messages={rendererMessages}
|
||||
/>
|
||||
);
|
||||
|
||||
await render(widget(), createPort());
|
||||
await render(widget, createPort());
|
||||
await settle();
|
||||
expect(onStateChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
await render(widget(), createPort());
|
||||
await settle();
|
||||
expect(onStateChange).toHaveBeenCalledTimes(1);
|
||||
expect(host.textContent).toContain("containers");
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs an automatic query once and publishes data to the declarative data root", async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { BUNDLED_CUSTOM_WIDGETS } from "../core/bundled-widgets";
|
||||
import { customJsxExamples, CUSTOM_WIDGET_STARTER } from "../core/examples";
|
||||
import { customJsxRequestSchema, customWidgetCreateSchema, customWidgetDefinitionSchema } from "../core/schema";
|
||||
import { validateCustomWidgetOptions } from "../core/options";
|
||||
@@ -9,6 +10,23 @@ describe("Custom JSX v2 validation", () => {
|
||||
expect(() => customWidgetDefinitionSchema.parse(example.widget)).not.toThrow();
|
||||
});
|
||||
|
||||
test.each(BUNDLED_CUSTOM_WIDGETS)("validates bundled definition $id", ({ widget }) => {
|
||||
const result = customWidgetDefinitionSchema.safeParse(widget);
|
||||
expect(result.success, result.error?.issues.map((issue) => issue.message).join("; ")).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps the bundled seed set stable, disabled-ready, and credential-free", () => {
|
||||
expect(BUNDLED_CUSTOM_WIDGETS.map(({ id }) => id)).toEqual([
|
||||
"seed-dog-facts",
|
||||
"seed-currency-exchange",
|
||||
"seed-jellyfin",
|
||||
"seed-pokedex",
|
||||
]);
|
||||
for (const { widget } of BUNDLED_CUSTOM_WIDGETS) {
|
||||
expect(JSON.stringify(widget)).not.toMatch(/"(?:apiKey|password|secret|token)"\s*:/iu);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects every legacy schema", () => {
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, $schema: "homarr-custom-widget-v3" }).success,
|
||||
@@ -105,7 +123,7 @@ describe("Custom JSX v2 validation", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts multi-source actions, dynamic options, conditions, and typed state", () => {
|
||||
test("accepts multi-source actions, dynamic options, conditions, and temporary inputs", () => {
|
||||
const result = customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
sources: [
|
||||
@@ -177,8 +195,6 @@ describe("Custom JSX v2 validation", () => {
|
||||
then: { properties: { advancedColor: { type: "string" } } },
|
||||
},
|
||||
defaultOptions: { start: "today" },
|
||||
stateSchema: { selectedDate: "date", search: "string", opened: "boolean", selectedEntities: "string[]" },
|
||||
defaultState: { selectedDate: "2026-07-17", search: "", opened: false, selectedEntities: ["light.office"] },
|
||||
template:
|
||||
'<Stack><Calendar bind="selectedDate"/><ActionButton requestId="monitor-series" params={{ seriesId: 1 }}>Monitor</ActionButton></Stack>',
|
||||
});
|
||||
@@ -225,13 +241,7 @@ describe("Custom JSX v2 validation", () => {
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
stateSchema: { accessToken: "string" },
|
||||
defaultState: {},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, stateSchema: {} }).success).toBe(false);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
@@ -253,13 +263,6 @@ describe("Custom JSX v2 validation", () => {
|
||||
staticHeaders: { "X-API-Key": "not-exportable" },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
stateSchema: { count: "number" },
|
||||
defaultState: { count: "wrong" },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
@@ -299,27 +302,23 @@ describe("Custom JSX v2 validation", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("requires literal bindings to reference declared camel-case state", () => {
|
||||
test("requires literal temporary input bindings", () => {
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
stateSchema: { selectedDate: "date" },
|
||||
defaultState: { selectedDate: "2026-07-17" },
|
||||
template: '<Calendar bind="selectedDate" />',
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
stateSchema: { selectedDate: "date" },
|
||||
template: '<Calendar bind="missingDate" />',
|
||||
template: "<Calendar bind={inputs.selectedDate} />",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
stateSchema: { selectedDate: "date" },
|
||||
template: "<Calendar bind={state.selectedDate} />",
|
||||
template: "<Text>{state.search}</Text>",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,13 @@ import {
|
||||
renameCustomWidgetRequest,
|
||||
} from "../workbench";
|
||||
import { CUSTOM_WIDGET_STARTER } from "../core/examples";
|
||||
import {
|
||||
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
|
||||
CUSTOM_WIDGET_REQUEST_EXAMPLES,
|
||||
getCustomWidgetDefaultOptionsJsonSchema,
|
||||
getCustomWidgetOptionsJsonSchema,
|
||||
getCustomWidgetRequestsJsonSchema,
|
||||
} from "../core/schema-references";
|
||||
|
||||
describe("Custom Widget workbench contracts", () => {
|
||||
it("always provides a valid starter template", () => {
|
||||
@@ -41,6 +48,14 @@ describe("Custom Widget workbench contracts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("generates schema references and examples from canonical validators", () => {
|
||||
expect(getCustomWidgetRequestsJsonSchema()).toMatchObject({ type: "array" });
|
||||
expect(getCustomWidgetOptionsJsonSchema()).toMatchObject({ type: "object" });
|
||||
expect(getCustomWidgetDefaultOptionsJsonSchema()).toMatchObject({ type: "object" });
|
||||
expect(analyzeRequestManifest(JSON.stringify(CUSTOM_WIDGET_REQUEST_EXAMPLES.full))).toEqual([]);
|
||||
expect(CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.defaults).toMatchObject({ limit: 20 });
|
||||
});
|
||||
|
||||
it("validates named request references and rejects inline network capabilities", () => {
|
||||
expect(analyzeJsxTemplate('<SubFetch requestId="known" />', { requestIds: ["known"] })).toEqual([]);
|
||||
expect(analyzeJsxTemplate('<SubFetch requestId="missing" url="/api" />', { requestIds: ["known"] })).toEqual(
|
||||
@@ -51,13 +66,13 @@ describe("Custom Widget workbench contracts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("diagnoses dynamic and unknown local-state bindings", () => {
|
||||
expect(analyzeJsxTemplate('<Calendar bind="selectedDate" />', { stateKeys: ["selectedDate"] })).toEqual([]);
|
||||
expect(analyzeJsxTemplate('<Calendar bind="missing" />', { stateKeys: ["selectedDate"] })).toEqual([
|
||||
expect.objectContaining({ code: "unknownStateBinding", value: "missing" }),
|
||||
]);
|
||||
expect(analyzeJsxTemplate("<Calendar bind={state.selectedDate} />", { stateKeys: ["selectedDate"] })).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ code: "dynamicStateBinding" })]),
|
||||
it("accepts named temporary inputs and diagnoses dynamic bindings", () => {
|
||||
expect(analyzeJsxTemplate('<Calendar bind="selectedDate" />')).toEqual([]);
|
||||
expect(analyzeJsxTemplate("<Calendar bind={inputs.selectedDate} />")).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ code: "dynamicInputBinding" })]),
|
||||
);
|
||||
expect(analyzeJsxTemplate('<TextInput bind="filter"/><Switch bind="filter"/>')).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ code: "bindingTypeConflict", value: "filter" })]),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const languageDirectory = resolve(import.meta.dirname, "../../../translation/src/lang");
|
||||
|
||||
describe("Workshop navigation translations", () => {
|
||||
test("defines the navbar and breadcrumb label in every locale", async () => {
|
||||
const files = (await readdir(languageDirectory)).filter((file) => file.endsWith(".json"));
|
||||
for (const file of files) {
|
||||
const messages = JSON.parse(await readFile(resolve(languageDirectory, file), "utf8")) as {
|
||||
management?: { navbar?: { items?: { workshop?: unknown } } };
|
||||
navigationStructure?: { manage?: { workshop?: { label?: unknown } } };
|
||||
};
|
||||
expect(messages.management?.navbar?.items?.workshop, file).toBe("Workshop");
|
||||
expect(messages.navigationStructure?.manage?.workshop?.label, file).toBe("Workshop");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user