fix(custom-widgets): address v2 merge review

This commit is contained in:
ajnart committed 2026-07-22 23:36:25 +02:00
1 parent 6aceb2fd1f
commit 48ce83806d
122 files changed
+8534 -1447

No files matched your search

@@ -1,6 +1,7 @@
import { z } from "zod/v4";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
import { CUSTOM_JSX_BINDING_IDENTIFIER_PATTERN } from "../jsx/policy";
import { validateCredentialFreeExport, validatePrototypeKeys } from "./definition-security";
import { customWidgetOptionsSchema } from "./options-schema";
import {
@@ -20,7 +21,10 @@ export const customWidgetBindingIdentifierSchema = z
.string()
.min(1)
.max(64)
.regex(/^[A-Za-z][A-Za-z0-9_-]*$/u, "Binding names must start with a letter and contain letters, numbers, - or _");
.regex(
CUSTOM_JSX_BINDING_IDENTIFIER_PATTERN,
"Binding names must start with a letter and contain letters, numbers, - or _",
);
export const customJsxTemplateSchema = z
.string()
@@ -77,8 +81,8 @@ export const customWidgetDefinitionSchema = z
})
.superRefine((definition, ctx) => {
validateRequests(definition, ctx);
validateCaseInsensitiveSourceIds(definition.sources, ctx);
validateTemplateRequests(definition.template, definition.requests, ctx);
validateTemplateBindings(definition.template, ctx);
validateCredentialFreeExport(definition, ctx);
validatePrototypeKeys(definition, ctx);
});
@@ -144,6 +148,23 @@ function validateRequests(definition: Definition, ctx: z.RefinementCtx) {
}
}
function validateCaseInsensitiveSourceIds(sources: Definition["sources"], ctx: z.RefinementCtx) {
const seen = new Map<string, string>();
for (const sourceId of Object.keys(sources)) {
const normalized = sourceId.toLowerCase();
const existing = seen.get(normalized);
if (existing) {
ctx.addIssue({
code: "custom",
path: ["sources", sourceId],
message: `Source ID '${sourceId}' conflicts case-insensitively with '${existing}'`,
});
} else {
seen.set(normalized, sourceId);
}
}
}
function validateTemplateRequests(template: string, requests: Record<string, CustomJsxRequest>, ctx: z.RefinementCtx) {
for (const match of template.matchAll(/<(SubFetch|ActionButton|ToggleSwitch)\b([^>]*)>/gu)) {
const component = match[1] as "SubFetch" | "ActionButton" | "ToggleSwitch";
@@ -169,15 +190,6 @@ function validateTemplateRequests(template: string, requests: Record<string, Cus
}
}
function validateTemplateBindings(template: string, ctx: z.RefinementCtx) {
for (const match of template.matchAll(/<[A-Z][A-Za-z0-9.]*(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/gu)) {
const inputName = match[1] ?? match[2];
if (!inputName) ctx.addIssue({ code: "custom", path: ["template"], message: "bind must use a literal input name" });
else if (!customWidgetBindingIdentifierSchema.safeParse(inputName).success)
ctx.addIssue({ code: "custom", path: ["template"], message: `Invalid bind input name '${inputName}'` });
}
}
export type HomarrCustomWidgetV2 = z.infer<typeof customWidgetDefinitionSchema>;
export type HomarrCustomWidgetV2Input = z.input<typeof customWidgetDefinitionSchema>;
export const customWidgetCreateSchema = customWidgetDefinitionSchema.safeExtend({
@@ -7,7 +7,7 @@ import {
import type { AstNode } from "./analyzer-ast";
import { containsEscapingCallback, nodeOf, nodesOf } from "./analyzer-ast";
import { closestCustomJsxComponentName, customJsxTagName, isSafeLiteralCustomJsxUrl } from "./analyzer-language";
import { CUSTOM_JSX_URL_PROPS, isBlockedCustomJsxProp } from "./policy";
import { CUSTOM_JSX_BINDING_IDENTIFIER_PATTERN, CUSTOM_JSX_URL_PROPS, isBlockedCustomJsxProp } from "./policy";
import { getInvalidCustomJsxPropValueReason } from "./runtime-component-policy";
interface AnalyzerJsxContext {
@@ -75,6 +75,7 @@ export function analyzeCustomJsxElement(
} else if (resolvedName && attributeName === "bind" && !customJsxBindableComponentNames.has(resolvedName)) {
context.add(attribute, `BINDING_UNAVAILABLE: '${name}' does not have a declarative binding adapter`, "warning");
}
if (attributeName === "bind") analyzeBindAttribute(attribute, context);
analyzeAttributeValue(resolvedName, attributeName, attribute, depth, bindings, context);
}
@@ -88,6 +89,15 @@ export function analyzeCustomJsxElement(
});
}
function analyzeBindAttribute(attribute: AstNode, context: AnalyzerJsxContext): void {
const value = nodeOf(attribute.value);
if (value?.type !== "Literal" || typeof value.value !== "string") {
context.add(attribute, "bind must use a literal input name");
} else if (!CUSTOM_JSX_BINDING_IDENTIFIER_PATTERN.test(value.value)) {
context.add(attribute, `Invalid bind input name '${value.value}'`);
}
}
function analyzeAttributeValue(
componentName: string | null,
attributeName: string,
+5 -12
View File
@@ -23,6 +23,7 @@ import {
CUSTOM_JSX_CALLBACK_METHODS,
CUSTOM_JSX_SAFE_VALUE_METHODS,
} from "./safe-language-policy";
import { isSafeRegexLiteral } from "./regex-policy";
export type { CustomJsxTemplateDiagnostic } from "./analyzer-diagnostics";
@@ -153,7 +154,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
case "Literal":
if (typeof node.value === "bigint") add(node, "BIGINT_NOT_SUPPORTED: Use Number for widget values");
if (node.regex !== undefined && !isSafeRegexLiteral(node.regex))
add(node, "UNSAFE_REGEX: Use a bounded regex without lookbehind, backreferences, or nested quantifiers");
add(node, "UNSAFE_REGEX: Use a conservative regex without repeated alternatives or variable quantifiers");
return;
case "Identifier": {
const name = String(node.name ?? "");
@@ -232,7 +233,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
}
case "CallExpression": {
const callee = nodeOf(node.callee);
const arguments_ = nodesOf(node.arguments);
const callArguments = nodesOf(node.arguments);
if (node.optional) {
add(node, "CALL_TARGET_NOT_ALLOWED: Optional calls are not supported");
}
@@ -251,7 +252,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
add(callee, "CALL_TARGET_NOT_ALLOWED: Only documented safe helpers and value methods can be called");
}
if (method && CUSTOM_JSX_CALLBACK_METHODS.has(method)) {
const callback = arguments_[0];
const callback = callArguments[0];
if (!callback) {
add(node, `CALLBACK_VALUE_NOT_ALLOWED: '${method}' requires a callback`);
} else if (["reduce", "sort"].includes(method) && callback.type !== "ArrowFunctionExpression") {
@@ -260,7 +261,7 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
add(callback, `CALLBACK_VALUE_NOT_ALLOWED: '${method}' requires an inline arrow or safe helper callback`);
}
}
arguments_.forEach((argument, argumentIndex) => {
callArguments.forEach((argument, argumentIndex) => {
if (argument.type === "SpreadElement") add(argument, "Spread call arguments are not supported");
else if (
argument.type === "ArrowFunctionExpression" &&
@@ -318,11 +319,3 @@ export function validateCustomJsxTemplate(template: string): CustomJsxTemplateDi
return diagnostics;
}
function isSafeRegexLiteral(regex: unknown): boolean {
if (!regex || typeof regex !== "object") return false;
const { pattern, flags } = regex as { pattern?: unknown; flags?: unknown };
if (typeof pattern !== "string" || pattern.length > 128 || typeof flags !== "string" || /[^gimsu]/u.test(flags))
return false;
return !/\\[1-9]|\(\?<[=!]|(?:\+|\*|\{\d+(?:,\d*)?\})\)?(?:\+|\*|\{)/u.test(pattern);
}
+78 -7
View File
@@ -1,14 +1,85 @@
import { createSafeCallable } from "./safe-bindings";
import { SafeJsxBudgetError, SafeJsxError } from "./interpreter-foundation";
export const CUSTOM_JSX_DATA_LIMITS = Object.freeze({
depth: 32,
nodes: 50_000,
stringLength: 200_000,
});
interface SanitizeTask {
value: unknown;
depth: number;
assign(value: unknown): void;
}
function sanitizeData(value: unknown): unknown {
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(sanitizeData);
const safe: Record<string, unknown> = Object.create(null);
for (const [key, child] of Object.entries(value)) {
if (["constructor", "__proto__", "prototype"].includes(key)) continue;
safe[key] = sanitizeData(child);
let result: unknown;
let nodes = 0;
const seen = new WeakSet<object>();
const tasks: SanitizeTask[] = [{ value, depth: 0, assign: (sanitized) => (result = sanitized) }];
while (tasks.length > 0) {
const task = tasks.pop();
if (!task) break;
if (++nodes > CUSTOM_JSX_DATA_LIMITS.nodes) {
throw new SafeJsxBudgetError(`Response data exceeded the node limit (${CUSTOM_JSX_DATA_LIMITS.nodes})`);
}
if (task.depth > CUSTOM_JSX_DATA_LIMITS.depth) {
throw new SafeJsxBudgetError(`Response data exceeded the depth limit (${CUSTOM_JSX_DATA_LIMITS.depth})`);
}
if (typeof task.value === "string" && task.value.length > CUSTOM_JSX_DATA_LIMITS.stringLength) {
throw new SafeJsxBudgetError(
`Response data exceeded the string length limit (${CUSTOM_JSX_DATA_LIMITS.stringLength})`,
);
}
if (task.value === null || typeof task.value !== "object") {
task.assign(task.value);
continue;
}
if (seen.has(task.value)) throw new SafeJsxError("Response data must not contain circular or repeated objects");
seen.add(task.value);
if (Array.isArray(task.value)) {
if (task.value.length > CUSTOM_JSX_DATA_LIMITS.nodes - nodes) {
throw new SafeJsxBudgetError(`Response data exceeded the node limit (${CUSTOM_JSX_DATA_LIMITS.nodes})`);
}
const safe = Array.from<unknown>({ length: task.value.length });
task.assign(safe);
for (let index = task.value.length - 1; index >= 0; index -= 1) {
if (!Object.hasOwn(task.value, index)) continue;
tasks.push({
value: task.value[index],
depth: task.depth + 1,
assign: (sanitized) => (safe[index] = sanitized),
});
}
continue;
}
const entries = Object.entries(task.value);
if (entries.length > CUSTOM_JSX_DATA_LIMITS.nodes - nodes) {
throw new SafeJsxBudgetError(`Response data exceeded the node limit (${CUSTOM_JSX_DATA_LIMITS.nodes})`);
}
const safe: Record<string, unknown> = Object.create(null);
task.assign(safe);
for (let index = entries.length - 1; index >= 0; index -= 1) {
const [key, child] = entries[index] as [string, unknown];
if (["constructor", "__proto__", "prototype"].includes(key)) continue;
if (key.length > CUSTOM_JSX_DATA_LIMITS.stringLength) {
throw new SafeJsxBudgetError(
`Response data exceeded the string length limit (${CUSTOM_JSX_DATA_LIMITS.stringLength})`,
);
}
tasks.push({
value: child,
depth: task.depth + 1,
assign: (sanitized) => (safe[key] = sanitized),
});
}
}
return safe;
return result;
}
const safeMath = Object.freeze(
@@ -11,6 +11,7 @@ import type { AstNode, EvaluationBudgets, InterpreterCallback, SafeJsxBudgets }
import { normalizeCustomJsxText, parseCustomJsxTemplate } from "./interpreter-parser";
import { normalizedProperty, ownProperty } from "./safe-properties";
import { CUSTOM_JSX_CALLBACK_METHODS } from "./safe-language-policy";
import { isSafeRegexLiteral } from "./regex-policy";
export { SafeJsxBudgetError, SafeJsxError } from "./interpreter-foundation";
export type { SafeJsxBudgets } from "./interpreter-foundation";
@@ -51,16 +52,10 @@ class Interpreter {
case "Literal":
if (node.regex !== undefined) {
const regex = node.regex as { pattern?: unknown; flags?: unknown };
if (
typeof regex.pattern !== "string" ||
regex.pattern.length > 128 ||
typeof regex.flags !== "string" ||
/[^gimsu]/u.test(regex.flags) ||
/\\[1-9]|\(\?<[=!]|(?:\+|\*|\{\d+(?:,\d*)?\})\)?(?:\+|\*|\{)/u.test(regex.pattern)
) {
if (!isSafeRegexLiteral(regex)) {
throw new SafeJsxError("UNSAFE_REGEX: Invalid regular expression literal");
}
return new RegExp(regex.pattern, regex.flags);
return new RegExp(regex.pattern as string, regex.flags as string);
}
if (typeof node.value === "bigint")
throw new SafeJsxError("BIGINT_NOT_SUPPORTED: Use Number for widget values");
@@ -43,7 +43,12 @@ export function emitJsxElement(
for (const attribute of asNodeArray(opening.attributes, "JSX attributes")) {
if (attribute.type === "JSXSpreadAttribute") {
const spread = context.evaluate(asNode(attribute.argument, "spread attribute"), environment, depth + 1);
if (spread && typeof spread === "object" && !Array.isArray(spread)) Object.assign(rawProps, spread);
if (spread && typeof spread === "object" && !Array.isArray(spread)) {
if (Object.hasOwn(spread, "bind")) {
throw new SafeJsxError("BIND_SPREAD_NOT_ALLOWED: The bind prop must be an explicit literal JSX attribute");
}
Object.assign(rawProps, spread);
}
continue;
}
if (attribute.type !== "JSXAttribute") throw new SafeJsxError(`Unsupported JSX attribute: ${attribute.type}`);
@@ -7,6 +7,8 @@ export const CUSTOM_JSX_LIMITS = Object.freeze({
stringLength: 200_000,
});
export const CUSTOM_JSX_BINDING_IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/u;
export const CUSTOM_JSX_BLOCKED_PROPERTIES: ReadonlySet<string> = new Set([
"__proto__",
"apply",
@@ -0,0 +1,144 @@
interface RegexGroupState {
atomCount: number;
containsAlternation: boolean;
containsQuantifier: boolean;
}
interface RegexQuantifier {
nextIndex: number;
variable: boolean;
}
const MAX_REGEX_PATTERN_LENGTH = 128;
const MAX_EXACT_REPETITION = 1_000;
/**
* JavaScript's regular-expression engine can backtrack exponentially. Keep authored
* expressions in a deliberately small, linear-time subset: no lookarounds or
* backreferences, no repeated alternatives/groups, and at most one quantifier that
* can consume a variable number of characters in the whole expression.
*/
export function isSafeRegexLiteral(regex: unknown): boolean {
if (!regex || typeof regex !== "object") return false;
const { pattern, flags } = regex as { pattern?: unknown; flags?: unknown };
if (
typeof pattern !== "string" ||
pattern.length > MAX_REGEX_PATTERN_LENGTH ||
typeof flags !== "string" ||
/[^gimsu]/u.test(flags)
) {
return false;
}
return isSafeRegexPattern(pattern);
}
function isSafeRegexPattern(pattern: string): boolean {
const groups: RegexGroupState[] = [createGroupState()];
let variableQuantifiers = 0;
let index = 0;
const consumeAtom = (nested?: RegexGroupState): boolean => {
const parent = groups.at(-1);
if (!parent) return false;
parent.atomCount += 1;
if (nested) {
parent.containsAlternation ||= nested.containsAlternation;
parent.containsQuantifier ||= nested.containsQuantifier;
}
const quantifier = readQuantifier(pattern, index);
if (!quantifier) return true;
if (quantifier.nextIndex > pattern.length) return false;
if (nested && (nested.atomCount === 0 || nested.containsAlternation || nested.containsQuantifier)) return false;
parent.containsQuantifier = true;
if (quantifier.variable && ++variableQuantifiers > 1) return false;
index = quantifier.nextIndex;
return true;
};
while (index < pattern.length) {
const character = pattern[index];
if (character === "\\") {
const escaped = pattern[index + 1];
if (escaped === undefined || /[1-9k]/u.test(escaped)) return false;
index += 2;
if (!consumeAtom()) return false;
continue;
}
if (character === "[") {
index += 1;
let closed = false;
while (index < pattern.length) {
if (pattern[index] === "\\") index += 2;
else {
const classCharacter = pattern[index++];
if (classCharacter === "]") {
closed = true;
break;
}
}
}
if (!closed || !consumeAtom()) return false;
continue;
}
if (character === "(") {
if (pattern[index + 1] === "?") return false;
groups.push(createGroupState());
index += 1;
continue;
}
if (character === ")") {
if (groups.length === 1) return false;
const nested = groups.pop();
index += 1;
if (!nested || !consumeAtom(nested)) return false;
continue;
}
if (character === "|") {
const current = groups.at(-1);
if (!current) return false;
current.containsAlternation = true;
index += 1;
continue;
}
if (character === "^" || character === "$") {
index += 1;
continue;
}
if (character === "*" || character === "+" || character === "?" || character === "{") {
// A valid quantifier is consumed with its atom. Reaching one here means it is
// malformed or is an unsupported literal brace construct.
return false;
}
index += 1;
if (!consumeAtom()) return false;
}
return groups.length === 1;
}
function createGroupState(): RegexGroupState {
return { atomCount: 0, containsAlternation: false, containsQuantifier: false };
}
function readQuantifier(pattern: string, index: number): RegexQuantifier | null {
const character = pattern[index];
if (character === "*" || character === "+" || character === "?") {
const nextIndex = pattern[index + 1] === "?" ? index + 2 : index + 1;
return { nextIndex, variable: true };
}
if (character !== "{") return null;
const match = /^\{(\d+)(?:,(\d*))?\}/u.exec(pattern.slice(index));
if (!match) return null;
const minimum = Number(match[1]);
const maximum = match[2] === undefined || match[2] === "" ? undefined : Number(match[2]);
if (
!Number.isSafeInteger(minimum) ||
minimum > MAX_EXACT_REPETITION ||
(maximum !== undefined && (!Number.isSafeInteger(maximum) || maximum > MAX_EXACT_REPETITION))
) {
return { nextIndex: pattern.length + 1, variable: true };
}
let nextIndex = index + match[0].length;
if (pattern[nextIndex] === "?") nextIndex += 1;
return { nextIndex, variable: match[2] !== undefined && maximum !== minimum };
}
@@ -162,19 +162,24 @@ function CustomJsxRendererSession({
},
[inputTypes],
);
const bindings = useMemo(
() => ({ ...createBindings(data), status, options, inputs }),
[createBindings, data, inputs, options, status],
);
const boundaryKey = useMemo(() => createBoundaryKey(template, bindings), [bindings, template]);
const rendered = useMemo(() => {
try {
return { ...renderSafeJsx({ template, components, bindings }), error: null };
const bindings = { ...createBindings(data), status, options, inputs };
return {
...renderSafeJsx({ template, components, bindings }),
boundaryKey: createBoundaryKey(template, bindings),
error: null,
};
} catch (error) {
return { node: null, warnings: [], error: error instanceof Error ? error : new Error(String(error)) };
return {
node: null,
warnings: [],
boundaryKey: `${template.length}:error`,
error: error instanceof Error ? error : new Error(String(error)),
};
}
}, [bindings, components, template]);
useEffect(() => setParseErrors([]), [bindings, template]);
}, [components, createBindings, data, inputs, options, status, template]);
useEffect(() => setParseErrors([]), [rendered.boundaryKey, template]);
const handleError = useCallback(
(error: Error) =>
setParseErrors((current) => {
@@ -201,7 +206,7 @@ function CustomJsxRendererSession({
registerInput={registerInput}
setInputValue={setInputValue}
>
<RendererErrorBoundary resetKey={boundaryKey} onError={handleError}>
<RendererErrorBoundary resetKey={rendered.boundaryKey} onError={handleError}>
{rendered.node}
</RendererErrorBoundary>
</CustomJsxInputsProvider>
@@ -34,6 +34,11 @@ export interface SubFetchProps {
trigger?: "auto" | "manual";
}
// Browser timers use signed 32-bit millisecond delays. Whole-second refresh
// intervals are capped at the largest value that remains below that boundary.
export const MAX_REFRESH_INTERVAL_SECONDS = 2_147_483;
export const MAX_REFRESH_INTERVAL_MS = MAX_REFRESH_INTERVAL_SECONDS * 1_000;
export function SubFetch(props: SubFetchProps) {
const { itemId, previewSessionId, queriesDisabled, port, messages, setQueryState } = useCustomWidgetRuntime();
const [manualRun, setManualRun] = useState(false);
@@ -155,8 +160,9 @@ export function SubFetch(props: SubFetchProps) {
return <SubFetchDataContext.Provider value={result?.data}>{content}</SubFetchDataContext.Provider>;
}
function normalizeRefreshInterval(value: number | undefined): number | false {
return value && Number.isFinite(value) && value > 0 ? Math.max(1_000, value * 1_000) : false;
export function normalizeRefreshInterval(value: number | undefined): number | false {
if (!value || !Number.isFinite(value) || value <= 0) return false;
return Math.max(1_000, Math.min(value, MAX_REFRESH_INTERVAL_SECONDS) * 1_000);
}
function renderContent(props: SubFetchProps, data: unknown, metadata: SubFetchMetadata) {
@@ -1,6 +1,7 @@
export type CustomWidgetDomainErrorCode =
| "BAD_GATEWAY"
| "BAD_REQUEST"
| "CONFLICT"
| "FORBIDDEN"
| "INTERNAL_SERVER_ERROR"
| "NOT_FOUND"
+2 -1
View File
@@ -6,6 +6,7 @@ export {
classifyAddress,
executeCustomWidgetRequest,
invalidateCustomWidgetResponseCache,
MAX_REQUEST_DURATION_MS,
MAX_REQUEST_BODY_BYTES,
MAX_RESPONSE_BODY_BYTES,
MAX_RESPONSE_JSON_DEPTH,
@@ -15,7 +16,7 @@ export {
validateCustomWidgetUrl,
} from "./request-executor";
export type { CustomWidgetAuthConfig, CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "./request-executor";
export { CustomWidgetRequestLimiter } from "./request-limits";
export { CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS, CustomWidgetRequestLimiter } from "./request-limits";
export type { RequestLimitInput, RequestLimiterOptions, RequestLimitStore } from "./request-limits";
export { CustomWidgetPreviewSessionService } from "./preview-sessions";
export type {
@@ -12,6 +12,7 @@ import { CustomWidgetDomainError } from "./errors";
const SESSION_TTL_MS = 10 * 60_000;
const MAX_JOURNAL_ENTRIES = 50;
const MAX_UPDATE_ATTEMPTS = 8;
const encryptedSecretSchema = z.object({
sourceId: z.string(),
@@ -22,6 +23,7 @@ const encryptedSecretSchema = z.object({
const sessionSchema = z.object({
id: z.string(),
userId: z.string(),
revision: z.number().int().nonnegative(),
expiresAt: z.number(),
sources: customWidgetSourcesSchema,
secrets: z.array(encryptedSecretSchema),
@@ -62,6 +64,7 @@ export interface CreatePreviewSessionInput {
export interface PreviewSessionStore {
saveSession(id: string, value: unknown, ttlMs: number): Promise<void>;
compareAndSwapSession(id: string, expectedRevision: number, value: unknown, ttlMs: number): Promise<boolean>;
getSession(id: string): Promise<unknown>;
deleteSession(id: string): Promise<void>;
appendJournal(id: string, value: unknown, maxEntries: number, ttlMs: number): Promise<void>;
@@ -96,6 +99,7 @@ export class CustomWidgetPreviewSessionService {
const session: CustomWidgetPreviewSession = {
id: this.options.createId(),
userId: input.userId,
revision: 0,
expiresAt: this.now() + SESSION_TTL_MS,
sources: input.sources,
secrets: input.secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
@@ -114,17 +118,19 @@ export class CustomWidgetPreviewSessionService {
public async get(id: string, userId: string): Promise<CustomWidgetPreviewSession> {
const candidate = this.options.store ? await this.options.store.getSession(id) : this.sessions.get(id);
const parsed = sessionSchema.safeParse(parseStoredValue(candidate));
if (!parsed.success || parsed.data.userId !== userId || parsed.data.expiresAt <= this.now()) {
if (!parsed.success || parsed.data.expiresAt <= this.now()) {
if (this.options.store) await this.options.store.deleteSession(id);
else this.sessions.delete(id);
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired or was not found" });
}
if (parsed.data.userId !== userId) {
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired or was not found" });
}
return parsed.data;
}
public async setLiveActions(id: string, userId: string, enabled: boolean) {
const session = { ...(await this.get(id, userId)), liveActions: enabled };
await this.save(session);
const session = await this.update(id, userId, (current) => ({ ...current, liveActions: enabled }));
return { id, expiresAt: session.expiresAt, liveActions: enabled };
}
@@ -160,23 +166,23 @@ export class CustomWidgetPreviewSessionService {
userId: string,
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>,
) {
const session = await this.get(id, userId);
const sourceIds = new Set(Object.keys(session.sources));
if (secrets.some((secret) => !sourceIds.has(secret.sourceId))) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "A preview secret references an unknown source",
});
}
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
const next = {
...session,
secrets: [
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
],
};
await this.save(next);
const next = await this.update(id, userId, (session) => {
const sourceIds = new Set(Object.keys(session.sources));
if (secrets.some((secret) => !sourceIds.has(secret.sourceId))) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "A preview secret references an unknown source",
});
}
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
return {
...session,
secrets: [
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
],
};
});
return { id, expiresAt: next.expiresAt };
}
@@ -187,33 +193,53 @@ export class CustomWidgetPreviewSessionService {
source: CustomWidgetSource,
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>,
) {
const session = await this.get(id, userId);
const currentSource = session.sources[sourceId];
if (!currentSource) {
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview source was not found" });
}
if (!hasSameCustomWidgetSourceAuthentication(currentSource, source)) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "Preview source authentication changed; create a new configuration request",
});
}
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
const next = {
...session,
sources: {
...session.sources,
[sourceId]: { ...currentSource, baseUrl: source.baseUrl, networkScope: source.networkScope },
},
secrets: [
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
],
};
await this.save(next);
const next = await this.update(id, userId, (session) => {
const currentSource = session.sources[sourceId];
if (!currentSource) {
throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview source was not found" });
}
if (!hasSameCustomWidgetSourceAuthentication(currentSource, source)) {
throw new CustomWidgetDomainError({
code: "BAD_REQUEST",
message: "Preview source authentication changed; create a new configuration request",
});
}
const replacements = new Set(secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
return {
...session,
sources: {
...session.sources,
[sourceId]: { ...currentSource, baseUrl: source.baseUrl, networkScope: source.networkScope },
},
secrets: [
...session.secrets.filter((secret) => !replacements.has(`${secret.sourceId}:${secret.kind}`)),
...secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
],
};
});
return { id, expiresAt: next.expiresAt };
}
private async update(
id: string,
userId: string,
mutate: (session: CustomWidgetPreviewSession) => CustomWidgetPreviewSession,
) {
for (let attempt = 0; attempt < MAX_UPDATE_ATTEMPTS; attempt += 1) {
const current = await this.get(id, userId);
const next = { ...mutate(current), revision: current.revision + 1 };
const ttlMs = Math.max(0, next.expiresAt - this.now());
if (!ttlMs) throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired" });
if (this.options.store) {
if (await this.options.store.compareAndSwapSession(id, current.revision, next, ttlMs)) return next;
} else if (this.sessions.get(id)?.revision === current.revision) {
this.sessions.set(id, next);
return next;
}
}
throw new CustomWidgetDomainError({ code: "CONFLICT", message: "Preview session changed too many times; retry" });
}
private async save(session: CustomWidgetPreviewSession): Promise<void> {
const ttlMs = Math.max(0, session.expiresAt - this.now());
if (!ttlMs) throw new CustomWidgetDomainError({ code: "NOT_FOUND", message: "Preview session expired" });
@@ -32,6 +32,7 @@ export const MAX_REQUEST_BODY_BYTES = 10 * 1024;
const REQUEST_TIMEOUT_MS = 10_000;
const MAX_QUERY_REDIRECTS = 3;
const MAX_RESPONSE_CACHE_ENTRIES = 1_000;
export const MAX_REQUEST_DURATION_MS = 45_000;
export interface CustomWidgetAuthConfig {
type: string;
@@ -64,6 +65,29 @@ const inFlight = new Map<string, Promise<CustomWidgetHttpResponse>>();
let cacheEpoch = 0;
async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWidgetHttpResponse> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), MAX_REQUEST_DURATION_MS);
try {
return await performRequestWithinDeadline(input, controller.signal);
} catch (error) {
if (error instanceof CustomWidgetDomainError) throw error;
if (controller.signal.aborted) {
throw new CustomWidgetDomainError({
code: "BAD_GATEWAY",
message: "External request exceeded the total time limit",
cause: error,
});
}
throw error;
} finally {
clearTimeout(timeout);
}
}
async function performRequestWithinDeadline(
input: CustomWidgetHttpRequest,
deadlineSignal: AbortSignal,
): Promise<CustomWidgetHttpResponse> {
assertRequest(input);
const baseUrl = validateCustomWidgetUrl(input.baseUrl);
let currentUrl = resolveSameOriginTarget(input.baseUrl, input.targetUrl);
@@ -86,7 +110,7 @@ async function performRequest(input: CustomWidgetHttpRequest): Promise<CustomWid
method: currentMethod,
headers,
body: currentBody,
signal: controller.signal,
signal: AbortSignal.any([deadlineSignal, controller.signal]),
});
} catch (error) {
await dispatcher.close();
@@ -1,9 +1,12 @@
import { createHash } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { CustomWidgetDomainError } from "./errors";
const WINDOW_MS = 60_000;
const CONCURRENCY_TTL_MS = 30_000;
// A query may legally follow three redirects with a ten-second timeout per hop.
// Keep the lease above that total and identify every acquisition so a late
// release can never remove capacity owned by a newer request.
export const CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS = 60_000;
const CATEGORY_LIMITS = { query: 60, action: 10, delete: 3 } as const;
type RequestCategory = keyof typeof CATEGORY_LIMITS;
@@ -16,8 +19,8 @@ export interface RequestLimitInput {
export interface RequestLimitStore {
incrementRate(key: string, windowMs: number): Promise<{ count: number; retryAfterMs: number }>;
acquireConcurrency(key: string, limit: number, ttlMs: number): Promise<boolean>;
releaseConcurrency(key: string): Promise<void>;
acquireConcurrency(key: string, ownerId: string, limit: number, ttlMs: number): Promise<boolean>;
releaseConcurrency(key: string, ownerId: string): Promise<void>;
}
export interface RequestLimiterOptions {
@@ -34,8 +37,8 @@ export class CustomWidgetRequestLimiter {
public async acquire(input: RequestLimitInput): Promise<() => Promise<void>> {
const identity = input.userId ?? "anonymous";
const rateKey = `custom-widget:rate:${input.category}:${hashKey(identity, input.itemId)}`;
const userKey = `custom-widget:concurrency:user-item:${hashKey(identity, input.itemId)}`;
const definitionKey = `custom-widget:concurrency:definition:${hashKey(input.definitionId)}`;
const userKey = `custom-widget:concurrency:v2:user-item:${hashKey(identity, input.itemId)}`;
const definitionKey = `custom-widget:concurrency:v2:definition:${hashKey(input.definitionId)}`;
if (!this.options.store) return this.acquireLocal(rateKey, userKey, definitionKey, input.category);
try {
return await this.acquireStored(rateKey, userKey, definitionKey, input.category, this.options.store);
@@ -76,17 +79,22 @@ export class CustomWidgetRequestLimiter {
category: RequestCategory,
store: RequestLimitStore,
) {
const ownerId = randomUUID();
const rate = await store.incrementRate(rateKey, WINDOW_MS);
if (rate.count > CATEGORY_LIMITS[category]) throwLimit(rate.retryAfterMs);
if (!(await store.acquireConcurrency(userKey, 4, CONCURRENCY_TTL_MS))) throwLimit(1_000);
if (!(await store.acquireConcurrency(userKey, ownerId, 4, CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS))) {
throwLimit(1_000);
}
try {
if (!(await store.acquireConcurrency(definitionKey, 8, CONCURRENCY_TTL_MS))) throwLimit(1_000);
if (!(await store.acquireConcurrency(definitionKey, ownerId, 8, CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS))) {
throwLimit(1_000);
}
} catch (error) {
await store.releaseConcurrency(userKey);
await store.releaseConcurrency(userKey, ownerId);
throw error;
}
return async () => {
await Promise.all([store.releaseConcurrency(userKey), store.releaseConcurrency(definitionKey)]);
await Promise.all([store.releaseConcurrency(userKey, ownerId), store.releaseConcurrency(definitionKey, ownerId)]);
};
}
@@ -3,7 +3,7 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
import { createCustomJsxBindings } from "../jsx/bindings";
import { createCustomJsxBindings, CUSTOM_JSX_DATA_LIMITS } from "../jsx/bindings";
import { renderSafeJsx, sanitizeCustomJsxProps } from "../jsx/interpreter";
import { CUSTOM_JSX_BLOCKED_PROPERTIES, CUSTOM_JSX_BLOCKED_PROPS, CUSTOM_JSX_LIMITS } from "../jsx/policy";
@@ -221,6 +221,37 @@ describe("shared Custom JSX policy", () => {
expect(diagnostics.some(({ message }) => message.includes("Prop 'bind'"))).toBe(false);
});
test("validates explicit bind attributes through the JSX AST", () => {
expect(
validateCustomJsxTemplate('<TextInput bind="search" />').filter(({ severity }) => severity === "error"),
).toEqual([]);
expect(validateCustomJsxTemplate("<TextInput bind={data.name} />")).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("literal input name") }),
]),
);
expect(validateCustomJsxTemplate('<TextInput bind="not valid" />')).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("Invalid bind input name") }),
]),
);
expect(
validateCustomJsxTemplate('<Text>{"<TextInput bind={data.name} />"}</Text>').some(({ message }) =>
message.includes("literal input name"),
),
).toBe(false);
});
test("rejects bind values introduced by JSX spreads at the emitter boundary", () => {
expect(() =>
renderSafeJsx({
template: "<TextInput {...data.props} />",
components: { TextInput: (() => null) as never },
bindings: { data: { props: { bind: "remote-name", label: "Unsafe" } } },
}),
).toThrow("BIND_SPREAD_NOT_ALLOWED");
});
test("provides closest component and binding diagnostics", () => {
expect(validateCustomJsxTemplate("<Stak />")[0]?.message).toMatch(/UNKNOWN_COMPONENT.*Stack/u);
expect(validateCustomJsxTemplate('<Text bind="search" />')).toEqual(
@@ -305,9 +336,34 @@ describe("shared Custom JSX policy", () => {
expect(renderToStaticMarkup(rendered.node)).toContain("Bulba-saur-");
});
test.each(["/(a+)+$/", "/(?<=a)b/", "/(a)\\1/"])("rejects unsafe regex %s", (pattern) => {
expect(validateCustomJsxTemplate(`<Text>{${pattern}.test(data.name)}</Text>`)).toEqual(
expect.arrayContaining([expect.objectContaining({ message: expect.stringContaining("UNSAFE_REGEX") })]),
test.each(["/(a+)+$/", "/(?<=a)b/", "/(a)\\1/", "/^(a|aa)+$/", "/^(a|a?)+$/", "/^a+a+$/", "/^(\\w+\\s?)*$/"])(
"rejects unsafe regex %s in analysis and at runtime",
(pattern) => {
const template = `<Text>{${pattern}.test(data.name)}</Text>`;
expect(validateCustomJsxTemplate(template)).toEqual(
expect.arrayContaining([expect.objectContaining({ message: expect.stringContaining("UNSAFE_REGEX") })]),
);
expect(() =>
renderSafeJsx({ template, components, bindings: { data: { name: "a".repeat(10_000) + "!" } } }),
).toThrow("UNSAFE_REGEX");
},
);
test("allows a single unambiguous variable regex quantifier", () => {
const template = '<Text>{/^(ab)+$/.test(data.name) ? "yes" : "no"}</Text>';
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
expect(() => renderSafeJsx({ template, components, bindings: { data: { name: "abab" } } })).not.toThrow();
});
test("bounds response data depth, node count, and string length without recursive traversal", () => {
let deeplyNested: unknown = null;
for (let depth = 0; depth <= CUSTOM_JSX_DATA_LIMITS.depth; depth += 1) deeplyNested = { child: deeplyNested };
expect(() => createCustomJsxBindings(deeplyNested)).toThrow(/depth limit/u);
expect(() => createCustomJsxBindings(Array.from({ length: CUSTOM_JSX_DATA_LIMITS.nodes }).fill(null))).toThrow(
/node limit/u,
);
expect(() => createCustomJsxBindings("x".repeat(CUSTOM_JSX_DATA_LIMITS.stringLength + 1))).toThrow(
/string length limit/u,
);
});
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { customWidgetDefinitionSchema } from "../core";
import { CustomWidgetPreviewSessionService } from "../server";
import type { PreviewSessionStore } from "../server";
const definition = customWidgetDefinitionSchema.parse({
$schema: "homarr-custom-widget-v2",
@@ -109,4 +110,83 @@ describe("preview sessions", () => {
),
).rejects.toThrow("authentication changed");
});
it("does not delete a preview when a different user looks it up", async () => {
const values = new Map<string, unknown>();
const store: PreviewSessionStore = {
saveSession: async (id, value) => {
values.set(id, value);
},
compareAndSwapSession: async (id, revision, value) => {
const current = values.get(id) as { revision?: number } | undefined;
if (current?.revision !== revision) return false;
values.set(id, value);
return true;
},
getSession: async (id) => values.get(id),
deleteSession: async (id) => {
values.delete(id);
},
appendJournal: async () => undefined,
getJournal: async () => [],
};
const service = new CustomWidgetPreviewSessionService({
createId: () => "session",
encrypt: String,
decrypt: String,
store,
});
await service.create({
userId: "owner",
sources: definition.sources,
requests: definition.requests,
name: definition.name,
template: definition.template,
optionDefinitions: definition.options,
options: { limit: 10 },
secrets: [],
});
await expect(service.get("session", "other-user")).rejects.toMatchObject({ code: "NOT_FOUND" });
await expect(service.get("session", "owner")).resolves.toMatchObject({ id: "session" });
});
it("retries concurrent mutations without losing source, secret, or action state", async () => {
const service = new CustomWidgetPreviewSessionService({
createId: () => "session",
encrypt: (value) => `encrypted:${value}`,
decrypt: (value) => value.replace("encrypted:", ""),
});
await service.create({
userId: "owner",
sources: {
...definition.sources,
secondary: { baseUrl: "https://secondary.example.com", networkScope: "public", auth: "basic" },
},
requests: definition.requests,
name: definition.name,
template: definition.template,
optionDefinitions: definition.options,
options: { limit: 10 },
secrets: [],
});
await Promise.all([
service.setLiveActions("session", "owner", true),
service.setSecrets("session", "owner", [{ sourceId: "secondary", kind: "username", value: "operator" }]),
service.configureSource(
"session",
"owner",
"default",
{ baseUrl: "https://api.example.com", networkScope: "public", auth: "bearer" },
[{ sourceId: "default", kind: "apiKey", value: "token" }],
),
]);
const session = await service.get("session", "owner");
expect(session.liveActions).toBe(true);
expect(session.sources.default?.baseUrl).toBe("https://api.example.com");
expect(service.getSecrets(session, "default")).toEqual([{ kind: "apiKey", value: "token" }]);
expect(service.getSecrets(session, "secondary")).toEqual([{ kind: "username", value: "operator" }]);
});
});
@@ -1,11 +1,19 @@
import { describe, expect, it, vi } from "vitest";
import { CustomWidgetRequestLimiter } from "../server";
import {
CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS,
CustomWidgetRequestLimiter,
MAX_REQUEST_DURATION_MS,
} from "../server";
import type { RequestLimitStore } from "../server";
const input = { category: "action", userId: "user", itemId: "item", definitionId: "definition" } as const;
describe("custom widget request limits", () => {
it("keeps owned concurrency leases beyond the total request deadline", () => {
expect(CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS).toBeGreaterThan(MAX_REQUEST_DURATION_MS);
});
it("enforces local per-user/item concurrency and releases capacity", async () => {
const limiter = new CustomWidgetRequestLimiter();
const releases = await Promise.all(Array.from({ length: 4 }, () => limiter.acquire(input)));
@@ -39,4 +47,31 @@ describe("custom widget request limits", () => {
});
expect(store.releaseConcurrency).toHaveBeenCalledOnce();
});
it("releases only the concurrency lease owned by the request", async () => {
const owners = new Set<string>();
const store: RequestLimitStore = {
incrementRate: vi.fn(async () => ({ count: 1, retryAfterMs: 60_000 })),
acquireConcurrency: vi.fn(async (_key, ownerId) => {
owners.add(ownerId);
return true;
}),
releaseConcurrency: vi.fn(async (_key, ownerId) => {
owners.delete(ownerId);
}),
};
const releaseFirst = await new CustomWidgetRequestLimiter({ store }).acquire(input);
const firstOwner = [...owners][0];
expect(store.acquireConcurrency).toHaveBeenCalledWith(
expect.any(String),
firstOwner,
expect.any(Number),
CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS,
);
await releaseFirst();
expect(firstOwner).toBeTypeOf("string");
expect(store.releaseConcurrency).toHaveBeenCalledWith(expect.any(String), firstOwner);
expect(owners).not.toContain(firstOwner);
});
});
@@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ActionButton, CustomJsxRenderer, CustomWidgetRuntimeProvider, SubFetch, ToggleSwitch } from "../runtime";
import { createCustomJsxComponents } from "../jsx";
import { MAX_REFRESH_INTERVAL_MS, MAX_REFRESH_INTERVAL_SECONDS, normalizeRefreshInterval } from "../runtime/sub-fetch";
import type {
CustomJsxRequestCapability,
CustomWidgetPublishedQueryState,
@@ -106,6 +107,31 @@ async function settle() {
}
describe("Custom Widget runtime ports", () => {
it("clamps refresh intervals below the browser timer overflow boundary", () => {
expect(normalizeRefreshInterval(MAX_REFRESH_INTERVAL_SECONDS)).toBe(MAX_REFRESH_INTERVAL_MS);
expect(normalizeRefreshInterval(MAX_REFRESH_INTERVAL_SECONDS + 1)).toBe(MAX_REFRESH_INTERVAL_MS);
expect(normalizeRefreshInterval(Number.MAX_VALUE)).toBe(MAX_REFRESH_INTERVAL_MS);
expect(MAX_REFRESH_INTERVAL_MS).toBeLessThan(2 ** 31 - 1);
});
it("routes binding-construction failures through the runtime error display", async () => {
await render(
<CustomJsxRenderer
template="<Text>Safe</Text>"
data={{}}
components={{ Text: (() => null) as never }}
createBindings={() => {
throw new RangeError("Response data exceeded the depth limit");
}}
messages={{ noTemplate: "No template", templateWarnings: (count) => `${count} warnings` }}
/>,
createPort(),
);
expect(host.textContent).toContain("RUNTIME_RENDER_ERROR");
expect(host.textContent).toContain("Response data exceeded the depth limit");
});
it("renders common safe collection and number formatting operations", async () => {
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
@@ -162,9 +188,7 @@ describe("Custom Widget runtime ports", () => {
await render(
<>
<button name="shared" role="radio" aria-checked="false" type="button">
Outside
</button>
<input aria-label="Outside" name="shared" type="radio" />
<CustomJsxRenderer
template={
'<Radio.Group name="shared" defaultValue="inside"><Radio.Card value="inside"><Radio.Indicator /></Radio.Card><Radio.Card value="other"><Radio.Indicator /></Radio.Card></Radio.Group>'
@@ -179,7 +203,8 @@ describe("Custom Widget runtime ports", () => {
);
await settle();
const [outside, inside, other] = [...host.querySelectorAll<HTMLButtonElement>('button[role="radio"]')];
const outside = host.querySelector<HTMLInputElement>('input[type="radio"]');
const [inside, other] = [...host.querySelectorAll<HTMLButtonElement>('button[role="radio"]')];
expect(outside?.name).toBe("shared");
expect(inside?.name).toMatch(/^custom-widget-/u);
expect(inside?.name).not.toBe(outside?.name);
@@ -53,6 +53,27 @@ describe("lean Custom Widget schema", () => {
expect(customWidgetDefinitionSchema.parse(withoutOptions).options).toEqual({});
});
it("rejects source IDs that collide case-insensitively", () => {
const result = customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
sources: {
Api: { baseUrl: "https://one.example.com", networkScope: "public", auth: "none" },
api: { baseUrl: "https://two.example.com", networkScope: "public", auth: "none" },
},
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: ["sources", "api"],
message: expect.stringContaining("conflicts case-insensitively"),
}),
]),
);
}
});
it("rejects invocation params on load and unknown options", () => {
const loadParam = customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
@@ -7,7 +7,40 @@ import type { Root } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { PreviewHeader, PreviewResponsePanel, ResponseTree } from "../workbench";
import { CustomWidgetCodeEditor, PreviewHeader, PreviewResponsePanel, ResponseTree } from "../workbench";
import type { CustomWidgetEditorMessages } from "../workbench";
vi.mock("../workbench/direct-code-mirror", () => ({
default: ({ id, label, labelledBy }: { id: string; label: string; labelledBy: string }) => (
<textarea id={id} aria-label={label} aria-labelledby={labelledBy} />
),
}));
const editorMessages: CustomWidgetEditorMessages = {
languageJsx: "JSX",
languageJson: "JSON",
undo: "Undo",
redo: "Redo",
components: "Components",
componentSearch: "Search components",
componentEmpty: "No components",
componentCount: (count) => `${count} components`,
insertStarter: "Insert starter",
format: "Format",
copy: "Copy",
copied: "Copied",
schema: "Schema",
schemaTab: "JSON Schema",
minimalTab: "Minimal",
fullTab: "Full",
errors: (count) => `${count} errors`,
warnings: (count) => `${count} warnings`,
ready: "Ready",
position: ({ line, column }) => `${line}:${column}`,
characters: (count) => `${count}`,
diagnosticsTitle: "Diagnostics",
diagnostic: (diagnostic) => diagnostic.code,
};
let root: Root;
let host: HTMLDivElement;
@@ -39,6 +72,25 @@ async function render(node: ReactNode) {
}
describe("Custom Widget workbench UI", () => {
test("associates the visible label with the editable CodeMirror control", async () => {
await render(
<CustomWidgetCodeEditor
id="template-editor"
label="Template"
language="jsx"
value="<Text>Hello</Text>"
messages={editorMessages}
onChange={vi.fn()}
/>,
);
await act(async () => Promise.resolve());
const label = host.querySelector('#template-editor-label[for="template-editor"]');
expect(label).not.toBeNull();
expect(host.querySelector("#template-editor")?.getAttribute("aria-label")).toBe("Template");
expect(host.querySelector("#template-editor")?.getAttribute("aria-labelledby")).toBe("template-editor-label");
});
test("renders response data through the Mantine Tree with accessible path actions", async () => {
await render(
<ResponseTree
@@ -100,7 +100,7 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
description={props.description}
error={props.error}
required={props.required}
labelProps={{ htmlFor: props.id }}
labelProps={{ id: `${props.id}-label`, htmlFor: props.id }}
inputWrapperOrder={["label", "description", "input", "error"]}
>
<div className={classes.root}>
@@ -152,7 +152,8 @@ export function CustomWidgetCodeEditor(props: CustomWidgetCodeEditorProps) {
{editorReady ? (
<Suspense fallback={<EditorLoader />}>
<LazyCodeMirror
id={`${props.id}-root`}
id={props.id}
labelledBy={`${props.id}-label`}
value={props.value}
language={props.language}
diagnostics={diagnostics}
@@ -24,6 +24,7 @@ interface DirectCodeMirrorProps {
completions: Completion[];
id: string;
label: string;
labelledBy: string;
placeholder?: string;
height: string;
theme: "light" | "dark";
@@ -121,7 +122,7 @@ function createExtensions(instanceId: string, props: DirectCodeMirrorProps) {
};
}),
),
EditorView.contentAttributes.of({ id: props.id, "aria-label": props.label }),
EditorView.contentAttributes.of({ id: props.id, "aria-label": props.label, "aria-labelledby": props.labelledBy }),
EditorView.editable.of(!props.readOnly),
EditorState.readOnly.of(props.readOnly),
keymap.of(props.readOnly ? [] : [indentWithTab]),