diff --git a/web_src/js/components/ActionRunJobView.vue b/web_src/js/components/ActionRunJobView.vue
index 4e439a6db7..b297ff98d2 100644
--- a/web_src/js/components/ActionRunJobView.vue
+++ b/web_src/js/components/ActionRunJobView.vue
@@ -722,7 +722,7 @@ async function hashChangeListener() {
.job-step-logs .job-log-line .log-msg {
flex: 1;
- white-space: break-spaces;
+ white-space: break-spaces; /* decoded commands like "::error::foo%0Abar" contain "\n" */
margin-left: 12px;
overflow-wrap: anywhere;
}
diff --git a/web_src/js/components/ActionRunView.test.ts b/web_src/js/components/ActionRunView.test.ts
index b625b15e71..e6eb658503 100644
--- a/web_src/js/components/ActionRunView.test.ts
+++ b/web_src/js/components/ActionRunView.test.ts
@@ -14,6 +14,13 @@ test('LogLineMessage', () => {
'##[debug] foo': 'Debug: foo',
'::error::foo': 'Error: foo',
'::warning file=test.js,line=1::foo': 'Warning: foo',
+ '::error::foo%0Abar': 'Error: foo\nbar',
+ '::error::foo%0D%0Abar': 'Error: foo\nbar',
+ '::error::100%25 done%250A': 'Error: 100% done%0A',
+ '::error::keep%5Dsemi%3B': 'Error: keep%5Dsemi%3B',
+ '::group::foo%0Abar': 'foo\nbar',
+ '##[error]foo%0Abar%3B%5D': 'Error: foo\nbar;]',
+ '##[command]foo%0Abar': 'foo%0Abar',
'::notice::foo': 'Notice: foo',
'::debug::foo': 'Debug: foo',
'##[command] foo': ' foo',
diff --git a/web_src/js/components/ActionRunView.ts b/web_src/js/components/ActionRunView.ts
index 52fcdf56cd..d94c6510a1 100644
--- a/web_src/js/components/ActionRunView.ts
+++ b/web_src/js/components/ActionRunView.ts
@@ -9,7 +9,8 @@ import {POST} from '../modules/fetch.ts';
// * Workflow command outputs log commands like "::group::the-title", "::add-matcher::...."
// * Workflow runner parses and processes the commands to "##[group]", apply "matchers", hide secrets, etc.
// * The reported logs are the processed logs.
-// HOWEVER: Gitea runner does not completely process those commands. Many works are done by the frontend at the moment.
+// HOWEVER: Gitea cannot, a decoded message may contain newlines and FormatLog drops them,
+// so the commands arrive here still escaped and the frontend decodes them.
const LogLinePrefixCommandMap: Record = {
'::group::': 'group',
'##[group]': 'group',
@@ -67,14 +68,23 @@ const LogLineLabelMap: Partial> = {
'debug': 'Debug',
};
+function decodeLineMessage(line: LogLine, cmd: LogLineCommand | null): string {
+ // TODO: for some commands (::group::), the "prefix removal" works well, for some commands with "arguments" (::remove-matcher ...::),
+ // it needs to do further processing in the future (fortunately, at the moment we don't need to handle these commands)
+ if (!cmd) return line.message;
+ let msg = line.message.substring(cmd.prefix.length);
+ if (cmd.name === 'command') return msg; // "command" is only an output tag, do not parse or escape it
+ // "##[cmd]" also escapes ";" and "]" which delimit its header, "::cmd::" does not
+ if (!cmd.prefix.startsWith('::')) msg = msg.replace(/%3B/g, ';').replace(/%5D/g, ']');
+ // renderAnsiInto breaks a line per "\r", so "%0D%0A" is one break. "%25" last keeps "%250A" literal
+ return msg.replace(/(?:%0D)?%0A/g, '\n').replace(/%0D/g, '\r').replace(/%25/g, '%');
+}
+
export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null) {
const logMsgAttrs = {class: 'log-msg'};
if (cmd?.name) logMsgAttrs.class += ` log-cmd-${cmd.name}`; // make it easier to add styles to some commands like "error"
- // TODO: for some commands (::group::), the "prefix removal" works well, for some commands with "arguments" (::remove-matcher ...::),
- // it needs to do further processing in the future (fortunately, at the moment we don't need to handle these commands)
- const msgContent = cmd ? line.message.substring(cmd.prefix.length) : line.message;
-
+ const msgContent = decodeLineMessage(line, cmd);
const logMsg = createElementFromAttrs('span', logMsgAttrs);
const label = cmd ? LogLineLabelMap[cmd.name] : null;
if (label) {