Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ type ReleaseStreamEvent =
| { type: "snapshot"; job: ReleaseJob | null }
| { type: "log"; line: string }
| { type: "log.replace"; line: string }
| { type: "log.rewind"; count: number }
| { type: "phase"; phase: ReleasePhase; error?: string }
| { type: "runUrl"; url: string }
| { type: "tag"; tag: string };
Expand Down Expand Up @@ -214,6 +215,19 @@ function replaceReleaseLog(job: ReleaseJob, line: string): void {
broadcastReleaseEvent({ type: "log.replace", line });
}

function rewindReleaseLog(job: ReleaseJob, count: number): void {
const actual = Math.min(count, job.log.length);
if (actual > 0) {
job.log.splice(-actual);
broadcastReleaseEvent({ type: "log.rewind", count: actual });
}
}

/** Strip ANSI escape sequences for clean log display */
function stripAnsi(str: string): string {
return str.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
}

function setReleasePhase(job: ReleaseJob, phase: ReleasePhase, error?: string): void {
job.phase = phase;
broadcastReleaseEvent({ type: "phase", phase, error });
Expand All @@ -238,7 +252,16 @@ function streamProcess(
buffer += chunk.toString();
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
for (const rawLine of lines) {
// Detect ANSI cursor-up sequence (ESC[<N>A) used by tools like
// `gh run watch` to redraw multi-line output blocks in-place.
const cursorUpMatch = rawLine.match(/\x1b\[(\d+)A/);
if (cursorUpMatch) {
rewindReleaseLog(job, parseInt(cursorUpMatch[1], 10));
}

const line = stripAnsi(rawLine);

// A line may contain \r-separated segments (in-place terminal updates).
// Only the final segment matters; earlier ones were meant to be overwritten.
const crParts = line.split("\r").filter(Boolean);
Expand Down
47 changes: 47 additions & 0 deletions test/mock-gh-watch.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/bin/bash
# Simulates gh run watch output with ANSI cursor-up redraws.
# Usage: ./test/mock-gh-watch.sh
#
# This outputs 3 refresh cycles similar to what `gh run watch` produces,
# using ESC[<N>A (cursor up) + ESC[J (clear to end) to redraw in-place.

block_lines=9

# --- First render (in_progress) ---
echo "* main Release · 12345"
echo "Triggered via workflow_dispatch"
echo ""
echo "JOBS"
echo "* release (ID 999)"
echo "✓ Set up job"
echo "* Install dependencies"
echo "* Build"
echo "Refreshing run status every 3 seconds. Press Ctrl+C to quit."

sleep 0.3

# --- Second render (more steps complete) ---
printf "\033[${block_lines}A\033[J"
echo "* main Release · 12345"
echo "Triggered via workflow_dispatch"
echo ""
echo "JOBS"
echo "* release (ID 999)"
echo "✓ Set up job"
echo "✓ Install dependencies"
echo "* Build"
echo "Refreshing run status every 3 seconds. Press Ctrl+C to quit."

sleep 0.3

# --- Third render (all complete) ---
printf "\033[${block_lines}A\033[J"
echo "✓ main Release · 12345"
echo "Triggered via workflow_dispatch"
echo ""
echo "JOBS"
echo "✓ release (ID 999)"
echo "✓ Set up job"
echo "✓ Install dependencies"
echo "✓ Build"
echo ""
168 changes: 168 additions & 0 deletions test/stream-process.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* Test that streamProcess correctly handles ANSI cursor-up redraws
* (as produced by `gh run watch`) instead of appending duplicate blocks.
*
* Run: npx tsx test/stream-process.test.ts
*/
import { spawn } from "node:child_process";
import path from "node:path";

// ---- Minimal replicas of the server helpers ----

type LogEvent =
| { type: "log"; line: string }
| { type: "log.replace"; line: string }
| { type: "log.rewind"; count: number };

interface FakeJob {
log: string[];
}

const events: LogEvent[] = [];

function appendReleaseLog(job: FakeJob, line: string): void {
job.log.push(line);
events.push({ type: "log", line });
}

function replaceReleaseLog(job: FakeJob, line: string): void {
if (job.log.length > 0) {
job.log[job.log.length - 1] = line;
} else {
job.log.push(line);
}
events.push({ type: "log.replace", line });
}

function rewindReleaseLog(job: FakeJob, count: number): void {
const actual = Math.min(count, job.log.length);
if (actual > 0) {
job.log.splice(-actual);
events.push({ type: "log.rewind", count: actual });
}
}

function stripAnsi(str: string): string {
return str.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
}

// ---- streamProcess (copy of server logic) ----

function streamProcess(
command: string,
args: string[],
options: { cwd?: string },
job: FakeJob,
): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd: options.cwd,
stdio: ["ignore", "pipe", "pipe"],
});

let buffer = "";
let lastWasCR = false;
const processChunk = (chunk: Buffer): void => {
buffer += chunk.toString();
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const rawLine of lines) {
const cursorUpMatch = rawLine.match(/\x1b\[(\d+)A/);
if (cursorUpMatch) {
rewindReleaseLog(job, parseInt(cursorUpMatch[1], 10));
}

const line = stripAnsi(rawLine);

const crParts = line.split("\r").filter(Boolean);
if (crParts.length > 1 || lastWasCR) {
const final = crParts[crParts.length - 1] ?? "";
replaceReleaseLog(job, final);
} else {
appendReleaseLog(job, crParts[0] ?? line);
}
lastWasCR = false;
}
if (buffer.includes("\r")) {
const crParts = buffer.split("\r").filter(Boolean);
const final = crParts[crParts.length - 1] ?? "";
replaceReleaseLog(job, final);
buffer = "";
lastWasCR = true;
}
};

child.stdout.on("data", processChunk);
child.stderr.on("data", processChunk);

child.on("error", (err) => reject(err));
child.on("close", (code) => {
if (buffer) {
appendReleaseLog(job, buffer);
}
if (code !== 0) {
reject(new Error(`Process exited with code ${code}`));
} else {
resolve();
}
});
});
}

// ---- Run the test ----

async function main() {
const job: FakeJob = { log: [] };
const mockScript = path.join(import.meta.dirname, "mock-gh-watch.sh");

console.log("Running mock gh run watch through streamProcess...\n");
await streamProcess("bash", [mockScript], {}, job);

console.log("=== Final log state ===");
for (const [i, line] of job.log.entries()) {
console.log(` [${i}] ${JSON.stringify(line)}`);
}

console.log(`\n=== Events emitted (${events.length}) ===`);
for (const evt of events) {
if (evt.type === "log") console.log(` log: ${JSON.stringify(evt.line)}`);
else if (evt.type === "log.replace") console.log(` replace: ${JSON.stringify(evt.line)}`);
else if (evt.type === "log.rewind") console.log(` rewind: ${evt.count}`);
}

// Assertions
const rewinds = events.filter((e) => e.type === "log.rewind");
const finalLogCount = job.log.length;

console.log("\n=== Assertions ===");

// Should have exactly 2 rewinds (second and third render)
console.assert(rewinds.length === 2, `Expected 2 rewinds, got ${rewinds.length}`);
console.log(`✓ Rewind events: ${rewinds.length}`);

// Final log should have ~9 lines (the last render), not 27 (3x9)
console.assert(finalLogCount <= 12, `Expected ≤12 final log lines, got ${finalLogCount}`);
console.log(`✓ Final log lines: ${finalLogCount} (would be ~27 without fix)`);

// Should NOT contain duplicate "Refreshing" lines
const refreshLines = job.log.filter((l) => l.includes("Refreshing"));
console.assert(refreshLines.length <= 1, `Expected ≤1 Refreshing lines, got ${refreshLines.length}`);
console.log(`✓ Refreshing lines: ${refreshLines.length}`);

// Final log should end with the completed state
const hasCompleted = job.log.some((l) => l.includes("✓ main Release"));
console.assert(hasCompleted, "Expected final log to contain completed state");
console.log(`✓ Contains completed state`);

// No ANSI escape sequences should remain in the log
const hasAnsi = job.log.some((l) => /\x1b\[/.test(l));
console.assert(!hasAnsi, "Expected no ANSI sequences in final log");
console.log(`✓ No ANSI escape sequences in output`);

console.log("\nAll assertions passed!");
}

main().catch((err) => {
console.error("Test failed:", err);
process.exit(1);
});
6 changes: 5 additions & 1 deletion web/src/components/app/release-manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type ReleaseStreamEvent =
| { type: "snapshot"; job: ReleaseJob | null }
| { type: "log"; line: string }
| { type: "log.replace"; line: string }
| { type: "log.rewind"; count: number }
| { type: "phase"; phase: ReleasePhase; error?: string }
| { type: "runUrl"; url: string }
| { type: "tag"; tag: string };
Expand Down Expand Up @@ -164,6 +165,9 @@ export function ReleaseManager(): JSX.Element {
setJob((prev) => {
if (!prev) return prev;
if (event.type === "log") return { ...prev, log: [...prev.log, event.line] };
if (event.type === "log.rewind") {
return { ...prev, log: prev.log.slice(0, -event.count) };
}
if (event.type === "log.replace") {
const updated = [...prev.log];
if (updated.length > 0) {
Expand Down Expand Up @@ -370,7 +374,7 @@ export function ReleaseManager(): JSX.Element {
<div className={cn(
"h-2 w-2 rounded-full shrink-0",
done && "bg-green-500",
current && !isFailed && "animate-pulse bg-primary",
current && !isFailed && "animate-pulse bg-amber-500",
current && isFailed && "bg-destructive",
!done && !current && "bg-muted"
)} />
Expand Down
Loading