mirror of
https://github.com/jenkinsci/jenkins.git
synced 2026-10-26 19:15:09 +00:00
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:
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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user