|
| 1 | +/* |
| 2 | + * Copyright (c) 2025, Salesforce, Inc. |
| 3 | + * SPDX-License-Identifier: Apache-2 |
| 4 | + * For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0 |
| 5 | + */ |
| 6 | + |
| 7 | +import {expect} from 'chai'; |
| 8 | +import {execa} from 'execa'; |
| 9 | +import path from 'node:path'; |
| 10 | +import {fileURLToPath} from 'node:url'; |
| 11 | + |
| 12 | +const __filename = fileURLToPath(import.meta.url); |
| 13 | +const __dirname = path.dirname(__filename); |
| 14 | + |
| 15 | +/** |
| 16 | + * Helper function to parse JSON response from CLI |
| 17 | + */ |
| 18 | +function parseJson(output: string): Record<string, unknown> { |
| 19 | + try { |
| 20 | + // Try to parse the entire output as JSON first |
| 21 | + return JSON.parse(output); |
| 22 | + } catch { |
| 23 | + // If that fails, look for JSON in the output |
| 24 | + const lines = output.split('\n'); |
| 25 | + for (const line of lines) { |
| 26 | + const trimmed = line.trim(); |
| 27 | + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { |
| 28 | + try { |
| 29 | + return JSON.parse(trimmed); |
| 30 | + } catch {} |
| 31 | + } |
| 32 | + } |
| 33 | + throw new Error(`No valid JSON found in output: ${output}`); |
| 34 | + } |
| 35 | +} |
| 36 | + |
| 37 | +/** |
| 38 | + * E2E Tests for ODS (On-Demand Sandbox) Lifecycle |
| 39 | + * |
| 40 | + * This test suite covers the complete lifecycle of an ODS sandbox: |
| 41 | + * 1. Create sandbox with permissions |
| 42 | + * 2. List sandboxes and verify creation |
| 43 | + * 3. Deploy code to sandbox |
| 44 | + * 4. Stop sandbox |
| 45 | + * 5. Start sandbox |
| 46 | + * 6. Restart sandbox |
| 47 | + * 7. Get sandbox status |
| 48 | + * 8. Delete sandbox |
| 49 | + */ |
| 50 | +describe('ODS Lifecycle E2E Tests', function () { |
| 51 | + // Timeout for entire test suite |
| 52 | + this.timeout(360_000); // 6 minutes |
| 53 | + |
| 54 | + // Test configuration (paths) |
| 55 | + const CLI_BIN = path.resolve(__dirname, '../../../bin/run.js'); |
| 56 | + const CARTRIDGES_DIR = path.resolve(__dirname, '../fixtures/cartridges'); |
| 57 | + |
| 58 | + // Test state |
| 59 | + let sandboxId: string; |
| 60 | + let serverHostname: string; |
| 61 | + |
| 62 | + before(function () { |
| 63 | + // Check required environment variables |
| 64 | + if (!process.env.SFCC_CLIENT_ID || !process.env.SFCC_CLIENT_SECRET || !process.env.TEST_REALM) { |
| 65 | + this.skip(); |
| 66 | + } |
| 67 | + }); |
| 68 | + |
| 69 | + /** |
| 70 | + * Helper function to run CLI commands with proper environment. |
| 71 | + * Uses process.env directly to get credentials from GitHub secrets. |
| 72 | + */ |
| 73 | + async function runCLI(args: string[]) { |
| 74 | + const result = await execa('node', [CLI_BIN, ...args], { |
| 75 | + env: { |
| 76 | + ...process.env, |
| 77 | + SFCC_LOG_LEVEL: 'silent', |
| 78 | + }, |
| 79 | + reject: false, |
| 80 | + }); |
| 81 | + |
| 82 | + return result; |
| 83 | + } |
| 84 | + |
| 85 | + /** |
| 86 | + * Helper function to get current sandbox state (for verification only) |
| 87 | + */ |
| 88 | + async function getSandboxState(sandboxId: string): Promise<null | string> { |
| 89 | + const result = await runCLI(['ods', 'get', sandboxId, '--json']); |
| 90 | + if (result.exitCode === 0) { |
| 91 | + const sandbox = parseJson(result.stdout); |
| 92 | + return sandbox.state; |
| 93 | + } |
| 94 | + return null; |
| 95 | + } |
| 96 | + |
| 97 | + describe('Step 1: Create Sandbox', function () { |
| 98 | + it('should create a new sandbox with permissions and wait for readiness', async function () { |
| 99 | + // --wait can take 5-10 minutes, so increase timeout for this test |
| 100 | + this.timeout(600_000); // 6 minutes |
| 101 | + |
| 102 | + const result = await runCLI([ |
| 103 | + 'ods', |
| 104 | + 'create', |
| 105 | + '--realm', |
| 106 | + process.env.TEST_REALM!, |
| 107 | + '--ttl', |
| 108 | + '24', |
| 109 | + '--wait', |
| 110 | + '--set-permissions', |
| 111 | + '--json', |
| 112 | + ]); |
| 113 | + |
| 114 | + expect(result.exitCode).to.equal(0, `Create command failed: ${result.stderr}`); |
| 115 | + expect(result.stdout, 'Create command should return JSON output').to.not.be.empty; |
| 116 | + |
| 117 | + const response = parseJson(result.stdout); |
| 118 | + expect(response, 'Create response should be a valid object').to.be.an('object'); |
| 119 | + expect(response.id, 'Create response should contain a sandbox ID').to.be.a('string').and.not.be.empty; |
| 120 | + expect(response.hostName, 'Create response should contain a hostname').to.be.a('string').and.not.be.empty; |
| 121 | + expect(response.state, `Sandbox state should be 'started' after --wait, but got '${response.state}'`).to.equal( |
| 122 | + 'started', |
| 123 | + ); |
| 124 | + |
| 125 | + // Store for subsequent tests |
| 126 | + sandboxId = response.id; |
| 127 | + serverHostname = response.hostName; |
| 128 | + |
| 129 | + // Debug output to verify values are set |
| 130 | + console.log(`Created sandbox: ${sandboxId} on ${serverHostname}`); |
| 131 | + }); |
| 132 | + }); |
| 133 | + |
| 134 | + describe('Step 2: List Sandboxes', function () { |
| 135 | + it('should list sandboxes and verify the created one is present', async function () { |
| 136 | + // Skip if we don't have a valid sandbox ID |
| 137 | + if (!sandboxId) { |
| 138 | + this.skip(); |
| 139 | + } |
| 140 | + |
| 141 | + const result = await runCLI(['ods', 'list', '--realm', process.env.TEST_REALM!, '--json']); |
| 142 | + |
| 143 | + expect(result.exitCode).to.equal(0, `List command failed: ${result.stderr}`); |
| 144 | + expect(result.stdout, 'List command should return JSON output').to.not.be.empty; |
| 145 | + |
| 146 | + const response = parseJson(result.stdout); |
| 147 | + expect(response, 'List response should be a valid object').to.be.an('object'); |
| 148 | + expect(response.data, 'List response should contain data array').to.be.an('array'); |
| 149 | + |
| 150 | + // Find our sandbox in the list |
| 151 | + const foundSandbox = response.data.find((sandbox: Record<string, unknown>) => sandbox.id === sandboxId); |
| 152 | + expect(foundSandbox, `Sandbox '${sandboxId}' not found in list.`).to.exist; |
| 153 | + expect(foundSandbox.id).to.equal(sandboxId); |
| 154 | + }); |
| 155 | + }); |
| 156 | + |
| 157 | + describe('Step 3: Deploy Code', function () { |
| 158 | + it('should deploy test cartridge to the sandbox', async function () { |
| 159 | + // Skip deploy if we don't have a valid sandbox |
| 160 | + if (!sandboxId || !serverHostname) { |
| 161 | + this.skip(); |
| 162 | + } |
| 163 | + |
| 164 | + const result = await runCLI([ |
| 165 | + 'code', |
| 166 | + 'deploy', |
| 167 | + CARTRIDGES_DIR, |
| 168 | + '--cartridge', |
| 169 | + 'plugin_example', |
| 170 | + '--server', |
| 171 | + serverHostname, |
| 172 | + '--account-manager-host', |
| 173 | + process.env.SFCC_ACCOUNT_MANAGER_HOST!, |
| 174 | + '--json', |
| 175 | + ]); |
| 176 | + |
| 177 | + expect(result.exitCode).to.equal(0, `Deploy command failed: ${result.stderr}`); |
| 178 | + expect(result.stdout, 'Deploy command should return JSON output').to.not.be.empty; |
| 179 | + |
| 180 | + const response = parseJson(result.stdout); |
| 181 | + expect(response, 'Deploy response should be a valid object').to.be.an('object'); |
| 182 | + expect(response.cartridges, 'Deploy response should contain cartridges array') |
| 183 | + .to.be.an('array') |
| 184 | + .with.length.greaterThan(0); |
| 185 | + expect(response.codeVersion, 'Deploy response should contain code version').to.be.a('string').and.not.be.empty; |
| 186 | + }); |
| 187 | + }); |
| 188 | + |
| 189 | + describe('Step 4: Stop Sandbox', function () { |
| 190 | + it('should stop the sandbox', async function () { |
| 191 | + // Skip if we don't have a valid sandbox ID |
| 192 | + if (!sandboxId) { |
| 193 | + this.skip(); |
| 194 | + } |
| 195 | + |
| 196 | + const result = await runCLI(['ods', 'stop', sandboxId, '--json']); |
| 197 | + |
| 198 | + expect(result.exitCode).to.equal(0, `Stop command failed: ${result.stderr}`); |
| 199 | + |
| 200 | + const state = await getSandboxState(sandboxId); |
| 201 | + if (state) { |
| 202 | + expect( |
| 203 | + ['stopped', 'stopping'], |
| 204 | + `Sandbox state should be 'stopped' or 'stopping' after stop command`, |
| 205 | + ).to.include(state); |
| 206 | + } |
| 207 | + }); |
| 208 | + }); |
| 209 | + |
| 210 | + describe('Step 5: Start Sandbox', function () { |
| 211 | + it('should start the sandbox', async function () { |
| 212 | + // Skip if we don't have a valid sandbox ID |
| 213 | + if (!sandboxId) { |
| 214 | + this.skip(); |
| 215 | + } |
| 216 | + |
| 217 | + const result = await runCLI(['ods', 'start', sandboxId, '--json']); |
| 218 | + |
| 219 | + expect(result.exitCode).to.equal(0, `Start command failed: ${result.stderr}`); |
| 220 | + const state = await getSandboxState(sandboxId); |
| 221 | + if (state) { |
| 222 | + expect(['started']).to.include(state); |
| 223 | + } |
| 224 | + }); |
| 225 | + }); |
| 226 | + |
| 227 | + describe('Step 6: Restart Sandbox', function () { |
| 228 | + it('should restart the sandbox', async function () { |
| 229 | + // Skip if we don't have a valid sandbox ID |
| 230 | + if (!sandboxId) { |
| 231 | + this.skip(); |
| 232 | + } |
| 233 | + |
| 234 | + const result = await runCLI(['ods', 'restart', sandboxId, '--json']); |
| 235 | + |
| 236 | + expect(result.exitCode).to.equal(0, `Restart command failed: ${result.stderr}`); |
| 237 | + |
| 238 | + const state = await getSandboxState(sandboxId); |
| 239 | + if (state) { |
| 240 | + expect( |
| 241 | + ['started', 'starting', 'restarting'], |
| 242 | + `Sandbox state should be 'started', 'starting', or 'restarting' after restart command, but got '${state}'`, |
| 243 | + ).to.include(state); |
| 244 | + } |
| 245 | + }); |
| 246 | + }); |
| 247 | + |
| 248 | + describe('Step 7: Get Sandbox Status', function () { |
| 249 | + it('should retrieve sandbox status', async function () { |
| 250 | + // Skip if we don't have a valid sandbox ID |
| 251 | + if (!sandboxId) { |
| 252 | + this.skip(); |
| 253 | + } |
| 254 | + |
| 255 | + const result = await runCLI(['ods', 'get', sandboxId, '--json']); |
| 256 | + |
| 257 | + expect(result.exitCode).to.equal(0, `Get command failed: ${result.stderr}`); |
| 258 | + expect(result.stdout, 'Get command should return JSON output').to.not.be.empty; |
| 259 | + |
| 260 | + const response = parseJson(result.stdout); |
| 261 | + expect(response, 'Get response should be a valid object').to.be.an('object'); |
| 262 | + expect(response.id, `Get response ID '${response.id}' should match requested sandbox '${sandboxId}'`).to.equal( |
| 263 | + sandboxId, |
| 264 | + ); |
| 265 | + expect(response.state, 'Get response should contain sandbox state').to.be.a('string').and.not.be.empty; |
| 266 | + }); |
| 267 | + }); |
| 268 | + |
| 269 | + describe('Step 8: Delete Sandbox', function () { |
| 270 | + it('should delete the sandbox', async function () { |
| 271 | + // Skip if we don't have a valid sandbox ID |
| 272 | + if (!sandboxId) { |
| 273 | + this.skip(); |
| 274 | + } |
| 275 | + |
| 276 | + const result = await runCLI(['ods', 'delete', sandboxId, '--force', '--json']); |
| 277 | + |
| 278 | + expect(result.exitCode).to.equal(0, `Delete command failed: ${result.stderr}`); |
| 279 | + }); |
| 280 | + }); |
| 281 | + |
| 282 | + describe('Additional Test Cases', function () { |
| 283 | + describe('Error Handling', function () { |
| 284 | + it('should handle invalid realm gracefully', async function () { |
| 285 | + const result = await runCLI(['ods', 'list', '--realm', 'invalid-realm-xyz', '--json']); |
| 286 | + |
| 287 | + // Command should either succeed with empty list or fail with error |
| 288 | + expect( |
| 289 | + result.exitCode, |
| 290 | + `Invalid realm command should either succeed (0) or fail (1), but got ${result.exitCode}`, |
| 291 | + ).to.be.oneOf([0, 1]); |
| 292 | + }); |
| 293 | + |
| 294 | + it('should handle missing sandbox ID gracefully', async function () { |
| 295 | + const result = await runCLI(['ods', 'get', 'non-existent-sandbox-id', '--json']); |
| 296 | + |
| 297 | + expect( |
| 298 | + result.exitCode, |
| 299 | + `Missing sandbox command should fail, but got exit code ${result.exitCode}`, |
| 300 | + ).to.not.equal(0); |
| 301 | + expect(result.stderr, 'Missing sandbox command should return error message').to.not.be.empty; |
| 302 | + }); |
| 303 | + }); |
| 304 | + |
| 305 | + describe('Authentication', function () { |
| 306 | + it('should fail with invalid credentials', async function () { |
| 307 | + const result = await execa('node', [CLI_BIN, 'ods', 'list', '--realm', process.env.TEST_REALM!, '--json'], { |
| 308 | + env: { |
| 309 | + ...process.env, |
| 310 | + SFCC_CLIENT_ID: 'invalid-client-id', |
| 311 | + SFCC_CLIENT_SECRET: 'invalid-client-secret', |
| 312 | + SFCC_LOG_LEVEL: 'silent', |
| 313 | + }, |
| 314 | + reject: false, |
| 315 | + }); |
| 316 | + |
| 317 | + expect(result.exitCode, `Invalid credentials should fail, but got exit code ${result.exitCode}`).to.not.equal( |
| 318 | + 0, |
| 319 | + ); |
| 320 | + expect(result.stderr, 'Invalid credentials should return authentication error').to.match( |
| 321 | + /401|unauthorized|invalid.*client/i, |
| 322 | + ); |
| 323 | + }); |
| 324 | + }); |
| 325 | + }); |
| 326 | + |
| 327 | + after(function () {}); |
| 328 | +}); |
0 commit comments