diff --git a/package.json b/package.json index 9d2ea2a3..02a6ddc3 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "lint:fix": "yarn lint --fix", "package": "webpack --mode production --devtool hidden-source-map", "package:prerelease": "npx vsce package --pre-release", - "pretest": "tsc -p . --outDir out && yarn run build && yarn run lint", + "pretest": "tsc -p . --outDir out && tsc -p test --outDir out && yarn run build && yarn run lint", "test": "vitest", "test:ci": "CI=true yarn test", "test:integration": "vscode-test", diff --git a/test/fixtures/bin.bash b/test/fixtures/scripts/bin.bash similarity index 100% rename from test/fixtures/bin.bash rename to test/fixtures/scripts/bin.bash diff --git a/test/fixtures/bin.old.bash b/test/fixtures/scripts/bin.old.bash similarity index 100% rename from test/fixtures/bin.old.bash rename to test/fixtures/scripts/bin.old.bash diff --git a/test/unit/core/cliManager.test.ts b/test/unit/core/cliManager.test.ts index 3e1dfb0d..f2a2c2e5 100644 --- a/test/unit/core/cliManager.test.ts +++ b/test/unit/core/cliManager.test.ts @@ -20,6 +20,7 @@ import { MockProgressReporter, MockUserInteraction, } from "../../mocks/testHelpers"; +import { expectPathsEqual } from "../../utils/platform"; vi.mock("os"); vi.mock("axios"); @@ -213,7 +214,7 @@ describe("CliManager", () => { it("accepts valid semver versions", async () => { withExistingBinary(TEST_VERSION); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); }); }); @@ -226,7 +227,7 @@ describe("CliManager", () => { it("reuses matching binary without downloading", async () => { withExistingBinary(TEST_VERSION); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(mockAxios.get).not.toHaveBeenCalled(); // Verify binary still exists expect(memfs.existsSync(BINARY_PATH)).toBe(true); @@ -236,7 +237,7 @@ describe("CliManager", () => { withExistingBinary("1.0.0"); withSuccessfulDownload(); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(mockAxios.get).toHaveBeenCalled(); // Verify new binary exists expect(memfs.existsSync(BINARY_PATH)).toBe(true); @@ -249,7 +250,7 @@ describe("CliManager", () => { mockConfig.set("coder.enableDownloads", false); withExistingBinary("1.0.0"); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(mockAxios.get).not.toHaveBeenCalled(); // Should still have the old version expect(memfs.existsSync(BINARY_PATH)).toBe(true); @@ -262,7 +263,7 @@ describe("CliManager", () => { withCorruptedBinary(); withSuccessfulDownload(); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(mockAxios.get).toHaveBeenCalled(); expect(memfs.existsSync(BINARY_PATH)).toBe(true); expect(memfs.readFileSync(BINARY_PATH).toString()).toBe( @@ -276,7 +277,7 @@ describe("CliManager", () => { withSuccessfulDownload(); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(mockAxios.get).toHaveBeenCalled(); // Verify directory was created and binary exists @@ -392,7 +393,7 @@ describe("CliManager", () => { withExistingBinary("1.0.0"); withHttpResponse(304); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); // No change expect(memfs.readFileSync(BINARY_PATH).toString()).toBe( mockBinaryContent("1.0.0"), @@ -460,7 +461,7 @@ describe("CliManager", () => { it("handles missing content-length", async () => { withSuccessfulDownload({ headers: {} }); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(memfs.existsSync(BINARY_PATH)).toBe(true); }); }); @@ -494,7 +495,7 @@ describe("CliManager", () => { withSuccessfulDownload(); withSignatureResponses([200]); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(pgp.verifySignature).toHaveBeenCalled(); const sigFile = expectFileInDir(BINARY_DIR, ".asc"); expect(sigFile).toBeDefined(); @@ -505,7 +506,7 @@ describe("CliManager", () => { withSignatureResponses([404, 200]); mockUI.setResponse("Signature not found", "Download signature"); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(mockAxios.get).toHaveBeenCalledTimes(3); const sigFile = expectFileInDir(BINARY_DIR, ".asc"); expect(sigFile).toBeDefined(); @@ -519,7 +520,7 @@ describe("CliManager", () => { ); mockUI.setResponse("Signature does not match", "Run anyway"); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(memfs.existsSync(BINARY_PATH)).toBe(true); }); @@ -539,7 +540,7 @@ describe("CliManager", () => { mockConfig.set("coder.disableSignatureVerification", true); withSuccessfulDownload(); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(pgp.verifySignature).not.toHaveBeenCalled(); const files = readdir(BINARY_DIR); expect(files.find((file) => file.includes(".asc"))).toBeUndefined(); @@ -553,7 +554,7 @@ describe("CliManager", () => { withHttpResponse(status); mockUI.setResponse(message, "Run without verification"); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(pgp.verifySignature).not.toHaveBeenCalled(); }); @@ -615,13 +616,16 @@ describe("CliManager", () => { withSuccessfulDownload(); const result = await manager.fetchBinary(mockApi, "test label"); - expect(result).toBe(`${pathWithSpaces}/test label/bin/${BINARY_NAME}`); + expectPathsEqual( + result, + `${pathWithSpaces}/test label/bin/${BINARY_NAME}`, + ); }); it("handles empty deployment label", async () => { withExistingBinary(TEST_VERSION, "/path/base/bin"); const result = await manager.fetchBinary(mockApi, ""); - expect(result).toBe(path.join(BASE_PATH, "bin", BINARY_NAME)); + expectPathsEqual(result, path.join(BASE_PATH, "bin", BINARY_NAME)); }); }); diff --git a/test/unit/core/cliUtils.test.ts b/test/unit/core/cliUtils.test.ts index d63ddd87..dd1c56f0 100644 --- a/test/unit/core/cliUtils.test.ts +++ b/test/unit/core/cliUtils.test.ts @@ -6,6 +6,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import * as cliUtils from "@/core/cliUtils"; import { getFixturePath } from "../../utils/fixtures"; +import { isWindows } from "../../utils/platform"; describe("CliUtils", () => { const tmp = path.join(os.tmpdir(), "vscode-coder-tests"); @@ -28,12 +29,14 @@ describe("CliUtils", () => { expect((await cliUtils.stat(binPath))?.size).toBe(4); }); - // TODO: CI only runs on Linux but we should run it on Windows too. - it("version", async () => { + it.skipIf(isWindows())("version", async () => { const binPath = path.join(tmp, "version"); await expect(cliUtils.version(binPath)).rejects.toThrow("ENOENT"); - const binTmpl = await fs.readFile(getFixturePath("bin.bash"), "utf8"); + const binTmpl = await fs.readFile( + getFixturePath("scripts", "bin.bash"), + "utf8", + ); await fs.writeFile(binPath, binTmpl.replace("$ECHO", "hello")); await expect(cliUtils.version(binPath)).rejects.toThrow("EACCES"); @@ -56,7 +59,10 @@ describe("CliUtils", () => { ); expect(await cliUtils.version(binPath)).toBe("v0.0.0"); - const oldTmpl = await fs.readFile(getFixturePath("bin.old.bash"), "utf8"); + const oldTmpl = await fs.readFile( + getFixturePath("scripts", "bin.old.bash"), + "utf8", + ); const old = (stderr: string, stdout: string): string => { return oldTmpl.replace("$STDERR", stderr).replace("$STDOUT", stdout); }; diff --git a/test/unit/core/pathResolver.test.ts b/test/unit/core/pathResolver.test.ts index e0e3b4d6..2930fb7e 100644 --- a/test/unit/core/pathResolver.test.ts +++ b/test/unit/core/pathResolver.test.ts @@ -1,9 +1,10 @@ import * as path from "path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, it, vi } from "vitest"; import { PathResolver } from "@/core/pathResolver"; import { MockConfigurationProvider } from "../../mocks/testHelpers"; +import { expectPathsEqual } from "../../utils/platform"; describe("PathResolver", () => { const basePath = @@ -19,17 +20,19 @@ describe("PathResolver", () => { }); it("should use base path for empty labels", () => { - expect(pathResolver.getGlobalConfigDir("")).toBe(basePath); - expect(pathResolver.getSessionTokenPath("")).toBe( + expectPathsEqual(pathResolver.getGlobalConfigDir(""), basePath); + expectPathsEqual( + pathResolver.getSessionTokenPath(""), path.join(basePath, "session"), ); - expect(pathResolver.getUrlPath("")).toBe(path.join(basePath, "url")); + expectPathsEqual(pathResolver.getUrlPath(""), path.join(basePath, "url")); }); describe("getBinaryCachePath", () => { it("should use custom binary destination when configured", () => { mockConfig.set("coder.binaryDestination", "/custom/binary/path"); - expect(pathResolver.getBinaryCachePath("deployment")).toBe( + expectPathsEqual( + pathResolver.getBinaryCachePath("deployment"), "/custom/binary/path", ); }); @@ -37,14 +40,16 @@ describe("PathResolver", () => { it("should use default path when custom destination is empty or whitespace", () => { vi.stubEnv("CODER_BINARY_DESTINATION", " "); mockConfig.set("coder.binaryDestination", " "); - expect(pathResolver.getBinaryCachePath("deployment")).toBe( + expectPathsEqual( + pathResolver.getBinaryCachePath("deployment"), path.join(basePath, "deployment", "bin"), ); }); it("should normalize custom paths", () => { mockConfig.set("coder.binaryDestination", "/custom/../binary/./path"); - expect(pathResolver.getBinaryCachePath("deployment")).toBe( + expectPathsEqual( + pathResolver.getBinaryCachePath("deployment"), "/binary/path", ); }); @@ -53,19 +58,22 @@ describe("PathResolver", () => { // Use the global storage when the environment variable and setting are unset/blank vi.stubEnv("CODER_BINARY_DESTINATION", ""); mockConfig.set("coder.binaryDestination", ""); - expect(pathResolver.getBinaryCachePath("deployment")).toBe( + expectPathsEqual( + pathResolver.getBinaryCachePath("deployment"), path.join(basePath, "deployment", "bin"), ); // Test environment variable takes precedence over global storage vi.stubEnv("CODER_BINARY_DESTINATION", " /env/binary/path "); - expect(pathResolver.getBinaryCachePath("deployment")).toBe( + expectPathsEqual( + pathResolver.getBinaryCachePath("deployment"), "/env/binary/path", ); // Test setting takes precedence over environment variable mockConfig.set("coder.binaryDestination", " /setting/path "); - expect(pathResolver.getBinaryCachePath("deployment")).toBe( + expectPathsEqual( + pathResolver.getBinaryCachePath("deployment"), "/setting/path", ); }); diff --git a/test/unit/globalFlags.test.ts b/test/unit/globalFlags.test.ts index d570d609..94c89dba 100644 --- a/test/unit/globalFlags.test.ts +++ b/test/unit/globalFlags.test.ts @@ -3,6 +3,8 @@ import { type WorkspaceConfiguration } from "vscode"; import { getGlobalFlags } from "@/globalFlags"; +import { isWindows } from "../utils/platform"; + describe("Global flags suite", () => { it("should return global-config and header args when no global flags configured", () => { const config = { @@ -53,10 +55,11 @@ describe("Global flags suite", () => { }); it("should not filter header-command flags, header args appended at end", () => { + const headerCommand = "echo test"; const config = { get: (key: string) => { if (key === "coder.headerCommand") { - return "echo test"; + return headerCommand; } if (key === "coder.globalFlags") { return ["-v", "--header-command custom", "--no-feature-warning"]; @@ -73,7 +76,13 @@ describe("Global flags suite", () => { "--global-config", '"/config/dir"', "--header-command", - "'echo test'", + quoteCommand(headerCommand), ]); }); }); + +function quoteCommand(value: string): string { + // Used to escape environment variables in commands. See `getHeaderArgs` in src/headers.ts + const quote = isWindows() ? '"' : "'"; + return `${quote}${value}${quote}`; +} diff --git a/test/unit/headers.test.ts b/test/unit/headers.test.ts index b2c29e22..f5812ec1 100644 --- a/test/unit/headers.test.ts +++ b/test/unit/headers.test.ts @@ -1,10 +1,11 @@ -import * as os from "os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type WorkspaceConfiguration } from "vscode"; import { getHeaderCommand, getHeaders } from "@/headers"; import { type Logger } from "@/logging/logger"; +import { printCommand, exitCommand, printEnvCommand } from "../utils/platform"; + const logger: Logger = { trace: () => {}, debug: () => {}, @@ -13,142 +14,142 @@ const logger: Logger = { error: () => {}, }; -it("should return no headers", async () => { - await expect(getHeaders(undefined, undefined, logger)).resolves.toStrictEqual( - {}, - ); - await expect( - getHeaders("localhost", undefined, logger), - ).resolves.toStrictEqual({}); - await expect(getHeaders(undefined, "command", logger)).resolves.toStrictEqual( - {}, - ); - await expect(getHeaders("localhost", "", logger)).resolves.toStrictEqual({}); - await expect(getHeaders("", "command", logger)).resolves.toStrictEqual({}); - await expect(getHeaders("localhost", " ", logger)).resolves.toStrictEqual( - {}, - ); - await expect(getHeaders(" ", "command", logger)).resolves.toStrictEqual({}); - await expect( - getHeaders("localhost", "printf ''", logger), - ).resolves.toStrictEqual({}); -}); - -it("should return headers", async () => { - await expect( - getHeaders("localhost", "printf 'foo=bar\\nbaz=qux'", logger), - ).resolves.toStrictEqual({ - foo: "bar", - baz: "qux", - }); - await expect( - getHeaders("localhost", "printf 'foo=bar\\r\\nbaz=qux'", logger), - ).resolves.toStrictEqual({ - foo: "bar", - baz: "qux", +describe("Headers", () => { + it("should return no headers", async () => { + await expect( + getHeaders(undefined, undefined, logger), + ).resolves.toStrictEqual({}); + await expect( + getHeaders("localhost", undefined, logger), + ).resolves.toStrictEqual({}); + await expect( + getHeaders(undefined, "command", logger), + ).resolves.toStrictEqual({}); + await expect(getHeaders("localhost", "", logger)).resolves.toStrictEqual( + {}, + ); + await expect(getHeaders("", "command", logger)).resolves.toStrictEqual({}); + await expect(getHeaders("localhost", " ", logger)).resolves.toStrictEqual( + {}, + ); + await expect(getHeaders(" ", "command", logger)).resolves.toStrictEqual( + {}, + ); + await expect( + getHeaders("localhost", printCommand(""), logger), + ).resolves.toStrictEqual({}); }); - await expect( - getHeaders("localhost", "printf 'foo=bar\\r\\n'", logger), - ).resolves.toStrictEqual({ foo: "bar" }); - await expect( - getHeaders("localhost", "printf 'foo=bar'", logger), - ).resolves.toStrictEqual({ foo: "bar" }); - await expect( - getHeaders("localhost", "printf 'foo=bar='", logger), - ).resolves.toStrictEqual({ foo: "bar=" }); - await expect( - getHeaders("localhost", "printf 'foo=bar=baz'", logger), - ).resolves.toStrictEqual({ foo: "bar=baz" }); - await expect( - getHeaders("localhost", "printf 'foo='", logger), - ).resolves.toStrictEqual({ foo: "" }); -}); - -it("should error on malformed or empty lines", async () => { - await expect( - getHeaders("localhost", "printf 'foo=bar\\r\\n\\r\\n'", logger), - ).rejects.toThrow(/Malformed/); - await expect( - getHeaders("localhost", "printf '\\r\\nfoo=bar'", logger), - ).rejects.toThrow(/Malformed/); - await expect( - getHeaders("localhost", "printf '=foo'", logger), - ).rejects.toThrow(/Malformed/); - await expect(getHeaders("localhost", "printf 'foo'", logger)).rejects.toThrow( - /Malformed/, - ); - await expect( - getHeaders("localhost", "printf ' =foo'", logger), - ).rejects.toThrow(/Malformed/); - await expect( - getHeaders("localhost", "printf 'foo =bar'", logger), - ).rejects.toThrow(/Malformed/); - await expect( - getHeaders("localhost", "printf 'foo foo=bar'", logger), - ).rejects.toThrow(/Malformed/); -}); -it("should have access to environment variables", async () => { - const coderUrl = "dev.coder.com"; - await expect( - getHeaders( - coderUrl, - os.platform() === "win32" - ? "printf url=%CODER_URL%" - : "printf url=$CODER_URL", - logger, - ), - ).resolves.toStrictEqual({ url: coderUrl }); -}); + it("should return headers", async () => { + await expect( + getHeaders("localhost", printCommand("foo=bar\nbaz=qux"), logger), + ).resolves.toStrictEqual({ + foo: "bar", + baz: "qux", + }); + await expect( + getHeaders("localhost", printCommand("foo=bar\r\nbaz=qux"), logger), + ).resolves.toStrictEqual({ + foo: "bar", + baz: "qux", + }); + await expect( + getHeaders("localhost", printCommand("foo=bar\r\n"), logger), + ).resolves.toStrictEqual({ foo: "bar" }); + await expect( + getHeaders("localhost", printCommand("foo=bar"), logger), + ).resolves.toStrictEqual({ foo: "bar" }); + await expect( + getHeaders("localhost", printCommand("foo=bar="), logger), + ).resolves.toStrictEqual({ foo: "bar=" }); + await expect( + getHeaders("localhost", printCommand("foo=bar=baz"), logger), + ).resolves.toStrictEqual({ foo: "bar=baz" }); + await expect( + getHeaders("localhost", printCommand("foo="), logger), + ).resolves.toStrictEqual({ foo: "" }); + }); -it("should error on non-zero exit", async () => { - await expect(getHeaders("localhost", "exit 10", logger)).rejects.toThrow( - /exited unexpectedly with code 10/, - ); -}); + it("should error on malformed or empty lines", async () => { + await expect( + getHeaders("localhost", printCommand("foo=bar\r\n\r\n"), logger), + ).rejects.toThrow(/Malformed/); + await expect( + getHeaders("localhost", printCommand("\r\nfoo=bar"), logger), + ).rejects.toThrow(/Malformed/); + await expect( + getHeaders("localhost", printCommand("=foo"), logger), + ).rejects.toThrow(/Malformed/); + await expect( + getHeaders("localhost", printCommand("foo"), logger), + ).rejects.toThrow(/Malformed/); + await expect( + getHeaders("localhost", printCommand(" =foo"), logger), + ).rejects.toThrow(/Malformed/); + await expect( + getHeaders("localhost", printCommand("foo =bar"), logger), + ).rejects.toThrow(/Malformed/); + await expect( + getHeaders("localhost", printCommand("foo foo=bar"), logger), + ).rejects.toThrow(/Malformed/); + }); -describe("getHeaderCommand", () => { - beforeEach(() => { - vi.stubEnv("CODER_HEADER_COMMAND", ""); + it("should have access to environment variables", async () => { + const coderUrl = "dev.coder.com"; + await expect( + getHeaders(coderUrl, printEnvCommand("url", "CODER_URL"), logger), + ).resolves.toStrictEqual({ url: coderUrl }); }); - afterEach(() => { - vi.unstubAllEnvs(); + it("should error on non-zero exit", async () => { + await expect( + getHeaders("localhost", exitCommand(10), logger), + ).rejects.toThrow(/exited unexpectedly with code 10/); }); - it("should return undefined if coder.headerCommand is not set in config", () => { - const config = { - get: () => undefined, - } as unknown as WorkspaceConfiguration; + describe("getHeaderCommand", () => { + beforeEach(() => { + vi.stubEnv("CODER_HEADER_COMMAND", ""); + }); - expect(getHeaderCommand(config)).toBeUndefined(); - }); + afterEach(() => { + vi.unstubAllEnvs(); + }); - it("should return undefined if coder.headerCommand is a blank string", () => { - const config = { - get: () => " ", - } as unknown as WorkspaceConfiguration; + it("should return undefined if coder.headerCommand is not set in config", () => { + const config = { + get: () => undefined, + } as unknown as WorkspaceConfiguration; - expect(getHeaderCommand(config)).toBeUndefined(); - }); + expect(getHeaderCommand(config)).toBeUndefined(); + }); - it("should return coder.headerCommand if set in config", () => { - vi.stubEnv("CODER_HEADER_COMMAND", "printf 'x=y'"); + it("should return undefined if coder.headerCommand is a blank string", () => { + const config = { + get: () => " ", + } as unknown as WorkspaceConfiguration; - const config = { - get: () => "printf 'foo=bar'", - } as unknown as WorkspaceConfiguration; + expect(getHeaderCommand(config)).toBeUndefined(); + }); - expect(getHeaderCommand(config)).toBe("printf 'foo=bar'"); - }); + it("should return coder.headerCommand if set in config", () => { + vi.stubEnv("CODER_HEADER_COMMAND", "printf 'x=y'"); + + const config = { + get: () => "printf 'foo=bar'", + } as unknown as WorkspaceConfiguration; + + expect(getHeaderCommand(config)).toBe("printf 'foo=bar'"); + }); - it("should return CODER_HEADER_COMMAND if coder.headerCommand is not set in config and CODER_HEADER_COMMAND is set in environment", () => { - vi.stubEnv("CODER_HEADER_COMMAND", "printf 'x=y'"); + it("should return CODER_HEADER_COMMAND if coder.headerCommand is not set in config and CODER_HEADER_COMMAND is set in environment", () => { + vi.stubEnv("CODER_HEADER_COMMAND", "printf 'x=y'"); - const config = { - get: () => undefined, - } as unknown as WorkspaceConfiguration; + const config = { + get: () => undefined, + } as unknown as WorkspaceConfiguration; - expect(getHeaderCommand(config)).toBe("printf 'x=y'"); + expect(getHeaderCommand(config)).toBe("printf 'x=y'"); + }); }); }); diff --git a/test/utils/platform.test.ts b/test/utils/platform.test.ts new file mode 100644 index 00000000..c04820d6 --- /dev/null +++ b/test/utils/platform.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { + expectPathsEqual, + exitCommand, + printCommand, + printEnvCommand, + isWindows, +} from "./platform"; + +describe("platform utils", () => { + describe("printCommand", () => { + it("should generate a simple node command", () => { + const result = printCommand("hello world"); + expect(result).toBe("node -e \"process.stdout.write('hello world')\""); + }); + + it("should escape special characters", () => { + const result = printCommand('path\\to\\file\'s "name"\nline2\rcarriage'); + expect(result).toBe( + 'node -e "process.stdout.write(\'path\\\\to\\\\file\\\'s \\"name\\"\\nline2\\rcarriage\')"', + ); + }); + }); + + describe("exitCommand", () => { + it("should generate node commands with various exit codes", () => { + expect(exitCommand(0)).toBe('node -e "process.exit(0)"'); + expect(exitCommand(1)).toBe('node -e "process.exit(1)"'); + expect(exitCommand(42)).toBe('node -e "process.exit(42)"'); + expect(exitCommand(-1)).toBe('node -e "process.exit(-1)"'); + }); + }); + + describe("printEnvCommand", () => { + it("should generate node commands that print env variables", () => { + expect(printEnvCommand("url", "CODER_URL")).toBe( + "node -e \"process.stdout.write('url=' + process.env.CODER_URL)\"", + ); + expect(printEnvCommand("token", "CODER_TOKEN")).toBe( + "node -e \"process.stdout.write('token=' + process.env.CODER_TOKEN)\"", + ); + // Will fail to execute but that's fine + expect(printEnvCommand("", "")).toBe( + "node -e \"process.stdout.write('=' + process.env.)\"", + ); + }); + }); + + describe("expectPathsEqual", () => { + it("should consider identical paths equal", () => { + expectPathsEqual("same/path", "same/path"); + }); + + it("should throw when paths are different", () => { + expect(() => + expectPathsEqual("path/to/file1", "path/to/file2"), + ).toThrow(); + }); + + it("should handle empty paths", () => { + expectPathsEqual("", ""); + }); + + it.runIf(isWindows())( + "should consider paths with different separators equal on Windows", + () => { + expectPathsEqual("path/to/file", "path\\to\\file"); + expectPathsEqual("C:/path/to/file", "C:\\path\\to\\file"); + expectPathsEqual( + "C:/path with spaces/file", + "C:\\path with spaces\\file", + ); + }, + ); + + it.skipIf(isWindows())( + "should consider backslash as literal on non-Windows", + () => { + expect(() => + expectPathsEqual("path/to/file", "path\\to\\file"), + ).toThrow(); + }, + ); + }); +}); diff --git a/test/utils/platform.ts b/test/utils/platform.ts new file mode 100644 index 00000000..b0abc660 --- /dev/null +++ b/test/utils/platform.ts @@ -0,0 +1,46 @@ +import os from "node:os"; +import path from "node:path"; +import { expect } from "vitest"; + +export function isWindows(): boolean { + return os.platform() === "win32"; +} + +/** + * Returns a platform-independent command that outputs the given text. + * Uses Node.js which is guaranteed to be available during tests. + */ +export function printCommand(output: string): string { + const escaped = output + .replace(/\\/g, "\\\\") // Escape backslashes first + .replace(/'/g, "\\'") // Escape single quotes + .replace(/"/g, '\\"') // Escape double quotes + .replace(/\r/g, "\\r") // Preserve carriage returns + .replace(/\n/g, "\\n"); // Preserve newlines + + return `node -e "process.stdout.write('${escaped}')"`; +} + +/** + * Returns a platform-independent command that exits with the given code. + */ +export function exitCommand(code: number): string { + return `node -e "process.exit(${code})"`; +} + +/** + * Returns a platform-independent command that prints an environment variable. + * @param key The key for the header (e.g., "url" to output "url=value") + * @param varName The environment variable name to access + */ +export function printEnvCommand(key: string, varName: string): string { + return `node -e "process.stdout.write('${key}=' + process.env.${varName})"`; +} + +export function expectPathsEqual(actual: string, expected: string) { + expect(normalizePath(actual)).toBe(normalizePath(expected)); +} + +function normalizePath(p: string): string { + return p.replaceAll(path.sep, path.posix.sep); +} diff --git a/vitest.config.ts b/vitest.config.ts index 01e3896a..40c5f958 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,13 +5,8 @@ export default defineConfig({ test: { globals: true, environment: "node", - include: ["test/unit/**/*.test.ts", "test/integration/**/*.test.ts"], - exclude: [ - "test/integration/**", - "**/node_modules/**", - "**/out/**", - "**/*.d.ts", - ], + include: ["test/unit/**/*.test.ts", "test/utils/**/*.test.ts"], + exclude: ["**/node_modules/**", "**/out/**", "**/*.d.ts"], pool: "threads", fileParallelism: true, coverage: {