1
0
mirror of https://github.com/sasjs/adapter.git synced 2026-01-05 03:30:05 +00:00

Compare commits

..

26 Commits

Author SHA1 Message Date
Yury Shkoda
76bbb8acf2 chore(sasjsTests): debugging 2023-05-29 18:11:25 +03:00
Yury Shkoda
1ead483921 chore(sasjsTests): debugging 2023-05-29 18:00:57 +03:00
Yury Shkoda
e2cb787f89 chore(sasjs-tests): trying to run only sendArrTests 2023-05-29 17:40:40 +03:00
Yury Shkoda
828aef1873 chore(sasjs-tests): debugging 2023-05-29 17:33:01 +03:00
Yury Shkoda
f6ee1111c5 chore(cypress): debugging 2023-05-29 17:19:08 +03:00
Yury Shkoda
5f8750a8b6 chore(cypress): debugging 2023-05-29 16:54:21 +03:00
Yury Shkoda
d027acacb6 chore(cypress): debugging 2023-05-29 16:45:32 +03:00
Yury Shkoda
3660b9127a chore(sasjsTests): debugging 2023-05-29 16:39:31 +03:00
Yury Shkoda
5ac0f12435 chore(sasjsTests): debugging 2023-05-29 16:28:59 +03:00
Yury Shkoda
ee0c4b007b chore(sasjsTests): decreased defaultCommandTimeout for cypress 2023-05-29 15:41:15 +03:00
Yury Shkoda
db4a4e6d57 chore(sasjsTests): trying @sasjs/adapter@4.3.5 2023-05-29 15:23:38 +03:00
Yury Shkoda
eba30432dd chore(sasjsTests): debugging 2023-05-29 15:07:35 +03:00
Yury Shkoda
6b9cb3af5f chore(sasjsTests): added sleep step 2023-05-29 14:52:06 +03:00
Yury Shkoda
afe612925e chore(sasjsTests): debugging 2023-05-29 14:40:26 +03:00
Yury Shkoda
1f9bed0625 chore(sasjsTest): debugging 2023-05-29 14:27:49 +03:00
Yury Shkoda
51fdea46fc chore(sasjs-tests): debugging 2023-05-29 14:07:28 +03:00
Yury Shkoda
007b00565c chore(sasjsTests): removed pm2 log 2023-05-29 13:54:28 +03:00
Yury Shkoda
38eef00216 chore(sasjsTests): using different user 2023-05-29 13:46:53 +03:00
Yury Shkoda
f1c67432bf chore(sasjs-tests): debugging 2023-05-26 12:21:27 +03:00
Yury Shkoda
3041a0f4b1 chore(sasjs-tests): debugging 2023-05-26 12:07:42 +03:00
Yury Shkoda
6a5529f3f0 chore: debugging 2023-05-26 11:07:31 +03:00
Yury Shkoda
7758b78a88 chore: debugging sasjs 2023-05-26 10:11:02 +03:00
Yury Shkoda
09c1038cbd chore: debugging sasjs 2023-05-26 10:02:07 +03:00
Yury Shkoda
87e2449b6f chore: debugging sasjs 2023-05-26 09:52:37 +03:00
Yury Shkoda
c6b927c525 test: updated unit tests related to tokens operations 2023-05-25 10:35:10 +03:00
Yury Shkoda
4b6445d524 feat: improved error message for requests related to tokens operations 2023-05-25 10:27:54 +03:00
104 changed files with 41439 additions and 23886 deletions

View File

@@ -14,7 +14,7 @@ What code changes have been made to achieve the intent.
No PR (that involves a non-trivial code change) should be merged, unless all items below are confirmed! If an urgent fix is needed - use a tar file. No PR (that involves a non-trivial code change) should be merged, unless all items below are confirmed! If an urgent fix is needed - use a tar file.
- [ ] Unit tests coverage has been increased and a new threshold is set.
- [ ] All `sasjs-cli` unit tests are passing (`npm test`). - [ ] All `sasjs-cli` unit tests are passing (`npm test`).
- (CI Runs this) All `sasjs-tests` are passing. If you want to run it manually (instructions available [here](https://github.com/sasjs/adapter/blob/master/sasjs-tests/README.md)). - (CI Runs this) All `sasjs-tests` are passing. If you want to run it manually (instructions available [here](https://github.com/sasjs/adapter/blob/master/sasjs-tests/README.md)).
- [ ] [Data Controller](https://datacontroller.io) builds and is functional on both SAS 9 and Viya - [ ] [Data Controller](https://datacontroller.io) builds and is functional on both SAS 9 and Viya

View File

@@ -5,3 +5,7 @@ groups:
- YuryShkoda - YuryShkoda
- medjedovicm - medjedovicm
- sabhas - sabhas
- name: SASjs QA
reviewers: 1
usernames:
- VladislavParhomchik

View File

@@ -3,12 +3,10 @@ client
tls-client tls-client
dev tun dev tun
# this will connect with whatever proto DNS tells us (https://community.openvpn.net/openvpn/ticket/934) # this will connect with whatever proto DNS tells us (https://community.openvpn.net/openvpn/ticket/934)
proto udp proto tcp
remote vpn.4gl.io 7194 remote vpn.4gl.io 7494
resolv-retry infinite resolv-retry infinite
# this will fallback from udp6 to udp4 as well cipher AES-256-CBC
connect-timeout 5
data-ciphers AES-256-CBC:AES-256-GCM
auth SHA256 auth SHA256
script-security 2 script-security 2
keepalive 10 120 keepalive 10 120

View File

@@ -10,4 +10,4 @@ jobs:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: uesteibar/reviewer-lottery@v1 - uses: uesteibar/reviewer-lottery@v1
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GH_TOKEN }}

View File

@@ -1,58 +0,0 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: SASjs Build and Unit Test
on:
pull_request:
jobs:
build:
runs-on: ubuntu-22.04
strategy:
matrix:
node-version: [lts/hydrogen]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
# 2. Restore npm cache manually
- name: Restore npm cache
uses: actions/cache@v3
id: npm-cache
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Check npm audit
run: npm audit --production --audit-level=low
- name: Install Dependencies
run: npm ci
- name: Install Rimraf
run: npm i rimraf
- name: Check code style
run: npm run lint
- name: Run unit tests
run: npm test
- name: Build Package
run: npm run package:lib
env:
CI: true
# For some reason if coverage report action is run before other commands, those commands can't access the directories and files on which they depend on
- name: Generate coverage report
uses: artiomtr/jest-coverage-report-action@v2.0-rc.2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,18 +1,18 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: SASjs Build and Server Tests name: SASjs Build
on: on:
pull_request: pull_request:
jobs: jobs:
test: build:
runs-on: ubuntu-22.04 runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
node-version: [lts/hydrogen] node-version: [lts/fermium]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
@@ -20,22 +20,19 @@ jobs:
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: npm
# 2. Restore npm cache manually # - name: Check npm audit
- name: Restore npm cache # run: npm audit --production --audit-level=low
uses: actions/cache@v3
id: npm-cache
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install Dependencies - name: Install Dependencies
run: npm ci run: npm ci
- name: Install Rimraf # - name: Check code style
run: npm i rimraf # run: npm run lint
# - name: Run unit tests
# run: npm test
- name: Build Package - name: Build Package
run: npm run package:lib run: npm run package:lib
@@ -55,10 +52,6 @@ jobs:
USER_KEY: ${{ secrets.USER_KEY }} USER_KEY: ${{ secrets.USER_KEY }}
TLS_KEY: ${{ secrets.TLS_KEY }} TLS_KEY: ${{ secrets.TLS_KEY }}
- name: Chmod VPN files
run: |
chmod 600 .github/vpn/ca.crt .github/vpn/user.crt .github/vpn/user.key .github/vpn/tls.key
- name: Install Open VPN - name: Install Open VPN
run: | run: |
sudo apt install apt-transport-https sudo apt install apt-transport-https
@@ -74,34 +67,36 @@ jobs:
- name: install pm2 - name: install pm2
run: npm i -g pm2 run: npm i -g pm2
- name: Fetch SASJS server
run: curl ${{ secrets.SASJS_SERVER_URL }}/SASjsApi/info
- name: Deploy sasjs-tests - name: Deploy sasjs-tests
run: | run: |
npm install -g replace-in-files-cli npm install -g replace-in-files-cli
cd sasjs-tests cd sasjs-tests
replace-in-files --regex='"@sasjs/adapter".*' --replacement='"@sasjs/adapter":"latest",' ./package.json replace-in-files --regex='"@sasjs/adapter".*' --replacement='"@sasjs/adapter":"latest",' ./package.json
npm i npm i --legacy-peer-deps
replace-in-files --regex='"serverUrl".*' --replacement='"serverUrl":"${{ secrets.SASJS_SERVER_URL }}",' ./public/config.json replace-in-files --regex='"serverUrl".*' --replacement='"serverUrl":"${{ secrets.SASJS_SERVER_URL }}",' ./public/config.json
replace-in-files --regex='"userName".*' --replacement='"userName":"${{ secrets.SASJS_USERNAME }}",' ./public/config.json replace-in-files --regex='"userName".*' --replacement='"userName":"${{ secrets.SASJS_USERNAME_DEV }}",' ./public/config.json
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD_DEV }}",' ./public/config.json
replace-in-files --regex='"serverType".*' --replacement='"serverType":"SASJS",' ./public/config.json replace-in-files --regex='"serverType".*' --replacement='"serverType":"SASJS",' ./public/config.json
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./public/config.json # npm run update:adapter
cat ./public/config.json
npm run update:adapter
pm2 start --name sasjs-test npm -- start pm2 start --name sasjs-test npm -- start
cat ./public/config.json
cat ../cypress.json
- name: Sleep for 10 seconds - name: Sleep for 10 seconds
run: sleep 10s uses: jakejarvis/wait-action@master
shell: bash with:
time: '10s'
- name: Run cypress on sasjs - name: Run cypress on sasjs
run: | run: |
ss -lntu
replace-in-files --regex='"sasjsTestsUrl".*' --replacement='"sasjsTestsUrl":"http://localhost:3000",' ./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='"username".*' --replacement='"username":"${{ secrets.SASJS_USERNAME_DEV }}",' ./cypress.json
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./cypress.json replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD_DEV }}",' ./cypress.json
cat ./cypress.json
echo "SASJS_USERNAME=${{ secrets.SASJS_USERNAME }}"
sh ./sasjs-tests/sasjs-cypress-run.sh ${{ secrets.MATRIX_TOKEN }} https://github.com/${{github.repository}}/actions/runs/${{github.run_id}} sh ./sasjs-tests/sasjs-cypress-run.sh ${{ secrets.MATRIX_TOKEN }} https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}
# For some reason if coverage report action is run before other commands, those commands can't access the directories and files on which they depend on
- name: Generate coverage report
uses: artiomtr/jest-coverage-report-action@v2.0-rc.2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -11,7 +11,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node-version: [lts/hydrogen] node-version: [lts/fermium]
steps: steps:
- name: Checkout - name: Checkout
@@ -21,16 +21,7 @@ jobs:
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: npm
# 2. Restore npm cache manually
- name: Restore npm cache
uses: actions/cache@v3
id: npm-cache
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install Dependencies - name: Install Dependencies
run: npm ci run: npm ci
@@ -46,7 +37,7 @@ jobs:
- name: Push generated docs - name: Push generated docs
uses: peaceiris/actions-gh-pages@v3 uses: peaceiris/actions-gh-pages@v3
with: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GH_TOKEN }}
publish_branch: gh-pages publish_branch: gh-pages
publish_dir: ./docs publish_dir: ./docs
cname: adapter.sasjs.io cname: adapter.sasjs.io

View File

@@ -14,7 +14,7 @@ jobs:
strategy: strategy:
matrix: matrix:
node-version: [lts/hydrogen] node-version: [lts/fermium]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
@@ -22,16 +22,7 @@ jobs:
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: npm
# 2. Restore npm cache manually
- name: Restore npm cache
uses: actions/cache@v3
id: npm-cache
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install Dependencies - name: Install Dependencies
run: npm ci run: npm ci
@@ -45,7 +36,7 @@ jobs:
- name: Semantic Release - name: Semantic Release
uses: cycjimmy/semantic-release-action@v3 uses: cycjimmy/semantic-release-action@v3
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Send Matrix message - name: Send Matrix message

View File

@@ -1,3 +0,0 @@
{
"cSpell.words": ["SASVIYA"]
}

View File

@@ -153,10 +153,6 @@ 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. 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.
### Verbose Mode
Set `verbose` to `true` to enable verbose mode that logs a summary of every HTTP response. Verbose mode can be disabled by calling `disableVerboseMode` method or enabled by `enableVerboseMode` method. Verbose mode also supports `bleached` mode that disables extra colors in req/res summary. To enable `bleached` verbose mode, pass `verbose` equal to `bleached` while instantiating an instance of `RequestClient` or to `setVerboseMode` method. Verbose mode can also be enabled/disabled by `startComputeJob` method.
### Session Manager ### 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: 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:
@@ -277,7 +273,6 @@ Configuration on the client side involves passing an object on startup, which ca
* `serverType` - either `SAS9`, `SASVIYA` or `SASJS`. The `SASJS` server type is for use with [sasjs/server](https://github.com/sasjs/server). * `serverType` - either `SAS9`, `SASVIYA` or `SASJS`. The `SASJS` server type is for use with [sasjs/server](https://github.com/sasjs/server).
* `serverUrl` - the location (including http protocol and port) of the SAS Server. Can be omitted, eg if serving directly from the SAS Web Server, or in streaming mode. * `serverUrl` - the location (including http protocol and port) of the SAS Server. Can be omitted, eg if serving directly from the SAS Web Server, or in streaming mode.
* `debug` - if `true` then SAS Logs and extra debug information is returned. * `debug` - if `true` then SAS Logs and extra debug information is returned.
* `verbose` - optional, if `true` then a summary of every HTTP response is logged.
* `loginMechanism` - either `Default` or `Redirected`. See [SAS Logon](#sas-logon) section. * `loginMechanism` - either `Default` or `Redirected`. See [SAS Logon](#sas-logon) section.
* `useComputeApi` - Only relevant when the serverType is `SASVIYA`. If `true` the [Compute API](#using-the-compute-api) is used. If `false` the [JES API](#using-the-jes-api) is used. If `null` or `undefined` the [Web](#using-jes-web-app) approach is used. * `useComputeApi` - Only relevant when the serverType is `SASVIYA`. If `true` the [Compute API](#using-the-compute-api) is used. If `false` the [JES API](#using-the-jes-api) is used. If `null` or `undefined` the [Web](#using-jes-web-app) approach is used.
* `contextName` - Compute context on which the requests will be called. If missing or not provided, defaults to `Job Execution Compute context`. * `contextName` - Compute context on which the requests will be called. If missing or not provided, defaults to `Job Execution Compute context`.

View File

@@ -4,56 +4,127 @@ const password = Cypress.env('password')
const testingFinishTimeout = Cypress.env('testingFinishTimeout') const testingFinishTimeout = Cypress.env('testingFinishTimeout')
context('sasjs-tests', function () { context('sasjs-tests', function () {
before(() => { this.beforeAll(() => {
cy.task('log', 'beforeAll')
cy.task('log', `sasjsTestsUrl: ${sasjsTestsUrl}`)
cy.visit(sasjsTestsUrl) cy.visit(sasjsTestsUrl)
}) })
beforeEach(() => { this.beforeEach(() => {
cy.reload() cy.reload()
}) })
function loginIfNeeded() { it('Should have all tests successfull', (done) => {
cy.task('log', `Should have all tests successfull`)
cy.get('body').then(($body) => { cy.get('body').then(($body) => {
if ($body.find('input[placeholder="User Name"]').length > 0) { cy.task('log', `22`)
cy.get('input[placeholder="User Name"]') cy.wait(1000).then(() => {
.should('be.visible') const startButton = $body.find(
.type(username) '.ui.massive.icon.primary.left.labeled.button'
cy.get('input[placeholder="Password"]') )[0]
.should('be.visible')
.type(password) // ui massive icon primary left labeled button
cy.get('.submit-button').should('be.visible').click()
cy.get('input[placeholder="User Name"]').should('not.exist') // Wait for login to finish cy.task('log', `startButton: ${startButton}`)
}
}) if (
!startButton ||
(startButton && !Cypress.dom.isVisible(startButton))
) {
cy.task('log', `34`)
cy.task('log', `username: ${username}`)
cy.task('log', `password: ${password}`)
const userNameInput = cy.get('input[placeholder="User Name"]')
const passwordInput = cy.get('input[placeholder="Password"]')
cy.task('log', `userNameInput: ${userNameInput}`)
cy.task('log', `passwordInput: ${passwordInput}`)
cy.get('input[placeholder="User Name"]').type(username)
cy.get('input[placeholder="Password"]').type(password)
const submitBtn = cy.get('.submit-button')
cy.task('log', `submitBtn: ${submitBtn}`)
cy.get('.submit-button').click()
} }
it('Should have all tests successful', () => { cy.get('input[placeholder="User Name"]', { timeout: 40000 })
loginIfNeeded() .should('not.exist')
.then(() => {
cy.task('log', `46`)
cy.get('.ui.massive.icon.primary.left.labeled.button') cy.get('.ui.massive.icon.primary.left.labeled.button')
.should('be.visible')
.click() .click()
.then(() => {
cy.task('log', `50`)
const loadingButton = $body.find(
'.ui.massive.loading.primary.button'
)[0]
cy.task('log', `loadingButton: ${loadingButton}`)
cy.get('.ui.massive.loading.primary.button', { cy.get('.ui.massive.loading.primary.button', {
timeout: testingFinishTimeout timeout: testingFinishTimeout
}).should('not.exist')
cy.get('span.icon.failed').should('not.exist')
}) })
.should('not.exist')
it('Should have all tests successful with debug on', () => { .then(() => {
loginIfNeeded() cy.task('log', `56`)
cy.get('span.icon.failed')
cy.get('.ui.fitted.toggle.checkbox label').should('be.visible').click() .should('not.exist')
.then(() => {
cy.get('.ui.massive.icon.primary.left.labeled.button') cy.task('log', `60`)
.should('be.visible') done()
.click()
cy.get('.ui.massive.loading.primary.button', {
timeout: testingFinishTimeout
}).should('not.exist')
cy.get('span.icon.failed').should('not.exist')
}) })
}) })
})
})
})
})
})
// it('Should have all tests successfull with debug on', (done) => {
// cy.get('body').then(($body) => {
// cy.wait(1000).then(() => {
// const startButton = $body.find(
// '.ui.massive.icon.primary.left.labeled.button'
// )[0]
// 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

@@ -39,4 +39,11 @@ module.exports = (on, config) => {
return launchOptions return launchOptions
} }
}) })
on('task', {
log(message) {
console.log(message)
return null
}
})
} }

Binary file not shown.

View File

@@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<script src="https://cdn.jsdelivr.net/combine/npm/chart.js@2.9.3,npm/jquery@3.5.1,npm/@sasjs/adapter@4"></script> <script src="https://cdn.jsdelivr.net/combine/npm/chart.js@2.9.3,npm/jquery@3.5.1,npm/@sasjs/adapter@1"></script>
<script> <script>
var sasJs = new SASjs.default({ var sasJs = new SASjs.default({
appLoc: "/Public/app/readme" appLoc: "/Public/app/readme"

View File

@@ -41,14 +41,7 @@ module.exports = {
// ], // ],
// An object that configures minimum threshold enforcement for coverage results // An object that configures minimum threshold enforcement for coverage results
coverageThreshold: { // coverageThreshold: undefined,
global: {
statements: 64.03,
branches: 45.11,
functions: 54.18,
lines: 64.53
}
},
// A path to a custom dependency extractor // A path to a custom dependency extractor
// dependencyExtractor: undefined, // dependencyExtractor: undefined,
@@ -142,8 +135,6 @@ module.exports = {
// Options that will be passed to the testEnvironment // Options that will be passed to the testEnvironment
// testEnvironmentOptions: {}, // testEnvironmentOptions: {},
testEnvironment: 'node',
// Adds a location field to test results // Adds a location field to test results
// testLocationInResults: false, // testLocationInResults: false,

22950
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
"nodeVersionMessage": "echo \u001b[33m make sure you are running node lts version \u001b[0m", "nodeVersionMessage": "echo \u001b[33m make sure you are running node lts version \u001b[0m",
"preinstall": "npm run nodeVersionMessage", "preinstall": "npm run nodeVersionMessage",
"prebuild": "npm run nodeVersionMessage", "prebuild": "npm run nodeVersionMessage",
"build": "npx rimraf build && npx rimraf node && mkdir node && copyfiles -u 1 \"./src/**/*\" ./node && webpack && npx rimraf build/src && npx rimraf node", "build": "rimraf build && rimraf node && mkdir node && copyfiles -u 1 \"./src/**/*\" ./node && webpack && rimraf build/src && rimraf node",
"package:lib": "npm run build && copyfiles ./package.json build && cd build && npm version \"5.0.0\" && npm pack", "package:lib": "npm run build && copyfiles ./package.json build && cd build && npm version \"5.0.0\" && npm pack",
"publish:lib": "npm run build && cd build && npm publish", "publish:lib": "npm run build && cd build && npm publish",
"lint:fix": "npx prettier --loglevel silent --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\" && npx prettier --loglevel silent --write \"sasjs-tests/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\" && npx prettier --loglevel silent --write \"cypress/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\"", "lint:fix": "npx prettier --loglevel silent --write \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\" && npx prettier --loglevel silent --write \"sasjs-tests/src/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\" && npx prettier --loglevel silent --write \"cypress/**/*.{ts,tsx,js,jsx,html,css,sass,less,json,yml,md,graphql}\"",
@@ -45,45 +45,43 @@
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@cypress/webpack-preprocessor": "5.9.1", "@cypress/webpack-preprocessor": "5.9.1",
"@types/cors": "^2.8.17",
"@types/express": "4.17.13", "@types/express": "4.17.13",
"@types/jest": "29.5.14", "@types/jest": "27.4.0",
"@types/mime": "2.0.3", "@types/mime": "2.0.3",
"@types/pem": "1.9.6", "@types/pem": "1.9.6",
"@types/tough-cookie": "4.0.2", "@types/tough-cookie": "4.0.1",
"copyfiles": "2.4.1", "copyfiles": "2.4.1",
"cors": "^2.8.5",
"cp": "0.2.0", "cp": "0.2.0",
"cypress": "7.7.0", "cypress": "7.7.0",
"dotenv": "16.0.0", "dotenv": "16.0.0",
"express": "4.17.3", "express": "4.17.3",
"jest": "29.7.0", "jest": "27.4.7",
"jest-environment-jsdom": "^29.7.0", "jest-extended": "2.0.0",
"jest-extended": "4.0.2",
"node-polyfill-webpack-plugin": "1.1.4", "node-polyfill-webpack-plugin": "1.1.4",
"path": "0.12.7", "path": "0.12.7",
"pem": "1.14.5", "pem": "1.14.5",
"prettier": "2.8.7", "prettier": "2.8.7",
"process": "0.11.10", "process": "0.11.10",
"rimraf": "3.0.2",
"semantic-release": "19.0.3", "semantic-release": "19.0.3",
"terser-webpack-plugin": "5.3.6", "terser-webpack-plugin": "5.3.6",
"ts-jest": "29.2.6", "ts-jest": "27.1.3",
"ts-loader": "9.4.0", "ts-loader": "9.4.0",
"tslint": "6.1.3", "tslint": "6.1.3",
"tslint-config-prettier": "1.18.0", "tslint-config-prettier": "1.18.0",
"typedoc": "0.23.24", "typedoc": "0.23.24",
"typedoc-plugin-rename-defaults": "0.6.4", "typedoc-plugin-rename-defaults": "0.6.4",
"typescript": "4.9.5", "typescript": "4.8.3",
"webpack": "5.76.2", "webpack": "5.76.2",
"webpack-cli": "4.9.2" "webpack-cli": "4.9.2"
}, },
"main": "index.js", "main": "index.js",
"dependencies": { "dependencies": {
"@sasjs/utils": "3.5.2", "@sasjs/utils": "2.52.0",
"axios": "1.8.2", "axios": "0.27.2",
"axios-cookiejar-support": "5.0.5", "axios-cookiejar-support": "1.0.1",
"form-data": "4.0.4", "form-data": "4.0.0",
"https": "1.0.0", "https": "1.0.0",
"tough-cookie": "4.1.3" "tough-cookie": "4.0.0"
} }
} }

View File

@@ -1,3 +1 @@
SKIP_PREFLIGHT_CHECK=true SKIP_PREFLIGHT_CHECK=true
# Removes index.html inline scripts
INLINE_RUNTIME_CHUNK=false

View File

@@ -1,15 +0,0 @@
// craco.config.js
// We use craco instead of react-scripts so we can override webpack config, to include source maps
// so we can debug @sasjs/adapter easier when tests fail
module.exports = {
webpack: {
configure: (webpackConfig, { env }) => {
// Disable optimizations in both development and production
webpackConfig.optimization.minimize = false;
webpackConfig.optimization.minimizer = [];
webpackConfig.optimization.concatenateModules = false;
webpackConfig.optimization.splitChunks = { cacheGroups: { default: false } };
return webpackConfig;
}
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -4,25 +4,25 @@
"homepage": ".", "homepage": ".",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@sasjs/adapter": "file:../build/sasjs-adapter-5.0.0.tgz", "@sasjs/adapter": "4.3.5",
"@sasjs/test-framework": "1.5.7", "@sasjs/test-framework": "1.5.7",
"@types/jest": "^26.0.20", "@types/jest": "^26.0.20",
"@types/node": "^14.14.41", "@types/node": "^14.14.41",
"@types/react": "^16.0.1", "@types/react": "^17.0.1",
"@types/react-dom": "^16.0.0", "@types/react-dom": "^17.0.0",
"@types/react-router-dom": "^5.1.7", "@types/react-router-dom": "^5.1.7",
"react": "^16.0.1", "react": "^17.0.1",
"react-dom": "^16.0.1", "react-dom": "^17.0.1",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-scripts": "4.0.3", "react-scripts": "^5.0.1",
"typescript": "^4.1.3" "typescript": "^4.1.3"
}, },
"scripts": { "scripts": {
"start": "NODE_OPTIONS=--openssl-legacy-provider react-scripts start", "start": "react-scripts start",
"build": "NODE_OPTIONS=--openssl-legacy-provider craco build", "build": "react-scripts build",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject", "eject": "react-scripts eject",
"update:adapter": "cd .. && npm run package:lib && cd sasjs-tests && npm i ../build/sasjs-adapter-5.0.0.tgz", "update:adapter": "cd .. && npm run package:lib && cd sasjs-tests && npm i ../build/sasjs-adapter-5.0.0.tgz --legacy-peer-deps",
"deploy:tests": "rsync -avhe ssh ./build/* --delete $SSH_ACCOUNT:$DEPLOY_PATH || npm run deploy:tests-win", "deploy:tests": "rsync -avhe ssh ./build/* --delete $SSH_ACCOUNT:$DEPLOY_PATH || npm run deploy:tests-win",
"deploy:tests-win": "scp %DEPLOY_PATH% ./build/*", "deploy:tests-win": "scp %DEPLOY_PATH% ./build/*",
"deploy": "npm run update:adapter && npm run build && npm run deploy:tests" "deploy": "npm run update:adapter && npm run build && npm run deploy:tests"
@@ -43,8 +43,6 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@craco/craco": "6.4.3", "node-sass": "7.0.3"
"node-sass": "9.0.0",
"source-map-loader": "0.2.4"
} }
} }

View File

@@ -2,7 +2,7 @@
"userName": "", "userName": "",
"password": "", "password": "",
"sasJsConfig": { "sasJsConfig": {
"serverUrl": "", "serverUrl": "https://sas9.4gl.io",
"appLoc": "/Public/app/adapter-tests/services", "appLoc": "/Public/app/adapter-tests/services",
"serverType": "SASJS", "serverType": "SASJS",
"debug": false, "debug": false,

View File

@@ -4,7 +4,7 @@ if npm run cy:run -- --spec "cypress/integration/sasjs.tests.ts" ; then
echo "Cypress sasjs testing passed!" echo "Cypress sasjs testing passed!"
else else
echo '{"msgtype":"m.text", "body":"Automated sasjs-tests failed on the @sasjs/adapter PR: '$2'"}' echo '{"msgtype":"m.text", "body":"Automated sasjs-tests failed on the @sasjs/adapter PR: '$2'"}'
curl -XPOST -d '{"msgtype":"m.text", "body":"Automated sasjs-tests failed on the @sasjs/adapter PR: '$2'"}' https://matrix.4gl.io/_matrix/client/r0/rooms/%21jRebyiGmHZlpfDwYXN:4gl.io/send/m.room.message?access_token=$1 curl -XPOST -d '{"msgtype":"m.text", "body":"Automated sasjs-tests failed on the @sasjs/adapter PR: '$2'"}' https://matrix.4gl.io/_matrix/client/r0/rooms/%21jRebyiGmHZlpfDwYXN:4gl.io:4gl.io/send/m.room.message?access_token=$1
echo "Cypress sasjs testing failed!" echo "Cypress sasjs testing failed!"
exit 1 exit 1
fi fi

View File

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

View File

@@ -19,7 +19,7 @@ const App = (): ReactElement<{}> => {
basicTests(adapter, config.userName, config.password), basicTests(adapter, config.userName, config.password),
sendArrTests(adapter, appLoc), sendArrTests(adapter, appLoc),
sendObjTests(adapter), sendObjTests(adapter),
// specialCaseTests(adapter), specialCaseTests(adapter),
sasjsRequestTests(adapter), sasjsRequestTests(adapter),
fileUploadTests(adapter) fileUploadTests(adapter)
] ]

View File

@@ -11,7 +11,7 @@ const Login = (): ReactElement<{}> => {
const handleSubmit = useCallback( const handleSubmit = useCallback(
(e: any) => { (e: any) => {
e.preventDefault() e.preventDefault()
appContext.adapter.logIn(username, password).then((res) => { appContext.adapter.logIn(username, password).then((res: any) => {
appContext.setIsLoggedIn(res.isLoggedIn) appContext.setIsLoggedIn(res.isLoggedIn)
}) })
}, },

View File

@@ -87,20 +87,6 @@ export const basicTests = (
return response.table1[0][0] === stringData.table1[0].col1 return response.table1[0][0] === stringData.table1[0].col1
} }
}, },
{
title: 'Web request',
description: 'Should run the request with old web approach',
test: async () => {
const config: Partial<SASjsConfig> = {
useComputeApi: false
}
return await adapter.request('common/sendArr', stringData, config)
},
assertion: (response: any) => {
return response.table1[0][0] === stringData.table1[0].col1
}
},
{ {
title: 'Request with debug on', title: 'Request with debug on',
description: description:
@@ -173,6 +159,20 @@ export const basicTests = (
sasjsConfig.debug === false sasjsConfig.debug === false
) )
} }
},
{
title: 'Web request',
description: 'Should run the request with old web approach',
test: async () => {
const config: Partial<SASjsConfig> = {
useComputeApi: false
}
return await adapter.request('common/sendArr', stringData, config)
},
assertion: (response: any) => {
return response.table1[0][0] === stringData.table1[0].col1
}
} }
] ]
}) })

View File

@@ -20,30 +20,30 @@ export const sasjsRequestTests = (adapter: SASjs): TestSuite => ({
return requests[0].SASWORK === null return requests[0].SASWORK === null
} }
} }
},
{
title: 'Make error and capture log',
description:
'Should make an error and capture log, in the same time it is testing if debug override is working',
test: async () => {
return adapter
.request('common/makeErr', data, { debug: true })
.catch(() => {
const sasRequests = adapter.getSasRequests()
const makeErrRequest: any =
sasRequests.find((req) => req.serviceLink.includes('makeErr')) ||
null
if (!makeErrRequest) return false
return !!(
makeErrRequest.logFile && makeErrRequest.logFile.length > 0
)
})
},
assertion: (response) => {
return response
}
} }
// {
// title: 'Make error and capture log',
// description:
// 'Should make an error and capture log, in the same time it is testing if debug override is working',
// test: async () => {
// return adapter
// .request('common/makeErr', data, { debug: true })
// .catch(() => {
// const sasRequests = adapter.getSasRequests()
// const makeErrRequest: any =
// sasRequests.find((req) => req.serviceLink.includes('makeErr')) ||
// null
// if (!makeErrRequest) return false
// return !!(
// makeErrRequest.logFile && makeErrRequest.logFile.length > 0
// )
// })
// },
// assertion: (response) => {
// return response
// }
// }
] ]
}) })

View File

@@ -134,20 +134,6 @@ export const specialCaseTests = (adapter: SASjs): TestSuite => ({
return adapter.request('common/sendArr', moreSpecialCharData) return adapter.request('common/sendArr', moreSpecialCharData)
}, },
assertion: (res: any) => { assertion: (res: any) => {
// If sas session is `latin9` or `wlatin1` we can't process the special characters,
// But it can happen that response is broken JSON, so we first need to check if
// it's object and then check accordingly
if (typeof res === 'object') {
// Valid JSON response
if (res.SYSENCODING === 'latin9' || res.SYSENCODING === 'wlatin1')
return true
} else {
// Since we got string response (broken JSON), we need to check with regex
const regex = /"SYSENCODING"\s*:\s*"(?:wlatin1|latin9)"/
if (regex.test(res)) return true
}
return ( return (
res.table1[0][0] === moreSpecialCharData.table1[0].speech0 && res.table1[0][0] === moreSpecialCharData.table1[0].speech0 &&
res.table1[0][1] === moreSpecialCharData.table1[0].pct && res.table1[0][1] === moreSpecialCharData.table1[0].pct &&

View File

@@ -6,8 +6,6 @@
"dom.iterable", "dom.iterable",
"esnext" "esnext"
], ],
"sourceMap": true,
"inlineSources": true,
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,

View File

@@ -1,6 +1,6 @@
import * as https from 'https' import * as https from 'https'
import { generateTimestamp } from '@sasjs/utils/time' import { generateTimestamp } from '@sasjs/utils/time'
import NodeFormData from 'form-data' import * as NodeFormData from 'form-data'
import { Sas9RequestClient } from './request/Sas9RequestClient' import { Sas9RequestClient } from './request/Sas9RequestClient'
import { isUrl } from './utils' import { isUrl } from './utils'

View File

@@ -1,5 +1,5 @@
import { isRelativePath, isUri, isUrl } from './utils' import { isRelativePath, isUri, isUrl } from './utils'
import NodeFormData from 'form-data' import * as NodeFormData from 'form-data'
import { import {
Job, Job,
Session, Session,
@@ -25,16 +25,9 @@ import { prefixMessage } from '@sasjs/utils/error'
import { pollJobState } from './api/viya/pollJobState' import { pollJobState } from './api/viya/pollJobState'
import { getTokens } from './auth/getTokens' import { getTokens } from './auth/getTokens'
import { uploadTables } from './api/viya/uploadTables' import { uploadTables } from './api/viya/uploadTables'
import { executeOnComputeApi } from './api/viya/executeOnComputeApi' import { executeScript } from './api/viya/executeScript'
import { getAccessTokenForViya } from './auth/getAccessTokenForViya' import { getAccessTokenForViya } from './auth/getAccessTokenForViya'
import { refreshTokensForViya } from './auth/refreshTokensForViya' import { refreshTokensForViya } from './auth/refreshTokensForViya'
import { FileResource } from './types/FileResource'
interface JobExecutionResult {
result?: { result: object }
log?: string
error?: object
}
/** /**
* A client for interfacing with the SAS Viya REST API. * A client for interfacing with the SAS Viya REST API.
@@ -277,7 +270,7 @@ export class SASViyaApiClient {
* @param debug - when set to true, the log will be returned. * @param debug - when set to true, the log will be returned.
* @param expectWebout - when set to true, the automatic _webout fileref will be checked for content, and that content returned. This fileref is used when the Job contains a SASjs web request (as opposed to executing arbitrary SAS code). * @param expectWebout - when set to true, the automatic _webout fileref will be checked for content, and that content returned. This fileref is used when the Job contains a SASjs web request (as opposed to executing arbitrary SAS code).
* @param waitForResult - when set to true, function will return the session * @param waitForResult - when set to true, function will return the session
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts. * @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }.
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job. * @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
* @param variables - an object that represents macro variables. * @param variables - an object that represents macro variables.
*/ */
@@ -294,7 +287,7 @@ export class SASViyaApiClient {
printPid = false, printPid = false,
variables?: MacroVar variables?: MacroVar
): Promise<any> { ): Promise<any> {
return executeOnComputeApi( return executeScript(
this.requestClient, this.requestClient,
this.sessionManager, this.sessionManager,
this.rootFolderName, this.rootFolderName,
@@ -312,84 +305,6 @@ export class SASViyaApiClient {
) )
} }
/**
* Fetches the file content for a file in the specified folder.
*
* @param folderPath - the full path to the folder containing the file. eg: /Public/folder1/folder2
* @param fileName - the name of the file in the `folderPath`
* @param accessToken - an access token for authorizing the request
*/
public async getFileContent(
folderPath: string,
fileName: string,
accessToken?: string
) {
const fileUri = await this.getFileUri(
folderPath,
fileName,
accessToken
).catch((err) => {
throw prefixMessage(
err,
`Error while getting file URI for: ${fileName} in folder: ${folderPath}. `
)
})
return await this.requestClient
.get<string>(`${this.serverUrl}${fileUri}/content`, accessToken)
.then((res) => res.result)
}
/**
* Updates the file content for a file in the specified folder.
*
* @param folderPath - the full path to the folder containing the file. eg: /Public/folder1/folder2
* @param fileName - the name of the file in the `folderPath`
* @param content - the new content to be written to the file
* @param accessToken - an access token for authorizing the request
*/
public async updateFileContent(
folderPath: string,
fileName: string,
content: string,
accessToken?: string
) {
const fileUri = await this.getFileUri(
folderPath,
fileName,
accessToken
).catch((err) => {
throw prefixMessage(
err,
`Error while getting file URI for: ${fileName} in folder: ${folderPath}. `
)
})
// Fetch the file resource details to get the Etag and content type
const { result: originalFileResource, etag } =
await this.requestClient.get<FileResource>(
`${this.serverUrl}${fileUri}`,
accessToken
)
if (!originalFileResource || !etag)
throw new Error(
`File ${fileName} does not have an ETag, or request failed.`
)
return await this.requestClient
.put<FileResource>(
`${this.serverUrl}${fileUri}/content`,
content,
accessToken,
{
'If-Match': etag,
'Content-Type': originalFileResource.contentType
}
)
.then((res) => res.result)
}
/** /**
* Fetches a folder. Path to the folder is required. * Fetches a folder. Path to the folder is required.
* @param folderPath - the absolute path to the folder. * @param folderPath - the absolute path to the folder.
@@ -706,7 +621,7 @@ export class SASViyaApiClient {
* @param accessToken - an optional access token for an authorized user. * @param accessToken - an optional access token for an authorized user.
* @param waitForResult - a boolean indicating if the function should wait for a result. * @param waitForResult - a boolean indicating if the function should wait for a result.
* @param expectWebout - a boolean indicating whether to expect a _webout response. * @param expectWebout - a boolean indicating whether to expect a _webout response.
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts. * @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }.
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job. * @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
* @param variables - an object that represents macro variables. * @param variables - an object that represents macro variables.
*/ */
@@ -817,13 +732,11 @@ export class SASViyaApiClient {
debug: boolean, debug: boolean,
data?: any, data?: any,
authConfig?: AuthConfig authConfig?: AuthConfig
): Promise<JobExecutionResult> { ) {
let access_token = (authConfig || {}).access_token let access_token = (authConfig || {}).access_token
if (authConfig) { if (authConfig) {
;({ access_token } = await getTokens(this.requestClient, authConfig)) ;({ access_token } = await getTokens(this.requestClient, authConfig))
} }
if (isRelativePath(sasJob) && !this.rootFolderName) { if (isRelativePath(sasJob) && !this.rootFolderName) {
throw new Error( throw new Error(
'Relative paths cannot be used without specifying a root folder name.' 'Relative paths cannot be used without specifying a root folder name.'
@@ -836,7 +749,6 @@ export class SASViyaApiClient {
const fullFolderPath = isRelativePath(sasJob) const fullFolderPath = isRelativePath(sasJob)
? `${this.rootFolderName}/${folderPath}` ? `${this.rootFolderName}/${folderPath}`
: folderPath : folderPath
await this.populateFolderMap(fullFolderPath, access_token) await this.populateFolderMap(fullFolderPath, access_token)
const jobFolder = this.folderMap.get(fullFolderPath) const jobFolder = this.folderMap.get(fullFolderPath)
@@ -853,8 +765,9 @@ export class SASViyaApiClient {
files = await this.uploadTables(data, access_token) files = await this.uploadTables(data, access_token)
} }
if (!jobToExecute) throw new Error(`Job was not found.`) if (!jobToExecute) {
throw new Error(`Job was not found.`)
}
const jobDefinitionLink = jobToExecute?.links.find( const jobDefinitionLink = jobToExecute?.links.find(
(l) => l.rel === 'getResource' (l) => l.rel === 'getResource'
)?.href )?.href
@@ -870,14 +783,14 @@ export class SASViyaApiClient {
_webin_file_count: files.length, _webin_file_count: files.length,
_OMITJSONLISTING: true, _OMITJSONLISTING: true,
_OMITJSONLOG: true, _OMITJSONLOG: true,
_omitSessionResults: false, _OMITSESSIONRESULTS: true,
_OMITTEXTLISTING: true, _OMITTEXTLISTING: true,
_OMITTEXTLOG: true _OMITTEXTLOG: true
} }
if (debug) { if (debug) {
jobArguments['_OMITTEXTLOG'] = 'false' jobArguments['_OMITTEXTLOG'] = 'false'
jobArguments['_omitSessionResults'] = 'false' jobArguments['_OMITSESSIONRESULTS'] = 'false'
jobArguments['_DEBUG'] = 131 jobArguments['_DEBUG'] = 131
} }
@@ -894,19 +807,16 @@ export class SASViyaApiClient {
jobDefinition, jobDefinition,
arguments: jobArguments arguments: jobArguments
} }
const { result: postedJob } = await this.requestClient.post<Job>( const { result: postedJob } = await this.requestClient.post<Job>(
`${this.serverUrl}/jobExecution/jobs?_action=wait`, `${this.serverUrl}/jobExecution/jobs?_action=wait`,
postJobRequestBody, postJobRequestBody,
access_token access_token
) )
const jobStatus = await this.pollJobState(postedJob, authConfig).catch( const jobStatus = await this.pollJobState(postedJob, authConfig).catch(
(err) => { (err) => {
throw prefixMessage(err, 'Error while polling job status. ') throw prefixMessage(err, 'Error while polling job status. ')
} }
) )
const { result: currentJob } = await this.requestClient.get<Job>( const { result: currentJob } = await this.requestClient.get<Job>(
`${this.serverUrl}/jobExecution/jobs/${postedJob.id}`, `${this.serverUrl}/jobExecution/jobs/${postedJob.id}`,
access_token access_token
@@ -917,7 +827,6 @@ export class SASViyaApiClient {
const resultLink = currentJob.results['_webout.json'] const resultLink = currentJob.results['_webout.json']
const logLink = currentJob.links.find((l) => l.rel === 'log') const logLink = currentJob.links.find((l) => l.rel === 'log')
if (resultLink) { if (resultLink) {
jobResult = await this.requestClient.get<any>( jobResult = await this.requestClient.get<any>(
`${this.serverUrl}${resultLink}/content`, `${this.serverUrl}${resultLink}/content`,
@@ -925,13 +834,11 @@ export class SASViyaApiClient {
'text/plain' 'text/plain'
) )
} }
if (debug && logLink) { if (debug && logLink) {
log = await this.requestClient log = await this.requestClient
.get<any>(`${this.serverUrl}${logLink.href}/content`, access_token) .get<any>(`${this.serverUrl}${logLink.href}/content`, access_token)
.then((res: any) => res.result.items.map((i: any) => i.line).join('\n')) .then((res: any) => res.result.items.map((i: any) => i.line).join('\n'))
} }
if (jobStatus === 'failed') { if (jobStatus === 'failed') {
throw new JobExecutionError( throw new JobExecutionError(
currentJob.error?.errorCode, currentJob.error?.errorCode,
@@ -939,16 +846,7 @@ export class SASViyaApiClient {
log log
) )
} }
return { result: jobResult?.result, log }
const executionResult: JobExecutionResult = {
result: jobResult?.result,
log
}
const { error } = currentJob
if (error) executionResult.error = error
return executionResult
} }
private async populateFolderMap(folderPath: string, accessToken?: string) { private async populateFolderMap(folderPath: string, accessToken?: string) {
@@ -1020,7 +918,6 @@ export class SASViyaApiClient {
}) })
if (!folder) return undefined if (!folder) return undefined
return folder return folder
} }
@@ -1032,30 +929,6 @@ export class SASViyaApiClient {
return `/folders/folders/${folderDetails.id}` return `/folders/folders/${folderDetails.id}`
} }
private async getFileUri(
folderPath: string,
fileName: string,
accessToken?: string
): Promise<string> {
const folderMembers = await this.listFolder(folderPath, accessToken, 1000, {
returnDetails: true
}).catch((err) => {
throw prefixMessage(err, `Error while listing folder: ${folderPath}. `)
})
if (!folderMembers || !folderMembers.length)
throw new Error(`No members found in folder: ${folderPath}`)
const fileUri = folderMembers.find(
(member) => member.name === fileName
)?.uri
if (!fileUri)
throw new Error(`File ${fileName} not found in folder: ${folderPath}`)
return fileUri
}
private async getRecycleBinUri(accessToken?: string) { private async getRecycleBinUri(accessToken?: string) {
const url = '/folders/folders/@myRecycleBin' const url = '/folders/folders/@myRecycleBin'
@@ -1103,19 +976,14 @@ export class SASViyaApiClient {
} }
/** /**
* Lists children folders/files for given Viya folder. * Lists children folders for given Viya folder.
* @param sourceFolder - the full path (eg `/Public/example/myFolder`) or URI of the source folder listed. Providing URI instead of path will save one extra request. * @param sourceFolder - the full path (eg `/Public/example/myFolder`) or URI of the source folder listed. Providing URI instead of path will save one extra request.
* @param accessToken - an access token for authorizing the request. * @param accessToken - an access token for authorizing the request.
* @param {Object} [options] - Additional options.
* @param {boolean} [options.returnDetails=false] - when set to true, the function will return an array of objects with member details, otherwise it will return an array of member names.
*/ */
public async listFolder( public async listFolder(
sourceFolder: string, sourceFolder: string,
accessToken?: string, accessToken?: string,
limit: number = 20, limit: number = 20
options?: {
returnDetails?: boolean
}
) { ) {
// checks if 'sourceFolder' is already a URI // checks if 'sourceFolder' is already a URI
const sourceFolderUri = isUri(sourceFolder) const sourceFolderUri = isUri(sourceFolder)
@@ -1127,22 +995,13 @@ export class SASViyaApiClient {
accessToken accessToken
) )
let membersToReturn = []
if (members && members.items) { if (members && members.items) {
// If returnDetails is true, return full member details return members.items.map((item: any) => item.name)
if (options?.returnDetails) {
membersToReturn = members.items
} else { } else {
// If returnDetails is false, return only member names return []
membersToReturn = members.items.map((item: any) => item.name)
} }
} }
// Return members without Etag
return membersToReturn
}
/** /**
* Moves Viya folder to a new location. The folder may be renamed at the same time. * Moves Viya folder to a new location. The folder may be renamed at the same time.
* @param sourceFolder - the full path (eg `/Public/example/myFolder`) or URI of the source folder to be moved. Providing URI instead of path will save one extra request. * @param sourceFolder - the full path (eg `/Public/example/myFolder`) or URI of the source folder to be moved. Providing URI instead of path will save one extra request.

View File

@@ -4,12 +4,7 @@ import {
UploadFile, UploadFile,
EditContextInput, EditContextInput,
PollOptions, PollOptions,
LoginMechanism, LoginMechanism
VerboseMode,
ErrorResponse,
LoginOptions,
LoginResult,
ExecutionQuery
} from './types' } from './types'
import { SASViyaApiClient } from './SASViyaApiClient' import { SASViyaApiClient } from './SASViyaApiClient'
import { SAS9ApiClient } from './SAS9ApiClient' import { SAS9ApiClient } from './SAS9ApiClient'
@@ -34,7 +29,8 @@ import {
Sas9JobExecutor, Sas9JobExecutor,
FileUploader FileUploader
} from './job-execution' } from './job-execution'
import { AxiosResponse, AxiosError } from 'axios' import { ErrorResponse } from './types/errors'
import { LoginOptions, LoginResult } from './types/Login'
interface ExecuteScriptParams { interface ExecuteScriptParams {
linesOfCode: string[] linesOfCode: string[]
@@ -161,23 +157,6 @@ export default class SASjs {
} }
} }
/**
* Executes job on SASJS server.
* @param query - an object containing job path and debug level.
* @param appLoc - an application path.
* @param authConfig - an object for authentication.
* @returns a promise that resolves into job execution result and log.
*/
public async executeJob(
query: ExecutionQuery,
appLoc: string,
authConfig?: AuthConfig
) {
this.isMethodSupported('executeScript', [ServerType.Sasjs])
return await this.sasJSApiClient?.executeJob(query, appLoc, authConfig)
}
/** /**
* Gets compute contexts. * Gets compute contexts.
* @param accessToken - an access token for an authorised user. * @param accessToken - an access token for an authorised user.
@@ -411,51 +390,6 @@ export default class SASjs {
) )
} }
/**
* Fetches the file content for a file in the specified folder.
*
* @param folderPath - the full path to the folder containing the file. eg: /Public/folder1/folder2
* @param fileName - the name of the file in the `folderPath`
* @param accessToken - an access token for authorizing the request
*/
public async getFileContent(
folderPath: string,
fileName: string,
accessToken?: string
) {
this.isMethodSupported('getFileContent', [ServerType.SasViya])
return await this.sasViyaApiClient!.getFileContent(
folderPath,
fileName,
accessToken
)
}
/**
* Updates the file content for a file in the specified folder.
*
* @param folderPath - the full path to the folder containing the file. eg: /Public/folder1/folder2
* @param fileName - the name of the file in the `folderPath`
* @param content - the new content to be written to the file
* @param accessToken - an access token for authorizing the request
*/
public async updateFileContent(
folderPath: string,
fileName: string,
content: string,
accessToken?: string
) {
this.isMethodSupported('updateFileContent', [ServerType.SasViya])
return await this.sasViyaApiClient!.updateFileContent(
folderPath,
fileName,
content,
accessToken
)
}
/** /**
* Fetches a folder from the SAS file system. * Fetches a folder from the SAS file system.
* @param folderPath - path of the folder to be fetched. * @param folderPath - path of the folder to be fetched.
@@ -481,23 +415,18 @@ export default class SASjs {
* Lists children folders for given Viya folder. * Lists children folders for given Viya folder.
* @param sourceFolder - the full path (eg `/Public/example/myFolder`) or URI of the source folder listed. Providing URI instead of path will save one extra request. * @param sourceFolder - the full path (eg `/Public/example/myFolder`) or URI of the source folder listed. Providing URI instead of path will save one extra request.
* @param accessToken - an access token for authorizing the request. * @param accessToken - an access token for authorizing the request.
* @param returnDetails - when set to true, the function will return an array of objects with member details, otherwise it will return an array of member names.
*/ */
public async listFolder( public async listFolder(
sourceFolder: string, sourceFolder: string,
accessToken?: string, accessToken?: string,
limit?: number, limit?: number
returnDetails = false
) { ) {
this.isMethodSupported('listFolder', [ServerType.SasViya]) this.isMethodSupported('listFolder', [ServerType.SasViya])
return await this.sasViyaApiClient?.listFolder( return await this.sasViyaApiClient?.listFolder(
sourceFolder, sourceFolder,
accessToken, accessToken,
limit, limit
{
returnDetails
}
) )
} }
@@ -922,10 +851,9 @@ export default class SASjs {
* @param authConfig - a valid client, secret, refresh and access tokens that are authorised to execute compute jobs. * @param authConfig - a valid client, secret, refresh and access tokens that are authorised to execute compute jobs.
* The access token is not required when the user is authenticated via the browser. * The access token is not required when the user is authenticated via the browser.
* @param waitForResult - a boolean that indicates whether the function needs to wait for execution to complete. * @param waitForResult - a boolean that indicates whether the function needs to wait for execution to complete.
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts. * @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }.
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job. * @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
* @param variables - an object that represents macro variables. * @param variables - an object that represents macro variables.
* @param verboseMode - boolean or a string equal to 'bleached' to enable verbose mode (log every HTTP response).
*/ */
public async startComputeJob( public async startComputeJob(
sasJob: string, sasJob: string,
@@ -935,8 +863,7 @@ export default class SASjs {
waitForResult?: boolean, waitForResult?: boolean,
pollOptions?: PollOptions, pollOptions?: PollOptions,
printPid = false, printPid = false,
variables?: MacroVar, variables?: MacroVar
verboseMode?: VerboseMode
) { ) {
config = { config = {
...this.sasjsConfig, ...this.sasjsConfig,
@@ -950,11 +877,6 @@ export default class SASjs {
) )
} }
if (verboseMode) {
this.requestClient?.setVerboseMode(verboseMode)
this.requestClient?.enableVerboseMode()
} else if (verboseMode === false) this.requestClient?.disableVerboseMode()
return this.sasViyaApiClient?.executeComputeJob( return this.sasViyaApiClient?.executeComputeJob(
sasJob, sasJob,
config.contextName, config.contextName,
@@ -1048,8 +970,7 @@ export default class SASjs {
this.requestClient = new RequestClientClass( this.requestClient = new RequestClientClass(
this.sasjsConfig.serverUrl, this.sasjsConfig.serverUrl,
this.sasjsConfig.httpsAgentOptions, this.sasjsConfig.httpsAgentOptions,
this.sasjsConfig.requestHistoryLimit, this.sasjsConfig.requestHistoryLimit
this.sasjsConfig.verbose
) )
} else { } else {
this.requestClient.setConfig( this.requestClient.setConfig(
@@ -1213,31 +1134,4 @@ export default class SASjs {
) )
} }
} }
/**
* Enables verbose mode that will log a summary of every HTTP response.
* @param successCallBack - function that should be triggered on every HTTP response with the status 2**.
* @param errorCallBack - function that should be triggered on every HTTP response with the status different from 2**.
*/
public enableVerboseMode(
successCallBack?: (response: AxiosResponse) => AxiosResponse,
errorCallBack?: (response: AxiosError) => AxiosError
) {
this.requestClient?.enableVerboseMode(successCallBack, errorCallBack)
}
/**
* Turns off verbose mode to log every HTTP response.
*/
public disableVerboseMode() {
this.requestClient?.disableVerboseMode()
}
/**
* Sets verbose mode.
* @param verboseMode - value of the verbose mode, can be true, false or bleached(without extra colors).
*/
public setVerboseMode = (verboseMode: VerboseMode) => {
this.requestClient?.setVerboseMode(verboseMode)
}
} }

View File

@@ -1,4 +1,4 @@
import NodeFormData from 'form-data' import * as NodeFormData from 'form-data'
import { AuthConfig, ServerType, ServicePackSASjs } from '@sasjs/utils/types' import { AuthConfig, ServerType, ServicePackSASjs } from '@sasjs/utils/types'
import { prefixMessage } from '@sasjs/utils/error' import { prefixMessage } from '@sasjs/utils/error'
import { ExecutionQuery } from './types' import { ExecutionQuery } from './types'

View File

@@ -1,4 +1,4 @@
import { Session, Context, SessionVariable, SessionState } from './types' import { Session, Context, SessionVariable } from './types'
import { NoSessionStateError } from './types/errors' import { NoSessionStateError } from './types/errors'
import { asyncForEach, isUrl } from './utils' import { asyncForEach, isUrl } from './utils'
import { prefixMessage } from '@sasjs/utils/error' import { prefixMessage } from '@sasjs/utils/error'
@@ -12,7 +12,6 @@ interface ApiErrorResponse {
export class SessionManager { export class SessionManager {
private loggedErrors: NoSessionStateError[] = [] private loggedErrors: NoSessionStateError[] = []
private sessionStateLinkError = 'Error while getting session state link. '
constructor( constructor(
private serverUrl: string, private serverUrl: string,
@@ -29,7 +28,7 @@ export class SessionManager {
private _debug: boolean = false private _debug: boolean = false
private printedSessionState = { private printedSessionState = {
printed: false, printed: false,
state: SessionState.NoState state: ''
} }
public get debug() { public get debug() {
@@ -266,18 +265,6 @@ export class SessionManager {
) )
}) })
// Add response etag to Session object.
createdSession.etag = etag
// Get session state link.
const stateLink = createdSession.links.find((link) => link.rel === 'state')
// Throw error if session state link is not present.
if (!stateLink) throw this.sessionStateLinkError
// Add session state link to Session object.
createdSession.stateUrl = stateLink.href
await this.waitForSession(createdSession, etag, accessToken) await this.waitForSession(createdSession, etag, accessToken)
this.sessions.push(createdSession) this.sessions.push(createdSession)
@@ -340,30 +327,32 @@ export class SessionManager {
etag: string | null, etag: string | null,
accessToken?: string accessToken?: string
): Promise<string> { ): Promise<string> {
let { state: sessionState } = session
const { stateUrl } = session
const logger = process.logger || console const logger = process.logger || console
let sessionState = session.state
const stateLink = session.links.find((l: any) => l.rel === 'state')
if ( if (
sessionState === SessionState.Pending || sessionState === 'pending' ||
sessionState === SessionState.Running || sessionState === 'running' ||
sessionState === SessionState.NoState sessionState === ''
) { ) {
if (stateUrl) { if (stateLink) {
if (this.debug && !this.printedSessionState.printed) { if (this.debug && !this.printedSessionState.printed) {
logger.info(`Polling: ${this.serverUrl + stateUrl}`) logger.info(`Polling: ${this.serverUrl + stateLink.href}`)
this.printedSessionState.printed = true this.printedSessionState.printed = true
} }
const url = `${this.serverUrl}${stateUrl}?wait=30` const url = `${this.serverUrl}${stateLink.href}?wait=30`
const { result: state, responseStatus: responseStatus } = const { result: state, responseStatus: responseStatus } =
await this.getSessionState(url, etag!, accessToken).catch((err) => { await this.getSessionState(url, etag!, accessToken).catch((err) => {
throw prefixMessage(err, 'Error while waiting for session. ') throw prefixMessage(err, 'Error while waiting for session. ')
}) })
sessionState = state.trim() as SessionState sessionState = state.trim()
if (this.debug && this.printedSessionState.state !== sessionState) { if (this.debug && this.printedSessionState.state !== sessionState) {
logger.info(`Current session state is '${sessionState}'`) logger.info(`Current session state is '${sessionState}'`)
@@ -375,7 +364,7 @@ export class SessionManager {
if (!sessionState) { if (!sessionState) {
const stateError = new NoSessionStateError( const stateError = new NoSessionStateError(
responseStatus, responseStatus,
this.serverUrl + stateUrl, this.serverUrl + stateLink.href,
session.links.find((l: any) => l.rel === 'log')?.href as string session.links.find((l: any) => l.rel === 'log')?.href as string
) )
@@ -397,7 +386,7 @@ export class SessionManager {
return sessionState return sessionState
} else { } else {
throw this.sessionStateLinkError throw 'Error while getting session state link. '
} }
} else { } else {
this.loggedErrors = [] this.loggedErrors = []
@@ -424,7 +413,7 @@ export class SessionManager {
return await this.requestClient return await this.requestClient
.get(url, accessToken, 'text/plain', { 'If-None-Match': etag }) .get(url, accessToken, 'text/plain', { 'If-None-Match': etag })
.then((res) => ({ .then((res) => ({
result: res.result as SessionState, result: res.result as string,
responseStatus: res.status responseStatus: res.status
})) }))
.catch((err) => { .catch((err) => {

View File

@@ -12,15 +12,11 @@ import { RequestClient } from '../../request/RequestClient'
import { SessionManager } from '../../SessionManager' import { SessionManager } from '../../SessionManager'
import { isRelativePath, fetchLogByChunks } from '../../utils' import { isRelativePath, fetchLogByChunks } from '../../utils'
import { formatDataForRequest } from '../../utils/formatDataForRequest' import { formatDataForRequest } from '../../utils/formatDataForRequest'
import { pollJobState, JobState } from './pollJobState' import { pollJobState } from './pollJobState'
import { uploadTables } from './uploadTables' import { uploadTables } from './uploadTables'
interface JobRequestBody {
[key: string]: number | string | string[]
}
/** /**
* Executes SAS program on the current SAS Viya server using Compute API. * Executes code on the current SAS Viya server.
* @param jobPath - the path to the file being submitted for execution. * @param jobPath - the path to the file being submitted for execution.
* @param linesOfCode - an array of code lines to execute. * @param linesOfCode - an array of code lines to execute.
* @param contextName - the context to execute the code in. * @param contextName - the context to execute the code in.
@@ -29,11 +25,11 @@ interface JobRequestBody {
* @param debug - when set to true, the log will be returned. * @param debug - when set to true, the log will be returned.
* @param expectWebout - when set to true, the automatic _webout fileref will be checked for content, and that content returned. This fileref is used when the Job contains a SASjs web request (as opposed to executing arbitrary SAS code). * @param expectWebout - when set to true, the automatic _webout fileref will be checked for content, and that content returned. This fileref is used when the Job contains a SASjs web request (as opposed to executing arbitrary SAS code).
* @param waitForResult - when set to true, function will return the session * @param waitForResult - when set to true, function will return the session
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts. * @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }.
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job. * @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
* @param variables - an object that represents macro variables. * @param variables - an object that represents macro variables.
*/ */
export async function executeOnComputeApi( export async function executeScript(
requestClient: RequestClient, requestClient: RequestClient,
sessionManager: SessionManager, sessionManager: SessionManager,
rootFolderName: string, rootFolderName: string,
@@ -50,7 +46,6 @@ export async function executeOnComputeApi(
variables?: MacroVar variables?: MacroVar
): Promise<any> { ): Promise<any> {
let access_token = (authConfig || {}).access_token let access_token = (authConfig || {}).access_token
if (authConfig) { if (authConfig) {
;({ access_token } = await getTokens(requestClient, authConfig)) ;({ access_token } = await getTokens(requestClient, authConfig))
} }
@@ -83,13 +78,27 @@ export async function executeOnComputeApi(
const logger = process.logger || console const logger = process.logger || console
logger.info( logger.info(
`Triggering '${relativeJobPath}' with PID ${ `Triggered '${relativeJobPath}' with PID ${
jobIdVariable.value jobIdVariable.value
} at ${timestampToYYYYMMDDHHMMSS()}` } at ${timestampToYYYYMMDDHHMMSS()}`
) )
} }
} }
const jobArguments: { [key: string]: any } = {
_contextName: contextName,
_OMITJSONLISTING: true,
_OMITJSONLOG: true,
_OMITSESSIONRESULTS: true,
_OMITTEXTLISTING: true,
_OMITTEXTLOG: true
}
if (debug) {
jobArguments['_OMITTEXTLOG'] = false
jobArguments['_OMITSESSIONRESULTS'] = false
}
let fileName let fileName
if (isRelativePath(jobPath)) { if (isRelativePath(jobPath)) {
@@ -98,7 +107,6 @@ export async function executeOnComputeApi(
}` }`
} else { } else {
const jobPathParts = jobPath.split('/') const jobPathParts = jobPath.split('/')
fileName = jobPathParts.pop() fileName = jobPathParts.pop()
} }
@@ -110,6 +118,7 @@ export async function executeOnComputeApi(
} }
if (variables) jobVariables = { ...jobVariables, ...variables } if (variables) jobVariables = { ...jobVariables, ...variables }
if (debug) jobVariables = { ...jobVariables, _DEBUG: 131 } if (debug) jobVariables = { ...jobVariables, _DEBUG: 131 }
let files: any[] = [] let files: any[] = []
@@ -136,12 +145,12 @@ export async function executeOnComputeApi(
} }
// Execute job in session // Execute job in session
const jobRequestBody: JobRequestBody = { const jobRequestBody = {
name: fileName || 'Default Job Name', name: fileName,
description: 'Powered by SASjs', description: 'Powered by SASjs',
code: linesOfCode, code: linesOfCode,
variables: jobVariables, variables: jobVariables,
version: 2 arguments: jobArguments
} }
const { result: postedJob, etag } = await requestClient const { result: postedJob, etag } = await requestClient
@@ -170,21 +179,16 @@ export async function executeOnComputeApi(
postedJob, postedJob,
debug, debug,
authConfig, authConfig,
pollOptions, pollOptions
{
session,
sessionManager
}
).catch(async (err) => { ).catch(async (err) => {
const error = err?.response?.data const error = err?.response?.data
const result = /err=[0-9]*,/.exec(error) const result = /err=[0-9]*,/.exec(error)
const errorCode = '5113'
const errorCode = '5113'
if (result?.[0]?.slice(4, -1) === errorCode) { if (result?.[0]?.slice(4, -1) === errorCode) {
const logCount = 1000000
const sessionLogUrl = const sessionLogUrl =
postedJob.links.find((l: any) => l.rel === 'up')!.href + '/log' postedJob.links.find((l: any) => l.rel === 'up')!.href + '/log'
const logCount = 1000000
err.log = await fetchLogByChunks( err.log = await fetchLogByChunks(
requestClient, requestClient,
access_token!, access_token!,
@@ -192,7 +196,6 @@ export async function executeOnComputeApi(
logCount logCount
) )
} }
throw prefixMessage(err, 'Error while polling job status. ') throw prefixMessage(err, 'Error while polling job status. ')
}) })
@@ -211,12 +214,12 @@ export async function executeOnComputeApi(
let jobResult let jobResult
let log = '' let log = ''
const logLink = currentJob.links.find((l) => l.rel === 'log') const logLink = currentJob.links.find((l) => l.rel === 'log')
if (debug && logLink) { if (debug && logLink) {
const logUrl = `${logLink.href}/content` const logUrl = `${logLink.href}/content`
const logCount = currentJob.logStatistics?.lineCount ?? 1000000 const logCount = currentJob.logStatistics?.lineCount ?? 1000000
log = await fetchLogByChunks( log = await fetchLogByChunks(
requestClient, requestClient,
access_token!, access_token!,
@@ -225,11 +228,13 @@ export async function executeOnComputeApi(
) )
} }
if (jobStatus === JobState.Failed || jobStatus === JobState.Error) { if (jobStatus === 'failed' || jobStatus === 'error') {
throw new ComputeJobExecutionError(currentJob, log) throw new ComputeJobExecutionError(currentJob, log)
} }
if (!expectWebout) return { job: currentJob, log } if (!expectWebout) {
return { job: currentJob, log }
}
const resultLink = `/compute/sessions/${executionSessionId}/filerefs/_webout/content` const resultLink = `/compute/sessions/${executionSessionId}/filerefs/_webout/content`
@@ -240,7 +245,6 @@ export async function executeOnComputeApi(
if (logLink) { if (logLink) {
const logUrl = `${logLink.href}/content` const logUrl = `${logLink.href}/content`
const logCount = currentJob.logStatistics?.lineCount ?? 1000000 const logCount = currentJob.logStatistics?.lineCount ?? 1000000
log = await fetchLogByChunks( log = await fetchLogByChunks(
requestClient, requestClient,
access_token!, access_token!,
@@ -275,7 +279,7 @@ export async function executeOnComputeApi(
const error = e as HttpError const error = e as HttpError
if (error.status === 404) { if (error.status === 404) {
return executeOnComputeApi( return executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
rootFolderName, rootFolderName,

View File

@@ -1,89 +1,28 @@
import { AuthConfig } from '@sasjs/utils/types' import { AuthConfig } from '@sasjs/utils/types'
import { Job, PollOptions, PollStrategy } from '../..' import { Job, PollOptions } from '../..'
import { getTokens } from '../../auth/getTokens' import { getTokens } from '../../auth/getTokens'
import { RequestClient } from '../../request/RequestClient' import { RequestClient } from '../../request/RequestClient'
import { JobStatePollError } from '../../types/errors' import { JobStatePollError } from '../../types/errors'
import { Link, WriteStream, SessionState, JobSessionManager } from '../../types' import { Link, WriteStream } from '../../types'
import { delay, isNode } from '../../utils' import { delay, isNode } from '../../utils'
export enum JobState {
Completed = 'completed',
Running = 'running',
Pending = 'pending',
Unavailable = 'unavailable',
NoState = '',
Failed = 'failed',
Error = 'error'
}
/**
* Polls job status using default or provided poll options.
* @param requestClient - the pre-configured HTTP request client.
* @param postedJob - the relative or absolute path to the job.
* @param debug - sets the _debug flag in the job arguments.
* @param authConfig - an access token, refresh token, client and secret for an authorized user.
* @param pollOptions - an object containing maxPollCount, pollInterval, streamLog and logFolderPath. It will override the first default poll options in poll strategy if provided.
* Example pollOptions:
* {
* maxPollCount: 200,
* pollInterval: 300,
* streamLog: true, // optional, equals to false by default.
* pollStrategy?: // optional array of poll options that should be applied after 'maxPollCount' of the provided poll options is reached. If not provided the default (see example below) poll strategy will be used.
* }
* Example pollStrategy (values used from default poll strategy):
* [
* { maxPollCount: 200, pollInterval: 300 }, // approximately ~2 mins (including time to get response (~300ms))
* { maxPollCount: 300, pollInterval: 3000 }, // approximately ~5.5 mins (including time to get response (~300ms))
* { maxPollCount: 500, pollInterval: 30000 }, // approximately ~50.5 mins (including time to get response (~300ms))
* { maxPollCount: 3400, pollInterval: 60000 } // approximately ~3015 mins (~125 hours) (including time to get response (~300ms))
* ]
* @param jobSessionManager - job session object containing session object and an instance of Session Manager. Job session object is used to periodically (every 10th job state poll) check parent session state.
* @returns - a promise which resolves with a job state
*/
export async function pollJobState( export async function pollJobState(
requestClient: RequestClient, requestClient: RequestClient,
postedJob: Job, postedJob: Job,
debug: boolean, debug: boolean,
authConfig?: AuthConfig, authConfig?: AuthConfig,
pollOptions?: PollOptions, pollOptions?: PollOptions
jobSessionManager?: JobSessionManager ) {
): Promise<JobState> {
const logger = process.logger || console const logger = process.logger || console
const streamLog = pollOptions?.streamLog || false let pollInterval = 300
let maxPollCount = 1000
const defaultPollStrategy: PollStrategy = [ const defaultPollOptions: PollOptions = {
{ maxPollCount: 200, pollInterval: 300 }, maxPollCount,
{ maxPollCount: 300, pollInterval: 3000 }, pollInterval,
{ maxPollCount: 500, pollInterval: 30000 }, streamLog: false
{ maxPollCount: 3400, pollInterval: 60000 }
]
let pollStrategy: PollStrategy
if (pollOptions !== undefined) {
pollStrategy = [pollOptions]
let { pollStrategy: providedPollStrategy } = pollOptions
if (providedPollStrategy !== undefined) {
validatePollStrategies(providedPollStrategy)
// INFO: sort by 'maxPollCount'
providedPollStrategy = providedPollStrategy.sort(
(strategyA: PollOptions, strategyB: PollOptions) =>
strategyA.maxPollCount - strategyB.maxPollCount
)
pollStrategy = [...pollStrategy, ...providedPollStrategy]
} else {
pollStrategy = [...pollStrategy, ...defaultPollStrategy]
} }
} else {
pollStrategy = defaultPollStrategy
}
let defaultPollOptions: PollOptions = pollStrategy.splice(0, 1)[0]
pollOptions = { ...defaultPollOptions, ...(pollOptions || {}) } pollOptions = { ...defaultPollOptions, ...(pollOptions || {}) }
@@ -92,10 +31,10 @@ export async function pollJobState(
throw new Error(`Job state link was not found.`) throw new Error(`Job state link was not found.`)
} }
let currentState: JobState = await getJobState( let currentState = await getJobState(
requestClient, requestClient,
postedJob, postedJob,
JobState.NoState, '',
debug, debug,
authConfig authConfig
).catch((err) => { ).catch((err) => {
@@ -103,53 +42,54 @@ export async function pollJobState(
`Error fetching job state from ${stateLink.href}. Starting poll, assuming job to be running.`, `Error fetching job state from ${stateLink.href}. Starting poll, assuming job to be running.`,
err err
) )
return 'unavailable'
return JobState.Unavailable
}) })
let pollCount = 0 let pollCount = 0
if (currentState === JobState.Completed) { if (currentState === 'completed') {
return Promise.resolve(currentState) return Promise.resolve(currentState)
} }
let logFileStream let logFileStream
if (streamLog && isNode()) { if (pollOptions.streamLog && isNode()) {
const { getFileStream } = require('./getFileStream') const { getFileStream } = require('./getFileStream')
logFileStream = await getFileStream(postedJob, pollOptions.logFolderPath) logFileStream = await getFileStream(postedJob, pollOptions.logFolderPath)
} }
// Poll up to the first 100 times with the specified poll interval
let result = await doPoll( let result = await doPoll(
requestClient, requestClient,
postedJob, postedJob,
currentState, currentState,
debug, debug,
pollCount, pollCount,
pollOptions,
authConfig, authConfig,
streamLog, {
logFileStream, ...pollOptions,
jobSessionManager maxPollCount:
pollOptions.maxPollCount <= 100 ? pollOptions.maxPollCount : 100
},
logFileStream
) )
currentState = result.state currentState = result.state
pollCount = result.pollCount pollCount = result.pollCount
if ( if (!needsRetry(currentState) || pollCount >= pollOptions.maxPollCount) {
!needsRetry(currentState) ||
(pollCount >= pollOptions.maxPollCount && !pollStrategy.length)
) {
return currentState return currentState
} }
// INFO: If we get to this point, this is a long-running job that needs longer polling. // If we get to this point, this is a long-running job that needs longer polling.
// We will resume polling with a bigger interval according to the next polling strategy // We will resume polling with a bigger interval of 1 minute
while (pollStrategy.length && needsRetry(currentState)) { let longJobPollOptions: PollOptions = {
defaultPollOptions = pollStrategy.splice(0, 1)[0] maxPollCount: 24 * 60,
pollInterval: 60000,
streamLog: false
}
if (pollOptions) { if (pollOptions) {
defaultPollOptions.logFolderPath = pollOptions.logFolderPath longJobPollOptions.streamLog = pollOptions.streamLog
longJobPollOptions.logFolderPath = pollOptions.logFolderPath
} }
result = await doPoll( result = await doPoll(
@@ -158,18 +98,17 @@ export async function pollJobState(
currentState, currentState,
debug, debug,
pollCount, pollCount,
defaultPollOptions,
authConfig, authConfig,
streamLog, longJobPollOptions,
logFileStream, logFileStream
jobSessionManager
) )
currentState = result.state currentState = result.state
pollCount = result.pollCount pollCount = result.pollCount
}
if (logFileStream) logFileStream.end() if (logFileStream) {
logFileStream.end()
}
return currentState return currentState
} }
@@ -180,13 +119,17 @@ const getJobState = async (
currentState: string, currentState: string,
debug: boolean, debug: boolean,
authConfig?: AuthConfig authConfig?: AuthConfig
): Promise<JobState> => { ) => {
const stateLink = job.links.find((l: any) => l.rel === 'state')! const stateLink = job.links.find((l: any) => l.rel === 'state')
if (!stateLink) {
throw new Error(`Job state link was not found.`)
}
if (needsRetry(currentState)) { if (needsRetry(currentState)) {
let tokens let tokens
if (authConfig) {
if (authConfig) tokens = await getTokens(requestClient, authConfig) tokens = await getTokens(requestClient, authConfig)
}
const { result: jobState } = await requestClient const { result: jobState } = await requestClient
.get<string>( .get<string>(
@@ -200,88 +143,49 @@ const getJobState = async (
throw new JobStatePollError(job.id, err) throw new JobStatePollError(job.id, err)
}) })
return jobState.trim() as JobState return jobState.trim()
} else { } else {
return currentState as JobState return currentState
} }
} }
const needsRetry = (state: string) => const needsRetry = (state: string) =>
state === JobState.Running || state === 'running' ||
state === JobState.NoState || state === '' ||
state === JobState.Pending || state === 'pending' ||
state === JobState.Unavailable state === 'unavailable'
/** const doPoll = async (
* Polls job state.
* @param requestClient - the pre-configured HTTP request client.
* @param postedJob - the relative or absolute path to the job.
* @param currentState - current job state.
* @param debug - sets the _debug flag in the job arguments.
* @param pollCount - current poll count.
* @param pollOptions - an object containing maxPollCount, pollInterval, streamLog and logFolderPath.
* @param authConfig - an access token, refresh token, client and secret for an authorized user.
* @param streamLog - indicates if job log should be streamed.
* @param logStream - job log stream.
* @param jobSessionManager - job session object containing session object and an instance of Session Manager. Job session object is used to periodically (every 10th job state poll) check parent session state. Session state is considered healthy if it is equal to 'running' or 'idle'.
* @returns - a promise which resolves with a job state
*/
export const doPoll = async (
requestClient: RequestClient, requestClient: RequestClient,
postedJob: Job, postedJob: Job,
currentState: JobState, currentState: string,
debug: boolean, debug: boolean,
pollCount: number, pollCount: number,
pollOptions: PollOptions,
authConfig?: AuthConfig, authConfig?: AuthConfig,
streamLog?: boolean, pollOptions?: PollOptions,
logStream?: WriteStream, logStream?: WriteStream
jobSessionManager?: JobSessionManager ): Promise<{ state: string; pollCount: number }> => {
): Promise<{ state: JobState; pollCount: number }> => { let pollInterval = 300
const { maxPollCount, pollInterval } = pollOptions let maxPollCount = 1000
const logger = process.logger || console
const stateLink = postedJob.links.find((l: Link) => l.rel === 'state')!
let maxErrorCount = 5 let maxErrorCount = 5
let errorCount = 0 let errorCount = 0
let state = currentState let state = currentState
let printedState = JobState.NoState let printedState = ''
let startLogLine = 0 let startLogLine = 0
const logger = process.logger || console
if (pollOptions) {
pollInterval = pollOptions.pollInterval || pollInterval
maxPollCount = pollOptions.maxPollCount || maxPollCount
}
const stateLink = postedJob.links.find((l: Link) => l.rel === 'state')
if (!stateLink) {
throw new Error(`Job state link was not found.`)
}
while (needsRetry(state) && pollCount <= maxPollCount) { while (needsRetry(state) && pollCount <= maxPollCount) {
// Check parent session state on every 10th job state poll.
if (jobSessionManager && pollCount && pollCount % 10 === 0 && authConfig) {
const { session, sessionManager } = jobSessionManager
const { stateUrl, etag, id: sessionId } = session
const { access_token } = authConfig
const { id: jobId } = postedJob
// Get session state.
const { result: sessionState, responseStatus } = await sessionManager[
'getSessionState'
](stateUrl, etag, access_token).catch((err) => {
// Handle error while getting session state.
throw new JobStatePollError(jobId, err)
})
// Checks if session state is equal to 'running' or 'idle'.
const isSessionStatesHealthy = (state: string) =>
[SessionState.Running, SessionState.Idle].includes(
state as SessionState
)
// Clear parent session and throw an error if session state is not
// 'running', 'idle' or response status is not 200.
if (!isSessionStatesHealthy(sessionState) || responseStatus !== 200) {
sessionManager.clearSession(sessionId, access_token)
const sessionError = isSessionStatesHealthy(sessionState)
? `Session response status is not 200. Session response status is ${responseStatus}.`
: `Session state of the job is not 'running' or 'idle'. Session state is '${sessionState}'`
throw new JobStatePollError(jobId, new Error(sessionError))
}
}
state = await getJobState( state = await getJobState(
requestClient, requestClient,
postedJob, postedJob,
@@ -290,24 +194,21 @@ export const doPoll = async (
authConfig authConfig
).catch((err) => { ).catch((err) => {
errorCount++ errorCount++
if (pollCount >= maxPollCount || errorCount >= maxErrorCount) { if (pollCount >= maxPollCount || errorCount >= maxErrorCount) {
throw err throw err
} }
logger.error( logger.error(
`Error fetching job state from ${stateLink.href}. Resuming poll, assuming job to be running.`, `Error fetching job state from ${stateLink.href}. Resuming poll, assuming job to be running.`,
err err
) )
return 'unavailable'
return JobState.Unavailable
}) })
pollCount++ pollCount++
const jobHref = postedJob.links.find((l: Link) => l.rel === 'self')!.href const jobHref = postedJob.links.find((l: Link) => l.rel === 'self')!.href
if (streamLog) { if (pollOptions?.streamLog) {
const { result: job } = await requestClient.get<Job>( const { result: job } = await requestClient.get<Job>(
jobHref, jobHref,
authConfig?.access_token authConfig?.access_token
@@ -337,45 +238,12 @@ export const doPoll = async (
printedState = state printedState = state
} }
if (state !== JobState.Unavailable && errorCount > 0) { if (state != 'unavailable' && errorCount > 0) {
errorCount = 0 errorCount = 0
} }
if (state !== JobState.Completed) {
await delay(pollInterval) await delay(pollInterval)
} }
}
return { state, pollCount } return { state, pollCount }
} }
const validatePollStrategies = (strategy: PollStrategy) => {
const throwError = (message?: string, pollOptions?: PollOptions) => {
throw new Error(
`Poll strategies are not valid.${message ? ` ${message}` : ''}${
pollOptions
? ` Invalid poll strategy: \n${JSON.stringify(pollOptions, null, 2)}`
: ''
}`
)
}
strategy.forEach((pollOptions: PollOptions, i: number) => {
const { maxPollCount, pollInterval } = pollOptions
if (maxPollCount < 1) {
throwError(`'maxPollCount' has to be greater than 0.`, pollOptions)
} else if (i !== 0) {
const previousPollOptions = strategy[i - 1]
if (maxPollCount <= previousPollOptions.maxPollCount) {
throwError(
`'maxPollCount' has to be greater than 'maxPollCount' in previous poll strategy.`,
pollOptions
)
}
} else if (pollInterval < 1) {
throwError(`'pollInterval' has to be greater than 0.`, pollOptions)
}
})
}

View File

@@ -1,21 +1,22 @@
import { RequestClient } from '../../../request/RequestClient' import { RequestClient } from '../../../request/RequestClient'
import { SessionManager } from '../../../SessionManager' import { SessionManager } from '../../../SessionManager'
import { executeOnComputeApi } from '../executeOnComputeApi' import { executeScript } from '../executeScript'
import { mockSession, mockAuthConfig, mockJob } from './mockResponses' import { mockSession, mockAuthConfig, mockJob } from './mockResponses'
import * as pollJobStateModule from '../pollJobState' import * as pollJobStateModule from '../pollJobState'
import * as uploadTablesModule from '../uploadTables' import * as uploadTablesModule from '../uploadTables'
import * as getTokensModule from '../../../auth/getTokens' import * as getTokensModule from '../../../auth/getTokens'
import * as formatDataModule from '../../../utils/formatDataForRequest' import * as formatDataModule from '../../../utils/formatDataForRequest'
import * as fetchLogsModule from '../../../utils/fetchLogByChunks' import * as fetchLogsModule from '../../../utils/fetchLogByChunks'
import { PollOptions, JobSessionManager } from '../../../types' import { PollOptions } from '../../../types'
import { ComputeJobExecutionError, NotFoundError } from '../../../types/errors' import { ComputeJobExecutionError, NotFoundError } from '../../../types/errors'
import { Logger, LogLevel } from '@sasjs/utils/logger' import { Logger, LogLevel } from '@sasjs/utils'
const sessionManager = new (<jest.Mock<SessionManager>>SessionManager)() const sessionManager = new (<jest.Mock<SessionManager>>SessionManager)()
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)() const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
const defaultPollOptions: PollOptions = { const defaultPollOptions: PollOptions = {
maxPollCount: 100, maxPollCount: 100,
pollInterval: 500 pollInterval: 500,
streamLog: false
} }
describe('executeScript', () => { describe('executeScript', () => {
@@ -25,7 +26,7 @@ describe('executeScript', () => {
}) })
it('should not try to get fresh tokens if an authConfig is not provided', async () => { it('should not try to get fresh tokens if an authConfig is not provided', async () => {
await executeOnComputeApi( await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -38,7 +39,7 @@ describe('executeScript', () => {
}) })
it('should try to get fresh tokens if an authConfig is provided', async () => { it('should try to get fresh tokens if an authConfig is provided', async () => {
await executeOnComputeApi( await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -55,7 +56,7 @@ describe('executeScript', () => {
}) })
it('should get a session from the session manager before executing', async () => { it('should get a session from the session manager before executing', async () => {
await executeOnComputeApi( await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -72,7 +73,7 @@ describe('executeScript', () => {
.spyOn(sessionManager, 'getSession') .spyOn(sessionManager, 'getSession')
.mockImplementation(() => Promise.reject('Test Error')) .mockImplementation(() => Promise.reject('Test Error'))
const error = await executeOnComputeApi( const error = await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -85,7 +86,7 @@ describe('executeScript', () => {
}) })
it('should fetch the PID when printPid is true', async () => { it('should fetch the PID when printPid is true', async () => {
await executeOnComputeApi( await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -113,7 +114,7 @@ describe('executeScript', () => {
.spyOn(sessionManager, 'getVariable') .spyOn(sessionManager, 'getVariable')
.mockImplementation(() => Promise.reject('Test Error')) .mockImplementation(() => Promise.reject('Test Error'))
const error = await executeOnComputeApi( const error = await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -139,7 +140,7 @@ describe('executeScript', () => {
Promise.resolve([{ tableName: 'test', file: { id: 1 } }]) Promise.resolve([{ tableName: 'test', file: { id: 1 } }])
) )
await executeOnComputeApi( await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -163,7 +164,7 @@ describe('executeScript', () => {
}) })
it('should format data as CSV when it does not contain semicolons', async () => { it('should format data as CSV when it does not contain semicolons', async () => {
await executeOnComputeApi( await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -189,7 +190,7 @@ describe('executeScript', () => {
.spyOn(formatDataModule, 'formatDataForRequest') .spyOn(formatDataModule, 'formatDataForRequest')
.mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' })) .mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' }))
await executeOnComputeApi( await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -217,7 +218,14 @@ describe('executeScript', () => {
sasjs_tables: 'foo', sasjs_tables: 'foo',
sasjs0data: 'bar' sasjs0data: 'bar'
}, },
version: 2 arguments: {
_contextName: 'test context',
_OMITJSONLISTING: true,
_OMITJSONLOG: true,
_OMITSESSIONRESULTS: true,
_OMITTEXTLISTING: true,
_OMITTEXTLOG: true
}
}, },
mockAuthConfig.access_token mockAuthConfig.access_token
) )
@@ -228,7 +236,7 @@ describe('executeScript', () => {
.spyOn(formatDataModule, 'formatDataForRequest') .spyOn(formatDataModule, 'formatDataForRequest')
.mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' })) .mockImplementation(() => ({ sasjs_tables: 'foo', sasjs0data: 'bar' }))
await executeOnComputeApi( await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -257,7 +265,14 @@ describe('executeScript', () => {
sasjs0data: 'bar', sasjs0data: 'bar',
_DEBUG: 131 _DEBUG: 131
}, },
version: 2 arguments: {
_contextName: 'test context',
_OMITJSONLISTING: true,
_OMITJSONLOG: true,
_OMITSESSIONRESULTS: false,
_OMITTEXTLISTING: true,
_OMITTEXTLOG: false
}
}, },
mockAuthConfig.access_token mockAuthConfig.access_token
) )
@@ -268,7 +283,7 @@ describe('executeScript', () => {
.spyOn(requestClient, 'post') .spyOn(requestClient, 'post')
.mockImplementation(() => Promise.reject('Test Error')) .mockImplementation(() => Promise.reject('Test Error'))
const error = await executeOnComputeApi( const error = await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -288,7 +303,7 @@ describe('executeScript', () => {
}) })
it('should immediately return the session when waitForResult is false', async () => { it('should immediately return the session when waitForResult is false', async () => {
const result = await executeOnComputeApi( const result = await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -308,12 +323,7 @@ describe('executeScript', () => {
}) })
it('should poll for job completion when waitForResult is true', async () => { it('should poll for job completion when waitForResult is true', async () => {
const jobSessionManager: JobSessionManager = { await executeScript(
session: mockSession,
sessionManager: sessionManager
}
await executeOnComputeApi(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -334,8 +344,7 @@ describe('executeScript', () => {
mockJob, mockJob,
false, false,
mockAuthConfig, mockAuthConfig,
defaultPollOptions, defaultPollOptions
jobSessionManager
) )
}) })
@@ -344,7 +353,7 @@ describe('executeScript', () => {
.spyOn(pollJobStateModule, 'pollJobState') .spyOn(pollJobStateModule, 'pollJobState')
.mockImplementation(() => Promise.reject('Poll Error')) .mockImplementation(() => Promise.reject('Poll Error'))
const error = await executeOnComputeApi( const error = await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -370,7 +379,7 @@ describe('executeScript', () => {
Promise.reject({ response: { data: 'err=5113,' } }) Promise.reject({ response: { data: 'err=5113,' } })
) )
const error = await executeOnComputeApi( const error = await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -396,7 +405,7 @@ describe('executeScript', () => {
}) })
it('should fetch the logs for the job if debug is true and a log URL is available', async () => { it('should fetch the logs for the job if debug is true and a log URL is available', async () => {
await executeOnComputeApi( await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -421,7 +430,7 @@ describe('executeScript', () => {
}) })
it('should not fetch the logs for the job if debug is false', async () => { it('should not fetch the logs for the job if debug is false', async () => {
await executeOnComputeApi( await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -443,11 +452,9 @@ describe('executeScript', () => {
it('should throw a ComputeJobExecutionError if the job has failed', async () => { it('should throw a ComputeJobExecutionError if the job has failed', async () => {
jest jest
.spyOn(pollJobStateModule, 'pollJobState') .spyOn(pollJobStateModule, 'pollJobState')
.mockImplementation(() => .mockImplementation(() => Promise.resolve('failed'))
Promise.resolve(pollJobStateModule.JobState.Failed)
)
const error: ComputeJobExecutionError = await executeOnComputeApi( const error: ComputeJobExecutionError = await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -478,11 +485,9 @@ describe('executeScript', () => {
it('should throw a ComputeJobExecutionError if the job has errored out', async () => { it('should throw a ComputeJobExecutionError if the job has errored out', async () => {
jest jest
.spyOn(pollJobStateModule, 'pollJobState') .spyOn(pollJobStateModule, 'pollJobState')
.mockImplementation(() => .mockImplementation(() => Promise.resolve('error'))
Promise.resolve(pollJobStateModule.JobState.Error)
)
const error: ComputeJobExecutionError = await executeOnComputeApi( const error: ComputeJobExecutionError = await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -511,7 +516,7 @@ describe('executeScript', () => {
}) })
it('should fetch the result if expectWebout is true', async () => { it('should fetch the result if expectWebout is true', async () => {
await executeOnComputeApi( await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -542,7 +547,7 @@ describe('executeScript', () => {
return Promise.resolve({ result: mockJob, etag: '', status: 200 }) return Promise.resolve({ result: mockJob, etag: '', status: 200 })
}) })
const error = await executeOnComputeApi( const error = await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -576,7 +581,7 @@ describe('executeScript', () => {
}) })
it('should clear the session after execution is complete', async () => { it('should clear the session after execution is complete', async () => {
await executeOnComputeApi( await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -603,7 +608,7 @@ describe('executeScript', () => {
.spyOn(sessionManager, 'clearSession') .spyOn(sessionManager, 'clearSession')
.mockImplementation(() => Promise.reject('Clear Session Error')) .mockImplementation(() => Promise.reject('Clear Session Error'))
const error = await executeOnComputeApi( const error = await executeScript(
requestClient, requestClient,
sessionManager, sessionManager,
'test', 'test',
@@ -649,9 +654,7 @@ const setupMocks = () => {
.mockImplementation(() => Promise.resolve(mockAuthConfig)) .mockImplementation(() => Promise.resolve(mockAuthConfig))
jest jest
.spyOn(pollJobStateModule, 'pollJobState') .spyOn(pollJobStateModule, 'pollJobState')
.mockImplementation(() => .mockImplementation(() => Promise.resolve('completed'))
Promise.resolve(pollJobStateModule.JobState.Completed)
)
jest jest
.spyOn(sessionManager, 'getVariable') .spyOn(sessionManager, 'getVariable')
.mockImplementation(() => .mockImplementation(() =>

View File

@@ -1,16 +1,14 @@
import { AuthConfig } from '@sasjs/utils/types' import { AuthConfig } from '@sasjs/utils/types'
import { Job, Session, SessionState } from '../../../types' import { Job, Session } from '../../../types'
export const mockSession: Session = { export const mockSession: Session = {
id: 's35510n', id: 's35510n',
state: SessionState.Idle, state: 'idle',
stateUrl: '',
links: [], links: [],
attributes: { attributes: {
sessionInactiveTimeout: 1 sessionInactiveTimeout: 1
}, },
creationTimeStamp: new Date().valueOf().toString(), creationTimeStamp: new Date().valueOf().toString()
etag: 'etag-string'
} }
export const mockJob: Job = { export const mockJob: Job = {

View File

@@ -1,31 +1,22 @@
import { Logger, LogLevel } from '@sasjs/utils/logger' import { Logger, LogLevel } from '@sasjs/utils'
import { RequestClient } from '../../../request/RequestClient' import { RequestClient } from '../../../request/RequestClient'
import { mockAuthConfig, mockJob } from './mockResponses' import { mockAuthConfig, mockJob } from './mockResponses'
import { pollJobState, doPoll, JobState } from '../pollJobState' import { pollJobState } from '../pollJobState'
import * as getTokensModule from '../../../auth/getTokens' import * as getTokensModule from '../../../auth/getTokens'
import * as saveLogModule from '../saveLog' import * as saveLogModule from '../saveLog'
import * as getFileStreamModule from '../getFileStream' import * as getFileStreamModule from '../getFileStream'
import * as isNodeModule from '../../../utils/isNode' import * as isNodeModule from '../../../utils/isNode'
import * as delayModule from '../../../utils/delay' import { PollOptions } from '../../../types'
import {
PollOptions,
PollStrategy,
SessionState,
JobSessionManager
} from '../../../types'
import { WriteStream } from 'fs' import { WriteStream } from 'fs'
import { SessionManager } from '../../../SessionManager'
import { JobStatePollError } from '../../../types'
const baseUrl = 'http://localhost' const baseUrl = 'http://localhost'
const requestClient = new (<jest.Mock<RequestClient>>RequestClient)() const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
const sessionManager = new (<jest.Mock<SessionManager>>SessionManager)()
requestClient['httpClient'].defaults.baseURL = baseUrl requestClient['httpClient'].defaults.baseURL = baseUrl
const defaultStreamLog = false const defaultPollOptions: PollOptions = {
const defaultPollStrategy: PollOptions = {
maxPollCount: 100, maxPollCount: 100,
pollInterval: 500 pollInterval: 500,
streamLog: false
} }
describe('pollJobState', () => { describe('pollJobState', () => {
@@ -35,10 +26,13 @@ describe('pollJobState', () => {
}) })
it('should get valid tokens if the authConfig has been provided', async () => { it('should get valid tokens if the authConfig has been provided', async () => {
await pollJobState(requestClient, mockJob, false, mockAuthConfig, { await pollJobState(
...defaultPollStrategy, requestClient,
streamLog: defaultStreamLog mockJob,
}) false,
mockAuthConfig,
defaultPollOptions
)
expect(getTokensModule.getTokens).toHaveBeenCalledWith( expect(getTokensModule.getTokens).toHaveBeenCalledWith(
requestClient, requestClient,
@@ -52,7 +46,7 @@ describe('pollJobState', () => {
mockJob, mockJob,
false, false,
undefined, undefined,
defaultPollStrategy defaultPollOptions
) )
expect(getTokensModule.getTokens).not.toHaveBeenCalled() expect(getTokensModule.getTokens).not.toHaveBeenCalled()
@@ -64,7 +58,7 @@ describe('pollJobState', () => {
{ ...mockJob, links: mockJob.links.filter((l) => l.rel !== 'state') }, { ...mockJob, links: mockJob.links.filter((l) => l.rel !== 'state') },
false, false,
undefined, undefined,
defaultPollStrategy defaultPollOptions
).catch((e: any) => e) ).catch((e: any) => e)
expect((error as Error).message).toContain('Job state link was not found.') expect((error as Error).message).toContain('Job state link was not found.')
@@ -78,7 +72,7 @@ describe('pollJobState', () => {
mockJob, mockJob,
false, false,
mockAuthConfig, mockAuthConfig,
defaultPollStrategy defaultPollOptions
) )
expect(getTokensModule.getTokens).toHaveBeenCalledTimes(3) expect(getTokensModule.getTokens).toHaveBeenCalledTimes(3)
@@ -89,7 +83,7 @@ describe('pollJobState', () => {
const { saveLog } = require('../saveLog') const { saveLog } = require('../saveLog')
await pollJobState(requestClient, mockJob, false, mockAuthConfig, { await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
...defaultPollStrategy, ...defaultPollOptions,
streamLog: true streamLog: true
}) })
@@ -102,7 +96,7 @@ describe('pollJobState', () => {
const { saveLog } = require('../saveLog') const { saveLog } = require('../saveLog')
await pollJobState(requestClient, mockJob, false, mockAuthConfig, { await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
...defaultPollStrategy, ...defaultPollOptions,
streamLog: true streamLog: true
}) })
@@ -117,7 +111,7 @@ describe('pollJobState', () => {
const { getFileStream } = require('../getFileStream') const { getFileStream } = require('../getFileStream')
await pollJobState(requestClient, mockJob, false, mockAuthConfig, { await pollJobState(requestClient, mockJob, false, mockAuthConfig, {
...defaultPollStrategy, ...defaultPollOptions,
streamLog: true streamLog: true
}) })
@@ -133,7 +127,7 @@ describe('pollJobState', () => {
mockJob, mockJob,
false, false,
mockAuthConfig, mockAuthConfig,
defaultPollStrategy defaultPollOptions
) )
expect(saveLogModule.saveLog).not.toHaveBeenCalled() expect(saveLogModule.saveLog).not.toHaveBeenCalled()
@@ -142,18 +136,15 @@ describe('pollJobState', () => {
it('should return the current status when the max poll count is reached', async () => { it('should return the current status when the max poll count is reached', async () => {
mockRunningPoll() mockRunningPoll()
const pollOptions: PollOptions = {
...defaultPollStrategy,
maxPollCount: 1,
pollStrategy: []
}
const state = await pollJobState( const state = await pollJobState(
requestClient, requestClient,
mockJob, mockJob,
false, false,
mockAuthConfig, mockAuthConfig,
pollOptions {
...defaultPollOptions,
maxPollCount: 1
}
) )
expect(state).toEqual('running') expect(state).toEqual('running')
@@ -168,7 +159,7 @@ describe('pollJobState', () => {
false, false,
mockAuthConfig, mockAuthConfig,
{ {
...defaultPollStrategy, ...defaultPollOptions,
maxPollCount: 200, maxPollCount: 200,
pollInterval: 10 pollInterval: 10
} }
@@ -185,7 +176,7 @@ describe('pollJobState', () => {
mockJob, mockJob,
false, false,
undefined, undefined,
defaultPollStrategy defaultPollOptions
) )
expect(requestClient.get).toHaveBeenCalledTimes(2) expect(requestClient.get).toHaveBeenCalledTimes(2)
@@ -201,7 +192,7 @@ describe('pollJobState', () => {
mockJob, mockJob,
true, true,
undefined, undefined,
defaultPollStrategy defaultPollOptions
) )
expect((process as any).logger.info).toHaveBeenCalledTimes(4) expect((process as any).logger.info).toHaveBeenCalledTimes(4)
@@ -231,7 +222,7 @@ describe('pollJobState', () => {
mockJob, mockJob,
false, false,
undefined, undefined,
defaultPollStrategy defaultPollOptions
) )
expect(requestClient.get).toHaveBeenCalledTimes(2) expect(requestClient.get).toHaveBeenCalledTimes(2)
@@ -246,401 +237,13 @@ describe('pollJobState', () => {
mockJob, mockJob,
false, false,
undefined, undefined,
defaultPollStrategy defaultPollOptions
).catch((e: any) => e) ).catch((e: any) => e)
expect(error.message).toEqual( expect(error.message).toEqual(
'Error while polling job state for job j0b: Status Error' 'Error while polling job state for job j0b: Status Error'
) )
}) })
it('should change poll strategies', async () => {
mockSimplePoll(6)
const delays: number[] = []
jest.spyOn(delayModule, 'delay').mockImplementation((ms: number) => {
delays.push(ms)
return Promise.resolve()
})
const pollIntervals = [3, 4, 5, 6]
const pollStrategy = [
{ maxPollCount: 2, pollInterval: pollIntervals[1] },
{ maxPollCount: 3, pollInterval: pollIntervals[2] },
{ maxPollCount: 4, pollInterval: pollIntervals[3] }
]
const pollOptions: PollOptions = {
maxPollCount: 1,
pollInterval: pollIntervals[0],
pollStrategy: pollStrategy
}
await pollJobState(requestClient, mockJob, false, undefined, pollOptions)
expect(delays).toEqual([pollIntervals[0], ...pollIntervals])
})
it('should change default poll strategies after completing provided poll options', async () => {
const delays: number[] = []
jest.spyOn(delayModule, 'delay').mockImplementation((ms: number) => {
delays.push(ms)
return Promise.resolve()
})
const customPollOptions: PollOptions = {
maxPollCount: 0,
pollInterval: 0
}
const requests = [
{ maxPollCount: 202, pollInterval: 300 },
{ maxPollCount: 300, pollInterval: 3000 },
{ maxPollCount: 500, pollInterval: 30000 },
{ maxPollCount: 3400, pollInterval: 60000 }
]
// ~200 requests with delay 300ms
let request = requests.splice(0, 1)[0]
let { maxPollCount, pollInterval } = request
// should be only one interval because maxPollCount is equal to 0
const pollIntervals = [customPollOptions.pollInterval]
pollIntervals.push(...Array(maxPollCount - 2).fill(pollInterval))
// ~300 requests with delay 3000
request = requests.splice(0, 1)[0]
let newAmount = request.maxPollCount
pollInterval = request.pollInterval
pollIntervals.push(...Array(newAmount - maxPollCount).fill(pollInterval))
pollIntervals.push(...Array(2).fill(pollInterval))
// ~500 requests with delay 30000
request = requests.splice(0, 1)[0]
let oldAmount = newAmount
newAmount = request.maxPollCount
pollInterval = request.pollInterval
pollIntervals.push(...Array(newAmount - oldAmount - 2).fill(pollInterval))
pollIntervals.push(...Array(2).fill(pollInterval))
// ~3400 requests with delay 60000
request = requests.splice(0, 1)[0]
oldAmount = newAmount
newAmount = request.maxPollCount
pollInterval = request.pollInterval
mockSimplePoll(newAmount)
pollIntervals.push(...Array(newAmount - oldAmount - 2).fill(pollInterval))
await pollJobState(
requestClient,
mockJob,
false,
undefined,
customPollOptions
)
expect(delays).toEqual(pollIntervals)
})
it('should throw an error if not valid poll strategies provided', async () => {
// INFO: 'maxPollCount' has to be > 0
let invalidPollStrategy = {
maxPollCount: 0,
pollInterval: 3
}
let pollStrategy: PollStrategy = [invalidPollStrategy]
let expectedError = new Error(
`Poll strategies are not valid. 'maxPollCount' has to be greater than 0. Invalid poll strategy: \n${JSON.stringify(
invalidPollStrategy,
null,
2
)}`
)
await expect(
pollJobState(requestClient, mockJob, false, undefined, {
...defaultPollStrategy,
pollStrategy: pollStrategy
})
).rejects.toThrow(expectedError)
// INFO: 'maxPollCount' has to be > than 'maxPollCount' of the previous strategy
const validPollStrategy = {
maxPollCount: 5,
pollInterval: 2
}
invalidPollStrategy = {
maxPollCount: validPollStrategy.maxPollCount,
pollInterval: 3
}
pollStrategy = [validPollStrategy, invalidPollStrategy]
expectedError = new Error(
`Poll strategies are not valid. 'maxPollCount' has to be greater than 'maxPollCount' in previous poll strategy. Invalid poll strategy: \n${JSON.stringify(
invalidPollStrategy,
null,
2
)}`
)
await expect(
pollJobState(requestClient, mockJob, false, undefined, {
...defaultPollStrategy,
pollStrategy: pollStrategy
})
).rejects.toThrow(expectedError)
// INFO: invalid 'pollInterval'
invalidPollStrategy = {
maxPollCount: 1,
pollInterval: 0
}
pollStrategy = [invalidPollStrategy]
expectedError = new Error(
`Poll strategies are not valid. 'pollInterval' has to be greater than 0. Invalid poll strategy: \n${JSON.stringify(
invalidPollStrategy,
null,
2
)}`
)
await expect(
pollJobState(requestClient, mockJob, false, undefined, {
...defaultPollStrategy,
pollStrategy: pollStrategy
})
).rejects.toThrow(expectedError)
})
})
describe('doPoll', () => {
const sessionStateLink = '/compute/sessions/session-id-ses0000/state'
const jobSessionManager: JobSessionManager = {
sessionManager,
session: {
id: ['id', new Date().getTime(), Math.random()].join('-'),
state: SessionState.NoState,
links: [
{
href: sessionStateLink,
method: 'GET',
rel: 'state',
type: 'text/plain',
uri: sessionStateLink
}
],
attributes: {
sessionInactiveTimeout: 900
},
creationTimeStamp: `${new Date(new Date().getTime()).toISOString()}`,
stateUrl: '',
etag: ''
}
}
beforeEach(() => {
setupMocks()
})
it('should check session state on every 10th job state poll', async () => {
const mockedGetSessionState = jest
.spyOn(sessionManager as any, 'getSessionState')
.mockImplementation(() => {
return Promise.resolve({
result: SessionState.Idle,
responseStatus: 200
})
})
let getSessionStateCount = 0
jest.spyOn(requestClient, 'get').mockImplementation(() => {
getSessionStateCount++
return Promise.resolve({
result:
getSessionStateCount < 20 ? JobState.Running : JobState.Completed,
etag: 'etag-string',
status: 200
})
})
await doPoll(
requestClient,
mockJob,
JobState.Running,
false,
1,
defaultPollStrategy,
mockAuthConfig,
undefined,
undefined,
jobSessionManager
)
expect(mockedGetSessionState).toHaveBeenCalledTimes(2)
})
it('should handle error while checking session state', async () => {
const sessionStateError = 'Error while getting session state.'
jest
.spyOn(sessionManager as any, 'getSessionState')
.mockImplementation(() => {
return Promise.reject(sessionStateError)
})
jest.spyOn(requestClient, 'get').mockImplementation(() => {
return Promise.resolve({
result: JobState.Running,
etag: 'etag-string',
status: 200
})
})
await expect(
doPoll(
requestClient,
mockJob,
JobState.Running,
false,
1,
defaultPollStrategy,
mockAuthConfig,
undefined,
undefined,
jobSessionManager
)
).rejects.toEqual(
new JobStatePollError(mockJob.id, new Error(sessionStateError))
)
})
it('should throw an error if session state is not healthy', async () => {
const filteredSessionStates = Object.values(SessionState).filter(
(state) => state !== SessionState.Running && state !== SessionState.Idle
)
const randomSessionState =
filteredSessionStates[
Math.floor(Math.random() * filteredSessionStates.length)
]
jest
.spyOn(sessionManager as any, 'getSessionState')
.mockImplementation(() => {
return Promise.resolve({
result: randomSessionState,
responseStatus: 200
})
})
jest.spyOn(requestClient, 'get').mockImplementation(() => {
return Promise.resolve({
result: JobState.Running,
etag: 'etag-string',
status: 200
})
})
const mockedClearSession = jest
.spyOn(sessionManager, 'clearSession')
.mockImplementation(() => Promise.resolve())
await expect(
doPoll(
requestClient,
mockJob,
JobState.Running,
false,
1,
defaultPollStrategy,
mockAuthConfig,
undefined,
undefined,
jobSessionManager
)
).rejects.toEqual(
new JobStatePollError(
mockJob.id,
new Error(
`Session state of the job is not 'running' or 'idle'. Session state is '${randomSessionState}'`
)
)
)
expect(mockedClearSession).toHaveBeenCalledWith(
jobSessionManager.session.id,
mockAuthConfig.access_token
)
})
it('should handle throw an error if response status of session state is not 200', async () => {
const sessionStateResponseStatus = 500
jest
.spyOn(sessionManager as any, 'getSessionState')
.mockImplementation(() => {
return Promise.resolve({
result: SessionState.Running,
responseStatus: sessionStateResponseStatus
})
})
jest.spyOn(requestClient, 'get').mockImplementation(() => {
return Promise.resolve({
result: JobState.Running,
etag: 'etag-string',
status: 200
})
})
const mockedClearSession = jest
.spyOn(sessionManager, 'clearSession')
.mockImplementation(() => Promise.resolve())
await expect(
doPoll(
requestClient,
mockJob,
JobState.Running,
false,
1,
defaultPollStrategy,
mockAuthConfig,
undefined,
undefined,
jobSessionManager
)
).rejects.toEqual(
new JobStatePollError(
mockJob.id,
new Error(
`Session response status is not 200. Session response status is ${sessionStateResponseStatus}.`
)
)
)
expect(mockedClearSession).toHaveBeenCalledWith(
jobSessionManager.session.id,
mockAuthConfig.access_token
)
})
}) })
const setupMocks = () => { const setupMocks = () => {
@@ -670,14 +273,11 @@ const setupMocks = () => {
const mockSimplePoll = (runningCount = 2) => { const mockSimplePoll = (runningCount = 2) => {
let count = 0 let count = 0
jest.spyOn(requestClient, 'get').mockImplementation((url) => { jest.spyOn(requestClient, 'get').mockImplementation((url) => {
count++ count++
if (url.includes('job')) { if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 }) return Promise.resolve({ result: mockJob, etag: '', status: 200 })
} }
return Promise.resolve({ return Promise.resolve({
result: result:
count === 0 count === 0
@@ -693,14 +293,11 @@ const mockSimplePoll = (runningCount = 2) => {
const mockRunningPoll = () => { const mockRunningPoll = () => {
let count = 0 let count = 0
jest.spyOn(requestClient, 'get').mockImplementation((url) => { jest.spyOn(requestClient, 'get').mockImplementation((url) => {
count++ count++
if (url.includes('job')) { if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 }) return Promise.resolve({ result: mockJob, etag: '', status: 200 })
} }
return Promise.resolve({ return Promise.resolve({
result: count === 0 ? 'pending' : 'running', result: count === 0 ? 'pending' : 'running',
etag: '', etag: '',
@@ -711,14 +308,11 @@ const mockRunningPoll = () => {
const mockLongPoll = () => { const mockLongPoll = () => {
let count = 0 let count = 0
jest.spyOn(requestClient, 'get').mockImplementation((url) => { jest.spyOn(requestClient, 'get').mockImplementation((url) => {
count++ count++
if (url.includes('job')) { if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 }) return Promise.resolve({ result: mockJob, etag: '', status: 200 })
} }
return Promise.resolve({ return Promise.resolve({
result: count <= 102 ? 'running' : 'completed', result: count <= 102 ? 'running' : 'completed',
etag: '', etag: '',
@@ -729,18 +323,14 @@ const mockLongPoll = () => {
const mockPollWithSingleError = () => { const mockPollWithSingleError = () => {
let count = 0 let count = 0
jest.spyOn(requestClient, 'get').mockImplementation((url) => { jest.spyOn(requestClient, 'get').mockImplementation((url) => {
count++ count++
if (url.includes('job')) { if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 }) return Promise.resolve({ result: mockJob, etag: '', status: 200 })
} }
if (count === 1) { if (count === 1) {
return Promise.reject('Status Error') return Promise.reject('Status Error')
} }
return Promise.resolve({ return Promise.resolve({
result: count === 0 ? 'pending' : 'completed', result: count === 0 ? 'pending' : 'completed',
etag: '', etag: '',
@@ -754,7 +344,6 @@ const mockErroredPoll = () => {
if (url.includes('job')) { if (url.includes('job')) {
return Promise.resolve({ result: mockJob, etag: '', status: 200 }) return Promise.resolve({ result: mockJob, etag: '', status: 200 })
} }
return Promise.reject('Status Error') return Promise.reject('Status Error')
}) })
} }

View File

@@ -1,4 +1,4 @@
import { Logger, LogLevel } from '@sasjs/utils/logger' import { Logger, LogLevel } from '@sasjs/utils'
import { RequestClient } from '../../../request/RequestClient' import { RequestClient } from '../../../request/RequestClient'
import * as fetchLogsModule from '../../../utils/fetchLogByChunks' import * as fetchLogsModule from '../../../utils/fetchLogByChunks'
import * as writeStreamModule from '../writeStream' import * as writeStreamModule from '../writeStream'
@@ -69,5 +69,5 @@ const setupMocks = () => {
.mockImplementation(() => Promise.resolve('Test Log')) .mockImplementation(() => Promise.resolve('Test Log'))
jest jest
.spyOn(writeStreamModule, 'writeStream') .spyOn(writeStreamModule, 'writeStream')
.mockImplementation(() => Promise.resolve(true)) .mockImplementation(() => Promise.resolve())
} }

View File

@@ -5,27 +5,17 @@ import {
fileExists, fileExists,
readFile, readFile,
deleteFile deleteFile
} from '@sasjs/utils/file' } from '@sasjs/utils'
describe('writeStream', () => { describe('writeStream', () => {
const filename = 'test.txt' const filename = 'test.txt'
const content = 'test' const content = 'test'
let stream: WriteStream let stream: WriteStream
beforeAll(async () => { beforeAll(async () => {
stream = await createWriteStream(filename) stream = await createWriteStream(filename)
}) })
beforeEach(async () => {
await deleteFile(filename).catch(() => {}) // Ignore errors if the file doesn't exist
stream = await createWriteStream(filename)
})
afterEach(async () => {
await deleteFile(filename).catch(() => {}) // Ensure cleanup after test
})
it('should resolve when the stream is written successfully', async () => { it('should resolve when the stream is written successfully', async () => {
await expect(writeStream(stream, content)).toResolve() await expect(writeStream(stream, content)).toResolve()
await expect(fileExists(filename)).resolves.toEqual(true) await expect(fileExists(filename)).resolves.toEqual(true)
@@ -35,30 +25,11 @@ describe('writeStream', () => {
}) })
it('should reject when the write errors out', async () => { it('should reject when the write errors out', async () => {
// Mock implementation of the write method
jest jest
.spyOn(stream, 'write') .spyOn(stream, 'write')
.mockImplementation( .mockImplementation((_, callback) => callback(new Error('Test Error')))
(
chunk: any,
encodingOrCb?:
| BufferEncoding
| ((error: Error | null | undefined) => void),
cb?: (error: Error | null | undefined) => void
) => {
const callback =
typeof encodingOrCb === 'function' ? encodingOrCb : cb
if (callback) {
callback(new Error('Test Error')) // Simulate an error
}
return true // Simulate that the write operation was called
}
)
// Call the writeStream function and catch the error
const error = await writeStream(stream, content).catch((e: any) => e) const error = await writeStream(stream, content).catch((e: any) => e)
// Assert that the error is correctly handled
expect(error.message).toEqual('Test Error') expect(error.message).toEqual('Test Error')
}) })
}) })

View File

@@ -3,14 +3,9 @@ import { WriteStream } from '../../types'
export const writeStream = async ( export const writeStream = async (
stream: WriteStream, stream: WriteStream,
content: string content: string
): Promise<boolean> => { ): Promise<void> =>
return new Promise((resolve, reject) => { stream.write(content + '\n', (e: any) => {
stream.write(content + '\n', (err: Error | null | undefined) => { if (e) return Promise.reject(e)
if (err) {
reject(err) // Reject on write error return Promise.resolve()
} else {
resolve(true) // Resolve on successful write
}
}) })
})
}

View File

@@ -7,7 +7,6 @@ import { extractUserLongNameSas9 } from '../utils/sas9/extractUserLongNameSas9'
import { openWebPage } from './openWebPage' import { openWebPage } from './openWebPage'
import { verifySas9Login } from './verifySas9Login' import { verifySas9Login } from './verifySas9Login'
import { verifySasViyaLogin } from './verifySasViyaLogin' import { verifySasViyaLogin } from './verifySasViyaLogin'
import { isLogInSuccessHeaderPresent } from './'
export class AuthManager { export class AuthManager {
public userName = '' public userName = ''
@@ -15,7 +14,6 @@ export class AuthManager {
private loginUrl: string private loginUrl: string
private logoutUrl: string private logoutUrl: string
private redirectedLoginUrl = `/SASLogon` //SAS 9 M8 no longer redirects from `/SASLogon/home` to the login page. `/SASLogon` seems to be stable enough across SAS versions private redirectedLoginUrl = `/SASLogon` //SAS 9 M8 no longer redirects from `/SASLogon/home` to the login page. `/SASLogon` seems to be stable enough across SAS versions
constructor( constructor(
private serverUrl: string, private serverUrl: string,
private serverType: ServerType, private serverType: ServerType,
@@ -29,8 +27,6 @@ export class AuthManager {
: this.serverType === ServerType.SasViya : this.serverType === ServerType.SasViya
? '/SASLogon/logout.do?' ? '/SASLogon/logout.do?'
: '/SASLogon/logout' : '/SASLogon/logout'
this.redirectedLoginUrl = this.serverUrl + this.redirectedLoginUrl
} }
/** /**
@@ -133,7 +129,7 @@ export class AuthManager {
let loginResponse = await this.sendLoginRequest(loginForm, loginParams) let loginResponse = await this.sendLoginRequest(loginForm, loginParams)
let isLoggedIn = isLogInSuccessHeaderPresent(this.serverType, loginResponse) let isLoggedIn = isLogInSuccess(this.serverType, loginResponse)
if (!isLoggedIn) { if (!isLoggedIn) {
if (isCredentialsVerifyError(loginResponse)) { if (isCredentialsVerifyError(loginResponse)) {
@@ -218,7 +214,7 @@ export class AuthManager {
* - a boolean `isLoggedIn` * - a boolean `isLoggedIn`
* - a string `userName`, * - a string `userName`,
* - a string `userFullName` and * - a string `userFullName` and
* - a form `loginForm` if not loggedIn. * - a form `loginForm` if not loggedin.
*/ */
public async checkSession(): Promise<LoginResultInternal> { public async checkSession(): Promise<LoginResultInternal> {
const { isLoggedIn, userName, userLongName } = await this.fetchUserName() const { isLoggedIn, userName, userLongName } = await this.fetchUserName()
@@ -385,3 +381,9 @@ const isCredentialsVerifyError = (response: string): boolean =>
/An error occurred while the system was verifying your credentials. Please enter your credentials again./gm.test( /An error occurred while the system was verifying your credentials. Please enter your credentials again./gm.test(
response response
) )
const isLogInSuccess = (serverType: ServerType, response: any): boolean => {
if (serverType === ServerType.Sasjs) return response?.loggedin
return /You have signed in/gm.test(response)
}

View File

@@ -1,7 +1,7 @@
import { prefixMessage } from '@sasjs/utils/error' import { prefixMessage } from '@sasjs/utils/error'
import { RequestClient } from '../request/RequestClient' import { RequestClient } from '../request/RequestClient'
import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix' import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix'
import { ServerType } from '@sasjs/utils/types' import { ServerType } from '@sasjs/utils'
/** /**
* Exchanges the auth code for an access token for the given client. * Exchanges the auth code for an access token for the given client.

View File

@@ -4,6 +4,7 @@ import { RequestClient } from '../request/RequestClient'
import { CertificateError } from '../types/errors' import { CertificateError } from '../types/errors'
import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix' import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix'
// TODO: update func docs
/** /**
* Exchange the auth code for access / refresh tokens for the given client / secret pair. * Exchange the auth code for access / refresh tokens for the given client / secret pair.
* @param requestClient - the pre-configured HTTP request client. * @param requestClient - the pre-configured HTTP request client.
@@ -30,11 +31,10 @@ export async function getAccessTokenForViya(
Authorization: 'Basic ' + token, Authorization: 'Basic ' + token,
Accept: 'application/json' Accept: 'application/json'
} }
const dataJson = {
const dataJson = new URLSearchParams({
grant_type: 'authorization_code', grant_type: 'authorization_code',
code: authCode code: authCode
}) }
const data = new URLSearchParams(dataJson) const data = new URLSearchParams(dataJson)
const authResponse = await requestClient const authResponse = await requestClient

View File

@@ -1,4 +1,3 @@
export * from './AuthManager' export * from './AuthManager'
export * from './isAuthorizeFormRequired' export * from './isAuthorizeFormRequired'
export * from './isLoginRequired' export * from './isLoginRequired'
export * from './loginHeader'

View File

@@ -1,97 +0,0 @@
import { ServerType } from '@sasjs/utils/types'
import { getUserLanguage } from '../utils'
const enLoginSuccessHeader = 'You have signed in.'
export const defaultSuccessHeaderKey = 'default'
// The following headers provided by https://github.com/sasjs/adapter/issues/835#issuecomment-2177818601
export const loginSuccessHeaders: { [key: string]: string } = {
es: `Ya se ha iniciado la sesi\u00f3n.`,
th: `\u0e04\u0e38\u0e13\u0e25\u0e07\u0e0a\u0e37\u0e48\u0e2d\u0e40\u0e02\u0e49\u0e32\u0e43\u0e0a\u0e49\u0e41\u0e25\u0e49\u0e27`,
ja: `\u30b5\u30a4\u30f3\u30a4\u30f3\u3057\u307e\u3057\u305f\u3002`,
nb: `Du har logget deg p\u00e5.`,
sl: `Prijavili ste se.`,
ar: `\u0644\u0642\u062f \u0642\u0645\u062a `,
sk: `Prihl\u00e1sili ste sa.`,
zh_HK: `\u60a8\u5df2\u767b\u5165\u3002`,
zh_CN: `\u60a8\u5df2\u767b\u5f55\u3002`,
it: `L'utente si \u00e8 connesso.`,
sv: `Du har loggat in.`,
he: `\u05e0\u05db\u05e0\u05e1\u05ea `,
nl: `U hebt zich aangemeld.`,
pl: `Zosta\u0142e\u015b zalogowany.`,
ko: `\ub85c\uadf8\uc778\ud588\uc2b5\ub2c8\ub2e4.`,
zh_TW: `\u60a8\u5df2\u767b\u5165\u3002`,
tr: `Oturum a\u00e7t\u0131n\u0131z.`,
iw: `\u05e0\u05db\u05e0\u05e1\u05ea `,
fr: `Vous \u00eates connect\u00e9.`,
uk: `\u0412\u0438 \u0432\u0432\u0456\u0439\u0448\u043b\u0438 \u0432 \u043e\u0431\u043b\u0456\u043a\u043e\u0432\u0438\u0439 \u0437\u0430\u043f\u0438\u0441.`,
pt_BR: `Voc\u00ea se conectou.`,
no: `Du har logget deg p\u00e5.`,
cs: `Jste p\u0159ihl\u00e1\u0161eni.`,
fi: `Olet kirjautunut sis\u00e4\u00e4n.`,
ru: `\u0412\u044b \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u043b\u0438 \u0432\u0445\u043e\u0434 \u0432 \u0441\u0438\u0441\u0442\u0435\u043c\u0443.`,
el: `\u0388\u03c7\u03b5\u03c4\u03b5 \u03c3\u03c5\u03bd\u03b4\u03b5\u03b8\u03b5\u03af.`,
hr: `Prijavili ste se.`,
da: `Du er logget p\u00e5.`,
de: `Sie sind jetzt angemeldet.`,
sh: `Prijavljeni ste.`,
pt: `Iniciou sess\u00e3o.`,
hu: `Bejelentkezett.`,
sr: `Prijavljeni ste.`,
en: enLoginSuccessHeader,
[defaultSuccessHeaderKey]: enLoginSuccessHeader
}
/**
* Provides expected login header based on language settings of the browser.
* @returns - expected header as a string.
*/
export const getExpectedLogInSuccessHeader = (): string => {
// get default success header
let successHeader = loginSuccessHeaders[defaultSuccessHeaderKey]
// get user language based on language settings of the browser
const userLang = getUserLanguage()
if (userLang) {
// get success header on exact match of the language code
let userLangSuccessHeader = loginSuccessHeaders[userLang]
// handle case when there is no exact match of the language code
if (!userLangSuccessHeader) {
// get all supported language codes
const headerLanguages = Object.keys(loginSuccessHeaders)
// find language code on partial match
const headerLanguage = headerLanguages.find((language) =>
new RegExp(language, 'i').test(userLang)
)
// reassign success header if partial match was found
if (headerLanguage) {
successHeader = loginSuccessHeaders[headerLanguage]
}
} else {
successHeader = userLangSuccessHeader
}
}
return successHeader
}
/**
* Checks if Login success header is present in the response based on language settings of the browser.
* @param serverType - server type.
* @param response - response object.
* @returns - boolean indicating if Login success header is present.
*/
export const isLogInSuccessHeaderPresent = (
serverType: ServerType,
response: any
): boolean => {
if (serverType === ServerType.Sasjs) return response?.loggedIn
return new RegExp(getExpectedLogInSuccessHeader(), 'gm').test(response)
}

View File

@@ -1,7 +1,7 @@
import { prefixMessage } from '@sasjs/utils/error' import { prefixMessage } from '@sasjs/utils/error'
import { RequestClient } from '../request/RequestClient' import { RequestClient } from '../request/RequestClient'
import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix' import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix'
import { ServerType } from '@sasjs/utils/types' import { ServerType } from '@sasjs/utils'
/** /**
* Exchanges the refresh token for an access token for the given client. * Exchanges the refresh token for an access token for the given client.

View File

@@ -1,6 +1,6 @@
import { SasAuthResponse, ServerType } from '@sasjs/utils/types' import { SasAuthResponse, ServerType } from '@sasjs/utils/types'
import { prefixMessage } from '@sasjs/utils/error' import { prefixMessage } from '@sasjs/utils/error'
import NodeFormData from 'form-data' import * as NodeFormData from 'form-data'
import { RequestClient } from '../request/RequestClient' import { RequestClient } from '../request/RequestClient'
import { isNode } from '../utils' import { isNode } from '../utils'
import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix' import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix'

View File

@@ -1,22 +1,17 @@
/**
* @jest-environment jsdom
*/
import { AuthManager } from '../AuthManager' import { AuthManager } from '../AuthManager'
import * as dotenv from 'dotenv' import * as dotenv from 'dotenv'
import { ServerType } from '@sasjs/utils/types' import { ServerType } from '@sasjs/utils/types'
import axios from 'axios' import axios from 'axios'
import { import {
mockedCurrentUserApi, mockedCurrentUserApi,
mockLoginAuthoriseRequiredResponse mockLoginAuthoriseRequiredResponse,
mockLoginSuccessResponse
} from './mockResponses' } from './mockResponses'
import { serialize } from '../../utils' import { serialize } from '../../utils'
import * as openWebPageModule from '../openWebPage' import * as openWebPageModule from '../openWebPage'
import * as verifySasViyaLoginModule from '../verifySasViyaLogin' import * as verifySasViyaLoginModule from '../verifySasViyaLogin'
import * as verifySas9LoginModule from '../verifySas9Login' import * as verifySas9LoginModule from '../verifySas9Login'
import { RequestClient } from '../../request/RequestClient' import { RequestClient } from '../../request/RequestClient'
import { getExpectedLogInSuccessHeader } from '../'
jest.mock('axios') jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios> const mockedAxios = axios as jest.Mocked<typeof axios>
@@ -130,7 +125,6 @@ describe('AuthManager', () => {
requestClient, requestClient,
authCallback authCallback
) )
jest.spyOn(authManager, 'checkSession').mockImplementation(() => jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({ Promise.resolve({
isLoggedIn: false, isLoggedIn: false,
@@ -139,9 +133,8 @@ describe('AuthManager', () => {
loginForm: { name: 'test' } loginForm: { name: 'test' }
}) })
) )
mockedAxios.post.mockImplementation(() => mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: getExpectedLogInSuccessHeader() }) Promise.resolve({ data: mockLoginSuccessResponse })
) )
const loginResponse = await authManager.logIn(userName, password) const loginResponse = await authManager.logIn(userName, password)
@@ -159,7 +152,7 @@ describe('AuthManager', () => {
`/SASLogon/login`, `/SASLogon/login`,
loginParams, loginParams,
{ {
withXSRFToken: true, withCredentials: true,
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Accept: '*/*' Accept: '*/*'
@@ -177,7 +170,6 @@ describe('AuthManager', () => {
requestClient, requestClient,
authCallback authCallback
) )
jest.spyOn(authManager, 'checkSession').mockImplementation(() => jest.spyOn(authManager, 'checkSession').mockImplementation(() =>
Promise.resolve({ Promise.resolve({
isLoggedIn: false, isLoggedIn: false,
@@ -186,9 +178,8 @@ describe('AuthManager', () => {
loginForm: { name: 'test' } loginForm: { name: 'test' }
}) })
) )
mockedAxios.post.mockImplementation(() => mockedAxios.post.mockImplementation(() =>
Promise.resolve({ data: getExpectedLogInSuccessHeader() }) Promise.resolve({ data: mockLoginSuccessResponse })
) )
mockedAxios.get.mockImplementation(() => Promise.resolve({ status: 200 })) mockedAxios.get.mockImplementation(() => Promise.resolve({ status: 200 }))
@@ -207,7 +198,7 @@ describe('AuthManager', () => {
`/SASLogon/login`, `/SASLogon/login`,
loginParams, loginParams,
{ {
withXSRFToken: true, withCredentials: true,
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Accept: '*/*' Accept: '*/*'
@@ -256,7 +247,7 @@ describe('AuthManager', () => {
`/SASLogon/login`, `/SASLogon/login`,
loginParams, loginParams,
{ {
withXSRFToken: true, withCredentials: true,
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Accept: '*/*' Accept: '*/*'
@@ -374,7 +365,7 @@ describe('AuthManager', () => {
expect(loginResponse.userName).toEqual(userName) expect(loginResponse.userName).toEqual(userName)
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith( expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
`${serverUrl}/SASLogon`, `/SASLogon`,
'SASLogon', 'SASLogon',
{ {
width: 500, width: 500,
@@ -418,7 +409,7 @@ describe('AuthManager', () => {
expect(loginResponse.userName).toEqual(userName) expect(loginResponse.userName).toEqual(userName)
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith( expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
`${serverUrl}/SASLogon`, `/SASLogon`,
'SASLogon', 'SASLogon',
{ {
width: 500, width: 500,
@@ -462,7 +453,7 @@ describe('AuthManager', () => {
expect(loginResponse.userName).toEqual('') expect(loginResponse.userName).toEqual('')
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith( expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
`${serverUrl}/SASLogon`, `/SASLogon`,
'SASLogon', 'SASLogon',
{ {
width: 500, width: 500,
@@ -506,7 +497,7 @@ describe('AuthManager', () => {
expect(loginResponse.userName).toEqual('') expect(loginResponse.userName).toEqual('')
expect(openWebPageModule.openWebPage).toHaveBeenCalledWith( expect(openWebPageModule.openWebPage).toHaveBeenCalledWith(
`${serverUrl}/SASLogon`, `/SASLogon`,
'SASLogon', 'SASLogon',
{ {
width: 500, width: 500,
@@ -539,7 +530,7 @@ describe('AuthManager', () => {
1, 1,
`http://test-server.com/identities/users/@currentUser`, `http://test-server.com/identities/users/@currentUser`,
{ {
withXSRFToken: true, withCredentials: true,
responseType: 'text', responseType: 'text',
transformResponse: undefined, transformResponse: undefined,
headers: { headers: {
@@ -573,7 +564,7 @@ describe('AuthManager', () => {
1, 1,
`http://test-server.com/SASStoredProcess`, `http://test-server.com/SASStoredProcess`,
{ {
withXSRFToken: true, withCredentials: true,
responseType: 'text', responseType: 'text',
transformResponse: undefined, transformResponse: undefined,
headers: { headers: {
@@ -602,7 +593,7 @@ describe('AuthManager', () => {
1, 1,
`http://test-server.com/identities/users/@currentUser`, `http://test-server.com/identities/users/@currentUser`,
{ {
withXSRFToken: true, withCredentials: true,
responseType: 'text', responseType: 'text',
transformResponse: undefined, transformResponse: undefined,
headers: { headers: {
@@ -621,7 +612,7 @@ describe('AuthManager', () => {
}) })
const getHeadersJson = { const getHeadersJson = {
withXSRFToken: true, withCredentials: true,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json' Accept: 'application/json'

View File

@@ -1,4 +1,4 @@
import { AuthConfig } from '@sasjs/utils/types' import { AuthConfig } from '@sasjs/utils'
import { generateToken, mockSasjsAuthResponse } from './mockResponses' import { generateToken, mockSasjsAuthResponse } from './mockResponses'
import { RequestClient } from '../../request/RequestClient' import { RequestClient } from '../../request/RequestClient'
import { getAccessTokenForSasjs } from '../getAccessTokenForSasjs' import { getAccessTokenForSasjs } from '../getAccessTokenForSasjs'

View File

@@ -1,5 +1,5 @@
import { AuthConfig } from '@sasjs/utils/types' import { AuthConfig } from '@sasjs/utils'
import NodeFormData from 'form-data' import * as NodeFormData from 'form-data'
import { generateToken, mockAuthResponse } from './mockResponses' import { generateToken, mockAuthResponse } from './mockResponses'
import { RequestClient } from '../../request/RequestClient' import { RequestClient } from '../../request/RequestClient'
import { getAccessTokenForViya } from '../getAccessTokenForViya' import { getAccessTokenForViya } from '../getAccessTokenForViya'

View File

@@ -1,4 +1,4 @@
import { AuthConfig } from '@sasjs/utils/types' import { AuthConfig } from '@sasjs/utils'
import * as refreshTokensModule from '../refreshTokensForViya' import * as refreshTokensModule from '../refreshTokensForViya'
import { generateToken, mockAuthResponse } from './mockResponses' import { generateToken, mockAuthResponse } from './mockResponses'
import { getTokens } from '../getTokens' import { getTokens } from '../getTokens'

View File

@@ -1,82 +0,0 @@
/**
* @jest-environment jsdom
*/
import { ServerType } from '@sasjs/utils/types'
import {
loginSuccessHeaders,
isLogInSuccessHeaderPresent,
defaultSuccessHeaderKey
} from '../'
describe('isLogInSuccessHeaderPresent', () => {
let languageGetter: any
beforeEach(() => {
languageGetter = jest.spyOn(window.navigator, 'language', 'get')
})
it('should check SASVIYA and SAS9 login success header based on language preferences of the browser', () => {
// test SASVIYA server type
Object.keys(loginSuccessHeaders).forEach((key) => {
languageGetter.mockReturnValue(key)
expect(
isLogInSuccessHeaderPresent(
ServerType.SasViya,
loginSuccessHeaders[key]
)
).toBeTruthy()
})
// test SAS9 server type
Object.keys(loginSuccessHeaders).forEach((key) => {
languageGetter.mockReturnValue(key)
expect(
isLogInSuccessHeaderPresent(ServerType.Sas9, loginSuccessHeaders[key])
).toBeTruthy()
})
// test possible longer language codes
const possibleLanguageCodes = [
{ short: 'en', long: 'en-US' },
{ short: 'fr', long: 'fr-FR' },
{ short: 'es', long: 'es-ES' }
]
possibleLanguageCodes.forEach((key) => {
const { short, long } = key
languageGetter.mockReturnValue(long)
expect(
isLogInSuccessHeaderPresent(
ServerType.SasViya,
loginSuccessHeaders[short]
)
).toBeTruthy()
})
// test falling back to default language code
languageGetter.mockReturnValue('WRONG-LANGUAGE')
expect(
isLogInSuccessHeaderPresent(
ServerType.Sas9,
loginSuccessHeaders[defaultSuccessHeaderKey]
)
).toBeTruthy()
})
it('should check SASVJS login success header', () => {
expect(
isLogInSuccessHeaderPresent(ServerType.Sasjs, { loggedIn: true })
).toBeTruthy()
expect(
isLogInSuccessHeaderPresent(ServerType.Sasjs, { loggedIn: false })
).toBeFalsy()
expect(isLogInSuccessHeaderPresent(ServerType.Sasjs, undefined)).toBeFalsy()
})
})

View File

@@ -1,6 +1,7 @@
import { SasAuthResponse } from '@sasjs/utils/types' import { SasAuthResponse } from '@sasjs/utils/types'
export const mockLoginAuthoriseRequiredResponse = `<form id="application_authorization" action="/SASLogon/oauth/authorize" method="POST"><input type="hidden" name="X-Uaa-Csrf" value="2nfuxIn6WaOURWL7tzTXCe"/>` export const mockLoginAuthoriseRequiredResponse = `<form id="application_authorization" action="/SASLogon/oauth/authorize" method="POST"><input type="hidden" name="X-Uaa-Csrf" value="2nfuxIn6WaOURWL7tzTXCe"/>`
export const mockLoginSuccessResponse = `You have signed in`
export const mockAuthResponse: SasAuthResponse = { export const mockAuthResponse: SasAuthResponse = {
access_token: 'acc355', access_token: 'acc355',

View File

@@ -1,4 +1,4 @@
import { ServerType } from '@sasjs/utils/types' import { ServerType } from '@sasjs/utils'
import { generateToken, mockAuthResponse } from './mockResponses' import { generateToken, mockAuthResponse } from './mockResponses'
import { RequestClient } from '../../request/RequestClient' import { RequestClient } from '../../request/RequestClient'
import { refreshTokensForSasjs } from '../refreshTokensForSasjs' import { refreshTokensForSasjs } from '../refreshTokensForSasjs'

View File

@@ -1,5 +1,5 @@
import { AuthConfig, ServerType } from '@sasjs/utils/types' import { AuthConfig, ServerType } from '@sasjs/utils'
import NodeFormData from 'form-data' import * as NodeFormData from 'form-data'
import { generateToken, mockAuthResponse } from './mockResponses' import { generateToken, mockAuthResponse } from './mockResponses'
import { RequestClient } from '../../request/RequestClient' import { RequestClient } from '../../request/RequestClient'
import { refreshTokensForViya } from '../refreshTokensForViya' import { refreshTokensForViya } from '../refreshTokensForViya'

View File

@@ -3,7 +3,6 @@
*/ */
import { verifySas9Login } from '../verifySas9Login' import { verifySas9Login } from '../verifySas9Login'
import * as delayModule from '../../utils/delay' import * as delayModule from '../../utils/delay'
import { getExpectedLogInSuccessHeader } from '../'
describe('verifySas9Login', () => { describe('verifySas9Login', () => {
const serverUrl = 'http://test-server.com' const serverUrl = 'http://test-server.com'
@@ -19,9 +18,7 @@ describe('verifySas9Login', () => {
const popup = { const popup = {
window: { window: {
location: { href: serverUrl + `/SASLogon` }, location: { href: serverUrl + `/SASLogon` },
document: { document: { body: { innerText: '<h3>You have signed in.</h3>' } }
body: { innerText: `<h3>${getExpectedLogInSuccessHeader()}</h3>` }
}
} }
} as unknown as Window } as unknown as Window

View File

@@ -3,7 +3,6 @@
*/ */
import { verifySasViyaLogin } from '../verifySasViyaLogin' import { verifySasViyaLogin } from '../verifySasViyaLogin'
import * as delayModule from '../../utils/delay' import * as delayModule from '../../utils/delay'
import { getExpectedLogInSuccessHeader } from '../'
describe('verifySasViyaLogin', () => { describe('verifySasViyaLogin', () => {
const serverUrl = 'http://test-server.com' const serverUrl = 'http://test-server.com'
@@ -20,9 +19,7 @@ describe('verifySasViyaLogin', () => {
const popup = { const popup = {
window: { window: {
location: { href: serverUrl + `/SASLogon` }, location: { href: serverUrl + `/SASLogon` },
document: { document: { body: { innerText: '<h3>You have signed in.</h3>' } }
body: { innerText: `<h3>${getExpectedLogInSuccessHeader()}</h3>` }
}
} }
} as unknown as Window } as unknown as Window

View File

@@ -1,5 +1,4 @@
import { delay } from '../utils' import { delay } from '../utils'
import { getExpectedLogInSuccessHeader } from './'
export async function verifySas9Login(loginPopup: Window): Promise<{ export async function verifySas9Login(loginPopup: Window): Promise<{
isLoggedIn: boolean isLoggedIn: boolean
@@ -7,17 +6,13 @@ export async function verifySas9Login(loginPopup: Window): Promise<{
let isLoggedIn = false let isLoggedIn = false
let startTime = new Date() let startTime = new Date()
let elapsedSeconds = 0 let elapsedSeconds = 0
do { do {
await delay(1000) await delay(1000)
if (loginPopup.closed) break if (loginPopup.closed) break
isLoggedIn = isLoggedIn =
loginPopup.window.location.href.includes('SASLogon') && loginPopup.window.location.href.includes('SASLogon') &&
loginPopup.window.document.body.innerText.includes( loginPopup.window.document.body.innerText.includes('You have signed in.')
getExpectedLogInSuccessHeader()
)
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000 elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
} while (!isLoggedIn && elapsedSeconds < 5 * 60) } while (!isLoggedIn && elapsedSeconds < 5 * 60)

View File

@@ -1,5 +1,4 @@
import { delay } from '../utils' import { delay } from '../utils'
import { getExpectedLogInSuccessHeader } from './'
export async function verifySasViyaLogin(loginPopup: Window): Promise<{ export async function verifySasViyaLogin(loginPopup: Window): Promise<{
isLoggedIn: boolean isLoggedIn: boolean
@@ -7,32 +6,23 @@ export async function verifySasViyaLogin(loginPopup: Window): Promise<{
let isLoggedIn = false let isLoggedIn = false
let startTime = new Date() let startTime = new Date()
let elapsedSeconds = 0 let elapsedSeconds = 0
do { do {
await delay(1000) await delay(1000)
if (loginPopup.closed) break if (loginPopup.closed) break
isLoggedIn = isLoggedInSASVIYA() isLoggedIn = isLoggedInSASVIYA()
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000 elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
} while (!isLoggedIn && elapsedSeconds < 5 * 60) } while (!isLoggedIn && elapsedSeconds < 5 * 60)
let isAuthorized = false let isAuthorized = false
startTime = new Date() startTime = new Date()
do { do {
await delay(1000) await delay(1000)
if (loginPopup.closed) break if (loginPopup.closed) break
isAuthorized = isAuthorized =
loginPopup.window.location.href.includes('SASLogon') || loginPopup.window.location.href.includes('SASLogon') ||
loginPopup.window.document.body?.innerText?.includes( loginPopup.window.document.body?.innerText?.includes(
getExpectedLogInSuccessHeader() 'You have signed in.'
) )
elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000 elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000
} while (!isAuthorized && elapsedSeconds < 5 * 60) } while (!isAuthorized && elapsedSeconds < 5 * 60)

View File

@@ -1,6 +1,5 @@
import NodeFormData from 'form-data' import * as NodeFormData from 'form-data'
import { convertToCSV } from '../utils/convertToCsv' import { convertToCSV } from '../utils/convertToCsv'
import { isNode } from '../utils'
/** /**
* One of the approaches SASjs takes to send tables-formatted JSON (see README) * One of the approaches SASjs takes to send tables-formatted JSON (see README)
@@ -27,15 +26,12 @@ export const generateFileUploadForm = (
) )
} }
// INFO: unfortunately it is not possible to check if formData is instance of NodeFormData or FormData because it will return true for both if (typeof FormData === 'undefined' && formData instanceof NodeFormData) {
if (isNode()) { formData.append(name, csv, {
// INFO: environment is Node and formData is instance of NodeFormData
;(formData as NodeFormData).append(name, csv, {
filename: `${name}.csv`, filename: `${name}.csv`,
contentType: 'application/csv' contentType: 'application/csv'
}) })
} else { } else {
// INFO: environment is Browser and formData is instance of FormData
const file = new Blob([csv], { const file = new Blob([csv], {
type: 'application/csv' type: 'application/csv'
}) })

View File

@@ -1,4 +1,4 @@
import NodeFormData from 'form-data' import * as NodeFormData from 'form-data'
import { convertToCSV, isFormatsTable } from '../utils/convertToCsv' import { convertToCSV, isFormatsTable } from '../utils/convertToCsv'
import { splitChunks } from '../utils/splitChunks' import { splitChunks } from '../utils/splitChunks'

View File

@@ -1,7 +1,4 @@
import { generateFileUploadForm } from '../generateFileUploadForm' import { generateFileUploadForm } from '../generateFileUploadForm'
import { convertToCSV } from '../../utils/convertToCsv'
import NodeFormData from 'form-data'
import * as isNodeModule from '../../utils/isNode'
describe('generateFileUploadForm', () => { describe('generateFileUploadForm', () => {
beforeAll(() => { beforeAll(() => {
@@ -14,11 +11,6 @@ describe('generateFileUploadForm', () => {
;(global as any).Blob = BlobMock ;(global as any).Blob = BlobMock
}) })
describe('browser', () => {
afterAll(() => {
jest.restoreAllMocks()
})
it('should generate file upload form from data', () => { it('should generate file upload form from data', () => {
const formData = new FormData() const formData = new FormData()
const testTable = 'sometable' const testTable = 'sometable'
@@ -33,12 +25,11 @@ describe('generateFileUploadForm', () => {
], ],
[`$${testTable}`]: { formats: { var1: '$char12.', nullvar: 'best.' } } [`$${testTable}`]: { formats: { var1: '$char12.', nullvar: 'best.' } }
} }
const tableName = Object.keys(testTableWithNullVars).filter( const tableName = Object.keys(testTableWithNullVars).filter((key: string) =>
(key: string) => Array.isArray(testTableWithNullVars[key]) Array.isArray(testTableWithNullVars[key])
)[0] )[0]
jest.spyOn(formData, 'append').mockImplementation(() => {}) jest.spyOn(formData, 'append').mockImplementation(() => {})
jest.spyOn(isNodeModule, 'isNode').mockImplementation(() => false)
generateFileUploadForm(formData, testTableWithNullVars) generateFileUploadForm(formData, testTableWithNullVars)
@@ -61,47 +52,3 @@ describe('generateFileUploadForm', () => {
) )
}) })
}) })
describe('node', () => {
it('should generate file upload form from data', () => {
const formData = new NodeFormData()
const testTable = 'sometable'
const testTableWithNullVars: { [key: string]: any } = {
[testTable]: [
{ var1: 'string', var2: 232, nullvar: 'A' },
{ var1: 'string', var2: 232, nullvar: 'B' },
{ var1: 'string', var2: 232, nullvar: '_' },
{ var1: 'string', var2: 232, nullvar: 0 },
{ var1: 'string', var2: 232, nullvar: 'z' },
{ var1: 'string', var2: 232, nullvar: null }
],
[`$${testTable}`]: { formats: { var1: '$char12.', nullvar: 'best.' } }
}
const tableName = Object.keys(testTableWithNullVars).filter(
(key: string) => Array.isArray(testTableWithNullVars[key])
)[0]
const csv = convertToCSV(testTableWithNullVars, tableName)
jest.spyOn(formData, 'append').mockImplementation(() => {})
generateFileUploadForm(formData, testTableWithNullVars)
expect(formData.append).toHaveBeenCalledOnce()
expect(formData.append).toHaveBeenCalledWith(tableName, csv, {
contentType: 'application/csv',
filename: `${tableName}.csv`
})
})
it('should throw an error if too large string was provided', () => {
const formData = new NodeFormData()
const data = { testTable: [{ var1: 'z'.repeat(32765 + 1) }] }
expect(() => generateFileUploadForm(formData, data)).toThrow(
new Error(
'The max length of a string value in SASjs is 32765 characters.'
)
)
})
})
})

View File

@@ -1,7 +1,8 @@
import { import {
getValidJson, getValidJson,
parseSasViyaDebugResponse, parseSasViyaDebugResponse,
parseWeboutResponse parseWeboutResponse,
SASJS_LOGS_SEPARATOR
} from '../utils' } from '../utils'
import { UploadFile } from '../types/UploadFile' import { UploadFile } from '../types/UploadFile'
import { import {
@@ -92,24 +93,15 @@ export class FileUploader extends BaseJobExecutor {
this.requestClient, this.requestClient,
config.serverUrl config.serverUrl
) )
break break
case ServerType.Sas9: case ServerType.Sas9:
jsonResponse = jsonResponse =
typeof res.result === 'string' typeof res.result === 'string'
? parseWeboutResponse(res.result, uploadUrl) ? parseWeboutResponse(res.result, uploadUrl)
: res.result : res.result
break
case ServerType.Sasjs:
jsonResponse =
typeof res.result === 'string'
? getValidJson(res.result)
: res.result
break break
} }
} else { } else if (this.serverType !== ServerType.Sasjs) {
jsonResponse = jsonResponse =
typeof res.result === 'string' typeof res.result === 'string'
? getValidJson(res.result) ? getValidJson(res.result)

View File

@@ -51,7 +51,7 @@ export abstract class BaseJobExecutor implements JobExecutor {
if (config.debug) { if (config.debug) {
requestParams['_omittextlog'] = 'false' requestParams['_omittextlog'] = 'false'
requestParams['_omitSessionResults'] = 'false' requestParams['_omitsessionresults'] = 'false'
requestParams['_debug'] = 131 requestParams['_debug'] = 131
} }

View File

@@ -1,6 +1,6 @@
import * as https from 'https' import * as https from 'https'
import { ServerType } from '@sasjs/utils/types' import { ServerType } from '@sasjs/utils/types'
import NodeFormData from 'form-data' import * as NodeFormData from 'form-data'
import { ErrorResponse } from '../types/errors' import { ErrorResponse } from '../types/errors'
import { convertToCSV, isRelativePath } from '../utils' import { convertToCSV, isRelativePath } from '../utils'
import { BaseJobExecutor } from './JobExecutor' import { BaseJobExecutor } from './JobExecutor'

View File

@@ -1,4 +1,4 @@
import NodeFormData from 'form-data' import * as NodeFormData from 'form-data'
import { import {
AuthConfig, AuthConfig,
ExtraResponseAttributes, ExtraResponseAttributes,
@@ -10,8 +10,8 @@ import {
LoginRequiredError LoginRequiredError
} from '../types/errors' } from '../types/errors'
import { generateFileUploadForm } from '../file/generateFileUploadForm' import { generateFileUploadForm } from '../file/generateFileUploadForm'
import { RequestClient } from '../request/RequestClient' import { RequestClient } from '../request/RequestClient'
import { getFormData } from '../utils'
import { import {
isRelativePath, isRelativePath,
@@ -53,7 +53,8 @@ export class SasjsJobExecutor extends BaseJobExecutor {
* Use the available form data object (FormData in Browser, NodeFormData in * Use the available form data object (FormData in Browser, NodeFormData in
* Node) * Node)
*/ */
let formData = getFormData() let formData =
typeof FormData === 'undefined' ? new NodeFormData() : new FormData()
if (data) { if (data) {
// file upload approach // file upload approach
@@ -73,10 +74,8 @@ export class SasjsJobExecutor extends BaseJobExecutor {
/* The NodeFormData object does not set the request header - so, set it */ /* The NodeFormData object does not set the request header - so, set it */
const contentType = const contentType =
formData instanceof NodeFormData && typeof FormData === 'undefined' formData instanceof NodeFormData && typeof FormData === 'undefined'
? `multipart/form-data; boundary=${ ? `multipart/form-data; boundary=${formData.getBoundary()}`
formData.getHeaders()['content-type'] : undefined
}`
: 'multipart/form-data'
const requestPromise = new Promise((resolve, reject) => { const requestPromise = new Promise((resolve, reject) => {
this.requestClient!.post( this.requestClient!.post(
@@ -94,10 +93,8 @@ export class SasjsJobExecutor extends BaseJobExecutor {
) )
} }
const { result } = res const { result } = res.result
if (result && result.trim()) res.result = getValidJson(result)
if (result && typeof result === 'string' && result.trim())
res.result = getValidJson(result)
this.requestClient!.appendRequest(res, sasJob, config.debug) this.requestClient!.appendRequest(res, sasJob, config.debug)

View File

@@ -1,4 +1,4 @@
import NodeFormData from 'form-data' import * as NodeFormData from 'form-data'
import { import {
AuthConfig, AuthConfig,
ExtraResponseAttributes, ExtraResponseAttributes,
@@ -16,11 +16,10 @@ import { SASViyaApiClient } from '../SASViyaApiClient'
import { import {
isRelativePath, isRelativePath,
parseSasViyaDebugResponse, parseSasViyaDebugResponse,
appendExtraResponseAttributes, appendExtraResponseAttributes
parseWeboutResponse,
getFormData
} from '../utils' } from '../utils'
import { BaseJobExecutor } from './JobExecutor' import { BaseJobExecutor } from './JobExecutor'
import { parseWeboutResponse } from '../utils/parseWeboutResponse'
export interface WaitingRequstPromise { export interface WaitingRequstPromise {
promise: Promise<any> | null promise: Promise<any> | null
@@ -113,7 +112,8 @@ export class WebJobExecutor extends BaseJobExecutor {
* Use the available form data object (FormData in Browser, NodeFormData in * Use the available form data object (FormData in Browser, NodeFormData in
* Node) * Node)
*/ */
let formData = getFormData() let formData =
typeof FormData === 'undefined' ? new NodeFormData() : new FormData()
if (data) { if (data) {
const stringifiedData = JSON.stringify(data) const stringifiedData = JSON.stringify(data)
@@ -150,10 +150,8 @@ export class WebJobExecutor extends BaseJobExecutor {
/* The NodeFormData object does not set the request header - so, set it */ /* The NodeFormData object does not set the request header - so, set it */
const contentType = const contentType =
formData instanceof NodeFormData && typeof FormData === 'undefined' formData instanceof NodeFormData && typeof FormData === 'undefined'
? `multipart/form-data; boundary=${ ? `multipart/form-data; boundary=${formData.getBoundary()}`
formData.getHeaders()['content-type'] : undefined
}`
: 'multipart/form-data'
const requestPromise = new Promise((resolve, reject) => { const requestPromise = new Promise((resolve, reject) => {
this.requestClient!.post( this.requestClient!.post(

View File

@@ -233,8 +233,7 @@ export default class SASjs {
this.requestClient = new RequestClient( this.requestClient = new RequestClient(
this.sasjsConfig.serverUrl, this.sasjsConfig.serverUrl,
this.sasjsConfig.httpsAgentOptions, this.sasjsConfig.httpsAgentOptions,
this.sasjsConfig.requestHistoryLimit, this.sasjsConfig.requestHistoryLimit
this.sasjsConfig.verbose
) )
} else { } else {
this.requestClient.setConfig( this.requestClient.setConfig(

View File

@@ -11,6 +11,7 @@ import {
import { RequestClient } from '../../request/RequestClient' import { RequestClient } from '../../request/RequestClient'
import { import {
isRelativePath, isRelativePath,
parseSasViyaDebugResponse,
appendExtraResponseAttributes, appendExtraResponseAttributes,
convertToCSV convertToCSV
} from '../../utils' } from '../../utils'

View File

@@ -1,10 +1,4 @@
import { import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosRequestHeaders,
AxiosResponse
} from 'axios'
import * as https from 'https' import * as https from 'https'
import { CsrfToken } from '..' import { CsrfToken } from '..'
import { isAuthorizeFormRequired, isLogInRequired } from '../auth' import { isAuthorizeFormRequired, isLogInRequired } from '../auth'
@@ -16,7 +10,7 @@ import {
JobExecutionError, JobExecutionError,
CertificateError CertificateError
} from '../types/errors' } from '../types/errors'
import { SASjsRequest, HttpClient, VerboseMode } from '../types' import { SASjsRequest } from '../types'
import { parseWeboutResponse } from '../utils/parseWeboutResponse' import { parseWeboutResponse } from '../utils/parseWeboutResponse'
import { prefixMessage } from '@sasjs/utils/error' import { prefixMessage } from '@sasjs/utils/error'
import { SAS9AuthError } from '../types/errors/SAS9AuthError' import { SAS9AuthError } from '../types/errors/SAS9AuthError'
@@ -26,13 +20,45 @@ import {
createAxiosInstance createAxiosInstance
} from '../utils' } from '../utils'
import { InvalidSASjsCsrfError } from '../types/errors/InvalidSASjsCsrfError' import { InvalidSASjsCsrfError } from '../types/errors/InvalidSASjsCsrfError'
import { inspect } from 'util'
export interface HttpClient {
get<T>(
url: string,
accessToken: string | undefined,
contentType: string,
overrideHeaders: { [key: string]: string | number }
): Promise<{ result: T; etag: string }>
post<T>(
url: string,
data: any,
accessToken: string | undefined,
contentType: string,
overrideHeaders: { [key: string]: string | number }
): Promise<{ result: T; etag: string }>
put<T>(
url: string,
data: any,
accessToken: string | undefined,
overrideHeaders: { [key: string]: string | number }
): Promise<{ result: T; etag: string }>
delete<T>(
url: string,
accessToken: string | undefined
): Promise<{ result: T; etag: string }>
getCsrfToken(type: 'general' | 'file'): CsrfToken | undefined
saveLocalStorageToken(accessToken: string, refreshToken: string): void
clearCsrfTokens(): void
clearLocalStorageTokens(): void
getBaseUrl(): string
}
export class RequestClient implements HttpClient { export class RequestClient implements HttpClient {
private requests: SASjsRequest[] = [] private requests: SASjsRequest[] = []
private requestsLimit: number = 10 private requestsLimit: number = 10
private httpInterceptor?: number
private verboseMode: VerboseMode = false
protected csrfToken: CsrfToken = { headerName: '', value: '' } protected csrfToken: CsrfToken = { headerName: '', value: '' }
protected fileUploadCsrfToken: CsrfToken | undefined protected fileUploadCsrfToken: CsrfToken | undefined
@@ -41,17 +67,10 @@ export class RequestClient implements HttpClient {
constructor( constructor(
protected baseUrl: string, protected baseUrl: string,
httpsAgentOptions?: https.AgentOptions, httpsAgentOptions?: https.AgentOptions,
requestsLimit?: number, requestsLimit?: number
verboseMode?: VerboseMode
) { ) {
this.createHttpClient(baseUrl, httpsAgentOptions) this.createHttpClient(baseUrl, httpsAgentOptions)
if (requestsLimit) this.requestsLimit = requestsLimit if (requestsLimit) this.requestsLimit = requestsLimit
if (verboseMode) {
this.setVerboseMode(verboseMode)
this.enableVerboseMode()
}
} }
public setConfig(baseUrl: string, httpsAgentOptions?: https.AgentOptions) { public setConfig(baseUrl: string, httpsAgentOptions?: https.AgentOptions) {
@@ -71,7 +90,6 @@ export class RequestClient implements HttpClient {
this.csrfToken = { headerName: '', value: '' } this.csrfToken = { headerName: '', value: '' }
this.fileUploadCsrfToken = { headerName: '', value: '' } this.fileUploadCsrfToken = { headerName: '', value: '' }
} }
public clearLocalStorageTokens() { public clearLocalStorageTokens() {
localStorage.setItem('accessToken', '') localStorage.setItem('accessToken', '')
localStorage.setItem('refreshToken', '') localStorage.setItem('refreshToken', '')
@@ -160,9 +178,8 @@ export class RequestClient implements HttpClient {
const requestConfig: AxiosRequestConfig = { const requestConfig: AxiosRequestConfig = {
headers, headers,
responseType: contentType === 'text/plain' ? 'text' : 'json', responseType: contentType === 'text/plain' ? 'text' : 'json',
withXSRFToken: true withCredentials: true
} }
if (contentType === 'text/plain') { if (contentType === 'text/plain') {
requestConfig.transformResponse = undefined requestConfig.transformResponse = undefined
} }
@@ -191,13 +208,6 @@ export class RequestClient implements HttpClient {
}) })
} }
/**
* @param contentType Newer version of Axios is more strict so if you don't
* set the contentType to `form data` while sending a FormData object
* application/json will be used by default, axios wont treat it as FormData.
* Instead, it serializes data as JSON—resulting in a payload like
* {"sometable":{}} and we lose the multipart/form-data formatting.
*/
public async post<T>( public async post<T>(
url: string, url: string,
data: any, data: any,
@@ -214,7 +224,7 @@ export class RequestClient implements HttpClient {
return this.httpClient return this.httpClient
.post<T>(url, data, { .post<T>(url, data, {
headers, headers,
withXSRFToken: true, withCredentials: true,
...additionalSettings ...additionalSettings
}) })
.then((response) => { .then((response) => {
@@ -241,7 +251,7 @@ export class RequestClient implements HttpClient {
} }
return this.httpClient return this.httpClient
.put<T>(url, data, { headers, withXSRFToken: true }) .put<T>(url, data, { headers, withCredentials: true })
.then((response) => { .then((response) => {
throwIfError(response) throwIfError(response)
return this.parseResponse<T>(response) return this.parseResponse<T>(response)
@@ -260,7 +270,7 @@ export class RequestClient implements HttpClient {
const headers = this.getHeaders(accessToken, 'application/json') const headers = this.getHeaders(accessToken, 'application/json')
return this.httpClient return this.httpClient
.delete<T>(url, { headers, withXSRFToken: true }) .delete<T>(url, { headers, withCredentials: true })
.then((response) => { .then((response) => {
throwIfError(response) throwIfError(response)
return this.parseResponse<T>(response) return this.parseResponse<T>(response)
@@ -278,7 +288,7 @@ export class RequestClient implements HttpClient {
const headers = this.getHeaders(accessToken, 'application/json') const headers = this.getHeaders(accessToken, 'application/json')
return this.httpClient return this.httpClient
.patch<T>(url, data, { headers, withXSRFToken: true }) .patch<T>(url, data, { headers, withCredentials: true })
.then((response) => { .then((response) => {
throwIfError(response) throwIfError(response)
return this.parseResponse<T>(response) return this.parseResponse<T>(response)
@@ -379,166 +389,6 @@ export class RequestClient implements HttpClient {
}) })
} }
/**
* Adds colors to the string.
* If verboseMode is set to 'bleached', colors should be disabled
* @param str - string to be prettified.
* @returns - prettified string
*/
private prettifyString = (str: any) =>
inspect(str, { colors: this.verboseMode !== 'bleached' })
/**
* Formats HTTP request/response body.
* @param body - HTTP request/response body.
* @returns - formatted string.
*/
private parseInterceptedBody = (body: any) => {
if (!body) return ''
let parsedBody
// Tries to parse body into JSON object.
if (typeof body === 'string') {
try {
parsedBody = JSON.parse(body)
} catch (error) {
parsedBody = body
}
} else {
parsedBody = body
}
const bodyLines = this.prettifyString(parsedBody).split('\n')
// Leaves first 50 lines
if (bodyLines.length > 51) {
bodyLines.splice(50)
bodyLines.push('...')
}
return bodyLines.join('\n')
}
private handleAxiosResponse = (response: AxiosResponse) => {
const { status, config, request, data } = response
const reqHeaders = request?._header ?? 'Not provided\n'
const rawHeaders = request?.res?.rawHeaders ?? ['Not provided']
const resHeaders = this.formatHeaders(rawHeaders)
const parsedResBody = this.parseInterceptedBody(data)
process.logger?.info(`HTTP Request (first 50 lines):
${reqHeaders}${this.parseInterceptedBody(config.data)}
HTTP Response Code: ${this.prettifyString(status)}
HTTP Response (first 50 lines):
${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
`)
return response
}
private handleAxiosError = (error: AxiosError) => {
// Message indicating absent value.
const noValueMessage = 'Not provided'
const { response, request, config } = error
// Fallback request object that can be safely used to form request summary.
// _header is not present in responses with status 1**
// rawHeaders are not present in responses with status 1**
let fallbackRequest = {
_header: `${noValueMessage}\n`,
res: { rawHeaders: [noValueMessage] }
}
if (request) {
fallbackRequest = {
_header:
request._header ?? request._currentRequest?._header ?? noValueMessage,
res: { rawHeaders: request.res?.rawHeaders ?? [noValueMessage] }
}
}
// Fallback response object that can be safely used to form response summary.
let fallbackResponse = response || {
status: noValueMessage,
request: fallbackRequest,
config: config || {
data: noValueMessage,
headers: {} as AxiosRequestHeaders
},
data: noValueMessage
}
const { status, request: req, data: resData } = fallbackResponse
const { _header: reqHeaders, res } = req
const resHeaders = this.formatHeaders(res.rawHeaders)
const parsedResBody = this.parseInterceptedBody(resData)
process.logger?.info(`HTTP Request (first 50 lines):
${reqHeaders}${this.parseInterceptedBody(config?.data)}
HTTP Response Code: ${this.prettifyString(status)}
HTTP Response (first 50 lines):
${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
`)
return error
}
// Converts an array of strings into a single string with the following format:
// <headerName>: <headerValue>
private formatHeaders = (rawHeaders: string[]): string => {
return rawHeaders.reduce((acc, value, i) => {
if (i % 2 === 0) {
acc += `${i === 0 ? '' : '\n'}${value}`
} else {
acc += `: ${value}`
}
return acc
}, '')
}
/**
* Sets verbose mode.
* @param verboseMode - value of the verbose mode, can be true, false or bleached(without extra colors).
*/
public setVerboseMode = (verboseMode: VerboseMode) => {
this.verboseMode = verboseMode
if (this.verboseMode) this.enableVerboseMode()
else this.disableVerboseMode()
}
/**
* Turns on verbose mode to log every HTTP response.
* @param successCallBack - function that should be triggered on every HTTP response with the status 2**.
* @param errorCallBack - function that should be triggered on every HTTP response with the status different from 2**.
*/
public enableVerboseMode = (
successCallBack = this.handleAxiosResponse,
errorCallBack = this.handleAxiosError
) => {
this.httpInterceptor = this.httpClient.interceptors.response.use(
successCallBack,
errorCallBack
)
}
/**
* Turns off verbose mode to log every HTTP response.
*/
public disableVerboseMode = () => {
if (this.httpInterceptor) {
this.httpClient.interceptors.response.eject(this.httpInterceptor)
}
}
protected getHeaders = ( protected getHeaders = (
accessToken: string | undefined, accessToken: string | undefined,
contentType: string contentType: string
@@ -637,7 +487,7 @@ ${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
// Fetching root and creating CSRF cookie // Fetching root and creating CSRF cookie
await this.httpClient await this.httpClient
.get('/', { .get('/', {
withXSRFToken: true withCredentials: true
}) })
.then((response) => { .then((response) => {
const cookie = const cookie =

View File

@@ -1,6 +1,6 @@
import * as https from 'https' import * as https from 'https'
import { AxiosRequestConfig } from 'axios' import { AxiosRequestConfig } from 'axios'
import { wrapper } from 'axios-cookiejar-support' import axiosCookieJarSupport from 'axios-cookiejar-support'
import * as tough from 'tough-cookie' import * as tough from 'tough-cookie'
import { prefixMessage } from '@sasjs/utils/error' import { prefixMessage } from '@sasjs/utils/error'
import { RequestClient, throwIfError } from './RequestClient' import { RequestClient, throwIfError } from './RequestClient'
@@ -17,8 +17,8 @@ export class Sas9RequestClient extends RequestClient {
this.httpClient.defaults.validateStatus = (status) => this.httpClient.defaults.validateStatus = (status) =>
status >= 200 && status < 303 status >= 200 && status < 303
if (wrapper) { if (axiosCookieJarSupport) {
wrapper(this.httpClient) axiosCookieJarSupport(this.httpClient)
this.httpClient.defaults.jar = new tough.CookieJar() this.httpClient.defaults.jar = new tough.CookieJar()
} }
} }
@@ -50,7 +50,7 @@ export class Sas9RequestClient extends RequestClient {
const requestConfig: AxiosRequestConfig = { const requestConfig: AxiosRequestConfig = {
headers, headers,
responseType: contentType === 'text/plain' ? 'text' : 'json', responseType: contentType === 'text/plain' ? 'text' : 'json',
withXSRFToken: true withCredentials: true
} }
if (contentType === 'text/plain') { if (contentType === 'text/plain') {
requestConfig.transformResponse = undefined requestConfig.transformResponse = undefined
@@ -103,7 +103,7 @@ export class Sas9RequestClient extends RequestClient {
} }
return this.httpClient return this.httpClient
.post<T>(url, data, { headers, withXSRFToken: true }) .post<T>(url, data, { headers, withCredentials: true })
.then(async (response) => { .then(async (response) => {
if (response.status === 302) { if (response.status === 302) {
return await this.get( return await this.get(

View File

@@ -1,11 +1,20 @@
import { RequestClient } from './RequestClient' import { RequestClient } from './RequestClient'
import { AxiosResponse } from 'axios' import { AxiosResponse } from 'axios'
import { SasjsParsedResponse } from '../types' import { SASJS_LOGS_SEPARATOR } from '../utils'
interface SasjsParsedResponse<T> {
result: T
log: string
etag: string
status: number
printOutput?: string
}
/** /**
* Specific request client for SASJS. * Specific request client for SASJS.
* Append tokens in headers. * Append tokens in headers.
*/ */
export class SasjsRequestClient extends RequestClient { export class SasjsRequestClient extends RequestClient {
getHeaders = (accessToken: string | undefined, contentType: string) => { getHeaders = (accessToken: string | undefined, contentType: string) => {
const headers: any = {} const headers: any = {}
@@ -36,30 +45,13 @@ export class SasjsRequestClient extends RequestClient {
} }
} catch { } catch {
if (response.data.includes(SASJS_LOGS_SEPARATOR)) { if (response.data.includes(SASJS_LOGS_SEPARATOR)) {
const { data } = response const splittedResponse = response.data.split(SASJS_LOGS_SEPARATOR)
const splittedResponse: string[] = data.split(SASJS_LOGS_SEPARATOR)
webout = splittedResponse.splice(0, 1)[0] webout = splittedResponse[0]
if (webout !== undefined) parsedResponse = webout if (webout !== undefined) parsedResponse = webout
// log can contain nested logs log = splittedResponse[1]
const logs = splittedResponse.splice(0, splittedResponse.length - 1) printOutput = splittedResponse[2]
// tests if string ends with SASJS_LOGS_SEPARATOR
const endingWithLogSepRegExp = new RegExp(`${SASJS_LOGS_SEPARATOR}$`)
// at this point splittedResponse can contain only one item
const lastChunk = splittedResponse[0]
if (lastChunk) {
// if the last chunk doesn't end with SASJS_LOGS_SEPARATOR, then it is a printOutput
// else the last chunk is part of the log and has to be joined
if (!endingWithLogSepRegExp.test(data)) printOutput = lastChunk
else if (logs.length > 1) logs.push(lastChunk)
}
// join logs into single log with SASJS_LOGS_SEPARATOR
log = logs.join(SASJS_LOGS_SEPARATOR)
} else { } else {
parsedResponse = response.data parsedResponse = response.data
} }
@@ -67,7 +59,7 @@ export class SasjsRequestClient extends RequestClient {
const returnResult: SasjsParsedResponse<T> = { const returnResult: SasjsParsedResponse<T> = {
result: parsedResponse as T, result: parsedResponse as T,
log: log || '', log,
etag, etag,
status: response.status status: response.status
} }
@@ -77,6 +69,3 @@ export class SasjsRequestClient extends RequestClient {
return returnResult return returnResult
} }
} }
export const SASJS_LOGS_SEPARATOR =
'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'

View File

@@ -1,177 +0,0 @@
import { SASJS_LOGS_SEPARATOR, SasjsRequestClient } from '../SasjsRequestClient'
import { SasjsParsedResponse } from '../../types'
import { AxiosRequestHeaders, AxiosResponse } from 'axios'
describe('SasjsRequestClient', () => {
const requestClient = new SasjsRequestClient('')
const etag = 'etag'
const status = 200
const webout = `hello`
const log = `1 The SAS System Tuesday, 25 July 2023 12:51:00
PROC MIGRATE will preserve current SAS file attributes and is
recommended for converting all your SAS libraries from any
SAS 8 release to SAS 9. For details and examples, please see
http://support.sas.com/rnd/migration/index.html
NOTE: SAS initialization used:
real time 0.01 seconds
cpu time 0.02 seconds
`
const printOutput = 'printOutPut'
describe('parseResponse', () => {})
it('should parse response with 1 log', () => {
const response: AxiosResponse<any> = {
data: `${webout}
${SASJS_LOGS_SEPARATOR}
${log}
${SASJS_LOGS_SEPARATOR}`,
status,
statusText: 'ok',
headers: { etag },
config: {
headers: {} as AxiosRequestHeaders
}
}
const expectedParsedResponse: SasjsParsedResponse<string> = {
result: `${webout}
`,
log: `
${log}
`,
etag,
status
}
expect(requestClient['parseResponse'](response)).toEqual(
expectedParsedResponse
)
})
it('should parse response with 1 log and printOutput', () => {
const response: AxiosResponse<any> = {
data: `${webout}
${SASJS_LOGS_SEPARATOR}
${log}
${SASJS_LOGS_SEPARATOR}
${printOutput}`,
status,
statusText: 'ok',
headers: { etag },
config: {
headers: {} as AxiosRequestHeaders
}
}
const expectedParsedResponse: SasjsParsedResponse<string> = {
result: `${webout}
`,
log: `
${log}
`,
etag,
status,
printOutput: `
${printOutput}`
}
expect(requestClient['parseResponse'](response)).toEqual(
expectedParsedResponse
)
})
it('should parse response with nested logs', () => {
const logWithNestedLog = `root log start
${SASJS_LOGS_SEPARATOR}
${log}
${SASJS_LOGS_SEPARATOR}
root log end`
const response: AxiosResponse<any> = {
data: `${webout}
${SASJS_LOGS_SEPARATOR}
${logWithNestedLog}
${SASJS_LOGS_SEPARATOR}`,
status,
statusText: 'ok',
headers: { etag },
config: {
headers: {} as AxiosRequestHeaders
}
}
const expectedParsedResponse: SasjsParsedResponse<string> = {
result: `${webout}
`,
log: `
${logWithNestedLog}
`,
etag,
status
}
expect(requestClient['parseResponse'](response)).toEqual(
expectedParsedResponse
)
})
it('should parse response with nested logs and printOutput', () => {
const logWithNestedLog = `root log start
${SASJS_LOGS_SEPARATOR}
${log}
${SASJS_LOGS_SEPARATOR}
log with indentation
${SASJS_LOGS_SEPARATOR}
${log}
${SASJS_LOGS_SEPARATOR}
some SAS code containing ${SASJS_LOGS_SEPARATOR}
root log end`
const response: AxiosResponse<any> = {
data: `${webout}
${SASJS_LOGS_SEPARATOR}
${logWithNestedLog}
${SASJS_LOGS_SEPARATOR}
${printOutput}`,
status,
statusText: 'ok',
headers: { etag },
config: {
headers: {} as AxiosRequestHeaders
}
}
const expectedParsedResponse: SasjsParsedResponse<string> = {
result: `${webout}
`,
log: `
${logWithNestedLog}
`,
etag,
status,
printOutput: `
${printOutput}`
}
expect(requestClient['parseResponse'](response)).toEqual(
expectedParsedResponse
)
})
})
describe('SASJS_LOGS_SEPARATOR', () => {
it('SASJS_LOGS_SEPARATOR should be hardcoded', () => {
expect(SASJS_LOGS_SEPARATOR).toEqual(
'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'
)
})
})

View File

@@ -1,130 +0,0 @@
/**
* @jest-environment node
*/
import * as https from 'https'
import NodeFormData from 'form-data'
import { SAS9ApiClient } from '../SAS9ApiClient'
import { Sas9RequestClient } from '../request/Sas9RequestClient'
// Mock the Sas9RequestClient so that we can control its behavior
jest.mock('../request/Sas9RequestClient', () => {
return {
Sas9RequestClient: jest
.fn()
.mockImplementation(
(serverUrl: string, httpsAgentOptions?: https.AgentOptions) => {
return {
login: jest.fn().mockResolvedValue(undefined),
post: jest.fn().mockResolvedValue({ result: 'execution result' })
}
}
)
}
})
describe('SAS9ApiClient', () => {
const serverUrl = 'http://test-server.com'
const jobsPath = '/SASStoredProcess/do'
let client: SAS9ApiClient
let mockRequestClient: any
beforeEach(() => {
client = new SAS9ApiClient(serverUrl, jobsPath)
// Retrieve the instance of the mocked Sas9RequestClient
mockRequestClient = (Sas9RequestClient as jest.Mock).mock.results[0].value
})
afterEach(() => {
jest.clearAllMocks()
})
describe('getConfig', () => {
it('should return the correct configuration', () => {
const config = client.getConfig()
expect(config).toEqual({ serverUrl })
})
})
describe('setConfig', () => {
it('should update the serverUrl when a valid value is provided', () => {
const newUrl = 'http://new-server.com'
client.setConfig(newUrl)
expect(client.getConfig()).toEqual({ serverUrl: newUrl })
})
it('should not update the serverUrl when an empty string is provided', () => {
const originalConfig = client.getConfig()
client.setConfig('')
expect(client.getConfig()).toEqual(originalConfig)
})
})
describe('executeScript', () => {
const linesOfCode = ['line1;', 'line2;']
const userName = 'testUser'
const password = 'testPass'
const fixedTimestamp = '1234567890'
const expectedFilename = `sasjs-execute-sas9-${fixedTimestamp}.sas`
beforeAll(() => {
// Stub generateTimestamp so that we get a consistent filename in our tests.
jest
.spyOn(require('@sasjs/utils/time'), 'generateTimestamp')
.mockReturnValue(fixedTimestamp)
})
afterAll(() => {
jest.restoreAllMocks()
})
it('should execute the script and return the result', async () => {
const result = await client.executeScript(linesOfCode, userName, password)
// Verify that login is called with the correct parameters.
expect(mockRequestClient.login).toHaveBeenCalledWith(
userName,
password,
jobsPath
)
// Build the expected stored process URL.
const codeInjectorPath = `/User Folders/${userName}/My Folder/sasjs/runner`
const expectedUrl =
`${jobsPath}/?` + '_program=' + codeInjectorPath + '&_debug=log'
// Verify that post was called with the expected stored process URL.
expect(mockRequestClient.post).toHaveBeenCalledWith(
expectedUrl,
expect.any(NodeFormData),
undefined,
expect.stringContaining('multipart/form-data; boundary='),
expect.objectContaining({
'Content-Length': expect.any(Number),
'Content-Type': expect.stringContaining(
'multipart/form-data; boundary='
),
Accept: '*/*'
})
)
// The method should return the result from the post call.
expect(result).toEqual('execution result')
})
it('should include the force output code in the uploaded form data', async () => {
await client.executeScript(linesOfCode, userName, password)
// Retrieve the form data passed to post
const postCallArgs = (mockRequestClient.post as jest.Mock).mock.calls[0]
const formData: NodeFormData = postCallArgs[1]
// We can inspect the boundary and ensure that the filename was generated correctly.
expect(formData.getBoundary()).toBeDefined()
// The filename is used as the key for the form field.
const formDataBuffer = formData.getBuffer().toString()
expect(formDataBuffer).toContain(expectedFilename)
// Also check that the force output code is appended.
expect(formDataBuffer).toContain("put 'Executed sasjs run';")
})
})
})

View File

@@ -1,231 +0,0 @@
import NodeFormData from 'form-data'
import {
SASjsApiClient,
SASjsAuthResponse,
ScriptExecutionResult
} from '../SASjsApiClient'
import { AuthConfig, ServicePackSASjs } from '@sasjs/utils/types'
import { ExecutionQuery } from '../types'
// Create a mock request client with a post method.
const mockPost = jest.fn()
const mockRequestClient = {
post: mockPost
}
// Instead of referencing external variables, inline the dummy values in the mock factories.
jest.mock('../auth/getTokens', () => ({
getTokens: jest.fn().mockResolvedValue({ access_token: 'dummyAccessToken' })
}))
jest.mock('../auth/getAccessTokenForSasjs', () => ({
getAccessTokenForSasjs: jest.fn().mockResolvedValue({
access_token: 'newAccessToken',
refresh_token: 'newRefreshToken'
} as any)
}))
jest.mock('../auth/refreshTokensForSasjs', () => ({
refreshTokensForSasjs: jest.fn().mockResolvedValue({
access_token: 'newAccessToken',
refresh_token: 'newRefreshToken'
} as any)
}))
// For deployZipFile, mock the file reading function.
jest.mock('@sasjs/utils/file', () => ({
createReadStream: jest.fn().mockResolvedValue('readStreamDummy')
}))
// Dummy result to compare against.
const dummyResult = {
status: 'OK',
message: 'Success',
streamServiceName: 'service',
example: {}
}
describe('SASjsApiClient', () => {
let client: SASjsApiClient
beforeEach(() => {
client = new SASjsApiClient(mockRequestClient as any)
mockPost.mockReset()
})
describe('deploy', () => {
it('should deploy service pack using JSON', async () => {
// Arrange: Simulate a successful response.
mockPost.mockResolvedValue({ result: dummyResult })
const dataJson: ServicePackSASjs = {
appLoc: '',
someOtherProp: 'value'
} as any
const appLoc = '/base/appLoc'
const authConfig: AuthConfig = {
client: 'clientId',
secret: 'secret',
access_token: 'token',
refresh_token: 'refresh'
}
// Act
const result = await client.deploy(dataJson, appLoc, authConfig)
// Assert: Ensure that the JSON gets the appLoc set if not defined.
expect(dataJson.appLoc).toBe(appLoc)
expect(mockPost).toHaveBeenCalledWith(
'SASjsApi/drive/deploy',
dataJson,
'dummyAccessToken',
undefined,
{},
{ maxContentLength: Infinity, maxBodyLength: Infinity }
)
expect(result).toEqual(dummyResult)
})
})
describe('deployZipFile', () => {
it('should deploy zip file and return the result', async () => {
// Arrange: Simulate a successful response.
mockPost.mockResolvedValue({ result: dummyResult })
const zipFilePath = 'path/to/deploy.zip'
const authConfig: AuthConfig = {
client: 'clientId',
secret: 'secret',
access_token: 'token',
refresh_token: 'refresh'
}
// Act
const result = await client.deployZipFile(zipFilePath, authConfig)
// Assert: Verify that POST is called with multipart form-data.
expect(mockPost).toHaveBeenCalled()
const callArgs = mockPost.mock.calls[0]
expect(callArgs[0]).toBe('SASjsApi/drive/deploy/upload')
expect(result).toEqual(dummyResult)
})
})
describe('executeJob', () => {
it('should execute a job with absolute program path', async () => {
// Arrange
const query: ExecutionQuery = { _program: '/absolute/path' } as any
const appLoc = '/base/appLoc'
const authConfig: AuthConfig = { access_token: 'anyToken' } as any
mockPost.mockResolvedValue({
result: { jobId: 123 },
log: 'execution log'
})
// Act
const { result, log } = await client.executeJob(query, appLoc, authConfig)
// Assert: The program path should not be prefixed.
expect(mockPost).toHaveBeenCalledWith(
'SASjsApi/stp/execute',
{ _debug: 131, ...query, _program: '/absolute/path' },
'anyToken'
)
expect(result).toEqual({ jobId: 123 })
expect(log).toBe('execution log')
})
it('should execute a job with relative program path', async () => {
// Arrange
const query: ExecutionQuery = { _program: 'relative/path' } as any
const appLoc = '/base/appLoc'
mockPost.mockResolvedValue({ result: { jobId: 456 }, log: 'another log' })
// Act
const { result, log } = await client.executeJob(query, appLoc)
// Assert: The program path should be prefixed with appLoc.
expect(mockPost).toHaveBeenCalledWith(
'SASjsApi/stp/execute',
{ _debug: 131, ...query, _program: '/base/appLoc/relative/path' },
undefined
)
expect(result).toEqual({ jobId: 456 })
expect(log).toBe('another log')
})
})
describe('executeScript', () => {
it('should execute a script and return the execution result', async () => {
// Arrange
const code = 'data _null_; run;'
const runTime = 'sas'
const authConfig: AuthConfig = {
client: 'clientId',
secret: 'secret',
access_token: 'token',
refresh_token: 'refresh'
}
const responsePayload = {
log: 'log output',
printOutput: 'print output',
result: 'web output'
}
mockPost.mockResolvedValue(responsePayload)
// Act
const result: ScriptExecutionResult = await client.executeScript(
code,
runTime,
authConfig
)
// Assert
expect(mockPost).toHaveBeenCalledWith(
'SASjsApi/code/execute',
{ code, runTime },
'dummyAccessToken'
)
expect(result.log).toBe('log output')
expect(result.printOutput).toBe('print output')
expect(result.webout).toBe('web output')
})
it('should throw an error with a prefixed message when POST fails', async () => {
// Arrange
const code = 'data _null_; run;'
const errorMessage = 'Network Error'
mockPost.mockRejectedValue(new Error(errorMessage))
// Act & Assert
await expect(client.executeScript(code)).rejects.toThrow(
/Error while sending POST request to execute code/
)
})
})
describe('getAccessToken', () => {
it('should exchange auth code for access token', async () => {
// Act
const result = await client.getAccessToken('clientId', 'authCode123')
// Assert: The result should match the dummy auth response.
expect(result).toEqual({
access_token: 'newAccessToken',
refresh_token: 'newRefreshToken'
})
})
})
describe('refreshTokens', () => {
it('should exchange refresh token for new tokens', async () => {
// Act
const result = await client.refreshTokens('refreshToken123')
// Assert: The result should match the dummy auth response.
expect(result).toEqual({
access_token: 'newAccessToken',
refresh_token: 'newRefreshToken'
})
})
})
})

View File

@@ -2,42 +2,39 @@ import * as pem from 'pem'
import * as http from 'http' import * as http from 'http'
import * as https from 'https' import * as https from 'https'
import { app, mockedAuthResponse } from './SAS_server_app' import { app, mockedAuthResponse } from './SAS_server_app'
import { ServerType } from '@sasjs/utils/types' import { ServerType } from '@sasjs/utils'
import SASjs from '../SASjs' import SASjs from '../SASjs'
import * as axiosModules from '../utils/createAxiosInstance' import * as axiosModules from '../utils/createAxiosInstance'
import axios, { AxiosRequestHeaders } from 'axios'
import { import {
LoginRequiredError, LoginRequiredError,
AuthorizeError, AuthorizeError,
NotFoundError, NotFoundError,
InternalServerError, InternalServerError
VerboseMode } from '../types/errors'
} from '../types'
import { RequestClient } from '../request/RequestClient' import { RequestClient } from '../request/RequestClient'
import { getTokenRequestErrorPrefixResponse } from '../auth/getTokenRequestErrorPrefix' import { getTokenRequestErrorPrefixResponse } from '../auth/getTokenRequestErrorPrefix'
import { AxiosResponse, AxiosError } from 'axios'
import { Logger, LogLevel } from '@sasjs/utils/logger'
import * as UtilsModule from 'util'
const axiosActual = jest.requireActual('axios') const axiosActual = jest.requireActual('axios')
jest jest
.spyOn(axiosModules, 'createAxiosInstance') .spyOn(axiosModules, 'createAxiosInstance')
.mockImplementation((baseURL: string, httpsAgent?: https.Agent) => .mockImplementation((baseURL: string, httpsAgent?: https.Agent) =>
axiosActual.create({ baseURL, httpsAgent, withXSRFToken: true }) axiosActual.create({ baseURL, httpsAgent })
) )
jest.mock('util', () => {
const actualUtil = jest.requireActual('util')
return {
...actualUtil,
inspect: jest.fn(actualUtil.inspect)
}
})
const PORT = 8000 const PORT = 8000
const SERVER_URL = `https://localhost:${PORT}/` const SERVER_URL = `https://localhost:${PORT}/`
const ERROR_MESSAGES = {
selfSigned: 'self signed certificate',
CCA: 'unable to verify the first certificate'
}
const incorrectAuthCodeErr = {
error: 'unauthorized',
error_description: 'Bad credentials'
}
describe('RequestClient', () => { describe('RequestClient', () => {
let server: http.Server let server: http.Server
@@ -83,413 +80,6 @@ describe('RequestClient', () => {
expect(rejectionErrorMessage).toEqual(expectedError.message) expect(rejectionErrorMessage).toEqual(expectedError.message)
}) })
describe('defaultInterceptionCallBacks for successful requests and failed requests', () => {
const reqHeaders = `POST https://sas.server.com/compute/sessions/session_id/jobs HTTP/1.1
Accept: application/json
Content-Type: application/json
User-Agent: axios/0.27.2
Content-Length: 334
host: sas.server.io
Connection: close
`
const reqData = `{
name: 'test_job',
description: 'Powered by SASjs',
code: ['test_code'],
variables: {
SYS_JES_JOB_URI: '',
_program: '/Public/sasjs/jobs/jobs/test_job'
},
arguments: {
_contextName: 'SAS Job Execution compute context',
_OMITJSONLISTING: true,
_OMITJSONLOG: true,
_omitSessionResults: true,
_OMITTEXTLISTING: true,
_OMITTEXTLOG: true
}
}`
const resHeaders = ['content-type', 'application/json']
const resData = {
id: 'id_string',
name: 'name_string',
uri: 'uri_string',
createdBy: 'createdBy_string',
code: 'TEST CODE',
links: [
{
method: 'method_string',
rel: 'state',
href: 'state_href_string',
uri: 'uri_string',
type: 'type_string'
},
{
method: 'method_string',
rel: 'state',
href: 'state_href_string',
uri: 'uri_string',
type: 'type_string'
},
{
method: 'method_string',
rel: 'state',
href: 'state_href_string',
uri: 'uri_string',
type: 'type_string'
},
{
method: 'method_string',
rel: 'state',
href: 'state_href_string',
uri: 'uri_string',
type: 'type_string'
},
{
method: 'method_string',
rel: 'state',
href: 'state_href_string',
uri: 'uri_string',
type: 'type_string'
},
{
method: 'method_string',
rel: 'self',
href: 'self_href_string',
uri: 'uri_string',
type: 'type_string'
}
],
results: { '_webout.json': '_webout.json_string' },
logStatistics: {
lineCount: 1,
modifiedTimeStamp: 'modifiedTimeStamp_string'
}
}
beforeAll(() => {
;(process as any).logger = new Logger(LogLevel.Off)
jest.spyOn((process as any).logger, 'info')
})
it('should log parsed response with status 1**', () => {
const mockedAxiosError = {
config: {
data: reqData
},
request: {
_currentRequest: {
_header: reqHeaders
}
}
} as AxiosError
const requestClient = new RequestClient('')
requestClient['handleAxiosError'](mockedAxiosError)
const noValueMessage = 'Not provided'
const expectedLog = `HTTP Request (first 50 lines):
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
HTTP Response Code: ${requestClient['prettifyString'](noValueMessage)}
HTTP Response (first 50 lines):
${noValueMessage}
\n${requestClient['parseInterceptedBody'](noValueMessage)}
`
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
})
it('should log parsed response with status 2**', () => {
const status = getRandomStatus([
200, 201, 202, 203, 204, 205, 206, 207, 208, 226
])
const mockedResponse: AxiosResponse = {
data: resData,
status,
statusText: '',
headers: {},
config: {
data: reqData,
headers: {} as AxiosRequestHeaders
},
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } }
}
const requestClient = new RequestClient('')
requestClient['handleAxiosResponse'](mockedResponse)
const expectedLog = `HTTP Request (first 50 lines):
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
HTTP Response Code: ${requestClient['prettifyString'](status)}
HTTP Response (first 50 lines):
${resHeaders[0]}: ${resHeaders[1]}${
requestClient['parseInterceptedBody'](resData)
? `\n\n${requestClient['parseInterceptedBody'](resData)}`
: ''
}
`
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
})
it('should log parsed response with status 3**', () => {
const status = getRandomStatus([300, 301, 302, 303, 304, 307, 308])
const mockedAxiosError = {
config: {
data: reqData
},
request: {
_currentRequest: {
_header: reqHeaders
}
}
} as AxiosError
const requestClient = new RequestClient('')
requestClient['handleAxiosError'](mockedAxiosError)
const noValueMessage = 'Not provided'
const expectedLog = `HTTP Request (first 50 lines):
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
HTTP Response Code: ${requestClient['prettifyString'](noValueMessage)}
HTTP Response (first 50 lines):
${noValueMessage}
\n${requestClient['parseInterceptedBody'](noValueMessage)}
`
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
})
it('should log parsed response with status 4**', () => {
const spyIsAxiosError = jest
.spyOn(axios, 'isAxiosError')
.mockImplementation(() => true)
const status = getRandomStatus([
400, 401, 402, 403, 404, 407, 408, 409, 410, 411, 412, 413, 414, 415,
416, 417, 418, 421, 422, 423, 424, 425, 426, 428, 429, 431, 451
])
const mockedResponse: AxiosResponse = {
data: resData,
status,
statusText: '',
headers: {},
config: {
data: reqData,
headers: {} as AxiosRequestHeaders
},
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } }
}
const mockedAxiosError = {
config: {
data: reqData
},
request: {
_currentRequest: {
_header: reqHeaders
}
},
response: mockedResponse
} as AxiosError
const requestClient = new RequestClient('')
requestClient['handleAxiosError'](mockedAxiosError)
const expectedLog = `HTTP Request (first 50 lines):
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
HTTP Response Code: ${requestClient['prettifyString'](status)}
HTTP Response (first 50 lines):
${resHeaders[0]}: ${resHeaders[1]}${
requestClient['parseInterceptedBody'](resData)
? `\n\n${requestClient['parseInterceptedBody'](resData)}`
: ''
}
`
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
spyIsAxiosError.mockReset()
})
it('should log parsed response with status 5**', () => {
const spyIsAxiosError = jest
.spyOn(axios, 'isAxiosError')
.mockImplementation(() => true)
const status = getRandomStatus([
500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511
])
const mockedResponse: AxiosResponse = {
data: resData,
status,
statusText: '',
headers: {},
config: {
data: reqData,
headers: {} as AxiosRequestHeaders
},
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } }
}
const mockedAxiosError = {
config: {
data: reqData
},
request: {
_currentRequest: {
_header: reqHeaders
}
},
response: mockedResponse
} as AxiosError
const requestClient = new RequestClient('')
requestClient['handleAxiosError'](mockedAxiosError)
const expectedLog = `HTTP Request (first 50 lines):
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
HTTP Response Code: ${requestClient['prettifyString'](status)}
HTTP Response (first 50 lines):
${resHeaders[0]}: ${resHeaders[1]}${
requestClient['parseInterceptedBody'](resData)
? `\n\n${requestClient['parseInterceptedBody'](resData)}`
: ''
}
`
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
spyIsAxiosError.mockReset()
})
})
describe('enableVerboseMode', () => {
it('should add defaultInterceptionCallBack functions to response interceptors', () => {
const requestClient = new RequestClient('')
const interceptorSpy = jest.spyOn(
requestClient['httpClient'].interceptors.response,
'use'
)
requestClient.enableVerboseMode()
expect(interceptorSpy).toHaveBeenCalledWith(
requestClient['handleAxiosResponse'],
requestClient['handleAxiosError']
)
})
it('should add callback functions to response interceptors', () => {
const requestClient = new RequestClient('')
const interceptorSpy = jest.spyOn(
requestClient['httpClient'].interceptors.response,
'use'
)
const successCallback = (response: AxiosResponse) => {
console.log('success')
return response
}
const failureCallback = (response: AxiosError) => {
console.log('failure')
return response
}
requestClient.enableVerboseMode(successCallback, failureCallback)
expect(interceptorSpy).toHaveBeenCalledWith(
successCallback,
failureCallback
)
})
})
describe('setVerboseMode', () => {
it(`should set verbose mode`, () => {
const requestClient = new RequestClient('')
let verbose: VerboseMode = false
requestClient.setVerboseMode(verbose)
expect(requestClient['verboseMode']).toEqual(verbose)
verbose = true
requestClient.setVerboseMode(verbose)
expect(requestClient['verboseMode']).toEqual(verbose)
verbose = 'bleached'
requestClient.setVerboseMode(verbose)
expect(requestClient['verboseMode']).toEqual(verbose)
})
})
describe('prettifyString', () => {
const inspectMock = UtilsModule.inspect as unknown as jest.Mock
beforeEach(() => {
// Reset the mock before each test to ensure a clean slate
inspectMock.mockClear()
})
it(`should call inspect without colors when verbose mode is set to 'bleached'`, () => {
const requestClient = new RequestClient('')
requestClient.setVerboseMode('bleached')
const testStr = JSON.stringify({ test: 'test' })
requestClient['prettifyString'](testStr)
expect(UtilsModule.inspect).toHaveBeenCalledWith(testStr, {
colors: false
})
})
it(`should call inspect with colors when verbose mode is set to true`, () => {
const requestClient = new RequestClient('')
requestClient.setVerboseMode(true)
const testStr = JSON.stringify({ test: 'test' })
requestClient['prettifyString'](testStr)
expect(UtilsModule.inspect).toHaveBeenCalledWith(testStr, {
colors: true
})
})
})
describe('disableVerboseMode', () => {
it('should eject interceptor', () => {
const requestClient = new RequestClient('')
const interceptorSpy = jest.spyOn(
requestClient['httpClient'].interceptors.response,
'eject'
)
const interceptorId = 100
requestClient['httpInterceptor'] = interceptorId
requestClient.disableVerboseMode()
expect(interceptorSpy).toHaveBeenCalledWith(interceptorId)
})
})
describe('handleError', () => { describe('handleError', () => {
const requestClient = new RequestClient('https://localhost:8009') const requestClient = new RequestClient('https://localhost:8009')
const randomError = 'some error' const randomError = 'some error'
@@ -623,7 +213,7 @@ describe('RequestClient - Self Signed Server', () => {
serverType: ServerType.SasViya serverType: ServerType.SasViya
}) })
const expectedError = 'self-signed certificate' const expectedError = 'self signed certificate'
const rejectionErrorMessage = await adapterWithoutCertificate const rejectionErrorMessage = await adapterWithoutCertificate
.getAccessToken('clientId', 'clientSecret', 'authCode') .getAccessToken('clientId', 'clientSecret', 'authCode')
@@ -703,11 +293,3 @@ const createCertificate = async (): Promise<pem.CertificateCreationResult> => {
) )
}) })
} }
/**
* Returns a random status code.
* @param statuses - an array of available statuses.
* @returns - random item from an array of statuses.
*/
const getRandomStatus = (statuses: number[]) =>
statuses[Math.floor(Math.random() * statuses.length)]

View File

@@ -1,15 +1,7 @@
import express = require('express') import express = require('express')
import cors from 'cors'
export const app = express() export const app = express()
app.use(
cors({
origin: 'http://localhost', // Allow requests only from this origin
credentials: true // Allow credentials (cookies, auth headers, etc.)
})
)
export const mockedAuthResponse = { export const mockedAuthResponse = {
access_token: 'access_token', access_token: 'access_token',
token_type: 'bearer', token_type: 'bearer',
@@ -20,11 +12,11 @@ export const mockedAuthResponse = {
jti: 'jti' jti: 'jti'
} }
app.get('/', (req: any, res: any) => { app.get('/', function (req: any, res: any) {
res.send('Hello World') res.send('Hello World')
}) })
app.post('/SASLogon/oauth/token', (req: any, res: any) => { app.post('/SASLogon/oauth/token', function (req: any, res: any) {
let valid = true let valid = true
// capture the encoded form data // capture the encoded form data

View File

@@ -2,8 +2,8 @@ import { SessionManager } from '../SessionManager'
import { RequestClient } from '../request/RequestClient' import { RequestClient } from '../request/RequestClient'
import * as dotenv from 'dotenv' import * as dotenv from 'dotenv'
import axios from 'axios' import axios from 'axios'
import { Logger, LogLevel } from '@sasjs/utils/logger' import { Logger, LogLevel } from '@sasjs/utils'
import { Session, SessionState, Context } from '../types' import { Session, Context } from '../types'
jest.mock('axios') jest.mock('axios')
const mockedAxios = axios as jest.Mocked<typeof axios> const mockedAxios = axios as jest.Mocked<typeof axios>
@@ -11,34 +11,21 @@ const requestClient = new (<jest.Mock<RequestClient>>RequestClient)()
describe('SessionManager', () => { describe('SessionManager', () => {
dotenv.config() dotenv.config()
process.env.SERVER_URL = 'https://server.com'
const sessionManager = new SessionManager( const sessionManager = new SessionManager(
process.env.SERVER_URL as string, process.env.SERVER_URL as string,
process.env.DEFAULT_COMPUTE_CONTEXT as string, process.env.DEFAULT_COMPUTE_CONTEXT as string,
requestClient requestClient
) )
const sessionStateLink = '/compute/sessions/session-id-ses0000/state'
const sessionEtag = 'etag-string'
const getMockSession = (): Session => ({ const getMockSession = () => ({
id: ['id', new Date().getTime(), Math.random()].join('-'), id: ['id', new Date().getTime(), Math.random()].join('-'),
state: SessionState.NoState, state: '',
links: [ links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }],
{
href: sessionStateLink,
method: 'GET',
rel: 'state',
type: 'text/plain',
uri: sessionStateLink
}
],
attributes: { attributes: {
sessionInactiveTimeout: 900 sessionInactiveTimeout: 900
}, },
creationTimeStamp: `${new Date(new Date().getTime()).toISOString()}`, creationTimeStamp: `${new Date(new Date().getTime()).toISOString()}`
stateUrl: sessionStateLink,
etag: sessionEtag
}) })
afterEach(() => { afterEach(() => {
@@ -102,21 +89,19 @@ describe('SessionManager', () => {
describe('waitForSession', () => { describe('waitForSession', () => {
const session: Session = { const session: Session = {
id: 'id', id: 'id',
state: SessionState.NoState, state: '',
links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }], links: [{ rel: 'state', href: '', uri: '', type: '', method: 'GET' }],
attributes: { attributes: {
sessionInactiveTimeout: 0 sessionInactiveTimeout: 0
}, },
creationTimeStamp: '', creationTimeStamp: ''
stateUrl: sessionStateLink,
etag: sessionEtag
} }
beforeEach(() => { beforeEach(() => {
;(process as any).logger = new Logger(LogLevel.Off) ;(process as any).logger = new Logger(LogLevel.Off)
}) })
it('should log http response code and session state if SAS server did not provide session state', async () => { it('should reject with NoSessionStateError if SAS server did not provide session state', async () => {
let requestAttempt = 0 let requestAttempt = 0
const requestAttemptLimit = 10 const requestAttemptLimit = 10
const sessionState = 'idle' const sessionState = 'idle'
@@ -139,17 +124,15 @@ describe('SessionManager', () => {
sessionManager['waitForSession'](session, null, 'access_token') sessionManager['waitForSession'](session, null, 'access_token')
).resolves.toEqual(sessionState) ).resolves.toEqual(sessionState)
const sessionStateUrl = process.env.SERVER_URL + session.stateUrl
expect(mockedAxios.get).toHaveBeenCalledTimes(requestAttemptLimit) expect(mockedAxios.get).toHaveBeenCalledTimes(requestAttemptLimit)
expect((process as any).logger.info).toHaveBeenCalledTimes(3) expect((process as any).logger.info).toHaveBeenCalledTimes(3)
expect((process as any).logger.info).toHaveBeenNthCalledWith( expect((process as any).logger.info).toHaveBeenNthCalledWith(
1, 1,
`Polling: ${sessionStateUrl}` `Polling: ${process.env.SERVER_URL}`
) )
expect((process as any).logger.info).toHaveBeenNthCalledWith( expect((process as any).logger.info).toHaveBeenNthCalledWith(
2, 2,
`Could not get session state. Server responded with 304 whilst checking state: ${sessionStateUrl}` `Could not get session state. Server responded with 304 whilst checking state: ${process.env.SERVER_URL}`
) )
expect((process as any).logger.info).toHaveBeenNthCalledWith( expect((process as any).logger.info).toHaveBeenNthCalledWith(
3, 3,
@@ -159,7 +142,7 @@ describe('SessionManager', () => {
it('should throw an error if there is no session link', async () => { it('should throw an error if there is no session link', async () => {
const customSession = JSON.parse(JSON.stringify(session)) const customSession = JSON.parse(JSON.stringify(session))
customSession.stateUrl = '' customSession.links = []
mockedAxios.get.mockImplementation(() => mockedAxios.get.mockImplementation(() =>
Promise.resolve({ data: customSession.state, status: 200 }) Promise.resolve({ data: customSession.state, status: 200 })
@@ -173,7 +156,6 @@ describe('SessionManager', () => {
it('should throw an error if could not get session state', async () => { it('should throw an error if could not get session state', async () => {
const gettingSessionStatus = 500 const gettingSessionStatus = 500
const sessionStatusError = `Getting session status timed out after 60 seconds. Request failed with status code ${gettingSessionStatus}` const sessionStatusError = `Getting session status timed out after 60 seconds. Request failed with status code ${gettingSessionStatus}`
const sessionStateUrl = process.env.SERVER_URL + session.stateUrl
mockedAxios.get.mockImplementation(() => mockedAxios.get.mockImplementation(() =>
Promise.reject({ Promise.reject({
@@ -186,7 +168,7 @@ describe('SessionManager', () => {
}) })
) )
const expectedError = `Error while waiting for session. Error while getting session state. GET request to ${sessionStateUrl}?wait=30 failed with status code ${gettingSessionStatus}. ${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( await expect(
sessionManager['waitForSession'](session, null, 'access_token') sessionManager['waitForSession'](session, null, 'access_token')
@@ -445,45 +427,4 @@ describe('SessionManager', () => {
) )
}) })
}) })
describe('createAndWaitForSession', () => {
it('should create session with etag and stateUrl', async () => {
const etag = sessionEtag
const customSession: any = getMockSession()
delete customSession.etag
delete customSession.stateUrl
jest.spyOn(requestClient, 'post').mockImplementation(() =>
Promise.resolve({
result: customSession,
etag
})
)
jest
.spyOn(sessionManager as any, 'setCurrentContext')
.mockImplementation(() => Promise.resolve())
sessionManager['currentContext'] = {
name: 'context name',
id: 'string',
createdBy: 'string',
version: 1
}
jest
.spyOn(sessionManager as any, 'getSessionState')
.mockImplementation(() =>
Promise.resolve({ result: SessionState.Idle, responseStatus: 200 })
)
const expectedSession = await sessionManager['createAndWaitForSession']()
expect(customSession.id).toEqual(expectedSession.id)
expect(
customSession.links.find((l: any) => l.rel === 'state').href
).toEqual(expectedSession.stateUrl)
expect(expectedSession.etag).toEqual(etag)
})
})
}) })

View File

@@ -1,33 +0,0 @@
export interface FileResource {
creationTimeStamp: string
modifiedTimeStamp: string
createdBy: string
modifiedBy: string
id: string
properties: Properties
contentDisposition: string
contentType: string
encoding: string
links: Link[]
name: string
size: number
searchable: boolean
fileStatus: string
fileVersion: number
typeDefName: string
version: number
virusDetected: boolean
urlDetected: boolean
quarantine: boolean
}
export interface Link {
method: string
rel: string
href: string
uri: string
type?: string
responseType?: string
}
export interface Properties {}

View File

@@ -1,9 +1,6 @@
export interface PollOptions { export interface PollOptions {
maxPollCount: number maxPollCount: number
pollInterval: number // milliseconds pollInterval: number
pollStrategy?: PollStrategy streamLog: boolean
streamLog?: boolean
logFolderPath?: string logFolderPath?: string
} }
export type PollStrategy = PollOptions[]

View File

@@ -1,55 +0,0 @@
import { CsrfToken } from '..'
export interface HttpClient {
get<T>(
url: string,
accessToken: string | undefined,
contentType: string,
overrideHeaders: { [key: string]: string | number }
): Promise<{ result: T; etag: string }>
post<T>(
url: string,
data: any,
accessToken: string | undefined,
contentType: string,
overrideHeaders: { [key: string]: string | number }
): Promise<{ result: T; etag: string }>
put<T>(
url: string,
data: any,
accessToken: string | undefined,
overrideHeaders: { [key: string]: string | number }
): Promise<{ result: T; etag: string }>
delete<T>(
url: string,
accessToken: string | undefined
): Promise<{ result: T; etag: string }>
getCsrfToken(type: 'general' | 'file'): CsrfToken | undefined
saveLocalStorageToken(accessToken: string, refreshToken: string): void
clearCsrfTokens(): void
clearLocalStorageTokens(): void
getBaseUrl(): string
}
export interface SASjsRequest {
serviceLink: string
timestamp: Date
sourceCode: string
generatedCode: string
logFile: string
SASWORK: any
}
export interface SasjsParsedResponse<T> {
result: T
log: string
etag: string
status: number
printOutput?: string
}
export type VerboseMode = boolean | 'bleached'

View File

@@ -1,6 +1,5 @@
import * as https from 'https' import * as https from 'https'
import { ServerType } from '@sasjs/utils/types' import { ServerType } from '@sasjs/utils/types'
import { VerboseMode } from '../types'
/** /**
* Specifies the configuration for the SASjs instance - eg where and how to * Specifies the configuration for the SASjs instance - eg where and how to
@@ -46,10 +45,6 @@ export class SASjsConfig {
* Set to `true` to enable additional debugging. * Set to `true` to enable additional debugging.
*/ */
debug: boolean = true debug: boolean = true
/**
* Set to `true` to enable verbose mode that will log a summary of every HTTP response.
*/
verbose?: VerboseMode = true
/** /**
* The name of the compute context to use when calling the Viya services directly. * The name of the compute context to use when calling the Viya services directly.
* Example value: 'SAS Job Execution compute context' * Example value: 'SAS Job Execution compute context'

12
src/types/SASjsRequest.ts Normal file
View File

@@ -0,0 +1,12 @@
/**
* Represents a SASjs request, its response and logs.
*
*/
export interface SASjsRequest {
serviceLink: string
timestamp: Date
sourceCode: string
generatedCode: string
logFile: string
SASWORK: any
}

View File

@@ -1,34 +1,15 @@
import { Link } from './Link' import { Link } from './Link'
import { SessionManager } from '../SessionManager'
export enum SessionState {
Completed = 'completed',
Running = 'running',
Pending = 'pending',
Idle = 'idle',
Unavailable = 'unavailable',
NoState = '',
Failed = 'failed',
Error = 'error'
}
export interface Session { export interface Session {
id: string id: string
state: SessionState state: string
stateUrl: string
links: Link[] links: Link[]
attributes: { attributes: {
sessionInactiveTimeout: number sessionInactiveTimeout: number
} }
creationTimeStamp: string creationTimeStamp: string
etag: string
} }
export interface SessionVariable { export interface SessionVariable {
value: string value: string
} }
export interface JobSessionManager {
session: Session
sessionManager: SessionManager
}

View File

@@ -1,10 +1,4 @@
import { WriteStream as FsWriteStream } from 'fs' export interface WriteStream {
write: (content: string, callback: (err?: Error) => any) => void
export interface WriteStream extends FsWriteStream { path: string
write(
chunk: any,
encoding?: BufferEncoding | ((error: Error | null | undefined) => void),
cb?: (error: Error | null | undefined) => void
): boolean
path: string | Buffer
} }

View File

@@ -7,7 +7,7 @@ describe('RootFolderNotFoundError', () => {
const error = new RootFolderNotFoundError( const error = new RootFolderNotFoundError(
'/myProject', '/myProject',
'https://sas.4gl.io', 'https://analytium.co.uk',
token token
) )
@@ -19,7 +19,7 @@ describe('RootFolderNotFoundError', () => {
it('when access token is not provided, error message should not contain scopes', () => { it('when access token is not provided, error message should not contain scopes', () => {
const error = new RootFolderNotFoundError( const error = new RootFolderNotFoundError(
'/myProject', '/myProject',
'https://sas.4gl.io' 'https://analytium.co.uk'
) )
expect(error).toBeInstanceOf(RootFolderNotFoundError) expect(error).toBeInstanceOf(RootFolderNotFoundError)
@@ -30,7 +30,7 @@ describe('RootFolderNotFoundError', () => {
it('should include the folder path and SASDrive URL in the message', () => { it('should include the folder path and SASDrive URL in the message', () => {
const folderPath = '/myProject' const folderPath = '/myProject'
const serverUrl = 'https://sas.4gl.io' const serverUrl = 'https://analytium.co.uk'
const error = new RootFolderNotFoundError(folderPath, serverUrl) const error = new RootFolderNotFoundError(folderPath, serverUrl)
expect(error).toBeInstanceOf(RootFolderNotFoundError) expect(error).toBeInstanceOf(RootFolderNotFoundError)

View File

@@ -1,30 +0,0 @@
import { SAS9AuthError } from '../SAS9AuthError'
describe('SAS9AuthError', () => {
it('should have the correct error message', () => {
const error = new SAS9AuthError()
expect(error.message).toBe(
'The credentials you provided cannot be authenticated. Please provide a valid set of credentials.'
)
})
it('should have the correct error name', () => {
const error = new SAS9AuthError()
expect(error.name).toBe('AuthorizeError')
})
it('should be an instance of SAS9AuthError', () => {
const error = new SAS9AuthError()
expect(error).toBeInstanceOf(SAS9AuthError)
})
it('should be an instance of Error', () => {
const error = new SAS9AuthError()
expect(error).toBeInstanceOf(Error)
})
it('should set the prototype correctly', () => {
const error = new SAS9AuthError()
expect(Object.getPrototypeOf(error)).toBe(SAS9AuthError.prototype)
})
})

View File

@@ -6,12 +6,10 @@ export * from './Job'
export * from './JobDefinition' export * from './JobDefinition'
export * from './JobResult' export * from './JobResult'
export * from './Link' export * from './Link'
export * from './Login'
export * from './SASjsConfig' export * from './SASjsConfig'
export * from './RequestClient' export * from './SASjsRequest'
export * from './Session' export * from './Session'
export * from './UploadFile' export * from './UploadFile'
export * from './PollOptions' export * from './PollOptions'
export * from './WriteStream' export * from './WriteStream'
export * from './ExecuteScript' export * from './ExecuteScript'
export * from './errors'

View File

@@ -1,4 +1,4 @@
import { SASjsRequest } from '../types' import { SASjsRequest } from '../types/SASjsRequest'
/** /**
* Comparator for SASjs request timestamps. * Comparator for SASjs request timestamps.

2
src/utils/constants.ts Normal file
View File

@@ -0,0 +1,2 @@
export const SASJS_LOGS_SEPARATOR =
'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784'

View File

@@ -10,14 +10,10 @@ export const convertToCSV = (
tableName: string tableName: string
) => { ) => {
if (!data[tableName]) { if (!data[tableName]) {
const error = prefixMessage( throw prefixMessage(
'No table provided to be converted to CSV.', 'No table provided to be converted to CSV.',
'Error while converting to CSV. ' 'Error while converting to CSV. '
) )
if (typeof error === 'string') throw new Error(error)
throw error
} }
const table = data[tableName] const table = data[tableName]

View File

@@ -1,5 +0,0 @@
import { isNode } from './'
import NodeFormData from 'form-data'
export const getFormData = (): NodeFormData | FormData =>
isNode() ? new NodeFormData() : new FormData()

View File

@@ -1,10 +0,0 @@
interface IEnavigator {
userLanguage?: string
}
/**
* Provides preferred language of the user.
* @returns A string representing the preferred language of the user, usually the language of the browser UI. Examples of valid language codes include "en", "en-US", "fr", "fr-FR", "es-ES". More info available https://datatracker.ietf.org/doc/html/rfc5646
*/
export const getUserLanguage = () =>
window.navigator.language || (window.navigator as IEnavigator).userLanguage

View File

@@ -2,6 +2,7 @@ export * from './appendExtraResponseAttributes'
export * from './asyncForEach' export * from './asyncForEach'
export * from './compareTimestamps' export * from './compareTimestamps'
export * from './convertToCsv' export * from './convertToCsv'
export * from './constants'
export * from './createAxiosInstance' export * from './createAxiosInstance'
export * from './delay' export * from './delay'
export * from './fetchLogByChunks' export * from './fetchLogByChunks'
@@ -19,5 +20,3 @@ export * from './parseWeboutResponse'
export * from './serialize' export * from './serialize'
export * from './splitChunks' export * from './splitChunks'
export * from './validateInput' export * from './validateInput'
export * from './getFormData'
export * from './getUserLanguage'

View File

@@ -1,20 +0,0 @@
import { getFormData } from '..'
import * as isNodeModule from '../isNode'
import NodeFormData from 'form-data'
describe('getFormData', () => {
it('should return NodeFormData if environment is Node', () => {
jest.spyOn(isNodeModule, 'isNode').mockImplementation(() => true)
expect(getFormData() instanceof NodeFormData).toEqual(true)
})
it('should return FormData if environment is not Node', () => {
// Ensure FormData is globally available
;(global as any).FormData = class FormData {}
jest.spyOn(isNodeModule, 'isNode').mockImplementation(() => false)
expect(getFormData() instanceof FormData).toEqual(true)
})
})

View File

@@ -1,24 +0,0 @@
import { parseSasViyaLog } from '../parseSasViyaLog'
describe('parseSasViyaLog', () => {
it('should parse sas viya log if environment is Node', () => {
const logResponse = {
items: [{ line: 'Line 1' }, { line: 'Line 2' }, { line: 'Line 3' }]
}
const expectedLog = 'Line 1\nLine 2\nLine 3'
const result = parseSasViyaLog(logResponse)
expect(result).toEqual(expectedLog)
})
it('should handle exceptions and return the original logResponse', () => {
// Create a logResponse that will cause an error in the mapping process.
const logResponse: any = {
items: null
}
// Since logResponse.items is null, the ternary operator returns the else branch.
const expectedLog = JSON.stringify(logResponse)
const result = parseSasViyaLog(logResponse)
expect(result).toEqual(expectedLog)
})
})

Some files were not shown because too many files have changed in this diff Show More