@stigmer/react 3.0.9-dev.20260615150714 → 3.0.9-dev.20260616060535

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/execution/ArtifactContentRenderer.d.ts.map +1 -1
  2. package/execution/ArtifactContentRenderer.js +7 -3
  3. package/execution/ArtifactContentRenderer.js.map +1 -1
  4. package/execution/ArtifactPreviewModal.d.ts +3 -3
  5. package/execution/ArtifactPreviewModal.js +4 -4
  6. package/execution/ArtifactPreviewModal.js.map +1 -1
  7. package/execution/MessageEntry.d.ts.map +1 -1
  8. package/execution/MessageEntry.js +7 -3
  9. package/execution/MessageEntry.js.map +1 -1
  10. package/execution/PlanArtifactCard.d.ts +25 -14
  11. package/execution/PlanArtifactCard.d.ts.map +1 -1
  12. package/execution/PlanArtifactCard.js +25 -10
  13. package/execution/PlanArtifactCard.js.map +1 -1
  14. package/execution/PlanCompletionCard.d.ts +12 -9
  15. package/execution/PlanCompletionCard.d.ts.map +1 -1
  16. package/execution/PlanCompletionCard.js +14 -9
  17. package/execution/PlanCompletionCard.js.map +1 -1
  18. package/execution/use-build-from-plan-hotkey.d.ts +19 -0
  19. package/execution/use-build-from-plan-hotkey.d.ts.map +1 -0
  20. package/execution/use-build-from-plan-hotkey.js +30 -0
  21. package/execution/use-build-from-plan-hotkey.js.map +1 -0
  22. package/internal/code-highlight.d.ts +24 -0
  23. package/internal/code-highlight.d.ts.map +1 -0
  24. package/internal/code-highlight.js +115 -0
  25. package/internal/code-highlight.js.map +1 -0
  26. package/internal/markdown-components.d.ts +18 -0
  27. package/internal/markdown-components.d.ts.map +1 -1
  28. package/internal/markdown-components.js +46 -2
  29. package/internal/markdown-components.js.map +1 -1
  30. package/package.json +7 -4
  31. package/src/execution/ArtifactContentRenderer.tsx +11 -2
  32. package/src/execution/ArtifactPreviewModal.tsx +7 -7
  33. package/src/execution/MessageEntry.tsx +14 -3
  34. package/src/execution/PlanArtifactCard.tsx +60 -51
  35. package/src/execution/PlanCompletionCard.tsx +20 -13
  36. package/src/execution/__tests__/ArtifactContentRenderer.test.tsx +62 -0
  37. package/src/execution/__tests__/ArtifactPreviewModal.test.tsx +6 -6
  38. package/src/execution/__tests__/MessageThread.test.tsx +3 -3
  39. package/src/execution/__tests__/PlanArtifactCard.test.tsx +120 -31
  40. package/src/execution/__tests__/PlanCompletionCard.test.tsx +31 -2
  41. package/src/execution/__tests__/message-entry.test.tsx +43 -0
  42. package/src/execution/use-build-from-plan-hotkey.ts +39 -0
  43. package/src/internal/__tests__/code-highlight.test.tsx +59 -0
  44. package/src/internal/__tests__/markdown-components.test.tsx +119 -0
  45. package/src/internal/code-highlight.tsx +120 -0
  46. package/src/internal/markdown-components.tsx +56 -3
  47. package/src/session/inspector/__tests__/ArtifactsTab.test.tsx +7 -7
  48. package/src/styles.css +88 -0
  49. package/styles.css +1 -1
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { render, screen, fireEvent, cleanup } from "@testing-library/react";
3
+ import { ArtifactContentRenderer } from "../ArtifactContentRenderer";
4
+
5
+ afterEach(cleanup);
6
+
7
+ describe("ArtifactContentRenderer — markdown", () => {
8
+ const wrappedPlan = "```markdown\n# Plan\n\n1. First\n2. Second\n```";
9
+
10
+ it("unwraps a model-wrapped ```markdown plan in the Rendered view", () => {
11
+ const { container } = render(
12
+ <ArtifactContentRenderer content={wrappedPlan} fileName="plan.md" />,
13
+ );
14
+
15
+ // Rendered is the default tab: the heading/list render as real elements and
16
+ // the plan is not trapped inside a single <pre>.
17
+ expect(container.querySelector("h1")).not.toBeNull();
18
+ expect(container.querySelector("ol")).not.toBeNull();
19
+ expect(container.querySelector("pre")).toBeNull();
20
+ });
21
+
22
+ it("keeps the Source view byte-faithful to the stored artifact", () => {
23
+ const { container } = render(
24
+ <ArtifactContentRenderer content={wrappedPlan} fileName="plan.md" />,
25
+ );
26
+
27
+ fireEvent.click(screen.getByRole("tab", { name: "Source" }));
28
+
29
+ // Source shows the raw bytes — including the enclosing fence we hid in the
30
+ // Rendered view — so a download/copy stays faithful to what the agent wrote.
31
+ const pre = container.querySelector("pre");
32
+ expect(pre).not.toBeNull();
33
+ expect(pre!.textContent).toContain("```markdown");
34
+ });
35
+
36
+ it("leaves an already-rich markdown plan unchanged", () => {
37
+ const { container } = render(
38
+ <ArtifactContentRenderer
39
+ content={"# Plan\n\n- only step"}
40
+ fileName="plan.md"
41
+ />,
42
+ );
43
+
44
+ expect(container.querySelector("h1")).not.toBeNull();
45
+ expect(container.querySelector("pre")).toBeNull();
46
+ });
47
+
48
+ it("syntax-highlights fenced code blocks via the shared seam", () => {
49
+ const md = "# Notes\n\n```go\nfunc main() {}\n```\n";
50
+ const { container } = render(
51
+ <ArtifactContentRenderer content={md} fileName="notes.md" />,
52
+ );
53
+
54
+ // Same shared `MARKDOWN_COMPONENTS.code` override the chat stream uses, so
55
+ // the react-markdown artifact path colorizes code identically.
56
+ const code = container.querySelector("code.hljs");
57
+ expect(code).not.toBeNull();
58
+ expect(
59
+ container.querySelectorAll('span[class*="hljs-"]').length,
60
+ ).toBeGreaterThan(0);
61
+ });
62
+ });
@@ -27,8 +27,8 @@ function withStigmer(children: ReactNode) {
27
27
 
28
28
  afterEach(cleanup);
29
29
 
30
- describe("ArtifactPreviewContent — Implement action", () => {
31
- it("renders an Implement button only when onImplement is provided", () => {
30
+ describe("ArtifactPreviewContent — Build from plan action", () => {
31
+ it("renders a 'Build from plan' button only when onImplement is provided", () => {
32
32
  const { rerender } = render(
33
33
  withStigmer(
34
34
  <ArtifactPreviewContent
@@ -40,7 +40,7 @@ describe("ArtifactPreviewContent — Implement action", () => {
40
40
  />,
41
41
  ),
42
42
  );
43
- expect(screen.queryByText("Implement")).toBeNull();
43
+ expect(screen.queryByText("Build from plan")).toBeNull();
44
44
 
45
45
  rerender(
46
46
  withStigmer(
@@ -54,10 +54,10 @@ describe("ArtifactPreviewContent — Implement action", () => {
54
54
  />,
55
55
  ),
56
56
  );
57
- expect(screen.getByText("Implement")).toBeTruthy();
57
+ expect(screen.getByText("Build from plan")).toBeTruthy();
58
58
  });
59
59
 
60
- it("calls onImplement then closes the modal when Implement is clicked", () => {
60
+ it("calls onImplement then closes the modal when 'Build from plan' is clicked", () => {
61
61
  const calls: string[] = [];
62
62
  const onImplement = vi.fn(() => calls.push("implement"));
63
63
  const onClose = vi.fn(() => calls.push("close"));
@@ -75,7 +75,7 @@ describe("ArtifactPreviewContent — Implement action", () => {
75
75
  ),
76
76
  );
77
77
 
78
- fireEvent.click(screen.getByText("Implement"));
78
+ fireEvent.click(screen.getByText("Build from plan"));
79
79
 
80
80
  expect(onImplement).toHaveBeenCalledTimes(1);
81
81
  expect(onClose).toHaveBeenCalledTimes(1);
@@ -476,10 +476,10 @@ describe("MessageThread", () => {
476
476
  />,
477
477
  );
478
478
 
479
- const implementBtn = screen.getByRole("button", { name: /implement/i });
480
- expect(implementBtn).toBeTruthy();
479
+ const buildBtn = screen.getByRole("button", { name: /build from plan/i });
480
+ expect(buildBtn).toBeTruthy();
481
481
 
482
- fireEvent.click(implementBtn);
482
+ fireEvent.click(buildBtn);
483
483
  expect(onBuildFromPlan).toHaveBeenCalledOnce();
484
484
  });
485
485
  });
@@ -17,36 +17,49 @@ const planArtifact = create(ExecutionArtifactSchema, {
17
17
  });
18
18
 
19
19
  /** Modal content fetches artifact text — keep it pending so nothing rejects. */
20
- function createStigmerMock(): Stigmer {
20
+ function createStigmerMock(): { stigmer: Stigmer; getArtifactContent: ReturnType<typeof vi.fn> } {
21
+ const getArtifactContent = vi.fn().mockReturnValue(new Promise(() => {}));
21
22
  return {
22
- agentExecution: {
23
- getArtifactContent: vi.fn().mockReturnValue(new Promise(() => {})),
24
- },
25
- } as unknown as Stigmer;
23
+ stigmer: {
24
+ agentExecution: { getArtifactContent },
25
+ } as unknown as Stigmer,
26
+ getArtifactContent,
27
+ };
26
28
  }
27
29
 
28
- function withStigmer(children: ReactNode) {
30
+ function withStigmer(children: ReactNode, stigmer: Stigmer) {
29
31
  return (
30
- <StigmerContext.Provider value={createStigmerMock()}>
31
- {children}
32
- </StigmerContext.Provider>
32
+ <StigmerContext.Provider value={stigmer}>{children}</StigmerContext.Provider>
33
33
  );
34
34
  }
35
35
 
36
36
  afterEach(cleanup);
37
37
 
38
- describe("PlanArtifactCard", () => {
39
- it("renders the review header with the artifact name and size", () => {
38
+ describe("PlanArtifactCard — action surface", () => {
39
+ it("is an accessible region labelled as the plan's actions, with name + size", () => {
40
40
  render(
41
41
  <PlanArtifactCard executionId="aex_1" artifact={planArtifact} org="acme" />,
42
42
  );
43
43
 
44
- const region = screen.getByRole("region", { name: "Plan ready to review" });
45
- expect(region.textContent).toContain("Plan ready to review");
46
- expect(region.textContent).toContain("plan.md");
44
+ const region = screen.getByRole("region", { name: "Plan actions" });
45
+ expect(region.textContent).toContain("Plan");
46
+ expect(region.textContent).toContain("KB");
47
47
  });
48
48
 
49
- it("calls onImplement when Implement is clicked", () => {
49
+ it("does NOT render the plan content (the message above is the document)", () => {
50
+ const { stigmer, getArtifactContent } = createStigmerMock();
51
+ render(
52
+ withStigmer(
53
+ <PlanArtifactCard executionId="aex_1" artifact={planArtifact} org="acme" />,
54
+ stigmer,
55
+ ),
56
+ );
57
+ // The card is a pure action surface — it must not fetch the plan body until
58
+ // the user explicitly opens the full preview.
59
+ expect(getArtifactContent).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it("offers exactly one primary 'Build from plan' action that calls onImplement", () => {
50
63
  const onImplement = vi.fn();
51
64
  render(
52
65
  <PlanArtifactCard
@@ -57,18 +70,18 @@ describe("PlanArtifactCard", () => {
57
70
  />,
58
71
  );
59
72
 
60
- fireEvent.click(screen.getByText("Implement"));
73
+ fireEvent.click(screen.getByText("Build from plan"));
61
74
  expect(onImplement).toHaveBeenCalledTimes(1);
62
75
  });
63
76
 
64
- it("hides Implement when onImplement is not provided", () => {
77
+ it("hides 'Build from plan' when onImplement is not provided", () => {
65
78
  render(
66
79
  <PlanArtifactCard executionId="aex_1" artifact={planArtifact} org="acme" />,
67
80
  );
68
- expect(screen.queryByText("Implement")).toBeNull();
81
+ expect(screen.queryByText("Build from plan")).toBeNull();
69
82
  });
70
83
 
71
- it("disables Implement when disabled", () => {
84
+ it("disables 'Build from plan' when disabled", () => {
72
85
  const onImplement = vi.fn();
73
86
  render(
74
87
  <PlanArtifactCard
@@ -80,7 +93,7 @@ describe("PlanArtifactCard", () => {
80
93
  />,
81
94
  );
82
95
 
83
- const button = screen.getByText("Implement").closest("button")!;
96
+ const button = screen.getByText("Build from plan").closest("button")!;
84
97
  expect(button.disabled).toBe(true);
85
98
  fireEvent.click(button);
86
99
  expect(onImplement).not.toHaveBeenCalled();
@@ -93,30 +106,106 @@ describe("PlanArtifactCard", () => {
93
106
  const link = screen.getByText("Download").closest("a")!;
94
107
  expect(link.getAttribute("href")).toBe("https://example.test/plan.md");
95
108
  });
109
+ });
96
110
 
97
- it("opens the shared preview modal when Review plan is clicked", () => {
111
+ describe("PlanArtifactCard Cmd/Ctrl+Enter accelerator (card-scoped)", () => {
112
+ it("fires onImplement on Cmd+Enter from within the card", () => {
113
+ const onImplement = vi.fn();
114
+ render(
115
+ <PlanArtifactCard
116
+ executionId="aex_1"
117
+ artifact={planArtifact}
118
+ org="acme"
119
+ onImplement={onImplement}
120
+ />,
121
+ );
122
+
123
+ const region = screen.getByRole("region", { name: "Plan actions" });
124
+ fireEvent.keyDown(region, { key: "Enter", metaKey: true });
125
+ expect(onImplement).toHaveBeenCalledTimes(1);
126
+ });
127
+
128
+ it("fires onImplement on Ctrl+Enter", () => {
129
+ const onImplement = vi.fn();
130
+ render(
131
+ <PlanArtifactCard
132
+ executionId="aex_1"
133
+ artifact={planArtifact}
134
+ org="acme"
135
+ onImplement={onImplement}
136
+ />,
137
+ );
138
+
139
+ const region = screen.getByRole("region", { name: "Plan actions" });
140
+ fireEvent.keyDown(region, { key: "Enter", ctrlKey: true });
141
+ expect(onImplement).toHaveBeenCalledTimes(1);
142
+ });
143
+
144
+ it("ignores a plain Enter (no modifier) so it never hijacks typing", () => {
145
+ const onImplement = vi.fn();
146
+ render(
147
+ <PlanArtifactCard
148
+ executionId="aex_1"
149
+ artifact={planArtifact}
150
+ org="acme"
151
+ onImplement={onImplement}
152
+ />,
153
+ );
154
+
155
+ const region = screen.getByRole("region", { name: "Plan actions" });
156
+ fireEvent.keyDown(region, { key: "Enter" });
157
+ expect(onImplement).not.toHaveBeenCalled();
158
+ });
159
+
160
+ it("does not fire when disabled", () => {
161
+ const onImplement = vi.fn();
162
+ render(
163
+ <PlanArtifactCard
164
+ executionId="aex_1"
165
+ artifact={planArtifact}
166
+ org="acme"
167
+ onImplement={onImplement}
168
+ disabled
169
+ />,
170
+ );
171
+
172
+ const region = screen.getByRole("region", { name: "Plan actions" });
173
+ fireEvent.keyDown(region, { key: "Enter", metaKey: true });
174
+ expect(onImplement).not.toHaveBeenCalled();
175
+ });
176
+ });
177
+
178
+ describe("PlanArtifactCard — Open full (org-gated preview)", () => {
179
+ it("opens the shared preview modal when 'Open full' is clicked", () => {
180
+ const { stigmer } = createStigmerMock();
98
181
  render(
99
182
  withStigmer(
100
- <PlanArtifactCard
101
- executionId="aex_1"
102
- artifact={planArtifact}
103
- org="acme"
104
- />,
183
+ <PlanArtifactCard executionId="aex_1" artifact={planArtifact} org="acme" />,
184
+ stigmer,
105
185
  ),
106
186
  );
107
187
 
108
- // No dialog before the user reviews.
188
+ // No dialog before the user opens it.
109
189
  expect(document.querySelector("dialog")).toBeNull();
110
190
 
111
- fireEvent.click(screen.getByText("Review plan"));
191
+ fireEvent.click(screen.getByText("Open full"));
112
192
 
113
193
  const dialog = document.querySelector("dialog");
114
194
  expect(dialog).toBeTruthy();
115
195
  expect(dialog!.getAttribute("aria-label")).toBe("Preview plan.md");
116
196
  });
117
197
 
118
- it("hides Review plan when org is absent (modal needs org)", () => {
119
- render(<PlanArtifactCard executionId="aex_1" artifact={planArtifact} />);
120
- expect(screen.queryByText("Review plan")).toBeNull();
198
+ it("hides 'Open full' when org is absent (modal needs org), keeping Build + Download", () => {
199
+ const onImplement = vi.fn();
200
+ render(
201
+ <PlanArtifactCard
202
+ executionId="aex_1"
203
+ artifact={planArtifact}
204
+ onImplement={onImplement}
205
+ />,
206
+ );
207
+ expect(screen.queryByText("Open full")).toBeNull();
208
+ expect(screen.queryByText("Build from plan")).not.toBeNull();
209
+ expect(screen.queryByText("Download")).not.toBeNull();
121
210
  });
122
211
  });
@@ -3,7 +3,7 @@ import { render, fireEvent, cleanup } from "@testing-library/react";
3
3
  import { PlanCompletionCard } from "../PlanCompletionCard";
4
4
 
5
5
  describe("PlanCompletionCard", () => {
6
- it("renders the card with status text and implement button", () => {
6
+ it("renders the card with status text and a 'Build from plan' button", () => {
7
7
  const { container } = render(<PlanCompletionCard onImplement={() => {}} />);
8
8
 
9
9
  const root = container.firstElementChild as HTMLElement;
@@ -13,12 +13,41 @@ describe("PlanCompletionCard", () => {
13
13
 
14
14
  const button = root.querySelector("button");
15
15
  expect(button).toBeTruthy();
16
- expect(button!.textContent).toContain("Implement");
16
+ expect(button!.textContent).toContain("Build from plan");
17
17
 
18
18
  expect(root.textContent).toContain("Plan complete");
19
19
  cleanup();
20
20
  });
21
21
 
22
+ it("fires onImplement on Cmd/Ctrl+Enter from within the card", () => {
23
+ const onImplement = vi.fn();
24
+ const { container } = render(
25
+ <PlanCompletionCard onImplement={onImplement} />,
26
+ );
27
+
28
+ const root = container.firstElementChild as HTMLElement;
29
+ fireEvent.keyDown(root, { key: "Enter", metaKey: true });
30
+ fireEvent.keyDown(root, { key: "Enter", ctrlKey: true });
31
+ expect(onImplement).toHaveBeenCalledTimes(2);
32
+
33
+ // A plain Enter must not trigger it.
34
+ fireEvent.keyDown(root, { key: "Enter" });
35
+ expect(onImplement).toHaveBeenCalledTimes(2);
36
+ cleanup();
37
+ });
38
+
39
+ it("does not fire the accelerator when disabled", () => {
40
+ const onImplement = vi.fn();
41
+ const { container } = render(
42
+ <PlanCompletionCard onImplement={onImplement} disabled />,
43
+ );
44
+
45
+ const root = container.firstElementChild as HTMLElement;
46
+ fireEvent.keyDown(root, { key: "Enter", metaKey: true });
47
+ expect(onImplement).not.toHaveBeenCalled();
48
+ cleanup();
49
+ });
50
+
22
51
  it("calls onImplement when the button is clicked", () => {
23
52
  const onImplement = vi.fn();
24
53
  const { container } = render(<PlanCompletionCard onImplement={onImplement} />);
@@ -126,6 +126,49 @@ describe("MessageEntry — AI messages (Streamdown)", () => {
126
126
  const prose = container.querySelector(".stgm-prose");
127
127
  expect(prose).not.toBeNull();
128
128
  });
129
+
130
+ it("renders a model-wrapped ```markdown plan as rich markdown, not a code block", () => {
131
+ const msg = makeMessage(
132
+ MessageType.MESSAGE_AI,
133
+ "```markdown\n# Plan\n\n1. First step\n2. Second step\n```",
134
+ );
135
+ const { container } = render(<MessageEntry message={msg} />);
136
+
137
+ const article = queryArticle(container, "AI response");
138
+ // The enclosing fence is unwrapped: the heading and list render as elements,
139
+ // and the whole plan is NOT trapped inside a single <pre>.
140
+ expect(article!.querySelector("h1")).not.toBeNull();
141
+ expect(article!.querySelector("ol")).not.toBeNull();
142
+ expect(article!.querySelector("pre")).toBeNull();
143
+ expect(article!.textContent).toContain("Plan");
144
+ });
145
+
146
+ it("still renders a genuine ```js code block as a code block", () => {
147
+ const msg = makeMessage(
148
+ MessageType.MESSAGE_AI,
149
+ "```js\nconsole.log('hi');\n```",
150
+ );
151
+ const { container } = render(<MessageEntry message={msg} />);
152
+
153
+ const article = queryArticle(container, "AI response");
154
+ expect(article!.querySelector("pre")).not.toBeNull();
155
+ });
156
+
157
+ it("syntax-highlights fenced code in the chat stream (Streamdown path)", () => {
158
+ const msg = makeMessage(
159
+ MessageType.MESSAGE_AI,
160
+ "```go\nfunc main() {}\n```",
161
+ );
162
+ const { container } = render(<MessageEntry message={msg} />);
163
+
164
+ const article = queryArticle(container, "AI response");
165
+ // Streamdown routes fenced code through the shared MARKDOWN_COMPONENTS.code
166
+ // override, so chat highlights identically to the artifact viewer.
167
+ expect(article!.querySelector("code.hljs")).not.toBeNull();
168
+ expect(
169
+ article!.querySelectorAll('span[class*="hljs-"]').length,
170
+ ).toBeGreaterThan(0);
171
+ });
129
172
  });
130
173
 
131
174
  // ---------------------------------------------------------------------------
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import { useCallback, type KeyboardEvent } from "react";
4
+
5
+ /**
6
+ * Card-scoped `Cmd/Ctrl+Enter` accelerator for the "Build from plan" action.
7
+ *
8
+ * Returns an `onKeyDown` handler for a plan card's root element. Because it is
9
+ * attached to the card (not `window`), it fires only when the keystroke
10
+ * originates from within the card — so the SDK never installs a global listener
11
+ * that could hijack a host application's keyboard shortcuts (an embeddable-a11y
12
+ * requirement). The card's primary button stays natively activatable with
13
+ * Enter/Space when focused; this adds the power-user accelerator on top.
14
+ *
15
+ * No-op when there is no action wired or the action is disabled.
16
+ *
17
+ * Shared by {@link PlanArtifactCard} and {@link PlanCompletionCard} so the
18
+ * accelerator's behavior lives in exactly one place and cannot drift between
19
+ * the two cards.
20
+ */
21
+ export function useBuildFromPlanHotkey(
22
+ onImplement: (() => void) | undefined,
23
+ disabled: boolean | undefined,
24
+ ): (event: KeyboardEvent<HTMLElement>) => void {
25
+ return useCallback(
26
+ (event) => {
27
+ if (
28
+ onImplement &&
29
+ !disabled &&
30
+ event.key === "Enter" &&
31
+ (event.metaKey || event.ctrlKey)
32
+ ) {
33
+ event.preventDefault();
34
+ onImplement();
35
+ }
36
+ },
37
+ [onImplement, disabled],
38
+ );
39
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { render, cleanup } from "@testing-library/react";
3
+ import { resolveLanguage, highlightToReact } from "../code-highlight";
4
+
5
+ afterEach(cleanup);
6
+
7
+ describe("resolveLanguage", () => {
8
+ it.each([
9
+ ["a registered name", "go", "go"],
10
+ ["uppercase is normalized", "GO", "go"],
11
+ ["surrounding whitespace is trimmed", " yaml ", "yaml"],
12
+ ["a registered alias (ts → typescript)", "ts", "ts"],
13
+ ["a registered alias (tsx)", "tsx", "tsx"],
14
+ ["a registered alias (html → xml)", "html", "html"],
15
+ ["a registered alias (sh → bash)", "sh", "sh"],
16
+ ])("resolves %s", (_label, input, expected) => {
17
+ expect(resolveLanguage(input)).toBe(expected);
18
+ });
19
+
20
+ it.each([
21
+ ["an unregistered language", "hcl"],
22
+ ["a nonsense language", "klingon"],
23
+ ["an empty string", ""],
24
+ ["whitespace only", " "],
25
+ ["undefined", undefined],
26
+ ])("returns null for %s", (_label, input) => {
27
+ expect(resolveLanguage(input)).toBeNull();
28
+ });
29
+ });
30
+
31
+ describe("highlightToReact", () => {
32
+ it("produces hljs token spans for a known language", () => {
33
+ const node = highlightToReact("func main() {}", "go");
34
+ expect(node).not.toBeNull();
35
+
36
+ const { container } = render(<code>{node}</code>);
37
+ // `func` is a Go keyword → at least one tokenized span must appear.
38
+ expect(
39
+ container.querySelectorAll('span[class*="hljs-"]').length,
40
+ ).toBeGreaterThan(0);
41
+ });
42
+
43
+ it("highlights via an alias (ts)", () => {
44
+ const node = highlightToReact("const x: number = 1;", "ts");
45
+ expect(node).not.toBeNull();
46
+
47
+ const { container } = render(<code>{node}</code>);
48
+ expect(
49
+ container.querySelectorAll('span[class*="hljs-"]').length,
50
+ ).toBeGreaterThan(0);
51
+ });
52
+
53
+ it.each([
54
+ ["an unregistered language", "hcl"],
55
+ ["no language", undefined],
56
+ ])("returns null for %s (caller falls back to flat)", (_label, language) => {
57
+ expect(highlightToReact("anything at all", language)).toBeNull();
58
+ });
59
+ });
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import { render, cleanup } from "@testing-library/react";
3
+ import type { ComponentType, ReactNode } from "react";
4
+ import {
5
+ MARKDOWN_COMPONENTS,
6
+ unwrapEnclosingMarkdownFence,
7
+ } from "../markdown-components";
8
+
9
+ afterEach(cleanup);
10
+
11
+ /** The shared `code` override, typed for direct rendering in tests. */
12
+ const CodeComponent = MARKDOWN_COMPONENTS.code as ComponentType<{
13
+ className?: string;
14
+ children?: ReactNode;
15
+ }>;
16
+
17
+ describe("unwrapEnclosingMarkdownFence", () => {
18
+ describe("unwraps a whole-message markdown fence", () => {
19
+ it.each([
20
+ ["```markdown tag", "```markdown\n# Plan\n- step\n```", "# Plan\n- step"],
21
+ ["```md tag", "```md\n# Plan\n```", "# Plan"],
22
+ ["case-insensitive tag", "```Markdown\n# Plan\n```", "# Plan"],
23
+ ["uppercase md", "```MD\n# Plan\n```", "# Plan"],
24
+ [
25
+ "tilde-free body with inner code fence preserved",
26
+ "```markdown\n# Plan\n```ts\nconst x = 1;\n```\n```",
27
+ "# Plan\n```ts\nconst x = 1;\n```",
28
+ ],
29
+ [
30
+ "surrounding whitespace is ignored",
31
+ "\n\n```markdown\n# Plan\n```\n\n",
32
+ "# Plan",
33
+ ],
34
+ [
35
+ "longer-than-three backtick fence",
36
+ "````markdown\n# Plan\n````",
37
+ "# Plan",
38
+ ],
39
+ ])("%s", (_label, input, expected) => {
40
+ expect(unwrapEnclosingMarkdownFence(input)).toBe(expected);
41
+ });
42
+ });
43
+
44
+ describe("leaves everything else untouched (returns the original)", () => {
45
+ it.each([
46
+ ["plain markdown (no fence)", "# Plan\n\n- step one\n- step two"],
47
+ ["a bare ``` fence (ambiguous — could be code)", "```\n# Plan\n```"],
48
+ ["a language-tagged code block", "```js\nconsole.log('hi');\n```"],
49
+ ["an unclosed fence (mid-stream)", "```markdown\n# Plan in progr"],
50
+ ["trailing content after the fence", "```markdown\n# Plan\n```\nthen more"],
51
+ ["leading content before the fence", "intro\n```markdown\n# Plan\n```"],
52
+ ["a richer info string than markdown", "```markdown title\n# Plan\n```"],
53
+ ["an empty fenced block", "```markdown\n```"],
54
+ ["plain prose", "Here is the plan you asked for."],
55
+ ["empty string", ""],
56
+ ])("%s", (_label, input) => {
57
+ expect(unwrapEnclosingMarkdownFence(input)).toBe(input);
58
+ });
59
+ });
60
+
61
+ it("is idempotent — a once-unwrapped plan is left alone", () => {
62
+ const wrapped = "```markdown\n# Plan\n- a\n```";
63
+ const once = unwrapEnclosingMarkdownFence(wrapped);
64
+ expect(unwrapEnclosingMarkdownFence(once)).toBe(once);
65
+ });
66
+ });
67
+
68
+ describe("MARKDOWN_COMPONENTS.code (shared highlight seam)", () => {
69
+ it("syntax-highlights a fenced block with a known language", () => {
70
+ const { container } = render(
71
+ <CodeComponent className="language-go">
72
+ {"func main() {}"}
73
+ </CodeComponent>,
74
+ );
75
+
76
+ const code = container.querySelector("code");
77
+ expect(code).not.toBeNull();
78
+ expect(code!.className).toContain("hljs");
79
+ expect(code!.className).toContain("language-go");
80
+ expect(
81
+ container.querySelectorAll('span[class*="hljs-"]').length,
82
+ ).toBeGreaterThan(0);
83
+ });
84
+
85
+ it("falls back to flat text for an unknown language (no token spans)", () => {
86
+ const { container } = render(
87
+ <CodeComponent className="language-hcl">
88
+ {'resource "x" {}'}
89
+ </CodeComponent>,
90
+ );
91
+
92
+ const code = container.querySelector("code");
93
+ expect(code).not.toBeNull();
94
+ expect(code!.textContent).toContain('resource "x" {}');
95
+ expect(container.querySelectorAll('span[class*="hljs-"]').length).toBe(0);
96
+ });
97
+
98
+ it("falls back to flat for non-string children (e.g. a streaming caret)", () => {
99
+ const { container } = render(
100
+ <CodeComponent className="language-go">
101
+ <span data-testid="caret">x</span>
102
+ </CodeComponent>,
103
+ );
104
+
105
+ // Renders children untouched, no throw, no tokenization attempted.
106
+ expect(container.querySelector('[data-testid="caret"]')).not.toBeNull();
107
+ expect(container.querySelectorAll('span[class*="hljs-"]').length).toBe(0);
108
+ });
109
+
110
+ it("leaves inline code unhighlighted and unboxed by hljs", () => {
111
+ const { container } = render(<CodeComponent>{"inlineToken"}</CodeComponent>);
112
+
113
+ const code = container.querySelector("code");
114
+ expect(code).not.toBeNull();
115
+ expect(code!.className).not.toContain("hljs");
116
+ expect(code!.className).toContain("bg-muted");
117
+ expect(code!.textContent).toBe("inlineToken");
118
+ });
119
+ });