inspector/clients/tui/__tests__/uriTemplateToForm.test.ts
cliffhall 935113d1e0 fix: encode per RFC 3986, and derive the TUI form from the shared parser
Addresses Copilot's round-4 review on #2035 — again reported as "no new
comments" while carrying three suppressed ones. All three reproduced.

1. The TUI form still named its fields from the SDK. `uriTemplateToForm`
   read `UriTemplate.variableNames`, which mangles a name: `{;id}` yields
   ";id" and `{id:3}` yields "id:3". The form therefore submitted
   `{ ";id": "7" }` while the shared expander looked up `id`, found
   nothing, and dropped the expression — the value vanished silently.

   This is the finding that mattered most, because it falsified the claim
   this change is built on. Moving the expander into core only makes the
   clients agree if each client's FORM derives its names from the same
   parser: a form submits under the names it rendered. The TUI now reads
   `templateVariables`, and the SDK template is constructed only to
   validate, preserving the existing malformed-template diagnostic.

   Its `required` flag comes from `requiredGroups`, and only a variable
   that is the sole member of a non-omittable expression is marked: ink-
   form cannot express "any one of these", so marking every member of
   `{a,b}` required would refuse input the expander accepts.

2. `encodeURIComponent` is not RFC 3986's unreserved set — it leaves the
   sub-delims !'()* bare, which RFC 6570 requires encoded for every
   operator except + and #.

   Fixing that settled a design question left open in the previous round.
   With two encoders, the SAME value encoded differently depending on
   whether its expression happened to carry a modifier, since only the
   modifier pushed it onto our path. So delegation is gone entirely: one
   expander, one set of rules. The SDK's `UriTemplate` is still
   constructed, but only to validate a template.

3. The "Any one of: a, b" hint could contradict the disabled submit
   button. A name can sit in a singleton required group AND a shared one
   (`x://{a}/{a,b}`), where the singleton demands that exact field. The
   hint is suppressed in that case.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 21:04:20 -04:00

66 lines
2.6 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from "vitest";
import { uriTemplateToForm } from "../src/utils/uriTemplateToForm.js";
describe("uriTemplateToForm", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("creates a field for each template variable", () => {
const form = uriTemplateToForm("file:///{path}/{name}", "file");
expect(form.title).toBe("Read Resource: file");
const fields = form.sections[0]!.fields;
expect(fields.map((f) => f.name)).toEqual(["path", "name"]);
// Simple `{path}` sits mid-URI, so omitting it would leave an empty path
// segment rather than a shorter URI -- it is mandatory, matching the web
// panel. Both clients read this from core's `requiredGroups`.
expect(fields[0]).toMatchObject({ type: "string", required: true });
});
it("names fields as the expander looks them up, not as the SDK parses them", () => {
// The SDK reports these as ";id" and "id:3"; submitting under those keys
// would make the expander find nothing and drop the expression (#1919).
expect(
uriTemplateToForm("x://a{;id}", "matrix").sections[0]!.fields.map(
(f) => f.name,
),
).toEqual(["id"]);
expect(
uriTemplateToForm("x://a/{id:3}", "prefix").sections[0]!.fields.map(
(f) => f.name,
),
).toEqual(["id"]);
});
it("leaves a shared required group optional rather than demanding every name", () => {
// `{a,b}` is satisfied by either name, and ink-form cannot say "any one
// of"; marking both required would refuse input the expander accepts.
const fields = uriTemplateToForm("x://{a,b}", "pair").sections[0]!.fields;
expect(fields.map((f) => f.name)).toEqual(["a", "b"]);
expect(fields.every((f) => f.required === false)).toBe(true);
});
it("marks an omittable variable optional", () => {
const fields = uriTemplateToForm("x://a{?topic}", "q").sections[0]!.fields;
expect(fields[0]).toMatchObject({ name: "topic", required: false });
});
it("returns an empty Template Variables section for a static URI", () => {
const form = uriTemplateToForm("file:///static", "static");
expect(form.sections[0]).toEqual({
title: "Template Variables",
fields: [],
});
});
it("logs and returns an empty form when the template cannot be parsed", () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const form = uriTemplateToForm("file:///{unclosed", "broken");
expect(errorSpy).toHaveBeenCalledWith(
"Failed to parse URI template:",
expect.any(Error),
);
expect(form.sections[0]!.fields).toEqual([]);
});
});