mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 08:15:17 +00:00
fix(docker): bound blocking command execution
- add default local and remote sync-command timeouts with clearer timeout diagnostics - remove extra synchronous fs and process.exit patterns from docker assets/bootstrap helpers - register the docker help handler in CLAUDE.md for the repo help-location reference
This commit is contained in:
1 parent
6ac8f59a78
commit
5ad4303345
5 files changed
+156
-48
No files matched your search
@@ -156,6 +156,7 @@ bun run validate # Step 3: Final check (must pass)
|
|||||||
| `ccs copilot --help` | `src/commands/copilot-command.ts` → `handleHelp()` |
|
| `ccs copilot --help` | `src/commands/copilot-command.ts` → `handleHelp()` |
|
||||||
| `ccs cursor --help` | `src/commands/cursor-command.ts` → `handleHelp()` |
|
| `ccs cursor --help` | `src/commands/cursor-command.ts` → `handleHelp()` |
|
||||||
| `ccs doctor --help` | `src/commands/doctor-command.ts` → `showHelp()` |
|
| `ccs doctor --help` | `src/commands/doctor-command.ts` → `showHelp()` |
|
||||||
|
| `ccs docker --help` | `src/commands/docker/help-subcommand.ts` → `showHelp()` |
|
||||||
| `ccs migrate --help` | `src/commands/migrate-command.ts` → `printMigrateHelp()` |
|
| `ccs migrate --help` | `src/commands/migrate-command.ts` → `printMigrateHelp()` |
|
||||||
| `ccs env --help` | `src/commands/env-command.ts` → `showHelp()` |
|
| `ccs env --help` | `src/commands/env-command.ts` → `showHelp()` |
|
||||||
| `ccs persist --help` | `src/commands/persist-command.ts` → `showHelp()` |
|
| `ccs persist --help` | `src/commands/persist-command.ts` → `showHelp()` |
|
||||||
|
|||||||
+30
-12
@@ -1,4 +1,3 @@
|
|||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { getCcsDir } from '../utils/config-manager';
|
import { getCcsDir } from '../utils/config-manager';
|
||||||
import type { DockerConfigSummary } from './docker-types';
|
import type { DockerConfigSummary } from './docker-types';
|
||||||
@@ -22,10 +21,33 @@ export interface DockerAssetPaths {
|
|||||||
entrypoint: string;
|
entrypoint: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let hasWarnedAboutVersionFallback = false;
|
||||||
|
|
||||||
function getPackageRoot(): string {
|
function getPackageRoot(): string {
|
||||||
return path.resolve(__dirname, '..', '..');
|
return path.resolve(__dirname, '..', '..');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureBundledAsset(assetPath: string): void {
|
||||||
|
try {
|
||||||
|
require.resolve(assetPath);
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`Missing bundled Docker asset: ${assetPath}\nReinstall CCS or use a package build that includes docker/ assets.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function warnAboutVersionFallback(error: unknown): void {
|
||||||
|
if (hasWarnedAboutVersionFallback) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hasWarnedAboutVersionFallback = true;
|
||||||
|
const detail = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error(
|
||||||
|
`[!] Failed to determine the installed CCS version from package metadata; falling back to latest.\n${detail}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getDockerAssetPaths(): DockerAssetPaths {
|
export function getDockerAssetPaths(): DockerAssetPaths {
|
||||||
const packageRoot = getPackageRoot();
|
const packageRoot = getPackageRoot();
|
||||||
const dockerDir = path.join(packageRoot, 'docker');
|
const dockerDir = path.join(packageRoot, 'docker');
|
||||||
@@ -37,13 +59,10 @@ export function getDockerAssetPaths(): DockerAssetPaths {
|
|||||||
entrypoint: path.join(dockerDir, 'entrypoint-integrated.sh'),
|
entrypoint: path.join(dockerDir, 'entrypoint-integrated.sh'),
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const assetPath of Object.values(assets)) {
|
ensureBundledAsset(assets.composeFile);
|
||||||
if (!fs.existsSync(assetPath)) {
|
ensureBundledAsset(assets.dockerfile);
|
||||||
throw new Error(
|
ensureBundledAsset(assets.supervisordConfig);
|
||||||
`Missing bundled Docker asset: ${assetPath}\nReinstall CCS or use a package build that includes docker/ assets.`
|
ensureBundledAsset(assets.entrypoint);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return assets;
|
return assets;
|
||||||
}
|
}
|
||||||
@@ -51,11 +70,10 @@ export function getDockerAssetPaths(): DockerAssetPaths {
|
|||||||
export function getInstalledCcsVersion(): string {
|
export function getInstalledCcsVersion(): string {
|
||||||
const packageJsonPath = path.join(getPackageRoot(), 'package.json');
|
const packageJsonPath = path.join(getPackageRoot(), 'package.json');
|
||||||
try {
|
try {
|
||||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as {
|
const packageJson = require(packageJsonPath) as { version?: string };
|
||||||
version?: string;
|
|
||||||
};
|
|
||||||
return packageJson.version?.trim() || 'latest';
|
return packageJson.version?.trim() || 'latest';
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
warnAboutVersionFallback(error);
|
||||||
return 'latest';
|
return 'latest';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ async function prepareIntegratedRuntime(): Promise<{ binaryPath: string; configP
|
|||||||
return { binaryPath, configPath };
|
return { binaryPath, configPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runCliproxy(): Promise<void> {
|
async function runCliproxy(): Promise<number> {
|
||||||
const { binaryPath, configPath } = await prepareIntegratedRuntime();
|
const { binaryPath, configPath } = await prepareIntegratedRuntime();
|
||||||
await new Promise<void>((resolve, reject) => {
|
return new Promise<number>((resolve, reject) => {
|
||||||
const child = spawn(binaryPath, ['--config', configPath], {
|
const child = spawn(binaryPath, ['--config', configPath], {
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: {
|
env: {
|
||||||
@@ -34,27 +34,30 @@ async function runCliproxy(): Promise<void> {
|
|||||||
|
|
||||||
child.on('error', reject);
|
child.on('error', reject);
|
||||||
child.on('close', (code) => {
|
child.on('close', (code) => {
|
||||||
process.exit(code ?? 1);
|
resolve(code ?? 1);
|
||||||
});
|
});
|
||||||
child.on('spawn', () => resolve());
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<number> {
|
||||||
const command = process.argv[2];
|
const command = process.argv[2];
|
||||||
if (command !== 'run-cliproxy') {
|
if (command !== 'run-cliproxy') {
|
||||||
console.error('[X] Usage: node dist/docker/docker-bootstrap.js run-cliproxy');
|
console.error('[X] Usage: node dist/docker/docker-bootstrap.js run-cliproxy');
|
||||||
process.exit(1);
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
await runCliproxy();
|
return runCliproxy();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
void main().catch((error) => {
|
void main()
|
||||||
console.error(
|
.then((exitCode) => {
|
||||||
`[X] Failed to prepare Docker runtime: ${error instanceof Error ? error.message : String(error)}`
|
process.exitCode = exitCode;
|
||||||
);
|
})
|
||||||
process.exit(1);
|
.catch((error) => {
|
||||||
});
|
console.error(
|
||||||
|
`[X] Failed to prepare Docker runtime: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,9 @@ import type {
|
|||||||
DockerUpOptions,
|
DockerUpOptions,
|
||||||
} from './docker-types';
|
} from './docker-types';
|
||||||
|
|
||||||
|
const LOCAL_DOCKER_SYNC_TIMEOUT_MS = 10_000;
|
||||||
|
const REMOTE_DOCKER_SYNC_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
function quotePosix(value: string): string {
|
function quotePosix(value: string): string {
|
||||||
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
||||||
}
|
}
|
||||||
@@ -35,6 +38,37 @@ interface DockerSyncRunOptions {
|
|||||||
cwd?: string;
|
cwd?: string;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
remote?: boolean;
|
remote?: boolean;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeout(timeoutMs: number): string {
|
||||||
|
return timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}s` : `${timeoutMs}ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTimeoutMessage(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
timeoutMs: number,
|
||||||
|
remote: boolean
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
`Command timed out after ${formatTimeout(timeoutMs)} while running a ${
|
||||||
|
remote ? 'remote' : 'local'
|
||||||
|
} Docker command.`,
|
||||||
|
`Command: ${renderCommand(command, args)}`,
|
||||||
|
remote
|
||||||
|
? 'Check SSH reachability and the remote Docker host, then try again.'
|
||||||
|
: 'Check Docker availability on this machine, then try again.',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDefaultTimeouts(options: DockerSyncRunOptions): DockerSyncRunOptions {
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
timeoutMs:
|
||||||
|
options.timeoutMs ??
|
||||||
|
(options.remote ? REMOTE_DOCKER_SYNC_TIMEOUT_MS : LOCAL_DOCKER_SYNC_TIMEOUT_MS),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function runSync(
|
function runSync(
|
||||||
@@ -42,21 +76,32 @@ function runSync(
|
|||||||
args: string[],
|
args: string[],
|
||||||
options: DockerSyncRunOptions = {}
|
options: DockerSyncRunOptions = {}
|
||||||
): DockerCommandResult {
|
): DockerCommandResult {
|
||||||
|
const normalizedOptions = applyDefaultTimeouts(options);
|
||||||
const result = spawnSync(command, args, {
|
const result = spawnSync(command, args, {
|
||||||
cwd: options.cwd,
|
cwd: normalizedOptions.cwd,
|
||||||
env: options.env,
|
env: normalizedOptions.env,
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
|
timeout: normalizedOptions.timeoutMs,
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
const stderr = [normalizeOutput(result.stderr), result.error?.message].filter(Boolean).join('\n');
|
const errorMessage =
|
||||||
|
result.error && 'code' in result.error && result.error.code === 'ETIMEDOUT'
|
||||||
|
? buildTimeoutMessage(
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
normalizedOptions.timeoutMs ?? LOCAL_DOCKER_SYNC_TIMEOUT_MS,
|
||||||
|
normalizedOptions.remote ?? false
|
||||||
|
)
|
||||||
|
: result.error?.message;
|
||||||
|
const stderr = [normalizeOutput(result.stderr), errorMessage].filter(Boolean).join('\n');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
command: renderCommand(command, args),
|
command: renderCommand(command, args),
|
||||||
exitCode: result.status ?? 1,
|
exitCode: result.status ?? 1,
|
||||||
stdout: normalizeOutput(result.stdout),
|
stdout: normalizeOutput(result.stdout),
|
||||||
stderr,
|
stderr,
|
||||||
remote: options.remote ?? false,
|
remote: normalizedOptions.remote ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,12 +123,27 @@ function runStreaming(command: string, args: string[]): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cachedLocalComposePrefix: string[] | undefined;
|
||||||
|
|
||||||
function resolveLocalComposePrefix(): string[] {
|
function resolveLocalComposePrefix(): string[] {
|
||||||
if (runSync('docker', ['compose', 'version']).exitCode === 0) {
|
if (cachedLocalComposePrefix) {
|
||||||
return ['docker', 'compose'];
|
return [...cachedLocalComposePrefix];
|
||||||
}
|
}
|
||||||
if (runSync('docker-compose', ['version']).exitCode === 0) {
|
if (
|
||||||
return ['docker-compose'];
|
runSync('docker', ['compose', 'version'], {
|
||||||
|
timeoutMs: LOCAL_DOCKER_SYNC_TIMEOUT_MS,
|
||||||
|
}).exitCode === 0
|
||||||
|
) {
|
||||||
|
cachedLocalComposePrefix = ['docker', 'compose'];
|
||||||
|
return [...cachedLocalComposePrefix];
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
runSync('docker-compose', ['version'], {
|
||||||
|
timeoutMs: LOCAL_DOCKER_SYNC_TIMEOUT_MS,
|
||||||
|
}).exitCode === 0
|
||||||
|
) {
|
||||||
|
cachedLocalComposePrefix = ['docker-compose'];
|
||||||
|
return [...cachedLocalComposePrefix];
|
||||||
}
|
}
|
||||||
throw new Error('Docker Compose is not available. Install Docker Desktop or docker-compose.');
|
throw new Error('Docker Compose is not available. Install Docker Desktop or docker-compose.');
|
||||||
}
|
}
|
||||||
@@ -129,7 +189,7 @@ export class DockerExecutor {
|
|||||||
return createDockerConfigSummary(options);
|
return createDockerConfigSummary(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async up(options: DockerUpOptions): Promise<void> {
|
up(options: DockerUpOptions): void {
|
||||||
if (options.host) {
|
if (options.host) {
|
||||||
this.stageRemoteAssets(options.host);
|
this.stageRemoteAssets(options.host);
|
||||||
}
|
}
|
||||||
@@ -144,11 +204,11 @@ export class DockerExecutor {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async down(options: DockerCommandTarget): Promise<void> {
|
down(options: DockerCommandTarget): void {
|
||||||
this.ensureSuccess(this.runCompose(['down'], options), 'Docker stack shutdown', options);
|
this.ensureSuccess(this.runCompose(['down'], options), 'Docker stack shutdown', options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async status(options: DockerCommandTarget): Promise<DockerStatusResult> {
|
status(options: DockerCommandTarget): DockerStatusResult {
|
||||||
const compose = this.runCompose(['ps'], options);
|
const compose = this.runCompose(['ps'], options);
|
||||||
this.ensureSuccess(compose, 'Docker status', options);
|
this.ensureSuccess(compose, 'Docker status', options);
|
||||||
let supervisor: DockerCommandResult | undefined;
|
let supervisor: DockerCommandResult | undefined;
|
||||||
@@ -161,7 +221,7 @@ export class DockerExecutor {
|
|||||||
return { compose, supervisor };
|
return { compose, supervisor };
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(options: DockerCommandTarget): Promise<void> {
|
update(options: DockerCommandTarget): void {
|
||||||
const script =
|
const script =
|
||||||
'npm install -g @kaitranntt/ccs@latest --force && ccs cliproxy --latest && supervisorctl -c /etc/supervisord.conf restart ccs-dashboard cliproxy';
|
'npm install -g @kaitranntt/ccs@latest --force && ccs cliproxy --latest && supervisorctl -c /etc/supervisord.conf restart ccs-dashboard cliproxy';
|
||||||
this.ensureSuccess(
|
this.ensureSuccess(
|
||||||
@@ -268,7 +328,11 @@ export class DockerExecutor {
|
|||||||
args: string[],
|
args: string[],
|
||||||
options: DockerSyncRunOptions = {}
|
options: DockerSyncRunOptions = {}
|
||||||
): DockerCommandResult {
|
): DockerCommandResult {
|
||||||
return this.deps.runSync?.(command, args, options) ?? runSync(command, args, options);
|
const normalizedOptions = applyDefaultTimeouts(options);
|
||||||
|
return (
|
||||||
|
this.deps.runSync?.(command, args, normalizedOptions) ??
|
||||||
|
runSync(command, args, normalizedOptions)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runStreaming(command: string, args: string[]): Promise<void> {
|
private async runStreaming(command: string, args: string[]): Promise<void> {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type SyncCall = {
|
|||||||
cwd?: string;
|
cwd?: string;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
remote?: boolean;
|
remote?: boolean;
|
||||||
|
timeoutMs?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,18 +48,12 @@ describe('docker executor', () => {
|
|||||||
|
|
||||||
expect(calls).toHaveLength(1);
|
expect(calls).toHaveLength(1);
|
||||||
expect(calls[0].command).toBe('docker');
|
expect(calls[0].command).toBe('docker');
|
||||||
expect(calls[0].args).toEqual([
|
expect(calls[0].args).toEqual(['compose', '-f', fakeAssets.composeFile, 'up', '-d', '--build']);
|
||||||
'compose',
|
|
||||||
'-f',
|
|
||||||
fakeAssets.composeFile,
|
|
||||||
'up',
|
|
||||||
'-d',
|
|
||||||
'--build',
|
|
||||||
]);
|
|
||||||
expect(calls[0].options?.cwd).toBe(fakeAssets.dockerDir);
|
expect(calls[0].options?.cwd).toBe(fakeAssets.dockerDir);
|
||||||
expect(calls[0].options?.env?.CCS_NPM_VERSION).toBe('7.59.0');
|
expect(calls[0].options?.env?.CCS_NPM_VERSION).toBe('7.59.0');
|
||||||
expect(calls[0].options?.env?.CCS_DASHBOARD_PORT).toBe('4000');
|
expect(calls[0].options?.env?.CCS_DASHBOARD_PORT).toBe('4000');
|
||||||
expect(calls[0].options?.env?.CCS_CLIPROXY_PORT).toBe('9317');
|
expect(calls[0].options?.env?.CCS_CLIPROXY_PORT).toBe('9317');
|
||||||
|
expect(calls[0].options?.timeoutMs).toBe(10_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stages bundled assets before remote compose startup', async () => {
|
it('stages bundled assets before remote compose startup', async () => {
|
||||||
@@ -78,7 +73,7 @@ describe('docker executor', () => {
|
|||||||
expect(calls[0]).toEqual({
|
expect(calls[0]).toEqual({
|
||||||
command: 'ssh',
|
command: 'ssh',
|
||||||
args: ['docker', 'mkdir -p ~/.ccs/docker'],
|
args: ['docker', 'mkdir -p ~/.ccs/docker'],
|
||||||
options: { remote: true },
|
options: { remote: true, timeoutMs: 30_000 },
|
||||||
});
|
});
|
||||||
expect(calls[1].command).toBe('scp');
|
expect(calls[1].command).toBe('scp');
|
||||||
expect(calls[1].args).toEqual([
|
expect(calls[1].args).toEqual([
|
||||||
@@ -88,13 +83,14 @@ describe('docker executor', () => {
|
|||||||
fakeAssets.entrypoint,
|
fakeAssets.entrypoint,
|
||||||
'docker:~/.ccs/docker/',
|
'docker:~/.ccs/docker/',
|
||||||
]);
|
]);
|
||||||
expect(calls[1].options).toEqual({ remote: true });
|
expect(calls[1].options).toEqual({ remote: true, timeoutMs: 30_000 });
|
||||||
expect(calls[2].command).toBe('ssh');
|
expect(calls[2].command).toBe('ssh');
|
||||||
expect(calls[2].args[0]).toBe('docker');
|
expect(calls[2].args[0]).toBe('docker');
|
||||||
expect(calls[2].args[1]).toContain("CCS_NPM_VERSION='7.59.0'");
|
expect(calls[2].args[1]).toContain("CCS_NPM_VERSION='7.59.0'");
|
||||||
expect(calls[2].args[1]).toContain("CCS_DASHBOARD_PORT='3000'");
|
expect(calls[2].args[1]).toContain("CCS_DASHBOARD_PORT='3000'");
|
||||||
expect(calls[2].args[1]).toContain("CCS_CLIPROXY_PORT='8317'");
|
expect(calls[2].args[1]).toContain("CCS_CLIPROXY_PORT='8317'");
|
||||||
expect(calls[2].args[1]).toContain('docker-compose version >/dev/null 2>&1');
|
expect(calls[2].args[1]).toContain('docker-compose version >/dev/null 2>&1');
|
||||||
|
expect(calls[2].options?.timeoutMs).toBe(30_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses npm install latest rather than npm update during in-container updates', async () => {
|
it('uses npm install latest rather than npm update during in-container updates', async () => {
|
||||||
@@ -162,4 +158,30 @@ describe('docker executor', () => {
|
|||||||
expect(status.supervisor?.exitCode).toBe(7);
|
expect(status.supervisor?.exitCode).toBe(7);
|
||||||
expect(status.supervisor?.stderr).toContain('supervisor.sock');
|
expect(status.supervisor?.stderr).toContain('supervisor.sock');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('surfaces a clear timeout message for blocked sync commands', () => {
|
||||||
|
const executor = new DockerExecutor({
|
||||||
|
assets: fakeAssets,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = (
|
||||||
|
executor as unknown as {
|
||||||
|
runSync: (
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options?: { remote?: boolean; timeoutMs?: number }
|
||||||
|
) => DockerCommandResult;
|
||||||
|
}
|
||||||
|
).runSync(process.execPath, ['-e', 'setTimeout(() => {}, 1000)'], {
|
||||||
|
remote: true,
|
||||||
|
timeoutMs: 25,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.stderr).toContain(
|
||||||
|
'Command timed out after 25ms while running a remote Docker command.'
|
||||||
|
);
|
||||||
|
expect(result.stderr).toContain(`Command: ${process.execPath}`);
|
||||||
|
expect(result.stderr).toContain('Check SSH reachability and the remote Docker host');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user