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
63 changes: 43 additions & 20 deletions modules/sdk/@sfmc-sdk/src/logs/terminal-progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export interface TerminalProgressOptions {
export interface ProgressHandle {
start(total: number, startValue?: number, payload?: Record<string, string>): void;
update(value: number, payload?: Record<string, string>): void;
/**
* 修正总量刻度(未知→已知 Content-Length / finish 补总量)。
* LSP:勿用重 start 冒充改 total;实现须保留当前 value,并允许随后 update 按新刻度出帧。
*/
setTotal(total: number): void;
stop(): void;
/** 当前是否已 start 且未 stop */
readonly active: boolean;
Expand Down Expand Up @@ -115,7 +120,7 @@ export interface DownloadProgressBinderOptions {
* BDS 更新与 CF 包下载共用,避免两处复制 lastTime/lastLoaded 环路(DRY)。
*
* LSP:与 httpDownload 契约对齐——中途 total=0(无 Content-Length)仍可回调;
* 总量一旦变为已知(含 finish 用 finalBytes 补总量),须重设 bar 刻度,避免一直卡在占位 1MB。
* 总量一旦变为已知(含 finish 用 finalBytes 补总量),经 setTotal 修正刻度,避免卡在占位 1MB。
*/
export function bindByteProgressToBar(
bar: ProgressHandle,
Expand All @@ -138,15 +143,16 @@ export function bindByteProgressToBar(
const speedText = formatDownloadSpeed(speed);

if (!started) {
bar.start(totalMb, 0, { speed: "0 KB/s" });
/* 首帧 startValue=已下载量,避免非 TTY 先打出虚假 0%(LSP) */
bar.start(totalMb, dlMb, { speed: speedText });
started = true;
knownTotal = hasTotal;
} else if (hasTotal && !knownTotal) {
/* 未知→已知:重 start 以修正 total(ProgressHandle 无独立 setTotal) */
bar.start(totalMb, dlMb, { speed: speedText });
/* 未知→已知:setTotal 修正刻度,勿重 start(OCP/LSP) */
bar.setTotal(totalMb);
knownTotal = true;
}
/* 始终 update:首帧反映已下载字节;非 TTY logger 也走统一刻度(LSP) */
/* 始终 update:TTY 重绘 / 非 TTY 步进日志与 payload 对齐 */
bar.update(dlMb, { speed: speedText });

if (speedSampleMs <= 0 || now - lastTime >= speedSampleMs) {
Expand Down Expand Up @@ -250,13 +256,21 @@ export function createTerminalProgress(opts: TerminalProgressOptions = {}): Prog
if (useBar) {
if (!id) register();
draw();
} else if (opts.logger) {
/* 尊重 startValue(LSP:与 ProgressHandle.start 契约一致;重 start 修正总量时勿写死 0%) */
const pct = total > 0 ? Math.min(100, Math.round((value / total) * 100)) : 0;
opts.logger(`进度 ${pct}% (${value.toFixed(1)}/${total.toFixed(1)})`);
lastLoggedPct = Math.floor(pct / stepPercent) * stepPercent;
} else {
/* 尊重 startValue(LSP);与 update 共用 emitNonTty(DRY) */
emitNonTty(true);
}
},
setTotal(t) {
if (!started || stopped) return;
total = t > 0 ? t : 1;
/* 刻度变更后允许按新百分比再出帧(含从错误占位%回落到真实%) */
lastLoggedPct = -1;
if (useBar) {
draw();
}
/* 非 TTY:留给随后 update 打一条正确刻度日志,避免 start+update 双打 */
},
update(v, p) {
if (!started || stopped) return;
value = v;
Expand All @@ -265,16 +279,7 @@ export function createTerminalProgress(opts: TerminalProgressOptions = {}): Prog
draw();
return;
}
if (!opts.logger) return;
const pct = total > 0 ? (value / total) * 100 : 0;
const stepped = Math.floor(pct / stepPercent) * stepPercent;
if (stepped !== lastLoggedPct && (stepped > lastLoggedPct || pct >= 100)) {
lastLoggedPct = stepped;
const speed = payload.speed ? ` ${payload.speed}` : "";
opts.logger(
`进度 ${Math.min(100, Math.round(pct))}% (${value.toFixed(1)}/${total.toFixed(1)})${speed}`
);
}
emitNonTty(false);
},
stop() {
if (stopped) return;
Expand All @@ -288,6 +293,24 @@ export function createTerminalProgress(opts: TerminalProgressOptions = {}): Prog
},
};

/** 非 TTY 百分比日志唯一出口(start / update 共用 — DRY) */
function emitNonTty(force: boolean): void {
if (!opts.logger) return;
const pct = total > 0 ? (value / total) * 100 : 0;
const stepped = Math.floor(pct / stepPercent) * stepPercent;
if (
!force &&
!(stepped !== lastLoggedPct && (stepped > lastLoggedPct || pct >= 100))
) {
return;
}
lastLoggedPct = stepped;
const speed = payload.speed ? ` ${payload.speed}` : "";
opts.logger(
`进度 ${Math.min(100, Math.round(pct))}% (${value.toFixed(1)}/${total.toFixed(1)})${speed}`
);
}

return handle;
}

Expand Down
96 changes: 75 additions & 21 deletions sfmc/terminal-progress.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,46 +14,50 @@ import {

describe("progress pause/resume contract (registry)", () => {
it("嵌套 pause 只在最外层 resume 时重绘", () => {
let writes = 0;
const stream = new Writable({
write(_chunk, _e, cb) {
writes++;
cb();
},
});
const bar = createTerminalProgress({ stream, forceBar: true });
bar.start(10, 0, { speed: "0 KB/s" });
const afterStart = writes;
assert.ok(afterStart >= 1, "start 应至少绘制一次");

pauseAllProgress();
const afterOuterPause = writes;
assert.ok(afterOuterPause > afterStart, "外层 pause 应清行");

pauseAllProgress();
assert.equal(writes, afterOuterPause, "嵌套 pause 不应再写流");

/* 嵌套 pause:内层 resume 不应重绘(pausedDepth 仍 >0) */
resumeAllProgress();
assert.equal(writes, afterOuterPause, "内层 resume 不应重绘");
assert.equal(bar.active, true);

resumeAllProgress();
assert.ok(writes > afterOuterPause, "最外层 resume 应重绘");
assert.equal(bar.active, true);
bar.stop();
});

it("Writable 可接收进度输出", () => {
const chunks = [];
const stream = new Writable({
write(chunk, _e, cb) {
chunks.push(String(chunk));
cb();
},
});
stream.write("progress-line\n");
assert.ok(chunks[0].includes("progress"));
});
});

describe("bindByteProgressToBar LSP(未知总量→已知)", () => {
it("无 Content-Length 中途 total=0,finish 补总量时重设 bar 刻度", () => {
it("无 Content-Length 中途 total=0,finish 补总量时 setTotal 修正刻度", () => {
const starts = [];
const setTotals = [];
const updates = [];
const bar = {
active: true,
start(total, value = 0, payload) {
starts.push({ total, value, payload });
},
setTotal(total) {
setTotals.push(total);
},
update(value, payload) {
updates.push({ value, payload });
},
Expand All @@ -65,34 +69,39 @@ describe("bindByteProgressToBar LSP(未知总量→已知)", () => {
onProgress: (dl, total) => seen.push([dl, total]),
});

/* 中途无 Content-Length */
/* 中途无 Content-Length:首帧 startValue=已下载量 */
onByte(512 * 1024, 0);
assert.equal(starts.length, 1);
assert.equal(starts[0].total, 1); /* 占位 1MB */
assert.equal(updates.length, 1, "首帧须 update 反映已下载字节");
assert.equal(starts[0].value, 0.5, "首帧 startValue 须反映已下载字节");
assert.equal(updates.length, 1, "首帧须 update");
assert.equal(updates[0].value, 0.5);
assert.equal(seen[0][0], 512 * 1024);
assert.equal(seen[0][1], 0);

/* finish:用 finalBytes 作总量(与 httpDownload LSP 契约一致) */
const finalBytes = 5 * 1024 * 1024;
onByte(finalBytes, finalBytes);
assert.equal(starts.length, 2, "总量从未知变为已知时应重 start");
assert.equal(starts[1].total, 5);
assert.equal(starts[1].value, 5);
assert.equal(updates.length, 2, "重 start 后仍须 update(非 TTY logger 对齐)");
assert.equal(starts.length, 1, "已知总量后不得再 start");
assert.equal(setTotals.length, 1, "总量从未知变为已知时应 setTotal");
assert.equal(setTotals[0], 5);
assert.equal(updates.length, 2, "setTotal 后仍须 update");
assert.equal(updates[1].value, 5);
assert.deepEqual(seen[1], [finalBytes, finalBytes]);
});

it("一开始就有 Content-Length 时不重复 start,且首帧 update", () => {
it("一开始就有 Content-Length 时不重复 start/setTotal,且首帧带已下载量", () => {
const starts = [];
const setTotals = [];
const updates = [];
const bar = {
active: true,
start(total, value = 0) {
starts.push({ total, value });
},
setTotal(total) {
setTotals.push(total);
},
update(value) {
updates.push(value);
},
Expand All @@ -103,11 +112,13 @@ describe("bindByteProgressToBar LSP(未知总量→已知)", () => {
onByte(2 * 1024 * 1024, 10 * 1024 * 1024);
assert.equal(starts.length, 1);
assert.equal(starts[0].total, 10);
assert.ok(starts[0].value > 0, "首帧 startValue > 0");
assert.equal(setTotals.length, 0, "一开始已知总量时不应 setTotal");
assert.equal(updates.length, 2);
assert.ok(updates[0] > 0);
});

it("createTerminalProgress 非 TTY:start 尊重 startValue(勿写死 0%)", () => {
it("createTerminalProgress 非 TTY:start 尊重 startValue;setTotal 后 update 出正确刻度", () => {
const logs = [];
const stream = new Writable({
write(_chunk, _e, cb) {
Expand All @@ -124,6 +135,49 @@ describe("bindByteProgressToBar LSP(未知总量→已知)", () => {
assert.ok(logs.length >= 1);
assert.match(logs[0], /100%/);
assert.match(logs[0], /5(?:\.0)?\/5/);

logs.length = 0;
/* 占位刻度 → setTotal 修正后由 update 打一条,勿虚假 0% */
const bar2 = createTerminalProgress({
stream,
forceBar: false,
logger: (m) => logs.push(m),
});
bar2.start(1, 0.5, { speed: "0 B/s" });
assert.match(logs[0], /50%/);
logs.length = 0;
bar2.setTotal(5);
assert.equal(logs.length, 0, "setTotal 本身不打日志(留给 update)");
bar2.update(5, { speed: "1 MB/s" });
assert.equal(logs.length, 1);
assert.match(logs[0], /100%/);
assert.match(logs[0], /5(?:\.0)?\/5/);
bar.stop();
bar2.stop();
});

it("binder+非 TTY:首帧不出现虚假 0%,finish 经 setTotal 收 100%", () => {
const logs = [];
const stream = new Writable({
write(_chunk, _e, cb) {
cb();
},
});
const bar = createTerminalProgress({
stream,
forceBar: false,
logger: (m) => logs.push(m),
});
const onByte = bindByteProgressToBar(bar, { speedSampleMs: 0 });
onByte(512 * 1024, 0);
assert.ok(logs.length >= 1);
assert.ok(!logs.some((m) => /^进度 0%/.test(m)), "首帧不得虚假 0%");
assert.match(logs[0], /50%/);

logs.length = 0;
const finalBytes = 5 * 1024 * 1024;
onByte(finalBytes, finalBytes);
assert.ok(logs.some((m) => /100%/.test(m)));
bar.stop();
});

Expand Down