Skip to content

Commit e5d2d47

Browse files
committed
fix(showcase): canonicalize build-result service names (trim) + reject array payloads
1 parent 781bc92 commit e5d2d47

2 files changed

Lines changed: 50 additions & 13 deletions

File tree

showcase/scripts/lib/__tests__/build-outputs.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,46 @@ describe("mergeBuildResultFiles", () => {
139139
expect(() => mergeBuildResultFiles(slotPayloads)).toThrow(/duplicate/i);
140140
expect(() => mergeBuildResultFiles(slotPayloads)).toThrow(/dup/);
141141
});
142+
143+
it("normalizes trailing whitespace in service to its trimmed canonical form", () => {
144+
const result = mergeBuildResultFiles([
145+
'{"service":"foo ","status":"success"}',
146+
]);
147+
expect(result).toEqual([{ service: "foo", status: "success" }]);
148+
});
149+
150+
it("normalizes leading whitespace in service to its trimmed canonical form", () => {
151+
const result = mergeBuildResultFiles([
152+
'{"service":" foo","status":"success"}',
153+
]);
154+
expect(result).toEqual([{ service: "foo", status: "success" }]);
155+
});
156+
157+
it("parseBuildOutputs also returns the trimmed canonical service", () => {
158+
const json = JSON.stringify([{ service: " bar ", status: "success" }]);
159+
expect(parseBuildOutputs(json)).toEqual([
160+
{ service: "bar", status: "success" },
161+
]);
162+
});
163+
164+
it("detects duplicates that differ only by surrounding whitespace", () => {
165+
const slotPayloads = [
166+
'{"service":"foo","status":"failure"}',
167+
'{"service":"foo ","status":"success"}',
168+
];
169+
expect(() => mergeBuildResultFiles(slotPayloads)).toThrow(/duplicate/i);
170+
expect(() => mergeBuildResultFiles(slotPayloads)).toThrow(/foo/);
171+
});
172+
173+
it("rejects an array payload with a clear 'expected object' error (not 'missing service')", () => {
174+
expect(() => mergeBuildResultFiles(["[]"])).toThrow(/expected object/i);
175+
expect(() => mergeBuildResultFiles(["[]"])).not.toThrow(/missing/i);
176+
});
177+
178+
it("parseBuildOutputs rejects an array nested as an entry with 'expected object'", () => {
179+
const bad = JSON.stringify([[]]);
180+
expect(() => parseBuildOutputs(bad)).toThrow(/expected object/i);
181+
});
142182
});
143183

144184
describe("shouldRedeployStaging", () => {

showcase/scripts/lib/build-outputs.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,18 @@
1717
*/
1818

1919
// Single source of truth for the set of valid build outcomes. The
20-
// `as const` tuple drives BOTH the compile-time `BuildOutcome` union
21-
// AND the runtime `VALID_STATUSES` set, so adding a status in one
22-
// place is enforced in the other. The exhaustiveness check below
23-
// guarantees the tuple and the union stay in lockstep.
20+
// `as const` tuple drives BOTH the runtime `VALID_STATUSES` set AND
21+
// the compile-time `BuildOutcome` union (derived via indexed access
22+
// below), so the tuple is the only place a status needs to be added.
23+
// Since `BuildOutcome` is derived from this tuple there is no separate
24+
// union that could drift out of sync — no redundant exhaustiveness
25+
// assertion is needed.
2426
const BUILD_OUTCOMES = ["success", "failure", "skipped"] as const;
2527

2628
export type BuildOutcome = (typeof BUILD_OUTCOMES)[number];
2729

2830
const VALID_STATUSES: ReadonlySet<BuildOutcome> = new Set(BUILD_OUTCOMES);
2931

30-
// Compile-time exhaustiveness check: if BuildOutcome ever drifts from
31-
// the BUILD_OUTCOMES tuple (e.g. a hand-edited union), this assignment
32-
// will fail to typecheck.
33-
const _exhaustive: ReadonlyArray<BuildOutcome> = BUILD_OUTCOMES;
34-
void _exhaustive;
35-
3632
export interface ServiceBuildResult {
3733
service: string;
3834
status: BuildOutcome;
@@ -55,7 +51,7 @@ function validateServiceBuildResult(
5551
raw: unknown,
5652
contextLabel: string,
5753
): ServiceBuildResult {
58-
if (typeof raw !== "object" || raw === null) {
54+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
5955
throw new Error(
6056
`${contextLabel}: expected object with {service, status}, got ${JSON.stringify(raw)}`,
6157
);
@@ -66,7 +62,8 @@ function validateServiceBuildResult(
6662
`${contextLabel}: missing required string field "service": ${JSON.stringify(raw)}`,
6763
);
6864
}
69-
if (service.trim().length === 0) {
65+
const trimmedService = service.trim();
66+
if (trimmedService.length === 0) {
7067
throw new Error(
7168
`${contextLabel}: field "service" must be a non-empty, non-whitespace string: ${JSON.stringify(raw)}`,
7269
);
@@ -80,7 +77,7 @@ function validateServiceBuildResult(
8077
`${contextLabel}: invalid "status" (must be success|failure|skipped): ${JSON.stringify(raw)}`,
8178
);
8279
}
83-
return { service, status: status as BuildOutcome };
80+
return { service: trimmedService, status: status as BuildOutcome };
8481
}
8582

8683
export function parseBuildOutputs(raw: string): ServiceBuildResult[] {

0 commit comments

Comments
 (0)