From cb0845cd3ea4aabeddaa3908e31268e6c67af500 Mon Sep 17 00:00:00 2001 From: forgejo-backport-action Date: Fri, 31 Oct 2025 16:15:29 +0100 Subject: [PATCH] [v13.0/forgejo] fix: don't show ConEmu OSC escape sequences (#9919) **Backport:** https://codeberg.org/forgejo/forgejo/pulls/9875 - Remove all [ConEMU OSC commands](https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC) from the output of Forgejo action logs when rendering. - The regex is constructed as followed: Match the prefix `ESC ] 9 ;`. Then matches any number of digits, then match everything up to and including `ST` (this is either `ESC\` or `BELL`). - Resolves forgejo/forgejo#9244 Co-authored-by: Gusted Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/9919 Reviewed-by: Earl Warren Co-authored-by: forgejo-backport-action Co-committed-by: forgejo-backport-action --- web_src/js/components/RepoActionView.test.js | 70 ++++++++++++++++++++ web_src/js/components/RepoActionView.vue | 16 ++++- web_src/js/render/ansi.js | 2 + web_src/js/render/ansi.test.js | 9 +++ 4 files changed, 94 insertions(+), 3 deletions(-) diff --git a/web_src/js/components/RepoActionView.test.js b/web_src/js/components/RepoActionView.test.js index 313732cc61..6f575ca8da 100644 --- a/web_src/js/components/RepoActionView.test.js +++ b/web_src/js/components/RepoActionView.test.js @@ -623,3 +623,73 @@ 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: { + 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') ? [] : 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 f9d6028b81..0db9432167 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -33,6 +33,7 @@ const sfc = { initialLoadComplete: false, needLoadingWithLogCursors: null, intervalID: null, + lineNumberOffset: [], currentJobStepsStates: [], artifacts: [], menuVisible: undefined, @@ -203,15 +204,16 @@ const sfc = { }, 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}-${line.index}`); + div.setAttribute('id', `jobstep-${stepIndex}-${lineNo}`); div._jobLogTime = line.timestamp; const lineNumber = document.createElement('a'); lineNumber.classList.add('line-num', 'muted'); - lineNumber.textContent = line.index; - lineNumber.setAttribute('href', `#jobstep-${stepIndex}-${line.index}`); + lineNumber.textContent = lineNo; + lineNumber.setAttribute('href', `#jobstep-${stepIndex}-${lineNo}`); div.append(lineNumber); // for "Show timestamps" @@ -230,6 +232,13 @@ const sfc = { 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); @@ -378,6 +387,7 @@ const sfc = { // append logs to the UI for (const logs of job.logs.stepsLog) { // save the cursor, it will be passed to backend next time + this.lineNumberOffset[logs.step] = 0; this.currentJobStepsStates[logs.step].cursor = logs.cursor; this.appendLogs(logs.step, logs.lines, logs.started); } diff --git a/web_src/js/render/ansi.js b/web_src/js/render/ansi.js index bb622dd1eb..4a1d83fe19 100644 --- a/web_src/js/render/ansi.js +++ b/web_src/js/render/ansi.js @@ -2,7 +2,9 @@ import {AnsiUp} from 'ansi_up'; const replacements = [ [/\x1b\[\d+[A-H]/g, ''], // Move cursor, treat them as no-op + [/\x1bM/g, ''], // Move cursor one line up, threat them as no-op. [/\x1b\[\d?[JK]/g, '\r'], // Erase display/line, treat them as a Carriage Return + [/\x1b\]9;\d+.*?(\x07|\x1b\\)/g, ''], // ConEmu, treat them as no-op. ]; // render ANSI to HTML diff --git a/web_src/js/render/ansi.test.js b/web_src/js/render/ansi.test.js index 5afff71c29..f0201f1526 100644 --- a/web_src/js/render/ansi.test.js +++ b/web_src/js/render/ansi.test.js @@ -17,4 +17,13 @@ test('renderAnsi', () => { // treat "\033[0K" and "\033[0J" (Erase display/line) as "\r", then it will be covered to "\n" finally. expect(renderAnsi('a\x1b[Kb\x1b[2Jc')).toEqual('a\nb\nc'); expect(renderAnsi('\x1b[48;5;88ma\x1b[38;208;48;5;159mb\x1b[m')).toEqual(`ab`); + + expect(renderAnsi('\x1b]9;4;0\x07')).toEqual(''); + expect(renderAnsi('\x1b]9;4;1;25\x07compiling main.zig')).toEqual('compiling main.zig'); + expect(renderAnsi('\x1b]9;4;1;25\x1b\\compiling main.zig')).toEqual('compiling main.zig'); + expect(renderAnsi('\x1b]9;4;3\x07waiting...')).toEqual('waiting...'); + expect(renderAnsi('\x1b]9;1;500\x07sleeping...')).toEqual('sleeping...'); + expect(renderAnsi('\x1b]9;4;3\x07waiting...\x1b]9;4;3\x07')).toEqual('waiting...'); + expect(renderAnsi('\x1b]9;12\x07')).toEqual(''); + expect(renderAnsi('\x1b]9;4;1;25\x07\x1bMcompiling main.zig')).toEqual('compiling main.zig'); });