diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b06f4109e..adeac74ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,7 +125,7 @@ jobs: - name: Install OpenCode CLI run: | source scripts/lib/opencode-release.sh - download_opencode_to 2.0.24 "$RUNNER_TEMP/opencode/bin/opencode" + download_opencode_to 2.1.2 "$RUNNER_TEMP/opencode/bin/opencode" echo "$RUNNER_TEMP/opencode/bin" >> "$GITHUB_PATH" - name: Verify OpenCode CLI diff --git a/Dockerfile b/Dockerfile index 2dad4aa2c..617080b77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,7 @@ RUN apt-get update && apt-get install -y \ python3 \ python3-pip \ python3-venv \ + tini \ && rm -rf /var/lib/apt/lists/* RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ @@ -62,7 +63,7 @@ FROM base AS runner # an arm64 host builds the amd64 platform; 0.12.7 is the newest verified-good # release, so re-verify a bump there before moving it. ARG UV_VERSION=0.12.7 -ARG OPENCODE_VERSION=2.0.15 +ARG OPENCODE_VERSION=2.1.2 ARG MICROSANDBOX_VERSION=0.7.2 ARG PLAYWRIGHT_VERSION=1.63.0 # Bump TOOLS_CACHEBUST (e.g. via --build-arg) to force a fresh uv/opencode @@ -155,5 +156,5 @@ EXPOSE 5003 5004 5100 5101 5102 5103 HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ CMD curl -f http://localhost:5003/api/health || exit 1 -ENTRYPOINT ["/docker-entrypoint.sh"] +ENTRYPOINT ["/usr/bin/tini", "--", "/docker-entrypoint.sh"] CMD ["bun", "backend/src/index.ts"] diff --git a/backend/src/auth/client-ip.ts b/backend/src/auth/client-ip.ts new file mode 100644 index 000000000..f39d1291c --- /dev/null +++ b/backend/src/auth/client-ip.ts @@ -0,0 +1,21 @@ +import type { Context } from 'hono' +import { getConnInfo } from '@hono/node-server/conninfo' + +export const AUTH_SOCKET_IP_HEADER = 'x-ocm-auth-socket-ip' + +export function resolveSocketIp(c: Context): string | undefined { + try { + return getConnInfo(c).remote.address ?? undefined + } catch { + return undefined + } +} + +export function applyClientIpHeaders(headers: Headers, socketIp: string | undefined): Headers { + headers.delete(AUTH_SOCKET_IP_HEADER) + if (socketIp) { + headers.set(AUTH_SOCKET_IP_HEADER, socketIp) + } + + return headers +} diff --git a/backend/src/auth/index.ts b/backend/src/auth/index.ts index 161527f2c..1732f74bb 100644 --- a/backend/src/auth/index.ts +++ b/backend/src/auth/index.ts @@ -4,6 +4,7 @@ import { passkey } from '@better-auth/passkey' import { Database } from 'bun:sqlite' import { ENV, getTrustedOrigins } from '@opencode-manager/shared/config/env' import { AUTH_COOKIE_PREFIX } from './cookies' +import { AUTH_SOCKET_IP_HEADER } from './client-ip' export type AuthInstance = ReturnType @@ -90,6 +91,9 @@ export function createAuth(db: Database) { advanced: { cookiePrefix: AUTH_COOKIE_PREFIX, useSecureCookies: ENV.AUTH.SECURE_COOKIES, + ipAddress: { + ipAddressHeaders: [AUTH_SOCKET_IP_HEADER], + }, }, }) diff --git a/backend/src/db/change-walkthroughs.ts b/backend/src/db/change-walkthroughs.ts index bca5b5f7a..0e9276593 100644 --- a/backend/src/db/change-walkthroughs.ts +++ b/backend/src/db/change-walkthroughs.ts @@ -222,6 +222,13 @@ export function updateChangeWalkthrough( return getChangeWalkthrough(db, sessionId, sourceKey) } +export function listChangeWalkthroughSessionIds(db: Database): string[] { + const rows = db + .prepare('SELECT DISTINCT walkthrough_session_id FROM change_walkthroughs WHERE walkthrough_session_id IS NOT NULL') + .all() as Array<{ walkthrough_session_id: string }> + return rows.map((row) => row.walkthrough_session_id) +} + export function listChangeWalkthroughs(db: Database, sessionId: string): ChangeWalkthrough[] { const rows = db .prepare(`SELECT ${ROW_COLUMNS} FROM change_walkthroughs WHERE session_id = ?`) diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 57e267d82..1f73ede93 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,5 +1,6 @@ import { Hono } from 'hono' import { isAdminConfigured, type AuthInstance } from '../auth' +import { applyClientIpHeaders, resolveSocketIp } from '../auth/client-ip' import { Database } from 'bun:sqlite' import { ENV } from '@opencode-manager/shared/config/env' import { logger } from '../utils/logger' @@ -8,7 +9,10 @@ import { hashPassword } from 'better-auth/crypto' export function createAuthRoutes(auth: AuthInstance): Hono { const app = new Hono() - app.all('/*', (c) => auth.handler(c.req.raw)) + app.all('/*', (c) => { + applyClientIpHeaders(c.req.raw.headers, resolveSocketIp(c)) + return auth.handler(c.req.raw) + }) return app } diff --git a/backend/src/routes/change-walkthroughs.ts b/backend/src/routes/change-walkthroughs.ts index add6c10eb..3e20ea28c 100644 --- a/backend/src/routes/change-walkthroughs.ts +++ b/backend/src/routes/change-walkthroughs.ts @@ -12,6 +12,10 @@ import { ServiceError } from '../utils/service-error' export function createChangeWalkthroughRoutes(service: ChangeWalkthroughService) { const app = new Hono() + app.get('/helper-sessions', (c) => { + return c.json({ sessionIds: service.listHelperSessionIds() }) + }) + app.get('/:sessionId', async (c) => { const sourceParam = c.req.query('source') const source = sourceParam === undefined ? DEFAULT_WALKTHROUGH_SOURCE : parseWalkthroughSourceKey(sourceParam) diff --git a/backend/src/routes/repos.test.ts b/backend/src/routes/repos.test.ts index 28b996191..4c3927595 100644 --- a/backend/src/routes/repos.test.ts +++ b/backend/src/routes/repos.test.ts @@ -424,7 +424,11 @@ describe('DELETE /api/repos/:id/workspaces', () => { const client = createStubOpenCodeClient() client.api.worktree.list = mock(async () => ([{ directory: worktreeDir, strategy: 'git' }])) as any client.api.worktree.remove = mock(async () => { - throw Object.assign(new Error('cannot remove'), { name: 'WorktreeError', data: { message: 'cannot remove' } }) + throw Object.assign(new Error('cannot remove'), { + _tag: 'WorktreeError', + name: 'WorktreeError', + data: { message: 'cannot remove' }, + }) }) as any const app = createTestApp(db, client) @@ -519,7 +523,11 @@ describe('POST /api/repos/:id/workspaces', () => { createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true }) const client = createStubOpenCodeClient() client.api.worktree.create = mock(async () => { - throw Object.assign(new Error('cannot create'), { name: 'WorktreeError', data: { message: 'cannot create' } }) + throw Object.assign(new Error('cannot create'), { + _tag: 'WorktreeError', + name: 'WorktreeError', + data: { message: 'cannot create' }, + }) }) as any const app = createTestApp(db, client) diff --git a/backend/src/routes/repos.ts b/backend/src/routes/repos.ts index 96f295a42..6e384aa22 100644 --- a/backend/src/routes/repos.ts +++ b/backend/src/routes/repos.ts @@ -498,7 +498,7 @@ app.get('/', async (c) => { return c.json({ ...updatedRepo, currentBranch }) } catch (error: unknown) { logger.error('Failed to create branch:', error) - return c.json({ error: getErrorMessage(error) }, 500) + return c.json({ error: getErrorMessage(error) }, getStatusCode(error) as ContentfulStatusCode) } }) diff --git a/backend/src/routes/session-sandbox.ts b/backend/src/routes/session-sandbox.ts index cdbc559ca..ec7ccc3ac 100644 --- a/backend/src/routes/session-sandbox.ts +++ b/backend/src/routes/session-sandbox.ts @@ -1,8 +1,23 @@ -import { Hono } from 'hono' +import { Hono, type Context } from 'hono' +import type { ContentfulStatusCode } from 'hono/utils/http-status' import { SetSessionSandboxRequestSchema } from '@opencode-manager/shared/schemas' +import { openCodeErrorStatus } from '@opencode-manager/shared/opencode' import { SessionSandboxError, type SessionSandboxService } from '../services/session-sandbox' +import { getErrorMessage } from '../utils/error-utils' import { handleServiceError, parseJsonBody } from '../utils/route-helpers' +/** + * Maps both sandbox errors and the OpenCode errors raised while resolving a session (for example + * `SessionNotFoundError` or `InvalidRequestError` for a malformed id) to their real status codes. + */ +function handleSessionSandboxError(c: Context, error: unknown, fallback: string) { + const status = openCodeErrorStatus(error) + if (status !== undefined) { + return c.json({ error: getErrorMessage(error) || fallback }, status as ContentfulStatusCode) + } + return handleServiceError(c, error, fallback, SessionSandboxError) +} + export function createSessionSandboxRoutes(service: SessionSandboxService) { const app = new Hono() @@ -11,7 +26,7 @@ export function createSessionSandboxRoutes(service: SessionSandboxService) { const state = await service.getState(c.req.param('sessionId')) return c.json(state) } catch (error) { - return handleServiceError(c, error, 'Failed to read session sandbox state', SessionSandboxError) + return handleSessionSandboxError(c, error, 'Failed to read session sandbox state') } }) @@ -25,7 +40,7 @@ export function createSessionSandboxRoutes(service: SessionSandboxService) { const state = await service.update(c.req.param('sessionId'), parsed.data) return c.json(state) } catch (error) { - return handleServiceError(c, error, 'Failed to set session sandbox state', SessionSandboxError) + return handleSessionSandboxError(c, error, 'Failed to set session sandbox state') } }) diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index e9309fbb6..3ba3af2cd 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -12,6 +12,7 @@ import type { OpenCodeClient } from '../services/opencode/client' import { getAgentsMdPath } from '@opencode-manager/shared/config/env' import { UserPreferencesSchema, + isValidGitIdentityEmail, type SandboxPreferences, type UserPreferences, } from '../types/settings' @@ -299,9 +300,38 @@ function spawnWithTimeout(args: string[], timeoutMs: number, env?: Record(identity: T): T => ({ + ...identity, + email: identity.email.trim(), +}) + const UpdateSettingsSchema = z.object({ preferences: UserPreferencesSchema.partial(), -}) +}).superRefine(({ preferences }, ctx) => { + if (preferences.gitIdentity && !isValidGitIdentityEmail(preferences.gitIdentity.email)) { + ctx.addIssue({ + code: 'custom', + path: ['preferences', 'gitIdentity', 'email'], + message: 'Invalid email address', + }) + } + + preferences.gitIdentities?.forEach((identity, index) => { + if (!isValidGitIdentityEmail(identity.email)) { + ctx.addIssue({ + code: 'custom', + path: ['preferences', 'gitIdentities', index, 'email'], + message: 'Invalid email address', + }) + } + }) +}).transform(({ preferences }) => ({ + preferences: { + ...preferences, + ...(preferences.gitIdentity ? { gitIdentity: trimGitIdentityEmail(preferences.gitIdentity) } : {}), + ...(preferences.gitIdentities ? { gitIdentities: preferences.gitIdentities.map(trimGitIdentityEmail) } : {}), + }, +})) const CreateCustomCommandSchema = z.object({ name: z.string().min(1).max(255), diff --git a/backend/src/services/change-walkthroughs.ts b/backend/src/services/change-walkthroughs.ts index 7a4d9fa55..b30b773f4 100644 --- a/backend/src/services/change-walkthroughs.ts +++ b/backend/src/services/change-walkthroughs.ts @@ -31,6 +31,7 @@ import { deleteChangeWalkthroughs, getChangeWalkthrough, getChangeWalkthroughByWalkthroughSession, + listChangeWalkthroughSessionIds, listChangeWalkthroughs, updateChangeWalkthrough, type ChangeWalkthroughPatch, @@ -56,6 +57,7 @@ import type { SettingsService } from './settings' import type { SSEEvent } from './sse-aggregator' const WALKTHROUGH_TIMEOUT_MESSAGE = 'The walkthrough session did not publish within 15 minutes' +const EMPTY_CHANGES_HASH = computeChangesHash([]) export interface ChangeWalkthroughServiceOptions { generationTimeoutMs?: number @@ -170,6 +172,11 @@ export class ChangeWalkthroughService { this.walkthroughsRoot = options.walkthroughsRoot ?? getWalkthroughsPath() } + /** Sessions the walkthrough runs were created in, which are helper sessions rather than user sessions. */ + listHelperSessionIds(): string[] { + return listChangeWalkthroughSessionIds(this.db) + } + async getState( sessionId: string, source: WalkthroughSource = DEFAULT_WALKTHROUGH_SOURCE, @@ -187,6 +194,7 @@ export class ChangeWalkthroughService { return { walkthrough, currentDiffHash, + hasChanges: currentDiffHash === null || currentDiffHash !== EMPTY_CHANGES_HASH, stale: walkthrough !== null && currentDiffHash !== null && walkthrough.diffHash !== currentDiffHash, generating: walkthrough?.status === 'generating', running: walkthrough !== null && walkthrough.executionStartedAt !== null, diff --git a/backend/src/services/files.ts b/backend/src/services/files.ts index 2a8d6c9ed..d230b5f26 100644 --- a/backend/src/services/files.ts +++ b/backend/src/services/files.ts @@ -40,9 +40,15 @@ interface FileUploadResult { mimeType: string } +const BINARY_MIME_TYPE = 'application/octet-stream' +const SNIFF_BYTE_COUNT = 8192 +const MAX_INVALID_UTF8_RATIO = 0.1 + +type DetectedMimeType = AllowedMimeType | typeof BINARY_MIME_TYPE + export interface RawFileMetadata { size: number - mimeType: AllowedMimeType + mimeType: DetectedMimeType lastModified: Date } @@ -85,7 +91,7 @@ export async function getRawFileMetadata(userPath: string): Promise { } else { // It's a file - get content let content = '' - const mimeType = getMimeType(validatedPath) + const mimeType = await detectMimeType(validatedPath) if (stats.size < FILE_LIMITS.MAX_SIZE_BYTES) { try { - const mimeType = getMimeType(validatedPath) - if (mimeType.startsWith('image/') || !mimeType.startsWith('text/')) { content = await readFileAsBase64(validatedPath) } else { @@ -179,7 +183,7 @@ export async function uploadFile(userPath: string, file: File, relativePath?: st throw new Error('File too large') } - const mimeType = (file.type || getMimeType(file.name)) as AllowedMimeType + const mimeType = (file.type || getKnownMimeType(file.name) || 'text/plain') as AllowedMimeType if (!ALLOWED_MIME_TYPES.includes(mimeType) && !mimeType.startsWith('text/')) { throw new Error('File type not allowed') } @@ -288,7 +292,7 @@ function validatePath(userPath: string): string { return resolved } -function getMimeType(filePath: string): AllowedMimeType { +function getKnownMimeType(filePath: string): AllowedMimeType | undefined { const ext = path.extname(filePath).toLowerCase() const mimeTypes: Record = { @@ -315,7 +319,32 @@ function getMimeType(filePath: string): AllowedMimeType { '.mdx': 'text/markdown', } - return mimeTypes[ext] || 'text/plain' + return mimeTypes[ext] +} + +async function detectMimeType(filePath: string): Promise { + const known = getKnownMimeType(filePath) + if (known) return known + return isBinaryContent(await readFilePrefix(filePath)) ? BINARY_MIME_TYPE : 'text/plain' +} + +async function readFilePrefix(filePath: string): Promise { + const handle = await fs.open(filePath, 'r') + try { + const buffer = Buffer.alloc(SNIFF_BYTE_COUNT) + const { bytesRead } = await handle.read(buffer, 0, SNIFF_BYTE_COUNT, 0) + return buffer.subarray(0, bytesRead) + } finally { + await handle.close() + } +} + +function isBinaryContent(buffer: Buffer): boolean { + if (buffer.length === 0) return false + if (buffer.includes(0)) return true + const decoded = buffer.toString('utf8') + const invalidCharacters = decoded.split('\uFFFD').length - 1 + return invalidCharacters / decoded.length > MAX_INVALID_UTF8_RATIO } async function readFileLinesAndCount( @@ -356,7 +385,7 @@ export async function getFileRange(userPath: string, startLine: number, endLine: const { lines, totalLines } = await readFileLinesAndCount(validatedPath, startLine, endLine) const clampedEnd = Math.min(endLine, totalLines) - const mimeType = getMimeType(validatedPath) + const mimeType = await detectMimeType(validatedPath) return { name: path.basename(validatedPath), diff --git a/backend/src/services/git/GitService.ts b/backend/src/services/git/GitService.ts index 9d2ff5749..1bf8860b6 100644 --- a/backend/src/services/git/GitService.ts +++ b/backend/src/services/git/GitService.ts @@ -176,12 +176,13 @@ export class GitService { } const repoPath = path.resolve(repo.fullPath) + const revision = branch?.trim() || 'HEAD' const logArgs = [ 'git', '-C', repoPath, 'log', - branch?.trim() || 'HEAD', + revision, `-n`, String(limit), '--format=%H|%an|%ae|%at|%s' @@ -210,7 +211,7 @@ export class GitService { } } - const unpushedCommits = await this.getUnpushedCommitHashes(repoPath, logEnv) + const unpushedCommits = await this.getUnpushedCommitHashes(repoPath, revision, logEnv, limit) return commits.map(commit => ({ ...commit, @@ -858,6 +859,9 @@ export class GitService { return { ahead: ahead || 0, behind: behind || 0 } } catch (error) { + if (isNoUpstreamError(error as Error)) { + return { ahead: 0, behind: 0 } + } logger.warn(`Could not get branch status for repo ${repoId}, returning zeros:`, error) return { ahead: 0, behind: 0 } } @@ -1515,16 +1519,18 @@ export class GitService { } private async isCommitUnpushed(repoPath: string, commitHash: string, env: Record): Promise { - const unpushedHashes = await this.getUnpushedCommitHashes(repoPath, env) + const unpushedHashes = await this.getUnpushedCommitHashes(repoPath, 'HEAD', env) return unpushedHashes.has(commitHash) } - private async getUnpushedCommitHashes(repoPath: string, env: Record): Promise> { + private async getUnpushedCommitHashes(repoPath: string, revision: string, env: Record, limit?: number): Promise> { try { - const output = await executeCommand( - ['git', '-C', repoPath, 'log', '--not', '--remotes', '--format=%H'], - { env, silent: true } - ) + const args = ['git', '-C', repoPath, 'log', revision, '--not', '--remotes'] + if (limit !== undefined) { + args.push('-n', String(limit)) + } + args.push('--format=%H') + const output = await executeCommand(args, { env, silent: true }) const hashes = output.trim().split('\n').filter(Boolean) return new Set(hashes) } catch { diff --git a/backend/src/services/opencode-server-registry.test.ts b/backend/src/services/opencode-server-registry.test.ts index 168dcb818..6fbe3d580 100644 --- a/backend/src/services/opencode-server-registry.test.ts +++ b/backend/src/services/opencode-server-registry.test.ts @@ -449,7 +449,7 @@ describe('OpenCodeServerRegistry', () => { describe('test', () => { it('reports a supported version', async () => { - fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.0.15' })) + fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.1.2' })) const result = await registry.test({ baseUrl: 'https://studio.example.com/', @@ -457,7 +457,7 @@ describe('OpenCodeServerRegistry', () => { password: 'super-secret', }) - expect(result).toEqual({ ok: true, version: '2.0.15', versionSupported: true, error: null }) + expect(result).toEqual({ ok: true, version: '2.1.2', versionSupported: true, error: null }) expect(fetchFn.mock.calls[0]?.[0]).toBe('https://studio.example.com/api/info') const headers = new Headers(fetchFn.mock.calls[0]?.[1]?.headers) expect(headers.get('authorization')).toBe(`Basic ${btoa('alice:super-secret')}`) @@ -478,7 +478,7 @@ describe('OpenCodeServerRegistry', () => { username: 'alice', password: 'super-secret', }) - fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.0.15' })) + fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.1.2' })) const result = await registry.test({ serverId: server.id }) @@ -493,7 +493,7 @@ describe('OpenCodeServerRegistry', () => { baseUrl: 'https://studio.example.com', password: 'super-secret', }) - fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.0.15' })) + fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.1.2' })) const result = await registry.test({ baseUrl: 'https://studio.example.com/opencode', @@ -513,7 +513,7 @@ describe('OpenCodeServerRegistry', () => { baseUrl: 'https://studio.example.com', password: 'super-secret', }) - fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.0.15' })) + fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.1.2' })) const result = await registry.test({ baseUrl: 'https://relocated.example.com', @@ -533,7 +533,7 @@ describe('OpenCodeServerRegistry', () => { baseUrl: 'https://studio.example.com', password: 'super-secret', }) - fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.0.15' })) + fetchFn.mockResolvedValueOnce(jsonResponse({ version: '2.1.2' })) await registry.test({ baseUrl: 'https://studio.example.com', diff --git a/backend/src/services/preview/ports.ts b/backend/src/services/preview/ports.ts index b0027af02..aacd21c7e 100644 --- a/backend/src/services/preview/ports.ts +++ b/backend/src/services/preview/ports.ts @@ -96,7 +96,7 @@ function isAllowedAddress(address: string): boolean { address === WILDCARD_V4 || address === WILDCARD_V6 || address === '::1' || - address.startsWith('127.') + address === '127.0.0.1' ) } diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index d85acf058..ed36f9c55 100644 --- a/backend/src/services/repo.ts +++ b/backend/src/services/repo.ts @@ -27,30 +27,52 @@ const GIT_CLONE_TIMEOUT = 300000 const DEFAULT_DISCOVERY_MAX_DEPTH = 4 const DISCOVERY_SKIP_DIRECTORIES = new Set(['.git', 'node_modules']) -function enhanceCloneError(error: unknown, repoUrl: string, originalMessage: string): Error { +export class RepoInputError extends Error { + readonly statusCode: 400 + + constructor(message: string) { + super(message) + this.name = 'RepoInputError' + this.statusCode = 400 + } +} + +function notFoundOrDeniedMessage(repoUrl: string): string { + return `Repository not found or access denied: ${repoUrl}. Check the URL and add credentials in Settings > Git Credentials if it is private.` +} + +function enhanceCloneError(repoUrl: string, originalMessage: string): RepoInputError { const message = originalMessage.toLowerCase() - - if (message.includes('authentication failed') || message.includes('could not authenticate') || message.includes('invalid credentials')) { - return new Error(`Authentication failed for ${repoUrl}. Please add your credentials in Settings > Git Credentials.`) + + if (message.includes('timed out')) { + return new RepoInputError(`Clone timed out for ${repoUrl}. The repository might be too large or there could be network issues.`) } - - if (message.includes('repository not found') || message.includes('404')) { - return new Error(`Repository not found: ${repoUrl}. Check the URL and ensure you have access to it.`) + + if (message.includes('could not resolve host') || message.includes('network is unreachable')) { + return new RepoInputError(`Repository not reachable: ${repoUrl}. Check the URL and your network connection.`) } - - if (isSSHUrl(repoUrl) && message.includes('permission denied')) { - return new Error(`Access denied to ${repoUrl}. Please add your SSH credentials in Settings > Git Credentials and ensure your SSH key has access to this repository.`) + + if (message.includes('repository not found') || message.includes('not found') || message.includes('does not exist') || message.includes('404')) { + return new RepoInputError(notFoundOrDeniedMessage(repoUrl)) } - - if (isGitHubHttpsUrl(repoUrl) && (message.includes('permission denied') || message.includes('fatal'))) { - return new Error(`Access denied to ${repoUrl}. Please add your credentials in Settings > Git Credentials and ensure you have proper access.`) + + if (message.includes('permission denied')) { + return isSSHUrl(repoUrl) + ? new RepoInputError(`Access denied to ${repoUrl}. Add your SSH credentials in Settings > Git Credentials and ensure your SSH key has access.`) + : new RepoInputError(notFoundOrDeniedMessage(repoUrl)) } - - if (message.includes('timed out')) { - return new Error(`Clone timed out for ${repoUrl}. The repository might be too large or there could be network issues. Try again or verify the repository exists.`) + + if (message.includes('authentication failed') || message.includes('could not authenticate') || message.includes('invalid credentials') || message.includes('could not read username')) { + return isGitHubHttpsUrl(repoUrl) + ? new RepoInputError(notFoundOrDeniedMessage(repoUrl)) + : new RepoInputError(`Authentication failed for ${repoUrl}. Check your credentials in Settings > Git Credentials.`) } - - return error instanceof Error ? error : new Error(originalMessage) + + if (message.includes('not a git repository') || message.includes('fatal:')) { + return new RepoInputError(notFoundOrDeniedMessage(repoUrl)) + } + + return new RepoInputError(`Could not clone ${repoUrl}. Check the URL and your access to it.`) } async function hasCommits(repoPath: string, env: Record): Promise { @@ -258,12 +280,12 @@ async function registerExistingLocalRepo( const exists = await pathExists(normalizedSourcePath) if (!exists) { - throw new Error(`No such file or directory: '${normalizedSourcePath}'`) + throw new RepoInputError(`No such directory: '${normalizedSourcePath}'`) } const isGitRepo = await isValidGitRepo(normalizedSourcePath, env) if (!isGitRepo) { - throw new Error(`Directory exists but is not a valid Git repository. Use folder discovery to scan nested repositories.`) + throw new RepoInputError(`Directory is not a valid Git repository: '${normalizedSourcePath}'.`) } if (branch) { @@ -317,11 +339,11 @@ export async function discoverLocalRepos( }> { const normalizedRootPath = normalizeAbsolutePath(rootPath) const rootStats = await fs.stat(normalizedRootPath).catch((error: unknown) => { - throw new Error(`Failed to access '${normalizedRootPath}': ${getErrorMessage(error)}`) + throw new RepoInputError(`Failed to access '${normalizedRootPath}': ${getErrorMessage(error)}`) }) if (!rootStats.isDirectory()) { - throw new Error(`Path is not a directory: '${normalizedRootPath}'`) + throw new RepoInputError(`Path is not a directory: '${normalizedRootPath}'`) } const repoPaths: string[] = [] @@ -687,7 +709,7 @@ export async function cloneRepo( try { await executeCommand(['git', 'clone', normalizedRepoUrl, worktreeDirName], { cwd: getReposPath(), env, timeout: GIT_CLONE_TIMEOUT }) } catch (cloneError: unknown) { - throw enhanceCloneError(cloneError, normalizedRepoUrl, getErrorMessage(cloneError)) + throw enhanceCloneError(normalizedRepoUrl, getErrorMessage(cloneError)) } let localBranchExists = 'missing' @@ -704,7 +726,7 @@ export async function cloneRepo( await executeCommand(['git', '-C', path.resolve(getReposPath(), worktreeDirName), 'checkout', branch]) } } else { - throw enhanceCloneError(error, normalizedRepoUrl, getErrorMessage(error)) + throw enhanceCloneError(normalizedRepoUrl, getErrorMessage(error)) } } } else { @@ -801,7 +823,7 @@ export async function cloneRepo( try { await executeCommand(['git', 'clone', normalizedRepoUrl, worktreeDirName], { cwd: getReposPath(), env, timeout: GIT_CLONE_TIMEOUT }) } catch (cloneError: unknown) { - throw enhanceCloneError(cloneError, normalizedRepoUrl, getErrorMessage(cloneError)) + throw enhanceCloneError(normalizedRepoUrl, getErrorMessage(cloneError)) } let localBranchExists = 'missing' @@ -818,7 +840,7 @@ export async function cloneRepo( await executeCommand(['git', '-C', path.resolve(getReposPath(), worktreeDirName), 'checkout', branch]) } } else { - throw enhanceCloneError(error, normalizedRepoUrl, getErrorMessage(error)) + throw enhanceCloneError(normalizedRepoUrl, getErrorMessage(error)) } } } @@ -876,6 +898,14 @@ export async function switchBranch( } } +async function assertValidBranchName(branch: string): Promise { + try { + await executeCommand(['git', 'check-ref-format', '--branch', branch], { silent: true }) + } catch { + throw new RepoInputError(`Invalid branch name: '${branch}'.`) + } +} + export async function createBranch(database: Database, gitAuthService: GitAuthService, repoId: number, branch: string): Promise { const repo = getRepoById(database, repoId) if (!repo) { @@ -891,6 +921,8 @@ export async function createBranch(database: Database, gitAuthService: GitAuthSe .replace(/^refs\/remotes\//, '') .replace(/^origin\//, '') + await assertValidBranchName(sanitizedBranch) + logger.info(`Creating new branch: ${sanitizedBranch} in ${repo.localPath}`) await executeCommand(['git', '-C', repoPath, 'checkout', '-b', sanitizedBranch], { env }) logger.info(`Successfully created and switched to branch: ${sanitizedBranch}`) @@ -944,6 +976,19 @@ export async function resolveMainCheckoutPath(worktreePath: string): Promise { + try { + const stats = await fs.lstat(aliasPath) + if (stats.isSymbolicLink()) { + await fs.unlink(aliasPath) + } + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + } +} + export async function deleteRepoFiles(database: Database, repoId: number): Promise { const repo = getRepoById(database, repoId) if (!repo) { @@ -959,9 +1004,14 @@ export async function deleteRepoFiles(database: Database, repoId: number): Promi if (baseRepoPath) { await removeWorktree(baseRepoPath, fullPath) } + + await executeCommand(['rm', '-rf', repo.localPath], getReposPath()) + } else if (repo.isLocal) { + await removeWorkspaceAlias(fullPath) + } else { + await executeCommand(['rm', '-rf', repo.localPath], getReposPath()) } - await executeCommand(['rm', '-rf', repo.localPath], getReposPath()) deleteRepo(database, repoId) } diff --git a/backend/src/services/terminal-hub.ts b/backend/src/services/terminal-hub.ts new file mode 100644 index 000000000..ae3d96477 --- /dev/null +++ b/backend/src/services/terminal-hub.ts @@ -0,0 +1,414 @@ +import { + MAX_PEER_BUFFERED_BYTES, + MAX_UPSTREAM_BUFFERED_BYTES, + UPSTREAM_CONNECT_TIMEOUT_MS, + type WebSocketBridge, + type WebSocketPeer, +} from '../utils/websocket-bridge' + +const BUFFER_LIMIT_CHARS = 256 * 1024 +const REPLAY_CHUNK_CHARS = 64 * 1024 +const MAX_CONNECT_ATTEMPTS = 3 +const RETRY_DELAY_MS = 100 +const META_FRAME_PREFIX = 0 +const MAX_REGISTERED_HUBS = 64 +const WEBSOCKET_OPEN = 1 +const WEBSOCKET_CLOSING = 2 +const WEBSOCKET_CLOSED = 3 +const DEFAULT_EXIT_CODE = 1000 +const ABNORMAL_CLOSE_CODE = 1011 +const TOO_SLOW_CLOSE_CODE = 1013 +const GRACEFUL_CLOSE_CODES: ReadonlySet = new Set([DEFAULT_EXIT_CODE, 4404]) + +type HubState = 'connecting' | 'open' | 'closed' | 'failed' + +const META_DECODER = new TextDecoder() +const META_ENCODER = new TextEncoder() +const DETACHED_BRIDGE: WebSocketBridge = { send: () => {}, close: () => {} } + +export function encodeTerminalMetaFrame(cursor: number): Uint8Array { + const payload = META_ENCODER.encode(JSON.stringify({ cursor })) + const frame = new Uint8Array(new ArrayBuffer(payload.length + 1)) + frame[0] = META_FRAME_PREFIX + frame.set(payload, 1) + return frame +} + +export interface TerminalHubOptions { + openUpstream: () => Promise + releaseOnOpen?: string +} + +export class TerminalHub { + private readonly decoder = new TextDecoder() + private readonly subscribers = new Set() + private readonly pendingInput: Array> = [] + private socket: WebSocket | null = null + private state: HubState = 'connecting' + private attempts = 0 + private retryTimer: ReturnType | undefined + private attemptTimer: ReturnType | undefined + private attemptToken = 0 + private pendingInputBytes = 0 + private buffer = '' + private bufferStart = 0 + private cursor = 0 + private exitCode = DEFAULT_EXIT_CODE + private released = false + + constructor(private readonly options: TerminalHubOptions) {} + + start(): Promise { + return this.connect() + } + + isAvailable(): boolean { + return this.state !== 'failed' + } + + isEnded(): boolean { + return this.state === 'closed' || this.state === 'failed' + } + + hasReleased(): boolean { + return this.released + } + + attach(peer: WebSocketPeer, cursor: number | undefined): WebSocketBridge { + this.replay(peer, cursor) + if (this.state === 'closed') { + this.closePeer(peer, this.exitCode) + return DETACHED_BRIDGE + } + if (this.state === 'failed') { + this.closePeer(peer, ABNORMAL_CLOSE_CODE) + return DETACHED_BRIDGE + } + this.subscribers.add(peer) + return { + send: (data) => this.write(data), + close: () => { + this.subscribers.delete(peer) + }, + } + } + + dispose(): void { + this.clearRetry() + this.clearAttemptTimer() + const closeCode = this.state === 'failed' ? ABNORMAL_CLOSE_CODE : this.exitCode + if (this.state !== 'failed') this.state = 'closed' + const socket = this.socket + this.socket = null + closeSocketQuietly(socket) + const peers = [...this.subscribers] + this.subscribers.clear() + for (const peer of peers) this.closePeer(peer, closeCode) + } + + private async connect(): Promise { + if (this.state !== 'connecting') return + this.attempts += 1 + const token = ++this.attemptToken + this.clearAttemptTimer() + this.attemptTimer = setTimeout(() => this.handleConnectTimeout(token), UPSTREAM_CONNECT_TIMEOUT_MS) + let socket: WebSocket + try { + socket = await this.options.openUpstream() + } catch { + if (token !== this.attemptToken) return + this.clearAttemptTimer() + this.retryOrFail() + return + } + if (token !== this.attemptToken || this.state !== 'connecting') { + closeSocketQuietly(socket) + return + } + this.socket = socket + socket.binaryType = 'arraybuffer' + socket.addEventListener('open', () => this.handleOpen(socket)) + socket.addEventListener('message', (event) => this.handleMessage(event as MessageEvent)) + socket.addEventListener('close', (event) => this.handleUpstreamClosed(socket, closeCodeOf(event))) + socket.addEventListener('error', () => this.handleUpstreamError(socket)) + const readyState = (socket as { readyState?: number }).readyState + if (readyState === WEBSOCKET_OPEN) { + this.handleOpen(socket) + return + } + if (readyState === WEBSOCKET_CLOSING || readyState === WEBSOCKET_CLOSED) { + this.socket = null + this.clearAttemptTimer() + this.retryOrFail() + } + } + + private handleConnectTimeout(token: number): void { + if (token !== this.attemptToken) return + this.attemptTimer = undefined + this.attemptToken += 1 + const socket = this.socket + this.socket = null + closeSocketQuietly(socket) + if (this.state !== 'connecting') return + this.retryOrFail() + } + + private handleOpen(socket: WebSocket): void { + if (this.socket !== socket || this.state !== 'connecting') return + this.state = 'open' + this.clearAttemptTimer() + if (!this.sendRelease(socket)) return + while (this.pendingInput.length > 0) { + const item = this.pendingInput.shift()! + this.pendingInputBytes -= byteLengthOf(item) + try { + socket.send(item) + } catch { + this.endStream(DEFAULT_EXIT_CODE) + return + } + } + } + + private sendRelease(socket: WebSocket): boolean { + const payload = this.options.releaseOnOpen + if (payload === undefined || this.released) return true + try { + socket.send(payload) + } catch { + this.failStream(ABNORMAL_CLOSE_CODE) + return false + } + this.released = true + return true + } + + private handleMessage(event: MessageEvent): void { + const data: unknown = event.data + if (typeof data === 'string') { + this.appendOutput(data) + return + } + let bytes: Uint8Array + if (data instanceof ArrayBuffer) { + bytes = new Uint8Array(data) + } else if (data instanceof Uint8Array) { + bytes = data + } else { + return + } + if (bytes.length > 0 && bytes[0] === META_FRAME_PREFIX) { + this.alignToCursor(bytes.subarray(1)) + return + } + this.appendOutput(this.decoder.decode(bytes, { stream: true })) + } + + private handleUpstreamError(socket: WebSocket): void { + if (this.socket !== socket || this.state !== 'connecting') return + this.socket = null + this.clearAttemptTimer() + closeSocketQuietly(socket) + this.retryOrFail() + } + + private handleUpstreamClosed(socket: WebSocket, code: number): void { + if (this.socket !== socket) return + this.socket = null + this.clearAttemptTimer() + if (this.state === 'open') { + if (GRACEFUL_CLOSE_CODES.has(code)) { + this.endStream(code) + return + } + this.failStream(ABNORMAL_CLOSE_CODE) + return + } + if (this.state === 'connecting') this.retryOrFail() + } + + private retryOrFail(): void { + if (this.state !== 'connecting') return + if (this.attempts >= MAX_CONNECT_ATTEMPTS) { + this.failStream(ABNORMAL_CLOSE_CODE) + return + } + this.clearRetry() + this.retryTimer = setTimeout(() => { + this.retryTimer = undefined + void this.connect() + }, RETRY_DELAY_MS) + } + + private clearRetry(): void { + if (this.retryTimer === undefined) return + clearTimeout(this.retryTimer) + this.retryTimer = undefined + } + + private clearAttemptTimer(): void { + if (this.attemptTimer === undefined) return + clearTimeout(this.attemptTimer) + this.attemptTimer = undefined + } + + private endStream(code: number): void { + if (this.state === 'closed' || this.state === 'failed') return + this.state = 'closed' + this.exitCode = code + this.closeSubscribers(code) + } + + private failStream(code: number, reason?: string): void { + if (this.state === 'failed') return + this.state = 'failed' + this.clearRetry() + this.clearAttemptTimer() + this.pendingInput.length = 0 + this.pendingInputBytes = 0 + const socket = this.socket + this.socket = null + closeSocketQuietly(socket) + this.closeSubscribers(code, reason) + } + + private closeSubscribers(code: number, reason?: string): void { + const peers = [...this.subscribers] + this.subscribers.clear() + for (const peer of peers) this.closePeer(peer, code, reason) + } + + private alignToCursor(payload: Uint8Array): void { + try { + const parsed = JSON.parse(META_DECODER.decode(payload)) as { cursor?: unknown } + if (typeof parsed.cursor !== 'number' || !Number.isSafeInteger(parsed.cursor)) return + if (parsed.cursor < this.cursor) return + this.cursor = parsed.cursor + this.bufferStart = Math.max(0, parsed.cursor - this.buffer.length) + } catch { + return + } + } + + private appendOutput(chunk: string): void { + if (chunk.length === 0) return + this.cursor += chunk.length + this.buffer += chunk + const excess = this.buffer.length - BUFFER_LIMIT_CHARS + if (excess > 0) { + this.buffer = this.buffer.slice(excess) + this.bufferStart += excess + } + for (const peer of [...this.subscribers]) this.deliver(peer, chunk) + } + + private replay(peer: WebSocketPeer, cursor: number | undefined): void { + const from = this.replayStart(cursor) + const offset = Math.max(0, from - this.bufferStart) + const replay = offset < this.buffer.length ? this.buffer.slice(offset) : '' + for (let index = 0; index < replay.length; index += REPLAY_CHUNK_CHARS) { + this.deliver(peer, replay.slice(index, index + REPLAY_CHUNK_CHARS)) + } + this.deliver(peer, encodeTerminalMetaFrame(this.cursor)) + } + + private replayStart(cursor: number | undefined): number { + if (cursor === -1) return this.cursor + if (cursor === undefined) return this.bufferStart + return Math.max(cursor, this.bufferStart) + } + + private write(data: string | ArrayBuffer | Uint8Array): void { + if (this.state === 'closed' || this.state === 'failed') return + const payload: string | Uint8Array = + typeof data === 'string' ? data : data instanceof Uint8Array ? data : new Uint8Array(data) + if (this.state === 'open' && this.socket) { + try { + this.socket.send(payload) + } catch { + this.endStream(DEFAULT_EXIT_CODE) + } + return + } + this.pendingInput.push(payload) + this.pendingInputBytes += byteLengthOf(payload) + if (this.pendingInputBytes > MAX_UPSTREAM_BUFFERED_BYTES) { + this.failStream(TOO_SLOW_CLOSE_CODE, 'Upstream too slow') + } + } + + private deliver(peer: WebSocketPeer, data: string | Uint8Array): void { + try { + peer.send(data) + } catch { + this.subscribers.delete(peer) + return + } + if (peer.bufferedAmount() > MAX_PEER_BUFFERED_BYTES) { + this.subscribers.delete(peer) + this.closePeer(peer, TOO_SLOW_CLOSE_CODE, 'Client too slow') + } + } + + private closePeer(peer: WebSocketPeer, code: number, reason?: string): void { + try { + peer.close(code, reason) + } catch { + return + } + } +} + +function closeCodeOf(event: unknown): number { + const code = (event as Partial | undefined)?.code + return typeof code === 'number' ? code : DEFAULT_EXIT_CODE +} + +function closeSocketQuietly(socket: { close: () => void } | null | undefined): void { + if (!socket) return + try { + socket.close() + } catch { + return + } +} + +function byteLengthOf(data: string | Uint8Array): number { + return typeof data === 'string' ? Buffer.byteLength(data) : data.byteLength +} + +export class TerminalHubRegistry { + private readonly hubs = new Map() + + get(key: string): TerminalHub | undefined { + return this.hubs.get(key) + } + + register(key: string, hub: TerminalHub): void { + if (this.hubs.has(key)) { + hub.dispose() + return + } + if (this.hubs.size >= MAX_REGISTERED_HUBS && !this.evict()) { + hub.dispose() + return + } + this.hubs.set(key, hub) + } + + dispose(key: string): void { + const hub = this.hubs.get(key) + if (!hub) return + this.hubs.delete(key) + hub.dispose() + } + + private evict(): boolean { + const target = [...this.hubs.entries()].find(([, hub]) => hub.isEnded()) + if (!target) return false + this.hubs.delete(target[0]) + target[1].dispose() + return true + } +} diff --git a/backend/src/services/terminal.ts b/backend/src/services/terminal.ts index d96def17e..c7704e23b 100644 --- a/backend/src/services/terminal.ts +++ b/backend/src/services/terminal.ts @@ -5,6 +5,7 @@ import type { OpenCodeClient } from './opencode/client' import type { CredentialProvider } from './credential-provider' import { toWebSocketUrl } from './opencode/upstream' import { bridgeWebSocket, type WebSocketBridge, type WebSocketPeer } from '../utils/websocket-bridge' +import { TerminalHub, TerminalHubRegistry } from './terminal-hub' type OpenCodePty = Awaited>['data'][number] type OpenCodePersistentPty = Awaited>[number] @@ -36,15 +37,32 @@ export class TerminalNotFoundError extends Error { } } +const DEFERRED_START_SHELL = '/bin/sh' +const DEFERRED_START_SCRIPT = 'stty -echo 2>/dev/null; IFS= read -r _; stty echo 2>/dev/null; exec "$@"' +const DEFERRED_START_HANDLE = 'ocm-terminal' +const DEFERRED_START_RELEASE = '\n' +const DEFERRED_START_KINDS: ReadonlySet = new Set(['action', 'setup']) + +type DeferredRelease = 'pending' | 'released' + +function deferCommandStart(command: string, args: readonly string[]): { command: string; args: string[] } { + return { + command: DEFERRED_START_SHELL, + args: ['-c', DEFERRED_START_SCRIPT, DEFERRED_START_HANDLE, command, ...args], + } +} + export class TerminalService { constructor( private readonly target: TerminalTarget, private readonly credentialProvider: CredentialProvider, private readonly openSocket: (url: string) => WebSocket = (url) => new WebSocket(url), + private readonly hubs: TerminalHubRegistry = new TerminalHubRegistry(), + private readonly deferredReleases: Map = new Map(), ) {} forTarget(target: TerminalTarget): TerminalService { - return new TerminalService(target, this.credentialProvider, this.openSocket) + return new TerminalService(target, this.credentialProvider, this.openSocket, this.hubs, this.deferredReleases) } async list(directory: string): Promise { @@ -53,17 +71,44 @@ export class TerminalService { } async create(directory: string, input: CreateTerminalInput): Promise { + const deferred = + input.command !== undefined && DEFERRED_START_KINDS.has(input.kind) && process.platform !== 'win32' + ? deferCommandStart(input.command, input.args ?? []) + : undefined const result = await this.target.client.api.pty.create({ location: { directory }, title: formatTerminalTitle({ kind: input.kind, name: input.name, actionId: input.actionId }), - ...(input.command !== undefined ? { command: input.command } : {}), - ...(input.args !== undefined ? { args: input.args } : {}), + ...(deferred + ? { command: deferred.command, args: deferred.args } + : { + ...(input.command !== undefined ? { command: input.command } : {}), + ...(input.args !== undefined ? { args: input.args } : {}), + }), env: { ...(this.target.includeLocalCredentials ? this.credentialProvider.getGhCliEnv({ cwd: directory }) : {}), ...input.env, }, }) - return this.toTerminalInfo(result.data) + const terminal = this.toTerminalInfo(result.data) + this.recordTerminal(directory, terminal.id, deferred !== undefined) + return terminal + } + + private recordTerminal(directory: string, ptyID: string, deferred: boolean): void { + if (!deferred) return + const key = this.hubKey(directory, ptyID) + if (this.hubs.get(key)) return + this.deferredReleases.set(key, 'pending') + const hub = new TerminalHub({ + openUpstream: () => this.openUpstreamSocket(directory, ptyID, 0), + releaseOnOpen: DEFERRED_START_RELEASE, + }) + this.hubs.register(key, hub) + void hub.start() + } + + private hubKey(directory: string, ptyID: string): string { + return `${this.target.baseUrl()}\u0000${directory}\u0000${ptyID}` } async requireTerminal(directory: string, ptyID: string): Promise { @@ -80,7 +125,13 @@ export class TerminalService { } async remove(directory: string, ptyID: string): Promise { - await this.target.client.api.pty.remove({ ptyID, location: { directory } }) + try { + await this.target.client.api.pty.remove({ ptyID, location: { directory } }) + } finally { + const key = this.hubKey(directory, ptyID) + this.hubs.dispose(key) + this.deferredReleases.delete(key) + } } async removeAll(directory: string): Promise { @@ -104,6 +155,44 @@ export class TerminalService { ): Promise { await this.requireTerminal(directory, ptyID) + const key = this.hubKey(directory, ptyID) + const hub = this.hubs.get(key) + if (hub?.isAvailable()) return hub.attach(peer, cursor) + if (hub) { + if (hub.hasReleased()) this.deferredReleases.set(key, 'released') + this.hubs.dispose(key) + } + + const releasePending = this.deferredReleases.get(key) === 'pending' + const upstream = await this.openUpstreamSocket(directory, ptyID, cursor) + if (releasePending) this.releaseOnOpen(key, upstream) + return bridgeWebSocket(upstream, peer) + } + + private releaseOnOpen(key: string, upstream: WebSocket): void { + const release = (): void => this.sendRelease(key, upstream) + if (upstream.readyState === WebSocket.OPEN) { + release() + return + } + upstream.addEventListener('open', release) + } + + private sendRelease(key: string, upstream: WebSocket): void { + if (this.deferredReleases.get(key) !== 'pending') return + try { + upstream.send(DEFERRED_START_RELEASE) + } catch { + return + } + this.deferredReleases.set(key, 'released') + } + + private async openUpstreamSocket( + directory: string, + ptyID: string, + cursor: number | undefined, + ): Promise { const result = await this.target.client.api.pty.connect.token({ ptyID, location: { directory }, @@ -117,7 +206,7 @@ export class TerminalService { socketUrl.searchParams.set('location[directory]', directory) if (cursor !== undefined) socketUrl.searchParams.set('cursor', String(cursor)) - return bridgeWebSocket(this.openSocket(toWebSocketUrl(socketUrl.toString())), peer) + return this.openSocket(toWebSocketUrl(socketUrl.toString())) } private async removePersistentTerminals(directory: string): Promise { diff --git a/backend/src/utils/discovery-cache.ts b/backend/src/utils/discovery-cache.ts index ecf47bfd8..6391cbd9f 100644 --- a/backend/src/utils/discovery-cache.ts +++ b/backend/src/utils/discovery-cache.ts @@ -15,6 +15,7 @@ export function normalizeToBaseUrl(endpoint: string): string { .replace(/\/v1\/audio\/transcriptions$/, '') .replace(/\/audio\/transcriptions$/, '') .replace(/\/$/, '') + .replace(/\/v1$/, '') } async function ensureDiscoveryCacheDir(): Promise { diff --git a/backend/src/utils/process.ts b/backend/src/utils/process.ts index d6f84173a..7c8f8499b 100644 --- a/backend/src/utils/process.ts +++ b/backend/src/utils/process.ts @@ -32,14 +32,10 @@ export async function executeCommand ({ + ENV: { + SERVER: { PORT: 5003, HOST: '0.0.0.0', NODE_ENV: 'test' }, + AUTH: { + SECRET: 'test-secret-for-encryption-key-32c', + TRUSTED_ORIGINS: 'http://localhost:5173,http://localhost:5003', + SECURE_COOKIES: false, + ADMIN_EMAIL: undefined as string | undefined, + ADMIN_PASSWORD: undefined as string | undefined, + ADMIN_PASSWORD_RESET: false, + GITHUB_CLIENT_ID: undefined as string | undefined, + GITHUB_CLIENT_SECRET: undefined as string | undefined, + GOOGLE_CLIENT_ID: undefined as string | undefined, + GOOGLE_CLIENT_SECRET: undefined as string | undefined, + DISCORD_CLIENT_ID: undefined as string | undefined, + DISCORD_CLIENT_SECRET: undefined as string | undefined, + PASSKEY_RP_ID: 'localhost', + PASSKEY_RP_NAME: 'OpenCode Manager', + PASSKEY_ORIGIN: 'http://localhost:5003', + }, + LOGGING: { DEBUG: false, LOG_LEVEL: 'info' }, + }, +})) + +vi.mock('@opencode-manager/shared/config/env', () => ({ + ENV, + getTrustedOrigins: () => ENV.AUTH.TRUSTED_ORIGINS + .split(',') + .map((origin: string) => origin.trim()) + .filter((origin: string) => origin.length > 0), +})) + +vi.mock('../../src/utils/logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) + +function socketEnv(address: string | undefined) { + return { + incoming: { + socket: { + remoteAddress: address, + remotePort: 54321, + remoteFamily: address?.includes(':') ? 'IPv6' : 'IPv4', + }, + }, + } +} + +function createAuthDatabase(): Database { + const db = new DatabaseSync(':memory:') + const compatible = Object.assign(db, { + run: (sql: string, ...params: (string | number | null)[]) => db.prepare(sql).run(...params), + }) as unknown as Database + migrate(compatible, allMigrations) + return compatible +} + +function signUpRequest(headers: Record): Request { + return new Request('http://localhost:5003/api/auth/sign-up/email', { + method: 'POST', + headers: { 'content-type': 'application/json', origin: 'http://localhost:5003', ...headers }, + body: JSON.stringify({ email: 'spoof@example.com', password: 'test-password', name: 'Spoof' }), + }) +} + +function readSessionIpAddress(db: Database): string { + const row = db.prepare('SELECT ipAddress FROM "session" LIMIT 1').get() as { ipAddress: string } + return row.ipAddress +} + +function createCapturingHandler() { + const received: Request[] = [] + const handler = vi.fn(async (request: Request) => { + received.push(request) + return new Response('ok') + }) + return { received, handler } +} + +describe('auth client ip headers', () => { + it('overrides a client-supplied socket header with the socket address', () => { + const headers = new Headers({ [AUTH_SOCKET_IP_HEADER]: '6.6.6.6' }) + + const result = applyClientIpHeaders(headers, '203.0.113.7') + + expect(result.get(AUTH_SOCKET_IP_HEADER)).toBe('203.0.113.7') + }) + + it('clears the socket header when the address cannot be resolved', () => { + const headers = new Headers({ [AUTH_SOCKET_IP_HEADER]: '6.6.6.6' }) + + const result = applyClientIpHeaders(headers, undefined) + + expect(result.get(AUTH_SOCKET_IP_HEADER)).toBeNull() + }) +}) + +describe('createAuthRoutes client ip forwarding', () => { + it('sets the socket address on the auth request and ignores a spoofed socket header', async () => { + const { received, handler } = createCapturingHandler() + const app: Hono = createAuthRoutes({ handler } as unknown as ReturnType) + + await app.fetch( + new Request('http://localhost:5003/api/auth/get-session', { + headers: { [AUTH_SOCKET_IP_HEADER]: '6.6.6.6', 'x-forwarded-for': '198.51.100.9' }, + }), + socketEnv('203.0.113.7') + ) + + expect(received[0]?.headers.get(AUTH_SOCKET_IP_HEADER)).toBe('203.0.113.7') + }) + + it('omits the socket header when the connection info is unavailable', async () => { + const { received, handler } = createCapturingHandler() + const app: Hono = createAuthRoutes({ handler } as unknown as ReturnType) + + await app.fetch(new Request('http://localhost:5003/api/auth/get-session', { + headers: { [AUTH_SOCKET_IP_HEADER]: '6.6.6.6' }, + })) + + expect(received[0]?.headers.get(AUTH_SOCKET_IP_HEADER)).toBeNull() + }) +}) + +describe('createAuth client ip resolution', () => { + it('records the socket address on a new session and ignores a spoofed forwarded header', async () => { + const db = createAuthDatabase() + const app: Hono = createAuthRoutes(createAuth(db)) + + try { + const response = await app.fetch( + signUpRequest({ 'x-forwarded-for': '198.51.100.9', [AUTH_SOCKET_IP_HEADER]: '6.6.6.6' }), + socketEnv('203.0.113.7') + ) + + expect(response.status).toBe(200) + expect(readSessionIpAddress(db)).toBe('203.0.113.7') + } finally { + db.close() + } + }, 30000) +}) diff --git a/backend/test/helpers/opencode-binary.test.ts b/backend/test/helpers/opencode-binary.test.ts index 715cd31ad..0479f49db 100644 --- a/backend/test/helpers/opencode-binary.test.ts +++ b/backend/test/helpers/opencode-binary.test.ts @@ -16,7 +16,7 @@ vi.mock('child_process', () => ({ spawn: childProcessMock.spawn, })) -const SUPPORTED_VERSION = '2.0.15' +const SUPPORTED_VERSION = '2.1.2' const OPENCODE_BIN_FIXTURE = '/fixture/opencode-2' const PATH_CANDIDATE = 'opencode' const ABSOLUTE_CANDIDATES = ['/usr/local/bin/opencode', '/opt/opencode/bin/opencode'] diff --git a/backend/test/routes/change-walkthroughs.test.ts b/backend/test/routes/change-walkthroughs.test.ts index 10adb63bc..8cf73cefd 100644 --- a/backend/test/routes/change-walkthroughs.test.ts +++ b/backend/test/routes/change-walkthroughs.test.ts @@ -11,6 +11,7 @@ function state(overrides: Partial = {}): ChangeWalkthrou return { walkthrough: null, currentDiffHash: 'hash', + hasChanges: true, stale: false, generating: false, running: false, @@ -24,6 +25,7 @@ describe('change walkthrough routes', () => { let startGeneration: ReturnType let cancelGeneration: ReturnType let refine: ReturnType + let listHelperSessionIds: ReturnType let app: Hono beforeEach(() => { @@ -31,13 +33,22 @@ describe('change walkthrough routes', () => { startGeneration = vi.fn(async () => state({ generating: true })) cancelGeneration = vi.fn(async () => true) refine = vi.fn(async () => state({ generating: true })) + listHelperSessionIds = vi.fn(() => ['ses_helper']) app = new Hono() app.route( '/change-walkthroughs', - createChangeWalkthroughRoutes({ getState, startGeneration, cancelGeneration, refine } as unknown as ChangeWalkthroughService), + createChangeWalkthroughRoutes({ getState, startGeneration, cancelGeneration, refine, listHelperSessionIds } as unknown as ChangeWalkthroughService), ) }) + it('GET helper-sessions lists walkthrough helper sessions', async () => { + const res = await app.request('/change-walkthroughs/helper-sessions') + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ sessionIds: ['ses_helper'] }) + expect(getState).not.toHaveBeenCalled() + }) + it('GET returns the state for the default source', async () => { const res = await app.request(`/change-walkthroughs/${SESSION_ID}`) diff --git a/backend/test/routes/repos.test.ts b/backend/test/routes/repos.test.ts index f2a56ce4e..5d853999a 100644 --- a/backend/test/routes/repos.test.ts +++ b/backend/test/routes/repos.test.ts @@ -494,6 +494,24 @@ describe('Repo Routes', () => { const body = await res.json() as { error: string } expect(body.error).toBe('init failed') }) + + it('should return the client status for user-input repo failures', async () => { + vi.mocked(repoService.cloneRepo).mockRejectedValue(Object.assign( + new Error('Repository not found or access denied: https://github.com/test/missing.'), + { statusCode: 400 }, + )) + + const app = createTestRoutes() + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ repoUrl: 'https://github.com/test/missing' }), + }) + + expect(res.status).toBe(400) + const body = await res.json() as { error: string } + expect(body.error).toBe('Repository not found or access denied: https://github.com/test/missing.') + }) }) describe('POST /:id/workspaces', () => { @@ -1108,6 +1126,25 @@ describe('Repo Routes', () => { const body = await res.json() as { error: string } expect(body.error).toBe('create failed') }) + + it('should return 400 when the branch name is invalid', async () => { + vi.mocked(db.getRepoById).mockReturnValue(createMockRepo({ id: 1 })) + vi.mocked(repoService.createBranch).mockRejectedValue(Object.assign( + new Error("Invalid branch name: 'bad name..x'."), + { statusCode: 400 }, + )) + + const app = createTestRoutes() + const res = await app.request('/1/branch/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branch: 'bad name..x' }), + }) + + expect(res.status).toBe(400) + const body = await res.json() as { error: string } + expect(body.error).toBe("Invalid branch name: 'bad name..x'.") + }) }) describe('GET /:id/download', () => { diff --git a/backend/test/routes/session-sandbox.test.ts b/backend/test/routes/session-sandbox.test.ts index 4e1d5f8fc..57c6e5e91 100644 --- a/backend/test/routes/session-sandbox.test.ts +++ b/backend/test/routes/session-sandbox.test.ts @@ -22,13 +22,20 @@ function createTestDb(): Database { function createTestApp( db: Database, - options: { parents?: Record; sandboxEnabled?: boolean } = {}, + options: { parents?: Record; sandboxEnabled?: boolean; sessionGetError?: Error } = {}, ): Hono { const settingsService = new SettingsService(db) if (options.sandboxEnabled) { settingsService.updateSettings({ sandbox: { enabled: true } }) } - const client = createFakeSessionPermissionClient({ parents: options.parents ?? {} }) + const client = createFakeSessionPermissionClient({ + parents: options.parents ?? {}, + getSession: options.sessionGetError + ? async () => { + throw options.sessionGetError + } + : undefined, + }) const service = new SessionSandboxService(db, new SessionAncestry(client), settingsService, { removeSessionSandbox: async () => undefined, }) @@ -96,6 +103,34 @@ describe('session sandbox routes', () => { }) }) + it('GET returns 404 when the session does not exist on OpenCode', async () => { + const notFound = Object.assign(new Error('Session not found: ses_missing'), { _tag: 'SessionNotFoundError' }) + const app = createTestApp(db, { sessionGetError: notFound }) + + const res = await app.request('/session-sandbox/ses_missing') + + expect(res.status).toBe(404) + await expect(res.json()).resolves.toMatchObject({ error: 'Session not found: ses_missing' }) + }) + + it('GET returns 400 when the session id is malformed', async () => { + const invalid = Object.assign(new Error('Expected a string starting with "ses"'), { _tag: 'InvalidRequestError' }) + const app = createTestApp(db, { sessionGetError: invalid }) + + const res = await app.request('/session-sandbox/nope') + + expect(res.status).toBe(400) + await expect(res.json()).resolves.toMatchObject({ error: 'Expected a string starting with "ses"' }) + }) + + it('GET keeps returning 500 for unexpected upstream failures', async () => { + const app = createTestApp(db, { sessionGetError: new Error('upstream unavailable') }) + + const res = await app.request('/session-sandbox/ses_root') + + expect(res.status).toBe(500) + }) + it('PUT stores a value and GET reads it back', async () => { const app = createTestApp(db, { sandboxEnabled: true }) diff --git a/backend/test/routes/settings.test.ts b/backend/test/routes/settings.test.ts index bc9d2f3ee..66f87fc6a 100644 --- a/backend/test/routes/settings.test.ts +++ b/backend/test/routes/settings.test.ts @@ -377,7 +377,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { mockInstallOpenCodeVersion.mockResolvedValue('/home/node/.opencode/bin/opencode') clearOpenCodeVersionCache() vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ - versions: { '2.0.15': {} }, + versions: { '2.1.2': {} }, time: {}, }), { status: 200 }))) forceProcessAttestation(true) @@ -756,8 +756,8 @@ describe('Settings Routes - OpenCode Upgrade', () => { describe('POST /opencode-upgrade', () => { describe('successful upgrade scenarios', () => { it('should install the latest version and respond with success', async () => { - mockGetVersion.mockReturnValueOnce('2.0.14') - mockFetchVersion.mockResolvedValueOnce('2.0.15') + mockGetVersion.mockReturnValueOnce('2.1.1') + mockFetchVersion.mockResolvedValueOnce('2.1.2') mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') const req = new Request('http://localhost/opencode-upgrade', { @@ -769,14 +769,14 @@ describe('Settings Routes - OpenCode Upgrade', () => { expect(res.status).toBe(200) expect(json.success).toBe(true) expect(json.upgraded).toBe(true) - expect(json.oldVersion).toBe('2.0.14') - expect(json.newVersion).toBe('2.0.15') - expect(json.message).toBe('OpenCode upgraded from v2.0.14 to v2.0.15 and restarted') - expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.0.15') + expect(json.oldVersion).toBe('2.1.1') + expect(json.newVersion).toBe('2.1.2') + expect(json.message).toBe('OpenCode upgraded from v2.1.1 to v2.1.2 and restarted') + expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.1.2') }) it('should not install when the latest version is not newer', async () => { - mockGetVersion.mockReturnValueOnce('2.0.15') + mockGetVersion.mockReturnValueOnce('2.1.2') const req = new Request('http://localhost/opencode-upgrade', { method: 'POST' @@ -794,7 +794,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { it('should replace an unsupported newer major with the latest supported release', async () => { mockGetVersion.mockReturnValueOnce('3.0.0') - mockFetchVersion.mockResolvedValueOnce('2.0.15') + mockFetchVersion.mockResolvedValueOnce('2.1.2') mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') const res = await settingsApp.fetch(new Request('http://localhost/opencode-upgrade', { method: 'POST' })) @@ -802,12 +802,12 @@ describe('Settings Routes - OpenCode Upgrade', () => { expect(res.status).toBe(200) expect(json.upgraded).toBe(true) - expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.0.15') + expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.1.2') }) it('should restart directly after a successful upgrade', async () => { - mockGetVersion.mockReturnValueOnce('2.0.14') - mockFetchVersion.mockResolvedValueOnce('2.0.15') + mockGetVersion.mockReturnValueOnce('2.1.1') + mockFetchVersion.mockResolvedValueOnce('2.1.2') mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') const req = new Request('http://localhost/opencode-upgrade', { @@ -820,22 +820,22 @@ describe('Settings Routes - OpenCode Upgrade', () => { it('allows upgrading while sandbox enforcement is active', async () => { mockIsSandboxEnforced.mockReturnValue(true) - mockGetVersion.mockReturnValueOnce('2.0.14') - mockFetchVersion.mockResolvedValueOnce('2.0.15') + mockGetVersion.mockReturnValueOnce('2.1.1') + mockFetchVersion.mockResolvedValueOnce('2.1.2') mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') const res = await settingsApp.fetch(new Request('http://localhost/opencode-upgrade', { method: 'POST' })) expect(res.status).toBe(200) - expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.0.15') + expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.1.2') expect(mockRestart).toHaveBeenCalled() }) }) describe('failure and recovery scenarios', () => { it('should attempt server recovery when the install fails after the binary swap started', async () => { - mockGetVersion.mockReturnValueOnce('2.0.14') - .mockReturnValueOnce('2.0.14') + mockGetVersion.mockReturnValueOnce('2.1.1') + .mockReturnValueOnce('2.1.1') mockInstallOpenCodeVersion.mockRejectedValueOnce(new OpenCodeInstallError('download failed', true)) const req = new Request('http://localhost/opencode-upgrade', { @@ -850,14 +850,14 @@ describe('Settings Routes - OpenCode Upgrade', () => { expect(json).toMatchObject({ upgraded: false, recovered: true, - oldVersion: '2.0.14', - newVersion: '2.0.14' + oldVersion: '2.1.1', + newVersion: '2.1.1' }) expect(json.error).toContain('recovered') }) it('should not restart the server when the install fails before the binary swap', async () => { - mockGetVersion.mockReturnValueOnce('2.0.14') + mockGetVersion.mockReturnValueOnce('2.1.1') mockInstallOpenCodeVersion.mockRejectedValueOnce(new OpenCodeInstallError('download failed', false)) const req = new Request('http://localhost/opencode-upgrade', { @@ -871,14 +871,14 @@ describe('Settings Routes - OpenCode Upgrade', () => { expect(json).toMatchObject({ upgraded: false, recovered: false, - oldVersion: '2.0.14', - newVersion: '2.0.14' + oldVersion: '2.1.1', + newVersion: '2.1.1' }) }) it('should return 500 when recovery fails', async () => { - mockGetVersion.mockReturnValueOnce('2.0.14') - .mockReturnValueOnce('2.0.14') + mockGetVersion.mockReturnValueOnce('2.1.1') + .mockReturnValueOnce('2.1.1') mockInstallOpenCodeVersion.mockRejectedValueOnce(new OpenCodeInstallError('download failed', true)) mockRestart.mockRejectedValueOnce(new Error('Restart failed')) @@ -896,7 +896,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { describe('version handling', () => { it('should install and restart when getVersion returns null', async () => { mockGetVersion.mockReturnValueOnce(null) - mockFetchVersion.mockResolvedValueOnce('2.0.15') + mockFetchVersion.mockResolvedValueOnce('2.1.2') mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') const req = new Request('http://localhost/opencode-upgrade', { @@ -909,8 +909,8 @@ describe('Settings Routes - OpenCode Upgrade', () => { expect(json.success).toBe(true) expect(json.upgraded).toBe(true) expect(json.oldVersion).toBe(null) - expect(json.newVersion).toBe('2.0.15') - expect(json.message).toBe('OpenCode v2.0.15 installed and restarted') + expect(json.newVersion).toBe('2.1.2') + expect(json.message).toBe('OpenCode v2.1.2 installed and restarted') expect(mockFetchVersion).toHaveBeenCalled() expect(mockClearStartupError).toHaveBeenCalled() expect(mockRestart).toHaveBeenCalledTimes(1) @@ -932,13 +932,13 @@ describe('Settings Routes - OpenCode Upgrade', () => { expect(json.upgraded).toBe(false) expect(json.recovered).toBe(true) expect(json.error).toBe('Upgrade failed but server recovered') - expect(json.details).toContain('did not result in version 2.0.15') + expect(json.details).toContain('did not result in version 2.1.2') expect(mockRestart).toHaveBeenCalledTimes(1) }) it('should not report success when an override still resolves to the older version', async () => { - mockGetVersion.mockReturnValueOnce('2.0.14') - mockFetchVersion.mockResolvedValueOnce('2.0.14') + mockGetVersion.mockReturnValueOnce('2.1.1') + mockFetchVersion.mockResolvedValueOnce('2.1.1') mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') const res = await settingsApp.fetch(new Request('http://localhost/opencode-upgrade', { method: 'POST' })) @@ -949,7 +949,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { expect(json.upgraded).toBe(false) expect(json.recovered).toBe(true) expect(json.error).toBe('Upgrade failed but server recovered') - expect(json.details).toContain('did not result in version 2.0.15') + expect(json.details).toContain('did not result in version 2.1.2') expect(mockRestart).toHaveBeenCalledTimes(1) }) }) @@ -958,13 +958,13 @@ describe('Settings Routes - OpenCode Upgrade', () => { describe('POST /opencode-install-version', () => { describe('successful installation', () => { it('should install specific version successfully', async () => { - mockGetVersion.mockReturnValueOnce('2.0.14') - mockFetchVersion.mockResolvedValueOnce('2.0.15') + mockGetVersion.mockReturnValueOnce('2.1.1') + mockFetchVersion.mockResolvedValueOnce('2.1.2') mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') const req = new Request('http://localhost/opencode-install-version', { method: 'POST', - body: JSON.stringify({ version: '2.0.15' }), + body: JSON.stringify({ version: '2.1.2' }), headers: { 'Content-Type': 'application/json' } }) const res = await settingsApp.fetch(req) @@ -972,70 +972,70 @@ describe('Settings Routes - OpenCode Upgrade', () => { expect(res.status).toBe(200) expect(json.success).toBe(true) - expect(json.newVersion).toBe('2.0.15') - expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.0.15') + expect(json.newVersion).toBe('2.1.2') + expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.1.2') }) it('does not report success when the requested version was not installed', async () => { - mockGetVersion.mockReturnValue('2.0.14') - mockFetchVersion.mockResolvedValueOnce('2.0.14') + mockGetVersion.mockReturnValue('2.1.1') + mockFetchVersion.mockResolvedValueOnce('2.1.1') mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') const res = await settingsApp.fetch(new Request('http://localhost/opencode-install-version', { method: 'POST', - body: JSON.stringify({ version: '2.0.15' }), + body: JSON.stringify({ version: '2.1.2' }), headers: { 'Content-Type': 'application/json' } })) const json = await res.json() as Record expect(res.status).toBe(400) expect(json.success).toBe(false) - expect(json.details).toContain('did not result in version 2.0.15') - expect(json.newVersion).toBe('2.0.14') + expect(json.details).toContain('did not result in version 2.1.2') + expect(json.newVersion).toBe('2.1.1') }) it('allows installing any version while sandbox enforcement is active', async () => { mockIsSandboxEnforced.mockReturnValue(true) - mockGetVersion.mockReturnValueOnce('2.0.14') - mockFetchVersion.mockResolvedValueOnce('2.0.15') + mockGetVersion.mockReturnValueOnce('2.1.1') + mockFetchVersion.mockResolvedValueOnce('2.1.2') mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') const res = await settingsApp.fetch(new Request('http://localhost/opencode-install-version', { method: 'POST', - body: JSON.stringify({ version: '2.0.15' }), + body: JSON.stringify({ version: '2.1.2' }), headers: { 'Content-Type': 'application/json' } })) expect(res.status).toBe(200) - expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.0.15') + expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.1.2') }) it('should install the bare version when the request carries a v prefix', async () => { - mockGetVersion.mockReturnValueOnce('2.0.14') - mockFetchVersion.mockResolvedValueOnce('2.0.15') + mockGetVersion.mockReturnValueOnce('2.1.1') + mockFetchVersion.mockResolvedValueOnce('2.1.2') mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') const req = new Request('http://localhost/opencode-install-version', { method: 'POST', - body: JSON.stringify({ version: 'v2.0.15' }), + body: JSON.stringify({ version: 'v2.1.2' }), headers: { 'Content-Type': 'application/json' } }) await settingsApp.fetch(req) - expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.0.15') + expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.1.2') }) }) describe('failure and recovery', () => { it('should recover the server when the version install fails after the binary swap started', async () => { - mockGetVersion.mockReturnValueOnce('2.0.14') - .mockReturnValueOnce('2.0.14') - mockFetchVersion.mockResolvedValueOnce('2.0.14') + mockGetVersion.mockReturnValueOnce('2.1.1') + .mockReturnValueOnce('2.1.1') + mockFetchVersion.mockResolvedValueOnce('2.1.1') mockInstallOpenCodeVersion.mockRejectedValueOnce(new OpenCodeInstallError('download failed', true)) const req = new Request('http://localhost/opencode-install-version', { method: 'POST', - body: JSON.stringify({ version: '2.0.15' }), + body: JSON.stringify({ version: '2.1.2' }), headers: { 'Content-Type': 'application/json' } }) const res = await settingsApp.fetch(req) @@ -1047,12 +1047,12 @@ describe('Settings Routes - OpenCode Upgrade', () => { }) it('should not restart the server when the version install fails before the binary swap', async () => { - mockGetVersion.mockReturnValueOnce('2.0.14') + mockGetVersion.mockReturnValueOnce('2.1.1') mockInstallOpenCodeVersion.mockRejectedValueOnce(new OpenCodeInstallError('download failed', false)) const req = new Request('http://localhost/opencode-install-version', { method: 'POST', - body: JSON.stringify({ version: '2.0.15' }), + body: JSON.stringify({ version: '2.1.2' }), headers: { 'Content-Type': 'application/json' } }) const res = await settingsApp.fetch(req) @@ -1119,11 +1119,11 @@ describe('Settings Routes - OpenCode Upgrade', () => { const json = await res.json() as Record expect(res.status).toBe(400) - expect(json.error).toBe('OpenCode 1.18.32 is not supported; OpenCode Manager requires OpenCode >=2.0.15 <3.0.0') + expect(json.error).toBe('OpenCode 1.18.32 is not supported; OpenCode Manager requires OpenCode >=2.1.2 <3.0.0') expect(mockInstallOpenCodeVersion).not.toHaveBeenCalled() }) - it.each(['2.0.14', '3.0.0'])('should reject %s outside the supported range before installing anything', async (version) => { + it.each(['2.1.1', '3.0.0'])('should reject %s outside the supported range before installing anything', async (version) => { const res = await settingsApp.fetch(new Request('http://localhost/opencode-install-version', { method: 'POST', body: JSON.stringify({ version }), @@ -1132,14 +1132,14 @@ describe('Settings Routes - OpenCode Upgrade', () => { const json = await res.json() as Record expect(res.status).toBe(400) - expect(json.error).toBe(`OpenCode ${version} is not supported; OpenCode Manager requires OpenCode >=2.0.15 <3.0.0`) + expect(json.error).toBe(`OpenCode ${version} is not supported; OpenCode Manager requires OpenCode >=2.1.2 <3.0.0`) expect(mockInstallOpenCodeVersion).not.toHaveBeenCalled() }) it('should reject a prerelease version before installing anything', async () => { const res = await settingsApp.fetch(new Request('http://localhost/opencode-install-version', { method: 'POST', - body: JSON.stringify({ version: '2.1.0-beta.1' }), + body: JSON.stringify({ version: '2.1.2-beta.1' }), headers: { 'Content-Type': 'application/json' } })) @@ -1176,7 +1176,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { const res = await settingsApp.fetch(new Request('http://localhost/opencode-install-version', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ version: '2.0.15' }), + body: JSON.stringify({ version: '2.1.2' }), })) const json = await res.json() as Record @@ -1189,7 +1189,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { describe('error scenarios - server stability', () => { it('should not restart the server when the install fails before the binary swap', async () => { - mockGetVersion.mockReturnValueOnce('2.0.14') + mockGetVersion.mockReturnValueOnce('2.1.1') mockInstallOpenCodeVersion.mockRejectedValueOnce(new OpenCodeInstallError('Unexpected error', false)) mockRestart.mockResolvedValue(undefined) @@ -1205,7 +1205,7 @@ describe('Settings Routes - OpenCode Upgrade', () => { }) it('should not crash when getVersion throws error during failure recovery', async () => { - mockGetVersion.mockImplementationOnce(() => '2.0.14') + mockGetVersion.mockImplementationOnce(() => '2.1.1') .mockImplementationOnce(() => { throw new Error('GetVersion failed') }) @@ -1222,8 +1222,8 @@ describe('Settings Routes - OpenCode Upgrade', () => { }) it('should handle fetchVersion throwing error during normal upgrade', async () => { - mockGetVersion.mockReturnValueOnce('2.0.14') - .mockReturnValueOnce('2.0.14') + mockGetVersion.mockReturnValueOnce('2.1.1') + .mockReturnValueOnce('2.1.1') mockFetchVersion.mockRejectedValueOnce(new Error('Fetch version failed')) mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') @@ -1239,8 +1239,8 @@ describe('Settings Routes - OpenCode Upgrade', () => { }) it('should not leave server in broken state when the install fails', async () => { - mockGetVersion.mockReturnValueOnce('2.0.14') - .mockReturnValueOnce('2.0.14') + mockGetVersion.mockReturnValueOnce('2.1.1') + .mockReturnValueOnce('2.1.1') mockInstallOpenCodeVersion.mockRejectedValueOnce(new OpenCodeInstallError('install failed', true)) mockRestart.mockResolvedValue(undefined) @@ -1554,6 +1554,120 @@ describe('Settings Routes - OpenCode Upgrade', () => { }) }) + describe('PATCH / - git identity validation', () => { + it.each(['not-an-email', 'two words@example.com', '@example.com'])('rejects the email %s with 400', async (email) => { + const res = await settingsApp.fetch(new Request('http://localhost/', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ preferences: { gitIdentity: { name: 'Agent', email } } }), + })) + const json = await res.json() as { error: string } + + expect(res.status).toBe(400) + expect(json.error).toBe('Invalid settings data') + expect(mockUpdateSettings).not.toHaveBeenCalled() + }) + + it('reports the rejected field path for the default identity email', async () => { + const res = await settingsApp.fetch(new Request('http://localhost/', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ preferences: { gitIdentity: { name: 'Agent', email: 'bad' } } }), + })) + const json = await res.json() as { details: Array<{ path: Array; message: string }> } + + expect(res.status).toBe(400) + expect(json.details).toContainEqual(expect.objectContaining({ + path: ['preferences', 'gitIdentity', 'email'], + message: 'Invalid email address', + })) + expect(mockUpdateSettings).not.toHaveBeenCalled() + }) + + it.each(['', 'agent@example.com', 'root@localhost', 'root@box'])('accepts the email %s', async (email) => { + mockGetSettings.mockReturnValue({ + preferences: { gitIdentity: { name: 'Agent', email: '' } }, + updatedAt: 1, + }) + mockUpdateSettings.mockReturnValue({ + preferences: { gitIdentity: { name: 'Agent', email } }, + updatedAt: 2, + }) + + const res = await settingsApp.fetch(new Request('http://localhost/', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ preferences: { gitIdentity: { name: 'Agent', email } } }), + })) + + expect(res.status).toBe(200) + expect(mockUpdateSettings).toHaveBeenCalledWith({ gitIdentity: { name: 'Agent', email } }, 'default') + }) + + it('trims identity emails before saving', async () => { + mockGetSettings.mockReturnValue({ + preferences: { gitIdentity: { name: 'Agent', email: '' }, gitIdentities: [] }, + updatedAt: 1, + }) + mockUpdateSettings.mockReturnValue({ + preferences: { + gitIdentity: { name: 'Root', email: 'root@box' }, + gitIdentities: [{ id: 'work', name: 'Work', email: 'agent@example.com' }], + }, + updatedAt: 2, + }) + + const res = await settingsApp.fetch(new Request('http://localhost/', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + preferences: { + gitIdentity: { name: 'Root', email: ' root@box ' }, + gitIdentities: [{ id: 'work', name: 'Work', email: ' agent@example.com ' }], + }, + }), + })) + + expect(res.status).toBe(200) + expect(mockUpdateSettings).toHaveBeenCalledWith({ + gitIdentity: { name: 'Root', email: 'root@box' }, + gitIdentities: [{ id: 'work', name: 'Work', email: 'agent@example.com' }], + }, 'default') + }) + + it('passes preferences through unchanged when no identity fields are present', async () => { + mockGetSettings.mockReturnValue({ preferences: { theme: 'dark', mode: 'build' }, updatedAt: 1 }) + mockUpdateSettings.mockReturnValue({ preferences: { theme: 'light', mode: 'plan' }, updatedAt: 2 }) + + const res = await settingsApp.fetch(new Request('http://localhost/', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ preferences: { theme: 'light', mode: 'plan' } }), + })) + + expect(res.status).toBe(200) + expect(mockUpdateSettings).toHaveBeenCalledWith({ theme: 'light', mode: 'plan' }, 'default') + }) + + it('rejects an invalid email in a saved identity profile', async () => { + const res = await settingsApp.fetch(new Request('http://localhost/', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + preferences: { gitIdentities: [{ id: 'a', name: 'Alpha', email: 'bad' }] }, + }), + })) + const json = await res.json() as { details: Array<{ path: Array; message: string }> } + + expect(res.status).toBe(400) + expect(json.details).toContainEqual(expect.objectContaining({ + path: ['preferences', 'gitIdentities', 0, 'email'], + message: 'Invalid email address', + })) + expect(mockUpdateSettings).not.toHaveBeenCalled() + }) + }) + describe('PATCH / - sandbox enable guard', () => { afterEach(() => { forceProcessAttestation(null) @@ -1839,8 +1953,8 @@ describe('Settings Routes - versions, directory files, skills, MCP and maintenan fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) clearOpenCodeVersionCache() - mockGetVersion.mockReturnValue('2.0.15') - mockFetchVersion.mockResolvedValue('2.0.15') + mockGetVersion.mockReturnValue('2.1.2') + mockFetchVersion.mockResolvedValue('2.1.2') mockInstallOpenCodeVersion.mockReset() mockInstallOpenCodeVersion.mockResolvedValue('/home/node/.opencode/bin/opencode') mockClearStartupError.mockReturnValue(undefined) @@ -1862,15 +1976,15 @@ describe('Settings Routes - versions, directory files, skills, MCP and maintenan fetchMock.mockResolvedValue(new Response(JSON.stringify({ versions: { '1.18.32': {}, - '2.0.14': {}, - '2.0.15': {}, - '2.0.16': {}, - '2.1.0-beta.1': {}, + '2.1.1': {}, + '2.1.2': {}, + '2.1.3': {}, + '2.1.2-beta.1': {}, '3.0.0': {}, }, time: { - '2.0.15': '2026-01-02T00:00:00Z', - '2.0.16': '2026-01-03T00:00:00Z', + '2.1.2': '2026-01-02T00:00:00Z', + '2.1.3': '2026-01-03T00:00:00Z', }, }), { status: 200 })) @@ -1878,16 +1992,16 @@ describe('Settings Routes - versions, directory files, skills, MCP and maintenan const json = await res.json() as { versions: Array<{ version: string; tag: string; name: string; publishedAt: string | null }>; currentVersion: string } expect(res.status).toBe(200) - expect(json.currentVersion).toBe('2.0.15') + expect(json.currentVersion).toBe('2.1.2') expect(json.versions).toEqual([ - { version: '2.0.16', tag: 'v2.0.16', name: 'v2.0.16', publishedAt: '2026-01-03T00:00:00Z' }, - { version: '2.0.15', tag: 'v2.0.15', name: 'v2.0.15', publishedAt: '2026-01-02T00:00:00Z' }, + { version: '2.1.3', tag: 'v2.1.3', name: 'v2.1.3', publishedAt: '2026-01-03T00:00:00Z' }, + { version: '2.1.2', tag: 'v2.1.2', name: 'v2.1.2', publishedAt: '2026-01-02T00:00:00Z' }, ]) expect(fetchMock).toHaveBeenCalledWith('https://registry.npmjs.org/@opencode/cli') }) it('serves repeated requests from the installer cache', async () => { - fetchMock.mockImplementation(async () => new Response(JSON.stringify({ versions: { '2.0.15': {} }, time: {} }), { status: 200 })) + fetchMock.mockImplementation(async () => new Response(JSON.stringify({ versions: { '2.1.2': {} }, time: {} }), { status: 200 })) await app.request(new Request('http://localhost/opencode-versions')) const res = await app.request(new Request('http://localhost/opencode-versions')) @@ -1924,42 +2038,42 @@ describe('Settings Routes - versions, directory files, skills, MCP and maintenan describe('POST /opencode-install-version', () => { it('installs the requested version and restarts the server', async () => { - mockGetVersion.mockReturnValue('2.0.14') - mockFetchVersion.mockResolvedValue('2.0.15') + mockGetVersion.mockReturnValue('2.1.1') + mockFetchVersion.mockResolvedValue('2.1.2') mockInstallOpenCodeVersion.mockResolvedValueOnce('/home/node/.opencode/bin/opencode') const res = await app.request(new Request('http://localhost/opencode-install-version', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ version: '2.0.15' }), + body: JSON.stringify({ version: '2.1.2' }), })) const json = await res.json() as { success: boolean; message: string; oldVersion: string; newVersion: string } expect(res.status).toBe(200) expect(json.success).toBe(true) - expect(json.oldVersion).toBe('2.0.14') - expect(json.newVersion).toBe('2.0.15') - expect(json.message).toContain('changed from v2.0.14 to v2.0.15') + expect(json.oldVersion).toBe('2.1.1') + expect(json.newVersion).toBe('2.1.2') + expect(json.message).toContain('changed from v2.1.1 to v2.1.2') expect(supervisor.restart).toHaveBeenCalledTimes(1) - expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.0.15') + expect(mockInstallOpenCodeVersion).toHaveBeenCalledWith('2.1.2') }) it('returns 500 without restarting when the version install fails before the binary swap', async () => { - mockGetVersion.mockReturnValue('2.0.14') - mockFetchVersion.mockResolvedValue('2.0.15') + mockGetVersion.mockReturnValue('2.1.1') + mockFetchVersion.mockResolvedValue('2.1.2') mockInstallOpenCodeVersion.mockRejectedValueOnce(new Error('download failed')) supervisor.restart.mockResolvedValue({ healthy: false }) const res = await app.request(new Request('http://localhost/opencode-install-version', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ version: '2.0.15' }), + body: JSON.stringify({ version: '2.1.2' }), })) const json = await res.json() as { recovered: boolean; newVersion: string } expect(res.status).toBe(500) expect(json.recovered).toBe(false) - expect(json.newVersion).toBe('2.0.14') + expect(json.newVersion).toBe('2.1.1') expect(supervisor.restart).not.toHaveBeenCalled() }) }) diff --git a/backend/test/scripts/docker-config.test.ts b/backend/test/scripts/docker-config.test.ts index 20cd381b0..7febd69fa 100644 --- a/backend/test/scripts/docker-config.test.ts +++ b/backend/test/scripts/docker-config.test.ts @@ -78,6 +78,13 @@ describe('entrypoint library wiring', () => { const dockerfile = read(dockerfilePath) expect(dockerfile).not.toMatch(/chmod \+x \/usr\/local\/lib\/ocm\/container-user\.sh/) }) + + it('runs the entrypoint under tini so orphaned processes are reaped', () => { + const dockerfile = read(dockerfilePath) + expect(dockerfile).toMatch(/^\s+tini \\$/m) + expect(dockerfile).toContain('ENTRYPOINT ["/usr/bin/tini", "--", "/docker-entrypoint.sh"]') + expect(read(composePath)).not.toContain('init: true') + }) }) describe('microsandbox runtime install', () => { diff --git a/backend/test/services/change-walkthroughs.test.ts b/backend/test/services/change-walkthroughs.test.ts index 0acb59131..858f50aaf 100644 --- a/backend/test/services/change-walkthroughs.test.ts +++ b/backend/test/services/change-walkthroughs.test.ts @@ -265,10 +265,26 @@ describe('ChangeWalkthroughService', () => { expect(state.walkthrough).toBeNull() expect(state.stale).toBe(false) expect(state.currentDiffHash).toBe(computeChangesHash(THREE_HUNKS)) + expect(state.hasChanges).toBe(true) expect(state.generating).toBe(false) expect(state.error).toBeNull() }) + it('reports no changes for a session with an empty diff', async () => { + sessions[SESSION_ID]!.changes = [] + + const state = await service.getState(SESSION_ID) + + expect(state.currentDiffHash).toBe(computeChangesHash([])) + expect(state.hasChanges).toBe(false) + }) + + it('lists the helper sessions of existing walkthroughs', async () => { + await service.startGeneration(SESSION_ID, {}) + + expect(service.listHelperSessionIds()).toEqual(['ses_walk_1']) + }) + it('creates exactly one walkthrough session and returns generating', async () => { const state = await service.startGeneration(SESSION_ID, {}) @@ -479,6 +495,7 @@ describe('ChangeWalkthroughService', () => { const state = await service.getState(SESSION_ID) expect(state.currentDiffHash).toBeNull() + expect(state.hasChanges).toBe(true) expect(state.stale).toBe(false) }) diff --git a/backend/test/services/files.test.ts b/backend/test/services/files.test.ts index 5797672f4..92d0938bf 100644 --- a/backend/test/services/files.test.ts +++ b/backend/test/services/files.test.ts @@ -93,6 +93,15 @@ describe('files service', () => { expect(await getRawFileMetadata(`${relativeRoot}/meta-dir`)).toBeNull() }) + it('sniffs an unknown extension and reports binary content', async () => { + const bytes = Buffer.from(Array.from({ length: 512 }, (_, index) => (index * 37 + 11) % 256)) + await writeFile(resolveTempPath(`${relativeRoot}/payload.unknownext`), bytes) + + const metadata = await getRawFileMetadata(`${relativeRoot}/payload.unknownext`) + + expect(metadata?.mimeType).toBe('application/octet-stream') + }) + it('rejects with 404 when the file is missing', async () => { await expect(getRawFileMetadata(`${relativeRoot}/missing.htm`)).rejects.toEqual({ message: 'File or directory not found', @@ -134,6 +143,32 @@ describe('files service', () => { expect(Buffer.from(result.content ?? '', 'base64').toString('utf8')).toBe('mystery') }) + it('reports an unknown extension with text content as text', async () => { + await writeLines(`${relativeRoot}/plain.foo`, 'still text') + + const result = await getFile(`${relativeRoot}/plain.foo`) + + expect(result.mimeType).toBe('text/plain') + }) + + it('reports an unknown extension with binary content as binary', async () => { + const bytes = Buffer.from(Array.from({ length: 2930 }, (_, index) => (index * 37 + 11) % 256)) + await writeFile(resolveTempPath(`${relativeRoot}/blob.bin`), bytes) + + const result = await getFile(`${relativeRoot}/blob.bin`) + + expect(result.mimeType).toBe('application/octet-stream') + expect(result.content).toBe(bytes.toString('base64')) + }) + + it('reports an empty unknown extension as text', async () => { + await writeLines(`${relativeRoot}/empty.foo`, '') + + const result = await getFile(`${relativeRoot}/empty.foo`) + + expect(result.mimeType).toBe('text/plain') + }) + it('maps .htm files to text/html', async () => { await writeLines(`${relativeRoot}/page.htm`, '

hi

') @@ -326,6 +361,15 @@ describe('files service', () => { expect(result.hasMore).toBe(false) }) + it('reports binary content for an unknown extension', async () => { + const bytes = Buffer.from(Array.from({ length: 512 }, (_, index) => (index * 37 + 11) % 256)) + await writeFile(resolveTempPath(`${relativeRoot}/blob.unknownext`), bytes) + + const result = await getFileRange(`${relativeRoot}/blob.unknownext`, 0, 5) + + expect(result.mimeType).toBe('application/octet-stream') + }) + it('rejects with 404 when the file is missing', async () => { await expect(getFileRange(`${relativeRoot}/missing.txt`, 0, 5)).rejects.toEqual({ message: 'File does not exist', diff --git a/backend/test/services/git/GitService.real.test.ts b/backend/test/services/git/GitService.real.test.ts index bc63233f1..0166fab31 100644 --- a/backend/test/services/git/GitService.real.test.ts +++ b/backend/test/services/git/GitService.real.test.ts @@ -1,7 +1,7 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' -import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Hono } from 'hono' import { Database } from 'bun:sqlite' import { migrate } from '../../../src/db/migration-runner' @@ -15,6 +15,7 @@ import { createRepoRoutes } from '../../../src/routes/repos' import { createStubOpenCodeClient } from '../../helpers/stub-opencode-client' import { cloneOrigin, createCommittedRepo, createGitAuthService, createOrigin, git, uniqueName } from '../../helpers/git-fixtures' import { GitOperationError, parseGitError } from '../../../src/utils/git-errors' +import { logger } from '../../../src/utils/logger' import type { GitAuthService } from '../../../src/services/git-auth' import type { Repo } from '../../../src/types/repo' import type { TerminalService } from '../../../src/services/terminal' @@ -103,6 +104,121 @@ describe('GitService real git', () => { }) }) + describe('getLog unpushed commits', () => { + it('flags only the commits that are not on a remote', async () => { + const origin = path.join(workspaceRoot, uniqueName('unpushed-origin.git')) + const work = path.join(workspaceRoot, uniqueName('unpushed-work')) + createOrigin(origin, work) + const repoPath = path.join(reposPath, uniqueName('unpushed-clone')) + cloneOrigin(origin, repoPath) + const repo = registerClone(origin, repoPath) + + writeFileSync(path.join(repoPath, 'pushed.txt'), 'pushed\n') + git(['add', 'pushed.txt'], repoPath) + git(['commit', '-m', 'pushed commit'], repoPath) + git(['push', 'origin', 'main'], repoPath) + writeFileSync(path.join(repoPath, 'local.txt'), 'local\n') + git(['add', 'local.txt'], repoPath) + git(['commit', '-m', 'local commit'], repoPath) + + const commits = await service.getLog(repo.id, db, 10) + + expect(commits.map((commit) => [commit.message, commit.unpushed])).toEqual([ + ['local commit', true], + ['pushed commit', false], + ['init', false], + ]) + }) + + it('flags the listed branch rather than HEAD', async () => { + const origin = path.join(workspaceRoot, uniqueName('unpushed-branch-origin.git')) + const work = path.join(workspaceRoot, uniqueName('unpushed-branch-work')) + createOrigin(origin, work) + const repoPath = path.join(reposPath, uniqueName('unpushed-branch-clone')) + cloneOrigin(origin, repoPath) + const repo = registerClone(origin, repoPath) + + git(['checkout', '-b', 'feature'], repoPath) + writeFileSync(path.join(repoPath, 'feature.txt'), 'feature\n') + git(['add', 'feature.txt'], repoPath) + git(['commit', '-m', 'feature commit'], repoPath) + git(['checkout', 'main'], repoPath) + + const commits = await service.getLog(repo.id, db, 10, 'feature') + + expect(commits.map((commit) => [commit.message, commit.unpushed])).toEqual([ + ['feature commit', true], + ['init', false], + ]) + }) + + it('flags every commit as unpushed when the repository has no remote', async () => { + const repoPath = path.join(reposPath, uniqueName('unpushed-local')) + createCommittedRepo(repoPath) + git(['commit', '--allow-empty', '-m', 'local only'], repoPath) + const repo = createRepo(db, { + isLocal: true, + localPath: path.basename(repoPath), + sourcePath: repoPath, + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + }) + + const commits = await service.getLog(repo.id, db, 10) + + expect(commits.map((commit) => commit.message)).toEqual(['local only', 'init']) + expect(commits.every((commit) => commit.unpushed)).toBe(true) + }) + }) + + describe('getBranchStatus', () => { + it('returns zeros without logging when the branch has no upstream', async () => { + const repoPath = path.join(reposPath, uniqueName('status-no-upstream')) + createCommittedRepo(repoPath) + const repo = createRepo(db, { + isLocal: true, + localPath: path.basename(repoPath), + sourcePath: repoPath, + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + }) + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + + try { + await expect(service.getBranchStatus(repo.id, db)).resolves.toEqual({ ahead: 0, behind: 0 }) + expect(warnSpy).not.toHaveBeenCalled() + } finally { + warnSpy.mockRestore() + } + }) + + it('still logs a real failure', async () => { + const notARepoPath = path.join(reposPath, uniqueName('status-not-a-repo')) + mkdirSync(notARepoPath, { recursive: true }) + const repo = createRepo(db, { + isLocal: true, + localPath: path.basename(notARepoPath), + sourcePath: notARepoPath, + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + }) + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + + try { + await expect(service.getBranchStatus(repo.id, db)).resolves.toEqual({ ahead: 0, behind: 0 }) + expect(warnSpy).toHaveBeenCalledTimes(1) + } finally { + warnSpy.mockRestore() + } + }) + }) + describe('renameBranch', () => { it('renames the checked-out branch and updates the repo row', async () => { const origin = path.join(workspaceRoot, uniqueName('rename-origin.git')) diff --git a/backend/test/services/opencode-installer.test.ts b/backend/test/services/opencode-installer.test.ts index ed4ecb207..e589eae07 100644 --- a/backend/test/services/opencode-installer.test.ts +++ b/backend/test/services/opencode-installer.test.ts @@ -131,46 +131,46 @@ describe('resolveOpenCodeInstallTarget', () => { describe('installOpenCodeVersion', () => { it('downloads, verifies and installs the requested version', async () => { const homeDirectory = join(workDirectory, 'home') - const archivePath = createArchive(workDirectory, '2.0.15') + const archivePath = createArchive(workDirectory, '2.1.2') const { calls, fetchFn } = createFetch(archivePath) - const installedPath = await installOpenCodeVersion('2.0.15', { fetch: fetchFn, homeDirectory, target: linuxTarget }) + const installedPath = await installOpenCodeVersion('2.1.2', { fetch: fetchFn, homeDirectory, target: linuxTarget }) expect(installedPath).toBe(resolveOpenCodeBinaryPath(homeDirectory)) - expect(calls).toEqual(['https://opencode.ai/files/bin/2.0.15/opencode-linux-x64.tar.gz']) + expect(calls).toEqual(['https://opencode.ai/files/bin/2.1.2/opencode-linux-x64.tar.gz']) expect(statSync(installedPath).mode & 0o777).toBe(0o755) - expect(readFileSync(installedPath, 'utf-8')).toContain('2.0.15') + expect(readFileSync(installedPath, 'utf-8')).toContain('2.1.2') }) it('requests the musl asset for a musl linux target', async () => { const homeDirectory = join(workDirectory, 'home') - const archivePath = createArchive(workDirectory, '2.0.15') + const archivePath = createArchive(workDirectory, '2.1.2') const { calls, fetchFn } = createFetch(archivePath) - await installOpenCodeVersion('2.0.15', { + await installOpenCodeVersion('2.1.2', { fetch: fetchFn, homeDirectory, target: { platform: 'linux', arch: 'arm64', musl: true }, }) - expect(calls).toEqual(['https://opencode.ai/files/bin/2.0.15/opencode-linux-arm64-musl.tar.gz']) + expect(calls).toEqual(['https://opencode.ai/files/bin/2.1.2/opencode-linux-arm64-musl.tar.gz']) }) it('extracts the darwin zip asset and installs it', async () => { const homeDirectory = join(workDirectory, 'home') - const archivePath = createDarwinArchive(workDirectory, '2.0.15') + const archivePath = createDarwinArchive(workDirectory, '2.1.2') const { calls, fetchFn } = createFetch(archivePath) - const installedPath = await installOpenCodeVersion('2.0.15', { + const installedPath = await installOpenCodeVersion('2.1.2', { fetch: fetchFn, homeDirectory, target: { platform: 'darwin', arch: 'arm64', musl: false }, }) - expect(calls).toEqual(['https://opencode.ai/files/bin/2.0.15/opencode-darwin-arm64.zip']) + expect(calls).toEqual(['https://opencode.ai/files/bin/2.1.2/opencode-darwin-arm64.zip']) expect(installedPath).toBe(resolveOpenCodeBinaryPath(homeDirectory)) expect(statSync(installedPath).mode & 0o777).toBe(0o755) - expect(readFileSync(installedPath, 'utf-8')).toContain('2.0.15') + expect(readFileSync(installedPath, 'utf-8')).toContain('2.1.2') }) it('rejects an OpenCode 1.x version before downloading anything', async () => { @@ -178,18 +178,18 @@ describe('installOpenCodeVersion', () => { const { calls, fetchFn } = createFetch(null) await expect(installOpenCodeVersion('1.18.32', { fetch: fetchFn, homeDirectory, target: linuxTarget })) - .rejects.toThrow('OpenCode 1.18.32 is not supported; OpenCode Manager requires OpenCode >=2.0.15 <3.0.0') + .rejects.toThrow('OpenCode 1.18.32 is not supported; OpenCode Manager requires OpenCode >=2.1.2 <3.0.0') expect(calls).toEqual([]) }) - it.each(['2.0.14', '3.0.0', '2.1.0-beta.1', '2.0.15; rm -rf /'])( + it.each(['2.1.1', '3.0.0', '2.1.2-beta.1', '2.1.2; rm -rf /'])( 'rejects %s outside the supported stable range before downloading anything', async (version) => { const homeDirectory = join(workDirectory, 'home') const { calls, fetchFn } = createFetch(null) await expect(installOpenCodeVersion(version, { fetch: fetchFn, homeDirectory, target: linuxTarget })) - .rejects.toThrow(/requires OpenCode >=2\.0\.15 <3\.0\.0/) + .rejects.toThrow(/requires OpenCode >=2\.1\.2 <3\.0\.0/) expect(calls).toEqual([]) }, ) @@ -198,41 +198,41 @@ describe('installOpenCodeVersion', () => { const homeDirectory = join(workDirectory, 'home') const { fetchFn } = createFetch(null, 404) - await expect(installOpenCodeVersion('2.0.15', { fetch: fetchFn, homeDirectory, target: linuxTarget })) + await expect(installOpenCodeVersion('2.1.2', { fetch: fetchFn, homeDirectory, target: linuxTarget })) .rejects.toThrow(/404/) expect(() => statSync(resolveOpenCodeBinaryPath(homeDirectory))).toThrow() }) it('fails and leaves no binary behind when the archive reports a different version', async () => { const homeDirectory = join(workDirectory, 'home') - const archivePath = createArchive(workDirectory, '2.0.16') + const archivePath = createArchive(workDirectory, '2.1.3') const { fetchFn } = createFetch(archivePath) - await expect(installOpenCodeVersion('2.0.15', { fetch: fetchFn, homeDirectory, target: linuxTarget })) - .rejects.toThrow(/2\.0\.15/) + await expect(installOpenCodeVersion('2.1.2', { fetch: fetchFn, homeDirectory, target: linuxTarget })) + .rejects.toThrow(/2\.1\.2/) expect(() => statSync(resolveOpenCodeBinaryPath(homeDirectory))).toThrow() }) it('replaces an existing installed binary with the requested version', async () => { const homeDirectory = join(workDirectory, 'home') mkdirSync(join(homeDirectory, '.opencode', 'bin'), { recursive: true }) - writeFileSync(resolveOpenCodeBinaryPath(homeDirectory), '#!/bin/sh\necho "opencode v2.0.14"\n') + writeFileSync(resolveOpenCodeBinaryPath(homeDirectory), '#!/bin/sh\necho "opencode v2.1.1"\n') - const archivePath = createArchive(workDirectory, '2.0.15') + const archivePath = createArchive(workDirectory, '2.1.2') const { fetchFn } = createFetch(archivePath) - await installOpenCodeVersion('2.0.15', { fetch: fetchFn, homeDirectory, target: linuxTarget }) + await installOpenCodeVersion('2.1.2', { fetch: fetchFn, homeDirectory, target: linuxTarget }) - expect(readFileSync(resolveOpenCodeBinaryPath(homeDirectory), 'utf-8')).toContain('2.0.15') + expect(readFileSync(resolveOpenCodeBinaryPath(homeDirectory), 'utf-8')).toContain('2.1.2') }) it('cleans up its staging directory after a successful install', async () => { const homeDirectory = join(workDirectory, 'home') - const archivePath = createArchive(workDirectory, '2.0.15') + const archivePath = createArchive(workDirectory, '2.1.2') const { fetchFn } = createFetch(archivePath) const before = countStagingDirectories() - await installOpenCodeVersion('2.0.15', { fetch: fetchFn, homeDirectory, target: linuxTarget }) + await installOpenCodeVersion('2.1.2', { fetch: fetchFn, homeDirectory, target: linuxTarget }) expect(countStagingDirectories()).toBe(before) }) @@ -242,17 +242,17 @@ describe('listOpenCodeVersions', () => { const registryPayload = { versions: { '1.18.32': {}, - '2.0.0': {}, '2.0.14': {}, - '2.0.15': {}, - '2.0.16': {}, - '2.1.0-beta.1': {}, + '2.1.1': {}, + '2.1.2': {}, + '2.1.3': {}, + '2.1.2-beta.1': {}, '3.0.0': {}, 'not-a-version': {}, }, time: { - '2.0.15': '2026-02-01T00:00:00.000Z', - '2.0.16': '2026-03-01T00:00:00.000Z', + '2.1.2': '2026-02-01T00:00:00.000Z', + '2.1.3': '2026-03-01T00:00:00.000Z', }, } @@ -262,20 +262,20 @@ describe('listOpenCodeVersions', () => { const versions = await listOpenCodeVersions({ fetch: fetchFn }) expect(versions).toEqual([ - { version: '2.0.16', publishedAt: '2026-03-01T00:00:00.000Z' }, - { version: '2.0.15', publishedAt: '2026-02-01T00:00:00.000Z' }, + { version: '2.1.3', publishedAt: '2026-03-01T00:00:00.000Z' }, + { version: '2.1.2', publishedAt: '2026-02-01T00:00:00.000Z' }, ]) expect(calls).toEqual(['https://registry.npmjs.org/@opencode/cli']) }) it('reports a null publishedAt when the registry has no timestamp for a version', async () => { const { fetchFn } = createRegistryFetch({ - versions: { '2.0.20': {} }, + versions: { '2.1.5': {} }, time: {}, }) await expect(listOpenCodeVersions({ fetch: fetchFn })).resolves.toEqual([ - { version: '2.0.20', publishedAt: null }, + { version: '2.1.5', publishedAt: null }, ]) }) @@ -293,14 +293,14 @@ describe('listOpenCodeVersions', () => { it('refreshes the list once the five minute cache expires', async () => { const stale = createRegistryFetch(registryPayload) - const fresh = createRegistryFetch({ versions: { '2.0.17': {} }, time: {} }) + const fresh = createRegistryFetch({ versions: { '2.1.7': {} }, time: {} }) let now = 1_000_000 await listOpenCodeVersions({ fetch: stale.fetchFn, now: () => now }) now += 5 * 60 * 1000 const refreshed = await listOpenCodeVersions({ fetch: fresh.fetchFn, now: () => now }) - expect(refreshed).toEqual([{ version: '2.0.17', publishedAt: null }]) + expect(refreshed).toEqual([{ version: '2.1.7', publishedAt: null }]) expect(stale.calls).toHaveLength(1) expect(fresh.calls).toHaveLength(1) }) @@ -331,7 +331,7 @@ describe('OpenCodeInstallError swap signalling', () => { const homeDirectory = join(workDirectory, 'home') const { fetchFn } = createFetch(null, 404) - const promise = installOpenCodeVersion('2.0.15', { fetch: fetchFn, homeDirectory, target: linuxTarget }) + const promise = installOpenCodeVersion('2.1.2', { fetch: fetchFn, homeDirectory, target: linuxTarget }) await expect(promise).rejects.toBeInstanceOf(OpenCodeInstallError) await expect(promise).rejects.toMatchObject({ swapStarted: false }) @@ -341,10 +341,10 @@ describe('OpenCodeInstallError swap signalling', () => { it('marks a failure while replacing the binary as having started the binary swap', async () => { const homeDirectory = join(workDirectory, 'home') mkdirSync(resolveOpenCodeBinaryPath(homeDirectory), { recursive: true }) - const archivePath = createArchive(workDirectory, '2.0.15') + const archivePath = createArchive(workDirectory, '2.1.2') const { fetchFn } = createFetch(archivePath) - const promise = installOpenCodeVersion('2.0.15', { fetch: fetchFn, homeDirectory, target: linuxTarget }) + const promise = installOpenCodeVersion('2.1.2', { fetch: fetchFn, homeDirectory, target: linuxTarget }) await expect(promise).rejects.toBeInstanceOf(OpenCodeInstallError) await expect(promise).rejects.toMatchObject({ swapStarted: true }) diff --git a/backend/test/services/opencode-single-server.test.ts b/backend/test/services/opencode-single-server.test.ts index b2ddb7832..7aa36fec2 100644 --- a/backend/test/services/opencode-single-server.test.ts +++ b/backend/test/services/opencode-single-server.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vite const createOpenCodeClientMock = vi.hoisted(() => vi.fn(() => ({ api: { server: { - info: vi.fn().mockResolvedValue({ version: '2.0.15', pid: 1234, urls: ['http://127.0.0.1:5551'], paths: { tmp: '/tmp' } }), + info: vi.fn().mockResolvedValue({ version: '2.1.2', pid: 1234, urls: ['http://127.0.0.1:5551'], paths: { tmp: '/tmp' } }), }, }, forwardRaw: vi.fn(), @@ -335,7 +335,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) setOpenCodeEnv({ host: '0.0.0.0', password: 'envpassword123' }) @@ -363,7 +363,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) setOpenCodeEnv({ host: '0.0.0.0', password: '' }) @@ -401,7 +401,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') @@ -520,7 +520,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) const savedEnv = snapshotMicrosandboxEnv() @@ -763,7 +763,7 @@ describe('OpenCodeServerManager - server auth', () => { let capturedSignal: AbortSignal | undefined const info = vi.fn(async (options: { signal?: AbortSignal }) => { capturedSignal = options.signal - return { version: '2.0.15', pid: 1234, urls: ['http://127.0.0.1:5551'], paths: { tmp: '/tmp' } } + return { version: '2.1.2', pid: 1234, urls: ['http://127.0.0.1:5551'], paths: { tmp: '/tmp' } } }) createOpenCodeClientMock.mockImplementationOnce(() => ({ api: { server: { info } }, @@ -793,7 +793,7 @@ describe('OpenCodeServerManager - server auth', () => { capturedSignal = options.signal queueMicrotask(() => emitExit(1, null)) return new Promise((resolve) => { - setTimeout(() => resolve({ version: '2.0.15', pid: 1234, urls: [], paths: { tmp: '/tmp' } }), 0) + setTimeout(() => resolve({ version: '2.1.2', pid: 1234, urls: [], paths: { tmp: '/tmp' } }), 0) }) }) createOpenCodeClientMock.mockImplementationOnce(() => ({ @@ -842,7 +842,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') @@ -930,7 +930,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') @@ -1016,7 +1016,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) manager.setDatabase(createPasswordDb(null)) @@ -1129,7 +1129,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') @@ -1179,7 +1179,7 @@ describe('OpenCodeServerManager - server auth', () => { if (cmd.includes('lsof')) { return spawnMock.mock.calls.length > 0 ? '1234\n' : '9999\n' } - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') @@ -1926,7 +1926,7 @@ describe('OpenCodeServerManager - server auth', () => { if (cmd.includes('lsof')) { return spawnMock.mock.calls.length > 0 ? '1234\n' : '9999\n' } - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) readFileSyncMock.mockReturnValue(procStatStringWithGroup(9999, '42')) @@ -1983,7 +1983,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) readdirSyncMock.mockReturnValue(['10001']) @@ -2062,7 +2062,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return '' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) readdirSyncMock.mockReturnValue(['10001']) @@ -2125,7 +2125,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) readFileSyncMock.mockImplementation((filePath: string) => { @@ -2181,7 +2181,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return '9999\n' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) readFileSyncMock.mockReturnValue(procStatStringWithGroup(9999, '42')) @@ -2223,7 +2223,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return '9998\n' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) killSpy.mockImplementation(((pid: number, signal?: number | string) => { @@ -2260,7 +2260,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return '9997\n' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) killSpy.mockImplementation(((pid: number, signal?: number | string) => { @@ -2292,7 +2292,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '8888\n' : '' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) killSpy.mockImplementation(((pid: number, signal?: number | string) => { @@ -2965,7 +2965,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return '' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) installManagedPluginsMock.mockRejectedValueOnce(new Error('readonly filesystem')) @@ -2997,7 +2997,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') @@ -3028,7 +3028,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') @@ -3056,7 +3056,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) restoreQuarantinedOpenCodePluginsMock.mockRejectedValueOnce(new Error('readonly filesystem')) @@ -3087,7 +3087,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return '' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) installManagedPluginsMock.mockRejectedValueOnce(new Error('readonly filesystem')) @@ -3119,7 +3119,7 @@ describe('OpenCodeServerManager - server auth', () => { })) execSyncMock.mockImplementation((cmd: string) => { if (cmd.includes('lsof')) return spawnMock.mock.calls.length > 0 ? '1234\n' : '' - if (cmd.includes('opencode --version')) return '2.0.15\n' + if (cmd.includes('opencode --version')) return '2.1.2\n' throw new Error('not found') }) const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') @@ -3155,7 +3155,7 @@ describe('OpenCodeServerManager - server auth', () => { await expect(manager.start()).rejects.toThrow('OpenCode 1.18.32 is not supported') expect(spawnMock).not.toHaveBeenCalled() - expect(manager.getLastStartupError()).toContain('requires OpenCode >=2.0.15 <3.0.0') + expect(manager.getLastStartupError()).toContain('requires OpenCode >=2.1.2 <3.0.0') expect(manager.isLastStartupErrorNonRecoverable()).toBe(true) }) @@ -3164,7 +3164,7 @@ describe('OpenCodeServerManager - server auth', () => { isEnabled: () => false, })) execSyncMock.mockImplementation((cmd: string) => { - if (cmd.includes('opencode --version')) return 'opencode v2.0.15\n' + if (cmd.includes('opencode --version')) return 'opencode v2.1.2\n' throw new Error('not found') }) const { OpenCodeServerManager } = await import('../../src/services/opencode-single-server') @@ -3174,7 +3174,7 @@ describe('OpenCodeServerManager - server auth', () => { await manager.start() expect(spawnMock).toHaveBeenCalled() - expect(manager.getVersion()).toBe('2.0.15') + expect(manager.getVersion()).toBe('2.1.2') expect(manager.isVersionSupported()).toBe(true) }) @@ -3195,7 +3195,7 @@ describe('OpenCodeServerManager - server auth', () => { marked = true manager.markRestartPending() } - return { version: '2.0.15', pid: 1234, urls: [], paths: { tmp: '/tmp' } } + return { version: '2.1.2', pid: 1234, urls: [], paths: { tmp: '/tmp' } } }), }, }, @@ -3399,7 +3399,7 @@ describe('ConfigReloadError', () => { }) describe('OpenCodeServerManager - checkHealth', () => { - const serverInfo = { version: '2.0.15', pid: 1234, urls: ['http://127.0.0.1:5551'], paths: { tmp: '/tmp' } } + const serverInfo = { version: '2.1.2', pid: 1234, urls: ['http://127.0.0.1:5551'], paths: { tmp: '/tmp' } } it('probes GET /api/info through the API client and returns true for a ServerInfo response', async () => { const { opencodeServerManager } = await import('../../src/services/opencode-single-server') diff --git a/backend/test/services/preview/ports.test.ts b/backend/test/services/preview/ports.test.ts index dd19abfb4..812708f9a 100644 --- a/backend/test/services/preview/ports.test.ts +++ b/backend/test/services/preview/ports.test.ts @@ -21,6 +21,7 @@ const IPV4_FIXTURE = [ procRow('0100007F:1F90', '00000000:0000', '0A', '22222'), procRow('0501A8C0:1F90', '00000000:0000', '0A', '33333'), procRow('0100007F:1435', '0100007F:9C40', '01', '44444'), + procRow('0B00007F:8F71', '00000000:0000', '0A', '88888'), ].join('\n') const IPV6_FIXTURE = [ @@ -70,6 +71,12 @@ describe('parseProcNetTcp', () => { ]) }) + it('drops loopback aliases such as Docker embedded DNS', () => { + const sockets = parseProcNetTcp(IPV4_FIXTURE, 4) + + expect(sockets.map((socket) => socket.address)).not.toContain('127.0.0.11') + }) + it('decodes little-endian IPv6 addresses and keeps loopback and wildcard', () => { const sockets = parseProcNetTcp(IPV6_FIXTURE, 6) @@ -252,6 +259,19 @@ describe('listListeningPorts', () => { expect(cwdCalls).toEqual([[777, 888]]) }) + it('drops loopback aliases from lsof listeners', async () => { + const deps = createDeps( + { files: {}, links: {}, dirs: {} }, + { + platform: 'darwin', + runLsof: async () => ['p999', 'cdockerd', 'n127.0.0.11:36721'].join('\n'), + runLsofCwd: async () => '', + }, + ) + + expect(await listListeningPorts(deps)).toEqual([]) + }) + it('keeps cwd null when the cwd lsof call rejects but still lists the ports', async () => { const deps = createDeps( { files: {}, links: {}, dirs: {} }, diff --git a/backend/test/services/repo-git.test.ts b/backend/test/services/repo-git.test.ts index 1d77238e1..5948bad60 100644 --- a/backend/test/services/repo-git.test.ts +++ b/backend/test/services/repo-git.test.ts @@ -196,9 +196,13 @@ describe('repo service real git', () => { }) it('rejects a missing absolute path', async () => { - const { initLocalRepo } = await import('../../src/services/repo') + const { initLocalRepo, RepoInputError } = await import('../../src/services/repo') + + const error = await initLocalRepo(db, gitAuth, path.join(projectsRoot, 'missing-absolute')).catch((thrown: unknown) => thrown) - await expect(initLocalRepo(db, gitAuth, path.join(projectsRoot, 'missing-absolute'))).rejects.toThrow(/No such file or directory/) + expect(error).toBeInstanceOf(RepoInputError) + expect((error as { statusCode: number }).statusCode).toBe(400) + expect((error as Error).message).toMatch(/No such directory/) }) it('rejects the repos root itself as a source', async () => { @@ -397,6 +401,19 @@ describe('repo service real git', () => { await expect(createBranch(db, gitAuth, 9999, 'main')).rejects.toThrow(/Repo not found/) }) + + it('rejects an invalid branch name as a repo input error', async () => { + const { createBranch, RepoInputError } = await import('../../src/services/repo') + const repoPath = path.join(reposPath, uniqueName('create-bad-branch')) + createCommittedRepo(repoPath) + const repo = registerLocalRepo(path.basename(repoPath)) + + const error = await createBranch(db, gitAuth, repo.id, 'bad name..x').catch((thrown: unknown) => thrown) + + expect(error).toBeInstanceOf(RepoInputError) + expect((error as Error).message).toBe("Invalid branch name: 'bad name..x'.") + expect(git(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath)).toBe('main') + }) }) describe('pullRepo', () => { @@ -458,14 +475,66 @@ describe('repo service real git', () => { }) describe('deleteRepoFiles', () => { - it('deletes a local repo directory and its row', async () => { + it('keeps the files of a local repo inside the repos directory and deletes the row', async () => { const { deleteRepoFiles } = await import('../../src/services/repo') - const repoPath = path.join(reposPath, uniqueName('delete-local')) + const repoPath = path.join(reposPath, uniqueName('delete-local-inside')) createCommittedRepo(repoPath) + writeFileSync(path.join(repoPath, 'important.txt'), 'keep me') const repo = registerLocalRepo(path.basename(repoPath)) await deleteRepoFiles(db, repo.id) + expect(existsSync(path.join(repoPath, 'important.txt'))).toBe(true) + expect(existsSync(path.join(repoPath, '.git'))).toBe(true) + expect(getRepoById(db, repo.id)).toBeNull() + }) + + it('removes only the workspace alias of a local repo outside the repos directory', async () => { + const { initLocalRepo, deleteRepoFiles } = await import('../../src/services/repo') + const sourcePath = path.join(projectsRoot, uniqueName('delete-local-outside')) + createCommittedRepo(sourcePath) + writeFileSync(path.join(sourcePath, 'important.txt'), 'keep me') + const repo = await initLocalRepo(db, gitAuth, sourcePath) + const aliasPath = path.join(reposPath, repo.localPath) + + expect(lstatSync(aliasPath).isSymbolicLink()).toBe(true) + + await deleteRepoFiles(db, repo.id) + + expect(existsSync(aliasPath)).toBe(false) + expect(existsSync(path.join(sourcePath, 'important.txt'))).toBe(true) + expect(getRepoById(db, repo.id)).toBeNull() + }) + + it('keeps local repo files when the alias is missing', async () => { + const { initLocalRepo, deleteRepoFiles } = await import('../../src/services/repo') + const sourcePath = path.join(projectsRoot, uniqueName('delete-local-alias-gone')) + createCommittedRepo(sourcePath) + const repo = await initLocalRepo(db, gitAuth, sourcePath) + rmSync(path.join(reposPath, repo.localPath), { force: true }) + + await deleteRepoFiles(db, repo.id) + + expect(existsSync(path.join(sourcePath, '.git'))).toBe(true) + expect(getRepoById(db, repo.id)).toBeNull() + }) + + it('deletes a cloned repo directory and its row', async () => { + const { deleteRepoFiles } = await import('../../src/services/repo') + const baseName = uniqueName('delete-clone') + const repoPath = path.join(reposPath, baseName) + createCommittedRepo(repoPath) + const repo = createRepo(db, { + repoUrl: `https://github.com/example/${baseName}.git`, + localPath: baseName, + branch: 'main', + defaultBranch: 'main', + cloneStatus: 'ready', + clonedAt: Date.now(), + }) + + await deleteRepoFiles(db, repo.id) + expect(existsSync(repoPath)).toBe(false) expect(getRepoById(db, repo.id)).toBeNull() }) @@ -653,10 +722,29 @@ describe('repo service real git', () => { }) it('enhances a missing local clone error and rolls back the row', async () => { - const { cloneRepo } = await import('../../src/services/repo') + const { cloneRepo, RepoInputError } = await import('../../src/services/repo') const name = uniqueName('clone-missing-source') - await expect(cloneRepo(db, gitAuth, path.join(workspaceRoot, 'no-such-origin'), { directoryName: name })).rejects.toThrow(/does not exist/) + const error = await cloneRepo(db, gitAuth, path.join(workspaceRoot, 'no-such-origin'), { directoryName: name }) + .catch((thrown: unknown) => thrown) + + expect(error).toBeInstanceOf(RepoInputError) + expect((error as Error).message).toMatch(/Repository not found or access denied/) + expect((error as { statusCode: number }).statusCode).toBe(400) + expect(getRepoByLocalPath(db, name)).toBeNull() + }) + + it('reports an unparseable clone URL as a repo input error', async () => { + const { cloneRepo, RepoInputError } = await import('../../src/services/repo') + const name = uniqueName('clone-bad-url') + + const error = await cloneRepo(db, gitAuth, 'not a url !!', { directoryName: name }) + .catch((thrown: unknown) => thrown) + + expect(error).toBeInstanceOf(RepoInputError) + expect((error as Error).message).not.toMatch(/Command failed/) + expect((error as Error).message).toMatch(/Repository not found or access denied/) + expect((error as { statusCode: number }).statusCode).toBe(400) expect(getRepoByLocalPath(db, name)).toBeNull() }) diff --git a/backend/test/services/repo-worktree.test.ts b/backend/test/services/repo-worktree.test.ts index 5b26ecc65..35de7adcc 100644 --- a/backend/test/services/repo-worktree.test.ts +++ b/backend/test/services/repo-worktree.test.ts @@ -21,17 +21,17 @@ describe('repo worktree helpers', () => { worktreePath = path.join(tmpDir, 'feature-x') // Init bare origin - execSync(`git init --bare "${originRepoPath}"`, { env }) + execSync(`git init --bare -b main "${originRepoPath}"`, { env }) - // Clone origin to get a working base repo (default branch is "master" in bare repos) + // Clone origin to get a working base repo (default branch is "main") execSync(`git clone "${originRepoPath}" "${baseRepoPath}"`, { env }) // Set git config for commits execSync(`git -C "${baseRepoPath}" config user.email test@test.com`, { env }) execSync(`git -C "${baseRepoPath}" config user.name Test`, { env }) - // Rename default branch to "main" and push - execSync(`git -C "${baseRepoPath}" branch -m master main`, { env }) + // Ensure the branch is "main" and push + execSync(`git -C "${baseRepoPath}" branch -M main`, { env }) execSync(`git -C "${baseRepoPath}" commit --allow-empty -m "Initial commit"`, { env }) execSync(`git -C "${baseRepoPath}" push origin main`, { env }) diff --git a/backend/test/services/schedule-worktree.test.ts b/backend/test/services/schedule-worktree.test.ts index b5727356d..36764df16 100644 --- a/backend/test/services/schedule-worktree.test.ts +++ b/backend/test/services/schedule-worktree.test.ts @@ -72,12 +72,12 @@ describe('ScheduleWorktreeManager', () => { nonGitDir = path.join(tmpDir, 'non-git-dir') mkdirSync(nonGitDir, { recursive: true }) - // Init bare origin and clone a working base repo - execSync(`git init --bare "${originRepoPath}"`, { env }) + // Init bare origin on main and clone a working base repo + execSync(`git init --bare -b main "${originRepoPath}"`, { env }) execSync(`git clone "${originRepoPath}" "${baseRepoPath}"`, { env }) execSync(`git -C "${baseRepoPath}" config user.email test@test.com`, { env }) execSync(`git -C "${baseRepoPath}" config user.name Test`, { env }) - execSync(`git -C "${baseRepoPath}" branch -m master main`, { env }) + execSync(`git -C "${baseRepoPath}" branch -M main`, { env }) execSync(`git -C "${baseRepoPath}" commit --allow-empty -m "Initial commit"`, { env }) execSync(`git -C "${baseRepoPath}" push origin main`, { env }) execSync(`git -C "${originRepoPath}" symbolic-ref HEAD refs/heads/main`, { env }) diff --git a/backend/test/services/settings.test.ts b/backend/test/services/settings.test.ts index ca25e2daf..a3810d51d 100644 --- a/backend/test/services/settings.test.ts +++ b/backend/test/services/settings.test.ts @@ -26,4 +26,57 @@ describe('SettingsService', () => { expect(settings.preferences.activityWindowMinutes).toBe(DEFAULT_ACTIVITY_WINDOW_MINUTES) expect(settings.preferences.autoScroll).toBe(false) }) + + it('reads a stored git identity email that the request pattern rejects', () => { + db.prepare( + `INSERT INTO user_preferences (user_id, preferences, updated_at) + VALUES (?, ?, ?)` + ).run( + 'default', + JSON.stringify({ mode: 'plan', gitIdentity: { name: 'Root', email: 'root@box' } }), + Date.now() + ) + + const settings = settingsService.getSettings() + + expect(settings.preferences.mode).toBe('plan') + expect(settings.preferences.gitIdentity).toEqual({ name: 'Root', email: 'root@box' }) + }) + + it('keeps a stored git identity and other preferences when an unrelated field is updated', () => { + db.prepare( + `INSERT INTO user_preferences (user_id, preferences, updated_at) + VALUES (?, ?, ?)` + ).run( + 'default', + JSON.stringify({ mode: 'plan', gitIdentity: { name: 'Root', email: 'root@box' } }), + Date.now() + ) + + const settings = settingsService.updateSettings({ theme: 'light' }) + + expect(settings.preferences.theme).toBe('light') + expect(settings.preferences.mode).toBe('plan') + expect(settings.preferences.gitIdentity).toEqual({ name: 'Root', email: 'root@box' }) + }) + + it('keeps a stored legacy identity email that no current pattern accepts', () => { + db.prepare( + `INSERT INTO user_preferences (user_id, preferences, updated_at) + VALUES (?, ?, ?)` + ).run( + 'default', + JSON.stringify({ mode: 'plan', gitIdentity: { name: 'Root', email: 'not an email' } }), + Date.now() + ) + + const settings = settingsService.getSettings() + const updated = settingsService.updateSettings({ autoScroll: false }) + + expect(settings.preferences.mode).toBe('plan') + expect(settings.preferences.gitIdentity).toEqual({ name: 'Root', email: 'not an email' }) + expect(updated.preferences.autoScroll).toBe(false) + expect(updated.preferences.mode).toBe('plan') + expect(updated.preferences.gitIdentity).toEqual({ name: 'Root', email: 'not an email' }) + }) }) diff --git a/backend/test/services/terminal-hub.test.ts b/backend/test/services/terminal-hub.test.ts new file mode 100644 index 000000000..ea79ef958 --- /dev/null +++ b/backend/test/services/terminal-hub.test.ts @@ -0,0 +1,279 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TerminalHub } from '../../src/services/terminal-hub' +import { MAX_UPSTREAM_BUFFERED_BYTES, UPSTREAM_CONNECT_TIMEOUT_MS, type WebSocketPeer } from '../../src/utils/websocket-bridge' + +const RETRY_DELAY_MS = 100 + +class FakeSocket { + binaryType: 'blob' | 'arraybuffer' = 'blob' + readyState = 0 + readonly sent: Array = [] + close = vi.fn() + private readonly listeners = new Map void>>() + + addEventListener(type: string, listener: (event: unknown) => void): void { + const set = this.listeners.get(type) ?? new Set<(event: unknown) => void>() + set.add(listener) + this.listeners.set(type, set) + } + + send(data: string | Uint8Array): void { + this.sent.push(data) + } + + emit(type: string, event: unknown = {}): void { + for (const listener of this.listeners.get(type) ?? []) listener(event) + } +} + +interface FakePeer extends WebSocketPeer { + sent: Array + closeCalls: Array<{ code?: number; reason?: string }> +} + +function createPeer(): FakePeer { + const sent: Array = [] + const closeCalls: Array<{ code?: number; reason?: string }> = [] + return { + sent, + closeCalls, + send(data) { + sent.push(data) + }, + close(code, reason) { + closeCalls.push({ code, reason }) + }, + bufferedAmount() { + return 0 + }, + } +} + +function decodeMeta(data: string | Uint8Array): number { + expect(data).toBeInstanceOf(Uint8Array) + const bytes = data as Uint8Array + expect(bytes[0]).toBe(0) + return (JSON.parse(new TextDecoder().decode(bytes.subarray(1))) as { cursor: number }).cursor +} + +async function createOpenHub(): Promise<{ hub: TerminalHub; socket: FakeSocket }> { + const socket = new FakeSocket() + const hub = new TerminalHub({ openUpstream: async () => socket as unknown as WebSocket }) + await hub.start() + socket.readyState = 1 + socket.emit('open') + return { hub, socket } +} + +describe('TerminalHub', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('sends retained output, then the cursor, then live output to a late client', async () => { + const { hub, socket } = await createOpenHub() + socket.emit('message', { data: 'hi\r\n' }) + + const peer = createPeer() + hub.attach(peer, undefined) + socket.emit('message', { data: 'more' }) + + expect(peer.sent[0]).toBe('hi\r\n') + expect(decodeMeta(peer.sent[1]!)).toBe(4) + expect(peer.sent[2]).toBe('more') + expect(peer.closeCalls).toEqual([]) + }) + + it('replays an exited terminal and then closes the client', async () => { + const { hub, socket } = await createOpenHub() + socket.emit('message', { data: 'hi' }) + socket.emit('close', { code: 1000 }) + + const peer = createPeer() + hub.attach(peer, undefined) + + expect(peer.sent[0]).toBe('hi') + expect(decodeMeta(peer.sent[1]!)).toBe(2) + expect(peer.closeCalls).toEqual([{ code: 1000, reason: undefined }]) + expect(hub.isEnded()).toBe(true) + }) + + it('replays only the output after the cursor a reconnecting client sends back', async () => { + const { hub, socket } = await createOpenHub() + socket.emit('message', { data: 'first' }) + socket.emit('message', { data: 'second' }) + + const client = createPeer() + hub.attach(client, undefined) + expect(client.sent[1]).toBeInstanceOf(Uint8Array) + hub.attach(client, decodeMeta(client.sent[1]!)) + + const reconnected = createPeer() + hub.attach(reconnected, 5) + + expect(reconnected.sent[0]).toBe('second') + expect(decodeMeta(reconnected.sent[1]!)).toBe(11) + }) + + it('tails an exited terminal without replaying its buffer for cursor -1', async () => { + const { hub, socket } = await createOpenHub() + socket.emit('message', { data: 'hello' }) + socket.emit('close', {}) + + const peer = createPeer() + hub.attach(peer, -1) + + expect(peer.sent).toHaveLength(1) + expect(decodeMeta(peer.sent[0]!)).toBe(5) + expect(peer.closeCalls).toEqual([{ code: 1000, reason: undefined }]) + }) + + it('keeps the retained buffer bounded while the cursor keeps counting', async () => { + const { hub, socket } = await createOpenHub() + socket.emit('message', { data: 'a'.repeat(300 * 1024) }) + + const peer = createPeer() + hub.attach(peer, undefined) + + const replay = peer.sent + .slice(0, -1) + .map((chunk) => chunk as string) + .join('') + expect(replay).toHaveLength(256 * 1024) + expect(decodeMeta(peer.sent[peer.sent.length - 1]!)).toBe(300 * 1024) + }) + + it('queues client input until the upstream opens and then forwards it', async () => { + const socket = new FakeSocket() + const hub = new TerminalHub({ openUpstream: async () => socket as unknown as WebSocket }) + await hub.start() + + const peer = createPeer() + const bridge = hub.attach(peer, undefined) + bridge.send('typed') + expect(socket.sent).toEqual([]) + + socket.readyState = 1 + socket.emit('open') + bridge.send('later') + + expect(socket.sent).toEqual(['typed', 'later']) + }) + + it('stops delivering live output once a client detaches', async () => { + const { hub, socket } = await createOpenHub() + const peer = createPeer() + const bridge = hub.attach(peer, undefined) + bridge.close() + + socket.emit('message', { data: 'after close' }) + + expect(peer.sent.some((chunk) => chunk === 'after close')).toBe(false) + }) + + it.each([1000, 4404])('closes attached clients with the upstream exit code %i', async (code) => { + const { hub, socket } = await createOpenHub() + const peer = createPeer() + hub.attach(peer, undefined) + + socket.emit('close', { code }) + + expect(peer.closeCalls).toEqual([{ code, reason: undefined }]) + expect(hub.isAvailable()).toBe(true) + expect(hub.isEnded()).toBe(true) + }) + + it('replays and closes a late client with a 4404 exit code', async () => { + const { hub, socket } = await createOpenHub() + socket.emit('message', { data: 'gone' }) + socket.emit('close', { code: 4404 }) + + const peer = createPeer() + hub.attach(peer, undefined) + + expect(peer.sent[0]).toBe('gone') + expect(peer.closeCalls).toEqual([{ code: 4404, reason: undefined }]) + }) + + it('fails the hub on an abnormal upstream close so the client can fall back', async () => { + const { hub, socket } = await createOpenHub() + const peer = createPeer() + hub.attach(peer, undefined) + + socket.emit('close', { code: 1006 }) + + expect(peer.closeCalls).toEqual([{ code: 1011, reason: undefined }]) + expect(hub.isAvailable()).toBe(false) + expect(hub.isEnded()).toBe(true) + + const late = createPeer() + hub.attach(late, undefined) + expect(late.closeCalls).toEqual([{ code: 1011, reason: undefined }]) + }) + + it('retries each connect attempt after the upstream timeout and then fails', async () => { + vi.useFakeTimers() + const sockets: FakeSocket[] = [] + const hub = new TerminalHub({ + openUpstream: async () => { + const socket = new FakeSocket() + sockets.push(socket) + return socket as unknown as WebSocket + }, + }) + await hub.start() + expect(sockets).toHaveLength(1) + + await vi.advanceTimersByTimeAsync(UPSTREAM_CONNECT_TIMEOUT_MS) + expect(sockets[0]!.close).toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS) + expect(sockets).toHaveLength(2) + + await vi.advanceTimersByTimeAsync(UPSTREAM_CONNECT_TIMEOUT_MS) + await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS) + expect(sockets).toHaveLength(3) + + const peer = createPeer() + hub.attach(peer, undefined) + await vi.advanceTimersByTimeAsync(UPSTREAM_CONNECT_TIMEOUT_MS) + + expect(hub.isAvailable()).toBe(false) + expect(peer.closeCalls).toEqual([{ code: 1011, reason: undefined }]) + expect(sockets).toHaveLength(3) + }) + + it('fails the hub and closes clients 1013 when queued input exceeds the upstream limit', async () => { + const socket = new FakeSocket() + const hub = new TerminalHub({ openUpstream: async () => socket as unknown as WebSocket }) + await hub.start() + + const peer = createPeer() + const bridge = hub.attach(peer, undefined) + bridge.send('x'.repeat(MAX_UPSTREAM_BUFFERED_BYTES + 1)) + + expect(hub.isAvailable()).toBe(false) + expect(peer.closeCalls).toEqual([{ code: 1013, reason: 'Upstream too slow' }]) + expect(socket.sent).toEqual([]) + }) + + it('releases a deferred command once before queued client input', async () => { + const socket = new FakeSocket() + const hub = new TerminalHub({ + openUpstream: async () => socket as unknown as WebSocket, + releaseOnOpen: '\n', + }) + await hub.start() + + const peer = createPeer() + const bridge = hub.attach(peer, undefined) + bridge.send('typed') + expect(socket.sent).toEqual([]) + + socket.readyState = 1 + socket.emit('open') + socket.emit('open') + + expect(socket.sent).toEqual(['\n', 'typed']) + expect(hub.hasReleased()).toBe(true) + }) +}) diff --git a/backend/test/services/terminal.test.ts b/backend/test/services/terminal.test.ts index ebc610b29..3f2d2cfa8 100644 --- a/backend/test/services/terminal.test.ts +++ b/backend/test/services/terminal.test.ts @@ -18,8 +18,27 @@ function createPty(overrides: Partial = {}): PtyRecord { return { id: 'pty-1', title: 'Terminal', command: 'sh', args: [], cwd: '/repo', status: 'running', pid: 1, ...overrides } } +const DEFERRED_SCRIPT = 'stty -echo 2>/dev/null; IFS= read -r _; stty echo 2>/dev/null; exec "$@"' + +function deferredArgs(command: string, args: string[]): string[] { + return ['-c', DEFERRED_SCRIPT, 'ocm-terminal', command, ...args] +} + function createClient(pty: Record, api: Record = {}): OpenCodeClient { - return { api: { pty, ...api } } as unknown as OpenCodeClient + const hubToken = vi.fn(async () => ({ data: { ticket: 'hub-ticket', expires_in: 60 } })) + return { api: { pty: { connect: { token: hubToken }, ...pty }, ...api } } as unknown as OpenCodeClient +} + +function createSocketFactory(): { openSocket: (url: string) => WebSocket; sockets: FakeUpstreamSocket[] } { + const sockets: FakeUpstreamSocket[] = [] + return { + sockets, + openSocket: (url) => { + const socket = new FakeUpstreamSocket(url) + sockets.push(socket) + return socket as unknown as WebSocket + }, + } } function createCredentialProvider(shellEnv: Record = {}): CredentialProvider { @@ -29,7 +48,7 @@ function createCredentialProvider(shellEnv: Record = {}): Creden function createTerminalService( client: OpenCodeClient, provider: CredentialProvider, - openSocket?: (url: string) => WebSocket, + openSocket: (url: string) => WebSocket = createSocketFactory().openSocket, baseUrl = 'http://127.0.0.1:5551', includeLocalCredentials = true, ): TerminalService { @@ -139,7 +158,7 @@ describe('TerminalService', () => { location: { directory: '/repo' }, title: 'ocm:action:dev:Dev server', command: '/bin/sh', - args: ['-c', 'npm run dev'], + args: deferredArgs('/bin/sh', ['-c', 'npm run dev']), env: { GH_TOKEN: 'gh', FOO: 'bar' }, }) expect(result).toEqual({ @@ -170,6 +189,230 @@ describe('TerminalService', () => { expect(captured).not.toHaveProperty('args') expect(captured?.env).toEqual({}) }) + + it('defers a setup command until the manager has attached to the new PTY', async () => { + const create = vi.fn(async () => ({ + location: { directory: '/repo' }, + data: createPty({ id: 'pty-setup', title: 'ocm:setup:Worktree setup' }), + })) + const service = createTerminalService(createClient({ create }), createCredentialProvider()) + + await service.create('/repo', { + kind: 'setup', + name: 'Worktree setup', + command: '/bin/sh', + args: ['-c', 'set -e\npnpm install'], + }) + + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + command: '/bin/sh', + args: deferredArgs('/bin/sh', ['-c', 'set -e\npnpm install']), + }), + ) + }) + + it('keeps a plain shell command without deferring it', async () => { + let captured: Record | undefined + const create = vi.fn(async (input: Record) => { + captured = input + return { location: { directory: '/repo' }, data: createPty({ id: 'pty-1', title: 'Terminal' }) } + }) + const service = createTerminalService(createClient({ create }), createCredentialProvider()) + + await service.create('/repo', { kind: 'shell', name: 'Terminal', command: '/bin/zsh', args: ['-l'] }) + + expect(captured).toMatchObject({ command: '/bin/zsh', args: ['-l'] }) + }) + }) + + describe('hub capture', () => { + function createCapturingService(actionTerminal = createPty({ id: 'pty-action', title: 'ocm:action:say:Say hi' })) { + const create = vi.fn(async () => ({ location: { directory: '/repo' }, data: actionTerminal })) + const list = vi.fn(async () => ({ location: { directory: '/repo' }, data: [actionTerminal] })) + const factory = createSocketFactory() + const service = createTerminalService( + createClient({ create, list }), + createCredentialProvider(), + factory.openSocket, + ) + return { service, sockets: factory.sockets } + } + + function createSingleTerminalService(terminal: PtyRecord) { + const create = vi.fn(async () => ({ location: { directory: '/repo' }, data: terminal })) + const list = vi.fn(async () => ({ location: { directory: '/repo' }, data: [terminal] })) + const factory = createSocketFactory() + const service = createTerminalService( + createClient({ create, list }), + createCredentialProvider(), + factory.openSocket, + ) + return { service, sockets: factory.sockets } + } + + async function openHub(sockets: FakeUpstreamSocket[]): Promise { + await vi.waitFor(() => expect(sockets).toHaveLength(1)) + sockets[0]!.emit('open', {}) + return sockets[0]! + } + + const actionInput = { + kind: 'action' as const, + actionId: 'say', + name: 'Say hi', + command: '/bin/sh', + args: ['-c', 'echo hi'], + } + + it('opens the upstream at creation so output is captured before the first client attaches', async () => { + const { service, sockets } = createCapturingService() + + await service.create('/repo', actionInput) + const socket = await openHub(sockets) + + const url = new URL(socket.url) + expect(url.pathname).toBe('/api/pty/pty-action/connect') + expect(url.searchParams.get('cursor')).toBe('0') + }) + + it('captures a setup terminal and releases its deferred command', async () => { + const setup = createPty({ id: 'pty-setup', title: 'ocm:setup:Worktree setup' }) + const { service, sockets } = createSingleTerminalService(setup) + + await service.create('/repo', { kind: 'setup', name: 'Worktree setup', command: '/bin/sh', args: ['-c', 'pnpm install'] }) + const socket = await openHub(sockets) + + expect(socket.sent).toEqual(['\n']) + }) + + it('bridges a plain shell without a hub', async () => { + const shell = createPty({ id: 'pty-shell', title: 'Terminal' }) + const { service, sockets } = createSingleTerminalService(shell) + + await service.create('/repo', { kind: 'shell', name: 'Terminal' }) + expect(sockets).toHaveLength(0) + + await service.connect('/repo', 'pty-shell', 12, createPeer()) + + expect(sockets).toHaveLength(1) + expect(new URL(sockets[0]!.url).searchParams.get('cursor')).toBe('12') + }) + + it('replays captured output and the exit close frame to a late client', async () => { + const exited = createPty({ id: 'pty-action', title: 'ocm:action:say:Say hi', status: 'exited', exitCode: 0 }) + const { service, sockets } = createCapturingService(exited) + + await service.create('/repo', actionInput) + const socket = await openHub(sockets) + socket.emit('message', { data: 'hi\r\n' }) + socket.emit('close', { code: 1000 }) + + const peer = createPeer() + await service.connect('/repo', 'pty-action', undefined, peer) + + expect(peer.sent[0]).toBe('hi\r\n') + expect(peer.sent[1]).toBeInstanceOf(Uint8Array) + const meta = peer.sent[1] as Uint8Array + expect(meta[0]).toBe(0) + expect(JSON.parse(new TextDecoder().decode(meta.subarray(1)))).toEqual({ cursor: 4 }) + expect(peer.closeCalls).toEqual([{ code: 1000, reason: undefined }]) + expect(sockets).toHaveLength(1) + }) + + it('forwards late-client input through the captured upstream', async () => { + const { service, sockets } = createCapturingService() + + await service.create('/repo', actionInput) + const socket = await openHub(sockets) + const peer = createPeer() + const bridge = await service.connect('/repo', 'pty-action', undefined, peer) + + bridge.send('ls\r') + + expect(socket.sent).toEqual(['\n', 'ls\r']) + }) + + it('streams live output to an attached client without duplicating the replay', async () => { + const { service, sockets } = createCapturingService() + + await service.create('/repo', actionInput) + const socket = await openHub(sockets) + socket.emit('message', { data: 'old' }) + const peer = createPeer() + await service.connect('/repo', 'pty-action', undefined, peer) + + socket.emit('message', { data: 'new' }) + + expect(peer.sent[0]).toBe('old') + expect(peer.sent[2]).toBe('new') + }) + + it('falls back to the bridge with the client cursor after an abnormal hub close', async () => { + const { service, sockets } = createCapturingService() + + await service.create('/repo', actionInput) + const socket = await openHub(sockets) + const attached = createPeer() + await service.connect('/repo', 'pty-action', undefined, attached) + + socket.emit('close', { code: 1006 }) + + expect(attached.closeCalls).toEqual([{ code: 1011, reason: undefined }]) + + const peer = createPeer() + await service.connect('/repo', 'pty-action', 7, peer) + + expect(sockets).toHaveLength(2) + expect(new URL(sockets[1]!.url).searchParams.get('cursor')).toBe('7') + sockets[1]!.emit('open', {}) + expect(sockets[1]!.sent).toEqual([]) + }) + + it('delivers the deferred release through the fallback bridge when the hub fails first', async () => { + const { service, sockets } = createCapturingService() + + await service.create('/repo', actionInput) + await vi.waitFor(() => expect(sockets).toHaveLength(1)) + sockets[0]!.emit('close', { code: 1006 }) + await vi.waitFor(() => expect(sockets).toHaveLength(2), { timeout: 3000 }) + sockets[1]!.emit('close', { code: 1006 }) + await vi.waitFor(() => expect(sockets).toHaveLength(3), { timeout: 3000 }) + sockets[2]!.emit('close', { code: 1006 }) + + const peer = createPeer() + await service.connect('/repo', 'pty-action', undefined, peer) + expect(sockets).toHaveLength(4) + sockets[3]!.emit('open', {}) + expect(sockets[3]!.sent).toEqual(['\n']) + + await service.connect('/repo', 'pty-action', undefined, createPeer()) + expect(sockets).toHaveLength(5) + sockets[4]!.emit('open', {}) + expect(sockets[4]!.sent).toEqual([]) + }) + + it('drops the captured upstream when the terminal is removed', async () => { + const removed = createPty({ id: 'pty-action', title: 'Terminal', status: 'exited', exitCode: 0 }) + const remove = vi.fn(async () => undefined) + const factory = createSocketFactory() + const service = createTerminalService( + createClient({ create: vi.fn(async () => ({ data: removed })), list: vi.fn(async () => ({ data: [removed] })), remove }), + createCredentialProvider(), + factory.openSocket, + ) + + await service.create('/repo', actionInput) + await openHub(factory.sockets) + await service.remove('/repo', 'pty-action') + + const peer = createPeer() + await service.connect('/repo', 'pty-action', undefined, peer) + + expect(remove).toHaveBeenCalledWith({ ptyID: 'pty-action', location: { directory: '/repo' } }) + expect(factory.sockets).toHaveLength(2) + expect(new URL(factory.sockets[1]!.url).searchParams.has('cursor')).toBe(false) + }) }) describe('remote target', () => { diff --git a/backend/test/shared/opencode-contract.test.ts b/backend/test/shared/opencode-contract.test.ts index a376ed963..6604d52bf 100644 --- a/backend/test/shared/opencode-contract.test.ts +++ b/backend/test/shared/opencode-contract.test.ts @@ -26,6 +26,10 @@ interface CapturedRequest { headers: Record } +interface PackageManifest { + dependencies: Record +} + function createFetchSpy() { const calls: CapturedRequest[] = [] const fetchSpy = (async (input: Parameters[0], init?: Parameters[1]) => { @@ -46,56 +50,58 @@ function createFetchSpy() { describe('OpenCode v2 contract', () => { describe('version policy', () => { it('pins the client version, derives the floor from it and fixes the V2 server username', () => { - expect(OPENCODE_PINNED_VERSION).toBe('2.0.15') - expect(OPENCODE_SUPPORTED_VERSION_RANGE).toBe('>=2.0.15 <3.0.0') + expect(OPENCODE_PINNED_VERSION).toBe('2.1.2') + expect(OPENCODE_SUPPORTED_VERSION_RANGE).toBe('>=2.1.2 <3.0.0') expect(OPENCODE_SERVER_USERNAME).toBe('opencode') }) it('accepts releases of the pinned major at or above the pin and rejects everything else', () => { expect(isSupportedOpenCodeVersion('1.18.32')).toBe(false) expect(isSupportedOpenCodeVersion('2.0.0')).toBe(false) - expect(isSupportedOpenCodeVersion('2.0.14')).toBe(false) - expect(isSupportedOpenCodeVersion('2.0.15')).toBe(true) - expect(isSupportedOpenCodeVersion('2.0.16')).toBe(true) - expect(isSupportedOpenCodeVersion('2.1.0')).toBe(true) - expect(isSupportedOpenCodeVersion('v2.0.15')).toBe(true) - expect(isSupportedOpenCodeVersion(' v2.0.15 ')).toBe(true) + expect(isSupportedOpenCodeVersion('2.0.15')).toBe(false) + expect(isSupportedOpenCodeVersion('2.1.0')).toBe(false) + expect(isSupportedOpenCodeVersion('2.1.1')).toBe(false) + expect(isSupportedOpenCodeVersion('2.1.2')).toBe(true) + expect(isSupportedOpenCodeVersion('2.1.3')).toBe(true) + expect(isSupportedOpenCodeVersion('2.2.0')).toBe(true) + expect(isSupportedOpenCodeVersion('v2.1.2')).toBe(true) + expect(isSupportedOpenCodeVersion(' v2.1.2 ')).toBe(true) expect(isSupportedOpenCodeVersion('3.0.0')).toBe(false) expect(isSupportedOpenCodeVersion('3.1.0')).toBe(false) expect(isSupportedOpenCodeVersion('not-a-version')).toBe(false) }) it('rejects prereleases of the pinned version', () => { - expect(isSupportedOpenCodeVersion('2.0.15-alpha.1')).toBe(false) - expect(isSupportedOpenCodeVersion('2.0.15-alpha')).toBe(false) - expect(isSupportedOpenCodeVersion('2.0.15-rc.1+build.5')).toBe(false) - expect(isSupportedOpenCodeVersion('2.0.15-0')).toBe(false) + expect(isSupportedOpenCodeVersion('2.1.2-alpha.1')).toBe(false) + expect(isSupportedOpenCodeVersion('2.1.2-alpha')).toBe(false) + expect(isSupportedOpenCodeVersion('2.1.2-rc.1+build.5')).toBe(false) + expect(isSupportedOpenCodeVersion('2.1.2-0')).toBe(false) }) it('accepts prereleases of higher numeric cores within the pinned major', () => { - expect(isSupportedOpenCodeVersion('2.1.0-beta.1')).toBe(true) - expect(isSupportedOpenCodeVersion('2.0.16-rc.2')).toBe(true) + expect(isSupportedOpenCodeVersion('2.2.0-beta.1')).toBe(true) + expect(isSupportedOpenCodeVersion('2.1.3-rc.2')).toBe(true) expect(isSupportedOpenCodeVersion('3.0.0-beta.1')).toBe(false) }) it('ignores build metadata for precedence', () => { - expect(isSupportedOpenCodeVersion('2.0.15+build')).toBe(true) - expect(isSupportedOpenCodeVersion('2.0.15+2.0.0')).toBe(true) + expect(isSupportedOpenCodeVersion('2.1.2+build')).toBe(true) + expect(isSupportedOpenCodeVersion('2.1.2+2.0.0')).toBe(true) }) it('rejects malformed and incomplete versions', () => { expect(isSupportedOpenCodeVersion('2garbage')).toBe(false) expect(isSupportedOpenCodeVersion('2')).toBe(false) expect(isSupportedOpenCodeVersion('2.0')).toBe(false) - expect(isSupportedOpenCodeVersion('2.0.15.1')).toBe(false) - expect(isSupportedOpenCodeVersion('02.0.15')).toBe(false) - expect(isSupportedOpenCodeVersion('2.0.15-alpha..1')).toBe(false) + expect(isSupportedOpenCodeVersion('2.1.2.1')).toBe(false) + expect(isSupportedOpenCodeVersion('02.1.2')).toBe(false) + expect(isSupportedOpenCodeVersion('2.1.2-alpha..1')).toBe(false) expect(isSupportedOpenCodeVersion('')).toBe(false) }) it('describes the supported range for unsupported versions', () => { expect(describeUnsupportedOpenCodeVersion('3.0.0')).toBe( - 'OpenCode 3.0.0 is not supported; OpenCode Manager requires OpenCode >=2.0.15 <3.0.0', + 'OpenCode 3.0.0 is not supported; OpenCode Manager requires OpenCode >=2.1.2 <3.0.0', ) }) }) @@ -143,6 +149,13 @@ describe('OpenCode v2 contract', () => { expect(dockerfile).toMatch(/^ENV OPENCODE_BUNDLED_VERSION=\$\{OPENCODE_VERSION\}$/m) }) + it('keeps the shared SDK dependency in sync with the pin', () => { + const manifest = JSON.parse( + readFileSync(join(REPO_ROOT, 'shared', 'package.json'), 'utf8'), + ) as PackageManifest + expect(manifest.dependencies['@opencode/client']).toBe(OPENCODE_PINNED_VERSION) + }) + it('makes the shell scripts derive the pin from the Dockerfile or the bundled version instead of hardcoding it', () => { const entrypoint = readFileSync(join(REPO_ROOT, 'scripts', 'docker-entrypoint.sh'), 'utf8') const setupDev = readFileSync(join(REPO_ROOT, 'scripts', 'setup-dev.sh'), 'utf8') diff --git a/backend/test/utils/discovery-cache.test.ts b/backend/test/utils/discovery-cache.test.ts new file mode 100644 index 000000000..c0fd8b7bc --- /dev/null +++ b/backend/test/utils/discovery-cache.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { normalizeToBaseUrl } from '../../src/utils/discovery-cache' + +describe('normalizeToBaseUrl', () => { + it.each([ + ['http://host:9000', 'http://host:9000'], + ['http://host:9000/', 'http://host:9000'], + ['http://host:9000/v1', 'http://host:9000'], + ['http://host:9000/v1/', 'http://host:9000'], + ['http://host:9000/v1/audio/transcriptions', 'http://host:9000'], + ['http://host:8880/v1/audio/speech', 'http://host:8880'], + ['http://host:8880/audio/speech', 'http://host:8880'], + ])('reduces %s to the server base %s', (endpoint, expected) => { + expect(normalizeToBaseUrl(endpoint)).toBe(expected) + }) +}) diff --git a/backend/test/utils/process.test.ts b/backend/test/utils/process.test.ts index d284473f8..657284bf7 100644 --- a/backend/test/utils/process.test.ts +++ b/backend/test/utils/process.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { executeCommand } from '../../src/utils/process' +import { logger } from '../../src/utils/logger' describe('executeCommand signal handling', () => { it('reports a signal-terminated child as a non-zero exit code when exit codes are ignored', async () => { @@ -50,4 +51,21 @@ describe('executeCommand signal handling', () => { expect(result).toBe('hello') }) + + it('logs a git command as one debug line and never at info level', async () => { + const infoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {}) + const debugSpy = vi.spyOn(logger, 'debug').mockImplementation(() => {}) + + try { + await executeCommand(['git', '--version'], { silent: true }) + + expect(infoSpy).not.toHaveBeenCalled() + expect(debugSpy).toHaveBeenCalledTimes(1) + expect(debugSpy.mock.calls[0]).toHaveLength(1) + expect(debugSpy.mock.calls[0]?.[0]).toContain('git --version') + } finally { + infoSpy.mockRestore() + debugSpy.mockRestore() + } + }) }) diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index a1ccc54ad..56589d0b1 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -4,18 +4,6 @@ import path from 'node:path' import { test as base, expect, request as playwrightRequest, type Page } from '@playwright/test' import { BASE_URL, E2E_TMP, STORAGE_STATE } from './env' -const UNAUTHENTICATED_BOOTSTRAP_ENDPOINTS = [ - '/api/settings', - '/api/schedules/all/runs/unread', - '/api/repos', - '/api/sse/stream', -] - -function isIgnoredConsoleError(text: string): boolean { - if (!text.includes('401 (Unauthorized)')) return false - return UNAUTHENTICATED_BOOTSTRAP_ENDPOINTS.some((endpoint) => text.includes(endpoint)) -} - export interface RepoFixture { id: number name: string @@ -48,7 +36,6 @@ export const test = base.extend<{ page: Page }, WorkerFixtures>({ if (message.type() !== 'error') return const url = message.location().url const text = `${message.text()}${url ? ` (${url})` : ''}` - if (isIgnoredConsoleError(text)) return errors.push(`console.error: ${text}`) }) diff --git a/e2e/routes.spec.ts b/e2e/routes.spec.ts index 16d1bbba3..4648b4266 100644 --- a/e2e/routes.spec.ts +++ b/e2e/routes.spec.ts @@ -65,9 +65,9 @@ test.describe('unauthenticated routes', () => { await expect(page.getByRole('button', { name: 'Sign In', exact: true })).toBeVisible() }) - test('protected route redirects to login', async ({ page }) => { + test('protected route redirects to login with the requested path', async ({ page }) => { await page.goto('/schedules') - await expect(page).toHaveURL(/\/login$/) + await expect(page).toHaveURL(/\/login\?redirect=%2Fschedules$/) await expect(page.getByRole('button', { name: 'Sign In', exact: true })).toBeVisible() }) }) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e3f049fae..6453076d7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ import { PwaUpdatePrompt } from '@/components/PwaUpdatePrompt' import { MobileTabBar } from '@/components/navigation/MobileTabBar' import { MobileSheetHost } from '@/components/navigation/MobileSheetHost' import { RouteErrorBoundary } from '@/components/ui/route-error-boundary' +import { NotFoundState } from '@/components/ui/not-found-state' import { DesktopSidebar } from '@/components/navigation/DesktopSidebar' import { useRightEdgeSwipe, useSwipeBack } from './hooks/useMobile' import { useMobileTabBar } from '@/hooks/useMobileTabBar' @@ -164,6 +165,19 @@ function ServerScope({ children }: { children: ReactNode }) { return {children} } +function RootLayout() { + return ( + + + + + + + + + ) +} + function AppShell() { const navigate = useNavigate() const location = useLocation() @@ -233,41 +247,36 @@ function AppShell() { ) return ( - - - - - - - -
- -
- -
- -
-
-
- - - - - - - - -
-
-
-
-
+ + + + + +
+ +
+ +
+ +
+
+
+ + + + + + + +
+
+
) } const router = createBrowserRouter([ { - element: , + element: , errorElement: , children: [ { @@ -286,62 +295,80 @@ const router = createBrowserRouter([ loader: setupLoader, }, { - path: '/', - element: , - loader: protectedLoader, - }, - { - path: '/assistant', - element: , - loader: protectedLoader, - }, - { - path: '/repos/:id', - element: , - loader: protectedLoader, - }, - { - path: '/repos/:id/assistant', - element: , - loader: protectedLoader, - }, - { - path: '/repos/:id/sessions/:sessionId', - element: , - loader: protectedLoader, - }, - { - path: '/repos/:id/schedules', - element: , - loader: protectedLoader, - }, - { - path: '/schedules', - element: , - loader: protectedLoader, - }, - { - path: '/servers/:serverId', - element: , - loader: protectedLoader, - }, - { - path: '/servers/:serverId/projects', - element: ( - - - - ), - loader: protectedLoader, - }, - { - path: '/servers/:serverId/sessions/:sessionId', - element: ( - - - - ), - loader: protectedLoader, + element: , + errorElement: , + children: [ + { + path: '/', + element: , + loader: protectedLoader, + }, + { + path: '/assistant', + element: , + loader: protectedLoader, + }, + { + path: '/repos/:id', + element: , + loader: protectedLoader, + }, + { + path: '/repos/:id/assistant', + element: , + loader: protectedLoader, + }, + { + path: '/repos/:id/sessions/:sessionId', + element: , + loader: protectedLoader, + }, + { + path: '/repos/:id/schedules', + element: , + loader: protectedLoader, + }, + { + path: '/schedules', + element: , + loader: protectedLoader, + }, + { + path: '/servers/:serverId', + element: , + loader: protectedLoader, + }, + { + path: '/servers/:serverId/projects', + element: ( + + + + ), + loader: protectedLoader, + }, + { + path: '/servers/:serverId/sessions/:sessionId', + element: ( + + + + ), + loader: protectedLoader, + }, + { + path: '*', + element: ( + + ), + loader: protectedLoader, + }, + ], }, ], }, @@ -350,11 +377,9 @@ const router = createBrowserRouter([ function App() { return ( - - - - - + + + ) } diff --git a/frontend/src/api/changeWalkthroughs.ts b/frontend/src/api/changeWalkthroughs.ts index 0d8c18498..3ab8d6132 100644 --- a/frontend/src/api/changeWalkthroughs.ts +++ b/frontend/src/api/changeWalkthroughs.ts @@ -12,6 +12,13 @@ function walkthroughUrl(sessionId: string): string { return `${API_BASE_URL}/api/change-walkthroughs/${encodeURIComponent(sessionId)}` } +const HELPER_SESSIONS_URL = `${API_BASE_URL}/api/change-walkthroughs/helper-sessions` + +export async function getWalkthroughHelperSessionIds(): Promise { + const response = await fetchWrapper<{ sessionIds: string[] }>(HELPER_SESSIONS_URL) + return response.sessionIds +} + export async function getChangeWalkthrough( sessionId: string, source: WalkthroughSource, diff --git a/frontend/src/api/repos.ts b/frontend/src/api/repos.ts index 0c529f7c3..541798933 100644 --- a/frontend/src/api/repos.ts +++ b/frontend/src/api/repos.ts @@ -42,6 +42,7 @@ export async function getRepo(id: number): Promise { return fetchWrapper(`${API_BASE_URL}/api/repos/${id}`) } + export function workspaceLabel(workspace: RepoSibling): string { return workspace.currentBranch || workspace.branch || workspace.localPath || 'worktree' } diff --git a/frontend/src/components/file-browser/FileBrowser.tsx b/frontend/src/components/file-browser/FileBrowser.tsx index 835aaae89..eaa8c8d83 100644 --- a/frontend/src/components/file-browser/FileBrowser.tsx +++ b/frontend/src/components/file-browser/FileBrowser.tsx @@ -615,7 +615,7 @@ useEffect(() => { onChange={(e) => setSearchQuery(e.target.value)} className="flex-1" /> - { File Browser - diff --git a/frontend/src/components/file-browser/FileDiffView.tsx b/frontend/src/components/file-browser/FileDiffView.tsx index 481b02cfc..fa29ae260 100644 --- a/frontend/src/components/file-browser/FileDiffView.tsx +++ b/frontend/src/components/file-browser/FileDiffView.tsx @@ -117,6 +117,7 @@ export function FileDiffContent({ size="sm" onClick={onBack} className="h-7 w-7 p-0 mr-1" + aria-label="Back to file list" > diff --git a/frontend/src/components/file-browser/FileOperations.tsx b/frontend/src/components/file-browser/FileOperations.tsx index 577aacab9..f8785e1a0 100644 --- a/frontend/src/components/file-browser/FileOperations.tsx +++ b/frontend/src/components/file-browser/FileOperations.tsx @@ -57,7 +57,7 @@ export const FileOperations = memo(function FileOperations({ onUpload, onCreate {isMobile ? ( - @@ -73,7 +73,7 @@ export const FileOperations = memo(function FileOperations({ onUpload, onCreate ) : ( - @@ -81,7 +81,7 @@ export const FileOperations = memo(function FileOperations({ onUpload, onCreate - diff --git a/frontend/src/components/file-browser/FilePreview.test.tsx b/frontend/src/components/file-browser/FilePreview.test.tsx index 22feb0842..d3c2bf06f 100644 --- a/frontend/src/components/file-browser/FilePreview.test.tsx +++ b/frontend/src/components/file-browser/FilePreview.test.tsx @@ -105,6 +105,14 @@ describe('FilePreview header buttons', () => { expect(external).toHaveAttribute('rel', 'noopener noreferrer') }) + it('shows the binary message and no edit action for non-text files', () => { + render() + + expect(screen.getByText('Binary file - download to view content')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Edit file' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Download file' })).toBeInTheDocument() + }) + it('keeps the success and destructive tints on the edit actions in dark mode', () => { render() diff --git a/frontend/src/components/file-browser/FilePreview.tsx b/frontend/src/components/file-browser/FilePreview.tsx index 70ee4879d..3234ff0fd 100644 --- a/frontend/src/components/file-browser/FilePreview.tsx +++ b/frontend/src/components/file-browser/FilePreview.tsx @@ -426,25 +426,25 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false, )} {isTextFile && viewMode !== 'edit' && ( - )} {showSaveButton && ( - )} {showCancelButton && ( - )} {viewMode !== 'edit' && ( - )} diff --git a/frontend/src/components/file-browser/FileTree.test.tsx b/frontend/src/components/file-browser/FileTree.test.tsx new file mode 100644 index 000000000..94b5f2315 --- /dev/null +++ b/frontend/src/components/file-browser/FileTree.test.tsx @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import type { FileInfo } from '@/types/files' +import { FileTree } from './FileTree' + +const file: FileInfo = { + name: 'notes.txt', + path: 'docker/notes.txt', + isDirectory: false, + size: 5, + mimeType: 'text/plain', + content: '', + lastModified: new Date('2026-07-28T09:12:40Z'), +} + +describe('FileTree row actions', () => { + it('names the per-row actions trigger after the file', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: 'Actions for notes.txt' })).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/file-browser/FileTree.tsx b/frontend/src/components/file-browser/FileTree.tsx index 394e8a215..54404652a 100644 --- a/frontend/src/components/file-browser/FileTree.tsx +++ b/frontend/src/components/file-browser/FileTree.tsx @@ -181,7 +181,8 @@ function TreeNode({ file, level, onFileSelect, onDirectoryClick, selectedFile, o diff --git a/frontend/src/components/message/ExplorationGroup.test.tsx b/frontend/src/components/message/ExplorationGroup.test.tsx new file mode 100644 index 000000000..39add4ba7 --- /dev/null +++ b/frontend/src/components/message/ExplorationGroup.test.tsx @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import type { SessionMessageAssistantTool } from '@opencode-manager/shared/opencode' +import { ExplorationGroup } from './ExplorationGroup' + +const mocks = vi.hoisted(() => ({ + useSettings: vi.fn(), + useToolCallPermission: vi.fn(), +})) + +vi.mock('@/hooks/useSettings', () => ({ + useSettings: mocks.useSettings, +})) + +vi.mock('@/contexts/EventContext', () => ({ + useToolCallPermission: mocks.useToolCallPermission, +})) + +const readTool = (id: string, status: 'completed' | 'running' | 'error'): SessionMessageAssistantTool => { + if (status === 'completed') { + return { + type: 'tool', + id, + name: 'read', + state: { status, input: { filePath: '/repo/a.ts' }, content: [], metadata: {} }, + time: { created: 0, ran: 1, completed: 2 }, + } + } + if (status === 'error') { + return { + type: 'tool', + id, + name: 'read', + state: { status, input: { filePath: '/repo/missing.ts' }, error: { type: 'tool.failed', message: 'File not found' }, metadata: {} }, + time: { created: 0, ran: 1, completed: 2 }, + } + } + return { + type: 'tool', + id, + name: 'read', + state: { status, input: { filePath: '/repo/a.ts' } }, + time: { created: 0, ran: 1 }, + } +} + +describe('ExplorationGroup', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.useSettings.mockReturnValue({ + preferences: { expandToolCalls: false }, + isLoading: false, + updateSettings: vi.fn(), + isUpdating: false, + }) + mocks.useToolCallPermission.mockReturnValue(null) + }) + + it('summarises a successful run and keeps the arrow marker', () => { + render() + + const summary = screen.getByRole('button', { name: /Explored: 2 reads/ }) + expect(summary).toHaveTextContent('→') + expect(summary).not.toHaveTextContent('✗') + }) + + it('marks a run with a failed member and counts it in the label', () => { + render() + + const summary = screen.getByRole('button', { name: /Explored: 2 reads, 1 failed/ }) + const marker = screen.getByText('✗') + expect(marker).toHaveClass('text-destructive') + expect(summary).not.toHaveTextContent('→') + }) + + it('keeps the spinner while an exploration run is still in progress', () => { + const { container } = render( + , + ) + + expect(screen.getByRole('button', { name: /Exploring: 2 reads/ })).toBeInTheDocument() + expect(screen.queryByText('✗')).not.toBeInTheDocument() + expect(container.querySelector('.animate-spin')).not.toBeNull() + }) +}) diff --git a/frontend/src/components/message/ExplorationGroup.tsx b/frontend/src/components/message/ExplorationGroup.tsx index c66886047..e4f9197ec 100644 --- a/frontend/src/components/message/ExplorationGroup.tsx +++ b/frontend/src/components/message/ExplorationGroup.tsx @@ -3,7 +3,7 @@ import { ChevronRight, Loader2 } from 'lucide-react' import type { SessionMessageAssistantTool } from '@opencode-manager/shared/opencode' import { useSettings } from '@/hooks/useSettings' import { useToolCallPermission } from '@/contexts/EventContext' -import { explorationLabel, isExplorationComplete } from '@/lib/explorationGroups' +import { explorationFailureCount, explorationLabel, isExplorationComplete } from '@/lib/explorationGroups' import type { WalkthroughSource } from '@opencode-manager/shared/schemas' import { MessagePart } from './MessagePart' @@ -31,6 +31,7 @@ export const ExplorationGroup = memo(function ExplorationGroup({ parts, ...membe const { preferences } = useSettings() const [expanded, setExpanded] = useState(preferences?.expandToolCalls ?? false) const completed = isExplorationComplete(parts) + const failed = completed && explorationFailureCount(parts) > 0 return (
@@ -40,7 +41,13 @@ export const ExplorationGroup = memo(function ExplorationGroup({ parts, ...membe aria-expanded={expanded} className="group flex w-full min-w-0 items-center gap-2 py-0.5 text-left text-sm text-muted-foreground hover:text-foreground transition-colors" > - {completed ? → : } + {failed ? ( + ✗ + ) : completed ? ( + → + ) : ( + + )} {explorationLabel(parts)} diff --git a/frontend/src/components/message/MessageThread.test.tsx b/frontend/src/components/message/MessageThread.test.tsx index 677ef6d46..4ad2d85ba 100644 --- a/frontend/src/components/message/MessageThread.test.tsx +++ b/frontend/src/components/message/MessageThread.test.tsx @@ -48,6 +48,7 @@ vi.mock('@/hooks/useTTS', () => ({ useTTS: () => ({ speakMessage: vi.fn(), stop: vi.fn(), + unlock: vi.fn(), isEnabled: false, isPlaying: false, isLoading: false, diff --git a/frontend/src/components/message/PromptInput.command.test.tsx b/frontend/src/components/message/PromptInput.command.test.tsx index 3438631be..eb07be053 100644 --- a/frontend/src/components/message/PromptInput.command.test.tsx +++ b/frontend/src/components/message/PromptInput.command.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PromptInput, type PromptInputHandle } from './PromptInput' +import { useComposerDraftStore } from '@/stores/composerDraftStore' import { BUILTIN_COMMANDS } from '@/lib/builtinCommands' import { createCommandActionsMock, stubMatchMedia, createVoiceConversationControllerMock } from '@/test/test-utils' @@ -147,7 +148,6 @@ describe('PromptInput command submission', () => { const defaultProps = { directory: '/test', sessionID: 'test-session', - showScrollButton: false, isSessionActive: false, isStreamingResponse: false, onScrollToBottom: vi.fn(), @@ -174,6 +174,7 @@ describe('PromptInput command submission', () => { beforeEach(() => { vi.clearAllMocks() + useComposerDraftStore.setState({ drafts: {} }) mocks.useVoiceConversation.mockReturnValue(createVoiceConversationControllerMock()) mocks.runCommand.mockResolvedValue(undefined) mocks.switchSessionModel.mockResolvedValue(undefined) @@ -496,7 +497,7 @@ describe('PromptInput command submission', () => { expect(screen.queryByTestId('composer-mode-badge')).not.toBeInTheDocument() }) - it('clears attachments and exits bash mode on Escape while idle', async () => { + it('exits bash mode on Escape while idle and keeps the attachments', async () => { const { container } = renderComponent() const input = await screen.findByPlaceholderText('Send a message...') @@ -510,7 +511,7 @@ describe('PromptInput command submission', () => { expect(input).toHaveValue('') expect(screen.queryByTestId('composer-mode-badge')).not.toBeInTheDocument() - expect(screen.queryByText('pic.png')).not.toBeInTheDocument() + expect(screen.getByText('pic.png')).toBeInTheDocument() }) it('keeps attachments and clears only the text when a built-in command is submitted', async () => { diff --git a/frontend/src/components/message/PromptInput.composer.test.tsx b/frontend/src/components/message/PromptInput.composer.test.tsx index e291a6221..f9fd93b6c 100644 --- a/frontend/src/components/message/PromptInput.composer.test.tsx +++ b/frontend/src/components/message/PromptInput.composer.test.tsx @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, waitFor, within, act } from '@testing-library/react' +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PromptInput } from './PromptInput' +import { useComposerDraftStore } from '@/stores/composerDraftStore' import { useSessionStatus } from '@/stores/sessionStatusStore' import { createCommandActionsMock, createVoiceConversationControllerMock } from '@/test/test-utils' @@ -48,9 +49,9 @@ vi.mock('@/lib/toast', () => ({ })) vi.mock('@/hooks/useOpenCode', () => ({ - useSendPrompt: () => ({ mutate: mocks.useSendPrompt, isPending: false }), + useSendPrompt: () => ({ mutate: mocks.useSendPrompt, mutateAsync: mocks.useSendPrompt, isPending: false }), useInterruptSession: () => ({ mutate: vi.fn() }), - useSendShell: () => ({ mutate: vi.fn(), isPending: false }), + useSendShell: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(() => Promise.resolve()), isPending: false }), useAgents: () => ({ data: [] }), useSkills: () => ({ data: [] }), })) @@ -93,7 +94,6 @@ const createTestQueryClient = () => new QueryClient({ const defaultProps = { directory: '/test', sessionID: 'test-session', - showScrollButton: false, isSessionActive: false, isStreamingResponse: false, onScrollToBottom: vi.fn(), @@ -116,6 +116,8 @@ describe('PromptInput composer layout', () => { beforeEach(() => { vi.clearAllMocks() + useComposerDraftStore.setState({ drafts: {} }) + mocks.useSendPrompt.mockResolvedValue({ id: 'inbox_1' }) mocks.useVoiceConversation.mockReturnValue(createVoiceConversationControllerMock()) mocks.hasPendingPermission.mockReturnValue(false) mocks.useMobile.mockReturnValue(false) @@ -191,12 +193,13 @@ describe('PromptInput composer layout', () => { expect(button.parentElement?.contains(controlsRow())).toBe(false) }) - it('sends the typed prompt from the row button', () => { + it('sends the typed prompt from the row button', async () => { renderComponent() fireEvent.change(promptInput(), { target: { value: 'row send' } }) fireEvent.click(screen.getByTitle('Send')) + await waitFor(() => expect(promptInput()).toHaveValue('')) const [variables] = mocks.useSendPrompt.mock.calls[0] as [{ text: string }] expect(variables.text).toBe('row send') }) @@ -277,55 +280,22 @@ describe('PromptInput composer layout', () => { expect(screen.getByTestId('model-quick-select')).toBeInTheDocument() }) - it('swaps the composer tools button for a scroll-to-bottom arrow while scrolled up', () => { - renderComponent({ showScrollButton: true }) - - const actions = controlsRow().lastElementChild as HTMLElement - expect(within(actions).getByRole('button', { name: 'Scroll to bottom' })).toBeInTheDocument() - expect(screen.queryByTestId('composer-tools-menu')).not.toBeInTheDocument() - expect(within(controlsRow().firstElementChild as HTMLElement).getByTestId('agent-quick-select')).toBeInTheDocument() - expect(within(controlsRow().firstElementChild as HTMLElement).getByTestId('model-quick-select')).toBeInTheDocument() - expect(screen.queryByText('Latest')).not.toBeInTheDocument() - }) - - it('keeps the scroll-to-bottom arrow on the right while the prompt is focused', () => { - renderComponent({ showScrollButton: true }) - - fireEvent.focus(promptInput()) - - const keys = screen.getByRole('toolbar', { name: 'Insert' }) - expect(within(keys).getByRole('button', { name: 'bash !' })).toBeInTheDocument() + it('keeps the composer tools button at the bottom of the transcript', () => { + renderComponent() - const actions = controlsRow().lastElementChild as HTMLElement - expect(within(actions).getByRole('button', { name: 'Scroll to bottom' })).toBeInTheDocument() - expect(screen.queryByTestId('composer-tools-menu')).not.toBeInTheDocument() + expect(screen.getByTestId('composer-tools-menu')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Scroll to bottom' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Jump to latest' })).not.toBeInTheDocument() }) - it('keeps the scroll-to-bottom arrow while the prompt has text', () => { - renderComponent({ showScrollButton: true }) + it('keeps the composer tools button while the prompt has text', () => { + renderComponent() fireEvent.change(promptInput(), { target: { value: 'hello' } }) - expect(screen.getByRole('button', { name: 'Scroll to bottom' })).toBeInTheDocument() - }) - - it('scrolls to the bottom from the swapped arrow and keeps the composer tools box size', () => { - const onScrollToBottom = vi.fn() - renderComponent({ showScrollButton: true, onScrollToBottom }) - - const arrow = screen.getByRole('button', { name: 'Scroll to bottom' }) - expect(arrow).toHaveClass('size-11', 'p-0') - - fireEvent.click(arrow) - - expect(onScrollToBottom).toHaveBeenCalledTimes(1) - }) - - it('keeps the composer tools button while at the bottom of the transcript', () => { - renderComponent({ showScrollButton: false }) - expect(screen.getByTestId('composer-tools-menu')).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Scroll to bottom' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Jump to latest' })).not.toBeInTheDocument() }) it('keeps the voice button and composer tools on the right side of the controls row', () => { @@ -339,24 +309,6 @@ describe('PromptInput composer layout', () => { }) }) - describe('desktop scroll button visibility', () => { - const desktopScrollButton = () => screen.getByTitle('Scroll to bottom') - - it('hides the desktop scroll arrow once the prompt has text', () => { - renderComponent({ showScrollButton: true }) - - fireEvent.change(promptInput(), { target: { value: 'hello' } }) - - expect(desktopScrollButton()).toHaveClass('invisible') - }) - - it('shows the desktop scroll arrow while the prompt is empty', () => { - renderComponent({ showScrollButton: true }) - - expect(desktopScrollButton()).toHaveClass('visible') - }) - }) - describe('mobile voice button', () => { beforeEach(() => { mocks.useMobile.mockReturnValue(true) diff --git a/frontend/src/components/message/PromptInput.draft.test.tsx b/frontend/src/components/message/PromptInput.draft.test.tsx new file mode 100644 index 000000000..207880829 --- /dev/null +++ b/frontend/src/components/message/PromptInput.draft.test.tsx @@ -0,0 +1,319 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { PromptInput } from './PromptInput' +import { useComposerDraftStore } from '@/stores/composerDraftStore' +import { createCommandActionsMock, createVoiceConversationControllerMock } from '@/test/test-utils' + +const mocks = vi.hoisted(() => ({ + useSTT: vi.fn(), + useVoiceConversation: vi.fn(), + useMobile: vi.fn(), + useCommands: vi.fn(), + useCommandHandler: vi.fn(), + useFileSearch: vi.fn(), + useModelSelection: vi.fn(), + useVariants: vi.fn(), + useSessionAgent: vi.fn(), + useUserBash: vi.fn(), + useSessionAgentStore: vi.fn(), + useSendErrorStore: vi.fn(), + useHasBackgroundActivity: vi.fn(), + useSendPrompt: vi.fn(), + useSendShell: vi.fn(), + hasPendingPermission: vi.fn(() => false), + setShowDialog: vi.fn(), +})) + +vi.mock('@/hooks/useSTT', () => ({ + useSTT: mocks.useSTT, + useSttAvailability: () => ({ isEnabled: true, isSupported: true }), +})) +vi.mock('@/hooks/useVoiceConversation', () => ({ useVoiceConversation: mocks.useVoiceConversation })) +vi.mock('@/hooks/useMobile', () => ({ useMobile: mocks.useMobile })) +vi.mock('@/hooks/useCommands', () => ({ useCommands: mocks.useCommands })) +vi.mock('@/hooks/useCommandHandler', () => ({ useCommandHandler: mocks.useCommandHandler })) +vi.mock('@/hooks/useFileSearch', () => ({ useFileSearch: mocks.useFileSearch })) +vi.mock('@/hooks/useModelSelection', () => ({ useModelSelection: mocks.useModelSelection })) +vi.mock('@/hooks/useVariants', () => ({ useVariants: mocks.useVariants })) +vi.mock('@/hooks/useSessionAgent', () => ({ useSessionAgent: mocks.useSessionAgent })) +vi.mock('@/stores/userBashStore', () => ({ useUserBash: mocks.useUserBash })) +vi.mock('@/stores/sessionAgentStore', () => ({ useSessionAgentStore: mocks.useSessionAgentStore })) +vi.mock('@/stores/sendErrorStore', () => ({ useSendErrorStore: mocks.useSendErrorStore })) +vi.mock('@/stores/sessionStatusStore', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useHasBackgroundActivity: mocks.useHasBackgroundActivity } +}) +vi.mock('@/lib/toast', () => ({ + showToast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn(), loading: vi.fn(), promise: vi.fn(), dismiss: vi.fn() }, +})) + +vi.mock('@/hooks/useOpenCode', () => ({ + useSendPrompt: () => ({ mutate: mocks.useSendPrompt, mutateAsync: mocks.useSendPrompt, isPending: false }), + useInterruptSession: () => ({ mutate: vi.fn() }), + useSendShell: () => ({ mutate: mocks.useSendShell, mutateAsync: mocks.useSendShell, isPending: false }), + useAgents: () => ({ data: [] }), + useSkills: () => ({ data: [] }), +})) + +vi.mock('@/hooks/useProviders', () => ({ useProviders: () => ({ data: undefined }) })) + +vi.mock('@/contexts/EventContext', () => ({ + usePermissions: () => ({ + hasForSession: mocks.hasPendingPermission, + setShowDialog: mocks.setShowDialog, + }), +})) + +vi.mock('@/components/agent/AgentQuickSelect', () => ({ + AgentQuickSelect: () =>
, +})) + +vi.mock('@/components/model/ModelQuickSelect', () => ({ + ModelQuickSelect: ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ), +})) + +vi.mock('@/components/session/PermissionModeToggle', () => ({ PermissionModeToggle: () => null })) +vi.mock('@/components/session/SessionSandboxMenu', () => ({ SessionSandboxMenu: () => null })) +vi.mock('./ComposerToolsMenu', () => ({ ComposerToolsMenu: () => null })) +vi.mock('./PromptSuggestions', () => ({ PromptSuggestions: () => null })) +vi.mock('@/hooks/useSessionGoals', () => ({ + useSessionGoal: () => ({ data: undefined }), + useStartSessionGoal: () => ({ mutateAsync: vi.fn(), isPending: false }), +})) +vi.mock('@/hooks/useSessionPermissionMode', () => ({ useSessionPermissionMode: () => ({ data: undefined }) })) + +const createTestQueryClient = () => new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, +}) + +const defaultProps = { + directory: '/test', + sessionID: 'session-a', + isSessionActive: false, + isStreamingResponse: false, + onScrollToBottom: vi.fn(), + commandActions: createCommandActionsMock(), + onPromptChange: vi.fn(), +} + +describe('PromptInput per-session composer draft', () => { + const harness = (sessionID: string, overrides: Partial = {}) => ( + + + + ) + + const renderSession = (sessionID: string, overrides: Partial = {}) => + render(harness(sessionID, overrides)) + + const promptInput = () => screen.getByPlaceholderText(/Send a message\.\.\.|Enter bash command\.\.\./) + + const attachImage = async (container: HTMLElement) => { + const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement + fireEvent.change(fileInput, { target: { files: [new File(['img'], 'pic.png', { type: 'image/png' })] } }) + await screen.findByText('pic.png') + } + + beforeEach(() => { + vi.clearAllMocks() + useComposerDraftStore.setState({ drafts: {} }) + mocks.useSendPrompt.mockResolvedValue({ id: 'inbox_1' }) + mocks.useSendShell.mockResolvedValue({ id: 'shell_1' }) + mocks.useVoiceConversation.mockReturnValue(createVoiceConversationControllerMock()) + mocks.hasPendingPermission.mockReturnValue(false) + mocks.useMobile.mockReturnValue(false) + mocks.useSTT.mockReturnValue({ + isRecording: false, + isProcessing: false, + isSupported: false, + isEnabled: false, + interimTranscript: '', + transcript: '', + startRecording: vi.fn(), + stopRecording: vi.fn(), + abortRecording: vi.fn(), + reset: vi.fn(), + clear: vi.fn(), + }) + mocks.useCommands.mockReturnValue({ searchCommands: () => [], findCommand: () => undefined, recentNames: [] }) + mocks.useCommandHandler.mockReturnValue({ executeCommand: vi.fn() }) + mocks.useFileSearch.mockReturnValue({ files: [] }) + mocks.useModelSelection.mockReturnValue({ + model: null, + modelString: 'test-model', + setModel: vi.fn(), + setActiveAgent: vi.fn(), + recentModels: [], + favoriteModels: [], + toggleFavorite: vi.fn(), + isModelReady: true, + }) + mocks.useVariants.mockReturnValue({ hasVariants: false, currentVariant: null, cycleVariant: vi.fn() }) + mocks.useSessionAgent.mockReturnValue({ agent: 'default' }) + mocks.useUserBash.mockImplementation((selector: (state: unknown) => unknown) => selector({ addUserBashCommand: vi.fn() })) + mocks.useSessionAgentStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ setAgent: vi.fn() })) + mocks.useSendErrorStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ errors: {} })) + mocks.useHasBackgroundActivity.mockReturnValue(false) + }) + + it('keeps the typed draft of each session', () => { + const { rerender } = renderSession('session-a') + + fireEvent.change(promptInput(), { target: { value: 'draft for A' } }) + + rerender(harness('session-b')) + expect(promptInput()).toHaveValue('') + + fireEvent.change(promptInput(), { target: { value: 'draft for B' } }) + + rerender(harness('session-a')) + expect(promptInput()).toHaveValue('draft for A') + + rerender(harness('session-b')) + expect(promptInput()).toHaveValue('draft for B') + }) + + it('keeps bash mode and attachments of each session', async () => { + const { container, rerender } = renderSession('session-a') + + fireEvent.change(promptInput(), { target: { value: '!' } }) + await attachImage(container) + expect(screen.getByTestId('composer-mode-badge')).toHaveTextContent('BASH') + + rerender(harness('session-b')) + expect(promptInput()).toHaveValue('') + expect(screen.queryByTestId('composer-mode-badge')).not.toBeInTheDocument() + expect(screen.queryByText('pic.png')).not.toBeInTheDocument() + + rerender(harness('session-a')) + expect(promptInput()).toHaveValue('!') + expect(screen.getByPlaceholderText('Enter bash command...')).toBeInTheDocument() + expect(screen.getByTestId('composer-mode-badge')).toHaveTextContent('BASH') + expect(screen.getByText('pic.png')).toBeInTheDocument() + }) + + it('sends the restored draft to the session that owns it and clears only that draft', async () => { + const { rerender } = renderSession('session-a') + + fireEvent.change(promptInput(), { target: { value: 'draft for A' } }) + + rerender(harness('session-b')) + fireEvent.change(promptInput(), { target: { value: 'draft for B' } }) + + rerender(harness('session-a')) + fireEvent.click(screen.getByTitle('Send')) + + expect(mocks.useSendPrompt).toHaveBeenCalledWith( + expect.objectContaining({ sessionID: 'session-a', text: 'draft for A' }), + ) + await waitFor(() => expect(promptInput()).toHaveValue('')) + + rerender(harness('session-b')) + expect(promptInput()).toHaveValue('draft for B') + }) + + it('clears a submitted draft after its session unmounted before the send resolved', async () => { + let resolveSend: (value: { id: string }) => void = () => {} + mocks.useSendPrompt.mockImplementation(() => new Promise((resolve) => { + resolveSend = resolve + })) + + const { rerender } = renderSession('session-a') + + fireEvent.change(promptInput(), { target: { value: 'sent while leaving' } }) + fireEvent.click(screen.getByTitle('Send')) + expect(useComposerDraftStore.getState().getDraft('session-a').prompt).toBe('sent while leaving') + + rerender(harness('session-b')) + await act(async () => { + resolveSend({ id: 'inbox_1' }) + }) + + rerender(harness('session-a')) + expect(promptInput()).toHaveValue('') + }) + + it('clears a submitted bash command after its session unmounted before the send resolved', async () => { + let resolveShell: (value: { id: string }) => void = () => {} + mocks.useSendShell.mockImplementation(() => new Promise((resolve) => { + resolveShell = resolve + })) + + const { rerender } = renderSession('session-a') + + fireEvent.change(promptInput(), { target: { value: '!' } }) + fireEvent.change(screen.getByPlaceholderText('Enter bash command...'), { target: { value: '!ls' } }) + fireEvent.click(screen.getByTitle('Send')) + + rerender(harness('session-b')) + await act(async () => { + resolveShell({ id: 'shell_1' }) + }) + + rerender(harness('session-a')) + expect(promptInput()).toHaveValue('') + expect(screen.queryByTestId('composer-mode-badge')).not.toBeInTheDocument() + }) + + it('keeps text typed after the submit when the send resolves', async () => { + let resolveSend: (value: { id: string }) => void = () => {} + mocks.useSendPrompt.mockImplementation(() => new Promise((resolve) => { + resolveSend = resolve + })) + + renderSession('session-a') + + fireEvent.change(promptInput(), { target: { value: 'sent message' } }) + fireEvent.click(screen.getByTitle('Send')) + fireEvent.change(promptInput(), { target: { value: 'sent message plus an edit' } }) + + await act(async () => { + resolveSend({ id: 'inbox_1' }) + }) + + expect(promptInput()).toHaveValue('sent message plus an edit') + expect(useComposerDraftStore.getState().getDraft('session-a').prompt).toBe('sent message plus an edit') + }) + + it('keeps the draft when Escape is pressed while the session is idle', () => { + renderSession('session-a') + + fireEvent.change(promptInput(), { target: { value: 'an important draft' } }) + fireEvent.keyDown(promptInput(), { key: 'Escape' }) + + expect(promptInput()).toHaveValue('an important draft') + }) + + it('reports suggestion list changes instead of clearing on Escape', async () => { + const onSuggestionsOpenChange = vi.fn() + mocks.useCommands.mockReturnValue({ + searchCommands: () => [{ item: { name: 'alpha', description: 'Alpha' }, ranges: [] }], + findCommand: () => undefined, + recentNames: [], + }) + + renderSession('session-a', { onSuggestionsOpenChange }) + expect(onSuggestionsOpenChange).toHaveBeenLastCalledWith(false) + + fireEvent.change(promptInput(), { target: { value: '/al', selectionStart: 3 } }) + + await waitFor(() => expect(onSuggestionsOpenChange).toHaveBeenLastCalledWith(true)) + expect(promptInput()).toHaveValue('/al') + + fireEvent.keyDown(promptInput(), { key: 'Escape' }) + + await waitFor(() => expect(onSuggestionsOpenChange).toHaveBeenLastCalledWith(false)) + expect(promptInput()).toHaveValue('/al') + }) + + it('labels the attachment remove button with the filename', async () => { + const { container } = renderSession('session-a') + + await attachImage(container) + + expect(screen.getByRole('button', { name: 'Remove pic.png' })).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/message/PromptInput.goal.test.tsx b/frontend/src/components/message/PromptInput.goal.test.tsx index da4a8efdb..2f63e25f3 100644 --- a/frontend/src/components/message/PromptInput.goal.test.tsx +++ b/frontend/src/components/message/PromptInput.goal.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PromptInput } from './PromptInput' +import { useComposerDraftStore } from '@/stores/composerDraftStore' import { createCommandActionsMock, stubMatchMedia, createVoiceConversationControllerMock } from '@/test/test-utils' const mocks = vi.hoisted(() => ({ @@ -41,8 +42,8 @@ vi.mock('@/hooks/useOpenCode', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - useSendPrompt: () => ({ mutate: mocks.sendPrompt, isPending: false }), - useSendShell: () => ({ mutate: mocks.sendShell, isPending: false }), + useSendPrompt: () => ({ mutate: mocks.sendPrompt, mutateAsync: mocks.sendPrompt, isPending: false }), + useSendShell: () => ({ mutate: mocks.sendShell, mutateAsync: mocks.sendShell, isPending: false }), useInterruptSession: () => ({ mutate: mocks.interrupt }), useAgents: () => ({ data: mocks.agents }), useSkills: () => ({ data: mocks.skills }), @@ -145,7 +146,6 @@ describe('PromptInput goal mode', () => { const defaultProps = { directory: '/test', sessionID: 'test-session', - showScrollButton: false, isSessionActive: false, isStreamingResponse: false, onScrollToBottom: vi.fn(), @@ -164,10 +164,13 @@ describe('PromptInput goal mode', () => { beforeEach(() => { vi.clearAllMocks() + useComposerDraftStore.setState({ drafts: {} }) mocks.useVoiceConversation.mockReturnValue(createVoiceConversationControllerMock()) mocks.useSessionGoal.mockReturnValue({ data: null }) mocks.useSessionPermissionMode.mockReturnValue({ data: undefined }) mocks.startGoal.mockResolvedValue({ id: 1 }) + mocks.sendPrompt.mockResolvedValue({ id: 'inbox_1' }) + mocks.sendShell.mockResolvedValue({ id: 'shell_1' }) mocks.agents = [] mocks.useMobile.mockReturnValue(false) mocks.useSTT.mockReturnValue({ @@ -265,7 +268,6 @@ describe('PromptInput goal mode', () => { })) await waitFor(() => expect(mocks.sendPrompt).toHaveBeenCalledWith( expect.objectContaining({ delivery: 'queue' }), - expect.anything(), )) expect(mocks.startGoal.mock.invocationCallOrder[0]).toBeLessThan(mocks.sendPrompt.mock.invocationCallOrder[0]) }) diff --git a/frontend/src/components/message/PromptInput.mention.test.tsx b/frontend/src/components/message/PromptInput.mention.test.tsx index fa7ae0ed7..2721ecac2 100644 --- a/frontend/src/components/message/PromptInput.mention.test.tsx +++ b/frontend/src/components/message/PromptInput.mention.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PromptInput } from './PromptInput' +import { useComposerDraftStore } from '@/stores/composerDraftStore' import { createCommandActionsMock, createVoiceConversationControllerMock } from '@/test/test-utils' const mocks = vi.hoisted(() => ({ @@ -9,7 +10,7 @@ const mocks = vi.hoisted(() => ({ switchSessionModel: vi.fn(), switchSessionAgent: vi.fn(), runCommand: vi.fn(), - agents: [] as Array<{ name: string; description?: string }>, + agents: [] as Array<{ name: string; description?: string; mode?: string; hidden?: boolean }>, skills: [] as Array<{ id: string; name: string; description?: string }>, useSTT: vi.fn(), useVoiceConversation: vi.fn(), @@ -24,7 +25,7 @@ const mocks = vi.hoisted(() => ({ useSendErrorStore: vi.fn(), })) -const suggestionProps = vi.hoisted(() => ({ isOpen: false, selectedIndex: 0 })) +const suggestionProps = vi.hoisted(() => ({ isOpen: false, selectedIndex: 0, labels: [] as string[] })) vi.mock('@/api/opencode', async () => { const actual = await vi.importActual('@/api/opencode') @@ -95,10 +96,11 @@ vi.mock('@/hooks/useSessionPermissionMode', () => ({ })) vi.mock('./PromptSuggestions', () => ({ - PromptSuggestions: (props: { isOpen: boolean; selectedIndex: number }) => { + PromptSuggestions: (props: { isOpen: boolean; selectedIndex: number; items: Array<{ label: string }> }) => { if (props.isOpen) { suggestionProps.isOpen = true suggestionProps.selectedIndex = props.selectedIndex + suggestionProps.labels = props.items.map((item) => item.label) } return null }, @@ -133,7 +135,6 @@ describe('PromptInput agent mention submission', () => { const defaultProps = { directory: '/test', sessionID: 'test-session', - showScrollButton: false, isSessionActive: false, isStreamingResponse: false, onScrollToBottom: vi.fn(), @@ -153,6 +154,7 @@ describe('PromptInput agent mention submission', () => { beforeEach(() => { vi.clearAllMocks() + useComposerDraftStore.setState({ drafts: {} }) mocks.useVoiceConversation.mockReturnValue(createVoiceConversationControllerMock()) mocks.sendPrompt.mockResolvedValue({ id: 'inbox_1', @@ -199,6 +201,7 @@ describe('PromptInput agent mention submission', () => { mocks.useSessionAgentStore.mockImplementation((selector: (state: unknown) => unknown) => selector({ setAgent: vi.fn() })) suggestionProps.isOpen = false suggestionProps.selectedIndex = 0 + suggestionProps.labels = [] }) it('sends an @agent mention as an attachment without switching the session agent', async () => { @@ -311,4 +314,23 @@ describe('PromptInput agent mention submission', () => { fireEvent.keyDown(input, { key: 'ArrowDown' }) await waitFor(() => expect(suggestionProps.selectedIndex).toBe(1)) }) + + it('offers only mentionable subagents in the @ list', async () => { + mocks.agents = [ + { name: 'build', description: 'Build', mode: 'primary', hidden: false }, + { name: 'plan', description: 'Plan', mode: 'primary', hidden: false }, + { name: 'explore', description: 'Explore', mode: 'subagent', hidden: false }, + { name: 'general', description: 'General', mode: 'all', hidden: false }, + { name: 'Compaction', description: 'Compaction', mode: 'primary', hidden: true }, + { name: 'Title', description: 'Title', mode: 'primary', hidden: true }, + ] + mocks.skills = [] + renderComponent() + + const input = await screen.findByPlaceholderText('Send a message...') + fireEvent.change(input, { target: { value: '@', selectionStart: 1 } }) + + await waitFor(() => expect(suggestionProps.isOpen).toBe(true)) + expect(suggestionProps.labels).toEqual(['explore', 'general']) + }) }) diff --git a/frontend/src/components/message/PromptInput.stt.test.tsx b/frontend/src/components/message/PromptInput.stt.test.tsx index ae5078ce8..22f4f146a 100644 --- a/frontend/src/components/message/PromptInput.stt.test.tsx +++ b/frontend/src/components/message/PromptInput.stt.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { PromptInput } from './PromptInput' +import { useComposerDraftStore } from '@/stores/composerDraftStore' import { SessionTranscriptContext, type SessionTranscriptSnapshot, @@ -55,7 +56,7 @@ vi.mock('@/hooks/useOpenCode', () => ({ isPending: mocks.sendPromptPending(), }), useInterruptSession: () => ({ mutate: vi.fn(), mutateAsync: vi.fn() }), - useSendShell: () => ({ mutate: vi.fn(), isPending: false }), + useSendShell: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(() => Promise.resolve()), isPending: false }), useAgents: () => ({ data: [] }), useSkills: () => ({ data: [] }), })) @@ -151,7 +152,6 @@ describe('PromptInput voice conversation', () => { const defaultProps = { directory: '/test', sessionID: 'test-session', - showScrollButton: false, isSessionActive: false, isStreamingResponse: false, onScrollToBottom: vi.fn(), @@ -220,9 +220,7 @@ describe('PromptInput voice conversation', () => { beforeEach(() => { vi.clearAllMocks() - mocks.useSendPromptMutate.mockImplementation((_variables, options) => { - options?.onSuccess?.() - }) + useComposerDraftStore.setState({ drafts: {} }) mocks.useSendPromptMutateAsync.mockResolvedValue({ id: 'inbox_1' }) mocks.sendPromptPending.mockReturnValue(false) mocks.useMobile.mockReturnValue(true) @@ -402,7 +400,6 @@ describe('PromptInput submission', () => { const defaultProps = { directory: '/test', sessionID: 'test-session', - showScrollButton: false, isSessionActive: false, isStreamingResponse: false, onScrollToBottom: vi.fn(), @@ -423,9 +420,8 @@ describe('PromptInput submission', () => { beforeEach(() => { vi.clearAllMocks() - mocks.useSendPromptMutate.mockImplementation((_variables, options) => { - options?.onSuccess?.() - }) + useComposerDraftStore.setState({ drafts: {} }) + mocks.useSendPromptMutateAsync.mockResolvedValue({ id: 'inbox_1' }) mocks.sendPromptPending.mockReturnValue(false) mocks.useMobile.mockReturnValue(true) mocks.useSttAvailability.mockReturnValue({ isEnabled: true, isSupported: true }) @@ -436,7 +432,7 @@ describe('PromptInput submission', () => { }) it('keeps submitted text until send succeeds', () => { - mocks.useSendPromptMutate.mockImplementation(() => undefined) + mocks.useSendPromptMutateAsync.mockImplementation(() => new Promise(() => {})) renderComponent() const input = screen.getByPlaceholderText('Send a message...') @@ -459,7 +455,7 @@ describe('PromptInput submission', () => { }) it('clears submitted text once the server confirms it is processing', async () => { - mocks.useSendPromptMutate.mockImplementation(() => undefined) + mocks.useSendPromptMutateAsync.mockImplementation(() => new Promise(() => {})) const view = renderComponent() const input = screen.getByPlaceholderText('Send a message...') @@ -475,7 +471,7 @@ describe('PromptInput submission', () => { }) }) - it('allows queuing a follow-up while a non-queued send is pending', () => { + it('allows queuing a follow-up while a non-queued send is pending', async () => { mocks.sendPromptPending.mockReturnValue(true) renderComponent({ isStreamingResponse: true }) @@ -487,7 +483,8 @@ describe('PromptInput submission', () => { fireEvent.click(queueButton) - const [callArgs] = mocks.useSendPromptMutate.mock.calls[0] as [{ text: string }, unknown] + await waitFor(() => expect(input).toHaveValue('')) + const [callArgs] = mocks.useSendPromptMutateAsync.mock.calls[0] as [{ text: string }] expect(callArgs.text).toBe('follow-up') }) @@ -498,7 +495,7 @@ describe('PromptInput submission', () => { fireEvent.change(input, { target: { value: 'queued message' } }) fireEvent.click(screen.getByTitle('Queue message')) - const [callArgs] = mocks.useSendPromptMutate.mock.calls[0] as [{ text: string }, unknown] + const [callArgs] = mocks.useSendPromptMutateAsync.mock.calls[0] as [{ text: string }] expect(callArgs.text).toBe('queued message') await waitFor(() => { diff --git a/frontend/src/components/message/PromptInput.tsx b/frontend/src/components/message/PromptInput.tsx index e416b0a7e..624964824 100644 --- a/frontend/src/components/message/PromptInput.tsx +++ b/frontend/src/components/message/PromptInput.tsx @@ -11,6 +11,7 @@ import { useSttAvailability } from '@/hooks/useSTT' import { useUserBash } from '@/stores/userBashStore' import { useSessionAgentStore } from '@/stores/sessionAgentStore' import { useSendErrorStore } from '@/stores/sendErrorStore' +import { useComposerDraftStore, type ComposerDraft } from '@/stores/composerDraftStore' import { useRecentCommandsStore } from '@/stores/recentCommandsStore' import { useHasBackgroundActivity } from '@/stores/sessionStatusStore' import { useTouchTapSelect } from '@/hooks/useTouchTapSelect' @@ -19,10 +20,10 @@ import { FINE_POINTER_MEDIA_QUERY, useMediaQuery } from '@/hooks/useMediaQuery' import { usePermissions } from '@/contexts/EventContext' import { isLocalOpenCodeServer, useOpenCodeServerId } from '@/contexts/OpenCodeServerContext' -import { ArrowDown, Upload, X, Mic, Target } from 'lucide-react' +import { Upload, X, Mic, Target } from 'lucide-react' import { SquareFill } from '@/components/ui/square-fill' -import { IconToggleButton, getIconToggleButtonClassName } from '@/components/ui/icon-toggle-button' +import { IconToggleButton } from '@/components/ui/icon-toggle-button' import { PromptSuggestions, type PromptSuggestion } from './PromptSuggestions' import { ComposerActivityBar } from './ComposerActivityBar' @@ -38,7 +39,7 @@ import { detectMentionTrigger, parsePromptToInput, getFilename, getDirectory, ty import { matchText, rankByMatch, type MatchRange } from '@/lib/fuzzyMatch' import { getNextPrimaryAgentId } from '@/lib/primaryAgents' import { getNextFavoriteModel } from '@/lib/favoriteModels' -import { cn, randomId } from '@/lib/utils' +import { randomId } from '@/lib/utils' import { showToast } from '@/lib/toast' import { findModelInfo, isSameModelSelection } from '@opencode-manager/shared/opencode' import { isOpenSessionGoal } from '@opencode-manager/shared/schemas' @@ -65,6 +66,16 @@ const revokeBlobUrls = (attachments: ImageAttachment[]) => { }) } +const runAfterSend = async (send: Promise, onSent: () => void, onFailed?: () => void) => { + try { + await send + } catch { + onFailed?.() + return + } + onSent() +} + const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] const COMMAND_TRIGGER_PATTERN = /(^|\s)\/([a-zA-Z0-9_-]*)$/ @@ -119,7 +130,7 @@ interface PromptInputProps { directory?: string sessionID: string onVoiceConversationActiveChange?: (active: boolean) => void - showScrollButton?: boolean + onSuggestionsOpenChange?: (open: boolean) => void isSessionActive?: boolean isStreamingResponse?: boolean onScrollToBottom: () => void @@ -131,20 +142,21 @@ export const PromptInput = memo(forwardRef( directory, sessionID, onVoiceConversationActiveChange, - showScrollButton, + onSuggestionsOpenChange, isSessionActive = false, isStreamingResponse = false, onScrollToBottom, commandActions, onPromptChange }, ref) { - const [prompt, setPrompt] = useState('') - const [isBashMode, setIsBashMode] = useState(false) + const [restoredDraft] = useState(() => useComposerDraftStore.getState().getDraft(sessionID)) + const [prompt, setPrompt] = useState(restoredDraft.prompt) + const [isBashMode, setIsBashMode] = useState(restoredDraft.isBashMode) const [isGoalArmed, setIsGoalArmed] = useState(false) const [showSuggestions, setShowSuggestions] = useState(false) const [suggestionQuery, setSuggestionQuery] = useState('') - const [attachedFiles, setAttachedFiles] = useState(new Map()) - const [imageAttachments, setImageAttachments] = useState([]) + const [attachedFiles, setAttachedFiles] = useState(restoredDraft.attachedFiles) + const [imageAttachments, setImageAttachments] = useState(restoredDraft.imageAttachments) const [isDragging, setIsDragging] = useState(false) const [isModelPickerOpen, setIsModelPickerOpen] = useState(false) const [showMentionSuggestions, setShowMentionSuggestions] = useState(false) @@ -183,6 +195,18 @@ export const PromptInput = memo(forwardRef( imageAttachmentsRef.current = imageAttachments }, [attachedFiles, imageAttachments, prompt]) + const saveDraft = useComposerDraftStore((state) => state.saveDraft) + const clearDraft = useComposerDraftStore((state) => state.clearDraft) + const clearDraftIfUnchanged = useComposerDraftStore((state) => state.clearDraftIfUnchanged) + + useEffect(() => { + if (prompt === '' && !isBashMode && attachedFiles.size === 0 && imageAttachments.length === 0) { + clearDraft(sessionID) + return + } + saveDraft(sessionID, { prompt, isBashMode, attachedFiles, imageAttachments }) + }, [sessionID, prompt, isBashMode, attachedFiles, imageAttachments, saveDraft, clearDraft]) + const clearSubmittedPrompt = useCallback((submittedPrompt: string, submittedAttachedFiles: Map, submittedImageAttachments: ImageAttachment[], options?: { keepAttachments?: boolean }) => { if ( promptRef.current !== submittedPrompt || @@ -200,6 +224,11 @@ export const PromptInput = memo(forwardRef( } }, []) + const completeSubmittedDraft = useCallback((submitted: ComposerDraft, options?: { keepAttachments?: boolean }) => { + clearDraftIfUnchanged(sessionID, submitted, options) + clearSubmittedPrompt(submitted.prompt, submitted.attachedFiles, submitted.imageAttachments, options) + }, [sessionID, clearDraftIfUnchanged, clearSubmittedPrompt]) + const pendingConfirmClearRef = useRef<{ prompt: string files: Map @@ -246,6 +275,10 @@ export const PromptInput = memo(forwardRef( const { data: agents = [] } = useAgents(directory) const { data: skills = [] } = useSkills(directory) + const mentionableAgents = useMemo( + () => agents.filter((agent) => !agent.hidden && agent.mode !== 'primary'), + [agents], + ) const agentNames = useMemo(() => agents.map((agent) => agent.name), [agents]) const skillIds = useMemo(() => skills.map((skill) => skill.id), [skills]) const failedPrompt = useSendErrorStore((state) => state.errors[sessionID]?.failedPrompt) @@ -269,7 +302,7 @@ export const PromptInput = memo(forwardRef( }, [failedPrompt]) const mentionSuggestions = useMemo((): PromptSuggestion[] => { - const agentMatches = rankByMatch(agents, mentionQuery, { + const agentMatches = rankByMatch(mentionableAgents, mentionQuery, { getName: (agent) => agent.name, getDescription: (agent) => agent.description, }).map(({ item, tier, ranges }) => ({ @@ -321,7 +354,7 @@ export const PromptInput = memo(forwardRef( return [...agentMatches, ...skillMatches, ...fileMatches] .sort((a, b) => a.tier - b.tier) .map(({ suggestion }) => suggestion) - }, [agents, skills, searchResults, mentionQuery]) + }, [mentionableAgents, skills, searchResults, mentionQuery]) const commandSuggestions = useMemo((): PromptSuggestion[] => searchCommands(suggestionQuery).map(({ item, ranges }) => ({ @@ -357,15 +390,13 @@ export const PromptInput = memo(forwardRef( if (isStreamingResponse) { onScrollToBottom() const parsed = parsePromptToInput(prompt, attachedFiles, agentNames, skillIds, imageAttachments) - const submittedPrompt = prompt - const submittedAttachedFiles = attachedFiles - const submittedImageAttachments = imageAttachments + const submitted: ComposerDraft = { prompt, isBashMode, attachedFiles, imageAttachments } if (isGoalArmed && !isBashMode && !isExactCommandPrompt(prompt)) { const goalStarted = await startArmedGoal(parsed.text) if (!goalStarted) return } - sendPrompt.mutate( - { + void runAfterSend( + sendPrompt.mutateAsync({ sessionID, text: parsed.text, files: parsed.files, @@ -374,10 +405,8 @@ export const PromptInput = memo(forwardRef( model: modelRef ?? undefined, agent: currentMode, delivery: 'queue', - }, - { - onSuccess: () => clearSubmittedPrompt(submittedPrompt, submittedAttachedFiles, submittedImageAttachments) - } + }), + () => completeSubmittedDraft(submitted), ) setStoredAgent(sessionID, currentMode) return @@ -388,19 +417,15 @@ export const PromptInput = memo(forwardRef( if (isBashMode) { const command = prompt.startsWith('!') ? prompt.slice(1) : prompt addUserBashCommand(command) - const submittedPrompt = prompt - sendShell.mutate( - { - sessionID, - command, + const submitted: ComposerDraft = { prompt, isBashMode, attachedFiles, imageAttachments } + void runAfterSend( + sendShell.mutateAsync({ sessionID, command }), + () => { + clearDraftIfUnchanged(sessionID, submitted, { keepAttachments: true }) + if (promptRef.current !== submitted.prompt) return + setPrompt('') + setIsBashMode(false) }, - { - onSuccess: () => { - if (promptRef.current !== submittedPrompt) return - setPrompt('') - setIsBashMode(false) - } - } ) setStoredAgent(sessionID, currentMode) return @@ -415,9 +440,7 @@ export const PromptInput = memo(forwardRef( if (command) { recordCommand(command.name) const parsed = parsePromptToInput(commandPrompt.args?.trim() || '', attachedFiles, agentNames, skillIds, imageAttachments) - const submittedPrompt = prompt - const submittedAttachedFiles = attachedFiles - const submittedImageAttachments = imageAttachments + const submitted: ComposerDraft = { prompt, isBashMode, attachedFiles, imageAttachments } void executeCommand(command, { text: parsed.text, @@ -426,22 +449,20 @@ export const PromptInput = memo(forwardRef( skills: parsed.skills, }).then((shouldClear) => { if (!shouldClear) return - const keepAttachments = isBuiltinCommand(command) && (submittedAttachedFiles.size > 0 || submittedImageAttachments.length > 0) + const keepAttachments = isBuiltinCommand(command) && (submitted.attachedFiles.size > 0 || submitted.imageAttachments.length > 0) if (keepAttachments) { - clearSubmittedPrompt(submittedPrompt, submittedAttachedFiles, submittedImageAttachments, { keepAttachments: true }) + completeSubmittedDraft(submitted, { keepAttachments: true }) showToast.info('Built-in commands do not use attachments; they were kept') return } - clearSubmittedPrompt(submittedPrompt, submittedAttachedFiles, submittedImageAttachments) + completeSubmittedDraft(submitted) }) return } } const parsed = parsePromptToInput(prompt, attachedFiles, agentNames, skillIds, imageAttachments) - const submittedPrompt = prompt - const submittedAttachedFiles = attachedFiles - const submittedImageAttachments = imageAttachments + const submitted: ComposerDraft = { prompt, isBashMode, attachedFiles, imageAttachments } if (isGoalArmed) { const goalStarted = await startArmedGoal(parsed.text) @@ -449,13 +470,13 @@ export const PromptInput = memo(forwardRef( } pendingConfirmClearRef.current = { - prompt: submittedPrompt, - files: submittedAttachedFiles, - images: submittedImageAttachments + prompt: submitted.prompt, + files: submitted.attachedFiles, + images: submitted.imageAttachments } - sendPrompt.mutate( - { + void runAfterSend( + sendPrompt.mutateAsync({ sessionID, text: parsed.text, files: parsed.files, @@ -463,16 +484,14 @@ export const PromptInput = memo(forwardRef( skills: parsed.skills, model: modelRef ?? undefined, agent: currentMode, + }), + () => { + pendingConfirmClearRef.current = null + completeSubmittedDraft(submitted) + }, + () => { + pendingConfirmClearRef.current = null }, - { - onSuccess: () => { - pendingConfirmClearRef.current = null - clearSubmittedPrompt(submittedPrompt, submittedAttachedFiles, submittedImageAttachments) - }, - onError: () => { - pendingConfirmClearRef.current = null - } - } ) onScrollToBottom() @@ -735,7 +754,10 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) const handleKeyDown = (e: KeyboardEvent) => { if (isBashMode && e.key === 'Escape') { e.preventDefault() - resetPrompt() + if (prompt === '!' || prompt === '') { + setIsBashMode(false) + setPrompt('') + } return } @@ -815,8 +837,6 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) textareaRef.current?.blur() } handleSubmit() - } else if (e.key === 'Escape' && !isSessionActive) { - resetPrompt() } } @@ -1063,6 +1083,12 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) }) const isCommandListOpen = showSuggestions && commandSuggestions.length > 0 const isMentionListOpen = showMentionSuggestions && mentionSuggestions.length > 0 + const isSuggestionListOpen = isCommandListOpen || isMentionListOpen + + useEffect(() => { + onSuggestionsOpenChange?.(isSuggestionListOpen) + }, [isSuggestionListOpen, onSuggestionsOpenChange]) + const composerMode: ComposerMode | null = isBashMode ? 'bash' : isMentionListOpen @@ -1071,9 +1097,8 @@ if (isIOS && isSecureContext && navigator.clipboard && navigator.clipboard.read) ? 'command' : null const composerModeStyle = composerMode ? COMPOSER_MODE_STYLES[composerMode] : null - const isTakeoverOpen = isMobile && (isCommandListOpen || isMentionListOpen) + const isTakeoverOpen = isMobile && isSuggestionListOpen const showStopButton = isSessionActive - const showMobileScrollButton = isMobile && showScrollButton const hasOpenGoal = isOpenSessionGoal(sessionGoal) const lockedReason = permissionMode?.lockedReason ?? null const goalButtonLabel = lockedReason === 'schedule' @@ -1279,6 +1304,7 @@ return ( {attachment.filename}
- {!isMobile && ( - - )} {showStopButton && ( {showVoiceMic && renderVoiceMic()} - {showMobileScrollButton ? ( - - ) : isMobile && directory && ( + {isMobile && directory && ( { + it('reserves room for the copy button so the first code line is not covered', () => { + const { container } = render() + + const pre = container.querySelector('pre') + expect(pre).not.toBeNull() + expect(pre).toHaveClass('pr-10') + expect(screen.getByTitle('Copy code')).toBeInTheDocument() + expect(pre?.textContent).toContain('const answer = 42') + }) +}) diff --git a/frontend/src/components/message/TextPart.tsx b/frontend/src/components/message/TextPart.tsx index 91f019947..d496102dc 100644 --- a/frontend/src/components/message/TextPart.tsx +++ b/frontend/src/components/message/TextPart.tsx @@ -167,7 +167,7 @@ function CodeBlock({ children, className, ...props }: CodeBlockProps) { return (
-
+      
         {children}
       
diff --git a/frontend/src/components/message/ToolCallPart.test.tsx b/frontend/src/components/message/ToolCallPart.test.tsx index b0f6b6712..55adfff95 100644 --- a/frontend/src/components/message/ToolCallPart.test.tsx +++ b/frontend/src/components/message/ToolCallPart.test.tsx @@ -292,6 +292,78 @@ describe('ToolCallPart background indicator', () => { }) }) +describe('ToolCallPart shell exit status', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.useSettings.mockReturnValue({ + preferences: { expandToolCalls: false }, + isLoading: false, + updateSettings: vi.fn(), + isUpdating: false, + }) + mocks.useToolCallPermission.mockReturnValue(null) + useUserBash.setState({ userBashCommands: new Map() }) + }) + + const completedShell = (metadata: Record): SessionMessageAssistantTool => ({ + type: 'tool', + id: 'call_exit', + name: 'shell', + time: { created: 1, ran: 2, completed: 3 }, + state: { + status: 'completed', + input: { command: 'cat does-not-exist.txt' }, + content: [{ type: 'text', text: 'Exited with code 1' }], + metadata, + }, + }) + + it('marks a completed shell with a non-zero exit as failed', () => { + renderWithProviders() + + expect(screen.getByText('exit 1')).toBeInTheDocument() + expect(screen.queryByText('completed')).not.toBeInTheDocument() + expect(screen.getByText('âś—').parentElement).toHaveClass('text-destructive') + }) + + it('keeps a completed shell with exit code zero successful', () => { + renderWithProviders() + + expect(screen.getByText('completed')).toBeInTheDocument() + expect(screen.getByText('âś“').parentElement).toHaveClass('text-success') + expect(screen.queryByText('âś—')).not.toBeInTheDocument() + }) + + it('keeps a completed shell without an exit code successful', () => { + renderWithProviders() + + expect(screen.getByText('completed')).toBeInTheDocument() + }) + + it('does not read an exit code from a non-shell tool', () => { + renderWithProviders( + , + ) + + expect(screen.getByText('completed')).toBeInTheDocument() + expect(screen.queryByText('âś—')).not.toBeInTheDocument() + }) +}) + describe('ToolCallPart execute renderer', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/frontend/src/components/message/ToolCallPart.tsx b/frontend/src/components/message/ToolCallPart.tsx index 20d0bbec7..388635ba8 100644 --- a/frontend/src/components/message/ToolCallPart.tsx +++ b/frontend/src/components/message/ToolCallPart.tsx @@ -121,6 +121,12 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, direct return typeof provider === 'string' ? provider : undefined })() const sandboxProviderLabel = sandboxProvider !== undefined && sandboxProvider !== 'ocm' ? sandboxProvider : undefined + const foregroundShellExit = + part.name === 'shell' && !isBackgroundShell && part.state.status === 'completed' + ? toolMetadata(part).exit + : undefined + const failedShellExit = + typeof foregroundShellExit === 'number' && foregroundShellExit !== 0 ? foregroundShellExit : undefined const [detailsOpen, setDetailsOpen] = useToolDetailsState(preferences?.expandToolCalls ?? false) useEffect(() => { @@ -132,6 +138,7 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, direct const getStatusColor = () => { if (isBackgroundShell) return backgroundTaskStatusColor(shellStatus) + if (failedShellExit !== undefined) return 'text-destructive' switch (part.state.status) { case 'completed': return 'text-success' @@ -151,6 +158,7 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, direct const getStatusIcon = () => { if (isBackgroundShell) return renderShellLifecycleIcon(shellStatus) + if (failedShellExit !== undefined) return âś— switch (part.state.status) { case 'completed': return âś“ @@ -294,7 +302,9 @@ export const ToolCallPart = memo(function ToolCallPart({ part, messageID, direct ? 'awaiting permission' : isBackgroundShell ? lifecycleLabel(shellStatus) - : part.state.status + : failedShellExit !== undefined + ? `exit ${failedShellExit}` + : part.state.status const commandBlock = (
diff --git a/frontend/src/components/message/VoiceConversationBar.test.tsx b/frontend/src/components/message/VoiceConversationBar.test.tsx index d7d3d2de4..952da0dfe 100644 --- a/frontend/src/components/message/VoiceConversationBar.test.tsx +++ b/frontend/src/components/message/VoiceConversationBar.test.tsx @@ -188,6 +188,22 @@ describe('VoiceConversationBar', () => { expect(screen.getByRole('button', { name: 'Sending' })).toHaveAttribute('aria-busy', 'true') }) + it('shows the failure reason and its fix instead of a bare retry', () => { + renderBar( + buildController({ + phase: 'paused', + label: 'Microphone access denied. Please allow microphone permissions.', + hint: 'Allow microphone access, then tap to retry', + orbLabel: 'Resume voice conversation', + }), + ) + + expect( + screen.getByText('Microphone access denied. Please allow microphone permissions.'), + ).toBeInTheDocument() + expect(screen.getByText('Allow microphone access, then tap to retry')).toBeInTheDocument() + }) + it('shows the notice when there is one and keeps the reserved hint line empty', () => { renderBar( buildController({ diff --git a/frontend/src/components/message/VoiceConversationBar.tsx b/frontend/src/components/message/VoiceConversationBar.tsx index 627b3e4b4..c9f9a6e45 100644 --- a/frontend/src/components/message/VoiceConversationBar.tsx +++ b/frontend/src/components/message/VoiceConversationBar.tsx @@ -74,7 +74,14 @@ export function VoiceConversationBar({ return (
- {label} + + {label} + {liveTranscript ? ( setSearchQuery(event.target.value)} placeholder="Search models..." - className="h-9 border-border bg-muted pl-9 text-sm text-foreground placeholder:text-muted-foreground" + className="h-9 border-border bg-muted pl-9 text-sm text-foreground placeholder:text-placeholder" autoComplete="off" name="model-search" /> diff --git a/frontend/src/components/navigation/MobileTabBar.test.tsx b/frontend/src/components/navigation/MobileTabBar.test.tsx index 018e19b0c..fb6c50891 100644 --- a/frontend/src/components/navigation/MobileTabBar.test.tsx +++ b/frontend/src/components/navigation/MobileTabBar.test.tsx @@ -16,6 +16,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { MobileTabBar } from './MobileTabBar' import { useMobile } from '@/hooks/useMobile' +import { useUnreadScheduleRuns } from '@/hooks/useSchedules' import { useSidebarServerStore } from '@/stores/sidebarServerStore' function LocationSpy() { @@ -27,6 +28,9 @@ describe('MobileTabBar', () => { beforeEach(() => { vi.clearAllMocks() localStorage.clear() + vi.mocked(useUnreadScheduleRuns).mockReturnValue( + { data: undefined } as unknown as ReturnType, + ) useSidebarServerStore.setState({ serverId: LOCAL_OPENCODE_SERVER_ID }) }) @@ -420,4 +424,43 @@ describe('MobileTabBar', () => { expect(container.firstChild).toBeNull() }) + it('shows the unread schedule indicator on More when the Schedules tab is replaced by Git', () => { + vi.mocked(useMobile).mockReturnValue(true) + vi.mocked(useUnreadScheduleRuns).mockReturnValue({ + data: { total: 2, runs: [] }, + } as unknown as ReturnType) + const queryClient = new QueryClient() + + render( + + + + + , + ) + + expect(screen.queryByText('Schedules')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'More' }).querySelector('[data-unread-badge]')).not.toBeNull() + expect(screen.getByRole('button', { name: 'Git' }).querySelector('[data-unread-badge]')).toBeNull() + }) + + it('keeps the unread schedule indicator on Schedules and off More when that tab is shown', () => { + vi.mocked(useMobile).mockReturnValue(true) + vi.mocked(useUnreadScheduleRuns).mockReturnValue({ + data: { total: 1, runs: [] }, + } as unknown as ReturnType) + const queryClient = new QueryClient() + + render( + + + + + , + ) + + expect(screen.getByRole('button', { name: 'Schedules' }).querySelector('[data-unread-badge]')).not.toBeNull() + expect(screen.getByRole('button', { name: 'More' }).querySelector('[data-unread-badge]')).toBeNull() + }) + }) diff --git a/frontend/src/components/navigation/MobileTabBar.tsx b/frontend/src/components/navigation/MobileTabBar.tsx index ea5816147..7fba4cf33 100644 --- a/frontend/src/components/navigation/MobileTabBar.tsx +++ b/frontend/src/components/navigation/MobileTabBar.tsx @@ -159,6 +159,7 @@ function buildGlobalTabs({ pathname, openSheet, open, close, navigate, isInsideR icon: Menu, onClick: () => open('more'), active: openSheet === 'more', + badge: !showsSchedules && unreadScheduleCount > 0, }, ] } @@ -213,7 +214,11 @@ const TabBarRow = memo(function TabBarRow({ tabs }: TabBarRowProps) {
{tab.badge && ( - +
{tab.label} diff --git a/frontend/src/components/navigation/MoreDrawer.test.tsx b/frontend/src/components/navigation/MoreDrawer.test.tsx index 1f4032abc..60f1bc641 100644 --- a/frontend/src/components/navigation/MoreDrawer.test.tsx +++ b/frontend/src/components/navigation/MoreDrawer.test.tsx @@ -229,7 +229,7 @@ describe('MoreDrawer', () => { renderMoreDrawer({ onClose: handleClose }) fireEvent.click(screen.getByText('Settings')) expect(navigateMock).toHaveBeenCalledWith( - { search: 'settings=open&settingsTab=account' }, + { search: 'settings=open' }, { replace: true }, ) }) @@ -332,7 +332,10 @@ describe('MoreDrawer', () => { fireEvent.click(screen.getByText('Repos')) - expect(navigateMock).toHaveBeenCalledWith({ search: 'mobileTab=repos' }, { replace: false }) + expect(navigateMock).toHaveBeenCalledWith( + { search: 'mobileTab=repos' }, + { state: { urlParamHistory: { previousSearch: '?mobileTab=more' } } }, + ) }) it('keeps Home and hides the Repos row outside a session', () => { diff --git a/frontend/src/components/navigation/MoreDrawer.tsx b/frontend/src/components/navigation/MoreDrawer.tsx index 2499d1229..eb865eba9 100644 --- a/frontend/src/components/navigation/MoreDrawer.tsx +++ b/frontend/src/components/navigation/MoreDrawer.tsx @@ -110,7 +110,7 @@ export function MoreDrawer({ isOpen, onClose }: MoreDrawerProps) { updateParams((p) => { p.delete('mobileTab') p.set('settings', 'open') - p.set('settingsTab', 'account') + p.delete('settingsTab') }, 'replace') } diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx index 021aaf119..6d2260c13 100644 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx +++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.test.tsx @@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { RepoQuickSwitchSheet } from './RepoQuickSwitchSheet' import { getRepoSiblings, listRepos } from '@/api/repos' +import { stubMatchMedia } from '@/test/test-utils' import { listProjects, type OpenCodeProject } from '@/api/opencode' import { useMobileTabBar } from '@/hooks/useMobileTabBar' import { ASSISTANT_REPO_ID } from '@opencode-manager/shared/utils' @@ -236,6 +237,7 @@ describe('RepoQuickSwitchSheet', () => { activityMocks.useActivityWindowMinutes.mockReturnValue(30) setActivityGroups({}) setLinkedServers([]) + stubMatchMedia(true) }) it('renders with loading state', async () => { @@ -246,7 +248,7 @@ describe('RepoQuickSwitchSheet', () => { { wrapper: createWrapper() }, ) await waitFor(() => { - expect(screen.getByPlaceholderText('Search projects...')).toBeInTheDocument() + expect(screen.getByPlaceholderText('Search repos...')).toBeInTheDocument() }) }) @@ -294,7 +296,7 @@ describe('RepoQuickSwitchSheet', () => { expect(screen.getByText('repo1')).toBeInTheDocument() expect(screen.getByText('repo2')).toBeInTheDocument() }) - const input = screen.getByPlaceholderText('Search projects...') + const input = screen.getByPlaceholderText('Search repos...') fireEvent.change(input, { target: { value: 'repo1' } }) await waitFor(() => { expect(screen.getByText('repo1')).toBeInTheDocument() @@ -773,7 +775,7 @@ describe('RepoQuickSwitchSheet', () => { render(, { wrapper: createWrapper() }) - const input = await screen.findByPlaceholderText('Search projects...') + const input = await screen.findByPlaceholderText('Search repos...') await waitFor(() => expect(screen.getByText('repo2')).toBeInTheDocument()) expect(screen.queryByLabelText('Clear search')).toBeNull() @@ -927,7 +929,7 @@ describe('RepoQuickSwitchSheet', () => { expect(await screen.findByRole('heading', { name: 'Activity' })).toBeInTheDocument() - fireEvent.change(screen.getByPlaceholderText('Search projects...'), { target: { value: 'repo' } }) + fireEvent.change(screen.getByPlaceholderText('Search repos...'), { target: { value: 'repo' } }) expect(screen.queryByRole('heading', { name: 'Activity' })).toBeNull() expect(screen.queryByText('Session s1')).toBeNull() @@ -1093,4 +1095,34 @@ describe('RepoQuickSwitchSheet', () => { ) expect(handleClose).toHaveBeenCalled() }) + + it('leaves the search input unfocused on touch screens so the keyboard stays closed', async () => { + vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) + stubMatchMedia(false) + + render(, { wrapper: createWrapper() }) + + const input = await screen.findByPlaceholderText('Search repos...') + expect(input).not.toHaveFocus() + }) + + it('focuses the search input when a fine pointer is available', async () => { + vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) + stubMatchMedia(true) + + render(, { wrapper: createWrapper() }) + + const input = await screen.findByPlaceholderText('Search repos...') + await waitFor(() => expect(input).toHaveFocus()) + }) + + it('uses project wording for the search placeholder when a linked server is selected', async () => { + vi.mocked(listRepos).mockResolvedValue([createRepo(1, 'repo1', '/path/to/repo1')]) + vi.mocked(listProjects).mockResolvedValue([createProject('p1', 'Alpha', '/projects/alpha')]) + useSidebarServerStore.setState({ serverId: 'srv_kitchen' }) + + render(, { wrapper: createWrapper() }) + + expect(await screen.findByPlaceholderText('Search projects...')).toBeInTheDocument() + }) }) diff --git a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx b/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx index e1b586625..94d9c827d 100644 --- a/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx +++ b/frontend/src/components/navigation/RepoQuickSwitchSheet.tsx @@ -2,6 +2,7 @@ import { useState, useMemo } from 'react' import { useNavigate, useLocation } from 'react-router-dom' import { LOCAL_OPENCODE_SERVER_ID } from '@opencode-manager/shared/schemas' import { useRefreshOnOpen } from '@/hooks/useRefreshOnOpen' +import { FINE_POINTER_MEDIA_QUERY, useMediaQuery } from '@/hooks/useMediaQuery' import { Input } from '@/components/ui/input' import { BottomSheet, BottomSheetHeader, BottomSheetContent } from '@/components/ui/bottom-sheet' import { Button } from '@/components/ui/button' @@ -32,6 +33,7 @@ export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetPr const [selectedServerId] = useSidebarServerSelection() const isRemote = selectedServerId !== LOCAL_OPENCODE_SERVER_ID const activeRepoId = getActiveRepoId(location.pathname) + const hasFinePointer = useMediaQuery(FINE_POINTER_MEDIA_QUERY) const { repos, isLoading, refetch } = useNavigableRepos(isOpen) @@ -76,10 +78,10 @@ export function RepoQuickSwitchSheet({ isOpen, onClose }: RepoQuickSwitchSheetPr
setSearchQuery(e.target.value)} - autoFocus + autoFocus={hasFinePointer} className="pr-9" autoComplete="off" name="repo-quick-switch" diff --git a/frontend/src/components/navigation/ToolSidePanel.test.tsx b/frontend/src/components/navigation/ToolSidePanel.test.tsx index 221c86ac1..60006f21d 100644 --- a/frontend/src/components/navigation/ToolSidePanel.test.tsx +++ b/frontend/src/components/navigation/ToolSidePanel.test.tsx @@ -36,6 +36,9 @@ vi.mock('@/components/terminal/TerminalPanel', () => ({ TerminalWorkspace: ({ onLastTerminalExited }: { onLastTerminalExited?: () => void }) => (
terminal-tool +
+