diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index b92eff57..e4233a49 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -16,6 +16,7 @@ "@mantine/hooks": "^8.3.17", "@mantine/notifications": "^8.3.17", "@napi-rs/keyring": "^1.3.0", + "ace-builds": "^1.44.0", "ajv": "^8.17.1", "atomically": "^2.1.1", "chokidar": "^4.0.3", @@ -25,6 +26,7 @@ "papaparse": "^5.5.4", "pino": "^9.14.0", "react": "^19.2.4", + "react-ace": "^15.0.0", "react-dom": "^19.2.4", "react-icons": "^5.6.0", "react-markdown": "^10.1.0", @@ -4383,6 +4385,12 @@ "dev": true, "license": "MIT" }, + "node_modules/ace-builds": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.44.0.tgz", + "integrity": "sha512-PFNMSYqFdEUkul2Ntud0HvA09AgY+F1ag0UYdpMH60wNI/qOA8cB8tlTgoALMEwIdUPJK2CjrIQ7OnbiSS/ugQ==", + "license": "BSD-3-Clause" + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -5061,6 +5069,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "license": "Apache-2.0" + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -5487,6 +5501,15 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -8294,6 +8317,22 @@ "node": ">=0.10.0" } }, + "node_modules/react-ace": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/react-ace/-/react-ace-15.0.0.tgz", + "integrity": "sha512-gdmS5ftF0hsbkcrAjZQqYbXwFg5JrHuxjki8mP6Bn9kfa6lHKfZk9sU9EfS0ifQ1CpGCmRxF/VC7GRvlJMBuZw==", + "license": "MIT", + "dependencies": { + "ace-builds": "^1.36.3", + "diff-match-patch": "^1.0.5", + "fast-equals": "^5.3.3", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-docgen": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.3.tgz", diff --git a/clients/web/package.json b/clients/web/package.json index 451c0767..8696fb25 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -42,6 +42,7 @@ "@mantine/hooks": "^8.3.17", "@mantine/notifications": "^8.3.17", "@napi-rs/keyring": "^1.3.0", + "ace-builds": "^1.44.0", "ajv": "^8.17.1", "atomically": "^2.1.1", "chokidar": "^4.0.3", @@ -51,6 +52,7 @@ "papaparse": "^5.5.4", "pino": "^9.14.0", "react": "^19.2.4", + "react-ace": "^15.0.0", "react-dom": "^19.2.4", "react-icons": "^5.6.0", "react-markdown": "^10.1.0", diff --git a/clients/web/src/App.css b/clients/web/src/App.css index b208285c..a32c6435 100644 --- a/clients/web/src/App.css +++ b/clients/web/src/App.css @@ -73,6 +73,16 @@ ScrollArea would break flex sizing, e.g. the Resources accordion) blend in. */ --inspector-scrollbar-thumb: rgba(0, 0, 0, 0.4); + /* ── Code editor (Ace) ─────────────────────────────────── */ + /* Ace's own `github` themes fail WCAG AA on the gutter: `#AAA` on the light + gutter's `#e8e8e8` is 1.89:1, and `#7388b5` on the dark gutter's `#24292e` + is 4.13:1 — both under 4.5:1 at the 12px the line numbers render. The + numbers are `aria-hidden`, which exempts nothing here: contrast is a + concern for sighted users, and axe flags them accordingly. + `gray-7` is 5.82:1 on the light gutter; the dark block lifts it to + `gray-5`, 4.88:1 on the dark one. */ + --inspector-code-gutter-text: var(--mantine-color-gray-7); + /* ── Connection status ─────────────────────────────────── */ --inspector-status-connected: var(--mantine-color-green-6); --inspector-status-connecting: var(--mantine-color-yellow-6); @@ -161,6 +171,9 @@ schemes, which sits too dark on charcoal — lift them one to two steps so they stay legible in dark mode. Light-mode `:root` keeps the `*-6` values. */ + /* Code editor (Ace) — see the note in `:root`. */ + --inspector-code-gutter-text: var(--mantine-color-gray-5); + /* Status */ --inspector-status-connected: var(--mantine-color-green-4); --inspector-status-connecting: var(--mantine-color-yellow-4); @@ -593,3 +606,12 @@ body.resizing-col { user-select: none; cursor: col-resize; } + +/* Ace renders its own DOM, so its gutter can only be reached by selector — it + is not a Mantine component with a theme entry. The doubled class raises + specificity above Ace's own `.ace-github .ace_gutter`, which is injected into + at runtime and therefore wins on source order otherwise. */ +.ace_editor.ace-github .ace_gutter, +.ace_editor.ace-github-dark .ace_gutter { + color: var(--inspector-code-gutter-text); +} diff --git a/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.stories.tsx b/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.stories.tsx index 51405384..cdbcfc0f 100644 --- a/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.stories.tsx +++ b/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.stories.tsx @@ -4,17 +4,23 @@ import { expect, userEvent, within } from "storybook/test"; import type { JsonObject } from "@inspector/core/json/jsonUtils.js"; import { JsonObjectInput } from "./JsonObjectInput"; +const LABEL = "Request metadata JSON"; + /** * The component is controlled, so every story drives it through a small - * stateful wrapper — otherwise the box would not accept a keystroke and the + * stateful wrapper — otherwise the editor would not accept a keystroke and the * play functions could not exercise the draft behavior at all. + * + * These stories run in real Chromium, which is what makes them the *only* + * place Ace's keyboard behavior can be tested: the unit suite runs in + * happy-dom, where Ace's hidden-textarea input path does not work at all. */ function ControlledJsonObjectInput({ initial }: { initial: JsonObject }) { const [value, setValue] = useState(initial); return ( @@ -29,6 +35,57 @@ const meta: Meta = { export default meta; type Story = StoryObj; +/** The live editor instance, which Ace hangs off its container as `env.editor`. */ +function aceEditor(canvasElement: HTMLElement): { + focus(): void; + selectAll(): void; + getValue(): string; +} { + const container = canvasElement.querySelector(".ace_editor"); + const editor = ( + container as + | (HTMLElement & { + env?: { + editor?: { + focus(): void; + selectAll(): void; + getValue(): string; + }; + }; + }) + | null + )?.env?.editor; + if (!editor) throw new Error("Ace editor not mounted"); + return editor; +} + +/** + * Focus the editor and empty it, so the next keystrokes are the whole document + * and the typing itself — the thing under test — is left to `userEvent`. + * + * Emptied rather than merely selected: typing an opening brace or quote with a + * selection makes Ace **wrap** the selection instead of replacing it, so typing + * `{` over a selected `{}` yields `{{}}` and the assertion stops being about + * auto-closing at all. + * + * Focus and select go through Ace's API on purpose. `userEvent.click` does not + * focus the editor — Ace's input is a 1px offscreen textarea, so the click + * lands nowhere and the keystrokes reach the body instead. And select-all is + * not `Control+A` everywhere (Ace binds `Cmd-A` on mac), so pressing it would + * make these stories pass or fail by host OS. + */ +async function focusAndClear(canvasElement: HTMLElement): Promise { + const editor = aceEditor(canvasElement); + editor.focus(); + editor.selectAll(); + await userEvent.keyboard("{Backspace}"); +} + +/** Ace renders each token in its own element, so read the document, not text. */ +function editorText(canvasElement: HTMLElement): string { + return aceEditor(canvasElement).getValue(); +} + /** A structured payload renders as formatted JSON, nesting intact. */ export const Populated: Story = { args: { @@ -39,9 +96,7 @@ export const Populated: Story = { }, }, play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const box = await canvas.findByLabelText("Request metadata JSON"); - await expect(JSON.parse((box as HTMLTextAreaElement).value)).toEqual({ + await expect(JSON.parse(editorText(canvasElement))).toEqual({ tenant: "acme", trace: { id: "abc123", sampled: true }, features: ["apps", "tasks"], @@ -54,8 +109,36 @@ export const Empty: Story = { args: { initial: {} }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const box = await canvas.findByLabelText("Request metadata JSON"); - await expect(box).toHaveValue("{}"); + await expect(editorText(canvasElement)).toBe("{}"); + await expect(canvas.queryByText(/Not valid JSON/)).toBeNull(); + }, +}; + +/** + * Opening a brace closes it — one of the two reasons this is an editor rather + * than a textarea, and the thing that makes hand-writing a nested `_meta` + * payload bearable. + * + * Deliberately one keystroke: Ace also auto-pairs quotes and skips over a + * closing character you type yourself, so a longer sequence asserts the sum of + * several behaviours and stops being a readable statement about this one. + */ +export const ClosesBracesWhileTyping: Story = { + args: { initial: {} }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await focusAndClear(canvasElement); + // `{{` is userEvent's escape for a literal `{`. + await userEvent.keyboard("{{"); + // Ace does not close it on the keystroke — an opening brace at the end of a + // line is often the start of a block the user is about to fill in. + await expect(editorText(canvasElement)).toBe("{"); + // Enter is what completes the pair, leaving the cursor indented between + // them. This is the behaviour that makes writing nested `_meta` by hand + // bearable, and the reason this is an editor rather than a textarea. + await userEvent.keyboard("{Enter}"); + await expect(editorText(canvasElement)).toBe("{\n \n}"); + // The result parses, so nothing is flagged. await expect(canvas.queryByText(/Not valid JSON/)).toBeNull(); }, }; @@ -69,22 +152,31 @@ export const InvalidDraft: Story = { args: { initial: {} }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const box = await canvas.findByLabelText("Request metadata JSON"); - await userEvent.clear(box); - await userEvent.type(box, '{{"a":'); - await expect(box).toHaveValue('{"a":'); + await focusAndClear(canvasElement); + // Genuinely half-typed — the state a user is in partway through writing an + // entry, rather than a contrived bad string. + await userEvent.keyboard('{{"a":'); + // Shown exactly as typed. The old textarea re-escaped its own contents on + // every keystroke here, which is what made it unusable (#1928). + await expect(editorText(canvasElement)).toBe('{"a":'); await expect(await canvas.findByText(/Not valid JSON/)).toBeVisible(); }, }; -/** Valid JSON that is not an object is rejected with its own message. */ +/** + * Valid JSON that is not an object is rejected with its own message, distinct + * from unparseable text. + * + * A bare number rather than an array: brackets are auto-paired too, so typing + * one would test the pairing as much as the rejection. + */ export const NotAnObject: Story = { args: { initial: {} }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const box = await canvas.findByLabelText("Request metadata JSON"); - await userEvent.clear(box); - await userEvent.type(box, "[[1,2]"); + await focusAndClear(canvasElement); + await userEvent.keyboard("42"); + await expect(editorText(canvasElement)).toBe("42"); await expect( await canvas.findByText(/Must be a JSON object/), ).toBeVisible(); diff --git a/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.test.tsx b/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.test.tsx index b1da0ffb..e805e8d4 100644 --- a/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.test.tsx +++ b/clients/web/src/components/elements/JsonObjectInput/JsonObjectInput.test.tsx @@ -1,9 +1,9 @@ import { useState } from "react"; import { describe, it, expect, vi } from "vitest"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +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"; @@ -20,7 +20,7 @@ function Harness({ return ( <> { setValue(next); @@ -32,38 +32,60 @@ function Harness({ ); } -function box() { - return screen.getByLabelText(LABEL) as HTMLTextAreaElement; -} - describe("JsonObjectInput", () => { + it("names the editor for assistive tech", () => { + renderWithMantine(); + // Ace composes "