diff --git a/.github/workflows/build-js.yml b/.github/workflows/build-js.yml
index a62ab9077..4d5c53955 100644
--- a/.github/workflows/build-js.yml
+++ b/.github/workflows/build-js.yml
@@ -15,10 +15,10 @@ jobs:
fetch-depth: 0
- uses: pnpm/action-setup@v2
with:
- version: 9.0.6
+ version: 11.0.0
- uses: actions/setup-node@v4
with:
- node-version: 20
+ node-version: 22
cache: pnpm
cache-dependency-path: "pnpm-lock.yaml"
@@ -41,10 +41,10 @@ jobs:
fetch-depth: 0
- uses: pnpm/action-setup@v2
with:
- version: 9.0.6
+ version: 11.0.0
- uses: actions/setup-node@v4
with:
- node-version: 20
+ node-version: 22
cache: pnpm
cache-dependency-path: "pnpm-lock.yaml"
@@ -53,3 +53,6 @@ jobs:
- name: Build examples
run: pnpm --filter "./examples/**" --workspace-concurrency=1 run build
+
+ - name: Test vue-chat
+ run: cd examples/vue-chat && pnpm nuxt prepare && pnpm exec vitest run
diff --git a/examples/vue-chat/README.md b/examples/vue-chat/README.md
index 631604a69..15d250029 100644
--- a/examples/vue-chat/README.md
+++ b/examples/vue-chat/README.md
@@ -13,7 +13,7 @@ A chat application built with [Nuxt 3](https://nuxt.com), [Vercel AI SDK](https:
### Prerequisites
-- Node.js 18+
+- Node.js 22+
- [pnpm](https://pnpm.io/)
- An OpenAI API key
@@ -48,11 +48,22 @@ The generated prompt lives at `generated/system-prompt.txt` and is checked in, s
### Run
```bash
+pnpm --filter vue-chat prepare
pnpm --filter vue-chat dev
```
Open [http://localhost:3000](http://localhost:3000).
+### Test
+
+```bash
+pnpm --filter vue-chat test
+```
+
+### Known issues
+
+- **Nuxt IPC socket error with `ssr: false`**: Nuxt 3.21.x has a bug where the dev server fails with `Vite Node IPC socket path not configured` in SPA mode. A workaround is included in `nuxt.config.ts` — see the comment there for details. This will be removed once the app upgrades to Nuxt 3.21.9+ where the bug is fixed upstream ([nuxt/nuxt#34957](https://github.com/nuxt/nuxt/issues/34957)).
+
## Project structure
```
@@ -70,9 +81,10 @@ components/
└── openui/ # Vue component renderers for openui-lang output
├── Stack.vue
├── Card.vue
- ├── TextContent.vue
+ ├── TextContent.vue # Renders text with markdown support
├── Button.vue
- └── Chart.vue
+ ├── Chart.vue
+ └── __tests__/ # Component unit tests
lib/
├── library.ts # OpenUI component definitions (Stack, Card, TextContent, Button, Chart)
└── tools.ts # AI tool definitions (weather, stocks, math, search)
diff --git a/examples/vue-chat/components/openui/TextContent.vue b/examples/vue-chat/components/openui/TextContent.vue
index 59a6f7a57..b1457dfae 100644
--- a/examples/vue-chat/components/openui/TextContent.vue
+++ b/examples/vue-chat/components/openui/TextContent.vue
@@ -1,7 +1,126 @@
- {{ props.text ?? "" }}
+
+
+
diff --git a/examples/vue-chat/components/openui/__tests__/TextContent.test.ts b/examples/vue-chat/components/openui/__tests__/TextContent.test.ts
new file mode 100644
index 000000000..ad5558072
--- /dev/null
+++ b/examples/vue-chat/components/openui/__tests__/TextContent.test.ts
@@ -0,0 +1,38 @@
+import { mount } from "@vue/test-utils";
+import { describe, expect, it } from "vitest";
+import { nextTick } from "vue";
+import TextContent from "../TextContent.vue";
+
+const mountText = (text: string) =>
+ mount(TextContent, {
+ props: { props: { text } },
+ });
+
+describe("TextContent", () => {
+ it("renders plain text", async () => {
+ const wrapper = mountText("Hello world");
+ await nextTick();
+ expect(wrapper.text()).toBe("Hello world");
+ });
+
+ it("renders bold markdown", async () => {
+ const wrapper = mountText("**bold**");
+ await nextTick();
+ expect(wrapper.find("strong").exists()).toBe(true);
+ expect(wrapper.find("strong").text()).toBe("bold");
+ });
+
+ it("renders inline code", async () => {
+ const wrapper = mountText("`code`");
+ await nextTick();
+ expect(wrapper.find("code").exists()).toBe(true);
+ expect(wrapper.find("code").text()).toBe("code");
+ });
+
+ it("escapes raw HTML", async () => {
+ const wrapper = mountText('');
+ await nextTick();
+ expect(wrapper.html()).not.toContain("