From 71ba7371bcafa3883dd7f60e039d12cc9a5a3d80 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 21 Jun 2026 09:25:48 -0400 Subject: [PATCH 1/5] fix: allow saving edited PAT credential when reusing existing token --- frontend/src/components/settings/GitCredentialDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/settings/GitCredentialDialog.tsx b/frontend/src/components/settings/GitCredentialDialog.tsx index 5705d37b..27808292 100644 --- a/frontend/src/components/settings/GitCredentialDialog.tsx +++ b/frontend/src/components/settings/GitCredentialDialog.tsx @@ -97,7 +97,7 @@ export function GitCredentialDialog({ open, onOpenChange, onSave, credential, re } if (formData.type === 'pat') { - if (!formData.token?.trim()) { + if (!formData.token?.trim() && !(credential?.token && !tokenEdited)) { showToast.error('Token is required for PAT type') return } From 9afd5f04d54fa14b0892c1be544ac70ff540229a Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:37:53 -0400 Subject: [PATCH 2/5] feat: add dev-server preview system with proxy, file serving, and HTML artifact preview Adds a full dev-server preview system including: - Backend proxy/upgrade handler for dev server traffic - File-serving backend route with CSP headers - DevServerPreviewButton and HtmlArtifactPanel components - HTML artifact detection and preview in messages - File preview improvements with mobile support - Shared types and settings schema for dev server config - Comprehensive test coverage for all new code --- backend/eslint.config.js | 1 + backend/src/constants.ts | 57 +-- backend/src/db/queries.ts | 14 + backend/src/default-agents.md | 56 +++ backend/src/index.ts | 9 +- backend/src/routes/dev-proxy.ts | 152 ++++++++ backend/src/routes/dev-server.ts | 64 ++++ backend/src/routes/files.ts | 127 +++++++ .../src/routes/internal/git-credentials.ts | 14 - backend/src/routes/internal/index.ts | 4 +- backend/src/routes/internal/shell-env.ts | 18 + backend/src/services/dev-server/manager.ts | 22 ++ backend/src/services/dev-server/ports.ts | 19 + .../src/services/dev-server/proxy-utils.ts | 182 ++++++++++ .../services/dev-server/upgrade-handler.ts | 125 +++++++ backend/src/services/files.ts | 28 ++ .../src/services/opencode-gh-env-plugin.ts | 2 +- backend/src/text-modules.d.ts | 4 + backend/test/routes/dev-server.test.ts | 265 ++++++++++++++ backend/test/routes/files.test.ts | 147 ++++++++ .../routes/internal/git-credentials.test.ts | 40 --- .../test/routes/internal/shell-env.test.ts | 48 +++ .../services/dev-server/proxy-utils.test.ts | 337 ++++++++++++++++++ .../dev-server/upgrade-handler.test.ts | 259 ++++++++++++++ .../services/opencode-gh-env-plugin.test.ts | 2 +- backend/vitest.config.ts | 12 + frontend/src/api/devServer.ts | 11 + frontend/src/api/files.test.ts | 40 +++ frontend/src/api/files.ts | 9 + frontend/src/api/types/settings.ts | 1 + .../DevServerPreviewButton.test.tsx | 55 +++ .../dev-server/DevServerPreviewButton.tsx | 44 +++ .../components/file-browser/FileBrowser.tsx | 61 +++- .../file-browser/FileBrowserSheet.test.tsx | 65 +++- .../file-browser/FilePreview.test.tsx | 104 ++++++ .../components/file-browser/FilePreview.tsx | 38 +- .../file-browser/MobileFilePreviewModal.tsx | 19 +- .../html-preview/HtmlArtifactPanel.test.tsx | 273 ++++++++++++++ .../html-preview/HtmlArtifactPanel.tsx | 111 ++++++ .../html-preview/HtmlPreviewFrame.tsx | 26 ++ .../html-preview/PreviewHtmlButton.tsx | 21 ++ .../components/message/CodePreview.test.tsx | 111 ++++++ .../src/components/message/CodePreview.tsx | 24 +- .../src/components/message/FileToolRender.tsx | 14 +- .../src/components/message/MessagePart.tsx | 9 +- .../src/components/message/MessageThread.tsx | 8 + .../src/components/message/TextPart.test.tsx | 100 ++++++ frontend/src/components/message/TextPart.tsx | 28 +- .../src/components/message/ToolCallPart.tsx | 10 +- .../components/settings/GeneralSettings.tsx | 35 ++ .../settings/GitCredentialDialog.tsx | 2 +- frontend/src/lib/htmlArtifacts.test.ts | 194 ++++++++++ frontend/src/lib/htmlArtifacts.ts | 82 +++++ frontend/src/pages/SessionDetail.tsx | 124 +++++-- shared/src/schemas/settings.ts | 3 + shared/src/types/dev-server.ts | 15 + shared/src/types/index.ts | 3 + 57 files changed, 3464 insertions(+), 184 deletions(-) create mode 100644 backend/src/default-agents.md create mode 100644 backend/src/routes/dev-proxy.ts create mode 100644 backend/src/routes/dev-server.ts delete mode 100644 backend/src/routes/internal/git-credentials.ts create mode 100644 backend/src/routes/internal/shell-env.ts create mode 100644 backend/src/services/dev-server/manager.ts create mode 100644 backend/src/services/dev-server/ports.ts create mode 100644 backend/src/services/dev-server/proxy-utils.ts create mode 100644 backend/src/services/dev-server/upgrade-handler.ts create mode 100644 backend/src/text-modules.d.ts create mode 100644 backend/test/routes/dev-server.test.ts delete mode 100644 backend/test/routes/internal/git-credentials.test.ts create mode 100644 backend/test/routes/internal/shell-env.test.ts create mode 100644 backend/test/services/dev-server/proxy-utils.test.ts create mode 100644 backend/test/services/dev-server/upgrade-handler.test.ts create mode 100644 frontend/src/api/devServer.ts create mode 100644 frontend/src/api/files.test.ts create mode 100644 frontend/src/components/dev-server/DevServerPreviewButton.test.tsx create mode 100644 frontend/src/components/dev-server/DevServerPreviewButton.tsx create mode 100644 frontend/src/components/file-browser/FilePreview.test.tsx create mode 100644 frontend/src/components/html-preview/HtmlArtifactPanel.test.tsx create mode 100644 frontend/src/components/html-preview/HtmlArtifactPanel.tsx create mode 100644 frontend/src/components/html-preview/HtmlPreviewFrame.tsx create mode 100644 frontend/src/components/html-preview/PreviewHtmlButton.tsx create mode 100644 frontend/src/components/message/CodePreview.test.tsx create mode 100644 frontend/src/components/message/TextPart.test.tsx create mode 100644 frontend/src/lib/htmlArtifacts.test.ts create mode 100644 frontend/src/lib/htmlArtifacts.ts create mode 100644 shared/src/types/dev-server.ts diff --git a/backend/eslint.config.js b/backend/eslint.config.js index f0084db6..a2972da6 100644 --- a/backend/eslint.config.js +++ b/backend/eslint.config.js @@ -26,6 +26,7 @@ export default defineConfig([ '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-unused-vars': 'error', 'no-useless-escape': 'warn', + 'no-empty': ['error', { allowEmptyCatch: true }], }, }, { diff --git a/backend/src/constants.ts b/backend/src/constants.ts index ca4d348d..e2da00f4 100644 --- a/backend/src/constants.ts +++ b/backend/src/constants.ts @@ -1,56 +1,3 @@ -export const DEFAULT_AGENTS_MD = `# OpenCode Manager - Global Agent Instructions +import defaultAgentsMd from './default-agents.md' with { type: 'text' } -## Critical System Constraints - -- **DO NOT** use ports 5003 or 5551 - these are reserved for OpenCode Manager -- **DO NOT** kill or stop processes on ports 5003 or 5551 -- **DO NOT** modify files in the \`.config/opencode\` directory unless explicitly requested - -## Dev Server Ports - -When starting dev servers, use the pre-allocated ports 5100-5103: -- Port 5100: Primary dev server (frontend) -- Port 5101: Secondary dev server (API/backend) -- Port 5102: Additional service -- Port 5103: Additional service - -Always bind to \`0.0.0.0\` to allow external access from the Docker host. - -## Package Management - -### Node.js Packages -Prefer **pnpm** or **bun** over npm for installing dependencies to save disk space: -- Use \`pnpm install\` instead of \`npm install\` -- Use \`bun install\` as an alternative -- Both are pre-installed in the container - - ### Python Packages - Always create a virtual environment in the repository directory before installing packages: - - 1. Create virtual environment in repo: - \`cd \`\` - \`uv venv .venv\` - - 2. Activate the virtual environment: - \`source .venv/bin/activate\` # or \`uv pip sync\` for project-based workflows - - 3. Install packages into activated environment: - \`uv pip install \`\` - \`uv pip install -r requirements.txt\` - - 4. Run Python commands: - \`python script.py\` # Uses activated .venv - - Alternative: Use \`uv run python script.py\` to skip explicit activation - - **Important:** - - Always create .venv in the repository directory (not workspace root) - - Activate the environment before running pip operations - - uv is pre-installed in the container and provides faster package installation - - .venv directories created in repos will persist but can be removed safely - -## General Guidelines - -- This file is merged with any AGENTS.md files in individual repositories -- Repository-specific instructions take precedence for their respective codebases -` +export const DEFAULT_AGENTS_MD = defaultAgentsMd diff --git a/backend/src/db/queries.ts b/backend/src/db/queries.ts index 2a1e85fd..623696b2 100644 --- a/backend/src/db/queries.ts +++ b/backend/src/db/queries.ts @@ -3,6 +3,7 @@ import type { Repo, CreateRepoInput } from '../types/repo' import { getReposPath } from '@opencode-manager/shared/config/env' import { ASSISTANT_REPO_ID, ASSISTANT_REPO_PATH } from '@opencode-manager/shared/utils' import { getErrorMessage } from '../utils/error-utils' +import type { DevServerConfig } from '@opencode-manager/shared/types' import path from 'path' interface RepoRow { @@ -331,6 +332,19 @@ export function updateRepoBranch(db: Database, id: number, branch: string): void } } +const DEV_SERVER_INJECT_BASE_KEY = 'devServerInjectBase' + +export function getDevServerConfig(db: Database, repoId: number): DevServerConfig { + const injectBase = getRepoSetting(db, repoId, DEV_SERVER_INJECT_BASE_KEY) + return { + injectBase: injectBase === '1', + } +} + +export function setDevServerConfig(db: Database, repoId: number, config: DevServerConfig): void { + setRepoSetting(db, repoId, DEV_SERVER_INJECT_BASE_KEY, config.injectBase ? '1' : null) +} + export function deleteRepo(db: Database, id: number): void { if (id === ASSISTANT_REPO_ID) { return diff --git a/backend/src/default-agents.md b/backend/src/default-agents.md new file mode 100644 index 00000000..1b85361f --- /dev/null +++ b/backend/src/default-agents.md @@ -0,0 +1,56 @@ +# OpenCode Manager - Global Agent Instructions + +## Critical System Constraints + +- **DO NOT** use ports 5003 or 5551 - these are reserved for OpenCode Manager +- **DO NOT** kill or stop processes on ports 5003 or 5551 +- **DO NOT** modify files in the `.config/opencode` directory unless explicitly requested + +## Dev Server Port + +The preview feature checks the port provided in the `OCM_DEV_SERVER_PORT` environment variable. Always run the dev server on that port so it can be previewed. + +- Start the dev server on the port in `$OCM_DEV_SERVER_PORT` (e.g. `PORT=$OCM_DEV_SERVER_PORT ` or pass `--port $OCM_DEV_SERVER_PORT`). +- Bind to `0.0.0.0` to allow external access from the Docker host. + +## Package Management + +### Node.js Packages + +Prefer **pnpm** or **bun** over npm for installing dependencies to save disk space: + +- Use `pnpm install` instead of `npm install` +- Use `bun install` as an alternative +- Both are pre-installed in the container + +### Python Packages + +Always create a virtual environment in the repository directory before installing packages: + +1. Create virtual environment in repo: + `cd ` + `uv venv .venv` + +2. Activate the virtual environment: + `source .venv/bin/activate` # or `uv pip sync` for project-based workflows + +3. Install packages into activated environment: + `uv pip install ` + `uv pip install -r requirements.txt` + +4. Run Python commands: + `python script.py` # Uses activated .venv + +Alternative: Use `uv run python script.py` to skip explicit activation + +**Important:** + +- Always create .venv in the repository directory (not workspace root) +- Activate the environment before running pip operations +- uv is pre-installed in the container and provides faster package installation +- .venv directories created in repos will persist but can be removed safely + +## General Guidelines + +- This file is merged with any AGENTS.md files in individual repositories +- Repository-specific instructions take precedence for their respective codebases diff --git a/backend/src/index.ts b/backend/src/index.ts index 21dd615b..1423d989 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -37,6 +37,9 @@ import { createPromptTemplateRoutes } from './routes/prompt-templates' import { createInternalRoutes } from './routes/internal' import { sweepStaleUploadSessions } from './routes/internal/repo-mirror-helpers' import { createOpenCodeProxyRoutes } from './routes/opencode-proxy' +import { createDevProxyRoutes } from './routes/dev-proxy' +import { createDevServerRoutes } from './routes/dev-server' +import { createDevProxyUpgradeHandler } from './services/dev-server/upgrade-handler' import { sseAggregator } from './services/sse-aggregator' import { ensureDirectoryExists, writeFileContent, fileExists, readFileContent } from './services/file-operations' import { SettingsService } from './services/settings' @@ -342,6 +345,8 @@ protectedApi.route('/ssh', createSSHRoutes(gitAuthService)) protectedApi.route('/notifications', createNotificationRoutes(notificationService)) protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db)) protectedApi.route('/schedules', createScheduleRoutes(scheduleService)) +protectedApi.route('/dev-server', createDevServerRoutes(db)) +protectedApi.route('/dev-proxy', createDevProxyRoutes(db)) app.route('/api', protectedApi) @@ -461,10 +466,12 @@ const shutdown = async (signal: string) => { process.on('SIGTERM', () => shutdown('SIGTERM')) process.on('SIGINT', () => shutdown('SIGINT')) -serve({ +const server = serve({ fetch: app.fetch, port: PORT, hostname: HOST, }) +server.on('upgrade', createDevProxyUpgradeHandler(auth, db)) + logger.info(`🚀 OpenCode WebUI API running on http://${HOST}:${PORT}`) diff --git a/backend/src/routes/dev-proxy.ts b/backend/src/routes/dev-proxy.ts new file mode 100644 index 00000000..20647f68 --- /dev/null +++ b/backend/src/routes/dev-proxy.ts @@ -0,0 +1,152 @@ +import { Hono, type Context } from 'hono' +import type { Database } from 'bun:sqlite' +import { getDevServerPort } from '../services/dev-server/manager' +import { + parseDevProxyPath, + buildUpstreamUrl, + filterProxyHeaders, + sanitizeUpstreamResponseHeaders, + prepareTransformedResponseHeaders, + isWebSocketUpgrade, + injectBaseTag, + rewriteDevProxyCssPaths, + rewriteDevProxyHtmlPaths, + rewriteDevProxyJavaScriptPaths, + rewriteViteClientHmrBase, + DEV_PROXY_PREFIX, +} from '../services/dev-server/proxy-utils' +import { getDevServerConfig } from '../db/queries' + +function getNotRunningHtml(port: number): string { + return ` + +Dev Server Not Running + +

Dev Server Not Running

+

No development server responded on localhost:${port}.

+

Go back

+ +` +} + +export function createDevProxyRoutes(db: Database): Hono { + const app = new Hono() + + app.all('/:repoId', async (c) => { + return handleProxyRequest(c, db) + }) + + app.all('/:repoId/*', async (c) => { + return handleProxyRequest(c, db) + }) + + return app +} + +async function handleProxyRequest(c: Context, db: Database): Promise { + const repoId = parseInt(c.req.param('repoId'), 10) + if (isNaN(repoId)) { + return c.json({ error: 'Invalid repoId' }, 400) + } + + const config = getDevServerConfig(db, repoId) + const port = getDevServerPort(db) + + if (isWebSocketUpgrade((key: string) => c.req.header(key))) { + return c.json({ error: 'WebSocket handled by upgrade listener' }, 426) + } + + const url = new URL(c.req.url) + const parsed = parseDevProxyPath(url.pathname) ?? parseMountedDevProxyPath(url.pathname, repoId) + if (!parsed) { + return c.json({ error: 'Invalid proxy path' }, 400) + } + + const upstreamUrl = buildUpstreamUrl(port, parsed.rest, url.search) + + const filteredHeaders = filterProxyHeaders(c.req.raw.headers) + delete filteredHeaders['if-none-match'] + delete filteredHeaders['if-modified-since'] + + let requestBody: RequestInit['body'] = undefined + if (c.req.method !== 'GET' && c.req.method !== 'HEAD') { + requestBody = c.req.raw.body + } + + let upstreamResponse: Response + try { + upstreamResponse = await fetch(upstreamUrl, { + method: c.req.method, + headers: filteredHeaders, + body: requestBody, + redirect: 'manual', + duplex: 'half', + }) + } catch { + return c.html(getNotRunningHtml(port), 503) + } + + const sanitizedHeaders = sanitizeUpstreamResponseHeaders(upstreamResponse.headers) + + const contentType = upstreamResponse.headers.get('content-type') ?? '' + + if (contentType.includes('text/html')) { + const text = await upstreamResponse.text() + const basePath = `${DEV_PROXY_PREFIX}/${repoId}/` + const modified = config.injectBase ? injectBaseTag(text, basePath) : rewriteDevProxyHtmlPaths(text, basePath) + return new Response(modified, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers: prepareTransformedResponseHeaders(sanitizedHeaders), + }) + } + + if (isJavaScriptContentType(contentType)) { + const text = await upstreamResponse.text() + const basePath = `${DEV_PROXY_PREFIX}/${repoId}/` + const rewrittenPaths = rewriteDevProxyJavaScriptPaths(text, basePath) + const modified = isViteClientRequest(parsed.rest) + ? rewriteViteClientHmrBase(rewrittenPaths, basePath) + : rewrittenPaths + return new Response(modified, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers: prepareTransformedResponseHeaders(sanitizedHeaders), + }) + } + + if (isCssContentType(contentType)) { + const text = await upstreamResponse.text() + const modified = rewriteDevProxyCssPaths(text, `${DEV_PROXY_PREFIX}/${repoId}/`) + return new Response(modified, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers: prepareTransformedResponseHeaders(sanitizedHeaders), + }) + } + + return new Response(upstreamResponse.body, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers: sanitizedHeaders, + }) +} + +function isViteClientRequest(rest: string): boolean { + return rest === '/@vite/client' +} + +function isJavaScriptContentType(contentType: string): boolean { + return contentType.includes('javascript') || contentType.includes('ecmascript') +} + +function isCssContentType(contentType: string): boolean { + return contentType.includes('text/css') +} + +function parseMountedDevProxyPath(pathname: string, repoId: number): { repoId: number; rest: string } | null { + const prefix = `/${repoId}` + if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) return null + const rest = pathname.slice(prefix.length) + return { repoId, rest: rest || '/' } +} diff --git a/backend/src/routes/dev-server.ts b/backend/src/routes/dev-server.ts new file mode 100644 index 00000000..fa2a0f11 --- /dev/null +++ b/backend/src/routes/dev-server.ts @@ -0,0 +1,64 @@ +import { Hono } from 'hono' +import type { Database } from 'bun:sqlite' +import { z } from 'zod' +import { getRepoById, getDevServerConfig, setDevServerConfig } from '../db/queries' +import { getDevServerState } from '../services/dev-server/manager' + +const UpdateConfigSchema = z.object({ + injectBase: z.boolean(), +}) + +export function createDevServerRoutes(db: Database): Hono { + const app = new Hono() + + app.get('/:repoId/status', async (c) => { + const repoId = parseInt(c.req.param('repoId'), 10) + if (isNaN(repoId)) { + return c.json({ error: 'Invalid repoId' }, 400) + } + + const repo = getRepoById(db, repoId) + if (!repo) { + return c.json({ error: 'Repository not found' }, 404) + } + + const state = await getDevServerState(db, repo.id) + + return c.json(state) + }) + + app.get('/:repoId/config', async (c) => { + const repoId = parseInt(c.req.param('repoId'), 10) + if (isNaN(repoId)) { + return c.json({ error: 'Invalid repoId' }, 400) + } + + const config = getDevServerConfig(db, repoId) + return c.json(config) + }) + + app.put('/:repoId/config', async (c) => { + const repoId = parseInt(c.req.param('repoId'), 10) + if (isNaN(repoId)) { + return c.json({ error: 'Invalid repoId' }, 400) + } + + let body: unknown + try { + body = await c.req.json() + } catch { + return c.json({ error: 'Invalid JSON body' }, 400) + } + + const parsed = UpdateConfigSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: parsed.error.flatten() }, 400) + } + + setDevServerConfig(db, repoId, parsed.data) + const saved = getDevServerConfig(db, repoId) + return c.json(saved) + }) + + return app +} diff --git a/backend/src/routes/files.ts b/backend/src/routes/files.ts index 4b43f6e4..eb162372 100644 --- a/backend/src/routes/files.ts +++ b/backend/src/routes/files.ts @@ -23,12 +23,139 @@ function getSpecialRoutePathFromRequest(c: Context, routeName: string): string | return match?.[1] ? decodeFilePath(match[1]) : undefined } +function getPreviewPathFromRequest(c: Context): string | undefined { + const path = c.req.path + const prefix = '/api/files/preview' + + const queryPath = c.req.query('path') + if (queryPath !== undefined) { + return queryPath + } + + if (path.startsWith(prefix + '/')) { + const previewPath = path.slice(prefix.length + 1) + return decodeURIComponent(previewPath) + } + + return undefined +} + +const PREVIEWABLE_MIME_TYPES = new Set([ + 'text/html', + 'text/css', + 'text/javascript', + 'application/javascript', + 'application/json', + 'application/xml', + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/svg+xml', +]) + +function isPreviewableMimeType(mimeType?: string): boolean { + return mimeType !== undefined && PREVIEWABLE_MIME_TYPES.has(mimeType) +} + +function buildPreviewValidators(stat: { size: number; lastModified: Date }): { etag: string; lastModified: string } { + return { + etag: `"${stat.size.toString(16)}-${stat.lastModified.getTime().toString(16)}"`, + lastModified: stat.lastModified.toUTCString(), + } +} + +function isPreviewFresh(c: Context, validators: { etag: string; lastModified: string }): boolean { + const ifNoneMatch = c.req.header('if-none-match') + if (ifNoneMatch !== undefined) { + return ifNoneMatch.split(',').some((tag: string) => tag.trim() === validators.etag) + } + + const ifModifiedSince = c.req.header('if-modified-since') + if (ifModifiedSince !== undefined) { + const since = Date.parse(ifModifiedSince) + return !Number.isNaN(since) && Date.parse(validators.lastModified) <= since + } + + return false +} + +function getPreviewHeaders(result: { + name: string + size: number + mimeType?: string + lastModified: Date +}): Record { + const isHtmlSvg = result.mimeType === 'text/html' || result.mimeType === 'image/svg+xml' + const validators = buildPreviewValidators(result) + const headers: Record = { + 'Content-Type': result.mimeType || 'application/octet-stream', + 'Content-Length': result.size.toString(), + 'Content-Disposition': `inline; filename="${result.name}"`, + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'no-referrer', + 'Cache-Control': 'private, must-revalidate, max-age=0', + 'ETag': validators.etag, + 'Last-Modified': validators.lastModified, + } + + if (isHtmlSvg) { + headers['Content-Security-Policy'] = [ + 'sandbox allow-scripts allow-same-origin;', + "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval';", + "script-src 'self' http: https: 'unsafe-inline' 'unsafe-eval';", + "style-src 'self' http: https: 'unsafe-inline';", + "img-src 'self' http: https: data: blob:;", + "font-src 'self' http: https: data:;", + "connect-src 'self' http: https:;", + "media-src 'self' http: https: data: blob:;", + "object-src 'none';", + "base-uri 'none';", + "frame-ancestors 'self'", + ].join(' ') + } + + return headers +} + export function createFileRoutes() { const app = new Hono() app.get('*', async (c) => { const path = c.req.path + if (path === '/api/files/preview' || path.startsWith('/api/files/preview/')) { + const userPath = getPreviewPathFromRequest(c) + + if (!userPath) { + return c.json({ error: 'No path provided' }, 400) + } + + try { + const stat = await fileService.getFilePreviewStat(userPath) + + if (stat.isDirectory) { + return c.json({ error: 'Cannot preview directories' }, 400) + } + + if (!isPreviewableMimeType(stat.mimeType)) { + return c.json({ error: 'File type cannot be previewed' }, 415) + } + + const headers = getPreviewHeaders(stat) + + if (isPreviewFresh(c, buildPreviewValidators(stat))) { + return new Response(null, { status: 304, headers }) + } + + const content = await fileService.getRawFileContent(userPath) + + return new Response(content, { headers }) + } catch (error: unknown) { + logger.error('Failed to preview file:', error) + return c.json({ error: getErrorMessage(error) || 'Failed to preview file' }, getStatusCode(error) as ContentfulStatusCode) + } + } + if (path.endsWith('/download-zip')) { const userPath = getSpecialRoutePathFromRequest(c, 'download-zip') diff --git a/backend/src/routes/internal/git-credentials.ts b/backend/src/routes/internal/git-credentials.ts deleted file mode 100644 index 53a1c470..00000000 --- a/backend/src/routes/internal/git-credentials.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Hono } from 'hono' -import type { Database } from 'bun:sqlite' -import { CredentialProvider } from '../../services/credential-provider' - -export function createInternalGitCredentialsRoutes(db: Database) { - const app = new Hono() - - app.get('/gh-env', (c) => { - const provider = new CredentialProvider(db) - return c.json(provider.getGhCliEnv({ cwd: c.req.query('cwd') })) - }) - - return app -} diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts index 590ef337..a3200857 100644 --- a/backend/src/routes/internal/index.ts +++ b/backend/src/routes/internal/index.ts @@ -13,7 +13,7 @@ import { createInternalRepoSyncRoutes } from './repo-sync' import { createInternalRepoMirrorRoutes as mirrorRoutes } from './repo-mirror' import { createInternalOpenCodeWorkspacesRoutes } from './opencode-workspaces' import { createInternalAssistantRoutes } from './assistant' -import { createInternalGitCredentialsRoutes } from './git-credentials' +import { createInternalShellEnvRoutes } from './shell-env' export function createInternalRoutes( db: Database, @@ -35,6 +35,6 @@ export function createInternalRoutes( app.route('/repos', repos) app.route('/opencode-workspaces', createInternalOpenCodeWorkspacesRoutes(db)) app.route('/assistant', createInternalAssistantRoutes(openCodeClient)) - app.route('/git-credentials', createInternalGitCredentialsRoutes(db)) + app.route('/shell-env', createInternalShellEnvRoutes(db)) return app } diff --git a/backend/src/routes/internal/shell-env.ts b/backend/src/routes/internal/shell-env.ts new file mode 100644 index 00000000..19b4d887 --- /dev/null +++ b/backend/src/routes/internal/shell-env.ts @@ -0,0 +1,18 @@ +import { Hono } from 'hono' +import type { Database } from 'bun:sqlite' +import { CredentialProvider } from '../../services/credential-provider' +import { getDevServerPort } from '../../services/dev-server/manager' + +export function createInternalShellEnvRoutes(db: Database) { + const app = new Hono() + + app.get('/', (c) => { + const provider = new CredentialProvider(db) + return c.json({ + ...provider.getGhCliEnv({ cwd: c.req.query('cwd') }), + OCM_DEV_SERVER_PORT: String(getDevServerPort(db)), + }) + }) + + return app +} diff --git a/backend/src/services/dev-server/manager.ts b/backend/src/services/dev-server/manager.ts new file mode 100644 index 00000000..2a0721a3 --- /dev/null +++ b/backend/src/services/dev-server/manager.ts @@ -0,0 +1,22 @@ +import type { Database } from 'bun:sqlite' +import { DEFAULT_DEV_SERVER_PORT, type DevServerState } from '@opencode-manager/shared/types' +import { SettingsService } from '../settings' +import { isPortOpen } from './ports' + +export function getDevServerPort(db: Database): number { + const port = new SettingsService(db).getSettings('default').preferences.devServerPort + return port ?? DEFAULT_DEV_SERVER_PORT +} + +export async function getDevServerState(db: Database, repoId: number): Promise { + const port = getDevServerPort(db) + const isRunning = await isPortOpen(port) + + return { + repoId, + status: isRunning ? 'running' : 'stopped', + port, + error: isRunning ? null : `No dev server detected on localhost:${port}`, + previewPath: isRunning ? `/api/dev-proxy/${repoId}/` : null, + } +} diff --git a/backend/src/services/dev-server/ports.ts b/backend/src/services/dev-server/ports.ts new file mode 100644 index 00000000..4944ed7d --- /dev/null +++ b/backend/src/services/dev-server/ports.ts @@ -0,0 +1,19 @@ +import net from 'net' + +export async function isPortOpen(port: number, host = '127.0.0.1', timeoutMs = 1000): Promise { + return new Promise((resolve) => { + const socket = new net.Socket() + const onError = () => { + socket.destroy() + resolve(false) + } + socket.setTimeout(timeoutMs) + socket.once('connect', () => { + socket.destroy() + resolve(true) + }) + socket.once('error', onError) + socket.once('timeout', onError) + socket.connect(port, host) + }) +} diff --git a/backend/src/services/dev-server/proxy-utils.ts b/backend/src/services/dev-server/proxy-utils.ts new file mode 100644 index 00000000..38b1a8d7 --- /dev/null +++ b/backend/src/services/dev-server/proxy-utils.ts @@ -0,0 +1,182 @@ +export const DEV_PROXY_PREFIX = '/api/dev-proxy' + +export const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailers', + 'upgrade', + 'transfer-encoding', + 'content-length', + 'content-encoding', + 'host', +]) + +export function parseDevProxyPath(pathname: string): { repoId: number; rest: string } | null { + const match = pathname.match(/^\/api\/dev-proxy\/(\d+)(\/.*)?$/) + if (!match) return null + return { repoId: parseInt(match[1]!, 10), rest: match[2] ?? '/' } +} + +export function buildUpstreamUrl(port: number, rest: string, search: string): string { + return `http://127.0.0.1:${port}${rest}${search}` +} + +export function filterProxyHeaders(headers: Headers): Record { + const result: Record = {} + headers.forEach((value, key) => { + if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) { + result[key] = value + } + }) + return result +} + +export function sanitizeUpstreamResponseHeaders(headers: Headers): Record { + const blocked = new Set(['x-frame-options', 'content-security-policy']) + const result: Record = {} + headers.forEach((value, key) => { + const lower = key.toLowerCase() + if (!HOP_BY_HOP_HEADERS.has(lower) && !blocked.has(lower)) { + result[key] = value + } + }) + result['referrer-policy'] = 'no-referrer' + return result +} + +export function prepareTransformedResponseHeaders(headers: Record): Record { + const result = { ...headers } + delete result.etag + delete result.ETag + delete result['last-modified'] + delete result['Last-Modified'] + result['cache-control'] = 'no-store' + return result +} + +export function isWebSocketUpgrade(headerGet: (k: string) => string | undefined): boolean { + const connection = headerGet('connection')?.toLowerCase() ?? '' + const upgrade = headerGet('upgrade')?.toLowerCase() ?? '' + return connection.includes('upgrade') && upgrade === 'websocket' +} + +export function injectBaseTag(html: string, basePath: string): string { + const htmlWithRewrittenPaths = rewriteDevProxyHtmlPaths(html, basePath) + if (/]*>/i) + if (!match) return htmlWithRewrittenPaths + + const idx = match.index! + match[0].length + return htmlWithRewrittenPaths.slice(0, idx) + `` + htmlWithRewrittenPaths.slice(idx) +} + +export function rewriteDevProxyHtmlPaths(html: string, basePath: string): string { + const rewrittenAttributes = html.replace( + /\b(src|href|action|poster)=(['"])(\/[^/][^'"]*)\2/gi, + (match, attribute: string, quote: string, path: string, offset: number, fullHtml: string) => { + if (isBaseTagAttribute(fullHtml, offset)) return match + const proxiedPath = toDevProxyPath(path, basePath) + if (proxiedPath === path) return match + return `${attribute}=${quote}${proxiedPath}${quote}` + } + ) + + const rewrittenSrcSets = rewrittenAttributes.replace( + /\bsrcset=(['"])([^'"]*)\1/gi, + (_match, quote: string, value: string) => { + const rewrittenValue = value + .split(',') + .map((candidate) => rewriteSrcSetCandidate(candidate, basePath)) + .join(',') + return `srcset=${quote}${rewrittenValue}${quote}` + } + ) + + return rewriteCssUrlReferences(rewrittenSrcSets, basePath) +} + +export function rewriteViteClientHmrBase(js: string, basePath: string): string { + const normalizedBasePath = basePath.endsWith('/') ? basePath : `${basePath}/` + + return js.replace( + /(\$\{\s*hmrPort\s*\|\|\s*importMetaUrl\.port\s*\}\$\{)(['"])([^'"]*)\2(\})/, + (match, prefix: string, quote: string, hmrBase: string, suffix: string) => { + if (hmrBase.startsWith(normalizedBasePath)) return match + const hmrBasePath = hmrBase.startsWith('/') ? hmrBase.slice(1) : hmrBase + const proxiedHmrBase = `${normalizedBasePath}${hmrBasePath}` + return `${prefix}${quote}${proxiedHmrBase}${quote}${suffix}` + } + ) +} + +export function rewriteDevProxyJavaScriptPaths(js: string, basePath: string): string { + const rewrittenStaticImports = js.replace( + /\b((?:import|export)\s+(?:[^'"]*?\s+from\s+)?)(['"])(\/[^/][^'"]*)\2/g, + (match, prefix: string, quote: string, path: string) => { + const proxiedPath = toDevProxyPath(path, basePath) + if (proxiedPath === path) return match + return `${prefix}${quote}${proxiedPath}${quote}` + } + ) + + return rewrittenStaticImports.replace( + /\b(import\(\s*)(['"])(\/[^/][^'"]*)\2(\s*\))/g, + (match, prefix: string, quote: string, path: string, suffix: string) => { + const proxiedPath = toDevProxyPath(path, basePath) + if (proxiedPath === path) return match + return `${prefix}${quote}${proxiedPath}${quote}${suffix}` + } + ) +} + +export function rewriteDevProxyCssPaths(css: string, basePath: string): string { + return rewriteCssUrlReferences(css, basePath) +} + +function rewriteCssUrlReferences(content: string, basePath: string): string { + const rewrittenUrls = content.replace( + /\burl\(\s*(['"]?)(\/(?!\/)[^)'"]*)\1\s*\)/gi, + (match, quote: string, path: string) => { + const proxiedPath = toDevProxyPath(path, basePath) + if (proxiedPath === path) return match + return `url(${quote}${proxiedPath}${quote})` + } + ) + + return rewrittenUrls.replace( + /(@import\s+)(['"])(\/(?!\/)[^'"]*)\2/gi, + (match, prefix: string, quote: string, path: string) => { + const proxiedPath = toDevProxyPath(path, basePath) + if (proxiedPath === path) return match + return `${prefix}${quote}${proxiedPath}${quote}` + } + ) +} + +function rewriteSrcSetCandidate(candidate: string, basePath: string): string { + const match = candidate.match(/^(\s*)(\/\S+)(.*)$/) + if (!match) return candidate + const [, leading = '', path = '', descriptor = ''] = match + return `${leading}${toDevProxyPath(path, basePath)}${descriptor}` +} + +function toDevProxyPath(path: string, basePath: string): string { + const normalizedBasePath = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath + const escapedBasePath = escapeRegExp(normalizedBasePath) + if (new RegExp(`^${escapedBasePath}(?:/|$)`).test(path)) return path + return `${normalizedBasePath}${path}` +} + +function isBaseTagAttribute(html: string, attributeOffset: number): boolean { + const tagStart = html.lastIndexOf('<', attributeOffset) + if (tagStart === -1) return false + return /^<\s*base\b/i.test(html.slice(tagStart, attributeOffset)) +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} diff --git a/backend/src/services/dev-server/upgrade-handler.ts b/backend/src/services/dev-server/upgrade-handler.ts new file mode 100644 index 00000000..68e8eb7f --- /dev/null +++ b/backend/src/services/dev-server/upgrade-handler.ts @@ -0,0 +1,125 @@ +import type { IncomingMessage } from 'http' +import type { Duplex } from 'stream' +import type { Database } from 'bun:sqlite' +import net from 'net' +import type { AuthInstance } from '../../auth' +import { getDevServerPort } from './manager' +import { parseDevProxyPath } from './proxy-utils' +import { logger } from '../../utils/logger' + +export function buildUpstreamUpgradeRequest(rawHead: string, rest: string, port: number): string { + const lines = rawHead.split('\r\n') + if (lines.length === 0 || (lines.length === 1 && lines[0] === '')) return rawHead + + const requestLine = lines[0]! + const parts = requestLine.split(' ') + const rewrittenLine = `${parts[0]} ${rest} ${parts.slice(2).join(' ')}` + + const result: string[] = [rewrittenLine] + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]! + const colonIdx = line.indexOf(':') + if (colonIdx !== -1) { + const headerName = line.slice(0, colonIdx).trim().toLowerCase() + if (headerName === 'host') continue + } + result.push(line) + } + + result.push(`Host: 127.0.0.1:${port}`) + + return result.join('\r\n') +} + +function nodeHeadersToWebHeaders(reqHeaders: IncomingMessage['headers']): Headers { + const headers = new Headers() + for (const [key, value] of Object.entries(reqHeaders)) { + if (value !== undefined) { + if (Array.isArray(value)) { + for (const v of value) { + headers.append(key, v) + } + } else { + headers.set(key, value) + } + } + } + return headers +} + +export function createDevProxyUpgradeHandler(auth: AuthInstance, db: Database) { + return async (req: IncomingMessage, socket: Duplex, head: Buffer): Promise => { + try { + const url = req.url ?? '' + const parsed = parseDevProxyPath(url) + if (!parsed) { + socket.destroy() + return + } + + const headers = nodeHeadersToWebHeaders(req.headers) + let session + try { + session = await auth.api.getSession({ headers }) + } catch { + socket.destroy() + return + } + + if (!session) { + socket.write('HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n') + socket.destroy() + return + } + + const port = getDevServerPort(db) + + const rawHeadLines: string[] = [`${req.method} ${req.url} HTTP/1.1`] + for (const [key, value] of Object.entries(req.headers)) { + if (value !== undefined) { + if (Array.isArray(value)) { + for (const v of value) { + rawHeadLines.push(`${key}: ${v}`) + } + } else { + rawHeadLines.push(`${key}: ${value}`) + } + } + } + const rawHead = rawHeadLines.join('\r\n') + + const upstreamRequest = buildUpstreamUpgradeRequest(rawHead, parsed.rest, port) + + const upstream = net.connect(port, '127.0.0.1') + + upstream.on('connect', () => { + upstream.write(upstreamRequest + '\r\n\r\n') + if (head.length > 0) { + upstream.write(head) + } + socket.pipe(upstream) + upstream.pipe(socket) + }) + + upstream.on('error', () => { + socket.destroy() + }) + + socket.on('error', () => { + upstream.destroy() + }) + + socket.on('close', () => { + upstream.destroy() + }) + + upstream.on('close', () => { + socket.destroy() + }) + } catch (error) { + logger.error('Dev proxy upgrade handler error:', error) + socket.destroy() + } + } +} diff --git a/backend/src/services/files.ts b/backend/src/services/files.ts index 4dfc77f1..5c4b7a4f 100644 --- a/backend/src/services/files.ts +++ b/backend/src/services/files.ts @@ -61,6 +61,33 @@ export async function getRawFileContent(userPath: string): Promise { } } +export interface FilePreviewStat { + isDirectory: boolean + name: string + size: number + mimeType: string + lastModified: Date +} + +export async function getFilePreviewStat(userPath: string): Promise { + const validatedPath = validatePath(userPath) + + const exists = await fileExists(validatedPath) + if (!exists) { + throw { message: 'File not found or cannot be read', statusCode: 404 } + } + + const stats = await getFileStats(validatedPath) + + return { + isDirectory: stats.isDirectory, + name: path.basename(validatedPath), + size: stats.size, + mimeType: getMimeType(validatedPath), + lastModified: stats.lastModified, + } +} + export async function getFile(userPath: string): Promise { const validatedPath = validatePath(userPath) logger.info(`Getting file for path: ${userPath} -> ${validatedPath}`) @@ -253,6 +280,7 @@ function getMimeType(filePath: string): AllowedMimeType { const mimeTypes: Record = { '.txt': 'text/plain', '.html': 'text/html', + '.htm': 'text/html', '.css': 'text/css', '.js': 'text/javascript', '.ts': 'text/typescript', diff --git a/backend/src/services/opencode-gh-env-plugin.ts b/backend/src/services/opencode-gh-env-plugin.ts index 81e908d0..7911156e 100644 --- a/backend/src/services/opencode-gh-env-plugin.ts +++ b/backend/src/services/opencode-gh-env-plugin.ts @@ -16,7 +16,7 @@ async function fetchGhEnv(cwd) { const cached = cache.get(cacheKey) if (cached && now < cached.expiry) return cached.env try { - const url = new URL(baseUrl + '/git-credentials/gh-env') + const url = new URL(baseUrl + '/shell-env') if (cwd) url.searchParams.set('cwd', cwd) const res = await fetch(url, { headers: { Authorization: 'Bearer ' + token }, diff --git a/backend/src/text-modules.d.ts b/backend/src/text-modules.d.ts new file mode 100644 index 00000000..43d00fea --- /dev/null +++ b/backend/src/text-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.md' { + const content: string + export default content +} diff --git a/backend/test/routes/dev-server.test.ts b/backend/test/routes/dev-server.test.ts new file mode 100644 index 00000000..fb30185e --- /dev/null +++ b/backend/test/routes/dev-server.test.ts @@ -0,0 +1,265 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +vi.mock('../../src/services/dev-server/manager', () => ({ + getDevServerState: vi.fn(), + getDevServerPort: vi.fn(() => 5100), +})) + +vi.mock('../../src/db/queries', () => ({ + getRepoById: vi.fn(), + getDevServerConfig: vi.fn(), + setDevServerConfig: vi.fn(), +})) + +import { getDevServerState } from '../../src/services/dev-server/manager' +import { getRepoById, getDevServerConfig, setDevServerConfig } from '../../src/db/queries' +import { createDevServerRoutes } from '../../src/routes/dev-server' +import { createDevProxyRoutes } from '../../src/routes/dev-proxy' +import { injectBaseTag, rewriteDevProxyHtmlPaths, rewriteDevProxyJavaScriptPaths, rewriteViteClientHmrBase } from '../../src/services/dev-server/proxy-utils' + +describe('DevServer Management Routes', () => { + let devServerApp: ReturnType + let mockDb: any + + beforeEach(() => { + vi.clearAllMocks() + mockDb = {} as any + devServerApp = createDevServerRoutes(mockDb) + }) + + describe('GET /:repoId/status', () => { + it('returns 404 when repo is not found', async () => { + vi.mocked(getRepoById).mockReturnValue(null) + + const res = await devServerApp.fetch(new Request('http://localhost/999/status')) + expect(res.status).toBe(404) + const body = await res.json() as Record + expect(body.error).toBe('Repository not found') + }) + + it('returns configured preview port status', async () => { + const mockRepo = { id: 1, fullPath: '/test/repo', repoUrl: null, localPath: '/test/repo' } + vi.mocked(getRepoById).mockReturnValue(mockRepo as any) + const mockState = { + repoId: 1, + status: 'running' as const, + port: 5100, + error: null, + previewPath: '/api/dev-proxy/1/', + } + vi.mocked(getDevServerState).mockResolvedValue(mockState) + + const res = await devServerApp.fetch(new Request('http://localhost/1/status')) + expect(res.status).toBe(200) + const body = await res.json() as Record + expect(body.status).toBe('running') + expect(body.port).toBe(5100) + expect(body.previewPath).toBe('/api/dev-proxy/1/') + expect(getDevServerState).toHaveBeenCalledWith(mockDb, 1) + }) + }) + + describe('GET /:repoId/config', () => { + it('returns dev server config for repo', async () => { + vi.mocked(getDevServerConfig).mockReturnValue({ injectBase: true }) + + const res = await devServerApp.fetch(new Request('http://localhost/1/config')) + expect(res.status).toBe(200) + const body = await res.json() as Record + expect(body.injectBase).toBe(true) + }) + + it('returns 400 for invalid repoId', async () => { + const res = await devServerApp.fetch(new Request('http://localhost/abc/config')) + expect(res.status).toBe(400) + }) + }) + + describe('PUT /:repoId/config', () => { + it('rejects body missing injectBase', async () => { + const res = await devServerApp.fetch( + new Request('http://localhost/1/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + ) + expect(res.status).toBe(400) + }) + + it('rejects non-boolean injectBase', async () => { + const res = await devServerApp.fetch( + new Request('http://localhost/1/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ injectBase: 'yes' }), + }) + ) + expect(res.status).toBe(400) + }) + + it('persists valid config and returns saved values', async () => { + vi.mocked(setDevServerConfig).mockImplementation(() => {}) + vi.mocked(getDevServerConfig).mockReturnValue({ injectBase: true }) + + const res = await devServerApp.fetch( + new Request('http://localhost/1/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ injectBase: true }), + }) + ) + expect(res.status).toBe(200) + const body = await res.json() as Record + expect(body.injectBase).toBe(true) + expect(setDevServerConfig).toHaveBeenCalledWith(mockDb, 1, { injectBase: true }) + }) + }) +}) + +describe('DevProxy Routes', () => { + let proxyApp: ReturnType + let mockDb: any + + beforeEach(() => { + vi.clearAllMocks() + mockDb = {} as any + proxyApp = createDevProxyRoutes(mockDb) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe('GET /:repoId/ and /:repoId/*', () => { + it('returns 503 HTML when dev server is not running', async () => { + vi.mocked(getDevServerConfig).mockReturnValue({ injectBase: false }) + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not running'))) + + const res = await proxyApp.fetch(new Request('http://localhost/1/')) + expect(res.status).toBe(503) + const text = await res.text() + expect(text).toContain('localhost:5100') + expect(res.headers.get('content-type')).toContain('text/html') + }) + + it('returns 503 HTML when dev server is not running (sub-path)', async () => { + vi.mocked(getDevServerConfig).mockReturnValue({ injectBase: false }) + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not running'))) + + const res = await proxyApp.fetch(new Request('http://localhost/1/some/page')) + expect(res.status).toBe(503) + const text = await res.text() + expect(text).toContain('Dev Server Not Running') + }) + + it('returns 426 for WebSocket upgrade requests', async () => { + vi.mocked(getDevServerConfig).mockReturnValue({ injectBase: false }) + + const res = await proxyApp.fetch(new Request('http://localhost/1/', { + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + }, + })) + expect(res.status).toBe(426) + const body = await res.json() as Record + expect(body.error).toContain('WebSocket') + }) + + it('rewrites Vite client HMR websocket base to stay under the dev proxy prefix', async () => { + vi.mocked(getDevServerConfig).mockReturnValue({ injectBase: false }) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response( + 'import "/node_modules/vite/dist/client/env.mjs"; const socketHost = `${__HMR_HOSTNAME__ || importMetaUrl.hostname}:${hmrPort || importMetaUrl.port}${"/"}`', + { headers: { 'content-type': 'application/javascript' } } + ))) + + const res = await proxyApp.fetch(new Request('http://localhost/1/@vite/client')) + const text = await res.text() + + expect(text).toContain('${"/api/dev-proxy/1/"}') + expect(text).toContain('import "/api/dev-proxy/1/node_modules/vite/dist/client/env.mjs"') + }) + + it('rewrites root absolute HTML paths even when base injection is disabled', async () => { + vi.mocked(getDevServerConfig).mockReturnValue({ injectBase: false }) + const fetchMock = vi.fn().mockResolvedValue(new Response( + '', + { headers: { 'content-type': 'text/html', etag: 'upstream-etag' } } + )) + vi.stubGlobal('fetch', fetchMock) + + const res = await proxyApp.fetch(new Request('http://localhost/1/', { + headers: { + 'if-none-match': 'cached-etag', + 'if-modified-since': 'Sun, 21 Jun 2026 18:23:30 GMT', + }, + })) + const text = await res.text() + const upstreamRequest = fetchMock.mock.calls[0]?.[1] as RequestInit + const upstreamHeaders = upstreamRequest.headers as Record + + expect(text).toContain('href="/api/dev-proxy/1/src/style.css"') + expect(text).toContain('src="/api/dev-proxy/1/src/tetris.js"') + expect(text).not.toContain(' { + vi.mocked(getDevServerConfig).mockReturnValue({ injectBase: false }) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response( + '@import "/reset.css"; .hero{background:url(/assets/hero.png)}', + { headers: { 'content-type': 'text/css', etag: 'css-etag' } } + ))) + + const res = await proxyApp.fetch(new Request('http://localhost/1/assets/app.css')) + const text = await res.text() + + expect(text).toContain('@import "/api/dev-proxy/1/reset.css"') + expect(text).toContain('url(/api/dev-proxy/1/assets/hero.png)') + expect(res.headers.get('etag')).toBeNull() + expect(res.headers.get('cache-control')).toBe('no-store') + }) + }) +}) + +describe('DevProxy HTML rewriting', () => { + it('keeps absolute Vite module paths under the dev proxy prefix', () => { + const html = '' + + const rewritten = injectBaseTag(html, '/api/dev-proxy/1/') + + expect(rewritten).toContain('src="/api/dev-proxy/1/@vite/client"') + expect(rewritten).toContain('src="/api/dev-proxy/1/src/main.tsx"') + }) + + it('rewrites absolute paths without injecting a base tag', () => { + const html = '' + + const rewritten = rewriteDevProxyHtmlPaths(html, '/api/dev-proxy/1/') + + expect(rewritten).toContain('src="/api/dev-proxy/1/src/tetris.js"') + expect(rewritten).not.toContain(' { + const js = 'const socketHost = `${__HMR_HOSTNAME__ || importMetaUrl.hostname}:${hmrPort || importMetaUrl.port}${"/"}`' + + const rewritten = rewriteViteClientHmrBase(js, '/api/dev-proxy/1/') + + expect(rewritten).toContain('${"/api/dev-proxy/1/"}') + }) + + it('keeps JavaScript module paths under the dev proxy prefix', () => { + const js = 'import "/node_modules/vite/dist/client/env.mjs"; export { game } from "/src/game.js"; import("/src/lazy.js")' + + const rewritten = rewriteDevProxyJavaScriptPaths(js, '/api/dev-proxy/1/') + + expect(rewritten).toContain('import "/api/dev-proxy/1/node_modules/vite/dist/client/env.mjs"') + expect(rewritten).toContain('from "/api/dev-proxy/1/src/game.js"') + expect(rewritten).toContain('import("/api/dev-proxy/1/src/lazy.js")') + }) +}) diff --git a/backend/test/routes/files.test.ts b/backend/test/routes/files.test.ts index 23aaa5ad..ceddc700 100644 --- a/backend/test/routes/files.test.ts +++ b/backend/test/routes/files.test.ts @@ -30,6 +30,7 @@ vi.mock('@opencode-manager/shared/config/env', () => ({ vi.mock('../../src/services/files', () => ({ getFile: vi.fn(), + getFilePreviewStat: vi.fn(), getRawFileContent: vi.fn(), getFileRange: vi.fn(), uploadFile: vi.fn(), @@ -47,6 +48,8 @@ vi.mock('../../src/services/archive', () => ({ })) const getFile = fileService.getFile as MockedFunction +const getFilePreviewStat = fileService.getFilePreviewStat as MockedFunction +const getRawFileContent = fileService.getRawFileContent as MockedFunction const getFileRange = fileService.getFileRange as MockedFunction const uploadFile = fileService.uploadFile as MockedFunction const createFileOrFolder = fileService.createFileOrFolder as MockedFunction @@ -69,6 +72,139 @@ describe('File Routes', () => { app.route('/api/files', filesApp) }) + describe('GET /preview/* - HTML Preview Assets', () => { + const lastModified = new Date('2024-01-01T00:00:00.000Z') + + const htmlStat = { + name: 'index.html', + isDirectory: false, + size: 31, + mimeType: 'text/html' as const, + lastModified, + } + + it('should serve HTML files with sandbox CSP and caching headers', async () => { + getFilePreviewStat.mockResolvedValue(htmlStat) + getRawFileContent.mockResolvedValue(Buffer.from('Hello')) + + const response = await app.request('/api/files/preview/test-repo/index.html') + const body = await response.text() + + expect(response.status).toBe(200) + expect(body).toBe('Hello') + expect(response.headers.get('Content-Type')).toContain('text/html') + expect(response.headers.get('Content-Disposition')).toContain('inline') + expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff') + expect(response.headers.get('Referrer-Policy')).toBe('no-referrer') + expect(response.headers.get('Content-Security-Policy')).toContain('sandbox allow-scripts allow-same-origin') + expect(response.headers.get('Content-Security-Policy')).toContain('https:') + expect(response.headers.get('Cache-Control')).toBe('private, must-revalidate, max-age=0') + expect(response.headers.get('ETag')).toBeTruthy() + expect(response.headers.get('Last-Modified')).toBe(lastModified.toUTCString()) + expect(getFilePreviewStat).toHaveBeenCalledWith('test-repo/index.html') + expect(getRawFileContent).toHaveBeenCalledWith('test-repo/index.html') + }) + + it('should return 304 when ETag matches If-None-Match without reading the file', async () => { + getFilePreviewStat.mockResolvedValue(htmlStat) + getRawFileContent.mockResolvedValue(Buffer.from('Hello')) + + const first = await app.request('/api/files/preview/test-repo/index.html') + const etag = first.headers.get('ETag') as string + await first.text() + getRawFileContent.mockClear() + + const response = await app.request('/api/files/preview/test-repo/index.html', { + headers: { 'If-None-Match': etag }, + }) + + expect(response.status).toBe(304) + expect(getRawFileContent).not.toHaveBeenCalled() + }) + + it('should serve CSS files with text/css Content-Type', async () => { + getFilePreviewStat.mockResolvedValue({ + name: 'styles.css', + isDirectory: false, + size: 50, + mimeType: 'text/css', + lastModified, + }) + getRawFileContent.mockResolvedValue(Buffer.from('body { color: red }')) + + const response = await app.request('/api/files/preview/test-repo/styles/app.css') + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toContain('text/css') + expect(response.headers.get('Content-Security-Policy')).toBeNull() + expect(getFilePreviewStat).toHaveBeenCalledWith('test-repo/styles/app.css') + }) + + it('should support query-based path parameter', async () => { + getFilePreviewStat.mockResolvedValue(htmlStat) + getRawFileContent.mockResolvedValue(Buffer.from('Hello')) + + const response = await app.request('/api/files/preview?path=test-repo/index.html') + const body = await response.text() + + expect(response.status).toBe(200) + expect(body).toBe('Hello') + expect(getFilePreviewStat).toHaveBeenCalledWith('test-repo/index.html') + }) + + it('should return 400 for directory results', async () => { + getFilePreviewStat.mockResolvedValue({ + name: 'test-repo', + isDirectory: true, + size: 0, + mimeType: 'text/plain', + lastModified, + }) + + const response = await app.request('/api/files/preview/test-repo') + + expect(response.status).toBe(400) + const body = await response.json() as { error: string } + expect(body.error).toContain('Cannot preview directories') + }) + + it('should return 415 for non-previewable MIME types', async () => { + getFilePreviewStat.mockResolvedValue({ + name: 'doc.pdf', + isDirectory: false, + size: 100, + mimeType: 'application/pdf' as never, + lastModified, + }) + + const response = await app.request('/api/files/preview/test-repo/doc.pdf') + + expect(response.status).toBe(415) + const body = await response.json() as { error: string } + expect(body.error).toContain('File type cannot be previewed') + expect(getRawFileContent).not.toHaveBeenCalled() + }) + + it('should return 400 when no path is provided', async () => { + const response = await app.request('/api/files/preview') + + expect(response.status).toBe(400) + const body = await response.json() as { error: string } + expect(body.error).toContain('No path provided') + }) + + it('should return 403 for path traversal attempts', async () => { + const error = { message: 'Path traversal detected', statusCode: 403 } + getFilePreviewStat.mockRejectedValue(error) + + const response = await app.request('/api/files/preview/test-repo/../outside') + + expect(response.status).toBe(403) + const body = await response.json() as { error: string } + expect(body.error).toContain('Path traversal detected') + }) + }) + describe('GET /*/download-zip - Route Order Regression Test', () => { it('should route to archive service for directory download, not file service', async () => { const mockStream = { @@ -175,6 +311,17 @@ describe('File Routes', () => { expect(response.status).toBe(404) expect(body).toHaveProperty('error') }) + + it('should route paths starting with "preview" but not matching /preview/ to generic handler', async () => { + getFile.mockResolvedValue(mockFileInfo) + + const response = await app.request('/api/files/previewer') + const body = await response.json() as FileInfo + + expect(response.status).toBe(200) + expect(body).toHaveProperty('isDirectory', false) + expect(getFile).toHaveBeenCalledWith('previewer') + }) }) describe('GET /* with ?startLine&endLine - File Range', () => { diff --git a/backend/test/routes/internal/git-credentials.test.ts b/backend/test/routes/internal/git-credentials.test.ts deleted file mode 100644 index 4ae326be..00000000 --- a/backend/test/routes/internal/git-credentials.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { Database } from 'bun:sqlite' -import { migrate } from '../../../src/db/migration-runner' -import { allMigrations } from '../../../src/db/migrations' -import { SettingsService } from '../../../src/services/settings' -import { createInternalGitCredentialsRoutes } from '../../../src/routes/internal/git-credentials' -import type { GitCredential } from '@opencode-manager/shared' - -describe('internal git-credentials routes', () => { - let db: Database - let settingsService: SettingsService - let app: ReturnType - - beforeEach(() => { - db = new Database(':memory:') - migrate(db, allMigrations) - settingsService = new SettingsService(db) - app = createInternalGitCredentialsRoutes(db) - }) - - it('GET /gh-env returns GH_TOKEN and GITHUB_TOKEN for a GitHub PAT', async () => { - settingsService.updateSettings({ - gitCredentials: [ - { name: 'github', host: 'github.com', type: 'pat', token: 'ghp_test_token' } as GitCredential, - ], - }) - - const res = await app.request('/gh-env') - - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ GH_TOKEN: 'ghp_test_token', GITHUB_TOKEN: 'ghp_test_token' }) - }) - - it('GET /gh-env returns an empty object when no GitHub credential exists', async () => { - const res = await app.request('/gh-env') - - expect(res.status).toBe(200) - expect(await res.json()).toEqual({}) - }) -}) diff --git a/backend/test/routes/internal/shell-env.test.ts b/backend/test/routes/internal/shell-env.test.ts new file mode 100644 index 00000000..961e0d3d --- /dev/null +++ b/backend/test/routes/internal/shell-env.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +const getGhCliEnvMock = vi.hoisted(() => vi.fn()) +const getDevServerPortMock = vi.hoisted(() => vi.fn()) + +vi.mock('../../../src/services/credential-provider', () => ({ + CredentialProvider: vi.fn().mockImplementation(() => ({ getGhCliEnv: getGhCliEnvMock })), +})) + +vi.mock('../../../src/services/dev-server/manager', () => ({ + getDevServerPort: getDevServerPortMock, +})) + +import { createInternalShellEnvRoutes } from '../../../src/routes/internal/shell-env' + +describe('internal shell-env routes', () => { + const mockDb = {} as never + + beforeEach(() => { + vi.clearAllMocks() + getGhCliEnvMock.mockReturnValue({ GH_TOKEN: 'ghp', GITHUB_TOKEN: 'ghp' }) + getDevServerPortMock.mockReturnValue(4321) + }) + + it('GET / merges gh env with the dev server port and forwards cwd', async () => { + const app = createInternalShellEnvRoutes(mockDb) + + const res = await app.request('/?cwd=%2Frepo') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + GH_TOKEN: 'ghp', + GITHUB_TOKEN: 'ghp', + OCM_DEV_SERVER_PORT: '4321', + }) + expect(getGhCliEnvMock).toHaveBeenCalledWith({ cwd: '/repo' }) + }) + + it('GET / returns only the dev server port when no GitHub credential exists', async () => { + getGhCliEnvMock.mockReturnValue({}) + const app = createInternalShellEnvRoutes(mockDb) + + const res = await app.request('/') + + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ OCM_DEV_SERVER_PORT: '4321' }) + }) +}) diff --git a/backend/test/services/dev-server/proxy-utils.test.ts b/backend/test/services/dev-server/proxy-utils.test.ts new file mode 100644 index 00000000..d84c74fb --- /dev/null +++ b/backend/test/services/dev-server/proxy-utils.test.ts @@ -0,0 +1,337 @@ +import { describe, it, expect } from 'vitest' +import { + DEV_PROXY_PREFIX, + parseDevProxyPath, + buildUpstreamUrl, + filterProxyHeaders, + sanitizeUpstreamResponseHeaders, + isWebSocketUpgrade, + injectBaseTag, + rewriteDevProxyCssPaths, + rewriteDevProxyHtmlPaths, + rewriteDevProxyJavaScriptPaths, +} from '../../../src/services/dev-server/proxy-utils' + +describe('DEV_PROXY_PREFIX', () => { + it('is /api/dev-proxy', () => { + expect(DEV_PROXY_PREFIX).toBe('/api/dev-proxy') + }) +}) + +describe('parseDevProxyPath', () => { + it('parses path with repo id and subpath', () => { + const result = parseDevProxyPath('/api/dev-proxy/12/assets/x.js') + expect(result).toEqual({ repoId: 12, rest: '/assets/x.js' }) + }) + + it('parses path with only repo id', () => { + const result = parseDevProxyPath('/api/dev-proxy/12') + expect(result).toEqual({ repoId: 12, rest: '/' }) + }) + + it('parses path with repo id and trailing slash', () => { + const result = parseDevProxyPath('/api/dev-proxy/12/') + expect(result).toEqual({ repoId: 12, rest: '/' }) + }) + + it('returns null for path without dev-proxy prefix', () => { + expect(parseDevProxyPath('/api/files')).toBeNull() + }) + + it('returns null for path with non-numeric repo id', () => { + expect(parseDevProxyPath('/api/dev-proxy/abc')).toBeNull() + }) + + it('returns null when repo id segment is empty', () => { + expect(parseDevProxyPath('/api/dev-proxy/')).toBeNull() + }) + + it('returns null for unrelated path', () => { + expect(parseDevProxyPath('/api/other/12/file.js')).toBeNull() + }) + + it('parses multi-digit port numbers', () => { + const result = parseDevProxyPath('/api/dev-proxy/12345/assets') + expect(result).toEqual({ repoId: 12345, rest: '/assets' }) + }) + + it('parses path with special characters in rest', () => { + const result = parseDevProxyPath('/api/dev-proxy/1/@fs/src/main.tsx') + expect(result).toEqual({ repoId: 1, rest: '/@fs/src/main.tsx' }) + }) +}) + +describe('buildUpstreamUrl', () => { + it('builds url with search params', () => { + const result = buildUpstreamUrl(5173, '/assets/x.js', '?v=1') + expect(result).toBe('http://127.0.0.1:5173/assets/x.js?v=1') + }) + + it('builds url without search params', () => { + const result = buildUpstreamUrl(5173, '/', '') + expect(result).toBe('http://127.0.0.1:5173/') + }) + + it('builds url with query string', () => { + const result = buildUpstreamUrl(3000, '/api/data', '?foo=bar&baz=1') + expect(result).toBe('http://127.0.0.1:3000/api/data?foo=bar&baz=1') + }) + + it('uses 127.0.0.1 as hostname', () => { + const result = buildUpstreamUrl(8080, '/', '') + expect(result).toMatch(/^http:\/\/127\.0\.0\.1:/) + }) +}) + +describe('filterProxyHeaders', () => { + it('removes hop-by-hop headers', () => { + const headers = new Headers({ + 'content-type': 'text/html', + 'connection': 'keep-alive', + 'upgrade': 'websocket', + 'cache-control': 'no-cache', + }) + const result = filterProxyHeaders(headers) + expect(result).toHaveProperty('content-type') + expect(result).toHaveProperty('cache-control') + expect(result).not.toHaveProperty('connection') + expect(result).not.toHaveProperty('upgrade') + }) + + it('removes all hop-by-hop headers case-insensitively', () => { + const headers = new Headers({ + 'Connection': 'close', + 'Transfer-Encoding': 'chunked', + 'Content-Length': '100', + 'Host': 'example.com', + }) + const result = filterProxyHeaders(headers) + expect(Object.keys(result)).toHaveLength(0) + }) + + it('preserves non-hop-by-hop headers', () => { + const headers = new Headers({ + 'accept': 'application/json', + 'user-agent': 'test', + 'referer': 'http://example.com', + }) + const result = filterProxyHeaders(headers) + expect(result).toHaveProperty('accept') + expect(result).toHaveProperty('user-agent') + expect(result).toHaveProperty('referer') + }) + + it('returns empty object for empty headers', () => { + const result = filterProxyHeaders(new Headers()) + expect(Object.keys(result)).toHaveLength(0) + }) +}) + +describe('sanitizeUpstreamResponseHeaders', () => { + it('strips hop-by-hop headers', () => { + const headers = new Headers({ + 'content-type': 'text/html', + 'connection': 'keep-alive', + 'x-frame-options': 'DENY', + }) + const result = sanitizeUpstreamResponseHeaders(headers) + expect(result).toHaveProperty('content-type') + expect(result).not.toHaveProperty('connection') + }) + + it('removes x-frame-options', () => { + const headers = new Headers({ + 'content-type': 'text/html', + 'x-frame-options': 'DENY', + }) + const result = sanitizeUpstreamResponseHeaders(headers) + expect(result).not.toHaveProperty('x-frame-options') + }) + + it('removes content-security-policy', () => { + const headers = new Headers({ + 'content-type': 'text/html', + 'content-security-policy': "default-src 'self'", + }) + const result = sanitizeUpstreamResponseHeaders(headers) + expect(result).not.toHaveProperty('content-security-policy') + }) + + it('handles case-insensitive blocked header names', () => { + const headers = new Headers({ + 'X-Frame-Options': 'SAMEORIGIN', + 'Content-Security-Policy': "default-src 'self'", + }) + const result = sanitizeUpstreamResponseHeaders(headers) + expect(result).not.toHaveProperty('X-Frame-Options') + expect(result).not.toHaveProperty('Content-Security-Policy') + }) + + it('adds referrer-policy: no-referrer', () => { + const headers = new Headers({ 'content-type': 'text/plain' }) + const result = sanitizeUpstreamResponseHeaders(headers) + expect(result['referrer-policy']).toBe('no-referrer') + }) + + it('overwrites upstream referrer-policy', () => { + const headers = new Headers({ + 'referrer-policy': 'origin', + }) + const result = sanitizeUpstreamResponseHeaders(headers) + expect(result['referrer-policy']).toBe('no-referrer') + }) + + it('preserves allowed headers', () => { + const headers = new Headers({ + 'content-type': 'application/json', + 'cache-control': 'public', + 'etag': '"abc123"', + }) + const result = sanitizeUpstreamResponseHeaders(headers) + expect(result).toHaveProperty('content-type') + expect(result).toHaveProperty('cache-control') + expect(result).toHaveProperty('etag') + }) +}) + +describe('isWebSocketUpgrade', () => { + it('returns true for websocket upgrade', () => { + const headerGet = (k: string) => + ({ connection: 'Upgrade', upgrade: 'websocket' })[k] + expect(isWebSocketUpgrade(headerGet)).toBe(true) + }) + + it('handles case-insensitive connection header', () => { + const headerGet = (k: string) => + ({ connection: 'upgrade', upgrade: 'websocket' })[k] + expect(isWebSocketUpgrade(headerGet)).toBe(true) + }) + + it('returns false when connection does not include upgrade', () => { + const headerGet = (k: string) => + ({ connection: 'keep-alive', upgrade: 'websocket' })[k] + expect(isWebSocketUpgrade(headerGet)).toBe(false) + }) + + it('returns false when upgrade is not websocket', () => { + const headerGet = (k: string) => + ({ connection: 'Upgrade', upgrade: 'h2c' })[k] + expect(isWebSocketUpgrade(headerGet)).toBe(false) + }) + + it('returns false when both headers are missing', () => { + const headerGet = () => undefined + expect(isWebSocketUpgrade(headerGet)).toBe(false) + }) + + it('returns false when connection header is missing', () => { + const headerGet = (k: string) => + ({ upgrade: 'websocket' })[k] + expect(isWebSocketUpgrade(headerGet)).toBe(false) + }) + + it('returns false when upgrade header is missing', () => { + const headerGet = (k: string) => + ({ connection: 'Upgrade' })[k] + expect(isWebSocketUpgrade(headerGet)).toBe(false) + }) +}) + +describe('injectBaseTag', () => { + it('injects base tag after ', () => { + const html = '' + const result = injectBaseTag(html, '/api/dev-proxy/3/') + expect(result).toBe('') + }) + + it('injects base tag after with attributes', () => { + const html = '' + const result = injectBaseTag(html, '/api/dev-proxy/3/') + expect(result).toBe('') + }) + + it('is no-op when already present', () => { + const html = '' + const result = injectBaseTag(html, '/api/dev-proxy/3/') + expect(result).toBe(html) + }) + + it('is no-op when no tag exists', () => { + const html = 'no head' + const result = injectBaseTag(html, '/api/dev-proxy/3/') + expect(result).toBe(html) + }) + + it('handles uppercase HEAD tag', () => { + const html = '' + const result = injectBaseTag(html, '/proxy/') + expect(result).toBe('') + }) + + it('injects after with newline after', () => { + const html = '\n Test\n' + const result = injectBaseTag(html, '/base/') + expect(result).toBe('\n Test\n') + }) + + it('does not inject when appears in non-head context (e.g. body)', () => { + const html = '' + const result = injectBaseTag(html, '/base/') + expect(result).toBe(html) + }) + + it('handles empty string', () => { + const result = injectBaseTag('', '/base/') + expect(result).toBe('') + }) +}) + +describe('dev proxy path rewriting', () => { + it('rewrites common framework HTML asset paths', () => { + const html = '
' + + const result = rewriteDevProxyHtmlPaths(html, '/api/dev-proxy/7/') + + expect(result).toContain('src="/api/dev-proxy/7/_next/image.png"') + expect(result).toContain('srcset="/api/dev-proxy/7/assets/a.png 1x, /api/dev-proxy/7/static/b.png 2x"') + expect(result).toContain('action="/api/dev-proxy/7/api/action"') + }) + + it('rewrites CSS paths in HTML style content', () => { + const html = '
' + + const result = rewriteDevProxyHtmlPaths(html, '/api/dev-proxy/7/') + + expect(result).toContain('url(/api/dev-proxy/7/assets/bg.png)') + expect(result).toContain('@import "/api/dev-proxy/7/theme.css"') + expect(result).toContain('url("/api/dev-proxy/7/img/bg.svg")') + }) + + it('rewrites CSS response paths', () => { + const css = '@import "/reset.css"; .hero{background:url(/assets/hero.png)} .icon{mask:url("/icons/x.svg")}' + + const result = rewriteDevProxyCssPaths(css, '/api/dev-proxy/7/') + + expect(result).toContain('@import "/api/dev-proxy/7/reset.css"') + expect(result).toContain('url(/api/dev-proxy/7/assets/hero.png)') + expect(result).toContain('url("/api/dev-proxy/7/icons/x.svg")') + }) + + it('rewrites JavaScript module paths', () => { + const js = 'import "/runtime.js"; export { App } from "/src/App.js"; const route = import("/routes/home.js")' + + const result = rewriteDevProxyJavaScriptPaths(js, '/api/dev-proxy/7/') + + expect(result).toContain('import "/api/dev-proxy/7/runtime.js"') + expect(result).toContain('from "/api/dev-proxy/7/src/App.js"') + expect(result).toContain('import("/api/dev-proxy/7/routes/home.js")') + }) + + it('does not rewrite protocol-relative or already proxied paths', () => { + const html = '' + + const result = rewriteDevProxyHtmlPaths(html, '/api/dev-proxy/7/') + + expect(result).toBe(html) + }) +}) diff --git a/backend/test/services/dev-server/upgrade-handler.test.ts b/backend/test/services/dev-server/upgrade-handler.test.ts new file mode 100644 index 00000000..b9175fea --- /dev/null +++ b/backend/test/services/dev-server/upgrade-handler.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + buildUpstreamUpgradeRequest, + createDevProxyUpgradeHandler, +} from '../../../src/services/dev-server/upgrade-handler' + +const netConnectMock = vi.hoisted(() => vi.fn()) +vi.mock('net', () => ({ connect: netConnectMock, default: { connect: netConnectMock } })) +vi.mock('../../../src/services/dev-server/manager', () => ({ + getDevServerPort: vi.fn(() => 5173), +})) + +describe('buildUpstreamUpgradeRequest', () => { + it('rewrites request target to rest and sets Host header', () => { + const rawHead = [ + 'GET /api/dev-proxy/1/ws-path HTTP/1.1', + 'Host: localhost:5003', + 'Upgrade: websocket', + 'Connection: Upgrade', + 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version: 13', + ].join('\r\n') + + const result = buildUpstreamUpgradeRequest(rawHead, '/ws-path', 5173) + + expect(result).toContain('GET /ws-path HTTP/1.1') + expect(result).toContain('Host: 127.0.0.1:5173') + expect(result).not.toContain('Host: localhost:5003') + }) + + it('preserves Upgrade, Connection, and Sec-WebSocket-* headers', () => { + const rawHead = [ + 'GET /api/dev-proxy/2/socket HTTP/1.1', + 'Host: example.com', + 'Upgrade: websocket', + 'Connection: Upgrade', + 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==', + 'Sec-WebSocket-Version: 13', + 'Sec-WebSocket-Protocol: json', + ].join('\r\n') + + const result = buildUpstreamUpgradeRequest(rawHead, '/socket', 3000) + + expect(result).toContain('Upgrade: websocket') + expect(result).toContain('Connection: Upgrade') + expect(result).toContain('Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==') + expect(result).toContain('Sec-WebSocket-Version: 13') + expect(result).toContain('Sec-WebSocket-Protocol: json') + }) + + it('handles root path rest', () => { + const rawHead = [ + 'GET /api/dev-proxy/1 HTTP/1.1', + 'Host: localhost:5003', + ].join('\r\n') + + const result = buildUpstreamUpgradeRequest(rawHead, '/', 5173) + + expect(result).toMatch(/^GET \/ HTTP\/1\.1\r\n/m) + }) + + it('preserves non-Host headers in order', () => { + const rawHead = [ + 'GET /api/dev-proxy/1/test HTTP/1.1', + 'Host: localhost:5003', + 'User-Agent: test-agent', + 'Accept: */*', + ].join('\r\n') + + const result = buildUpstreamUpgradeRequest(rawHead, '/test', 8080) + + const userAgentIdx = result.indexOf('User-Agent: test-agent') + const acceptIdx = result.indexOf('Accept: */*') + expect(userAgentIdx).toBeGreaterThan(0) + expect(acceptIdx).toBeGreaterThan(userAgentIdx) + }) + + it('returns empty string when given empty string', () => { + expect(buildUpstreamUpgradeRequest('', '/', 5173)).toBe('') + }) + + it('handles single-line input without headers', () => { + const result = buildUpstreamUpgradeRequest('GET /api/dev-proxy/1/ HTTP/1.1', '/', 5173) + expect(result).toBe('GET / HTTP/1.1\r\nHost: 127.0.0.1:5173') + }) + + it('removes duplicate host headers, keeping only the new one', () => { + const rawHead = [ + 'GET /api/dev-proxy/5/app HTTP/1.1', + 'Host: first.com', + 'Host: second.com', + 'Accept: application/json', + ].join('\r\n') + + const result = buildUpstreamUpgradeRequest(rawHead, '/app', 9999) + + const hostMatches = result.match(/^Host:/gm) + expect(hostMatches).toHaveLength(1) + expect(result).toContain('Host: 127.0.0.1:9999') + }) +}) + +describe('createDevProxyUpgradeHandler', () => { + const mockGetSession = vi.fn() + let auth: { api: { getSession: typeof mockGetSession } } + + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ session: { userId: 'u1' }, user: { id: 'u1' } }) + netConnectMock.mockReturnValue(createMockSocket()) + auth = { api: { getSession: mockGetSession } } + }) + + function createMockSocket() { + const handlers = new Map void>>() + return { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!handlers.has(event)) handlers.set(event, []) + handlers.get(event)!.push(handler) + }), + destroy: vi.fn(), + write: vi.fn(), + pipe: vi.fn(), + _handlers: handlers, + } + } + + function triggerEvent(obj: { _handlers?: Map void>> }, event: string, ...args: unknown[]) { + const map = obj._handlers + if (!map) return + const handlers = map.get(event) + if (handlers) { + for (const h of handlers) h(...args) + } + } + + describe('URL routing', () => { + it('destroys socket for non-proxy URL', async () => { + const handler = createDevProxyUpgradeHandler(auth as any, {} as any) + const socket = createMockSocket() + const req = { url: '/api/other', method: 'GET', headers: {} } + + await handler(req as any, socket as any, Buffer.alloc(0)) + + expect(socket.destroy).toHaveBeenCalledTimes(1) + expect(mockGetSession).not.toHaveBeenCalled() + }) + + it('destroys socket for URL without repo id', async () => { + const handler = createDevProxyUpgradeHandler(auth as any, {} as any) + const socket = createMockSocket() + const req = { url: '/api/dev-proxy/', method: 'GET', headers: {} } + + await handler(req as any, socket as any, Buffer.alloc(0)) + + expect(socket.destroy).toHaveBeenCalledTimes(1) + }) + }) + + describe('authentication', () => { + it('destroys socket when session lookup throws', async () => { + mockGetSession.mockRejectedValue(new Error('auth error')) + const handler = createDevProxyUpgradeHandler(auth as any, {} as any) + const socket = createMockSocket() + const req = { url: '/api/dev-proxy/1/ws', method: 'GET', headers: { host: 'localhost' } } + + await handler(req as any, socket as any, Buffer.alloc(0)) + + expect(socket.destroy).toHaveBeenCalledTimes(1) + }) + + it('writes 401 and destroys socket when no session', async () => { + mockGetSession.mockResolvedValue(null) + const handler = createDevProxyUpgradeHandler(auth as any, {} as any) + const socket = createMockSocket() + const req = { url: '/api/dev-proxy/1/hmr', method: 'GET', headers: { host: 'localhost' } } + + await handler(req as any, socket as any, Buffer.alloc(0)) + + expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('401 Unauthorized')) + expect(socket.destroy).toHaveBeenCalledTimes(1) + }) + }) + + describe('upstream connection', () => { + it('calls net.connect with active port and 127.0.0.1', async () => { + const handler = createDevProxyUpgradeHandler(auth as any, {} as any) + const socket = createMockSocket() + const req = { url: '/api/dev-proxy/1/hmr', method: 'GET', headers: { host: 'localhost' } } + + await handler(req as any, socket as any, Buffer.alloc(0)) + + expect(netConnectMock).toHaveBeenCalledWith(5173, '127.0.0.1') + }) + + it('writes upstream request on connect', async () => { + const upstreamSocket = createMockSocket() + netConnectMock.mockReturnValue(upstreamSocket) + + const handler = createDevProxyUpgradeHandler(auth as any, {} as any) + const socket = createMockSocket() + const req = { url: '/api/dev-proxy/1/hmr', method: 'GET', headers: { host: 'localhost', upgrade: 'websocket', connection: 'Upgrade' } } + + await handler(req as any, socket as any, Buffer.alloc(0)) + + triggerEvent(upstreamSocket, 'connect') + + expect(upstreamSocket.write).toHaveBeenCalledWith(expect.stringContaining('GET /hmr HTTP/1.1')) + expect(upstreamSocket.write).toHaveBeenCalledWith(expect.stringContaining('Host: 127.0.0.1:5173')) + }) + + it('pipes head buffer after upgrade request', async () => { + const upstreamSocket = createMockSocket() + netConnectMock.mockReturnValue(upstreamSocket) + + const handler = createDevProxyUpgradeHandler(auth as any, {} as any) + const socket = createMockSocket() + const req = { url: '/api/dev-proxy/1/ws', method: 'GET', headers: { host: 'localhost' } } + const head = Buffer.from('extra-data') + + await handler(req as any, socket as any, head) + + triggerEvent(upstreamSocket, 'connect') + + const writeMock = upstreamSocket.write as ReturnType + const secondCallArg = writeMock.mock.calls[1]?.[0] + expect(secondCallArg).toBe(head) + }) + + it('pipes socket and upstream both ways on connect', async () => { + const upstreamSocket = createMockSocket() + netConnectMock.mockReturnValue(upstreamSocket) + + const handler = createDevProxyUpgradeHandler(auth as any, {} as any) + const socket = createMockSocket() + const req = { url: '/api/dev-proxy/1/ws', method: 'GET', headers: { host: 'localhost' } } + + await handler(req as any, socket as any, Buffer.alloc(0)) + triggerEvent(upstreamSocket, 'connect') + + expect(socket.pipe).toHaveBeenCalledWith(upstreamSocket) + expect(upstreamSocket.pipe).toHaveBeenCalledWith(socket) + }) + + it('destroys socket on upstream error', async () => { + const upstreamSocket = createMockSocket() + netConnectMock.mockReturnValue(upstreamSocket) + + const handler = createDevProxyUpgradeHandler(auth as any, {} as any) + const socket = createMockSocket() + const req = { url: '/api/dev-proxy/1/ws', method: 'GET', headers: { host: 'localhost' } } + + await handler(req as any, socket as any, Buffer.alloc(0)) + triggerEvent(upstreamSocket, 'error', new Error('conn refused')) + + expect(socket.destroy).toHaveBeenCalled() + }) + }) +}) diff --git a/backend/test/services/opencode-gh-env-plugin.test.ts b/backend/test/services/opencode-gh-env-plugin.test.ts index 4ac101ee..c3b40fb8 100644 --- a/backend/test/services/opencode-gh-env-plugin.test.ts +++ b/backend/test/services/opencode-gh-env-plugin.test.ts @@ -53,7 +53,7 @@ describe('ocm-gh-env plugin', () => { expect(output.env).toEqual({ GH_TOKEN: 'ghp', GITHUB_TOKEN: 'ghp' }) const [url, options] = fetchMock.mock.calls[0]! - expect(url.toString()).toBe('http://localhost:5003/api/internal/git-credentials/gh-env?cwd=%2Frepo') + expect(url.toString()).toBe('http://localhost:5003/api/internal/shell-env?cwd=%2Frepo') expect(options).toEqual({ headers: { Authorization: 'Bearer secret-token' } }) }) diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts index da53ef82..cfdd9d47 100644 --- a/backend/vitest.config.ts +++ b/backend/vitest.config.ts @@ -2,6 +2,18 @@ import { defineConfig } from 'vitest/config' import path from 'path' export default defineConfig({ + plugins: [ + { + name: 'markdown-as-text', + enforce: 'pre', + transform(code: string, id: string) { + if (id.endsWith('.md')) { + return { code: `export default ${JSON.stringify(code)};`, map: null } + } + return null + }, + }, + ], test: { globals: true, environment: 'node', diff --git a/frontend/src/api/devServer.ts b/frontend/src/api/devServer.ts new file mode 100644 index 00000000..3e666876 --- /dev/null +++ b/frontend/src/api/devServer.ts @@ -0,0 +1,11 @@ +import { fetchWrapper } from './fetchWrapper' +import { API_BASE_URL } from '@/config' +import type { DevServerState } from '@opencode-manager/shared/types' + +export async function getDevServerStatus(repoId: number): Promise { + return fetchWrapper(`${API_BASE_URL}/api/dev-server/${repoId}/status`) +} + +export function getDevPreviewUrl(repoId: number): string { + return `${API_BASE_URL}/api/dev-proxy/${repoId}/` +} diff --git a/frontend/src/api/files.test.ts b/frontend/src/api/files.test.ts new file mode 100644 index 00000000..13373d12 --- /dev/null +++ b/frontend/src/api/files.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { getFileApiUrl } from './files' +import { encodePathForRoute, getFilePreviewUrl } from './files' + +describe('getFilePreviewUrl', () => { + it('constructs preview URL with encoded path segments', () => { + const url = getFilePreviewUrl('repo/dir/index.html') + expect(url).toBe('/api/files/preview/repo/dir/index.html') + }) + + it('encodes spaces per segment', () => { + const url = getFilePreviewUrl('my repo/file name.html') + expect(url).toBe('/api/files/preview/my%20repo/file%20name.html') + }) + + it('encodes hash characters per segment', () => { + const url = getFilePreviewUrl('repo/file#1.html') + expect(url).toBe('/api/files/preview/repo/file%231.html') + }) +}) + +describe('encodePathForRoute', () => { + it('encodes each path segment separately', () => { + const result = encodePathForRoute('repo/dir/index.html') + expect(result).toBe('repo/dir/index.html') + }) + + it('encodes special characters per segment', () => { + const result = encodePathForRoute('my dir/file name.html') + expect(result).toBe('my%20dir/file%20name.html') + }) +}) + +describe('getFileApiUrl - existing behavior unchanged', () => { + it('still returns a URL with path query param', () => { + const url = getFileApiUrl('repo/dir/index.html') + expect(url).toContain('/api/files') + expect(url).toContain('path=repo%2Fdir%2Findex.html') + }) +}) diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts index 7337d9f3..df4a12cd 100644 --- a/frontend/src/api/files.ts +++ b/frontend/src/api/files.ts @@ -77,3 +77,12 @@ export async function applyFilePatches(path: string, patches: PatchOperation[]): body: JSON.stringify({ patches }), }) } + +export function encodePathForRoute(path: string): string { + return path.split('/').map(encodeURIComponent).join('/') +} + +export function getFilePreviewUrl(path: string): string { + const encodedPath = encodePathForRoute(path) + return `${API_BASE_URL}/api/files/preview/${encodedPath}` +} diff --git a/frontend/src/api/types/settings.ts b/frontend/src/api/types/settings.ts index cabc73b2..8439f09b 100644 --- a/frontend/src/api/types/settings.ts +++ b/frontend/src/api/types/settings.ts @@ -71,6 +71,7 @@ export interface UserPreferences { repoSortMode?: 'recent' | 'manual' | 'name' serverEnvVars?: Array<{ key: string; value: string }> disabledDefaultServerEnvVars?: string[] + devServerPort?: number } export interface SettingsResponse { diff --git a/frontend/src/components/dev-server/DevServerPreviewButton.test.tsx b/frontend/src/components/dev-server/DevServerPreviewButton.test.tsx new file mode 100644 index 00000000..80cfb15a --- /dev/null +++ b/frontend/src/components/dev-server/DevServerPreviewButton.test.tsx @@ -0,0 +1,55 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { DevServerPreviewButton } from './DevServerPreviewButton' +import { useMutation } from '@tanstack/react-query' + +vi.mock('@tanstack/react-query', () => ({ + useMutation: vi.fn(), +})) + +vi.mock('@/api/devServer', () => ({ + getDevServerStatus: vi.fn(), + getDevPreviewUrl: (repoId: number) => `/api/dev-proxy/${repoId}/`, +})) + +describe('DevServerPreviewButton', () => { + it('calls onOpen with devserver input when configured port is running', () => { + const onOpen = vi.fn() + + let capturedOnSuccess: ((state: { status: 'running'; port: number }) => void) | undefined + vi.mocked(useMutation).mockImplementation(((options: { onSuccess?: (state: { status: 'running'; port: number }) => void }) => { + capturedOnSuccess = options.onSuccess + return { + mutate: () => capturedOnSuccess?.({ status: 'running', port: 5100 }), + isPending: false, + } as unknown as ReturnType + }) as typeof useMutation) + + render() + + const button = screen.getByLabelText('Open app preview') + expect(button).toBeInTheDocument() + + fireEvent.click(button) + + expect(onOpen).toHaveBeenCalledTimes(1) + expect(onOpen).toHaveBeenCalledWith({ + source: 'devserver', + previewUrl: '/api/dev-proxy/3/', + title: 'App preview', + }) + }) + + it('disables button while pending', () => { + const onOpen = vi.fn() + vi.mocked(useMutation).mockReturnValue({ + mutate: vi.fn(), + isPending: true, + } as unknown as ReturnType) + + render() + + const button = screen.getByLabelText('Open app preview') + expect(button).toBeDisabled() + }) +}) diff --git a/frontend/src/components/dev-server/DevServerPreviewButton.tsx b/frontend/src/components/dev-server/DevServerPreviewButton.tsx new file mode 100644 index 00000000..b8a32560 --- /dev/null +++ b/frontend/src/components/dev-server/DevServerPreviewButton.tsx @@ -0,0 +1,44 @@ +import { useMutation } from '@tanstack/react-query' +import { getDevServerStatus, getDevPreviewUrl } from '@/api/devServer' +import { Button } from '@/components/ui/button' +import { showToast } from '@/lib/toast' +import { Loader2, Play } from 'lucide-react' +import type { OpenHtmlArtifactInput } from '@/lib/htmlArtifacts' + +interface DevServerPreviewButtonProps { + repoId: number + onOpen: (input: OpenHtmlArtifactInput) => void +} + +export function DevServerPreviewButton({ repoId, onOpen }: DevServerPreviewButtonProps) { + const { mutate, isPending } = useMutation({ + mutationFn: () => getDevServerStatus(repoId), + onSuccess: (state) => { + if (state.status !== 'running') { + showToast.error(`No app detected on localhost:${state.port}`) + return + } + + onOpen({ + source: 'devserver', + previewUrl: getDevPreviewUrl(repoId), + title: 'App preview', + }) + }, + onError: () => { + showToast.error('Failed to check preview port') + }, + }) + + return ( + + ) +} diff --git a/frontend/src/components/file-browser/FileBrowser.tsx b/frontend/src/components/file-browser/FileBrowser.tsx index c6df1a35..b88c38f1 100644 --- a/frontend/src/components/file-browser/FileBrowser.tsx +++ b/frontend/src/components/file-browser/FileBrowser.tsx @@ -12,6 +12,9 @@ import { FolderOpen, Upload, RefreshCw, X } from 'lucide-react' import type { FileInfo } from '@/types/files' import { useMobile } from '@/hooks/useMobile' import { getFileApiUrl, useFile } from '@/api/files' +import { HtmlArtifactPanel } from '@/components/html-preview/HtmlArtifactPanel' +import type { HtmlArtifact } from '@/lib/htmlArtifacts' +import { createHtmlArtifact, isHtmlPath } from '@/lib/htmlArtifacts' export interface FileBrowserHandle { goBack: () => void @@ -51,6 +54,10 @@ const encodeBase64 = (content: string) => { return btoa(binary) } +const isHtmlFileInfo = (file: FileInfo): boolean => ( + isHtmlPath(file.path) || isHtmlPath(file.name) || file.mimeType === 'text/html' +) + async function readFileEntry(entry: FileSystemFileEntry): Promise { return new Promise((resolve, reject) => { entry.file(resolve, reject) @@ -135,6 +142,7 @@ export const FileBrowser = forwardRef(funct const [currentPath, setCurrentPath] = useState(basePath) const [files, setFiles] = useState(null) const [selectedFile, setSelectedFile] = useState(null) + const [htmlArtifact, setHtmlArtifact] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -152,7 +160,17 @@ useEffect(() => { if (initialFileData) { setSelectedFile(initialFileData) if (isMobile) { - setIsPreviewModalOpen(true) + if (isHtmlFileInfo(initialFileData)) { + setHtmlArtifact(createHtmlArtifact({ + source: 'file', + path: initialFileData.path, + title: initialFileData.name, + })) + setIsPreviewModalOpen(false) + } else { + setHtmlArtifact(null) + setIsPreviewModalOpen(true) + } onPreviewStateChange?.(true) } } @@ -262,7 +280,17 @@ useEffect(() => { // On mobile, open preview in modal if (isMobile) { - setIsPreviewModalOpen(true) + if (isHtmlFileInfo(fullFileData)) { + setHtmlArtifact(createHtmlArtifact({ + source: 'file', + path: fullFileData.path, + title: fullFileData.name, + })) + setIsPreviewModalOpen(false) + } else { + setHtmlArtifact(null) + setIsPreviewModalOpen(true) + } onPreviewStateChange?.(true) } } catch (err) { @@ -275,10 +303,19 @@ useEffect(() => { const handleCloseModal = useCallback(() => { setIsPreviewModalOpen(false) + setHtmlArtifact(null) + setSelectedFile(null) + onPreviewStateChange?.(false) + }, [onPreviewStateChange]) + + const handleCloseHtmlArtifact = useCallback(() => { + setHtmlArtifact(null) setSelectedFile(null) onPreviewStateChange?.(false) }, [onPreviewStateChange]) + const handleToggleHtmlArtifactFullscreen = useCallback(() => {}, []) + const handleDirectoryClick = (path: string) => { loadFiles(path) } @@ -645,6 +682,16 @@ useEffect(() => { file={selectedFile} showFilePreviewHeader={true} /> + + {isMobile && htmlArtifact && ( + + )} ) } @@ -746,6 +793,16 @@ useEffect(() => { onClose={handleCloseModal} file={selectedFile} /> + + {isMobile && htmlArtifact && ( + + )} {uploadDialog} diff --git a/frontend/src/components/file-browser/FileBrowserSheet.test.tsx b/frontend/src/components/file-browser/FileBrowserSheet.test.tsx index af322b0a..421c97a6 100644 --- a/frontend/src/components/file-browser/FileBrowserSheet.test.tsx +++ b/frontend/src/components/file-browser/FileBrowserSheet.test.tsx @@ -7,6 +7,15 @@ import { FileBrowser } from './FileBrowser' import type { FileBrowserHandle } from './FileBrowser' import * as useMobile from '../../hooks/useMobile' +vi.mock('@/components/html-preview/HtmlArtifactPanel', () => ({ + HtmlArtifactPanel: ({ artifact, onClose }: { artifact: { path?: string } | null; onClose: () => void }) => ( +
+ {artifact?.path} + +
+ ), +})) + function createQueryClient() { return new QueryClient({ defaultOptions: { @@ -87,6 +96,60 @@ describe('FileBrowserSheet', () => { }) describe('FileBrowser navigation', () => { + it('opens mobile HTML files with the shared HTML artifact panel', async () => { + const mobileSpy = vi.spyOn(useMobile, 'useMobile').mockReturnValue(true) + const originalFetch = globalThis.fetch + const htmlFile = { + name: 'dashboard.html', + path: 'test-repo/dashboard.html', + isDirectory: false, + size: 24, + mimeType: 'text/html', + content: btoa('

Dashboard

'), + lastModified: new Date(), + } + + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input), 'http://localhost') + const path = url.searchParams.get('path') + + if (path === htmlFile.path) { + return { + ok: true, + json: async () => htmlFile, + } as Response + } + + return { + ok: true, + json: async () => ({ + name: 'test-repo', + path: 'test-repo', + isDirectory: true, + children: [htmlFile], + }), + } as Response + }) + + try { + render( + , + { wrapper: createWrapper() } + ) + + fireEvent.click(await screen.findByText('dashboard.html')) + + expect(await screen.findByTestId('html-artifact-panel')).toHaveTextContent('test-repo/dashboard.html') + expect(screen.queryByTitle('HTML preview: dashboard.html')).not.toBeInTheDocument() + } finally { + globalThis.fetch = originalFetch + mobileSpy.mockRestore() + } + }) + it('exposes imperative handle with goBack, canGoBack, and getCurrentPath', () => { const ref = { current: null as unknown as FileBrowserHandle } @@ -1091,5 +1154,3 @@ describe('FileBrowserSheet swipe decision integration', () => { mockUseSwipeBack.mockRestore() }) }) - - diff --git a/frontend/src/components/file-browser/FilePreview.test.tsx b/frontend/src/components/file-browser/FilePreview.test.tsx new file mode 100644 index 00000000..99918745 --- /dev/null +++ b/frontend/src/components/file-browser/FilePreview.test.tsx @@ -0,0 +1,104 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import '@testing-library/jest-dom' +import { FilePreview } from './FilePreview' +import type { FileInfo } from '@/types/files' + +vi.mock('@/api/files', () => ({ + getFileApiUrl: (path: string) => `/api/files?path=${encodeURIComponent(path)}`, + getFilePreviewUrl: (path: string) => { + const encodedPath = path.split('/').map(encodeURIComponent).join('/') + return `/api/files/preview/${encodedPath}` + }, +})) + +vi.mock('@/components/ui/virtualized-text-view', () => ({ + VirtualizedTextView: vi.fn(() =>
), +})) + +function htmlFile(content: string, overrides?: Partial): FileInfo { + return { + name: 'dashboard.html', + path: 'test-repo/dashboard.html', + isDirectory: false, + size: content.length, + mimeType: 'text/html', + content: btoa(content), + lastModified: new Date(), + ...overrides, + } +} + +function textFile(name: string, content: string, mimeType: string): FileInfo { + return { + name, + path: `test-repo/${name}`, + isDirectory: false, + size: content.length, + mimeType, + content: btoa(content), + lastModified: new Date(), + } +} + +describe('FilePreview - HTML preview', () => { + it('renders HTML preview iframe by default for .html file', () => { + render(Dashboard')} />) + + const iframe = screen.getByTitle('HTML preview: dashboard.html') + expect(iframe).toBeInTheDocument() + expect(iframe).toHaveAttribute('sandbox', 'allow-scripts allow-same-origin') + expect(iframe.getAttribute('src')).toContain('/api/files/preview/test-repo/dashboard.html') + }) + + it('toggles to source view when code button is clicked', () => { + render(Dashboard')} />) + + const toggleButton = screen.getByTitle('Show HTML source') + expect(toggleButton).toBeInTheDocument() + + fireEvent.click(toggleButton) + + expect(screen.queryByTitle('HTML preview: dashboard.html')).not.toBeInTheDocument() + expect(screen.getByText('

Dashboard

')).toBeInTheDocument() + }) + + it('toggles back to preview when eye button is clicked from source view', () => { + render(Dashboard')} />) + + const showSourceButton = screen.getByTitle('Show HTML source') + fireEvent.click(showSourceButton) + expect(screen.queryByTitle('HTML preview: dashboard.html')).not.toBeInTheDocument() + + const showPreviewButton = screen.getByTitle('Preview rendered HTML') + expect(showPreviewButton).toBeInTheDocument() + + fireEvent.click(showPreviewButton) + + const iframe = screen.getByTitle('HTML preview: dashboard.html') + expect(iframe).toBeInTheDocument() + }) + + it('renders HTML preview for .htm file', () => { + render(HTM test

', { name: 'test.htm', path: 'test-repo/test.htm', mimeType: 'text/html' })} />) + + const iframe = screen.getByTitle('HTML preview: test.htm') + expect(iframe).toBeInTheDocument() + expect(iframe.getAttribute('src')).toContain('/api/files/preview/test-repo/test.htm') + }) + + it('does not show HTML preview toggle for non-HTML text file', () => { + render() + + expect(screen.queryByTitle('Show HTML source')).not.toBeInTheDocument() + expect(screen.queryByTitle('Preview rendered HTML')).not.toBeInTheDocument() + expect(screen.getByText('Hello world')).toBeInTheDocument() + }) + + it('preserves markdown preview behavior for .md files', () => { + render() + + const toggleButton = screen.getByTitle('Show raw markdown') + expect(toggleButton).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/file-browser/FilePreview.tsx b/frontend/src/components/file-browser/FilePreview.tsx index 9f8dfcb3..155da0d4 100644 --- a/frontend/src/components/file-browser/FilePreview.tsx +++ b/frontend/src/components/file-browser/FilePreview.tsx @@ -2,9 +2,11 @@ import { useState, useCallback, useRef, useEffect, memo } from 'react' import { Button } from '@/components/ui/button' import { Download, X, Edit3, Save, X as XIcon, WrapText, Eye, Code } from 'lucide-react' import type { FileInfo } from '@/types/files' -import { getFileApiUrl } from '@/api/files' +import { getFileApiUrl, getFilePreviewUrl } from '@/api/files' import { VirtualizedTextView, type VirtualizedTextViewHandle } from '@/components/ui/virtualized-text-view' import { MarkdownRenderer } from './MarkdownRenderer' +import { HtmlPreviewFrame } from '@/components/html-preview/HtmlPreviewFrame' +import { isHtmlPath } from '@/lib/htmlArtifacts' const VIRTUALIZATION_THRESHOLD_BYTES = 50_000 @@ -21,6 +23,7 @@ interface FilePreviewProps { export const FilePreview = memo(function FilePreview({ file, hideHeader = false, isMobileModal = false, onCloseModal, onFileSaved, initialLineNumber }: FilePreviewProps) { const isMarkdownFile = file.name.toLowerCase().endsWith('.md') || file.name.toLowerCase().endsWith('.mdx') || file.mimeType === 'text/markdown' + const isHtmlFile = isHtmlPath(file.name) || file.mimeType === 'text/html' const [viewMode, setViewMode] = useState<'preview' | 'edit'>('preview') const [editContent, setEditContent] = useState('') @@ -29,6 +32,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, const [highlightedLine, setHighlightedLine] = useState(initialLineNumber) const [lineWrap, setLineWrap] = useState(true) const [markdownPreview, setMarkdownPreview] = useState(isMarkdownFile) + const [htmlPreview, setHtmlPreview] = useState(isHtmlFile) const [isLoadingAllContent, setIsLoadingAllContent] = useState(false) const [fullContentLoaded, setFullContentLoaded] = useState(false) const [fullContent, setFullContent] = useState(null) @@ -42,9 +46,10 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, useEffect(() => { setFullContentLoaded(false) setMarkdownPreview(isMarkdownFile) + setHtmlPreview(isHtmlFile) setFullContent(null) setLocalMdContent(null) - }, [file.path, isMarkdownFile]) + }, [file.path, isMarkdownFile, isHtmlFile]) useEffect(() => { if (shouldVirtualize && isMarkdownFile && markdownPreview && !isMarkdownTooLarge && !fullContentLoaded) { @@ -214,6 +219,12 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, ['application/json', 'application/xml', 'text/javascript', 'text/typescript'].includes(file.mimeType || '') const renderContent = () => { + const htmlPreviewFrame = ( +
+ +
+ ) + if (file.mimeType?.startsWith('image/')) { return (
@@ -227,8 +238,13 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, } if (shouldVirtualize && isTextFile) { + const showHtmlPreview = isHtmlFile && htmlPreview && viewMode !== 'edit' const showMarkdownPreview = isMarkdownFile && markdownPreview && viewMode !== 'edit' + if (showHtmlPreview) { + return htmlPreviewFrame + } + return ( <>
@@ -285,6 +301,10 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, /> ) } + + if (isHtmlFile && htmlPreview) { + return htmlPreviewFrame + } try { const textContent = decodeBase64(file.content) @@ -392,8 +412,20 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, {markdownPreview ? : } )} + + {isHtmlFile && viewMode !== 'edit' && ( + + )} - {isTextFile && !markdownPreview && ( + {isTextFile && !markdownPreview && !htmlPreview && ( + )}
diff --git a/frontend/src/components/html-preview/HtmlArtifactPanel.test.tsx b/frontend/src/components/html-preview/HtmlArtifactPanel.test.tsx new file mode 100644 index 00000000..94a9cc87 --- /dev/null +++ b/frontend/src/components/html-preview/HtmlArtifactPanel.test.tsx @@ -0,0 +1,273 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import { HtmlArtifactPanel } from './HtmlArtifactPanel' +import type { HtmlArtifact } from '@/lib/htmlArtifacts' + +vi.mock('@/api/files', () => ({ + getFilePreviewUrl: (path: string) => `/api/files/preview/${encodeURIComponent(path)}`, +})) + +function createInlineArtifact(overrides?: Partial): HtmlArtifact { + return { + id: 'test-1', + title: 'Test Preview', + source: 'inline', + html: '

Hello World

', + ...overrides, + } +} + +function createFileArtifact(overrides?: Partial): HtmlArtifact { + return { + id: 'test-2', + title: 'File Preview', + source: 'file', + path: 'my-repo/dist/index.html', + ...overrides, + } +} + +function createDevServerArtifact(overrides?: Partial): HtmlArtifact { + return { + id: 'test-3', + title: 'App Preview', + source: 'devserver', + previewUrl: '/api/dev-proxy/3/', + ...overrides, + } +} + +describe('HtmlArtifactPanel', () => { + it('renders inline artifact with srcdoc and sandbox attribute', () => { + const artifact = createInlineArtifact() + render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + const iframe = screen.getByTitle('Test Preview') + expect(iframe).toBeInTheDocument() + expect(iframe.getAttribute('srcdoc')).toContain('') + expect(iframe.getAttribute('srcdoc')).toContain('

Hello World

') + expect(iframe).toHaveAttribute('sandbox', 'allow-scripts') + }) + + it('renders file artifact iframe with src pointing to preview API', () => { + const artifact = createFileArtifact() + render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + const iframe = screen.getByTitle('File Preview') + expect(iframe).toBeInTheDocument() + expect(iframe).toHaveAttribute('src') + expect(iframe.getAttribute('src')).toContain('/api/files/preview/') + expect(iframe).toHaveAttribute('sandbox', 'allow-scripts allow-same-origin') + }) + + it('does not include allow-same-origin in sandbox', () => { + const artifact = createInlineArtifact() + render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + const iframe = screen.getByTitle('Test Preview') + const sandbox = iframe.getAttribute('sandbox') + expect(sandbox).not.toContain('allow-same-origin') + }) + + it('calls onClose when close button is clicked', () => { + const onClose = vi.fn() + const artifact = createInlineArtifact() + render( + {}} + />, + ) + + const closeButton = screen.getByLabelText('Close preview') + fireEvent.click(closeButton) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('renders fullscreen toggle button on desktop', () => { + const artifact = createInlineArtifact() + render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + const toggleButton = screen.getByLabelText('Enter fullscreen') + expect(toggleButton).toBeInTheDocument() + }) + + it('renders FullscreenSheet on mobile', () => { + const artifact = createInlineArtifact() + const { container } = render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + const sheetRoot = container.querySelector('.fixed.inset-0') + expect(sheetRoot).toBeInTheDocument() + }) + + it('renders FullscreenSheet when isFullscreen is true (desktop)', () => { + const artifact = createInlineArtifact() + const { container } = render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + const sheetRoot = container.querySelector('.fixed.inset-0') + expect(sheetRoot).toBeInTheDocument() + }) + + it('returns null when artifact is null', () => { + const { container } = render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + expect(container.innerHTML).toBe('') + }) + + it('shows desktop side-panel classes on desktop', () => { + const artifact = createInlineArtifact() + const { container } = render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + const panel = container.querySelector('.hidden.md\\:flex') + expect(panel).toBeInTheDocument() + }) + + it('does not show desktop side panel on mobile', () => { + const artifact = createInlineArtifact() + const { container } = render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + const panel = container.querySelector('.hidden.md\\:flex') + expect(panel).not.toBeInTheDocument() + }) + + it('renders devserver artifact iframe with src and widened sandbox', () => { + const artifact = createDevServerArtifact() + render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + const iframe = screen.getByTitle('App Preview') + expect(iframe).toBeInTheDocument() + expect(iframe).toHaveAttribute('src', '/api/dev-proxy/3/') + expect(iframe).toHaveAttribute( + 'sandbox', + 'allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads', + ) + }) + + it('shows reload button for devserver artifact', () => { + const artifact = createDevServerArtifact() + render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + expect(screen.getByLabelText('Reload preview')).toBeInTheDocument() + }) + + it('shows reload button for file artifact', () => { + const artifact = createFileArtifact() + render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + expect(screen.getByLabelText('Reload preview')).toBeInTheDocument() + }) + + it('does not show reload button for inline artifact', () => { + const artifact = createInlineArtifact() + render( + {}} + onToggleFullscreen={() => {}} + />, + ) + + expect(screen.queryByLabelText('Reload preview')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/html-preview/HtmlArtifactPanel.tsx b/frontend/src/components/html-preview/HtmlArtifactPanel.tsx new file mode 100644 index 00000000..bc559f5a --- /dev/null +++ b/frontend/src/components/html-preview/HtmlArtifactPanel.tsx @@ -0,0 +1,111 @@ +import { getFilePreviewUrl } from '@/api/files' +import { + FullscreenSheet, + FullscreenSheetContent, +} from '@/components/ui/fullscreen-sheet' +import { Button } from '@/components/ui/button' +import { X, Maximize2, Minimize2, RotateCw } from 'lucide-react' +import { useState } from 'react' +import { HtmlPreviewFrame } from './HtmlPreviewFrame' +import type { HtmlArtifact } from '@/lib/htmlArtifacts' + +interface HtmlArtifactPanelProps { + artifact: HtmlArtifact | null + isFullscreen: boolean + isMobile: boolean + onClose: () => void + onToggleFullscreen: () => void +} + +export function HtmlArtifactPanel({ + artifact, + isFullscreen, + isMobile, + onClose, + onToggleFullscreen, +}: HtmlArtifactPanelProps) { + const [frameKey, setFrameKey] = useState(0) + + if (!artifact) return null + + const previewSrc = artifact.source === 'file' && artifact.path + ? getFilePreviewUrl(artifact.path) + : artifact.source === 'devserver' + ? artifact.previewUrl + : undefined + const previewSrcDoc = artifact.source === 'inline' ? artifact.html : undefined + const title = artifact.title + + const frameSandbox = artifact.source === 'devserver' + ? 'allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-downloads' + : undefined + + const showReload = artifact.source === 'devserver' || artifact.source === 'file' + + const headerButtons = ( +
+ {showReload && ( + + )} + {!isMobile && ( + + )} + +
+ ) + + const frame = ( + + ) + + if (isMobile || isFullscreen) { + return ( + + +
+ {headerButtons} +
+ {frame} +
+
+ ) + } + + return ( +
+
+ {frame} +
+
+ {headerButtons} +
+
+ ) +} diff --git a/frontend/src/components/html-preview/HtmlPreviewFrame.tsx b/frontend/src/components/html-preview/HtmlPreviewFrame.tsx new file mode 100644 index 00000000..2f79f425 --- /dev/null +++ b/frontend/src/components/html-preview/HtmlPreviewFrame.tsx @@ -0,0 +1,26 @@ +import { cn } from '@/lib/utils' +import { normalizeHtmlPreviewDocument } from '@/lib/htmlArtifacts' + +interface HtmlPreviewFrameProps { + title: string + src?: string + srcDoc?: string + className?: string + sandbox?: string +} + +export function HtmlPreviewFrame({ title, src, srcDoc, className, sandbox }: HtmlPreviewFrameProps) { + const resolvedSandbox = sandbox ?? (src ? 'allow-scripts allow-same-origin' : 'allow-scripts') + const normalizedSrcDoc = srcDoc ? normalizeHtmlPreviewDocument(srcDoc) : undefined + return ( +