mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Make sure ports are set correctly
This commit is contained in:
parent
919a12b5a6
commit
179f61dcda
3 changed files with 81 additions and 362 deletions
|
|
@ -101,17 +101,27 @@ if ! plutil -lint "${PLIST_PATH}" > /dev/null 2>&1; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Load the LaunchAgent to start the service now
|
# Load the LaunchAgent to start the service now
|
||||||
# Need to run as the actual user, not root
|
# Need to run as the actual user
|
||||||
sudo -u "${ACTUAL_USER}" launchctl load "${PLIST_PATH}" 2>/dev/null || true
|
sudo -u "${ACTUAL_USER}" launchctl load "${PLIST_PATH}" 2>/dev/null || true
|
||||||
|
|
||||||
# Give it a moment to start
|
# Give it a moment to start and write the port file
|
||||||
sleep 2
|
sleep 3
|
||||||
|
|
||||||
|
# Read the dynamically-assigned port from the port file
|
||||||
|
PORT_FILE="${USER_HOME}/.safe-chain/port"
|
||||||
|
if [ -f "${PORT_FILE}" ]; then
|
||||||
|
PROXY_PORT=$(cat "${PORT_FILE}")
|
||||||
|
echo "Detected proxy running on port: ${PROXY_PORT}"
|
||||||
|
else
|
||||||
|
echo "⚠ Warning: Could not detect proxy port, using default 8080"
|
||||||
|
PROXY_PORT=8080
|
||||||
|
fi
|
||||||
|
|
||||||
# Set system-wide environment variables so all processes can use the proxy
|
# Set system-wide environment variables so all processes can use the proxy
|
||||||
# These affect all processes for the user, not just the LaunchAgent
|
# These affect all processes for the user, not just the LaunchAgent
|
||||||
echo "Setting system-wide proxy environment variables..."
|
echo "Setting system-wide proxy environment variables..."
|
||||||
sudo -u "${ACTUAL_USER}" launchctl setenv HTTPS_PROXY "http://localhost:8080"
|
sudo -u "${ACTUAL_USER}" launchctl setenv HTTPS_PROXY "http://localhost:${PROXY_PORT}"
|
||||||
sudo -u "${ACTUAL_USER}" launchctl setenv GLOBAL_AGENT_HTTP_PROXY "http://localhost:8080"
|
sudo -u "${ACTUAL_USER}" launchctl setenv GLOBAL_AGENT_HTTP_PROXY "http://localhost:${PROXY_PORT}"
|
||||||
sudo -u "${ACTUAL_USER}" launchctl setenv NODE_EXTRA_CA_CERTS "${CERT_DIR}/ca-cert.pem"
|
sudo -u "${ACTUAL_USER}" launchctl setenv NODE_EXTRA_CA_CERTS "${CERT_DIR}/ca-cert.pem"
|
||||||
sudo -u "${ACTUAL_USER}" launchctl setenv SAFE_CHAIN_CERT_DIR "${CERT_DIR}"
|
sudo -u "${ACTUAL_USER}" launchctl setenv SAFE_CHAIN_CERT_DIR "${CERT_DIR}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import { ui } from "../environment/userInteraction.js";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { initializeCliArguments } from "../config/cliArguments.js";
|
import { initializeCliArguments } from "../config/cliArguments.js";
|
||||||
import { getCaCertPath } from "../registryProxy/certUtils.js";
|
import { getCaCertPath } from "../registryProxy/certUtils.js";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as os from "os";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run the Safe Chain proxy as a standalone service
|
* Run the Safe Chain proxy as a standalone service
|
||||||
|
|
@ -42,6 +45,17 @@ export async function runCommand(args) {
|
||||||
ui.writeInformation(` PID: ${chalk.cyan(process.pid)}`);
|
ui.writeInformation(` PID: ${chalk.cyan(process.pid)}`);
|
||||||
ui.emptyLine();
|
ui.emptyLine();
|
||||||
|
|
||||||
|
// Write port to a well-known location for the installer to read
|
||||||
|
// This allows launchctl setenv to use the actual dynamically-assigned port
|
||||||
|
const portFilePath = path.join(os.homedir(), ".safe-chain", "port");
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(portFilePath), { recursive: true });
|
||||||
|
fs.writeFileSync(portFilePath, String(port), "utf8");
|
||||||
|
} catch (/** @type {any} */ error) {
|
||||||
|
// Non-fatal, just log
|
||||||
|
ui.writeWarning(`Could not write port file: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
ui.writeInformation(chalk.bold("Environment Variables Set:"));
|
ui.writeInformation(chalk.bold("Environment Variables Set:"));
|
||||||
ui.writeInformation(` ${chalk.cyan("HTTPS_PROXY")}: http://localhost:${port}`);
|
ui.writeInformation(` ${chalk.cyan("HTTPS_PROXY")}: http://localhost:${port}`);
|
||||||
ui.writeInformation(` ${chalk.cyan("GLOBAL_AGENT_HTTP_PROXY")}: http://localhost:${port}`);
|
ui.writeInformation(` ${chalk.cyan("GLOBAL_AGENT_HTTP_PROXY")}: http://localhost:${port}`);
|
||||||
|
|
@ -61,6 +75,17 @@ export async function runCommand(args) {
|
||||||
ui.emptyLine();
|
ui.emptyLine();
|
||||||
ui.writeInformation(chalk.yellow("Proxy stopped."));
|
ui.writeInformation(chalk.yellow("Proxy stopped."));
|
||||||
|
|
||||||
|
// Clean up port file
|
||||||
|
const portFilePath = path.join(os.homedir(), ".safe-chain", "port");
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(portFilePath)) {
|
||||||
|
fs.unlinkSync(portFilePath);
|
||||||
|
}
|
||||||
|
} catch (/** @type {any} */ error) {
|
||||||
|
// Non-fatal
|
||||||
|
ui.writeWarning(`Could not remove port file: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (blockedPackages.length > 0) {
|
if (blockedPackages.length > 0) {
|
||||||
ui.emptyLine();
|
ui.emptyLine();
|
||||||
ui.writeInformation(
|
ui.writeInformation(
|
||||||
|
|
|
||||||
|
|
@ -1,375 +1,59 @@
|
||||||
import { describe, it, before, after } from "node:test";
|
import { describe, it, before, beforeEach, afterEach } from "node:test";
|
||||||
|
import { DockerTestContainer } from "./DockerTestContainer.js";
|
||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
import { spawn } from "node:child_process";
|
|
||||||
import { join, dirname } from "node:path";
|
|
||||||
import { homedir } from "node:os";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
let container;
|
||||||
const REPO_ROOT = join(__dirname, "../..");
|
|
||||||
|
|
||||||
const SAFE_CHAIN_BIN = join(
|
before(async () => {
|
||||||
REPO_ROOT,
|
DockerTestContainer.buildImage();
|
||||||
"packages/safe-chain/bin/safe-chain.js"
|
});
|
||||||
);
|
|
||||||
const AIKIDO_NPM_BIN = join(
|
|
||||||
REPO_ROOT,
|
|
||||||
"packages/safe-chain/bin/aikido-npm.js"
|
|
||||||
);
|
|
||||||
const AIKIDO_PIP_BIN = join(
|
|
||||||
REPO_ROOT,
|
|
||||||
"packages/safe-chain/bin/aikido-pip3.js"
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
beforeEach(async () => {
|
||||||
* Helper to start safe-chain run in agent mode
|
container = new DockerTestContainer();
|
||||||
* @param {string[]} args - Arguments to pass to safe-chain run
|
await container.start();
|
||||||
* @returns {Promise<{process: import('child_process').ChildProcess, port: number, pid: number}>}
|
});
|
||||||
*/
|
|
||||||
async function startAgentMode(args = []) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const proc = spawn("node", [SAFE_CHAIN_BIN, "run", ...args], {
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
detached: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let output = "";
|
afterEach(async () => {
|
||||||
let hasResolved = false;
|
if (container) {
|
||||||
|
await container.stop();
|
||||||
|
container = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const onData = (data) => {
|
describe("Agent Mode E2E", () => {
|
||||||
output += data.toString();
|
let shell;
|
||||||
|
|
||||||
// Strip ANSI color codes for parsing
|
beforeEach(async () => {
|
||||||
const strippedOutput = output.replace(/\x1b\[[0-9;]*m/g, '');
|
shell = await container.openShell("zsh");
|
||||||
|
await shell.runCommand("safe-chain setup-ci");
|
||||||
// Look for port and pid - they might arrive in separate chunks
|
await shell.runCommand("echo 'export PATH=\"$HOME/.safe-chain/shims:$PATH\"' >> ~/.zshrc");
|
||||||
const portMatch = strippedOutput.match(/Port:\s+(\d+)/);
|
|
||||||
const pidMatch = strippedOutput.match(/PID:\s+(\d+)/);
|
|
||||||
|
|
||||||
// Also check for the success checkmark as confirmation
|
|
||||||
const hasSuccess = strippedOutput.includes("Safe Chain proxy started successfully");
|
|
||||||
|
|
||||||
if (portMatch && pidMatch && hasSuccess && !hasResolved) {
|
|
||||||
hasResolved = true;
|
|
||||||
resolve({
|
|
||||||
process: proc,
|
|
||||||
port: parseInt(portMatch[1]),
|
|
||||||
pid: parseInt(pidMatch[1]),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
proc.stdout.on("data", onData);
|
|
||||||
proc.stderr.on("data", onData);
|
|
||||||
|
|
||||||
proc.on("error", (error) => {
|
|
||||||
if (!hasResolved) {
|
|
||||||
hasResolved = true;
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.on("exit", (code) => {
|
|
||||||
if (!hasResolved && code !== 0) {
|
|
||||||
hasResolved = true;
|
|
||||||
reject(new Error(`Process exited with code ${code}\n${output}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Timeout after 5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!hasResolved) {
|
|
||||||
hasResolved = true;
|
|
||||||
proc.kill();
|
|
||||||
reject(new Error(`Timeout waiting for agent to start\n${output}`));
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to stop agent mode process
|
|
||||||
* @param {import('child_process').ChildProcess} proc
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
async function stopAgentMode(proc) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (!proc || proc.killed) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolved = false;
|
|
||||||
const doResolve = () => {
|
|
||||||
if (!resolved) {
|
|
||||||
resolved = true;
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
proc.on("exit", doResolve);
|
|
||||||
|
|
||||||
// Send SIGTERM
|
|
||||||
proc.kill("SIGTERM");
|
|
||||||
|
|
||||||
// Force kill after 1 second if still running
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!proc.killed) {
|
|
||||||
proc.kill("SIGKILL");
|
|
||||||
}
|
|
||||||
doResolve();
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to run aikido-npm command
|
|
||||||
* @param {string[]} args
|
|
||||||
* @returns {Promise<{stdout: string, stderr: string, exitCode: number}>}
|
|
||||||
*/
|
|
||||||
async function runAikidoNpm(args) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const proc = spawn("node", [AIKIDO_NPM_BIN, ...args], {
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
cwd: "/tmp",
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdout = "";
|
|
||||||
let stderr = "";
|
|
||||||
|
|
||||||
proc.stdout.on("data", (data) => {
|
|
||||||
stdout += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.stderr.on("data", (data) => {
|
|
||||||
stderr += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.on("exit", (code) => {
|
|
||||||
resolve({
|
|
||||||
stdout,
|
|
||||||
stderr,
|
|
||||||
exitCode: code || 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to run aikido-pip command
|
|
||||||
* @param {string[]} args
|
|
||||||
* @returns {Promise<{stdout: string, stderr: string, exitCode: number}>}
|
|
||||||
*/
|
|
||||||
async function runAikidoPip(args) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const proc = spawn("node", [AIKIDO_PIP_BIN, ...args], {
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
cwd: "/tmp",
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdout = "";
|
|
||||||
let stderr = "";
|
|
||||||
|
|
||||||
proc.stdout.on("data", (data) => {
|
|
||||||
stdout += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.stderr.on("data", (data) => {
|
|
||||||
stderr += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.on("exit", (code) => {
|
|
||||||
resolve({
|
|
||||||
stdout,
|
|
||||||
stderr,
|
|
||||||
exitCode: code || 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Agent Mode E2E", { timeout: 60000 }, () => {
|
|
||||||
describe("safe-chain run", () => {
|
|
||||||
it("should start proxy successfully", async () => {
|
|
||||||
let agent;
|
|
||||||
try {
|
|
||||||
// Start agent mode
|
|
||||||
agent = await startAgentMode();
|
|
||||||
|
|
||||||
// Verify process is running
|
|
||||||
assert.ok(agent.process);
|
|
||||||
assert.ok(agent.port > 0);
|
|
||||||
assert.ok(agent.pid > 0);
|
|
||||||
} finally {
|
|
||||||
if (agent) {
|
|
||||||
await stopAgentMode(agent.process);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept verbose flag", async () => {
|
|
||||||
let agent;
|
|
||||||
try {
|
|
||||||
// Start agent mode with verbose flag
|
|
||||||
agent = await startAgentMode(["--verbose"]);
|
|
||||||
|
|
||||||
// Verify process started
|
|
||||||
assert.ok(agent.process);
|
|
||||||
assert.ok(agent.port > 0);
|
|
||||||
} finally {
|
|
||||||
if (agent) {
|
|
||||||
await stopAgentMode(agent.process);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should stop cleanly", async () => {
|
|
||||||
let agent;
|
|
||||||
try {
|
|
||||||
// Start agent mode
|
|
||||||
agent = await startAgentMode();
|
|
||||||
|
|
||||||
// Verify process is running
|
|
||||||
assert.ok(agent.process);
|
|
||||||
|
|
||||||
// Stop agent
|
|
||||||
await stopAgentMode(agent.process);
|
|
||||||
agent = null;
|
|
||||||
|
|
||||||
// Wait a bit for cleanup
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
} finally {
|
|
||||||
if (agent) {
|
|
||||||
await stopAgentMode(agent.process);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("aikido-npm with agent mode", () => {
|
it("should start proxy successfully", async () => {
|
||||||
let agent;
|
const result = await shell.runCommand("safe-chain run & sleep 2; ps aux | grep safe-chain");
|
||||||
|
assert.ok(result.output.includes("safe-chain"), "Proxy did not start successfully");
|
||||||
before(async () => {
|
|
||||||
// Start agent mode for all npm tests
|
|
||||||
agent = await startAgentMode(["--verbose"]);
|
|
||||||
// Wait a bit to ensure proxy is fully ready
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
// Stop agent after all tests
|
|
||||||
if (agent) {
|
|
||||||
await stopAgentMode(agent.process);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use existing proxy when running npm view", async () => {
|
|
||||||
const result = await runAikidoNpm(["view", "lodash", "version"]);
|
|
||||||
|
|
||||||
// Should succeed
|
|
||||||
assert.strictEqual(result.exitCode, 0);
|
|
||||||
|
|
||||||
// Should have output with version
|
|
||||||
assert.ok(result.stdout.includes("4.17") || result.stdout.includes("lodash"));
|
|
||||||
|
|
||||||
// Verify proxy intercepted the request (check stderr for proxy messages)
|
|
||||||
const allOutput = result.stdout + result.stderr;
|
|
||||||
assert.ok(
|
|
||||||
allOutput.includes("registry.npmjs.org") ||
|
|
||||||
allOutput.includes("MITM") ||
|
|
||||||
result.exitCode === 0,
|
|
||||||
"Should use proxy for request"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use existing proxy when running npm info", async () => {
|
|
||||||
const result = await runAikidoNpm(["info", "express", "version"]);
|
|
||||||
|
|
||||||
// Should succeed
|
|
||||||
assert.strictEqual(result.exitCode, 0);
|
|
||||||
|
|
||||||
// Should have output
|
|
||||||
assert.ok(result.stdout.length > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should detect malware using existing proxy", async () => {
|
|
||||||
// Note: This test assumes there's a known malware package in the database
|
|
||||||
// If no malware is configured, the test will just verify the command runs
|
|
||||||
const result = await runAikidoNpm(["view", "some-test-package", "version"]);
|
|
||||||
|
|
||||||
// Command should complete (may succeed or fail depending on package existence)
|
|
||||||
assert.ok(result.exitCode !== undefined);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("aikido-pip with agent mode", () => {
|
it("should accept verbose flag", async () => {
|
||||||
let agent;
|
const result = await shell.runCommand("safe-chain run --verbose & sleep 2; ps aux | grep safe-chain");
|
||||||
|
assert.ok(result.output.includes("safe-chain"), "Proxy did not start with verbose flag");
|
||||||
before(async () => {
|
|
||||||
// Start agent mode for all pip tests
|
|
||||||
agent = await startAgentMode(["--verbose"]);
|
|
||||||
// Wait a bit to ensure proxy is fully ready
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
// Stop agent after all tests
|
|
||||||
if (agent) {
|
|
||||||
await stopAgentMode(agent.process);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use existing proxy when running pip download", async () => {
|
|
||||||
// Use --dry-run to avoid actual installation
|
|
||||||
const result = await runAikidoPip(["download", "requests", "--dry-run"]);
|
|
||||||
|
|
||||||
// Command should complete
|
|
||||||
assert.ok(result.exitCode !== undefined);
|
|
||||||
|
|
||||||
// Should have some output
|
|
||||||
const allOutput = result.stdout + result.stderr;
|
|
||||||
assert.ok(allOutput.length > 0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("inline mode (no agent)", () => {
|
it("should stop cleanly", async () => {
|
||||||
it("should start inline proxy when no agent is running", async () => {
|
await shell.runCommand("safe-chain run & sleep 2; pkill -f safe-chain");
|
||||||
// Run aikido-npm without agent mode
|
const result = await shell.runCommand("ps aux | grep safe-chain");
|
||||||
const result = await runAikidoNpm(["view", "lodash", "version"]);
|
assert.ok(!result.output.includes("safe-chain run"), "Proxy did not stop cleanly");
|
||||||
|
|
||||||
// Should succeed with inline proxy
|
|
||||||
assert.strictEqual(result.exitCode, 0);
|
|
||||||
|
|
||||||
// Should have output
|
|
||||||
assert.ok(result.stdout.includes("4.17") || result.stdout.includes("lodash"));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("combined ecosystems", () => {
|
it("should use existing proxy when running npm view", async () => {
|
||||||
let agent;
|
await shell.runCommand("safe-chain run & sleep 2");
|
||||||
|
const result = await shell.runCommand("npm view lodash version");
|
||||||
|
assert.ok(result.output.includes("4.17") || result.output.includes("lodash"));
|
||||||
|
});
|
||||||
|
|
||||||
before(async () => {
|
it("should use existing proxy when running pip download", async () => {
|
||||||
// Start agent mode (supports all ecosystems by default)
|
await shell.runCommand("safe-chain run & sleep 2");
|
||||||
agent = await startAgentMode(["--verbose"]);
|
const result = await shell.runCommand("pip download requests --dry-run");
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
assert.ok(result.output.length > 0);
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
if (agent) {
|
|
||||||
await stopAgentMode(agent.process);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle npm requests", async () => {
|
|
||||||
const result = await runAikidoNpm(["view", "chalk", "version"]);
|
|
||||||
assert.strictEqual(result.exitCode, 0);
|
|
||||||
assert.ok(result.stdout.length > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle pip requests", async () => {
|
|
||||||
const result = await runAikidoPip(["download", "requests", "--dry-run"]);
|
|
||||||
// Command should complete
|
|
||||||
assert.ok(result.exitCode !== undefined);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue