diff --git a/e2e/httpHighlight.fixture.ts b/e2e/httpHighlight.fixture.ts new file mode 100644 index 000000000..568db9312 --- /dev/null +++ b/e2e/httpHighlight.fixture.ts @@ -0,0 +1,55 @@ +import "../src/styles/tokens.css"; +import { EditorView } from "@codemirror/view"; +import { syntaxHighlighting } from "@codemirror/language"; +import { registerLanguageMode } from "../src/editor/api"; +import { openideaHighlightStyle } from "../src/editor/core/cm/highlight"; +import { findLanguageDescription, loadSupport } from "../src/editor/core/cm/languages"; +import { httpLanguageDescription } from "../src/features/http-client/language"; + +const params = new URLSearchParams(location.search); +document.documentElement.dataset.theme = params.get("theme") ?? "dark"; +document.body.style.backgroundColor = "var(--bg-editor)"; +document.body.style.color = "var(--fg-editor)"; +registerLanguageMode(httpLanguageDescription); +const file = params.has("rest") ? "requests.rest" : "requests.http"; +const support = await loadSupport(findLanguageDescription("HTTP", file)!); +const http = document.createElement("div"); +http.id = "http"; +document.body.append(http); +new EditorView({ + parent: http, + doc: `### gRPC request with a JSON body +GRPC localhost:9003/example.Greeter/SayHello +Content-Type: application/json + +{ + "name": "World", + "count": 12, + "enabled": true, + "empty": null, + "items": [1, 2, 3] +} + +### WebSocket message +WEBSOCKET ws://localhost:8080/events + +{"message": "hello", "ready": false} + +### Regular HTTP request +POST https://example.test/messages + +{"message": "HTTP still works", "count": 1}`, + extensions: [support!, syntaxHighlighting(openideaHighlightStyle)], +}); + +const json = document.createElement("div"); +json.id = "json"; +document.body.append(json); +new EditorView({ + parent: json, + doc: '{"name": "World", "count": 12, "enabled": true, "ready": false, "empty": null, "items": [1]}', + extensions: [ + await loadSupport(findLanguageDescription("JSON", "body.json")!), + syntaxHighlighting(openideaHighlightStyle), + ], +}); diff --git a/e2e/httpHighlight.html b/e2e/httpHighlight.html new file mode 100644 index 000000000..fab1a8bb3 --- /dev/null +++ b/e2e/httpHighlight.html @@ -0,0 +1,9 @@ + + +
+ + + + + + diff --git a/e2e/httpHighlight.spec.ts b/e2e/httpHighlight.spec.ts new file mode 100644 index 000000000..3ae6e7678 --- /dev/null +++ b/e2e/httpHighlight.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from "@playwright/test"; + +for (const theme of ["light", "dark"] as const) { + for (const extension of ["http", "rest"] as const) { + test(`highlights request lines and JSON bodies in ${extension} files (${theme})`, async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(`/e2e/httpHighlight.html?theme=${theme}${extension === "rest" ? "&rest" : ""}`); + const token = (text: string) => + page + .locator("#http .cm-line span") + .filter({ hasText: new RegExp(`^${text}$`) }) + .first(); + const keyword = theme === "light" ? "rgb(0, 51, 179)" : "rgb(207, 142, 109)"; + const property = theme === "light" ? "rgb(135, 16, 148)" : "rgb(199, 125, 187)"; + const string = theme === "light" ? "rgb(6, 125, 23)" : "rgb(106, 171, 115)"; + for (const method of ["GRPC", "WEBSOCKET", "POST"]) await expect(token(method)).toHaveCSS("color", keyword); + await expect(token('"name"')).toHaveCSS("color", property); + await expect(token('"World"')).toHaveCSS("color", string); + await expect(token("12")).toHaveCSS("color", theme === "light" ? "rgb(23, 80, 235)" : "rgb(42, 172, 184)"); + for (const literal of ["true", "false", "null"]) await expect(token(literal)).toHaveCSS("color", keyword); + await expect(token("Content-Type")).toHaveCSS("color", property); + for (const text of ['"name"', '"World"', "12", "true", "false", "null", "{", "[", ":", ","]) { + const color = async (editor: string) => + page.locator(`${editor} .cm-line`).evaluateAll((lines, target) => { + for (const line of lines) { + if (!/^\s*["{[]/.test(line.textContent ?? "")) continue; + const index = line.textContent?.indexOf(target) ?? -1; + if (index < 0) continue; + const walker = document.createTreeWalker(line, NodeFilter.SHOW_TEXT); + let offset = 0; + while (walker.nextNode()) { + const node = walker.currentNode; + if (offset + (node.textContent?.length ?? 0) > index) + return getComputedStyle(node.parentElement!).color; + offset += node.textContent?.length ?? 0; + } + } + return null; + }, text); + const jsonColor = await color("#json"); + expect(jsonColor, `JSON color for ${text}`).not.toBeNull(); + expect(await color("#http"), `HTTP color for ${text}`).toBe(jsonColor); + } + expect(errors).toEqual([]); + }); + } +} diff --git a/src/features/http-client/httpClient.test.ts b/src/features/http-client/httpClient.test.ts index 5bb4ba7c2..52a1114f5 100644 --- a/src/features/http-client/httpClient.test.ts +++ b/src/features/http-client/httpClient.test.ts @@ -79,6 +79,137 @@ describe("http highlighting", () => { return out; } + it.each([ + ["GRPC", "localhost:9003/example.Greeter/SayHello"], + ["WEBSOCKET", "ws://localhost:8080/events"], + ["PROPFIND", "https://example.test/collection"], + ["M-SEARCH", "http://example.test/devices"], + ])("highlights %s request lines and their JSON bodies", (method, url) => { + const t = tokens( + `### Request with body\n${method} ${url}\nContent-Type: application/json\n\n{"name": "World", "count": 12, "enabled": true}\n`, + ); + expect(t).toContainEqual(["keyword", method]); + expect(t).toContainEqual(["link", url]); + expect(t).toContainEqual(["propertyName", "Content-Type"]); + expect(t).toContainEqual(["propertyName", '"name"']); + expect(t).toContainEqual(["string", '"World"']); + expect(t).toContainEqual(["number", "12"]); + expect(t).toContainEqual(["bool", "true"]); + }); + + it.each(["https://example.test/items", "/items", "{{endpoint}}", "{{host}}/items"])( + "recognizes a bare request target %s before entering the body", + (url) => { + const t = tokens(`${url}\n\n{"count": 12, "enabled": false}\n`); + expect(t[0]?.[0]).toContain(url.startsWith("{{") ? "variableName" : "link"); + expect(t).toContainEqual(["propertyName", '"count"']); + expect(t).toContainEqual(["number", "12"]); + expect(t).toContainEqual(["bool", "false"]); + }, + ); + + it("resets request state across protocol changes and keeps variable declarations", () => { + const t = tokens( + '@host = localhost\n### RPC\nGRPC {{host}}:9003/example.Greeter/SayHello\n\n{"count": 1}\n### HTTP\nPOST https://example.test/messages\n\n{"count": 2}\n### Socket\nWEBSOCKET ws://{{host}}/events\n\n{"count": 3}', + ); + expect(t.filter(([name]) => name === "keyword").map(([, text]) => text)).toEqual(["GRPC", "POST", "WEBSOCKET"]); + expect(t.filter(([name]) => name === "number").map(([, text]) => text)).toEqual(["1", "2", "3"]); + expect(t.some(([name, text]) => name.includes("variableName") && text === "@host")).toBe(true); + }); + + it("distinguishes JSON keys, values, arrays and primitive literals", () => { + const t = tokens( + 'POST https://example.test/items\n\n{"name": "World", "fraction": -1.25e+3, "enabled": true, "empty": null, "items": [1, false, {"nested": "yes"}]}', + ); + expect(t).toContainEqual(["propertyName", '"name"']); + expect(t).toContainEqual(["string", '"World"']); + expect(t).toContainEqual(["propertyName", '"nested"']); + expect(t).toContainEqual(["number", "-1.25e+3"]); + expect(t).toContainEqual(["bool", "true"]); + expect(t).toContainEqual(["bool", "false"]); + expect(t).toContainEqual(["null", "null"]); + expect(t.some(([name, text]) => name === "brace" && text.includes("{"))).toBe(true); + expect(t).toContainEqual(["squareBracket", "["]); + expect(t.some(([name, text]) => name === "separator" && text.includes(":"))).toBe(true); + }); + + it.each(["TODO something", "HOST: x", "WORD", "TODO", "TODO ", "TODO something\nHOST: x"])( + "ignores non-request preamble lines: %s", + (preamble) => { + const t = tokens( + `${preamble}\nPOST https://example.test/items HTTP/1.1\nContent-Type: application/json\n\n{"ok": true}`, + ); + expect(t).toContainEqual(["keyword", "POST"]); + expect(t).not.toContainEqual(["keyword", "TODO"]); + expect(t).toContainEqual(["meta", " HTTP/1.1"]); + expect(t).toContainEqual(["propertyName", "Content-Type"]); + expect(t).toContainEqual(["propertyName", '"ok"']); + }, + ); + + it("keeps comments, continued queries and separator resets", () => { + const t = tokens( + "TODO something\n### Request\n# comment\n// comment\n{{host}}/items HTTP/1.1\n ?q={{query}}\n &limit=12\n# header comment\nAccept: application/json\n\n{}\n### Next\nGET /next\n\n[]", + ); + expect(t).toContainEqual(["meta", " HTTP/1.1"]); + expect(t).toContainEqual(["link", "/items"]); + expect(t.some(([name, text]) => name === "link" && text.includes("&limit=12"))).toBe(true); + expect(t).toContainEqual(["comment", "# header comment"]); + expect(t).toContainEqual(["propertyName", "Accept"]); + expect(t).toContainEqual(["keyword", "GET"]); + expect(t).toContainEqual(["link", "/next"]); + }); + + it("highlights variables inside JSON strings and unquoted values", () => { + const t = tokens('POST /items\n\n{"name": "Hello {{name}}!", "count": {{count}}, "escaped": "a\\\"b"}'); + for (const name of ["name", "count"]) expect(t).toContainEqual(["variableName.special", `{{${name}}}`]); + expect(t).toContainEqual(["string", '!"']); + expect(t).toContainEqual(["propertyName", '"escaped"']); + }); + + it("keeps advancing while a JSON string ends with an unfinished escape", () => { + expect(() => tokens('POST /items\n\n{"name": "unfinished' + "\\")).not.toThrow(); + }); + + it.each([ + ["application/x-www-form-urlencoded", "name=World&items=[one,two]&enabled=true"], + ["application/xml", '