fix: harden assistant authoring and UI
This commit is contained in:
1 parent
fa0e55cc93
commit
edc7b4d55b
51 files changed
+4118
-916
No files matched your search
@@ -127,7 +127,11 @@ export const CUSTOM_WIDGET_AUTHORING_PROMPT = AUTHORING_PROMPT;
|
||||
|
||||
export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX v2 widget at a time.
|
||||
|
||||
Read the live schema and only the component resources needed for the design. Construct a credential-free widget, validate it, create a preview, test queries and simulated actions, visually inspect it, then create it. Configure deployment-specific source URLs and request credentials through Homarr when needed; never repeat plaintext. The live resources are authoritative for this Homarr release.
|
||||
First call customWidget_getSkill. Its single lazy response contains the complete installed SKILL.md and every bundled reference; follow all of it. Read the live schema and compact component catalog. Fetch only the named component and example resources needed for the design. Component resources contain component-specific props; if shared props are needed, collect their catalog names and fetch those docs together once with customWidget_getSharedProps. The live resources are authoritative for this Homarr release.
|
||||
|
||||
Follow this lifecycle without skipping steps: construct one credential-free definition; call customWidget_validate; repair every reported issue; call customWidget_previewCreate; call customWidget_previewQuery once for every query returned by that preview (supplying required manual parameters); inspect the real response shape and HTTP status; adjust paths and template bindings; validate and preview again after changes; open or provide the returned previewPath for visual review; only then call customWidget_create. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate, preview, and create tool inputs.
|
||||
|
||||
Do not claim a query works when it has not returned a successful preview response. Configure deployment-specific source URLs and request credentials through Homarr when needed; never repeat plaintext. Use simulated preview actions unless the user explicitly enables live preview actions.
|
||||
|
||||
Use the lean keyed sources, requests, and options contract. Bind saved values directly with $option and invocation values with $param. Load queries cannot use $param. Keep the design responsive, accessible, theme-safe, loading-aware, empty-aware, and error-aware.
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { customJsxAuthoringCatalog, getCustomJsxComponentProps } from "./component-catalog";
|
||||
import {
|
||||
customJsxAuthoringCatalog,
|
||||
customJsxCatalogGlobalPropByName,
|
||||
resolveCustomJsxPropDescriptor,
|
||||
} from "./component-catalog";
|
||||
import { customJsxExamples } from "./examples";
|
||||
import { customJsxTablerIconNames } from "./tabler-icons";
|
||||
|
||||
@@ -7,7 +11,58 @@ export const CUSTOM_WIDGET_SKILL_SOURCE_URL =
|
||||
"https://github.com/homarr-labs/homarr/tree/HEAD/.agents/skills/homarr-custom-widget";
|
||||
export const CUSTOM_WIDGET_SKILL_INSTALL_COMMAND =
|
||||
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget";
|
||||
export const CUSTOM_WIDGET_SKILL_VERSION = "2.1.0";
|
||||
export const CUSTOM_WIDGET_SKILL_VERSION = "2.2.0";
|
||||
|
||||
export const CUSTOM_WIDGET_SKILL_REFERENCES = {
|
||||
"references/schema.md": `# Schema
|
||||
|
||||
\`\`\`ts
|
||||
interface HomarrCustomWidgetV2 {
|
||||
$schema: "homarr-custom-widget-v2";
|
||||
name: string;
|
||||
description?: string;
|
||||
iconUrl?: string;
|
||||
sources: Record<string, CustomWidgetSource>;
|
||||
requests: Record<string, CustomWidgetRequest>;
|
||||
options?: Record<string, CustomWidgetOption>;
|
||||
template: string;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Sources require \`default\`. A request defaults to the default source, query, GET, load, inherited auth, and view permission. An action defaults to manual and modify permission. DELETE uses full permission and confirmation.
|
||||
|
||||
Use stable real URLs for public APIs and clear suggested URLs for self-hosted services. Homarr collects the installer's server URL, network scope, and credentials as source setup; credentials remain outside the manifest.
|
||||
|
||||
Paths use \`{option:name}\` and \`{param:name}\`. Query/body data uses \`{ "$option": "name" }\` and \`{ "$param": "name" }\`. Load queries cannot use params. Runtime parameter names and primitive values are inferred from references.
|
||||
|
||||
Every option has \`label\`, \`control\`, and \`default\`. Optional fields are \`description\`, \`choices\`, \`choicesFrom\`, \`min\`, \`max\`, \`step\`, \`advanced\`, and \`group\`.
|
||||
`,
|
||||
"references/runtime.md": `# Runtime
|
||||
|
||||
Templates read \`data.requestId\`, \`status.requestId\`, \`options.name\`, and temporary \`inputs.name\`. Status contains \`loading\`, \`ok\`, \`status\`, \`statusText\`, and \`error\`.
|
||||
|
||||
\`bind="search"\` creates an in-memory input. It is never persisted. Supply invocation values only through \`params\`, for example:
|
||||
|
||||
\`\`\`jsx
|
||||
<TextInput bind="search" label="Search" />
|
||||
<SubFetch requestId="search" params={{ query: inputs.search }}>
|
||||
{(items) => <Stack>{(items ?? []).map(item => <Text key={item.id}>{item.name}</Text>)}</Stack>}
|
||||
</SubFetch>
|
||||
\`\`\`
|
||||
|
||||
\`SubFetch\` with \`trigger="manual"\` renders its own load button. To make a card or image launch the request, pass that node through \`triggerContent\` and provide \`triggerAriaLabel\`; Homarr supplies the click and keyboard behavior. Its optional second callback argument contains \`{ ok, status, statusText, loading: false }\`; loading and request failures are rendered by \`SubFetch\` before the callback runs. Do not author \`onClick\` or a fetch callback.
|
||||
|
||||
Use expression callbacks for supported collection methods and trusted slots. Do not use callback blocks, IIFEs, authored recursion, or raw events. Bounded regex literals work only with safe string matching/replacement operations.
|
||||
`,
|
||||
"references/security.md": `# Security
|
||||
|
||||
All requests use Homarr's protected server executor. Source origin, network scope, DNS, redirects, SSRF, rate limits, permissions, size limits, timeouts, and encrypted credential injection remain enforced.
|
||||
|
||||
The JSX interpreter blocks imports, hooks, refs, raw event callbacks, browser requests, eval, arbitrary functions, prototype access, unsafe URLs, global CSS escape, arbitrary portals, bigint, statement blocks, IIFEs, and recursion. Regex literals must be bounded and reject backreferences, lookbehind, nested quantifiers, excessive length, and unsupported flags.
|
||||
|
||||
Credentials are stored separately and never exported or returned to an agent. A published self-hosted source URL is only a suggestion: installers must confirm or replace private and loopback URLs for their own Homarr deployment. Source origins cannot be controlled through widget options.
|
||||
`,
|
||||
} as const;
|
||||
|
||||
export const CUSTOM_WIDGET_SKILL_MD = `---
|
||||
name: homarr-custom-widget
|
||||
@@ -39,11 +94,19 @@ Preview-scoped configuration expires with the preview. To persist user-entered v
|
||||
When this skill is installed from the repository, optional offline details are in \`references/schema.md\`, \`references/runtime.md\`, and \`references/security.md\`. MCP-only clients should use the live Homarr resources instead.
|
||||
`;
|
||||
|
||||
const CUSTOM_WIDGET_SKILL_BUNDLE_MD = [
|
||||
CUSTOM_WIDGET_SKILL_MD.trimEnd(),
|
||||
...Object.entries(CUSTOM_WIDGET_SKILL_REFERENCES).map(
|
||||
([file, content]) => `\n\n---\n\n# Bundled file: ${file}\n\n${content.trimEnd()}`,
|
||||
),
|
||||
].join("");
|
||||
|
||||
export function getCustomWidgetSkill() {
|
||||
return {
|
||||
name: "homarr-custom-widget",
|
||||
version: CUSTOM_WIDGET_SKILL_VERSION,
|
||||
skillMd: CUSTOM_WIDGET_SKILL_MD,
|
||||
references: CUSTOM_WIDGET_SKILL_REFERENCES,
|
||||
skillsShUrl: CUSTOM_WIDGET_SKILLS_SH_URL,
|
||||
sourceUrl: CUSTOM_WIDGET_SKILL_SOURCE_URL,
|
||||
installCommand: CUSTOM_WIDGET_SKILL_INSTALL_COMMAND,
|
||||
@@ -51,11 +114,28 @@ export function getCustomWidgetSkill() {
|
||||
}
|
||||
|
||||
export function getCustomWidgetSkillContent() {
|
||||
return CUSTOM_WIDGET_SKILL_MD;
|
||||
return CUSTOM_WIDGET_SKILL_BUNDLE_MD;
|
||||
}
|
||||
|
||||
export function getCustomWidgetComponentCatalog() {
|
||||
return customJsxAuthoringCatalog;
|
||||
return {
|
||||
schemaVersion: customJsxAuthoringCatalog.schemaVersion,
|
||||
mantineVersion: customJsxAuthoringCatalog.mantineVersion,
|
||||
customWidgetVersion: customJsxAuthoringCatalog.customWidgetVersion,
|
||||
components: customJsxAuthoringCatalog.components.map(({ name, category, safety }) => ({ name, category, safety })),
|
||||
sharedProps: {
|
||||
count: customJsxAuthoringCatalog.globalProps.length,
|
||||
names: customJsxAuthoringCatalog.globalProps.map(({ name }) => name),
|
||||
fetchTool: "customWidget_getSharedProps" as const,
|
||||
maxPerRequest: 64,
|
||||
},
|
||||
blockedCapabilities: customJsxAuthoringCatalog.blockedCapabilities,
|
||||
examples: getCustomWidgetExampleCatalog(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getCustomWidgetExampleCatalog() {
|
||||
return customJsxExamples.map(({ id, title, description }) => ({ id, title, description }));
|
||||
}
|
||||
|
||||
export function getCustomWidgetComponent(name: string) {
|
||||
@@ -71,17 +151,37 @@ export function getCustomWidgetComponent(name: string) {
|
||||
category: component.category,
|
||||
safety: component.safety,
|
||||
description: component.description,
|
||||
props: getCustomJsxComponentProps(canonicalName),
|
||||
props: component.props.map((prop) => resolveCustomJsxPropDescriptor(prop)),
|
||||
blockedProps: component.blockedProps,
|
||||
bind: component.bind,
|
||||
subcomponents: component.subcomponents,
|
||||
accessibilityRequirements: component.accessibilityRequirements,
|
||||
documentationUrl: component.documentationUrl,
|
||||
sharedProps:
|
||||
component.safety === "denied"
|
||||
? undefined
|
||||
: {
|
||||
catalogField: "sharedProps.names" as const,
|
||||
fetchTool: "customWidget_getSharedProps" as const,
|
||||
maxPerRequest: 64,
|
||||
appliesExceptBlockedProps: true as const,
|
||||
},
|
||||
knownValues: component.name === "TablerIcon" ? { name: customJsxTablerIconNames } : undefined,
|
||||
deniedReason: component.deniedReason,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCustomWidgetSharedProps(names: readonly string[]) {
|
||||
const props: ReturnType<typeof resolveCustomJsxPropDescriptor>[] = [];
|
||||
const notFound: string[] = [];
|
||||
for (const name of new Set(names)) {
|
||||
const prop = customJsxCatalogGlobalPropByName.get(name);
|
||||
if (prop) props.push(resolveCustomJsxPropDescriptor(prop));
|
||||
else notFound.push(name);
|
||||
}
|
||||
return { props, notFound };
|
||||
}
|
||||
|
||||
export function getCustomWidgetExample(name: string) {
|
||||
return customJsxExamples.find((example) => example.id === name) ?? null;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { getCustomWidgetHttpUrlIssue } from "./url-policy";
|
||||
export * from "./options-schema";
|
||||
export * from "./request-schema";
|
||||
export const CUSTOM_WIDGET_SCHEMA = "homarr-custom-widget-v2";
|
||||
const CUSTOM_WIDGET_TEMPLATE_MAX_LENGTH = 50_000;
|
||||
|
||||
export const customWidgetBindingIdentifierSchema = z
|
||||
.string()
|
||||
@@ -30,7 +31,7 @@ export const customWidgetBindingIdentifierSchema = z
|
||||
export const customJsxTemplateSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(50_000)
|
||||
.max(CUSTOM_WIDGET_TEMPLATE_MAX_LENGTH)
|
||||
.superRefine((template, ctx) => {
|
||||
for (const diagnostic of validateCustomJsxTemplate(template)) {
|
||||
if (diagnostic.severity === "error") {
|
||||
@@ -193,13 +194,84 @@ function validateTemplateRequests(template: string, requests: Record<string, Cus
|
||||
|
||||
export type HomarrCustomWidgetV2 = z.infer<typeof customWidgetDefinitionSchema>;
|
||||
export type HomarrCustomWidgetV2Input = z.input<typeof customWidgetDefinitionSchema>;
|
||||
export const customWidgetCreateSchema = customWidgetDefinitionSchema.safeExtend({
|
||||
|
||||
const customWidgetTemplateLinesSchema = z
|
||||
.array(z.string().max(10_000))
|
||||
.min(1)
|
||||
.max(2_000)
|
||||
.refine(
|
||||
(lines) => lines.reduce((length, line) => length + line.length, Math.max(0, lines.length - 1)) <= 50_000,
|
||||
`The complete JSX template must contain at most ${CUSTOM_WIDGET_TEMPLATE_MAX_LENGTH} characters`,
|
||||
)
|
||||
.describe(
|
||||
"The complete JSX template split into lines. Prefer this over template for generated multiline JSX. Do not send both fields.",
|
||||
);
|
||||
|
||||
export const customWidgetAuthoringDefinitionSchema = z
|
||||
.strictObject({
|
||||
...customWidgetDefinitionSchema.shape,
|
||||
template: customJsxTemplateSchema
|
||||
.optional()
|
||||
.describe("The complete JSX template as one string. For multiline JSX, prefer templateLines instead."),
|
||||
templateLines: customWidgetTemplateLinesSchema.optional(),
|
||||
})
|
||||
.superRefine((definition, ctx) => {
|
||||
if (definition.template === undefined && definition.templateLines === undefined) {
|
||||
ctx.addIssue({ code: "custom", path: ["template"], message: "Provide template or templateLines" });
|
||||
}
|
||||
if (definition.template !== undefined && definition.templateLines !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["templateLines"],
|
||||
message: "Provide template or templateLines, not both",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type CustomWidgetAuthoringDefinitionInput = z.input<typeof customWidgetAuthoringDefinitionSchema>;
|
||||
|
||||
export function normalizeCustomWidgetAuthoringDefinition(input: CustomWidgetAuthoringDefinitionInput) {
|
||||
const { templateLines, ...definition } = input;
|
||||
return customWidgetDefinitionSchema.parse({
|
||||
...definition,
|
||||
template: definition.template ?? templateLines?.join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
const customWidgetAuthoringUpdateSchema = z
|
||||
.strictObject({
|
||||
...customWidgetDefinitionSchema.shape,
|
||||
options: customWidgetOptionsSchema.removeDefault(),
|
||||
templateLines: customWidgetTemplateLinesSchema.optional(),
|
||||
})
|
||||
.partial()
|
||||
.superRefine((definition, ctx) => {
|
||||
if (definition.template !== undefined && definition.templateLines !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["templateLines"],
|
||||
message: "Provide template or templateLines, not both",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type CustomWidgetAuthoringUpdateInput = z.input<typeof customWidgetAuthoringUpdateSchema>;
|
||||
|
||||
export function normalizeCustomWidgetAuthoringUpdate(input: CustomWidgetAuthoringUpdateInput) {
|
||||
const { templateLines, ...definition } = input;
|
||||
return {
|
||||
...definition,
|
||||
...(templateLines === undefined ? {} : { template: templateLines.join("\n") }),
|
||||
};
|
||||
}
|
||||
|
||||
export const customWidgetCreateSchema = customWidgetAuthoringDefinitionSchema.safeExtend({
|
||||
secrets: customWidgetSecretsInputSchema.default([]),
|
||||
});
|
||||
export const customWidgetUpdateSchema = z
|
||||
.strictObject(customWidgetDefinitionSchema.shape)
|
||||
.partial()
|
||||
.extend({ id: z.string().min(1), secrets: customWidgetSecretsInputSchema.optional() });
|
||||
export const customWidgetUpdateSchema = customWidgetAuthoringUpdateSchema.safeExtend({
|
||||
id: z.string().min(1),
|
||||
secrets: customWidgetSecretsInputSchema.optional(),
|
||||
});
|
||||
export const customWidgetImportSchema = customWidgetDefinitionSchema;
|
||||
export type CustomWidgetCreateInput = z.infer<typeof customWidgetCreateSchema>;
|
||||
export type CustomWidgetUpdateInput = z.infer<typeof customWidgetUpdateSchema>;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildCustomWidgetAiPrompt, CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION } from "../core/ai-prompt";
|
||||
import {
|
||||
buildCustomWidgetAiPrompt,
|
||||
CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION,
|
||||
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
|
||||
} from "../core/ai-prompt";
|
||||
|
||||
describe("AI prompt", () => {
|
||||
it("is self-contained, request-first, and compact", () => {
|
||||
@@ -36,6 +40,18 @@ describe("AI prompt", () => {
|
||||
expect(prompt.match(/Recommended components:/gu)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("makes the connected MCP workflow load the complete skill and test every preview query", () => {
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("First call customWidget_getSkill");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_validate");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_previewCreate");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_previewQuery once for every query");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_getSharedProps");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Prefer templateLines");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_previewQuery")).toBeLessThan(
|
||||
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_create"),
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts secrets and preserves the final instruction when optional context is large", () => {
|
||||
const prompt = buildCustomWidgetAiPrompt(
|
||||
undefined,
|
||||
|
||||
@@ -5,28 +5,73 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getCustomWidgetComponent,
|
||||
getCustomWidgetComponentCatalog,
|
||||
getCustomWidgetSharedProps,
|
||||
getCustomWidgetSkill,
|
||||
getCustomWidgetSkillContent,
|
||||
} from "../core/authoring-resources";
|
||||
|
||||
const skillSourceDirectory = path.resolve(import.meta.dirname, "../../../../.agents/skills/homarr-custom-widget");
|
||||
|
||||
describe("authoring resources", () => {
|
||||
it("returns the concise skills.sh discovery shape", () => {
|
||||
it("returns the complete portable skill and keeps every bundled source file in sync", () => {
|
||||
const skill = getCustomWidgetSkill();
|
||||
expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.1.0" });
|
||||
expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.2.0" });
|
||||
expect(skill.skillsShUrl).toContain("skills.sh/homarr-labs/homarr");
|
||||
expect(skill.sourceUrl).toContain("/tree/HEAD/.agents/skills/homarr-custom-widget");
|
||||
expect(skill.installCommand).toContain("--skill homarr-custom-widget");
|
||||
expect(skill).not.toHaveProperty("files");
|
||||
expect(skill).not.toHaveProperty("componentCatalog");
|
||||
expect(getCustomWidgetSkillContent()).toBe(skill.skillMd);
|
||||
expect(skill.skillMd.trim()).toBe(
|
||||
readFileSync(path.join(process.cwd(), ".agents/skills/homarr-custom-widget/SKILL.md"), "utf8").trim(),
|
||||
);
|
||||
expect(skill).not.toHaveProperty("entrypointMd");
|
||||
expect(skill).not.toHaveProperty("bundledMarkdown");
|
||||
expect(skill.skillMd.trim()).toBe(readFileSync(path.join(skillSourceDirectory, "SKILL.md"), "utf8").trim());
|
||||
const bundledContent = getCustomWidgetSkillContent();
|
||||
for (const [file, content] of Object.entries(skill.references)) {
|
||||
expect(content.trim()).toBe(readFileSync(path.join(skillSourceDirectory, file), "utf8").trim());
|
||||
expect(bundledContent).toContain(`# Bundled file: ${file}`);
|
||||
expect(bundledContent).toContain(content.trim());
|
||||
}
|
||||
expect(bundledContent).toContain(skill.skillMd.trim());
|
||||
expect(Buffer.byteLength(JSON.stringify(skill), "utf8")).toBeLessThan(12_000);
|
||||
});
|
||||
|
||||
it("keeps the full catalog retrieval-based", () => {
|
||||
expect(getCustomWidgetComponentCatalog().components.length).toBeGreaterThan(100);
|
||||
expect(getCustomWidgetComponent("Stack")?.name).toBe("Stack");
|
||||
it("keeps catalog discovery compact and component details lazy", () => {
|
||||
const catalog = getCustomWidgetComponentCatalog();
|
||||
expect(catalog.components.length).toBeGreaterThan(100);
|
||||
expect(catalog.components).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: "Stack", category: expect.any(String) })]),
|
||||
);
|
||||
expect(catalog.examples).toEqual(expect.arrayContaining([expect.objectContaining({ id: "service-dashboard" })]));
|
||||
expect(catalog.sharedProps).toMatchObject({
|
||||
count: expect.any(Number),
|
||||
fetchTool: "customWidget_getSharedProps",
|
||||
maxPerRequest: 64,
|
||||
});
|
||||
expect(catalog.sharedProps.names).toContain("p");
|
||||
expect(Buffer.byteLength(JSON.stringify(catalog), "utf8")).toBeLessThan(45_000);
|
||||
|
||||
const stack = getCustomWidgetComponent("Stack");
|
||||
expect(stack).toMatchObject({
|
||||
name: "Stack",
|
||||
sharedProps: { fetchTool: "customWidget_getSharedProps" },
|
||||
});
|
||||
expect(stack?.props.some(({ source }) => source === "global")).toBe(false);
|
||||
expect(Buffer.byteLength(JSON.stringify(stack), "utf8")).toBeLessThan(20_000);
|
||||
expect(getCustomWidgetComponent("Icon")?.knownValues?.name.length).toBeGreaterThan(100);
|
||||
|
||||
const largestComponentPayload = Math.max(
|
||||
...catalog.components.map(({ name }) =>
|
||||
Buffer.byteLength(JSON.stringify(getCustomWidgetComponent(name)), "utf8"),
|
||||
),
|
||||
);
|
||||
expect(largestComponentPayload).toBeLessThan(20_000);
|
||||
});
|
||||
|
||||
it("returns only requested shared prop documentation in one bounded payload", () => {
|
||||
const result = getCustomWidgetSharedProps(["p", "m", "not-a-shared-prop", "p"]);
|
||||
expect(result.props.map(({ name }) => name)).toEqual(["p", "m"]);
|
||||
expect(result.notFound).toEqual(["not-a-shared-prop"]);
|
||||
expect(Buffer.byteLength(JSON.stringify(result), "utf8")).toBeLessThan(4_000);
|
||||
|
||||
const boundedBatch = getCustomWidgetSharedProps(getCustomWidgetComponentCatalog().sharedProps.names.slice(0, 64));
|
||||
expect(Buffer.byteLength(JSON.stringify(boundedBatch), "utf8")).toBeLessThan(15_000);
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,16 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CUSTOM_WIDGET_STARTER,
|
||||
customWidgetAuthoringDefinitionSchema,
|
||||
customWidgetDefinitionSchema,
|
||||
customWidgetUpdateSchema,
|
||||
customJsxRequestSchema,
|
||||
getCustomWidgetDefaultOptions,
|
||||
getCustomWidgetSecretRequirements,
|
||||
getCustomWidgetSourceSetups,
|
||||
hasSameCustomWidgetSourceAuthentication,
|
||||
applyCustomWidgetSourceSetup,
|
||||
normalizeCustomWidgetAuthoringDefinition,
|
||||
} from "../core";
|
||||
|
||||
describe("lean Custom Widget schema", () => {
|
||||
@@ -61,6 +64,56 @@ describe("lean Custom Widget schema", () => {
|
||||
expect(customWidgetDefinitionSchema.parse(withoutOptions).options).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts and normalizes realistic multiline JSX without JSON string escaping", () => {
|
||||
const { template: _template, ...definition } = CUSTOM_WIDGET_STARTER;
|
||||
const templateLines = [
|
||||
'<Stack gap="sm">',
|
||||
' <Group justify="space-between">',
|
||||
" <Text fw={700}>Upcoming fixtures</Text>",
|
||||
' <Badge color="blue">{(data.fixtures ?? []).length} matches</Badge>',
|
||||
" </Group>",
|
||||
" {(data.fixtures ?? []).map(fixture => (",
|
||||
' <Paper key={fixture.id} p="sm" withBorder>',
|
||||
" <Text>{fixture.home_team.name} vs {fixture.away_team.name}</Text>",
|
||||
" </Paper>",
|
||||
" ))}",
|
||||
"</Stack>",
|
||||
];
|
||||
const candidate = customWidgetAuthoringDefinitionSchema.parse({
|
||||
...definition,
|
||||
requests: { fixtures: { path: "/fixtures" } },
|
||||
templateLines,
|
||||
});
|
||||
|
||||
expect(normalizeCustomWidgetAuthoringDefinition(candidate).template).toBe(templateLines.join("\n"));
|
||||
});
|
||||
|
||||
it("requires exactly one authoring template representation", () => {
|
||||
expect(
|
||||
customWidgetAuthoringDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, templateLines: ["<Text />"] })
|
||||
.success,
|
||||
).toBe(false);
|
||||
const { template: _template, ...withoutTemplate } = CUSTOM_WIDGET_STARTER;
|
||||
expect(customWidgetAuthoringDefinitionSchema.safeParse(withoutTemplate).success).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps omitted update fields absent when using templateLines", () => {
|
||||
expect(customWidgetUpdateSchema.parse({ id: "widget", templateLines: ["<Text />"] })).toEqual({
|
||||
id: "widget",
|
||||
templateLines: ["<Text />"],
|
||||
});
|
||||
});
|
||||
|
||||
it("applies the template size limit before joining templateLines", () => {
|
||||
const { template: _template, ...definition } = CUSTOM_WIDGET_STARTER;
|
||||
expect(
|
||||
customWidgetAuthoringDefinitionSchema.safeParse({
|
||||
...definition,
|
||||
templateLines: Array.from({ length: 6 }, () => "x".repeat(10_000)),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects source IDs that collide case-insensitively", () => {
|
||||
const result = customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
|
||||
Reference in new issue
Block a user