mirror of
https://github.com/sasjs/adapter.git
synced 2026-06-08 18:20:20 +00:00
fix(webjob): test coverage for _executionTasks=true requests without file upload (#883)
* test(cypress): show individual errors * test(cypress): half the cypress integration test timeout * test(cypress): add parallel tests, timeout and reports * test(cypress): use allSettled instead of all * test(runner): pre-render pending test cards
This commit is contained in:
committed by
GitHub
parent
eb1186b4b9
commit
55db8f45ab
+1
-1
@@ -13,6 +13,6 @@ module.exports = defineConfig({
|
|||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
screenshotOnRunFailure: false,
|
screenshotOnRunFailure: false,
|
||||||
testingFinishTimeout: 600000
|
testingFinishTimeout: 300000
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,6 +12,58 @@ context('sasjs-tests', function () {
|
|||||||
cy.visit(sasjsTestsUrl)
|
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() {
|
function loginIfNeeded() {
|
||||||
cy.get('login-form, tests-view', { timeout: 30000 }).should('exist')
|
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').should('be.visible').click()
|
||||||
|
|
||||||
cy.get('tests-view')
|
waitForTestsToFinish(testingFinishTimeout)
|
||||||
.shadow()
|
|
||||||
.find('#run-btn:disabled', {
|
|
||||||
timeout: testingFinishTimeout
|
|
||||||
})
|
|
||||||
.should('not.exist')
|
|
||||||
|
|
||||||
cy.get('test-card').shadow().find('.status-icon.failed').should('not.exist')
|
assertNoFailedTests()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Should have all tests successful with debug on', () => {
|
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').should('be.visible').click()
|
||||||
|
|
||||||
cy.get('tests-view')
|
waitForTestsToFinish(testingFinishTimeout)
|
||||||
.shadow()
|
|
||||||
.find('#run-btn:disabled', {
|
|
||||||
timeout: testingFinishTimeout
|
|
||||||
})
|
|
||||||
.should('not.exist')
|
|
||||||
|
|
||||||
cy.get('test-card').shadow().find('.status-icon.failed').should('not.exist')
|
assertNoFailedTests()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -66,10 +66,11 @@ export class TestSuiteElement extends HTMLElement {
|
|||||||
const passed = completedTests.filter((t) => t.status === 'passed').length
|
const passed = completedTests.filter((t) => t.status === 'passed').length
|
||||||
const failed = completedTests.filter((t) => t.status === 'failed').length
|
const failed = completedTests.filter((t) => t.status === 'failed').length
|
||||||
const running = completedTests.filter((t) => t.status === 'running').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')
|
const statsEl = this.shadow.querySelector('.stats')
|
||||||
if (statsEl) {
|
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 passed = completedTests.filter((t) => t.status === 'passed').length
|
||||||
const failed = completedTests.filter((t) => t.status === 'failed').length
|
const failed = completedTests.filter((t) => t.status === 'failed').length
|
||||||
const running = completedTests.filter((t) => t.status === 'running').length
|
const running = completedTests.filter((t) => t.status === 'running').length
|
||||||
|
const pending = completedTests.filter((t) => t.status === 'pending').length
|
||||||
|
|
||||||
this.shadow.innerHTML = `
|
this.shadow.innerHTML = `
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h2>${name}</h2>
|
<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>
|
||||||
<div class="tests" id="tests-container"></div>
|
<div class="tests" id="tests-container"></div>
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -30,12 +30,14 @@ export class TestRunner {
|
|||||||
) => void
|
) => void
|
||||||
): Promise<CompletedTestSuite[]> {
|
): Promise<CompletedTestSuite[]> {
|
||||||
this.isRunning = true
|
this.isRunning = true
|
||||||
this.completedTestSuites = []
|
this.completedTestSuites = this.testSuites.map((suite) => ({
|
||||||
|
name: suite.name,
|
||||||
|
completedTests: []
|
||||||
|
}))
|
||||||
|
|
||||||
for (let i = 0; i < this.testSuites.length; i++) {
|
await Promise.allSettled(
|
||||||
const suite = this.testSuites[i]
|
this.testSuites.map((suite, i) => this.runTestSuite(suite, i, onUpdate))
|
||||||
await this.runTestSuite(suite, i, onUpdate)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
this.isRunning = false
|
this.isRunning = false
|
||||||
return this.completedTestSuites
|
return this.completedTestSuites
|
||||||
@@ -49,7 +51,23 @@ export class TestRunner {
|
|||||||
currentIndex: number
|
currentIndex: number
|
||||||
) => void
|
) => void
|
||||||
): Promise<CompletedTestSuite> {
|
): 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
|
let context: unknown
|
||||||
|
|
||||||
// Run beforeAll if exists
|
// Run beforeAll if exists
|
||||||
@@ -62,15 +80,14 @@ export class TestRunner {
|
|||||||
const test = suite.tests[i]
|
const test = suite.tests[i]
|
||||||
const currentIndex = suiteIndex * 1000 + i
|
const currentIndex = suiteIndex * 1000 + i
|
||||||
|
|
||||||
// Set status to running
|
// Flip pending → running
|
||||||
const runningTest: CompletedTest = {
|
completedTests[i] = {
|
||||||
test,
|
test,
|
||||||
result: false,
|
result: false,
|
||||||
error: null,
|
error: null,
|
||||||
executionTime: 0,
|
executionTime: 0,
|
||||||
status: 'running'
|
status: 'running'
|
||||||
}
|
}
|
||||||
completedTests.push(runningTest)
|
|
||||||
|
|
||||||
// Notify update
|
// Notify update
|
||||||
if (onUpdate) {
|
if (onUpdate) {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { fileUploadTests } from './testSuites/FileUpload'
|
|||||||
import { computeTests } from './testSuites/Compute'
|
import { computeTests } from './testSuites/Compute'
|
||||||
import { sasjsRequestTests } from './testSuites/SasjsRequests'
|
import { sasjsRequestTests } from './testSuites/SasjsRequests'
|
||||||
import { specialCaseTests } from './testSuites/SpecialCases'
|
import { specialCaseTests } from './testSuites/SpecialCases'
|
||||||
|
import { executionTasksTests } from './testSuites/executionTasks'
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
const appContainer = document.getElementById('app')
|
const appContainer = document.getElementById('app')
|
||||||
@@ -104,8 +105,9 @@ function showTests(
|
|||||||
fileUploadTests(adapter)
|
fileUploadTests(adapter)
|
||||||
]
|
]
|
||||||
|
|
||||||
// Add compute tests for SASVIYA only
|
// Add tests for SASVIYA only
|
||||||
if (adapter.getSasjsConfig().serverType === 'SASVIYA') {
|
if (adapter.getSasjsConfig().serverType === 'SASVIYA') {
|
||||||
|
testSuites.push(executionTasksTests(adapter))
|
||||||
testSuites.push(computeTests(adapter, appLoc))
|
testSuites.push(computeTests(adapter, appLoc))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user