ink-e2e.test.tsx 28 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093
  1. /**
  2. * End-to-end test: JSON spec → Ink terminal output.
  3. *
  4. * Exercises the full user-facing API:
  5. * 1. defineCatalog() with standard Ink component/action definitions
  6. * 2. Hand-authored Spec (same shape an LLM would generate)
  7. * 3. JSONUIProvider + Renderer → renderToString() → actual terminal text
  8. *
  9. * Every assertion checks real rendered output, not internal structures.
  10. */
  11. import React from "react";
  12. import { describe, it, expect } from "vitest";
  13. import { renderToString } from "ink";
  14. import {
  15. defineCatalog,
  16. buildUserPrompt,
  17. createStateStore,
  18. type Spec,
  19. } from "@json-render/core";
  20. import { schema } from "@json-render/ink/schema";
  21. import {
  22. standardComponentDefinitions,
  23. standardActionDefinitions,
  24. } from "@json-render/ink/catalog";
  25. import { JSONUIProvider, Renderer } from "@json-render/ink";
  26. function renderSpec(
  27. spec: Spec,
  28. opts: { columns?: number; initialState?: Record<string, unknown> } = {},
  29. ): string {
  30. return renderToString(
  31. <JSONUIProvider initialState={opts.initialState ?? spec.state ?? {}}>
  32. <Renderer spec={spec} />
  33. </JSONUIProvider>,
  34. { columns: opts.columns ?? 100 },
  35. );
  36. }
  37. const catalog = defineCatalog(schema, {
  38. components: standardComponentDefinitions,
  39. actions: standardActionDefinitions,
  40. });
  41. describe("ink e2e: catalog → prompt", () => {
  42. it("prompt includes all standard components", () => {
  43. const prompt = catalog.prompt();
  44. for (const name of Object.keys(standardComponentDefinitions)) {
  45. expect(prompt, `Missing component "${name}" in prompt`).toContain(name);
  46. }
  47. });
  48. it("prompt includes all 5 standard + built-in actions", () => {
  49. const prompt = catalog.prompt();
  50. for (const name of [
  51. "setState",
  52. "pushState",
  53. "removeState",
  54. "exit",
  55. "log",
  56. ]) {
  57. expect(prompt, `Missing action "${name}" in prompt`).toContain(name);
  58. }
  59. });
  60. it("buildUserPrompt embeds the user message", () => {
  61. const msg = buildUserPrompt({
  62. prompt: "Show me a deployment dashboard",
  63. });
  64. expect(msg).toContain("deployment dashboard");
  65. });
  66. });
  67. describe("ink e2e: simple spec → terminal output", () => {
  68. it("renders a Heading", () => {
  69. const spec: Spec = {
  70. root: "h",
  71. elements: {
  72. h: {
  73. type: "Heading",
  74. props: { text: "Hello Terminal", level: "h1" },
  75. children: [],
  76. },
  77. },
  78. };
  79. const output = renderSpec(spec);
  80. expect(output).toContain("Hello Terminal");
  81. });
  82. it("renders Text with content", () => {
  83. const spec: Spec = {
  84. root: "t",
  85. elements: {
  86. t: {
  87. type: "Text",
  88. props: { text: "plain text output" },
  89. children: [],
  90. },
  91. },
  92. };
  93. const output = renderSpec(spec);
  94. expect(output).toContain("plain text output");
  95. });
  96. it("renders a Badge", () => {
  97. const spec: Spec = {
  98. root: "b",
  99. elements: {
  100. b: {
  101. type: "Badge",
  102. props: { label: "ACTIVE", variant: "success" },
  103. children: [],
  104. },
  105. },
  106. };
  107. const output = renderSpec(spec);
  108. expect(output).toContain("ACTIVE");
  109. });
  110. it("renders a KeyValue pair", () => {
  111. const spec: Spec = {
  112. root: "kv",
  113. elements: {
  114. kv: {
  115. type: "KeyValue",
  116. props: { label: "Status", value: "Running" },
  117. children: [],
  118. },
  119. },
  120. };
  121. const output = renderSpec(spec);
  122. expect(output).toContain("Status");
  123. expect(output).toContain("Running");
  124. });
  125. it("renders a StatusLine with icon", () => {
  126. const spec: Spec = {
  127. root: "sl",
  128. elements: {
  129. sl: {
  130. type: "StatusLine",
  131. props: { text: "All checks passed", status: "success" },
  132. children: [],
  133. },
  134. },
  135. };
  136. const output = renderSpec(spec);
  137. expect(output).toContain("All checks passed");
  138. expect(output).toContain("✔");
  139. });
  140. it("renders a Divider with title", () => {
  141. const spec: Spec = {
  142. root: "d",
  143. elements: {
  144. d: {
  145. type: "Divider",
  146. props: { title: "Section", color: "gray" },
  147. children: [],
  148. },
  149. },
  150. };
  151. const output = renderSpec(spec);
  152. expect(output).toContain("Section");
  153. expect(output).toContain("─");
  154. });
  155. it("renders a List", () => {
  156. const spec: Spec = {
  157. root: "l",
  158. elements: {
  159. l: {
  160. type: "List",
  161. props: { items: ["Install", "Build", "Deploy"], ordered: true },
  162. children: [],
  163. },
  164. },
  165. };
  166. const output = renderSpec(spec);
  167. expect(output).toContain("1.");
  168. expect(output).toContain("Install");
  169. expect(output).toContain("2.");
  170. expect(output).toContain("Build");
  171. expect(output).toContain("3.");
  172. expect(output).toContain("Deploy");
  173. });
  174. it("renders a ProgressBar", () => {
  175. const spec: Spec = {
  176. root: "pb",
  177. elements: {
  178. pb: {
  179. type: "ProgressBar",
  180. props: { progress: 0.5, width: 20, label: "Upload" },
  181. children: [],
  182. },
  183. },
  184. };
  185. const output = renderSpec(spec);
  186. expect(output).toContain("Upload");
  187. expect(output).toContain("50%");
  188. expect(output).toContain("█");
  189. });
  190. });
  191. describe("ink e2e: nested layout → terminal output", () => {
  192. it("renders Box with children", () => {
  193. const spec: Spec = {
  194. root: "box",
  195. elements: {
  196. box: {
  197. type: "Box",
  198. props: { flexDirection: "column", gap: 1 },
  199. children: ["line1", "line2"],
  200. },
  201. line1: { type: "Text", props: { text: "First line" }, children: [] },
  202. line2: { type: "Text", props: { text: "Second line" }, children: [] },
  203. },
  204. };
  205. const output = renderSpec(spec);
  206. expect(output).toContain("First line");
  207. expect(output).toContain("Second line");
  208. });
  209. it("renders Card with title and nested content", () => {
  210. const spec: Spec = {
  211. root: "card",
  212. elements: {
  213. card: {
  214. type: "Card",
  215. props: { title: "Details", borderStyle: "round" },
  216. children: ["inner"],
  217. },
  218. inner: { type: "Text", props: { text: "Card body" }, children: [] },
  219. },
  220. };
  221. const output = renderSpec(spec);
  222. expect(output).toContain("Details");
  223. expect(output).toContain("Card body");
  224. // Round border chars
  225. expect(output).toContain("╭");
  226. expect(output).toContain("╰");
  227. });
  228. it("renders Table with headers and data", () => {
  229. const spec: Spec = {
  230. root: "tbl",
  231. elements: {
  232. tbl: {
  233. type: "Table",
  234. props: {
  235. columns: [
  236. { header: "Name", key: "name", width: 12 },
  237. { header: "Role", key: "role", width: 10 },
  238. ],
  239. rows: [
  240. { name: "Alice", role: "Admin" },
  241. { name: "Bob", role: "User" },
  242. ],
  243. },
  244. children: [],
  245. },
  246. },
  247. };
  248. const output = renderSpec(spec);
  249. expect(output).toContain("Name");
  250. expect(output).toContain("Role");
  251. expect(output).toContain("Alice");
  252. expect(output).toContain("Admin");
  253. expect(output).toContain("Bob");
  254. expect(output).toContain("User");
  255. });
  256. });
  257. describe("ink e2e: state binding → terminal output", () => {
  258. it("$state expression resolves to state value", () => {
  259. const spec: Spec = {
  260. state: { greeting: "Hello from state!" },
  261. root: "t",
  262. elements: {
  263. t: {
  264. type: "Text",
  265. props: { text: { $state: "/greeting" } },
  266. children: [],
  267. },
  268. },
  269. };
  270. const output = renderSpec(spec);
  271. expect(output).toContain("Hello from state!");
  272. });
  273. it("visibility hides elements when condition is false", () => {
  274. const spec: Spec = {
  275. state: { show: false },
  276. root: "box",
  277. elements: {
  278. box: {
  279. type: "Box",
  280. props: { flexDirection: "column" },
  281. children: ["always", "maybe"],
  282. },
  283. always: {
  284. type: "Text",
  285. props: { text: "Always visible" },
  286. children: [],
  287. },
  288. maybe: {
  289. type: "Text",
  290. props: { text: "Conditional text" },
  291. visible: { $state: "/show" },
  292. children: [],
  293. },
  294. },
  295. };
  296. const output = renderSpec(spec);
  297. expect(output).toContain("Always visible");
  298. expect(output).not.toContain("Conditional text");
  299. });
  300. it("visibility shows elements when condition is true", () => {
  301. const spec: Spec = {
  302. state: { show: true },
  303. root: "box",
  304. elements: {
  305. box: {
  306. type: "Box",
  307. props: { flexDirection: "column" },
  308. children: ["maybe"],
  309. },
  310. maybe: {
  311. type: "Text",
  312. props: { text: "Now you see me" },
  313. visible: { $state: "/show" },
  314. children: [],
  315. },
  316. },
  317. };
  318. const output = renderSpec(spec);
  319. expect(output).toContain("Now you see me");
  320. });
  321. it("$state equality condition works", () => {
  322. const spec: Spec = {
  323. state: { tab: "deploy" },
  324. root: "box",
  325. elements: {
  326. box: {
  327. type: "Box",
  328. props: { flexDirection: "column" },
  329. children: ["deploy-tab", "settings-tab"],
  330. },
  331. "deploy-tab": {
  332. type: "Text",
  333. props: { text: "Deploy panel" },
  334. visible: { $state: "/tab", eq: "deploy" },
  335. children: [],
  336. },
  337. "settings-tab": {
  338. type: "Text",
  339. props: { text: "Settings panel" },
  340. visible: { $state: "/tab", eq: "settings" },
  341. children: [],
  342. },
  343. },
  344. };
  345. const output = renderSpec(spec);
  346. expect(output).toContain("Deploy panel");
  347. expect(output).not.toContain("Settings panel");
  348. });
  349. });
  350. describe("ink e2e: repeat → terminal output", () => {
  351. it("repeat renders one row per array item with $item", () => {
  352. const spec: Spec = {
  353. state: {
  354. tasks: [
  355. { name: "Lint", done: true },
  356. { name: "Test", done: false },
  357. { name: "Build", done: true },
  358. ],
  359. },
  360. root: "list",
  361. elements: {
  362. list: {
  363. type: "Box",
  364. props: { flexDirection: "column" },
  365. repeat: { statePath: "/tasks" },
  366. children: ["row"],
  367. },
  368. row: {
  369. type: "ListItem",
  370. props: {
  371. title: { $item: "name" },
  372. trailing: { $item: "done" },
  373. leading: "•",
  374. },
  375. children: [],
  376. },
  377. },
  378. };
  379. const output = renderSpec(spec);
  380. expect(output).toContain("Lint");
  381. expect(output).toContain("Test");
  382. expect(output).toContain("Build");
  383. });
  384. });
  385. describe("ink e2e: full dashboard spec → terminal output", () => {
  386. const dashboardSpec: Spec = {
  387. state: {
  388. deployments: [
  389. { id: "dpl_abc", service: "api-server", status: "running" },
  390. { id: "dpl_def", service: "web-app", status: "building" },
  391. { id: "dpl_ghi", service: "worker", status: "error" },
  392. ],
  393. },
  394. root: "root",
  395. elements: {
  396. root: {
  397. type: "Box",
  398. props: { flexDirection: "column", padding: 1, gap: 1 },
  399. children: [
  400. "heading",
  401. "divider",
  402. "stats",
  403. "table",
  404. "progress",
  405. "repeats",
  406. ],
  407. },
  408. heading: {
  409. type: "Heading",
  410. props: { text: "Deployment Dashboard", level: "h1", color: "cyan" },
  411. children: [],
  412. },
  413. divider: {
  414. type: "Divider",
  415. props: { title: "Overview", color: "gray" },
  416. children: [],
  417. },
  418. stats: {
  419. type: "Box",
  420. props: { gap: 4 },
  421. children: ["kv-total", "kv-err", "badge"],
  422. },
  423. "kv-total": {
  424. type: "KeyValue",
  425. props: { label: "Total", value: "3", labelColor: "cyan" },
  426. children: [],
  427. },
  428. "kv-err": {
  429. type: "KeyValue",
  430. props: { label: "Errors", value: "1", labelColor: "red" },
  431. children: [],
  432. },
  433. badge: {
  434. type: "Badge",
  435. props: { label: "LIVE", variant: "success" },
  436. children: [],
  437. },
  438. table: {
  439. type: "Table",
  440. props: {
  441. columns: [
  442. { header: "Service", key: "service", width: 15 },
  443. { header: "Status", key: "status", width: 12 },
  444. ],
  445. rows: [
  446. { service: "api-server", status: "running" },
  447. { service: "web-app", status: "building" },
  448. { service: "worker", status: "error" },
  449. ],
  450. headerColor: "cyan",
  451. },
  452. children: [],
  453. },
  454. progress: {
  455. type: "ProgressBar",
  456. props: { progress: 0.85, width: 30, color: "green", label: "Deploy" },
  457. children: [],
  458. },
  459. repeats: {
  460. type: "Box",
  461. props: { flexDirection: "column" },
  462. repeat: { statePath: "/deployments", key: "id" },
  463. children: ["item"],
  464. },
  465. item: {
  466. type: "ListItem",
  467. props: {
  468. title: { $item: "service" },
  469. subtitle: { $item: "id" },
  470. leading: "▸",
  471. trailing: { $item: "status" },
  472. },
  473. children: [],
  474. },
  475. },
  476. };
  477. it("renders without throwing", () => {
  478. expect(() => renderSpec(dashboardSpec)).not.toThrow();
  479. });
  480. it("output contains heading text", () => {
  481. const output = renderSpec(dashboardSpec);
  482. expect(output).toContain("Deployment Dashboard");
  483. });
  484. it("output contains table data", () => {
  485. const output = renderSpec(dashboardSpec);
  486. expect(output).toContain("Service");
  487. expect(output).toContain("api-server");
  488. expect(output).toContain("web-app");
  489. expect(output).toContain("worker");
  490. });
  491. it("output contains key-value stats", () => {
  492. const output = renderSpec(dashboardSpec);
  493. expect(output).toContain("Total");
  494. expect(output).toContain("Errors");
  495. });
  496. it("output contains progress bar", () => {
  497. const output = renderSpec(dashboardSpec);
  498. expect(output).toContain("Deploy");
  499. expect(output).toContain("85%");
  500. expect(output).toContain("█");
  501. });
  502. it("output contains repeated deployment items", () => {
  503. const output = renderSpec(dashboardSpec);
  504. // $item expressions should resolve to actual data
  505. expect(output).toContain("api-server");
  506. expect(output).toContain("dpl_abc");
  507. expect(output).toContain("running");
  508. expect(output).toContain("web-app");
  509. expect(output).toContain("worker");
  510. });
  511. it("output contains badge", () => {
  512. const output = renderSpec(dashboardSpec);
  513. expect(output).toContain("LIVE");
  514. });
  515. it("output contains divider with title", () => {
  516. const output = renderSpec(dashboardSpec);
  517. expect(output).toContain("Overview");
  518. expect(output).toContain("─");
  519. });
  520. });
  521. describe("ink e2e: Markdown component", () => {
  522. it("renders bold text", () => {
  523. const spec: Spec = {
  524. root: "md",
  525. elements: {
  526. md: {
  527. type: "Markdown",
  528. props: { text: "This is **bold** text" },
  529. children: [],
  530. },
  531. },
  532. };
  533. const output = renderSpec(spec);
  534. expect(output).toContain("bold");
  535. expect(output).not.toContain("**");
  536. });
  537. it("renders italic text", () => {
  538. const spec: Spec = {
  539. root: "md",
  540. elements: {
  541. md: {
  542. type: "Markdown",
  543. props: { text: "This is *italic* text" },
  544. children: [],
  545. },
  546. },
  547. };
  548. const output = renderSpec(spec);
  549. expect(output).toContain("italic");
  550. expect(output).not.toContain("*italic*");
  551. });
  552. it("renders inline code", () => {
  553. const spec: Spec = {
  554. root: "md",
  555. elements: {
  556. md: {
  557. type: "Markdown",
  558. props: { text: "Run `npm install` to start" },
  559. children: [],
  560. },
  561. },
  562. };
  563. const output = renderSpec(spec);
  564. expect(output).toContain("`npm install`");
  565. });
  566. it("renders headings", () => {
  567. const spec: Spec = {
  568. root: "md",
  569. elements: {
  570. md: {
  571. type: "Markdown",
  572. props: { text: "# Main Title\n\n## Section" },
  573. children: [],
  574. },
  575. },
  576. };
  577. const output = renderSpec(spec);
  578. expect(output).toContain("Main Title");
  579. expect(output).toContain("Section");
  580. expect(output).not.toContain("#");
  581. });
  582. it("renders unordered lists", () => {
  583. const spec: Spec = {
  584. root: "md",
  585. elements: {
  586. md: {
  587. type: "Markdown",
  588. props: { text: "- First\n- Second\n- Third" },
  589. children: [],
  590. },
  591. },
  592. };
  593. const output = renderSpec(spec);
  594. expect(output).toContain("First");
  595. expect(output).toContain("Second");
  596. expect(output).toContain("Third");
  597. expect(output).toContain("•");
  598. });
  599. it("renders ordered lists", () => {
  600. const spec: Spec = {
  601. root: "md",
  602. elements: {
  603. md: {
  604. type: "Markdown",
  605. props: { text: "1. Alpha\n2. Beta\n3. Gamma" },
  606. children: [],
  607. },
  608. },
  609. };
  610. const output = renderSpec(spec);
  611. expect(output).toContain("Alpha");
  612. expect(output).toContain("Beta");
  613. expect(output).toContain("Gamma");
  614. });
  615. it("renders horizontal rules", () => {
  616. const spec: Spec = {
  617. root: "md",
  618. elements: {
  619. md: {
  620. type: "Markdown",
  621. props: { text: "Above\n\n---\n\nBelow" },
  622. children: [],
  623. },
  624. },
  625. };
  626. const output = renderSpec(spec);
  627. expect(output).toContain("Above");
  628. expect(output).toContain("Below");
  629. expect(output).toContain("─");
  630. });
  631. it("renders code blocks", () => {
  632. const spec: Spec = {
  633. root: "md",
  634. elements: {
  635. md: {
  636. type: "Markdown",
  637. props: { text: "```\nconst x = 42;\n```" },
  638. children: [],
  639. },
  640. },
  641. };
  642. const output = renderSpec(spec);
  643. expect(output).toContain("const x = 42;");
  644. expect(output).not.toContain("```");
  645. });
  646. it("handles mixed markdown from LLM-style text", () => {
  647. const spec: Spec = {
  648. root: "md",
  649. elements: {
  650. md: {
  651. type: "Markdown",
  652. props: {
  653. text:
  654. "Here's a summary with **key metrics** on performance:\n\n" +
  655. "- **Latency**: 45ms average\n" +
  656. "- **Uptime**: 99.9%\n" +
  657. "- **Requests**: 1.2M/day",
  658. },
  659. children: [],
  660. },
  661. },
  662. };
  663. const output = renderSpec(spec);
  664. expect(output).toContain("key metrics");
  665. expect(output).toContain("Latency");
  666. expect(output).toContain("45ms average");
  667. expect(output).not.toContain("**");
  668. });
  669. });
  670. describe("ink e2e: MultiSelect", () => {
  671. it("renders options with check indicators", () => {
  672. const spec: Spec = {
  673. root: "ms",
  674. elements: {
  675. ms: {
  676. type: "MultiSelect",
  677. props: {
  678. options: [
  679. { label: "TypeScript", value: "ts" },
  680. { label: "Python", value: "py" },
  681. { label: "Rust", value: "rs" },
  682. ],
  683. value: { $state: "/langs" },
  684. $bindState: { value: "/langs" },
  685. label: "Languages",
  686. },
  687. children: [],
  688. },
  689. },
  690. state: { langs: ["ts"] },
  691. };
  692. const output = renderSpec(spec);
  693. expect(output).toContain("Languages");
  694. expect(output).toContain("TypeScript");
  695. expect(output).toContain("Python");
  696. expect(output).toContain("Rust");
  697. // ts is selected
  698. expect(output).toContain("◉");
  699. // py and rs are not
  700. expect(output).toContain("◯");
  701. expect(output).toContain("Selected: 1");
  702. });
  703. it("renders with no selections", () => {
  704. const spec: Spec = {
  705. root: "ms",
  706. elements: {
  707. ms: {
  708. type: "MultiSelect",
  709. props: {
  710. options: [
  711. { label: "A", value: "a" },
  712. { label: "B", value: "b" },
  713. ],
  714. value: { $state: "/sel" },
  715. $bindState: { value: "/sel" },
  716. },
  717. children: [],
  718. },
  719. },
  720. state: { sel: [] },
  721. };
  722. const output = renderSpec(spec);
  723. expect(output).toContain("A");
  724. expect(output).toContain("B");
  725. expect(output).not.toContain("◉");
  726. expect(output).not.toContain("Selected:");
  727. });
  728. });
  729. describe("ink e2e: ConfirmInput", () => {
  730. it("renders confirmation prompt with message", () => {
  731. const spec: Spec = {
  732. root: "ci",
  733. elements: {
  734. ci: {
  735. type: "ConfirmInput",
  736. props: {
  737. message: "Delete all files?",
  738. },
  739. children: [],
  740. },
  741. },
  742. };
  743. const output = renderSpec(spec);
  744. expect(output).toContain("Delete all files?");
  745. expect(output).toContain("Yes");
  746. expect(output).toContain("No");
  747. expect(output).toContain("(y/n)");
  748. });
  749. it("renders with custom labels", () => {
  750. const spec: Spec = {
  751. root: "ci",
  752. elements: {
  753. ci: {
  754. type: "ConfirmInput",
  755. props: {
  756. message: "Continue?",
  757. yesLabel: "Proceed",
  758. noLabel: "Cancel",
  759. },
  760. children: [],
  761. },
  762. },
  763. };
  764. const output = renderSpec(spec);
  765. expect(output).toContain("Continue?");
  766. expect(output).toContain("Proceed");
  767. expect(output).toContain("Cancel");
  768. });
  769. });
  770. describe("ink e2e: Tabs", () => {
  771. it("renders tab bar with active indicator", () => {
  772. const spec: Spec = {
  773. root: "tabs",
  774. elements: {
  775. tabs: {
  776. type: "Tabs",
  777. props: {
  778. tabs: [
  779. { label: "Overview", value: "overview" },
  780. { label: "Logs", value: "logs" },
  781. { label: "Settings", value: "settings" },
  782. ],
  783. value: { $state: "/tab" },
  784. $bindState: { value: "/tab" },
  785. },
  786. children: ["content-overview"],
  787. },
  788. "content-overview": {
  789. type: "Text",
  790. props: { text: "Overview content here" },
  791. children: [],
  792. visible: { $state: "/tab", eq: "overview" },
  793. },
  794. },
  795. state: { tab: "overview" },
  796. };
  797. const output = renderSpec(spec);
  798. expect(output).toContain("Overview");
  799. expect(output).toContain("Logs");
  800. expect(output).toContain("Settings");
  801. expect(output).toContain("Overview content here");
  802. // Active tab indicator
  803. expect(output).toContain("━");
  804. });
  805. it("renders with icons", () => {
  806. const spec: Spec = {
  807. root: "tabs",
  808. elements: {
  809. tabs: {
  810. type: "Tabs",
  811. props: {
  812. tabs: [
  813. { label: "Home", value: "home", icon: "🏠" },
  814. { label: "Settings", value: "settings", icon: "⚙" },
  815. ],
  816. value: { $state: "/tab" },
  817. $bindState: { value: "/tab" },
  818. },
  819. children: [],
  820. },
  821. },
  822. state: { tab: "home" },
  823. };
  824. const output = renderSpec(spec);
  825. expect(output).toContain("Home");
  826. expect(output).toContain("Settings");
  827. });
  828. it("hides content for inactive tab via visible condition", () => {
  829. const spec: Spec = {
  830. root: "tabs",
  831. elements: {
  832. tabs: {
  833. type: "Tabs",
  834. props: {
  835. tabs: [
  836. { label: "A", value: "a" },
  837. { label: "B", value: "b" },
  838. ],
  839. value: { $state: "/tab" },
  840. $bindState: { value: "/tab" },
  841. },
  842. children: ["panel-a", "panel-b"],
  843. },
  844. "panel-a": {
  845. type: "Text",
  846. props: { text: "Panel A content" },
  847. children: [],
  848. visible: { $state: "/tab", eq: "a" },
  849. },
  850. "panel-b": {
  851. type: "Text",
  852. props: { text: "Panel B content" },
  853. children: [],
  854. visible: { $state: "/tab", eq: "b" },
  855. },
  856. },
  857. state: { tab: "a" },
  858. };
  859. const output = renderSpec(spec);
  860. expect(output).toContain("Panel A content");
  861. expect(output).not.toContain("Panel B content");
  862. });
  863. });
  864. describe("ink e2e: Sparkline", () => {
  865. it("renders sparkline blocks from data", () => {
  866. const spec: Spec = {
  867. root: "spark",
  868. elements: {
  869. spark: {
  870. type: "Sparkline",
  871. props: {
  872. data: [1, 5, 2, 8, 3, 7, 4],
  873. label: "CPU",
  874. color: "cyan",
  875. },
  876. children: [],
  877. },
  878. },
  879. };
  880. const output = renderSpec(spec);
  881. expect(output).toContain("CPU");
  882. // Should contain some block characters
  883. expect(output).toMatch(/[▁▂▃▄▅▆▇█]/);
  884. });
  885. it("handles single value", () => {
  886. const spec: Spec = {
  887. root: "spark",
  888. elements: {
  889. spark: {
  890. type: "Sparkline",
  891. props: { data: [5] },
  892. children: [],
  893. },
  894. },
  895. };
  896. const output = renderSpec(spec);
  897. expect(output).toMatch(/[▁▂▃▄▅▆▇█]/);
  898. });
  899. it("handles empty data gracefully", () => {
  900. const spec: Spec = {
  901. root: "spark",
  902. elements: {
  903. spark: {
  904. type: "Sparkline",
  905. props: { data: [], label: "Empty" },
  906. children: [],
  907. },
  908. },
  909. };
  910. const output = renderSpec(spec);
  911. expect(output).toContain("no data");
  912. });
  913. it("renders min/max sparkline with all equal values at top", () => {
  914. const spec: Spec = {
  915. root: "spark",
  916. elements: {
  917. spark: {
  918. type: "Sparkline",
  919. props: { data: [5, 5, 5] },
  920. children: [],
  921. },
  922. },
  923. };
  924. const output = renderSpec(spec);
  925. // All same value — should produce consistent blocks
  926. expect(output).toMatch(/[▁▂▃▄▅▆▇█]/);
  927. });
  928. });
  929. describe("ink e2e: BarChart", () => {
  930. it("renders horizontal bars with labels", () => {
  931. const spec: Spec = {
  932. root: "chart",
  933. elements: {
  934. chart: {
  935. type: "BarChart",
  936. props: {
  937. data: [
  938. { label: "TypeScript", value: 65, color: "blue" },
  939. { label: "Python", value: 20, color: "yellow" },
  940. { label: "Rust", value: 15, color: "red" },
  941. ],
  942. showPercentage: true,
  943. },
  944. children: [],
  945. },
  946. },
  947. };
  948. const output = renderSpec(spec);
  949. expect(output).toContain("TypeScript");
  950. expect(output).toContain("Python");
  951. expect(output).toContain("Rust");
  952. expect(output).toContain("█");
  953. expect(output).toContain("65%");
  954. expect(output).toContain("20%");
  955. expect(output).toContain("15%");
  956. });
  957. it("renders with showValues", () => {
  958. const spec: Spec = {
  959. root: "chart",
  960. elements: {
  961. chart: {
  962. type: "BarChart",
  963. props: {
  964. data: [
  965. { label: "A", value: 100 },
  966. { label: "B", value: 50 },
  967. ],
  968. showValues: true,
  969. width: 20,
  970. },
  971. children: [],
  972. },
  973. },
  974. };
  975. const output = renderSpec(spec);
  976. expect(output).toContain("100");
  977. expect(output).toContain("50");
  978. });
  979. it("handles empty data", () => {
  980. const spec: Spec = {
  981. root: "chart",
  982. elements: {
  983. chart: {
  984. type: "BarChart",
  985. props: { data: [] },
  986. children: [],
  987. },
  988. },
  989. };
  990. const output = renderSpec(spec);
  991. // Should render nothing (no crash)
  992. expect(output).toBe("");
  993. });
  994. });
  995. describe("ink e2e: state store round-trip", () => {
  996. it("createStateStore reads/writes state used by specs", () => {
  997. const store = createStateStore({ name: "Alice", count: 0 });
  998. expect(store.get("/name")).toBe("Alice");
  999. expect(store.get("/count")).toBe(0);
  1000. store.set("/count", 42);
  1001. expect(store.get("/count")).toBe(42);
  1002. store.set("/name", "Bob");
  1003. expect(store.get("/name")).toBe("Bob");
  1004. });
  1005. it("array manipulation (push/remove pattern)", () => {
  1006. const store = createStateStore({ items: ["a", "b", "c"] });
  1007. // Push
  1008. const arr = store.get("/items") as string[];
  1009. store.set("/items", [...arr, "d"]);
  1010. expect((store.get("/items") as string[]).length).toBe(4);
  1011. // Remove index 1
  1012. const arr2 = store.get("/items") as string[];
  1013. store.set(
  1014. "/items",
  1015. arr2.filter((_, i) => i !== 1),
  1016. );
  1017. expect(store.get("/items")).toEqual(["a", "c", "d"]);
  1018. });
  1019. });