1
0
mirror of https://github.com/sasjs/adapter.git synced 2026-06-08 18:20:20 +00:00

Merge pull request #885 from sasjs/884-execution-tasks-web-support

Support for execution tasks
This commit is contained in:
Allan Bowe
2026-05-15 09:06:15 +01:00
committed by GitHub
17 changed files with 618 additions and 35 deletions
+2
View File
@@ -1 +1,3 @@
ignore-scripts=true
save-exact=true
fund=false
+1
View File
@@ -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.
+1 -1
View File
@@ -13,6 +13,6 @@ module.exports = defineConfig({
username: '',
password: '',
screenshotOnRunFailure: false,
testingFinishTimeout: 600000
testingFinishTimeout: 300000
}
})
+56 -14
View File
@@ -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()
})
})
+4 -2
View File
@@ -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 = `
<div class="header">
<h2>${name}</h2>
<div class="stats">Passed: ${passed} | Failed: ${failed} | Running: ${running}</div>
<div class="stats">Passed: ${passed} | Failed: ${failed} | Running: ${running} | Pending: ${pending}</div>
</div>
<div class="tests" id="tests-container"></div>
`
+26 -9
View File
@@ -30,12 +30,14 @@ export class TestRunner {
) => void
): Promise<CompletedTestSuite[]> {
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<CompletedTestSuite> {
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) {
+3 -1
View File
@@ -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))
}
@@ -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
}
]
})
+7
View File
@@ -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'
@@ -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
)
})
})
-1
View File
@@ -55,7 +55,6 @@ export abstract class BaseJobExecutor implements JobExecutor {
if (config.debug) {
requestParams['_omittextlog'] = 'false'
requestParams['_omitSessionResults'] = 'false'
requestParams['_debug'] = 131
}
+74 -2
View File
@@ -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,7 +205,10 @@ export class WebJobExecutor extends BaseJobExecutor {
if (config.debug) {
switch (this.serverType) {
case ServerType.SasViya:
jsonResponse = await parseSasViyaDebugResponse(
jsonResponse =
config.useComputeApi === null && config.runAsTask === true
? await parseSasViyaLogDebugResponse(res.result)
: await parseSasViyaDebugResponse(
res.result,
this.requestClient,
this.serverUrl
@@ -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'
)
}
}
@@ -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')
})
})
+8
View File
@@ -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 {
+1
View File
@@ -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'
+32
View File
@@ -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)
}
@@ -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 = `<html><body><script>
var blob = new Blob([\`${JSON.stringify(resultData)}\`], {type: 'application/json'});
</script></body></html>`
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 = `<script>
var blob = new Blob([\`{"SYSDATE" : "13MAY26"
,"SYSCC" : "0"
,"saslibs": [{"LIBRARYREF":"FORMATS"}]
}
\`], {type: 'application/json'});
</script>`
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 = `<script>
var blob = new Blob([\`>>weboutBEGIN<<
${JSON.stringify(resultData)}
>>weboutEND<<
\`], {type: 'text/plain'});
</script>`
const result = await parseSasViyaLogDebugResponse(response)
expect(result).toEqual(resultData)
})
it('should throw an error if blob is not found', async () => {
const response = `<html><body>No blob here</body></html>`
await expect(parseSasViyaLogDebugResponse(response)).rejects.toThrow(
'Unable to find webout blob in debug log response.'
)
})
})