mirror of
https://github.com/sasjs/adapter.git
synced 2025-12-11 09:24:35 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03b7592521 | ||
|
|
ba64ed1f20 | ||
|
|
480510b980 | ||
|
|
8c7767a36d | ||
|
|
f335be344e | ||
|
|
680f5a4872 | ||
|
|
c42a20a8ee | ||
|
|
87b60a4a21 | ||
|
|
07e4ba54f3 | ||
|
|
6f73011bc1 | ||
|
|
f26d51747f | ||
|
|
1f8554f925 | ||
|
|
0d871083ac | ||
|
|
ae71918ae2 | ||
|
|
364a063a11 | ||
|
|
ad4c9b2164 | ||
|
|
59198ed6ab | ||
|
|
79e5acb954 | ||
|
|
1eb5b29a77 | ||
|
|
bde28046be | ||
|
|
eab61a80bf | ||
|
|
9149f932c3 | ||
|
|
fb30ff8876 | ||
|
|
afff422333 | ||
|
|
b09a8b0891 |
215
.github/workflows/server-tests.yml
vendored
215
.github/workflows/server-tests.yml
vendored
@@ -1,107 +1,108 @@
|
||||
# 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 Server Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
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: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Rimraf
|
||||
run: npm i rimraf
|
||||
|
||||
- name: Build Package
|
||||
run: npm run package:lib
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Write VPN Files
|
||||
run: |
|
||||
echo "$CA_CRT" > .github/vpn/ca.crt
|
||||
echo "$USER_CRT" > .github/vpn/user.crt
|
||||
echo "$USER_KEY" > .github/vpn/user.key
|
||||
echo "$TLS_KEY" > .github/vpn/tls.key
|
||||
shell: bash
|
||||
env:
|
||||
CA_CRT: ${{ secrets.CA_CRT}}
|
||||
USER_CRT: ${{ secrets.USER_CRT }}
|
||||
USER_KEY: ${{ secrets.USER_KEY }}
|
||||
TLS_KEY: ${{ secrets.TLS_KEY }}
|
||||
|
||||
- name: Chmod VPN files
|
||||
run: |
|
||||
chmod 600 .github/vpn/ca.crt .github/vpn/user.crt .github/vpn/user.key .github/vpn/tls.key
|
||||
|
||||
- name: Install Open VPN
|
||||
run: |
|
||||
sudo apt install apt-transport-https
|
||||
sudo wget https://swupdate.openvpn.net/repos/openvpn-repo-pkg-key.pub
|
||||
sudo apt-key add openvpn-repo-pkg-key.pub
|
||||
sudo wget -O /etc/apt/sources.list.d/openvpn3.list https://swupdate.openvpn.net/community/openvpn3/repos/openvpn3-jammy.list
|
||||
sudo apt update
|
||||
sudo apt install openvpn3=17~betaUb22042+jammy
|
||||
|
||||
- name: Start Open VPN 3
|
||||
run: openvpn3 session-start --config .github/vpn/config.ovpn
|
||||
|
||||
- name: install pm2
|
||||
run: npm i -g pm2
|
||||
|
||||
- name: Fetch SASJS server
|
||||
run: curl ${{ secrets.SASJS_SERVER_URL }}/SASjsApi/info
|
||||
|
||||
- name: Deploy sasjs-tests
|
||||
run: |
|
||||
npm install -g replace-in-files-cli
|
||||
cd sasjs-tests
|
||||
replace-in-files --regex='"@sasjs/adapter".*' --replacement='"@sasjs/adapter":"latest",' ./package.json
|
||||
npm i
|
||||
replace-in-files --regex='"serverUrl".*' --replacement='"serverUrl":"${{ secrets.SASJS_SERVER_URL }}",' ./public/config.json
|
||||
replace-in-files --regex='"userName".*' --replacement='"userName":"${{ secrets.SASJS_USERNAME }}",' ./public/config.json
|
||||
replace-in-files --regex='"serverType".*' --replacement='"serverType":"SASJS",' ./public/config.json
|
||||
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./public/config.json
|
||||
cat ./public/config.json
|
||||
|
||||
npm run update:adapter
|
||||
pm2 start --name sasjs-test npm -- start
|
||||
|
||||
- name: Sleep for 10 seconds
|
||||
run: sleep 10s
|
||||
shell: bash
|
||||
|
||||
- name: Run cypress on sasjs
|
||||
run: |
|
||||
replace-in-files --regex='"sasjsTestsUrl".*' --replacement='"sasjsTestsUrl":"http://localhost:3000",' ./cypress.json
|
||||
replace-in-files --regex='"username".*' --replacement='"username":"${{ secrets.SASJS_USERNAME }}",' ./cypress.json
|
||||
replace-in-files --regex='"password".*' --replacement='"password":"${{ secrets.SASJS_PASSWORD }}",' ./cypress.json
|
||||
cat ./cypress.json
|
||||
echo "SASJS_USERNAME=${{ secrets.SASJS_USERNAME }}"
|
||||
|
||||
sh ./sasjs-tests/sasjs-cypress-run.sh ${{ secrets.MATRIX_TOKEN }} https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}
|
||||
# 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 Server Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
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: Install Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Rimraf
|
||||
run: npm i rimraf
|
||||
|
||||
- name: Build Package
|
||||
run: npm run package:lib
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- name: Write VPN Files
|
||||
run: |
|
||||
echo "$CA_CRT" > .github/vpn/ca.crt
|
||||
echo "$USER_CRT" > .github/vpn/user.crt
|
||||
echo "$USER_KEY" > .github/vpn/user.key
|
||||
echo "$TLS_KEY" > .github/vpn/tls.key
|
||||
shell: bash
|
||||
env:
|
||||
CA_CRT: ${{ secrets.CA_CRT}}
|
||||
USER_CRT: ${{ secrets.USER_CRT }}
|
||||
USER_KEY: ${{ secrets.USER_KEY }}
|
||||
TLS_KEY: ${{ secrets.TLS_KEY }}
|
||||
|
||||
- name: Chmod VPN files
|
||||
run: |
|
||||
chmod 600 .github/vpn/ca.crt .github/vpn/user.crt .github/vpn/user.key .github/vpn/tls.key
|
||||
|
||||
- name: Install Open VPN
|
||||
run: |
|
||||
sudo apt install apt-transport-https
|
||||
sudo wget https://swupdate.openvpn.net/repos/openvpn-repo-pkg-key.pub
|
||||
sudo apt-key add openvpn-repo-pkg-key.pub
|
||||
sudo wget -O /etc/apt/sources.list.d/openvpn3.list https://swupdate.openvpn.net/community/openvpn3/repos/openvpn3-jammy.list
|
||||
sudo apt update
|
||||
sudo apt install openvpn3=17~betaUb22042+jammy
|
||||
|
||||
- name: Start Open VPN 3
|
||||
run: openvpn3 session-start --config .github/vpn/config.ovpn
|
||||
|
||||
- name: install pm2
|
||||
run: npm i -g pm2
|
||||
|
||||
- name: Fetch SASJS server
|
||||
run: curl ${{ secrets.SASJS_SERVER_URL }}/SASjsApi/info
|
||||
|
||||
- name: Deploy sasjs-tests
|
||||
run: |
|
||||
sudo apt install jq
|
||||
cd sasjs-tests
|
||||
jq '.dependencies."@sasjs/adapter" |= "latest"' ./package.json > ./package.temp && mv ./package.temp ./package.json
|
||||
npm i
|
||||
jq '.sasJsConfig.serverUrl |= "${{ secrets.SASJS_SERVER_URL }}"' ./public/config.json > ./public/config.temp && mv ./public/config.temp ./public/config.json
|
||||
jq '.sasJsConfig.serverType |= "SASJS"' ./public/config.json > ./public/config.temp && mv ./public/config.temp ./public/config.json
|
||||
jq '.userName |= "${{ secrets.SASJS_USERNAME }}"' ./public/config.json > ./public/config.temp && mv ./public/config.temp ./public/config.json
|
||||
jq '.password |= "${{ secrets.SASJS_PASSWORD }}"' ./public/config.json > ./public/config.temp && mv ./public/config.temp ./public/config.json
|
||||
|
||||
cat ./public/config.json
|
||||
|
||||
npm run update:adapter
|
||||
pm2 start --name sasjs-test npm -- start
|
||||
|
||||
- name: Sleep for 10 seconds
|
||||
run: sleep 10s
|
||||
shell: bash
|
||||
|
||||
- name: Run cypress on sasjs
|
||||
run: |
|
||||
jq '.env.sasjsTestsUrl |= "http://localhost:3000"' ./cypress.json > ./cypress.temp && mv ./cypress.temp ./cypress.json
|
||||
jq '.env.username |= "${{ secrets.SASJS_USERNAME }}"' ./cypress.json > ./cypress.temp && mv ./cypress.temp ./cypress.json
|
||||
jq '.env.password |= "${{ secrets.SASJS_PASSWORD }}"' ./cypress.json > ./cypress.temp && mv ./cypress.temp ./cypress.json
|
||||
cat ./cypress.json
|
||||
echo "SASJS_USERNAME=${{ secrets.SASJS_USERNAME }}"
|
||||
|
||||
sh ./sasjs-tests/sasjs-cypress-run.sh ${{ secrets.MATRIX_TOKEN }} https://github.com/${{github.repository}}/actions/runs/${{github.run_id}}
|
||||
|
||||
207
README.md
207
README.md
@@ -3,18 +3,16 @@
|
||||
[![npm package][npm-image]][npm-url]
|
||||
[![Github Workflow][githubworkflow-image]][githubworkflow-url]
|
||||
[]()
|
||||

|
||||
[](/LICENSE)
|
||||

|
||||

|
||||

|
||||
[](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!
|
||||

|
||||
|
||||
## Contributors ✨
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
|
||||
[](#contributors-)
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
||||
|
||||
@@ -14,15 +14,23 @@ context('sasjs-tests', function () {
|
||||
|
||||
function loginIfNeeded() {
|
||||
cy.get('body').then(($body) => {
|
||||
if ($body.find('input[placeholder="User Name"]').length > 0) {
|
||||
cy.get('input[placeholder="User Name"]')
|
||||
if ($body.find('login-form').length > 0) {
|
||||
cy.get('login-form')
|
||||
.shadow()
|
||||
.find('#username')
|
||||
.should('be.visible')
|
||||
.type(username)
|
||||
cy.get('input[placeholder="Password"]')
|
||||
cy.get('login-form')
|
||||
.shadow()
|
||||
.find('#password')
|
||||
.should('be.visible')
|
||||
.type(password)
|
||||
cy.get('.submit-button').should('be.visible').click()
|
||||
cy.get('input[placeholder="User Name"]').should('not.exist') // Wait for login to finish
|
||||
cy.get('login-form')
|
||||
.shadow()
|
||||
.find('#submit-btn')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
cy.get('login-form').should('not.exist') // Wait for login to finish
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -30,30 +38,36 @@ context('sasjs-tests', function () {
|
||||
it('Should have all tests successful', () => {
|
||||
loginIfNeeded()
|
||||
|
||||
cy.get('.ui.massive.icon.primary.left.labeled.button')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
cy.get('tests-view').shadow().find('#run-btn').should('be.visible').click()
|
||||
|
||||
cy.get('.ui.massive.loading.primary.button', {
|
||||
timeout: testingFinishTimeout
|
||||
}).should('not.exist')
|
||||
cy.get('tests-view')
|
||||
.shadow()
|
||||
.find('#run-btn:disabled', {
|
||||
timeout: testingFinishTimeout
|
||||
})
|
||||
.should('not.exist')
|
||||
|
||||
cy.get('span.icon.failed').should('not.exist')
|
||||
cy.get('test-card').shadow().find('.status-icon.failed').should('not.exist')
|
||||
})
|
||||
|
||||
it('Should have all tests successful with debug on', () => {
|
||||
loginIfNeeded()
|
||||
|
||||
cy.get('.ui.fitted.toggle.checkbox label').should('be.visible').click()
|
||||
|
||||
cy.get('.ui.massive.icon.primary.left.labeled.button')
|
||||
cy.get('tests-view')
|
||||
.shadow()
|
||||
.find('#debug-toggle')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
|
||||
cy.get('.ui.massive.loading.primary.button', {
|
||||
timeout: testingFinishTimeout
|
||||
}).should('not.exist')
|
||||
cy.get('tests-view').shadow().find('#run-btn').should('be.visible').click()
|
||||
|
||||
cy.get('span.icon.failed').should('not.exist')
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -9,7 +9,7 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@sasjs/utils": "3.5.2",
|
||||
"axios": "1.8.2",
|
||||
"axios": "1.12.2",
|
||||
"axios-cookiejar-support": "5.0.5",
|
||||
"form-data": "4.0.4",
|
||||
"https": "1.0.0",
|
||||
@@ -3510,13 +3510,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
|
||||
"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"@sasjs/utils": "3.5.2",
|
||||
"axios": "1.8.2",
|
||||
"axios": "1.12.2",
|
||||
"axios-cookiejar-support": "5.0.5",
|
||||
"form-data": "4.0.4",
|
||||
"https": "1.0.0",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
SKIP_PREFLIGHT_CHECK=true
|
||||
# Removes index.html inline scripts
|
||||
INLINE_RUNTIME_CHUNK=false
|
||||
40
sasjs-tests/.gitignore
vendored
40
sasjs-tests/.gitignore
vendored
@@ -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
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
// craco.config.js
|
||||
// We use craco instead of react-scripts so we can override webpack config, to include source maps
|
||||
// so we can debug @sasjs/adapter easier when tests fail
|
||||
module.exports = {
|
||||
webpack: {
|
||||
configure: (webpackConfig, { env }) => {
|
||||
// Disable optimizations in both development and production
|
||||
webpackConfig.optimization.minimize = false;
|
||||
webpackConfig.optimization.minimizer = [];
|
||||
webpackConfig.optimization.concatenateModules = false;
|
||||
webpackConfig.optimization.splitChunks = { cacheGroups: { default: false } };
|
||||
return webpackConfig;
|
||||
}
|
||||
}
|
||||
};
|
||||
39
sasjs-tests/index.css
Normal file
39
sasjs-tests/index.css
Normal 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
14
sasjs-tests/index.html
Normal 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>
|
||||
26354
sasjs-tests/package-lock.json
generated
26354
sasjs-tests/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,50 +1,25 @@
|
||||
{
|
||||
"name": "@sasjs/tests",
|
||||
"version": "1.0.0",
|
||||
"homepage": ".",
|
||||
"name": "sasjs-tests-new",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@sasjs/adapter": "file:../build/sasjs-adapter-5.0.0.tgz",
|
||||
"@sasjs/test-framework": "1.5.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": "4.0.3",
|
||||
"typescript": "^4.1.3"
|
||||
},
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "NODE_OPTIONS=--openssl-legacy-provider react-scripts start",
|
||||
"build": "NODE_OPTIONS=--openssl-legacy-provider craco 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": {
|
||||
"@craco/craco": "6.4.3",
|
||||
"node-sass": "9.0.0",
|
||||
"source-map-loader": "0.2.4"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -20,7 +20,7 @@
|
||||
"streamConfig": {
|
||||
"streamWeb": true,
|
||||
"streamWebFolder": "webv",
|
||||
"webSourcePath": "build",
|
||||
"webSourcePath": "dist",
|
||||
"streamServiceName": "adapter-tests",
|
||||
"assetPaths": []
|
||||
}
|
||||
@@ -40,7 +40,7 @@
|
||||
"streamConfig": {
|
||||
"streamWeb": true,
|
||||
"streamWebFolder": "webv",
|
||||
"webSourcePath": "build",
|
||||
"webSourcePath": "dist",
|
||||
"streamServiceName": "adapter-tests",
|
||||
"assetPaths": []
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
65
sasjs-tests/src/components/LoginForm.css
Normal file
65
sasjs-tests/src/components/LoginForm.css
Normal 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;
|
||||
}
|
||||
93
sasjs-tests/src/components/LoginForm.ts
Normal file
93
sasjs-tests/src/components/LoginForm.ts
Normal 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)
|
||||
193
sasjs-tests/src/components/RequestsModal.css
Normal file
193
sasjs-tests/src/components/RequestsModal.css
Normal 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;
|
||||
}
|
||||
180
sasjs-tests/src/components/RequestsModal.ts
Normal file
180
sasjs-tests/src/components/RequestsModal.ts
Normal 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)
|
||||
126
sasjs-tests/src/components/TestCard.css
Normal file
126
sasjs-tests/src/components/TestCard.css
Normal 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;
|
||||
}
|
||||
}
|
||||
113
sasjs-tests/src/components/TestCard.ts
Normal file
113
sasjs-tests/src/components/TestCard.ts
Normal 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)
|
||||
34
sasjs-tests/src/components/TestSuite.css
Normal file
34
sasjs-tests/src/components/TestSuite.css
Normal 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;
|
||||
}
|
||||
117
sasjs-tests/src/components/TestSuite.ts
Normal file
117
sasjs-tests/src/components/TestSuite.ts
Normal 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)
|
||||
104
sasjs-tests/src/components/TestsView.css
Normal file
104
sasjs-tests/src/components/TestsView.css
Normal 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%;
|
||||
}
|
||||
163
sasjs-tests/src/components/TestsView.ts
Normal file
163
sasjs-tests/src/components/TestsView.ts
Normal 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)
|
||||
5
sasjs-tests/src/components/index.ts
Normal file
5
sasjs-tests/src/components/index.ts
Normal 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'
|
||||
14
sasjs-tests/src/config/loader.ts
Normal file
14
sasjs-tests/src/config/loader.ts
Normal 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()
|
||||
}
|
||||
60
sasjs-tests/src/core/AppContext.ts
Normal file
60
sasjs-tests/src/core/AppContext.ts
Normal 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()
|
||||
173
sasjs-tests/src/core/TestRunner.ts
Normal file
173
sasjs-tests/src/core/TestRunner.ts
Normal 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
|
||||
}
|
||||
}
|
||||
3
sasjs-tests/src/core/index.ts
Normal file
3
sasjs-tests/src/core/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './runTest'
|
||||
export * from './TestRunner'
|
||||
export * from './AppContext'
|
||||
30
sasjs-tests/src/core/runTest.ts
Normal file
30
sasjs-tests/src/core/runTest.ts
Normal 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 }
|
||||
})
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { Route, HashRouter, Switch } from 'react-router-dom'
|
||||
import './index.scss'
|
||||
import * as serviceWorker from './serviceWorker'
|
||||
import { AppProvider } from '@sasjs/test-framework'
|
||||
import PrivateRoute from './PrivateRoute'
|
||||
import Login from './Login'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.render(
|
||||
<AppProvider>
|
||||
<HashRouter>
|
||||
<Switch>
|
||||
<PrivateRoute exact path="/" component={App} />
|
||||
<Route exact path="/login" component={Login} />
|
||||
</Switch>
|
||||
</HashRouter>
|
||||
</AppProvider>,
|
||||
document.getElementById('root')
|
||||
)
|
||||
|
||||
// If you want your app to work offline and load faster, you can change
|
||||
// unregister() to register() below. Note this comes with some pitfalls.
|
||||
// Learn more about service workers: https://bit.ly/CRA-PWA
|
||||
serviceWorker.unregister()
|
||||
140
sasjs-tests/src/main.ts
Normal file
140
sasjs-tests/src/main.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import * as SASjsModule from '@sasjs/adapter'
|
||||
const SASjsImport = (SASjsModule as any).default || SASjsModule
|
||||
const SASjs = SASjsImport.default
|
||||
|
||||
import { appContext } from './core/AppContext'
|
||||
import { type ConfigWithCredentials, loadConfig } from './config/loader'
|
||||
import type { TestSuite } from './types'
|
||||
|
||||
// Import custom elements (this registers them)
|
||||
import './components/LoginForm'
|
||||
import './components/TestCard'
|
||||
import './components/TestSuite'
|
||||
import './components/TestsView'
|
||||
import './components/RequestsModal'
|
||||
import type { LoginForm } from './components/LoginForm'
|
||||
import type { TestsView } from './components/TestsView'
|
||||
import type { RequestsModal } from './components/RequestsModal'
|
||||
|
||||
// Import test suites
|
||||
// import { basicTests } from './testSuites/Basic'
|
||||
import { sendArrTests, sendObjTests } from './testSuites/RequestData'
|
||||
import { fileUploadTests } from './testSuites/FileUpload'
|
||||
import { computeTests } from './testSuites/Compute'
|
||||
import { sasjsRequestTests } from './testSuites/SasjsRequests'
|
||||
|
||||
async function init() {
|
||||
const appContainer = document.getElementById('app')
|
||||
if (!appContainer) {
|
||||
console.error('App container not found')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Load config
|
||||
const config = await loadConfig()
|
||||
|
||||
// Initialize adapter
|
||||
const adapter = new SASjs(config.sasJsConfig)
|
||||
appContext.setAdapter(adapter)
|
||||
appContext.setConfig(config)
|
||||
|
||||
// Check session
|
||||
try {
|
||||
const sessionResponse = await adapter.checkSession()
|
||||
if (sessionResponse && sessionResponse.isLoggedIn) {
|
||||
appContext.setIsLoggedIn(true)
|
||||
showTests(appContainer, adapter, config)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
console.log('No active session, showing login')
|
||||
}
|
||||
|
||||
// Show login
|
||||
showLogin(appContainer)
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize app:', error)
|
||||
appContainer.innerHTML = `
|
||||
<div class="app__error">
|
||||
<h1>Initialization Error</h1>
|
||||
<p>Failed to load configuration. Please check config.json file.</p>
|
||||
<pre>${error}</pre>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
function showLogin(container: HTMLElement) {
|
||||
container.innerHTML = ''
|
||||
const loginForm = document.createElement('login-form') as LoginForm
|
||||
|
||||
loginForm.addEventListener('login-success', () => {
|
||||
const adapter = appContext.getAdapter()
|
||||
const config = appContext.getConfig()
|
||||
if (adapter && config) {
|
||||
showTests(container, adapter, config)
|
||||
}
|
||||
})
|
||||
|
||||
container.appendChild(loginForm)
|
||||
}
|
||||
|
||||
function showTests(
|
||||
container: HTMLElement,
|
||||
adapter: typeof SASjs,
|
||||
config: ConfigWithCredentials
|
||||
) {
|
||||
const configTyped = config as {
|
||||
sasJsConfig: { appLoc: string }
|
||||
userName?: string
|
||||
password?: string
|
||||
}
|
||||
const appLoc = configTyped.sasJsConfig.appLoc
|
||||
|
||||
// Build test suites with adapter and credentials
|
||||
const testSuites: TestSuite[] = [
|
||||
// FIXME: disabled basicTests due to login/logout operations
|
||||
// basicTests(adapter, configTyped.userName || '', configTyped.password || ''),
|
||||
sendArrTests(adapter, appLoc),
|
||||
sendObjTests(adapter),
|
||||
// specialCaseTests(adapter),
|
||||
sasjsRequestTests(adapter),
|
||||
fileUploadTests(adapter)
|
||||
]
|
||||
|
||||
// Add compute tests for SASVIYA only
|
||||
if (adapter.getSasjsConfig().serverType === 'SASVIYA') {
|
||||
testSuites.push(computeTests(adapter, appLoc))
|
||||
}
|
||||
|
||||
container.innerHTML = ''
|
||||
const testsView = document.createElement('tests-view') as TestsView
|
||||
testsView.testSuites = testSuites
|
||||
|
||||
const requestsModal = document.createElement(
|
||||
'requests-modal'
|
||||
) as RequestsModal
|
||||
|
||||
testsView.addEventListener('logout', () => {
|
||||
showLogin(container)
|
||||
})
|
||||
|
||||
container.appendChild(requestsModal)
|
||||
container.appendChild(testsView)
|
||||
}
|
||||
|
||||
// Subscribe to auth changes
|
||||
appContext.subscribe((state) => {
|
||||
const appContainer = document.getElementById('app')
|
||||
if (!appContainer) return
|
||||
|
||||
if (!state.isLoggedIn) {
|
||||
showLogin(appContainer)
|
||||
} else if (state.adapter && state.config) {
|
||||
showTests(appContainer, state.adapter, state.config)
|
||||
}
|
||||
})
|
||||
|
||||
// Initialize app
|
||||
init()
|
||||
1
sasjs-tests/src/react-app-env.d.ts
vendored
1
sasjs-tests/src/react-app-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="react-scripts" />
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }] }
|
||||
|
||||
@@ -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' }] }
|
||||
|
||||
|
||||
@@ -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: [
|
||||
@@ -325,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()
|
||||
@@ -373,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()
|
||||
@@ -421,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()
|
||||
@@ -478,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()
|
||||
|
||||
12
sasjs-tests/src/types/context.ts
Normal file
12
sasjs-tests/src/types/context.ts
Normal 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
|
||||
}
|
||||
2
sasjs-tests/src/types/index.ts
Normal file
2
sasjs-tests/src/types/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './test'
|
||||
export * from './context'
|
||||
24
sasjs-tests/src/types/test.ts
Normal file
24
sasjs-tests/src/types/test.ts
Normal 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'
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"sourceMap": true,
|
||||
"inlineSources": true,
|
||||
"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"]
|
||||
}
|
||||
|
||||
8
sasjs-tests/vite.config.js
Normal file
8
sasjs-tests/vite.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
export default defineConfig({
|
||||
build: {
|
||||
assetsInlineLimit: 0,
|
||||
assetsDir: ''
|
||||
},
|
||||
base: ''
|
||||
})
|
||||
@@ -35,6 +35,60 @@ interface JobExecutionResult {
|
||||
log?: string
|
||||
error?: object
|
||||
}
|
||||
/* Viya /types/types?limit=999999 response structure */
|
||||
interface IViyaTypesResponse {
|
||||
accept: string
|
||||
count: number
|
||||
items: IViyaTypesItem[]
|
||||
limit: number
|
||||
links: IViyaTypesLink[]
|
||||
name: string
|
||||
start: number
|
||||
version: number
|
||||
}
|
||||
|
||||
/* Item element within the Viya types response */
|
||||
interface IViyaTypesItem {
|
||||
description?: string
|
||||
extensions?: string[]
|
||||
iconUri?: string
|
||||
label: string
|
||||
links: IViyaTypesLink[]
|
||||
mappedTypes?: string[]
|
||||
mediaType?: string
|
||||
mediaTypes?: string[]
|
||||
name?: string | undefined
|
||||
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 | undefined
|
||||
properties: IViyaTypesProperties | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A client for interfacing with the SAS Viya REST API.
|
||||
@@ -61,6 +115,9 @@ export class SASViyaApiClient {
|
||||
this.requestClient
|
||||
)
|
||||
private folderMap = new Map<string, Job[]>()
|
||||
private fileExtensionMap = new Map<string, IViyaTypesExtensionInfo>()
|
||||
private boolExtensionMap = false // required in case the map has zero entries
|
||||
// after an attempt to populate it.
|
||||
|
||||
/**
|
||||
* A helper method used to call appendRequest method of RequestClient
|
||||
@@ -434,15 +491,102 @@ export class SASViyaApiClient {
|
||||
const formData = new NodeFormData()
|
||||
formData.append('file', contentBuffer, fileName)
|
||||
|
||||
return (
|
||||
await this.requestClient.post<File>(
|
||||
`/files/files?parentFolderUri=${parentFolderUri}&typeDefName=file#rawUpload`,
|
||||
formData,
|
||||
accessToken,
|
||||
'multipart/form-data; boundary=' + (formData as any)._boundary,
|
||||
headers
|
||||
)
|
||||
).result
|
||||
/** Query Viya for file metadata based on extension type. */
|
||||
|
||||
// typeDefName - Viya accepts this property during the file creation
|
||||
let typeDefName: string | undefined = undefined
|
||||
// Additional properties are supplied by a patch.
|
||||
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, {
|
||||
// `name` becomes the typeDefName value at file creation time.
|
||||
// `name` is ignored here if it is not populated in the map, or
|
||||
// has a blank/empty value.
|
||||
typeDefName: e.name
|
||||
? e.name.trim().length
|
||||
? e.name.trim()
|
||||
: undefined
|
||||
: undefined,
|
||||
properties: e.properties
|
||||
})
|
||||
})
|
||||
})
|
||||
// 3. Toggle the flag to avoid repeating this step
|
||||
this.boolExtensionMap = true
|
||||
}
|
||||
|
||||
// Query the map for the current file extension
|
||||
const fileExtInfo = this.fileExtensionMap.get(fileExtension)
|
||||
if (fileExtInfo) {
|
||||
// If the extension was found in the map, record the typeDefName and
|
||||
// create a patch if a properties object was returned.
|
||||
typeDefName = fileExtInfo.typeDefName
|
||||
if (fileExtInfo.properties)
|
||||
filePatch = { name: fileName, properties: fileExtInfo.properties }
|
||||
}
|
||||
}
|
||||
|
||||
// Create the file
|
||||
const createFileResponse = await this.requestClient.post<File>(
|
||||
`/files/files?parentFolderUri=${parentFolderUri}&typeDefName=${
|
||||
typeDefName ?? 'file'
|
||||
}#rawUpload`,
|
||||
formData,
|
||||
accessToken,
|
||||
'multipart/form-data; boundary=' + (formData as any)._boundary,
|
||||
headers
|
||||
)
|
||||
|
||||
// If a patch was created...
|
||||
if (filePatch) {
|
||||
try {
|
||||
const patchHeaders = {
|
||||
Accept: 'application/json',
|
||||
'If-Match': '*'
|
||||
}
|
||||
// Get the URI of the newly created file
|
||||
const fileUri = createFileResponse.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,
|
||||
patchHeaders
|
||||
)
|
||||
).result
|
||||
} catch (e: any) {
|
||||
throw new Error(`Error patching file ${fileName}.\n${e.message}`)
|
||||
}
|
||||
}
|
||||
return createFileResponse.result
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -273,9 +273,13 @@ export class RequestClient implements HttpClient {
|
||||
public async patch<T>(
|
||||
url: string,
|
||||
data: any = {},
|
||||
accessToken?: string
|
||||
accessToken?: string,
|
||||
overrideHeaders: { [key: string]: string | number } = {}
|
||||
): Promise<{ result: T; etag: string }> {
|
||||
const headers = this.getHeaders(accessToken, 'application/json')
|
||||
const headers = {
|
||||
...this.getHeaders(accessToken, 'application/json'),
|
||||
...overrideHeaders
|
||||
}
|
||||
|
||||
return this.httpClient
|
||||
.patch<T>(url, data, { headers, withXSRFToken: true })
|
||||
|
||||
Reference in New Issue
Block a user