Skip to content

Commit 4ae40f2

Browse files
committed
fix(qa-lab): keep capture preview truncation UTF-16 safe
1 parent c067802 commit 4ae40f2

2 files changed

Lines changed: 59 additions & 3 deletions

File tree

extensions/qa-lab/web/src/ui-render.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Qa Lab UI render tests cover evidence gallery affordances.
22
import { describe, expect, it } from "vitest";
3-
import { renderQaLabUi, type UiState } from "./ui-render.js";
3+
import { redactCaptureScalar, renderQaLabUi, type UiState } from "./ui-render.js";
44

55
function evidenceState(overrides: Partial<UiState> = {}): UiState {
66
return {
@@ -411,3 +411,34 @@ describe("QA Lab UI evidence render", () => {
411411
expect(html).not.toContain("secret-token");
412412
});
413413
});
414+
415+
describe("redactCaptureScalar", () => {
416+
it("keeps long capture previews UTF-16 safe when an emoji crosses the head boundary", () => {
417+
const prefix = "a".repeat(279);
418+
const value = `${prefix}😀${"b".repeat(200)}`;
419+
420+
const result = redactCaptureScalar(value);
421+
422+
expect(result).toContain(prefix);
423+
expect(result).toContain("…");
424+
expect(result).not.toContain("😀");
425+
// A naive .slice(0, 280) would leave a dangling high surrogate at index 279.
426+
expect(
427+
Array.from(result).every((c) => c.charCodeAt(0) < 0xd800 || c.charCodeAt(0) > 0xdbff),
428+
).toBe(true);
429+
});
430+
431+
it("keeps long capture previews UTF-16 safe when an emoji crosses the tail boundary", () => {
432+
const suffix = "z".repeat(79);
433+
const value = `${"a".repeat(350)}😀${suffix}`;
434+
435+
const result = redactCaptureScalar(value);
436+
437+
expect(result).toContain(suffix);
438+
expect(result).toContain("…");
439+
expect(result).not.toContain("😀");
440+
expect(
441+
Array.from(result).every((c) => c.charCodeAt(0) < 0xd800 || c.charCodeAt(0) > 0xdbff),
442+
).toBe(true);
443+
});
444+
});

extensions/qa-lab/web/src/ui-render.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,31 @@ import type {
77
QaEvidenceProducerContextFile,
88
} from "../../shared/evidence-gallery-types.js";
99

10+
/** Slices a UTF-16 string without returning dangling surrogate halves at either edge. */
11+
function sliceUtf16Safe(input: string, start: number, end?: number): string {
12+
const len = input.length;
13+
let from = start < 0 ? Math.max(len + start, 0) : Math.min(start, len);
14+
let to = end === undefined ? len : end < 0 ? Math.max(len + end, 0) : Math.min(end, len);
15+
if (to <= from) {
16+
return "";
17+
}
18+
const isHighSurrogate = (codeUnit: number) => codeUnit >= 0xd800 && codeUnit <= 0xdbff;
19+
const isLowSurrogate = (codeUnit: number) => codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
20+
if (from > 0 && from < len) {
21+
const codeUnit = input.charCodeAt(from);
22+
if (isLowSurrogate(codeUnit) && isHighSurrogate(input.charCodeAt(from - 1))) {
23+
from += 1;
24+
}
25+
}
26+
if (to > 0 && to < len) {
27+
const codeUnit = input.charCodeAt(to - 1);
28+
if (isHighSurrogate(codeUnit) && isLowSurrogate(input.charCodeAt(to))) {
29+
to -= 1;
30+
}
31+
}
32+
return input.slice(from, to);
33+
}
34+
1035
/* ===== Shared types (unchanged from the bus protocol) ===== */
1136

1237
type Conversation = {
@@ -560,7 +585,7 @@ function isSensitiveCaptureField(label: string): boolean {
560585
);
561586
}
562587

563-
function redactCaptureScalar(value: string, label?: string): string {
588+
export function redactCaptureScalar(value: string, label?: string): string {
564589
const trimmed = value.trim();
565590
if (!trimmed) {
566591
return "";
@@ -572,7 +597,7 @@ function redactCaptureScalar(value: string, label?: string): string {
572597
return "[redacted]";
573598
}
574599
if (trimmed.length > 400) {
575-
return `${trimmed.slice(0, 280)}\n…\n${trimmed.slice(-80)}`;
600+
return `${sliceUtf16Safe(trimmed, 0, 280)}\n…\n${sliceUtf16Safe(trimmed, -80)}`;
576601
}
577602
return trimmed;
578603
}

0 commit comments

Comments
 (0)