From 5c2d3ccdf3282a3ebc90afaa312fdf61fe32d25b Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Fri, 8 May 2026 15:29:42 +0200 Subject: [PATCH 01/14] fix(webjob): formdata override for empty payloads Non-empty payloads still throws with useComputeApi=null and _executionTasks=true flags --- sasjs-tests/src/main.ts | 4 +- sasjs-tests/src/testSuites/WebJobExecutor.ts | 66 ++++++++++++++ src/job-execution/WebJobExecutor.ts | 20 +++-- src/job-execution/spec/WebJobExecutor.spec.ts | 86 +++++++++++++++++++ 4 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 sasjs-tests/src/testSuites/WebJobExecutor.ts create mode 100644 src/job-execution/spec/WebJobExecutor.spec.ts diff --git a/sasjs-tests/src/main.ts b/sasjs-tests/src/main.ts index 4dd8f503..0da02027 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 { webJobExecutorTests } from './testSuites/WebJobExecutor' async function init() { const appContainer = document.getElementById('app') @@ -101,7 +102,8 @@ function showTests( sendObjTests(adapter), specialCaseTests(adapter), sasjsRequestTests(adapter), - fileUploadTests(adapter) + fileUploadTests(adapter), + webJobExecutorTests(adapter) ] // Add compute tests for SASVIYA only diff --git a/sasjs-tests/src/testSuites/WebJobExecutor.ts b/sasjs-tests/src/testSuites/WebJobExecutor.ts new file mode 100644 index 00000000..880d4017 --- /dev/null +++ b/sasjs-tests/src/testSuites/WebJobExecutor.ts @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import SASjs from '@sasjs/adapter' +import type { TestSuite } from '../types' + +const stringData: any = { table1: [{ col1: 'first col value' }] } + +export const webJobExecutorTests = (adapter: SASjs): TestSuite => ({ + name: 'WebJobExecutor', + tests: [ + { + title: 'Empty payload, useComputeApi=null, _executionTasks flag', + description: + 'WebJobExecutor (useComputeApi=null) should skip multipart when payload empty; Viya rejects multipart on _executionTasks=true', + test: () => { + return adapter + .request('services/common/sendArr&_executionTasks=true', null, { + useComputeApi: null + }) + .then((res: any) => ({ ok: true, res })) + .catch((e: any) => ({ ok: false, error: e })) + }, + assertion: (res: any) => res?.ok === true + }, + // FIXME: failing test, tmp. disabled + // { + // title: 'Non-empty payload, useComputeApi=null, _executionTasks flag', + // description: + // 'WebJobExecutor (useComputeApi=null) should send multipart when payload present, even with _executionTasks=true', + // test: () => { + // return adapter + // .request('services/common/sendArr&_executionTasks=true', stringData, { + // useComputeApi: null + // }) + // .then((res: any) => ({ ok: true, res })) + // .catch((e: any) => ({ ok: false, error: e })) + // }, + // assertion: (res: any) => res?.ok === true + // }, + { + title: 'Empty payload, useComputeApi=null, no _executionTasks flag', + description: + 'WebJobExecutor (useComputeApi=null) should skip multipart when payload empty (regular job URL)', + test: () => { + return adapter + .request('services/common/sendArr', null, { useComputeApi: null }) + .then((res: any) => ({ ok: true, res })) + .catch((e: any) => ({ ok: false, error: e })) + }, + assertion: (res: any) => res?.ok === true + }, + { + title: 'Non-empty payload, useComputeApi=null, no _executionTasks flag', + description: + 'WebJobExecutor (useComputeApi=null) should send multipart when payload present (regular job URL)', + test: () => { + return adapter + .request('services/common/sendArr', stringData, { + 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/WebJobExecutor.ts b/src/job-execution/WebJobExecutor.ts index a9c980d1..0e3c3f94 100644 --- a/src/job-execution/WebJobExecutor.ts +++ b/src/job-execution/WebJobExecutor.ts @@ -107,13 +107,15 @@ export class WebJobExecutor extends BaseJobExecutor { ...this.getRequestParams(config) } + const hasData = !!data && Object.keys(data).length > 0 + /** * Use the available form data object (FormData in Browser, NodeFormData in * Node) */ let formData = getFormData() - if (data) { + if (hasData) { const stringifiedData = JSON.stringify(data) if ( config.serverType === ServerType.Sas9 || @@ -145,18 +147,22 @@ export class WebJobExecutor extends BaseJobExecutor { } } + // If nothing was appended, skip form-data; some servers reject multipart + // with no parts. + const hasFormContent = hasData || Object.keys(requestParams).length > 0 + + const body = hasFormContent ? formData : undefined /* The NodeFormData object does not set the request header - so, set it */ - const contentType = - formData instanceof NodeFormData && typeof FormData === 'undefined' - ? `multipart/form-data; boundary=${ - formData.getHeaders()['content-type'] - }` + const contentType = !hasFormContent + ? 'text/plain' + : formData instanceof NodeFormData && typeof FormData === 'undefined' + ? formData.getHeaders()['content-type'] : 'multipart/form-data' const requestPromise = new Promise((resolve, reject) => { this.requestClient!.post( apiUrl, - formData, + body, authConfig?.access_token, contentType ) diff --git a/src/job-execution/spec/WebJobExecutor.spec.ts b/src/job-execution/spec/WebJobExecutor.spec.ts new file mode 100644 index 00000000..b9354dfe --- /dev/null +++ b/src/job-execution/spec/WebJobExecutor.spec.ts @@ -0,0 +1,86 @@ +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.execute() Content-Type selection', () => { + const serverUrl = 'https://sample.server.com' + const jobsPath = '/SASJobExecution' + + const makeExecutor = () => { + const requestClient = new RequestClient(serverUrl) + const sasViyaApiClient = {} as SASViyaApiClient + const executor = new WebJobExecutor( + serverUrl, + ServerType.Sas9, + 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.Sas9, + appLoc: '/Public/app', + debug: false + } + + it('sends no body and text/plain when payload is empty and debug=false', async () => { + const { executor, postSpy } = makeExecutor() + + await executor.execute('services/common/sendArr', null, baseConfig) + + expect(postSpy).toHaveBeenCalledTimes(1) + const [, body, , contentType] = postSpy.mock.calls[0] + expect(body).toBeUndefined() + expect(contentType).toBe('text/plain') + }) + + it('sends no body and text/plain when data is an empty object', async () => { + const { executor, postSpy } = makeExecutor() + + await executor.execute('services/common/sendArr', {}, baseConfig) + + const [, body, , contentType] = postSpy.mock.calls[0] + expect(body).toBeUndefined() + expect(contentType).toBe('text/plain') + }) + + it('sends multipart form-data when data has content', 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/) + // never double-prefixed: at most one "boundary=" in the Content-Type + const boundaryCount = ((contentType as string).match(/boundary=/g) ?? []) + .length + expect(boundaryCount).toBeLessThanOrEqual(1) + }) + + it('sends multipart with debug params when payload empty but debug=true', async () => { + const { executor, postSpy } = makeExecutor() + + await executor.execute('services/common/sendArr', null, { + ...baseConfig, + debug: true + }) + + const [, body, , contentType] = postSpy.mock.calls[0] + expect(body).toBeInstanceOf(NodeFormData) + expect(contentType).toMatch(/^multipart\/form-data/) + }) +}) From 8e6a89231bd7a23e6cec898c2da352147250f6b8 Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Fri, 8 May 2026 17:02:01 +0200 Subject: [PATCH 02/14] chore: update comment --- src/job-execution/WebJobExecutor.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/job-execution/WebJobExecutor.ts b/src/job-execution/WebJobExecutor.ts index 0e3c3f94..d060ccb8 100644 --- a/src/job-execution/WebJobExecutor.ts +++ b/src/job-execution/WebJobExecutor.ts @@ -147,8 +147,9 @@ export class WebJobExecutor extends BaseJobExecutor { } } - // If nothing was appended, skip form-data; some servers reject multipart - // with no parts. + // No parts → skip form-data and send text/plain. Sidesteps empty-multipart + // rejection on some Viya endpoints (e.g. _executionTasks=true with useComputeApi=null). + // Non-empty multipart on those same endpoints is a separate server-side issue. const hasFormContent = hasData || Object.keys(requestParams).length > 0 const body = hasFormContent ? formData : undefined From 3613f4ad23735c01e560bdef66a136a6e86b641b Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Mon, 11 May 2026 09:53:05 +0200 Subject: [PATCH 03/14] test(cypress): show individual errors --- cypress/integration/sasjs.tests.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/cypress/integration/sasjs.tests.ts b/cypress/integration/sasjs.tests.ts index 07d82a31..bf58bac9 100644 --- a/cypress/integration/sasjs.tests.ts +++ b/cypress/integration/sasjs.tests.ts @@ -12,6 +12,26 @@ context('sasjs-tests', function () { cy.visit(sasjsTestsUrl) }) + function assertNoFailedTests() { + cy.get('test-card').then(($cards) => { + const failures: string[] = [] + $cards.each((_, card) => { + const shadow = (card as HTMLElement).shadowRoot + if (!shadow) return + if (!shadow.querySelector('.status-icon.failed')) return + const title = + shadow.querySelector('.header h3')?.textContent?.trim() ?? '(unknown)' + const error = + shadow.querySelector('.error pre')?.textContent?.trim() ?? '' + failures.push(error ? `- ${title}\n ${error}` : `- ${title}`) + }) + expect( + failures, + `${failures.length} test(s) failed:\n${failures.join('\n')}` + ).to.be.empty + }) + } + function loginIfNeeded() { cy.get('login-form, tests-view', { timeout: 30000 }).should('exist') @@ -49,7 +69,7 @@ context('sasjs-tests', function () { }) .should('not.exist') - cy.get('test-card').shadow().find('.status-icon.failed').should('not.exist') + assertNoFailedTests() }) it('Should have all tests successful with debug on', () => { @@ -70,6 +90,6 @@ context('sasjs-tests', function () { }) .should('not.exist') - cy.get('test-card').shadow().find('.status-icon.failed').should('not.exist') + assertNoFailedTests() }) }) From 630be9e5822d1cd9fc8fe2cd2c78d12475219521 Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Mon, 11 May 2026 10:39:28 +0200 Subject: [PATCH 04/14] test(cypress): half the cypress integration test timeout --- cypress.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress.config.js b/cypress.config.js index 62ca1ffe..3fb53564 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -13,6 +13,6 @@ module.exports = defineConfig({ username: '', password: '', screenshotOnRunFailure: false, - testingFinishTimeout: 600000 + testingFinishTimeout: 300000 } }) From 474239c46e27e9455b0f571a956d907f84c498ce Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Mon, 11 May 2026 10:41:24 +0200 Subject: [PATCH 05/14] test(cypress): add parallel tests, timeout and reports --- cypress/integration/sasjs.tests.ts | 64 ++++++++++++++++++++---------- sasjs-tests/src/core/TestRunner.ts | 12 +++--- 2 files changed, 50 insertions(+), 26 deletions(-) diff --git a/cypress/integration/sasjs.tests.ts b/cypress/integration/sasjs.tests.ts index bf58bac9..8cc056a4 100644 --- a/cypress/integration/sasjs.tests.ts +++ b/cypress/integration/sasjs.tests.ts @@ -12,23 +12,55 @@ 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 failures: string[] = [] + const failed: string[] = [] + const stuck: string[] = [] + const pending: string[] = [] $cards.each((_, card) => { const shadow = (card as HTMLElement).shadowRoot if (!shadow) return - if (!shadow.querySelector('.status-icon.failed')) return + const icon = shadow.querySelector('.status-icon') const title = shadow.querySelector('.header h3')?.textContent?.trim() ?? '(unknown)' - const error = - shadow.querySelector('.error pre')?.textContent?.trim() ?? '' - failures.push(error ? `- ${title}\n ${error}` : `- ${title}`) + 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}`) + } }) - expect( - failures, - `${failures.length} test(s) failed:\n${failures.join('\n')}` - ).to.be.empty + 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 }) } @@ -62,12 +94,7 @@ 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) assertNoFailedTests() }) @@ -83,12 +110,7 @@ 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) assertNoFailedTests() }) diff --git a/sasjs-tests/src/core/TestRunner.ts b/sasjs-tests/src/core/TestRunner.ts index 1fcc5833..7b16d411 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.all( + this.testSuites.map((suite, i) => this.runTestSuite(suite, i, onUpdate)) + ) this.isRunning = false return this.completedTestSuites From 8f84792cd634f84df4973c7c424b1e32de98e178 Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Mon, 11 May 2026 11:12:08 +0200 Subject: [PATCH 06/14] test(cypress): use allSettled instead of all --- sasjs-tests/src/core/TestRunner.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sasjs-tests/src/core/TestRunner.ts b/sasjs-tests/src/core/TestRunner.ts index 7b16d411..a793e825 100644 --- a/sasjs-tests/src/core/TestRunner.ts +++ b/sasjs-tests/src/core/TestRunner.ts @@ -35,7 +35,7 @@ export class TestRunner { completedTests: [] })) - await Promise.all( + await Promise.allSettled( this.testSuites.map((suite, i) => this.runTestSuite(suite, i, onUpdate)) ) From e1a76e46435e03e3d5fc67b7475e282a464f8e4c Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Mon, 11 May 2026 13:51:33 +0200 Subject: [PATCH 07/14] refactor: undo NodeFormData change --- src/job-execution/WebJobExecutor.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/job-execution/WebJobExecutor.ts b/src/job-execution/WebJobExecutor.ts index d060ccb8..c2b40db7 100644 --- a/src/job-execution/WebJobExecutor.ts +++ b/src/job-execution/WebJobExecutor.ts @@ -157,7 +157,9 @@ export class WebJobExecutor extends BaseJobExecutor { const contentType = !hasFormContent ? 'text/plain' : formData instanceof NodeFormData && typeof FormData === 'undefined' - ? formData.getHeaders()['content-type'] + ? `multipart/form-data; boundary=${ + formData.getHeaders()['content-type'] + }` : 'multipart/form-data' const requestPromise = new Promise((resolve, reject) => { From 262489e07680baaa9ab0704a2fe305052db7eda3 Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Mon, 11 May 2026 14:08:50 +0200 Subject: [PATCH 08/14] fix(webjob): inject _program for Viya _executionTasks=true --- sasjs-tests/src/main.ts | 6 +- sasjs-tests/src/testSuites/WebJobExecutor.ts | 28 +++++----- src/job-execution/WebJobExecutor.ts | 27 ++++----- src/job-execution/spec/WebJobExecutor.spec.ts | 55 ++++++++++++++----- 4 files changed, 73 insertions(+), 43 deletions(-) diff --git a/sasjs-tests/src/main.ts b/sasjs-tests/src/main.ts index 0da02027..e70b3e7e 100644 --- a/sasjs-tests/src/main.ts +++ b/sasjs-tests/src/main.ts @@ -102,12 +102,12 @@ function showTests( sendObjTests(adapter), specialCaseTests(adapter), sasjsRequestTests(adapter), - fileUploadTests(adapter), - webJobExecutorTests(adapter) + fileUploadTests(adapter) ] - // Add compute tests for SASVIYA only + // Add tests for SASVIYA only if (adapter.getSasjsConfig().serverType === 'SASVIYA') { + testSuites.push(webJobExecutorTests(adapter)) testSuites.push(computeTests(adapter, appLoc)) } diff --git a/sasjs-tests/src/testSuites/WebJobExecutor.ts b/sasjs-tests/src/testSuites/WebJobExecutor.ts index 880d4017..7d91cee1 100644 --- a/sasjs-tests/src/testSuites/WebJobExecutor.ts +++ b/sasjs-tests/src/testSuites/WebJobExecutor.ts @@ -22,20 +22,20 @@ export const webJobExecutorTests = (adapter: SASjs): TestSuite => ({ assertion: (res: any) => res?.ok === true }, // FIXME: failing test, tmp. disabled - // { - // title: 'Non-empty payload, useComputeApi=null, _executionTasks flag', - // description: - // 'WebJobExecutor (useComputeApi=null) should send multipart when payload present, even with _executionTasks=true', - // test: () => { - // return adapter - // .request('services/common/sendArr&_executionTasks=true', stringData, { - // useComputeApi: null - // }) - // .then((res: any) => ({ ok: true, res })) - // .catch((e: any) => ({ ok: false, error: e })) - // }, - // assertion: (res: any) => res?.ok === true - // }, + { + title: 'Non-empty payload, useComputeApi=null, _executionTasks flag', + description: + 'WebJobExecutor (useComputeApi=null) should send multipart when payload present, even with _executionTasks=true', + test: () => { + return adapter + .request('services/common/sendArr&_executionTasks=true', stringData, { + useComputeApi: null + }) + .then((res: any) => ({ ok: true, res })) + .catch((e: any) => ({ ok: false, error: e })) + }, + assertion: (res: any) => res?.ok === true + }, { title: 'Empty payload, useComputeApi=null, no _executionTasks flag', description: diff --git a/src/job-execution/WebJobExecutor.ts b/src/job-execution/WebJobExecutor.ts index c2b40db7..e0a67ea0 100644 --- a/src/job-execution/WebJobExecutor.ts +++ b/src/job-execution/WebJobExecutor.ts @@ -107,15 +107,23 @@ export class WebJobExecutor extends BaseJobExecutor { ...this.getRequestParams(config) } - const hasData = !!data && Object.keys(data).length > 0 - /** * Use the available form data object (FormData in Browser, NodeFormData in * Node) */ let formData = getFormData() - if (hasData) { + // Viya rejects empty multipart on _executionTasks=true (useComputeApi=null + // is already implicit by routing here). Ensure at least one part is present + // by injecting _program into the form body for that endpoint only. + if ( + config.serverType === ServerType.SasViya && + sasJob.includes('_executionTasks=true') + ) { + formData.append('_program', program) + } + + if (data) { const stringifiedData = JSON.stringify(data) if ( config.serverType === ServerType.Sas9 || @@ -147,16 +155,9 @@ export class WebJobExecutor extends BaseJobExecutor { } } - // No parts → skip form-data and send text/plain. Sidesteps empty-multipart - // rejection on some Viya endpoints (e.g. _executionTasks=true with useComputeApi=null). - // Non-empty multipart on those same endpoints is a separate server-side issue. - const hasFormContent = hasData || Object.keys(requestParams).length > 0 - - const body = hasFormContent ? formData : undefined /* The NodeFormData object does not set the request header - so, set it */ - const contentType = !hasFormContent - ? 'text/plain' - : formData instanceof NodeFormData && typeof FormData === 'undefined' + const contentType = + formData instanceof NodeFormData && typeof FormData === 'undefined' ? `multipart/form-data; boundary=${ formData.getHeaders()['content-type'] }` @@ -165,7 +166,7 @@ export class WebJobExecutor extends BaseJobExecutor { const requestPromise = new Promise((resolve, reject) => { this.requestClient!.post( apiUrl, - body, + formData, authConfig?.access_token, contentType ) diff --git a/src/job-execution/spec/WebJobExecutor.spec.ts b/src/job-execution/spec/WebJobExecutor.spec.ts index b9354dfe..d2068777 100644 --- a/src/job-execution/spec/WebJobExecutor.spec.ts +++ b/src/job-execution/spec/WebJobExecutor.spec.ts @@ -8,12 +8,14 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { const serverUrl = 'https://sample.server.com' const jobsPath = '/SASJobExecution' - const makeExecutor = () => { + const makeExecutor = (serverType: ServerType = ServerType.Sas9) => { const requestClient = new RequestClient(serverUrl) - const sasViyaApiClient = {} as SASViyaApiClient + const sasViyaApiClient = { + getJobsInFolder: async () => [] + } as unknown as SASViyaApiClient const executor = new WebJobExecutor( serverUrl, - ServerType.Sas9, + serverType, jobsPath, requestClient, sasViyaApiClient @@ -32,25 +34,25 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { debug: false } - it('sends no body and text/plain when payload is empty and debug=false', async () => { + it('sends multipart form-data when payload is empty and debug=false', async () => { const { executor, postSpy } = makeExecutor() await executor.execute('services/common/sendArr', null, baseConfig) expect(postSpy).toHaveBeenCalledTimes(1) const [, body, , contentType] = postSpy.mock.calls[0] - expect(body).toBeUndefined() - expect(contentType).toBe('text/plain') + expect(body).toBeInstanceOf(NodeFormData) + expect(contentType).toMatch(/^multipart\/form-data/) }) - it('sends no body and text/plain when data is an empty object', async () => { + it('sends multipart form-data when data is an empty object', async () => { const { executor, postSpy } = makeExecutor() await executor.execute('services/common/sendArr', {}, baseConfig) const [, body, , contentType] = postSpy.mock.calls[0] - expect(body).toBeUndefined() - expect(contentType).toBe('text/plain') + expect(body).toBeInstanceOf(NodeFormData) + expect(contentType).toMatch(/^multipart\/form-data/) }) it('sends multipart form-data when data has content', async () => { @@ -65,10 +67,6 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { const [, body, , contentType] = postSpy.mock.calls[0] expect(body).toBeInstanceOf(NodeFormData) expect(contentType).toMatch(/^multipart\/form-data/) - // never double-prefixed: at most one "boundary=" in the Content-Type - const boundaryCount = ((contentType as string).match(/boundary=/g) ?? []) - .length - expect(boundaryCount).toBeLessThanOrEqual(1) }) it('sends multipart with debug params when payload empty but debug=true', async () => { @@ -83,4 +81,35 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { expect(body).toBeInstanceOf(NodeFormData) expect(contentType).toMatch(/^multipart\/form-data/) }) + + it('appends _program to form body for SasViya when sasJob contains _executionTasks=true', async () => { + const { executor, postSpy } = makeExecutor(ServerType.SasViya) + + await executor.execute( + 'services/common/sendArr&_executionTasks=true', + null, + { ...baseConfig, serverType: ServerType.SasViya } + ) + + const [, body] = postSpy.mock.calls[0] + expect(body).toBeInstanceOf(NodeFormData) + expect((body as NodeFormData).getBuffer().toString()).toContain( + 'name="_program"' + ) + }) + + it('does not append _program for SasViya when sasJob has no _executionTasks=true', async () => { + const { executor, postSpy } = makeExecutor(ServerType.SasViya) + + await executor.execute('services/common/sendArr', null, { + ...baseConfig, + serverType: ServerType.SasViya + }) + + const [, body] = postSpy.mock.calls[0] + expect(body).toBeInstanceOf(NodeFormData) + expect((body as NodeFormData).getBuffer().toString()).not.toContain( + 'name="_program"' + ) + }) }) From 465db29c9c1294ea2200d467b7e6b86f73f81c8d Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Mon, 11 May 2026 14:15:04 +0200 Subject: [PATCH 09/14] test(runner): pre-render pending test cards --- sasjs-tests/src/components/TestSuite.ts | 6 ++++-- sasjs-tests/src/core/TestRunner.ts | 23 +++++++++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/sasjs-tests/src/components/TestSuite.ts b/sasjs-tests/src/components/TestSuite.ts index 39843a3d..d9430c0e 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 a793e825..e634198e 100644 --- a/sasjs-tests/src/core/TestRunner.ts +++ b/sasjs-tests/src/core/TestRunner.ts @@ -51,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 @@ -64,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) { From 7ac23be72a9f347fa15e912640f9032cf4986969 Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Mon, 11 May 2026 17:24:09 +0200 Subject: [PATCH 10/14] fix(webjob): use urlencoded for _executionTasks=true without file upload --- sasjs-tests/src/testSuites/WebJobExecutor.ts | 20 +++++- src/job-execution/WebJobExecutor.ts | 50 +++++++++----- src/job-execution/spec/WebJobExecutor.spec.ts | 69 +++++++++++++++++-- 3 files changed, 115 insertions(+), 24 deletions(-) diff --git a/sasjs-tests/src/testSuites/WebJobExecutor.ts b/sasjs-tests/src/testSuites/WebJobExecutor.ts index 7d91cee1..032010d8 100644 --- a/sasjs-tests/src/testSuites/WebJobExecutor.ts +++ b/sasjs-tests/src/testSuites/WebJobExecutor.ts @@ -3,6 +3,7 @@ import SASjs from '@sasjs/adapter' import type { TestSuite } from '../types' const stringData: any = { table1: [{ col1: 'first col value' }] } +const fileData: any = { table1: [{ col1: 'value with ; semicolon' }] } export const webJobExecutorTests = (adapter: SASjs): TestSuite => ({ name: 'WebJobExecutor', @@ -21,11 +22,10 @@ export const webJobExecutorTests = (adapter: SASjs): TestSuite => ({ }, assertion: (res: any) => res?.ok === true }, - // FIXME: failing test, tmp. disabled { - title: 'Non-empty payload, useComputeApi=null, _executionTasks flag', + title: 'Table payload, useComputeApi=null, _executionTasks flag', description: - 'WebJobExecutor (useComputeApi=null) should send multipart when payload present, even with _executionTasks=true', + 'WebJobExecutor (useComputeApi=null) with _executionTasks=true and table payload should send urlencoded body', test: () => { return adapter .request('services/common/sendArr&_executionTasks=true', stringData, { @@ -36,6 +36,20 @@ export const webJobExecutorTests = (adapter: SASjs): TestSuite => ({ }, assertion: (res: any) => res?.ok === true }, + { + title: 'File payload, useComputeApi=null, _executionTasks flag', + description: + 'WebJobExecutor (useComputeApi=null) should send multipart (file upload) when payload contains a file, with _executionTasks=true', + test: () => { + return 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 + }, { title: 'Empty payload, useComputeApi=null, no _executionTasks flag', description: diff --git a/src/job-execution/WebJobExecutor.ts b/src/job-execution/WebJobExecutor.ts index e0a67ea0..53201e51 100644 --- a/src/job-execution/WebJobExecutor.ts +++ b/src/job-execution/WebJobExecutor.ts @@ -113,23 +113,15 @@ export class WebJobExecutor extends BaseJobExecutor { */ let formData = getFormData() - // Viya rejects empty multipart on _executionTasks=true (useComputeApi=null - // is already implicit by routing here). Ensure at least one part is present - // by injecting _program into the form body for that endpoint only. - if ( - config.serverType === ServerType.SasViya && - sasJob.includes('_executionTasks=true') - ) { - formData.append('_program', program) - } + const stringifiedData = data ? JSON.stringify(data) : '' + const fileUploadApproach = + !!data && + (config.serverType === ServerType.Sas9 || + stringifiedData.length > 500000 || + stringifiedData.includes(';')) if (data) { - const stringifiedData = JSON.stringify(data) - if ( - config.serverType === ServerType.Sas9 || - stringifiedData.length > 500000 || - stringifiedData.includes(';') - ) { + if (fileUploadApproach) { // file upload approach try { formData = generateFileUploadForm(formData, data) @@ -156,17 +148,41 @@ export class WebJobExecutor extends BaseJobExecutor { } /* The NodeFormData object does not set the request header - so, set it */ - const contentType = + let contentType = formData instanceof NodeFormData && typeof FormData === 'undefined' ? `multipart/form-data; boundary=${ formData.getHeaders()['content-type'] }` : 'multipart/form-data' + let body: any = formData + + // Viya rejects empty multipart on _executionTasks=true if no files are + // uploaded. With no file upload, repack URL params + requestParams as + // x-www-form-urlencoded and strip the URL query; otherwise keep multipart. + const parsedUrl = new URL(apiUrl) + const isExecutionTasksEndpoint = + (parsedUrl.searchParams.has('_program') || + parsedUrl.searchParams.has('__program')) && + parsedUrl.searchParams.get('_executionTasks') === 'true' + + if (isExecutionTasksEndpoint && !fileUploadApproach) { + const urlParams = new URLSearchParams() + parsedUrl.searchParams.forEach((value, key) => { + urlParams.append(key, value) + }) + for (const [k, v] of Object.entries(requestParams)) { + urlParams.append(k, String(v)) + } + parsedUrl.search = '' + apiUrl = parsedUrl.toString() + body = urlParams + contentType = 'application/x-www-form-urlencoded' + } const requestPromise = new Promise((resolve, reject) => { this.requestClient!.post( apiUrl, - formData, + body, authConfig?.access_token, contentType ) diff --git a/src/job-execution/spec/WebJobExecutor.spec.ts b/src/job-execution/spec/WebJobExecutor.spec.ts index d2068777..cbe44fda 100644 --- a/src/job-execution/spec/WebJobExecutor.spec.ts +++ b/src/job-execution/spec/WebJobExecutor.spec.ts @@ -82,7 +82,7 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { expect(contentType).toMatch(/^multipart\/form-data/) }) - it('appends _program to form body for SasViya when sasJob contains _executionTasks=true', async () => { + it('sends urlencoded body when _executionTasks=true and no payload', async () => { const { executor, postSpy } = makeExecutor(ServerType.SasViya) await executor.execute( @@ -91,11 +91,72 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { { ...baseConfig, serverType: ServerType.SasViya } ) - const [, body] = postSpy.mock.calls[0] + const [apiUrl, body, , contentType] = postSpy.mock.calls[0] + expect(apiUrl).not.toContain('_program=') + expect(apiUrl).not.toContain('_executionTasks=true') + expect(body).toBeInstanceOf(URLSearchParams) + expect(contentType).toBe('application/x-www-form-urlencoded') + const params = body as URLSearchParams + expect(params.get('_program')).toBe('/Public/app/services/common/sendArr') + expect(params.get('_executionTasks')).toBe('true') + }) + + it('sends urlencoded body with table data when _executionTasks=true', async () => { + const { executor, postSpy } = makeExecutor(ServerType.SasViya) + + await executor.execute( + 'services/common/sendArr&_executionTasks=true', + { table1: [{ col1: 'v' }] }, + { ...baseConfig, serverType: ServerType.SasViya } + ) + + const [, body, , contentType] = postSpy.mock.calls[0] + expect(body).toBeInstanceOf(URLSearchParams) + expect(contentType).toBe('application/x-www-form-urlencoded') + const params = body as URLSearchParams + expect(params.get('_program')).toBe('/Public/app/services/common/sendArr') + expect(params.get('_executionTasks')).toBe('true') + expect(params.get('sasjs_tables')).toBe('table1') + expect(params.get('sasjs1data')).toBeTruthy() + }) + + it('uses multipart for file upload on Viya without _executionTasks', async () => { + const { executor, postSpy } = makeExecutor(ServerType.SasViya) + + await executor.execute( + 'services/common/sendArr', + { table1: [{ col1: 'has; semicolon' }] }, + { ...baseConfig, serverType: ServerType.SasViya } + ) + + const [apiUrl, body, , contentType] = postSpy.mock.calls[0] + expect(apiUrl).toContain('_program=') + expect(apiUrl).not.toContain('_executionTasks=') expect(body).toBeInstanceOf(NodeFormData) - expect((body as NodeFormData).getBuffer().toString()).toContain( - 'name="_program"' + expect(body).not.toBeInstanceOf(URLSearchParams) + expect(contentType).toMatch(/^multipart\/form-data/) + expect(contentType).not.toBe('application/x-www-form-urlencoded') + }) + + it('sends file as multipart when _executionTasks=true with file payload', async () => { + const { executor, postSpy } = makeExecutor(ServerType.SasViya) + + await executor.execute( + 'services/common/sendArr&_executionTasks=true', + { table1: [{ col1: 'has; semicolon' }] }, + { ...baseConfig, serverType: ServerType.SasViya } ) + + 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 _program for SasViya when sasJob has no _executionTasks=true', async () => { From 1300b7e80e5c729aa040bdcaa3bb0c29cfb938dc Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Mon, 11 May 2026 18:05:25 +0200 Subject: [PATCH 11/14] refactor(webjob): pick urlencoded container upfront for _executionTasks --- src/file/generateTableUploadForm.ts | 2 +- src/job-execution/WebJobExecutor.ts | 73 +++++++++---------- src/job-execution/spec/WebJobExecutor.spec.ts | 35 +++++++-- 3 files changed, 63 insertions(+), 47 deletions(-) diff --git a/src/file/generateTableUploadForm.ts b/src/file/generateTableUploadForm.ts index d7a13efc..4eb743aa 100644 --- a/src/file/generateTableUploadForm.ts +++ b/src/file/generateTableUploadForm.ts @@ -3,7 +3,7 @@ import { convertToCSV, isFormatsTable } from '../utils/convertToCsv' import { splitChunks } from '../utils/splitChunks' export const generateTableUploadForm = ( - formData: FormData | NodeFormData, + formData: FormData | NodeFormData | URLSearchParams, data: any ) => { const sasjsTables = [] diff --git a/src/job-execution/WebJobExecutor.ts b/src/job-execution/WebJobExecutor.ts index 53201e51..4dd391fe 100644 --- a/src/job-execution/WebJobExecutor.ts +++ b/src/job-execution/WebJobExecutor.ts @@ -107,12 +107,6 @@ export class WebJobExecutor extends BaseJobExecutor { ...this.getRequestParams(config) } - /** - * Use the available form data object (FormData in Browser, NodeFormData in - * Node) - */ - let formData = getFormData() - const stringifiedData = data ? JSON.stringify(data) : '' const fileUploadApproach = !!data && @@ -120,11 +114,31 @@ export class WebJobExecutor extends BaseJobExecutor { stringifiedData.length > 500000 || stringifiedData.includes(';')) + // Viya rejects empty multipart on _executionTasks=true when no file is + // uploaded. Use x-www-form-urlencoded in that case; URL params stay. + const parsedUrl = new URL(apiUrl) + const isExecutionTasksEndpoint = + (parsedUrl.searchParams.has('_program') || + parsedUrl.searchParams.has('__program')) && + parsedUrl.searchParams.get('_executionTasks') === 'true' + const useUrlencoded = isExecutionTasksEndpoint && !fileUploadApproach + + /** + * URLSearchParams when posting urlencoded; otherwise FormData (browser) or + * NodeFormData (Node). + */ + let formData: FormData | NodeFormData | URLSearchParams = useUrlencoded + ? new URLSearchParams() + : getFormData() + if (data) { if (fileUploadApproach) { // file upload approach try { - formData = generateFileUploadForm(formData, data) + formData = generateFileUploadForm( + formData as FormData | NodeFormData, + data + ) } catch (e: any) { return Promise.reject(new ErrorResponse(e?.message, e)) } @@ -143,46 +157,29 @@ export class WebJobExecutor extends BaseJobExecutor { for (const key in requestParams) { if (requestParams.hasOwnProperty(key)) { - formData.append(key, requestParams[key]) + formData.append(key, String(requestParams[key])) } } - /* The NodeFormData object does not set the request header - so, set it */ - let contentType = - formData instanceof NodeFormData && typeof FormData === 'undefined' - ? `multipart/form-data; boundary=${ - formData.getHeaders()['content-type'] - }` - : 'multipart/form-data' - let body: any = formData - - // Viya rejects empty multipart on _executionTasks=true if no files are - // uploaded. With no file upload, repack URL params + requestParams as - // x-www-form-urlencoded and strip the URL query; otherwise keep multipart. - const parsedUrl = new URL(apiUrl) - const isExecutionTasksEndpoint = - (parsedUrl.searchParams.has('_program') || - parsedUrl.searchParams.has('__program')) && - parsedUrl.searchParams.get('_executionTasks') === 'true' - - if (isExecutionTasksEndpoint && !fileUploadApproach) { - const urlParams = new URLSearchParams() - parsedUrl.searchParams.forEach((value, key) => { - urlParams.append(key, value) - }) - for (const [k, v] of Object.entries(requestParams)) { - urlParams.append(k, String(v)) - } - parsedUrl.search = '' - apiUrl = parsedUrl.toString() - body = urlParams + let contentType: string + if (useUrlencoded) { contentType = 'application/x-www-form-urlencoded' + } else if ( + formData instanceof NodeFormData && + typeof FormData === 'undefined' + ) { + /* The NodeFormData object does not set the request header - so, set it */ + contentType = `multipart/form-data; boundary=${ + formData.getHeaders()['content-type'] + }` + } else { + contentType = 'multipart/form-data' } const requestPromise = new Promise((resolve, reject) => { this.requestClient!.post( apiUrl, - body, + formData, authConfig?.access_token, contentType ) diff --git a/src/job-execution/spec/WebJobExecutor.spec.ts b/src/job-execution/spec/WebJobExecutor.spec.ts index cbe44fda..8ac233b1 100644 --- a/src/job-execution/spec/WebJobExecutor.spec.ts +++ b/src/job-execution/spec/WebJobExecutor.spec.ts @@ -92,13 +92,10 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { ) const [apiUrl, body, , contentType] = postSpy.mock.calls[0] - expect(apiUrl).not.toContain('_program=') - expect(apiUrl).not.toContain('_executionTasks=true') + expect(apiUrl).toContain('_program=/Public/app/services/common/sendArr') + expect(apiUrl).toContain('_executionTasks=true') expect(body).toBeInstanceOf(URLSearchParams) expect(contentType).toBe('application/x-www-form-urlencoded') - const params = body as URLSearchParams - expect(params.get('_program')).toBe('/Public/app/services/common/sendArr') - expect(params.get('_executionTasks')).toBe('true') }) it('sends urlencoded body with table data when _executionTasks=true', async () => { @@ -110,16 +107,38 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { { ...baseConfig, serverType: ServerType.SasViya } ) - const [, body, , contentType] = postSpy.mock.calls[0] + 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(URLSearchParams) expect(contentType).toBe('application/x-www-form-urlencoded') const params = body as URLSearchParams - expect(params.get('_program')).toBe('/Public/app/services/common/sendArr') - expect(params.get('_executionTasks')).toBe('true') expect(params.get('sasjs_tables')).toBe('table1') expect(params.get('sasjs1data')).toBeTruthy() }) + it('sends chunked CSV in urlencoded body when _executionTasks=true', async () => { + const { executor, postSpy } = makeExecutor(ServerType.SasViya) + + // Build a row whose CSV exceeds 16k but stringified JSON stays under 500k + // so we land on the param-based path, and the CSV gets split into chunks. + const longValue = 'x'.repeat(20000) + await executor.execute( + 'services/common/sendArr&_executionTasks=true', + { table1: [{ col1: longValue }] }, + { ...baseConfig, serverType: ServerType.SasViya } + ) + + const [, body, , contentType] = postSpy.mock.calls[0] + expect(body).toBeInstanceOf(URLSearchParams) + expect(contentType).toBe('application/x-www-form-urlencoded') + const params = body as URLSearchParams + expect(params.get('sasjs_tables')).toBe('table1') + const chunks = params.getAll('sasjs1data') + expect(chunks.length).toBeGreaterThan(1) + expect(chunks.join('')).toContain(longValue) + }) + it('uses multipart for file upload on Viya without _executionTasks', async () => { const { executor, postSpy } = makeExecutor(ServerType.SasViya) From 804cbbbb1b511de875f3cb29eb5a7d1dd95979b9 Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Mon, 11 May 2026 20:39:50 +0200 Subject: [PATCH 12/14] test(webjob): default spec to Viya and drop Sas9-only cases --- src/job-execution/spec/WebJobExecutor.spec.ts | 73 +++---------------- 1 file changed, 12 insertions(+), 61 deletions(-) diff --git a/src/job-execution/spec/WebJobExecutor.spec.ts b/src/job-execution/spec/WebJobExecutor.spec.ts index 8ac233b1..f494c966 100644 --- a/src/job-execution/spec/WebJobExecutor.spec.ts +++ b/src/job-execution/spec/WebJobExecutor.spec.ts @@ -8,7 +8,7 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { const serverUrl = 'https://sample.server.com' const jobsPath = '/SASJobExecution' - const makeExecutor = (serverType: ServerType = ServerType.Sas9) => { + const makeExecutor = (serverType: ServerType = ServerType.SasViya) => { const requestClient = new RequestClient(serverUrl) const sasViyaApiClient = { getJobsInFolder: async () => [] @@ -29,32 +29,11 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { const baseConfig = { serverUrl, - serverType: ServerType.Sas9, + serverType: ServerType.SasViya, appLoc: '/Public/app', debug: false } - it('sends multipart form-data when payload is empty and debug=false', async () => { - const { executor, postSpy } = makeExecutor() - - await executor.execute('services/common/sendArr', null, baseConfig) - - expect(postSpy).toHaveBeenCalledTimes(1) - const [, body, , contentType] = postSpy.mock.calls[0] - expect(body).toBeInstanceOf(NodeFormData) - expect(contentType).toMatch(/^multipart\/form-data/) - }) - - it('sends multipart form-data when data is an empty object', async () => { - const { executor, postSpy } = makeExecutor() - - await executor.execute('services/common/sendArr', {}, baseConfig) - - const [, body, , contentType] = postSpy.mock.calls[0] - expect(body).toBeInstanceOf(NodeFormData) - expect(contentType).toMatch(/^multipart\/form-data/) - }) - it('sends multipart form-data when data has content', async () => { const { executor, postSpy } = makeExecutor() @@ -69,26 +48,13 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { expect(contentType).toMatch(/^multipart\/form-data/) }) - it('sends multipart with debug params when payload empty but debug=true', async () => { - const { executor, postSpy } = makeExecutor() - - await executor.execute('services/common/sendArr', null, { - ...baseConfig, - debug: true - }) - - const [, body, , contentType] = postSpy.mock.calls[0] - expect(body).toBeInstanceOf(NodeFormData) - expect(contentType).toMatch(/^multipart\/form-data/) - }) - it('sends urlencoded body when _executionTasks=true and no payload', async () => { - const { executor, postSpy } = makeExecutor(ServerType.SasViya) + const { executor, postSpy } = makeExecutor() await executor.execute( 'services/common/sendArr&_executionTasks=true', null, - { ...baseConfig, serverType: ServerType.SasViya } + baseConfig ) const [apiUrl, body, , contentType] = postSpy.mock.calls[0] @@ -99,12 +65,12 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { }) it('sends urlencoded body with table data when _executionTasks=true', async () => { - const { executor, postSpy } = makeExecutor(ServerType.SasViya) + const { executor, postSpy } = makeExecutor() await executor.execute( 'services/common/sendArr&_executionTasks=true', { table1: [{ col1: 'v' }] }, - { ...baseConfig, serverType: ServerType.SasViya } + baseConfig ) const [apiUrl, body, , contentType] = postSpy.mock.calls[0] @@ -118,7 +84,7 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { }) it('sends chunked CSV in urlencoded body when _executionTasks=true', async () => { - const { executor, postSpy } = makeExecutor(ServerType.SasViya) + const { executor, postSpy } = makeExecutor() // Build a row whose CSV exceeds 16k but stringified JSON stays under 500k // so we land on the param-based path, and the CSV gets split into chunks. @@ -126,7 +92,7 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { await executor.execute( 'services/common/sendArr&_executionTasks=true', { table1: [{ col1: longValue }] }, - { ...baseConfig, serverType: ServerType.SasViya } + baseConfig ) const [, body, , contentType] = postSpy.mock.calls[0] @@ -140,12 +106,12 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { }) it('uses multipart for file upload on Viya without _executionTasks', async () => { - const { executor, postSpy } = makeExecutor(ServerType.SasViya) + const { executor, postSpy } = makeExecutor() await executor.execute( 'services/common/sendArr', { table1: [{ col1: 'has; semicolon' }] }, - { ...baseConfig, serverType: ServerType.SasViya } + baseConfig ) const [apiUrl, body, , contentType] = postSpy.mock.calls[0] @@ -158,12 +124,12 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { }) it('sends file as multipart when _executionTasks=true with file payload', async () => { - const { executor, postSpy } = makeExecutor(ServerType.SasViya) + const { executor, postSpy } = makeExecutor() await executor.execute( 'services/common/sendArr&_executionTasks=true', { table1: [{ col1: 'has; semicolon' }] }, - { ...baseConfig, serverType: ServerType.SasViya } + baseConfig ) const [apiUrl, body, , contentType] = postSpy.mock.calls[0] @@ -177,19 +143,4 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { expect(dump).toContain('filename="table1.csv"') expect(dump).toContain('Content-Type: application/csv') }) - - it('does not append _program for SasViya when sasJob has no _executionTasks=true', async () => { - const { executor, postSpy } = makeExecutor(ServerType.SasViya) - - await executor.execute('services/common/sendArr', null, { - ...baseConfig, - serverType: ServerType.SasViya - }) - - const [, body] = postSpy.mock.calls[0] - expect(body).toBeInstanceOf(NodeFormData) - expect((body as NodeFormData).getBuffer().toString()).not.toContain( - 'name="_program"' - ) - }) }) From 9cf9199ca2e3f0dcad339ef87b0075c816f7be06 Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Mon, 11 May 2026 21:31:14 +0200 Subject: [PATCH 13/14] refactor(webjob): drop urlencoded _executionTasks path, retarget spec tests --- src/file/generateTableUploadForm.ts | 2 +- src/job-execution/WebJobExecutor.ts | 59 ++++++------------- ...xecutor.spec.ts => executionTasks.spec.ts} | 58 ++++-------------- 3 files changed, 29 insertions(+), 90 deletions(-) rename src/job-execution/spec/{WebJobExecutor.spec.ts => executionTasks.spec.ts} (59%) diff --git a/src/file/generateTableUploadForm.ts b/src/file/generateTableUploadForm.ts index 4eb743aa..d7a13efc 100644 --- a/src/file/generateTableUploadForm.ts +++ b/src/file/generateTableUploadForm.ts @@ -3,7 +3,7 @@ import { convertToCSV, isFormatsTable } from '../utils/convertToCsv' import { splitChunks } from '../utils/splitChunks' export const generateTableUploadForm = ( - formData: FormData | NodeFormData | URLSearchParams, + formData: FormData | NodeFormData, data: any ) => { const sasjsTables = [] diff --git a/src/job-execution/WebJobExecutor.ts b/src/job-execution/WebJobExecutor.ts index 4dd391fe..a9c980d1 100644 --- a/src/job-execution/WebJobExecutor.ts +++ b/src/job-execution/WebJobExecutor.ts @@ -107,38 +107,22 @@ export class WebJobExecutor extends BaseJobExecutor { ...this.getRequestParams(config) } - const stringifiedData = data ? JSON.stringify(data) : '' - const fileUploadApproach = - !!data && - (config.serverType === ServerType.Sas9 || - stringifiedData.length > 500000 || - stringifiedData.includes(';')) - - // Viya rejects empty multipart on _executionTasks=true when no file is - // uploaded. Use x-www-form-urlencoded in that case; URL params stay. - const parsedUrl = new URL(apiUrl) - const isExecutionTasksEndpoint = - (parsedUrl.searchParams.has('_program') || - parsedUrl.searchParams.has('__program')) && - parsedUrl.searchParams.get('_executionTasks') === 'true' - const useUrlencoded = isExecutionTasksEndpoint && !fileUploadApproach - /** - * URLSearchParams when posting urlencoded; otherwise FormData (browser) or - * NodeFormData (Node). + * Use the available form data object (FormData in Browser, NodeFormData in + * Node) */ - let formData: FormData | NodeFormData | URLSearchParams = useUrlencoded - ? new URLSearchParams() - : getFormData() + let formData = getFormData() if (data) { - if (fileUploadApproach) { + const stringifiedData = JSON.stringify(data) + if ( + config.serverType === ServerType.Sas9 || + stringifiedData.length > 500000 || + stringifiedData.includes(';') + ) { // file upload approach try { - formData = generateFileUploadForm( - formData as FormData | NodeFormData, - data - ) + formData = generateFileUploadForm(formData, data) } catch (e: any) { return Promise.reject(new ErrorResponse(e?.message, e)) } @@ -157,24 +141,17 @@ export class WebJobExecutor extends BaseJobExecutor { for (const key in requestParams) { if (requestParams.hasOwnProperty(key)) { - formData.append(key, String(requestParams[key])) + formData.append(key, requestParams[key]) } } - let contentType: string - if (useUrlencoded) { - contentType = 'application/x-www-form-urlencoded' - } else if ( - formData instanceof NodeFormData && - typeof FormData === 'undefined' - ) { - /* The NodeFormData object does not set the request header - so, set it */ - contentType = `multipart/form-data; boundary=${ - formData.getHeaders()['content-type'] - }` - } else { - contentType = 'multipart/form-data' - } + /* The NodeFormData object does not set the request header - so, set it */ + const contentType = + formData instanceof NodeFormData && typeof FormData === 'undefined' + ? `multipart/form-data; boundary=${ + formData.getHeaders()['content-type'] + }` + : 'multipart/form-data' const requestPromise = new Promise((resolve, reject) => { this.requestClient!.post( diff --git a/src/job-execution/spec/WebJobExecutor.spec.ts b/src/job-execution/spec/executionTasks.spec.ts similarity index 59% rename from src/job-execution/spec/WebJobExecutor.spec.ts rename to src/job-execution/spec/executionTasks.spec.ts index f494c966..877c0f66 100644 --- a/src/job-execution/spec/WebJobExecutor.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.execute() Content-Type selection', () => { +describe('WebJobExecutor _executionTasks=true behaviour', () => { const serverUrl = 'https://sample.server.com' const jobsPath = '/SASJobExecution' @@ -34,7 +34,7 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { debug: false } - it('sends multipart form-data when data has content', async () => { + it('sends table data in body', async () => { const { executor, postSpy } = makeExecutor() await executor.execute( @@ -48,23 +48,7 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { expect(contentType).toMatch(/^multipart\/form-data/) }) - it('sends urlencoded body when _executionTasks=true and no payload', async () => { - const { executor, postSpy } = makeExecutor() - - await executor.execute( - 'services/common/sendArr&_executionTasks=true', - null, - 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(URLSearchParams) - expect(contentType).toBe('application/x-www-form-urlencoded') - }) - - it('sends urlencoded body with table data when _executionTasks=true', async () => { + it('sends table data when _executionTasks=true', async () => { const { executor, postSpy } = makeExecutor() await executor.execute( @@ -76,36 +60,14 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { 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(URLSearchParams) - expect(contentType).toBe('application/x-www-form-urlencoded') - const params = body as URLSearchParams - expect(params.get('sasjs_tables')).toBe('table1') - expect(params.get('sasjs1data')).toBeTruthy() - }) - - it('sends chunked CSV in urlencoded body when _executionTasks=true', async () => { - const { executor, postSpy } = makeExecutor() - - // Build a row whose CSV exceeds 16k but stringified JSON stays under 500k - // so we land on the param-based path, and the CSV gets split into chunks. - const longValue = 'x'.repeat(20000) - await executor.execute( - 'services/common/sendArr&_executionTasks=true', - { table1: [{ col1: longValue }] }, - baseConfig - ) - - const [, body, , contentType] = postSpy.mock.calls[0] - expect(body).toBeInstanceOf(URLSearchParams) - expect(contentType).toBe('application/x-www-form-urlencoded') - const params = body as URLSearchParams - expect(params.get('sasjs_tables')).toBe('table1') - const chunks = params.getAll('sasjs1data') - expect(chunks.length).toBeGreaterThan(1) - expect(chunks.join('')).toContain(longValue) + 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('uses multipart for file upload on Viya without _executionTasks', async () => { + it('uploads as file when payload has semicolons', async () => { const { executor, postSpy } = makeExecutor() await executor.execute( @@ -123,7 +85,7 @@ describe('WebJobExecutor.execute() Content-Type selection', () => { expect(contentType).not.toBe('application/x-www-form-urlencoded') }) - it('sends file as multipart when _executionTasks=true with file payload', async () => { + it('uploads as file when _executionTasks=true and payload has semicolons', async () => { const { executor, postSpy } = makeExecutor() await executor.execute( From ffb42e87e27b534207fc5e43df2ae7ea4a90b71b Mon Sep 17 00:00:00 2001 From: mulahasanovic Date: Mon, 11 May 2026 21:47:12 +0200 Subject: [PATCH 14/14] test: retarget integration suite to _executionTasks=true --- sasjs-tests/src/main.ts | 4 +- sasjs-tests/src/testSuites/WebJobExecutor.ts | 80 -------------------- sasjs-tests/src/testSuites/executionTasks.ts | 61 +++++++++++++++ 3 files changed, 63 insertions(+), 82 deletions(-) delete mode 100644 sasjs-tests/src/testSuites/WebJobExecutor.ts create mode 100644 sasjs-tests/src/testSuites/executionTasks.ts diff --git a/sasjs-tests/src/main.ts b/sasjs-tests/src/main.ts index e70b3e7e..95781c21 100644 --- a/sasjs-tests/src/main.ts +++ b/sasjs-tests/src/main.ts @@ -23,7 +23,7 @@ import { fileUploadTests } from './testSuites/FileUpload' import { computeTests } from './testSuites/Compute' import { sasjsRequestTests } from './testSuites/SasjsRequests' import { specialCaseTests } from './testSuites/SpecialCases' -import { webJobExecutorTests } from './testSuites/WebJobExecutor' +import { executionTasksTests } from './testSuites/executionTasks' async function init() { const appContainer = document.getElementById('app') @@ -107,7 +107,7 @@ function showTests( // Add tests for SASVIYA only if (adapter.getSasjsConfig().serverType === 'SASVIYA') { - testSuites.push(webJobExecutorTests(adapter)) + testSuites.push(executionTasksTests(adapter)) testSuites.push(computeTests(adapter, appLoc)) } diff --git a/sasjs-tests/src/testSuites/WebJobExecutor.ts b/sasjs-tests/src/testSuites/WebJobExecutor.ts deleted file mode 100644 index 032010d8..00000000 --- a/sasjs-tests/src/testSuites/WebJobExecutor.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import SASjs from '@sasjs/adapter' -import type { TestSuite } from '../types' - -const stringData: any = { table1: [{ col1: 'first col value' }] } -const fileData: any = { table1: [{ col1: 'value with ; semicolon' }] } - -export const webJobExecutorTests = (adapter: SASjs): TestSuite => ({ - name: 'WebJobExecutor', - tests: [ - { - title: 'Empty payload, useComputeApi=null, _executionTasks flag', - description: - 'WebJobExecutor (useComputeApi=null) should skip multipart when payload empty; Viya rejects multipart on _executionTasks=true', - test: () => { - return adapter - .request('services/common/sendArr&_executionTasks=true', null, { - useComputeApi: null - }) - .then((res: any) => ({ ok: true, res })) - .catch((e: any) => ({ ok: false, error: e })) - }, - assertion: (res: any) => res?.ok === true - }, - { - title: 'Table payload, useComputeApi=null, _executionTasks flag', - description: - 'WebJobExecutor (useComputeApi=null) with _executionTasks=true and table payload should send urlencoded body', - test: () => { - return adapter - .request('services/common/sendArr&_executionTasks=true', stringData, { - useComputeApi: null - }) - .then((res: any) => ({ ok: true, res })) - .catch((e: any) => ({ ok: false, error: e })) - }, - assertion: (res: any) => res?.ok === true - }, - { - title: 'File payload, useComputeApi=null, _executionTasks flag', - description: - 'WebJobExecutor (useComputeApi=null) should send multipart (file upload) when payload contains a file, with _executionTasks=true', - test: () => { - return 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 - }, - { - title: 'Empty payload, useComputeApi=null, no _executionTasks flag', - description: - 'WebJobExecutor (useComputeApi=null) should skip multipart when payload empty (regular job URL)', - test: () => { - return adapter - .request('services/common/sendArr', null, { useComputeApi: null }) - .then((res: any) => ({ ok: true, res })) - .catch((e: any) => ({ ok: false, error: e })) - }, - assertion: (res: any) => res?.ok === true - }, - { - title: 'Non-empty payload, useComputeApi=null, no _executionTasks flag', - description: - 'WebJobExecutor (useComputeApi=null) should send multipart when payload present (regular job URL)', - test: () => { - return adapter - .request('services/common/sendArr', stringData, { - useComputeApi: null - }) - .then((res: any) => ({ ok: true, res })) - .catch((e: any) => ({ ok: false, error: e })) - }, - assertion: (res: any) => res?.ok === true - } - ] -}) diff --git a/sasjs-tests/src/testSuites/executionTasks.ts b/sasjs-tests/src/testSuites/executionTasks.ts new file mode 100644 index 00000000..78e78661 --- /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 + } + ] +})