1
0
mirror of https://github.com/sasjs/adapter.git synced 2025-12-11 09:24:35 +00:00

Compare commits

...

43 Commits

Author SHA1 Message Date
7d826685f7 Merge pull request #813 from sasjs/sasjs-job-execute
fix: issue with parsing json in sasjs job executor
2023-06-15 16:08:08 +02:00
f42f6bca00 fix: issue with parsing json in sasjs job executor 2023-06-14 15:08:11 +02:00
Yury Shkoda
bd872e0e75 Merge pull request #809 from sasjs/webout-issue
fix(sasjs-request-client): fixed response parsing
2023-05-10 16:26:13 +03:00
Yury Shkoda
a14a1663fc fix(sasjs-request-client): fixed response parsing 2023-05-10 16:07:25 +03:00
Yury Shkoda
2482a0c674 Merge pull request #807 from sasjs/issue-245
fix(deploy-service-pack): fixed redeployment
2023-05-09 17:12:11 +03:00
Yury Shkoda
a19de50e67 fix(create-folder): improved error message 2023-05-09 16:59:18 +03:00
Yury Shkoda
860c9f907c fix(deploy-service-pack): fixed redeployment 2023-05-09 10:43:23 +03:00
Allan Bowe
d7126a6878 Merge pull request #802 from sasjs/fix-ci
chore(ci): install vpn and run sasjs tests
2023-05-04 10:51:34 +01:00
Yury Shkoda
a69ebd0fd3 Merge pull request #806 from sasjs/sasjs-job-execution
fix(job-execution): fixed webout for SASJS server type
2023-05-04 12:42:11 +03:00
Yury Shkoda
0f47326bb6 chore(execute-script): improved code style 2023-05-04 12:21:22 +03:00
Yury Shkoda
2d6efa2437 fix(job-execution): fixed webout for SASJS server type 2023-05-04 10:57:24 +03:00
Yury Shkoda
da7579a2bb Merge pull request #805 from sasjs/webpack-fix
fix(packaging): fixed output file for Node
2023-04-21 17:28:52 +03:00
Yury Shkoda
657e415c0c fix(packaging): fixed output file for Node 2023-04-21 16:02:47 +03:00
Allan Bowe
8fa908a201 Merge pull request #804 from sasjs/refrerefreshTokensForViya-fix
fix(refreshTokensForViya): fixed FormData logic
2023-04-21 09:53:55 +01:00
Yury Shkoda
a29b7f3b92 docs(refreshTokensForViya): added notes about limitation 2023-04-21 11:06:48 +03:00
Yury Shkoda
8360519408 fix(refreshTokensForViya): added throw error if not Node 2023-04-21 11:03:41 +03:00
Yury Shkoda
a71d422528 fix(refreshTokensForViya): fixed FormData logic 2023-04-20 16:13:22 +03:00
Allan Bowe
7d37e4d79d Merge pull request #793 from sasjs/session-manager-improvements
Session manager improvements
2023-04-19 15:08:42 +01:00
Yury Shkoda
7ae21c160a docs(session manager): added info about hot sessions 2023-04-19 16:01:49 +03:00
bfefdb65a3 chore: addressed comments 2023-04-18 00:45:03 +05:00
bbe52562da ci: sasjs tests 2023-04-10 13:15:41 +02:00
48917bb4d9 ci: sasjs tests 2023-04-10 12:22:28 +02:00
81c3d2a3dc style: lint 2023-04-10 12:10:41 +02:00
0052d5d340 ci: sasjs tests logged in fix 2023-04-10 12:08:01 +02:00
a633bda24d ci: sasjs tests 2023-04-10 10:05:10 +02:00
e951ea0ab2 chore: quick fix 2023-04-08 01:13:11 +05:00
b75ab13eb7 chore: update the command for running cypress 2023-04-07 23:50:11 +05:00
4de34cc8b0 chore: add streaming config and fix apploc 2023-04-07 17:06:08 +05:00
23a789b383 chore: add refresh token to .env.4gl 2023-04-06 23:13:23 +05:00
156f1b1180 chore: create .env.4gl at proper place 2023-04-06 22:43:32 +05:00
491e36d703 chore: install sasjs cli 2023-04-06 22:31:51 +05:00
06b6e48a16 chore: deploy the streaming app to sas9.4gl.io 2023-04-06 22:07:18 +05:00
9bebd356ca chore: deamonize npm start process 2023-04-06 16:28:33 +05:00
171f9bc7b9 chore(ci): install vpn and run sasjs tests locally 2023-04-06 16:03:16 +05:00
Yury Shkoda
88a1f3eac6 docs(session-manager): improved grammar 2023-03-28 13:00:07 +03:00
Yury Shkoda
b00db86811 chore(session-manager): renamed ErrorResponse interface to ApiErrorResponse 2023-03-27 18:34:44 +03:00
Yury Shkoda
96caadc6b1 test(request-client): fixing self-signed certificate error 2023-03-27 17:08:06 +03:00
Yury Shkoda
074a21c4bb test(session-manager): fixed server url 2023-03-27 16:53:45 +03:00
Yury Shkoda
588a4169bb docs(session-manager): added method descriptions 2023-03-27 16:43:33 +03:00
Yury Shkoda
3d4c01622e chore(session-manager): improved error handling 2023-03-27 16:40:57 +03:00
Yury Shkoda
33bc8f92cb test(request-client): fixed typo 2023-03-24 12:15:10 +03:00
Yury Shkoda
c1704fff78 feat(session-manager): refactored and covered with tests 2023-03-24 11:39:59 +03:00
Yury Shkoda
891cc13362 feat(session-manager): improving performance 2023-03-14 10:48:18 +03:00
27 changed files with 1844 additions and 633 deletions

25
.github/vpn/config.ovpn vendored Normal file
View File

@@ -0,0 +1,25 @@
# Client
client
tls-client
dev tun
# this will connect with whatever proto DNS tells us (https://community.openvpn.net/openvpn/ticket/934)
proto tcp
remote vpn.4gl.io 7494
resolv-retry infinite
cipher AES-256-CBC
auth SHA256
script-security 2
keepalive 10 120
remote-cert-tls server
# Keys
ca ca.crt
cert user.crt
key user.key
tls-auth tls.key 1
# Security
nobind
persist-key
persist-tun
verb 3

View File

@@ -39,11 +39,33 @@ jobs:
env:
CI: true
- name: Install SSH Key
uses: shimataro/ssh-key-action@v2
with:
key: ${{ secrets.DCGITLAB_KEY }}
known_hosts: 'placeholder'
- name: Write VPN Files
run: |
echo "$CA_CRT" > .github/vpn/ca.crt
echo "$USER_CRT" > .github/vpn/user.crt
echo "$USER_KEY" > .github/vpn/user.key
echo "$TLS_KEY" > .github/vpn/tls.key
shell: bash
env:
CA_CRT: ${{ secrets.CA_CRT}}
USER_CRT: ${{ secrets.USER_CRT }}
USER_KEY: ${{ secrets.USER_KEY }}
TLS_KEY: ${{ secrets.TLS_KEY }}
- name: Install Open VPN
run: |
sudo apt install apt-transport-https
sudo wget https://swupdate.openvpn.net/repos/openvpn-repo-pkg-key.pub
sudo apt-key add openvpn-repo-pkg-key.pub
sudo wget -O /etc/apt/sources.list.d/openvpn3.list https://swupdate.openvpn.net/community/openvpn3/repos/openvpn3-jammy.list
sudo apt update
sudo apt install openvpn3=17~betaUb22042+jammy
- name: Start Open VPN 3
run: openvpn3 session-start --config .github/vpn/config.ovpn
- name: install pm2
run: npm i -g pm2
- name: Deploy sasjs-tests
run: |
@@ -55,12 +77,12 @@ jobs:
replace-in-files --regex='"userName".*' --replacement='"userName":"${{ secrets.SASJS_USERNAME }}",' ./public/config.json
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./public/config.json
replace-in-files --regex='"serverType".*' --replacement='"serverType":"SASJS",' ./public/config.json
npm run update:adapter && npm run build
scp -o stricthostkeychecking=no -r ./build/* ${{ secrets.DCGITLAB_DEPLOY_PATH_VIYA }}
npm run update:adapter
pm2 start --name sasjs-test npm -- start
- name: Run cypress on sasjs
run: |
replace-in-files --regex='"sasjsTestsUrl".*' --replacement='"sasjsTestsUrl":"${{ secrets.SASJS_TEST_URL_VIYA }}",' ./cypress.json
replace-in-files --regex='"sasjsTestsUrl".*' --replacement='"sasjsTestsUrl":"http://localhost:3000",' ./cypress.json
replace-in-files --regex='"username".*' --replacement='"username":"${{ secrets.SASJS_USERNAME }}",' ./cypress.json
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./cypress.json
sh ./sasjs-tests/sasjs-cypress-run.sh ${{ secrets.MATRIX_TOKEN }} https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}

View File

@@ -153,6 +153,14 @@ The response object will contain returned tables and columns. Table names are a
The adapter will also cache the logs (if debug enabled) and even the work tables. For performance, it is best to keep debug mode off.
### Session Manager
To execute a script on Viya a session has to be created first which is time-consuming (~15sec). That is why a Session Manager has been created which is implementing the following logic:
1. When the first session is requested, we also create one more session (hot session) for future requests. Please notice two pending POST requests to create a session within the same context: ![the first session request](./screenshots/session-manager-first-request.png)
2. When a subsequent request for a session is received and there is a hot session available (not expired), this session is returned and an asynchronous request to create another hot session is sent. Please notice that there is a pending POST request to create a new session while a job has been already finished execution (POST request with status 201): ![subsequent session request](./screenshots/subsequent-session-request.png)
3. When a subsequent request for a session is received and there is no available hot session, 2 requests are sent asynchronously to create a session. The first created session will be returned and another session will be reserved for future requests.
### Variable Types
The SAS type (char/numeric) of the values is determined according to a set of rules:

View File

@@ -14,65 +14,83 @@ context('sasjs-tests', function () {
it('Should have all tests successfull', (done) => {
cy.get('body').then(($body) => {
if ($body.find('input[placeholder="User Name"]').length > 0) {
cy.get('input[placeholder="User Name"]').type(username)
cy.get('input[placeholder="Password"]').type(password)
cy.get('.submit-button').click()
}
cy.wait(1000).then(() => {
const startButton = $body.find(
'.ui.massive.icon.primary.left.labeled.button'
)[0]
cy.get('input[placeholder="User Name"]', { timeout: 40000 })
.should('not.exist')
.then(() => {
cy.get('.ui.massive.icon.primary.left.labeled.button')
.click()
.then(() => {
cy.get('.ui.massive.loading.primary.button', {
timeout: testingFinishTimeout
})
.should('not.exist')
.then(() => {
cy.get('span.icon.failed')
.should('not.exist')
.then(() => {
done()
})
if (
!startButton ||
(startButton && !Cypress.dom.isVisible(startButton))
) {
cy.get('input[placeholder="User Name"]').type(username)
cy.get('input[placeholder="Password"]').type(password)
cy.get('.submit-button').click()
}
cy.get('input[placeholder="User Name"]', { timeout: 40000 })
.should('not.exist')
.then(() => {
cy.get('.ui.massive.icon.primary.left.labeled.button')
.click()
.then(() => {
cy.get('.ui.massive.loading.primary.button', {
timeout: testingFinishTimeout
})
})
})
.should('not.exist')
.then(() => {
cy.get('span.icon.failed')
.should('not.exist')
.then(() => {
done()
})
})
})
})
})
})
})
it('Should have all tests successfull with debug on', (done) => {
cy.get('body').then(($body) => {
if ($body.find('input[placeholder="User Name"]').length > 0) {
cy.get('input[placeholder="User Name"]').type(username)
cy.get('input[placeholder="Password"]').type(password)
cy.get('.submit-button').click()
}
cy.wait(1000).then(() => {
const startButton = $body.find(
'.ui.massive.icon.primary.left.labeled.button'
)[0]
cy.get('.ui.fitted.toggle.checkbox label')
.click()
.then(() => {
cy.get('input[placeholder="User Name"]', { timeout: 40000 })
.should('not.exist')
.then(() => {
cy.get('.ui.massive.icon.primary.left.labeled.button')
.click()
.then(() => {
cy.get('.ui.massive.loading.primary.button', {
timeout: testingFinishTimeout
})
.should('not.exist')
.then(() => {
cy.get('span.icon.failed')
.should('not.exist')
.then(() => {
done()
})
if (
!startButton ||
(startButton && !Cypress.dom.isVisible(startButton))
) {
cy.get('input[placeholder="User Name"]').type(username)
cy.get('input[placeholder="Password"]').type(password)
cy.get('.submit-button').click()
}
cy.get('.ui.fitted.toggle.checkbox label')
.click()
.then(() => {
cy.get('input[placeholder="User Name"]', { timeout: 40000 })
.should('not.exist')
.then(() => {
cy.get('.ui.massive.icon.primary.left.labeled.button')
.click()
.then(() => {
cy.get('.ui.massive.loading.primary.button', {
timeout: testingFinishTimeout
})
})
})
})
.should('not.exist')
.then(() => {
cy.get('span.icon.failed')
.should('not.exist')
.then(() => {
done()
})
})
})
})
})
})
})
})
})

View File

@@ -0,0 +1,25 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })

20
cypress/support/index.js Normal file
View File

@@ -0,0 +1,20 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')

14
package-lock.json generated
View File

@@ -32,7 +32,7 @@
"node-polyfill-webpack-plugin": "1.1.4",
"path": "0.12.7",
"pem": "1.14.5",
"prettier": "2.7.1",
"prettier": "2.8.7",
"process": "0.11.10",
"rimraf": "3.0.2",
"semantic-release": "19.0.3",
@@ -13925,9 +13925,9 @@
}
},
"node_modules/prettier": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz",
"integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==",
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.7.tgz",
"integrity": "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==",
"dev": true,
"bin": {
"prettier": "bin-prettier.js"
@@ -27416,9 +27416,9 @@
"dev": true
},
"prettier": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz",
"integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==",
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.7.tgz",
"integrity": "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==",
"dev": true
},
"pretty-bytes": {

View File

@@ -60,7 +60,7 @@
"node-polyfill-webpack-plugin": "1.1.4",
"path": "0.12.7",
"pem": "1.14.5",
"prettier": "2.7.1",
"prettier": "2.8.7",
"process": "0.11.10",
"rimraf": "3.0.2",
"semantic-release": "19.0.3",

View File

@@ -13,10 +13,7 @@
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.*
npm-debug.log*
yarn-debug.log*

File diff suppressed because it is too large Load Diff

View File

@@ -3,10 +3,10 @@
"password": "",
"sasJsConfig": {
"serverUrl": "",
"appLoc": "/Public/app/adapter-tests",
"appLoc": "/Public/app/adapter-tests/services",
"serverType": "SASJS",
"debug": false,
"contextName": "sasjs adapter compute context",
"useComputeApi": true
}
}
}

View File

@@ -1,17 +1,29 @@
{
"$schema": "https://cli.sasjs.io/sasjsconfig-schema.json",
"serviceConfig": {
"serviceFolders": [
"sasjs/common"
]
"serviceFolders": ["sasjs/common"]
},
"defaultTarget": "4gl",
"targets": [
{
"name": "4gl",
"serverType": "SASJS",
"serverUrl": "https://sas9.4gl.io",
"appLoc": "/Public/app/adapter-tests"
"serverType": "SASJS",
"httpsAgentOptions": {
"allowInsecureRequests": false
},
"appLoc": "/Public/app/adapter-tests",
"deployConfig": {
"deployServicePack": true,
"deployScripts": []
},
"streamConfig": {
"streamWeb": true,
"streamWebFolder": "webv",
"webSourcePath": "build",
"streamServiceName": "adapter-tests",
"assetPaths": []
}
}
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

View File

@@ -29,11 +29,33 @@ describe('SASViyaApiClient', () => {
jest
.spyOn(requestClient, 'get')
.mockImplementation(() => Promise.reject('Not Found'))
const error = await sasViyaApiClient
.createFolder('test', '/foo')
.catch((e: any) => e)
expect(error).toBeInstanceOf(RootFolderNotFoundError)
})
it('should throw an error when ', async () => {
const testMessage1 = 'test message 1'
const testMessage2 = 'test message 2.'
jest.spyOn(requestClient, 'post').mockImplementation(() => {
return Promise.reject({
message: testMessage1,
response: { data: { message: testMessage2 }, status: 409 }
})
})
const error = await sasViyaApiClient
.createFolder('test', '/foo')
.catch((e: any) => e)
const expectedError = `${testMessage1}. ${testMessage2} To override, please set "isForced" to "true".`
expect(error).toEqual(expectedError)
})
})
const setupMocks = () => {

View File

@@ -378,12 +378,14 @@ export class SASViyaApiClient {
isForced?: boolean
): Promise<Folder> {
const logger = process.logger || console
if (!parentFolderPath && !parentFolderUri) {
throw new Error('Path or URI of the parent folder is required.')
}
if (!parentFolderUri && parentFolderPath) {
parentFolderUri = await this.getFolderUri(parentFolderPath, accessToken)
if (!parentFolderUri) {
logger.info(
`Parent folder at path '${parentFolderPath}' is not present.`
@@ -394,6 +396,7 @@ export class SASViyaApiClient {
parentFolderPath.lastIndexOf('/')
)
const newFolderName = `${parentFolderPath.split('/').pop()}`
if (newParentFolderPath === '') {
throw new RootFolderNotFoundError(
parentFolderPath,
@@ -401,20 +404,24 @@ export class SASViyaApiClient {
accessToken
)
}
logger.info(
`Creating parent folder:\n'${newFolderName}' in '${newParentFolderPath}'`
)
const parentFolder = await this.createFolder(
newFolderName,
newParentFolderPath,
undefined,
accessToken
)
logger.info(
`Parent folder '${newFolderName}' has been successfully created.`
)
parentFolderUri = `/folders/folders/${parentFolder.id}`
} else if (isForced && accessToken) {
} else if (isForced) {
const folderPath = parentFolderPath + '/' + folderName
const folderUri = await this.getFolderUri(folderPath, accessToken)
@@ -427,8 +434,8 @@ export class SASViyaApiClient {
}
}
const { result: createFolderResponse } =
await this.requestClient.post<Folder>(
const { result: createFolderResponse } = await this.requestClient
.post<Folder>(
`/folders/folders?parentFolderUri=${parentFolderUri}`,
{
name: folderName,
@@ -436,12 +443,34 @@ export class SASViyaApiClient {
},
accessToken
)
.catch((err) => {
const { message, response } = err
if (message && response && response.data && response.data.message) {
const { status } = response
const { message: responseMessage } = response.data
const messages = [message, responseMessage].map((mes: string) =>
/\.$/.test(mes) ? mes : `${mes}.`
)
if (!isForced && status === 409) {
messages.push(`To override, please set "isForced" to "true".`)
}
const errMessage = messages.join(' ')
throw errMessage
}
throw err
})
// update folder map with newly created folder.
await this.populateFolderMap(
`${parentFolderPath}/${folderName}`,
accessToken
)
return createFolderResponse
}
@@ -548,6 +577,7 @@ export class SASViyaApiClient {
/**
* Exchanges the refresh token for an access token for the given client.
* This method can only be used by Node.
* @param clientId - the client ID to authenticate with.
* @param clientSecret - the client secret to authenticate with.
* @param refreshToken - the refresh token received from the server.
@@ -899,7 +929,7 @@ export class SASViyaApiClient {
return `/folders/folders/${folderDetails.id}`
}
private async getRecycleBinUri(accessToken: string) {
private async getRecycleBinUri(accessToken?: string) {
const url = '/folders/folders/@myRecycleBin'
const { result: folder } = await this.requestClient
@@ -983,7 +1013,7 @@ export class SASViyaApiClient {
sourceFolder: string,
targetParentFolder: string,
targetFolderName: string,
accessToken: string
accessToken?: string
) {
// If target path is an existing folder, than keep source folder name, othervise rename it with given target folder name
const sourceFolderName = sourceFolder.split('/').pop() as string
@@ -1050,7 +1080,7 @@ export class SASViyaApiClient {
* @param folderPath - the full path (eg `/Public/example/deleteThis`) of the folder to be deleted.
* @param accessToken - an access token for authorizing the request.
*/
public async deleteFolder(folderPath: string, accessToken: string) {
public async deleteFolder(folderPath: string, accessToken?: string) {
const recycleBinUri = await this.getRecycleBinUri(accessToken)
const folderName = folderPath.split('/').pop() || ''
const date = new Date()

View File

@@ -337,13 +337,16 @@ export default class SASjs {
sasApiClient?: SASViyaApiClient,
isForced?: boolean
) {
if (sasApiClient)
if (sasApiClient) {
return await sasApiClient.createFolder(
folderName,
parentFolderPath,
parentFolderUri,
accessToken
accessToken,
isForced
)
}
return await this.sasViyaApiClient!.createFolder(
folderName,
parentFolderPath,
@@ -783,13 +786,11 @@ export default class SASjs {
this.isMethodSupported('deployServicePack', [ServerType.SasViya])
let sasApiClient: any = null
if (serverUrl || appLoc) {
if (!serverUrl) {
serverUrl = this.sasjsConfig.serverUrl
}
if (!appLoc) {
appLoc = this.sasjsConfig.appLoc
}
if (!serverUrl) serverUrl = this.sasjsConfig.serverUrl
if (!appLoc) appLoc = this.sasjsConfig.appLoc
if (this.sasjsConfig.serverType === ServerType.SasViya) {
sasApiClient = new SASViyaApiClient(
serverUrl,
@@ -807,11 +808,13 @@ export default class SASjs {
}
} else {
let sasClientConfig: any = null
if (this.sasjsConfig.serverType === ServerType.SasViya) {
sasClientConfig = this.sasViyaApiClient!.getConfig()
} else if (this.sasjsConfig.serverType === ServerType.Sas9) {
sasClientConfig = this.sas9ApiClient!.getConfig()
}
serverUrl = sasClientConfig.serverUrl
appLoc = sasClientConfig.rootFolderName as string
}

View File

@@ -1,11 +1,24 @@
import * as NodeFormData from 'form-data'
import { AuthConfig, ServerType, ServicePackSASjs } from '@sasjs/utils/types'
import { prefixMessage } from '@sasjs/utils/error'
import { ExecutionQuery } from './types'
import { RequestClient } from './request/RequestClient'
import { getAccessTokenForSasjs } from './auth/getAccessTokenForSasjs'
import { refreshTokensForSasjs } from './auth/refreshTokensForSasjs'
import { getTokens } from './auth/getTokens'
// TODO: move to sasjs/utils
export interface SASjsAuthResponse {
access_token: string
refresh_token: string
}
export interface ScriptExecutionResult {
log: string
webout?: string
printOutput?: string
}
export class SASjsApiClient {
constructor(private requestClient: RequestClient) {}
@@ -118,18 +131,28 @@ export class SASjsApiClient {
code: string,
runTime: string = 'sas',
authConfig?: AuthConfig
) {
): Promise<ScriptExecutionResult> {
const access_token = await this.getAccessTokenForRequest(authConfig)
let parsedSasjsServerLog = ''
const executionResult: ScriptExecutionResult = { log: '' }
await this.requestClient
.post('SASjsApi/code/execute', { code, runTime }, access_token)
.then((res: any) => {
if (res.log) parsedSasjsServerLog = res.log
const { log, printOutput, result: webout } = res
executionResult.log = log
if (printOutput) executionResult.printOutput = printOutput
if (webout) executionResult.webout = webout
})
.catch((err) => {
throw prefixMessage(
err,
'Error while sending POST request to execute code. '
)
})
return parsedSasjsServerLog
return executionResult
}
/**
@@ -152,9 +175,3 @@ export class SASjsApiClient {
return refreshTokensForSasjs(this.requestClient, refreshToken)
}
}
// todo move to sasjs/utils
export interface SASjsAuthResponse {
access_token: string
refresh_token: string
}

View File

@@ -6,6 +6,10 @@ import { RequestClient } from './request/RequestClient'
const MAX_SESSION_COUNT = 1
interface ApiErrorResponse {
response: { status: number | string; data: { message: string } }
}
export class SessionManager {
private loggedErrors: NoSessionStateError[] = []
@@ -17,8 +21,10 @@ export class SessionManager {
if (serverUrl) isUrl(serverUrl)
}
// INFO: session pool
private sessions: Session[] = []
private currentContext: Context | null = null
private settingContext: boolean = false
private _debug: boolean = false
private printedSessionState = {
printed: false,
@@ -33,71 +39,230 @@ export class SessionManager {
this._debug = value
}
async getSession(accessToken?: string) {
await this.createSessions(accessToken)
await this.createAndWaitForSession(accessToken)
const session = this.sessions.pop()
/**
* Checks if session is valid. Session is considered valid if time since it's creation is less than 'sessionInactiveTimeout' attribute.
* @param session - session object.
* @returns - boolean indicating if session is valid.
*/
private isSessionValid(session: Session): boolean {
if (!session) return false
const secondsSinceSessionCreation =
(new Date().getTime() - new Date(session!.creationTimeStamp).getTime()) /
(new Date().getTime() - new Date(session.creationTimeStamp).getTime()) /
1000
if (
!session!.attributes ||
secondsSinceSessionCreation >= session!.attributes.sessionInactiveTimeout
) {
await this.createSessions(accessToken)
const freshSession = this.sessions.pop()
return freshSession
return false
} else {
return true
}
return session
}
async clearSession(id: string, accessToken?: string) {
/**
* Removes session from pool of hot sessions.
* @param session - session object.
* @returns - void.
*/
private removeSessionFromPool(session: Session): void {
this.sessions = this.sessions.filter((ses) => ses.id !== session.id)
}
/**
* Filters session pool to keep only valid sessions.
* @param session - session object.
* @returns - void.
*/
private removeExpiredSessions(): void {
this.sessions = this.sessions.filter((session) =>
this.isSessionValid(session)
)
}
/**
* Throws set of errors as a single error.
* @param errors - array of errors or string.
* @param prefix - an optional final error prefix.
* @returns - never.
*/
private throwErrors(errors: (Error | string)[], prefix?: string): never {
throw prefix
? prefixMessage(new Error(errors.join('. ')), prefix)
: new Error(
errors
.map((err) =>
(err as Error).message ? (err as Error).message : err
)
.join('. ')
)
}
/**
* Returns session.
* If there is a hot session available, it will be returned immediately and an asynchronous request to create new hot session will be submitted.
* If there is no available session, 2 session creation requests will be submitted. The session is returned once it is created and ready.
* @param accessToken - an optional access token.
* @returns - a promise which resolves with a session.
*/
async getSession(accessToken?: string) {
const errors: (Error | string)[] = []
let isErrorThrown = false
const throwIfError = () => {
if (errors.length && !isErrorThrown) {
isErrorThrown = true
this.throwErrors(errors)
}
}
this.removeExpiredSessions()
if (this.sessions.length) {
const session = this.sessions[0]
this.removeSessionFromPool(session)
this.createSessions(accessToken).catch((err) => {
errors.push(err)
})
this.createAndWaitForSession(accessToken).catch((err) => {
errors.push(err)
})
throwIfError()
return session
} else {
this.createSessions(accessToken).catch((err) => {
errors.push(err)
})
await this.createAndWaitForSession(accessToken).catch((err) => {
errors.push(err)
})
this.removeExpiredSessions()
const session = this.sessions.pop()!
this.removeSessionFromPool(session)
throwIfError()
return session
}
}
/**
* Returns error message based on the response from SAS API.
* @param err - an optional access token.
* @param accessToken - an optional access token.
* @returns - an error message.
*/
private getErrorMessage(
err: ApiErrorResponse,
url: string,
method: 'GET' | 'POST' | 'DELETE'
) {
return (
`${method} request to ${url} failed with status code ${
err.response.status || 'unknown'
}. ` + err.response.data.message || ''
)
}
/**
* Deletes session.
* @param id - a session id.
* @param accessToken - an optional access token.
* @returns - a promise which resolves when session is deleted.
*/
async clearSession(id: string, accessToken?: string): Promise<void> {
const url = `/compute/sessions/${id}`
return await this.requestClient
.delete<Session>(`/compute/sessions/${id}`, accessToken)
.delete<Session>(url, accessToken)
.then(() => {
this.sessions = this.sessions.filter((s) => s.id !== id)
})
.catch((err) => {
throw prefixMessage(err, 'Error while deleting session. ')
.catch((err: ApiErrorResponse) => {
throw prefixMessage(
this.getErrorMessage(err, url, 'DELETE'),
'Error while deleting session. '
)
})
}
private async createSessions(accessToken?: string) {
/**
* Creates sessions in amount equal to MAX_SESSION_COUNT.
* @param accessToken - an optional access token.
* @returns - a promise which resolves when required amount of sessions is created.
*/
private async createSessions(accessToken?: string): Promise<void> {
const errors: (Error | string)[] = []
if (!this.sessions.length) {
if (!this.currentContext) {
await this.setCurrentContext(accessToken).catch((err) => {
throw err
})
}
await asyncForEach(new Array(MAX_SESSION_COUNT), async () => {
const createdSession = await this.createAndWaitForSession(
accessToken
).catch((err) => {
throw err
await this.createAndWaitForSession(accessToken).catch((err) => {
errors.push(err)
})
this.sessions.push(createdSession)
}).catch((err) => {
throw err
})
}
if (errors.length) {
this.throwErrors(errors, 'Error while creating session. ')
}
}
private async createAndWaitForSession(accessToken?: string) {
/**
* Waits for the current context to be set.
* @returns - a promise which resolves when current context is set.
*/
private async waitForCurrentContext(): Promise<void> {
return new Promise((resolve) => {
const timer = setInterval(() => {
if (this.currentContext) {
this.settingContext = false
clearInterval(timer)
resolve()
}
}, 100)
})
}
/**
* Creates and waits for session to be ready.
* @param accessToken - an optional access token.
* @returns - a promise which resolves with a session.
*/
private async createAndWaitForSession(
accessToken?: string
): Promise<Session> {
if (!this.currentContext) {
if (!this.settingContext) {
await this.setCurrentContext(accessToken)
} else {
await this.waitForCurrentContext()
}
}
const url = `${this.serverUrl}/compute/contexts/${
this.currentContext!.id
}/sessions`
const { result: createdSession, etag } = await this.requestClient
.post<Session>(
`${this.serverUrl}/compute/contexts/${
this.currentContext!.id
}/sessions`,
{},
accessToken
)
.catch((err) => {
throw err
.post<Session>(url, {}, accessToken)
.catch((err: ApiErrorResponse) => {
throw prefixMessage(
this.getErrorMessage(err, url, 'POST'),
`Error while creating session. `
)
})
await this.waitForSession(createdSession, etag, accessToken)
@@ -107,14 +272,26 @@ export class SessionManager {
return createdSession
}
private async setCurrentContext(accessToken?: string) {
/**
* Sets current context.
* @param accessToken - an optional access token.
* @returns - a promise which resolves when current context is set.
*/
private async setCurrentContext(accessToken?: string): Promise<void> {
if (!this.currentContext) {
const url = `${this.serverUrl}/compute/contexts?limit=10000`
this.settingContext = true
const { result: contexts } = await this.requestClient
.get<{
items: Context[]
}>(`${this.serverUrl}/compute/contexts?limit=10000`, accessToken)
.catch((err) => {
throw err
}>(url, accessToken)
.catch((err: ApiErrorResponse) => {
throw prefixMessage(
this.getErrorMessage(err, url, 'GET'),
`Error while getting list of contexts. `
)
})
const contextsList =
@@ -138,18 +315,13 @@ export class SessionManager {
}
}
private getHeaders(accessToken?: string) {
const headers: any = {
'Content-Type': 'application/json'
}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
return headers
}
/**
* Waits for session to be ready.
* @param session - a session object.
* @param etag - an etag that can be a string or null.
* @param accessToken - an optional access token.
* @returns - a promise which resolves with a session state.
*/
private async waitForSession(
session: Session,
etag: string | null,
@@ -173,13 +345,11 @@ export class SessionManager {
this.printedSessionState.printed = true
}
const url = `${this.serverUrl}${stateLink.href}?wait=30`
const { result: state, responseStatus: responseStatus } =
await this.getSessionState(
`${this.serverUrl}${stateLink.href}?wait=30`,
etag!,
accessToken
).catch((err) => {
throw prefixMessage(err, 'Error while getting session state.')
await this.getSessionState(url, etag!, accessToken).catch((err) => {
throw prefixMessage(err, 'Error while waiting for session. ')
})
sessionState = state.trim()
@@ -216,7 +386,7 @@ export class SessionManager {
return sessionState
} else {
throw 'Error while getting session state link.'
throw 'Error while getting session state link. '
}
} else {
this.loggedErrors = []
@@ -225,11 +395,21 @@ export class SessionManager {
}
}
/**
* Gets session state.
* @param url - a URL to get session state.
* @param etag - an etag string.
* @param accessToken - an optional access token.
* @returns - a promise which resolves with a result string and response status.
*/
private async getSessionState(
url: string,
etag: string,
accessToken?: string
) {
): Promise<{
result: string
responseStatus: number
}> {
return await this.requestClient
.get(url, accessToken, 'text/plain', { 'If-None-Match': etag })
.then((res) => ({
@@ -237,20 +417,37 @@ export class SessionManager {
responseStatus: res.status
}))
.catch((err) => {
throw err
throw prefixMessage(
this.getErrorMessage(err, url, 'GET'),
'Error while getting session state. '
)
})
}
async getVariable(sessionId: string, variable: string, accessToken?: string) {
/**
* Gets variable.
* @param sessionId - a session id.
* @param variable - a variable string.
* @param accessToken - an optional access token.
* @returns - a promise which resolves with a result that confirms to SessionVariable interface, etag string and status code.
*/
async getVariable(
sessionId: string,
variable: string,
accessToken?: string
): Promise<{
result: SessionVariable
etag: string
status: number
}> {
const url = `${this.serverUrl}/compute/sessions/${sessionId}/variables/${variable}`
return await this.requestClient
.get<SessionVariable>(
`${this.serverUrl}/compute/sessions/${sessionId}/variables/${variable}`,
accessToken
)
.get<SessionVariable>(url, accessToken)
.catch((err) => {
throw prefixMessage(
err,
`Error while fetching session variable '${variable}'.`
this.getErrorMessage(err, url, 'GET'),
`Error while fetching session variable '${variable}'. `
)
})
}

View File

@@ -10,6 +10,7 @@ import { refreshTokensForSasjs } from './refreshTokensForSasjs'
/**
* Returns the auth configuration, refreshing the tokens if necessary.
* This function can only be used by Node, if a server type is SASVIYA.
* @param requestClient - the pre-configured HTTP request client
* @param authConfig - an object containing a client ID, secret, access token and refresh token
* @param serverType - server type for which refreshing the tokens, defaults to SASVIYA
@@ -29,9 +30,12 @@ export async function getTokens(
const error =
'Unable to obtain new access token. Your refresh token has expired.'
logger.error(error)
throw new Error(error)
}
logger.info('Refreshing access and refresh tokens.')
const tokens =
serverType === ServerType.SasViya
? await refreshTokensForViya(

View File

@@ -2,9 +2,11 @@ import { SasAuthResponse } from '@sasjs/utils/types'
import { prefixMessage } from '@sasjs/utils/error'
import * as NodeFormData from 'form-data'
import { RequestClient } from '../request/RequestClient'
import { isNode } from '../utils'
/**
* Exchanges the refresh token for an access token for the given client.
* This function can only be used by Node.
* @param requestClient - the pre-configured HTTP request client
* @param clientId - the client ID to authenticate with.
* @param clientSecret - the client secret to authenticate with.
@@ -16,9 +18,12 @@ export async function refreshTokensForViya(
clientSecret: string,
refreshToken: string
) {
if (!isNode()) {
throw new Error(`Method 'refreshTokensForViya' can only be used by Node.`)
}
const url = '/SASLogon/oauth/token'
let token
token =
const token =
typeof Buffer === 'undefined'
? btoa(clientId + ':' + clientSecret)
: Buffer.from(clientId + ':' + clientSecret).toString('base64')
@@ -27,8 +32,7 @@ export async function refreshTokensForViya(
Authorization: 'Basic ' + token
}
const formData =
typeof FormData === 'undefined' ? new NodeFormData() : new FormData()
const formData = new NodeFormData()
formData.append('grant_type', 'refresh_token')
formData.append('refresh_token', refreshToken)

View File

@@ -3,6 +3,7 @@ import * as NodeFormData from 'form-data'
import { generateToken, mockAuthResponse } from './mockResponses'
import { RequestClient } from '../../request/RequestClient'
import { refreshTokensForViya } from '../refreshTokensForViya'
import * as IsNodeModule from '../../utils/isNode'
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
@@ -70,6 +71,18 @@ describe('refreshTokensForViya', () => {
expect(error).toEqual(`Error while refreshing tokens: ${tokenError}`)
})
it('should throw an error if environment is not Node', async () => {
jest.spyOn(IsNodeModule, 'isNode').mockImplementation(() => false)
const expectedError = new Error(
`Method 'refreshTokensForViya' can only be used by Node.`
)
expect(
refreshTokensForViya(requestClient, 'client', 'secret', 'token')
).rejects.toEqual(expectedError)
})
})
const setupMocks = () => {

View File

@@ -13,7 +13,11 @@ import { generateFileUploadForm } from '../file/generateFileUploadForm'
import { RequestClient } from '../request/RequestClient'
import { isRelativePath, appendExtraResponseAttributes } from '../utils'
import {
isRelativePath,
appendExtraResponseAttributes,
getValidJson
} from '../utils'
import { BaseJobExecutor } from './JobExecutor'
export class SasjsJobExecutor extends BaseJobExecutor {
@@ -89,12 +93,18 @@ export class SasjsJobExecutor extends BaseJobExecutor {
)
}
const { result } = res
if (result && typeof result === 'string' && result.trim())
res.result = getValidJson(result)
this.requestClient!.appendRequest(res, sasJob, config.debug)
const responseObject = appendExtraResponseAttributes(
res,
extraResponseAttributes
)
resolve(responseObject)
})
.catch(async (e: Error) => {

View File

@@ -1,6 +1,14 @@
import { RequestClient } from './RequestClient'
import { AxiosResponse } from 'axios'
import { SASJS_LOGS_SEPARATOR, getValidJson } from '../utils'
import { SASJS_LOGS_SEPARATOR } from '../utils'
interface SasjsParsedResponse<T> {
result: T
log: string
etag: string
status: number
printOutput?: string
}
/**
* Specific request client for SASJS.
@@ -27,7 +35,7 @@ export class SasjsRequestClient extends RequestClient {
protected parseResponse<T>(response: AxiosResponse<any>) {
const etag = response?.headers ? response.headers['etag'] : ''
let parsedResponse = {}
let log
let webout, log, printOutput
try {
if (typeof response.data === 'string') {
@@ -38,17 +46,26 @@ export class SasjsRequestClient extends RequestClient {
} catch {
if (response.data.includes(SASJS_LOGS_SEPARATOR)) {
const splittedResponse = response.data.split(SASJS_LOGS_SEPARATOR)
webout = splittedResponse[0]
if (webout !== undefined) parsedResponse = webout
log = splittedResponse[1]
if (splittedResponse[0].trim())
parsedResponse = getValidJson(splittedResponse[0])
} else parsedResponse = response.data
printOutput = splittedResponse[2]
} else {
parsedResponse = response.data
}
}
return {
const returnResult: SasjsParsedResponse<T> = {
result: parsedResponse as T,
log,
etag,
status: response.status
}
if (printOutput) returnResult.printOutput = printOutput
return returnResult
}
}

View File

@@ -3,10 +3,11 @@ import { RequestClient } from '../request/RequestClient'
import * as dotenv from 'dotenv'
import axios from 'axios'
import { Logger, LogLevel } from '@sasjs/utils'
import { Session } from '../types'
import { Session, Context } from '../types'
jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios>
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
describe('SessionManager', () => {
dotenv.config()
@@ -14,9 +15,23 @@ describe('SessionManager', () => {
const sessionManager = new SessionManager(
process.env.SERVER_URL as string,
process.env.DEFAULT_COMPUTE_CONTEXT as string,
new RequestClient('https://sample.server.com')
requestClient
)
const getMockSession = () => ({
id: ['id', new Date().getTime(), Math.random()].join('-'),
state: '',
links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }],
attributes: {
sessionInactiveTimeout: 900
},
creationTimeStamp: `${new Date(new Date().getTime()).toISOString()}`
})
afterEach(() => {
jest.restoreAllMocks()
})
describe('getVariable', () => {
it('should fetch session variable', async () => {
const sampleResponse = {
@@ -45,6 +60,30 @@ describe('SessionManager', () => {
)
).resolves.toEqual(expectedResponse)
})
it('should throw an error if GET request failed', async () => {
const responseStatus = 500
const responseErrorMessage = `The process timed out after 60 seconds. Request failed with status code ${responseStatus}`
const response = {
status: responseStatus,
data: {
message: responseErrorMessage
}
}
const testVariable = 'testVariable'
jest.spyOn(requestClient, 'get').mockImplementation(() =>
Promise.reject({
response
})
)
const expectedError = `Error while fetching session variable '${testVariable}'. GET request to ${process.env.SERVER_URL}/compute/sessions/testId/variables/${testVariable} failed with status code ${responseStatus}. ${responseErrorMessage}`
await expect(
sessionManager.getVariable('testId', testVariable)
).rejects.toEqual(expectedError)
})
})
describe('waitForSession', () => {
@@ -115,11 +154,25 @@ describe('SessionManager', () => {
})
it('should throw an error if could not get session state', async () => {
mockedAxios.get.mockImplementation(() => Promise.reject('Mocked error'))
const gettingSessionStatus = 500
const sessionStatusError = `Getting session status timed out after 60 seconds. Request failed with status code ${gettingSessionStatus}`
mockedAxios.get.mockImplementation(() =>
Promise.reject({
response: {
status: gettingSessionStatus,
data: {
message: sessionStatusError
}
}
})
)
const expectedError = `Error while waiting for session. Error while getting session state. GET request to ${process.env.SERVER_URL}?wait=30 failed with status code ${gettingSessionStatus}. ${sessionStatusError}`
await expect(
sessionManager['waitForSession'](session, null, 'access_token')
).rejects.toContain('Error while getting session state.')
).rejects.toEqual(expectedError)
})
it('should return session state', async () => {
@@ -135,4 +188,243 @@ describe('SessionManager', () => {
).resolves.toEqual(customSession.state)
})
})
describe('isSessionValid', () => {
const session: Session = getMockSession()
it('should return false if not a session provided', () => {
expect(sessionManager['isSessionValid'](undefined as any)).toEqual(false)
})
it('should return true if session is not expired', () => {
expect(sessionManager['isSessionValid'](session)).toEqual(true)
})
it('should return false if session is expired', () => {
session.creationTimeStamp = `${new Date(
new Date().getTime() -
(session.attributes.sessionInactiveTimeout * 1000 + 1000)
).toISOString()}`
expect(sessionManager['isSessionValid'](session)).toEqual(false)
})
})
describe('removeSessionFromPool', () => {
it('should remove session from the pool of sessions', () => {
const session: Session = getMockSession()
const sessions: Session[] = [getMockSession(), session]
sessionManager['sessions'] = sessions
sessionManager['removeSessionFromPool'](session)
expect(sessionManager['sessions'].length).toEqual(1)
})
})
describe('getSession', () => {
it('should return session if there is a valid session and create new session', async () => {
jest
.spyOn(sessionManager as any, 'createAndWaitForSession')
.mockImplementation(async () => Promise.resolve(getMockSession()))
const session = getMockSession()
sessionManager['sessions'] = [session]
await expect(sessionManager.getSession()).resolves.toEqual(session)
expect(sessionManager['createAndWaitForSession']).toHaveBeenCalled()
})
it('should return a session and keep one session if there is no sessions available', async () => {
jest
.spyOn(sessionManager as any, 'createAndWaitForSession')
.mockImplementation(async () => {
const session = getMockSession()
sessionManager['sessions'].push(session)
return Promise.resolve(session)
})
const session = await sessionManager.getSession()
expect(Object.keys(session)).toEqual(Object.keys(getMockSession()))
expect(sessionManager['createAndWaitForSession']).toHaveBeenCalledTimes(2)
expect(sessionManager['sessions'].length).toEqual(1)
})
it.concurrent(
'should throw an error if session creation request returned 500',
async () => {
const sessionCreationStatus = 500
const sessionCreationError = `The process initialization for the Compute server with the ID 'ed40398a-ec8a-422b-867a-61493ee8a57f' timed out after 60 seconds. Request failed with status code ${sessionCreationStatus}`
jest.spyOn(requestClient, 'post').mockImplementation(() =>
Promise.reject({
response: {
status: sessionCreationStatus,
data: {
message: sessionCreationError
}
}
})
)
const contextId = 'testContextId'
const context: Context = {
name: 'testContext',
id: contextId,
createdBy: 'createdBy',
version: 1
}
sessionManager['currentContext'] = context
const expectedError = new Error(
`Error while creating session. POST request to ${process.env.SERVER_URL}/compute/contexts/${contextId}/sessions failed with status code ${sessionCreationStatus}. ${sessionCreationError}`
)
await expect(sessionManager.getSession()).rejects.toEqual(expectedError)
}
)
})
describe('clearSession', () => {
it('should clear session', async () => {
jest
.spyOn(requestClient, 'delete')
.mockImplementation(() =>
Promise.resolve({ result: '', etag: '', status: 200 })
)
const sessionToBeCleared = getMockSession()
const sessionToStay = getMockSession()
sessionManager['sessions'] = [sessionToBeCleared, sessionToStay]
await sessionManager.clearSession(sessionToBeCleared.id)
expect(sessionManager['sessions']).toEqual([sessionToStay])
})
it('should throw error if DELETE request failed', async () => {
const sessionCreationStatus = 500
const sessionDeleteError = `The process timed out after 60 seconds. Request failed with status code ${sessionCreationStatus}`
jest.spyOn(requestClient, 'delete').mockImplementation(() =>
Promise.reject({
response: {
status: sessionCreationStatus,
data: {
message: sessionDeleteError
}
}
})
)
const session = getMockSession()
sessionManager['sessions'] = [session]
const expectedError = `Error while deleting session. DELETE request to /compute/sessions/${session.id} failed with status code ${sessionCreationStatus}. ${sessionDeleteError}`
await expect(sessionManager.clearSession(session.id)).rejects.toEqual(
expectedError
)
})
})
describe('waitForCurrentContext', () => {
it('should resolve when current context is ready', async () => {
sessionManager['settingContext'] = true
sessionManager['contextName'] = 'test context'
await expect(sessionManager['waitForCurrentContext']()).toResolve()
expect(sessionManager['settingContext']).toEqual(false)
})
})
describe('setCurrentContext', () => {
it('should set current context', async () => {
const contextName = 'test context'
const testContext: Context = {
name: contextName,
id: 'string',
createdBy: 'string',
version: 1
}
jest.spyOn(requestClient, 'get').mockImplementation(() => {
return Promise.resolve({
result: {
items: [testContext]
},
etag: '',
status: 200
})
})
sessionManager['currentContext'] = null
sessionManager['contextName'] = contextName
sessionManager['settingContext'] = false
await expect(sessionManager['setCurrentContext']()).toResolve()
expect(sessionManager['currentContext']).toEqual(testContext)
})
it('should throw error if GET request failed', async () => {
const responseStatus = 500
const responseErrorMessage = `The process timed out after 60 seconds. Request failed with status code ${responseStatus}`
const response = {
status: responseStatus,
data: {
message: responseErrorMessage
}
}
jest.spyOn(requestClient, 'get').mockImplementation(() =>
Promise.reject({
response
})
)
const expectedError = `Error while getting list of contexts. GET request to ${process.env.SERVER_URL}/compute/contexts?limit=10000 failed with status code ${responseStatus}. ${responseErrorMessage}`
sessionManager['currentContext'] = null
await expect(sessionManager['setCurrentContext']()).rejects.toEqual(
expectedError
)
})
it('should throw an error if current context is not in the list of contexts', async () => {
const contextName = 'test context'
const testContext: Context = {
name: `${contextName} does not exist`,
id: 'string',
createdBy: 'string',
version: 1
}
jest.spyOn(requestClient, 'get').mockImplementation(() => {
return Promise.resolve({
result: {
items: [testContext]
},
etag: '',
status: 200
})
})
sessionManager['currentContext'] = null
sessionManager['contextName'] = contextName
sessionManager['settingContext'] = false
const expectedError = new Error(
`The context '${contextName}' was not found on the server ${process.env.SERVER_URL}.`
)
await expect(sessionManager['setCurrentContext']()).rejects.toEqual(
expectedError
)
})
})
})

View File

@@ -17,6 +17,7 @@ export const getValidJson = (str: string | object): object => {
return JSON.parse(str)
} catch (e: any) {
if (e instanceof JsonParseArrayError) throw e
throw new InvalidJsonError()
}
}

View File

@@ -86,7 +86,8 @@ const nodeConfig = {
entry: './node/index.ts',
output: {
...browserConfig.output,
path: path.resolve(__dirname, 'build', 'node')
path: path.resolve(__dirname, 'build', 'node'),
filename: 'index.js'
}
}