linux-scope.spec.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529
  1. import { spawn, spawnSync } from 'node:child_process'
  2. import { existsSync } from 'node:fs'
  3. import { dirname } from 'node:path'
  4. import { describe, expect, it, vi } from 'vitest'
  5. import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
  6. import {
  7. launchLinuxScope,
  8. prepareLinuxTerminalScope,
  9. probeLinuxRunner,
  10. probeLinuxScope,
  11. probeLinuxUserManager,
  12. } from '../src/linux-scope.ts'
  13. import { spawnRunnerInvocation } from '../src/runner-launch.ts'
  14. function spec(argv: string[]): SubprocessSpawnSpec {
  15. return {
  16. argv,
  17. cwd: process.cwd(),
  18. stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } },
  19. graceMs: 100,
  20. env: { LITERAL_VALUE: '$HOME ${UNCHANGED}' },
  21. }
  22. }
  23. function asyncQuery(runSync: typeof spawnSync) {
  24. return async (command: string, args: readonly string[]) => {
  25. const result = runSync(command, [...args], { encoding: 'utf8', timeout: 5_000 })
  26. return {
  27. status: result.status,
  28. stdout: typeof result.stdout === 'string' ? result.stdout : '',
  29. stderr: typeof result.stderr === 'string' ? result.stderr : '',
  30. ...result.error === undefined ? {} : { error: result.error },
  31. }
  32. }
  33. }
  34. describe.skipIf(process.platform === 'win32')('Linux systemd scope adapter', () => {
  35. it('separates the live manager, stable scope, and ordinary-runner probes', () => {
  36. const secretName = 'DSH_SCOPE_TEST_TOKEN'
  37. const previousSecret = process.env[secretName]
  38. process.env[secretName] = 'secret'
  39. const calls: string[][] = []
  40. const environments: Array<NodeJS.ProcessEnv | undefined> = []
  41. const runSync = vi.fn((command: string, args: readonly string[], options?: { env?: NodeJS.ProcessEnv }) => {
  42. calls.push([command, ...args])
  43. environments.push(options?.env)
  44. return { status: 0, error: undefined }
  45. }) as unknown as typeof spawnSync
  46. const runnerInvocation: [string, ...string[]] = ['node-runtime', 'runner-entry.js']
  47. try {
  48. expect(probeLinuxUserManager({
  49. spawnSync: runSync,
  50. systemctl: 'systemctl',
  51. })).toBe(true)
  52. expect(probeLinuxRunner({
  53. spawnSync: runSync,
  54. runnerInvocation,
  55. })).toBe(true)
  56. expect(probeLinuxScope({
  57. spawnSync: runSync,
  58. systemdRun: 'systemd-run',
  59. systemctl: 'systemctl',
  60. })).toBe(true)
  61. expect(calls[0]).toEqual(['systemctl', '--user', 'show-environment'])
  62. expect(calls[1]).toEqual([...runnerInvocation, '--mode', 'probe-node'])
  63. expect(calls[2]).toContain('--expand-environment=no')
  64. expect(calls[2]).not.toContain('--pipe')
  65. expect(calls[2]).not.toContain('--wait')
  66. const unitArg = calls[2]?.find(arg => arg.startsWith('--unit='))
  67. if (unitArg === undefined) throw new Error('scope probe did not publish its unit')
  68. const separator = calls[2]?.indexOf('--') ?? -1
  69. expect(calls[2]?.slice(separator + 1)).toEqual([
  70. 'systemctl',
  71. '--user',
  72. 'show',
  73. `${unitArg.slice('--unit='.length)}.scope`,
  74. '--property=ActiveState',
  75. '--value',
  76. ])
  77. expect(environments[0]?.LC_ALL).toBe('C')
  78. for (const environment of environments) expect(environment).not.toHaveProperty(secretName)
  79. } finally {
  80. if (previousSecret === undefined) Reflect.deleteProperty(process.env, secretName)
  81. else process.env[secretName] = previousSecret
  82. }
  83. const oldSystemd = vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync
  84. expect(probeLinuxScope({ spawnSync: oldSystemd })).toBe(false)
  85. expect(probeLinuxScope({
  86. spawnSync: vi.fn(() => ({ status: 0, error: new Error('scope failed') })) as unknown as typeof spawnSync,
  87. })).toBe(false)
  88. const failedRunner = vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync
  89. expect(probeLinuxRunner({
  90. spawnSync: failedRunner,
  91. runnerInvocation: ['node-runtime', 'runner-entry.js'],
  92. })).toBe(false)
  93. expect(failedRunner).toHaveBeenCalledOnce()
  94. expect(probeLinuxRunner({
  95. spawnSync: vi.fn(() => ({ status: 0, error: new Error('runner failed') })) as unknown as typeof spawnSync,
  96. runnerInvocation: ['node-runtime', 'runner-entry.js'],
  97. })).toBe(false)
  98. const managerError = new Error('missing user manager')
  99. expect(probeLinuxUserManager({
  100. spawnSync: vi.fn(() => ({ error: managerError })) as unknown as typeof spawnSync,
  101. })).toBe(false)
  102. expect(probeLinuxUserManager({
  103. spawnSync: vi.fn(() => ({ status: 1, error: undefined })) as unknown as typeof spawnSync,
  104. })).toBe(false)
  105. })
  106. it('removes private runner files when systemd-run throws synchronously', () => {
  107. const failure = new Error('systemd-run threw')
  108. let requestPath: string | undefined
  109. const run = vi.fn((_command: string, args: readonly string[]) => {
  110. const requestIndex = args.indexOf('--request')
  111. requestPath = args[requestIndex + 1]
  112. throw failure
  113. }) as unknown as typeof spawn
  114. expect(() => launchLinuxScope(spec([process.execPath, '-e', '']), {
  115. spawn: run,
  116. runnerInvocation: spawnRunnerInvocation(),
  117. })).toThrow(failure)
  118. expect(requestPath).toBeDefined()
  119. expect(existsSync(dirname(requestPath as string))).toBe(false)
  120. })
  121. it('wraps terminal argv literally and binds signalling and observation to the same scope', async () => {
  122. const signalCalls: Array<[string, readonly string[]]> = []
  123. const queryCalls: Array<[string, readonly string[]]> = []
  124. const runSync = vi.fn((command: string, args: readonly string[]) => {
  125. signalCalls.push([command, args])
  126. return { status: 0, stdout: '', stderr: '', error: undefined }
  127. }) as unknown as typeof spawnSync
  128. const query = vi.fn(async (command: string, args: readonly string[]) => {
  129. queryCalls.push([command, args])
  130. return { status: 0, stdout: 'inactive\n', stderr: '' }
  131. })
  132. const argv = ['/bin/bash', '-c', 'printf "%s" "$HOME"']
  133. const launch = prepareLinuxTerminalScope(argv, {
  134. spawnSync: runSync,
  135. systemdRun: '/usr/bin/systemd-run',
  136. systemctl: '/usr/bin/systemctl',
  137. systemctlQuery: query,
  138. })
  139. const unitArg = launch.args.find(arg => arg.startsWith('--unit='))
  140. if (unitArg === undefined) throw new Error('terminal scope did not publish its unit')
  141. const unit = `${unitArg.slice('--unit='.length)}.scope`
  142. expect(launch.command).toBe('/usr/bin/systemd-run')
  143. expect(launch.args.slice(0, -argv.length)).toEqual([
  144. '--user',
  145. '--scope',
  146. '--quiet',
  147. '--collect',
  148. '--expand-environment=no',
  149. unitArg,
  150. '--',
  151. ])
  152. expect(launch.args.slice(-argv.length)).toEqual(argv)
  153. const owner = launch.bindOwner(() => false)
  154. owner.signal('SIGTERM')
  155. owner.signal('SIGKILL')
  156. await owner.waitForExit()
  157. expect(signalCalls).toEqual([
  158. [
  159. '/usr/bin/systemctl',
  160. ['--user', 'kill', '--kill-whom=all', '--signal=SIGTERM', unit],
  161. ],
  162. [
  163. '/usr/bin/systemctl',
  164. ['--user', 'kill', '--kill-whom=all', '--signal=SIGKILL', unit],
  165. ],
  166. ])
  167. expect(queryCalls).toEqual([[
  168. '/usr/bin/systemctl',
  169. ['--user', 'show', unit, '--property=ActiveState', '--value'],
  170. ]])
  171. })
  172. it('keeps user argv out of systemd-run and reports the direct target outcome', async () => {
  173. let wrapper: ReturnType<typeof spawn> | undefined
  174. let systemdArgs: readonly string[] = []
  175. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  176. systemdArgs = args
  177. const separator = args.indexOf('--')
  178. const command = args[separator + 1] as string
  179. wrapper = spawn(command, args.slice(separator + 2), options)
  180. return wrapper
  181. }) as unknown as typeof spawn
  182. const runSyncMock = vi.fn((command: string, args: readonly string[]) => {
  183. if (command === 'systemctl' && args[1] === 'show') {
  184. const active = wrapper?.exitCode === null && wrapper.signalCode === null
  185. return { status: 0, stdout: active ? 'active\n' : 'inactive\n', stderr: '', error: undefined }
  186. }
  187. return { status: 0, stdout: '', stderr: '', error: undefined }
  188. })
  189. const runSync = runSyncMock as unknown as typeof spawnSync
  190. const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(9)', 'literal $VALUE']), {
  191. spawn: run,
  192. spawnSync: runSync,
  193. systemctlQuery: asyncQuery(runSync),
  194. runnerInvocation: spawnRunnerInvocation(),
  195. })
  196. await expect(launch.direct).resolves.toEqual({ exitCode: 9, signal: null })
  197. await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
  198. await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
  199. const callsBeforeStaleSignal = runSyncMock.mock.calls.length
  200. launch.owner.signal('SIGKILL')
  201. expect(runSyncMock).toHaveBeenCalledTimes(callsBeforeStaleSignal)
  202. expect(systemdArgs).toContain('--expand-environment=no')
  203. expect(systemdArgs).not.toContain('--pipe')
  204. expect(systemdArgs).not.toContain('--wait')
  205. expect(systemdArgs).not.toContain('literal $VALUE')
  206. })
  207. it('uses a scope KILL after the owner proves the range empty', async () => {
  208. let wrapper: ReturnType<typeof spawn> | undefined
  209. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  210. const separator = args.indexOf('--')
  211. const command = args[separator + 1] as string
  212. wrapper = spawn(command, args.slice(separator + 2), { ...options, detached: true })
  213. return wrapper
  214. }) as unknown as typeof spawn
  215. const runSync = vi.fn((command: string, args: readonly string[]) => {
  216. if (command === 'systemctl' && args[1] === 'kill') {
  217. if (args.includes('--signal=SIGTERM')) {
  218. return { status: 1, stdout: '', stderr: 'Unit could not be found', error: undefined }
  219. }
  220. if (wrapper?.pid !== undefined) process.kill(-wrapper.pid, 'SIGKILL')
  221. return {
  222. status: 1,
  223. stdout: '',
  224. stderr: 'Failed to send signal SIGKILL to auxiliary processes: Invalid argument',
  225. error: undefined,
  226. }
  227. }
  228. if (command === 'systemctl' && args[1] === 'show') {
  229. const active = wrapper?.exitCode === null && wrapper.signalCode === null
  230. return { status: 0, stdout: active ? 'active\n' : 'inactive\n', stderr: '', error: undefined }
  231. }
  232. return { status: 0, stdout: '', stderr: '', error: undefined }
  233. }) as unknown as typeof spawnSync
  234. const launch = launchLinuxScope(spec([process.execPath, '-e', 'setInterval(() => {}, 1000)']), {
  235. spawn: run,
  236. spawnSync: runSync,
  237. systemctlQuery: asyncQuery(runSync),
  238. runnerInvocation: spawnRunnerInvocation(),
  239. })
  240. launch.owner.signal('SIGTERM')
  241. launch.owner.signal('SIGKILL')
  242. await expect(launch.direct).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
  243. await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
  244. })
  245. it('rejects wait when the selected native owner becomes unreadable', async () => {
  246. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  247. const separator = args.indexOf('--')
  248. return spawn(args[separator + 1] as string, args.slice(separator + 2), options)
  249. }) as unknown as typeof spawn
  250. const runSync = vi.fn(() => ({
  251. status: 1,
  252. stdout: '',
  253. stderr: 'Failed to connect to bus: No such file or directory',
  254. error: undefined,
  255. })) as unknown as typeof spawnSync
  256. const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), {
  257. spawn: run,
  258. spawnSync: runSync,
  259. systemctlQuery: asyncQuery(runSync),
  260. runnerInvocation: spawnRunnerInvocation(),
  261. })
  262. await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
  263. await expect(launch.owner.waitForExit()).rejects.toThrow('Failed to connect to bus')
  264. })
  265. it('propagates systemctl execution failures and unknown active states', async () => {
  266. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  267. const separator = args.indexOf('--')
  268. return spawn(args[separator + 1] as string, args.slice(separator + 2), options)
  269. }) as unknown as typeof spawn
  270. const failure = new Error('systemctl execution failed')
  271. const failedRead = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), {
  272. spawn: run,
  273. spawnSync: vi.fn(() => ({ error: failure })) as unknown as typeof spawnSync,
  274. systemctlQuery: async () => ({ status: null, stdout: '', stderr: '', error: failure }),
  275. runnerInvocation: spawnRunnerInvocation(),
  276. })
  277. await expect(failedRead.owner.waitForExit()).rejects.toBe(failure)
  278. await expect(failedRead.direct).resolves.toEqual({ exitCode: 0, signal: null })
  279. const unknownState = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), {
  280. spawn: run,
  281. spawnSync: vi.fn(() => ({ status: 0, stdout: 'reloading\n', stderr: '', error: undefined })) as unknown as typeof spawnSync,
  282. systemctlQuery: async () => ({ status: 0, stdout: 'reloading\n', stderr: '' }),
  283. runnerInvocation: spawnRunnerInvocation(),
  284. })
  285. await expect(unknownState.owner.waitForExit()).rejects.toThrow('unknown ActiveState')
  286. await expect(unknownState.direct).resolves.toEqual({ exitCode: 0, signal: null })
  287. const blankFailure = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), {
  288. spawn: run,
  289. spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: '', error: undefined })) as unknown as typeof spawnSync,
  290. systemctlQuery: async () => ({ status: 1, stdout: '', stderr: '' }),
  291. runnerInvocation: spawnRunnerInvocation(),
  292. })
  293. await expect(blankFailure.owner.waitForExit()).rejects.toThrow('exit 1')
  294. await expect(blankFailure.direct).resolves.toEqual({ exitCode: 0, signal: null })
  295. })
  296. it.each(['activating', 'deactivating', 'failed'])(
  297. 'recognizes the %s scope state',
  298. async (initialState) => {
  299. let wrapper: ReturnType<typeof spawn> | undefined
  300. let reads = 0
  301. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  302. const separator = args.indexOf('--')
  303. wrapper = spawn(args[separator + 1] as string, args.slice(separator + 2), options)
  304. return wrapper
  305. }) as unknown as typeof spawn
  306. const runSync = vi.fn(() => {
  307. reads += 1
  308. return {
  309. status: 0,
  310. stdout: reads === 1 ? `${initialState}\n` : 'inactive\n',
  311. stderr: '',
  312. error: undefined,
  313. }
  314. }) as unknown as typeof spawnSync
  315. const launch = launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']), {
  316. spawn: run,
  317. spawnSync: runSync,
  318. systemctlQuery: asyncQuery(runSync),
  319. runnerInvocation: spawnRunnerInvocation(),
  320. })
  321. await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
  322. await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
  323. },
  324. )
  325. it('uses runner liveness when systemd has already forgotten the scope', async () => {
  326. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  327. const separator = args.indexOf('--')
  328. return spawn(args[separator + 1] as string, args.slice(separator + 2), options)
  329. }) as unknown as typeof spawn
  330. const runSyncMock = vi.fn(() => ({
  331. status: 1,
  332. stdout: '',
  333. stderr: 'Unit could not be found',
  334. error: undefined,
  335. }))
  336. const runSync = runSyncMock as unknown as typeof spawnSync
  337. const launch = launchLinuxScope(spec([process.execPath, '-e', 'setTimeout(() => {}, 40)']), {
  338. spawn: run,
  339. spawnSync: runSync,
  340. systemctlQuery: asyncQuery(runSync),
  341. runnerInvocation: spawnRunnerInvocation(),
  342. })
  343. await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
  344. await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
  345. expect(runSyncMock.mock.calls.length).toBeGreaterThan(1)
  346. })
  347. it('settles a missing scope immediately when the wrapper never started', async () => {
  348. const launch = launchLinuxScope(spec([process.execPath, '-e', '']), {
  349. systemdRun: `missing-systemd-run-${String(process.pid)}-${String(Date.now())}`,
  350. systemctlQuery: async () => ({
  351. status: 1,
  352. stdout: '',
  353. stderr: 'Unit dsh-subprocess-missing.scope could not be found',
  354. }),
  355. runnerInvocation: spawnRunnerInvocation(),
  356. })
  357. expect(launch.pid).toBeUndefined()
  358. await expect(launch.direct).rejects.toThrow('runner failed to start')
  359. await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
  360. })
  361. it('does not fabricate a direct outcome after a non-forced scope signal', async () => {
  362. let wrapper: ReturnType<typeof spawn> | undefined
  363. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  364. const separator = args.indexOf('--')
  365. wrapper = spawn(args[separator + 1] as string, args.slice(separator + 2), { ...options, detached: true })
  366. return wrapper
  367. }) as unknown as typeof spawn
  368. const runSync = vi.fn((command: string, args: readonly string[]) => {
  369. if (command === 'systemctl' && args[1] === 'kill' && wrapper?.pid !== undefined) {
  370. process.kill(-wrapper.pid, 'SIGKILL')
  371. }
  372. if (command === 'systemctl' && args[1] === 'show') {
  373. const active = wrapper?.exitCode === null && wrapper.signalCode === null
  374. return { status: 0, stdout: active ? 'active\n' : 'inactive\n', stderr: '', error: undefined }
  375. }
  376. return { status: 0, stdout: '', stderr: '', error: undefined }
  377. }) as unknown as typeof spawnSync
  378. const launch = launchLinuxScope(spec([process.execPath, '-e', 'setInterval(() => {}, 1000)']), {
  379. spawn: run,
  380. spawnSync: runSync,
  381. systemctlQuery: asyncQuery(runSync),
  382. runnerInvocation: spawnRunnerInvocation(),
  383. })
  384. launch.owner.signal('SIGTERM')
  385. await expect(launch.direct).rejects.toThrow('exited without a direct-command result')
  386. await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
  387. })
  388. it.each([
  389. [
  390. 'execution error',
  391. { status: null, stdout: '', stderr: '', error: new Error('systemctl execution failed') },
  392. 'systemctl execution failed',
  393. ],
  394. [
  395. 'stderr',
  396. { status: 1, stdout: '', stderr: 'Failed to connect to bus', error: undefined },
  397. 'Failed to connect to bus',
  398. ],
  399. [
  400. 'exit status',
  401. { status: 1, stdout: '', stderr: '', error: undefined },
  402. 'exit 1',
  403. ],
  404. ])('reports a failed scope KILL through the shared wait: %s', async (_label, failure, message) => {
  405. let wrapper: ReturnType<typeof spawn> | undefined
  406. const run = vi.fn((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  407. const separator = args.indexOf('--')
  408. wrapper = spawn(args[separator + 1] as string, args.slice(separator + 2), options)
  409. return wrapper
  410. }) as unknown as typeof spawn
  411. const runSyncMock = vi.fn((
  412. command: string,
  413. args: readonly string[],
  414. _options?: { env?: NodeJS.ProcessEnv },
  415. ) => {
  416. if (command === 'systemctl' && args[1] === 'kill') {
  417. return failure
  418. }
  419. return { status: 0, stdout: 'active\n', stderr: '', error: undefined }
  420. })
  421. const runSync = runSyncMock as unknown as typeof spawnSync
  422. const launch = launchLinuxScope(spec([process.execPath, '-e', 'setInterval(() => {}, 1000)']), {
  423. spawn: run,
  424. spawnSync: runSync,
  425. systemctlQuery: asyncQuery(runSync),
  426. runnerInvocation: spawnRunnerInvocation(),
  427. })
  428. void launch.direct.catch(() => {})
  429. try {
  430. launch.owner.signal('SIGKILL')
  431. await expect(launch.owner.waitForExit()).rejects.toThrow(message)
  432. const killCall = runSyncMock.mock.calls.find(([, args]) => args.includes('--signal=SIGKILL'))
  433. expect(killCall?.[0]).toBe('systemctl')
  434. expect(killCall?.[1]).toContain('kill')
  435. expect(killCall?.[1]).toContain('--kill-whom=all')
  436. expect(killCall?.[2]?.env?.LC_ALL).toBe('C')
  437. } finally {
  438. wrapper?.kill('SIGKILL')
  439. }
  440. })
  441. it('uses the production command defaults when no Linux internals are supplied', async () => {
  442. let wrapper: ReturnType<typeof spawn> | undefined
  443. let queryFailure: (Error & { code?: string | number }) | undefined
  444. const run = vi.fn()
  445. const runSync = vi.fn()
  446. const runAsync = vi.fn()
  447. const queryEnvironments: Array<NodeJS.ProcessEnv | undefined> = []
  448. vi.resetModules()
  449. vi.doMock('node:child_process', async (importOriginal) => {
  450. const actual = await importOriginal<typeof import('node:child_process')>()
  451. run.mockImplementation((_command: string, args: readonly string[], options: Parameters<typeof spawn>[2]) => {
  452. const separator = args.indexOf('--')
  453. wrapper = actual.spawn(args[separator + 1] as string, args.slice(separator + 2), options)
  454. return wrapper
  455. })
  456. runSync.mockImplementation((_command: string, _args: readonly string[]) => {
  457. return { status: 0, stdout: '', stderr: '', error: undefined }
  458. })
  459. runAsync.mockImplementation((
  460. _command: string,
  461. args: readonly string[],
  462. options: { env?: NodeJS.ProcessEnv },
  463. callback: (error: Error | null, stdout: string, stderr: string) => void,
  464. ) => {
  465. queryEnvironments.push(options.env)
  466. if (queryFailure !== undefined) {
  467. callback(queryFailure, '', '')
  468. return
  469. }
  470. const active = wrapper?.exitCode === null && wrapper.signalCode === null
  471. callback(null, args[1] === 'show' && active ? 'active\n' : 'inactive\n', '')
  472. })
  473. return { ...actual, execFile: runAsync, spawn: run, spawnSync: runSync }
  474. })
  475. try {
  476. const defaults = await import('../src/linux-scope.ts')
  477. expect(defaults.probeLinuxUserManager()).toBe(true)
  478. expect(defaults.probeLinuxRunner()).toBe(true)
  479. expect(defaults.probeLinuxScope()).toBe(true)
  480. const terminalLaunch = defaults.prepareLinuxTerminalScope(['shell', 'literal $HOME'])
  481. expect(terminalLaunch.command).toBe('systemd-run')
  482. expect(terminalLaunch.args.slice(-3)).toEqual(['--', 'shell', 'literal $HOME'])
  483. const launch = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']))
  484. await expect(launch.direct).resolves.toEqual({ exitCode: 0, signal: null })
  485. await expect(launch.owner.waitForExit()).resolves.toBeUndefined()
  486. expect(run).toHaveBeenCalledWith('systemd-run', expect.any(Array), expect.any(Object))
  487. expect(runSync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object))
  488. expect(runAsync).toHaveBeenCalledWith('systemctl', expect.any(Array), expect.any(Object), expect.any(Function))
  489. expect(queryEnvironments[0]?.LC_ALL).toBe('C')
  490. queryFailure = Object.assign(new Error('numeric systemctl failure'), { code: 17 })
  491. const numericFailure = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']))
  492. await expect(numericFailure.owner.waitForExit()).rejects.toBe(queryFailure)
  493. await expect(numericFailure.direct).resolves.toEqual({ exitCode: 0, signal: null })
  494. queryFailure = Object.assign(new Error('named systemctl failure'), { code: 'EQUERY' })
  495. const namedFailure = defaults.launchLinuxScope(spec([process.execPath, '-e', 'process.exit(0)']))
  496. await expect(namedFailure.owner.waitForExit()).rejects.toBe(queryFailure)
  497. await expect(namedFailure.direct).resolves.toEqual({ exitCode: 0, signal: null })
  498. } finally {
  499. vi.doUnmock('node:child_process')
  500. vi.resetModules()
  501. }
  502. })
  503. })