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

Compare commits

...

275 Commits

Author SHA1 Message Date
dependabot[bot]
5e86f2b0fe chore(deps-dev): bump express from 4.17.3 to 4.20.0
Bumps [express](https://github.com/expressjs/express) from 4.17.3 to 4.20.0.
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.17.3...4.20.0)

---
updated-dependencies:
- dependency-name: express
  dependency-version: 4.20.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-19 12:22:38 +00:00
Allan Bowe
87b60a4a21 Merge pull request #858 from sasjs/sasjs-tests-no-deps
fix(sasjs-tests): remove dependancies
2025-11-19 12:20:07 +00:00
mulahasanovic
07e4ba54f3 fix(sasjs-tests): construct stylesheets 2025-11-18 17:26:24 +01:00
mulahasanovic
6f73011bc1 feat(sasjs-tests): granular test rerun 2025-11-18 17:13:37 +01:00
mulahasanovic
f26d51747f fix(sasjs-tests): duplicate test suite 2025-11-18 16:55:15 +01:00
mulahasanovic
1f8554f925 fix(sasjs-tests): add VIYA defaults to config.json 2025-11-18 14:41:36 +01:00
mulahasanovic
0d871083ac fix(sasjs-tests): disable basicTests, viya auth interactions 2025-11-18 14:40:57 +01:00
mulahasanovic
ae71918ae2 fix(sasjs-tests): update hardcoded compute job path 2025-11-18 14:40:17 +01:00
mulahasanovic
364a063a11 fix(sasjs-tests): show RequestsModal if debug mode is off 2025-11-18 14:39:25 +01:00
mulahasanovic
ad4c9b2164 docs: update README.md 2025-11-18 12:10:37 +01:00
mulahasanovic
59198ed6ab feat(sasjs-tests): update tests, use vite and minimal deps 2025-11-18 12:01:41 +01:00
Sead Mulahasanović
79e5acb954 chore(git): merge pull request #848 from sasjs/readme_update_20250828 2025-10-17 11:54:14 +02:00
mulahasanovic
1eb5b29a77 chore(docs): update README.md
Remove unavailable and update workflow badge
2025-09-18 19:26:30 +02:00
Allan Bowe
bde28046be Merge pull request #850 from sasjs/fix-CVE-2025-58754
fix(deps): update axios to v1.12.2
2025-09-18 17:16:45 +01:00
mulahasanovic
eab61a80bf chore: prettier 2025-09-18 18:10:30 +02:00
mulahasanovic
9149f932c3 chore: prettier 2025-09-18 18:06:05 +02:00
Sead Mulahasanović
fb30ff8876 chore(git): merge pull request #849 from glM26/master
fix: update dependency axios to version 1.12.2 (CVE-2025-58754)
2025-09-18 18:03:55 +02:00
Stephan Markiefka
afff422333 feat: Update dependency axios to version 1.12.2 2025-09-18 11:57:09 +02:00
Trevor Moody
b09a8b0891 chore: README.MD updated to correct dead link 2025-08-28 15:46:22 +01:00
Allan Bowe
b49010cfe5 Merge pull request #847 from sasjs/update_20250821
feat: h54s Tables() compatibility
2025-08-22 12:11:34 +01:00
Trevor Moody
fd6fad9b07 feat: h54s Tables() compatibility 2025-08-22 10:24:02 +01:00
Allan Bowe
8a10c229d6 Merge pull request #846 from sasjs/form-data-vulnerabilities
Update vulnerable form-data to v4.0.4
2025-07-23 13:38:35 +01:00
M
66462fcc50 fix: update vulnerable form-data to v4.0.4 2025-07-23 13:35:49 +02:00
Allan Bowe
7e23b5db9d Merge pull request #845 from sasjs/jes-workaround
Viya JES approach workaround, job arguments are case-sensitive and webout was not returned
2025-06-09 19:23:48 +01:00
78f117812e fix: Viya JES approach workaround, job arguments are case-sensitive and webout was not returned 2025-06-09 16:59:09 +02:00
Allan Bowe
55af8c3f50 Merge pull request #844 from sasjs/ci-fix
ci: npm caching fix
2025-06-05 13:46:05 +01:00
1185c2f1bf ci: npm caching fix 2025-06-05 14:43:33 +02:00
Allan Bowe
2842636c4a Merge pull request #843 from sasjs/get-update-file-content-viya
Added methods to GET and UPDATE file content on viya
2025-06-05 13:33:47 +01:00
8c7f614509 ci: jobs 2025-06-05 11:13:30 +02:00
943f60ea11 ci: jobs 2025-06-05 11:04:06 +02:00
3de343f135 ci: jobs 2025-06-05 10:37:53 +02:00
e11c97ec5d chore: fixing type duplicate 2025-06-05 10:07:13 +02:00
49fba07824 fix: viya updateFileContent not sending proper content-type 2025-06-04 17:29:38 +02:00
b1c0e26c23 feat: added methods to GET and UPDATE file content on viya 2025-06-04 17:04:27 +02:00
Allan Bowe
3ec73750b7 Merge pull request #841 from sasjs/bump-axios
fix: axios bump
2025-03-14 11:16:54 +00:00
e3c4cb6b90 chore(sasjs-test): skip few tests that are failing due to server 2025-03-14 15:59:31 +05:00
d35f1617b8 chore(cypress): update integration test 2025-03-14 01:29:03 +05:00
302752d79e chore: revert last commit 2025-03-13 16:56:29 +05:00
4e1e3e8e77 chore: workflow 2025-03-13 16:36:44 +05:00
954d3ff633 ci: jobs naming 2025-03-11 14:07:31 +01:00
fce0c7e522 fix: axios 1.8.2 2025-03-11 13:51:58 +01:00
d0fbc7b8c7 fix: sasjs/utils bump, separated CI jobs into unit tests and server tests 2025-03-11 13:39:40 +01:00
6171199a7e fix: prepared for Viya streaming 2025-03-07 16:05:02 +01:00
4fb0b96f11 fix: new axios requires form data content type, es6 strict issues with iterations 2025-03-07 10:36:43 +01:00
008a9b4ca5 fix: withXSRFToken instead of withCredentials 2025-03-04 16:29:01 +01:00
b3b2c1414c chore: better test coverage 2025-03-04 14:48:22 +01:00
18be9e8806 fix: jest tests RequestClient.spec.ts 2025-03-03 17:52:53 +01:00
7bdd826418 chore: fix specs for getFormData and convertToCsv 2025-03-03 00:19:50 +05:00
3713a226a4 chore: temp fix for InternalAxiosRequestConfig 2025-03-02 23:33:28 +05:00
77306fedee fix: usage and typings, axios, nodeFormData, cookiejar... 2025-02-28 15:04:51 +01:00
be3ce56b85 fix: refactor the default callback for axios interceptors 2025-02-28 18:21:44 +05:00
851b8fce2a fix: axios bump 2025-02-28 11:25:44 +01:00
Allan Bowe
16dd175053 Merge pull request #840 from sasjs/bumputils
fix: removing rimraf
2025-02-27 11:36:06 +00:00
27698b3e8a ci: rimraf 2025-02-27 12:21:55 +01:00
0faa50685d fix: removing rimraf 2025-02-27 12:12:17 +01:00
Allan Bowe
0f20048fb4 Merge pull request #838 from sasjs/bumputils
fix: bumping sasjs/utils to 3.5
2025-02-27 09:30:55 +00:00
249837dacf ci: run on older ubuntu 2025-02-26 16:48:17 +01:00
a115c12f55 fix(ci): vpn files 2025-02-26 16:41:48 +01:00
61c4d21467 fix(ci): new vpn config 2025-02-26 16:28:48 +01:00
3e9f38529f ci: validate VPN connection 2025-02-26 16:15:55 +01:00
Allan Bowe
06f79307b9 chore: logging info 2025-02-26 15:11:52 +00:00
Allan Bowe
5122d2a9c9 fix: matrix bot address 2025-02-26 14:56:43 +00:00
mulahasanovic
dc3eb3f0db chore(git): merge branch 'bumputils' of github.com:sasjs/adapter into bumputils 2025-02-26 15:42:52 +01:00
mulahasanovic
b940bc7cc3 fix(tests): special character case 2025-02-26 15:36:43 +01:00
Allan Bowe
82fc55ac1c chore: adding more logging to workflow 2025-02-26 14:03:32 +00:00
fc1a22c8c5 chore(git): merge 2025-02-26 11:20:58 +01:00
57b9f86077 fix(ci): openvpn hangs 2025-02-26 11:19:19 +01:00
Allan Bowe
68f7b2eac2 fix: updating pkg lock 2025-02-26 10:13:20 +00:00
Allan Bowe
2676873bb0 fix: bump utils to 3.5.1 2025-02-26 10:09:39 +00:00
add2f0a860 fix(ci): openvpn 2025-02-26 10:05:57 +01:00
2072136577 fix: new node, externals 2025-02-26 09:53:43 +01:00
afae632fc6 fix: bump rimraf (versions prior to v4 are deprecated) 2025-02-20 14:28:27 +01:00
Allan Bowe
317587a3c8 fix: bumping sasjs/utils to 3.5 2025-02-15 14:32:50 +00:00
Allan Bowe
ffd6bc5a5c Merge pull request #836 from sasjs/issue-835
feat(auth): added multi-language support to logIn method
2024-06-21 13:40:20 +01:00
Yury
c2e64d9ba6 chore: cleanup 2024-06-21 11:32:58 +03:00
Yury
a90f699abd feat(auth): added utils to get and check login header 2024-06-21 11:24:06 +03:00
Yury
2cca192f88 chore(auth-manager): added comment 2024-06-20 17:38:56 +03:00
Yury
053b07769a feat(auth): added multi-language support to logIn method 2024-06-20 17:15:05 +03:00
Yury Shkoda
4c4511913c Merge pull request #834 from sasjs/redirectLogin-fix
fix(auth-manager): fixed redirectedLoginUrl
2024-02-19 15:59:04 +03:00
Yury
8c64c24f3c chore: left a comment 2024-02-19 15:26:26 +03:00
Yury
1f2f445002 chore: fixed SASLogon URL in AuthManager test 2024-02-19 11:06:09 +03:00
Yury
6afa056a86 chore: testing 2024-02-19 10:58:23 +03:00
Yury
fe47ca1152 fix(auth-manager): fixed redirectedLoginUrl 2024-02-19 08:50:19 +03:00
Yury Shkoda
10da691f0f Merge pull request #833 from sasjs/parent-session-state-check
fix(poll-job-state): fixed checking session state
2023-09-15 12:12:24 +03:00
Yury Shkoda
318f9694cb test: updated unit tests threshold 2023-09-15 08:58:46 +03:00
Yury Shkoda
56e6131e5c fix(poll-job-state): fixed checking session state 2023-09-15 08:51:39 +03:00
5dfee30875 Merge pull request #832 from sasjs/parent-session-state-check
feat(job-state): added session state check to doPoll func
2023-09-11 13:03:24 +02:00
Yury Shkoda
3a186bc55c feat(job-state): added session state check to doPoll func 2023-09-11 11:17:05 +03:00
Yury Shkoda
0359fcb6be Merge pull request #830 from sasjs/issue-829
fix(execute-script): fixed executing jobs on viya using compute api
2023-09-05 15:20:38 +03:00
Yury Shkoda
f2997169cb chore: renamed executeScript to executeOnComputeApi 2023-09-05 13:09:28 +03:00
Yury Shkoda
451f2dfaca fix(execute-script): fixed executing jobs on viya using compute api 2023-09-04 18:55:59 +03:00
Yury Shkoda
38e11f1771 Merge pull request #828 from sasjs/verbose-mode-issue
Fixed verbose mode for all response statuses
2023-08-17 15:20:49 +03:00
Yury Shkoda
259b6b3ff2 test(request-client): revert changed port 2023-08-17 13:44:08 +03:00
Yury Shkoda
5b2d9e675f test: set coverage threshold 2023-08-17 13:29:57 +03:00
Yury Shkoda
8dd4ab8cec fix(verbose-mode): fixed handling axios errors 2023-08-17 13:29:27 +03:00
Allan Bowe
34135b889f Merge pull request #827 from sasjs/cli-issue-1367
Bleached Verbose Mode and unit testing pollJobState
2023-08-14 18:36:20 +01:00
Yury Shkoda
62f4577b64 fix(request-client): fixed setVerboseMode method 2023-08-14 17:01:47 +03:00
Yury Shkoda
7a4feddd82 feat: added public method executeJob 2023-08-14 17:01:15 +03:00
Yury Shkoda
681abf5b3b chore(ci-cd): added sleep step before running cypress 2023-08-14 11:25:57 +03:00
Yury Shkoda
46c6d3e7f4 test(poll-job-state): covered changing polling strategies 2023-08-14 10:55:45 +03:00
Yury Shkoda
5731b0f9b1 refactor(request-client): put related types in one file 2023-08-14 10:55:09 +03:00
Yury Shkoda
f18a523087 feat(request-client): added bleached verbose mode 2023-08-14 10:50:11 +03:00
Yury Shkoda
8cbd292f13 Merge pull request #826 from sasjs/gh-action-fix
chore(gh-action): fixed access token
2023-08-09 15:24:43 +03:00
Yury Shkoda
4851f25753 chore(gh-action): fixed access token 2023-08-08 15:46:56 +03:00
Yury Shkoda
5756638dc2 Merge pull request #825 from sasjs/verboseMode
feat(request-client): made verbose mode easier to configure
2023-08-01 09:52:59 +03:00
Yury Shkoda
e511cd613c docs(verbose): fixed docs 2023-07-31 17:41:04 +03:00
Yury Shkoda
2119c81ebb feat(sasjs-config): added verbose option 2023-07-31 17:37:39 +03:00
Yury Shkoda
ea4b30d6ef feat(request-client): made verbose mode easier to configure 2023-07-31 16:34:09 +03:00
Yury Shkoda
f1e1b33571 Merge pull request #824 from sasjs/startComputeJob-issue
feat(request-client): implemented verbose mode
2023-07-31 10:51:02 +03:00
Yury Shkoda
ccb8599f00 docs(request-client): added comments 2023-07-28 11:55:52 +03:00
Yury Shkoda
5bcd17096b feat(request-client): implemented verbose mode 2023-07-27 19:29:51 +03:00
Allan Bowe
d744ee12a3 Merge pull request #823 from sasjs/@sasjs/server-response-fix
feat(sasjs-request-client): improved parseResponse method
2023-07-26 11:45:04 +01:00
Yury Shkoda
5f15226cd9 test(sasjs-request-client): removed unnecessary part of the log 2023-07-25 17:31:39 +03:00
Yury Shkoda
f31ea28b9c refactor(sasjs-request-client): used SASJS_LOGS_SEPARATOR const 2023-07-25 16:08:16 +03:00
Yury Shkoda
e315e4a619 feat(sasjs-request-client): improved parseResponse method 2023-07-25 16:01:35 +03:00
Yury Shkoda
76bf5b88e9 Merge pull request #818 from sasjs/deps-bump
Dependencies bump
2023-07-12 09:50:50 +03:00
Yury Shkoda
a97ac4eaa6 chore: commiting changes 2023-07-11 15:36:13 +03:00
Yury Shkoda
37cfea6ca7 chore(deps): Merge branch 'dependabot/npm_and_yarn/sasjs-tests/loader-utils-2.0.4' of github.com:sasjs/adapter into deps-bump 2023-07-11 14:53:19 +03:00
Yury Shkoda
f74c8aca57 chore(deps): Merge branch 'dependabot/npm_and_yarn/sasjs-tests/json5-1.0.2' of github.com:sasjs/adapter into deps-bump 2023-07-11 14:52:28 +03:00
Yury Shkoda
77baaabfcd chore(deps): Merge branch 'dependabot/npm_and_yarn/sasjs-tests/http-cache-semantics-4.1.1' of github.com:sasjs/adapter into deps-bump 2023-07-11 14:51:32 +03:00
Yury Shkoda
510ba771f0 chore(deps): Merge branch 'dependabot/npm_and_yarn/sasjs-tests/webpack-5.76.3' of github.com:sasjs/adapter into deps-bump 2023-07-11 14:50:38 +03:00
Allan Bowe
6fce65f4c8 Merge pull request #817 from sasjs/request-sasjs-fix
fix(file-upload-form): fixed form data for node env
2023-07-11 09:58:57 +01:00
Yury Shkoda
fe03faa59f chore(file-upload-form): left comments 2023-07-11 09:26:36 +03:00
Yury Shkoda
6272eeda23 fix(form-data): fixed formData type check 2023-07-10 19:14:47 +03:00
Yury Shkoda
104d1b88b3 chore(deps): bimped tough-cookie and @types/tough-cookie 2023-07-10 17:07:39 +03:00
Yury Shkoda
0d9ba36de8 fix(file-upload-form): fixed form data for node env 2023-07-06 15:49:24 +03:00
Yury Shkoda
4e7a845d99 Merge pull request #816 from sasjs/ci/cd-workwlows-node-version
chore(ci-cd): used Node lts/hydrogen version
2023-07-06 12:42:12 +03:00
Yury Shkoda
716cc513ff chore(ci-cd): used Node lts/hydrogen version 2023-07-05 16:10:45 +03:00
Yury Shkoda
22edcb0a8e Merge pull request #810 from sasjs/pollJobState-improvements
Poll job state improvements
2023-07-05 11:15:42 +03:00
Yury Shkoda
aedf5c1734 chore: Merge branch 'master' of github.com:sasjs/adapter into pollJobState-improvements 2023-07-05 10:49:12 +03:00
Yury Shkoda
784bd20ee0 Merge pull request #814 from sasjs/issue-811-fixed
Issue 811 fixed
2023-07-05 10:27:04 +03:00
Yury Shkoda
61db1e0609 test: fixed unit tests 2023-06-23 18:04:48 +03:00
Yury Shkoda
5c589a6af3 chore: reverted dev changes to build.yml 2023-06-23 17:52:46 +03:00
Yury Shkoda
275cd6dbd3 chore: debugging 2023-06-23 17:20:16 +03:00
Yury Shkoda
d874e07889 fix(file-uploader): fixed parsing response for SASJS 2023-06-23 16:37:25 +03:00
Yury Shkoda
1648cf28d5 chore: Merge branch 'master' of github.com:sasjs/adapter into issue-811-fixed 2023-06-23 15:26:01 +03:00
a4aaeba31c fix: file upload with sasjs server type, json not parsed 2023-06-22 12:48:40 +02:00
Yury Shkoda
6bf68a315c fix(sasjs-utils): fixed imports 2023-06-22 13:37:07 +03:00
Allan Bowe
c0f78d0c1e fix: updating example.html to use v4 of the adapter 2023-06-22 08:56:32 +01:00
Yury Shkoda
e0aebc169f chore: debugging 2023-06-21 18:28:39 +03:00
Yury Shkoda
9a50e5cb63 chore: debugging 2023-06-21 18:16:20 +03:00
Yury Shkoda
a51923dad7 chore: debugging 2023-06-21 18:01:21 +03:00
Yury Shkoda
9aee77f0e3 chore: debugging 2023-06-21 17:44:24 +03:00
Yury Shkoda
c32d037063 chore: debugging 2023-06-21 17:34:16 +03:00
Yury Shkoda
94f7492c31 chore: debugging 2023-06-21 17:19:13 +03:00
Yury Shkoda
d29e0a0f57 chore(sasjs-tests): bumped node-sass 2023-06-21 16:36:22 +03:00
Yury Shkoda
8d7cc11db5 chore(sasjs-tests): bumped node-sass 2023-06-21 16:35:33 +03:00
Yury Shkoda
28e9d1cc6b test(get-token): covered getTokenRequestErrorPrefix 2023-06-21 16:34:26 +03:00
Yury Shkoda
375cec48ca feat(get-token): improved error prefix 2023-06-21 16:33:45 +03:00
7d826685f7 Merge pull request #813 from sasjs/sasjs-job-execute
fix: issue with parsing json in sasjs job executor
2023-06-15 16:08:08 +02:00
f42f6bca00 fix: issue with parsing json in sasjs job executor 2023-06-14 15:08:11 +02:00
Yury Shkoda
4440e5d1f9 fix(types): fixed PollOptions exports 2023-05-17 14:10:17 +03:00
Yury Shkoda
f484a5a6a1 refactor(poll-job-state): updated types and func attributes 2023-05-17 11:16:35 +03:00
Yury Shkoda
5c74186bab feat(poll-strategy): added subsequentStrategies to PollStrategy 2023-05-16 17:48:04 +03:00
Yury Shkoda
ea68c3dff3 docs(poll-job-state): updated docs 2023-05-16 17:42:27 +03:00
Yury Shkoda
153b285670 chore(poll-job-status): renamed PollOptions to PollStrategy and added docs 2023-05-15 16:32:07 +03:00
Yury Shkoda
f9f4aa5aa6 chore(reviewer-lottery): removed QA group 2023-05-15 14:53:55 +03:00
Yury Shkoda
bd02656b3c docs(poll-job-state): added comments 2023-05-15 14:36:18 +03:00
Yury Shkoda
991519a13d fix(execute-job): added error object if it present 2023-05-15 14:26:24 +03:00
Yury Shkoda
615c9d012e feat(poll-job-state): implemented polling strategies 2023-05-15 14:24:11 +03:00
Yury Shkoda
bd872e0e75 Merge pull request #809 from sasjs/webout-issue
fix(sasjs-request-client): fixed response parsing
2023-05-10 16:26:13 +03:00
Yury Shkoda
a14a1663fc fix(sasjs-request-client): fixed response parsing 2023-05-10 16:07:25 +03:00
Yury Shkoda
2482a0c674 Merge pull request #807 from sasjs/issue-245
fix(deploy-service-pack): fixed redeployment
2023-05-09 17:12:11 +03:00
Yury Shkoda
a19de50e67 fix(create-folder): improved error message 2023-05-09 16:59:18 +03:00
Yury Shkoda
860c9f907c fix(deploy-service-pack): fixed redeployment 2023-05-09 10:43:23 +03:00
Allan Bowe
d7126a6878 Merge pull request #802 from sasjs/fix-ci
chore(ci): install vpn and run sasjs tests
2023-05-04 10:51:34 +01:00
Yury Shkoda
a69ebd0fd3 Merge pull request #806 from sasjs/sasjs-job-execution
fix(job-execution): fixed webout for SASJS server type
2023-05-04 12:42:11 +03:00
Yury Shkoda
0f47326bb6 chore(execute-script): improved code style 2023-05-04 12:21:22 +03:00
Yury Shkoda
2d6efa2437 fix(job-execution): fixed webout for SASJS server type 2023-05-04 10:57:24 +03:00
Yury Shkoda
da7579a2bb Merge pull request #805 from sasjs/webpack-fix
fix(packaging): fixed output file for Node
2023-04-21 17:28:52 +03:00
Yury Shkoda
657e415c0c fix(packaging): fixed output file for Node 2023-04-21 16:02:47 +03:00
Allan Bowe
8fa908a201 Merge pull request #804 from sasjs/refrerefreshTokensForViya-fix
fix(refreshTokensForViya): fixed FormData logic
2023-04-21 09:53:55 +01:00
Yury Shkoda
a29b7f3b92 docs(refreshTokensForViya): added notes about limitation 2023-04-21 11:06:48 +03:00
Yury Shkoda
8360519408 fix(refreshTokensForViya): added throw error if not Node 2023-04-21 11:03:41 +03:00
Yury Shkoda
a71d422528 fix(refreshTokensForViya): fixed FormData logic 2023-04-20 16:13:22 +03:00
Allan Bowe
7d37e4d79d Merge pull request #793 from sasjs/session-manager-improvements
Session manager improvements
2023-04-19 15:08:42 +01:00
Yury Shkoda
7ae21c160a docs(session manager): added info about hot sessions 2023-04-19 16:01:49 +03:00
bfefdb65a3 chore: addressed comments 2023-04-18 00:45:03 +05:00
bbe52562da ci: sasjs tests 2023-04-10 13:15:41 +02:00
48917bb4d9 ci: sasjs tests 2023-04-10 12:22:28 +02:00
81c3d2a3dc style: lint 2023-04-10 12:10:41 +02:00
0052d5d340 ci: sasjs tests logged in fix 2023-04-10 12:08:01 +02:00
a633bda24d ci: sasjs tests 2023-04-10 10:05:10 +02:00
e951ea0ab2 chore: quick fix 2023-04-08 01:13:11 +05:00
b75ab13eb7 chore: update the command for running cypress 2023-04-07 23:50:11 +05:00
4de34cc8b0 chore: add streaming config and fix apploc 2023-04-07 17:06:08 +05:00
23a789b383 chore: add refresh token to .env.4gl 2023-04-06 23:13:23 +05:00
156f1b1180 chore: create .env.4gl at proper place 2023-04-06 22:43:32 +05:00
491e36d703 chore: install sasjs cli 2023-04-06 22:31:51 +05:00
06b6e48a16 chore: deploy the streaming app to sas9.4gl.io 2023-04-06 22:07:18 +05:00
9bebd356ca chore: deamonize npm start process 2023-04-06 16:28:33 +05:00
171f9bc7b9 chore(ci): install vpn and run sasjs tests locally 2023-04-06 16:03:16 +05:00
Allan Bowe
7cbc2bda08 Merge pull request #801 from sasjs/issue-680
ErrorBody interface made public
2023-04-06 12:02:15 +01:00
Allan Bowe
5bc2afba8c Merge pull request #800 from sasjs/sasjstestservices
chore: adding services in seperate (deployable) files as alternative …
2023-04-06 11:57:52 +01:00
dd3a7fe393 fix: ErrorBody interface made public 2023-04-06 12:45:22 +02:00
allan
ba1b1e6e80 Merge branch 'sasjstestservices' of github.com:sasjs/adapter into sasjstestservices 2023-04-06 11:42:04 +01:00
allan
3daa85a74a chore: updating appLoc to /Public/app/adapter-tests in sasjs-tests 2023-04-06 11:41:54 +01:00
Allan Bowe
d1139857a4 Update invalidJSON.sas 2023-04-06 11:33:31 +01:00
allan
cb106c76cb chore: adding services in seperate (deployable) files as alternative to manually running the code from the README in sasjs-test 2023-04-06 11:31:37 +01:00
Allan Bowe
ab8da28de1 Merge pull request #798 from sasjs/redirected-login-m8
fix: sas9 m8 redirected login
2023-04-05 15:14:16 +01:00
a729d67d3e chore: remove /home leftovers 2023-04-05 15:39:31 +02:00
548a44d665 chore: auth manager test fix 2023-04-04 14:08:47 +02:00
Allan Bowe
afda43fc7f Merge pull request #799 from sasjs/issue-470
fix: regex for extracting login url fixed
2023-04-03 22:21:13 +01:00
5291e7f01c fix: regex for extracting login url fixed 2023-04-03 23:10:27 +05:00
39abdad518 fix: sas9 m8 redirected login 2023-04-03 12:48:32 +02:00
Allan Bowe
6aa12ee950 Merge pull request #797 from sasjs/all-contributors/add-saramartinelli1992
docs: add saramartinelli1992 as a contributor for userTesting, and platform
2023-04-03 11:41:34 +01:00
allcontributors[bot]
b5b5093295 docs: update .all-contributorsrc [skip ci] 2023-04-03 10:41:06 +00:00
allcontributors[bot]
114ca21c17 docs: update README.md [skip ci] 2023-04-03 10:41:05 +00:00
Yury Shkoda
88a1f3eac6 docs(session-manager): improved grammar 2023-03-28 13:00:07 +03:00
Yury Shkoda
b00db86811 chore(session-manager): renamed ErrorResponse interface to ApiErrorResponse 2023-03-27 18:34:44 +03:00
Yury Shkoda
96caadc6b1 test(request-client): fixing self-signed certificate error 2023-03-27 17:08:06 +03:00
Yury Shkoda
074a21c4bb test(session-manager): fixed server url 2023-03-27 16:53:45 +03:00
Yury Shkoda
588a4169bb docs(session-manager): added method descriptions 2023-03-27 16:43:33 +03:00
Yury Shkoda
3d4c01622e chore(session-manager): improved error handling 2023-03-27 16:40:57 +03:00
dependabot[bot]
d166231c12 chore(deps): bump webpack from 5.73.0 to 5.76.3 in /sasjs-tests
Bumps [webpack](https://github.com/webpack/webpack) from 5.73.0 to 5.76.3.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.73.0...v5.76.3)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-03-27 12:12:06 +00:00
Allan Bowe
6aee95b21d Merge pull request #794 from sasjs/issue-303
fix: modify the regex to handle the loginForm response on latest sas9
2023-03-27 13:09:11 +01:00
3d281abbf8 chore: downgrade pem dev dependency by one minor version 2023-03-27 15:09:10 +05:00
99d783e174 fix: write a message to the log indicating that a login was not attempted as a valid session already exists 2023-03-24 15:11:10 +05:00
Yury Shkoda
33bc8f92cb test(request-client): fixed typo 2023-03-24 12:15:10 +03:00
Yury Shkoda
c1704fff78 feat(session-manager): refactored and covered with tests 2023-03-24 11:39:59 +03:00
17a3d1b8a9 fix: modify the reqgex to handle the loginForm response on latest sas9 2023-03-22 23:33:42 +05:00
Allan Bowe
01af5eb634 Merge pull request #791 from sasjs/sas9-mini
feat: create a minified version for sas9  and web based appications
2023-03-16 13:36:08 +00:00
0c3797e2de chore: deleted package-lock.json and recreated by npm install 2023-03-16 16:22:26 +05:00
c33c509207 chore: merge master 2023-03-16 13:40:22 +05:00
af351d7375 chore: bump webpack 2023-03-16 12:39:39 +05:00
2b53406cac chore: no need for forked version of tough cookie 2023-03-16 00:28:06 +05:00
99cfb8b2af feat: created a minified version of adapter for executing web jobs on sas9 2023-03-16 00:26:08 +05:00
Yury Shkoda
891cc13362 feat(session-manager): improving performance 2023-03-14 10:48:18 +03:00
Yury Shkoda
22fa185715 feat: minified adapter for SAS9 2023-03-14 10:45:03 +03:00
Allan Bowe
dad99557a7 Merge pull request #788 from sasjs/quick-fix
fix: throw error as it is when its an instance of JobExecutionError
2023-03-06 10:15:36 +00:00
c7cc2e5fa4 fix: throw error as it is in sas9RequestClient when its an instance of JobExecutionError 2023-03-06 14:53:42 +05:00
Allan Bowe
2bd7544051 Merge pull request #787 from sasjs/issue-786
fix: removing .do from loginUrl mechanism
2023-02-21 18:56:12 +00:00
1fb972d88a chore: improved url match 2023-02-21 16:10:14 +01:00
64f8f8c893 fix: removing .do from loginUrl mechanism 2023-02-21 16:04:50 +01:00
Sabir Hassan
ddb4a51c55 Merge pull request #785 from sasjs/issue-783
Sasjs executor not re-sending waiting requests
2023-02-15 21:45:16 +05:00
921d6ef364 fix: sasjs executor not re-sending waiting requests 2023-02-15 17:26:23 +01:00
Allan Bowe
105675d46a Merge pull request #784 from sasjs/medjedovicm-issue-template
Create issue_template.md
2023-02-15 16:18:27 +00:00
Allan Bowe
e4addba762 Update issue_template.md 2023-02-15 16:17:02 +00:00
8203e918fd Create issue_template.md 2023-02-15 17:15:24 +01:00
Allan Bowe
2210e43880 chore: update README
Thanks @saramartinelli1992

https://github.com/sasjs/adapter/issues/781#issuecomment-1420943632
2023-02-07 15:20:10 +00:00
dependabot[bot]
4cb150e951 chore(deps): bump http-cache-semantics in /sasjs-tests
Bumps [http-cache-semantics](https://github.com/kornelski/http-cache-semantics) from 4.1.0 to 4.1.1.
- [Release notes](https://github.com/kornelski/http-cache-semantics/releases)
- [Commits](https://github.com/kornelski/http-cache-semantics/compare/v4.1.0...v4.1.1)

---
updated-dependencies:
- dependency-name: http-cache-semantics
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-02-04 13:53:04 +00:00
Allan Bowe
b04df0bc6d Merge pull request #779 from sasjs/issue-771
fix: bump axios version
2023-02-01 10:23:29 +00:00
98e851b4d8 chore: npm audit fix 2023-01-31 23:08:56 +05:00
84306bea3d fix: bump axios version to 0.27.2 2023-01-31 23:05:54 +05:00
89d32262f8 chore: form-data provides its own type definitions, so you do not need @types/form-data installed 2023-01-31 15:49:40 +05:00
257010f57d chore: axios provides its own type definitions, so you don't need @types/axios installed 2023-01-31 15:48:08 +05:00
eb9991015b chore: fix npm install 2023-01-31 15:45:49 +05:00
Allan Bowe
9d17e87a09 Merge pull request #778 from sasjs/issue-777
fix: improve error message when sasjs runner is not found
2023-01-30 19:12:48 +00:00
55f309e998 chore: quick fix 2023-01-30 23:50:40 +05:00
3d9b40398c fix: improve error message when sasjs runner is not found 2023-01-30 23:22:43 +05:00
Allan Bowe
e0badae973 fix: bumping semantic release package 2023-01-26 13:51:22 +00:00
Allan Bowe
524c561390 Merge pull request #775 from sasjs/import-node-env
fix: createReadStream import where needed
2023-01-26 13:46:39 +00:00
e7ceac1b78 fix: createReadStream import where needed
It's node specific util and othervise adapter was failing on browser environment
2023-01-09 13:07:51 +01:00
dependabot[bot]
fc8598473f chore(deps): bump json5 from 1.0.1 to 1.0.2 in /sasjs-tests
Bumps [json5](https://github.com/json5/json5) from 1.0.1 to 1.0.2.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v1.0.1...v1.0.2)

---
updated-dependencies:
- dependency-name: json5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-08 11:36:38 +00:00
Allan Bowe
72ddd424a5 Merge pull request #772 from sasjs/add-deployZipFile
feat: add a new method deployZipFile in sasjsAPiClient class
2022-12-06 11:25:48 +00:00
85f771d1ed feat: add a new method deployZipFile in sasjsAPiClient class 2022-12-06 14:51:34 +05:00
dependabot[bot]
367e0ae25a chore(deps): bump loader-utils from 2.0.2 to 2.0.4 in /sasjs-tests
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.2 to 2.0.4.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.2...v2.0.4)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-16 06:56:29 +00:00
Allan Bowe
1a781c3a56 chore: adding matrix room link in action 2022-10-24 21:25:52 +00:00
Allan Bowe
296d4efdfb Merge pull request #768 from sasjs/update-sasjs-tests-deps
fix: vulnerabilities in sasjs-tests
2022-10-24 21:48:22 +01:00
8df09d01de fix: bump deps versions
@sasjs/test-framework
node-sass
2022-10-25 01:41:09 +05:00
Allan Bowe
2d4a9d6dee chore: updating matrix room id for matrixbot 2022-10-24 10:24:46 +00:00
Allan Bowe
38c30f6342 Merge pull request #767 from sasjs/removevpn
fix: removing old vpn files
2022-10-24 11:12:01 +01:00
Allan Bowe
dd72304bc7 fix: removing old vpn files
BREAKING CHANGE: The _previous_ commit introduced some breaking changes, due to method re-organisation, namely:
* merged executableScriptSAS9, executableScriptSASViya and executableScriptSASjs methods to executableScript
* removed deployToSASjs and executeJobSASjs from the main SASjs moodule, as we can use the SASjsApiClient directly for these operations
2022-10-24 10:10:39 +00:00
Allan Bowe
296a543b2d Merge pull request #766 from sasjs/breaking-change
fix!: move methods from main sasjs class to respective api client class
2022-10-20 14:02:06 +01:00
70b31dcb8f fix: export SasjsRequestClient from main index.ts 2022-10-18 19:10:46 +05:00
b0c2a81989 fix: remove deployToSasjs and executeJobSasjs from main sasjs module 2022-10-18 19:09:56 +05:00
53e167b17d fix: merge executeScriptSAS9, executeScriptSASViya and executeScriptSASjs into single executeScript
BREAKING CHANGE
2022-10-17 23:14:51 +05:00
Allan Bowe
5159318d0d chore: final edit to matrix 2022-10-07 12:35:09 +00:00
Allan Bowe
6842ee13e4 chore: matrix curl syntax 2022-10-07 12:29:13 +00:00
Allan Bowe
2ce0395a2e chore: new gh-pages action 2022-10-07 12:24:58 +00:00
Allan Bowe
63440ddfd2 chore: matrix message fix 2022-10-07 12:20:37 +00:00
Allan Bowe
eb6729a9c7 chore: fix error if folder does not exist 2022-10-07 12:13:40 +00:00
Allan Bowe
9cd9dc83f3 chore: putting docs back in docs folder 2022-10-07 12:06:47 +00:00
Allan Bowe
7608887a0e chore: move to gh-pages 2022-10-07 12:00:55 +00:00
dependabot[bot]
85dde61baf chore(deps): bump semver-regex from 3.1.3 to 3.1.4
Bumps [semver-regex](https://github.com/sindresorhus/semver-regex) from 3.1.3 to 3.1.4.
- [Release notes](https://github.com/sindresorhus/semver-regex/releases)
- [Commits](https://github.com/sindresorhus/semver-regex/commits/v3.1.4)

---
updated-dependencies:
- dependency-name: semver-regex
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-03 23:43:27 +00:00
179 changed files with 14833 additions and 48948 deletions

View File

@@ -106,6 +106,16 @@
"userTesting",
"doc"
]
},
{
"login": "saramartinelli1992",
"name": "Sara",
"avatar_url": "https://avatars.githubusercontent.com/u/100193908?v=4",
"profile": "https://github.com/saramartinelli1992",
"contributions": [
"userTesting",
"platform"
]
}
],
"contributorsPerLine": 7,

View File

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

12
.github/issue_template.md vendored Normal file
View File

@@ -0,0 +1,12 @@
## Expected behaviour
*Describe what should be happening*
## Current behaviour
*Describe what is actually happening*
## Environment info
**Client tech stack**: *Angular, React, Vue, VanillaJS, NodeJS etc.*
**Server type**: SASJS|SASVIYA|SAS9
**Login mechanism**: Default|Redirected
**Debug**: true|false
**Use Compute Api (relevant only on VIYA)**: true|false

View File

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

View File

@@ -1,30 +1,27 @@
cipher AES-256-CBC
setenv FORWARD_COMPATIBLE 1
# Client
client
server-poll-timeout 4
nobind
remote vpn.analytium.co.uk 1194 udp
remote vpn.analytium.co.uk 1194 udp
remote vpn.analytium.co.uk 443 tcp
remote vpn.analytium.co.uk 1194 udp
remote vpn.analytium.co.uk 1194 udp
remote vpn.analytium.co.uk 1194 udp
remote vpn.analytium.co.uk 1194 udp
remote vpn.analytium.co.uk 1194 udp
tls-client
dev tun
dev-type tun
ns-cert-type server
setenv opt tls-version-min 1.0 or-highest
reneg-sec 604800
sndbuf 0
rcvbuf 0
# NOTE: LZO commands are pushed by the Access Server at connect time.
# NOTE: The below line doesn't disable LZO.
comp-lzo no
verb 3
setenv PUSH_PEER_INFO
# this will connect with whatever proto DNS tells us (https://community.openvpn.net/openvpn/ticket/934)
proto udp
remote vpn.4gl.io 7194
resolv-retry infinite
# this will fallback from udp6 to udp4 as well
connect-timeout 5
data-ciphers AES-256-CBC:AES-256-GCM
auth SHA256
script-security 2
keepalive 10 120
remote-cert-tls server
# Keys
ca ca.crt
cert user.crt
key user.key
tls-auth tls.key 1
# Security
nobind
persist-key
persist-tun
verb 3

View File

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

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

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

View File

@@ -11,7 +11,7 @@ jobs:
strategy:
matrix:
node-version: [lts/fermium]
node-version: [lts/hydrogen]
steps:
- name: Checkout
@@ -21,22 +21,33 @@ jobs:
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: npm
# 2. Restore npm cache manually
- name: Restore npm cache
uses: actions/cache@v3
id: npm-cache
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install Dependencies
run: npm ci
- name: Ensure docs folder exists
run: |
rm -rf docs || true # avoid error if docs folder does not exist
mkdir docs
- name: Generate Docs
run: npm run typedoc
- name: Create CNAME file in docs
run: |
touch CNAME
echo adapter.sasjs.io >> CNAME
- name: Push generated docs
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_branch: gh-pages
publish_dir: ./docs
cname: adapter.sasjs.io
- name: Push generated docs to docs branch
uses: nicholasgriffintn/github-branch-deployment-action@0.0.1
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
BRANCH: docs
MESSAGE: 'Docs: ({sha}) {msg}'

View File

@@ -14,7 +14,7 @@ jobs:
strategy:
matrix:
node-version: [lts/fermium]
node-version: [lts/hydrogen]
steps:
- uses: actions/checkout@v2
@@ -22,7 +22,16 @@ jobs:
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: npm
# 2. Restore npm cache manually
- name: Restore npm cache
uses: actions/cache@v3
id: npm-cache
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install Dependencies
run: npm ci
@@ -34,10 +43,10 @@ jobs:
run: npm run build
- name: Semantic Release
uses: cycjimmy/semantic-release-action@v2
uses: cycjimmy/semantic-release-action@v3
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Send Matrix message
run: curl -X POST --data-urlencode "payload={\"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/%21BDUPBPEGVvRLKLQUxY: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 }}

View File

@@ -1,18 +1,18 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# 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:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
test:
runs-on: ubuntu-22.04
strategy:
matrix:
node-version: [lts/fermium]
node-version: [lts/hydrogen]
steps:
- uses: actions/checkout@v2
@@ -20,31 +20,28 @@ jobs:
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Check npm audit
run: npm audit --production --audit-level=low
# 2. Restore npm cache manually
- name: Restore npm cache
uses: actions/cache@v3
id: npm-cache
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install Dependencies
run: npm ci
- name: Check code style
run: npm run lint
- name: Run unit tests
run: npm test
- name: Install Rimraf
run: npm i rimraf
- name: Build Package
run: npm run package:lib
env:
CI: true
- name: Install SSH Key
uses: shimataro/ssh-key-action@v2
with:
key: ${{ secrets.DCGITLAB_KEY }}
known_hosts: 'placeholder'
- name: Write VPN Files
run: |
echo "$CA_CRT" > .github/vpn/ca.crt
@@ -58,18 +55,28 @@ jobs:
USER_KEY: ${{ secrets.USER_KEY }}
TLS_KEY: ${{ secrets.TLS_KEY }}
- name: Chmod VPN files
run: |
chmod 600 .github/vpn/ca.crt .github/vpn/user.crt .github/vpn/user.key .github/vpn/tls.key
- name: Install Open VPN
run: |
sudo apt install apt-transport-https
sudo wget https://swupdate.openvpn.net/repos/openvpn-repo-pkg-key.pub
sudo apt-key add openvpn-repo-pkg-key.pub
sudo wget -O /etc/apt/sources.list.d/openvpn3.list https://swupdate.openvpn.net/community/openvpn3/repos/openvpn3-focal.list
sudo wget -O /etc/apt/sources.list.d/openvpn3.list https://swupdate.openvpn.net/community/openvpn3/repos/openvpn3-jammy.list
sudo apt update
sudo apt install openvpn3=16~beta+focal
sudo apt install openvpn3=17~betaUb22042+jammy
- name: Start Open VPN 3
run: openvpn3 session-start --config .github/vpn/config.ovpn
- name: install pm2
run: npm i -g pm2
- name: Fetch SASJS server
run: curl ${{ secrets.SASJS_SERVER_URL }}/SASjsApi/info
- name: Deploy sasjs-tests
run: |
npm install -g replace-in-files-cli
@@ -78,20 +85,23 @@ jobs:
npm i
replace-in-files --regex='"serverUrl".*' --replacement='"serverUrl":"${{ secrets.SASJS_SERVER_URL }}",' ./public/config.json
replace-in-files --regex='"userName".*' --replacement='"userName":"${{ secrets.SASJS_USERNAME }}",' ./public/config.json
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./public/config.json
replace-in-files --regex='"serverType".*' --replacement='"serverType":"SASJS",' ./public/config.json
npm run update:adapter && npm run build
scp -o stricthostkeychecking=no -r ./build/* ${{ secrets.DCGITLAB_DEPLOY_PATH_VIYA }}
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./public/config.json
cat ./public/config.json
npm run update:adapter
pm2 start --name sasjs-test npm -- start
- name: Sleep for 10 seconds
run: sleep 10s
shell: bash
- name: Run cypress on sasjs
run: |
replace-in-files --regex='"sasjsTestsUrl".*' --replacement='"sasjsTestsUrl":"${{ secrets.SASJS_TEST_URL_VIYA }}",' ./cypress.json
replace-in-files --regex='"sasjsTestsUrl".*' --replacement='"sasjsTestsUrl":"http://localhost:3000",' ./cypress.json
replace-in-files --regex='"username".*' --replacement='"username":"${{ secrets.SASJS_USERNAME }}",' ./cypress.json
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./cypress.json
sh ./sasjs-tests/sasjs-cypress-run.sh ${{ secrets.MATRIX_TOKEN }} https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}
cat ./cypress.json
echo "SASJS_USERNAME=${{ secrets.SASJS_USERNAME }}"
# 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 }}
sh ./sasjs-tests/sasjs-cypress-run.sh ${{ secrets.MATRIX_TOKEN }} https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}

3
.vscode/settings.json vendored Normal file
View File

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

251
README.md
View File

@@ -3,24 +3,22 @@
[![npm package][npm-image]][npm-url]
[![Github Workflow][githubworkflow-image]][githubworkflow-url]
[![npm](https://img.shields.io/npm/dt/@sasjs/adapter)]()
![Snyk Vulnerabilities for npm package](https://img.shields.io/snyk/vulnerabilities/npm/@sasjs/adapter)
[![License](https://img.shields.io/apm/l/atomic-design-ui.svg)](/LICENSE)
![GitHub License](https://img.shields.io/github/license/sasjs/adapter)
![GitHub top language](https://img.shields.io/github/languages/top/sasjs/adapter)
![GitHub issues](https://img.shields.io/github/issues/sasjs/adapter)
[![Gitpod ready-to-code](https://img.shields.io/badge/Gitpod-ready--to--code-908a85?logo=gitpod)](https://gitpod.io/#https://github.com/sasjs/adapter)
[npm-image]:https://img.shields.io/npm/v/@sasjs/adapter.svg
[npm-url]:http://npmjs.org/package/@sasjs/adapter
[githubworkflow-image]:https://github.com/sasjs/adapter/actions/workflows/build.yml/badge.svg
[githubworkflow-url]:https://github.com/sasjs/adapter/blob/main/.github/workflows/build.yml
[dependency-image]:https://david-dm.org/sasjs/adapter.svg
[npm-image]: https://img.shields.io/npm/v/@sasjs/adapter.svg
[npm-url]: http://npmjs.org/package/@sasjs/adapter
[githubworkflow-image]: https://github.com/sasjs/adapter/actions/workflows/build-unit-tests.yml/badge.svg
[githubworkflow-url]: https://github.com/sasjs/adapter/blob/main/.github/workflows/build.yml
[dependency-image]: https://david-dm.org/sasjs/adapter.svg
SASjs is a open-source framework for building Web Apps on SAS® platforms. You can use as much or as little of it as you like. This repository contains the JS adapter, the part that handles the to/from SAS communication on the client side. There are 3 ways to install it:
1 - `npm install @sasjs/adapter` - for use in a nodeJS project (recommended)
2 - [Download](https://cdn.jsdelivr.net/npm/@sasjs/adapter@3/index.min.js) and use a copy of the latest JS file
2 - [Download](https://cdn.jsdelivr.net/npm/@sasjs/adapter@4/index.min.js) and use a copy of the latest JS file
3 - Reference directly from the CDN - in which case click [here](https://www.jsdelivr.com/package/npm/@sasjs/adapter?tab=collection) and select "SRI" to get the script tag with the integrity hash.
@@ -69,24 +67,27 @@ There are three parts to consider:
To install the library you can simply run `npm i @sasjs/adapter` or include a `<script>` tag with a reference to our [CDN](https://www.jsdelivr.com/package/npm/@sasjs/adapter).
Full technical documentation is available [here](https://adapter.sasjs.io). The main parts are:
Full technical documentation is available [here](https://adapter.sasjs.io). The main parts are:
### Instantiation
The following code will instantiate an instance of the adapter:
```javascript
let sasJs = new SASjs.default(
{
appLoc: "/Your/SAS/Folder",
serverType:"SAS9"
}
);
let sasJs = new SASjs.default({
appLoc: '/Your/SAS/Folder',
serverType: 'SAS9'
})
```
If you've installed it via NPM, you can import it as a default import like so:
```js
import SASjs from '@sasjs/adapter';
import SASjs from '@sasjs/adapter'
```
You can then instantiate it with:
```js
const sasJs = new SASjs({your config})
```
@@ -94,10 +95,11 @@ const sasJs = new SASjs({your config})
More on the config later.
### SAS Logon
All authentication from the adapter is done against SASLogon. There are two approaches that can be taken, which are configured using the `loginMechanism` attribute of the sasJs config object (above):
* `loginMechanism:'Redirected'` - this approach enables authentication through a SASLogon window, supporting complex authentication flows (such as 2FA) and avoids the need to handle passwords in the application itself. The styling of the window can be modified using CSS.
* `loginMechanism:'Default'` - this approach requires that the username and password are captured, and used within the `.login()` method. This can be helpful for development, or automated testing.
All authentication from the adapter is done against SASLogon. There are two approaches that can be taken, which are configured using the `loginMechanism` attribute of the sasJs config object (above):
- `loginMechanism:'Redirected'` - this approach enables authentication through a SASLogon window, supporting complex authentication flows (such as 2FA) and avoids the need to handle passwords in the application itself. The styling of the window can be modified using CSS.
- `loginMechanism:'Default'` - this approach requires that the username and password are captured, and used within the `.login()` method. This can be helpful for development, or automated testing.
Sample code for logging in with the `Default` approach:
@@ -114,88 +116,106 @@ sasJs.logIn('USERNAME','PASSWORD'
More examples of using authentication, and more, can be found in the [SASjs Seed Apps](https://github.com/search?q=topic%3Asasjs-app+org%3Asasjs+fork%3Atrue) on github.
### Request / Response
### Request / Response
A simple request can be sent to SAS in the following fashion:
```javascript
sasJs.request("/path/to/my/service", dataObject)
.then((response) => {
// all tables are in the response object, eg:
console.log(response.tablewith2cols1row[0].COL1.value)
})
sasJs.request('/path/to/my/service', dataObject).then((response) => {
// all tables are in the response object, eg:
console.log(response.tablewith2cols1row[0].COL1.value)
})
```
We supply the path to the SAS service, and a data object.
If the path starts with a `/` then it should be a full path to the service. If there is no leading `/` then it is relative to the `appLoc`.
If the path starts with a `/` then it should be a full path to the service. If there is no leading `/` then it is relative to the `appLoc`.
The data object can be null (for services with no input), or can contain one or more "tables" in the following format:
```javascript
let dataObject={
"tablewith2cols1row": [{
"col1": "val1",
"col2": 42
}],
"tablewith1col2rows": [{
"col": "row1"
}, {
"col": "row2"
}]
};
let dataObject = {
tablewith2cols1row: [
{
col1: 'val1',
col2: 42
}
],
tablewith1col2rows: [
{
col: 'row1'
},
{
col: 'row2'
}
]
}
```
These tables (`tablewith2cols1row` and `tablewith1col2rows`) will be created in SAS WORK after running `%webout(FETCH)` in your SAS service.
The `request()` method also has optional parameters such as a config object and a callback login function.
The response object will contain returned tables and columns. Table names are always lowercase, and column names uppercase.
The response object will contain returned tables and columns. Table names are always lowercase, and column names uppercase.
The adapter will also cache the logs (if debug enabled) and even the work tables. For performance, it is best to keep debug mode off.
The adapter will also cache the logs (if debug enabled) and even the work tables. For performance, it is best to keep debug mode off.
### Verbose Mode
Set `verbose` to `true` to enable verbose mode that logs a summary of every HTTP response. Verbose mode can be disabled by calling `disableVerboseMode` method or enabled by `enableVerboseMode` method. Verbose mode also supports `bleached` mode that disables extra colors in req/res summary. To enable `bleached` verbose mode, pass `verbose` equal to `bleached` while instantiating an instance of `RequestClient` or to `setVerboseMode` method. Verbose mode can also be enabled/disabled by `startComputeJob` method.
### Session Manager
To execute a script on Viya a session has to be created first which is time-consuming (~15sec). That is why a Session Manager has been created which is implementing the following logic:
1. When the first session is requested, we also create one more session (hot session) for future requests. Please notice two pending POST requests to create a session within the same context: ![the first session request](./screenshots/session-manager-first-request.png)
2. When a subsequent request for a session is received and there is a hot session available (not expired), this session is returned and an asynchronous request to create another hot session is sent. Please notice that there is a pending POST request to create a new session while a job has been already finished execution (POST request with status 201): ![subsequent session request](./screenshots/subsequent-session-request.png)
3. When a subsequent request for a session is received and there is no available hot session, 2 requests are sent asynchronously to create a session. The first created session will be returned and another session will be reserved for future requests.
### Variable Types
The SAS type (char/numeric) of the values is determined according to a set of rules:
* If the values are numeric, the SAS type is numeric
* If the values are all string, the SAS type is character
* If the values contain a single character (a-Z + underscore + .) AND a numeric, then the SAS type is numeric (with special missing values).
* `null` is set to either '.' or '' depending on the assigned or derived type per the above rules. If entire column is `null` then the type will be numeric.
- If the values are numeric, the SAS type is numeric
- If the values are all string, the SAS type is character
- If the values contain a single character (a-Z + underscore + .) AND a numeric, then the SAS type is numeric (with special missing values).
- `null` is set to either '.' or '' depending on the assigned or derived type per the above rules. If entire column is `null` then the type will be numeric.
The following table illustrates the formats applied to columns under various scenarios:
|JS Values |SAS Format|
|---|---|
|'a', 'a' |$char1.|
|0, '_' |best.|
|'Z', 0 |best.|
|'a', 'aaa' |$char3.|
|null, 'a', 'aaa' | $char3.|
|null, 'a', 0 | best.|
|null, null | best.|
|null, '' | $char1.|
|null, 'a' | $char1.|
|'a' | $char1.|
|'a', null | $char1.|
|'a', null, 0 | best.|
| JS Values | SAS Format |
| ---------------- | ---------- |
| 'a', 'a' | $char1. |
| 0, '\_' | best. |
| 'Z', 0 | best. |
| 'a', 'aaa' | $char3. |
| null, 'a', 'aaa' | $char3. |
| null, 'a', 0 | best. |
| null, null | best. |
| null, '' | $char1. |
| null, 'a' | $char1. |
| 'a' | $char1. |
| 'a', null | $char1. |
| 'a', null, 0 | best. |
Validation is also performed on the values. The following combinations will throw errors:
Validation is also performed on the values. The following combinations will throw errors:
|JS Values |SAS Format|
|---|---|
|null, 'aaaa', 0 | Error: mixed types. 'aaaa' is not a special missing value.|
|0, 'a', '!' | Error: mixed types. '!' is not a special missing value|
|1.1, '.', 0| Error: mixed types. For regular nulls, use `null`|
| JS Values | SAS Format |
| --------------- | ---------------------------------------------------------- |
| null, 'aaaa', 0 | Error: mixed types. 'aaaa' is not a special missing value. |
| 0, 'a', '!' | Error: mixed types. '!' is not a special missing value |
| 1.1, '.', 0 | Error: mixed types. For regular nulls, use `null` |
### Variable Format Override
The auto-detect functionality above is thwarted in the following scenarios:
* A character column containing only `null` values (is considered numeric)
* A numeric column containing only special missing values (is considered character)
- A character column containing only `null` values (is considered numeric)
- A numeric column containing only special missing values (is considered character)
To cater for these scenarios, an optional array of formats can be passed along with the data to ensure that SAS will read them in correctly.
To understand these formats, it should be noted that the JSON data is NOT passed directly (as JSON) to SAS. It is first converted into CSV, and the header row is actually an `infile` statement in disguise. It looks a bit like this:
To understand these formats, it should be noted that the JSON data is NOT passed directly (as JSON) to SAS. It is first converted into CSV, and the header row is actually an `infile` statement in disguise. It looks a bit like this:
```csv
CHARVAR1:$char4. CHARVAR2:$char1. NUMVAR:best.
@@ -206,14 +226,13 @@ ABCD,X,.
To provide overrides to this header row, the tables object can be constructed as follows (with a leading '$' in the table name):
```javascript
let specialData={
"tablewith2cols2rows": [
{"col1": "val1","specialMissingsCol": "A"},
{"col1": "val2","specialMissingsCol": "_"}
let specialData = {
tablewith2cols2rows: [
{ col1: 'val1', specialMissingsCol: 'A' },
{ col1: 'val2', specialMissingsCol: '_' }
],
"$tablewith2cols2rows":{"formats":{"specialMissingsCol":"best."}
}
};
$tablewith2cols2rows: { formats: { specialMissingsCol: 'best.' } }
}
```
It is not necessary to provide formats for ALL the columns, only the ones that need to be overridden.
@@ -242,14 +261,15 @@ run;
%webout(CLOSE) /* Close the JSON and add default variables */
```
By default, special SAS numeric missings (_a-Z) are converted to `null` in the JSON. If you'd like to preserve these, use the `missing=STRING` option as follows:
By default, special SAS numeric missings (\_a-Z) are converted to `null` in the JSON. If you'd like to preserve these, use the `missing=STRING` option as follows:
```sas
%webout(OBJ,a,missing=STRING)
```
In this case, special missings (such as `.a`, `.b`) are converted to javascript string values (`'A', 'B'`).
Where an entire column is made up of special missing numerics, there would be no way to distinguish it from a single-character column by looking at the values. To cater for this scenario, it is possible to export the variable types (and other attributes such as label and format) by adding a `showmeta` param to the `webout()` macro as follows:
Where an entire column is made up of special missing numerics, there would be no way to distinguish it from a single-character column by looking at the values. To cater for this scenario, it is possible to export the variable types (and other attributes such as label and format) by adding a `showmeta` param to the `webout()` macro as follows:
```sas
%webout(OBJ,a,missing=STRING,showmeta=YES)
@@ -259,22 +279,23 @@ The `%webout()` macro itself is just a wrapper for the [mp_jsonout](https://core
## Configuration
Configuration on the client side involves passing an object on startup, which can also be passed with each request. Technical documentation on the SASjsConfig class is available [here](https://adapter.sasjs.io/classes/types.sasjsconfig.html). The main config items are:
Configuration on the client side involves passing an object on startup, which can also be passed with each request. Technical documentation on the SASjsConfig class is available [here](https://github.com/sasjs/adapter/blob/master/src/types/SASjsConfig.ts). The main config items are:
* `appLoc` - this is the folder (eg in metadata or SAS Drive) under which the SAS services are created.
* `serverType` - either `SAS9`, `SASVIYA` or `SASJS`. The `SASJS` server type is for use with [sasjs/server](https://github.com/sasjs/server).
* `serverUrl` - the location (including http protocol and port) of the SAS Server. Can be omitted, eg if serving directly from the SAS Web Server, or in streaming mode.
* `debug` - if `true` then SAS Logs and extra debug information is returned.
* `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.
* `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.
- `appLoc` - this is the folder (eg in metadata or SAS Drive) under which the SAS services are created.
- `serverType` - either `SAS9`, `SASVIYA` or `SASJS`. The `SASJS` server type is for use with [sasjs/server](https://github.com/sasjs/server).
- `serverUrl` - the location (including http protocol and port) of the SAS Server. Can be omitted, eg if serving directly from the SAS Web Server, or in streaming mode.
- `debug` - if `true` then SAS Logs and extra debug information is returned.
- `verbose` - optional, if `true` then a summary of every HTTP response is logged.
- `loginMechanism` - either `Default` or `Redirected`. See [SAS Logon](#sas-logon) section.
- `useComputeApi` - Only relevant when the serverType is `SASVIYA`. If `true` the [Compute API](#using-the-compute-api) is used. If `false` the [JES API](#using-the-jes-api) is used. If `null` or `undefined` the [Web](#using-jes-web-app) approach is used.
- `contextName` - Compute context on which the requests will be called. If missing or not provided, defaults to `Job Execution Compute context`.
- `requestHistoryLimit` - Request history limit. Increasing this limit may affect browser performance, especially with debug (logs) enabled. Default is 10.
The adapter supports a number of approaches for interfacing with Viya (`serverType` is `SASVIYA`). For maximum performance, be sure to [configure your compute context](https://sasjs.io/guide-viya/#shared-account-and-server-re-use) with `reuseServerProcesses` as `true` and a system account in `runServerAs`. This functionality is available since Viya 3.5. This configuration is supported when [creating contexts using the CLI](https://sasjs.io/sasjs-cli-context/#sasjs-context-create).
The adapter supports a number of approaches for interfacing with Viya (`serverType` is `SASVIYA`). For maximum performance, be sure to [configure your compute context](https://sasjs.io/guide-viya/#shared-account-and-server-re-use) with `reuseServerProcesses` as `true` and a system account in `runServerAs`. This functionality is available since Viya 3.5. This configuration is supported when [creating contexts using the CLI](https://sasjs.io/sasjs-cli-context/#sasjs-context-create).
### Using JES Web App
In this setup, all requests are routed through the JES web app, at `YOURSERVER/SASJobExecution?_program=/your/program`. This is the most reliable method, and also the slowest. One request is made to the JES app, and remaining requests (getting job uri, session spawning, passing parameters, running the program, fetching the log) are handled by the SAS server inside the JES app.
In this setup, all requests are routed through the JES web app, at `YOURSERVER/SASJobExecution?_program=/your/program`. This is the most reliable method, and also the slowest. One request is made to the JES app, and remaining requests (getting job uri, session spawning, passing parameters, running the program, fetching the log) are handled by the SAS server inside the JES app.
```
{
@@ -287,34 +308,35 @@ In this setup, all requests are routed through the JES web app, at `YOURSERVER/S
Note - to use the web approach, the `useComputeApi` property must be `undefined` or `null`.
### Using the JES API
Here we are running Jobs using the Job Execution Service except this time we are making the requests directly using the REST API instead of through the JES Web App. This is helpful when we need to call web services outside of a browser (eg with the SASjs CLI or other commandline tools). To save one network request, the adapter prefetches the JOB URIs and passes them in the `__job` parameter. Depending on your network bandwidth, it may or may not be faster than the JES Web approach.
Here we are running Jobs using the Job Execution Service except this time we are making the requests directly using the REST API instead of through the JES Web App. This is helpful when we need to call web services outside of a browser (eg with the SASjs CLI or other commandline tools). To save one network request, the adapter prefetches the JOB URIs and passes them in the `__job` parameter. Depending on your network bandwidth, it may or may not be faster than the JES Web approach.
This approach (`useComputeApi: false`) also ensures that jobs are displayed in Environment Manager.
```json
{
appLoc:"/Your/Path",
serverType:"SASVIYA",
useComputeApi: false,
contextName: 'yourComputeContext'
"appLoc": "/Your/Path",
"serverType": "SASVIYA",
"useComputeApi": false,
"contextName": "yourComputeContext"
}
```
### Using the Compute API
This approach is by far the fastest, as a result of the optimisations we have built into the adapter. With this configuration, in the first sasjs request, we take a URI map of the services in the target folder, and create a session manager. This manager will spawn a additional session every time a request is made. Subsequent requests will use the existing 'hot' session, if it exists. Sessions are always deleted after every use, which actually makes this _less_ resource intensive than a typical JES web app, in which all sessions are kept alive by default for 15 minutes.
This approach is by far the fastest, as a result of the optimisations we have built into the adapter. With this configuration, in the first sasjs request, we take a URI map of the services in the target folder, and create a session manager. This manager will spawn a additional session every time a request is made. Subsequent requests will use the existing 'hot' session, if it exists. Sessions are always deleted after every use, which actually makes this _less_ resource intensive than a typical JES web app, in which all sessions are kept alive by default for 15 minutes.
With this approach (`useComputeApi: true`), the requests/logs will _not_ appear in the list in Environment manager.
```json
{
appLoc:"/Your/Path",
serverType:"SASVIYA",
useComputeApi: true,
contextName: "yourComputeContext"
"appLoc": "/Your/Path",
"serverType": "SASVIYA",
"useComputeApi": true,
"contextName": "yourComputeContext"
}
```
# More resources
For more information and examples specific to this adapter you can check out the [user guide](https://sasjs.io/sasjs-adapter/) or the [technical](http://adapter.sasjs.io/) documentation.
@@ -323,7 +345,6 @@ For more information on building web apps in general, check out these [resources
As a SAS customer you can also request a copy of [Data Controller](https://datacontroller.io) - free for up to 5 users, this tool makes use of all parts of the SASjs framework.
## Star Gazing
If you find this library useful, help us grow our star graph!
@@ -331,8 +352,11 @@ If you find this library useful, help us grow our star graph!
![](https://starchart.cc/sasjs/adapter.svg)
## Contributors ✨
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-8-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-9-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
@@ -341,18 +365,21 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="https://krishna-acondy.io/"><img src="https://avatars.githubusercontent.com/u/2980428?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Krishna Acondy</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=krishna-acondy" title="Code">💻</a> <a href="#infra-krishna-acondy" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#blog-krishna-acondy" title="Blogposts">📝</a> <a href="#content-krishna-acondy" title="Content">🖋</a> <a href="#ideas-krishna-acondy" title="Ideas, Planning, & Feedback">🤔</a> <a href="#video-krishna-acondy" title="Videos">📹</a></td>
<td align="center"><a href="https://www.erudicat.com/"><img src="https://avatars.githubusercontent.com/u/25773492?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Yury Shkoda</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=YuryShkoda" title="Code">💻</a> <a href="#infra-YuryShkoda" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#ideas-YuryShkoda" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/sasjs/adapter/commits?author=YuryShkoda" title="Tests">⚠️</a> <a href="#video-YuryShkoda" title="Videos">📹</a></td>
<td align="center"><a href="https://github.com/medjedovicm"><img src="https://avatars.githubusercontent.com/u/18329105?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Mihajlo Medjedovic</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=medjedovicm" title="Code">💻</a> <a href="#infra-medjedovicm" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/sasjs/adapter/commits?author=medjedovicm" title="Tests">⚠️</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3Amedjedovicm" title="Reviewed Pull Requests">👀</a></td>
<td align="center"><a href="https://github.com/allanbowe"><img src="https://avatars.githubusercontent.com/u/4420615?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Allan Bowe</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=allanbowe" title="Code">💻</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3Aallanbowe" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/sasjs/adapter/commits?author=allanbowe" title="Tests">⚠️</a> <a href="#mentoring-allanbowe" title="Mentoring">🧑‍🏫</a> <a href="#maintenance-allanbowe" title="Maintenance">🚧</a></td>
<td align="center"><a href="https://github.com/saadjutt01"><img src="https://avatars.githubusercontent.com/u/8914650?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Muhammad Saad </b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=saadjutt01" title="Code">💻</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3Asaadjutt01" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/sasjs/adapter/commits?author=saadjutt01" title="Tests">⚠️</a> <a href="#mentoring-saadjutt01" title="Mentoring">🧑‍🏫</a> <a href="#infra-saadjutt01" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/sabhas"><img src="https://avatars.githubusercontent.com/u/82647447?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sabir Hassan</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=sabhas" title="Code">💻</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3Asabhas" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/sasjs/adapter/commits?author=sabhas" title="Tests">⚠️</a> <a href="#ideas-sabhas" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center"><a href="https://github.com/VladislavParhomchik"><img src="https://avatars.githubusercontent.com/u/83717836?v=4?s=100" width="100px;" alt=""/><br /><sub><b>VladislavParhomchik</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=VladislavParhomchik" title="Tests">⚠️</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3AVladislavParhomchik" title="Reviewed Pull Requests">👀</a></td>
</tr>
<tr>
<td align="center"><a href="http://rudvfaden.github.io/"><img src="https://avatars.githubusercontent.com/u/2445577?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Rud Faden</b></sub></a><br /><a href="#userTesting-rudvfaden" title="User Testing">📓</a> <a href="https://github.com/sasjs/adapter/commits?author=rudvfaden" title="Documentation">📖</a></td>
</tr>
<tbody>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://krishna-acondy.io/"><img src="https://avatars.githubusercontent.com/u/2980428?v=4?s=100" width="100px;" alt="Krishna Acondy"/><br /><sub><b>Krishna Acondy</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=krishna-acondy" title="Code">💻</a> <a href="#infra-krishna-acondy" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#blog-krishna-acondy" title="Blogposts">📝</a> <a href="#content-krishna-acondy" title="Content">🖋</a> <a href="#ideas-krishna-acondy" title="Ideas, Planning, & Feedback">🤔</a> <a href="#video-krishna-acondy" title="Videos">📹</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://www.erudicat.com/"><img src="https://avatars.githubusercontent.com/u/25773492?v=4?s=100" width="100px;" alt="Yury Shkoda"/><br /><sub><b>Yury Shkoda</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=YuryShkoda" title="Code">💻</a> <a href="#infra-YuryShkoda" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#ideas-YuryShkoda" title="Ideas, Planning, & Feedback">🤔</a> <a href="https://github.com/sasjs/adapter/commits?author=YuryShkoda" title="Tests">⚠️</a> <a href="#video-YuryShkoda" title="Videos">📹</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/medjedovicm"><img src="https://avatars.githubusercontent.com/u/18329105?v=4?s=100" width="100px;" alt="Mihajlo Medjedovic"/><br /><sub><b>Mihajlo Medjedovic</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=medjedovicm" title="Code">💻</a> <a href="#infra-medjedovicm" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/sasjs/adapter/commits?author=medjedovicm" title="Tests">⚠️</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3Amedjedovicm" title="Reviewed Pull Requests">👀</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/allanbowe"><img src="https://avatars.githubusercontent.com/u/4420615?v=4?s=100" width="100px;" alt="Allan Bowe"/><br /><sub><b>Allan Bowe</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=allanbowe" title="Code">💻</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3Aallanbowe" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/sasjs/adapter/commits?author=allanbowe" title="Tests">⚠️</a> <a href="#mentoring-allanbowe" title="Mentoring">🧑‍🏫</a> <a href="#maintenance-allanbowe" title="Maintenance">🚧</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/saadjutt01"><img src="https://avatars.githubusercontent.com/u/8914650?v=4?s=100" width="100px;" alt="Muhammad Saad "/><br /><sub><b>Muhammad Saad </b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=saadjutt01" title="Code">💻</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3Asaadjutt01" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/sasjs/adapter/commits?author=saadjutt01" title="Tests">⚠️</a> <a href="#mentoring-saadjutt01" title="Mentoring">🧑‍🏫</a> <a href="#infra-saadjutt01" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/sabhas"><img src="https://avatars.githubusercontent.com/u/82647447?v=4?s=100" width="100px;" alt="Sabir Hassan"/><br /><sub><b>Sabir Hassan</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=sabhas" title="Code">💻</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3Asabhas" title="Reviewed Pull Requests">👀</a> <a href="https://github.com/sasjs/adapter/commits?author=sabhas" title="Tests">⚠️</a> <a href="#ideas-sabhas" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/VladislavParhomchik"><img src="https://avatars.githubusercontent.com/u/83717836?v=4?s=100" width="100px;" alt="VladislavParhomchik"/><br /><sub><b>VladislavParhomchik</b></sub></a><br /><a href="https://github.com/sasjs/adapter/commits?author=VladislavParhomchik" title="Tests">⚠️</a> <a href="https://github.com/sasjs/adapter/pulls?q=is%3Apr+reviewed-by%3AVladislavParhomchik" title="Reviewed Pull Requests">👀</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="http://rudvfaden.github.io/"><img src="https://avatars.githubusercontent.com/u/2445577?v=4?s=100" width="100px;" alt="Rud Faden"/><br /><sub><b>Rud Faden</b></sub></a><br /><a href="#userTesting-rudvfaden" title="User Testing">📓</a> <a href="https://github.com/sasjs/adapter/commits?author=rudvfaden" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/saramartinelli1992"><img src="https://avatars.githubusercontent.com/u/100193908?v=4?s=100" width="100px;" alt="Sara"/><br /><sub><b>Sara</b></sub></a><br /><a href="#userTesting-saramartinelli1992" title="User Testing">📓</a> <a href="#platform-saramartinelli1992" title="Packaging/porting to new platform">📦</a></td>
</tr>
</tbody>
</table>
<!-- markdownlint-restore -->

View File

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

View File

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

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

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

Binary file not shown.

View File

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

View File

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

23316
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1 +0,0 @@
SKIP_PREFLIGHT_CHECK=true

View File

@@ -1,23 +1,29 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# sasjs
sasjsbuild
sasjsresults

View File

@@ -1,6 +0,0 @@
{
"trailingComma": "none",
"tabWidth": 2,
"semi": false,
"singleQuote": true
}

19
sasjs-tests/.sasjslint Normal file
View File

@@ -0,0 +1,19 @@
{
"lineEndings": "off",
"noTrailingSpaces": true,
"noEncodedPasswords": true,
"hasDoxygenHeader": true,
"noSpacesInFileNames": true,
"lowerCaseFileNames": true,
"maxLineLength": 80,
"maxHeaderLineLength": 80,
"maxDataLineLength": 80,
"noTabIndentation": true,
"indentationMultiple": 2,
"hasMacroNameInMend": true,
"noNestedMacros": true,
"hasMacroParentheses": true,
"strictMacroDefinition": true,
"noGremlins": true,
"defaultHeader": "/**{lineEnding} @file{lineEnding} @brief <Your brief here>{lineEnding} <h4> SAS Macros </h4>{lineEnding}**/"
}

View File

@@ -1,6 +1,8 @@
# SASjs Tests
`sasjs-tests` is a test suite for the SASjs adapter.
It is a React app bootstrapped using [Create React App](https://github.com/facebook/create-react-app) and [@sasjs/test-framework](https://github.com/sasjs/test-framework).
Browser-based integration testing for [@sasjs/adapter](https://github.com/sasjs/adapter) using TypeScript, Custom Elements, and zero dependencies.
When developing on `@sasjs/adapter`, it's good practice to run the test suite against your changed version of the adapter to ensure that existing functionality has not been impacted.
@@ -20,11 +22,70 @@ There are three prerequisites to be able to run the tests:
2. `sasjs-tests` deployed to your SAS server.
3. The required SAS services created on the same server.
### 1. Configuring the SASjs adapter
### Configuring the SASjs adapter
There is a `config.json` file in the `/public` folder which specifies the configuration for the SASjs adapter. You can set the values within the `sasjsConfig` property in this file to match your SAS server configuration.
### 2. Deploying to your SAS server
## Test Suites
Tests are defined in `src/testSuites/`:
- **Basic.ts** - Login, config, session management, debug mode
- **RequestData.ts** - Data serialization (sendArr, sendObj) with various types
- **FileUpload.ts** - File upload functionality (VIYA only)
- **Compute.ts** - Compute API, JES API, executeScript (VIYA only)
- **SasjsRequests.ts** - WORK tables, log capture
- **SpecialCases.ts** - Edge cases (currently disabled)
Each test suite follows this pattern:
```typescript
export const myTests = (adapter: SASjs): TestSuite => ({
name: 'My Test Suite',
tests: [
{
title: 'Should do something',
description: 'Description of what this tests',
test: async () => {
// Test logic - return a value
return adapter.request('service', data)
},
assertion: (response) => {
// Assertion - return true/false
return response.success === true
}
}
],
beforeAll: async () => {
// Optional: runs once before all tests
},
afterAll: async () => {
// Optional: runs once after all tests
}
})
```
### Shadow DOM Access
Cypress accesses Shadow DOM using a custom command:
```javascript
cy.get('login-form').shadow().find('input#username').type('user')
```
The `shadow()` command is defined in `cypress/support/commands.js`.
## Deployment
### Build for Production
```bash
npm run build
```
This creates a `dist/` folder ready for deployment.
### Deploy to SAS Server
There is a `deploy` NPM script provided in the `sasjs-tests` project's `package.json`.
@@ -42,21 +103,26 @@ SSH_ACCOUNT=me@my-sas-server.com DEPLOY_PATH=/var/www/html/my-folder/sasjs-tests
```
If you are on `WINDOWS`, you will first need to install one dependency:
```bash
npm i -g copyfiles
```
and then run to build:
```bash
npm run update:adapter && npm run build
```
when it finishes run to deploy:
```bash
scp -rp ./build/* me@my-sas-server.com:/var/www/html/my-folder/sasjs-tests
```
If you'd like to deploy just `sasjs-tests` without changing the adapter version, you can use the `deploy:tests` script, while also setting the same environment variables as above.
## 3. Creating the required SAS services
#### Creating the required SAS services
The below services need to be created on your SAS server, at the location specified as the `appLoc` in the SASjs configuration.
@@ -65,6 +131,7 @@ The code below will work on ALL SAS platforms (Viya, SAS 9 EBI, SASjs Server).
```sas
filename mc url "https://raw.githubusercontent.com/sasjs/core/main/all.sas";
%inc mc;
%let apploc=/Public/app/adapter-tests;
filename ft15f001 temp lrecl=1000;
parmcards4;
%webout(FETCH)
@@ -74,13 +141,13 @@ parmcards4;
%let table=%scan(&sasjs_tables,&i);
%webout(OBJ,&table,missing=STRING,showmeta=YES)
%end;
%else %do i=1 %to &_webin_file_count;
%webout(OBJ,&&_webin_name&i,missing=STRING,showmeta=YES)
%else %do i=1 %to &_webin_file_count;
%webout(OBJ,&&_webin_name&i,missing=STRING,showmeta=YES)
%end;
%mend; %x()
%webout(CLOSE)
;;;;
%mx_createwebservice(path=/Public/app/common,name=sendObj)
%mx_createwebservice(path=&apploc/services/common,name=sendObj)
parmcards4;
%webout(FETCH)
%webout(OPEN)
@@ -89,29 +156,29 @@ parmcards4;
%let table=%scan(&sasjs_tables,&i);
%webout(ARR,&table,missing=STRING,showmeta=YES)
%end;
%else %do i=1 %to &_webin_file_count;
%webout(ARR,&&_webin_name&i,missing=STRING,showmeta=YES)
%else %do i=1 %to &_webin_file_count;
%webout(ARR,&&_webin_name&i,missing=STRING,showmeta=YES)
%end;
%mend; %x()
%webout(CLOSE)
;;;;
%mx_createwebservice(path=/Public/app/common,name=sendArr)
%mx_createwebservice(path=&apploc/services/common,name=sendArr)
parmcards4;
data work.macvars;
set sashelp.vmacro;
run;
%webout(OPEN)
%webout(OBJ,macvars)
%webout(OBJ,macvars)
%webout(CLOSE)
;;;;
%mx_createwebservice(path=/Public/app/common,name=sendMacVars)
%mx_createwebservice(path=&apploc/services/common,name=sendMacVars)
parmcards4;
If you can keep your head when all about you
Are losing theirs and blaming it on you,
If you can trust yourself when all men doubt you,
But make allowance for their doubting too;
;;;;
%mx_createwebservice(path=/Public/app/common,name=makeErr)
%mx_createwebservice(path=&apploc/services/common,name=makeErr)
parmcards4;
%webout(OPEN)
data _null_;
@@ -120,28 +187,69 @@ data _null_;
run;
%webout(CLOSE)
;;;;
%mx_createwebservice(path=/Public/app/common,name=invalidJSON)
%mx_createwebservice(path=&apploc/services/common,name=invalidJSON)
```
You should now be able to access the tests in your browser at the deployed path on your server.
## Creating new tests
#### Using SASjs CLI
The `src/testSuites` folder contains all the test suites currently available.
Each suite contains a set of specs, each of which looks like this:
```javascript
{
title: "Your test title",
description: "A slightly more detailed description",
test: async () => {
// typically makes a request using the adapter and returns a promise
},
assertion: (response: any) =>
// receives the response when the test promise resolves, runs an assertion and returns a boolean
}
```bash
sasjs deploy -t <target>
```
A test suite is an array of such objects, along with a `name` property.
### Matrix Notifications
You can add your test to one of the existing suites if suitable, or create a new file that specifies a new test suite.
The `sasjs-cypress-run.sh` script sends Matrix chat notifications on test failure:
```bash
./sasjs-cypress-run.sh $MATRIX_ACCESS_TOKEN $PR_NUMBER
```
Notification format:
```
Automated sasjs-tests failed on the @sasjs/adapter PR: <PR_NUMBER>
```
## SAS Service Setup
The tests require SAS services to be deployed at the `appLoc` specified in `config.json`.
Services expected:
- `common/sendArr` - Echo back array data
- `common/sendObj` - Echo back object data
- (Additional services per test suite)
Deploy these services using [SASjs CLI](https://cli.sasjs.io) or manually.
## UI Components (Custom Elements)
- `<login-form>` - SAS authentication
- `<tests-view>` - Test orchestrator with run controls
- `<test-suite>` - Test suite display with stats
- `<test-card>` - Individual test with status (pending/running/passed/failed)
All components use Shadow DOM for style encapsulation and expose custom events for interactivity.
### Adding New Test Suites
1. Create file in `src/testSuites/MyNewTests.ts`
2. Export function returning TestSuite
3. Import in `src/index.ts`
4. Add to `testSuites` array in `showTests()` function
### Modifying UI Components
Components are in `src/components/`:
- Edit `.ts` file
- Styles are in corresponding `.css` file
- Rebuild with `npm run build`
## Links
- [@sasjs/adapter](https://adapter.sasjs.io)
- [SASjs Documentation](https://sasjs.io)
- [SASjs CLI](https://cli.sasjs.io)

43
sasjs-tests/index.css Normal file
View File

@@ -0,0 +1,43 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
}
#app {
min-height: 100vh;
}
.app__error {
max-width: 800px;
margin: 50px auto;
padding: 30px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
h1 {
color: #e74c3c;
margin-bottom: 15px;
}
p {
margin-bottom: 15px;
}
pre {
background: #2c3e50;
color: #ecf0f1;
padding: 15px;
border-radius: 4px;
overflow-x: auto;
}
}

14
sasjs-tests/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="./src/images/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SASjs tests</title>
<link rel="stylesheet" href="./index.css">
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +1,25 @@
{
"name": "@sasjs/tests",
"version": "1.0.0",
"homepage": ".",
"name": "sasjs-tests-new",
"private": true,
"dependencies": {
"@sasjs/adapter": "file:../build/sasjs-adapter-5.0.0.tgz",
"@sasjs/test-framework": "^1.5.6",
"@types/jest": "^26.0.20",
"@types/node": "^14.14.41",
"@types/react": "^17.0.1",
"@types/react-dom": "^17.0.0",
"@types/react-router-dom": "^5.1.7",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-router-dom": "^5.2.0",
"react-scripts": "^5.0.1",
"typescript": "^4.1.3"
},
"version": "0.0.0",
"type": "module",
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"update:adapter": "cd .. && npm run package:lib && cd sasjs-tests && npm i ../build/sasjs-adapter-5.0.0.tgz --legacy-peer-deps",
"deploy:tests": "rsync -avhe ssh ./build/* --delete $SSH_ACCOUNT:$DEPLOY_PATH || npm run deploy:tests-win",
"deploy:tests-win": "scp %DEPLOY_PATH% ./build/*",
"start": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"update:adapter": "cd .. && npm run package:lib && cd sasjs-tests && npm i ../build/sasjs-adapter-5.0.0.tgz",
"deploy:tests": "rsync -avhe ssh ./dist/* --delete $SSH_ACCOUNT:$DEPLOY_PATH || npm run deploy:tests-win",
"deploy:tests-win": "scp %DEPLOY_PATH% ./dist/*",
"deploy": "npm run update:adapter && npm run build && npm run deploy:tests"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"node-sass": "^7.0.1"
"typescript": "~5.9.3",
"vite": "npm:rolldown-vite@7.2.2"
},
"overrides": {
"vite": "npm:rolldown-vite@7.2.2"
},
"dependencies": {
"@sasjs/adapter": "^4.14.0"
}
}

View File

@@ -2,11 +2,12 @@
"userName": "",
"password": "",
"sasJsConfig": {
"loginMechanism": "Redirected",
"serverUrl": "",
"appLoc": "/Public/app",
"appLoc": "/Public/app/adapter-tests/services",
"serverType": "SASVIYA",
"debug": false,
"contextName": "sasjs adapter compute context",
"contextName": "SAS Job Execution compute context",
"useComputeApi": true
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -1,40 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Tests for SASjs" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>SASjs Tests</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -1,25 +0,0 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

View File

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

View File

@@ -0,0 +1,13 @@
/**
@file
@brief Makes an invalid JSON file
<h4> SAS Macros </h4>
**/
%webout(OPEN)
data _null_;
file _webout;
put ' the discovery channel ';
run;
%webout(CLOSE)

View File

@@ -0,0 +1,11 @@
/**
@file
@brief Makes an error
<h4> SAS Macros </h4>
**/
If you can keep your head when all about you
Are losing theirs and blaming it on you,
If you can trust yourself when all men doubt you,
But make allowance for their doubting too;

View File

@@ -0,0 +1,21 @@
/**
@file
@brief Returns JSON in Array format
<h4> SAS Macros </h4>
**/
%webout(FETCH)
%webout(OPEN)
%macro x();
%if %symexist(sasjs_tables) %then
%do i=1 %to %sysfunc(countw(&sasjs_tables));
%let table=%scan(&sasjs_tables,&i);
%webout(ARR,&table,missing=STRING,showmeta=YES)
%end;
%else %do i=1 %to &_webin_file_count;
%webout(ARR,&&_webin_name&i,missing=STRING,showmeta=YES)
%end;
%mend x;
%x()
%webout(CLOSE)

View File

@@ -0,0 +1,13 @@
/**
@file
@brief Returns Macro Variables
<h4> SAS Macros </h4>
**/
data work.macvars;
set sashelp.vmacro;
run;
%webout(OPEN)
%webout(OBJ,macvars)
%webout(CLOSE)

View File

@@ -0,0 +1,21 @@
/**
@file
@brief Returns JSON in Object format
<h4> SAS Macros </h4>
**/
%webout(FETCH)
%webout(OPEN)
%macro x();
%if %symexist(sasjs_tables) %then
%do i=1 %to %sysfunc(countw(&sasjs_tables));
%let table=%scan(&sasjs_tables,&i);
%webout(OBJ,&table,missing=STRING,showmeta=YES)
%end;
%else %do i=1 %to &_webin_file_count;
%webout(OBJ,&&_webin_name&i,missing=STRING,showmeta=YES)
%end;
%mend x;
%x()
%webout(CLOSE)

View File

@@ -0,0 +1,40 @@
ALPHABETICAL_INDEX = NO
ENABLE_PREPROCESSING = NO
EXTENSION_MAPPING = sas=Java ddl=Java
EXTRACT_LOCAL_CLASSES = NO
FILE_PATTERNS = *.sas \
*.ddl \
*.dox
GENERATE_LATEX = NO
GENERATE_TREEVIEW = YES
HIDE_FRIEND_COMPOUNDS = YES
HIDE_IN_BODY_DOCS = YES
HIDE_SCOPE_NAMES = YES
HIDE_UNDOC_CLASSES = YES
HIDE_UNDOC_MEMBERS = YES
HTML_OUTPUT = $(DOXY_HTML_OUTPUT)
HTML_HEADER = $(HTML_HEADER)
HTML_EXTRA_FILES = $(HTML_EXTRA_FILES)
HTML_FOOTER = $(HTML_FOOTER)
HTML_EXTRA_STYLESHEET = $(HTML_EXTRA_STYLESHEET)
INHERIT_DOCS = NO
INLINE_INFO = NO
INPUT = $(DOXY_INPUT)
LAYOUT_FILE = $(LAYOUT_FILE)
USE_MDFILE_AS_MAINPAGE = README.md
MAX_INITIALIZER_LINES = 0
PROJECT_NAME = $(PROJECT_NAME)
PROJECT_LOGO = $(PROJECT_LOGO)
PROJECT_BRIEF = $(PROJECT_BRIEF)
RECURSIVE = YES
REPEAT_BRIEF = NO
SHOW_NAMESPACES = NO
SHOW_USED_FILES = NO
SOURCE_BROWSER = YES
SOURCE_TOOLTIPS = NO
STRICT_PROTO_MATCHING = YES
STRIP_CODE_COMMENTS = NO
SUBGROUPING = NO
TAB_SIZE = 2
VERBATIM_HEADERS = NO

View File

@@ -0,0 +1,112 @@
<doxygenlayout version="1.0">
<!-- Generated by doxygen 1.8.14 -->
<!-- Navigation index tabs for HTML output -->
<navindex>
<tab type="mainpage" visible="yes" title="Home"/>
<tab type="pages" visible="no" title="" intro=""/>
<tab type="modules" visible="no" title="" intro=""/>
<tab type="namespaces" visible="no" title="">
<tab type="namespacelist" visible="no" title="" intro=""/>
<tab type="namespacemembers" visible="no" title="" intro=""/>
</tab>
<tab type="classes" visible="no" title="">
<tab type="classlist" visible="no" title="" intro=""/>
<tab type="classindex" visible="no" title=""/>
<tab type="hierarchy" visible="no" title="" intro=""/>
<tab type="classmembers" visible="no" title="" intro=""/>
</tab>
<tab type="filelist" visible="yes" title="" intro="List of Files"/>
<tab type="examples" visible="no" title="" intro=""/>
<tab type="user" url="data_lineage.svg" title="Lineage"/>
</navindex>
<!-- Layout definition for a file page -->
<file>
<briefdescription visible="yes"/>
<includes visible="$SHOW_INCLUDE_FILES"/>
<includegraph visible="$INCLUDE_GRAPH"/>
<includedbygraph visible="$INCLUDED_BY_GRAPH"/>
<sourcelink visible="yes"/>
<memberdecl>
<classes visible="no" title=""/>
<namespaces visible="no" title=""/>
<constantgroups visible="no" title=""/>
<defines title=""/>
<typedefs title=""/>
<enums title=""/>
<functions title=""/>
<variables title=""/>
<membergroups visible="no"/>
</memberdecl>
<detaileddescription title=""/>
<memberdef>
<inlineclasses title=""/>
<defines title=""/>
<typedefs title=""/>
<enums title=""/>
<functions title=""/>
<variables title=""/>
</memberdef>
<authorsection/>
</file>
<!-- Layout definition for a group page -->
<group>
<briefdescription visible="no"/>
<groupgraph visible="$GROUP_GRAPHS"/>
<memberdecl>
<nestedgroups visible="no" title=""/>
<dirs visible="yes" title=""/>
<files visible="yes" title=""/>
<namespaces visible="no" title=""/>
<classes visible="no" title=""/>
<defines title=""/>
<typedefs title=""/>
<enums title=""/>
<enumvalues title=""/>
<functions title=""/>
<variables title=""/>
<signals title=""/>
<publicslots title=""/>
<protectedslots title=""/>
<privateslots title=""/>
<events title=""/>
<properties title=""/>
<friends title=""/>
<membergroups visible="yes"/>
</memberdecl>
<detaileddescription title=""/>
<memberdef>
<pagedocs/>
<inlineclasses title=""/>
<defines title=""/>
<typedefs title=""/>
<enums title=""/>
<enumvalues title=""/>
<functions title=""/>
<variables title=""/>
<signals title=""/>
<publicslots title=""/>
<protectedslots title=""/>
<privateslots title=""/>
<events title=""/>
<properties title=""/>
<friends title=""/>
</memberdef>
<authorsection visible="yes"/>
</group>
<!-- Layout definition for a directory page -->
<directory>
<briefdescription visible="yes"/>
<detaileddescription visible="yes" title=""/>
<directorygraph visible="yes"/>
<memberdecl>
<dirs visible="yes"/>
<files visible="yes"/>
</memberdecl>
</directory>
</doxygenlayout>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@@ -0,0 +1,33 @@
<!-- HTML footer for doxygen 1.8.17-->
<!-- start footer part -->
<!--BEGIN GENERATE_TREEVIEW-->
<div id="nav-path" class="navpath">
<!-- id is needed for treeview function! -->
<ul>
$navpath
<li class="footer">
$generatedby
<a href="https://www.doxygen.org/index.html">
<img class="footer" src="$relpath^doxygen.svg" alt="doxygen"
/></a>
$doxygenversion
</li>
<li>
<i> For more information visit the </i>
<a href="https://cli.sasjs.io">SASjs cli</a> documentation.
</li>
</ul>
</div>
<!--END GENERATE_TREEVIEW-->
<!--BEGIN !GENERATE_TREEVIEW-->
<hr class="footer" />
<address class="footer">
<small>
$generatedby &#160;<a href="http://www.doxygen.org/index.html">
<img class="footer" src="$relpath^doxygen.svg" alt="doxygen" />
</a>
$doxygenversion
</small>
</address>
<!--END !GENERATE_TREEVIEW-->

View File

@@ -0,0 +1,57 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "https://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!-- HTML header for doxygen 1.8.17-->
<html xmlns="https://www.w3.org/1999/xhtml" lang="en">
<head>
<meta http-equiv="Content-Type" content="text/xhtml;charset=UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=9" />
<meta name="generator" content="Doxygen $doxygenversion" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!--BEGIN PROJECT_NAME-->
<title>$projectname: $title</title>
<meta name="description" content="$projectbrief" />
<!--END PROJECT_NAME-->
<!--BEGIN !PROJECT_NAME-->
<title>$title</title>
<!--END !PROJECT_NAME-->
<link href="$relpath^tabs.css" rel="stylesheet" type="text/css" />
<script type="text/javascript" src="$relpath^jquery.js"></script>
<script type="text/javascript" src="$relpath^dynsections.js"></script>
$treeview $search $mathjax
<link href="$relpath^$stylesheet" rel="stylesheet" type="text/css" />
<link rel="shortcut icon" href="$relpath^favicon.ico" type="image/x-icon" />
$extrastylesheet
</head>
<body>
<div id="top">
<!-- do not remove this div, it is closed by doxygen! -->
<!--BEGIN TITLEAREA-->
<div id="titlearea">
<table cellspacing="0" cellpadding="0">
<tbody>
<tr style="height: 56px">
<!--BEGIN PROJECT_LOGO-->
<td id="projectlogo">
<a href="$relpath^"
><img alt="Logo" src="$relpath^$projectlogo"
/></a>
</td>
<!--END PROJECT_LOGO-->
<td id="projectalign" style="padding-left: 0.5em">
<div id="projectname">$projectname</div>
<div id="projectbrief">$projectbrief</div>
</td>
<!--BEGIN DISABLE_INDEX-->
<!--BEGIN SEARCHENGINE-->
<td>$searchbox</td>
<!--END SEARCHENGINE-->
<!--END DISABLE_INDEX-->
</tr>
</tbody>
</table>
</div>
<!--END TITLEAREA-->
<!-- end header part -->
</div>
</body>
</html>

View File

@@ -0,0 +1,4 @@
#projectlogo img {
border: 0px none;
max-height: 70px;
}

View File

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

View File

@@ -1,42 +0,0 @@
import React, { ReactElement, useState, useContext, useEffect } from 'react'
import { TestSuiteRunner, TestSuite, AppContext } from '@sasjs/test-framework'
import { basicTests } from './testSuites/Basic'
import { sendArrTests, sendObjTests } from './testSuites/RequestData'
import { specialCaseTests } from './testSuites/SpecialCases'
import { sasjsRequestTests } from './testSuites/SasjsRequests'
import '@sasjs/test-framework/dist/index.css'
import { computeTests } from './testSuites/Compute'
import { fileUploadTests } from './testSuites/FileUpload'
const App = (): ReactElement<{}> => {
const { adapter, config } = useContext(AppContext)
const [testSuites, setTestSuites] = useState<TestSuite[]>([])
const appLoc = config.sasJsConfig.appLoc
useEffect(() => {
if (adapter) {
const testSuites = [
basicTests(adapter, config.userName, config.password),
sendArrTests(adapter, appLoc),
sendObjTests(adapter),
specialCaseTests(adapter),
sasjsRequestTests(adapter),
fileUploadTests(adapter)
]
if (adapter.getSasjsConfig().serverType === 'SASVIYA') {
testSuites.push(computeTests(adapter, appLoc))
}
setTestSuites(testSuites)
}
}, [adapter, config, appLoc])
return (
<div className="app">
{adapter && testSuites && <TestSuiteRunner testSuites={testSuites} />}
</div>
)
}
export default App

View File

@@ -1,34 +0,0 @@
.login-container {
display: flex;
flex-direction: column;
height: 100vh;
justify-content: center;
align-items: center;
img {
max-width: 30%;
}
form {
width: 33%;
margin-top: 3%;
display: flex;
flex-direction: column;
.row {
input {
font-size: 0.9em;
}
label {
font-weight: bold;
font-size: 0.9em;
margin-bottom: 4px;
}
}
.submit-button {
margin-top: 16px;
}
}
}

View File

@@ -1,54 +0,0 @@
import React, { ReactElement, useState, useCallback, useContext } from 'react'
import './Login.scss'
import { AppContext } from '@sasjs/test-framework'
import { Redirect } from 'react-router-dom'
const Login = (): ReactElement<{}> => {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const appContext = useContext(AppContext)
const handleSubmit = useCallback(
(e: any) => {
e.preventDefault()
appContext.adapter.logIn(username, password).then((res) => {
appContext.setIsLoggedIn(res.isLoggedIn)
})
},
[username, password, appContext]
)
return !appContext.isLoggedIn ? (
<div className="login-container">
<img src="sasjs-logo.png" alt="SASjs Logo" />
<form onSubmit={handleSubmit}>
<div className="row">
<label>User Name</label>
<input
placeholder="User Name"
value={username}
required
onChange={(e: any) => setUsername(e.target.value)}
/>
</div>
<div className="row">
<label>Password</label>
<input
placeholder="Password"
type="password"
value={password}
required
onChange={(e: any) => setPassword(e.target.value)}
/>
</div>
<button type="submit" className="submit-button">
Log In
</button>
</form>
</div>
) : (
<Redirect to="/" />
)
}
export default Login

View File

@@ -1,23 +0,0 @@
import React, { ReactElement, useContext, FunctionComponent } from 'react'
import { Redirect, Route } from 'react-router-dom'
import { AppContext } from '@sasjs/test-framework'
interface PrivateRouteProps {
component: FunctionComponent
exact?: boolean
path: string
}
const PrivateRoute = (
props: PrivateRouteProps
): ReactElement<PrivateRouteProps> => {
const { component, path, exact } = props
const appContext = useContext(AppContext)
return appContext.isLoggedIn ? (
<Route component={component} path={path} exact={exact} />
) : (
<Redirect to="/login" />
)
}
export default PrivateRoute

View File

@@ -0,0 +1,65 @@
:host {
display: block;
max-width: 400px;
margin: 100px auto;
padding: 40px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
h1 {
text-align: center;
margin-bottom: 30px;
color: #2c3e50;
}
form {
display: flex;
flex-direction: column;
gap: 15px;
}
label {
font-weight: 600;
margin-bottom: 5px;
}
input {
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
&:focus {
outline: none;
border-color: #3498db;
}
}
button {
padding: 12px;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
&:hover:not(:disabled) {
background: #2980b9;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.error {
color: #e74c3c;
font-size: 14px;
min-height: 20px;
}

View File

@@ -0,0 +1,93 @@
import { appContext } from '../core/AppContext'
import styles from './LoginForm.css?inline'
export class LoginForm extends HTMLElement {
private static styleSheet = new CSSStyleSheet()
private shadow: ShadowRoot
static {
this.styleSheet.replaceSync(styles)
}
constructor() {
super()
this.shadow = this.attachShadow({ mode: 'open' })
this.shadow.adoptedStyleSheets = [LoginForm.styleSheet]
}
connectedCallback() {
this.render()
this.attachEventListeners()
}
render() {
this.shadow.innerHTML = `
<h1>SASjs Tests</h1>
<form id="login-form">
<label for="username">Username</label>
<input type="text" id="username" name="username" placeholder="Enter username" required />
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="Enter password" required />
<button type="submit" id="submit-btn">Log In</button>
<div class="error" id="error"></div>
</form>
`
}
attachEventListeners() {
const form = this.shadow.getElementById('login-form') as HTMLFormElement
form.addEventListener('submit', async (e) => {
e.preventDefault()
await this.handleLogin()
})
}
async handleLogin() {
const username = (
this.shadow.getElementById('username') as HTMLInputElement
).value
const password = (
this.shadow.getElementById('password') as HTMLInputElement
).value
const submitBtn = this.shadow.getElementById(
'submit-btn'
) as HTMLButtonElement
const errorDiv = this.shadow.getElementById('error') as HTMLDivElement
errorDiv.textContent = ''
submitBtn.textContent = 'Logging in...'
submitBtn.disabled = true
try {
const adapter = appContext.getAdapter()
if (!adapter) {
throw new Error('Adapter not initialized')
}
const response = await adapter.logIn(username, password)
if (response && response.isLoggedIn) {
appContext.setIsLoggedIn(true)
this.dispatchEvent(
new CustomEvent('login-success', {
bubbles: true,
composed: true
})
)
} else {
throw new Error('Login failed')
}
} catch (error: unknown) {
errorDiv.textContent =
error instanceof Error
? error.message
: 'Login failed. Please try again.'
submitBtn.textContent = 'Log In'
submitBtn.disabled = false
}
}
}
customElements.define('login-form', LoginForm)

View File

@@ -0,0 +1,193 @@
:host {
display: contents;
}
button {
padding: 8px 16px;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-left: 10px;
&:hover {
background: #2980b9;
}
}
dialog {
max-width: 95vw;
max-height: 95vh;
width: 1400px;
padding: 0;
border: none;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
background: #2c3e50;
color: #ecf0f1;
&::backdrop {
background: rgba(0, 0, 0, 0.5);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #34495e;
h2 {
margin: 0;
color: #ecf0f1;
font-size: 20px;
}
}
.close-btn {
background: transparent;
color: #ecf0f1;
font-size: 24px;
padding: 0;
width: 32px;
height: 32px;
cursor: pointer;
border-radius: 4px;
margin: 0;
&:hover {
background: #34495e;
}
}
.modal-content {
padding: 20px;
overflow-y: auto;
max-height: calc(95vh - 80px);
}
.debug-message {
text-align: center;
padding: 60px 20px;
.icon {
font-size: 64px;
margin-bottom: 20px;
}
h3 {
margin: 10px 0;
color: #ecf0f1;
}
span {
color: #95a5a6;
}
}
.requests-list {
display: flex;
flex-direction: column;
gap: 10px;
}
details {
border: 1px solid #34495e;
border-radius: 4px;
background: #34495e;
&[open] summary::before {
transform: rotate(90deg);
}
}
summary {
padding: 15px;
cursor: pointer;
user-select: none;
list-style: none;
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.2s;
&::-webkit-details-marker {
display: none;
}
&::before {
content: '▶';
display: inline-block;
margin-right: 10px;
transition: transform 0.2s;
}
&:hover {
background: #3d5266;
}
}
.request-timestamp {
color: #95a5a6;
font-size: 13px;
}
.request-content {
padding: 0 15px 15px 15px;
}
.tabs {
display: flex;
gap: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #2c3e50;
margin-bottom: 10px;
}
.tab-btn {
padding: 8px 16px;
background: transparent;
color: #95a5a6;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 14px;
margin: 0;
border-radius: 0;
&:hover {
color: #ecf0f1;
background: transparent;
}
&.active {
color: #3498db;
border-bottom-color: #3498db;
background: transparent;
}
}
.tab-pane {
display: none;
&.active {
display: block;
}
}
pre {
background: #1e2832;
padding: 15px;
border-radius: 4px;
overflow-x: auto;
margin: 0;
color: #ecf0f1;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
}

View File

@@ -0,0 +1,180 @@
import type { SASjsRequest } from '@sasjs/adapter'
import { appContext } from '../core/AppContext'
import styles from './RequestsModal.css?inline'
export class RequestsModal extends HTMLElement {
private static styleSheet = new CSSStyleSheet()
private shadow: ShadowRoot
private dialog: HTMLDialogElement | null = null
static {
this.styleSheet.replaceSync(styles)
}
constructor() {
super()
this.shadow = this.attachShadow({ mode: 'open' })
this.shadow.adoptedStyleSheets = [RequestsModal.styleSheet]
}
connectedCallback() {
this.render()
this.attachEventListeners()
}
render() {
this.shadow.innerHTML = `
<dialog id="requests-dialog">
<div class="modal-header">
<h2 id="modal-title"></h2>
<button class="close-btn" id="close-btn">×</button>
</div>
<div class="modal-content" id="modal-content"></div>
</dialog>
`
}
attachEventListeners() {
const dialog = this.shadow.getElementById(
'requests-dialog'
) as HTMLDialogElement
const closeBtn = this.shadow.getElementById('close-btn')
this.dialog = dialog
closeBtn?.addEventListener('click', () => this.closeModal())
dialog?.addEventListener('click', (e) => {
if (e.target === dialog) {
this.closeModal()
}
})
}
openModal() {
if (!this.dialog) return
const adapter = appContext.getAdapter()
if (!adapter) return
const requests = adapter.getSasRequests()
const title = this.shadow.getElementById('modal-title')
const content = this.shadow.getElementById('modal-content')
if (!title || !content) return
title.textContent = 'Last 20 requests'
if (!requests || requests.length === 0) {
content.innerHTML = `
<div class="debug-message">
<div class="icon">🐛</div>
<h3>There are no requests available.</h3>
<span>Please run a test and check again.</span>
</div>
`
} else {
content.innerHTML = `
<div class="requests-list">
${requests
.map((request, index) => this.renderRequest(request, index))
.join('')}
</div>
`
this.attachTabListeners()
}
this.dialog.showModal()
}
closeModal() {
this.dialog?.close()
}
renderRequest(request: SASjsRequest, index: number): string {
const timestamp = new Date(request.timestamp)
const formattedDate = timestamp.toLocaleString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric'
})
const timeAgo = this.getTimeAgo(timestamp)
return `
<details data-index="${index}">
<summary>
<span>${request.serviceLink}</span>
<span class="request-timestamp">${formattedDate} (${timeAgo})</span>
</summary>
<div class="request-content">
<div class="tabs">
<button class="tab-btn active" data-tab="log-${index}">Log</button>
<button class="tab-btn" data-tab="source-${index}">Source Code</button>
<button class="tab-btn" data-tab="generated-${index}">Generated Code</button>
</div>
<div class="tab-panes">
<div class="tab-pane active" id="log-${index}">
<pre>${this.decodeHtml(request.logFile)}</pre>
</div>
<div class="tab-pane" id="source-${index}">
<pre>${this.decodeHtml(request.sourceCode)}</pre>
</div>
<div class="tab-pane" id="generated-${index}">
<pre>${this.decodeHtml(request.generatedCode)}</pre>
</div>
</div>
</div>
</details>
`
}
attachTabListeners() {
const tabBtns = this.shadow.querySelectorAll('.tab-btn')
tabBtns.forEach((btn) => {
btn.addEventListener('click', (e) => {
const target = e.currentTarget as HTMLElement
const tabId = target.getAttribute('data-tab')
if (!tabId) return
const container = target.closest('.request-content')
if (!container) return
container
.querySelectorAll('.tab-btn')
.forEach((b) => b.classList.remove('active'))
container
.querySelectorAll('.tab-pane')
.forEach((p) => p.classList.remove('active'))
target.classList.add('active')
const pane = container.querySelector(`#${tabId}`)
pane?.classList.add('active')
})
})
}
decodeHtml(encodedString: string): string {
const tempElement = document.createElement('textarea')
tempElement.innerHTML = encodedString
return tempElement.value
}
getTimeAgo(date: Date): string {
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000)
if (seconds < 60) return `${seconds} seconds ago`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`
const days = Math.floor(hours / 24)
return `${days} day${days !== 1 ? 's' : ''} ago`
}
}
customElements.define('requests-modal', RequestsModal)

View File

@@ -0,0 +1,126 @@
:host {
display: block;
border: 2px solid #ecf0f1;
border-radius: 6px;
padding: 15px;
background: #fafafa;
transition: border-color 0.2s;
&[status='passed'] {
border-color: #27ae60;
background: #f0fff4;
}
&[status='failed'] {
border-color: #e74c3c;
background: #fff5f5;
}
&[status='running'] {
border-color: #f39c12;
background: #fffbf0;
}
&[status='pending'] {
border-color: #95a5a6;
background: #f8f9fa;
}
}
.header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.status-icon {
font-size: 20px;
font-weight: bold;
&.passed {
color: #27ae60;
}
&.failed {
color: #e74c3c;
}
&.running {
color: #f39c12;
animation: spin 1s linear infinite;
}
&.pending {
color: #95a5a6;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
h3 {
font-size: 16px;
color: #2c3e50;
margin: 0;
}
.description {
color: #7f8c8d;
font-size: 14px;
margin-bottom: 10px;
}
.details {
margin-top: 10px;
font-size: 14px;
}
.time {
color: #7f8c8d;
margin-bottom: 5px;
}
.error {
margin-top: 10px;
strong {
color: #e74c3c;
display: block;
margin-bottom: 5px;
}
pre {
background: #2c3e50;
color: #ecf0f1;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
font-size: 12px;
line-height: 1.4;
margin: 0;
}
}
button {
margin-top: 10px;
padding: 6px 12px;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
&:hover:not(:disabled) {
background: #2980b9;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}

View File

@@ -0,0 +1,113 @@
import type { CompletedTest } from '../core/TestRunner'
import type { TestStatus } from '../types'
import styles from './TestCard.css?inline'
export class TestCard extends HTMLElement {
private static styleSheet = new CSSStyleSheet()
private shadow: ShadowRoot
private _testData: CompletedTest | null = null
static {
this.styleSheet.replaceSync(styles)
}
static get observedAttributes() {
return ['status', 'execution-time']
}
constructor() {
super()
this.shadow = this.attachShadow({ mode: 'open' })
this.shadow.adoptedStyleSheets = [TestCard.styleSheet]
}
connectedCallback() {
this.render()
}
attributeChangedCallback(_name: string, oldValue: string, newValue: string) {
if (oldValue !== newValue) {
this.render()
}
}
set testData(data: CompletedTest) {
this._testData = data
this.setAttribute('status', data.status)
if (data.executionTime) {
this.setAttribute('execution-time', data.executionTime.toString())
}
this.render()
}
get testData(): CompletedTest | null {
return this._testData
}
render() {
if (!this._testData) return
const { test, status, executionTime, error } = this._testData
const statusIcon = this.getStatusIcon(status)
this.shadow.innerHTML = `
<div class="header">
<span class="status-icon ${status}">${statusIcon}</span>
<h3>${test.title}</h3>
</div>
<p class="description">${test.description}</p>
${
executionTime
? `
<div class="details">
<div class="time">Time: ${executionTime.toFixed(3)}s</div>
</div>
`
: ''
}
${
error
? `
<div class="error">
<strong>Error:</strong>
<pre>${(error as Error).message || String(error)}</pre>
</div>
`
: ''
}
<button id="rerun-btn">Rerun</button>
`
const rerunBtn = this.shadow.getElementById('rerun-btn')
if (rerunBtn) {
rerunBtn.addEventListener('click', () => {
this.dispatchEvent(
new CustomEvent('rerun', {
bubbles: true,
composed: true
})
)
})
}
}
getStatusIcon(status: TestStatus): string {
switch (status) {
case 'passed':
return '✓'
case 'failed':
return '✗'
case 'running':
return '⟳'
case 'pending':
return '○'
default:
return '?'
}
}
}
customElements.define('test-card', TestCard)

View File

@@ -0,0 +1,34 @@
:host {
display: block;
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #ecf0f1;
}
h2 {
color: #2c3e50;
font-size: 20px;
margin: 0;
}
.stats {
font-size: 14px;
color: #7f8c8d;
}
.tests {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 15px;
}

View File

@@ -0,0 +1,117 @@
import type { CompletedTestSuite } from '../core/TestRunner'
import { TestCard } from './TestCard'
import styles from './TestSuite.css?inline'
export class TestSuiteElement extends HTMLElement {
private static styleSheet = new CSSStyleSheet()
private shadow: ShadowRoot
private _suiteData: CompletedTestSuite | null = null
private _suiteIndex: number = 0
static {
this.styleSheet.replaceSync(styles)
}
constructor() {
super()
this.shadow = this.attachShadow({ mode: 'open' })
this.shadow.adoptedStyleSheets = [TestSuiteElement.styleSheet]
}
connectedCallback() {
this.render()
}
set suiteData(data: CompletedTestSuite) {
this._suiteData = data
this.render()
}
get suiteData(): CompletedTestSuite | null {
return this._suiteData
}
set suiteIndex(index: number) {
this._suiteIndex = index
}
get suiteIndex(): number {
return this._suiteIndex
}
updateTest(testIndex: number, testData: any) {
if (!this._suiteData) return
// Update the data
this._suiteData.completedTests[testIndex] = testData
// Update stats
this.updateStats()
// Update the specific test card
const testsContainer = this.shadow.getElementById('tests-container')
if (testsContainer) {
const cards = testsContainer.querySelectorAll('test-card')
const card = cards[testIndex] as TestCard
if (card) {
card.testData = testData
}
}
}
updateStats() {
if (!this._suiteData) return
const { completedTests } = this._suiteData
const passed = completedTests.filter((t) => t.status === 'passed').length
const failed = completedTests.filter((t) => t.status === 'failed').length
const running = completedTests.filter((t) => t.status === 'running').length
const statsEl = this.shadow.querySelector('.stats')
if (statsEl) {
statsEl.textContent = `Passed: ${passed} | Failed: ${failed} | Running: ${running}`
}
}
render() {
if (!this._suiteData) return
const { name, completedTests } = this._suiteData
const passed = completedTests.filter((t) => t.status === 'passed').length
const failed = completedTests.filter((t) => t.status === 'failed').length
const running = completedTests.filter((t) => t.status === 'running').length
this.shadow.innerHTML = `
<div class="header">
<h2>${name}</h2>
<div class="stats">Passed: ${passed} | Failed: ${failed} | Running: ${running}</div>
</div>
<div class="tests" id="tests-container"></div>
`
const testsContainer = this.shadow.getElementById('tests-container')
if (testsContainer) {
completedTests.forEach((completedTest, testIndex) => {
const card = document.createElement('test-card') as TestCard
card.testData = completedTest
card.addEventListener('rerun', () => {
this.dispatchEvent(
new CustomEvent('rerun-test', {
bubbles: true,
composed: true,
detail: {
suiteIndex: this._suiteIndex,
testIndex
}
})
)
})
testsContainer.appendChild(card)
})
}
}
}
customElements.define('test-suite', TestSuiteElement)

View File

@@ -0,0 +1,104 @@
:host {
display: block;
width: 100%;
padding: 80px 20px 20px 20px;
}
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: white;
border-bottom: 2px solid #3498db;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 100;
height: 32px;
}
h1 {
color: #2c3e50;
margin: 0;
font-size: 20px;
}
.header-controls {
display: flex;
align-items: center;
gap: 15px;
}
.logout-btn {
padding: 8px 16px;
background: #e74c3c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
&:hover {
background: #c0392b;
}
}
.requests-btn {
padding: 8px 16px;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
&:hover {
background: #2980b9;
}
}
.debug-toggle {
display: flex;
align-items: center;
gap: 8px;
label {
font-size: 14px;
color: #2c3e50;
cursor: pointer;
}
}
input[type='checkbox'] {
width: 18px;
height: 18px;
cursor: pointer;
}
.run-btn {
padding: 8px 16px;
background: #27ae60;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
&:hover:not(:disabled) {
background: #229954;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.results {
margin-top: 64px;
width: 100%;
}

View File

@@ -0,0 +1,163 @@
import { appContext } from '../core/AppContext'
import { TestRunner, type CompletedTestSuite } from '../core/TestRunner'
import type { TestSuite } from '../types'
import { TestSuiteElement } from './TestSuite'
import styles from './TestsView.css?inline'
export class TestsView extends HTMLElement {
private static styleSheet = new CSSStyleSheet()
private shadow: ShadowRoot
private testRunner: TestRunner | null = null
private _testSuites: TestSuite[] = []
private debugMode: boolean = false
static {
this.styleSheet.replaceSync(styles)
}
constructor() {
super()
this.shadow = this.attachShadow({ mode: 'open' })
this.shadow.adoptedStyleSheets = [TestsView.styleSheet]
}
connectedCallback() {
this.render()
}
get testSuites(): TestSuite[] {
return this._testSuites
}
set testSuites(suites: TestSuite[]) {
this._testSuites = suites
this.testRunner = new TestRunner(suites)
this.render()
}
render() {
this.shadow.innerHTML = `
<div class="header">
<h1>SASjs Adapter Tests</h1>
<div class="header-controls">
<div class="debug-toggle">
<input type="checkbox" id="debug-toggle" ${
this.debugMode ? 'checked' : ''
} />
<label for="debug-toggle">Debug Mode</label>
</div>
<button class="run-btn" id="run-btn">Run All Tests</button>
<button class="logout-btn" id="logout-btn">Logout</button>
<button class="requests-btn" id="requests-btn">View Requests</button>
</div>
</div>
<div class="results" id="results"></div>
`
const logoutBtn = this.shadow.getElementById('logout-btn')
logoutBtn?.addEventListener('click', () => this.handleLogout())
const debugToggle = this.shadow.getElementById(
'debug-toggle'
) as HTMLInputElement
debugToggle?.addEventListener('change', (e) => this.handleDebugToggle(e))
const runBtn = this.shadow.getElementById('run-btn') as HTMLButtonElement
runBtn?.addEventListener('click', () => this.handleRunTests(runBtn))
const requestsBtn = this.shadow.getElementById('requests-btn')
requestsBtn?.addEventListener('click', () => this.handleViewRequests())
}
handleViewRequests() {
const requestsModal = document.querySelector('requests-modal') as any
if (requestsModal && requestsModal.openModal) {
requestsModal.openModal()
}
}
handleDebugToggle(e: Event) {
const checkbox = e.target as HTMLInputElement
this.debugMode = checkbox.checked
const adapter = appContext.getAdapter()
if (adapter) {
adapter.setDebugState(this.debugMode)
}
}
async handleLogout() {
const adapter = appContext.getAdapter()
if (adapter) {
await adapter.logOut()
appContext.setIsLoggedIn(false)
this.dispatchEvent(
new CustomEvent('logout', {
bubbles: true,
composed: true
})
)
}
}
async handleRunTests(runBtn: HTMLButtonElement) {
if (!this.testRunner) return
runBtn.disabled = true
runBtn.textContent = 'Running...'
const resultsContainer = this.shadow.getElementById('results')
if (resultsContainer) {
resultsContainer.innerHTML = ''
}
await this.testRunner.runAllTests((completedSuites) => {
this.renderResults(resultsContainer!, completedSuites)
})
runBtn.disabled = false
runBtn.textContent = 'Run All Tests'
}
renderResults(container: HTMLElement, completedSuites: CompletedTestSuite[]) {
container.innerHTML = ''
completedSuites.forEach((suite, suiteIndex) => {
const suiteElement = document.createElement(
'test-suite'
) as TestSuiteElement
suiteElement.suiteData = suite
suiteElement.suiteIndex = suiteIndex
suiteElement.addEventListener('rerun-test', ((e: CustomEvent) => {
const { suiteIndex, testIndex } = e.detail
this.handleRerunTest(suiteIndex, testIndex, container)
}) as EventListener)
container.appendChild(suiteElement)
})
}
async handleRerunTest(
suiteIndex: number,
testIndex: number,
container: HTMLElement
) {
if (!this.testRunner) return
await this.testRunner.rerunTest(
suiteIndex,
testIndex,
(suiteIdx, testIdx, testData) => {
const suites = container.querySelectorAll('test-suite')
const suiteElement = suites[suiteIdx] as TestSuiteElement
if (suiteElement && suiteElement.updateTest) {
suiteElement.updateTest(testIdx, testData)
}
}
)
}
}
customElements.define('tests-view', TestsView)

View File

@@ -0,0 +1,5 @@
export { LoginForm } from './LoginForm'
export { TestCard } from './TestCard'
export { TestSuiteElement } from './TestSuite'
export { TestsView } from './TestsView'
export { RequestsModal } from './RequestsModal'

View File

@@ -0,0 +1,14 @@
import type { AppConfig } from '../types'
export interface ConfigWithCredentials extends AppConfig {
userName?: string
password?: string
}
export async function loadConfig(): Promise<ConfigWithCredentials> {
const response = await fetch('config.json')
if (!response.ok) {
throw new Error('Failed to load config.json')
}
return response.json()
}

View File

@@ -0,0 +1,60 @@
import type SASjs from '@sasjs/adapter'
import type { AppConfig, AppState } from '../types'
export class AppContext {
private state: AppState = {
config: null,
adapter: null,
isLoggedIn: false
}
private listeners: Array<(state: AppState) => void> = []
getState(): AppState {
return { ...this.state }
}
setState(newState: Partial<AppState>): void {
this.state = { ...this.state, ...newState }
this.notifyListeners()
}
subscribe(listener: (state: AppState) => void): () => void {
this.listeners.push(listener)
// Return unsubscribe function
return () => {
this.listeners = this.listeners.filter((l) => l !== listener)
}
}
private notifyListeners(): void {
this.listeners.forEach((listener) => listener(this.getState()))
}
setConfig(config: AppConfig): void {
this.setState({ config })
}
setAdapter(adapter: SASjs): void {
this.setState({ adapter })
}
setIsLoggedIn(isLoggedIn: boolean): void {
this.setState({ isLoggedIn })
}
getAdapter(): SASjs | null {
return this.state.adapter
}
getConfig(): AppConfig | null {
return this.state.config
}
isUserLoggedIn(): boolean {
return this.state.isLoggedIn
}
}
// Global singleton instance
export const appContext = new AppContext()

View File

@@ -0,0 +1,173 @@
import type { Test, TestSuite, TestStatus } from '../types'
import { runTest } from './runTest'
export interface CompletedTest {
test: Test
result: boolean
error: unknown
executionTime: number
status: TestStatus
}
export interface CompletedTestSuite {
name: string
completedTests: CompletedTest[]
}
export class TestRunner {
private testSuites: TestSuite[]
private completedTestSuites: CompletedTestSuite[] = []
private isRunning = false
constructor(testSuites: TestSuite[]) {
this.testSuites = testSuites
}
async runAllTests(
onUpdate?: (
completedSuites: CompletedTestSuite[],
currentIndex: number
) => void
): Promise<CompletedTestSuite[]> {
this.isRunning = true
this.completedTestSuites = []
for (let i = 0; i < this.testSuites.length; i++) {
const suite = this.testSuites[i]
await this.runTestSuite(suite, i, onUpdate)
}
this.isRunning = false
return this.completedTestSuites
}
async runTestSuite(
suite: TestSuite,
suiteIndex: number,
onUpdate?: (
completedSuites: CompletedTestSuite[],
currentIndex: number
) => void
): Promise<CompletedTestSuite> {
const completedTests: CompletedTest[] = []
let context: unknown
// Run beforeAll if exists
if (suite.beforeAll) {
context = await suite.beforeAll()
}
// Run each test sequentially
for (let i = 0; i < suite.tests.length; i++) {
const test = suite.tests[i]
const currentIndex = suiteIndex * 1000 + i
// Set status to running
const runningTest: CompletedTest = {
test,
result: false,
error: null,
executionTime: 0,
status: 'running'
}
completedTests.push(runningTest)
// Notify update
if (onUpdate) {
this.completedTestSuites[suiteIndex] = {
name: suite.name,
completedTests: [...completedTests]
}
onUpdate([...this.completedTestSuites], currentIndex)
}
// Execute test
const result = await runTest(test, { data: context })
// Update with result
completedTests[i] = {
test,
result: result.result,
error: result.error,
executionTime: result.executionTime,
status: result.result ? 'passed' : 'failed'
}
// Notify update
if (onUpdate) {
this.completedTestSuites[suiteIndex] = {
name: suite.name,
completedTests: [...completedTests]
}
onUpdate([...this.completedTestSuites], currentIndex)
}
}
// Run afterAll if exists
if (suite.afterAll) {
await suite.afterAll()
}
return {
name: suite.name,
completedTests
}
}
async rerunTest(
suiteIndex: number,
testIndex: number,
onUpdate?: (
suiteIndex: number,
testIndex: number,
testData: CompletedTest
) => void
): Promise<void> {
const suite = this.testSuites[suiteIndex]
const test = suite.tests[testIndex]
let context: unknown
if (suite.beforeAll) {
context = await suite.beforeAll()
}
// Set status to running
this.completedTestSuites[suiteIndex].completedTests[testIndex].status =
'running'
if (onUpdate) {
onUpdate(
suiteIndex,
testIndex,
this.completedTestSuites[suiteIndex].completedTests[testIndex]
)
}
// Execute test
const result = await runTest(test, { data: context })
// Update with result
this.completedTestSuites[suiteIndex].completedTests[testIndex] = {
test,
result: result.result,
error: result.error,
executionTime: result.executionTime,
status: result.result ? 'passed' : 'failed'
}
if (onUpdate) {
onUpdate(
suiteIndex,
testIndex,
this.completedTestSuites[suiteIndex].completedTests[testIndex]
)
}
}
getCompletedTestSuites(): CompletedTestSuite[] {
return this.completedTestSuites
}
isTestRunning(): boolean {
return this.isRunning
}
}

View File

@@ -0,0 +1,3 @@
export * from './runTest'
export * from './TestRunner'
export * from './AppContext'

View File

@@ -0,0 +1,30 @@
import type { Test, TestResult } from '../types'
export async function runTest(
testToRun: Test,
context: unknown
): Promise<TestResult> {
const { test, assertion, beforeTest, afterTest } = testToRun
const beforeTestFunction = beforeTest ? beforeTest : () => Promise.resolve()
const afterTestFunction = afterTest ? afterTest : () => Promise.resolve()
const startTime = new Date().valueOf()
return beforeTestFunction()
.then(() => test(context))
.then((res) => {
return Promise.resolve(assertion(res, context))
})
.then((testResult) => {
afterTestFunction()
const endTime = new Date().valueOf()
const executionTime = (endTime - startTime) / 1000
return { result: testResult, error: null, executionTime }
})
.catch((e) => {
console.error(e)
const endTime = new Date().valueOf()
const executionTime = (endTime - startTime) / 1000
return { result: false, error: e, executionTime }
})
}

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,61 +0,0 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
"Droid Sans", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #1f2027;
color: #eee;
}
* {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
}
input {
padding: 8px;
border-radius: 3px;
border: none;
font-size: 1.125em;
}
.submit-button {
border: none;
border-radius: 3px;
padding: 8px;
background-color: #f9e804;
color: black;
font-size: 0.8em;
&.disabled {
pointer-events: none;
}
}
.row {
display: flex;
flex-direction: column;
margin-top: 16px;
}
.loading-spinner {
display: inline-block;
width: 50px;
height: 50px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
-webkit-animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
-webkit-transform: rotate(360deg);
}
}
@-webkit-keyframes spin {
to {
-webkit-transform: rotate(360deg);
}
}

View File

@@ -1,26 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { Route, HashRouter, Switch } from 'react-router-dom'
import './index.scss'
import * as serviceWorker from './serviceWorker'
import { AppProvider } from '@sasjs/test-framework'
import PrivateRoute from './PrivateRoute'
import Login from './Login'
import App from './App'
ReactDOM.render(
<AppProvider>
<HashRouter>
<Switch>
<PrivateRoute exact path="/" component={App} />
<Route exact path="/login" component={Login} />
</Switch>
</HashRouter>
</AppProvider>,
document.getElementById('root')
)
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister()

140
sasjs-tests/src/main.ts Normal file
View File

@@ -0,0 +1,140 @@
import * as SASjsModule from '@sasjs/adapter'
const SASjsImport = (SASjsModule as any).default || SASjsModule
const SASjs = SASjsImport.default
import { appContext } from './core/AppContext'
import { type ConfigWithCredentials, loadConfig } from './config/loader'
import type { TestSuite } from './types'
// Import custom elements (this registers them)
import './components/LoginForm'
import './components/TestCard'
import './components/TestSuite'
import './components/TestsView'
import './components/RequestsModal'
import type { LoginForm } from './components/LoginForm'
import type { TestsView } from './components/TestsView'
import type { RequestsModal } from './components/RequestsModal'
// Import test suites
// import { basicTests } from './testSuites/Basic'
import { sendArrTests, sendObjTests } from './testSuites/RequestData'
import { fileUploadTests } from './testSuites/FileUpload'
import { computeTests } from './testSuites/Compute'
import { sasjsRequestTests } from './testSuites/SasjsRequests'
async function init() {
const appContainer = document.getElementById('app')
if (!appContainer) {
console.error('App container not found')
return
}
try {
// Load config
const config = await loadConfig()
// Initialize adapter
const adapter = new SASjs(config.sasJsConfig)
appContext.setAdapter(adapter)
appContext.setConfig(config)
// Check session
try {
const sessionResponse = await adapter.checkSession()
if (sessionResponse && sessionResponse.isLoggedIn) {
appContext.setIsLoggedIn(true)
showTests(appContainer, adapter, config)
return
}
} catch {
console.log('No active session, showing login')
}
// Show login
showLogin(appContainer)
} catch (error) {
console.error('Failed to initialize app:', error)
appContainer.innerHTML = `
<div class="app__error">
<h1>Initialization Error</h1>
<p>Failed to load configuration. Please check config.json file.</p>
<pre>${error}</pre>
</div>
`
}
}
function showLogin(container: HTMLElement) {
container.innerHTML = ''
const loginForm = document.createElement('login-form') as LoginForm
loginForm.addEventListener('login-success', () => {
const adapter = appContext.getAdapter()
const config = appContext.getConfig()
if (adapter && config) {
showTests(container, adapter, config)
}
})
container.appendChild(loginForm)
}
function showTests(
container: HTMLElement,
adapter: typeof SASjs,
config: ConfigWithCredentials
) {
const configTyped = config as {
sasJsConfig: { appLoc: string }
userName?: string
password?: string
}
const appLoc = configTyped.sasJsConfig.appLoc
// Build test suites with adapter and credentials
const testSuites: TestSuite[] = [
// FIXME: disabled basicTests due to login/logout operations
// basicTests(adapter, configTyped.userName || '', configTyped.password || ''),
sendArrTests(adapter, appLoc),
sendObjTests(adapter),
// specialCaseTests(adapter),
sasjsRequestTests(adapter),
fileUploadTests(adapter)
]
// Add compute tests for SASVIYA only
if (adapter.getSasjsConfig().serverType === 'SASVIYA') {
testSuites.push(computeTests(adapter, appLoc))
}
container.innerHTML = ''
const testsView = document.createElement('tests-view') as TestsView
testsView.testSuites = testSuites
const requestsModal = document.createElement(
'requests-modal'
) as RequestsModal
testsView.addEventListener('logout', () => {
showLogin(container)
})
container.appendChild(requestsModal)
container.appendChild(testsView)
}
// Subscribe to auth changes
appContext.subscribe((state) => {
const appContainer = document.getElementById('app')
if (!appContainer) return
if (!state.isLoggedIn) {
showLogin(appContainer)
} else if (state.adapter && state.config) {
showTests(appContainer, state.adapter, state.config)
}
})
// Initialize app
init()

View File

@@ -1 +0,0 @@
/// <reference types="react-scripts" />

View File

@@ -1,141 +0,0 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
)
export function register(config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href)
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config)
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
)
})
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config)
}
})
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
registration.onupdatefound = () => {
const installingWorker = registration.installing
if (installingWorker == null) {
return
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
)
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration)
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.')
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration)
}
}
}
}
}
})
.catch((error) => {
console.error('Error during service worker registration:', error)
})
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' }
})
.then((response) => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type')
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => {
window.location.reload()
})
})
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config)
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
)
})
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then((registration) => {
registration.unregister()
})
.catch((error) => {
console.error(error.message)
})
}
}

View File

@@ -1,5 +0,0 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect'

View File

@@ -1,6 +1,8 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import SASjs, { LoginMechanism, SASjsConfig } from '@sasjs/adapter'
import { TestSuite } from '@sasjs/test-framework'
import { ServerType } from '@sasjs/utils/types'
import type { TestSuite } from '../types'
const stringData: any = { table1: [{ col1: 'first col value' }] }
@@ -61,7 +63,7 @@ export const basicTests = (
'Should fail on first attempt and should log the user in on second attempt',
test: async () => {
await adapter.logOut()
await adapter.logIn('invalid', 'invalid').catch((err: any) => {})
await adapter.logIn('invalid', 'invalid').catch((_err: any) => {})
return await adapter.logIn(userName, password)
},
assertion: (response: any) =>
@@ -87,6 +89,20 @@ export const basicTests = (
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',
description:
@@ -159,20 +175,6 @@ export const basicTests = (
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

@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import SASjs from '@sasjs/adapter'
import { TestSuite } from '@sasjs/test-framework'
import type { TestSuite } from '../types'
const stringData: any = { table1: [{ col1: 'first col value' }] }
@@ -48,7 +49,7 @@ export const computeTests = (adapter: SASjs, appLoc: string): TestSuite => ({
test: () => {
const data: any = { table1: [{ col1: 'first col value' }] }
return adapter.startComputeJob(
'/Public/app/common/sendArr',
`${appLoc}/common/sendArr`,
data,
{},
undefined,
@@ -71,13 +72,12 @@ export const computeTests = (adapter: SASjs, appLoc: string): TestSuite => ({
test: () => {
const fileLines = [`data;`, `do x=1 to 100;`, `output;`, `end;`, `run;`]
return adapter.executeScriptSASViya(
'sasCode.sas',
fileLines,
'SAS Studio compute context',
undefined,
true
)
return adapter.executeScript({
fileName: 'sasCode.sas',
linesOfCode: fileLines,
contextName: 'SAS Studio compute context',
debug: true
})
},
assertion: (res: any) => {
const expectedLogContent = `1 data;\\n2 do x=1 to 100;\\n3 output;\\n4 end;\\n5 run;\\n\\n`
@@ -92,13 +92,12 @@ export const computeTests = (adapter: SASjs, appLoc: string): TestSuite => ({
const fileLines = [`%abort;`]
return adapter
.executeScriptSASViya(
'sasCode.sas',
fileLines,
'SAS Studio compute context',
undefined,
true
)
.executeScript({
fileName: 'sasCode.sas',
linesOfCode: fileLines,
contextName: 'SAS Studio compute context',
debug: true
})
.catch((err: any) => err)
},
assertion: (res: any) => {

View File

@@ -1,5 +1,7 @@
/* eslint-disable prefer-const */
/* eslint-disable @typescript-eslint/no-explicit-any */
import SASjs from '@sasjs/adapter'
import { TestSuite } from '@sasjs/test-framework'
import type { TestSuite } from '../types'
export const fileUploadTests = (adapter: SASjs): TestSuite => ({
name: 'File Upload Tests',

View File

@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import SASjs from '@sasjs/adapter'
import { TestSuite } from '@sasjs/test-framework'
import type { TestSuite } from '../types'
const stringData: any = { table1: [{ col1: 'first col value' }] }
const numericData: any = { table1: [{ col1: 3.14159265 }] }

View File

@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import SASjs from '@sasjs/adapter'
import { TestSuite } from '@sasjs/test-framework'
import type { TestSuite } from '../types'
const data: any = { table1: [{ col1: 'first col value' }] }
@@ -20,30 +21,30 @@ export const sasjsRequestTests = (adapter: SASjs): TestSuite => ({
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

@@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import SASjs from '@sasjs/adapter'
import { TestSuite } from '@sasjs/test-framework'
import type { TestSuite } from '../types'
const specialCharData: any = {
table1: [
@@ -134,6 +136,20 @@ export const specialCaseTests = (adapter: SASjs): TestSuite => ({
return adapter.request('common/sendArr', moreSpecialCharData)
},
assertion: (res: any) => {
// If sas session is `latin9` or `wlatin1` we can't process the special characters,
// But it can happen that response is broken JSON, so we first need to check if
// it's object and then check accordingly
if (typeof res === 'object') {
// Valid JSON response
if (res.SYSENCODING === 'latin9' || res.SYSENCODING === 'wlatin1')
return true
} else {
// Since we got string response (broken JSON), we need to check with regex
const regex = /"SYSENCODING"\s*:\s*"(?:wlatin1|latin9)"/
if (regex.test(res)) return true
}
return (
res.table1[0][0] === moreSpecialCharData.table1[0].speech0 &&
res.table1[0][1] === moreSpecialCharData.table1[0].pct &&
@@ -311,7 +327,7 @@ export const specialCaseTests = (adapter: SASjs): TestSuite => ({
// We receive formats in response. We compare it with formats that we included in request to make sure they are equal
const resVars = res[`$${testTable}`].vars
Object.keys(resVars).forEach((key: any, i: number) => {
Object.keys(resVars).forEach((key: any, _i: number) => {
let formatValue =
testTableWithSpecialNumeric[`$${testTable}`].formats[
key.toLowerCase()
@@ -359,7 +375,7 @@ export const specialCaseTests = (adapter: SASjs): TestSuite => ({
// We receive formats in response. We compare it with formats that we included in request to make sure they are equal
const resVars = res[`$${testTable}`].vars
Object.keys(resVars).forEach((key: any, i: number) => {
Object.keys(resVars).forEach((key: any, _i: number) => {
let formatValue =
testTableWithSpecialNumeric[`$${testTable}`].formats[
key.toLowerCase()
@@ -407,7 +423,7 @@ export const specialCaseTests = (adapter: SASjs): TestSuite => ({
// We receive formats in response. We compare it with formats that we included in request to make sure they are equal
const resVars = res[`$${testTable}`].vars
Object.keys(resVars).forEach((key: any, i: number) => {
Object.keys(resVars).forEach((key: any, _i: number) => {
let formatValue =
testTableWithSpecialNumericLowercase[`$${testTable}`].formats[
key.toLowerCase()
@@ -464,7 +480,7 @@ export const specialCaseTests = (adapter: SASjs): TestSuite => ({
// We receive formats in response. We compare it with formats that we included in request to make sure they are equal
const resVars = res[`$${testTable}`].vars
Object.keys(resVars).forEach((key: any, i: number) => {
Object.keys(resVars).forEach((key: any, _i: number) => {
let formatValue =
testTableWithSpecialNumeric[`$${testTable}`].formats[
key.toLowerCase()

View File

@@ -0,0 +1,12 @@
import type SASjs from '@sasjs/adapter'
import type { SASjsConfig } from '@sasjs/adapter'
export interface AppConfig {
sasJsConfig: SASjsConfig
}
export interface AppState {
config: AppConfig | null
adapter: SASjs | null
isLoggedIn: boolean
}

View File

@@ -0,0 +1,2 @@
export * from './test'
export * from './context'

View File

@@ -0,0 +1,24 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface Test {
title: string
description: string
beforeTest?: (...args: any) => Promise<any>
afterTest?: (...args: any) => Promise<any>
test: (context: any) => Promise<any>
assertion: (...args: any) => boolean
}
export interface TestSuite {
name: string
tests: Test[]
beforeAll?: (...args: any) => Promise<any>
afterAll?: (...args: any) => Promise<any>
}
export interface TestResult {
result: boolean
error: Error | null
executionTime: number
}
export type TestStatus = 'pending' | 'running' | 'passed' | 'failed'

View File

@@ -1,22 +0,0 @@
export const assert = (
expression: boolean | (() => boolean),
message = 'Assertion failed'
) => {
let result
try {
if (typeof expression === 'boolean') {
result = expression
} else {
result = expression()
}
} catch (e: any) {
console.error(message)
throw new Error(message)
}
if (!!result) {
return
} else {
console.error(message)
throw new Error(message)
}
}

View File

@@ -1,26 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"noFallthroughCasesInSwitch": true
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"src"
]
"include": ["src"]
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
export default defineConfig({
build: {
assetsInlineLimit: 0,
assetsDir: ''
},
base: ''
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { isRelativePath, isUri, isUrl } from './utils'
import * as NodeFormData from 'form-data'
import NodeFormData from 'form-data'
import {
Job,
Session,
@@ -25,9 +25,16 @@ import { prefixMessage } from '@sasjs/utils/error'
import { pollJobState } from './api/viya/pollJobState'
import { getTokens } from './auth/getTokens'
import { uploadTables } from './api/viya/uploadTables'
import { executeScript } from './api/viya/executeScript'
import { executeOnComputeApi } from './api/viya/executeOnComputeApi'
import { getAccessTokenForViya } from './auth/getAccessTokenForViya'
import { refreshTokensForViya } from './auth/refreshTokensForViya'
import { FileResource } from './types/FileResource'
interface JobExecutionResult {
result?: { result: object }
log?: string
error?: object
}
/**
* A client for interfacing with the SAS Viya REST API.
@@ -270,7 +277,7 @@ export class SASViyaApiClient {
* @param debug - when set to true, the log will be returned.
* @param expectWebout - when set to true, the automatic _webout fileref will be checked for content, and that content returned. This fileref is used when the Job contains a SASjs web request (as opposed to executing arbitrary SAS code).
* @param waitForResult - when set to true, function will return the session
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }.
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts.
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
* @param variables - an object that represents macro variables.
*/
@@ -287,7 +294,7 @@ export class SASViyaApiClient {
printPid = false,
variables?: MacroVar
): Promise<any> {
return executeScript(
return executeOnComputeApi(
this.requestClient,
this.sessionManager,
this.rootFolderName,
@@ -305,6 +312,84 @@ export class SASViyaApiClient {
)
}
/**
* Fetches the file content for a file in the specified folder.
*
* @param folderPath - the full path to the folder containing the file. eg: /Public/folder1/folder2
* @param fileName - the name of the file in the `folderPath`
* @param accessToken - an access token for authorizing the request
*/
public async getFileContent(
folderPath: string,
fileName: string,
accessToken?: string
) {
const fileUri = await this.getFileUri(
folderPath,
fileName,
accessToken
).catch((err) => {
throw prefixMessage(
err,
`Error while getting file URI for: ${fileName} in folder: ${folderPath}. `
)
})
return await this.requestClient
.get<string>(`${this.serverUrl}${fileUri}/content`, accessToken)
.then((res) => res.result)
}
/**
* Updates the file content for a file in the specified folder.
*
* @param folderPath - the full path to the folder containing the file. eg: /Public/folder1/folder2
* @param fileName - the name of the file in the `folderPath`
* @param content - the new content to be written to the file
* @param accessToken - an access token for authorizing the request
*/
public async updateFileContent(
folderPath: string,
fileName: string,
content: string,
accessToken?: string
) {
const fileUri = await this.getFileUri(
folderPath,
fileName,
accessToken
).catch((err) => {
throw prefixMessage(
err,
`Error while getting file URI for: ${fileName} in folder: ${folderPath}. `
)
})
// Fetch the file resource details to get the Etag and content type
const { result: originalFileResource, etag } =
await this.requestClient.get<FileResource>(
`${this.serverUrl}${fileUri}`,
accessToken
)
if (!originalFileResource || !etag)
throw new Error(
`File ${fileName} does not have an ETag, or request failed.`
)
return await this.requestClient
.put<FileResource>(
`${this.serverUrl}${fileUri}/content`,
content,
accessToken,
{
'If-Match': etag,
'Content-Type': originalFileResource.contentType
}
)
.then((res) => res.result)
}
/**
* Fetches a folder. Path to the folder is required.
* @param folderPath - the absolute path to the folder.
@@ -378,12 +463,14 @@ export class SASViyaApiClient {
isForced?: boolean
): Promise<Folder> {
const logger = process.logger || console
if (!parentFolderPath && !parentFolderUri) {
throw new Error('Path or URI of the parent folder is required.')
}
if (!parentFolderUri && parentFolderPath) {
parentFolderUri = await this.getFolderUri(parentFolderPath, accessToken)
if (!parentFolderUri) {
logger.info(
`Parent folder at path '${parentFolderPath}' is not present.`
@@ -394,6 +481,7 @@ export class SASViyaApiClient {
parentFolderPath.lastIndexOf('/')
)
const newFolderName = `${parentFolderPath.split('/').pop()}`
if (newParentFolderPath === '') {
throw new RootFolderNotFoundError(
parentFolderPath,
@@ -401,20 +489,24 @@ export class SASViyaApiClient {
accessToken
)
}
logger.info(
`Creating parent folder:\n'${newFolderName}' in '${newParentFolderPath}'`
)
const parentFolder = await this.createFolder(
newFolderName,
newParentFolderPath,
undefined,
accessToken
)
logger.info(
`Parent folder '${newFolderName}' has been successfully created.`
)
parentFolderUri = `/folders/folders/${parentFolder.id}`
} else if (isForced && accessToken) {
} else if (isForced) {
const folderPath = parentFolderPath + '/' + folderName
const folderUri = await this.getFolderUri(folderPath, accessToken)
@@ -427,8 +519,8 @@ export class SASViyaApiClient {
}
}
const { result: createFolderResponse } =
await this.requestClient.post<Folder>(
const { result: createFolderResponse } = await this.requestClient
.post<Folder>(
`/folders/folders?parentFolderUri=${parentFolderUri}`,
{
name: folderName,
@@ -436,12 +528,34 @@ export class SASViyaApiClient {
},
accessToken
)
.catch((err) => {
const { message, response } = err
if (message && response && response.data && response.data.message) {
const { status } = response
const { message: responseMessage } = response.data
const messages = [message, responseMessage].map((mes: string) =>
/\.$/.test(mes) ? mes : `${mes}.`
)
if (!isForced && status === 409) {
messages.push(`To override, please set "isForced" to "true".`)
}
const errMessage = messages.join(' ')
throw errMessage
}
throw err
})
// update folder map with newly created folder.
await this.populateFolderMap(
`${parentFolderPath}/${folderName}`,
accessToken
)
return createFolderResponse
}
@@ -548,6 +662,7 @@ export class SASViyaApiClient {
/**
* Exchanges the refresh token for an access token for the given client.
* This method can only be used by Node.
* @param clientId - the client ID to authenticate with.
* @param clientSecret - the client secret to authenticate with.
* @param refreshToken - the refresh token received from the server.
@@ -591,7 +706,7 @@ export class SASViyaApiClient {
* @param accessToken - an optional access token for an authorized user.
* @param waitForResult - a boolean indicating if the function should wait for a result.
* @param expectWebout - a boolean indicating whether to expect a _webout response.
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }.
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts.
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
* @param variables - an object that represents macro variables.
*/
@@ -702,11 +817,13 @@ export class SASViyaApiClient {
debug: boolean,
data?: any,
authConfig?: AuthConfig
) {
): Promise<JobExecutionResult> {
let access_token = (authConfig || {}).access_token
if (authConfig) {
;({ access_token } = await getTokens(this.requestClient, authConfig))
}
if (isRelativePath(sasJob) && !this.rootFolderName) {
throw new Error(
'Relative paths cannot be used without specifying a root folder name.'
@@ -719,6 +836,7 @@ export class SASViyaApiClient {
const fullFolderPath = isRelativePath(sasJob)
? `${this.rootFolderName}/${folderPath}`
: folderPath
await this.populateFolderMap(fullFolderPath, access_token)
const jobFolder = this.folderMap.get(fullFolderPath)
@@ -735,9 +853,8 @@ export class SASViyaApiClient {
files = await this.uploadTables(data, access_token)
}
if (!jobToExecute) {
throw new Error(`Job was not found.`)
}
if (!jobToExecute) throw new Error(`Job was not found.`)
const jobDefinitionLink = jobToExecute?.links.find(
(l) => l.rel === 'getResource'
)?.href
@@ -753,14 +870,14 @@ export class SASViyaApiClient {
_webin_file_count: files.length,
_OMITJSONLISTING: true,
_OMITJSONLOG: true,
_OMITSESSIONRESULTS: true,
_omitSessionResults: false,
_OMITTEXTLISTING: true,
_OMITTEXTLOG: true
}
if (debug) {
jobArguments['_OMITTEXTLOG'] = 'false'
jobArguments['_OMITSESSIONRESULTS'] = 'false'
jobArguments['_omitSessionResults'] = 'false'
jobArguments['_DEBUG'] = 131
}
@@ -777,16 +894,19 @@ export class SASViyaApiClient {
jobDefinition,
arguments: jobArguments
}
const { result: postedJob } = await this.requestClient.post<Job>(
`${this.serverUrl}/jobExecution/jobs?_action=wait`,
postJobRequestBody,
access_token
)
const jobStatus = await this.pollJobState(postedJob, authConfig).catch(
(err) => {
throw prefixMessage(err, 'Error while polling job status. ')
}
)
const { result: currentJob } = await this.requestClient.get<Job>(
`${this.serverUrl}/jobExecution/jobs/${postedJob.id}`,
access_token
@@ -797,6 +917,7 @@ export class SASViyaApiClient {
const resultLink = currentJob.results['_webout.json']
const logLink = currentJob.links.find((l) => l.rel === 'log')
if (resultLink) {
jobResult = await this.requestClient.get<any>(
`${this.serverUrl}${resultLink}/content`,
@@ -804,11 +925,13 @@ export class SASViyaApiClient {
'text/plain'
)
}
if (debug && logLink) {
log = await this.requestClient
.get<any>(`${this.serverUrl}${logLink.href}/content`, access_token)
.then((res: any) => res.result.items.map((i: any) => i.line).join('\n'))
}
if (jobStatus === 'failed') {
throw new JobExecutionError(
currentJob.error?.errorCode,
@@ -816,7 +939,16 @@ export class SASViyaApiClient {
log
)
}
return { result: jobResult?.result, log }
const executionResult: JobExecutionResult = {
result: jobResult?.result,
log
}
const { error } = currentJob
if (error) executionResult.error = error
return executionResult
}
private async populateFolderMap(folderPath: string, accessToken?: string) {
@@ -888,6 +1020,7 @@ export class SASViyaApiClient {
})
if (!folder) return undefined
return folder
}
@@ -899,7 +1032,31 @@ export class SASViyaApiClient {
return `/folders/folders/${folderDetails.id}`
}
private async getRecycleBinUri(accessToken: string) {
private async getFileUri(
folderPath: string,
fileName: string,
accessToken?: string
): Promise<string> {
const folderMembers = await this.listFolder(folderPath, accessToken, 1000, {
returnDetails: true
}).catch((err) => {
throw prefixMessage(err, `Error while listing folder: ${folderPath}. `)
})
if (!folderMembers || !folderMembers.length)
throw new Error(`No members found in folder: ${folderPath}`)
const fileUri = folderMembers.find(
(member) => member.name === fileName
)?.uri
if (!fileUri)
throw new Error(`File ${fileName} not found in folder: ${folderPath}`)
return fileUri
}
private async getRecycleBinUri(accessToken?: string) {
const url = '/folders/folders/@myRecycleBin'
const { result: folder } = await this.requestClient
@@ -946,14 +1103,19 @@ export class SASViyaApiClient {
}
/**
* Lists children folders for given Viya folder.
* Lists children folders/files for given Viya folder.
* @param sourceFolder - the full path (eg `/Public/example/myFolder`) or URI of the source folder listed. Providing URI instead of path will save one extra request.
* @param accessToken - an access token for authorizing the request.
* @param {Object} [options] - Additional options.
* @param {boolean} [options.returnDetails=false] - when set to true, the function will return an array of objects with member details, otherwise it will return an array of member names.
*/
public async listFolder(
sourceFolder: string,
accessToken?: string,
limit: number = 20
limit: number = 20,
options?: {
returnDetails?: boolean
}
) {
// checks if 'sourceFolder' is already a URI
const sourceFolderUri = isUri(sourceFolder)
@@ -965,11 +1127,20 @@ export class SASViyaApiClient {
accessToken
)
let membersToReturn = []
if (members && members.items) {
return members.items.map((item: any) => item.name)
} else {
return []
// If returnDetails is true, return full member details
if (options?.returnDetails) {
membersToReturn = members.items
} else {
// If returnDetails is false, return only member names
membersToReturn = members.items.map((item: any) => item.name)
}
}
// Return members without Etag
return membersToReturn
}
/**
@@ -983,7 +1154,7 @@ export class SASViyaApiClient {
sourceFolder: string,
targetParentFolder: string,
targetFolderName: string,
accessToken: string
accessToken?: string
) {
// If target path is an existing folder, than keep source folder name, othervise rename it with given target folder name
const sourceFolderName = sourceFolder.split('/').pop() as string
@@ -1050,7 +1221,7 @@ export class SASViyaApiClient {
* @param folderPath - the full path (eg `/Public/example/deleteThis`) of the folder to be deleted.
* @param accessToken - an access token for authorizing the request.
*/
public async deleteFolder(folderPath: string, accessToken: string) {
public async deleteFolder(folderPath: string, accessToken?: string) {
const recycleBinUri = await this.getRecycleBinUri(accessToken)
const folderName = folderPath.split('/').pop() || ''
const date = new Date()

View File

@@ -5,7 +5,12 @@ import {
EditContextInput,
PollOptions,
LoginMechanism,
ExecutionQuery
VerboseMode,
ErrorResponse,
LoginOptions,
LoginResult,
ExecutionQuery,
Tables
} from './types'
import { SASViyaApiClient } from './SASViyaApiClient'
import { SAS9ApiClient } from './SAS9ApiClient'
@@ -17,7 +22,7 @@ import {
AuthConfig,
ExtraResponseAttributes,
SasAuthResponse,
ServicePackSASjs
AuthConfigSas9
} from '@sasjs/utils/types'
import { RequestClient } from './request/RequestClient'
import { SasjsRequestClient } from './request/SasjsRequestClient'
@@ -30,8 +35,17 @@ import {
Sas9JobExecutor,
FileUploader
} from './job-execution'
import { ErrorResponse } from './types/errors'
import { LoginOptions, LoginResult } from './types/Login'
import { AxiosResponse, AxiosError } from 'axios'
interface ExecuteScriptParams {
linesOfCode: string[]
fileName?: string
contextName?: string
runTime?: string
authConfig?: AuthConfig
authConfigSas9?: AuthConfigSas9
debug?: boolean
}
const defaultConfig: SASjsConfig = {
serverUrl: '',
@@ -79,74 +93,90 @@ export default class SASjs {
}
/**
* Executes SAS code on a SAS 9 server. Requires a runner to be present in
* the users home directory in metadata.
* @param linesOfCode - lines of sas code from the file to run.
* @param username - a string representing the username.
* @param password - a string representing the password.
*/
public async executeScriptSAS9(
linesOfCode: string[],
userName: string,
password: string
) {
this.isMethodSupported('executeScriptSAS9', [ServerType.Sas9])
return await this.sas9ApiClient?.executeScript(
linesOfCode,
userName,
password
)
}
/**
* Executes SAS code on a SASJS server
* @param code - a string of code from the file to run.
* Executes code on a SAS server.
* @param linesOfCode - lines of code to run.
* @param fileName - (required for server type sas viya) name of the file to run. It will be converted to path to the file being submitted for execution.
* @param contextName - (required for server type sas viya) context name on which code will be run on the server.
* @param runTime - (required for server type sasjs) a string to represent runTime for code execution.
* @param authConfig - (optional) a valid client, secret, refresh and access tokens that are authorised to execute scripts.
*/
public async executeScriptSASjs(
code: string,
runTime?: string,
authConfig?: AuthConfig
) {
this.isMethodSupported('executeScriptSASJS', [ServerType.Sasjs])
return await this.sasJSApiClient?.executeScript(code, runTime, authConfig)
}
/**
* Executes sas code in a SAS Viya compute session.
* @param fileName - name of the file to run. It will be converted to path to the file being submitted for execution.
* @param linesOfCode - lines of sas code from the file to run.
* @param contextName - context name on which code will be run on the server.
* @param authConfig - (optional) the access token, refresh token, client and secret for authorizing the request.
* @param authConfigSas9 - (required for server type sas9) a valid username and password that are authorised to execute scripts.
* @param debug - (optional) if true, global debug config will be overriden
*/
public async executeScriptSASViya(
fileName: string,
linesOfCode: string[],
contextName: string,
authConfig?: AuthConfig,
debug?: boolean
) {
this.isMethodSupported('executeScriptSASViya', [ServerType.SasViya])
public async executeScript({
linesOfCode,
fileName,
contextName,
runTime,
authConfig,
authConfigSas9,
debug
}: ExecuteScriptParams) {
this.isMethodSupported('executeScript', [
ServerType.Sas9,
ServerType.Sasjs,
ServerType.SasViya
])
contextName = contextName || this.sasjsConfig.contextName
if (this.sasjsConfig.serverType === ServerType.Sas9) {
if (!authConfigSas9)
throw new Error('Auth config for sas9 is not provided')
if (!contextName) {
throw new Error(
'Context name is undefined. Please set a `contextName` in your SASjs or override config.'
return await this.sas9ApiClient?.executeScript(
linesOfCode,
authConfigSas9.userName,
authConfigSas9.password
)
}
return await this.sasViyaApiClient!.executeScript(
fileName,
linesOfCode,
contextName,
authConfig,
null,
debug ? debug : this.sasjsConfig.debug
)
if (this.sasjsConfig.serverType === ServerType.Sasjs) {
return await this.sasJSApiClient?.executeScript(
linesOfCode.join('\n'),
runTime,
authConfig
)
}
if (this.sasjsConfig.serverType === ServerType.SasViya) {
contextName = contextName || this.sasjsConfig.contextName
if (!contextName) {
throw new Error(
'Context name is undefined. Please set a `contextName` in your SASjs or override config.'
)
}
if (!fileName) {
throw new Error(
'File name is required in case of SAS VIYA. Please provide a `fileName`.'
)
}
return await this.sasViyaApiClient!.executeScript(
fileName,
linesOfCode,
contextName,
authConfig,
null,
debug ? debug : this.sasjsConfig.debug
)
}
}
/**
* Executes job on SASJS server.
* @param query - an object containing job path and debug level.
* @param appLoc - an application path.
* @param authConfig - an object for authentication.
* @returns a promise that resolves into job execution result and log.
*/
public async executeJob(
query: ExecutionQuery,
appLoc: string,
authConfig?: AuthConfig
) {
this.isMethodSupported('executeScript', [ServerType.Sasjs])
return await this.sasJSApiClient?.executeJob(query, appLoc, authConfig)
}
/**
@@ -329,13 +359,16 @@ export default class SASjs {
sasApiClient?: SASViyaApiClient,
isForced?: boolean
) {
if (sasApiClient)
if (sasApiClient) {
return await sasApiClient.createFolder(
folderName,
parentFolderPath,
parentFolderUri,
accessToken
accessToken,
isForced
)
}
return await this.sasViyaApiClient!.createFolder(
folderName,
parentFolderPath,
@@ -379,6 +412,51 @@ export default class SASjs {
)
}
/**
* Fetches the file content for a file in the specified folder.
*
* @param folderPath - the full path to the folder containing the file. eg: /Public/folder1/folder2
* @param fileName - the name of the file in the `folderPath`
* @param accessToken - an access token for authorizing the request
*/
public async getFileContent(
folderPath: string,
fileName: string,
accessToken?: string
) {
this.isMethodSupported('getFileContent', [ServerType.SasViya])
return await this.sasViyaApiClient!.getFileContent(
folderPath,
fileName,
accessToken
)
}
/**
* Updates the file content for a file in the specified folder.
*
* @param folderPath - the full path to the folder containing the file. eg: /Public/folder1/folder2
* @param fileName - the name of the file in the `folderPath`
* @param content - the new content to be written to the file
* @param accessToken - an access token for authorizing the request
*/
public async updateFileContent(
folderPath: string,
fileName: string,
content: string,
accessToken?: string
) {
this.isMethodSupported('updateFileContent', [ServerType.SasViya])
return await this.sasViyaApiClient!.updateFileContent(
folderPath,
fileName,
content,
accessToken
)
}
/**
* Fetches a folder from the SAS file system.
* @param folderPath - path of the folder to be fetched.
@@ -404,18 +482,23 @@ export default class SASjs {
* Lists children folders for given Viya folder.
* @param sourceFolder - the full path (eg `/Public/example/myFolder`) or URI of the source folder listed. Providing URI instead of path will save one extra request.
* @param accessToken - an access token for authorizing the request.
* @param returnDetails - when set to true, the function will return an array of objects with member details, otherwise it will return an array of member names.
*/
public async listFolder(
sourceFolder: string,
accessToken?: string,
limit?: number
limit?: number,
returnDetails = false
) {
this.isMethodSupported('listFolder', [ServerType.SasViya])
return await this.sasViyaApiClient?.listFolder(
sourceFolder,
accessToken,
limit
limit,
{
returnDetails
}
)
}
@@ -775,13 +858,11 @@ export default class SASjs {
this.isMethodSupported('deployServicePack', [ServerType.SasViya])
let sasApiClient: any = null
if (serverUrl || appLoc) {
if (!serverUrl) {
serverUrl = this.sasjsConfig.serverUrl
}
if (!appLoc) {
appLoc = this.sasjsConfig.appLoc
}
if (!serverUrl) serverUrl = this.sasjsConfig.serverUrl
if (!appLoc) appLoc = this.sasjsConfig.appLoc
if (this.sasjsConfig.serverType === ServerType.SasViya) {
sasApiClient = new SASViyaApiClient(
serverUrl,
@@ -799,11 +880,13 @@ export default class SASjs {
}
} else {
let sasClientConfig: any = null
if (this.sasjsConfig.serverType === ServerType.SasViya) {
sasClientConfig = this.sasViyaApiClient!.getConfig()
} else if (this.sasjsConfig.serverType === ServerType.Sas9) {
sasClientConfig = this.sas9ApiClient!.getConfig()
}
serverUrl = sasClientConfig.serverUrl
appLoc = sasClientConfig.rootFolderName as string
}
@@ -826,33 +909,6 @@ export default class SASjs {
)
}
/**
* Creates the folders and services at the given location `appLoc` on the given server `serverUrl`.
* @param dataJson - the JSON specifying the folders and files to be created, can also includes
* appLoc, streamServiceName, streamWebFolder, streamLogo
* @param appLoc - (optional) the base folder in which to create the new folders and
* services. If not provided, is taken from SASjsConfig. Precedence will be of appLoc present in dataJson.
* @param authConfig - (optional) a valid client, secret, refresh and access tokens that are authorised to execute compute jobs.
*/
public async deployToSASjs(
dataJson: ServicePackSASjs,
appLoc?: string,
authConfig?: AuthConfig
) {
if (!appLoc) {
appLoc = this.sasjsConfig.appLoc
}
return await this.sasJSApiClient?.deploy(dataJson, appLoc, authConfig)
}
public async executeJobSASjs(query: ExecutionQuery, authConfig?: AuthConfig) {
return await this.sasJSApiClient?.executeJob(
query,
this.sasjsConfig.appLoc,
authConfig
)
}
/**
* Kicks off execution of the given job via the compute API.
* @returns an object representing the compute session created for the given job.
@@ -867,9 +923,10 @@ export default class SASjs {
* @param authConfig - a valid client, secret, refresh and access tokens that are authorised to execute compute jobs.
* The access token is not required when the user is authenticated via the browser.
* @param waitForResult - a boolean that indicates whether the function needs to wait for execution to complete.
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }.
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts.
* @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
* @param variables - an object that represents macro variables.
* @param verboseMode - boolean or a string equal to 'bleached' to enable verbose mode (log every HTTP response).
*/
public async startComputeJob(
sasJob: string,
@@ -879,7 +936,8 @@ export default class SASjs {
waitForResult?: boolean,
pollOptions?: PollOptions,
printPid = false,
variables?: MacroVar
variables?: MacroVar,
verboseMode?: VerboseMode
) {
config = {
...this.sasjsConfig,
@@ -893,6 +951,11 @@ export default class SASjs {
)
}
if (verboseMode) {
this.requestClient?.setVerboseMode(verboseMode)
this.requestClient?.enableVerboseMode()
} else if (verboseMode === false) this.requestClient?.disableVerboseMode()
return this.sasViyaApiClient?.executeComputeJob(
sasJob,
config.contextName,
@@ -912,6 +975,7 @@ export default class SASjs {
await this.computeJobExecutor?.resendWaitingRequests()
await this.jesJobExecutor?.resendWaitingRequests()
await this.fileUploader?.resendWaitingRequests()
await this.sasjsJobExecutor?.resendWaitingRequests()
}
/**
@@ -985,7 +1049,8 @@ export default class SASjs {
this.requestClient = new RequestClientClass(
this.sasjsConfig.serverUrl,
this.sasjsConfig.httpsAgentOptions,
this.sasjsConfig.requestHistoryLimit
this.sasjsConfig.requestHistoryLimit,
this.sasjsConfig.verbose
)
} else {
this.requestClient.setConfig(
@@ -1149,4 +1214,42 @@ export default class SASjs {
)
}
}
/**
* Enables verbose mode that will log a summary of every HTTP response.
* @param successCallBack - function that should be triggered on every HTTP response with the status 2**.
* @param errorCallBack - function that should be triggered on every HTTP response with the status different from 2**.
*/
public enableVerboseMode(
successCallBack?: (response: AxiosResponse) => AxiosResponse,
errorCallBack?: (response: AxiosError) => AxiosError
) {
this.requestClient?.enableVerboseMode(successCallBack, errorCallBack)
}
/**
* Turns off verbose mode to log every HTTP response.
*/
public disableVerboseMode() {
this.requestClient?.disableVerboseMode()
}
/**
* Sets verbose mode.
* @param verboseMode - value of the verbose mode, can be true, false or bleached(without extra colors).
*/
public setVerboseMode = (verboseMode: VerboseMode) => {
this.requestClient?.setVerboseMode(verboseMode)
}
/**
* Create a tables class containing one or more tables to be sent to
* SAS.
* @param table - initial table data
* @param macroName - macro name
* @returns Tables class
*/
Tables(table: Record<string, any>, macroName: string) {
return new Tables(table, macroName)
}
}

View File

@@ -1,27 +1,52 @@
import NodeFormData from 'form-data'
import { AuthConfig, ServerType, ServicePackSASjs } from '@sasjs/utils/types'
import { prefixMessage } from '@sasjs/utils/error'
import { ExecutionQuery } from './types'
import { RequestClient } from './request/RequestClient'
import { getAccessTokenForSasjs } from './auth/getAccessTokenForSasjs'
import { refreshTokensForSasjs } from './auth/refreshTokensForSasjs'
import { getTokens } from './auth/getTokens'
// TODO: move to sasjs/utils
export interface SASjsAuthResponse {
access_token: string
refresh_token: string
}
export interface ScriptExecutionResult {
log: string
webout?: string
printOutput?: string
}
export class SASjsApiClient {
constructor(private requestClient: RequestClient) {}
private async getAccessTokenForRequest(authConfig?: AuthConfig) {
if (authConfig) {
const { access_token } = await getTokens(
this.requestClient,
authConfig,
ServerType.Sasjs
)
return access_token
}
}
/**
* Creates the folders and services at the given location `appLoc` on the given server `serverUrl`.
* @param dataJson - the JSON specifying the folders and files to be created, can also includes
* appLoc, streamServiceName, streamWebFolder, streamLogo
* @param appLoc - the base folder in which to create the new folders and services.
* @param authConfig - (optional) a valid client, secret, refresh and access tokens that are authorised to execute compute jobs.
*/
public async deploy(
dataJson: ServicePackSASjs,
appLoc: string,
authConfig?: AuthConfig
) {
let access_token = (authConfig || {}).access_token
if (authConfig) {
;({ access_token } = await getTokens(
this.requestClient,
authConfig,
ServerType.Sasjs
))
}
const access_token = await this.getAccessTokenForRequest(authConfig)
dataJson.appLoc = dataJson.appLoc || appLoc
const { result } = await this.requestClient.post<{
@@ -41,6 +66,40 @@ export class SASjsApiClient {
return Promise.resolve(result)
}
/**
* Creates/updates files within SASjs drive using uploaded json compressed file.
* @param zipFilePath - Compressed file path; file should only contain one JSON file and
* should have same name as of compressed file e.g. deploy.JSON should be compressed to deploy.JSON.zip
* Any other file or JSON file in zipped will be ignored!
* @param authConfig - (optional) a valid client, secret, refresh and access tokens that are authorised to execute compute jobs.
*/
public async deployZipFile(zipFilePath: string, authConfig?: AuthConfig) {
const { createReadStream } = require('@sasjs/utils/file')
const access_token = await this.getAccessTokenForRequest(authConfig)
const file = await createReadStream(zipFilePath)
const formData = new NodeFormData()
formData.append('file', file)
const contentType = `multipart/form-data; boundary=${formData.getBoundary()}`
const { result } = await this.requestClient.post<{
status: string
message: string
streamServiceName?: string
example?: {}
}>(
'SASjsApi/drive/deploy/upload',
formData,
access_token,
contentType,
{},
{ maxContentLength: Infinity, maxBodyLength: Infinity }
)
return Promise.resolve(result)
}
public async executeJob(
query: ExecutionQuery,
appLoc: string,
@@ -72,25 +131,28 @@ export class SASjsApiClient {
code: string,
runTime: string = 'sas',
authConfig?: AuthConfig
) {
let access_token = (authConfig || {}).access_token
if (authConfig) {
;({ access_token } = await getTokens(
this.requestClient,
authConfig,
ServerType.Sasjs
))
}
let parsedSasjsServerLog = ''
): Promise<ScriptExecutionResult> {
const access_token = await this.getAccessTokenForRequest(authConfig)
const executionResult: ScriptExecutionResult = { log: '' }
await this.requestClient
.post('SASjsApi/code/execute', { code, runTime }, access_token)
.then((res: any) => {
if (res.log) parsedSasjsServerLog = res.log
const { log, printOutput, result: webout } = res
executionResult.log = log
if (printOutput) executionResult.printOutput = printOutput
if (webout) executionResult.webout = webout
})
.catch((err) => {
throw prefixMessage(
err,
'Error while sending POST request to execute code. '
)
})
return parsedSasjsServerLog
return executionResult
}
/**
@@ -113,9 +175,3 @@ export class SASjsApiClient {
return refreshTokensForSasjs(this.requestClient, refreshToken)
}
}
// todo move to sasjs/utils
export interface SASjsAuthResponse {
access_token: string
refresh_token: string
}

View File

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

View File

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

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