shared.test.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. import { afterEach, describe, expect, mock, test } from 'bun:test';
  2. type SpawnResult = {
  3. exited: Promise<number>;
  4. stdout: () => Promise<string>;
  5. stderr: () => Promise<string>;
  6. };
  7. const crossSpawnMock = mock(
  8. (_args: string[]): SpawnResult => ({
  9. exited: Promise.resolve(0),
  10. stdout: () => Promise.resolve(''),
  11. stderr: () => Promise.resolve(''),
  12. }),
  13. );
  14. mock.module('../utils/compat', () => ({
  15. crossSpawn: crossSpawnMock,
  16. }));
  17. let importCounter = 0;
  18. async function importShared() {
  19. return import(`./shared?test=${importCounter++}`);
  20. }
  21. describe('gracefulClosePane', () => {
  22. afterEach(() => {
  23. crossSpawnMock.mockReset();
  24. });
  25. test('sends Ctrl+C, waits 250ms, then closes, returning true on exit 0', async () => {
  26. const calls: string[][] = [];
  27. crossSpawnMock.mockImplementation((args: string[]) => {
  28. calls.push(args);
  29. return {
  30. exited: Promise.resolve(0),
  31. stdout: () => Promise.resolve(''),
  32. stderr: () => Promise.resolve(''),
  33. };
  34. });
  35. const { gracefulClosePane } = await importShared();
  36. const ok = await gracefulClosePane('tmux', '%1', {
  37. ctrlC: ['send-keys', '-t', '%1', 'C-c'],
  38. close: ['kill-pane', '-t', '%1'],
  39. });
  40. expect(ok).toBe(true);
  41. expect(calls).toHaveLength(2);
  42. });
  43. test('returns true when acceptExitCode1 and exit code is 1', async () => {
  44. crossSpawnMock.mockImplementation(() => ({
  45. exited: Promise.resolve(1),
  46. stdout: () => Promise.resolve(''),
  47. stderr: () => Promise.resolve(''),
  48. }));
  49. const { gracefulClosePane } = await importShared();
  50. const ok = await gracefulClosePane('zellij', 'terminal_1', {
  51. ctrlC: ['action', 'write', '--pane-id', 'terminal_1', '\u0003'],
  52. close: ['action', 'close-pane', '--pane-id', 'terminal_1'],
  53. acceptExitCode1: true,
  54. });
  55. expect(ok).toBe(true);
  56. });
  57. test('returns false on exit 1 when acceptExitCode1 is false', async () => {
  58. crossSpawnMock.mockImplementation(() => ({
  59. exited: Promise.resolve(1),
  60. stdout: () => Promise.resolve(''),
  61. stderr: () => Promise.resolve(''),
  62. }));
  63. const { gracefulClosePane } = await importShared();
  64. const ok = await gracefulClosePane('tmux', '%1', {
  65. ctrlC: ['send-keys', '-t', '%1', 'C-c'],
  66. close: ['kill-pane', '-t', '%1'],
  67. });
  68. expect(ok).toBe(false);
  69. });
  70. test('returns emptyPaneReturnsTrue when paneId is empty', async () => {
  71. const { gracefulClosePane } = await importShared();
  72. const ok = await gracefulClosePane('zellij', '', {
  73. ctrlC: ['action', 'write', '--pane-id', '', '\u0003'],
  74. close: ['action', 'close-pane', '--pane-id', ''],
  75. emptyPaneReturnsTrue: true,
  76. });
  77. expect(ok).toBe(true);
  78. expect(crossSpawnMock.mock.calls).toHaveLength(0);
  79. });
  80. test('returns false when binary is null', async () => {
  81. const { gracefulClosePane } = await importShared();
  82. const ok = await gracefulClosePane(null, '%1', {
  83. ctrlC: ['x'],
  84. close: ['y'],
  85. });
  86. expect(ok).toBe(false);
  87. });
  88. });
  89. describe('buildOpencodeAttachCommand', () => {
  90. test('quotes an absolute executable containing spaces and apostrophes', async () => {
  91. const { buildOpencodeAttachCommand } = await importShared();
  92. const cmd = buildOpencodeAttachCommand(
  93. 'sess',
  94. 'url',
  95. '/repo',
  96. "/Users/King's Tools/opencode",
  97. );
  98. expect(cmd).toStartWith("'/Users/King'\\''s Tools/opencode' attach");
  99. });
  100. test('resolves host executable with env, process, and bare fallbacks', async () => {
  101. const { resolveHostOpencodeBinary } = await importShared();
  102. expect(
  103. resolveHostOpencodeBinary({
  104. envOverride: '/Users/king/.opencode/bin/opencode',
  105. pathExists: () => true,
  106. execPath: '/opt/homebrew/bin/bun',
  107. argv0: '/opt/homebrew/bin/bun',
  108. }),
  109. ).toBe('/Users/king/.opencode/bin/opencode');
  110. expect(
  111. resolveHostOpencodeBinary({
  112. envOverride: '/missing/opencode',
  113. pathExists: (path) => path === '/Users/king/.opencode/bin/opencode',
  114. execPath: '/Users/king/.opencode/bin/opencode',
  115. }),
  116. ).toBe('/Users/king/.opencode/bin/opencode');
  117. expect(
  118. resolveHostOpencodeBinary({
  119. envOverride: 'relative/opencode',
  120. pathExists: () => true,
  121. execPath: '/opt/homebrew/bin/bun',
  122. argv0: 'bun',
  123. }),
  124. ).toBeNull();
  125. });
  126. test('normalizes Windows backslash paths to forward slashes', async () => {
  127. const original = process.platform;
  128. Object.defineProperty(process, 'platform', {
  129. value: 'win32',
  130. configurable: true,
  131. });
  132. try {
  133. const { buildOpencodeAttachCommand } = await importShared();
  134. const cmd = buildOpencodeAttachCommand(
  135. 'sess',
  136. 'url',
  137. 'C:\\Users\\foo\\repo',
  138. );
  139. expect(cmd).toContain('C:/Users/foo/repo');
  140. } finally {
  141. Object.defineProperty(process, 'platform', {
  142. value: original,
  143. configurable: true,
  144. });
  145. }
  146. });
  147. test('leaves non-Windows paths unchanged', async () => {
  148. const original = process.platform;
  149. Object.defineProperty(process, 'platform', {
  150. value: 'linux',
  151. configurable: true,
  152. });
  153. try {
  154. const { buildOpencodeAttachCommand } = await importShared();
  155. const cmd = buildOpencodeAttachCommand('sess', 'url', '/home/user/repo');
  156. expect(cmd).toContain('/home/user/repo');
  157. } finally {
  158. Object.defineProperty(process, 'platform', {
  159. value: original,
  160. configurable: true,
  161. });
  162. }
  163. });
  164. });
  165. describe('buildShellLaunchArgs', () => {
  166. const cases: Array<{
  167. shell: string;
  168. expected: (cmd: string) => string[];
  169. }> = [
  170. {
  171. shell: '/opt/homebrew/bin/fish',
  172. expected: (cmd) => ['/opt/homebrew/bin/fish', '-c', cmd],
  173. },
  174. {
  175. shell: '/usr/bin/nu',
  176. expected: (cmd) => ['/usr/bin/nu', '-c', cmd],
  177. },
  178. {
  179. shell: '/bin/zsh',
  180. expected: (cmd) => ['/bin/zsh', '-l', '-c', expect.stringContaining(cmd)],
  181. },
  182. {
  183. shell: '/bin/bash',
  184. expected: (cmd) => [
  185. '/bin/bash',
  186. '-l',
  187. '-c',
  188. expect.stringContaining(cmd),
  189. ],
  190. },
  191. {
  192. shell: 'C:\\Windows\\System32\\cmd.exe',
  193. expected: (cmd) => ['C:\\Windows\\System32\\cmd.exe', '/c', cmd],
  194. },
  195. {
  196. shell: '/usr/bin/pwsh',
  197. expected: (cmd) => ['/usr/bin/pwsh', '-NoProfile', '-Command', cmd],
  198. },
  199. {
  200. shell: '/bin/dash',
  201. expected: (cmd) => ['/bin/dash', '-c', cmd],
  202. },
  203. {
  204. shell: '/usr/bin/elvish',
  205. expected: (cmd) => ['/usr/bin/elvish', '-c', cmd],
  206. },
  207. ];
  208. for (const { shell, expected } of cases) {
  209. test(`uses correct args for ${shell}`, async () => {
  210. const original = process.env.SHELL;
  211. process.env.SHELL = shell;
  212. try {
  213. const { buildShellLaunchArgs } = await importShared();
  214. const cmd = 'opencode attach url --session s';
  215. expect(buildShellLaunchArgs(cmd)).toEqual(expected(cmd));
  216. } finally {
  217. process.env.SHELL = original;
  218. }
  219. });
  220. }
  221. test('falls back to /bin/sh when SHELL is unset', async () => {
  222. const original = process.env.SHELL;
  223. delete process.env.SHELL;
  224. try {
  225. const { buildShellLaunchArgs } = await importShared();
  226. const cmd = 'opencode attach url';
  227. expect(buildShellLaunchArgs(cmd)).toEqual(['/bin/sh', '-c', cmd]);
  228. } finally {
  229. process.env.SHELL = original;
  230. }
  231. });
  232. });