diff --git a/.npmrc b/.npmrc
index 537f81f..702c607 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1 +1,3 @@
-ignore-scripts=true
\ No newline at end of file
+ignore-scripts=true
+save-exact=true
+fund=false
\ No newline at end of file
diff --git a/README.md b/README.md
index 773fbb5..d2f5b7c 100644
--- a/README.md
+++ b/README.md
@@ -288,6 +288,7 @@ Configuration on the client side involves passing an object on startup, which ca
- `verbose` - optional, if `true` then a summary of every HTTP response is logged.
- `loginMechanism` - either `Default` or `Redirected`. See [SAS Logon](#sas-logon) section.
- `useComputeApi` - Only relevant when the serverType is `SASVIYA`. If `true` the [Compute API](#using-the-compute-api) is used. If `false` the [JES API](#using-the-jes-api) is used. If `null` or `undefined` the [Web](#using-jes-web-app) approach is used.
+- `runAsTask` - Only relevant for serverType `SASVIYA` and useComputeApi `null`. Will add the `_EXECUTIONTASKS=true` URL param and thus execute jobs as Compute Tasks.
- `contextName` - Compute context on which the requests will be called. If missing or not provided, defaults to `Job Execution Compute context`.
- `requestHistoryLimit` - Request history limit. Increasing this limit may affect browser performance, especially with debug (logs) enabled. Default is 10.
diff --git a/cypress.config.js b/cypress.config.js
index 62ca1ff..3fb5356 100644
--- a/cypress.config.js
+++ b/cypress.config.js
@@ -13,6 +13,6 @@ module.exports = defineConfig({
username: '',
password: '',
screenshotOnRunFailure: false,
- testingFinishTimeout: 600000
+ testingFinishTimeout: 300000
}
})
diff --git a/cypress/integration/sasjs.tests.ts b/cypress/integration/sasjs.tests.ts
index 07d82a3..8cc056a 100644
--- a/cypress/integration/sasjs.tests.ts
+++ b/cypress/integration/sasjs.tests.ts
@@ -12,6 +12,58 @@ context('sasjs-tests', function () {
cy.visit(sasjsTestsUrl)
})
+ function waitForTestsToFinish(timeout: number) {
+ const deadline = Date.now() + timeout
+ function check() {
+ cy.get('tests-view', { log: false }).then(($view) => {
+ const shadow = ($view[0] as HTMLElement).shadowRoot
+ const stillRunning = !!shadow?.querySelector('#run-btn:disabled')
+ if (!stillRunning) return
+ if (Date.now() >= deadline) {
+ cy.log('Timed out waiting for tests to finish; reporting status')
+ return
+ }
+ cy.wait(2000, { log: false })
+ check()
+ })
+ }
+ check()
+ }
+
+ function assertNoFailedTests() {
+ cy.get('test-card').then(($cards) => {
+ const failed: string[] = []
+ const stuck: string[] = []
+ const pending: string[] = []
+ $cards.each((_, card) => {
+ const shadow = (card as HTMLElement).shadowRoot
+ if (!shadow) return
+ const icon = shadow.querySelector('.status-icon')
+ const title =
+ shadow.querySelector('.header h3')?.textContent?.trim() ?? '(unknown)'
+ if (icon?.classList.contains('failed')) {
+ const error =
+ shadow.querySelector('.error pre')?.textContent?.trim() ?? ''
+ failed.push(error ? `- ${title}\n ${error}` : `- ${title}`)
+ } else if (icon?.classList.contains('running')) {
+ stuck.push(`- ${title}`)
+ } else if (icon?.classList.contains('pending')) {
+ pending.push(`- ${title}`)
+ }
+ })
+ const parts: string[] = []
+ if (failed.length)
+ parts.push(`${failed.length} failed:\n${failed.join('\n')}`)
+ if (stuck.length)
+ parts.push(`${stuck.length} stuck (running):\n${stuck.join('\n')}`)
+ if (pending.length)
+ parts.push(
+ `${pending.length} did not start (pending):\n${pending.join('\n')}`
+ )
+ expect(parts, parts.join('\n\n')).to.be.empty
+ })
+ }
+
function loginIfNeeded() {
cy.get('login-form, tests-view', { timeout: 30000 }).should('exist')
@@ -42,14 +94,9 @@ context('sasjs-tests', function () {
cy.get('tests-view').shadow().find('#run-btn').should('be.visible').click()
- cy.get('tests-view')
- .shadow()
- .find('#run-btn:disabled', {
- timeout: testingFinishTimeout
- })
- .should('not.exist')
+ waitForTestsToFinish(testingFinishTimeout)
- cy.get('test-card').shadow().find('.status-icon.failed').should('not.exist')
+ assertNoFailedTests()
})
it('Should have all tests successful with debug on', () => {
@@ -63,13 +110,8 @@ context('sasjs-tests', function () {
cy.get('tests-view').shadow().find('#run-btn').should('be.visible').click()
- cy.get('tests-view')
- .shadow()
- .find('#run-btn:disabled', {
- timeout: testingFinishTimeout
- })
- .should('not.exist')
+ waitForTestsToFinish(testingFinishTimeout)
- cy.get('test-card').shadow().find('.status-icon.failed').should('not.exist')
+ assertNoFailedTests()
})
})
diff --git a/sasjs-tests/src/components/TestSuite.ts b/sasjs-tests/src/components/TestSuite.ts
index 39843a3..d9430c0 100644
--- a/sasjs-tests/src/components/TestSuite.ts
+++ b/sasjs-tests/src/components/TestSuite.ts
@@ -66,10 +66,11 @@ export class TestSuiteElement extends HTMLElement {
const passed = completedTests.filter((t) => t.status === 'passed').length
const failed = completedTests.filter((t) => t.status === 'failed').length
const running = completedTests.filter((t) => t.status === 'running').length
+ const pending = completedTests.filter((t) => t.status === 'pending').length
const statsEl = this.shadow.querySelector('.stats')
if (statsEl) {
- statsEl.textContent = `Passed: ${passed} | Failed: ${failed} | Running: ${running}`
+ statsEl.textContent = `Passed: ${passed} | Failed: ${failed} | Running: ${running} | Pending: ${pending}`
}
}
@@ -80,11 +81,12 @@ export class TestSuiteElement extends HTMLElement {
const passed = completedTests.filter((t) => t.status === 'passed').length
const failed = completedTests.filter((t) => t.status === 'failed').length
const running = completedTests.filter((t) => t.status === 'running').length
+ const pending = completedTests.filter((t) => t.status === 'pending').length
this.shadow.innerHTML = `
`
diff --git a/sasjs-tests/src/core/TestRunner.ts b/sasjs-tests/src/core/TestRunner.ts
index 1fcc583..e634198 100644
--- a/sasjs-tests/src/core/TestRunner.ts
+++ b/sasjs-tests/src/core/TestRunner.ts
@@ -30,12 +30,14 @@ export class TestRunner {
) => void
): Promise {
this.isRunning = true
- this.completedTestSuites = []
+ this.completedTestSuites = this.testSuites.map((suite) => ({
+ name: suite.name,
+ completedTests: []
+ }))
- for (let i = 0; i < this.testSuites.length; i++) {
- const suite = this.testSuites[i]
- await this.runTestSuite(suite, i, onUpdate)
- }
+ await Promise.allSettled(
+ this.testSuites.map((suite, i) => this.runTestSuite(suite, i, onUpdate))
+ )
this.isRunning = false
return this.completedTestSuites
@@ -49,7 +51,23 @@ export class TestRunner {
currentIndex: number
) => void
): Promise {
- const completedTests: CompletedTest[] = []
+ // Seed all tests as pending so every card renders before any run starts.
+ const completedTests: CompletedTest[] = suite.tests.map((test) => ({
+ test,
+ result: false,
+ error: null,
+ executionTime: 0,
+ status: 'pending'
+ }))
+
+ if (onUpdate) {
+ this.completedTestSuites[suiteIndex] = {
+ name: suite.name,
+ completedTests: [...completedTests]
+ }
+ onUpdate([...this.completedTestSuites], suiteIndex * 1000)
+ }
+
let context: unknown
// Run beforeAll if exists
@@ -62,15 +80,14 @@ export class TestRunner {
const test = suite.tests[i]
const currentIndex = suiteIndex * 1000 + i
- // Set status to running
- const runningTest: CompletedTest = {
+ // Flip pending → running
+ completedTests[i] = {
test,
result: false,
error: null,
executionTime: 0,
status: 'running'
}
- completedTests.push(runningTest)
// Notify update
if (onUpdate) {
diff --git a/sasjs-tests/src/main.ts b/sasjs-tests/src/main.ts
index 4dd8f50..95781c2 100644
--- a/sasjs-tests/src/main.ts
+++ b/sasjs-tests/src/main.ts
@@ -23,6 +23,7 @@ import { fileUploadTests } from './testSuites/FileUpload'
import { computeTests } from './testSuites/Compute'
import { sasjsRequestTests } from './testSuites/SasjsRequests'
import { specialCaseTests } from './testSuites/SpecialCases'
+import { executionTasksTests } from './testSuites/executionTasks'
async function init() {
const appContainer = document.getElementById('app')
@@ -104,8 +105,9 @@ function showTests(
fileUploadTests(adapter)
]
- // Add compute tests for SASVIYA only
+ // Add tests for SASVIYA only
if (adapter.getSasjsConfig().serverType === 'SASVIYA') {
+ testSuites.push(executionTasksTests(adapter))
testSuites.push(computeTests(adapter, appLoc))
}
diff --git a/sasjs-tests/src/testSuites/executionTasks.ts b/sasjs-tests/src/testSuites/executionTasks.ts
new file mode 100644
index 0000000..1ab689d
--- /dev/null
+++ b/sasjs-tests/src/testSuites/executionTasks.ts
@@ -0,0 +1,126 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import SASjs from '@sasjs/adapter'
+import type { TestSuite } from '../types'
+
+const tableData: any = { table1: [{ col1: 'first col value' }] }
+const fileData: any = { table1: [{ col1: 'value with ; semicolon' }] }
+const multiTableData: any = {
+ table1: [{ col1: 'first col value' }],
+ table2: [{ col2: 'second table value' }]
+}
+const multiFileData: any = {
+ table1: [{ col1: 'value with ; semicolon' }],
+ table2: [{ col2: 'another; value' }]
+}
+
+const taskConfig: any = { useComputeApi: null, runAsTask: true }
+const noTaskConfig: any = { useComputeApi: null, runAsTask: false }
+
+export const executionTasksTests = (adapter: SASjs): TestSuite => ({
+ name: 'runAsTask behaviour',
+ tests: [
+ {
+ title: 'no inputs (runAsTask=false)',
+ description: 'no payload, runAsTask explicitly disabled',
+ test: () =>
+ adapter
+ .request('services/common/sendArr', null, noTaskConfig)
+ .then((res: any) => ({ ok: true, res }))
+ .catch((e: any) => ({ ok: false, error: e })),
+ assertion: (res: any) => res?.ok === true
+ },
+ {
+ title: 'no inputs (runAsTask=true)',
+ description: 'no payload, runAsTask=true via config',
+ test: () =>
+ adapter
+ .request('services/common/sendArr', null, taskConfig)
+ .then((res: any) => ({ ok: true, res }))
+ .catch((e: any) => ({ ok: false, error: e })),
+ assertion: (res: any) => res?.ok === true
+ },
+ {
+ title: 'one input table (runAsTask=false)',
+ description: 'single table payload, runAsTask explicitly disabled',
+ test: () =>
+ adapter
+ .request('services/common/sendArr', tableData, noTaskConfig)
+ .then((res: any) => ({ ok: true, res }))
+ .catch((e: any) => ({ ok: false, error: e })),
+ assertion: (res: any) => res?.ok === true
+ },
+ {
+ title: 'one input table (runAsTask=true)',
+ description: 'single table payload, runAsTask=true via config',
+ test: () =>
+ adapter
+ .request('services/common/sendArr', tableData, taskConfig)
+ .then((res: any) => ({ ok: true, res }))
+ .catch((e: any) => ({ ok: false, error: e })),
+ assertion: (res: any) => res?.ok === true
+ },
+ {
+ title: 'multiple input tables (runAsTask=false)',
+ description: 'multi-table payload, runAsTask explicitly disabled',
+ test: () =>
+ adapter
+ .request('services/common/sendArr', multiTableData, noTaskConfig)
+ .then((res: any) => ({ ok: true, res }))
+ .catch((e: any) => ({ ok: false, error: e })),
+ assertion: (res: any) => res?.ok === true
+ },
+ {
+ title: 'multiple input tables (runAsTask=true)',
+ description: 'multi-table payload, runAsTask=true via config',
+ test: () =>
+ adapter
+ .request('services/common/sendArr', multiTableData, taskConfig)
+ .then((res: any) => ({ ok: true, res }))
+ .catch((e: any) => ({ ok: false, error: e })),
+ assertion: (res: any) => res?.ok === true
+ },
+ {
+ title: 'semicolon payload, single table, blob path (runAsTask=false)',
+ description: 'semicolon payload routes through blob path, runAsTask off',
+ test: () =>
+ adapter
+ .request('services/common/sendArr', fileData, noTaskConfig)
+ .then((res: any) => ({ ok: true, res }))
+ .catch((e: any) => ({ ok: false, error: e })),
+ assertion: (res: any) => res?.ok === true
+ },
+ {
+ title: 'semicolon payload, single table, blob path (runAsTask=true)',
+ description:
+ 'semicolon payload (single table) routes through blob path, runAsTask=true',
+ test: () =>
+ adapter
+ .request('services/common/sendArr', fileData, taskConfig)
+ .then((res: any) => ({ ok: true, res }))
+ .catch((e: any) => ({ ok: false, error: e })),
+ assertion: (res: any) => res?.ok === true
+ },
+ {
+ title: 'semicolon payload, multi-table, blob path (runAsTask=false)',
+ description:
+ 'semicolon payload (multi-table) routes through blob path, runAsTask off',
+ test: () =>
+ adapter
+ .request('services/common/sendArr', multiFileData, noTaskConfig)
+ .then((res: any) => ({ ok: true, res }))
+ .catch((e: any) => ({ ok: false, error: e })),
+ assertion: (res: any) => res?.ok === true
+ },
+ {
+ title: 'semicolon payload, multi-table, blob path (runAsTask=true)',
+ description:
+ 'semicolon payload (multi-table) routes through blob path, runAsTask=true',
+ test: () =>
+ adapter
+ .request('services/common/sendArr', multiFileData, taskConfig)
+ .then((res: any) => ({ ok: true, res }))
+ .catch((e: any) => ({ ok: false, error: e })),
+ assertion: (res: any) => res?.ok === true
+ }
+ ]
+})
diff --git a/src/file/generateTableUploadForm.ts b/src/file/generateTableUploadForm.ts
index d7a13ef..ec33272 100644
--- a/src/file/generateTableUploadForm.ts
+++ b/src/file/generateTableUploadForm.ts
@@ -11,6 +11,13 @@ export const generateTableUploadForm = (
let tableCounter = 0
for (const tableName in data) {
+ if (
+ isFormatsTable(tableName) &&
+ Object.keys(data).includes(tableName.replace(/^\$/, ''))
+ ) {
+ continue
+ }
+
tableCounter++
// Formats table should not be sent as part of 'sasjs_tables'
diff --git a/src/file/spec/generateTableUploadForm.spec.ts b/src/file/spec/generateTableUploadForm.spec.ts
new file mode 100644
index 0000000..ebb994a
--- /dev/null
+++ b/src/file/spec/generateTableUploadForm.spec.ts
@@ -0,0 +1,71 @@
+import { generateTableUploadForm } from '../generateTableUploadForm'
+import { convertToCSV } from '../../utils/convertToCsv'
+import NodeFormData from 'form-data'
+
+describe('generateTableUploadForm', () => {
+ it('should skip formats table and emit single sasjs1data for paired data + $data', () => {
+ const tableName = 'jsdata'
+ const data: { [key: string]: any } = {
+ [tableName]: [
+ { var1: 'string', var2: 232 },
+ { var1: 'string', var2: 233 }
+ ],
+ [`$${tableName}`]: { formats: { var1: '$char12.', var2: 'best.' } }
+ }
+ const expectedCsv = convertToCSV(data, tableName)
+
+ const formData = new NodeFormData()
+ const { requestParams } = generateTableUploadForm(formData, data)
+
+ expect(requestParams.sasjs_tables).toBe(tableName)
+ expect(requestParams.sasjs1data).toBe(expectedCsv)
+ expect(requestParams.sasjs2data).toBeUndefined()
+ })
+
+ it('should number sequentially across multiple tables w/ paired formats', () => {
+ const data: { [key: string]: any } = {
+ tableA: [{ a: 1 }],
+ $tableA: { formats: { a: 'best.' } },
+ tableB: [{ b: 'x' }],
+ $tableB: { formats: { b: '$char1.' } }
+ }
+ const expectedCsvA = convertToCSV(data, 'tableA')
+ const expectedCsvB = convertToCSV(data, 'tableB')
+
+ const formData = new NodeFormData()
+ const { requestParams } = generateTableUploadForm(formData, data)
+
+ expect(requestParams.sasjs_tables).toBe('tableA tableB')
+ expect(requestParams.sasjs1data).toBe(expectedCsvA)
+ expect(requestParams.sasjs2data).toBe(expectedCsvB)
+ expect(requestParams.sasjs3data).toBeUndefined()
+ })
+
+ it('should throw if string value exceeds 32765 chars', () => {
+ const formData = new NodeFormData()
+ const data = { testTable: [{ var1: 'z'.repeat(32766) }] }
+
+ expect(() => generateTableUploadForm(formData, data)).toThrow(
+ new Error(
+ 'The max length of a string value in SASjs is 32765 characters.'
+ )
+ )
+ })
+
+ it('should append chunks to formData when csv exceeds 16k', () => {
+ const tableName = 'big'
+ const data = { [tableName]: [{ var1: 'z'.repeat(16001) }] }
+
+ const formData = new NodeFormData()
+ const appendSpy = jest.spyOn(formData, 'append')
+
+ const { requestParams } = generateTableUploadForm(formData, data)
+
+ expect(requestParams.sasjs_tables).toBe(tableName)
+ expect(requestParams.sasjs1data).toBeUndefined()
+ expect(appendSpy).toHaveBeenCalled()
+ expect(appendSpy.mock.calls.every(([key]) => key === 'sasjs1data')).toBe(
+ true
+ )
+ })
+})
diff --git a/src/job-execution/JobExecutor.ts b/src/job-execution/JobExecutor.ts
index f60ddfa..955611c 100644
--- a/src/job-execution/JobExecutor.ts
+++ b/src/job-execution/JobExecutor.ts
@@ -55,7 +55,6 @@ export abstract class BaseJobExecutor implements JobExecutor {
if (config.debug) {
requestParams['_omittextlog'] = 'false'
requestParams['_omitSessionResults'] = 'false'
-
requestParams['_debug'] = 131
}
diff --git a/src/job-execution/WebJobExecutor.ts b/src/job-execution/WebJobExecutor.ts
index a9c980d..545e8a4 100644
--- a/src/job-execution/WebJobExecutor.ts
+++ b/src/job-execution/WebJobExecutor.ts
@@ -16,9 +16,11 @@ import { SASViyaApiClient } from '../SASViyaApiClient'
import {
isRelativePath,
parseSasViyaDebugResponse,
+ parseSasViyaLogDebugResponse,
appendExtraResponseAttributes,
parseWeboutResponse,
- getFormData
+ getFormData,
+ isNode
} from '../utils'
import { BaseJobExecutor } from './JobExecutor'
@@ -101,6 +103,10 @@ export class WebJobExecutor extends BaseJobExecutor {
apiUrl += config.contextName?.trim()
? `&_contextname=${encodeURIComponent(config.contextName)}`
: ''
+
+ if (config.runAsTask === true) {
+ apiUrl += '&_executionTasks=true'
+ }
}
let requestParams = {
@@ -113,6 +119,26 @@ export class WebJobExecutor extends BaseJobExecutor {
*/
let formData = getFormData()
+ // FIXME(viya - SAS Track CS0409737): remove when Viya stops rejecting empty multipart on
+ // _executionTasks=true. Dummy file keeps the body non-empty
+ const hasExecutionTasksFlag = config.runAsTask === true
+
+ // Move debug params to URL for viya; viya seems to honor them more
+ // reliably in the query string than the multipart body.
+ if (
+ hasExecutionTasksFlag &&
+ config.debug &&
+ config.serverType === ServerType.SasViya
+ ) {
+ const debugKeys = ['_debug', '_omitSessionResults']
+ debugKeys.forEach((key) => {
+ if (requestParams[key] !== undefined) {
+ apiUrl += `&${key}=${encodeURIComponent(requestParams[key])}`
+ delete requestParams[key]
+ }
+ })
+ }
+
if (data) {
const stringifiedData = JSON.stringify(data)
if (
@@ -133,10 +159,21 @@ export class WebJobExecutor extends BaseJobExecutor {
generateTableUploadForm(formData, data)
formData = newFormData
requestParams = { ...requestParams, ...params }
+
+ if (
+ config.serverType === ServerType.SasViya &&
+ hasExecutionTasksFlag
+ ) {
+ addDummyFile(formData)
+ }
} catch (e: any) {
return Promise.reject(new ErrorResponse(e?.message, e))
}
}
+ } else {
+ if (config.serverType === ServerType.SasViya && hasExecutionTasksFlag) {
+ addDummyFile(formData)
+ }
}
for (const key in requestParams) {
@@ -168,11 +205,14 @@ export class WebJobExecutor extends BaseJobExecutor {
if (config.debug) {
switch (this.serverType) {
case ServerType.SasViya:
- jsonResponse = await parseSasViyaDebugResponse(
- res.result,
- this.requestClient,
- this.serverUrl
- )
+ jsonResponse =
+ config.useComputeApi === null && config.runAsTask === true
+ ? await parseSasViyaLogDebugResponse(res.result)
+ : await parseSasViyaDebugResponse(
+ res.result,
+ this.requestClient,
+ this.serverUrl
+ )
break
case ServerType.Sas9:
jsonResponse =
@@ -231,6 +271,23 @@ export class WebJobExecutor extends BaseJobExecutor {
return requestPromise
}
+ protected getRequestParams(config: any): any {
+ const requestParams = super.getRequestParams(config)
+
+ // FIXME(viya - possible issue with default debug flags)
+ // runAsTask on Viya: use _debug=128 (not 131) and omit _omittextlog
+ if (
+ config.debug &&
+ config.serverType === ServerType.SasViya &&
+ config.runAsTask === true
+ ) {
+ requestParams['_debug'] = 128
+ delete requestParams['_omittextlog']
+ }
+
+ return requestParams
+ }
+
private async getJobUri(sasJob: string) {
if (!this.sasViyaApiClient) return ''
let uri = ''
@@ -263,3 +320,18 @@ export class WebJobExecutor extends BaseJobExecutor {
return uri
}
}
+
+function addDummyFile(formData: NodeFormData | FormData) {
+ if (isNode()) {
+ ;(formData as NodeFormData).append('_sasjs_noop', '', {
+ filename: '_sasjs_noop.txt',
+ contentType: 'text/plain'
+ })
+ } else {
+ ;(formData as FormData).append(
+ '_sasjs_noop',
+ new Blob([''], { type: 'text/plain' }),
+ '_sasjs_noop.txt'
+ )
+ }
+}
diff --git a/src/job-execution/spec/executionTasks.spec.ts b/src/job-execution/spec/executionTasks.spec.ts
new file mode 100644
index 0000000..3851700
--- /dev/null
+++ b/src/job-execution/spec/executionTasks.spec.ts
@@ -0,0 +1,146 @@
+import NodeFormData from 'form-data'
+import { ServerType } from '@sasjs/utils/types'
+import { WebJobExecutor } from '../WebJobExecutor'
+import { RequestClient } from '../../request/RequestClient'
+import { SASViyaApiClient } from '../../SASViyaApiClient'
+
+describe('WebJobExecutor runAsTask behaviour', () => {
+ const serverUrl = 'https://sample.server.com'
+ const jobsPath = '/SASJobExecution'
+
+ const makeExecutor = (serverType: ServerType = ServerType.SasViya) => {
+ const requestClient = new RequestClient(serverUrl)
+ const sasViyaApiClient = {
+ getJobsInFolder: async () => []
+ } as unknown as SASViyaApiClient
+ const executor = new WebJobExecutor(
+ serverUrl,
+ serverType,
+ jobsPath,
+ requestClient,
+ sasViyaApiClient
+ )
+ const postSpy = jest
+ .spyOn(requestClient, 'post')
+ .mockResolvedValue({ result: { table1: [] }, etag: '' } as any)
+ jest.spyOn(requestClient, 'appendRequest').mockImplementation()
+ return { executor, postSpy }
+ }
+
+ const baseConfig = {
+ serverUrl,
+ serverType: ServerType.SasViya,
+ appLoc: '/Public/app',
+ useComputeApi: false,
+ debug: false
+ }
+
+ it('sends table data in body (runAsTask=false)', async () => {
+ const { executor, postSpy } = makeExecutor()
+
+ await executor.execute(
+ 'services/common/sendArr',
+ { table1: [{ col1: 'v' }] },
+ { ...baseConfig, runAsTask: false }
+ )
+
+ const [apiUrl, body, , contentType] = postSpy.mock.calls[0]
+ expect(apiUrl).not.toContain('_executionTasks=true')
+ expect(body).toBeInstanceOf(NodeFormData)
+ expect(contentType).toMatch(/^multipart\/form-data/)
+ })
+
+ it('uploads as file when payload has semicolons (runAsTask=false)', async () => {
+ const { executor, postSpy } = makeExecutor()
+
+ await executor.execute(
+ 'services/common/sendArr',
+ { table1: [{ col1: 'has; semicolon' }] },
+ { ...baseConfig, runAsTask: false }
+ )
+
+ const [apiUrl, body, , contentType] = postSpy.mock.calls[0]
+ expect(apiUrl).not.toContain('_executionTasks=')
+ expect(body).toBeInstanceOf(NodeFormData)
+ expect(contentType).toMatch(/^multipart\/form-data/)
+ })
+
+ it('appends &_executionTasks=true to URL when runAsTask=true and no data', async () => {
+ const { executor, postSpy } = makeExecutor()
+
+ await executor.execute('services/common/sendArr', null, {
+ ...baseConfig,
+ runAsTask: true
+ })
+
+ const [apiUrl] = postSpy.mock.calls[0]
+ expect(apiUrl).toContain('&_executionTasks=true')
+ })
+
+ it('appends &_executionTasks=true and sends table data when runAsTask=true with one input table', async () => {
+ const { executor, postSpy } = makeExecutor()
+
+ await executor.execute(
+ 'services/common/sendArr',
+ { table1: [{ col1: 'v' }] },
+ { ...baseConfig, runAsTask: true }
+ )
+
+ const [apiUrl, body, , contentType] = postSpy.mock.calls[0]
+ expect(apiUrl).toContain('&_executionTasks=true')
+ expect(body).toBeInstanceOf(NodeFormData)
+ expect(contentType).toMatch(/^multipart\/form-data/)
+ const dump = (body as NodeFormData).getBuffer().toString()
+ expect(dump).toContain('name="sasjs_tables"')
+ expect(dump).toContain('name="sasjs1data"')
+ })
+
+ it('appends &_executionTasks=true to URL when runAsTask=true with multiple input tables', async () => {
+ const { executor, postSpy } = makeExecutor()
+
+ await executor.execute(
+ 'services/common/sendArr',
+ { table1: [{ col1: 'v' }], table2: [{ col2: 'w' }] },
+ { ...baseConfig, runAsTask: true }
+ )
+
+ const [apiUrl, body, , contentType] = postSpy.mock.calls[0]
+ expect(apiUrl).toContain('&_executionTasks=true')
+ expect(body).toBeInstanceOf(NodeFormData)
+ expect(contentType).toMatch(/^multipart\/form-data/)
+ const dump = (body as NodeFormData).getBuffer().toString()
+ expect(dump).toContain('name="sasjs_tables"')
+ expect(dump).toMatch(/table1\s+table2/)
+ })
+
+ it('uploads as file when runAsTask=true and payload has semicolons', async () => {
+ const { executor, postSpy } = makeExecutor()
+
+ await executor.execute(
+ 'services/common/sendArr',
+ { table1: [{ col1: 'has; semicolon' }] },
+ { ...baseConfig, runAsTask: true }
+ )
+
+ const [apiUrl, body, , contentType] = postSpy.mock.calls[0]
+ expect(apiUrl).toContain('&_executionTasks=true')
+ expect(body).toBeInstanceOf(NodeFormData)
+ expect(contentType).toMatch(/^multipart\/form-data/)
+ const dump = (body as NodeFormData).getBuffer().toString()
+ expect(dump).toContain('filename="table1.csv"')
+ expect(dump).toContain('Content-Type: application/csv')
+ })
+
+ it('does NOT append _executionTasks=true to URL when runAsTask=false', async () => {
+ const { executor, postSpy } = makeExecutor()
+
+ await executor.execute(
+ 'services/common/sendArr',
+ { table1: [{ col1: 'v' }] },
+ { ...baseConfig, runAsTask: false }
+ )
+
+ const [apiUrl] = postSpy.mock.calls[0]
+ expect(apiUrl).not.toContain('_executionTasks=true')
+ })
+})
diff --git a/src/types/SASjsConfig.ts b/src/types/SASjsConfig.ts
index 92a0198..f1fafb9 100644
--- a/src/types/SASjsConfig.ts
+++ b/src/types/SASjsConfig.ts
@@ -79,6 +79,14 @@ export class SASjsConfig {
* may affect browser performance, especially with debug (logs) enabled.
*/
requestHistoryLimit?: number = 10
+ /**
+ * Optional setting. When `true`, the request runs as a Viya execution task —
+ * appends `_executionTasks=true` to the request URL. Only applies to the Viya
+ * web jobs path, i.e. when `serverType === SASVIYA` AND
+ * `useComputeApi` is `null`/`undefined`. Has no effect when `useComputeApi`
+ * is explicitly set to `true` or `false`.
+ */
+ runAsTask?: boolean = false
}
export enum LoginMechanism {
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 2a2b405..ac11428 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -15,6 +15,7 @@ export * from './parseGeneratedCode'
export * from './parseSasViyaLog'
export * from './parseSourceCode'
export * from './parseViyaDebugResponse'
+export * from './parseViyaLogDebugResponse'
export * from './parseWeboutResponse'
export * from './serialize'
export * from './splitChunks'
diff --git a/src/utils/parseViyaLogDebugResponse.ts b/src/utils/parseViyaLogDebugResponse.ts
new file mode 100644
index 0000000..9060c9c
--- /dev/null
+++ b/src/utils/parseViyaLogDebugResponse.ts
@@ -0,0 +1,32 @@
+import { getValidJson } from './getValidJson'
+import { parseWeboutResponse } from './parseWeboutResponse'
+
+/**
+ * When querying a Viya job using the Web approach with _DEBUG=128 (used when
+ * runAsTask is true), the webout JSON is inlined into the response via:
+ * var blob = new Blob([`{...}`], {type: 'application/json'});
+ * On abort/error paths the same shape is used but with text/plain and
+ * weboutBEGIN/END markers around the JSON:
+ * var blob = new Blob([`>>weboutBEGIN<<\n{...}\n>>weboutEND<<\n`], {type: 'text/plain'});
+ * No follow-up request is needed — extract and parse the JSON directly.
+ */
+export const parseSasViyaLogDebugResponse = async (response: any) => {
+ // If upstream already parsed the response as JSON (object), pass through.
+ if (typeof response !== 'string') {
+ return response
+ }
+
+ const blobMatch = response.match(
+ /new Blob\(\[`([\s\S]*?)`\],\s*\{type:\s*'(?:application\/json|text\/plain)'\}\)/
+ )
+ if (!blobMatch) {
+ throw new Error('Unable to find webout blob in debug log response.')
+ }
+
+ const blobContent = blobMatch[1]
+ const stripped = blobContent.includes('>>weboutBEGIN<<')
+ ? parseWeboutResponse(blobContent)
+ : blobContent
+
+ return getValidJson(stripped)
+}
diff --git a/src/utils/spec/parseViyaLogDebugResponse.spec.ts b/src/utils/spec/parseViyaLogDebugResponse.spec.ts
new file mode 100644
index 0000000..2683d0a
--- /dev/null
+++ b/src/utils/spec/parseViyaLogDebugResponse.spec.ts
@@ -0,0 +1,55 @@
+import { parseSasViyaLogDebugResponse } from '../parseViyaLogDebugResponse'
+
+describe('parseSasViyaLogDebugResponse', () => {
+ it('should extract and parse JSON from inline Blob', async () => {
+ const resultData = { message: 'success' }
+ const response = ``
+
+ const result = await parseSasViyaLogDebugResponse(response)
+
+ expect(result).toEqual(resultData)
+ })
+
+ it('should extract and parse multiline JSON from inline Blob', async () => {
+ const resultData = {
+ SYSDATE: '13MAY26',
+ SYSCC: '0',
+ saslibs: [{ LIBRARYREF: 'FORMATS' }]
+ }
+ const response = ``
+
+ const result = await parseSasViyaLogDebugResponse(response)
+
+ expect(result).toEqual(resultData)
+ })
+
+ it('should extract and parse JSON wrapped in weboutBEGIN/END (text/plain blob)', async () => {
+ const resultData = { SYSCC: '1012', SYSERRORTEXT: 'File missing.' }
+ const response = ``
+
+ const result = await parseSasViyaLogDebugResponse(response)
+
+ expect(result).toEqual(resultData)
+ })
+
+ it('should throw an error if blob is not found', async () => {
+ const response = `No blob here`
+
+ await expect(parseSasViyaLogDebugResponse(response)).rejects.toThrow(
+ 'Unable to find webout blob in debug log response.'
+ )
+ })
+})