glyphs.test.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. /**
  2. * Glyph fallback / Unicode-support detection.
  3. *
  4. * Pinned because the matrix is small and the consequence of regression
  5. * is highly visible: shimmer-worker output on Windows mojibakes when
  6. * UTF-8 glyphs are written via `fs.writeSync` (see #168). The detection
  7. * + ASCII fallback is the contract that prevents this.
  8. */
  9. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  10. import {
  11. supportsUnicode,
  12. supportsUnicodeRawWrites,
  13. getGlyphs,
  14. UNICODE_GLYPHS,
  15. ASCII_GLYPHS,
  16. _resetGlyphsCache,
  17. } from '../src/ui/glyphs';
  18. function withEnv(patch: Record<string, string | undefined>, fn: () => void): void {
  19. const saved: Record<string, string | undefined> = {};
  20. const savedPlatform = process.platform;
  21. for (const key of Object.keys(patch)) {
  22. saved[key] = process.env[key];
  23. if (patch[key] === undefined) delete process.env[key];
  24. else process.env[key] = patch[key];
  25. }
  26. _resetGlyphsCache();
  27. try {
  28. fn();
  29. } finally {
  30. for (const key of Object.keys(saved)) {
  31. if (saved[key] === undefined) delete process.env[key];
  32. else process.env[key] = saved[key];
  33. }
  34. Object.defineProperty(process, 'platform', { value: savedPlatform });
  35. _resetGlyphsCache();
  36. }
  37. }
  38. function setPlatform(value: NodeJS.Platform): void {
  39. Object.defineProperty(process, 'platform', { value });
  40. }
  41. describe('supportsUnicode', () => {
  42. let originalPlatform: NodeJS.Platform;
  43. beforeEach(() => {
  44. originalPlatform = process.platform;
  45. _resetGlyphsCache();
  46. });
  47. afterEach(() => {
  48. Object.defineProperty(process, 'platform', { value: originalPlatform });
  49. _resetGlyphsCache();
  50. });
  51. /** Clears every signal the Windows detection reads, so cases are explicit. */
  52. const NO_TERMINAL_SIGNALS: Record<string, string | undefined> = {
  53. CODEGRAPH_ASCII: undefined,
  54. CODEGRAPH_UNICODE: undefined,
  55. TERM: undefined,
  56. CI: undefined,
  57. WT_SESSION: undefined,
  58. TERMINUS_SUBLIME: undefined,
  59. ConEmuTask: undefined,
  60. TERM_PROGRAM: undefined,
  61. TERMINAL_EMULATOR: undefined,
  62. };
  63. it('returns false on Windows in an unrecognized console (mojibake-prone legacy conhost)', () => {
  64. withEnv(NO_TERMINAL_SIGNALS, () => {
  65. setPlatform('win32');
  66. expect(supportsUnicode()).toBe(false);
  67. });
  68. });
  69. // The Windows allowlist must match @clack/prompts' bundled detection —
  70. // wherever clack draws its Unicode frame, our rails must be Unicode too,
  71. // or `codegraph index` mixes `|` and `│` in one output block (#398).
  72. it.each([
  73. ['Windows Terminal', { WT_SESSION: 'a-guid' }],
  74. ['VS Code terminal', { TERM_PROGRAM: 'vscode' }],
  75. ['ConEmu/Cmder', { ConEmuTask: '{cmd::Cmder}' }],
  76. ['Alacritty', { TERM: 'alacritty' }],
  77. ['xterm-256color', { TERM: 'xterm-256color' }],
  78. ['JetBrains terminal', { TERMINAL_EMULATOR: 'JetBrains-JediTerm' }],
  79. ['CI', { CI: 'true' }],
  80. ])('returns true on Windows in %s (agrees with clack, #398)', (_name, envPatch) => {
  81. withEnv({ ...NO_TERMINAL_SIGNALS, ...envPatch }, () => {
  82. setPlatform('win32');
  83. expect(supportsUnicode()).toBe(true);
  84. });
  85. });
  86. it('CODEGRAPH_ASCII=1 still wins inside Windows Terminal (escape hatch)', () => {
  87. withEnv({ ...NO_TERMINAL_SIGNALS, CODEGRAPH_ASCII: '1', WT_SESSION: 'a-guid' }, () => {
  88. setPlatform('win32');
  89. expect(supportsUnicode()).toBe(false);
  90. });
  91. });
  92. // The raw fs.writeSync(1) path (shimmer animation frames) decodes through
  93. // the console CODEPAGE on Windows, so it must stay ASCII there even in
  94. // terminals where the codepage-immune main-thread path goes Unicode (#168).
  95. describe('supportsUnicodeRawWrites', () => {
  96. it('stays ASCII on Windows even inside Windows Terminal / vscode / CI', () => {
  97. for (const envPatch of [{ WT_SESSION: 'a-guid' }, { TERM_PROGRAM: 'vscode' }, { CI: 'true' }]) {
  98. withEnv({ ...NO_TERMINAL_SIGNALS, ...envPatch }, () => {
  99. setPlatform('win32');
  100. expect(supportsUnicodeRawWrites()).toBe(false);
  101. expect(supportsUnicode()).toBe(true); // main-thread path DOES go Unicode there
  102. });
  103. }
  104. });
  105. it('CODEGRAPH_UNICODE=1 opts the raw path in on Windows', () => {
  106. withEnv({ ...NO_TERMINAL_SIGNALS, CODEGRAPH_UNICODE: '1' }, () => {
  107. setPlatform('win32');
  108. expect(supportsUnicodeRawWrites()).toBe(true);
  109. });
  110. });
  111. it('matches supportsUnicode() off Windows (Unicode on macOS, ASCII on TERM=linux)', () => {
  112. withEnv({ ...NO_TERMINAL_SIGNALS }, () => {
  113. setPlatform('darwin');
  114. expect(supportsUnicodeRawWrites()).toBe(true);
  115. });
  116. withEnv({ ...NO_TERMINAL_SIGNALS, TERM: 'linux' }, () => {
  117. setPlatform('linux');
  118. expect(supportsUnicodeRawWrites()).toBe(false);
  119. });
  120. });
  121. });
  122. it('returns true on macOS by default', () => {
  123. withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => {
  124. setPlatform('darwin');
  125. expect(supportsUnicode()).toBe(true);
  126. });
  127. });
  128. it('returns true on Linux by default', () => {
  129. withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: undefined }, () => {
  130. setPlatform('linux');
  131. expect(supportsUnicode()).toBe(true);
  132. });
  133. });
  134. it('returns false on Linux kernel console (TERM=linux)', () => {
  135. withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined, TERM: 'linux' }, () => {
  136. setPlatform('linux');
  137. expect(supportsUnicode()).toBe(false);
  138. });
  139. });
  140. it('respects CODEGRAPH_UNICODE=1 on Windows (opt-in escape hatch)', () => {
  141. withEnv({ CODEGRAPH_UNICODE: '1', CODEGRAPH_ASCII: undefined }, () => {
  142. setPlatform('win32');
  143. expect(supportsUnicode()).toBe(true);
  144. });
  145. });
  146. it('respects CODEGRAPH_ASCII=1 on macOS (opt-out escape hatch)', () => {
  147. withEnv({ CODEGRAPH_ASCII: '1', CODEGRAPH_UNICODE: undefined }, () => {
  148. setPlatform('darwin');
  149. expect(supportsUnicode()).toBe(false);
  150. });
  151. });
  152. it('CODEGRAPH_ASCII takes precedence over CODEGRAPH_UNICODE', () => {
  153. withEnv({ CODEGRAPH_ASCII: '1', CODEGRAPH_UNICODE: '1' }, () => {
  154. setPlatform('darwin');
  155. expect(supportsUnicode()).toBe(false);
  156. });
  157. });
  158. });
  159. describe('getGlyphs', () => {
  160. let originalPlatform: NodeJS.Platform;
  161. beforeEach(() => {
  162. originalPlatform = process.platform;
  163. _resetGlyphsCache();
  164. });
  165. afterEach(() => {
  166. Object.defineProperty(process, 'platform', { value: originalPlatform });
  167. _resetGlyphsCache();
  168. });
  169. it('returns ASCII glyphs on Windows in an unrecognized console', () => {
  170. withEnv(
  171. {
  172. CODEGRAPH_ASCII: undefined,
  173. CODEGRAPH_UNICODE: undefined,
  174. TERM: undefined,
  175. CI: undefined,
  176. WT_SESSION: undefined,
  177. TERMINUS_SUBLIME: undefined,
  178. ConEmuTask: undefined,
  179. TERM_PROGRAM: undefined,
  180. TERMINAL_EMULATOR: undefined,
  181. },
  182. () => {
  183. setPlatform('win32');
  184. const g = getGlyphs();
  185. expect(g).toBe(ASCII_GLYPHS);
  186. expect(g.ok).toBe('[OK]');
  187. expect(g.rail).toBe('|');
  188. expect(g.phaseDone).toBe('*');
  189. expect(g.dash).toBe('-');
  190. });
  191. });
  192. it('returns Unicode glyphs on macOS', () => {
  193. withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => {
  194. setPlatform('darwin');
  195. const g = getGlyphs();
  196. expect(g).toBe(UNICODE_GLYPHS);
  197. expect(g.ok).toBe('✓');
  198. expect(g.rail).toBe('│');
  199. expect(g.phaseDone).toBe('◆');
  200. expect(g.dash).toBe('—');
  201. });
  202. });
  203. it('caches the result so repeated calls return the same object', () => {
  204. withEnv({ CODEGRAPH_ASCII: undefined, CODEGRAPH_UNICODE: undefined }, () => {
  205. setPlatform('darwin');
  206. expect(getGlyphs()).toBe(getGlyphs());
  207. });
  208. });
  209. });
  210. describe('Glyph sets', () => {
  211. it('ASCII and Unicode sets cover the same keys', () => {
  212. expect(Object.keys(ASCII_GLYPHS).sort()).toEqual(Object.keys(UNICODE_GLYPHS).sort());
  213. });
  214. it('ASCII glyphs are all 7-bit ASCII', () => {
  215. for (const [key, value] of Object.entries(ASCII_GLYPHS)) {
  216. const flat = Array.isArray(value) ? value.join('') : value;
  217. for (let i = 0; i < flat.length; i++) {
  218. const codepoint = flat.charCodeAt(i);
  219. expect(codepoint, `ASCII_GLYPHS.${key} contains non-ASCII char U+${codepoint.toString(16).toUpperCase().padStart(4, '0')}`).toBeLessThan(128);
  220. }
  221. }
  222. });
  223. it('ASCII spinner has the same frame count as the Unicode spinner', () => {
  224. expect(ASCII_GLYPHS.spinner.length).toBe(UNICODE_GLYPHS.spinner.length);
  225. });
  226. });