From e843ca9d1c92da666391007c9b53b009afacf26e Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Thu, 30 Oct 2025 16:46:00 +0000 Subject: [PATCH 1/3] refactor: extract CLI workflow into cli-workflow.ts and add unit tests Signed-off-by: Jiaxiao (mossaka) Zhou --- src/cli-workflow.test.ts | 109 +++++++++++++++++++++++++++++++++++++++ src/cli-workflow.ts | 68 ++++++++++++++++++++++++ src/cli.ts | 47 ++++++++--------- 3 files changed, 198 insertions(+), 26 deletions(-) create mode 100644 src/cli-workflow.test.ts create mode 100644 src/cli-workflow.ts diff --git a/src/cli-workflow.test.ts b/src/cli-workflow.test.ts new file mode 100644 index 000000000..90975585d --- /dev/null +++ b/src/cli-workflow.test.ts @@ -0,0 +1,109 @@ +import { runMainWorkflow, WorkflowDependencies } from './cli-workflow'; +import { WrapperConfig } from './types'; + +const baseConfig: WrapperConfig = { + allowedDomains: ['github.com'], + copilotCommand: 'echo "hello"', + logLevel: 'info', + keepContainers: false, + workDir: '/tmp/awf-test', + imageRegistry: 'registry', + imageTag: 'latest', + buildLocal: false, +}; + +const createLogger = () => ({ + info: jest.fn(), + success: jest.fn(), + warn: jest.fn(), +}); + +describe('runMainWorkflow', () => { + it('executes workflow steps in order and logs success for zero exit code', async () => { + const callOrder: string[] = []; + const dependencies: WorkflowDependencies = { + ensureFirewallNetwork: jest.fn().mockImplementation(async () => { + callOrder.push('ensureFirewallNetwork'); + return { squidIp: '172.30.0.10' }; + }), + setupHostIptables: jest.fn().mockImplementation(async () => { + callOrder.push('setupHostIptables'); + }), + writeConfigs: jest.fn().mockImplementation(async () => { + callOrder.push('writeConfigs'); + }), + startContainers: jest.fn().mockImplementation(async () => { + callOrder.push('startContainers'); + }), + runCopilotCommand: jest.fn().mockImplementation(async () => { + callOrder.push('runCopilotCommand'); + return { exitCode: 0 }; + }), + }; + const performCleanup = jest.fn().mockImplementation(async () => { + callOrder.push('performCleanup'); + }); + const logger = createLogger(); + + const exitCode = await runMainWorkflow(baseConfig, dependencies, { + logger, + performCleanup, + }); + + expect(callOrder).toEqual([ + 'ensureFirewallNetwork', + 'setupHostIptables', + 'writeConfigs', + 'startContainers', + 'runCopilotCommand', + 'performCleanup', + ]); + expect(exitCode).toBe(0); + expect(logger.success).toHaveBeenCalledWith('Command completed successfully'); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('logs warning with exit code when command fails', async () => { + const callOrder: string[] = []; + const dependencies: WorkflowDependencies = { + ensureFirewallNetwork: jest.fn().mockImplementation(async () => { + callOrder.push('ensureFirewallNetwork'); + return { squidIp: '172.30.0.10' }; + }), + setupHostIptables: jest.fn().mockImplementation(async () => { + callOrder.push('setupHostIptables'); + }), + writeConfigs: jest.fn().mockImplementation(async () => { + callOrder.push('writeConfigs'); + }), + startContainers: jest.fn().mockImplementation(async () => { + callOrder.push('startContainers'); + }), + runCopilotCommand: jest.fn().mockImplementation(async () => { + callOrder.push('runCopilotCommand'); + return { exitCode: 42 }; + }), + }; + const performCleanup = jest.fn().mockImplementation(async () => { + callOrder.push('performCleanup'); + }); + const logger = createLogger(); + + const exitCode = await runMainWorkflow(baseConfig, dependencies, { + logger, + performCleanup, + }); + + expect(exitCode).toBe(42); + expect(callOrder).toEqual([ + 'ensureFirewallNetwork', + 'setupHostIptables', + 'writeConfigs', + 'startContainers', + 'runCopilotCommand', + 'performCleanup', + ]); + expect(logger.warn).toHaveBeenCalledWith('Command completed with exit code: 42'); + expect(logger.success).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts new file mode 100644 index 000000000..67ca5282c --- /dev/null +++ b/src/cli-workflow.ts @@ -0,0 +1,68 @@ +import { WrapperConfig } from './types'; + +export interface WorkflowDependencies { + ensureFirewallNetwork: () => Promise<{ squidIp: string }>; + setupHostIptables: (squidIp: string, port: number) => Promise; + writeConfigs: (config: WrapperConfig) => Promise; + startContainers: (workDir: string, allowedDomains: string[]) => Promise; + runCopilotCommand: ( + workDir: string, + allowedDomains: string[] + ) => Promise<{ exitCode: number }>; +} + +export interface WorkflowCallbacks { + onHostIptablesSetup?: () => void; + onContainersStarted?: () => void; +} + +export interface WorkflowLogger { + info: (message: string, ...args: unknown[]) => void; + success: (message: string, ...args: unknown[]) => void; + warn: (message: string, ...args: unknown[]) => void; +} + +export interface WorkflowOptions extends WorkflowCallbacks { + logger: WorkflowLogger; + performCleanup: () => Promise; +} + +/** + * Executes the primary workflow for the CLI. This function is intentionally pure so + * it can be unit tested with mocked dependencies. + */ +export async function runMainWorkflow( + config: WrapperConfig, + dependencies: WorkflowDependencies, + options: WorkflowOptions +): Promise { + const { logger, performCleanup, onHostIptablesSetup, onContainersStarted } = options; + + // Step 0: Setup host-level network and iptables + logger.info('Setting up host-level firewall network and iptables rules...'); + const networkConfig = await dependencies.ensureFirewallNetwork(); + await dependencies.setupHostIptables(networkConfig.squidIp, 3128); + onHostIptablesSetup?.(); + + // Step 1: Write configuration files + logger.info('Generating configuration files...'); + await dependencies.writeConfigs(config); + + // Step 2: Start containers + await dependencies.startContainers(config.workDir, config.allowedDomains); + onContainersStarted?.(); + + // Step 3: Wait for copilot to complete + const result = await dependencies.runCopilotCommand(config.workDir, config.allowedDomains); + + // Step 4: Cleanup (logs will be preserved automatically if they exist) + await performCleanup(); + + if (result.exitCode === 0) { + logger.success('Command completed successfully'); + } else { + logger.warn(`Command completed with exit code: ${result.exitCode}`); + } + + return result.exitCode; +} diff --git a/src/cli.ts b/src/cli.ts index f3bec7916..7fe76860d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,8 +17,8 @@ import { ensureFirewallNetwork, setupHostIptables, cleanupHostIptables, - cleanupFirewallNetwork, } from './host-iptables'; +import { runMainWorkflow } from './cli-workflow'; /** * Redacts sensitive information from command strings @@ -190,32 +190,27 @@ program }); try { - // Step 0: Setup host-level network and iptables - logger.info('Setting up host-level firewall network and iptables rules...'); - const networkConfig = await ensureFirewallNetwork(); - await setupHostIptables(networkConfig.squidIp, 3128); - hostIptablesSetup = true; - - // Step 1: Write configuration files - logger.info('Generating configuration files...'); - await writeConfigs(config); - - // Step 2: Start containers - await startContainers(config.workDir, config.allowedDomains); - containersStarted = true; - - // Step 3: Wait for copilot to complete - const result = await runCopilotCommand(config.workDir, config.allowedDomains); - exitCode = result.exitCode; - - // Step 4: Cleanup (logs will be preserved automatically if they exist) - await performCleanup(); + exitCode = await runMainWorkflow( + config, + { + ensureFirewallNetwork, + setupHostIptables, + writeConfigs, + startContainers, + runCopilotCommand, + }, + { + logger, + performCleanup, + onHostIptablesSetup: () => { + hostIptablesSetup = true; + }, + onContainersStarted: () => { + containersStarted = true; + }, + } + ); - if (exitCode === 0) { - logger.success(`Command completed successfully`); - } else { - logger.warn(`Command completed with exit code: ${exitCode}`); - } process.exit(exitCode); } catch (error) { logger.error('Fatal error:', error); From d6a85015f98f0dcdcf56f0a7e4f4c90feed8fd2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:47:35 +0000 Subject: [PATCH 2/3] Initial plan From c1f03a2e09d1d7ccfa826acf77d1e962b88aa775 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Oct 2025 16:54:23 +0000 Subject: [PATCH 3/3] Export subnetsOverlap function to eliminate test duplication Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- src/docker-manager.test.ts | 25 +------------------------ src/docker-manager.ts | 2 +- 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index fcb1570bf..c2ec0fe9b 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -1,31 +1,8 @@ -import { generateDockerCompose } from './docker-manager'; +import { generateDockerCompose, subnetsOverlap } from './docker-manager'; import { WrapperConfig } from './types'; describe('docker-manager', () => { describe('subnetsOverlap', () => { - // Import private function for testing by extracting logic - const subnetsOverlap = (subnet1: string, subnet2: string): boolean => { - const [ip1, cidr1] = subnet1.split('/'); - const [ip2, cidr2] = subnet2.split('/'); - - const ipToNumber = (ip: string): number => { - return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0; - }; - - const getNetworkRange = (ip: string, cidr: string): [number, number] => { - const ipNum = ipToNumber(ip); - const maskBits = parseInt(cidr, 10); - const mask = (0xffffffff << (32 - maskBits)) >>> 0; - const networkAddr = (ipNum & mask) >>> 0; - const broadcastAddr = (networkAddr | ~mask) >>> 0; - return [networkAddr, broadcastAddr]; - }; - - const [start1, end1] = getNetworkRange(ip1, cidr1); - const [start2, end2] = getNetworkRange(ip2, cidr2); - - return (start1 <= end2 && end1 >= start2); - }; it('should detect overlapping subnets with same CIDR', () => { expect(subnetsOverlap('172.30.0.0/24', '172.30.0.0/24')).toBe(true); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index ddb99e8da..9a7865d76 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -46,7 +46,7 @@ async function getExistingDockerSubnets(): Promise { * Checks if two subnets overlap * Returns true if the new subnet conflicts with an existing subnet */ -function subnetsOverlap(subnet1: string, subnet2: string): boolean { +export function subnetsOverlap(subnet1: string, subnet2: string): boolean { // Parse CIDR notation: "172.17.0.0/16" -> ["172.17.0.0", "16"] const [ip1, cidr1] = subnet1.split('/'); const [ip2, cidr2] = subnet2.split('/');