diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d583e5b3..5f5ead8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,6 +183,39 @@ jobs: - name: Test run: npm test --prefix js/packages/truapi-host + ts-react-renderer: + name: "@parity/truapi-react-renderer" + runs-on: ubuntu-latest + needs: codegen + env: + TRUAPI_REQUIRE_GENERATED: 1 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + + - name: Download codegen output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: codegen-output + + - name: Install + run: npm ci --ignore-scripts + + - name: Build + run: npm run build --prefix js/packages/truapi-react-renderer + + - name: Test + run: npm test --prefix js/packages/truapi-react-renderer + playground: name: Playground (build + lint) runs-on: ubuntu-latest diff --git a/.prettierignore b/.prettierignore index 5ae3704a..8b6b9717 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,3 +9,4 @@ playground/tsconfig.tsbuildinfo js/packages/truapi/dist/ js/packages/truapi/node_modules/ REVIEW_TODO*.md +js/packages/truapi-react-renderer/dist/ diff --git a/js/packages/truapi-react-renderer/.gitignore b/js/packages/truapi-react-renderer/.gitignore new file mode 100644 index 00000000..4d6f8eee --- /dev/null +++ b/js/packages/truapi-react-renderer/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.tsbuildinfo +dist/ diff --git a/js/packages/truapi-react-renderer/package.json b/js/packages/truapi-react-renderer/package.json new file mode 100644 index 00000000..b23cd680 --- /dev/null +++ b/js/packages/truapi-react-renderer/package.json @@ -0,0 +1,47 @@ +{ + "name": "@parity/truapi-react-renderer", + "version": "0.0.0", + "description": "React renderer for TrUAPI custom chat messages: maps a React component tree to CustomRendererNode widget trees", + "license": "MIT", + "author": "Parity Technologies ", + "repository": { + "type": "git", + "url": "git+https://github.com/paritytech/truapi.git", + "directory": "js/packages/truapi-react-renderer" + }, + "homepage": "https://github.com/paritytech/truapi#readme", + "bugs": { + "url": "https://github.com/paritytech/truapi/issues" + }, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -b", + "typecheck": "npm run build", + "test": "bun test" + }, + "dependencies": { + "@parity/truapi": "^0.7.0", + "react-reconciler": "0.33.0" + }, + "peerDependencies": { + "react": "^19.2.0" + }, + "devDependencies": { + "@types/bun": "^1.3.0", + "@types/react": "^19.0.0", + "@types/react-reconciler": "0.33.0", + "react": "^19.2.0", + "typescript": "^5.7" + } +} diff --git a/js/packages/truapi-react-renderer/src/components.test.tsx b/js/packages/truapi-react-renderer/src/components.test.tsx new file mode 100644 index 00000000..e767e23a --- /dev/null +++ b/js/packages/truapi-react-renderer/src/components.test.tsx @@ -0,0 +1,340 @@ +// @ts-expect-error Untyped +globalThis["IS_REACT_ACT_ENVIRONMENT"] = true; + +import { str } from "@parity/truapi/scale"; +import { describe, expect, it, mock } from "bun:test"; +import type { ReactNode } from "react"; +import { act } from "react"; + +import { + Box, + Button, + Column, + Row, + Spacer, + Text, + TextField, +} from "./components.js"; +import type { ActionCallback } from "./context.js"; +import { createRenderer } from "./renderer.js"; + +function makeActionBus() { + let listener: ActionCallback | undefined; + + const subscribeActions = mock((cb: ActionCallback) => { + listener = cb; + return () => { + listener = undefined; + }; + }); + + function dispatch(actionId: string, payload?: Uint8Array) { + listener?.(actionId, payload); + } + + return { subscribeActions, dispatch }; +} + +/** + * Mounts a single element and returns the serialized root node plus a dispatch helper. + */ +async function mount(element: ReactNode) { + const bus = makeActionBus(); + const onRender = mock((_node: unknown) => {}); + const renderer = createRenderer({ + onRender, + subscribeActions: bus.subscribeActions, + }); + + await act(async () => { + renderer.mount(element); + }); + + function lastNode() { + return onRender.mock.calls[onRender.mock.calls.length - 1]![0] as any; + } + + async function dispatchAction(actionId: string, payload?: Uint8Array) { + await act(async () => { + bus.dispatch(actionId, payload); + }); + } + + return { node: lastNode(), lastNode, dispatchAction, onRender, renderer }; +} + +describe("custom components", () => { + describe("Box", () => { + it("serializes contentAlignment prop", async () => { + const { node } = await mount(); + expect(node.tag).toBe("Box"); + expect(node.value.props.contentAlignment).toBe("Center"); + }); + + it("passes children through", async () => { + const { node } = await mount( + + child + , + ); + expect(node.value.children).toHaveLength(1); + expect(node.value.children[0].tag).toBe("Text"); + }); + }); + + describe("Column", () => { + it("serializes horizontalAlignment and verticalArrangement props", async () => { + const { node } = await mount( + , + ); + expect(node.tag).toBe("Column"); + expect(node.value.props.horizontalAlignment).toBe("Center"); + expect(node.value.props.verticalArrangement).toBe("SpaceBetween"); + }); + + it("passes children through", async () => { + const { node } = await mount( + + + + , + ); + expect(node.value.children).toHaveLength(2); + }); + }); + + describe("Row", () => { + it("serializes verticalAlignment and horizontalArrangement props", async () => { + const { node } = await mount( + , + ); + expect(node.tag).toBe("Row"); + expect(node.value.props.verticalAlignment).toBe("Center"); + expect(node.value.props.horizontalArrangement).toBe("End"); + }); + }); + + describe("Spacer", () => { + it("serializes as a Spacer node with no props", async () => { + const { node } = await mount(); + expect(node.tag).toBe("Spacer"); + }); + + it("forwards layout modifiers", async () => { + const { node } = await mount(); + const heightMod = (node.value.modifiers as any[]).find( + (m: any) => m.tag === "Height", + ); + expect(heightMod.value).toEqual({ height: 16 }); + }); + }); + + describe("Text", () => { + it("serializes style and color props", async () => { + const { node } = await mount( + , + ); + expect(node.tag).toBe("Text"); + expect(node.value.props.style).toBe("HeadlineLarge"); + expect(node.value.props.color).toBe("FgPrimary"); + }); + + it("renders text content as a String child", async () => { + const { node } = await mount(Hello world); + expect(node.value.children[0]).toEqual({ + tag: "String", + value: { text: "Hello world" }, + }); + }); + + it("forwards layout modifiers", async () => { + const { node } = await mount(hi); + const paddingMod = (node.value.modifiers as any[]).find( + (m: any) => m.tag === "Padding", + ); + expect(paddingMod.value).toEqual({ top: 8, end: 8 }); + }); + }); + + describe("Button", () => { + it("serializes text, variant, enabled and loading props", async () => { + const { node } = await mount( +