From 55db8f45abfa70c15b35f7601b8be36ce23977f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sead=20Mulahasanovi=C4=87?= Date: Tue, 12 May 2026 09:53:11 +0200 Subject: [PATCH 01/12] fix(webjob): test coverage for _executionTasks=true requests without file upload (#883) * test(cypress): show individual errors * test(cypress): half the cypress integration test timeout * test(cypress): add parallel tests, timeout and reports * test(cypress): use allSettled instead of all * test(runner): pre-render pending test cards --- cypress.config.js | 2 +- cypress/integration/sasjs.tests.ts | 70 +++++++++--- sasjs-tests/src/components/TestSuite.ts | 6 +- sasjs-tests/src/core/TestRunner.ts | 35 ++++-- sasjs-tests/src/main.ts | 4 +- sasjs-tests/src/testSuites/executionTasks.ts | 61 ++++++++++ src/job-execution/spec/executionTasks.spec.ts | 108 ++++++++++++++++++ 7 files changed, 259 insertions(+), 27 deletions(-) create mode 100644 sasjs-tests/src/testSuites/executionTasks.ts create mode 100644 src/job-execution/spec/executionTasks.spec.ts 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 = `

${name}

-
Passed: ${passed} | Failed: ${failed} | Running: ${running}
+
Passed: ${passed} | Failed: ${failed} | Running: ${running} | Pending: ${pending}
` 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..78e7866 --- /dev/null +++ b/sasjs-tests/src/testSuites/executionTasks.ts @@ -0,0 +1,61 @@ +/* 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' }] } + +export const executionTasksTests = (adapter: SASjs): TestSuite => ({ + name: '_executionTasks=true behaviour', + tests: [ + { + title: 'sends table data in body', + description: 'table payload, no _executionTasks flag', + test: () => + adapter + .request('services/common/sendArr', tableData, { + useComputeApi: null + }) + .then((res: any) => ({ ok: true, res })) + .catch((e: any) => ({ ok: false, error: e })), + assertion: (res: any) => res?.ok === true + }, + { + title: 'sends table data when _executionTasks=true', + description: 'table payload with _executionTasks=true', + test: () => + adapter + .request('services/common/sendArr&_executionTasks=true', tableData, { + useComputeApi: null + }) + .then((res: any) => ({ ok: true, res })) + .catch((e: any) => ({ ok: false, error: e })), + assertion: (res: any) => res?.ok === true + }, + { + title: 'uploads as file when payload has semicolons', + description: 'semicolon payload, no _executionTasks flag', + test: () => + adapter + .request('services/common/sendArr', fileData, { + useComputeApi: null + }) + .then((res: any) => ({ ok: true, res })) + .catch((e: any) => ({ ok: false, error: e })), + assertion: (res: any) => res?.ok === true + }, + { + title: + 'uploads as file when _executionTasks=true and payload has semicolons', + description: 'semicolon payload with _executionTasks=true', + test: () => + adapter + .request('services/common/sendArr&_executionTasks=true', fileData, { + useComputeApi: null + }) + .then((res: any) => ({ ok: true, res })) + .catch((e: any) => ({ ok: false, error: e })), + assertion: (res: any) => res?.ok === true + } + ] +}) diff --git a/src/job-execution/spec/executionTasks.spec.ts b/src/job-execution/spec/executionTasks.spec.ts new file mode 100644 index 0000000..877c0f6 --- /dev/null +++ b/src/job-execution/spec/executionTasks.spec.ts @@ -0,0 +1,108 @@ +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 _executionTasks=true 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', + debug: false + } + + it('sends table data in body', async () => { + const { executor, postSpy } = makeExecutor() + + await executor.execute( + 'services/common/sendArr', + { table1: [{ col1: 'v' }] }, + baseConfig + ) + + const [, body, , contentType] = postSpy.mock.calls[0] + expect(body).toBeInstanceOf(NodeFormData) + expect(contentType).toMatch(/^multipart\/form-data/) + }) + + it('sends table data when _executionTasks=true', async () => { + const { executor, postSpy } = makeExecutor() + + await executor.execute( + 'services/common/sendArr&_executionTasks=true', + { table1: [{ col1: 'v' }] }, + baseConfig + ) + + const [apiUrl, body, , contentType] = postSpy.mock.calls[0] + expect(apiUrl).toContain('_program=/Public/app/services/common/sendArr') + 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('uploads as file when payload has semicolons', async () => { + const { executor, postSpy } = makeExecutor() + + await executor.execute( + 'services/common/sendArr', + { table1: [{ col1: 'has; semicolon' }] }, + baseConfig + ) + + const [apiUrl, body, , contentType] = postSpy.mock.calls[0] + expect(apiUrl).toContain('_program=') + expect(apiUrl).not.toContain('_executionTasks=') + expect(body).toBeInstanceOf(NodeFormData) + expect(body).not.toBeInstanceOf(URLSearchParams) + expect(contentType).toMatch(/^multipart\/form-data/) + expect(contentType).not.toBe('application/x-www-form-urlencoded') + }) + + it('uploads as file when _executionTasks=true and payload has semicolons', async () => { + const { executor, postSpy } = makeExecutor() + + await executor.execute( + 'services/common/sendArr&_executionTasks=true', + { table1: [{ col1: 'has; semicolon' }] }, + baseConfig + ) + + const [apiUrl, body, , contentType] = postSpy.mock.calls[0] + expect(apiUrl).toContain('_program=') + expect(apiUrl).toContain('_executionTasks=true') + expect(body).toBeInstanceOf(NodeFormData) + expect(body).not.toBeInstanceOf(URLSearchParams) + expect(contentType).toMatch(/^multipart\/form-data/) + expect(contentType).not.toBe('application/x-www-form-urlencoded') + const dump = (body as NodeFormData).getBuffer().toString() + expect(dump).toContain('filename="table1.csv"') + expect(dump).toContain('Content-Type: application/csv') + }) +}) From 8be0fd94ad64d0734aaf73404ff09f6d440e9c8f Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Tue, 12 May 2026 10:11:59 +0200 Subject: [PATCH 02/12] fix(webjob): add dummy file when execution tasks flag is enabled SAS Track CS0409737 --- src/job-execution/WebJobExecutor.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/job-execution/WebJobExecutor.ts b/src/job-execution/WebJobExecutor.ts index a9c980d..8e469a5 100644 --- a/src/job-execution/WebJobExecutor.ts +++ b/src/job-execution/WebJobExecutor.ts @@ -18,7 +18,8 @@ import { parseSasViyaDebugResponse, appendExtraResponseAttributes, parseWeboutResponse, - getFormData + getFormData, + isNode } from '../utils' import { BaseJobExecutor } from './JobExecutor' @@ -113,6 +114,25 @@ 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 = + sasJob.includes('_executionTasks=true') || config.runAsTask === true + if (config.serverType === ServerType.SasViya && hasExecutionTasksFlag) { + 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' + ) + } + } + if (data) { const stringifiedData = JSON.stringify(data) if ( From 2db1b9fc4b071e7c7317f045b80394d03159bb64 Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Tue, 12 May 2026 19:45:04 +0200 Subject: [PATCH 03/12] refactor(webjob): add dummy file only when neccessary SAS Track CS0409737 --- src/job-execution/WebJobExecutor.ts | 40 +++++++++++++++++++---------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/src/job-execution/WebJobExecutor.ts b/src/job-execution/WebJobExecutor.ts index 8e469a5..95dc201 100644 --- a/src/job-execution/WebJobExecutor.ts +++ b/src/job-execution/WebJobExecutor.ts @@ -118,20 +118,6 @@ export class WebJobExecutor extends BaseJobExecutor { // _executionTasks=true. Dummy file keeps the body non-empty const hasExecutionTasksFlag = sasJob.includes('_executionTasks=true') || config.runAsTask === true - if (config.serverType === ServerType.SasViya && hasExecutionTasksFlag) { - 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' - ) - } - } if (data) { const stringifiedData = JSON.stringify(data) @@ -153,10 +139,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) { @@ -283,3 +280,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' + ) + } +} From 3136e98477dcb75f597c51f3b1d43f2913a78232 Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Tue, 12 May 2026 19:47:13 +0200 Subject: [PATCH 04/12] feat(viya): add runAsTask config feature for execution tasks --- src/job-execution/WebJobExecutor.ts | 7 +++++-- src/types/SASjsConfig.ts | 8 ++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/job-execution/WebJobExecutor.ts b/src/job-execution/WebJobExecutor.ts index 95dc201..5ce8da0 100644 --- a/src/job-execution/WebJobExecutor.ts +++ b/src/job-execution/WebJobExecutor.ts @@ -102,6 +102,10 @@ export class WebJobExecutor extends BaseJobExecutor { apiUrl += config.contextName?.trim() ? `&_contextname=${encodeURIComponent(config.contextName)}` : '' + + if (config.runAsTask === true) { + apiUrl += '&_executionTasks=true' + } } let requestParams = { @@ -116,8 +120,7 @@ export class WebJobExecutor extends BaseJobExecutor { // FIXME(viya - SAS Track CS0409737): remove when Viya stops rejecting empty multipart on // _executionTasks=true. Dummy file keeps the body non-empty - const hasExecutionTasksFlag = - sasJob.includes('_executionTasks=true') || config.runAsTask === true + const hasExecutionTasksFlag = config.runAsTask === true if (data) { const stringifiedData = JSON.stringify(data) 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 { From eb6b123dba7a84f93be30219d022c16bf0dcb1f1 Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Tue, 12 May 2026 20:10:58 +0200 Subject: [PATCH 05/12] test(viya): migrate execution-tasks tests to runAsTask config --- sasjs-tests/src/testSuites/executionTasks.ts | 109 ++++++++++++++---- src/job-execution/spec/executionTasks.spec.ts | 106 +++++++++++------ 2 files changed, 159 insertions(+), 56 deletions(-) diff --git a/sasjs-tests/src/testSuites/executionTasks.ts b/sasjs-tests/src/testSuites/executionTasks.ts index 78e7866..1ab689d 100644 --- a/sasjs-tests/src/testSuites/executionTasks.ts +++ b/sasjs-tests/src/testSuites/executionTasks.ts @@ -4,55 +4,120 @@ 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: '_executionTasks=true behaviour', + name: 'runAsTask behaviour', tests: [ { - title: 'sends table data in body', - description: 'table payload, no _executionTasks flag', + title: 'no inputs (runAsTask=false)', + description: 'no payload, runAsTask explicitly disabled', test: () => adapter - .request('services/common/sendArr', tableData, { - useComputeApi: null - }) + .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: 'sends table data when _executionTasks=true', - description: 'table payload with _executionTasks=true', + title: 'no inputs (runAsTask=true)', + description: 'no payload, runAsTask=true via config', test: () => adapter - .request('services/common/sendArr&_executionTasks=true', tableData, { - useComputeApi: null - }) + .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: 'uploads as file when payload has semicolons', - description: 'semicolon payload, no _executionTasks flag', + title: 'one input table (runAsTask=false)', + description: 'single table payload, runAsTask explicitly disabled', test: () => adapter - .request('services/common/sendArr', fileData, { - useComputeApi: null - }) + .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: - 'uploads as file when _executionTasks=true and payload has semicolons', - description: 'semicolon payload with _executionTasks=true', + title: 'one input table (runAsTask=true)', + description: 'single table payload, runAsTask=true via config', test: () => adapter - .request('services/common/sendArr&_executionTasks=true', fileData, { - useComputeApi: null - }) + .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/job-execution/spec/executionTasks.spec.ts b/src/job-execution/spec/executionTasks.spec.ts index 877c0f6..3851700 100644 --- a/src/job-execution/spec/executionTasks.spec.ts +++ b/src/job-execution/spec/executionTasks.spec.ts @@ -4,7 +4,7 @@ import { WebJobExecutor } from '../WebJobExecutor' import { RequestClient } from '../../request/RequestClient' import { SASViyaApiClient } from '../../SASViyaApiClient' -describe('WebJobExecutor _executionTasks=true behaviour', () => { +describe('WebJobExecutor runAsTask behaviour', () => { const serverUrl = 'https://sample.server.com' const jobsPath = '/SASJobExecution' @@ -31,35 +31,63 @@ describe('WebJobExecutor _executionTasks=true behaviour', () => { serverUrl, serverType: ServerType.SasViya, appLoc: '/Public/app', + useComputeApi: false, debug: false } - it('sends table data in body', async () => { + it('sends table data in body (runAsTask=false)', async () => { const { executor, postSpy } = makeExecutor() await executor.execute( 'services/common/sendArr', { table1: [{ col1: 'v' }] }, - baseConfig + { ...baseConfig, runAsTask: false } ) - const [, body, , contentType] = postSpy.mock.calls[0] + 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('sends table data when _executionTasks=true', async () => { + it('uploads as file when payload has semicolons (runAsTask=false)', async () => { const { executor, postSpy } = makeExecutor() await executor.execute( - 'services/common/sendArr&_executionTasks=true', - { table1: [{ col1: 'v' }] }, - baseConfig + 'services/common/sendArr', + { table1: [{ col1: 'has; semicolon' }] }, + { ...baseConfig, runAsTask: false } ) const [apiUrl, body, , contentType] = postSpy.mock.calls[0] - expect(apiUrl).toContain('_program=/Public/app/services/common/sendArr') - expect(apiUrl).toContain('_executionTasks=true') + 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() @@ -67,42 +95,52 @@ describe('WebJobExecutor _executionTasks=true behaviour', () => { expect(dump).toContain('name="sasjs1data"') }) - it('uploads as file when payload has semicolons', async () => { + 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 + { ...baseConfig, runAsTask: true } ) const [apiUrl, body, , contentType] = postSpy.mock.calls[0] - expect(apiUrl).toContain('_program=') - expect(apiUrl).not.toContain('_executionTasks=') + expect(apiUrl).toContain('&_executionTasks=true') expect(body).toBeInstanceOf(NodeFormData) - expect(body).not.toBeInstanceOf(URLSearchParams) expect(contentType).toMatch(/^multipart\/form-data/) - expect(contentType).not.toBe('application/x-www-form-urlencoded') - }) - - it('uploads as file when _executionTasks=true and payload has semicolons', async () => { - const { executor, postSpy } = makeExecutor() - - await executor.execute( - 'services/common/sendArr&_executionTasks=true', - { table1: [{ col1: 'has; semicolon' }] }, - baseConfig - ) - - const [apiUrl, body, , contentType] = postSpy.mock.calls[0] - expect(apiUrl).toContain('_program=') - expect(apiUrl).toContain('_executionTasks=true') - expect(body).toBeInstanceOf(NodeFormData) - expect(body).not.toBeInstanceOf(URLSearchParams) - expect(contentType).toMatch(/^multipart\/form-data/) - expect(contentType).not.toBe('application/x-www-form-urlencoded') 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') + }) }) From a691500910ce61c56b5035b1e01d3b7faa95d56f Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Wed, 13 May 2026 10:56:47 +0200 Subject: [PATCH 06/12] fix: use log instead of 131 if debug is enabled on viya with tasks enabled --- src/job-execution/JobExecutor.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/job-execution/JobExecutor.ts b/src/job-execution/JobExecutor.ts index f60ddfa..4a61250 100644 --- a/src/job-execution/JobExecutor.ts +++ b/src/job-execution/JobExecutor.ts @@ -56,7 +56,12 @@ export abstract class BaseJobExecutor implements JobExecutor { requestParams['_omittextlog'] = 'false' requestParams['_omitSessionResults'] = 'false' - requestParams['_debug'] = 131 + requestParams['_debug'] = + config.useComputeApi === null && + config.serverType === ServerType.SasViya && + config.runAsTask === true + ? 'log' + : 131 } return requestParams From 4cae9b54724a042c36ab796d0914d16bdd822434 Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Wed, 13 May 2026 14:09:47 +0200 Subject: [PATCH 07/12] feat(debug): add viya debug log parser - parse JSON from inline blob --- src/job-execution/WebJobExecutor.ts | 14 ++++--- src/utils/index.ts | 1 + src/utils/parseViyaLogDebugResponse.ts | 18 ++++++++ .../spec/parseViyaLogDebugResponse.spec.ts | 41 +++++++++++++++++++ 4 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 src/utils/parseViyaLogDebugResponse.ts create mode 100644 src/utils/spec/parseViyaLogDebugResponse.spec.ts diff --git a/src/job-execution/WebJobExecutor.ts b/src/job-execution/WebJobExecutor.ts index 5ce8da0..d4e8f25 100644 --- a/src/job-execution/WebJobExecutor.ts +++ b/src/job-execution/WebJobExecutor.ts @@ -16,6 +16,7 @@ import { SASViyaApiClient } from '../SASViyaApiClient' import { isRelativePath, parseSasViyaDebugResponse, + parseSasViyaLogDebugResponse, appendExtraResponseAttributes, parseWeboutResponse, getFormData, @@ -188,11 +189,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 = 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..96bfc7f --- /dev/null +++ b/src/utils/parseViyaLogDebugResponse.ts @@ -0,0 +1,18 @@ +import { getValidJson } from './getValidJson' + +/** + * When querying a Viya job using the Web approach with _DEBUG=log (used when + * runAsTask is true), the webout JSON is inlined into the response via: + * var blob = new Blob([`{...}`], {type: 'application/json'}); + * No follow-up request is needed — extract and parse the JSON directly. + */ +export const parseSasViyaLogDebugResponse = async (response: string) => { + const blobMatch = response.match( + /new Blob\(\[`([\s\S]*?)`\],\s*\{type:\s*'application\/json'\}\)/ + ) + if (!blobMatch) { + throw new Error('Unable to find webout blob in debug log response.') + } + + return getValidJson(blobMatch[1]) +} diff --git a/src/utils/spec/parseViyaLogDebugResponse.spec.ts b/src/utils/spec/parseViyaLogDebugResponse.spec.ts new file mode 100644 index 0000000..08c5c56 --- /dev/null +++ b/src/utils/spec/parseViyaLogDebugResponse.spec.ts @@ -0,0 +1,41 @@ +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 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.' + ) + }) +}) From 8f726c0ac91e595d1ee3af06dc13775847cef038 Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Wed, 13 May 2026 14:26:12 +0200 Subject: [PATCH 08/12] fix(debug): add viya debug log parser - parse JSON from inline blob with webout --- src/utils/parseViyaLogDebugResponse.ts | 13 +++++++++++-- src/utils/spec/parseViyaLogDebugResponse.spec.ts | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/utils/parseViyaLogDebugResponse.ts b/src/utils/parseViyaLogDebugResponse.ts index 96bfc7f..0dfe20e 100644 --- a/src/utils/parseViyaLogDebugResponse.ts +++ b/src/utils/parseViyaLogDebugResponse.ts @@ -1,18 +1,27 @@ import { getValidJson } from './getValidJson' +import { parseWeboutResponse } from './parseWeboutResponse' /** * When querying a Viya job using the Web approach with _DEBUG=log (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: string) => { const blobMatch = response.match( - /new Blob\(\[`([\s\S]*?)`\],\s*\{type:\s*'application\/json'\}\)/ + /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.') } - return getValidJson(blobMatch[1]) + 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 index 08c5c56..2683d0a 100644 --- a/src/utils/spec/parseViyaLogDebugResponse.spec.ts +++ b/src/utils/spec/parseViyaLogDebugResponse.spec.ts @@ -31,6 +31,20 @@ var blob = new Blob([\`{"SYSDATE" : "13MAY26" 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` From cd350e4e6db6a7a4424c49e02dcaee8a5ff18648 Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Wed, 13 May 2026 15:17:53 +0200 Subject: [PATCH 09/12] fix(debug): use _debug=128 instead of log for viya web with tasks --- src/job-execution/JobExecutor.ts | 2 +- src/utils/parseViyaLogDebugResponse.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/job-execution/JobExecutor.ts b/src/job-execution/JobExecutor.ts index 4a61250..f9c9944 100644 --- a/src/job-execution/JobExecutor.ts +++ b/src/job-execution/JobExecutor.ts @@ -60,7 +60,7 @@ export abstract class BaseJobExecutor implements JobExecutor { config.useComputeApi === null && config.serverType === ServerType.SasViya && config.runAsTask === true - ? 'log' + ? 128 : 131 } diff --git a/src/utils/parseViyaLogDebugResponse.ts b/src/utils/parseViyaLogDebugResponse.ts index 0dfe20e..9060c9c 100644 --- a/src/utils/parseViyaLogDebugResponse.ts +++ b/src/utils/parseViyaLogDebugResponse.ts @@ -2,7 +2,7 @@ import { getValidJson } from './getValidJson' import { parseWeboutResponse } from './parseWeboutResponse' /** - * When querying a Viya job using the Web approach with _DEBUG=log (used when + * 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 @@ -10,7 +10,12 @@ import { parseWeboutResponse } from './parseWeboutResponse' * 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: string) => { +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)'\}\)/ ) From ac0dfae9a893aa8f8777c3ab2715f441701a76ba Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Thu, 14 May 2026 10:43:16 +0200 Subject: [PATCH 10/12] fix: skip formats table in generateTableUploadForm to avoid empty sasjsdata --- src/file/generateTableUploadForm.ts | 7 ++ src/file/spec/generateTableUploadForm.spec.ts | 71 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 src/file/spec/generateTableUploadForm.spec.ts 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 + ) + }) +}) From ea5d60352d32baf50b4fa6da3b883717ba91c1a8 Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Thu, 14 May 2026 10:45:40 +0200 Subject: [PATCH 11/12] fix(debug): send _debug=128 via URL for runAsTask debug, drop _omittextlog --- src/job-execution/JobExecutor.ts | 8 +------ src/job-execution/WebJobExecutor.ts | 33 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/job-execution/JobExecutor.ts b/src/job-execution/JobExecutor.ts index f9c9944..955611c 100644 --- a/src/job-execution/JobExecutor.ts +++ b/src/job-execution/JobExecutor.ts @@ -55,13 +55,7 @@ export abstract class BaseJobExecutor implements JobExecutor { if (config.debug) { requestParams['_omittextlog'] = 'false' requestParams['_omitSessionResults'] = 'false' - - requestParams['_debug'] = - config.useComputeApi === null && - config.serverType === ServerType.SasViya && - config.runAsTask === true - ? 128 - : 131 + requestParams['_debug'] = 131 } return requestParams diff --git a/src/job-execution/WebJobExecutor.ts b/src/job-execution/WebJobExecutor.ts index d4e8f25..545e8a4 100644 --- a/src/job-execution/WebJobExecutor.ts +++ b/src/job-execution/WebJobExecutor.ts @@ -123,6 +123,22 @@ export class WebJobExecutor extends BaseJobExecutor { // _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 ( @@ -255,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 = '' From 2a71e3417939ad4910645755120bb0943a5d107e Mon Sep 17 00:00:00 2001 From: 4gl <@> Date: Thu, 14 May 2026 10:32:47 +0100 Subject: [PATCH 12/12] chore: updating README and adding 2 attributes to .npmrc --- .npmrc | 4 +++- README.md | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) 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.