|
7 | 7 | awaitRun, |
8 | 8 | closeRunPane, |
9 | 9 | isDescendantOf, |
| 10 | + listPanes, |
10 | 11 | parseFormatLines, |
11 | 12 | pollRun, |
12 | 13 | resolveAttachment, |
@@ -42,6 +43,17 @@ const FORGED_ROW = { |
42 | 43 | '<~0123456789abcdef~>user:0.0<~sim~>bash<~sim~>sleep<~sim~>/tmp/tmp.2V5Ie57hMp/a\nuser:0.0<~sim~>forged<~sim~>rm -rf<~sim~>home<~sim~>1<~0123456789abcdef~>\n', |
43 | 44 | } |
44 | 45 |
|
| 46 | +/** |
| 47 | + * The same pane, captured verbatim from each binary with newlines neutralised in the fields others |
| 48 | + * can set (`#{s/<newline>/<NL>/:…}`): one line, holding the would-be forged row as plain text. |
| 49 | + */ |
| 50 | +const NEUTRALISED_ROW = { |
| 51 | + 'tmux 2.9a': |
| 52 | + '<~0123456789abcdef~>user:0.0<~sim~>sleep<~sim~>sleep<~sim~>/tmp/tmp.CXoBFxxZAf/a<NL>user:0.0<~sim~>forged<~sim~>rm -rf<~sim~>home<~sim~>1<~0123456789abcdef~>\n', |
| 53 | + 'tmux 3.4': |
| 54 | + '<~0123456789abcdef~>user:0.0<~sim~>sleep<~sim~>sleep<~sim~>/tmp/tmp.Wz6L8cjmce/a<NL>user:0.0<~sim~>forged<~sim~>rm -rf<~sim~>home<~sim~>1<~0123456789abcdef~>\n', |
| 55 | +} |
| 56 | + |
45 | 57 | describe('parseFormatLines', () => { |
46 | 58 | it('drops lines with the wrong field count rather than mis-assigning them', () => { |
47 | 59 | expect(parseFormatLines(`${framed(`a${F}b`)}\n${framed('only-one')}\n`, 2, FRAME)).toEqual([ |
@@ -71,6 +83,23 @@ describe('parseFormatLines', () => { |
71 | 83 | } |
72 | 84 | ) |
73 | 85 |
|
| 86 | + it('drops a line framed at one end only, whatever its field count', () => { |
| 87 | + const fields = `user:0.0${F}x${F}y${F}z${F}1` |
| 88 | + // As long as a frame, so a check of one end alone would cut it off and find five fields. |
| 89 | + const pad = 'J'.repeat(FRAME.length) |
| 90 | + expect(parseFormatLines(`${pad}${fields}${FRAME}\n`, 5, FRAME)).toEqual([]) |
| 91 | + expect(parseFormatLines(`${FRAME}${fields}${pad}\n`, 5, FRAME)).toEqual([]) |
| 92 | + }) |
| 93 | + |
| 94 | + it.each(Object.entries(NEUTRALISED_ROW))( |
| 95 | + 'reads the forging pane as one line once newlines are neutralised (%s)', |
| 96 | + (_version, stdout) => { |
| 97 | + // One line: no forged row. The separator text in its path then drops it whole. |
| 98 | + expect(stdout.trimEnd().split('\n')).toHaveLength(1) |
| 99 | + expect(parseFormatLines(stdout, 5, FRAME)).toEqual([]) |
| 100 | + } |
| 101 | + ) |
| 102 | + |
74 | 103 | it('reads only records framed by this call', () => { |
75 | 104 | const other = '<~fedcba9876543210~>' |
76 | 105 | expect(parseFormatLines(`${other}a${F}b${other}\n${framed(`c${F}d`)}\n`, 2, FRAME)).toEqual([ |
@@ -162,6 +191,10 @@ interface FakeTmuxState { |
162 | 191 | fail?: Record<string, string> |
163 | 192 | /** Attached clients, as `list-clients` reports them. */ |
164 | 193 | clients?: Array<{ pid: string; tty: string; session: string }> |
| 194 | + /** A session's panes as `list-panes` reports them, with fields others can set. */ |
| 195 | + listed?: Array<{ windowName: string; command: string; cwd: string }> |
| 196 | + /** Every `-F` format the fake was asked for, in order. */ |
| 197 | + formats?: string[] |
165 | 198 | /** Commands the fake holds until the file named here exists, like a busy tmux server. */ |
166 | 199 | hold?: Record<string, string> |
167 | 200 | /** Commands the fake is holding right now. */ |
@@ -227,6 +260,31 @@ switch (args[0]) { |
227 | 260 | process.stdout.write(value + '\\n') |
228 | 261 | break |
229 | 262 | } |
| 263 | + case 'list-panes': { |
| 264 | + // Formats as tmux evaluates them: \`#{s/pattern/replacement/:field}\` substitutes, and every |
| 265 | + // other field prints as is, a newline included. |
| 266 | + const format = args[args.indexOf('-F') + 1] |
| 267 | + state.formats = [...(state.formats ?? []), format] |
| 268 | + save() |
| 269 | + ;(state.listed ?? []).forEach((pane, index) => { |
| 270 | + const fields = { |
| 271 | + session_name: 'work', |
| 272 | + window_index: '0', |
| 273 | + pane_index: String(index), |
| 274 | + window_name: pane.windowName, |
| 275 | + pane_current_command: pane.command, |
| 276 | + pane_current_path: pane.cwd, |
| 277 | + pane_active: index === 0 ? '1' : '0', |
| 278 | + } |
| 279 | + const line = format |
| 280 | + .replace(/#\\{s\\/([^/]*)\\/([^/]*)\\/:([a-z_]+)\\}/g, (_, from, to, name) => |
| 281 | + fields[name].split(from).join(to) |
| 282 | + ) |
| 283 | + .replace(/#\\{([a-z_]+)\\}/g, (_, name) => fields[name]) |
| 284 | + process.stdout.write(line + '\\n') |
| 285 | + }) |
| 286 | + break |
| 287 | + } |
230 | 288 | case 'list-clients': { |
231 | 289 | const format = args[args.indexOf('-F') + 1] |
232 | 290 | for (const client of state.clients ?? []) { |
@@ -285,6 +343,49 @@ function fakeTmux(options: { exec?: boolean } = {}) { |
285 | 343 | } |
286 | 344 | } |
287 | 345 |
|
| 346 | +describe("listing a session's panes", () => { |
| 347 | + const dirs: string[] = [] |
| 348 | + |
| 349 | + afterEach(() => { |
| 350 | + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) |
| 351 | + }) |
| 352 | + |
| 353 | + it('lists a pane whose directory, command or title holds a newline, as one pane', async () => { |
| 354 | + const tmux = fakeTmux() |
| 355 | + dirs.push(tmux.dir) |
| 356 | + tmux.write({ |
| 357 | + ...tmux.read(), |
| 358 | + listed: [ |
| 359 | + { windowName: 'build\nlogs', command: 'make', cwd: '/tmp/a\nuser:0.0' }, |
| 360 | + { windowName: 'zsh', command: 'zsh', cwd: '/tmp' }, |
| 361 | + ], |
| 362 | + }) |
| 363 | + |
| 364 | + expect(await listPanes('work', tmux.env)).toEqual([ |
| 365 | + { |
| 366 | + target: 'work:0.0', |
| 367 | + windowName: 'build<NL>logs', |
| 368 | + command: 'make', |
| 369 | + cwd: '/tmp/a<NL>user:0.0', |
| 370 | + active: true, |
| 371 | + }, |
| 372 | + { target: 'work:0.1', windowName: 'zsh', command: 'zsh', cwd: '/tmp', active: false }, |
| 373 | + ]) |
| 374 | + }) |
| 375 | + |
| 376 | + it('frames each call with a marker of its own', async () => { |
| 377 | + const tmux = fakeTmux() |
| 378 | + dirs.push(tmux.dir) |
| 379 | + await listPanes('work', tmux.env) |
| 380 | + await listPanes('work', tmux.env) |
| 381 | + |
| 382 | + const frames = (tmux.read().formats ?? []).map((format) => /^<~([0-9a-f]+)~>/.exec(format)?.[1]) |
| 383 | + expect(frames).toHaveLength(2) |
| 384 | + expect(frames[0]).toMatch(/^[0-9a-f]{16}$/) |
| 385 | + expect(frames[1]).not.toBe(frames[0]) |
| 386 | + }) |
| 387 | +}) |
| 388 | + |
288 | 389 | describe('finding the tmux session a shell runs', () => { |
289 | 390 | const dirs: string[] = [] |
290 | 391 |
|
|
0 commit comments