shell_integration.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. #!/usr/bin/env python
  2. # License: GPLv3 Copyright: 2022, Kovid Goyal <kovid at kovidgoyal.net>
  3. import errno
  4. import os
  5. import shlex
  6. import shutil
  7. import subprocess
  8. import tempfile
  9. import unittest
  10. from contextlib import contextmanager
  11. from functools import lru_cache, partial
  12. from kitty.bash import decode_ansi_c_quoted_string
  13. from kitty.constants import is_macos, kitten_exe, kitty_base_dir, shell_integration_dir, terminfo_dir
  14. from kitty.fast_data_types import CURSOR_BEAM, CURSOR_BLOCK, CURSOR_UNDERLINE
  15. from kitty.shell_integration import setup_bash_env, setup_fish_env, setup_zsh_env
  16. from . import BaseTest
  17. @lru_cache
  18. def bash_ok():
  19. v = shutil.which('bash')
  20. if not v:
  21. return False
  22. o = subprocess.check_output([v, '-c', 'echo "${BASH_VERSINFO[0]}\n${BASH_VERSINFO[4]}"']).decode('utf-8').splitlines()
  23. if not o:
  24. return False
  25. major_ver, relstatus = o[0], o[-1]
  26. return int(major_ver) >= 5 and relstatus == 'release'
  27. def basic_shell_env(home_dir):
  28. ans = {
  29. 'PATH': os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin'),
  30. 'HOME': home_dir,
  31. 'TERM': 'xterm-kitty',
  32. 'TERMINFO': terminfo_dir,
  33. 'KITTY_SHELL_INTEGRATION': 'enabled',
  34. 'KITTY_INSTALLATION_DIR': kitty_base_dir,
  35. 'BASH_SILENCE_DEPRECATION_WARNING': '1',
  36. 'PYTHONDONTWRITEBYTECODE': '1',
  37. 'WEZTERM_SHELL_SKIP_ALL': '1', # dont fail if WezTerm's system wide, default on (why?) shell integration is installed
  38. }
  39. for x in ('USER', 'LANG'):
  40. if os.environ.get(x):
  41. ans[x] = os.environ[x]
  42. return ans
  43. def safe_env_for_running_shell(argv, home_dir, rc='', shell='zsh', with_kitten=False):
  44. ans = basic_shell_env(home_dir)
  45. if shell == 'zsh':
  46. argv.insert(1, '--noglobalrcs')
  47. with open(os.path.join(home_dir, '.zshrc'), 'w') as f:
  48. print(rc + '\nZLE_RPROMPT_INDENT=0', file=f)
  49. setup_zsh_env(ans, argv)
  50. elif shell == 'fish':
  51. conf_dir = os.path.join(home_dir, '.config', 'fish')
  52. os.makedirs(conf_dir, exist_ok=True)
  53. # Avoid generating unneeded completion scripts
  54. os.makedirs(os.path.join(home_dir, '.local', 'share', 'fish', 'generated_completions'), exist_ok=True)
  55. with open(os.path.join(conf_dir, 'config.fish'), 'w') as f:
  56. print(rc + '\n', file=f)
  57. setup_fish_env(ans, argv)
  58. elif shell == 'bash':
  59. bashrc = os.path.join(home_dir, '.bashrc')
  60. if with_kitten:
  61. ans['KITTY_RUNNING_BASH_INTEGRATION_TEST'] = bashrc
  62. else:
  63. setup_bash_env(ans, argv)
  64. ans['KITTY_BASH_INJECT'] += ' posix'
  65. ans['KITTY_BASH_POSIX_ENV'] = bashrc
  66. with open(bashrc, 'w') as f:
  67. # ensure LINES and COLUMNS are kept up to date
  68. print('shopt -s checkwinsize', file=f)
  69. if rc:
  70. print(rc, file=f)
  71. return ans
  72. class ShellIntegration(BaseTest):
  73. with_kitten = False
  74. @contextmanager
  75. def run_shell(self, shell='zsh', rc='', cmd='', setup_env=None):
  76. home_dir = self.home_dir = os.path.realpath(tempfile.mkdtemp())
  77. cmd = cmd or shell
  78. cmd = shlex.split(cmd.format(**locals()))
  79. env = (setup_env or safe_env_for_running_shell)(cmd, home_dir, rc=rc, shell=shell, with_kitten=self.with_kitten)
  80. env['KITTY_RUNNING_SHELL_INTEGRATION_TEST'] = '1'
  81. try:
  82. if self.with_kitten:
  83. cmd = [kitten_exe(), 'run-shell', '--shell', shlex.join(cmd)]
  84. pty = self.create_pty(cmd, cwd=home_dir, env=env, cols=180)
  85. i = 10
  86. while i > 0 and not pty.screen_contents().strip():
  87. pty.process_input_from_child()
  88. i -= 1
  89. yield pty
  90. finally:
  91. while os.path.exists(home_dir):
  92. try:
  93. shutil.rmtree(home_dir)
  94. except OSError as e:
  95. # As of fish 4 fish runs a background daemon generating
  96. # completions.
  97. if e.errno == errno.ENOTEMPTY:
  98. continue
  99. raise
  100. @unittest.skipUnless(shutil.which('zsh'), 'zsh not installed')
  101. def test_zsh_integration(self):
  102. ps1, rps1 = 'left>', '<right'
  103. with self.run_shell(
  104. rc=f'''
  105. PS1="{ps1}"
  106. RPS1="{rps1}"
  107. ''') as pty:
  108. q = ps1 + ' ' * (pty.screen.columns - len(ps1) - len(rps1)) + rps1
  109. try:
  110. pty.wait_till(lambda: pty.screen.cursor.shape == CURSOR_BEAM)
  111. except TimeoutError as e:
  112. raise AssertionError(f'Cursor was not changed to beam. Screen contents: {repr(pty.screen_contents())}') from e
  113. pty.wait_till(lambda: pty.screen_contents() == q)
  114. self.ae(pty.callbacks.titlebuf[-1], '~')
  115. pty.callbacks.clear()
  116. pty.send_cmd_to_child('mkdir test && ls -a')
  117. self.assert_command(pty)
  118. pty.wait_till(lambda: pty.screen_contents().count(rps1) == 2)
  119. self.ae(pty.callbacks.titlebuf[-2:], ['mkdir test && ls -a', '~'])
  120. q = '\n'.join(str(pty.screen.line(i)) for i in range(1, pty.screen.cursor.y))
  121. self.ae(pty.last_cmd_output(), q)
  122. # shrink the screen
  123. pty.write_to_child(r'echo $COLUMNS')
  124. pty.set_window_size(rows=20, columns=40)
  125. q = ps1 + 'echo $COLUMNS' + ' ' * (40 - len(ps1) - len(rps1) - len('echo $COLUMNS')) + rps1
  126. pty.process_input_from_child()
  127. def redrawn():
  128. q = pty.screen_contents()
  129. return '$COLUMNS' in q and q.count(rps1) == 2 and q.count(ps1) == 2
  130. pty.wait_till(redrawn)
  131. self.ae(q, str(pty.screen.line(pty.screen.cursor.y)))
  132. pty.write_to_child('\r')
  133. self.assert_command(pty, 'echo $COLUMNS')
  134. pty.wait_till(lambda: pty.screen_contents().count(rps1) == 3)
  135. self.ae('40', str(pty.screen.line(pty.screen.cursor.y - 1)))
  136. self.ae(q, str(pty.screen.line(pty.screen.cursor.y - 2)))
  137. pty.send_cmd_to_child('clear')
  138. self.assert_command(pty)
  139. q = ps1 + ' ' * (pty.screen.columns - len(ps1) - len(rps1)) + rps1
  140. pty.wait_till(lambda: pty.screen_contents() == q)
  141. pty.wait_till(lambda: pty.screen.cursor.shape == CURSOR_BEAM)
  142. pty.send_cmd_to_child('cat')
  143. pty.wait_till(lambda: pty.screen.cursor.shape == 0)
  144. pty.write_to_child('\x04')
  145. pty.wait_till(lambda: pty.screen.cursor.shape == CURSOR_BEAM)
  146. self.assert_command(pty)
  147. with self.run_shell(rc=f'''PS1="{ps1}"''') as pty:
  148. pty.callbacks.clear()
  149. pty.send_cmd_to_child('printf "%s\x16\a%s" "a" "b"')
  150. pty.wait_till(lambda: 'ab' in pty.screen_contents())
  151. self.assertTrue(pty.screen.last_reported_cwd.decode().endswith(self.home_dir))
  152. self.assertIn('%s^G%s', pty.screen_contents())
  153. q = os.path.join(self.home_dir, 'testing-cwd-notification-🐱')
  154. os.mkdir(q)
  155. pty.send_cmd_to_child(f'cd {q}')
  156. pty.wait_till(lambda: pty.screen.last_reported_cwd.decode().endswith(q))
  157. if not is_macos: # Fails on older macOS like the one used to build kitty binary because of unicode encoding issues
  158. self.assert_command(pty)
  159. with self.run_shell(rc=f'''PS1="{ps1}"\nexport ES="a\n b c\nd"''') as pty:
  160. pty.callbacks.clear()
  161. pty.send_cmd_to_child('clone-in-kitty')
  162. pty.wait_till(lambda: len(pty.callbacks.clone_cmds) == 1)
  163. self.assert_command(pty)
  164. env = pty.callbacks.clone_cmds[0].env
  165. self.ae(env.get('ES'), 'a\n b c\nd')
  166. @unittest.skipUnless(shutil.which('fish'), 'fish not installed')
  167. def test_fish_integration(self):
  168. fish_prompt, right_prompt = 'left>', '<right'
  169. completions_dir = os.path.join(kitty_base_dir, 'shell-integration', 'fish', 'vendor_completions.d')
  170. with self.run_shell(
  171. shell='fish',
  172. rc=f'''
  173. set -g fish_greeting
  174. function fish_prompt; echo -n "{fish_prompt}"; end
  175. function fish_right_prompt; echo -n "{right_prompt}"; end
  176. function _test_comp_path; contains "{completions_dir}" $fish_complete_path; and echo ok; end
  177. function _set_key; set -g fish_key_bindings fish_$argv[1]_key_bindings; end
  178. function _set_status_prompt; function fish_prompt; echo -n "$pipestatus $status {fish_prompt}"; end; end
  179. ''') as pty:
  180. q = fish_prompt + ' ' * (pty.screen.columns - len(fish_prompt) - len(right_prompt)) + right_prompt
  181. pty.wait_till(lambda: pty.screen_contents().count(right_prompt) == 1)
  182. self.ae(pty.screen_contents(), q)
  183. # shell integration dir must not be in XDG_DATA_DIRS
  184. cmd = f'string match -q -- "*{shell_integration_dir}*" "$XDG_DATA_DIRS" || echo "XDD_OK"'
  185. pty.send_cmd_to_child(cmd)
  186. pty.wait_till(lambda: 'XDD_OK' in pty.screen_contents())
  187. # self.assert_command(pty, cmd)
  188. # CWD reporting
  189. self.assertTrue(pty.screen.last_reported_cwd.decode().endswith(self.home_dir))
  190. q = os.path.join(self.home_dir, 'testing-cwd-notification-🐱')
  191. os.mkdir(q)
  192. pty.send_cmd_to_child(f'cd {q}')
  193. # self.assert_command(pty)
  194. pty.wait_till(lambda: pty.screen.last_reported_cwd.decode().endswith(q))
  195. pty.send_cmd_to_child('cd -')
  196. pty.wait_till(lambda: pty.screen.last_reported_cwd.decode().endswith(self.home_dir))
  197. # completion and prompt marking
  198. pty.wait_till(lambda: 'cd -' not in pty.screen_contents().splitlines()[-1])
  199. pty.send_cmd_to_child('clear')
  200. pty.wait_till(lambda: pty.screen_contents().count(right_prompt) == 1)
  201. pty.send_cmd_to_child('_test_comp_path')
  202. # self.assert_command(pty)
  203. pty.wait_till(lambda: pty.screen_contents().count(right_prompt) == 2)
  204. q = '\n'.join(str(pty.screen.line(i)) for i in range(1, pty.screen.cursor.y))
  205. self.ae(q, 'ok')
  206. self.ae(pty.last_cmd_output(), q)
  207. # resize and redraw (fish_handle_reflow)
  208. pty.write_to_child(r'echo $COLUMNS')
  209. pty.set_window_size(rows=20, columns=40)
  210. q = fish_prompt + 'echo $COLUMNS' + ' ' * (40 - len(fish_prompt) - len(right_prompt) - len('echo $COLUMNS')) + right_prompt
  211. pty.process_input_from_child()
  212. def redrawn():
  213. q = pty.screen_contents()
  214. return '$COLUMNS' in q and q.count(right_prompt) == 2 and q.count(fish_prompt) == 2
  215. pty.wait_till(redrawn)
  216. self.ae(q, str(pty.screen.line(pty.screen.cursor.y)))
  217. pty.write_to_child('\r')
  218. pty.wait_till(lambda: pty.screen_contents().count(right_prompt) == 3)
  219. # self.assert_command(pty, 'echo $COLUMNS')
  220. self.ae('40', str(pty.screen.line(pty.screen.cursor.y - 1)))
  221. self.ae(q, str(pty.screen.line(pty.screen.cursor.y - 2)))
  222. # cursor shapes
  223. pty.send_cmd_to_child('clear')
  224. q = fish_prompt + ' ' * (pty.screen.columns - len(fish_prompt) - len(right_prompt)) + right_prompt
  225. pty.wait_till(lambda: pty.screen_contents() == q)
  226. pty.wait_till(lambda: pty.screen.cursor.shape == CURSOR_BEAM)
  227. pty.send_cmd_to_child('echo; cat')
  228. pty.wait_till(lambda: pty.screen.cursor.shape == 0 and pty.screen.cursor.y > 1)
  229. pty.write_to_child('\x04')
  230. pty.wait_till(lambda: pty.screen.cursor.shape == CURSOR_BEAM)
  231. pty.send_cmd_to_child('_set_key vi')
  232. pty.wait_till(lambda: pty.screen_contents().count(right_prompt) == 3)
  233. pty.wait_till(lambda: pty.screen.cursor.shape == CURSOR_BEAM)
  234. pty.write_to_child('\x1b')
  235. pty.wait_till(lambda: pty.screen.cursor.shape == CURSOR_BLOCK)
  236. pty.write_to_child('r')
  237. pty.wait_till(lambda: pty.screen.cursor.shape == CURSOR_UNDERLINE)
  238. pty.write_to_child('\x1b')
  239. pty.wait_till(lambda: pty.screen.cursor.shape == CURSOR_BLOCK)
  240. pty.write_to_child('i')
  241. pty.wait_till(lambda: pty.screen.cursor.shape == CURSOR_BEAM)
  242. pty.send_cmd_to_child('_set_key default')
  243. # self.assert_command(pty)
  244. pty.wait_till(lambda: pty.screen_contents().count(right_prompt) == 4)
  245. pty.wait_till(lambda: pty.screen.cursor.shape == CURSOR_BEAM)
  246. pty.send_cmd_to_child('exit')
  247. def assert_command(self, pty, cmd='', exit_status=0):
  248. cmd = cmd or pty.last_cmd
  249. pty.wait_till(lambda: pty.callbacks.last_cmd_exit_status == 0, timeout_msg=lambda: f'{pty.callbacks.last_cmd_exit_status=} != {exit_status}')
  250. pty.wait_till(lambda: pty.callbacks.last_cmd_cmdline == cmd, timeout_msg=lambda: f'{pty.callbacks.last_cmd_cmdline=!r} != {cmd!r}')
  251. @unittest.skipUnless(bash_ok(), 'bash not installed, too old, or debug build')
  252. def test_bash_integration(self):
  253. ps1 = 'prompt> '
  254. with self.run_shell(
  255. shell='bash', rc=f'''
  256. PS1="{ps1}"
  257. ''') as pty:
  258. try:
  259. pty.wait_till(lambda: pty.screen.cursor.shape == CURSOR_BEAM)
  260. except TimeoutError as e:
  261. raise AssertionError(f'Cursor was not changed to beam. Screen contents: {repr(pty.screen_contents())}') from e
  262. pty.wait_till(lambda: pty.screen_contents().count(ps1) == 1)
  263. self.ae(pty.screen_contents(), ps1)
  264. pty.wait_till(lambda: pty.callbacks.titlebuf[-1:] == ['~'])
  265. self.ae(pty.callbacks.titlebuf[-1], '~')
  266. pty.callbacks.clear()
  267. cmd = 'mkdir test && ls -a'
  268. pty.send_cmd_to_child(cmd)
  269. pty.wait_till(lambda: pty.callbacks.titlebuf[-2:] == [cmd, '~'])
  270. pty.wait_till(lambda: pty.screen_contents().count(ps1) == 2)
  271. self.assert_command(pty, cmd)
  272. q = '\n'.join(str(pty.screen.line(i)) for i in range(1, pty.screen.cursor.y))
  273. self.ae(pty.last_cmd_output(), q)
  274. # shrink the screen
  275. pty.write_to_child(r'echo $COLUMNS')
  276. pty.set_window_size(rows=20, columns=40)
  277. pty.process_input_from_child()
  278. def redrawn():
  279. q = pty.screen_contents()
  280. return '$COLUMNS' in q and q.count(ps1) == 2
  281. pty.wait_till(redrawn)
  282. self.ae(ps1 + 'echo $COLUMNS', str(pty.screen.line(pty.screen.cursor.y)))
  283. pty.write_to_child('\r')
  284. pty.wait_till(lambda: pty.screen_contents().count(ps1) == 3)
  285. self.ae('40', str(pty.screen.line(pty.screen.cursor.y - 1)))
  286. self.ae(ps1 + 'echo $COLUMNS', str(pty.screen.line(pty.screen.cursor.y - 2)))
  287. pty.send_cmd_to_child('clear')
  288. pty.wait_till(lambda: pty.screen_contents() == ps1)
  289. pty.wait_till(lambda: pty.screen.cursor.shape == CURSOR_BEAM)
  290. pty.send_cmd_to_child('cat')
  291. pty.wait_till(lambda: pty.screen.cursor.shape == 0)
  292. pty.write_to_child('\x04')
  293. pty.wait_till(lambda: pty.screen.cursor.shape == CURSOR_BEAM)
  294. pty.write_to_child('\x04')
  295. pty.send_cmd_to_child('clear')
  296. pty.wait_till(lambda: pty.callbacks.titlebuf)
  297. with self.run_shell(shell='bash', rc=f'''PS1="{ps1}"\ndeclare LOCAL_KSI_VAR=1''') as pty:
  298. pty.callbacks.clear()
  299. pty.send_cmd_to_child('declare')
  300. pty.wait_till(lambda: 'LOCAL_KSI_VAR' in pty.screen_contents())
  301. self.assert_command(pty, 'declare')
  302. with self.run_shell(shell='bash', rc=f'''PS1="{ps1}"''') as pty:
  303. pty.callbacks.clear()
  304. pty.send_cmd_to_child('printf "%s\x16\a%s" "a" "b"')
  305. pty.wait_till(lambda: pty.screen_contents().count(ps1) == 2)
  306. self.ae(pty.screen_contents(), f'{ps1}printf "%s^G%s" "a" "b"\nab{ps1}')
  307. self.assertTrue(pty.screen.last_reported_cwd.decode().endswith(self.home_dir))
  308. pty.send_cmd_to_child('echo $HISTFILE')
  309. pty.wait_till(lambda: '.bash_history' in pty.screen_contents().replace('\n', ''))
  310. q = os.path.join(self.home_dir, 'testing-cwd-notification-🐱')
  311. os.mkdir(q)
  312. pty.send_cmd_to_child(f'cd {q}')
  313. pty.wait_till(lambda: pty.screen.last_reported_cwd.decode().endswith(q))
  314. for ps1 in ('line1\\nline\\2\\prompt> ', 'line1\nprompt> ', 'line1\\nprompt> ',):
  315. with self.subTest(ps1=ps1), self.run_shell(
  316. shell='bash', rc=f'''
  317. PS1="{ps1}"
  318. ''') as pty:
  319. ps1 = ps1.replace('\\n', '\n')
  320. pty.wait_till(lambda: pty.screen_contents().count(ps1) == 1)
  321. pty.send_cmd_to_child('echo test')
  322. pty.wait_till(lambda: pty.screen_contents().count(ps1) == 2)
  323. self.ae(pty.screen_contents(), f'{ps1}echo test\ntest\n{ps1}')
  324. pty.write_to_child(r'echo $COLUMNS')
  325. pty.set_window_size(rows=20, columns=40)
  326. pty.process_input_from_child()
  327. pty.wait_till(redrawn)
  328. self.ae(ps1.splitlines()[-1] + 'echo $COLUMNS', str(pty.screen.line(pty.screen.cursor.y)))
  329. pty.write_to_child('\r')
  330. pty.wait_till(lambda: pty.screen_contents().count(ps1) == 3)
  331. self.ae('40', str(pty.screen.line(pty.screen.cursor.y - len(ps1.splitlines()))))
  332. self.ae(ps1.splitlines()[-1] + 'echo $COLUMNS', str(pty.screen.line(pty.screen.cursor.y - 1 - len(ps1.splitlines()))))
  333. self.assert_command(pty, 'echo $COLUMNS')
  334. # test startup file sourcing
  335. def setup_env(excluded, argv, home_dir, rc='', shell='bash', with_kitten=self.with_kitten):
  336. ans = basic_shell_env(home_dir)
  337. if not with_kitten:
  338. setup_bash_env(ans, argv)
  339. for x in {'profile', 'bash.bashrc', '.bash_profile', '.bash_login', '.profile', '.bashrc', 'rcfile'} - excluded:
  340. with open(os.path.join(home_dir, x), 'w') as f:
  341. if x == '.bashrc' and rc:
  342. print(rc, file=f)
  343. else:
  344. print(f'echo [{x}]', file=f)
  345. ans['KITTY_BASH_ETC_LOCATION'] = home_dir
  346. ans['PS1'] = 'PROMPT $ '
  347. return ans
  348. def run_test(argv, *expected, excluded=(), rc='', wait_string='PROMPT $', assert_not_in=False):
  349. with self.subTest(argv=argv), self.run_shell(shell='bash', setup_env=partial(setup_env, set(excluded)), cmd=argv, rc=rc) as pty:
  350. pty.wait_till(lambda: wait_string in pty.screen_contents())
  351. q = pty.screen_contents()
  352. for x in expected:
  353. if assert_not_in:
  354. self.assertNotIn(f'[{x}]', q)
  355. else:
  356. self.assertIn(f'[{x}]', q)
  357. run_test('bash', 'bash.bashrc', '.bashrc')
  358. run_test('bash --rcfile rcfile', 'bash.bashrc', 'rcfile')
  359. run_test('bash --init-file rcfile', 'bash.bashrc', 'rcfile')
  360. run_test('bash --norc')
  361. run_test('bash -l', 'profile', '.bash_profile')
  362. run_test('bash --noprofile -l')
  363. run_test('bash -l', 'profile', '.bash_login', excluded=('.bash_profile',))
  364. run_test('bash -l', 'profile', '.profile', excluded=('.bash_profile', '.bash_login'))
  365. # test argument parsing and non-interactive shell
  366. run_test('bash -s arg1 --rcfile rcfile', 'rcfile', rc='echo ok;read', wait_string='ok', assert_not_in=True)
  367. run_test('bash +O login_shell -ic "echo ok;read"', 'bash.bashrc', excluded=('.bash_profile'), wait_string='ok', assert_not_in=True)
  368. run_test('bash -l .bashrc', 'profile', rc='echo ok;read', wait_string='ok', assert_not_in=True)
  369. run_test('bash -il -- .bashrc', 'profile', rc='echo ok;read', wait_string='ok')
  370. with self.run_shell(shell='bash', setup_env=partial(setup_env, set()), cmd='bash',
  371. rc=f'''PS1="{ps1}"\nexport ES=$'a\n `b` c\n$d'\nexport ES2="XXX" ''') as pty:
  372. pty.callbacks.clear()
  373. pty.send_cmd_to_child('clone-in-kitty')
  374. pty.wait_till(lambda: len(pty.callbacks.clone_cmds) == 1)
  375. env = pty.callbacks.clone_cmds[0].env
  376. self.ae(env.get('ES'), 'a\n `b` c\n$d', f'Screen contents: {pty.screen_contents()!r}')
  377. self.ae(env.get('ES2'), 'XXX', f'Screen contents: {pty.screen_contents()!r}')
  378. for q, e in {
  379. 'a': 'a',
  380. r'a\ab': 'a\ab',
  381. r'a\x7z': 'a\x07z',
  382. r'a\7b': 'a\007b',
  383. r'a\U1f345x': 'a🍅x',
  384. r'a\c b': 'a\0b',
  385. }.items():
  386. self.ae(decode_ansi_c_quoted_string(f"$'{q}'"), e, f'Failed to decode: {q!r}')
  387. class ShellIntegrationWithKitten(ShellIntegration):
  388. with_kitten = True