diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 510def7..fe3a64e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,4 +1,4 @@
-name: Unit Test
+name: CI
on:
push:
@@ -13,6 +13,31 @@ jobs:
unit-test:
uses: sxzz/workflows/.github/workflows/unit-test.yml@main
+ e2e:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - name: Cache Playwright browsers
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
+ - run: pnpm exec playwright install --with-deps chromium
+ - run: pnpm test:e2e
+ - if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report
+ path: playwright-report/
+ retention-days: 7
+
# coverage:
# uses: sxzz/workflows/.github/workflows/coverage.yml@main
# needs: unit-test
diff --git a/.gitignore b/.gitignore
index fadaea2..0f7cea9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,7 @@ temp
**/.vitepress/cache
**/.vitepress/dist
packages/devframe/skills
+test-results
+playwright-report
+playwright/.cache
+blob-report
diff --git a/docs/guide/built-with.md b/docs/guide/built-with.md
index a7ba144..ef770aa 100644
--- a/docs/guide/built-with.md
+++ b/docs/guide/built-with.md
@@ -12,6 +12,5 @@ Real-world devtools shipping on Devframe:
End-to-end examples in this repo, exercising the full adapter surface:
-- [**counter**](https://github.com/devframes/devframe/tree/main/examples/counter) — smallest possible demo, exercises all adapters.
- [**files-inspector**](https://github.com/devframes/devframe/tree/main/examples/files-inspector) — lists files in cwd via RPC; exercises CLI dev/build/spa surfaces.
- [**streaming-chat**](https://github.com/devframes/devframe/tree/main/examples/streaming-chat) — streams synthetic chat tokens from server to client via `ctx.rpc.streaming`.
diff --git a/examples/counter/bin.mjs b/examples/counter/bin.mjs
deleted file mode 100755
index e760d99..0000000
--- a/examples/counter/bin.mjs
+++ /dev/null
@@ -1,14 +0,0 @@
-#!/usr/bin/env node
-import process from 'node:process'
-import { createCli } from 'devframe/adapters/cli'
-import devframe from './src/devframe.ts'
-
-async function main() {
- const cli = createCli(devframe)
- await cli.parse()
-}
-
-main().catch((error) => {
- console.error(error)
- process.exit(1)
-})
diff --git a/examples/counter/package.json b/examples/counter/package.json
deleted file mode 100644
index 2d8bde8..0000000
--- a/examples/counter/package.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "name": "counter-example",
- "type": "module",
- "version": "0.2.0",
- "private": true,
- "description": "Smallest end-to-end devframe demo — exercises all six adapters.",
- "main": "src/devframe.ts",
- "bin": {
- "counter": "./bin.mjs"
- },
- "scripts": {
- "dev": "node bin.mjs",
- "build": "node bin.mjs build --out-dir dist-static"
- },
- "dependencies": {
- "devframe": "workspace:*"
- }
-}
diff --git a/examples/counter/src/client/index.html b/examples/counter/src/client/index.html
deleted file mode 100644
index 5c7d472..0000000
--- a/examples/counter/src/client/index.html
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
- Devframe Counter
-
-
- Counter
- …
-
-
-
-
diff --git a/examples/counter/src/client/main.ts b/examples/counter/src/client/main.ts
deleted file mode 100644
index 68a6e33..0000000
--- a/examples/counter/src/client/main.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { connectDevframe } from 'devframe/client'
-
-async function main() {
- const rpc = await connectDevframe()
-
- async function refresh() {
- const result = await rpc.call('devframe-counter:get' as any)
- document.getElementById('count')!.textContent = String((result as any).count)
- }
-
- document.getElementById('bump')!.addEventListener('click', async () => {
- await rpc.call('devframe-counter:increment' as any)
- await refresh()
- })
-
- await refresh()
-}
-
-main().catch((error) => {
- console.error(error)
-})
diff --git a/examples/counter/src/devframe.ts b/examples/counter/src/devframe.ts
deleted file mode 100644
index add4dc4..0000000
--- a/examples/counter/src/devframe.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import process from 'node:process'
-import { defineDevframe, defineRpcFunction } from 'devframe'
-import * as v from 'valibot'
-
-let counter = 0
-let watchInterval: ReturnType | undefined
-
-export default defineDevframe({
- id: 'devframe-counter',
- name: 'Devframe Counter',
- icon: 'ph:counter-duotone',
- async setup(ctx) {
- // Static snapshot — included in the static-build dump.
- ctx.rpc.register(defineRpcFunction({
- name: 'devframe-counter:get',
- type: 'static',
- handler: () => ({ count: counter }),
- }))
-
- // Action with valibot-validated input.
- ctx.rpc.register(defineRpcFunction({
- name: 'devframe-counter:increment',
- type: 'action',
- args: [v.object({ by: v.optional(v.number()) })],
- handler: ({ by = 1 }: { by?: number }) => {
- counter += by
- return { count: counter }
- },
- }))
-
- // Reactive shared state — clients see updates live via WS.
- const state = await ctx.rpc.sharedState.get(
- 'devframe-counter:value' as any,
- { initialValue: { count: counter } as any },
- )
-
- // File-watch-style auto-increment — the inspector-shape tick source
- // that validates the shared-state broadcast path end-to-end.
- if (ctx.mode === 'dev') {
- watchInterval = setInterval(() => {
- counter += 1
- state.mutate((draft: any) => {
- draft.count = counter
- })
- }, 5000)
- }
-
- ctx.docks.register({
- id: 'devframe-counter',
- title: 'Counter',
- icon: 'ph:counter-duotone',
- type: 'iframe',
- url: '/devframe-counter/',
- })
- },
-
- // Browser-only setup for the SPA adapter — in-browser RPC handler so
- // the deployed static SPA can answer `devframe-counter:get` without a
- // server. (Stub until the SPA adapter lands.)
- setupBrowser() {
- // no-op placeholder
- },
-
- cli: {
- command: 'devframe-counter',
- port: 9999,
- },
- spa: {
- loader: 'query',
- },
-})
-
-// Graceful shutdown so nodemon / parent processes don't hang.
-process.on('beforeExit', () => {
- if (watchInterval)
- clearInterval(watchInterval)
-})
diff --git a/examples/files-inspector/src/devframe.ts b/examples/files-inspector/src/devframe.ts
index 05eb8f9..13819ea 100644
--- a/examples/files-inspector/src/devframe.ts
+++ b/examples/files-inspector/src/devframe.ts
@@ -1,3 +1,4 @@
+import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { defineRpcFunction } from 'devframe'
import { defineDevframe } from 'devframe/types'
@@ -18,11 +19,13 @@ export default defineDevframe({
},
spa: { loader: 'none' },
async setup(ctx) {
+ const targetCwd = process.env.DEVFRAME_E2E_CWD || ctx.cwd
+
ctx.rpc.register(defineRpcFunction({
name: 'devframe-files-inspector:get-cwd',
type: 'static',
jsonSerializable: true,
- handler: () => ({ cwd: ctx.cwd }),
+ handler: () => ({ cwd: targetCwd }),
}))
ctx.rpc.register(defineRpcFunction({
@@ -30,7 +33,7 @@ export default defineDevframe({
type: 'query',
jsonSerializable: true,
handler: async () => {
- const files = await glob(['*'], { cwd: ctx.cwd, onlyFiles: true, dot: false })
+ const files = await glob(['*'], { cwd: targetCwd, onlyFiles: true, dot: false })
return files.map(f => f.replace(/\\/g, '/')).sort()
},
snapshot: true,
diff --git a/examples/streaming-chat/package.json b/examples/streaming-chat/package.json
index 59f0011..ea61d88 100644
--- a/examples/streaming-chat/package.json
+++ b/examples/streaming-chat/package.json
@@ -10,6 +10,7 @@
},
"scripts": {
"build": "vite build --config src/client/vite.config.ts",
+ "cli:build": "node bin.mjs build --out-dir dist/static",
"dev": "node bin.mjs",
"test": "vitest run"
},
diff --git a/package.json b/package.json
index 661707f..c400fce 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,8 @@
"docs:serve": "pnpm -C docs run docs:serve",
"lint": "eslint --cache",
"test": "turbo run build && vitest",
+ "test:e2e": "turbo run build && playwright test",
+ "test:e2e:ui": "turbo run build && playwright test --ui",
"release": "bumpp -r",
"typecheck": "tsc -b",
"postinstall": "npx simple-git-hooks && skills-npm"
@@ -29,6 +31,7 @@
"@antfu/eslint-config": "catalog:devtools",
"@antfu/ni": "catalog:build",
"@antfu/utils": "catalog:inlined",
+ "@playwright/test": "catalog:testing",
"@types/node": "catalog:types",
"@types/ws": "catalog:types",
"bumpp": "catalog:devtools",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..040a6c3
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,64 @@
+import process from 'node:process'
+import { fileURLToPath } from 'node:url'
+import { defineConfig, devices } from '@playwright/test'
+
+const fixtureCwd = fileURLToPath(new URL('./tests/e2e/fixtures', import.meta.url))
+const serveStatic = fileURLToPath(new URL('./tests/e2e/_support/serve-static.mjs', import.meta.url))
+
+export default defineConfig({
+ testDir: './tests/e2e',
+ testIgnore: ['_support/**'],
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ reporter: process.env.CI ? [['github'], ['html']] : 'list',
+ use: {
+ trace: 'on-first-retry',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+ webServer: [
+ {
+ command: 'node bin.mjs',
+ cwd: 'examples/files-inspector',
+ env: { DEVFRAME_E2E_CWD: fixtureCwd },
+ url: 'http://localhost:9876/__devframe-files-inspector/',
+ timeout: 60_000,
+ reuseExistingServer: !process.env.CI,
+ stdout: 'pipe',
+ stderr: 'pipe',
+ },
+ {
+ command: 'node bin.mjs',
+ cwd: 'examples/streaming-chat',
+ url: 'http://localhost:9897/__devframe-streaming-chat/',
+ timeout: 60_000,
+ reuseExistingServer: !process.env.CI,
+ stdout: 'pipe',
+ stderr: 'pipe',
+ },
+ {
+ command: `node bin.mjs build --out-dir dist/static && node ${JSON.stringify(serveStatic)} dist/static 9886`,
+ cwd: 'examples/files-inspector',
+ env: { DEVFRAME_E2E_CWD: fixtureCwd },
+ url: 'http://127.0.0.1:9886/',
+ timeout: 60_000,
+ reuseExistingServer: !process.env.CI,
+ stdout: 'pipe',
+ stderr: 'pipe',
+ },
+ {
+ command: `node bin.mjs build --out-dir dist/static && node ${JSON.stringify(serveStatic)} dist/static 9898`,
+ cwd: 'examples/streaming-chat',
+ url: 'http://127.0.0.1:9898/',
+ timeout: 60_000,
+ reuseExistingServer: !process.env.CI,
+ stdout: 'pipe',
+ stderr: 'pipe',
+ },
+ ],
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 766af06..940ae2e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -147,6 +147,9 @@ catalogs:
specifier: ^0.1.1
version: 0.1.1
testing:
+ '@playwright/test':
+ specifier: ^1.50.0
+ version: 1.60.0
tsnapi:
specifier: ^0.3.3
version: 0.3.3
@@ -178,6 +181,9 @@ importers:
'@antfu/utils':
specifier: catalog:inlined
version: 9.3.0
+ '@playwright/test':
+ specifier: catalog:testing
+ version: 1.60.0
'@types/node':
specifier: catalog:types
version: 25.7.0
@@ -239,12 +245,6 @@ importers:
specifier: catalog:docs
version: 2.0.17(mermaid@11.15.0)(vitepress@2.0.0-alpha.17(@types/node@25.7.0)(change-case@5.4.4)(fuse.js@7.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(oxc-minify@0.128.0)(postcss@8.5.14)(terser@5.47.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.4))
- examples/counter:
- dependencies:
- devframe:
- specifier: workspace:*
- version: link:../../packages/devframe
-
examples/files-inspector:
dependencies:
devframe:
@@ -2027,6 +2027,11 @@ packages:
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
+ '@playwright/test@1.60.0':
+ resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -4143,6 +4148,11 @@ packages:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -5234,6 +5244,16 @@ packages:
pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
+ playwright-core@1.60.0:
+ resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.60.0:
+ resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
@@ -8048,6 +8068,10 @@ snapshots:
'@pkgr/core@0.2.9': {}
+ '@playwright/test@1.60.0':
+ dependencies:
+ playwright: 1.60.0
+
'@polka/url@1.0.0-next.29': {}
'@poppinss/colors@4.1.6':
@@ -10247,6 +10271,9 @@ snapshots:
fresh@2.0.0: {}
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -11775,6 +11802,14 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
+ playwright-core@1.60.0: {}
+
+ playwright@1.60.0:
+ dependencies:
+ playwright-core: 1.60.0
+ optionalDependencies:
+ fsevents: 2.3.2
+
pluralize@8.0.0: {}
pnpm-workspace-yaml@1.6.0:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 5e37495..96cc869 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -72,6 +72,7 @@ catalogs:
human-id: ^4.1.3
ua-parser-modern: ^0.1.1
testing:
+ '@playwright/test': ^1.50.0
tsnapi: ^0.3.3
vitest: ^4.1.6
types:
diff --git a/tests/e2e/_support/serve-static.mjs b/tests/e2e/_support/serve-static.mjs
new file mode 100644
index 0000000..b39a70a
--- /dev/null
+++ b/tests/e2e/_support/serve-static.mjs
@@ -0,0 +1,75 @@
+#!/usr/bin/env node
+import { createReadStream, statSync } from 'node:fs'
+import { createServer } from 'node:http'
+import { extname, isAbsolute, join, normalize, relative, resolve } from 'node:path'
+import process from 'node:process'
+
+const [, , dirArg, portArg] = process.argv
+if (!dirArg || !portArg) {
+ console.error('Usage: serve-static.mjs ')
+ process.exit(1)
+}
+
+const root = resolve(process.cwd(), dirArg)
+const port = Number(portArg)
+const host = '127.0.0.1'
+
+const MIME = {
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'text/javascript; charset=utf-8',
+ '.mjs': 'text/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.svg': 'image/svg+xml',
+ '.png': 'image/png',
+ '.ico': 'image/x-icon',
+ '.map': 'application/json; charset=utf-8',
+}
+
+function resolveAsset(urlPath) {
+ // Strip query/hash.
+ const clean = urlPath.split('?')[0].split('#')[0]
+ const decoded = decodeURIComponent(clean)
+ // Treat trailing-slash URLs as index.html.
+ const requestPath = decoded.endsWith('/') ? `${decoded}index.html` : decoded
+ const target = normalize(join(root, requestPath))
+ const fromRoot = relative(root, target)
+ if (fromRoot.startsWith('..') || isAbsolute(fromRoot))
+ return null
+ try {
+ const st = statSync(target)
+ if (st.isFile())
+ return target
+ }
+ catch {}
+ return null
+}
+
+function send(res, status, body, headers = {}) {
+ res.writeHead(status, headers)
+ res.end(body)
+}
+
+const server = createServer((req, res) => {
+ const found = resolveAsset(req.url ?? '/')
+ // SPA fallback — unknown routes (no file extension match) serve index.html
+ // so client-side routing works under the static dump.
+ const file = found ?? resolveAsset('/index.html')
+ if (!file) {
+ send(res, 404, 'Not Found')
+ return
+ }
+ const type = MIME[extname(file)] ?? 'application/octet-stream'
+ res.writeHead(200, { 'Content-Type': type })
+ createReadStream(file).pipe(res)
+})
+
+server.listen(port, host, () => {
+ process.stdout.write(`[serve-static] http://${host}:${port}/ -> ${root}\n`)
+})
+
+for (const sig of ['SIGINT', 'SIGTERM']) {
+ process.on(sig, () => {
+ server.close(() => process.exit(0))
+ })
+}
diff --git a/tests/e2e/files-inspector-dev.spec.ts b/tests/e2e/files-inspector-dev.spec.ts
new file mode 100644
index 0000000..626ffa1
--- /dev/null
+++ b/tests/e2e/files-inspector-dev.spec.ts
@@ -0,0 +1,28 @@
+import { expect, test } from '@playwright/test'
+
+const BASE = 'http://localhost:9876/__devframe-files-inspector/'
+
+test.describe('files-inspector (dev)', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(BASE)
+ await expect(page.locator('h1')).toHaveText('Files Inspector')
+ })
+
+ test('lists fixture files on home', async ({ page }) => {
+ await expect(page.locator('section h2')).toContainText('Files')
+ await expect(page.locator('section h2 small')).toHaveText('(3)')
+ await expect(page.locator('section ul li')).toHaveText([
+ 'README.md',
+ 'package.json',
+ 'sample.txt',
+ ])
+ })
+
+ test('navigates to about and shows cwd', async ({ page }) => {
+ await page.click('a:has-text("About")')
+ await expect(page.locator('section h2')).toHaveText('About')
+
+ const cwdValue = page.locator('dt:has-text("Server cwd") + dd code')
+ await expect(cwdValue).toContainText(/fixtures$/)
+ })
+})
diff --git a/tests/e2e/files-inspector-static.spec.ts b/tests/e2e/files-inspector-static.spec.ts
new file mode 100644
index 0000000..2c1e24a
--- /dev/null
+++ b/tests/e2e/files-inspector-static.spec.ts
@@ -0,0 +1,31 @@
+import { expect, test } from '@playwright/test'
+
+const BASE = 'http://127.0.0.1:9886/'
+
+test.describe('files-inspector (static build)', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(BASE)
+ await expect(page.locator('h1')).toHaveText('Files Inspector')
+ })
+
+ test('renders the file list from the static RPC dump', async ({ page }) => {
+ await expect(page.locator('section h2 small')).toHaveText('(3)')
+ await expect(page.locator('section ul li')).toHaveText([
+ 'README.md',
+ 'package.json',
+ 'sample.txt',
+ ])
+ })
+
+ test('reports static backend on the About page', async ({ page }) => {
+ await page.click('a:has-text("About")')
+ await expect(page.locator('section h2')).toHaveText('About')
+
+ await expect(
+ page.locator('dt:has-text("RPC backend") + dd code'),
+ ).toHaveText('static')
+ await expect(
+ page.locator('dt:has-text("Server cwd") + dd code'),
+ ).toContainText(/fixtures$/)
+ })
+})
diff --git a/tests/e2e/fixtures/README.md b/tests/e2e/fixtures/README.md
new file mode 100644
index 0000000..9741694
--- /dev/null
+++ b/tests/e2e/fixtures/README.md
@@ -0,0 +1 @@
+# fixture
diff --git a/tests/e2e/fixtures/package.json b/tests/e2e/fixtures/package.json
new file mode 100644
index 0000000..63824cd
--- /dev/null
+++ b/tests/e2e/fixtures/package.json
@@ -0,0 +1 @@
+{ "name": "fixture" }
diff --git a/tests/e2e/fixtures/sample.txt b/tests/e2e/fixtures/sample.txt
new file mode 100644
index 0000000..ce01362
--- /dev/null
+++ b/tests/e2e/fixtures/sample.txt
@@ -0,0 +1 @@
+hello
diff --git a/tests/e2e/streaming-chat-dev.spec.ts b/tests/e2e/streaming-chat-dev.spec.ts
new file mode 100644
index 0000000..d7e204d
--- /dev/null
+++ b/tests/e2e/streaming-chat-dev.spec.ts
@@ -0,0 +1,47 @@
+import { expect, test } from '@playwright/test'
+
+const BASE = 'http://localhost:9897/__devframe-streaming-chat/'
+
+// Shared server-side history means parallel browsers see each other's
+// messages — pin the suite to serial so each test starts from a clean
+// `clear()` and exits with its stream settled.
+test.describe.configure({ mode: 'serial' })
+
+test.describe('streaming-chat (dev)', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(BASE)
+ await expect(page.locator('h1')).toHaveText('Streaming Chat')
+ await expect(page.locator('.demo-prompts button').first()).toBeVisible()
+
+ const clearBtn = page.locator('.toolbar button:has-text("Clear")')
+ if (await clearBtn.isEnabled())
+ await clearBtn.click()
+ await expect(page.locator('.msg')).toHaveCount(0)
+ })
+
+ test('demo prompt streams tokens into a message', async ({ page }) => {
+ await page.click('.demo-prompts button:has-text("Write a haiku about RPC.")')
+
+ await expect(page.locator('.msg-user').last())
+ .toHaveText('Write a haiku about RPC.')
+
+ await expect(page.locator('.msg-assistant').last())
+ .toContainText('Tiny chunks arrive', { timeout: 10_000 })
+
+ await expect(page.locator('.msg-assistant.streaming')).toHaveCount(0, {
+ timeout: 10_000,
+ })
+ })
+
+ test('clear button resets history', async ({ page }) => {
+ await page.click('.demo-prompts button:has-text("Write a haiku about RPC.")')
+ await expect(page.locator('.msg-assistant').last())
+ .toContainText('Tiny chunks arrive', { timeout: 10_000 })
+ await expect(page.locator('.msg-assistant.streaming')).toHaveCount(0)
+
+ await page.locator('.toolbar button:has-text("Clear")').click()
+
+ await expect(page.locator('.msg')).toHaveCount(0)
+ await expect(page.locator('.status')).toContainText('0 messages')
+ })
+})
diff --git a/tests/e2e/streaming-chat-static.spec.ts b/tests/e2e/streaming-chat-static.spec.ts
new file mode 100644
index 0000000..1867c8f
--- /dev/null
+++ b/tests/e2e/streaming-chat-static.spec.ts
@@ -0,0 +1,30 @@
+import { expect, test } from '@playwright/test'
+
+const BASE = 'http://127.0.0.1:9898/'
+
+// Static dumps only carry pre-computed `static` / `query{snapshot:true}`
+// RPC results. streaming-chat's `send` and `clear` are `action` functions
+// so they never run from a static build — these specs cover what *does*
+// render: the demo-prompts list (static RPC) and the connection meta.
+
+test.describe('streaming-chat (static build)', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto(BASE)
+ await expect(page.locator('h1')).toHaveText('Streaming Chat')
+ })
+
+ test('renders demo prompts from the static RPC dump', async ({ page }) => {
+ const prompts = page.locator('.demo-prompts button')
+ await expect(prompts).toHaveCount(3)
+ await expect(prompts).toHaveText([
+ 'Tell me about devframe.',
+ 'How does streaming work?',
+ 'Write a haiku about RPC.',
+ ])
+ })
+
+ test('reports static backend in the status bar', async ({ page }) => {
+ await expect(page.locator('.status code').first()).toHaveText('static')
+ await expect(page.locator('.status')).toContainText('0 messages')
+ })
+})
diff --git a/turbo.json b/turbo.json
index 8b78a88..676bba6 100644
--- a/turbo.json
+++ b/turbo.json
@@ -19,6 +19,17 @@
"outputLogs": "new-only",
"dependsOn": ["devframe#build"],
"outputs": ["dist/**"]
+ },
+ "files-inspector-example#cli:build": {
+ "outputLogs": "new-only",
+ "dependsOn": ["files-inspector-example#build"],
+ "env": ["DEVFRAME_E2E_CWD"],
+ "outputs": ["dist/static/**"]
+ },
+ "streaming-chat-example#cli:build": {
+ "outputLogs": "new-only",
+ "dependsOn": ["streaming-chat-example#build"],
+ "outputs": ["dist/static/**"]
}
}
}
diff --git a/vitest.config.ts b/vitest.config.ts
index c9b5ee3..4536eb2 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -6,7 +6,13 @@ export default defineConfig({
'packages/devframe',
'examples/files-inspector',
'examples/streaming-chat',
- 'tests',
+ {
+ test: {
+ name: 'tests',
+ root: './tests',
+ exclude: ['e2e/**', '**/node_modules/**', '**/dist/**'],
+ },
+ },
],
testTimeout: 10000,
},