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

Compare commits

...

54 Commits

Author SHA1 Message Date
Trevor Moody
a3c5e985f7 chore: css tidy up preventing redundant scroll bar 2025-11-21 09:37:28 +00:00
Trevor Moody
68e0da8a91 chore: partial viya createFile testing 2025-11-21 09:36:01 +00:00
Trevor Moody
d0aaad024b fix: improved Viya createFile to apply related properties 2025-11-20 13:00:34 +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
108 changed files with 6450 additions and 19949 deletions

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

@@ -21,7 +21,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

View File

@@ -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

View File

@@ -1,13 +1,13 @@
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# 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:
test:
runs-on: ubuntu-22.04
strategy:
@@ -20,11 +20,16 @@ jobs:
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: npm
# FIXME: uncomment 'Check npm audit' step after axios version bump
# - 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
@@ -32,12 +37,6 @@ jobs:
- 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:
@@ -106,9 +105,3 @@ jobs:
echo "SASJS_USERNAME=${{ secrets.SASJS_USERNAME }}"
sh ./sasjs-tests/sasjs-cypress-run.sh ${{ secrets.MATRIX_TOKEN }} https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}
# 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 }}

207
README.md
View File

@@ -3,18 +3,16 @@
[![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:
@@ -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,42 +116,47 @@ 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.
@@ -169,45 +176,46 @@ To execute a script on Viya a session has to be created first which is time-cons
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.
@@ -218,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.
@@ -254,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)
@@ -271,23 +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.
* `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.
- `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.
```
{
@@ -300,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.
@@ -336,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!
@@ -344,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-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)):

View File

@@ -4,93 +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) => {
cy.wait(1000).then(() => {
const startButton = $body.find(
'.ui.massive.icon.primary.left.labeled.button'
)[0]
if (
!startButton ||
(startButton && !Cypress.dom.isVisible(startButton))
) {
cy.get('input[placeholder="User Name"]').type(username)
cy.get('input[placeholder="Password"]').type(password)
cy.get('.submit-button').click()
}
cy.get('input[placeholder="User Name"]', { timeout: 40000 })
.should('not.exist')
.then(() => {
cy.get('.ui.massive.icon.primary.left.labeled.button')
.click()
.then(() => {
cy.get('.ui.massive.loading.primary.button', {
timeout: testingFinishTimeout
})
.should('not.exist')
.then(() => {
cy.get('span.icon.failed')
.should('not.exist')
.then(() => {
done()
})
})
})
})
})
if ($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
}
})
}
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) => {
cy.wait(1000).then(() => {
const startButton = $body.find(
'.ui.massive.icon.primary.left.labeled.button'
)[0]
it('Should have all tests successful with debug on', () => {
loginIfNeeded()
if (
!startButton ||
(startButton && !Cypress.dom.isVisible(startButton))
) {
cy.get('input[placeholder="User Name"]').type(username)
cy.get('input[placeholder="Password"]').type(password)
cy.get('.submit-button').click()
}
cy.get('tests-view')
.shadow()
.find('#debug-toggle')
.should('be.visible')
.click()
cy.get('.ui.fitted.toggle.checkbox label')
.click()
.then(() => {
cy.get('input[placeholder="User Name"]', { timeout: 40000 })
.should('not.exist')
.then(() => {
cy.get('.ui.massive.icon.primary.left.labeled.button')
.click()
.then(() => {
cy.get('.ui.massive.loading.primary.button', {
timeout: testingFinishTimeout
})
.should('not.exist')
.then(() => {
cy.get('span.icon.failed')
.should('not.exist')
.then(() => {
done()
})
})
})
})
})
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')
})
})

Binary file not shown.

View File

@@ -142,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,

3779
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,18 +45,21 @@
"license": "ISC",
"devDependencies": {
"@cypress/webpack-preprocessor": "5.9.1",
"@types/cors": "^2.8.17",
"@types/express": "4.17.13",
"@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.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",
"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.5",
@@ -64,22 +67,22 @@
"process": "0.11.10",
"semantic-release": "19.0.3",
"terser-webpack-plugin": "5.3.6",
"ts-jest": "27.1.3",
"ts-jest": "29.2.6",
"ts-loader": "9.4.0",
"tslint": "6.1.3",
"tslint-config-prettier": "1.18.0",
"typedoc": "0.23.24",
"typedoc-plugin-rename-defaults": "0.6.4",
"typescript": "4.8.3",
"typescript": "4.9.5",
"webpack": "5.76.2",
"webpack-cli": "4.9.2"
},
"main": "index.js",
"dependencies": {
"@sasjs/utils": "^3.5.1",
"axios": "0.27.2",
"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.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.*
# 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
}

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.
@@ -75,8 +141,8 @@ 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)
@@ -90,8 +156,8 @@ 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)
@@ -102,7 +168,7 @@ parmcards4;
set sashelp.vmacro;
run;
%webout(OPEN)
%webout(OBJ,macvars)
%webout(OBJ,macvars)
%webout(CLOSE)
;;;;
%mx_createwebservice(path=&apploc/services/common,name=sendMacVars)
@@ -126,23 +192,64 @@ data _null_;
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)

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

@@ -0,0 +1,39 @@
* {
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__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,47 +1,25 @@
{
"name": "@sasjs/tests",
"version": "1.0.0",
"homepage": ".",
"name": "sasjs-tests-new",
"private": true,
"dependencies": {
"@sasjs/test-framework": "1.5.7",
"@types/jest": "^26.0.20",
"@types/node": "^14.14.41",
"@types/react": "^16.0.1",
"@types/react-dom": "^16.0.0",
"@types/react-router-dom": "^5.1.7",
"react": "^16.0.1",
"react-dom": "^16.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",
"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 ./build/* --delete $SSH_ACCOUNT:$DEPLOY_PATH || npm run deploy:tests-win",
"deploy:tests-win": "scp %DEPLOY_PATH% ./build/*",
"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": "9.0.0"
"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/adapter-tests/services",
"serverType": "SASJS",
"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

@@ -20,7 +20,27 @@
"streamConfig": {
"streamWeb": true,
"streamWebFolder": "webv",
"webSourcePath": "build",
"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()

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

@@ -0,0 +1,142 @@
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'
import { viyaFileTests } from './testSuites/ViyaFile'
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 certain tests for SASVIYA only
if (adapter.getSasjsConfig().serverType === 'SASVIYA') {
testSuites.push(computeTests(adapter, appLoc))
testSuites.push(viyaFileTests(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/adapter-tests/services/common/sendArr',
`${appLoc}/common/sendArr`,
data,
{},
undefined,

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,8 +136,19 @@ export const specialCaseTests = (adapter: SASjs): TestSuite => ({
return adapter.request('common/sendArr', moreSpecialCharData)
},
assertion: (res: any) => {
// If sas session is latin9 we can't process the special characters
if (res.SYSENCODING === 'latin9') return true
// 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 &&
@@ -314,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()
@@ -362,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()
@@ -410,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()
@@ -467,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,31 @@
import SASjs from '@sasjs/adapter'
import type { TestSuite } from '../types'
export const viyaFileTests = (adapter: SASjs, appLoc: string): TestSuite => ({
name: 'SAS Viya File Tests',
tests: [
{
title: 'Create html file',
description: 'Should create an html file with appropriate properties',
test: async () => {
const fileContentBuffer = Buffer.from(
`<html>` +
` <head><title>Test</title></head>` +
` <body><p>This is a test</p></body>` +
`</html>`
)
// generate a timestamp string formatted as YYYYmmDDTHHMMSS_999
const timeMark = new Date()
.toISOString()
.replace(/(\/|:|\s|-|Z)/g, '')
.replace(/\./g, '_')
const filename = `viya_createFile_test_${timeMark}.html`
return adapter.createFile(filename, fileContentBuffer, appLoc)
},
assertion: () => {
//A test that returns a boolean
return true // dummy
}
}
]
})

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: ''
})

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

@@ -1,5 +1,5 @@
import { isRelativePath, isUri, isUrl } from './utils'
import * as NodeFormData from 'form-data'
import NodeFormData from 'form-data'
import {
Job,
Session,
@@ -28,6 +28,7 @@ import { uploadTables } from './api/viya/uploadTables'
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 }
@@ -35,6 +36,59 @@ interface JobExecutionResult {
error?: object
}
interface IViyaTypesResponse {
accept: string
count: number
items: IViyaTypesItem[]
limit: number
links: IViyaTypesLink[]
name: string
start: number
version: number
}
interface IViyaTypesItem {
description?: string
extensions?: string[]
iconUri?: string
label: string
links: IViyaTypesLink[]
mappedTypes?: string[]
mediaType?: string
mediaTypes?: string[]
name: string
pluralLabel?: string
properties?: IViyaTypesProperties
resourceUri?: string
serviceRootUri?: string
tags?: string[]
version: number
}
/**
* Generic structure for a link
* in the links array of a Viya
* types/types api response
*/
type IViyaTypesLink = Record<string, string>
/**
* Generic structure for a type's
* 'properties' object from the Viya
* types/types api response
*/
type IViyaTypesProperties = Record<string, string>
/**
* Arbitrary interface for storing
* sufficient additional detail to
* create and patch a new file.
*/
interface IViyaTypesExtensionInfo {
typeDefName: string
properties: IViyaTypesProperties | undefined
}
/**
* A client for interfacing with the SAS Viya REST API.
*
@@ -61,6 +115,9 @@ export class SASViyaApiClient {
)
private folderMap = new Map<string, Job[]>()
private fileExtensionMap = new Map<string, IViyaTypesExtensionInfo>()
private boolExtensionMap = false // has the fileExtensionMap been populated yet?
/**
* A helper method used to call appendRequest method of RequestClient
* @param response - response from sasjs request
@@ -311,6 +368,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.
@@ -355,14 +490,89 @@ export class SASViyaApiClient {
const formData = new NodeFormData()
formData.append('file', contentBuffer, fileName)
/** Query Viya for file metadata based on extension type.
* Without providing certain properties, some versions of Viya will not
* serve files as intended. Avoid this issue by applying the properties
* that Viya has registered for a file extension.
*/
// typeDefName - Viya should automatically determine this and additional
// properties at runtime if not provided in the file creation request.
let typeDefName: string | undefined = undefined
// Viya update 2025.09 resulted in a change to this automatic behaviour.
// We patch the new file to replicate the behaviour.
let filePatch:
| {
name: string
properties: IViyaTypesProperties | undefined
}
| undefined = undefined
// The patching process requires properties related to the file-extension
const fileExtension: string | undefined = fileName
.split('.')
.pop()
?.toLowerCase()
if (fileExtension) {
if (!this.boolExtensionMap) {
// Populate the file extension map
// 1. Get Viya's response to this api call
const typesQueryUrl = `/types/types?limit=999999`
const response = (
await this.requestClient.get(typesQueryUrl, accessToken)
).result as IViyaTypesResponse
// 2. Filter the returned items that have file extensions into a map
// using forEach as an item may relate to multiple file extensions.
response.items
.filter((e) => e.extensions)
.forEach((e) => {
e.extensions?.forEach((ext) => {
this.fileExtensionMap.set(ext, {
typeDefName: e.name, // "name:" is the typeDefName value required for file creation.
properties: e.properties
})
})
})
// 3. Toggle the flag to avoid repeating this step
this.boolExtensionMap = true
}
const fileExtInfo = this.fileExtensionMap.get(fileExtension)
if (fileExtInfo) {
typeDefName = fileExtInfo.typeDefName
if (fileExtInfo.properties)
filePatch = { name: fileName, properties: fileExtInfo.properties }
}
}
return (
await this.requestClient.post<File>(
`/files/files?parentFolderUri=${parentFolderUri}&typeDefName=file#rawUpload`,
formData,
accessToken,
'multipart/form-data; boundary=' + (formData as any)._boundary,
headers
)
await this.requestClient
.post<File>(
`/files/files?parentFolderUri=${parentFolderUri}&typeDefName=${
typeDefName ?? 'file'
}#rawUpload`,
formData,
accessToken,
'multipart/form-data; boundary=' + (formData as any)._boundary,
headers
)
.then(async (res) => {
// If a patch was created...
if (filePatch) {
// Get the URI of the newly created file
const fileUri = res.result.links.filter(
(e) => e.method == 'PATCH' && e.rel == 'patch'
)[0].uri
// and apply the patch
return await this.requestClient.patch<File>(
`${fileUri}`,
filePatch,
accessToken
)
}
return res
})
).result
}
@@ -791,14 +1001,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
}
@@ -941,6 +1151,7 @@ export class SASViyaApiClient {
})
if (!folder) return undefined
return folder
}
@@ -952,6 +1163,30 @@ export class SASViyaApiClient {
return `/folders/folders/${folderDetails.id}`
}
private async getFileUri(
folderPath: string,
fileName: string,
accessToken?: string
): Promise<string> {
const folderMembers = await this.listFolder(folderPath, accessToken, 1000, {
returnDetails: true
}).catch((err) => {
throw prefixMessage(err, `Error while listing folder: ${folderPath}. `)
})
if (!folderMembers || !folderMembers.length)
throw new Error(`No members found in folder: ${folderPath}`)
const fileUri = folderMembers.find(
(member) => member.name === fileName
)?.uri
if (!fileUri)
throw new Error(`File ${fileName} not found in folder: ${folderPath}`)
return fileUri
}
private async getRecycleBinUri(accessToken?: string) {
const url = '/folders/folders/@myRecycleBin'
@@ -999,14 +1234,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)
@@ -1018,11 +1258,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
}
/**

View File

@@ -9,7 +9,8 @@ import {
ErrorResponse,
LoginOptions,
LoginResult,
ExecutionQuery
ExecutionQuery,
Tables
} from './types'
import { SASViyaApiClient } from './SASViyaApiClient'
import { SAS9ApiClient } from './SAS9ApiClient'
@@ -411,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.
@@ -436,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
}
)
}
@@ -1170,8 +1221,8 @@ export default class SASjs {
* @param errorCallBack - function that should be triggered on every HTTP response with the status different from 2**.
*/
public enableVerboseMode(
successCallBack?: (response: AxiosResponse | AxiosError) => AxiosResponse,
errorCallBack?: (response: AxiosResponse | AxiosError) => AxiosResponse
successCallBack?: (response: AxiosResponse) => AxiosResponse,
errorCallBack?: (response: AxiosError) => AxiosError
) {
this.requestClient?.enableVerboseMode(successCallBack, errorCallBack)
}
@@ -1190,4 +1241,15 @@ export default class SASjs {
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,4 +1,4 @@
import * as NodeFormData from 'form-data'
import NodeFormData from 'form-data'
import { AuthConfig, ServerType, ServicePackSASjs } from '@sasjs/utils/types'
import { prefixMessage } from '@sasjs/utils/error'
import { ExecutionQuery } from './types'

View File

@@ -69,5 +69,5 @@ const setupMocks = () => {
.mockImplementation(() => Promise.resolve('Test Log'))
jest
.spyOn(writeStreamModule, 'writeStream')
.mockImplementation(() => Promise.resolve())
.mockImplementation(() => Promise.resolve(true))
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import * as NodeFormData from 'form-data'
import NodeFormData from 'form-data'
import {
AuthConfig,
ExtraResponseAttributes,
@@ -73,8 +73,10 @@ export class SasjsJobExecutor extends BaseJobExecutor {
/* The NodeFormData object does not set the request header - so, set it */
const contentType =
formData instanceof NodeFormData && typeof FormData === 'undefined'
? `multipart/form-data; boundary=${formData.getBoundary()}`
: undefined
? `multipart/form-data; boundary=${
formData.getHeaders()['content-type']
}`
: 'multipart/form-data'
const requestPromise = new Promise((resolve, reject) => {
this.requestClient!.post(

View File

@@ -1,4 +1,4 @@
import * as NodeFormData from 'form-data'
import NodeFormData from 'form-data'
import {
AuthConfig,
ExtraResponseAttributes,
@@ -150,8 +150,10 @@ export class WebJobExecutor extends BaseJobExecutor {
/* The NodeFormData object does not set the request header - so, set it */
const contentType =
formData instanceof NodeFormData && typeof FormData === 'undefined'
? `multipart/form-data; boundary=${formData.getBoundary()}`
: undefined
? `multipart/form-data; boundary=${
formData.getHeaders()['content-type']
}`
: 'multipart/form-data'
const requestPromise = new Promise((resolve, reject) => {
this.requestClient!.post(

View File

@@ -2,9 +2,9 @@ import {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosRequestHeaders,
AxiosResponse
} from 'axios'
import axios from 'axios'
import * as https from 'https'
import { CsrfToken } from '..'
import { isAuthorizeFormRequired, isLogInRequired } from '../auth'
@@ -160,7 +160,7 @@ export class RequestClient implements HttpClient {
const requestConfig: AxiosRequestConfig = {
headers,
responseType: contentType === 'text/plain' ? 'text' : 'json',
withCredentials: true
withXSRFToken: true
}
if (contentType === 'text/plain') {
@@ -191,6 +191,13 @@ export class RequestClient implements HttpClient {
})
}
/**
* @param contentType Newer version of Axios is more strict so if you don't
* set the contentType to `form data` while sending a FormData object
* application/json will be used by default, axios wont treat it as FormData.
* Instead, it serializes data as JSON—resulting in a payload like
* {"sometable":{}} and we lose the multipart/form-data formatting.
*/
public async post<T>(
url: string,
data: any,
@@ -207,7 +214,7 @@ export class RequestClient implements HttpClient {
return this.httpClient
.post<T>(url, data, {
headers,
withCredentials: true,
withXSRFToken: true,
...additionalSettings
})
.then((response) => {
@@ -234,7 +241,7 @@ export class RequestClient implements HttpClient {
}
return this.httpClient
.put<T>(url, data, { headers, withCredentials: true })
.put<T>(url, data, { headers, withXSRFToken: true })
.then((response) => {
throwIfError(response)
return this.parseResponse<T>(response)
@@ -253,7 +260,7 @@ export class RequestClient implements HttpClient {
const headers = this.getHeaders(accessToken, 'application/json')
return this.httpClient
.delete<T>(url, { headers, withCredentials: true })
.delete<T>(url, { headers, withXSRFToken: true })
.then((response) => {
throwIfError(response)
return this.parseResponse<T>(response)
@@ -271,7 +278,7 @@ export class RequestClient implements HttpClient {
const headers = this.getHeaders(accessToken, 'application/json')
return this.httpClient
.patch<T>(url, data, { headers, withCredentials: true })
.patch<T>(url, data, { headers, withXSRFToken: true })
.then((response) => {
throwIfError(response)
return this.parseResponse<T>(response)
@@ -413,95 +420,17 @@ export class RequestClient implements HttpClient {
return bodyLines.join('\n')
}
private defaultInterceptionCallBack = (
axiosResponse: AxiosResponse | AxiosError
) => {
// Message indicating absent value.
const noValueMessage = 'Not provided'
private handleAxiosResponse = (response: AxiosResponse) => {
const { status, config, request, data } = response
// Fallback request object that can be safely used to form request summary.
type FallbackRequest = { _header?: string; res: { rawHeaders: string[] } }
// _header is not present in responses with status 1**
// rawHeaders are not present in responses with status 1**
let fallbackRequest: FallbackRequest = {
_header: `${noValueMessage}\n`,
res: { rawHeaders: [noValueMessage] }
}
const reqHeaders = request?._header ?? 'Not provided\n'
const rawHeaders = request?.res?.rawHeaders ?? ['Not provided']
// Fallback response object that can be safely used to form response summary.
type FallbackResponse = {
status?: number | string
request?: FallbackRequest
config: { data?: string }
data?: unknown
}
let fallbackResponse: FallbackResponse = axiosResponse
const resHeaders = this.formatHeaders(rawHeaders)
const parsedResBody = this.parseInterceptedBody(data)
if (axios.isAxiosError(axiosResponse)) {
const { response, request, config } = axiosResponse
// Try to use axiosResponse.response to form response summary.
if (response) {
fallbackResponse = response
} else {
// Try to use axiosResponse.request to form request summary.
if (request) {
const { _header, _currentRequest } = request
// Try to use axiosResponse.request._header to form request summary.
if (_header) {
fallbackRequest._header = _header
}
// Try to use axiosResponse.request._currentRequest._header to form request summary.
else if (_currentRequest && _currentRequest._header) {
fallbackRequest._header = _currentRequest._header
}
const { res } = request
// Try to use axiosResponse.request.res.rawHeaders to form request summary.
if (res && res.rawHeaders) {
fallbackRequest.res.rawHeaders = res.rawHeaders
}
}
// Fallback config that can be safely used to form response summary.
const fallbackConfig = { data: noValueMessage }
fallbackResponse = {
status: noValueMessage,
request: fallbackRequest,
config: config || fallbackConfig,
data: noValueMessage
}
}
}
const { status, config, request, data: resData } = fallbackResponse
const { data: reqData } = config
const { _header: reqHeaders, res } = request || fallbackRequest
const { rawHeaders } = res
// Converts an array of strings into a single string with the following format:
// <headerName>: <headerValue>
const resHeaders = rawHeaders.reduce(
(acc: string, value: string, i: number) => {
if (i % 2 === 0) {
acc += `${i === 0 ? '' : '\n'}${value}`
} else {
acc += `: ${value}`
}
return acc
},
''
)
const parsedResBody = this.parseInterceptedBody(resData)
// HTTP response summary.
process.logger?.info(`HTTP Request (first 50 lines):
${reqHeaders}${this.parseInterceptedBody(reqData)}
${reqHeaders}${this.parseInterceptedBody(config.data)}
HTTP Response Code: ${this.prettifyString(status)}
@@ -509,7 +438,70 @@ HTTP Response (first 50 lines):
${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
`)
return axiosResponse
return response
}
private handleAxiosError = (error: AxiosError) => {
// Message indicating absent value.
const noValueMessage = 'Not provided'
const { response, request, config } = error
// Fallback request object that can be safely used to form request summary.
// _header is not present in responses with status 1**
// rawHeaders are not present in responses with status 1**
let fallbackRequest = {
_header: `${noValueMessage}\n`,
res: { rawHeaders: [noValueMessage] }
}
if (request) {
fallbackRequest = {
_header:
request._header ?? request._currentRequest?._header ?? noValueMessage,
res: { rawHeaders: request.res?.rawHeaders ?? [noValueMessage] }
}
}
// Fallback response object that can be safely used to form response summary.
let fallbackResponse = response || {
status: noValueMessage,
request: fallbackRequest,
config: config || {
data: noValueMessage,
headers: {} as AxiosRequestHeaders
},
data: noValueMessage
}
const { status, request: req, data: resData } = fallbackResponse
const { _header: reqHeaders, res } = req
const resHeaders = this.formatHeaders(res.rawHeaders)
const parsedResBody = this.parseInterceptedBody(resData)
process.logger?.info(`HTTP Request (first 50 lines):
${reqHeaders}${this.parseInterceptedBody(config?.data)}
HTTP Response Code: ${this.prettifyString(status)}
HTTP Response (first 50 lines):
${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
`)
return error
}
// Converts an array of strings into a single string with the following format:
// <headerName>: <headerValue>
private formatHeaders = (rawHeaders: string[]): string => {
return rawHeaders.reduce((acc, value, i) => {
if (i % 2 === 0) {
acc += `${i === 0 ? '' : '\n'}${value}`
} else {
acc += `: ${value}`
}
return acc
}, '')
}
/**
@@ -529,8 +521,8 @@ ${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
* @param errorCallBack - function that should be triggered on every HTTP response with the status different from 2**.
*/
public enableVerboseMode = (
successCallBack = this.defaultInterceptionCallBack,
errorCallBack = this.defaultInterceptionCallBack
successCallBack = this.handleAxiosResponse,
errorCallBack = this.handleAxiosError
) => {
this.httpInterceptor = this.httpClient.interceptors.response.use(
successCallBack,
@@ -645,7 +637,7 @@ ${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
// Fetching root and creating CSRF cookie
await this.httpClient
.get('/', {
withCredentials: true
withXSRFToken: true
})
.then((response) => {
const cookie =

View File

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

View File

@@ -1,6 +1,6 @@
import { SASJS_LOGS_SEPARATOR, SasjsRequestClient } from '../SasjsRequestClient'
import { SasjsParsedResponse } from '../../types'
import { AxiosResponse } from 'axios'
import { AxiosRequestHeaders, AxiosResponse } from 'axios'
describe('SasjsRequestClient', () => {
const requestClient = new SasjsRequestClient('')
@@ -37,7 +37,9 @@ ${SASJS_LOGS_SEPARATOR}`,
status,
statusText: 'ok',
headers: { etag },
config: {}
config: {
headers: {} as AxiosRequestHeaders
}
}
const expectedParsedResponse: SasjsParsedResponse<string> = {
@@ -65,7 +67,9 @@ ${printOutput}`,
status,
statusText: 'ok',
headers: { etag },
config: {}
config: {
headers: {} as AxiosRequestHeaders
}
}
const expectedParsedResponse: SasjsParsedResponse<string> = {
@@ -100,7 +104,9 @@ ${SASJS_LOGS_SEPARATOR}`,
status,
statusText: 'ok',
headers: { etag },
config: {}
config: {
headers: {} as AxiosRequestHeaders
}
}
const expectedParsedResponse: SasjsParsedResponse<string> = {
@@ -139,7 +145,9 @@ ${printOutput}`,
status,
statusText: 'ok',
headers: { etag },
config: {}
config: {
headers: {} as AxiosRequestHeaders
}
}
const expectedParsedResponse: SasjsParsedResponse<string> = {

View File

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

View File

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

View File

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

View File

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

33
src/types/FileResource.ts Normal file
View File

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

28
src/types/Tables.spec.ts Normal file
View File

@@ -0,0 +1,28 @@
import SASjs from '../SASjs'
describe('Tables - basic coverage', () => {
const adapter = new SASjs()
it('should throw an error if first argument is not an array', () => {
expect(() => adapter.Tables({}, 'test')).toThrow('First argument')
})
it('should throw an error if second argument is not a string', () => {
// @ts-expect-error
expect(() => adapter.Tables([], 1234)).toThrow('Second argument')
})
it('should throw an error if macro name ends with a number', () => {
expect(() => adapter.Tables([], 'test1')).toThrow('number at the end')
})
it('should throw an error if no arguments are passed', () => {
// @ts-expect-error
expect(() => adapter.Tables()).toThrow('Missing arguments')
})
it('should create Tables class successfully with _tables property', () => {
const tables = adapter.Tables([], 'test')
expect(tables).toHaveProperty('_tables')
})
})

29
src/types/Tables.ts Normal file
View File

@@ -0,0 +1,29 @@
import { ArgumentError } from './errors'
export class Tables {
_tables: { [macroName: string]: Record<string, any> }
constructor(table: Record<string, any>, macroName: string) {
this._tables = {}
this.add(table, macroName)
}
add(table: Record<string, any> | null, macroName: string) {
if (table && macroName) {
if (!(table instanceof Array)) {
throw new ArgumentError('First argument must be array')
}
if (typeof macroName !== 'string') {
throw new ArgumentError('Second argument must be string')
}
if (!isNaN(Number(macroName[macroName.length - 1]))) {
throw new ArgumentError('Macro name cannot have number at the end')
}
} else {
throw new ArgumentError('Missing arguments')
}
this._tables[macroName] = table
}
}

View File

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

View File

@@ -0,0 +1,7 @@
export class ArgumentError extends Error {
constructor(public message: string) {
super(message)
this.name = 'ArgumentError'
Object.setPrototypeOf(this, ArgumentError.prototype)
}
}

View File

@@ -1,3 +1,4 @@
export * from './ArgumentError'
export * from './AuthorizeError'
export * from './CertificateError'
export * from './ComputeJobExecutionError'

View File

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

View File

@@ -15,3 +15,4 @@ export * from './PollOptions'
export * from './WriteStream'
export * from './ExecuteScript'
export * from './errors'
export * from './Tables'

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