From 05ebb3f19ee50c71ff3daa69bccea4ef084aafe4 Mon Sep 17 00:00:00 2001 From: Sander Declerck Date: Wed, 30 Jul 2025 16:10:46 +0200 Subject: [PATCH] First setup and teardown tests --- package-lock.json | 17 +++ package.json | 1 + test/e2e/DockerTestContainer.js | 113 +++++++++++++++++ test/e2e/Dockerfile | 31 ++--- test/e2e/parseShellOutput.js | 100 +++++++++++++++ test/e2e/setup.e2e.spec.js | 208 +++++++------------------------- 6 files changed, 292 insertions(+), 178 deletions(-) create mode 100644 test/e2e/DockerTestContainer.js create mode 100644 test/e2e/parseShellOutput.js diff --git a/package-lock.json b/package-lock.json index 260ee8b..747aedb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@inquirer/prompts": "^7.4.1", "abbrev": "^3.0.1", "chalk": "^5.4.1", + "node-pty": "^1.0.0", "npm-registry-fetch": "^18.0.2", "ora": "^8.2.0", "semver": "^7.7.2" @@ -3905,6 +3906,12 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/nan": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", + "integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -3921,6 +3928,16 @@ "node": ">= 0.6" } }, + "node_modules/node-pty": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", + "integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "nan": "^2.17.0" + } + }, "node_modules/npm-package-arg": { "version": "12.0.2", "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-12.0.2.tgz", diff --git a/package.json b/package.json index 8049b02..eceeef9 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@inquirer/prompts": "^7.4.1", "abbrev": "^3.0.1", "chalk": "^5.4.1", + "node-pty": "^1.0.0", "npm-registry-fetch": "^18.0.2", "ora": "^8.2.0", "semver": "^7.7.2" diff --git a/test/e2e/DockerTestContainer.js b/test/e2e/DockerTestContainer.js new file mode 100644 index 0000000..3351c08 --- /dev/null +++ b/test/e2e/DockerTestContainer.js @@ -0,0 +1,113 @@ +import { execSync } from "node:child_process"; +import * as pty from "node-pty"; +import { parseShellOutput } from "./parseShellOutput.js"; + +export class DockerTestContainer { + constructor(imageName, containerName) { + this.imageName = imageName; + this.containerName = containerName; + this.isRunning = false; + } + + async start() { + if (this.isRunning) { + throw new Error("Container is already running"); + } + + try { + // Start a long-running container that we can exec commands into + execSync( + `docker run -d --name ${this.containerName} ${this.imageName} sleep infinity`, + { stdio: "ignore" } + ); + this.isRunning = true; + } catch (error) { + throw new Error(`Failed to start container: ${error.message}`); + } + } + + async openShell(shell) { + let ptyProcess = pty.spawn( + "docker", + ["exec", "-it", this.containerName, shell], + { + name: "xterm-color", + cols: 80, + rows: 30, + } + ); + + await new Promise((resolve, reject) => { + ptyProcess.on("data", (data) => { + if (data.includes("\u001b[?2004h")) { + // This indicates that the shell is ready + resolve(); + } + }); + + ptyProcess.on("error", (err) => { + reject(err); + }); + }); + + function runCommand(command) { + if (!ptyProcess) { + throw new Error("Shell is not running"); + } + + return new Promise((resolve) => { + let allData = []; + + ptyProcess.on("data", handleInput); + + const timeout = setTimeout(() => { + // Fallback in case the command doesn't finish in a reasonable time + resolve({ allData, output: parseShellOutput(allData), command }); + ptyProcess.removeListener("data", handleInput); + }, 10000); + + function handleInput(data) { + allData.push(data); + + if (data.includes("\u001b[?2004h")) { + // This indicates that the command has finished executing + resolve({ allData, output: parseShellOutput(allData), command }); + ptyProcess.removeListener("data", handleInput); + clearTimeout(timeout); + } + } + + ptyProcess.write(`${command}\n`); + }); + } + + return { runCommand }; + } + + async stop() { + if (!this.isRunning) { + return; // Already stopped + } + + try { + // Force stop and remove the container + execSync(`docker kill ${this.containerName}`, { + stdio: "ignore", + timeout: 10000, + }); + } catch { + // Container might already be stopped + } + + try { + execSync(`docker rm -f ${this.containerName}`, { + stdio: "ignore", + timeout: 5000, + }); + } catch { + // Container might already be removed + } + + this.isRunning = false; + } +} diff --git a/test/e2e/Dockerfile b/test/e2e/Dockerfile index 0b9e24d..8518957 100644 --- a/test/e2e/Dockerfile +++ b/test/e2e/Dockerfile @@ -1,10 +1,6 @@ -FROM node:18-alpine +FROM node:24 as builder -# Install bash and basic utilities (Alpine uses apk, not apt-get) -RUN apk add --no-cache bash curl - -# Create a test user to simulate real user environment (Alpine syntax) -RUN addgroup -S testuser && adduser -S testuser -G testuser -s /bin/bash +ENV CI=true # Set working directory WORKDIR /app @@ -18,15 +14,20 @@ RUN npm install # Copy the rest of the application COPY . . -# Switch to test user -USER testuser +# Build the application +RUN npm --no-git-tag-version version 1.0.0 --allow-same-version +RUN npm pack -# Create home directory structure that bash expects -RUN mkdir -p /home/testuser +FROM mcr.microsoft.com/devcontainers/javascript-node as runner -# Set environment variables for testing -ENV HOME=/home/testuser -ENV SHELL=/bin/bash +WORKDIR /app -# Default command runs our test -CMD ["bash", "test/e2e/test-setup.sh"] \ No newline at end of file +COPY --from=builder /app/*.tgz /app/ + +# # Install the application package globally +RUN npm install -g /app/*.tgz + +RUN mkdir /testapp +RUN cd /testapp && npm init -y + +# ENV SHELL=/bin/bash diff --git a/test/e2e/parseShellOutput.js b/test/e2e/parseShellOutput.js new file mode 100644 index 0000000..29a581b --- /dev/null +++ b/test/e2e/parseShellOutput.js @@ -0,0 +1,100 @@ +const escapeChar = "\u001b"; +const startMarker = `${escapeChar}[?2004l`; +const endMarker = `${escapeChar}[?2004h`; + +export function parseShellOutput(rawData) { + const stringData = rawData.join(""); + + let output = getDataBetweenStartAndEndMarkers(stringData); + output = processBackspaces(output); + output = processEraseCommands(output); + output = removeOscSequences(output); + output = removeAnsiSgrSequences(output); + output = removeRemainingEscapeSequences(output); + + return output.trim(); +} + +function getDataBetweenStartAndEndMarkers(data) { + if (!data.includes(startMarker) || !data.includes(endMarker)) { + return data; + } + + const startIndex = data.indexOf(startMarker); + const endIndex = data.indexOf(endMarker, startIndex + startMarker.length); + + if (startIndex === -1 || endIndex === -1) { + return ""; + } + + return data.slice(startIndex + startMarker.length, endIndex); +} + +function processBackspaces(data) { + const result = []; + + for (let i = 0; i < data.length; i++) { + const char = data[i]; + + if (char === "\b") { + // Backspace: remove the previous character if it exists + if (result.length > 0) { + result.pop(); + } + } else { + result.push(char); + } + } + + return result.join(""); +} + +function removeOscSequences(data) { + return data.replace(/\u001b\][0-9]*;[^\u0007\u001b]*(\u0007|\u001b\\)/g, ""); +} + +function removeAnsiSgrSequences(data) { + return data.replace(/\u001b\[[0-9;]*m/g, ""); +} + +function processEraseCommands(data) { + const lines = data.split("\n"); + const result = []; + + for (let line of lines) { + // Process erase in line commands + line = line.replace(/\u001b\[K/g, ""); // Erase to end of line + line = line.replace(/\u001b\[0K/g, ""); // Erase to end of line + line = line.replace(/\u001b\[1K/g, ""); // Erase from start of line to cursor + line = line.replace(/\u001b\[2K/g, ""); // Erase entire line - remove the whole line + + // Skip lines that were completely erased + if (line.includes("\u001b[2K")) { + continue; + } + + result.push(line); + } + + // Process erase in display commands + let output = result.join("\n"); + output = output.replace(/\u001b\[J/g, ""); // Erase to end of display + output = output.replace(/\u001b\[0J/g, ""); // Erase to end of display + output = output.replace(/\u001b\[1J/g, ""); // Erase from start to cursor + output = output.replace(/\u001b\[2J/g, ""); // Erase entire display + + return output; +} + +function removeRemainingEscapeSequences(data) { + // Remove mode setting sequences like \u001b[?1h, \u001b[?1l + data = data.replace(/\u001b\[\?[0-9]+[hl]/g, ""); + + // Remove any other CSI sequences we haven't handled + data = data.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, ""); + + // Remove incomplete or malformed escape sequences + data = data.replace(/\u001b[^\u001b]*/g, ""); + + return data; +} diff --git a/test/e2e/setup.e2e.spec.js b/test/e2e/setup.e2e.spec.js index 7c4d905..16b2669 100644 --- a/test/e2e/setup.e2e.spec.js +++ b/test/e2e/setup.e2e.spec.js @@ -1,8 +1,9 @@ -import { describe, it, before, after } from "node:test"; -import assert from "node:assert"; -import { execSync, spawn } from "node:child_process"; +import { describe, it, before, beforeEach, afterEach } from "node:test"; +import { execSync } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { DockerTestContainer } from "./DockerTestContainer.js"; +import assert from "node:assert"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -11,185 +12,66 @@ const projectRoot = path.resolve(__dirname, "../.."); describe("E2E: safe-chain setup command", () => { const imageName = "safe-chain-e2e-test"; const containerName = "safe-chain-e2e-test-container"; + let container; before(async () => { - console.log("Building Docker image for e2e tests..."); + // Build the Docker image for the test environment try { execSync(`docker build -t ${imageName} -f test/e2e/Dockerfile .`, { cwd: projectRoot, - stdio: "inherit", + stdio: "ignore", }); - console.log("Docker image built successfully"); } catch (error) { - throw new Error(`Failed to build Docker image: ${error.message}`); + throw new Error(`Failed to setup test environment: ${error.message}`); } }); - after(async () => { - // Clean up: remove container and image - try { - execSync(`docker rm -f ${containerName}`, { stdio: "ignore" }); - } catch { - // Container might not exist, ignore - } - - try { - execSync(`docker rmi ${imageName}`, { stdio: "ignore" }); - } catch { - // Image might be in use, ignore + beforeEach(async () => { + // Run a new Docker container for each test + container = new DockerTestContainer(imageName, containerName); + + await container.start(); + }); + + afterEach(async () => { + // Stop and clean up the container after each test + if (container) { + await container.stop(); + container = null; } }); - it("should successfully run safe-chain setup and create aliases", async () => { - // Run the container and capture output - const result = await runDockerTest([ - "node", "bin/safe-chain.js", "setup" - ]); + for (let shell of ["bash", "zsh"]) { + it(`safe-chain setup wraps npm command after installation for ${shell}`, async () => { + // setting up the container + const installationShell = await container.openShell(shell); + await installationShell.runCommand("safe-chain setup"); - // Verify setup completed successfully - assert.ok( - result.stdout.includes("Setup successful"), - "Setup should report success" - ); - - assert.strictEqual( - result.exitCode, - 0, - `Setup should exit with code 0, got ${result.exitCode}` - ); - }); + const projectShell = await container.openShell(shell); + await projectShell.runCommand("cd /testapp"); + const result = await projectShell.runCommand("npm i axios"); - it("should create correct aliases in .bashrc", async () => { - // Run setup and then check .bashrc contents - const result = await runDockerTest([ - "bash", "-c", ` - node bin/safe-chain.js setup && - echo "=== BASHRC CONTENTS ===" && - cat /home/testuser/.bashrc - ` - ]); - - assert.strictEqual(result.exitCode, 0, "Commands should succeed"); - - const bashrcContent = result.stdout; - - // Check for all expected aliases - const expectedAliases = [ - 'alias npm="aikido-npm" # Safe-chain alias for npm', - 'alias npx="aikido-npx" # Safe-chain alias for npx', - 'alias yarn="aikido-yarn" # Safe-chain alias for yarn', - 'alias pnpm="aikido-pnpm" # Safe-chain alias for pnpm', - 'alias pnpx="aikido-pnpx" # Safe-chain alias for pnpx' - ]; - - for (const expectedAlias of expectedAliases) { assert.ok( - bashrcContent.includes(expectedAlias), - `Should contain alias: ${expectedAlias}` + result.output.includes("Scanning for malicious packages..."), + "Expected npm command to be wrapped by safe-chain" ); - } - }); + }); - it("should be idempotent (not create duplicate aliases)", async () => { - // Run setup twice and check for duplicates - const result = await runDockerTest([ - "bash", "-c", ` - node bin/safe-chain.js setup && - node bin/safe-chain.js setup && - echo "=== ALIAS COUNT ===" && - grep -c 'alias npm="aikido-npm"' /home/testuser/.bashrc || echo 0 - ` - ]); + it(`safe-chain teardown unwraps npm command after uninstallation for ${shell}`, async () => { + // setting up the container + const installationShell = await container.openShell(shell); + await installationShell.runCommand("safe-chain setup"); + await installationShell.runCommand("safe-chain teardown"); - assert.strictEqual(result.exitCode, 0, "Commands should succeed"); - - // Extract the count from output - const lines = result.stdout.split('\n'); - const countLine = lines.find(line => line.match(/^\d+$/)); - const aliasCount = parseInt(countLine || '0'); - - assert.strictEqual( - aliasCount, - 1, - `Should have exactly 1 npm alias, found ${aliasCount}` - ); - }); + const projectShell = await container.openShell(shell); + await projectShell.runCommand("cd /testapp"); + await projectShell.runCommand("npm i axios"); + const result = await projectShell.runCommand("npm i axios"); - it("should work with fresh .bashrc file", async () => { - // Ensure no .bashrc exists initially - const result = await runDockerTest([ - "bash", "-c", ` - rm -f /home/testuser/.bashrc && - node bin/safe-chain.js setup && - test -f /home/testuser/.bashrc && echo "BASHRC_CREATED" || - echo "BASHRC_NOT_CREATED" - ` - ]); - - assert.strictEqual(result.exitCode, 0, "Commands should succeed"); - assert.ok( - result.stdout.includes("BASHRC_CREATED"), - ".bashrc should be created if it doesn't exist" - ); - }); - - it("should detect bash shell correctly", async () => { - const result = await runDockerTest([ - "node", "bin/safe-chain.js", "setup" - ]); - - assert.strictEqual(result.exitCode, 0, "Setup should succeed"); - assert.ok( - result.stdout.includes("Detected") && result.stdout.includes("Bash"), - "Should detect Bash shell" - ); - }); - - /** - * Helper function to run a command in Docker container and return result - */ - async function runDockerTest(command) { - return new Promise((resolve, reject) => { - const dockerArgs = [ - "run", "--rm", - "--name", containerName, - imageName, - ...command - ]; - - const child = spawn("docker", dockerArgs, { - cwd: projectRoot, - stdio: ["pipe", "pipe", "pipe"] - }); - - let stdout = ""; - let stderr = ""; - - child.stdout.on("data", (data) => { - stdout += data.toString(); - }); - - child.stderr.on("data", (data) => { - stderr += data.toString(); - }); - - child.on("close", (code) => { - resolve({ - exitCode: code, - stdout, - stderr - }); - }); - - child.on("error", (error) => { - reject(new Error(`Docker command failed: ${error.message}`)); - }); - - // Set timeout to prevent hanging tests - setTimeout(() => { - child.kill(); - reject(new Error("Test timed out after 60 seconds")); - }, 60000); + assert.ok( + !result.output.includes("Scanning for malicious packages..."), + "Expected npm command to not be wrapped by safe-chain after teardown" + ); }); } -}); \ No newline at end of file +});