From 5331140bcaf95dabd230d557c092d295dcc104fb Mon Sep 17 00:00:00 2001 From: Jesus Orosco Date: Thu, 9 Apr 2026 18:01:52 -0700 Subject: [PATCH 1/4] fix: adding integration testing --- .github/workflows/sf_cli_integration.yml | 267 ++++++++++++++++++++ test/utils/sfCliContract.test.ts | 308 +++++++++++++++++++++++ 2 files changed, 575 insertions(+) create mode 100644 .github/workflows/sf_cli_integration.yml create mode 100644 test/utils/sfCliContract.test.ts diff --git a/.github/workflows/sf_cli_integration.yml b/.github/workflows/sf_cli_integration.yml new file mode 100644 index 0000000..e1865bb --- /dev/null +++ b/.github/workflows/sf_cli_integration.yml @@ -0,0 +1,267 @@ +name: SF CLI Integration Test + +on: + pull_request: + +jobs: + sf-cli-integration: + runs-on: ubuntu-latest + env: + NO_COLOR: '1' + + steps: + # ── Setup ───────────────────────────────────────────────────────────────── + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install datacustomcode Python SDK + run: pip install salesforce-data-customcode + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: lts/* + + - name: Install Node.js dependencies and compile + run: yarn install && yarn compile + + - name: Set up Java 17 (required for PySpark during run) + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + # ── Mock Salesforce server + fake org auth ──────────────────────────────── + + - name: Start mock Salesforce server + run: python scripts/mock_sf_server.py & + env: + MOCK_SF_PORT: '8888' + + - name: Create fake SF CLI auth for org alias 'dev1' + run: | + sleep 1 + python - <<'PYEOF' + import json, pathlib + home = pathlib.Path.home() + + # Auth file — @salesforce/core reads ~/.sfdx/.json on Linux + # (plain-text storage, no OS keychain involved on CI runners) + sfdx_dir = home / ".sfdx" + sfdx_dir.mkdir(exist_ok=True) + auth = { + "accessToken": "00D000000000001AAA!fakeTokenForCITesting", + "instanceUrl": "http://localhost:8888", + "loginUrl": "https://login.salesforce.com", + "orgId": "00D000000000001AAA", + "userId": "005000000000001AAA", + "username": "dev1@example.com", + "clientId": "PlatformCLI", + "isDevHub": False, + "isSandbox": False, + "created": "2024-01-01T00:00:00.000Z", + "createdOrgInstance": "CS1", + } + (sfdx_dir / "dev1@example.com.json").write_text(json.dumps(auth, indent=2)) + + # Alias mapping — write to both locations for compat across sf versions + alias_data = {"orgs": {"dev1": "dev1@example.com"}} + sf_dir = home / ".sf" + sf_dir.mkdir(exist_ok=True) + (sf_dir / "alias.json").write_text(json.dumps(alias_data)) + (sfdx_dir / "alias.json").write_text(json.dumps(alias_data)) + + print("Fake SF CLI org auth written to ~/.sfdx/dev1@example.com.json") + PYEOF + + # ── Script: init ────────────────────────────────────────────────────────── + + - name: '[script] init — bin/dev.js data-code-extension script init --package-dir testScript' + run: | + ./bin/dev.js data-code-extension script init --package-dir testScript || { + echo "::error::bin/dev.js data-code-extension script init FAILED. Verify --package-dir is still a recognised flag and that the command exits 0 on success." + exit 1 + } + + - name: '[script] verify init — expected files exist' + run: | + test -f testScript/payload/entrypoint.py || { + echo "::error::testScript/payload/entrypoint.py not found after init. The init command may not have copied the script template." + exit 1 + } + test -f testScript/.datacustomcode_proj/sdk_config.json || { + echo "::error::testScript/.datacustomcode_proj/sdk_config.json not found after init. The SDK config marker was not written." + exit 1 + } + + # ── Script: scan ────────────────────────────────────────────────────────── + + - name: '[script] scan — bin/dev.js data-code-extension script scan --entrypoint testScript/payload/entrypoint.py' + run: | + ./bin/dev.js data-code-extension script scan --entrypoint testScript/payload/entrypoint.py || { + echo "::error::bin/dev.js data-code-extension script scan FAILED. Verify --entrypoint is still a recognised flag and the command exits 0." + exit 1 + } + + - name: '[script] verify scan — config.json contains permissions' + run: | + python - <<'EOF' + import json, sys + path = "testScript/payload/config.json" + try: + with open(path) as f: + data = json.load(f) + except Exception as e: + print(f"::error::Could not read {path}: {e}") + sys.exit(1) + if "permissions" not in data: + print(f"::error::{path} is missing 'permissions' key after scan. Got: {json.dumps(data)}") + sys.exit(1) + print("config.json OK:", json.dumps(data, indent=2)) + EOF + + # ── Script: zip ─────────────────────────────────────────────────────────── + + - name: '[script] prepare for zip — clear requirements.txt to skip native-dep Docker build' + run: echo "" > testScript/payload/requirements.txt + + - name: '[script] zip — bin/dev.js data-code-extension script zip --package-dir testScript' + run: | + ./bin/dev.js data-code-extension script zip --package-dir testScript || { + echo "::error::bin/dev.js data-code-extension script zip FAILED. Verify --package-dir is still recognised and the command exits 0." + exit 1 + } + + - name: '[script] verify zip — deployment.zip exists' + run: | + test -f deployment.zip || { + echo "::error::deployment.zip not found after sf data-code-extension script zip. The zip command may have written to a different path or failed silently." + exit 1 + } + + # ── Script: run ─────────────────────────────────────────────────────────── + + - name: '[script] run — bin/dev.js data-code-extension script run --entrypoint testScript/payload/entrypoint.py -o dev1' + run: | + ./bin/dev.js data-code-extension script run \ + --entrypoint testScript/payload/entrypoint.py \ + -o dev1 || { + echo "::error::bin/dev.js data-code-extension script run FAILED. Check mock server output above for which endpoint failed. The --entrypoint flag or org auth contract may have changed." + exit 1 + } + + # ── Script: deploy ─────────────────────────────────────────────────────── + + - name: '[script] deploy — bin/dev.js data-code-extension script deploy' + run: | + ./bin/dev.js data-code-extension script deploy \ + --name test-script-deploy \ + --package-version 0.0.1 \ + --description "Test script deploy" \ + --package-dir testScript/payload \ + --cpu-size CPU_2XL \ + -o dev1 || { + echo "::error::bin/dev.js data-code-extension script deploy FAILED. Check mock server output above for which endpoint failed. The deploy command flags or API contract may have changed." + exit 1 + } + + # ── Function: init ──────────────────────────────────────────────────────── + + - name: '[function] init — bin/dev.js data-code-extension function init --package-dir testFunction' + run: | + ./bin/dev.js data-code-extension function init --package-dir testFunction || { + echo "::error::bin/dev.js data-code-extension function init FAILED. Verify --package-dir is still recognised and the function template copies correctly." + exit 1 + } + + - name: '[function] verify init — expected files exist' + run: | + test -f testFunction/payload/entrypoint.py || { + echo "::error::testFunction/payload/entrypoint.py not found after function init." + exit 1 + } + test -f testFunction/.datacustomcode_proj/sdk_config.json || { + echo "::error::testFunction/.datacustomcode_proj/sdk_config.json not found after function init." + exit 1 + } + + # ── Function: scan ──────────────────────────────────────────────────────── + + - name: '[function] scan — bin/dev.js data-code-extension function scan --entrypoint testFunction/payload/entrypoint.py' + run: | + ./bin/dev.js data-code-extension function scan --entrypoint testFunction/payload/entrypoint.py || { + echo "::error::bin/dev.js data-code-extension function scan FAILED." + exit 1 + } + + - name: '[function] verify scan — config.json contains entryPoint' + run: | + python - <<'EOF' + import json, sys + path = "testFunction/payload/config.json" + try: + with open(path) as f: + data = json.load(f) + except Exception as e: + print(f"::error::Could not read {path}: {e}") + sys.exit(1) + if "entryPoint" not in data: + print(f"::error::{path} is missing 'entryPoint' key after scan. Got: {json.dumps(data)}") + sys.exit(1) + print("config.json OK:", json.dumps(data, indent=2)) + EOF + + # ── Function: zip ───────────────────────────────────────────────────────── + + - name: '[function] prepare for zip — clear requirements.txt to skip native-dep Docker build' + run: echo "" > testFunction/payload/requirements.txt + + - name: '[function] clean up previous deployment.zip before function zip' + run: rm -f deployment.zip + + - name: '[function] zip — bin/dev.js data-code-extension function zip --package-dir testFunction' + run: | + ./bin/dev.js data-code-extension function zip --package-dir testFunction || { + echo "::error::bin/dev.js data-code-extension function zip FAILED." + exit 1 + } + + - name: '[function] verify zip — deployment.zip exists' + run: | + test -f deployment.zip || { + echo "::error::deployment.zip not found after sf data-code-extension function zip." + exit 1 + } + + # ── Function: run ───────────────────────────────────────────────────────── + + - name: '[function] run — bin/dev.js data-code-extension function run --entrypoint testFunction/payload/entrypoint.py -o dev1' + run: | + ./bin/dev.js data-code-extension function run \ + --entrypoint testFunction/payload/entrypoint.py \ + -o dev1 || { + echo "::error::bin/dev.js data-code-extension function run FAILED. Check mock server output above; the --entrypoint flag or org auth contract may have changed." + exit 1 + } + + # ── Function: deploy ───────────────────────────────────────────────────── + + - name: '[function] deploy — bin/dev.js data-code-extension function deploy' + run: | + ./bin/dev.js data-code-extension function deploy \ + --name test-function-deploy \ + --package-version 0.0.1 \ + --description "Test function deploy" \ + --package-dir testFunction/payload \ + --cpu-size CPU_2XL \ + --function-invoke-opt UnstructuredChunking \ + -o dev1 || { + echo "::error::bin/dev.js data-code-extension function deploy FAILED. Check mock server output above for which endpoint failed. The deploy command flags or API contract may have changed." + exit 1 + } diff --git a/test/utils/sfCliContract.test.ts b/test/utils/sfCliContract.test.ts new file mode 100644 index 0000000..11ce2af --- /dev/null +++ b/test/utils/sfCliContract.test.ts @@ -0,0 +1,308 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Contract tests: verify that DatacodeBinaryExecutor passes exactly the argument + * signatures that the Python CLI (datacustomcode) expects. + * + * Mirror of: datacloud-customcode-python-sdk/tests/test_sf_cli_contract.py + * + * These tests do NOT exercise business logic. They verify that: + * 1. All flags passed by the SF CLI plugin are present in each executeBinary*() call. + * 2. The arg arrays match what the Python CLI expects for each command. + * 3. stdout regex patterns used to parse Python CLI output work correctly. + * + * Source of truth for expected args and stdout regex patterns: + * src/utils/datacodeBinaryExecutor.ts + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { expect } from 'chai'; +import { DatacodeBinaryExecutor } from '../../src/utils/datacodeBinaryExecutor.js'; + +// ── Fake binary ─────────────────────────────────────────────────────────────── +// +// Each test runs a fake `datacustomcode` Node.js script installed into a temp dir +// that is prepended to PATH. The script records argv[2..] to DC_FAKE_ARGS_FILE and +// writes the stdout patterns that the SF CLI plugin's regexes expect. +// +// This mirrors the Python tests' use of CliRunner.invoke() — the real executor code +// runs unchanged; only the binary it spawns is replaced. + +const FAKE_BINARY_SCRIPT = `#!/usr/bin/env node +const fs = require('fs'); +const args = process.argv.slice(2); +const argsFile = process.env.DC_FAKE_ARGS_FILE; +if (argsFile) fs.writeFileSync(argsFile, JSON.stringify(args)); +const cmd = args[0]; +if (cmd === 'init') { + const pkgDir = args[args.length - 1]; + process.stdout.write('Copying template to ' + pkgDir + '\\n'); +} else if (cmd === 'scan') { + const entrypoint = args[args.length - 1]; + process.stdout.write('Scanning ' + entrypoint + '...\\n'); +} +process.exit(0); +`; + +function installFakeBinary(binDir: string): void { + const binPath = path.join(binDir, 'datacustomcode'); + fs.writeFileSync(binPath, FAKE_BINARY_SCRIPT, { mode: 0o755 }); +} + +function readRecordedArgs(argsFile: string): string[] { + return JSON.parse(fs.readFileSync(argsFile, 'utf8')) as string[]; +} + +// ── Test suite ──────────────────────────────────────────────────────────────── + +describe('SF CLI ↔ Python CLI arg contract (DatacodeBinaryExecutor)', () => { + let tmpDir: string; + let argsFile: string; + let origPath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-contract-')); + argsFile = path.join(tmpDir, 'args.json'); + installFakeBinary(tmpDir); + origPath = process.env.PATH ?? ''; + process.env.PATH = `${tmpDir}${path.delimiter}${origPath}`; + process.env.DC_FAKE_ARGS_FILE = argsFile; + }); + + afterEach(() => { + process.env.PATH = origPath; + delete process.env.DC_FAKE_ARGS_FILE; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + // ── init ──────────────────────────────────────────────────────────────────── + + describe('TestInitArgContract', () => { + /** + * SF CLI spawn: datacustomcode init --code-type + * Ref: executeBinaryInit() + */ + + it('accepts --code-type script', async () => { + await DatacodeBinaryExecutor.executeBinaryInit('script', 'mydir'); + expect(readRecordedArgs(argsFile)).to.deep.equal(['init', '--code-type', 'script', 'mydir']); + }); + + it('accepts --code-type function', async () => { + await DatacodeBinaryExecutor.executeBinaryInit('function', 'mydir'); + expect(readRecordedArgs(argsFile)).to.deep.equal(['init', '--code-type', 'function', 'mydir']); + }); + }); + + // ── scan ──────────────────────────────────────────────────────────────────── + + describe('TestScanArgContract', () => { + /** + * SF CLI spawn: datacustomcode scan [--dry-run] [--no-requirements] [--config ] + * Ref: executeBinaryScan() + */ + + it('accepts positional entrypoint', async () => { + await DatacodeBinaryExecutor.executeBinaryScan(tmpDir, 'payload/entrypoint.py'); + expect(readRecordedArgs(argsFile)).to.deep.equal(['scan', 'payload/entrypoint.py']); + }); + + it('accepts --dry-run flag', async () => { + await DatacodeBinaryExecutor.executeBinaryScan(tmpDir, 'payload/entrypoint.py', true); + expect(readRecordedArgs(argsFile)).to.deep.equal(['scan', '--dry-run', 'payload/entrypoint.py']); + }); + + it('accepts --no-requirements flag', async () => { + await DatacodeBinaryExecutor.executeBinaryScan(tmpDir, 'payload/entrypoint.py', false, true); + expect(readRecordedArgs(argsFile)).to.deep.equal(['scan', '--no-requirements', 'payload/entrypoint.py']); + }); + + it('accepts --config flag', async () => { + await DatacodeBinaryExecutor.executeBinaryScan( + tmpDir, + 'payload/entrypoint.py', + false, + false, + 'custom/config.json' + ); + expect(readRecordedArgs(argsFile)).to.deep.equal([ + 'scan', + '--config', + 'custom/config.json', + 'payload/entrypoint.py', + ]); + }); + }); + + // ── zip ───────────────────────────────────────────────────────────────────── + + describe('TestZipArgContract', () => { + /** + * SF CLI spawn: datacustomcode zip [--network ] + * Ref: executeBinaryZip() + */ + + it('accepts positional ', async () => { + await DatacodeBinaryExecutor.executeBinaryZip('payload'); + expect(readRecordedArgs(argsFile)).to.deep.equal(['zip', 'payload']); + }); + + it('accepts --network flag', async () => { + await DatacodeBinaryExecutor.executeBinaryZip('payload', 'custom'); + expect(readRecordedArgs(argsFile)).to.deep.equal(['zip', '--network', 'custom', 'payload']); + }); + }); + + // ── deploy ────────────────────────────────────────────────────────────────── + + describe('TestDeployArgContract', () => { + /** + * SF CLI spawn: datacustomcode deploy + * --name --version --description + * --path --sf-cli-org --cpu-size + * [--network ] [--function-invoke-opt ] + * Ref: executeBinaryDeploy() + */ + + const BASE_ARGS = [ + 'deploy', + '--name', + 'my-pkg', + '--version', + '1.0.0', + '--description', + 'My description', + '--path', + 'payload', + '--sf-cli-org', + 'my-org', + '--cpu-size', + 'CPU_2XL', + ]; + + it('accepts required flags', async () => { + await DatacodeBinaryExecutor.executeBinaryDeploy( + 'my-pkg', + '1.0.0', + 'My description', + 'payload', + 'my-org', + 'CPU_2XL' + ); + expect(readRecordedArgs(argsFile)).to.deep.equal(BASE_ARGS); + }); + + it('accepts --network flag', async () => { + await DatacodeBinaryExecutor.executeBinaryDeploy( + 'my-pkg', + '1.0.0', + 'My description', + 'payload', + 'my-org', + 'CPU_2XL', + 'custom' + ); + expect(readRecordedArgs(argsFile)).to.deep.equal([...BASE_ARGS, '--network', 'custom']); + }); + + it('accepts --function-invoke-opt flag', async () => { + await DatacodeBinaryExecutor.executeBinaryDeploy( + 'my-pkg', + '1.0.0', + 'My description', + 'payload', + 'my-org', + 'CPU_2XL', + undefined, + 'ASYNC' + ); + expect(readRecordedArgs(argsFile)).to.deep.equal([...BASE_ARGS, '--function-invoke-opt', 'ASYNC']); + }); + }); + + // ── run ───────────────────────────────────────────────────────────────────── + + describe('TestRunArgContract', () => { + /** + * SF CLI spawn: datacustomcode run --sf-cli-org + * [--config-file ] [--dependencies ] + * Ref: executeBinaryRun() + * + * Known incompatibility: SF CLI passes `--dependencies` once as a single string. + * Python CLI declares multiple=True, so the value arrives as a 1-tuple containing + * the raw string rather than individual dep names. + */ + + it('accepts --sf-cli-org and positional ', async () => { + await DatacodeBinaryExecutor.executeBinaryRun('payload/entrypoint.py', 'my-org'); + expect(readRecordedArgs(argsFile)).to.deep.equal(['run', '--sf-cli-org', 'my-org', 'payload/entrypoint.py']); + }); + + it('accepts --config-file flag', async () => { + await DatacodeBinaryExecutor.executeBinaryRun('payload/entrypoint.py', 'my-org', 'payload/config.json'); + expect(readRecordedArgs(argsFile)).to.deep.equal([ + 'run', + '--sf-cli-org', + 'my-org', + '--config-file', + 'payload/config.json', + 'payload/entrypoint.py', + ]); + }); + + it('passes --dependencies as a single string (Python multiple=True receives it as a 1-tuple)', async () => { + // SF CLI passes --dependencies once as a comma-separated string. + // Python CLI uses multiple=True, so run_entrypoint receives ("dep1,dep2",) + // not ("dep1", "dep2"). The string is NOT split on commas. + await DatacodeBinaryExecutor.executeBinaryRun('payload/entrypoint.py', 'my-org', undefined, 'dep1,dep2'); + expect(readRecordedArgs(argsFile)).to.deep.equal([ + 'run', + '--sf-cli-org', + 'my-org', + '--dependencies', + 'dep1,dep2', + 'payload/entrypoint.py', + ]); + }); + }); + + // ── stdout regex contract ──────────────────────────────────────────────────── + + describe('TestSfCliOutputRegexContract', () => { + /** + * The SF CLI plugin parses stdout from each command with regex patterns. + * These tests verify that executeBinary*() correctly extracts structured data + * from the Python CLI's actual output patterns (v0.1.4). + * + * Ref: stdout parsing in each executeBinary*() method of datacodeBinaryExecutor.ts. + */ + + it('init: parses "Copying template to " into filesCreated', async () => { + // Fake binary outputs: "Copying template to mydir" + const result = await DatacodeBinaryExecutor.executeBinaryInit('script', 'mydir'); + expect(result.filesCreated).to.deep.equal(['mydir']); + }); + + it('scan: parses "Scanning ..." into filesScanned', async () => { + // Fake binary outputs: "Scanning payload/entrypoint.py..." + const result = await DatacodeBinaryExecutor.executeBinaryScan(tmpDir, 'payload/entrypoint.py', true); + expect(result.filesScanned).to.deep.equal(['payload/entrypoint.py']); + }); + }); +}); From 4f1124333a667a15354ba38785c0227036795fd8 Mon Sep 17 00:00:00 2001 From: Jesus Orosco Date: Thu, 9 Apr 2026 18:25:53 -0700 Subject: [PATCH 2/4] fix: failing test --- .github/workflows/sf_cli_integration.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/sf_cli_integration.yml b/.github/workflows/sf_cli_integration.yml index e1865bb..898baa1 100644 --- a/.github/workflows/sf_cli_integration.yml +++ b/.github/workflows/sf_cli_integration.yml @@ -28,6 +28,9 @@ jobs: with: node-version: lts/* + - name: Install Salesforce CLI + run: npm install -g @salesforce/cli + - name: Install Node.js dependencies and compile run: yarn install && yarn compile From 19893c2099ce6025e4fab8806735f9212dceb40c Mon Sep 17 00:00:00 2001 From: Jesus Orosco Date: Thu, 9 Apr 2026 20:51:47 -0700 Subject: [PATCH 3/4] fix: try again --- .github/workflows/sf_cli_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sf_cli_integration.yml b/.github/workflows/sf_cli_integration.yml index 898baa1..fce5a4a 100644 --- a/.github/workflows/sf_cli_integration.yml +++ b/.github/workflows/sf_cli_integration.yml @@ -43,7 +43,7 @@ jobs: # ── Mock Salesforce server + fake org auth ──────────────────────────────── - name: Start mock Salesforce server - run: python scripts/mock_sf_server.py & + run: nohup python scripts/mock_sf_server.py > /tmp/mock_sf_server.log 2>&1 & env: MOCK_SF_PORT: '8888' From 71caf1f3ae9c9cd4c6383ac47ea21d22250483fb Mon Sep 17 00:00:00 2001 From: Jesus Orosco Date: Thu, 9 Apr 2026 21:24:49 -0700 Subject: [PATCH 4/4] fix: ipv4 --- .github/workflows/sf_cli_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sf_cli_integration.yml b/.github/workflows/sf_cli_integration.yml index fce5a4a..71363e4 100644 --- a/.github/workflows/sf_cli_integration.yml +++ b/.github/workflows/sf_cli_integration.yml @@ -60,7 +60,7 @@ jobs: sfdx_dir.mkdir(exist_ok=True) auth = { "accessToken": "00D000000000001AAA!fakeTokenForCITesting", - "instanceUrl": "http://localhost:8888", + "instanceUrl": "http://127.0.0.1:8888", "loginUrl": "https://login.salesforce.com", "orgId": "00D000000000001AAA", "userId": "005000000000001AAA",