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 = `

${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 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') + }) +})