From adaac3508052513c2afc500d747f44543b5bc7cb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:52:54 +0000 Subject: [PATCH 1/2] test: add coverage for main-action sbx branches Cover uncovered branches in src/commands/main-action.ts: - sbx isSbxAvailable=false error path - sbx api-proxy health check failure (proceeds with warning) - sbx runAgentCommand non-zero exit with enableApiProxy diagnostics - sbx cleanup removeSandbox with keepContainers=false vs true - non-sbx runAgentCommand containerRuntime passthrough - sbx dnsOverHttps wiring - GITHUB_WORKSPACE env var fallback in createSandbox Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../main-action-coverage-gaps.test.ts | 343 ++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 src/commands/main-action-coverage-gaps.test.ts diff --git a/src/commands/main-action-coverage-gaps.test.ts b/src/commands/main-action-coverage-gaps.test.ts new file mode 100644 index 000000000..514b454d9 --- /dev/null +++ b/src/commands/main-action-coverage-gaps.test.ts @@ -0,0 +1,343 @@ +// Module-level mock functions for fs — must be declared before jest.mock('fs') +const mockMkdirSync = jest.fn(); +const mockWriteFileSync = jest.fn(); +const mockChmodSync = jest.fn(); +const mockOpenSync = jest.fn().mockReturnValue(42); +const mockCloseSync = jest.fn(); + +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return { + ...actual, + mkdirSync: (...args: unknown[]) => mockMkdirSync(...args), + writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args), + chmodSync: (...args: unknown[]) => mockChmodSync(...args), + openSync: (...args: unknown[]) => mockOpenSync(...args), + closeSync: (...args: unknown[]) => mockCloseSync(...args), + }; +}); + +import { createMainAction } from './main-action'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +jest.mock('../logger', () => require('../test-helpers/mock-logger.test-utils').loggerMockFactory()); +jest.mock('../docker-manager'); +jest.mock('../host-iptables'); +jest.mock('../cli-workflow'); +jest.mock('../redact-secrets'); +jest.mock('../option-parsers'); +jest.mock('../dind-probe'); +jest.mock('../dind-bootstrap'); +jest.mock('./preflight'); +jest.mock('./signal-handler'); +jest.mock('./validate-options'); +jest.mock('../sbx-manager'); + +import { logger } from '../logger'; +import * as dockerManager from '../docker-manager'; +import * as hostIptables from '../host-iptables'; +import * as cliWorkflow from '../cli-workflow'; +import * as redactSecrets from '../redact-secrets'; +import * as optionParsers from '../option-parsers'; +import * as dindProbe from '../dind-probe'; +import * as dindBootstrap from '../dind-bootstrap'; +import * as preflight from './preflight'; +import * as signalHandler from './signal-handler'; +import * as validateOptions from './validate-options'; +import * as sbxManager from '../sbx-manager'; + +const mockedLogger = logger as jest.Mocked; +const mockedDockerManager = dockerManager as jest.Mocked; +const mockedHostIptables = hostIptables as jest.Mocked; +const mockedCliWorkflow = cliWorkflow as jest.Mocked; +const mockedRedactSecrets = redactSecrets as jest.Mocked; +const mockedOptionParsers = optionParsers as jest.Mocked; +const mockedDindProbe = dindProbe as jest.Mocked; +const mockedDindBootstrap = dindBootstrap as jest.Mocked; +const mockedPreflight = preflight as jest.Mocked; +const mockedSignalHandler = signalHandler as jest.Mocked; +const mockedValidateOptions = validateOptions as jest.Mocked; +const mockedSbxManager = sbxManager as jest.Mocked; + +const STUB_CONFIG = { + allowedDomains: ['github.com'], + blockedDomains: undefined, + agentCommand: 'echo hi', + logLevel: 'info', + keepContainers: false, + workDir: '/tmp/awf-test', + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + buildLocal: false, + dnsServers: ['8.8.8.8'], + awfDockerHost: undefined, + proxyLogsDir: undefined, + auditDir: undefined, + sessionStateDir: undefined, +} as unknown as import('../types').WrapperConfig; + +describe('createMainAction coverage gaps', () => { + let processExitSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + let getOptionValueSource: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + processExitSpy = jest.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { + if (code !== 0) { + throw new Error(`process.exit: ${code}`); + } + return undefined as never; + }); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + getOptionValueSource = jest.fn().mockReturnValue(undefined); + + mockedPreflight.applyConfigFilePrecedence.mockImplementation(() => {}); + mockedValidateOptions.validateOptions.mockImplementation( + () => ({ ...STUB_CONFIG } as unknown as import('../types').WrapperConfig) + ); + mockedDockerManager.setAwfDockerHost.mockImplementation(() => {}); + mockedRedactSecrets.redactSecrets.mockImplementation((s: string) => s); + mockedOptionParsers.joinShellArgs.mockImplementation((args: string[]) => args.join(' ')); + mockedDindProbe.probeSplitFilesystem.mockResolvedValue({ + prefix: undefined, + splitDetected: false, + inconclusive: false, + }); + mockedDindBootstrap.runDindBootstrap.mockResolvedValue(undefined); + mockedSignalHandler.registerSignalHandlers.mockImplementation(() => {}); + mockedCliWorkflow.runMainWorkflow.mockResolvedValue(0); + mockedSbxManager.isSbxAvailable.mockResolvedValue(true); + mockedSbxManager.createSandbox.mockResolvedValue('awf-agent-test'); + mockedSbxManager.execInSandbox.mockResolvedValue({ exitCode: 0 }); + mockedSbxManager.removeSandbox.mockResolvedValue(undefined); + }); + + afterEach(() => { + processExitSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + describe('sbx: isSbxAvailable false throws', () => { + it('throws when sbx CLI is not found', async () => { + const sbxConfig = { + ...STUB_CONFIG, + containerRuntime: 'sbx', + } as unknown as import('../types').WrapperConfig; + mockedValidateOptions.validateOptions.mockReturnValue(sbxConfig); + mockedSbxManager.isSbxAvailable.mockResolvedValue(false); + mockedCliWorkflow.runMainWorkflow.mockImplementation(async (_config, deps) => { + await deps.startContainers('/tmp/awf-test', ['github.com']); + return 0; + }); + + const action = createMainAction(getOptionValueSource); + await expect(action(['echo hi'], {})).rejects.toThrow('process.exit: 1'); + expect(mockedLogger.error).toHaveBeenCalledWith( + 'Fatal error:', + expect.objectContaining({ message: expect.stringContaining('sbx') }), + ); + }); + }); + + describe('sbx: api-proxy health check failure proceeds anyway', () => { + it('logs warning when api-proxy health check fails but continues', async () => { + const sbxConfig = { + ...STUB_CONFIG, + containerRuntime: 'sbx', + enableApiProxy: true, + containerWorkDir: '/workspace', + } as unknown as import('../types').WrapperConfig; + mockedValidateOptions.validateOptions.mockReturnValue(sbxConfig); + mockedSbxManager.execInSandbox + .mockResolvedValueOnce({ exitCode: 1 }) // api-proxy health check fails + .mockResolvedValueOnce({ exitCode: 1 }) // squid connectivity check + .mockResolvedValueOnce({ exitCode: 0 }); // agent command + mockedCliWorkflow.runMainWorkflow.mockImplementation(async (_config, deps) => { + await deps.startContainers('/tmp/awf-test', ['github.com']); + const result = await deps.runAgentCommand('/tmp/awf-test', ['github.com'], undefined, 5); + return result.exitCode; + }); + + const action = createMainAction(getOptionValueSource); + await action(['echo hi'], {}); + + expect(mockedLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('api-proxy health check failed'), + ); + }); + }); + + describe('sbx: runAgentCommand logs api-proxy diagnostics on non-zero exit', () => { + it('dumps api-proxy logs when agent exits non-zero with enableApiProxy', async () => { + const sbxConfig = { + ...STUB_CONFIG, + containerRuntime: 'sbx', + enableApiProxy: true, + containerWorkDir: '/workspace', + } as unknown as import('../types').WrapperConfig; + mockedValidateOptions.validateOptions.mockReturnValue(sbxConfig); + + // api-proxy health check succeeds, squid diag succeeds, agent fails + mockedSbxManager.execInSandbox + .mockResolvedValueOnce({ exitCode: 0 }) // api-proxy health + .mockResolvedValueOnce({ exitCode: 0 }) // squid diag + .mockResolvedValueOnce({ exitCode: 42 }); // agent command fails + + mockedCliWorkflow.runMainWorkflow.mockImplementation(async (_config, deps) => { + await deps.startContainers('/tmp/awf-test', ['github.com']); + const result = await deps.runAgentCommand('/tmp/awf-test', ['github.com'], undefined, 5); + return result.exitCode; + }); + + const action = createMainAction(getOptionValueSource); + // process.exit(42) is caught by the outer try-catch which then calls process.exit(1) + await expect(action(['echo hi'], {})).rejects.toThrow('process.exit: 1'); + + // Should log agent exit code + expect(mockedLogger.info).toHaveBeenCalledWith( + expect.stringContaining('Agent command exited with code 42'), + ); + }); + }); + + describe('sbx cleanup: keepContainers=false removes sandbox', () => { + it('calls removeSandbox during cleanup when not keepContainers', async () => { + const sbxConfig = { + ...STUB_CONFIG, + containerRuntime: 'sbx', + keepContainers: false, + } as unknown as import('../types').WrapperConfig; + mockedValidateOptions.validateOptions.mockReturnValue(sbxConfig); + mockedCliWorkflow.runMainWorkflow.mockImplementation(async (_config, _deps, callbacks) => { + await callbacks.performCleanup(); + return 0; + }); + + const action = createMainAction(getOptionValueSource); + await action(['echo hi'], {}); + + expect(mockedSbxManager.removeSandbox).toHaveBeenCalled(); + }); + }); + + describe('sbx cleanup: keepContainers=true skips removeSandbox', () => { + it('does not call removeSandbox when keepContainers is true', async () => { + const sbxConfig = { + ...STUB_CONFIG, + containerRuntime: 'sbx', + keepContainers: true, + } as unknown as import('../types').WrapperConfig; + mockedValidateOptions.validateOptions.mockReturnValue(sbxConfig); + mockedCliWorkflow.runMainWorkflow.mockImplementation(async (_config, _deps, callbacks) => { + await callbacks.performCleanup(); + return 0; + }); + + const action = createMainAction(getOptionValueSource); + await action(['echo hi'], {}); + + expect(mockedSbxManager.removeSandbox).not.toHaveBeenCalled(); + }); + }); + + describe('non-sbx runAgentCommand uses containerRuntime', () => { + it('passes containerRuntime to runAgentCommand for non-sbx config', async () => { + const dockerConfig = { + ...STUB_CONFIG, + containerRuntime: 'docker', + } as unknown as import('../types').WrapperConfig; + mockedValidateOptions.validateOptions.mockReturnValue(dockerConfig); + mockedDockerManager.runAgentCommand.mockResolvedValue({ exitCode: 0, blockedDomains: [] }); + mockedCliWorkflow.runMainWorkflow.mockImplementation(async (_config, deps) => { + const result = await deps.runAgentCommand('/tmp/awf-test', ['github.com'], undefined, 5); + return result.exitCode; + }); + + const action = createMainAction(getOptionValueSource); + await action(['echo hi'], {}); + + expect(mockedDockerManager.runAgentCommand).toHaveBeenCalledWith( + '/tmp/awf-test', + ['github.com'], + undefined, + 5, + 'docker', + ); + }); + }); + + describe('sbx: dnsOverHttps and difcProxyHost wiring', () => { + it('sets dohProxyIp when dnsOverHttps is enabled', async () => { + const sbxConfig = { + ...STUB_CONFIG, + containerRuntime: 'sbx', + dnsOverHttps: true, + containerWorkDir: '/workspace', + } as unknown as import('../types').WrapperConfig; + mockedValidateOptions.validateOptions.mockReturnValue(sbxConfig); + mockedSbxManager.execInSandbox.mockResolvedValue({ exitCode: 0 }); + mockedCliWorkflow.runMainWorkflow.mockImplementation(async (_config, deps) => { + await deps.startContainers('/tmp/awf-test', ['github.com']); + const result = await deps.runAgentCommand('/tmp/awf-test', ['github.com']); + return result.exitCode; + }); + + const action = createMainAction(getOptionValueSource); + await action(['echo hi'], {}); + + // sbxEnvironment should have been built; just verify no fatal errors + expect(mockedSbxManager.createSandbox).toHaveBeenCalled(); + }); + }); + + describe('sbx: GITHUB_WORKSPACE env fallback', () => { + it('falls back to cwd when GITHUB_WORKSPACE is not set', async () => { + delete process.env.GITHUB_WORKSPACE; + const sbxConfig = { + ...STUB_CONFIG, + containerRuntime: 'sbx', + containerWorkDir: '/workspace', + } as unknown as import('../types').WrapperConfig; + mockedValidateOptions.validateOptions.mockReturnValue(sbxConfig); + mockedSbxManager.execInSandbox.mockResolvedValue({ exitCode: 0 }); + mockedCliWorkflow.runMainWorkflow.mockImplementation(async (_config, deps) => { + await deps.startContainers('/tmp/awf-test', ['github.com']); + const result = await deps.runAgentCommand('/tmp/awf-test', ['github.com']); + return result.exitCode; + }); + + const action = createMainAction(getOptionValueSource); + await action(['echo hi'], {}); + + expect(mockedSbxManager.createSandbox).toHaveBeenCalledWith( + expect.objectContaining({ workspaceDir: expect.any(String) }), + ); + }); + + it('uses GITHUB_WORKSPACE when set', async () => { + process.env.GITHUB_WORKSPACE = '/github/workspace'; + const sbxConfig = { + ...STUB_CONFIG, + containerRuntime: 'sbx', + containerWorkDir: '/workspace', + } as unknown as import('../types').WrapperConfig; + mockedValidateOptions.validateOptions.mockReturnValue(sbxConfig); + mockedSbxManager.execInSandbox.mockResolvedValue({ exitCode: 0 }); + mockedCliWorkflow.runMainWorkflow.mockImplementation(async (_config, deps) => { + await deps.startContainers('/tmp/awf-test', ['github.com']); + const result = await deps.runAgentCommand('/tmp/awf-test', ['github.com']); + return result.exitCode; + }); + + const action = createMainAction(getOptionValueSource); + await action(['echo hi'], {}); + + expect(mockedSbxManager.createSandbox).toHaveBeenCalledWith( + expect.objectContaining({ workspaceDir: '/github/workspace' }), + ); + + delete process.env.GITHUB_WORKSPACE; + }); + }); +}); From 9a0ebb255490645daf101d79bd5adcdbff830899 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:05:56 +0000 Subject: [PATCH 2/2] test: address main-action coverage review feedback --- .../main-action-coverage-gaps.test.ts | 56 +++++++++++++++---- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/src/commands/main-action-coverage-gaps.test.ts b/src/commands/main-action-coverage-gaps.test.ts index 514b454d9..1d34fdeff 100644 --- a/src/commands/main-action-coverage-gaps.test.ts +++ b/src/commands/main-action-coverage-gaps.test.ts @@ -4,6 +4,7 @@ const mockWriteFileSync = jest.fn(); const mockChmodSync = jest.fn(); const mockOpenSync = jest.fn().mockReturnValue(42); const mockCloseSync = jest.fn(); +const mockExecSync = jest.fn(); jest.mock('fs', () => { const actual = jest.requireActual('fs'); @@ -17,6 +18,10 @@ jest.mock('fs', () => { }; }); +jest.mock('child_process', () => ({ + execSync: (...args: unknown[]) => mockExecSync(...args), +})); + import { createMainAction } from './main-action'; // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -35,7 +40,6 @@ jest.mock('../sbx-manager'); import { logger } from '../logger'; import * as dockerManager from '../docker-manager'; -import * as hostIptables from '../host-iptables'; import * as cliWorkflow from '../cli-workflow'; import * as redactSecrets from '../redact-secrets'; import * as optionParsers from '../option-parsers'; @@ -45,10 +49,10 @@ import * as preflight from './preflight'; import * as signalHandler from './signal-handler'; import * as validateOptions from './validate-options'; import * as sbxManager from '../sbx-manager'; +import { DOH_PROXY_IP } from '../host-iptables-shared'; const mockedLogger = logger as jest.Mocked; const mockedDockerManager = dockerManager as jest.Mocked; -const mockedHostIptables = hostIptables as jest.Mocked; const mockedCliWorkflow = cliWorkflow as jest.Mocked; const mockedRedactSecrets = redactSecrets as jest.Mocked; const mockedOptionParsers = optionParsers as jest.Mocked; @@ -80,11 +84,12 @@ describe('createMainAction coverage gaps', () => { let processExitSpy: jest.SpyInstance; let consoleErrorSpy: jest.SpyInstance; let getOptionValueSource: jest.Mock; + const savedGithubWorkspace = process.env.GITHUB_WORKSPACE; beforeEach(() => { jest.clearAllMocks(); processExitSpy = jest.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { - if (code !== 0) { + if (code === 1) { throw new Error(`process.exit: ${code}`); } return undefined as never; @@ -114,6 +119,11 @@ describe('createMainAction coverage gaps', () => { }); afterEach(() => { + if (savedGithubWorkspace === undefined) { + delete process.env.GITHUB_WORKSPACE; + } else { + process.env.GITHUB_WORKSPACE = savedGithubWorkspace; + } processExitSpy.mockRestore(); consoleErrorSpy.mockRestore(); }); @@ -183,6 +193,9 @@ describe('createMainAction coverage gaps', () => { .mockResolvedValueOnce({ exitCode: 0 }) // api-proxy health .mockResolvedValueOnce({ exitCode: 0 }) // squid diag .mockResolvedValueOnce({ exitCode: 42 }); // agent command fails + mockExecSync + .mockReturnValueOnce('api-proxy diagnostic log\n') + .mockReturnValueOnce('healthy\n'); mockedCliWorkflow.runMainWorkflow.mockImplementation(async (_config, deps) => { await deps.startContainers('/tmp/awf-test', ['github.com']); @@ -191,13 +204,28 @@ describe('createMainAction coverage gaps', () => { }); const action = createMainAction(getOptionValueSource); - // process.exit(42) is caught by the outer try-catch which then calls process.exit(1) - await expect(action(['echo hi'], {})).rejects.toThrow('process.exit: 1'); + await action(['echo hi'], {}); - // Should log agent exit code + expect(processExitSpy).toHaveBeenCalledWith(42); expect(mockedLogger.info).toHaveBeenCalledWith( expect.stringContaining('Agent command exited with code 42'), ); + expect(mockExecSync).toHaveBeenNthCalledWith( + 1, + 'docker logs --tail 80 awf-api-proxy 2>&1', + { encoding: 'utf-8', timeout: 10000 }, + ); + expect(mockExecSync).toHaveBeenNthCalledWith( + 2, + 'docker inspect --format={{.State.Health.Status}} awf-api-proxy 2>&1', + { encoding: 'utf-8', timeout: 5000 }, + ); + expect(mockedLogger.info).toHaveBeenCalledWith( + '[sbx-diag] api-proxy logs:\napi-proxy diagnostic log\n', + ); + expect(mockedLogger.info).toHaveBeenCalledWith( + '[sbx-diag] api-proxy health status: healthy', + ); }); }); @@ -286,8 +314,16 @@ describe('createMainAction coverage gaps', () => { const action = createMainAction(getOptionValueSource); await action(['echo hi'], {}); - // sbxEnvironment should have been built; just verify no fatal errors - expect(mockedSbxManager.createSandbox).toHaveBeenCalled(); + expect(mockedSbxManager.execInSandbox).toHaveBeenLastCalledWith( + 'awf-agent-test', + 'echo hi', + expect.objectContaining({ + environment: expect.objectContaining({ + AWF_DOH_ENABLED: 'true', + AWF_DOH_PROXY_IP: DOH_PROXY_IP, + }), + }), + ); }); }); @@ -311,7 +347,7 @@ describe('createMainAction coverage gaps', () => { await action(['echo hi'], {}); expect(mockedSbxManager.createSandbox).toHaveBeenCalledWith( - expect.objectContaining({ workspaceDir: expect.any(String) }), + expect.objectContaining({ workspaceDir: process.cwd() }), ); }); @@ -336,8 +372,6 @@ describe('createMainAction coverage gaps', () => { expect(mockedSbxManager.createSandbox).toHaveBeenCalledWith( expect.objectContaining({ workspaceDir: '/github/workspace' }), ); - - delete process.env.GITHUB_WORKSPACE; }); }); });