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
}
})
diff --git a/cypress/integration/sasjs.tests.ts b/cypress/integration/sasjs.tests.ts
index 07d82a31..8cc056a4 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 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 = `
`
diff --git a/sasjs-tests/src/core/TestRunner.ts b/sasjs-tests/src/core/TestRunner.ts
index 1fcc5833..e634198e 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 4dd8f503..95781c21 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 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
+ }
+ ]
+})
diff --git a/src/job-execution/spec/executionTasks.spec.ts b/src/job-execution/spec/executionTasks.spec.ts
new file mode 100644
index 00000000..877c0f66
--- /dev/null
+++ b/src/job-execution/spec/executionTasks.spec.ts
@@ -0,0 +1,108 @@
+import NodeFormData from 'form-data'
+import { ServerType } from '@sasjs/utils/types'
+import { WebJobExecutor } from '../WebJobExecutor'
+import { RequestClient } from '../../request/RequestClient'
+import { SASViyaApiClient } from '../../SASViyaApiClient'
+
+describe('WebJobExecutor _executionTasks=true behaviour', () => {
+ const serverUrl = 'https://sample.server.com'
+ const jobsPath = '/SASJobExecution'
+
+ const makeExecutor = (serverType: ServerType = ServerType.SasViya) => {
+ const requestClient = new RequestClient(serverUrl)
+ const sasViyaApiClient = {
+ getJobsInFolder: async () => []
+ } as unknown as SASViyaApiClient
+ const executor = new WebJobExecutor(
+ serverUrl,
+ serverType,
+ jobsPath,
+ requestClient,
+ sasViyaApiClient
+ )
+ const postSpy = jest
+ .spyOn(requestClient, 'post')
+ .mockResolvedValue({ result: { table1: [] }, etag: '' } as any)
+ jest.spyOn(requestClient, 'appendRequest').mockImplementation()
+ return { executor, postSpy }
+ }
+
+ const baseConfig = {
+ serverUrl,
+ serverType: ServerType.SasViya,
+ appLoc: '/Public/app',
+ debug: false
+ }
+
+ it('sends table data in body', async () => {
+ const { executor, postSpy } = makeExecutor()
+
+ await executor.execute(
+ 'services/common/sendArr',
+ { table1: [{ col1: 'v' }] },
+ baseConfig
+ )
+
+ const [, body, , contentType] = postSpy.mock.calls[0]
+ expect(body).toBeInstanceOf(NodeFormData)
+ expect(contentType).toMatch(/^multipart\/form-data/)
+ })
+
+ it('sends table data when _executionTasks=true', async () => {
+ const { executor, postSpy } = makeExecutor()
+
+ await executor.execute(
+ 'services/common/sendArr&_executionTasks=true',
+ { table1: [{ col1: 'v' }] },
+ baseConfig
+ )
+
+ const [apiUrl, body, , contentType] = postSpy.mock.calls[0]
+ expect(apiUrl).toContain('_program=/Public/app/services/common/sendArr')
+ expect(apiUrl).toContain('_executionTasks=true')
+ expect(body).toBeInstanceOf(NodeFormData)
+ expect(contentType).toMatch(/^multipart\/form-data/)
+ const dump = (body as NodeFormData).getBuffer().toString()
+ expect(dump).toContain('name="sasjs_tables"')
+ expect(dump).toContain('name="sasjs1data"')
+ })
+
+ it('uploads as file when payload has semicolons', async () => {
+ const { executor, postSpy } = makeExecutor()
+
+ await executor.execute(
+ 'services/common/sendArr',
+ { table1: [{ col1: 'has; semicolon' }] },
+ baseConfig
+ )
+
+ const [apiUrl, body, , contentType] = postSpy.mock.calls[0]
+ expect(apiUrl).toContain('_program=')
+ expect(apiUrl).not.toContain('_executionTasks=')
+ expect(body).toBeInstanceOf(NodeFormData)
+ expect(body).not.toBeInstanceOf(URLSearchParams)
+ expect(contentType).toMatch(/^multipart\/form-data/)
+ expect(contentType).not.toBe('application/x-www-form-urlencoded')
+ })
+
+ it('uploads as file when _executionTasks=true and payload has semicolons', async () => {
+ const { executor, postSpy } = makeExecutor()
+
+ await executor.execute(
+ 'services/common/sendArr&_executionTasks=true',
+ { table1: [{ col1: 'has; semicolon' }] },
+ baseConfig
+ )
+
+ const [apiUrl, body, , contentType] = postSpy.mock.calls[0]
+ expect(apiUrl).toContain('_program=')
+ expect(apiUrl).toContain('_executionTasks=true')
+ expect(body).toBeInstanceOf(NodeFormData)
+ expect(body).not.toBeInstanceOf(URLSearchParams)
+ expect(contentType).toMatch(/^multipart\/form-data/)
+ expect(contentType).not.toBe('application/x-www-form-urlencoded')
+ const dump = (body as NodeFormData).getBuffer().toString()
+ expect(dump).toContain('filename="table1.csv"')
+ expect(dump).toContain('Content-Type: application/csv')
+ })
+})