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
@@ -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