inspector/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.test.tsx
cliffhall ed799a0c0b feat(web): use the Ace editor for the request-metadata JSON
A textarea is a poor place to hand-write a nested `_meta` payload. Ace closes a
brace when you open one, folds nested objects, and — through its JSON worker —
marks the offending line in the gutter instead of reporting one message for the
whole document.

Three things the swap turned up:

- Ace fires *two* change events for a replace (a remove, then an insert), so
  select-all-and-retype briefly leaves an empty document. Acting on the first
  emitted `{}` and wiped the user's saved metadata the instant they selected
  it. `handleChange` now coalesces the pair in a microtask, and the component
  tracks what it last emitted so the parent echoing that value back is not
  mistaken for an external reset.

- Ace's own `github` themes fail WCAG AA on the gutter — `#AAA` on `#e8e8e8` is
  1.89:1, and the dark theme is 4.13:1. Folding needs the gutter, so the colors
  are overridden with palette tokens (5.82:1 and 4.88:1) rather than hiding it.

- `textInputAriaLabel` alone never reaches the DOM: Ace composes the label as
  "<label>, Cursor at row N" and only recomputes it when the cursor moves, so
  it is forced once on load. Until then the control's only accessible name is a
  position readout.

Testing is split because Ace's input path does not work under happy-dom at all
— `userEvent.type` reaches the textarea and produces no edit, so a test written
that way asserts nothing. Unit tests drive the editor instance through a shared
`src/test/aceEditor.ts` helper; the real keyboard behaviour (`{` then Enter
giving `{\n  \n}`) is asserted by Storybook play functions in Chromium.

`react-ace`/`ace-builds` are declared in `clients/web` rather than the root, per
the rule for a dependency that renders React components.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-23 16:55:23 -04:00

143 lines
5.6 KiB
TypeScript

import { useState } from "react";
import { describe, it, expect, vi } from "vitest";
import { act, screen } from "@testing-library/react";
import type { JsonObject } from "@inspector/core/json/jsonUtils.js";
import { renderWithMantine } from "../../../test/renderWithMantine";
import { aceLabel, getAceText, setAceText } from "../../../test/aceEditor";
import { JsonObjectInput } from "./JsonObjectInput";
const LABEL = "Payload JSON";
/** Controlled harness: the component never owns the value, only the draft. */
function Harness({
initial,
onChange,
}: {
initial: JsonObject;
onChange?: (v: JsonObject) => void;
}) {
const [value, setValue] = useState<JsonObject>(initial);
return (
<>
<JsonObjectInput
ariaLabel={LABEL}
value={value}
onChange={(next) => {
setValue(next);
onChange?.(next);
}}
/>
<output data-testid="emitted">{JSON.stringify(value)}</output>
</>
);
}
describe("JsonObjectInput", () => {
it("names the editor for assistive tech", () => {
renderWithMantine(<Harness initial={{}} />);
// Ace composes "<label>, Cursor at row N", so the match is by substring —
// the position readout is Ace's, the name is ours.
expect(screen.getByLabelText(aceLabel(LABEL))).toBeInTheDocument();
});
it("uses Ace's dark theme under a dark color scheme", () => {
// The editor picks its Ace theme from the computed scheme rather than
// inheriting Mantine tokens, so the dark branch is only exercised by
// actually rendering dark.
renderWithMantine(<Harness initial={{}} />, { colorScheme: "dark" });
// `ace-github-dark`, hyphenated — Ace's cssClass, not the module name
// (`theme-github_dark`). The App.css gutter-contrast override keys off this
// same class, so getting it wrong silently drops the fix in dark mode.
expect(document.querySelector(".ace-github-dark")).not.toBeNull();
expect(document.querySelector(".ace-github")).toBeNull();
});
it("uses Ace's light theme under a light color scheme", () => {
renderWithMantine(<Harness initial={{}} />, { colorScheme: "light" });
expect(document.querySelector(".ace-github")).not.toBeNull();
expect(document.querySelector(".ace-github_dark")).toBeNull();
});
it("opens with the value serialized two-space, nesting intact", () => {
renderWithMantine(<Harness initial={{ a: { b: [1, 2] } }} />);
expect(getAceText()).toBe(
'{\n "a": {\n "b": [\n 1,\n 2\n ]\n }\n}',
);
});
it("emits the parsed object — a structured value stays structured", async () => {
const onChange = vi.fn();
renderWithMantine(<Harness initial={{}} onChange={onChange} />);
await setAceText('{"trace":{"id":"x"},"n":3}');
expect(onChange).toHaveBeenLastCalledWith({ trace: { id: "x" }, n: 3 });
});
// Also the regression test for the paired-event bug `handleChange` coalesces:
// `setValue` is a remove followed by an insert, and acting on the remove
// emitted `{}` and wiped `keep` before the insert had landed.
it("shows the text as typed while it is invalid and does not emit", async () => {
const onChange = vi.fn();
renderWithMantine(<Harness initial={{ keep: "me" }} onChange={onChange} />);
await setAceText('{"a":');
console.log("[dbg] emitted:", screen.getByTestId("emitted").textContent);
console.log(
"[dbg] body has error?",
document.body.textContent?.includes("Not valid JSON"),
);
// Displayed verbatim — not re-escaped back through JSON.stringify.
expect(getAceText()).toBe('{"a":');
expect(
screen.getByText("Not valid JSON — changes are not applied"),
).toBeInTheDocument();
// Nothing emitted, so the caller still holds the last valid object.
expect(onChange).not.toHaveBeenCalled();
expect(screen.getByTestId("emitted").textContent).toBe('{"keep":"me"}');
});
it("flags valid JSON that is not an object without emitting", async () => {
const onChange = vi.fn();
renderWithMantine(<Harness initial={{ keep: "me" }} onChange={onChange} />);
await setAceText("[1]");
expect(screen.getByText(/Must be a JSON object/)).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
it("treats a cleared editor as the empty object", async () => {
const onChange = vi.fn();
renderWithMantine(<Harness initial={{ a: 1 }} onChange={onChange} />);
await setAceText("");
expect(onChange).toHaveBeenLastCalledWith({});
expect(screen.queryByText(/Not valid JSON/)).toBeNull();
});
it("re-syncs when the parent's value diverges from the draft", async () => {
// An external reset (switching servers, a reloaded catalog) must reach the
// editor; an in-progress edit must not be overwritten by what it emitted.
function Externally() {
const [value, setValue] = useState<JsonObject>({ a: 1 });
return (
<>
<JsonObjectInput
ariaLabel={LABEL}
value={value}
onChange={setValue}
/>
<button onClick={() => setValue({ b: 2 })}>reset</button>
</>
);
}
renderWithMantine(<Externally />);
await act(async () => {
screen.getByRole("button", { name: "reset" }).click();
});
expect(JSON.parse(getAceText())).toEqual({ b: 2 });
});
it("leaves an in-progress edit alone when the parent echoes it back", async () => {
renderWithMantine(<Harness initial={{}} />);
// The parent re-renders with the object this text parsed to; the draft must
// not be reformatted out from under the cursor.
await setAceText('{"a":1}');
expect(getAceText()).toBe('{"a":1}');
});
});