refactor: extract ActionJobStep from RepoActionView (#10366)
Some checks are pending
/ release (push) Waiting to run
testing-integration / test-unit (push) Waiting to run
testing-integration / test-sqlite (push) Waiting to run
testing-integration / test-mariadb (v10.6) (push) Waiting to run
testing-integration / test-mariadb (v11.8) (push) Waiting to run
testing / security-check (push) Blocked by required conditions
testing / backend-checks (push) Waiting to run
testing / frontend-checks (push) Waiting to run
testing / test-unit (push) Blocked by required conditions
testing / test-e2e (push) Blocked by required conditions
testing / test-remote-cacher (redis) (push) Blocked by required conditions
testing / test-remote-cacher (valkey) (push) Blocked by required conditions
testing / test-remote-cacher (garnet) (push) Blocked by required conditions
testing / test-remote-cacher (redict) (push) Blocked by required conditions
testing / test-mysql (push) Blocked by required conditions
testing / test-pgsql (push) Blocked by required conditions
testing / test-sqlite (push) Blocked by required conditions

The `RepoActionView` Vue component attempts to do almost everything in the action log view in one Vue component, including data management & fetching new logs, rendering the list of jobs, rendering the list of steps, rendering the logs, etc.  As part of #9768, this view is expected to receive some significant new features to display nested jobs within steps.  Before that work commences, I'm refactoring the component.

This refactor step moves the rendering of the step header, expansion of the step, and rendering of the logs, into a smaller component `ActionJobStep`.  Tests for the functionality of the new component are added, and some tests have been moved from `RepoActionView` where they only touched log rendering.

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests

- I added test coverage for Go changes...
  - [ ] in their respective `*_test.go` for unit tests.
  - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I added test coverage for JavaScript changes...
  - [x] in `web_src/js/*.test.js` if it can be unit tested.
  - [ ] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [x] I do not want this change to show in the release notes.
- [ ] I want the title to show in the release notes with a link to this pull request.
- [ ] I want the content of the `release-notes/<pull request number>.md` to be be used for the release notes instead of the title.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10366
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net>
Co-committed-by: Mathieu Fenniak <mathieu@fenniak.net>
This commit is contained in:
Mathieu Fenniak 2025-12-08 15:58:49 +01:00 committed by Mathieu Fenniak
parent cd441a54a7
commit f43d48d5c8
4 changed files with 525 additions and 291 deletions

View file

@ -0,0 +1,265 @@
import {describe, expect, test, vi} from 'vitest';
import {mount} from '@vue/test-utils';
import ActionJobStep from './ActionJobStep.vue';
vi.mock('../utils/time.js', () => ({
formatDatetime: vi.fn((date) => date.toISOString()),
}));
describe('ActionJobStep', () => {
const defaultProps = {
stepId: 12321,
status: 'success',
runStatus: 'success',
expanded: false,
isExpandable: vi.fn(() => true),
isDone: vi.fn(() => true),
cursor: null,
summary: 'Build project',
duration: '2m 30s',
timeVisibleTimestamp: false,
timeVisibleSeconds: false,
};
function createWrapper(props = {}) {
return mount(ActionJobStep, {
props: {
...defaultProps,
...props,
},
});
}
describe('rendering', () => {
test('renders job step summary correctly', () => {
const wrapper = createWrapper();
expect(wrapper.find('.job-step-summary').exists()).toBe(true);
expect(wrapper.find('.step-summary-msg').text()).toBe('Build project');
expect(wrapper.find('.step-summary-duration').text()).toBe('2m 30s');
});
test('shows loading icon when expanded and cursor is null', () => {
const wrapper = createWrapper({
expanded: true,
cursor: null,
});
const icons = wrapper.findAllComponents({name: 'SvgIcon'});
expect(icons[0].props('name')).toBe('octicon-sync');
});
test('shows chevron-down when expanded', () => {
const wrapper = createWrapper({
expanded: true,
cursor: 10,
});
const icons = wrapper.findAllComponents({name: 'SvgIcon'});
expect(icons[0].props('name')).toBe('octicon-chevron-down');
});
test('shows chevron-right when not expanded', () => {
const wrapper = createWrapper({
expanded: false,
});
const icons = wrapper.findAllComponents({name: 'SvgIcon'});
expect(icons[0].props('name')).toBe('octicon-chevron-right');
});
test('adds step-expandable class when step is expandable', () => {
const wrapper = createWrapper();
expect(wrapper.find('.job-step-summary').classes()).toContain('step-expandable');
});
test('does not add step-expandable class when step is not expandable', () => {
const wrapper = createWrapper({
isExpandable: vi.fn(() => false),
});
expect(wrapper.find('.job-step-summary').classes()).not.toContain('step-expandable');
});
test('adds selected class when expanded', () => {
const wrapper = createWrapper({
expanded: true,
});
expect(wrapper.find('.job-step-summary').classes()).toContain('selected');
});
test('hides logs container when not expanded', async () => {
const wrapper = createWrapper({
expanded: false,
});
const logsContainer = wrapper.find('.job-step-logs');
// expect(logsContainer.isVisible()).toBe(false); // isVisible doesn't work, even attempting workarounds https://github.com/vuejs/vue-test-utils/issues/2073
expect(logsContainer.element.style.display).toBe('none');
});
test('shows logs container when expanded', () => {
const wrapper = createWrapper({
expanded: true,
});
const logsContainer = wrapper.find('.job-step-logs');
expect(logsContainer.isVisible()).toBe(true);
expect(logsContainer.element.style.display).not.toBe('none'); // since we can't rely on isVisible (see !expanded test)
});
});
describe('events', () => {
test('emits toggle event on click when expandable', async () => {
const wrapper = createWrapper();
await wrapper.find('.job-step-summary').trigger('click');
expect(wrapper.emitted('toggle')).toBeTruthy();
expect(wrapper.emitted('toggle')).toHaveLength(1);
});
test('does not emit toggle event on click when not expandable', async () => {
const wrapper = createWrapper({
isExpandable: vi.fn(() => false),
});
await wrapper.find('.job-step-summary').trigger('click');
expect(wrapper.emitted('toggle')).toBeFalsy();
});
test('emits toggle event on Enter key when expandable', async () => {
const wrapper = createWrapper();
await wrapper.find('.job-step-summary').trigger('keyup.enter');
expect(wrapper.emitted('toggle')).toBeTruthy();
});
test('emits toggle event on Space key when expandable', async () => {
const wrapper = createWrapper();
await wrapper.find('.job-step-summary').trigger('keyup.space');
expect(wrapper.emitted('toggle')).toBeTruthy();
});
});
describe('appendLogs method', () => {
test('creates log lines and appends them to container', () => {
const wrapper = createWrapper();
const logLines = [
{index: 1, timestamp: 1765163618, message: 'Starting build'},
{index: 2, timestamp: 1765163619, message: 'Running tests'},
{index: 3, timestamp: 1765163620, message: 'Build complete'},
];
wrapper.vm.appendLogs(logLines, 1765163618);
const container = wrapper.vm.$refs.logsContainer;
expect(container.children.length).toBe(3);
});
test('if ANSI renders empty line, skip line & line number', async () => {
const wrapper = createWrapper({
expanded: true,
});
const logLines = [
{index: 1, message: '\u001b]9;4;3\u0007\r\u001bM\u001b[?2026l\u001b[?2026h\u001b[J', timestamp: 0},
{index: 2, message: 'second line', timestamp: 0},
{index: 3, message: '\u001b]9;4;3\u0007\r\u001bM\u001b[?2026l\u001b[J\u001b]9;4;0\u0007\u001b[?2026h\u001b[J\u001b]9;4;1;0\u0007\u001b[?2026l\u001b[J\u001b]9;4;0\u0007', timestamp: 0},
{index: 4, message: 'fourth line', timestamp: 0},
];
wrapper.vm.appendLogs(logLines, 1765163618);
// Check if two lines where rendered
expect(wrapper.findAll('.job-log-line').length).toEqual(2);
// Check line one.
expect(wrapper.get('.job-log-line:nth-of-type(1)').attributes('id')).toEqual('jobstep-12321-1');
expect(wrapper.get('.job-log-line:nth-of-type(1) .line-num').text()).toEqual('1');
expect(wrapper.get('.job-log-line:nth-of-type(1) .line-num').attributes('href')).toEqual('#jobstep-12321-1');
expect(wrapper.get('.job-log-line:nth-of-type(1) .log-msg').text()).toEqual('second line');
// Check line two.
expect(wrapper.get('.job-log-line:nth-of-type(2)').attributes('id')).toEqual('jobstep-12321-2');
expect(wrapper.get('.job-log-line:nth-of-type(2) .line-num').text()).toEqual('2');
expect(wrapper.get('.job-log-line:nth-of-type(2) .line-num').attributes('href')).toEqual('#jobstep-12321-2');
expect(wrapper.get('.job-log-line:nth-of-type(2) .log-msg').text()).toEqual('fourth line');
});
});
describe('createLogLine method', () => {
test('creates log line with correct structure', () => {
const wrapper = createWrapper();
const line = {
index: 1,
timestamp: 1765163618,
message: 'Test message',
};
const logLine = wrapper.vm.createLogLine(line, 1765163618, {depth: 0, isHeader: false});
expect(logLine.classList.contains('job-log-line')).toBe(true);
expect(logLine.getAttribute('id')).toBe('jobstep-12321-1');
});
test('with timestamp', () => {
const wrapper = createWrapper({timeVisibleTimestamp: true});
const line = {
index: 1,
timestamp: 1765163618,
message: 'Test message',
};
const logLine = wrapper.vm.createLogLine(line, 1765163618, {depth: 0, isHeader: false});
expect(logLine.querySelector('.log-time-stamp').textContent).toBe('2025-12-08T03:13:38.000Z');
});
test('with duration', () => {
const wrapper = createWrapper({timeVisibleSeconds: true});
const line = {
index: 1,
timestamp: 1765163618,
message: 'Test message',
};
const logLine = wrapper.vm.createLogLine(line, 1765163618 - 150, {depth: 0, isHeader: false});
expect(logLine.querySelector('.log-time-seconds').textContent).toBe('150s');
});
test('creates line number link with correct href', () => {
const wrapper = createWrapper();
const line = {
index: 5,
timestamp: 1765163618,
message: 'Test',
};
const logLine = wrapper.vm.createLogLine(line, 1765163618, {depth: 0, isHeader: false});
const lineNumber = logLine.querySelector('.line-num');
expect(lineNumber.textContent).toBe('5');
expect(lineNumber.getAttribute('href')).toBe('#jobstep-12321-5');
});
});
test('append logs with a group', () => {
const 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 wrapper = createWrapper();
wrapper.vm.appendLogs(lines, 1765163618);
// 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');
});
});

View file

@ -0,0 +1,238 @@
<script>
import {SvgIcon} from '../svg.js';
import ActionRunStatus from './ActionRunStatus.vue';
import {toggleElem} from '../utils/dom.js';
import {formatDatetime} from '../utils/time.js';
import {renderAnsi} from '../render/ansi.js';
export default {
name: 'ActionJobStep',
components: {
SvgIcon,
ActionRunStatus,
},
props: {
stepId: {
type: Number,
required: true,
},
status: {
type: String,
required: true,
},
runStatus: {
type: String,
required: true,
},
expanded: {
type: Boolean,
required: true,
},
isExpandable: {
type: Function,
required: true,
},
isDone: {
type: Function,
required: true,
},
cursor: {
type: Number,
required: false,
default: null,
},
summary: {
type: String,
required: true,
},
duration: {
type: String,
required: true,
},
timeVisibleTimestamp: {
type: Boolean,
required: true,
},
timeVisibleSeconds: {
type: Boolean,
required: true,
},
},
emits: ['toggle'],
data() {
return {
lineNumberOffset: 0,
};
},
methods: {
createLogLine(line, startTime, group) {
const lineNo = line.index - this.lineNumberOffset;
const div = document.createElement('div');
div.classList.add('job-log-line');
div.setAttribute('id', `jobstep-${this.stepId}-${lineNo}`);
div._jobLogTime = line.timestamp;
const lineNumber = document.createElement('a');
lineNumber.classList.add('line-num', 'muted');
lineNumber.textContent = lineNo;
lineNumber.setAttribute('href', `#jobstep-${this.stepId}-${lineNo}`);
div.append(lineNumber);
// for "Show timestamps"
const logTimeStamp = document.createElement('span');
logTimeStamp.className = 'log-time-stamp';
const date = new Date(parseFloat(line.timestamp * 1000));
const timeStamp = formatDatetime(date);
logTimeStamp.textContent = timeStamp;
toggleElem(logTimeStamp, this.timeVisibleTimestamp);
// for "Show seconds"
const logTimeSeconds = document.createElement('span');
logTimeSeconds.className = 'log-time-seconds';
const seconds = Math.floor(parseFloat(line.timestamp) - parseFloat(startTime));
logTimeSeconds.textContent = `${seconds}s`;
toggleElem(logTimeSeconds, this.timeVisibleSeconds);
let logMessage = document.createElement('span');
logMessage.innerHTML = renderAnsi(line.message);
// If the input to renderAnsi is not empty and the output is empty we can
// assume the input was only ANSI escape codes that have been removed. In
// that case we should not display this message
if (line.message !== '' && logMessage.innerHTML === '') {
this.lineNumberOffset++;
return [];
}
if (group.isHeader) {
const details = document.createElement('details');
details.addEventListener('toggle', this.toggleGroupLogs);
const summary = document.createElement('summary');
summary.append(logMessage);
details.append(summary);
logMessage = details;
}
logMessage.className = 'log-msg';
logMessage.style.paddingLeft = `${group.depth}em`;
div.append(logTimeStamp);
div.append(logMessage);
div.append(logTimeSeconds);
return div;
},
appendLogs(logLines, startTime) {
this.lineNumberOffset = 0;
const groupStack = [];
const container = this.$refs.logsContainer;
for (const line of logLines) {
const el = groupStack.length > 0 ? groupStack[groupStack.length - 1] : container;
const group = {
depth: groupStack.length,
isHeader: false,
};
if (line.message.startsWith('##[group]')) {
group.isHeader = true;
const logLine = this.createLogLine(
{
...line,
message: line.message.substring(9),
},
startTime, group,
);
logLine.setAttribute('data-group', group.index);
el.append(logLine);
const list = document.createElement('div');
list.classList.add('job-log-list', 'hidden');
list.setAttribute('data-group', group.index);
groupStack.push(list);
el.append(list);
} else if (line.message.startsWith('##[endgroup]')) {
groupStack.pop();
} else {
el.append(this.createLogLine(line, startTime, group));
}
}
},
// show/hide the step logs for a group
toggleGroupLogs(event) {
const line = event.target.parentElement;
const list = line.nextSibling;
list.classList.toggle('hidden', event.newState !== 'open');
},
},
};
</script>
<template>
<div
class="job-step-summary"
tabindex="0"
@click.stop="isExpandable(status) && $emit('toggle')"
@keyup.enter.stop="isExpandable(status) && $emit('toggle')"
@keyup.space.stop="isExpandable(status) && $emit('toggle')"
:class="[expanded ? 'selected' : '', isExpandable(status) && 'step-expandable']"
>
<!-- If the job is done and the job step log is loaded for the first time, show the loading icon
currentJobStepsStates[i].cursor === null means the log is loaded for the first time
-->
<SvgIcon
v-if="isDone(runStatus) && expanded && cursor === null"
name="octicon-sync"
class="tw-mr-2 job-status-rotate"
/>
<SvgIcon
v-else
:name="expanded ? 'octicon-chevron-down': 'octicon-chevron-right'"
:class="['tw-mr-2', !isExpandable(status) && 'tw-invisible']"
/>
<ActionRunStatus :status="status" class="tw-mr-2"/>
<span class="step-summary-msg gt-ellipsis">{{ summary }}</span>
<span class="step-summary-duration">{{ duration }}</span>
</div>
<!-- the log elements could be a lot, do not use v-if to destroy/reconstruct the DOM,
use native DOM elements for "log line" to improve performance, Vue is not suitable for managing so many reactive elements. -->
<div class="job-step-logs" ref="logsContainer" v-show="expanded"/>
</template>
<style scoped>
.job-step-summary {
padding: 5px 10px;
display: flex;
align-items: center;
border-radius: var(--border-radius);
}
.job-step-summary.step-expandable {
cursor: pointer;
}
.job-step-summary.step-expandable:hover {
color: var(--color-console-fg);
background: var(--color-console-hover-bg);
}
.job-step-summary .step-summary-msg {
flex: 1;
}
.job-step-summary .step-summary-duration {
margin-left: 16px;
}
.job-step-summary.selected {
color: var(--color-console-fg);
background-color: var(--color-console-active-bg);
position: sticky;
top: 60px;
}
</style>
<style>
/* some elements are not managed by vue, so we need to use global style */
</style>

View file

@ -67,86 +67,6 @@ const defaultTestProps = {
workflowURL: 'https://example.com/example-org/example-repo/actions?workflow=test.yml',
};
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: {
title: 'Test',
steps: [
{
summary: 'Test Job',
duration: '1s',
status: 'success',
},
],
allAttempts: [{number: 1, time_since_started_html: '', status: 'success', status_diagnostics: ['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: defaultTestProps,
});
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) => {
@ -436,6 +356,8 @@ test('run approval interaction', async () => {
steps: [
{
summary: 'Test Job',
duration: '1s',
status: 'success',
},
],
},
@ -685,74 +607,3 @@ test('view with pre-execution error', async () => {
expect(block.exists()).toBe(true);
expect(block.text()).toBe('pre-execution error Oops, I dropped it.');
});
test('Offset index', async () => {
Object.defineProperty(document.documentElement, 'lang', {value: 'en'});
vi.spyOn(global, 'fetch').mockImplementation((url, opts) => {
const stepsLog_value = [
{
step: 0,
cursor: 0,
lines: [
{index: 1, message: '\u001b]9;4;3\u0007\r\u001bM\u001b[?2026l\u001b[?2026h\u001b[J', timestamp: 0},
{index: 2, message: 'second line', timestamp: 0},
{index: 3, message: '\u001b]9;4;3\u0007\r\u001bM\u001b[?2026l\u001b[J\u001b]9;4;0\u0007\u001b[?2026h\u001b[J\u001b]9;4;1;0\u0007\u001b[?2026l\u001b[J\u001b]9;4;0\u0007', timestamp: 0},
{index: 4, message: 'fourth line', timestamp: 0},
],
},
];
const jobs_value = {
state: {
run: {
status: 'success',
commit: {
pusher: {},
},
},
currentJob: {
title: 'test',
steps: [
{
summary: 'Test Job',
duration: '1s',
status: 'success',
},
],
allAttempts: [{number: 1, time_since_started_html: '', status: 'success', status_diagnostics: ['Success']}],
},
},
logs: {
stepsLog: opts.body?.includes('"cursor":null') ? stepsLog_value : [],
},
};
return Promise.resolve({
ok: true,
json: vi.fn().mockResolvedValue(
url.endsWith('/artifacts') ? [] : jobs_value,
),
});
});
const wrapper = mount(RepoActionView, {
props: defaultTestProps,
});
await flushPromises();
await wrapper.get('.job-step-summary').trigger('click');
await flushPromises();
// Check if two lines where rendered
expect(wrapper.findAll('.job-log-line').length).toEqual(2);
// Check line one.
expect(wrapper.get('.job-log-line:nth-of-type(1)').attributes('id')).toEqual('jobstep-0-1');
expect(wrapper.get('.job-log-line:nth-of-type(1) .line-num').text()).toEqual('1');
expect(wrapper.get('.job-log-line:nth-of-type(1) .line-num').attributes('href')).toEqual('#jobstep-0-1');
expect(wrapper.get('.job-log-line:nth-of-type(1) .log-msg').text()).toEqual('second line');
// Check line two.
expect(wrapper.get('.job-log-line:nth-of-type(2)').attributes('id')).toEqual('jobstep-0-2');
expect(wrapper.get('.job-log-line:nth-of-type(2) .line-num').text()).toEqual('2');
expect(wrapper.get('.job-log-line:nth-of-type(2) .line-num').attributes('href')).toEqual('#jobstep-0-2');
expect(wrapper.get('.job-log-line:nth-of-type(2) .log-msg').text()).toEqual('fourth line');
});

View file

@ -1,9 +1,8 @@
<script>
import {SvgIcon} from '../svg.js';
import ActionRunStatus from './ActionRunStatus.vue';
import ActionJobStep from './ActionJobStep.vue';
import {toggleElem} from '../utils/dom.js';
import {formatDatetime} from '../utils/time.js';
import {renderAnsi} from '../render/ansi.js';
import {GET, POST, DELETE} from '../modules/fetch.js';
export default {
@ -11,6 +10,7 @@ export default {
components: {
SvgIcon,
ActionRunStatus,
ActionJobStep,
},
props: {
initialJobData: {
@ -230,109 +230,20 @@ export default {
this.loadJob();
}
},
// cancel a run
cancelRun() {
POST(`${this.run.link}/cancel`);
},
// approve a run
approveRun() {
const url = `${this.run.commit.branch.link}#pull-request-trust-panel`;
window.location.href = url;
},
// show/hide the step logs for a group
toggleGroupLogs(event) {
const line = event.target.parentElement;
const list = line.nextSibling;
list.classList.toggle('hidden', event.newState !== 'open');
},
createLogLine(line, startTime, stepIndex, group) {
const lineNo = line.index - this.lineNumberOffset[stepIndex];
const div = document.createElement('div');
div.classList.add('job-log-line');
div.setAttribute('id', `jobstep-${stepIndex}-${lineNo}`);
div._jobLogTime = line.timestamp;
const lineNumber = document.createElement('a');
lineNumber.classList.add('line-num', 'muted');
lineNumber.textContent = lineNo;
lineNumber.setAttribute('href', `#jobstep-${stepIndex}-${lineNo}`);
div.append(lineNumber);
// for "Show timestamps"
const logTimeStamp = document.createElement('span');
logTimeStamp.className = 'log-time-stamp';
const date = new Date(parseFloat(line.timestamp * 1000));
const timeStamp = formatDatetime(date);
logTimeStamp.textContent = timeStamp;
toggleElem(logTimeStamp, this.timeVisible['log-time-stamp']);
// for "Show seconds"
const logTimeSeconds = document.createElement('span');
logTimeSeconds.className = 'log-time-seconds';
const seconds = Math.floor(parseFloat(line.timestamp) - parseFloat(startTime));
logTimeSeconds.textContent = `${seconds}s`;
toggleElem(logTimeSeconds, this.timeVisible['log-time-seconds']);
let logMessage = document.createElement('span');
logMessage.innerHTML = renderAnsi(line.message);
// If the input to renderAnsi is not empty and the output is empty we can
// assume the input was only ANSI escape codes that have been removed. In
// that case we should not display this message
if (line.message !== '' && logMessage.innerHTML === '') {
this.lineNumberOffset[stepIndex]++;
return [];
}
if (group.isHeader) {
const details = document.createElement('details');
details.addEventListener('toggle', this.toggleGroupLogs);
const summary = document.createElement('summary');
summary.append(logMessage);
details.append(summary);
logMessage = details;
}
logMessage.className = 'log-msg';
logMessage.style.paddingLeft = `${group.depth}em`;
div.append(logTimeStamp);
div.append(logMessage);
div.append(logTimeSeconds);
return div;
},
appendLogs(stepIndex, logLines, startTime) {
const groupStack = [];
const container = this.$refs.logs[stepIndex];
for (const line of logLines) {
const el = groupStack.length > 0 ? groupStack[groupStack.length - 1] : container;
const group = {
depth: groupStack.length,
isHeader: false,
};
if (line.message.startsWith('##[group]')) {
group.isHeader = true;
const logLine = this.createLogLine(
{
...line,
message: line.message.substring(9),
},
startTime, stepIndex, group,
);
logLine.setAttribute('data-group', group.index);
el.append(logLine);
const list = document.createElement('div');
list.classList.add('job-log-list', 'hidden');
list.setAttribute('data-group', group.index);
groupStack.push(list);
el.append(list);
} else if (line.message.startsWith('##[endgroup]')) {
groupStack.pop();
} else {
el.append(this.createLogLine(line, startTime, stepIndex, group));
}
}
this.$refs.jobSteps[stepIndex].appendLogs(logLines, startTime);
},
async fetchArtifacts() {
@ -680,21 +591,21 @@ export default {
</div>
<div class="job-step-container" ref="steps" v-if="currentJob.steps.length">
<div class="job-step-section" v-for="(jobStep, i) in currentJob.steps" :key="i">
<div class="job-step-summary" tabindex="0" @click.stop="isExpandable(jobStep.status) && toggleStepLogs(i)" @keyup.enter.stop="isExpandable(jobStep.status) && toggleStepLogs(i)" @keyup.space.stop="isExpandable(jobStep.status) && toggleStepLogs(i)" :class="[currentJobStepsStates[i].expanded ? 'selected' : '', isExpandable(jobStep.status) && 'step-expandable']">
<!-- If the job is done and the job step log is loaded for the first time, show the loading icon
currentJobStepsStates[i].cursor === null means the log is loaded for the first time
-->
<SvgIcon v-if="isDone(run.status) && currentJobStepsStates[i].expanded && currentJobStepsStates[i].cursor === null" name="octicon-sync" class="tw-mr-2 job-status-rotate"/>
<SvgIcon v-else :name="currentJobStepsStates[i].expanded ? 'octicon-chevron-down': 'octicon-chevron-right'" :class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"/>
<ActionRunStatus :status="jobStep.status" class="tw-mr-2"/>
<span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span>
<span class="step-summary-duration">{{ jobStep.duration }}</span>
</div>
<!-- the log elements could be a lot, do not use v-if to destroy/reconstruct the DOM,
use native DOM elements for "log line" to improve performance, Vue is not suitable for managing so many reactive elements. -->
<div class="job-step-logs" ref="logs" v-show="currentJobStepsStates[i].expanded"/>
<ActionJobStep
ref="jobSteps"
:run-status="run.status"
:is-expandable="isExpandable"
:is-done="isDone"
:step-id="i"
:status="jobStep.status"
:summary="jobStep.summary"
:duration="jobStep.duration"
:expanded="currentJobStepsStates[i].expanded"
:cursor="currentJobStepsStates[i].cursor"
:time-visible-timestamp="timeVisible['log-time-stamp']"
:time-visible-seconds="timeVisible['log-time-seconds']"
@toggle="() => toggleStepLogs(i)"
/>
</div>
</div>
</div>
@ -963,37 +874,6 @@ export default {
z-index: 0;
}
.job-step-container .job-step-summary {
padding: 5px 10px;
display: flex;
align-items: center;
border-radius: var(--border-radius);
}
.job-step-container .job-step-summary.step-expandable {
cursor: pointer;
}
.job-step-container .job-step-summary.step-expandable:hover {
color: var(--color-console-fg);
background: var(--color-console-hover-bg);
}
.job-step-container .job-step-summary .step-summary-msg {
flex: 1;
}
.job-step-container .job-step-summary .step-summary-duration {
margin-left: 16px;
}
.job-step-container .job-step-summary.selected {
color: var(--color-console-fg);
background-color: var(--color-console-active-bg);
position: sticky;
top: 60px;
}
@media (max-width: 767.98px) {
.action-view-body {
flex-direction: column;