transform.test.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. import { describe, it, expect } from "vitest";
  2. import { SPEC_DATA_PART_TYPE, type StreamChunk } from "@json-render/core";
  3. import { createYamlTransform } from "./transform";
  4. /** Helper: feed text chunks through the transform and collect output. */
  5. async function runTransform(
  6. lines: string[],
  7. options?: Parameters<typeof createYamlTransform>[0],
  8. ): Promise<StreamChunk[]> {
  9. const transform = createYamlTransform(options);
  10. const output: StreamChunk[] = [];
  11. // Build input chunks
  12. const inputChunks: StreamChunk[] = [
  13. { type: "text-start", id: "1" },
  14. ...lines.map((l) => ({ type: "text-delta" as const, id: "1", delta: l })),
  15. { type: "text-end", id: "1" },
  16. ];
  17. // Create a readable from the input chunks and pipe through
  18. const input = new ReadableStream<StreamChunk>({
  19. start(controller) {
  20. for (const chunk of inputChunks) {
  21. controller.enqueue(chunk);
  22. }
  23. controller.close();
  24. },
  25. });
  26. const outputStream = input.pipeThrough(transform);
  27. const reader = outputStream.getReader();
  28. while (true) {
  29. const { done, value } = await reader.read();
  30. if (done) break;
  31. output.push(value);
  32. }
  33. return output;
  34. }
  35. function extractPatches(output: StreamChunk[]) {
  36. return output
  37. .filter((c) => c.type === SPEC_DATA_PART_TYPE)
  38. .map((c) => (c as { data: { type: string; patch: unknown } }).data.patch);
  39. }
  40. function extractText(output: StreamChunk[]) {
  41. return output
  42. .filter((c) => c.type === "text-delta")
  43. .map((c) => (c as { delta: string }).delta)
  44. .join("");
  45. }
  46. describe("createYamlTransform", () => {
  47. it("passes through plain text outside fences", async () => {
  48. const output = await runTransform(["Hello world\n", "How are you?\n"]);
  49. const text = extractText(output);
  50. expect(text).toContain("Hello world");
  51. expect(text).toContain("How are you?");
  52. });
  53. it("parses yaml-spec fence into patches", async () => {
  54. const output = await runTransform([
  55. "Here is your UI:\n",
  56. "```yaml-spec\n",
  57. "root: main\n",
  58. "elements:\n",
  59. " main:\n",
  60. " type: Card\n",
  61. " props:\n",
  62. " title: Dashboard\n",
  63. " children: []\n",
  64. "```\n",
  65. ]);
  66. const patches = extractPatches(output);
  67. expect(patches.length).toBeGreaterThan(0);
  68. // Should have patched the root
  69. const rootPatch = patches.find(
  70. (p: any) => p.path === "/root" && p.value === "main",
  71. );
  72. expect(rootPatch).toBeDefined();
  73. // Text before the fence should pass through
  74. const text = extractText(output);
  75. expect(text).toContain("Here is your UI:");
  76. });
  77. it("parses yaml-edit fence with merge semantics", async () => {
  78. const previousSpec = {
  79. root: "main",
  80. elements: {
  81. main: {
  82. type: "Card",
  83. props: { title: "Old Title" },
  84. children: [],
  85. },
  86. },
  87. };
  88. // Only send a yaml-edit block — previousSpec is the base
  89. const output = await runTransform(
  90. [
  91. "```yaml-edit\n",
  92. "elements:\n",
  93. " main:\n",
  94. " props:\n",
  95. " title: New Title\n",
  96. "```\n",
  97. ],
  98. { previousSpec: previousSpec as any },
  99. );
  100. const patches = extractPatches(output);
  101. // Should have at least one patch
  102. expect(patches.length).toBeGreaterThan(0);
  103. // Should include a patch that updates the title — could be at
  104. // the leaf level or replacing the whole props object
  105. const hasTitleUpdate = patches.some(
  106. (p: any) =>
  107. (p.path === "/elements/main/props/title" && p.value === "New Title") ||
  108. (p.path === "/elements/main/props" &&
  109. (p.value as any)?.title === "New Title"),
  110. );
  111. expect(hasTitleUpdate).toBe(true);
  112. });
  113. it("swallows fence delimiters (not emitted as text)", async () => {
  114. const output = await runTransform([
  115. "```yaml-spec\n",
  116. "root: main\n",
  117. "```\n",
  118. ]);
  119. const text = extractText(output);
  120. expect(text).not.toContain("```yaml-spec");
  121. expect(text).not.toContain("```");
  122. });
  123. it("handles non-text chunks by passing them through", async () => {
  124. const transform = createYamlTransform();
  125. const input = new ReadableStream<StreamChunk>({
  126. start(controller) {
  127. controller.enqueue({ type: "tool-call", id: "t1", name: "getWeather" });
  128. controller.close();
  129. },
  130. });
  131. const reader = input.pipeThrough(transform).getReader();
  132. const { value } = await reader.read();
  133. expect(value).toEqual({
  134. type: "tool-call",
  135. id: "t1",
  136. name: "getWeather",
  137. });
  138. });
  139. });