UNPKG

cadence-web

Version:

Cadence Web UI

1,344 lines (1,156 loc) 44.3 kB
// Copyright (c) 2017-2024 Uber Technologies Inc. // // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. import MockDate from 'mockdate'; import moment from 'moment'; import { getFixture } from './helpers'; describe('Workflow', () => { function workflowTest(mochaTest, options) { const extendedOptions = { workflowId: 'email-daily-summaries', runId: 'emailRun1', view: 'summary', ...options, }; return [ new Scenario(mochaTest) .withDomain('ci-test') .withDomainAuthorization('ci-test', true) .withFeatureFlags() .withEmptyNewsFeed() .withStoreState({ workflowHistory: { graphEnabled: true, }, }) .withWorkflow( extendedOptions.workflowId, extendedOptions.runId, extendedOptions.execution ) .withTaskList('ci_task_list') .startingAt( `/domains/ci-test/workflows/${extendedOptions.workflowId}/${ extendedOptions.runId }/${extendedOptions.view}${ extendedOptions.query ? `?${extendedOptions.query}` : '' }` ), extendedOptions, ]; } async function summaryTest(mochaTest, options = {}, loading) { const [scenario, opts] = workflowTest(mochaTest, { view: 'summary', ...options, }); scenario.withFullHistory(opts.events, options.history); const summaryEl = await scenario .render(opts.attach) .waitUntilExists(`section.execution.${loading ? 'loading' : 'ready'}`); await summaryEl.waitUntilExists('section.workflow-summary dl'); return [summaryEl.parentElement, scenario]; } async function waitUntilCompleted(element) { return element.waitUntilExists('section.execution.ready'); } const closedWorkflowExecution = { workflowExecutionInfo: { closeTime: moment().subtract(1, 'day'), closeStatus: 'COMPLETED', type: {}, execution: {}, }, }; describe('Workflow Statistics', () => { it('should show statistics from the workflow', async function test() { const [summaryEl] = await summaryTest(this.test, {}, true); summaryEl .querySelector('.workflow-id dd') .should.have.text('email-daily-summaries'); summaryEl.querySelector('.run-id dd').should.have.text('emailRun1'); summaryEl.querySelector('.history-length dd').should.have.text('14'); summaryEl .querySelector('.workflow-name dd') .should.have.text('CIDemoWorkflow'); summaryEl .querySelector('.task-list dd a[href]') .should.contain.text('ci_task_list') .and.have.attr('href', '/domains/ci-test/task-lists/ci_task_list'); summaryEl.querySelector('.started-at dd').should.have.text( moment() .startOf('hour') .subtract(2, 'minutes') .format('MMM D, YYYY h:mm:ss A') ); summaryEl.should.not.have.descendant('.close-time'); summaryEl.should.not.have.descendant('.pending-activities'); summaryEl.should.not.have.descendant('.parent-workflow'); summaryEl .querySelector('.cron-schedule dd') .should.have.text('30 * * * *'); summaryEl .querySelector('.workflow-status dd') .should.contain.text('running'); summaryEl .querySelector('.workflow-status loader.bar') .should.not.have.property('display', 'none'); await waitUntilCompleted(summaryEl); }); }); describe('Summary', () => { it('should show the input of the workflow, and any pending events', async function test() { const [summaryEl] = await summaryTest(this.test, { execution: { pendingActivities: [ { activityId: 4, status: 'STARTED', }, { activityId: 5, status: 'QUEUED', }, ], }, }); summaryEl .querySelectorAll('.pending-activities dl.details') .should.have.length(2); summaryEl .textNodes('.pending-activities dt') .should.deep.equal([ 'Pending Activities', 'activityId', 'status', 'activityId', 'status', ]); summaryEl .textNodes('.pending-activities > dd:first-of-type dd') .should.deep.equal(['4', 'STARTED']); summaryEl .textNodes('.pending-activities > dd:nth-of-type(2) dd') .should.deep.equal(['5', 'QUEUED']); summaryEl .querySelector('.workflow-input pre') .should.have.text(JSON.stringify([839134, { env: 'prod' }], null, 2)); }); it('should show a full screen view option for input that overflows the area', async function test() { const input = { foo: 1, bar: 'a', baz: new Array(100).fill('aa').join('|'), }; const [summaryEl, scenario] = await summaryTest(this.test, { attach: true, events: [ { eventId: 1, eventType: 'WorkflowExecutionStarted', details: { type: { name: 'ci-input-overflow-test', }, execution: {}, input, }, timestamp: new Date().toISOString(), }, ], }); const inputDataView = await summaryEl.waitUntilExists( '.workflow-input .data-viewer' ); inputDataView.should.have .class('overflow') .and.have.descendant('a.view-full-screen').and.be.displayed; inputDataView.querySelector('a.view-full-screen').trigger('click'); const modal = await scenario.vm.$el.waitUntilExists( '[data-modal="data-viewer-fullscreen"]' ); await retry(() => { modal.should.have .descendant('h3') .with.text('email-daily-summaries Input'); modal.should .contain('a.copy') .and.contain('a.close') .and.have.descendant('pre.language-json') .with.text(JSON.stringify(input, null, 2)); }); }); it('should link to the new workflow if the status is ContinuedAsNew', async function test() { const [summaryEl] = await summaryTest(this.test, { attach: true, events: [ { eventId: 1, eventType: 'WorkflowExecutionStarted', details: { type: { name: 'ci-input-overflow-test', }, execution: {}, input: { greet: 'hello' }, }, timestamp: new Date().toISOString(), }, { timestamp: '2018-08-16T01:00:01.582Z', eventType: 'WorkflowExecutionContinuedAsNew', eventId: 42, details: { newExecutionRunId: '617d8b6f-ea42-479c-bc7c-0ec4dacddf64', workflowType: { name: 'code.uber.internal/marketplace/dsp-scheduler/scheduler/workflow.CTBWorkflow', }, taskList: { name: 'ctb-decider', kind: null, }, input: { CityID: 240, CurTime: '', BudgetGroup: 'budget_group_1', }, executionStartToCloseTimeoutSeconds: 2604800, taskStartToCloseTimeoutSeconds: 300, decisionTaskCompletedEventId: 41, }, }, ], }); const wfStatusEl = await summaryEl.waitUntilExists( '.workflow-status[data-status="continued-as-new"]' ); wfStatusEl.should.contain .descendant('dd a') .and.have.text('Continued As New') .and.have.attr( 'href', '/domains/ci-test/workflows/email-daily-summaries/617d8b6f-ea42-479c-bc7c-0ec4dacddf64/summary' ); }); it('should show the result of the workflow if completed', async function test() { const [summaryEl] = await summaryTest(this.test); const resultsEl = await summaryEl.waitUntilExists('.workflow-result pre'); const historyFixture = getFixture('history.emailRun1'); JSON.parse(resultsEl.textContent).should.deep.equal( historyFixture[historyFixture.length - 1].details.result ); resultsEl .textNodes('.token.string') .should.deep.equal([ '"bob@example.com"', '"jane@example.com"', '"foobarbaz"', ]); }); it('should show the failure result from a failed workflow', async function test() { const [summaryEl] = await summaryTest(this.test, { events: getFixture('history.exampleTimeout'), }); const resultsEl = await summaryEl.waitUntilExists('.workflow-result pre'); JSON.parse(resultsEl.textContent).should.deep.equal({ reason: 'activityTimeout', activityId: 0, }); }); it('should have a link to a parent workflow if applicable', async function test() { const [summaryEl] = await summaryTest(this.test, { events: [ { eventType: 'WorkflowExecutionStarted', timestamp: moment().toISOString(), eventId: 1, details: { workflowType: { name: 'com.github/uber/ci-test-parent' }, parentWorkflowDomain: 'another-domain', parentWorkflowExecution: { workflowId: 'the-parent-wfid', runId: '1234', }, }, }, { eventId: 1, eventType: 'DecisionTaskScheduled', timestamp: moment().toISOString(), }, ], }); const parentWf = await summaryEl.waitUntilExists('.parent-workflow'); parentWf .querySelector('dd a') .should.contain.text('the-parent-wfid') .and.have.attr( 'href', '/domains/another-domain/workflows/the-parent-wfid/1234/summary' ); }); describe('Actions', () => { it('should offer the user to terminate a running workflow, prompting the user for a termination reason', async function test() { const [summaryEl] = await summaryTest( this.test, { history: { delay: 500 }, }, true ); const terminateEl = await summaryEl.waitUntilExists( 'aside.actions button' ); await retry(() => terminateEl.should.not.have.attr('disabled')); terminateEl.trigger('click'); const confirmTerminateEl = await summaryEl.waitUntilExists( '[data-modal="confirm-termination"]' ); await Promise.delay(50); confirmTerminateEl.should.contain.text( 'Are you sure you want to terminate this workflow?' ); confirmTerminateEl.should .contain('button[name="button-terminate"]') .and.contain('button[name="button-cancel"]') .and.contain('input[placeholder="Reason"]'); await waitUntilCompleted(summaryEl); }); it('should terminate the workflow with the provided reason', async function test() { const [summaryEl, scenario] = await summaryTest( this.test, { history: { delay: 250 }, }, true ); const terminateEl = await summaryEl.waitUntilExists( 'aside.actions button' ); await retry(() => terminateEl.should.not.have.attr('disabled')); terminateEl.trigger('click'); const confirmTerminateEl = await summaryEl.waitUntilExists( '[data-modal="confirm-termination"]' ); const reasonEl = confirmTerminateEl.querySelector('input'); reasonEl.value = 'example termination'; reasonEl.trigger('input'); await Promise.delay(10); scenario.withWorkflowTermination('example termination'); confirmTerminateEl .querySelector('button[name="button-terminate"]') .trigger('click'); await retry(() => summaryEl.should.not.contain('[data-modal="confirm-termination"]') ); await waitUntilCompleted(summaryEl); }); it('should terminate the workflow without a reason', async function test() { const [summaryEl, scenario] = await summaryTest( this.test, { history: { delay: 250 }, }, true ); const terminateEl = await summaryEl.waitUntilExists( 'aside.actions button' ); await retry(() => terminateEl.should.not.have.attr('disabled')); terminateEl.trigger('click'); const terminateConfirmEl = await summaryEl.waitUntilExists( '[data-modal="confirm-termination"] button[name="button-terminate"]' ); scenario.withWorkflowTermination(); terminateConfirmEl.trigger('click'); await retry(() => summaryEl.should.not.contain('[data-modal="confirm-termination"]') ); await waitUntilCompleted(summaryEl); }); it('should allow the user to cancel the termination prompt, doing nothing', async function test() { const [summaryEl] = await summaryTest( this.test, { history: { delay: 250 }, }, true ); const terminateEl = await summaryEl.waitUntilExists( 'aside.actions button' ); await retry(() => terminateEl.should.not.have.attr('disabled')); terminateEl.trigger('click'); const cancelDialog = await summaryEl.waitUntilExists( '[data-modal="confirm-termination"] button[name="button-cancel"]' ); cancelDialog.trigger('click'); await retry(() => summaryEl.should.not.contain('[data-modal="confirm-termination"]') ); await waitUntilCompleted(summaryEl); }); it('should not offer the user the ability to terminate completed workflows', async function test() { const [summaryEl] = await summaryTest(this.test, { execution: closedWorkflowExecution, history: { delay: 250 }, }); await retry(() => summaryEl.should.have .descendant('.workflow-status dd') .and.have.trimmed.text('completed') ); summaryEl.should.have.descendant('aside.actions button.disabled'); }); }); }); describe('History', () => { async function historyTest(mochaTest, o) { const [scenario, opts] = workflowTest(mochaTest, { view: 'history', ...o, }); scenario.withFullHistory(opts.events).withExportHistory(opts.events); const historyEl = await scenario .render(opts.attach) .waitUntilExists('section.execution.ready'); return [historyEl, scenario, opts]; } it('should pick default view format from localstorage if exists ', async function test() { localStorage.setItem('ci-test:history-viewing-format', 'json'); const [historyEl] = await historyTest(this.test); historyEl .querySelector('.view-formats .json') .should.have.class('active'); }); it('should set default view format to compact if no persisted selection in localstorage', async function test() { localStorage.removeItem('ci-test:history-viewing-format'); const [historyEl] = await historyTest(this.test); historyEl .querySelector('.view-formats .compact') .should.have.class('active'); }); it('should allow the user to change the view format', async function test() { const [historyEl, scenario] = await historyTest(this.test); await historyEl.waitUntilExists('section.results'); const resultsEl = historyEl.querySelector('section.results'); historyEl.querySelector('.view-formats a.grid').trigger('click'); await retry(() => resultsEl.querySelectorAll('.tr').should.have.length(6) ); resultsEl.should.not.have.descendant('pre.json'); resultsEl.should.not.have.descendant('.compact-view'); historyEl.querySelector('.view-formats a.compact').trigger('click'); await retry(() => historyEl .querySelectorAll('.compact-view .timeline-event') .should.have.length(2) ); resultsEl.should.not.have.descendant('pre.json'); resultsEl.should.not.have.descendant('table'); scenario.location.should.equal( '/domains/ci-test/workflows/email-daily-summaries/emailRun1/history?format=compact' ); historyEl.querySelector('.view-formats a.json').trigger('click'); const jsonView = await resultsEl.waitUntilExists( 'pre.json.language-json' ); jsonView.should.contain.text('"eventId":'); resultsEl.should.not.have.descendant('.compact-view'); scenario.location.should.equal( '/domains/ci-test/workflows/email-daily-summaries/emailRun1/history?format=json' ); }); it('should allow downloading the full history via export', async function test() { const [, scenario, opts] = await historyTest(this.test); const exportEl = await scenario.vm.$el.waitUntilExists( 'section.history .controls a.export' ); const downloadLink = 'javascript:void(0);'; //prevent createing a redirectable link chai.spy.on(window.URL, 'createObjectURL', () => downloadLink); exportEl.trigger('click'); await retry(() => { window.URL.createObjectURL.should.have.been.called(); exportEl.should.have.attr('href', downloadLink); exportEl.should.have.attr( 'download', 'email daily summaries - emailRun1.json' ); }); }); describe('Compact View', function describeTest() { let prevPollInterval; let prevRetryAttempts; before(() => { prevPollInterval = window.retry.pollInterval; prevRetryAttempts = window.retry.retryAttempts; window.retry.pollInterval = 20; window.retry.retryAttempts = 200; }); after(() => { window.retry.pollInterval = prevPollInterval; window.retry.retryAttempts = prevRetryAttempts; }); async function compactViewTest(mochaTest) { const [summaryEl, scenario] = await historyTest(mochaTest, { events: getFixture('history.timelineVariety'), query: 'format=compact&graphView=timeline', attach: true, }); const timelineEl = await summaryEl.waitUntilExists( '.timeline-split div.timeline' ); await retry(() => timelineEl.timeline.fit.should.be.instanceof(Function) ); return [ timelineEl, summaryEl.querySelector('.results .compact-view'), scenario, ]; } it('should build timeline events from granular event history', async function test() { const [, compactViewEl] = await compactViewTest(this.test); await retry(() => compactViewEl .querySelectorAll('.timeline-event') .should.have.length(8) ); compactViewEl .querySelectorAll('.timeline-event.activity') .should.have.length(2); compactViewEl .querySelectorAll('.timeline-event.activity.completed') .should.have.length(1); compactViewEl .querySelectorAll('.timeline-event.activity.failed') .should.have.length(1); }); it('should also populate the timeline with those events', async function test() { this.retries(3); // flakey on mocha-chrome but not normal, windowed Chrome const [timelineEl] = await compactViewTest(this.test); await Promise.delay(50); timelineEl.timeline.fit(); await retry(() => timelineEl .querySelectorAll('.vis-box, .vis-range') .should.have.length(8) ); timelineEl .querySelectorAll('.vis-range.activity') .should.have.length(2); timelineEl .querySelectorAll('.vis-range.activity.completed') .should.have.length(1); timelineEl .querySelectorAll('.vis-range.activity.failed') .should.have.length(1); timelineEl.querySelectorAll('.vis-box.marker').should.have.length(4); timelineEl .querySelectorAll('.vis-box.marker.marker-version') .should.have.length(1); timelineEl .querySelectorAll('.vis-box.marker.marker-sideeffect') .should.have.length(1); timelineEl .querySelectorAll('.vis-box.marker.marker-localactivity') .should.have.length(2); }); it('should focus the timeline when an event is clicked, updating the URL and zooming in', async function test() { const [timelineEl, compactViewEl, scenario] = await compactViewTest( this.test ); await Promise.delay(50); timelineEl.timeline.fit(); scenario.location.should.equal( '/domains/ci-test/workflows/email-daily-summaries/emailRun1/history?format=compact&graphView=timeline' ); await retry(() => timelineEl .querySelectorAll('.vis-range.activity.failed') .should.have.length(1) ); timelineEl .querySelector('.vis-range.activity.failed') .should.not.have.class('vis-selected'); const failedActivity = await compactViewEl.waitUntilExists( '.timeline-event.activity.failed' ); failedActivity.trigger('click'); await retry(() => { scenario.location.should.equal( '/domains/ci-test/workflows/email-daily-summaries/emailRun1/history?format=compact&graphView=timeline&eventId=16' ); timelineEl .querySelector('.vis-range.activity.failed') .should.have.class('vis-selected'); Number( timelineEl .querySelector('.vis-range.activity.completed') .style.transform.match(/[-0-9]+/)[0] ).should.be.below(0); }); }); // TODO: need to investigate how to trigger the events needed to simulate a click for the timeline - looks like it uses Hammer.js and listens to PointerEvents // eslint-disable-next-line jest/no-commented-out-tests /* it.skip('should scroll the event into view if an event is clicked from the timeline, updating the URL', async function test() { const [timelineEl, , scenario] = await compactViewTest(this.test); timelineEl.timeline.fit(); scenario.location.should.equal( '/domains/ci-test/workflows/email-daily-summaries/emailRun1/history?format=compact' ); const failedActivity = await timelineEl.waitUntilExists( '.vis-range.activity.failed' ); failedActivity.trigger('select'); await retry(() => scenario.location.should.equal( '/domains/ci-test/workflows/email-daily-summaries/emailRun1/history?format=compact&eventId=16' ) ); }); */ it('should show event details when an event is clicked', async function test() { const [timelineEl, compactViewEl, scenario] = await compactViewTest( this.test ); compactViewEl.querySelectorAll('.selected-event-detail').should.be .empty; const childWf = await compactViewEl.waitUntilExists( '.timeline-event.child-workflow.completed ' ); childWf.trigger('click'); await retry(() => { compactViewEl .querySelector('.selected-event-detail') .should.have.class('active'); scenario.location.should.equal( '/domains/ci-test/workflows/email-daily-summaries/emailRun1/history?format=compact&graphView=timeline&eventId=18' ); timelineEl .querySelector('.vis-range.child-workflow.completed') .should.have.class('vis-selected'); compactViewEl .querySelector('.timeline-event.child-workflow.completed') .should.have.class('vis-selected'); }); }); it('should show event details on initial load, and allow dismissal', async function test() { const [summaryEl] = await historyTest(this.test, { events: getFixture('history.timelineVariety'), query: 'format=compact&eventId=8', attach: true, }); await summaryEl.waitUntilExists('.results .compact-view'); const compactViewEl = summaryEl.querySelector('.results .compact-view'); await retry(() => { compactViewEl .querySelector('.selected-event-detail') .should.have.class('active'); compactViewEl .querySelector('.timeline-event.marker-sideeffect') .should.have.class('vis-selected'); }); }); }); describe('Grid View', () => { async function gridViewTest(mochaTest, o) { const [summaryEl, scenario] = await historyTest(mochaTest, { query: 'format=grid', ...(o || {}), }); return [summaryEl, scenario]; } it('should show full results in a grid', async function test() { return gridViewTest(this.test).then(async ([historyEl]) => { await historyEl.waitUntilExists( '.results .table .vue-recycle-scroller__item-view:nth-child(5) .tr' ); historyEl .textNodes('.table .vue-recycle-scroller__item-view .td.col-id') .length.should.be.lessThan(12); const textNodes = historyEl .textNodes('.table .thead .th') .slice(0, 2); textNodes[0].should.equal('ID'); textNodes[1].should.include('Type'); //test id column exists in correct format await retry(() => historyEl .textNodes('.table .vue-recycle-scroller__item-view .td.col-id') .should.deep.equal( new Array(6).fill('').map((_, i) => String(i + 1)) ) ); // test time column exists in correct format await retry(() => historyEl .textNodes('.table .vue-recycle-scroller__item-view .td.col-time') .should.deep.equal( getFixture('history.emailRun1') .filter((_value, index) => index < 6) .map(e => moment(e.timestamp).format('MMM D, YYYY h:mm:ss A')) ) ); // test type column exists wirth correct values historyEl .textNodes('.table .vue-recycle-scroller__item-view .td.col-type') .slice(0, 3) .should.deep.equal([ 'WorkflowExecutionStarted', 'DecisionTaskScheduled', 'DecisionTaskStarted', ]); // test elapsed time exists with correct values historyEl .textNodes( '.table .vue-recycle-scroller__item-view .td.col-elapsed-time' ) .should.deep.equal([ moment(getFixture('history.emailRun1')[0].timestamp).format( 'MMM D, YYYY h:mm:ss A' ), '', '', '1s (+1s)', '2s (+1s)', '3s (+1s)', ]); }); }); it('should show details as flattened key-value pairs from parsed json, except for result and input', async function test() { const [historyEl] = await gridViewTest(this.test); const startDetails = await historyEl.waitUntilExists( '.results .tr:first-child .td:nth-child(5)' ); const inputPreText = JSON.stringify( getFixture('history.emailRun1')[0].details.input, null, 2 ); startDetails .textNodes('dl.details dt') .should.deep.equal([ 'cronSchedule', 'workflowType.name', 'taskList.name', 'input', 'executionStartToCloseTimeout', 'taskStartToCloseTimeout', ]); startDetails .textNodes('dl.details dd') .should.deep.equal([ '30 * * * *', 'email-daily-summaries', 'ci-task-queue', inputPreText, '6m', '3m', ]); }); it('should show a full screen view option for large JSON fields, and allow copying it', async function test() { const input = { foo: 1, bar: 'a', baz: new Array(100).fill('aa').join('|'), }; const [historyEl, scenario] = await gridViewTest(this.test, { attach: true, events: [ { eventId: 1, eventType: 'WorkflowExecutionStarted', details: { type: { name: 'ci-input-overflow-test', }, execution: {}, input, }, timestamp: new Date().toISOString(), }, ], }); const viewFullScreen = await historyEl.waitUntilExists( '.results .td:nth-child(5) .data-viewer.overflow a.view-full-screen' ); viewFullScreen.trigger('click'); const modal = await scenario.vm.$el.waitUntilExists( '[data-modal="data-viewer-fullscreen"]' ); await retry(() => { modal.should.have .descendant('h3') .with.text('Event #1 WorkflowExecutionStarted - input'); modal.should.have .descendant('pre.language-json') .with.text(JSON.stringify(input, null, 2)); }); modal.querySelector('a.copy').trigger('click'); window.Mocha.copiedText.should.equal(JSON.stringify(input, null, 2)); }); it('should allow toggling of the details column between summary and full details', async function test() { const [historyEl] = await gridViewTest(this.test); await historyEl.waitUntilExists( '.results .vue-recycle-scroller__item-view:nth-child(5) .tr' ); const [summaryEl, fullDetailsEl] = historyEl.querySelectorAll( '.thead .th:nth-child(5) a' ); summaryEl.should.have.text('Summary').and.have.attr('href', '#'); fullDetailsEl.should.have .text('Full Details') .and.not.have.attr('href'); summaryEl.trigger('click'); await retry(() => { historyEl .textNodes( '.results .vue-recycle-scroller__item-view:first-child .tr .td.col-summary dl.details dt' ) .should.deep.equal(['Close Timeout', 'input', 'Workflow']); const ddTextNodes = historyEl.textNodes( '.results .vue-recycle-scroller__item-view:first-child .tr .td.col-summary dl.details dd' ); ddTextNodes[0].should.equal('6m'); ddTextNodes[1].should.equalIgnoreSpaces( JSON.stringify(getFixture('history.emailRun1')[0].details.input) ); ddTextNodes[2].should.equal('email-daily-summaries'); }); localStorage .getItem('ci-test:history-compact-details') .should.equal('true'); }); it('should use the details format from local storage if available', async function test() { localStorage.setItem('ci-test:history-compact-details', 'true'); const [historyEl] = await gridViewTest(this.test); await retry(() => historyEl .textNodes( '.results .vue-recycle-scroller__item-view:first-child .tr .td:nth-child(5) dl.details dt' ) .should.deep.equal(['Close Timeout', 'input', 'Workflow']) ); }); it('should specially handle MarkerRecorded events', async function test() { const [historyEl] = await gridViewTest(this.test, { events: [ { eventId: 1, eventType: 'WorkflowExecutionStarted', details: { workflowType: { name: 'ci-input-overflow-test', }, execution: {}, }, timestamp: new Date().toISOString(), }, { eventId: 2, eventType: 'MarkerRecorded', details: { markerName: 'Version', details: ['initial version', 0], }, timestamp: new Date().toISOString(), }, { eventId: 3, eventType: 'MarkerRecorded', details: { markerName: 'SideEffect', details: [0, btoa(JSON.stringify({ foo: 'bar' }))], }, timestamp: new Date().toISOString(), }, { eventId: 4, eventType: 'MarkerRecorded', details: { markerName: 'LocalActivity', details: { ActivityID: 2, ErrJSON: JSON.stringify({ err: 'in json' }), ErrReason: 'string error reason', ResultJSON: JSON.stringify({ result: 'in json' }), }, }, timestamp: new Date().toISOString(), }, ], }); await historyEl.waitUntilExists( '.results .vue-recycle-scroller__item-view:nth-child(4) .tr' ); historyEl .textNodes( '.vue-recycle-scroller__item-view:not(:first-child) .tr dl.details dt' ) .should.deep.equal([ 'markerName', 'details', 'sideEffectID', 'data', 'markerName', 'details', ]); historyEl .querySelector( '.vue-recycle-scroller__item-view:nth-child(3) .tr [data-prop="data"] dd' ) .should.have.trimmed.text(JSON.stringify({ foo: 'bar' }, null, 2)); historyEl.querySelector('.thead a.summary').trigger('click'); await Promise.delay(50); historyEl .textNodes( '.vue-recycle-scroller__item-view:not(:first-child) .tr dl.details dt' ) .should.deep.equal([ 'Details', 'Version', 'data', 'Side Effect ID', 'Local Activity ID', 'Error', 'reason', 'result', ]); const ddTextNodes = historyEl.textNodes( '.vue-recycle-scroller__item-view:not(:first-child) .tr dl.details dd' ); ddTextNodes[0].should.equal('initial version'); ddTextNodes[1].should.equal('0'); ddTextNodes[2].should.equalIgnoreSpaces('{"foo":"bar"}'); ddTextNodes[3].should.equal('0'); ddTextNodes[4].should.equal('2'); ddTextNodes[5].should.equalIgnoreSpaces('{"err":"in json"}'); ddTextNodes[6].should.equal('string error reason'); ddTextNodes[7].should.equalIgnoreSpaces('{"result":"in json"}'); }); it('should render event inputs as highlighted json', async function test() { const [historyEl] = await gridViewTest(this.test); const startDetails = await historyEl.waitUntilExists( '.results .tr:first-child .td:nth-child(5)' ); const inputPreText = JSON.stringify( getFixture('history.emailRun1')[0].details.input, null, 2 ); startDetails .textNodes('dl.details dd pre') .should.deep.equal([inputPreText]); startDetails .textNodes('dl.details dd pre .token') .should.have.length(9); }); it('should link to child workflows, and load its history when navigated too', async function test() { const [historyEl] = await gridViewTest(this.test, { events: [ { eventType: 'WorkflowExecutionStarted', timestamp: moment().toISOString(), eventId: 1, details: { workflowType: { name: 'com.github/uber/ci-test-parent' }, }, }, { eventId: 1, eventType: 'ChildWorkflowExecutionInitiated', timestamp: moment().toISOString(), details: { domain: 'child-domain', workflowExecution: { workflowId: 'child-wfid', runId: '2345', }, workflowType: { name: 'some-child-workflow' }, }, }, ], }); const childStartDetails = await historyEl.waitUntilExists( '.results .vue-recycle-scroller__item-view:nth-child(1) .tr .td:nth-child(5)' ); childStartDetails .querySelector('[data-prop="workflowExecution.runId"] dd a') .should.have.text('2345') .and.have.attr( 'href', '/domains/child-domain/workflows/child-wfid/2345/summary' ); }); }); }); describe('Stack Trace', () => { before(() => { MockDate.set(new Date()); }); after(() => { MockDate.reset(); }); it('should also show a stack trace tab for running workflows', async function test() { const [, scenario] = await summaryTest(this.test); scenario.vm.$el .attrValues('section.execution > nav a', 'href') .should.deep.equal([ '/domains/ci-test/workflows/email-daily-summaries/emailRun1/summary', '/domains/ci-test/workflows/email-daily-summaries/emailRun1/history', '/domains/ci-test/workflows/email-daily-summaries/emailRun1/pending', '/domains/ci-test/workflows/email-daily-summaries/emailRun1/stack-trace', '/domains/ci-test/workflows/email-daily-summaries/emailRun1/query', ]); scenario.vm.$el .querySelector('section.execution > nav a#nav-link-stack-trace') .should.not.have.property('display', 'none'); scenario.vm.$el .querySelector('section.execution > nav a#nav-link-query') .should.not.have.property('display', 'none'); }); it('should show the current stack trace', async function test() { const [scenario] = workflowTest(this.test, { view: 'stack-trace', }); scenario .withFullHistory() .api.postOnce(`${scenario.execApiBase()}/query/__stack_trace`, { queryResult: 'goroutine 1:\n\tat foo.go:56', }); const stackTraceTime = moment().format('MMM D, YYYY h:mm:ss A'); const stackTraceEl = await scenario .render() .waitUntilExists('section.execution.ready section.stack-trace'); await retry(() => stackTraceEl .querySelector('header span') .should.contain.text(`Stack trace at ${stackTraceTime}`) ); stackTraceEl .querySelector('pre') .should.have.text('goroutine 1:\n\tat foo.go:56'); }); it('should allow the user to refresh the stack trace', async function test() { const [scenario] = workflowTest(this.test, { view: 'stack-trace', }); let called = 0; scenario .withFullHistory() .api.post(`${scenario.execApiBase()}/query/__stack_trace`, () => { // eslint-disable-next-line no-plusplus if (++called === 1) { return { queryResult: 'goroutine 1:\n\tat foo.go:56' }; } if (called === 2) { return { queryResult: 'goroutine 1:\n\tat foo.go:56\n\n\tgoroutine 2:\n\tat bar.go:42', }; } throw new Error( `stack trace query API was called too many times (${called})` ); }); const stackTraceEl = await scenario .render() .waitUntilExists('section.execution.ready section.stack-trace'); await retry(() => stackTraceEl .querySelector('pre') .should.have.text('goroutine 1:\n\tat foo.go:56') ); stackTraceEl.querySelector('a.refresh').trigger('click'); await retry(() => stackTraceEl .querySelector('pre') .should.have.text( 'goroutine 1:\n\tat foo.go:56\n\n\tgoroutine 2:\n\tat bar.go:42' ) ); }); }); describe('Query', () => { async function queryTest(mochaTest, query) { const [scenario] = workflowTest(mochaTest, { view: 'query' }); scenario.withHistory(getFixture('history.emailRun1')).withQuery(query); const queryEl = await scenario .render() .waitUntilExists('section.execution.ready section.query'); return [queryEl, scenario]; } it('should query the list of stack traces, and show it in the dropdown, enabling run as appropriate', async function test() { const [queryEl] = await queryTest(this.test, [ '__stack_trace', 'foo', 'bar', ]); const queryDropdown = await queryEl.waitUntilExists( '.query-name .select-input' ); const options = await queryDropdown.selectOptions(); options.should.deep.equal(['foo', 'bar']); }); it('should show an error if query could not be listed', async function test() { const [queryEl] = await queryTest(this.test, { status: 400, body: { message: 'I do not understand' }, }); await retry(() => queryEl .querySelector('span.error') .should.have.text('I do not understand') ); queryEl.should.not.contain('header .query-name'); }); it('should run a query and show the result', async function test() { const [queryEl, scenario] = await queryTest(this.test); await queryEl.waitUntilExists('.query-name .select-input'); const runButton = queryEl.querySelector('a.run'); await retry(() => runButton.should.have.attr('href', '#')); scenario.withQueryResult('status', 'All is good!'); runButton.trigger('click'); await retry(() => queryEl.querySelector('pre').should.have.text('All is good!') ); }); it('should show an error if there was an error running the query', async function test() { const [queryEl, scenario] = await queryTest(this.test); await queryEl.waitUntilExists('.query-name .select-input'); const runButton = queryEl.querySelector('a.run'); await retry(() => runButton.should.have.attr('href', '#')); scenario.withQueryResult('status', { status: 503, body: { message: 'Server Unavailable' }, }); runButton.trigger('click'); await retry(() => queryEl .querySelector('span.error') .should.have.text('Server Unavailable') ); queryEl.should.not.have.descendant('pre'); }); }); });