feat: run startupservice in a new window from its error modal
Build / Build-and-ng-test (pull_request) Failing after 1m56s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 19m25s

When the startupservice fails, the modal is a dead end: the adapter returned
no log, so there is nothing to act on. The modal now offers a button that runs
the same service through the JES / STP web app in a new browser window, where
the SAS log is returned as the page body.

`_debug=131` is always added (the log is the point), and `_executionTasks` is
never included, so on Viya the run is not submitted as a Compute task and
cannot hit the execution-task timeout. The URL is built by `buildServiceUrl`,
which works for SAS 9, Viya and SASjs Server, and is the same shape the deploy
configurator already uses to open makedata.

The startup failure modals now carry the service name, which is what shows the
button: all three failure branches (malformed response, missing properties,
request rejected) pass `public/startupservice`.
This commit is contained in:
dc committed 2026-10-23 01:15:31 +00:00
1 parent 779006b0bf
commit 40f61331aa
12 files changed
+404 -8

No files matched your search

+1 -1
View File
@@ -198,7 +198,7 @@ jobs:
# Start frontend and run cypress
# timeout 1800: SIGTERM after 30 min so Cypress can flush video/screenshots
# before the outer timeout-minutes hard-kills the step (avoids silent multi-hour hangs)
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/hook-programs.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/filter-panel.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/full-table-search.cy.ts,cypress/e2e/viewbox.cy.ts,cypress/e2e/stage.cy.ts,cypress/e2e/browser-info.cy.ts,cypress/e2e/ddl-export.cy.ts,cypress/e2e/row-cell-limits.cy.ts"
npx ng serve --host 0.0.0.0 --port 4200 & npx wait-on http://localhost:4200 && timeout 1800 npx cypress run --browser chrome --spec "cypress/e2e/csv-limited.cy.ts,cypress/e2e/hook-programs.cy.ts,cypress/e2e/liveness.cy.ts,cypress/e2e/editor.cy.ts,cypress/e2e/excel-multi-load.cy.ts,cypress/e2e/excel.cy.ts,cypress/e2e/csv.cy.ts,cypress/e2e/filtering.cy.ts,cypress/e2e/filter-panel.cy.ts,cypress/e2e/licensing.cy.ts,cypress/e2e/viewer-labels.cy.ts,cypress/e2e/full-table-search.cy.ts,cypress/e2e/viewbox.cy.ts,cypress/e2e/stage.cy.ts,cypress/e2e/browser-info.cy.ts,cypress/e2e/ddl-export.cy.ts,cypress/e2e/row-cell-limits.cy.ts,cypress/e2e/startupservice-launcher.cy.ts"
- name: Zip Cypress videos
if: always()
@@ -0,0 +1,75 @@
// Marks this file as an ES module so its top-level consts don't collide with
// other spec files under the TS type-checker.
export {}
//
// When the startup service fails without a usable response, the error modal
// offers a button that runs the same service in a new browser window, where the
// SAS log is returned instead. This spec forces a startup failure against the
// mock backend and checks the button and the URL it opens.
const hostUrl = Cypress.env('hosturl')
const STARTUP_PROGRAM = '/Public/app/dc/services/public/startupservice'
/** The body a Viya Compute task returns when the run outlives the
* execution-task timeout: a bare string, so the adapter has no webout and no
* log to report. */
const rawJobError = [
'Job error',
'The Compute service could not execute the task c74a707a-8b88-46a5-8f28-9a7694ad5e13 because the context is not reusable.',
'path: /compute/tasks',
'correlator: dc1a9fda-8f65-4f32-b4d4-2a70e02179b1;1bcb6a4c-8a16-4053-a790-292544c25a03'
].join('\n')
context('startupservice launcher: ', function () {
it('1 | runs the startupservice in a new window when its response is malformed', () => {
// Report an empty admin folder so the app loads the startup service: on a
// freshly seeded mock estate makedata is still present, which sends the app
// to the deploy screen instead.
cy.intercept('GET', '**/drive/folder**', (req) => {
if (decodeURIComponent(req.url).includes('services/admin')) {
req.reply({ files: [], folders: [] })
return
}
req.continue()
})
cy.intercept('**/stp/execute/**', (req) => {
if (req.url.includes('public/startupservice')) {
// The adapter cannot use a bare string as a response payload, so the
// startup request fails with nothing to show.
req.reply({
statusCode: 200,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(rawJobError)
})
return
}
req.continue()
})
// The launcher must not actually open a tab under test.
cy.visit(hostUrl, {
timeout: 60000,
onBeforeLoad(win) {
cy.stub(win, 'open').as('windowOpen')
}
})
cy.get('.clr-abort-modal', { timeout: 60000 }).should('be.visible')
cy.get('.clr-abort-modal').last().should('contain.text', 'startupservice')
cy.get('.clr-abort-modal')
.last()
.contains('button', 'Open startupservice in new window')
.click()
cy.get('@windowOpen').should('have.been.calledOnce')
cy.get('@windowOpen').then((stub: any) => {
const url = stub.firstCall.args[0]
expect(url).to.contain(STARTUP_PROGRAM)
expect(url).to.contain('_debug=131')
// The browser route is what makes the log available: no execution task.
expect(url).not.to.contain('_executionTasks')
})
})
})
+3 -1
View File
@@ -170,9 +170,11 @@ describe('AppService - startup retry', () => {
expect(deps.sasService.request).toHaveBeenCalledTimes(1)
expect(deps.eventService.showInfoModal).toHaveBeenCalledTimes(1)
const [, message] = deps.eventService.showInfoModal.calls.mostRecent().args
const [, message, sasService] =
deps.eventService.showInfoModal.calls.mostRecent().args
expect(message).toContain(rawJobError)
expect(message).not.toContain('Globvars, Sasdatasets, Saslibs, XLMaps')
expect(sasService).toBe('public/startupservice')
expect(deps.licenceService.isAppActivated.value).toBeFalse()
})
})
+9 -3
View File
@@ -107,7 +107,11 @@ export class AppService {
malformedMessage
)
this.startupCheckService.updateStep(2, 'error', 'Malformed response')
this.eventService.showInfoModal('Error', malformedMessage)
this.eventService.showInfoModal(
'Error',
malformedMessage,
'public/startupservice'
)
this.licenceService.isAppActivated.next(false)
return
@@ -139,7 +143,8 @@ export class AppService {
)
this.eventService.showInfoModal(
'Error',
`${missingProps.join(', ')} are not present in the startupservice`
`${missingProps.join(', ')} are not present in the startupservice`,
'public/startupservice'
)
this.licenceService.isAppActivated.next(false)
@@ -221,7 +226,8 @@ export class AppService {
this.startupCheckService.updateStep(2, 'error', 'Request failed')
this.eventService.showInfoModal(
'Error',
'There is an issue with startupservice response'
'There is an issue with startupservice response',
'public/startupservice'
)
this.licenceService.isAppActivated.next(false)
})
+6 -2
View File
@@ -42,11 +42,15 @@ export class EventService {
this.onDemoLimitModalShow.emit(featureName)
}
public showInfoModal(modalTitle: string = 'Info', message: string) {
public showInfoModal(
modalTitle: string = 'Info',
message: string,
sasService: string | null = null
) {
let infoModal: InfoModal = {
modalTitle,
message,
sasService: '',
sasService,
details: null
}
+41 -1
View File
@@ -14,7 +14,12 @@ const buildService = () => {
const sasjsAdapter: any = {
request: jasmine.createSpy('request').and.resolveTo({}),
getSasRequests: () => [],
setDebugState: jasmine.createSpy('setDebugState')
setDebugState: jasmine.createSpy('setDebugState'),
getSasjsConfig: () => ({
serverUrl: 'https://sas.example.com',
appLoc: '/Public/app/dc',
serverType: 'SASVIYA'
})
}
const loggerService: any = {
logRequestData: jasmine.createSpy('logRequestData')
@@ -163,3 +168,38 @@ describe('SasService debug state', () => {
expect(initService(true).getSasjsConfig().debug).toBe(false)
})
})
/**
* The URL shape the deploy configurator already uses for makedata: the service
* is run by the JES / STP web app in a browser tab, so the SAS log comes back
* as the page body instead of through the adapter.
*/
describe('SasService openServiceInNewWindow', () => {
it('opens the service URL built from the live adapter config', () => {
const { service } = buildService()
spyOn(service, 'getExecutionPath').and.returnValue('/SASJobExecution')
const openSpy = spyOn(window, 'open')
service.openServiceInNewWindow('public/startupservice', {
_contextname: 'ctx'
})
expect(openSpy).toHaveBeenCalledWith(
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&_contextname=ctx&_debug=131',
'_blank'
)
})
it('opens without parameters when none are given', () => {
const { service } = buildService()
spyOn(service, 'getExecutionPath').and.returnValue('/SASJobExecution')
const openSpy = spyOn(window, 'open')
service.openServiceInNewWindow('public/startupservice')
expect(openSpy).toHaveBeenCalledWith(
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&_debug=131',
'_blank'
)
})
})
+28
View File
@@ -21,6 +21,7 @@ import { SasViyaService } from './sas-viya.service'
import { ViyaApiFolder } from '../viya-api-explorer/models/viya-api-folder.model'
import { ViyaApiFolderMembers } from '../viya-api-explorer/models/viya-api-folder-content.model'
import { parseUserAgent } from '../shared/utils/parse-user-agent'
import { buildServiceUrl } from '../shared/utils/service-url'
import { VERSION } from '../../environments/version'
/**
@@ -842,6 +843,33 @@ export class SasService {
return this.sasjsAdapter
}
/**
* Runs a SAS service in a new browser window, bypassing the adapter.
*
* The adapter's request path can hide the backend log when a service fails:
* on Viya a long startup service may be submitted as an execution task and
* time out before it finishes, returning neither webout nor log. A browser
* request to the JES / STP web app runs the same `_program` with no task and
* returns the SAS log (`_debug=131`), so this is the diagnostic path offered
* when a failed request leaves nothing usable to display.
*/
public openServiceInNewWindow(
service: string,
params: { [name: string]: string | number | null | undefined } = {}
) {
const config = this.getSasjsConfig()
const url = buildServiceUrl({
serverUrl: config.serverUrl || '',
appLoc: config.appLoc || '',
executionPath: this.getExecutionPath() || '',
service,
params
})
window.open(url, '_blank')
}
public getServerType(): string {
const sasjsConfig = this.sasjsAdapter.getSasjsConfig()
@@ -28,6 +28,16 @@
}
</div>
<div class="modal-footer">
@if (showServiceLauncher(data.sasService)) {
<button
type="button"
class="btn btn-primary"
(click)="openServiceInNewWindow()"
>
Open startupservice in new window
</button>
}
@if (showConfiguratorButton(data.sasService)) {
<button
type="button"
@@ -0,0 +1,71 @@
import { InfoModalComponent } from './info-modal.component'
describe('InfoModalComponent', () => {
let component: InfoModalComponent
let sasService: any
const buildComponent = (sasServiceConfig: any = {}) => {
sasService = {
getSasjsConfig: () => sasServiceConfig,
openServiceInNewWindow: jasmine.createSpy('openServiceInNewWindow')
}
component = new InfoModalComponent(
{} as any,
{} as any,
sasService,
{} as any
)
component.data = {
modalTitle: 'Error',
message: 'startupservice failed',
sasService: 'public/startupservice',
details: null
}
component.ngOnInit()
}
describe('showServiceLauncher', () => {
beforeEach(() => buildComponent())
it('is shown for the startupservice, on every server type', () => {
expect(component.showServiceLauncher('public/startupservice')).toBeTrue()
expect(
component.showServiceLauncher('services/public/startupservice')
).toBeTrue()
})
it('is not shown for other services or a missing service', () => {
expect(component.showServiceLauncher('public/getdata')).toBeFalse()
expect(component.showServiceLauncher('')).toBeFalse()
expect(component.showServiceLauncher(null)).toBeFalse()
})
})
describe('openServiceInNewWindow', () => {
it('runs the startupservice in a new window', () => {
buildComponent({ serverType: 'SASVIYA' })
component.openServiceInNewWindow()
expect(sasService.openServiceInNewWindow).toHaveBeenCalledWith(
'public/startupservice',
{}
)
})
it('passes the configured Viya context so the log comes from it', () => {
buildComponent({
serverType: 'SASVIYA',
contextName: 'SAS Job Execution compute context'
})
component.openServiceInNewWindow()
expect(sasService.openServiceInNewWindow).toHaveBeenCalledWith(
'public/startupservice',
{ _contextname: 'SAS Job Execution compute context' }
)
})
})
})
@@ -71,6 +71,34 @@ export class InfoModalComponent implements OnInit {
this.onConfirmModalClick.emit()
}
/**
* Whether to show the `Open startupservice in new window` button.
*
* The startupservice failure modal is a dead end otherwise: the adapter
* returned no log, so the only way to see the backend reason is to run the
* service through the JES / STP web app in a browser tab. Offered on every
* server type - SAS 9, Viya and SASjs Server all accept the `_program` URL.
*/
showServiceLauncher(sasService: string | null) {
return !!sasService?.includes('startupservice')
}
/**
* Runs the startupservice in a new browser window so the SAS log is
* available even when the adapter response carried none (e.g. a Viya
* execution task that timed out).
*/
openServiceInNewWindow() {
const sasjsConfig = this.sasService.getSasjsConfig()
const params: { [name: string]: string } = {}
if (sasjsConfig.contextName) {
params['_contextname'] = sasjsConfig.contextName
}
this.sasService.openServiceInNewWindow(this.data.sasService || '', params)
}
/**
* Only on SAS9, opening a backend configurator/deploy page
*/
@@ -0,0 +1,86 @@
import { buildServiceUrl } from './service-url'
describe('buildServiceUrl', () => {
const viyaConfig = {
serverUrl: 'https://sas.example.com',
appLoc: '/Public/app/dc',
executionPath: '/SASJobExecution',
service: 'public/startupservice'
}
it('builds the Viya JES URL for a service, with the log enabled', () => {
expect(buildServiceUrl(viyaConfig)).toBe(
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&_debug=131'
)
})
it('leaves serverUrl empty on SAS 9 so the browser resolves the origin', () => {
expect(
buildServiceUrl({
...viyaConfig,
serverUrl: '',
executionPath: '/SASStoredProcess/do'
})
).toBe(
'/SASStoredProcess/do/?_program=/Public/app/dc/services/public/startupservice&_debug=131'
)
})
it('builds the SASjs Server URL from its execution path', () => {
expect(
buildServiceUrl({
...viyaConfig,
executionPath: '/SASjsApi/stp/execute'
})
).toBe(
'https://sas.example.com/SASjsApi/stp/execute/?_program=/Public/app/dc/services/public/startupservice&_debug=131'
)
})
it('appends encoded parameters before the debug flag', () => {
expect(
buildServiceUrl({
...viyaConfig,
params: { _contextname: 'SAS Job Execution compute context' }
})
).toBe(
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&_contextname=SAS%20Job%20Execution%20compute%20context&_debug=131'
)
})
it('skips parameters with no value', () => {
expect(
buildServiceUrl({
...viyaConfig,
params: { _contextname: undefined, admin: null, dcpath: 'sas' }
})
).toBe(
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&dcpath=sas&_debug=131'
)
})
it('omits the debug flag when debug is off', () => {
expect(buildServiceUrl({ ...viyaConfig, debug: false })).toBe(
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice'
)
})
it('accepts a service with a leading slash', () => {
expect(
buildServiceUrl({ ...viyaConfig, service: '/public/startupservice' })
).toBe(
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&_debug=131'
)
})
it('accepts the services/ prefix the request wrapper reports', () => {
expect(
buildServiceUrl({
...viyaConfig,
service: 'services/public/startupservice'
})
).toBe(
'https://sas.example.com/SASJobExecution/?_program=/Public/app/dc/services/public/startupservice&_debug=131'
)
})
})
@@ -0,0 +1,46 @@
/**
* Builds the URL that runs a SAS service directly in a browser window.
*
* The adapter submits requests from JavaScript, and on Viya that can route the
* run through the Compute service (`_executionTasks=true`), where the SAS log
* is not returned to the caller and the request can time out before the
* service finishes. A browser request to the JES / STP web app runs the same
* `_program` without any task involved, and `_debug=131` returns the SAS log
* as the page body - which is what makes it useful when a service fails.
*
* `serverUrl` is left empty on SAS 9 when the app is served from the SAS
* server itself; the browser then resolves the path against the current
* origin.
*/
export interface ServiceUrlOptions {
serverUrl: string
appLoc: string
executionPath: string
service: string
params?: { [name: string]: string | number | null | undefined }
debug?: boolean
}
export const buildServiceUrl = ({
serverUrl,
appLoc,
executionPath,
service,
params = {},
debug = true
}: ServiceUrlOptions): string => {
// Callers pass the service as the app knows it: `public/getdata` in the
// request path, but the request wrapper reports `services/public/getdata`
// on a failed request. Both resolve to the same program.
const servicePath = service.replace(/^\/+/, '').replace(/^services\//, '')
const paramString = Object.entries(params)
.filter(([, value]) => value !== null && value !== undefined)
.map(
([name, value]) =>
`&${encodeURIComponent(name)}=${encodeURIComponent(String(value))}`
)
.join('')
const debugParam = debug ? '&_debug=131' : ''
return `${serverUrl}${executionPath}/?_program=${appLoc}/services/${servicePath}${paramString}${debugParam}`
}