Repository: vercel/next.js
Pull request: fix: worker logs should still support color

Logs emitted from the static export worker lost their ANSI colors because the worker process starts with piped stdio, so picocolors disables coloring. Most terminal-color helpers treat ANSI styling as a TTY-only feature. At module load it checks process.stdout.isTTY; when a process’ stdout is piped to another stream (as is the case in our export worker), Node marks it as non‑TTY.

To fix, when spawning the worker, this PR will re-check color support using the parent’s TTY/env state and set `FORCE_COLOR=1` only when the parent would have colored output (respecting user opt-outs). 

Changed files:
filename: packages/next/src/lib/worker.test.ts
patch: @@ -0,0 +1,127 @@
+import { PassThrough } from 'stream'
+
+let latestForkEnv: NodeJS.ProcessEnv | undefined
+
+jest.mock('next/dist/compiled/jest-worker', () => {
+  const WorkerMock = jest.fn().mockImplementation((_path, options) => {
+    latestForkEnv = options?.forkOptions?.env
+    return {
+      _workerPool: { _workers: [] },
+      getStdout: () => new PassThrough(),
+      getStderr: () => new PassThrough(),
+      end: jest.fn().mockResolvedValue(undefined),
+      close: jest.fn(),
+    }
+  })
+
+  return { Worker: WorkerMock }
+})
+
+const noopOptions = {
+  debuggerPortOffset: -1,
+  isolatedMemory: false,
+  exposedMethods: [] as string[],
+}
+
+const restoreDescriptors: Array<() => void> = []
+
+const overrideBooleanDescriptor = (
+  target: NodeJS.WriteStream,
+  property: 'isTTY',
+  value: boolean | undefined
+) => {
+  const descriptor = Object.getOwnPropertyDescriptor(target, property)
+  restoreDescriptors.push(() => {
+    if (descriptor) {
+      Object.defineProperty(target, property, descriptor)
+    } else {
+      delete (target as any)[property]
+    }
+  })
+  Object.defineProperty(target, property, {
+    configurable: true,
+    enumerable: false,
+    value,
+    writable: true,
+  })
+}
+
+describe('lib/worker color propagation', () => {
+  const originalEnv = { ...process.env }
+
+  const restoreEnv = () => {
+    for (const key of Object.keys(process.env)) {
+      delete process.env[key]
+    }
+    Object.assign(process.env, originalEnv)
+  }
+
+  afterEach(() => {
+    restoreEnv()
+    while (restoreDescriptors.length > 0) {
+      const restore = restoreDescriptors.pop()
+      restore?.()
+    }
+    jest.resetModules()
+    latestForkEnv = undefined
+  })
+
+  it('enables FORCE_COLOR when the parent supports colors', () => {
+    delete process.env.FORCE_COLOR
+    delete process.env.NO_COLOR
+    delete process.env.CI
+    process.env.TERM = 'xterm-256color'
+
+    overrideBooleanDescriptor(process.stdout, 'isTTY', true)
+    overrideBooleanDescriptor(process.stderr, 'isTTY', false)
+
+    const { Worker } = require('./worker') as typeof import('./worker')
+
+    const worker = new Worker(__filename, noopOptions)
+    worker.close()
+
+    expect(latestForkEnv?.FORCE_COLOR).toBe('1')
+  })
+
+  it('does not overwrite existing FORCE_COLOR', () => {
+    process.env.FORCE_COLOR = '0'
+
+    const { Worker } = require('./worker') as typeof import('./worker')
+
+    const worker = new Worker(__filename, noopOptions)
+    worker.close()
+
+    expect(latestForkEnv?.FORCE_COLOR).toBe('0')
+  })
+
+  it('respects NO_COLOR', () => {
+    delete process.env.FORCE_COLOR
+    process.env.NO_COLOR = '1'
+
+    overrideBooleanDescriptor(process.stdout, 'isTTY', true)
+
+    const { Worker } = require('./worker') as typeof import('./worker')
+
+    const worker = new Worker(__filename, noopOptions)
+    worker.close()
+
+    expect(latestForkEnv?.FORCE_COLOR).toBeUndefined()
+  })
+
+  it('does not force color when not attached to a TTY', () => {
+    delete process.env.FORCE_COLOR
+    delete process.env.CI
+    delete process.env.NO_COLOR
+    process.env.TERM = 'xterm-256color'
+
+    overrideBooleanDescriptor(process.stdout, 'isTTY', false)
+    overrideBooleanDescriptor(process.stderr, 'isTTY', false)
+
+    const { Worker } = require('./worker') as typeof import('./worker')
+
+    const worker = new Worker(__filename, noopOptions)
+    worker.close()
+
+    expect(latestForkEnv?.FORCE_COLOR).toBeUndefined()
+  })
+})

filename: packages/next/src/lib/worker.ts
patch: @@ -103,16 +103,35 @@ export class Worker {
     }
 
     const createWorker = () => {
+      const workerEnv: NodeJS.ProcessEnv = {
+        ...process.env,
+        ...((farmOptions.forkOptions?.env || {}) as any),
+        IS_NEXT_WORKER: 'true',
+        NODE_OPTIONS: formatNodeOptions(nodeOptions),
+      }
+
+      if (workerEnv.FORCE_COLOR === undefined) {
+        // Mirror the enablement heuristic from picocolors (see https://github.com/vercel/next.js/blob/6a40da0345939fe4f7b1ae519b296a86dd103432/packages/next/src/lib/picocolors.ts#L21-L24).
+        // Picocolors snapshots `process.env`/`stdout.isTTY` at module load time, so when the worker
+        // process bootstraps with piped stdio its own check would disable colors. Re-evaluating the
+        // same conditions here lets us opt the worker into color output only when the parent would
+        // have seen colors, while still respecting explicit opt-outs like NO_COLOR.
+        const supportsColors =
+          !workerEnv.NO_COLOR &&
+          !workerEnv.CI &&
+          workerEnv.TERM !== 'dumb' &&
+          (process.stdout.isTTY || process.stderr?.isTTY)
+
+        if (supportsColors) {
+          workerEnv.FORCE_COLOR = '1'
+        }
+      }
+
       this._worker = new JestWorker(workerPath, {
         ...farmOptions,
         forkOptions: {
           ...farmOptions.forkOptions,
-          env: {
-            ...process.env,
-            ...((farmOptions.forkOptions?.env || {}) as any),
-            IS_NEXT_WORKER: 'true',
-            NODE_OPTIONS: formatNodeOptions(nodeOptions),
-          } as any,
+          env: workerEnv,
         },
         maxRetries: 0,
       }) as JestWorker