import {mount, flushPromises} from '@vue/test-utils'; import {toAbsoluteUrl} from '../utils.js'; import RepoActionView from './RepoActionView.vue'; const testLocale = { approve: 'Locale Approve', cancel: 'Locale Cancel', rerun: 'Locale Re-run', artifactsTitle: 'artifactTitleHere', areYouSure: '', confirmDeleteArtifact: '', rerun_all: '', showTimeStamps: '', showLogSeconds: '', showFullScreen: '', downloadLogs: '', runAttemptLabel: 'Run attempt %[1]s %[2]s', viewingOutOfDateRun: 'oh no, out of date since %[1]s give or take or so', viewMostRecentRun: '', status: { unknown: '', waiting: '', running: '', success: '', failure: '', cancelled: '', skipped: '', blocked: '', }, }; const minimalInitialJobData = { state: { run: { status: 'success', commit: { pusher: {}, }, }, currentJob: { steps: [ { summary: 'Test Job', duration: '1s', status: 'success', }, ], }, }, logs: { stepsLog: [], }, }; const minimalInitialArtifactData = { artifacts: [], }; test('processes ##[group] and ##[endgroup]', async () => { Object.defineProperty(document.documentElement, 'lang', {value: 'en'}); vi.spyOn(global, 'fetch').mockImplementation((url, opts) => { const artifacts_value = { artifacts: [], }; const stepsLog_value = [ { step: 0, cursor: 0, lines: [ {index: 1, message: '##[group]Test group', timestamp: 0}, {index: 2, message: 'A test line', timestamp: 0}, {index: 3, message: '##[endgroup]', timestamp: 0}, {index: 4, message: 'A line outside the group', timestamp: 0}, ], }, ]; const jobs_value = { state: { run: { status: 'success', commit: { pusher: {}, }, }, currentJob: { steps: [ { summary: 'Test Job', duration: '1s', status: 'success', }, ], allAttempts: [{number: 1, time_since_started_html: '', status: 'success'}], }, }, logs: { stepsLog: opts.body?.includes('"cursor":null') ? stepsLog_value : [], }, }; return Promise.resolve({ ok: true, json: vi.fn().mockResolvedValue( url.endsWith('/artifacts') ? artifacts_value : jobs_value, ), }); }); const wrapper = mount(RepoActionView, { props: { jobIndex: '1', attemptNumber: '1', initialJobData: minimalInitialJobData, initialArtifactData: minimalInitialArtifactData, locale: testLocale, }, }); await flushPromises(); await wrapper.get('.job-step-summary').trigger('click'); await flushPromises(); // Test if header was loaded correctly expect(wrapper.get('.step-summary-msg').text()).toEqual('Test Job'); // Check if 3 lines where rendered expect(wrapper.findAll('.job-log-line').length).toEqual(3); // Check if line 1 contains the group header expect(wrapper.get('.job-log-line:nth-of-type(1) > details.log-msg').text()).toEqual('Test group'); // Check if right after the header line exists a log list expect(wrapper.find('.job-log-line:nth-of-type(1) + .job-log-list.hidden').exists()).toBe(true); // Check if inside the loglist exist exactly one log line expect(wrapper.findAll('.job-log-list > .job-log-line').length).toEqual(1); // Check if inside the loglist is an logline with our second logline expect(wrapper.get('.job-log-list > .job-log-line > .log-msg').text()).toEqual('A test line'); // Check if after the log list exists another log line expect(wrapper.get('.job-log-list + .job-log-line > .log-msg').text()).toEqual('A line outside the group'); }); test('load multiple steps on a finished action', async () => { Object.defineProperty(document.documentElement, 'lang', {value: 'en'}); vi.spyOn(global, 'fetch').mockImplementation((url, opts) => { if (url.endsWith('/artifacts')) { return Promise.resolve({ ok: true, json: vi.fn().mockResolvedValue( { artifacts: [], }, ), }); } const postBody = JSON.parse(opts.body); const stepsLog_value = []; for (const cursor of postBody.logCursors) { if (cursor.expanded) { stepsLog_value.push( { step: cursor.step, cursor: 0, lines: [ {index: 1, message: `Step #${cursor.step + 1} Log #1`, timestamp: 0}, {index: 1, message: `Step #${cursor.step + 1} Log #2`, timestamp: 0}, {index: 1, message: `Step #${cursor.step + 1} Log #3`, timestamp: 0}, ], }, ); } } const jobs_value = { state: { run: { status: 'success', commit: { pusher: {}, }, }, currentJob: { steps: [ { summary: 'Test Step #1', duration: '1s', status: 'success', }, { summary: 'Test Step #2', duration: '1s', status: 'success', }, ], allAttempts: [{number: 1, time_since_started_html: '', status: 'success'}], }, }, logs: { stepsLog: opts.body?.includes('"cursor":null') ? stepsLog_value : [], }, }; return Promise.resolve({ ok: true, json: vi.fn().mockResolvedValue( jobs_value, ), }); }); const wrapper = mount(RepoActionView, { props: { actionsURL: 'https://example.com/example-org/example-repo/actions', initialJobData: minimalInitialJobData, initialArtifactData: minimalInitialArtifactData, runIndex: '1', jobIndex: '2', attemptNumber: '1', locale: testLocale, }, }); wrapper.vm.loadJob(); // simulate intermittent reload immediately so UI switches from minimalInitialJobData to the mock data from the test's fetch spy. await flushPromises(); // Click on both steps to start their log loading in fast succession... await wrapper.get('.job-step-section:nth-of-type(1) .job-step-summary').trigger('click'); await wrapper.get('.job-step-section:nth-of-type(2) .job-step-summary').trigger('click'); await flushPromises(); // Verify both step's logs were loaded expect(wrapper.get('.job-step-section:nth-of-type(1) .job-log-line:nth-of-type(1) .log-msg').text()).toEqual('Step #1 Log #1'); expect(wrapper.get('.job-step-section:nth-of-type(1) .job-log-line:nth-of-type(2) .log-msg').text()).toEqual('Step #1 Log #2'); expect(wrapper.get('.job-step-section:nth-of-type(1) .job-log-line:nth-of-type(3) .log-msg').text()).toEqual('Step #1 Log #3'); expect(wrapper.get('.job-step-section:nth-of-type(2) .job-log-line:nth-of-type(1) .log-msg').text()).toEqual('Step #2 Log #1'); expect(wrapper.get('.job-step-section:nth-of-type(2) .job-log-line:nth-of-type(2) .log-msg').text()).toEqual('Step #2 Log #2'); expect(wrapper.get('.job-step-section:nth-of-type(2) .job-log-line:nth-of-type(3) .log-msg').text()).toEqual('Step #2 Log #3'); }); function configureForMultipleAttemptTests({viewHistorical}) { Object.defineProperty(document.documentElement, 'lang', {value: 'en'}); const myJobState = { run: { canApprove: true, canCancel: true, canRerun: true, status: 'success', commit: { pusher: {}, }, }, currentJob: { steps: [ { summary: 'Test Job', duration: '1s', status: 'success', }, ], allAttempts: [ {number: 2, time_since_started_html: 'yesterday', status: 'success'}, {number: 1, time_since_started_html: 'two days ago', status: 'failure'}, ], }, }; vi.spyOn(global, 'fetch').mockImplementation((url, opts) => { const artifacts_value = { artifacts: [], }; const stepsLog_value = [ { step: 0, cursor: 0, lines: [], }, ]; const jobs_value = { state: myJobState, logs: { stepsLog: opts.body?.includes('"cursor":null') ? stepsLog_value : [], }, }; return Promise.resolve({ ok: true, json: vi.fn().mockResolvedValue( url.endsWith('/artifacts') ? artifacts_value : jobs_value, ), }); }); const wrapper = mount(RepoActionView, { props: { runIndex: '123', jobIndex: '1', attemptNumber: viewHistorical ? '1' : '2', actionsURL: toAbsoluteUrl('/user1/repo2/actions'), initialJobData: {...minimalInitialJobData, state: myJobState}, initialArtifactData: minimalInitialArtifactData, locale: testLocale, }, }); return wrapper; } test('display baseline with most-recent attempt', async () => { const wrapper = configureForMultipleAttemptTests({viewHistorical: false}); await flushPromises(); // Warning dialog for viewing an out-of-date attempt... expect(wrapper.findAll('.job-out-of-date-warning').length).toEqual(0); // Approve button should be visible; can't have all three at once but at least this verifies the inverse of the // historical attempt test below. expect(wrapper.findAll('button').filter((button) => button.text() === 'Locale Approve').length).toEqual(1); // Job list will be visible... expect(wrapper.findAll('.job-group-section').length).toEqual(1); // Attempt selector dropdown... expect(wrapper.findAll('.job-attempt-dropdown').length).toEqual(1); expect(wrapper.findAll('.job-attempt-dropdown .svg.octicon-check-circle-fill.text.green').length).toEqual(1); expect(wrapper.get('.job-attempt-dropdown .ui.dropdown').text()).toEqual('Run attempt 2 yesterday'); }); test('display reconfigured for historical attempt', async () => { const wrapper = configureForMultipleAttemptTests({viewHistorical: true}); await flushPromises(); // Warning dialog for viewing an out-of-date attempt... expect(wrapper.findAll('.job-out-of-date-warning').length).toEqual(1); expect(wrapper.get('.job-out-of-date-warning').text()).toEqual('oh no, out of date since two days ago give or take or so'); await wrapper.get('.job-out-of-date-warning button').trigger('click'); expect(window.location.href).toEqual(toAbsoluteUrl('/user1/repo2/actions/runs/123/jobs/1')); // eslint-disable-next-line no-restricted-globals history.back(); await flushPromises(); // Approve, Cancel, Re-run all buttons should all be suppressed... expect(wrapper.findAll('button').filter((button) => button.text() === 'Locale Approve').length).toEqual(0); expect(wrapper.findAll('button').filter((button) => button.text() === 'Locale Cancel').length).toEqual(0); expect(wrapper.findAll('button').filter((button) => button.text() === 'Locale Re-run').length).toEqual(0); // Job list will be suppressed... expect(wrapper.findAll('.job-group-section').length).toEqual(0); // Attempt selector dropdown... expect(wrapper.findAll('.job-attempt-dropdown').length).toEqual(1); expect(wrapper.findAll('.job-attempt-dropdown .svg.octicon-x-circle-fill.text.red').length).toEqual(1); expect(wrapper.get('.job-attempt-dropdown .ui.dropdown').text()).toEqual('Run attempt 1 two days ago'); }); test('historical attempt dropdown interactions', async () => { const wrapper = configureForMultipleAttemptTests({viewHistorical: true}); await flushPromises(); // Check dropdown exists, but isn't expanded. const attemptsNotExpanded = () => { expect(wrapper.findAll('.job-attempt-dropdown').length).toEqual(1); expect(wrapper.findAll('.job-attempt-dropdown .action-job-menu').length).toEqual(0, 'dropdown content not yet visible'); }; attemptsNotExpanded(); // Click on attempt dropdown wrapper.get('.job-attempt-dropdown .ui.dropdown').trigger('click'); await flushPromises(); // Check dropdown is expanded and both options are displayed const attemptsExpanded = () => { expect(wrapper.findAll('.job-attempt-dropdown .action-job-menu').length).toEqual(1); expect(wrapper.get('.job-attempt-dropdown .action-job-menu').isVisible()).toBe(true); expect(wrapper.findAll('.job-attempt-dropdown .action-job-menu a').filter((a) => a.text() === 'Run attempt 2 yesterday').length).toEqual(1); expect(wrapper.findAll('.job-attempt-dropdown .action-job-menu a').filter((a) => a.text() === 'Run attempt 1 two days ago').length).toEqual(1); }; attemptsExpanded(); // Normally dismiss occurs on a body click event; simulate that by calling `closeDropdown()` wrapper.vm.closeDropdown(); await flushPromises(); // Should return to not expanded. attemptsNotExpanded(); // Click on the gear dropdown wrapper.get('.job-gear-dropdown').trigger('click'); await flushPromises(); // Check that gear's menu is expanded, and attempt dropdown isn't. expect(wrapper.findAll('.job-gear-dropdown .action-job-menu').length).toEqual(1); expect(wrapper.get('.job-gear-dropdown .action-job-menu').isVisible()).toBe(true); attemptsNotExpanded(); // Click on attempt dropdown wrapper.get('.job-attempt-dropdown .ui.dropdown').trigger('click'); await flushPromises(); // Check that attempt dropdown expanded again, gear dropdown disappeared (mutually exclusive) expect(wrapper.findAll('.job-gear-dropdown .action-job-menu').length).toEqual(0); attemptsExpanded(); // Click on the other option in the dropdown to verify it navigates to the target attempt wrapper.findAll('.job-attempt-dropdown .action-job-menu a').find((a) => a.text() === 'Run attempt 2 yesterday').trigger('click'); expect(window.location.href).toEqual(toAbsoluteUrl('/user1/repo2/actions/runs/123/jobs/1/attempt/2')); }); test('artifacts download links', async () => { Object.defineProperty(document.documentElement, 'lang', {value: 'en'}); vi.spyOn(global, 'fetch').mockImplementation((url, opts) => { if (url.endsWith('/artifacts')) { return Promise.resolve({ ok: true, json: vi.fn().mockResolvedValue( { artifacts: [ {name: 'artifactname1', size: 111, status: 'completed'}, {name: 'artifactname2', size: 222, status: 'expired'}, ], }, ), }); } const postBody = JSON.parse(opts.body); const stepsLog_value = []; for (const cursor of postBody.logCursors) { if (cursor.expanded) { stepsLog_value.push( { step: cursor.step, cursor: 0, lines: [ {index: 1, message: `Step #${cursor.step + 1} Log #1`, timestamp: 0}, ], }, ); } } const jobs_value = { state: { run: { status: 'success', commit: { pusher: {}, }, }, currentJob: { steps: [ { summary: 'Test Step #1', duration: '1s', status: 'success', }, ], allAttempts: [{number: 1, time_since_started_html: '', status: 'success'}], }, }, logs: { stepsLog: opts.body?.includes('"cursor":null') ? stepsLog_value : [], }, }; return Promise.resolve({ ok: true, json: vi.fn().mockResolvedValue( jobs_value, ), }); }); const wrapper = mount(RepoActionView, { props: { actionsURL: 'https://example.com/example-org/example-repo/actions', initialJobData: minimalInitialJobData, initialArtifactData: minimalInitialArtifactData, runIndex: '10', runID: '1001', jobIndex: '2', attemptNumber: '1', locale: testLocale, }, }); wrapper.vm.loadJob(); // simulate intermittent reload immediately so UI switches from minimalInitialJobData to the mock data from the test's fetch spy. await flushPromises(); expect(wrapper.get('.job-artifacts .job-artifacts-title').text()).toEqual('artifactTitleHere'); expect(wrapper.get('.job-artifacts .job-artifacts-item:nth-of-type(1) .job-artifacts-link').attributes('href')).toEqual('https://example.com/example-org/example-repo/actions/runs/1001/artifacts/artifactname1'); expect(wrapper.get('.job-artifacts .job-artifacts-item:nth-of-type(2) .job-artifacts-link').attributes('href')).toEqual('https://example.com/example-org/example-repo/actions/runs/1001/artifacts/artifactname2'); }); test('initial load schedules refresh when job is not done', async () => { Object.defineProperty(document.documentElement, 'lang', {value: 'en'}); vi.spyOn(global, 'fetch').mockImplementation((url, _opts) => { return Promise.resolve({ ok: true, json: vi.fn().mockResolvedValue( url.endsWith('/artifacts') ? minimalInitialArtifactData : minimalInitialJobData, ), }); }); // Provide a job that is "done" so that the component doesn't start incremental refresh... { const doneInitialJobData = structuredClone(minimalInitialJobData); doneInitialJobData.state.run.done = true; const wrapper = mount(RepoActionView, { props: { jobIndex: '1', attemptNumber: '1', initialJobData: doneInitialJobData, initialArtifactData: minimalInitialArtifactData, locale: testLocale, }, }); await flushPromises(); const container = wrapper.find('.action-view-container'); expect(container.exists()).toBe(true); expect(container.classes()).not.toContain('interval-pending'); wrapper.unmount(); } // Provide a job that is *not* "done" so that the component does start incremental refresh... { const runningInitialJobData = structuredClone(minimalInitialJobData); runningInitialJobData.state.run.done = false; const wrapper = mount(RepoActionView, { props: { jobIndex: '1', attemptNumber: '1', initialJobData: runningInitialJobData, initialArtifactData: minimalInitialArtifactData, locale: testLocale, }, }); await flushPromises(); const container = wrapper.find('.action-view-container'); expect(container.exists()).toBe(true); expect(container.classes()).toContain('interval-pending'); wrapper.unmount(); } }); test('initial load data is used without calling fetch()', async () => { Object.defineProperty(document.documentElement, 'lang', {value: 'en'}); const fetchSpy = vi.spyOn(global, 'fetch').mockImplementation((url, _opts) => { return Promise.resolve({ ok: true, json: vi.fn().mockResolvedValue( url.endsWith('/artifacts') ? minimalInitialArtifactData : minimalInitialJobData, ), }); }); mount(RepoActionView, { props: { jobIndex: '1', attemptNumber: '1', initialJobData: minimalInitialJobData, initialArtifactData: minimalInitialArtifactData, locale: testLocale, }, }); await flushPromises(); expect(fetchSpy).not.toHaveBeenCalled(); }); test('view non-picked action run job', async () => { Object.defineProperty(document.documentElement, 'lang', {value: 'en'}); const wrapper = mount(RepoActionView, { props: { actionsURL: 'https://example.com/example-org/example-repo/actions', runIndex: '10', runID: '1001', jobIndex: '2', attemptNumber: '1', initialJobData: { ...minimalInitialJobData, // Definitions here should match the same type of content as the related backend test, // "TestActionsViewViewPost/attempt out-of-bounds on non-picked task", so that we're sure we can handle in the // UI what the backend will deliver in this case. state: { run: { done: false, status: 'waiting', commit: { pusher: {}, }, jobs: [ { id: 161, name: 'check-1', status: 'waiting', canRerun: false, duration: '0s', }, { id: 162, name: 'check-2', status: 'waiting', canRerun: false, duration: '0s', }, { id: 163, name: 'check-3', status: 'waiting', canRerun: false, duration: '0s', }, ], }, currentJob: { title: 'check-1', detail: 'waiting (locale)', // locale-specific, not exact match to backend test steps: [], allAttempts: null, }, }, }, initialArtifactData: minimalInitialArtifactData, locale: testLocale, }, }); await flushPromises(); expect(wrapper.get('.job-info-header-detail').text()).toEqual('waiting (locale)'); expect(wrapper.get('.job-brief-list .job-brief-item:nth-of-type(1) .job-brief-name').text()).toEqual('check-1'); expect(wrapper.get('.job-brief-list .job-brief-item:nth-of-type(2) .job-brief-name').text()).toEqual('check-2'); expect(wrapper.get('.job-brief-list .job-brief-item:nth-of-type(3) .job-brief-name').text()).toEqual('check-3'); });