mirror of
https://github.com/sasjs/adapter.git
synced 2026-07-23 22:55:28 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c5967fa10 | ||
|
|
a641a2fbdf | ||
|
|
5f1d2fa1c9 | ||
|
|
b7b56ecde1 | ||
|
|
39e5386346 | ||
|
|
33e28483c5 | ||
|
|
fa67b5b447 | ||
|
|
6f42f40f07 | ||
|
|
c0289583d5 | ||
|
|
b3921f7d05 | ||
|
|
3d99a9b1e4 | ||
|
|
2a71e34179 | ||
|
|
ea5d60352d | ||
|
|
ac0dfae9a8 | ||
|
|
cd350e4e6d | ||
|
|
8f726c0ac9 | ||
|
|
4cae9b5472 | ||
|
|
a691500910 | ||
|
|
eb6b123dba | ||
|
|
3136e98477 | ||
|
|
2db1b9fc4b | ||
|
|
8be0fd94ad | ||
|
|
55db8f45ab |
@@ -11,21 +11,41 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
id-token: write # required for npm provenance (trusted publisher)
|
||||||
|
issues: write # optional: lets @semantic-release/github comment on issues
|
||||||
|
pull-requests: write # optional: lets @semantic-release/github comment on PRs
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [22]
|
node-version: [22]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
# 1. Mint a short-lived GitHub App token (bypass-capable)
|
||||||
|
- name: Generate token
|
||||||
|
id: app-token
|
||||||
|
uses: actions/create-github-app-token@v1
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.APP_ID }}
|
||||||
|
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
|
||||||
|
# 2. Checkout using the app token so the release commit can be pushed
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
persist-credentials: true
|
||||||
|
fetch-depth: 0 # semantic-release needs full history + tags
|
||||||
|
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
|
registry-url: https://registry.npmjs.org
|
||||||
|
|
||||||
# 2. Restore npm cache manually
|
# 3. Restore npm cache manually
|
||||||
- name: Restore npm cache
|
- name: Restore npm cache
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
id: npm-cache
|
id: npm-cache
|
||||||
with:
|
with:
|
||||||
path: ~/.npm
|
path: ~/.npm
|
||||||
@@ -46,10 +66,15 @@ jobs:
|
|||||||
run: npm run publishInit
|
run: npm run publishInit
|
||||||
|
|
||||||
- name: Semantic Release
|
- name: Semantic Release
|
||||||
uses: cycjimmy/semantic-release-action@v3
|
run: npx semantic-release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} # <-- app token, not secrets.GITHUB_TOKEN
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
|
- name: Publish to npm with trusted publisher
|
||||||
|
if: success()
|
||||||
|
env:
|
||||||
|
NPM_CONFIG_PROVENANCE: true
|
||||||
|
run: npm publish --access public --provenance
|
||||||
|
|
||||||
- name: Send Matrix message
|
- name: Send Matrix message
|
||||||
run: curl -XPOST -d "{\"msgtype\":\"m.text\", \"body\":\"New version of @sasjs/adapter has been released! \n Please deploy and run 'dctests' with new adapter to make sure everything is still in place.\"}" https://matrix.4gl.io/_matrix/client/r0/rooms/!jRebyiGmHZlpfDwYXN:4gl.io/send/m.room.message?access_token=${{ secrets.MATRIX_TOKEN }}
|
run: curl -XPOST -d "{\"msgtype\":\"m.text\", \"body\":\"New version of @sasjs/adapter has been released! \n Please deploy and run 'dctests' with new adapter to make sure everything is still in place.\"}" https://matrix.4gl.io/_matrix/client/r0/rooms/!jRebyiGmHZlpfDwYXN:4gl.io/send/m.room.message?access_token=${{ secrets.MATRIX_TOKEN }}
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
ignore-scripts=true
|
ignore-scripts=true
|
||||||
|
save-exact=true
|
||||||
|
fund=false
|
||||||
@@ -288,6 +288,7 @@ Configuration on the client side involves passing an object on startup, which ca
|
|||||||
- `verbose` - optional, if `true` then a summary of every HTTP response is logged.
|
- `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.
|
||||||
|
- `runAsTask` - Only relevant for serverType `SASVIYA` and useComputeApi `null`. Will add the `_EXECUTIONTASKS=true` URL param and thus execute jobs as Compute Tasks.
|
||||||
- `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`.
|
||||||
- `requestHistoryLimit` - Request history limit. Increasing this limit may affect browser performance, especially with debug (logs) enabled. Default is 10.
|
- `requestHistoryLimit` - Request history limit. Increasing this limit may affect browser performance, especially with debug (logs) enabled. Default is 10.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -13,6 +13,6 @@ module.exports = defineConfig({
|
|||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
screenshotOnRunFailure: false,
|
screenshotOnRunFailure: false,
|
||||||
testingFinishTimeout: 600000
|
testingFinishTimeout: 300000
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,6 +12,58 @@ context('sasjs-tests', function () {
|
|||||||
cy.visit(sasjsTestsUrl)
|
cy.visit(sasjsTestsUrl)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function waitForTestsToFinish(timeout: number) {
|
||||||
|
const deadline = Date.now() + timeout
|
||||||
|
function check() {
|
||||||
|
cy.get('tests-view', { log: false }).then(($view) => {
|
||||||
|
const shadow = ($view[0] as HTMLElement).shadowRoot
|
||||||
|
const stillRunning = !!shadow?.querySelector('#run-btn:disabled')
|
||||||
|
if (!stillRunning) return
|
||||||
|
if (Date.now() >= deadline) {
|
||||||
|
cy.log('Timed out waiting for tests to finish; reporting status')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cy.wait(2000, { log: false })
|
||||||
|
check()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
check()
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNoFailedTests() {
|
||||||
|
cy.get('test-card').then(($cards) => {
|
||||||
|
const failed: string[] = []
|
||||||
|
const stuck: string[] = []
|
||||||
|
const pending: string[] = []
|
||||||
|
$cards.each((_, card) => {
|
||||||
|
const shadow = (card as HTMLElement).shadowRoot
|
||||||
|
if (!shadow) return
|
||||||
|
const icon = shadow.querySelector('.status-icon')
|
||||||
|
const title =
|
||||||
|
shadow.querySelector('.header h3')?.textContent?.trim() ?? '(unknown)'
|
||||||
|
if (icon?.classList.contains('failed')) {
|
||||||
|
const error =
|
||||||
|
shadow.querySelector('.error pre')?.textContent?.trim() ?? ''
|
||||||
|
failed.push(error ? `- ${title}\n ${error}` : `- ${title}`)
|
||||||
|
} else if (icon?.classList.contains('running')) {
|
||||||
|
stuck.push(`- ${title}`)
|
||||||
|
} else if (icon?.classList.contains('pending')) {
|
||||||
|
pending.push(`- ${title}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const parts: string[] = []
|
||||||
|
if (failed.length)
|
||||||
|
parts.push(`${failed.length} failed:\n${failed.join('\n')}`)
|
||||||
|
if (stuck.length)
|
||||||
|
parts.push(`${stuck.length} stuck (running):\n${stuck.join('\n')}`)
|
||||||
|
if (pending.length)
|
||||||
|
parts.push(
|
||||||
|
`${pending.length} did not start (pending):\n${pending.join('\n')}`
|
||||||
|
)
|
||||||
|
expect(parts, parts.join('\n\n')).to.be.empty
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function loginIfNeeded() {
|
function loginIfNeeded() {
|
||||||
cy.get('login-form, tests-view', { timeout: 30000 }).should('exist')
|
cy.get('login-form, tests-view', { timeout: 30000 }).should('exist')
|
||||||
|
|
||||||
@@ -42,14 +94,9 @@ context('sasjs-tests', function () {
|
|||||||
|
|
||||||
cy.get('tests-view').shadow().find('#run-btn').should('be.visible').click()
|
cy.get('tests-view').shadow().find('#run-btn').should('be.visible').click()
|
||||||
|
|
||||||
cy.get('tests-view')
|
waitForTestsToFinish(testingFinishTimeout)
|
||||||
.shadow()
|
|
||||||
.find('#run-btn:disabled', {
|
|
||||||
timeout: testingFinishTimeout
|
|
||||||
})
|
|
||||||
.should('not.exist')
|
|
||||||
|
|
||||||
cy.get('test-card').shadow().find('.status-icon.failed').should('not.exist')
|
assertNoFailedTests()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should have all tests successful with debug on', () => {
|
it('Should have all tests successful with debug on', () => {
|
||||||
@@ -63,13 +110,8 @@ context('sasjs-tests', function () {
|
|||||||
|
|
||||||
cy.get('tests-view').shadow().find('#run-btn').should('be.visible').click()
|
cy.get('tests-view').shadow().find('#run-btn').should('be.visible').click()
|
||||||
|
|
||||||
cy.get('tests-view')
|
waitForTestsToFinish(testingFinishTimeout)
|
||||||
.shadow()
|
|
||||||
.find('#run-btn:disabled', {
|
|
||||||
timeout: testingFinishTimeout
|
|
||||||
})
|
|
||||||
.should('not.exist')
|
|
||||||
|
|
||||||
cy.get('test-card').shadow().find('.status-icon.failed').should('not.exist')
|
assertNoFailedTests()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+153
-25
@@ -10,12 +10,14 @@
|
|||||||
"@sasjs/utils": "^3.5.6",
|
"@sasjs/utils": "^3.5.6",
|
||||||
"axios": "1.16.0",
|
"axios": "1.16.0",
|
||||||
"axios-cookiejar-support": "5.0.5",
|
"axios-cookiejar-support": "5.0.5",
|
||||||
"form-data": "4.0.4",
|
"form-data": "4.0.6",
|
||||||
"https": "1.0.0",
|
"https": "1.0.0",
|
||||||
"tough-cookie": "4.1.3"
|
"tough-cookie": "4.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cypress/webpack-preprocessor": "5.9.1",
|
"@cypress/webpack-preprocessor": "5.9.1",
|
||||||
|
"@semantic-release/exec": "6.0.3",
|
||||||
|
"@semantic-release/git": "10.0.1",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "4.17.13",
|
"@types/express": "4.17.13",
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
@@ -47,7 +49,8 @@
|
|||||||
"typescript": "4.9.5",
|
"typescript": "4.9.5",
|
||||||
"webpack": "5.76.2",
|
"webpack": "5.76.2",
|
||||||
"webpack-cli": "4.9.2"
|
"webpack-cli": "4.9.2"
|
||||||
}
|
},
|
||||||
|
"version": "4.17.1"
|
||||||
},
|
},
|
||||||
"node_modules/@ampproject/remapping": {
|
"node_modules/@ampproject/remapping": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
@@ -2471,6 +2474,144 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@semantic-release/exec": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@semantic-release/exec/-/exec-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-bxAq8vLOw76aV89vxxICecEa8jfaWwYITw6X74zzlO0mc/Bgieqx9kBRz9z96pHectiTAtsCwsQcUyLYWnp3VQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@semantic-release/error": "^3.0.0",
|
||||||
|
"aggregate-error": "^3.0.0",
|
||||||
|
"debug": "^4.0.0",
|
||||||
|
"execa": "^5.0.0",
|
||||||
|
"lodash": "^4.17.4",
|
||||||
|
"parse-json": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"semantic-release": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@semantic-release/exec/node_modules/execa": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "^7.0.3",
|
||||||
|
"get-stream": "^6.0.0",
|
||||||
|
"human-signals": "^2.1.0",
|
||||||
|
"is-stream": "^2.0.0",
|
||||||
|
"merge-stream": "^2.0.0",
|
||||||
|
"npm-run-path": "^4.0.1",
|
||||||
|
"onetime": "^5.1.2",
|
||||||
|
"signal-exit": "^3.0.3",
|
||||||
|
"strip-final-newline": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@semantic-release/exec/node_modules/get-stream": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@semantic-release/exec/node_modules/human-signals": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@semantic-release/git": {
|
||||||
|
"version": "10.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@semantic-release/git/-/git-10.0.1.tgz",
|
||||||
|
"integrity": "sha512-eWrx5KguUcU2wUPaO6sfvZI0wPafUKAMNC18aXY4EnNcrZL86dEmpNVnC9uMpGZkmZJ9EfCVJBQx4pV4EMGT1w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@semantic-release/error": "^3.0.0",
|
||||||
|
"aggregate-error": "^3.0.0",
|
||||||
|
"debug": "^4.0.0",
|
||||||
|
"dir-glob": "^3.0.0",
|
||||||
|
"execa": "^5.0.0",
|
||||||
|
"lodash": "^4.17.4",
|
||||||
|
"micromatch": "^4.0.0",
|
||||||
|
"p-reduce": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"semantic-release": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@semantic-release/git/node_modules/execa": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cross-spawn": "^7.0.3",
|
||||||
|
"get-stream": "^6.0.0",
|
||||||
|
"human-signals": "^2.1.0",
|
||||||
|
"is-stream": "^2.0.0",
|
||||||
|
"merge-stream": "^2.0.0",
|
||||||
|
"npm-run-path": "^4.0.1",
|
||||||
|
"onetime": "^5.1.2",
|
||||||
|
"signal-exit": "^3.0.3",
|
||||||
|
"strip-final-newline": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@semantic-release/git/node_modules/get-stream": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@semantic-release/git/node_modules/human-signals": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@semantic-release/github": {
|
"node_modules/@semantic-release/github": {
|
||||||
"version": "8.1.0",
|
"version": "8.1.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -3556,22 +3697,6 @@
|
|||||||
"tough-cookie": ">=4.0.0"
|
"tough-cookie": ">=4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios/node_modules/form-data": {
|
|
||||||
"version": "4.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
|
||||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"asynckit": "^0.4.0",
|
|
||||||
"combined-stream": "^1.0.8",
|
|
||||||
"es-set-tostringtag": "^2.1.0",
|
|
||||||
"hasown": "^2.0.2",
|
|
||||||
"mime-types": "^2.1.12"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/babel-jest": {
|
"node_modules/babel-jest": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
||||||
@@ -6179,16 +6304,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||||
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
"combined-stream": "^1.0.8",
|
"combined-stream": "^1.0.8",
|
||||||
"es-set-tostringtag": "^2.1.0",
|
"es-set-tostringtag": "^2.1.0",
|
||||||
"hasown": "^2.0.2",
|
"hasown": "^2.0.4",
|
||||||
"mime-types": "^2.1.12"
|
"mime-types": "^2.1.35"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
@@ -6616,7 +6741,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -15242,5 +15369,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"version": "4.17.1"
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-6
@@ -23,10 +23,24 @@
|
|||||||
},
|
},
|
||||||
"release": {
|
"release": {
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"@semantic-release/npm",
|
"@semantic-release/commit-analyzer",
|
||||||
{
|
"@semantic-release/release-notes-generator",
|
||||||
"pkgRoot": "/build"
|
[
|
||||||
}
|
"@semantic-release/exec",
|
||||||
|
{
|
||||||
|
"prepareCmd": "npm version ${nextRelease.version} --no-git-tag-version"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/git",
|
||||||
|
{
|
||||||
|
"assets": [
|
||||||
|
"package.json",
|
||||||
|
"package-lock.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@semantic-release/github"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -42,6 +56,8 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cypress/webpack-preprocessor": "5.9.1",
|
"@cypress/webpack-preprocessor": "5.9.1",
|
||||||
|
"@semantic-release/exec": "6.0.3",
|
||||||
|
"@semantic-release/git": "10.0.1",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "4.17.13",
|
"@types/express": "4.17.13",
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "29.5.14",
|
||||||
@@ -79,8 +95,9 @@
|
|||||||
"@sasjs/utils": "^3.5.6",
|
"@sasjs/utils": "^3.5.6",
|
||||||
"axios": "1.16.0",
|
"axios": "1.16.0",
|
||||||
"axios-cookiejar-support": "5.0.5",
|
"axios-cookiejar-support": "5.0.5",
|
||||||
"form-data": "4.0.4",
|
"form-data": "4.0.6",
|
||||||
"https": "1.0.0",
|
"https": "1.0.0",
|
||||||
"tough-cookie": "4.1.3"
|
"tough-cookie": "4.1.3"
|
||||||
}
|
},
|
||||||
|
"version": "4.17.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,10 +66,11 @@ export class TestSuiteElement extends HTMLElement {
|
|||||||
const passed = completedTests.filter((t) => t.status === 'passed').length
|
const passed = completedTests.filter((t) => t.status === 'passed').length
|
||||||
const failed = completedTests.filter((t) => t.status === 'failed').length
|
const failed = completedTests.filter((t) => t.status === 'failed').length
|
||||||
const running = completedTests.filter((t) => t.status === 'running').length
|
const running = completedTests.filter((t) => t.status === 'running').length
|
||||||
|
const pending = completedTests.filter((t) => t.status === 'pending').length
|
||||||
|
|
||||||
const statsEl = this.shadow.querySelector('.stats')
|
const statsEl = this.shadow.querySelector('.stats')
|
||||||
if (statsEl) {
|
if (statsEl) {
|
||||||
statsEl.textContent = `Passed: ${passed} | Failed: ${failed} | Running: ${running}`
|
statsEl.textContent = `Passed: ${passed} | Failed: ${failed} | Running: ${running} | Pending: ${pending}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,11 +81,12 @@ export class TestSuiteElement extends HTMLElement {
|
|||||||
const passed = completedTests.filter((t) => t.status === 'passed').length
|
const passed = completedTests.filter((t) => t.status === 'passed').length
|
||||||
const failed = completedTests.filter((t) => t.status === 'failed').length
|
const failed = completedTests.filter((t) => t.status === 'failed').length
|
||||||
const running = completedTests.filter((t) => t.status === 'running').length
|
const running = completedTests.filter((t) => t.status === 'running').length
|
||||||
|
const pending = completedTests.filter((t) => t.status === 'pending').length
|
||||||
|
|
||||||
this.shadow.innerHTML = `
|
this.shadow.innerHTML = `
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h2>${name}</h2>
|
<h2>${name}</h2>
|
||||||
<div class="stats">Passed: ${passed} | Failed: ${failed} | Running: ${running}</div>
|
<div class="stats">Passed: ${passed} | Failed: ${failed} | Running: ${running} | Pending: ${pending}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tests" id="tests-container"></div>
|
<div class="tests" id="tests-container"></div>
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -30,12 +30,14 @@ export class TestRunner {
|
|||||||
) => void
|
) => void
|
||||||
): Promise<CompletedTestSuite[]> {
|
): Promise<CompletedTestSuite[]> {
|
||||||
this.isRunning = true
|
this.isRunning = true
|
||||||
this.completedTestSuites = []
|
this.completedTestSuites = this.testSuites.map((suite) => ({
|
||||||
|
name: suite.name,
|
||||||
|
completedTests: []
|
||||||
|
}))
|
||||||
|
|
||||||
for (let i = 0; i < this.testSuites.length; i++) {
|
await Promise.allSettled(
|
||||||
const suite = this.testSuites[i]
|
this.testSuites.map((suite, i) => this.runTestSuite(suite, i, onUpdate))
|
||||||
await this.runTestSuite(suite, i, onUpdate)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
this.isRunning = false
|
this.isRunning = false
|
||||||
return this.completedTestSuites
|
return this.completedTestSuites
|
||||||
@@ -49,7 +51,23 @@ export class TestRunner {
|
|||||||
currentIndex: number
|
currentIndex: number
|
||||||
) => void
|
) => void
|
||||||
): Promise<CompletedTestSuite> {
|
): Promise<CompletedTestSuite> {
|
||||||
const completedTests: CompletedTest[] = []
|
// Seed all tests as pending so every card renders before any run starts.
|
||||||
|
const completedTests: CompletedTest[] = suite.tests.map((test) => ({
|
||||||
|
test,
|
||||||
|
result: false,
|
||||||
|
error: null,
|
||||||
|
executionTime: 0,
|
||||||
|
status: 'pending'
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (onUpdate) {
|
||||||
|
this.completedTestSuites[suiteIndex] = {
|
||||||
|
name: suite.name,
|
||||||
|
completedTests: [...completedTests]
|
||||||
|
}
|
||||||
|
onUpdate([...this.completedTestSuites], suiteIndex * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
let context: unknown
|
let context: unknown
|
||||||
|
|
||||||
// Run beforeAll if exists
|
// Run beforeAll if exists
|
||||||
@@ -62,15 +80,14 @@ export class TestRunner {
|
|||||||
const test = suite.tests[i]
|
const test = suite.tests[i]
|
||||||
const currentIndex = suiteIndex * 1000 + i
|
const currentIndex = suiteIndex * 1000 + i
|
||||||
|
|
||||||
// Set status to running
|
// Flip pending → running
|
||||||
const runningTest: CompletedTest = {
|
completedTests[i] = {
|
||||||
test,
|
test,
|
||||||
result: false,
|
result: false,
|
||||||
error: null,
|
error: null,
|
||||||
executionTime: 0,
|
executionTime: 0,
|
||||||
status: 'running'
|
status: 'running'
|
||||||
}
|
}
|
||||||
completedTests.push(runningTest)
|
|
||||||
|
|
||||||
// Notify update
|
// Notify update
|
||||||
if (onUpdate) {
|
if (onUpdate) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { fileUploadTests } from './testSuites/FileUpload'
|
|||||||
import { computeTests } from './testSuites/Compute'
|
import { computeTests } from './testSuites/Compute'
|
||||||
import { sasjsRequestTests } from './testSuites/SasjsRequests'
|
import { sasjsRequestTests } from './testSuites/SasjsRequests'
|
||||||
import { specialCaseTests } from './testSuites/SpecialCases'
|
import { specialCaseTests } from './testSuites/SpecialCases'
|
||||||
|
import { executionTasksTests } from './testSuites/executionTasks'
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
const appContainer = document.getElementById('app')
|
const appContainer = document.getElementById('app')
|
||||||
@@ -104,8 +105,9 @@ function showTests(
|
|||||||
fileUploadTests(adapter)
|
fileUploadTests(adapter)
|
||||||
]
|
]
|
||||||
|
|
||||||
// Add compute tests for SASVIYA only
|
// Add tests for SASVIYA only
|
||||||
if (adapter.getSasjsConfig().serverType === 'SASVIYA') {
|
if (adapter.getSasjsConfig().serverType === 'SASVIYA') {
|
||||||
|
testSuites.push(executionTasksTests(adapter))
|
||||||
testSuites.push(computeTests(adapter, appLoc))
|
testSuites.push(computeTests(adapter, appLoc))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import SASjs from '@sasjs/adapter'
|
||||||
|
import type { TestSuite } from '../types'
|
||||||
|
|
||||||
|
const tableData: any = { table1: [{ col1: 'first col value' }] }
|
||||||
|
const fileData: any = { table1: [{ col1: 'value with ; semicolon' }] }
|
||||||
|
const multiTableData: any = {
|
||||||
|
table1: [{ col1: 'first col value' }],
|
||||||
|
table2: [{ col2: 'second table value' }]
|
||||||
|
}
|
||||||
|
const multiFileData: any = {
|
||||||
|
table1: [{ col1: 'value with ; semicolon' }],
|
||||||
|
table2: [{ col2: 'another; value' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskConfig: any = { useComputeApi: null, runAsTask: true }
|
||||||
|
const noTaskConfig: any = { useComputeApi: null, runAsTask: false }
|
||||||
|
|
||||||
|
export const executionTasksTests = (adapter: SASjs): TestSuite => ({
|
||||||
|
name: 'runAsTask behaviour',
|
||||||
|
tests: [
|
||||||
|
{
|
||||||
|
title: 'no inputs (runAsTask=false)',
|
||||||
|
description: 'no payload, runAsTask explicitly disabled',
|
||||||
|
test: () =>
|
||||||
|
adapter
|
||||||
|
.request('services/common/sendArr', null, noTaskConfig)
|
||||||
|
.then((res: any) => ({ ok: true, res }))
|
||||||
|
.catch((e: any) => ({ ok: false, error: e })),
|
||||||
|
assertion: (res: any) => res?.ok === true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'no inputs (runAsTask=true)',
|
||||||
|
description: 'no payload, runAsTask=true via config',
|
||||||
|
test: () =>
|
||||||
|
adapter
|
||||||
|
.request('services/common/sendArr', null, taskConfig)
|
||||||
|
.then((res: any) => ({ ok: true, res }))
|
||||||
|
.catch((e: any) => ({ ok: false, error: e })),
|
||||||
|
assertion: (res: any) => res?.ok === true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'one input table (runAsTask=false)',
|
||||||
|
description: 'single table payload, runAsTask explicitly disabled',
|
||||||
|
test: () =>
|
||||||
|
adapter
|
||||||
|
.request('services/common/sendArr', tableData, noTaskConfig)
|
||||||
|
.then((res: any) => ({ ok: true, res }))
|
||||||
|
.catch((e: any) => ({ ok: false, error: e })),
|
||||||
|
assertion: (res: any) => res?.ok === true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'one input table (runAsTask=true)',
|
||||||
|
description: 'single table payload, runAsTask=true via config',
|
||||||
|
test: () =>
|
||||||
|
adapter
|
||||||
|
.request('services/common/sendArr', tableData, taskConfig)
|
||||||
|
.then((res: any) => ({ ok: true, res }))
|
||||||
|
.catch((e: any) => ({ ok: false, error: e })),
|
||||||
|
assertion: (res: any) => res?.ok === true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'multiple input tables (runAsTask=false)',
|
||||||
|
description: 'multi-table payload, runAsTask explicitly disabled',
|
||||||
|
test: () =>
|
||||||
|
adapter
|
||||||
|
.request('services/common/sendArr', multiTableData, noTaskConfig)
|
||||||
|
.then((res: any) => ({ ok: true, res }))
|
||||||
|
.catch((e: any) => ({ ok: false, error: e })),
|
||||||
|
assertion: (res: any) => res?.ok === true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'multiple input tables (runAsTask=true)',
|
||||||
|
description: 'multi-table payload, runAsTask=true via config',
|
||||||
|
test: () =>
|
||||||
|
adapter
|
||||||
|
.request('services/common/sendArr', multiTableData, taskConfig)
|
||||||
|
.then((res: any) => ({ ok: true, res }))
|
||||||
|
.catch((e: any) => ({ ok: false, error: e })),
|
||||||
|
assertion: (res: any) => res?.ok === true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'semicolon payload, single table, blob path (runAsTask=false)',
|
||||||
|
description: 'semicolon payload routes through blob path, runAsTask off',
|
||||||
|
test: () =>
|
||||||
|
adapter
|
||||||
|
.request('services/common/sendArr', fileData, noTaskConfig)
|
||||||
|
.then((res: any) => ({ ok: true, res }))
|
||||||
|
.catch((e: any) => ({ ok: false, error: e })),
|
||||||
|
assertion: (res: any) => res?.ok === true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'semicolon payload, single table, blob path (runAsTask=true)',
|
||||||
|
description:
|
||||||
|
'semicolon payload (single table) routes through blob path, runAsTask=true',
|
||||||
|
test: () =>
|
||||||
|
adapter
|
||||||
|
.request('services/common/sendArr', fileData, taskConfig)
|
||||||
|
.then((res: any) => ({ ok: true, res }))
|
||||||
|
.catch((e: any) => ({ ok: false, error: e })),
|
||||||
|
assertion: (res: any) => res?.ok === true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'semicolon payload, multi-table, blob path (runAsTask=false)',
|
||||||
|
description:
|
||||||
|
'semicolon payload (multi-table) routes through blob path, runAsTask off',
|
||||||
|
test: () =>
|
||||||
|
adapter
|
||||||
|
.request('services/common/sendArr', multiFileData, noTaskConfig)
|
||||||
|
.then((res: any) => ({ ok: true, res }))
|
||||||
|
.catch((e: any) => ({ ok: false, error: e })),
|
||||||
|
assertion: (res: any) => res?.ok === true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'semicolon payload, multi-table, blob path (runAsTask=true)',
|
||||||
|
description:
|
||||||
|
'semicolon payload (multi-table) routes through blob path, runAsTask=true',
|
||||||
|
test: () =>
|
||||||
|
adapter
|
||||||
|
.request('services/common/sendArr', multiFileData, taskConfig)
|
||||||
|
.then((res: any) => ({ ok: true, res }))
|
||||||
|
.catch((e: any) => ({ ok: false, error: e })),
|
||||||
|
assertion: (res: any) => res?.ok === true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
@@ -375,7 +375,7 @@ export class AuthManager {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
public async logOut() {
|
public async logOut() {
|
||||||
this.requestClient.clearCsrfTokens()
|
this.requestClient.resetInMemoryAuthState()
|
||||||
|
|
||||||
return this.requestClient.get(this.logoutUrl, undefined).then(() => true)
|
return this.requestClient.get(this.logoutUrl, undefined).then(() => true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ export const generateTableUploadForm = (
|
|||||||
let tableCounter = 0
|
let tableCounter = 0
|
||||||
|
|
||||||
for (const tableName in data) {
|
for (const tableName in data) {
|
||||||
|
if (
|
||||||
|
isFormatsTable(tableName) &&
|
||||||
|
Object.keys(data).includes(tableName.replace(/^\$/, ''))
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
tableCounter++
|
tableCounter++
|
||||||
|
|
||||||
// Formats table should not be sent as part of 'sasjs_tables'
|
// Formats table should not be sent as part of 'sasjs_tables'
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { generateTableUploadForm } from '../generateTableUploadForm'
|
||||||
|
import { convertToCSV } from '../../utils/convertToCsv'
|
||||||
|
import NodeFormData from 'form-data'
|
||||||
|
|
||||||
|
describe('generateTableUploadForm', () => {
|
||||||
|
it('should skip formats table and emit single sasjs1data for paired data + $data', () => {
|
||||||
|
const tableName = 'jsdata'
|
||||||
|
const data: { [key: string]: any } = {
|
||||||
|
[tableName]: [
|
||||||
|
{ var1: 'string', var2: 232 },
|
||||||
|
{ var1: 'string', var2: 233 }
|
||||||
|
],
|
||||||
|
[`$${tableName}`]: { formats: { var1: '$char12.', var2: 'best.' } }
|
||||||
|
}
|
||||||
|
const expectedCsv = convertToCSV(data, tableName)
|
||||||
|
|
||||||
|
const formData = new NodeFormData()
|
||||||
|
const { requestParams } = generateTableUploadForm(formData, data)
|
||||||
|
|
||||||
|
expect(requestParams.sasjs_tables).toBe(tableName)
|
||||||
|
expect(requestParams.sasjs1data).toBe(expectedCsv)
|
||||||
|
expect(requestParams.sasjs2data).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should number sequentially across multiple tables w/ paired formats', () => {
|
||||||
|
const data: { [key: string]: any } = {
|
||||||
|
tableA: [{ a: 1 }],
|
||||||
|
$tableA: { formats: { a: 'best.' } },
|
||||||
|
tableB: [{ b: 'x' }],
|
||||||
|
$tableB: { formats: { b: '$char1.' } }
|
||||||
|
}
|
||||||
|
const expectedCsvA = convertToCSV(data, 'tableA')
|
||||||
|
const expectedCsvB = convertToCSV(data, 'tableB')
|
||||||
|
|
||||||
|
const formData = new NodeFormData()
|
||||||
|
const { requestParams } = generateTableUploadForm(formData, data)
|
||||||
|
|
||||||
|
expect(requestParams.sasjs_tables).toBe('tableA tableB')
|
||||||
|
expect(requestParams.sasjs1data).toBe(expectedCsvA)
|
||||||
|
expect(requestParams.sasjs2data).toBe(expectedCsvB)
|
||||||
|
expect(requestParams.sasjs3data).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw if string value exceeds 32765 chars', () => {
|
||||||
|
const formData = new NodeFormData()
|
||||||
|
const data = { testTable: [{ var1: 'z'.repeat(32766) }] }
|
||||||
|
|
||||||
|
expect(() => generateTableUploadForm(formData, data)).toThrow(
|
||||||
|
new Error(
|
||||||
|
'The max length of a string value in SASjs is 32765 characters.'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should append chunks to formData when csv exceeds 16k', () => {
|
||||||
|
const tableName = 'big'
|
||||||
|
const data = { [tableName]: [{ var1: 'z'.repeat(16001) }] }
|
||||||
|
|
||||||
|
const formData = new NodeFormData()
|
||||||
|
const appendSpy = jest.spyOn(formData, 'append')
|
||||||
|
|
||||||
|
const { requestParams } = generateTableUploadForm(formData, data)
|
||||||
|
|
||||||
|
expect(requestParams.sasjs_tables).toBe(tableName)
|
||||||
|
expect(requestParams.sasjs1data).toBeUndefined()
|
||||||
|
expect(appendSpy).toHaveBeenCalled()
|
||||||
|
expect(appendSpy.mock.calls.every(([key]) => key === 'sasjs1data')).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -55,7 +55,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ import { SASViyaApiClient } from '../SASViyaApiClient'
|
|||||||
import {
|
import {
|
||||||
isRelativePath,
|
isRelativePath,
|
||||||
parseSasViyaDebugResponse,
|
parseSasViyaDebugResponse,
|
||||||
|
parseSasViyaLogDebugResponse,
|
||||||
appendExtraResponseAttributes,
|
appendExtraResponseAttributes,
|
||||||
parseWeboutResponse,
|
parseWeboutResponse,
|
||||||
getFormData
|
getFormData,
|
||||||
|
isNode
|
||||||
} from '../utils'
|
} from '../utils'
|
||||||
import { BaseJobExecutor } from './JobExecutor'
|
import { BaseJobExecutor } from './JobExecutor'
|
||||||
|
|
||||||
@@ -101,6 +103,10 @@ export class WebJobExecutor extends BaseJobExecutor {
|
|||||||
apiUrl += config.contextName?.trim()
|
apiUrl += config.contextName?.trim()
|
||||||
? `&_contextname=${encodeURIComponent(config.contextName)}`
|
? `&_contextname=${encodeURIComponent(config.contextName)}`
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
|
if (config.runAsTask === true) {
|
||||||
|
apiUrl += '&_executionTasks=true'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let requestParams = {
|
let requestParams = {
|
||||||
@@ -113,6 +119,26 @@ export class WebJobExecutor extends BaseJobExecutor {
|
|||||||
*/
|
*/
|
||||||
let formData = getFormData()
|
let formData = getFormData()
|
||||||
|
|
||||||
|
// FIXME(viya - SAS Track CS0409737): remove when Viya stops rejecting empty multipart on
|
||||||
|
// _executionTasks=true. Dummy file keeps the body non-empty
|
||||||
|
const hasExecutionTasksFlag = config.runAsTask === true
|
||||||
|
|
||||||
|
// Move debug params to URL for viya; viya seems to honor them more
|
||||||
|
// reliably in the query string than the multipart body.
|
||||||
|
if (
|
||||||
|
hasExecutionTasksFlag &&
|
||||||
|
config.debug &&
|
||||||
|
config.serverType === ServerType.SasViya
|
||||||
|
) {
|
||||||
|
const debugKeys = ['_debug', '_omitSessionResults']
|
||||||
|
debugKeys.forEach((key) => {
|
||||||
|
if (requestParams[key] !== undefined) {
|
||||||
|
apiUrl += `&${key}=${encodeURIComponent(requestParams[key])}`
|
||||||
|
delete requestParams[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
const stringifiedData = JSON.stringify(data)
|
const stringifiedData = JSON.stringify(data)
|
||||||
if (
|
if (
|
||||||
@@ -133,10 +159,21 @@ export class WebJobExecutor extends BaseJobExecutor {
|
|||||||
generateTableUploadForm(formData, data)
|
generateTableUploadForm(formData, data)
|
||||||
formData = newFormData
|
formData = newFormData
|
||||||
requestParams = { ...requestParams, ...params }
|
requestParams = { ...requestParams, ...params }
|
||||||
|
|
||||||
|
if (
|
||||||
|
config.serverType === ServerType.SasViya &&
|
||||||
|
hasExecutionTasksFlag
|
||||||
|
) {
|
||||||
|
addDummyFile(formData)
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return Promise.reject(new ErrorResponse(e?.message, e))
|
return Promise.reject(new ErrorResponse(e?.message, e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (config.serverType === ServerType.SasViya && hasExecutionTasksFlag) {
|
||||||
|
addDummyFile(formData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key in requestParams) {
|
for (const key in requestParams) {
|
||||||
@@ -168,11 +205,14 @@ export class WebJobExecutor extends BaseJobExecutor {
|
|||||||
if (config.debug) {
|
if (config.debug) {
|
||||||
switch (this.serverType) {
|
switch (this.serverType) {
|
||||||
case ServerType.SasViya:
|
case ServerType.SasViya:
|
||||||
jsonResponse = await parseSasViyaDebugResponse(
|
jsonResponse =
|
||||||
res.result,
|
config.useComputeApi === null && config.runAsTask === true
|
||||||
this.requestClient,
|
? await parseSasViyaLogDebugResponse(res.result)
|
||||||
this.serverUrl
|
: await parseSasViyaDebugResponse(
|
||||||
)
|
res.result,
|
||||||
|
this.requestClient,
|
||||||
|
this.serverUrl
|
||||||
|
)
|
||||||
break
|
break
|
||||||
case ServerType.Sas9:
|
case ServerType.Sas9:
|
||||||
jsonResponse =
|
jsonResponse =
|
||||||
@@ -231,6 +271,23 @@ export class WebJobExecutor extends BaseJobExecutor {
|
|||||||
return requestPromise
|
return requestPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected getRequestParams(config: any): any {
|
||||||
|
const requestParams = super.getRequestParams(config)
|
||||||
|
|
||||||
|
// FIXME(viya - possible issue with default debug flags)
|
||||||
|
// runAsTask on Viya: use _debug=128 (not 131) and omit _omittextlog
|
||||||
|
if (
|
||||||
|
config.debug &&
|
||||||
|
config.serverType === ServerType.SasViya &&
|
||||||
|
config.runAsTask === true
|
||||||
|
) {
|
||||||
|
requestParams['_debug'] = 128
|
||||||
|
delete requestParams['_omittextlog']
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestParams
|
||||||
|
}
|
||||||
|
|
||||||
private async getJobUri(sasJob: string) {
|
private async getJobUri(sasJob: string) {
|
||||||
if (!this.sasViyaApiClient) return ''
|
if (!this.sasViyaApiClient) return ''
|
||||||
let uri = ''
|
let uri = ''
|
||||||
@@ -263,3 +320,18 @@ export class WebJobExecutor extends BaseJobExecutor {
|
|||||||
return uri
|
return uri
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addDummyFile(formData: NodeFormData | FormData) {
|
||||||
|
if (isNode()) {
|
||||||
|
;(formData as NodeFormData).append('_sasjs_noop', '', {
|
||||||
|
filename: '_sasjs_noop.txt',
|
||||||
|
contentType: 'text/plain'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
;(formData as FormData).append(
|
||||||
|
'_sasjs_noop',
|
||||||
|
new Blob([''], { type: 'text/plain' }),
|
||||||
|
'_sasjs_noop.txt'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import NodeFormData from 'form-data'
|
||||||
|
import { ServerType } from '@sasjs/utils/types'
|
||||||
|
import { WebJobExecutor } from '../WebJobExecutor'
|
||||||
|
import { RequestClient } from '../../request/RequestClient'
|
||||||
|
import { SASViyaApiClient } from '../../SASViyaApiClient'
|
||||||
|
|
||||||
|
describe('WebJobExecutor runAsTask behaviour', () => {
|
||||||
|
const serverUrl = 'https://sample.server.com'
|
||||||
|
const jobsPath = '/SASJobExecution'
|
||||||
|
|
||||||
|
const makeExecutor = (serverType: ServerType = ServerType.SasViya) => {
|
||||||
|
const requestClient = new RequestClient(serverUrl)
|
||||||
|
const sasViyaApiClient = {
|
||||||
|
getJobsInFolder: async () => []
|
||||||
|
} as unknown as SASViyaApiClient
|
||||||
|
const executor = new WebJobExecutor(
|
||||||
|
serverUrl,
|
||||||
|
serverType,
|
||||||
|
jobsPath,
|
||||||
|
requestClient,
|
||||||
|
sasViyaApiClient
|
||||||
|
)
|
||||||
|
const postSpy = jest
|
||||||
|
.spyOn(requestClient, 'post')
|
||||||
|
.mockResolvedValue({ result: { table1: [] }, etag: '' } as any)
|
||||||
|
jest.spyOn(requestClient, 'appendRequest').mockImplementation()
|
||||||
|
return { executor, postSpy }
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseConfig = {
|
||||||
|
serverUrl,
|
||||||
|
serverType: ServerType.SasViya,
|
||||||
|
appLoc: '/Public/app',
|
||||||
|
useComputeApi: false,
|
||||||
|
debug: false
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sends table data in body (runAsTask=false)', async () => {
|
||||||
|
const { executor, postSpy } = makeExecutor()
|
||||||
|
|
||||||
|
await executor.execute(
|
||||||
|
'services/common/sendArr',
|
||||||
|
{ table1: [{ col1: 'v' }] },
|
||||||
|
{ ...baseConfig, runAsTask: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const [apiUrl, body, , contentType] = postSpy.mock.calls[0]
|
||||||
|
expect(apiUrl).not.toContain('_executionTasks=true')
|
||||||
|
expect(body).toBeInstanceOf(NodeFormData)
|
||||||
|
expect(contentType).toMatch(/^multipart\/form-data/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uploads as file when payload has semicolons (runAsTask=false)', async () => {
|
||||||
|
const { executor, postSpy } = makeExecutor()
|
||||||
|
|
||||||
|
await executor.execute(
|
||||||
|
'services/common/sendArr',
|
||||||
|
{ table1: [{ col1: 'has; semicolon' }] },
|
||||||
|
{ ...baseConfig, runAsTask: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const [apiUrl, body, , contentType] = postSpy.mock.calls[0]
|
||||||
|
expect(apiUrl).not.toContain('_executionTasks=')
|
||||||
|
expect(body).toBeInstanceOf(NodeFormData)
|
||||||
|
expect(contentType).toMatch(/^multipart\/form-data/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appends &_executionTasks=true to URL when runAsTask=true and no data', async () => {
|
||||||
|
const { executor, postSpy } = makeExecutor()
|
||||||
|
|
||||||
|
await executor.execute('services/common/sendArr', null, {
|
||||||
|
...baseConfig,
|
||||||
|
runAsTask: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const [apiUrl] = postSpy.mock.calls[0]
|
||||||
|
expect(apiUrl).toContain('&_executionTasks=true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appends &_executionTasks=true and sends table data when runAsTask=true with one input table', async () => {
|
||||||
|
const { executor, postSpy } = makeExecutor()
|
||||||
|
|
||||||
|
await executor.execute(
|
||||||
|
'services/common/sendArr',
|
||||||
|
{ table1: [{ col1: 'v' }] },
|
||||||
|
{ ...baseConfig, runAsTask: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const [apiUrl, body, , contentType] = postSpy.mock.calls[0]
|
||||||
|
expect(apiUrl).toContain('&_executionTasks=true')
|
||||||
|
expect(body).toBeInstanceOf(NodeFormData)
|
||||||
|
expect(contentType).toMatch(/^multipart\/form-data/)
|
||||||
|
const dump = (body as NodeFormData).getBuffer().toString()
|
||||||
|
expect(dump).toContain('name="sasjs_tables"')
|
||||||
|
expect(dump).toContain('name="sasjs1data"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('appends &_executionTasks=true to URL when runAsTask=true with multiple input tables', async () => {
|
||||||
|
const { executor, postSpy } = makeExecutor()
|
||||||
|
|
||||||
|
await executor.execute(
|
||||||
|
'services/common/sendArr',
|
||||||
|
{ table1: [{ col1: 'v' }], table2: [{ col2: 'w' }] },
|
||||||
|
{ ...baseConfig, runAsTask: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const [apiUrl, body, , contentType] = postSpy.mock.calls[0]
|
||||||
|
expect(apiUrl).toContain('&_executionTasks=true')
|
||||||
|
expect(body).toBeInstanceOf(NodeFormData)
|
||||||
|
expect(contentType).toMatch(/^multipart\/form-data/)
|
||||||
|
const dump = (body as NodeFormData).getBuffer().toString()
|
||||||
|
expect(dump).toContain('name="sasjs_tables"')
|
||||||
|
expect(dump).toMatch(/table1\s+table2/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uploads as file when runAsTask=true and payload has semicolons', async () => {
|
||||||
|
const { executor, postSpy } = makeExecutor()
|
||||||
|
|
||||||
|
await executor.execute(
|
||||||
|
'services/common/sendArr',
|
||||||
|
{ table1: [{ col1: 'has; semicolon' }] },
|
||||||
|
{ ...baseConfig, runAsTask: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const [apiUrl, body, , contentType] = postSpy.mock.calls[0]
|
||||||
|
expect(apiUrl).toContain('&_executionTasks=true')
|
||||||
|
expect(body).toBeInstanceOf(NodeFormData)
|
||||||
|
expect(contentType).toMatch(/^multipart\/form-data/)
|
||||||
|
const dump = (body as NodeFormData).getBuffer().toString()
|
||||||
|
expect(dump).toContain('filename="table1.csv"')
|
||||||
|
expect(dump).toContain('Content-Type: application/csv')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT append _executionTasks=true to URL when runAsTask=false', async () => {
|
||||||
|
const { executor, postSpy } = makeExecutor()
|
||||||
|
|
||||||
|
await executor.execute(
|
||||||
|
'services/common/sendArr',
|
||||||
|
{ table1: [{ col1: 'v' }] },
|
||||||
|
{ ...baseConfig, runAsTask: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const [apiUrl] = postSpy.mock.calls[0]
|
||||||
|
expect(apiUrl).not.toContain('_executionTasks=true')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -28,6 +28,9 @@ import {
|
|||||||
import { InvalidSASjsCsrfError } from '../types/errors/InvalidSASjsCsrfError'
|
import { InvalidSASjsCsrfError } from '../types/errors/InvalidSASjsCsrfError'
|
||||||
import { inspect } from 'util'
|
import { inspect } from 'util'
|
||||||
|
|
||||||
|
const getLogger = () =>
|
||||||
|
(typeof process !== 'undefined' && process.logger) || console
|
||||||
|
|
||||||
export class RequestClient implements HttpClient {
|
export class RequestClient implements HttpClient {
|
||||||
private requests: SASjsRequest[] = []
|
private requests: SASjsRequest[] = []
|
||||||
private requestsLimit: number = 10
|
private requestsLimit: number = 10
|
||||||
@@ -37,6 +40,7 @@ export class RequestClient implements HttpClient {
|
|||||||
protected csrfToken: CsrfToken = { headerName: '', value: '' }
|
protected csrfToken: CsrfToken = { headerName: '', value: '' }
|
||||||
protected fileUploadCsrfToken: CsrfToken | undefined
|
protected fileUploadCsrfToken: CsrfToken | undefined
|
||||||
protected httpClient!: AxiosInstance
|
protected httpClient!: AxiosInstance
|
||||||
|
private isRecoveringFromNetworkError = false
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
protected baseUrl: string,
|
protected baseUrl: string,
|
||||||
@@ -77,6 +81,25 @@ export class RequestClient implements HttpClient {
|
|||||||
localStorage.setItem('refreshToken', '')
|
localStorage.setItem('refreshToken', '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public resetInMemoryAuthState() {
|
||||||
|
this.clearCsrfTokens()
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
this.clearLocalStorageTokens()
|
||||||
|
}
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
this.clearAllCookies()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearAllCookies() {
|
||||||
|
const cookies = document.cookie.split(';')
|
||||||
|
for (const cookie of cookies) {
|
||||||
|
const name = cookie.split('=')[0].trim()
|
||||||
|
if (!name) continue
|
||||||
|
document.cookie = `${name}=; Max-Age=0; Path=/;`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public getBaseUrl() {
|
public getBaseUrl() {
|
||||||
return this.httpClient.defaults.baseURL || ''
|
return this.httpClient.defaults.baseURL || ''
|
||||||
}
|
}
|
||||||
@@ -354,6 +377,7 @@ export class RequestClient implements HttpClient {
|
|||||||
const csrfTokenKey = Object.keys(params).find((k) =>
|
const csrfTokenKey = Object.keys(params).find((k) =>
|
||||||
k?.toLowerCase().includes('csrf')
|
k?.toLowerCase().includes('csrf')
|
||||||
)
|
)
|
||||||
|
|
||||||
if (csrfTokenKey) {
|
if (csrfTokenKey) {
|
||||||
this.csrfToken.value = params[csrfTokenKey]
|
this.csrfToken.value = params[csrfTokenKey]
|
||||||
this.csrfToken.headerName = this.csrfToken.headerName || 'x-csrf-token'
|
this.csrfToken.headerName = this.csrfToken.headerName || 'x-csrf-token'
|
||||||
@@ -378,7 +402,7 @@ export class RequestClient implements HttpClient {
|
|||||||
})
|
})
|
||||||
.then((res) => res.data)
|
.then((res) => res.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
const logger = process.logger || console
|
const logger = getLogger()
|
||||||
logger.error(error)
|
logger.error(error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -687,6 +711,45 @@ ${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
|
|||||||
throw new CertificateError(e.message)
|
throw new CertificateError(e.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
e.isAxiosError &&
|
||||||
|
!response &&
|
||||||
|
e.code === 'ERR_NETWORK' &&
|
||||||
|
!this.isRecoveringFromNetworkError
|
||||||
|
) {
|
||||||
|
// Opaque ERR_NETWORK usually means the server rejected stale credentials.
|
||||||
|
// Wipe in-memory auth state, re-establish session via GET /,
|
||||||
|
// then retry the original request.
|
||||||
|
this.resetInMemoryAuthState()
|
||||||
|
this.isRecoveringFromNetworkError = true
|
||||||
|
try {
|
||||||
|
// Re-establish session and CSRF cookie
|
||||||
|
const rootResponse = await this.httpClient
|
||||||
|
.get('/', { withXSRFToken: true })
|
||||||
|
.catch((err) => err.response)
|
||||||
|
|
||||||
|
if (rootResponse?.data) {
|
||||||
|
const cookie =
|
||||||
|
/<script>document.cookie = '(XSRF-TOKEN=.*; Max-Age=86400; SameSite=Strict; Path=\/;)'<\/script>/.exec(
|
||||||
|
rootResponse.data
|
||||||
|
)?.[1]
|
||||||
|
|
||||||
|
if (cookie && typeof document !== 'undefined') {
|
||||||
|
document.cookie = cookie
|
||||||
|
}
|
||||||
|
|
||||||
|
this.parseAndSetCsrfToken(rootResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await callback()
|
||||||
|
} catch {
|
||||||
|
// Session could not be recovered — surface LoginRequiredError
|
||||||
|
throw new LoginRequiredError()
|
||||||
|
} finally {
|
||||||
|
this.isRecoveringFromNetworkError = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (e.message) throw e
|
if (e.message) throw e
|
||||||
else throw prefixMessage(e, 'Error while handling error. ')
|
else throw prefixMessage(e, 'Error while handling error. ')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export class Sas9RequestClient extends RequestClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public resetInMemoryAuthState() {
|
||||||
|
super.resetInMemoryAuthState()
|
||||||
|
if (this.httpClient.defaults.jar) {
|
||||||
|
;(this.httpClient.defaults.jar as tough.CookieJar).removeAllCookiesSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async login(username: string, password: string, jobsPath: string) {
|
public async login(username: string, password: string, jobsPath: string) {
|
||||||
const codeInjectorPath = `/User Folders/${username}/My Folder/sasjs/runner`
|
const codeInjectorPath = `/User Folders/${username}/My Folder/sasjs/runner`
|
||||||
if (this.httpClient.defaults.jar) {
|
if (this.httpClient.defaults.jar) {
|
||||||
|
|||||||
@@ -589,6 +589,42 @@ ${resHeaders[0]}: ${resHeaders[1]}${
|
|||||||
requestClient['handleError'](error, () => {}, false)
|
requestClient['handleError'](error, () => {}, false)
|
||||||
).resolves.toEqual(undefined)
|
).resolves.toEqual(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should clear CSRF and retry once on opaque ERR_NETWORK', async () => {
|
||||||
|
const networkError = {
|
||||||
|
isAxiosError: true,
|
||||||
|
code: 'ERR_NETWORK',
|
||||||
|
message: 'Network Error'
|
||||||
|
}
|
||||||
|
requestClient['csrfToken'] = { headerName: 'h', value: 'v' }
|
||||||
|
const callback = jest.fn().mockResolvedValue('ok')
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requestClient['handleError'](networkError, callback)
|
||||||
|
).resolves.toEqual('ok')
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1)
|
||||||
|
expect(requestClient['csrfToken']).toEqual({ headerName: '', value: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw LoginRequiredError if retry also fails with ERR_NETWORK', async () => {
|
||||||
|
const networkError = {
|
||||||
|
isAxiosError: true,
|
||||||
|
code: 'ERR_NETWORK',
|
||||||
|
message: 'Network Error'
|
||||||
|
}
|
||||||
|
const innerHandle = jest.fn(() =>
|
||||||
|
requestClient['handleError'](networkError, () =>
|
||||||
|
Promise.reject(networkError)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requestClient['handleError'](networkError, innerHandle)
|
||||||
|
).rejects.toThrow(LoginRequiredError)
|
||||||
|
|
||||||
|
expect(innerHandle).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,14 @@ export class SASjsConfig {
|
|||||||
* may affect browser performance, especially with debug (logs) enabled.
|
* may affect browser performance, especially with debug (logs) enabled.
|
||||||
*/
|
*/
|
||||||
requestHistoryLimit?: number = 10
|
requestHistoryLimit?: number = 10
|
||||||
|
/**
|
||||||
|
* Optional setting. When `true`, the request runs as a Viya execution task —
|
||||||
|
* appends `_executionTasks=true` to the request URL. Only applies to the Viya
|
||||||
|
* web jobs path, i.e. when `serverType === SASVIYA` AND
|
||||||
|
* `useComputeApi` is `null`/`undefined`. Has no effect when `useComputeApi`
|
||||||
|
* is explicitly set to `true` or `false`.
|
||||||
|
*/
|
||||||
|
runAsTask?: boolean = false
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum LoginMechanism {
|
export enum LoginMechanism {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export * from './parseGeneratedCode'
|
|||||||
export * from './parseSasViyaLog'
|
export * from './parseSasViyaLog'
|
||||||
export * from './parseSourceCode'
|
export * from './parseSourceCode'
|
||||||
export * from './parseViyaDebugResponse'
|
export * from './parseViyaDebugResponse'
|
||||||
|
export * from './parseViyaLogDebugResponse'
|
||||||
export * from './parseWeboutResponse'
|
export * from './parseWeboutResponse'
|
||||||
export * from './serialize'
|
export * from './serialize'
|
||||||
export * from './splitChunks'
|
export * from './splitChunks'
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { getValidJson } from './getValidJson'
|
||||||
|
import { parseWeboutResponse } from './parseWeboutResponse'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When querying a Viya job using the Web approach with _DEBUG=128 (used when
|
||||||
|
* runAsTask is true), the webout JSON is inlined into the response via:
|
||||||
|
* var blob = new Blob([`{...}`], {type: 'application/json'});
|
||||||
|
* On abort/error paths the same shape is used but with text/plain and
|
||||||
|
* weboutBEGIN/END markers around the JSON:
|
||||||
|
* var blob = new Blob([`>>weboutBEGIN<<\n{...}\n>>weboutEND<<\n`], {type: 'text/plain'});
|
||||||
|
* No follow-up request is needed — extract and parse the JSON directly.
|
||||||
|
*/
|
||||||
|
export const parseSasViyaLogDebugResponse = async (response: any) => {
|
||||||
|
// If upstream already parsed the response as JSON (object), pass through.
|
||||||
|
if (typeof response !== 'string') {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
const blobMatch = response.match(
|
||||||
|
/new Blob\(\[`([\s\S]*?)`\],\s*\{type:\s*'(?:application\/json|text\/plain)'\}\)/
|
||||||
|
)
|
||||||
|
if (!blobMatch) {
|
||||||
|
throw new Error('Unable to find webout blob in debug log response.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const blobContent = blobMatch[1]
|
||||||
|
const stripped = blobContent.includes('>>weboutBEGIN<<')
|
||||||
|
? parseWeboutResponse(blobContent)
|
||||||
|
: blobContent
|
||||||
|
|
||||||
|
return getValidJson(stripped)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { parseSasViyaLogDebugResponse } from '../parseViyaLogDebugResponse'
|
||||||
|
|
||||||
|
describe('parseSasViyaLogDebugResponse', () => {
|
||||||
|
it('should extract and parse JSON from inline Blob', async () => {
|
||||||
|
const resultData = { message: 'success' }
|
||||||
|
const response = `<html><body><script>
|
||||||
|
var blob = new Blob([\`${JSON.stringify(resultData)}\`], {type: 'application/json'});
|
||||||
|
</script></body></html>`
|
||||||
|
|
||||||
|
const result = await parseSasViyaLogDebugResponse(response)
|
||||||
|
|
||||||
|
expect(result).toEqual(resultData)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract and parse multiline JSON from inline Blob', async () => {
|
||||||
|
const resultData = {
|
||||||
|
SYSDATE: '13MAY26',
|
||||||
|
SYSCC: '0',
|
||||||
|
saslibs: [{ LIBRARYREF: 'FORMATS' }]
|
||||||
|
}
|
||||||
|
const response = `<script>
|
||||||
|
var blob = new Blob([\`{"SYSDATE" : "13MAY26"
|
||||||
|
,"SYSCC" : "0"
|
||||||
|
,"saslibs": [{"LIBRARYREF":"FORMATS"}]
|
||||||
|
}
|
||||||
|
\`], {type: 'application/json'});
|
||||||
|
</script>`
|
||||||
|
|
||||||
|
const result = await parseSasViyaLogDebugResponse(response)
|
||||||
|
|
||||||
|
expect(result).toEqual(resultData)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should extract and parse JSON wrapped in weboutBEGIN/END (text/plain blob)', async () => {
|
||||||
|
const resultData = { SYSCC: '1012', SYSERRORTEXT: 'File missing.' }
|
||||||
|
const response = `<script>
|
||||||
|
var blob = new Blob([\`>>weboutBEGIN<<
|
||||||
|
${JSON.stringify(resultData)}
|
||||||
|
>>weboutEND<<
|
||||||
|
\`], {type: 'text/plain'});
|
||||||
|
</script>`
|
||||||
|
|
||||||
|
const result = await parseSasViyaLogDebugResponse(response)
|
||||||
|
|
||||||
|
expect(result).toEqual(resultData)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw an error if blob is not found', async () => {
|
||||||
|
const response = `<html><body>No blob here</body></html>`
|
||||||
|
|
||||||
|
await expect(parseSasViyaLogDebugResponse(response)).rejects.toThrow(
|
||||||
|
'Unable to find webout blob in debug log response.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user