From 2370928ea7b648b4e6bbc1f8b51c5b8082861fd2 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 4 Nov 2022 06:25:29 -0700 Subject: [PATCH] Fix syncing shell environment when using fish (#6502) * Fix syncing shell environment when using fish - Add some better logging for the future Signed-off-by: Sebastian Malton * Add some unit tests to codify assumptions Signed-off-by: Sebastian Malton * Fix timeout Signed-off-by: Sebastian Malton * Update tests Signed-off-by: Sebastian Malton * Fix tests Signed-off-by: Sebastian Malton * Fix handling of '' in env Signed-off-by: Sebastian Malton * Add function description Signed-off-by: Sebastian Malton Signed-off-by: Sebastian Malton --- package.json | 2 + .../runnables/setup-shell.injectable.ts | 3 +- .../compute-shell-environment.injectable.ts | 29 +- ...mpute-unix-shell-environment.injectable.ts | 138 +++-- .../compute-unix-shell-environment.test.ts | 493 ++++++++++++++++++ src/main/utils/shell-env/env.injectable.ts | 14 + .../utils/shell-env/execPath.injectable.ts | 14 + yarn.lock | 12 + 8 files changed, 645 insertions(+), 60 deletions(-) create mode 100644 src/main/utils/shell-env/compute-unix-shell-environment.test.ts create mode 100644 src/main/utils/shell-env/env.injectable.ts create mode 100644 src/main/utils/shell-env/execPath.injectable.ts diff --git a/package.json b/package.json index d33cc57a06..a94be4d199 100644 --- a/package.json +++ b/package.json @@ -329,6 +329,7 @@ "@types/lodash": "^4.14.187", "@types/marked": "^4.0.7", "@types/md5-file": "^4.0.2", + "@types/memorystream": "^0.3.0", "@types/mini-css-extract-plugin": "^2.4.0", "@types/mock-fs": "^4.13.1", "@types/node": "^16.18.3", @@ -396,6 +397,7 @@ "jest-environment-jsdom": "^28.1.3", "jest-mock-extended": "^2.0.9", "make-plural": "^6.2.2", + "memorystream": "^0.3.1", "mini-css-extract-plugin": "^2.6.1", "mock-http": "^1.1.0", "node-gyp": "^8.3.0", diff --git a/src/main/start-main-application/runnables/setup-shell.injectable.ts b/src/main/start-main-application/runnables/setup-shell.injectable.ts index 9eb736b2db..6f80b225ac 100644 --- a/src/main/start-main-application/runnables/setup-shell.injectable.ts +++ b/src/main/start-main-application/runnables/setup-shell.injectable.ts @@ -55,7 +55,8 @@ const setupShellInjectable = getInjectable({ ...process.env, }; - logger.debug(`[SHELL-SYNC]: Synced shell env, and updating`, env, process.env); + logger.info(`[SHELL-SYNC]: Synced shell env`); + logger.debug(`[SHELL-SYNC]: updated env`, process.env); }, }; }, diff --git a/src/main/utils/shell-env/compute-shell-environment.injectable.ts b/src/main/utils/shell-env/compute-shell-environment.injectable.ts index e3a60fcd03..63eaede181 100644 --- a/src/main/utils/shell-env/compute-shell-environment.injectable.ts +++ b/src/main/utils/shell-env/compute-shell-environment.injectable.ts @@ -6,7 +6,6 @@ import type { AsyncResult } from "../../../common/utils/async-result"; import { getInjectable } from "@ogre-tools/injectable"; import isWindowsInjectable from "../../../common/vars/is-windows.injectable"; -import { disposer } from "../../../common/utils"; import computeUnixShellEnvironmentInjectable from "./compute-unix-shell-environment.injectable"; export type EnvironmentVariables = Partial>; @@ -28,32 +27,24 @@ const computeShellEnvironmentInjectable = getInjectable({ return async (shell) => { const controller = new AbortController(); const shellEnv = computeUnixShellEnvironment(shell, { signal: controller.signal }); - const cleanup = disposer(); - const timeoutHandle = setTimeout(() => controller.abort(), 30_000); - cleanup.push(() => clearTimeout(timeoutHandle)); + const result = await shellEnv; - try { - return { - callWasSuccessful: true, - response: await shellEnv, - }; - } catch (error) { - if (controller.signal.aborted) { - return { - callWasSuccessful: false, - error: "Resolving shell environment is taking very long. Please review your shell configuration.", - }; - } + clearTimeout(timeoutHandle); + if (result.callWasSuccessful) { + return result; + } + + if (controller.signal.aborted) { return { callWasSuccessful: false, - error: String(error), + error: `Resolving shell environment is taking very long. Please review your shell configuration: ${result.error}`, }; - } finally { - cleanup(); } + + return result; }; }, }); diff --git a/src/main/utils/shell-env/compute-unix-shell-environment.injectable.ts b/src/main/utils/shell-env/compute-unix-shell-environment.injectable.ts index 90108f7029..d26a3ee29c 100644 --- a/src/main/utils/shell-env/compute-unix-shell-environment.injectable.ts +++ b/src/main/utils/shell-env/compute-unix-shell-environment.injectable.ts @@ -7,96 +7,154 @@ import { getInjectable } from "@ogre-tools/injectable"; import getBasenameOfPathInjectable from "../../../common/path/get-basename.injectable"; import spawnInjectable from "../../child-process/spawn.injectable"; import randomUUIDInjectable from "../../crypto/random-uuid.injectable"; +import loggerInjectable from "../../../common/logger.injectable"; +import processExecPathInjectable from "./execPath.injectable"; +import processEnvInjectable from "./env.injectable"; +import { object } from "../../../common/utils"; +import type { AsyncResult } from "../../../common/utils/async-result"; export interface UnixShellEnvOptions { signal: AbortSignal; } -export type ComputeUnixShellEnvironment = (shell: string, opts: UnixShellEnvOptions) => Promise; +export type ComputeUnixShellEnvironment = (shell: string, opts: UnixShellEnvOptions) => Promise>; + +/** + * @param src The object containing the current environment variables + * @param overrides The environment variables that want to be overridden before passing the env to a child process + * @returns The combination of environment variables and a function which resets an object of environment variables to the values the keys corresponded to in `src` (rather than `overrides`) + */ +const getResetProcessEnv = (src: Partial>, overrides: Partial>): { + resetEnvPairs: (target: Partial>) => void; + env: Partial>; +} => { + const originals = object.entries(overrides).map(([name]) => [name, src[name]] as const); + + return { + env: { + ...src, + ...overrides, + }, + resetEnvPairs: (target) => { + for (const [name, orginalValue] of originals) { + if (typeof orginalValue === "string") { + target[name] = orginalValue; + } else { + delete target[name]; + } + } + }, + }; +}; const computeUnixShellEnvironmentInjectable = getInjectable({ id: "compute-unix-shell-environment", instantiate: (di): ComputeUnixShellEnvironment => { const powerShellName = /^pwsh(-preview)?$/; - const nonBashLikeShellName = /^t?csh$/; + const cshLikeShellName = /^(t?csh)$/; + const fishLikeShellName = /^fish$/; const getBasenameOfPath = di.inject(getBasenameOfPathInjectable); const spawn = di.inject(spawnInjectable); + const logger = di.inject(loggerInjectable); const randomUUID = di.inject(randomUUIDInjectable); + const processExecPath = di.inject(processExecPathInjectable); + const processEnv = di.inject(processEnvInjectable); - const getShellSpecifices = (shellPath: string, mark: string) => { - const shellName = getBasenameOfPath(shellPath); + const getShellSpecifices = (shellName: string) => { + const mark = randomUUID().replace(/-/g, ""); + const regex = new RegExp(`${mark}(\\{.*\\})${mark}`); if (powerShellName.test(shellName)) { // Older versions of PowerShell removes double quotes sometimes so we use "double single quotes" which is how // you escape single quotes inside of a single quoted string. return { - command: `Command '${process.execPath}' -p '\\"${mark}\\" + JSON.stringify(process.env) + \\"${mark}\\"'`, + command: `Command '${processExecPath}' -p '\\"${mark}\\" + JSON.stringify(process.env) + \\"${mark}\\"'`, shellArgs: ["-Login"], + regex, }; } - return { - command: `'${process.execPath}' -p '"${mark}" + JSON.stringify(process.env) + "${mark}"'`, - shellArgs: nonBashLikeShellName.test(shellName) - // tcsh and csh don't support any other options when providing the -l (login) shell option - ? ["-l"] - // zsh (at least, maybe others) don't load RC files when in non-interactive mode, even when using -l (login) option - : ["-li"], - }; + let command = `'${processExecPath}' -p '"${mark}" + JSON.stringify(process.env) + "${mark}"'`; + const shellArgs = ["-l"]; + + if (fishLikeShellName.test(shellName)) { + shellArgs.push("-c", command); + command = ""; + } else if (!cshLikeShellName.test(shellName)) { + // zsh (at least, maybe others) don't load RC files when in non-interactive mode, even when using -l (login) option + shellArgs.push("-i"); + } else { + // Some shells don't support any other options when providing the -l (login) shell option + } + + return { command, shellArgs, regex }; }; + return async (shellPath, opts) => { - const runAsNode = process.env["ELECTRON_RUN_AS_NODE"]; - const noAttach = process.env["ELECTRON_NO_ATTACH_CONSOLE"]; - const env = { - ...process.env, + const { resetEnvPairs, env } = getResetProcessEnv(processEnv, { ELECTRON_RUN_AS_NODE: "1", ELECTRON_NO_ATTACH_CONSOLE: "1", - }; - const mark = randomUUID().replace(/-/g, ""); - const regex = new RegExp(`${mark}(\\{.*\\})${mark}`); - const { command, shellArgs } = getShellSpecifices(shellPath, mark); + TERM: "screen-256color-bce", // required for fish + }); + const shellName = getBasenameOfPath(shellPath); + const { command, shellArgs, regex } = getShellSpecifices(shellName); - return new Promise((resolve, reject) => { + logger.info(`[UNIX-SHELL-ENV]: running against ${shellPath}`, { command, shellArgs }); + + return new Promise((resolve) => { const shellProcess = spawn(shellPath, shellArgs, { - detached: true, signal: opts.signal, env, }); const stdout: Buffer[] = []; + const stderr: Buffer[] = []; shellProcess.stdout.on("data", b => stdout.push(b)); + shellProcess.stderr.on("data", b => stderr.push(b)); - shellProcess.on("error", (err) => reject(err)); + shellProcess.on("error", (err) => resolve({ + callWasSuccessful: false, + error: `Failed to spawn ${shellPath}: ${err}`, + })); shellProcess.on("close", (code, signal) => { if (code || signal) { - return reject(new Error(`Unexpected return code from spawned shell (code: ${code}, signal: ${signal})`)); + const context = { + code, + signal, + stdout: Buffer.concat(stdout).toString("utf-8"), + stderr: Buffer.concat(stderr).toString("utf-8"), + }; + + return resolve({ + callWasSuccessful: false, + error: `Shell did not exit sucessfully: ${JSON.stringify(context, null, 4)}`, + }); } try { const rawOutput = Buffer.concat(stdout).toString("utf-8"); + + logger.info(`[UNIX-SHELL-ENV]: got the following output`, { rawOutput }); + const match = regex.exec(rawOutput); const strippedRawOutput = match ? match[1] : "{}"; - const resolvedEnv = JSON.parse(strippedRawOutput); + const resolvedEnv = JSON.parse(strippedRawOutput) as Partial>; - if (runAsNode) { - resolvedEnv["ELECTRON_RUN_AS_NODE"] = runAsNode; - } else { - delete resolvedEnv["ELECTRON_RUN_AS_NODE"]; - } - - if (noAttach) { - resolvedEnv["ELECTRON_NO_ATTACH_CONSOLE"] = noAttach; - } else { - delete resolvedEnv["ELECTRON_NO_ATTACH_CONSOLE"]; - } - - resolve(resolvedEnv); + resetEnvPairs(resolvedEnv); + resolve({ + callWasSuccessful: true, + response: resolvedEnv, + }); } catch (err) { - reject(err); + resolve({ + callWasSuccessful: false, + error: String(err), + }); } }); + shellProcess.stdin.end(command); }); }; diff --git a/src/main/utils/shell-env/compute-unix-shell-environment.test.ts b/src/main/utils/shell-env/compute-unix-shell-environment.test.ts new file mode 100644 index 0000000000..2de7204ae8 --- /dev/null +++ b/src/main/utils/shell-env/compute-unix-shell-environment.test.ts @@ -0,0 +1,493 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { DiContainer } from "@ogre-tools/injectable"; +import type { ChildProcessWithoutNullStreams } from "child_process"; +import EventEmitter from "events"; +import { flushPromises } from "../../../common/test-utils/flush-promises"; +import type { Spawn } from "../../child-process/spawn.injectable"; +import spawnInjectable from "../../child-process/spawn.injectable"; +import randomUUIDInjectable from "../../crypto/random-uuid.injectable"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import type { ComputeUnixShellEnvironment } from "./compute-unix-shell-environment.injectable"; +import computeUnixShellEnvironmentInjectable from "./compute-unix-shell-environment.injectable"; +import processEnvInjectable from "./env.injectable"; +import processExecPathInjectable from "./execPath.injectable"; +import MemoryStream from "memorystream"; + +const expectedEnv = { + SOME_ENV_VAR: "some-env-value", + ELECTRON_RUN_AS_NODE: "1", + ELECTRON_NO_ATTACH_CONSOLE: "1", + TERM: "screen-256color-bce", + SOME_THIRD_NON_UNDEFINED_VALUE: "", +}; + +describe("computeUnixShellEnvironment technical tests", () => { + let di: DiContainer; + let computeUnixShellEnvironment: ComputeUnixShellEnvironment; + let spawnMock: jest.MockedFunction; + let shellProcessFake: ChildProcessWithoutNullStreams; + let stdinValue: string; + let shellStdin: MemoryStream; + let shellStdout: MemoryStream; + let shellStderr: MemoryStream; + let unixShellEnv: ReturnType; + + beforeEach(() => { + di = getDiForUnitTesting({ + doGeneralOverrides: true, + }); + + spawnMock = jest.fn().mockImplementation((spawnfile, spawnargs) => { + shellStdin = new MemoryStream(); + shellStdout = new MemoryStream(); + shellStderr = new MemoryStream(); + stdinValue = ""; + + shellStdin.on("data", (chunk) => { + stdinValue += chunk.toString(); + }); + + return shellProcessFake = Object.assign(new EventEmitter(), { + stdin: shellStdin, + stdout: shellStdout, + stderr: shellStderr, + stdio: [ + shellStdin, + shellStdout, + shellStderr, + ] as any, + killed: false, + kill: jest.fn(), + send: jest.fn(), + disconnect: jest.fn(), + unref: jest.fn(), + ref: jest.fn(), + connected: false, + exitCode: null, + signalCode: null, + spawnargs, + spawnfile, + }); + }); + di.override(spawnInjectable, () => spawnMock); + di.override(randomUUIDInjectable, () => () => "deadbeef"); + + di.override(processEnvInjectable, () => ({ + SOME_ENV_VAR: "some-env-value", + TERM: "some-other-value", + SOME_THIRD_NON_UNDEFINED_VALUE: "", + })); + di.override(processExecPathInjectable, () => "/some/process/exec/path"); + + di.unoverride(computeUnixShellEnvironmentInjectable); + di.permitSideEffects(computeUnixShellEnvironmentInjectable); + computeUnixShellEnvironment = di.inject(computeUnixShellEnvironmentInjectable); + }); + + describe.each([ + "/bin/csh", + "/bin/tcsh", + ])("when shell is %s", (shellPath) => { + beforeEach(async () => { + const controller = new AbortController(); + + unixShellEnv = computeUnixShellEnvironment(shellPath, { signal: controller.signal }); + await flushPromises(); + }); + + it("should spawn a process with the correct arguments", () => { + expect(spawnMock).toBeCalledWith( + shellPath, + [ + "-l", + ], + expect.objectContaining({ + env: expectedEnv, + }), + ); + }); + + it("should send the command via stdin", () => { + expect(stdinValue).toBe(`'/some/process/exec/path' -p '"deadbeef" + JSON.stringify(process.env) + "deadbeef"'`); + }); + + it("should close stdin", () => { + expect(shellStdin.readableEnded).toBe(true); + }); + + describe("when process errors", () => { + beforeEach(() => { + shellProcessFake.emit("error", new Error("some-error")); + }); + + it("should resolve with a failed call", async () => { + await expect(unixShellEnv).resolves.toEqual({ + callWasSuccessful: false, + error: `Failed to spawn ${shellPath}: Error: some-error`, + }); + }); + }); + + describe("when process exits with non-zero exit code", () => { + beforeEach(() => { + shellProcessFake.emit("close", 1, null); + }); + + it("should resolve with a failed call", async () => { + await expect(unixShellEnv).resolves.toEqual({ + callWasSuccessful: false, + error: 'Shell did not exit sucessfully: {\n "code": 1,\n "signal": null,\n "stdout": "",\n "stderr": ""\n}', + }); + }); + }); + + describe("when process exits with a signal", () => { + beforeEach(() => { + shellProcessFake.emit("close", 0, "SIGKILL"); + }); + + it("should resolve with a failed call", async () => { + await expect(unixShellEnv).resolves.toEqual({ + callWasSuccessful: false, + error: 'Shell did not exit sucessfully: {\n "code": 0,\n "signal": "SIGKILL",\n "stdout": "",\n "stderr": ""\n}', + }); + }); + }); + + describe("when process stdout emits some data", () => { + beforeEach(() => { + const fakeInnerEnv = { + PATH: "/bin", + ...expectedEnv, + }; + + shellStdout.emit("data", Buffer.from(`some-other-datadeadbeef${JSON.stringify(fakeInnerEnv)}deadbeefsome-third-other-data`)); + }); + + describe("when process successfully exits", () => { + beforeEach(() => { + shellProcessFake.emit("close", 0); + }); + + it("should resolve the env", async () => { + await expect(unixShellEnv).resolves.toEqual({ + callWasSuccessful: true, + response: { + PATH: "/bin", + SOME_ENV_VAR: "some-env-value", + TERM: "some-other-value", + SOME_THIRD_NON_UNDEFINED_VALUE: "", + }, + }); + }); + }); + }); + }); + + describe.each([ + "/bin/bash", + "/bin/sh", + "/bin/zsh", + ])("when shell is %s", (shellPath) => { + beforeEach(async () => { + const controller = new AbortController(); + + unixShellEnv = computeUnixShellEnvironment(shellPath, { signal: controller.signal }); + await flushPromises(); + }); + + it("should spawn a process with the correct arguments", () => { + expect(spawnMock).toBeCalledWith( + shellPath, + [ + "-l", + "-i", + ], + expect.objectContaining({ + env: expectedEnv, + }), + ); + }); + + it("should send the command via stdin", () => { + expect(stdinValue).toBe(`'/some/process/exec/path' -p '"deadbeef" + JSON.stringify(process.env) + "deadbeef"'`); + }); + + it("should close stdin", () => { + expect(shellStdin.readableEnded).toBe(true); + }); + + describe("when process errors", () => { + beforeEach(() => { + shellProcessFake.emit("error", new Error("some-error")); + }); + + it("should resolve with a failed call", async () => { + await expect(unixShellEnv).resolves.toEqual({ + callWasSuccessful: false, + error: `Failed to spawn ${shellPath}: Error: some-error`, + }); + }); + }); + + describe("when process exits with non-zero exit code", () => { + beforeEach(() => { + shellProcessFake.emit("close", 1, null); + }); + + it("should resolve with a failed call", async () => { + await expect(unixShellEnv).resolves.toEqual({ + callWasSuccessful: false, + error: 'Shell did not exit sucessfully: {\n "code": 1,\n "signal": null,\n "stdout": "",\n "stderr": ""\n}', + }); + }); + }); + + describe("when process exits with a signal", () => { + beforeEach(() => { + shellProcessFake.emit("close", 0, "SIGKILL"); + }); + + it("should resolve with a failed call", async () => { + await expect(unixShellEnv).resolves.toEqual({ + callWasSuccessful: false, + error: 'Shell did not exit sucessfully: {\n "code": 0,\n "signal": "SIGKILL",\n "stdout": "",\n "stderr": ""\n}', + }); + }); + }); + + describe("when process stdout emits some data", () => { + beforeEach(() => { + const fakeInnerEnv = { + PATH: "/bin", + ...expectedEnv, + }; + + shellStdout.emit("data", Buffer.from(`some-other-datadeadbeef${JSON.stringify(fakeInnerEnv)}deadbeefsome-third-other-data`)); + }); + + describe("when process successfully exits", () => { + beforeEach(() => { + shellProcessFake.emit("close", 0); + }); + + it("should resolve the env", async () => { + await expect(unixShellEnv).resolves.toEqual({ + callWasSuccessful: true, + response: { + PATH: "/bin", + SOME_ENV_VAR: "some-env-value", + TERM: "some-other-value", + SOME_THIRD_NON_UNDEFINED_VALUE: "", + }, + }); + }); + }); + }); + }); + + describe.each([ + "/usr/local/bin/fish", + ])("when shell is %s", (shellPath) => { + beforeEach(async () => { + const controller = new AbortController(); + + unixShellEnv = computeUnixShellEnvironment(shellPath, { signal: controller.signal }); + await flushPromises(); + }); + + it("should spawn a process with the correct arguments", () => { + expect(spawnMock).toBeCalledWith( + shellPath, + [ + "-l", + "-c", + `'/some/process/exec/path' -p '"deadbeef" + JSON.stringify(process.env) + "deadbeef"'`, + ], + expect.objectContaining({ + env: expectedEnv, + }), + ); + }); + + it("should not send anything via stdin", () => { + expect(stdinValue).toBe(""); + }); + + it("should close stdin", () => { + expect(shellStdin.readableEnded).toBe(true); + }); + + describe("when process errors", () => { + beforeEach(() => { + shellProcessFake.emit("error", new Error("some-error")); + }); + + it("should resolve with a failed call", async () => { + await expect(unixShellEnv).resolves.toEqual({ + callWasSuccessful: false, + error: `Failed to spawn ${shellPath}: Error: some-error`, + }); + }); + }); + + describe("when process exits with non-zero exit code", () => { + beforeEach(() => { + shellProcessFake.emit("close", 1, null); + }); + + it("should resolve with a failed call", async () => { + await expect(unixShellEnv).resolves.toEqual({ + callWasSuccessful: false, + error: 'Shell did not exit sucessfully: {\n "code": 1,\n "signal": null,\n "stdout": "",\n "stderr": ""\n}', + }); + }); + }); + + describe("when process exits with a signal", () => { + beforeEach(() => { + shellProcessFake.emit("close", 0, "SIGKILL"); + }); + + it("should resolve with a failed call", async () => { + await expect(unixShellEnv).resolves.toEqual({ + callWasSuccessful: false, + error: 'Shell did not exit sucessfully: {\n "code": 0,\n "signal": "SIGKILL",\n "stdout": "",\n "stderr": ""\n}', + }); + }); + }); + + describe("when process stdout emits some data", () => { + beforeEach(() => { + const fakeInnerEnv = { + PATH: "/bin", + ...expectedEnv, + }; + + shellStdout.emit("data", Buffer.from(`some-other-datadeadbeef${JSON.stringify(fakeInnerEnv)}deadbeefsome-third-other-data`)); + }); + + describe("when process successfully exits", () => { + beforeEach(() => { + shellProcessFake.emit("close", 0); + }); + + it("should resolve the env", async () => { + await expect(unixShellEnv).resolves.toEqual({ + callWasSuccessful: true, + response: { + PATH: "/bin", + SOME_ENV_VAR: "some-env-value", + TERM: "some-other-value", + SOME_THIRD_NON_UNDEFINED_VALUE: "", + }, + }); + }); + }); + }); + }); + + describe.each([ + "/usr/local/bin/pwsh", + "/usr/local/bin/pwsh-preview", + ])("when shell is %s", (shellPath) => { + beforeEach(async () => { + const controller = new AbortController(); + + unixShellEnv = computeUnixShellEnvironment(shellPath, { signal: controller.signal }); + await flushPromises(); + }); + + it("should spawn a process with the correct arguments", () => { + expect(spawnMock).toBeCalledWith( + shellPath, + [ + "-Login", + ], + expect.objectContaining({ + env: expectedEnv, + }), + ); + }); + + it("should send the command via stdin", () => { + expect(stdinValue).toBe(`Command '/some/process/exec/path' -p '\\"deadbeef\\" + JSON.stringify(process.env) + \\"deadbeef\\"'`); + }); + + it("should close stdin", () => { + expect(shellStdin.readableEnded).toBe(true); + }); + + describe("when process errors", () => { + beforeEach(() => { + shellProcessFake.emit("error", new Error("some-error")); + }); + + it("should resolve with a failed call", async () => { + await expect(unixShellEnv).resolves.toEqual({ + callWasSuccessful: false, + error: `Failed to spawn ${shellPath}: Error: some-error`, + }); + }); + }); + + describe("when process exits with non-zero exit code", () => { + beforeEach(() => { + shellProcessFake.emit("close", 1, null); + }); + + it("should resolve with a failed call", async () => { + await expect(unixShellEnv).resolves.toEqual({ + callWasSuccessful: false, + error: 'Shell did not exit sucessfully: {\n "code": 1,\n "signal": null,\n "stdout": "",\n "stderr": ""\n}', + }); + }); + }); + + describe("when process exits with a signal", () => { + beforeEach(() => { + shellProcessFake.emit("close", 0, "SIGKILL"); + }); + + it("should resolve with a failed call", async () => { + await expect(unixShellEnv).resolves.toEqual({ + callWasSuccessful: false, + error: 'Shell did not exit sucessfully: {\n "code": 0,\n "signal": "SIGKILL",\n "stdout": "",\n "stderr": ""\n}', + }); + }); + }); + + describe("when process stdout emits some data", () => { + beforeEach(() => { + const fakeInnerEnv = { + PATH: "/bin", + ...expectedEnv, + }; + + shellStdout.emit("data", Buffer.from(`some-other-datadeadbeef${JSON.stringify(fakeInnerEnv)}deadbeefsome-third-other-data`)); + }); + + describe("when process successfully exits", () => { + beforeEach(() => { + shellProcessFake.emit("close", 0); + }); + + it("should resolve the env", async () => { + await expect(unixShellEnv).resolves.toEqual({ + callWasSuccessful: true, + response: { + PATH: "/bin", + SOME_ENV_VAR: "some-env-value", + TERM: "some-other-value", + SOME_THIRD_NON_UNDEFINED_VALUE: "", + }, + }); + }); + }); + }); + }); +}); diff --git a/src/main/utils/shell-env/env.injectable.ts b/src/main/utils/shell-env/env.injectable.ts new file mode 100644 index 0000000000..e9c359354f --- /dev/null +++ b/src/main/utils/shell-env/env.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import process from "process"; + +const processEnvInjectable = getInjectable({ + id: "process-env", + instantiate: () => process.env, + causesSideEffects: true, +}); + +export default processEnvInjectable; diff --git a/src/main/utils/shell-env/execPath.injectable.ts b/src/main/utils/shell-env/execPath.injectable.ts new file mode 100644 index 0000000000..75ad886a29 --- /dev/null +++ b/src/main/utils/shell-env/execPath.injectable.ts @@ -0,0 +1,14 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import process from "process"; + +const processExecPathInjectable = getInjectable({ + id: "process-exec-path", + instantiate: () => process.execPath, + causesSideEffects: true, +}); + +export default processExecPathInjectable; diff --git a/yarn.lock b/yarn.lock index 44a68db512..0bd50ccbab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2218,6 +2218,13 @@ resolved "https://registry.yarnpkg.com/@types/md5-file/-/md5-file-4.0.2.tgz#c7241e88f4aa17218c774befb0fc34f33f21fe36" integrity sha512-8gacRfEqLrmZ6KofpFfxyjsm/LYepeWUWUJGaf5A9W9J5B2/dRZMdkDqFDL6YDa9IweH12IO76jO7mpsK2B3wg== +"@types/memorystream@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@types/memorystream/-/memorystream-0.3.0.tgz#7616df4c42a479805d052a058d990b879d5e368f" + integrity sha512-gzh6mqZcLryYHn4g2MuMWjo9J1+Py/XYwITyZmUxV7ZoBIi7bTbBgSiuC5tcm3UL3gmaiYssQFDlXr/3fK94cw== + dependencies: + "@types/node" "*" + "@types/mime@^1": version "1.3.2" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" @@ -8736,6 +8743,11 @@ memoize-one@^6.0.0: resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045" integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw== +memorystream@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/memorystream/-/memorystream-0.3.1.tgz#86d7090b30ce455d63fbae12dda51a47ddcaf9b2" + integrity sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw== + merge-descriptors@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"