diff --git a/.npmrc b/.npmrc index 537f81f..702c607 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,3 @@ -ignore-scripts=true \ No newline at end of file +ignore-scripts=true +save-exact=true +fund=false \ No newline at end of file diff --git a/README.md b/README.md index 773fbb5..d2f5b7c 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,7 @@ Configuration on the client side involves passing an object on startup, which ca - `verbose` - optional, if `true` then a summary of every HTTP response is logged. - `loginMechanism` - either `Default` or `Redirected`. See [SAS Logon](#sas-logon) section. - `useComputeApi` - Only relevant when the serverType is `SASVIYA`. If `true` the [Compute API](#using-the-compute-api) is used. If `false` the [JES API](#using-the-jes-api) is used. If `null` or `undefined` the [Web](#using-jes-web-app) approach is used. +- `runAsTask` - Only relevant for serverType `SASVIYA` and useComputeApi `null`. Will add the `_EXECUTIONTASKS=true` URL param and thus execute jobs as Compute Tasks. - `contextName` - Compute context on which the requests will be called. If missing or not provided, defaults to `Job Execution Compute context`. - `requestHistoryLimit` - Request history limit. Increasing this limit may affect browser performance, especially with debug (logs) enabled. Default is 10. diff --git a/cypress.config.js b/cypress.config.js index 62ca1ff..3fb5356 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -13,6 +13,6 @@ module.exports = defineConfig({ username: '', password: '', screenshotOnRunFailure: false, - testingFinishTimeout: 600000 + testingFinishTimeout: 300000 } }) diff --git a/cypress/integration/sasjs.tests.ts b/cypress/integration/sasjs.tests.ts index 07d82a3..8cc056a 100644 --- a/cypress/integration/sasjs.tests.ts +++ b/cypress/integration/sasjs.tests.ts @@ -12,6 +12,58 @@ context('sasjs-tests', function () { cy.visit(sasjsTestsUrl) }) + function waitForTestsToFinish(timeout: number) { + const deadline = Date.now() + timeout + function check() { + cy.get('tests-view', { log: false }).then(($view) => { + const shadow = ($view[0] as HTMLElement).shadowRoot + const stillRunning = !!shadow?.querySelector('#run-btn:disabled') + if (!stillRunning) return + if (Date.now() >= deadline) { + cy.log('Timed out waiting for tests to finish; reporting status') + return + } + cy.wait(2000, { log: false }) + check() + }) + } + check() + } + + function assertNoFailedTests() { + cy.get('test-card').then(($cards) => { + const failed: string[] = [] + const stuck: string[] = [] + const pending: string[] = [] + $cards.each((_, card) => { + const shadow = (card as HTMLElement).shadowRoot + if (!shadow) return + const icon = shadow.querySelector('.status-icon') + const title = + shadow.querySelector('.header h3')?.textContent?.trim() ?? '(unknown)' + if (icon?.classList.contains('failed')) { + const error = + shadow.querySelector('.error pre')?.textContent?.trim() ?? '' + failed.push(error ? `- ${title}\n ${error}` : `- ${title}`) + } else if (icon?.classList.contains('running')) { + stuck.push(`- ${title}`) + } else if (icon?.classList.contains('pending')) { + pending.push(`- ${title}`) + } + }) + const parts: string[] = [] + if (failed.length) + parts.push(`${failed.length} failed:\n${failed.join('\n')}`) + if (stuck.length) + parts.push(`${stuck.length} stuck (running):\n${stuck.join('\n')}`) + if (pending.length) + parts.push( + `${pending.length} did not start (pending):\n${pending.join('\n')}` + ) + expect(parts, parts.join('\n\n')).to.be.empty + }) + } + function loginIfNeeded() { cy.get('login-form, tests-view', { timeout: 30000 }).should('exist') @@ -42,14 +94,9 @@ context('sasjs-tests', function () { cy.get('tests-view').shadow().find('#run-btn').should('be.visible').click() - cy.get('tests-view') - .shadow() - .find('#run-btn:disabled', { - timeout: testingFinishTimeout - }) - .should('not.exist') + waitForTestsToFinish(testingFinishTimeout) - cy.get('test-card').shadow().find('.status-icon.failed').should('not.exist') + assertNoFailedTests() }) it('Should have all tests successful with debug on', () => { @@ -63,13 +110,8 @@ context('sasjs-tests', function () { cy.get('tests-view').shadow().find('#run-btn').should('be.visible').click() - cy.get('tests-view') - .shadow() - .find('#run-btn:disabled', { - timeout: testingFinishTimeout - }) - .should('not.exist') + waitForTestsToFinish(testingFinishTimeout) - cy.get('test-card').shadow().find('.status-icon.failed').should('not.exist') + assertNoFailedTests() }) }) diff --git a/sasjs-tests/src/components/TestSuite.ts b/sasjs-tests/src/components/TestSuite.ts index 39843a3..d9430c0 100644 --- a/sasjs-tests/src/components/TestSuite.ts +++ b/sasjs-tests/src/components/TestSuite.ts @@ -66,10 +66,11 @@ export class TestSuiteElement extends HTMLElement { const passed = completedTests.filter((t) => t.status === 'passed').length const failed = completedTests.filter((t) => t.status === 'failed').length const running = completedTests.filter((t) => t.status === 'running').length + const pending = completedTests.filter((t) => t.status === 'pending').length const statsEl = this.shadow.querySelector('.stats') if (statsEl) { - statsEl.textContent = `Passed: ${passed} | Failed: ${failed} | Running: ${running}` + statsEl.textContent = `Passed: ${passed} | Failed: ${failed} | Running: ${running} | Pending: ${pending}` } } @@ -80,11 +81,12 @@ export class TestSuiteElement extends HTMLElement { const passed = completedTests.filter((t) => t.status === 'passed').length const failed = completedTests.filter((t) => t.status === 'failed').length const running = completedTests.filter((t) => t.status === 'running').length + const pending = completedTests.filter((t) => t.status === 'pending').length this.shadow.innerHTML = `

${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..1ab689d --- /dev/null +++ b/sasjs-tests/src/testSuites/executionTasks.ts @@ -0,0 +1,126 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import SASjs from '@sasjs/adapter' +import type { TestSuite } from '../types' + +const tableData: any = { table1: [{ col1: 'first col value' }] } +const fileData: any = { table1: [{ col1: 'value with ; semicolon' }] } +const multiTableData: any = { + table1: [{ col1: 'first col value' }], + table2: [{ col2: 'second table value' }] +} +const multiFileData: any = { + table1: [{ col1: 'value with ; semicolon' }], + table2: [{ col2: 'another; value' }] +} + +const taskConfig: any = { useComputeApi: null, runAsTask: true } +const noTaskConfig: any = { useComputeApi: null, runAsTask: false } + +export const executionTasksTests = (adapter: SASjs): TestSuite => ({ + name: 'runAsTask behaviour', + tests: [ + { + title: 'no inputs (runAsTask=false)', + description: 'no payload, runAsTask explicitly disabled', + test: () => + adapter + .request('services/common/sendArr', null, noTaskConfig) + .then((res: any) => ({ ok: true, res })) + .catch((e: any) => ({ ok: false, error: e })), + assertion: (res: any) => res?.ok === true + }, + { + title: 'no inputs (runAsTask=true)', + description: 'no payload, runAsTask=true via config', + test: () => + adapter + .request('services/common/sendArr', null, taskConfig) + .then((res: any) => ({ ok: true, res })) + .catch((e: any) => ({ ok: false, error: e })), + assertion: (res: any) => res?.ok === true + }, + { + title: 'one input table (runAsTask=false)', + description: 'single table payload, runAsTask explicitly disabled', + test: () => + adapter + .request('services/common/sendArr', tableData, noTaskConfig) + .then((res: any) => ({ ok: true, res })) + .catch((e: any) => ({ ok: false, error: e })), + assertion: (res: any) => res?.ok === true + }, + { + title: 'one input table (runAsTask=true)', + description: 'single table payload, runAsTask=true via config', + test: () => + adapter + .request('services/common/sendArr', tableData, taskConfig) + .then((res: any) => ({ ok: true, res })) + .catch((e: any) => ({ ok: false, error: e })), + assertion: (res: any) => res?.ok === true + }, + { + title: 'multiple input tables (runAsTask=false)', + description: 'multi-table payload, runAsTask explicitly disabled', + test: () => + adapter + .request('services/common/sendArr', multiTableData, noTaskConfig) + .then((res: any) => ({ ok: true, res })) + .catch((e: any) => ({ ok: false, error: e })), + assertion: (res: any) => res?.ok === true + }, + { + title: 'multiple input tables (runAsTask=true)', + description: 'multi-table payload, runAsTask=true via config', + test: () => + adapter + .request('services/common/sendArr', multiTableData, taskConfig) + .then((res: any) => ({ ok: true, res })) + .catch((e: any) => ({ ok: false, error: e })), + assertion: (res: any) => res?.ok === true + }, + { + title: 'semicolon payload, single table, blob path (runAsTask=false)', + description: 'semicolon payload routes through blob path, runAsTask off', + test: () => + adapter + .request('services/common/sendArr', fileData, noTaskConfig) + .then((res: any) => ({ ok: true, res })) + .catch((e: any) => ({ ok: false, error: e })), + assertion: (res: any) => res?.ok === true + }, + { + title: 'semicolon payload, single table, blob path (runAsTask=true)', + description: + 'semicolon payload (single table) routes through blob path, runAsTask=true', + test: () => + adapter + .request('services/common/sendArr', fileData, taskConfig) + .then((res: any) => ({ ok: true, res })) + .catch((e: any) => ({ ok: false, error: e })), + assertion: (res: any) => res?.ok === true + }, + { + title: 'semicolon payload, multi-table, blob path (runAsTask=false)', + description: + 'semicolon payload (multi-table) routes through blob path, runAsTask off', + test: () => + adapter + .request('services/common/sendArr', multiFileData, noTaskConfig) + .then((res: any) => ({ ok: true, res })) + .catch((e: any) => ({ ok: false, error: e })), + assertion: (res: any) => res?.ok === true + }, + { + title: 'semicolon payload, multi-table, blob path (runAsTask=true)', + description: + 'semicolon payload (multi-table) routes through blob path, runAsTask=true', + test: () => + adapter + .request('services/common/sendArr', multiFileData, taskConfig) + .then((res: any) => ({ ok: true, res })) + .catch((e: any) => ({ ok: false, error: e })), + assertion: (res: any) => res?.ok === true + } + ] +}) diff --git a/src/file/generateTableUploadForm.ts b/src/file/generateTableUploadForm.ts index d7a13ef..ec33272 100644 --- a/src/file/generateTableUploadForm.ts +++ b/src/file/generateTableUploadForm.ts @@ -11,6 +11,13 @@ export const generateTableUploadForm = ( let tableCounter = 0 for (const tableName in data) { + if ( + isFormatsTable(tableName) && + Object.keys(data).includes(tableName.replace(/^\$/, '')) + ) { + continue + } + tableCounter++ // Formats table should not be sent as part of 'sasjs_tables' diff --git a/src/file/spec/generateTableUploadForm.spec.ts b/src/file/spec/generateTableUploadForm.spec.ts new file mode 100644 index 0000000..ebb994a --- /dev/null +++ b/src/file/spec/generateTableUploadForm.spec.ts @@ -0,0 +1,71 @@ +import { generateTableUploadForm } from '../generateTableUploadForm' +import { convertToCSV } from '../../utils/convertToCsv' +import NodeFormData from 'form-data' + +describe('generateTableUploadForm', () => { + it('should skip formats table and emit single sasjs1data for paired data + $data', () => { + const tableName = 'jsdata' + const data: { [key: string]: any } = { + [tableName]: [ + { var1: 'string', var2: 232 }, + { var1: 'string', var2: 233 } + ], + [`$${tableName}`]: { formats: { var1: '$char12.', var2: 'best.' } } + } + const expectedCsv = convertToCSV(data, tableName) + + const formData = new NodeFormData() + const { requestParams } = generateTableUploadForm(formData, data) + + expect(requestParams.sasjs_tables).toBe(tableName) + expect(requestParams.sasjs1data).toBe(expectedCsv) + expect(requestParams.sasjs2data).toBeUndefined() + }) + + it('should number sequentially across multiple tables w/ paired formats', () => { + const data: { [key: string]: any } = { + tableA: [{ a: 1 }], + $tableA: { formats: { a: 'best.' } }, + tableB: [{ b: 'x' }], + $tableB: { formats: { b: '$char1.' } } + } + const expectedCsvA = convertToCSV(data, 'tableA') + const expectedCsvB = convertToCSV(data, 'tableB') + + const formData = new NodeFormData() + const { requestParams } = generateTableUploadForm(formData, data) + + expect(requestParams.sasjs_tables).toBe('tableA tableB') + expect(requestParams.sasjs1data).toBe(expectedCsvA) + expect(requestParams.sasjs2data).toBe(expectedCsvB) + expect(requestParams.sasjs3data).toBeUndefined() + }) + + it('should throw if string value exceeds 32765 chars', () => { + const formData = new NodeFormData() + const data = { testTable: [{ var1: 'z'.repeat(32766) }] } + + expect(() => generateTableUploadForm(formData, data)).toThrow( + new Error( + 'The max length of a string value in SASjs is 32765 characters.' + ) + ) + }) + + it('should append chunks to formData when csv exceeds 16k', () => { + const tableName = 'big' + const data = { [tableName]: [{ var1: 'z'.repeat(16001) }] } + + const formData = new NodeFormData() + const appendSpy = jest.spyOn(formData, 'append') + + const { requestParams } = generateTableUploadForm(formData, data) + + expect(requestParams.sasjs_tables).toBe(tableName) + expect(requestParams.sasjs1data).toBeUndefined() + expect(appendSpy).toHaveBeenCalled() + expect(appendSpy.mock.calls.every(([key]) => key === 'sasjs1data')).toBe( + true + ) + }) +}) diff --git a/src/job-execution/JobExecutor.ts b/src/job-execution/JobExecutor.ts index f60ddfa..955611c 100644 --- a/src/job-execution/JobExecutor.ts +++ b/src/job-execution/JobExecutor.ts @@ -55,7 +55,6 @@ export abstract class BaseJobExecutor implements JobExecutor { if (config.debug) { requestParams['_omittextlog'] = 'false' requestParams['_omitSessionResults'] = 'false' - requestParams['_debug'] = 131 } diff --git a/src/job-execution/WebJobExecutor.ts b/src/job-execution/WebJobExecutor.ts index a9c980d..545e8a4 100644 --- a/src/job-execution/WebJobExecutor.ts +++ b/src/job-execution/WebJobExecutor.ts @@ -16,9 +16,11 @@ import { SASViyaApiClient } from '../SASViyaApiClient' import { isRelativePath, parseSasViyaDebugResponse, + parseSasViyaLogDebugResponse, appendExtraResponseAttributes, parseWeboutResponse, - getFormData + getFormData, + isNode } from '../utils' import { BaseJobExecutor } from './JobExecutor' @@ -101,6 +103,10 @@ export class WebJobExecutor extends BaseJobExecutor { apiUrl += config.contextName?.trim() ? `&_contextname=${encodeURIComponent(config.contextName)}` : '' + + if (config.runAsTask === true) { + apiUrl += '&_executionTasks=true' + } } let requestParams = { @@ -113,6 +119,26 @@ export class WebJobExecutor extends BaseJobExecutor { */ let formData = getFormData() + // FIXME(viya - SAS Track CS0409737): remove when Viya stops rejecting empty multipart on + // _executionTasks=true. Dummy file keeps the body non-empty + const hasExecutionTasksFlag = config.runAsTask === true + + // Move debug params to URL for viya; viya seems to honor them more + // reliably in the query string than the multipart body. + if ( + hasExecutionTasksFlag && + config.debug && + config.serverType === ServerType.SasViya + ) { + const debugKeys = ['_debug', '_omitSessionResults'] + debugKeys.forEach((key) => { + if (requestParams[key] !== undefined) { + apiUrl += `&${key}=${encodeURIComponent(requestParams[key])}` + delete requestParams[key] + } + }) + } + if (data) { const stringifiedData = JSON.stringify(data) if ( @@ -133,10 +159,21 @@ export class WebJobExecutor extends BaseJobExecutor { generateTableUploadForm(formData, data) formData = newFormData requestParams = { ...requestParams, ...params } + + if ( + config.serverType === ServerType.SasViya && + hasExecutionTasksFlag + ) { + addDummyFile(formData) + } } catch (e: any) { return Promise.reject(new ErrorResponse(e?.message, e)) } } + } else { + if (config.serverType === ServerType.SasViya && hasExecutionTasksFlag) { + addDummyFile(formData) + } } for (const key in requestParams) { @@ -168,11 +205,14 @@ export class WebJobExecutor extends BaseJobExecutor { if (config.debug) { switch (this.serverType) { case ServerType.SasViya: - jsonResponse = await parseSasViyaDebugResponse( - res.result, - this.requestClient, - this.serverUrl - ) + jsonResponse = + config.useComputeApi === null && config.runAsTask === true + ? await parseSasViyaLogDebugResponse(res.result) + : await parseSasViyaDebugResponse( + res.result, + this.requestClient, + this.serverUrl + ) break case ServerType.Sas9: jsonResponse = @@ -231,6 +271,23 @@ export class WebJobExecutor extends BaseJobExecutor { return requestPromise } + protected getRequestParams(config: any): any { + const requestParams = super.getRequestParams(config) + + // FIXME(viya - possible issue with default debug flags) + // runAsTask on Viya: use _debug=128 (not 131) and omit _omittextlog + if ( + config.debug && + config.serverType === ServerType.SasViya && + config.runAsTask === true + ) { + requestParams['_debug'] = 128 + delete requestParams['_omittextlog'] + } + + return requestParams + } + private async getJobUri(sasJob: string) { if (!this.sasViyaApiClient) return '' let uri = '' @@ -263,3 +320,18 @@ export class WebJobExecutor extends BaseJobExecutor { return uri } } + +function addDummyFile(formData: NodeFormData | FormData) { + if (isNode()) { + ;(formData as NodeFormData).append('_sasjs_noop', '', { + filename: '_sasjs_noop.txt', + contentType: 'text/plain' + }) + } else { + ;(formData as FormData).append( + '_sasjs_noop', + new Blob([''], { type: 'text/plain' }), + '_sasjs_noop.txt' + ) + } +} diff --git a/src/job-execution/spec/executionTasks.spec.ts b/src/job-execution/spec/executionTasks.spec.ts new file mode 100644 index 0000000..3851700 --- /dev/null +++ b/src/job-execution/spec/executionTasks.spec.ts @@ -0,0 +1,146 @@ +import NodeFormData from 'form-data' +import { ServerType } from '@sasjs/utils/types' +import { WebJobExecutor } from '../WebJobExecutor' +import { RequestClient } from '../../request/RequestClient' +import { SASViyaApiClient } from '../../SASViyaApiClient' + +describe('WebJobExecutor runAsTask behaviour', () => { + const serverUrl = 'https://sample.server.com' + const jobsPath = '/SASJobExecution' + + const makeExecutor = (serverType: ServerType = ServerType.SasViya) => { + const requestClient = new RequestClient(serverUrl) + const sasViyaApiClient = { + getJobsInFolder: async () => [] + } as unknown as SASViyaApiClient + const executor = new WebJobExecutor( + serverUrl, + serverType, + jobsPath, + requestClient, + sasViyaApiClient + ) + const postSpy = jest + .spyOn(requestClient, 'post') + .mockResolvedValue({ result: { table1: [] }, etag: '' } as any) + jest.spyOn(requestClient, 'appendRequest').mockImplementation() + return { executor, postSpy } + } + + const baseConfig = { + serverUrl, + serverType: ServerType.SasViya, + appLoc: '/Public/app', + useComputeApi: false, + debug: false + } + + it('sends table data in body (runAsTask=false)', async () => { + const { executor, postSpy } = makeExecutor() + + await executor.execute( + 'services/common/sendArr', + { table1: [{ col1: 'v' }] }, + { ...baseConfig, runAsTask: false } + ) + + const [apiUrl, body, , contentType] = postSpy.mock.calls[0] + expect(apiUrl).not.toContain('_executionTasks=true') + expect(body).toBeInstanceOf(NodeFormData) + expect(contentType).toMatch(/^multipart\/form-data/) + }) + + it('uploads as file when payload has semicolons (runAsTask=false)', async () => { + const { executor, postSpy } = makeExecutor() + + await executor.execute( + 'services/common/sendArr', + { table1: [{ col1: 'has; semicolon' }] }, + { ...baseConfig, runAsTask: false } + ) + + const [apiUrl, body, , contentType] = postSpy.mock.calls[0] + expect(apiUrl).not.toContain('_executionTasks=') + expect(body).toBeInstanceOf(NodeFormData) + expect(contentType).toMatch(/^multipart\/form-data/) + }) + + it('appends &_executionTasks=true to URL when runAsTask=true and no data', async () => { + const { executor, postSpy } = makeExecutor() + + await executor.execute('services/common/sendArr', null, { + ...baseConfig, + runAsTask: true + }) + + const [apiUrl] = postSpy.mock.calls[0] + expect(apiUrl).toContain('&_executionTasks=true') + }) + + it('appends &_executionTasks=true and sends table data when runAsTask=true with one input table', async () => { + const { executor, postSpy } = makeExecutor() + + await executor.execute( + 'services/common/sendArr', + { table1: [{ col1: 'v' }] }, + { ...baseConfig, runAsTask: true } + ) + + const [apiUrl, body, , contentType] = postSpy.mock.calls[0] + expect(apiUrl).toContain('&_executionTasks=true') + expect(body).toBeInstanceOf(NodeFormData) + expect(contentType).toMatch(/^multipart\/form-data/) + const dump = (body as NodeFormData).getBuffer().toString() + expect(dump).toContain('name="sasjs_tables"') + expect(dump).toContain('name="sasjs1data"') + }) + + it('appends &_executionTasks=true to URL when runAsTask=true with multiple input tables', async () => { + const { executor, postSpy } = makeExecutor() + + await executor.execute( + 'services/common/sendArr', + { table1: [{ col1: 'v' }], table2: [{ col2: 'w' }] }, + { ...baseConfig, runAsTask: true } + ) + + const [apiUrl, body, , contentType] = postSpy.mock.calls[0] + expect(apiUrl).toContain('&_executionTasks=true') + expect(body).toBeInstanceOf(NodeFormData) + expect(contentType).toMatch(/^multipart\/form-data/) + const dump = (body as NodeFormData).getBuffer().toString() + expect(dump).toContain('name="sasjs_tables"') + expect(dump).toMatch(/table1\s+table2/) + }) + + it('uploads as file when runAsTask=true and payload has semicolons', async () => { + const { executor, postSpy } = makeExecutor() + + await executor.execute( + 'services/common/sendArr', + { table1: [{ col1: 'has; semicolon' }] }, + { ...baseConfig, runAsTask: true } + ) + + const [apiUrl, body, , contentType] = postSpy.mock.calls[0] + expect(apiUrl).toContain('&_executionTasks=true') + expect(body).toBeInstanceOf(NodeFormData) + expect(contentType).toMatch(/^multipart\/form-data/) + const dump = (body as NodeFormData).getBuffer().toString() + expect(dump).toContain('filename="table1.csv"') + expect(dump).toContain('Content-Type: application/csv') + }) + + it('does NOT append _executionTasks=true to URL when runAsTask=false', async () => { + const { executor, postSpy } = makeExecutor() + + await executor.execute( + 'services/common/sendArr', + { table1: [{ col1: 'v' }] }, + { ...baseConfig, runAsTask: false } + ) + + const [apiUrl] = postSpy.mock.calls[0] + expect(apiUrl).not.toContain('_executionTasks=true') + }) +}) diff --git a/src/types/SASjsConfig.ts b/src/types/SASjsConfig.ts index 92a0198..f1fafb9 100644 --- a/src/types/SASjsConfig.ts +++ b/src/types/SASjsConfig.ts @@ -79,6 +79,14 @@ export class SASjsConfig { * may affect browser performance, especially with debug (logs) enabled. */ requestHistoryLimit?: number = 10 + /** + * Optional setting. When `true`, the request runs as a Viya execution task — + * appends `_executionTasks=true` to the request URL. Only applies to the Viya + * web jobs path, i.e. when `serverType === SASVIYA` AND + * `useComputeApi` is `null`/`undefined`. Has no effect when `useComputeApi` + * is explicitly set to `true` or `false`. + */ + runAsTask?: boolean = false } export enum LoginMechanism { diff --git a/src/utils/index.ts b/src/utils/index.ts index 2a2b405..ac11428 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -15,6 +15,7 @@ export * from './parseGeneratedCode' export * from './parseSasViyaLog' export * from './parseSourceCode' export * from './parseViyaDebugResponse' +export * from './parseViyaLogDebugResponse' export * from './parseWeboutResponse' export * from './serialize' export * from './splitChunks' diff --git a/src/utils/parseViyaLogDebugResponse.ts b/src/utils/parseViyaLogDebugResponse.ts new file mode 100644 index 0000000..9060c9c --- /dev/null +++ b/src/utils/parseViyaLogDebugResponse.ts @@ -0,0 +1,32 @@ +import { getValidJson } from './getValidJson' +import { parseWeboutResponse } from './parseWeboutResponse' + +/** + * When querying a Viya job using the Web approach with _DEBUG=128 (used when + * runAsTask is true), the webout JSON is inlined into the response via: + * var blob = new Blob([`{...}`], {type: 'application/json'}); + * On abort/error paths the same shape is used but with text/plain and + * weboutBEGIN/END markers around the JSON: + * var blob = new Blob([`>>weboutBEGIN<<\n{...}\n>>weboutEND<<\n`], {type: 'text/plain'}); + * No follow-up request is needed — extract and parse the JSON directly. + */ +export const parseSasViyaLogDebugResponse = async (response: any) => { + // If upstream already parsed the response as JSON (object), pass through. + if (typeof response !== 'string') { + return response + } + + const blobMatch = response.match( + /new Blob\(\[`([\s\S]*?)`\],\s*\{type:\s*'(?:application\/json|text\/plain)'\}\)/ + ) + if (!blobMatch) { + throw new Error('Unable to find webout blob in debug log response.') + } + + const blobContent = blobMatch[1] + const stripped = blobContent.includes('>>weboutBEGIN<<') + ? parseWeboutResponse(blobContent) + : blobContent + + return getValidJson(stripped) +} diff --git a/src/utils/spec/parseViyaLogDebugResponse.spec.ts b/src/utils/spec/parseViyaLogDebugResponse.spec.ts new file mode 100644 index 0000000..2683d0a --- /dev/null +++ b/src/utils/spec/parseViyaLogDebugResponse.spec.ts @@ -0,0 +1,55 @@ +import { parseSasViyaLogDebugResponse } from '../parseViyaLogDebugResponse' + +describe('parseSasViyaLogDebugResponse', () => { + it('should extract and parse JSON from inline Blob', async () => { + const resultData = { message: 'success' } + const response = `` + + const result = await parseSasViyaLogDebugResponse(response) + + expect(result).toEqual(resultData) + }) + + it('should extract and parse multiline JSON from inline Blob', async () => { + const resultData = { + SYSDATE: '13MAY26', + SYSCC: '0', + saslibs: [{ LIBRARYREF: 'FORMATS' }] + } + const response = `` + + const result = await parseSasViyaLogDebugResponse(response) + + expect(result).toEqual(resultData) + }) + + it('should extract and parse JSON wrapped in weboutBEGIN/END (text/plain blob)', async () => { + const resultData = { SYSCC: '1012', SYSERRORTEXT: 'File missing.' } + const response = `` + + const result = await parseSasViyaLogDebugResponse(response) + + expect(result).toEqual(resultData) + }) + + it('should throw an error if blob is not found', async () => { + const response = `No blob here` + + await expect(parseSasViyaLogDebugResponse(response)).rejects.toThrow( + 'Unable to find webout blob in debug log response.' + ) + }) +})