From aecb193bcc7358590c61ff6888ea6cff8ac23667 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Thu, 14 May 2026 09:29:55 +0900 Subject: [PATCH 1/3] test: add playwright e2e suite for files-inspector and streaming-chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the browser-level paths the existing in-process vitest suites can't reach — DOM render, RPC results landing in the UI, client-side route navigation, live token streaming over WS, and shared-state-backed history clearing. Suite spins up the real CLI dev servers via Playwright webServer; files-inspector's cwd is pinned to a fixture dir through a new DEVFRAME_E2E_CWD env so list-files assertions stay deterministic. Wires test:e2e into the root scripts and adds an e2e job alongside unit-test in ci.yml (renamed to CI). --- .github/workflows/ci.yml | 27 +++++++++++++- .gitignore | 4 ++ examples/files-inspector/src/devframe.ts | 7 +++- package.json | 3 ++ playwright.config.ts | 43 ++++++++++++++++++++++ pnpm-lock.yaml | 41 +++++++++++++++++++++ pnpm-workspace.yaml | 1 + tests/e2e/files-inspector.spec.ts | 28 ++++++++++++++ tests/e2e/fixtures/README.md | 1 + tests/e2e/fixtures/package.json | 1 + tests/e2e/fixtures/sample.txt | 1 + tests/e2e/streaming-chat.spec.ts | 47 ++++++++++++++++++++++++ vitest.config.ts | 8 +++- 13 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 playwright.config.ts create mode 100644 tests/e2e/files-inspector.spec.ts create mode 100644 tests/e2e/fixtures/README.md create mode 100644 tests/e2e/fixtures/package.json create mode 100644 tests/e2e/fixtures/sample.txt create mode 100644 tests/e2e/streaming-chat.spec.ts 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/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/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..ca28fbc --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,43 @@ +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)) + +export default defineConfig({ + testDir: './tests/e2e', + 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', + }, + ], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 766af06..bfd17f6 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 @@ -2027,6 +2033,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 +4154,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 +5250,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 +8074,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 +10277,9 @@ snapshots: fresh@2.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -11775,6 +11808,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/files-inspector.spec.ts b/tests/e2e/files-inspector.spec.ts new file mode 100644 index 0000000..db87ee0 --- /dev/null +++ b/tests/e2e/files-inspector.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from '@playwright/test' + +const BASE = 'http://localhost:9876/__devframe-files-inspector/' + +test.describe('files-inspector', () => { + 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/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.spec.ts b/tests/e2e/streaming-chat.spec.ts new file mode 100644 index 0000000..f679269 --- /dev/null +++ b/tests/e2e/streaming-chat.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', () => { + 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/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, }, From a91ad6dce6ff7dfc51df5e22872d128690b1d5df Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Thu, 14 May 2026 09:50:18 +0900 Subject: [PATCH 2/3] test(e2e): cover static-build surface and drop counter example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each example now runs through Playwright twice — once against the live CLI dev server (WS RPC, actions, streaming) and once against the static-build output served as plain files (pre-computed `static` / `query{snapshot:true}` dumps, `backend: 'static'` connection meta). The static surface is exactly what `node bin.mjs build --out-dir ...` ships to a CDN, so the new specs guard the deploy contract end-to-end. Static dumps are served by a tiny zero-dep `tests/e2e/_support/ serve-static.mjs` script with SPA fallback. streaming-chat's static specs intentionally only cover the demo-prompts list and the static backend marker — `send`/`clear` are actions and never reach a static dump. The counter example was removed; it was too thin to carry useful coverage and its dev mode runs in bridge mode (no SPA mount), so the e2e suite couldn't drive it without bespoke wiring. --- docs/guide/built-with.md | 1 - examples/counter/bin.mjs | 14 ---- examples/counter/package.json | 18 ----- examples/counter/src/client/index.html | 13 ---- examples/counter/src/client/main.ts | 21 ----- examples/counter/src/devframe.ts | 77 ------------------- examples/streaming-chat/package.json | 1 + playwright.config.ts | 21 +++++ pnpm-lock.yaml | 6 -- tests/e2e/_support/serve-static.mjs | 74 ++++++++++++++++++ ...or.spec.ts => files-inspector-dev.spec.ts} | 2 +- tests/e2e/files-inspector-static.spec.ts | 31 ++++++++ ...hat.spec.ts => streaming-chat-dev.spec.ts} | 2 +- tests/e2e/streaming-chat-static.spec.ts | 30 ++++++++ turbo.json | 11 +++ 15 files changed, 170 insertions(+), 152 deletions(-) delete mode 100755 examples/counter/bin.mjs delete mode 100644 examples/counter/package.json delete mode 100644 examples/counter/src/client/index.html delete mode 100644 examples/counter/src/client/main.ts delete mode 100644 examples/counter/src/devframe.ts create mode 100644 tests/e2e/_support/serve-static.mjs rename tests/e2e/{files-inspector.spec.ts => files-inspector-dev.spec.ts} (95%) create mode 100644 tests/e2e/files-inspector-static.spec.ts rename tests/e2e/{streaming-chat.spec.ts => streaming-chat-dev.spec.ts} (97%) create mode 100644 tests/e2e/streaming-chat-static.spec.ts 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/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/playwright.config.ts b/playwright.config.ts index ca28fbc..040a6c3 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,9 +3,11 @@ 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, @@ -39,5 +41,24 @@ export default defineConfig({ 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 bfd17f6..940ae2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -245,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: diff --git a/tests/e2e/_support/serve-static.mjs b/tests/e2e/_support/serve-static.mjs new file mode 100644 index 0000000..5f3a9ec --- /dev/null +++ b/tests/e2e/_support/serve-static.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +import { createReadStream, statSync } from 'node:fs' +import { createServer } from 'node:http' +import { extname, join, normalize, 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 relative = decoded.endsWith('/') ? `${decoded}index.html` : decoded + const target = normalize(join(root, relative)) + if (!target.startsWith(root)) + 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.spec.ts b/tests/e2e/files-inspector-dev.spec.ts similarity index 95% rename from tests/e2e/files-inspector.spec.ts rename to tests/e2e/files-inspector-dev.spec.ts index db87ee0..626ffa1 100644 --- a/tests/e2e/files-inspector.spec.ts +++ b/tests/e2e/files-inspector-dev.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test' const BASE = 'http://localhost:9876/__devframe-files-inspector/' -test.describe('files-inspector', () => { +test.describe('files-inspector (dev)', () => { test.beforeEach(async ({ page }) => { await page.goto(BASE) await expect(page.locator('h1')).toHaveText('Files Inspector') 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/streaming-chat.spec.ts b/tests/e2e/streaming-chat-dev.spec.ts similarity index 97% rename from tests/e2e/streaming-chat.spec.ts rename to tests/e2e/streaming-chat-dev.spec.ts index f679269..d7e204d 100644 --- a/tests/e2e/streaming-chat.spec.ts +++ b/tests/e2e/streaming-chat-dev.spec.ts @@ -7,7 +7,7 @@ const BASE = 'http://localhost:9897/__devframe-streaming-chat/' // `clear()` and exits with its stream settled. test.describe.configure({ mode: 'serial' }) -test.describe('streaming-chat', () => { +test.describe('streaming-chat (dev)', () => { test.beforeEach(async ({ page }) => { await page.goto(BASE) await expect(page.locator('h1')).toHaveText('Streaming Chat') 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/**"] } } } From e46bab7a7c017584bd5782466345453b3406daa3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 01:35:44 +0000 Subject: [PATCH 3/3] fix(e2e): harden static server root containment check --- tests/e2e/_support/serve-static.mjs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/e2e/_support/serve-static.mjs b/tests/e2e/_support/serve-static.mjs index 5f3a9ec..b39a70a 100644 --- a/tests/e2e/_support/serve-static.mjs +++ b/tests/e2e/_support/serve-static.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { createReadStream, statSync } from 'node:fs' import { createServer } from 'node:http' -import { extname, join, normalize, resolve } from 'node:path' +import { extname, isAbsolute, join, normalize, relative, resolve } from 'node:path' import process from 'node:process' const [, , dirArg, portArg] = process.argv @@ -31,9 +31,10 @@ function resolveAsset(urlPath) { const clean = urlPath.split('?')[0].split('#')[0] const decoded = decodeURIComponent(clean) // Treat trailing-slash URLs as index.html. - const relative = decoded.endsWith('/') ? `${decoded}index.html` : decoded - const target = normalize(join(root, relative)) - if (!target.startsWith(root)) + 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)