Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
d36fa00
test(terminal): capture expected [TerminalView] warnings in lifecycle…
obra Jul 18, 2026
52f340b
test(terminal): capture expected restore_unavailable warning in resum…
obra Jul 18, 2026
0dad81b
test(settings): capture expected [NetworkSettings] warnings in networ…
obra Jul 18, 2026
a06a6a9
test(settings): capture expected settingsThunks warning in editor test
obra Jul 18, 2026
c3750e2
test(settings): capture expected settingsThunks warnings in edge-case…
obra Jul 18, 2026
c86fcef
test(app): capture expected [App] bootstrap warnings in ws-bootstrap …
obra Jul 18, 2026
ec9ab19
test(app): silence perf telemetry console.info in perf-audit test
obra Jul 18, 2026
ed36260
test(panes): capture expected extension-registry warning in PanePicke…
obra Jul 18, 2026
3a0f075
test(logger): allow subject console.warn in client-logger forwarding …
obra Jul 18, 2026
aeda2fa
test(logger): allow subject console.warn/log in console-error-collect…
obra Jul 18, 2026
e6bce12
test(fresh-agent): capture expected malformed-edge warnings in turn-c…
obra Jul 18, 2026
1b535de
test(terminal): capture expected [TerminalCursor] warning in malforme…
obra Jul 18, 2026
2b4923a
test(lib): capture expected warning in open-url non-string test
obra Jul 18, 2026
28094d4
test(perf): silence perf telemetry console.info in perf-logger tests
obra Jul 18, 2026
5e65836
test(store): capture expected [StorageMigration] warnings in migratio…
obra Jul 18, 2026
ecf1237
test(store): capture expected background-refresh warning in sidebar-s…
obra Jul 18, 2026
efb4132
test(hooks): stop jsdom navigation leak by canceling the mailto click
obra Jul 18, 2026
0fa10be
test(setup): fail on unexpected console.warn, mirroring the console.e…
obra Jul 18, 2026
b7c324e
test(e2e): capture expected restore_unavailable warning in codex-refr…
obra Jul 18, 2026
f0a1b2e
test(e2e): complete @/lib/api mock and capture expected bootstrap-fai…
obra Jul 18, 2026
356640e
test(setup): extract the console.error/warn trap into a shared module
obra Jul 18, 2026
28db2f7
test(server): capture the shared logger's console output under test
obra Jul 18, 2026
723fc7b
test(setup): add the server-side log trap
obra Jul 18, 2026
48fc533
fix(bootstrap): only auto-run env bootstrap when launched as the serv…
obra Jul 18, 2026
76aaab9
test(codex): stop echoing the large-forward child's stdout on success
obra Jul 18, 2026
b4ea484
test(server): capture expected config fallback error logs in config-s…
obra Jul 18, 2026
575bb42
test(codex): capture expected sidecar-signal refusal error logs in ru…
obra Jul 18, 2026
fb1b396
test(server): capture expected 'SDK stream ended with error' logs in …
obra Jul 18, 2026
22ae888
test(server): capture expected config fallback error logs in api-edge…
obra Jul 18, 2026
5a92502
test(server): capture expected compact-manifest warning in tabs-regis…
obra Jul 18, 2026
560d140
test(server): capture expected WSL/remote-access error logs in networ…
obra Jul 18, 2026
1f0b000
test(server): capture expected rebind error logs and await the pendin…
obra Jul 18, 2026
832ba42
test(server): capture expected 'Port forward close failed' logs in po…
obra Jul 18, 2026
50519e8
test(server): capture expected 'Failed to get LAN info' log in lan-in…
obra Jul 18, 2026
2e83221
test(server): enable the log trap and silence the sqlite experimental…
obra Jul 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config/vitest/vitest.server.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export default defineConfig({
test: {
environment: 'node',
globalSetup: ['./test/setup/server-global-setup.ts'],
setupFiles: ['./test/setup/server.ts'],
include: [
'test/server/**/*.test.ts',
'test/unit/server/**/*.test.ts',
Expand All @@ -48,6 +49,9 @@ export default defineConfig({
threads: {
singleThread: false,
isolate: true,
// node:sqlite emits a one-time ExperimentalWarning on first load. That is
// runtime infrastructure noise, not test output, so silence it in workers.
execArgv: ['--disable-warning=ExperimentalWarning'],
},
},
fileParallelism: true,
Expand Down
39 changes: 26 additions & 13 deletions server/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,17 +378,30 @@ export function resolveProjectRoot(): string {
}

// --- Auto-run on import ---
const projectRoot = resolveProjectRoot()
const envPath = path.join(projectRoot, '.env')

const result = ensureEnvFile(envPath)

if (result.action === 'created') {
console.log(`[bootstrap] Created .env with auto-generated AUTH_TOKEN`)
console.log(`[bootstrap] Token: ${result.token?.slice(0, 8)}...${result.token?.slice(-8)}`)
} else if (result.action === 'patched') {
console.log(`[bootstrap] Patched .env with new AUTH_TOKEN`)
console.log(`[bootstrap] Token: ${result.token?.slice(0, 8)}...${result.token?.slice(-8)}`)
} else if (result.action === 'error') {
console.error(`[bootstrap] Failed to ensure .env: ${result.error}`)
// Only run when this process was launched as the server entry (server/index.ts
// in dev, dist/server/index.js in production, including the compiled server a
// test spawns). When the module is merely imported for its exports — e.g. a unit
// test, or network-manager pulling in detectLanIps — the import must not mutate
// the real project .env or emit first-run setup output. A plain VITEST check is
// insufficient: a test-spawned real server inherits VITEST yet must bootstrap.
function isRunningAsServerEntry(): boolean {
const entry = process.argv[1] ?? ''
return /(^|[\\/])(dist[\\/])?server[\\/]index\.(js|ts)$/.test(entry)
}

if (isRunningAsServerEntry()) {
const projectRoot = resolveProjectRoot()
const envPath = path.join(projectRoot, '.env')

const result = ensureEnvFile(envPath)

if (result.action === 'created') {
console.log(`[bootstrap] Created .env with auto-generated AUTH_TOKEN`)
console.log(`[bootstrap] Token: ${result.token?.slice(0, 8)}...${result.token?.slice(-8)}`)
} else if (result.action === 'patched') {
console.log(`[bootstrap] Patched .env with new AUTH_TOKEN`)
console.log(`[bootstrap] Token: ${result.token?.slice(0, 8)}...${result.token?.slice(-8)}`)
} else if (result.action === 'error') {
console.error(`[bootstrap] Failed to ensure .env: ${result.error}`)
}
}
6 changes: 5 additions & 1 deletion server/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createRequire } from 'module'
import pino, { type DestinationStream, type LevelWithSilent } from 'pino'
import { createStream, type RotatingFileStream } from 'rotating-file-stream'
import { getFreshellHomeDir } from './freshell-home.js'
import { createConsoleCaptureStream } from './test-log-capture.js'

const env = process.env.NODE_ENV || 'development'
const level = process.env.LOG_LEVEL || 'debug'
Expand Down Expand Up @@ -249,6 +250,7 @@ function createConsoleStream(shouldPrettyPrint: boolean): DestinationStream {
return pretty
}


export function attachDebugStreamWarnings(
stream: RotatingFileStream,
consoleLogger: pino.Logger,
Expand All @@ -270,7 +272,9 @@ export function createLogger(destination?: DestinationStream) {
}

const shouldPrettyPrint = env !== 'production' && env !== 'test'
const consoleStream = createConsoleStream(shouldPrettyPrint)
const consoleStream = isTestRuntime(process.env)
? createConsoleCaptureStream()
: createConsoleStream(shouldPrettyPrint)
const consoleLogger = pino(createPinoOptions({ level: DEFAULT_CONSOLE_LOG_LEVEL }), consoleStream)
const consoleDiagnosticLogger = pino(createPinoOptions({ level: 'warn' }), consoleStream)
const streams: Array<{ stream: DestinationStream; level: LevelWithSilent }> = [
Expand Down
61 changes: 61 additions & 0 deletions server/test-log-capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Writable } from 'node:stream'
import type { DestinationStream } from 'pino'

// Standalone, dependency-light capture buffer for the shared logger's
// console-bound output under test. It is kept separate from logger.ts (and its
// heavy transitive imports such as freshell-home) so the server test setup can
// read captured records WITHOUT importing the logger graph — importing that
// graph at setup-eval time defeats per-test module mocks (e.g. vi.mock('os')).

export interface CapturedLogRecord {
level: number
severity: string
msg?: string
raw: string
}

let capturedConsoleLogRecords: CapturedLogRecord[] = []

export function getCapturedConsoleLogRecords(): readonly CapturedLogRecord[] {
return capturedConsoleLogRecords
}

export function resetCapturedConsoleLogRecords(): void {
capturedConsoleLogRecords = []
}

/**
* Assert-and-consume the expected server error/warn logs a test deliberately
* triggers: returns the matching records and removes them from the buffer so the
* server test harness's afterEach only fails on output the test did NOT expect.
*/
export function consumeCapturedLogRecords(
predicate: (record: CapturedLogRecord) => boolean,
): CapturedLogRecord[] {
const matched = capturedConsoleLogRecords.filter(predicate)
capturedConsoleLogRecords = capturedConsoleLogRecords.filter((record) => !predicate(record))
return matched
}

export function createConsoleCaptureStream(): DestinationStream {
return new Writable({
write(chunk, _encoding, callback) {
const raw = chunk.toString()
for (const line of raw.split('\n')) {
if (!line.trim()) continue
try {
const parsed = JSON.parse(line) as { level?: number; severity?: string; msg?: string }
capturedConsoleLogRecords.push({
level: parsed.level ?? 0,
severity: parsed.severity ?? 'unknown',
msg: parsed.msg,
raw: line,
})
} catch {
capturedConsoleLogRecords.push({ level: 0, severity: 'unparsed', raw: line })
}
}
callback()
},
}) as unknown as DestinationStream
}
46 changes: 29 additions & 17 deletions test/e2e/codex-refresh-rehydrate-flow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -657,25 +657,37 @@ describe('codex refresh rehydrate flow (e2e)', () => {

const baselineMessages = sentMessages().length

act(() => {
wsHarness.emit({
type: 'error',
code: 'INVALID_TERMINAL_ID',
message: 'Unknown terminalId',
terminalId: 'term-codex-live-only',
// The live-only recovery path intentionally logs a restore_unavailable notice.
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
act(() => {
wsHarness.emit({
type: 'error',
code: 'INVALID_TERMINAL_ID',
message: 'Unknown terminalId',
terminalId: 'term-codex-live-only',
})
})
})

await waitFor(() => {
const recreated = sentMessages().slice(baselineMessages).find((msg) => msg?.type === 'terminal.create')
expect(recreated).toMatchObject({
type: 'terminal.create',
mode: 'codex',
recoveryIntent: 'fresh_after_restore_unavailable',
await waitFor(() => {
const recreated = sentMessages().slice(baselineMessages).find((msg) => msg?.type === 'terminal.create')
expect(recreated).toMatchObject({
type: 'terminal.create',
mode: 'codex',
recoveryIntent: 'fresh_after_restore_unavailable',
})
expect(recreated?.restore).toBeUndefined()
expect(recreated?.sessionRef).toBeUndefined()
expect(recreated?.codexDurability).toBeUndefined()
})
expect(recreated?.restore).toBeUndefined()
expect(recreated?.sessionRef).toBeUndefined()
expect(recreated?.codexDurability).toBeUndefined()
})

expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('[TerminalView]'),
'restore_unavailable',
expect.anything(),
)
} finally {
warnSpy.mockRestore()
}
})
})
80 changes: 46 additions & 34 deletions test/e2e/open-tab-session-sidebar-visibility.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,16 +109,20 @@ vi.mock('@/lib/ws-client', () => ({
}),
}))

vi.mock('@/lib/api', () => ({
api: {
get: (url: string) => apiGet(url),
patch: vi.fn().mockResolvedValue({}),
post: vi.fn().mockResolvedValue({}),
},
fetchSidebarSessionsSnapshot: (options?: unknown) => fetchSidebarSessionsSnapshot(options),
searchSessions: (...args: any[]) => searchSessions(...args),
isApiUnauthorizedError: (err: any) => !!err && typeof err === 'object' && err.status === 401,
}))
vi.mock('@/lib/api', async (importActual) => {
const actual = await importActual<typeof import('@/lib/api')>()
return {
...actual,
api: {
get: (url: string) => apiGet(url),
patch: vi.fn().mockResolvedValue({}),
post: vi.fn().mockResolvedValue({}),
},
fetchSidebarSessionsSnapshot: (options?: unknown) => fetchSidebarSessionsSnapshot(options),
searchSessions: (...args: any[]) => searchSessions(...args),
isApiUnauthorizedError: (err: any) => !!err && typeof err === 'object' && err.status === 401,
}
})

function broadcastWs(msg: any) {
for (const handler of Array.from(wsHandlers)) {
Expand Down Expand Up @@ -445,35 +449,43 @@ describe('open tab session sidebar visibility (e2e)', () => {

const store = createStore()

render(
<Provider store={store}>
<App />
</Provider>,
)
// The first bootstrap and sidebar loads fail transiently; the app logs the
// contained failure and recovers on the websocket `ready` refetch.
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
render(
<Provider store={store}>
<App />
</Provider>,
)

await waitFor(() => {
expect(apiGet).toHaveBeenCalledWith('/api/bootstrap')
})
await waitFor(() => {
expect(apiGet).toHaveBeenCalledWith('/api/bootstrap')
})

act(() => {
wsMocks.isReady = true
wsMocks.serverInstanceId = 'srv-recovered'
broadcastWs({
type: 'ready',
timestamp: new Date().toISOString(),
serverInstanceId: 'srv-recovered',
act(() => {
wsMocks.isReady = true
wsMocks.serverInstanceId = 'srv-recovered'
broadcastWs({
type: 'ready',
timestamp: new Date().toISOString(),
serverInstanceId: 'srv-recovered',
})
})
})

await waitFor(() => {
expect(store.getState().connection.availableClis).toEqual({ claude: true, codex: true })
expect(store.getState().settings.settings.sidebar.excludeFirstChatSubstrings).toEqual(['__AUTO__'])
expect(screen.queryByText('Hidden Auto Session')).not.toBeInTheDocument()
expect(screen.getAllByText('Visible Manual Session').length).toBeGreaterThan(0)
})
await waitFor(() => {
expect(store.getState().connection.availableClis).toEqual({ claude: true, codex: true })
expect(store.getState().settings.settings.sidebar.excludeFirstChatSubstrings).toEqual(['__AUTO__'])
expect(screen.queryByText('Hidden Auto Session')).not.toBeInTheDocument()
expect(screen.getAllByText('Visible Manual Session').length).toBeGreaterThan(0)
})

expect(bootstrapCalls).toBe(2)
expect(sidebarCalls).toBe(2)
expect(bootstrapCalls).toBe(2)
expect(sidebarCalls).toBe(2)
expect(warnSpy).toHaveBeenCalledWith('[App]', 'Failed to load bootstrap data', expect.anything())
} finally {
warnSpy.mockRestore()
}
})

it('ignores legacy sessions.updated websocket pushes because the sidebar window is HTTP-owned', async () => {
Expand Down
3 changes: 3 additions & 0 deletions test/integration/server/api-edge-cases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import request from 'supertest'
import fsp from 'fs/promises'
import path from 'path'
import os from 'os'
import { consumeCapturedLogRecords } from '../../../server/test-log-capture.js'

// Use vi.hoisted to ensure mockState is available before vi.mock runs
const mockState = vi.hoisted(() => ({
Expand Down Expand Up @@ -1132,6 +1133,7 @@ describe('API Edge Cases - Security Testing', () => {

// Should fall back to defaults
expect(settings).toEqual(defaultSettings)
expect(consumeCapturedLogRecords((r) => r.msg === 'Config file parse failed; falling back to defaults')).toHaveLength(1)
})

it('recovers from config with wrong version', async () => {
Expand All @@ -1146,6 +1148,7 @@ describe('API Edge Cases - Security Testing', () => {
const settings = await newStore.getSettings()

expect(settings).toEqual(defaultSettings)
expect(consumeCapturedLogRecords((r) => r.msg === 'Config file version mismatch; falling back to defaults')).toHaveLength(1)
})

it('handles missing .freshell directory', async () => {
Expand Down
4 changes: 4 additions & 0 deletions test/integration/server/lan-info-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import express, { type Express } from 'express'
import request from 'supertest'
import { createNetworkRouter } from '../../../server/network-router.js'
import { consumeCapturedLogRecords } from '../../../server/test-log-capture.js'

const TEST_AUTH_TOKEN = 'test-auth-token-12345678'

Expand Down Expand Up @@ -124,6 +125,9 @@ describe('LAN Info API', () => {

expect(res.status).toBe(500)
expect(res.body).toEqual({ error: 'Failed to get LAN info' })
expect(
consumeCapturedLogRecords((r) => r.msg === 'Failed to get LAN info'),
).toHaveLength(1)
})
})
})
Loading