fix: enforce console schema contract safeguards
This commit is contained in:
@@ -236,6 +236,25 @@ describe("useCapabilityDiscovery", () => {
|
||||
expect(result.current.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("stops automatic pagination when the server repeats a cursor", async () => {
|
||||
const repeatedPage = deferred<CapabilityPage>();
|
||||
client.list
|
||||
.mockResolvedValueOnce(page([summary("serena.default.search")], "page-2"))
|
||||
.mockReturnValueOnce(repeatedPage.promise)
|
||||
.mockReturnValue(new Promise<CapabilityPage>(() => undefined));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useCapabilityDiscovery({ loadAllPages: true }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(client.list).toHaveBeenCalledTimes(2));
|
||||
repeatedPage.resolve(page([summary("wf.std.constant", "wf.std")], "page-2"));
|
||||
await waitFor(() => expect(result.current.items).toHaveLength(2));
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(client.list).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.nextCursor).toBe("page-2");
|
||||
});
|
||||
|
||||
it("uses the applied filters when loading more after draft edits", async () => {
|
||||
client.list
|
||||
.mockResolvedValueOnce(page([summary("local.documents.read")], "page-2"))
|
||||
|
||||
@@ -107,6 +107,7 @@ export const useCapabilityDiscovery = (
|
||||
const [state, setState] = useState<DiscoveryStateWithAppliedFilters>(initialState);
|
||||
const listGenerationRef = useRef(0);
|
||||
const inspectGenerationRef = useRef(0);
|
||||
const automaticPageCursorsRef = useRef<Set<string>>(new Set());
|
||||
const committedProvenanceRef = useRef<ConnectionProvenance | null>(null);
|
||||
const listProvenanceRef = useRef<ConnectionProvenance | null>(null);
|
||||
const selectedProvenanceRef = useRef<ConnectionProvenance | null>(null);
|
||||
@@ -123,7 +124,10 @@ export const useCapabilityDiscovery = (
|
||||
if (!client || currentProvenance === null) return;
|
||||
const requestProvenance = currentProvenance;
|
||||
const generation = ++listGenerationRef.current;
|
||||
if (append === false) inspectGenerationRef.current++;
|
||||
if (append === false) {
|
||||
inspectGenerationRef.current++;
|
||||
automaticPageCursorsRef.current.clear();
|
||||
}
|
||||
listProvenanceRef.current = requestProvenance;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
@@ -226,8 +230,11 @@ export const useCapabilityDiscovery = (
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadAllPages || state.phase !== "ready" || state.nextCursor === null) return;
|
||||
if (automaticPageCursorsRef.current.has(state.nextCursor)) return;
|
||||
automaticPageCursorsRef.current.add(state.nextCursor);
|
||||
// Authoring palettes need the complete catalog because they do not expose
|
||||
// discovery pagination; continue one page at a time through the same guarded loader.
|
||||
// discovery pagination. Stop when a server repeats a cursor rather than
|
||||
// hiding an infinite request loop behind item deduplication.
|
||||
loadMore();
|
||||
}, [loadAllPages, loadMore, state.nextCursor, state.phase]);
|
||||
|
||||
|
||||
@@ -171,6 +171,20 @@ describe("normalizeSchema", () => {
|
||||
hasDefault: true,
|
||||
defaultValue: "source_resource_ref",
|
||||
});
|
||||
expect(ref?.children.find((child) => child.key === "mime_type")).toMatchObject({
|
||||
kind: "string",
|
||||
title: "Mime Type",
|
||||
hasDefault: true,
|
||||
defaultValue: null,
|
||||
fallbackReason: null,
|
||||
});
|
||||
expect(ref?.children.find((child) => child.key === "name")).toMatchObject({
|
||||
kind: "string",
|
||||
title: "Name",
|
||||
hasDefault: true,
|
||||
defaultValue: null,
|
||||
fallbackReason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back deterministically for an object reference cycle through a property", () => {
|
||||
|
||||
@@ -45,6 +45,25 @@ const isRecord = (value: unknown): value is SchemaRecord =>
|
||||
const hasOwn = (value: SchemaRecord, key: string): boolean =>
|
||||
Object.prototype.hasOwnProperty.call(value, key);
|
||||
|
||||
const nullableNonNullSchema = (schema: SchemaRecord): SchemaRecord | null => {
|
||||
const anyOf = schema.anyOf;
|
||||
if (!Array.isArray(anyOf) || anyOf.length !== 2) return null;
|
||||
const nullBranches = anyOf.filter(
|
||||
(branch) => isRecord(branch) && branch.type === "null",
|
||||
);
|
||||
const nonNullBranches = anyOf.filter(
|
||||
(branch): branch is SchemaRecord => isRecord(branch) && branch.type !== "null",
|
||||
);
|
||||
if (nullBranches.length !== 1 || nonNullBranches.length !== 1) return null;
|
||||
|
||||
const annotations = Object.fromEntries(
|
||||
["default", "description", "title"]
|
||||
.filter((key) => hasOwn(schema, key))
|
||||
.map((key) => [key, schema[key]]),
|
||||
);
|
||||
return { ...nonNullBranches[0], ...annotations };
|
||||
};
|
||||
|
||||
const stringValue = (value: unknown): string | null =>
|
||||
typeof value === "string" ? value : null;
|
||||
|
||||
@@ -134,7 +153,30 @@ const normalizeField = (
|
||||
const title = isRecord(schema) ? stringValue(schema.title) ?? defaultTitle : defaultTitle;
|
||||
return fallback(schema, path, key, required, title, resolution.reason);
|
||||
}
|
||||
const resolvedSchema = resolution.schema;
|
||||
let resolvedSchema = resolution.schema;
|
||||
let resolvedReferenceAncestry = resolution.referenceAncestry;
|
||||
if (isRecord(resolvedSchema)) {
|
||||
const nullableSchema = nullableNonNullSchema(resolvedSchema);
|
||||
if (nullableSchema !== null) {
|
||||
const nullableResolution = resolveLocalSchemaNodeWithAncestry(
|
||||
rootSchema,
|
||||
nullableSchema,
|
||||
resolvedReferenceAncestry,
|
||||
);
|
||||
if (!nullableResolution.ok) {
|
||||
return fallback(
|
||||
resolvedSchema,
|
||||
path,
|
||||
key,
|
||||
required,
|
||||
stringValue(resolvedSchema.title) ?? defaultTitle,
|
||||
nullableResolution.reason,
|
||||
);
|
||||
}
|
||||
resolvedSchema = nullableResolution.schema;
|
||||
resolvedReferenceAncestry = nullableResolution.referenceAncestry;
|
||||
}
|
||||
}
|
||||
const title = isRecord(resolvedSchema)
|
||||
? stringValue(resolvedSchema.title) ?? defaultTitle
|
||||
: defaultTitle;
|
||||
@@ -183,7 +225,7 @@ const normalizeField = (
|
||||
propertyKey,
|
||||
requiredNames.has(propertyKey),
|
||||
stringValue(propertySchema && isRecord(propertySchema) ? propertySchema.title : null) ?? propertyKey,
|
||||
resolution.referenceAncestry,
|
||||
resolvedReferenceAncestry,
|
||||
depth + 1,
|
||||
),
|
||||
)
|
||||
@@ -216,7 +258,7 @@ const normalizeField = (
|
||||
"item",
|
||||
true,
|
||||
`${title} item`,
|
||||
resolution.referenceAncestry,
|
||||
resolvedReferenceAncestry,
|
||||
depth + 1,
|
||||
);
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user