Files
web-terminal/test/locale.test.ts
Yaojia Wang b1bc50ccd1 fix(session): give the PTY a UTF-8 locale so tmux stops mangling CJK
A launchd/Finder-launched server has no LANG/LC_* at all, so tmux — which
decides per client whether the terminal is UTF-8 capable purely from those
variables — fell back to its non-UTF-8 mode and rewrote the byte stream
server-side, before it ever reached xterm:

  - every wide character became `_`  (中文测试 → ________)
  - `✓` / `⏺` / emoji became `_`
  - `═║╔╗` was downgraded to DEC Special Graphics (ESC ( 0), so rounded
    corners vanished and boxes rendered as loose horizontal lines

The `-l` login shell cannot fix this: tmux is the PTY's root process and has
already made its decision by the time the shell sources ~/.zprofile.

withUtf8Locale() fills a locale in on the spawn env, and only when needed —
an existing UTF-8 LC_ALL / LC_CTYPE / LANG (zh_CN.UTF-8, ja_JP.UTF-8, …) is
left untouched; only a missing or non-UTF-8 charset is replaced.

Existing tmux sessions do not need recreating: re-attaching with a UTF-8
client restores correct output (already-scrolled `_` stays mangled).
2026-07-29 08:09:48 +02:00

71 lines
2.6 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { withUtf8Locale, DEFAULT_UTF8_LOCALE } from '../src/session/locale.js';
/**
* locale.ts — UTF-8 locale guarantee for the spawned PTY.
*
* Why this exists: when the server is launched from Finder / launchd (the Mac
* desktop app) the process env has NO `LANG`/`LC_*`. tmux then runs in its
* non-UTF-8 mode and rewrites every wide character to `_` and downgrades
* double-line box drawing (`═║╔╗`) to DEC Special Graphics single lines — so all
* CJK output in the Claude Code TUI is destroyed before it ever reaches xterm.
*/
describe('withUtf8Locale', () => {
it('adds LANG when no locale variable is set at all', () => {
const out = withUtf8Locale({ PATH: '/usr/bin' });
expect(out.LANG).toBe(DEFAULT_UTF8_LOCALE);
expect(out.PATH).toBe('/usr/bin');
});
it('keeps a UTF-8 LANG the user already has', () => {
const out = withUtf8Locale({ LANG: 'zh_CN.UTF-8' });
expect(out.LANG).toBe('zh_CN.UTF-8');
});
it('accepts lowercase / hyphen-less spellings of utf8', () => {
expect(withUtf8Locale({ LANG: 'en_US.utf8' }).LANG).toBe('en_US.utf8');
expect(withUtf8Locale({ LANG: 'C.UTF8' }).LANG).toBe('C.UTF8');
});
it('replaces a non-UTF-8 LANG (C / POSIX / latin1) with the default', () => {
expect(withUtf8Locale({ LANG: 'C' }).LANG).toBe(DEFAULT_UTF8_LOCALE);
expect(withUtf8Locale({ LANG: 'POSIX' }).LANG).toBe(DEFAULT_UTF8_LOCALE);
expect(withUtf8Locale({ LANG: 'en_US.ISO8859-1' }).LANG).toBe(DEFAULT_UTF8_LOCALE);
});
it('respects LC_ALL — it outranks LANG, so a UTF-8 LC_ALL is enough', () => {
const out = withUtf8Locale({ LC_ALL: 'ja_JP.UTF-8', LANG: 'C' });
expect(out.LC_ALL).toBe('ja_JP.UTF-8');
expect(out.LANG).toBe('C'); // untouched — LC_ALL already decides the charset
});
it('respects a UTF-8 LC_CTYPE (macOS Terminal.app sets only this)', () => {
const out = withUtf8Locale({ LC_CTYPE: 'UTF-8' });
expect(out.LC_CTYPE).toBe('UTF-8');
expect(out.LANG).toBeUndefined();
});
it('overrides LANG when LC_CTYPE is present but not UTF-8', () => {
const out = withUtf8Locale({ LC_CTYPE: 'C' });
expect(out.LANG).toBe(DEFAULT_UTF8_LOCALE);
});
it('does not mutate the input env (immutability)', () => {
const input = { PATH: '/usr/bin' };
const out = withUtf8Locale(input);
expect(input).toEqual({ PATH: '/usr/bin' });
expect(out).not.toBe(input);
});
it('ignores an empty-string locale (unset in practice)', () => {
expect(withUtf8Locale({ LANG: '' }).LANG).toBe(DEFAULT_UTF8_LOCALE);
expect(withUtf8Locale({ LC_ALL: '' }).LANG).toBe(DEFAULT_UTF8_LOCALE);
});
});