From 40f61331aad6bc8a52c551b924f0d1f1e6426cee Mon Sep 17 00:00:00 2001 From: dc Date: Fri, 23 Oct 2026 01:15:31 +0000 Subject: [PATCH] feat: run startupservice in a new window from its error modal 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`. --- .gitea/workflows/build.yaml | 2 +- .../cypress/e2e/startupservice-launcher.cy.ts | 75 ++++++++++++++++ client/src/app/services/app.service.spec.ts | 4 +- client/src/app/services/app.service.ts | 12 ++- client/src/app/services/event.service.ts | 8 +- client/src/app/services/sas.service.spec.ts | 42 ++++++++- client/src/app/services/sas.service.ts | 28 ++++++ .../abort-modal/info-modal.component.html | 10 +++ .../abort-modal/info-modal.component.spec.ts | 71 +++++++++++++++ .../abort-modal/info-modal.component.ts | 28 ++++++ .../src/app/shared/utils/service-url.spec.ts | 86 +++++++++++++++++++ client/src/app/shared/utils/service-url.ts | 46 ++++++++++ 12 files changed, 404 insertions(+), 8 deletions(-) create mode 100644 client/cypress/e2e/startupservice-launcher.cy.ts create mode 100644 client/src/app/shared/abort-modal/info-modal.component.spec.ts create mode 100644 client/src/app/shared/utils/service-url.spec.ts create mode 100644 client/src/app/shared/utils/service-url.ts diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 4145c05..3cb447a 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -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() diff --git a/client/cypress/e2e/startupservice-launcher.cy.ts b/client/cypress/e2e/startupservice-launcher.cy.ts new file mode 100644 index 0000000..b12b84e --- /dev/null +++ b/client/cypress/e2e/startupservice-launcher.cy.ts @@ -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') + }) + }) +}) diff --git a/client/src/app/services/app.service.spec.ts b/client/src/app/services/app.service.spec.ts index ab978af..d0eefe7 100644 --- a/client/src/app/services/app.service.spec.ts +++ b/client/src/app/services/app.service.spec.ts @@ -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() }) }) diff --git a/client/src/app/services/app.service.ts b/client/src/app/services/app.service.ts index 17d84c2..2825938 100644 --- a/client/src/app/services/app.service.ts +++ b/client/src/app/services/app.service.ts @@ -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) }) diff --git a/client/src/app/services/event.service.ts b/client/src/app/services/event.service.ts index 71477a0..01c5b56 100644 --- a/client/src/app/services/event.service.ts +++ b/client/src/app/services/event.service.ts @@ -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 } diff --git a/client/src/app/services/sas.service.spec.ts b/client/src/app/services/sas.service.spec.ts index 4a33519..3bf71c2 100644 --- a/client/src/app/services/sas.service.spec.ts +++ b/client/src/app/services/sas.service.spec.ts @@ -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' + ) + }) +}) diff --git a/client/src/app/services/sas.service.ts b/client/src/app/services/sas.service.ts index 2e8a502..f898eb9 100644 --- a/client/src/app/services/sas.service.ts +++ b/client/src/app/services/sas.service.ts @@ -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() diff --git a/client/src/app/shared/abort-modal/info-modal.component.html b/client/src/app/shared/abort-modal/info-modal.component.html index 3a442e3..777529d 100644 --- a/client/src/app/shared/abort-modal/info-modal.component.html +++ b/client/src/app/shared/abort-modal/info-modal.component.html @@ -28,6 +28,16 @@ }