Keep ANSI colours in the live console after a pause in output (#27424)

* Keep console annotator state across empty progressive log chunks

The streaming progressiveHtml response only includes consoleAnnotator
in its metadata when there is new text to render. When a poll returns
no new output, the client overwrote its stored state with undefined and
stopped sending X-ConsoleAnnotator, so every later chunk was rendered by
a fresh annotator: AnsiColor escapes appeared raw and Timestamper
formatting was lost until the page was reloaded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Compare offsets to detect an empty chunk and add a test

An empty chunk is one where the requested start equals the returned end,
which does not depend on how the annotator state is transported. Adds
src/test/js/progressive-text.test.js covering both an empty poll and a
poll with new output.

Co-authored-by: Jakob Ackermann <17931887+das7pad@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Jakob Ackermann <17931887+das7pad@users.noreply.github.com>
Co-authored-by: Kris Stern <88480540+krisstern@users.noreply.github.com>
(cherry picked from commit 5bedd4759f)
This commit is contained in:
Oleksandr Srv authored and Kris Stern committed 2026-10-14 20:14:06 +08:00
1 parent 45171eec07
commit 2da7a02364
2 files changed
+126 -2

No files matched your search

@@ -21,6 +21,7 @@ Behaviour.specify(
Where to retrieve additional text from
*/
function fetchNext(e, href, onFinishEvent) {
const start = e.fetchedBytes;
var headers = crumb.wrap({
"Content-Type": "application/x-www-form-urlencoded",
Accept: "multipart/form-data, */*",
@@ -33,7 +34,7 @@ Behaviour.specify(
method: "post",
headers,
body: new URLSearchParams({
start: e.fetchedBytes,
start,
}),
}).then((rsp) => {
if (rsp.status >= 500 || rsp.status === 0) {
@@ -81,8 +82,11 @@ Behaviour.specify(
}
/* append text and do autoscroll if applicable */
parse.then(({ text, end, consoleAnnotator, completed }) => {
// An empty chunk carries no annotator state; keep the current one.
if (start !== end) {
e.consoleAnnotator = consoleAnnotator;
}
e.fetchedBytes = end;
e.consoleAnnotator = consoleAnnotator;
if (text !== "") {
var p = document.createElement("DIV");
e.appendChild(p); // Needs to be first for IE
+120
View File
@@ -0,0 +1,120 @@
import progressiveTextSource from "../../../core/src/main/resources/lib/hudson/progressive-text.js?raw";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* progressive-text.js is a classic script that registers its behaviour with
* Behaviour.specify, so evaluate it in the global scope and run the
* registered callback against a holder element ourselves.
*/
function loadProgressiveText() {
let apply;
globalThis.Behaviour = {
specify: (selector, id, priority, fn) => {
apply = fn;
},
applySubtree: () => {},
};
globalThis.crumb = { wrap: (headers) => headers };
globalThis.AutoScroller = class {
isSticking() {
return false;
}
scrollToBottom() {}
};
// indirect eval, so that the script runs in the global scope
(0, eval)(progressiveTextSource);
return apply;
}
describe("progressive-text", () => {
/** The requests handed to fetch, each one settled by the test. */
let requests;
beforeEach(() => {
vi.useFakeTimers();
requests = [];
globalThis.fetch = vi.fn(
(url, options) =>
new Promise((resolve) => {
requests.push({ url, options, resolve });
}),
);
document.body.innerHTML = `
<pre id="out"></pre>
<div class="progressiveText-holder" data-href="logText/progressiveHtml"
data-idref="out" data-spinner="" data-start-offset=""
data-on-finish-event="" data-error-message="failed"></div>`;
loadProgressiveText()(document.querySelector(".progressiveText-holder"));
});
afterEach(() => {
vi.useRealTimers();
});
/**
* Answers a request the way the streaming progressiveHtml endpoint does,
* then lets the client schedule and send its next request.
*/
async function respondWith(request, text, meta) {
request.resolve({
status: 200,
headers: {
get: (name) =>
name === "Content-Type"
? "multipart/form-data;boundary=b;charset=utf-8"
: null,
},
formData: () =>
Promise.resolve({
get: (name) => ({ text, meta: JSON.stringify(meta) })[name],
}),
});
await vi.advanceTimersByTimeAsync(1000);
}
function sentAnnotator(request) {
return request.options.headers["X-ConsoleAnnotator"];
}
function sentStart(request) {
return request.options.body.get("start");
}
it("keeps sending the console annotator after a poll without new output", async () => {
expect(sentAnnotator(requests[0])).toBeUndefined();
await respondWith(requests[0], "line1\n", {
completed: false,
start: 0,
end: 6,
consoleAnnotator: "state-1",
});
expect(sentAnnotator(requests[1])).toBe("state-1");
// No new output: the server sends no annotator state for an empty chunk.
await respondWith(requests[1], "", { completed: false, start: 6, end: 6 });
expect(sentStart(requests[2])).toBe("6");
expect(sentAnnotator(requests[2])).toBe("state-1");
});
it("sends the new console annotator after more output", async () => {
await respondWith(requests[0], "line1\n", {
completed: false,
start: 0,
end: 6,
consoleAnnotator: "state-1",
});
await respondWith(requests[1], "", { completed: false, start: 6, end: 6 });
await respondWith(requests[2], "line2\n", {
completed: false,
start: 6,
end: 12,
consoleAnnotator: "state-2",
});
expect(sentStart(requests[3])).toBe("12");
expect(sentAnnotator(requests[3])).toBe("state-2");
expect(document.getElementById("out").textContent).toBe("line1\nline2\n");
});
});