Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cypress.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ module.exports = defineConfig({
username: '',
password: '',
screenshotOnRunFailure: false,
testingFinishTimeout: 600000
testingFinishTimeout: 300000
}
})
70 changes: 56 additions & 14 deletions cypress/integration/sasjs.tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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', () => {
Expand All @@ -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()
})
})
6 changes: 4 additions & 2 deletions sasjs-tests/src/components/TestSuite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
}
}

Expand All @@ -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>
`
Expand Down
35 changes: 26 additions & 9 deletions sasjs-tests/src/core/TestRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion sasjs-tests/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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))
}

Expand Down
61 changes: 61 additions & 0 deletions sasjs-tests/src/testSuites/executionTasks.ts
Original file line number Diff line number Diff line change
@@ -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
}
]
})
108 changes: 108 additions & 0 deletions src/job-execution/spec/executionTasks.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading