|
1 | 1 | import { resolvePath } from "@typespec/compiler"; |
2 | 2 | import { createTester, mockFile } from "@typespec/compiler/testing"; |
3 | | -import { describe, expect, it } from "vitest"; |
| 3 | +import { beforeEach, describe, expect, it } from "vitest"; |
4 | 4 | import { createSurfaceChecksManifest } from "../src/coverage/surface-checks-manifest.js"; |
5 | | -import { listSurfaceDocs } from "../src/lib/decorators.js"; |
| 5 | +import { listSurfaceDocs, type SurfaceDoc } from "../src/lib/decorators.js"; |
6 | 6 |
|
7 | 7 | // A minimal stand-in for the client-generator decorators, declared under the |
8 | 8 | // same namespaces so derivation matches them by name+namespace without spector |
9 | | -// having to depend on the real client-generator package. |
| 9 | +// having to depend on the (separate-repo) client-generator package. Core |
| 10 | +// decorators like `@list` are real and need no stub. |
10 | 11 | const clientGeneratorTsp = ` |
11 | 12 | import "./client-generator.js"; |
12 | 13 | using TypeSpec.Reflection; |
@@ -52,81 +53,133 @@ const Tester = createTester(resolvePath(import.meta.dirname, ".."), { |
52 | 53 | .import("./client-generator.tsp") |
53 | 54 | .using("Spector"); |
54 | 55 |
|
55 | | -const spec = ` |
56 | | - @Azure.ClientGenerator.Core.clientName("ClientExtensibleEnum") |
57 | | - @surfaceDoc("Exposed to clients as ClientExtensibleEnum, not its service name.") |
58 | | - enum ServerExtensibleEnum { |
59 | | - value1, |
60 | | - value2, |
61 | | - } |
62 | | -
|
63 | | - @Azure.ClientGenerator.Core.access(Azure.ClientGenerator.Core.Access.internal) |
64 | | - @Azure.ClientGenerator.Core.clientName("WidgetInternal") |
65 | | - @surfaceDoc("Hidden from the public surface and renamed to WidgetInternal for clients.") |
66 | | - model Widget { |
67 | | - id: string; |
68 | | - } |
69 | | -
|
70 | | - model Animal {} |
| 56 | +async function compileSurfaceDocs(code: string): Promise<SurfaceDoc[]> { |
| 57 | + const { program } = await Tester.compile(code); |
| 58 | + return listSurfaceDocs(program); |
| 59 | +} |
| 60 | + |
| 61 | +describe("listSurfaceDocs derivation", () => { |
| 62 | + it("keeps the authored prose on the surface doc", async () => { |
| 63 | + const [doc] = await compileSurfaceDocs(` |
| 64 | + @surfaceDoc("Surfaces a lazy paged iterator on the client, not a raw response.") |
| 65 | + @list |
| 66 | + op listItems(): { @pageItems items: string[]; @nextLink next?: url }; |
| 67 | + `); |
| 68 | + expect(doc.doc).toBe("Surfaces a lazy paged iterator on the client, not a raw response."); |
| 69 | + }); |
71 | 70 |
|
72 | | - @Azure.ClientGenerator.Core.Legacy.hierarchyBuilding(Pet) |
73 | | - @surfaceDoc("Dog is surfaced as a subtype of Pet, not Animal.") |
74 | | - model Dog extends Animal { |
75 | | - kind: string; |
76 | | - } |
| 71 | + it("derives a naming check from @clientName", async () => { |
| 72 | + const [doc] = await compileSurfaceDocs(` |
| 73 | + @Azure.ClientGenerator.Core.clientName("ClientExtensibleEnum") |
| 74 | + @surfaceDoc("Exposed to clients as ClientExtensibleEnum, not its service name.") |
| 75 | + enum ServerExtensibleEnum { |
| 76 | + value1, |
| 77 | + value2, |
| 78 | + } |
| 79 | + `); |
| 80 | + expect(doc.checks).toEqual([ |
| 81 | + { category: "naming", expected: "ClientExtensibleEnum", kind: "enum" }, |
| 82 | + ]); |
| 83 | + }); |
77 | 84 |
|
78 | | - model Pet {} |
| 85 | + it("derives an access check from @access", async () => { |
| 86 | + const [doc] = await compileSurfaceDocs(` |
| 87 | + @Azure.ClientGenerator.Core.access(Azure.ClientGenerator.Core.Access.internal) |
| 88 | + @surfaceDoc("Hidden from the public client surface.") |
| 89 | + model Widget { |
| 90 | + id: string; |
| 91 | + } |
| 92 | + `); |
| 93 | + expect(doc.checks).toEqual([{ category: "access", internal: true }]); |
| 94 | + }); |
79 | 95 |
|
80 | | - @surfaceDoc("This operation surfaces a lazy paged iterator, not a raw response.") |
81 | | - op listItems(): string[]; |
82 | | -`; |
| 96 | + it("derives a hierarchy check from @hierarchyBuilding", async () => { |
| 97 | + const [doc] = await compileSurfaceDocs(` |
| 98 | + model Animal {} |
| 99 | + model Pet {} |
| 100 | +
|
| 101 | + @Azure.ClientGenerator.Core.Legacy.hierarchyBuilding(Pet) |
| 102 | + @surfaceDoc("Dog is surfaced as a subtype of Pet, not Animal.") |
| 103 | + model Dog extends Animal { |
| 104 | + kind: string; |
| 105 | + } |
| 106 | + `); |
| 107 | + expect(doc.checks).toEqual([{ category: "hierarchy", expectedBase: "Pet" }]); |
| 108 | + }); |
83 | 109 |
|
84 | | -describe("@surfaceDoc / listSurfaceDocs (natural language + derivation)", () => { |
85 | | - it("keeps the prose and derives checks from client decorators", async () => { |
86 | | - const { program } = await Tester.compile(spec); |
87 | | - const docs = listSurfaceDocs(program); |
| 110 | + it("derives a paging check from the core @list decorator", async () => { |
| 111 | + const [doc] = await compileSurfaceDocs(` |
| 112 | + @surfaceDoc("Surfaces a lazy paged iterator on the client.") |
| 113 | + @list |
| 114 | + op listItems(): { @pageItems items: string[]; @nextLink next?: url }; |
| 115 | + `); |
| 116 | + expect(doc.checks).toEqual([{ category: "paging" }]); |
| 117 | + }); |
88 | 118 |
|
89 | | - expect(docs.map((d) => d.name).sort()).toEqual([ |
90 | | - "Dog", |
91 | | - "ServerExtensibleEnum", |
92 | | - "Widget", |
93 | | - "listItems", |
94 | | - ]); |
| 119 | + it("derives one check per client decorator when several are applied", async () => { |
| 120 | + const [doc] = await compileSurfaceDocs(` |
| 121 | + @Azure.ClientGenerator.Core.access(Azure.ClientGenerator.Core.Access.internal) |
| 122 | + @Azure.ClientGenerator.Core.clientName("WidgetInternal") |
| 123 | + @surfaceDoc("Hidden from the public surface and renamed to WidgetInternal for clients.") |
| 124 | + model Widget { |
| 125 | + id: string; |
| 126 | + } |
| 127 | + `); |
| 128 | + expect(doc.checks.map((c) => c.category).sort()).toEqual(["access", "naming"]); |
| 129 | + }); |
95 | 130 |
|
96 | | - const widget = docs.find((d) => d.name === "Widget")!; |
97 | | - expect(widget.doc).toContain("Hidden from the public surface"); |
98 | | - // Two client decorators -> two derived checks. |
99 | | - expect(widget.checks.map((c) => c.category).sort()).toEqual(["access", "naming"]); |
| 131 | + it("derives no checks for prose with no recognized decorator (AI fallback)", async () => { |
| 132 | + const [doc] = await compileSurfaceDocs(` |
| 133 | + @surfaceDoc("The response body is surfaced as a strongly typed model.") |
| 134 | + op getItem(): { id: string }; |
| 135 | + `); |
| 136 | + expect(doc.checks).toHaveLength(0); |
| 137 | + }); |
| 138 | +}); |
100 | 139 |
|
101 | | - // Prose with no recognized client decorator -> no derived checks (AI fallback). |
102 | | - const listItems = docs.find((d) => d.name === "listItems")!; |
103 | | - expect(listItems.checks).toHaveLength(0); |
104 | | - expect(listItems.doc).toContain("lazy paged iterator"); |
| 140 | +describe("surface-checks.json manifest", () => { |
| 141 | + let byId: Record<string, ReturnType<typeof createSurfaceChecksManifest>["items"][number]>; |
| 142 | + |
| 143 | + beforeEach(async () => { |
| 144 | + const docs = await compileSurfaceDocs(` |
| 145 | + @Azure.ClientGenerator.Core.clientName("ClientExtensibleEnum") |
| 146 | + @surfaceDoc("Exposed to clients as ClientExtensibleEnum, not its service name.") |
| 147 | + enum ServerExtensibleEnum { |
| 148 | + value1, |
| 149 | + value2, |
| 150 | + } |
| 151 | +
|
| 152 | + model Pet {} |
| 153 | +
|
| 154 | + @Azure.ClientGenerator.Core.Legacy.hierarchyBuilding(Pet) |
| 155 | + @surfaceDoc("Dog is surfaced as a subtype of Pet.") |
| 156 | + model Dog { |
| 157 | + kind: string; |
| 158 | + } |
| 159 | +
|
| 160 | + @surfaceDoc("Surfaces a lazy paged iterator on the client, not a raw response.") |
| 161 | + @list |
| 162 | + op listItems(): { @pageItems items: string[]; @nextLink next?: url }; |
| 163 | + `); |
| 164 | + const manifest = createSurfaceChecksManifest(".", "1.0.0", "abc123", docs); |
| 165 | + byId = Object.fromEntries(manifest.items.map((i) => [i.id, i])); |
105 | 166 | }); |
106 | 167 |
|
107 | | - it("formats a verify.py-compatible manifest, prose carried on every item", async () => { |
108 | | - const { program } = await Tester.compile(spec); |
109 | | - const manifest = createSurfaceChecksManifest(".", "1.0.0", "abc123", listSurfaceDocs(program)); |
| 168 | + it("carries the authored prose on every item", () => { |
| 169 | + expect(byId["ServerExtensibleEnum_naming"].doc).toContain("Exposed to clients"); |
| 170 | + expect(byId["listItems_paging"].doc).toContain("lazy paged iterator"); |
| 171 | + }); |
110 | 172 |
|
111 | | - const byId = Object.fromEntries(manifest.items.map((i) => [i.id, i])); |
| 173 | + it("emits camelCase check fields as snake_case for verify.py", () => { |
| 174 | + expect(byId["Dog_hierarchy"].expected_base).toBe("Pet"); |
| 175 | + }); |
112 | 176 |
|
| 177 | + it("routes each derived check to its category", () => { |
| 178 | + expect(byId["listItems_paging"]).toMatchObject({ category: "paging", target: "listItems" }); |
113 | 179 | expect(byId["ServerExtensibleEnum_naming"]).toMatchObject({ |
114 | 180 | category: "naming", |
115 | | - target: "ServerExtensibleEnum", |
116 | 181 | expected: "ClientExtensibleEnum", |
117 | 182 | kind: "enum", |
118 | 183 | }); |
119 | | - expect(byId["ServerExtensibleEnum_naming"].doc).toContain("Exposed to clients"); |
120 | | - |
121 | | - expect(byId["Widget_access"]).toMatchObject({ category: "access", internal: true }); |
122 | | - expect(byId["Widget_naming"]).toMatchObject({ expected: "WidgetInternal", kind: "model" }); |
123 | | - |
124 | | - // camelCase check fields are emitted snake_case for verify.py. |
125 | | - expect(byId["Dog_hierarchy"].expected_base).toBe("Pet"); |
126 | | - |
127 | | - // Prose-only element becomes a single AI-routed item. |
128 | | - expect(byId["listItems_unspecified"]).toMatchObject({ category: "unspecified" }); |
129 | | - expect(byId["listItems_unspecified"].doc).toContain("lazy paged iterator"); |
130 | | - expect(byId["listItems_unspecified"].expected).toBeUndefined(); |
131 | 184 | }); |
132 | 185 | }); |
0 commit comments