Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-formatvalue-budget-overrun.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sapiom/harness": patch
---

Fix a debug-context helper that could return output slightly longer than its configured character budget.
8 changes: 6 additions & 2 deletions packages/harness/web/src/lib/extract-step-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,14 +154,18 @@ describe("extractStepContext — input/output", () => {
expect(ctx).toContain("[object Object]");
});

it("truncates a very large value and marks the truncation", () => {
it("truncates a very large value and marks the truncation, bounding the total output", () => {
const step: StepView = { id: "s1", name: "big", status: "passed" };
const huge = "z".repeat(5000);
const ctx = extractStepContext(step, { output: huge });
expect(ctx).toContain("… (truncated, 5000 chars total)");
// The head is kept; the value is not pasted whole.
expect(ctx).not.toContain(huge);
expect(ctx).toContain("z".repeat(2000));
// The marker's own length has to come out of VALUE_CAP too, so slightly
// less than 2000 chars of content survive -- the total (content +
// marker) is what's bounded, not the content alone.
expect(ctx).toContain("z".repeat(1968));
expect(ctx).not.toContain("z".repeat(1969));
});
});

Expand Down
16 changes: 11 additions & 5 deletions packages/harness/web/src/lib/extract-step-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ export function formatLatency(ms: number): string {
* strings pass through verbatim; anything JSON can't represent (a cycle, a
* bigint) falls back to `String(value)` so the builder never throws. When the
* result exceeds the cap it is head-kept (the shape and leading content matter
* most for a value) with an explicit truncation marker.
* most for a value) with an explicit truncation marker, and the TOTAL output
* (content + marker) is what respects the cap — not the content alone, or the
* marker itself would push the result past VALUE_CAP.
*/
function formatValue(value: unknown): string {
let text: string;
Expand All @@ -117,10 +119,14 @@ function formatValue(value: unknown): string {
text = String(value);
}
}
if (text.length > VALUE_CAP) {
return `${text.slice(0, VALUE_CAP)}\n… (truncated, ${text.length} chars total)`;
}
return text;
if (text.length <= VALUE_CAP) return text;
// Unlike a "chars dropped" marker, this one reports the original total
// length, which doesn't change with where we slice -- so the marker's
// length is fixed and a single subtraction (no converging loop needed)
// correctly reserves room for it within VALUE_CAP.
const marker = `\n… (truncated, ${text.length} chars total)`;
const sliceLen = Math.max(0, VALUE_CAP - marker.length);
return `${text.slice(0, sliceLen)}${marker}`.slice(0, VALUE_CAP);
}

/**
Expand Down
Loading