diff --git a/web_src/js/components/ActionJobStep.test.js b/web_src/js/components/ActionJobStep.test.js
new file mode 100644
index 0000000000..ae52fd7bcc
--- /dev/null
+++ b/web_src/js/components/ActionJobStep.test.js
@@ -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');
+ });
+});
diff --git a/web_src/js/components/ActionJobStep.vue b/web_src/js/components/ActionJobStep.vue
new file mode 100644
index 0000000000..5d9dd64ea4
--- /dev/null
+++ b/web_src/js/components/ActionJobStep.vue
@@ -0,0 +1,238 @@
+
+
+
+
+
+
+
+
+
{{ summary }}
+
{{ duration }}
+
+
+
+
+
+
+
diff --git a/web_src/js/components/RepoActionView.test.js b/web_src/js/components/RepoActionView.test.js
index 472763541b..db6c4035e8 100644
--- a/web_src/js/components/RepoActionView.test.js
+++ b/web_src/js/components/RepoActionView.test.js
@@ -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');
-});
diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue
index e79dae7078..e80b6475c9 100644
--- a/web_src/js/components/RepoActionView.vue
+++ b/web_src/js/components/RepoActionView.vue
@@ -1,9 +1,8 @@