diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4203871..18da3c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,3 +34,28 @@ jobs: - name: Run tests run: npm run test:run + + e2e: + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run E2E tests + run: npx playwright test e2e/ + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b3d2f5a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,68 @@ +# almostnode + +## What This Is + +almostnode is a **real competitor to WebContainers (StackBlitz)**. It runs Node.js natively in the browser — virtual filesystem, npm package installation, dev servers, the works. + +## Core Principle + +**Never write library-specific shim code. Fix the platform instead.** + +When a package doesn't work, the fix goes into the generic shims (fs, path, crypto, etc.), not into a package-specific adapter. Every demo should use real npm packages installed via `PackageManager`, served via `/_npm/` bundling, and running through the standard runtime. No CDN shortcuts, no manual protocol reimplementations, no fake adapters. + +## Architecture + +- **Runtime** (`src/runtime.ts`) — JS execution engine with `require()`, ESM-to-CJS transforms, 43 built-in module shims +- **VirtualFS** (`src/virtual-fs.ts`) — In-memory filesystem, exposed as `require('fs')` +- **PackageManager** (`src/npm/`) — Real npm packages downloaded, extracted, ESM-to-CJS transformed via esbuild-wasm +- **Service Worker** — Network interception for HTTP servers (`/__virtual__/{port}/`) +- **Dev Servers** — `NextDevServer` (Pages + App Router), `ViteDevServer` (React + HMR) +- **just-bash** — Bash emulator with custom commands (`node`, `npm`, `convex`) +- **Code Transforms** (`src/frameworks/code-transforms.ts`) — CSS Modules (css-tree AST), ESM-to-CJS (acorn AST), React Refresh, npm import redirect + +### Next.js Dev Server (split across files) + +- `src/frameworks/next-dev-server.ts` — Orchestrator (~1360 lines) +- `src/frameworks/next-route-resolver.ts` — Route resolution (~600 lines) +- `src/frameworks/next-api-handler.ts` — API route handlers (~350 lines) +- `src/frameworks/next-shims.ts` — Shim string constants (~1040 lines) +- `src/frameworks/next-html-generator.ts` — HTML page generation (~560 lines) +- `src/frameworks/next-config-parser.ts` — next.config.js parsing (AST + regex fallback) + +## Commands + +```bash +npm run dev # Vite dev server (port 5173) +npm run test:run # Unit tests (vitest, ~2250 tests, ~10s) +npm run test:e2e # E2E tests (playwright, ~105 tests) +npm run build # Build for production +``` + +## Testing + +- Unit tests: `tests/` directory, run with `npm run test:run` +- E2E tests: `e2e/` directory, run with `npx playwright test e2e/` +- Run a single E2E file: `npx playwright test e2e/vite-demo.spec.ts` +- Test harnesses live in `examples/` (HTML files with VFS setup) + +## Key Technical Details + +- **`/_npm/` endpoint**: Bundles npm packages from VFS as ESM for browser consumption via esbuild +- **`/_next/route-info`**: Server endpoint returning resolved route info (page, layouts, params) — used by client-side navigation +- **Virtual prefix**: `/__virtual__/{port}/` — all imports go through this for service worker interception +- **`isBrowser` flag**: In test env (jsdom), `isBrowser=false` — transforms run differently +- **ESM-to-CJS**: Happens both at install time (esbuild-wasm) and at runtime (in `loadModule()`) +- **Route groups**: `(groupName)` directories are transparent in URLs, resolved server-side + +## Where to Find More Context + +- **`README.md`** — Public API docs, usage examples, comparison with WebContainers, sandbox setup +- **`CHANGELOG.md`** — Version history and what changed +- **`examples/`** — Working demo HTML files (next-demo, vite-demo, express-demo, etc.) — read these to understand how the platform is used end-to-end +- **`e2e/`** — Playwright E2E tests that exercise each demo — read these to understand what each demo should do + +When working on a specific demo or feature, read the corresponding example HTML and E2E test first. + +## Release Process + +Always bump version in `package.json` and update `CHANGELOG.md` before pushing. Follow [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format, Semantic Versioning. diff --git a/e2e/agent-workbench.spec.ts b/e2e/agent-workbench.spec.ts new file mode 100644 index 0000000..6634b93 --- /dev/null +++ b/e2e/agent-workbench.spec.ts @@ -0,0 +1,175 @@ +import { test, expect } from '@playwright/test'; + +const API_KEY = process.env.OPENAI_API_KEY || ''; +const PROXY_URL = encodeURIComponent('http://localhost:8787/?'); + +test.describe('Agent Workbench with /_npm/ bundling', () => { + let pageErrors: string[] = []; + + test.beforeEach(async ({ page }) => { + pageErrors = []; + page.on('console', (msg) => { + console.log(`[Browser ${msg.type()}]`, msg.text()); + }); + page.on('pageerror', (error) => { + console.error('[Page Error]', error.message); + pageErrors.push(error.message); + }); + }); + + test('should load workbench and install packages', async ({ page }) => { + await page.goto('/examples/agent-workbench.html'); + + // Wait for all packages to be installed and workbench ready + await expect(page.locator('#logs')).toContainText('Workbench ready!', { timeout: 30000 }); + await expect(page.locator('#logs')).toContainText('All packages installed'); + await expect(page.locator('#logs')).toContainText('@ai-sdk/react'); + }); + + test('should serve /_npm/ bundles with correct exports', async ({ page }) => { + await page.goto('/examples/agent-workbench.html'); + await expect(page.locator('#logs')).toContainText('Workbench ready!', { timeout: 30000 }); + + // Start agent to activate the iframe + service worker + await page.fill('#setupKeyInput', API_KEY || 'sk-fake-key-for-testing'); + await page.click('#setupKeyBtn'); + await expect(page.locator('#logs')).toContainText('Agent ready', { timeout: 10000 }); + + // Wait a bit for SW to be fully ready + await page.waitForTimeout(3000); + + // Fetch the /_npm/@ai-sdk/react bundle directly via the service worker + const bundleInfo = await page.evaluate(async () => { + try { + const res = await fetch('/__virtual__/3004/_npm/@ai-sdk/react'); + const text = await res.text(); + // Find all lines containing 'react' (not inside function bodies) + const lines = text.split('\n'); + const reactLines = lines + .map((l, i) => ({ line: i+1, text: l.trim() })) + .filter(l => /\breact\b/.test(l.text) && l.text.length < 200) + .slice(0, 30); + return { + status: res.status, + length: text.length, + first2000: text.slice(0, 2000), + last2000: text.slice(-2000), + hasExportUseChat: text.includes('export var useChat'), + hasImportReact: text.includes('from "react"') || text.includes("from 'react'"), + hasRequireReact: text.includes('require("react")') || text.includes("require('react')"), + has__require: text.includes('__require'), + reactLines: reactLines, + }; + } catch (e) { + return { error: String(e) }; + } + }); + + console.log('[Test] Bundle info:', { + length: bundleInfo.length, + hasExportUseChat: (bundleInfo as any).hasExportUseChat, + has__require: (bundleInfo as any).has__require, + }); + expect(bundleInfo.status).toBe(200); + expect(bundleInfo.length).toBeGreaterThan(500); // Real bundle, not error message + expect((bundleInfo as any).hasExportUseChat).toBe(true); + }); + + test('should serve /_npm/ bundles from VFS via esbuild', async ({ page }) => { + await page.goto('/examples/agent-workbench.html'); + + // Wait for workbench ready + await expect(page.locator('#logs')).toContainText('Workbench ready!', { timeout: 30000 }); + + // Enter API key and start agent + await page.fill('#setupKeyInput', API_KEY || 'sk-fake-key-for-testing'); + await page.click('#setupKeyBtn'); + + // Wait for agent ready + await expect(page.locator('#logs')).toContainText('Agent ready', { timeout: 10000 }); + + // The overlay should be hidden + await expect(page.locator('#setupOverlay')).toHaveClass(/hidden/); + + // Wait for iframe to load + const iframe = page.frameLocator('#preview-iframe'); + + // Monitor network requests for /_npm/ endpoint + const npmRequests: string[] = []; + page.on('request', (req) => { + if (req.url().includes('/_npm/')) { + npmRequests.push(req.url()); + } + }); + + const npmResponses: { url: string; status: number }[] = []; + page.on('response', (res) => { + if (res.url().includes('/_npm/')) { + npmResponses.push({ url: res.url(), status: res.status() }); + } + }); + + // Wait for the page to render in iframe — this triggers /_npm/@ai-sdk/react loading + // The chat UI uses useChat from @ai-sdk/react which is now served via /_npm/ + await page.waitForTimeout(10000); + + // Check that the iframe loaded something (not blank) + const iframeEl = page.locator('#preview-iframe'); + await expect(iframeEl).toBeVisible(); + + // The chat form MUST be visible — proves React mounted with @ai-sdk/react + const chatForm = iframe.locator('form'); + await expect(chatForm).toBeVisible({ timeout: 15000 }); + console.log('[Test] Chat form is visible in iframe — /_npm/ bundling works!'); + + // Verify no npm bundle requests failed + const failedNpm = npmResponses.filter(r => r.status >= 400); + expect(failedNpm).toEqual([]); + + // No page errors during rendering + const relevantErrors = pageErrors.filter(e => + !e.includes('favicon') && !e.includes('robots.txt') + ); + expect(relevantErrors).toEqual([]); + }); + + test('should send a message and get AI response', async ({ page }) => { + test.skip(!API_KEY, 'OPENAI_API_KEY not set'); + test.setTimeout(90000); + + await page.goto(`/examples/agent-workbench.html?corsProxy=${PROXY_URL}`); + await expect(page.locator('#logs')).toContainText('Workbench ready!', { timeout: 30000 }); + + // Start agent + await page.fill('#setupKeyInput', API_KEY); + await page.click('#setupKeyBtn'); + await expect(page.locator('#logs')).toContainText('Agent ready', { timeout: 10000 }); + + // Wait for iframe chat UI to load + const iframe = page.frameLocator('#preview-iframe'); + const chatInput = iframe.locator('input[type="text"], textarea').first(); + await expect(chatInput).toBeVisible({ timeout: 30000 }); + + // Type a message + await chatInput.fill('say hello in one word'); + + // Submit the form + const submitBtn = iframe.locator('button[type="submit"], form button').first(); + await submitBtn.click(); + + // Wait for response (either AI response or error from CORS) + await page.waitForTimeout(15000); + + // User message must be visible + const userMessage = iframe.locator('text=say hello in one word'); + await expect(userMessage).toBeVisible({ timeout: 5000 }); + + // Should have a real AI response, not an error + const pageText = await iframe.locator('body').innerText(); + console.log('[Test] Chat text:', pageText.substring(0, 500)); + expect(pageText).toContain('say hello in one word'); + expect(pageText).not.toContain('Failed to fetch'); + // AI should have responded with something beyond just the user message + expect(pageText.length).toBeGreaterThan(40); + }); +}); diff --git a/e2e/convex-app-demo.spec.ts b/e2e/convex-app-demo.spec.ts index df6edbb..3f73f23 100644 --- a/e2e/convex-app-demo.spec.ts +++ b/e2e/convex-app-demo.spec.ts @@ -1,201 +1,142 @@ import { test, expect } from '@playwright/test'; test.describe('Convex App Demo', () => { + // Collect page errors — asserted at the end of critical tests + let pageErrors: string[] = []; + test.beforeEach(async ({ page }) => { - // Listen to console messages for debugging + pageErrors = []; page.on('console', (msg) => { console.log(`[Browser ${msg.type()}]`, msg.text()); }); - - // Listen to page errors page.on('pageerror', (error) => { console.error('[Page Error]', error.message); + pageErrors.push(error.message); }); }); test('should load the demo page', async ({ page }) => { await page.goto('/examples/demo-convex-app.html'); - - // Check the title - await expect(page.locator('header h1')).toContainText('Convex App Demo'); - - // Check that buttons exist + await expect(page.locator('.demo-topbar .title')).toContainText('Convex App'); await expect(page.locator('#refreshBtn')).toBeVisible(); - await expect(page.locator('#openBtn')).toBeVisible(); + await expect(page.locator('#setupOverlay')).toBeVisible(); + await expect(page.locator('#setupKeyInput')).toBeVisible(); }); - test('should initialize and show Running status', async ({ page }) => { + test('should initialize and show Running status with expected logs', async ({ page }) => { await page.goto('/examples/demo-convex-app.html'); + await expect(page.locator('#statusText')).toContainText('Running', { timeout: 120000 }); - // Wait for initialization - should show "Running" when ready - await expect(page.locator('#statusText')).toContainText('Running', { timeout: 30000 }); - - // Buttons should be enabled when running - await expect(page.locator('#refreshBtn')).not.toBeDisabled(); - await expect(page.locator('#openBtn')).not.toBeDisabled(); - }); - - test('should show project files in console', async ({ page }) => { - await page.goto('/examples/demo-convex-app.html'); - - // Wait for initialization - await expect(page.locator('#statusText')).toContainText('Running', { timeout: 30000 }); - - // Check console output has key messages + // Verify key initialization messages appeared in order const logs = page.locator('#logs'); await expect(logs).toContainText('Project files created'); - await expect(logs).toContainText('Demo ready'); await expect(logs).toContainText('/convex/schema.ts'); await expect(logs).toContainText('/convex/todos.ts'); + await expect(logs).toContainText('Convex package installed'); + await expect(logs).toContainText('Service Worker ready'); + await expect(logs).toContainText('Demo ready'); }); - test('should load iframe with preview', async ({ page }) => { - await page.goto('/examples/demo-convex-app.html'); - - // Wait for initialization - await expect(page.locator('#statusText')).toContainText('Running', { timeout: 30000 }); - - // Check that iframe is visible - const iframe = page.locator('#preview-iframe'); - await expect(iframe).toBeVisible({ timeout: 10000 }); - - // Get iframe src - const iframeSrc = await iframe.getAttribute('src'); - console.log('[Iframe src]', iframeSrc); - expect(iframeSrc).toContain('__virtual__/3002'); - }); - - test('should render home page in iframe', async ({ page }) => { - await page.goto('/examples/demo-convex-app.html'); - - // Wait for initialization - await expect(page.locator('#statusText')).toContainText('Running', { timeout: 30000 }); - - // Wait for iframe to fully load - await page.waitForTimeout(5000); - - const iframe = page.locator('#preview-iframe'); - const iframeHandle = await iframe.elementHandle(); - const frame = await iframeHandle?.contentFrame(); - - if (frame) { - // Wait for React to render - when Convex isn't connected, shows "Connect to Convex" - try { - await frame.waitForSelector('h2', { timeout: 15000 }); - const h2Text = await frame.locator('h2').first().textContent(); - console.log('[H2 content]', h2Text); - expect(h2Text).toContain('Connect to Convex'); - } catch { - // Fallback - check that at least the page was served - const html = await frame.content(); - console.log('[Fallback - HTML length]', html.length); - expect(html.length).toBeGreaterThan(100); - } - } - }); - - test('should fetch home page via fetch API', async ({ page }) => { + test('should bundle convex/react npm module without errors', async ({ page }) => { await page.goto('/examples/demo-convex-app.html'); - await expect(page.locator('#statusText')).toContainText('Running', { timeout: 30000 }); + await expect(page.locator('#statusText')).toContainText('Running', { timeout: 120000 }); - // Fetch the virtual URL + // Directly fetch the npm module that caused the bug. + // If resolvePackageEntry fails on nested exports, this returns 500. const result = await page.evaluate(async () => { - try { - const response = await fetch('/__virtual__/3002/'); - const text = await response.text(); - return { - ok: response.ok, - status: response.status, - contentType: response.headers.get('content-type'), - textLength: text.length, - hasReact: text.includes('react'), - hasTailwind: text.includes('tailwind'), - }; - } catch (error) { - return { error: error instanceof Error ? error.message : String(error) }; - } + const res = await fetch('/__virtual__/3002/_npm/convex/react'); + const text = await res.text(); + return { + status: res.status, + contentType: res.headers.get('content-type'), + length: text.length, + startsWithExport: text.includes('export '), + hasError: text.includes('Failed to bundle'), + }; }); - console.log('[Fetch result]', result); - - expect(result.ok).toBe(true); + console.log('[/_npm/convex/react]', { status: result.status, length: result.length }); expect(result.status).toBe(200); - expect(result.textLength).toBeGreaterThan(100); + expect(result.contentType).toContain('javascript'); + expect(result.hasError).toBe(false); + expect(result.startsWithExport).toBe(true); + expect(result.length).toBeGreaterThan(500); // real bundle, not an error message }); - test('should serve tasks page', async ({ page }) => { + test('should bundle convex/server npm module without errors', async ({ page }) => { await page.goto('/examples/demo-convex-app.html'); - await expect(page.locator('#statusText')).toContainText('Running', { timeout: 30000 }); + await expect(page.locator('#statusText')).toContainText('Running', { timeout: 120000 }); - // Fetch the tasks page const result = await page.evaluate(async () => { - const response = await fetch('/__virtual__/3002/tasks'); + const res = await fetch('/__virtual__/3002/_npm/convex/server'); + const text = await res.text(); return { - status: response.status, - ok: response.ok, - contentType: response.headers.get('content-type'), - textLength: (await response.text()).length, + status: res.status, + contentType: res.headers.get('content-type'), + length: text.length, + hasError: text.includes('Failed to bundle'), }; }); - console.log('[Tasks page result]', result); + console.log('[/_npm/convex/server]', { status: result.status, length: result.length }); expect(result.status).toBe(200); - expect(result.textLength).toBeGreaterThan(100); + expect(result.contentType).toContain('javascript'); + expect(result.hasError).toBe(false); + expect(result.length).toBeGreaterThan(500); }); - test('should serve about page', async ({ page }) => { + test('should render React app in iframe with "Connect to Convex" message', async ({ page }) => { await page.goto('/examples/demo-convex-app.html'); - await expect(page.locator('#statusText')).toContainText('Running', { timeout: 30000 }); + await expect(page.locator('#statusText')).toContainText('Running', { timeout: 120000 }); - // Fetch the about page - const result = await page.evaluate(async () => { - const response = await fetch('/__virtual__/3002/about'); - return { - status: response.status, - ok: response.ok, - contentType: response.headers.get('content-type'), - textLength: (await response.text()).length, - }; - }); + // Wait for iframe to appear + const iframe = page.locator('#preview-iframe'); + await expect(iframe).toBeVisible({ timeout: 10000 }); - console.log('[About page result]', result); - expect(result.status).toBe(200); - expect(result.textLength).toBeGreaterThan(100); + // Get iframe content frame + const iframeHandle = await iframe.elementHandle(); + const frame = await iframeHandle?.contentFrame(); + expect(frame).toBeTruthy(); + + // The React app should render. Without a Convex URL, it shows "Connect to Convex". + // This is the CRITICAL assertion — if JS imports fail (convex/react 500), + // React never mounts and this h2 never appears. + await frame!.waitForSelector('h2', { timeout: 20000 }); + const h2Text = await frame!.locator('h2').first().textContent(); + expect(h2Text).toContain('Connect to Convex'); + + // No page errors should have occurred during rendering + const relevantErrors = pageErrors.filter(e => + !e.includes('favicon') && !e.includes('robots.txt') + ); + expect(relevantErrors).toEqual([]); }); - test('should call API health route', async ({ page }) => { + test('should serve home page HTML with React bootstrap', async ({ page }) => { await page.goto('/examples/demo-convex-app.html'); - await expect(page.locator('#statusText')).toContainText('Running', { timeout: 30000 }); + await expect(page.locator('#statusText')).toContainText('Running', { timeout: 120000 }); - // Call API route const result = await page.evaluate(async () => { - const response = await fetch('/__virtual__/3002/api/health'); - let data; - try { - data = await response.json(); - } catch { - data = await response.text(); - } + const res = await fetch('/__virtual__/3002/'); + const text = await res.text(); return { - status: response.status, - ok: response.ok, - contentType: response.headers.get('content-type'), - data, + status: res.status, + contentType: res.headers.get('content-type'), + length: text.length, + hasReact: text.includes('react'), }; }); - console.log('[API Health Result]', result); - // API routes should return JSON - if (result.ok) { - expect(result.contentType).toContain('json'); - } + expect(result.status).toBe(200); + expect(result.contentType).toContain('text/html'); + expect(result.hasReact).toBe(true); + expect(result.length).toBeGreaterThan(200); }); test('Service Worker should be registered', async ({ page }) => { await page.goto('/examples/demo-convex-app.html'); - await expect(page.locator('#statusText')).toContainText('Running', { timeout: 30000 }); + await expect(page.locator('#statusText')).toContainText('Running', { timeout: 120000 }); - // Check if SW is registered const swRegistered = await page.evaluate(async () => { if ('serviceWorker' in navigator) { const registrations = await navigator.serviceWorker.getRegistrations(); @@ -206,18 +147,4 @@ test.describe('Convex App Demo', () => { expect(swRegistered).toBe(true); }); - - test('refresh button should work', async ({ page }) => { - await page.goto('/examples/demo-convex-app.html'); - await expect(page.locator('#statusText')).toContainText('Running', { timeout: 30000 }); - - // Wait for iframe to load - await page.waitForTimeout(2000); - - // Click refresh button - await page.click('#refreshBtn'); - - // Check that "Refreshing" message appears in logs - await expect(page.locator('#logs')).toContainText('Refreshing preview'); - }); }); diff --git a/e2e/convex-deploy.spec.ts b/e2e/convex-deploy.spec.ts index 9e3a614..41814d2 100644 --- a/e2e/convex-deploy.spec.ts +++ b/e2e/convex-deploy.spec.ts @@ -26,6 +26,13 @@ test.describe('Convex Deployment', () => { // Attach logs to test info for debugging (page as any).__consoleLogs = consoleLogs; + + // Navigate and wait for init, then dismiss setup overlay so #deployBtn is clickable + await page.goto('/examples/demo-convex-app.html'); + await expect(page.locator('#statusText')).toContainText('Running', { timeout: 60000 }); + await page.evaluate(() => { + document.getElementById('setupOverlay')?.classList.add('hidden'); + }); }); test('should deploy schema and functions to Convex', async ({ page }) => { @@ -36,11 +43,6 @@ test.describe('Convex Deployment', () => { return; } - // Go to the demo page - await page.goto('/examples/demo-convex-app.html'); - - // Wait for initialization - await expect(page.locator('#statusText')).toContainText('Running', { timeout: 60000 }); console.log('✓ Demo initialized and running'); // Check initial log messages @@ -147,11 +149,6 @@ test.describe('Convex Deployment', () => { }); test('should handle invalid deploy key', async ({ page }) => { - await page.goto('/examples/demo-convex-app.html'); - - // Wait for initialization - await expect(page.locator('#statusText')).toContainText('Running', { timeout: 60000 }); - // Enter an invalid key await page.fill('#convexKey', 'invalid-key'); await page.click('#deployBtn'); @@ -165,11 +162,6 @@ test.describe('Convex Deployment', () => { }); test('should show error for empty deploy key', async ({ page }) => { - await page.goto('/examples/demo-convex-app.html'); - - // Wait for initialization - await expect(page.locator('#statusText')).toContainText('Running', { timeout: 60000 }); - // Click deploy without entering key await page.click('#deployBtn'); @@ -186,11 +178,6 @@ test.describe('Convex Deployment', () => { return; } - await page.goto('/examples/demo-convex-app.html'); - - // Wait for initialization - await expect(page.locator('#statusText')).toContainText('Running', { timeout: 60000 }); - // First deployment await page.fill('#convexKey', deployKey); await page.click('#deployBtn'); @@ -235,10 +222,6 @@ test.describe('Convex Deployment', () => { return; } - await page.goto('/examples/demo-convex-app.html'); - - // Wait for initialization - await expect(page.locator('#statusText')).toContainText('Running', { timeout: 60000 }); console.log('✓ Demo initialized'); // 1. Initial deployment @@ -337,141 +320,4 @@ test.describe('Convex Deployment', () => { console.log('\n=== Re-deployment File Changes Test Complete ==='); }); - test('deployment serves modified mutation via HTTP API', async ({ page, request }) => { - const deployKey = process.env.CONVEX_DEPLOY_KEY; - - if (!deployKey) { - test.skip(); - return; - } - - // Parse deploy key to get deployment URL - const keyMatch = deployKey.match(/^(dev|prod):([^|]+)\|(.+)$/); - expect(keyMatch).toBeTruthy(); - const deploymentName = keyMatch![2]; - const convexApiBase = `https://${deploymentName}.convex.cloud`; - - // 1. Navigate and wait for app to initialize - await page.goto('/examples/demo-convex-app.html'); - await expect(page.locator('#statusText')).toContainText('Running', { timeout: 60000 }); - console.log('✓ Demo initialized'); - - // 2. Initial deployment (baseline) - await page.fill('#convexKey', deployKey); - await page.click('#deployBtn'); - await expect(page.locator('#logs')).toContainText('[STATUS:COMPLETE]', { timeout: 120000 }); - console.log('✓ Initial deployment complete'); - - // 3. Modify the create mutation to append " hello there" to titles - await page.evaluate(() => { - const vfs = (window as any).__vfs__; - const content = vfs.readFileSync('/convex/todos.ts', 'utf8'); - const modified = content.replace( - 'title: args.title,', - 'title: args.title + " hello there",' - ); - vfs.writeFileSync('/convex/todos.ts', modified); - console.log('Modified /convex/todos.ts: create mutation now appends " hello there"'); - }); - console.log('✓ Modified create mutation'); - - // 4. Re-deploy with the modification - await page.click('#deployBtn'); - const logs = page.locator('#logs'); - await expect(async () => { - const logsText = await logs.textContent(); - const completeCount = (logsText?.match(/\[STATUS:COMPLETE\]/g) || []).length; - expect(completeCount).toBeGreaterThanOrEqual(2); - }).toPass({ timeout: 120000 }); - console.log('✓ Re-deployment complete'); - - // Wait for functions to be live on the backend - await page.waitForTimeout(3000); - - // 5. Call the Convex HTTP API to create a test todo - const testTitle = `e2e-test-${Date.now()}`; - console.log(`Creating todo with title: "${testTitle}"`); - - const createResponse = await request.post(`${convexApiBase}/api/mutation`, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Convex ${deployKey}`, - }, - data: { - path: 'todos:create', - args: { title: testTitle, priority: 'low' }, - format: 'json', - }, - }); - - console.log(`Create response status: ${createResponse.status()}`); - const createBody = await createResponse.text(); - console.log(`Create response body: ${createBody}`); - expect(createResponse.ok()).toBe(true); - - // 6. Query todos to find our todo and verify it has " hello there" appended - const listResponse = await request.post(`${convexApiBase}/api/query`, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Convex ${deployKey}`, - }, - data: { - path: 'todos:list', - args: {}, - format: 'json', - }, - }); - - expect(listResponse.ok()).toBe(true); - const listResult = await listResponse.json(); - console.log(`List response: ${JSON.stringify(listResult).substring(0, 500)}`); - - // The response format is { status: "success", value: [...] } - const todos = listResult.value || listResult; - const ourTodo = (Array.isArray(todos) ? todos : []).find((t: any) => - t.title && t.title.includes(testTitle) - ); - - expect(ourTodo).toBeTruthy(); - expect(ourTodo.title).toBe(`${testTitle} hello there`); - console.log(`✓ Found todo with title: "${ourTodo.title}" — mutation modification is live!`); - - // 7. Cleanup: remove the test todo - if (ourTodo?._id) { - const removeResponse = await request.post(`${convexApiBase}/api/mutation`, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Convex ${deployKey}`, - }, - data: { - path: 'todos:remove', - args: { id: ourTodo._id }, - format: 'json', - }, - }); - console.log(`Cleanup remove status: ${removeResponse.status()}`); - } - - // 8. Restore the original todos.ts - await page.evaluate(() => { - const vfs = (window as any).__vfs__; - const content = vfs.readFileSync('/convex/todos.ts', 'utf8'); - const restored = content.replace( - 'title: args.title + " hello there",', - 'title: args.title,' - ); - vfs.writeFileSync('/convex/todos.ts', restored); - }); - - // 9. Re-deploy to restore original state - await page.click('#deployBtn'); - await expect(async () => { - const logsText = await logs.textContent(); - const completeCount = (logsText?.match(/\[STATUS:COMPLETE\]/g) || []).length; - expect(completeCount).toBeGreaterThanOrEqual(3); - }).toPass({ timeout: 120000 }); - console.log('✓ Restored original mutation and re-deployed'); - - console.log('\n=== Deployment HTTP API Verification Test Complete ==='); - }); }); diff --git a/e2e/cors-proxy-server.mjs b/e2e/cors-proxy-server.mjs new file mode 100644 index 0000000..ab2039a --- /dev/null +++ b/e2e/cors-proxy-server.mjs @@ -0,0 +1,100 @@ +/** + * Simple CORS proxy for E2E tests. + * Forwards requests to the target URL and adds CORS headers. + * + * Usage: node e2e/cors-proxy-server.mjs + * Listens on port 8787 by default. + * + * Proxy URL format: http://localhost:8787/?https%3A%2F%2Fapi.openai.com%2F... + */ + +import { createServer } from 'node:http'; + +const PORT = parseInt(process.env.CORS_PROXY_PORT || '8787', 10); + +const server = createServer(async (req, res) => { + // CORS preflight + if (req.method === 'OPTIONS') { + res.writeHead(200, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Max-Age': '86400', + }); + res.end(); + return; + } + + // Extract target URL from query string + const targetUrl = decodeURIComponent(req.url.slice(2)); // skip /? + if (!targetUrl || !targetUrl.startsWith('http')) { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Bad Request: provide target URL as query parameter'); + return; + } + + try { + // Read request body + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + const body = chunks.length > 0 ? Buffer.concat(chunks) : undefined; + + // Forward headers (exclude host and origin) + const headers = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (['host', 'origin', 'referer', 'connection'].includes(key)) continue; + headers[key] = value; + } + + // Forward request to target + const response = await fetch(targetUrl, { + method: req.method, + headers, + body, + }); + + // Build response headers with CORS + const responseHeaders = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', + 'Access-Control-Allow-Headers': '*', + 'Access-Control-Expose-Headers': '*', + }; + + // Copy relevant response headers + for (const [key, value] of response.headers.entries()) { + if (['content-encoding', 'transfer-encoding', 'connection'].includes(key)) continue; + responseHeaders[key] = value; + } + + // Stream response back + res.writeHead(response.status, responseHeaders); + + if (response.body) { + const reader = response.body.getReader(); + const pump = async () => { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + res.write(value); + } + res.end(); + }; + pump().catch(() => res.end()); + } else { + res.end(); + } + } catch (err) { + res.writeHead(502, { + 'Content-Type': 'text/plain', + 'Access-Control-Allow-Origin': '*', + }); + res.end(`Proxy error: ${err.message}`); + } +}); + +server.listen(PORT, () => { + console.log(`CORS proxy listening on http://localhost:${PORT}`); +}); diff --git a/e2e/express-demo.spec.ts b/e2e/express-demo.spec.ts new file mode 100644 index 0000000..10afe38 --- /dev/null +++ b/e2e/express-demo.spec.ts @@ -0,0 +1,119 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Express Demo', () => { + test.beforeEach(async ({ page }) => { + page.on('console', (msg) => { + console.log(`[Browser ${msg.type()}]`, msg.text()); + }); + page.on('pageerror', (error) => { + console.error('[Page Error]', error.message); + }); + }); + + test('should load the demo page', async ({ page }) => { + await page.goto('/examples/express-demo.html'); + + // Check the title in topbar + await expect(page.locator('.demo-topbar .title')).toContainText('Express'); + + // Check that Run Server button exists + await expect(page.locator('#runBtn')).toBeVisible(); + + // Check editor has Express code + const editor = page.locator('#editor'); + const content = await editor.inputValue(); + expect(content).toContain("require('express')"); + expect(content).toContain('app.listen'); + }); + + test('should install express and start server', async ({ page }) => { + await page.goto('/examples/express-demo.html'); + + // Click Run Server + await page.click('#runBtn'); + + // Wait for server to be ready (includes express install) + await expect(page.locator('#status')).toContainText('Running', { timeout: 60000 }); + + // Terminal should show success + const terminal = page.locator('#terminal'); + await expect(terminal).toContainText('Express server running on port 3000', { timeout: 60000 }); + }); + + test('should load HTML page in preview iframe', async ({ page }) => { + await page.goto('/examples/express-demo.html'); + + // Start server + await page.click('#runBtn'); + await expect(page.locator('#status')).toContainText('Running', { timeout: 60000 }); + + // Wait for preview to load + await page.waitForTimeout(2000); + + // Check iframe loaded Express content + const iframe = page.locator('#preview'); + const iframeHandle = await iframe.elementHandle(); + const frame = await iframeHandle?.contentFrame(); + expect(frame).toBeTruthy(); + + const html = await frame!.content(); + console.log('[Iframe HTML length]', html.length); + expect(html).toContain('Express'); + expect(html.length).toBeGreaterThan(100); + }); + + test('should navigate to API route via link click', async ({ page }) => { + await page.goto('/examples/express-demo.html'); + + // Start server + await page.click('#runBtn'); + await expect(page.locator('#status')).toContainText('Running', { timeout: 60000 }); + + // Wait for preview to load + await page.waitForTimeout(2000); + + // Terminal should log the GET / request + const terminal = page.locator('#terminal'); + await expect(terminal).toContainText('GET /', { timeout: 5000 }); + + // Access iframe — must succeed + const iframe = page.locator('#preview'); + const iframeHandle = await iframe.elementHandle(); + const frame = await iframeHandle?.contentFrame(); + expect(frame).toBeTruthy(); + + // The /api/users link must exist + const usersLink = frame!.locator('a[href="/api/users"]'); + await expect(usersLink).toBeVisible({ timeout: 5000 }); + await usersLink.click(); + + // Wait for navigation + await expect(terminal).toContainText('Navigating to /api/users', { timeout: 5000 }); + await page.waitForTimeout(2000); + + // The preview should now show JSON with users + const updatedHandle = await page.locator('#preview').elementHandle(); + const updatedFrame = await updatedHandle?.contentFrame(); + expect(updatedFrame).toBeTruthy(); + + const html = await updatedFrame!.content(); + expect(html).toContain('Alice'); + expect(html).toContain('Bob'); + }); + + test('should handle re-running the server', async ({ page }) => { + await page.goto('/examples/express-demo.html'); + + // First run + await page.click('#runBtn'); + await expect(page.locator('#status')).toContainText('Running', { timeout: 60000 }); + + // Run again (should reset and restart) + await page.click('#runBtn'); + await expect(page.locator('#status')).toContainText('Running', { timeout: 60000 }); + + // Terminal should show the server is running + const terminal = page.locator('#terminal'); + await expect(terminal).toContainText('Express server running on port 3000'); + }); +}); diff --git a/e2e/next-demo.spec.ts b/e2e/next-demo.spec.ts index c523cad..cbf47af 100644 --- a/e2e/next-demo.spec.ts +++ b/e2e/next-demo.spec.ts @@ -1,23 +1,24 @@ import { test, expect } from '@playwright/test'; test.describe('Next.js Demo with Service Worker', () => { + let pageErrors: string[] = []; + test.beforeEach(async ({ page }) => { - // Listen to console messages for debugging + pageErrors = []; page.on('console', (msg) => { console.log(`[Browser ${msg.type()}]`, msg.text()); }); - - // Listen to page errors page.on('pageerror', (error) => { console.error('[Page Error]', error.message); + pageErrors.push(error.message); }); }); test('should load the demo page', async ({ page }) => { await page.goto('/examples/next-demo.html'); - // Check the title - await expect(page.locator('h1')).toContainText('Next.js'); + // Check the title in topbar + await expect(page.locator('.demo-topbar .title')).toContainText('Next.js'); // Check that buttons exist await expect(page.locator('#save-btn')).toBeVisible(); @@ -75,15 +76,13 @@ test.describe('Next.js Demo with Service Worker', () => { const iframeHandle = await iframe.elementHandle(); const frame = await iframeHandle?.contentFrame(); - if (frame) { - const html = await frame.content(); - console.log('[Iframe HTML length]', html.length); + expect(frame).toBeTruthy(); - // Check for __next container - const hasNext = await frame.locator('#__next').count(); - console.log('[Iframe has #__next]', hasNext); - expect(hasNext).toBeGreaterThan(0); - } + // Check for __next container — proves React rendered + await frame!.waitForSelector('#__next', { timeout: 10000 }); + const hasNext = await frame!.locator('#__next').count(); + console.log('[Iframe has #__next]', hasNext); + expect(hasNext).toBeGreaterThan(0); }); test('should show console output', async ({ page }) => { @@ -94,7 +93,7 @@ test.describe('Next.js Demo with Service Worker', () => { // Check console output has initialization messages const output = page.locator('#output'); - await expect(output).toContainText('Demo ready'); + await expect(output).toContainText('Click Start Preview to launch'); }); test('should load editor with file content', async ({ page }) => { @@ -145,21 +144,13 @@ test.describe('Next.js Demo with Service Worker', () => { const iframeHandle = await iframe.elementHandle(); const frame = await iframeHandle?.contentFrame(); - if (frame) { - // Wait for React to render (with longer timeout and error handling) - try { - await frame.waitForSelector('h1', { timeout: 15000 }); - const h1Text = await frame.locator('h1').first().textContent(); - console.log('[Initial H1]', h1Text); - // The h1 should exist if React rendered - expect(h1Text).toBeTruthy(); - } catch { - // If React didn't render in time, check that at least the page was served - const html = await frame.content(); - console.log('[Fallback - checking HTML was served]', html.length > 0); - expect(html.length).toBeGreaterThan(100); - } - } + expect(frame).toBeTruthy(); + + // Wait for React to render — no fallback, must succeed + await frame!.waitForSelector('h1', { timeout: 15000 }); + const h1Text = await frame!.locator('h1').first().textContent(); + console.log('[Initial H1]', h1Text); + expect(h1Text).toBeTruthy(); }); test('should handle client-side navigation WITHOUT full reload', async ({ page }) => { @@ -267,10 +258,8 @@ test.describe('Next.js Demo with Service Worker', () => { }); console.log('[API Result]', result); - // API routes should at least return a JSON response (200 or 500) + expect(result.status).toBe(200); expect(result.contentType).toContain('json'); - // The simplified API handler implementation may return different statuses - expect([200, 500]).toContain(result.status); }); test('should serve static files from public directory', async ({ page }) => { @@ -398,8 +387,7 @@ test.describe('Next.js Demo with Service Worker', () => { if (!content.includes(originalText)) { console.log('[Content snippet]', content.substring(0, 500)); - // Skip if text not found - file structure may differ - return; + throw new Error(`Expected editor to contain "${originalText}" but it was not found`); } const newContent = content.replace(originalText, newText); diff --git a/e2e/next-features.spec.ts b/e2e/next-features.spec.ts index c7be7a3..538a525 100644 --- a/e2e/next-features.spec.ts +++ b/e2e/next-features.spec.ts @@ -12,7 +12,10 @@ import { test, expect } from '@playwright/test'; const VIRTUAL_PREFIX = '/__virtual__/3002'; test.describe('Next.js New Features E2E', () => { + let pageErrors: string[] = []; + test.beforeEach(async ({ page }) => { + pageErrors = []; page.on('console', (msg) => { if (msg.type() === 'error') { console.log(`[Browser ERROR]`, msg.text()); @@ -21,6 +24,7 @@ test.describe('Next.js New Features E2E', () => { page.on('pageerror', (error) => { console.error('[Page Error]', error.message); + pageErrors.push(error.message); }); // Navigate to test harness @@ -369,19 +373,11 @@ test.describe('Next.js New Features E2E', () => { const iframe = page.locator('#preview-frame'); const iframeHandle = await iframe.elementHandle(); const frame = await iframeHandle?.contentFrame(); + expect(frame).toBeTruthy(); - if (frame) { - try { - await frame.waitForSelector('#__next', { timeout: 15000 }); - const hasNext = await frame.locator('#__next').count(); - console.log('[Iframe has #__next]', hasNext); - expect(hasNext).toBeGreaterThan(0); - } catch { - const html = await frame.content(); - console.log('[Iframe fallback - HTML length]', html.length); - expect(html.length).toBeGreaterThan(100); - } - } + await frame!.waitForSelector('#__next', { timeout: 15000 }); + const hasNext = await frame!.locator('#__next').count(); + expect(hasNext).toBeGreaterThan(0); }); test('should render home page heading in iframe', async ({ page }) => { @@ -390,19 +386,12 @@ test.describe('Next.js New Features E2E', () => { const iframe = page.locator('#preview-frame'); const iframeHandle = await iframe.elementHandle(); const frame = await iframeHandle?.contentFrame(); + expect(frame).toBeTruthy(); - if (frame) { - try { - await frame.waitForSelector('#home-heading', { timeout: 15000 }); - const headingText = await frame.locator('#home-heading').textContent(); - console.log('[Home heading]', headingText); - expect(headingText).toContain('Features Test Home'); - } catch { - const html = await frame.content(); - console.log('[Fallback - checking HTML served]', html.length); - expect(html.length).toBeGreaterThan(100); - } - } + await frame!.waitForSelector('#home-heading', { timeout: 15000 }); + const headingText = await frame!.locator('#home-heading').textContent(); + console.log('[Home heading]', headingText); + expect(headingText).toContain('Features Test Home'); }); test('should render CSS module page with scoped classes in iframe', async ({ page }) => { @@ -417,31 +406,23 @@ test.describe('Next.js New Features E2E', () => { const iframe = page.locator('#preview-frame'); const iframeHandle = await iframe.elementHandle(); const frame = await iframeHandle?.contentFrame(); - - if (frame) { - try { - await frame.waitForSelector('#css-title', { timeout: 15000 }); - const title = await frame.locator('#css-title').textContent(); - console.log('[CSS Module iframe title]', title); - expect(title).toContain('CSS Modules Test'); - - // Check that the className has scoped class name - const className = await frame.locator('#css-title').getAttribute('class'); - console.log('[CSS Module className]', className); - expect(className).toMatch(/title_[a-z0-9]+/); - - // Check that the styles object is rendered - const classesJson = await frame.locator('#css-classes').textContent(); - console.log('[CSS Module classes JSON]', classesJson); - expect(classesJson).toContain('title'); - expect(classesJson).toContain('card'); - } catch (e) { - console.log('[CSS Module iframe error]', e); - const html = await frame.content(); - console.log('[CSS Module iframe HTML length]', html.length); - expect(html.length).toBeGreaterThan(100); - } - } + expect(frame).toBeTruthy(); + + await frame!.waitForSelector('#css-title', { timeout: 15000 }); + const title = await frame!.locator('#css-title').textContent(); + console.log('[CSS Module iframe title]', title); + expect(title).toContain('CSS Modules Test'); + + // Check that the className has scoped class name + const className = await frame!.locator('#css-title').getAttribute('class'); + console.log('[CSS Module className]', className); + expect(className).toMatch(/title_[a-z0-9]+/); + + // Check that the styles object is rendered + const classesJson = await frame!.locator('#css-classes').textContent(); + console.log('[CSS Module classes JSON]', classesJson); + expect(classesJson).toContain('title'); + expect(classesJson).toContain('card'); }); test('should render route group page in iframe', async ({ page }) => { @@ -456,21 +437,12 @@ test.describe('Next.js New Features E2E', () => { const iframe = page.locator('#preview-frame'); const iframeHandle = await iframe.elementHandle(); const frame = await iframeHandle?.contentFrame(); + expect(frame).toBeTruthy(); - if (frame) { - try { - await frame.waitForSelector('#about-heading', { timeout: 15000 }); - const heading = await frame.locator('#about-heading').textContent(); - console.log('[Route Group iframe heading]', heading); - expect(heading).toContain('About Page'); - } catch (e) { - console.log('[Route Group iframe error]', e); - const html = await frame.content(); - console.log('[Route Group iframe HTML length]', html.length); - // At minimum, HTML should be served - expect(html.length).toBeGreaterThan(100); - } - } + await frame!.waitForSelector('#about-heading', { timeout: 15000 }); + const heading = await frame!.locator('#about-heading').textContent(); + console.log('[Route Group iframe heading]', heading); + expect(heading).toContain('About Page'); }); }); }); diff --git a/e2e/vercel-ai-sdk-demo.spec.ts b/e2e/vercel-ai-sdk-demo.spec.ts new file mode 100644 index 0000000..5a825a4 --- /dev/null +++ b/e2e/vercel-ai-sdk-demo.spec.ts @@ -0,0 +1,155 @@ +import { test, expect } from '@playwright/test'; + +const API_KEY = process.env.OPENAI_API_KEY || ''; +const PROXY_URL = encodeURIComponent('http://localhost:8787/?'); + +test.describe('Vercel AI SDK Demo', () => { + let pageErrors: string[] = []; + + test.beforeEach(async ({ page }) => { + pageErrors = []; + page.on('console', (msg) => { + console.log(`[Browser ${msg.type()}]`, msg.text()); + }); + page.on('pageerror', (error) => { + console.error('[Page Error]', error.message); + pageErrors.push(error.message); + }); + }); + + test('should load the demo page', async ({ page }) => { + await page.goto('/examples/demo-vercel-ai-sdk.html'); + await expect(page.locator('.demo-topbar .title')).toContainText('Vercel AI SDK'); + await expect(page.locator('#setupOverlay')).toBeVisible(); + await expect(page.locator('#setupKeyInput')).toBeVisible(); + await expect(page.locator('#setupKeyBtn')).toBeVisible(); + }); + + test('should initialize dev server and show Running status', async ({ page }) => { + await page.goto('/examples/demo-vercel-ai-sdk.html'); + await expect(page.locator('#statusText')).toContainText('Running', { timeout: 120000 }); + + const logs = page.locator('#logs'); + await expect(logs).toContainText('All packages installed'); + await expect(logs).toContainText('Service Worker ready'); + await expect(logs).toContainText('Demo ready'); + }); + + test('should bundle @ai-sdk/react npm module without errors', async ({ page }) => { + await page.goto('/examples/demo-vercel-ai-sdk.html'); + await expect(page.locator('#statusText')).toContainText('Running', { timeout: 120000 }); + + // Directly fetch the bundled npm module. If resolution/bundling fails, returns 500. + const result = await page.evaluate(async () => { + const res = await fetch('/__virtual__/3003/_npm/@ai-sdk/react'); + const text = await res.text(); + return { + status: res.status, + contentType: res.headers.get('content-type'), + length: text.length, + hasExport: text.includes('export '), + hasError: text.includes('Failed to bundle'), + }; + }); + + console.log('[/_npm/@ai-sdk/react]', { status: result.status, length: result.length }); + expect(result.status).toBe(200); + expect(result.contentType).toContain('javascript'); + expect(result.hasError).toBe(false); + expect(result.hasExport).toBe(true); + expect(result.length).toBeGreaterThan(500); + }); + + test('should bundle ai npm module without errors', async ({ page }) => { + await page.goto('/examples/demo-vercel-ai-sdk.html'); + await expect(page.locator('#statusText')).toContainText('Running', { timeout: 120000 }); + + const result = await page.evaluate(async () => { + const res = await fetch('/__virtual__/3003/_npm/ai'); + const text = await res.text(); + return { + status: res.status, + contentType: res.headers.get('content-type'), + length: text.length, + hasError: text.includes('Failed to bundle'), + }; + }); + + console.log('[/_npm/ai]', { status: result.status, length: result.length }); + expect(result.status).toBe(200); + expect(result.contentType).toContain('javascript'); + expect(result.hasError).toBe(false); + expect(result.length).toBeGreaterThan(500); + }); + + test('should render chat interface in iframe', async ({ page }) => { + await page.goto('/examples/demo-vercel-ai-sdk.html'); + await expect(page.locator('#statusText')).toContainText('Running', { timeout: 120000 }); + + // Enter API key and connect + await page.fill('#setupKeyInput', API_KEY || 'sk-fake-key-for-testing'); + await page.click('#setupKeyBtn'); + await expect(page.locator('#setupOverlay')).toHaveClass(/hidden/, { timeout: 5000 }); + + // Wait for iframe + const iframe = page.locator('#previewContainer iframe'); + await expect(iframe).toBeVisible({ timeout: 10000 }); + + // Get iframe content — React must render the chat UI + const iframeHandle = await iframe.elementHandle(); + const frame = await iframeHandle?.contentFrame(); + expect(frame).toBeTruthy(); + + // The chat page shows "Start a conversation" when empty. + // This proves React mounted AND @ai-sdk/react loaded (useChat hook didn't crash). + await frame!.waitForSelector('input[type="text"]', { timeout: 20000 }); + const bodyText = await frame!.locator('body').innerText(); + expect(bodyText).toContain('Start a conversation'); + + // No page errors from failed imports or runtime crashes + const relevantErrors = pageErrors.filter(e => + !e.includes('favicon') && !e.includes('robots.txt') + ); + expect(relevantErrors).toEqual([]); + }); + + test('should enable Connect button when API key is entered', async ({ page }) => { + await page.goto('/examples/demo-vercel-ai-sdk.html'); + await expect(page.locator('#statusText')).toContainText('Running', { timeout: 120000 }); + + await expect(page.locator('#setupKeyBtn')).toBeDisabled(); + await page.fill('#setupKeyInput', 'sk-test-fake-key'); + await expect(page.locator('#setupKeyBtn')).not.toBeDisabled(); + }); + + test('should send message and get AI response', async ({ page }) => { + test.skip(!API_KEY, 'OPENAI_API_KEY not set — skipping live chat test'); + test.setTimeout(90000); + + await page.goto(`/examples/demo-vercel-ai-sdk.html?corsProxy=${PROXY_URL}`); + await expect(page.locator('#statusText')).toContainText('Running', { timeout: 120000 }); + + // Connect with real API key + await page.fill('#setupKeyInput', API_KEY); + await page.click('#setupKeyBtn'); + await expect(page.locator('#setupOverlay')).toHaveClass(/hidden/, { timeout: 5000 }); + + // Wait for chat UI in iframe + const iframe = page.frameLocator('#previewContainer iframe'); + const chatInput = iframe.locator('input[type="text"]').first(); + await expect(chatInput).toBeVisible({ timeout: 30000 }); + + // Send a message + await chatInput.fill('say hello in one word'); + const submitBtn = iframe.locator('button[type="submit"]').first(); + await submitBtn.click(); + + // Wait for AI response + await page.waitForTimeout(15000); + + const chatText = await iframe.locator('body').innerText(); + console.log('[Test] Chat text:', chatText.substring(0, 500)); + expect(chatText).toContain('say hello in one word'); + expect(chatText.length).toBeGreaterThan(50); + }); +}); diff --git a/e2e/vite-demo.spec.ts b/e2e/vite-demo.spec.ts index 887365f..12a5c51 100644 --- a/e2e/vite-demo.spec.ts +++ b/e2e/vite-demo.spec.ts @@ -1,23 +1,24 @@ import { test, expect } from '@playwright/test'; test.describe('Vite Demo with Service Worker', () => { + let pageErrors: string[] = []; + test.beforeEach(async ({ page }) => { - // Listen to console messages for debugging + pageErrors = []; page.on('console', (msg) => { console.log(`[Browser ${msg.type()}]`, msg.text()); }); - - // Listen to page errors page.on('pageerror', (error) => { console.error('[Page Error]', error.message); + pageErrors.push(error.message); }); }); test('should load the demo page', async ({ page }) => { await page.goto('/examples/vite-demo.html'); - // Check the title - await expect(page.locator('h1')).toContainText('Vite Demo'); + // Check the title in topbar + await expect(page.locator('.demo-topbar .title')).toContainText('Vite'); // Check that buttons exist await expect(page.locator('#save-btn')).toBeVisible(); @@ -97,29 +98,14 @@ test.describe('Vite Demo with Service Worker', () => { const iframeHandle = await iframe.elementHandle(); const frame = await iframeHandle?.contentFrame(); - if (frame) { - // Get full HTML first for debugging - const html = await frame.content(); - console.log('[Iframe HTML length]', html.length); - console.log('[Iframe HTML snippet]', html.substring(0, 500)); - - // Check for #root - const hasRoot = await frame.locator('#root').count(); - console.log('[Iframe has #root]', hasRoot); - - if (hasRoot > 0) { - const rootContent = await frame.locator('#root').innerHTML(); - console.log('[Iframe #root content]', rootContent.substring(0, 300)); - expect(rootContent.length).toBeGreaterThanOrEqual(0); - } else { - // If no root, check what's in body - const bodyContent = await frame.locator('body').innerHTML(); - console.log('[Iframe body content]', bodyContent.substring(0, 500)); - throw new Error('No #root element found in iframe'); - } - } else { - throw new Error('Could not access iframe content'); - } + expect(frame).toBeTruthy(); + + // Must have #root — proves Vite HTML was served correctly + await frame!.waitForSelector('#root', { timeout: 10000 }); + const rootContent = await frame!.locator('#root').innerHTML(); + console.log('[Iframe #root content length]', rootContent.length); + // React should have rendered something into #root + expect(rootContent.length).toBeGreaterThan(0); }); test('should show console output', async ({ page }) => { @@ -187,51 +173,6 @@ test.describe('Vite Demo with Service Worker', () => { expect(swRegistered).toBe(true); }); - test('SW debug mode should show what SW receives', async ({ page }) => { - page.on('console', (msg) => console.log(`[Console ${msg.type()}]`, msg.text())); - - // Go to demo and start the server to register SW - await page.goto('/examples/vite-demo.html'); - await expect(page.locator('#status-text')).toContainText('Ready', { timeout: 10000 }); - await page.click('#run-btn'); - await expect(page.locator('#status-text')).toContainText('Dev server running', { timeout: 30000 }); - - // Now test the SW with debug mode - const result = await page.evaluate(async () => { - const response = await fetch('/__virtual__/3000/?__sw_debug__'); - const text = await response.text(); - return { - status: response.status, - text: text, - }; - }); - - console.log('[SW Debug Result]', result.text); - expect(result.status).toBe(200); - }); - - test('SW test mode should return hardcoded response', async ({ page }) => { - // Go to demo and start the server to register SW - await page.goto('/examples/vite-demo.html'); - await expect(page.locator('#status-text')).toContainText('Ready', { timeout: 10000 }); - await page.click('#run-btn'); - await expect(page.locator('#status-text')).toContainText('Dev server running', { timeout: 30000 }); - - // Now test the SW with test mode - const result = await page.evaluate(async () => { - const response = await fetch('/__virtual__/3000/?__sw_test__'); - const text = await response.text(); - return { - status: response.status, - text: text, - }; - }); - - console.log('[SW Test Result]', result); - expect(result.status).toBe(200); - expect(result.text).toContain('SW Test OK'); - }); - test('should not have esbuild initialization errors in iframe', async ({ page }) => { const errors: string[] = []; const iframeErrors: string[] = []; @@ -289,37 +230,22 @@ test.describe('Vite Demo with Service Worker', () => { const iframeHandle = await iframe.elementHandle(); const frame = await iframeHandle?.contentFrame(); - if (frame) { - // Get the iframe HTML - const html = await frame.content(); - console.log('[Iframe HTML length]', html.length); - console.log('[Iframe HTML snippet]', html.substring(0, 500)); + expect(frame).toBeTruthy(); - // Check if there are any script errors visible in the iframe - const bodyText = await frame.locator('body').innerText().catch(() => ''); - console.log('[Iframe body text]', bodyText.substring(0, 500)); + // Verify React rendered into #root + await frame!.waitForSelector('#root', { timeout: 10000 }); + const rootContent = await frame!.locator('#root').innerHTML(); + expect(rootContent.length).toBeGreaterThan(0); - // Check for #root - const hasRoot = await frame.locator('#root').count(); - console.log('[Iframe has #root]', hasRoot); - - // Check if React app rendered - const rootContent = await frame.locator('#root').innerHTML().catch(() => ''); - console.log('[Iframe #root content length]', rootContent.length); - console.log('[Iframe #root content]', rootContent.substring(0, 300)); - } + // No esbuild initialization errors + const esbuildErrors = errors.filter(e => e.includes('Cannot call') && e.includes('initialize')); + expect(esbuildErrors).toEqual([]); - // Log all errors for debugging - console.log('[All main page errors]', errors); - - // Check for esbuild initialization error - const hasEsbuildError = errors.some(e => e.includes('Cannot call') && e.includes('initialize')); - - if (hasEsbuildError) { - console.log('[ERROR] Found esbuild initialization error!'); - } - - expect(hasEsbuildError).toBe(false); + // No other page errors (excluding expected ones) + const unexpectedErrors = errors.filter(e => + !e.includes('favicon') && !e.includes('robots.txt') + ); + expect(unexpectedErrors).toEqual([]); }); test('should fetch virtual URL via fetch API', async ({ page }) => { @@ -329,66 +255,42 @@ test.describe('Vite Demo with Service Worker', () => { await page.click('#run-btn'); await expect(page.locator('#status-text')).toContainText('Dev server running', { timeout: 30000 }); - // Now try to fetch the virtual URL directly from the page + // Fetch the virtual URL and verify it's a real Vite HTML page const result = await page.evaluate(async () => { - try { - const response = await fetch('/__virtual__/3000/'); - const text = await response.text(); - return { - ok: response.ok, - status: response.status, - contentType: response.headers.get('content-type'), - textLength: text.length, - textSnippet: text.substring(0, 500), - }; - } catch (error) { - return { error: error instanceof Error ? error.message : String(error) }; - } + const response = await fetch('/__virtual__/3000/'); + const text = await response.text(); + return { + ok: response.ok, + status: response.status, + contentType: response.headers.get('content-type'), + length: text.length, + hasRoot: text.includes('id="root"'), + hasScript: text.includes(' { - // First, go to demo and start the server + test('should access virtual URL directly in new tab', async ({ page }) => { await page.goto('/examples/vite-demo.html'); await expect(page.locator('#status-text')).toContainText('Ready', { timeout: 10000 }); await page.click('#run-btn'); await expect(page.locator('#status-text')).toContainText('Dev server running', { timeout: 30000 }); - // Now try to access the virtual URL directly in a new page + // Open virtual URL directly in a new page const newPage = await page.context().newPage(); - - // Listen to console for debugging - newPage.on('console', (msg) => { - console.log(`[Virtual Page ${msg.type()}]`, msg.text()); - }); - - newPage.on('pageerror', (error) => { - console.error('[Virtual Page Error]', error.message); - }); - await newPage.goto('http://localhost:5173/__virtual__/3000/'); - - // Wait for the page to load await newPage.waitForTimeout(3000); - // Check the title - const title = await newPage.title(); - console.log('[Virtual Page Title]', title); - - // Check for React root + // Must have React #root const hasRoot = await newPage.locator('#root').count(); - console.log('[Has #root]', hasRoot); - - // Get the page content - const html = await newPage.content(); - console.log('[Page HTML length]', html.length); - console.log('[Page HTML snippet]', html.substring(0, 500)); + expect(hasRoot).toBeGreaterThan(0); await newPage.close(); }); diff --git a/examples/agent-workbench.html b/examples/agent-workbench.html new file mode 100644 index 0000000..2adbcda --- /dev/null +++ b/examples/agent-workbench.html @@ -0,0 +1,165 @@ + + + + + + Agent Workbench — almostnode + + + + +
+ ← demos + / + + / + Agent Workbench + next.js · ai sdk · streamText · tool-calling +
+ +
+ +
+
+
+

Starting dev server...

+
+
+ + +
+
+ Console +
+
+
+
+ + +
+
+

OpenAI API Key Required

+

This demo uses an AI coding agent powered by OpenAI. Enter your API key to get started.

+
Your key stays in your browser and is sent directly to OpenAI via a CORS proxy. It is never stored or sent to our servers.
+ + +
+
+ + + + diff --git a/index.html b/index.html index df71060..8e0aac0 100644 --- a/index.html +++ b/index.html @@ -1374,6 +1374,12 @@

Vercel AI SDK

next.js · ai sdk · openai + +

Agent Workbench

+

AI coding agent that builds Next.js pages live — with file editing, bash, and HMR preview.

+ ai agent · tool-calling · next.js +
+ diff --git a/package-lock.json b/package-lock.json index 0e0ae43..9612856 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,21 @@ { "name": "almostnode", - "version": "0.2.11", + "version": "0.2.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "almostnode", - "version": "0.2.11", + "version": "0.2.13", "license": "MIT", "dependencies": { + "@ai-sdk/openai": "^3.0.28", + "@ai-sdk/react": "^3.0.87", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", + "ai": "^6.0.85", "brotli": "^1.3.3", "brotli-wasm": "^3.0.1", "comlink": "^4.4.2", @@ -21,15 +24,18 @@ "pako": "^2.1.0", "resolve.exports": "^2.0.3", "vite-plugin-top-level-await": "^1.6.0", - "vite-plugin-wasm": "^3.5.0" + "vite-plugin-wasm": "^3.5.0", + "zod": "^4.3.6" }, "devDependencies": { "@playwright/test": "^1.58.0", "@types/css-tree": "^2.3.11", "@types/node": "^25.0.10", "@types/pako": "^2.0.4", + "dotenv": "^17.3.1", "esbuild": "^0.27.2", "jsdom": "^27.4.0", + "react-dom": "^19.2.4", "typescript": "^5.9.3", "vite": "^5.4.0", "vitest": "^4.0.18" @@ -45,6 +51,86 @@ "dev": true, "license": "MIT" }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.45", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.45.tgz", + "integrity": "sha512-ZB6kHV+D8mLCRnkpWotLCV/rZK4NiODxx4Kv7JdT9QmQknbG/scbE4iyoT4JLFdULA8Y/IVbMvyE0Nwq3Dceqw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.15", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "3.0.28", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.28.tgz", + "integrity": "sha512-m2Dm6fwUzMksqnPrd5f/WZ4cZ9GTZHpzsVO6jxKQwwc84gFHzAFZmUCG0C5mV7XlPOw4mwaiYV3HfLiEfphvvA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.15" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.15.tgz", + "integrity": "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/react": { + "version": "3.0.87", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.87.tgz", + "integrity": "sha512-qa4Ywm08g27Voys1xuF2WeX3s8shd4hLJCCxi/Ws6cUZsWpMnFW2rtEfCcKRlWyJ4NRypauiNmcvQKz4v6u0/A==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "4.0.15", + "ai": "6.0.85", + "swr": "^2.2.5", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", @@ -730,6 +816,15 @@ "node": ">= 20.19.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@playwright/test": { "version": "1.58.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz", @@ -1092,7 +1187,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, "license": "MIT" }, "node_modules/@swc/core": { @@ -1385,6 +1479,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@vitest/expect": { "version": "4.0.18", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", @@ -1515,6 +1618,24 @@ "node": ">= 14" } }, + "node_modules/ai": { + "version": "6.0.85", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.85.tgz", + "integrity": "sha512-2bP7M+OcNQGSIH8I3jdujUadxj4tAwuHBvLhpmDSlcjRXXry3zNGEajjjRraOjObHMO/Yqa37PJWhPVHIHt2TQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.45", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.15", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/amdefine": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", @@ -1765,6 +1886,15 @@ "node": ">=4.0.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1783,6 +1913,19 @@ "node": ">=0.3.1" } }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -1864,6 +2007,15 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -2092,6 +2244,12 @@ } } }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/just-bash": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/just-bash/-/just-bash-2.7.0.tgz", @@ -2509,6 +2667,29 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -2619,6 +2800,13 @@ "node": ">=v12.22.7" } }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -2776,6 +2964,19 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/swr": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.0.tgz", + "integrity": "sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -2811,6 +3012,18 @@ "node": ">=6" } }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2973,6 +3186,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3783,6 +4005,15 @@ "funding": { "url": "https://github.com/sponsors/eemeli" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index eb129a7..1909caf 100644 --- a/package.json +++ b/package.json @@ -85,10 +85,13 @@ "prepublishOnly": "npm run test:run && npm run build:publish" }, "dependencies": { + "@ai-sdk/openai": "^3.0.28", + "@ai-sdk/react": "^3.0.87", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", + "ai": "^6.0.85", "brotli": "^1.3.3", "brotli-wasm": "^3.0.1", "comlink": "^4.4.2", @@ -97,15 +100,18 @@ "pako": "^2.1.0", "resolve.exports": "^2.0.3", "vite-plugin-top-level-await": "^1.6.0", - "vite-plugin-wasm": "^3.5.0" + "vite-plugin-wasm": "^3.5.0", + "zod": "^4.3.6" }, "devDependencies": { "@playwright/test": "^1.58.0", "@types/css-tree": "^2.3.11", "@types/node": "^25.0.10", "@types/pako": "^2.0.4", + "dotenv": "^17.3.1", "esbuild": "^0.27.2", "jsdom": "^27.4.0", + "react-dom": "^19.2.4", "typescript": "^5.9.3", "vite": "^5.4.0", "vitest": "^4.0.18" diff --git a/playwright.config.ts b/playwright.config.ts index 0133430..2a9b521 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,3 +1,4 @@ +import 'dotenv/config'; import { defineConfig } from '@playwright/test'; export default defineConfig({ @@ -10,12 +11,20 @@ export default defineConfig({ screenshot: 'only-on-failure', trace: 'on-first-retry', }, - webServer: { - command: 'npm run dev', - url: 'http://localhost:5173/examples/vite-demo.html', - reuseExistingServer: !process.env.CI, - timeout: 30000, - }, + webServer: [ + { + command: 'npm run dev', + url: 'http://localhost:5173/examples/vite-demo.html', + reuseExistingServer: !process.env.CI, + timeout: 30000, + }, + { + command: 'node e2e/cors-proxy-server.mjs', + url: 'http://localhost:8787', + reuseExistingServer: !process.env.CI, + timeout: 10000, + }, + ], projects: [ { name: 'chromium', diff --git a/src/agent-workbench-entry.ts b/src/agent-workbench-entry.ts new file mode 100644 index 0000000..41a5162 --- /dev/null +++ b/src/agent-workbench-entry.ts @@ -0,0 +1,199 @@ +/** + * Agent Workbench Entry Point + * + * Architecture: + * - The entire app (chat UI + preview) runs inside almostnode's virtual Next.js + * - Chat page (/app/page.tsx) uses useChat from @ai-sdk/react (loaded from esm.sh) + * - API route (/pages/api/chat.ts) uses streamText with tool-calling + * - AI SDK packages (ai, @ai-sdk/openai, zod) are installed via PackageManager + * - Tools operate on VFS directly (read, write, replace, list, bash) + */ + +import { VirtualFS } from './virtual-fs'; +import { NextDevServer } from './frameworks/next-dev-server'; +import { getServerBridge } from './server-bridge'; +import { createAgentWorkbenchProject } from './agent-workbench-project'; +import { initChildProcess, exec as cpExec } from './shims/child_process'; +import { PackageManager } from './npm/index'; + +// ── Constants ── + +const CORS_PROXY = new URLSearchParams(window.location.search).get('corsProxy') || 'https://corsproxy.io/?'; +const PORT = 3004; + +// ── Logging (outside React) ── + +const logsEl = document.getElementById('logs') as HTMLDivElement; + +function log(message: string, type: 'info' | 'error' | 'warn' | 'success' = 'info') { + const line = document.createElement('div'); + const time = new Date().toLocaleTimeString(); + line.textContent = `[${time}] ${message}`; + line.className = type; + logsEl.appendChild(line); + logsEl.scrollTop = logsEl.scrollHeight; +} + +// ── Create __project__ module (VFS operations for the API route) ── + +function createProjectModule(vfs: VirtualFS) { + return { + readFile: (path: string) => vfs.readFileSync(path, 'utf8') as string, + writeFile: (path: string, content: string) => vfs.writeFileSync(path, content), + existsSync: (path: string) => vfs.existsSync(path), + listFiles: (dir: string) => vfs.readdirSync(dir), + statSync: (path: string) => vfs.statSync(path), + mkdirSync: (dir: string, opts?: { recursive?: boolean }) => vfs.mkdirSync(dir, opts), + runCommand: (command: string): Promise => { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve('Error: Command timed out (10s)'); + }, 10000); + cpExec(command, { cwd: '/' }, (error, stdout, stderr) => { + clearTimeout(timeout); + if (error) { + resolve(stderr ? `Error: ${stderr}` : `Error: ${error.message}`); + } else { + const output = (stdout || '') + (stderr ? `\n[stderr] ${stderr}` : ''); + resolve(output || '(no output)'); + } + }); + }); + }, + log: (msg: string) => log(msg, 'success'), + }; +} + +// ── Bootstrap ── + +async function main() { + try { + log('Creating virtual file system...'); + const vfs = new VirtualFS(); + + log('Setting up starter project...'); + createAgentWorkbenchProject(vfs); + initChildProcess(vfs); + log('Project files created', 'success'); + + // Install AI SDK packages via PackageManager + log('Installing npm packages...'); + const pm = new PackageManager(vfs, { cwd: '/' }); + + // Install zod v4 (provides both zod/v3 and zod/v4 sub-paths needed by + // @ai-sdk/provider-utils). The AI SDK server-side code runs in VFS so it + // uses the real npm-installed zod, not esm.sh. + // @ai-sdk/react is installed locally and served via /_npm/ (not esm.sh) + // to avoid esm.sh resolution bugs with zod/v4 sub-path exports. + const packages = ['zod', 'ai@5', '@ai-sdk/openai@2', '@ai-sdk/react@2']; + for (const pkg of packages) { + log(`Installing ${pkg}...`); + await pm.install(pkg, { + onProgress: (msg) => log(msg), + transform: true, + }); + } + log('All packages installed', 'success'); + + log('Starting Next.js dev server...'); + + const projectModule = createProjectModule(vfs); + + const apiModules: Record = { + '__project__': projectModule, + }; + + const devServer = new NextDevServer(vfs, { + port: PORT, + root: '/', + preferAppRouter: true, + apiModules, + corsProxy: CORS_PROXY, + }); + + const bridge = getServerBridge(); + + try { + log('Initializing Service Worker...'); + await bridge.initServiceWorker(); + log('Service Worker ready', 'success'); + } catch (error) { + log(`Service Worker warning: ${error}`, 'warn'); + } + + bridge.registerServer(devServer as any, PORT); + devServer.start(); + + const serverUrl = bridge.getServerUrl(PORT) + '/'; + log(`Server running at: ${serverUrl}`, 'success'); + + // Create preview iframe + const previewContainer = document.getElementById('previewContainer') as HTMLDivElement; + previewContainer.innerHTML = ''; + const iframe = document.createElement('iframe'); + iframe.src = serverUrl; + iframe.id = 'preview-iframe'; + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.border = 'none'; + iframe.setAttribute( + 'sandbox', + 'allow-forms allow-scripts allow-same-origin allow-popups allow-pointer-lock allow-modals' + ); + + iframe.onload = () => { + if (iframe?.contentWindow && devServer) { + devServer.setHMRTarget(iframe.contentWindow); + } + }; + + previewContainer.appendChild(iframe); + + // Setup overlay handlers + const setupOverlay = document.getElementById('setupOverlay') as HTMLDivElement; + const setupKeyInput = document.getElementById('setupKeyInput') as HTMLInputElement; + const setupKeyBtn = document.getElementById('setupKeyBtn') as HTMLButtonElement; + + setupKeyInput.oninput = () => { + setupKeyBtn.disabled = !setupKeyInput.value.trim(); + }; + + const startAgent = (key: string) => { + const sanitizedKey = key.trim().replace(/[^\x00-\x7F]/g, ''); + if (!sanitizedKey) { + log('Please enter an API key', 'error'); + return; + } + if (!sanitizedKey.startsWith('sk-')) { + log('Warning: OpenAI keys typically start with "sk-"', 'warn'); + } + + // Pass API key to the virtual environment via env vars. + // The API route reads process.env.OPENAI_API_KEY and configures the + // CORS proxy itself — no need to inject pre-configured modules. + devServer.setEnv('OPENAI_API_KEY', sanitizedKey); + + setupOverlay.classList.add('hidden'); + log('Agent ready — enter a message in the chat', 'success'); + }; + + setupKeyBtn.onclick = () => { + startAgent(setupKeyInput.value); + }; + + setupKeyInput.onkeydown = (e) => { + if (e.key === 'Enter' && setupKeyInput.value.trim()) { + startAgent(setupKeyInput.value); + } + }; + + log('Workbench ready!', 'success'); + log('Enter your OpenAI API key to start.'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log(`Error: ${errorMessage}`, 'error'); + console.error(error); + } +} + +main(); diff --git a/src/agent-workbench-project.ts b/src/agent-workbench-project.ts new file mode 100644 index 0000000..867e599 --- /dev/null +++ b/src/agent-workbench-project.ts @@ -0,0 +1,455 @@ +/** + * Agent Workbench - Virtual Project Seed + * + * Creates a Next.js project in VFS with: + * - Chat UI using useChat from @ai-sdk/react (App Router client page, loaded from esm.sh) + * - API route using streamText + Pages Router streaming (server, proven pattern) + * - Tools: read_file, write_file, replace_in_file, list_files, run_bash + */ + +import { VirtualFS } from './virtual-fs'; + +const PACKAGE_JSON = { + name: 'agent-workbench-app', + version: '0.1.0', + private: true, + scripts: { + dev: 'next dev', + build: 'next build', + start: 'next start', + }, + dependencies: { + next: '^14.0.0', + react: '^18.2.0', + 'react-dom': '^18.2.0', + ai: '^6.0.0', + '@ai-sdk/react': '^3.0.0', + }, +}; + +// ── API Route (/pages/api/chat.ts) — Pages Router for proven streaming ── + +const API_ROUTE = `import { streamText, tool, stepCountIs, convertToModelMessages } from 'ai'; +import { createOpenAI } from '@ai-sdk/openai'; +import { z } from 'zod'; +import { readFile, writeFile, existsSync, listFiles, statSync, mkdirSync, runCommand, log } from '__project__'; + +var CORS_PROXY = process.env.CORS_PROXY_URL || 'https://corsproxy.io/?'; + +var openai = createOpenAI({ + apiKey: process.env.OPENAI_API_KEY || '', + fetch: function(url, init) { + var proxiedUrl = CORS_PROXY + encodeURIComponent(String(url)); + return globalThis.fetch(proxiedUrl, init); + }, +}); + +var SYSTEM_PROMPT = 'You are a frontend developer agent. You help users build and modify a Next.js App Router application running in the browser.\\n\\nAvailable tools:\\n- read_file: Read file contents at a given path\\n- write_file: Create or overwrite a file (parent directories are created automatically)\\n- replace_in_file: Make a targeted text replacement in a file (first occurrence)\\n- list_files: List files and directories at a path\\n- run_bash: Run a shell command (e.g. ls, cat, echo, node scripts)\\n\\nThe project uses Next.js App Router. Current structure:\\n- /app/layout.tsx — Root layout\\n- /app/page.tsx — Root page (the chat UI, but you can replace it)\\n- /public/ — Static assets\\n- /package.json — Project config\\n\\nGuidelines:\\n- You can modify ANY file in the project, including the root page (/app/page.tsx) and layout\\n- The only protected file is /pages/api/chat.ts (the agent API route)\\n- Create new pages under /app/ (e.g. /app/about/page.tsx, /app/dashboard/page.tsx)\\n- After creating a page, tell the user to type the path (e.g. /about) in the preview URL bar and click Go\\n- Use inline styles for styling\\n- Write clean, modern React (JSX/TSX) code\\n- Keep responses concise — explain what you did briefly'; + +function validatePath(path, isWrite) { + if (!path.startsWith('/')) return 'Path must be absolute (start with /)'; + if (path.includes('..')) return 'Path must not contain ..'; + if (path.startsWith('/node_modules')) return 'Cannot access /node_modules'; + if (isWrite && path === '/pages/api/chat.ts') return 'Cannot modify the agent API route'; + return null; +} + +var agentTools = { + read_file: tool({ + description: 'Read the contents of a file at the given path', + inputSchema: z.object({ + path: z.string().describe('Absolute path to the file (e.g. /app/page.tsx)'), + }), + execute: async function(args) { + var err = validatePath(args.path, false); + if (err) return 'Error: ' + err; + if (!existsSync(args.path)) return 'Error: File not found: ' + args.path; + return readFile(args.path); + }, + }), + + write_file: tool({ + description: 'Write content to a file. Creates the file if it does not exist, or overwrites it. Parent directories are created automatically.', + inputSchema: z.object({ + path: z.string().describe('Absolute path to the file'), + content: z.string().describe('Full file content to write'), + }), + execute: async function(args) { + var err = validatePath(args.path, true); + if (err) return 'Error: ' + err; + if (args.content.length > 50000) return 'Error: File content too large (max 50KB)'; + var dir = args.path.substring(0, args.path.lastIndexOf('/')); + if (dir && !existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFile(args.path, args.content); + log('File written: ' + args.path + ' (' + args.content.length + ' chars)'); + return 'File written successfully'; + }, + }), + + replace_in_file: tool({ + description: 'Replace the first occurrence of old_text with new_text in a file. Use this for targeted edits instead of rewriting the whole file.', + inputSchema: z.object({ + path: z.string().describe('Absolute path to the file'), + old_text: z.string().describe('Exact text to find in the file'), + new_text: z.string().describe('Replacement text'), + }), + execute: async function(args) { + var err = validatePath(args.path, true); + if (err) return 'Error: ' + err; + if (!existsSync(args.path)) return 'Error: File not found: ' + args.path; + var fileContent = readFile(args.path); + if (!fileContent.includes(args.old_text)) return 'Error: old_text not found in file'; + var newContent = fileContent.replace(args.old_text, args.new_text); + writeFile(args.path, newContent); + log('File edited: ' + args.path); + return 'Replacement made successfully'; + }, + }), + + list_files: tool({ + description: 'List files and directories at the given path. Directories end with /', + inputSchema: z.object({ + path: z.string().describe('Directory path to list (e.g. / or /app)'), + }), + execute: async function(args) { + var err = validatePath(args.path, false); + if (err) return 'Error: ' + err; + if (!existsSync(args.path)) return 'Error: Directory not found: ' + args.path; + var entries = listFiles(args.path); + var result = entries.map(function(entry) { + var fullPath = args.path.endsWith('/') ? args.path + entry : args.path + '/' + entry; + try { + var stat = statSync(fullPath); + return stat.isDirectory() ? entry + '/' : entry; + } catch (e) { + return entry; + } + }); + return result.join('\\n') || '(empty directory)'; + }, + }), + + run_bash: tool({ + description: 'Run a shell command in the virtual environment. Supports basic commands like ls, cat, echo, mkdir, cp, mv, node. Output is captured and returned.', + inputSchema: z.object({ + command: z.string().describe('The shell command to run (e.g. "ls -la /app")'), + }), + execute: async function(args) { + if (!args.command) return 'Error: No command provided'; + log('Bash: ' + args.command); + var result = await runCommand(args.command); + return result; + }, + }), +}; + +export default async function handler(req, res) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + var uiMessages = req.body.messages; + if (!uiMessages || !Array.isArray(uiMessages)) { + return res.status(400).json({ error: 'Invalid messages format' }); + } + + var messages = await convertToModelMessages(uiMessages); + + var result = streamText({ + model: openai('gpt-4.1'), + system: SYSTEM_PROMPT, + messages: messages, + tools: agentTools, + stopWhen: stepCountIs(15), + onStepFinish: function(step) { + if (step.toolCalls && step.toolCalls.length > 0) { + log(step.toolCalls.map(function(tc) { return tc.toolName; }).join(', ') + ' (' + (step.usage.totalTokens || 0) + ' tokens)'); + } + }, + onError: function(info) { + log('Stream error: ' + info.error); + }, + }); + + return result.toUIMessageStreamResponse(); + } catch (error) { + log('API error: ' + (error && error.message ? error.message : String(error))); + if (!res.headersSent) { + res.status(500).json({ error: error && error.message ? error.message : 'Internal server error' }); + } + } +} +`; + +// ── Page (/app/page.tsx) — Chat UI with embedded preview ── +// This runs entirely inside the virtual Next.js via esm.sh imports. + +const PAGE = `'use client'; + +import React, { useState, useEffect, useRef } from 'react'; +import { useChat } from '@ai-sdk/react'; + +function formatToolArgs(toolName, args) { + if (!args) return ''; + if (toolName === 'write_file') return args.path + ' (' + (args.content || '').length + ' chars)'; + if (toolName === 'run_bash') return args.command || ''; + if (toolName === 'replace_in_file') return args.path || ''; + return args.path || JSON.stringify(args).slice(0, 120); +} + +export default function AgentWorkbench() { + var loc = typeof window !== 'undefined' ? window.location : null; + var basePath = loc + ? (loc.pathname.endsWith('/') ? loc.pathname.slice(0, -1) : loc.pathname) + : ''; + + var [input, setInput] = useState(''); + var [pathInput, setPathInput] = useState('/welcome'); + var [previewSrc, setPreviewSrc] = useState(basePath + '/welcome'); + var bottomRef = useRef(null); + var iframeRef = useRef(null); + + var { messages, sendMessage, status, error } = useChat({ + api: basePath + '/api/chat', + }); + + var isLoading = status === 'submitted' || status === 'streaming'; + + useEffect(function() { + if (bottomRef.current) bottomRef.current.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + // Relay HMR messages from parent window to preview iframe + useEffect(function() { + function onMessage(event) { + if (event.data && event.data.channel === 'next-hmr' && iframeRef.current && iframeRef.current.contentWindow) { + try { iframeRef.current.contentWindow.postMessage(event.data, '*'); } catch(e) {} + } + } + window.addEventListener('message', onMessage); + return function() { window.removeEventListener('message', onMessage); }; + }, []); + + function handleSubmit(e) { + e.preventDefault(); + if (!input.trim() || isLoading) return; + sendMessage({ text: input }); + setInput(''); + } + + function navigatePreview(path) { + if (!path) return; + var p = path.startsWith('/') ? path : '/' + path; + setPathInput(p); + setPreviewSrc(basePath + p); + } + + return ( +
+ {/* Chat panel */} +
+ {/* Header */} +
+ Agent Chat + gpt-4.1 +
+ + {/* Messages */} +
+ {messages.length === 0 && ( +
+

What do you want to build?

+

I can create pages, components, and layouts. The preview updates live via HMR.

+
+ )} + + {messages.map(function(m) { + return ( +
+ {m.role === 'user' ? ( +
+ {m.parts && m.parts.filter(function(p) { return p.type === 'text'; }).map(function(p, i) { + return {p.text}; + })} +
+ ) : ( +
+ {m.parts && m.parts.map(function(part, i) { + if (part.type === 'text' && part.text) { + return ( +
+ {part.text} +
+ ); + } + var toolMatch = part.type && part.type.match(/^tool-(.+)$/); + if (toolMatch) { + var toolName = toolMatch[1]; + var done = part.state === 'result'; + return ( +
+
{toolName}
+
{formatToolArgs(toolName, part.args)}
+ {done &&
{String(part.result).slice(0, 200)}
} +
+ ); + } + return null; + })} +
+ )} +
+ ); + })} + + {isLoading && messages.length > 0 && ( +
Thinking...
+ )} + + {error && ( +
{error.message}
+ )} + +
+
+ + {/* Input */} +
+ + +
+
+ + {/* Preview panel */} +
+ {/* Preview URL bar */} +
+ Preview + + + +
+ + {/* Preview content */} +
+