fix(ui): neutralize Usage CSV text cells [AI] (#111653)

* fix(ui): neutralize usage csv text cells

* test(ui): align usage csv fixture types

* fix(ui): preserve benign usage csv whitespace
This commit is contained in:
Pavan Kumar Gondhi
2026-07-20 14:08:33 +05:30
committed by GitHub
parent 1de4a099ca
commit 391c3bcb17
2 changed files with 98 additions and 5 deletions
+88
View File
@@ -16,4 +16,92 @@ describe("usage query CSV export", () => {
expect(csv).toContain("session-1,Session 1,,,,,,,,,,,,,,,");
});
it.each([
["equals", "=1+1", "'=1+1"],
["plus", "+1+1", "'+1+1"],
["minus", "-1+1", "'-1+1"],
["at", "@SUM(A1:A2)", "'@SUM(A1:A2)"],
["leading whitespace", " \t=1+1", "' \t=1+1"],
["fullwidth equals", "\uFF1D1+1", "'\uFF1D1+1"],
["fullwidth plus", "\uFF0B1+1", "'\uFF0B1+1"],
["fullwidth minus", "\uFF0D1+1", "'\uFF0D1+1"],
["fullwidth at", "\uFF20SUM(A1:A2)", "'\uFF20SUM(A1:A2)"],
])("neutralizes spreadsheet formula labels with %s prefix", (_name, label, expected) => {
const csv = buildSessionsCsv([
{
key: "session-1",
label,
updatedAt: 0,
usage: null,
} satisfies UsageSessionEntry,
]);
expect(csv).toContain(`session-1,${expected},`);
});
it("quotes carriage returns in formula-neutralized labels", () => {
const csv = buildSessionsCsv([
{
key: "session-1",
label: "\r=1+1",
updatedAt: 0,
usage: null,
} satisfies UsageSessionEntry,
]);
expect(csv).toContain('session-1,"\'\r=1+1",');
});
it.each([
["tab", "\tplain", "\tplain"],
["carriage return", "\rplain", '"\rplain"'],
["newline", "\nplain", '"\nplain"'],
])("preserves benign labels with leading %s", (_name, label, expected) => {
const csv = buildSessionsCsv([
{
key: "session-1",
label,
updatedAt: 0,
usage: null,
} satisfies UsageSessionEntry,
]);
expect(csv).toContain(`session-1,${expected},`);
});
it("keeps numeric cells numeric while neutralizing string labels", () => {
const csv = buildSessionsCsv([
{
key: "session-1",
label: "-remote-label",
updatedAt: 0,
usage: {
durationMs: -1,
messageCounts: {
total: -2,
user: -3,
assistant: -4,
toolCalls: -5,
toolResults: -6,
errors: -7,
},
input: -5,
output: -6,
cacheRead: -7,
cacheWrite: -8,
totalTokens: -9,
totalCost: -10,
inputCost: -11,
outputCost: -12,
cacheReadCost: -13,
cacheWriteCost: -14,
missingCostEntries: -15,
},
} satisfies UsageSessionEntry,
]);
expect(csv).toContain("session-1,'-remote-label,,,");
expect(csv).toContain(",-1,-2,-7,-5,-5,-6,-7,-8,-9,-10");
});
});
+10 -5
View File
@@ -14,11 +14,16 @@ function downloadTextFile(filename: string, content: string, type = "text/plain"
URL.revokeObjectURL(url);
}
function csvEscape(value: string): string {
if (/[",\n]/.test(value)) {
return `"${value.replaceAll('"', '""')}"`;
function neutralizeSpreadsheetFormulaCell(value: string): string {
return /^[ \t\r\n]*[=+\-@\uFF0B\uFF0D\uFF1D\uFF20]/u.test(value) ? `'${value}` : value;
}
function csvEscape(value: string, neutralizeFormulas = true): string {
const safeValue = neutralizeFormulas ? neutralizeSpreadsheetFormulaCell(value) : value;
if (/[",\r\n]/.test(safeValue)) {
return `"${safeValue.replaceAll('"', '""')}"`;
}
return value;
return safeValue;
}
function toCsvRow(values: Array<string | number | undefined | null>): string {
@@ -27,7 +32,7 @@ function toCsvRow(values: Array<string | number | undefined | null>): string {
if (value === undefined || value === null) {
return "";
}
return csvEscape(String(value));
return csvEscape(String(value), typeof value === "string");
})
.join(",");
}