1
0
mirror of https://github.com/sasjs/adapter.git synced 2025-12-11 01:14:36 +00:00

Merge pull request #841 from sasjs/bump-axios

fix: axios bump
This commit is contained in:
Allan Bowe
2025-03-14 11:16:54 +00:00
committed by GitHub
52 changed files with 18300 additions and 10367 deletions

49
.github/workflows/build-unit-tests.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
# 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 }}
cache: npm
- 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,13 +1,13 @@
# 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 name: SASjs Build and Server Tests
on: on:
pull_request: pull_request:
jobs: jobs:
build: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
strategy: strategy:
@@ -22,22 +22,12 @@ jobs:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: npm cache: npm
# FIXME: uncomment 'Check npm audit' step after axios version bump
# - name: Check npm audit
# run: npm audit --production --audit-level=low
- name: Install Dependencies - name: Install Dependencies
run: npm ci run: npm ci
- name: Install Rimraf - name: Install Rimraf
run: npm i rimraf run: npm i rimraf
- name: Check code style
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
env: env:
@@ -106,9 +96,3 @@ jobs:
echo "SASJS_USERNAME=${{ secrets.SASJS_USERNAME }}" 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

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

Binary file not shown.

View File

@@ -142,6 +142,8 @@ 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,

3773
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,18 +45,21 @@
"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": "27.4.0", "@types/jest": "29.5.14",
"@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.2",
"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": "27.4.7", "jest": "29.7.0",
"jest-extended": "2.0.0", "jest-environment-jsdom": "^29.7.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",
@@ -64,21 +67,21 @@
"process": "0.11.10", "process": "0.11.10",
"semantic-release": "19.0.3", "semantic-release": "19.0.3",
"terser-webpack-plugin": "5.3.6", "terser-webpack-plugin": "5.3.6",
"ts-jest": "27.1.3", "ts-jest": "29.2.6",
"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.8.3", "typescript": "4.9.5",
"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.1", "@sasjs/utils": "3.5.2",
"axios": "0.27.2", "axios": "1.8.2",
"axios-cookiejar-support": "1.0.1", "axios-cookiejar-support": "5.0.5",
"form-data": "4.0.0", "form-data": "4.0.0",
"https": "1.0.0", "https": "1.0.0",
"tough-cookie": "4.1.3" "tough-cookie": "4.1.3"

View File

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

View File

@@ -0,0 +1,15 @@
// 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,6 +4,7 @@
"homepage": ".", "homepage": ".",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@sasjs/adapter": "file:../build/sasjs-adapter-5.0.0.tgz",
"@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",
@@ -13,12 +14,12 @@
"react": "^16.0.1", "react": "^16.0.1",
"react-dom": "^16.0.1", "react-dom": "^16.0.1",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-scripts": "^5.0.1", "react-scripts": "4.0.3",
"typescript": "^4.1.3" "typescript": "^4.1.3"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "NODE_OPTIONS=--openssl-legacy-provider react-scripts start",
"build": "react-scripts build", "build": "NODE_OPTIONS=--openssl-legacy-provider craco 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",
@@ -42,6 +43,8 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"node-sass": "9.0.0" "@craco/craco": "6.4.3",
"node-sass": "9.0.0",
"source-map-loader": "0.2.4"
} }
} }

View File

@@ -24,6 +24,26 @@
"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

@@ -87,6 +87,20 @@ 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:
@@ -159,20 +173,6 @@ 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,8 +134,19 @@ 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 we can't process the special characters // If sas session is `latin9` or `wlatin1` we can't process the special characters,
if (res.SYSENCODING === 'latin9') return true // 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 &&

View File

@@ -6,6 +6,8 @@
"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 * as NodeFormData from 'form-data' import 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 * as NodeFormData from 'form-data' import NodeFormData from 'form-data'
import { import {
Job, Job,
Session, Session,

View File

@@ -1170,8 +1170,8 @@ export default class SASjs {
* @param errorCallBack - function that should be triggered on every HTTP response with the status different from 2**. * @param errorCallBack - function that should be triggered on every HTTP response with the status different from 2**.
*/ */
public enableVerboseMode( public enableVerboseMode(
successCallBack?: (response: AxiosResponse | AxiosError) => AxiosResponse, successCallBack?: (response: AxiosResponse) => AxiosResponse,
errorCallBack?: (response: AxiosResponse | AxiosError) => AxiosResponse errorCallBack?: (response: AxiosError) => AxiosError
) { ) {
this.requestClient?.enableVerboseMode(successCallBack, errorCallBack) this.requestClient?.enableVerboseMode(successCallBack, errorCallBack)
} }

View File

@@ -1,4 +1,4 @@
import * as NodeFormData from 'form-data' import 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

@@ -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()) .mockImplementation(() => Promise.resolve(true))
} }

View File

@@ -10,12 +10,22 @@ import {
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)
@@ -25,11 +35,30 @@ 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((_, callback) => callback(new Error('Test Error'))) .mockImplementation(
(
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,9 +3,14 @@ import { WriteStream } from '../../types'
export const writeStream = async ( export const writeStream = async (
stream: WriteStream, stream: WriteStream,
content: string content: string
): Promise<void> => ): Promise<boolean> => {
stream.write(content + '\n', (e: any) => { return new Promise((resolve, reject) => {
if (e) return Promise.reject(e) stream.write(content + '\n', (err: Error | null | undefined) => {
if (err) {
return Promise.resolve() reject(err) // Reject on write error
} else {
resolve(true) // Resolve on successful write
}
})
}) })
}

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 * as NodeFormData from 'form-data' import 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

@@ -159,7 +159,7 @@ describe('AuthManager', () => {
`/SASLogon/login`, `/SASLogon/login`,
loginParams, loginParams,
{ {
withCredentials: true, withXSRFToken: true,
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Accept: '*/*' Accept: '*/*'
@@ -207,7 +207,7 @@ describe('AuthManager', () => {
`/SASLogon/login`, `/SASLogon/login`,
loginParams, loginParams,
{ {
withCredentials: true, withXSRFToken: true,
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Accept: '*/*' Accept: '*/*'
@@ -256,7 +256,7 @@ describe('AuthManager', () => {
`/SASLogon/login`, `/SASLogon/login`,
loginParams, loginParams,
{ {
withCredentials: true, withXSRFToken: true,
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Accept: '*/*' Accept: '*/*'
@@ -539,7 +539,7 @@ describe('AuthManager', () => {
1, 1,
`http://test-server.com/identities/users/@currentUser`, `http://test-server.com/identities/users/@currentUser`,
{ {
withCredentials: true, withXSRFToken: true,
responseType: 'text', responseType: 'text',
transformResponse: undefined, transformResponse: undefined,
headers: { headers: {
@@ -573,7 +573,7 @@ describe('AuthManager', () => {
1, 1,
`http://test-server.com/SASStoredProcess`, `http://test-server.com/SASStoredProcess`,
{ {
withCredentials: true, withXSRFToken: true,
responseType: 'text', responseType: 'text',
transformResponse: undefined, transformResponse: undefined,
headers: { headers: {
@@ -602,7 +602,7 @@ describe('AuthManager', () => {
1, 1,
`http://test-server.com/identities/users/@currentUser`, `http://test-server.com/identities/users/@currentUser`,
{ {
withCredentials: true, withXSRFToken: true,
responseType: 'text', responseType: 'text',
transformResponse: undefined, transformResponse: undefined,
headers: { headers: {
@@ -621,7 +621,7 @@ describe('AuthManager', () => {
}) })
const getHeadersJson = { const getHeadersJson = {
withCredentials: true, withXSRFToken: true,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Accept: 'application/json' Accept: 'application/json'

View File

@@ -1,5 +1,5 @@
import { AuthConfig } from '@sasjs/utils/types' import { AuthConfig } from '@sasjs/utils/types'
import * as NodeFormData from 'form-data' import 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,5 +1,5 @@
import { AuthConfig, ServerType } from '@sasjs/utils/types' import { AuthConfig, ServerType } from '@sasjs/utils/types'
import * as NodeFormData from 'form-data' import 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

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

View File

@@ -1,4 +1,4 @@
import * as NodeFormData from 'form-data' import 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,6 +1,6 @@
import { generateFileUploadForm } from '../generateFileUploadForm' import { generateFileUploadForm } from '../generateFileUploadForm'
import { convertToCSV } from '../../utils/convertToCsv' import { convertToCSV } from '../../utils/convertToCsv'
import * as NodeFormData from 'form-data' import NodeFormData from 'form-data'
import * as isNodeModule from '../../utils/isNode' import * as isNodeModule from '../../utils/isNode'
describe('generateFileUploadForm', () => { describe('generateFileUploadForm', () => {

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 * as NodeFormData from 'form-data' import 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 * as NodeFormData from 'form-data' import NodeFormData from 'form-data'
import { import {
AuthConfig, AuthConfig,
ExtraResponseAttributes, ExtraResponseAttributes,
@@ -73,8 +73,10 @@ 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=${formData.getBoundary()}` ? `multipart/form-data; boundary=${
: undefined formData.getHeaders()['content-type']
}`
: 'multipart/form-data'
const requestPromise = new Promise((resolve, reject) => { const requestPromise = new Promise((resolve, reject) => {
this.requestClient!.post( this.requestClient!.post(

View File

@@ -1,4 +1,4 @@
import * as NodeFormData from 'form-data' import NodeFormData from 'form-data'
import { import {
AuthConfig, AuthConfig,
ExtraResponseAttributes, ExtraResponseAttributes,
@@ -150,8 +150,10 @@ 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=${formData.getBoundary()}` ? `multipart/form-data; boundary=${
: undefined formData.getHeaders()['content-type']
}`
: 'multipart/form-data'
const requestPromise = new Promise((resolve, reject) => { const requestPromise = new Promise((resolve, reject) => {
this.requestClient!.post( this.requestClient!.post(

View File

@@ -2,9 +2,9 @@ import {
AxiosError, AxiosError,
AxiosInstance, AxiosInstance,
AxiosRequestConfig, AxiosRequestConfig,
AxiosRequestHeaders,
AxiosResponse AxiosResponse
} from 'axios' } from 'axios'
import axios 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'
@@ -160,7 +160,7 @@ 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',
withCredentials: true withXSRFToken: true
} }
if (contentType === 'text/plain') { if (contentType === 'text/plain') {
@@ -191,6 +191,13 @@ 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,
@@ -207,7 +214,7 @@ export class RequestClient implements HttpClient {
return this.httpClient return this.httpClient
.post<T>(url, data, { .post<T>(url, data, {
headers, headers,
withCredentials: true, withXSRFToken: true,
...additionalSettings ...additionalSettings
}) })
.then((response) => { .then((response) => {
@@ -234,7 +241,7 @@ export class RequestClient implements HttpClient {
} }
return this.httpClient return this.httpClient
.put<T>(url, data, { headers, withCredentials: true }) .put<T>(url, data, { headers, withXSRFToken: true })
.then((response) => { .then((response) => {
throwIfError(response) throwIfError(response)
return this.parseResponse<T>(response) return this.parseResponse<T>(response)
@@ -253,7 +260,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, withCredentials: true }) .delete<T>(url, { headers, withXSRFToken: true })
.then((response) => { .then((response) => {
throwIfError(response) throwIfError(response)
return this.parseResponse<T>(response) return this.parseResponse<T>(response)
@@ -271,7 +278,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, withCredentials: true }) .patch<T>(url, data, { headers, withXSRFToken: true })
.then((response) => { .then((response) => {
throwIfError(response) throwIfError(response)
return this.parseResponse<T>(response) return this.parseResponse<T>(response)
@@ -413,95 +420,17 @@ export class RequestClient implements HttpClient {
return bodyLines.join('\n') return bodyLines.join('\n')
} }
private defaultInterceptionCallBack = ( private handleAxiosResponse = (response: AxiosResponse) => {
axiosResponse: AxiosResponse | AxiosError const { status, config, request, data } = response
) => {
// Message indicating absent value.
const noValueMessage = 'Not provided'
// Fallback request object that can be safely used to form request summary. const reqHeaders = request?._header ?? 'Not provided\n'
type FallbackRequest = { _header?: string; res: { rawHeaders: string[] } } const rawHeaders = request?.res?.rawHeaders ?? ['Not provided']
// _header is not present in responses with status 1**
// rawHeaders are not present in responses with status 1**
let fallbackRequest: FallbackRequest = {
_header: `${noValueMessage}\n`,
res: { rawHeaders: [noValueMessage] }
}
// Fallback response object that can be safely used to form response summary. const resHeaders = this.formatHeaders(rawHeaders)
type FallbackResponse = { const parsedResBody = this.parseInterceptedBody(data)
status?: number | string
request?: FallbackRequest
config: { data?: string }
data?: unknown
}
let fallbackResponse: FallbackResponse = axiosResponse
if (axios.isAxiosError(axiosResponse)) {
const { response, request, config } = axiosResponse
// Try to use axiosResponse.response to form response summary.
if (response) {
fallbackResponse = response
} else {
// Try to use axiosResponse.request to form request summary.
if (request) {
const { _header, _currentRequest } = request
// Try to use axiosResponse.request._header to form request summary.
if (_header) {
fallbackRequest._header = _header
}
// Try to use axiosResponse.request._currentRequest._header to form request summary.
else if (_currentRequest && _currentRequest._header) {
fallbackRequest._header = _currentRequest._header
}
const { res } = request
// Try to use axiosResponse.request.res.rawHeaders to form request summary.
if (res && res.rawHeaders) {
fallbackRequest.res.rawHeaders = res.rawHeaders
}
}
// Fallback config that can be safely used to form response summary.
const fallbackConfig = { data: noValueMessage }
fallbackResponse = {
status: noValueMessage,
request: fallbackRequest,
config: config || fallbackConfig,
data: noValueMessage
}
}
}
const { status, config, request, data: resData } = fallbackResponse
const { data: reqData } = config
const { _header: reqHeaders, res } = request || fallbackRequest
const { rawHeaders } = res
// Converts an array of strings into a single string with the following format:
// <headerName>: <headerValue>
const resHeaders = rawHeaders.reduce(
(acc: string, value: string, i: number) => {
if (i % 2 === 0) {
acc += `${i === 0 ? '' : '\n'}${value}`
} else {
acc += `: ${value}`
}
return acc
},
''
)
const parsedResBody = this.parseInterceptedBody(resData)
// HTTP response summary.
process.logger?.info(`HTTP Request (first 50 lines): process.logger?.info(`HTTP Request (first 50 lines):
${reqHeaders}${this.parseInterceptedBody(reqData)} ${reqHeaders}${this.parseInterceptedBody(config.data)}
HTTP Response Code: ${this.prettifyString(status)} HTTP Response Code: ${this.prettifyString(status)}
@@ -509,7 +438,70 @@ HTTP Response (first 50 lines):
${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''} ${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
`) `)
return axiosResponse 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
}, '')
} }
/** /**
@@ -529,8 +521,8 @@ ${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
* @param errorCallBack - function that should be triggered on every HTTP response with the status different from 2**. * @param errorCallBack - function that should be triggered on every HTTP response with the status different from 2**.
*/ */
public enableVerboseMode = ( public enableVerboseMode = (
successCallBack = this.defaultInterceptionCallBack, successCallBack = this.handleAxiosResponse,
errorCallBack = this.defaultInterceptionCallBack errorCallBack = this.handleAxiosError
) => { ) => {
this.httpInterceptor = this.httpClient.interceptors.response.use( this.httpInterceptor = this.httpClient.interceptors.response.use(
successCallBack, successCallBack,
@@ -645,7 +637,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('/', {
withCredentials: true withXSRFToken: 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 axiosCookieJarSupport from 'axios-cookiejar-support' import { wrapper } 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 (axiosCookieJarSupport) { if (wrapper) {
axiosCookieJarSupport(this.httpClient) wrapper(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',
withCredentials: true withXSRFToken: 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, withCredentials: true }) .post<T>(url, data, { headers, withXSRFToken: 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,6 +1,6 @@
import { SASJS_LOGS_SEPARATOR, SasjsRequestClient } from '../SasjsRequestClient' import { SASJS_LOGS_SEPARATOR, SasjsRequestClient } from '../SasjsRequestClient'
import { SasjsParsedResponse } from '../../types' import { SasjsParsedResponse } from '../../types'
import { AxiosResponse } from 'axios' import { AxiosRequestHeaders, AxiosResponse } from 'axios'
describe('SasjsRequestClient', () => { describe('SasjsRequestClient', () => {
const requestClient = new SasjsRequestClient('') const requestClient = new SasjsRequestClient('')
@@ -37,7 +37,9 @@ ${SASJS_LOGS_SEPARATOR}`,
status, status,
statusText: 'ok', statusText: 'ok',
headers: { etag }, headers: { etag },
config: {} config: {
headers: {} as AxiosRequestHeaders
}
} }
const expectedParsedResponse: SasjsParsedResponse<string> = { const expectedParsedResponse: SasjsParsedResponse<string> = {
@@ -65,7 +67,9 @@ ${printOutput}`,
status, status,
statusText: 'ok', statusText: 'ok',
headers: { etag }, headers: { etag },
config: {} config: {
headers: {} as AxiosRequestHeaders
}
} }
const expectedParsedResponse: SasjsParsedResponse<string> = { const expectedParsedResponse: SasjsParsedResponse<string> = {
@@ -100,7 +104,9 @@ ${SASJS_LOGS_SEPARATOR}`,
status, status,
statusText: 'ok', statusText: 'ok',
headers: { etag }, headers: { etag },
config: {} config: {
headers: {} as AxiosRequestHeaders
}
} }
const expectedParsedResponse: SasjsParsedResponse<string> = { const expectedParsedResponse: SasjsParsedResponse<string> = {
@@ -139,7 +145,9 @@ ${printOutput}`,
status, status,
statusText: 'ok', statusText: 'ok',
headers: { etag }, headers: { etag },
config: {} config: {
headers: {} as AxiosRequestHeaders
}
} }
const expectedParsedResponse: SasjsParsedResponse<string> = { const expectedParsedResponse: SasjsParsedResponse<string> = {

View File

@@ -0,0 +1,130 @@
/**
* @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

@@ -0,0 +1,231 @@
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

@@ -5,7 +5,7 @@ import { app, mockedAuthResponse } from './SAS_server_app'
import { ServerType } from '@sasjs/utils/types' import { ServerType } from '@sasjs/utils/types'
import SASjs from '../SASjs' import SASjs from '../SASjs'
import * as axiosModules from '../utils/createAxiosInstance' import * as axiosModules from '../utils/createAxiosInstance'
import axios from 'axios' import axios, { AxiosRequestHeaders } from 'axios'
import { import {
LoginRequiredError, LoginRequiredError,
AuthorizeError, AuthorizeError,
@@ -24,9 +24,17 @@ 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 }) axiosActual.create({ baseURL, httpsAgent, withXSRFToken: true })
) )
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}/`
@@ -75,7 +83,7 @@ describe('RequestClient', () => {
expect(rejectionErrorMessage).toEqual(expectedError.message) expect(rejectionErrorMessage).toEqual(expectedError.message)
}) })
describe('defaultInterceptionCallBack', () => { describe('defaultInterceptionCallBacks for successful requests and failed requests', () => {
const reqHeaders = `POST https://sas.server.com/compute/sessions/session_id/jobs HTTP/1.1 const reqHeaders = `POST https://sas.server.com/compute/sessions/session_id/jobs HTTP/1.1
Accept: application/json Accept: application/json
Content-Type: application/json Content-Type: application/json
@@ -165,10 +173,6 @@ Connection: close
}) })
it('should log parsed response with status 1**', () => { it('should log parsed response with status 1**', () => {
const spyIsAxiosError = jest
.spyOn(axios, 'isAxiosError')
.mockImplementation(() => true)
const mockedAxiosError = { const mockedAxiosError = {
config: { config: {
data: reqData data: reqData
@@ -181,7 +185,7 @@ Connection: close
} as AxiosError } as AxiosError
const requestClient = new RequestClient('') const requestClient = new RequestClient('')
requestClient['defaultInterceptionCallBack'](mockedAxiosError) requestClient['handleAxiosError'](mockedAxiosError)
const noValueMessage = 'Not provided' const noValueMessage = 'Not provided'
const expectedLog = `HTTP Request (first 50 lines): const expectedLog = `HTTP Request (first 50 lines):
@@ -195,8 +199,6 @@ ${noValueMessage}
` `
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog) expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
spyIsAxiosError.mockReset()
}) })
it('should log parsed response with status 2**', () => { it('should log parsed response with status 2**', () => {
@@ -209,12 +211,15 @@ ${noValueMessage}
status, status,
statusText: '', statusText: '',
headers: {}, headers: {},
config: { data: reqData }, config: {
data: reqData,
headers: {} as AxiosRequestHeaders
},
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } } request: { _header: reqHeaders, res: { rawHeaders: resHeaders } }
} }
const requestClient = new RequestClient('') const requestClient = new RequestClient('')
requestClient['defaultInterceptionCallBack'](mockedResponse) requestClient['handleAxiosResponse'](mockedResponse)
const expectedLog = `HTTP Request (first 50 lines): const expectedLog = `HTTP Request (first 50 lines):
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)} ${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
@@ -235,29 +240,29 @@ ${resHeaders[0]}: ${resHeaders[1]}${
it('should log parsed response with status 3**', () => { it('should log parsed response with status 3**', () => {
const status = getRandomStatus([300, 301, 302, 303, 304, 307, 308]) const status = getRandomStatus([300, 301, 302, 303, 304, 307, 308])
const mockedResponse: AxiosResponse = { const mockedAxiosError = {
data: resData, config: {
status, data: reqData
statusText: '', },
headers: {}, request: {
config: { data: reqData }, _currentRequest: {
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } } _header: reqHeaders
} }
}
} as AxiosError
const requestClient = new RequestClient('') const requestClient = new RequestClient('')
requestClient['defaultInterceptionCallBack'](mockedResponse) requestClient['handleAxiosError'](mockedAxiosError)
const noValueMessage = 'Not provided'
const expectedLog = `HTTP Request (first 50 lines): const expectedLog = `HTTP Request (first 50 lines):
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)} ${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
HTTP Response Code: ${requestClient['prettifyString'](status)} HTTP Response Code: ${requestClient['prettifyString'](noValueMessage)}
HTTP Response (first 50 lines): HTTP Response (first 50 lines):
${resHeaders[0]}: ${resHeaders[1]}${ ${noValueMessage}
requestClient['parseInterceptedBody'](resData) \n${requestClient['parseInterceptedBody'](noValueMessage)}
? `\n\n${requestClient['parseInterceptedBody'](resData)}`
: ''
}
` `
expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog) expect((process as any).logger.info).toHaveBeenCalledWith(expectedLog)
@@ -278,7 +283,10 @@ ${resHeaders[0]}: ${resHeaders[1]}${
status, status,
statusText: '', statusText: '',
headers: {}, headers: {},
config: { data: reqData }, config: {
data: reqData,
headers: {} as AxiosRequestHeaders
},
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } } request: { _header: reqHeaders, res: { rawHeaders: resHeaders } }
} }
const mockedAxiosError = { const mockedAxiosError = {
@@ -294,7 +302,7 @@ ${resHeaders[0]}: ${resHeaders[1]}${
} as AxiosError } as AxiosError
const requestClient = new RequestClient('') const requestClient = new RequestClient('')
requestClient['defaultInterceptionCallBack'](mockedAxiosError) requestClient['handleAxiosError'](mockedAxiosError)
const expectedLog = `HTTP Request (first 50 lines): const expectedLog = `HTTP Request (first 50 lines):
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)} ${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
@@ -328,7 +336,10 @@ ${resHeaders[0]}: ${resHeaders[1]}${
status, status,
statusText: '', statusText: '',
headers: {}, headers: {},
config: { data: reqData }, config: {
data: reqData,
headers: {} as AxiosRequestHeaders
},
request: { _header: reqHeaders, res: { rawHeaders: resHeaders } } request: { _header: reqHeaders, res: { rawHeaders: resHeaders } }
} }
const mockedAxiosError = { const mockedAxiosError = {
@@ -344,7 +355,7 @@ ${resHeaders[0]}: ${resHeaders[1]}${
} as AxiosError } as AxiosError
const requestClient = new RequestClient('') const requestClient = new RequestClient('')
requestClient['defaultInterceptionCallBack'](mockedAxiosError) requestClient['handleAxiosError'](mockedAxiosError)
const expectedLog = `HTTP Request (first 50 lines): const expectedLog = `HTTP Request (first 50 lines):
${reqHeaders}${requestClient['parseInterceptedBody'](reqData)} ${reqHeaders}${requestClient['parseInterceptedBody'](reqData)}
@@ -376,8 +387,8 @@ ${resHeaders[0]}: ${resHeaders[1]}${
requestClient.enableVerboseMode() requestClient.enableVerboseMode()
expect(interceptorSpy).toHaveBeenCalledWith( expect(interceptorSpy).toHaveBeenCalledWith(
requestClient['defaultInterceptionCallBack'], requestClient['handleAxiosResponse'],
requestClient['defaultInterceptionCallBack'] requestClient['handleAxiosError']
) )
}) })
@@ -388,12 +399,12 @@ ${resHeaders[0]}: ${resHeaders[1]}${
'use' 'use'
) )
const successCallback = (response: AxiosResponse | AxiosError) => { const successCallback = (response: AxiosResponse) => {
console.log('success') console.log('success')
return response return response
} }
const failureCallback = (response: AxiosResponse | AxiosError) => { const failureCallback = (response: AxiosError) => {
console.log('failure') console.log('failure')
return response return response
@@ -429,15 +440,18 @@ ${resHeaders[0]}: ${resHeaders[1]}${
}) })
describe('prettifyString', () => { 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'`, () => { it(`should call inspect without colors when verbose mode is set to 'bleached'`, () => {
const requestClient = new RequestClient('') const requestClient = new RequestClient('')
let verbose: VerboseMode = 'bleached' requestClient.setVerboseMode('bleached')
requestClient.setVerboseMode(verbose)
jest.spyOn(UtilsModule, 'inspect')
const testStr = JSON.stringify({ test: 'test' }) const testStr = JSON.stringify({ test: 'test' })
requestClient['prettifyString'](testStr) requestClient['prettifyString'](testStr)
expect(UtilsModule.inspect).toHaveBeenCalledWith(testStr, { expect(UtilsModule.inspect).toHaveBeenCalledWith(testStr, {
@@ -445,15 +459,11 @@ ${resHeaders[0]}: ${resHeaders[1]}${
}) })
}) })
it(`should call inspect with colors when verbose mode is set to 'true'`, () => { it(`should call inspect with colors when verbose mode is set to true`, () => {
const requestClient = new RequestClient('') const requestClient = new RequestClient('')
let verbose: VerboseMode = true requestClient.setVerboseMode(true)
requestClient.setVerboseMode(verbose)
jest.spyOn(UtilsModule, 'inspect')
const testStr = JSON.stringify({ test: 'test' }) const testStr = JSON.stringify({ test: 'test' })
requestClient['prettifyString'](testStr) requestClient['prettifyString'](testStr)
expect(UtilsModule.inspect).toHaveBeenCalledWith(testStr, { expect(UtilsModule.inspect).toHaveBeenCalledWith(testStr, {

View File

@@ -1,7 +1,15 @@
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',
@@ -12,11 +20,11 @@ export const mockedAuthResponse = {
jti: 'jti' jti: 'jti'
} }
app.get('/', function (req: any, res: any) { app.get('/', (req: any, res: any) => {
res.send('Hello World') res.send('Hello World')
}) })
app.post('/SASLogon/oauth/token', function (req: any, res: any) { app.post('/SASLogon/oauth/token', (req: any, res: any) => {
let valid = true let valid = true
// capture the encoded form data // capture the encoded form data

View File

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

View File

@@ -0,0 +1,30 @@
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

@@ -10,10 +10,14 @@ export const convertToCSV = (
tableName: string tableName: string
) => { ) => {
if (!data[tableName]) { if (!data[tableName]) {
throw prefixMessage( const error = 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 +1,5 @@
import { isNode } from './' import { isNode } from './'
import * as NodeFormData from 'form-data' import NodeFormData from 'form-data'
export const getFormData = () => export const getFormData = (): NodeFormData | FormData =>
isNode() ? new NodeFormData() : new FormData() isNode() ? new NodeFormData() : new FormData()

View File

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

View File

@@ -0,0 +1,24 @@
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)
})
})

View File

@@ -0,0 +1,72 @@
import { RequestClient } from '../../request/RequestClient'
import { parseSasViyaDebugResponse } from '../parseViyaDebugResponse'
describe('parseSasViyaDebugResponse', () => {
let requestClient: RequestClient
const serverUrl = 'http://test-server.com'
beforeEach(() => {
requestClient = {
get: jest.fn()
} as unknown as RequestClient
})
it('should extract URL and call get for Viya 3.5 iframe style', async () => {
const iframeUrl = '/path/to/log.json'
const response = `<html><body><iframe style="width: 99%; height: 500px" src="${iframeUrl}"></iframe></body></html>`
const resultData = { message: 'success' }
// Mock the get method to resolve with an object containing the JSON result as string.
;(requestClient.get as jest.Mock).mockResolvedValue({
result: JSON.stringify(resultData)
})
const result = await parseSasViyaDebugResponse(
response,
requestClient,
serverUrl
)
expect(requestClient.get).toHaveBeenCalledWith(
serverUrl + iframeUrl,
undefined,
'text/plain'
)
expect(result).toEqual(resultData)
})
it('should extract URL and call get for Viya 4 iframe style', async () => {
const iframeUrl = '/another/path/to/log.json'
// Note: For Viya 4, the regex splits in such a way that the extracted URL includes an extra starting double-quote.
// For example, the URL becomes: '"/another/path/to/log.json'
const response = `<html><body><iframe style="width: 99%; height: 500px; background-color:Canvas;" src="${iframeUrl}"></iframe></body></html>`
const resultData = { status: 'ok' }
;(requestClient.get as jest.Mock).mockResolvedValue({
result: JSON.stringify(resultData)
})
const result = await parseSasViyaDebugResponse(
response,
requestClient,
serverUrl
)
// Expect the extra starting double-quote as per the current implementation.
const expectedUrl = serverUrl + `"` + iframeUrl
expect(requestClient.get).toHaveBeenCalledWith(
expectedUrl,
undefined,
'text/plain'
)
expect(result).toEqual(resultData)
})
it('should throw an error if iframe URL is not found', async () => {
const response = `<html><body>No iframe here</body></html>`
await expect(
parseSasViyaDebugResponse(response, requestClient, serverUrl)
).rejects.toThrow('Unable to find webout file URL.')
})
})

View File

@@ -52,19 +52,22 @@ export const validateInput = (
} }
} }
for (const item of data[key]) { // ES6 is stricter so we had to include the check for the array
if (getType(item) !== 'object') { if (Array.isArray(data[key])) {
return { for (const item of data[key]) {
status: false, if (getType(item) !== 'object') {
msg: `Table ${key} contains invalid structure. ${MORE_INFO}` return {
} status: false,
} else { msg: `Table ${key} contains invalid structure. ${MORE_INFO}`
const attributes = Object.keys(item) }
for (const attribute of attributes) { } else {
if (item[attribute] === undefined) { const attributes = Object.keys(item)
return { for (const attribute of attributes) {
status: false, if (item[attribute] === undefined) {
msg: `A row in table ${key} contains invalid value. Can't assign undefined to ${attribute}.` return {
status: false,
msg: `A row in table ${key} contains invalid value. Can't assign undefined to ${attribute}.`
}
} }
} }
} }

View File

@@ -1,12 +1,14 @@
{ {
"compilerOptions": { "compilerOptions": {
"lib": ["ES2018", "DOM", "ES2019.String"], "lib": ["ES2018", "DOM", "ES2019.String"],
"target": "es5", "target": "es6",
"module": "commonjs", "module": "commonjs",
"declaration": true, "declaration": true,
"outDir": "./build", "outDir": "./build",
"esModuleInterop": true,
"strict": true, "strict": true,
"sourceMap": true, "sourceMap": true,
"inlineSources": true,
"typeRoots": ["./node_modules/@types", "./src/types/system"] "typeRoots": ["./node_modules/@types", "./src/types/system"]
}, },
"include": ["src"], "include": ["src"],

View File

@@ -13,12 +13,12 @@ const defaultPlugins = [
] ]
const optimization = { const optimization = {
minimize: true, minimize: false,
minimizer: [ minimizer: [
new terserPlugin({ // new terserPlugin({
parallel: true, // parallel: true,
terserOptions: {} // terserOptions: {}
}) // })
] ]
} }
@@ -44,6 +44,7 @@ const browserConfig = {
}, },
mode: 'production', mode: 'production',
optimization: optimization, optimization: optimization,
devtool: 'inline-source-map',
module: { module: {
rules: [ rules: [
{ {