mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-01-11 20:56:29 +00:00
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
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:
parent
cd441a54a7
commit
f43d48d5c8
4 changed files with 525 additions and 291 deletions
265
web_src/js/components/ActionJobStep.test.js
Normal file
265
web_src/js/components/ActionJobStep.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
238
web_src/js/components/ActionJobStep.vue
Normal file
238
web_src/js/components/ActionJobStep.vue
Normal 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>
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue