Skip to content

Commit ea7da0c

Browse files
authored
test(scripts): add direct unit tests for write-ui-openapi's preserveExistingObjectOrder (#7788)
preserveExistingObjectOrder() re-orders the freshly generated OpenAPI spec's keys to match the committed file so ui:openapi:check (a hard test:ci gate) produces a stable diff, but it had no direct test — a regression in its recursive logic would either spuriously fail ui:openapi:check on unrelated PRs or mask a genuinely stale spec. Export the function (and guard the script's side-effecting main behind the standard `import.meta.url === argv[1]` check, so importing it for a unit test does not build/read/write the spec or call process.exit — mirrors write-cloudflare-schema.ts). Add test/unit/write-ui-openapi-script.test.ts covering a nested-object reorder, new-only and current-only keys, an array-valued key walked positionally (incl. a shorter current array), undefined/null/primitive leaves, and a wrong-shape current value. Script behavior when run via tsx is unchanged (ui:openapi:check still passes). Closes #7770
1 parent 2840506 commit ea7da0c

2 files changed

Lines changed: 108 additions & 33 deletions

File tree

scripts/write-ui-openapi.ts

Lines changed: 46 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,39 +4,15 @@ import { dirname, resolve } from "node:path";
44
import { fileURLToPath } from "node:url";
55
import { buildOpenApiSpec } from "../src/openapi/spec";
66

7-
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8-
const target = resolve(root, "apps/loopover-ui/public/openapi.json");
9-
const checkOnly = process.argv.includes("--check");
10-
11-
const spec = buildOpenApiSpec();
12-
spec.servers = [{ url: "https://api.loopover.ai", description: "Production" }];
13-
14-
const current = await readFile(target, "utf8").catch(() => "");
15-
const currentSpec = parseCurrentSpec(current);
16-
const orderedSpec = currentSpec ? preserveExistingObjectOrder(spec, currentSpec) : spec;
17-
const next = `${JSON.stringify(orderedSpec, null, 2)}\n`;
18-
19-
if (checkOnly) {
20-
if (current !== next) {
21-
console.error("apps/loopover-ui/public/openapi.json is stale; run npm run ui:openapi.");
22-
process.exit(1);
23-
}
24-
console.log("checked apps/loopover-ui/public/openapi.json");
25-
} else {
26-
await writeFile(target, next);
27-
console.log("wrote apps/loopover-ui/public/openapi.json");
28-
}
29-
30-
function parseCurrentSpec(currentText: string): Record<string, unknown> | null {
31-
try {
32-
return JSON.parse(currentText) as Record<string, unknown>;
33-
} catch {
34-
return null;
35-
}
36-
}
37-
38-
function preserveExistingObjectOrder<T>(next: T, current: unknown): T {
39-
if (Array.isArray(next)) return next.map((item, index) => preserveExistingObjectOrder(item, Array.isArray(current) ? current[index] : undefined)) as T;
7+
/** Recursively re-order the keys of `next` to match `current`'s existing key order (keys only in `next` are
8+
* appended in their own order), so `ui:openapi:check` produces a stable, minimal diff. Arrays are walked
9+
* positionally; non-plain-object values (primitives, `undefined`, `null`, arrays' leaves) pass through
10+
* unchanged. Exported for direct unit testing (#7770). */
11+
export function preserveExistingObjectOrder<T>(next: T, current: unknown): T {
12+
if (Array.isArray(next))
13+
return next.map((item, index) =>
14+
preserveExistingObjectOrder(item, Array.isArray(current) ? current[index] : undefined),
15+
) as T;
4016
if (!isPlainObject(next)) return next;
4117

4218
const currentObject = isPlainObject(current) ? current : {};
@@ -53,3 +29,40 @@ function preserveExistingObjectOrder<T>(next: T, current: unknown): T {
5329
function isPlainObject(value: unknown): value is Record<string, unknown> {
5430
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
5531
}
32+
33+
function parseCurrentSpec(currentText: string): Record<string, unknown> | null {
34+
try {
35+
return JSON.parse(currentText) as Record<string, unknown>;
36+
} catch {
37+
return null;
38+
}
39+
}
40+
41+
async function main(): Promise<void> {
42+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
43+
const target = resolve(root, "apps/loopover-ui/public/openapi.json");
44+
const checkOnly = process.argv.includes("--check");
45+
46+
const spec = buildOpenApiSpec();
47+
spec.servers = [{ url: "https://api.loopover.ai", description: "Production" }];
48+
49+
const current = await readFile(target, "utf8").catch(() => "");
50+
const currentSpec = parseCurrentSpec(current);
51+
const orderedSpec = currentSpec ? preserveExistingObjectOrder(spec, currentSpec) : spec;
52+
const next = `${JSON.stringify(orderedSpec, null, 2)}\n`;
53+
54+
if (checkOnly) {
55+
if (current !== next) {
56+
console.error("apps/loopover-ui/public/openapi.json is stale; run npm run ui:openapi.");
57+
process.exit(1);
58+
}
59+
console.log("checked apps/loopover-ui/public/openapi.json");
60+
} else {
61+
await writeFile(target, next);
62+
console.log("wrote apps/loopover-ui/public/openapi.json");
63+
}
64+
}
65+
66+
if (process.argv[1] && import.meta.url === new URL(process.argv[1], "file://").href) {
67+
await main();
68+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { preserveExistingObjectOrder } from "../../scripts/write-ui-openapi";
4+
5+
// #7770: direct unit tests for the OpenAPI key-ordering helper, which only had indirect coverage before.
6+
describe("preserveExistingObjectOrder (#7770)", () => {
7+
it("reorders a nested object's keys to match the current file's order", () => {
8+
const next = { b: 1, a: { y: 2, x: 3 }, c: 4 };
9+
const current = { a: { x: 0, y: 0 }, b: 0 };
10+
const result = preserveExistingObjectOrder(next, current);
11+
// Top level: current's a, b first; c (new-only) appended.
12+
expect(Object.keys(result)).toEqual(["a", "b", "c"]);
13+
// Nested object is reordered to current's x, y with next's values.
14+
expect(Object.keys(result.a)).toEqual(["x", "y"]);
15+
expect(result).toEqual({ a: { x: 3, y: 2 }, b: 1, c: 4 });
16+
});
17+
18+
it("appends keys present in next but not current, in next's own order", () => {
19+
const result = preserveExistingObjectOrder({ first: 1, second: 2, third: 3 }, { second: 0 });
20+
expect(Object.keys(result)).toEqual(["second", "first", "third"]);
21+
});
22+
23+
it("drops keys present in current but not in next (only next's keys survive)", () => {
24+
const result = preserveExistingObjectOrder({ a: 1 }, { a: 0, removed: 0 });
25+
expect(result).toEqual({ a: 1 });
26+
expect("removed" in result).toBe(false);
27+
});
28+
29+
it("walks array-valued keys positionally, aligning each item with the current array", () => {
30+
const next = {
31+
list: [
32+
{ b: 1, a: 2 },
33+
{ d: 3, c: 4 },
34+
],
35+
};
36+
// Shorter current array: index 1 has no counterpart, so it stays in next's own order.
37+
const current = { list: [{ a: 0, b: 0 }] };
38+
const result = preserveExistingObjectOrder(next, current);
39+
expect(Object.keys(result.list[0]!)).toEqual(["a", "b"]);
40+
expect(result.list[1]).toEqual({ d: 3, c: 4 });
41+
expect(result.list).toHaveLength(2);
42+
});
43+
44+
it("passes primitive, null, and undefined leaf values through unchanged", () => {
45+
expect(preserveExistingObjectOrder(5, { a: 1 })).toBe(5);
46+
expect(preserveExistingObjectOrder("s", undefined)).toBe("s");
47+
expect(preserveExistingObjectOrder(null, { a: 1 })).toBe(null);
48+
// An undefined-valued key is retained as a key (with undefined value), ordered by current.
49+
const result = preserveExistingObjectOrder({ b: undefined, a: 1 }, { a: 0, b: 0 });
50+
expect(Object.keys(result)).toEqual(["a", "b"]);
51+
expect(result.b).toBeUndefined();
52+
});
53+
54+
it("treats a current value of the wrong shape as empty, keeping next's own order", () => {
55+
// next is an object but current is an array -> currentObject falls back to {}, so next's order wins.
56+
const objVsArr = preserveExistingObjectOrder({ z: 1, a: 2 }, [1, 2, 3]);
57+
expect(Object.keys(objVsArr)).toEqual(["z", "a"]);
58+
// next is an array but current is an object -> each current[index] is undefined.
59+
const arrVsObj = preserveExistingObjectOrder([{ b: 1, a: 2 }], { not: "an-array" });
60+
expect(Object.keys(arrVsObj[0]!)).toEqual(["b", "a"]);
61+
});
62+
});

0 commit comments

Comments
 (0)