import { describe, test, expect } from "vitest"; import { extractSurface, extractPropsFromDts, diffSurface, serializeSnapshot, parseSnapshot, formatDelta, type SurfaceSnapshot, } from "./surface-snapshot"; // ── Fixtures ────────────────────────────────────────────────────────── const SIMPLE_LEXICON_JSON = JSON.stringify({ Bucket: { kind: "resource", resourceType: "AWS::S3::Bucket", attrs: { WebsiteURL: "WebsiteURL", Arn: "Arn" }, createOnly: ["BucketName"], tagging: { taggable: true, tagOnCreate: true, tagUpdatable: true }, }, Bucket_Tag: { kind: "property", resourceType: "AWS::S3::Bucket.Tag", }, Function: { kind: "resource", resourceType: "AWS::Lambda::Function", attrs: { Arn: "Arn" }, createOnly: [], }, }); const SIMPLE_DTS = ` // Code generated by chant aws generate. DO NOT EDIT generated sections. export declare class Bucket { constructor(props: { BucketName?: string; Tags?: Bucket_Tag[]; }, attributes?: CFResourceAttributes); readonly Arn: string; readonly WebsiteURL: string; } export declare class Function { constructor(props: { Code: string; Role: string; FunctionName?: string; }, attributes?: CFResourceAttributes); readonly Arn: string; } `; // ── extractPropsFromDts ─────────────────────────────────────────────── describe("extractPropsFromDts", () => { test("extracts required and optional props from class declarations", () => { const map = extractPropsFromDts(SIMPLE_DTS); expect(map.has("Bucket")).toBe(true); const bucketProps = map.get("Bucket")!; // BucketName?: string → optional → :false expect(bucketProps).toContain("BucketName:false"); // Tags?: Bucket_Tag[] → optional → :false expect(bucketProps).toContain("Tags:false"); expect(bucketProps).toHaveLength(2); }); test("extracts required props correctly", () => { const map = extractPropsFromDts(SIMPLE_DTS); const fnProps = map.get("Function")!; expect(fnProps).toContain("Code:true"); expect(fnProps).toContain("Role:true"); expect(fnProps).toContain("FunctionName:false"); expect(fnProps).toHaveLength(3); }); test("returns sorted props", () => { const map = extractPropsFromDts(SIMPLE_DTS); const props = map.get("Bucket")!; const sorted = [...props].sort(); expect(props).toEqual(sorted); }); test("skips comment lines in constructor body", () => { const dts = ` export declare class Widget { constructor(props: { /** The widget name. */ Name: string; // legacy OldProp?: string; }); } `; const map = extractPropsFromDts(dts); const props = map.get("Widget")!; expect(props).toContain("Name:true"); expect(props).toContain("OldProp:false"); expect(props).toHaveLength(2); }); test("handles empty constructor (Record form)", () => { const dts = ` export declare class Simple { constructor(props: Record); } `; const map = extractPropsFromDts(dts); // No props extracted for this form expect(map.has("Simple")).toBe(false); }); test("handles quoted property names", () => { const dts = ` export declare class Quirky { constructor(props: { "some-key": string; "other-key"?: number; }); } `; const map = extractPropsFromDts(dts); const props = map.get("Quirky")!; expect(props).toContain("some-key:true"); expect(props).toContain("other-key:false"); }); }); // ── extractSurface ──────────────────────────────────────────────────── describe("extractSurface", () => { test("extracts resource entries with attrs and props", () => { const snap = extractSurface(SIMPLE_LEXICON_JSON, SIMPLE_DTS); expect(snap.schemaVersion).toBe(1); expect(typeof snap.generatedAt).toBe("string"); const bucket = snap.entries.Bucket; expect(bucket).toBeDefined(); expect(bucket.kind).toBe("resource"); expect(bucket.resourceType).toBe("AWS::S3::Bucket"); expect(bucket.attrs).toContain("Arn"); expect(bucket.attrs).toContain("WebsiteURL"); expect(bucket.props).toContain("BucketName:false"); expect(bucket.createOnly).toEqual(["BucketName"]); expect(bucket.taggable).toBe(true); }); test("extracts property-type entries without props/attrs", () => { const snap = extractSurface(SIMPLE_LEXICON_JSON, SIMPLE_DTS); const bucketTag = snap.entries.Bucket_Tag; expect(bucketTag).toBeDefined(); expect(bucketTag.kind).toBe("property"); expect(bucketTag.props).toBeUndefined(); expect(bucketTag.attrs).toBeUndefined(); }); test("entries are sorted alphabetically", () => { const snap = extractSurface(SIMPLE_LEXICON_JSON, SIMPLE_DTS); const keys = Object.keys(snap.entries); expect(keys).toEqual([...keys].sort()); }); test("works without .d.ts (no props extracted)", () => { const snap = extractSurface(SIMPLE_LEXICON_JSON, ""); const bucket = snap.entries.Bucket; expect(bucket).toBeDefined(); expect(bucket.attrs).toContain("Arn"); // No props without .d.ts expect(bucket.props).toBeUndefined(); }); }); // ── diffSurface ─────────────────────────────────────────────────────── function makeSnapshot(entries: SurfaceSnapshot["entries"]): SurfaceSnapshot { return { schemaVersion: 1, generatedAt: new Date().toISOString(), entries }; } describe("diffSurface — additive changes", () => { test("added resource entry → additive severity", () => { const baseline = makeSnapshot({}); const fresh = makeSnapshot({ NewBucket: { kind: "resource", resourceType: "AWS::S3::Bucket" }, }); const delta = diffSurface(baseline, fresh); expect(delta.added).toHaveLength(1); expect(delta.added[0].name).toBe("NewBucket"); expect(delta.removed).toHaveLength(0); expect(delta.changed).toHaveLength(0); expect(delta.severity).toBe("additive"); }); test("added optional prop → additive severity", () => { const baseline = makeSnapshot({ Bucket: { kind: "resource", resourceType: "AWS::S3::Bucket", props: ["Name:false"] }, }); const fresh = makeSnapshot({ Bucket: { kind: "resource", resourceType: "AWS::S3::Bucket", props: ["Name:false", "Tags:false"] }, }); const delta = diffSurface(baseline, fresh); expect(delta.changed).toHaveLength(1); expect(delta.changed[0].addedProps).toContain("Tags"); expect(delta.severity).toBe("additive"); }); test("added attr → additive severity", () => { const baseline = makeSnapshot({ Bucket: { kind: "resource", resourceType: "AWS::S3::Bucket", attrs: ["Arn"] }, }); const fresh = makeSnapshot({ Bucket: { kind: "resource", resourceType: "AWS::S3::Bucket", attrs: ["Arn", "WebsiteURL"] }, }); const delta = diffSurface(baseline, fresh); expect(delta.changed[0].addedAttrs).toContain("WebsiteURL"); expect(delta.severity).toBe("additive"); }); test("required prop becoming optional → additive severity", () => { const baseline = makeSnapshot({ Fn: { kind: "resource", resourceType: "AWS::Lambda::Function", props: ["Code:true", "Role:true"] }, }); const fresh = makeSnapshot({ Fn: { kind: "resource", resourceType: "AWS::Lambda::Function", props: ["Code:false", "Role:true"] }, }); const delta = diffSurface(baseline, fresh); expect(delta.changed[0].nowOptional).toContain("Code"); expect(delta.severity).toBe("additive"); }); test("gaining taggable → additive severity", () => { const baseline = makeSnapshot({ Res: { kind: "resource", resourceType: "X::Y::Z", taggable: false }, }); const fresh = makeSnapshot({ Res: { kind: "resource", resourceType: "X::Y::Z", taggable: true }, }); const delta = diffSurface(baseline, fresh); expect(delta.changed[0].taggableChanged).toEqual({ before: false, after: true }); expect(delta.severity).toBe("additive"); }); }); describe("diffSurface — breaking changes", () => { test("removed resource entry → breaking severity", () => { const baseline = makeSnapshot({ OldBucket: { kind: "resource", resourceType: "AWS::S3::Bucket" }, }); const fresh = makeSnapshot({}); const delta = diffSurface(baseline, fresh); expect(delta.removed).toHaveLength(1); expect(delta.removed[0].name).toBe("OldBucket"); expect(delta.severity).toBe("breaking"); }); test("removed required prop → breaking severity", () => { const baseline = makeSnapshot({ Fn: { kind: "resource", resourceType: "X", props: ["Code:true", "Role:true"] }, }); const fresh = makeSnapshot({ Fn: { kind: "resource", resourceType: "X", props: ["Role:true"] }, }); const delta = diffSurface(baseline, fresh); expect(delta.changed[0].removedProps).toContain("Code"); expect(delta.severity).toBe("breaking"); }); test("removed optional prop → breaking severity", () => { const baseline = makeSnapshot({ Fn: { kind: "resource", resourceType: "X", props: ["Code:true", "Tags:false"] }, }); const fresh = makeSnapshot({ Fn: { kind: "resource", resourceType: "X", props: ["Code:true"] }, }); const delta = diffSurface(baseline, fresh); expect(delta.changed[0].removedProps).toContain("Tags"); expect(delta.severity).toBe("breaking"); }); test("optional prop becoming required → breaking severity", () => { const baseline = makeSnapshot({ Fn: { kind: "resource", resourceType: "X", props: ["Code:false"] }, }); const fresh = makeSnapshot({ Fn: { kind: "resource", resourceType: "X", props: ["Code:true"] }, }); const delta = diffSurface(baseline, fresh); expect(delta.changed[0].nowRequired).toContain("Code"); expect(delta.severity).toBe("breaking"); }); test("removed attr → breaking severity", () => { const baseline = makeSnapshot({ Res: { kind: "resource", resourceType: "X", attrs: ["Arn", "Name"] }, }); const fresh = makeSnapshot({ Res: { kind: "resource", resourceType: "X", attrs: ["Arn"] }, }); const delta = diffSurface(baseline, fresh); expect(delta.changed[0].removedAttrs).toContain("Name"); expect(delta.severity).toBe("breaking"); }); test("resourceType changed → breaking severity", () => { const baseline = makeSnapshot({ Res: { kind: "resource", resourceType: "AWS::S3::Bucket" }, }); const fresh = makeSnapshot({ Res: { kind: "resource", resourceType: "AWS::S3::BucketV2" }, }); const delta = diffSurface(baseline, fresh); expect(delta.changed[0].resourceTypeChanged?.before).toBe("AWS::S3::Bucket"); expect(delta.changed[0].resourceTypeChanged?.after).toBe("AWS::S3::BucketV2"); expect(delta.severity).toBe("breaking"); }); test("kind changed resource→property → breaking severity", () => { const baseline = makeSnapshot({ Res: { kind: "resource", resourceType: "X" }, }); const fresh = makeSnapshot({ Res: { kind: "property", resourceType: "X" }, }); const delta = diffSurface(baseline, fresh); expect(delta.changed[0].kindChanged).toBeDefined(); expect(delta.severity).toBe("breaking"); }); test("createOnly set changed → breaking severity", () => { const baseline = makeSnapshot({ Res: { kind: "resource", resourceType: "X", createOnly: ["BucketName"] }, }); const fresh = makeSnapshot({ Res: { kind: "resource", resourceType: "X", createOnly: [] }, }); const delta = diffSurface(baseline, fresh); expect(delta.changed[0].createOnlyChanged).toBeDefined(); expect(delta.severity).toBe("breaking"); }); test("losing taggable → breaking severity", () => { const baseline = makeSnapshot({ Res: { kind: "resource", resourceType: "X", taggable: true }, }); const fresh = makeSnapshot({ Res: { kind: "resource", resourceType: "X", taggable: false }, }); const delta = diffSurface(baseline, fresh); expect(delta.changed[0].taggableChanged).toEqual({ before: true, after: false }); expect(delta.severity).toBe("breaking"); }); }); describe("diffSurface — no changes", () => { test("identical snapshots → severity none", () => { const baseline = makeSnapshot({ Bucket: { kind: "resource", resourceType: "AWS::S3::Bucket", props: ["Name:false"] }, }); const delta = diffSurface(baseline, baseline); expect(delta.added).toHaveLength(0); expect(delta.changed).toHaveLength(0); expect(delta.removed).toHaveLength(0); expect(delta.severity).toBe("none"); }); test("empty baseline and empty fresh → severity none", () => { const baseline = makeSnapshot({}); const fresh = makeSnapshot({}); const delta = diffSurface(baseline, fresh); expect(delta.severity).toBe("none"); }); }); describe("diffSurface — mixed severities", () => { test("breaking change dominates additive change", () => { const baseline = makeSnapshot({ Res: { kind: "resource", resourceType: "X", props: ["Name:false"] }, }); const fresh = makeSnapshot({ Res: { kind: "resource", resourceType: "X", props: ["Name:true"] }, // now required = breaking NewRes: { kind: "resource", resourceType: "Y" }, // added = additive }); const delta = diffSurface(baseline, fresh); expect(delta.severity).toBe("breaking"); }); }); // ── Serialization round-trip ────────────────────────────────────────── describe("serializeSnapshot / parseSnapshot", () => { test("round-trips a snapshot", () => { const snap = extractSurface(SIMPLE_LEXICON_JSON, SIMPLE_DTS); const json = serializeSnapshot(snap); const back = parseSnapshot(json); expect(back.schemaVersion).toBe(1); expect(Object.keys(back.entries)).toEqual(Object.keys(snap.entries)); expect(back.entries.Bucket.kind).toBe("resource"); expect(back.entries.Bucket.attrs).toEqual(snap.entries.Bucket.attrs); }); test("serialized JSON ends with newline", () => { const snap = makeSnapshot({}); const json = serializeSnapshot(snap); expect(json.endsWith("\n")).toBe(true); }); }); // ── formatDelta ─────────────────────────────────────────────────────── describe("diffSurface — renames (#1460)", () => { test("a removal and an addition with the same resourceType pair as one rename", () => { const baseline = makeSnapshot({ MacieSession: { kind: "resource", resourceType: "AWS::Macie::Session", props: ["Status:false"] }, }); const fresh = makeSnapshot({ Session: { kind: "resource", resourceType: "AWS::Macie::Session", props: ["Status:false"] }, }); const delta = diffSurface(baseline, fresh); expect(delta.added).toEqual([]); expect(delta.removed).toEqual([]); expect(delta.renamed).toEqual([ { from: "MacieSession", to: "Session", entry: fresh.entries.Session }, ]); // The old import still breaks, so a rename stays breaking for 1.x. expect(delta.severity).toBe("breaking"); }); test("property types rename the same way, and a same-kind match is required", () => { const baseline = makeSnapshot({ BCMDataExportsExport_DataQuery: { kind: "property", resourceType: "AWS::BCMDataExports::Export.DataQuery" }, Thing: { kind: "resource", resourceType: "X::Thing" }, }); const fresh = makeSnapshot({ Export_DataQuery: { kind: "property", resourceType: "AWS::BCMDataExports::Export.DataQuery" }, Thing_Prop: { kind: "property", resourceType: "X::Thing" }, }); const delta = diffSurface(baseline, fresh); expect(delta.renamed.map((r) => `${r.from} -> ${r.to}`)).toEqual([ "BCMDataExportsExport_DataQuery -> Export_DataQuery", ]); // A resource and a property under the same type string are not a rename. expect(delta.removed.map((r) => r.name)).toEqual(["Thing"]); expect(delta.added.map((a) => a.name)).toEqual(["Thing_Prop"]); expect(delta.severity).toBe("breaking"); }); test("a rename that also changed shape carries the structural diff", () => { const baseline = makeSnapshot({ PanoramaPackage: { kind: "resource", resourceType: "AWS::Panorama::Package", props: ["PackageName:true", "Tags:false"] }, }); const fresh = makeSnapshot({ Package: { kind: "resource", resourceType: "AWS::Panorama::Package", props: ["PackageName:true"], attrs: ["Arn"] }, }); const delta = diffSurface(baseline, fresh); expect(delta.renamed).toHaveLength(1); expect(delta.renamed[0].changes).toEqual({ removedProps: ["Tags"], addedAttrs: ["Arn"] }); expect(delta.changed).toEqual([]); }); test("several old and new names under one resourceType pair by similarity; the rest stay add/remove", () => { const rt = "AWS::SageMaker::Space"; const baseline = makeSnapshot({ Space_OwnershipSettings: { kind: "property", resourceType: rt }, Space_SpaceSettings: { kind: "property", resourceType: rt }, Space_Gone: { kind: "property", resourceType: rt }, }); const fresh = makeSnapshot({ SageMakerSpace_SpaceSettings: { kind: "property", resourceType: rt }, SageMakerSpace_OwnershipSettings: { kind: "property", resourceType: rt }, }); const delta = diffSurface(baseline, fresh); expect(delta.renamed.map((r) => `${r.from} -> ${r.to}`)).toEqual([ "Space_OwnershipSettings -> SageMakerSpace_OwnershipSettings", "Space_SpaceSettings -> SageMakerSpace_SpaceSettings", ]); expect(delta.removed.map((r) => r.name)).toEqual(["Space_Gone"]); expect(delta.added).toEqual([]); }); test("entries with an empty resourceType never pair", () => { const baseline = makeSnapshot({ A: { kind: "property", resourceType: "" } }); const fresh = makeSnapshot({ B: { kind: "property", resourceType: "" } }); const delta = diffSurface(baseline, fresh); expect(delta.renamed).toEqual([]); expect(delta.removed).toHaveLength(1); expect(delta.added).toHaveLength(1); }); test("the #1423 shape: 28 renames among 148 removals and 83 additions", () => { // 3 resources renamed, each dragging 25/3-ish property types with them — // 28 renames in total, 120 plain removals, 55 plain additions. const baseEntries: SurfaceSnapshot["entries"] = {}; const freshEntries: SurfaceSnapshot["entries"] = {}; const renamedResources: Array<[string, string, string]> = [ ["MacieSession", "Session", "AWS::Macie::Session"], ["PanoramaPackage", "Package", "AWS::Panorama::Package"], ["Space", "SageMakerSpace", "AWS::SageMaker::Space"], ]; let renames = 0; for (const [oldName, newName, rt] of renamedResources) { baseEntries[oldName] = { kind: "resource", resourceType: rt }; freshEntries[newName] = { kind: "resource", resourceType: rt }; renames++; } // 25 property renames spread across the three parents for (let i = 0; i < 25; i++) { const [oldName, newName, rt] = renamedResources[i % 3]; baseEntries[`${oldName}_Prop${i}`] = { kind: "property", resourceType: `${rt}.Prop${i}` }; freshEntries[`${newName}_Prop${i}`] = { kind: "property", resourceType: `${rt}.Prop${i}` }; renames++; } expect(renames).toBe(28); for (let i = 0; i < 120; i++) { baseEntries[`Gone${i}`] = { kind: "resource", resourceType: `AWS::Gone::Type${i}` }; } for (let i = 0; i < 55; i++) { freshEntries[`Fresh${i}`] = { kind: "resource", resourceType: `AWS::Fresh::Type${i}` }; } const delta = diffSurface(makeSnapshot(baseEntries), makeSnapshot(freshEntries)); expect(delta.renamed).toHaveLength(28); expect(delta.removed).toHaveLength(120); expect(delta.added).toHaveLength(55); expect(delta.severity).toBe("breaking"); const text = formatDelta(delta); expect(text).toContain("Renamed (28):"); expect(text).toContain("Removed (120):"); expect(text).toContain("Added (55):"); expect(text).toContain(" ~ MacieSession -> Session [resource] (AWS::Macie::Session)"); expect(text).toContain("Severity: breaking"); }); }); describe("formatDelta", () => { test("includes severity in output", () => { const delta = diffSurface( makeSnapshot({}), makeSnapshot({ NewRes: { kind: "resource", resourceType: "X" } }), ); const text = formatDelta(delta); expect(text).toContain("Severity: additive"); }); test("shows 'No surface changes.' when nothing changed", () => { const snap = makeSnapshot({}); const delta = diffSurface(snap, snap); expect(formatDelta(delta)).toContain("No surface changes."); }); test("shows added/removed/changed counts", () => { const baseline = makeSnapshot({ Old: { kind: "resource", resourceType: "X" }, Both: { kind: "resource", resourceType: "Y", attrs: ["Arn"] }, }); const fresh = makeSnapshot({ New: { kind: "resource", resourceType: "Z" }, Both: { kind: "resource", resourceType: "Y", attrs: ["Arn", "Extra"] }, }); const text = formatDelta(diffSurface(baseline, fresh)); expect(text).toContain("Added (1):"); expect(text).toContain("Removed (1):"); expect(text).toContain("Changed (1):"); expect(text).not.toContain("Renamed"); }); test("renames print as Old -> New with the shared resourceType", () => { const baseline = makeSnapshot({ Old: { kind: "resource", resourceType: "X" } }); const fresh = makeSnapshot({ New: { kind: "resource", resourceType: "X" } }); const text = formatDelta(diffSurface(baseline, fresh)); expect(text).toContain("Renamed (1):"); expect(text).toContain(" ~ Old -> New [resource] (X)"); expect(text).not.toContain("Added ("); expect(text).not.toContain("Removed ("); expect(text).toContain("Severity: breaking"); }); });