├── .npmrc ├── .gitattributes ├── src ├── types │ ├── system │ │ └── global.d.ts │ ├── JobDefinition.ts │ ├── JobResult.ts │ ├── CsrfToken.ts │ ├── ExecuteScript.ts │ ├── LogStatistics.ts │ ├── Link.ts │ ├── Process.d.ts │ ├── File.ts │ ├── Folder.ts │ ├── UploadFile.ts │ ├── errors │ │ ├── ArgumentError.ts │ │ ├── InvalidJsonError.ts │ │ ├── NotFoundError.ts │ │ ├── AuthorizeError.ts │ │ ├── JsonParseArrayError.ts │ │ ├── InternalServerError.ts │ │ ├── WeboutResponseError.ts │ │ ├── InvalidSASjsCsrfError.ts │ │ ├── SAS9AuthError.ts │ │ ├── ComputeJobExecutionError.ts │ │ ├── JobExecutionError.ts │ │ ├── JobStatePollError.ts │ │ ├── LoginRequiredError.ts │ │ ├── CertificateError.ts │ │ ├── NoSessionStateError.ts │ │ ├── index.ts │ │ ├── ErrorResponse.ts │ │ ├── RootFolderNotFoundError.ts │ │ ├── spec │ │ │ └── SAS9AuthError.spec.ts │ │ └── RootFolderNotFoundError.spec.ts │ ├── PollOptions.ts │ ├── WriteStream.ts │ ├── Login.ts │ ├── Job.ts │ ├── index.ts │ ├── FileResource.ts │ ├── Session.ts │ ├── Tables.ts │ ├── Context.ts │ ├── Tables.spec.ts │ ├── RequestClient.ts │ └── SASjsConfig.ts ├── minified │ └── sas9 │ │ └── index.ts ├── utils │ ├── delay.ts │ ├── isRelativePath.ts │ ├── isNode.ts │ ├── isUri.ts │ ├── getFormData.ts │ ├── asyncForEach.ts │ ├── createAxiosInstance.ts │ ├── compareTimestamps.ts │ ├── parseSourceCode.ts │ ├── parseGeneratedCode.ts │ ├── splitChunks.ts │ ├── parseSasViyaLog.ts │ ├── isUrl.ts │ ├── serialize.ts │ ├── getUserLanguage.ts │ ├── needsRetry.ts │ ├── parseWeboutResponse.ts │ ├── appendExtraResponseAttributes.ts │ ├── spec │ │ ├── getFormData.spec.ts │ │ ├── parseSasViyaLog.spec.ts │ │ ├── parseViyaDebugResponse.spec.ts │ │ ├── validateInput.spec.ts │ │ └── extractUserLongNameSas9.spec.ts │ ├── getValidJson.ts │ ├── isIeOrEdge.ts │ ├── index.ts │ ├── sas9 │ │ └── extractUserLongNameSas9.ts │ ├── formatDataForRequest.ts │ ├── parseViyaDebugResponse.ts │ ├── fetchLogByChunks.ts │ └── validateInput.ts ├── auth │ ├── index.ts │ ├── isAuthorizeFormRequired.ts │ ├── isLoginRequired.ts │ ├── verifySas9Login.ts │ ├── openWebPage.ts │ ├── spec │ │ ├── verifySas9Login.spec.ts │ │ ├── verifySasViyaLogin.spec.ts │ │ ├── refreshTokensForSasjs.spec.ts │ │ ├── mockResponses.ts │ │ ├── openWebPage.spec.ts │ │ ├── getAccessTokenForSasjs.spec.ts │ │ ├── getTokenRequestErrorPrefix.spec.ts │ │ ├── getAccessTokenForViya.spec.ts │ │ ├── loginHeader.spec.ts │ │ ├── getTokens.spec.ts │ │ └── refreshTokensForViya.spec.ts │ ├── verifySasViyaLogin.ts │ ├── refreshTokensForSasjs.ts │ ├── getAccessTokenForSasjs.ts │ ├── getTokens.ts │ ├── getAccessTokenForViya.ts │ ├── refreshTokensForViya.ts │ └── getTokenRequestErrorPrefix.ts ├── __mocks__ │ └── axios.ts ├── job-execution │ ├── index.ts │ ├── JobExecutor.ts │ ├── ComputeJobExecutor.ts │ └── JesJobExecutor.ts ├── index.ts ├── api │ └── viya │ │ ├── writeStream.ts │ │ ├── getFileStream.ts │ │ ├── uploadTables.ts │ │ ├── spec │ │ ├── getFileStream.spec.ts │ │ ├── mockResponses.ts │ │ ├── writeStream.spec.ts │ │ ├── uploadTables.spec.ts │ │ └── saveLog.spec.ts │ │ └── saveLog.ts ├── test │ ├── utils │ │ ├── isUrl.spec.ts │ │ ├── parseSourceCode.spec.ts │ │ ├── getValidJson.spec.ts │ │ └── parseGeneratedCode.spec.ts │ ├── SAS_server_app.ts │ └── FolderOperations.spec.ts ├── file │ ├── generateTableUploadForm.ts │ └── generateFileUploadForm.ts ├── SASViyaApiClient.spec.ts ├── request │ └── SasjsRequestClient.ts └── SAS9ApiClient.ts ├── .gitpod.yml ├── .vscode └── settings.json ├── sasjs-tests ├── src │ ├── types │ │ ├── index.ts │ │ ├── context.ts │ │ └── test.ts │ ├── images │ │ └── favicon.png │ ├── core │ │ ├── index.ts │ │ ├── runTest.ts │ │ └── AppContext.ts │ ├── components │ │ ├── index.ts │ │ ├── TestSuite.css │ │ ├── LoginForm.css │ │ ├── TestsView.css │ │ ├── TestCard.css │ │ ├── LoginForm.ts │ │ ├── TestCard.ts │ │ └── TestSuite.ts │ ├── config │ │ └── loader.ts │ └── testSuites │ │ ├── FileUpload.ts │ │ └── SasjsRequests.ts ├── public │ ├── robots.txt │ └── config.json ├── sasjs │ ├── doxy │ │ ├── logo.png │ │ ├── new_stylesheet.css │ │ ├── favicon.ico │ │ ├── new_footer.html │ │ ├── Doxyfile │ │ └── new_header.html │ ├── common │ │ ├── sendMacVars.sas │ │ ├── invalidJSON.sas │ │ ├── makeErr.sas │ │ ├── sendArr.sas │ │ └── sendObj.sas │ └── sasjsconfig.json ├── vite.config.js ├── .gitignore ├── index.html ├── sasjs-cypress-run.sh ├── .sasjslint ├── tsconfig.json ├── index.css └── package.json ├── .env.example ├── .gitignore ├── cypress ├── videos │ └── sasjs.tests.ts.mp4 ├── tsconfig.json ├── screenshots │ └── sasjs.tests.ts │ │ └── sasjs-tests -- Should have all tests successfull -- before all hook (failed).png ├── webpack.config.js ├── plugins │ ├── cy-ts-preprocessor.js │ └── index.js ├── support │ ├── index.js │ └── commands.js └── integration │ └── sasjs.tests.ts ├── .prettierrc ├── screenshots ├── subsequent-session-request.png └── session-manager-first-request.png ├── .github ├── dependabot.yml ├── reviewer-lottery.yml ├── workflows │ ├── assign-reviewer.yml │ ├── generateDocs.yml │ ├── build-unit-tests.yml │ └── npmpublish.yml ├── issue_template.md ├── vpn │ └── config.ovpn ├── PULL_REQUEST_TEMPLATE.md └── CONTRIBUTING.md ├── tslint.json ├── .npmignore ├── typedoc.json ├── .git-hooks ├── pre-commit └── commit-msg ├── tsconfig.json ├── checkNodeVersion.js ├── cypress.config.js ├── createTSDocs.js ├── LICENSE ├── webpack.config.js └── .all-contributorsrc /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-scripts=true -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /src/types/system/global.d.ts: -------------------------------------------------------------------------------- 1 | import 'jest-extended' 2 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - init: npm install && npm run build -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["SASVIYA"] 3 | } 4 | -------------------------------------------------------------------------------- /sasjs-tests/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './test' 2 | export * from './context' 3 | -------------------------------------------------------------------------------- /src/types/JobDefinition.ts: -------------------------------------------------------------------------------- 1 | export interface JobDefinition { 2 | code: string 3 | } 4 | -------------------------------------------------------------------------------- /src/types/JobResult.ts: -------------------------------------------------------------------------------- 1 | export interface JobResult { 2 | '_webout.json': string 3 | } 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | SERVER_URL=https://server.com 2 | DEFAULT_COMPUTE_CONTEXT=SAS Job Execution compute context -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | 4 | docs 5 | 6 | .env 7 | 8 | /coverage 9 | 10 | .DS_Store -------------------------------------------------------------------------------- /sasjs-tests/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/types/CsrfToken.ts: -------------------------------------------------------------------------------- 1 | export interface CsrfToken { 2 | headerName: string 3 | value: string 4 | } 5 | -------------------------------------------------------------------------------- /sasjs-tests/sasjs/doxy/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasjs/adapter/master/sasjs-tests/sasjs/doxy/logo.png -------------------------------------------------------------------------------- /cypress/videos/sasjs.tests.ts.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasjs/adapter/master/cypress/videos/sasjs.tests.ts.mp4 -------------------------------------------------------------------------------- /sasjs-tests/sasjs/doxy/new_stylesheet.css: -------------------------------------------------------------------------------- 1 | #projectlogo img { 2 | border: 0px none; 3 | max-height: 70px; 4 | } 5 | -------------------------------------------------------------------------------- /src/minified/sas9/index.ts: -------------------------------------------------------------------------------- 1 | import SASjs from './SASjs' 2 | export * from '../../types' 3 | export default SASjs 4 | -------------------------------------------------------------------------------- /src/types/ExecuteScript.ts: -------------------------------------------------------------------------------- 1 | export interface ExecutionQuery { 2 | _program: string 3 | _debug?: number 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/delay.ts: -------------------------------------------------------------------------------- 1 | export const delay = (ms: number) => 2 | new Promise((resolve) => setTimeout(resolve, ms)) 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /sasjs-tests/sasjs/doxy/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasjs/adapter/master/sasjs-tests/sasjs/doxy/favicon.ico -------------------------------------------------------------------------------- /sasjs-tests/src/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasjs/adapter/master/sasjs-tests/src/images/favicon.png -------------------------------------------------------------------------------- /src/utils/isRelativePath.ts: -------------------------------------------------------------------------------- 1 | export const isRelativePath = (uri: string): boolean => 2 | !!uri && !uri.startsWith('/') 3 | -------------------------------------------------------------------------------- /sasjs-tests/src/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './runTest' 2 | export * from './TestRunner' 3 | export * from './AppContext' 4 | -------------------------------------------------------------------------------- /src/types/LogStatistics.ts: -------------------------------------------------------------------------------- 1 | export interface LogStatistics { 2 | lineCount: number 3 | modifiedTimeStamp: string 4 | } 5 | -------------------------------------------------------------------------------- /screenshots/subsequent-session-request.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasjs/adapter/master/screenshots/subsequent-session-request.png -------------------------------------------------------------------------------- /screenshots/session-manager-first-request.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasjs/adapter/master/screenshots/session-manager-first-request.png -------------------------------------------------------------------------------- /src/types/Link.ts: -------------------------------------------------------------------------------- 1 | export interface Link { 2 | method: string 3 | rel: string 4 | href: string 5 | uri: string 6 | type: string 7 | } 8 | -------------------------------------------------------------------------------- /src/types/Process.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace NodeJS { 2 | export interface Process { 3 | logger?: import('@sasjs/utils/logger').Logger 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/isNode.ts: -------------------------------------------------------------------------------- 1 | export const isNode = () => 2 | typeof process !== 'undefined' && 3 | process.versions != null && 4 | process.versions.node != null 5 | -------------------------------------------------------------------------------- /src/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AuthManager' 2 | export * from './isAuthorizeFormRequired' 3 | export * from './isLoginRequired' 4 | export * from './loginHeader' 5 | -------------------------------------------------------------------------------- /src/types/File.ts: -------------------------------------------------------------------------------- 1 | import { Link } from './Link' 2 | 3 | export interface File { 4 | id: string 5 | name: string 6 | parentUri: string 7 | links: Link[] 8 | } 9 | -------------------------------------------------------------------------------- /src/types/Folder.ts: -------------------------------------------------------------------------------- 1 | import { Link } from './Link' 2 | 3 | export interface Folder { 4 | id: string 5 | uri: string 6 | links: Link[] 7 | memberCount: number 8 | } 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 1 8 | -------------------------------------------------------------------------------- /src/types/UploadFile.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents an object that is passed to the file uploader. 3 | * 4 | */ 5 | export interface UploadFile { 6 | file: File 7 | fileName: string 8 | } 9 | -------------------------------------------------------------------------------- /sasjs-tests/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | export default defineConfig({ 3 | build: { 4 | assetsInlineLimit: 0, 5 | assetsDir: '' 6 | }, 7 | base: '' 8 | }) 9 | -------------------------------------------------------------------------------- /src/utils/isUri.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks if string is in URI format. 3 | * @param str - string to check. 4 | */ 5 | export const isUri = (str: string): boolean => /^\/folders\/folders\//.test(str) 6 | -------------------------------------------------------------------------------- /src/auth/isAuthorizeFormRequired.ts: -------------------------------------------------------------------------------- 1 | export const isAuthorizeFormRequired = (response: string): boolean => { 2 | return //gm.test(response) 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/getFormData.ts: -------------------------------------------------------------------------------- 1 | import { isNode } from './' 2 | import NodeFormData from 'form-data' 3 | 4 | export const getFormData = (): NodeFormData | FormData => 5 | isNode() ? new NodeFormData() : new FormData() 6 | -------------------------------------------------------------------------------- /src/__mocks__/axios.ts: -------------------------------------------------------------------------------- 1 | import { AxiosStatic } from 'axios' 2 | 3 | const mockAxios = jest.genMockFromModule('axios') as AxiosStatic 4 | 5 | mockAxios.create = jest.fn(() => mockAxios) 6 | 7 | export default mockAxios 8 | -------------------------------------------------------------------------------- /src/utils/asyncForEach.ts: -------------------------------------------------------------------------------- 1 | export async function asyncForEach(array: any[], callback: any) { 2 | for (let index = 0; index < array.length; index++) { 3 | await callback(array[index], index, array) 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/auth/isLoginRequired.ts: -------------------------------------------------------------------------------- 1 | export const isLogInRequired = (response: string): boolean => { 2 | const pattern: RegExp = //gm 3 | const matches = pattern.test(response) 4 | return matches 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/createAxiosInstance.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import * as https from 'https' 3 | 4 | export const createAxiosInstance = ( 5 | baseURL: string, 6 | httpsAgent?: https.Agent 7 | ) => axios.create({ baseURL, httpsAgent }) 8 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-config-prettier"], 3 | "rules": { 4 | "no-string-literal": false, 5 | "forin": false, 6 | "no-console": false, 7 | "max-classes-per-file": false 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/types/errors/ArgumentError.ts: -------------------------------------------------------------------------------- 1 | export class ArgumentError extends Error { 2 | constructor(public message: string) { 3 | super(message) 4 | this.name = 'ArgumentError' 5 | Object.setPrototypeOf(this, ArgumentError.prototype) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "baseUrl": "../node_modules", 5 | "target": "es6", 6 | "lib": ["es2019", "dom"], 7 | "types": ["cypress"] 8 | }, 9 | "include": ["**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /sasjs-tests/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { LoginForm } from './LoginForm' 2 | export { TestCard } from './TestCard' 3 | export { TestSuiteElement } from './TestSuite' 4 | export { TestsView } from './TestsView' 5 | export { RequestsModal } from './RequestsModal' 6 | -------------------------------------------------------------------------------- /.github/reviewer-lottery.yml: -------------------------------------------------------------------------------- 1 | groups: 2 | - name: SASjs Devs # name of the group 3 | reviewers: 1 # how many reviewers do you want to assign? 4 | usernames: # github usernames of the reviewers 5 | - YuryShkoda 6 | - medjedovicm 7 | - sabhas 8 | -------------------------------------------------------------------------------- /sasjs-tests/sasjs/common/sendMacVars.sas: -------------------------------------------------------------------------------- 1 | /** 2 | @file 3 | @brief Returns Macro Variables 4 | 5 |

SAS Macros

6 | **/ 7 | 8 | data work.macvars; 9 | set sashelp.vmacro; 10 | run; 11 | %webout(OPEN) 12 | %webout(OBJ,macvars) 13 | %webout(CLOSE) -------------------------------------------------------------------------------- /src/types/errors/InvalidJsonError.ts: -------------------------------------------------------------------------------- 1 | export class InvalidJsonError extends Error { 2 | constructor() { 3 | super('Error: invalid Json string') 4 | this.name = 'InvalidJsonError' 5 | Object.setPrototypeOf(this, InvalidJsonError.prototype) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /cypress/screenshots/sasjs.tests.ts/sasjs-tests -- Should have all tests successfull -- before all hook (failed).png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sasjs/adapter/master/cypress/screenshots/sasjs.tests.ts/sasjs-tests -- Should have all tests successfull -- before all hook (failed).png -------------------------------------------------------------------------------- /sasjs-tests/sasjs/common/invalidJSON.sas: -------------------------------------------------------------------------------- 1 | /** 2 | @file 3 | @brief Makes an invalid JSON file 4 | 5 |

SAS Macros

6 | **/ 7 | 8 | %webout(OPEN) 9 | data _null_; 10 | file _webout; 11 | put ' the discovery channel '; 12 | run; 13 | %webout(CLOSE) 14 | -------------------------------------------------------------------------------- /src/job-execution/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ComputeJobExecutor' 2 | export * from './FileUploader' 3 | export * from './JesJobExecutor' 4 | export * from './JobExecutor' 5 | export * from './Sas9JobExecutor' 6 | export * from './WebJobExecutor' 7 | export * from './SasjsJobExecutor' 8 | -------------------------------------------------------------------------------- /src/types/PollOptions.ts: -------------------------------------------------------------------------------- 1 | export interface PollOptions { 2 | maxPollCount: number 3 | pollInterval: number // milliseconds 4 | pollStrategy?: PollStrategy 5 | streamLog?: boolean 6 | logFolderPath?: string 7 | } 8 | 9 | export type PollStrategy = PollOptions[] 10 | -------------------------------------------------------------------------------- /src/types/errors/NotFoundError.ts: -------------------------------------------------------------------------------- 1 | export class NotFoundError extends Error { 2 | constructor(public url: string) { 3 | super(`Error: Resource at ${url} was not found`) 4 | this.name = 'NotFoundError' 5 | Object.setPrototypeOf(this, NotFoundError.prototype) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | sasjs-tests/ 2 | docs/ 3 | .github/ 4 | *.md 5 | *.spec.ts 6 | .all-contributorsrc 7 | cypress/ 8 | .gitpod.yml 9 | .prettierrc 10 | cypress.json 11 | jest.config.js 12 | sasjs-cypress-run.sh 13 | tsconfig.json 14 | tslint.json 15 | typedoc.json 16 | webpack.config.js 17 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import SASjs from './SASjs' 2 | export * from './types' 3 | export * from './types/errors' 4 | export * from './SASViyaApiClient' 5 | export * from './SAS9ApiClient' 6 | export * from './SASjsApiClient' 7 | export * from './request/SasjsRequestClient' 8 | export default SASjs 9 | -------------------------------------------------------------------------------- /src/types/errors/AuthorizeError.ts: -------------------------------------------------------------------------------- 1 | export class AuthorizeError extends Error { 2 | constructor(public message: string, public confirmUrl: string) { 3 | super(message) 4 | this.name = 'AuthorizeError' 5 | Object.setPrototypeOf(this, AuthorizeError.prototype) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/types/errors/JsonParseArrayError.ts: -------------------------------------------------------------------------------- 1 | export class JsonParseArrayError extends Error { 2 | constructor() { 3 | super('Can not parse array object to json.') 4 | this.name = 'JsonParseArrayError' 5 | Object.setPrototypeOf(this, JsonParseArrayError.prototype) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/compareTimestamps.ts: -------------------------------------------------------------------------------- 1 | import { SASjsRequest } from '../types' 2 | 3 | /** 4 | * Comparator for SASjs request timestamps. 5 | * 6 | */ 7 | export const compareTimestamps = (a: SASjsRequest, b: SASjsRequest) => { 8 | return b.timestamp.getTime() - a.timestamp.getTime() 9 | } 10 | -------------------------------------------------------------------------------- /src/types/errors/InternalServerError.ts: -------------------------------------------------------------------------------- 1 | export class InternalServerError extends Error { 2 | constructor() { 3 | super('Error: Internal server error.') 4 | 5 | this.name = 'InternalServerError' 6 | 7 | Object.setPrototypeOf(this, InternalServerError.prototype) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/parseSourceCode.ts: -------------------------------------------------------------------------------- 1 | export const parseSourceCode = (log: string): string => { 2 | const isSourceCodeLine = (line: string) => 3 | line.trim().substring(0, 10).trimStart().match(/^\d/) 4 | const logLines = log.split('\n').filter(isSourceCodeLine) 5 | return logLines.join('\r\n') 6 | } 7 | -------------------------------------------------------------------------------- /src/types/errors/WeboutResponseError.ts: -------------------------------------------------------------------------------- 1 | export class WeboutResponseError extends Error { 2 | constructor(public url: string) { 3 | super(`Error: error while parsing response from ${url}`) 4 | this.name = 'WeboutResponseError' 5 | Object.setPrototypeOf(this, WeboutResponseError.prototype) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/parseGeneratedCode.ts: -------------------------------------------------------------------------------- 1 | export const parseGeneratedCode = (log: string) => { 2 | const startsWith = 'MPRINT' 3 | const isGeneratedCodeLine = (line: string) => 4 | line.trim().startsWith(startsWith) 5 | const logLines = log.split('\n').filter(isGeneratedCodeLine) 6 | return logLines.join('\r\n') 7 | } 8 | -------------------------------------------------------------------------------- /sasjs-tests/sasjs/common/makeErr.sas: -------------------------------------------------------------------------------- 1 | /** 2 | @file 3 | @brief Makes an error 4 | 5 |

SAS Macros

6 | **/ 7 | 8 | If you can keep your head when all about you 9 | Are losing theirs and blaming it on you, 10 | If you can trust yourself when all men doubt you, 11 | But make allowance for their doubting too; -------------------------------------------------------------------------------- /sasjs-tests/src/types/context.ts: -------------------------------------------------------------------------------- 1 | import type SASjs from '@sasjs/adapter' 2 | import type { SASjsConfig } from '@sasjs/adapter' 3 | 4 | export interface AppConfig { 5 | sasJsConfig: SASjsConfig 6 | } 7 | 8 | export interface AppState { 9 | config: AppConfig | null 10 | adapter: SASjs | null 11 | isLoggedIn: boolean 12 | } 13 | -------------------------------------------------------------------------------- /src/types/errors/InvalidSASjsCsrfError.ts: -------------------------------------------------------------------------------- 1 | export class InvalidSASjsCsrfError extends Error { 2 | constructor() { 3 | const message = 'Invalid CSRF token!' 4 | 5 | super(`Auth error: ${message}`) 6 | this.name = 'InvalidSASjsCsrfError' 7 | Object.setPrototypeOf(this, InvalidSASjsCsrfError.prototype) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "out": "docs", 3 | "exclude": ["**/*+(index|.spec|.e2e).ts"], 4 | "excludeExternals": true, 5 | "excludePrivate": true, 6 | "entryPoints": [ 7 | "src/SASjs.ts", 8 | "src/SAS9ApiClient.ts", 9 | "src/SASjsApiClient.ts", 10 | "src/SASViyaApiClient.ts", 11 | "src/types" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/types/errors/SAS9AuthError.ts: -------------------------------------------------------------------------------- 1 | export class SAS9AuthError extends Error { 2 | constructor() { 3 | super( 4 | 'The credentials you provided cannot be authenticated. Please provide a valid set of credentials.' 5 | ) 6 | this.name = 'AuthorizeError' 7 | Object.setPrototypeOf(this, SAS9AuthError.prototype) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.github/workflows/assign-reviewer.yml: -------------------------------------------------------------------------------- 1 | name: 'Assign Reviewer' 2 | on: 3 | pull_request: 4 | types: [opened] 5 | 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: uesteibar/reviewer-lottery@v1 12 | with: 13 | repo-token: ${{ secrets.GITHUB_TOKEN }} 14 | -------------------------------------------------------------------------------- /src/types/WriteStream.ts: -------------------------------------------------------------------------------- 1 | import { WriteStream as FsWriteStream } from 'fs' 2 | 3 | export interface WriteStream extends FsWriteStream { 4 | write( 5 | chunk: any, 6 | encoding?: BufferEncoding | ((error: Error | null | undefined) => void), 7 | cb?: (error: Error | null | undefined) => void 8 | ): boolean 9 | path: string | Buffer 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/splitChunks.ts: -------------------------------------------------------------------------------- 1 | export const splitChunks = (content: string) => { 2 | const size = 16000 3 | 4 | const numChunks = Math.ceil(content.length / size) 5 | const chunks = new Array(numChunks) 6 | 7 | for (let i = 0, o = 0; i < numChunks; ++i, o += size) { 8 | chunks[i] = content.substr(o, size) 9 | } 10 | 11 | return chunks 12 | } 13 | -------------------------------------------------------------------------------- /src/types/Login.ts: -------------------------------------------------------------------------------- 1 | export interface LoginOptions { 2 | onLoggedOut?: () => Promise 3 | } 4 | 5 | export interface LoginResult { 6 | isLoggedIn: boolean 7 | userName: string 8 | userLongName: string 9 | } 10 | export interface LoginResultInternal { 11 | isLoggedIn: boolean 12 | userName: string 13 | userLongName: string 14 | loginForm?: any 15 | } 16 | -------------------------------------------------------------------------------- /src/types/errors/ComputeJobExecutionError.ts: -------------------------------------------------------------------------------- 1 | import { Job } from '../Job' 2 | 3 | export class ComputeJobExecutionError extends Error { 4 | constructor(public job: Job, public log: string) { 5 | super('Error: Job execution failed') 6 | this.name = 'ComputeJobExecutionError' 7 | Object.setPrototypeOf(this, ComputeJobExecutionError.prototype) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/types/Job.ts: -------------------------------------------------------------------------------- 1 | import { Link } from './Link' 2 | import { JobResult } from './JobResult' 3 | import { LogStatistics } from './LogStatistics' 4 | 5 | export interface Job { 6 | id: string 7 | name: string 8 | uri: string 9 | createdBy: string 10 | code?: string 11 | links: Link[] 12 | results: JobResult 13 | error?: any 14 | logStatistics: LogStatistics 15 | } 16 | -------------------------------------------------------------------------------- /sasjs-tests/public/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "userName": "", 3 | "password": "", 4 | "sasJsConfig": { 5 | "loginMechanism": "Redirected", 6 | "serverUrl": "", 7 | "appLoc": "/Public/app/adapter-tests/services", 8 | "serverType": "SASVIYA", 9 | "debug": false, 10 | "contextName": "SAS Job Execution compute context", 11 | "useComputeApi": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/types/errors/JobExecutionError.ts: -------------------------------------------------------------------------------- 1 | export class JobExecutionError extends Error { 2 | constructor( 3 | public errorCode: number, 4 | public errorMessage: string, 5 | public result: string 6 | ) { 7 | super(`Error Code ${errorCode}: ${errorMessage}`) 8 | this.name = 'JobExecutionError' 9 | Object.setPrototypeOf(this, JobExecutionError.prototype) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/types/errors/JobStatePollError.ts: -------------------------------------------------------------------------------- 1 | export class JobStatePollError extends Error { 2 | constructor(id: string, public originalError: Error) { 3 | super( 4 | `Error while polling job state for job ${id}: ${ 5 | originalError.message || originalError 6 | }` 7 | ) 8 | this.name = 'JobStatePollError' 9 | Object.setPrototypeOf(this, JobStatePollError.prototype) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.git-hooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Using `--silent` helps for showing any errs in the first line of the response 4 | # The first line is picked up by the VS Code GIT UI popup when rc is not 0 5 | 6 | if npm run --silent lint:silent ; then 7 | exit 0 8 | else 9 | npm run --silent lint:fix 10 | echo "❌ Prettier check failed! We ran lint:fix for you. Please add & commit again." 11 | exit 1 12 | fi 13 | -------------------------------------------------------------------------------- /src/utils/parseSasViyaLog.ts: -------------------------------------------------------------------------------- 1 | export const parseSasViyaLog = (logResponse: { items: any[] }) => { 2 | let log 3 | try { 4 | log = logResponse.items 5 | ? logResponse.items.map((i) => i.line).join('\n') 6 | : JSON.stringify(logResponse) 7 | } catch (e: any) { 8 | console.error('An error has occurred while parsing the log response', e) 9 | log = logResponse 10 | } 11 | return log 12 | } 13 | -------------------------------------------------------------------------------- /src/types/errors/LoginRequiredError.ts: -------------------------------------------------------------------------------- 1 | export class LoginRequiredError extends Error { 2 | constructor(details?: any) { 3 | const message = details 4 | ? JSON.stringify(details, null, 2) 5 | : 'You must be logged in to access this resource' 6 | 7 | super(`Auth error: ${message}`) 8 | this.name = 'LoginRequiredError' 9 | Object.setPrototypeOf(this, LoginRequiredError.prototype) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/isUrl.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks if string is in URL format. 3 | * @param str - string to check. 4 | */ 5 | export const isUrl = (str: string): boolean => { 6 | const supportedProtocols = ['http:', 'https:'] 7 | 8 | try { 9 | const url = new URL(str) 10 | 11 | if (!supportedProtocols.includes(url.protocol)) return false 12 | } catch (_) { 13 | return false 14 | } 15 | 16 | return true 17 | } 18 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | ## Expected behaviour 2 | *Describe what should be happening* 3 | 4 | ## Current behaviour 5 | *Describe what is actually happening* 6 | 7 | ## Environment info 8 | **Client tech stack**: *Angular, React, Vue, VanillaJS, NodeJS etc.* 9 | **Server type**: SASJS|SASVIYA|SAS9 10 | **Login mechanism**: Default|Redirected 11 | **Debug**: true|false 12 | **Use Compute Api (relevant only on VIYA)**: true|false 13 | -------------------------------------------------------------------------------- /sasjs-tests/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | 27 | # sasjs 28 | sasjsbuild 29 | sasjsresults -------------------------------------------------------------------------------- /sasjs-tests/src/config/loader.ts: -------------------------------------------------------------------------------- 1 | import type { AppConfig } from '../types' 2 | 3 | export interface ConfigWithCredentials extends AppConfig { 4 | userName?: string 5 | password?: string 6 | } 7 | 8 | export async function loadConfig(): Promise { 9 | const response = await fetch('config.json') 10 | if (!response.ok) { 11 | throw new Error('Failed to load config.json') 12 | } 13 | return response.json() 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2018", "DOM", "ES2019.String"], 4 | "target": "es6", 5 | "module": "commonjs", 6 | "declaration": true, 7 | "outDir": "./build", 8 | "esModuleInterop": true, 9 | "strict": true, 10 | "sourceMap": true, 11 | "inlineSources": true, 12 | "typeRoots": ["./node_modules/@types", "./src/types/system"] 13 | }, 14 | "include": ["src"], 15 | "exclude": ["node_modules"] 16 | } 17 | -------------------------------------------------------------------------------- /src/types/errors/CertificateError.ts: -------------------------------------------------------------------------------- 1 | const instructionsToFix = 2 | 'https://github.com/sasjs/cli/issues/1181#issuecomment-1090638584' 3 | 4 | export class CertificateError extends Error { 5 | constructor(message: string) { 6 | super( 7 | `${message}\nPlease visit the link below for further information on this issue:\n- ${instructionsToFix}\n` 8 | ) 9 | this.name = 'CertificateError' 10 | Object.setPrototypeOf(this, CertificateError.prototype) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /sasjs-tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | SASjs tests 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/api/viya/writeStream.ts: -------------------------------------------------------------------------------- 1 | import { WriteStream } from '../../types' 2 | 3 | export const writeStream = async ( 4 | stream: WriteStream, 5 | content: string 6 | ): Promise => { 7 | return new Promise((resolve, reject) => { 8 | stream.write(content + '\n', (err: Error | null | undefined) => { 9 | if (err) { 10 | reject(err) // Reject on write error 11 | } else { 12 | resolve(true) // Resolve on successful write 13 | } 14 | }) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /checkNodeVersion.js: -------------------------------------------------------------------------------- 1 | const result = process.versions 2 | if (result && result.node) { 3 | if (parseInt(result.node) < 14) { 4 | console.log( 5 | '\x1b[31m%s\x1b[0m', 6 | `❌ Process failed due to Node Version,\nPlease install and use Node Version >= 14\nYour current Node Version is: ${result.node}` 7 | ) 8 | process.exit(1) 9 | } 10 | } else { 11 | console.log( 12 | '\x1b[31m%s\x1b[0m', 13 | 'Something went wrong while checking Node version' 14 | ) 15 | process.exit(1) 16 | } 17 | -------------------------------------------------------------------------------- /src/types/errors/NoSessionStateError.ts: -------------------------------------------------------------------------------- 1 | export class NoSessionStateError extends Error { 2 | constructor( 3 | public serverResponseStatus: number, 4 | public sessionStateUrl: string, 5 | public logUrl: string 6 | ) { 7 | super( 8 | `Could not get session state. Server responded with ${serverResponseStatus} whilst checking state: ${sessionStateUrl}` 9 | ) 10 | 11 | this.name = 'NoSessionStatus' 12 | 13 | Object.setPrototypeOf(this, NoSessionStateError.prototype) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/serialize.ts: -------------------------------------------------------------------------------- 1 | export const serialize = (obj: any) => { 2 | const str: any[] = [] 3 | for (const p in obj) { 4 | if (obj.hasOwnProperty(p)) { 5 | if (obj[p] instanceof Array) { 6 | for (let i = 0, n = obj[p].length; i < n; i++) { 7 | str.push(encodeURIComponent(p) + '=' + encodeURIComponent(obj[p][i])) 8 | } 9 | } else { 10 | str.push(encodeURIComponent(p) + '=' + encodeURIComponent(obj[p])) 11 | } 12 | } 13 | } 14 | return str.join('&') 15 | } 16 | -------------------------------------------------------------------------------- /cypress/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mode: 'development', 3 | resolve: { 4 | extensions: ['.ts', '.js'] 5 | }, 6 | module: { 7 | rules: [ 8 | { 9 | test: /\.ts$/, 10 | exclude: [/node_modules/], 11 | use: [ 12 | { 13 | loader: 'ts-loader', 14 | options: { 15 | // skip typechecking for speed 16 | transpileOnly: true 17 | } 18 | } 19 | ] 20 | } 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('cypress') 2 | 3 | module.exports = defineConfig({ 4 | e2e: { 5 | includeShadowDom: true, 6 | chromeWebSecurity: false, 7 | defaultCommandTimeout: 20000, 8 | specPattern: 'cypress/integration/**/*.ts', 9 | supportFile: 'cypress/support/index.js' 10 | }, 11 | env: { 12 | sasjsTestsUrl: 'http://localhost:5173', 13 | username: '', 14 | password: '', 15 | screenshotOnRunFailure: false, 16 | testingFinishTimeout: 600000 17 | } 18 | }) 19 | -------------------------------------------------------------------------------- /src/utils/getUserLanguage.ts: -------------------------------------------------------------------------------- 1 | interface IEnavigator { 2 | userLanguage?: string 3 | } 4 | 5 | /** 6 | * Provides preferred language of the user. 7 | * @returns A string representing the preferred language of the user, usually the language of the browser UI. Examples of valid language codes include "en", "en-US", "fr", "fr-FR", "es-ES". More info available https://datatracker.ietf.org/doc/html/rfc5646 8 | */ 9 | export const getUserLanguage = () => 10 | window.navigator.language || (window.navigator as IEnavigator).userLanguage 11 | -------------------------------------------------------------------------------- /sasjs-tests/sasjs/common/sendArr.sas: -------------------------------------------------------------------------------- 1 | /** 2 | @file 3 | @brief Returns JSON in Array format 4 | 5 |

SAS Macros

6 | **/ 7 | 8 | %webout(FETCH) 9 | %webout(OPEN) 10 | %macro x(); 11 | %if %symexist(sasjs_tables) %then 12 | %do i=1 %to %sysfunc(countw(&sasjs_tables)); 13 | %let table=%scan(&sasjs_tables,&i); 14 | %webout(ARR,&table,missing=STRING,showmeta=YES) 15 | %end; 16 | %else %do i=1 %to &_webin_file_count; 17 | %webout(ARR,&&_webin_name&i,missing=STRING,showmeta=YES) 18 | %end; 19 | %mend x; 20 | %x() 21 | %webout(CLOSE) -------------------------------------------------------------------------------- /sasjs-tests/sasjs/common/sendObj.sas: -------------------------------------------------------------------------------- 1 | /** 2 | @file 3 | @brief Returns JSON in Object format 4 | 5 |

SAS Macros

6 | **/ 7 | 8 | %webout(FETCH) 9 | %webout(OPEN) 10 | %macro x(); 11 | %if %symexist(sasjs_tables) %then 12 | %do i=1 %to %sysfunc(countw(&sasjs_tables)); 13 | %let table=%scan(&sasjs_tables,&i); 14 | %webout(OBJ,&table,missing=STRING,showmeta=YES) 15 | %end; 16 | %else %do i=1 %to &_webin_file_count; 17 | %webout(OBJ,&&_webin_name&i,missing=STRING,showmeta=YES) 18 | %end; 19 | %mend x; 20 | %x() 21 | %webout(CLOSE) -------------------------------------------------------------------------------- /src/utils/needsRetry.ts: -------------------------------------------------------------------------------- 1 | export const needsRetry = (responseText: string): boolean => { 2 | return ( 3 | !!responseText && 4 | ((responseText.includes('"errorCode":403') && 5 | responseText.includes('_csrf') && 6 | responseText.includes('X-CSRF-TOKEN')) || 7 | (responseText.includes('"status":403') && 8 | responseText.includes('"error":"Forbidden"')) || 9 | (responseText.includes('"status":449') && 10 | responseText.includes( 11 | 'Authentication success, retry original request' 12 | ))) 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /sasjs-tests/sasjs-cypress-run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if npm run cy:run -- --spec "cypress/integration/sasjs.tests.ts" ; then 4 | echo "Cypress sasjs testing passed!" 5 | else 6 | echo '{"msgtype":"m.text", "body":"Automated sasjs-tests failed on the @sasjs/adapter PR: '$2'"}' 7 | curl -XPOST -d '{"msgtype":"m.text", "body":"Automated sasjs-tests failed on the @sasjs/adapter PR: '$2'"}' https://matrix.4gl.io/_matrix/client/r0/rooms/%21jRebyiGmHZlpfDwYXN:4gl.io/send/m.room.message?access_token=$1 8 | echo "Cypress sasjs testing failed!" 9 | exit 1 10 | fi 11 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Context' 2 | export * from './CsrfToken' 3 | export * from './Folder' 4 | export * from './File' 5 | export * from './Job' 6 | export * from './JobDefinition' 7 | export * from './JobResult' 8 | export * from './Link' 9 | export * from './Login' 10 | export * from './SASjsConfig' 11 | export * from './RequestClient' 12 | export * from './Session' 13 | export * from './UploadFile' 14 | export * from './PollOptions' 15 | export * from './WriteStream' 16 | export * from './ExecuteScript' 17 | export * from './errors' 18 | export * from './Tables' 19 | -------------------------------------------------------------------------------- /src/utils/parseWeboutResponse.ts: -------------------------------------------------------------------------------- 1 | import { WeboutResponseError } from '../types/errors' 2 | 3 | export const parseWeboutResponse = (response: string, url?: string): string => { 4 | let sasResponse = '' 5 | 6 | if (response.includes('>>weboutBEGIN<<')) { 7 | try { 8 | sasResponse = response 9 | .split('>>weboutBEGIN<<')[1] 10 | .split('>>weboutEND<<')[0] 11 | } catch (e: any) { 12 | if (url) throw new WeboutResponseError(url) 13 | 14 | sasResponse = '' 15 | console.error(e) 16 | } 17 | } 18 | 19 | return sasResponse 20 | } 21 | -------------------------------------------------------------------------------- /.github/vpn/config.ovpn: -------------------------------------------------------------------------------- 1 | # Client 2 | client 3 | tls-client 4 | dev tun 5 | # this will connect with whatever proto DNS tells us (https://community.openvpn.net/openvpn/ticket/934) 6 | proto udp 7 | remote vpn.4gl.io 7194 8 | resolv-retry infinite 9 | # this will fallback from udp6 to udp4 as well 10 | connect-timeout 5 11 | data-ciphers AES-256-CBC:AES-256-GCM 12 | auth SHA256 13 | script-security 2 14 | keepalive 10 120 15 | remote-cert-tls server 16 | 17 | # Keys 18 | ca ca.crt 19 | cert user.crt 20 | key user.key 21 | tls-auth tls.key 1 22 | 23 | # Security 24 | nobind 25 | persist-key 26 | persist-tun 27 | verb 3 28 | -------------------------------------------------------------------------------- /src/types/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ArgumentError' 2 | export * from './AuthorizeError' 3 | export * from './CertificateError' 4 | export * from './ComputeJobExecutionError' 5 | export * from './ErrorResponse' 6 | export * from './InternalServerError' 7 | export * from './InvalidJsonError' 8 | export * from './JobExecutionError' 9 | export * from './JobStatePollError' 10 | export * from './JsonParseArrayError' 11 | export * from './LoginRequiredError' 12 | export * from './NoSessionStateError' 13 | export * from './NotFoundError' 14 | export * from './RootFolderNotFoundError' 15 | export * from './WeboutResponseError' 16 | -------------------------------------------------------------------------------- /src/types/errors/ErrorResponse.ts: -------------------------------------------------------------------------------- 1 | export class ErrorResponse { 2 | error: ErrorBody 3 | 4 | constructor(message: string, details?: any, raw?: any) { 5 | let detailsString = details 6 | 7 | if (typeof details !== 'object') { 8 | try { 9 | detailsString = JSON.parse(details) 10 | } catch { 11 | raw = details 12 | detailsString = '' 13 | } 14 | } 15 | 16 | this.error = { 17 | message, 18 | details: detailsString, 19 | raw 20 | } 21 | } 22 | } 23 | 24 | export interface ErrorBody { 25 | message: string 26 | details: any 27 | raw: any 28 | } 29 | -------------------------------------------------------------------------------- /sasjs-tests/.sasjslint: -------------------------------------------------------------------------------- 1 | { 2 | "lineEndings": "off", 3 | "noTrailingSpaces": true, 4 | "noEncodedPasswords": true, 5 | "hasDoxygenHeader": true, 6 | "noSpacesInFileNames": true, 7 | "lowerCaseFileNames": true, 8 | "maxLineLength": 80, 9 | "maxHeaderLineLength": 80, 10 | "maxDataLineLength": 80, 11 | "noTabIndentation": true, 12 | "indentationMultiple": 2, 13 | "hasMacroNameInMend": true, 14 | "noNestedMacros": true, 15 | "hasMacroParentheses": true, 16 | "strictMacroDefinition": true, 17 | "noGremlins": true, 18 | "defaultHeader": "/**{lineEnding} @file{lineEnding} @brief {lineEnding}

SAS Macros

{lineEnding}**/" 19 | } -------------------------------------------------------------------------------- /src/utils/appendExtraResponseAttributes.ts: -------------------------------------------------------------------------------- 1 | import { ExtraResponseAttributes } from '@sasjs/utils/types' 2 | 3 | export async function appendExtraResponseAttributes( 4 | response: any, 5 | extraResponseAttributes: ExtraResponseAttributes[] 6 | ) { 7 | let responseObject = {} 8 | 9 | if (extraResponseAttributes?.length) { 10 | const extraAttributes = extraResponseAttributes.reduce( 11 | (map: any, obj: any) => ((map[obj] = response[obj]), map), 12 | {} 13 | ) 14 | 15 | responseObject = { 16 | result: response.result, 17 | ...extraAttributes 18 | } 19 | } else responseObject = response.result 20 | 21 | return responseObject 22 | } 23 | -------------------------------------------------------------------------------- /sasjs-tests/src/components/TestSuite.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | background: white; 4 | border-radius: 8px; 5 | padding: 20px; 6 | margin-bottom: 20px; 7 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); 8 | } 9 | 10 | .header { 11 | display: flex; 12 | justify-content: space-between; 13 | align-items: center; 14 | margin-bottom: 15px; 15 | padding-bottom: 10px; 16 | border-bottom: 1px solid #ecf0f1; 17 | } 18 | 19 | h2 { 20 | color: #2c3e50; 21 | font-size: 20px; 22 | margin: 0; 23 | } 24 | 25 | .stats { 26 | font-size: 14px; 27 | color: #7f8c8d; 28 | } 29 | 30 | .tests { 31 | display: grid; 32 | grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); 33 | gap: 15px; 34 | } 35 | -------------------------------------------------------------------------------- /sasjs-tests/src/types/test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | export interface Test { 3 | title: string 4 | description: string 5 | beforeTest?: (...args: any) => Promise 6 | afterTest?: (...args: any) => Promise 7 | test: (context: any) => Promise 8 | assertion: (...args: any) => boolean 9 | } 10 | 11 | export interface TestSuite { 12 | name: string 13 | tests: Test[] 14 | beforeAll?: (...args: any) => Promise 15 | afterAll?: (...args: any) => Promise 16 | } 17 | 18 | export interface TestResult { 19 | result: boolean 20 | error: Error | null 21 | executionTime: number 22 | } 23 | 24 | export type TestStatus = 'pending' | 'running' | 'passed' | 'failed' 25 | -------------------------------------------------------------------------------- /cypress/plugins/cy-ts-preprocessor.js: -------------------------------------------------------------------------------- 1 | const wp = require('@cypress/webpack-preprocessor') 2 | 3 | const webpackOptions = { 4 | resolve: { 5 | extensions: ['.ts', '.js'] 6 | }, 7 | module: { 8 | rules: [ 9 | { 10 | test: /\.ts$/, 11 | loaders: ['ts-loader'], 12 | exclude: [/node_modules/] 13 | }, 14 | { 15 | test: /\.(html|css)$/, 16 | loader: 'raw-loader', 17 | exclude: /\.async\.(html|css)$/ 18 | }, 19 | { 20 | test: /\.async\.(html|css)$/, 21 | loaders: ['file?name=[name].[hash].[ext]', 'extract'] 22 | } 23 | ] 24 | } 25 | } 26 | 27 | const options = { 28 | webpackOptions 29 | } 30 | 31 | module.exports = wp(options) 32 | -------------------------------------------------------------------------------- /.git-hooks/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | RED="\033[1;31m" 3 | GREEN="\033[1;32m" 4 | 5 | # Get the commit message (the parameter we're given is just the path to the 6 | # temporary file which holds the message). 7 | commit_message=$(cat "$1") 8 | 9 | if (echo "$commit_message" | grep -Eq "^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9 -\*]+\))?!?: .+$") then 10 | echo "${GREEN} ✔ Commit message meets Conventional Commit standards" 11 | exit 0 12 | fi 13 | 14 | echo "${RED}❌ Commit message does not meet the Conventional Commit standard!" 15 | echo "An example of a valid message is:" 16 | echo " feat(login): add the 'remember me' button" 17 | echo "ℹ More details at: https://www.conventionalcommits.org/en/v1.0.0/#summary" 18 | exit 1 -------------------------------------------------------------------------------- /src/api/viya/getFileStream.ts: -------------------------------------------------------------------------------- 1 | import { isFolder } from '@sasjs/utils/file' 2 | import { generateTimestamp } from '@sasjs/utils/time' 3 | import { Job } from '../../types' 4 | 5 | export const getFileStream = async (job: Job, filePath?: string) => { 6 | const { createWriteStream } = require('@sasjs/utils/file') 7 | const logPath = filePath || process.cwd() 8 | const isFolderPath = await isFolder(logPath) 9 | if (isFolderPath) { 10 | const logFileName = `${job.name || 'job'}-${generateTimestamp()}.log` 11 | const path = require('path') 12 | const logFilePath = path.join(filePath || process.cwd(), logFileName) 13 | return await createWriteStream(logFilePath) 14 | } else { 15 | return await createWriteStream(logPath) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /sasjs-tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "types": ["vite/client"], 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "erasableSyntaxOnly": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["src"] 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/spec/getFormData.spec.ts: -------------------------------------------------------------------------------- 1 | import { getFormData } from '..' 2 | import * as isNodeModule from '../isNode' 3 | import NodeFormData from 'form-data' 4 | 5 | describe('getFormData', () => { 6 | it('should return NodeFormData if environment is Node', () => { 7 | jest.spyOn(isNodeModule, 'isNode').mockImplementation(() => true) 8 | 9 | expect(getFormData() instanceof NodeFormData).toEqual(true) 10 | }) 11 | 12 | it('should return FormData if environment is not Node', () => { 13 | // Ensure FormData is globally available 14 | ;(global as any).FormData = class FormData {} 15 | 16 | jest.spyOn(isNodeModule, 'isNode').mockImplementation(() => false) 17 | 18 | expect(getFormData() instanceof FormData).toEqual(true) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /src/utils/getValidJson.ts: -------------------------------------------------------------------------------- 1 | import { JsonParseArrayError, InvalidJsonError } from '../types/errors' 2 | 3 | /** 4 | * if string passed then parse the string to json else if throw error for all other types unless it is not a valid json object. 5 | * @param str - string to check. 6 | */ 7 | export const getValidJson = (str: string | object): object => { 8 | try { 9 | if (str === null || str === undefined) throw new InvalidJsonError() 10 | 11 | if (Array.isArray(str)) throw new JsonParseArrayError() 12 | 13 | if (typeof str === 'object') return str 14 | 15 | if (str === '') return {} 16 | 17 | return JSON.parse(str) 18 | } catch (e: any) { 19 | if (e instanceof JsonParseArrayError) throw e 20 | 21 | throw new InvalidJsonError() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/types/FileResource.ts: -------------------------------------------------------------------------------- 1 | export interface FileResource { 2 | creationTimeStamp: string 3 | modifiedTimeStamp: string 4 | createdBy: string 5 | modifiedBy: string 6 | id: string 7 | properties: Properties 8 | contentDisposition: string 9 | contentType: string 10 | encoding: string 11 | links: Link[] 12 | name: string 13 | size: number 14 | searchable: boolean 15 | fileStatus: string 16 | fileVersion: number 17 | typeDefName: string 18 | version: number 19 | virusDetected: boolean 20 | urlDetected: boolean 21 | quarantine: boolean 22 | } 23 | 24 | export interface Link { 25 | method: string 26 | rel: string 27 | href: string 28 | uri: string 29 | type?: string 30 | responseType?: string 31 | } 32 | 33 | export interface Properties {} 34 | -------------------------------------------------------------------------------- /src/auth/verifySas9Login.ts: -------------------------------------------------------------------------------- 1 | import { delay } from '../utils' 2 | import { getExpectedLogInSuccessHeader } from './' 3 | 4 | export async function verifySas9Login(loginPopup: Window): Promise<{ 5 | isLoggedIn: boolean 6 | }> { 7 | let isLoggedIn = false 8 | let startTime = new Date() 9 | let elapsedSeconds = 0 10 | 11 | do { 12 | await delay(1000) 13 | if (loginPopup.closed) break 14 | 15 | isLoggedIn = 16 | loginPopup.window.location.href.includes('SASLogon') && 17 | loginPopup.window.document.body.innerText.includes( 18 | getExpectedLogInSuccessHeader() 19 | ) 20 | 21 | elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000 22 | } while (!isLoggedIn && elapsedSeconds < 5 * 60) 23 | 24 | return { isLoggedIn } 25 | } 26 | -------------------------------------------------------------------------------- /src/types/Session.ts: -------------------------------------------------------------------------------- 1 | import { Link } from './Link' 2 | import { SessionManager } from '../SessionManager' 3 | 4 | export enum SessionState { 5 | Completed = 'completed', 6 | Running = 'running', 7 | Pending = 'pending', 8 | Idle = 'idle', 9 | Unavailable = 'unavailable', 10 | NoState = '', 11 | Failed = 'failed', 12 | Error = 'error' 13 | } 14 | 15 | export interface Session { 16 | id: string 17 | state: SessionState 18 | stateUrl: string 19 | links: Link[] 20 | attributes: { 21 | sessionInactiveTimeout: number 22 | } 23 | creationTimeStamp: string 24 | etag: string 25 | } 26 | 27 | export interface SessionVariable { 28 | value: string 29 | } 30 | 31 | export interface JobSessionManager { 32 | session: Session 33 | sessionManager: SessionManager 34 | } 35 | -------------------------------------------------------------------------------- /sasjs-tests/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | body { 8 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, 9 | Ubuntu, Cantarell, sans-serif; 10 | line-height: 1.6; 11 | color: #333; 12 | background: #f5f5f5; 13 | } 14 | 15 | .app__error { 16 | max-width: 800px; 17 | margin: 50px auto; 18 | padding: 30px; 19 | background: white; 20 | border-radius: 8px; 21 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 22 | 23 | h1 { 24 | color: #e74c3c; 25 | margin-bottom: 15px; 26 | } 27 | 28 | p { 29 | margin-bottom: 15px; 30 | } 31 | 32 | pre { 33 | background: #2c3e50; 34 | color: #ecf0f1; 35 | padding: 15px; 36 | border-radius: 4px; 37 | overflow-x: auto; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/isIeOrEdge.ts: -------------------------------------------------------------------------------- 1 | export function isIEorEdgeOrOldFirefox() { 2 | if (typeof window === 'undefined') { 3 | return false 4 | } 5 | const ua = window.navigator.userAgent 6 | 7 | if (ua.indexOf('Firefox') > 0) { 8 | const version = parseInt( 9 | ua.substring(ua.lastIndexOf('Firefox/') + 8, ua.length), 10 | 10 11 | ) 12 | return version <= 60 13 | } 14 | 15 | const msie = ua.indexOf('MSIE ') 16 | if (msie > 0) { 17 | // IE 10 or older => return version number 18 | return true 19 | } 20 | 21 | const trident = ua.indexOf('Trident/') 22 | if (trident > 0) { 23 | return true 24 | } 25 | 26 | const edge = ua.indexOf('Edge/') 27 | if (edge > 0) { 28 | // Edge (IE 12+) => return version number 29 | return true 30 | } 31 | 32 | // other browser 33 | return false 34 | } 35 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Issue 2 | 3 | Link any related issue(s) in this section. 4 | 5 | ## Intent 6 | 7 | What this PR intends to achieve. 8 | 9 | ## Implementation 10 | 11 | What code changes have been made to achieve the intent. 12 | 13 | ## Checks 14 | 15 | No PR (that involves a non-trivial code change) should be merged, unless all items below are confirmed! If an urgent fix is needed - use a tar file. 16 | 17 | - [ ] Unit tests coverage has been increased and a new threshold is set. 18 | - [ ] All `sasjs-cli` unit tests are passing (`npm test`). 19 | - (CI Runs this) All `sasjs-tests` are passing. If you want to run it manually (instructions available [here](https://github.com/sasjs/adapter/blob/master/sasjs-tests/README.md)). 20 | - [ ] [Data Controller](https://datacontroller.io) builds and is functional on both SAS 9 and Viya 21 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './appendExtraResponseAttributes' 2 | export * from './asyncForEach' 3 | export * from './compareTimestamps' 4 | export * from './convertToCsv' 5 | export * from './createAxiosInstance' 6 | export * from './delay' 7 | export * from './fetchLogByChunks' 8 | export * from './getValidJson' 9 | export * from './isNode' 10 | export * from './isRelativePath' 11 | export * from './isUri' 12 | export * from './isUrl' 13 | export * from './needsRetry' 14 | export * from './parseGeneratedCode' 15 | export * from './parseSasViyaLog' 16 | export * from './parseSourceCode' 17 | export * from './parseViyaDebugResponse' 18 | export * from './parseWeboutResponse' 19 | export * from './serialize' 20 | export * from './splitChunks' 21 | export * from './validateInput' 22 | export * from './getFormData' 23 | export * from './getUserLanguage' 24 | -------------------------------------------------------------------------------- /sasjs-tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sasjs-tests-new", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "start": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "update:adapter": "cd .. && npm run package:lib && cd sasjs-tests && npm i ../build/sasjs-adapter-5.0.0.tgz", 11 | "deploy:tests": "rsync -avhe ssh ./dist/* --delete $SSH_ACCOUNT:$DEPLOY_PATH || npm run deploy:tests-win", 12 | "deploy:tests-win": "scp %DEPLOY_PATH% ./dist/*", 13 | "deploy": "npm run update:adapter && npm run build && npm run deploy:tests" 14 | }, 15 | "devDependencies": { 16 | "typescript": "~5.9.3", 17 | "vite": "npm:rolldown-vite@7.2.2" 18 | }, 19 | "overrides": { 20 | "vite": "npm:rolldown-vite@7.2.2" 21 | }, 22 | "dependencies": { 23 | "@sasjs/adapter": "^4.14.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/types/errors/RootFolderNotFoundError.ts: -------------------------------------------------------------------------------- 1 | import { decodeToken } from '@sasjs/utils/auth' 2 | 3 | export class RootFolderNotFoundError extends Error { 4 | constructor( 5 | parentFolderPath: string, 6 | serverUrl: string, 7 | accessToken?: string 8 | ) { 9 | let message: string = 10 | `Root folder ${parentFolderPath} was not found.` + 11 | `\nPlease check ${serverUrl}/SASDrive.` + 12 | `\nIf the folder DOES exist then it is likely a permission problem.\n` 13 | if (accessToken) { 14 | const decodedToken = decodeToken(accessToken) 15 | let scope = decodedToken.scope 16 | scope = scope.map((element) => '* ' + element) 17 | message += 18 | `Your access token contains the following scopes:\n` + scope.join('\n') 19 | } 20 | super(message) 21 | this.name = 'RootFolderNotFoundError' 22 | Object.setPrototypeOf(this, RootFolderNotFoundError.prototype) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/types/Tables.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentError } from './errors' 2 | 3 | export class Tables { 4 | _tables: { [macroName: string]: Record } 5 | 6 | constructor(table: Record, macroName: string) { 7 | this._tables = {} 8 | 9 | this.add(table, macroName) 10 | } 11 | 12 | add(table: Record | null, macroName: string) { 13 | if (table && macroName) { 14 | if (!(table instanceof Array)) { 15 | throw new ArgumentError('First argument must be array') 16 | } 17 | if (typeof macroName !== 'string') { 18 | throw new ArgumentError('Second argument must be string') 19 | } 20 | if (!isNaN(Number(macroName[macroName.length - 1]))) { 21 | throw new ArgumentError('Macro name cannot have number at the end') 22 | } 23 | } else { 24 | throw new ArgumentError('Missing arguments') 25 | } 26 | 27 | this._tables[macroName] = table 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /src/types/Context.ts: -------------------------------------------------------------------------------- 1 | export interface Context { 2 | name: string 3 | id: string 4 | createdBy: string 5 | version: number 6 | attributes?: any 7 | } 8 | 9 | export interface EditContextInput { 10 | name?: string 11 | description?: string 12 | launchContext?: { name: string } 13 | environment?: { options?: string[]; autoExecLines?: string[] } 14 | attributes?: any 15 | authorizedUsers?: string[] 16 | authorizeAllAuthenticatedUsers?: boolean 17 | id?: string 18 | } 19 | 20 | export interface ContextAllAttributes { 21 | attributes: { 22 | reuseServerProcesses: boolean 23 | runServerAs: string 24 | } 25 | modifiedTimeStamp: string 26 | createdBy: string 27 | creationTimeStamp: string 28 | launchType: string 29 | environment: { 30 | autoExecLines: [string] 31 | } 32 | launchContext: { 33 | contextName: string 34 | } 35 | modifiedBy: string 36 | id: string 37 | version: number 38 | name: string 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/spec/parseSasViyaLog.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseSasViyaLog } from '../parseSasViyaLog' 2 | 3 | describe('parseSasViyaLog', () => { 4 | it('should parse sas viya log if environment is Node', () => { 5 | const logResponse = { 6 | items: [{ line: 'Line 1' }, { line: 'Line 2' }, { line: 'Line 3' }] 7 | } 8 | 9 | const expectedLog = 'Line 1\nLine 2\nLine 3' 10 | const result = parseSasViyaLog(logResponse) 11 | expect(result).toEqual(expectedLog) 12 | }) 13 | 14 | it('should handle exceptions and return the original logResponse', () => { 15 | // Create a logResponse that will cause an error in the mapping process. 16 | const logResponse: any = { 17 | items: null 18 | } 19 | // Since logResponse.items is null, the ternary operator returns the else branch. 20 | const expectedLog = JSON.stringify(logResponse) 21 | const result = parseSasViyaLog(logResponse) 22 | expect(result).toEqual(expectedLog) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /createTSDocs.js: -------------------------------------------------------------------------------- 1 | const td = require('typedoc') 2 | const ts = require('typescript') 3 | 4 | const typedocJson = require('./typedoc.json') 5 | 6 | async function createTSDocs() { 7 | if (!typedocJson.entryPoints?.length) { 8 | throw new Error( 9 | 'Typedoc error: entryPoints option is missing in typedoc configuration.' 10 | ) 11 | } 12 | 13 | if (!typedocJson.out) { 14 | throw new Error( 15 | 'Typedoc error: out option is missing in typedoc configuration.' 16 | ) 17 | } 18 | const app = new td.Application() 19 | app.options.addReader(new td.TSConfigReader()) 20 | 21 | app.bootstrap({ 22 | ...typedocJson, 23 | tsconfig: 'tsconfig.json' 24 | }) 25 | 26 | const project = app.converter.convert(app.getEntryPoints() ?? []) 27 | 28 | if (project) { 29 | await app.generateDocs(project, typedocJson.out) 30 | } else { 31 | throw new Error('Typedoc error: error creating the TS docs.') 32 | } 33 | } 34 | 35 | createTSDocs() 36 | -------------------------------------------------------------------------------- /src/utils/sas9/extractUserLongNameSas9.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Dictionary should contain only languages in SAS where `logout` text 3 | * is represented with more then one word 4 | */ 5 | const dictionary = ['Log Off'] 6 | 7 | /** 8 | * Extracts user full name assuming the first word after "title" means log off if not found otherwise in the dictionary 9 | * @param response SAS response content 10 | * @returns user full name 11 | */ 12 | export const extractUserLongNameSas9 = (response: string) => { 13 | const regex = /"title":\s?".*?"/ 14 | 15 | const matched = response?.match(regex) 16 | let fullName = matched?.[0].split(':')[1].trim() 17 | let breakIndex = fullName?.indexOf(' ') 18 | 19 | if (!fullName) return 'unknown' 20 | 21 | dictionary.map((logoutWord) => { 22 | const index = fullName?.indexOf(logoutWord) || -1 23 | 24 | if (index > -1) { 25 | breakIndex = index + logoutWord.length 26 | } 27 | }) 28 | 29 | //Cut only name 30 | return fullName.slice(breakIndex, -1).trim() 31 | } 32 | -------------------------------------------------------------------------------- /src/types/Tables.spec.ts: -------------------------------------------------------------------------------- 1 | import SASjs from '../SASjs' 2 | 3 | describe('Tables - basic coverage', () => { 4 | const adapter = new SASjs() 5 | 6 | it('should throw an error if first argument is not an array', () => { 7 | expect(() => adapter.Tables({}, 'test')).toThrow('First argument') 8 | }) 9 | 10 | it('should throw an error if second argument is not a string', () => { 11 | // @ts-expect-error 12 | expect(() => adapter.Tables([], 1234)).toThrow('Second argument') 13 | }) 14 | 15 | it('should throw an error if macro name ends with a number', () => { 16 | expect(() => adapter.Tables([], 'test1')).toThrow('number at the end') 17 | }) 18 | 19 | it('should throw an error if no arguments are passed', () => { 20 | // @ts-expect-error 21 | expect(() => adapter.Tables()).toThrow('Missing arguments') 22 | }) 23 | 24 | it('should create Tables class successfully with _tables property', () => { 25 | const tables = adapter.Tables([], 'test') 26 | expect(tables).toHaveProperty('_tables') 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /sasjs-tests/sasjs/doxy/new_footer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 21 | 22 | 23 | 24 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/types/errors/spec/SAS9AuthError.spec.ts: -------------------------------------------------------------------------------- 1 | import { SAS9AuthError } from '../SAS9AuthError' 2 | 3 | describe('SAS9AuthError', () => { 4 | it('should have the correct error message', () => { 5 | const error = new SAS9AuthError() 6 | expect(error.message).toBe( 7 | 'The credentials you provided cannot be authenticated. Please provide a valid set of credentials.' 8 | ) 9 | }) 10 | 11 | it('should have the correct error name', () => { 12 | const error = new SAS9AuthError() 13 | expect(error.name).toBe('AuthorizeError') 14 | }) 15 | 16 | it('should be an instance of SAS9AuthError', () => { 17 | const error = new SAS9AuthError() 18 | expect(error).toBeInstanceOf(SAS9AuthError) 19 | }) 20 | 21 | it('should be an instance of Error', () => { 22 | const error = new SAS9AuthError() 23 | expect(error).toBeInstanceOf(Error) 24 | }) 25 | 26 | it('should set the prototype correctly', () => { 27 | const error = new SAS9AuthError() 28 | expect(Object.getPrototypeOf(error)).toBe(SAS9AuthError.prototype) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /src/test/utils/isUrl.spec.ts: -------------------------------------------------------------------------------- 1 | import { isUrl } from '../../utils/isUrl' 2 | 3 | describe('urlValidator', () => { 4 | it('should return true with an HTTP URL', () => { 5 | const url = 'http://google.com' 6 | 7 | expect(isUrl(url)).toEqual(true) 8 | }) 9 | 10 | it('should return true with an HTTPS URL', () => { 11 | const url = 'https://google.com' 12 | 13 | expect(isUrl(url)).toEqual(true) 14 | }) 15 | 16 | it('should return true when the URL is blank', () => { 17 | const url = '' 18 | 19 | expect(isUrl(url)).toEqual(false) 20 | }) 21 | 22 | it('should return false when the URL has not supported protocol', () => { 23 | const url = 'htpps://google.com' 24 | 25 | expect(isUrl(url)).toEqual(false) 26 | }) 27 | 28 | it('should return false when the URL is null', () => { 29 | const url = null 30 | 31 | expect(isUrl(url as unknown as string)).toEqual(false) 32 | }) 33 | 34 | it('should return false when the URL is undefined', () => { 35 | const url = undefined 36 | 37 | expect(isUrl(url as unknown as string)).toEqual(false) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /sasjs-tests/src/core/runTest.ts: -------------------------------------------------------------------------------- 1 | import type { Test, TestResult } from '../types' 2 | 3 | export async function runTest( 4 | testToRun: Test, 5 | context: unknown 6 | ): Promise { 7 | const { test, assertion, beforeTest, afterTest } = testToRun 8 | const beforeTestFunction = beforeTest ? beforeTest : () => Promise.resolve() 9 | const afterTestFunction = afterTest ? afterTest : () => Promise.resolve() 10 | 11 | const startTime = new Date().valueOf() 12 | 13 | return beforeTestFunction() 14 | .then(() => test(context)) 15 | .then((res) => { 16 | return Promise.resolve(assertion(res, context)) 17 | }) 18 | .then((testResult) => { 19 | afterTestFunction() 20 | const endTime = new Date().valueOf() 21 | const executionTime = (endTime - startTime) / 1000 22 | return { result: testResult, error: null, executionTime } 23 | }) 24 | .catch((e) => { 25 | console.error(e) 26 | const endTime = new Date().valueOf() 27 | const executionTime = (endTime - startTime) / 1000 28 | return { result: false, error: e, executionTime } 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Macro People 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /sasjs-tests/src/components/LoginForm.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | max-width: 400px; 4 | margin: 100px auto; 5 | padding: 40px; 6 | background: white; 7 | border-radius: 8px; 8 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 9 | } 10 | 11 | h1 { 12 | text-align: center; 13 | margin-bottom: 30px; 14 | color: #2c3e50; 15 | } 16 | 17 | form { 18 | display: flex; 19 | flex-direction: column; 20 | gap: 15px; 21 | } 22 | 23 | label { 24 | font-weight: 600; 25 | margin-bottom: 5px; 26 | } 27 | 28 | input { 29 | padding: 10px; 30 | border: 1px solid #ddd; 31 | border-radius: 4px; 32 | font-size: 14px; 33 | 34 | &:focus { 35 | outline: none; 36 | border-color: #3498db; 37 | } 38 | } 39 | 40 | button { 41 | padding: 12px; 42 | background: #3498db; 43 | color: white; 44 | border: none; 45 | border-radius: 4px; 46 | font-size: 16px; 47 | font-weight: 600; 48 | cursor: pointer; 49 | transition: background 0.2s; 50 | 51 | &:hover:not(:disabled) { 52 | background: #2980b9; 53 | } 54 | 55 | &:disabled { 56 | opacity: 0.6; 57 | cursor: not-allowed; 58 | } 59 | } 60 | 61 | .error { 62 | color: #e74c3c; 63 | font-size: 14px; 64 | min-height: 20px; 65 | } 66 | -------------------------------------------------------------------------------- /src/auth/openWebPage.ts: -------------------------------------------------------------------------------- 1 | import { openLoginPrompt } from '../utils/loginPrompt' 2 | 3 | interface WindowFeatures { 4 | width: number 5 | height: number 6 | } 7 | 8 | const defaultWindowFeatures: WindowFeatures = { width: 500, height: 600 } 9 | 10 | export async function openWebPage( 11 | url: string, 12 | windowName: string = '', 13 | WindowFeatures: WindowFeatures = defaultWindowFeatures, 14 | onLoggedOut?: () => Promise 15 | ): Promise { 16 | const { width, height } = WindowFeatures 17 | const left = screen.width / 2 - width / 2 18 | const top = screen.height / 2 - height / 2 19 | 20 | const loginPopup = window.open( 21 | url, 22 | windowName, 23 | `toolbar=0,location=0,menubar=0,width=${width},height=${height},left=${left},top=${top}` 24 | ) 25 | 26 | if (!loginPopup) { 27 | const getUserAction: () => Promise = onLoggedOut ?? openLoginPrompt 28 | 29 | const doLogin = await getUserAction() 30 | return doLogin 31 | ? window.open( 32 | url, 33 | windowName, 34 | `toolbar=0,location=0,menubar=0,width=${width},height=${height},left=${left},top=${top}` 35 | ) 36 | : null 37 | } 38 | 39 | return loginPopup 40 | } 41 | -------------------------------------------------------------------------------- /src/auth/spec/verifySas9Login.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import { verifySas9Login } from '../verifySas9Login' 5 | import * as delayModule from '../../utils/delay' 6 | import { getExpectedLogInSuccessHeader } from '../' 7 | 8 | describe('verifySas9Login', () => { 9 | const serverUrl = 'http://test-server.com' 10 | 11 | beforeAll(() => { 12 | jest.mock('../../utils') 13 | jest 14 | .spyOn(delayModule, 'delay') 15 | .mockImplementation(() => Promise.resolve({})) 16 | }) 17 | 18 | it('should return isLoggedIn true by checking state of popup', async () => { 19 | const popup = { 20 | window: { 21 | location: { href: serverUrl + `/SASLogon` }, 22 | document: { 23 | body: { innerText: `

${getExpectedLogInSuccessHeader()}

` } 24 | } 25 | } 26 | } as unknown as Window 27 | 28 | await expect(verifySas9Login(popup)).resolves.toEqual({ 29 | isLoggedIn: true 30 | }) 31 | }) 32 | 33 | it('should return isLoggedIn false if user closed popup, already', async () => { 34 | const popup: Window = { closed: true } as unknown as Window 35 | 36 | await expect(verifySas9Login(popup)).resolves.toEqual({ 37 | isLoggedIn: false 38 | }) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /src/test/SAS_server_app.ts: -------------------------------------------------------------------------------- 1 | import express = require('express') 2 | import cors from 'cors' 3 | 4 | export const app = express() 5 | 6 | app.use( 7 | cors({ 8 | origin: 'http://localhost', // Allow requests only from this origin 9 | credentials: true // Allow credentials (cookies, auth headers, etc.) 10 | }) 11 | ) 12 | 13 | export const mockedAuthResponse = { 14 | access_token: 'access_token', 15 | token_type: 'bearer', 16 | id_token: 'id_token', 17 | refresh_token: 'refresh_token', 18 | expires_in: 43199, 19 | scope: 'openid', 20 | jti: 'jti' 21 | } 22 | 23 | app.get('/', (req: any, res: any) => { 24 | res.send('Hello World') 25 | }) 26 | 27 | app.post('/SASLogon/oauth/token', (req: any, res: any) => { 28 | let valid = true 29 | 30 | // capture the encoded form data 31 | req.on('data', (data: any) => { 32 | const resData = data.toString() 33 | 34 | if (resData.includes('incorrect')) valid = false 35 | }) 36 | 37 | // send a response when finished reading 38 | // the encoded form data 39 | req.on('end', () => { 40 | if (valid) res.status(200).send(mockedAuthResponse) 41 | else 42 | res.status(401).send({ 43 | error: 'unauthorized', 44 | error_description: 'Bad credentials' 45 | }) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /sasjs-tests/src/testSuites/FileUpload.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-const */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | import SASjs from '@sasjs/adapter' 4 | import type { TestSuite } from '../types' 5 | 6 | export const fileUploadTests = (adapter: SASjs): TestSuite => ({ 7 | name: 'File Upload Tests', 8 | tests: [ 9 | { 10 | title: 'Upload File', 11 | description: 'Should upload the file to VIYA', 12 | test: async () => { 13 | let blob: any = new Blob(['test'], { type: 'text/html' }) 14 | blob['lastModifiedDate'] = '' 15 | blob['name'] = 'macvars_testfile' 16 | let file = blob 17 | 18 | const filesToUpload = [ 19 | { 20 | file: file, 21 | fileName: file.name 22 | } 23 | ] 24 | 25 | return adapter.uploadFile('common/sendMacVars', filesToUpload, null) 26 | }, 27 | assertion: (response: any) => 28 | (response.macvars as any[]).findIndex( 29 | (el: any) => el.NAME === '_WEBIN_FILE_COUNT' && el.VALUE === '1' 30 | ) > -1 && 31 | (response.macvars as any[]).findIndex( 32 | (el: any) => 33 | el.NAME === '_WEBIN_FILENAME' && el.VALUE === 'macvars_testfile' 34 | ) > -1 35 | } 36 | ] 37 | }) 38 | -------------------------------------------------------------------------------- /src/auth/verifySasViyaLogin.ts: -------------------------------------------------------------------------------- 1 | import { delay } from '../utils' 2 | import { getExpectedLogInSuccessHeader } from './' 3 | 4 | export async function verifySasViyaLogin(loginPopup: Window): Promise<{ 5 | isLoggedIn: boolean 6 | }> { 7 | let isLoggedIn = false 8 | let startTime = new Date() 9 | let elapsedSeconds = 0 10 | 11 | do { 12 | await delay(1000) 13 | 14 | if (loginPopup.closed) break 15 | 16 | isLoggedIn = isLoggedInSASVIYA() 17 | 18 | elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000 19 | } while (!isLoggedIn && elapsedSeconds < 5 * 60) 20 | 21 | let isAuthorized = false 22 | 23 | startTime = new Date() 24 | 25 | do { 26 | await delay(1000) 27 | 28 | if (loginPopup.closed) break 29 | 30 | isAuthorized = 31 | loginPopup.window.location.href.includes('SASLogon') || 32 | loginPopup.window.document.body?.innerText?.includes( 33 | getExpectedLogInSuccessHeader() 34 | ) 35 | 36 | elapsedSeconds = (new Date().valueOf() - startTime.valueOf()) / 1000 37 | } while (!isAuthorized && elapsedSeconds < 5 * 60) 38 | 39 | return { isLoggedIn: isLoggedIn && isAuthorized } 40 | } 41 | 42 | export const isLoggedInSASVIYA = () => 43 | document.cookie.includes('Current-User') && document.cookie.includes('userId') 44 | -------------------------------------------------------------------------------- /src/auth/spec/verifySasViyaLogin.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment jsdom 3 | */ 4 | import { verifySasViyaLogin } from '../verifySasViyaLogin' 5 | import * as delayModule from '../../utils/delay' 6 | import { getExpectedLogInSuccessHeader } from '../' 7 | 8 | describe('verifySasViyaLogin', () => { 9 | const serverUrl = 'http://test-server.com' 10 | 11 | beforeAll(() => { 12 | jest.mock('../../utils') 13 | jest 14 | .spyOn(delayModule, 'delay') 15 | .mockImplementation(() => Promise.resolve({})) 16 | document.cookie = encodeURIComponent('Current-User={"userId":"user-hash"}') 17 | }) 18 | 19 | it('should return isLoggedIn true by checking state of popup', async () => { 20 | const popup = { 21 | window: { 22 | location: { href: serverUrl + `/SASLogon` }, 23 | document: { 24 | body: { innerText: `

${getExpectedLogInSuccessHeader()}

` } 25 | } 26 | } 27 | } as unknown as Window 28 | 29 | await expect(verifySasViyaLogin(popup)).resolves.toEqual({ 30 | isLoggedIn: true 31 | }) 32 | }) 33 | 34 | it('should return isLoggedIn false if user closed popup, already', async () => { 35 | const popup: Window = { closed: true } as unknown as Window 36 | 37 | await expect(verifySasViyaLogin(popup)).resolves.toEqual({ 38 | isLoggedIn: false 39 | }) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/file/generateTableUploadForm.ts: -------------------------------------------------------------------------------- 1 | import NodeFormData from 'form-data' 2 | import { convertToCSV, isFormatsTable } from '../utils/convertToCsv' 3 | import { splitChunks } from '../utils/splitChunks' 4 | 5 | export const generateTableUploadForm = ( 6 | formData: FormData | NodeFormData, 7 | data: any 8 | ) => { 9 | const sasjsTables = [] 10 | const requestParams: any = {} 11 | let tableCounter = 0 12 | 13 | for (const tableName in data) { 14 | tableCounter++ 15 | 16 | // Formats table should not be sent as part of 'sasjs_tables' 17 | if (!isFormatsTable(tableName)) sasjsTables.push(tableName) 18 | 19 | const csv = convertToCSV(data, tableName) 20 | 21 | if (csv === 'ERROR: LARGE STRING LENGTH') { 22 | throw new Error( 23 | 'The max length of a string value in SASjs is 32765 characters.' 24 | ) 25 | } 26 | 27 | // if csv has length more then 16k, send in chunks 28 | if (csv.length > 16000) { 29 | const csvChunks = splitChunks(csv) 30 | 31 | // append chunks to form data with same key 32 | csvChunks.map((chunk) => { 33 | formData.append(`sasjs${tableCounter}data`, chunk) 34 | }) 35 | } else { 36 | requestParams[`sasjs${tableCounter}data`] = csv 37 | } 38 | } 39 | 40 | requestParams['sasjs_tables'] = sasjsTables.join(' ') 41 | 42 | return { formData, requestParams } 43 | } 44 | -------------------------------------------------------------------------------- /src/test/utils/parseSourceCode.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseSourceCode } from '../../utils/index' 2 | 3 | it('should parse SAS9 source code', async () => { 4 | expect(sampleResponse).toBeTruthy() 5 | 6 | const parsedSourceCode = parseSourceCode(sampleResponse) 7 | 8 | expect(parsedSourceCode).toBeTruthy() 9 | 10 | const sourceCodeLines = parsedSourceCode.split('\r\n') 11 | 12 | expect(sourceCodeLines.length).toEqual(5) 13 | expect(sourceCodeLines[0].startsWith('6')).toBeTruthy() 14 | expect(sourceCodeLines[1].startsWith('7')).toBeTruthy() 15 | expect(sourceCodeLines[2].startsWith('8')).toBeTruthy() 16 | expect(sourceCodeLines[3].startsWith('9')).toBeTruthy() 17 | expect(sourceCodeLines[4].startsWith('10')).toBeTruthy() 18 | }) 19 | 20 | /* tslint:disable */ 21 | const sampleResponse = ` 22 | 6 @file mm_webout.sas 23 | 7 @brief Send data to/from SAS Stored Processes 24 | 8 @details This macro should be added to the start of each Stored Process, 25 | 9 **immediately** followed by a call to: 26 | 10 %webout(OPEN) 27 | MPRINT(MM_WEBIN): ; 28 | MPRINT(MM_WEBLEFT): filename _temp temp lrecl=999999; 29 | MPRINT(MM_WEBOUT): data _null_; 30 | MPRINT(MM_WEBRIGHT): file _temp; 31 | MPRINT(MM_WEBOUT): if upcase(symget('_debug'))='LOG' then put '>>weboutBEGIN<<'; 32 | ` 33 | /* tslint:enable */ 34 | -------------------------------------------------------------------------------- /sasjs-tests/sasjs/sasjsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://cli.sasjs.io/sasjsconfig-schema.json", 3 | "serviceConfig": { 4 | "serviceFolders": ["sasjs/common"] 5 | }, 6 | "defaultTarget": "4gl", 7 | "targets": [ 8 | { 9 | "name": "4gl", 10 | "serverUrl": "https://sas9.4gl.io", 11 | "serverType": "SASJS", 12 | "httpsAgentOptions": { 13 | "allowInsecureRequests": false 14 | }, 15 | "appLoc": "/Public/app/adapter-tests", 16 | "deployConfig": { 17 | "deployServicePack": true, 18 | "deployScripts": [] 19 | }, 20 | "streamConfig": { 21 | "streamWeb": true, 22 | "streamWebFolder": "webv", 23 | "webSourcePath": "dist", 24 | "streamServiceName": "adapter-tests", 25 | "assetPaths": [] 26 | } 27 | }, 28 | { 29 | "name": "viya", 30 | "serverUrl": "", 31 | "serverType": "SASVIYA", 32 | "httpsAgentOptions": { 33 | "allowInsecureRequests": false 34 | }, 35 | "appLoc": "/Public/app/adapter-tests", 36 | "deployConfig": { 37 | "deployServicePack": true, 38 | "deployScripts": [] 39 | }, 40 | "streamConfig": { 41 | "streamWeb": true, 42 | "streamWebFolder": "webv", 43 | "webSourcePath": "dist", 44 | "streamServiceName": "adapter-tests", 45 | "assetPaths": [] 46 | } 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /src/api/viya/uploadTables.ts: -------------------------------------------------------------------------------- 1 | import { prefixMessage } from '@sasjs/utils/error' 2 | import { RequestClient } from '../../request/RequestClient' 3 | import { convertToCSV } from '../../utils/convertToCsv' 4 | 5 | /** 6 | * Uploads tables to SAS as specially formatted CSVs. 7 | * This is more compact than JSON, and easier to read within SAS. 8 | * @param requestClient - the pre-configured HTTP request client 9 | * @param data - the JSON representation of the data to be uploaded 10 | * @param accessToken - an optional access token for authentication/authorization 11 | * The access token is not required when uploading tables from the browser. 12 | */ 13 | export async function uploadTables( 14 | requestClient: RequestClient, 15 | data: any, 16 | accessToken?: string 17 | ) { 18 | const uploadedFiles = [] 19 | 20 | for (const tableName in data) { 21 | const csv = convertToCSV(data, tableName) 22 | if (csv === 'ERROR: LARGE STRING LENGTH') { 23 | throw new Error( 24 | 'The max length of a string value in SASjs is 32765 characters.' 25 | ) 26 | } 27 | 28 | const uploadResponse = await requestClient 29 | .uploadFile(`/files/files#rawUpload`, csv, accessToken) 30 | .catch((err) => { 31 | throw prefixMessage(err, 'Error while uploading file. ') 32 | }) 33 | 34 | uploadedFiles.push({ tableName, file: uploadResponse.result }) 35 | } 36 | return uploadedFiles 37 | } 38 | -------------------------------------------------------------------------------- /sasjs-tests/sasjs/doxy/Doxyfile: -------------------------------------------------------------------------------- 1 | ALPHABETICAL_INDEX = NO 2 | 3 | ENABLE_PREPROCESSING = NO 4 | EXTENSION_MAPPING = sas=Java ddl=Java 5 | EXTRACT_LOCAL_CLASSES = NO 6 | FILE_PATTERNS = *.sas \ 7 | *.ddl \ 8 | *.dox 9 | GENERATE_LATEX = NO 10 | GENERATE_TREEVIEW = YES 11 | HIDE_FRIEND_COMPOUNDS = YES 12 | HIDE_IN_BODY_DOCS = YES 13 | HIDE_SCOPE_NAMES = YES 14 | HIDE_UNDOC_CLASSES = YES 15 | HIDE_UNDOC_MEMBERS = YES 16 | HTML_OUTPUT = $(DOXY_HTML_OUTPUT) 17 | HTML_HEADER = $(HTML_HEADER) 18 | HTML_EXTRA_FILES = $(HTML_EXTRA_FILES) 19 | HTML_FOOTER = $(HTML_FOOTER) 20 | HTML_EXTRA_STYLESHEET = $(HTML_EXTRA_STYLESHEET) 21 | INHERIT_DOCS = NO 22 | INLINE_INFO = NO 23 | INPUT = $(DOXY_INPUT) 24 | LAYOUT_FILE = $(LAYOUT_FILE) 25 | USE_MDFILE_AS_MAINPAGE = README.md 26 | MAX_INITIALIZER_LINES = 0 27 | PROJECT_NAME = $(PROJECT_NAME) 28 | PROJECT_LOGO = $(PROJECT_LOGO) 29 | PROJECT_BRIEF = $(PROJECT_BRIEF) 30 | RECURSIVE = YES 31 | REPEAT_BRIEF = NO 32 | SHOW_NAMESPACES = NO 33 | SHOW_USED_FILES = NO 34 | SOURCE_BROWSER = YES 35 | SOURCE_TOOLTIPS = NO 36 | STRICT_PROTO_MATCHING = YES 37 | STRIP_CODE_COMMENTS = NO 38 | SUBGROUPING = NO 39 | TAB_SIZE = 2 40 | VERBATIM_HEADERS = NO -------------------------------------------------------------------------------- /src/auth/refreshTokensForSasjs.ts: -------------------------------------------------------------------------------- 1 | import { prefixMessage } from '@sasjs/utils/error' 2 | import { RequestClient } from '../request/RequestClient' 3 | import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix' 4 | import { ServerType } from '@sasjs/utils/types' 5 | 6 | /** 7 | * Exchanges the refresh token for an access token for the given client. 8 | * @param requestClient - the pre-configured HTTP request client 9 | * @param refreshToken - the refresh token received from the server. 10 | */ 11 | export async function refreshTokensForSasjs( 12 | requestClient: RequestClient, 13 | refreshToken: string 14 | ) { 15 | const url = '/SASjsApi/auth/refresh' 16 | const headers = { 17 | Authorization: 'Bearer ' + refreshToken 18 | } 19 | 20 | const authResponse = await requestClient 21 | .post(url, undefined, undefined, undefined, headers) 22 | .then((res) => { 23 | const sasAuth = res.result as { 24 | accessToken: string 25 | refreshToken: string 26 | } 27 | return { 28 | access_token: sasAuth.accessToken, 29 | refresh_token: sasAuth.refreshToken 30 | } 31 | }) 32 | .catch((err) => { 33 | throw prefixMessage( 34 | err, 35 | getTokenRequestErrorPrefix( 36 | 'refreshing tokens', 37 | 'refreshTokensForSasjs', 38 | ServerType.Sasjs, 39 | url 40 | ) 41 | ) 42 | }) 43 | 44 | return authResponse 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/generateDocs.yml: -------------------------------------------------------------------------------- 1 | name: Generate docs and Push to docs Branch 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | generate_and_push_docs: 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: 14 | node-version: [lts/iron] 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v2 19 | 20 | - name: Setup Node 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: 24 | ${{ matrix.node-version }} 25 | 26 | # 2. Restore npm cache manually 27 | - name: Restore npm cache 28 | uses: actions/cache@v3 29 | id: npm-cache 30 | with: 31 | path: ~/.npm 32 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 33 | restore-keys: | 34 | ${{ runner.os }}-node- 35 | 36 | - name: Install Dependencies 37 | run: npm ci 38 | 39 | - name: Ensure docs folder exists 40 | run: | 41 | rm -rf docs || true # avoid error if docs folder does not exist 42 | mkdir docs 43 | 44 | - name: Generate Docs 45 | run: npm run typedoc 46 | 47 | - name: Push generated docs 48 | uses: peaceiris/actions-gh-pages@v3 49 | with: 50 | github_token: ${{ secrets.GITHUB_TOKEN }} 51 | publish_branch: gh-pages 52 | publish_dir: ./docs 53 | cname: adapter.sasjs.io 54 | -------------------------------------------------------------------------------- /src/utils/formatDataForRequest.ts: -------------------------------------------------------------------------------- 1 | import { convertToCSV, isFormatsTable } from './convertToCsv' 2 | import { splitChunks } from './splitChunks' 3 | 4 | export const formatDataForRequest = (data: any) => { 5 | const sasjsTables = [] 6 | let tableCounter = 0 7 | const result: any = {} 8 | 9 | for (const tableName in data) { 10 | if ( 11 | isFormatsTable(tableName) && 12 | Object.keys(data).includes(tableName.replace(/^\$/, '')) 13 | ) { 14 | continue 15 | } 16 | 17 | tableCounter++ 18 | 19 | // Formats table should not be sent as part of 'sasjs_tables' 20 | if (!isFormatsTable(tableName)) sasjsTables.push(tableName) 21 | 22 | const csv = convertToCSV(data, tableName) 23 | 24 | if (csv === 'ERROR: LARGE STRING LENGTH') { 25 | throw new Error( 26 | 'The max length of a string value in SASjs is 32765 characters.' 27 | ) 28 | } 29 | 30 | // if csv has length more then 16k, send in chunks 31 | if (csv.length > 16000) { 32 | const csvChunks = splitChunks(csv) 33 | 34 | // append chunks to form data with same key 35 | result[`sasjs${tableCounter}data0`] = csvChunks.length 36 | 37 | csvChunks.forEach((chunk, index) => { 38 | result[`sasjs${tableCounter}data${index + 1}`] = chunk 39 | }) 40 | } else { 41 | result[`sasjs${tableCounter}data`] = csv 42 | } 43 | } 44 | 45 | result['sasjs_tables'] = sasjsTables.join(' ') 46 | 47 | return result 48 | } 49 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions to SASjs are very welcome! When making a PR, test cases should be included. 4 | 5 | ## Code Style 6 | 7 | This repository uses `Prettier` to ensure a uniform code style. 8 | If you are using VS Code for development, you can automatically fix your code to match the style as follows: 9 | 10 | - Install the `Prettier` extension for VS Code. 11 | - Open your `settings.json` file by choosing 'Preferences: Open Settings (JSON)' from the command palette. 12 | - Add the following items to the JSON. 13 | ``` 14 | "editor.formatOnSave": true, 15 | "editor.formatOnPaste": true, 16 | ``` 17 | 18 | If you are using another editor, or are unable to install the extension, you can run `npm run lint:fix` to fix the formatting after you've made your changes. 19 | 20 | ## Testing 21 | 22 | This repository contains a suite of tests built using [@sasjs/test-framework](https://github.com/sasjs/test-framework). 23 | 24 | Detailed instructions for creating and running the tests can be found [here](https://github.com/sasjs/adapter/blob/master/sasjs-tests/README.md). 25 | 26 | If you'd like to test your changes in an app that uses the adapter, you can do so as follows: 27 | 28 | 1. Run `npm run package:lib` from the root folder in this repository. 29 | This creates a tarball in the `/build` folder. 30 | 2. In your app's root folder, run `npm install `. 31 | This will install the changed version of the adapter in your app. 32 | -------------------------------------------------------------------------------- /src/auth/getAccessTokenForSasjs.ts: -------------------------------------------------------------------------------- 1 | import { prefixMessage } from '@sasjs/utils/error' 2 | import { RequestClient } from '../request/RequestClient' 3 | import { getTokenRequestErrorPrefix } from './getTokenRequestErrorPrefix' 4 | import { ServerType } from '@sasjs/utils/types' 5 | 6 | /** 7 | * Exchanges the auth code for an access token for the given client. 8 | * @param requestClient - the pre-configured HTTP request client 9 | * @param clientId - the client ID to authenticate with. 10 | * @param authCode - the auth code received from the server. 11 | */ 12 | export async function getAccessTokenForSasjs( 13 | requestClient: RequestClient, 14 | clientId: string, 15 | authCode: string 16 | ) { 17 | const url = '/SASjsApi/auth/token' 18 | const data = { 19 | clientId, 20 | code: authCode 21 | } 22 | 23 | return await requestClient 24 | .post(url, data, undefined) 25 | .then((res) => { 26 | const sasAuth = res.result as { 27 | accessToken: string 28 | refreshToken: string 29 | } 30 | return { 31 | access_token: sasAuth.accessToken, 32 | refresh_token: sasAuth.refreshToken 33 | } 34 | }) 35 | .catch((err) => { 36 | throw prefixMessage( 37 | err, 38 | getTokenRequestErrorPrefix( 39 | 'fetching access token', 40 | 'getAccessTokenForSasjs', 41 | ServerType.Sasjs, 42 | url, 43 | data, 44 | clientId 45 | ) 46 | ) 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /src/test/utils/getValidJson.spec.ts: -------------------------------------------------------------------------------- 1 | import { getValidJson } from '../../utils' 2 | import { JsonParseArrayError, InvalidJsonError } from '../../types/errors' 3 | 4 | describe('jsonValidator', () => { 5 | it('should not throw an error with a valid json', () => { 6 | const json = { 7 | test: 'test' 8 | } 9 | 10 | expect(getValidJson(json)).toBe(json) 11 | }) 12 | 13 | it('should not throw an error with a valid json string', () => { 14 | const json = { 15 | test: 'test' 16 | } 17 | 18 | expect(getValidJson(JSON.stringify(json))).toStrictEqual(json) 19 | }) 20 | 21 | it('should throw an error with an invalid json', () => { 22 | const json = `{\"test\":\"test\"\"test2\":\"test\"}` 23 | const test = () => { 24 | getValidJson(json) 25 | } 26 | expect(test).toThrowError(InvalidJsonError) 27 | }) 28 | 29 | it('should throw an error when an array is passed', () => { 30 | const array = ['hello', 'world'] 31 | const test = () => { 32 | getValidJson(array) 33 | } 34 | expect(test).toThrow(JsonParseArrayError) 35 | }) 36 | 37 | it('should throw an error when null is passed', () => { 38 | const test = () => { 39 | getValidJson(null as any) 40 | } 41 | expect(test).toThrow(InvalidJsonError) 42 | }) 43 | 44 | it('should throw an error when undefined is passed', () => { 45 | const test = () => { 46 | getValidJson(undefined as any) 47 | } 48 | expect(test).toThrow(InvalidJsonError) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /src/api/viya/spec/getFileStream.spec.ts: -------------------------------------------------------------------------------- 1 | import { Logger, LogLevel } from '@sasjs/utils/logger' 2 | import * as path from 'path' 3 | import * as fileModule from '@sasjs/utils/file' 4 | import { getFileStream } from '../getFileStream' 5 | import { mockJob } from './mockResponses' 6 | import { WriteStream } from '../../../types' 7 | 8 | describe('getFileStream', () => { 9 | beforeEach(() => { 10 | ;(process as any).logger = new Logger(LogLevel.Off) 11 | setupMocks() 12 | }) 13 | it('should use the given log path if it points to a file', async () => { 14 | const { createWriteStream } = require('@sasjs/utils/file') 15 | 16 | await getFileStream(mockJob, path.join(__dirname, 'test.log')) 17 | 18 | expect(createWriteStream).toHaveBeenCalledWith( 19 | path.join(__dirname, 'test.log') 20 | ) 21 | }) 22 | 23 | it('should generate a log file path with a timestamp if it points to a folder', async () => { 24 | const { createWriteStream } = require('@sasjs/utils/file') 25 | 26 | await getFileStream(mockJob, __dirname) 27 | 28 | expect(createWriteStream).not.toHaveBeenCalledWith(__dirname) 29 | expect(createWriteStream).toHaveBeenCalledWith( 30 | expect.stringContaining(path.join(__dirname, 'test job-20')) 31 | ) 32 | }) 33 | }) 34 | 35 | const setupMocks = () => { 36 | jest.restoreAllMocks() 37 | jest.mock('@sasjs/utils/file/file') 38 | jest 39 | .spyOn(fileModule, 'createWriteStream') 40 | .mockImplementation(() => Promise.resolve({} as unknown as WriteStream)) 41 | } 42 | -------------------------------------------------------------------------------- /src/test/utils/parseGeneratedCode.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseGeneratedCode } from '../../utils/index' 2 | 3 | it('should parse generated code', () => { 4 | expect(sampleResponse).toBeTruthy() 5 | 6 | const parsedGeneratedCode = parseGeneratedCode(sampleResponse) 7 | 8 | expect(parsedGeneratedCode).toBeTruthy() 9 | 10 | const generatedCodeLines = parsedGeneratedCode.split('\r\n') 11 | 12 | expect(generatedCodeLines.length).toEqual(5) 13 | expect(generatedCodeLines[0].startsWith('MPRINT(MM_WEBIN)')).toBeTruthy() 14 | expect(generatedCodeLines[1].startsWith('MPRINT(MM_WEBLEFT)')).toBeTruthy() 15 | expect(generatedCodeLines[2].startsWith('MPRINT(MM_WEBOUT)')).toBeTruthy() 16 | expect(generatedCodeLines[3].startsWith('MPRINT(MM_WEBRIGHT)')).toBeTruthy() 17 | expect(generatedCodeLines[4].startsWith('MPRINT(MM_WEBOUT)')).toBeTruthy() 18 | }) 19 | 20 | /* tslint:disable */ 21 | const sampleResponse = ` 22 | 6 @file mm_webout.sas 23 | 7 @brief Send data to/from SAS Stored Processes 24 | 8 @details This macro should be added to the start of each Stored Process, 25 | 9 **immediately** followed by a call to: 26 | 10 %webout(OPEN) 27 | MPRINT(MM_WEBIN): ; 28 | MPRINT(MM_WEBLEFT): filename _temp temp lrecl=999999; 29 | MPRINT(MM_WEBOUT): data _null_; 30 | MPRINT(MM_WEBRIGHT): file _temp; 31 | MPRINT(MM_WEBOUT): if upcase(symget('_debug'))='LOG' then put '>>weboutBEGIN<<'; 32 | ` 33 | /* tslint:enable */ 34 | -------------------------------------------------------------------------------- /src/utils/parseViyaDebugResponse.ts: -------------------------------------------------------------------------------- 1 | import { RequestClient } from '../request/RequestClient' 2 | import { getValidJson } from '../utils' 3 | 4 | /** 5 | * When querying a Viya job using the Web approach (as opposed to using the APIs) with _DEBUG enabled, 6 | * the first response contains the log with the content in an iframe. Therefore when debug is enabled, 7 | * and the serverType is VIYA, and useComputeApi is null (WEB), we call this function to extract the 8 | * (_webout) content from the iframe. 9 | * @param response - first response from viya job 10 | * @param requestClient 11 | * @param serverUrl 12 | * @returns 13 | */ 14 | export const parseSasViyaDebugResponse = async ( 15 | response: string, 16 | requestClient: RequestClient, 17 | serverUrl: string 18 | ) => { 19 | // On viya 3.5, iframe is like 20 | // On viya 4, iframe is like 21 | 22 | const iframeStart = response.split( 23 | /` 17 | const resultData = { message: 'success' } 18 | 19 | // Mock the get method to resolve with an object containing the JSON result as string. 20 | ;(requestClient.get as jest.Mock).mockResolvedValue({ 21 | result: JSON.stringify(resultData) 22 | }) 23 | 24 | const result = await parseSasViyaDebugResponse( 25 | response, 26 | requestClient, 27 | serverUrl 28 | ) 29 | 30 | expect(requestClient.get).toHaveBeenCalledWith( 31 | serverUrl + iframeUrl, 32 | undefined, 33 | 'text/plain' 34 | ) 35 | expect(result).toEqual(resultData) 36 | }) 37 | 38 | it('should extract URL and call get for Viya 4 iframe style', async () => { 39 | const iframeUrl = '/another/path/to/log.json' 40 | // Note: For Viya 4, the regex splits in such a way that the extracted URL includes an extra starting double-quote. 41 | // For example, the URL becomes: '"/another/path/to/log.json' 42 | const response = `` 43 | const resultData = { status: 'ok' } 44 | 45 | ;(requestClient.get as jest.Mock).mockResolvedValue({ 46 | result: JSON.stringify(resultData) 47 | }) 48 | 49 | const result = await parseSasViyaDebugResponse( 50 | response, 51 | requestClient, 52 | serverUrl 53 | ) 54 | // Expect the extra starting double-quote as per the current implementation. 55 | const expectedUrl = serverUrl + `"` + iframeUrl 56 | 57 | expect(requestClient.get).toHaveBeenCalledWith( 58 | expectedUrl, 59 | undefined, 60 | 'text/plain' 61 | ) 62 | expect(result).toEqual(resultData) 63 | }) 64 | 65 | it('should throw an error if iframe URL is not found', async () => { 66 | const response = `No iframe here` 67 | 68 | await expect( 69 | parseSasViyaDebugResponse(response, requestClient, serverUrl) 70 | ).rejects.toThrow('Unable to find webout file URL.') 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const terserPlugin = require('terser-webpack-plugin') 4 | const nodePolyfillPlugin = require('node-polyfill-webpack-plugin') 5 | 6 | const defaultPlugins = [ 7 | new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /en/), 8 | new webpack.SourceMapDevToolPlugin({ 9 | filename: null, 10 | exclude: [/node_modules/], 11 | test: /\.ts($|\?)/i 12 | }) 13 | ] 14 | 15 | const optimization = { 16 | minimize: false, 17 | minimizer: [ 18 | // new terserPlugin({ 19 | // parallel: true, 20 | // terserOptions: {} 21 | // }) 22 | ] 23 | } 24 | 25 | const browserConfig = { 26 | entry: { 27 | index: './src/index.ts', 28 | minified_sas9: './src/minified/sas9/index.ts' 29 | }, 30 | externals: { 31 | 'node:fs': 'node:fs', 32 | 'node:fs/promises': 'node:fs/promises', 33 | 'node:path': 'node:path', 34 | 'node:stream': 'node:stream', 35 | 'node:url': 'node:url', 36 | 'node:events': 'node:events', 37 | 'node:string_decoder': 'node:string_decoder' 38 | }, 39 | output: { 40 | filename: '[name].js', 41 | path: path.resolve(__dirname, 'build'), 42 | libraryTarget: 'umd', 43 | library: 'SASjs' 44 | }, 45 | mode: 'production', 46 | optimization: optimization, 47 | devtool: 'inline-source-map', 48 | module: { 49 | rules: [ 50 | { 51 | test: /\.ts?$/, 52 | use: 'ts-loader', 53 | exclude: /node_modules/ 54 | } 55 | ] 56 | }, 57 | resolve: { 58 | extensions: ['.ts', '.js'], 59 | fallback: { https: false, fs: false, readline: false } 60 | }, 61 | plugins: [ 62 | ...defaultPlugins, 63 | new webpack.ProvidePlugin({ 64 | process: 'process/browser' 65 | }), 66 | new nodePolyfillPlugin() 67 | ] 68 | } 69 | 70 | const browserConfigWithDevTool = { 71 | ...browserConfig, 72 | entry: './src/index.ts', 73 | output: { 74 | filename: 'index-dev.js', 75 | path: path.resolve(__dirname, 'build'), 76 | libraryTarget: 'umd', 77 | library: 'SASjs' 78 | }, 79 | devtool: 'inline-source-map' 80 | } 81 | 82 | const browserConfigWithoutProcessPlugin = { 83 | entry: browserConfig.entry, 84 | devtool: browserConfig.devtool, 85 | mode: browserConfig.mode, 86 | optimization: optimization, 87 | module: browserConfig.module, 88 | resolve: browserConfig.resolve, 89 | output: browserConfig.output, 90 | plugins: defaultPlugins 91 | } 92 | 93 | const nodeConfig = { 94 | ...browserConfigWithoutProcessPlugin, 95 | target: 'node', 96 | entry: './node/index.ts', 97 | output: { 98 | ...browserConfig.output, 99 | path: path.resolve(__dirname, 'build', 'node'), 100 | filename: 'index.js' 101 | } 102 | } 103 | 104 | module.exports = [browserConfig, browserConfigWithDevTool, nodeConfig] 105 | -------------------------------------------------------------------------------- /sasjs-tests/src/components/LoginForm.ts: -------------------------------------------------------------------------------- 1 | import { appContext } from '../core/AppContext' 2 | import styles from './LoginForm.css?inline' 3 | 4 | export class LoginForm extends HTMLElement { 5 | private static styleSheet = new CSSStyleSheet() 6 | private shadow: ShadowRoot 7 | 8 | static { 9 | this.styleSheet.replaceSync(styles) 10 | } 11 | 12 | constructor() { 13 | super() 14 | this.shadow = this.attachShadow({ mode: 'open' }) 15 | this.shadow.adoptedStyleSheets = [LoginForm.styleSheet] 16 | } 17 | 18 | connectedCallback() { 19 | this.render() 20 | this.attachEventListeners() 21 | } 22 | 23 | render() { 24 | this.shadow.innerHTML = ` 25 |

SASjs Tests

26 |
27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 |
35 |
36 | ` 37 | } 38 | 39 | attachEventListeners() { 40 | const form = this.shadow.getElementById('login-form') as HTMLFormElement 41 | form.addEventListener('submit', async (e) => { 42 | e.preventDefault() 43 | await this.handleLogin() 44 | }) 45 | } 46 | 47 | async handleLogin() { 48 | const username = ( 49 | this.shadow.getElementById('username') as HTMLInputElement 50 | ).value 51 | const password = ( 52 | this.shadow.getElementById('password') as HTMLInputElement 53 | ).value 54 | const submitBtn = this.shadow.getElementById( 55 | 'submit-btn' 56 | ) as HTMLButtonElement 57 | const errorDiv = this.shadow.getElementById('error') as HTMLDivElement 58 | 59 | errorDiv.textContent = '' 60 | submitBtn.textContent = 'Logging in...' 61 | submitBtn.disabled = true 62 | 63 | try { 64 | const adapter = appContext.getAdapter() 65 | if (!adapter) { 66 | throw new Error('Adapter not initialized') 67 | } 68 | 69 | const response = await adapter.logIn(username, password) 70 | 71 | if (response && response.isLoggedIn) { 72 | appContext.setIsLoggedIn(true) 73 | this.dispatchEvent( 74 | new CustomEvent('login-success', { 75 | bubbles: true, 76 | composed: true 77 | }) 78 | ) 79 | } else { 80 | throw new Error('Login failed') 81 | } 82 | } catch (error: unknown) { 83 | errorDiv.textContent = 84 | error instanceof Error 85 | ? error.message 86 | : 'Login failed. Please try again.' 87 | submitBtn.textContent = 'Log In' 88 | submitBtn.disabled = false 89 | } 90 | } 91 | } 92 | 93 | customElements.define('login-form', LoginForm) 94 | -------------------------------------------------------------------------------- /src/request/SasjsRequestClient.ts: -------------------------------------------------------------------------------- 1 | import { RequestClient } from './RequestClient' 2 | import { AxiosResponse } from 'axios' 3 | import { SasjsParsedResponse } from '../types' 4 | 5 | /** 6 | * Specific request client for SASJS. 7 | * Append tokens in headers. 8 | */ 9 | export class SasjsRequestClient extends RequestClient { 10 | getHeaders = (accessToken: string | undefined, contentType: string) => { 11 | const headers: any = {} 12 | 13 | if (contentType !== 'application/x-www-form-urlencoded') 14 | headers['Content-Type'] = contentType 15 | 16 | headers.Accept = contentType === 'application/json' ? contentType : '*/*' 17 | 18 | if (!accessToken && typeof window !== 'undefined') 19 | accessToken = localStorage.getItem('accessToken') ?? undefined 20 | 21 | if (accessToken) headers.Authorization = `Bearer ${accessToken}` 22 | 23 | return headers 24 | } 25 | 26 | protected parseResponse(response: AxiosResponse) { 27 | const etag = response?.headers ? response.headers['etag'] : '' 28 | let parsedResponse = {} 29 | let webout, log, printOutput 30 | 31 | try { 32 | if (typeof response.data === 'string') { 33 | parsedResponse = JSON.parse(response.data) 34 | } else { 35 | parsedResponse = response.data 36 | } 37 | } catch { 38 | if (response.data.includes(SASJS_LOGS_SEPARATOR)) { 39 | const { data } = response 40 | const splittedResponse: string[] = data.split(SASJS_LOGS_SEPARATOR) 41 | 42 | webout = splittedResponse.splice(0, 1)[0] 43 | if (webout !== undefined) parsedResponse = webout 44 | 45 | // log can contain nested logs 46 | const logs = splittedResponse.splice(0, splittedResponse.length - 1) 47 | 48 | // tests if string ends with SASJS_LOGS_SEPARATOR 49 | const endingWithLogSepRegExp = new RegExp(`${SASJS_LOGS_SEPARATOR}$`) 50 | 51 | // at this point splittedResponse can contain only one item 52 | const lastChunk = splittedResponse[0] 53 | 54 | if (lastChunk) { 55 | // if the last chunk doesn't end with SASJS_LOGS_SEPARATOR, then it is a printOutput 56 | // else the last chunk is part of the log and has to be joined 57 | if (!endingWithLogSepRegExp.test(data)) printOutput = lastChunk 58 | else if (logs.length > 1) logs.push(lastChunk) 59 | } 60 | 61 | // join logs into single log with SASJS_LOGS_SEPARATOR 62 | log = logs.join(SASJS_LOGS_SEPARATOR) 63 | } else { 64 | parsedResponse = response.data 65 | } 66 | } 67 | 68 | const returnResult: SasjsParsedResponse = { 69 | result: parsedResponse as T, 70 | log: log || '', 71 | etag, 72 | status: response.status 73 | } 74 | 75 | if (printOutput) returnResult.printOutput = printOutput 76 | 77 | return returnResult 78 | } 79 | } 80 | 81 | export const SASJS_LOGS_SEPARATOR = 82 | 'SASJS_LOGS_SEPARATOR_163ee17b6ff24f028928972d80a26784' 83 | -------------------------------------------------------------------------------- /src/utils/validateInput.ts: -------------------------------------------------------------------------------- 1 | export const MORE_INFO = 2 | 'For more info see https://sasjs.io/sasjs-adapter/#request-response' 3 | export const INVALID_TABLE_STRUCTURE = `Parameter data contains invalid table structure. ${MORE_INFO}` 4 | 5 | /** 6 | * This function validates the input data structure and table naming convention 7 | * 8 | * @param data A json object that contains one or more tables, it can also be null 9 | * @returns An object which contains two attributes: 1) status: boolean, 2) msg: string 10 | */ 11 | export const validateInput = ( 12 | data: { [key: string]: any } | null 13 | ): { 14 | status: boolean 15 | msg: string 16 | } => { 17 | if (data === null) return { status: true, msg: '' } 18 | 19 | if (getType(data) !== 'object') { 20 | return { 21 | status: false, 22 | msg: INVALID_TABLE_STRUCTURE 23 | } 24 | } 25 | 26 | const isSasFormatsTable = (key: string) => 27 | key.match(/^\$.*/) && Object.keys(data).includes(key.replace(/^\$/, '')) 28 | 29 | for (const key in data) { 30 | if (!key.match(/^[a-zA-Z_]/) && !isSasFormatsTable(key)) { 31 | return { 32 | status: false, 33 | msg: 'First letter of table should be alphabet or underscore.' 34 | } 35 | } 36 | 37 | if (!key.match(/^[a-zA-Z_][a-zA-Z0-9_]*$/) && !isSasFormatsTable(key)) { 38 | return { status: false, msg: 'Table name should be alphanumeric.' } 39 | } 40 | 41 | if (key.length > 32) { 42 | return { 43 | status: false, 44 | msg: 'Maximum length for table name could be 32 characters.' 45 | } 46 | } 47 | 48 | if (getType(data[key]) !== 'Array' && !isSasFormatsTable(key)) { 49 | return { 50 | status: false, 51 | msg: INVALID_TABLE_STRUCTURE 52 | } 53 | } 54 | 55 | // ES6 is stricter so we had to include the check for the array 56 | if (Array.isArray(data[key])) { 57 | for (const item of data[key]) { 58 | if (getType(item) !== 'object') { 59 | return { 60 | status: false, 61 | msg: `Table ${key} contains invalid structure. ${MORE_INFO}` 62 | } 63 | } else { 64 | const attributes = Object.keys(item) 65 | for (const attribute of attributes) { 66 | if (item[attribute] === undefined) { 67 | return { 68 | status: false, 69 | msg: `A row in table ${key} contains invalid value. Can't assign undefined to ${attribute}.` 70 | } 71 | } 72 | } 73 | } 74 | } 75 | } 76 | } 77 | 78 | return { status: true, msg: '' } 79 | } 80 | 81 | /** 82 | * this function returns the type of variable 83 | * 84 | * @param data it could be anything, like string, array, object etc. 85 | * @returns a string which tells the type of input parameter 86 | */ 87 | const getType = (data: any): string => { 88 | if (Array.isArray(data)) { 89 | return 'Array' 90 | } else { 91 | return typeof data 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/auth/getTokenRequestErrorPrefix.ts: -------------------------------------------------------------------------------- 1 | import { ServerType } from '@sasjs/utils/types' 2 | 3 | type Server = ServerType.SasViya | ServerType.Sasjs 4 | type Operation = 'fetching access token' | 'refreshing tokens' 5 | 6 | const getServerName = (server: Server) => 7 | server === ServerType.SasViya ? 'Viya' : 'Sasjs' 8 | 9 | const getResponseTitle = (server: Server) => 10 | `Response from ${getServerName(server)} is below.` 11 | 12 | /** 13 | * Forms error prefix for requests related to token operations. 14 | * @param operation - string describing operation ('fetching access token' or 'refreshing tokens'). 15 | * @param funcName - name of the function sent the request. 16 | * @param server - server type (SASVIYA or SASJS). 17 | * @param url - endpoint used to send the request. 18 | * @param data - request payload. 19 | * @param headers - request headers. 20 | * @param clientId - client ID to authenticate with. 21 | * @param clientSecret - client secret to authenticate with. 22 | * @returns - string containing request information. Example: 23 | * Error while fetching access token from /SASLogon/oauth/token 24 | * Thrown by the @sasjs/adapter getAccessTokenForViya function. 25 | * Payload: 26 | * { 27 | * "grant_type": "authorization_code", 28 | * "code": "example_code" 29 | * } 30 | * Headers: 31 | * { 32 | * "Authorization": "Basic NEdMQXBwOjRHTEFwcDE=", 33 | * "Accept": "application/json" 34 | * } 35 | * ClientId: exampleClientId 36 | * ClientSecret: exampleClientSecret 37 | * 38 | * Response from Viya is below. 39 | * Auth error: { 40 | * "error": "invalid_token", 41 | * "error_description": "No scopes were granted" 42 | * } 43 | */ 44 | export const getTokenRequestErrorPrefix = ( 45 | operation: Operation, 46 | funcName: string, 47 | server: Server, 48 | url: string, 49 | data?: {}, 50 | headers?: {}, 51 | clientId?: string, 52 | clientSecret?: string 53 | ) => { 54 | const stringify = (obj: {}) => JSON.stringify(obj, null, 2) 55 | 56 | const lines = [ 57 | `Error while ${operation} from ${url}`, 58 | `Thrown by the @sasjs/adapter ${funcName} function.` 59 | ] 60 | 61 | if (data) { 62 | lines.push('Payload:') 63 | lines.push(stringify(data)) 64 | } 65 | if (headers) { 66 | lines.push('Headers:') 67 | lines.push(stringify(headers)) 68 | } 69 | if (clientId) lines.push(`ClientId: ${clientId}`) 70 | if (clientSecret) lines.push(`ClientSecret: ${clientSecret}`) 71 | 72 | lines.push('') 73 | lines.push(`${getResponseTitle(server)}`) 74 | lines.push('') 75 | 76 | return lines.join(`\n`) 77 | } 78 | 79 | /** 80 | * Parse error prefix to get response payload. 81 | * @param prefix - error prefix generated by getTokenRequestErrorPrefix function. 82 | * @param server - server type (SASVIYA or SASJS). 83 | * @returns - response payload. 84 | */ 85 | export const getTokenRequestErrorPrefixResponse = ( 86 | prefix: string, 87 | server: ServerType.SasViya | ServerType.Sasjs 88 | ) => prefix.split(`${getResponseTitle(server)}\n`).pop() as string 89 | -------------------------------------------------------------------------------- /src/utils/spec/validateInput.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | validateInput, 3 | INVALID_TABLE_STRUCTURE, 4 | MORE_INFO 5 | } from '../validateInput' 6 | 7 | const tableArray = [{ col1: 'first col value' }] 8 | const stringData: any = { table1: tableArray } 9 | 10 | describe('validateInput', () => { 11 | it('should not return an error message if input data valid', () => { 12 | const validationResult = validateInput(stringData) 13 | expect(validationResult).toEqual({ 14 | status: true, 15 | msg: '' 16 | }) 17 | }) 18 | 19 | it('should not return an error message if input data is null', () => { 20 | const validationResult = validateInput(null) 21 | expect(validationResult).toEqual({ 22 | status: true, 23 | msg: '' 24 | }) 25 | }) 26 | 27 | it('should return an error message if input data is an array', () => { 28 | const validationResult = validateInput(tableArray) 29 | expect(validationResult).toEqual({ 30 | status: false, 31 | msg: INVALID_TABLE_STRUCTURE 32 | }) 33 | }) 34 | 35 | it('should return an error message if first letter of table is neither alphabet nor underscore', () => { 36 | const validationResult = validateInput({ '1stTable': tableArray }) 37 | expect(validationResult).toEqual({ 38 | status: false, 39 | msg: 'First letter of table should be alphabet or underscore.' 40 | }) 41 | }) 42 | 43 | it('should return an error message if table name contains a character other than alphanumeric or underscore', () => { 44 | const validationResult = validateInput({ 'table!': tableArray }) 45 | expect(validationResult).toEqual({ 46 | status: false, 47 | msg: 'Table name should be alphanumeric.' 48 | }) 49 | }) 50 | 51 | it('should return an error message if length of table name contains exceeds 32', () => { 52 | const validationResult = validateInput({ 53 | xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx: tableArray 54 | }) 55 | expect(validationResult).toEqual({ 56 | status: false, 57 | msg: 'Maximum length for table name could be 32 characters.' 58 | }) 59 | }) 60 | 61 | it('should return an error message if table does not have array of objects', () => { 62 | const validationResult = validateInput({ table: stringData }) 63 | expect(validationResult).toEqual({ 64 | status: false, 65 | msg: INVALID_TABLE_STRUCTURE 66 | }) 67 | }) 68 | 69 | it('should return an error message if a table array has an item other than object', () => { 70 | const validationResult = validateInput({ table1: ['invalid'] }) 71 | expect(validationResult).toEqual({ 72 | status: false, 73 | msg: `Table table1 contains invalid structure. ${MORE_INFO}` 74 | }) 75 | }) 76 | 77 | it('should return an error message if a row in a table contains an column with undefined value', () => { 78 | const validationResult = validateInput({ table1: [{ column: undefined }] }) 79 | expect(validationResult).toEqual({ 80 | status: false, 81 | msg: `A row in table table1 contains invalid value. Can't assign undefined to column.` 82 | }) 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /src/auth/spec/refreshTokensForViya.spec.ts: -------------------------------------------------------------------------------- 1 | import { AuthConfig, ServerType } from '@sasjs/utils/types' 2 | import NodeFormData from 'form-data' 3 | import { generateToken, mockAuthResponse } from './mockResponses' 4 | import { RequestClient } from '../../request/RequestClient' 5 | import { refreshTokensForViya } from '../refreshTokensForViya' 6 | import * as IsNodeModule from '../../utils/isNode' 7 | import { getTokenRequestErrorPrefixResponse } from '../getTokenRequestErrorPrefix' 8 | 9 | const requestClient = new (>RequestClient)() 10 | 11 | describe('refreshTokensForViya', () => { 12 | it('should attempt to refresh tokens', async () => { 13 | setupMocks() 14 | const access_token = generateToken(30) 15 | const refresh_token = generateToken(30) 16 | const authConfig: AuthConfig = { 17 | access_token, 18 | refresh_token, 19 | client: 'cl13nt', 20 | secret: 's3cr3t' 21 | } 22 | jest 23 | .spyOn(requestClient, 'post') 24 | .mockImplementation(() => 25 | Promise.resolve({ result: mockAuthResponse, etag: '' }) 26 | ) 27 | const token = Buffer.from( 28 | authConfig.client + ':' + authConfig.secret 29 | ).toString('base64') 30 | 31 | await refreshTokensForViya( 32 | requestClient, 33 | authConfig.client, 34 | authConfig.secret, 35 | authConfig.refresh_token 36 | ) 37 | 38 | expect(requestClient.post).toHaveBeenCalledWith( 39 | '/SASLogon/oauth/token', 40 | expect.any(NodeFormData), 41 | undefined, 42 | expect.stringContaining('multipart/form-data; boundary='), 43 | { 44 | Authorization: 'Basic ' + token 45 | } 46 | ) 47 | }) 48 | 49 | it('should handle errors while refreshing tokens', async () => { 50 | setupMocks() 51 | 52 | const access_token = generateToken(30) 53 | const refresh_token = generateToken(30) 54 | const tokenError = 'unable to verify the first certificate' 55 | const authConfig: AuthConfig = { 56 | access_token, 57 | refresh_token, 58 | client: 'cl13nt', 59 | secret: 's3cr3t' 60 | } 61 | 62 | jest 63 | .spyOn(requestClient, 'post') 64 | .mockImplementation(() => Promise.reject(tokenError)) 65 | 66 | const error = await refreshTokensForViya( 67 | requestClient, 68 | authConfig.client, 69 | authConfig.secret, 70 | authConfig.refresh_token 71 | ).catch((e: any) => 72 | getTokenRequestErrorPrefixResponse(e, ServerType.SasViya) 73 | ) 74 | 75 | expect(error).toEqual(tokenError) 76 | }) 77 | 78 | it('should throw an error if environment is not Node', async () => { 79 | jest.spyOn(IsNodeModule, 'isNode').mockImplementation(() => false) 80 | 81 | const expectedError = new Error( 82 | `Method 'refreshTokensForViya' can only be used by Node.` 83 | ) 84 | 85 | expect( 86 | refreshTokensForViya(requestClient, 'client', 'secret', 'token') 87 | ).rejects.toEqual(expectedError) 88 | }) 89 | }) 90 | 91 | const setupMocks = () => { 92 | jest.restoreAllMocks() 93 | jest.mock('../../request/RequestClient') 94 | } 95 | -------------------------------------------------------------------------------- /sasjs-tests/src/components/TestCard.ts: -------------------------------------------------------------------------------- 1 | import type { CompletedTest } from '../core/TestRunner' 2 | import type { TestStatus } from '../types' 3 | import styles from './TestCard.css?inline' 4 | 5 | export class TestCard extends HTMLElement { 6 | private static styleSheet = new CSSStyleSheet() 7 | private shadow: ShadowRoot 8 | private _testData: CompletedTest | null = null 9 | 10 | static { 11 | this.styleSheet.replaceSync(styles) 12 | } 13 | 14 | static get observedAttributes() { 15 | return ['status', 'execution-time'] 16 | } 17 | 18 | constructor() { 19 | super() 20 | this.shadow = this.attachShadow({ mode: 'open' }) 21 | this.shadow.adoptedStyleSheets = [TestCard.styleSheet] 22 | } 23 | 24 | connectedCallback() { 25 | this.render() 26 | } 27 | 28 | attributeChangedCallback(_name: string, oldValue: string, newValue: string) { 29 | if (oldValue !== newValue) { 30 | this.render() 31 | } 32 | } 33 | 34 | set testData(data: CompletedTest) { 35 | this._testData = data 36 | this.setAttribute('status', data.status) 37 | if (data.executionTime) { 38 | this.setAttribute('execution-time', data.executionTime.toString()) 39 | } 40 | this.render() 41 | } 42 | 43 | get testData(): CompletedTest | null { 44 | return this._testData 45 | } 46 | 47 | render() { 48 | if (!this._testData) return 49 | 50 | const { test, status, executionTime, error } = this._testData 51 | const statusIcon = this.getStatusIcon(status) 52 | 53 | this.shadow.innerHTML = ` 54 |
55 | ${statusIcon} 56 |

${test.title}

57 |
58 |

${test.description}

59 | 60 | ${ 61 | executionTime 62 | ? ` 63 |
64 |
Time: ${executionTime.toFixed(3)}s
65 |
66 | ` 67 | : '' 68 | } 69 | 70 | ${ 71 | error 72 | ? ` 73 |
74 | Error: 75 |
${(error as Error).message || String(error)}
76 |
77 | ` 78 | : '' 79 | } 80 | 81 | 82 | ` 83 | 84 | const rerunBtn = this.shadow.getElementById('rerun-btn') 85 | if (rerunBtn) { 86 | rerunBtn.addEventListener('click', () => { 87 | this.dispatchEvent( 88 | new CustomEvent('rerun', { 89 | bubbles: true, 90 | composed: true 91 | }) 92 | ) 93 | }) 94 | } 95 | } 96 | 97 | getStatusIcon(status: TestStatus): string { 98 | switch (status) { 99 | case 'passed': 100 | return '✓' 101 | case 'failed': 102 | return '✗' 103 | case 'running': 104 | return '⟳' 105 | case 'pending': 106 | return '○' 107 | default: 108 | return '?' 109 | } 110 | } 111 | } 112 | 113 | customElements.define('test-card', TestCard) 114 | -------------------------------------------------------------------------------- /src/SAS9ApiClient.ts: -------------------------------------------------------------------------------- 1 | import * as https from 'https' 2 | import { generateTimestamp } from '@sasjs/utils/time' 3 | import NodeFormData from 'form-data' 4 | import { Sas9RequestClient } from './request/Sas9RequestClient' 5 | import { isUrl } from './utils' 6 | 7 | /** 8 | * A client for interfacing with the SAS9 REST API. 9 | * 10 | */ 11 | export class SAS9ApiClient { 12 | private requestClient: Sas9RequestClient 13 | 14 | constructor( 15 | private serverUrl: string, 16 | private jobsPath: string, 17 | httpsAgentOptions?: https.AgentOptions 18 | ) { 19 | if (serverUrl) isUrl(serverUrl) 20 | this.requestClient = new Sas9RequestClient(serverUrl, httpsAgentOptions) 21 | } 22 | 23 | /** 24 | * Returns an object containing server URL. 25 | */ 26 | public getConfig() { 27 | return { 28 | serverUrl: this.serverUrl 29 | } 30 | } 31 | 32 | /** 33 | * Updates server URL which is not null. 34 | * @param serverUrl - URL of the server to be set. 35 | */ 36 | public setConfig(serverUrl: string) { 37 | if (serverUrl) this.serverUrl = serverUrl 38 | } 39 | 40 | /** 41 | * Executes code on a SAS9 server. 42 | * @param linesOfCode - an array of code lines to execute. 43 | * @param userName - the user name to log into the current SAS server. 44 | * @param password - the password to log into the current SAS server. 45 | */ 46 | public async executeScript( 47 | linesOfCode: string[], 48 | userName: string, 49 | password: string 50 | ) { 51 | await this.requestClient.login(userName, password, this.jobsPath) 52 | 53 | // This piece of code forces a webout to prevent Stored Process Errors. 54 | const forceOutputCode = [ 55 | 'data _null_;', 56 | 'file _webout;', 57 | `put 'Executed sasjs run';`, 58 | 'run;' 59 | ] 60 | const formData = generateFileUploadForm( 61 | [...linesOfCode, ...forceOutputCode].join('\n') 62 | ) 63 | 64 | const codeInjectorPath = `/User Folders/${userName}/My Folder/sasjs/runner` 65 | const contentType = 66 | 'multipart/form-data; boundary=' + formData.getBoundary() 67 | const contentLength = formData.getLengthSync() 68 | 69 | const headers = { 70 | 'cache-control': 'no-cache', 71 | Accept: '*/*', 72 | 'Content-Type': contentType, 73 | 'Content-Length': contentLength, 74 | Connection: 'keep-alive' 75 | } 76 | const storedProcessUrl = `${this.jobsPath}/?${ 77 | '_program=' + codeInjectorPath + '&_debug=log' 78 | }` 79 | const response = await this.requestClient.post( 80 | storedProcessUrl, 81 | formData, 82 | undefined, 83 | contentType, 84 | headers 85 | ) 86 | 87 | return response.result as string 88 | } 89 | } 90 | 91 | const generateFileUploadForm = (data: any): NodeFormData => { 92 | const formData = new NodeFormData() 93 | const filename = `sasjs-execute-sas9-${generateTimestamp('')}.sas` 94 | formData.append(filename, data, { 95 | filename, 96 | contentType: 'text/plain' 97 | }) 98 | 99 | return formData 100 | } 101 | -------------------------------------------------------------------------------- /src/types/SASjsConfig.ts: -------------------------------------------------------------------------------- 1 | import * as https from 'https' 2 | import { ServerType } from '@sasjs/utils/types' 3 | import { VerboseMode } from '../types' 4 | 5 | /** 6 | * Specifies the configuration for the SASjs instance - eg where and how to 7 | * connect to SAS. 8 | */ 9 | export class SASjsConfig { 10 | /** 11 | * The location (including http protocol and port) of the SAS Server. 12 | * Can be omitted, eg if serving directly from the SAS Web Server or being 13 | * streamed. 14 | */ 15 | serverUrl: string = '' 16 | /** 17 | * The location of the STP Process Web Application. By default the adapter 18 | * will use '/SASjsApi/stp/execute' on SAS JS. 19 | */ 20 | pathSASJS: string = '' 21 | /** 22 | * The location of the Stored Process Web Application. By default the adapter 23 | * will use '/SASStoredProcess/do' on SAS 9. 24 | */ 25 | pathSAS9: string = '' 26 | /** 27 | * The location of the Job Execution Web Application. By default the adapter 28 | * will use '/SASJobExecution' on SAS Viya. 29 | */ 30 | pathSASViya: string = '' 31 | /** 32 | * The appLoc is the parent folder under which the SAS services (STPs or Job 33 | * Execution Services) are stored. We recommend that each app is stored in 34 | * a dedicated parent folder (the appLoc) and the services are grouped inside 35 | * subfolders within the appLoc - allowing functionality to be restricted 36 | * according to those groups at backend. 37 | * When using appLoc, the paths provided in the `request` function should be 38 | * _without_ a leading slash (/). 39 | */ 40 | appLoc: string = '' 41 | /** 42 | * Can be `SAS9` or `SASVIYA`. 43 | */ 44 | serverType: ServerType | null = null 45 | /** 46 | * Set to `true` to enable additional debugging. 47 | */ 48 | debug: boolean = true 49 | /** 50 | * Set to `true` to enable verbose mode that will log a summary of every HTTP response. 51 | */ 52 | verbose?: VerboseMode = true 53 | /** 54 | * The name of the compute context to use when calling the Viya services directly. 55 | * Example value: 'SAS Job Execution compute context' 56 | */ 57 | contextName: string = '' 58 | /** 59 | * If it's `false` adapter will use the JES API as connection approach. To enhance VIYA 60 | * performance, set to `true` and provide a `contextName` on which to run 61 | * the code. When running on a named context, the code executes under the 62 | * user identity. When running as a Job Execution service, the code runs 63 | * under the identity in the JES context. If `useComputeApi` is `null` or `undefined`, the service will run as a Job, except 64 | * triggered using the APIs instead of the Job Execution Web Service broker. 65 | */ 66 | useComputeApi: boolean | null = null 67 | /** 68 | * Optional setting to configure HTTPS Agent. 69 | * By providing `key`, `cert`, `ca` to connect with server 70 | * Other options can be set `rejectUnauthorized` and `requestCert` 71 | */ 72 | httpsAgentOptions?: https.AgentOptions 73 | /** 74 | * Supported login mechanisms are - Redirected and Default 75 | */ 76 | loginMechanism: LoginMechanism = LoginMechanism.Default 77 | /** 78 | * Optional setting to configure request history limit. Increasing this limit 79 | * may affect browser performance, especially with debug (logs) enabled. 80 | */ 81 | requestHistoryLimit?: number = 10 82 | } 83 | 84 | export enum LoginMechanism { 85 | Default = 'Default', 86 | Redirected = 'Redirected' 87 | } 88 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "adapter", 3 | "projectOwner": "sasjs", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": false, 11 | "commitConvention": "angular", 12 | "contributors": [ 13 | { 14 | "login": "krishna-acondy", 15 | "name": "Krishna Acondy", 16 | "avatar_url": "https://avatars.githubusercontent.com/u/2980428?v=4", 17 | "profile": "https://krishna-acondy.io/", 18 | "contributions": [ 19 | "code", 20 | "infra", 21 | "blog", 22 | "content", 23 | "ideas", 24 | "video" 25 | ] 26 | }, 27 | { 28 | "login": "YuryShkoda", 29 | "name": "Yury Shkoda", 30 | "avatar_url": "https://avatars.githubusercontent.com/u/25773492?v=4", 31 | "profile": "https://www.erudicat.com/", 32 | "contributions": [ 33 | "code", 34 | "infra", 35 | "ideas", 36 | "test", 37 | "video" 38 | ] 39 | }, 40 | { 41 | "login": "medjedovicm", 42 | "name": "Mihajlo Medjedovic", 43 | "avatar_url": "https://avatars.githubusercontent.com/u/18329105?v=4", 44 | "profile": "https://github.com/medjedovicm", 45 | "contributions": [ 46 | "code", 47 | "infra", 48 | "test", 49 | "review" 50 | ] 51 | }, 52 | { 53 | "login": "allanbowe", 54 | "name": "Allan Bowe", 55 | "avatar_url": "https://avatars.githubusercontent.com/u/4420615?v=4", 56 | "profile": "https://github.com/allanbowe", 57 | "contributions": [ 58 | "code", 59 | "review", 60 | "test", 61 | "mentoring", 62 | "maintenance" 63 | ] 64 | }, 65 | { 66 | "login": "saadjutt01", 67 | "name": "Muhammad Saad ", 68 | "avatar_url": "https://avatars.githubusercontent.com/u/8914650?v=4", 69 | "profile": "https://github.com/saadjutt01", 70 | "contributions": [ 71 | "code", 72 | "review", 73 | "test", 74 | "mentoring", 75 | "infra" 76 | ] 77 | }, 78 | { 79 | "login": "sabhas", 80 | "name": "Sabir Hassan", 81 | "avatar_url": "https://avatars.githubusercontent.com/u/82647447?v=4", 82 | "profile": "https://github.com/sabhas", 83 | "contributions": [ 84 | "code", 85 | "review", 86 | "test", 87 | "ideas" 88 | ] 89 | }, 90 | { 91 | "login": "VladislavParhomchik", 92 | "name": "VladislavParhomchik", 93 | "avatar_url": "https://avatars.githubusercontent.com/u/83717836?v=4", 94 | "profile": "https://github.com/VladislavParhomchik", 95 | "contributions": [ 96 | "test", 97 | "review" 98 | ] 99 | }, 100 | { 101 | "login": "rudvfaden", 102 | "name": "Rud Faden", 103 | "avatar_url": "https://avatars.githubusercontent.com/u/2445577?v=4", 104 | "profile": "http://rudvfaden.github.io/", 105 | "contributions": [ 106 | "userTesting", 107 | "doc" 108 | ] 109 | }, 110 | { 111 | "login": "saramartinelli1992", 112 | "name": "Sara", 113 | "avatar_url": "https://avatars.githubusercontent.com/u/100193908?v=4", 114 | "profile": "https://github.com/saramartinelli1992", 115 | "contributions": [ 116 | "userTesting", 117 | "platform" 118 | ] 119 | } 120 | ], 121 | "contributorsPerLine": 7, 122 | "skipCi": true 123 | } 124 | -------------------------------------------------------------------------------- /src/utils/spec/extractUserLongNameSas9.spec.ts: -------------------------------------------------------------------------------- 1 | import { extractUserLongNameSas9 } from '../sas9/extractUserLongNameSas9' 2 | 3 | describe('Extract username SAS9 English - two word logout handled language', () => { 4 | const logoutWord = 'Log Off' 5 | 6 | it('should return username with space after colon', () => { 7 | const response = ` "title": "${logoutWord} SAS User One",` 8 | const username = extractUserLongNameSas9(response) 9 | 10 | expect(username).toEqual('SAS User One') 11 | }) 12 | 13 | it('should return username without space after colon', () => { 14 | const response = ` "title":"${logoutWord} SAS User One",` 15 | const username = extractUserLongNameSas9(response) 16 | 17 | expect(username).toEqual('SAS User One') 18 | }) 19 | 20 | it('should return username with one word user name', () => { 21 | const response = ` "title": "${logoutWord} SasUserOne",` 22 | const username = extractUserLongNameSas9(response) 23 | 24 | expect(username).toEqual('SasUserOne') 25 | }) 26 | 27 | it('should return username unknown', () => { 28 | const response = ` invalid",` 29 | const username = extractUserLongNameSas9(response) 30 | 31 | expect(username).toEqual('unknown') 32 | }) 33 | }) 34 | 35 | describe('Extract username SAS9 two word logout unhandled language', () => { 36 | const logoutWord = 'Log out' 37 | 38 | it('should return username with space after colon', () => { 39 | const response = ` "title": "${logoutWord} SAS User One",` 40 | const username = extractUserLongNameSas9(response) 41 | 42 | expect(username).toEqual('out SAS User One') 43 | }) 44 | 45 | it('should return username without space after colon', () => { 46 | const response = ` "title":"${logoutWord} SAS User One",` 47 | const username = extractUserLongNameSas9(response) 48 | 49 | expect(username).toEqual('out SAS User One') 50 | }) 51 | 52 | it('should return username with one word user name', () => { 53 | const response = ` "title": "${logoutWord} SasUserOne",` 54 | const username = extractUserLongNameSas9(response) 55 | 56 | expect(username).toEqual('out SasUserOne') 57 | }) 58 | 59 | it('should return username unknown', () => { 60 | const response = ` invalid",` 61 | const username = extractUserLongNameSas9(response) 62 | 63 | expect(username).toEqual('unknown') 64 | }) 65 | }) 66 | 67 | describe('Extract username SAS9 Spanish - one word logout languages', () => { 68 | const logoutWord = 'Desconexión' 69 | 70 | it('should return username with space after colon', () => { 71 | const response = ` "title": "${logoutWord} SAS User One",` 72 | const username = extractUserLongNameSas9(response) 73 | 74 | expect(username).toEqual('SAS User One') 75 | }) 76 | 77 | it('should return username without space after colon', () => { 78 | const response = ` "title":"${logoutWord} SAS User One",` 79 | const username = extractUserLongNameSas9(response) 80 | 81 | expect(username).toEqual('SAS User One') 82 | }) 83 | 84 | it('should return username with one word user name', () => { 85 | const response = ` "title": "${logoutWord} SasUserOne",` 86 | const username = extractUserLongNameSas9(response) 87 | 88 | expect(username).toEqual('SasUserOne') 89 | }) 90 | 91 | it('should return username unknown', () => { 92 | const response = ` invalid",` 93 | const username = extractUserLongNameSas9(response) 94 | 95 | expect(username).toEqual('unknown') 96 | }) 97 | }) 98 | -------------------------------------------------------------------------------- /sasjs-tests/src/components/TestSuite.ts: -------------------------------------------------------------------------------- 1 | import type { CompletedTestSuite } from '../core/TestRunner' 2 | import { TestCard } from './TestCard' 3 | import styles from './TestSuite.css?inline' 4 | 5 | export class TestSuiteElement extends HTMLElement { 6 | private static styleSheet = new CSSStyleSheet() 7 | private shadow: ShadowRoot 8 | private _suiteData: CompletedTestSuite | null = null 9 | private _suiteIndex: number = 0 10 | 11 | static { 12 | this.styleSheet.replaceSync(styles) 13 | } 14 | 15 | constructor() { 16 | super() 17 | this.shadow = this.attachShadow({ mode: 'open' }) 18 | this.shadow.adoptedStyleSheets = [TestSuiteElement.styleSheet] 19 | } 20 | 21 | connectedCallback() { 22 | this.render() 23 | } 24 | 25 | set suiteData(data: CompletedTestSuite) { 26 | this._suiteData = data 27 | this.render() 28 | } 29 | 30 | get suiteData(): CompletedTestSuite | null { 31 | return this._suiteData 32 | } 33 | 34 | set suiteIndex(index: number) { 35 | this._suiteIndex = index 36 | } 37 | 38 | get suiteIndex(): number { 39 | return this._suiteIndex 40 | } 41 | 42 | updateTest(testIndex: number, testData: any) { 43 | if (!this._suiteData) return 44 | 45 | // Update the data 46 | this._suiteData.completedTests[testIndex] = testData 47 | 48 | // Update stats 49 | this.updateStats() 50 | 51 | // Update the specific test card 52 | const testsContainer = this.shadow.getElementById('tests-container') 53 | if (testsContainer) { 54 | const cards = testsContainer.querySelectorAll('test-card') 55 | const card = cards[testIndex] as TestCard 56 | if (card) { 57 | card.testData = testData 58 | } 59 | } 60 | } 61 | 62 | updateStats() { 63 | if (!this._suiteData) return 64 | 65 | const { completedTests } = this._suiteData 66 | const passed = completedTests.filter((t) => t.status === 'passed').length 67 | const failed = completedTests.filter((t) => t.status === 'failed').length 68 | const running = completedTests.filter((t) => t.status === 'running').length 69 | 70 | const statsEl = this.shadow.querySelector('.stats') 71 | if (statsEl) { 72 | statsEl.textContent = `Passed: ${passed} | Failed: ${failed} | Running: ${running}` 73 | } 74 | } 75 | 76 | render() { 77 | if (!this._suiteData) return 78 | 79 | const { name, completedTests } = this._suiteData 80 | const passed = completedTests.filter((t) => t.status === 'passed').length 81 | const failed = completedTests.filter((t) => t.status === 'failed').length 82 | const running = completedTests.filter((t) => t.status === 'running').length 83 | 84 | this.shadow.innerHTML = ` 85 |
86 |

${name}

87 |
Passed: ${passed} | Failed: ${failed} | Running: ${running}
88 |
89 |
90 | ` 91 | 92 | const testsContainer = this.shadow.getElementById('tests-container') 93 | if (testsContainer) { 94 | completedTests.forEach((completedTest, testIndex) => { 95 | const card = document.createElement('test-card') as TestCard 96 | card.testData = completedTest 97 | 98 | card.addEventListener('rerun', () => { 99 | this.dispatchEvent( 100 | new CustomEvent('rerun-test', { 101 | bubbles: true, 102 | composed: true, 103 | detail: { 104 | suiteIndex: this._suiteIndex, 105 | testIndex 106 | } 107 | }) 108 | ) 109 | }) 110 | 111 | testsContainer.appendChild(card) 112 | }) 113 | } 114 | } 115 | } 116 | 117 | customElements.define('test-suite', TestSuiteElement) 118 | --------------------------------------------------------------------------------