hana-cli
Version:
HANA Developer Command Line Interface
604 lines (529 loc) • 20.2 kB
JavaScript
// @ts-check
import * as base from '../base.js'
import { expect } from 'chai'
import { spawn } from 'child_process'
import http from 'http'
import { getLocalConnectionCredentials, getLiveTestControl, gateLiveTestInCI, skipOrFailLiveTest } from './helpers.js'
/**
* Make HTTP GET request and return response body as string
* @param {string} url - Full URL to request
* @param {number} timeout - Request timeout in milliseconds
* @returns {Promise<{statusCode: number, body: string, headers: object}>}
*/
function httpGet(url, timeout = 5000) {
return new Promise((resolve, reject) => {
const req = http.get(url, { timeout }, (res) => {
let body = ''
res.on('data', (chunk) => body += chunk)
res.on('end', () => resolve({
statusCode: res.statusCode || 0,
body,
headers: res.headers
}))
})
req.on('error', reject)
req.on('timeout', () => {
req.destroy()
reject(new Error(`Request timeout after ${timeout}ms`))
})
})
}
/**
* Wait for server to be ready by checking console output
* @param {import('child_process').ChildProcess} process - Server process
* @param {number} timeout - Max wait time in milliseconds
* @returns {Promise<string>} - URL of the running server
*/
function waitForServer(process, timeout = 30000) {
return new Promise((resolve, reject) => {
let output = ''
let resolved = false
const timeoutId = setTimeout(() => {
if (!resolved) {
resolved = true
reject(new Error(`Server did not start within ${timeout}ms. Output:\n${output}`))
}
}, timeout)
const checkOutput = (/** @type {Buffer} */ data) => {
const chunk = data.toString()
output += chunk
// Look for the server startup message
const serverMatch = chunk.match(/http server.*http:\/\/(localhost|[\d.]+):(\d+)/i)
if (serverMatch && !resolved) {
resolved = true
clearTimeout(timeoutId)
const urlMatch = serverMatch[0].match(/(http:\/\/[^\s]+)/i)
if (urlMatch) {
resolve(urlMatch[1])
} else {
reject(new Error('Could not extract URL from server output'))
}
}
}
process.stdout?.on('data', checkOutput)
process.stderr?.on('data', checkOutput)
process.on('error', (err) => {
if (!resolved) {
resolved = true
clearTimeout(timeoutId)
reject(err)
}
})
// Don't reject on exit if we already resolved (server started successfully)
process.on('exit', (code) => {
if (!resolved) {
resolved = true
clearTimeout(timeoutId)
if (code !== 0) {
reject(new Error(`Server process exited with code ${code}. Output:\n${output}`))
}
}
})
})
}
describe('cds command - E2E Tests', function () {
this.timeout(20000)
// Track spawned processes for cleanup
/** @type {import('child_process').ChildProcess[]} */
const spawnedProcesses = []
after(function () {
// Clean up any spawned processes
spawnedProcesses.forEach(proc => {
if (proc && !proc.killed) {
proc.kill('SIGTERM')
// Give it a moment, then force kill if needed
setTimeout(() => {
if (!proc.killed) {
proc.kill('SIGKILL')
}
}, 1000)
}
})
})
describe('Help output', () => {
it('shows help with --help flag', function (done) {
base.exec('node bin/cli.js cds --help', (error, stdout) => {
expect(error).to.be.null
expect(stdout).to.include('hana-cli cds')
expect(stdout).to.include('--table')
expect(stdout).to.include('--schema')
expect(stdout).to.include('--view')
expect(stdout).to.include('--useHanaTypes')
expect(stdout).to.include('--port')
expect(stdout).to.include('--profile')
base.addContext(this, { title: 'CDS Help', value: stdout })
done()
})
})
it('includes documentation and related command links', function (done) {
base.exec('node bin/cli.js cds --help', (error, stdout) => {
expect(error).to.be.null
expect(stdout).to.match(/Documentation:\s+https:\/\/sap-samples\.github\.io\/hana-developer-cli-tool-example\/02-commands\/developer-tools\/cds/i)
expect(stdout).to.include('hana-cli activateHDI --help')
expect(stdout).to.include('hana-cli generateDocs --help')
expect(stdout).to.include('hana-cli codeTemplate --help')
done()
})
})
it('shows example usage', function (done) {
base.exec('node bin/cli.js cds --help', (error, stdout) => {
expect(error).to.be.null
expect(stdout).to.include('Examples:')
expect(stdout).to.include('hana-cli cds --table myTable --schema MYSCHEMA')
done()
})
})
})
describe('Aliases', () => {
it('supports alias "cdsPreview"', function (done) {
base.exec('node bin/cli.js cdsPreview --help', (error, stdout) => {
expect(error).to.be.null
expect(stdout).to.include('hana-cli cds')
done()
})
})
})
describe('Validation', () => {
it('rejects missing required table parameter (with --quiet)', function (done) {
base.exec('node bin/cli.js cds --schema MYSCHEMA --quiet --no-prompt', (error, stdout, stderr) => {
expect(error).to.exist
const output = `${stdout || ''}\n${stderr || ''}`
// Command should fail without required table parameter
expect(output.toLowerCase()).to.satisfy((/** @type {string} */ str) =>
str.includes('table') || str.includes('required') || str.includes('missing'),
'Expected error about missing table parameter'
)
base.addContext(this, { title: 'Validation error output', value: output })
done()
})
})
it('accepts valid port number', function (done) {
// This will fail at connection stage but should pass validation
base.exec('node bin/cli.js cds --table TEST --port 4000 --quiet --no-prompt', (error, stdout, stderr) => {
const output = `${stdout || ''}\n${stderr || ''}`
// Should not complain about port format
expect(output).to.not.match(/invalid.*port/i)
base.addContext(this, { title: 'Port validation output', value: output })
done()
})
})
})
describe('Live server tests (optional)', () => {
this.timeout(60000) // Server startup can take time
it('starts CDS preview server and serves OData endpoints', function (done) {
const liveControl = getLiveTestControl('HANA_CLI_E2E_LIVE_CDS')
if (!gateLiveTestInCI(this, done, liveControl, 'CDS live E2E')) {
return
}
getLocalConnectionCredentials().then((creds) => {
if (!creds || creds.kind !== 'hana') {
return skipOrFailLiveTest(this, done, liveControl,
'Live CDS E2E prerequisites not met: no HANA credentials resolved.')
}
// Use a random port to avoid conflicts
const testPort = 3000 + Math.floor(Math.random() * 1000)
// Spawn the CDS server process
// Note: Using SCHEMAS view instead of DUMMY table because DUMMY has special
// characteristics that don't translate well to CDS syntax
const serverProcess = spawn('node', [
'bin/cli.js',
'cds',
'--table', 'SCHEMAS',
'--schema', 'SYS',
'--view',
'--port', testPort.toString(),
'--quiet'
], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
NODE_ENV: 'test',
// Suppress unhandled rejection warnings from CDS server internals
NODE_OPTIONS: '--unhandled-rejections=warn'
}
})
spawnedProcesses.push(serverProcess)
/** @type {string|undefined} */
let serverUrl
let testsPassed = false
const cleanup = () => {
if (serverProcess && !serverProcess.killed) {
serverProcess.kill('SIGTERM')
setTimeout(() => {
if (!serverProcess.killed) {
serverProcess.kill('SIGKILL')
}
}, 1000)
}
}
// Wait for server to start
waitForServer(serverProcess, 45000)
.then((url) => {
serverUrl = url
base.addContext(this, { title: 'Server URL', value: serverUrl })
console.log(` CDS server started at ${serverUrl}`)
// Test 1: Check homepage
return httpGet(serverUrl, 10000)
})
.then((response) => {
expect(response.statusCode).to.equal(200)
expect(response.body).to.include('HANA-Cli CDS Preview Tool')
expect(response.body).to.include('odata')
base.addContext(this, { title: 'Homepage response', value: response.body.substring(0, 500) })
console.log(' ✓ Homepage loaded successfully')
// Test 2: Check OData service document
return httpGet(`${serverUrl}/odata/v4/HanaCli`, 10000)
})
.then((response) => {
expect(response.statusCode).to.equal(200)
const contentType = /** @type {any} */(response.headers)['content-type']
expect(contentType).to.match(/application\/json/)
const serviceDoc = JSON.parse(response.body)
expect(serviceDoc).to.have.property('@odata.context')
base.addContext(this, { title: 'OData service document', value: response.body })
console.log(' ✓ OData service document retrieved')
// Test 3: Check metadata endpoint
return httpGet(`${serverUrl}/odata/v4/HanaCli/$metadata`, 10000)
})
.then((response) => {
expect(response.statusCode).to.equal(200)
const contentType = /** @type {any} */(response.headers)['content-type']
expect(contentType).to.match(/application\/xml|xml/)
expect(response.body).to.include('edmx:Edmx')
expect(response.body).to.include('SCHEMAS')
base.addContext(this, { title: 'OData metadata', value: response.body.substring(0, 1000) })
console.log(' ✓ OData metadata endpoint working')
testsPassed = true
cleanup()
done()
})
.catch((err) => {
cleanup()
base.addContext(this, { title: 'Test error', value: err.message })
if (liveControl.force) {
done(err)
} else {
console.log(` ⚠ Test failed but not in forced mode: ${err.message}`)
this.skip()
done()
}
})
// Handle server process errors
serverProcess.on('error', (err) => {
if (!testsPassed) {
cleanup()
done(err)
}
})
}).catch((err) => {
skipOrFailLiveTest(this, done, liveControl,
`Failed to resolve credentials: ${err.message}`)
})
})
it('serves API documentation at /api-docs', function (done) {
const liveControl = getLiveTestControl('HANA_CLI_E2E_LIVE_CDS')
if (!gateLiveTestInCI(this, done, liveControl, 'CDS API docs E2E')) {
return
}
getLocalConnectionCredentials().then((creds) => {
if (!creds || creds.kind !== 'hana') {
return skipOrFailLiveTest(this, done, liveControl,
'Live CDS E2E prerequisites not met: no HANA credentials resolved.')
}
const testPort = 3100 + Math.floor(Math.random() * 1000)
// Using SCHEMAS view for consistent results
const serverProcess = spawn('node', [
'bin/cli.js',
'cds',
'--table', 'SCHEMAS',
'--view',
'--schema', 'SYS',
'--port', testPort.toString(),
'--quiet'
], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
NODE_ENV: 'test',
NODE_OPTIONS: '--unhandled-rejections=warn'
}
})
spawnedProcesses.push(serverProcess)
/** @type {string|undefined} */
let serverUrl
let testsPassed = false
const cleanup = () => {
if (serverProcess && !serverProcess.killed) {
serverProcess.kill('SIGTERM')
setTimeout(() => {
if (!serverProcess.killed) {
serverProcess.kill('SIGKILL')
}
}, 1000)
}
}
waitForServer(serverProcess, 45000)
.then((url) => {
serverUrl = url
console.log(` CDS server started at ${serverUrl}`)
// Check if Swagger UI is available
return httpGet(`${serverUrl}/api-docs/`, 10000)
})
.then((response) => {
// Swagger UI should redirect or return HTML
expect([200, 301, 302]).to.include(response.statusCode)
if (response.statusCode === 200) {
// Should contain Swagger UI elements
expect(response.body).to.satisfy((/** @type {string} */ body) =>
body.includes('swagger') || body.includes('Swagger') || body.includes('openapi'),
'Expected Swagger UI content'
)
console.log(' ✓ API documentation endpoint accessible')
}
base.addContext(this, { title: 'API docs response', value: response.body.substring(0, 500) })
testsPassed = true
cleanup()
done()
})
.catch((err) => {
cleanup()
base.addContext(this, { title: 'Test error', value: err.message })
if (liveControl.force) {
done(err)
} else {
console.log(` ⚠ Test skipped: ${err.message}`)
this.skip()
done()
}
})
serverProcess.on('error', (err) => {
if (!testsPassed) {
cleanup()
done(err)
}
})
}).catch((err) => {
skipOrFailLiveTest(this, done, liveControl,
`Failed to resolve credentials: ${err.message}`)
})
})
it('accepts custom port via --port flag', function (done) {
const liveControl = getLiveTestControl('HANA_CLI_E2E_LIVE_CDS')
if (!gateLiveTestInCI(this, done, liveControl, 'CDS custom port E2E')) {
return
}
getLocalConnectionCredentials().then((creds) => {
if (!creds || creds.kind !== 'hana') {
return skipOrFailLiveTest(this, done, liveControl,
'Live CDS E2E prerequisites not met: no HANA credentials resolved.')
}
const customPort = 3200 + Math.floor(Math.random() * 800)
// Using SCHEMAS view for consistent results
const serverProcess = spawn('node', [
'bin/cli.js',
'cds',
'--table', 'SCHEMAS',
'--view',
'--schema', 'SYS',
'--port', customPort.toString(),
'--quiet'
], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
NODE_ENV: 'test',
NODE_OPTIONS: '--unhandled-rejections=warn'
}
})
spawnedProcesses.push(serverProcess)
let testsPassed = false
const cleanup = () => {
if (serverProcess && !serverProcess.killed) {
serverProcess.kill('SIGTERM')
setTimeout(() => {
if (!serverProcess.killed) {
serverProcess.kill('SIGKILL')
}
}, 1000)
}
}
waitForServer(serverProcess, 45000)
.then((url) => {
// Verify the URL contains our custom port
expect(url).to.include(`:${customPort}`)
base.addContext(this, { title: 'Server URL with custom port', value: url })
console.log(` ✓ Server started on custom port ${customPort}`)
// Verify server is actually listening on that port
return httpGet(url, 10000)
})
.then((response) => {
expect(response.statusCode).to.equal(200)
console.log(' ✓ Server responding on custom port')
testsPassed = true
cleanup()
done()
})
.catch((err) => {
cleanup()
base.addContext(this, { title: 'Test error', value: err.message })
if (liveControl.force) {
done(err)
} else {
console.log(` ⚠ Test skipped: ${err.message}`)
this.skip()
done()
}
})
serverProcess.on('error', (err) => {
if (!testsPassed) {
cleanup()
done(err)
}
})
}).catch((err) => {
skipOrFailLiveTest(this, done, liveControl,
`Failed to resolve credentials: ${err.message}`)
})
})
it('handles --view flag for CDS view generation', function (done) {
const liveControl = getLiveTestControl('HANA_CLI_E2E_LIVE_CDS')
if (!gateLiveTestInCI(this, done, liveControl, 'CDS view flag E2E')) {
return
}
getLocalConnectionCredentials().then((creds) => {
if (!creds || creds.kind !== 'hana') {
return skipOrFailLiveTest(this, done, liveControl,
'Live CDS E2E prerequisites not met: no HANA credentials resolved.')
}
const testPort = 3300 + Math.floor(Math.random() * 700)
// Try with a system view (M_TABLES is commonly available)
const serverProcess = spawn('node', [
'bin/cli.js',
'cds',
'--table', 'M_TABLES',
'--schema', 'SYS',
'--view',
'--port', testPort.toString(),
'--quiet'
], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
NODE_ENV: 'test',
NODE_OPTIONS: '--unhandled-rejections=warn'
}
})
spawnedProcesses.push(serverProcess)
/** @type {string|undefined} */
let serverUrl
let testsPassed = false
const cleanup = () => {
if (serverProcess && !serverProcess.killed) {
serverProcess.kill('SIGTERM')
setTimeout(() => {
if (!serverProcess.killed) {
serverProcess.kill('SIGKILL')
}
}, 1000)
}
}
waitForServer(serverProcess, 45000)
.then((url) => {
serverUrl = url
console.log(` CDS server started for view at ${serverUrl}`)
// Check OData metadata to verify view was processed
return httpGet(`${serverUrl}/odata/v4/HanaCli/$metadata`, 10000)
})
.then((response) => {
expect(response.statusCode).to.equal(200)
expect(response.body).to.include('M_TABLES')
base.addContext(this, { title: 'View metadata', value: response.body.substring(0, 1000) })
console.log(' ✓ View processed successfully in CDS')
testsPassed = true
cleanup()
done()
})
.catch((err) => {
cleanup()
base.addContext(this, { title: 'Test error', value: err.message })
if (liveControl.force) {
done(err)
} else {
console.log(` ⚠ Test skipped: ${err.message}`)
this.skip()
done()
}
})
serverProcess.on('error', (err) => {
if (!testsPassed) {
cleanup()
done(err)
}
})
}).catch((err) => {
skipOrFailLiveTest(this, done, liveControl,
`Failed to resolve credentials: ${err.message}`)
})
})
})
})